diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..c902602 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,82 @@ +name: Build Plugin + +on: + push: + branches: + - main + pull_request_target: + branches: ['*'] + +jobs: + build: + name: Build plugin + runs-on: ubuntu-latest + + steps: + - name: Create variables + run: echo "TIMESTAMP=$(date +'%Y%m%d%H%M%S')" >> $GITHUB_ENV + + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + fetch-depth: 0 + submodules: "recursive" + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Append hash to plugin version + run: | + echo "::notice::This run was triggered by a commit. Appending the commit hash to the plugin version." + + PACKAGE_FILE="package.json" + + SHA=$(cut -c1-7 <<< "${{ github.event.pull_request.head.sha || github.sha }}") + VERSION=$(jq -r '.version' $PACKAGE_FILE) + NEW_VERSION="$VERSION-$SHA" + + echo "::notice::Going from $VERSION to $NEW_VERSION" + + tmp=$(mktemp) + jq --arg newversion "$NEW_VERSION" '.version = $newversion' $PACKAGE_FILE > $tmp + mv $tmp $PACKAGE_FILE + + echo "::endgroup::" + + - uses: pnpm/action-setup@v3 + + - name: Update pnpm lockfile + run: | + $(which pnpm) install --lockfile-only + + - name: Download Decky CLI + run: | + mkdir -p "$(pwd)"/cli + curl -L -o "$(pwd)"/cli/decky "https://github.com/SteamDeckHomebrew/cli/releases/latest/download/decky-linux-x86_64" + chmod +x "$(pwd)"/cli/decky + + - name: Build plugin + run: | + # Run the CLI as root to get around Docker's weird permissions + sudo .vscode/build.sh + sudo chown -R $(whoami) out + + - name: Unzip plugin + run: | + for file in out/*.zip; do + echo "Unzipping $file" + unzip -qq "$file" -d out + done + tree out + + - name: Upload Artifacts + uses: actions/upload-artifact@v4 + with: + name: output + path: out/*/ + + - name: Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ env.TIMESTAMP }} + prerelease: true + files: out/*.zip \ No newline at end of file diff --git a/assets/languages/en.json b/assets/languages/en.json index 576bbae..813d76c 100644 --- a/assets/languages/en.json +++ b/assets/languages/en.json @@ -49,5 +49,11 @@ "sync.logs": "Sync logs", "no.available.logs": "No available logs", "deleting.locks.sync": "Deleting locks and syncing", - "delete.locks": "Delete locks" + "delete.locks": "Delete locks", + "library.sync": "Library Sync", + "documents": "Documents", + "music": "Music", + "pictures": "Pictures", + "videos": "Videos", + "enabled": "Enabled" } \ No newline at end of file diff --git a/backend/rcloneLauncher b/backend/rcloneLauncher index 7230f16..7d39037 100644 --- a/backend/rcloneLauncher +++ b/backend/rcloneLauncher @@ -1,7 +1,8 @@ #!/usr/bin/env bash -PLUGIN_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) -CONFIG_DIR="$PLUGIN_DIR/../../../settings/decky-cloud-save" +BIN_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) +PLUGIN_DIR=$(realpath "$BIN_DIR"/..) +CONFIG_DIR=$(realpath "$PLUGIN_DIR"/../../settings/"$(basename "$PLUGIN_DIR")") -exec "$PLUGIN_DIR/rclone" "--config" "$CONFIG_DIR/rclone.conf" "$@" +exec "$BIN_DIR/rclone" "--config" "$CONFIG_DIR/rclone.conf" "$@" diff --git a/package.json b/package.json index b2966f5..6cf6a38 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "decky-cloud-save", "version": "1.4.2", "description": "Manage cloud saves for games that do not support it in [current year].", + "packageManager": "pnpm@9.8.0", "scripts": { "build": "shx rm -rf dist && rollup -c", "watch": "rollup -c -w", diff --git a/py_modules/plugin_config.py b/py_modules/plugin_config.py index 579107e..fd79173 100644 --- a/py_modules/plugin_config.py +++ b/py_modules/plugin_config.py @@ -1,6 +1,7 @@ import os from pathlib import Path import decky_plugin +from settings import SettingsManager as settings_manager # Plugin directories and files plugin_dir = Path(decky_plugin.DECKY_PLUGIN_DIR) @@ -12,22 +13,38 @@ cfg_syncpath_includes_file = config_dir / "sync_paths.txt" cfg_syncpath_excludes_file = config_dir / "sync_paths_excludes.txt" cfg_syncpath_filter_file = config_dir / "sync_paths_filter.txt" -cfg_property_file = config_dir / "plugin.properties" -def get_config(): +config = settings_manager(name="config", settings_directory=decky_plugin.DECKY_PLUGIN_SETTINGS_DIR) + +def get_cfg_property(): """ - Reads and parses the plugin configuration file. + Reads and parses the plugin.properties. + This is only used for migration from legacy config file and should not be used for any other purpose. Returns: list: A list of key-value pairs representing the configuration. """ + cfg_property_file = config_dir / "plugin.properties" + if not cfg_property_file.is_file(): + return [] with open(cfg_property_file) as f: lines = f.readlines() lines = list(map(lambda x: x.strip().split('='), lines)) decky_plugin.logger.debug("config %s", lines) + cfg_property_file.unlink() return lines -def set_config(key: str, value: str): +def get_config(): + """ + Retrieves the plugin configuration. + + Returns: + dict: The plugin configuration. + """ + config.read() + return config.settings + +def set_config(key: str, value): """ Sets a configuration key-value pair in the plugin configuration file. @@ -35,20 +52,9 @@ def set_config(key: str, value: str): key (str): The key to set. value (str): The value to set for the key. """ - with open(cfg_property_file, "r") as f: - lines = f.readlines() - with open(cfg_property_file, "w") as f: - found = False - for line in lines: - if line.startswith(key + '='): - f.write(f"{key}={value}\n") - found = True - else: - f.write(line) - if not found: - f.write(f"{key}={value}\n") - -def get_config_item(name: str, default: str = None): + config.setSetting(key, value) + +def get_config_item(name: str, default = None): """ Retrieves a configuration item by name. @@ -59,7 +65,8 @@ def get_config_item(name: str, default: str = None): Returns: str: The value of the configuration item. """ - return next((x[1] for x in get_config() if x[0] == name), default) + config.read() + return config.getSetting(name, default) def regenerate_filter_file(): """ @@ -82,6 +89,42 @@ def regenerate_filter_file(): f.write("\n") f.write("- **\n") +def get_library_sync_config(key: str = None): + """ + Retrieves the library sync configuration. + + Parameters: + key (str, optional): The key of the value to retrieve. If not provided, the entire configuration will be returned. + + Returns: + dict: The library sync configuration. + """ + config.read() + if key: + return config.getSetting("library_sync", {}).get(key, {"enabled": False, "bisync": False, "destination": f"deck-libraries/{key}"}) + else: + return config.getSetting("library_sync", {}) + +def set_library_sync_config(key: str, enabled: bool = None, bisync: bool = None, destination: str = None): + """ + Sets the library sync configuration. + + Parameters: + key (str): The key to set. + enabled (bool): Whether the key is enabled. + destination (str): The destination of the key. + """ + config.read() + if enabled == None: + enabled = get_library_sync_config(key).get("enabled", False) + if bisync == None: + bisync = get_library_sync_config(key).get(key, {}).get("bisync", False) + if destination == None: + destination = get_library_sync_config(key).get(key, {}).get("destination", f"deck-libraries/{key}") + library_sync_config = get_library_sync_config() + library_sync_config.update({key: {"enabled": enabled, "bisync": bisync, "destination": destination}}) + config.setSetting("library_sync", library_sync_config) + def migrate(): """ Performs migration tasks if necessary, like creating directories and files, and setting default configurations. @@ -92,23 +135,36 @@ def migrate(): cfg_syncpath_includes_file.touch() if not cfg_syncpath_excludes_file.is_file(): cfg_syncpath_excludes_file.touch() - if not cfg_property_file.is_file(): - cfg_property_file.touch() if not cfg_syncpath_filter_file.is_file(): regenerate_filter_file() - config = get_config() - if not any(e[0] == "destination_directory" for e in config): + # Migrate from plugin.properties to config.json + config_pairs_list = get_cfg_property() + for pair in config_pairs_list: + if pair[1] == "true": + pair[1] = True + elif pair[1] == "false": + pair[1] = False + set_config(pair[0], pair[1]) + + # Set default configurations + current_config = get_config() + if not "destination_directory" in current_config: set_config("destination_directory", "decky-cloud-save") - if not any(e[0] == "bisync_enabled" for e in config): - set_config("bisync_enabled", "false") - if not any(e[0] == "log_level" for e in config): + if not "bisync_enabled" in current_config: + set_config("bisync_enabled", False) + if not "log_level" in current_config: set_config("log_level", "INFO") - if not any(e[0] == "sync_on_game_exit" for e in config): - set_config("sync_on_game_exit", "true") - if not any(e[0] == "toast_auto_sync" for e in config): - set_config("toast_auto_sync", "true") - if not any(e[0] == "additional_sync_args" for e in config): - set_config("additional_sync_args", "") - if not any(e[0] == "sync_root" for e in config): + if not "sync_on_game_exit" in current_config: + set_config("sync_on_game_exit", True) + if not "toast_auto_sync" in current_config: + set_config("toast_auto_sync", True) + if not "additional_sync_args" in current_config: + set_config("additional_sync_args", []) + if not "sync_root" in current_config: set_config("sync_root", "/") + if not "library_sync" in current_config: + set_library_sync_config("Documents") + set_library_sync_config("Music") + set_library_sync_config("Pictures") + set_library_sync_config("Videos") diff --git a/py_modules/rclone_sync_manager.py b/py_modules/rclone_sync_manager.py index 5de02f5..8a47bca 100644 --- a/py_modules/rclone_sync_manager.py +++ b/py_modules/rclone_sync_manager.py @@ -6,6 +6,7 @@ import os import os.path from glob import glob +from pathlib import Path class RcloneSyncManager: @@ -20,32 +21,45 @@ async def delete_lock_files(self): os.remove(hgx) async def sync_now(self, winner: str, resync: bool): + for k, v in plugin_config.get_library_sync_config().items(): + if v.get("enabled", False): + await self.sync_now_internal( + [str(Path.home() / k), f"backend:{v.get('destination', f'deck-libraries/{k}')}"], + v.get("bisync", False), + winner, + resync + ) + + sync_root = plugin_config.get_config_item("sync_root", "/") + destination_path = plugin_config.get_config_item("destination_directory", "decky-cloud-save") + await self.sync_now_internal( + [sync_root, f"backend:{destination_path}", "--filter-from", plugin_config.cfg_syncpath_filter_file], + plugin_config.get_config_item("bisync_enabled", False), + winner, + resync + ) + + async def sync_now_internal(self, path_args: list, bisync: bool, winner: str, resync: bool): """ Initiates a synchronization process using rclone. Parameters: + path_args (list[str]): List of arguments for rclone, it contains the destination and filter path winner (str): The winner of any conflicts during synchronization. resync (bool): Whether to perform a resynchronization. - """ args = [] - bisync_enabled = plugin_config.get_config_item("bisync_enabled", "false") == "true" - destination_path = plugin_config.get_config_item("destination_directory", "decky-cloud-save") - sync_root = plugin_config.get_config_item("sync_root", "/") - - additional_args = [x for x in plugin_config.get_config_item("additional_sync_args", "").split(' ') if x] - - if bisync_enabled: + if bisync: args.extend(["bisync"]) decky_plugin.logger.debug("Using bisync") else: args.extend(["copy"]) decky_plugin.logger.debug("Using copy") - args.extend([sync_root, f"backend:{destination_path}", "--filter-from", - plugin_config.cfg_syncpath_filter_file, "--copy-links"]) - if bisync_enabled: + args.extend(path_args) + args.extend(["--copy-links"]) + if bisync: if resync: args.extend(["--resync-mode", winner, "--resync"]) else: @@ -53,8 +67,8 @@ async def sync_now(self, winner: str, resync: bool): args.extend(["--transfers", "8", "--checkers", "16", "--log-file", decky_plugin.DECKY_PLUGIN_LOG, "--log-format", "none", "-v"]) - - args.extend(additional_args) + + args.extend(plugin_config.get_config_item("additional_sync_args", [])) cmd = [plugin_config.rclone_bin, *args] @@ -73,7 +87,7 @@ async def probe(self): """ if not self.current_sync: return 0 - + if self.current_sync.returncode is not None: decky_plugin.logger.info("=== FINISHING SYNC ===") diff --git a/src/helpers/apiClient.ts b/src/helpers/apiClient.ts index 16ae095..a35351f 100644 --- a/src/helpers/apiClient.ts +++ b/src/helpers/apiClient.ts @@ -128,15 +128,15 @@ export class ApiClient { private static async syncNowInternal(showToast: boolean, winner: string, resync: boolean = false): Promise { Logger.info("Synchronizing"); const start = new Date(); - if (ApplicationState.getAppState().currentState.syncing === "true") { + if (ApplicationState.getAppState().currentState.syncing) { Toast.toast(Translator.translate("waiting.previous"), 2000); - while (ApplicationState.getAppState().currentState.syncing === "true") { + while (ApplicationState.getAppState().currentState.syncing) { await sleep(300); } } Storage.setSessionStorageItem("syncing", "true"); - ApplicationState.setAppState("syncing", "true"); + ApplicationState.setAppState("syncing", true); await ApplicationState.getServerApi().callPluginMethod("sync_now_internal", { winner, resync }); let exitCode = 0; @@ -164,7 +164,7 @@ export class ApiClient { pass = false; break; } - ApplicationState.setAppState("syncing", "false"); + ApplicationState.setAppState("syncing", false); Storage.setSessionStorageItem("syncing", "false"); let body; diff --git a/src/helpers/state.ts b/src/helpers/state.ts index 84bf33d..56e4f26 100644 --- a/src/helpers/state.ts +++ b/src/helpers/state.ts @@ -3,26 +3,48 @@ import { useEffect, useState } from "react"; import { Logger } from "./logger"; type State = { - sync_on_game_exit: string; - syncing: string; - bisync_enabled: string; - experimental_menu: string; - toast_auto_sync: string; + sync_on_game_exit: boolean; + syncing: boolean; + bisync_enabled: boolean; + experimental_menu: boolean; + toast_auto_sync: boolean; destination_directory: string; - playing: string + playing: boolean; + library_sync: LibrarySyncState; +}; + +type ValueOf = T[keyof T]; + +export type LibrarySyncState = { + Documents: LibrarySyncStateEntry; + Music: LibrarySyncStateEntry; + Pictures: LibrarySyncStateEntry; + Videos: LibrarySyncStateEntry; +}; + +interface LibrarySyncStateEntry { + enabled: boolean; + bisync: boolean; + destination: string; }; class AppState { private _subscribers: { id: number; callback: (e: State) => void }[] = []; private _currentState: State = { - syncing: "false", - sync_on_game_exit: "true", - bisync_enabled: "false", - experimental_menu: "false", - toast_auto_sync: "true", + syncing: false, + sync_on_game_exit: true, + bisync_enabled: false, + experimental_menu: false, + toast_auto_sync: true, destination_directory: "decky-cloud-save", - playing: "false" + playing: false, + library_sync: { + Documents: { enabled: false, bisync: false, destination: "deck-libraries/Documents" }, + Music: { enabled: false, bisync: false, destination: "deck-libraries/Music" }, + Pictures: { enabled: false, bisync: false, destination: "deck-libraries/Pictures" }, + Videos: { enabled: false, bisync: false, destination: "deck-libraries/Videos" }, + }, }; private _serverApi: ServerAPI = null!; @@ -47,21 +69,38 @@ class AppState { this._serverApi = serverApi; - const data = await serverApi.callPluginMethod<{}, string[][]>("get_config", {}); + const data = await serverApi.callPluginMethod<{}, State>("get_config", {}); if (data.success) { - data.result.forEach((e) => this.setState(e[0] as keyof State, e[1])); + for (const [key, value] of Object.entries(data.result)) { + this.setState(key as keyof State, value as ValueOf); + } } else { Logger.error(data); } } - public setState = (key: keyof State, value: string, persist = false) => { + public setLibSyncState = (key: keyof LibrarySyncState, values: Partial<{ enabled: boolean; bisync: boolean; destination: string }>, persist = false) => { + let entry = this._currentState.library_sync[key]; + if (values.enabled !== undefined) { + entry.enabled = values.enabled; + } + if (values.bisync !== undefined) { + entry.bisync = values.bisync; + } + if (values.destination !== undefined) { + entry.destination = values.destination; + } + + this.setState("library_sync", this._currentState.library_sync, persist); + } + + public setState = (key: keyof State, value: ValueOf, persist = false) => { this._currentState = { ...this.currentState, [key]: value }; Logger.debug("Setting '" + key + "' to '" + value + "' with persistence: " + persist); if (persist) { - this.serverApi.callPluginMethod<{ key: string; value: string }, null>("set_config", { key, value }).then(e => Logger.debug(e)); + this.serverApi.callPluginMethod<{ key: string; value: ValueOf }, null>("set_config", { key, value: this._currentState[key] }).then(e => Logger.debug(e)); } this._subscribers.forEach((e) => e.callback(this.currentState)); @@ -97,9 +136,9 @@ export class ApplicationState { private constructor(){ } - + private static appState = new AppState(); - + public static async initialize(serverApi: ServerAPI) { await this.appState.initialize(serverApi); } @@ -125,6 +164,7 @@ export class ApplicationState { return ApplicationState.appState; } public static setAppState = ApplicationState.appState.setState; + public static setLibSyncState = ApplicationState.appState.setLibSyncState; public static setbisync_enabled = ApplicationState.appState.setbisync_enabled; public static getServerApi = () => ApplicationState.appState.serverApi; } diff --git a/src/index.tsx b/src/index.tsx index 0dc1fe3..194fd46 100755 --- a/src/index.tsx +++ b/src/index.tsx @@ -16,11 +16,12 @@ import RenderPluginLogPage from "./pages/RenderPluginLogPage"; declare const appStore: any; export default definePlugin((serverApi: ServerAPI) => { - Storage.clearAllSessionStorage(); - ApplicationState.initialize(serverApi).then(async () => { - Backend.initialize(serverApi); - await Logger.initialize(); - await Translator.initialize(); + Storage.clearAllSessionStorage() + ApplicationState.initialize(serverApi).then( + async () => { + Backend.initialize(serverApi); + await Logger.initialize(); + await Translator.initialize(); }); serverApi.routerHook.addRoute("/dcs-configure-paths", () => , { exact: true }); @@ -30,7 +31,7 @@ export default definePlugin((serverApi: ServerAPI) => { const { unregister: removeGameExecutionListener } = SteamClient.GameSessions.RegisterForAppLifetimeNotifications((e: LifetimeNotification) => { const currentState = ApplicationState.getAppState().currentState; - if (currentState.sync_on_game_exit === "true") { + if (currentState.sync_on_game_exit) { const gameInfo = appStore.GetAppOverviewByGameID(e.unAppID); Logger.info((e.bRunning ? "Starting" : "Stopping") + " game '" + gameInfo.display_name + "' (" + e.unAppID + ")"); @@ -61,10 +62,10 @@ export default definePlugin((serverApi: ServerAPI) => { function syncGame(e: LifetimeNotification) { const currentState = ApplicationState.getAppState().currentState; - let toast = currentState.toast_auto_sync === "true"; + let toast = currentState.toast_auto_sync; if (e.bRunning) { // Only sync at start when bisync is enabled. No need to when its not. - if (currentState.bisync_enabled === "true") { + if (currentState.bisync_enabled) { if (toast) { Toast.toast(Translator.translate("synchronizing.savedata"), 2000); } diff --git a/src/pages/ConfigurePathsPage.tsx b/src/pages/ConfigurePathsPage.tsx index 2181139..1d86dcb 100644 --- a/src/pages/ConfigurePathsPage.tsx +++ b/src/pages/ConfigurePathsPage.tsx @@ -1,4 +1,4 @@ -import { PanelSectionRow, PanelSection, TextField } from "decky-frontend-lib"; +import { showModal, ConfirmModal, Focusable, PanelSectionRow, PanelSection, TextField, ToggleField, Field } from "decky-frontend-lib"; import { useEffect, useState } from "react"; import { PageProps } from "../helpers/types"; import AddNewPathButton from "../components/AddNewPathButton"; @@ -6,11 +6,12 @@ import { RenderExistingPathButton } from "../components/RenderExistingPathButton import Container from "../components/Container"; import { HelpAssistant } from "../components/HelpAssistant"; import { ApiClient } from "../helpers/apiClient"; -import { ApplicationState } from "../helpers/state"; +import { ApplicationState, LibrarySyncState } from "../helpers/state"; import { Translator } from "../helpers/translator"; export default function ConfigurePathsPage({ serverApi }: PageProps<{}>) { const appState = ApplicationState.useAppState(); + const [includePaths, setIncludePaths] = useState(undefined); const [excludePaths, setExcludePaths] = useState(undefined); @@ -24,6 +25,9 @@ export default function ConfigurePathsPage({ serverApi }: PageProps<{}>) { useEffect(() => onPathsUpdated(), []); return ( + +
+ ) { ))} + + + + + + + + + +
+
); + + interface LibrarySyncEntryProps { + title: string; + stateKey: keyof LibrarySyncState; + } + + function LibrarySyncEntry({ title, stateKey }: LibrarySyncEntryProps) { + return ( + + + +
+ ApplicationState.setLibSyncState(stateKey, {enabled: e}, true)}/> + ApplicationState.setLibSyncState(stateKey, {bisync: e}, true)}/> +
+
+ { + showModal( + ApplicationState.setLibSyncState(stateKey, {destination: appState.library_sync[stateKey].destination}, true)}> + ApplicationState.setLibSyncState(stateKey, {destination: e.target.value})} /> + + ); + }}/> +
+
+
+
+ ); + } } diff --git a/src/pages/RenderDCSMenu.tsx b/src/pages/RenderDCSMenu.tsx index cd989c5..4553043 100644 --- a/src/pages/RenderDCSMenu.tsx +++ b/src/pages/RenderDCSMenu.tsx @@ -27,12 +27,12 @@ export const Content: VFC<{}> = () => { { ApiClient.syncNow(true); }} > - }> + }> {Translator.translate("sync.now")} @@ -44,16 +44,16 @@ export const Content: VFC<{}> = () => { ApplicationState.setAppState("sync_on_game_exit", e ? "true" : "false", true)} + checked={appState.sync_on_game_exit} + onChange={(e) => ApplicationState.setAppState("sync_on_game_exit", e, true)} /> ApplicationState.setAppState("toast_auto_sync", e ? "true" : "false", true)} + checked={appState.toast_auto_sync} + onChange={(e) => ApplicationState.setAppState("toast_auto_sync", e, true)} /> @@ -103,7 +103,7 @@ export const Content: VFC<{}> = () => { { (async () => { let logs = await Backend.getLastSyncLog(); @@ -124,8 +124,8 @@ export const Content: VFC<{}> = () => { ApplicationState.setAppState("bisync_enabled", e ? "true" : "false", true)} + checked={appState.bisync_enabled} + onChange={(e) => ApplicationState.setAppState("bisync_enabled", e, true)} />