diff --git a/rust/crates/spicetify/src/platform/macos.rs b/rust/crates/spicetify/src/platform/macos.rs index 66ad69ef36..7cffb753fd 100644 --- a/rust/crates/spicetify/src/platform/macos.rs +++ b/rust/crates/spicetify/src/platform/macos.rs @@ -129,12 +129,24 @@ fn install_protocol_handler() -> crate::error::Result { tracing::debug!(error = %e, "could not remove the temporary applescript"); } - declare_url_scheme(&bundle.join("Contents").join("Info.plist"))?; + finalize_protocol_bundle(&bundle)?; run(LSREGISTER, &["-f".as_ref(), bundle.as_os_str()])?; Ok(bundle) } +fn finalize_protocol_bundle(bundle: &Path) -> crate::error::Result<()> { + declare_url_scheme(&bundle.join("Contents").join("Info.plist"))?; + run( + "/usr/bin/codesign", + &["--force".as_ref(), "--sign".as_ref(), "-".as_ref(), bundle.as_os_str()], + )?; + run( + "/usr/bin/codesign", + &["--verify".as_ref(), "--deep".as_ref(), "--strict".as_ref(), bundle.as_os_str()], + ) +} + /// `quoted form of` is `AppleScript`'s shell escaping, so a hostile URI cannot /// break out of the command. The CLI validates the URI itself. fn applescript(exe: &Path, log: &Path) -> String { @@ -184,6 +196,46 @@ fn run(program: &str, args: &[&std::ffi::OsStr]) -> crate::error::Result<()> { mod tests { use super::*; + #[test] + fn protocol_bundle_signature_covers_the_final_url_metadata() -> crate::error::Result<()> { + let mut nonce = [0; 16]; + getrandom::fill(&mut nonce)?; + let root = std::env::temp_dir().join(format!("spicetify-protocol-{}", hex::encode(nonce))); + std::fs::create_dir(&root)?; + let result = (|| { + let source = root.join("handler.applescript"); + let bundle = root.join("Spicetify.app"); + std::fs::write( + &source, + applescript(Path::new("/usr/bin/true"), &root.join("protocol.log")), + )?; + run("/usr/bin/osacompile", &["-o".as_ref(), bundle.as_os_str(), source.as_os_str()])?; + finalize_protocol_bundle(&bundle)?; + let metadata = std::process::Command::new("/usr/bin/plutil") + .args(["-convert", "json", "-o", "-"]) + .arg(bundle.join("Contents/Info.plist")) + .output()?; + assert!(metadata.status.success()); + let plist: serde_json::Value = serde_json::from_slice(&metadata.stdout)?; + assert_eq!( + plist.get("CFBundleIdentifier").and_then(serde_json::Value::as_str), + Some(BUNDLE_ID) + ); + assert_eq!( + plist + .pointer("/CFBundleURLTypes/0/CFBundleURLSchemes/0") + .and_then(serde_json::Value::as_str), + Some("spicetify") + ); + run( + "/usr/bin/codesign", + &["--verify".as_ref(), "--deep".as_ref(), "--strict".as_ref(), bundle.as_os_str()], + ) + })(); + std::fs::remove_dir_all(&root)?; + result + } + #[test] fn resolve_bundle_prefers_an_existing_candidate() { let dir = std::env::temp_dir().join(format!("spicetify-bundle-{}", std::process::id())); diff --git a/src/jsHelper/spicetifyWrapper/webpack/graphql-definitions.js b/src/jsHelper/spicetifyWrapper/webpack/graphql-definitions.js new file mode 100644 index 0000000000..570aeed100 --- /dev/null +++ b/src/jsHelper/spicetifyWrapper/webpack/graphql-definitions.js @@ -0,0 +1,56 @@ +const DEFINITION = /\bnew\s+[\w$]+(?:\.[\w$]+)*\(\s*"([_A-Za-z][_0-9A-Za-z]*)"\s*,\s*"(query|mutation)"\s*,\s*"([a-fA-F0-9]{64})"\s*,\s*null\s*\)/g; + +export function extractGraphQLDefinitions(source) { + return Array.from(source.matchAll(DEFINITION), ([, name, operation, sha256Hash]) => Object.freeze({ name, operation, sha256Hash, value: null })); +} + +// Read metadata without requiring modules or rewriting client code. A stable +// dictionary keeps destructured references useful when later chunks arrive. +export function createGraphQLDefinitions(getFactories) { + const definitions = Object.create(null); + const parsed = new WeakMap(); + let previous = new Set(); + + function refresh() { + const factories = new Set(Object.values(getFactories() ?? {}).filter((factory) => typeof factory === "function")); + if (factories.size === previous.size && [...factories].every((factory) => previous.has(factory))) return; + previous = factories; + for (const name of Object.keys(definitions)) delete definitions[name]; + const conflicts = new Set(); + for (const factory of factories) { + if (!parsed.has(factory)) parsed.set(factory, extractGraphQLDefinitions(Function.prototype.toString.call(factory))); + for (const definition of parsed.get(factory)) { + const existing = definitions[definition.name]; + if (existing && (existing.operation !== definition.operation || existing.sha256Hash !== definition.sha256Hash)) { + conflicts.add(definition.name); + } + definitions[definition.name] = definition; + } + } + for (const name of conflicts) delete definitions[name]; + } + + return new Proxy(definitions, { + get(target, name) { + if (typeof name === "string") refresh(); + return Reflect.get(target, name); + }, + has(target, name) { + refresh(); + return Reflect.has(target, name); + }, + ownKeys(target) { + refresh(); + return Reflect.ownKeys(target); + }, + getOwnPropertyDescriptor(target, name) { + refresh(); + return Reflect.getOwnPropertyDescriptor(target, name); + }, + set: () => false, + defineProperty: () => false, + deleteProperty: () => false, + preventExtensions: () => false, + setPrototypeOf: () => false, + }); +} diff --git a/src/jsHelper/spicetifyWrapper/webpack/graphql-definitions.test.mts b/src/jsHelper/spicetifyWrapper/webpack/graphql-definitions.test.mts new file mode 100644 index 0000000000..a2e3594bad --- /dev/null +++ b/src/jsHelper/spicetifyWrapper/webpack/graphql-definitions.test.mts @@ -0,0 +1,161 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createGraphQLDefinitions, extractGraphQLDefinitions } from "./graphql-definitions.js"; +import { createSpicetifyBindings } from "./spicetify-bindings.js"; + +declare const api: { Document: new (name: string, operation: string, hash: string, value: null) => unknown }; + +function albumFactory() { + new api.Document("getAlbum", "query", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", null); + throw new Error("discovery must not execute factories"); +} + +function searchFactory() { + return new api.Document("searchDesktop", "query", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", null); +} + +function mutationFactory() { + return new api.Document("saveRecentSearches", "mutation", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", null); +} + +test("discovers current-client definitions without executing factories", () => { + const definitions = createGraphQLDefinitions(() => ({ album: albumFactory, mutation: mutationFactory })); + assert.deepEqual(definitions.getAlbum, { + name: "getAlbum", + operation: "query", + sha256Hash: "a".repeat(64), + value: null, + }); + assert.equal(definitions.saveRecentSearches.operation, "mutation"); + assert.deepEqual(Object.keys(definitions).sort(), ["getAlbum", "saveRecentSearches"]); + assert.equal(definitions.missingOperation, undefined); +}); + +test("late chunks are discovered and replaced or removed factories do not leave stale definitions", () => { + const factories: Record unknown> = { current: albumFactory }; + const definitions = createGraphQLDefinitions(() => factories); + const album = definitions.getAlbum; + assert.ok(album); + assert.equal(definitions.getAlbum, album, "unchanged definitions keep their identity"); + factories.current = searchFactory; + assert.equal(definitions.getAlbum, undefined); + assert.equal(definitions.searchDesktop.name, "searchDesktop"); + delete factories.current; + assert.deepEqual(Object.keys(definitions), []); +}); + +test("deduplicates identical definitions but refuses conflicting operation hashes", () => { + function conflict() { + return new api.Document("getAlbum", "query", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", null); + } + const factories: Record unknown> = { first: albumFactory, duplicate: albumFactory }; + const definitions = createGraphQLDefinitions(() => factories); + assert.equal(definitions.getAlbum.sha256Hash, "a".repeat(64)); + factories.duplicate = conflict; + assert.equal(definitions.getAlbum, undefined); + assert.equal("getAlbum" in definitions, false); + delete factories.duplicate; + assert.equal(definitions.getAlbum.sha256Hash, "a".repeat(64)); +}); + +test("own-property inspection discovers late chunks and drops removed definitions", () => { + const factories: Record unknown> = {}; + const definitions = createGraphQLDefinitions(() => factories); + assert.equal(Object.hasOwn(definitions, "getAlbum"), false); + factories.album = albumFactory; + assert.equal(Object.hasOwn(definitions, "getAlbum"), true); + assert.equal(Object.getOwnPropertyDescriptor(definitions, "getAlbum")?.value.name, "getAlbum"); + delete factories.album; + assert.equal(Object.hasOwn(definitions, "getAlbum"), false); +}); + +test("conflicting operation types are omitted even when the hashes match", () => { + function conflict() { + return new api.Document("getAlbum", "mutation", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", null); + } + const definitions = createGraphQLDefinitions(() => ({ album: albumFactory, conflict })); + assert.equal(definitions.getAlbum, undefined); +}); + +test("invalid metadata is ignored and the registry tolerates unavailable capture", () => { + function invalid() { + const badHash = new api.Document("badHash", "query", "not-a-hash", null); + const subscription = new api.Document("stream", "subscription", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", null); + return [badHash, subscription]; + } + let factories: Record unknown> | undefined; + const definitions = createGraphQLDefinitions(() => factories); + assert.deepEqual(Object.keys(definitions), []); + factories = { invalid, album: albumFactory }; + assert.deepEqual(Object.keys(definitions), ["getAlbum"]); +}); + +test("definitions and their registry cannot be modified by consumers", () => { + const definitions = createGraphQLDefinitions(() => ({ album: albumFactory })); + assert.equal(Object.getPrototypeOf(definitions), null); + assert.equal(Reflect.setPrototypeOf(definitions, { fake: {} }), false); + assert.equal(Reflect.set(definitions, "getAlbum", {}), false); + assert.throws(() => { + definitions.getAlbum = {}; + }, TypeError); + assert.equal(Reflect.defineProperty(definitions, "fake", { value: {} }), false); + assert.equal(Reflect.deleteProperty(definitions, "getAlbum"), false); + assert.equal(Reflect.preventExtensions(definitions), false); + assert.equal(Reflect.set(definitions.getAlbum, "sha256Hash", "b".repeat(64)), false); + assert.equal(definitions.getAlbum.sha256Hash, "a".repeat(64)); + assert.equal(definitions.fake, undefined); +}); + +test("recognizes the compact persisted-operation shape used by Spotify bundles", () => { + const source = 'var a=new n.Ay("getAlbum","query","aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",null);'; + assert.deepEqual(extractGraphQLDefinitions(source), [ + { + name: "getAlbum", + operation: "query", + sha256Hash: "a".repeat(64), + value: null, + }, + ]); +}); + +for (const mode of [undefined, "direct", "snapshot"]) { + test(`wrapper GraphQL binding preserves the ${mode ?? "v2"} contract`, (t) => { + const legacyDefinitions = { legacy: { name: "legacy" } }; + for (const [name, value] of Object.entries({ + Spicetify: { GraphQL: { Definitions: legacyDefinitions } }, + __SPICETIFY_CLIENT_BUNDLE_MODE__: mode, + })) { + const original = Object.getOwnPropertyDescriptor(globalThis, name); + Object.defineProperty(globalThis, name, { configurable: true, writable: true, value }); + t.after(() => { + if (original) Object.defineProperty(globalThis, name, original); + else Reflect.deleteProperty(globalThis, name); + }); + } + const require = Object.assign( + () => { + throw new Error("must not execute factories"); + }, + { m: { album: albumFactory } }, + ); + const bindings = createSpicetifyBindings({ + cache: [], + chunks: [], + modules: [], + functionModules: [], + require, + exportedMemos: [], + exportedMemoFRefs: [], + reactComponentsUI: {}, + scrollableContainer: undefined, + }); + if (mode === undefined) { + assert.equal(bindings.GraphQL.Definitions, legacyDefinitions); + assert.equal(Reflect.set(bindings.GraphQL.Definitions, "extra", {}), true); + } else { + assert.equal(bindings.GraphQL.Definitions.getAlbum.sha256Hash, "a".repeat(64)); + assert.equal(Reflect.set(bindings.GraphQL.Definitions, "extra", {}), false); + } + }); +} diff --git a/src/jsHelper/spicetifyWrapper/webpack/spicetify-bindings.js b/src/jsHelper/spicetifyWrapper/webpack/spicetify-bindings.js index 56a3c8601d..5bfd02ed71 100644 --- a/src/jsHelper/spicetifyWrapper/webpack/spicetify-bindings.js +++ b/src/jsHelper/spicetifyWrapper/webpack/spicetify-bindings.js @@ -1,4 +1,5 @@ import { fnStr } from "../shared/string.js"; +import { createGraphQLDefinitions } from "./graphql-definitions.js"; import { createReactComponents } from "./react-components.js"; import { findReactQuery } from "./react-query.js"; @@ -31,6 +32,7 @@ export function createSpicetifyBindings({ }, GraphQL: { ...Spicetify.GraphQL, + Definitions: globalThis.__SPICETIFY_CLIENT_BUNDLE_MODE__ ? createGraphQLDefinitions(() => require.m) : Spicetify.GraphQL.Definitions, get Request() { return Spicetify.Platform?.GraphQLLoader || Spicetify.GraphQL.Handler?.(Spicetify.GraphQL.Context); },