diff --git a/patches/0012-Launch-a-running-instance-again-on-another-account.patch b/patches/0012-Launch-a-running-instance-again-on-another-account.patch new file mode 100644 index 0000000..ef0ab41 --- /dev/null +++ b/patches/0012-Launch-a-running-instance-again-on-another-account.patch @@ -0,0 +1,999 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Modrinth Enhanced +Date: Tue, 15 Sep 2026 09:26:27 +0200 +Subject: [PATCH] Launch a running instance again on another account + +An instance that is running can be started once more from the button +beside Stop on its page. The new copy runs as whichever account is +selected now, so switching accounts first is how one instance is played +on two accounts at once. A plain launch still refuses a running instance, +so a double-clicked play button stays one game. + +The live log was kept per instance, so two copies would have written +into one buffer. It is kept per process now, log events carry the +process they came from, and while more than one copy runs the Logs tab +shows a console for each, named after its account. The instance's log +file is shared: another copy appends to it under a header naming its +account instead of wiping what the first is still writing. + +The app event bindings are regenerated for the new log event field. + +Adapted from Noctrinth (https://github.com/Everelsu/Noctrinth). +--- + .../src/components/ui/ProcessConsole.vue | 92 +++++++++++++ + .../src/composables/useInstanceConsole.ts | 33 ++++- + .../src/generated/app-events/LogPayload.ts | 6 +- + .../generated/app-events/postcard/index.d.ts | 2 +- + .../generated/app-events/postcard/index.js | 1 + + apps/app-frontend/src/helpers/instance.ts | 4 +- + apps/app-frontend/src/helpers/logs.js | 10 ++ + .../instance/components/page-header/index.vue | 18 +++ + .../src/pages/instance/layout.vue | 25 ++++ + .../src/pages/instance/logs/index.vue | 30 ++++- + .../src/providers/setup/app-event-codec.ts | 2 +- + apps/app/build.rs | 2 + + apps/app/src/api/instance.rs | 9 +- + apps/app/src/api/logs.rs | 19 +++ + packages/app-lib/src/api/instance.rs | 3 +- + packages/app-lib/src/api/instance/run.rs | 24 +++- + packages/app-lib/src/api/logs.rs | 23 +++- + packages/app-lib/src/event/mod.rs | 2 + + packages/app-lib/src/launcher/mod.rs | 41 +++--- + packages/app-lib/src/state/process.rs | 123 ++++++++++++++---- + 20 files changed, 418 insertions(+), 51 deletions(-) + create mode 100644 apps/app-frontend/src/components/ui/ProcessConsole.vue + +diff --git a/apps/app-frontend/src/components/ui/ProcessConsole.vue b/apps/app-frontend/src/components/ui/ProcessConsole.vue +new file mode 100644 +index 0000000..b5102eb +--- /dev/null ++++ b/apps/app-frontend/src/components/ui/ProcessConsole.vue +@@ -0,0 +1,92 @@ ++ ++ ++ ++ +diff --git a/apps/app-frontend/src/composables/useInstanceConsole.ts b/apps/app-frontend/src/composables/useInstanceConsole.ts +index e7e30d4..c2b3277 100644 +--- a/apps/app-frontend/src/composables/useInstanceConsole.ts ++++ b/apps/app-frontend/src/composables/useInstanceConsole.ts +@@ -1,6 +1,12 @@ + import { createConsoleState } from '@modrinth/ui' + +-import { clear_log_buffer, get_live_log_buffer, get_logs } from '@/helpers/logs' ++import { ++ clear_log_buffer, ++ clear_log_buffer_for_process, ++ get_live_log_buffer, ++ get_live_log_buffer_for_process, ++ get_logs, ++} from '@/helpers/logs' + + type ConsoleState = ReturnType + +@@ -84,6 +90,31 @@ async function destroy(instanceId: string): Promise { + await clear_log_buffer(instanceId).catch(() => {}) + } + ++/** Live consoles of single processes, for an instance running more than once. */ ++const processConsoles = new Map() ++ ++export function useProcessConsole(processUuid: string) { ++ let state = processConsoles.get(processUuid) ++ if (!state) { ++ state = createConsoleState() ++ processConsoles.set(processUuid, state) ++ } ++ const console = state ++ ++ return { ++ console, ++ hydrate: async () => { ++ if (console.output.value.length > 0) return ++ const buffer = await get_live_log_buffer_for_process(processUuid) ++ if (buffer) console.addLegacyLog(buffer) ++ }, ++ clear: async () => { ++ console.clear() ++ await clear_log_buffer_for_process(processUuid).catch(() => {}) ++ }, ++ } ++} ++ + export function useInstanceConsole(instanceId: string) { + const entry = getOrCreate(instanceId) + return { +diff --git a/apps/app-frontend/src/generated/app-events/LogPayload.ts b/apps/app-frontend/src/generated/app-events/LogPayload.ts +index 977bf82..c390c4c 100644 +--- a/apps/app-frontend/src/generated/app-events/LogPayload.ts ++++ b/apps/app-frontend/src/generated/app-events/LogPayload.ts +@@ -1,4 +1,8 @@ + // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + import type { Log4jEvent } from "./Log4jEvent"; + +-export type LogPayload = { instance_id: string, } & ({ "type": "log4j" } & Log4jEvent | { "type": "legacy", message: string, }); ++export type LogPayload = { instance_id: string, ++/** ++ * Which process said this: an instance can run more than once. ++ */ ++process_uuid: string, } & ({ "type": "log4j" } & Log4jEvent | { "type": "legacy", message: string, }); +diff --git a/apps/app-frontend/src/generated/app-events/postcard/index.d.ts b/apps/app-frontend/src/generated/app-events/postcard/index.d.ts +index 66144c0..42f013a 100644 +--- a/apps/app-frontend/src/generated/app-events/postcard/index.d.ts ++++ b/apps/app-frontend/src/generated/app-events/postcard/index.d.ts +@@ -50,7 +50,7 @@ export type FriendPayload = { tag: "friend_request", value: { from: string } } | + export type FriendStatusPayload = { user_id: string, profile_name: string | undefined, last_update: string } + export type SharedInstanceUnavailableReason = { tag: "deleted" } | { tag: "access_revoked" } | { tag: "quarantined" } + export type LogEvent = { tag: "log4j", value: Log4jEvent } | { tag: "legacy", value: { message: string } } +-export type LogPayload = { instance_id: string, event: LogEvent } ++export type LogPayload = { instance_id: string, process_uuid: string, event: LogEvent } + export type OnboardingChecklist = { has_created_instance: boolean, has_logged_into_minecraft: boolean, has_logged_into_modrinth: boolean, show_checklist: boolean } + export type Log4jEvent = { timestamp_millis: i64 | undefined, logger_name: string | undefined, level: string | undefined, thread_name: string | undefined, message: string | undefined, throwable: string | undefined } + export type ModLoader = { tag: "vanilla" } | { tag: "forge" } | { tag: "fabric" } | { tag: "quilt" } | { tag: "neoforge" } +diff --git a/apps/app-frontend/src/generated/app-events/postcard/index.js b/apps/app-frontend/src/generated/app-events/postcard/index.js +index c8816ff..cd56865 100644 +--- a/apps/app-frontend/src/generated/app-events/postcard/index.js ++++ b/apps/app-frontend/src/generated/app-events/postcard/index.js +@@ -475,6 +475,7 @@ function deserialize_LOG_EVENT(d) { + function deserialize_LOG_PAYLOAD(d) { + return { + instance_id: d.deserialize_string(), ++ process_uuid: d.deserialize_string(), + event: deserialize_LOG_EVENT(d) + }; + } +diff --git a/apps/app-frontend/src/helpers/instance.ts b/apps/app-frontend/src/helpers/instance.ts +index a0c0b56..817a63c 100644 +--- a/apps/app-frontend/src/helpers/instance.ts ++++ b/apps/app-frontend/src/helpers/instance.ts +@@ -601,11 +601,13 @@ export async function get_pack_export_candidates( + + // Run Minecraft using an instance + // Returns PID of child ++// `additional` starts an instance that is already running once more. + export async function run( + instanceId: string, + serverAddress: string | null = null, ++ additional = false, + ): Promise { +- return await invoke('plugin:instance|instance_run', { instanceId, serverAddress }) ++ return await invoke('plugin:instance|instance_run', { instanceId, serverAddress, additional }) + } + + export async function kill(instanceId: string): Promise { +diff --git a/apps/app-frontend/src/helpers/logs.js b/apps/app-frontend/src/helpers/logs.js +index 843e572..44d07d5 100644 +--- a/apps/app-frontend/src/helpers/logs.js ++++ b/apps/app-frontend/src/helpers/logs.js +@@ -72,3 +72,13 @@ export async function get_live_log_buffer(instanceId) { + export async function clear_log_buffer(instanceId) { + return await invoke('plugin:logs|logs_clear_live_log_buffer', { instanceId }) + } ++ ++/// Get the buffered live log lines of one process ++export async function get_live_log_buffer_for_process(processUuid) { ++ return await invoke('plugin:logs|logs_get_live_log_buffer_for_process', { processUuid }) ++} ++ ++/// Clear the live log buffer of one process ++export async function clear_log_buffer_for_process(processUuid) { ++ return await invoke('plugin:logs|logs_clear_live_log_buffer_for_process', { processUuid }) ++} +diff --git a/apps/app-frontend/src/pages/instance/components/page-header/index.vue b/apps/app-frontend/src/pages/instance/components/page-header/index.vue +index ce4b436..31a6ded 100644 +--- a/apps/app-frontend/src/pages/instance/components/page-header/index.vue ++++ b/apps/app-frontend/src/pages/instance/components/page-header/index.vue +@@ -144,6 +144,19 @@ + formatMessage(messages.starting) + }} + ++ ++ ++ ++ ++ + { + }) + } + ++/** ++ * Starts an instance that is already running once more, as the account selected ++ * now, which is how somebody plays one instance on two accounts. ++ */ ++const launchAnotherCopy = async (context: string) => { ++ const currentInstance = instance.value ++ if (!currentInstance || currentInstance.quarantined || loading.value || !playing.value) return ++ ++ loading.value = true ++ try { ++ await run(currentInstance.id, null, true) ++ await processesQuery.refetch() ++ } catch (err) { ++ handleSevereError(err, { instanceId: currentInstance.id }) ++ } ++ loading.value = false ++ ++ trackEvent('InstanceStart', { ++ loader: currentInstance.loader, ++ game_version: currentInstance.game_version, ++ source: context, ++ }) ++} ++ + async function handleSharedInstanceUnavailable( + reason: SharedInstanceUnavailableReason | null = null, + ) { +diff --git a/apps/app-frontend/src/pages/instance/logs/index.vue b/apps/app-frontend/src/pages/instance/logs/index.vue +index 2aacfe0..e2a59fd 100644 +--- a/apps/app-frontend/src/pages/instance/logs/index.vue ++++ b/apps/app-frontend/src/pages/instance/logs/index.vue +@@ -1,6 +1,10 @@ + + +@@ -14,12 +18,13 @@ import { + import { useQuery } from '@tanstack/vue-query' + import { computed, ref, shallowRef, triggerRef, watch, watchEffect } from 'vue' + ++import ProcessConsole from '@/components/ui/ProcessConsole.vue' + import { useAppEvent } from '@/composables/use-app-event' + import { useInstanceConsole } from '@/composables/useInstanceConsole' + import { delete_logs_by_filename, get_output_by_filename } from '@/helpers/logs.js' + + import { injectInstancePage } from '../instance-context' +-import { instanceKeys } from '../query-options' ++import { instanceKeys, instanceProcessesQueryOptions } from '../query-options' + + const client = injectModrinthClient() + const { handleError } = injectNotificationManager() +@@ -83,6 +88,20 @@ watch(historicalLogsQuery.error, (error) => { + if (error) handleError(error) + }) + ++// The running copies decide between one console and one per copy. Oldest ++// first, so the consoles keep their places. ++const processesQuery = useQuery( ++ computed(() => ({ ++ ...instanceProcessesQueryOptions(instanceId.value), ++ enabled: !!instanceId.value, ++ })), ++) ++const runningCopies = computed(() => ++ [...(processesQuery.data.value ?? [])].sort((a, b) => ++ String(a.start_time ?? '').localeCompare(String(b.start_time ?? '')), ++ ), ++) ++ + const selectedLogIndex = ref(0) + const isLive = computed(() => selectedLogIndex.value === 0) + +@@ -194,6 +213,11 @@ if (!instancePage.playing.value) { + useAppEvent('log', (payload) => { + if (payload.instance_id !== instanceId.value) return + ++ // This console follows the newest copy, which is what its buffer holds, so it ++ // makes sense again once the other copies have stopped. ++ const newest = runningCopies.value[runningCopies.value.length - 1] ++ if (newest && payload.process_uuid !== newest.uuid) return ++ + if (payload.type === 'log4j') { + liveConsole.addLog4jEvent(payload) + } else if (payload.type === 'legacy') { +@@ -203,6 +227,8 @@ useAppEvent('log', (payload) => { + + useAppEvent('process', async (e) => { + if (e.instance_id !== instanceId.value) return ++ // A copy starting or stopping adds or takes away a console. ++ void processesQuery.refetch() + if (e.event === 'launched') { + liveConsole.clear() + invalidate() +diff --git a/apps/app-frontend/src/providers/setup/app-event-codec.ts b/apps/app-frontend/src/providers/setup/app-event-codec.ts +index 638386d..3cccc8a 100644 +--- a/apps/app-frontend/src/providers/setup/app-event-codec.ts ++++ b/apps/app-frontend/src/providers/setup/app-event-codec.ts +@@ -122,7 +122,7 @@ function normalizeLogPayload(value: unknown): WireObject { + event.timestamp_millis = number(event.timestamp_millis) + } + +- return { instance_id: payload.instance_id, ...event } ++ return { instance_id: payload.instance_id, process_uuid: payload.process_uuid, ...event } + } + + function normalizeInstallProgress(value: unknown): WireObject { +diff --git a/apps/app/build.rs b/apps/app/build.rs +index cc3611c..382ce22 100644 +--- a/apps/app/build.rs ++++ b/apps/app/build.rs +@@ -110,7 +110,9 @@ fn main() { + "logs_delete_logs_by_filename", + "logs_get_latest_log_cursor", + "logs_get_live_log_buffer", ++ "logs_get_live_log_buffer_for_process", + "logs_clear_live_log_buffer", ++ "logs_clear_live_log_buffer_for_process", + ]) + .default_permission( + DefaultPermissionRule::AllowAllCommands, +diff --git a/apps/app/src/api/instance.rs b/apps/app/src/api/instance.rs +index 2861594..2a512dd 100644 +--- a/apps/app/src/api/instance.rs ++++ b/apps/app/src/api/instance.rs +@@ -1324,12 +1324,19 @@ pub async fn instance_get_pack_export_candidates( + pub async fn instance_run( + instance_id: &str, + server_address: Option, ++ additional: Option, + ) -> Result { + let quick_play = match server_address { + Some(addr) => QuickPlayType::Server(ServerAddress::Unresolved(addr)), + None => QuickPlayType::None, + }; +- Ok(theseus::instance::run(instance_id, quick_play).await?) ++ // Another copy of a running instance has to be asked for, so that a ++ // double-clicked play button is still one game. ++ Ok(if additional.unwrap_or(false) { ++ theseus::instance::run_additional(instance_id, quick_play).await? ++ } else { ++ theseus::instance::run(instance_id, quick_play).await? ++ }) + } + + #[tauri::command] +diff --git a/apps/app/src/api/logs.rs b/apps/app/src/api/logs.rs +index e938efa..5150b00 100644 +--- a/apps/app/src/api/logs.rs ++++ b/apps/app/src/api/logs.rs +@@ -22,7 +22,9 @@ pub fn init() -> tauri::plugin::TauriPlugin { + logs_delete_logs_by_filename, + logs_get_latest_log_cursor, + logs_get_live_log_buffer, ++ logs_get_live_log_buffer_for_process, + logs_clear_live_log_buffer, ++ logs_clear_live_log_buffer_for_process, + ]) + .build() + } +@@ -91,9 +93,26 @@ pub async fn logs_get_live_log_buffer( + Ok(logs::get_live_log_buffer(instance_id).await?) + } + ++/// Get the live log buffer of one process. ++#[tauri::command] ++pub async fn logs_get_live_log_buffer_for_process( ++ process_uuid: uuid::Uuid, ++) -> Result { ++ Ok(logs::get_live_log_buffer_for_process(process_uuid).await?) ++} ++ + /// Clear the live log buffer for an instance. + #[tauri::command] + pub async fn logs_clear_live_log_buffer(instance_id: &str) -> Result<()> { + logs::clear_live_log_buffer(instance_id); + Ok(()) + } ++ ++/// Clear the live log buffer of one process. ++#[tauri::command] ++pub async fn logs_clear_live_log_buffer_for_process( ++ process_uuid: uuid::Uuid, ++) -> Result<()> { ++ logs::clear_live_log_buffer_for_process(process_uuid); ++ Ok(()) ++} +diff --git a/packages/app-lib/src/api/instance.rs b/packages/app-lib/src/api/instance.rs +index 0efd145..c5cfed2 100644 +--- a/packages/app-lib/src/api/instance.rs ++++ b/packages/app-lib/src/api/instance.rs +@@ -53,7 +53,8 @@ pub use self::projects::{ + update_managed_modrinth_version, update_project, + }; + pub use self::run::{ +- QuickPlayType, kill, run, try_update_playtime_by_instance_id, ++ QuickPlayType, kill, run, run_additional, ++ try_update_playtime_by_instance_id, + }; + pub use self::screenshot_groups::{ + ScreenshotGroup, ScreenshotGroupImport, ScreenshotGroupMembershipUpdate, +diff --git a/packages/app-lib/src/api/instance/run.rs b/packages/app-lib/src/api/instance/run.rs +index d638128..0394da0 100644 +--- a/packages/app-lib/src/api/instance/run.rs ++++ b/packages/app-lib/src/api/instance/run.rs +@@ -20,6 +20,25 @@ pub enum QuickPlayType { + pub async fn run( + instance_id: &str, + quick_play_type: QuickPlayType, ++) -> crate::Result { ++ run_inner(instance_id, quick_play_type, false).await ++} ++ ++/// Runs an instance that is already running once more, as whichever account is ++/// selected now. A normal run refuses that, so a double-clicked play button ++/// stays one game. ++#[tracing::instrument] ++pub async fn run_additional( ++ instance_id: &str, ++ quick_play_type: QuickPlayType, ++) -> crate::Result { ++ run_inner(instance_id, quick_play_type, true).await ++} ++ ++async fn run_inner( ++ instance_id: &str, ++ quick_play_type: QuickPlayType, ++ additional: bool, + ) -> crate::Result { + let state = State::get().await?; + if crate::state::instances::adapters::sqlite::instance_rows::is_instance_quarantined( +@@ -43,7 +62,8 @@ pub async fn run( + .await? + .ok_or_else(|| crate::ErrorKind::NoCredentialsError.as_error())?; + +- run_credentials(instance_id, &default_account, quick_play_type).await ++ run_credentials(instance_id, &default_account, quick_play_type, additional) ++ .await + } + + #[tracing::instrument(skip(credentials))] +@@ -51,6 +71,7 @@ async fn run_credentials( + instance_id: &str, + credentials: &Credentials, + quick_play_type: QuickPlayType, ++ additional: bool, + ) -> crate::Result { + let state = State::get().await?; + let settings = Settings::get(&state.pool).await?; +@@ -246,6 +267,7 @@ async fn run_credentials( + post_exit_hook, + &context, + quick_play_type, ++ additional, + ) + .await + } +diff --git a/packages/app-lib/src/api/logs.rs b/packages/app-lib/src/api/logs.rs +index 4ce0c57..3382b5b 100644 +--- a/packages/app-lib/src/api/logs.rs ++++ b/packages/app-lib/src/api/logs.rs +@@ -469,12 +469,27 @@ pub async fn delete_logs_by_filename( + Ok(()) + } + ++/// The live output of an instance's newest process. + #[tracing::instrument] + pub async fn get_live_log_buffer( + instance_id: &str, ++) -> crate::Result { ++ censor_live_lines(crate::state::get_instance_log_buffer(instance_id)).await ++} ++ ++/// The live output of one process, for showing each copy of an instance on ++/// its own. ++#[tracing::instrument] ++pub async fn get_live_log_buffer_for_process( ++ process_uuid: uuid::Uuid, ++) -> crate::Result { ++ censor_live_lines(crate::state::get_log_buffer(process_uuid)).await ++} ++ ++async fn censor_live_lines( ++ lines: Vec, + ) -> crate::Result { + let state = State::get().await?; +- let lines = crate::state::get_log_buffer(instance_id); + let joined = lines.join("\n"); + let compacted = compact_duplicate_lines(&joined); + +@@ -488,7 +503,11 @@ pub async fn get_live_log_buffer( + } + + pub fn clear_live_log_buffer(instance_id: &str) { +- crate::state::remove_log_buffer(instance_id); ++ crate::state::remove_instance_log_buffers(instance_id); ++} ++ ++pub fn clear_live_log_buffer_for_process(process_uuid: uuid::Uuid) { ++ crate::state::remove_log_buffer(process_uuid); + } + + #[tracing::instrument] +diff --git a/packages/app-lib/src/event/mod.rs b/packages/app-lib/src/event/mod.rs +index 059bfbf..a83f732 100644 +--- a/packages/app-lib/src/event/mod.rs ++++ b/packages/app-lib/src/event/mod.rs +@@ -558,6 +558,8 @@ mod log_types { + #[serde_binhum::serde_binhum] + pub struct LogPayload { + pub instance_id: String, ++ /// Which process said this: an instance can run more than once. ++ pub process_uuid: String, + #[serde(flatten)] + #[cfg_attr(feature = "export-ts", ts(flatten))] + pub event: LogEvent, +diff --git a/packages/app-lib/src/launcher/mod.rs b/packages/app-lib/src/launcher/mod.rs +index b6055bb..55e1664 100644 +--- a/packages/app-lib/src/launcher/mod.rs ++++ b/packages/app-lib/src/launcher/mod.rs +@@ -823,6 +823,9 @@ pub async fn launch_minecraft( + post_exit_hook: Option, + context: &InstanceLaunchContext, + mut quick_play_type: QuickPlayType, ++ // Whether this is another copy of an instance that is already running, ++ // asked for on purpose. ++ additional: bool, + ) -> crate::Result { + let instance = &context.instance; + let content_set = &context.applied_content_set; +@@ -951,21 +954,27 @@ pub async fn launch_minecraft( + + let env_args = Vec::from(env_args); + +- // Check if instance has a running process, and reject running the command if it does +- let existing_processes = process::get_by_instance_id(&instance.id).await?; +- if let Some(process) = existing_processes.first() { +- return Err(crate::ErrorKind::LauncherError(format!( +- "Instance {} is already running as process {}", +- instance.id, process.uuid +- )) +- .as_error()); +- } +- if crate::state::instance_has_running_process(&instance.id, &state).await? { +- return Err(crate::ErrorKind::LauncherError(format!( +- "Instance {} is already running", +- instance.id +- )) +- .as_error()); ++ // Check if instance has a running process, and reject running the command if it does, ++ // unless another copy is exactly what was asked for. ++ if !additional { ++ let existing_processes = ++ process::get_by_instance_id(&instance.id).await?; ++ if let Some(process) = existing_processes.first() { ++ return Err(crate::ErrorKind::LauncherError(format!( ++ "Instance {} is already running as process {}", ++ instance.id, process.uuid ++ )) ++ .as_error()); ++ } ++ if crate::state::instance_has_running_process(&instance.id, &state) ++ .await? ++ { ++ return Err(crate::ErrorKind::LauncherError(format!( ++ "Instance {} is already running", ++ instance.id ++ )) ++ .as_error()); ++ } + } + + let natives_dir = state.directories.version_natives_dir(&version_jar); +@@ -1203,6 +1212,8 @@ pub async fn launch_minecraft( + &instance.id, + &instance.path, + &instance.name, ++ &credentials.offline_profile.name, ++ additional, + command, + post_exit_hook, + env_args, +diff --git a/packages/app-lib/src/state/process.rs b/packages/app-lib/src/state/process.rs +index fb09842..e9aaa3a 100644 +--- a/packages/app-lib/src/state/process.rs ++++ b/packages/app-lib/src/state/process.rs +@@ -47,37 +47,74 @@ impl LogRingBuffer { + fn get_all(&self) -> Vec { + self.lines.iter().cloned().collect() + } +- +- fn clear(&mut self) { +- self.lines.clear(); +- } + } + +-static LOG_BUFFERS: LazyLock> = ++/// Live output, kept per process: an instance can run more than once, and two ++/// games in one buffer is a log nobody can read. ++static LOG_BUFFERS: LazyLock> = ++ LazyLock::new(DashMap::new); ++ ++/// The processes each instance has had since it was last started fresh, oldest ++/// first. Whatever only knows the instance reads the newest one. ++static INSTANCE_PROCESSES: LazyLock>> = + LazyLock::new(DashMap::new); + +-pub fn push_log_line(instance_id: &str, line: String) { ++pub fn push_log_line(process_uuid: Uuid, line: String) { + LOG_BUFFERS +- .entry(instance_id.to_string()) ++ .entry(process_uuid) + .or_insert_with(LogRingBuffer::new) + .push(line); + } + +-pub fn get_log_buffer(instance_id: &str) -> Vec { ++pub fn get_log_buffer(process_uuid: Uuid) -> Vec { + LOG_BUFFERS +- .get(instance_id) ++ .get(&process_uuid) + .map(|buf| buf.get_all()) + .unwrap_or_default() + } + +-pub fn clear_log_buffer(instance_id: &str) { +- if let Some(mut buf) = LOG_BUFFERS.get_mut(instance_id) { +- buf.clear(); ++/// The live output of an instance's newest process. ++pub fn get_instance_log_buffer(instance_id: &str) -> Vec { ++ INSTANCE_PROCESSES ++ .get(instance_id) ++ .and_then(|uuids| uuids.last().map(|uuid| get_log_buffer(*uuid))) ++ .unwrap_or_default() ++} ++ ++pub fn remove_log_buffer(process_uuid: Uuid) { ++ LOG_BUFFERS.remove(&process_uuid); ++} ++ ++/// Forgets what an instance's processes said, but not which processes there ++/// are, so a game still running has somewhere to be read from. ++pub fn remove_instance_log_buffers(instance_id: &str) { ++ if let Some(uuids) = INSTANCE_PROCESSES.get(instance_id) { ++ for uuid in uuids.iter() { ++ remove_log_buffer(*uuid); ++ } + } + } + +-pub fn remove_log_buffer(instance_id: &str) { +- LOG_BUFFERS.remove(instance_id); ++/// Takes note of a process that has just started. ++/// ++/// A fresh start drops what the instance's earlier processes said, as starting ++/// the game always has. A second copy joins them: the first is still running. ++fn note_new_process(instance_id: &str, process_uuid: Uuid, additional: bool) { ++ if additional { ++ INSTANCE_PROCESSES ++ .entry(instance_id.to_string()) ++ .or_default() ++ .push(process_uuid); ++ return; ++ } ++ ++ if let Some(previous) = INSTANCE_PROCESSES ++ .insert(instance_id.to_string(), vec![process_uuid]) ++ { ++ for uuid in previous { ++ remove_log_buffer(uuid); ++ } ++ } + } + + async fn clear_persisted_process( +@@ -171,6 +208,10 @@ impl ProcessManager { + instance_id: &str, + instance_path: &str, + instance_name: &str, ++ account_name: &str, ++ // Whether the instance is already running and this is another copy of ++ // it, whose log joins the one already being written. ++ additional: bool, + mut mc_command: Command, + post_exit_command: Option, + post_exit_env_vars: Vec<(String, String)>, +@@ -200,13 +241,17 @@ impl ProcessManager { + + let log_path = logs_folder.join(LAUNCHER_LOG_PATH); + +- clear_log_buffer(instance_id); ++ let process_uuid = Uuid::new_v4(); ++ note_new_process(instance_id, process_uuid, additional); + + { ++ // One log file per instance: another copy appends to it rather than ++ // wiping what the running one is still writing. + let mut log_file = OpenOptions::new() + .write(true) + .create(true) +- .truncate(true) ++ .append(additional) ++ .truncate(!additional) + .open(&log_path) + .map_err(|e| IOError::with_path(e, &log_path))?; + +@@ -219,6 +264,10 @@ impl ProcessManager { + .map_err(|e| IOError::with_path(e, &log_path))?; + writeln!(log_file, "# Instance: {instance_path} \n") + .map_err(|e| IOError::with_path(e, &log_path))?; ++ if additional { ++ writeln!(log_file, "# Another copy, as {account_name}") ++ .map_err(|e| IOError::with_path(e, &log_path))?; ++ } + writeln!(log_file).map_err(|e| IOError::with_path(e, &log_path))?; + } + +@@ -230,11 +279,12 @@ impl ProcessManager { + + let mut process = Process { + metadata: ProcessMetadata { +- uuid: Uuid::new_v4(), ++ uuid: process_uuid, + start_time: Utc::now(), + instance_id: instance_id.to_string(), + instance_path: instance_path.to_string(), + instance_name: instance_name.to_string(), ++ account_name: account_name.to_string(), + }, + child: mc_proc, + rpc_server, +@@ -297,10 +347,12 @@ impl ProcessManager { + + let instance_id = metadata.instance_id.clone(); + let instance_path = metadata.instance_path.clone(); ++ let process_uuid = metadata.uuid; + tokio::spawn(async move { + Process::process_output( + &instance_id, + &instance_path, ++ process_uuid, + stdout, + log_path_clone, + xml_logging, +@@ -314,10 +366,12 @@ impl ProcessManager { + + let instance_id = metadata.instance_id.clone(); + let instance_path = metadata.instance_path.clone(); ++ let process_uuid = metadata.uuid; + tokio::spawn(async move { + Process::process_output( + &instance_id, + &instance_path, ++ process_uuid, + stderr, + log_path_clone, + xml_logging, +@@ -400,6 +454,10 @@ pub struct ProcessMetadata { + pub instance_id: String, + pub instance_path: String, + pub instance_name: String, ++ /// The account the game was started as, which is what tells copies of one ++ /// instance apart. ++ #[serde(default)] ++ pub account_name: String, + pub start_time: DateTime, + } + +@@ -429,6 +487,7 @@ impl Process { + async fn process_output( + instance_id: &str, + _instance_path: &str, ++ process_uuid: Uuid, + reader: R, + log_path: impl AsRef, + xml_logging: bool, +@@ -555,6 +614,7 @@ impl Process { + + Self::emit_log4j_event( + instance_id, ++ process_uuid, + ¤t_event, + ); + } +@@ -599,6 +659,7 @@ impl Process { + + Self::emit_log4j_event( + instance_id, ++ process_uuid, + ¤t_event, + ); + } +@@ -625,7 +686,11 @@ impl Process { + e + ); + } +- Self::emit_legacy_log(instance_id, &text); ++ Self::emit_legacy_log( ++ instance_id, ++ process_uuid, ++ &text, ++ ); + } + } + Ok(Event::CData(e)) => { +@@ -652,7 +717,11 @@ impl Process { + if let Err(e) = Self::append_to_log_file(&log_path, &line) { + tracing::warn!("Failed to write to log file: {}", e); + } +- Self::emit_legacy_log(instance_id, line.trim_ascii_end()); ++ Self::emit_legacy_log( ++ instance_id, ++ process_uuid, ++ line.trim_ascii_end(), ++ ); + if let Err(e) = Self::maybe_handle_old_server_join_logging( + instance_id, + line.trim_ascii_end(), +@@ -711,13 +780,17 @@ impl Process { + )) + } + +- fn emit_log4j_event(instance_id: &str, event: &Log4jEvent) { ++ fn emit_log4j_event( ++ instance_id: &str, ++ process_uuid: Uuid, ++ event: &Log4jEvent, ++ ) { + if let Some(formatted) = Self::format_log4j_entry(event) { +- push_log_line(instance_id, formatted.trim_end().to_string()); ++ push_log_line(process_uuid, formatted.trim_end().to_string()); + } + if let Some(ref throwable) = event.throwable { + for line in throwable.lines().filter(|l| !l.is_empty()) { +- push_log_line(instance_id, line.to_string()); ++ push_log_line(process_uuid, line.to_string()); + } + } + +@@ -726,6 +799,7 @@ impl Process { + let event_state = crate::EventState::get(); + let _ = event_state.send(crate::event::AppEvent::Log(LogPayload { + instance_id: instance_id.to_string(), ++ process_uuid: process_uuid.to_string(), + event: LogEvent::Log4j(event.clone()), + })); + } +@@ -735,14 +809,15 @@ impl Process { + } + } + +- fn emit_legacy_log(instance_id: &str, message: &str) { +- push_log_line(instance_id, message.to_string()); ++ fn emit_legacy_log(instance_id: &str, process_uuid: Uuid, message: &str) { ++ push_log_line(process_uuid, message.to_string()); + + #[cfg(feature = "tauri")] + { + let event_state = crate::EventState::get(); + let _ = event_state.send(crate::event::AppEvent::Log(LogPayload { + instance_id: instance_id.to_string(), ++ process_uuid: process_uuid.to_string(), + event: LogEvent::Legacy { + message: message.to_string(), + },