diff --git a/theseus/src/event/emit.rs b/theseus/src/event/emit.rs index 54cb1968c..449d877d2 100644 --- a/theseus/src/event/emit.rs +++ b/theseus/src/event/emit.rs @@ -233,6 +233,22 @@ pub async fn emit_warning(message: &str) -> crate::Result<()> { Ok(()) } +// emit_offline(bool) +// This is used to emit an event to the frontend that the app is offline after a refresh (or online) +#[allow(dead_code)] +#[allow(unused_variables)] +pub async fn emit_offline(offline: bool) -> crate::Result<()> { + #[cfg(feature = "tauri")] + { + let event_state = crate::EventState::get().await?; + event_state + .app + .emit_all("offline", offline) + .map_err(EventError::from)?; + } + Ok(()) +} + // emit_command(CommandPayload::Something { something }) // ie: installing a pack, opening an .mrpack, etc // Generally used for url deep links and file opens that we we want to handle in the frontend diff --git a/theseus/src/event/mod.rs b/theseus/src/event/mod.rs index f2706f7a3..c28b97a66 100644 --- a/theseus/src/event/mod.rs +++ b/theseus/src/event/mod.rs @@ -194,6 +194,11 @@ pub struct LoadingPayload { pub message: String, } +#[derive(Serialize, Clone)] +pub struct OfflinePayload { + pub offline: bool, +} + #[derive(Serialize, Clone)] pub struct WarningPayload { pub message: String, diff --git a/theseus/src/launcher/mod.rs b/theseus/src/launcher/mod.rs index 158f24a7b..2e7162aa8 100644 --- a/theseus/src/launcher/mod.rs +++ b/theseus/src/launcher/mod.rs @@ -542,7 +542,7 @@ pub async fn launch_minecraft( } } - { + if !*state.offline.read().await { // Add game played to discord rich presence let _ = state .discord_rpc diff --git a/theseus/src/state/metadata.rs b/theseus/src/state/metadata.rs index a750a15ab..96d190bbe 100644 --- a/theseus/src/state/metadata.rs +++ b/theseus/src/state/metadata.rs @@ -58,6 +58,7 @@ impl Metadata { #[theseus_macros::debug_pin] pub async fn init( dirs: &DirectoryInfo, + fetch_online: bool, io_semaphore: &IoSemaphore, ) -> crate::Result { let mut metadata = None; @@ -67,7 +68,7 @@ impl Metadata { read_json::(&metadata_path, io_semaphore).await { metadata = Some(metadata_json); - } else { + } else if fetch_online { let res = async { let metadata_fetch = Self::fetch().await?; diff --git a/theseus/src/state/mod.rs b/theseus/src/state/mod.rs index c0ae6a1b3..96992750d 100644 --- a/theseus/src/state/mod.rs +++ b/theseus/src/state/mod.rs @@ -1,16 +1,17 @@ //! Theseus state management system -use crate::event::emit::{emit_loading, init_loading_unsafe}; +use crate::event::emit::{emit_loading, emit_offline, init_loading_unsafe}; use std::path::PathBuf; use crate::event::LoadingBarType; use crate::loading_join; use crate::state::users::Users; -use crate::util::fetch::{FetchSemaphore, IoSemaphore}; +use crate::util::fetch::{self, FetchSemaphore, IoSemaphore}; use notify::RecommendedWatcher; use notify_debouncer_mini::{new_debouncer, DebounceEventResult, Debouncer}; use std::sync::Arc; use std::time::Duration; +use tokio::join; use tokio::sync::{OnceCell, RwLock, Semaphore}; use futures::{channel::mpsc::channel, SinkExt, StreamExt}; @@ -55,6 +56,9 @@ pub use self::discord::*; // RwLock on state only has concurrent reads, except for config dir change which takes control of the State static LAUNCHER_STATE: OnceCell> = OnceCell::const_new(); pub struct State { + /// Whether or not the launcher is currently operating in 'offline mode' + pub offline: RwLock, + /// Information on the location of files used in the launcher pub directories: DirectoryInfo, @@ -145,10 +149,17 @@ impl State { ))); emit_loading(&loading_bar, 10.0, None).await?; - let metadata_fut = Metadata::init(&directories, &io_semaphore); + let is_offline = !fetch::check_internet(&fetch_semaphore, 3).await; + + let metadata_fut = + Metadata::init(&directories, !is_offline, &io_semaphore); let profiles_fut = Profiles::init(&directories, &mut file_watcher); - let tags_fut = - Tags::init(&directories, &io_semaphore, &fetch_semaphore); + let tags_fut = Tags::init( + &directories, + !is_offline, + &io_semaphore, + &fetch_semaphore, + ); let users_fut = Users::init(&directories, &io_semaphore); // Launcher data let (metadata, profiles, tags, users) = loading_join! { @@ -165,9 +176,13 @@ impl State { let discord_rpc = DiscordGuard::init().await?; + // Starts a loop of checking if we are online, and updating + Self::offine_check_loop(); + emit_loading(&loading_bar, 10.0, None).await?; Ok::, crate::Error>(RwLock::new(Self { + offline: RwLock::new(is_offline), directories, fetch_semaphore, fetch_semaphore_max: RwLock::new( @@ -190,13 +205,36 @@ impl State { })) } - /// Updates state with data from the web + /// Starts a loop of checking if we are online, and updating + pub fn offine_check_loop() { + tokio::task::spawn(async { + loop { + let state = Self::get().await; + if let Ok(state) = state { + let _ = state.refresh_offline().await; + } + + // Wait 5 seconds + tokio::time::sleep(Duration::from_secs(5)).await; + } + }); + } + + /// Updates state with data from the web, if we are online pub fn update() { - tokio::task::spawn(Metadata::update()); - tokio::task::spawn(Tags::update()); - tokio::task::spawn(Profiles::update_projects()); - tokio::task::spawn(Profiles::update_modrinth_versions()); - tokio::task::spawn(Settings::update_java()); + tokio::task::spawn(async { + if let Ok(state) = crate::State::get().await { + if !*state.offline.read().await { + let res1 = Profiles::update_modrinth_versions(); + let res2 = Tags::update(); + let res3 = Metadata::update(); + let res4 = Profiles::update_projects(); + let res5 = Settings::update_java(); + + let _ = join!(res1, res2, res3, res4, res5); + } + } + }); } #[tracing::instrument] @@ -264,6 +302,21 @@ impl State { *total_permits = settings.max_concurrent_downloads as u32; *io_semaphore = Semaphore::new(settings.max_concurrent_downloads); } + + /// Refreshes whether or not the launcher should be offline, by whether or not there is an internet connection + pub async fn refresh_offline(&self) -> crate::Result<()> { + let is_online = fetch::check_internet(&self.fetch_semaphore, 3).await; + + let mut offline = self.offline.write().await; + + if *offline != is_online { + return Ok(()); + } + + emit_offline(!is_online).await?; + *offline = !is_online; + Ok(()) + } } pub async fn init_watcher() -> crate::Result> { diff --git a/theseus/src/state/tags.rs b/theseus/src/state/tags.rs index 01ce8c452..88e6ce53b 100644 --- a/theseus/src/state/tags.rs +++ b/theseus/src/state/tags.rs @@ -24,6 +24,7 @@ impl Tags { #[theseus_macros::debug_pin] pub async fn init( dirs: &DirectoryInfo, + fetch_online: bool, io_semaphore: &IoSemaphore, fetch_semaphore: &FetchSemaphore, ) -> crate::Result { @@ -33,7 +34,7 @@ impl Tags { if let Ok(tags_json) = read_json::(&tags_path, io_semaphore).await { tags = Some(tags_json); - } else { + } else if fetch_online { match Self::fetch(fetch_semaphore).await { Ok(tags_fetch) => tags = Some(tags_fetch), Err(err) => { diff --git a/theseus/src/util/fetch.rs b/theseus/src/util/fetch.rs index 97cd01cbf..1cc06a42b 100644 --- a/theseus/src/util/fetch.rs +++ b/theseus/src/util/fetch.rs @@ -7,7 +7,7 @@ use reqwest::Method; use serde::de::DeserializeOwned; use std::ffi::OsStr; use std::path::{Path, PathBuf}; -use std::time; +use std::time::{self, Duration}; use tokio::sync::{RwLock, Semaphore}; use tokio::{fs::File, io::AsyncWriteExt}; @@ -182,6 +182,16 @@ pub async fn fetch_mirrors( unreachable!() } +/// Using labrinth API, checks if an internet response can be found, with a timeout in seconds +#[tracing::instrument(skip(semaphore))] +#[theseus_macros::debug_pin] +pub async fn check_internet(semaphore: &FetchSemaphore, timeout: u64) -> bool { + let result = fetch("https://api.modrinth.com", None, semaphore); + let result = + tokio::time::timeout(Duration::from_secs(timeout), result).await; + matches!(result, Ok(Ok(_))) +} + pub async fn read_json( path: &Path, semaphore: &IoSemaphore, diff --git a/theseus_gui/package.json b/theseus_gui/package.json index 9f9f6ece2..ee4e960da 100644 --- a/theseus_gui/package.json +++ b/theseus_gui/package.json @@ -18,7 +18,7 @@ "floating-vue": "^2.0.0-beta.20", "mixpanel-browser": "^2.47.0", "ofetch": "^1.0.1", - "omorphia": "^0.4.33", + "omorphia": "^0.4.34", "pinia": "^2.1.3", "qrcode.vue": "^3.4.0", "tauri-plugin-window-state-api": "github:tauri-apps/tauri-plugin-window-state#v1", diff --git a/theseus_gui/pnpm-lock.yaml b/theseus_gui/pnpm-lock.yaml index 9198fc0ba..c20577138 100644 --- a/theseus_gui/pnpm-lock.yaml +++ b/theseus_gui/pnpm-lock.yaml @@ -17,8 +17,8 @@ dependencies: specifier: ^1.0.1 version: 1.0.1 omorphia: - specifier: ^0.4.33 - version: 0.4.33 + specifier: ^0.4.34 + version: 0.4.34 pinia: specifier: ^2.1.3 version: 2.1.3(vue@3.3.4) @@ -27,7 +27,7 @@ dependencies: version: 3.4.0(vue@3.3.4) tauri-plugin-window-state-api: specifier: github:tauri-apps/tauri-plugin-window-state#v1 - version: github.com/tauri-apps/tauri-plugin-window-state/347c792535d2623fc21f66590d06f4c8dadd85ba + version: github.com/tauri-apps/tauri-plugin-window-state/5ea9eb0d4a9affd17269f92c0085935046be3f4a vite-svg-loader: specifier: ^4.0.0 version: 4.0.0 @@ -1344,8 +1344,8 @@ packages: ufo: 1.1.2 dev: false - /omorphia@0.4.33: - resolution: {integrity: sha512-Wo0t16zRL8ZLJSKVAYv6pdYhL4YXYjDYs18shO7V5cfxjcynvd5j0sui6uBR8ghVMWFEJH994AEC/urLwcHiBA==} + /omorphia@0.4.34: + resolution: {integrity: sha512-6uAH1kgzbYYmJDM41Vy4/MhzT9kRj+s1t8IknHKeOQqmVft+wPtv/pbA7pqTMfCzBOarLKKO5s4sNlz8TeMmaQ==} dependencies: dayjs: 1.11.7 floating-vue: 2.0.0-beta.20(vue@3.3.4) @@ -1808,8 +1808,8 @@ packages: engines: {node: '>=10'} dev: true - github.com/tauri-apps/tauri-plugin-window-state/347c792535d2623fc21f66590d06f4c8dadd85ba: - resolution: {tarball: https://codeload.github.com/tauri-apps/tauri-plugin-window-state/tar.gz/347c792535d2623fc21f66590d06f4c8dadd85ba} + github.com/tauri-apps/tauri-plugin-window-state/5ea9eb0d4a9affd17269f92c0085935046be3f4a: + resolution: {tarball: https://codeload.github.com/tauri-apps/tauri-plugin-window-state/tar.gz/5ea9eb0d4a9affd17269f92c0085935046be3f4a} name: tauri-plugin-window-state-api version: 0.0.0 dependencies: diff --git a/theseus_gui/src-tauri/src/api/utils.rs b/theseus_gui/src-tauri/src/api/utils.rs index ccf5628e6..c1dd8465f 100644 --- a/theseus_gui/src-tauri/src/api/utils.rs +++ b/theseus_gui/src-tauri/src/api/utils.rs @@ -12,6 +12,8 @@ pub fn init() -> tauri::plugin::TauriPlugin { safety_check_safe_loading_bars, get_opening_command, await_sync, + is_offline, + refresh_offline ]) .build() } @@ -125,3 +127,20 @@ pub async fn await_sync() -> Result<()> { tracing::debug!("State synced"); Ok(()) } + +/// Check if theseus is currently in offline mode, without a refresh attempt +#[tauri::command] +pub async fn is_offline() -> Result { + let state = State::get().await?; + let offline = *state.offline.read().await; + Ok(offline) +} + +/// Refreshes whether or not theseus is in offline mode, and returns the new value +#[tauri::command] +pub async fn refresh_offline() -> Result { + let state = State::get().await?; + state.refresh_offline().await?; + let offline = *state.offline.read().await; + Ok(offline) +} diff --git a/theseus_gui/src/App.vue b/theseus_gui/src/App.vue index 535db78a2..04c4dbfd0 100644 --- a/theseus_gui/src/App.vue +++ b/theseus_gui/src/App.vue @@ -20,12 +20,17 @@ import RunningAppBar from '@/components/ui/RunningAppBar.vue' import SplashScreen from '@/components/ui/SplashScreen.vue' import ModrinthLoadingIndicator from '@/components/modrinth-loading-indicator' import { useNotifications } from '@/store/notifications.js' -import { command_listener, warning_listener } from '@/helpers/events.js' +import { offline_listener, command_listener, warning_listener } from '@/helpers/events.js' import { MinimizeIcon, MaximizeIcon } from '@/assets/icons' import { type } from '@tauri-apps/api/os' import { appWindow } from '@tauri-apps/api/window' -import { isDev } from '@/helpers/utils.js' -import mixpanel from 'mixpanel-browser' +import { isDev, isOffline } from '@/helpers/utils.js' +import { + mixpanel_track, + mixpanel_init, + mixpanel_opt_out_tracking, + mixpanel_is_loaded, +} from '@/helpers/mixpanel' import { saveWindowState, StateFlags } from 'tauri-plugin-window-state-api' import { getVersion } from '@tauri-apps/api/app' import { window as TauriWindow } from '@tauri-apps/api' @@ -33,13 +38,14 @@ import { TauriEvent } from '@tauri-apps/api/event' import { await_sync, check_safe_loading_bars_complete } from './helpers/state' import { confirm } from '@tauri-apps/api/dialog' import URLConfirmModal from '@/components/ui/URLConfirmModal.vue' -// import OnboardingScreen from '@/components/ui/tutorial/OnboardingScreen.vue' import StickyTitleBar from '@/components/ui/tutorial/StickyTitleBar.vue' import OnboardingScreen from '@/components/ui/tutorial/OnboardingScreen.vue' const themeStore = useTheming() const urlModal = ref(null) const isLoading = ref(true) +const offline = ref(false) + const videoPlaying = ref(true) const showOnboarding = ref(false) @@ -58,11 +64,11 @@ defineExpose({ themeStore.collapsedNavigation = collapsed_navigation themeStore.advancedRendering = advanced_rendering - mixpanel.init('014c7d6a336d0efaefe3aca91063748d', { debug: dev, persistence: 'localStorage' }) + mixpanel_init('014c7d6a336d0efaefe3aca91063748d', { debug: dev, persistence: 'localStorage' }) if (opt_out_analytics) { - mixpanel.opt_out_tracking() + mixpanel_opt_out_tracking() } - mixpanel.track('Launched', { version, dev, onboarded_new }) + mixpanel_track('Launched', { version, dev, onboarded_new }) if (!dev) document.addEventListener('contextmenu', (event) => event.preventDefault()) @@ -72,6 +78,11 @@ defineExpose({ document.getElementsByTagName('html')[0].classList.add('windows') } + offline.value = await isOffline() + await offline_listener((b) => { + offline.value = b + }) + await warning_listener((e) => notificationsWrapper.value.addNotification({ title: 'Warning', @@ -121,8 +132,8 @@ TauriWindow.getCurrent().listen(TauriEvent.WINDOW_CLOSE_REQUESTED, async () => { const router = useRouter() router.afterEach((to, from, failure) => { - if (mixpanel.__loaded) { - mixpanel.track('PageView', { path: to.path, fromPath: from.path, failed: failure }) + if (mixpanel_is_loaded()) { + mixpanel_track('PageView', { path: to.path, fromPath: from.path, failed: failure }) } }) const route = useRoute() @@ -214,6 +225,7 @@ command_listener((e) => { +
+ +
+ Offline +
+
@@ -107,12 +113,13 @@ import { kill_by_uuid as killProfile, get_uuids_by_profile_path as getProfileProcesses, } from '@/helpers/process' -import { loading_listener, process_listener } from '@/helpers/events' +import { loading_listener, process_listener, offline_listener } from '@/helpers/events' import { useRouter } from 'vue-router' import { progress_bars_list } from '@/helpers/state.js' +import { refreshOffline, isOffline } from '@/helpers/utils.js' import ProgressBar from '@/components/ui/ProgressBar.vue' import { handleError } from '@/store/notifications.js' -import mixpanel from 'mixpanel-browser' +import { mixpanel_track } from '@/helpers/mixpanel' const router = useRouter() const card = ref(null) @@ -126,10 +133,20 @@ const showProfiles = ref(false) const currentProcesses = ref(await getRunningProfiles().catch(handleError)) const selectedProfile = ref(currentProcesses.value[0]) +const offline = ref(await isOffline().catch(handleError)) +const refreshInternet = async () => { + offline.value = await refreshOffline().catch(handleError) +} + const unlistenProcess = await process_listener(async () => { await refresh() }) +const unlistenRefresh = await offline_listener(async (b) => { + offline.value = b + await refresh() +}) + const refresh = async () => { currentProcesses.value = await getRunningProfiles().catch(handleError) if (!currentProcesses.value.includes(selectedProfile.value)) { @@ -142,7 +159,7 @@ const stop = async (path) => { const processes = await getProfileProcesses(path ?? selectedProfile.value.path) await killProfile(processes[0]) - mixpanel.track('InstanceStop', { + mixpanel_track('InstanceStop', { loader: currentProcesses.value[0].metadata.loader, game_version: currentProcesses.value[0].metadata.game_version, source: 'AppBar', @@ -240,6 +257,7 @@ onBeforeUnmount(() => { window.removeEventListener('click', handleClickOutsideProfile) unlistenProcess() unlistenLoading() + unlistenRefresh() }) diff --git a/theseus_gui/src/components/ui/SearchCard.vue b/theseus_gui/src/components/ui/SearchCard.vue index 029258415..9724e2548 100644 --- a/theseus_gui/src/components/ui/SearchCard.vue +++ b/theseus_gui/src/components/ui/SearchCard.vue @@ -85,7 +85,7 @@ import { install as packInstall } from '@/helpers/pack.js' import { installVersionDependencies } from '@/helpers/utils.js' import { useFetch } from '@/helpers/fetch.js' import { handleError } from '@/store/notifications.js' -import mixpanel from 'mixpanel-browser' +import { mixpanel_track } from '@/helpers/mixpanel' dayjs.extend(relativeTime) const props = defineProps({ @@ -165,7 +165,7 @@ async function install() { props.project.icon_url ).catch(handleError) - mixpanel.track('PackInstall', { + mixpanel_track('PackInstall', { id: props.project.project_id, version_id: queuedVersionData.id, title: props.project.title, @@ -196,7 +196,7 @@ async function install() { await installMod(props.instance.path, queuedVersionData.id).catch(handleError) await installVersionDependencies(props.instance, queuedVersionData) - mixpanel.track('ProjectInstall', { + mixpanel_track('ProjectInstall', { loader: props.instance.metadata.loader, game_version: props.instance.metadata.game_version, id: props.project.project_id, diff --git a/theseus_gui/src/helpers/events.js b/theseus_gui/src/helpers/events.js index 2f065940a..7752077b6 100644 --- a/theseus_gui/src/helpers/events.js +++ b/theseus_gui/src/helpers/events.js @@ -92,3 +92,15 @@ export async function command_listener(callback) { export async function warning_listener(callback) { return await listen('warning', (event) => callback(event.payload)) } + +/// Payload for the 'offline' event +/* + OfflinePayload { + offline: bool, true or false + } +*/ +export async function offline_listener(callback) { + return await listen('offline', (event) => { + return callback(event.payload) + }) +} diff --git a/theseus_gui/src/helpers/mixpanel.js b/theseus_gui/src/helpers/mixpanel.js new file mode 100644 index 000000000..f09f1d08c --- /dev/null +++ b/theseus_gui/src/helpers/mixpanel.js @@ -0,0 +1,57 @@ +import mixpanel from 'mixpanel-browser' + +// mixpanel_track +function trackWrapper(originalTrack) { + return function (event_name, properties = {}) { + try { + originalTrack(event_name, properties) + } catch (e) { + console.error(e) + } + } +} +export const mixpanel_track = trackWrapper(mixpanel.track.bind(mixpanel)) + +// mixpanel_opt_out_tracking() +function optOutTrackingWrapper(originalOptOutTracking) { + return function () { + try { + originalOptOutTracking() + } catch (e) { + console.error(e) + } + } +} +export const mixpanel_opt_out_tracking = optOutTrackingWrapper( + mixpanel.opt_out_tracking.bind(mixpanel) +) + +// mixpanel_opt_in_tracking() +function optInTrackingWrapper(originalOptInTracking) { + return function () { + try { + originalOptInTracking() + } catch (e) { + console.error(e) + } + } +} +export const mixpanel_opt_in_tracking = optInTrackingWrapper( + mixpanel.opt_in_tracking.bind(mixpanel) +) + +// mixpanel_init +function initWrapper(originalInit) { + return function (token, config = {}) { + try { + originalInit(token, config) + } catch (e) { + console.error(e) + } + } +} +export const mixpanel_init = initWrapper(mixpanel.init.bind(mixpanel)) + +export const mixpanel_is_loaded = () => { + return mixpanel.__loaded +} diff --git a/theseus_gui/src/helpers/utils.js b/theseus_gui/src/helpers/utils.js index 8163d9c38..25aa4114a 100644 --- a/theseus_gui/src/helpers/utils.js +++ b/theseus_gui/src/helpers/utils.js @@ -77,3 +77,12 @@ export const openLink = (url) => { }, }) } + +export const refreshOffline = async () => { + return await invoke('plugin:utils|refresh_offline', {}) +} + +// returns true/false +export const isOffline = async () => { + return await invoke('plugin:utils|is_offline', {}) +} diff --git a/theseus_gui/src/pages/Browse.vue b/theseus_gui/src/pages/Browse.vue index 9548bd388..cf2f4ea5f 100644 --- a/theseus_gui/src/pages/Browse.vue +++ b/theseus_gui/src/pages/Browse.vue @@ -1,5 +1,5 @@