Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 53 additions & 1 deletion rust/crates/spicetify/src/platform/macos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,12 +129,24 @@ fn install_protocol_handler() -> crate::error::Result<PathBuf> {
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 {
Expand Down Expand Up @@ -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()));
Expand Down
56 changes: 56 additions & 0 deletions src/jsHelper/spicetifyWrapper/webpack/graphql-definitions.js
Original file line number Diff line number Diff line change
@@ -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,
});
}
161 changes: 161 additions & 0 deletions src/jsHelper/spicetifyWrapper/webpack/graphql-definitions.test.mts
Original file line number Diff line number Diff line change
@@ -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<string, () => 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<string, () => 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<string, () => 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<string, () => 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);
}
});
}
2 changes: 2 additions & 0 deletions src/jsHelper/spicetifyWrapper/webpack/spicetify-bindings.js
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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);
},
Expand Down