Merge pull request #1498 from hydralauncher/feat/auto-install-on-linux-and-friend-request-notification

feat: auto install update option on linux + new friend request notification
This commit is contained in:
Zamitto
2025-03-15 19:38:02 -03:00
committed by GitHub
21 changed files with 224 additions and 80 deletions

13
.github/workflows/trigger-lp.yml vendored Normal file
View File

@@ -0,0 +1,13 @@
name: Trigger Landing Page Build
on:
release:
types: [published]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Trigger Landing Page build
run: curl --location --request POST '${{ secrets.LP_TRIGGER_DEPLOY_URL }}'

File diff suppressed because one or more lines are too long

View File

@@ -340,7 +340,9 @@
"import_theme": "Import theme",
"import_theme_description": "You will import {{theme}} from the theme store",
"error_importing_theme": "Error importing theme",
"theme_imported": "Theme imported successfully"
"theme_imported": "Theme imported successfully",
"enable_friend_request_notifications": "When a friend request is received",
"enable_auto_install": "Download updates automatically"
},
"notifications": {
"download_complete": "Download complete",
@@ -351,7 +353,9 @@
"new_update_available": "Version {{version}} available",
"restart_to_install_update": "Restart Hydra to install the update",
"notification_achievement_unlocked_title": "Achievement unlocked for {{game}}",
"notification_achievement_unlocked_body": "{{achievement}} and other {{count}} were unlocked"
"notification_achievement_unlocked_body": "{{achievement}} and other {{count}} were unlocked",
"new_friend_request_description": "You have received a new friend request",
"new_friend_request_title": "New friend request"
},
"system_tray": {
"open": "Open Hydra",

View File

@@ -304,7 +304,6 @@
"no_email_account": "No has configurado un correo aún",
"no_subscription": "Disfruta Hydra de la mejor manera",
"no_users_blocked": "No tienes usuarios bloqueados",
"notifications": "Notificaciones",
"renew_subscription": "Renovar Hydra Cloud",
"subscription_active_until": "Tu Hydra Cloud está activa hasta {{date}}",
"subscription_expired_at": "Tú suscripción expiró el {{date}}",
@@ -341,7 +340,9 @@
"torbox_account_linked": "Cuenta de TorBox vinculada",
"torbox_description": "TorBox es tu servicio premium de seedbox que rivaliza incluso a los mejores servidores del mercado.",
"unset_theme": "Desactivar tema",
"web_store": "Tienda Web"
"web_store": "Tienda Web",
"enable_friend_request_notifications": "Cuando se recibe una solicitud de amistad",
"enable_auto_install": "Descargar actualizaciones automáticamente"
},
"notifications": {
"download_complete": "Descarga completada",
@@ -352,7 +353,9 @@
"new_update_available": "Version {{version}} disponible",
"restart_to_install_update": "Reinicia Hydra para instalar la actualización",
"notification_achievement_unlocked_title": "Logro desbloqueado de {{game}}",
"notification_achievement_unlocked_body": "{{achievement}} y otros {{count}} fueron desbloqueados"
"notification_achievement_unlocked_body": "{{achievement}} y otros {{count}} fueron desbloqueados",
"new_friend_request_title": "Nueva solicitud de amistad",
"new_friend_request_description": "Has recibido una nueva solicitud de amistad"
},
"system_tray": {
"open": "Abrir Hydra",

View File

@@ -31,7 +31,6 @@
},
"header": {
"search": "Buscar jogos",
"catalogue": "Catálogo",
"downloads": "Downloads",
"search_results": "Resultados da busca",
@@ -328,7 +327,9 @@
"import_theme": "Importar tema",
"import_theme_description": "Você irá importar {{theme}} da loja de temas",
"error_importing_theme": "Erro ao importar tema",
"theme_imported": "Tema importado com sucesso"
"theme_imported": "Tema importado com sucesso",
"enable_friend_request_notifications": "Quando um pedido de amizade é recebido",
"enable_auto_install": "Baixar atualizações automaticamente"
},
"notifications": {
"download_complete": "Download concluído",
@@ -337,7 +338,9 @@
"repack_count_one": "{{count}} novo repack",
"repack_count_other": "{{count}} novos repacks",
"new_update_available": "Versão {{version}} disponível",
"restart_to_install_update": "Reinicie o Hydra para instalar a nova versão"
"restart_to_install_update": "Reinicie o Hydra para instalar a nova versão",
"new_friend_request_title": "Novo pedido de amizade",
"new_friend_request_description": "Você recebeu um novo pedido de amizade"
},
"system_tray": {
"open": "Abrir Hydra",

View File

@@ -30,7 +30,6 @@
},
"header": {
"search": "Procurar jogos",
"catalogue": "Catálogo",
"downloads": "Transferências",
"search_results": "Resultados da pesquisa",
@@ -281,6 +280,7 @@
"blocked_users": "Utilizadores bloqueados",
"user_unblocked": "Utilizador desbloqueado",
"enable_achievement_notifications": "Quando uma conquista é desbloqueada",
"enable_friend_request_notifications": "Quando um pedido de amizade é recebido",
"launch_minimized": "Iniciar Hydra minimizado",
"disable_nsfw_alert": "Desativar alertas NSFW",
"seed_after_download_complete": "Semear após concluir o download",
@@ -338,7 +338,9 @@
"repack_count_one": "{{count}} novo repack",
"repack_count_other": "{{count}} novos repacks",
"new_update_available": "Versão {{version}} disponível",
"restart_to_install_update": "Reinicia o Hydra para instalar a nova versão"
"restart_to_install_update": "Reinicia o Hydra para instalar a nova versão",
"new_friend_request_title": "Novo pedido de amizade",
"new_friend_request_description": "Recebeste um novo pedido de amizade"
},
"system_tray": {
"open": "Abrir o Hydra",

View File

@@ -338,7 +338,9 @@
"import_theme": "Импортировать тему",
"import_theme_description": "Вы импортируете {{theme}} из магазина тем",
"error_importing_theme": "Ошибка при импорте темы",
"theme_imported": "Тема успешно импортирована"
"theme_imported": "Тема успешно импортирована",
"enable_friend_request_notifications": "При получении запроса на добавление в друзья",
"enable_auto_install": "Загружать обновления автоматически"
},
"notifications": {
"download_complete": "Загрузка завершена",
@@ -349,7 +351,9 @@
"new_update_available": "Доступна новая версия {{version}}",
"restart_to_install_update": "Перезапустите Hydra для установки обновления",
"notification_achievement_unlocked_title": "Достижение разблокировано для {{game}}",
"notification_achievement_unlocked_body": "были разблокированы {{achievement}} и другие {{count}}"
"notification_achievement_unlocked_body": "были разблокированы {{achievement}} и другие {{count}}",
"new_friend_request_title": "Новый запрос на добавление в друзья",
"new_friend_request_description": "Вы получили новый запрос на добавление в друзья"
},
"system_tray": {
"open": "Открыть Hydra",

View File

@@ -31,3 +31,5 @@ export const achievementSoundPath = app.isPackaged
export const backupsPath = path.join(app.getPath("userData"), "Backups");
export const appVersion = app.getVersion() + (isStaging ? "-staging" : "");
export const MAIN_LOOP_INTERVAL = 1500;

View File

@@ -4,11 +4,15 @@ import updater from "electron-updater";
const { autoUpdater } = updater;
const restartAndInstallUpdate = async (_event: Electron.IpcMainInvokeEvent) => {
export const restartAndInstallUpdate = () => {
autoUpdater.removeAllListeners();
if (app.isPackaged) {
autoUpdater.quitAndInstall(false);
}
};
registerEvent("restartAndInstallUpdate", restartAndInstallUpdate);
const restartAndInstallUpdateEvent = async (
_event: Electron.IpcMainInvokeEvent
) => restartAndInstallUpdate();
registerEvent("restartAndInstallUpdate", restartAndInstallUpdateEvent);

View File

@@ -1,17 +1,59 @@
import { MAIN_LOOP_INTERVAL } from "@main/constants";
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import { HydraApi, WindowManager } from "@main/services";
import { publishNewFriendRequestNotification } from "@main/services/notifications";
import { UserNotLoggedInError } from "@shared";
import type { FriendRequestSync } from "@types";
const syncFriendRequests = async (_event: Electron.IpcMainInvokeEvent) => {
return HydraApi.get<FriendRequestSync>(`/profile/friend-requests/sync`).catch(
(err) => {
if (err instanceof UserNotLoggedInError) {
return { friendRequests: [] };
}
throw err;
}
);
interface SyncState {
friendRequestCount: number | null;
tick: number;
}
const ticksToUpdate = (2 * 60 * 1000) / MAIN_LOOP_INTERVAL; // 2 minutes
const syncState: SyncState = {
friendRequestCount: null,
tick: 0,
};
registerEvent("syncFriendRequests", syncFriendRequests);
const syncFriendRequests = async () => {
return HydraApi.get<FriendRequestSync>(`/profile/friend-requests/sync`)
.then((res) => {
if (
syncState.friendRequestCount != null &&
syncState.friendRequestCount < res.friendRequestCount
) {
publishNewFriendRequestNotification();
}
syncState.friendRequestCount = res.friendRequestCount;
WindowManager.mainWindow?.webContents.send(
"on-sync-friend-requests",
res
);
return res;
})
.catch((err) => {
if (err instanceof UserNotLoggedInError) {
return { friendRequestCount: 0 } as FriendRequestSync;
}
throw err;
});
};
const syncFriendRequestsEvent = async (_event: Electron.IpcMainInvokeEvent) => {
return syncFriendRequests();
};
export const watchFriendRequests = async () => {
if (syncState.tick % ticksToUpdate === 0) {
await syncFriendRequests();
}
syncState.tick++;
};
registerEvent("syncFriendRequests", syncFriendRequestsEvent);

View File

@@ -3,18 +3,21 @@ import { DownloadManager } from "./download";
import { watchProcesses } from "./process-watcher";
import { AchievementWatcherManager } from "./achievements/achievement-watcher-manager";
import { UpdateManager } from "./update-manager";
import { watchFriendRequests } from "@main/events/profile/sync-friend-requests";
import { MAIN_LOOP_INTERVAL } from "@main/constants";
export const startMainLoop = async () => {
// eslint-disable-next-line no-constant-condition
while (true) {
await Promise.allSettled([
watchProcesses(),
watchFriendRequests(),
DownloadManager.watchDownloads(),
AchievementWatcherManager.watchAchievements(),
DownloadManager.getSeedStatus(),
UpdateManager.checkForUpdatePeriodically(),
]);
await sleep(1500);
await sleep(MAIN_LOOP_INTERVAL);
}
};

View File

@@ -12,6 +12,7 @@ import { logger } from "../logger";
import { WindowManager } from "../window-manager";
import type { Game, UserPreferences } from "@types";
import { db, levelKeys } from "@main/level";
import { restartAndInstallUpdate } from "@main/events/autoupdater/restart-and-install-update";
async function downloadImage(url: string | null) {
if (!url) return undefined;
@@ -72,10 +73,33 @@ export const publishNotificationUpdateReadyToInstall = async (
ns: "notifications",
}),
icon: trayIcon,
}).show();
})
.on("click", () => {
restartAndInstallUpdate();
})
.show();
};
export const publishNewFriendRequestNotification = async () => {};
export const publishNewFriendRequestNotification = async () => {
const userPreferences = await db.get<string, UserPreferences | null>(
levelKeys.userPreferences,
{
valueEncoding: "json",
}
);
if (!userPreferences?.friendRequestNotificationsEnabled) return;
new Notification({
title: t("new_friend_request_title", {
ns: "notifications",
}),
body: t("new_friend_request_description", {
ns: "notifications",
}),
icon: trayIcon,
}).show();
};
export const publishCombinedNewAchievementNotification = async (
achievementCount,

View File

@@ -1,14 +1,14 @@
import updater, { UpdateInfo } from "electron-updater";
import { logger, WindowManager } from "@main/services";
import { AppUpdaterEvent } from "@types";
import { AppUpdaterEvent, UserPreferences } from "@types";
import { app } from "electron";
import { publishNotificationUpdateReadyToInstall } from "@main/services/notifications";
const isAutoInstallAvailable =
process.platform !== "darwin" && process.env.PORTABLE_EXECUTABLE_FILE == null;
import { db, levelKeys } from "@main/level";
import { MAIN_LOOP_INTERVAL } from "@main/constants";
const { autoUpdater } = updater;
const sendEventsForDebug = false;
const ticksToUpdate = (50 * 60 * 1000) / MAIN_LOOP_INTERVAL; // 50 minutes
export class UpdateManager {
private static hasNotified = false;
@@ -16,7 +16,7 @@ export class UpdateManager {
private static checkTick = 0;
private static mockValuesForDebug() {
this.sendEvent({ type: "update-available", info: { version: "1.3.0" } });
this.sendEvent({ type: "update-available", info: { version: "3.3.1" } });
this.sendEvent({ type: "update-downloaded" });
}
@@ -24,7 +24,27 @@ export class UpdateManager {
WindowManager.mainWindow?.webContents.send("autoUpdaterEvent", event);
}
public static checkForUpdates() {
private static async isAutoInstallEnabled() {
if (process.platform === "darwin") return false;
if (process.platform === "win32") {
return process.env.PORTABLE_EXECUTABLE_FILE == null;
}
if (process.platform === "linux") {
const userPreferences = await db.get<string, UserPreferences | null>(
levelKeys.userPreferences,
{
valueEncoding: "json",
}
);
return userPreferences?.enableAutoInstall === true;
}
return false;
}
public static async checkForUpdates() {
autoUpdater
.once("update-available", (info: UpdateInfo) => {
this.sendEvent({ type: "update-available", info });
@@ -39,6 +59,8 @@ export class UpdateManager {
}
});
const isAutoInstallAvailable = await this.isAutoInstallEnabled();
if (app.isPackaged) {
autoUpdater.autoDownload = isAutoInstallAvailable;
autoUpdater.checkForUpdates().then((result) => {
@@ -52,7 +74,7 @@ export class UpdateManager {
}
public static checkForUpdatePeriodically() {
if (this.checkTick % 2000 == 0) {
if (this.checkTick % ticksToUpdate == 0) {
this.checkForUpdates();
}
this.checkTick++;

View File

@@ -15,6 +15,7 @@ import type {
SeedingStatus,
GameAchievement,
Theme,
FriendRequestSync,
} from "@types";
import type { AuthPage, CatalogueCategory } from "@shared";
import type { AxiosProgressEvent } from "axios";
@@ -306,6 +307,15 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.invoke("processProfileImage", imagePath),
getFriendRequests: () => ipcRenderer.invoke("getFriendRequests"),
syncFriendRequests: () => ipcRenderer.invoke("syncFriendRequests"),
onSyncFriendRequests: (cb: (friendRequests: FriendRequestSync) => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
friendRequests: FriendRequestSync
) => cb(friendRequests);
ipcRenderer.on("on-sync-friend-requests", listener);
return () =>
ipcRenderer.removeListener("on-sync-friend-requests", listener);
},
updateFriendRequest: (userId: string, action: FriendRequestAction) =>
ipcRenderer.invoke("updateFriendRequest", userId, action),
sendFriendRequest: (userId: string) =>

View File

@@ -20,6 +20,7 @@ import {
setUserDetails,
setProfileBackground,
setGameRunning,
setFriendRequestCount,
} from "@renderer/features";
import { useTranslation } from "react-i18next";
import { UserFriendModal } from "./pages/shared-modals/user-friend-modal";
@@ -51,7 +52,6 @@ export function App() {
isFriendsModalVisible,
friendRequetsModalTab,
friendModalUserId,
syncFriendRequests,
hideFriendsModal,
fetchUserDetails,
updateUserDetails,
@@ -123,7 +123,7 @@ export function App() {
.then((response) => {
if (response) {
updateUserDetails(response);
syncFriendRequests();
window.electron.syncFriendRequests();
}
})
.finally(() => {
@@ -134,23 +134,27 @@ export function App() {
$script.src = `${import.meta.env.RENDERER_VITE_EXTERNAL_RESOURCES_URL}/bundle.js?t=${Date.now()}`;
document.head.appendChild($script);
});
}, [fetchUserDetails, syncFriendRequests, updateUserDetails, dispatch]);
}, [fetchUserDetails, updateUserDetails, dispatch]);
const onSignIn = useCallback(() => {
fetchUserDetails().then((response) => {
if (response) {
updateUserDetails(response);
syncFriendRequests();
window.electron.syncFriendRequests();
showSuccessToast(t("successfully_signed_in"));
}
});
}, [
fetchUserDetails,
syncFriendRequests,
t,
showSuccessToast,
updateUserDetails,
]);
}, [fetchUserDetails, t, showSuccessToast, updateUserDetails]);
useEffect(() => {
const unsubscribe = window.electron.onSyncFriendRequests((result) => {
dispatch(setFriendRequestCount(result.friendRequestCount));
});
return () => {
unsubscribe();
};
}, [dispatch]);
useEffect(() => {
const unsubscribe = window.electron.onGamesRunning((gamesRunning) => {

View File

@@ -1,7 +1,7 @@
import { useNavigate } from "react-router-dom";
import { PeopleIcon } from "@primer/octicons-react";
import { useAppSelector, useUserDetails } from "@renderer/hooks";
import { useEffect, useMemo } from "react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
@@ -9,19 +9,13 @@ import { Avatar } from "../avatar/avatar";
import { AuthPage } from "@shared";
import "./sidebar-profile.scss";
const LONG_POLLING_INTERVAL = 120_000;
export function SidebarProfile() {
const navigate = useNavigate();
const { t } = useTranslation("sidebar");
const {
userDetails,
friendRequestCount,
showFriendsModal,
syncFriendRequests,
} = useUserDetails();
const { userDetails, friendRequestCount, showFriendsModal } =
useUserDetails();
const { gameRunning } = useAppSelector((state) => state.gameRunning);
@@ -34,16 +28,6 @@ export function SidebarProfile() {
navigate(`/profile/${userDetails.id}`);
};
useEffect(() => {
const pollingInterval = setInterval(() => {
syncFriendRequests();
}, LONG_POLLING_INTERVAL);
return () => {
clearInterval(pollingInterval);
};
}, [syncFriendRequests]);
const friendsButton = useMemo(() => {
if (!userDetails) return null;

View File

@@ -280,7 +280,10 @@ declare global {
path: string
) => Promise<{ imagePath: string; mimeType: string }>;
getFriendRequests: () => Promise<FriendRequest[]>;
syncFriendRequests: () => Promise<FriendRequestSync>;
syncFriendRequests: () => Promise<void>;
onSyncFriendRequests: (
cb: (friendRequests: FriendRequestSync) => void
) => () => Electron.IpcRenderer;
updateFriendRequest: (
userId: string,
action: FriendRequestAction

View File

@@ -6,7 +6,6 @@ import {
setFriendRequests,
setFriendsModalVisible,
setFriendsModalHidden,
setFriendRequestCount,
} from "@renderer/features";
import type {
FriendRequestAction,
@@ -88,24 +87,15 @@ export function useUserDetails() {
]
);
const syncFriendRequests = useCallback(async () => {
return window.electron
.syncFriendRequests()
.then((sync) => {
dispatch(setFriendRequestCount(sync.friendRequestCount));
})
.catch(() => {});
}, [dispatch]);
const fetchFriendRequests = useCallback(async () => {
return window.electron
.getFriendRequests()
.then((friendRequests) => {
syncFriendRequests();
window.electron.syncFriendRequests();
dispatch(setFriendRequests(friendRequests));
})
.catch(() => {});
}, [dispatch, syncFriendRequests]);
}, [dispatch]);
const showFriendsModal = useCallback(
(initialTab: UserFriendModalTab, userId: string) => {
@@ -167,7 +157,6 @@ export function useUserDetails() {
patchUser,
sendFriendRequest,
fetchFriendRequests,
syncFriendRequests,
updateFriendRequestState,
blockUser,
unblockUser,

View File

@@ -20,6 +20,7 @@ export function SettingsBehavior() {
runAtStartup: false,
startMinimized: false,
disableNsfwAlert: false,
enableAutoInstall: false,
seedAfterDownloadComplete: false,
showHiddenAchievementsDescription: false,
});
@@ -34,6 +35,7 @@ export function SettingsBehavior() {
runAtStartup: userPreferences.runAtStartup ?? false,
startMinimized: userPreferences.startMinimized ?? false,
disableNsfwAlert: userPreferences.disableNsfwAlert ?? false,
enableAutoInstall: userPreferences.enableAutoInstall ?? false,
seedAfterDownloadComplete:
userPreferences.seedAfterDownloadComplete ?? false,
showHiddenAchievementsDescription:
@@ -99,6 +101,16 @@ export function SettingsBehavior() {
</div>
)}
{window.electron.platform === "linux" && (
<CheckboxField
label={t("enable_auto_install")}
checked={form.enableAutoInstall}
onChange={() =>
handleChange({ enableAutoInstall: !form.enableAutoInstall })
}
/>
)}
<CheckboxField
label={t("disable_nsfw_alert")}
checked={form.disableNsfwAlert}

View File

@@ -32,6 +32,7 @@ export function SettingsGeneral() {
downloadNotificationsEnabled: false,
repackUpdatesNotificationsEnabled: false,
achievementNotificationsEnabled: false,
friendRequestNotificationsEnabled: false,
language: "",
customStyles: window.localStorage.getItem("customStyles") || "",
@@ -82,6 +83,8 @@ export function SettingsGeneral() {
userPreferences.repackUpdatesNotificationsEnabled ?? false,
achievementNotificationsEnabled:
userPreferences.achievementNotificationsEnabled ?? false,
friendRequestNotificationsEnabled:
userPreferences.friendRequestNotificationsEnabled ?? false,
language: language ?? "en",
}));
}
@@ -171,6 +174,17 @@ export function SettingsGeneral() {
})
}
/>
<CheckboxField
label={t("enable_friend_request_notifications")}
checked={form.friendRequestNotificationsEnabled}
onChange={() =>
handleChange({
friendRequestNotificationsEnabled:
!form.friendRequestNotificationsEnabled,
})
}
/>
</div>
);
}

View File

@@ -76,11 +76,13 @@ export interface UserPreferences {
runAtStartup?: boolean;
startMinimized?: boolean;
disableNsfwAlert?: boolean;
enableAutoInstall?: boolean;
seedAfterDownloadComplete?: boolean;
showHiddenAchievementsDescription?: boolean;
downloadNotificationsEnabled?: boolean;
repackUpdatesNotificationsEnabled?: boolean;
achievementNotificationsEnabled?: boolean;
friendRequestNotificationsEnabled?: boolean;
}
export interface ScreenState {