567 lines
18 KiB
TypeScript
567 lines
18 KiB
TypeScript
/*
|
|
* Vencord, a Discord client mod
|
|
* Copyright (c) 2025 Vendicated and contributors
|
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
*/
|
|
|
|
import * as DataStore from "@api/DataStore";
|
|
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 { SettingsRouter } from "@webpack/common";
|
|
import { deflateSync, inflateSync } from "fflate";
|
|
|
|
import { deauthorizeCloud, getCloudAuth, getCloudUrl } from "./cloudSetup";
|
|
import { exportSettings, importSettings } from "./offline";
|
|
import { ManifestEntry, SyncRequest, SyncResponse } from "./types";
|
|
|
|
const logger = new Logger("SettingsSync:Cloud", "#39b7e0");
|
|
|
|
const MANIFEST_STORE_KEY = "Vencord_cloudManifest";
|
|
const API_VERSION_STORE_KEY = "Vencord_cloudApiVersions";
|
|
|
|
type ApiVersion = "v2" | "v1";
|
|
|
|
const SYNC_DIRECTION_KEY = "Vencord_cloudSyncDirection";
|
|
const SETTINGS_DIRTY_KEY = "Vencord_settingsDirty";
|
|
export const getCloudSyncDirection = () => localStorage.getItem(SYNC_DIRECTION_KEY) || "both";
|
|
export const setCloudSyncDirection = (direction: "push" | "pull" | "both" | "manual") => localStorage.setItem(SYNC_DIRECTION_KEY, direction);
|
|
export const areLocalSettingsDirty = () => localStorage.getItem(SETTINGS_DIRTY_KEY) === "true";
|
|
export const markLocalSettingsDirty = () => localStorage.setItem(SETTINGS_DIRTY_KEY, "true");
|
|
export const markLocalSettingsClean = () => localStorage.removeItem(SETTINGS_DIRTY_KEY);
|
|
|
|
async function loadApiVersionMap(): Promise<Record<string, ApiVersion>> {
|
|
return await DataStore.get<Record<string, ApiVersion>>(API_VERSION_STORE_KEY) ?? {};
|
|
}
|
|
|
|
async function getApiVersion(): Promise<ApiVersion> {
|
|
const map = await loadApiVersionMap();
|
|
return map[getCloudUrl().origin] ?? "v2";
|
|
}
|
|
|
|
async function setApiVersion(version: ApiVersion) {
|
|
await DataStore.update<Record<string, ApiVersion>>(API_VERSION_STORE_KEY, map => {
|
|
map ??= {};
|
|
map[getCloudUrl().origin] = version;
|
|
return map;
|
|
});
|
|
}
|
|
|
|
function toBase64(data: Uint8Array): string {
|
|
let binary = "";
|
|
for (let i = 0; i < data.length; i++)
|
|
binary += String.fromCharCode(data[i]);
|
|
return btoa(binary);
|
|
}
|
|
|
|
function fromBase64(b64: string): Uint8Array {
|
|
const binary = atob(b64);
|
|
const bytes = new Uint8Array(binary.length);
|
|
for (let i = 0; i < binary.length; i++)
|
|
bytes[i] = binary.charCodeAt(i);
|
|
return bytes;
|
|
}
|
|
|
|
async function computeChecksum(data: Uint8Array): Promise<string> {
|
|
const hash = await crypto.subtle.digest("SHA-256", new Uint8Array(data));
|
|
const bytes = new Uint8Array(hash, 0, 8);
|
|
return Array.from(bytes, b => b.toString(16).padStart(2, "0")).join("");
|
|
}
|
|
|
|
async function getLocalManifest(): Promise<ManifestEntry[]> {
|
|
return await DataStore.get<ManifestEntry[]>(MANIFEST_STORE_KEY) ?? [];
|
|
}
|
|
|
|
async function saveLocalManifest(manifest: ManifestEntry[]) {
|
|
await DataStore.set(MANIFEST_STORE_KEY, manifest);
|
|
}
|
|
|
|
async function buildLocalData(): Promise<Map<string, Uint8Array>> {
|
|
const encoder = new TextEncoder();
|
|
const data = new Map<string, Uint8Array>();
|
|
|
|
data.set("settings", encoder.encode(JSON.stringify(VencordNative.settings.get())));
|
|
|
|
const quickCss = await VencordNative.quickCss.get();
|
|
if (quickCss) data.set("quickCss", encoder.encode(quickCss));
|
|
|
|
const dataStoreEntries = await DataStore.entries();
|
|
if (dataStoreEntries) data.set("dataStore", encoder.encode(JSON.stringify(dataStoreEntries)));
|
|
|
|
return data;
|
|
}
|
|
|
|
async function applyDownloads(downloads: SyncResponse["downloads"]) {
|
|
if (downloads.length === 0) return false;
|
|
|
|
let settingsChanged = false;
|
|
const decoder = new TextDecoder();
|
|
|
|
for (const dl of downloads) {
|
|
const text = decoder.decode(fromBase64(dl.value));
|
|
|
|
if (dl.key === "settings") {
|
|
try {
|
|
await importSettings(JSON.stringify({ settings: JSON.parse(text) }), "all", true);
|
|
settingsChanged = true;
|
|
} catch (e) {
|
|
logger.error("Failed to apply settings download", e);
|
|
}
|
|
} else if (dl.key === "quickCss") {
|
|
await VencordNative.quickCss.set(text);
|
|
settingsChanged = true;
|
|
} else if (dl.key.startsWith("dataStore/")) {
|
|
const dsKey = dl.key.slice("dataStore/".length);
|
|
try {
|
|
await DataStore.set(dsKey, JSON.parse(text));
|
|
} catch (e) {
|
|
logger.error(`Failed to apply dataStore download for ${dsKey}`, e);
|
|
}
|
|
}
|
|
}
|
|
|
|
return settingsChanged;
|
|
}
|
|
|
|
function handleAuthFailure() {
|
|
showNotification({
|
|
title: "Cloud Settings",
|
|
body: "Cloud sync was disabled because this account isn't connected. Reconnect in Cloud Settings.",
|
|
color: "var(--yellow-360)",
|
|
onClick: () => SettingsRouter.openUserSettings("equicord_cloud_panel"),
|
|
});
|
|
Settings.cloud.authenticated = false;
|
|
}
|
|
|
|
async function doSyncV2(uploads: SyncRequest["uploads"], clientManifest: ManifestEntry[]): Promise<SyncResponse | null> {
|
|
let res: Response;
|
|
res = await fetch(new URL("/v2/sync", getCloudUrl()), {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: await getCloudAuth(),
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({ client_manifest: clientManifest, uploads } satisfies SyncRequest),
|
|
});
|
|
|
|
if (res.status === 404) {
|
|
logger.info("Server does not support v2, falling back to v1");
|
|
await setApiVersion("v1");
|
|
return null;
|
|
}
|
|
|
|
if (res.status === 401) {
|
|
handleAuthFailure();
|
|
return null;
|
|
}
|
|
|
|
if (!res.ok) {
|
|
logger.error(`Sync failed, API returned ${res.status}`);
|
|
showNotification({
|
|
title: "Cloud Settings",
|
|
body: `Could not synchronize settings (API returned ${res.status}).`,
|
|
color: "var(--red-360)",
|
|
});
|
|
return null;
|
|
}
|
|
|
|
return await res.json();
|
|
}
|
|
|
|
async function putV2(manual?: boolean) {
|
|
const localManifest = await getLocalManifest();
|
|
const manifestMap = new Map(localManifest.map(e => [e.key, e]));
|
|
|
|
const localData = await buildLocalData();
|
|
const uploads: SyncRequest["uploads"] = [];
|
|
|
|
for (const [key, value] of localData) {
|
|
const checksum = await computeChecksum(value);
|
|
const existing = manifestMap.get(key);
|
|
|
|
if (!existing || existing.checksum !== checksum)
|
|
uploads.push({ key, value: toBase64(value), checksum });
|
|
}
|
|
|
|
if (uploads.length === 0 && !manual) {
|
|
logger.info("No changes to push");
|
|
delete localStorage.Vencord_settingsDirty;
|
|
return;
|
|
}
|
|
|
|
const response = await doSyncV2(uploads, localManifest);
|
|
if (!response) return;
|
|
|
|
for (const err of response.errors)
|
|
logger.error(`Sync error for ${err.key}: ${err.error}`);
|
|
|
|
const hadDownloads = await applyDownloads(response.downloads);
|
|
await saveLocalManifest(response.server_manifest);
|
|
|
|
PlainSettings.cloud.settingsSyncVersion = Date.now();
|
|
await VencordNative.settings.set(PlainSettings);
|
|
|
|
logger.info(`Sync complete: ${response.uploaded.length} uploaded, ${response.downloads.length} downloaded`);
|
|
|
|
if (manual) {
|
|
showNotification({
|
|
title: "Cloud Settings",
|
|
body: hadDownloads
|
|
? "Settings synced! Click here to restart to fully apply changes."
|
|
: "Settings synchronized to the cloud!",
|
|
color: "var(--green-360)",
|
|
onClick: hadDownloads ? (IS_WEB ? () => location.reload() : relaunch) : undefined,
|
|
noPersist: true,
|
|
});
|
|
}
|
|
|
|
delete localStorage.Vencord_settingsDirty;
|
|
}
|
|
|
|
async function getV2(shouldNotify: boolean, force: boolean) {
|
|
const localManifest = force ? [] : await getLocalManifest();
|
|
|
|
const response = await doSyncV2([], localManifest);
|
|
if (!response) return false;
|
|
|
|
for (const err of response.errors)
|
|
logger.error(`Sync error for ${err.key}: ${err.error}`);
|
|
|
|
if (response.downloads.length === 0) {
|
|
logger.info("Settings up to date");
|
|
if (shouldNotify)
|
|
showNotification({
|
|
title: "Cloud Settings",
|
|
body: "Your settings are up to date.",
|
|
noPersist: true,
|
|
});
|
|
return false;
|
|
}
|
|
|
|
const settingsChanged = await applyDownloads(response.downloads);
|
|
await saveLocalManifest(response.server_manifest);
|
|
|
|
PlainSettings.cloud.settingsSyncVersion = Date.now();
|
|
await VencordNative.settings.set(PlainSettings);
|
|
|
|
logger.info(`Pulled ${response.downloads.length} keys from cloud`);
|
|
|
|
if (shouldNotify)
|
|
showNotification({
|
|
title: "Cloud Settings",
|
|
body: settingsChanged
|
|
? "Your settings have been updated! Click here to restart to fully apply changes!"
|
|
: "Cloud data synchronized.",
|
|
color: "var(--green-360)",
|
|
onClick: settingsChanged ? (IS_WEB ? () => location.reload() : relaunch) : undefined,
|
|
noPersist: true,
|
|
});
|
|
|
|
delete localStorage.Vencord_settingsDirty;
|
|
return true;
|
|
}
|
|
|
|
async function deleteV2() {
|
|
const auth = await getCloudAuth();
|
|
|
|
const manifestRes = await fetch(new URL("/v2/manifest", getCloudUrl()), {
|
|
headers: { Authorization: auth },
|
|
});
|
|
|
|
if (manifestRes.status === 404) {
|
|
logger.info("Server does not support v2, falling back to v1");
|
|
await setApiVersion("v1");
|
|
return;
|
|
}
|
|
|
|
if (!manifestRes.ok) {
|
|
showNotification({
|
|
title: "Cloud Settings",
|
|
body: `Could not fetch manifest for deletion (API returned ${manifestRes.status}).`,
|
|
color: "var(--red-360)",
|
|
});
|
|
return;
|
|
}
|
|
|
|
const { entries }: { entries: ManifestEntry[]; } = await manifestRes.json();
|
|
|
|
await Promise.all(entries.map(async entry => {
|
|
const res = await fetch(new URL(`/v2/data/${encodeURIComponent(entry.key)}`, getCloudUrl()), {
|
|
method: "DELETE",
|
|
headers: { Authorization: auth },
|
|
});
|
|
if (!res.ok && res.status !== 404)
|
|
logger.error(`Failed to delete key ${entry.key}: ${res.status}`);
|
|
}));
|
|
|
|
await saveLocalManifest([]);
|
|
|
|
PlainSettings.cloud.settingsSyncVersion = 0;
|
|
await VencordNative.settings.set(PlainSettings);
|
|
|
|
logger.info("Settings deleted from cloud successfully");
|
|
showNotification({
|
|
title: "Cloud Settings",
|
|
body: "Settings deleted from cloud!",
|
|
color: "var(--green-360)",
|
|
});
|
|
}
|
|
|
|
async function putV1(manual?: boolean) {
|
|
const settings = await exportSettings({ syncDataStore: false, minify: true });
|
|
|
|
const res = await fetch(new URL("/v1/settings", getCloudUrl()), {
|
|
method: "PUT",
|
|
headers: {
|
|
Authorization: await getCloudAuth(),
|
|
"Content-Type": "application/octet-stream",
|
|
},
|
|
body: deflateSync(new TextEncoder().encode(settings)) as Uint8Array<ArrayBuffer>,
|
|
});
|
|
|
|
if (!res.ok) {
|
|
logger.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.settings.set(PlainSettings);
|
|
|
|
logger.info("Settings uploaded to cloud successfully");
|
|
|
|
if (manual) {
|
|
showNotification({
|
|
title: "Cloud Settings",
|
|
body: "Synchronized settings to the cloud!",
|
|
noPersist: true,
|
|
});
|
|
}
|
|
|
|
delete localStorage.Vencord_settingsDirty;
|
|
}
|
|
|
|
async function getV1(shouldNotify: boolean, force: boolean) {
|
|
const res = await fetch(new URL("/v1/settings", getCloudUrl()), {
|
|
method: "GET",
|
|
headers: {
|
|
Authorization: await getCloudAuth(),
|
|
Accept: "application/octet-stream",
|
|
"If-None-Match": Settings.cloud.settingsSyncVersion.toString(),
|
|
},
|
|
});
|
|
|
|
if (res.status === 401) {
|
|
handleAuthFailure();
|
|
return false;
|
|
}
|
|
|
|
if (res.status === 404) {
|
|
logger.info("No settings on the cloud");
|
|
if (shouldNotify)
|
|
showNotification({
|
|
title: "Cloud Settings",
|
|
body: "There are no settings in the cloud.",
|
|
noPersist: true,
|
|
});
|
|
return false;
|
|
}
|
|
|
|
if (res.status === 304) {
|
|
logger.info("Settings up to date");
|
|
if (shouldNotify)
|
|
showNotification({
|
|
title: "Cloud Settings",
|
|
body: "Your settings are up to date.",
|
|
noPersist: true,
|
|
});
|
|
return false;
|
|
}
|
|
|
|
if (!res.ok) {
|
|
logger.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;
|
|
|
|
if (!force && written < localWritten) {
|
|
if (shouldNotify)
|
|
showNotification({
|
|
title: "Cloud Settings",
|
|
body: "Your local settings are newer than the cloud ones.",
|
|
noPersist: true,
|
|
});
|
|
return false;
|
|
}
|
|
|
|
const data = await res.arrayBuffer();
|
|
const settings = new TextDecoder().decode(inflateSync(new Uint8Array(data)));
|
|
await importSettings(settings, "all", true);
|
|
|
|
PlainSettings.cloud.settingsSyncVersion = written;
|
|
VencordNative.settings.set(PlainSettings);
|
|
|
|
logger.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: IS_WEB ? () => location.reload() : relaunch,
|
|
noPersist: true,
|
|
});
|
|
|
|
delete localStorage.Vencord_settingsDirty;
|
|
return true;
|
|
}
|
|
|
|
async function deleteV1() {
|
|
const res = await fetch(new URL("/v1/settings", getCloudUrl()), {
|
|
method: "DELETE",
|
|
headers: { Authorization: await getCloudAuth() },
|
|
});
|
|
|
|
if (!res.ok) {
|
|
logger.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;
|
|
}
|
|
|
|
logger.info("Settings deleted from cloud successfully");
|
|
showNotification({
|
|
title: "Cloud Settings",
|
|
body: "Settings deleted from cloud!",
|
|
color: "var(--green-360)",
|
|
});
|
|
}
|
|
|
|
export function shouldCloudSync(direction: "push" | "pull") {
|
|
const localDirection = localStorage.Vencord_cloudSyncDirection;
|
|
return localDirection === direction || localDirection === "both";
|
|
}
|
|
|
|
export async function putCloudSettings(manual?: boolean) {
|
|
try {
|
|
const version = await getApiVersion();
|
|
if (version === "v2") {
|
|
try {
|
|
await putV2(manual);
|
|
if (await getApiVersion() === "v1")
|
|
await putV1(manual);
|
|
} catch (e) {
|
|
logger.info("v2 sync failed with network error, trying v1...");
|
|
await putV1(manual);
|
|
await setApiVersion("v1");
|
|
}
|
|
} else {
|
|
await putV1(manual);
|
|
}
|
|
} catch (e: any) {
|
|
logger.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 version = await getApiVersion();
|
|
if (version === "v2") {
|
|
try {
|
|
const result = await getV2(shouldNotify, force);
|
|
if (await getApiVersion() === "v1")
|
|
return await getV1(shouldNotify, force);
|
|
return result;
|
|
} catch (e) {
|
|
logger.info("v2 sync failed with network error, trying v1...");
|
|
const result = await getV1(shouldNotify, force);
|
|
await setApiVersion("v1");
|
|
return result;
|
|
}
|
|
}
|
|
return await getV1(shouldNotify, force);
|
|
} catch (e: any) {
|
|
logger.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 version = await getApiVersion();
|
|
if (version === "v2") {
|
|
try {
|
|
await deleteV2();
|
|
if (await getApiVersion() === "v1") {
|
|
await deleteV1();
|
|
}
|
|
} catch (e) {
|
|
logger.info("v2 sync failed with network error, trying v1...");
|
|
await deleteV1();
|
|
await setApiVersion("v1");
|
|
}
|
|
} else {
|
|
await deleteV1();
|
|
}
|
|
} catch (e: any) {
|
|
logger.error("Failed to delete", e);
|
|
showNotification({
|
|
title: "Cloud Settings",
|
|
body: `Could not delete settings (${e.toString()}).`,
|
|
color: "var(--red-360)",
|
|
});
|
|
}
|
|
}
|
|
|
|
export async function eraseAllCloudData() {
|
|
const res = await fetch(new URL("/v1/", getCloudUrl()), {
|
|
method: "DELETE",
|
|
headers: { Authorization: await getCloudAuth() },
|
|
});
|
|
|
|
if (!res.ok) {
|
|
logger.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();
|
|
await saveLocalManifest([]);
|
|
|
|
showNotification({
|
|
title: "Cloud Integrations",
|
|
body: "Successfully erased all data.",
|
|
color: "var(--green-360)",
|
|
});
|
|
}
|