Cloud Sync: Add sync direction option for single source of truth (#3834)

Co-authored-by: V <vendicated@riseup.net>
This commit is contained in:
lewisakura 2025-12-19 23:02:05 +00:00 committed by GitHub
parent ea271496a3
commit bfc356b69e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 265 additions and 120 deletions

View file

@ -39,7 +39,7 @@ import { get as dsGet } from "./api/DataStore";
import { NotificationData, showNotification } from "./api/Notifications";
import { initPluginManager, PMLogger, startAllPlugins } from "./api/PluginManager";
import { PlainSettings, Settings, SettingsStore } from "./api/Settings";
import { getCloudSettings, putCloudSettings } from "./api/SettingsSync/cloudSync";
import { getCloudSettings, putCloudSettings, shouldCloudSync } from "./api/SettingsSync/cloudSync";
import { localStorage } from "./utils/localStorage";
import { relaunch } from "./utils/native";
import { checkForUpdates, update, UpdateLogger } from "./utils/updater";
@ -52,6 +52,11 @@ if (IS_REPORTER) {
}
async function syncSettings() {
if (localStorage.Vencord_cloudSyncDirection === undefined) {
// by default, sync bi-directionally
localStorage.Vencord_cloudSyncDirection = "both";
}
// pre-check for local shared settings
if (
Settings.cloud.authenticated &&
@ -70,12 +75,12 @@ async function syncSettings() {
if (
Settings.cloud.settingsSync && // if it's enabled
Settings.cloud.authenticated // if cloud integrations are enabled
Settings.cloud.authenticated && // if cloud integrations are enabled
localStorage.Vencord_cloudSyncDirection !== "manual" // if we're not in manual mode
) {
if (localStorage.Vencord_settingsDirty) {
if (localStorage.Vencord_settingsDirty && shouldCloudSync("push")) {
await putCloudSettings();
delete localStorage.Vencord_settingsDirty;
} else if (await getCloudSettings(false)) { // if we synchronized something (false means no sync)
} else if (shouldCloudSync("pull") && 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
@ -90,9 +95,8 @@ async function syncSettings() {
}
const saveSettingsOnFrequentAction = debounce(async () => {
if (Settings.cloud.settingsSync && Settings.cloud.authenticated) {
if (Settings.cloud.settingsSync && Settings.cloud.authenticated && shouldCloudSync("push")) {
await putCloudSettings();
delete localStorage.Vencord_settingsDirty;
}
}, 60_000);

View file

@ -6,6 +6,7 @@
import { showNotification } from "@api/Notifications";
import { PlainSettings, Settings } from "@api/Settings";
import { localStorage } from "@utils/localStorage";
import { Logger } from "@utils/Logger";
import { relaunch } from "@utils/native";
import { deflateSync, inflateSync } from "fflate";
@ -15,6 +16,12 @@ import { exportSettings, importSettings } from "./offline";
const logger = new Logger("SettingsSync:Cloud", "#39b7e0");
export function shouldCloudSync(direction: "push" | "pull") {
const localDirection = localStorage.Vencord_cloudSyncDirection;
return localDirection === direction || localDirection === "both";
}
export async function putCloudSettings(manual?: boolean) {
const settings = await exportSettings({ minify: true });
@ -53,6 +60,8 @@ export async function putCloudSettings(manual?: boolean) {
noPersist: true,
});
}
delete localStorage.Vencord_settingsDirty;
} catch (e: any) {
logger.error("Failed to sync up", e);
showNotification({
@ -141,6 +150,8 @@ export async function getCloudSettings(shouldNotify = true, force = false) {
noPersist: true
});
delete localStorage.Vencord_settingsDirty;
return true;
} catch (e: any) {
logger.error("Failed to sync down", e);

View file

@ -124,11 +124,11 @@
}
.vc-btn-positive {
background-color: var(--redesign-button-positive-background);
background-color: var(--redesign-button-positive-background, var(--green-430));
color: var(--white);
&:hover {
background-color: var(--redesign-button-positive-pressed-background);
background-color: var(--redesign-button-positive-pressed-background, var(--green-460));
}
}

View file

@ -542,3 +542,31 @@ export function VesktopSettingsIcon(props: IconProps) {
</Icon>
);
}
export function CloudDownloadIcon(props: IconProps) {
return (
<Icon
{...props}
viewBox="0 0 24 24"
>
<path
fill={props.fill || "currentColor"}
d="M6.5 20Q4.22 20 2.61 18.43 1 16.85 1 14.58 1 12.63 2.17 11.1 3.35 9.57 5.25 9.15 5.83 7.13 7.39 5.75 8.95 4.38 11 4.08V12.15L9.4 10.6L8 12L12 16L16 12L14.6 10.6L13 12.15V4.08Q15.58 4.43 17.29 6.39 19 8.35 19 11 20.73 11.2 21.86 12.5 23 13.78 23 15.5 23 17.38 21.69 18.69 20.38 20 18.5 20Z"
/>
</Icon>
);
}
export function CloudUploadIcon(props: IconProps) {
return (
<Icon
{...props}
viewBox="0 0 24 24"
>
<path
fill={props.fill || "currentColor"}
d="M11 20H6.5Q4.22 20 2.61 18.43 1 16.85 1 14.58 1 12.63 2.17 11.1 3.35 9.57 5.25 9.15 5.88 6.85 7.75 5.43 9.63 4 12 4 14.93 4 16.96 6.04 19 8.07 19 11 20.73 11.2 21.86 12.5 23 13.78 23 15.5 23 17.38 21.69 18.69 20.38 20 18.5 20H13V12.85L14.6 14.4L16 13L12 9L8 13L9.4 14.4L11 12.85Z"
/>
</Icon>
);
}

View file

@ -43,6 +43,7 @@
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1em;
margin-top: 16px;
}
.vc-cloud-erase-data-danger-btn {
@ -50,6 +51,16 @@
background-color: var(--redesign-button-danger-background);
}
.vc-cloud-icon-with-button {
display: flex;
gap: 0.5em;
padding-inline: 0.5em 1em;
}
.vc-cloud-button-icon {
height: 1.25em;
}
.vc-settings-modal {
padding: 1.5em !important;
}

View file

@ -19,15 +19,23 @@
import { useSettings } from "@api/Settings";
import { authorizeCloud, deauthorizeCloud } from "@api/SettingsSync/cloudSetup";
import { deleteCloudSettings, eraseAllCloudData, getCloudSettings, putCloudSettings } from "@api/SettingsSync/cloudSync";
import { BaseText } from "@components/BaseText";
import { Button, ButtonProps } from "@components/Button";
import { CheckedTextInput } from "@components/CheckedTextInput";
import { Divider } from "@components/Divider";
import { Flex } from "@components/Flex";
import { FormSwitch } from "@components/FormSwitch";
import { Grid } from "@components/Grid";
import { Heading } from "@components/Heading";
import { CloudDownloadIcon, CloudUploadIcon, DeleteIcon, RestartIcon } from "@components/Icons";
import { Link } from "@components/Link";
import { Paragraph } from "@components/Paragraph";
import { SettingsTab, wrapTab } from "@components/settings/tabs/BaseTab";
import { localStorage } from "@utils/localStorage";
import { Margins } from "@utils/margins";
import { Alerts, Button, Forms, Tooltip } from "@webpack/common";
import { classes } from "@utils/misc";
import { IconComponent } from "@utils/types";
import { Alerts, Select, Tooltip } from "@webpack/common";
function validateUrl(url: string) {
try {
@ -38,131 +46,214 @@ function validateUrl(url: string) {
}
}
const SectionHeading = ({ text }: { text: string; }) => (
<BaseText
tag="h5"
size="lg"
weight="semibold"
className={Margins.bottom16}
>
{text}
</BaseText>
);
function ButtonWithIcon({ children, Icon, className, ...buttonProps }: ButtonProps & { Icon: IconComponent; }) {
return (
<Button {...buttonProps} className={classes("vc-cloud-icon-with-button", className)}>
<Icon className={"vc-cloud-button-icon"} />
{children}
</Button>
);
}
function CloudSetupSection() {
const { cloud } = useSettings(["cloud.authenticated", "cloud.url"]);
return (
<section>
<SectionHeading text="Cloud Integrations" />
<Paragraph size="md" className={Margins.bottom20}>
Vencord comes with a cloud integration that adds goodies like settings sync across devices.
It <Link href="https://vencord.dev/cloud/privacy">respects your privacy</Link>, and
the <Link href="https://github.com/Vencord/Backend">source code</Link> is AGPL 3.0 licensed so you
can host it yourself.
</Paragraph>
<FormSwitch
key="backend"
title="Enable Cloud Integrations"
description="This will request authorization if you have not yet set up cloud integrations."
value={cloud.authenticated}
onChange={v => {
if (v)
authorizeCloud();
else
cloud.authenticated = v;
}}
/>
<Heading tag="h5" className={Margins.top16}>Backend URL</Heading>
<Paragraph className={Margins.bottom8}>
Which backend to use when using cloud integrations.
</Paragraph>
<CheckedTextInput
key="backendUrl"
value={cloud.url}
onChange={async v => {
cloud.url = v;
cloud.authenticated = false;
deauthorizeCloud();
}}
validate={validateUrl}
/>
<Grid columns={1} gap="1em" className={Margins.top8}>
<ButtonWithIcon
variant="primary"
disabled={!cloud.authenticated}
onClick={async () => {
await deauthorizeCloud();
cloud.authenticated = false;
await authorizeCloud();
}}
Icon={RestartIcon}
>
Reauthorise
</ButtonWithIcon>
</Grid>
</section>
);
}
function SettingsSyncSection() {
const { cloud } = useSettings(["cloud.authenticated", "cloud.settingsSync"]);
const sectionEnabled = cloud.authenticated && cloud.settingsSync;
return (
<section className={Margins.top16}>
<Forms.FormTitle tag="h5">Settings Sync</Forms.FormTitle>
<section>
<SectionHeading text="Settings Sync" />
<Flex flexDirection="column" gap="1em">
<FormSwitch
key="cloud-sync"
title="Enable Settings Sync"
description="Save your Vencord settings to the cloud so you can easily keep them the same on all your devices"
value={cloud.settingsSync}
onChange={v => { cloud.settingsSync = v; }}
disabled={!cloud.authenticated}
hideBorder
/>
<Paragraph size="md" className={Margins.bottom20}>
Synchronize your settings to the cloud. This allows easy synchronization across multiple devices with
minimal effort.
</Paragraph>
<FormSwitch
key="cloud-sync"
title="Settings Sync"
value={cloud.settingsSync}
onChange={v => { cloud.settingsSync = v; }}
disabled={!cloud.authenticated}
/>
<div className="vc-cloud-settings-sync-grid">
<Button
size={Button.Sizes.SMALL}
disabled={!sectionEnabled}
onClick={() => putCloudSettings(true)}
>
Sync to Cloud
</Button>
<Tooltip text="This will overwrite your local settings with the ones on the cloud. Use wisely!">
{({ onMouseLeave, onMouseEnter }) => (
<Button
onMouseLeave={onMouseLeave}
onMouseEnter={onMouseEnter}
size={Button.Sizes.SMALL}
color={Button.Colors.RED}
disabled={!sectionEnabled}
onClick={() => getCloudSettings(true, true)}
>
Sync from Cloud
</Button>
)}
</Tooltip>
<Button
size={Button.Sizes.SMALL}
color={Button.Colors.RED}
disabled={!sectionEnabled}
<div>
<Heading tag="h5">
Sync Rules for This Device
</Heading>
<Paragraph className={Margins.bottom8}>
This setting controls how settings move between <strong>this device</strong> and the cloud.
You can let changes flow both ways, or choose one place to be the main source of truth.
</Paragraph>
<Select
options={[
{
label: "Two-way sync (changes go both directions)",
value: "both",
default: true,
},
{
label: "This device is the source (upload only)",
value: "push",
},
{
label: "The cloud is the source (download only)",
value: "pull",
},
{
label: "Do not sync automatically (manual sync via buttons below only)",
value: "manual",
}
]}
isSelected={v => v === localStorage.Vencord_cloudSyncDirection}
serialize={v => String(v)}
select={v => {
localStorage.Vencord_cloudSyncDirection = v;
}}
closeOnSelect={true}
/>
</div>
<Grid columns={2} gap="1em" className={Margins.top20}>
<ButtonWithIcon
variant="positive"
disabled={!sectionEnabled}
onClick={() => putCloudSettings(true)}
Icon={CloudUploadIcon}
>
Upload Settings
</ButtonWithIcon>
<Tooltip text="This will replace your current settings with the ones saved in the cloud. Be careful!">
{({ onMouseLeave, onMouseEnter }) => (
<ButtonWithIcon
variant="dangerPrimary"
onMouseLeave={onMouseLeave}
onMouseEnter={onMouseEnter}
disabled={!sectionEnabled}
onClick={() => getCloudSettings(true, true)}
Icon={CloudDownloadIcon}
>
Download Settings
</ButtonWithIcon>
)}
</Tooltip>
</Grid>
</Flex>
</section>
);
}
function ResetSection() {
const { authenticated, settingsSync } = useSettings(["cloud.authenticated", "cloud.settingsSync"]).cloud;
return (
<section>
<SectionHeading text="Reset Cloud Data" />
<Grid columns={2} gap="1em">
<ButtonWithIcon
variant="dangerPrimary"
disabled={!authenticated || !settingsSync}
onClick={() => deleteCloudSettings()}
Icon={DeleteIcon}
>
Delete Cloud Settings
</Button>
</div>
Delete Settings from Cloud
</ButtonWithIcon>
<ButtonWithIcon
variant="dangerPrimary"
disabled={!authenticated}
onClick={() => Alerts.show({
title: "Are you sure?",
body: "Once your data is erased, we cannot recover it. There's no going back!",
onConfirm: eraseAllCloudData,
confirmText: "Erase it!",
confirmColor: "vc-cloud-erase-data-danger-btn",
cancelText: "Nevermind"
})}
Icon={DeleteIcon}
>
Delete your Cloud Account
</ButtonWithIcon>
</Grid>
</section>
);
}
function CloudTab() {
const settings = useSettings(["cloud.authenticated", "cloud.url"]);
return (
<SettingsTab>
<section className={Margins.top16}>
<Paragraph size="md" className={Margins.bottom20}>
Vencord comes with a cloud integration that adds goodies like settings sync across devices.
It <Link href="https://vencord.dev/cloud/privacy">respects your privacy</Link>, and
the <Link href="https://github.com/Vencord/Backend">source code</Link> is AGPL 3.0 licensed so you
can host it yourself.
</Paragraph>
<FormSwitch
key="backend"
title="Enable Cloud Integrations"
description="This will request authorization if you have not yet set up cloud integrations."
value={settings.cloud.authenticated}
onChange={v => {
if (v)
authorizeCloud();
else
settings.cloud.authenticated = v;
}}
/>
<Forms.FormTitle tag="h5" className={Margins.top16}>Backend URL</Forms.FormTitle>
<Forms.FormText className={Margins.bottom8}>
Which backend to use when using cloud integrations.
</Forms.FormText>
<CheckedTextInput
key="backendUrl"
value={settings.cloud.url}
onChange={async v => {
settings.cloud.url = v;
settings.cloud.authenticated = false;
deauthorizeCloud();
}}
validate={validateUrl}
/>
<Grid columns={2} gap="1em" className={Margins.top8}>
<Button
size={Button.Sizes.MEDIUM}
disabled={!settings.cloud.authenticated}
onClick={async () => {
await deauthorizeCloud();
settings.cloud.authenticated = false;
await authorizeCloud();
}}
>
Reauthorise
</Button>
<Button
size={Button.Sizes.MEDIUM}
color={Button.Colors.RED}
disabled={!settings.cloud.authenticated}
onClick={() => Alerts.show({
title: "Are you sure?",
body: "Once your data is erased, we cannot recover it. There's no going back!",
onConfirm: eraseAllCloudData,
confirmText: "Erase it!",
confirmColor: "vc-cloud-erase-data-danger-btn",
cancelText: "Nevermind"
})}
>
Erase All Data
</Button>
</Grid>
<Divider className={Margins.top16} />
</section >
<SettingsSyncSection />
<Flex flexDirection="column" gap="1em">
<CloudSetupSection />
<Divider />
<SettingsSyncSection />
<Divider />
<ResetSection />
</Flex>
</SettingsTab>
);
}