diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 483fa59..5f93a37 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,7 +50,7 @@ jobs: uses: cachix/install-nix-action@v31 - name: Build Nix package - run: nix build + run: NIXPKGS_ALLOW_UNFREE=1 nix build --impure arch-build: name: Build Arch Linux Package diff --git a/README.md b/README.md index 3be1e0b..6e21e38 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ It automatically generates beautiful color themes from your wallpapers, and lets - [`cage`](https://github.com/cage-kiosk/cage) - runs wallpapers in an isolated, invisible Wayland session specifically so we can screenshot them without affecting the user’s desktop - [`grim`](https://github.com/emersion/grim) - takes a screenshot of the wallpaper running inside cage - [`ffmpeg`](https://github.com/FFmpeg/FFmpeg) - generate thumbnails for video files +- [`steamcmd`](https://developer.valvesoftware.com/wiki/SteamCMD) - for download wallpaper engine wallpapers ### Arch Linux (AUR) diff --git a/assets/screenshot.png b/assets/screenshot.png index 257b98d..b44f9be 100644 Binary files a/assets/screenshot.png and b/assets/screenshot.png differ diff --git a/package-lock.json b/package-lock.json index 9508be3..b406a85 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,14 +32,16 @@ "clsx": "^2.1.1", "conf": "^14.0.0", "electron-trpc-experimental": "^1.0.0-alpha.0", + "env-paths": "^3.0.0", "keytar": "^7.9.0", "lucide-react": "^0.536.0", "next-themes": "^0.4.6", + "pino": "^9.8.0", + "pino-pretty": "^13.1.1", "quantize": "^1.0.2", "react": "^19.1.1", "react-blurhash": "^0.3.0", "react-dom": "^19.1.1", - "react-intersection-observer": "^9.13.0", "react-virtualized": "^9.22.6", "sharp": "^0.34.3", "sonner": "^2.0.6", @@ -973,6 +975,16 @@ "global-agent": "^3.0.0" } }, + "node_modules/@electron/get/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/@electron/get/node_modules/fs-extra": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", @@ -1053,6 +1065,16 @@ "balanced-match": "^1.0.0" } }, + "node_modules/@electron/node-gyp/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/@electron/node-gyp/node_modules/glob": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", @@ -5368,6 +5390,15 @@ "node": ">= 4.0.0" } }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/atomically": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/atomically/-/atomically-2.0.3.tgz", @@ -6026,7 +6057,6 @@ "version": "2.0.20", "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", - "dev": true, "license": "MIT" }, "node_modules/colors-named": { @@ -6103,18 +6133,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/conf/node_modules/env-paths": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", - "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -6228,6 +6246,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dateformat": { + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", + "integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/debounce-fn": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/debounce-fn/-/debounce-fn-6.0.0.tgz", @@ -6977,6 +7004,15 @@ "undici-types": "~6.21.0" } }, + "node_modules/electron/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==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/electron/node_modules/fs-extra": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", @@ -7065,12 +7101,15 @@ } }, "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==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", "license": "MIT", "engines": { - "node": ">=6" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/environment": { @@ -7809,6 +7848,12 @@ "@types/yauzl": "^2.9.1" } }, + "node_modules/fast-copy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-3.0.2.tgz", + "integrity": "sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -7859,6 +7904,21 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-redact": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/fast-redact/-/fast-redact-3.5.0.tgz", + "integrity": "sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "license": "MIT" + }, "node_modules/fast-uri": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", @@ -8548,6 +8608,12 @@ "node": ">= 0.4" } }, + "node_modules/help-me": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", + "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", + "license": "MIT" + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -9306,6 +9372,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -10745,6 +10820,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -11129,6 +11213,79 @@ "node": ">=0.10.0" } }, + "node_modules/pino": { + "version": "9.8.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.8.0.tgz", + "integrity": "sha512-L5+rV1wL7vGAcxXP7sPpN5lrJ07Piruka6ArXr7EWBXxdVWjJshGVX8suFsiusJVcGKDGUFfbgbnKdg+VAC+0g==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0", + "fast-redact": "^3.1.1", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", + "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-pretty": { + "version": "13.1.1", + "resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.1.1.tgz", + "integrity": "sha512-TNNEOg0eA0u+/WuqH0MH0Xui7uqVk9D74ESOpjtebSQYbNWJk/dIxCXIxFsNfeN53JmtWqYHP2OrIZjT/CBEnA==", + "license": "MIT", + "dependencies": { + "colorette": "^2.0.7", + "dateformat": "^4.6.3", + "fast-copy": "^3.0.2", + "fast-safe-stringify": "^2.1.1", + "help-me": "^5.0.0", + "joycon": "^3.1.1", + "minimist": "^1.2.6", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pump": "^3.0.0", + "secure-json-parse": "^4.0.0", + "sonic-boom": "^4.0.1", + "strip-json-comments": "^5.0.2" + }, + "bin": { + "pino-pretty": "bin.js" + } + }, + "node_modules/pino-pretty/node_modules/strip-json-comments": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", + "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.0.0.tgz", + "integrity": "sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==", + "license": "MIT" + }, "node_modules/plist": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", @@ -11357,6 +11514,22 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", @@ -11448,6 +11621,12 @@ ], "license": "MIT" }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, "node_modules/quick-lru": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", @@ -11521,21 +11700,6 @@ "react": "^19.1.1" } }, - "node_modules/react-intersection-observer": { - "version": "9.16.0", - "resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-9.16.0.tgz", - "integrity": "sha512-w9nJSEp+DrW9KmQmeWHQyfaP6b03v+TdXynaoA964Wxt7mdR3An11z4NNCQgL4gKSK7y1ver2Fq+JKH6CWEzUA==", - "license": "MIT", - "peerDependencies": { - "react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "react-dom": { - "optional": true - } - } - }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -11773,6 +11937,15 @@ "node": ">= 6" } }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/rechoir": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", @@ -12134,6 +12307,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -12148,6 +12330,22 @@ "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", "license": "MIT" }, + "node_modules/secure-json-parse": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.0.0.tgz", + "integrity": "sha512-dxtLJO6sc35jWidmLxo7ij+Eg48PM/kleBsxpC8QJE0qJICe+KawkDQmvCMZUr9u7WKVHgMW6vy3fQ7zMiFZMA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/semver": { "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", @@ -12524,6 +12722,15 @@ "node": ">= 10" } }, + "node_modules/sonic-boom": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz", + "integrity": "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/sonner": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", @@ -12600,6 +12807,15 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/sprintf-js": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", @@ -12986,6 +13202,15 @@ "dev": true, "license": "ISC" }, + "node_modules/thread-stream": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", + "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + } + }, "node_modules/tiny-each-async": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/tiny-each-async/-/tiny-each-async-2.0.3.tgz", diff --git a/package.json b/package.json index 07c2a17..9f76dc8 100644 --- a/package.json +++ b/package.json @@ -93,14 +93,16 @@ "clsx": "^2.1.1", "conf": "^14.0.0", "electron-trpc-experimental": "^1.0.0-alpha.0", + "env-paths": "^3.0.0", "keytar": "^7.9.0", "lucide-react": "^0.536.0", "next-themes": "^0.4.6", + "pino": "^9.8.0", + "pino-pretty": "^13.1.1", "quantize": "^1.0.2", "react": "^19.1.1", "react-blurhash": "^0.3.0", "react-dom": "^19.1.1", - "react-intersection-observer": "^9.13.0", "react-virtualized": "^9.22.6", "sharp": "^0.34.3", "sonner": "^2.0.6", diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index cc495e5..8a3a3e3 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -6,7 +6,7 @@ pkgdesc="Wallpaper and theme management application" arch=("x86_64") url="https://github.com/kasper24/walltone" license=("GPL3") -depends=("nss" "libsecret" "swaybg" "mpvpaper" "linux-wallpaperengine" "cage" "grim" "ffmpeg") +depends=("nss" "libsecret" "swaybg" "mpvpaper" "linux-wallpaperengine" "cage" "grim" "ffmpeg" "steamcmd") makedepends=("npm" "nodejs" "git") source=("$pkgname::git+$url.git") sha256sums=("SKIP") diff --git a/packaging/nix/package.nix b/packaging/nix/package.nix index 62b5d37..67a5348 100644 --- a/packaging/nix/package.nix +++ b/packaging/nix/package.nix @@ -13,6 +13,7 @@ cage, grim, ffmpeg, + steamcmd, vips, lib, }: @@ -40,7 +41,7 @@ buildNpmPackage rec { src = ../../.; - npmDepsHash = "sha256-pE6e/FPyall5hSMn1+9OS/ibPhLcmzWUdLer3yqn6tU="; + npmDepsHash = "sha256-dryMm7BhFtFVsYZ9bI5uiwyrxvXfNcLDwx59zjRE24s="; dontNpmBuild = true; makeCacheWritable = true; @@ -83,6 +84,7 @@ buildNpmPackage rec { cage grim ffmpeg + steamcmd ] } diff --git a/src/electron/main/index.ts b/src/electron/main/index.ts index 418c52c..7e22f7e 100644 --- a/src/electron/main/index.ts +++ b/src/electron/main/index.ts @@ -13,7 +13,7 @@ const initializeApp = async () => { createIPCHandler({ router: appRouter, windows: [mainWindow] }); - await caller.wallpaper.restoreOnStart(); + caller.wallpaper.restoreOnStart(); mainWindow.once("ready-to-show", () => { mainWindow.show(); @@ -65,8 +65,9 @@ app.on("web-contents-created", (_, contents) => { }); }); -app.on("before-quit", function () { +app.on("before-quit", async function () { isQuitting = true; + await caller.wallpaper.killWallpapersOnExit(); }); registerProtocols(); diff --git a/src/electron/main/lib/index.ts b/src/electron/main/lib/index.ts index 3d4735c..b51d764 100644 --- a/src/electron/main/lib/index.ts +++ b/src/electron/main/lib/index.ts @@ -1,27 +1,34 @@ import { spawn } from "child_process"; import { TRPCError } from "@trpc/server"; +import { logger } from "@electron/main/lib/logger.js"; -const execute = ({ - command, - args = [], - env = {}, - shell = false, - detached = false, - ignoreErrors = false, - logStdout = true, - logStderr = true, -}: { +type ExecuteParams = { command: string; args?: string[]; env?: NodeJS.ProcessEnv; shell?: boolean; detached?: boolean; - ignoreErrors?: boolean; logStdout?: boolean; logStderr?: boolean; -}): Promise<{ stdout: string; stderr: string }> => { + onStdout?: (text: string) => void; + onStderr?: (text: string) => void; +}; + +const execute = (params: ExecuteParams): Promise<{ stdout: string; stderr: string }> => { + const { + command, + args = [], + env = {}, + shell = false, + detached = false, + logStdout = true, + logStderr = true, + onStdout, + onStderr, + } = params; + return new Promise((resolve, reject) => { - console.log(`Executing: ${command} ${args.join(" ")}`); + logger.info({ params }, "Executing command"); const child = spawn(command, args, { env: { @@ -38,44 +45,87 @@ const execute = ({ child.stdout.on("data", (data) => { const text = data.toString(); stdout += text; - if (logStdout) console.log(`[${command}] STDOUT:`, text.trim()); + if (onStdout) onStdout(text); + + if (logStdout) + logger.debug( + { + pid: child.pid, + text: text.trim(), + }, + "Process STDOUT" + ); }); child.stderr.on("data", (data) => { const text = data.toString(); stderr += text; - if (logStdout) console.log(`[${command}] STDERR:`, text.trim()); - }); + if (onStderr) onStderr(text); - child.on("close", (code) => { - if (ignoreErrors && code !== 0) { - console.warn(`[${command}] Process exited with code ${code}, but ignoring errors`); - return resolve({ stdout, stderr }); - } + if (logStderr) + logger.warn( + { + pid: child.pid, + text: text.trim(), + }, + "Process STDERR" + ); + }); + child.on("close", (code, signal) => { if (code === 0) { - if (logStderr) console.log(`[${command}] Process completed successfully`); + logger.info( + { + pid: child.pid, + code, + signal, + }, + "Process completed successfully" + ); + resolve({ stdout, stderr }); } else { + logger.error( + { + pid: child.pid, + code, + signal, + }, + "Process failed" + ); + const error = new Error(`Process exited with code ${code}`); - if (logStderr) console.error(`[${command}] Process failed with code ${code}`); reject(error); } }); child.on("error", (error) => { - if (logStderr) console.error(`[${command}] Process error:`, error); - if (!ignoreErrors) reject(error); + logger.error({ pid: child.pid, error }, "Process error"); + + reject(error); }); }); }; const killProcess = async (processName: string) => { + logger.info({ processName }, "killProcess() called"); + try { - await execute({ command: "pkill", args: ["-f", processName] }); - console.log(`Killed existing ${processName} processes`); + const result = await execute({ command: "pkill", args: ["-f", processName] }); + + logger.info( + { processName, result }, + `Successfully killed all running '${processName}' processes.` + ); } catch (error) { - console.log(`No ${processName} processes found or kill failed - ${error}`); + logger.warn( + { + processName, + error: error instanceof Error ? error.message : error, + stack: error instanceof Error ? error.stack : undefined, + }, + `No '${processName}' processes found or failed to kill.` + ); } }; diff --git a/src/electron/main/lib/logger.ts b/src/electron/main/lib/logger.ts new file mode 100644 index 0000000..1d53d66 --- /dev/null +++ b/src/electron/main/lib/logger.ts @@ -0,0 +1,45 @@ +import pino from "pino"; +import pinoPretty from "pino-pretty"; +import { logFilePath } from "@electron/main/lib/paths.js"; + +const isProduction = process.env.NODE_ENV === "production"; + +let logger: pino.Logger; + +if (isProduction) { + logger = pino( + { + level: "info", + }, + pino.multistream([ + { stream: process.stdout, level: "info" }, + { stream: pino.destination({ dest: logFilePath, mkdir: true }), level: "info" }, + ]) + ); +} else { + const prettyConsole = pinoPretty({ + colorize: true, + translateTime: "SYS:standard", + ignore: "pid,hostname", + }); + + const prettyFile = pinoPretty({ + colorize: false, // no colors in file + translateTime: "SYS:standard", + ignore: "pid,hostname", + destination: logFilePath, + mkdir: true, + }); + + logger = pino( + { + level: "debug", + }, + pino.multistream([ + { stream: prettyConsole, level: "debug" }, + { stream: prettyFile, level: "debug" }, + ]) + ); +} + +export { logger }; diff --git a/src/electron/main/lib/paths.ts b/src/electron/main/lib/paths.ts new file mode 100644 index 0000000..4d5b2b0 --- /dev/null +++ b/src/electron/main/lib/paths.ts @@ -0,0 +1,13 @@ +import path from "path"; +import envPaths from "env-paths"; + +const paths = envPaths("walltone", { + suffix: "", +}); + +const cageScreenshotPath = path.join(paths.temp, "walltone-wallpaper-screenshot.png"); +const logFilePath = path.join(paths.log, "app.log"); +const thumbnailDir = path.join(paths.cache, "thumbnails"); +const wallpapersDownloadPath = path.join(paths.cache, "downloads"); + +export { cageScreenshotPath, logFilePath, wallpapersDownloadPath, thumbnailDir }; diff --git a/src/electron/main/trpc/index.ts b/src/electron/main/trpc/index.ts index 9435b96..5ee23b3 100644 --- a/src/electron/main/trpc/index.ts +++ b/src/electron/main/trpc/index.ts @@ -1,7 +1,54 @@ +import { EventEmitter } from "events"; import { initTRPC } from "@trpc/server"; +import { logger } from "@electron/main/lib/logger.js"; const t = initTRPC.create({ isServer: true }); +function sanitizeInput(input: unknown): unknown { + if (!input || typeof input !== "object") return input; + + // Recursively clone and remove sensitive keys + const SENSITIVE_KEYS = ["username", "password", "guard", "token", "secret", "apiKey"]; + + const clone: Record = {}; + Object.entries(input).forEach(([key, value]) => { + if (SENSITIVE_KEYS.includes(key)) { + clone[key] = "[REDACTED]"; + } else if (typeof value === "object" && value !== null) { + clone[key] = sanitizeInput(value); + } else { + clone[key] = value; + } + }); + + return clone; +} + +const loggingMiddleware = t.middleware(async ({ path, type, next, getRawInput }) => { + const rawInput = await getRawInput(); + const safeInput = sanitizeInput(rawInput); + const start = Date.now(); + + logger.debug({ path, type, input: safeInput }, "tRPC call started"); + + const result = await next(); + const durationMs = Date.now() - start; + + if (result.ok) { + logger.debug( + { path, type, durationMs, input: safeInput, result: result.data }, + "tRPC call succeeded" + ); + } else { + logger.error( + { path, type, durationMs, input: safeInput, error: result.error }, + "tRPC call failed" + ); + } + return result; +}); + export const router = t.router; -export const publicProcedure = t.procedure; +export const publicProcedure = t.procedure.use(loggingMiddleware); export const createCallerFactory = t.createCallerFactory; +export const eventsEmitter = new EventEmitter(); diff --git a/src/electron/main/trpc/routes/api/index.ts b/src/electron/main/trpc/routes/api/index.ts index 43972cb..a929195 100644 --- a/src/electron/main/trpc/routes/api/index.ts +++ b/src/electron/main/trpc/routes/api/index.ts @@ -1,14 +1,68 @@ -import { router } from "@electron/main/trpc/index.js"; +import path from "path"; +import { promises as fs } from "fs"; +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; +import { router, publicProcedure } from "@electron/main/trpc/index.js"; +import { wallpapersDownloadPath } from "@electron/main/lib/paths.js"; import { pexelsRouter } from "./pexels/index.js"; import { pixabayRouter } from "./pixabay/index.js"; import { unsplashRouter } from "./unsplash/index.js"; import { wallhavenRouter } from "./wallhaven/index.js"; import { wallpaperEngineRouter } from "./wallpaper-engine/index.js"; +const downloadSchema = z.object({ + id: z.string(), + applyPath: z.string(), +}); + export const apiRouter = router({ pexels: pexelsRouter, pixabay: pixabayRouter, unsplash: unsplashRouter, wallhaven: wallhavenRouter, wallpaperEngine: wallpaperEngineRouter, + download: publicProcedure.input(downloadSchema).mutation(async ({ input }) => { + await fs.mkdir(wallpapersDownloadPath, { recursive: true }); + + const response = await fetch(input.applyPath); + if (!response.ok) throw new Error(`Failed to download: ${response.statusText}`); + if (!response.body) throw new Error("Response body is null or undefined."); + + const contentType = response.headers.get("content-type"); + if (!contentType) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Content-Type header is missing.", + }); + } + + const ext = mimeToExtension(contentType); + const downloadPath = path.join(wallpapersDownloadPath, `${input.id}${ext}`); + + if (!ext) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `File type '${ext}' is not allowed.`, + }); + } + + const arrayBuffer = await response.arrayBuffer(); + await fs.writeFile(downloadPath, Buffer.from(arrayBuffer)); + + return downloadPath; + }), }); + +const mimeToExtension = (mime: string): string | null => { + const map: Record = { + "image/jpg": ".jpg", + "image/jpeg": ".jpg", + "image/png": ".png", + "image/webp": ".webp", + "image/gif": ".gif", + "video/mp4": ".mp4", + "video/webm": ".webm", + "video/mov": ".mov", + }; + return map[mime.toLowerCase()] ?? null; +}; diff --git a/src/electron/main/trpc/routes/api/pexels/index.ts b/src/electron/main/trpc/routes/api/pexels/index.ts index 8d0c2ad..6814872 100644 --- a/src/electron/main/trpc/routes/api/pexels/index.ts +++ b/src/electron/main/trpc/routes/api/pexels/index.ts @@ -134,10 +134,8 @@ export const pexelsRouter = router({ input.type === "photos" ? "https://api.pexels.com/v1/search" : "https://api.pexels.com/videos/search"; - const url = new URL(baseUrl); const params = url.searchParams; - params.set("query", input.query); params.set("page", input.page.toString()); params.set("per_page", input.perPage.toString()); @@ -150,11 +148,12 @@ export const pexelsRouter = router({ const response = await fetch(url.toString(), { headers: { Authorization: input.apiKey }, }); - if (!response.ok) + if (!response.ok) { throw new TRPCError({ code: "BAD_REQUEST", message: `Pexels API request failed: ${response.statusText}`, }); + } const data: PexelsSearchResponse = await response.json(); const numberOfPages = Math.ceil(data.total_results / input.perPage); diff --git a/src/electron/main/trpc/routes/api/pixabay/index.ts b/src/electron/main/trpc/routes/api/pixabay/index.ts index 520642e..f9bf08c 100644 --- a/src/electron/main/trpc/routes/api/pixabay/index.ts +++ b/src/electron/main/trpc/routes/api/pixabay/index.ts @@ -155,31 +155,31 @@ export const pixabayRouter = router({ search: publicProcedure.input(pixabaySearchParamsSchema).query(async ({ input }) => { const isImage = input.type === "image"; const endpoint = isImage ? "https://pixabay.com/api/" : "https://pixabay.com/api/videos/"; - const url = new URL(endpoint); - url.searchParams.set("key", input.apiKey); - url.searchParams.set("q", input.query); - url.searchParams.set("page", input.page.toString()); - url.searchParams.set("per_page", input.perPage.toString()); - if (input.colors) url.searchParams.set("colors", input.colors); - if (input.imageType && isImage) url.searchParams.set("image_type", input.imageType); - if (input.videoType && !isImage) url.searchParams.set("video_type", input.videoType); - if (input.orientation) url.searchParams.set("orientation", input.orientation); - if (input.category) url.searchParams.set("category", input.category); - if (input.order) url.searchParams.set("order", input.order); - if (input.minWidth) url.searchParams.set("min_width", input.minWidth.toString()); - if (input.minHeight) url.searchParams.set("min_height", input.minHeight.toString()); - if (input.editorsChoice) - url.searchParams.set("editors_choice", input.editorsChoice ? "true" : "false"); - if (input.safeSearch) url.searchParams.set("safesearch", input.safeSearch ? "true" : "false"); + const params = url.searchParams; + params.set("key", input.apiKey); + params.set("q", input.query); + params.set("page", input.page.toString()); + params.set("per_page", input.perPage.toString()); + if (input.colors) params.set("colors", input.colors); + if (input.imageType && isImage) params.set("image_type", input.imageType); + if (input.videoType && !isImage) params.set("video_type", input.videoType); + if (input.orientation) params.set("orientation", input.orientation); + if (input.category) params.set("category", input.category); + if (input.order) params.set("order", input.order); + if (input.minWidth) params.set("min_width", input.minWidth.toString()); + if (input.minHeight) params.set("min_height", input.minHeight.toString()); + if (input.editorsChoice) params.set("editors_choice", input.editorsChoice ? "true" : "false"); + if (input.safeSearch) params.set("safesearch", input.safeSearch ? "true" : "false"); try { const response = await fetch(url.toString()); - if (!response.ok) + if (!response.ok) { throw new TRPCError({ code: "BAD_REQUEST", message: `Pixabay API request failed: ${response.statusText}`, }); + } const data: PixabaySearchResponse = await response.json(); const hits = data.hits || []; diff --git a/src/electron/main/trpc/routes/api/unsplash/index.ts b/src/electron/main/trpc/routes/api/unsplash/index.ts index 447391f..ad6af31 100644 --- a/src/electron/main/trpc/routes/api/unsplash/index.ts +++ b/src/electron/main/trpc/routes/api/unsplash/index.ts @@ -194,7 +194,6 @@ export const unsplashRouter = router({ search: publicProcedure.input(unsplashSearchParamsSchema).query(async ({ input }) => { const url = new URL("https://api.unsplash.com/search/photos"); const params = url.searchParams; - params.set("client_id", input.apiKey); params.set("page", input.page.toString()); params.set("per_page", input.perPage.toString()); @@ -205,15 +204,14 @@ export const unsplashRouter = router({ try { const response = await fetch(url.toString()); - if (!response.ok) + if (!response.ok) { throw new TRPCError({ code: "BAD_REQUEST", message: `Unsplash API request failed: ${response.statusText}`, }); + } const data: UnsplashSearchResult | UnsplashPhoto[] = await response.json(); - - // Normalize the response format const photos = Array.isArray(data) ? data : data.results; const totalItems = Array.isArray(data) ? photos.length : data.total; const totalPages = Array.isArray(data) ? Infinity : data.total_pages; @@ -230,7 +228,7 @@ export const unsplashRouter = router({ const message = error instanceof Error ? error.message : "Unknown error"; throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", - message: `Pexels API request failed: ${message}`, + message: `Unsplash API request failed: ${message}`, cause: error, }); } diff --git a/src/electron/main/trpc/routes/api/wallhaven/index.ts b/src/electron/main/trpc/routes/api/wallhaven/index.ts index 1f7e63c..dcf5402 100644 --- a/src/electron/main/trpc/routes/api/wallhaven/index.ts +++ b/src/electron/main/trpc/routes/api/wallhaven/index.ts @@ -88,7 +88,6 @@ export const wallhavenRouter = router({ search: publicProcedure.input(wallhavenSearchParamsSchema).query(async ({ input }) => { const url = new URL(`https://wallhaven.cc/api/v1/search`); const params = url.searchParams; - if (input.query) params.set("q", input.query); if (input.categories) params.set("categories", convertCategories(input.categories)); if (input.purity) params.set("purity", convertPurity(input.purity)); @@ -104,11 +103,12 @@ export const wallhavenRouter = router({ try { const response = await fetch(url.toString()); - if (!response.ok) + if (!response.ok) { throw new TRPCError({ code: "BAD_REQUEST", message: `Wallhaven API request failed: ${response.statusText}`, }); + } const data: WallhavenSearchResult = await response.json(); const totalPages = Math.ceil(data.meta.total / data.meta.per_page); diff --git a/src/electron/main/trpc/routes/api/wallpaper-engine/index.ts b/src/electron/main/trpc/routes/api/wallpaper-engine/index.ts index 225d9ec..64a1ce0 100644 --- a/src/electron/main/trpc/routes/api/wallpaper-engine/index.ts +++ b/src/electron/main/trpc/routes/api/wallpaper-engine/index.ts @@ -1,5 +1,9 @@ +import path from "path"; +import { promises as fs } from "fs"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; +import { execute } from "@electron/main/lib/index.js"; +import { wallpapersDownloadPath } from "@electron/main/lib/paths.js"; import { publicProcedure, router } from "@electron/main/trpc/index.js"; import { type BaseWallpaper } from "@electron/main/trpc/routes/wallpaper/types.js"; @@ -90,16 +94,17 @@ const searchSchema = z.object({ matchAll: z.boolean().optional(), }); -const subscriptionSchema = z.object({ - apiKey: z.string().min(1, "API Key is required"), - id: z.string().min(1, "Item ID is required"), +const downloadSchema = z.object({ + workshopId: z.string().min(1, "Workshop ID is required"), + username: z.string().min(1, "Username is required"), + password: z.string().optional(), + guard: z.string().optional(), }); export const wallpaperEngineRouter = router({ search: publicProcedure.input(searchSchema).query(async ({ input }) => { const url = new URL("https://api.steampowered.com/IPublishedFileService/QueryFiles/v1"); const params = url.searchParams; - params.set("key", input.apiKey); params.set("creator_appid", "431960"); params.set("appid", "431960"); @@ -109,6 +114,7 @@ export const wallpaperEngineRouter = router({ params.set("return_tags", "true"); params.set("return_previews", "true"); params.set("return_short_description", "true"); + if (input.query) params.set("search_text", input.query); if (input.tags) { input.tags.forEach((tag, index) => params.append(`requiredtags[${index}]`, tag)); @@ -118,11 +124,12 @@ export const wallpaperEngineRouter = router({ try { const response = await fetch(url); - if (!response.ok) + if (!response.ok) { throw new TRPCError({ code: "BAD_REQUEST", message: `Pexels API request failed: ${response.statusText}`, }); + } const data: WallpaperEngineWorkshopSearchResponse = await response.json(); const numberOfPages = Math.ceil(data.response.total / input.perPage); @@ -143,51 +150,40 @@ export const wallpaperEngineRouter = router({ } }), - subscribe: publicProcedure.input(subscriptionSchema).mutation(async ({ input }) => { - const url = new URL("https://api.steampowered.com/IPublishedFileService/Subscribe/v1/"); - const params = url.searchParams; - - params.set("key", input.apiKey); - params.set("publishedfileid", input.id); - params.set("list_type", "1"); - params.set("appid", "431960"); - params.set("notify_client", "true"); + download: publicProcedure.input(downloadSchema).mutation(async ({ input }) => { + await fs.mkdir(wallpapersDownloadPath, { recursive: true }); try { - const response = await fetch(url, { method: "POST", body: params }); - if (!response.ok) - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Pexels API request failed: ${response.statusText}`, - }); - return await response.json(); + await execute({ + command: "steamcmd", + args: [ + "+force_install_dir", + wallpapersDownloadPath, + "+login", + input.username, + ...(input.password ? [input.password] : []), + ...(input.guard ? [input.guard] : []), + "+workshop_download_item", + "431960", + input.workshopId, + "+quit", + ], + }); + + return path.join( + wallpapersDownloadPath, + "steamapps", + "workshop", + "content", + "431960", + input.workshopId + ); } catch (error) { - const message = error instanceof Error ? error.message : "Unknown error"; - throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message, cause: error }); - } - }), - - unsubscribe: publicProcedure.input(subscriptionSchema).mutation(async ({ input }) => { - const url = new URL("https://api.steampowered.com/IPublishedFileService/Unsubscribe/v1/"); - const params = url.searchParams; - - params.set("key", input.apiKey); - params.set("publishedfileid", input.id); - params.set("list_type", "1"); - params.set("appid", "431960"); - params.set("notify_client", "true"); - - try { - const response = await fetch(url, { method: "POST", body: params }); - if (!response.ok) - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Pexels API request failed: ${response.statusText}`, - }); - return await response.json(); - } catch (error) { - const message = error instanceof Error ? error.message : "Unknown error"; - throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message, cause: error }); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Failed to execute steamcmd: ${error instanceof Error ? error.message : "Unknown error"}`, + cause: error, + }); } }), }); diff --git a/src/electron/main/trpc/routes/file/index.ts b/src/electron/main/trpc/routes/file/index.ts index cabe95a..038a63a 100644 --- a/src/electron/main/trpc/routes/file/index.ts +++ b/src/electron/main/trpc/routes/file/index.ts @@ -18,9 +18,9 @@ export const fileRouter = router({ message: `Path does not point to a file: ${input.path}`, }); } + shell.showItemInFolder(input.path); } catch (error) { - if (error instanceof TRPCError) throw error; throw new TRPCError({ code: "NOT_FOUND", message: `Failed to open file in explorer. The path '${input.path}' may not exist.`, diff --git a/src/electron/main/trpc/routes/settings/index.ts b/src/electron/main/trpc/routes/settings/index.ts index de5c87e..b59035d 100644 --- a/src/electron/main/trpc/routes/settings/index.ts +++ b/src/electron/main/trpc/routes/settings/index.ts @@ -11,6 +11,7 @@ export interface SettingsSchema { app: { uiTheme: "light" | "dark"; restoreWallpaperOnStart: boolean; + killWallpaperOnExit: boolean; }; /** Settings that control how themes are generated from an image. */ @@ -68,6 +69,7 @@ const schema: Schema = { properties: { uiTheme: { type: "string", enum: ["light", "dark"], default: "dark" }, restoreWallpaperOnStart: { type: "boolean", default: true }, + killWallpaperOnExit: { type: "boolean", default: true }, }, default: {}, }, @@ -234,9 +236,7 @@ const deleteSchema = z.object({ export const settingsRouter = router({ get: publicProcedure.input(getSchema).query(async ({ input }) => { try { - if (input.decrypt) { - return keytar.getPassword("walltone", input.key); - } + if (input.decrypt) return await keytar.getPassword("walltone", input.key); return store.get(input.key); } catch (error) { throw new TRPCError({ @@ -255,11 +255,8 @@ export const settingsRouter = router({ input.value = selectedPath; } - if (input.encrypt) { - await keytar.setPassword("walltone", input.key, String(input.value ?? "")); - } else { - store.set(input.key, input.value); - } + if (input.encrypt) await keytar.setPassword("walltone", input.key, String(input.value ?? "")); + else store.set(input.key, input.value); } catch (error) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", diff --git a/src/electron/main/trpc/routes/theme/index.ts b/src/electron/main/trpc/routes/theme/index.ts index 40b0109..914ee17 100644 --- a/src/electron/main/trpc/routes/theme/index.ts +++ b/src/electron/main/trpc/routes/theme/index.ts @@ -121,39 +121,47 @@ export const themeRouter = router({ }); const base16Settings = await caller.settings.get({ key: "themeGeneration.base16" }); - return await new Promise((resolve, reject) => { - const workerPath = path.join(import.meta.dirname, "theme-generator.js"); - const worker = new Worker(workerPath); - - worker.on("message", (event) => { - const result = event.data; - if (event.status === "success") { - resolve(result); - } else { - reject(new Error(result?.error || "Worker failed with an unknown error.")); - } - worker.terminate(); - }); + try { + return await new Promise((resolve, reject) => { + const workerPath = path.join(import.meta.dirname, "theme-generator.js"); + const worker = new Worker(workerPath); + + worker.on("message", (event) => { + const result = event.data; + if (event.status === "success") { + resolve(result); + } else { + reject(new Error(result?.error || "Worker failed with an unknown error.")); + } + worker.terminate(); + }); - worker.on("error", (error) => { - reject(error); - worker.terminate(); - }); + worker.on("error", (error) => { + reject(error); + worker.terminate(); + }); - worker.postMessage({ imageSrc, quantizeLibrary, base16Settings }); - }); + worker.postMessage({ imageSrc, quantizeLibrary, base16Settings }); + }); + } catch (error) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Error generating theme: ${error instanceof Error ? error.message : "Unknown error"}`, + cause: error, + }); + } }), set: publicProcedure.input(setSchema).mutation(async ({ input }) => { const templates = (await caller.settings.get({ key: "themeOutput.templates", })) as SettingsSchema["themeOutput"]["templates"]; - - if (!templates) + if (!templates) { throw new TRPCError({ code: "NOT_FOUND", message: "Templates are not set.", }); + } const context = { wallpaper: { @@ -164,56 +172,64 @@ export const themeRouter = router({ theme: themeToChroma(input.theme), }; - await Promise.all( - templates.map(async (tpl) => { - if (!tpl.src || !tpl.dest) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Invalid template configuration: ${JSON.stringify(tpl)}`, - }); - } - - try { - const content = await fs.readFile(tpl.src, "utf-8"); - const rendered = await renderString(content, context); + try { + await Promise.all( + templates.map(async (tpl) => { + if (!tpl.src || !tpl.dest) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Invalid template configuration: ${JSON.stringify(tpl)}`, + }); + } try { - const destination = await renderString(tpl.dest, context); - await fs.mkdir(path.dirname(destination), { recursive: true }); - await fs.writeFile(destination, rendered, "utf-8"); - - if (tpl.postHook) { - try { - const postHook = await renderString(tpl.postHook, context); - const [cmd, ...args] = postHook.split(" "); - await execute({ command: cmd, args, shell: true }); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : "Unknown error occurred"; - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `Error running post-hook command: ${tpl.postHook}: ${errorMessage}`, - cause: error, - }); + const content = await fs.readFile(tpl.src, "utf-8"); + const rendered = await renderString(content, context); + + try { + const destination = await renderString(tpl.dest, context); + await fs.mkdir(path.dirname(destination), { recursive: true }); + await fs.writeFile(destination, rendered, "utf-8"); + if (tpl.postHook) { + try { + const postHook = await renderString(tpl.postHook, context); + const [cmd, ...args] = postHook.split(" "); + await execute({ command: cmd, args, shell: true }); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Unknown error occurred"; + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Error running post-hook command: ${tpl.postHook}: ${errorMessage}`, + cause: error, + }); + } } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Unknown error occurred"; + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Error writing file to ${tpl.dest}: ${errorMessage}`, + cause: error, + }); } } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", - message: `Error writing file to ${tpl.dest}: ${errorMessage}`, + message: `Error reading template file ${tpl.src}: ${errorMessage}`, cause: error, }); } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `Error reading template file ${tpl.src}: ${errorMessage}`, - cause: error, - }); - } - }) - ); + }) + ); + } catch (error) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Error setting theme: ${error instanceof Error ? error.message : "Unknown error"}`, + cause: error, + }); + } }), }); diff --git a/src/electron/main/trpc/routes/wallpaper/index.ts b/src/electron/main/trpc/routes/wallpaper/index.ts index a5b2b4e..b490b2c 100644 --- a/src/electron/main/trpc/routes/wallpaper/index.ts +++ b/src/electron/main/trpc/routes/wallpaper/index.ts @@ -1,7 +1,9 @@ import path from "path"; import { Worker } from "worker_threads"; import z from "zod"; -import { publicProcedure, router } from "@electron/main/trpc/index.js"; +import { TRPCError } from "@trpc/server"; +import { observable } from "@trpc/server/observable"; +import { eventsEmitter, publicProcedure, router } from "@electron/main/trpc/index.js"; import { caller } from "@electron/main/trpc/routes/index.js"; import { type SettingsSchema } from "@electron/main/trpc/routes/settings/index.js"; import { type WallpaperData, type LibraryWallpaper } from "./types.js"; @@ -13,7 +15,6 @@ import { paginateData, } from "./search.js"; import { - downloadRemoteWallpaper, getMonitors, saveLastWallpaper, killWallpaperProcesses, @@ -111,15 +112,31 @@ export const wallpaperRouter = router({ }), set: publicProcedure.input(setWallpaperSchema).mutation(async ({ input }) => { - if (input.applyPath.startsWith("http://") || input.applyPath.startsWith("https://")) - input.applyPath = await downloadRemoteWallpaper(input); - if (input.monitors.length === 0) input.monitors = await getMonitors(); await saveLastWallpaper(input); await killWallpaperProcesses(); - await screenshotWallpaper(input); - await setWallpaper(input, false); + await Promise.resolve(setTimeout(() => {}, 1000)); // Allow time for last wallpaper to be saved + + try { + await screenshotWallpaper(input); + } finally { + await setWallpaper(input); + } + }), + + onWallpaperError: publicProcedure.subscription(() => { + return observable((emit) => { + function onWallpaperError(error: string) { + emit.next(error); + } + + eventsEmitter.on("wallpaper-error", onWallpaperError); + + return () => { + eventsEmitter.off("wallpaper-error", onWallpaperError); + }; + }); }), restoreOnStart: publicProcedure.mutation(async () => { @@ -132,11 +149,27 @@ export const wallpaperRouter = router({ key: "internal.lastWallpaper", })) as SettingsSchema["internal"]["lastWallpaper"]; - Object.values(lastWallpaper).forEach(async (wallpaper) => { - if (wallpaper.monitors.length === 0) wallpaper.monitors = await getMonitors(); + try { + await Promise.all( + Object.values(lastWallpaper).map(async (wallpaper) => { + if (wallpaper.monitors.length === 0) wallpaper.monitors = await getMonitors(); + await killWallpaperProcesses(); + await setWallpaper(wallpaper); + }) + ); + } catch (error) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Error setting wallpaper: ${error instanceof Error ? error.message : "Unknown error"}`, + cause: error, + }); + } + }), - await killWallpaperProcesses(); - await setWallpaper(wallpaper, true); + killWallpapersOnExit: publicProcedure.mutation(async () => { + const killOnExit = await caller.settings.get({ + key: "app.killWallpaperOnExit", }); + if (killOnExit) await killWallpaperProcesses(); }), }); diff --git a/src/electron/main/trpc/routes/wallpaper/search.ts b/src/electron/main/trpc/routes/wallpaper/search.ts index 58754dd..76abb2f 100644 --- a/src/electron/main/trpc/routes/wallpaper/search.ts +++ b/src/electron/main/trpc/routes/wallpaper/search.ts @@ -2,6 +2,8 @@ import path from "path"; import { promises as fs } from "fs"; import { TRPCError } from "@trpc/server"; import { caller } from "@electron/main/trpc/routes/index.js"; +import { logger } from "@electron/main/lib/logger.js"; + import { type WallpaperEngineWallpaper, type LibraryWallpaper, @@ -30,6 +32,12 @@ const getImageAndVideoWallpapers = async (type: "image" | "video") => { for (const folder of folders) { try { const files = await searchForFiles(folder, WALLPAPERS_TYPE_TO_EXTS[type]); + + logger.info( + { type, folder, fileCount: files.length }, + `Found ${files.length} ${type} files in folder: ${folder}` + ); + files.forEach(async (file) => { wallpapers.push({ id: path.basename(file.path, path.extname(file.path)), @@ -42,8 +50,10 @@ const getImageAndVideoWallpapers = async (type: "image" | "video") => { type: type, }); }); + + logger.info({ type, folder }, `Completed processing for folder: ${folder}`); } catch (error) { - console.error(`Failed to search ${type} files in ${folder}:`, error); + logger.error({ err: error, type, folder }, `Failed to search ${type} files in ${folder}`); } } @@ -125,12 +135,15 @@ const getWallpaperEngineWallpapers = async () => { } } } catch (jsonError) { - console.error(`Failed to read or parse ${jsonFilePath}:`, jsonError); + logger.error( + { err: jsonError, jsonFilePath, dir: subdirectoryPath }, + `Failed to read or parse project.json for wallpaper engine item in ${subdirectoryPath}` + ); } } } } catch (error) { - console.error(`Error reading wallpaper engine directory ${folder}:`, error); + logger.error({ err: error, folder }, `Error reading wallpaper engine directory ${folder}`); } } @@ -167,11 +180,11 @@ const sortWallpapers = (wallpapers: LibraryWallpaper[], sorting: string) => { } }; -const paginateData = ( - data: T[], +const paginateData = ( + data: TWallpaper[], page: number, itemsPerPage: number -): WallpaperData => { +): WallpaperData => { const currentPage = page; const totalItems = data.length; const totalPages = Math.ceil(totalItems / itemsPerPage); diff --git a/src/electron/main/trpc/routes/wallpaper/set.ts b/src/electron/main/trpc/routes/wallpaper/set.ts index e1157c1..5568126 100644 --- a/src/electron/main/trpc/routes/wallpaper/set.ts +++ b/src/electron/main/trpc/routes/wallpaper/set.ts @@ -1,66 +1,15 @@ -import os from "os"; import path from "path"; import { promises as fs } from "fs"; import { TRPCError } from "@trpc/server"; import { execute, killProcess, santize, renderString } from "@electron/main/lib/index.js"; import { caller } from "@electron/main/trpc/routes/index.js"; import { type SettingsSchema } from "@electron/main/trpc/routes/settings/index.js"; -import { SetWallpaperInput } from "./types.js"; +import { cageScreenshotPath } from "@electron/main/lib/paths.js"; +import { type SetWallpaperInput } from "./types.js"; +import { eventsEmitter } from "@electron/main/trpc/index.js"; const VIDEO_INIT_TIME = 1; const WALLPAPER_ENGINE_INIT_TIME = 5; -const CAGE_SCREENSHOT_PATH = "/tmp/walltone-wallpaper-screenshot.png"; -const WALLPAPERS_DOWNLOAD_CACHE_DIR = path.join( - os.homedir(), - ".cache", - "walltone", - "wallpapers-downloads" -); - -const mimeToExtension = (mime: string): string | null => { - const map: Record = { - "image/jpg": ".jpg", - "image/jpeg": ".jpg", - "image/png": ".png", - "image/webp": ".webp", - "image/gif": ".gif", - "video/mp4": ".mp4", - "video/webm": ".webm", - "video/mov": ".mov", - }; - return map[mime.toLowerCase()] ?? null; -}; - -const downloadRemoteWallpaper = async (input: SetWallpaperInput) => { - await fs.mkdir(WALLPAPERS_DOWNLOAD_CACHE_DIR, { recursive: true }); - - const response = await fetch(input.applyPath); - if (!response.ok) throw new Error(`Failed to download: ${response.statusText}`); - if (!response.body) throw new Error("Response body is null or undefined."); - - const contentType = response.headers.get("content-type"); - if (!contentType) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Content-Type header is missing.", - }); - } - - const ext = mimeToExtension(contentType); - const downloadPath = path.join(WALLPAPERS_DOWNLOAD_CACHE_DIR, `${input.id}${ext}`); - - if (!ext) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `File type '${ext}' is not allowed.`, - }); - } - - const arrayBuffer = await response.arrayBuffer(); - await fs.writeFile(downloadPath, Buffer.from(arrayBuffer)); - - return downloadPath; -}; const getMonitors = async () => { return (await caller.monitor.search()).map((monitor) => ({ @@ -86,7 +35,7 @@ const screenshotWallpaper = async (input: SetWallpaperInput) => { await copyWallpaperToDestinations(input.id, input.name, input.applyPath); } else if (input.type === "video") { await screenshotWallpaperInCage(["mpv", "panscan=1.0", input.applyPath], VIDEO_INIT_TIME); - await copyWallpaperToDestinations(input.id, input.name, CAGE_SCREENSHOT_PATH); + await copyWallpaperToDestinations(input.id, input.name, cageScreenshotPath); } else if (input.type === "wallpaper-engine") { const assetsPath = await caller.settings.get({ key: "wallpaperSources.wallpaperEngineAssetsFolder", @@ -113,41 +62,31 @@ const screenshotWallpaper = async (input: SetWallpaperInput) => { ], WALLPAPER_ENGINE_INIT_TIME ); - await copyWallpaperToDestinations(input.id, input.name, CAGE_SCREENSHOT_PATH); + await copyWallpaperToDestinations(input.id, input.name, cageScreenshotPath); } }; -const setWallpaper = async (input: SetWallpaperInput, detached: boolean) => { - if (input.type === "image") { - await setImageWallpaper(input.applyPath, input.monitors, detached); - } else if (input.type === "video") { - await setVideoWallpaper(input.applyPath, input.monitors, input.videoOptions, detached); - } else if (input.type === "wallpaper-engine") { - const assetsPath = await caller.settings.get({ - key: "wallpaperSources.wallpaperEngineAssetsFolder", - }); - - if (typeof assetsPath !== "string" || !assetsPath) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Wallpaper Engine assets folder is not set.", - }); +const setWallpaper = async (input: SetWallpaperInput) => { + try { + if (input.type === "image") { + await setImageWallpaper(input.applyPath, input.monitors); + } else if (input.type === "video") { + await setVideoWallpaper(input.applyPath, input.monitors, input.videoOptions); + } else if (input.type === "wallpaper-engine") { + await setWallpaperEngineWallpaper( + input.applyPath, + input.monitors, + input.wallpaperEngineOptions + ); } - - await setWallpaperEngineWallpaper( - assetsPath, - input.applyPath, - input.monitors, - input.wallpaperEngineOptions, - detached - ); + } catch (error) { + eventsEmitter.emit("wallpaper-error", error instanceof Error ? error.message : "Unknown error"); } }; const setImageWallpaper = async ( imagePath: string, - monitors: { id: string; scalingMethod?: string }[], - detached: boolean + monitors: { id: string; scalingMethod?: string }[] ) => { const args: string[] = []; @@ -162,7 +101,7 @@ const setImageWallpaper = async ( ); }); - await execute({ command: "swaybg", args, detached, logStdout: false }); + await execute({ command: "swaybg", args, detached: true, logStdout: false }); }; const setVideoWallpaper = async ( @@ -170,8 +109,7 @@ const setVideoWallpaper = async ( monitors: { id: string; scalingMethod?: string }[], options?: { mute?: boolean; - }, - detached?: boolean + } ) => { // Build mpv options array const mpvOptions: string[] = ["loop"]; @@ -219,11 +157,10 @@ const setVideoWallpaper = async ( // Add video path args.push(videoPath); - await execute({ command: "mpvpaper", args, detached, logStdout: false }); + await execute({ command: "mpvpaper", args, detached: true, logStdout: false }); }; const setWallpaperEngineWallpaper = async ( - assetsPath: string, wallpaperPath: string, monitors: { id: string; scalingMethod?: string }[], options?: { @@ -236,9 +173,19 @@ const setWallpaperEngineWallpaper = async ( disableMouse?: boolean; disableParallax?: boolean; noFullscreenPause?: boolean; - }, - detached?: boolean + } ) => { + const assetsPath = await caller.settings.get({ + key: "wallpaperSources.wallpaperEngineAssetsFolder", + }); + + if (typeof assetsPath !== "string" || !assetsPath) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Wallpaper Engine assets folder is not set.", + }); + } + const args = [ ...monitors.flatMap((monitor) => [ "--screen-root", @@ -291,19 +238,24 @@ const setWallpaperEngineWallpaper = async ( await execute({ command: "linux-wallpaperengine", args, - detached, + detached: true, logStdout: false, }); }; const screenshotWallpaperInCage = async (cmd: string[], delay: number) => { - const args = [ - "--", - "sh", - "-c", - `${cmd.join(" ")} & pid=$!; sleep ${delay} && grim -g "0,0 1280x720" ${CAGE_SCREENSHOT_PATH} && kill $pid`, - ]; - await execute({ command: "cage", args, env: { WLR_BACKENDS: "headless" } }); + await fs.mkdir(path.dirname(cageScreenshotPath), { recursive: true }); + + await execute({ + command: "cage", + args: [ + "--", + "sh", + "-c", + `${cmd.join(" ")} & pid=$!; sleep ${delay} && grim -g "0,0 1280x720" ${cageScreenshotPath} && kill $pid`, + ], + env: { WLR_BACKENDS: "headless" }, + }); }; const copyWallpaperToDestinations = async ( @@ -330,7 +282,6 @@ const copyWallpaperToDestinations = async ( }; export { - downloadRemoteWallpaper, getMonitors, saveLastWallpaper, killWallpaperProcesses, diff --git a/src/electron/main/trpc/routes/wallpaper/thumbnail.ts b/src/electron/main/trpc/routes/wallpaper/thumbnail.ts index 2f4455a..8b23195 100644 --- a/src/electron/main/trpc/routes/wallpaper/thumbnail.ts +++ b/src/electron/main/trpc/routes/wallpaper/thumbnail.ts @@ -1,14 +1,15 @@ import { parentPort } from "worker_threads"; -import os from "os"; import path from "path"; import crypto from "crypto"; import { promises as fs } from "fs"; import sharp from "sharp"; import { encode } from "blurhash"; import { execute } from "@electron/main/lib/index.js"; -import { LibraryWallpaper, WallpaperData } from "./types.js"; +import { logger } from "@electron/main/lib/logger.js"; + +import { type LibraryWallpaper, type WallpaperData } from "./types.js"; +import { thumbnailDir } from "@electron/main/lib/paths.js"; -const THUMB_CACHE_DIR = path.join(os.homedir(), ".cache", "walltone", "thumbnails"); const THUMBNAIL_WIDTH = 640; const getFileHash = async (filePath: string): Promise => { @@ -24,58 +25,66 @@ const getOrCreateThumbnail = async (wallpaper: LibraryWallpaper) => { } const fullSizePath = wallpaper.fullSizePath.replace("image://", "").replace("video://", ""); - await fs.mkdir(THUMB_CACHE_DIR, { recursive: true }); + await fs.mkdir(thumbnailDir, { recursive: true }); const hash = await getFileHash(fullSizePath); - const thumbPath = path.join(THUMB_CACHE_DIR, `${hash}.jpeg`); + const thumbPath = path.join(thumbnailDir, `${hash}.jpeg`); try { await fs.access(thumbPath); - console.log("Cache hit: ", wallpaper.fullSizePath); + logger.debug( + { fullSizePath: wallpaper.fullSizePath, thumbPath }, + "Thumbnail cache hit for wallpaper" + ); } catch { - console.log("Cache miss: ", wallpaper.fullSizePath); - if (wallpaper.type === "image" || wallpaper.type === "wallpaper-engine") - await sharp(fullSizePath) - .rotate() - .resize(THUMBNAIL_WIDTH, null, { - withoutEnlargement: true, - }) - .jpeg({ - quality: 80, - mozjpeg: true, - }) - .toFile(thumbPath); + try { + await sharp(fullSizePath) + .rotate() + .resize(THUMBNAIL_WIDTH, null, { + withoutEnlargement: true, + }) + .jpeg({ + quality: 80, + mozjpeg: true, + }) + .toFile(thumbPath); + logger.info({ fullSizePath, thumbPath }, "Successfully generated image thumbnail"); + } catch (err) { + logger.error({ err, fullSizePath, thumbPath }, "Failed to generate image thumbnail"); + } else if (wallpaper.type === "video") - await execute({ - ignoreErrors: true, - logStdout: false, - logStderr: false, - command: "ffmpeg", - args: [ - "-y", - "-ss", - "1", // seek to 1s - "-i", - fullSizePath, - "-frames:v", - "1", // grab one frame - "-vf", - `scale='if(gt(iw,${THUMBNAIL_WIDTH}),${THUMBNAIL_WIDTH},iw)':-1`, - "-q:v", - "1", // quality (1 = best, 31 = worst) - thumbPath, // save directly to file - ], - }); + try { + await execute({ + command: "ffmpeg", + args: [ + "-y", + "-ss", + "1", // seek to 1s + "-i", + fullSizePath, + "-frames:v", + "1", // grab one frame + "-vf", + `scale='if(gt(iw,${THUMBNAIL_WIDTH}),${THUMBNAIL_WIDTH},iw)':-1`, + "-q:v", + "1", // quality (1 = best, 31 = worst) + thumbPath, // save directly to file + ], + }); + logger.info({ fullSizePath, thumbPath }, "Successfully generated video thumbnail"); + } catch (err) { + logger.error({ err, fullSizePath, thumbPath }, "Failed to generate video thumbnail"); + } } return `image://${thumbPath}`; }; const generateBlurHash = async (wallpaper: LibraryWallpaper) => { - const fullSizePath = wallpaper.thumbnailPath.replace("image://", "").replace("video://", ""); + const thumbnailPath = wallpaper.thumbnailPath.replace("image://", "").replace("video://", ""); try { - const buf = await sharp(fullSizePath).resize(32, 32).ensureAlpha().raw().toBuffer(); + const buf = await sharp(thumbnailPath).resize(32, 32).ensureAlpha().raw().toBuffer(); return encode(Uint8ClampedArray.from(buf), 32, 32, 4, 4); } catch { return undefined; diff --git a/src/electron/main/trpc/routes/wallpaper/types.ts b/src/electron/main/trpc/routes/wallpaper/types.ts index e5078a3..9a6259d 100644 --- a/src/electron/main/trpc/routes/wallpaper/types.ts +++ b/src/electron/main/trpc/routes/wallpaper/types.ts @@ -40,8 +40,8 @@ export interface WallpaperEngineWallpaper extends BaseWallpaper { export type LibraryWallpaper = ImageWallpaper | VideoWallpaper | WallpaperEngineWallpaper; -export interface WallpaperData { - data: T[]; +export interface WallpaperData { + data: TWallpaper[]; currentPage: number; prevPage: number | null; nextPage: number | null; diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 5acf277..452a94e 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,3 +1,5 @@ +import React from "react"; +import { toast } from "sonner"; import { Settings } from "lucide-react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { Button } from "@renderer/components/ui/button.js"; @@ -14,10 +16,20 @@ import { CurrentTabProvider } from "@renderer/providers/current-tab/provider.js" import { useCurrentTab } from "@renderer/providers/current-tab/hook.js"; import { Toaster } from "@renderer/components/ui/sonner.js"; import { NavigationPaths, routes } from "@renderer/routes/index.js"; +import { client } from "@renderer/lib/trpc.js"; const queryClient = new QueryClient(); const App = () => { + React.useEffect(() => { + const sub = client.wallpaper.onWallpaperError.subscribe(undefined, { + onData(data) { + toast.error(data as string); + }, + }); + return () => sub.unsubscribe(); + }, []); + return ( diff --git a/src/renderer/components/ui/loading-button.tsx b/src/renderer/components/ui/loading-button.tsx index 394c6c7..d02dd6b 100644 --- a/src/renderer/components/ui/loading-button.tsx +++ b/src/renderer/components/ui/loading-button.tsx @@ -9,7 +9,7 @@ interface Props extends React.ComponentProps { const LoadingButton = ({ children, isLoading, ...props }: Props) => { return ( - diff --git a/src/renderer/components/wallpaper-apply-dialog/dynamic-controls.tsx b/src/renderer/components/wallpaper-apply-dialog/dynamic-controls.tsx new file mode 100644 index 0000000..0936480 --- /dev/null +++ b/src/renderer/components/wallpaper-apply-dialog/dynamic-controls.tsx @@ -0,0 +1,171 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@renderer/components/ui/card.js"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@renderer/components/ui/select.js"; +import { Slider } from "@renderer/components/ui/slider.js"; +import { Switch } from "@renderer/components/ui/switch.js"; +import { Label } from "@renderer/components/ui/label.js"; +import { + DynamicControlDefinition, + DynamicControlValues, + SetDynamicControlValues, +} from "@renderer/components/wallpaper-dialog/types.js"; + +export const DynamicControls = ({ + controlDefinitions, + controlValues, + setControlValues, +}: { + controlDefinitions: DynamicControlDefinition[]; + controlValues: DynamicControlValues; + setControlValues: SetDynamicControlValues; +}) => { + return ( + + + Additional Settings + + + {controlDefinitions.map((control) => ( + + setControlValues((prev) => ({ + ...prev, + [control.key]: value, + })) + } + /> + ))} + + + ); +}; + +export const DynamicControl = ({ + control, + value, + onChange, +}: { + control: DynamicControlDefinition; + value: string | number | boolean; + onChange: (value: string | number | boolean) => void; +}) => { + switch (control.type) { + case "range": + return ; + case "boolean": + return ; + case "select": + return ; + default: + return null; + } +}; + +export const RangeControl = ({ + control, + value, + onChange, +}: { + control: DynamicControlDefinition; + value: number; + onChange: (value: number) => void; +}) => { + const min = control.options?.min ?? 0; + const max = control.options?.max ?? 100; + const step = control.options?.step ?? 1; + + return ( +
+
+
+ + {control.description && ( +

{control.description}

+ )} +
+
+ + {value ?? control.defaultValue ?? min} + +
+
+ onChange(values[0])} + min={min} + max={max} + step={step} + className="w-full" + /> +
+ {min} + {max} +
+
+ ); +}; + +export const BooleanControl = ({ + control, + value, + onChange, +}: { + control: DynamicControlDefinition; + value: boolean; + onChange: (value: boolean) => void; +}) => { + return ( +
+
+ + {control.description && ( +

{control.description}

+ )} +
+ +
+ ); +}; + +export const SelectControl = ({ + control, + value, + onChange, +}: { + control: DynamicControlDefinition; + value: string; + onChange: (value: string) => void; +}) => { + const options = control.options?.values ?? []; + + return ( +
+
+ + {control.description && ( +

{control.description}

+ )} +
+ +
+ ); +}; diff --git a/src/renderer/components/wallpaper-apply-dialog/hooks.tsx b/src/renderer/components/wallpaper-apply-dialog/hooks.tsx new file mode 100644 index 0000000..1bd8c62 --- /dev/null +++ b/src/renderer/components/wallpaper-apply-dialog/hooks.tsx @@ -0,0 +1,94 @@ +import React from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { client } from "@renderer/lib/trpc.js"; + +export const useMonitorSelection = (scalingOptions?: { key: string; text: string }[]) => { + const queryClient = useQueryClient(); + const [selectedMonitors, setSelectedMonitors] = React.useState>(new Set()); + const [monitorScalingMethods, setMonitorScalingMethods] = React.useState>( + {} + ); + + const defaultScalingMethod = scalingOptions?.[0]?.key || "fill"; + + const monitorsQuery = useQuery({ + queryKey: ["all-monitors"], + queryFn: async () => { + return await client.monitor.search.query(); + }, + staleTime: 1000 * 60 * 1, + }); + + React.useEffect(() => { + if (monitorsQuery.data && monitorsQuery.data.length > 0) { + const firstMonitor = monitorsQuery.data[0]; + if (firstMonitor) { + setSelectedMonitors(new Set([firstMonitor.id])); + setMonitorScalingMethods({ [firstMonitor.id]: defaultScalingMethod }); + } + } + }, [monitorsQuery.data, defaultScalingMethod]); + + const toggleMonitor = React.useCallback( + (id: string) => { + setSelectedMonitors((prev) => { + const newSet = new Set(prev); + if (newSet.has(id)) { + newSet.delete(id); + setMonitorScalingMethods((prevMethods) => { + const newMethods = { ...prevMethods }; + delete newMethods[id]; + return newMethods; + }); + } else { + newSet.add(id); + setMonitorScalingMethods((prevMethods) => ({ + ...prevMethods, + [id]: defaultScalingMethod, + })); + } + return newSet; + }); + }, + [defaultScalingMethod] + ); + + const updateScalingMethod = React.useCallback((id: string, scalingMethod: string) => { + setMonitorScalingMethods((prev) => ({ + ...prev, + [id]: scalingMethod, + })); + }, []); + + const selectAll = React.useCallback(() => { + if (monitorsQuery.data) { + const allnames = monitorsQuery.data.map((monitor) => monitor.id); + setSelectedMonitors(new Set(allnames)); + + const allMethods = allnames.reduce( + (acc, name) => ({ + ...acc, + [name]: defaultScalingMethod, + }), + {} + ); + setMonitorScalingMethods(allMethods); + } + }, [monitorsQuery.data, defaultScalingMethod]); + + const selectNone = React.useCallback(() => { + setSelectedMonitors(new Set()); + setMonitorScalingMethods({}); + }, []); + + return { + ...monitorsQuery, + selectedMonitors, + monitorScalingMethods, + toggleMonitor, + updateScalingMethod, + selectAll, + selectNone, + retryQuery: () => queryClient.invalidateQueries({ queryKey: ["all-monitors"] }), + }; +}; diff --git a/src/renderer/components/wallpaper-dialog/apply-dialog.tsx b/src/renderer/components/wallpaper-apply-dialog/index.tsx similarity index 71% rename from src/renderer/components/wallpaper-dialog/apply-dialog.tsx rename to src/renderer/components/wallpaper-apply-dialog/index.tsx index 2b156ab..98aadea 100644 --- a/src/renderer/components/wallpaper-dialog/apply-dialog.tsx +++ b/src/renderer/components/wallpaper-apply-dialog/index.tsx @@ -12,7 +12,7 @@ import { } from "@renderer/components/ui/dialog.js"; import { Button } from "@renderer/components/ui/button.js"; import { Checkbox } from "@renderer/components/ui/checkbox.js"; -import { Card, CardContent, CardHeader, CardTitle } from "@renderer/components/ui/card.js"; +import { Card, CardContent } from "@renderer/components/ui/card.js"; import { Select, SelectContent, @@ -20,27 +20,25 @@ import { SelectTrigger, SelectValue, } from "@renderer/components/ui/select.js"; -import { Slider } from "@renderer/components/ui/slider.js"; -import { Switch } from "@renderer/components/ui/switch.js"; -import { Label } from "@renderer/components/ui/label.js"; import { ScrollArea } from "@renderer/components/ui/scroll-area.js"; import { BlurhashPreview } from "@renderer/components/ui/blurhash-preview.js"; import { OnWallpaperApply } from "@renderer/components/wallpapers-grid/types.js"; -import { useMonitorSelection, useWallpaperActions } from "./hooks.js"; +import { useWallpaperActions } from "@renderer/components/wallpaper-dialog/hooks.js"; import { DynamicControlDefinition, DynamicControlValues, - SetDynamicControlValues, -} from "./types.js"; +} from "@renderer/components/wallpaper-dialog/types.js"; +import { useMonitorSelection } from "./hooks.js"; +import { DynamicControls } from "./dynamic-controls.js"; -const ApplyWallpaperDialog = ({ +const ApplyWallpaperDialog = ({ wallpaper, onApply, scalingOptions, controlDefinitions, }: { - wallpaper: T; - onApply?: OnWallpaperApply; + wallpaper: TWallpaper; + onApply?: OnWallpaperApply; scalingOptions?: { key: string; text: string }[]; controlDefinitions?: DynamicControlDefinition[]; }) => { @@ -400,163 +398,4 @@ const MonitorList = ({ ); }; -// ======================================================================================== -// DYNAMIC CONTROLS COMPONENTS -// ======================================================================================== - -const DynamicControls = ({ - controlDefinitions, - controlValues, - setControlValues, -}: { - controlDefinitions: DynamicControlDefinition[]; - controlValues: DynamicControlValues; - setControlValues: SetDynamicControlValues; -}) => { - return ( - - - Additional Settings - - - {controlDefinitions.map((control) => ( - - setControlValues((prev) => ({ - ...prev, - [control.key]: value, - })) - } - /> - ))} - - - ); -}; - -const DynamicControl = ({ - control, - value, - onChange, -}: { - control: DynamicControlDefinition; - value: string | number | boolean; - onChange: (value: string | number | boolean) => void; -}) => { - switch (control.type) { - case "range": - return ; - case "boolean": - return ; - case "select": - return ; - default: - return null; - } -}; - -const RangeControl = ({ - control, - value, - onChange, -}: { - control: DynamicControlDefinition; - value: number; - onChange: (value: number) => void; -}) => { - const min = control.options?.min ?? 0; - const max = control.options?.max ?? 100; - const step = control.options?.step ?? 1; - - return ( -
-
-
- - {control.description && ( -

{control.description}

- )} -
-
- - {value ?? control.defaultValue ?? min} - -
-
- onChange(values[0])} - min={min} - max={max} - step={step} - className="w-full" - /> -
- {min} - {max} -
-
- ); -}; - -const BooleanControl = ({ - control, - value, - onChange, -}: { - control: DynamicControlDefinition; - value: boolean; - onChange: (value: boolean) => void; -}) => { - return ( -
-
- - {control.description && ( -

{control.description}

- )} -
- -
- ); -}; - -const SelectControl = ({ - control, - value, - onChange, -}: { - control: DynamicControlDefinition; - value: string; - onChange: (value: string) => void; -}) => { - const options = control.options?.values ?? []; - - return ( -
-
- - {control.description && ( -

{control.description}

- )} -
- -
- ); -}; - export default ApplyWallpaperDialog; diff --git a/src/renderer/components/wallpaper-dialog/hooks.tsx b/src/renderer/components/wallpaper-dialog/hooks.tsx index dec389b..aee4ef0 100644 --- a/src/renderer/components/wallpaper-dialog/hooks.tsx +++ b/src/renderer/components/wallpaper-dialog/hooks.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { toast } from "sonner"; import type { ThemeType, ThemePolarity, Theme } from "@electron/main/trpc/routes/theme/index.js"; import { type BaseWallpaper } from "@electron/main/trpc/routes/wallpaper/types.js"; @@ -94,100 +94,9 @@ export const useThemeEditor = (theme: Theme | undefined, selectedColorKey: strin }; }; -export const useMonitorSelection = (scalingOptions?: { key: string; text: string }[]) => { - const queryClient = useQueryClient(); - const [selectedMonitors, setSelectedMonitors] = React.useState>(new Set()); - const [monitorScalingMethods, setMonitorScalingMethods] = React.useState>( - {} - ); - - const defaultScalingMethod = scalingOptions?.[0]?.key || "fill"; - - const monitorsQuery = useQuery({ - queryKey: ["all-monitors"], - queryFn: async () => { - return await client.monitor.search.query(); - }, - staleTime: 1000 * 60 * 1, - }); - - React.useEffect(() => { - if (monitorsQuery.data && monitorsQuery.data.length > 0) { - const firstMonitor = monitorsQuery.data[0]; - if (firstMonitor) { - setSelectedMonitors(new Set([firstMonitor.id])); - setMonitorScalingMethods({ [firstMonitor.id]: defaultScalingMethod }); - } - } - }, [monitorsQuery.data, defaultScalingMethod]); - - const toggleMonitor = React.useCallback( - (id: string) => { - setSelectedMonitors((prev) => { - const newSet = new Set(prev); - if (newSet.has(id)) { - newSet.delete(id); - setMonitorScalingMethods((prevMethods) => { - const newMethods = { ...prevMethods }; - delete newMethods[id]; - return newMethods; - }); - } else { - newSet.add(id); - setMonitorScalingMethods((prevMethods) => ({ - ...prevMethods, - [id]: defaultScalingMethod, - })); - } - return newSet; - }); - }, - [defaultScalingMethod] - ); - - const updateScalingMethod = React.useCallback((id: string, scalingMethod: string) => { - setMonitorScalingMethods((prev) => ({ - ...prev, - [id]: scalingMethod, - })); - }, []); - - const selectAll = React.useCallback(() => { - if (monitorsQuery.data) { - const allnames = monitorsQuery.data.map((monitor) => monitor.id); - setSelectedMonitors(new Set(allnames)); - - const allMethods = allnames.reduce( - (acc, name) => ({ - ...acc, - [name]: defaultScalingMethod, - }), - {} - ); - setMonitorScalingMethods(allMethods); - } - }, [monitorsQuery.data, defaultScalingMethod]); - - const selectNone = React.useCallback(() => { - setSelectedMonitors(new Set()); - setMonitorScalingMethods({}); - }, []); - - return { - ...monitorsQuery, - selectedMonitors, - monitorScalingMethods, - toggleMonitor, - updateScalingMethod, - selectAll, - selectNone, - retryQuery: () => queryClient.invalidateQueries({ queryKey: ["all-monitors"] }), - }; -}; - -export const useWallpaperActions = (wallpaper: T) => { +export const useWallpaperActions = (wallpaper: TWallpaper) => { const downloadMutation = useMutation({ - mutationFn: async (onDownload: OnWallpaperDownload) => { + mutationFn: async (onDownload: OnWallpaperDownload) => { return await onDownload(wallpaper); }, onSuccess: () => { @@ -219,18 +128,12 @@ export const useWallpaperActions = (wallpaper: T) => { monitorConfigs, controlValues, }: { - onApply: OnWallpaperApply; + onApply: OnWallpaperApply; monitorConfigs: { id: string; scalingMethod: string }[]; controlValues?: DynamicControlValues; }) => { return await onApply(wallpaper, monitorConfigs, controlValues); }, - onSuccess: () => { - toast.success("Wallpaper applied successfully"); - }, - onError: (error) => { - toast.error(error.message); - }, }); return { diff --git a/src/renderer/components/wallpaper-dialog/index.tsx b/src/renderer/components/wallpaper-dialog/index.tsx index aae1cbc..aae74de 100644 --- a/src/renderer/components/wallpaper-dialog/index.tsx +++ b/src/renderer/components/wallpaper-dialog/index.tsx @@ -29,10 +29,10 @@ import { useThemeEditor, useWallpaperActions, } from "./hooks.js"; -import ApplyWallpaperDialog from "./apply-dialog.js"; +import ApplyWallpaperDialog from "../wallpaper-apply-dialog/index.js"; import { type DynamicControlDefinition } from "./types.js"; -const WallpaperDialog = ({ +const WallpaperDialog = ({ wallpaper, onApply, onDownload, @@ -40,9 +40,9 @@ const WallpaperDialog = ({ controlDefinitions, isOpen, }: { - wallpaper: T; - onApply?: OnWallpaperApply; - onDownload?: OnWallpaperDownload; + wallpaper: TWallpaper; + onApply?: OnWallpaperApply; + onDownload?: OnWallpaperDownload; scalingOptions?: { key: string; text: string }[]; controlDefinitions?: DynamicControlDefinition[]; isOpen: boolean; @@ -72,7 +72,7 @@ const WallpaperDialog = ({ ); return ( - +
@@ -154,19 +154,23 @@ const WallpaperDialog = ({ ); }; -const Header = ({ wallpaper }: { wallpaper: T }) => { +const Header = ({ wallpaper }: { wallpaper: TWallpaper }) => { return ( - {wallpaper.name} + {wallpaper.name} Generate and customize color themes from this wallpaper ); }; -const WallpaperImage = ({ wallpaper }: { wallpaper: T }) => { +const WallpaperImage = ({ + wallpaper, +}: { + wallpaper: TWallpaper; +}) => { return ( @@ -368,7 +372,7 @@ const ThemeColors = ({ ); }; -const WallpaperActions = ({ +const WallpaperActions = ({ wallpaper, theme, onApply, @@ -376,10 +380,10 @@ const WallpaperActions = ({ scalingOptions, controlDefinitions, }: { - wallpaper: T; + wallpaper: TWallpaper; theme?: Theme; - onApply?: OnWallpaperApply; - onDownload?: OnWallpaperDownload; + onApply?: OnWallpaperApply; + onDownload?: OnWallpaperDownload; scalingOptions?: { key: string; text: string }[]; controlDefinitions?: DynamicControlDefinition[]; }) => { diff --git a/src/renderer/components/wallpapers-grid/content-components.tsx b/src/renderer/components/wallpapers-grid/content-components.tsx index f66dc74..3f03ed8 100644 --- a/src/renderer/components/wallpapers-grid/content-components.tsx +++ b/src/renderer/components/wallpapers-grid/content-components.tsx @@ -142,16 +142,16 @@ export const ConfigurationScreen = ({ ); }; -export const Wallpaper = ({ +export const Wallpaper = ({ wallpaper, onWallpaperApply, onWallpaperDownload, scalingOptions, controlDefinitions, }: { - wallpaper: T; - onWallpaperApply?: OnWallpaperApply; - onWallpaperDownload?: OnWallpaperDownload; + wallpaper: TWallpaper; + onWallpaperApply?: OnWallpaperApply; + onWallpaperDownload?: OnWallpaperDownload; scalingOptions?: { key: string; text: string }[]; controlDefinitions?: DynamicControlDefinition[]; }) => { @@ -418,7 +418,7 @@ export const EmptyWallpapers = ({ ); }; -export const WallpaperGrid = ({ +export const WallpaperGrid = ({ isError, error, refetch, @@ -445,11 +445,11 @@ export const WallpaperGrid = ({ isFetchingNextPage: boolean; hasNextPage: boolean; fetchNextPage: () => void; - allWallpapers: T[]; + allWallpapers: TWallpaper[]; debouncedInputValue: string; clearSearch: () => void; - onWallpaperApply?: OnWallpaperApply; - onWallpaperDownload?: OnWallpaperDownload; + onWallpaperApply?: OnWallpaperApply; + onWallpaperDownload?: OnWallpaperDownload; scalingOptions?: { key: string; text: string }[]; controlDefinitions?: DynamicControlDefinition[]; }) => { diff --git a/src/renderer/components/wallpapers-grid/hooks.ts b/src/renderer/components/wallpapers-grid/hooks.ts index f9003f9..2412c28 100644 --- a/src/renderer/components/wallpapers-grid/hooks.ts +++ b/src/renderer/components/wallpapers-grid/hooks.ts @@ -1,5 +1,4 @@ import React from "react"; -import { useInView } from "react-intersection-observer"; import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; import { useDebouncedCallback } from "use-debounce"; import { type BaseWallpaper } from "@electron/main/trpc/routes/wallpaper/types.js"; @@ -61,14 +60,14 @@ export const useConfiguration = ( isPending: isConfigPending, isError: isConfigError, refetch: refetchConfig, - } = useQuery({ + } = useQuery>({ enabled: !!requiresConfiguration, queryKey: [`${requiresConfiguration?.setting.key}`], queryFn: async () => { - return await client.settings.get.query({ + return (await client.settings.get.query({ key: requiresConfiguration!.setting.key, decrypt: requiresConfiguration!.setting.decrypt || false, - }); + })) as DotNotationValueOf; }, }); @@ -103,7 +102,7 @@ export const useWallpaperData = < debouncedInputValue: string; sorting: TSorting; appliedFilters: AppliedFilters; - configValue: DotNotationValueOf; + configValue?: DotNotationValueOf; isConfigurationValid: boolean; }) => { const query = useInfiniteQuery({ @@ -120,8 +119,8 @@ export const useWallpaperData = < initialPageParam: 1, getNextPageParam: (lastPage) => lastPage.nextPage, getPreviousPageParam: (firstPage) => firstPage.prevPage, - retry: 3, - retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000), + retry: 1, + // retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000), }); const allWallpapers = React.useMemo(() => { diff --git a/src/renderer/components/wallpapers-grid/index.tsx b/src/renderer/components/wallpapers-grid/index.tsx index 56a4a00..d6d04c0 100644 --- a/src/renderer/components/wallpapers-grid/index.tsx +++ b/src/renderer/components/wallpapers-grid/index.tsx @@ -85,7 +85,7 @@ const WallpaperGridControls = ({ }; const WallpapersGrid = < - T extends BaseWallpaper, + TWallpaper extends BaseWallpaper, TSorting extends string, TConfigKey extends SettingKey, >({ @@ -99,7 +99,7 @@ const WallpapersGrid = < onWallpaperDownload, requiresConfiguration, controlDefinitions, -}: WallpapersGridProps) => { +}: WallpapersGridProps) => { const { debouncedInputValue, handleSearch, clearSearch } = useWallpaperSearch(); const { sorting, setSorting, appliedFilters, setAppliedFilters } = useWallpaperFilters(sortingOptions); diff --git a/src/renderer/components/wallpapers-grid/types.ts b/src/renderer/components/wallpapers-grid/types.ts index db40036..b57f534 100644 --- a/src/renderer/components/wallpapers-grid/types.ts +++ b/src/renderer/components/wallpapers-grid/types.ts @@ -10,13 +10,15 @@ import { DynamicControlValues, } from "@renderer/components/wallpaper-dialog/types.js"; -export type OnWallpaperApply = ( - wallpaper: T, +export type OnWallpaperApply = ( + wallpaper: TWallpaper, monitorConfigs: { id: string; scalingMethod: string }[], controlValues?: DynamicControlValues ) => Promise; -export type OnWallpaperDownload = (wallpaper: T) => Promise; +export type OnWallpaperDownload = ( + wallpaper: TWallpaper +) => Promise; export interface FilterDefinition { type: "single" | "multiple" | "boolean"; @@ -57,7 +59,7 @@ export interface ConfigurationRequirement { } export interface WallpapersGridProps< - T extends BaseWallpaper, + TWallpaper extends BaseWallpaper, TSorting extends string, TConfigKey extends SettingKey, > { @@ -68,7 +70,7 @@ export interface WallpapersGridProps< sorting: TSorting; appliedFilters?: AppliedFilters; configValue?: DotNotationValueOf; - }) => Promise>; + }) => Promise>; queryEnabled?: boolean; sortingOptions?: { key: TSorting; @@ -79,8 +81,8 @@ export interface WallpapersGridProps< key: string; text: string; }[]; - onWallpaperApply?: OnWallpaperApply; - onWallpaperDownload?: OnWallpaperDownload; + onWallpaperApply?: OnWallpaperApply; + onWallpaperDownload?: OnWallpaperDownload; requiresConfiguration?: ConfigurationRequirement; controlDefinitions?: DynamicControlDefinition[]; } diff --git a/src/renderer/pages/discover/pexels-images.tsx b/src/renderer/pages/discover/pexels-images.tsx index 06dee8f..afded28 100644 --- a/src/renderer/pages/discover/pexels-images.tsx +++ b/src/renderer/pages/discover/pexels-images.tsx @@ -89,12 +89,23 @@ const DiscoverPexelsImagesTab = () => { ], }, ]} + scalingOptions={[ + { key: "stretch", text: "Stretch" }, + { key: "fit", text: "Fit" }, + { key: "fill", text: "Fill" }, + { key: "center", text: "Center" }, + { key: "tile", text: "Tile" }, + ]} onWallpaperApply={async (wallpaper, monitors) => { + const applyPath = await client.api.download.mutate({ + id: wallpaper.id, + applyPath: wallpaper.downloadUrl, + }); await client.wallpaper.set.mutate({ type: "image", id: wallpaper.id, name: wallpaper.name, - applyPath: wallpaper.downloadUrl, + applyPath: applyPath, monitors, }); }} diff --git a/src/renderer/pages/discover/pexels-videos.tsx b/src/renderer/pages/discover/pexels-videos.tsx index 107daa9..2e3e4e9 100644 --- a/src/renderer/pages/discover/pexels-videos.tsx +++ b/src/renderer/pages/discover/pexels-videos.tsx @@ -70,12 +70,23 @@ const DiscoverPexelsVideosTab = () => { values: ["small", "medium", "large"], }, ]} + scalingOptions={[ + { key: "fill", text: "Fill" }, + { key: "fit", text: "Fit" }, + { key: "center", text: "Center" }, + { key: "stretch", text: "Stretch" }, + { key: "tile", text: "Tile" }, + ]} onWallpaperApply={async (wallpaper, monitors) => { + const applyPath = await client.api.download.mutate({ + id: wallpaper.id, + applyPath: wallpaper.downloadUrl, + }); await client.wallpaper.set.mutate({ type: "video", id: wallpaper.id, name: wallpaper.name, - applyPath: wallpaper.downloadUrl, + applyPath: applyPath, monitors, }); }} diff --git a/src/renderer/pages/discover/pixabay-images.tsx b/src/renderer/pages/discover/pixabay-images.tsx index 3008c3f..511be16 100644 --- a/src/renderer/pages/discover/pixabay-images.tsx +++ b/src/renderer/pages/discover/pixabay-images.tsx @@ -99,8 +99,8 @@ const DiscoverPixabayImagesTab = () => { }, { type: "single", - key: "color", - title: "Color", + key: "colors", + title: "Colors", values: [ "grayscale", "transparent", @@ -135,12 +135,23 @@ const DiscoverPixabayImagesTab = () => { values: ["popular", "latest"], }, ]} + scalingOptions={[ + { key: "stretch", text: "Stretch" }, + { key: "fit", text: "Fit" }, + { key: "fill", text: "Fill" }, + { key: "center", text: "Center" }, + { key: "tile", text: "Tile" }, + ]} onWallpaperApply={async (wallpaper, monitors) => { + const applyPath = await client.api.download.mutate({ + id: wallpaper.id, + applyPath: wallpaper.downloadUrl, + }); await client.wallpaper.set.mutate({ type: "image", id: wallpaper.id, name: wallpaper.name, - applyPath: wallpaper.downloadUrl, + applyPath: applyPath, monitors, }); }} diff --git a/src/renderer/pages/discover/pixabay-videos.tsx b/src/renderer/pages/discover/pixabay-videos.tsx index 64b8fd0..c836e57 100644 --- a/src/renderer/pages/discover/pixabay-videos.tsx +++ b/src/renderer/pages/discover/pixabay-videos.tsx @@ -99,8 +99,8 @@ const DiscoverPixabayVideosTab = () => { }, { type: "single", - key: "color", - title: "Color", + key: "colors", + title: "Colors", values: [ "grayscale", "transparent", @@ -135,12 +135,23 @@ const DiscoverPixabayVideosTab = () => { values: ["popular", "latest"], }, ]} + scalingOptions={[ + { key: "fill", text: "Fill" }, + { key: "fit", text: "Fit" }, + { key: "center", text: "Center" }, + { key: "stretch", text: "Stretch" }, + { key: "tile", text: "Tile" }, + ]} onWallpaperApply={async (wallpaper, monitors) => { + const applyPath = await client.api.download.mutate({ + id: wallpaper.id, + applyPath: wallpaper.downloadUrl, + }); await client.wallpaper.set.mutate({ type: "video", id: wallpaper.id, name: wallpaper.name, - applyPath: wallpaper.downloadUrl, + applyPath: applyPath, monitors, }); }} diff --git a/src/renderer/pages/discover/unsplash.tsx b/src/renderer/pages/discover/unsplash.tsx index 991dfd0..d4adbdf 100644 --- a/src/renderer/pages/discover/unsplash.tsx +++ b/src/renderer/pages/discover/unsplash.tsx @@ -86,12 +86,23 @@ const DiscoverUnsplashTab = () => { ], }, ]} + scalingOptions={[ + { key: "stretch", text: "Stretch" }, + { key: "fit", text: "Fit" }, + { key: "fill", text: "Fill" }, + { key: "center", text: "Center" }, + { key: "tile", text: "Tile" }, + ]} onWallpaperApply={async (wallpaper, monitors) => { + const applyPath = await client.api.download.mutate({ + id: wallpaper.id, + applyPath: wallpaper.downloadUrl, + }); await client.wallpaper.set.mutate({ type: "image", id: wallpaper.id, name: wallpaper.name, - applyPath: wallpaper.downloadUrl, + applyPath: applyPath, monitors, }); }} diff --git a/src/renderer/pages/discover/wallhaven.tsx b/src/renderer/pages/discover/wallhaven.tsx index af4e45f..fa2ffc6 100644 --- a/src/renderer/pages/discover/wallhaven.tsx +++ b/src/renderer/pages/discover/wallhaven.tsx @@ -47,12 +47,23 @@ const DiscoverWallhavenTab = () => { values: ["16:9", "16:10", "4:3", "21:9", "32:9"], }, ]} + scalingOptions={[ + { key: "stretch", text: "Stretch" }, + { key: "fit", text: "Fit" }, + { key: "fill", text: "Fill" }, + { key: "center", text: "Center" }, + { key: "tile", text: "Tile" }, + ]} onWallpaperApply={async (wallpaper, monitors) => { + const applyPath = await client.api.download.mutate({ + id: wallpaper.id, + applyPath: wallpaper.downloadUrl, + }); await client.wallpaper.set.mutate({ type: "image", id: wallpaper.id, name: wallpaper.name, - applyPath: wallpaper.downloadUrl, + applyPath: applyPath, monitors, }); }} diff --git a/src/renderer/pages/discover/wallpaper-engine.tsx b/src/renderer/pages/discover/wallpaper-engine.tsx index fba8320..ee6c76f 100644 --- a/src/renderer/pages/discover/wallpaper-engine.tsx +++ b/src/renderer/pages/discover/wallpaper-engine.tsx @@ -1,236 +1,351 @@ +import React from "react"; +import { toast } from "sonner"; import { ExternalLink, Key, RefreshCcw, Settings } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogClose, +} from "@renderer/components/ui/dialog.js"; +import { Input } from "@renderer/components/ui/input.js"; +import { Button } from "@renderer/components/ui/button.js"; import WallpapersGrid from "@renderer/components/wallpapers-grid/index.js"; import { useCurrentTab } from "@renderer/providers/current-tab/hook.js"; import { client } from "@renderer/lib/trpc.js"; +import { + type MonitorConfig, + type WallpaperEngineWallpaper, +} from "@electron/main/trpc/routes/wallpaper/types.js"; +import { useMutation } from "@tanstack/react-query"; +import LoadingButton from "@renderer/components/ui/loading-button.js"; + +const SteamCredentialDialog = ({ + showSteamDialog, + setShowSteamDialog, + selectedWallpaper, + selectedMonitors, +}: { + showSteamDialog: boolean; + setShowSteamDialog: (value: boolean) => void; + selectedWallpaper: WallpaperEngineWallpaper | null; + selectedMonitors: MonitorConfig | null; +}) => { + const [steamUsername, setSteamUsername] = React.useState(""); + const [steamPassword, setSteamPassword] = React.useState(""); + const [steamGuard, setSteamGuard] = React.useState(""); + + const downloadMutation = useMutation({ + mutationFn: async () => { + return await client.api.wallpaperEngine.download.mutate({ + workshopId: selectedWallpaper!.id, + username: steamUsername, + password: steamPassword, + guard: steamGuard, + }); + }, + onSuccess: async (applyPath: string) => { + client.wallpaper.set.mutate({ + type: "wallpaper-engine", + id: selectedWallpaper!.id, + name: selectedWallpaper!.name, + applyPath, + monitors: selectedMonitors!, + }); + setShowSteamDialog(false); + }, + onError: (error) => { + toast.error(error.message); + }, + }); + + return ( + + + + Steam Login Required + +
+ setSteamUsername(e.target.value)} + /> + setSteamPassword(e.target.value)} + /> + setSteamGuard(e.target.value)} + /> +
+ + + + + { + downloadMutation.mutate(); + }} + className="flex-1 text-sm" + > + Apply + + +
+
+ ); +}; const DiscoverWallpaperEngineTab = () => { const { setCurrentTab } = useCurrentTab(); + const [showSteamDialog, setShowSteamDialog] = React.useState(false); + const [selectedWallpaper, setSelectedWallpaper] = React.useState( + null + ); + const [selectedMonitors, setSelectedMonitors] = React.useState(null); return ( - + + window.open("https://steamcommunity.com/dev/apikey", "_blank"), + }, + { + title: "Open Settings", + description: "Refresh the image library to load new wallpapers", + icon: Settings, + variant: "outline", + onClick: () => setCurrentTab("/settings"), + }, + { + title: "Check Again", + description: "Refresh to load new wallpapers", + icon: RefreshCcw, + variant: "ghost", + onClick: (refresh) => refresh(), + }, + ], + }} + queryKeys={[`wallpapers.discover.wallpaperEngine`]} + queryFn={async ({ pageParam, query, sorting, appliedFilters, configValue }) => { + const tags = Object.entries(appliedFilters?.arrays || {}).flatMap( + ([_, values]) => values + ); + + return await client.api.wallpaperEngine.search.query({ + apiKey: configValue!, + page: pageParam, + query, + sorting, + tags, + ...appliedFilters?.booleans, + }); + }} + sortingOptions={[ + { key: "0", text: "Vote" }, + { key: "1", text: "Date" }, + { key: "3", text: "Trend" }, + { key: "9", text: "Subscriptions" }, + ]} + scalingOptions={[ + { key: "default", text: "Default" }, + { key: "stretch", text: "Stretch" }, + { key: "fit", text: "Fit" }, + { key: "fill", text: "Fill" }, + ]} + filterDefinitions={[ { - title: "Get Wallpaper Engine API Key", - description: "Opens in new window", - icon: ExternalLink, - variant: "default", - onClick: () => window.open("https://steamcommunity.com/dev/apikey", "_blank"), + type: "boolean", + key: "matchAll", + title: "Match All", }, { - title: "Open Settings", - description: "Refresh the image library to load new wallpapers", - icon: Settings, - variant: "outline", - onClick: () => setCurrentTab("/settings"), + type: "multiple", + key: "types", + title: "Types", + values: ["Scene", "Video", "Web", "Application"], }, { - title: "Check Again", - description: "Refresh to load new wallpapers", - icon: RefreshCcw, - variant: "ghost", - onClick: (refresh) => refresh(), + type: "multiple", + key: "ages", + title: "Age", + values: ["Everyone", "Questionable", "Mature"], }, - ], - }} - queryKeys={[`wallpapers.discover.wallpaperEngine`]} - queryFn={async ({ pageParam, query, sorting, appliedFilters, configValue }) => { - const tags = Object.entries(appliedFilters?.arrays || {}).flatMap(([_, values]) => values); - - return await client.api.wallpaperEngine.search.query({ - apiKey: configValue!, - page: pageParam, - query, - sorting, - tags, - ...appliedFilters?.booleans, - }); - }} - sortingOptions={[ - { key: "0", text: "Vote" }, - { key: "1", text: "Date" }, - { key: "3", text: "Trend" }, - { key: "9", text: "Subscriptions" }, - ]} - filterDefinitions={[ - { - type: "boolean", - key: "matchAll", - title: "Match All", - }, - { - type: "multiple", - key: "types", - title: "Types", - values: ["Scene", "Video", "Web", "Application"], - }, - { - type: "multiple", - key: "ages", - title: "Age", - values: ["Everyone", "Questionable", "Mature"], - }, - { - type: "multiple", - key: "genres", - title: "Genres", - values: [ - "Abstract", - "Animal", - "Anime", - "Cartoon", - "CGI", - "Cyberpunk", - "Fantasy", - "Game", - "Girls", - "Guys", - "Landscape", - "Medieval", - "Memes", - "MMD", - "Music", - "Nature", - "Pixel art", - "Relaxing", - "Retro", - "Sci-Fi", - "Sports", - "Technology", - "Television", - "Vehicle", - "Unspecified", - ], - }, - { - type: "multiple", - key: "resolutions", - title: "Resolutions", - values: [ - "Standard Definition", - "1280 x 720", - "1366 x 768", - "1920 x 1080", - "2560 x 1440", - "3840 x 2160", - "Ultrawide Standard Definition", - "Ultrawide 2560 x 1080", - "Ultrawide 3440 x 1440", - "Dual Standard Definition", - "Dual 3840 x 1080", - "Dual 5120 x 1440", - "Dual 3840 x 2160", - "Triple Standard Definition", - "Triple 4096 x 768", - "Triple 5760 x 1080", - "Triple 7680 x 1440", - "Triple 11520 x 2160", - "Portrait Standard Definition", - "Portrait 720 x 1280", - "Portrait 1080 x 1920", - "Portrait 1440 x 2560", - "Portrait 2160 x 3840", - "Other resolution", - "Dynamic resolution", - ], - }, - { - type: "multiple", - key: "categories", - title: "Categories", - values: ["Wallpaper", "Preset", "Asset"], - }, - { - type: "multiple", - key: "assetTypes", - title: "Asset Types", - values: [ - "Particle", - "Image", - "Sound", - "Model", - "Text", - "Sprite", - "Fullscreen", - "Composite", - "Script", - "Effect", - ], - }, - { - type: "multiple", - key: "assetGenres", - title: "Asset Genres", - values: [ - "Audio Visualizer", - "Background", - "Character", - "Clock", - "Fire", - "Interactive", - "Magic", - "Post Processing", - "Smoke", - "Space", - ], - }, - { - type: "multiple", - key: "scriptTypes", - title: "Script Types", - values: [ - "Boolean", - "Number", - "Vec2", - "Vec3", - "Vec4", - "String", - "No Animation", - "Oversized", - ], - }, - { - type: "multiple", - key: "miscellaneous", - title: "Miscellaneous", - values: [ - "Approved", - "Audio responsive", - "Customizable", - "Puppet Warp", - "HDR", - "Video Texture", - "Asset Pack", - "Media Integration", - "3D", - ], - }, - ]} - onWallpaperDownload={async (wallpaper) => { - const apiKey = await client.settings.get.query({ - key: "apiKeys.wallpaperEngine", - decrypt: true, - }); - - await client.api.wallpaperEngine.subscribe.mutate({ - apiKey, - id: wallpaper.id, - }); - }} - /> + { + type: "multiple", + key: "genres", + title: "Genres", + values: [ + "Abstract", + "Animal", + "Anime", + "Cartoon", + "CGI", + "Cyberpunk", + "Fantasy", + "Game", + "Girls", + "Guys", + "Landscape", + "Medieval", + "Memes", + "MMD", + "Music", + "Nature", + "Pixel art", + "Relaxing", + "Retro", + "Sci-Fi", + "Sports", + "Technology", + "Television", + "Vehicle", + "Unspecified", + ], + }, + { + type: "multiple", + key: "resolutions", + title: "Resolutions", + values: [ + "Standard Definition", + "1280 x 720", + "1366 x 768", + "1920 x 1080", + "2560 x 1440", + "3840 x 2160", + "Ultrawide Standard Definition", + "Ultrawide 2560 x 1080", + "Ultrawide 3440 x 1440", + "Dual Standard Definition", + "Dual 3840 x 1080", + "Dual 5120 x 1440", + "Dual 3840 x 2160", + "Triple Standard Definition", + "Triple 4096 x 768", + "Triple 5760 x 1080", + "Triple 7680 x 1440", + "Triple 11520 x 2160", + "Portrait Standard Definition", + "Portrait 720 x 1280", + "Portrait 1080 x 1920", + "Portrait 1440 x 2560", + "Portrait 2160 x 3840", + "Other resolution", + "Dynamic resolution", + ], + }, + { + type: "multiple", + key: "categories", + title: "Categories", + values: ["Wallpaper", "Preset", "Asset"], + }, + { + type: "multiple", + key: "assetTypes", + title: "Asset Types", + values: [ + "Particle", + "Image", + "Sound", + "Model", + "Text", + "Sprite", + "Fullscreen", + "Composite", + "Script", + "Effect", + ], + }, + { + type: "multiple", + key: "assetGenres", + title: "Asset Genres", + values: [ + "Audio Visualizer", + "Background", + "Character", + "Clock", + "Fire", + "Interactive", + "Magic", + "Post Processing", + "Smoke", + "Space", + ], + }, + { + type: "multiple", + key: "scriptTypes", + title: "Script Types", + values: [ + "Boolean", + "Number", + "Vec2", + "Vec3", + "Vec4", + "String", + "No Animation", + "Oversized", + ], + }, + { + type: "multiple", + key: "miscellaneous", + title: "Miscellaneous", + values: [ + "Approved", + "Audio responsive", + "Customizable", + "Puppet Warp", + "HDR", + "Video Texture", + "Asset Pack", + "Media Integration", + "3D", + ], + }, + ]} + onWallpaperApply={async (wallpaper, monitors) => { + setSelectedWallpaper(wallpaper as WallpaperEngineWallpaper); + setSelectedMonitors(monitors); + setShowSteamDialog(true); + }} + /> +
); }; diff --git a/src/renderer/pages/settings/components.tsx b/src/renderer/pages/settings/components.tsx index 9611a91..4a617d7 100644 --- a/src/renderer/pages/settings/components.tsx +++ b/src/renderer/pages/settings/components.tsx @@ -48,9 +48,10 @@ function useSettings({ data: value, isPending, isError, - } = useQuery({ + } = useQuery({ queryKey: [settingKey], - queryFn: async () => await client.settings.get.query({ key: settingKey, decrypt: encrypted }), + queryFn: async () => + (await client.settings.get.query({ key: settingKey, decrypt: encrypted })) as T, }); React.useEffect(() => { diff --git a/src/renderer/pages/settings/index.tsx b/src/renderer/pages/settings/index.tsx index 375f1d5..97664e9 100644 --- a/src/renderer/pages/settings/index.tsx +++ b/src/renderer/pages/settings/index.tsx @@ -67,6 +67,12 @@ const SETTINGS_CONFIG: SettingsSection[] = [ description: "Restore the last wallpaper on startup", type: "boolean", }, + { + settingKey: "app.killWallpaperOnExit", + title: "Kill Wallpaper on Exit", + description: "Terminate wallpaper processes when exiting the app", + type: "boolean", + }, ], }, {