diff --git a/biome.jsonc b/biome.jsonc index c374e415..62317f46 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -7,7 +7,8 @@ }, "formatter": { "indentStyle": "space", - "lineWidth": 120 + "lineWidth": 120, + "lineEnding": "auto" }, "javascript": { "jsxRuntime": "reactClassic", diff --git a/package.json b/package.json index 7d900432..8527085f 100644 --- a/package.json +++ b/package.json @@ -29,10 +29,13 @@ "check:ci": "biome ci", "check:write": "biome check --write", "check:types": "pnpm run -r check:types", + "build:proto": "node scripts/build-proto.mjs", "prepare": "husky install" }, "devDependencies": { "@biomejs/biome": "catalog:lint", - "husky": "catalog:lint" + "husky": "catalog:lint", + "@protobuf-ts/plugin": "^2.9.0", + "@protobuf-ts/protoc": "^2.9.0" } } diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 5254b561..4be8b0a7 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -123,6 +123,9 @@ window._moonlightBrowserInit = async () => { // @ts-expect-error Set by esbuild branch: MOONLIGHT_BRANCH as MoonlightBranch, + getFullConfig() { + return config; + }, getConfig(ext) { return getConfig(ext, config); }, diff --git a/packages/core-extensions/package.json b/packages/core-extensions/package.json index 4609ec27..8860adea 100644 --- a/packages/core-extensions/package.json +++ b/packages/core-extensions/package.json @@ -10,6 +10,7 @@ "dependencies": { "@moonlight-mod/core": "workspace:*", "@moonlight-mod/types": "workspace:*", + "@protobuf-ts/runtime": "catalog:", "microdiff": "catalog:", "nanotar": "catalog:" }, diff --git a/packages/core-extensions/src/cloudSync/index.ts b/packages/core-extensions/src/cloudSync/index.ts new file mode 100644 index 00000000..8cdd1f83 --- /dev/null +++ b/packages/core-extensions/src/cloudSync/index.ts @@ -0,0 +1,28 @@ +import type { ExtensionWebExports } from "@moonlight-mod/types"; + +export const webpackModules: ExtensionWebExports["webpackModules"] = { + proto: {}, + + sync: { + dependencies: [ + { id: "discord/packages/flux" }, + { id: "discord/Dispatcher" }, + { id: "discord/utils/HTTPUtils" }, + { id: "react" }, + { ext: "notices", id: "notices" }, + { ext: "spacepack", id: "spacepack" }, + { ext: "cloudSync", id: "proto" } + ], + entrypoint: true + } +}; + +export const patches: ExtensionWebExports["patches"] = [ + { + find: "UserSettingsProto must not be a string", + replace: { + match: /\["USER_SETTINGS_PROTO_UPDATE"\],(\i)=>\{/, + replacement: `$&require("cloudSync_sync").default.onUserSettingsProtoUpdate($1);` + } + } +]; diff --git a/packages/core-extensions/src/cloudSync/manifest.json b/packages/core-extensions/src/cloudSync/manifest.json new file mode 100644 index 00000000..a2911dfa --- /dev/null +++ b/packages/core-extensions/src/cloudSync/manifest.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://moonlight-mod.github.io/manifest.schema.json", + "id": "cloudSync", + "apiLevel": 2, + "meta": { + "name": "Cloud Sync", + "tagline": "Sync your moonlight settings to Discord", + "authors": ["moonlight"], + "tags": ["qol"] + }, + "dependencies": ["spacepack", "notices"], + "cors": [] +} diff --git a/packages/core-extensions/src/cloudSync/node.ts b/packages/core-extensions/src/cloudSync/node.ts new file mode 100644 index 00000000..e5f69f7b --- /dev/null +++ b/packages/core-extensions/src/cloudSync/node.ts @@ -0,0 +1,9 @@ +import { deflateSync, inflateSync } from "node:zlib"; + +export function toBinary(buf: string): Uint8Array { + return new Uint8Array(deflateSync(new TextEncoder().encode(buf))); +} + +export function fromBinary(buf: Uint8Array): string { + return new TextDecoder().decode(inflateSync(buf)); +} diff --git a/packages/core-extensions/src/cloudSync/proto/settings.proto b/packages/core-extensions/src/cloudSync/proto/settings.proto new file mode 100644 index 00000000..ff012f83 --- /dev/null +++ b/packages/core-extensions/src/cloudSync/proto/settings.proto @@ -0,0 +1,26 @@ +syntax = "proto3"; + +message CustomUserSettings { + message Versions { + uint32 client_version = 1; + uint32 server_version = 2; + uint32 data_version = 3; + } + + message SettingsEntry { + bytes data = 1; + } + + message MoonlightSettingsEntry { + uint32 version = 1; + bytes data = 2; + } + + message ClientSettings { + SettingsEntry shared = 1; + MoonlightSettingsEntry moonlight = 14045442; + } + + optional Versions versions = 1; + optional ClientSettings settings = 2; +} \ No newline at end of file diff --git a/packages/core-extensions/src/cloudSync/proto/settings.ts b/packages/core-extensions/src/cloudSync/proto/settings.ts new file mode 100644 index 00000000..686556a5 --- /dev/null +++ b/packages/core-extensions/src/cloudSync/proto/settings.ts @@ -0,0 +1,408 @@ +/** biome-ignore-all lint: auto-generated file */ + +// @generated by protobuf-ts 2.11.1 +// @generated from protobuf file "settings.proto" (syntax proto3) +// tslint:disable +import type { + BinaryReadOptions, + BinaryWriteOptions, + IBinaryReader, + IBinaryWriter, + PartialMessage +} from "@protobuf-ts/runtime"; +import { MessageType, reflectionMergePartial, UnknownFieldHandler, WireType } from "@protobuf-ts/runtime"; +/** + * @generated from protobuf message CustomUserSettings + */ +export interface CustomUserSettings { + /** + * @generated from protobuf field: optional CustomUserSettings.Versions versions = 1 + */ + versions?: CustomUserSettings_Versions; + /** + * @generated from protobuf field: optional CustomUserSettings.ClientSettings settings = 2 + */ + settings?: CustomUserSettings_ClientSettings; +} +/** + * @generated from protobuf message CustomUserSettings.Versions + */ +export interface CustomUserSettings_Versions { + /** + * @generated from protobuf field: uint32 client_version = 1 + */ + clientVersion: number; + /** + * @generated from protobuf field: uint32 server_version = 2 + */ + serverVersion: number; + /** + * @generated from protobuf field: uint32 data_version = 3 + */ + dataVersion: number; +} +/** + * @generated from protobuf message CustomUserSettings.SettingsEntry + */ +export interface CustomUserSettings_SettingsEntry { + /** + * @generated from protobuf field: bytes data = 1 + */ + data: Uint8Array; +} +/** + * @generated from protobuf message CustomUserSettings.MoonlightSettingsEntry + */ +export interface CustomUserSettings_MoonlightSettingsEntry { + /** + * @generated from protobuf field: uint32 version = 1 + */ + version: number; + /** + * @generated from protobuf field: bytes data = 2 + */ + data: Uint8Array; +} +/** + * @generated from protobuf message CustomUserSettings.ClientSettings + */ +export interface CustomUserSettings_ClientSettings { + /** + * @generated from protobuf field: CustomUserSettings.SettingsEntry shared = 1 + */ + shared?: CustomUserSettings_SettingsEntry; + /** + * @generated from protobuf field: CustomUserSettings.MoonlightSettingsEntry moonlight = 14045442 + */ + moonlight?: CustomUserSettings_MoonlightSettingsEntry; +} +// @generated message type with reflection information, may provide speed optimized methods +class CustomUserSettings$Type extends MessageType { + constructor() { + super("CustomUserSettings", [ + { no: 1, name: "versions", kind: "message", T: () => CustomUserSettings_Versions }, + { no: 2, name: "settings", kind: "message", T: () => CustomUserSettings_ClientSettings } + ]); + } + create(value?: PartialMessage): CustomUserSettings { + const message = globalThis.Object.create(this.messagePrototype!); + if (value !== undefined) reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead( + reader: IBinaryReader, + length: number, + options: BinaryReadOptions, + target?: CustomUserSettings + ): CustomUserSettings { + let message = target ?? this.create(), + end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* optional CustomUserSettings.Versions versions */ 1: + message.versions = CustomUserSettings_Versions.internalBinaryRead( + reader, + reader.uint32(), + options, + message.versions + ); + break; + case /* optional CustomUserSettings.ClientSettings settings */ 2: + message.settings = CustomUserSettings_ClientSettings.internalBinaryRead( + reader, + reader.uint32(), + options, + message.settings + ); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite(message: CustomUserSettings, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter { + /* optional CustomUserSettings.Versions versions = 1; */ + if (message.versions) + CustomUserSettings_Versions.internalBinaryWrite( + message.versions, + writer.tag(1, WireType.LengthDelimited).fork(), + options + ).join(); + /* optional CustomUserSettings.ClientSettings settings = 2; */ + if (message.settings) + CustomUserSettings_ClientSettings.internalBinaryWrite( + message.settings, + writer.tag(2, WireType.LengthDelimited).fork(), + options + ).join(); + let u = options.writeUnknownFields; + if (u !== false) (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message CustomUserSettings + */ +export const CustomUserSettings = new CustomUserSettings$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class CustomUserSettings_Versions$Type extends MessageType { + constructor() { + super("CustomUserSettings.Versions", [ + { no: 1, name: "client_version", kind: "scalar", T: 13 /*ScalarType.UINT32*/ }, + { no: 2, name: "server_version", kind: "scalar", T: 13 /*ScalarType.UINT32*/ }, + { no: 3, name: "data_version", kind: "scalar", T: 13 /*ScalarType.UINT32*/ } + ]); + } + create(value?: PartialMessage): CustomUserSettings_Versions { + const message = globalThis.Object.create(this.messagePrototype!); + message.clientVersion = 0; + message.serverVersion = 0; + message.dataVersion = 0; + if (value !== undefined) reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead( + reader: IBinaryReader, + length: number, + options: BinaryReadOptions, + target?: CustomUserSettings_Versions + ): CustomUserSettings_Versions { + let message = target ?? this.create(), + end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* uint32 client_version */ 1: + message.clientVersion = reader.uint32(); + break; + case /* uint32 server_version */ 2: + message.serverVersion = reader.uint32(); + break; + case /* uint32 data_version */ 3: + message.dataVersion = reader.uint32(); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite( + message: CustomUserSettings_Versions, + writer: IBinaryWriter, + options: BinaryWriteOptions + ): IBinaryWriter { + /* uint32 client_version = 1; */ + if (message.clientVersion !== 0) writer.tag(1, WireType.Varint).uint32(message.clientVersion); + /* uint32 server_version = 2; */ + if (message.serverVersion !== 0) writer.tag(2, WireType.Varint).uint32(message.serverVersion); + /* uint32 data_version = 3; */ + if (message.dataVersion !== 0) writer.tag(3, WireType.Varint).uint32(message.dataVersion); + let u = options.writeUnknownFields; + if (u !== false) (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message CustomUserSettings.Versions + */ +export const CustomUserSettings_Versions = new CustomUserSettings_Versions$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class CustomUserSettings_SettingsEntry$Type extends MessageType { + constructor() { + super("CustomUserSettings.SettingsEntry", [{ no: 1, name: "data", kind: "scalar", T: 12 /*ScalarType.BYTES*/ }]); + } + create(value?: PartialMessage): CustomUserSettings_SettingsEntry { + const message = globalThis.Object.create(this.messagePrototype!); + message.data = new Uint8Array(0); + if (value !== undefined) reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead( + reader: IBinaryReader, + length: number, + options: BinaryReadOptions, + target?: CustomUserSettings_SettingsEntry + ): CustomUserSettings_SettingsEntry { + let message = target ?? this.create(), + end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* bytes data */ 1: + message.data = reader.bytes(); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite( + message: CustomUserSettings_SettingsEntry, + writer: IBinaryWriter, + options: BinaryWriteOptions + ): IBinaryWriter { + /* bytes data = 1; */ + if (message.data.length) writer.tag(1, WireType.LengthDelimited).bytes(message.data); + let u = options.writeUnknownFields; + if (u !== false) (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message CustomUserSettings.SettingsEntry + */ +export const CustomUserSettings_SettingsEntry = new CustomUserSettings_SettingsEntry$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class CustomUserSettings_MoonlightSettingsEntry$Type extends MessageType { + constructor() { + super("CustomUserSettings.MoonlightSettingsEntry", [ + { no: 1, name: "version", kind: "scalar", T: 13 /*ScalarType.UINT32*/ }, + { no: 2, name: "data", kind: "scalar", T: 12 /*ScalarType.BYTES*/ } + ]); + } + create(value?: PartialMessage): CustomUserSettings_MoonlightSettingsEntry { + const message = globalThis.Object.create(this.messagePrototype!); + message.version = 0; + message.data = new Uint8Array(0); + if (value !== undefined) reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead( + reader: IBinaryReader, + length: number, + options: BinaryReadOptions, + target?: CustomUserSettings_MoonlightSettingsEntry + ): CustomUserSettings_MoonlightSettingsEntry { + let message = target ?? this.create(), + end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* uint32 version */ 1: + message.version = reader.uint32(); + break; + case /* bytes data */ 2: + message.data = reader.bytes(); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite( + message: CustomUserSettings_MoonlightSettingsEntry, + writer: IBinaryWriter, + options: BinaryWriteOptions + ): IBinaryWriter { + /* uint32 version = 1; */ + if (message.version !== 0) writer.tag(1, WireType.Varint).uint32(message.version); + /* bytes data = 2; */ + if (message.data.length) writer.tag(2, WireType.LengthDelimited).bytes(message.data); + let u = options.writeUnknownFields; + if (u !== false) (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message CustomUserSettings.MoonlightSettingsEntry + */ +export const CustomUserSettings_MoonlightSettingsEntry = new CustomUserSettings_MoonlightSettingsEntry$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class CustomUserSettings_ClientSettings$Type extends MessageType { + constructor() { + super("CustomUserSettings.ClientSettings", [ + { no: 1, name: "shared", kind: "message", T: () => CustomUserSettings_SettingsEntry }, + { no: 14045442, name: "moonlight", kind: "message", T: () => CustomUserSettings_MoonlightSettingsEntry } + ]); + } + create(value?: PartialMessage): CustomUserSettings_ClientSettings { + const message = globalThis.Object.create(this.messagePrototype!); + if (value !== undefined) reflectionMergePartial(this, message, value); + return message; + } + internalBinaryRead( + reader: IBinaryReader, + length: number, + options: BinaryReadOptions, + target?: CustomUserSettings_ClientSettings + ): CustomUserSettings_ClientSettings { + let message = target ?? this.create(), + end = reader.pos + length; + while (reader.pos < end) { + let [fieldNo, wireType] = reader.tag(); + switch (fieldNo) { + case /* CustomUserSettings.SettingsEntry shared */ 1: + message.shared = CustomUserSettings_SettingsEntry.internalBinaryRead( + reader, + reader.uint32(), + options, + message.shared + ); + break; + case /* CustomUserSettings.MoonlightSettingsEntry moonlight */ 14045442: + message.moonlight = CustomUserSettings_MoonlightSettingsEntry.internalBinaryRead( + reader, + reader.uint32(), + options, + message.moonlight + ); + break; + default: + let u = options.readUnknownField; + if (u === "throw") + throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`); + let d = reader.skip(wireType); + if (u !== false) (u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d); + } + } + return message; + } + internalBinaryWrite( + message: CustomUserSettings_ClientSettings, + writer: IBinaryWriter, + options: BinaryWriteOptions + ): IBinaryWriter { + /* CustomUserSettings.SettingsEntry shared = 1; */ + if (message.shared) + CustomUserSettings_SettingsEntry.internalBinaryWrite( + message.shared, + writer.tag(1, WireType.LengthDelimited).fork(), + options + ).join(); + /* CustomUserSettings.MoonlightSettingsEntry moonlight = 14045442; */ + if (message.moonlight) + CustomUserSettings_MoonlightSettingsEntry.internalBinaryWrite( + message.moonlight, + writer.tag(14045442, WireType.LengthDelimited).fork(), + options + ).join(); + let u = options.writeUnknownFields; + if (u !== false) (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); + return writer; + } +} +/** + * @generated MessageType for protobuf message CustomUserSettings.ClientSettings + */ +export const CustomUserSettings_ClientSettings = new CustomUserSettings_ClientSettings$Type(); diff --git a/packages/core-extensions/src/cloudSync/webpackModules/proto.ts b/packages/core-extensions/src/cloudSync/webpackModules/proto.ts new file mode 100644 index 00000000..294304bd --- /dev/null +++ b/packages/core-extensions/src/cloudSync/webpackModules/proto.ts @@ -0,0 +1,12 @@ +// See: https://gist.github.com/dolfies/7a2d589a60c4c3eba4cc2197be0f0b89 +export { CustomUserSettings } from "../proto/settings"; + +const natives = moonlight.getNatives("cloudSync"); + +export function toBinary(buf: string): Uint8Array { + return natives.toBinary(buf); +} + +export function fromBinary(buf: Uint8Array): string { + return natives.fromBinary(buf); +} diff --git a/packages/core-extensions/src/cloudSync/webpackModules/sync.ts b/packages/core-extensions/src/cloudSync/webpackModules/sync.ts new file mode 100644 index 00000000..9606ab74 --- /dev/null +++ b/packages/core-extensions/src/cloudSync/webpackModules/sync.ts @@ -0,0 +1,476 @@ +import type { Config } from "@moonlight-mod/types"; +import { NodeEventType } from "@moonlight-mod/types/core/event"; +import { CustomUserSettings, fromBinary, toBinary } from "@moonlight-mod/wp/cloudSync_proto"; +import Dispatcher from "@moonlight-mod/wp/discord/Dispatcher"; +import { Store } from "@moonlight-mod/wp/discord/packages/flux"; +import { HTTP } from "@moonlight-mod/wp/discord/utils/HTTPUtils"; +import Notices from "@moonlight-mod/wp/notices_notices"; +import React from "@moonlight-mod/wp/react"; +import spacepack from "@moonlight-mod/wp/spacepack_spacepack"; + +const logger = moonlight.getLogger("CloudSync"); + +type UserSettingsProtoUpdate = { + settings: { type: 1 | 2 | 3; proto: string }; +}; + +export enum SyncStatus { + Idle = "idle", + Pulling = "pulling", + Pushing = "pushing", + Conflict = "conflict", + Error = "error" +} + +const DEBOUNCE_MS = 3000; // TODO: in a perfect world, this reuses the UserSettingsDelay constant and machinery + +class CloudSyncStore extends Store { + status: SyncStatus = SyncStatus.Idle; + lastError: string | null = null; + + /** The last-known remote version we synced with */ + remoteVersion = 0; + /** + * Last decoded CustomUserSettings from the server. Cloned before each PATCH so that + * @protobuf-ts/runtime's UnknownFieldHandler transparently re-emits any unknown + * fields the server may have added, without us having to track them manually. + */ + private lastDecodedMsg: CustomUserSettings | null = null; + private accountId: string | undefined; + + private debounceTimer: ReturnType | null = null; + localDirty = false; + isFirstSync = false; + + private pendingRemoteConfig: string | null = null; + private pendingRemoteVersion = 0; + + constructor() { + super(Dispatcher); + } + + start(initial: boolean = true) { + if (!this.isEnabled() && initial) return; + + this.remoteVersion = moonlightNode.getFullConfig()._rev ?? 0; + this.isFirstSync = this.remoteVersion === 0; + this.accountId = moonlightNode.getConfigOption("cloudSync", "accountId"); + + moonlightNode.events.addEventListener(NodeEventType.ConfigSaved, this.onConfigSaved); + Dispatcher.subscribe("CONNECTION_OPEN", this.onConnectionOpen); + + if (!initial) this.pull(); + logger.info(`Cloud sync started (syncedRemoteVersion=${this.remoteVersion}, isFirstSync=${this.isFirstSync})`); + } + + async stop() { + moonlightNode.events.removeEventListener(NodeEventType.ConfigSaved, this.onConfigSaved); + Dispatcher.unsubscribe("CONNECTION_OPEN", this.onConnectionOpen); + if (this.debounceTimer != null) { + clearTimeout(this.debounceTimer); + this.debounceTimer = null; + } + + // We can't trust future versions anymore + this.remoteVersion = 0; + await moonlightNode.writeConfig({ ...moonlightNode.getFullConfig(), _rev: 0 }); + + this.status = SyncStatus.Idle; + this.lastError = null; + this.lastDecodedMsg = null; + this.localDirty = false; + this.pendingRemoteConfig = null; + this.pendingRemoteVersion = 0; + + // Clean up our notices + // @ts-expect-error + while (Notices.getCurrentNotice()?.element?.props?.children?.includes?.("sync")) { + Notices.popNotice(); + } + + this.emitChange(); + logger.info("Cloud sync stopped"); + } + + private isEnabled(): boolean { + return moonlightNode.getConfigOption("cloudSync", "enabled") ?? false; + } + + private getAuthToken(): string { + const TokenManager = spacepack.findByCode("encryptAndStoreTokens")?.[0]?.exports; + return TokenManager.getToken(this.accountId); + } + + private onConfigSaved = (_config: Config) => { + if (!this.isEnabled()) return; + if ( + typeof _config.extensions?.cloudSync === "object" && + _config.extensions.cloudSync.config?.accountId !== this.accountId + ) { + logger.info("Account ID changed, restarting sync"); + this.stop().then(() => this.start(false)); + return; + } + + if (this.status !== SyncStatus.Idle) return; // Ignore writes triggered by sync itself + logger.trace("Config saved", _config); + + this.localDirty = true; + this.debouncedPush(); + }; + + private onConnectionOpen = async () => { + logger.info("Fetching on connection open"); + await this.pull(); + }; + + // As Flux no longer dispatches USER_SETTINGS_PROTO_UPDATE for unknown protos, we hook it manually + onUserSettingsProtoUpdate = async ({ settings }: UserSettingsProtoUpdate) => { + if (settings.type !== 3) return; + logger.trace("Received USER_SETTINGS_PROTO_UPDATE", settings); + + // Keep pending state up to date with the latest remote even during conflict + if (this.status === SyncStatus.Conflict) { + logger.info("Received remote update during conflict, updating pending remote config"); + const { version: remoteVersion, settings: remoteConfigJson } = this.unwrapProto(settings.proto); + if (remoteVersion > this.pendingRemoteVersion) { + this.pendingRemoteConfig = remoteConfigJson; + this.pendingRemoteVersion = remoteVersion; + this.emitChange(); + } + return; + } + + await this.processRemote(settings.proto); + }; + + debouncedPush() { + if (this.debounceTimer != null) { + clearTimeout(this.debounceTimer); + } + this.debounceTimer = setTimeout(() => { + this.debounceTimer = null; + this.push(); + }, DEBOUNCE_MS); + } + + private unwrapProto(encoded: string): { + version: number; + settings: string | null; + } { + const msg = CustomUserSettings.fromBinary(new Uint8Array([...atob(encoded)].map((c) => c.charCodeAt(0)))); + this.lastDecodedMsg = msg; + const data = msg.settings?.moonlight?.data; + return { + version: msg.settings?.moonlight?.version ?? 0, + settings: data?.length ? fromBinary(data) : null + }; + } + + private async patchProto(msg: CustomUserSettings) { + const encoded = CustomUserSettings.toBinary(msg); + return HTTP.patch({ + url: "/users/@me/settings-proto/3", + body: { + settings: btoa(String.fromCharCode(...encoded)), + required_data_version: msg.versions?.dataVersion + }, + headers: { + Authorization: this.getAuthToken() + } + }); + } + + private async processRemote(encoded: string) { + const { version: remoteVersion, settings: remoteConfigJson } = this.unwrapProto(encoded); + logger.info( + `Processing remote config version ${remoteVersion} (current remoteVersion=${this.remoteVersion}, localDirty=${this.localDirty})` + ); + + // First-ever sync: if the server has any moonlight data, always prompt regardless of versions; + // the remote config could be from a completely different machine/profile + if (this.isFirstSync) { + this.isFirstSync = false; + if (remoteConfigJson != null) { + this.status = SyncStatus.Conflict; + this.pendingRemoteConfig = remoteConfigJson; + this.pendingRemoteVersion = remoteVersion; + this.emitChange(); + this.showFirstSyncNotice(); + return; + } + } + + // If remote version hasn't changed, nothing to do + if (remoteVersion <= this.remoteVersion) { + this.status = SyncStatus.Idle; + this.emitChange(); + if (this.localDirty) this.debouncedPush(); + return; + } + + if (remoteConfigJson == null) { + logger.warn("Remote has a newer version but no moonlight data; ignoring"); + this.status = SyncStatus.Idle; + this.emitChange(); + return; + } + + // Remote is newer + if (this.localDirty) { + this.status = SyncStatus.Conflict; + this.pendingRemoteConfig = remoteConfigJson; + this.pendingRemoteVersion = remoteVersion; + this.emitChange(); + this.showConflictNotice(); + return; + } + + // Set Pulling so onConfigSaved ignores the write even when called from onUserSettingsProtoUpdate + this.remoteVersion = remoteVersion; + this.status = SyncStatus.Pulling; + await this.applyRemoteConfig(remoteConfigJson, remoteVersion); + this.showRemoteUpdateNotice(); + this.status = SyncStatus.Idle; + this.emitChange(); + } + + async pull() { + this.status = SyncStatus.Pulling; + this.emitChange(); + + try { + const resp = await HTTP.get({ + url: "/users/@me/settings-proto/3", + headers: { + Authorization: this.getAuthToken() + } + }); + if (!resp.ok) throw new Error(`Get settings proto returned ${resp.status}`); + await this.processRemote(resp.body.settings); + } catch (e: any) { + logger.error("Pull failed:", e); + this.lastError = e.message ?? String(e); + this.status = SyncStatus.Error; + this.emitChange(); + } + } + + async push(retried: boolean = false) { + if (!this.lastDecodedMsg) { + logger.warn("Called push without a cached msg!"); + await this.pull(); + if (!this.lastDecodedMsg || this.status !== SyncStatus.Idle) { + throw new Error("Aborting push: failed to pull latest settings from server"); + } + } + + this.status = SyncStatus.Pushing; + this.emitChange(); + + try { + const currentConfig = moonlightNode.getFullConfig(); + logger.trace("Pushing config", currentConfig, "with remoteVersion", this.remoteVersion); + const { _rev, ...configToSend } = currentConfig; + const configJson = JSON.stringify(configToSend); + const dataBytes = toBinary(configJson); + const newVersion = this.remoteVersion + 1; + + // Clone the last decoded message so @protobuf-ts re-emits any unknown fields + const toSend = CustomUserSettings.clone(this.lastDecodedMsg); + toSend.settings ??= {}; + toSend.settings.moonlight = { + version: newVersion, + data: dataBytes + }; + + const resp = await this.patchProto(toSend); + + if (!resp.ok) { + throw new Error(`Modify settings proto returned ${resp.status}`); + } else if (resp.body.out_of_date) { + const { version: serverVersion, settings: serverConfigJson } = this.unwrapProto(resp.body.settings); + if (serverVersion > this.remoteVersion) { + // We somehow missed a change + logger.info("Push somehow out of date, cannot recover"); + this.pendingRemoteConfig = serverConfigJson; + this.pendingRemoteVersion = serverVersion; + this.localDirty = true; + this.status = SyncStatus.Conflict; + this.emitChange(); + this.showConflictNotice(); + return; + } + + // It's ok, only data_version changed + if (!retried) { + logger.info("Push out of date, retrying with updated base"); + await this.push(true); + return; + } + + throw new Error("Push repeatedly out of date after data_version refresh"); + } + + await moonlightNode.writeConfig({ ...moonlightNode.getFullConfig(), _rev: newVersion }); + this.remoteVersion = newVersion; + this.localDirty = false; + this.status = SyncStatus.Idle; + this.emitChange(); + + logger.info(`Pushed settings version ${newVersion}`); + } catch (e: any) { + logger.error("Push failed:", e); + this.lastError = e?.body?.message ?? String(e); + this.status = SyncStatus.Error; + this.emitChange(); + } + } + + async resolveConflictUseRemote(fromSettings: boolean = false) { + if (this.pendingRemoteConfig == null) return; + fromSettings && Notices.popNotice(); + + this.remoteVersion = this.pendingRemoteVersion; + await this.applyRemoteConfig(this.pendingRemoteConfig, this.pendingRemoteVersion); + this.localDirty = false; + this.pendingRemoteConfig = null; + this.pendingRemoteVersion = 0; + this.status = SyncStatus.Idle; + this.emitChange(); + } + + async resolveConflictUseLocal(fromSettings: boolean = false) { + fromSettings && Notices.popNotice(); + + this.remoteVersion = this.pendingRemoteVersion; + this.pendingRemoteConfig = null; + this.pendingRemoteVersion = 0; + this.localDirty = true; + this.status = SyncStatus.Idle; + this.emitChange(); + await this.push(); + } + + private async applyRemoteConfig(configJson: string, remoteVersion: number) { + try { + const remoteConfig: Config = JSON.parse(configJson); + + await moonlightNode.writeConfig({ ...remoteConfig, _rev: remoteVersion }); + } catch (e) { + logger.error("Failed to apply remote config:", e); + } + } + + private showRemoteUpdateNotice() { + Notices.addNotice({ + element: React.createElement("span", null, "Your moonlight settings were synced automatically."), + showClose: true, + buttons: [ + { + name: "Reload", + onClick: () => { + window.location.reload(); + return true; + } + } + ] + }); + } + + private showFirstSyncNotice() { + Notices.addNotice({ + element: React.createElement( + "span", + null, + "Cloud sync found existing settings on the server. Which would you like to keep? This action is permanent." + ), + showClose: false, + buttons: [ + { + name: "Keep remote", + onClick: () => { + this.resolveConflictUseRemote(); + return true; + } + }, + { + name: "Keep local", + onClick: () => { + this.resolveConflictUseLocal(); + return true; + } + } + ] + }); + } + + private showConflictNotice() { + Notices.addNotice({ + element: React.createElement( + "span", + null, + "Cloud sync conflict: remote settings changed while you have unsaved local changes." + ), + showClose: true, + buttons: [ + { + name: "Keep remote", + onClick: () => { + this.resolveConflictUseRemote(); + return true; + } + }, + { + name: "Keep local", + onClick: () => { + this.resolveConflictUseLocal(); + return true; + } + } + ] + }); + } + + async syncNow() { + await this.pull(); + if (this.localDirty && this.status !== SyncStatus.Conflict) { + await this.push(); + } + } + + async deleteSyncData() { + if (!this.lastDecodedMsg) { + await this.pull(); + if (!this.lastDecodedMsg || this.status !== SyncStatus.Idle) { + throw new Error("Aborting delete: failed to pull latest settings from server"); + } + } + + this.status = SyncStatus.Pushing; + this.emitChange(); + + try { + const toSend = CustomUserSettings.clone(this.lastDecodedMsg); + toSend.settings ??= {}; + toSend.settings.moonlight = undefined; + + const resp = await this.patchProto(toSend); + if (!resp.ok) throw new Error(`Delete settings proto returned ${resp.status}`); + + await this.stop(); + await moonlightNode.setConfigOption("cloudSync", "enabled", false); + logger.info("Server settings deleted and cloud sync disabled"); + } catch (e: any) { + logger.error("Delete failed:", e); + this.lastError = e?.body?.message ?? String(e); + this.status = SyncStatus.Error; + this.emitChange(); + } + } +} + +const cloudSyncStore = new CloudSyncStore(); +cloudSyncStore.start(); + +export default cloudSyncStore; diff --git a/packages/core-extensions/src/cloudSync/wp.d.ts b/packages/core-extensions/src/cloudSync/wp.d.ts new file mode 100644 index 00000000..d758b874 --- /dev/null +++ b/packages/core-extensions/src/cloudSync/wp.d.ts @@ -0,0 +1,11 @@ +declare module "@moonlight-mod/wp/cloudSync_proto" { + export * from "src/cloudSync/webpackModules/proto"; +} + +declare module "@moonlight-mod/wp/cloudSync_sync" { + import { CloudSyncStore } from "src/cloudSync/webpackModules/sync"; + + const _default: CloudSyncStore; + export default _default; + export * from "src/cloudSync/webpackModules/sync"; +} diff --git a/packages/core-extensions/src/moonbase/index.tsx b/packages/core-extensions/src/moonbase/index.tsx index a870cff7..c78143e2 100644 --- a/packages/core-extensions/src/moonbase/index.tsx +++ b/packages/core-extensions/src/moonbase/index.tsx @@ -40,11 +40,16 @@ export const webpackModules: Record = { { ext: "spacepack", id: "spacepack" }, { id: "react" }, { id: "discord/components/common/index" }, + { id: "discord/components/common/Select" }, + { id: "discord/uikit/Flex" }, + { id: "discord/uikit/legacy/Button" }, { ext: "moonbase", id: "stores" }, { ext: "moonbase", id: "ThemeDarkIcon" }, { id: "discord/modules/guild_settings/web/AppCard.css" }, { ext: "contextMenu", id: "contextMenu" }, { id: "discord/modules/modals/Modals" }, + { ext: "cloudSync", id: "sync" }, + { ext: "common", id: "ErrorBoundary" }, "Masks.PANEL_BUTTON", '"Missing channel in Channel.openChannelContextMenu"' ] diff --git a/packages/core-extensions/src/moonbase/manifest.json b/packages/core-extensions/src/moonbase/manifest.json index 0bdea4ab..a566472c 100644 --- a/packages/core-extensions/src/moonbase/manifest.json +++ b/packages/core-extensions/src/moonbase/manifest.json @@ -7,7 +7,7 @@ "tagline": "The official settings UI for moonlight", "authors": ["Cynosphere", "NotNite", "redstonekasi"] }, - "dependencies": ["spacepack", "settings", "common", "notices", "contextMenu"], + "dependencies": ["spacepack", "settings", "common", "notices", "contextMenu", "cloudSync"], "settings": { "sections": { "advice": "reload", diff --git a/packages/core-extensions/src/moonbase/webpackModules/stores.ts b/packages/core-extensions/src/moonbase/webpackModules/stores.ts index fbe67daa..07e84b5f 100644 --- a/packages/core-extensions/src/moonbase/webpackModules/stores.ts +++ b/packages/core-extensions/src/moonbase/webpackModules/stores.ts @@ -239,8 +239,9 @@ class MoonbaseSettingsStore extends Store { return settings?.[key]?.description; } - setExtensionConfig(id: string, key: string, value: any) { + setExtensionConfig(id: string, key: string, value: any, permanent: boolean = false) { setConfigOption(this.config, id, key, value); + if (permanent) moonlightNode.setConfigOption(id, key, value); this.modified = this.isModified(); this.emitChange(); } diff --git a/packages/core-extensions/src/moonbase/webpackModules/ui/cloudSync.tsx b/packages/core-extensions/src/moonbase/webpackModules/ui/cloudSync.tsx new file mode 100644 index 00000000..911ab684 --- /dev/null +++ b/packages/core-extensions/src/moonbase/webpackModules/ui/cloudSync.tsx @@ -0,0 +1,190 @@ +import CloudSyncStore, { SyncStatus } from "@moonlight-mod/wp/cloudSync_sync"; +import ErrorBoundary from "@moonlight-mod/wp/common_ErrorBoundary"; +import { MultiAccountStore, UserStore } from "@moonlight-mod/wp/common_stores"; +import { FormDivider, FormItem, FormSwitch, FormText, Text } from "@moonlight-mod/wp/discord/components/common/index"; +import { SingleSelect } from "@moonlight-mod/wp/discord/components/common/Select"; +import { openModalLazy } from "@moonlight-mod/wp/discord/modules/modals/Modals"; +import { useStateFromStores } from "@moonlight-mod/wp/discord/packages/flux"; +import Margins from "@moonlight-mod/wp/discord/styles/shared/Margins.css"; +import Flex from "@moonlight-mod/wp/discord/uikit/Flex"; +import { Button } from "@moonlight-mod/wp/discord/uikit/legacy/Button"; +import { MoonbaseSettingsStore } from "@moonlight-mod/wp/moonbase_stores"; +import React from "@moonlight-mod/wp/react"; +import spacepack from "@moonlight-mod/wp/spacepack_spacepack"; + +let ConfirmModal: typeof import("@moonlight-mod/wp/discord/components/modals/ConfirmModal").ConfirmModal; +function lazyLoadConfirmModal() { + if (!ConfirmModal) { + ConfirmModal = (spacepack.require("discord/components/modals/ConfirmModal") as any).ConfirmModal; + } +} + +function openDeleteConfirmModal() { + lazyLoadConfirmModal(); + openModalLazy(async () => { + return ({ transitionState, onClose }: { transitionState: number | null; onClose: () => void }) => ( + CloudSyncStore.deleteSyncData()} + > + + Are you sure you want to delete all cloud sync settings? This cannot be undone. + + + ); + }); +} + +const statusLabels: Record = { + [SyncStatus.Idle]: "Idle", + [SyncStatus.Pulling]: "Pulling remote settings…", + [SyncStatus.Pushing]: "Pushing local settings…", + [SyncStatus.Conflict]: "Conflict detected", + [SyncStatus.Error]: "Error" +}; + +const statusColors: Record = { + [SyncStatus.Idle]: "var(--text-positive)", + [SyncStatus.Pulling]: "var(--text-brand)", + [SyncStatus.Pushing]: "var(--text-brand)", + [SyncStatus.Conflict]: "var(--text-warning)", + [SyncStatus.Error]: "var(--text-danger)" +}; + +interface Account { + id: string; + username: string; +} + +function useAccounts(): { value: string; label: string; current: boolean }[] { + const accounts = useStateFromStores([MultiAccountStore], () => { + return MultiAccountStore.getValidUsers(); + }); + const currentUser = useStateFromStores([UserStore], () => UserStore.getCurrentUser()); + + return accounts.map((account: Account) => ({ + value: account.id, + label: account.username + (account.id === currentUser?.id ? " (current)" : ""), + current: account.id === currentUser?.id + })); +} + +export default function CloudSyncPage() { + const enabled = MoonbaseSettingsStore.getExtensionConfigRaw("cloudSync", "enabled", false) ?? false; + const accountId = MoonbaseSettingsStore.getExtensionConfigRaw("cloudSync", "accountId", "0") ?? "0"; + + const [status, lastError] = useStateFromStores<[SyncStatus, string | null]>([CloudSyncStore], () => [ + CloudSyncStore.status, + CloudSyncStore.lastError + ]); + + const accounts = useAccounts(); + if (accountId === "0") { + // Default to the current account + const currentAccount = accounts.find((a) => a.current); + if (currentAccount) { + MoonbaseSettingsStore.setExtensionConfig("cloudSync", "accountId", currentAccount.value, true); + } + } + + return ( + +
+ { + MoonbaseSettingsStore.setExtensionConfig("cloudSync", "enabled", value, true); + value ? CloudSyncStore.start(false) : await CloudSyncStore.stop(); + }} + label="Enable Cloud Sync" + description="Sync your moonlight settings with your Discord account" + /> +
+ + + Which account to use for sync + { + MoonbaseSettingsStore.setExtensionConfig("cloudSync", "accountId", v); + }} + /> + + + {enabled && ( + <> + + + + + + {statusLabels[status]} + + + + + + + + {status === SyncStatus.Error && lastError && ( + + {lastError} + + )} + + + {status === SyncStatus.Conflict && ( + <> + + + + + Remote settings changed while you had unsaved local modifications. Choose which version to keep. + + + + + + + + + )} + + )} +
+ ); +} diff --git a/packages/core-extensions/src/moonbase/webpackModules/ui/index.tsx b/packages/core-extensions/src/moonbase/webpackModules/ui/index.tsx index cf686ec5..0f2d7705 100644 --- a/packages/core-extensions/src/moonbase/webpackModules/ui/index.tsx +++ b/packages/core-extensions/src/moonbase/webpackModules/ui/index.tsx @@ -9,6 +9,7 @@ import { useStateFromStores } from "@moonlight-mod/wp/discord/packages/flux"; import Margins from "@moonlight-mod/wp/discord/styles/shared/Margins.css"; import React from "@moonlight-mod/wp/react"; import AboutPage from "./about"; +import CloudSyncPage from "./cloudSync"; import ConfigPage from "./config"; import ExtensionsPage from "./extensions"; import RestartAdviceMessage from "./RestartAdvice"; @@ -29,6 +30,11 @@ export const pages: { name: "Config", element: ConfigPage }, + { + id: "cloudSync", + name: "Cloud Sync", + element: CloudSyncPage + }, { id: "about", name: "About", diff --git a/packages/injector/src/index.ts b/packages/injector/src/index.ts index 4a5b2b60..1136a4bf 100644 --- a/packages/injector/src/index.ts +++ b/packages/injector/src/index.ts @@ -260,6 +260,9 @@ export async function inject(asarPath: string, _injectorConfig?: InjectorConfig) // @ts-expect-error Set by esbuild branch: MOONLIGHT_BRANCH as MoonlightBranch, + getFullConfig() { + return config; + }, getConfig, getConfigPath, getConfigOption(ext, name) { diff --git a/packages/node-preload/src/index.ts b/packages/node-preload/src/index.ts index a187937d..14e44e34 100644 --- a/packages/node-preload/src/index.ts +++ b/packages/node-preload/src/index.ts @@ -59,6 +59,9 @@ async function injectGlobals() { // @ts-expect-error Set by esbuild branch: MOONLIGHT_BRANCH as MoonlightBranch, + getFullConfig() { + return config; + }, getConfig(ext) { return getConfig(ext, config); }, diff --git a/packages/types/src/config.ts b/packages/types/src/config.ts index ac3e5f3d..f57ec12a 100644 --- a/packages/types/src/config.ts +++ b/packages/types/src/config.ts @@ -4,6 +4,7 @@ export type Config = { devSearchPaths?: string[]; loggerLevel?: string; patchAll?: boolean; + _rev?: number; }; export type ConfigExtensions = { [key: string]: boolean } | { [key: string]: ConfigExtension }; diff --git a/packages/types/src/globals.ts b/packages/types/src/globals.ts index 8614c6a7..8234b0c0 100644 --- a/packages/types/src/globals.ts +++ b/packages/types/src/globals.ts @@ -23,6 +23,7 @@ export type MoonlightHost = { version: string; branch: MoonlightBranch; + getFullConfig: () => Config; getConfig: (ext: string) => ConfigExtension["config"]; getConfigPath: () => Promise; getConfigOption: (ext: string, name: string) => T | undefined; @@ -46,6 +47,7 @@ export type MoonlightNode = { version: string; branch: MoonlightBranch; + getFullConfig: () => Config; getConfig: (ext: string) => ConfigExtension["config"]; getConfigOption: (ext: string, name: string) => T | undefined; setConfigOption: (ext: string, name: string, value: T) => Promise; @@ -82,6 +84,7 @@ export type MoonlightWeb = { apiLevel: number; // Re-exports for ease of use + getFullConfig: () => Config; getConfig: MoonlightNode["getConfig"]; getConfigOption: MoonlightNode["getConfigOption"]; setConfigOption: MoonlightNode["setConfigOption"]; diff --git a/packages/web-preload/src/index.ts b/packages/web-preload/src/index.ts index d5959079..4d5f7863 100644 --- a/packages/web-preload/src/index.ts +++ b/packages/web-preload/src/index.ts @@ -34,6 +34,7 @@ async function load() { branch: MOONLIGHT_BRANCH as MoonlightBranch, apiLevel: constants.apiLevel, + getFullConfig: moonlightNode.getFullConfig.bind(moonlightNode), getConfig: moonlightNode.getConfig.bind(moonlightNode), getConfigOption: moonlightNode.getConfigOption.bind(moonlightNode), setConfigOption: moonlightNode.setConfigOption.bind(moonlightNode), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f0adcade..076dfc8a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,6 +19,9 @@ catalogs: '@moonlight-mod/moonmap': specifier: ^1.0.7 version: 1.0.7 + '@protobuf-ts/runtime': + specifier: ^2.9.0 + version: 2.11.1 '@zenfs/core': specifier: ^2.0.0 version: 2.0.0 @@ -80,6 +83,12 @@ importers: '@biomejs/biome': specifier: catalog:lint version: 2.4.4 + '@protobuf-ts/plugin': + specifier: ^2.9.0 + version: 2.11.1 + '@protobuf-ts/protoc': + specifier: ^2.9.0 + version: 2.11.1 husky: specifier: catalog:lint version: 8.0.3 @@ -136,6 +145,9 @@ importers: '@moonlight-mod/types': specifier: workspace:* version: link:../types + '@protobuf-ts/runtime': + specifier: 'catalog:' + version: 2.11.1 microdiff: specifier: 'catalog:' version: 1.5.0 @@ -351,6 +363,12 @@ packages: cpu: [x64] os: [win32] + '@bufbuild/protobuf@2.11.0': + resolution: {integrity: sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==} + + '@bufbuild/protoplugin@2.11.0': + resolution: {integrity: sha512-lyZVNFUHArIOt4W0+dwYBe5GBwbKzbOy8ObaloEqsw9Mmiwv2O48TwddDoHN4itylC+BaEGqFdI1W8WQt2vWJQ==} + '@esbuild/android-arm64@0.19.3': resolution: {integrity: sha512-w+Akc0vv5leog550kjJV9Ru+MXMR2VuMrui3C61mnysim0gkFCPOUTAfzTP0qX+HpN9Syu3YA3p1hf3EPqObRw==} engines: {node: '>=12'} @@ -490,6 +508,20 @@ packages: resolution: {integrity: sha512-K4TXEHYPgAtPjWSPWtO9+jzs7+/OYAF1GGkh1BtXrkeVT8vEFlNMztbZYy+N0bJLWzwytS4mrheMdSaBfJw6fw==} engines: {node: '>=22', npm: pnpm, pnpm: '>=10', yarn: pnpm} + '@protobuf-ts/plugin@2.11.1': + resolution: {integrity: sha512-HyuprDcw0bEEJqkOWe1rnXUP0gwYLij8YhPuZyZk6cJbIgc/Q0IFgoHQxOXNIXAcXM4Sbehh6kjVnCzasElw1A==} + hasBin: true + + '@protobuf-ts/protoc@2.11.1': + resolution: {integrity: sha512-mUZJaV0daGO6HUX90o/atzQ6A7bbN2RSuHtdwo8SSF2Qoe3zHwa4IHyCN1evftTeHfLmdz+45qo47sL+5P8nyg==} + hasBin: true + + '@protobuf-ts/runtime-rpc@2.11.1': + resolution: {integrity: sha512-4CqqUmNA+/uMz00+d3CYKgElXO9VrEbucjnBFEjqI4GuDrEQ32MaI3q+9qPBvIGOlL4PmHXrzM32vBPWRhQKWQ==} + + '@protobuf-ts/runtime@2.11.1': + resolution: {integrity: sha512-KuDaT1IfHkugM2pyz+FwiY80ejWrkH1pAtOBOZFuR6SXEFTsnb/jiQWQ1rCIrcKx2BtyxnxW6BWwsVSA/Ie+WQ==} + '@types/chroma-js@3.1.2': resolution: {integrity: sha512-YBTQqArPN8A0niHXCwrO1z5x++a+6l0mLBykncUpr23oIPW7L4h39s6gokdK/bDrPmSh8+TjMmrhBPnyiaWPmQ==} @@ -538,6 +570,11 @@ packages: '@types/react@19.1.2': resolution: {integrity: sha512-oxLPMytKchWGbnQM9O7D67uPa9paTNxO7jVoNMXgkkErULBPhPARCfkKL9ytcIJJRGjbsVwW4ugJzyFFvm/Tiw==} + '@typescript/vfs@1.6.4': + resolution: {integrity: sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ==} + peerDependencies: + typescript: '*' + '@xterm/xterm@5.5.0': resolution: {integrity: sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==} @@ -570,6 +607,15 @@ packages: csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + esbuild@0.19.3: resolution: {integrity: sha512-UlJ1qUUA2jL2nNib1JTSkifQTcYTroFqRjwCFW4QYEKEsixXD5Tik9xML7zh2gTxkYTBKGHNH9y7txMwVyPbjw==} engines: {node: '>=12'} @@ -604,6 +650,9 @@ packages: microdiff@1.5.0: resolution: {integrity: sha512-Drq+/THMvDdzRYrK0oxJmOKiC24ayUV8ahrt8l3oRK51PWt6gdtrIGrlIH3pT/lFh1z93FbAcidtsHcWbnRz8Q==} + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + nanotar@0.1.1: resolution: {integrity: sha512-AiJsGsSF3O0havL1BydvI4+wR76sKT+okKRwWIaK96cZUnXqH0uNBOsHlbwZq3+m2BR1VKqHDVudl3gO4mYjpQ==} @@ -624,6 +673,16 @@ packages: string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + typescript@3.9.10: + resolution: {integrity: sha512-w6fIxVE/H1PkLKcCPsFqKE7Kv7QUwhU8qQY2MueZXWx5cPZdwFupLgKK3vntcK98BtNHZtAF4LA/yl2a7k8R6Q==} + engines: {node: '>=4.2.0'} + hasBin: true + + typescript@5.4.5: + resolution: {integrity: sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==} + engines: {node: '>=14.17'} + hasBin: true + typescript@5.8.2: resolution: {integrity: sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==} engines: {node: '>=14.17'} @@ -693,6 +752,16 @@ snapshots: '@biomejs/cli-win32-x64@2.4.4': optional: true + '@bufbuild/protobuf@2.11.0': {} + + '@bufbuild/protoplugin@2.11.0': + dependencies: + '@bufbuild/protobuf': 2.11.0 + '@typescript/vfs': 1.6.4(typescript@5.4.5) + typescript: 5.4.5 + transitivePeerDependencies: + - supports-color + '@esbuild/android-arm64@0.19.3': optional: true @@ -767,6 +836,25 @@ snapshots: '@moonlight-mod/moonmap@1.0.7': {} + '@protobuf-ts/plugin@2.11.1': + dependencies: + '@bufbuild/protobuf': 2.11.0 + '@bufbuild/protoplugin': 2.11.0 + '@protobuf-ts/protoc': 2.11.1 + '@protobuf-ts/runtime': 2.11.1 + '@protobuf-ts/runtime-rpc': 2.11.1 + typescript: 3.9.10 + transitivePeerDependencies: + - supports-color + + '@protobuf-ts/protoc@2.11.1': {} + + '@protobuf-ts/runtime-rpc@2.11.1': + dependencies: + '@protobuf-ts/runtime': 2.11.1 + + '@protobuf-ts/runtime@2.11.1': {} + '@types/chroma-js@3.1.2': {} '@types/chrome@0.0.313': @@ -815,6 +903,13 @@ snapshots: dependencies: csstype: 3.1.3 + '@typescript/vfs@1.6.4(typescript@5.4.5)': + dependencies: + debug: 4.4.3 + typescript: 5.4.5 + transitivePeerDependencies: + - supports-color + '@xterm/xterm@5.5.0': optional: true @@ -846,6 +941,10 @@ snapshots: csstype@3.1.3: {} + debug@4.4.3: + dependencies: + ms: 2.1.3 + esbuild@0.19.3: optionalDependencies: '@esbuild/android-arm': 0.19.3 @@ -890,6 +989,8 @@ snapshots: microdiff@1.5.0: {} + ms@2.1.3: {} + nanotar@0.1.1: {} process@0.11.10: {} @@ -912,6 +1013,10 @@ snapshots: dependencies: safe-buffer: 5.2.1 + typescript@3.9.10: {} + + typescript@5.4.5: {} + typescript@5.8.2: {} undici-types@6.20.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 54c08ddc..c72c853c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,6 +4,7 @@ packages: catalog: "@moonlight-mod/lunast": ^1.0.1 "@moonlight-mod/moonmap": ^1.0.7 + "@protobuf-ts/runtime": ^2.9.0 "@zenfs/core": ^2.0.0 "@zenfs/dom": ^1.1.3 microdiff: ^1.5.0 diff --git a/scripts/build-proto.mjs b/scripts/build-proto.mjs new file mode 100644 index 00000000..ae137399 --- /dev/null +++ b/scripts/build-proto.mjs @@ -0,0 +1,25 @@ +/** biome-ignore-all lint/suspicious/noConsole: repository script */ + +import { execSync } from "node:child_process"; +import * as fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(__dirname, ".."); +const protoDir = path.join(root, "packages", "core-extensions", "src", "cloudSync", "proto"); + +execSync(`pnpm exec protoc --ts_out "${protoDir}" --proto_path "${protoDir}" settings.proto`, { + stdio: "inherit", + cwd: root +}); + +// biome loves complaining +const generatedFilePath = path.join(protoDir, "settings.ts"); +const generatedContent = await fs.readFile(generatedFilePath, "utf-8"); +const biomeDirective = "/** biome-ignore-all lint: auto-generated file */\n\n"; +if (!generatedContent.startsWith(biomeDirective)) { + await fs.writeFile(generatedFilePath, biomeDirective + generatedContent); +} + +console.log(`Protobuf build complete: ${generatedFilePath}`);