Merge pull request #1374 from hydralauncher/feature/check-directory-permission

feat: changing permission verify strategy
This commit is contained in:
Zamitto 2025-01-31 23:55:35 -03:00 committed by GitHub
commit 732a00c388
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 248 additions and 126 deletions

View File

@ -94,7 +94,7 @@ def seed_status():
@app.route("/healthcheck", methods=["GET"])
def healthcheck():
return "", 200
return "ok", 200
@app.route("/process-list", methods=["GET"])
def process_list():

View File

@ -172,7 +172,8 @@
"reset_achievements_description": "Isso irá resetar todas as conquistas de {{game}}",
"reset_achievements_title": "Tem certeza?",
"reset_achievements_success": "Conquistas resetadas com sucesso",
"reset_achievements_error": "Falha ao resetar conquistas"
"reset_achievements_error": "Falha ao resetar conquistas",
"no_write_permission": "Não é possível baixar nesse diretório. Clique aqui para saber mais."
},
"activation": {
"title": "Ativação",

View File

@ -1,47 +1,8 @@
import type { AppUpdaterEvent } from "@types";
import { registerEvent } from "../register-event";
import updater, { UpdateInfo } from "electron-updater";
import { WindowManager } from "@main/services";
import { app } from "electron";
import { publishNotificationUpdateReadyToInstall } from "@main/services/notifications";
const { autoUpdater } = updater;
const sendEvent = (event: AppUpdaterEvent) => {
WindowManager.mainWindow?.webContents.send("autoUpdaterEvent", event);
};
const sendEventsForDebug = false;
const isAutoInstallAvailable =
process.platform !== "darwin" && process.env.PORTABLE_EXECUTABLE_FILE == null;
const mockValuesForDebug = () => {
sendEvent({ type: "update-available", info: { version: "1.3.0" } });
sendEvent({ type: "update-downloaded" });
};
const newVersionInfo = { version: "" };
import { UpdateManager } from "@main/services/update-manager";
const checkForUpdates = async (_event: Electron.IpcMainInvokeEvent) => {
autoUpdater
.once("update-available", (info: UpdateInfo) => {
sendEvent({ type: "update-available", info });
newVersionInfo.version = info.version;
})
.once("update-downloaded", () => {
sendEvent({ type: "update-downloaded" });
publishNotificationUpdateReadyToInstall(newVersionInfo.version);
});
if (app.isPackaged) {
autoUpdater.autoDownload = isAutoInstallAvailable;
autoUpdater.checkForUpdates();
} else if (sendEventsForDebug) {
mockValuesForDebug();
}
return isAutoInstallAvailable;
return UpdateManager.checkForUpdates();
};
registerEvent("checkForUpdates", checkForUpdates);

View File

@ -1,15 +1,21 @@
import fs from "node:fs";
import path from "node:path";
import { registerEvent } from "../register-event";
const checkFolderWritePermission = async (
_event: Electron.IpcMainInvokeEvent,
path: string
) =>
new Promise((resolve) => {
fs.access(path, fs.constants.W_OK, (err) => {
resolve(!err);
});
});
testPath: string
) => {
const testFilePath = path.join(testPath, ".hydra-write-test");
try {
fs.writeFileSync(testFilePath, "");
fs.rmSync(testFilePath);
return true;
} catch (err) {
return false;
}
};
registerEvent("checkFolderWritePermission", checkFolderWritePermission);

View File

@ -0,0 +1,7 @@
export const parseLaunchOptions = (params?: string | null): string[] => {
if (!params) {
return [];
}
return params.split(" ");
};

View File

@ -1,8 +1,10 @@
import { registerEvent } from "../register-event";
import { shell } from "electron";
import { spawn } from "child_process";
import { parseExecutablePath } from "../helpers/parse-executable-path";
import { gamesSublevel, levelKeys } from "@main/level";
import { GameShop } from "@types";
import { parseLaunchOptions } from "../helpers/parse-launch-options";
const openGame = async (
_event: Electron.IpcMainInvokeEvent,
@ -11,8 +13,8 @@ const openGame = async (
executablePath: string,
launchOptions?: string | null
) => {
// TODO: revisit this for launchOptions
const parsedPath = parseExecutablePath(executablePath);
const parsedParams = parseLaunchOptions(launchOptions);
const gameKey = levelKeys.game(shop, objectId);
@ -26,7 +28,12 @@ const openGame = async (
launchOptions,
});
shell.openPath(parsedPath);
if (parsedParams.length === 0) {
shell.openPath(parsedPath);
return;
}
spawn(parsedPath, parsedParams, { shell: false, detached: true });
};
registerEvent("openGame", openGame);

View File

@ -7,7 +7,7 @@ import { omit } from "lodash-es";
import axios from "axios";
import { fileTypeFromFile } from "file-type";
const patchUserProfile = async (updateProfile: UpdateProfileRequest) => {
export const patchUserProfile = async (updateProfile: UpdateProfileRequest) => {
return HydraApi.patch<UserProfile>("/profile", updateProfile);
};

View File

@ -3,6 +3,7 @@ import { registerEvent } from "../register-event";
import type { UserPreferences } from "@types";
import i18next from "i18next";
import { db, levelKeys } from "@main/level";
import { patchUserProfile } from "../profile/update-profile";
const updateUserPreferences = async (
_event: Electron.IpcMainInvokeEvent,
@ -19,6 +20,7 @@ const updateUserPreferences = async (
});
i18next.changeLanguage(preferences.language);
patchUserProfile({ language: preferences.language }).catch(() => {});
}
await db.put<string, UserPreferences>(

View File

@ -9,6 +9,7 @@ import resources from "@locales";
import { PythonRPC } from "./services/python-rpc";
import { Aria2 } from "./services/aria2";
import { db, levelKeys } from "./level";
import { loadState } from "./main";
const { autoUpdater } = updater;
@ -57,7 +58,7 @@ app.whenReady().then(async () => {
return net.fetch(url.pathToFileURL(decodeURI(filePath)).toString());
});
await import("./main");
await loadState();
const language = await db.get<string, string>(levelKeys.language, {
valueEncoding: "utf-8",

View File

@ -21,8 +21,18 @@ import {
import { Auth, User, type UserPreferences } from "@types";
import { knexClient } from "./knex-client";
const loadState = async (userPreferences: UserPreferences | null) => {
import("./events");
export const loadState = async () => {
const userPreferences = await migrateFromSqlite().then(async () => {
await db.put<string, boolean>(levelKeys.sqliteMigrationDone, true, {
valueEncoding: "json",
});
return db.get<string, UserPreferences>(levelKeys.userPreferences, {
valueEncoding: "json",
});
});
await import("./events");
Aria2.spawn();
@ -192,15 +202,3 @@ const migrateFromSqlite = async () => {
migrateUser,
]);
};
migrateFromSqlite().then(async () => {
await db.put<string, boolean>(levelKeys.sqliteMigrationDone, true, {
valueEncoding: "json",
});
db.get<string, UserPreferences>(levelKeys.userPreferences, {
valueEncoding: "json",
}).then((userPreferences) => {
loadState(userPreferences);
});
});

View File

@ -141,7 +141,7 @@ const processAchievementFileDiff = async (
export class AchievementWatcherManager {
private static hasFinishedMergingWithRemote = false;
public static watchAchievements = () => {
public static watchAchievements() {
if (!this.hasFinishedMergingWithRemote) return;
if (process.platform === "win32") {
@ -149,12 +149,12 @@ export class AchievementWatcherManager {
}
return watchAchievementsWithWine();
};
}
private static preProcessGameAchievementFiles = (
private static preProcessGameAchievementFiles(
game: Game,
gameAchievementFiles: AchievementFile[]
) => {
) {
const unlockedAchievements: UnlockedAchievement[] = [];
for (const achievementFile of gameAchievementFiles) {
const parsedAchievements = parseAchievementFile(
@ -182,7 +182,7 @@ export class AchievementWatcherManager {
}
return mergeAchievements(game, unlockedAchievements, false);
};
}
private static preSearchAchievementsWindows = async () => {
const games = await gamesSublevel
@ -230,7 +230,7 @@ export class AchievementWatcherManager {
);
};
public static preSearchAchievements = async () => {
public static async preSearchAchievements() {
try {
const newAchievementsCount =
process.platform === "win32"
@ -256,5 +256,5 @@ export class AchievementWatcherManager {
}
this.hasFinishedMergingWithRemote = true;
};
}
}

View File

@ -40,7 +40,7 @@ export const getGameAchievementData = async (
throw err;
}
logger.error("Failed to get game achievements", err);
logger.error("Failed to get game achievements for", objectId, err);
return [];
});

View File

@ -59,7 +59,7 @@ export const mergeAchievements = async (
const unlockedAchievements = localGameAchievement?.unlockedAchievements ?? [];
const newAchievementsMap = new Map(
achievements.reverse().map((achievement) => {
achievements.toReversed().map((achievement) => {
return [achievement.name.toUpperCase(), achievement];
})
);
@ -87,7 +87,7 @@ export const mergeAchievements = async (
userPreferences?.achievementNotificationsEnabled
) {
const achievementsInfo = newAchievements
.sort((a, b) => {
.toSorted((a, b) => {
return a.unlockTime - b.unlockTime;
})
.map((achievement) => {

View File

@ -3,7 +3,7 @@ import { WindowManager } from "./window-manager";
import url from "url";
import { uploadGamesBatch } from "./library-sync";
import { clearGamesRemoteIds } from "./library-sync/clear-games-remote-id";
import { logger } from "./logger";
import { networkLogger as logger } from "./logger";
import { UserNotLoggedInError, SubscriptionRequiredError } from "@shared";
import { omit } from "lodash-es";
import { appVersion } from "@main/constants";
@ -32,7 +32,8 @@ export class HydraApi {
private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5; // 5 minutes
private static readonly ADD_LOG_INTERCEPTOR = true;
private static secondsToMilliseconds = (seconds: number) => seconds * 1000;
private static readonly secondsToMilliseconds = (seconds: number) =>
seconds * 1000;
private static userAuth: HydraApiUserAuth = {
authToken: "",
@ -153,7 +154,8 @@ export class HydraApi {
(error) => {
logger.error(" ---- RESPONSE ERROR -----");
const { config } = error;
const data = JSON.parse(config.data);
const data = JSON.parse(config.data ?? null);
logger.error(
config.method,
@ -174,14 +176,22 @@ export class HydraApi {
error.response.status,
error.response.data
);
} else if (error.request) {
const errorData = error.toJSON();
logger.error("Request error:", errorData.message);
} else {
logger.error("Error", error.message);
return Promise.reject(error as Error);
}
logger.error(" ----- END RESPONSE ERROR -------");
return Promise.reject(error);
if (error.request) {
const errorData = error.toJSON();
logger.error("Request error:", errorData.code, errorData.message);
return Promise.reject(
new Error(
`Request failed with ${errorData.code} ${errorData.message}`
)
);
}
logger.error("Error", error.message);
return Promise.reject(error as Error);
}
);
}

View File

@ -6,8 +6,12 @@ log.transports.file.resolvePathFn = (
_: log.PathVariables,
message?: log.LogMessage | undefined
) => {
if (message?.scope === "python-instance") {
return path.join(logsPath, "pythoninstance.txt");
if (message?.scope === "python-rpc") {
return path.join(logsPath, "pythonrpc.txt");
}
if (message?.scope === "network") {
return path.join(logsPath, "network.txt");
}
if (message?.scope == "achievements") {
@ -34,3 +38,4 @@ log.initialize();
export const pythonRpcLogger = log.scope("python-rpc");
export const logger = log.scope("main");
export const achievementsLogger = log.scope("achievements");
export const networkLogger = log.scope("network");

View File

@ -2,6 +2,7 @@ import { sleep } from "@main/helpers";
import { DownloadManager } from "./download";
import { watchProcesses } from "./process-watcher";
import { AchievementWatcherManager } from "./achievements/achievement-watcher-manager";
import { UpdateManager } from "./update-manager";
export const startMainLoop = async () => {
// eslint-disable-next-line no-constant-condition
@ -11,6 +12,7 @@ export const startMainLoop = async () => {
DownloadManager.watchDownloads(),
AchievementWatcherManager.watchAchievements(),
DownloadManager.getSeedStatus(),
UpdateManager.checkForUpdatePeriodically(),
]);
await sleep(1500);

View File

@ -9,6 +9,7 @@ import { achievementSoundPath } from "@main/constants";
import icon from "@resources/icon.png?asset";
import { NotificationOptions, toXmlString } from "./xml";
import { logger } from "../logger";
import { WindowManager } from "../window-manager";
import type { Game, UserPreferences } from "@types";
import { db, levelKeys } from "@main/level";
@ -96,7 +97,9 @@ export const publishCombinedNewAchievementNotification = async (
toastXml: toXmlString(options),
}).show();
if (process.platform !== "linux") {
if (WindowManager.mainWindow) {
WindowManager.mainWindow.webContents.send("on-achievement-unlocked");
} else if (process.platform !== "linux") {
sound.play(achievementSoundPath);
}
};
@ -143,7 +146,9 @@ export const publishNewAchievementNotification = async (info: {
toastXml: toXmlString(options),
}).show();
if (process.platform !== "linux") {
if (WindowManager.mainWindow) {
WindowManager.mainWindow.webContents.send("on-achievement-unlocked");
} else if (process.platform !== "linux") {
sound.play(achievementSoundPath);
}
};

View File

@ -0,0 +1,60 @@
import updater, { UpdateInfo } from "electron-updater";
import { logger, WindowManager } from "@main/services";
import { AppUpdaterEvent } from "@types";
import { app } from "electron";
import { publishNotificationUpdateReadyToInstall } from "@main/services/notifications";
const isAutoInstallAvailable =
process.platform !== "darwin" && process.env.PORTABLE_EXECUTABLE_FILE == null;
const { autoUpdater } = updater;
const sendEventsForDebug = false;
export class UpdateManager {
private static hasNotified = false;
private static newVersion = "";
private static checkTick = 0;
private static mockValuesForDebug() {
this.sendEvent({ type: "update-available", info: { version: "1.3.0" } });
this.sendEvent({ type: "update-downloaded" });
}
private static sendEvent(event: AppUpdaterEvent) {
WindowManager.mainWindow?.webContents.send("autoUpdaterEvent", event);
}
public static checkForUpdates() {
autoUpdater
.once("update-available", (info: UpdateInfo) => {
this.sendEvent({ type: "update-available", info });
this.newVersion = info.version;
})
.once("update-downloaded", () => {
this.sendEvent({ type: "update-downloaded" });
if (!this.hasNotified) {
this.hasNotified = true;
publishNotificationUpdateReadyToInstall(this.newVersion);
}
});
if (app.isPackaged) {
autoUpdater.autoDownload = isAutoInstallAvailable;
autoUpdater.checkForUpdates().then((result) => {
logger.log(`Check for updates result: ${result}`);
});
} else if (sendEventsForDebug) {
this.mockValuesForDebug();
}
return isAutoInstallAvailable;
}
public static checkForUpdatePeriodically() {
if (this.checkTick % 2000 == 0) {
this.checkForUpdates();
}
this.checkTick++;
}
}

View File

@ -29,7 +29,6 @@ export const getUserData = async () => {
})
.catch(async (err) => {
if (err instanceof UserNotLoggedInError) {
logger.info("User is not logged in", err);
return null;
}
logger.error("Failed to get logged user");
@ -59,6 +58,7 @@ export const getUserData = async () => {
expiresAt: loggedUser.subscription.expiresAt,
}
: null,
featurebaseJwt: "",
} as UserDetails;
}

View File

@ -50,7 +50,7 @@ export class WindowManager {
minHeight: 540,
backgroundColor: "#1c1c1c",
titleBarStyle: process.platform === "linux" ? "default" : "hidden",
...(process.platform === "linux" ? { icon } : {}),
icon,
trafficLightPosition: { x: 16, y: 16 },
titleBarOverlay: {
symbolColor: "#DADBE1",
@ -145,6 +145,11 @@ export class WindowManager {
WindowManager.mainWindow?.setProgressBar(-1);
WindowManager.mainWindow = null;
});
this.mainWindow.webContents.setWindowOpenHandler((handler) => {
shell.openExternal(handler.url);
return { action: "deny" };
});
}
public static openAuthWindow(page: AuthPage, searchParams: URLSearchParams) {

View File

@ -169,6 +169,12 @@ contextBridge.exposeInMainWorld("electron", {
return () =>
ipcRenderer.removeListener("on-library-batch-complete", listener);
},
onAchievementUnlocked: (cb: () => void) => {
const listener = (_event: Electron.IpcRendererEvent) => cb();
ipcRenderer.on("on-achievement-unlocked", listener);
return () =>
ipcRenderer.removeListener("on-achievement-unlocked", listener);
},
/* Hardware */
getDiskFreeSpace: (path: string) =>

View File

@ -123,7 +123,7 @@ export const titleBar = style({
alignItems: "center",
padding: `0 ${SPACING_UNIT * 2}px`,
WebkitAppRegion: "drag",
zIndex: "4",
zIndex: vars.zIndex.titleBar,
borderBottom: `1px solid ${vars.color.border}`,
} as ComplexStyleRule);

View File

@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef } from "react";
import achievementSound from "@renderer/assets/audio/achievement.wav";
import { Sidebar, BottomPanel, Header, Toast } from "@renderer/components";
import {
@ -233,13 +233,29 @@ export function App() {
downloadSourcesWorker.postMessage(["SYNC_DOWNLOAD_SOURCES", id]);
}, [updateRepacks]);
const playAudio = useCallback(() => {
const audio = new Audio(achievementSound);
audio.volume = 0.2;
audio.play();
}, []);
useEffect(() => {
const unsubscribe = window.electron.onAchievementUnlocked(() => {
playAudio();
});
return () => {
unsubscribe();
};
}, [playAudio]);
const handleToastClose = useCallback(() => {
dispatch(closeToast());
}, [dispatch]);
return (
<>
{window.electron.platform === "win32" && (
{/* {window.electron.platform === "win32" && (
<div className={styles.titleBar}>
<h4>
Hydra
@ -248,7 +264,15 @@ export function App() {
)}
</h4>
</div>
)}
)} */}
<div className={styles.titleBar}>
<h4>
Hydra
{hasActiveSubscription && (
<span className={styles.cloudText}> Cloud</span>
)}
</h4>
</div>
<Toast
visible={toast.visible}

Binary file not shown.

View File

@ -21,4 +21,14 @@
cursor: pointer;
}
}
&__version-button {
color: globals.$body-color;
border-bottom: solid 1px transparent;
&:hover {
border-bottom: solid 1px globals.$body-color;
cursor: pointer;
}
}
}

View File

@ -76,10 +76,15 @@ export function BottomPanel() {
<small>{status}</small>
</button>
<small>
{sessionHash ? `${sessionHash} -` : ""} v{version} &quot;
{VERSION_CODENAME}&quot;
</small>
<button
data-featurebase-changelog
className="bottom-panel__version-button"
>
<small data-featurebase-changelog>
{sessionHash ? `${sessionHash} -` : ""} v{version} &quot;
{VERSION_CODENAME}&quot;
</small>
</button>
</footer>
);
}

View File

@ -142,6 +142,7 @@ declare global {
minimized: boolean;
}) => Promise<void>;
authenticateRealDebrid: (apiToken: string) => Promise<RealDebridUser>;
onAchievementUnlocked: (cb: () => void) => () => Electron.IpcRenderer;
/* Download sources */
putDownloadSource: (

View File

@ -78,9 +78,15 @@ export function useUserDetails() {
...response,
username: userDetails?.username || "",
subscription: userDetails?.subscription || null,
featurebaseJwt: userDetails?.featurebaseJwt || "",
});
},
[updateUserDetails, userDetails?.username, userDetails?.subscription]
[
updateUserDetails,
userDetails?.username,
userDetails?.subscription,
userDetails?.featurebaseJwt,
]
);
const syncFriendRequests = useCallback(async () => {

View File

@ -45,6 +45,7 @@ Sentry.init({
tracesSampleRate: 1.0,
replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1.0,
release: await window.electron.getVersion(),
});
console.log = logger.log;

View File

@ -98,9 +98,7 @@ export function DownloadSettingsModal({
? Downloader.RealDebrid
: filteredDownloaders[0];
setSelectedDownloader(
selectedDownloader === undefined ? null : selectedDownloader
);
setSelectedDownloader(selectedDownloader ?? null);
}, [
userPreferences?.downloadsPath,
downloaders,

View File

@ -183,8 +183,6 @@ export function GameOptionsModal({
}
};
const shouldShowLaunchOptionsConfiguration = false;
return (
<>
<DeleteGameModal
@ -299,27 +297,28 @@ export function GameOptionsModal({
</div>
)}
{shouldShowLaunchOptionsConfiguration && (
<div className={styles.optionsContainer}>
<div className={styles.gameOptionHeader}>
<h2>{t("launch_options")}</h2>
<h4 className={styles.gameOptionHeaderDescription}>
{t("launch_options_description")}
</h4>
<TextField
value={launchOptions}
theme="dark"
placeholder={t("launch_options_placeholder")}
onChange={handleChangeLaunchOptions}
rightContent={
game.launchOptions && (
<Button onClick={handleClearLaunchOptions} theme="outline">
{t("clear")}
</Button>
)
}
/>
</div>
)}
<TextField
value={launchOptions}
theme="dark"
placeholder={t("launch_options_placeholder")}
onChange={handleChangeLaunchOptions}
rightContent={
game.launchOptions && (
<Button onClick={handleClearLaunchOptions} theme="outline">
{t("clear")}
</Button>
)
}
/>
</div>
<div className={styles.gameOptionHeader}>
<h2>{t("downloads_secion_title")}</h2>

View File

@ -65,7 +65,7 @@ export function SettingsAccount() {
return () => {
unsubscribe();
};
}, [fetchUserDetails, updateUserDetails]);
}, [fetchUserDetails, updateUserDetails, showSuccessToast]);
const visibilityOptions = [
{ value: "PUBLIC", label: t("public") },

View File

@ -16,6 +16,6 @@ $spacing-unit: 8px;
$toast-z-index: 5;
$bottom-panel-z-index: 3;
$title-bar-z-index: 4;
$title-bar-z-index: 1900000001;
$backdrop-z-index: 4;
$modal-z-index: 5;

View File

@ -24,7 +24,7 @@ export const vars = createGlobalTheme(":root", {
zIndex: {
toast: "5",
bottomPanel: "3",
titleBar: "4",
titleBar: "1900000001",
backdrop: "4",
},
});

View File

@ -139,6 +139,7 @@ export interface UserDetails {
backgroundImageUrl: string | null;
profileVisibility: ProfileVisibility;
bio: string;
featurebaseJwt: string;
subscription: Subscription | null;
quirks?: {
backupsPerGameLimit: number;
@ -171,6 +172,7 @@ export interface UpdateProfileRequest {
profileImageUrl?: string | null;
backgroundImageUrl?: string | null;
bio?: string;
language?: string;
}
export interface DownloadSourceDownload {