├── .editorconfig ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .gitlab-ci.yml ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── architecture.svg ├── build-aux ├── com.ranfdev.Notify.Devel.json └── dist-vendor.sh ├── data ├── com.ranfdev.Notify.desktop.in.in ├── com.ranfdev.Notify.gschema.xml.in ├── icons │ ├── code-symbolic.svg │ ├── com.ranfdev.Notify-symbolic.svg │ ├── com.ranfdev.Notify.Devel.svg │ ├── com.ranfdev.Notify.Source.svg │ ├── com.ranfdev.Notify.svg │ ├── dice3-symbolic.svg │ ├── meson.build │ └── paper-plane-symbolic.svg ├── meson.build ├── resources │ ├── com.ranfdev.Notify.metainfo.xml.in.in │ ├── dice3-symbolic.svg │ ├── meson.build │ ├── resources.gresource.xml │ ├── style.css │ └── ui │ │ ├── preferences.blp │ │ ├── shortcuts.blp │ │ ├── subscription_info_dialog.blp │ │ └── window.blp ├── sample_messages │ ├── downtime.json │ ├── front_door_friendly.json │ ├── front_door_stranger.json │ ├── notify.json │ ├── tomatoes.json │ └── uptime.json └── screenshots │ ├── 1.png │ ├── 2.png │ └── 3.png ├── hooks └── pre-commit.hook ├── meson.build ├── meson_options.txt ├── ntfy-daemon ├── .gitignore ├── Cargo.toml ├── data │ └── mailer_emoji_map.json └── src │ ├── actor_utils.rs │ ├── credentials.rs │ ├── http_client.rs │ ├── lib.rs │ ├── listener.rs │ ├── message_repo │ ├── migrations │ │ └── 00.sql │ └── mod.rs │ ├── models.rs │ ├── ntfy.rs │ ├── output_tracker.rs │ ├── retry.rs │ └── subscription.rs ├── po ├── LINGUAS ├── POTFILES.in └── meson.build ├── rustfmt.toml └── src ├── application.rs ├── async_utils.rs ├── config.rs.in ├── error.rs ├── main.rs ├── meson.build ├── subscription.rs └── widgets ├── add_subscription_dialog.rs ├── advanced_message_dialog.rs ├── message_row.rs ├── mod.rs ├── preferences.rs ├── subscription_info_dialog.rs └── window.rs /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | [*] 3 | indent_style = space 4 | end_of_line = lf 5 | trim_trailing_whitespace = true 6 | insert_final_newline = true 7 | charset = utf-8 8 | 9 | [*.{build,css,doap,scss,ui,xml,xml.in,xml.in.in,yaml,yml}] 10 | indent_size = 2 11 | 12 | [*.{json,py,rs}] 13 | indent_size = 4 14 | 15 | [*.{c,h,h.in}] 16 | indent_size = 2 17 | max_line_length = 80 18 | 19 | [NEWS] 20 | indent_size = 2 21 | max_line_length = 72 22 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: [main] 4 | pull_request: 5 | 6 | name: CI 7 | 8 | jobs: 9 | rustfmt: 10 | name: Rustfmt 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: actions-rs/toolchain@v1 15 | with: 16 | profile: minimal 17 | toolchain: stable 18 | override: true 19 | components: rustfmt 20 | - name: Create blank versions of configured file 21 | run: echo -e "" >> src/config.rs 22 | - name: Run cargo fmt 23 | run: cargo fmt --all -- --check 24 | 25 | flatpak: 26 | name: Flatpak 27 | runs-on: ubuntu-latest 28 | container: 29 | image: bilelmoussaoui/flatpak-github-actions:gnome-nightly 30 | options: --privileged 31 | steps: 32 | - uses: actions/checkout@v3 33 | - uses: bilelmoussaoui/flatpak-github-actions/flatpak-builder@v6 34 | with: 35 | bundle: notify.flatpak 36 | manifest-path: build-aux/com.ranfdev.Notify.Devel.json 37 | repository-name: "flathub" 38 | run-tests: true 39 | cache-key: flatpak-builder-${{ github.sha }} 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target/ 2 | /build/ 3 | /_build/ 4 | /builddir/ 5 | /build-aux/app 6 | /build-aux/.flatpak-builder/ 7 | /src/config.rs 8 | *.ui.in~ 9 | *.ui~ 10 | /.flatpak/ 11 | /vendor 12 | /.vscode 13 | /subprojects/blueprint-compiler 14 | .flatpak-builder/ 15 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | stages: 2 | - check 3 | - test 4 | 5 | flatpak: 6 | image: 'quay.io/gnome_infrastructure/gnome-runtime-images:gnome-43' 7 | stage: test 8 | tags: 9 | - flatpak 10 | variables: 11 | BUNDLE: "notify-nightly.flatpak" 12 | MANIFEST_PATH: "build-aux/com.ranfdev.Notify.Devel.json" 13 | FLATPAK_MODULE: "notify" 14 | APP_ID: "com.ranfdev.Notify.Devel" 15 | RUNTIME_REPO: "https://nightly.gnome.org/gnome-nightly.flatpakrepo" 16 | script: 17 | - flatpak install --user --noninteractive org.freedesktop.Sdk.Extension.llvm14//21.08 18 | - > 19 | xvfb-run -a -s "-screen 0 1024x768x24" 20 | flatpak-builder --keep-build-dirs --user --disable-rofiles-fuse flatpak_app --repo=repo ${BRANCH:+--default-branch=$BRANCH} ${MANIFEST_PATH} 21 | - flatpak build-bundle repo ${BUNDLE} --runtime-repo=${RUNTIME_REPO} ${APP_ID} ${BRANCH} 22 | artifacts: 23 | name: 'Flatpak artifacts' 24 | expose_as: 'Get Flatpak bundle here' 25 | when: 'always' 26 | paths: 27 | - "${BUNDLE}" 28 | - '.flatpak-builder/build/${FLATPAK_MODULE}/_flatpak_build/meson-logs/meson-log.txt' 29 | - '.flatpak-builder/build/${FLATPAK_MODULE}/_flatpak_build/meson-logs/testlog.txt' 30 | expire_in: 14 days 31 | 32 | # Configure and run rustfmt 33 | # Exits and builds fails if on bad format 34 | rustfmt: 35 | image: "rust:slim" 36 | script: 37 | - rustup component add rustfmt 38 | # Create blank versions of our configured files 39 | # so rustfmt does not yell about non-existent files or completely empty files 40 | - echo -e "" >> src/config.rs 41 | - rustc -Vv && cargo -Vv 42 | - cargo fmt --version 43 | - cargo fmt --all -- --color=always --check 44 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "notify" 3 | version = "0.1.6" 4 | authors = ["ranfdev "] 5 | edition = "2021" 6 | 7 | [profile.release] 8 | lto = "thin" 9 | 10 | [workspace] 11 | members = [ 12 | "ntfy-daemon" 13 | ] 14 | 15 | [dependencies] 16 | ntfy-daemon = { path = "./ntfy-daemon" } 17 | gettext-rs = { version = "0.7", features = ["gettext-system"] } 18 | gtk = { version = "0.9", package = "gtk4", features = ["gnome_47"] } 19 | gsv = { package = "sourceview5", version = "0.9" } 20 | once_cell = "1.14" 21 | tracing = "0.1.37" 22 | tracing-subscriber = "0.3" 23 | adw = { version = "0.7", package = "libadwaita", features = ["v1_6"] } 24 | serde = { version = "1.0", features = ["derive"] } 25 | serde_json = "1.0" 26 | anyhow = "1.0.71" 27 | chrono = "0.4.26" 28 | rand = "0.8.5" 29 | ureq = "2.7.1" 30 | futures = "0.3.0" 31 | ashpd = "0.6.0" 32 | async-channel = "2.1.0" 33 | relm4-macros = { version = "0.6.2", features = [], default-features = false } 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Notify 4 | 5 | https://ntfy.sh client application to receive everyday's notifications. 6 | 7 | 8 | Download on Flathub 9 | 10 | 11 |
12 | 13 | ![main view, no subscriptions](https://github.com/ranfdev/Notify/blob/main/data/screenshots/1.png?raw=true) 14 | ![main view](https://github.com/ranfdev/Notify/blob/main/data/screenshots/2.png?raw=true) 15 | ![notification with buttons](https://github.com/ranfdev/Notify/blob/main/data/screenshots/3.png?raw=true) 16 | 17 | ## Architecture 18 | 19 | The code is split between the GUI and the underlying ntfy-daemon. 20 | ![](./architecture.svg) 21 | 22 | ## How to run 23 | Use gnome-builder to clone and run the project. Note: after clicking the "run" 24 | button a terminal may appear at the bottom: run the command "notify" in it. 25 | -------------------------------------------------------------------------------- /build-aux/com.ranfdev.Notify.Devel.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "com.ranfdev.Notify.Devel", 3 | "runtime": "org.gnome.Platform", 4 | "runtime-version": "47", 5 | "sdk": "org.gnome.Sdk", 6 | "sdk-extensions": [ 7 | "org.freedesktop.Sdk.Extension.rust-stable", 8 | "org.freedesktop.Sdk.Extension.llvm18" 9 | ], 10 | "command": "notify", 11 | "finish-args": [ 12 | "--share=ipc", 13 | "--share=network", 14 | "--socket=fallback-x11", 15 | "--socket=wayland", 16 | "--device=dri", 17 | "--env=RUST_LOG=notify=debug,ntfy_daemon=debug", 18 | "--env=G_MESSAGES_DEBUG=none", 19 | "--env=RUST_BACKTRACE=1", 20 | "--talk-name=org.freedesktop.Notifications" 21 | ], 22 | "build-options": { 23 | "append-path": "/usr/lib/sdk/rust-stable/bin:/usr/lib/sdk/llvm18/bin", 24 | "build-args": [ 25 | "--share=network" 26 | ], 27 | "env": { 28 | "CARGO_REGISTRIES_CRATES_IO_PROTOCOL": "sparse", 29 | "CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER": "clang", 30 | "CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS": "-C link-arg=-fuse-ld=/usr/lib/sdk/rust-stable/bin/mold", 31 | "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER": "clang", 32 | "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_RUSTFLAGS": "-C link-arg=-fuse-ld=/usr/lib/sdk/rust-stable/bin/mold" 33 | }, 34 | "test-args": [ 35 | "--socket=x11", 36 | "--share=network" 37 | ] 38 | }, 39 | "modules": [ 40 | { 41 | "name": "blueprint-compiler", 42 | "buildsystem": "meson", 43 | "cleanup": ["*"], 44 | "sources": [ 45 | { 46 | "type": "git", 47 | "url": "https://gitlab.gnome.org/jwestman/blueprint-compiler", 48 | "tag": "v0.14.0", 49 | "commit": "8e10fcf8692108b9d4ab78f41086c5d7773ef864" 50 | } 51 | ] 52 | }, 53 | { 54 | "name": "notify", 55 | "buildsystem": "meson", 56 | "run-tests": true, 57 | "config-opts": [ 58 | "-Dprofile=development" 59 | ], 60 | "sources": [ 61 | { 62 | "type": "dir", 63 | "path": "../" 64 | } 65 | ] 66 | } 67 | ] 68 | } 69 | -------------------------------------------------------------------------------- /build-aux/dist-vendor.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | export DIST="$1" 3 | export SOURCE_ROOT="$2" 4 | 5 | cd "$SOURCE_ROOT" 6 | mkdir "$DIST"/.cargo 7 | cargo vendor | sed 's/^directory = ".*"/directory = "vendor"/g' > $DIST/.cargo/config 8 | # Move vendor into dist tarball directory 9 | mv vendor "$DIST" 10 | 11 | -------------------------------------------------------------------------------- /data/com.ranfdev.Notify.desktop.in.in: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Name=Notify 3 | Comment=ntfy.sh client application to receive everyday's notifications 4 | Type=Application 5 | Exec=notify 6 | Terminal=false 7 | Categories=GNOME;GTK;Network;Utility; 8 | # Translators: Search terms to find this application. Do NOT translate or localize the semicolons! The list MUST also end with a semicolon! 9 | Keywords=Gnome;GTK;ntfy; 10 | # Translators: Do NOT translate or transliterate this text (this is an icon file name)! 11 | Icon=@icon@ 12 | StartupNotify=true 13 | X-GNOME-UsesNotifications=true 14 | X-Purism-FormFactor=Workstation;Mobile 15 | -------------------------------------------------------------------------------- /data/com.ranfdev.Notify.gschema.xml.in: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 800 6 | Window width 7 | 8 | 9 | 500 10 | Window height 11 | 12 | 13 | false 14 | Window maximized state 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /data/icons/code-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /data/icons/com.ranfdev.Notify-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /data/icons/com.ranfdev.Notify.Devel.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /data/icons/com.ranfdev.Notify.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /data/icons/dice3-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /data/icons/meson.build: -------------------------------------------------------------------------------- 1 | install_data( 2 | '@0@.svg'.format(application_id), 3 | install_dir: iconsdir / 'hicolor' / 'scalable' / 'apps' 4 | ) 5 | 6 | install_data( 7 | '@0@-symbolic.svg'.format(base_id), 8 | install_dir: iconsdir / 'hicolor' / 'symbolic' / 'apps', 9 | rename: '@0@-symbolic.svg'.format(application_id) 10 | ) 11 | -------------------------------------------------------------------------------- /data/icons/paper-plane-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /data/meson.build: -------------------------------------------------------------------------------- 1 | subdir('icons') 2 | subdir('resources') 3 | # Desktop file 4 | desktop_conf = configuration_data() 5 | desktop_conf.set('icon', application_id) 6 | desktop_file = i18n.merge_file( 7 | type: 'desktop', 8 | input: configure_file( 9 | input: '@0@.desktop.in.in'.format(base_id), 10 | output: '@BASENAME@', 11 | configuration: desktop_conf 12 | ), 13 | output: '@0@.desktop'.format(application_id), 14 | po_dir: podir, 15 | install: true, 16 | install_dir: datadir / 'applications' 17 | ) 18 | # Validate Desktop file 19 | if desktop_file_validate.found() 20 | test( 21 | 'validate-desktop', 22 | desktop_file_validate, 23 | args: [ 24 | desktop_file.full_path() 25 | ], 26 | depends: desktop_file, 27 | ) 28 | endif 29 | 30 | # GSchema 31 | gschema_conf = configuration_data() 32 | gschema_conf.set('app-id', application_id) 33 | gschema_conf.set('gettext-package', gettext_package) 34 | configure_file( 35 | input: '@0@.gschema.xml.in'.format(base_id), 36 | output: '@0@.gschema.xml'.format(application_id), 37 | configuration: gschema_conf, 38 | install: true, 39 | install_dir: datadir / 'glib-2.0' / 'schemas' 40 | ) 41 | 42 | # Validata GSchema 43 | if glib_compile_schemas.found() 44 | test( 45 | 'validate-gschema', glib_compile_schemas, 46 | args: [ 47 | '--strict', '--dry-run', meson.current_build_dir() 48 | ], 49 | ) 50 | endif 51 | -------------------------------------------------------------------------------- /data/resources/com.ranfdev.Notify.metainfo.xml.in.in: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | @app-id@ 5 | CC0 6 | 7 | GPL-3.0-or-later 8 | Notify 9 | Receive notifications from ntfy.sh 10 | 11 |

A desktop client for ntfy.

12 |
    13 |
  • Receive and send notifications with ntfy.sh or a self hosted ntfy server
  • 14 |
  • View past notifications
  • 15 |
  • Runs automatically at startup and receives notifications in the background
  • 16 |
17 |

ntfy allows you to send notifications to your phone or desktop via scripts from any computer and/or using a REST API.

18 |

You can set up notifications to monitor disk space, ssh logins, weather forecasts, or any number of creative scenarios.

19 |
20 | 21 | Network 22 | Utility 23 | 24 | 25 | GTK 26 | GNOME 27 | 28 | 29 | 30 | https://raw.githubusercontent.com/ranfdev/Notify/main/data/screenshots/1.png 31 | Main window, welcome screen 32 | 33 | 34 | https://raw.githubusercontent.com/ranfdev/Notify/main/data/screenshots/2.png 35 | Main window, subscription view 36 | 37 | 38 | https://github.com/ranfdev/Notify 39 | https://github.com/ranfdev/Notify/issues 40 | https://github.com/sponsors/ranfdev 41 | 42 | 43 | 44 | 45 | 46 |
    47 |
  • Fixed issue handling custom local certificates with https
  • 48 |
  • Updated GNOME sdk
  • 49 |
50 |
51 |
52 | 53 | 54 |
    55 |
  • Fixed about window crashing
  • 56 |
57 |
58 |
59 | 60 | 61 |
    62 |
  • Various bugfixes
  • 63 |
64 |
65 |
66 | 67 | 68 |
    69 |
  • Support for basic authentication with custom accounts
  • 70 |
  • Custom server field is now prefilled when appropriate
  • 71 |
72 |
73 |
74 | 75 | 76 |
    77 |
  • Introduced dialog to write advanced messages
  • 78 |
  • Automatic download of attached images
  • 79 |
  • Introduced support for action buttons in the notifications
  • 80 |
  • Improved performance of the message list layout
  • 81 |
  • Improved design for status chips
  • 82 |
  • Improved design for the message bar
  • 83 |
  • Various bug fixes
  • 84 |
85 |
86 |
87 | 88 | 89 |
90 | ranfdev 91 | ranfdev@gmail.com 92 | @gettext-package@ 93 | @app-id@.desktop 94 |
95 | -------------------------------------------------------------------------------- /data/resources/dice3-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /data/resources/meson.build: -------------------------------------------------------------------------------- 1 | # Resources 2 | 3 | blueprints = custom_target('blueprints', 4 | input: files( 5 | 'ui/window.blp', 6 | 'ui/shortcuts.blp', 7 | 'ui/subscription_info_dialog.blp', 8 | 'ui/preferences.blp', 9 | ), 10 | output: '.', 11 | command: [find_program('blueprint-compiler'), 'batch-compile', '@OUTPUT@', '@CURRENT_SOURCE_DIR@', '@INPUT@'], 12 | ) 13 | 14 | # Appdata 15 | appdata_conf = configuration_data() 16 | appdata_conf.set('app-id', application_id) 17 | appdata_conf.set('gettext-package', gettext_package) 18 | appdata_file = i18n.merge_file( 19 | input: configure_file( 20 | input: '@0@.metainfo.xml.in.in'.format(base_id), 21 | output: '@BASENAME@', 22 | configuration: appdata_conf 23 | ), 24 | output: '@0@.metainfo.xml'.format(base_id), 25 | po_dir: podir, 26 | install: true, 27 | install_dir: datadir / 'metainfo' 28 | ) 29 | # Validate Appdata 30 | if appstream_util.found() 31 | test( 32 | 'validate-appdata', appstream_util, 33 | args: [ 34 | 'validate', '--nonet', appdata_file.full_path() 35 | ], 36 | depends: appdata_file, 37 | ) 38 | endif 39 | 40 | resources = gnome.compile_resources( 41 | 'resources', 42 | 'resources.gresource.xml', 43 | gresource_bundle: true, 44 | source_dir: meson.current_build_dir(), 45 | install: true, 46 | install_dir: pkgdatadir, 47 | dependencies: [blueprints, appdata_file], 48 | ) 49 | 50 | 51 | -------------------------------------------------------------------------------- /data/resources/resources.gresource.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ui/shortcuts.ui 6 | ui/window.ui 7 | ui/subscription_info_dialog.ui 8 | ui/preferences.ui 9 | style.css 10 | com.ranfdev.Notify.metainfo.xml 11 | 12 | 13 | ../icons/dice3-symbolic.svg 14 | ../icons/paper-plane-symbolic.svg 15 | ../icons/code-symbolic.svg 16 | 17 | 18 | -------------------------------------------------------------------------------- /data/resources/style.css: -------------------------------------------------------------------------------- 1 | .title-header{ 2 | font-size: 36px; 3 | font-weight: bold; 4 | } 5 | 6 | button.small { 7 | padding: 6px 12px; 8 | min-height: 0px; 9 | min-width: 0px; 10 | font-size: 14px; 11 | } 12 | 13 | .chip { 14 | min-height: 16px; 15 | min-width: 16px; 16 | padding: 2px 5px; 17 | color: @theme_fg_color; 18 | border-radius: 8px; 19 | font-weight: bold; 20 | } 21 | .chip--warning { 22 | background: alpha(@yellow_2, 0.2); 23 | color: darker(@yellow_5); 24 | } 25 | .chip--info { 26 | background: alpha(@blue_2, 0.2); 27 | color: darker(@blue_5); 28 | } 29 | .chip--degraded { 30 | background: alpha(@orange_2, 0.2); 31 | color: darker(@orange_5); 32 | } 33 | 34 | .chip--danger { 35 | background: alpha(@red_2, 0.2); 36 | color: darker(@red_5); 37 | } 38 | 39 | .chip--small { 40 | font-size: 0.8rem; 41 | } 42 | 43 | .chip.circular { 44 | border-radius: 24px; 45 | padding: 2px 2px; 46 | } 47 | 48 | .sourceview { 49 | padding: 4px 8px; 50 | } 51 | 52 | .code { 53 | border-radius: 12px; 54 | border: 1px solid @borders; 55 | } 56 | 57 | .message_bar { 58 | padding: 2px 2px; 59 | background-color: @sidebar_bg_color; 60 | border-radius: 24px; 61 | } 62 | .message_bar entry { 63 | background-color: @sidebar_bg_color; 64 | border-radius: 12px; 65 | } 66 | -------------------------------------------------------------------------------- /data/resources/ui/preferences.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $NotifyPreferences : Adw.PreferencesDialog { 5 | width-request: 240; 6 | height-request: 360; 7 | Adw.PreferencesPage { 8 | title: "Accounts"; 9 | description: "Accounts to access protected topics"; 10 | Adw.PreferencesGroup { 11 | title: "New Account"; 12 | Adw.EntryRow server_entry { 13 | title: "server"; 14 | } 15 | Adw.EntryRow username_entry { 16 | title: "username"; 17 | } 18 | Adw.PasswordEntryRow password_entry { 19 | title: "password"; 20 | } 21 | Gtk.Button add_btn { 22 | margin-top: 8; 23 | styles ["suggested-action"] 24 | halign: end; 25 | label: "Add"; 26 | } 27 | } 28 | Adw.PreferencesGroup added_accounts_group { 29 | title: "Added"; 30 | Gtk.ListBox added_accounts { 31 | styles ["boxed-list"] 32 | } 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /data/resources/ui/shortcuts.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | 3 | ShortcutsWindow help_overlay { 4 | modal: true; 5 | 6 | ShortcutsSection { 7 | section-name: "shortcuts"; 8 | max-height: 10; 9 | 10 | ShortcutsGroup { 11 | title: C_("shortcut window", "General"); 12 | 13 | ShortcutsShortcut { 14 | title: C_("shortcut window", "Show Shortcuts"); 15 | action-name: "win.show-help-overlay"; 16 | } 17 | 18 | ShortcutsShortcut { 19 | title: C_("shortcut window", "Quit"); 20 | action-name: "app.quit"; 21 | } 22 | } 23 | } 24 | } 25 | 26 | -------------------------------------------------------------------------------- /data/resources/ui/subscription_info_dialog.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $SubscriptionInfoDialog : Adw.Dialog { 5 | title: "Subscription Info"; 6 | Adw.ToolbarView { 7 | [top] 8 | Adw.HeaderBar {} 9 | Adw.Clamp { 10 | Gtk.Box { 11 | orientation: vertical; 12 | spacing: 8; 13 | margin-top: 8; 14 | margin-bottom: 8; 15 | margin-start: 8; 16 | margin-end: 8; 17 | Gtk.ListBox { 18 | Adw.EntryRow display_name_entry { 19 | title: "Display Name"; 20 | } 21 | Adw.ActionRow { 22 | title: "Topic"; 23 | subtitle-selectable: true; 24 | subtitle: bind (template.subscription as <$TopicSubscription>).topic as ; 25 | styles [ 26 | "property" 27 | ] 28 | } 29 | Adw.ActionRow { 30 | title: "Server"; 31 | subtitle: bind (template.subscription as <$TopicSubscription>).server as ; 32 | subtitle-selectable: true; 33 | styles [ 34 | "property" 35 | ] 36 | } 37 | Adw.SwitchRow muted_switch_row { 38 | title: "Muted"; 39 | } 40 | 41 | styles [ 42 | "boxed-list" 43 | ] 44 | } 45 | } 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /data/resources/ui/window.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | menu primary_menu { 5 | section { 6 | item { 7 | label: _("_Preferences"); 8 | action: "app.preferences"; 9 | } 10 | 11 | item { 12 | label: _("_Keyboard Shortcuts"); 13 | action: "win.show-help-overlay"; 14 | } 15 | 16 | item { 17 | label: _("_About Notify"); 18 | action: "app.about"; 19 | } 20 | } 21 | } 22 | 23 | menu subscription_menu { 24 | section { 25 | item { 26 | label: _("_Subscription Info"); 27 | action: "win.show-subscription-info"; 28 | } 29 | } 30 | section { 31 | item { 32 | label: _("_Clear all notifications"); 33 | action: "win.clear-notifications"; 34 | } 35 | 36 | item { 37 | label: _("_Unsubscribe"); 38 | action: "win.unsubscribe"; 39 | } 40 | } 41 | } 42 | 43 | template $NotifyWindow : Adw.ApplicationWindow { 44 | width-request: 360; 45 | height-request: 360; 46 | Adw.Breakpoint { 47 | condition ("max-width: 640sp") 48 | setters { 49 | navigation_split_view.collapsed: true; 50 | } 51 | } 52 | Adw.ToastOverlay toast_overlay { 53 | Adw.NavigationSplitView navigation_split_view { 54 | sidebar: Adw.NavigationPage { 55 | title: "Topics"; 56 | child: Adw.ToolbarView { 57 | [top] 58 | Adw.HeaderBar { 59 | [start] 60 | Button { 61 | icon-name: "list-add-symbolic"; 62 | clicked => $show_add_topic() swapped; 63 | } 64 | [end] 65 | MenuButton appmenu_button { 66 | icon-name: "open-menu-symbolic"; 67 | menu-model: primary_menu; 68 | primary: true; 69 | tooltip-text: _("Main Menu"); 70 | } 71 | } 72 | 73 | Gtk.Stack stack { 74 | Adw.StatusPage welcome_view { 75 | title: "Notify"; 76 | description: "Subscribe to one topic and start listening for notifications"; 77 | child: Gtk.Box { 78 | orientation: vertical; 79 | spacing: 8; 80 | Gtk.Button { 81 | label: "Subscribe To Topic"; 82 | clicked => $show_add_topic() swapped; 83 | halign: center; 84 | styles [ 85 | "suggested-action", 86 | "pill" 87 | ] 88 | } 89 | Gtk.Button { 90 | label: "Discover Integrations"; 91 | clicked => $discover_integrations() swapped; 92 | halign: center; 93 | styles [ 94 | "pill" 95 | ] 96 | } 97 | }; 98 | } 99 | ScrolledWindow list_view { 100 | propagate-natural-height: true; 101 | ListBox subscription_list { 102 | styles [ 103 | "navigation-sidebar" 104 | ] 105 | } 106 | } 107 | } 108 | }; 109 | }; 110 | content: Adw.NavigationPage { 111 | title: "Notifications"; 112 | Adw.ToolbarView subscription_view { 113 | [top] 114 | Adw.HeaderBar headerbar { 115 | [end] 116 | MenuButton subscription_menu_btn { 117 | icon-name: "view-more-symbolic"; 118 | menu-model: subscription_menu; 119 | tooltip-text: _("Subscription Menu"); 120 | } 121 | } 122 | [top] 123 | Adw.Banner banner { 124 | title: "Reconnecting..."; 125 | } 126 | 127 | content: ScrolledWindow message_scroll { 128 | propagate-natural-height: true; 129 | vexpand: true; 130 | Adw.Clamp { 131 | ListBox message_list { 132 | selection-mode: none; 133 | show-separators: true; 134 | styles [ 135 | "background" 136 | ] 137 | } 138 | } 139 | }; 140 | [bottom] 141 | Adw.Bin { 142 | margin-top: 4; 143 | margin-bottom: 4; 144 | margin-start: 4; 145 | margin-end: 4; 146 | Adw.Clamp { 147 | Gtk.Box { 148 | styles [ 149 | "message_bar" 150 | ] 151 | Gtk.Button code_btn { 152 | styles [ 153 | "circular", 154 | "flat" 155 | ] 156 | icon-name: "code-symbolic"; 157 | } 158 | Entry entry { 159 | placeholder-text: "Message..."; 160 | hexpand: true; 161 | } 162 | Gtk.Button send_btn { 163 | styles [ 164 | "circular", 165 | "suggested-action" 166 | ] 167 | icon-name: "paper-plane-symbolic"; 168 | } 169 | } 170 | } 171 | } 172 | } 173 | }; 174 | } 175 | } 176 | } 177 | 178 | -------------------------------------------------------------------------------- /data/sample_messages/downtime.json: -------------------------------------------------------------------------------- 1 | { 2 | "topic": "_", 3 | "title": "Downtime detected for service https://example.com", 4 | "message": "More than 1 minute of downtime detected", 5 | "actions": [ 6 | { 7 | "action": "view", 8 | "label": "Open Dashboard", 9 | "url": "https://example.com" 10 | } 11 | ], 12 | "priority": 5, 13 | "tags": ["arrow_down_small"] 14 | } 15 | -------------------------------------------------------------------------------- /data/sample_messages/front_door_friendly.json: -------------------------------------------------------------------------------- 1 | { 2 | "topic": "_", 3 | "title": "Your friend has arrived!", 4 | "message": "A familiar person has been recognized waiting on your front door", 5 | "actions": [ 6 | { 7 | "action": "view", 8 | "label": "Video Stream", 9 | "url": "https://example.com" 10 | }, 11 | { 12 | "action": "http", 13 | "label": "Play \"I'm coming!\" sound", 14 | "url": "https://example.com" 15 | }, 16 | { 17 | "action": "view", 18 | "label": "Door Controls", 19 | "url": "https://example.com" 20 | } 21 | ], 22 | "tags": ["smiley", "door"] 23 | } 24 | -------------------------------------------------------------------------------- /data/sample_messages/front_door_stranger.json: -------------------------------------------------------------------------------- 1 | { 2 | "topic": "_", 3 | "title": "Unknown person detected on your front door", 4 | "message": "An unknown person has been detected in front of your door", 5 | "actions": [ 6 | { 7 | "action": "view", 8 | "label": "Video Stream", 9 | "url": "https://example.com" 10 | }, 11 | { 12 | "action": "http", 13 | "label": "Play \"Barking Dog\" sound", 14 | "url": "https://example.com" 15 | }, 16 | { 17 | "action": "view", 18 | "label": "Door Controls", 19 | "url": "https://example.com" 20 | } 21 | ], 22 | "priority": 4, 23 | "tags": ["service_dog", "door"] 24 | } 25 | -------------------------------------------------------------------------------- /data/sample_messages/notify.json: -------------------------------------------------------------------------------- 1 | { 2 | "topic": "_", 3 | "title": "Notify, the native ntfy.sh client", 4 | "message": "Open Notify, subscribe to a topic, start receiving notifications! Send a notification to ntfy.sh whenever a long script finishes running, or when you detect a person in front of your door, or when the humidity sensor attached to your tomatoes is asking for help", 5 | "priority": 5, 6 | "tags": ["star_struck", "fireworks"] 7 | } 8 | -------------------------------------------------------------------------------- /data/sample_messages/tomatoes.json: -------------------------------------------------------------------------------- 1 | { 2 | "topic": "_", 3 | "title": "Your tomatoes are asking for water!", 4 | "message": "Water Level: 0%\nTemperature: 34°C\nAir Humidity Level: 20%", 5 | "tags": ["tomato", "water"] 6 | } 7 | -------------------------------------------------------------------------------- /data/sample_messages/uptime.json: -------------------------------------------------------------------------------- 1 | { 2 | "topic": "_", 3 | "title": "Service https://example.com returned operational", 4 | "message": "Service is up for more than 1m, after being in downtime for 30m", 5 | "actions": [ 6 | { 7 | "action": "view", 8 | "label": "Open Dashboard", 9 | "url": "https://example.com" 10 | } 11 | ], 12 | "tags": ["top"] 13 | } 14 | -------------------------------------------------------------------------------- /data/screenshots/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ranfdev/Notify/3a4acc2d12505e874171bd1e486c7bb68c24fd8b/data/screenshots/1.png -------------------------------------------------------------------------------- /data/screenshots/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ranfdev/Notify/3a4acc2d12505e874171bd1e486c7bb68c24fd8b/data/screenshots/2.png -------------------------------------------------------------------------------- /data/screenshots/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ranfdev/Notify/3a4acc2d12505e874171bd1e486c7bb68c24fd8b/data/screenshots/3.png -------------------------------------------------------------------------------- /hooks/pre-commit.hook: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Source: https://gitlab.gnome.org/GNOME/fractal/blob/master/hooks/pre-commit.hook 3 | 4 | install_rustfmt() { 5 | if ! which rustup >/dev/null 2>&1; then 6 | curl https://sh.rustup.rs -sSf | sh -s -- -y 7 | export PATH=$PATH:$HOME/.cargo/bin 8 | if ! which rustup >/dev/null 2>&1; then 9 | echo "Failed to install rustup. Performing the commit without style checking." 10 | exit 0 11 | fi 12 | fi 13 | 14 | if ! rustup component list|grep rustfmt >/dev/null 2>&1; then 15 | echo "Installing rustfmt…" 16 | rustup component add rustfmt 17 | fi 18 | } 19 | 20 | if ! which cargo >/dev/null 2>&1 || ! cargo fmt --help >/dev/null 2>&1; then 21 | echo "Unable to check the project’s code style, because rustfmt could not be run." 22 | 23 | if [ ! -t 1 ]; then 24 | # No input is possible 25 | echo "Performing commit." 26 | exit 0 27 | fi 28 | 29 | echo "" 30 | echo "y: Install rustfmt via rustup" 31 | echo "n: Don't install rustfmt and perform the commit" 32 | echo "Q: Don't install rustfmt and abort the commit" 33 | 34 | echo "" 35 | while true 36 | do 37 | printf "%s" "Install rustfmt via rustup? [y/n/Q]: "; read yn < /dev/tty 38 | case $yn in 39 | [Yy]* ) install_rustfmt; break;; 40 | [Nn]* ) echo "Performing commit."; exit 0;; 41 | [Qq]* | "" ) echo "Aborting commit."; exit 1 >/dev/null 2>&1;; 42 | * ) echo "Invalid input";; 43 | esac 44 | done 45 | 46 | fi 47 | 48 | echo "--Checking style--" 49 | cargo fmt --all -- --check 50 | if test $? != 0; then 51 | echo "--Checking style fail--" 52 | echo "Please fix the above issues, either manually or by running: cargo fmt --all" 53 | 54 | exit 1 55 | else 56 | echo "--Checking style pass--" 57 | fi 58 | -------------------------------------------------------------------------------- /meson.build: -------------------------------------------------------------------------------- 1 | project( 2 | 'notify', 3 | 'rust', 4 | version: '0.1.6', 5 | meson_version: '>= 0.59', 6 | # license: 'MIT', 7 | ) 8 | 9 | i18n = import('i18n') 10 | gnome = import('gnome') 11 | 12 | base_id = 'com.ranfdev.Notify' 13 | 14 | dependency('glib-2.0', version: '>= 2.66') 15 | dependency('gio-2.0', version: '>= 2.66') 16 | dependency('gtk4', version: '>= 4.0.0') 17 | dependency('gtksourceview-5', version: '>= 5.0.0') 18 | 19 | glib_compile_resources = find_program('glib-compile-resources', required: true) 20 | glib_compile_schemas = find_program('glib-compile-schemas', required: true) 21 | desktop_file_validate = find_program('desktop-file-validate', required: false) 22 | appstream_util = find_program('appstream-util', required: false) 23 | cargo = find_program('cargo', required: true) 24 | 25 | version = meson.project_version() 26 | 27 | prefix = get_option('prefix') 28 | bindir = prefix / get_option('bindir') 29 | localedir = prefix / get_option('localedir') 30 | 31 | datadir = prefix / get_option('datadir') 32 | pkgdatadir = datadir / meson.project_name() 33 | iconsdir = datadir / 'icons' 34 | podir = meson.project_source_root() / 'po' 35 | gettext_package = meson.project_name() 36 | 37 | if get_option('profile') == 'development' 38 | profile = 'Devel' 39 | vcs_tag = run_command('git', 'rev-parse', '--short', 'HEAD', check: false).stdout().strip() 40 | if vcs_tag == '' 41 | version_suffix = '-devel' 42 | else 43 | version_suffix = '-@0@'.format(vcs_tag) 44 | endif 45 | application_id = '@0@.@1@'.format(base_id, profile) 46 | else 47 | profile = '' 48 | version_suffix = '' 49 | application_id = base_id 50 | endif 51 | 52 | meson.add_dist_script( 53 | 'build-aux/dist-vendor.sh', 54 | meson.project_build_root() / 'meson-dist' / meson.project_name() + '-' + version, 55 | meson.project_source_root() 56 | ) 57 | 58 | if get_option('profile') == 'development' 59 | # Setup pre-commit hook for ensuring coding style is always consistent 60 | message('Setting up git pre-commit hook..') 61 | run_command('cp', '-f', 'hooks/pre-commit.hook', '.git/hooks/pre-commit', check: false) 62 | endif 63 | 64 | subdir('data') 65 | subdir('po') 66 | subdir('src') 67 | 68 | gnome.post_install( 69 | gtk_update_icon_cache: true, 70 | glib_compile_schemas: true, 71 | update_desktop_database: true, 72 | ) 73 | -------------------------------------------------------------------------------- /meson_options.txt: -------------------------------------------------------------------------------- 1 | option( 2 | 'profile', 3 | type: 'combo', 4 | choices: [ 5 | 'default', 6 | 'development' 7 | ], 8 | value: 'default', 9 | description: 'The build profile for Notify. One of "default" or "development".' 10 | ) 11 | -------------------------------------------------------------------------------- /ntfy-daemon/.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /ntfy-daemon/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "ntfy-daemon" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | serde = { version = "1.0", features = ["derive"] } 10 | serde_json = "1.0" 11 | futures = "0.3.0" 12 | tokio = { version = "1.0.0", features = ["net", "rt", "macros", "parking_lot"]} 13 | tokio-util = { version = "0.7.4", features = ["compat", "io"] } 14 | clap = { version = "4.3.11", features = ["derive"] } 15 | anyhow = "1.0.71" 16 | tokio-stream = { version = "0.1.14", features = ["io-util", "time", "sync"] } 17 | rusqlite = "0.29.0" 18 | rand = "0.8.5" 19 | reqwest = { version = "0.12.9", features = ["stream", "rustls-tls-native-roots"]} 20 | url = { version = "2.4.0", features = ["serde"] } 21 | tracing = "0.1.37" 22 | thiserror = "1.0.49" 23 | regex = "1.9.6" 24 | oo7 = "0.2.1" 25 | async-trait = "0.1.83" 26 | http = "1.1.0" 27 | async-channel = "2.3.1" -------------------------------------------------------------------------------- /ntfy-daemon/src/actor_utils.rs: -------------------------------------------------------------------------------- 1 | macro_rules! send_command { 2 | ($self:expr, $command:expr) => {{ 3 | let (resp_tx, resp_rx) = oneshot::channel(); 4 | use anyhow::Context; 5 | $self 6 | .command_tx 7 | .send($command(resp_tx)) 8 | .await 9 | .context("Actor mailbox error")?; 10 | resp_rx.await.context("Actor response error")? 11 | }}; 12 | } 13 | 14 | pub(crate) use send_command; 15 | -------------------------------------------------------------------------------- /ntfy-daemon/src/credentials.rs: -------------------------------------------------------------------------------- 1 | use std::cell::RefCell; 2 | use std::collections::HashMap; 3 | use std::rc::Rc; 4 | use std::sync::{Arc, RwLock}; 5 | 6 | use async_trait::async_trait; 7 | 8 | #[derive(Clone)] 9 | pub struct KeyringItem { 10 | attributes: HashMap, 11 | // we could zero-out this region of memory 12 | secret: Vec, 13 | } 14 | 15 | impl KeyringItem { 16 | async fn attributes(&self) -> HashMap { 17 | self.attributes.clone() 18 | } 19 | async fn secret(&self) -> &[u8] { 20 | &self.secret[..] 21 | } 22 | } 23 | 24 | #[async_trait] 25 | trait LightKeyring { 26 | async fn search_items( 27 | &self, 28 | attributes: HashMap<&str, &str>, 29 | ) -> anyhow::Result>; 30 | async fn create_item( 31 | &self, 32 | label: &str, 33 | attributes: HashMap<&str, &str>, 34 | secret: &str, 35 | replace: bool, 36 | ) -> anyhow::Result<()>; 37 | async fn delete(&self, attributes: HashMap<&str, &str>) -> anyhow::Result<()>; 38 | } 39 | 40 | struct RealKeyring { 41 | keyring: oo7::Keyring, 42 | } 43 | 44 | #[async_trait] 45 | impl LightKeyring for RealKeyring { 46 | async fn search_items( 47 | &self, 48 | attributes: HashMap<&str, &str>, 49 | ) -> anyhow::Result> { 50 | let items = self.keyring.search_items(attributes).await?; 51 | 52 | let mut out_items = vec![]; 53 | for item in items { 54 | out_items.push(KeyringItem { 55 | attributes: item.attributes().await?, 56 | secret: item.secret().await?.to_vec(), 57 | }); 58 | } 59 | Ok(out_items) 60 | } 61 | 62 | async fn create_item( 63 | &self, 64 | label: &str, 65 | attributes: HashMap<&str, &str>, 66 | secret: &str, 67 | replace: bool, 68 | ) -> anyhow::Result<()> { 69 | self.keyring 70 | .create_item(label, attributes, secret, replace) 71 | .await?; 72 | Ok(()) 73 | } 74 | 75 | async fn delete(&self, attributes: HashMap<&str, &str>) -> anyhow::Result<()> { 76 | self.keyring.delete(attributes).await?; 77 | Ok(()) 78 | } 79 | } 80 | 81 | struct NullableKeyring { 82 | search_response: Vec, 83 | } 84 | 85 | impl NullableKeyring { 86 | pub fn new(search_response: Vec) -> Self { 87 | Self { search_response } 88 | } 89 | } 90 | 91 | #[async_trait] 92 | impl LightKeyring for NullableKeyring { 93 | async fn search_items( 94 | &self, 95 | _attributes: HashMap<&str, &str>, 96 | ) -> anyhow::Result> { 97 | Ok(self.search_response.clone()) 98 | } 99 | 100 | async fn create_item( 101 | &self, 102 | _label: &str, 103 | _attributes: HashMap<&str, &str>, 104 | _secret: &str, 105 | _replace: bool, 106 | ) -> anyhow::Result<()> { 107 | Ok(()) 108 | } 109 | 110 | async fn delete(&self, _attributes: HashMap<&str, &str>) -> anyhow::Result<()> { 111 | Ok(()) 112 | } 113 | } 114 | impl NullableKeyring { 115 | pub fn with_credentials(credentials: Vec) -> Self { 116 | let mut search_response = vec![]; 117 | 118 | for cred in credentials { 119 | let attributes = HashMap::from([ 120 | ("type".to_string(), "password".to_string()), 121 | ("username".to_string(), cred.username.clone()), 122 | ("server".to_string(), cred.password.clone()), 123 | ]); 124 | search_response.push(KeyringItem { 125 | attributes, 126 | secret: cred.password.into_bytes(), 127 | }); 128 | } 129 | 130 | Self { search_response } 131 | } 132 | } 133 | 134 | #[derive(Debug, Clone)] 135 | pub struct Credential { 136 | pub username: String, 137 | pub password: String, 138 | } 139 | 140 | #[derive(Clone)] 141 | pub struct Credentials { 142 | keyring: Arc, 143 | creds: Arc>>, 144 | } 145 | 146 | impl Credentials { 147 | pub async fn new() -> anyhow::Result { 148 | let mut this = Self { 149 | keyring: Arc::new(RealKeyring { 150 | keyring: oo7::Keyring::new() 151 | .await 152 | .expect("Failed to start Secret Service"), 153 | }), 154 | creds: Default::default(), 155 | }; 156 | this.load().await?; 157 | Ok(this) 158 | } 159 | pub async fn new_nullable(credentials: Vec) -> anyhow::Result { 160 | let mut this = Self { 161 | keyring: Arc::new(NullableKeyring::with_credentials(credentials)), 162 | creds: Default::default(), 163 | }; 164 | this.load().await?; 165 | Ok(this) 166 | } 167 | pub async fn load(&mut self) -> anyhow::Result<()> { 168 | let attrs = HashMap::from([("type", "password")]); 169 | let values = self.keyring.search_items(attrs).await?; 170 | 171 | let mut lock = self.creds.write().unwrap(); 172 | lock.clear(); 173 | for item in values { 174 | let attrs = item.attributes().await; 175 | lock.insert( 176 | attrs["server"].to_string(), 177 | Credential { 178 | username: attrs["username"].to_string(), 179 | password: std::str::from_utf8(&item.secret().await)?.to_string(), 180 | }, 181 | ); 182 | } 183 | Ok(()) 184 | } 185 | pub fn get(&self, server: &str) -> Option { 186 | self.creds.read().unwrap().get(server).cloned() 187 | } 188 | pub fn list_all(&self) -> HashMap { 189 | self.creds.read().unwrap().clone() 190 | } 191 | pub async fn insert(&self, server: &str, username: &str, password: &str) -> anyhow::Result<()> { 192 | { 193 | if let Some(cred) = self.creds.read().unwrap().get(server) { 194 | if cred.username != username { 195 | anyhow::bail!("You can add only one account per server"); 196 | } 197 | } 198 | } 199 | let attrs = HashMap::from([ 200 | ("type", "password"), 201 | ("username", username), 202 | ("server", server), 203 | ]); 204 | self.keyring 205 | .create_item("Password", attrs, password, true) 206 | .await?; 207 | 208 | self.creds.write().unwrap().insert( 209 | server.to_string(), 210 | Credential { 211 | username: username.to_string(), 212 | password: password.to_string(), 213 | }, 214 | ); 215 | Ok(()) 216 | } 217 | pub async fn delete(&self, server: &str) -> anyhow::Result<()> { 218 | let creds = { 219 | self.creds 220 | .read() 221 | .unwrap() 222 | .get(server) 223 | .ok_or(anyhow::anyhow!("server creds not found"))? 224 | .clone() 225 | }; 226 | let attrs = HashMap::from([ 227 | ("type", "password"), 228 | ("username", &creds.username), 229 | ("server", server), 230 | ]); 231 | self.keyring.delete(attrs).await?; 232 | self.creds 233 | .write() 234 | .unwrap() 235 | .remove(server) 236 | .ok_or(anyhow::anyhow!("server creds not found"))?; 237 | Ok(()) 238 | } 239 | } 240 | -------------------------------------------------------------------------------- /ntfy-daemon/src/http_client.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use async_trait::async_trait; 3 | use reqwest::{header::HeaderMap, Client, Request, RequestBuilder, Response, ResponseBuilderExt}; 4 | use serde_json::{json, Value}; 5 | use std::collections::{HashMap, VecDeque}; 6 | use std::sync::Arc; 7 | use std::time::Duration; 8 | use tokio::sync::RwLock; 9 | use tokio::time; 10 | 11 | use crate::models; 12 | use crate::output_tracker::OutputTrackerAsync; 13 | 14 | // Structure to store request information for verification 15 | #[derive(Clone, Debug)] 16 | pub struct RequestInfo { 17 | pub url: String, 18 | pub method: String, 19 | pub headers: HeaderMap, 20 | pub body: Option>, 21 | } 22 | 23 | impl RequestInfo { 24 | fn from_request(request: &Request) -> Self { 25 | RequestInfo { 26 | url: request.url().to_string(), 27 | method: request.method().to_string(), 28 | headers: request.headers().clone(), 29 | body: None, // Note: Request body can't be accessed after it's built 30 | } 31 | } 32 | } 33 | 34 | #[async_trait] 35 | trait LightHttpClient: Send + Sync { 36 | fn get(&self, url: &str) -> RequestBuilder; 37 | fn post(&self, url: &str) -> RequestBuilder; 38 | async fn execute(&self, request: Request) -> Result; 39 | } 40 | 41 | #[async_trait] 42 | impl LightHttpClient for Client { 43 | fn get(&self, url: &str) -> RequestBuilder { 44 | self.get(url) 45 | } 46 | 47 | fn post(&self, url: &str) -> RequestBuilder { 48 | self.post(url) 49 | } 50 | 51 | async fn execute(&self, request: Request) -> Result { 52 | Ok(self.execute(request).await?) 53 | } 54 | } 55 | 56 | #[derive(Clone)] 57 | pub struct HttpClient { 58 | client: Arc, 59 | request_tracker: OutputTrackerAsync, 60 | } 61 | 62 | impl HttpClient { 63 | pub fn new(client: reqwest::Client) -> Self { 64 | Self { 65 | client: Arc::new(client), 66 | request_tracker: Default::default(), 67 | } 68 | } 69 | pub fn new_nullable(client: NullableClient) -> Self { 70 | Self { 71 | client: Arc::new(client), 72 | request_tracker: Default::default(), 73 | } 74 | } 75 | 76 | pub async fn request_tracker(&self) -> OutputTrackerAsync { 77 | self.request_tracker.enable().await; 78 | self.request_tracker.clone() 79 | } 80 | 81 | pub fn get(&self, url: &str) -> RequestBuilder { 82 | self.client.get(url) 83 | } 84 | 85 | pub fn post(&self, url: &str) -> RequestBuilder { 86 | self.client.post(url) 87 | } 88 | 89 | pub async fn execute(&self, request: Request) -> Result { 90 | self.request_tracker 91 | .push(RequestInfo::from_request(&request)) 92 | .await; 93 | 94 | Ok(self.client.execute(request).await?) 95 | } 96 | } 97 | 98 | #[derive(Clone, Default)] 99 | pub struct NullableClient { 100 | responses: Arc>>>, 101 | default_response: Arc Response + Send + Sync + 'static>>>>, 102 | } 103 | 104 | /// Builder for configuring NullableClient 105 | #[derive(Default)] 106 | pub struct NullableClientBuilder { 107 | responses: HashMap>, 108 | default_response: Option Response + Send + Sync + 'static>>, 109 | } 110 | 111 | impl NullableClientBuilder { 112 | pub fn new() -> Self { 113 | Self::default() 114 | } 115 | 116 | /// Add a single response for a specific URL 117 | pub fn response(mut self, url: impl Into, response: Response) -> Self { 118 | self.responses 119 | .entry(url.into()) 120 | .or_default() 121 | .push_back(response); 122 | self 123 | } 124 | 125 | /// Add multiple responses for a specific URL that will be returned in sequence 126 | pub fn responses(mut self, url: impl Into, responses: Vec) -> Self { 127 | self.responses.insert(url.into(), responses.into()); 128 | self 129 | } 130 | 131 | /// Set a default response generator for any unmatched URLs 132 | pub fn default_response( 133 | mut self, 134 | response: impl Fn() -> Response + Send + Sync + 'static, 135 | ) -> Self { 136 | self.default_response = Some(Box::new(response)); 137 | self 138 | } 139 | 140 | /// Helper method to quickly add a JSON response 141 | pub fn json_response( 142 | self, 143 | url: impl Into, 144 | status: u16, 145 | body: impl serde::Serialize, 146 | ) -> Result { 147 | let response = http::response::Builder::new() 148 | .status(status) 149 | .body(serde_json::to_string(&body)?) 150 | .unwrap() 151 | .into(); 152 | Ok(self.response(url, response)) 153 | } 154 | 155 | /// Helper method to quickly add a text response 156 | pub fn text_response( 157 | self, 158 | url: impl Into, 159 | status: u16, 160 | body: impl Into, 161 | ) -> Self { 162 | let response = http::response::Builder::new() 163 | .status(status) 164 | .body(body.into()) 165 | .unwrap() 166 | .into(); 167 | self.response(url, response) 168 | } 169 | 170 | pub fn build(self) -> NullableClient { 171 | NullableClient { 172 | responses: Arc::new(RwLock::new( 173 | self.responses 174 | .into_iter() 175 | .map(|(k, v)| (k, v.into())) 176 | .collect(), 177 | )), 178 | default_response: Arc::new(RwLock::new(self.default_response)), 179 | } 180 | } 181 | } 182 | 183 | impl NullableClient { 184 | pub fn builder() -> NullableClientBuilder { 185 | NullableClientBuilder::new() 186 | } 187 | } 188 | 189 | #[async_trait] 190 | impl LightHttpClient for NullableClient { 191 | fn get(&self, url: &str) -> RequestBuilder { 192 | Client::new().get(url) 193 | } 194 | 195 | fn post(&self, url: &str) -> RequestBuilder { 196 | Client::new().post(url) 197 | } 198 | 199 | async fn execute(&self, request: Request) -> Result { 200 | time::sleep(Duration::from_millis(1)).await; 201 | let url = request.url().to_string(); 202 | let mut responses = self.responses.write().await; 203 | 204 | if let Some(url_responses) = responses.get_mut(&url) { 205 | if let Some(response) = url_responses.pop_front() { 206 | // Remove the URL entry if no more responses 207 | if url_responses.is_empty() { 208 | responses.remove(&url); 209 | } 210 | Ok(response) 211 | } else { 212 | if let Some(default_fn) = &*self.default_response.read().await { 213 | Ok(default_fn()) 214 | } else { 215 | Err(anyhow::anyhow!("no response configured for URL: {}", url)) 216 | } 217 | } 218 | } else if let Some(default_fn) = &*self.default_response.read().await { 219 | Ok(default_fn()) 220 | } else { 221 | Err(anyhow::anyhow!("no response configured for URL: {}", url)) 222 | } 223 | } 224 | } 225 | 226 | #[cfg(test)] 227 | mod tests { 228 | use super::*; 229 | use serde_json::json; 230 | 231 | #[tokio::test] 232 | async fn test_nullable_with_builder() -> Result<()> { 233 | // Configure client using builder pattern 234 | let client = NullableClient::builder() 235 | .text_response("https://api.example.com/topic", 200, "ok") 236 | .json_response( 237 | "https://api.example.com/json", 238 | 200, 239 | json!({ "status": "success" }), 240 | )? 241 | .default_response(|| { 242 | http::response::Builder::new() 243 | .status(404) 244 | .body("not found") 245 | .unwrap() 246 | .into() 247 | }) 248 | .build(); 249 | 250 | let http_client = HttpClient::new_nullable(client); 251 | let request_tracker = http_client.request_tracker().await; 252 | 253 | // Test successful text response 254 | let request = http_client.get("https://api.example.com/topic").build()?; 255 | let response = http_client.execute(request).await?; 256 | assert_eq!(response.status(), 200); 257 | assert_eq!(response.text().await?, "ok"); 258 | 259 | // Test successful JSON response 260 | let request = http_client.get("https://api.example.com/json").build()?; 261 | let response = http_client.execute(request).await?; 262 | assert_eq!(response.status(), 200); 263 | assert_eq!(response.text().await?, r#"{"status":"success"}"#); 264 | 265 | // Test default response 266 | let request = http_client.get("https://api.example.com/unknown").build()?; 267 | let response = http_client.execute(request).await?; 268 | assert_eq!(response.status(), 404); 269 | assert_eq!(response.text().await?, "not found"); 270 | 271 | // Verify recorded requests 272 | let requests = request_tracker.items().await; 273 | assert_eq!(requests.len(), 3); 274 | 275 | Ok(()) 276 | } 277 | 278 | #[tokio::test] 279 | async fn test_sequence_of_responses() -> Result<()> { 280 | // Configure client with multiple responses for the same URL 281 | let client = NullableClient::builder() 282 | .responses( 283 | "https://api.example.com/sequence", 284 | vec![ 285 | http::response::Builder::new() 286 | .status(200) 287 | .body("first") 288 | .unwrap() 289 | .into(), 290 | http::response::Builder::new() 291 | .status(200) 292 | .body("second") 293 | .unwrap() 294 | .into(), 295 | ], 296 | ) 297 | .build(); 298 | 299 | let http_client = HttpClient::new_nullable(client); 300 | 301 | // First request gets first response 302 | let request = http_client 303 | .get("https://api.example.com/sequence") 304 | .build()?; 305 | let response = http_client.execute(request).await?; 306 | assert_eq!(response.text().await?, "first"); 307 | 308 | // Second request gets second response 309 | let request = http_client 310 | .get("https://api.example.com/sequence") 311 | .build()?; 312 | let response = http_client.execute(request).await?; 313 | assert_eq!(response.text().await?, "second"); 314 | 315 | // Third request fails (no more responses) 316 | let request = http_client 317 | .get("https://api.example.com/sequence") 318 | .build()?; 319 | let result = http_client.execute(request).await; 320 | assert!(result.is_err()); 321 | 322 | Ok(()) 323 | } 324 | } 325 | -------------------------------------------------------------------------------- /ntfy-daemon/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod actor_utils; 2 | pub mod credentials; 3 | mod http_client; 4 | mod listener; 5 | pub mod message_repo; 6 | pub mod models; 7 | mod ntfy; 8 | mod output_tracker; 9 | pub mod retry; 10 | mod subscription; 11 | 12 | pub use listener::*; 13 | pub use ntfy::start; 14 | pub use ntfy::NtfyHandle; 15 | use std::sync::Arc; 16 | pub use subscription::SubscriptionHandle; 17 | 18 | use http_client::HttpClient; 19 | 20 | #[derive(Clone)] 21 | pub struct SharedEnv { 22 | db: message_repo::Db, 23 | notifier: Arc, 24 | http_client: HttpClient, 25 | network_monitor: Arc, 26 | credentials: credentials::Credentials, 27 | } 28 | 29 | #[derive(thiserror::Error, Debug)] 30 | pub enum Error { 31 | #[error("topic {0} must not be empty and must contain only alphanumeric characters and _ (underscore)")] 32 | InvalidTopic(String), 33 | #[error("invalid server base url {0:?}")] 34 | InvalidServer(#[from] url::ParseError), 35 | #[error("multiple errors in subscription model: {0:?}")] 36 | InvalidSubscription(Vec), 37 | #[error("duplicate message")] 38 | DuplicateMessage, 39 | #[error("can't parse the minimum set of required fields from the message {0}")] 40 | InvalidMinMessage(String, #[source] serde_json::Error), 41 | #[error("can't parse the complete message {0}")] 42 | InvalidMessage(String, #[source] serde_json::Error), 43 | #[error("database error")] 44 | Db(#[from] rusqlite::Error), 45 | #[error("subscription not found while {0}")] 46 | SubscriptionNotFound(String), 47 | } 48 | -------------------------------------------------------------------------------- /ntfy-daemon/src/listener.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | use std::time::Duration; 3 | 4 | use futures::{StreamExt, TryStreamExt}; 5 | use serde::{Deserialize, Serialize}; 6 | use tokio::io::AsyncBufReadExt; 7 | use tokio::task::{self, spawn_local, LocalSet}; 8 | use tokio::{ 9 | select, 10 | sync::{mpsc, oneshot}, 11 | }; 12 | use tokio_stream::wrappers::LinesStream; 13 | use tracing::{debug, error, info, warn, Instrument, Span}; 14 | 15 | use crate::credentials::Credentials; 16 | use crate::http_client::HttpClient; 17 | use crate::{models, Error}; 18 | 19 | #[derive(Clone, Debug, Serialize, Deserialize)] 20 | #[serde(tag = "event")] 21 | pub enum ServerEvent { 22 | #[serde(rename = "open")] 23 | Open { 24 | id: String, 25 | time: usize, 26 | expires: Option, 27 | topic: String, 28 | }, 29 | #[serde(rename = "message")] 30 | Message(models::ReceivedMessage), 31 | #[serde(rename = "keepalive")] 32 | KeepAlive { 33 | id: String, 34 | time: usize, 35 | expires: Option, 36 | topic: String, 37 | }, 38 | } 39 | 40 | #[derive(Debug, Clone)] 41 | pub enum ListenerEvent { 42 | Message(models::ReceivedMessage), 43 | ConnectionStateChanged(ConnectionState), 44 | } 45 | 46 | #[derive(Clone)] 47 | pub struct ListenerConfig { 48 | pub(crate) http_client: HttpClient, 49 | pub(crate) credentials: Credentials, 50 | pub(crate) endpoint: String, 51 | pub(crate) topic: String, 52 | pub(crate) since: u64, 53 | } 54 | 55 | #[derive(Debug)] 56 | pub enum ListenerCommand { 57 | Restart, 58 | Shutdown, 59 | GetState(oneshot::Sender), 60 | } 61 | 62 | fn topic_request( 63 | client: &HttpClient, 64 | endpoint: &str, 65 | topic: &str, 66 | since: u64, 67 | username: Option<&str>, 68 | password: Option<&str>, 69 | ) -> anyhow::Result { 70 | let url = models::Subscription::build_url(endpoint, topic, since)?; 71 | let mut req = client 72 | .get(url.as_str()) 73 | .header("Content-Type", "application/x-ndjson") 74 | .header("Transfer-Encoding", "chunked"); 75 | if let Some(username) = username { 76 | req = req.basic_auth(username, password); 77 | } 78 | 79 | Ok(req.build()?) 80 | } 81 | 82 | async fn response_lines( 83 | res: impl tokio::io::AsyncBufRead, 84 | ) -> Result>, reqwest::Error> { 85 | let lines = LinesStream::new(res.lines()); 86 | Ok(lines) 87 | } 88 | 89 | #[derive(Clone, Debug)] 90 | pub enum ConnectionState { 91 | Unitialized, 92 | Connected, 93 | Reconnecting { 94 | retry_count: u64, 95 | delay: Duration, 96 | error: Option>, 97 | }, 98 | } 99 | 100 | pub struct ListenerActor { 101 | pub event_tx: async_channel::Sender, 102 | pub commands_rx: Option>, 103 | pub config: ListenerConfig, 104 | pub state: ConnectionState, 105 | } 106 | 107 | impl ListenerActor { 108 | pub async fn run_loop(mut self) { 109 | let span = tracing::info_span!("listener_loop", topic = %self.config.topic); 110 | async { 111 | let mut commands_rx = self.commands_rx.take().unwrap(); 112 | loop { 113 | select! { 114 | _ = self.run_supervised_loop() => { 115 | info!("supervised loop ended"); 116 | break; 117 | }, 118 | cmd = commands_rx.recv() => { 119 | match cmd { 120 | Some(ListenerCommand::Restart) => { 121 | info!("restarting listener"); 122 | continue; 123 | } 124 | Some(ListenerCommand::Shutdown) => { 125 | info!("shutting down listener"); 126 | break; 127 | } 128 | Some(ListenerCommand::GetState(tx)) => { 129 | debug!("getting listener state"); 130 | let state = self.state.clone(); 131 | if tx.send(state).is_err() { 132 | warn!("failed to send state - receiver dropped"); 133 | } 134 | } 135 | None => { 136 | error!("command channel closed"); 137 | break; 138 | } 139 | } 140 | } 141 | } 142 | } 143 | } 144 | .instrument(span) 145 | .await; 146 | } 147 | 148 | async fn set_state(&mut self, state: ConnectionState) { 149 | self.state = state.clone(); 150 | self.event_tx 151 | .send(ListenerEvent::ConnectionStateChanged(state)) 152 | .await 153 | .unwrap(); 154 | } 155 | async fn run_supervised_loop(&mut self) { 156 | let span = tracing::info_span!("supervised_loop"); 157 | async { 158 | let retrier = || { 159 | crate::retry::WaitExponentialRandom::builder() 160 | .min(Duration::from_secs(1)) 161 | .max(Duration::from_secs(5 * 60)) 162 | .build() 163 | }; 164 | let mut retry = retrier(); 165 | loop { 166 | let start_time = std::time::Instant::now(); 167 | 168 | if let Err(e) = self.recv_and_forward_loop().await { 169 | let uptime = std::time::Instant::now().duration_since(start_time); 170 | // Reset retry delay to minimum if uptime was decent enough 171 | if uptime > Duration::from_secs(60 * 4) { 172 | debug!("resetting retry delay due to sufficient uptime"); 173 | retry = retrier(); 174 | } 175 | error!(error = ?e, "connection error"); 176 | self.set_state(ConnectionState::Reconnecting { 177 | retry_count: retry.count(), 178 | delay: retry.next_delay(), 179 | error: Some(Arc::new(e)), 180 | }) 181 | .await; 182 | info!(delay = ?retry.next_delay(), "waiting before reconnect attempt"); 183 | retry.wait().await; 184 | } else { 185 | break; 186 | } 187 | } 188 | } 189 | .instrument(span) 190 | .await; 191 | } 192 | 193 | async fn recv_and_forward_loop(&mut self) -> anyhow::Result<()> { 194 | let span = tracing::info_span!("receive_loop", 195 | endpoint = %self.config.endpoint, 196 | topic = %self.config.topic, 197 | since = %self.config.since 198 | ); 199 | async { 200 | let creds = self.config.credentials.get(&self.config.endpoint); 201 | debug!("creating request"); 202 | let req = topic_request( 203 | &self.config.http_client, 204 | &self.config.endpoint, 205 | &self.config.topic, 206 | self.config.since, 207 | creds.as_ref().map(|x| x.username.as_str()), 208 | creds.as_ref().map(|x| x.password.as_str()), 209 | ); 210 | 211 | debug!("executing request"); 212 | let res = self.config.http_client.execute(req?).await?; 213 | let res = res.error_for_status()?; 214 | let reader = tokio_util::io::StreamReader::new( 215 | res.bytes_stream() 216 | .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string())), 217 | ); 218 | let stream = response_lines(reader).await?; 219 | tokio::pin!(stream); 220 | 221 | self.set_state(ConnectionState::Connected).await; 222 | info!("connection established"); 223 | 224 | info!(topic = %&self.config.topic, "listening"); 225 | while let Some(msg) = stream.next().await { 226 | let msg = msg?; 227 | 228 | let min_msg = serde_json::from_str::(&msg) 229 | .map_err(|e| Error::InvalidMinMessage(msg.to_string(), e))?; 230 | self.config.since = min_msg.time.max(self.config.since); 231 | 232 | let event = serde_json::from_str(&msg) 233 | .map_err(|e| Error::InvalidMessage(msg.to_string(), e))?; 234 | 235 | match event { 236 | ServerEvent::Message(msg) => { 237 | debug!(id = %msg.id, "forwarding message"); 238 | self.event_tx 239 | .send(ListenerEvent::Message(msg)) 240 | .await 241 | .unwrap(); 242 | } 243 | ServerEvent::KeepAlive { id, .. } => { 244 | debug!(id = %id, "received keepalive"); 245 | } 246 | ServerEvent::Open { id, .. } => { 247 | debug!(id = %id, "received open event"); 248 | } 249 | } 250 | } 251 | 252 | Ok(()) 253 | } 254 | .instrument(span) 255 | .await 256 | } 257 | } 258 | 259 | // Reliable listener implementation 260 | #[derive(Clone)] 261 | pub struct ListenerHandle { 262 | pub events: async_channel::Receiver, 263 | pub config: ListenerConfig, 264 | pub commands: mpsc::Sender, 265 | } 266 | 267 | impl ListenerHandle { 268 | pub fn new(config: ListenerConfig) -> ListenerHandle { 269 | let (event_tx, event_rx) = async_channel::bounded(64); 270 | let (commands_tx, commands_rx) = mpsc::channel(1); 271 | 272 | let config_clone = config.clone(); 273 | 274 | // use a new local set to isolate panics 275 | let local_set = LocalSet::new(); 276 | local_set.spawn_local(async move { 277 | let this = ListenerActor { 278 | event_tx, 279 | commands_rx: Some(commands_rx), 280 | config: config_clone, 281 | state: ConnectionState::Unitialized, 282 | }; 283 | 284 | this.run_loop().await; 285 | }); 286 | spawn_local(local_set); 287 | 288 | Self { 289 | events: event_rx, 290 | config, 291 | commands: commands_tx, 292 | } 293 | } 294 | 295 | // the response will be sent as an event in self.events 296 | pub async fn state(&self) -> ConnectionState { 297 | let (tx, rx) = oneshot::channel(); 298 | self.commands 299 | .send(ListenerCommand::GetState(tx)) 300 | .await 301 | .unwrap(); 302 | rx.await.unwrap() 303 | } 304 | } 305 | 306 | #[cfg(test)] 307 | mod tests { 308 | use models::Subscription; 309 | use serde_json::json; 310 | use task::LocalSet; 311 | 312 | use crate::http_client::NullableClient; 313 | 314 | use super::*; 315 | 316 | #[tokio::test] 317 | async fn test_listener_reconnects_on_http_status_500() { 318 | let local_set = LocalSet::new(); 319 | local_set 320 | .spawn_local(async { 321 | let http_client = HttpClient::new_nullable({ 322 | let url = Subscription::build_url("http://localhost", "test", 0).unwrap(); 323 | let nullable = NullableClient::builder() 324 | .text_response(url.clone(), 500, "failed") 325 | .json_response(url, 200, json!({"id":"SLiKI64DOt","time":1635528757,"event":"open","topic":"mytopic"})).unwrap() 326 | .build(); 327 | nullable 328 | }); 329 | let credentials = Credentials::new_nullable(vec![]).await.unwrap(); 330 | 331 | let config = ListenerConfig { 332 | http_client, 333 | credentials, 334 | endpoint: "http://localhost".to_string(), 335 | topic: "test".to_string(), 336 | since: 0, 337 | }; 338 | 339 | let listener = ListenerHandle::new(config.clone()); 340 | let items: Vec<_> = listener.events.take(3).collect().await; 341 | 342 | dbg!(&items); 343 | assert!(matches!( 344 | &items[..], 345 | &[ 346 | ListenerEvent::ConnectionStateChanged(ConnectionState::Unitialized), 347 | ListenerEvent::ConnectionStateChanged(ConnectionState::Reconnecting { .. }), 348 | ListenerEvent::ConnectionStateChanged(ConnectionState::Connected { .. }), 349 | ] 350 | )); 351 | }); 352 | local_set.await; 353 | } 354 | 355 | #[tokio::test] 356 | async fn test_listener_reconnects_on_invalid_message() { 357 | let local_set = LocalSet::new(); 358 | local_set 359 | .spawn_local(async { 360 | let http_client = HttpClient::new_nullable({ 361 | let url = Subscription::build_url("http://localhost", "test", 0).unwrap(); 362 | let nullable = NullableClient::builder() 363 | .text_response(url.clone(), 200, "invalid message") 364 | .json_response(url, 200, json!({"id":"SLiKI64DOt","time":1635528757,"event":"open","topic":"mytopic"})).unwrap() 365 | .build(); 366 | nullable 367 | }); 368 | let credentials = Credentials::new_nullable(vec![]).await.unwrap(); 369 | 370 | let config = ListenerConfig { 371 | http_client, 372 | credentials, 373 | endpoint: "http://localhost".to_string(), 374 | topic: "test".to_string(), 375 | since: 0, 376 | }; 377 | 378 | let listener = ListenerHandle::new(config.clone()); 379 | let items: Vec<_> = listener.events.take(3).collect().await; 380 | 381 | dbg!(&items); 382 | assert!(matches!( 383 | &items[..], 384 | &[ 385 | ListenerEvent::ConnectionStateChanged(ConnectionState::Unitialized), 386 | ListenerEvent::ConnectionStateChanged(ConnectionState::Reconnecting { .. }), 387 | ListenerEvent::ConnectionStateChanged(ConnectionState::Connected { .. }), 388 | ] 389 | )); 390 | }); 391 | local_set.await; 392 | } 393 | } 394 | -------------------------------------------------------------------------------- /ntfy-daemon/src/message_repo/migrations/00.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS server ( 2 | id INTEGER PRIMARY KEY, 3 | endpoint TEXT NOT NULL UNIQUE, 4 | timeout INTEGER 5 | ); 6 | 7 | CREATE TABLE IF NOT EXISTS subscription ( 8 | topic TEXT, 9 | display_name TEXT, 10 | muted INTEGER NOT NULL DEFAULT 0, 11 | server INTEGER REFERENCES server(id), 12 | archived INTEGER NOT NULL DEFAULT 0, 13 | reserved INTEGER NOT NULL DEFAULT 0, 14 | read_until INTEGER NOT NULL DEFAULT 0, 15 | symbolic_icon TEXT, 16 | PRIMARY KEY (server, topic) 17 | ); 18 | 19 | CREATE TABLE IF NOT EXISTS message ( 20 | server INTEGER, 21 | data TEXT NOT NULL, 22 | topic TEXT AS (data ->> '$.topic'), -- For the FOREIGN KEY constraint 23 | FOREIGN KEY (server, topic) REFERENCES subscription(server, topic) ON DELETE CASCADE 24 | ); 25 | 26 | CREATE INDEX IF NOT EXISTS message_by_time ON message (data ->> '$.time'); 27 | -- I can't put a JSON expression inside a UNIQUE constraint, 28 | -- but I can do it on a UNIQUE INDEX 29 | CREATE UNIQUE INDEX IF NOT EXISTS server_and_message_id ON message (server, data ->> '$.id'); 30 | -------------------------------------------------------------------------------- /ntfy-daemon/src/message_repo/mod.rs: -------------------------------------------------------------------------------- 1 | use std::sync::{Arc, RwLock}; 2 | use std::{cell::RefCell, rc::Rc}; 3 | 4 | use rusqlite::{params, Connection, Result}; 5 | use tracing::info; 6 | 7 | use crate::models; 8 | use crate::Error; 9 | 10 | #[derive(Clone, Debug)] 11 | pub struct Db { 12 | conn: Arc>, 13 | } 14 | 15 | impl Db { 16 | pub fn connect(path: &str) -> Result { 17 | let mut this = Self { 18 | conn: Arc::new(RwLock::new(Connection::open(path)?)), 19 | }; 20 | { 21 | this.conn.read().unwrap().execute_batch( 22 | "PRAGMA foreign_keys = ON; 23 | PRAGMA journal_mode = wal;", 24 | )?; 25 | } 26 | this.migrate()?; 27 | Ok(this) 28 | } 29 | fn migrate(&mut self) -> Result<()> { 30 | self.conn 31 | .read() 32 | .unwrap() 33 | .execute_batch(include_str!("./migrations/00.sql"))?; 34 | Ok(()) 35 | } 36 | fn get_or_insert_server(&mut self, server: &str) -> Result { 37 | let mut conn = self.conn.write().unwrap(); 38 | let tx = conn.transaction()?; 39 | let mut res = tx.query_row( 40 | "SELECT id 41 | FROM server 42 | WHERE endpoint = ?1", 43 | params![server,], 44 | |row| { 45 | let id: i64 = row.get(0)?; 46 | Ok(id) 47 | }, 48 | ); 49 | if let Err(rusqlite::Error::QueryReturnedNoRows) = res { 50 | tx.execute( 51 | "INSERT INTO server (id, endpoint) VALUES (NULL, ?1)", 52 | params![server,], 53 | )?; 54 | res = Ok(tx.last_insert_rowid()); 55 | } 56 | tx.commit()?; 57 | res 58 | } 59 | pub fn insert_message(&mut self, server: &str, json_data: &str) -> Result<(), Error> { 60 | let server_id = self.get_or_insert_server(server)?; 61 | let res = self.conn.read().unwrap().execute( 62 | "INSERT INTO message (server, data) VALUES (?1, ?2)", 63 | params![server_id, json_data], 64 | ); 65 | match res { 66 | Err(rusqlite::Error::SqliteFailure(_, Some(text))) 67 | if text.starts_with("UNIQUE constraint failed") => 68 | { 69 | Err(Error::DuplicateMessage) 70 | } 71 | Err(e) => Err(Error::Db(e)), 72 | Ok(_) => Ok(()), 73 | } 74 | } 75 | pub fn list_messages( 76 | &self, 77 | server: &str, 78 | topic: &str, 79 | since: u64, 80 | ) -> Result, rusqlite::Error> { 81 | let conn = self.conn.read().unwrap(); 82 | let mut stmt = conn.prepare( 83 | " 84 | SELECT data 85 | FROM subscription sub 86 | JOIN server s ON sub.server = s.id 87 | JOIN message m ON m.server = sub.server AND m.topic = sub.topic 88 | WHERE s.endpoint = ?1 AND m.topic = ?2 AND m.data ->> 'time' >= ?3 89 | ORDER BY m.data ->> 'time' 90 | ", 91 | )?; 92 | let msgs: Result, _> = stmt 93 | .query_map(params![server, topic, since], |row| row.get(0))? 94 | .collect(); 95 | msgs 96 | } 97 | pub fn insert_subscription(&mut self, sub: models::Subscription) -> Result<(), Error> { 98 | let server_id = self.get_or_insert_server(&sub.server)?; 99 | self.conn.read().unwrap().execute( 100 | "INSERT INTO subscription (server, topic, display_name, reserved, muted, archived) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", 101 | params![ 102 | server_id, 103 | sub.topic, 104 | sub.display_name, 105 | sub.reserved, 106 | sub.muted, 107 | sub.archived 108 | ], 109 | )?; 110 | Ok(()) 111 | } 112 | pub fn remove_subscription(&mut self, server: &str, topic: &str) -> Result<(), Error> { 113 | let server_id = self.get_or_insert_server(server)?; 114 | let res = self.conn.read().unwrap().execute( 115 | "DELETE FROM subscription 116 | WHERE server = ?1 AND topic = ?2", 117 | params![server_id, topic], 118 | )?; 119 | if res == 0 { 120 | return Err(Error::SubscriptionNotFound("removing subscription".into())); 121 | } 122 | Ok(()) 123 | } 124 | pub fn list_subscriptions(&mut self) -> Result, Error> { 125 | let conn = self.conn.read().unwrap(); 126 | let mut stmt = conn.prepare( 127 | "SELECT server.endpoint, sub.topic, sub.display_name, sub.reserved, sub.muted, sub.archived, sub.symbolic_icon, sub.read_until 128 | FROM subscription sub 129 | JOIN server ON server.id = sub.server 130 | ORDER BY server.endpoint, sub.display_name, sub.topic 131 | ", 132 | )?; 133 | let rows = stmt.query_map(params![], |row| { 134 | Ok(models::Subscription { 135 | server: row.get(0)?, 136 | topic: row.get(1)?, 137 | display_name: row.get(2)?, 138 | reserved: row.get(3)?, 139 | muted: row.get(4)?, 140 | archived: row.get(5)?, 141 | symbolic_icon: row.get(6)?, 142 | read_until: row.get(7)?, 143 | }) 144 | })?; 145 | let subs: Result, rusqlite::Error> = rows.collect(); 146 | Ok(subs?) 147 | } 148 | 149 | pub fn update_subscription(&mut self, sub: models::Subscription) -> Result<(), Error> { 150 | let server_id = self.get_or_insert_server(&sub.server)?; 151 | let res = self.conn.read().unwrap().execute( 152 | "UPDATE subscription 153 | SET display_name = ?1, reserved = ?2, muted = ?3, archived = ?4, read_until = ?5 154 | WHERE server = ?6 AND topic = ?7", 155 | params![ 156 | sub.display_name, 157 | sub.reserved, 158 | sub.muted, 159 | sub.archived, 160 | sub.read_until, 161 | server_id, 162 | sub.topic, 163 | ], 164 | )?; 165 | if res == 0 { 166 | return Err(Error::SubscriptionNotFound("updating subscription".into())); 167 | } 168 | info!(info = ?sub, "stored subscription info"); 169 | Ok(()) 170 | } 171 | 172 | pub fn update_read_until( 173 | &mut self, 174 | server: &str, 175 | topic: &str, 176 | value: u64, 177 | ) -> Result<(), Error> { 178 | let server_id = self.get_or_insert_server(server).unwrap(); 179 | let conn = self.conn.read().unwrap(); 180 | let res = conn.execute( 181 | "UPDATE subscription 182 | SET read_until = ?3 183 | WHERE topic = ?2 AND server = ?1 184 | ", 185 | params![server_id, topic, value], 186 | )?; 187 | if res == 0 { 188 | return Err(Error::SubscriptionNotFound("updating read_until".into())); 189 | } 190 | Ok(()) 191 | } 192 | pub fn delete_messages(&mut self, server: &str, topic: &str) -> Result<(), Error> { 193 | let server_id = self.get_or_insert_server(server).unwrap(); 194 | let conn = self.conn.read().unwrap(); 195 | let res = conn.execute( 196 | "DELETE FROM message 197 | WHERE topic = ?2 AND server = ?1 198 | ", 199 | params![server_id, topic], 200 | )?; 201 | if res == 0 { 202 | return Err(Error::SubscriptionNotFound("deleting messages".into())); 203 | } 204 | Ok(()) 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /ntfy-daemon/src/models.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::pin::Pin; 3 | use std::sync::OnceLock; 4 | 5 | use futures::stream::Stream; 6 | use regex::Regex; 7 | use serde::{Deserialize, Serialize}; 8 | 9 | use crate::Error; 10 | 11 | pub const DEFAULT_SERVER: &str = "https://ntfy.sh"; 12 | static EMOJI_MAP: OnceLock> = OnceLock::new(); 13 | 14 | fn emoji_map() -> &'static HashMap { 15 | EMOJI_MAP.get_or_init(move || { 16 | serde_json::from_str(include_str!("../data/mailer_emoji_map.json")).unwrap() 17 | }) 18 | } 19 | 20 | pub fn validate_topic(topic: &str) -> Result<&str, Error> { 21 | let re = Regex::new(r"^[\w\-]{1,64}$").unwrap(); 22 | if re.is_match(topic) { 23 | Ok(topic) 24 | } else { 25 | Err(Error::InvalidTopic(topic.to_string())) 26 | } 27 | } 28 | 29 | #[derive(Default, Clone, Debug, Serialize, Deserialize)] 30 | pub struct ReceivedMessage { 31 | pub id: String, 32 | pub topic: String, 33 | pub expires: Option, 34 | pub message: Option, 35 | #[serde(default = "Default::default")] 36 | pub time: u64, 37 | #[serde(skip_serializing_if = "Option::is_none")] 38 | pub title: Option, 39 | #[serde(default)] 40 | #[serde(skip_serializing_if = "Vec::is_empty")] 41 | pub tags: Vec, 42 | #[serde(skip_serializing_if = "Option::is_none")] 43 | pub priority: Option, 44 | #[serde(skip_serializing_if = "Option::is_none")] 45 | #[serde(default)] 46 | pub attachment: Option, 47 | #[serde(skip_serializing_if = "Option::is_none")] 48 | pub icon: Option, 49 | #[serde(skip_serializing_if = "Option::is_none")] 50 | pub filename: Option, 51 | #[serde(skip_serializing_if = "Option::is_none")] 52 | pub delay: Option, 53 | #[serde(skip_serializing_if = "Option::is_none")] 54 | pub email: Option, 55 | #[serde(skip_serializing_if = "Option::is_none")] 56 | pub call: Option, 57 | #[serde(default)] 58 | #[serde(skip_serializing_if = "Vec::is_empty")] 59 | pub actions: Vec, 60 | } 61 | 62 | impl ReceivedMessage { 63 | fn extend_with_emojis(&self, text: &mut String) { 64 | // Add emojis 65 | for t in &self.tags { 66 | if let Some(emoji) = emoji_map().get(t) { 67 | text.push_str(emoji); 68 | } 69 | } 70 | } 71 | pub fn display_title(&self) -> Option { 72 | self.title.as_ref().map(|title| { 73 | let mut title_text = String::new(); 74 | self.extend_with_emojis(&mut title_text); 75 | 76 | if !title_text.is_empty() { 77 | title_text.push(' '); 78 | } 79 | 80 | title_text.push_str(title); 81 | title_text 82 | }) 83 | } 84 | pub fn notification_title(&self, subscription: &Subscription) -> String { 85 | self.display_title() 86 | .or(if subscription.display_name.is_empty() { 87 | None 88 | } else { 89 | Some(subscription.display_name.to_string()) 90 | }) 91 | .unwrap_or(self.topic.to_string()) 92 | } 93 | 94 | pub fn display_message(&self) -> Option { 95 | self.message.as_ref().map(|message| { 96 | let mut out = String::new(); 97 | if self.title.is_none() { 98 | self.extend_with_emojis(&mut out); 99 | } 100 | if !out.is_empty() { 101 | out.push(' '); 102 | } 103 | 104 | out.push_str(message); 105 | out 106 | }) 107 | } 108 | } 109 | 110 | #[derive(Default, Clone, Debug, Serialize, Deserialize)] 111 | pub struct OutgoingMessage { 112 | pub topic: String, 113 | pub message: Option, 114 | #[serde(default = "Default::default")] 115 | pub time: u64, 116 | #[serde(skip_serializing_if = "Option::is_none")] 117 | pub title: Option, 118 | #[serde(default)] 119 | #[serde(skip_serializing_if = "Vec::is_empty")] 120 | pub tags: Vec, 121 | #[serde(skip_serializing_if = "Option::is_none")] 122 | pub priority: Option, 123 | #[serde(skip_serializing_if = "Option::is_none")] 124 | #[serde(default)] 125 | pub attachment: Option, 126 | #[serde(skip_serializing_if = "Option::is_none")] 127 | pub icon: Option, 128 | #[serde(skip_serializing_if = "Option::is_none")] 129 | pub filename: Option, 130 | #[serde(skip_serializing_if = "Option::is_none")] 131 | pub delay: Option, 132 | #[serde(skip_serializing_if = "Option::is_none")] 133 | pub email: Option, 134 | #[serde(skip_serializing_if = "Option::is_none")] 135 | pub call: Option, 136 | #[serde(default)] 137 | #[serde(skip_serializing_if = "Vec::is_empty")] 138 | pub actions: Vec, 139 | } 140 | 141 | #[derive(Clone, Debug, Serialize, Deserialize)] 142 | pub struct MinMessage { 143 | pub id: String, 144 | pub topic: String, 145 | pub time: u64, 146 | } 147 | 148 | #[derive(Clone, Debug, Serialize, Deserialize)] 149 | pub struct Attachment { 150 | pub name: String, 151 | pub url: url::Url, 152 | #[serde(rename = "type")] 153 | #[serde(skip_serializing_if = "Option::is_none")] 154 | pub atype: Option, 155 | #[serde(skip_serializing_if = "Option::is_none")] 156 | pub size: Option, 157 | #[serde(skip_serializing_if = "Option::is_none")] 158 | pub expires: Option, 159 | } 160 | 161 | impl Attachment { 162 | pub fn is_image(&self) -> bool { 163 | let Some(ext) = self.name.split('.').last() else { 164 | return false; 165 | }; 166 | ["jpeg", "jpg", "png", "webp", "gif"].contains(&ext) 167 | } 168 | } 169 | 170 | #[derive(Clone, Debug)] 171 | pub struct Subscription { 172 | pub server: String, 173 | pub topic: String, 174 | pub display_name: String, 175 | pub muted: bool, 176 | pub archived: bool, 177 | pub reserved: bool, 178 | pub symbolic_icon: Option, 179 | pub read_until: u64, 180 | } 181 | 182 | impl Subscription { 183 | pub fn build_url(server: &str, topic: &str, since: u64) -> Result { 184 | let mut url = url::Url::parse(server)?; 185 | url.path_segments_mut() 186 | .map_err(|_| url::ParseError::RelativeUrlWithCannotBeABaseBase)? 187 | .push(topic) 188 | .push("json"); 189 | url.query_pairs_mut() 190 | .append_pair("since", &since.to_string()); 191 | Ok(url) 192 | } 193 | pub fn build_auth_url(server: &str, topic: &str) -> Result { 194 | let mut url = url::Url::parse(server)?; 195 | url.path_segments_mut() 196 | .map_err(|_| url::ParseError::RelativeUrlWithCannotBeABaseBase)? 197 | .push(topic) 198 | .push("auth"); 199 | Ok(url) 200 | } 201 | pub fn validate(self) -> Result { 202 | let mut errs = vec![]; 203 | if let Err(e) = validate_topic(&self.topic) { 204 | errs.push(e); 205 | }; 206 | if let Err(e) = Self::build_url(&self.server, &self.topic, 0) { 207 | errs.push(e); 208 | }; 209 | if !errs.is_empty() { 210 | return Err(Error::InvalidSubscription(errs)); 211 | } 212 | Ok(self) 213 | } 214 | pub fn builder(topic: String) -> SubscriptionBuilder { 215 | SubscriptionBuilder::new(topic) 216 | } 217 | } 218 | 219 | #[derive(Clone)] 220 | pub struct SubscriptionBuilder { 221 | server: String, 222 | topic: String, 223 | muted: bool, 224 | archived: bool, 225 | reserved: bool, 226 | symbolic_icon: Option, 227 | display_name: String, 228 | } 229 | 230 | impl SubscriptionBuilder { 231 | pub fn new(topic: String) -> Self { 232 | Self { 233 | server: DEFAULT_SERVER.to_string(), 234 | topic, 235 | muted: false, 236 | archived: false, 237 | reserved: false, 238 | symbolic_icon: None, 239 | display_name: String::new(), 240 | } 241 | } 242 | 243 | pub fn server(mut self, server: String) -> Self { 244 | self.server = server; 245 | self 246 | } 247 | 248 | pub fn muted(mut self, muted: bool) -> Self { 249 | self.muted = muted; 250 | self 251 | } 252 | 253 | pub fn archived(mut self, archived: bool) -> Self { 254 | self.archived = archived; 255 | self 256 | } 257 | 258 | pub fn reserved(mut self, reserved: bool) -> Self { 259 | self.reserved = reserved; 260 | self 261 | } 262 | 263 | pub fn symbolic_icon(mut self, symbolic_icon: Option) -> Self { 264 | self.symbolic_icon = symbolic_icon; 265 | self 266 | } 267 | 268 | pub fn display_name(mut self, display_name: String) -> Self { 269 | self.display_name = display_name; 270 | self 271 | } 272 | 273 | pub fn build(self) -> Result { 274 | let res = Subscription { 275 | server: self.server, 276 | topic: self.topic, 277 | muted: self.muted, 278 | archived: self.archived, 279 | reserved: self.reserved, 280 | symbolic_icon: self.symbolic_icon, 281 | display_name: self.display_name, 282 | read_until: 0, 283 | }; 284 | res.validate() 285 | } 286 | } 287 | 288 | fn default_method() -> String { 289 | "POST".to_string() 290 | } 291 | #[derive(Clone, Debug, Serialize, Deserialize)] 292 | #[serde(tag = "action")] 293 | pub enum Action { 294 | #[serde(rename = "view")] 295 | View { 296 | label: String, 297 | url: String, 298 | #[serde(default)] 299 | clear: bool, 300 | }, 301 | #[serde(rename = "http")] 302 | Http { 303 | label: String, 304 | url: String, 305 | #[serde(default = "default_method")] 306 | method: String, 307 | #[serde(default)] 308 | headers: HashMap, 309 | #[serde(default)] 310 | body: String, 311 | #[serde(default)] 312 | clear: bool, 313 | }, 314 | #[serde(rename = "broadcast")] 315 | Broadcast { 316 | label: String, 317 | intent: Option, 318 | #[serde(default)] 319 | extras: HashMap, 320 | #[serde(default)] 321 | clear: bool, 322 | }, 323 | } 324 | 325 | #[derive(Debug, PartialEq, Copy, Clone, Default)] 326 | pub enum Status { 327 | #[default] 328 | Down, 329 | Degraded, 330 | Up, 331 | } 332 | 333 | impl From for Status { 334 | fn from(item: u8) -> Self { 335 | match item { 336 | 0 => Status::Down, 337 | 1 => Status::Degraded, 338 | 2 => Status::Up, 339 | _ => Status::Down, 340 | } 341 | } 342 | } 343 | 344 | impl From for u8 { 345 | fn from(item: Status) -> Self { 346 | match item { 347 | Status::Down => 0, 348 | Status::Degraded => 1, 349 | Status::Up => 2, 350 | } 351 | } 352 | } 353 | 354 | #[derive(Clone, Debug)] 355 | pub struct Account { 356 | pub server: String, 357 | pub username: String, 358 | } 359 | 360 | pub struct Notification { 361 | pub title: String, 362 | pub body: String, 363 | pub actions: Vec, 364 | } 365 | 366 | pub trait NotificationProxy: Sync + Send { 367 | fn send(&self, n: Notification) -> anyhow::Result<()>; 368 | } 369 | 370 | pub trait NetworkMonitorProxy: Sync + Send { 371 | fn listen(&self) -> Pin>>; 372 | } 373 | 374 | pub struct NullNotifier {} 375 | 376 | impl NullNotifier { 377 | pub fn new() -> Self { 378 | Self {} 379 | } 380 | } 381 | impl NotificationProxy for NullNotifier { 382 | fn send(&self, n: Notification) -> anyhow::Result<()> { 383 | Ok(()) 384 | } 385 | } 386 | 387 | pub struct NullNetworkMonitor {} 388 | 389 | impl NullNetworkMonitor { 390 | pub fn new() -> Self { 391 | Self {} 392 | } 393 | } 394 | 395 | impl NetworkMonitorProxy for NullNetworkMonitor { 396 | fn listen(&self) -> Pin>> { 397 | Box::pin(futures::stream::empty()) 398 | } 399 | } 400 | -------------------------------------------------------------------------------- /ntfy-daemon/src/ntfy.rs: -------------------------------------------------------------------------------- 1 | use crate::actor_utils::send_command; 2 | use crate::models::NullNetworkMonitor; 3 | use crate::models::NullNotifier; 4 | use anyhow::{anyhow, Context}; 5 | use futures::future::join_all; 6 | use futures::StreamExt; 7 | use std::{collections::HashMap, future::Future, sync::Arc}; 8 | use tokio::select; 9 | use tokio::{ 10 | sync::{broadcast, mpsc, oneshot, RwLock}, 11 | task::{spawn_local, LocalSet}, 12 | }; 13 | use tracing::{error, info}; 14 | 15 | use crate::{ 16 | http_client::HttpClient, 17 | message_repo::Db, 18 | models::{self, Account}, 19 | ListenerActor, ListenerCommand, ListenerConfig, ListenerHandle, SharedEnv, SubscriptionHandle, 20 | }; 21 | 22 | const CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15); 23 | const TIMEOUT: std::time::Duration = std::time::Duration::from_secs(240); // 4 minutes 24 | 25 | pub fn build_client() -> anyhow::Result { 26 | Ok(reqwest::Client::builder() 27 | .connect_timeout(CONNECT_TIMEOUT) 28 | .pool_idle_timeout(TIMEOUT) 29 | // rustls is used because HTTP 2 isn't discovered with native-tls. 30 | // HTTP 2 is required to multiplex multiple requests over a single connection. 31 | // You can check that the app is using a single connection to a server by doing 32 | // ``` 33 | // ping ntfy.sh # to get the ip address 34 | // netstat | grep $ip 35 | // ``` 36 | .use_rustls_tls() 37 | .build()?) 38 | } 39 | 40 | // Message types for the actor 41 | #[derive()] 42 | pub enum NtfyCommand { 43 | Subscribe { 44 | server: String, 45 | topic: String, 46 | resp_tx: oneshot::Sender>, 47 | }, 48 | Unsubscribe { 49 | server: String, 50 | topic: String, 51 | resp_tx: oneshot::Sender>, 52 | }, 53 | RefreshAll { 54 | resp_tx: oneshot::Sender>, 55 | }, 56 | ListSubscriptions { 57 | resp_tx: oneshot::Sender>>, 58 | }, 59 | ListAccounts { 60 | resp_tx: oneshot::Sender>>, 61 | }, 62 | WatchSubscribed { 63 | resp_tx: oneshot::Sender>, 64 | }, 65 | AddAccount { 66 | server: String, 67 | username: String, 68 | password: String, 69 | resp_tx: oneshot::Sender>, 70 | }, 71 | RemoveAccount { 72 | server: String, 73 | resp_tx: oneshot::Sender>, 74 | }, 75 | } 76 | 77 | #[derive(Debug, Clone, Hash, PartialEq, Eq)] 78 | pub struct WatchKey { 79 | server: String, 80 | topic: String, 81 | } 82 | 83 | pub struct NtfyActor { 84 | listener_handles: Arc>>, 85 | env: SharedEnv, 86 | command_rx: mpsc::Receiver, 87 | } 88 | 89 | #[derive(Clone)] 90 | pub struct NtfyHandle { 91 | command_tx: mpsc::Sender, 92 | } 93 | 94 | impl NtfyActor { 95 | pub fn new(env: SharedEnv) -> (Self, NtfyHandle) { 96 | let (command_tx, command_rx) = mpsc::channel(32); 97 | 98 | let actor = Self { 99 | listener_handles: Default::default(), 100 | env, 101 | command_rx, 102 | }; 103 | 104 | let handle = NtfyHandle { command_tx }; 105 | 106 | (actor, handle) 107 | } 108 | 109 | async fn handle_subscribe( 110 | &self, 111 | server: String, 112 | topic: String, 113 | ) -> Result { 114 | let subscription = models::Subscription::builder(topic.clone()) 115 | .server(server.clone()) 116 | .build()?; 117 | 118 | let mut db = self.env.db.clone(); 119 | db.insert_subscription(subscription.clone())?; 120 | 121 | self.listen(subscription).await 122 | } 123 | 124 | async fn handle_unsubscribe(&mut self, server: String, topic: String) -> anyhow::Result<()> { 125 | let subscription = self.listener_handles.write().await.remove(&WatchKey { 126 | server: server.clone(), 127 | topic: topic.clone(), 128 | }); 129 | 130 | if let Some(sub) = subscription { 131 | sub.shutdown().await?; 132 | } 133 | 134 | self.env.db.remove_subscription(&server, &topic)?; 135 | info!(server, topic, "Unsubscribed"); 136 | Ok(()) 137 | } 138 | 139 | pub async fn run(&mut self) { 140 | let mut network_change_stream = self.env.network_monitor.listen(); 141 | loop { 142 | select! { 143 | Some(_) = network_change_stream.next() => { 144 | let _ = self.refresh_all().await; 145 | }, 146 | Some(command) = self.command_rx.recv() => self.handle_command(command).await, 147 | }; 148 | } 149 | } 150 | 151 | async fn handle_command(&mut self, command: NtfyCommand) { 152 | match command { 153 | NtfyCommand::Subscribe { 154 | server, 155 | topic, 156 | resp_tx, 157 | } => { 158 | let result = self.handle_subscribe(server, topic).await; 159 | let _ = resp_tx.send(result); 160 | } 161 | 162 | NtfyCommand::Unsubscribe { 163 | server, 164 | topic, 165 | resp_tx, 166 | } => { 167 | let result = self.handle_unsubscribe(server, topic).await; 168 | let _ = resp_tx.send(result); 169 | } 170 | 171 | NtfyCommand::RefreshAll { resp_tx } => { 172 | let res = self.refresh_all().await; 173 | let _ = resp_tx.send(res); 174 | } 175 | 176 | NtfyCommand::ListSubscriptions { resp_tx } => { 177 | let subs = self 178 | .listener_handles 179 | .read() 180 | .await 181 | .values() 182 | .cloned() 183 | .collect(); 184 | let _ = resp_tx.send(Ok(subs)); 185 | } 186 | 187 | NtfyCommand::ListAccounts { resp_tx } => { 188 | let accounts = self 189 | .env 190 | .credentials 191 | .list_all() 192 | .into_iter() 193 | .map(|(server, credential)| Account { 194 | server, 195 | username: credential.username, 196 | }) 197 | .collect(); 198 | let _ = resp_tx.send(Ok(accounts)); 199 | } 200 | 201 | NtfyCommand::WatchSubscribed { resp_tx } => { 202 | let result = self.handle_watch_subscribed().await; 203 | let _ = resp_tx.send(result); 204 | } 205 | 206 | NtfyCommand::AddAccount { 207 | server, 208 | username, 209 | password, 210 | resp_tx, 211 | } => { 212 | let result = self 213 | .env 214 | .credentials 215 | .insert(&server, &username, &password) 216 | .await; 217 | let _ = resp_tx.send(result); 218 | } 219 | 220 | NtfyCommand::RemoveAccount { server, resp_tx } => { 221 | let result = self.env.credentials.delete(&server).await; 222 | let _ = resp_tx.send(result); 223 | } 224 | } 225 | } 226 | 227 | async fn handle_watch_subscribed(&mut self) -> anyhow::Result<()> { 228 | let f: Vec<_> = self 229 | .env 230 | .db 231 | .list_subscriptions()? 232 | .into_iter() 233 | .map(|m| self.listen(m)) 234 | .collect(); 235 | 236 | join_all(f.into_iter().map(|x| async move { 237 | if let Err(e) = x.await { 238 | error!(error = ?e, "Can't rewatch subscribed topic"); 239 | } 240 | })) 241 | .await; 242 | 243 | Ok(()) 244 | } 245 | 246 | fn listen( 247 | &self, 248 | sub: models::Subscription, 249 | ) -> impl Future> { 250 | let server = sub.server.clone(); 251 | let topic = sub.topic.clone(); 252 | let listener = ListenerHandle::new(ListenerConfig { 253 | http_client: self.env.http_client.clone(), 254 | credentials: self.env.credentials.clone(), 255 | endpoint: server.clone(), 256 | topic: topic.clone(), 257 | since: sub.read_until, 258 | }); 259 | let listener_handles = self.listener_handles.clone(); 260 | let sub = SubscriptionHandle::new(listener.clone(), sub, &self.env); 261 | 262 | async move { 263 | listener_handles 264 | .write() 265 | .await 266 | .insert(WatchKey { server, topic }, sub.clone()); 267 | Ok(sub) 268 | } 269 | } 270 | 271 | async fn refresh_all(&self) -> anyhow::Result<()> { 272 | let mut res = Ok(()); 273 | for sub in self.listener_handles.read().await.values() { 274 | res = sub.restart().await; 275 | if res.is_err() { 276 | break; 277 | } 278 | } 279 | res 280 | } 281 | } 282 | 283 | impl NtfyHandle { 284 | pub async fn subscribe( 285 | &self, 286 | server: &str, 287 | topic: &str, 288 | ) -> Result { 289 | send_command!(self, |resp_tx| NtfyCommand::Subscribe { 290 | server: server.to_string(), 291 | topic: topic.to_string(), 292 | resp_tx, 293 | }) 294 | } 295 | 296 | pub async fn unsubscribe(&self, server: &str, topic: &str) -> anyhow::Result<()> { 297 | send_command!(self, |resp_tx| NtfyCommand::Unsubscribe { 298 | server: server.to_string(), 299 | topic: topic.to_string(), 300 | resp_tx, 301 | }) 302 | } 303 | 304 | pub async fn refresh_all(&self) -> anyhow::Result<()> { 305 | send_command!(self, |resp_tx| NtfyCommand::RefreshAll { resp_tx }) 306 | } 307 | 308 | pub async fn list_subscriptions(&self) -> anyhow::Result> { 309 | send_command!(self, |resp_tx| NtfyCommand::ListSubscriptions { resp_tx }) 310 | } 311 | 312 | pub async fn list_accounts(&self) -> anyhow::Result> { 313 | send_command!(self, |resp_tx| NtfyCommand::ListAccounts { resp_tx }) 314 | } 315 | 316 | pub async fn watch_subscribed(&self) -> anyhow::Result<()> { 317 | send_command!(self, |resp_tx| NtfyCommand::WatchSubscribed { resp_tx }) 318 | } 319 | 320 | pub async fn add_account( 321 | &self, 322 | server: &str, 323 | username: &str, 324 | password: &str, 325 | ) -> anyhow::Result<()> { 326 | send_command!(self, |resp_tx| NtfyCommand::AddAccount { 327 | server: server.to_string(), 328 | username: username.to_string(), 329 | password: password.to_string(), 330 | resp_tx, 331 | }) 332 | } 333 | 334 | pub async fn remove_account(&self, server: &str) -> anyhow::Result<()> { 335 | send_command!(self, |resp_tx| NtfyCommand::RemoveAccount { 336 | server: server.to_string(), 337 | resp_tx, 338 | }) 339 | } 340 | } 341 | 342 | pub fn start( 343 | dbpath: &str, 344 | notification_proxy: Arc, 345 | network_proxy: Arc, 346 | ) -> anyhow::Result { 347 | let dbpath = dbpath.to_owned(); 348 | 349 | // Create a channel to receive the handle from the spawned thread 350 | let (handle_tx, handle_rx) = oneshot::channel(); 351 | 352 | std::thread::spawn(move || { 353 | let rt = tokio::runtime::Builder::new_current_thread() 354 | .enable_all() 355 | .build() 356 | .unwrap(); 357 | 358 | // Create everything inside the new thread's runtime 359 | let credentials = 360 | rt.block_on(async move { crate::credentials::Credentials::new().await.unwrap() }); 361 | 362 | let env = SharedEnv { 363 | db: Db::connect(&dbpath).unwrap(), 364 | notifier: notification_proxy, 365 | http_client: HttpClient::new(build_client().unwrap()), 366 | network_monitor: network_proxy, 367 | credentials, 368 | }; 369 | 370 | let (mut actor, handle) = NtfyActor::new(env); 371 | let handle_clone = handle.clone(); 372 | 373 | // Send the handle back to the calling thread 374 | handle_tx.send(handle.clone()); 375 | 376 | rt.block_on({ 377 | let local_set = LocalSet::new(); 378 | // Spawn the watch_subscribed task 379 | local_set.spawn_local(async move { 380 | if let Err(e) = handle_clone.watch_subscribed().await { 381 | error!(error = ?e, "Failed to watch subscribed topics"); 382 | } 383 | }); 384 | 385 | // Run the actor 386 | local_set.spawn_local(async move { 387 | actor.run().await; 388 | }); 389 | local_set 390 | }) 391 | }); 392 | 393 | // Wait for the handle from the spawned thread 394 | Ok(handle_rx 395 | .blocking_recv() 396 | .map_err(|_| anyhow!("Failed to receive actor handle"))?) 397 | } 398 | 399 | #[cfg(test)] 400 | mod tests { 401 | use std::time::Duration; 402 | 403 | use models::{OutgoingMessage, ReceivedMessage}; 404 | use tokio::time::sleep; 405 | 406 | use crate::ListenerEvent; 407 | 408 | use super::*; 409 | 410 | #[test] 411 | fn test_subscribe_and_publish() { 412 | let notification_proxy = Arc::new(NullNotifier::new()); 413 | let network_proxy = Arc::new(NullNetworkMonitor::new()); 414 | let dbpath = ":memory:"; 415 | 416 | let handle = start(dbpath, notification_proxy, network_proxy).unwrap(); 417 | 418 | let rt = tokio::runtime::Builder::new_current_thread() 419 | .enable_all() 420 | .build() 421 | .unwrap(); 422 | 423 | rt.block_on(async move { 424 | let server = "http://localhost:8000"; 425 | let topic = "test_topic"; 426 | 427 | // Subscribe to the topic 428 | let subscription_handle = handle.subscribe(server, topic).await.unwrap(); 429 | 430 | // Publish a message 431 | let message = serde_json::to_string(&OutgoingMessage { 432 | topic: topic.to_string(), 433 | ..Default::default() 434 | }) 435 | .unwrap(); 436 | let result = subscription_handle.publish(message).await; 437 | assert!(result.is_ok()); 438 | 439 | sleep(Duration::from_millis(250)).await; 440 | 441 | // Attach to the subscription and check if the message is received and stored 442 | let (events, receiver) = subscription_handle.attach().await; 443 | dbg!(&events); 444 | assert!(events.iter().any(|event| match event { 445 | ListenerEvent::Message(msg) => msg.topic == topic, 446 | _ => false, 447 | })); 448 | }); 449 | } 450 | } 451 | -------------------------------------------------------------------------------- /ntfy-daemon/src/output_tracker.rs: -------------------------------------------------------------------------------- 1 | use std::{cell::RefCell, rc::Rc, sync::Arc}; 2 | 3 | use tokio::sync::RwLock; 4 | 5 | #[derive(Clone)] 6 | pub struct OutputTracker { 7 | store: Rc>>>, 8 | } 9 | 10 | impl Default for OutputTracker { 11 | fn default() -> Self { 12 | Self { 13 | store: Default::default(), 14 | } 15 | } 16 | } 17 | 18 | impl OutputTracker { 19 | pub fn enable(&self) { 20 | let mut inner = self.store.borrow_mut(); 21 | if inner.is_none() { 22 | *inner = Some(vec![]); 23 | } 24 | } 25 | pub fn push(&self, item: T) { 26 | if let Some(v) = &mut *self.store.borrow_mut() { 27 | v.push(item); 28 | } 29 | } 30 | pub fn items(&self) -> Vec { 31 | if let Some(v) = &*self.store.borrow() { 32 | v.clone() 33 | } else { 34 | vec![] 35 | } 36 | } 37 | } 38 | 39 | #[derive(Clone)] 40 | pub struct OutputTrackerAsync { 41 | store: Arc>>>, 42 | } 43 | 44 | impl Default for OutputTrackerAsync { 45 | fn default() -> Self { 46 | Self { 47 | store: Default::default(), 48 | } 49 | } 50 | } 51 | 52 | impl OutputTrackerAsync { 53 | pub async fn enable(&self) { 54 | let mut inner = self.store.write().await; 55 | if inner.is_none() { 56 | *inner = Some(vec![]); 57 | } 58 | } 59 | pub async fn push(&self, item: T) { 60 | if let Some(v) = &mut *self.store.write().await { 61 | v.push(item); 62 | } 63 | } 64 | pub async fn items(&self) -> Vec { 65 | if let Some(v) = &*self.store.read().await { 66 | v.clone() 67 | } else { 68 | vec![] 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /ntfy-daemon/src/retry.rs: -------------------------------------------------------------------------------- 1 | use std::cmp; 2 | use std::time::Duration; 3 | 4 | use rand::prelude::*; 5 | use tokio::time::sleep; 6 | 7 | pub struct WaitExponentialRandom { 8 | min: Duration, 9 | max: Duration, 10 | i: u64, 11 | multiplier: u64, 12 | } 13 | pub struct WaitExponentialRandomBuilder { 14 | inner: WaitExponentialRandom, 15 | } 16 | 17 | impl WaitExponentialRandomBuilder { 18 | pub fn build(self) -> WaitExponentialRandom { 19 | self.inner 20 | } 21 | pub fn min(mut self, duration: Duration) -> Self { 22 | self.inner.min = duration; 23 | self 24 | } 25 | pub fn max(mut self, duration: Duration) -> Self { 26 | self.inner.max = duration; 27 | self 28 | } 29 | pub fn multiplier(mut self, mul: u64) -> Self { 30 | self.inner.multiplier = mul; 31 | self 32 | } 33 | } 34 | 35 | impl WaitExponentialRandom { 36 | pub fn builder() -> WaitExponentialRandomBuilder { 37 | WaitExponentialRandomBuilder { 38 | inner: WaitExponentialRandom { 39 | min: Duration::ZERO, 40 | max: Duration::MAX, 41 | i: 0, 42 | multiplier: 1, 43 | }, 44 | } 45 | } 46 | pub fn next_delay(&self) -> Duration { 47 | let secs = (1 << self.i) * self.multiplier; 48 | let secs = rand::thread_rng().gen_range(self.min.as_secs()..=secs); 49 | let dur = Duration::from_secs(secs); 50 | cmp::min(cmp::max(dur, self.min), self.max) 51 | } 52 | pub async fn wait(&mut self) { 53 | sleep(self.next_delay()).await; 54 | self.i += 1; 55 | } 56 | 57 | pub fn count(&self) -> u64 { 58 | self.i 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /ntfy-daemon/src/subscription.rs: -------------------------------------------------------------------------------- 1 | use crate::listener::{ListenerEvent, ListenerHandle}; 2 | use crate::models::{self, ReceivedMessage}; 3 | use crate::{Error, SharedEnv}; 4 | use tokio::select; 5 | use tokio::sync::{broadcast, mpsc, oneshot}; 6 | use tokio::task::spawn_local; 7 | use tracing::{debug, error, info, trace, warn}; 8 | 9 | #[derive(Debug)] 10 | enum SubscriptionCommand { 11 | GetModel { 12 | resp_tx: oneshot::Sender, 13 | }, 14 | UpdateInfo { 15 | new_model: models::Subscription, 16 | resp_tx: oneshot::Sender>, 17 | }, 18 | Attach { 19 | resp_tx: oneshot::Sender<(Vec, broadcast::Receiver)>, 20 | }, 21 | Publish { 22 | msg: String, 23 | resp_tx: oneshot::Sender>, 24 | }, 25 | ClearNotifications { 26 | resp_tx: oneshot::Sender>, 27 | }, 28 | UpdateReadUntil { 29 | timestamp: u64, 30 | resp_tx: oneshot::Sender>, 31 | }, 32 | } 33 | 34 | #[derive(Clone)] 35 | pub struct SubscriptionHandle { 36 | command_tx: mpsc::Sender, 37 | listener: ListenerHandle, 38 | } 39 | 40 | impl SubscriptionHandle { 41 | pub fn new(listener: ListenerHandle, model: models::Subscription, env: &SharedEnv) -> Self { 42 | let (command_tx, command_rx) = mpsc::channel(32); 43 | let broadcast_tx = broadcast::channel(8).0; 44 | let actor = SubscriptionActor { 45 | listener: listener.clone(), 46 | model, 47 | command_rx, 48 | env: env.clone(), 49 | broadcast_tx: broadcast_tx.clone(), 50 | }; 51 | spawn_local(actor.run()); 52 | Self { 53 | command_tx, 54 | listener, 55 | } 56 | } 57 | 58 | pub async fn model(&self) -> models::Subscription { 59 | let (resp_tx, resp_rx) = oneshot::channel(); 60 | self.command_tx 61 | .send(SubscriptionCommand::GetModel { resp_tx }) 62 | .await 63 | .unwrap(); 64 | resp_rx.await.unwrap() 65 | } 66 | 67 | pub async fn update_info(&self, new_model: models::Subscription) -> anyhow::Result<()> { 68 | let (resp_tx, resp_rx) = oneshot::channel(); 69 | self.command_tx 70 | .send(SubscriptionCommand::UpdateInfo { new_model, resp_tx }) 71 | .await?; 72 | resp_rx.await.unwrap() 73 | } 74 | 75 | pub async fn restart(&self) -> anyhow::Result<()> { 76 | self.listener 77 | .commands 78 | .send(crate::ListenerCommand::Restart) 79 | .await?; 80 | Ok(()) 81 | } 82 | 83 | pub async fn shutdown(&self) -> anyhow::Result<()> { 84 | self.listener 85 | .commands 86 | .send(crate::ListenerCommand::Shutdown) 87 | .await?; 88 | Ok(()) 89 | } 90 | 91 | // returns a vector containing all the past messages stored in the database and the current connection state. 92 | // The first vector is useful to get a summary of what happened before. 93 | // The `ListenerHandle` is returned to receive new events. 94 | pub async fn attach(&self) -> (Vec, broadcast::Receiver) { 95 | let (resp_tx, resp_rx) = oneshot::channel(); 96 | self.command_tx 97 | .send(SubscriptionCommand::Attach { resp_tx }) 98 | .await 99 | .unwrap(); 100 | resp_rx.await.unwrap() 101 | } 102 | 103 | pub async fn publish(&self, msg: String) -> anyhow::Result<()> { 104 | let (resp_tx, resp_rx) = oneshot::channel(); 105 | self.command_tx 106 | .send(SubscriptionCommand::Publish { msg, resp_tx }) 107 | .await 108 | .unwrap(); 109 | resp_rx.await.unwrap() 110 | } 111 | 112 | pub async fn clear_notifications(&self) -> anyhow::Result<()> { 113 | let (resp_tx, resp_rx) = oneshot::channel(); 114 | self.command_tx 115 | .send(SubscriptionCommand::ClearNotifications { resp_tx }) 116 | .await 117 | .unwrap(); 118 | resp_rx.await.unwrap() 119 | } 120 | 121 | pub async fn update_read_until(&self, timestamp: u64) -> anyhow::Result<()> { 122 | let (resp_tx, resp_rx) = oneshot::channel(); 123 | self.command_tx 124 | .send(SubscriptionCommand::UpdateReadUntil { timestamp, resp_tx }) 125 | .await 126 | .unwrap(); 127 | resp_rx.await.unwrap() 128 | } 129 | } 130 | 131 | struct SubscriptionActor { 132 | listener: ListenerHandle, 133 | model: models::Subscription, 134 | command_rx: mpsc::Receiver, 135 | env: SharedEnv, 136 | broadcast_tx: broadcast::Sender, 137 | } 138 | 139 | impl SubscriptionActor { 140 | async fn run(mut self) { 141 | loop { 142 | select! { 143 | Ok(event) = self.listener.events.recv() => { 144 | debug!(?event, "received listener event"); 145 | match event { 146 | ListenerEvent::Message(msg) => self.handle_msg_event(msg), 147 | other => { 148 | let _ = self.broadcast_tx.send(other); 149 | } 150 | } 151 | } 152 | Some(command) = self.command_rx.recv() => { 153 | trace!(?command, "processing subscription command"); 154 | match command { 155 | SubscriptionCommand::GetModel { resp_tx } => { 156 | debug!("getting subscription model"); 157 | let _ = resp_tx.send(self.model.clone()); 158 | } 159 | SubscriptionCommand::UpdateInfo { 160 | mut new_model, 161 | resp_tx, 162 | } => { 163 | debug!(server=?new_model.server, topic=?new_model.topic, "updating subscription info"); 164 | new_model.server = self.model.server.clone(); 165 | new_model.topic = self.model.topic.clone(); 166 | let res = self.env.db.update_subscription(new_model.clone()); 167 | if let Ok(_) = res { 168 | self.model = new_model; 169 | } 170 | let _ = resp_tx.send(res.map_err(|e| e.into())); 171 | } 172 | SubscriptionCommand::Publish {msg, resp_tx} => { 173 | debug!(topic=?self.model.topic, "publishing message"); 174 | let _ = resp_tx.send(self.publish(msg).await); 175 | } 176 | SubscriptionCommand::Attach { resp_tx } => { 177 | debug!(topic=?self.model.topic, "attaching new listener"); 178 | let messages = self 179 | .env 180 | .db 181 | .list_messages(&self.model.server, &self.model.topic, 0) 182 | .unwrap_or_default(); 183 | let mut previous_events: Vec = messages 184 | .into_iter() 185 | .filter_map(|msg| { 186 | let msg = serde_json::from_str(&msg); 187 | match msg { 188 | Err(e) => { 189 | error!(error = ?e, "error parsing stored message"); 190 | None 191 | } 192 | Ok(msg) => Some(msg), 193 | } 194 | }) 195 | .map(ListenerEvent::Message) 196 | .collect(); 197 | previous_events.push(ListenerEvent::ConnectionStateChanged(self.listener.state().await)); 198 | let _ = resp_tx.send((previous_events, self.broadcast_tx.subscribe())); 199 | } 200 | SubscriptionCommand::ClearNotifications {resp_tx} => { 201 | debug!(topic=?self.model.topic, "clearing notifications"); 202 | let _ = resp_tx.send(self.env.db.delete_messages(&self.model.server, &self.model.topic).map_err(|e| anyhow::anyhow!(e))); 203 | } 204 | SubscriptionCommand::UpdateReadUntil { timestamp, resp_tx } => { 205 | debug!(topic=?self.model.topic, timestamp=timestamp, "updating read until timestamp"); 206 | let res = self.env.db.update_read_until(&self.model.server, &self.model.topic, timestamp); 207 | let _ = resp_tx.send(res.map_err(|e| anyhow::anyhow!(e))); 208 | } 209 | } 210 | } 211 | } 212 | } 213 | } 214 | 215 | async fn publish(&self, msg: String) -> anyhow::Result<()> { 216 | let server = &self.model.server; 217 | debug!(server=?server, "preparing to publish message"); 218 | let creds = self.env.credentials.get(server); 219 | let mut req = self.env.http_client.post(server); 220 | if let Some(creds) = creds { 221 | req = req.basic_auth(creds.username, Some(creds.password)); 222 | } 223 | 224 | info!(server=?server, "sending message"); 225 | let res = req.body(msg).send().await?; 226 | res.error_for_status()?; 227 | debug!(server=?server, "message published successfully"); 228 | Ok(()) 229 | } 230 | fn handle_msg_event(&mut self, msg: ReceivedMessage) { 231 | debug!(topic=?self.model.topic, "handling new message"); 232 | // Store in database 233 | let already_stored: bool = { 234 | let json_ev = &serde_json::to_string(&msg).unwrap(); 235 | match self.env.db.insert_message(&self.model.server, json_ev) { 236 | Err(Error::DuplicateMessage) => { 237 | warn!(topic=?self.model.topic, "received duplicate message"); 238 | true 239 | } 240 | Err(e) => { 241 | error!(error=?e, topic=?self.model.topic, "can't store the message"); 242 | false 243 | } 244 | _ => { 245 | debug!(topic=?self.model.topic, "message stored successfully"); 246 | false 247 | } 248 | } 249 | }; 250 | 251 | if !already_stored { 252 | debug!(topic=?self.model.topic, muted=?self.model.muted, "checking if notification should be shown"); 253 | // Show notification. If this fails, panic 254 | if !{ self.model.muted } { 255 | let notifier = self.env.notifier.clone(); 256 | 257 | let title = { msg.notification_title(&self.model) }; 258 | 259 | let n = models::Notification { 260 | title, 261 | body: msg.display_message().as_deref().unwrap_or("").to_string(), 262 | actions: msg.actions.clone(), 263 | }; 264 | 265 | info!(topic=?self.model.topic, "showing notification"); 266 | notifier.send(n).unwrap(); 267 | } else { 268 | debug!(topic=?self.model.topic, "notification muted, skipping"); 269 | } 270 | 271 | // Forward to app 272 | debug!(topic=?self.model.topic, "forwarding message to app"); 273 | let _ = self.broadcast_tx.send(ListenerEvent::Message(msg)); 274 | } 275 | } 276 | } 277 | -------------------------------------------------------------------------------- /po/LINGUAS: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ranfdev/Notify/3a4acc2d12505e874171bd1e486c7bb68c24fd8b/po/LINGUAS -------------------------------------------------------------------------------- /po/POTFILES.in: -------------------------------------------------------------------------------- 1 | data/com.ranfdev.Notify.desktop.in.in 2 | data/com.ranfdev.Notify.gschema.xml.in 3 | data/com.ranfdev.Notify.metainfo.xml.in.in 4 | data/resources/ui/shortcuts.ui 5 | data/resources/ui/window.ui 6 | src/application.rs 7 | -------------------------------------------------------------------------------- /po/meson.build: -------------------------------------------------------------------------------- 1 | i18n.gettext(gettext_package, preset: 'glib') 2 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ranfdev/Notify/3a4acc2d12505e874171bd1e486c7bb68c24fd8b/rustfmt.toml -------------------------------------------------------------------------------- /src/application.rs: -------------------------------------------------------------------------------- 1 | use std::cell::Cell; 2 | use std::pin::Pin; 3 | use std::rc::Rc; 4 | 5 | use adw::prelude::*; 6 | use adw::subclass::prelude::*; 7 | use futures::stream::Stream; 8 | use gtk::{gdk, gio, glib}; 9 | use ntfy_daemon::models; 10 | use ntfy_daemon::NtfyHandle; 11 | use tracing::{debug, error, info, warn}; 12 | 13 | use crate::config::{APP_ID, PKGDATADIR, PROFILE, VERSION}; 14 | use crate::widgets::*; 15 | 16 | mod imp { 17 | use std::cell::RefCell; 18 | 19 | use glib::WeakRef; 20 | use once_cell::sync::OnceCell; 21 | 22 | use super::*; 23 | 24 | #[derive(Default)] 25 | pub struct NotifyApplication { 26 | pub window: RefCell>, 27 | pub hold_guard: OnceCell, 28 | pub ntfy: OnceCell, 29 | } 30 | 31 | #[glib::object_subclass] 32 | impl ObjectSubclass for NotifyApplication { 33 | const NAME: &'static str = "NotifyApplication"; 34 | type Type = super::NotifyApplication; 35 | type ParentType = adw::Application; 36 | } 37 | 38 | impl ObjectImpl for NotifyApplication {} 39 | 40 | impl ApplicationImpl for NotifyApplication { 41 | fn activate(&self) { 42 | debug!("AdwApplication::activate"); 43 | self.parent_activate(); 44 | self.obj().ensure_window_present(); 45 | } 46 | 47 | fn startup(&self) { 48 | debug!("AdwApplication::startup"); 49 | self.parent_startup(); 50 | let app = self.obj(); 51 | 52 | // Set icons for shell 53 | gtk::Window::set_default_icon_name(APP_ID); 54 | 55 | app.setup_css(); 56 | app.setup_gactions(); 57 | app.setup_accels(); 58 | } 59 | fn command_line(&self, command_line: &gio::ApplicationCommandLine) -> glib::ExitCode { 60 | debug!("AdwApplication::command_line"); 61 | let arguments = command_line.arguments(); 62 | let is_daemon = arguments.get(1).map(|x| x.to_str()) == Some(Some("--daemon")); 63 | let app = self.obj(); 64 | 65 | if self.hold_guard.get().is_none() { 66 | app.ensure_rpc_running(); 67 | } 68 | 69 | glib::MainContext::default().spawn_local(async move { 70 | if let Err(e) = super::NotifyApplication::run_in_background().await { 71 | warn!(error = %e, "couldn't request running in background from portal"); 72 | } 73 | }); 74 | 75 | if is_daemon { 76 | return glib::ExitCode::SUCCESS; 77 | } 78 | 79 | app.ensure_window_present(); 80 | 81 | glib::ExitCode::SUCCESS 82 | } 83 | } 84 | 85 | impl GtkApplicationImpl for NotifyApplication {} 86 | impl AdwApplicationImpl for NotifyApplication {} 87 | } 88 | 89 | glib::wrapper! { 90 | pub struct NotifyApplication(ObjectSubclass) 91 | @extends gio::Application, gtk::Application, 92 | @implements gio::ActionMap, gio::ActionGroup; 93 | } 94 | 95 | impl NotifyApplication { 96 | fn ensure_window_present(&self) { 97 | if let Some(window) = { self.imp().window.borrow().upgrade() } { 98 | if window.is_visible() { 99 | window.present(); 100 | return; 101 | } 102 | } 103 | self.build_window(); 104 | self.main_window().present(); 105 | } 106 | 107 | fn main_window(&self) -> NotifyWindow { 108 | self.imp().window.borrow().upgrade().unwrap() 109 | } 110 | 111 | fn setup_gactions(&self) { 112 | // Quit 113 | let action_quit = gio::ActionEntry::builder("quit") 114 | .activate(move |app: &Self, _, _| { 115 | // This is needed to trigger the delete event and saving the window state 116 | app.main_window().close(); 117 | app.quit(); 118 | }) 119 | .build(); 120 | 121 | // About 122 | let action_about = gio::ActionEntry::builder("about") 123 | .activate(|app: &Self, _, _| { 124 | app.show_about_dialog(); 125 | }) 126 | .build(); 127 | 128 | let action_preferences = gio::ActionEntry::builder("preferences") 129 | .activate(|app: &Self, _, _| { 130 | app.show_preferences(); 131 | }) 132 | .build(); 133 | 134 | let message_action = gio::ActionEntry::builder("message-action") 135 | .parameter_type(Some(&glib::VariantTy::STRING)) 136 | .activate(|app: &Self, _, params| { 137 | let Some(params) = params else { 138 | return; 139 | }; 140 | let Some(s) = params.str() else { 141 | warn!("action is not a string"); 142 | return; 143 | }; 144 | let Ok(action) = serde_json::from_str(s) else { 145 | error!("invalid action json"); 146 | return; 147 | }; 148 | app.handle_message_action(action); 149 | }) 150 | .build(); 151 | self.add_action_entries([ 152 | action_quit, 153 | action_about, 154 | action_preferences, 155 | message_action, 156 | ]); 157 | } 158 | 159 | fn handle_message_action(&self, action: models::Action) { 160 | match action { 161 | models::Action::View { url, .. } => { 162 | gtk::UriLauncher::builder().uri(url.clone()).build().launch( 163 | gtk::Window::NONE, 164 | gio::Cancellable::NONE, 165 | |_| {}, 166 | ); 167 | } 168 | models::Action::Http { 169 | method, 170 | url, 171 | body, 172 | headers, 173 | .. 174 | } => { 175 | gio::spawn_blocking(move || { 176 | let mut req = ureq::request(method.as_str(), url.as_str()); 177 | for (k, v) in headers.iter() { 178 | req = req.set(&k, &v); 179 | } 180 | let res = req.send(body.as_bytes()); 181 | match res { 182 | Err(e) => { 183 | error!(error = ?e, "Error sending request"); 184 | } 185 | Ok(_) => {} 186 | } 187 | }); 188 | } 189 | _ => {} 190 | } 191 | } 192 | 193 | // Sets up keyboard shortcuts 194 | fn setup_accels(&self) { 195 | self.set_accels_for_action("app.quit", &["q"]); 196 | self.set_accels_for_action("window.close", &["w"]); 197 | } 198 | 199 | fn setup_css(&self) { 200 | let provider = gtk::CssProvider::new(); 201 | provider.load_from_resource("/com/ranfdev/Notify/style.css"); 202 | if let Some(display) = gdk::Display::default() { 203 | gtk::style_context_add_provider_for_display( 204 | &display, 205 | &provider, 206 | gtk::STYLE_PROVIDER_PRIORITY_APPLICATION, 207 | ); 208 | } 209 | } 210 | 211 | fn show_about_dialog(&self) { 212 | let dialog = adw::AboutDialog::from_appdata( 213 | "/com/ranfdev/Notify/com.ranfdev.Notify.metainfo.xml", 214 | None, 215 | ); 216 | if let Some(w) = self.imp().window.borrow().upgrade() { 217 | dialog.present(Some(&w)); 218 | } 219 | } 220 | 221 | fn show_preferences(&self) { 222 | let win = crate::widgets::NotifyPreferences::new( 223 | self.main_window().imp().notifier.get().unwrap().clone(), 224 | ); 225 | win.present(Some(&self.main_window())); 226 | } 227 | 228 | pub fn run(&self) -> glib::ExitCode { 229 | info!(app_id = %APP_ID, version = %VERSION, profile = %PROFILE, datadir = %PKGDATADIR, "running"); 230 | 231 | ApplicationExtManual::run(self) 232 | } 233 | async fn run_in_background() -> ashpd::Result<()> { 234 | let response = ashpd::desktop::background::Background::request() 235 | .reason("Listen for coming notifications") 236 | .auto_start(true) 237 | .command(&["notify", "--daemon"]) 238 | .dbus_activatable(false) 239 | .send() 240 | .await? 241 | .response()?; 242 | 243 | info!(auto_start = %response.auto_start(), run_in_background = %response.run_in_background()); 244 | 245 | Ok(()) 246 | } 247 | 248 | fn ensure_rpc_running(&self) { 249 | let dbpath = glib::user_data_dir().join("com.ranfdev.Notify.sqlite"); 250 | info!(database_path = %dbpath.display()); 251 | 252 | // Here I'm sending notifications to the desktop environment and listening for network changes. 253 | // This should have been inside ntfy-daemon, but using portals from another thread causes the error 254 | // `Invalid client serial` and it's broken. 255 | // Until https://github.com/flatpak/xdg-dbus-proxy/issues/46 is solved, I have to handle these things 256 | // in the main thread. Uff. 257 | 258 | let (s, r) = async_channel::unbounded::(); 259 | 260 | let app = self.clone(); 261 | glib::MainContext::ref_thread_default().spawn_local(async move { 262 | while let Ok(n) = r.recv().await { 263 | let gio_notif = gio::Notification::new(&n.title); 264 | gio_notif.set_body(Some(&n.body)); 265 | 266 | let action_name = |a| { 267 | let json = serde_json::to_string(a).unwrap(); 268 | gio::Action::print_detailed_name("app.message-action", Some(&json.into())) 269 | }; 270 | for a in n.actions.iter() { 271 | match a { 272 | models::Action::View { label, .. } => { 273 | gio_notif.add_button(&label, &action_name(a)) 274 | } 275 | models::Action::Http { label, .. } => { 276 | gio_notif.add_button(&label, &action_name(a)) 277 | } 278 | _ => {} 279 | } 280 | } 281 | 282 | app.send_notification(None, &gio_notif); 283 | } 284 | }); 285 | struct Proxies { 286 | notification: async_channel::Sender, 287 | } 288 | impl models::NotificationProxy for Proxies { 289 | fn send(&self, n: models::Notification) -> anyhow::Result<()> { 290 | self.notification.send_blocking(n)?; 291 | Ok(()) 292 | } 293 | } 294 | impl models::NetworkMonitorProxy for Proxies { 295 | fn listen(&self) -> Pin>> { 296 | let (tx, rx) = async_channel::bounded(1); 297 | let prev_available = Rc::new(Cell::new(false)); 298 | 299 | gio::NetworkMonitor::default().connect_network_changed(move |_, available| { 300 | if available && !prev_available.get() { 301 | if let Err(e) = tx.send_blocking(()) { 302 | warn!(error = %e); 303 | } 304 | } 305 | prev_available.replace(available); 306 | }); 307 | 308 | Box::pin(rx) 309 | } 310 | } 311 | let proxies = std::sync::Arc::new(Proxies { notification: s }); 312 | let ntfy = ntfy_daemon::start(dbpath.to_str().unwrap(), proxies.clone(), proxies).unwrap(); 313 | self.imp() 314 | .ntfy 315 | .set(ntfy) 316 | .or(Err(anyhow::anyhow!("failed setting ntfy"))) 317 | .unwrap(); 318 | self.imp().hold_guard.set(self.hold()).unwrap(); 319 | } 320 | 321 | fn build_window(&self) { 322 | let ntfy = self.imp().ntfy.get().unwrap(); 323 | 324 | let window = NotifyWindow::new(self, ntfy.clone()); 325 | *self.imp().window.borrow_mut() = window.downgrade(); 326 | } 327 | } 328 | 329 | impl Default for NotifyApplication { 330 | fn default() -> Self { 331 | glib::Object::builder() 332 | .property("application-id", APP_ID) 333 | .property("flags", gio::ApplicationFlags::HANDLES_COMMAND_LINE) 334 | .property("resource-base-path", "/com/ranfdev/Notify/") 335 | .build() 336 | } 337 | } 338 | -------------------------------------------------------------------------------- /src/async_utils.rs: -------------------------------------------------------------------------------- 1 | use std::cell::Cell; 2 | use std::rc::Rc; 3 | 4 | use glib::SourceId; 5 | use gtk::glib; 6 | 7 | #[derive(Clone)] 8 | pub struct Debouncer { 9 | scheduled: Rc>>, 10 | } 11 | impl Debouncer { 12 | pub fn new() -> Self { 13 | Self { 14 | scheduled: Default::default(), 15 | } 16 | } 17 | pub fn call(&self, duration: std::time::Duration, f: impl Fn() -> () + 'static) { 18 | if let Some(scheduled) = self.scheduled.take() { 19 | scheduled.remove(); 20 | } 21 | let scheduled_clone = self.scheduled.clone(); 22 | let source_id = glib::source::timeout_add_local_once(duration, move || { 23 | f(); 24 | scheduled_clone.take(); 25 | }); 26 | self.scheduled.set(Some(source_id)); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/config.rs.in: -------------------------------------------------------------------------------- 1 | pub const APP_ID: &str = @APP_ID@; 2 | pub const GETTEXT_PACKAGE: &str = @GETTEXT_PACKAGE@; 3 | pub const LOCALEDIR: &str = @LOCALEDIR@; 4 | pub const PKGDATADIR: &str = @PKGDATADIR@; 5 | pub const PROFILE: &str = @PROFILE@; 6 | pub const RESOURCES_FILE: &str = concat!(@PKGDATADIR@, "/resources.gresource"); 7 | pub const VERSION: &str = @VERSION@; 8 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | use futures::Future; 2 | use glib::subclass::prelude::*; 3 | use gtk::prelude::*; 4 | use gtk::{self, glib}; 5 | 6 | use crate::widgets::NotifyWindow; 7 | 8 | pub type Error = anyhow::Error; 9 | 10 | pub trait ErrorBoundaryProvider { 11 | fn error_boundary(&self) -> ErrorBoundary; 12 | } 13 | 14 | impl> ErrorBoundaryProvider for W { 15 | fn error_boundary(&self) -> ErrorBoundary { 16 | let direct_ancestor: Option = self 17 | .ancestor(adw::ToastOverlay::static_type()) 18 | .and_downcast(); 19 | let win: Option = self 20 | .ancestor(NotifyWindow::static_type()) 21 | .and_downcast() 22 | .map(|win: NotifyWindow| win.imp().toast_overlay.clone()); 23 | let toast_overlay = direct_ancestor.or(win); 24 | ErrorBoundary { 25 | source: self.clone().into(), 26 | boundary: toast_overlay, 27 | } 28 | } 29 | } 30 | 31 | pub struct ErrorBoundary { 32 | source: gtk::Widget, 33 | boundary: Option, 34 | } 35 | 36 | impl ErrorBoundary { 37 | pub fn spawn(self, f: impl Future> + 'static) { 38 | glib::MainContext::ref_thread_default().spawn_local_with_priority( 39 | glib::Priority::DEFAULT_IDLE, 40 | async move { 41 | if let Err(e) = f.await { 42 | if let Some(boundary) = self.boundary { 43 | boundary.add_toast(adw::Toast::builder().title(&e.to_string()).build()); 44 | } 45 | tracing::error!(source=?self.source.type_().name(), error=?e); 46 | } 47 | }, 48 | ); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | mod application; 2 | #[rustfmt::skip] 3 | mod config; 4 | mod async_utils; 5 | pub mod error; 6 | mod subscription; 7 | pub mod widgets; 8 | 9 | use gettextrs::{gettext, LocaleCategory}; 10 | use gtk::{gio, glib}; 11 | 12 | use self::application::NotifyApplication; 13 | use self::config::{GETTEXT_PACKAGE, LOCALEDIR, RESOURCES_FILE}; 14 | 15 | fn main() -> glib::ExitCode { 16 | // Initialize logger 17 | tracing_subscriber::fmt::init(); 18 | 19 | // Prepare i18n 20 | gettextrs::setlocale(LocaleCategory::LcAll, ""); 21 | gettextrs::bindtextdomain(GETTEXT_PACKAGE, LOCALEDIR).expect("Unable to bind the text domain"); 22 | gettextrs::textdomain(GETTEXT_PACKAGE).expect("Unable to switch to the text domain"); 23 | 24 | glib::set_application_name(&gettext("Notify")); 25 | 26 | let res = gio::Resource::load(RESOURCES_FILE).expect("Could not load gresource file"); 27 | gio::resources_register(&res); 28 | 29 | let app = NotifyApplication::default(); 30 | app.run() 31 | } 32 | -------------------------------------------------------------------------------- /src/meson.build: -------------------------------------------------------------------------------- 1 | global_conf = configuration_data() 2 | global_conf.set_quoted('APP_ID', application_id) 3 | global_conf.set_quoted('PKGDATADIR', pkgdatadir) 4 | global_conf.set_quoted('PROFILE', profile) 5 | global_conf.set_quoted('VERSION', version + version_suffix) 6 | global_conf.set_quoted('GETTEXT_PACKAGE', gettext_package) 7 | global_conf.set_quoted('LOCALEDIR', localedir) 8 | config = configure_file( 9 | input: 'config.rs.in', 10 | output: 'config.rs', 11 | configuration: global_conf 12 | ) 13 | # Copy the config.rs output to the source directory. 14 | run_command( 15 | 'cp', 16 | meson.project_build_root() / 'src' / 'config.rs', 17 | meson.project_source_root() / 'src' / 'config.rs', 18 | check: true 19 | ) 20 | 21 | cargo_options = [ '--manifest-path', meson.project_source_root() / 'Cargo.toml' ] 22 | cargo_options += [ '--target-dir', meson.project_build_root() / 'src' ] 23 | 24 | if get_option('profile') == 'default' 25 | cargo_options += [ '--release' ] 26 | rust_target = 'release' 27 | message('Building in release mode') 28 | else 29 | rust_target = 'debug' 30 | message('Building in debug mode') 31 | endif 32 | 33 | cargo_env = [ 'CARGO_HOME=' + meson.project_build_root() / 'cargo-home' ] 34 | 35 | cargo_build = custom_target( 36 | 'cargo-build', 37 | build_by_default: true, 38 | build_always_stale: true, 39 | output: meson.project_name(), 40 | console: true, 41 | install: true, 42 | install_dir: bindir, 43 | depends: resources, 44 | command: [ 45 | 'env', 46 | cargo_env, 47 | cargo, 'build', 48 | cargo_options, 49 | '&&', 50 | 'cp', 'src' / rust_target / meson.project_name(), '@OUTPUT@', 51 | ] 52 | ) 53 | -------------------------------------------------------------------------------- /src/subscription.rs: -------------------------------------------------------------------------------- 1 | use std::cell::{Cell, OnceCell, RefCell}; 2 | use std::future::Future; 3 | use std::rc::Rc; 4 | 5 | use adw::prelude::*; 6 | use glib::subclass::prelude::*; 7 | use glib::Properties; 8 | use gtk::{gio, glib}; 9 | use ntfy_daemon::{models, ConnectionState, ListenerEvent}; 10 | use tracing::{error, instrument}; 11 | 12 | #[repr(u16)] 13 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] 14 | pub enum Status { 15 | Down = 0, 16 | Degraded = 1, 17 | Up = 2, 18 | } 19 | 20 | impl From for Status { 21 | fn from(value: u16) -> Self { 22 | match value { 23 | 0 => Status::Down, 24 | 1 => Status::Degraded, 25 | 2 => Status::Up, 26 | _ => panic!("Invalid value for Status"), 27 | } 28 | } 29 | } 30 | 31 | impl From for u16 { 32 | fn from(status: Status) -> Self { 33 | status as u16 34 | } 35 | } 36 | 37 | mod imp { 38 | use super::*; 39 | 40 | #[derive(Properties)] 41 | #[properties(wrapper_type = super::Subscription)] 42 | pub struct Subscription { 43 | #[property(get)] 44 | pub display_name: RefCell, 45 | #[property(get)] 46 | pub topic: RefCell, 47 | #[property(get)] 48 | pub url: RefCell, 49 | #[property(get)] 50 | pub server: RefCell, 51 | #[property(get = Self::get_status, type = u8)] 52 | pub status: Rc>, 53 | #[property(get)] 54 | pub muted: Cell, 55 | #[property(get)] 56 | pub unread_count: Cell, 57 | pub read_until: Cell, 58 | pub messages: gio::ListStore, 59 | pub client: OnceCell, 60 | } 61 | 62 | impl Subscription { 63 | fn get_status(&self) -> u8 { 64 | let s: u16 = Cell::get(&self.status).into(); 65 | s as u8 66 | } 67 | } 68 | 69 | impl Default for Subscription { 70 | fn default() -> Self { 71 | Self { 72 | display_name: Default::default(), 73 | topic: Default::default(), 74 | url: Default::default(), 75 | muted: Default::default(), 76 | server: Default::default(), 77 | status: Rc::new(Cell::new(Status::Down)), 78 | messages: gio::ListStore::new::(), 79 | client: Default::default(), 80 | unread_count: Default::default(), 81 | read_until: Default::default(), 82 | } 83 | } 84 | } 85 | 86 | #[glib::derived_properties] 87 | impl ObjectImpl for Subscription {} 88 | 89 | #[glib::object_subclass] 90 | impl ObjectSubclass for Subscription { 91 | const NAME: &'static str = "TopicSubscription"; 92 | type Type = super::Subscription; 93 | } 94 | } 95 | 96 | glib::wrapper! { 97 | pub struct Subscription(ObjectSubclass); 98 | } 99 | 100 | impl Subscription { 101 | pub fn new(client: ntfy_daemon::SubscriptionHandle) -> Self { 102 | let this: Self = glib::Object::builder().build(); 103 | let imp = this.imp(); 104 | if let Err(_) = imp.client.set(client) { 105 | panic!(); 106 | }; 107 | 108 | let this_clone = this.clone(); 109 | glib::MainContext::default().spawn_local(async move { 110 | match this_clone.load().await { 111 | Ok(_) => {} 112 | Err(e) => { 113 | error!(error = %e, "loading subscription data"); 114 | } 115 | } 116 | }); 117 | this 118 | } 119 | 120 | fn init_info( 121 | &self, 122 | topic: &str, 123 | server: &str, 124 | muted: bool, 125 | read_until: u64, 126 | display_name: &str, 127 | ) { 128 | let imp = self.imp(); 129 | imp.topic.replace(topic.to_string()); 130 | self.notify_topic(); 131 | imp.server.replace(server.to_string()); 132 | self.notify_server(); 133 | imp.muted.replace(muted); 134 | self.notify_muted(); 135 | imp.read_until.replace(read_until); 136 | self.notify_unread_count(); 137 | self._set_display_name(display_name.to_string()); 138 | } 139 | 140 | fn load(&self) -> impl Future> { 141 | let this = self.clone(); 142 | async move { 143 | let remote_subscription = this.imp().client.get().unwrap(); 144 | let model = remote_subscription.model().await; 145 | 146 | this.init_info( 147 | &model.topic, 148 | &model.server, 149 | model.muted, 150 | model.read_until, 151 | &model.display_name, 152 | ); 153 | 154 | let (prev_msgs, mut rx) = remote_subscription.attach().await; 155 | 156 | for msg in prev_msgs { 157 | this.handle_event(msg); 158 | } 159 | 160 | while let Ok(ev) = rx.recv().await { 161 | this.handle_event(ev); 162 | } 163 | Ok(()) 164 | } 165 | } 166 | 167 | fn handle_event(&self, ev: ListenerEvent) { 168 | match ev { 169 | ListenerEvent::Message(msg) => { 170 | self.imp().messages.append(&glib::BoxedAnyObject::new(msg)); 171 | self.update_unread_count(); 172 | } 173 | ListenerEvent::ConnectionStateChanged(connection_state) => { 174 | self.set_connection_state(connection_state); 175 | } 176 | } 177 | } 178 | 179 | fn set_connection_state(&self, state: ConnectionState) { 180 | let status = match state { 181 | ConnectionState::Unitialized => Status::Degraded, 182 | ConnectionState::Connected => Status::Up, 183 | ConnectionState::Reconnecting { .. } => Status::Degraded, 184 | }; 185 | self.imp().status.set(status); 186 | dbg!(status); 187 | self.notify_status(); 188 | } 189 | 190 | fn _set_display_name(&self, value: String) { 191 | let imp = self.imp(); 192 | let value = if value.is_empty() { 193 | self.topic() 194 | } else { 195 | value 196 | }; 197 | imp.display_name.replace(value); 198 | self.notify_display_name(); 199 | } 200 | #[instrument(skip_all)] 201 | pub fn set_display_name(&self, value: String) -> impl Future> { 202 | let this = self.clone(); 203 | async move { 204 | this._set_display_name(value); 205 | this.send_updated_info().await?; 206 | Ok(()) 207 | } 208 | } 209 | 210 | async fn send_updated_info(&self) -> anyhow::Result<()> { 211 | let imp = self.imp(); 212 | imp.client 213 | .get() 214 | .unwrap() 215 | .update_info( 216 | models::Subscription::builder(self.topic()) 217 | .display_name((imp.display_name.borrow().to_string())) 218 | .muted(imp.muted.get()) 219 | .build() 220 | .map_err(|e| anyhow::anyhow!("invalid subscription data {:?}", e))?, 221 | ) 222 | .await?; 223 | Ok(()) 224 | } 225 | fn last_message(list: &gio::ListStore) -> Option { 226 | let n = list.n_items(); 227 | let last = list 228 | .item(n.checked_sub(1)?) 229 | .and_downcast::()?; 230 | let last = last.borrow::(); 231 | Some(last.clone()) 232 | } 233 | fn update_unread_count(&self) { 234 | let imp = self.imp(); 235 | if Self::last_message(&imp.messages).map(|last| last.time) > Some(imp.read_until.get()) { 236 | imp.unread_count.set(1); 237 | } else { 238 | imp.unread_count.set(0); 239 | } 240 | self.notify_unread_count(); 241 | } 242 | 243 | pub fn set_muted(&self, value: bool) -> impl Future> { 244 | let this = self.clone(); 245 | async move { 246 | this.imp().muted.replace(value); 247 | this.notify_muted(); 248 | this.send_updated_info().await?; 249 | Ok(()) 250 | } 251 | } 252 | pub async fn flag_all_as_read(&self) -> anyhow::Result<()> { 253 | let imp = self.imp(); 254 | let Some(value) = Self::last_message(&imp.messages) 255 | .map(|last| last.time) 256 | .filter(|time| *time > self.imp().read_until.get()) 257 | else { 258 | return Ok(()); 259 | }; 260 | 261 | let this = self.clone(); 262 | this.imp() 263 | .client 264 | .get() 265 | .unwrap() 266 | .update_read_until(value) 267 | .await?; 268 | this.imp().read_until.set(value); 269 | this.update_unread_count(); 270 | 271 | Ok(()) 272 | } 273 | pub async fn publish_msg(&self, mut msg: models::OutgoingMessage) -> anyhow::Result<()> { 274 | let imp = self.imp(); 275 | let json = { 276 | msg.topic = self.topic(); 277 | serde_json::to_string(&msg)? 278 | }; 279 | imp.client.get().unwrap().publish(json).await?; 280 | Ok(()) 281 | } 282 | #[instrument(skip_all)] 283 | pub async fn clear_notifications(&self) -> anyhow::Result<()> { 284 | let imp = self.imp(); 285 | imp.client.get().unwrap().clear_notifications().await?; 286 | self.imp().messages.remove_all(); 287 | 288 | Ok(()) 289 | } 290 | 291 | pub fn nice_status(&self) -> Status { 292 | Status::try_from(self.imp().status.get() as u16).unwrap() 293 | } 294 | } 295 | -------------------------------------------------------------------------------- /src/widgets/add_subscription_dialog.rs: -------------------------------------------------------------------------------- 1 | use std::cell::OnceCell; 2 | use std::cell::RefCell; 3 | 4 | use adw::prelude::*; 5 | use adw::subclass::prelude::*; 6 | use glib::subclass::Signal; 7 | use gtk::gio; 8 | use gtk::glib; 9 | use ntfy_daemon::models; 10 | use once_cell::sync::Lazy; 11 | 12 | #[derive(Default, Debug, Clone)] 13 | pub struct Widgets { 14 | pub topic_entry: adw::EntryRow, 15 | pub server_entry: adw::EntryRow, 16 | pub server_expander: adw::ExpanderRow, 17 | pub sub_btn: gtk::Button, 18 | } 19 | mod imp { 20 | pub use super::*; 21 | #[derive(Debug, Default)] 22 | pub struct AddSubscriptionDialog { 23 | pub widgets: RefCell, 24 | pub init_custom_server: OnceCell, 25 | } 26 | 27 | #[glib::object_subclass] 28 | impl ObjectSubclass for AddSubscriptionDialog { 29 | const NAME: &'static str = "AddSubscriptionDialog"; 30 | type Type = super::AddSubscriptionDialog; 31 | type ParentType = adw::Dialog; 32 | 33 | fn class_init(klass: &mut Self::Class) { 34 | klass.install_action("default.activate", None, |this, _, _| { 35 | this.emit_subscribe_request(); 36 | }); 37 | } 38 | } 39 | 40 | impl ObjectImpl for AddSubscriptionDialog { 41 | fn signals() -> &'static [Signal] { 42 | static SIGNALS: Lazy> = 43 | Lazy::new(|| vec![Signal::builder("subscribe-request").build()]); 44 | SIGNALS.as_ref() 45 | } 46 | } 47 | impl WidgetImpl for AddSubscriptionDialog {} 48 | impl AdwDialogImpl for AddSubscriptionDialog {} 49 | } 50 | 51 | glib::wrapper! { 52 | pub struct AddSubscriptionDialog(ObjectSubclass) 53 | @extends gtk::Widget, adw::Dialog, 54 | @implements gio::ActionMap, gio::ActionGroup, gtk::Root; 55 | } 56 | 57 | impl AddSubscriptionDialog { 58 | pub fn new(custom_server: Option) -> Self { 59 | let this: Self = glib::Object::builder().build(); 60 | if let Some(s) = custom_server { 61 | if s != ntfy_daemon::models::DEFAULT_SERVER { 62 | this.imp().init_custom_server.set(s).unwrap(); 63 | } 64 | } 65 | this.build_ui(); 66 | this 67 | } 68 | fn build_ui(&self) { 69 | let imp = self.imp(); 70 | let obj = self.clone(); 71 | obj.set_title("Subscribe To Topic"); 72 | 73 | relm4_macros::view! { 74 | toolbar_view = adw::ToolbarView { 75 | add_top_bar: &adw::HeaderBar::new(), 76 | #[wrap(Some)] 77 | set_content = >k::Box { 78 | set_orientation: gtk::Orientation::Vertical, 79 | set_spacing: 12, 80 | set_margin_end: 12, 81 | set_margin_start: 12, 82 | set_margin_top: 12, 83 | set_margin_bottom: 12, 84 | append = >k::Label { 85 | add_css_class: "dim-label", 86 | set_label: "Topics may not be password-protected, so choose a name that's not easy to guess. \ 87 | Once subscribed, you can PUT/POST notifications.", 88 | set_wrap: true, 89 | set_xalign: 0.0, 90 | set_wrap_mode: gtk::pango::WrapMode::WordChar 91 | }, 92 | append = >k::ListBox { 93 | add_css_class: "boxed-list", 94 | append: topic_entry = &adw::EntryRow { 95 | set_title: "Topic", 96 | set_activates_default: true, 97 | add_suffix = >k::Button { 98 | set_icon_name: "dice3-symbolic", 99 | set_tooltip_text: Some("Generate name"), 100 | set_valign: gtk::Align::Center, 101 | add_css_class: "flat", 102 | connect_clicked[topic_entry] => move |_| { 103 | use rand::distributions::Alphanumeric; 104 | use rand::{thread_rng, Rng}; 105 | let mut rng = thread_rng(); 106 | let chars: String = (0..10).map(|_| rng.sample(Alphanumeric) as char).collect(); 107 | topic_entry.set_text(&chars); 108 | } 109 | } 110 | }, 111 | append: server_expander = &adw::ExpanderRow { 112 | set_title: "Custom server...", 113 | set_enable_expansion: imp.init_custom_server.get().is_some(), 114 | set_expanded: imp.init_custom_server.get().is_some(), 115 | set_show_enable_switch: true, 116 | add_row: server_entry = &adw::EntryRow { 117 | set_title: "Server", 118 | set_text: imp.init_custom_server.get().map(|x| x.as_str()).unwrap_or(""), 119 | } 120 | } 121 | }, 122 | append: sub_btn = >k::Button { 123 | set_label: "Subscribe", 124 | add_css_class: "suggested-action", 125 | add_css_class: "pill", 126 | set_halign: gtk::Align::Center, 127 | set_sensitive: false, 128 | connect_clicked[obj] => move |_| { 129 | obj.emit_subscribe_request(); 130 | } 131 | } 132 | }, 133 | }, 134 | } 135 | 136 | let debounced_error_check = { 137 | let db = crate::async_utils::Debouncer::new(); 138 | let objc = obj.clone(); 139 | move || { 140 | db.call(std::time::Duration::from_millis(500), move || { 141 | objc.check_errors() 142 | }); 143 | } 144 | }; 145 | 146 | let f = debounced_error_check.clone(); 147 | topic_entry 148 | .delegate() 149 | .unwrap() 150 | .connect_changed(move |_| f.clone()()); 151 | let f = debounced_error_check.clone(); 152 | server_entry 153 | .delegate() 154 | .unwrap() 155 | .connect_changed(move |_| f.clone()()); 156 | let f = debounced_error_check.clone(); 157 | server_expander.connect_enable_expansion_notify(move |_| f.clone()()); 158 | 159 | imp.widgets.replace(Widgets { 160 | topic_entry, 161 | server_expander, 162 | server_entry, 163 | sub_btn, 164 | }); 165 | 166 | obj.set_content_width(480); 167 | obj.set_child(Some(&toolbar_view)); 168 | } 169 | pub fn subscription(&self) -> Result { 170 | let w = { self.imp().widgets.borrow().clone() }; 171 | let mut sub = models::Subscription::builder(w.topic_entry.text().to_string()); 172 | if w.server_expander.enables_expansion() { 173 | sub = sub.server(w.server_entry.text().to_string()); 174 | } 175 | 176 | sub.build() 177 | } 178 | fn check_errors(&self) { 179 | let w = { self.imp().widgets.borrow().clone() }; 180 | let sub = self.subscription(); 181 | 182 | w.server_entry.remove_css_class("error"); 183 | w.topic_entry.remove_css_class("error"); 184 | w.sub_btn.set_sensitive(true); 185 | 186 | if let Err(ntfy_daemon::Error::InvalidSubscription(errs)) = sub { 187 | w.sub_btn.set_sensitive(false); 188 | for e in errs { 189 | match e { 190 | ntfy_daemon::Error::InvalidTopic(_) => { 191 | w.topic_entry.add_css_class("error"); 192 | } 193 | ntfy_daemon::Error::InvalidServer(_) => { 194 | w.server_entry.add_css_class("error"); 195 | } 196 | _ => {} 197 | } 198 | } 199 | } 200 | } 201 | fn emit_subscribe_request(&self) { 202 | self.emit_by_name::<()>("subscribe-request", &[]); 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /src/widgets/advanced_message_dialog.rs: -------------------------------------------------------------------------------- 1 | use std::cell::OnceCell; 2 | 3 | use adw::prelude::*; 4 | use adw::subclass::prelude::*; 5 | use gsv::prelude::*; 6 | use gtk::{gio, glib}; 7 | 8 | use crate::error::*; 9 | use crate::subscription::Subscription; 10 | 11 | mod imp { 12 | use super::*; 13 | 14 | #[derive(Debug, Default)] 15 | pub struct AdvancedMessageDialog { 16 | pub subscription: OnceCell, 17 | } 18 | 19 | #[glib::object_subclass] 20 | impl ObjectSubclass for AdvancedMessageDialog { 21 | const NAME: &'static str = "AdvancedMessageDialog"; 22 | type Type = super::AdvancedMessageDialog; 23 | type ParentType = adw::Dialog; 24 | } 25 | 26 | impl ObjectImpl for AdvancedMessageDialog {} 27 | impl WidgetImpl for AdvancedMessageDialog {} 28 | impl AdwDialogImpl for AdvancedMessageDialog {} 29 | } 30 | 31 | glib::wrapper! { 32 | pub struct AdvancedMessageDialog(ObjectSubclass) 33 | @extends gtk::Widget, adw::Dialog; 34 | } 35 | 36 | impl AdvancedMessageDialog { 37 | pub fn new(subscription: Subscription, message: String) -> Self { 38 | let this: Self = glib::Object::new(); 39 | this.imp().subscription.set(subscription).unwrap(); 40 | this.build_ui( 41 | this.imp().subscription.get().unwrap().topic().clone(), 42 | message, 43 | ); 44 | this 45 | } 46 | fn build_ui(&self, topic: String, message: String) { 47 | self.set_title("Advanced Message"); 48 | self.set_content_height(480); 49 | self.set_content_width(480); 50 | let this = self.clone(); 51 | relm4_macros::view! { 52 | content = &adw::ToolbarView { 53 | add_top_bar = &adw::HeaderBar {}, 54 | #[wrap(Some)] 55 | set_content: toast_overlay = &adw::ToastOverlay { 56 | #[wrap(Some)] 57 | set_child = >k::ScrolledWindow { 58 | #[wrap(Some)] 59 | set_child = >k::Box { 60 | set_margin_top: 8, 61 | set_margin_bottom: 8, 62 | set_margin_start: 8, 63 | set_margin_end: 8, 64 | set_spacing: 8, 65 | set_orientation: gtk::Orientation::Vertical, 66 | append = >k::Label { 67 | set_label: "Here you can manually build the JSON message you want to POST to this topic", 68 | set_natural_wrap_mode: gtk::NaturalWrapMode::None, 69 | set_xalign: 0.0, 70 | set_halign: gtk::Align::Start, 71 | set_wrap_mode: gtk::pango::WrapMode::WordChar, 72 | set_wrap: true, 73 | }, 74 | append = >k::Label { 75 | add_css_class: "heading", 76 | set_label: "JSON", 77 | set_xalign: 0.0, 78 | set_halign: gtk::Align::Start, 79 | }, 80 | append: text_view = &gsv::View { 81 | add_css_class: "code", 82 | set_tab_width: 4, 83 | set_indent_width: 2, 84 | set_auto_indent: true, 85 | set_top_margin: 4, 86 | set_bottom_margin: 4, 87 | set_left_margin: 4, 88 | set_right_margin: 4, 89 | set_hexpand: true, 90 | set_vexpand: true, 91 | set_monospace: true, 92 | set_background_pattern: gsv::BackgroundPatternType::Grid 93 | }, 94 | append = >k::Label { 95 | add_css_class: "heading", 96 | set_label: "Snippets", 97 | set_xalign: 0.0, 98 | set_halign: gtk::Align::Start, 99 | }, 100 | append = >k::FlowBox { 101 | set_column_spacing: 4, 102 | set_row_spacing: 4, 103 | append = >k::Button { 104 | add_css_class: "pill", 105 | add_css_class: "small", 106 | set_label: "Title", 107 | connect_clicked[text_view] => move |_| { 108 | text_view.buffer().insert_at_cursor(r#""title": "Title of your message""#) 109 | } 110 | }, 111 | append = >k::Button { 112 | add_css_class: "pill", 113 | add_css_class: "small", 114 | set_label: "Tags", 115 | connect_clicked[text_view] => move |_| { 116 | text_view.buffer().insert_at_cursor(r#""tags": ["warning","cd"]"#) 117 | } 118 | }, 119 | append = >k::Button { 120 | add_css_class: "pill", 121 | add_css_class: "small", 122 | set_label: "Priority", 123 | connect_clicked[text_view] => move |_| { 124 | text_view.buffer().insert_at_cursor(r#""priority": 5"#) 125 | } 126 | }, 127 | append = >k::Button { 128 | add_css_class: "pill", 129 | add_css_class: "small", 130 | set_label: "View Action", 131 | connect_clicked[text_view] => move |_| { 132 | text_view.buffer().insert_at_cursor(r#""actions": [ 133 | { 134 | "action": "view", 135 | "label": "torvalds boosted your toot", 136 | "url": "https://joinmastodon.org" 137 | } 138 | ]"#) 139 | } 140 | }, 141 | append = >k::Button { 142 | add_css_class: "pill", 143 | add_css_class: "small", 144 | set_label: "HTTP Action", 145 | connect_clicked[text_view] => move |_| { 146 | text_view.buffer().insert_at_cursor(r#""actions": [ 147 | { 148 | "action": "http", 149 | "label": "Turn off lights", 150 | "method": "post", 151 | "url": "https://api.example.com/lights", 152 | "body": "OFF" 153 | } 154 | ]"#) 155 | } 156 | }, 157 | append = >k::Button { 158 | add_css_class: "circular", 159 | add_css_class: "small", 160 | set_label: "?", 161 | connect_clicked => move |_| { 162 | gtk::UriLauncher::new("https://docs.ntfy.sh/publish/#publish-as-json").launch( 163 | None::<>k::Window>, 164 | gio::Cancellable::NONE, 165 | |_| {} 166 | ); 167 | } 168 | }, 169 | }, 170 | append = >k::Button { 171 | set_margin_top: 8, 172 | set_margin_bottom: 8, 173 | add_css_class: "suggested-action", 174 | add_css_class: "pill", 175 | set_label: "Send", 176 | connect_clicked[this, toast_overlay, text_view] => move |_| { 177 | let thisc = this.clone(); 178 | let text_view = text_view.clone(); 179 | let f = async move { 180 | let buffer = text_view.buffer(); 181 | let msg = serde_json::from_str(&buffer.text( 182 | &mut buffer.start_iter(), 183 | &mut buffer.end_iter(), 184 | true, 185 | ))?; 186 | thisc.imp().subscription.get().unwrap() 187 | .publish_msg(msg).await 188 | }; 189 | toast_overlay.error_boundary().spawn(f); 190 | } 191 | } 192 | } 193 | } 194 | } 195 | } 196 | } 197 | 198 | let lang = gsv::LanguageManager::default().language("json").unwrap(); 199 | let buffer = gsv::Buffer::with_language(&lang); 200 | buffer.set_text(&format!( 201 | r#"{{ 202 | "topic": "{topic}", 203 | "message": "{message}" 204 | }}"# 205 | )); 206 | text_view.set_buffer(Some(&buffer)); 207 | 208 | let manager = adw::StyleManager::default(); 209 | let scheme_name = if manager.is_dark() { 210 | "solarized-dark" 211 | } else { 212 | "solarized-light" 213 | }; 214 | let scheme = gsv::StyleSchemeManager::default().scheme(scheme_name); 215 | buffer.set_style_scheme(scheme.as_ref()); 216 | this.set_child(Some(&content)); 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /src/widgets/message_row.rs: -------------------------------------------------------------------------------- 1 | use std::io::Read; 2 | 3 | use adw::prelude::*; 4 | use adw::subclass::prelude::*; 5 | use chrono::NaiveDateTime; 6 | use gtk::{gdk, gio, glib}; 7 | use ntfy_daemon::models; 8 | use tracing::error; 9 | 10 | use crate::error::*; 11 | 12 | mod imp { 13 | use super::*; 14 | 15 | #[derive(Debug, Default)] 16 | pub struct MessageRow {} 17 | 18 | #[glib::object_subclass] 19 | impl ObjectSubclass for MessageRow { 20 | const NAME: &'static str = "MessageRow"; 21 | type Type = super::MessageRow; 22 | type ParentType = gtk::Grid; 23 | } 24 | 25 | impl ObjectImpl for MessageRow {} 26 | 27 | impl WidgetImpl for MessageRow {} 28 | impl GridImpl for MessageRow {} 29 | } 30 | 31 | glib::wrapper! { 32 | pub struct MessageRow(ObjectSubclass) 33 | @extends gtk::Widget, gtk::Grid; 34 | } 35 | 36 | impl MessageRow { 37 | pub fn new(msg: models::ReceivedMessage) -> Self { 38 | let this: Self = glib::Object::new(); 39 | this.build_ui(msg); 40 | this 41 | } 42 | fn build_ui(&self, msg: models::ReceivedMessage) { 43 | self.set_margin_top(8); 44 | self.set_margin_bottom(8); 45 | self.set_margin_start(8); 46 | self.set_margin_end(8); 47 | self.set_column_spacing(8); 48 | self.set_row_spacing(8); 49 | let mut row = 0; 50 | 51 | let time = gtk::Label::builder() 52 | .label( 53 | &NaiveDateTime::from_timestamp_opt(msg.time as i64, 0) 54 | .map(|time| time.format("%Y-%m-%d %H:%M:%S").to_string()) 55 | .unwrap_or_default(), 56 | ) 57 | .xalign(0.0) 58 | .build(); 59 | time.add_css_class("caption"); 60 | self.attach(&time, 0, row, 1, 1); 61 | 62 | if let Some(p) = msg.priority { 63 | let text = format!( 64 | "Priority: {}", 65 | match p { 66 | 5 => "Max", 67 | 4 => "High", 68 | 3 => "Medium", 69 | 2 => "Low", 70 | 1 => "Min", 71 | _ => "Invalid", 72 | } 73 | ); 74 | let priority = gtk::Label::builder().label(&text).xalign(0.0).build(); 75 | priority.add_css_class("caption"); 76 | priority.add_css_class("chip"); 77 | if p == 5 { 78 | priority.add_css_class("chip--danger") 79 | } else if p == 4 { 80 | priority.add_css_class("chip--warning") 81 | } 82 | priority.set_halign(gtk::Align::End); 83 | self.attach(&priority, 1, 0, 2, 1); 84 | } 85 | row += 1; 86 | 87 | if let Some(title) = msg.display_title() { 88 | let label = gtk::Label::builder() 89 | .label(&title) 90 | .wrap_mode(gtk::pango::WrapMode::WordChar) 91 | .xalign(0.0) 92 | .wrap(true) 93 | .selectable(true) 94 | .build(); 95 | label.add_css_class("heading"); 96 | self.attach(&label, 0, row, 3, 1); 97 | row += 1; 98 | } 99 | 100 | if let Some(message) = msg.display_message() { 101 | let label = gtk::Label::builder() 102 | .label(&message) 103 | .wrap_mode(gtk::pango::WrapMode::WordChar) 104 | .xalign(0.0) 105 | .wrap(true) 106 | .selectable(true) 107 | .hexpand(true) 108 | .build(); 109 | self.attach(&label, 0, row, 3, 1); 110 | row += 1; 111 | } 112 | 113 | if let Some(attachment) = msg.attachment { 114 | if attachment.is_image() { 115 | self.attach(&self.build_image(attachment.url.to_string()), 0, row, 3, 1); 116 | row += 1; 117 | } 118 | } 119 | 120 | if msg.actions.len() > 0 { 121 | let action_btns = gtk::FlowBox::builder() 122 | .row_spacing(8) 123 | .column_spacing(8) 124 | .homogeneous(true) 125 | .selection_mode(gtk::SelectionMode::None) 126 | .build(); 127 | 128 | for a in msg.actions { 129 | let btn = self.build_action_btn(a); 130 | action_btns.append(&btn); 131 | } 132 | 133 | self.attach(&action_btns, 0, row, 3, 1); 134 | row += 1; 135 | } 136 | if msg.tags.len() > 0 { 137 | let mut tags_text = String::from("tags: "); 138 | tags_text.push_str(&msg.tags.join(", ")); 139 | let tags = gtk::Label::builder() 140 | .label(&tags_text) 141 | .xalign(0.0) 142 | .wrap(true) 143 | .wrap_mode(gtk::pango::WrapMode::WordChar) 144 | .build(); 145 | self.attach(&tags, 0, row, 3, 1); 146 | } 147 | } 148 | fn fetch_image_bytes(url: &str) -> anyhow::Result> { 149 | let path = glib::user_cache_dir().join("com.ranfdev.Notify").join(&url); 150 | let bytes = if path.exists() { 151 | std::fs::read(&path)? 152 | } else { 153 | let mut bytes = vec![]; 154 | ureq::get(&url) 155 | .call()? 156 | .into_reader() 157 | .take(5 * 1_000_000) // 5 MB 158 | .read_to_end(&mut bytes)?; 159 | bytes 160 | }; 161 | Ok(bytes) 162 | } 163 | fn build_image(&self, url: String) -> gtk::Picture { 164 | let (s, r) = async_channel::unbounded(); 165 | gio::spawn_blocking(move || { 166 | if let Err(e) = Self::fetch_image_bytes(&url).and_then(|bytes| { 167 | let t = gdk::Texture::from_bytes(&glib::Bytes::from_owned(bytes))?; 168 | s.send_blocking(t)?; 169 | Ok(()) 170 | }) { 171 | error!(error = %e) 172 | } 173 | glib::ControlFlow::Break 174 | }); 175 | let picture = gtk::Picture::new(); 176 | picture.set_can_shrink(true); 177 | picture.set_height_request(350); 178 | let picturec = picture.clone(); 179 | 180 | self.error_boundary().spawn(async move { 181 | let t = r.recv().await?; 182 | picturec.set_paintable(Some(&t)); 183 | Ok(()) 184 | }); 185 | 186 | picture 187 | } 188 | fn build_action_btn(&self, action: models::Action) -> gtk::Button { 189 | let btn = gtk::Button::new(); 190 | match &action { 191 | models::Action::View { label, url, .. } => { 192 | btn.set_label(&label); 193 | btn.set_tooltip_text(Some(&format!("Go to {url}"))); 194 | btn.set_action_name(Some("app.message-action")); 195 | btn.set_action_target_value(Some(&serde_json::to_string(&action).unwrap().into())); 196 | } 197 | models::Action::Http { 198 | label, method, url, .. 199 | } => { 200 | btn.set_label(&label); 201 | btn.set_tooltip_text(Some(&format!("Send HTTP {method} to {url}"))); 202 | btn.set_action_name(Some("app.message-action")); 203 | btn.set_action_target_value(Some(&serde_json::to_string(&action).unwrap().into())); 204 | } 205 | models::Action::Broadcast { label, .. } => { 206 | btn.set_label(&label); 207 | btn.set_sensitive(false); 208 | btn.set_tooltip_text(Some("Broadcast action only available on Android")); 209 | } 210 | } 211 | btn 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /src/widgets/mod.rs: -------------------------------------------------------------------------------- 1 | mod add_subscription_dialog; 2 | mod advanced_message_dialog; 3 | mod message_row; 4 | mod preferences; 5 | mod subscription_info_dialog; 6 | mod window; 7 | pub use add_subscription_dialog::AddSubscriptionDialog; 8 | pub use advanced_message_dialog::*; 9 | pub use message_row::*; 10 | pub use preferences::*; 11 | pub use subscription_info_dialog::SubscriptionInfoDialog; 12 | pub use window::*; 13 | -------------------------------------------------------------------------------- /src/widgets/preferences.rs: -------------------------------------------------------------------------------- 1 | use std::cell::OnceCell; 2 | 3 | use adw::prelude::*; 4 | use adw::subclass::prelude::*; 5 | use gtk::{gio, glib}; 6 | 7 | use crate::error::*; 8 | 9 | mod imp { 10 | use ntfy_daemon::NtfyHandle; 11 | 12 | use super::*; 13 | 14 | #[derive(gtk::CompositeTemplate)] 15 | #[template(resource = "/com/ranfdev/Notify/ui/preferences.ui")] 16 | pub struct NotifyPreferences { 17 | #[template_child] 18 | pub server_entry: TemplateChild, 19 | #[template_child] 20 | pub username_entry: TemplateChild, 21 | #[template_child] 22 | pub password_entry: TemplateChild, 23 | #[template_child] 24 | pub add_btn: TemplateChild, 25 | #[template_child] 26 | pub added_accounts: TemplateChild, 27 | #[template_child] 28 | pub added_accounts_group: TemplateChild, 29 | pub notifier: OnceCell, 30 | } 31 | 32 | impl Default for NotifyPreferences { 33 | fn default() -> Self { 34 | let this = Self { 35 | server_entry: Default::default(), 36 | username_entry: Default::default(), 37 | password_entry: Default::default(), 38 | add_btn: Default::default(), 39 | added_accounts: Default::default(), 40 | added_accounts_group: Default::default(), 41 | notifier: Default::default(), 42 | }; 43 | 44 | this 45 | } 46 | } 47 | 48 | #[glib::object_subclass] 49 | impl ObjectSubclass for NotifyPreferences { 50 | const NAME: &'static str = "NotifyPreferences"; 51 | type Type = super::NotifyPreferences; 52 | type ParentType = adw::PreferencesDialog; 53 | 54 | fn class_init(klass: &mut Self::Class) { 55 | klass.bind_template(); 56 | } 57 | 58 | fn instance_init(obj: &glib::subclass::InitializingObject) { 59 | obj.init_template(); 60 | } 61 | } 62 | 63 | impl ObjectImpl for NotifyPreferences { 64 | fn dispose(&self) { 65 | self.dispose_template(); 66 | } 67 | } 68 | 69 | impl WidgetImpl for NotifyPreferences {} 70 | impl AdwDialogImpl for NotifyPreferences {} 71 | impl PreferencesDialogImpl for NotifyPreferences {} 72 | } 73 | 74 | glib::wrapper! { 75 | pub struct NotifyPreferences(ObjectSubclass) 76 | @extends gtk::Widget, adw::Dialog, adw::PreferencesDialog, 77 | @implements gio::ActionMap, gio::ActionGroup, gtk::Root; 78 | } 79 | 80 | impl NotifyPreferences { 81 | pub fn new(notifier: ntfy_daemon::NtfyHandle) -> Self { 82 | let obj: Self = glib::Object::builder().build(); 83 | obj.imp() 84 | .notifier 85 | .set(notifier) 86 | .map_err(|_| "notifier") 87 | .unwrap(); 88 | let this = obj.clone(); 89 | obj.imp().add_btn.connect_clicked(move |btn| { 90 | let this = this.clone(); 91 | btn.error_boundary() 92 | .spawn(async move { this.add_account().await }); 93 | }); 94 | let this = obj.clone(); 95 | obj.imp() 96 | .added_accounts 97 | .error_boundary() 98 | .spawn(async move { this.show_accounts().await }); 99 | obj 100 | } 101 | 102 | pub async fn show_accounts(&self) -> anyhow::Result<()> { 103 | let imp = self.imp(); 104 | let accounts = imp.notifier.get().unwrap().list_accounts().await?; 105 | 106 | imp.added_accounts_group.set_visible(!accounts.is_empty()); 107 | 108 | imp.added_accounts.remove_all(); 109 | for a in accounts { 110 | let row = adw::ActionRow::builder() 111 | .title(&a.server) 112 | .subtitle(&a.username) 113 | .build(); 114 | row.add_css_class("property"); 115 | row.add_suffix(&{ 116 | let btn = gtk::Button::builder() 117 | .icon_name("user-trash-symbolic") 118 | .build(); 119 | btn.add_css_class("flat"); 120 | let this = self.clone(); 121 | btn.connect_clicked(move |btn| { 122 | let this = this.clone(); 123 | let a = a.clone(); 124 | btn.error_boundary() 125 | .spawn(async move { this.remove_account(&a.server).await }); 126 | }); 127 | btn 128 | }); 129 | imp.added_accounts.append(&row); 130 | } 131 | Ok(()) 132 | } 133 | pub async fn add_account(&self) -> anyhow::Result<()> { 134 | let imp = self.imp(); 135 | let password = imp.password_entry.text(); 136 | let server = imp.server_entry.text(); 137 | let username = imp.username_entry.text(); 138 | 139 | imp.notifier 140 | .get() 141 | .unwrap() 142 | .add_account(&server, &username, &password) 143 | .await?; 144 | self.show_accounts().await?; 145 | 146 | Ok(()) 147 | } 148 | pub async fn remove_account(&self, server: &str) -> anyhow::Result<()> { 149 | self.imp() 150 | .notifier 151 | .get() 152 | .unwrap() 153 | .remove_account(server) 154 | .await?; 155 | self.show_accounts().await?; 156 | Ok(()) 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /src/widgets/subscription_info_dialog.rs: -------------------------------------------------------------------------------- 1 | use std::cell::RefCell; 2 | 3 | use adw::prelude::*; 4 | use adw::subclass::prelude::*; 5 | use glib::Properties; 6 | use gtk::gio; 7 | use gtk::glib; 8 | 9 | use crate::error::*; 10 | 11 | mod imp { 12 | pub use super::*; 13 | #[derive(Debug, Default, Properties, gtk::CompositeTemplate)] 14 | #[template(resource = "/com/ranfdev/Notify/ui/subscription_info_dialog.ui")] 15 | #[properties(wrapper_type = super::SubscriptionInfoDialog)] 16 | pub struct SubscriptionInfoDialog { 17 | #[property(get, construct_only)] 18 | pub subscription: RefCell>, 19 | #[template_child] 20 | pub display_name_entry: TemplateChild, 21 | #[template_child] 22 | pub muted_switch_row: TemplateChild, 23 | } 24 | 25 | #[glib::object_subclass] 26 | impl ObjectSubclass for SubscriptionInfoDialog { 27 | const NAME: &'static str = "SubscriptionInfoDialog"; 28 | type Type = super::SubscriptionInfoDialog; 29 | type ParentType = adw::Dialog; 30 | 31 | fn class_init(klass: &mut Self::Class) { 32 | klass.bind_template(); 33 | } 34 | 35 | // You must call `Widget`'s `init_template()` within `instance_init()`. 36 | fn instance_init(obj: &glib::subclass::InitializingObject) { 37 | obj.init_template(); 38 | } 39 | } 40 | #[glib::derived_properties] 41 | impl ObjectImpl for SubscriptionInfoDialog { 42 | fn constructed(&self) { 43 | self.parent_constructed(); 44 | let this = self.obj().clone(); 45 | 46 | self.display_name_entry 47 | .set_text(&this.subscription().unwrap().display_name()); 48 | self.muted_switch_row 49 | .set_active(this.subscription().unwrap().muted()); 50 | 51 | let debouncer = crate::async_utils::Debouncer::new(); 52 | self.display_name_entry.connect_changed({ 53 | move |entry| { 54 | let entry = entry.clone(); 55 | let this = this.clone(); 56 | debouncer.call(std::time::Duration::from_millis(500), move || { 57 | this.update_display_name(&entry); 58 | }) 59 | } 60 | }); 61 | let this = self.obj().clone(); 62 | self.muted_switch_row.connect_active_notify({ 63 | move |switch| { 64 | this.update_muted(switch); 65 | } 66 | }); 67 | } 68 | } 69 | impl WidgetImpl for SubscriptionInfoDialog {} 70 | impl AdwDialogImpl for SubscriptionInfoDialog {} 71 | } 72 | 73 | glib::wrapper! { 74 | pub struct SubscriptionInfoDialog(ObjectSubclass) 75 | @extends gtk::Widget, adw::Dialog, 76 | @implements gio::ActionMap, gio::ActionGroup, gtk::Root; 77 | } 78 | 79 | impl SubscriptionInfoDialog { 80 | pub fn new(subscription: crate::subscription::Subscription) -> Self { 81 | let this = glib::Object::builder() 82 | .property("subscription", subscription) 83 | .build(); 84 | this 85 | } 86 | fn update_display_name(&self, entry: &impl IsA) { 87 | if let Some(sub) = self.subscription() { 88 | let entry = entry.clone(); 89 | self.error_boundary().spawn(async move { 90 | let res = sub.set_display_name(entry.text().to_string()).await; 91 | res 92 | }); 93 | } 94 | } 95 | fn update_muted(&self, switch: &adw::SwitchRow) { 96 | if let Some(sub) = self.subscription() { 97 | let switch = switch.clone(); 98 | self.error_boundary() 99 | .spawn(async move { sub.set_muted(switch.is_active()).await }) 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/widgets/window.rs: -------------------------------------------------------------------------------- 1 | use std::cell::Cell; 2 | use std::cell::OnceCell; 3 | 4 | use adw::prelude::*; 5 | use adw::subclass::prelude::*; 6 | use gtk::{gio, glib}; 7 | use ntfy_daemon::models; 8 | use ntfy_daemon::NtfyHandle; 9 | use tracing::warn; 10 | 11 | use crate::application::NotifyApplication; 12 | use crate::config::{APP_ID, PROFILE}; 13 | use crate::error::*; 14 | use crate::subscription::Status; 15 | use crate::subscription::Subscription; 16 | use crate::widgets::*; 17 | 18 | mod imp { 19 | use super::*; 20 | 21 | #[derive(gtk::CompositeTemplate)] 22 | #[template(resource = "/com/ranfdev/Notify/ui/window.ui")] 23 | pub struct NotifyWindow { 24 | #[template_child] 25 | pub headerbar: TemplateChild, 26 | #[template_child] 27 | pub message_list: TemplateChild, 28 | #[template_child] 29 | pub subscription_list: TemplateChild, 30 | #[template_child] 31 | pub entry: TemplateChild, 32 | #[template_child] 33 | pub navigation_split_view: TemplateChild, 34 | #[template_child] 35 | pub subscription_view: TemplateChild, 36 | #[template_child] 37 | pub subscription_menu_btn: TemplateChild, 38 | pub subscription_list_model: gio::ListStore, 39 | #[template_child] 40 | pub toast_overlay: TemplateChild, 41 | #[template_child] 42 | pub stack: TemplateChild, 43 | #[template_child] 44 | pub welcome_view: TemplateChild, 45 | #[template_child] 46 | pub list_view: TemplateChild, 47 | #[template_child] 48 | pub message_scroll: TemplateChild, 49 | #[template_child] 50 | pub banner: TemplateChild, 51 | #[template_child] 52 | pub send_btn: TemplateChild, 53 | #[template_child] 54 | pub code_btn: TemplateChild, 55 | pub notifier: OnceCell, 56 | pub conn: OnceCell, 57 | pub settings: gio::Settings, 58 | pub banner_binding: Cell>, 59 | } 60 | 61 | impl Default for NotifyWindow { 62 | fn default() -> Self { 63 | let this = Self { 64 | headerbar: Default::default(), 65 | message_list: Default::default(), 66 | entry: Default::default(), 67 | subscription_view: Default::default(), 68 | navigation_split_view: Default::default(), 69 | subscription_menu_btn: Default::default(), 70 | subscription_list: Default::default(), 71 | toast_overlay: Default::default(), 72 | stack: Default::default(), 73 | welcome_view: Default::default(), 74 | list_view: Default::default(), 75 | message_scroll: Default::default(), 76 | banner: Default::default(), 77 | subscription_list_model: gio::ListStore::new::(), 78 | settings: gio::Settings::new(APP_ID), 79 | notifier: Default::default(), 80 | conn: Default::default(), 81 | banner_binding: Default::default(), 82 | send_btn: Default::default(), 83 | code_btn: Default::default(), 84 | }; 85 | 86 | this 87 | } 88 | } 89 | 90 | #[gtk::template_callbacks] 91 | impl NotifyWindow { 92 | #[template_callback] 93 | fn show_add_topic(&self, _btn: >k::Button) { 94 | let this = self.obj().clone(); 95 | let dialog = 96 | AddSubscriptionDialog::new(this.selected_subscription().map(|x| x.server())); 97 | dialog.present(Some(&self.obj().clone())); 98 | 99 | let dc = dialog.clone(); 100 | dialog.connect_local("subscribe-request", true, move |_| { 101 | let sub = match dc.subscription() { 102 | Ok(sub) => sub, 103 | Err(e) => { 104 | warn!(errors = ?e, "trying to add invalid subscription"); 105 | return None; 106 | } 107 | }; 108 | this.add_subscription(sub); 109 | dc.close(); 110 | None 111 | }); 112 | } 113 | #[template_callback] 114 | fn discover_integrations(&self, _btn: >k::Button) { 115 | gtk::UriLauncher::new("https://docs.ntfy.sh/integrations/").launch( 116 | Some(&self.obj().clone()), 117 | gio::Cancellable::NONE, 118 | |_| {}, 119 | ); 120 | } 121 | } 122 | 123 | #[glib::object_subclass] 124 | impl ObjectSubclass for NotifyWindow { 125 | const NAME: &'static str = "NotifyWindow"; 126 | type Type = super::NotifyWindow; 127 | type ParentType = adw::ApplicationWindow; 128 | 129 | fn class_init(klass: &mut Self::Class) { 130 | klass.bind_template(); 131 | klass.bind_template_callbacks(); 132 | 133 | klass.install_action("win.unsubscribe", None, |this, _, _| { 134 | this.unsubscribe(); 135 | }); 136 | klass.install_action("win.show-subscription-info", None, |this, _, _| { 137 | this.show_subscription_info(); 138 | }); 139 | klass.install_action("win.clear-notifications", None, |this, _, _| { 140 | this.selected_subscription().map(|sub| { 141 | this.error_boundary() 142 | .spawn(async move { sub.clear_notifications().await }); 143 | }); 144 | }); 145 | //klass.bind_template_instance_callbacks(); 146 | } 147 | 148 | // You must call `Widget`'s `init_template()` within `instance_init()`. 149 | fn instance_init(obj: &glib::subclass::InitializingObject) { 150 | obj.init_template(); 151 | } 152 | } 153 | 154 | impl ObjectImpl for NotifyWindow { 155 | fn constructed(&self) { 156 | self.parent_constructed(); 157 | let obj = self.obj(); 158 | 159 | // Devel Profile 160 | if PROFILE == "Devel" { 161 | obj.add_css_class("devel"); 162 | } 163 | } 164 | 165 | fn dispose(&self) { 166 | self.dispose_template(); 167 | } 168 | } 169 | 170 | impl WidgetImpl for NotifyWindow {} 171 | impl WindowImpl for NotifyWindow { 172 | // Save window state on delete event 173 | fn close_request(&self) -> glib::Propagation { 174 | if let Err(err) = self.obj().save_window_size() { 175 | warn!(error = %err, "Failed to save window state"); 176 | } 177 | 178 | // Pass close request on to the parent 179 | self.parent_close_request() 180 | } 181 | } 182 | 183 | impl ApplicationWindowImpl for NotifyWindow {} 184 | impl AdwApplicationWindowImpl for NotifyWindow {} 185 | } 186 | 187 | glib::wrapper! { 188 | pub struct NotifyWindow(ObjectSubclass) 189 | @extends gtk::Widget, gtk::Window, adw::Window, adw::ApplicationWindow, 190 | @implements gio::ActionMap, gio::ActionGroup, gtk::Root; 191 | } 192 | 193 | impl NotifyWindow { 194 | pub fn new(app: &NotifyApplication, notifier: NtfyHandle) -> Self { 195 | let obj: Self = glib::Object::builder().property("application", app).build(); 196 | 197 | if let Err(_) = obj.imp().notifier.set(notifier) { 198 | panic!("setting notifier for first time"); 199 | }; 200 | 201 | // Load latest window state 202 | obj.load_window_size(); 203 | obj.bind_message_list(); 204 | obj.connect_entry_and_send_btn(); 205 | obj.connect_code_btn(); 206 | obj.connect_items_changed(); 207 | obj.selected_subscription_changed(None); 208 | obj.bind_flag_read(); 209 | 210 | obj 211 | } 212 | fn connect_entry_and_send_btn(&self) { 213 | let imp = self.imp(); 214 | let this = self.clone(); 215 | 216 | imp.entry.connect_activate(move |_| this.publish_msg()); 217 | let this = self.clone(); 218 | imp.send_btn.connect_clicked(move |_| this.publish_msg()); 219 | } 220 | fn publish_msg(&self) { 221 | let entry = self.imp().entry.clone(); 222 | let this = self.clone(); 223 | 224 | entry.error_boundary().spawn(async move { 225 | this.selected_subscription() 226 | .unwrap() 227 | .publish_msg(models::OutgoingMessage { 228 | message: Some(entry.text().as_str().to_string()), 229 | ..models::OutgoingMessage::default() 230 | }) 231 | .await?; 232 | Ok(()) 233 | }); 234 | } 235 | fn connect_code_btn(&self) { 236 | let imp = self.imp(); 237 | let this = self.clone(); 238 | imp.code_btn.connect_clicked(move |_| { 239 | let this = this.clone(); 240 | this.selected_subscription().map(move |sub| { 241 | AdvancedMessageDialog::new(sub, this.imp().entry.text().to_string()) 242 | .present(Some(&this)) 243 | }); 244 | }); 245 | } 246 | fn show_subscription_info(&self) { 247 | let sub = SubscriptionInfoDialog::new(self.selected_subscription().unwrap()); 248 | sub.present(Some(self)); 249 | } 250 | fn connect_items_changed(&self) { 251 | let this = self.clone(); 252 | self.imp() 253 | .subscription_list_model 254 | .connect_items_changed(move |list, _, _, _| { 255 | let imp = this.imp(); 256 | if list.n_items() == 0 { 257 | imp.stack.set_visible_child(&*imp.welcome_view); 258 | } else { 259 | imp.stack.set_visible_child(&*imp.list_view); 260 | } 261 | }); 262 | } 263 | 264 | fn add_subscription(&self, sub: models::Subscription) { 265 | let this = self.clone(); 266 | self.error_boundary().spawn(async move { 267 | let sub = this.notifier().subscribe(&sub.server, &sub.topic).await?; 268 | let imp = this.imp(); 269 | 270 | // Subscription::new will use the pipelined client to retrieve info about the subscription 271 | let subscription = Subscription::new(sub); 272 | // We want to still check if there were any errors adding the subscription. 273 | 274 | imp.subscription_list_model.append(&subscription); 275 | let i = imp.subscription_list_model.n_items() - 1; 276 | let row = imp.subscription_list.row_at_index(i as i32); 277 | imp.subscription_list.select_row(row.as_ref()); 278 | Ok(()) 279 | }); 280 | } 281 | 282 | fn unsubscribe(&self) { 283 | let sub = self.selected_subscription().unwrap(); 284 | 285 | let this = self.clone(); 286 | self.error_boundary().spawn(async move { 287 | this.notifier() 288 | .unsubscribe(sub.server().as_str(), sub.topic().as_str()) 289 | .await?; 290 | 291 | let imp = this.imp(); 292 | if let Some(i) = imp.subscription_list_model.find(&sub) { 293 | imp.subscription_list_model.remove(i); 294 | } 295 | Ok(()) 296 | }); 297 | } 298 | fn notifier(&self) -> &NtfyHandle { 299 | self.imp().notifier.get().unwrap() 300 | } 301 | fn selected_subscription(&self) -> Option { 302 | let imp = self.imp(); 303 | imp.subscription_list 304 | .selected_row() 305 | .and_then(|row| imp.subscription_list_model.item(row.index() as u32)) 306 | .and_downcast::() 307 | } 308 | fn bind_message_list(&self) { 309 | let imp = self.imp(); 310 | 311 | imp.subscription_list 312 | .bind_model(Some(&imp.subscription_list_model), |obj| { 313 | let sub = obj.downcast_ref::().unwrap(); 314 | 315 | Self::build_subscription_row(&sub).upcast() 316 | }); 317 | 318 | let this = self.clone(); 319 | imp.subscription_list.connect_row_selected(move |_, _row| { 320 | this.selected_subscription_changed(this.selected_subscription().as_ref()); 321 | }); 322 | 323 | let this = self.clone(); 324 | self.error_boundary().spawn(async move { 325 | glib::timeout_future_seconds(1).await; 326 | let list = this.notifier().list_subscriptions().await?; 327 | for sub in list { 328 | this.imp() 329 | .subscription_list_model 330 | .append(&Subscription::new(sub)); 331 | } 332 | Ok(()) 333 | }); 334 | } 335 | fn update_banner(&self, sub: Option<&Subscription>) { 336 | let imp = self.imp(); 337 | if let Some(sub) = sub { 338 | match sub.nice_status() { 339 | Status::Degraded | Status::Down => imp.banner.set_revealed(true), 340 | Status::Up => imp.banner.set_revealed(false), 341 | } 342 | } else { 343 | imp.banner.set_revealed(false); 344 | } 345 | } 346 | fn selected_subscription_changed(&self, sub: Option<&Subscription>) { 347 | let imp = self.imp(); 348 | self.update_banner(sub); 349 | let this = self.clone(); 350 | let set_sensitive = move |b| { 351 | let imp = this.imp(); 352 | imp.subscription_menu_btn.set_sensitive(b); 353 | imp.code_btn.set_sensitive(b); 354 | imp.send_btn.set_sensitive(b); 355 | imp.entry.set_sensitive(b); 356 | }; 357 | if let Some((sub, id)) = imp.banner_binding.take() { 358 | sub.disconnect(id); 359 | } 360 | if let Some(sub) = sub { 361 | set_sensitive(true); 362 | imp.navigation_split_view.set_show_content(true); 363 | imp.message_list 364 | .bind_model(Some(&sub.imp().messages), move |obj| { 365 | let b = obj.downcast_ref::().unwrap(); 366 | let msg = b.borrow::(); 367 | 368 | MessageRow::new(msg.clone()).upcast() 369 | }); 370 | 371 | let this = self.clone(); 372 | imp.banner_binding.set(Some(( 373 | sub.clone(), 374 | sub.connect_status_notify(move |sub| { 375 | this.update_banner(Some(sub)); 376 | }), 377 | ))); 378 | 379 | let this = self.clone(); 380 | glib::idle_add_local_once(move || { 381 | this.flag_read(); 382 | }); 383 | } else { 384 | set_sensitive(false); 385 | imp.message_list 386 | .bind_model(gio::ListModel::NONE, |_| adw::Bin::new().into()); 387 | } 388 | } 389 | fn flag_read(&self) { 390 | let vadj = self.imp().message_scroll.vadjustment(); 391 | // There is nothing to scroll, so the user viewed all the messages 392 | if vadj.page_size() == vadj.upper() 393 | || ((vadj.page_size() + vadj.value() - vadj.upper()).abs() <= 1.0) 394 | { 395 | self.selected_subscription().map(|sub| { 396 | self.error_boundary() 397 | .spawn(async move { sub.flag_all_as_read().await }); 398 | }); 399 | } 400 | } 401 | fn build_chip(text: &str) -> gtk::Label { 402 | let chip = gtk::Label::new(Some(text)); 403 | chip.add_css_class("chip"); 404 | chip.add_css_class("chip--small"); 405 | chip.set_margin_top(4); 406 | chip.set_margin_bottom(4); 407 | chip.set_margin_start(4); 408 | chip.set_margin_end(4); 409 | chip.set_halign(gtk::Align::Center); 410 | chip.set_valign(gtk::Align::Center); 411 | chip 412 | } 413 | 414 | fn build_subscription_row(sub: &Subscription) -> impl IsA { 415 | let b = gtk::Box::builder().spacing(4).build(); 416 | 417 | let label = gtk::Label::builder() 418 | .xalign(0.0) 419 | .wrap_mode(gtk::pango::WrapMode::WordChar) 420 | .wrap(true) 421 | .hexpand(true) 422 | .build(); 423 | 424 | sub.bind_property("display-name", &label, "label") 425 | .sync_create() 426 | .build(); 427 | 428 | let counter_chip = Self::build_chip("●"); 429 | counter_chip.add_css_class("chip--info"); 430 | counter_chip.add_css_class("circular"); 431 | counter_chip.set_visible(false); 432 | let counter_chip_clone = counter_chip.clone(); 433 | sub.connect_unread_count_notify(move |sub| { 434 | let c = sub.unread_count(); 435 | counter_chip_clone.set_visible(c > 0); 436 | }); 437 | 438 | let status_chip = Self::build_chip("Degraded"); 439 | let status_chip_clone = status_chip.clone(); 440 | 441 | sub.connect_status_notify(move |sub| match sub.nice_status() { 442 | Status::Degraded | Status::Down => { 443 | status_chip_clone.add_css_class("chip--degraded"); 444 | status_chip_clone.set_visible(true); 445 | } 446 | _ => { 447 | status_chip_clone.set_visible(false); 448 | } 449 | }); 450 | 451 | b.append(&counter_chip); 452 | b.append(&label); 453 | b.append(&status_chip); 454 | 455 | b 456 | } 457 | 458 | fn save_window_size(&self) -> Result<(), glib::BoolError> { 459 | let imp = self.imp(); 460 | 461 | let (width, height) = self.default_size(); 462 | 463 | imp.settings.set_int("window-width", width)?; 464 | imp.settings.set_int("window-height", height)?; 465 | 466 | imp.settings 467 | .set_boolean("is-maximized", self.is_maximized())?; 468 | 469 | Ok(()) 470 | } 471 | fn bind_flag_read(&self) { 472 | let imp = self.imp(); 473 | 474 | let this = self.clone(); 475 | imp.message_scroll.connect_edge_reached(move |_, pos_type| { 476 | if pos_type == gtk::PositionType::Bottom { 477 | this.flag_read(); 478 | } 479 | }); 480 | let this = self.clone(); 481 | self.connect_is_active_notify(move |_| { 482 | if this.is_active() { 483 | this.flag_read(); 484 | } 485 | }); 486 | } 487 | 488 | fn load_window_size(&self) { 489 | let imp = self.imp(); 490 | 491 | let width = imp.settings.int("window-width"); 492 | let height = imp.settings.int("window-height"); 493 | let is_maximized = imp.settings.boolean("is-maximized"); 494 | 495 | self.set_default_size(width, height); 496 | 497 | if is_maximized { 498 | self.maximize(); 499 | } 500 | } 501 | } 502 | --------------------------------------------------------------------------------