Repository: hydralauncher/hydra
Pull request #1722: feat: persist playtime if sync fails after closing app
Author: login: zamitto
Created: 2025-05-22T16:14:04Z | Merged: 2025-05-22T17:49:21Z



Diff:
diff --git a/src/main/constants.ts b/src/main/constants.ts
index 243891e76..16642d507 100644
--- a/src/main/constants.ts
+++ b/src/main/constants.ts
@@ -26,11 +26,10 @@ export const commonRedistPath = path.join(
   "CommonRedist"
 );
 
-export const logsPath = path.join(SystemPath.getPath("userData"), "logs");
-
-export const seedsPath = app.isPackaged
-  ? path.join(process.resourcesPath, "seeds")
-  : path.join(__dirname, "..", "..", "seeds");
+export const logsPath = path.join(
+  SystemPath.getPath("userData"),
+  `logs${isStaging ? "-staging" : ""}`
+);
 
 export const achievementSoundPath = app.isPackaged
   ? path.join(process.resourcesPath, "achievement.wav")
diff --git a/src/main/index.ts b/src/main/index.ts
index 5151b9567..de88a548f 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -4,7 +4,7 @@ import i18n from "i18next";
 import path from "node:path";
 import url from "node:url";
 import { electronApp, optimizer } from "@electron-toolkit/utils";
-import { logger, WindowManager } from "@main/services";
+import { logger, clearGamesPlaytime, WindowManager } from "@main/services";
 import resources from "@locales";
 import { PythonRPC } from "./services/python-rpc";
 import { db, levelKeys } from "./level";
@@ -143,9 +143,17 @@ app.on("window-all-closed", () => {
   WindowManager.mainWindow = null;
 });
 
-app.on("before-quit", () => {
-  /* Disconnects libtorrent */
-  PythonRPC.kill();
+let canAppBeClosed = false;
+
+app.on("before-quit", async (e) => {
+  if (!canAppBeClosed) {
+    e.preventDefault();
+    /* Disconnects libtorrent */
+    PythonRPC.kill();
+    await clearGamesPlaytime();
+    canAppBeClosed = true;
+    app.quit();
+  }
 });
 
 app.on("activate", () => {
diff --git a/src/main/services/process-watcher.ts b/src/main/services/process-watcher.ts
index 1bd94e2b7..deb93f4e9 100644
--- a/src/main/services/process-watcher.ts
+++ b/src/main/services/process-watcher.ts
@@ -28,7 +28,7 @@ interface GameExecutables {
   [key: string]: ExecutableInfo[];
 }
 
-const TICKS_TO_UPDATE_API = 120;
+const TICKS_TO_UPDATE_API = 80;
 let currentTick = 1;
 
 const isWindowsPlatform = process.platform === "win32";
@@ -225,7 +225,18 @@ function onOpenGame(game: Game) {
   });
 
   if (game.remoteId) {
-    updateGamePlaytime(game, 0, new Date()).catch(() => {});
+    updateGamePlaytime(
+      game,
+      game.unsyncedDeltaPlayTimeInMilliseconds ?? 0,
+      new Date()
+    )
+      .then(() => {
+        gamesSublevel.put(levelKeys.game(game.shop, game.objectId), {
+          ...game,
+          unsyncedDeltaPlayTimeInMilliseconds: 0,
+        });
+      })
+      .catch(() => {});
 
     if (game.automaticCloudSync) {
       CloudSync.uploadSaveGame(
@@ -260,22 +271,34 @@ function onTickGame(game: Game) {
   });
 
   if (currentTick % TICKS_TO_UPDATE_API === 0) {
+    const deltaToSync =
+      now -
+      gamePlaytime.lastSyncTick +
+      (game.unsyncedDeltaPlayTimeInMilliseconds ?? 0);
+
     const gamePromise = game.remoteId
-      ? updateGamePlaytime(
-          game,
-          now - gamePlaytime.lastSyncTick,
-          game.lastTimePlayed!
-        )
+      ? updateGamePlaytime(game, deltaToSync, game.lastTimePlayed!)
       : createGame(game);
 
     gamePromise
       .then(() => {
+        gamesSublevel.put(levelKeys.game(game.shop, game.objectId), {
+          ...game,
+          unsyncedDeltaPlayTimeInMilliseconds: 0,
+        });
+      })
+      .catch(() => {
+        gamesSublevel.put(levelKeys.game(game.shop, game.objectId), {
+          ...game,
+          unsyncedDeltaPlayTimeInMilliseconds: deltaToSync,
+        });
+      })
+      .finally(() => {
         gamesPlaytime.set(levelKeys.game(game.shop, game.objectId), {
           ...gamePlaytime,
           lastSyncTick: now,
         });
-      })
-      .catch(() => {});
+      });
   }
 }
 
@@ -286,12 +309,6 @@ const onCloseGame = (game: Game) => {
   gamesPlaytime.delete(levelKeys.game(game.shop, game.objectId));
 
   if (game.remoteId) {
-    updateGamePlaytime(
-      game,
-      performance.now() - gamePlaytime.lastSyncTick,
-      game.lastTimePlayed!
-    ).catch(() => {});
-
     if (game.automaticCloudSync) {
       CloudSync.uploadSaveGame(
         game.objectId,
@@ -300,7 +317,38 @@ const onCloseGame = (game: Game) => {
         CloudSync.getBackupLabel(true)
       );
     }
+
+    const deltaToSync =
+      performance.now() -
+      gamePlaytime.lastSyncTick +
+      (game.unsyncedDeltaPlayTimeInMilliseconds ?? 0);
+
+    return updateGamePlaytime(game, deltaToSync, game.lastTimePlayed!)
+      .then(() => {
+        return gamesSublevel.put(levelKeys.game(game.shop, game.objectId), {
+          ...game,
+          unsyncedDeltaPlayTimeInMilliseconds: 0,
+        });
+      })
+      .catch(() => {
+        return gamesSublevel.put(levelKeys.game(game.shop, game.objectId), {
+          ...game,
+          unsyncedDeltaPlayTimeInMilliseconds: deltaToSync,
+        });
+      });
   } else {
-    createGame(game).catch(() => {});
+    return createGame(game).catch(() => {});
+  }
+};
+
+export const clearGamesPlaytime = async () => {
+  for (const game of gamesPlaytime.keys()) {
+    const gameData = await gamesSublevel.get(game);
+
+    if (gameData) {
+      await onCloseGame(gameData);
+    }
   }
+
+  gamesPlaytime.clear();
 };
diff --git a/src/types/level.types.ts b/src/types/level.types.ts
index fd904ca53..6a729cc5f 100644
--- a/src/types/level.types.ts
+++ b/src/types/level.types.ts
@@ -34,6 +34,7 @@ export interface Game {
   title: string;
   iconUrl: string | null;
   playTimeInMilliseconds: number;
+  unsyncedDeltaPlayTimeInMilliseconds?: number;
   lastTimePlayed: Date | null;
   objectId: string;
   shop: GameShop;


Reviews:
user: login: greptile-apps[bot]
body: 
## PR Summary

This PR enhances game playtime persistence by implementing safeguards against data loss during app closure and sync failures.

- Added `unsyncedDeltaPlayTimeInMilliseconds` in `src/types/level.types.ts` to track unsynced playtime data
- Modified `src/main/index.ts` to prevent premature app closure with async cleanup handling
- Reduced `TICKS_TO_UPDATE_API` from 120 to 80 in `src/main/services/process-watcher.ts` for more frequent syncs
- Added `clearGamesPlaytime` function in process-watcher for proper state cleanup
- **Warning**: Hardcoded `isStaging = true` in `src/main/constants.ts` could affect production deployments

<sub>4 file(s) reviewed, 4 comment(s)</sub>
<sub>[Edit PR Review Bot Settings](https://app.greptile.com/review/github) | [Greptile](https://greptile.com?utm_source=greptile_expert&utm_medium=github&utm_campaign=code_reviews&utm_content=hydra_1722)</sub>
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T16:14:56Z
user: login: chubbygrannychaser
body: LGTM
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T16:30:41Z

Review comments:
diff_hunk: @@ -143,9 +143,17 @@ app.on("window-all-closed", () => {
   WindowManager.mainWindow = null;
 });
 
-app.on("before-quit", () => {
-  /* Disconnects libtorrent */
-  PythonRPC.kill();
+let canAppBeClose = false;
path: src/main/index.ts
user: login: greptile-apps[bot]
body: style: Consider moving canAppBeClose into WindowManager to better encapsulate app state
created_at: 2025-05-22T16:14:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -4,7 +4,8 @@ import { SystemPath } from "./services/system-path";
 
 export const defaultDownloadsPath = SystemPath.getPath("downloads");
 
-export const isStaging = import.meta.env.MAIN_VITE_API_URL.includes("staging");
+export const isStaging =
+  true || import.meta.env.MAIN_VITE_API_URL.includes("staging");
path: src/main/constants.ts
user: login: greptile-apps[bot]
body: logic: Hardcoding `true ||` forces staging mode in all environments, which could affect production deployments

```suggestion
export const isStaging =
  import.meta.env.MAIN_VITE_API_URL.includes("staging");
```
created_at: 2025-05-22T16:14:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -260,22 +271,34 @@ function onTickGame(game: Game) {
   });
 
   if (currentTick % TICKS_TO_UPDATE_API === 0) {
+    const deltaToSync =
+      now -
+      gamePlaytime.lastSyncTick +
+      (game.unsyncedDeltaPlayTimeInMilliseconds ?? 0);
path: src/main/services/process-watcher.ts
user: login: greptile-apps[bot]
body: logic: Race condition risk: deltaToSync calculation uses 'now' which could be stale if previous promises in the chain took time to resolve
created_at: 2025-05-22T16:14:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -260,22 +271,34 @@ function onTickGame(game: Game) {
   });
 
   if (currentTick % TICKS_TO_UPDATE_API === 0) {
+    const deltaToSync =
+      now -
+      gamePlaytime.lastSyncTick +
+      (game.unsyncedDeltaPlayTimeInMilliseconds ?? 0);
+
     const gamePromise = game.remoteId
-      ? updateGamePlaytime(
-          game,
-          now - gamePlaytime.lastSyncTick,
-          game.lastTimePlayed!
-        )
+      ? updateGamePlaytime(game, deltaToSync, game.lastTimePlayed!)
       : createGame(game);
 
     gamePromise
       .then(() => {
+        gamesSublevel.put(levelKeys.game(game.shop, game.objectId), {
+          ...game,
+          unsyncedDeltaPlayTimeInMilliseconds: 0,
+        });
+      })
path: src/main/services/process-watcher.ts
user: login: greptile-apps[bot]
body: logic: Potential state inconsistency: game object used in put operation could be stale since it wasn't re-fetched after previous operations
created_at: 2025-05-22T16:14:38Z
author_association: CONTRIBUTOR

PR comments:
user: login: sonarqubecloud[bot]
created_at: 2025-05-22T17:46:47Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=hydralauncher_hydra&pullRequest=1722) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [2 New issues](https://sonarcloud.io/project/issues?id=hydralauncher_hydra&pullRequest=1722&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=hydralauncher_hydra&pullRequest=1722&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=hydralauncher_hydra&pullRequest=1722&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=hydralauncher_hydra&pullRequest=1722&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=hydralauncher_hydra&pullRequest=1722&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=hydralauncher_hydra&pullRequest=1722)


author_association: NONE