commit 9dc1838fc2341c07717eee608d46efa051ccc23e Author: linus Date: Fri Sep 18 20:55:24 2026 +0200 IDK diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c25e1e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/build_app.sh b/build_app.sh new file mode 100755 index 0000000..a09ce2b --- /dev/null +++ b/build_app.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# Builds "AniList Companion.app" by copying the already-downloaded +# Electron.app binary and rebranding it with our app code + a custom +# Info.plist - avoids re-downloading Electron via electron-builder (disk +# space is tight), same technique electron-packager uses internally. +set -euo pipefail + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ELECTRON_APP="$DIR/node_modules/electron/dist/Electron.app" +OUT_APP="/Applications/AniList Companion.app" + +if [ ! -d "$ELECTRON_APP" ]; then + echo "Electron.app not found at $ELECTRON_APP - run 'npm install' first." >&2 + exit 1 +fi + +rm -rf "$OUT_APP" +cp -R "$ELECTRON_APP" "$OUT_APP" + +# Rename the binary itself so it shows up correctly in Activity Monitor / +# Dock, and embed our app source into Contents/Resources/app. +mv "$OUT_APP/Contents/MacOS/Electron" "$OUT_APP/Contents/MacOS/AniList Companion" + +RESOURCES="$OUT_APP/Contents/Resources" +mkdir -p "$RESOURCES/app" +cp -R "$DIR/src" "$RESOURCES/app/src" +cp "$DIR/package.json" "$RESOURCES/app/package.json" + +# Custom Info.plist: proper app name, bundle id, icon reference. +cat > "$OUT_APP/Contents/Info.plist" << 'PLIST' + + + + + CFBundleName + AniList Companion + CFBundleDisplayName + AniList Companion + CFBundleIdentifier + com.anilist-tracker.companion + CFBundleExecutable + AniList Companion + CFBundleIconFile + electron.icns + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0.0 + CFBundleVersion + 1.0.0 + LSMinimumSystemVersion + 11.0 + NSHighResolutionCapable + + + +PLIST + +# Re-sign ad-hoc so macOS Gatekeeper accepts the renamed/modified bundle. +codesign --force --deep --sign - "$OUT_APP" 2>&1 | tail -5 + +echo "Built: $OUT_APP" diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..091491e --- /dev/null +++ b/package-lock.json @@ -0,0 +1,889 @@ +{ + "name": "anilist-app", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "anilist-app", + "version": "1.0.0", + "license": "MIT", + "devDependencies": { + "electron": "^38.5.0" + } + }, + "node_modules/@electron/get": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", + "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/@electron/get/node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/@electron/get/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/get/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@electron/get/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "dev": true, + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "24.13.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.5.tgz", + "integrity": "sha512-TXyindR+lBr22aJIdMQzCFHPHR6cR4js838mRDCSz5hOKWZvZwsXSSiXDmjRj4iJmgl+sR9O+1mkoVBSMadNug==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", + "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/electron": { + "version": "38.8.6", + "resolved": "https://registry.npmjs.org/electron/-/electron-38.8.6.tgz", + "integrity": "sha512-lyBhcVi9QYAZL6FO6r5twAWAjWnYomo3iVDvrb5SJZlq928BGemHOKG0tPIq41NOLaCu9f3XdEEjMkjQPjprRg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@electron/get": "^2.0.0", + "@types/node": "^22.7.7", + "extract-zip": "^2.0.1" + }, + "bin": { + "electron": "cli.js" + }, + "engines": { + "node": ">= 12.20.55" + } + }, + "node_modules/electron/node_modules/@types/node": { + "version": "22.20.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.3.tgz", + "integrity": "sha512-DZmzkmwHzXrLPAXPyKNDzlIwMMUZCVacoD25ywdy5YTKGbOx/2ld+Q38Im2zJ0vBuZP5Prd3VZutKZyXwkOS8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/electron/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/got": { + "version": "11.8.6", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/sumchecker": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", + "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9b4ea6c --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "anilist-app", + "version": "1.0.0", + "description": "Standalone desktop app for tracking anime on AniList, with a built-in browser running the real MAL-Sync extension.", + "main": "src/main.js", + "scripts": { + "start": "electron .", + "build:mac": "electron-builder --mac" + }, + "author": "linus", + "license": "MIT", + "devDependencies": { + "electron": "^38.5.0" + } +} diff --git a/src/dashboard-preload.js b/src/dashboard-preload.js new file mode 100644 index 0000000..edf971f --- /dev/null +++ b/src/dashboard-preload.js @@ -0,0 +1,11 @@ +const { contextBridge, ipcRenderer } = require("electron"); + +contextBridge.exposeInMainWorld("dashboardApi", { + fetchWatching: () => ipcRenderer.invoke("fetch-watching"), + searchAnime: (query) => ipcRenderer.invoke("search-anime", query), + addAnime: (mediaId, status) => ipcRenderer.invoke("add-anime", mediaId, status), + updateAnime: (mediaId, fields) => ipcRenderer.invoke("update-anime", mediaId, fields), + watchNow: (url, trackable) => ipcRenderer.invoke("watch-now", { url, trackable }), + fetchMediaDetail: (mediaId) => ipcRenderer.invoke("fetch-media-detail", mediaId), + onRefresh: (cb) => ipcRenderer.on("refresh", cb) +}); diff --git a/src/dashboard.html b/src/dashboard.html new file mode 100644 index 0000000..fc28703 --- /dev/null +++ b/src/dashboard.html @@ -0,0 +1,924 @@ + + + + + + + +
+
+

🎬 Meine AniList-Aktivität

+
Lade...
+
+
+
+ + +
+ + + +
+
+ + + + + + + + +
+ +
+
+ + + + + + diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..4355274 --- /dev/null +++ b/src/main.js @@ -0,0 +1,601 @@ +const { app, BrowserWindow, BrowserView, ipcMain, session, shell } = require("electron"); +const path = require("path"); +const fs = require("fs"); +const os = require("os"); + +// Path to the MAL-Sync extension we built earlier from the real MALSync +// source (github.com/MALSync/MALSync). Electron can load unpacked Chrome +// extensions directly via session.loadExtension, so the SAME extension +// code that runs in Brave also runs here, inside our own app's browser +// tab - MAL-Sync itself is unmodified, just hosted in our own window +// instead of a general-purpose browser. +const MALSYNC_EXTENSION_PATH = path.join( + require("os").homedir(), + "projects/MALSync/dist/webextension" +); + +// Shares the same config file as our existing anilist-tracker hub server +// (~/.anilist-tracker/config.json), so this app reads the SAME AniList +// access token you already logged in with - no separate login needed. +const CONFIG_PATH = path.join(os.homedir(), ".anilist-tracker", "config.json"); + +function loadHubConfig() { + try { + return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8")); + } catch { + return null; + } +} + +const WATCHING_QUERY = ` +query ($userId: Int) { + MediaListCollection(userId: $userId, type: ANIME, status_in: [CURRENT, REPEATING, COMPLETED, PAUSED, DROPPED, PLANNING]) { + user { mediaListOptions { scoreFormat } } + lists { + entries { + id + progress + status + score + updatedAt + media { + id + title { romaji english } + episodes + coverImage { medium large } + bannerImage + siteUrl + externalLinks { site url type language icon color } + } + } + } + } +} +`; + +const MEDIA_DETAIL_QUERY = ` +query ($id: Int) { + Media(id: $id) { + id + title { romaji english native } + description(asHtml: false) + genres + averageScore + meanScore + popularity + status + format + season + seasonYear + episodes + duration + startDate { year month day } + endDate { year month day } + studios(isMain: true) { nodes { name } } + coverImage { large } + bannerImage + externalLinks { site url type color } + } +} +`; + +async function fetchMediaDetail(mediaId) { + const cfg = loadHubConfig(); + const res = await fetch("https://graphql.anilist.co", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + ...(cfg?.accessToken ? { Authorization: `Bearer ${cfg.accessToken}` } : {}) + }, + body: JSON.stringify({ query: MEDIA_DETAIL_QUERY, variables: { id: mediaId } }) + }); + const json = await res.json(); + if (json.errors) throw new Error(json.errors.map((e) => e.message).join(", ")); + return json.data.Media; +} + +// Streaming platforms we actually know how to route into our own +// embedded "Streamen" browser tab with MAL-Sync tracking active (site +// keys map 1:1 to MAL-Sync's supported page list). Anything AniList +// reports outside this set still gets a working link, just opened in the +// user's default system browser instead (shell.openExternal) since we +// can't guarantee MAL-Sync tracks it inside our own window. +const TRACKED_STREAMING_SITES = new Set([ + "crunchyroll.com", + "netflix.com", + "hidive.com", + "hulu.com", + "animationdigitalnetwork.com", + "bilibili.tv", + "iq.com", + "disneyplus.com", + "primevideo.com", + "amazon.com" +]); + +function isTrackedHost(url) { + try { + const host = new URL(url).hostname.replace(/^www\./, ""); + return [...TRACKED_STREAMING_SITES].some((h) => host === h || host.endsWith("." + h)); + } catch { + return false; + } +} + +// Runs in the MAIN process (full Node + network access, no renderer +// sandbox/CSP quirks to fight) and is exposed to the dashboard view via +// ipcMain.handle below - the standard, reliable Electron pattern for +// renderer-to-network calls. +async function fetchWatching() { + const cfg = loadHubConfig(); + if (!cfg || !cfg.accessToken || !cfg.userId) { + return { loggedIn: false }; + } + const res = await fetch("https://graphql.anilist.co", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + Authorization: `Bearer ${cfg.accessToken}` + }, + body: JSON.stringify({ query: WATCHING_QUERY, variables: { userId: cfg.userId } }) + }); + const json = await res.json(); + const collection = json.data?.MediaListCollection; + const entries = (collection?.lists || []).flatMap((l) => l.entries); + entries.sort((a, b) => b.updatedAt - a.updatedAt); + // Attach only the REAL streaming availability for each entry (AniList's + // own externalLinks with type STREAMING) - this is what lets the + // dashboard show "only where it's actually available" instead of a + // fixed site picker, and lets us jump straight to the show's real page + // instead of a generic site search. + for (const e of entries) { + const links = (e.media.externalLinks || []).filter((l) => l.type === "STREAMING"); + e.streamingLinks = links.map((l) => ({ + site: l.site, + url: l.url, + color: l.color, + icon: l.icon, + trackable: isTrackedHost(l.url) + })); + } + // scoreFormat tells the dashboard how to render/collect the score input + // (POINT_5 = 0-5 stars, POINT_10 = 0-10, POINT_100 = 0-100, etc.) - the + // raw `score` field on each entry is ALREADY in this format, no + // conversion needed as long as we send updates back in the same format. + const scoreFormat = collection?.user?.mediaListOptions?.scoreFormat || "POINT_10"; + return { loggedIn: true, userName: cfg.userName, entries, scoreFormat }; +} + +const SEARCH_QUERY = ` +query ($search: String) { + Page(page: 1, perPage: 10) { + media(search: $search, type: ANIME, sort: SEARCH_MATCH) { + id + title { romaji english } + episodes + status + coverImage { medium } + mediaListEntry { id status progress } + } + } +} +`; + +const ADD_MUTATION = ` +mutation ($mediaId: Int, $status: MediaListStatus) { + SaveMediaListEntry(mediaId: $mediaId, status: $status) { + id + status + progress + } +} +`; + +async function anilistRequest(query, variables) { + const cfg = loadHubConfig(); + if (!cfg || !cfg.accessToken) { + throw new Error("Nicht eingeloggt - Hub-Server-Login fehlt."); + } + const res = await fetch("https://graphql.anilist.co", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + Authorization: `Bearer ${cfg.accessToken}` + }, + body: JSON.stringify({ query, variables }) + }); + const json = await res.json(); + if (json.errors) { + throw new Error(json.errors.map((e) => e.message).join(", ")); + } + return json.data; +} + +async function searchAnime(search) { + const data = await anilistRequest(SEARCH_QUERY, { search }); + return data.Page.media; +} + +// status: "PLANNING" | "CURRENT" | ... (AniList MediaListStatus enum) +async function addAnimeToList(mediaId, status = "PLANNING") { + const data = await anilistRequest(ADD_MUTATION, { mediaId, status }); + return data.SaveMediaListEntry; +} + +const UPDATE_MUTATION = ` +mutation ($mediaId: Int, $progress: Int, $status: MediaListStatus, $score: Float) { + SaveMediaListEntry(mediaId: $mediaId, progress: $progress, status: $status, score: $score) { + id + progress + status + score + } +} +`; + +// Partial update: only fields the caller actually passed get sent to +// AniList (undefined fields are omitted from the GraphQL variables so +// they don't get overwritten with null) - lets the dashboard update just +// the episode count, just the score, or just the status independently. +async function updateAnimeEntry(mediaId, { progress, status, score } = {}) { + const variables = { mediaId }; + if (progress !== undefined) variables.progress = progress; + if (status !== undefined) variables.status = status; + if (score !== undefined) variables.score = score; + const data = await anilistRequest(UPDATE_MUTATION, variables); + return data.SaveMediaListEntry; +} + +// Maps a streaming URL's hostname to one of our persistent tab views, so +// "watch-now" can switch to the RIGHT tab (Crunchyroll link -> Crunchyroll +// tab, Netflix link -> Netflix tab, etc.) instead of always reusing one. +function siteIdForUrl(url) { + try { + const host = new URL(url).hostname.replace(/^www\./, ""); + if (host.endsWith("crunchyroll.com")) return "crunchyroll"; + if (host.endsWith("netflix.com")) return "netflix"; + if (host.endsWith("disneyplus.com")) return "disneyplus"; + return null; + } catch { + return null; + } +} + +ipcMain.handle("fetch-watching", () => fetchWatching()); +ipcMain.handle("search-anime", (event, search) => searchAnime(search)); +ipcMain.handle("add-anime", (event, mediaId, status) => addAnimeToList(mediaId, status)); +ipcMain.handle("update-anime", (event, mediaId, fields) => updateAnimeEntry(mediaId, fields)); +ipcMain.handle("fetch-media-detail", (event, mediaId) => fetchMediaDetail(mediaId)); +// "Schauen" button on a dashboard card: opens the REAL streaming URL for +// that exact show (from AniList's own externalLinks), not a site search. +// If it's one of our own tabbed sites (Crunchyroll/Netflix/Disney+), we +// switch to that tab and navigate it there so MAL-Sync can track it; +// otherwise open in the user's default browser. +ipcMain.handle("watch-now", (event, { url, trackable }) => { + const siteId = siteIdForUrl(url); + if (trackable && siteId && streamingViews[siteId]) { + streamingViews[siteId].webContents.loadURL(url); + activeTab = siteId; + layoutViews(); + // Keep the tab bar's own highlighted tab in sync, since this switch + // was triggered from the dashboard view, not a click on the tab bar. + tabBarView.webContents.send("activate-tab", siteId); + return { ok: true, opened: siteId }; + } + shell.openExternal(url); + return { ok: true, opened: "external" }; +}); + +let mainWindow; +let dashboardView; +let tabBarView; +let browserView; +let activeTab = "dashboard"; + +// Multiple streaming site tabs, each a persistent BrowserView so +// switching between Crunchyroll/Netflix/Disney+ keeps its own login +// session, scroll position and playback state instead of reloading. +const STREAMING_SITES = [ + { id: "crunchyroll", label: "Crunchyroll", url: "https://www.crunchyroll.com" }, + { id: "netflix", label: "Netflix", url: "https://www.netflix.com" }, + { id: "disneyplus", label: "Disney+", url: "https://www.disneyplus.com" } +]; +const streamingViews = {}; // id -> BrowserView + +function layoutViews() { + if (!mainWindow) return; + const [width, height] = mainWindow.getContentSize(); + const tabBarHeight = 44; + const bounds = { x: 0, y: tabBarHeight, width, height: height - tabBarHeight }; + const hidden = { x: 0, y: 0, width: 0, height: 0 }; + + dashboardView.setBounds(activeTab === "dashboard" ? bounds : hidden); + for (const [id, view] of Object.entries(streamingViews)) { + view.setBounds(activeTab === id ? bounds : hidden); + } +} + +const WINDOW_STATE_PATH = path.join(os.homedir(), ".anilist-tracker", "window-state.json"); + +function loadWindowState() { + try { + return JSON.parse(fs.readFileSync(WINDOW_STATE_PATH, "utf8")); + } catch { + return { width: 1280, height: 860 }; // first-launch default + } +} + +function saveWindowState(win) { + if (!win || win.isDestroyed()) return; + const bounds = win.isMaximized() || win.isFullScreen() ? win.getNormalBounds() : win.getBounds(); + const state = { + ...bounds, + isMaximized: win.isMaximized(), + isFullScreen: win.isFullScreen() + }; + try { + fs.mkdirSync(path.dirname(WINDOW_STATE_PATH), { recursive: true }); + fs.writeFileSync(WINDOW_STATE_PATH, JSON.stringify(state)); + } catch (err) { + console.error("Failed to save window state:", err.message); + } +} + +async function createWindow() { + // Load the real MAL-Sync extension into this app's own session so its + // background service worker + content scripts run against pages opened + // in browserView below. + try { + await session.defaultSession.loadExtension(MALSYNC_EXTENSION_PATH, { + allowFileAccess: true + }); + console.log("MAL-Sync extension loaded into app session"); + } catch (err) { + console.error("Failed to load MAL-Sync extension:", err.message); + } + + mainWindow = new BrowserWindow({ + ...loadWindowState(), + title: "AniList Companion", + webPreferences: { + preload: path.join(__dirname, "tabbar-preload.js"), + contextIsolation: true + } + }); + + if (loadWindowState().isMaximized) mainWindow.maximize(); + if (loadWindowState().isFullScreen) mainWindow.setFullScreen(true); + + // Debounced save so dragging/resizing doesn't hammer disk writes - + // only the LAST bounds after the user stops moving/resizing gets saved. + let saveStateTimer = null; + const scheduleSaveState = () => { + clearTimeout(saveStateTimer); + saveStateTimer = setTimeout(() => saveWindowState(mainWindow), 400); + }; + mainWindow.on("move", scheduleSaveState); + mainWindow.on("resize", scheduleSaveState); + mainWindow.on("close", () => saveWindowState(mainWindow)); + + // Thin tab bar at the top (dashboard vs. streaming-with-tracking), + // rendered as its own tiny BrowserView so it stays visible regardless + // of what the browser tab is doing. + tabBarView = new BrowserView({ + webPreferences: { + preload: path.join(__dirname, "tabbar-preload.js"), + contextIsolation: true + } + }); + mainWindow.addBrowserView(tabBarView); + tabBarView.setBounds({ x: 0, y: 0, width: 1280, height: 44 }); + tabBarView.setAutoResize({ width: true }); + tabBarView.webContents.loadFile(path.join(__dirname, "tabbar.html")); + + dashboardView = new BrowserView({ + webPreferences: { + preload: path.join(__dirname, "dashboard-preload.js"), + contextIsolation: true + } + }); + mainWindow.addBrowserView(dashboardView); + dashboardView.webContents.loadFile(path.join(__dirname, "dashboard.html")); + + // One persistent BrowserView per streaming site, each loaded once at + // startup and kept alive when switching tabs (so login sessions, + // scroll position, and any in-progress video keep playing in the + // background instead of being torn down every tab switch). + for (const site of STREAMING_SITES) { + const view = new BrowserView({ + webPreferences: { contextIsolation: true } + }); + mainWindow.addBrowserView(view); + view.webContents.loadURL(site.url); + // Network traffic capture (CDP), used to discover streaming sites' + // real internal API endpoints/IDs for a more precise AniList + // mapping. Only observes traffic this app's own embedded browser + // makes - not a system-wide proxy, doesn't touch other apps. + setupTrafficCapture(view.webContents); + streamingViews[site.id] = view; + } + browserView = streamingViews.crunchyroll; // default target for watch-now IPC + + layoutViews(); + mainWindow.on("resize", () => { + const [width] = mainWindow.getContentSize(); + tabBarView.setBounds({ x: 0, y: 0, width, height: 44 }); + layoutViews(); + }); + + // IPC-free tab switching: the tab bar page posts a message via + // executeJavaScript-triggered navigation event we listen for. + tabBarView.webContents.on("ipc-message", (event, channel, tab) => { + if (channel === "switch-tab") { + activeTab = tab; + layoutViews(); + if (tab === "dashboard") dashboardView.webContents.send("refresh"); + } + }); + + mainWindow.on("closed", () => { + mainWindow = null; + }); +} + +const TRAFFIC_LOG_PATH = path.join(os.homedir(), ".anilist-tracker", "cr-traffic.jsonl"); + +// Only log requests to hosts that are actually part of Crunchyroll's own +// API surface - excludes ads/analytics/CDN noise so the log stays +// readable and focused on discovering their internal show/episode IDs. +const CR_API_HOST_PATTERN = /(^|\.)crunchyroll\.com$/i; + +function setupTrafficCapture(webContents) { + const dbg = webContents.debugger; + try { + dbg.attach("1.3"); + } catch (err) { + console.error("Traffic capture: failed to attach debugger:", err.message); + return; + } + + dbg.sendCommand("Network.enable").catch(() => {}); + + const pendingRequests = new Map(); // requestId -> { url, method } + + dbg.on("message", (event, method, params) => { + if (method === "Network.requestWillBeSent") { + const url = params.request?.url || ""; + let host; + try { + host = new URL(url).hostname; + } catch { + return; + } + if (!CR_API_HOST_PATTERN.test(host)) return; + // Only care about actual API/data calls, not asset loads + // (images/fonts/scripts) - keeps the log to the interesting stuff. + if (/\.(jpg|jpeg|png|gif|svg|woff2?|ttf|css|js|ico)(\?|$)/i.test(url)) return; + pendingRequests.set(params.requestId, { + url, + method: params.request.method, + postData: params.request.postData || null + }); + } else if (method === "Network.responseReceived") { + const pending = pendingRequests.get(params.requestId); + if (!pending) return; + pending.status = params.response.status; + pending.mimeType = params.response.mimeType; + } else if (method === "Network.loadingFinished") { + const pending = pendingRequests.get(params.requestId); + if (!pending) return; + pendingRequests.delete(params.requestId); + // Fetch the actual response body via CDP, then append a structured + // record to the traffic log. Errors here (body already discarded, + // etc.) are swallowed - we still keep the request metadata. + dbg + .sendCommand("Network.getResponseBody", { requestId: params.requestId }) + .then((body) => { + appendTrafficRecord({ ...pending, responseBody: body.body, base64: body.base64Encoded }); + }) + .catch(() => { + appendTrafficRecord(pending); + }); + } + }); + + webContents.on("destroyed", () => { + try { + dbg.detach(); + } catch {} + }); +} + +function appendTrafficRecord(record) { + try { + fs.mkdirSync(path.dirname(TRAFFIC_LOG_PATH), { recursive: true }); + fs.appendFileSync( + TRAFFIC_LOG_PATH, + JSON.stringify({ time: new Date().toISOString(), ...record }) + "\n" + ); + } catch (err) { + console.error("Traffic capture: failed to write log:", err.message); + } + // Crunchyroll's series-level CMS object (content/v2/cms/objects/) + // is the most reliable source of the real series title for a bare + // series_id - populate our id->title cache whenever we observe one, + // so later up_next events (which usually just have the episode title) + // can resolve back to the correct series name. + if (record.url && record.url.includes("/content/v2/cms/objects/") && record.responseBody) { + try { + const json = JSON.parse(record.responseBody); + for (const item of json.data || []) { + if (item.id && item.title && item.type === "series") { + seriesIdToTitle.set(item.id, item.title); + } + } + } catch { + // ignore parse failures, other capture paths still work + } + } + + // Real-time extraction: Crunchyroll's own content/v2/discover/up_next + // API returns an authoritative "identifier" string in the shape + // "||E" plus a human-readable + // slug_title and title - this is far more precise than scraping page + // text (it's literally what Crunchyroll's own frontend uses), so we + // feed it straight into the hub server as a structured event instead + // of relying on the DOM-scraping content script for this site. + if (record.url && record.url.includes("/content/v2/discover/up_next/") && record.responseBody) { + try { + const json = JSON.parse(record.responseBody); + const panel = json.data?.[0]?.panel; + const meta = panel?.episode_metadata; + if (panel && meta?.identifier) { + const [seriesId, seasonId, episodeTag] = meta.identifier.split("|"); + const episodeNumber = meta.episode_number ?? parseInt((episodeTag || "").replace(/^E/, ""), 10); + reportCrunchyrollApiEvent({ + seriesId, + seasonId, + episodeNumber, + episodeTitle: panel.title, + seriesTitle: meta.season_title || panel.title + }); + } + } catch { + // Not JSON, or shape changed - ignore, other capture paths still work. + } + } +} + +// Crunchyroll's series-level CMS object (content/v2/cms/objects/) +// gives us the real series title for a bare series_id, since up_next +// only reliably has the EPISODE title. Cache id->title as we observe it. +const seriesIdToTitle = new Map(); + +function reportCrunchyrollApiEvent({ seriesId, seasonId, episodeNumber, episodeTitle, seriesTitle }) { + const knownTitle = seriesIdToTitle.get(seriesId); + const title = knownTitle || seriesTitle || episodeTitle; + if (!title || episodeNumber == null) return; + fetch("http://localhost:9632/event", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + sourceId: "app-cr-api", + rawTitle: title, + episode: episodeNumber, + force: false + }) + }).catch(() => {}); +} + +app.whenReady().then(createWindow); + +app.on("window-all-closed", () => { + if (process.platform !== "darwin") app.quit(); +}); + +app.on("activate", () => { + if (BrowserWindow.getAllWindows().length === 0) createWindow(); +}); diff --git a/src/tabbar-preload.js b/src/tabbar-preload.js new file mode 100644 index 0000000..e05af5f --- /dev/null +++ b/src/tabbar-preload.js @@ -0,0 +1,10 @@ +const { contextBridge, ipcRenderer } = require("electron"); + +// Note: this BrowserView's webContents fires an "ipc-message" event in +// the main process whenever ipcRenderer.send() is called here - NOT +// sendToHost (that API only exists for the legacy tag). +contextBridge.exposeInMainWorld("tabbar", { + switchTab: (tab) => ipcRenderer.send("switch-tab", tab), + navigate: (url) => ipcRenderer.send("navigate", url), + onActivateTab: (cb) => ipcRenderer.on("activate-tab", (event, tab) => cb(tab)) +}); diff --git a/src/tabbar.html b/src/tabbar.html new file mode 100644 index 0000000..25c4a41 --- /dev/null +++ b/src/tabbar.html @@ -0,0 +1,83 @@ + + + + + + + + + + + +
+ + +