Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 43 additions & 2 deletions apps/app-frontend/src/components/ui/modal/InstallToPlayModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,22 @@
</div>
</div>
</div>
<div v-else class="flex w-full flex-col gap-6">
<Admonition type="critical" :header="formatMessage(messages.unavailableHeader)">
{{ formatMessage(messages.unavailableDescription) }}
</Admonition>

<div class="flex w-full items-center justify-between gap-2">
<Button type="quiet" color="red" @click="handleReport">
<ReportIcon />
{{ formatMessage(commonMessages.reportButton) }}
</Button>
<Button @click="hide">
<XIcon />
{{ formatMessage(commonMessages.closeButton) }}
</Button>
</div>
</div>
</NewModal>

<ManagedContentModal
Expand Down Expand Up @@ -226,7 +242,12 @@ async function fetchData(versionId: string) {
const version = await get_version(versionId, 'bypass')
modpackVersion.value = version

if (version?.project_id) {
if (!version) {
console.warn(`InstallToPlayModal: modpack version ${versionId} is unavailable`)
return
}

if (version.project_id) {
requiredContentProject.value = await get_project(version.project_id, 'bypass')
externalFiles.value = [
...new Set(
Expand Down Expand Up @@ -349,7 +370,18 @@ async function show(
externalFiles.value = []
onInstallComplete.value = callback

if (modpackVersionIdVal) await fetchData(modpackVersionIdVal)
if (modpackVersionIdVal) {
try {
await fetchData(modpackVersionIdVal)
} catch (error) {
console.error(
`InstallToPlayModal: failed to load modpack version ${modpackVersionIdVal}`,
error,
)
}
} else {
console.warn(`InstallToPlayModal: server project ${projectVal.id} has no modpack version`)
}

modal.value?.show(e)
await nextTick()
Expand Down Expand Up @@ -415,6 +447,15 @@ const messages = defineMessages({
id: 'app.modal.install-to-play.external-files-dont-install',
defaultMessage: "Don't install",
},
unavailableHeader: {
id: 'app.modal.install-to-play.unavailable-header',
defaultMessage: "This server's modpack isn't available",
},
unavailableDescription: {
id: 'app.modal.install-to-play.unavailable-description',
defaultMessage:
"The modpack version this server uses can't be downloaded right now. It may be unpublished or under review. Try again later, or contact the server owner.",
},
})

const externalFileColumns = computed<TableColumn<ExternalFileColumn>[]>(() => [
Expand Down
6 changes: 6 additions & 0 deletions apps/app-frontend/src/locales/en-US/index.json
Original file line number Diff line number Diff line change
Expand Up @@ -1322,6 +1322,12 @@
"app.modal.install-to-play.shared-instance-unknown-files-description": {
"message": "This shared instance contains files that aren鈥檛 published on Modrinth. We strongly recommend only installing files from sources you trust."
},
"app.modal.install-to-play.unavailable-description": {
"message": "The modpack version this server uses can't be downloaded right now. It may be unpublished or under review. Try again later, or contact the server owner."
},
"app.modal.install-to-play.unavailable-header": {
"message": "This server's modpack isn't available"
},
"app.modal.install-to-play.unknown-files-description": {
"message": "This server modpack contains files that aren鈥檛 published on Modrinth. We strongly recommend only installing files from sources you trust."
},
Expand Down
41 changes: 41 additions & 0 deletions packages/app-lib/src/api/pack/install_mrpack.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1309,6 +1309,47 @@ fn modpack_source_kind(version_id: Option<&str>) -> ContentSourceKind {
}
}

/// Instance-relative paths installing or removing this pack can write: its
/// manifest files, its overrides and the instance icon.
pub(crate) async fn mrpack_instance_paths(
mrpack_file: &CreatePackFile,
) -> crate::Result<Vec<String>> {
let mut zip_reader = MrpackZipReader::new(mrpack_file).await?;
let Some(manifest_idx) = zip_reader.file().entries().iter().position(|f| {
matches!(f.filename().as_str(), Ok("modrinth.index.json"))
}) else {
return Err(crate::Error::from(crate::ErrorKind::InputError(
"No pack manifest found in mrpack".to_string(),
)));
};
let manifest = zip_reader.read_entry_to_string(manifest_idx).await?;
let pack: PackFormat = serde_json::from_str(&manifest)?;

let mut paths = vec!["icon.png".to_string()];
paths.extend(pack.files.iter().map(|file| file.path.to_string()));
for entry in zip_reader.file().entries() {
let Ok(filename) = entry.filename().as_str() else {
continue;
};
if filename.ends_with('/') {
continue;
}
let Some(relative) = filename
.strip_prefix("overrides/")
.or_else(|| filename.strip_prefix("client-overrides/"))
else {
continue;
};
if let Ok(relative) =
SafeRelativeUtf8UnixPathBuf::try_from(relative.to_string())
{
paths.push(relative.to_string());
}
}

Ok(paths)
}

#[tracing::instrument(skip(mrpack_file))]

pub async fn remove_all_related_files(
Expand Down
147 changes: 126 additions & 21 deletions packages/app-lib/src/install/recovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,16 @@ struct SharedInstanceUpdateRollback {
missing_file_ids: std::collections::HashSet<String>,
#[serde(default)]
copied_file_ids: std::collections::HashSet<String>,
#[serde(default)]
scoped_paths: Option<Vec<String>>,
}

/// With `scoped_paths`, only those instance-relative files and folders are backed
/// up. A path that does not exist yet is removed again on rollback.
pub(super) async fn prepare_instance_update_backup(
job_id: Uuid,
metadata: &InstanceMetadata,
scoped_paths: Option<Vec<String>>,
state: &State,
) -> crate::Result<PathBuf> {
let _lease = state.content_store.lease().await;
Expand Down Expand Up @@ -110,7 +115,7 @@ pub(super) async fn prepare_instance_update_backup(
copied_file_ids.insert(file.id.clone());
}
}
let skipped = files
let skipped: std::collections::HashSet<String> = files
.iter()
.filter(|file| {
bindings.iter().any(|binding| binding.file_id == file.id)
Expand All @@ -122,24 +127,44 @@ pub(super) async fn prepare_instance_update_backup(
.iter()
.map(|binding| binding.blob_sha512.clone())
.collect::<Vec<_>>();
let scoped_paths = scoped_paths.map(outermost_paths);
let instance_path = state
.directories
.instances_dir()
.join(&metadata.instance.path);
let backup_path =
staging_dir.join(SHARED_INSTANCE_ROLLBACK_INSTANCE_DIR);
match &scoped_paths {
Some(paths) => {
for path in paths {
let prefix = format!("{path}/");
let skipped = skipped
.iter()
.filter_map(|skipped| skipped.strip_prefix(&prefix))
.map(str::to_string)
.collect();
copy_path(
&instance_path.join(path),
&backup_path.join(path),
&skipped,
state,
)
.await?;
}
}
None => {
copy_directory(&instance_path, &backup_path, &skipped, state)
.await?;
}
}
let snapshot = SharedInstanceUpdateRollback {
files,
entries,
bindings,
missing_file_ids,
copied_file_ids,
scoped_paths,
};
let instance_path = state
.directories
.instances_dir()
.join(&metadata.instance.path);
copy_directory(
&instance_path,
&staging_dir.join(SHARED_INSTANCE_ROLLBACK_INSTANCE_DIR),
&skipped,
state,
)
.await?;
crate::util::io::write(
staging_dir.join(SHARED_INSTANCE_ROLLBACK_FILE),
serde_json::to_vec(&snapshot)?,
Expand Down Expand Up @@ -299,16 +324,33 @@ async fn restore_instance_update(
));
}
}
if tokio::fs::try_exists(&instance_path).await? {
crate::util::io::remove_dir_all(&instance_path).await?;
match &snapshot.scoped_paths {
Some(paths) => {
for path in paths {
let target = instance_path.join(path);
remove_path(&target).await?;
copy_path(
&backup_path.join(path),
&target,
&std::collections::HashSet::new(),
state,
)
.await?;
}
}
None => {
if tokio::fs::try_exists(&instance_path).await? {
crate::util::io::remove_dir_all(&instance_path).await?;
}
copy_directory(
&backup_path,
&instance_path,
&std::collections::HashSet::new(),
state,
)
.await?;
}
}
copy_directory(
&backup_path,
&instance_path,
&std::collections::HashSet::new(),
state,
)
.await?;
content_rows::restore_instance_content_snapshot(
&rollback.instance.instance.id,
&snapshot.files,
Expand Down Expand Up @@ -403,6 +445,69 @@ async fn restore_instance_metadata(
Ok(())
}

/// Drops duplicates and paths inside another listed folder.
fn outermost_paths(paths: Vec<String>) -> Vec<String> {
let all = paths
.iter()
.map(String::as_str)
.collect::<std::collections::HashSet<_>>();
let mut outermost = paths
.iter()
.filter(|path| {
!path
.match_indices('/')
.any(|(index, _)| all.contains(&path[..index]))
})
.cloned()
.collect::<Vec<_>>();
outermost.sort();
outermost.dedup();
outermost
}

async fn symlink_metadata(
path: &Path,
) -> crate::Result<Option<std::fs::Metadata>> {
match tokio::fs::symlink_metadata(path).await {
Ok(metadata) => Ok(Some(metadata)),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(error.into()),
}
}

/// Removes a file, folder or symlink without following symlinks.
async fn remove_path(path: &Path) -> crate::Result<()> {
match symlink_metadata(path).await? {
Some(metadata) if metadata.is_file() => {
crate::util::io::remove_file(path).await?;
}
Some(_) => crate::util::io::remove_dir_all(path).await?,
None => {}
}
Ok(())
}

/// Copies a file, folder or symlink. Missing paths are skipped.
async fn copy_path(
source: &Path,
target: &Path,
skipped: &std::collections::HashSet<String>,
state: &State,
) -> crate::Result<()> {
match symlink_metadata(source).await? {
Some(metadata) if metadata.is_symlink() => {
copy_symlink(source, target).await
}
Some(metadata) if metadata.is_dir() => {
copy_directory(source, target, skipped, state).await
}
Some(_) => {
crate::util::fetch::copy(source, target, &state.io_semaphore).await
}
None => Ok(()),
}
}

async fn copy_directory(
source: &Path,
target: &Path,
Expand Down
Loading
Loading