├── assets ├── icon.png ├── screenshot.png └── icon.svg ├── .gitmodules ├── .gitignore ├── types ├── ui │ ├── index.d.ts │ ├── panelMenu.d.ts │ ├── main.d.ts │ └── popupMenu.d.ts ├── mappings │ └── index.d.ts └── misc │ └── index.d.ts ├── package.json ├── src ├── metadata.json ├── ui │ ├── localFolder.blp │ ├── pageSources.blp │ ├── sourceRow.blp │ ├── unsplash.blp │ ├── sourceConfigModal.blp │ ├── urlSource.blp │ ├── reddit.blp │ ├── reddit.ts │ ├── urlSource.ts │ ├── localFolder.ts │ ├── genericJson.blp │ ├── wallhaven.blp │ ├── genericJson.ts │ ├── unsplash.ts │ ├── pageGeneral.blp │ ├── wallhaven.ts │ ├── sourceRow.ts │ └── sourceConfigModal.ts ├── stylesheet.css ├── notifications.ts ├── extension.ts ├── adapter │ ├── urlSource.ts │ ├── baseAdapter.ts │ ├── localFolder.ts │ ├── reddit.ts │ ├── unsplash.ts │ ├── genericJson.ts │ └── wallhaven.ts ├── soupBowl.ts ├── manager │ ├── superPaper.ts │ ├── defaultWallpaperManager.ts │ ├── wallpaperManager.ts │ ├── externalWallpaperManager.ts │ └── hydraPaper.ts ├── logger.ts ├── jsonPath.ts ├── timer.ts └── history.ts ├── install.sh ├── debug.sh ├── container ├── runx11docker.sh ├── README.md └── Dockerfile ├── LICENSE ├── tsconfig.json ├── .github └── workflows │ └── build.yml ├── README.md └── .eslintrc.yml /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DarkGhostHunter/RandomWallpaperGnome3/develop/assets/icon.png -------------------------------------------------------------------------------- /assets/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DarkGhostHunter/RandomWallpaperGnome3/develop/assets/screenshot.png -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "container/x11docker"] 2 | path = container/x11docker 3 | url = https://github.com/mviereck/x11docker.git 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | 3 | # Temporary ui files 4 | **/*~ 5 | 6 | # Generated stuff 7 | randomwallpaper@iflow.space/ 8 | randomwallpaper@iflow.space.shell-extension.zip 9 | 10 | # Build stuff 11 | node_modules/ 12 | -------------------------------------------------------------------------------- /types/ui/index.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | // doing similar to https://github.com/gi-ts/environment 3 | 4 | /// 5 | /// 6 | /// 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "@girs/adw-1": "^1.4.0-3.2.6", 4 | "@girs/gjs": "^3.2.6", 5 | "@girs/gtk-4.0": "^4.12.3-3.2.6", 6 | "@girs/shell-13": "^13.0.0-3.2.6", 7 | "@girs/soup-3.0": "^3.4.4-3.2.6", 8 | "@typescript-eslint/eslint-plugin": "^6.3.0", 9 | "@typescript-eslint/parser": "^6.3.0", 10 | "eslint": "^8.47.0", 11 | "eslint-plugin-jsdoc": "^46.4.6", 12 | "typescript": "^5.1.6" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /types/ui/panelMenu.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | declare module 'resource:///org/gnome/shell/ui/panelMenu.js' { 4 | import St from 'gi://St'; 5 | 6 | import {PopupMenu} from 'resource:///org/gnome/shell/ui/popupMenu.js'; 7 | 8 | export class ButtonBox extends St.Widget{} 9 | 10 | export class Button extends ButtonBox { 11 | menu: PopupMenu; 12 | 13 | constructor(menuAlignment: number, nameText: string, dontCreateMenu?: boolean); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "shell-version": [ "45", "46" ], 3 | "uuid": "randomwallpaper@iflow.space", 4 | "settings-schema": "org.gnome.shell.extensions.space.iflow.randomwallpaper", 5 | "name": "Random Wallpaper", 6 | "description": "Load new desktop wallpapers from various online sources with ease!", 7 | "version": 35, 8 | "semantic-version": "3.0.2", 9 | "url": "https://github.com/ifl0w/RandomWallpaperGnome3", 10 | "issue-url": "https://github.com/ifl0w/RandomWallpaperGnome3/issues" 11 | } 12 | -------------------------------------------------------------------------------- /src/ui/localFolder.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $LocalFolderSettings: Adw.PreferencesPage { 5 | Adw.PreferencesGroup { 6 | title: _("General"); 7 | 8 | Adw.EntryRow folder_row { 9 | title: _("Folder"); 10 | 11 | Button folder { 12 | valign: center; 13 | 14 | Adw.ButtonContent { 15 | icon-name: "folder-open-symbolic"; 16 | } 17 | } 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | datahome="${XDG_DATA_HOME:-$HOME/.local/share}" 4 | 5 | extensionFolder="randomwallpaper@iflow.space" 6 | sourcepath="$PWD/$extensionFolder" 7 | targetpath="$datahome/gnome-shell/extensions" 8 | 9 | if [ "$1" = "uninstall" ]; then 10 | echo "# Removing $targetpath/$extensionFolder" 11 | rm "$targetpath/$extensionFolder" 12 | else 13 | echo "# Making extension directory" 14 | mkdir -p "$targetpath" 15 | echo "# Linking extension folder" 16 | ln -s "$sourcepath" "$targetpath" 17 | fi 18 | -------------------------------------------------------------------------------- /debug.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [ "$1" = "prefs" ]; then 4 | journalctl -f /usr/bin/gjs 5 | elif [ "$1" = "filtered" ]; then 6 | # Note: filtering journal via the extensions UUID was removed in 45. 7 | # Falling back to grep log filtering. This is only necessary when other 8 | # extensions produce to much output in the gnome-shell log. 9 | # https://gjs.guide/extensions/upgrading/gnome-shell-45.html#logging 10 | journalctl -f /usr/bin/gnome-shell | grep "RandomWallpaper" 11 | else 12 | journalctl -f /usr/bin/gnome-shell 13 | fi 14 | -------------------------------------------------------------------------------- /src/stylesheet.css: -------------------------------------------------------------------------------- 1 | .rwg-new-label { 2 | font-size: 130%; 3 | } 4 | 5 | .rwg-history-index { 6 | text-align: left; 7 | padding-top: .4em; 8 | margin-right: .2em; 9 | font-size: 120%; 10 | } 11 | 12 | .rwg-history-date { 13 | font-size: 100%; 14 | } 15 | 16 | .rwg-history-time { 17 | font-size: 80%; 18 | } 19 | 20 | .rwg-preview-image { 21 | border-radius: 0.3em; 22 | padding-top: 0.3em; 23 | padding-bottom: 0.3em; 24 | background-color: rgba(0,0,0,0.2); 25 | } 26 | 27 | .rwg-submenu-separator { 28 | background-color: rgba(255,255,255,0.1); 29 | } 30 | -------------------------------------------------------------------------------- /types/ui/main.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | declare module 'resource:///org/gnome/shell/ui/main.js' { 4 | import St from 'gi://St'; 5 | 6 | import type * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js'; 7 | 8 | // lazy inline declaration to avoid import chain 9 | declare class Panel extends St.Widget { 10 | addToStatusArea(role: string, indicator: PanelMenu.Button, position?: number, box?: unknown): PanelMenu.Button; 11 | } 12 | 13 | export function notify(title: string, message: string): void; 14 | 15 | export let panel: Panel; 16 | } 17 | -------------------------------------------------------------------------------- /types/mappings/index.d.ts: -------------------------------------------------------------------------------- 1 | // These mappings from '@girs/*' to 'gi://*' are somehow missing but exist in the real gnome environment 2 | declare module 'gi://Clutter' { 3 | export * from '@girs/clutter-13'; 4 | } 5 | 6 | declare module 'gi://Cogl' { 7 | export * from '@girs/cogl-13'; 8 | } 9 | 10 | declare module 'gi://Meta' { 11 | export * from '@girs/Meta'; 12 | } 13 | 14 | declare module 'gi://St' { 15 | export * from '@girs/st-13'; 16 | } 17 | 18 | declare module 'gi://Soup' { 19 | export * from '@girs/soup-3.0'; 20 | } 21 | 22 | declare module 'gi://GLib' { 23 | export * from '@girs/glib-2.0'; 24 | } 25 | 26 | declare module 'gi://Adw' { 27 | export * from '@girs/adw-1'; 28 | } 29 | -------------------------------------------------------------------------------- /container/runx11docker.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | SCRIPT_DIR=$(dirname $(readlink -f $0)) 4 | SRC_DIR=$SCRIPT_DIR/../randomwallpaper@iflow.space 5 | DST_DIR=/home/dev/.local/share/gnome-shell/extensions/randomwallpaper@iflow.space 6 | 7 | if [ -z "$1" ] 8 | then 9 | echo "$(basename $0): Provide your docker image as an argument" 10 | exit 22 11 | fi 12 | 13 | echo "$(basename $0): You might have to move the X window around before GNOME is fully loaded" 14 | sleep 3 15 | 16 | $SCRIPT_DIR/x11docker/x11docker \ 17 | --desktop \ 18 | --network \ 19 | --init=systemd \ 20 | --user=RETAIN \ 21 | --runasuser="gnome-extensions enable randomwallpaper@iflow.space; journalctl -f &" \ 22 | -- --mount type=bind,source=$SRC_DIR,target=$DST_DIR,readonly -- \ 23 | $1 24 | -------------------------------------------------------------------------------- /src/ui/pageSources.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | Adw.PreferencesPage page_sources { 5 | title: _("Wallpaper Sources"); 6 | icon-name: "preferences-desktop-wallpaper-symbolic"; 7 | 8 | Adw.PreferencesGroup sources_list { 9 | title: _("Wallpaper Sources"); 10 | description: _("Wallpapers are loaded from the configured sources"); 11 | header-suffix: Button button_new_source { 12 | styles [ 13 | "suggested-action", 14 | ] 15 | 16 | Adw.ButtonContent { 17 | icon-name: "list-add-symbolic"; 18 | label: _("Add Source"); 19 | } 20 | }; 21 | 22 | Adw.ActionRow placeholder_no_source { 23 | title: _("No Sources Configured!"); 24 | subtitle: _("Random images will be requested from \"unsplash.com\" until a source is added and enabled!"); 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Wolfgang Rumpler 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/notifications.ts: -------------------------------------------------------------------------------- 1 | import {notify} from 'resource:///org/gnome/shell/ui/main.js'; 2 | 3 | import {HistoryEntry} from './history.js'; 4 | 5 | /** 6 | * A convenience class for presenting notifications to the user. 7 | */ 8 | class Notification { 9 | /** 10 | * Show a notification for the newly set wallpapers. 11 | * 12 | * @param {HistoryEntry[]} historyEntries The history elements representing the new wallpapers 13 | */ 14 | static newWallpaper(historyEntries: HistoryEntry[]): void { 15 | const infoString = `Source: ${historyEntries.map(h => `${h.source.source ?? 'Unknown Source'}`).join(', ')}`; 16 | const message = `A new wallpaper was set!\n${infoString}`; 17 | notify('New Wallpaper', message); 18 | } 19 | 20 | /** 21 | * Show an error notification for failed wallpaper downloads. 22 | * 23 | * @param {unknown} error The error that was thrown when fetching a new wallpaper 24 | */ 25 | static fetchWallpaperFailed(error: unknown): void { 26 | let errorMessage = String(error); 27 | 28 | if (error instanceof Error) 29 | errorMessage = error.message; 30 | 31 | notify('RandomWallpaperGnome3: Wallpaper Download Failed!', errorMessage); 32 | } 33 | } 34 | 35 | export {Notification}; 36 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "experimentalDecorators": true, 4 | "target": "ES2020", 5 | "module": "es2020", 6 | "sourceMap": false, 7 | "strict": true, 8 | "pretty": true, 9 | "removeComments": false, 10 | "baseUrl": "./src", 11 | "allowSyntheticDefaultImports": true, 12 | "outDir": "./randomwallpaper@iflow.space", 13 | "moduleResolution": "node", 14 | "skipLibCheck": true, 15 | "lib": [ 16 | "ES2021", 17 | "DOM", // FIXME: This is here for TextDecoder which should be defined by global GJS 18 | // https://gjs-docs.gnome.org/gjs/encoding.md 19 | // > The functions in this module are available globally, without import. 20 | ], 21 | "typeRoots": [ 22 | "./node_modules/@gi-types", 23 | "./types", 24 | ], 25 | "types": [ 26 | "mappings", // mappings 'from @girs' to 'gi://' 27 | "ui", // shell import types 28 | "misc", // extension import types 29 | ], 30 | 31 | }, 32 | "include": [ 33 | "src/**/*.ts", 34 | "randomwallpaper@iflow.space/**/*.js" 35 | ], 36 | "exclude": [ 37 | "./node_modules/**", 38 | "./types/**" 39 | ], 40 | } 41 | -------------------------------------------------------------------------------- /src/ui/sourceRow.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $SourceRow : Adw.ExpanderRow { 5 | title: _("Name"); 6 | subtitle: _("Type"); 7 | 8 | [prefix] 9 | Gtk.Switch switch_enable { 10 | valign: center; 11 | } 12 | 13 | [suffix] 14 | Adw.ActionRow { 15 | Button button_remove { 16 | valign: center; 17 | halign: start; 18 | 19 | styles [ 20 | "destructive-action", 21 | ] 22 | 23 | Adw.ButtonContent { 24 | icon-name: "user-trash-symbolic"; 25 | valign: center; 26 | label: _("Remove"); 27 | } 28 | } 29 | 30 | Button button_edit { 31 | valign: center; 32 | halign: end; 33 | 34 | Adw.ButtonContent { 35 | icon-name: "document-edit-symbolic"; 36 | valign: center; 37 | label: _("Edit"); 38 | } 39 | } 40 | } 41 | 42 | Adw.PreferencesGroup blocked_images_list { 43 | title: _("Blocked Images"); 44 | margin-top: 10; 45 | margin-bottom: 10; 46 | margin-start: 10; 47 | margin-end: 10; 48 | 49 | Adw.ActionRow placeholder_no_blocked { 50 | title: _("No Blocked Images"); 51 | sensitive: false; 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/ui/unsplash.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $UnsplashSettings: Adw.PreferencesPage { 5 | Adw.PreferencesGroup { 6 | title: _("General"); 7 | 8 | Adw.EntryRow keyword { 9 | title: _("Keywords - Comma Separated"); 10 | input-purpose: free_form; 11 | } 12 | 13 | Adw.ActionRow { 14 | title: _("Only Featured Images"); 15 | subtitle: _("This option results in a smaller image pool, but the images are considered to be of higher quality."); 16 | 17 | Switch featured_only { 18 | valign: center; 19 | } 20 | } 21 | 22 | Adw.ActionRow { 23 | title: _("Image Dimensions"); 24 | 25 | SpinButton { 26 | valign: center; 27 | numeric: true; 28 | 29 | adjustment: Adjustment image_width { 30 | step-increment: 1; 31 | page-increment: 10; 32 | lower: 1; 33 | upper: 1000000; 34 | }; 35 | } 36 | 37 | Label { 38 | label: "x"; 39 | } 40 | 41 | SpinButton { 42 | valign: center; 43 | numeric: true; 44 | 45 | adjustment: Adjustment image_height { 46 | step-increment: 1; 47 | page-increment: 10; 48 | lower: 1; 49 | upper: 1000000; 50 | }; 51 | } 52 | } 53 | } 54 | 55 | Adw.PreferencesGroup { 56 | title: _("Constraint"); 57 | 58 | Adw.ComboRow constraint_type { 59 | title: _("Type"); 60 | } 61 | 62 | Adw.EntryRow constraint_value { 63 | title: _("Value"); 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/ui/sourceConfigModal.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $SourceConfigModal: Adw.Window { 5 | content: Adw.ToolbarView { 6 | top-bar-style: raised; 7 | bottom-bar-style: raised; 8 | 9 | [top] 10 | Box { 11 | orientation: vertical; 12 | valign: fill; 13 | 14 | styles [ 15 | "toolbar", 16 | ] 17 | 18 | Adw.HeaderBar { } 19 | 20 | Box { 21 | orientation: horizontal; 22 | spacing: 5; 23 | valign: fill; 24 | 25 | Gtk.DropDown combo { } 26 | 27 | Adw.EntryRow source_name { 28 | title: _("Name"); 29 | hexpand: true; 30 | input-purpose: free_form; 31 | text: _("My Source"); 32 | } 33 | } 34 | } 35 | 36 | content: ScrolledWindow settings_container {}; 37 | 38 | [bottom] 39 | ActionBar { 40 | hexpand: true; 41 | halign: fill; 42 | 43 | [start] 44 | Button button_cancel { 45 | halign: start; 46 | label: _("Cancel"); 47 | } 48 | 49 | [end] 50 | Button button_add { 51 | halign: end; 52 | 53 | styles [ 54 | "suggested-action", 55 | ] 56 | 57 | Adw.ButtonContent { 58 | icon-name: "list-add-symbolic"; 59 | label: _("Add Source"); 60 | } 61 | } 62 | 63 | [end] 64 | Button button_close { 65 | halign: end; 66 | 67 | styles [ 68 | "suggested-action", 69 | ] 70 | 71 | Adw.ButtonContent { 72 | icon-name: "emblem-ok-symbolic"; 73 | label: _("Close"); 74 | } 75 | } 76 | } 77 | }; 78 | } 79 | 80 | -------------------------------------------------------------------------------- /src/ui/urlSource.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $UrlSourceSettings: Adw.PreferencesPage { 5 | Adw.PreferencesGroup { 6 | title: _("General"); 7 | 8 | Adw.EntryRow domain { 9 | title: _("Domain"); 10 | input-purpose: url; 11 | 12 | LinkButton { 13 | valign: center; 14 | uri: bind domain.text; 15 | 16 | Adw.ButtonContent { 17 | icon-name: "globe-symbolic"; 18 | } 19 | 20 | styles [ 21 | "flat", 22 | ] 23 | } 24 | } 25 | 26 | Adw.EntryRow image_url { 27 | title: _("Image URL"); 28 | 29 | LinkButton { 30 | valign: center; 31 | uri: bind image_url.text; 32 | 33 | Adw.ButtonContent { 34 | icon-name: "globe-symbolic"; 35 | } 36 | 37 | styles [ 38 | "flat", 39 | ] 40 | } 41 | } 42 | 43 | Adw.EntryRow post_url { 44 | title: _("Post URL"); 45 | input-purpose: free_form; 46 | 47 | LinkButton { 48 | valign: center; 49 | uri: bind post_url.text; 50 | 51 | Adw.ButtonContent { 52 | icon-name: "globe-symbolic"; 53 | } 54 | 55 | styles [ 56 | "flat", 57 | ] 58 | } 59 | } 60 | 61 | Adw.ActionRow { 62 | title: _("Yields Different Images"); 63 | subtitle: _("...on consecutive request in a short amount of time."); 64 | 65 | Switch different_images { 66 | valign: center; 67 | } 68 | } 69 | } 70 | 71 | Adw.PreferencesGroup { 72 | title: _("Author"); 73 | 74 | Adw.EntryRow author_name { 75 | title: _("Name"); 76 | input-purpose: free_form; 77 | } 78 | 79 | Adw.EntryRow author_url { 80 | title: _("URL"); 81 | input-purpose: free_form; 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/ui/reddit.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $RedditSettings: Adw.PreferencesPage { 5 | Adw.PreferencesGroup { 6 | title: _("General"); 7 | 8 | Adw.EntryRow subreddits { 9 | title: _("Subreddits - e.g.: wallpaper, wallpapers, minimalwallpaper"); 10 | } 11 | 12 | Adw.ActionRow { 13 | title: _("Minimal Resolution"); 14 | 15 | SpinButton { 16 | valign: center; 17 | numeric: true; 18 | 19 | adjustment: Adjustment min_width { 20 | step-increment: 1; 21 | page-increment: 10; 22 | lower: 1; 23 | upper: 1000000; 24 | }; 25 | } 26 | 27 | Label { 28 | label: "x"; 29 | } 30 | 31 | SpinButton { 32 | valign: center; 33 | numeric: true; 34 | 35 | adjustment: Adjustment min_height { 36 | step-increment: 1; 37 | page-increment: 10; 38 | lower: 1; 39 | upper: 1000000; 40 | }; 41 | } 42 | } 43 | 44 | Adw.ActionRow { 45 | title: _("Minimal Image Ratio"); 46 | 47 | SpinButton { 48 | valign: center; 49 | numeric: true; 50 | 51 | adjustment: Adjustment image_ratio1 { 52 | step-increment: 1; 53 | page-increment: 10; 54 | lower: 1; 55 | upper: 1000000; 56 | }; 57 | } 58 | 59 | Label { 60 | label: ":"; 61 | } 62 | 63 | SpinButton { 64 | valign: center; 65 | numeric: true; 66 | 67 | adjustment: Adjustment image_ratio2 { 68 | step-increment: 1; 69 | page-increment: 10; 70 | lower: 1; 71 | upper: 1000000; 72 | }; 73 | } 74 | } 75 | 76 | Adw.ActionRow { 77 | title: "SFW"; 78 | subtitle: _("Safe for work"); 79 | 80 | Switch allow_sfw { 81 | valign: center; 82 | } 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /container/README.md: -------------------------------------------------------------------------------- 1 | Development Containers 2 | ===== 3 | The files in this directory are used to easily test the extension for other GNOME versions without the need for multiple virtual machines or systems. We use [x11docker](https://github.com/mviereck/x11docker) for this which is a bash script which does some preprocessing before finally starting a docker container. Each Docker image is only around 700-900 MB in size depending on the version. 4 | 5 | ## Getting x11docker 6 | First get x11docker which is included as a submodule in this repository: 7 | ```shell 8 | git submodule init 9 | git submodule update --depth=1 10 | ``` 11 | 12 | ## Building desired Docker images 13 | Currently we use Ubuntu in the images to determine the GNOME version. This version must be provided as a build argument when building the Docker image: 14 | ```shell 15 | docker build -t gnome38 --build-arg VERSION=21.04 . 16 | ``` 17 | 18 | The following Ubuntu versions are known to be working: 19 | 20 | Ubuntu | GNOME 21 | ------ | ----- 22 | 20.04 (LTS) | 3.36.9 23 | 21.04 | 3.38.4 24 | 21.10 | 40.5 25 | 26 | The images are very minimal to keep them small. Only the necessary GNOME components are included. 27 | 28 | ## Running x11docker 29 | > Some version of Docker have trouble running Ubuntu 21.10. In this case you might have to add `--security-opt seccomp=unconfined` to the `runx11docker.sh` script after the `--mount` parameter. As this reduces the security of the container, this is not added by default. 30 | 31 | The script `runx11docker.sh` is provided to start x11docker with the correct parameters. It automatically mounts the extension directory from this repository in the GNOME container. You need to supply the name of your recently build image as argument: 32 | ```shell 33 | ./runx11docker.sh gnome38 34 | ``` 35 | 36 | **For unknown reasons you have to move the X window around until the container is fully loaded for now.** 37 | 38 | The application ARandR is included which can use the change the resolution. You can enable the extension and access the settings window via the Extensions application. 39 | 40 | ## Testing changes 41 | You can keep the container running while making changes. Once you've made some changes to the code, be sure to run `build.sh` from the parent directory so `gschemas.compiled` is recreated. 42 | 43 | Inside the container you have to restart GNOME. Press CTRL + SHIFT to lock your mouse and keyboard in the X windows so you can use key modifiers. Then restart GNOME by pressing ALT + F2 and running the command `r`. You can use CTRL + SHIFT to release your keyboard and mouse again. 44 | 45 | Any debug messages will be displayed in your console, so there is no need to run `debug.sh`. 46 | -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js'; 2 | 3 | import * as AFTimer from './timer.js'; 4 | import * as WallpaperController from './wallpaperController.js'; 5 | import * as RandomWallpaperMenu from './randomWallpaperMenu.js'; 6 | 7 | import {Logger} from './logger.js'; 8 | import {Settings} from './settings.js'; 9 | 10 | /** 11 | * Own extension class object. Entry point for Gnome Shell hooks. 12 | * 13 | * The functions enable() and disable() are required. 14 | */ 15 | class RandomWallpaperExtension extends Extension { 16 | private _wallpaperController: WallpaperController.WallpaperController | null = null; 17 | private _panelMenu: RandomWallpaperMenu.RandomWallpaperMenu | null = null; 18 | private _timer: AFTimer.AFTimer | null = null; 19 | 20 | /** 21 | * This function is called when your extension is enabled, which could be 22 | * done in GNOME Extensions, when you log in or when the screen is unlocked. 23 | * 24 | * This is when you should setup any UI for your extension, change existing 25 | * widgets, connect signals or modify GNOME Shell's behavior. 26 | */ 27 | enable(): void { 28 | // Set statics for the current extension context (shell background) 29 | Settings.extensionContext = Extension; 30 | Logger.SETTINGS = new Settings(); 31 | 32 | this._timer = AFTimer.AFTimer.getTimer(); 33 | this._wallpaperController = new WallpaperController.WallpaperController(); 34 | this._panelMenu = new RandomWallpaperMenu.RandomWallpaperMenu(this._wallpaperController); 35 | 36 | Logger.info('Enable extension.', this); 37 | this._panelMenu.init(); 38 | } 39 | 40 | /** 41 | * This function is called when your extension is uninstalled, disabled in 42 | * GNOME Extensions, when you log out or when the screen locks. 43 | * 44 | * Anything you created, modified or setup in enable() MUST be undone here. 45 | * Not doing so is the most common reason extensions are rejected in review! 46 | */ 47 | disable(): void { 48 | Logger.info('Disable extension.'); 49 | 50 | if (this._panelMenu) 51 | this._panelMenu.cleanup(); 52 | 53 | // cleanup the timer singleton 54 | if (this._timer) 55 | AFTimer.AFTimer.destroy(); 56 | 57 | if (this._wallpaperController) 58 | this._wallpaperController.cleanup(); 59 | 60 | this._timer = null; 61 | this._panelMenu = null; 62 | this._wallpaperController = null; 63 | 64 | // Destruction of log helper is the last step 65 | Logger.destroy(); 66 | Settings.extensionContext = undefined; 67 | } 68 | } 69 | 70 | export {RandomWallpaperExtension as default}; 71 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build and create ZIP 2 | run-name: Generate javascript, ui and schema to publish 3 | on: [push, pull_request] 4 | jobs: 5 | check: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - name: Install dependencies 9 | run: | 10 | sudo apt -q update 11 | sudo apt -q install --no-install-recommends npm 12 | - name: Check out repository code 13 | uses: actions/checkout@v3 14 | - name: Setup environment 15 | run: | 16 | ${{github.workspace}}/build.sh setup_env 17 | - name: Check TypeScript 18 | run: | 19 | ${{github.workspace}}/build.sh check 20 | build: 21 | runs-on: ubuntu-latest 22 | steps: 23 | - name: Install dependencies 24 | run: | 25 | sudo apt -q update 26 | sudo apt -q install --no-install-recommends npm libglib2.0-0 bash gnome-shell libgtk-4-bin libgtk-4-common libgtk-4-dev libadwaita-1-dev gir1.2-adw-1 gir1.2-gtk-4.0 zstd 27 | # TODO: Remove the next step once "Jammy" (22.04) receives version 0.8+ and add to the install list above 28 | # https://launchpad.net/ubuntu/+source/blueprint-compiler 29 | - name: Build recent blueprint-compiler 30 | run: | 31 | sudo apt -q install --no-install-recommends meson ninja-build 32 | git clone https://gitlab.gnome.org/jwestman/blueprint-compiler.git 33 | cd blueprint-compiler 34 | meson _build 35 | sudo ninja -C _build install 36 | - name: Check out repository code 37 | uses: actions/checkout@v3 38 | # TODO: Remove the next step once "Jammy" (22.04) receives version 1.2+ 39 | # https://launchpad.net/ubuntu/+source/libadwaita-1 40 | - name: Update to recent libadwaita 41 | run: | 42 | mkdir libadwaita 43 | cd libadwaita || exit 1 44 | wget https://launchpad.net/ubuntu/+source/libadwaita-1/1.4.0-1ubuntu1/+build/26712528/+files/gir1.2-adw-1_1.4.0-1ubuntu1_amd64.deb 45 | wget https://launchpad.net/ubuntu/+source/libadwaita-1/1.4.0-1ubuntu1/+build/26712528/+files/libadwaita-1-0_1.4.0-1ubuntu1_amd64.deb 46 | wget https://launchpad.net/ubuntu/+source/libadwaita-1/1.4.0-1ubuntu1/+build/26712528/+files/libadwaita-1-dev_1.4.0-1ubuntu1_amd64.deb 47 | sudo dpkg --recursive --install --force-depends-version --force-depends . 48 | - run: ${{github.workspace}}/build.sh setup_env 49 | - run: ${{github.workspace}}/build.sh build 50 | - run: ${{github.workspace}}/build.sh format 51 | - run: ${{github.workspace}}/build.sh copy_static 52 | - uses: actions/upload-artifact@v3 53 | with: 54 | name: randomwallpaper@iflow.space.shell-extension.zip 55 | path: ${{github.workspace}}/randomwallpaper@iflow.space 56 | -------------------------------------------------------------------------------- /types/ui/popupMenu.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | declare module 'resource:///org/gnome/shell/ui/popupMenu.js' { 4 | // https://gitlab.gnome.org/GNOME/gnome-shell/-/blob/main/js/ui/popupMenu.js 5 | 6 | import Clutter from 'gi://Clutter'; 7 | import St from 'gi://St'; 8 | 9 | // https://gitlab.gnome.org/GNOME/gnome-shell/-/blob/main/js/misc/signals.js 10 | export class EventEmitter { 11 | connectObject(args: unknown): unknown; 12 | connect_object(args: unknown): unknown; 13 | disconnect_object(args: unknown): unknown; 14 | disconnectObject(args: unknown): unknown; 15 | 16 | // don't know where these are: 17 | connect(key: string, callback: (actor: typeof this, ...args: unknown[]) => unknown): void; 18 | } 19 | 20 | export class PopupMenuBase extends EventEmitter { 21 | actor: Clutter.Actor; 22 | box: St.BoxLayout; 23 | 24 | addMenuItem(menuItem: PopupMenuSection | PopupSubMenuMenuItem | PopupSeparatorMenuItem | PopupBaseMenuItem, position?: number): void; 25 | removeAll(): void; 26 | } 27 | 28 | export class PopupBaseMenuItem extends St.BoxLayout { 29 | actor: typeof this; 30 | // get actor(): typeof this; 31 | get sensitive(): boolean; 32 | set sensitive(sensitive: boolean); 33 | } 34 | 35 | export class PopupMenu extends PopupMenuBase { 36 | constructor(sourceActor: Clutter.Actor, arrowAlignment: unknown, arrowSide: unknown) 37 | } 38 | 39 | export class PopupMenuItem extends PopupBaseMenuItem { 40 | constructor(text: string, params?: unknown) 41 | label: St.Label; 42 | } 43 | 44 | export class PopupSubMenuMenuItem extends PopupBaseMenuItem { 45 | constructor(text: string, wantIcon: boolean) 46 | 47 | label: St.Label; 48 | menu: PopupSubMenu; 49 | } 50 | 51 | export class PopupSubMenu extends PopupMenuBase { 52 | actor: St.ScrollView; 53 | } 54 | 55 | export class PopupMenuSection extends PopupMenuBase { 56 | actor: St.BoxLayout | Clutter.Actor; 57 | } 58 | 59 | export class PopupSeparatorMenuItem extends PopupBaseMenuItem {} 60 | export class Switch extends St.Bin {} 61 | export class PopupSwitchMenuItem extends PopupBaseMenuItem { 62 | constructor(text: string, active: boolean, params?: { 63 | reactive: boolean | undefined, 64 | activate: boolean | undefined, 65 | hover: boolean | undefined, 66 | style_class: unknown | null | undefined, 67 | can_focus: boolean | undefined 68 | }) 69 | 70 | setToggleState(state: boolean): void; 71 | } 72 | export class PopupImageMenuItem extends PopupBaseMenuItem {} 73 | export class PopupDummyMenu extends EventEmitter {} 74 | export class PopupMenuManager {} 75 | } 76 | -------------------------------------------------------------------------------- /src/ui/reddit.ts: -------------------------------------------------------------------------------- 1 | import Adw from 'gi://Adw'; 2 | import Gio from 'gi://Gio'; 3 | import GLib from 'gi://GLib'; 4 | import GObject from 'gi://GObject'; 5 | import Gtk from 'gi://Gtk'; 6 | 7 | import * as Settings from './../settings.js'; 8 | 9 | // FIXME: Generated static class code produces a no-unused-expressions rule error 10 | /* eslint-disable no-unused-expressions */ 11 | 12 | /** 13 | * Subclass containing the preferences for Reddit adapter 14 | */ 15 | class RedditSettings extends Adw.PreferencesPage { 16 | static [GObject.GTypeName] = 'RedditSettings'; 17 | // @ts-expect-error Gtk.template is not in the type definitions files yet 18 | static [Gtk.template] = GLib.uri_resolve_relative(import.meta.url, './reddit.ui', GLib.UriFlags.NONE); 19 | // @ts-expect-error Gtk.internalChildren is not in the type definitions files yet 20 | static [Gtk.internalChildren] = [ 21 | 'allow_sfw', 22 | 'image_ratio1', 23 | 'image_ratio2', 24 | 'min_height', 25 | 'min_width', 26 | 'subreddits', 27 | ]; 28 | 29 | static { 30 | GObject.registerClass(this); 31 | } 32 | 33 | // InternalChildren 34 | private _allow_sfw!: Gtk.Switch; 35 | private _image_ratio1!: Gtk.Adjustment; 36 | private _image_ratio2!: Gtk.Adjustment; 37 | private _min_height!: Gtk.Adjustment; 38 | private _min_width!: Gtk.Adjustment; 39 | private _subreddits!: Adw.EntryRow; 40 | 41 | private _settings; 42 | 43 | /** 44 | * Craft a new adapter using an unique ID. 45 | * 46 | * Previously saved settings will be used if the adapter and ID match. 47 | * 48 | * @param {string} id Unique ID 49 | */ 50 | constructor(id: string) { 51 | super(undefined); 52 | 53 | const path = `${Settings.RWG_SETTINGS_SCHEMA_PATH}/sources/reddit/${id}/`; 54 | this._settings = new Settings.Settings(Settings.RWG_SETTINGS_SCHEMA_SOURCES_REDDIT, path); 55 | 56 | this._settings.bind('allow-sfw', 57 | this._allow_sfw, 58 | 'active', 59 | Gio.SettingsBindFlags.DEFAULT); 60 | this._settings.bind('image-ratio1', 61 | this._image_ratio1, 62 | 'value', 63 | Gio.SettingsBindFlags.DEFAULT); 64 | this._settings.bind('image-ratio2', 65 | this._image_ratio2, 66 | 'value', 67 | Gio.SettingsBindFlags.DEFAULT); 68 | this._settings.bind('min-height', 69 | this._min_height, 70 | 'value', 71 | Gio.SettingsBindFlags.DEFAULT); 72 | this._settings.bind('min-width', 73 | this._min_width, 74 | 'value', 75 | Gio.SettingsBindFlags.DEFAULT); 76 | this._settings.bind('subreddits', 77 | this._subreddits, 78 | 'text', 79 | Gio.SettingsBindFlags.DEFAULT); 80 | } 81 | 82 | /** 83 | * Clear all config options associated to this specific adapter. 84 | */ 85 | clearConfig(): void { 86 | this._settings.resetSchema(); 87 | } 88 | } 89 | 90 | export {RedditSettings}; 91 | -------------------------------------------------------------------------------- /src/ui/urlSource.ts: -------------------------------------------------------------------------------- 1 | import Adw from 'gi://Adw'; 2 | import Gio from 'gi://Gio'; 3 | import GLib from 'gi://GLib'; 4 | import GObject from 'gi://GObject'; 5 | import Gtk from 'gi://Gtk'; 6 | 7 | import * as Settings from './../settings.js'; 8 | 9 | // FIXME: Generated static class code produces a no-unused-expressions rule error 10 | /* eslint-disable no-unused-expressions */ 11 | 12 | /** 13 | * Subclass containing the preferences for UrlSource adapter 14 | */ 15 | class UrlSourceSettings extends Adw.PreferencesPage { 16 | static [GObject.GTypeName] = 'UrlSourceSettings'; 17 | // @ts-expect-error Gtk.template is not in the type definitions files yet 18 | static [Gtk.template] = GLib.uri_resolve_relative(import.meta.url, './urlSource.ui', GLib.UriFlags.NONE); 19 | // @ts-expect-error Gtk.internalChildren is not in the type definitions files yet 20 | static [Gtk.internalChildren] = [ 21 | 'author_name', 22 | 'author_url', 23 | 'different_images', 24 | 'domain', 25 | 'image_url', 26 | 'post_url', 27 | ]; 28 | 29 | static { 30 | GObject.registerClass(this); 31 | } 32 | 33 | // InternalChildren 34 | private _author_name!: Adw.EntryRow; 35 | private _author_url!: Adw.EntryRow; 36 | private _different_images!: Gtk.Switch; 37 | private _domain!: Adw.EntryRow; 38 | private _image_url!: Adw.EntryRow; 39 | private _post_url!: Adw.EntryRow; 40 | 41 | private _settings; 42 | 43 | /** 44 | * Craft a new adapter using an unique ID. 45 | * 46 | * Previously saved settings will be used if the adapter and ID match. 47 | * 48 | * @param {string} id Unique ID 49 | */ 50 | constructor(id: string) { 51 | super(undefined); 52 | 53 | const path = `${Settings.RWG_SETTINGS_SCHEMA_PATH}/sources/urlSource/${id}/`; 54 | this._settings = new Settings.Settings(Settings.RWG_SETTINGS_SCHEMA_SOURCES_URL_SOURCE, path); 55 | 56 | this._settings.bind('author-name', 57 | this._author_name, 58 | 'text', 59 | Gio.SettingsBindFlags.DEFAULT); 60 | this._settings.bind('author-url', 61 | this._author_url, 62 | 'text', 63 | Gio.SettingsBindFlags.DEFAULT); 64 | this._settings.bind('different-images', 65 | this._different_images, 66 | 'active', 67 | Gio.SettingsBindFlags.DEFAULT); 68 | this._settings.bind('domain', 69 | this._domain, 70 | 'text', 71 | Gio.SettingsBindFlags.DEFAULT); 72 | this._settings.bind('image-url', 73 | this._image_url, 74 | 'text', 75 | Gio.SettingsBindFlags.DEFAULT); 76 | this._settings.bind('post-url', 77 | this._post_url, 78 | 'text', 79 | Gio.SettingsBindFlags.DEFAULT); 80 | } 81 | 82 | /** 83 | * Clear all config options associated to this specific adapter. 84 | */ 85 | clearConfig(): void { 86 | this._settings.resetSchema(); 87 | } 88 | } 89 | 90 | export {UrlSourceSettings}; 91 | -------------------------------------------------------------------------------- /src/adapter/urlSource.ts: -------------------------------------------------------------------------------- 1 | import * as SettingsModule from './../settings.js'; 2 | 3 | import {BaseAdapter} from './../adapter/baseAdapter.js'; 4 | import {HistoryEntry} from './../history.js'; 5 | import {Logger} from '../logger.js'; 6 | 7 | /** 8 | * Adapter for using a single static URL as an image source. 9 | */ 10 | class UrlSourceAdapter extends BaseAdapter { 11 | /** 12 | * Create a new static url adapter. 13 | * 14 | * @param {string} id Unique ID 15 | * @param {string} name Custom name of this adapter 16 | */ 17 | constructor(id: string, name: string) { 18 | super({ 19 | defaultName: 'Static URL', 20 | id, 21 | name, 22 | schemaID: SettingsModule.RWG_SETTINGS_SCHEMA_SOURCES_URL_SOURCE, 23 | schemaPath: `${SettingsModule.RWG_SETTINGS_SCHEMA_PATH}/sources/urlSource/${id}/`, 24 | }); 25 | } 26 | 27 | /** 28 | * Retrieves new URLs for images and crafts new HistoryEntries. 29 | * 30 | * Can internally query the request URL multiple times because only one image will be reported back. 31 | * 32 | * @param {number} count Number of requested wallpaper 33 | * @returns {HistoryEntry[]} Array of crafted HistoryEntries 34 | * @throws {HistoryEntry[]} Array of crafted historyEntries, can be empty 35 | */ 36 | requestRandomImage(count: number): Promise { 37 | const wallpaperResult: HistoryEntry[] = []; 38 | 39 | let requestedEntries = 1; 40 | if (this._settings.getBoolean('different-images')) 41 | requestedEntries = count; 42 | 43 | const imageDownloadUrl = this._settings.getString('image-url'); 44 | let authorName: string | null = this._settings.getString('author-name'); 45 | const authorUrl = this._settings.getString('author-url'); 46 | const domainUrl = this._settings.getString('domain'); 47 | const postUrl = this._settings.getString('domain'); 48 | 49 | if (imageDownloadUrl === '') { 50 | Logger.error('Missing download url', this); 51 | throw wallpaperResult; 52 | } 53 | 54 | if (authorName === '') 55 | authorName = null; 56 | 57 | for (let i = 0; i < requestedEntries; i++) { 58 | const historyEntry = new HistoryEntry(authorName, this._sourceName, imageDownloadUrl); 59 | 60 | if (authorUrl !== '') 61 | historyEntry.source.authorUrl = authorUrl; 62 | 63 | if (postUrl !== '') 64 | historyEntry.source.imageLinkUrl = postUrl; 65 | 66 | if (domainUrl !== '') 67 | historyEntry.source.sourceUrl = domainUrl; 68 | 69 | // overwrite historyEntry.id because the name will be the same and the timestamp might be too. 70 | // historyEntry.name can't be null here because we created the entry with the constructor. 71 | historyEntry.id = `${historyEntry.timestamp}_${i}_${historyEntry.name!}`; 72 | 73 | wallpaperResult.push(historyEntry); 74 | } 75 | 76 | return Promise.resolve(wallpaperResult); 77 | } 78 | } 79 | 80 | export {UrlSourceAdapter}; 81 | -------------------------------------------------------------------------------- /src/ui/localFolder.ts: -------------------------------------------------------------------------------- 1 | import Adw from 'gi://Adw'; 2 | import Gio from 'gi://Gio'; 3 | import GLib from 'gi://GLib'; 4 | import GObject from 'gi://GObject'; 5 | import Gtk from 'gi://Gtk'; 6 | 7 | import * as Settings from './../settings.js'; 8 | 9 | // FIXME: Generated static class code produces a no-unused-expressions rule error 10 | /* eslint-disable no-unused-expressions */ 11 | 12 | /** 13 | * Subclass containing the preferences for LocalFolder adapter 14 | */ 15 | class LocalFolderSettings extends Adw.PreferencesPage { 16 | static [GObject.GTypeName] = 'LocalFolderSettings'; 17 | // @ts-expect-error Gtk.template is not in the type definitions files yet 18 | static [Gtk.template] = GLib.uri_resolve_relative(import.meta.url, './localFolder.ui', GLib.UriFlags.NONE); 19 | // @ts-expect-error Gtk.internalChildren is not in the type definitions files yet 20 | static [Gtk.internalChildren] = [ 21 | 'folder', 22 | 'folder_row', 23 | ]; 24 | 25 | static { 26 | GObject.registerClass(this); 27 | } 28 | 29 | // InternalChildren 30 | private _folder!: Gtk.Button; 31 | private _folder_row!: Adw.EntryRow; 32 | 33 | private _saveDialog: Gtk.FileChooserNative | undefined; 34 | private _settings; 35 | 36 | /** 37 | * Craft a new adapter using an unique ID. 38 | * 39 | * Previously saved settings will be used if the adapter and ID match. 40 | * 41 | * @param {string} id Unique ID 42 | */ 43 | constructor(id: string) { 44 | super(undefined); 45 | 46 | const path = `${Settings.RWG_SETTINGS_SCHEMA_PATH}/sources/localFolder/${id}/`; 47 | this._settings = new Settings.Settings(Settings.RWG_SETTINGS_SCHEMA_SOURCES_LOCAL_FOLDER, path); 48 | 49 | this._settings.bind('folder', 50 | this._folder_row, 51 | 'text', 52 | Gio.SettingsBindFlags.DEFAULT); 53 | 54 | this._folder.connect('clicked', () => { 55 | // TODO: GTK 4.10+ 56 | // Gtk.FileDialog(); 57 | 58 | // https://stackoverflow.com/a/54487948 59 | this._saveDialog = new Gtk.FileChooserNative({ 60 | title: 'Choose a Wallpaper Folder', 61 | action: Gtk.FileChooserAction.SELECT_FOLDER, 62 | accept_label: 'Open', 63 | cancel_label: 'Cancel', 64 | transient_for: this.get_root() as Gtk.Window ?? undefined, 65 | modal: true, 66 | }); 67 | 68 | this._saveDialog.connect('response', (_dialog: Gtk.FileChooserNative, response_id: Gtk.ResponseType) => { 69 | if (response_id === Gtk.ResponseType.ACCEPT) { 70 | const chosenPath = _dialog.get_file()?.get_path(); 71 | 72 | if (chosenPath) 73 | this._folder_row.text = chosenPath; 74 | } 75 | _dialog.destroy(); 76 | }); 77 | 78 | this._saveDialog.show(); 79 | }); 80 | } 81 | 82 | /** 83 | * Clear all config options associated to this specific adapter. 84 | */ 85 | clearConfig(): void { 86 | this._settings.resetSchema(); 87 | } 88 | } 89 | 90 | export {LocalFolderSettings}; 91 | -------------------------------------------------------------------------------- /src/ui/genericJson.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $GenericJsonSettings: Adw.PreferencesPage { 5 | Adw.PreferencesGroup { 6 | title: _("Disclaimer"); 7 | description: _("This wallpaper sources requires some know how. However, many different wallpaper providers can be used with the generic JSON source. You will have to specify an URL with a JSON response and a path to the target image URL within the JSON response. You can also define a prefix that will be added to the image URL."); 8 | 9 | header-suffix: LinkButton { 10 | valign: center; 11 | uri: "https://github.com/ifl0w/RandomWallpaperGnome3/wiki/Generic-JSON-Source"; 12 | 13 | Adw.ButtonContent { 14 | icon-name: "globe-symbolic"; 15 | label: _("Help"); 16 | } 17 | 18 | styles [ 19 | "flat", 20 | ] 21 | }; 22 | } 23 | 24 | Adw.PreferencesGroup { 25 | title: _("General"); 26 | 27 | Adw.EntryRow domain { 28 | title: _("Domain"); 29 | input-purpose: url; 30 | 31 | LinkButton { 32 | valign: center; 33 | uri: bind domain.text; 34 | 35 | Adw.ButtonContent { 36 | icon-name: "globe-symbolic"; 37 | } 38 | 39 | styles [ 40 | "flat", 41 | ] 42 | } 43 | } 44 | 45 | Adw.EntryRow request_url { 46 | title: _("Request URL"); 47 | input-purpose: url; 48 | 49 | LinkButton { 50 | valign: center; 51 | uri: bind request_url.text; 52 | 53 | Adw.ButtonContent { 54 | icon-name: "globe-symbolic"; 55 | } 56 | 57 | styles [ 58 | "flat", 59 | ] 60 | } 61 | } 62 | } 63 | 64 | Adw.PreferencesGroup { 65 | title: _("Image"); 66 | 67 | Adw.EntryRow image_path { 68 | title: _("JSON Path"); 69 | input-purpose: free_form; 70 | } 71 | 72 | Adw.EntryRow image_prefix { 73 | title: _("URL Prefix"); 74 | input-purpose: free_form; 75 | } 76 | } 77 | 78 | Adw.PreferencesGroup { 79 | title: _("Post"); 80 | 81 | Adw.EntryRow post_path { 82 | title: _("JSON Path"); 83 | input-purpose: free_form; 84 | } 85 | 86 | Adw.EntryRow post_prefix { 87 | title: _("URL Prefix"); 88 | input-purpose: free_form; 89 | } 90 | } 91 | 92 | Adw.PreferencesGroup { 93 | title: _("Author"); 94 | 95 | Adw.EntryRow author_name_path { 96 | title: _("Name JSON Path"); 97 | input-purpose: free_form; 98 | } 99 | 100 | Adw.EntryRow author_url_path { 101 | title: _("URL JSON Path"); 102 | input-purpose: free_form; 103 | } 104 | 105 | Adw.EntryRow author_url_prefix { 106 | title: _("URL Prefix"); 107 | input-purpose: free_form; 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/soupBowl.ts: -------------------------------------------------------------------------------- 1 | import GLib from 'gi://GLib'; 2 | import Soup from 'gi://Soup'; 3 | 4 | import {Logger} from './logger.js'; 5 | 6 | /** 7 | * A compatibility and convenience wrapper around the Soup API. 8 | * 9 | * libSoup is accessed through the SoupBowl wrapper to support libSoup3 and libSoup2.4 simultaneously in the extension 10 | * runtime and in the preferences window. 11 | */ 12 | class SoupBowl { 13 | MessageFlags = Soup.MessageFlags; 14 | 15 | private _session = new Soup.Session(); 16 | 17 | /** 18 | * Send a request with Soup. 19 | * 20 | * @param {Soup.Message} soupMessage Message to send 21 | * @returns {Promise} Raw byte answer 22 | */ 23 | send_and_receive(soupMessage: Soup.Message): Promise { 24 | if (Soup.get_major_version() === 2) 25 | return this._send_and_receive_soup24(soupMessage); 26 | else if (Soup.get_major_version() === 3) 27 | return this._send_and_receive_soup30(soupMessage); 28 | else 29 | throw new Error('Unknown libsoup version'); 30 | } 31 | 32 | /** 33 | * Craft a new GET request. 34 | * 35 | * @param {string} uri Request address 36 | * @returns {Soup.Message} Crafted message 37 | */ 38 | newGetMessage(uri: string): Soup.Message { 39 | const message = Soup.Message.new('GET', uri); 40 | // set User-Agent to appear more like a standard browser 41 | message.request_headers.append('User-Agent', 'RandomWallpaperGnome3/3.0'); 42 | return message; 43 | } 44 | 45 | /** 46 | * Send a request using Soup 2.4 47 | * 48 | * @param {Soup.Message} soupMessage Request message 49 | * @returns {Promise} Raw byte answer 50 | */ 51 | private _send_and_receive_soup24(soupMessage: Soup.Message): Promise { 52 | return new Promise((resolve, reject) => { 53 | try { 54 | /* eslint-disable */ 55 | // Incompatible version of Soup types. Ignoring type checks. 56 | // @ts-ignore 57 | this._session.queue_message(soupMessage, (_, msg) => { 58 | if (!msg.response_body) { 59 | reject(new Error('Message has no response body')); 60 | return; 61 | } 62 | 63 | const response_body_bytes = msg.response_body.flatten().get_data(); 64 | resolve(response_body_bytes); 65 | }); 66 | /* eslint-enable */ 67 | } catch (error) { 68 | Logger.error(error, this); 69 | reject(new Error('Request failed')); 70 | } 71 | }); 72 | } 73 | 74 | /** 75 | * Send a request using Soup 3.0 76 | * 77 | * @param {Soup.Message} soupMessage Request message 78 | * @returns {Promise} Raw byte answer 79 | */ 80 | private _send_and_receive_soup30(soupMessage: Soup.Message): Promise { 81 | return new Promise((resolve, reject) => { 82 | this._session.send_and_read_async(soupMessage, 1, null).then((bytes: GLib.Bytes) => { 83 | if (bytes) 84 | resolve(bytes.toArray()); 85 | else 86 | reject(new Error('Empty response')); 87 | }).catch(error => { 88 | Logger.error(error, this); 89 | reject(new Error('Request failed')); 90 | }); 91 | }); 92 | } 93 | } 94 | 95 | export {SoupBowl}; 96 | -------------------------------------------------------------------------------- /src/manager/superPaper.ts: -------------------------------------------------------------------------------- 1 | import * as Utils from '../utils.js'; 2 | 3 | import {ExternalWallpaperManager} from './externalWallpaperManager.js'; 4 | 5 | /** 6 | * Wrapper for Superpaper using it as a manager. 7 | */ 8 | class Superpaper extends ExternalWallpaperManager { 9 | protected readonly _possibleCommands = ['superpaper']; 10 | 11 | /** 12 | * Sets the background image in light and dark mode. 13 | * 14 | * @param {string[]} wallpaperPaths Array of strings to image files 15 | */ 16 | // We don't need the settings object because Superpaper already set both picture-uri on it's own. 17 | protected async _setBackground(wallpaperPaths: string[]): Promise { 18 | await this._createCommandAndRun(wallpaperPaths); 19 | } 20 | 21 | /** 22 | * Sets the lock screen image in light and dark mode. 23 | * 24 | * @param {string[]} wallpaperPaths Array of strings to image files 25 | */ 26 | protected async _setLockScreen(wallpaperPaths: string[]): Promise { 27 | // Remember keys, Superpaper will change these 28 | const tmpBackground = this._backgroundSettings.getString('picture-uri'); 29 | const tmpBackgroundDark = this._backgroundSettings.getString('picture-uri-dark'); 30 | const tmpMode = this._backgroundSettings.getString('picture-options'); 31 | 32 | await this._createCommandAndRun(wallpaperPaths); 33 | 34 | this._screensaverSettings.setString('picture-options', 'spanned'); 35 | Utils.setPictureUriOfSettingsObject(this._screensaverSettings, this._backgroundSettings.getString('picture-uri-dark')); 36 | 37 | // Superpaper possibly changed these, change them back 38 | this._backgroundSettings.setString('picture-uri', tmpBackground); 39 | this._backgroundSettings.setString('picture-uri-dark', tmpBackgroundDark); 40 | this._backgroundSettings.setString('picture-options', tmpMode); 41 | } 42 | 43 | // https://github.com/hhannine/superpaper/blob/master/docs/cli-usage.md 44 | /** 45 | * Run Superpaper in CLI mode. 46 | * 47 | * Superpaper: 48 | * - Saves merged images alternating in `$XDG_CACHE_HOME/superpaper/temp/cli-{a,b}.png` 49 | * - Sets `picture-option` to `spanned` 50 | * - Always sets both `picture-uri` and `picture-uri-dark` options 51 | * - Can use only single images 52 | * 53 | * @param {string[]} wallpaperArray Array of paths to the desired wallpapers, should match the display count, can be a single image 54 | */ 55 | private async _createCommandAndRun(wallpaperArray: string[]): Promise { 56 | let command = []; 57 | 58 | // cspell:disable-next-line 59 | command.push('--setimages'); 60 | command = command.concat(wallpaperArray); 61 | 62 | await this._runExternal(command); 63 | } 64 | 65 | /** 66 | * Check if a filename matches a merged wallpaper name. 67 | * 68 | * Merged wallpaper need special handling as these are single images 69 | * but span across all displays. 70 | * 71 | * @param {string} filename Naming to check 72 | * @returns {boolean} Whether the image is a merged wallpaper 73 | */ 74 | static isImageMerged(filename: string): boolean { 75 | const mergedWallpaperNames = [ 76 | 'cli-a', 77 | 'cli-b', 78 | ]; 79 | 80 | for (const name of mergedWallpaperNames) { 81 | if (filename.includes(name)) 82 | return true; 83 | } 84 | 85 | return false; 86 | } 87 | } 88 | 89 | export {Superpaper}; 90 | -------------------------------------------------------------------------------- /src/ui/wallhaven.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $WallhavenSettings: Adw.PreferencesPage { 5 | 6 | Adw.PreferencesGroup { 7 | title: _("General"); 8 | 9 | Adw.EntryRow keyword { 10 | title: _("Keywords - Comma Separated"); 11 | input-purpose: free_form; 12 | } 13 | 14 | Adw.PasswordEntryRow api_key { 15 | title: _("API Key"); 16 | input-purpose: password; 17 | 18 | LinkButton { 19 | valign: center; 20 | uri: "https://wallhaven.cc/settings/account"; 21 | 22 | Adw.ButtonContent { 23 | icon-name: "globe-symbolic"; 24 | } 25 | 26 | styles [ 27 | "flat", 28 | ] 29 | } 30 | } 31 | 32 | Adw.EntryRow minimal_resolution { 33 | title: _("Minimal Resolution: 1920x1080"); 34 | input-purpose: free_form; 35 | text: ""; 36 | } 37 | 38 | Adw.EntryRow aspect_ratios { 39 | title: _("Allowed Aspect Ratios: 16x9,16x10"); 40 | input-purpose: free_form; 41 | text: ""; 42 | } 43 | 44 | Adw.ActionRow row_color { 45 | title: _("Search by color"); 46 | subtitle: ""; 47 | 48 | Box { 49 | Button button_color_undo { 50 | valign: center; 51 | 52 | styles [ 53 | "flat", 54 | ] 55 | 56 | Adw.ButtonContent { 57 | icon-name: "edit-undo-symbolic"; 58 | } 59 | } 60 | 61 | Button button_color { 62 | valign: center; 63 | 64 | Adw.ButtonContent { 65 | icon-name: "color-select-symbolic"; 66 | } 67 | } 68 | } 69 | } 70 | 71 | Adw.ActionRow { 72 | title: _("Allow AI Generated Images"); 73 | 74 | Switch ai_art { 75 | valign: center; 76 | } 77 | } 78 | } 79 | 80 | Adw.PreferencesGroup { 81 | title: _("Allowed Content Ratings"); 82 | 83 | Adw.ActionRow { 84 | title: "SFW"; 85 | subtitle: _("Safe for work"); 86 | 87 | Switch allow_sfw { 88 | valign: center; 89 | } 90 | } 91 | 92 | Adw.ActionRow { 93 | title: "Sketchy"; 94 | 95 | Switch allow_sketchy { 96 | valign: center; 97 | } 98 | } 99 | 100 | Adw.ActionRow { 101 | title: "NSFW"; 102 | subtitle: _("Not safe for work"); 103 | 104 | Switch allow_nsfw { 105 | valign: center; 106 | } 107 | } 108 | } 109 | 110 | Adw.PreferencesGroup { 111 | title: _("Categories"); 112 | 113 | Adw.ActionRow { 114 | title: "General"; 115 | 116 | Switch category_general { 117 | valign: center; 118 | } 119 | } 120 | 121 | Adw.ActionRow { 122 | title: "Anime"; 123 | 124 | Switch category_anime { 125 | valign: center; 126 | } 127 | } 128 | 129 | Adw.ActionRow { 130 | title: "People"; 131 | 132 | Switch category_people { 133 | valign: center; 134 | } 135 | } 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /container/Dockerfile: -------------------------------------------------------------------------------- 1 | # MIT License 2 | # 3 | # Copyright (c) 2019 mviereck 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining a copy 6 | # of this software and associated documentation files (the "Software"), to deal 7 | # in the Software without restriction, including without limitation the rights 8 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | # copies of the Software, and to permit persons to whom the Software is 10 | # furnished to do so, subject to the following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be included in all 13 | # copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | # SOFTWARE. 22 | # 23 | # Based on x11docker/gnome 24 | # https://github.com/mviereck/x11docker 25 | 26 | ARG VERSION 27 | FROM ubuntu:${VERSION} 28 | ENV LANG en_US.UTF-8 29 | ENV SHELL=/bin/bash 30 | 31 | # cleanup script for use after apt-get 32 | RUN echo '#! /bin/sh\n\ 33 | env DEBIAN_FRONTEND=noninteractive apt-get autoremove -y\n\ 34 | apt-get clean\n\ 35 | find /var/lib/apt/lists -type f -delete\n\ 36 | find /var/cache -type f -delete\n\ 37 | find /var/log -type f -delete\n\ 38 | exit 0\n\ 39 | ' > /cleanup && chmod +x /cleanup 40 | 41 | # basics 42 | RUN apt-get update && \ 43 | env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ 44 | locales && \ 45 | echo "$LANG UTF-8" >> /etc/locale.gen && \ 46 | locale-gen && \ 47 | env DEBIAN_FRONTEND=noninteractive apt-get install -y \ 48 | dbus \ 49 | dbus-x11 \ 50 | systemd && \ 51 | /cleanup 52 | 53 | # Gnome 3 54 | RUN apt-get update && \ 55 | env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ 56 | gnome-session && \ 57 | /cleanup 58 | 59 | # Gnome 3 apps 60 | RUN apt-get update && \ 61 | env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ 62 | arandr `#Lightweight utility to set resolution` \ 63 | gnome-icon-theme \ 64 | gnome-terminal \ 65 | nautilus && \ 66 | /cleanup 67 | 68 | # Gnome Shell extensions 69 | RUN apt-get update && \ 70 | env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ 71 | gnome-shell-extension-prefs && \ 72 | /cleanup 73 | 74 | # Workaround to get gnome-session running. 75 | # gnome-session fails if started directly. Running gnome-shell only works, but lacks configuration support. 76 | RUN apt-get update && \ 77 | env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ 78 | guake && \ 79 | rm /usr/share/applications/guake.desktop /usr/share/applications/guake-prefs.desktop && \ 80 | echo "#! /bin/bash\n\ 81 | guake -e gnome-session\n\ 82 | while pgrep gnome-shell; do sleep 1 ; done\n\ 83 | " >/usr/local/bin/startgnome && \ 84 | chmod +x /usr/local/bin/startgnome && \ 85 | /cleanup 86 | 87 | # Make sure we already add a user so bind mount won't cause problems later 88 | RUN adduser --disabled-password --gecos "" dev 89 | USER dev 90 | 91 | # Also prevent the parent directories of the mount to be created and thus owned by root 92 | RUN mkdir --parents /home/dev/.local/share/gnome-shell/extensions/randomwallpaper@iflow.space 93 | 94 | CMD /usr/local/bin/startgnome 95 | -------------------------------------------------------------------------------- /src/ui/genericJson.ts: -------------------------------------------------------------------------------- 1 | import Adw from 'gi://Adw'; 2 | import Gio from 'gi://Gio'; 3 | import GLib from 'gi://GLib'; 4 | import GObject from 'gi://GObject'; 5 | import Gtk from 'gi://Gtk'; 6 | 7 | import * as Settings from './../settings.js'; 8 | 9 | // FIXME: Generated static class code produces a no-unused-expressions rule error 10 | /* eslint-disable no-unused-expressions */ 11 | 12 | /** 13 | * Subclass containing the preferences for GenericJson adapter 14 | */ 15 | class GenericJsonSettings extends Adw.PreferencesPage { 16 | static [GObject.GTypeName] = 'GenericJsonSettings'; 17 | // @ts-expect-error Gtk.template is not in the type definitions files yet 18 | static [Gtk.template] = GLib.uri_resolve_relative(import.meta.url, './genericJson.ui', GLib.UriFlags.NONE); 19 | // @ts-expect-error Gtk.internalChildren is not in the type definitions files yet 20 | static [Gtk.internalChildren] = [ 21 | 'author_name_path', 22 | 'author_url_path', 23 | 'author_url_prefix', 24 | 'domain', 25 | 'image_path', 26 | 'image_prefix', 27 | 'post_path', 28 | 'post_prefix', 29 | 'request_url', 30 | ]; 31 | 32 | static { 33 | GObject.registerClass(this); 34 | } 35 | 36 | // InternalChildren 37 | private _author_name_path!: Adw.EntryRow; 38 | private _author_url_path!: Adw.EntryRow; 39 | private _author_url_prefix!: Adw.EntryRow; 40 | private _domain!: Adw.EntryRow; 41 | private _image_path!: Adw.EntryRow; 42 | private _image_prefix!: Adw.EntryRow; 43 | private _post_path!: Adw.EntryRow; 44 | private _post_prefix!: Adw.EntryRow; 45 | private _request_url!: Adw.EntryRow; 46 | 47 | private _settings; 48 | 49 | /** 50 | * Craft a new adapter using an unique ID. 51 | * 52 | * Previously saved settings will be used if the adapter and ID match. 53 | * 54 | * @param {string} id Unique ID 55 | */ 56 | constructor(id: string) { 57 | super(undefined); 58 | 59 | const path = `${Settings.RWG_SETTINGS_SCHEMA_PATH}/sources/genericJSON/${id}/`; 60 | this._settings = new Settings.Settings(Settings.RWG_SETTINGS_SCHEMA_SOURCES_GENERIC_JSON, path); 61 | 62 | this._settings.bind('domain', 63 | this._domain, 64 | 'text', 65 | Gio.SettingsBindFlags.DEFAULT); 66 | this._settings.bind('request-url', 67 | this._request_url, 68 | 'text', 69 | Gio.SettingsBindFlags.DEFAULT); 70 | this._settings.bind('image-path', 71 | this._image_path, 72 | 'text', 73 | Gio.SettingsBindFlags.DEFAULT); 74 | this._settings.bind('image-prefix', 75 | this._image_prefix, 76 | 'text', 77 | Gio.SettingsBindFlags.DEFAULT); 78 | this._settings.bind('post-path', 79 | this._post_path, 80 | 'text', 81 | Gio.SettingsBindFlags.DEFAULT); 82 | this._settings.bind('post-prefix', 83 | this._post_prefix, 84 | 'text', 85 | Gio.SettingsBindFlags.DEFAULT); 86 | this._settings.bind('author-name-path', 87 | this._author_name_path, 88 | 'text', 89 | Gio.SettingsBindFlags.DEFAULT); 90 | this._settings.bind('author-url-path', 91 | this._author_url_path, 92 | 'text', 93 | Gio.SettingsBindFlags.DEFAULT); 94 | this._settings.bind('author-url-prefix', 95 | this._author_url_prefix, 96 | 'text', 97 | Gio.SettingsBindFlags.DEFAULT); 98 | } 99 | 100 | /** 101 | * Clear all config options associated to this specific adapter. 102 | */ 103 | clearConfig(): void { 104 | this._settings.resetSchema(); 105 | } 106 | } 107 | 108 | export {GenericJsonSettings}; 109 | -------------------------------------------------------------------------------- /src/manager/defaultWallpaperManager.ts: -------------------------------------------------------------------------------- 1 | import * as Utils from '../utils.js'; 2 | 3 | import {WallpaperManager} from './wallpaperManager.js'; 4 | import {Logger} from '../logger.js'; 5 | import {Settings} from '../settings.js'; 6 | 7 | /** 8 | * A general default wallpaper manager. 9 | * 10 | * Unable to handle multiple displays. 11 | */ 12 | class DefaultWallpaperManager extends WallpaperManager { 13 | /** 14 | * Sets the background image in light and dark mode. 15 | * 16 | * @param {string[]} wallpaperPaths Array of strings to image files, expects a single image only. 17 | * @returns {Promise} Only resolves 18 | */ 19 | protected async _setBackground(wallpaperPaths: string[]): Promise { 20 | // The default manager can't handle multiple displays 21 | if (wallpaperPaths.length > 1) 22 | Logger.warn('Single handling manager called with multiple images!', this); 23 | 24 | await DefaultWallpaperManager.setSingleBackground(`file://${wallpaperPaths[0]}`, this._backgroundSettings); 25 | 26 | return Promise.resolve(); 27 | } 28 | 29 | /** 30 | * Sets the lock screen image in light and dark mode. 31 | * 32 | * @param {string[]} wallpaperPaths Array of strings to image files, expects a single image only. 33 | * @returns {Promise} Only resolves 34 | */ 35 | protected async _setLockScreen(wallpaperPaths: string[]): Promise { 36 | // The default manager can't handle multiple displays 37 | if (wallpaperPaths.length > 1) 38 | Logger.warn('Single handling manager called with multiple images!', this); 39 | 40 | await DefaultWallpaperManager.setSingleLockScreen(`file://${wallpaperPaths[0]}`, this._backgroundSettings, this._screensaverSettings); 41 | 42 | return Promise.resolve(); 43 | } 44 | 45 | /** 46 | * Default fallback function to set a single image background. 47 | * 48 | * @param {string} wallpaperURI URI to image file 49 | * @param {Settings} backgroundSettings Settings containing the background `picture-uri` key 50 | * @returns {Promise} Only resolves 51 | */ 52 | static setSingleBackground(wallpaperURI: string, backgroundSettings: Settings): Promise { 53 | const storedScalingMode = new Settings().getString('scaling-mode'); 54 | if (Utils.isImageMerged(wallpaperURI)) 55 | // merged wallpapers need mode "spanned" 56 | backgroundSettings.setString('picture-options', 'spanned'); 57 | else if (storedScalingMode) 58 | backgroundSettings.setString('picture-options', storedScalingMode); 59 | 60 | Utils.setPictureUriOfSettingsObject(backgroundSettings, wallpaperURI); 61 | return Promise.resolve(); 62 | } 63 | 64 | /** 65 | *Default fallback function to set a single image lock screen. 66 | * 67 | * @param {string} wallpaperURI URI to image file 68 | * @param {Settings} backgroundSettings Settings containing the background `picture-uri` key 69 | * @param {Settings} screensaverSettings Settings containing the lock screen `picture-uri` key 70 | * @returns {Promise} Only resolves 71 | */ 72 | static setSingleLockScreen(wallpaperURI: string, backgroundSettings: Settings, screensaverSettings: Settings): Promise { 73 | const storedScalingMode = new Settings().getString('scaling-mode'); 74 | if (Utils.isImageMerged(wallpaperURI)) 75 | // merged wallpapers need mode "spanned" 76 | screensaverSettings.setString('picture-options', 'spanned'); 77 | else if (storedScalingMode) 78 | screensaverSettings.setString('picture-options', storedScalingMode); 79 | 80 | Utils.setPictureUriOfSettingsObject(screensaverSettings, wallpaperURI); 81 | return Promise.resolve(); 82 | } 83 | 84 | /** 85 | * Check if a filename matches a merged wallpaper name. 86 | * 87 | * Merged wallpaper need special handling as these are single images 88 | * but span across all displays. 89 | * 90 | * @param {string} _filename Unused naming to check 91 | * @returns {boolean} Whether the image is a merged wallpaper 92 | */ 93 | static isImageMerged(_filename: string): boolean { 94 | // This manager can't create merged wallpaper 95 | return false; 96 | } 97 | } 98 | 99 | 100 | export {DefaultWallpaperManager}; 101 | -------------------------------------------------------------------------------- /src/manager/wallpaperManager.ts: -------------------------------------------------------------------------------- 1 | import {Settings} from './../settings.js'; 2 | import {getEnumFromSettings} from './../utils.js'; 3 | 4 | // Generated code produces a no-shadow rule error 5 | /* eslint-disable */ 6 | enum Mode { 7 | /** Only change the desktop background */ 8 | BACKGROUND, 9 | /** Only change the lock screen background */ 10 | LOCKSCREEN, 11 | /** Change the desktop and lock screen background to the same image. */ 12 | // This allows for optimizations when processing images. 13 | BACKGROUND_AND_LOCKSCREEN, 14 | /** Change each - the desktop and lock screen background - to different images. */ 15 | BACKGROUND_AND_LOCKSCREEN_INDEPENDENT, 16 | } 17 | /* eslint-enable */ 18 | 19 | /** 20 | * Wallpaper manager is a base class for manager to implement. 21 | */ 22 | abstract class WallpaperManager { 23 | public canHandleMultipleImages = false; 24 | 25 | protected _backgroundSettings = new Settings('org.gnome.desktop.background'); 26 | protected _screensaverSettings = new Settings('org.gnome.desktop.screensaver'); 27 | 28 | /** 29 | * Set the wallpapers for a given mode. 30 | * 31 | * @param {string[]} wallpaperPaths Array of paths to the desired wallpapers, should match the display count 32 | * @param {Mode} mode Enum indicating what images to change 33 | */ 34 | async setWallpaper(wallpaperPaths: string[], mode: Mode = Mode.BACKGROUND): Promise { 35 | if (wallpaperPaths.length < 1) 36 | throw new Error('Empty wallpaper array'); 37 | 38 | const promises = []; 39 | if (mode === Mode.BACKGROUND || mode === Mode.BACKGROUND_AND_LOCKSCREEN) 40 | promises.push(this._setBackground(wallpaperPaths)); 41 | 42 | if (mode === Mode.LOCKSCREEN || mode === Mode.BACKGROUND_AND_LOCKSCREEN) 43 | promises.push(this._setLockScreen(wallpaperPaths)); 44 | 45 | if (mode === Mode.BACKGROUND_AND_LOCKSCREEN_INDEPENDENT) { 46 | if (wallpaperPaths.length < 2) 47 | throw new Error('Not enough wallpaper'); 48 | 49 | // Half the images for the background 50 | promises.push(this._setBackground(wallpaperPaths.slice(0, wallpaperPaths.length / 2))); 51 | // Half the images for the lock screen 52 | promises.push(this._setLockScreen(wallpaperPaths.slice(wallpaperPaths.length / 2))); 53 | } 54 | 55 | await Promise.allSettled(promises); 56 | } 57 | 58 | protected abstract _setBackground(wallpaperPaths: string[]): Promise; 59 | protected abstract _setLockScreen(wallpaperPaths: string[]): Promise; 60 | } 61 | 62 | /** 63 | * Retrieve the human readable enum name. 64 | * 65 | * @param {Mode} mode The mode to name 66 | * @returns {string} Name 67 | */ 68 | function _getModeName(mode: Mode): string { 69 | let name: string; 70 | 71 | switch (mode) { 72 | case Mode.BACKGROUND: 73 | name = 'Background'; 74 | break; 75 | case Mode.LOCKSCREEN: 76 | name = 'Lockscreen'; 77 | break; 78 | case Mode.BACKGROUND_AND_LOCKSCREEN: 79 | name = 'Background and lockscreen'; 80 | break; 81 | case Mode.BACKGROUND_AND_LOCKSCREEN_INDEPENDENT: 82 | name = 'Background and lockscreen independently'; 83 | break; 84 | 85 | default: 86 | name = 'Mode name not found'; 87 | break; 88 | } 89 | 90 | return name; 91 | } 92 | 93 | /** 94 | * Get a list of human readable enum entries. 95 | * 96 | * @returns {string[]} Array with key names 97 | */ 98 | function getModeNameList(): string[] { 99 | const list: string[] = []; 100 | 101 | const values = Object.values(Mode).filter(v => !isNaN(Number(v))); 102 | for (const i of values) 103 | list.push(_getModeName(i as Mode)); 104 | 105 | return list; 106 | } 107 | 108 | /** 109 | * Get a list of all the valid enum entries and return them as an array of strings 110 | * 111 | * Note: The list gets pre-filtered of unwanted values. 112 | * 113 | * @returns {string[]} Array of string containing valid enum values 114 | */ 115 | function getScalingModeEnum(): string[] { 116 | const excludes = [ 117 | 'none', // No wallpaper 118 | 'wallpaper', // Tiled wallpapers, repeating pattern 119 | 'spanned', // Ignoring aspect ratio 120 | ]; 121 | 122 | return getEnumFromSettings(new Settings('org.gnome.desktop.background'), 'picture-options') 123 | .filter(s => !excludes.includes(s)); 124 | } 125 | 126 | export { 127 | WallpaperManager, 128 | Mode, 129 | getModeNameList, 130 | getScalingModeEnum 131 | }; 132 | -------------------------------------------------------------------------------- /src/adapter/baseAdapter.ts: -------------------------------------------------------------------------------- 1 | import Gio from 'gi://Gio'; 2 | 3 | import * as SettingsModule from './../settings.js'; 4 | 5 | import {HistoryEntry} from './../history.js'; 6 | import {Logger} from './../logger.js'; 7 | import {SoupBowl} from './../soupBowl.js'; 8 | 9 | /** 10 | * Abstract base adapter for subsequent classes to implement. 11 | */ 12 | abstract class BaseAdapter { 13 | protected _bowl = new SoupBowl(); 14 | 15 | protected _generalSettings: SettingsModule.Settings; 16 | protected _settings: SettingsModule.Settings; 17 | protected _sourceName: string; 18 | 19 | /** 20 | * Create a new base adapter. 21 | * 22 | * Exposes settings and utilities for subsequent classes. 23 | * Previously saved settings will be used if the ID matches. 24 | * 25 | * @param {object} params Parameter object with settings 26 | * @param {string} params.defaultName Default adapter name 27 | * @param {string} params.id Unique ID 28 | * @param {string | null} params.name Custom name, falls back to the default name on null 29 | * @param {string} params.schemaID ID of the adapter specific schema ID 30 | * @param {string} params.schemaPath Path to the adapter specific settings schema 31 | */ 32 | constructor(params: { 33 | defaultName: string; 34 | id: string; 35 | name: string | null; 36 | schemaID: string; 37 | schemaPath: string; 38 | }) { 39 | const path = `${SettingsModule.RWG_SETTINGS_SCHEMA_PATH}/sources/general/${params.id}/`; 40 | 41 | this._settings = new SettingsModule.Settings(params.schemaID, params.schemaPath); 42 | this._sourceName = params.name ?? params.defaultName; 43 | 44 | this._generalSettings = new SettingsModule.Settings( 45 | SettingsModule.RWG_SETTINGS_SCHEMA_SOURCES_GENERAL, 46 | path 47 | ); 48 | } 49 | 50 | /** 51 | * Retrieves new URLs for images and crafts new HistoryEntries. 52 | * 53 | * @param {number} count Number of requested wallpaper 54 | * @returns {HistoryEntry[]} Array of crafted HistoryEntries 55 | * @throws {HistoryEntry[]} Array of crafted historyEntries, can be empty 56 | */ 57 | abstract requestRandomImage (count: number): Promise; 58 | 59 | /** 60 | * Fetches an image according to a given HistoryEntry. 61 | * 62 | * This default implementation requests the image in HistoryEntry.source.imageDownloadUrl 63 | * using Soup and saves it to HistoryEntry.path 64 | * 65 | * @param {HistoryEntry} historyEntry The historyEntry to fetch 66 | * @returns {Promise} unaltered HistoryEntry 67 | */ 68 | async fetchFile(historyEntry: HistoryEntry): Promise { 69 | const file = Gio.file_new_for_path(historyEntry.path); 70 | const fstream = file.replace(null, false, Gio.FileCreateFlags.NONE, null); 71 | 72 | // craft new message from details 73 | const request = this._bowl.newGetMessage(historyEntry.source.imageDownloadUrl); 74 | 75 | // start the download 76 | const response_data_bytes = await this._bowl.send_and_receive(request); 77 | if (!response_data_bytes) { 78 | fstream.close(null); 79 | throw new Error('Not a valid image response'); 80 | } 81 | 82 | fstream.write(response_data_bytes, null); 83 | fstream.close(null); 84 | 85 | return historyEntry; 86 | } 87 | 88 | /** 89 | * Check if an array already contains a matching HistoryEntry. 90 | * 91 | * @param {HistoryEntry[]} array Array to search in 92 | * @param {string} uri URI to search for 93 | * @returns {boolean} Whether the array contains an item with $uri 94 | */ 95 | protected _includesWallpaper(array: HistoryEntry[], uri: string): boolean { 96 | for (const element of array) { 97 | if (element.source.imageDownloadUrl === uri) 98 | return true; 99 | } 100 | 101 | return false; 102 | } 103 | 104 | /** 105 | * Check if this image is in the list of blocked images. 106 | * 107 | * @param {string} filename Name of the image 108 | * @returns {boolean} Whether the image is blocked 109 | */ 110 | protected _isImageBlocked(filename: string): boolean { 111 | const blockedFilenames = this._generalSettings.getStrv('blocked-images'); 112 | 113 | if (blockedFilenames.includes(filename)) { 114 | Logger.info(`Image is blocked: ${filename}`, this); 115 | return true; 116 | } 117 | 118 | return false; 119 | } 120 | } 121 | 122 | export {BaseAdapter}; 123 | -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | // https://gitlab.gnome.org/GNOME/gjs/-/blob/master/doc/Logging.md 2 | 3 | import {Settings} from './settings.js'; 4 | 5 | // Generated code produces a no-shadow rule error 6 | /* eslint-disable */ 7 | enum LogLevel { 8 | SILENT, 9 | ERROR, 10 | WARNING, 11 | INFO, 12 | DEBUG, 13 | } 14 | /* eslint-enable */ 15 | 16 | const LOG_PREFIX = 'RandomWallpaper'; 17 | 18 | /** 19 | * A convenience logger class. 20 | */ 21 | class Logger { 22 | public static SETTINGS: Settings | null = null; 23 | 24 | /** 25 | * Helper function to safely log to the console. 26 | * 27 | * @param {LogLevel} level the selected log level 28 | * @param {unknown} message Message to send, ideally an Error() or string 29 | * @param {object} sourceInstance Object where the log originates from (i.e., the source context) 30 | */ 31 | private static _log(level: LogLevel, message: unknown, sourceInstance?: object): void { 32 | if (Logger._selectedLogLevel() < level) 33 | return; 34 | 35 | let errorMessage = String(message); 36 | 37 | if (message instanceof Error) 38 | errorMessage = message.message; 39 | 40 | let sourceName = ''; 41 | if (sourceInstance) 42 | sourceName = ` >> ${sourceInstance.constructor.name}`; 43 | 44 | // This logs messages with GLib.LogLevelFlags.LEVEL_MESSAGE 45 | console.log(`${LOG_PREFIX} [${LogLevel[level]}]${sourceName} :: ${errorMessage}`); 46 | 47 | // Log stack trace if available 48 | if (message instanceof Error && message.stack) 49 | // This logs messages with GLib.LogLevelFlags.LEVEL_WARNING 50 | console.warn(message); 51 | } 52 | 53 | /** 54 | * Get the log level selected by the user. 55 | * 56 | * Requires the static SETTINGS member to be set first. 57 | * Falls back to LogLevel.WARNING if settings object is not set. 58 | * 59 | * @returns {LogLevel} Log level 60 | */ 61 | private static _selectedLogLevel(): LogLevel { 62 | if (Logger.SETTINGS === null) { 63 | this._log(LogLevel.ERROR, 'Extension context not set before first use!', Logger); 64 | return LogLevel.WARNING; 65 | } 66 | 67 | return Logger.SETTINGS.getInt('log-level'); 68 | } 69 | 70 | /** 71 | * Log a DEBUG message. 72 | * 73 | * @param {unknown} message Message to send, ideally an Error() or string 74 | * @param {object} sourceInstance Object where the log originates from (i.e., the source context) 75 | */ 76 | static debug(message: unknown, sourceInstance?: object): void { 77 | Logger._log(LogLevel.DEBUG, message, sourceInstance); 78 | } 79 | 80 | /** 81 | * Log an INFO message. 82 | * 83 | * @param {unknown} message Message to send, ideally an Error() or string 84 | * @param {object} sourceInstance Object where the log originates from (i.e., the source context) 85 | */ 86 | static info(message: unknown, sourceInstance?: object): void { 87 | Logger._log(LogLevel.INFO, message, sourceInstance); 88 | } 89 | 90 | /** 91 | * Log a WARN message. 92 | * 93 | * @param {unknown} message Message to send, ideally an Error() or string 94 | * @param {object} sourceInstance Object where the log originates from (i.e., the source context) 95 | */ 96 | static warn(message: unknown, sourceInstance?: object): void { 97 | Logger._log(LogLevel.WARNING, message, sourceInstance); 98 | } 99 | 100 | /** 101 | * Log an ERROR message. 102 | * 103 | * @param {unknown} message Message to send, ideally an Error() or string 104 | * @param {object} sourceInstance Object where the log originates from (i.e., the source context) 105 | */ 106 | static error(message: unknown, sourceInstance?: object): void { 107 | Logger._log(LogLevel.ERROR, message, sourceInstance); 108 | } 109 | 110 | /** 111 | * Get a list of human readable enum entries. 112 | * 113 | * @returns {string[]} Array with key names 114 | */ 115 | static getLogLevelNameList(): string[] { 116 | const list: string[] = []; 117 | 118 | const values = Object.values(LogLevel).filter(v => !isNaN(Number(v))); 119 | for (const i of values) 120 | list.push(`${LogLevel[i as number]}`); 121 | 122 | return list; 123 | } 124 | 125 | /** 126 | * Remove references hold by this class 127 | */ 128 | static destroy(): void { 129 | // clear reference to settings object 130 | Logger.SETTINGS = null; 131 | } 132 | } 133 | 134 | export { 135 | Logger 136 | }; 137 | -------------------------------------------------------------------------------- /src/jsonPath.ts: -------------------------------------------------------------------------------- 1 | import * as Utils from './utils.js'; 2 | 3 | /** 4 | * Access a simple json path expression of an object. 5 | * Returns the accessed value or null if the access was not possible. 6 | * Accepts predefined number values to access the same elements as previously 7 | * and allows to override the use of these values. 8 | * 9 | * @param {unknown} inputObject A JSON object 10 | * @param {string} inputString JSONPath to follow, see wiki for syntax 11 | * @returns {[unknown, string]} Tuple with an object of unknown type and a chosen JSONPath string 12 | */ 13 | function getTarget(inputObject: unknown, inputString: string): [object: unknown, chosenPath: string] { 14 | if (!inputObject) 15 | return [null, '']; 16 | 17 | if (inputString.length === 0) 18 | return [inputObject, inputString]; 19 | 20 | let startDot = inputString.indexOf('.'); 21 | if (startDot === -1) 22 | startDot = inputString.length; 23 | 24 | let keyString = inputString.slice(0, startDot); 25 | const inputStringTail = inputString.slice(startDot + 1); 26 | 27 | const startParentheses = keyString.indexOf('['); 28 | 29 | if (startParentheses === -1) { 30 | // Expect Object here 31 | const targetObject = _getObjectMember(inputObject, keyString); 32 | if (!targetObject) 33 | return [null, '']; 34 | 35 | const [object, path] = getTarget(targetObject, inputStringTail); 36 | return [object, inputString.slice(0, inputString.length - inputStringTail.length) + path]; 37 | } else { 38 | const indexString = keyString.slice(startParentheses + 1, keyString.length - 1); 39 | keyString = keyString.slice(0, startParentheses); 40 | 41 | // Expect an Array at this point 42 | const targetObject = _getObjectMember(inputObject, keyString); 43 | if (!targetObject || !Array.isArray(targetObject)) 44 | return [null, '']; 45 | 46 | // add special keywords here 47 | switch (indexString) { 48 | case '@random': { 49 | const [chosenElement, chosenNumber] = _randomElement(targetObject); 50 | const [object, path] = getTarget(chosenElement, inputStringTail); 51 | return [object, inputString.slice(0, inputString.length - inputStringTail.length).replace('@random', String(chosenNumber)) + path]; 52 | } 53 | default: { 54 | // expecting integer 55 | const [object, path] = getTarget(targetObject[parseInt(indexString)], inputStringTail); 56 | return [object, inputString.slice(0, inputString.length - inputStringTail.length) + path]; 57 | } 58 | } 59 | } 60 | } 61 | 62 | /** 63 | * Check validity of the key string and return the target member or null. 64 | * 65 | * @param {object} inputObject JSON object 66 | * @param {string} keyString Name of the key in the object 67 | * @returns {unknown | null} Found object member or null 68 | */ 69 | function _getObjectMember(inputObject: object, keyString: string): unknown { 70 | if (keyString === '$') 71 | return inputObject; 72 | 73 | for (const [key, value] of Object.entries(inputObject)) { 74 | if (key === keyString) 75 | return value; 76 | } 77 | 78 | return null; 79 | } 80 | 81 | /** 82 | * Returns the value of a random key of a given array. 83 | * 84 | * @param {Array} array Array with values 85 | * @returns {[T, number]} Tuple with an array member and index of that member 86 | */ 87 | function _randomElement(array: Array): [T, number] { 88 | const randomNumber = Utils.getRandomNumber(array.length); 89 | return [array[randomNumber], randomNumber]; 90 | } 91 | 92 | /** 93 | * Replace '@random' according to an already resolved path. 94 | * 95 | * '@random' would yield different results so this makes sure the values stay 96 | * the same as long as the path is identical. 97 | * 98 | * @param {string} randomPath Path containing '@random' to resolve 99 | * @param {string} resolvedPath Path with resolved '@random' 100 | * @returns {string} Input string with replaced '@random' 101 | */ 102 | function replaceRandomInPath(randomPath: string, resolvedPath: string): string { 103 | if (!randomPath.includes('@random')) 104 | return randomPath; 105 | 106 | let newPath = randomPath; 107 | while (newPath.includes('@random')) { 108 | const startRandom = newPath.indexOf('@random'); 109 | 110 | // abort if path is not equal up to this point 111 | if (newPath.substring(0, startRandom) !== resolvedPath.substring(0, startRandom)) 112 | break; 113 | 114 | const endParenthesis = resolvedPath.indexOf(']', startRandom); 115 | newPath = newPath.replace('@random', resolvedPath.substring(startRandom, endParenthesis)); 116 | } 117 | 118 | return newPath; 119 | } 120 | 121 | export {getTarget, replaceRandomInPath}; 122 | -------------------------------------------------------------------------------- /src/adapter/localFolder.ts: -------------------------------------------------------------------------------- 1 | import Gio from 'gi://Gio'; 2 | import GLib from 'gi://GLib'; 3 | 4 | import * as SettingsModule from './../settings.js'; 5 | import * as Utils from './../utils.js'; 6 | 7 | import {BaseAdapter} from './../adapter/baseAdapter.js'; 8 | import {HistoryEntry} from './../history.js'; 9 | import {Logger} from './../logger.js'; 10 | 11 | // https://gjs.guide/guides/gjs/asynchronous-programming.html#promisify-helper 12 | Gio._promisify(Gio.File.prototype, 'copy_async', 'copy_finish'); 13 | 14 | /** 15 | * Adapter for fetching from the local filesystem. 16 | */ 17 | class LocalFolderAdapter extends BaseAdapter { 18 | /** 19 | * Create a new local folder adapter. 20 | * 21 | * @param {string} id Unique ID 22 | * @param {string} name Custom name of this adapter 23 | */ 24 | constructor(id: string, name: string) { 25 | super({ 26 | defaultName: 'Local Folder', 27 | id, 28 | name, 29 | schemaID: SettingsModule.RWG_SETTINGS_SCHEMA_SOURCES_LOCAL_FOLDER, 30 | schemaPath: `${SettingsModule.RWG_SETTINGS_SCHEMA_PATH}/sources/localFolder/${id}/`, 31 | }); 32 | } 33 | 34 | /** 35 | * Retrieves new URLs for images and crafts new HistoryEntries. 36 | * 37 | * @param {number} count Number of requested wallpaper 38 | * @returns {HistoryEntry[]} Array of crafted HistoryEntries 39 | * @throws {HistoryEntry[]} Array of crafted historyEntries, can be empty 40 | */ 41 | requestRandomImage(count: number): Promise { 42 | return new Promise((resolve, reject) => { 43 | const folder = Gio.File.new_for_path(this._settings.getString('folder')); 44 | const files = this._listDirectory(folder); 45 | const wallpaperResult: HistoryEntry[] = []; 46 | 47 | if (files.length < 1) { 48 | Logger.error('No files found', this); 49 | reject(wallpaperResult); 50 | return; 51 | } 52 | Logger.debug(`Found ${files.length} possible wallpaper in "${this._settings.getString('folder')}"`, this); 53 | 54 | const shuffledFiles = Utils.shuffleArray(files); 55 | 56 | for (let i = 0; i < shuffledFiles.length; i++) { 57 | const randomFile = shuffledFiles[i]; 58 | const randomFilePath = randomFile.get_uri(); 59 | 60 | if (randomFilePath) { 61 | const historyEntry = new HistoryEntry(null, this._sourceName, randomFilePath); 62 | historyEntry.source.sourceUrl = randomFilePath; 63 | 64 | wallpaperResult.push(historyEntry); 65 | if (wallpaperResult.length >= count) 66 | break; 67 | } else { 68 | Logger.error('Failed to get URI from file.'); 69 | } 70 | } 71 | 72 | if (wallpaperResult.length < count) { 73 | Logger.warn('Returning less images than requested.', this); 74 | reject(wallpaperResult); 75 | return; 76 | } 77 | 78 | resolve(wallpaperResult); 79 | }); 80 | } 81 | 82 | /** 83 | * Copies a file from the filesystem to the destination folder. 84 | * 85 | * @param {HistoryEntry} historyEntry The historyEntry to fetch 86 | * @returns {Promise} unaltered HistoryEntry 87 | */ 88 | async fetchFile(historyEntry: HistoryEntry): Promise { 89 | const sourceFile = Gio.File.new_for_uri(historyEntry.source.imageDownloadUrl); 90 | const targetFile = Gio.File.new_for_path(historyEntry.path); 91 | 92 | // https://gjs.guide/guides/gio/file-operations.html#copying-and-moving-files 93 | if (!await sourceFile.copy_async(targetFile, Gio.FileCopyFlags.NONE, GLib.PRIORITY_DEFAULT, null, null)) 94 | throw new Error('Failed copying image.'); 95 | 96 | return historyEntry; 97 | } 98 | 99 | // https://gjs.guide/guides/gio/file-operations.html#recursively-deleting-a-directory 100 | /** 101 | * Walk recursively through a folder and retrieve a list of all images. 102 | * 103 | * This already checks for blocked filenames and omits them from the returned list. 104 | * 105 | * @param {Gio.File} directory Directory to scan 106 | * @returns {Gio.File[]} List of images 107 | */ 108 | private _listDirectory(directory: Gio.File): Gio.File[] { 109 | const iterator = directory.enumerate_children('standard::*', Gio.FileQueryInfoFlags.NONE, null); 110 | 111 | let files: Gio.File[] = []; 112 | while (true) { 113 | const info = iterator.next_file(null); 114 | 115 | if (info === null) 116 | break; 117 | 118 | const child = iterator.get_child(info); 119 | const type = info.get_file_type(); 120 | 121 | switch (type) { 122 | case Gio.FileType.DIRECTORY: 123 | files = files.concat(this._listDirectory(child)); 124 | break; 125 | 126 | default: 127 | break; 128 | } 129 | 130 | const contentType = info.get_content_type(); 131 | const filename = child.get_basename(); 132 | if (contentType?.startsWith('image/') && filename && !this._isImageBlocked(filename)) 133 | files.push(child); 134 | } 135 | 136 | return files; 137 | } 138 | } 139 | 140 | export {LocalFolderAdapter}; 141 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | RandomWallpaperGnome3 2 | ===================== 3 | 4 | Random Wallpapers for Gnome 3 is a gnome-shell extension that fetches a random wallpaper from an online source and sets it as desktop background. 5 | 6 | Install and try the extension at [extensions.gnome.org](https://extensions.gnome.org/extension/1040/random-wallpaper/). 7 | 8 | ![Screenshot](/assets/screenshot.png) 9 | 10 | ## Features 11 | 12 | * Various configurable wallpaper sources 13 | * [Unsplash](https://unsplash.com/) 14 | * [Wallhaven](https://wallhaven.cc/) 15 | * [Reddit](https://reddit.com) 16 | * Basically any JSON API/File ([Examples](https://github.com/ifl0w/RandomWallpaperGnome3/wiki/Generic-JSON-Source)) 17 | * Chromecast Images 18 | * NASA Picture of the day 19 | * Bing Picture of the day 20 | * Google Earth View 21 | * Local folders 22 | * Static URLs 23 | * Multiple sources to create a pool of sources 24 | * History of previous images 25 | * Save your favourite wallpaper 26 | * Add images to a block list 27 | * Set the lock screen background 28 | * Timer based renewal (Auto-Fetching) 29 | * Load a new wallpaper on startup 30 | * Pause the timer when desired 31 | * Support for multiple monitors using third party tools 32 | * [Hydra Paper](https://hydrapaper.gabmus.org/) 33 | * [Superpaper](https://github.com/hhannine/superpaper) 34 | * Execute a custom command after every new wallpaper ([Examples](https://github.com/ifl0w/RandomWallpaperGnome3/wiki/Post-commands)) 35 | 36 | ## Installation 37 | ### Release Archives 38 | Archives from the [release page](https://github.com/ifl0w/RandomWallpaperGnome3/releases) can be installed and uninstalled using the gnome-extensions command line tool with the commands below. 39 | ``` 40 | gnome-extensions install 41 | ``` 42 | 43 | ``` 44 | gnome-extensions uninstall randomwallpaper@iflow.space 45 | ``` 46 | 47 | ### Symlink to the Repository 48 | __Installing this way has following advantages:__ 49 | * Updating the extension using git (`git pull && ./build.sh`) 50 | * Switching between versions and branches using git (run `./build.sh` after switching branch). 51 | 52 | Requires [`blueprint-compiler`](https://repology.org/project/blueprint-compiler/versions) and [`npm`](https://repology.org/project/npm/versions) at install and update time. 53 | 54 | Clone the repository and run `./build.sh && ./install.sh` in the repository folder to make a symbolic link from the extensions folder to the git repository. 55 | This installation will depend on the repository folder, so do not delete the cloned folder. 56 | 57 | Then open the command prompt (Alt+F2) end enter `r` to restart the gnome session. 58 | In the case you are using Wayland, then no restart should be required. 59 | 60 | Now you should be able to activate the extension through the gnome-tweak-tool. 61 | 62 | To uninstall the extension, run `./install uninstall` or manually delete the corresponding symbolic link. 63 | 64 | ## Build From Source 65 | Requires [`blueprint-compiler`](https://repology.org/project/blueprint-compiler/versions) and [`npm`](https://repology.org/project/npm/versions) at install and update time. 66 | 67 | Clone or download the repository and copy the folder `randomwallpaper@iflow.space` in the repository to `$XDG_DATA_HOME/gnome-shell/extensions/` (usually `$HOME/.local/share/gnome-shell/extensions/`). 68 | Run `./build.sh` inside the repository. 69 | 70 | Then open the command prompt (Alt+F2) end enter `r` to restart the gnome session. 71 | In the case you are using Wayland, then no restart should be required. 72 | 73 | Now, you should be able to activate the extension through the gnome-tweak-tool. 74 | 75 | ## Debugging 76 | You can follow the output of the extension with `./debug.sh`. Information should be printed using the existing logger class but can also be printed with `global.log()` (not recommended). 77 | To debug the `prefs.js` use `./debug.sh prefs`. 78 | 79 | ## Compiling individual parts 80 | ### Schemas 81 | This can be done with the command: 82 | ~~~ 83 | glib-compile-schemas --targetdir="randomwallpaper@iflow.space/schemas/" "src/schemas" 84 | ~~~ 85 | 86 | ### UI 87 | Requires [`blueprint-compiler`](https://jwestman.pages.gitlab.gnome.org/blueprint-compiler/): 88 | ~~~ 89 | blueprint-compiler batch-compile "src/ui" "randomwallpaper@iflow.space/ui" "src"/ui/*.blp 90 | ~~~ 91 | 92 | ### TypeScript 93 | Requires [`npm`](https://repology.org/project/npm/versions): 94 | ~~~ 95 | npm install 96 | npx --silent tsc 97 | ~~~ 98 | 99 | ## Adding new sources 100 | 1. Build UI for settings using the [blueprint-compiler](https://jwestman.pages.gitlab.gnome.org/blueprint-compiler/) language in `src/ui/mySource.blp` - see [Workbench](https://apps.gnome.org/app/re.sonny.Workbench/) for a live preview editor. 101 | 1. Create and add a settings layout to the `src/schemas/….gschema.xml`. Also add your source to the `types` enum. 102 | 1. Create your logic hooking the settings in a `src/ui/mySource.ts` 103 | 1. Add the new source to `src/ui/sourceRow.ts:_getSettingsGroup()`, don't forget the import statement. 104 | 1. Create a adapter to read the settings and fetching the images and additional information in `src/adapter/mySource.ts` by extending the `BaseAdapter`. 105 | 1. Add your adapter to `src/wallpaperController.ts:_getRandomAdapter()`, don't forget the import statement. 106 | 107 | ## Support Me 108 | If you enjoy this extension and want to support the development, then feel free to buy me a coffee. :wink: :coffee: 109 | 110 | 111 | [![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg)](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=RBLX73X4DPS7A) 112 | -------------------------------------------------------------------------------- /types/misc/index.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | declare module 'sharedInternals' { 4 | // https://gitlab.gnome.org/GNOME/gnome-shell/-/blob/main/js/extensions/sharedInternals.js 5 | import type Gio from 'gi://Gio'; 6 | 7 | export class ExtensionBase { 8 | /** 9 | * @param {object} metadata - metadata passed in when loading the extension 10 | */ 11 | constructor(metadata: ExtensionMetadata) 12 | 13 | /** the metadata.json file, parsed as JSON */ 14 | readonly metadata: { 15 | 'settings-schema': string, 16 | uuid: string, 17 | name: string, 18 | version: string, 19 | 'semantic-version': string, 20 | url: string, 21 | description: string, 22 | url: strint, 23 | 'issue-url': string, 24 | // … 25 | }; 26 | 27 | /** the extension UUID */ 28 | readonly uuid: string; 29 | /** the extension directory */ 30 | readonly dir: Gio.File; 31 | /** the extension directory path */ 32 | readonly path: string; 33 | 34 | /** 35 | * Get a GSettings object for schema, using schema files in 36 | * extensionsdir/schemas. If schema is omitted, it is taken 37 | * from metadata['settings-schema']. 38 | * 39 | * @param {string=} schema - the GSettings schema id 40 | * 41 | * @returns {Gio.Settings} 42 | */ 43 | getSettings(schema?: string | undefined): Gio.Settings; 44 | 45 | /** 46 | * Initialize Gettext to load translations from extensionsdir/locale. If 47 | * domain is not provided, it will be taken from metadata['gettext-domain'] 48 | * if provided, or use the UUID 49 | * 50 | * @param {string=} domain - the gettext domain to use 51 | */ 52 | initTranslations(domain?: string | undefined): void; 53 | 54 | /** 55 | * Translate `str` using the extension's gettext domain 56 | * 57 | * @param {string} str - the string to translate 58 | * 59 | * @returns {string} the translated string 60 | */ 61 | gettext(str: string): string; 62 | 63 | /** 64 | * Translate `str` and choose plural form using the extension's 65 | * gettext domain 66 | * 67 | * @param {string} str - the string to translate 68 | * @param {string} strPlural - the plural form of the string 69 | * @param {number} n - the quantity for which translation is needed 70 | * 71 | * @returns {string} the translated string 72 | */ 73 | ngettext(str: string, strPlural: string, n: number): string; 74 | 75 | /** 76 | * Translate `str` in the context of `context` using the extension's 77 | * gettext domain 78 | * 79 | * @param {string} context - context to disambiguate `str` 80 | * @param {string} str - the string to translate 81 | * 82 | * @returns {string} the translated string 83 | */ 84 | pgettext(context: string, str: string): string; 85 | 86 | /** lookup the extension object from any module by using the static method */ 87 | static lookupByUUID(uuid: string): ExtensionBase | null; 88 | /** lookup the extension object from any module by using the static method */ 89 | static lookupByURL(url: string): ExtensionBase | null; 90 | } 91 | } 92 | 93 | declare module 'resource:///org/gnome/shell/extensions/extension.js' { 94 | import {ExtensionBase} from 'sharedInternals'; 95 | 96 | /** 97 | * An object describing the extension and various properties available for extensions to use. 98 | * 99 | * Some properties may only be available in some versions of GNOME Shell, while others may not be meant for extension authors to use. All properties should be considered read-only. 100 | */ 101 | export class Extension extends ExtensionBase { 102 | constructor(metadata: ExtensionMetadata) { 103 | super(metadata); 104 | } 105 | 106 | /** the extension type; `1` for system, `2` for user */ 107 | readonly type: number; 108 | /** an error message or an empty string if no error */ 109 | readonly error: string; 110 | /** whether the extension has a preferences dialog */ 111 | readonly hasPrefs: boolean; 112 | /** whether the extension has a pending update */ 113 | readonly hasUpdate: boolean; 114 | /** whether the extension can be enabled/disabled */ 115 | readonly canChange: boolean; 116 | /** a list of supported session modes */ 117 | readonly sessionModes: string[]; 118 | 119 | /** 120 | * Open the extension's preferences window 121 | */ 122 | openPreferences(): void; 123 | } 124 | } 125 | 126 | declare module 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js' { 127 | import type Adw from 'gi://Adw'; 128 | import type Gtk from 'gi://Gtk'; 129 | 130 | import {ExtensionBase} from 'sharedInternals'; 131 | 132 | export class ExtensionPreferences extends ExtensionBase { 133 | constructor(metadata: ExtensionMetadata) { 134 | super(metadata); 135 | } 136 | 137 | /** 138 | * Fill the preferences window with preferences. 139 | * 140 | * The default implementation adds the widget 141 | * returned by getPreferencesWidget(). 142 | * 143 | * @param {Adw.PreferencesWindow} window - the preferences window 144 | */ 145 | fillPreferencesWindow(window: Adw.PreferencesWindow): void; 146 | 147 | /** 148 | * Get the single widget that implements 149 | * the extension's preferences. 150 | * 151 | * @returns {Gtk.Widget} 152 | */ 153 | getPreferencesWidget(): Gtk.Widget; 154 | } 155 | } 156 | 157 | declare module 'resource:///org/gnome/shell/misc/config.js' { 158 | export const PACKAGE_VERSION: string; 159 | } 160 | -------------------------------------------------------------------------------- /src/ui/unsplash.ts: -------------------------------------------------------------------------------- 1 | import Adw from 'gi://Adw'; 2 | import Gio from 'gi://Gio'; 3 | import GLib from 'gi://GLib'; 4 | import GObject from 'gi://GObject'; 5 | import Gtk from 'gi://Gtk'; 6 | 7 | import * as Settings from './../settings.js'; 8 | 9 | // Generated code produces a no-shadow rule error 10 | /* eslint-disable */ 11 | enum ConstraintType { 12 | UNCONSTRAINED, 13 | USER, 14 | USERS_LIKES, 15 | COLLECTION_ID, 16 | } 17 | /* eslint-enable */ 18 | 19 | // FIXME: Generated static class code produces a no-unused-expressions rule error 20 | /* eslint-disable no-unused-expressions */ 21 | 22 | /** 23 | * Subclass containing the preferences for Unsplash adapter 24 | */ 25 | class UnsplashSettings extends Adw.PreferencesPage { 26 | static [GObject.GTypeName] = 'UnsplashSettings'; 27 | // @ts-expect-error Gtk.template is not in the type definitions files yet 28 | static [Gtk.template] = GLib.uri_resolve_relative(import.meta.url, './unsplash.ui', GLib.UriFlags.NONE); 29 | // @ts-expect-error Gtk.internalChildren is not in the type definitions files yet 30 | static [Gtk.internalChildren] = [ 31 | 'constraint_type', 32 | 'constraint_value', 33 | 'featured_only', 34 | 'image_height', 35 | 'image_width', 36 | 'keyword', 37 | ]; 38 | 39 | // InternalChildren 40 | private _constraint_type!: Adw.ComboRow; 41 | private _constraint_value!: Adw.EntryRow; 42 | private _featured_only!: Gtk.Switch; 43 | private _image_height!: Gtk.Adjustment; 44 | private _image_width!: Gtk.Adjustment; 45 | private _keyword!: Adw.EntryRow; 46 | 47 | static { 48 | GObject.registerClass(this); 49 | } 50 | 51 | // This list is the same across all rows 52 | static _stringList: Gtk.StringList; 53 | 54 | private _settings; 55 | 56 | /** 57 | * Craft a new adapter using an unique ID. 58 | * 59 | * Previously saved settings will be used if the adapter and ID match. 60 | * 61 | * @param {string} id Unique ID 62 | */ 63 | constructor(id: string) { 64 | super(undefined); 65 | 66 | const path = `${Settings.RWG_SETTINGS_SCHEMA_PATH}/sources/unsplash/${id}/`; 67 | this._settings = new Settings.Settings(Settings.RWG_SETTINGS_SCHEMA_SOURCES_UNSPLASH, path); 68 | 69 | if (!UnsplashSettings._stringList) 70 | UnsplashSettings._stringList = Gtk.StringList.new(getConstraintTypeNameList()); 71 | 72 | this._constraint_type.model = UnsplashSettings._stringList; 73 | 74 | this._settings.bind('constraint-type', 75 | this._constraint_type, 76 | 'selected', 77 | Gio.SettingsBindFlags.DEFAULT); 78 | this._settings.bind('constraint-value', 79 | this._constraint_value, 80 | 'text', 81 | Gio.SettingsBindFlags.DEFAULT); 82 | this._settings.bind('featured-only', 83 | this._featured_only, 84 | 'active', 85 | Gio.SettingsBindFlags.DEFAULT); 86 | this._settings.bind('image-width', 87 | this._image_width, 88 | 'value', 89 | Gio.SettingsBindFlags.DEFAULT); 90 | this._settings.bind('image-height', 91 | this._image_height, 92 | 'value', 93 | Gio.SettingsBindFlags.DEFAULT); 94 | this._settings.bind('keyword', 95 | this._keyword, 96 | 'text', 97 | Gio.SettingsBindFlags.DEFAULT); 98 | 99 | this._unsplashUnconstrained(this._constraint_type, true, this._featured_only); 100 | this._unsplashUnconstrained(this._constraint_type, false, this._constraint_value); 101 | this._constraint_type.connect('notify::selected', (comboRow: Adw.ComboRow) => { 102 | this._unsplashUnconstrained(comboRow, true, this._featured_only); 103 | this._unsplashUnconstrained(comboRow, false, this._constraint_value); 104 | 105 | this._featured_only.set_active(false); 106 | }); 107 | } 108 | 109 | /** 110 | * Switch element sensitivity based on a selected combo row entry. 111 | * 112 | * @param {Adw.ComboRow} comboRow ComboRow with selected entry 113 | * @param {boolean} enable Whether to make the element sensitive 114 | * @param {Gtk.Widget} targetElement The element to target the sensitivity setting 115 | */ 116 | private _unsplashUnconstrained(comboRow: Adw.ComboRow, enable: boolean, targetElement: Gtk.Widget): void { 117 | if (comboRow.selected === 0) 118 | targetElement.set_sensitive(enable); 119 | else 120 | targetElement.set_sensitive(!enable); 121 | } 122 | 123 | /** 124 | * Clear all config options associated to this specific adapter. 125 | */ 126 | clearConfig(): void { 127 | this._settings.resetSchema(); 128 | } 129 | } 130 | 131 | /** 132 | * Retrieve the human readable enum name. 133 | * 134 | * @param {ConstraintType} type The type to name 135 | * @returns {string} Name 136 | */ 137 | function _getConstraintTypeName(type: ConstraintType): string { 138 | let name: string; 139 | 140 | switch (type) { 141 | case ConstraintType.UNCONSTRAINED: 142 | name = 'Unconstrained'; 143 | break; 144 | case ConstraintType.USER: 145 | name = 'User'; 146 | break; 147 | case ConstraintType.USERS_LIKES: 148 | name = 'User\'s Likes'; 149 | break; 150 | case ConstraintType.COLLECTION_ID: 151 | name = 'Collection ID'; 152 | break; 153 | 154 | default: 155 | name = 'Constraint type name not found'; 156 | break; 157 | } 158 | 159 | return name; 160 | } 161 | 162 | /** 163 | * Get a list of human readable enum entries. 164 | * 165 | * @returns {string[]} Array with key names 166 | */ 167 | function getConstraintTypeNameList(): string[] { 168 | const list: string[] = []; 169 | 170 | const values = Object.values(ConstraintType).filter(v => !isNaN(Number(v))); 171 | for (const i of values) 172 | list.push(_getConstraintTypeName(i as ConstraintType)); 173 | 174 | return list; 175 | } 176 | 177 | export {UnsplashSettings}; 178 | -------------------------------------------------------------------------------- /assets/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 22 | 24 | 25 | 27 | image/svg+xml 28 | 30 | 31 | 32 | 33 | 34 | 36 | 39 | 43 | 47 | 48 | 57 | 61 | 66 | 67 | 68 | 92 | 98 | 104 | 107 | 112 | 113 | 114 | -------------------------------------------------------------------------------- /src/adapter/reddit.ts: -------------------------------------------------------------------------------- 1 | import * as SettingsModule from './../settings.js'; 2 | import * as Utils from './../utils.js'; 3 | 4 | import {BaseAdapter} from './../adapter/baseAdapter.js'; 5 | import {HistoryEntry} from './../history.js'; 6 | import {Logger} from '../logger.js'; 7 | 8 | interface RedditResponse { 9 | data: { 10 | children: RedditSubmission[], 11 | } 12 | } 13 | 14 | interface RedditSubmission { 15 | data: { 16 | post_hint: string, 17 | over_18: boolean, 18 | subreddit_name_prefixed: string, 19 | permalink: string, 20 | preview: { 21 | images: { 22 | source: { 23 | width: number, 24 | height: number, 25 | url: string, 26 | } 27 | }[] 28 | } 29 | } 30 | } 31 | 32 | /** 33 | * Adapter for Reddit image sources. 34 | */ 35 | class RedditAdapter extends BaseAdapter { 36 | /** 37 | * Create a new Reddit adapter. 38 | * 39 | * @param {string} id Unique ID 40 | * @param {string} name Custom name of this adapter 41 | */ 42 | constructor(id: string, name: string) { 43 | super({ 44 | defaultName: 'Reddit', 45 | id, 46 | name, 47 | schemaID: SettingsModule.RWG_SETTINGS_SCHEMA_SOURCES_REDDIT, 48 | schemaPath: `${SettingsModule.RWG_SETTINGS_SCHEMA_PATH}/sources/reddit/${id}/`, 49 | }); 50 | } 51 | 52 | /** 53 | * Replace an HTML & with an actual & symbol. 54 | * 55 | * @param {string} string String to replace in 56 | * @returns {string} String with replaced symbols 57 | */ 58 | private _ampDecode(string: string): string { 59 | return string.replace(/&/g, '&'); 60 | } 61 | 62 | /** 63 | * Retrieves new URLs for images and crafts new HistoryEntries. 64 | * 65 | * @param {number} count Number of requested wallpaper 66 | * @returns {HistoryEntry[]} Array of crafted HistoryEntries 67 | * @throws {HistoryEntry[]} Array of crafted historyEntries, can be empty 68 | */ 69 | async requestRandomImage(count: number): Promise { 70 | const wallpaperResult: HistoryEntry[] = []; 71 | const subreddits = this._settings.getString('subreddits').split(',').map(s => s.trim()).join('+'); 72 | const require_sfw = this._settings.getBoolean('allow-sfw'); 73 | 74 | const url = encodeURI(`https://www.reddit.com/r/${subreddits}.json`); 75 | const message = this._bowl.newGetMessage(url); 76 | 77 | let response_body; 78 | try { 79 | const response_body_bytes = await this._bowl.send_and_receive(message); 80 | response_body = JSON.parse(new TextDecoder().decode(response_body_bytes)) as unknown; 81 | } catch (error) { 82 | Logger.error(`Could not parse response for ${url}!\n${String(error)}`, this); 83 | throw wallpaperResult; 84 | } 85 | 86 | if (!this._isRedditResponse(response_body)) { 87 | Logger.error('Unexpected response', this); 88 | throw wallpaperResult; 89 | } 90 | 91 | const filteredSubmissions = response_body.data.children.filter(child => { 92 | if (child.data.post_hint !== 'image') 93 | return false; 94 | if (require_sfw) 95 | return child.data.over_18 === false; 96 | 97 | const minWidth = this._settings.getInt('min-width'); 98 | const minHeight = this._settings.getInt('min-height'); 99 | if (child.data.preview.images[0].source.width < minWidth) 100 | return false; 101 | if (child.data.preview.images[0].source.height < minHeight) 102 | return false; 103 | 104 | const imageRatio1 = this._settings.getInt('image-ratio1'); 105 | const imageRatio2 = this._settings.getInt('image-ratio2'); 106 | if (child.data.preview.images[0].source.width / imageRatio1 * imageRatio2 < child.data.preview.images[0].source.height) 107 | return false; 108 | return true; 109 | }); 110 | 111 | if (filteredSubmissions.length === 0) { 112 | Logger.error('No suitable submissions found!', this); 113 | throw wallpaperResult; 114 | } 115 | 116 | for (let i = 0; i < filteredSubmissions.length && wallpaperResult.length < count; i++) { 117 | const random = Utils.getRandomNumber(filteredSubmissions.length); 118 | const submission = filteredSubmissions[random].data; 119 | const imageDownloadUrl = this._ampDecode(submission.preview.images[0].source.url); 120 | 121 | if (this._isImageBlocked(Utils.fileName(imageDownloadUrl))) 122 | continue; 123 | 124 | const historyEntry = new HistoryEntry(null, this._sourceName, imageDownloadUrl); 125 | historyEntry.source.sourceUrl = `https://www.reddit.com/${submission.subreddit_name_prefixed}`; 126 | historyEntry.source.imageLinkUrl = `https://www.reddit.com/${submission.permalink}`; 127 | 128 | if (!this._includesWallpaper(wallpaperResult, historyEntry.source.imageDownloadUrl)) 129 | wallpaperResult.push(historyEntry); 130 | } 131 | 132 | if (wallpaperResult.length < count) { 133 | Logger.warn('Returning less images than requested.', this); 134 | throw wallpaperResult; 135 | } 136 | 137 | return wallpaperResult; 138 | } 139 | 140 | /** 141 | * Check if the response is expected to be a response by Reddit. 142 | * 143 | * Primarily in use for typescript typing. 144 | * 145 | * @param {unknown} object Unknown object to narrow down 146 | * @returns {boolean} Whether the response is from Reddit 147 | */ 148 | private _isRedditResponse(object: unknown): object is RedditResponse { 149 | if (typeof object === 'object' && 150 | object && 151 | 'data' in object && 152 | typeof object.data === 'object' && 153 | object.data && 154 | 'children' in object.data && 155 | Array.isArray(object.data.children) 156 | ) 157 | return true; 158 | 159 | return false; 160 | } 161 | } 162 | 163 | export {RedditAdapter}; 164 | -------------------------------------------------------------------------------- /src/manager/externalWallpaperManager.ts: -------------------------------------------------------------------------------- 1 | import Gio from 'gi://Gio'; 2 | import GLib from 'gi://GLib'; 3 | 4 | import * as Utils from '../utils.js'; 5 | 6 | import {DefaultWallpaperManager} from './defaultWallpaperManager.js'; 7 | import {Mode, WallpaperManager} from './wallpaperManager.js'; 8 | import {Logger} from '../logger.js'; 9 | 10 | /** 11 | * Abstract base class for external manager to implement. 12 | */ 13 | abstract class ExternalWallpaperManager extends WallpaperManager { 14 | public canHandleMultipleImages = true; 15 | 16 | protected static _command: string[] | null = null; 17 | protected abstract readonly _possibleCommands: string[]; 18 | 19 | private _cancellable: Gio.Cancellable | null = null; 20 | 21 | /** 22 | * Checks if the current manager is available in the `$PATH`. 23 | * 24 | * @returns {boolean} Whether the manager is found 25 | */ 26 | isAvailable(): boolean { 27 | if (ExternalWallpaperManager._command !== null) 28 | return true; 29 | 30 | for (const command of this._possibleCommands) { 31 | const path = GLib.find_program_in_path(command); 32 | 33 | if (path) { 34 | ExternalWallpaperManager._command = [path]; 35 | break; 36 | } 37 | } 38 | 39 | return ExternalWallpaperManager._command !== null; 40 | } 41 | 42 | /** 43 | * Set the wallpapers for a given mode. 44 | * 45 | * @param {string[]} wallpaperPaths Array of paths to the desired wallpapers, should match the display count 46 | * @param {Mode} mode Enum indicating what images to change 47 | */ 48 | async setWallpaper(wallpaperPaths: string[], mode: Mode = Mode.BACKGROUND): Promise { 49 | if (wallpaperPaths.length < 1) 50 | throw new Error('Empty wallpaper array'); 51 | 52 | // Cancel already running processes before setting new images 53 | this._cancelRunning(); 54 | 55 | // Fallback to default manager, all currently supported external manager don't support setting single images 56 | if (wallpaperPaths.length === 1 || (mode === Mode.BACKGROUND_AND_LOCKSCREEN_INDEPENDENT && wallpaperPaths.length === 2)) { 57 | const promises = []; 58 | 59 | if (mode === Mode.BACKGROUND || mode === Mode.BACKGROUND_AND_LOCKSCREEN) 60 | promises.push(DefaultWallpaperManager.setSingleBackground(`file://${wallpaperPaths[0]}`, this._backgroundSettings)); 61 | 62 | if (mode === Mode.LOCKSCREEN || mode === Mode.BACKGROUND_AND_LOCKSCREEN) 63 | promises.push(DefaultWallpaperManager.setSingleLockScreen(`file://${wallpaperPaths[0]}`, this._backgroundSettings, this._screensaverSettings)); 64 | 65 | if (mode === Mode.BACKGROUND_AND_LOCKSCREEN_INDEPENDENT) { 66 | if (wallpaperPaths.length < 2) 67 | throw new Error('Not enough wallpaper'); 68 | 69 | // Half the images for the background 70 | promises.push(DefaultWallpaperManager.setSingleBackground(`file://${wallpaperPaths[0]}`, this._backgroundSettings)); 71 | // Half the images for the lock screen 72 | promises.push(DefaultWallpaperManager.setSingleLockScreen(`file://${wallpaperPaths[1]}`, this._backgroundSettings, this._screensaverSettings)); 73 | } 74 | 75 | await Promise.allSettled(promises); 76 | return; 77 | } 78 | 79 | /** 80 | * Don't run these concurrently! 81 | * External manager may need to shove settings around to circumvent the fact the manager writes multiple settings on its own. 82 | * These are called in this fixed order so external manager can rely on functions ran previously. 83 | */ 84 | 85 | if (mode === Mode.BACKGROUND || mode === Mode.BACKGROUND_AND_LOCKSCREEN) 86 | await this._setBackground(wallpaperPaths); 87 | 88 | if (mode === Mode.LOCKSCREEN) 89 | await this._setLockScreen(wallpaperPaths); 90 | 91 | if (mode === Mode.BACKGROUND_AND_LOCKSCREEN) 92 | await this._setLockScreenAfterBackground(wallpaperPaths); 93 | 94 | if (mode === Mode.BACKGROUND_AND_LOCKSCREEN_INDEPENDENT) { 95 | await this._setBackground(wallpaperPaths.slice(0, wallpaperPaths.length / 2)); 96 | await this._setLockScreen(wallpaperPaths.slice(wallpaperPaths.length / 2)); 97 | } 98 | } 99 | 100 | /** 101 | * Forcefully stop a previously started manager process. 102 | */ 103 | private _cancelRunning(): void { 104 | if (this._cancellable === null) 105 | return; 106 | 107 | Logger.debug('Stopping manager process.', this); 108 | this._cancellable.cancel(); 109 | this._cancellable = null; 110 | } 111 | 112 | /** 113 | * Wrapper around calling the program command together with arguments. 114 | * 115 | * @param {string[]} commandArguments Arguments to append 116 | */ 117 | protected async _runExternal(commandArguments: string[]): Promise { 118 | // Cancel already running processes before starting new ones 119 | this._cancelRunning(); 120 | 121 | if (!ExternalWallpaperManager._command || ExternalWallpaperManager._command.length < 1) 122 | throw new Error('Command empty!'); 123 | 124 | // Needs a copy here 125 | const command = ExternalWallpaperManager._command.concat(commandArguments); 126 | 127 | this._cancellable = new Gio.Cancellable(); 128 | 129 | Logger.debug(`Running command: ${command.toString()}`, this); 130 | await Utils.execCheck(command, this._cancellable); 131 | 132 | this._cancellable = null; 133 | } 134 | 135 | /** 136 | * Sync the lock screen to the background. 137 | * 138 | * This function exists to save compute time on identical background and lock screen images. 139 | * 140 | * @param {string[]} _wallpaperPaths Unused array of strings to image files 141 | * @returns {Promise} Only resolves 142 | */ 143 | protected _setLockScreenAfterBackground(_wallpaperPaths: string[]): Promise { 144 | Utils.setPictureUriOfSettingsObject(this._screensaverSettings, this._backgroundSettings.getString('picture-uri')); 145 | return Promise.resolve(); 146 | } 147 | } 148 | 149 | export {ExternalWallpaperManager}; 150 | -------------------------------------------------------------------------------- /src/adapter/unsplash.ts: -------------------------------------------------------------------------------- 1 | import * as SettingsModule from './../settings.js'; 2 | import * as Utils from './../utils.js'; 3 | 4 | import {BaseAdapter} from './../adapter/baseAdapter.js'; 5 | import {HistoryEntry} from './../history.js'; 6 | import {Logger} from './../logger.js'; 7 | 8 | /** How many times the service should be queried at maximum. */ 9 | const MAX_SERVICE_RETRIES = 5; 10 | 11 | // Generated code produces a no-shadow rule error 12 | /* eslint-disable */ 13 | enum ConstraintType { 14 | UNCONSTRAINED, 15 | USER, 16 | USERS_LIKES, 17 | COLLECTION_ID, 18 | } 19 | /* eslint-enable */ 20 | 21 | /** 22 | * Adapter for image sources using Unsplash. 23 | */ 24 | class UnsplashAdapter extends BaseAdapter { 25 | private _sourceUrl = 'https://source.unsplash.com'; 26 | 27 | // default query options 28 | private _options = { 29 | 'query': '', 30 | 'w': 1920, 31 | 'h': 1080, 32 | 'featured': true, 33 | 'constraintType': 0, 34 | 'constraintValue': '', 35 | }; 36 | 37 | /** 38 | * Create a new Unsplash adapter. 39 | * 40 | * @param {string} id Unique ID 41 | * @param {string} name Custom name of this adapter 42 | */ 43 | constructor(id: string | null, name: string | null) { 44 | super({ 45 | defaultName: 'Unsplash', 46 | id: id ?? '-1', 47 | name, 48 | schemaID: SettingsModule.RWG_SETTINGS_SCHEMA_SOURCES_UNSPLASH, 49 | schemaPath: `${SettingsModule.RWG_SETTINGS_SCHEMA_PATH}/sources/unsplash/${id ?? '-1'}/`, 50 | }); 51 | } 52 | 53 | /** 54 | * Retrieves a new URL for an image and crafts new HistoryEntry. 55 | * 56 | * @returns {HistoryEntry} Crafted HistoryEntry 57 | * @throws {Error} Error with description 58 | */ 59 | private async _getHistoryEntry(): Promise { 60 | this._readOptionsFromSettings(); 61 | const optionsString = this._generateOptionsString(); 62 | 63 | let url = `https://source.unsplash.com${optionsString}`; 64 | url = encodeURI(url); 65 | 66 | Logger.debug(`Unsplash request to: ${url}`, this); 67 | 68 | const message = this._bowl.newGetMessage(url); 69 | 70 | // unsplash redirects to actual file; we only want the file location 71 | message.set_flags(this._bowl.MessageFlags.NO_REDIRECT); 72 | 73 | await this._bowl.send_and_receive(message); 74 | 75 | // expecting redirect 76 | if (message.status_code !== 302) 77 | throw new Error('Unexpected response status code (expected 302)'); 78 | 79 | const imageLinkUrl = message.response_headers.get_one('Location'); 80 | if (!imageLinkUrl) 81 | throw new Error('No image link in response.'); 82 | 83 | 84 | if (this._isImageBlocked(Utils.fileName(imageLinkUrl))) { 85 | // Abort and try again 86 | throw new Error('Image blocked'); 87 | } 88 | 89 | const historyEntry = new HistoryEntry(null, this._sourceName, imageLinkUrl); 90 | historyEntry.source.sourceUrl = this._sourceUrl; 91 | historyEntry.source.imageLinkUrl = imageLinkUrl; 92 | 93 | return historyEntry; 94 | } 95 | 96 | /** 97 | * Retrieves new URLs for images and crafts new HistoryEntries. 98 | * 99 | * Can internally query the request URL multiple times because only one image will be reported back. 100 | * 101 | * @param {number} count Number of requested wallpaper 102 | * @returns {HistoryEntry[]} Array of crafted HistoryEntries 103 | * @throws {HistoryEntry[]} Array of crafted historyEntries, can be empty 104 | */ 105 | async requestRandomImage(count: number): Promise { 106 | const wallpaperResult: HistoryEntry[] = []; 107 | 108 | for (let i = 0; i < MAX_SERVICE_RETRIES + count && wallpaperResult.length < count; i++) { 109 | try { 110 | // This should run sequentially 111 | // eslint-disable-next-line no-await-in-loop 112 | const historyEntry = await this._getHistoryEntry(); 113 | 114 | if (!this._includesWallpaper(wallpaperResult, historyEntry.source.imageDownloadUrl)) 115 | wallpaperResult.push(historyEntry); 116 | } catch (error) { 117 | Logger.warn('Failed getting image.', this); 118 | Logger.warn(error, this); 119 | // Do not escalate yet, try again 120 | } 121 | 122 | // Image blocked, try again 123 | } 124 | 125 | if (wallpaperResult.length < count) { 126 | Logger.warn('Returning less images than requested.', this); 127 | throw wallpaperResult; 128 | } 129 | 130 | return wallpaperResult; 131 | } 132 | 133 | /** 134 | * Create an option string based on user settings. 135 | * 136 | * Does not refresh settings itself. 137 | * 138 | * @returns {string} Options string 139 | */ 140 | private _generateOptionsString(): string { 141 | const options = this._options; 142 | let optionsString = ''; 143 | 144 | switch (options.constraintType) { 145 | case ConstraintType.USER: 146 | optionsString = `/user/${options.constraintValue}/`; 147 | break; 148 | case ConstraintType.USERS_LIKES: 149 | optionsString = `/user/${options.constraintValue}/likes/`; 150 | break; 151 | case ConstraintType.COLLECTION_ID: 152 | optionsString = `/collection/${options.constraintValue}/`; 153 | break; 154 | default: 155 | if (options.featured) 156 | optionsString = '/featured/'; 157 | else 158 | optionsString = '/random/'; 159 | } 160 | 161 | if (options.w && options.h) 162 | optionsString += `${options.w}x${options.h}`; 163 | 164 | 165 | if (options.query) { 166 | const q = options.query.replace(/\W/, ','); 167 | optionsString += `?${q}`; 168 | } 169 | 170 | return optionsString; 171 | } 172 | 173 | /** 174 | * Freshly read the user settings options. 175 | */ 176 | private _readOptionsFromSettings(): void { 177 | this._options.w = this._settings.getInt('image-width'); 178 | this._options.h = this._settings.getInt('image-height'); 179 | 180 | this._options.constraintType = this._settings.getInt('constraint-type'); 181 | this._options.constraintValue = this._settings.getString('constraint-value'); 182 | 183 | const keywords = this._settings.getString('keyword').split(','); 184 | if (keywords.length > 0) { 185 | const randomKeyword = keywords[Utils.getRandomNumber(keywords.length)]; 186 | this._options.query = randomKeyword.trim(); 187 | } 188 | 189 | this._options.featured = this._settings.getBoolean('featured-only'); 190 | } 191 | } 192 | 193 | export { 194 | UnsplashAdapter, 195 | ConstraintType 196 | }; 197 | -------------------------------------------------------------------------------- /src/ui/pageGeneral.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | Adw.PreferencesPage page_general { 5 | title: _("General"); 6 | icon-name: "preferences-system-symbolic"; 7 | 8 | Adw.PreferencesGroup { 9 | Adw.ActionRow request_new_wallpaper { 10 | title: _("Request New Wallpaper"); 11 | activatable: true; 12 | 13 | styles [ 14 | "suggested-action", 15 | "title-3", 16 | ] 17 | 18 | // I don't know how to center the title so just overwrite it with a label 19 | child: Label { 20 | label: _("Request New Wallpaper"); 21 | height-request: 50; 22 | }; 23 | } 24 | } 25 | 26 | Adw.PreferencesGroup { 27 | title: _("Wallpaper Settings"); 28 | 29 | Adw.ComboRow combo_background_type { 30 | title: _("Change Type"); 31 | use-subtitle: true; 32 | } 33 | 34 | Adw.ComboRow combo_scaling_mode { 35 | title: _("Wallpaper Scaling Mode"); 36 | use-subtitle: true; 37 | } 38 | 39 | Adw.ActionRow multiple_displays_row { 40 | title: _("Different Wallpapers on Multiple Displays"); 41 | subtitle: _("Requires HydraPaper or Superpaper."); 42 | sensitive: false; 43 | 44 | Switch enable_multiple_displays { 45 | valign: center; 46 | } 47 | } 48 | 49 | Adw.EntryRow general_post_command { 50 | title: _("Run post-command - available variables: %wallpaper_path%"); 51 | } 52 | } 53 | 54 | Adw.PreferencesGroup { 55 | title: _("History Settings"); 56 | 57 | header-suffix: Box { 58 | spacing: 5; 59 | 60 | Button open_wallpaper_folder { 61 | Adw.ButtonContent { 62 | icon-name: "folder-open-symbolic"; 63 | label: _("Open"); 64 | } 65 | } 66 | 67 | Button clear_history { 68 | Adw.ButtonContent { 69 | icon-name: "user-trash-symbolic"; 70 | label: _("Delete"); 71 | } 72 | 73 | styles [ 74 | "destructive-action", 75 | ] 76 | } 77 | }; 78 | 79 | Adw.EntryRow row_favorites_folder{ 80 | title: _("Save for Later Folder"); 81 | 82 | Button button_favorites_folder { 83 | valign: center; 84 | 85 | Adw.ButtonContent { 86 | icon-name: "folder-open-symbolic"; 87 | } 88 | } 89 | } 90 | 91 | Adw.ActionRow { 92 | title: _("History Length"); 93 | subtitle: _("The number of wallpapers that will be shown in the history and stored in the wallpaper folder of this extension."); 94 | 95 | SpinButton { 96 | valign: center; 97 | numeric: true; 98 | 99 | adjustment: Adjustment history_length { 100 | lower: 1; 101 | upper: 100; 102 | value: 10; 103 | step-increment: 1; 104 | page-increment: 10; 105 | }; 106 | } 107 | } 108 | } 109 | 110 | Adw.PreferencesGroup { 111 | title: "Auto-Fetching"; 112 | 113 | Adw.ExpanderRow af_switch { 114 | title: _("Auto-Fetching"); 115 | subtitle: _("Automatically fetch new wallpapers based on an interval."); 116 | show-enable-switch: true; 117 | 118 | Adw.ActionRow { 119 | title: _("Hours"); 120 | 121 | Scale duration_slider_hours { 122 | draw-value: true; 123 | orientation: horizontal; 124 | hexpand: true; 125 | digits: 0; 126 | 127 | adjustment: Adjustment duration_hours { 128 | value: 1; 129 | step-increment: 1; 130 | page-increment: 10; 131 | lower: 0; 132 | upper: 23; 133 | }; 134 | } 135 | } 136 | 137 | Adw.ActionRow { 138 | title: _("Minutes"); 139 | 140 | Scale duration_slider_minutes { 141 | draw-value: true; 142 | orientation: horizontal; 143 | hexpand: true; 144 | digits: 0; 145 | 146 | adjustment: Adjustment duration_minutes { 147 | value: 30; 148 | step-increment: 1; 149 | page-increment: 10; 150 | lower: 1; 151 | upper: 59; 152 | }; 153 | } 154 | } 155 | } 156 | 157 | Adw.ActionRow { 158 | title: _("Fetch on Startup"); 159 | subtitle: _("Fetch a new wallpaper during the startup of the extension (i.e., after login, or when enabling the extension).\nIMPORTANT: Do not enable this feature if you observe crashes when requesting new wallpapers because your system could crash on startup! In case you run into this issue, you might have to disable the extension or this feature from the commandline."); 160 | 161 | Switch fetch_on_startup { 162 | valign: center; 163 | } 164 | } 165 | } 166 | 167 | Adw.PreferencesGroup { 168 | title: _("General Settings"); 169 | 170 | Adw.ActionRow { 171 | title: _("Hide Panel Icon"); 172 | subtitle: _("You won't be able to access the history and the settings through the panel menu. Enabling this option currently is only reasonable in conjunction with the Auto-Fetching feature.\nOnly enable this option if you know how to open the settings without the panel icon!"); 173 | 174 | Switch hide_panel_icon { 175 | valign: center; 176 | } 177 | } 178 | 179 | Adw.ActionRow { 180 | title: _("Show Notifications"); 181 | subtitle: _("System notifications will be displayed to provide information, such as when a new wallpaper is set."); 182 | 183 | Switch show_notifications { 184 | valign: center; 185 | } 186 | } 187 | 188 | Adw.ActionRow { 189 | title: _("Disable Hover Preview"); 190 | subtitle: _("Disable the desktop preview of the background while hovering the history items. Try enabling if you encounter crashes or lags of the gnome-shell while using the extension."); 191 | 192 | Switch disable_hover_preview { 193 | valign: center; 194 | } 195 | } 196 | 197 | Adw.ComboRow log_level { 198 | title: _("Log Level"); 199 | subtitle: _("Set the tier of warnings appearing in the journal"); 200 | } 201 | 202 | Adw.ActionRow open_about { 203 | title: _("About"); 204 | activatable: true; 205 | } 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /src/manager/hydraPaper.ts: -------------------------------------------------------------------------------- 1 | import Gio from 'gi://Gio'; 2 | 3 | import * as Utils from '../utils.js'; 4 | import {Settings} from './../settings.js'; 5 | 6 | import {ExternalWallpaperManager} from './externalWallpaperManager.js'; 7 | 8 | // https://gjs.guide/guides/gjs/asynchronous-programming.html#promisify-helper 9 | Gio._promisify(Gio.Subprocess.prototype, 'communicate_utf8_async', 'communicate_utf8_finish'); 10 | 11 | /** 12 | * Wrapper for HydraPaper using it as a manager. 13 | */ 14 | class HydraPaper extends ExternalWallpaperManager { 15 | protected readonly _possibleCommands = ['hydrapaper', 'org.gabmus.hydrapaper']; 16 | 17 | /** 18 | * We have to know if HydraPaper is in a version >= 3.3.2 19 | * With that version the behavior changed to automatic light/dark mode detection. 20 | */ 21 | private static _versionIsOld?: boolean; 22 | 23 | /** 24 | * Sets the background image in light and dark mode. 25 | * 26 | * @param {string[]} wallpaperPaths Array of strings to image files 27 | */ 28 | protected async _setBackground(wallpaperPaths: string[]): Promise { 29 | await this._createCommandAndRun(wallpaperPaths); 30 | await this._syncColorModes(this._backgroundSettings, this._backgroundSettings); 31 | } 32 | 33 | /** 34 | * Sets the lock screen image in light and dark mode. 35 | * 36 | * @param {string[]} wallpaperPaths Array of strings to image files 37 | */ 38 | protected async _setLockScreen(wallpaperPaths: string[]): Promise { 39 | // Remember keys, HydraPaper will change these 40 | const tmpBackground = this._backgroundSettings.getString('picture-uri'); 41 | const tmpBackgroundDark = this._backgroundSettings.getString('picture-uri-dark'); 42 | const tmpMode = this._backgroundSettings.getString('picture-options'); 43 | 44 | await this._createCommandAndRun(wallpaperPaths); 45 | 46 | this._screensaverSettings.setString('picture-options', 'spanned'); 47 | await this._syncColorModes(this._screensaverSettings, this._backgroundSettings); 48 | 49 | // HydraPaper possibly changed these, change them back 50 | this._backgroundSettings.setString('picture-uri', tmpBackground); 51 | this._backgroundSettings.setString('picture-uri-dark', tmpBackgroundDark); 52 | this._backgroundSettings.setString('picture-options', tmpMode); 53 | } 54 | 55 | /** 56 | * Run HydraPaper in CLI mode. 57 | * 58 | * HydraPaper: 59 | * - Saves merged images in the cache folder. 60 | * - Sets `picture-option` to `spanned` 61 | * - Sets `picture-uri` and `picture-uri-dark`, versions before 3.3.2 only set 'picture-uri' 62 | * - Needs matching image path count and display count 63 | * 64 | * @param {string[]} wallpaperArray Array of image paths, should match the display count 65 | */ 66 | private async _createCommandAndRun(wallpaperArray: string[]): Promise { 67 | let command = []; 68 | 69 | // hydrapaper --cli PATH PATH PATH 70 | command.push('--cli'); 71 | command = command.concat(wallpaperArray); 72 | 73 | await this._runExternal(command); 74 | } 75 | 76 | /** 77 | * Since version 3.3.2 HydraPaper sets the mode automatically depending on the currently used dark/light mode. 78 | * HydraPaper might be in a version below 3.3.2 which only sets light mode. 79 | * 80 | * @param {Settings} sourceSettings Settings object containing the picture-uri to sync from 81 | * @param {Settings} targetSettings Settings object containing the picture-uri to sync to 82 | */ 83 | private async _syncColorModes(sourceSettings: Settings, targetSettings: Settings): Promise { 84 | if (HydraPaper._versionIsOld === undefined || HydraPaper._versionIsOld === null) 85 | await this._getVersion(); 86 | 87 | // The old version only sets light mode and we can simply sync to dark mode 88 | if (HydraPaper._versionIsOld) { 89 | Utils.setPictureUriOfSettingsObject(targetSettings, sourceSettings.getString('picture-uri')); 90 | return; 91 | } 92 | 93 | /** 94 | * The new version sets the correct mode automatically. 95 | * We have to guess which one it is and sync to the other. 96 | */ 97 | const interfaceSettings = new Settings('org.gnome.desktop.interface'); 98 | 99 | // 'default', 'prefer-dark', 'prefer-light' 100 | const theme = interfaceSettings.getString('color-scheme'); 101 | 102 | if (theme === 'default' || theme === 'prefer-light') 103 | Utils.setPictureUriOfSettingsObject(targetSettings, sourceSettings.getString('picture-uri')); 104 | 105 | if (theme === 'prefer-dark') 106 | Utils.setPictureUriOfSettingsObject(targetSettings, sourceSettings.getString('picture-uri-dark')); 107 | } 108 | 109 | /** 110 | * Workaround for detecting old HydraPaper versions by testing supported command-line options. 111 | * This has to be done because there is no dedicated way to list the version (i.e., there is no --version option). 112 | * 113 | * This tests if the argument '--dark' is known: 114 | * Versions < 3.3.2 know that argument 115 | * Versions >= 3.3.2 don't know that argument 116 | */ 117 | // https://gjs.guide/guides/gio/subprocesses.html#communicating-with-processes 118 | private async _getVersion(): Promise { 119 | if (!ExternalWallpaperManager._command || ExternalWallpaperManager._command.length < 1) 120 | throw new Error('Command empty!'); 121 | 122 | /** 123 | * We care for the success/failure of '--dark'. 124 | * '--cli' is evaluated before, so we have to give a fake wallpaper path to pass that check. 125 | */ 126 | const command = ExternalWallpaperManager._command.concat(['--dark', '--cli', 'dummyWallpaperPath']); 127 | const proc = Gio.Subprocess.new(command, Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE); 128 | 129 | const [_, stderr] = await proc.communicate_utf8_async(null, null); 130 | 131 | // We expect this to fail 132 | if (proc.get_successful()) 133 | throw new Error('HydraPaper did not fail.'); 134 | 135 | // Assuming new version with this specific error. 136 | if (stderr && stderr.includes('error: unrecognized arguments: --dark')) { 137 | HydraPaper._versionIsOld = false; 138 | return; 139 | } 140 | 141 | // Otherwise assume it's the old version 142 | HydraPaper._versionIsOld = true; 143 | } 144 | 145 | /** 146 | * Check if a filename matches a merged wallpaper name. 147 | * 148 | * Merged wallpaper need special handling as these are single images 149 | * but span across all displays. 150 | * 151 | * @param {string} filename Naming to check 152 | * @returns {boolean} Whether the image is a merged wallpaper 153 | */ 154 | static isImageMerged(filename: string): boolean { 155 | const mergedWallpaperNames = [ 156 | 'merged_wallpaper', 157 | ]; 158 | 159 | for (const name of mergedWallpaperNames) { 160 | if (filename.includes(name)) 161 | return true; 162 | } 163 | 164 | return false; 165 | } 166 | } 167 | 168 | export {HydraPaper}; 169 | -------------------------------------------------------------------------------- /src/ui/wallhaven.ts: -------------------------------------------------------------------------------- 1 | import Adw from 'gi://Adw'; 2 | import Gdk from 'gi://Gdk'; 3 | import Gio from 'gi://Gio'; 4 | import GLib from 'gi://GLib'; 5 | import GObject from 'gi://GObject'; 6 | import Gtk from 'gi://Gtk'; 7 | 8 | import * as Settings from './../settings.js'; 9 | 10 | // FIXME: Generated static class code produces a no-unused-expressions rule error 11 | /* eslint-disable no-unused-expressions */ 12 | 13 | /** 14 | * Subclass containing the preferences for Wallhaven adapter 15 | */ 16 | class WallhavenSettings extends Adw.PreferencesPage { 17 | static [GObject.GTypeName] = 'WallhavenSettings'; 18 | // @ts-expect-error Gtk.template is not in the type definitions files yet 19 | static [Gtk.template] = GLib.uri_resolve_relative(import.meta.url, './wallhaven.ui', GLib.UriFlags.NONE); 20 | // @ts-expect-error Gtk.internalChildren is not in the type definitions files yet 21 | static [Gtk.internalChildren] = [ 22 | 'ai_art', 23 | 'allow_nsfw', 24 | 'allow_sfw', 25 | 'allow_sketchy', 26 | 'api_key', 27 | 'aspect_ratios', 28 | 'button_color_undo', 29 | 'button_color', 30 | 'category_anime', 31 | 'category_general', 32 | 'category_people', 33 | 'keyword', 34 | 'minimal_resolution', 35 | 'row_color', 36 | ]; 37 | 38 | static { 39 | GObject.registerClass(this); 40 | } 41 | 42 | private static _colorPalette: Gdk.RGBA[]; 43 | private static _availableColors: string[] = [ 44 | '#660000', '#990000', '#cc0000', '#cc3333', '#ea4c88', 45 | '#993399', '#663399', '#333399', '#0066cc', '#0099cc', 46 | '#66cccc', '#77cc33', '#669900', '#336600', '#666600', 47 | '#999900', '#cccc33', '#ffff00', '#ffcc33', '#ff9900', 48 | '#ff6600', '#cc6633', '#996633', '#663300', '#000000', 49 | '#999999', '#cccccc', '#ffffff', '#424153', 50 | ]; 51 | 52 | // InternalChildren 53 | private _ai_art!: Gtk.Switch; 54 | private _allow_nsfw!: Gtk.Switch; 55 | private _allow_sfw!: Gtk.Switch; 56 | private _allow_sketchy!: Gtk.Switch; 57 | private _api_key!: Adw.EntryRow; 58 | private _aspect_ratios!: Adw.EntryRow; 59 | private _button_color_undo!: Gtk.Button; 60 | private _button_color!: Gtk.Button; 61 | private _category_anime!: Gtk.Switch; 62 | private _category_general!: Gtk.Switch; 63 | private _category_people!: Gtk.Switch; 64 | private _keyword!: Adw.EntryRow; 65 | private _minimal_resolution!: Adw.EntryRow; 66 | private _row_color!: Adw.ActionRow; 67 | 68 | private _colorDialog: Gtk.ColorChooserDialog | undefined; 69 | private _settings; 70 | 71 | /** 72 | * Craft a new adapter using an unique ID. 73 | * 74 | * Previously saved settings will be used if the adapter and ID match. 75 | * 76 | * @param {string} id Unique ID 77 | */ 78 | constructor(id: string) { 79 | super(undefined); 80 | 81 | const path = `${Settings.RWG_SETTINGS_SCHEMA_PATH}/sources/wallhaven/${id}/`; 82 | this._settings = new Settings.Settings(Settings.RWG_SETTINGS_SCHEMA_SOURCES_WALLHAVEN, path); 83 | 84 | this._settings.bind('ai-art', 85 | this._ai_art, 86 | 'active', 87 | Gio.SettingsBindFlags.DEFAULT); 88 | this._settings.bind('allow-nsfw', 89 | this._allow_nsfw, 90 | 'active', 91 | Gio.SettingsBindFlags.DEFAULT); 92 | this._settings.bind('allow-sfw', 93 | this._allow_sfw, 94 | 'active', 95 | Gio.SettingsBindFlags.DEFAULT); 96 | this._settings.bind('allow-sketchy', 97 | this._allow_sketchy, 98 | 'active', 99 | Gio.SettingsBindFlags.DEFAULT); 100 | this._settings.bind('api-key', 101 | this._api_key, 102 | 'text', 103 | Gio.SettingsBindFlags.DEFAULT); 104 | this._settings.bind('category-anime', 105 | this._category_anime, 106 | 'active', 107 | Gio.SettingsBindFlags.DEFAULT); 108 | this._settings.bind('category-general', 109 | this._category_general, 110 | 'active', 111 | Gio.SettingsBindFlags.DEFAULT); 112 | this._settings.bind('category-people', 113 | this._category_people, 114 | 'active', 115 | Gio.SettingsBindFlags.DEFAULT); 116 | this._settings.bind('color', 117 | this._row_color, 118 | 'subtitle', 119 | Gio.SettingsBindFlags.DEFAULT); 120 | this._settings.bind('keyword', 121 | this._keyword, 122 | 'text', 123 | Gio.SettingsBindFlags.DEFAULT); 124 | this._settings.bind('minimal-resolution', 125 | this._minimal_resolution, 126 | 'text', 127 | Gio.SettingsBindFlags.DEFAULT); 128 | this._settings.bind('aspect-ratios', 129 | this._aspect_ratios, 130 | 'text', 131 | Gio.SettingsBindFlags.DEFAULT); 132 | 133 | this._button_color_undo.connect('clicked', () => { 134 | this._row_color.subtitle = ''; 135 | }); 136 | 137 | this._button_color.connect('clicked', () => { 138 | // TODO: For GTK 4.10+ 139 | // Gtk.ColorDialog(); 140 | 141 | // https://stackoverflow.com/a/54487948 142 | this._colorDialog = new Gtk.ColorChooserDialog({ 143 | title: 'Choose a Color', 144 | transient_for: this.get_root() as Gtk.Window ?? undefined, 145 | modal: true, 146 | }); 147 | this._colorDialog.set_use_alpha(false); 148 | 149 | if (!WallhavenSettings._colorPalette) { 150 | WallhavenSettings._colorPalette = []; 151 | 152 | WallhavenSettings._availableColors.forEach(hexColor => { 153 | const rgbaColor = new Gdk.RGBA(); 154 | rgbaColor.parse(hexColor); 155 | WallhavenSettings._colorPalette.push(rgbaColor); 156 | }); 157 | } 158 | this._colorDialog.add_palette(Gtk.Orientation.HORIZONTAL, 10, WallhavenSettings._colorPalette); 159 | 160 | this._colorDialog.connect('response', (dialog: Gtk.ColorChooserDialog, response_id: Gtk.ResponseType) => { 161 | if (response_id === Gtk.ResponseType.OK) { 162 | // result is a Gdk.RGBA which uses float 163 | const rgba = dialog.get_rgba(); 164 | // convert to rgba so it's useful 165 | const rgbaString = rgba.to_string() ?? 'rgb(0,0,0)'; 166 | const rgbaArray = rgbaString.replace('rgb(', '').replace(')', '').split(','); 167 | const hexString = `${parseInt(rgbaArray[0]).toString(16).padStart(2, '0')}${parseInt(rgbaArray[1]).toString(16).padStart(2, '0')}${parseInt(rgbaArray[2]).toString(16).padStart(2, '0')}`; 168 | this._row_color.subtitle = hexString; 169 | } 170 | dialog.destroy(); 171 | }); 172 | 173 | this._colorDialog.show(); 174 | }); 175 | } 176 | 177 | /** 178 | * Clear all config options associated to this specific adapter. 179 | */ 180 | clearConfig(): void { 181 | this._settings.resetSchema(); 182 | } 183 | } 184 | 185 | export {WallhavenSettings}; 186 | -------------------------------------------------------------------------------- /src/ui/sourceRow.ts: -------------------------------------------------------------------------------- 1 | import Adw from 'gi://Adw'; 2 | import Gio from 'gi://Gio'; 3 | import GLib from 'gi://GLib'; 4 | import GObject from 'gi://GObject'; 5 | import Gtk from 'gi://Gtk'; 6 | 7 | import * as Settings from './../settings.js'; 8 | import * as Utils from './../utils.js'; 9 | 10 | import {Logger} from './../logger.js'; 11 | 12 | import {GenericJsonSettings} from './genericJson.js'; 13 | import {LocalFolderSettings} from './localFolder.js'; 14 | import {RedditSettings} from './reddit.js'; 15 | import {UnsplashSettings} from './unsplash.js'; 16 | import {UrlSourceSettings} from './urlSource.js'; 17 | import {WallhavenSettings} from './wallhaven.js'; 18 | 19 | // FIXME: Generated static class code produces a no-unused-expressions rule error 20 | /* eslint-disable no-unused-expressions */ 21 | 22 | /** 23 | * Class containing general settings for each adapter source as well as the adapter source 24 | */ 25 | class SourceRow extends Adw.ExpanderRow { 26 | static [GObject.GTypeName] = 'SourceRow'; 27 | // @ts-expect-error Gtk.template is not in the type definitions files yet 28 | static [Gtk.template] = GLib.uri_resolve_relative(import.meta.url, './sourceRow.ui', GLib.UriFlags.NONE); 29 | // @ts-expect-error Gtk.children is not in the type definitions files yet 30 | static [Gtk.children] = [ 31 | 'button_remove', 32 | 'button_edit', 33 | ]; 34 | 35 | // @ts-expect-error Gtk.internalChildren is not in the type definitions files yet 36 | static [Gtk.internalChildren] = [ 37 | 'switch_enable', 38 | 'blocked_images_list', 39 | 'placeholder_no_blocked', 40 | ]; 41 | 42 | static { 43 | GObject.registerClass(this); 44 | } 45 | 46 | // This list is the same across all rows 47 | static _stringList: Gtk.StringList; 48 | 49 | // Children 50 | button_edit!: Gtk.Button; 51 | button_remove!: Gtk.Button; 52 | 53 | // InternalChildren 54 | private _switch_enable!: Gtk.Switch; 55 | private _blocked_images_list!: Adw.PreferencesGroup; 56 | private _placeholder_no_blocked!: Adw.ActionRow; 57 | 58 | public id = String(Date.now()); 59 | public settings: Settings.Settings; 60 | 61 | /** 62 | * Craft a new source row using an unique ID. 63 | * 64 | * Default unique ID is Date.now() 65 | * Previously saved settings will be used if the ID matches. 66 | * 67 | * @param {string | null} id Unique ID or null 68 | */ 69 | constructor(id?: string | null) { 70 | super(undefined); 71 | 72 | if (id) 73 | this.id = id; 74 | 75 | const path = `${Settings.RWG_SETTINGS_SCHEMA_PATH}/sources/general/${this.id}/`; 76 | this.settings = new Settings.Settings(Settings.RWG_SETTINGS_SCHEMA_SOURCES_GENERAL, path); 77 | 78 | this.title = this.settings.getString('name'); 79 | this.subtitle = Utils.getSourceTypeName(this.settings.getInt('type')); 80 | this.settings.bind('name', 81 | this, 82 | 'title', 83 | Gio.SettingsBindFlags.DEFAULT); 84 | this.settings.observe('type', () => { 85 | this.subtitle = Utils.getSourceTypeName(this.settings.getInt('type')); 86 | }); 87 | 88 | this.settings.bind('enabled', 89 | this._switch_enable, 90 | 'active', 91 | Gio.SettingsBindFlags.DEFAULT); 92 | 93 | this.settings.observe('blocked-images', () => this._updateBlockedImages()); 94 | this._updateBlockedImages(); 95 | } 96 | 97 | /** 98 | * Get a new adapter based on an enum source type. 99 | * 100 | * @param {Utils.SourceType} type Enum of the adapter to get 101 | * @returns {UnsplashSettings | WallhavenSettings | RedditSettings | GenericJsonSettings | LocalFolderSettings | UrlSourceSettings | null} Newly crafted adapter or null 102 | */ 103 | private _getSettingsWidget(type: Utils.SourceType = Utils.SourceType.UNSPLASH): UnsplashSettings 104 | | WallhavenSettings 105 | | RedditSettings 106 | | GenericJsonSettings 107 | | LocalFolderSettings 108 | | UrlSourceSettings 109 | | null { 110 | let targetWidget = null; 111 | switch (type) { 112 | case Utils.SourceType.UNSPLASH: 113 | targetWidget = new UnsplashSettings(this.id); 114 | break; 115 | case Utils.SourceType.WALLHAVEN: 116 | targetWidget = new WallhavenSettings(this.id); 117 | break; 118 | case Utils.SourceType.REDDIT: 119 | targetWidget = new RedditSettings(this.id); 120 | break; 121 | case Utils.SourceType.GENERIC_JSON: 122 | targetWidget = new GenericJsonSettings(this.id); 123 | break; 124 | case Utils.SourceType.LOCAL_FOLDER: 125 | targetWidget = new LocalFolderSettings(this.id); 126 | break; 127 | case Utils.SourceType.STATIC_URL: 128 | targetWidget = new UrlSourceSettings(this.id); 129 | break; 130 | default: 131 | targetWidget = null; 132 | Logger.error('The selected source has no corresponding widget!', this); 133 | break; 134 | } 135 | return targetWidget; 136 | } 137 | 138 | /** 139 | * Remove an image name from the blocked image list. 140 | * 141 | * @param {string} filename Image name to remove 142 | */ 143 | private _removeBlockedImage(filename: string): void { 144 | let blockedImages = this.settings.getStrv('blocked-images'); 145 | if (!blockedImages.includes(filename)) 146 | return; 147 | 148 | 149 | blockedImages = Utils.removeItemOnce(blockedImages, filename); 150 | this.settings.setStrv('blocked-images', blockedImages); 151 | } 152 | 153 | /** 154 | * Clear all keys associated to this ID across all adapter 155 | */ 156 | clearConfig(): void { 157 | for (const i of Array(6).keys()) { 158 | const widget = this._getSettingsWidget(i); 159 | if (widget) 160 | widget.clearConfig(); 161 | } 162 | 163 | this.settings.resetSchema(); 164 | } 165 | 166 | private blockedImageWidgets: Adw.ActionRow[] = []; 167 | 168 | /** 169 | * Update the blocked images list of this source row entry. 170 | */ 171 | private _updateBlockedImages(): void { 172 | const blockedImages: string[] = this.settings.getStrv('blocked-images'); 173 | 174 | if (blockedImages.length > 0) 175 | this._placeholder_no_blocked.hide(); 176 | else 177 | this._placeholder_no_blocked.show(); 178 | 179 | // remove all widget first 180 | for (const widget of this.blockedImageWidgets) 181 | this._blocked_images_list.remove(widget); 182 | this.blockedImageWidgets = []; 183 | 184 | blockedImages.forEach(filename => { 185 | const blockedImageRow = new Adw.ActionRow(); 186 | blockedImageRow.set_title(filename); 187 | this.blockedImageWidgets.push(blockedImageRow); 188 | 189 | const button = new Gtk.Button(); 190 | button.set_valign(Gtk.Align.CENTER); 191 | button.connect('clicked', () => { 192 | this._removeBlockedImage(filename); 193 | this._blocked_images_list.remove(blockedImageRow); 194 | }); 195 | 196 | const buttonContent = new Adw.ButtonContent(); 197 | buttonContent.set_icon_name('user-trash-symbolic'); 198 | 199 | button.set_child(buttonContent); 200 | blockedImageRow.add_suffix(button); 201 | this._blocked_images_list.add(blockedImageRow); 202 | }); 203 | } 204 | } 205 | 206 | export {SourceRow}; 207 | -------------------------------------------------------------------------------- /src/timer.ts: -------------------------------------------------------------------------------- 1 | import GLib from 'gi://GLib'; 2 | 3 | import {Logger} from './logger.js'; 4 | import {Settings} from './settings.js'; 5 | 6 | /** 7 | * Timer for the auto fetch feature. 8 | */ 9 | class AFTimer { 10 | private static _afTimerInstance?: AFTimer | null = null; 11 | 12 | private _settings = new Settings(); 13 | private _timeout?: number = undefined; 14 | private _timeoutEndCallback?: () => Promise = undefined; 15 | private _minutes = 30; 16 | private _paused = false; 17 | 18 | /** 19 | * Get the timer singleton. 20 | * 21 | * @returns {AFTimer} Timer object 22 | */ 23 | static getTimer(): AFTimer { 24 | if (!this._afTimerInstance) 25 | this._afTimerInstance = new AFTimer(); 26 | 27 | return this._afTimerInstance; 28 | } 29 | 30 | /** 31 | * Remove the timer singleton. 32 | */ 33 | static destroy(): void { 34 | if (this._afTimerInstance) 35 | this._afTimerInstance.cleanup(); 36 | 37 | this._afTimerInstance = null; 38 | } 39 | 40 | /** 41 | * Continue a paused timer. 42 | * 43 | * Removes the pause lock and starts the timer. 44 | * If the trigger time was surpassed while paused the callback gets 45 | * called directly and the next trigger is scheduled at the 46 | * next correct time frame repeatedly. 47 | */ 48 | continue(): void { 49 | if (!this.isEnabled()) 50 | return; 51 | 52 | Logger.debug('Continuing timer', this); 53 | this._paused = false; 54 | 55 | // We don't care about awaiting. This should start immediately and 56 | // run continuously in the background. 57 | void this.start(); 58 | } 59 | 60 | /** 61 | * Check if the timer is currently set as enabled. 62 | * 63 | * @returns {boolean} Whether the timer is enabled 64 | */ 65 | isEnabled(): boolean { 66 | return this._settings.getBoolean('auto-fetch'); 67 | } 68 | 69 | /** 70 | * Check if the timer is currently paused. 71 | * 72 | * @returns {boolean} Whether the timer is paused 73 | */ 74 | isPaused(): boolean { 75 | return this._paused; 76 | } 77 | 78 | /** 79 | * Pauses the timer. 80 | * 81 | * This stops any currently running timer and prohibits starting 82 | * until continue() was called. 83 | * 'timer-last-trigger' stays the same. 84 | */ 85 | pause(): void { 86 | Logger.debug('Timer paused', this); 87 | this._paused = true; 88 | this.cleanup(); 89 | } 90 | 91 | /** 92 | * Get the minutes until the timer activates. 93 | * 94 | * @returns {number} Minutes to activation 95 | */ 96 | remainingMinutes(): number { 97 | const minutesElapsed = this.minutesElapsed(); 98 | const remainder = minutesElapsed % this._minutes; 99 | return Math.max(this._minutes - remainder, 0); 100 | } 101 | 102 | /** 103 | * Register a function which gets called on timer activation. 104 | * 105 | * Overwrites previously registered function. 106 | * 107 | * @param {() => Promise} callback Function to call 108 | */ 109 | registerCallback(callback: () => Promise): void { 110 | this._timeoutEndCallback = callback; 111 | } 112 | 113 | /** 114 | * Sets the minutes of the timer. 115 | * 116 | * @param {number} minutes Number in minutes 117 | */ 118 | setMinutes(minutes: number): void { 119 | this._minutes = minutes; 120 | } 121 | 122 | /** 123 | * Start the timer. 124 | * 125 | * Starts the timer if not paused. 126 | * Removes any previously running timer. 127 | * If the trigger time was surpassed the callback gets started 128 | * directly and the next trigger is scheduled at the 129 | * next correct time frame repeatedly. 130 | * 131 | * @param {boolean | undefined} forceTrigger Force calling the timeoutEndCallback on initial call 132 | */ 133 | async start(forceTrigger: boolean = false): Promise { 134 | if (this._paused) 135 | return; 136 | 137 | this.cleanup(); 138 | 139 | const last = this._settings.getInt64('timer-last-trigger'); 140 | if (last === 0) 141 | this._reset(); 142 | 143 | const millisecondsRemaining = this.remainingMinutes() * 60 * 1000; 144 | 145 | // set new wallpaper if the interval was surpassed… 146 | const intervalSurpassed = this._surpassedInterval(); 147 | if (forceTrigger || intervalSurpassed) { 148 | if (this._timeoutEndCallback) { 149 | Logger.debug('Running callback now', this); 150 | 151 | try { 152 | await this._timeoutEndCallback(); 153 | } catch (error) { 154 | Logger.error(error, this); 155 | } 156 | } 157 | } 158 | 159 | // …and set the timestamp to when it should have been updated 160 | if (intervalSurpassed) { 161 | const millisecondsOverdue = (this._minutes * 60 * 1000) - millisecondsRemaining; 162 | this._settings.setInt64('timer-last-trigger', Date.now() - millisecondsOverdue); 163 | } 164 | 165 | // actual timer function 166 | Logger.debug(`Starting timer, will run callback in ${millisecondsRemaining}ms`, this); 167 | this._timeout = GLib.timeout_add(GLib.PRIORITY_DEFAULT, millisecondsRemaining, () => { 168 | // Reset time immediately to avoid shifting the timer 169 | this._reset(); 170 | 171 | // Call this function again and forcefully skip the surpassed timer check so it will run the timeoutEndCallback 172 | this.start(true).catch(error => { 173 | Logger.error(error, this); 174 | }); 175 | 176 | return GLib.SOURCE_REMOVE; 177 | }); 178 | } 179 | 180 | /** 181 | * Stop the timer. 182 | */ 183 | stop(): void { 184 | this._settings.setInt64('timer-last-trigger', 0); 185 | this.cleanup(); 186 | } 187 | 188 | /** 189 | * Cleanup the timeout callback if it exists. 190 | */ 191 | cleanup(): void { 192 | if (this._timeout) { // only remove if a timeout is active 193 | Logger.debug('Removing running timer', this); 194 | GLib.source_remove(this._timeout); 195 | this._timeout = undefined; 196 | } 197 | } 198 | 199 | /** 200 | * Sets the last activation time to [now]. This doesn't effect an already running timer. 201 | */ 202 | private _reset(): void { 203 | this._settings.setInt64('timer-last-trigger', new Date().getTime()); 204 | } 205 | 206 | /** 207 | * Get the elapsed minutes since the last timer activation. 208 | * 209 | * @returns {number} Elapsed time in minutes 210 | */ 211 | minutesElapsed(): number { 212 | const now = Date.now(); 213 | const last = this._settings.getInt64('timer-last-trigger'); 214 | 215 | if (last === 0) 216 | return 0; 217 | 218 | const elapsed = Math.max(now - last, 0); 219 | return Math.floor(elapsed / (60 * 1000)); 220 | } 221 | 222 | /** 223 | * Checks if the configured timer interval has surpassed since the last timer activation. 224 | * 225 | * @returns {boolean} Whether the interval was surpassed 226 | */ 227 | private _surpassedInterval(): boolean { 228 | const now = Date.now(); 229 | const last = this._settings.getInt64('timer-last-trigger'); 230 | const diff = now - last; 231 | const intervalLength = this._minutes * 60 * 1000; 232 | 233 | return diff > intervalLength; 234 | } 235 | } 236 | 237 | export {AFTimer}; 238 | -------------------------------------------------------------------------------- /src/ui/sourceConfigModal.ts: -------------------------------------------------------------------------------- 1 | import Adw from 'gi://Adw'; 2 | import GLib from 'gi://GLib'; 3 | import Gtk from 'gi://Gtk'; 4 | import Gio from 'gi://Gio'; 5 | import GObject from 'gi://GObject'; 6 | 7 | import * as Utils from './../utils.js'; 8 | 9 | import {Logger} from './../logger.js'; 10 | import {SourceRow} from './sourceRow.js'; 11 | 12 | import {GenericJsonSettings} from './genericJson.js'; 13 | import {LocalFolderSettings} from './localFolder.js'; 14 | import {RedditSettings} from './reddit.js'; 15 | import {UnsplashSettings} from './unsplash.js'; 16 | import {UrlSourceSettings} from './urlSource.js'; 17 | import {WallhavenSettings} from './wallhaven.js'; 18 | 19 | // FIXME: Generated static class code produces a no-unused-expressions rule error 20 | /* eslint-disable no-unused-expressions */ 21 | 22 | /** 23 | * Subclass of Adw.Window for configuring a single source in a modal window. 24 | */ 25 | class SourceConfigModal extends Adw.Window { 26 | static [GObject.GTypeName] = 'SourceConfigModal'; 27 | // @ts-expect-error Gtk.template is not in the type definitions files yet 28 | static [Gtk.template] = GLib.uri_resolve_relative(import.meta.url, './sourceConfigModal.ui', GLib.UriFlags.NONE); 29 | // @ts-expect-error Gtk.children is not in the type definitions files yet 30 | static [Gtk.children] = [ 31 | ]; 32 | 33 | // @ts-expect-error Gtk.children is not in the type definitions files yet 34 | static [Gtk.internalChildren] = [ 35 | 'combo', 36 | 'settings_container', 37 | 'source_name', 38 | 'button_add', 39 | 'button_cancel', 40 | 'button_close', 41 | ]; 42 | 43 | static { 44 | GObject.registerClass(this); 45 | } 46 | 47 | // This list is the same across all rows 48 | static _stringList: Gtk.StringList; 49 | 50 | private _combo!: Adw.ComboRow; 51 | private _settings_container!: Gtk.ScrolledWindow; 52 | private _source_name!: Adw.EntryRow; 53 | private _button_add!: Gtk.Button; 54 | private _button_cancel!: Gtk.Button; 55 | private _button_close!: Gtk.Button; 56 | 57 | private _currentSourceRow: SourceRow; 58 | 59 | /** 60 | * Craft a new source row using an unique ID. 61 | * 62 | * Default unique ID is Date.now() 63 | * Previously saved settings will be used if the ID matches. 64 | * 65 | * @param {Adw.Window} parentWindow The window that this model is transient for. 66 | * @param {SourceRow} source Optional SourceRow object for editing if not present a new SourceRow will be created. 67 | */ 68 | constructor(parentWindow: Adw.Window, source?: SourceRow) { 69 | super({ 70 | title: source ? 'Edit Source' : 'Add New Source', 71 | transient_for: parentWindow, 72 | modal: true, 73 | defaultHeight: parentWindow.get_height() * 0.9, 74 | defaultWidth: parentWindow.get_width() * 0.9, 75 | }); 76 | 77 | if (!source) { 78 | this._currentSourceRow = new SourceRow(); 79 | this._button_cancel.show(); 80 | this._button_add.show(); 81 | this._button_close.hide(); 82 | } else { 83 | this._currentSourceRow = source; 84 | this._button_cancel.hide(); 85 | this._button_add.hide(); 86 | this._button_close.show(); 87 | } 88 | 89 | if (!SourceConfigModal._stringList) { 90 | const availableTypeNames: string[] = []; 91 | 92 | // Fill combo from enum 93 | // https://stackoverflow.com/a/39372911 94 | for (const type in Utils.SourceType) { 95 | if (isNaN(Number(type))) 96 | continue; 97 | 98 | availableTypeNames.push(Utils.getSourceTypeName(Number(type))); 99 | } 100 | 101 | SourceConfigModal._stringList = Gtk.StringList.new(availableTypeNames); 102 | } 103 | this._combo.model = SourceConfigModal._stringList; 104 | this._combo.selected = this._currentSourceRow.settings.getInt('type'); 105 | 106 | this._currentSourceRow.settings.bind('name', 107 | this._source_name, 108 | 'text', 109 | Gio.SettingsBindFlags.DEFAULT); 110 | 111 | this._combo.connect('notify::selected', (comboRow: Adw.ComboRow) => { 112 | this._currentSourceRow.settings.setInt('type', comboRow.selected); 113 | this._fillRow(comboRow.selected); 114 | }); 115 | 116 | this._fillRow(this._combo.selected); 117 | } 118 | 119 | /** 120 | * Fill this source row with adapter settings. 121 | * 122 | * @param {number} type Enum of the adapter to use 123 | */ 124 | private _fillRow(type: number): void { 125 | const targetWidget = this._getSettingsWidget(type); 126 | if (targetWidget !== null) 127 | this._settings_container.set_child(targetWidget); 128 | } 129 | 130 | /** 131 | * Get a new adapter based on an enum source type. 132 | * 133 | * @param {Utils.SourceType} type Enum of the adapter to get 134 | * @returns {UnsplashSettings | WallhavenSettings | RedditSettings | GenericJsonSettings | LocalFolderSettings | UrlSourceSettings | null} Newly crafted adapter or null 135 | */ 136 | private _getSettingsWidget(type: Utils.SourceType = Utils.SourceType.UNSPLASH): UnsplashSettings 137 | | WallhavenSettings 138 | | RedditSettings 139 | | GenericJsonSettings 140 | | LocalFolderSettings 141 | | UrlSourceSettings 142 | | null { 143 | let targetWidget = null; 144 | switch (type) { 145 | case Utils.SourceType.UNSPLASH: 146 | targetWidget = new UnsplashSettings(this._currentSourceRow.id); 147 | break; 148 | case Utils.SourceType.WALLHAVEN: 149 | targetWidget = new WallhavenSettings(this._currentSourceRow.id); 150 | break; 151 | case Utils.SourceType.REDDIT: 152 | targetWidget = new RedditSettings(this._currentSourceRow.id); 153 | break; 154 | case Utils.SourceType.GENERIC_JSON: 155 | targetWidget = new GenericJsonSettings(this._currentSourceRow.id); 156 | break; 157 | case Utils.SourceType.LOCAL_FOLDER: 158 | targetWidget = new LocalFolderSettings(this._currentSourceRow.id); 159 | break; 160 | case Utils.SourceType.STATIC_URL: 161 | targetWidget = new UrlSourceSettings(this._currentSourceRow.id); 162 | break; 163 | default: 164 | targetWidget = null; 165 | Logger.error('The selected source has no corresponding widget!', this); 166 | break; 167 | } 168 | return targetWidget; 169 | } 170 | 171 | /** 172 | * Open the modal window. 173 | * 174 | * @returns {Promise} Returns a promise resolving into the created/edited source row when closed/saved. 175 | */ 176 | async open(): Promise { 177 | const promise = await new Promise((resolve: (sourceRow: SourceRow) => void, reject: (error: Error) => void) => { 178 | this.show(); 179 | 180 | this._button_add.connect('clicked', () => { 181 | this.close(); 182 | resolve(this._currentSourceRow); 183 | }); 184 | 185 | this._button_close.connect('clicked', () => { 186 | this.close(); 187 | resolve(this._currentSourceRow); 188 | }); 189 | 190 | this._button_cancel.connect('clicked', () => { 191 | this.close(); 192 | this._currentSourceRow.clearConfig(); 193 | reject(new Error('Canceled')); 194 | }); 195 | }); 196 | return promise; 197 | } 198 | } 199 | 200 | export {SourceConfigModal}; 201 | -------------------------------------------------------------------------------- /src/adapter/genericJson.ts: -------------------------------------------------------------------------------- 1 | import * as JSONPath from './../jsonPath.js'; 2 | import * as SettingsModule from './../settings.js'; 3 | import * as Utils from './../utils.js'; 4 | 5 | import {BaseAdapter} from './../adapter/baseAdapter.js'; 6 | import {HistoryEntry} from './../history.js'; 7 | import {Logger} from './../logger.js'; 8 | 9 | /** How many times the service should be queried at maximum. */ 10 | const MAX_SERVICE_RETRIES = 5; 11 | /** 12 | * How many times we should try to get a new image from an array. 13 | * No new request are being made. 14 | */ 15 | const MAX_ARRAY_RETRIES = 5; 16 | 17 | /** 18 | * Adapter for generic JSON image sources. 19 | */ 20 | class GenericJsonAdapter extends BaseAdapter { 21 | /** 22 | * Create a new generic json adapter. 23 | * 24 | * @param {string} id Unique ID 25 | * @param {string} name Custom name of this adapter 26 | */ 27 | constructor(id: string, name: string) { 28 | super({ 29 | defaultName: 'Generic JSON Source', 30 | id, 31 | name, 32 | schemaID: SettingsModule.RWG_SETTINGS_SCHEMA_SOURCES_GENERIC_JSON, 33 | schemaPath: `${SettingsModule.RWG_SETTINGS_SCHEMA_PATH}/sources/genericJSON/${id}/`, 34 | }); 35 | } 36 | 37 | /** 38 | * Retrieves new URLs for images and crafts new HistoryEntries. 39 | * 40 | * @param {number} count Number of requested wallpaper 41 | * @returns {HistoryEntry[]} Array of crafted HistoryEntries 42 | * @throws {HistoryEntry[]} Array of crafted historyEntries, can be empty 43 | */ 44 | private async _getHistoryEntry(count: number): Promise { 45 | const wallpaperResult: HistoryEntry[] = []; 46 | 47 | let url = this._settings.getString('request-url'); 48 | url = encodeURI(url); 49 | 50 | const message = this._bowl.newGetMessage(url); 51 | if (message === null) { 52 | Logger.error('Could not create request.', this); 53 | throw wallpaperResult; 54 | } 55 | 56 | let response_body; 57 | try { 58 | const response_body_bytes = await this._bowl.send_and_receive(message); 59 | response_body = JSON.parse(new TextDecoder().decode(response_body_bytes)) as unknown; 60 | } catch (error) { 61 | Logger.error(error, this); 62 | throw wallpaperResult; 63 | } 64 | 65 | const imageJSONPath = this._settings.getString('image-path'); 66 | const postJSONPath = this._settings.getString('post-path'); 67 | const domainUrl = this._settings.getString('domain'); 68 | const authorNameJSONPath = this._settings.getString('author-name-path'); 69 | const authorUrlJSONPath = this._settings.getString('author-url-path'); 70 | 71 | for (let i = 0; i < MAX_ARRAY_RETRIES + count && wallpaperResult.length < count; i++) { 72 | const [returnObject, resolvedPath] = JSONPath.getTarget(response_body, imageJSONPath); 73 | if (!returnObject || (typeof returnObject !== 'string' && typeof returnObject !== 'number') || returnObject === '') { 74 | Logger.error('Unexpected json member found', this); 75 | break; 76 | } 77 | 78 | const imageDownloadUrl = this._settings.getString('image-prefix') + String(returnObject); 79 | const imageBlocked = this._isImageBlocked(Utils.fileName(imageDownloadUrl)); 80 | 81 | // Don't retry without @random present in JSONPath 82 | if (imageBlocked && !imageJSONPath.includes('@random')) { 83 | // Abort and try again 84 | break; 85 | } 86 | 87 | if (imageBlocked) 88 | continue; 89 | 90 | // A bit cumbersome to handle "unknown" in the following parts: 91 | // https://github.com/microsoft/TypeScript/issues/27706 92 | 93 | let postUrl: string; 94 | const postUrlObject = JSONPath.getTarget(response_body, JSONPath.replaceRandomInPath(postJSONPath, resolvedPath))[0]; 95 | if (typeof postUrlObject === 'string' || typeof postUrlObject === 'number') 96 | postUrl = this._settings.getString('post-prefix') + String(postUrlObject); 97 | else 98 | postUrl = ''; 99 | 100 | let authorName: string | null = null; 101 | const authorNameObject = JSONPath.getTarget(response_body, JSONPath.replaceRandomInPath(authorNameJSONPath, resolvedPath))[0]; 102 | if (typeof authorNameObject === 'string' && authorNameObject !== '') 103 | authorName = authorNameObject; 104 | 105 | let authorUrl: string; 106 | const authorUrlObject = JSONPath.getTarget(response_body, JSONPath.replaceRandomInPath(authorUrlJSONPath, resolvedPath))[0]; 107 | if (typeof authorUrlObject === 'string' || typeof authorUrlObject === 'number') 108 | authorUrl = this._settings.getString('author-url-prefix') + String(authorUrlObject); 109 | else 110 | authorUrl = ''; 111 | 112 | const historyEntry = new HistoryEntry(authorName, this._sourceName, imageDownloadUrl); 113 | 114 | if (authorUrl !== '') 115 | historyEntry.source.authorUrl = authorUrl; 116 | 117 | if (postUrl !== '') 118 | historyEntry.source.imageLinkUrl = postUrl; 119 | 120 | if (domainUrl !== '') 121 | historyEntry.source.sourceUrl = domainUrl; 122 | 123 | if (!this._includesWallpaper(wallpaperResult, historyEntry.source.imageDownloadUrl)) 124 | wallpaperResult.push(historyEntry); 125 | } 126 | 127 | if (wallpaperResult.length < count) { 128 | Logger.warn('Returning less images than requested.', this); 129 | throw wallpaperResult; 130 | } 131 | 132 | return wallpaperResult; 133 | } 134 | 135 | /** 136 | * Retrieves new URLs for images and crafts new HistoryEntries. 137 | * 138 | * Can internally query the request URL multiple times because it's unknown how many images will be reported back. 139 | * 140 | * @param {number} count Number of requested wallpaper 141 | * @returns {HistoryEntry[]} Array of crafted HistoryEntries 142 | * @throws {HistoryEntry[]} Array of crafted historyEntries, can be empty 143 | */ 144 | async requestRandomImage(count: number): Promise { 145 | const wallpaperResult: HistoryEntry[] = []; 146 | 147 | for (let i = 0; i < MAX_SERVICE_RETRIES + count && wallpaperResult.length < count; i++) { 148 | let historyArray: HistoryEntry[] = []; 149 | 150 | try { 151 | // This should run sequentially 152 | // eslint-disable-next-line no-await-in-loop 153 | historyArray = await this._getHistoryEntry(count); 154 | } catch (error) { 155 | Logger.warn('Failed getting image', this); 156 | 157 | if (Array.isArray(error) && error.length > 0 && error[0] instanceof HistoryEntry) 158 | historyArray = error as HistoryEntry[]; 159 | 160 | // Do not escalate yet, try again 161 | } finally { 162 | historyArray.forEach(element => { 163 | if (!this._includesWallpaper(wallpaperResult, element.source.imageDownloadUrl)) 164 | wallpaperResult.push(element); 165 | }); 166 | } 167 | 168 | // Image blocked, try again 169 | } 170 | 171 | if (wallpaperResult.length < count) { 172 | Logger.warn('Returning less images than requested.', this); 173 | throw wallpaperResult; 174 | } 175 | 176 | return wallpaperResult; 177 | } 178 | } 179 | 180 | export {GenericJsonAdapter}; 181 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # SPDX-License-Identifier: MIT OR LGPL-2.0-or-later 3 | # SPDX-FileCopyrightText: 2018 Claudio André 4 | env: 5 | es2021: true 6 | extends: 7 | - 'eslint:recommended' 8 | - 'plugin:@typescript-eslint/recommended-requiring-type-checking' 9 | - 'plugin:jsdoc/recommended-typescript-error' 10 | parser: "@typescript-eslint/parser" 11 | plugins: 12 | - jsdoc 13 | - "@typescript-eslint" 14 | rules: 15 | array-bracket-newline: 16 | - error 17 | - consistent 18 | array-bracket-spacing: 19 | - error 20 | - never 21 | array-callback-return: error 22 | arrow-parens: 23 | - error 24 | - as-needed 25 | arrow-spacing: error 26 | block-scoped-var: error 27 | block-spacing: error 28 | brace-style: error 29 | # Waiting for this to have matured a bit in eslint 30 | # camelcase: 31 | # - error 32 | # - properties: never 33 | # allow: [^vfunc_, ^on_, _instance_init] 34 | comma-dangle: 35 | - error 36 | - arrays: always-multiline 37 | objects: always-multiline 38 | functions: never 39 | comma-spacing: 40 | - error 41 | - before: false 42 | after: true 43 | comma-style: 44 | - error 45 | - last 46 | computed-property-spacing: error 47 | curly: 48 | - error 49 | - multi-or-nest 50 | - consistent 51 | dot-location: 52 | - error 53 | - property 54 | eol-last: error 55 | eqeqeq: error 56 | "@typescript-eslint/explicit-function-return-type": error 57 | func-call-spacing: error 58 | func-name-matching: error 59 | func-style: 60 | - error 61 | - declaration 62 | - allowArrowFunctions: true 63 | indent: 64 | - error 65 | - 4 66 | - ignoredNodes: 67 | # Allow not indenting the body of GObject.registerClass, since in the 68 | # future it's intended to be a decorator 69 | - 'CallExpression[callee.object.name=GObject][callee.property.name=registerClass] > ClassExpression:first-child' 70 | # Allow de denting chained member expressions 71 | MemberExpression: 'off' 72 | jsdoc/check-alignment: error 73 | jsdoc/check-param-names: error 74 | jsdoc/check-tag-names: error 75 | jsdoc/check-types: error 76 | jsdoc/implements-on-classes: error 77 | jsdoc/no-types: off 78 | jsdoc/require-description: error 79 | jsdoc/require-jsdoc: 80 | - error 81 | - require: 82 | ClassDeclaration: true 83 | MethodDefinition: true 84 | jsdoc/require-param: error 85 | jsdoc/require-param-description: error 86 | jsdoc/require-param-name: error 87 | jsdoc/require-param-type: error 88 | jsdoc/tag-lines: 89 | - error 90 | - never 91 | - startLines: 1 92 | key-spacing: 93 | - error 94 | - beforeColon: false 95 | afterColon: true 96 | keyword-spacing: 97 | - error 98 | - before: true 99 | after: true 100 | linebreak-style: 101 | - error 102 | - unix 103 | lines-between-class-members: 104 | - error 105 | - always 106 | - exceptAfterSingleLine: true 107 | max-nested-callbacks: error 108 | max-statements-per-line: error 109 | new-parens: error 110 | no-array-constructor: error 111 | no-await-in-loop: error 112 | no-caller: error 113 | no-constant-condition: 114 | - error 115 | - checkLoops: false 116 | no-div-regex: error 117 | no-empty: 118 | - error 119 | - allowEmptyCatch: true 120 | no-extra-bind: error 121 | no-extra-parens: off 122 | '@typescript-eslint/no-extra-parens': 123 | - error 124 | - all 125 | - conditionalAssign: false 126 | nestedBinaryExpressions: false 127 | returnAssign: false 128 | no-implicit-coercion: 129 | - error 130 | - allow: 131 | - '!!' 132 | no-invalid-this: off 133 | "@typescript-eslint/no-invalid-this": "error" 134 | no-iterator: error 135 | no-label-var: error 136 | no-lonely-if: error 137 | no-loop-func: error 138 | no-nested-ternary: error 139 | no-new-object: error 140 | no-new-wrappers: error 141 | no-octal-escape: error 142 | no-proto: error 143 | no-prototype-builtins: 'off' 144 | no-restricted-globals: [error, window] 145 | no-restricted-properties: 146 | - error 147 | - object: imports 148 | property: format 149 | message: Use template strings 150 | - object: pkg 151 | property: initFormat 152 | message: Use template strings 153 | - object: Lang 154 | property: copyProperties 155 | message: Use Object.assign() 156 | - object: Lang 157 | property: bind 158 | message: Use arrow notation or Function.prototype.bind() 159 | - object: Lang 160 | property: Class 161 | message: Use ES6 classes 162 | no-restricted-syntax: 163 | - error 164 | - selector: >- 165 | MethodDefinition[key.name="_init"] > 166 | FunctionExpression[params.length=1] > 167 | BlockStatement[body.length=1] 168 | CallExpression[arguments.length=1][callee.object.type="Super"][callee.property.name="_init"] > 169 | Identifier:first-child 170 | message: _init() that only calls super._init() is unnecessary 171 | - selector: >- 172 | MethodDefinition[key.name="_init"] > 173 | FunctionExpression[params.length=0] > 174 | BlockStatement[body.length=1] 175 | CallExpression[arguments.length=0][callee.object.type="Super"][callee.property.name="_init"] 176 | message: _init() that only calls super._init() is unnecessary 177 | - selector: BinaryExpression[operator="instanceof"][right.name="Array"] 178 | message: Use Array.isArray() 179 | no-return-assign: error 180 | no-return-await: error 181 | no-self-compare: error 182 | no-shadow: off 183 | "@typescript-eslint/no-shadow": error 184 | no-shadow-restricted-names: error 185 | no-spaced-func: error 186 | no-tabs: error 187 | no-template-curly-in-string: error 188 | no-throw-literal: error 189 | no-trailing-spaces: error 190 | no-undef-init: error 191 | no-unneeded-ternary: error 192 | no-unused-expressions: error 193 | no-unused-vars: off 194 | "@typescript-eslint/no-unused-vars": 195 | - error 196 | # Vars use a suffix _ instead of a prefix because of file-scope private vars 197 | - varsIgnorePattern: (^unused|_$) 198 | argsIgnorePattern: ^(unused|_) 199 | no-useless-call: error 200 | no-useless-computed-key: error 201 | no-useless-concat: error 202 | no-useless-constructor: error 203 | no-useless-rename: error 204 | no-useless-return: error 205 | no-whitespace-before-property: error 206 | no-with: error 207 | nonblock-statement-body-position: 208 | - error 209 | - below 210 | object-curly-newline: 211 | - error 212 | - consistent: true 213 | multiline: true 214 | object-curly-spacing: error 215 | object-shorthand: error 216 | operator-assignment: error 217 | operator-linebreak: error 218 | padded-blocks: 219 | - error 220 | - never 221 | # These may be a bit controversial, we can try them out and enable them later 222 | # prefer-const: error 223 | # prefer-destructuring: error 224 | prefer-numeric-literals: error 225 | prefer-promise-reject-errors: error 226 | prefer-rest-params: error 227 | prefer-spread: error 228 | prefer-template: error 229 | quotes: 230 | - error 231 | - single 232 | - avoidEscape: true 233 | require-await: error 234 | rest-spread-spacing: error 235 | semi: 236 | - error 237 | - always 238 | semi-spacing: 239 | - error 240 | - before: false 241 | after: true 242 | semi-style: error 243 | space-before-blocks: error 244 | space-before-function-paren: 245 | - error 246 | - named: never 247 | # for `function ()` and `async () =>`, preserve space around keywords 248 | anonymous: always 249 | asyncArrow: always 250 | space-in-parens: error 251 | space-infix-ops: 252 | - error 253 | - int32Hint: false 254 | space-unary-ops: error 255 | spaced-comment: error 256 | switch-colon-spacing: error 257 | symbol-description: error 258 | template-curly-spacing: error 259 | template-tag-spacing: error 260 | unicode-bom: error 261 | wrap-iife: 262 | - error 263 | - inside 264 | yield-star-spacing: error 265 | yoda: error 266 | settings: 267 | jsdoc: 268 | mode: typescript 269 | globals: 270 | ARGV: readonly 271 | Debugger: readonly 272 | GIRepositoryGType: readonly 273 | globalThis: readonly 274 | imports: readonly 275 | Intl: readonly 276 | log: readonly 277 | logError: readonly 278 | print: readonly 279 | printerr: readonly 280 | window: readonly 281 | TextEncoder: readonly 282 | TextDecoder: readonly 283 | console: readonly 284 | setTimeout: readonly 285 | setInterval: readonly 286 | clearTimeout: readonly 287 | clearInterval: readonly 288 | parserOptions: 289 | ecmaVersion: 2022 290 | sourceType: "module" 291 | project: ['./tsconfig.json'] 292 | -------------------------------------------------------------------------------- /src/adapter/wallhaven.ts: -------------------------------------------------------------------------------- 1 | import Gio from 'gi://Gio'; 2 | 3 | import * as SettingsModule from './../settings.js'; 4 | import * as Utils from './../utils.js'; 5 | 6 | import {BaseAdapter} from './../adapter/baseAdapter.js'; 7 | import {HistoryEntry} from './../history.js'; 8 | import {Logger} from './../logger.js'; 9 | 10 | interface QueryOptions { 11 | /** 12 | * Filter AI generated images. 13 | * 14 | * - 0 = Include them in search results 15 | * - 1 = Don't include them in search results 16 | */ 17 | ai_art_filter: string, 18 | 19 | atleast: string, 20 | categories: string, 21 | colors: string, 22 | purity: string, 23 | q: string, 24 | ratios: string[], 25 | sorting: string, 26 | } 27 | 28 | interface WallhavenSearchResponse { 29 | data: { 30 | path: string, 31 | url: string, 32 | }[] 33 | } 34 | 35 | /** 36 | * Adapter for Wallhaven image sources. 37 | */ 38 | class WallhavenAdapter extends BaseAdapter { 39 | private _options: QueryOptions = { 40 | ai_art_filter: '1', 41 | q: '', 42 | purity: '110', // SFW, sketchy 43 | sorting: 'random', 44 | categories: '111', // General, Anime, People 45 | atleast: '1920x1080', 46 | ratios: ['16x9'], 47 | colors: '', 48 | }; 49 | 50 | /** 51 | * Create a new wallhaven adapter. 52 | * 53 | * @param {string} id Unique ID 54 | * @param {string} name Custom name of this adapter 55 | */ 56 | constructor(id: string, name: string) { 57 | super({ 58 | id, 59 | schemaID: SettingsModule.RWG_SETTINGS_SCHEMA_SOURCES_WALLHAVEN, 60 | schemaPath: `${SettingsModule.RWG_SETTINGS_SCHEMA_PATH}/sources/wallhaven/${id}/`, 61 | name, 62 | defaultName: 'Wallhaven', 63 | }); 64 | } 65 | 66 | /** 67 | * Retrieves new URLs for images and crafts new HistoryEntries. 68 | * 69 | * @param {number} count Number of requested wallpaper 70 | * @returns {HistoryEntry[]} Array of crafted HistoryEntries 71 | * @throws {HistoryEntry[]} Array of crafted historyEntries, can be empty 72 | */ 73 | async requestRandomImage(count: number): Promise { 74 | const wallpaperResult: HistoryEntry[] = []; 75 | 76 | this._readOptionsFromSettings(); 77 | const optionsString = this._generateOptionsString(this._options); 78 | 79 | const url = `https://wallhaven.cc/api/v1/search?${encodeURI(optionsString)}`; 80 | const message = this._bowl.newGetMessage(url); 81 | 82 | const apiKey = this._settings.getString('api-key'); 83 | if (apiKey !== '') 84 | message.requestHeaders.append('X-API-Key', apiKey); 85 | 86 | Logger.debug(`Search URL: ${url}`, this); 87 | 88 | let wallhavenResponse; 89 | try { 90 | const response_body_bytes = await this._bowl.send_and_receive(message); 91 | wallhavenResponse = JSON.parse(new TextDecoder().decode(response_body_bytes)) as unknown; 92 | } catch (error) { 93 | Logger.error(error, this); 94 | throw wallpaperResult; 95 | } 96 | 97 | if (!this._isWallhavenResponse(wallhavenResponse)) { 98 | Logger.error('Unexpected response', this); 99 | throw wallpaperResult; 100 | } 101 | 102 | const response = wallhavenResponse.data; 103 | if (!response || response.length === 0) { 104 | Logger.error('Empty response', this); 105 | throw wallpaperResult; 106 | } 107 | 108 | for (let i = 0; i < response.length && wallpaperResult.length < count; i++) { 109 | const entry = response[i]; 110 | const siteURL = entry.url; 111 | const downloadURL = entry.path; 112 | 113 | if (this._isImageBlocked(Utils.fileName(downloadURL))) 114 | continue; 115 | 116 | const historyEntry = new HistoryEntry(null, this._sourceName, downloadURL); 117 | historyEntry.source.sourceUrl = 'https://wallhaven.cc/'; 118 | historyEntry.source.imageLinkUrl = siteURL; 119 | 120 | if (!this._includesWallpaper(wallpaperResult, historyEntry.source.imageDownloadUrl)) 121 | wallpaperResult.push(historyEntry); 122 | } 123 | 124 | if (wallpaperResult.length < count) { 125 | Logger.warn('Returning less images than requested.', this); 126 | throw wallpaperResult; 127 | } 128 | 129 | return wallpaperResult; 130 | } 131 | 132 | /** 133 | * Fetches an image according to a given HistoryEntry. 134 | * 135 | * This implementation requests the image in HistoryEntry.source.imageDownloadUrl 136 | * using Soup and saves it to HistoryEntry.path while setting the X-API-Key header. 137 | * 138 | * @param {HistoryEntry} historyEntry The historyEntry to fetch 139 | * @returns {Promise} unaltered HistoryEntry 140 | */ 141 | async fetchFile(historyEntry: HistoryEntry): Promise { 142 | const file = Gio.file_new_for_path(historyEntry.path); 143 | const fstream = file.replace(null, false, Gio.FileCreateFlags.NONE, null); 144 | 145 | // craft new message from details 146 | const request = this._bowl.newGetMessage(historyEntry.source.imageDownloadUrl); 147 | 148 | const apiKey = this._settings.getString('api-key'); 149 | if (apiKey !== '') 150 | request.requestHeaders.append('X-API-Key', apiKey); 151 | 152 | // start the download 153 | const response_data_bytes = await this._bowl.send_and_receive(request); 154 | if (!response_data_bytes) { 155 | fstream.close(null); 156 | throw new Error('Not a valid image response'); 157 | } 158 | 159 | fstream.write(response_data_bytes, null); 160 | fstream.close(null); 161 | 162 | return historyEntry; 163 | } 164 | 165 | /** 166 | * Create an option string based on user settings. 167 | * 168 | * Does not refresh settings itself. 169 | * 170 | * @param {QueryOptions} options Options to check 171 | * @returns {string} Options string 172 | */ 173 | private _generateOptionsString(options: T): string { 174 | let optionsString = ''; 175 | 176 | for (const key in options) { 177 | if (options.hasOwnProperty(key)) { 178 | if (Array.isArray(options[key])) 179 | optionsString += `${key}=${(options[key] as Array).join()}&`; 180 | else if (typeof options[key] === 'string' && options[key] !== '') 181 | optionsString += `${key}=${options[key] as string}&`; 182 | } 183 | } 184 | 185 | return optionsString; 186 | } 187 | 188 | /** 189 | * Check if the response is expected to be a response by Wallhaven. 190 | * 191 | * Primarily in use for typescript typing. 192 | * 193 | * @param {unknown} object Unknown object to narrow down 194 | * @returns {boolean} Whether the response is from Reddit 195 | */ 196 | private _isWallhavenResponse(object: unknown): object is WallhavenSearchResponse { 197 | if (typeof object === 'object' && 198 | object && 199 | 'data' in object && 200 | Array.isArray(object.data) 201 | ) 202 | return true; 203 | 204 | return false; 205 | } 206 | 207 | /** 208 | * Freshly read the user settings options. 209 | */ 210 | private _readOptionsFromSettings(): void { 211 | const keywords = this._settings.getString('keyword').split(','); 212 | if (keywords.length > 0) { 213 | const randomKeyword = keywords[Utils.getRandomNumber(keywords.length)]; 214 | this._options.q = randomKeyword.trim(); 215 | } 216 | 217 | this._options.atleast = this._settings.getString('minimal-resolution'); 218 | this._options.ratios = this._settings.getString('aspect-ratios').split(','); 219 | this._options.ratios = this._options.ratios.map(elem => { 220 | return elem.trim(); 221 | }); 222 | 223 | const categories = []; 224 | categories.push(Number(this._settings.getBoolean('category-general'))); 225 | categories.push(Number(this._settings.getBoolean('category-anime'))); 226 | categories.push(Number(this._settings.getBoolean('category-people'))); 227 | this._options.categories = categories.join(''); 228 | 229 | const purity = []; 230 | purity.push(Number(this._settings.getBoolean('allow-sfw'))); 231 | purity.push(Number(this._settings.getBoolean('allow-sketchy'))); 232 | purity.push(Number(this._settings.getBoolean('allow-nsfw'))); 233 | this._options.purity = purity.join(''); 234 | 235 | this._options.ai_art_filter = this._settings.getBoolean('ai-art') ? '0' : '1'; 236 | 237 | this._options.colors = this._settings.getString('color'); 238 | } 239 | } 240 | 241 | export {WallhavenAdapter}; 242 | -------------------------------------------------------------------------------- /src/history.ts: -------------------------------------------------------------------------------- 1 | import Gio from 'gi://Gio'; 2 | import GLib from 'gi://GLib'; 3 | 4 | import * as Utils from './utils.js'; 5 | 6 | import {Logger} from './logger.js'; 7 | import {Settings} from './settings.js'; 8 | 9 | // Gets filled by the HistoryController which is constructed at extension startup 10 | let _wallpaperLocation: string; 11 | 12 | interface SourceInfo { 13 | author: string | null; 14 | authorUrl: string | null; 15 | source: string | null; 16 | sourceUrl: string | null; 17 | imageDownloadUrl: string; 18 | imageLinkUrl: string | null; 19 | } 20 | 21 | interface AdapterInfo { 22 | /** Identifier to access the settings path */ 23 | id: string | null; 24 | /** Adapter type as enum */ 25 | type: number | null; 26 | } 27 | 28 | /** 29 | * Defines an image with core properties. 30 | */ 31 | class HistoryEntry { 32 | timestamp = new Date().getTime(); 33 | /** Unique identifier, concat of timestamp and name */ 34 | id: string; 35 | /** Basename of URI */ 36 | name: string | null; // This can be null when an entry from an older version is mapped from settings 37 | path: string; 38 | source: SourceInfo; 39 | adapter: AdapterInfo | null = { // This can be null when an entry from an older version is mapped from settings 40 | id: null, 41 | type: null, 42 | }; 43 | 44 | /** 45 | * Create a new HistoryEntry. 46 | * 47 | * The name, id, and path will be prefilled. 48 | * 49 | * @param {string | null} author Author of the image or null 50 | * @param {string | null} source The image source or null 51 | * @param {string} url The request URL of the image 52 | */ 53 | constructor(author: string | null, source: string | null, url: string) { 54 | this.source = { 55 | author, 56 | authorUrl: null, 57 | source, 58 | sourceUrl: null, 59 | imageDownloadUrl: url, // URL used for downloading the image 60 | imageLinkUrl: url, // URL used for linking back to the website of the image 61 | }; 62 | 63 | // extract the name from the url 64 | this.name = Utils.fileName(this.source.imageDownloadUrl); 65 | this.id = `${this.timestamp}_${this.name}`; 66 | this.path = `${_wallpaperLocation}/${this.id}`; 67 | } 68 | } 69 | 70 | /** 71 | * Controls the history and related code parts. 72 | */ 73 | class HistoryController { 74 | history: HistoryEntry[] = []; 75 | size = 10; 76 | 77 | private _settings = new Settings(); 78 | 79 | /** 80 | * Create a new HistoryController. 81 | * 82 | * Loads an existing history from the settings schema. 83 | * 84 | * @param {string} wallpaperLocation Root save location for new HistoryEntries. 85 | */ 86 | constructor(wallpaperLocation: string) { 87 | _wallpaperLocation = wallpaperLocation; 88 | 89 | this.load(); 90 | } 91 | 92 | /** 93 | * Insert images at the beginning of the history. 94 | * 95 | * Throws old images out of the stack and saves to the schema. 96 | * 97 | * @param {HistoryEntry[]} historyElements Array of elements to insert 98 | */ 99 | insert(historyElements: HistoryEntry[]): void { 100 | for (const historyElement of historyElements) 101 | this.history.unshift(historyElement); 102 | 103 | this._deleteOldPictures(); 104 | this.save(); 105 | } 106 | 107 | /** 108 | * Set the given id to to the first history element (the current one) 109 | * 110 | * @param {string} id ID of the historyEntry 111 | * @returns {boolean} Whether the sorting was successful 112 | */ 113 | promoteToActive(id: string): boolean { 114 | const element = this.get(id); 115 | if (element === null) 116 | return false; 117 | 118 | 119 | element.timestamp = new Date().getTime(); 120 | this.history = this.history.sort((elem1, elem2) => { 121 | return elem1.timestamp < elem2.timestamp ? 1 : 0; 122 | }); 123 | this.save(); 124 | 125 | return true; 126 | } 127 | 128 | /** 129 | * Get a specific HistoryEntry by ID. 130 | * 131 | * @param {string} id ID of the HistoryEntry 132 | * @returns {HistoryEntry | null} The corresponding HistoryEntry or null 133 | */ 134 | get(id: string): HistoryEntry | null { 135 | for (const elem of this.history) { 136 | if (elem.id === id) 137 | return elem; 138 | } 139 | 140 | return null; 141 | } 142 | 143 | /** 144 | * Get the current history element. 145 | * 146 | * @returns {HistoryEntry} Current first entry 147 | */ 148 | getCurrentEntry(): HistoryEntry { 149 | return this.history[0]; 150 | } 151 | 152 | /** 153 | * Get a HistoryEntry by its file path. 154 | * 155 | * @param {string} path Path to search for 156 | * @returns {HistoryEntry | null} The corresponding HistoryEntry or null 157 | */ 158 | getEntryByPath(path: string): HistoryEntry | null { 159 | for (const element of this.history) { 160 | if (element.path === path) 161 | return element; 162 | } 163 | 164 | return null; 165 | } 166 | 167 | /** 168 | * Get a random HistoryEntry. 169 | * 170 | * @returns {HistoryEntry} Random entry 171 | */ 172 | getRandom(): HistoryEntry { 173 | return this.history[Utils.getRandomNumber(this.history.length)]; 174 | } 175 | 176 | /** 177 | * Load the history from the schema 178 | */ 179 | load(): void { 180 | this.size = this._settings.getInt('history-length'); 181 | 182 | const stringHistory: string[] = this._settings.getStrv('history'); 183 | this.history = stringHistory.map((elem: string) => { 184 | const unknownObject = JSON.parse(elem) as unknown; 185 | if (!this._isHistoryEntry(unknownObject)) 186 | throw new Error('Failed loading history data.'); 187 | 188 | return unknownObject; 189 | }); 190 | } 191 | 192 | /** 193 | * Save the history to the schema 194 | */ 195 | save(): void { 196 | const stringHistory = this.history.map(elem => { 197 | return JSON.stringify(elem); 198 | }); 199 | this._settings.setStrv('history', stringHistory); 200 | Gio.Settings.sync(); 201 | } 202 | 203 | /** 204 | * Clear the history and delete all photos except the current one. 205 | * 206 | * This function clears the cache folder, ignoring if the image appears in the history or not. 207 | */ 208 | clear(): void { 209 | const firstHistoryElement = this.history[0]; 210 | 211 | this.history = []; 212 | 213 | const directory = Gio.file_new_for_path(_wallpaperLocation); 214 | const enumerator = directory.enumerate_children('', Gio.FileQueryInfoFlags.NONE, null); 215 | 216 | let fileInfo; 217 | do { 218 | fileInfo = enumerator.next_file(null); 219 | 220 | if (!fileInfo) 221 | break; 222 | 223 | const id = fileInfo.get_name(); 224 | 225 | // ignore hidden files and first element 226 | if (id[0] !== '.' && id !== firstHistoryElement.id) { 227 | const deleteFile = Gio.file_new_for_path(_wallpaperLocation + id); 228 | this._deleteFile(deleteFile); 229 | } 230 | } while (fileInfo); 231 | 232 | this.save(); 233 | } 234 | 235 | /** 236 | * Delete all pictures that have no slot in the history. 237 | */ 238 | private _deleteOldPictures(): void { 239 | this.size = this._settings.getInt('history-length'); 240 | while (this.history.length > this.size) { 241 | const path = this.history.pop()?.path; 242 | if (!path) 243 | continue; 244 | 245 | const file = Gio.file_new_for_path(path); 246 | this._deleteFile(file); 247 | } 248 | } 249 | 250 | /** 251 | * Helper function to delete files. 252 | * 253 | * Has some special treatments factored in to ignore file not found issues 254 | * when the parent path is available. 255 | * 256 | * @param {Gio.File} file The file to delete 257 | * @throws On any other error than Gio.IOErrorEnum.NOT_FOUND 258 | */ 259 | private _deleteFile(file: Gio.File): void { 260 | try { 261 | file.delete(null); 262 | } catch (error) { 263 | /** 264 | * Ignore deletion errors when the file doesn't exist but the parent path is accessible. 265 | * This tries to avoid invalid states later on because we would have thrown here and therefore skip saving. 266 | */ 267 | if (file.get_parent()?.query_exists(null) && error instanceof GLib.Error && error.matches(Gio.io_error_quark(), Gio.IOErrorEnum.NOT_FOUND)) { 268 | Logger.warn(`Ignoring Gio.IOErrorEnum.NOT_FOUND: ${file.get_path() ?? 'undefined'}`, this); 269 | return; 270 | } 271 | 272 | throw error; 273 | } 274 | } 275 | 276 | /** 277 | * Check if an object is a HistoryEntry. 278 | * 279 | * @param {unknown} object Object to check 280 | * @returns {boolean} Whether the object is a HistoryEntry 281 | */ 282 | private _isHistoryEntry(object: unknown): object is HistoryEntry { 283 | if (typeof object === 'object' && 284 | object && 285 | 'timestamp' in object && 286 | typeof object.timestamp === 'number' && 287 | 'id' in object && 288 | typeof object.id === 'string' && 289 | 'path' in object && 290 | typeof object.path === 'string' 291 | ) 292 | return true; 293 | 294 | return false; 295 | } 296 | } 297 | 298 | export {HistoryEntry, HistoryController}; 299 | --------------------------------------------------------------------------------