Merge branch 'main' of https://github.com/ApTx2012/Starlight_Lancher
# Conflicts: # .github/workflows/build-on-tag.yml # .github/workflows/sync-cnb-source.yml
This commit is contained in:
commit
6ce39f3a42
43 files changed
+1477
-716
No files matched your search
@@ -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]
|
||||
|
||||
File mode changed.
@@ -810,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?;
|
||||
@@ -1418,6 +1492,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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -38,4 +38,5 @@ export function canToggleContentItem(item: ContentItem): boolean {
|
||||
export interface ContentFilterOption {
|
||||
id: string
|
||||
label: string
|
||||
tooltip?: string
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { registerHooks } from 'node:module'
|
||||
import test, { type TestContext } from 'node:test'
|
||||
|
||||
import { effectScope, nextTick, ref } from 'vue'
|
||||
|
||||
import type { ContentItem } from '../types.ts'
|
||||
|
||||
// Keep the real Vue state, search, and filter pipeline; replace only the UI's
|
||||
// translation provider, which normally requires an application context.
|
||||
const hooks = registerHooks({
|
||||
resolve(specifier, context, nextResolve) {
|
||||
if (specifier === '#ui/composables/i18n' || specifier === '../composables/i18n') {
|
||||
return {
|
||||
url: `data:text/javascript,${encodeURIComponent('export const defineMessage=x=>x, defineMessages=x=>x, useVIntl=()=>({formatMessage:x=>x.defaultMessage})')}`,
|
||||
shortCircuit: true,
|
||||
}
|
||||
}
|
||||
if (specifier.startsWith('#ui/')) {
|
||||
return nextResolve(
|
||||
new URL(`../../../../${specifier.slice(4)}.ts`, import.meta.url).href,
|
||||
context,
|
||||
)
|
||||
}
|
||||
if (specifier.startsWith('./') && !specifier.endsWith('.ts')) {
|
||||
return nextResolve(`${specifier}.ts`, context)
|
||||
}
|
||||
return nextResolve(specifier, context)
|
||||
},
|
||||
})
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window')
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: {} })
|
||||
const { useContentPipeline } = await import('./content-pipeline.ts')
|
||||
hooks.deregister()
|
||||
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow)
|
||||
else Reflect.deleteProperty(globalThis, 'window')
|
||||
|
||||
function item(id: string, overrides: Partial<ContentItem> = {}): ContentItem {
|
||||
return {
|
||||
id,
|
||||
project: { id, title: id, slug: id, icon_url: null },
|
||||
file_name: `${id}.jar`,
|
||||
project_type: 'mod',
|
||||
update: null,
|
||||
origin_provider: null,
|
||||
provider_refs: [],
|
||||
enabled: true,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function fixture(t: TestContext) {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] })
|
||||
const update = {
|
||||
provider: 'modrinth' as const,
|
||||
project_id: 'project',
|
||||
current_version_id: 'v1',
|
||||
target_version_id: 'v2',
|
||||
}
|
||||
const items = ref([
|
||||
item('ordinary'),
|
||||
item('update', { update }),
|
||||
item('warning', { environment: 'client_only' }),
|
||||
item('both', { update, pack_client_depends: true }),
|
||||
item('disabled', { enabled: false }),
|
||||
])
|
||||
const scope = effectScope()
|
||||
t.after(() => scope.stop())
|
||||
const pipeline = scope.run(() =>
|
||||
useContentPipeline({
|
||||
items,
|
||||
duplicateItems: ref([]),
|
||||
sortItems: (items) => items,
|
||||
getItemId: (item) => item.id,
|
||||
showUpdateFilter: true,
|
||||
showWarningsFilter: true,
|
||||
}),
|
||||
)!
|
||||
return {
|
||||
...pipeline,
|
||||
ids: () => pipeline.filteredItems.value.map((item) => item.id),
|
||||
async flush() {
|
||||
await nextTick()
|
||||
t.mock.timers.tick(100)
|
||||
await nextTick()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
test('status filters update the visible list on every click without reloading items', async (t) => {
|
||||
const p = fixture(t)
|
||||
await p.flush()
|
||||
assert.equal(p.ids().length, 5)
|
||||
p.toggleStatusFilter('updates')
|
||||
await p.flush()
|
||||
assert.deepEqual(p.ids(), ['update', 'both'])
|
||||
p.toggleStatusFilter('warnings')
|
||||
await p.flush()
|
||||
assert.deepEqual(p.ids(), ['both'])
|
||||
p.toggleStatusFilter('updates')
|
||||
await p.flush()
|
||||
assert.deepEqual(p.ids(), ['warning', 'both'])
|
||||
p.toggleStatusFilter('warnings')
|
||||
await p.flush()
|
||||
assert.equal(p.ids().length, 5)
|
||||
})
|
||||
|
||||
test('All clears status filters and restores the visible list without a refresh', async (t) => {
|
||||
const p = fixture(t)
|
||||
await p.flush()
|
||||
p.toggleStatusFilter('warnings')
|
||||
await p.flush()
|
||||
assert.deepEqual(p.ids(), ['warning', 'both'])
|
||||
p.selectedStatusFilters.value = []
|
||||
await p.flush()
|
||||
assert.equal(p.ids().length, 5)
|
||||
})
|
||||
|
||||
test('enabled and disabled remain exclusive and can each be deselected', async (t) => {
|
||||
const p = fixture(t)
|
||||
await p.flush()
|
||||
p.toggleStatusFilter('enabled')
|
||||
await p.flush()
|
||||
assert.deepEqual(p.ids(), ['ordinary', 'update', 'warning', 'both'])
|
||||
p.toggleStatusFilter('enabled')
|
||||
await p.flush()
|
||||
assert.equal(p.ids().length, 5)
|
||||
p.toggleStatusFilter('enabled')
|
||||
p.toggleStatusFilter('disabled')
|
||||
await p.flush()
|
||||
assert.deepEqual(p.selectedStatusFilters.value, ['disabled'])
|
||||
assert.deepEqual(p.ids(), ['disabled'])
|
||||
p.toggleStatusFilter('disabled')
|
||||
await p.flush()
|
||||
assert.equal(p.ids().length, 5)
|
||||
})
|
||||
@@ -62,9 +62,14 @@ const filterMessages = defineMessages({
|
||||
id: 'content.filter.updates',
|
||||
defaultMessage: 'Update available',
|
||||
},
|
||||
warnings: {
|
||||
id: 'content.filter.warnings',
|
||||
defaultMessage: 'Warnings',
|
||||
clientOnly: {
|
||||
id: 'content.filter.client-only',
|
||||
defaultMessage: 'Client only',
|
||||
},
|
||||
clientOnlyDescription: {
|
||||
id: 'content.filter.client-only-description',
|
||||
defaultMessage:
|
||||
'Includes client-only or singleplayer-only mods and mods that depend on them. These may be incompatible with dedicated servers.',
|
||||
},
|
||||
duplicates: {
|
||||
id: 'content.filter.duplicates',
|
||||
@@ -329,7 +334,11 @@ export function useContentPipeline(config: ContentPipelineConfig) {
|
||||
row2.push({ id: 'updates', label: formatMessage(filterMessages.updates) })
|
||||
}
|
||||
if (showWarningsFilter && typeFiltered.some((m) => getClientWarningType(m) !== null)) {
|
||||
row2.push({ id: 'warnings', label: formatMessage(filterMessages.warnings) })
|
||||
row2.push({
|
||||
id: 'warnings',
|
||||
label: formatMessage(filterMessages.clientOnly),
|
||||
tooltip: formatMessage(filterMessages.clientOnlyDescription),
|
||||
})
|
||||
}
|
||||
|
||||
if (hasEnabled && hasDisabled) {
|
||||
@@ -472,26 +481,20 @@ export function useContentPipeline(config: ContentPipelineConfig) {
|
||||
}
|
||||
|
||||
function toggleStatusFilter(filterId: string) {
|
||||
if (filterId === 'enabled' || filterId === 'disabled') {
|
||||
const index = selectedStatusFilters.value.indexOf(filterId)
|
||||
const otherStatusFilter = filterId === 'enabled' ? 'disabled' : 'enabled'
|
||||
if (index === -1) {
|
||||
selectedStatusFilters.value = [
|
||||
...selectedStatusFilters.value.filter((f) => f !== otherStatusFilter),
|
||||
filterId,
|
||||
]
|
||||
} else {
|
||||
selectedStatusFilters.value.splice(index, 1)
|
||||
}
|
||||
const filters = selectedStatusFilters.value
|
||||
// Replace the array so both the pipeline and saved-filter watchers are notified.
|
||||
if (filters.includes(filterId)) {
|
||||
selectedStatusFilters.value = filters.filter((id) => id !== filterId)
|
||||
return
|
||||
}
|
||||
|
||||
const index = selectedStatusFilters.value.indexOf(filterId)
|
||||
if (index === -1) {
|
||||
selectedStatusFilters.value.push(filterId)
|
||||
} else {
|
||||
selectedStatusFilters.value.splice(index, 1)
|
||||
if (filterId === 'enabled' || filterId === 'disabled') {
|
||||
const otherStatusFilter = filterId === 'enabled' ? 'disabled' : 'enabled'
|
||||
selectedStatusFilters.value = [...filters.filter((id) => id !== otherStatusFilter), filterId]
|
||||
return
|
||||
}
|
||||
|
||||
selectedStatusFilters.value = [...filters, filterId]
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1110,6 +1110,7 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
|
||||
<button
|
||||
v-for="option in row2FilterOptions"
|
||||
:key="option.id"
|
||||
v-tooltip="option.tooltip"
|
||||
class="relative pb-1 text-base font-semibold transition-colors"
|
||||
:class="
|
||||
selectedStatusFilters.includes(option.id)
|
||||
|
||||
@@ -872,6 +872,12 @@
|
||||
"content.filter.updates": {
|
||||
"defaultMessage": "Update available"
|
||||
},
|
||||
"content.filter.client-only": {
|
||||
"defaultMessage": "Client only"
|
||||
},
|
||||
"content.filter.client-only-description": {
|
||||
"defaultMessage": "Includes client-only or singleplayer-only mods and mods that depend on them. These may be incompatible with dedicated servers."
|
||||
},
|
||||
"content.filter.warnings": {
|
||||
"defaultMessage": "Warnings"
|
||||
},
|
||||
|
||||
@@ -848,6 +848,12 @@
|
||||
"content.filter.updates": {
|
||||
"defaultMessage": "可更新"
|
||||
},
|
||||
"content.filter.client-only": {
|
||||
"defaultMessage": "仅客户端"
|
||||
},
|
||||
"content.filter.client-only-description": {
|
||||
"defaultMessage": "包含仅供客户端或单人游戏使用的模组,以及依赖它们的模组。这些模组可能不兼容独立服务器。"
|
||||
},
|
||||
"content.filter.warnings": {
|
||||
"defaultMessage": "警告"
|
||||
},
|
||||
|
||||
@@ -698,6 +698,12 @@
|
||||
"content.filter.updates": {
|
||||
"defaultMessage": "更新"
|
||||
},
|
||||
"content.filter.client-only": {
|
||||
"defaultMessage": "僅用戶端"
|
||||
},
|
||||
"content.filter.client-only-description": {
|
||||
"defaultMessage": "包含僅供用戶端或單人遊戲使用的模組,以及依賴它們的模組。這些模組可能不相容於獨立伺服器。"
|
||||
},
|
||||
"content.filter.warnings": {
|
||||
"defaultMessage": "警告"
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user