From 97f8d4d5154d566568fc475d6aaba5db07399b2b Mon Sep 17 00:00:00 2001 From: Lewis Crichton Date: Fri, 7 Apr 2023 01:27:18 +0100 Subject: [PATCH] feat: Cloud settings sync (#505) Co-authored-by: Ven --- src/Vencord.ts | 29 ++- src/api/settings.ts | 27 +++ src/components/VencordSettings/CloudTab.tsx | 164 +++++++++++++++ src/components/VencordSettings/index.tsx | 4 +- .../VencordSettings/settingsStyles.css | 11 + src/plugins/settings.tsx | 7 + src/utils/cloud.tsx | 124 ++++++++++++ src/utils/localStorage.ts | 19 ++ src/utils/settingsSync.ts | 191 +++++++++++++++++- 9 files changed, 564 insertions(+), 12 deletions(-) create mode 100644 src/components/VencordSettings/CloudTab.tsx create mode 100644 src/utils/cloud.tsx create mode 100644 src/utils/localStorage.ts diff --git a/src/Vencord.ts b/src/Vencord.ts index 73b53e84..a23b1a8b 100644 --- a/src/Vencord.ts +++ b/src/Vencord.ts @@ -30,17 +30,44 @@ import "./webpack/patchWebpack"; import { showNotification } from "./api/Notifications"; import { PlainSettings, Settings } from "./api/settings"; import { patches, PMLogger, startAllPlugins } from "./plugins"; -import { checkForUpdates, rebuild, update,UpdateLogger } from "./utils/updater"; +import { localStorage } from "./utils/localStorage"; +import { getCloudSettings, putCloudSettings } from "./utils/settingsSync"; +import { checkForUpdates, rebuild, update, UpdateLogger } from "./utils/updater"; import { onceReady } from "./webpack"; import { SettingsRouter } from "./webpack/common"; export let Components: any; +async function syncSettings() { + if ( + Settings.cloud.settingsSync && // if it's enabled + Settings.cloud.authenticated // if cloud integrations are enabled + ) { + if (localStorage.Vencord_settingsDirty) { + await putCloudSettings(); + delete localStorage.Vencord_settingsDirty; + } else if (await getCloudSettings(false)) { // if we synchronized something (false means no sync) + // we show a notification here instead of allowing getCloudSettings() to show one to declutter the amount of + // potential notifications that might occur. getCloudSettings() will always send a notification regardless if + // there was an error to notify the user, but besides that we only want to show one notification instead of all + // of the possible ones it has (such as when your settings are newer). + showNotification({ + title: "Cloud Settings", + body: "Your settings have been updated! Click here to restart to fully apply changes!", + color: "var(--green-360)", + onClick: () => window.DiscordNative.app.relaunch() + }); + } + } +} + async function init() { await onceReady; startAllPlugins(); Components = await import("./components"); + syncSettings(); + if (!IS_WEB) { try { const isOutdated = await checkForUpdates(); diff --git a/src/api/settings.ts b/src/api/settings.ts index 321a4c42..8a7d9ffd 100644 --- a/src/api/settings.ts +++ b/src/api/settings.ts @@ -16,9 +16,12 @@ * along with this program. If not, see . */ +import { debounce } from "@utils/debounce"; import IpcEvents from "@utils/IpcEvents"; +import { localStorage } from "@utils/localStorage"; import Logger from "@utils/Logger"; import { mergeDefaults } from "@utils/misc"; +import { putCloudSettings } from "@utils/settingsSync"; import { DefinedSettings, OptionType, SettingsChecks, SettingsDefinition } from "@utils/types"; import { React } from "@webpack/common"; @@ -49,6 +52,13 @@ export interface Settings { useNative: "always" | "never" | "not-focused"; logLimit: number; }; + + cloud: { + authenticated: boolean; + url: string; + settingsSync: boolean; + settingsSyncVersion: number; + }; } const DefaultSettings: Settings = { @@ -69,6 +79,13 @@ const DefaultSettings: Settings = { position: "bottom-right", useNative: "not-focused", logLimit: 50 + }, + + cloud: { + authenticated: false, + url: "https://api.vencord.dev/", + settingsSync: false, + settingsSyncVersion: 0 } }; @@ -80,6 +97,13 @@ try { logger.error("An error occurred while loading the settings. Corrupt settings file?\n", err); } +const saveSettingsOnFrequentAction = debounce(async () => { + if (Settings.cloud.settingsSync && Settings.cloud.authenticated) { + await putCloudSettings(); + delete localStorage.Vencord_settingsDirty; + } +}, 60_000); + type SubscriptionCallback = ((newValue: any, path: string) => void) & { _path?: string; }; const subscriptions = new Set(); @@ -142,6 +166,9 @@ function makeProxy(settings: any, root = settings, path = ""): Settings { } } // And don't forget to persist the settings! + PlainSettings.cloud.settingsSyncVersion = Date.now(); + localStorage.Vencord_settingsDirty = true; + saveSettingsOnFrequentAction(); VencordNative.ipc.invoke(IpcEvents.SET_SETTINGS, JSON.stringify(root, null, 4)); return true; } diff --git a/src/components/VencordSettings/CloudTab.tsx b/src/components/VencordSettings/CloudTab.tsx new file mode 100644 index 00000000..3452cef5 --- /dev/null +++ b/src/components/VencordSettings/CloudTab.tsx @@ -0,0 +1,164 @@ +/* + * Vencord, a modification for Discord's desktop app + * Copyright (c) 2023 Vendicated and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . +*/ + +import { showNotification } from "@api/Notifications"; +import { Settings, useSettings } from "@api/settings"; +import { CheckedTextInput } from "@components/CheckedTextInput"; +import ErrorBoundary from "@components/ErrorBoundary"; +import { Link } from "@components/Link"; +import { authorizeCloud, cloudLogger, deauthorizeCloud, getCloudAuth, getCloudUrl } from "@utils/cloud"; +import { Margins } from "@utils/margins"; +import { deleteCloudSettings, getCloudSettings, putCloudSettings } from "@utils/settingsSync"; +import { Alerts, Button, Forms, Switch, Tooltip } from "@webpack/common"; + +function validateUrl(url: string) { + try { + new URL(url); + return true; + } catch { + return "Invalid URL"; + } +} + +async function eraseAllData() { + const res = await fetch(new URL("/v1/", getCloudUrl()), { + method: "DELETE", + headers: new Headers({ + Authorization: await getCloudAuth() + }) + }); + + if (!res.ok) { + cloudLogger.error(`Failed to erase data, API returned ${res.status}`); + showNotification({ + title: "Cloud Integrations", + body: `Could not erase all data (API returned ${res.status}), please contact support.`, + color: "var(--red-360)" + }); + return; + } + + Settings.cloud.authenticated = false; + await deauthorizeCloud(); + + showNotification({ + title: "Cloud Integrations", + body: "Successfully erased all data.", + color: "var(--green-360)" + }); +} + +function SettingsSyncSection() { + const { cloud } = useSettings(["cloud.authenticated", "cloud.settingsSync"]); + const sectionEnabled = cloud.authenticated && cloud.settingsSync; + + return ( + + + Synchronize your settings to the cloud. This allows easy synchronization across multiple devices with + minimal effort. + + { cloud.settingsSync = v; }} + > + Settings Sync + +
+ + + {({ onMouseLeave, onMouseEnter }) => ( + + )} + + +
+
+ ); +} + +function CloudTab() { + const settings = useSettings(["cloud.authenticated", "cloud.url"]); + + return ( + <> + + + Vencord comes with a cloud integration that adds goodies like settings sync across devices. + It respects your privacy, and + the source code is AGPL 3.0 licensed so you + can host it yourself. + + { v && authorizeCloud(); if (!v) settings.cloud.authenticated = v; }} + note="This will request authorization if you have not yet set up cloud integrations." + > + Enable Cloud Integrations + + Backend URL + + Which backend to use when using cloud integrations. + + { settings.cloud.url = v; settings.cloud.authenticated = false; deauthorizeCloud(); }} + validate={validateUrl} + /> + + + + + + ); +} + +export default ErrorBoundary.wrap(CloudTab); diff --git a/src/components/VencordSettings/index.tsx b/src/components/VencordSettings/index.tsx index cd6ce604..c15944c7 100644 --- a/src/components/VencordSettings/index.tsx +++ b/src/components/VencordSettings/index.tsx @@ -24,6 +24,7 @@ import { handleComponentFailed } from "@components/handleComponentFailed"; import { Forms, SettingsRouter, TabBar, Text } from "@webpack/common"; import BackupRestoreTab from "./BackupRestoreTab"; +import CloudTab from "./CloudTab"; import PluginsTab from "./PluginsTab"; import ThemesTab from "./ThemesTab"; import Updater from "./Updater"; @@ -45,7 +46,8 @@ const SettingsTabs: Record = { VencordPlugins: { name: "Plugins", component: () => }, VencordThemes: { name: "Themes", component: () => }, VencordUpdater: { name: "Updater" }, // Only show updater if IS_WEB is false - VencordSettingsSync: { name: "Backup & Restore", component: () => }, + VencordCloud: { name: "Cloud", component: () => }, + VencordSettingsSync: { name: "Backup & Restore", component: () => } }; if (!IS_WEB) SettingsTabs.VencordUpdater.component = () => Updater && ; diff --git a/src/components/VencordSettings/settingsStyles.css b/src/components/VencordSettings/settingsStyles.css index 709c8236..ebc112c0 100644 --- a/src/components/VencordSettings/settingsStyles.css +++ b/src/components/VencordSettings/settingsStyles.css @@ -46,3 +46,14 @@ padding: 0.5em; border: 1px solid var(--background-modifier-accent); } + +.vc-cloud-settings-sync-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + grid-gap: 1em; +} + +.vc-cloud-erase-data-danger-btn { + color: var(--white-500); + background-color: var(--button-danger-background); +} diff --git a/src/plugins/settings.tsx b/src/plugins/settings.tsx index 8db0a4e0..c8a6372a 100644 --- a/src/plugins/settings.tsx +++ b/src/plugins/settings.tsx @@ -106,6 +106,13 @@ export default definePlugin({ onClick: makeOnClick("VencordUpdater") }); + cats.push({ + section: "VencordCloud", + label: "Cloud", + element: () => , + onClick: makeOnClick("VencordCloud") + }); + cats.push({ section: "VencordSettingsSync", label: "Backup & Restore", diff --git a/src/utils/cloud.tsx b/src/utils/cloud.tsx new file mode 100644 index 00000000..b31091f0 --- /dev/null +++ b/src/utils/cloud.tsx @@ -0,0 +1,124 @@ +/* + * Vencord, a modification for Discord's desktop app + * Copyright (c) 2023 Vendicated and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . +*/ + +import * as DataStore from "@api/DataStore"; +import { showNotification } from "@api/Notifications"; +import { Settings } from "@api/settings"; +import { findByProps } from "@webpack"; +import { UserStore } from "@webpack/common"; + +import Logger from "./Logger"; +import { openModal } from "./modal"; + +export const cloudLogger = new Logger("Cloud", "#39b7e0"); +export const getCloudUrl = () => new URL(Settings.cloud.url); + +export async function getAuthorization() { + const secrets = await DataStore.get>("Vencord_cloudSecret") ?? {}; + return secrets[getCloudUrl().origin]; +} + +async function setAuthorization(secret: string) { + await DataStore.update>("Vencord_cloudSecret", secrets => { + secrets ??= {}; + secrets[getCloudUrl().origin] = secret; + return secrets; + }); +} + +export async function deauthorizeCloud() { + await DataStore.update>("Vencord_cloudSecret", secrets => { + secrets ??= {}; + delete secrets[getCloudUrl().origin]; + return secrets; + }); +} + +export async function authorizeCloud() { + if (await getAuthorization() !== undefined) { + Settings.cloud.authenticated = true; + return; + } + + try { + const oauthConfiguration = await fetch(new URL("/v1/oauth/settings", getCloudUrl())); + var { clientId, redirectUri } = await oauthConfiguration.json(); + } catch { + showNotification({ + title: "Cloud Integration", + body: "Setup failed (couldn't retrieve OAuth configuration)." + }); + Settings.cloud.authenticated = false; + return; + } + + const { OAuth2AuthorizeModal } = findByProps("OAuth2AuthorizeModal"); + + openModal((props: any) => { + if (!callbackUrl) { + Settings.cloud.authenticated = false; + return; + } + + try { + const res = await fetch(callbackUrl, { + headers: new Headers({ Accept: "application/json" }) + }); + const { secret } = await res.json(); + if (secret) { + cloudLogger.info("Authorized with secret"); + await setAuthorization(secret); + showNotification({ + title: "Cloud Integration", + body: "Cloud integrations enabled!" + }); + Settings.cloud.authenticated = true; + } else { + showNotification({ + title: "Cloud Integration", + body: "Setup failed (no secret returned?)." + }); + Settings.cloud.authenticated = false; + } + } catch (e: any) { + cloudLogger.error("Failed to authorize", e); + showNotification({ + title: "Cloud Integration", + body: `Setup failed (${e.toString()}).` + }); + Settings.cloud.authenticated = false; + } + } + } + />); +} + +export async function getCloudAuth() { + const userId = UserStore.getCurrentUser().id; + const secret = await getAuthorization(); + + return window.btoa(`${secret}:${userId}`); +} diff --git a/src/utils/localStorage.ts b/src/utils/localStorage.ts new file mode 100644 index 00000000..8730bb2c --- /dev/null +++ b/src/utils/localStorage.ts @@ -0,0 +1,19 @@ +/* + * Vencord, a modification for Discord's desktop app + * Copyright (c) 2023 Vendicated and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . +*/ + +export const { localStorage } = window; diff --git a/src/utils/settingsSync.ts b/src/utils/settingsSync.ts index 781899f0..d59787d5 100644 --- a/src/utils/settingsSync.ts +++ b/src/utils/settingsSync.ts @@ -16,8 +16,12 @@ * along with this program. If not, see . */ +import { showNotification } from "@api/Notifications"; +import { PlainSettings, Settings } from "@api/settings"; import { Toasts } from "@webpack/common"; +import { deflateSync, inflateSync } from "fflate"; +import { getCloudAuth, getCloudUrl } from "./cloud"; import IpcEvents from "./IpcEvents"; import Logger from "./Logger"; @@ -64,17 +68,18 @@ export async function downloadSettingsBackup() { } } -const toastSuccess = () => Toasts.show({ - type: Toasts.Type.SUCCESS, - message: "Settings successfully imported. Restart to apply changes!", - id: Toasts.genId() -}); +const toast = (type: number, message: string) => + Toasts.show({ + type, + message, + id: Toasts.genId() + }); -const toastFailure = (err: any) => Toasts.show({ - type: Toasts.Type.FAILURE, - message: `Failed to import settings: ${String(err)}`, - id: Toasts.genId() -}); +const toastSuccess = () => + toast(Toasts.Type.SUCCESS, "Settings successfully imported. Restart to apply changes!"); + +const toastFailure = (err: any) => + toast(Toasts.Type.FAILURE, `Failed to import settings: ${String(err)}`); export async function uploadSettingsBackup(showToast = true): Promise { if (IS_DISCORD_DESKTOP) { @@ -121,3 +126,169 @@ export async function uploadSettingsBackup(showToast = true): Promise { setImmediate(() => document.body.removeChild(input)); } } + +// Cloud settings +const cloudSettingsLogger = new Logger("Cloud:Settings", "#39b7e0"); + +export async function putCloudSettings() { + const settings = await exportSettings(); + + try { + const res = await fetch(new URL("/v1/settings", getCloudUrl()), { + method: "PUT", + headers: new Headers({ + Authorization: await getCloudAuth(), + "Content-Type": "application/octet-stream" + }), + body: deflateSync(new TextEncoder().encode(settings)) + }); + + if (!res.ok) { + cloudSettingsLogger.error(`Failed to sync up, API returned ${res.status}`); + showNotification({ + title: "Cloud Settings", + body: `Could not synchronize settings to cloud (API returned ${res.status}).`, + color: "var(--red-360)" + }); + return; + } + + const { written } = await res.json(); + PlainSettings.cloud.settingsSyncVersion = written; + VencordNative.ipc.invoke(IpcEvents.SET_SETTINGS, JSON.stringify(PlainSettings, null, 4)); + + cloudSettingsLogger.info("Settings uploaded to cloud successfully"); + showNotification({ + title: "Cloud Settings", + body: "Synchronized your settings to the cloud!", + color: "var(--green-360)" + }); + } catch (e: any) { + cloudSettingsLogger.error("Failed to sync up", e); + showNotification({ + title: "Cloud Settings", + body: `Could not synchronize settings to the cloud (${e.toString()}).`, + color: "var(--red-360)" + }); + } +} + +export async function getCloudSettings(shouldNotify = true, force = false) { + try { + const res = await fetch(new URL("/v1/settings", getCloudUrl()), { + method: "GET", + headers: new Headers({ + Authorization: await getCloudAuth(), + Accept: "application/octet-stream", + "If-None-Match": Settings.cloud.settingsSyncVersion.toString() + }), + }); + + if (res.status === 404) { + cloudSettingsLogger.info("No settings on the cloud"); + if (shouldNotify) + showNotification({ + title: "Cloud Settings", + body: "There are no settings in the cloud." + }); + return false; + } + + if (res.status === 304) { + cloudSettingsLogger.info("Settings up to date"); + if (shouldNotify) + showNotification({ + title: "Cloud Settings", + body: "Your settings are up to date." + }); + return false; + } + + if (!res.ok) { + cloudSettingsLogger.error(`Failed to sync down, API returned ${res.status}`); + showNotification({ + title: "Cloud Settings", + body: `Could not synchronize settings from the cloud (API returned ${res.status}).`, + color: "var(--red-360)" + }); + return false; + } + + const written = Number(res.headers.get("etag")!); + const localWritten = Settings.cloud.settingsSyncVersion; + + // don't need to check for written > localWritten because the server will return 304 due to if-none-match + if (!force && written < localWritten) { + if (shouldNotify) + showNotification({ + title: "Cloud Settings", + body: "Your local settings are newer than the cloud ones." + }); + return; + } + + const data = await res.arrayBuffer(); + + const settings = new TextDecoder().decode(inflateSync(new Uint8Array(data))); + await importSettings(settings); + + // sync with server timestamp instead of local one + PlainSettings.cloud.settingsSyncVersion = written; + VencordNative.ipc.invoke(IpcEvents.SET_SETTINGS, JSON.stringify(PlainSettings, null, 4)); + + cloudSettingsLogger.info("Settings loaded from cloud successfully"); + if (shouldNotify) + showNotification({ + title: "Cloud Settings", + body: "Your settings have been updated! Click here to restart to fully apply changes!", + color: "var(--green-360)", + onClick: () => window.DiscordNative.app.relaunch() + }); + + return true; + } catch (e: any) { + cloudSettingsLogger.error("Failed to sync down", e); + showNotification({ + title: "Cloud Settings", + body: `Could not synchronize settings from the cloud (${e.toString()}).`, + color: "var(--red-360)" + }); + + return false; + } +} + +export async function deleteCloudSettings() { + try { + const res = await fetch(new URL("/v1/settings", getCloudUrl()), { + method: "DELETE", + headers: new Headers({ + Authorization: await getCloudAuth() + }), + }); + + if (!res.ok) { + cloudSettingsLogger.error(`Failed to delete, API returned ${res.status}`); + showNotification({ + title: "Cloud Settings", + body: `Could not delete settings (API returned ${res.status}).`, + color: "var(--red-360)" + }); + return; + } + + cloudSettingsLogger.info("Settings deleted from cloud successfully"); + showNotification({ + title: "Cloud Settings", + body: "Settings deleted from cloud!", + color: "var(--green-360)" + }); + } catch (e: any) { + cloudSettingsLogger.error("Failed to delete", e); + showNotification({ + title: "Cloud Settings", + body: `Could not delete settings (${e.toString()}).`, + color: "var(--red-360)" + }); + } +}