fix: support concurrent game windows and prepare beta12 GitHub releases

This commit is contained in:
Disy committed 2026-09-25 11:49:41 +08:00
1 parent 91f483b960
commit 1cf67602d8
33 files changed
+681 -293

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
[package]
name = "theseus"
# The actual version is set by the theseus-build workflow on tagging
version = "1.9.6-beta.3"
version = "1.0.0-beta12"
edition.workspace = true
[dependencies]
+132 -4
View File
@@ -426,9 +426,7 @@ pub async fn default_publication() -> crate::Result<Publication> {
})
}
pub async fn create(
game_dir_root: Option<String>,
) -> crate::Result<String> {
pub async fn create(game_dir_root: Option<String>) -> crate::Result<String> {
let publication = default_publication().await?;
let runtime = &publication.manifest.runtime;
let state = State::get().await?;
@@ -812,11 +810,85 @@ pub async fn prepare_launch(
"StarLight 实例必须登录并联网检查更新后才能启动;离线游玩请使用本地实例",
));
}
synchronize_locked(instance_id).await?;
if crate::process::get_by_instance_id(instance_id)
.await?
.is_empty()
{
synchronize_locked(instance_id).await?;
} else {
check_running_pack(instance_id).await?;
}
}
Ok(guard)
}
async fn check_running_pack(instance_id: &str) -> crate::Result<()> {
let auth = authorization().await?;
let root = crate::instance::get_full_path(instance_id).await?;
let previous: Binding = read_json(&root, BINDING)
.await?
.ok_or_else(|| invalid("请先关闭该实例的所有游戏窗口,再同步整合包"))?;
let remote = request_authorized::<Option<SyncState>>("/sync-state", &auth)
.await?
.ok_or_else(|| {
invalid("管理员尚未指定已发布的默认整合包,请联系服务器管理员")
})?;
if !marker_matches(&previous, &remote) {
return Err(invalid(
"整合包有更新,请先关闭该实例的所有游戏窗口,更新完成后再多开",
));
}
let tagged = request_authorized::<tagged::TaggedManifest>(
&format!("/tagged-mods/{}", previous.publication.release_id),
&auth,
)
.await?;
tagged.validate()?;
if !running_pack_matches(&root, &previous, &tagged).await? {
return Err(invalid(
"整合包 Mod 有变更,请先关闭该实例的所有游戏窗口,更新完成后再多开",
));
}
ensure_session(&auth).await
}
async fn running_pack_matches(
root: &Path,
previous: &Binding,
tagged: &tagged::TaggedManifest,
) -> crate::Result<bool> {
let mut desired = previous.publication.manifest.files.clone();
desired.extend(previous.resolved_external.iter().cloned());
let duplicates = tagged::merge(
root,
&target(root, ".starlight-pack-cache")?,
&mut desired,
&mut BTreeMap::new(),
tagged,
)
.await?;
validate(&desired)?;
let inventory = |files: &[PackFile]| {
files
.iter()
.map(|file| (file.path.clone(), file.sha256.clone()))
.collect::<BTreeMap<_, _>>()
};
if !duplicates.is_empty()
|| inventory(&desired) != inventory(&previous.files)
{
return Ok(false);
}
for file in desired.iter().filter(|file| tagged.includes_content(file)) {
if hash(&target(root, &file.path)?).await?.as_deref()
!= Some(&file.sha256)
{
return Ok(false);
}
}
Ok(true)
}
async fn synchronize_locked(instance_id: &str) -> crate::Result<SyncResult> {
ensure_idle(instance_id).await?;
recover(instance_id).await?;
@@ -1417,6 +1489,62 @@ async fn synchronize_with_progress(
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn running_pack_checks_tag_changes_without_writing_live_files() {
let root = tempfile::tempdir().unwrap();
tokio::fs::create_dir(root.path().join("mods"))
.await
.unwrap();
let path = root.path().join("mods/example.starlight.jar");
tokio::fs::write(&path, b"installed").await.unwrap();
let sha = hash(&path).await.unwrap().unwrap();
let file = serde_json::json!({"path":"mods/example.starlight.jar", "sha256":sha, "size":9,
"modIds":["example"], "force":true});
let previous: Binding = serde_json::from_value(serde_json::json!({
"publication":{"packId":"pack", "releaseId":1, "manifest":{
"schemaVersion":1, "name":"Pack", "version":"1", "format":"multimc",
"runtime":{"gameVersion":"1.21.1", "loader":"vanilla"}, "files":[]
}}, "files":[file], "syncMarker":"pack:1"
})).unwrap();
let manifest = |sha: &str| {
serde_json::from_value::<tagged::TaggedManifest>(serde_json::json!({
"files":[{"modId":"example", "modIds":["example"], "path":"mods/example.starlight.jar", "sha256":sha, "size":9}],
"replaces":[]
})).unwrap()
};
assert!(
running_pack_matches(root.path(), &previous, &manifest(&sha))
.await
.unwrap()
);
assert!(
!running_pack_matches(
root.path(),
&previous,
&manifest(&"a".repeat(64))
)
.await
.unwrap()
);
let empty = serde_json::from_value(
serde_json::json!({"files":[], "replaces":[]}),
)
.unwrap();
assert!(
!running_pack_matches(root.path(), &previous, &empty)
.await
.unwrap()
);
assert_eq!(tokio::fs::read(&path).await.unwrap(), b"installed");
assert!(!root.path().join(JOURNAL).exists());
tokio::fs::write(&path, b"modified").await.unwrap();
assert!(
!running_pack_matches(root.path(), &previous, &manifest(&sha))
.await
.unwrap()
);
}
#[test]
fn sync_marker_requires_an_installed_publication_and_changes_on_default_or_release()
{
@@ -23,6 +23,10 @@ pub(super) struct TaggedManifest {
}
impl TaggedManifest {
pub fn includes_content(&self, file: &PackFile) -> bool {
self.files.iter().any(|tagged| tagged.sha256 == file.sha256)
}
pub fn contains(&self, path: &str) -> bool {
self.files.iter().any(|file| file.path == path)
}
@@ -51,8 +51,7 @@ pub async fn import_generic(
let info = detect_instance_info(&layout.json_source, overrides).await?;
register_instance(instance_id, &layout.name, &info).await?;
copy_instance_files(instance_id, &layout, reporter, details, symlink)
.await
copy_instance_files(instance_id, &layout, reporter, details, symlink).await
}
/// The resolved source layout for a generic import.
@@ -589,8 +589,8 @@ fn try_extract_version_from_needle(
) -> Option<String> {
let pos = content.find(needle)?;
let after = &content[pos + needle.len()..];
let end = after
.find(&['"', ',', '\n', '}', ']', '[', ':', ' '] as &[char])?;
let end =
after.find(&['"', ',', '\n', '}', ']', '[', ':', ' '] as &[char])?;
let ver = &after[..end];
if let Some(ch) = split_at
&& let Some(pos) = ver.rfind(ch)
@@ -609,7 +609,11 @@ fn sanitize_loader_version(raw: &str) -> String {
// Cut at the first character that cannot appear in a loader version id.
let end = trimmed
.find(|ch: char| {
!(ch.is_ascii_alphanumeric() || ch == '.' || ch == '-' || ch == '_' || ch == '+')
!(ch.is_ascii_alphanumeric()
|| ch == '.'
|| ch == '-'
|| ch == '_'
|| ch == '+')
})
.unwrap_or(trimmed.len());
trimmed[..end].trim().to_string()
+8 -3
View File
@@ -1234,10 +1234,13 @@ fn is_other_version_entry(rel: &Path, keep: &Path) -> bool {
let mut keep_components = keep.components();
// Both must start with the literal `versions` component.
if rel_components.next().map(|c| c.as_os_str()) != Some("versions".as_ref()) {
if rel_components.next().map(|c| c.as_os_str()) != Some("versions".as_ref())
{
return false;
}
if keep_components.next().map(|c| c.as_os_str()) != Some("versions".as_ref()) {
if keep_components.next().map(|c| c.as_os_str())
!= Some("versions".as_ref())
{
return false;
}
@@ -1253,7 +1256,9 @@ fn is_other_version_entry(rel: &Path, keep: &Path) -> bool {
let rel_is_inside_version_dir = rel_components.next().is_some();
match (rel_version, keep_version) {
(Some(rel_v), Some(keep_v)) if rel_is_inside_version_dir => rel_v != keep_v,
(Some(rel_v), Some(keep_v)) if rel_is_inside_version_dir => {
rel_v != keep_v
}
// A file directly under `versions/`, shared metadata: keep it.
_ => false,
}
@@ -338,22 +338,6 @@ impl DirectLinkedLaunch {
}
}
pub(crate) fn natives_cache_key(&self) -> String {
let identity = format!(
"{}\0{}\0{}",
self.dot_minecraft.display(),
self.version_id,
self.version_json
.as_deref()
.unwrap_or_else(|| self.dot_minecraft.as_path())
.display()
);
format!(
"linked-{}",
sha1_smol::Sha1::from(identity.as_bytes()).hexdigest()
)
}
pub(crate) fn library_path(
&self,
library: &LinkedLibrary,
+18 -42
View File
@@ -2045,17 +2045,6 @@ 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
// Done late so a quick double call doesn't launch two instances
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());
}
// Ensure phase (HMCL/PCL parity): before anything reads from the linked
// installation, complete its missing libraries, assets, and logging
// config in the standard shared locations. Version JSONs, launcher
@@ -2076,26 +2065,15 @@ pub async fn launch_minecraft(
.await?;
}
let natives_dir = if let Some(direct) = &direct_launch {
state
.directories
.version_natives_dir(&direct.natives_cache_key())
} else {
state.directories.version_natives_dir(&version_jar)
};
// Linked native archives can change outside Axolotl, so rebuild only the
// Axolotl-owned linked cache on every launch. Never mutate linked folders.
if direct_launch.is_some() && natives_dir.exists() {
io::remove_dir_all(&natives_dir).await?;
}
if !natives_dir.exists() {
io::create_dir_all(&natives_dir).await?;
}
let (main_class_keep_alive, main_class_path) =
get_resource_file!(env "JAVA_JARS_DIR" / "theseus.jar")?;
let natives_dir = main_class_keep_alive.path().join("natives");
io::create_dir_all(&natives_dir).await?;
if let (Some(direct), Some(libraries)) =
(direct_launch.clone(), linked_libraries.clone())
{
// Linked instances rebuild the Axolotl-owned natives cache on every
// launch; the managed restore path below must never touch them.
// Each process owns its native DLLs until it exits. Rebuilding a
// shared directory fails on Windows while another JVM holds a DLL.
let target = natives_dir.clone();
let java_arch = java_version.architecture.clone();
tokio::task::spawn_blocking(move || {
@@ -2109,18 +2087,7 @@ pub async fn launch_minecraft(
})
.await??;
} else if direct_launch.is_none() {
if offline_mode {
natives::prepare_native_libraries(
&state.directories.natives_dir(),
&state.directories.libraries_dir(),
&state.directories.caches_dir(),
version_info.libraries.as_slice(),
&version_jar,
&java_version.architecture,
minecraft_updated,
)
.await?;
} else {
if !offline_mode {
download::download_libraries(
&state,
None,
@@ -2135,6 +2102,16 @@ pub async fn launch_minecraft(
)
.await?;
}
natives::prepare_native_libraries(
main_class_keep_alive.path(),
&state.directories.libraries_dir(),
&state.directories.caches_dir(),
version_info.libraries.as_slice(),
"natives",
&java_version.architecture,
minecraft_updated,
)
.await?;
}
tracing::debug!(
@@ -2172,8 +2149,6 @@ pub async fn launch_minecraft(
address.resolve().await?;
}
let (main_class_keep_alive, main_class_path) =
get_resource_file!(env "JAVA_JARS_DIR" / "theseus.jar")?;
let yggdrasil_agent = if credentials.is_yggdrasil() {
let account = credentials.yggdrasil.as_ref().ok_or_else(|| {
crate::ErrorKind::LauncherError(
@@ -2323,6 +2298,7 @@ pub async fn launch_minecraft(
let options_existed = options_path.exists();
if direct_launch.is_none()
&& process::get_by_instance_id(&instance.id).await?.is_empty()
&& (!mc_set_options.is_empty()
|| offline_skin_pack.enabled_pack_id.is_some()
|| options_existed
+41
View File
@@ -486,6 +486,47 @@ mod tests {
}
}
#[tokio::test]
async fn concurrent_launch_native_directories_do_not_replace_each_other() {
let archives = tempfile::tempdir().unwrap();
let first = tempfile::tempdir().unwrap();
let second = tempfile::tempdir().unwrap();
let source = archives.path().join("natives.jar");
write_archive(&source, &[("lwjgl.dll", b"first runtime")]);
materialize_native_directory(
&[archive(source.clone())],
first.path(),
"natives",
)
.await
.unwrap();
let first_dll = first.path().join("natives/lwjgl.dll");
#[cfg(windows)]
let _running_dll = {
use std::os::windows::fs::OpenOptionsExt;
std::fs::OpenOptions::new()
.read(true)
.share_mode(1)
.open(&first_dll)
.unwrap()
};
write_archive(&source, &[("lwjgl.dll", b"second runtime")]);
materialize_native_directory(
&[archive(source)],
second.path(),
"natives",
)
.await
.unwrap();
assert_eq!(std::fs::read(&first_dll).unwrap(), b"first runtime");
assert_eq!(
std::fs::read(second.path().join("natives/lwjgl.dll")).unwrap(),
b"second runtime"
);
drop(second);
assert!(first_dll.exists());
}
#[tokio::test]
async fn repairs_missing_truncated_same_size_and_directory_entries() {
let root = tempfile::tempdir().unwrap();
+86 -15
View File
@@ -310,13 +310,20 @@ impl ProcessManager {
let log_path = logs_folder.join(LAUNCHER_LOG_PATH);
clear_log_buffer(instance_id);
let already_running = self
.get_all()
.iter()
.any(|process| process.instance_id == instance_id);
if !already_running {
clear_log_buffer(instance_id);
}
{
let mut log_file = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.truncate(!already_running)
.append(already_running)
.open(&log_path)
.map_err(|e| IOError::with_path(e, &log_path))?;
@@ -490,8 +497,8 @@ impl ProcessManager {
}
pub async fn wait_for(&self, id: Uuid) -> crate::Result<()> {
if let Some(mut process) = self.processes.get_mut(&id) {
process.child.wait().await?;
while matches!(self.try_wait(id)?, Some(None)) {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
Ok(())
}
@@ -499,7 +506,7 @@ impl ProcessManager {
pub async fn kill(&self, id: Uuid) -> crate::Result<()> {
if let Some(mut process) = self.processes.get_mut(&id) {
process.manually_killed = true;
if let Err(error) = process.child.kill().await {
if let Err(error) = process.child.start_kill() {
process.manually_killed = false;
return Err(error.into());
}
@@ -631,7 +638,12 @@ impl Process {
let mut chunk = [0u8; 8192];
loop {
let read = match tokio::io::AsyncReadExt::read(&mut buf_reader, &mut chunk).await {
let read = match tokio::io::AsyncReadExt::read(
&mut buf_reader,
&mut chunk,
)
.await
{
Ok(0) => break,
Ok(n) => n,
Err(e) => {
@@ -772,12 +784,12 @@ impl Process {
}
b"log4j:Throwable" => {
in_throwable = false;
current_event.throwable =
if current_content.is_empty() {
None
} else {
Some(current_content.clone())
};
current_event.throwable = if current_content.is_empty()
{
None
} else {
Some(current_content.clone())
};
}
b"log4j:Event" => {
if let Some(formatted) =
@@ -792,8 +804,7 @@ impl Process {
}
if let Some(ref throwable) = current_event.throwable
&& let Err(e) = Self::append_to_log_file(
log_path,
throwable,
log_path, throwable,
)
{
tracing::error!(
@@ -1176,7 +1187,15 @@ impl Process {
}
}
}
crate::api::multiplayer::minecraft_process_finished(&instance_id).await;
if !state
.process_manager
.get_all()
.iter()
.any(|process| process.instance_id == instance_id)
{
crate::api::multiplayer::minecraft_process_finished(&instance_id)
.await;
}
// Now fully complete- update playtime one last time
update_playtime(&mut last_updated_playtime, &instance_id, true).await;
@@ -1293,6 +1312,58 @@ fn take_next_log4j_frame(buffer: &mut String) -> Option<String> {
mod post_upgrade_tests {
use super::*;
#[cfg(windows)]
#[tokio::test]
async fn waiting_for_one_process_leaves_registry_available() {
let manager = ProcessManager::new();
let mut child = Command::new("powershell.exe");
child
.args([
"-NoProfile",
"-NonInteractive",
"-Command",
"Start-Sleep -Seconds 30",
])
.creation_flags(0x08000000)
.kill_on_drop(true);
let child = child.spawn().unwrap();
let uuid = Uuid::new_v4();
let rpc = crate::util::rpc::RpcServerBuilder::new()
.launch()
.await
.unwrap();
manager.processes.insert(
uuid,
Process {
metadata: ProcessMetadata {
uuid,
pid: child.id().unwrap(),
maximize_window: false,
instance_id: "first".into(),
instance_path: "first".into(),
instance_name: "First".into(),
start_time: Utc::now(),
},
child,
manually_killed: false,
output_tasks: Vec::new(),
_main_class_keep_alive: tempfile::tempdir().unwrap(),
rpc_server: rpc,
},
);
let waiting = manager.wait_for(uuid);
tokio::pin!(waiting);
assert!(futures::poll!(&mut waiting).is_pending());
assert!(!manager.processes.try_get(&uuid).is_locked());
assert_eq!(manager.get_all().len(), 1);
manager.kill(uuid).await.unwrap();
tokio::time::timeout(std::time::Duration::from_secs(5), waiting)
.await
.unwrap()
.unwrap();
assert!(manager.was_manually_killed(uuid));
}
#[test]
fn live_log_lines_are_truncated_on_character_boundaries() {
let line = format!("prefix{}suffix", "你".repeat(40_000));