├── 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 | 
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 | [](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 |
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 |
--------------------------------------------------------------------------------