fix: better offline handling

This commit is contained in:
Calum H. (IMB11)
2026-07-27 15:39:52 +01:00
parent c8b21fe120
commit 08bb4de9dc
12 changed files with 156 additions and 34 deletions
@@ -72,7 +72,6 @@ import {
ButtonStyled, ButtonStyled,
CopyCode, CopyCode,
defineMessages, defineMessages,
injectNotificationManager,
Table, Table,
type TableColumn, type TableColumn,
useFormatDateTime, useFormatDateTime,
@@ -86,10 +85,11 @@ import ConfirmRevokeSharedInstanceInviteModal from '@/components/ui/shared-insta
import SharedInstanceInstallationSettingsControls from '@/components/ui/shared-instances/SharedInstanceInstallationSettingsControls.vue' import SharedInstanceInstallationSettingsControls from '@/components/ui/shared-instances/SharedInstanceInstallationSettingsControls.vue'
import { config } from '@/config' import { config } from '@/config'
import { type SharedInstanceInvite, unpublish_shared_instance } from '@/helpers/instance' import { type SharedInstanceInvite, unpublish_shared_instance } from '@/helpers/instance'
import { useSharedInstanceErrors } from '@/helpers/shared-instance-errors'
import { injectInstanceSettings } from '@/providers/instance-settings' import { injectInstanceSettings } from '@/providers/instance-settings'
const { instance, offline, onUnlinked } = injectInstanceSettings() const { instance, offline, onUnlinked } = injectInstanceSettings()
const { handleError } = injectNotificationManager() const { notifySharedInstanceError } = useSharedInstanceErrors()
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const unpublishing = ref(false) const unpublishing = ref(false)
@@ -162,7 +162,7 @@ async function unpublishSharedInstance() {
await queryClient.invalidateQueries({ queryKey: ['linkedModpackInfo', instance.value.id] }) await queryClient.invalidateQueries({ queryKey: ['linkedModpackInfo', instance.value.id] })
onUnlinked() onUnlinked()
} catch (error) { } catch (error) {
handleError(error) notifySharedInstanceError(error)
} finally { } finally {
unpublishing.value = false unpublishing.value = false
} }
@@ -19,6 +19,7 @@ import {
install_shared_instance, install_shared_instance,
} from '@/helpers/install' } from '@/helpers/install'
import { list } from '@/helpers/instance' import { list } from '@/helpers/instance'
import { useSharedInstanceErrors } from '@/helpers/shared-instance-errors'
import { useTheming } from '@/store/state' import { useTheming } from '@/store/state'
import { parseSharedInstanceInviteNotification } from './shared-instance-invite-parser' import { parseSharedInstanceInviteNotification } from './shared-instance-invite-parser'
@@ -47,6 +48,8 @@ export function useSharedInstanceInviteHandler(
const auth = injectAuth() const auth = injectAuth()
const client = injectModrinthClient() const client = injectModrinthClient()
const { handleError } = injectNotificationManager() const { handleError } = injectNotificationManager()
const { notifySharedInstanceConnectionError, notifySharedInstanceError } =
useSharedInstanceErrors()
const popupNotificationManager = injectPopupNotificationManager() const popupNotificationManager = injectPopupNotificationManager()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const router = useRouter() const router = useRouter()
@@ -78,7 +81,10 @@ export function useSharedInstanceInviteHandler(
!invite.invitedByUsername && invite.invitedById !invite.invitedByUsername && invite.invitedById
? get_user(invite.invitedById, 'bypass').catch(() => null) ? get_user(invite.invitedById, 'bypass').catch(() => null)
: null, : null,
client.sharedinstances.instances_v1.get(invite.sharedInstanceId).catch(() => null), client.sharedinstances.instances_v1.get(invite.sharedInstanceId).catch(() => {
notifySharedInstanceConnectionError()
return null
}),
]) ])
return { return {
@@ -177,7 +183,7 @@ export function useSharedInstanceInviteHandler(
() => markNotificationRead(notification), () => markNotificationRead(notification),
) )
} catch (error) { } catch (error) {
handleError(toError(error)) notifySharedInstanceError(error)
} }
} }
@@ -267,7 +273,7 @@ export function useSharedInstanceInviteHandler(
await queryClient.invalidateQueries({ queryKey: ['instances'] }) await queryClient.invalidateQueries({ queryKey: ['instances'] })
}) })
} catch (error) { } catch (error) {
handleError(toError(error)) notifySharedInstanceError(error)
} }
} }
+5
View File
@@ -102,6 +102,7 @@ export interface SharedInstanceUpdateDiff {
} }
export const SHARED_INSTANCE_UNAVAILABLE_ERROR_CODE = 'shared_instance_unavailable' export const SHARED_INSTANCE_UNAVAILABLE_ERROR_CODE = 'shared_instance_unavailable'
export const SHARED_INSTANCES_API_ERROR_CODE = 'shared_instances_api_error'
export type SharedInstanceUnavailableReason = 'deleted' | 'access_revoked' | 'quarantined' export type SharedInstanceUnavailableReason = 'deleted' | 'access_revoked' | 'quarantined'
@@ -113,6 +114,10 @@ export function isSharedInstanceUnavailableError(error: unknown) {
return getSharedInstanceUnavailableReason(error) !== null return getSharedInstanceUnavailableReason(error) !== null
} }
export function isSharedInstancesApiError(error: unknown) {
return isRecord(error) && error.code === SHARED_INSTANCES_API_ERROR_CODE
}
export function getSharedInstanceUnavailableReason( export function getSharedInstanceUnavailableReason(
error: unknown, error: unknown,
): SharedInstanceUnavailableReason | null { ): SharedInstanceUnavailableReason | null {
@@ -1,6 +1,10 @@
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui' import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { getErrorMessage, type SharedInstanceUnavailableReason } from '@/helpers/install' import {
getErrorMessage,
isSharedInstancesApiError,
type SharedInstanceUnavailableReason,
} from '@/helpers/install'
export const sharedInstanceErrorMessages = defineMessages({ export const sharedInstanceErrorMessages = defineMessages({
unavailableTitle: { unavailableTitle: {
@@ -39,6 +43,14 @@ export const sharedInstanceErrorMessages = defineMessages({
id: 'instance.shared-instance.error.title', id: 'instance.shared-instance.error.title',
defaultMessage: 'Something has gone wrong', defaultMessage: 'Something has gone wrong',
}, },
networkErrorTitle: {
id: 'instance.shared-instance.network-error.title',
defaultMessage: 'Network error',
},
networkErrorText: {
id: 'instance.shared-instance.network-error.text',
defaultMessage: 'Unable to connect to shared instances API',
},
}) })
export function sharedInstanceUnavailableTextMessage( export function sharedInstanceUnavailableTextMessage(
@@ -82,7 +94,20 @@ export function useSharedInstanceErrors() {
}) })
} }
function notifySharedInstanceConnectionError() {
addNotification({
type: 'error',
title: formatMessage(sharedInstanceErrorMessages.networkErrorTitle),
text: formatMessage(sharedInstanceErrorMessages.networkErrorText),
})
}
function notifySharedInstanceError(error: unknown) { function notifySharedInstanceError(error: unknown) {
if (isSharedInstancesApiError(error)) {
notifySharedInstanceConnectionError()
return
}
addNotification({ addNotification({
type: 'error', type: 'error',
title: formatMessage(sharedInstanceErrorMessages.errorTitle), title: formatMessage(sharedInstanceErrorMessages.errorTitle),
@@ -92,6 +117,7 @@ export function useSharedInstanceErrors() {
return { return {
formatSharedInstanceUnavailable, formatSharedInstanceUnavailable,
notifySharedInstanceConnectionError,
notifySharedInstanceError, notifySharedInstanceError,
notifySharedInstanceUnavailable, notifySharedInstanceUnavailable,
} }
@@ -590,17 +590,16 @@ const startInstance = async (context: string) => {
!!instance.value.shared_instance && !sharedInstanceActionsLocked.value && !offline.value !!instance.value.shared_instance && !sharedInstanceActionsLocked.value && !offline.value
if (canCheckSharedInstanceUpdate) { if (canCheckSharedInstanceUpdate) {
let preview: Awaited<ReturnType<typeof refreshSharedInstanceUpdatePreview>> let preview: Awaited<ReturnType<typeof refreshSharedInstanceUpdatePreview>> = null
checkingSharedInstanceLaunch.value = true checkingSharedInstanceLaunch.value = true
try { try {
preview = await refreshSharedInstanceUpdatePreview() preview = await refreshSharedInstanceUpdatePreview()
} catch (error) { } catch (error) {
if (isSharedInstanceUnavailableError(error)) { if (isSharedInstanceUnavailableError(error)) {
await handleSharedInstanceUnavailable(getSharedInstanceUnavailableReason(error)) await handleSharedInstanceUnavailable(getSharedInstanceUnavailableReason(error))
} else { return
notifySharedInstanceError(error)
} }
return notifySharedInstanceError(error)
} finally { } finally {
checkingSharedInstanceLaunch.value = false checkingSharedInstanceLaunch.value = false
} }
@@ -707,7 +706,7 @@ async function reportSharedInstance(event?: MouseEvent, closeUpdateModal = false
if (closeUpdateModal) sharedInstanceUpdateModal.value?.hide() if (closeUpdateModal) sharedInstanceUpdateModal.value?.hide()
sharedInstanceReportModal.value?.showReport(preview, event) sharedInstanceReportModal.value?.showReport(preview, event)
} catch (error) { } catch (error) {
handleError(error as Error) notifySharedInstanceError(error)
} }
} }
@@ -47,7 +47,13 @@
@state-change="publishState = $event" @state-change="publishState = $event"
/> />
<div v-if="membersTableLoading" class="h-64" aria-hidden="true" /> <SharedInstanceShareEmptyState
v-if="unableToConnect"
:heading="formatMessage(messages.unableToConnectHeading)"
:description="formatMessage(messages.unableToConnectDescription)"
/>
<div v-else-if="membersTableLoading" class="h-64" aria-hidden="true" />
<SharedInstanceMembersTable <SharedInstanceMembersTable
v-else-if="showMembersTable" v-else-if="showMembersTable"
@@ -150,7 +156,7 @@ import {
type InvitePlayersUser, type InvitePlayersUser,
useVIntl, useVIntl,
} from '@modrinth/ui' } from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query' import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { openUrl } from '@tauri-apps/plugin-opener' import { openUrl } from '@tauri-apps/plugin-opener'
import { computed, ref, toRef, watch } from 'vue' import { computed, ref, toRef, watch } from 'vue'
@@ -158,9 +164,10 @@ import ModrinthAccountRequiredModal from '@/components/ui/modal/ModrinthAccountR
import SharedInstancePublishModal from '@/components/ui/shared-instances/SharedInstancePublishModal.vue' import SharedInstancePublishModal from '@/components/ui/shared-instances/SharedInstancePublishModal.vue'
import { import {
getSharedInstanceUnavailableReason, getSharedInstanceUnavailableReason,
isSharedInstancesApiError,
isSharedInstanceUnavailableError, isSharedInstanceUnavailableError,
} from '@/helpers/install' } from '@/helpers/install'
import { edit } from '@/helpers/instance' import { can_current_user_use_shared_instances, edit } from '@/helpers/instance'
import type { ModrinthAuthFlow } from '@/helpers/mr_auth.ts' import type { ModrinthAuthFlow } from '@/helpers/mr_auth.ts'
import { import {
sharedInstanceErrorMessages, sharedInstanceErrorMessages,
@@ -196,6 +203,7 @@ const actionsLocked = sharedInstanceState.shareActionsLocked
const sharedInstanceActionsLocked = actionsLocked const sharedInstanceActionsLocked = actionsLocked
const currentUserId = computed(() => auth.user.value?.id ?? null) const currentUserId = computed(() => auth.user.value?.id ?? null)
const isSignedIn = computed(() => !!auth.session_token.value) const isSignedIn = computed(() => !!auth.session_token.value)
const sharedInstancesApiUnavailable = ref(false)
const accountRequiredModal = ref<InstanceType<typeof ModrinthAccountRequiredModal>>() const accountRequiredModal = ref<InstanceType<typeof ModrinthAccountRequiredModal>>()
const invitePlayersModal = ref<InstanceType<typeof InvitePlayersModal>>() const invitePlayersModal = ref<InstanceType<typeof InvitePlayersModal>>()
const unlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>() const unlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
@@ -212,10 +220,22 @@ function notifyOperationError(error: unknown) {
sharedInstanceState.unavailableManager.value, sharedInstanceState.unavailableManager.value,
) )
} else { } else {
if (isSharedInstancesApiError(error)) sharedInstancesApiUnavailable.value = true
notifySharedInstanceError(error) notifySharedInstanceError(error)
} }
} }
const eligibilityQuery = useQuery({
queryKey: computed(() => ['shared-instance-eligibility', currentUserId.value]),
queryFn: can_current_user_use_shared_instances,
enabled: () => isSignedIn.value && !!currentUserId.value,
retry: false,
staleTime: Infinity,
refetchOnMount: 'always',
refetchOnWindowFocus: false,
refetchOnReconnect: false,
})
const members = useSharedInstanceMembers({ const members = useSharedInstanceMembers({
instance, instance,
currentUserId, currentUserId,
@@ -258,6 +278,12 @@ const lockedActionButton = computed(() =>
const sharedInstanceUnavailableReason = sharedInstanceState.unavailableReason const sharedInstanceUnavailableReason = sharedInstanceState.unavailableReason
const sharedInstanceUnavailable = computed(() => !!sharedInstanceUnavailableReason.value) const sharedInstanceUnavailable = computed(() => !!sharedInstanceUnavailableReason.value)
const sharedInstanceUnavailableManager = sharedInstanceState.unavailableManager const sharedInstanceUnavailableManager = sharedInstanceState.unavailableManager
const unableToConnect = computed(
() =>
sharedInstancesApiUnavailable.value ||
isSharedInstancesApiError(eligibilityQuery.error.value) ||
isSharedInstancesApiError(members.query.error.value),
)
const membersTableLoading = computed( const membersTableLoading = computed(
() => () =>
members.rows.value.length === 0 && members.rows.value.length === 0 &&
@@ -289,6 +315,15 @@ const importedModpackBackupTip = computed(() =>
const messages = defineMessages({ const messages = defineMessages({
signInButton: { id: 'app.instance.share.sign-in.button', defaultMessage: 'Sign in' }, signInButton: { id: 'app.instance.share.sign-in.button', defaultMessage: 'Sign in' },
unableToConnectHeading: {
id: 'app.instance.share.unable-to-connect.heading',
defaultMessage: 'Unable to connect',
},
unableToConnectDescription: {
id: 'app.instance.share.unable-to-connect.description',
defaultMessage:
'The shared instances service is not accessible at the moment, please try again later',
},
noFriendsInvitedHeading: { noFriendsInvitedHeading: {
id: 'app.instance.share.empty.heading', id: 'app.instance.share.empty.heading',
defaultMessage: 'No friends invited', defaultMessage: 'No friends invited',
@@ -396,6 +431,23 @@ function signInToShare(event?: MouseEvent) {
void accountRequiredModal.value?.show(event) void accountRequiredModal.value?.show(event)
} }
watch(
[eligibilityQuery.error, members.query.error],
(errors) => {
for (const error of errors) {
if (isSharedInstancesApiError(error)) notifyOperationError(error)
}
},
{ immediate: true },
)
watch(
[eligibilityQuery.data, members.query.data],
([eligibility, memberRows]) => {
if (eligibility !== undefined && memberRows !== undefined) {
sharedInstancesApiUnavailable.value = false
}
},
)
watch( watch(
() => props.instance.id, () => props.instance.id,
() => { () => {
@@ -58,6 +58,7 @@ export function useSharedInstanceMembers(options: {
queryFn: ({ queryKey }) => fetchRows(queryKey), queryFn: ({ queryKey }) => fetchRows(queryKey),
enabled: () => enabled: () =>
options.isSignedIn.value && !!options.instance.value.id && !options.actionsLocked.value, options.isSignedIn.value && !!options.instance.value.id && !options.actionsLocked.value,
retry: false,
staleTime: Infinity, staleTime: Infinity,
refetchOnMount: 'always', refetchOnMount: 'always',
refetchOnReconnect: false, refetchOnReconnect: false,
+13 -2
View File
@@ -91,14 +91,25 @@ macro_rules! impl_serialize {
theseus::ErrorKind::SharedInstanceUnavailable(reason) => Some(reason), theseus::ErrorKind::SharedInstanceUnavailable(reason) => Some(reason),
_ => None, _ => None,
}; };
let code = match theseus_error.raw.as_ref() {
theseus::ErrorKind::SharedInstanceUnavailable(_) => {
Some("shared_instance_unavailable")
}
theseus::ErrorKind::SharedInstancesApiError(_) => {
Some("shared_instances_api_error")
}
_ => None,
};
let mut state = serializer.serialize_struct( let mut state = serializer.serialize_struct(
"Theseus", "Theseus",
if unavailable_reason.is_some() { 4 } else { 2 }, 2 + usize::from(code.is_some()) + usize::from(unavailable_reason.is_some()),
)?; )?;
state.serialize_field("field_name", "Theseus")?; state.serialize_field("field_name", "Theseus")?;
state.serialize_field("message", &theseus_error.to_string())?; state.serialize_field("message", &theseus_error.to_string())?;
if let Some(code) = code {
state.serialize_field("code", code)?;
}
if let Some(reason) = unavailable_reason { if let Some(reason) = unavailable_reason {
state.serialize_field("code", "shared_instance_unavailable")?;
state.serialize_field("reason", reason)?; state.serialize_field("reason", reason)?;
} }
state.end() state.end()
@@ -713,9 +713,17 @@ where
let body = match response.text().await { let body = match response.text().await {
Ok(body) => body, Ok(body) => body,
Err(error) if strip_response_url => { Err(error) if strip_response_url => {
return Err(error.without_url().into()); return Err(crate::ErrorKind::SharedInstancesApiError(
error.without_url().to_string(),
)
.into());
}
Err(error) => {
return Err(crate::ErrorKind::SharedInstancesApiError(
error.to_string(),
)
.into());
} }
Err(error) => return Err(error.into()),
}; };
serde_json::from_str::<T>(&body).map_err(|error| { serde_json::from_str::<T>(&body).map_err(|error| {
tracing::warn!( tracing::warn!(
@@ -729,7 +737,7 @@ where
error_column = error.column(), error_column = error.column(),
"Shared instances API returned an invalid JSON response" "Shared instances API returned an invalid JSON response"
); );
crate::ErrorKind::OtherError(format!( crate::ErrorKind::SharedInstancesApiError(format!(
"Shared instances API request {operation} {method} {log_path} returned invalid JSON with status {status}" "Shared instances API request {operation} {method} {log_path} returned invalid JSON with status {status}"
)) ))
.into() .into()
@@ -870,7 +878,12 @@ pub(super) async fn send_bytes_request_to_url(
.header(reqwest::header::CONTENT_TYPE, "application/octet-stream") .header(reqwest::header::CONTENT_TYPE, "application/octet-stream")
.body(body) .body(body)
.send() .send()
.await?; .await
.map_err(|error| {
crate::ErrorKind::SharedInstancesApiError(
error.without_url().to_string(),
)
})?;
if response.status().is_success() { if response.status().is_success() {
let request_id = response_request_id(&response); let request_id = response_request_id(&response);
@@ -960,9 +973,17 @@ async fn send_request_with_auth_and_log_path(
let response = match request.send().await { let response = match request.send().await {
Ok(response) => response, Ok(response) => response,
Err(error) if path != log_path => { Err(error) if path != log_path => {
return Err(error.without_url().into()); return Err(crate::ErrorKind::SharedInstancesApiError(
error.without_url().to_string(),
)
.into());
}
Err(error) => {
return Err(crate::ErrorKind::SharedInstancesApiError(
error.to_string(),
)
.into());
} }
Err(error) => return Err(error.into()),
}; };
if response.status().is_success() { if response.status().is_success() {
let request_id = response_request_id(&response); let request_id = response_request_id(&response);
@@ -995,10 +1016,14 @@ pub(super) async fn shared_instances_request_error<T>(
request_id = request_id.as_deref().unwrap_or("none"), request_id = request_id.as_deref().unwrap_or("none"),
"Shared instances API request failed" "Shared instances API request failed"
); );
Err(crate::ErrorKind::OtherError(format!( let message = format!(
"Shared instances API request {operation} {method} {path} failed with status {status}" "Shared instances API request {operation} {method} {path} failed with status {status}"
)) );
.into()) if status.is_server_error() {
return Err(crate::ErrorKind::SharedInstancesApiError(message).into());
}
Err(crate::ErrorKind::OtherError(message).into())
} }
pub(super) fn response_request_id( pub(super) fn response_request_id(
@@ -244,13 +244,7 @@ pub(crate) async fn check_shared_instance_availability_before_launch(
let availability = let availability =
match get_remote_instance_access(&attachment.id, state).await { match get_remote_instance_access(&attachment.id, state).await {
Ok(availability) => availability, Ok(availability) => availability,
Err(error) Err(error) => {
if matches!(
error.raw.as_ref(),
crate::ErrorKind::NoCredentialsError
| crate::ErrorKind::FetchError(_)
) =>
{
tracing::warn!( tracing::warn!(
instance_id, instance_id,
shared_instance_id = %attachment.id, shared_instance_id = %attachment.id,
@@ -259,7 +253,6 @@ pub(crate) async fn check_shared_instance_availability_before_launch(
); );
return Ok(()); return Ok(());
} }
Err(error) => return Err(error),
}; };
if let SharedInstanceRemoteResponse::Unavailable(reason) = availability { if let SharedInstanceRemoteResponse::Unavailable(reason) = availability {
+3
View File
@@ -124,6 +124,9 @@ pub enum ErrorKind {
#[error("Shared instance unavailable: {0}")] #[error("Shared instance unavailable: {0}")]
SharedInstanceUnavailable(SharedInstanceUnavailableReason), SharedInstanceUnavailable(SharedInstanceUnavailableReason),
#[error("Shared instances API request failed: {0}")]
SharedInstancesApiError(String),
#[error("Join handle error: {0}")] #[error("Join handle error: {0}")]
JoinError(#[from] tokio::task::JoinError), JoinError(#[from] tokio::task::JoinError),
+1
View File
@@ -1293,6 +1293,7 @@ fn install_error_code(
ErrorKind::SharedInstanceUnavailable(_) => { ErrorKind::SharedInstanceUnavailable(_) => {
"shared_instance_unavailable" "shared_instance_unavailable"
} }
ErrorKind::SharedInstancesApiError(_) => "shared_instances_api_error",
ErrorKind::InputError(_) => match phase { ErrorKind::InputError(_) => match phase {
PreparingInstance | Finalizing => "instance_error", PreparingInstance | Finalizing => "instance_error",
ResolvingPack | DownloadingPackFile | ReadingPackManifest => { ResolvingPack | DownloadingPackFile | ReadingPackManifest => {