init notify

This commit is contained in:
ranfdev
2023-10-08 15:57:09 +02:00
parent c3de2224a8
commit 52ea57057e
40 changed files with 13845 additions and 552 deletions

3165
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -7,10 +7,26 @@ edition = "2021"
[profile.release]
lto = true
[workspace]
members = [
"ntfy-daemon"
]
[dependencies]
ntfy-daemon = { path = "./ntfy-daemon" }
gettext-rs = { version = "0.7", features = ["gettext-system"] }
gtk = { version = "0.6", package = "gtk4", features = ["v4_8"] }
gtk = { version = "0.7", package = "gtk4", features = ["v4_12", "blueprint"] }
once_cell = "1.14"
tracing = "0.1.37"
tracing-subscriber = "0.3"
adw = { version = "0.4", package = "libadwaita", features = ["v1_4"] }
adw = { version = "0.5", package = "libadwaita", features = ["v1_4"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
capnp = "0.17.2"
capnp-rpc = "0.17.0"
anyhow = "1.0.71"
chrono = "0.4.26"
rand = "0.8.5"
ureq = "2.7.1"
futures = "0.3.0"
ashpd = "0.6.0"

View File

@ -1,76 +1,8 @@
# GTK + Libadwaita + Blueprint + Rust + Meson + Flatpak = <3
# Notify
A boilerplate template to get started with GTK, Libadwaita, Blueprint, Rust, Meson, Flatpak made for GNOME.
https://ntfy.sh client application to receive everyday's notifications.
![Main window](data/resources/screenshots/screenshot1.png "Main window")
## Architecture
## What does it contains?
The code is split between the GUI and the underlying ntfy-daemon.
- A simple window with a headerbar
- Bunch of useful files that you SHOULD ship with your application on Linux:
- Metainfo: describe your application for the different application stores out there;
- Desktop: the application launcher;
- Icons: This repo contains three icons, a normal, a nightly & monochromatic icon (symbolic) per the GNOME HIG, exported using [App Icon Preview](https://flathub.org/apps/details/org.gnome.design.AppIconPreview).
- Flatpak Manifest for nightly builds
- Dual installation support
- Uses Meson for building the application
- Bundles the UI files & the CSS using gresources
- A pre-commit hook to run rustfmt on your code
- Tests to validate your Metainfo, Schemas & Desktop files
- Gsettings to store the window state, more settings could be added
- Gitlab CI to produce flatpak nightlies
- i18n support
## How to init a project ?
The template ships a simple python script to init a project easily. It asks you a few questions and replaces & renames all the necessary files.
The script requires having `git` installed on your system.
You can run it with,
```shell
python3 create-project.py
```
```shell
➜ python3 create-project.py
Welcome to GTK Rust Template
Name: Contrast
Project Name: contrast
Application ID (e.g. org.domain.MyAwesomeApp, see: https://developer.gnome.org/ChooseApplicationID/): org.gnome.design.Contrast
Author: Bilal Elmoussaoui
Email: bil.elmoussaoui@gmail.com
```
A new directory named `contrast` containing the generated project
## Building the project
Make sure you have `flatpak` and `flatpak-builder` installed. Then run the commands below. Replace `<application_id>` with the value you entered during project creation. Please note that these commands are just for demonstration purposes. Normally this would be handled by your IDE, such as GNOME Builder or VS Code with the Flatpak extension.
```
flatpak install --user org.gnome.Sdk//master org.freedesktop.Sdk.Extension.rust-stable org.gnome.Platform//master org.freedesktop.Sdk.Extension.llvm16
flatpak-builder --user flatpak_app build-aux/<application_id>.Devel.json
```
## Running the project
Once the project is build, run the command below. Replace Replace `<application_id>` and `<project_name>` with the values you entered during project creation. Please note that these commands are just for demonstration purposes. Normally this would be handled by your IDE, such as GNOME Builder or VS Code with the Flatpak extension.
```
flatpak-builder --run flatpak_app build-aux/<application_id>.Devel.json <project_name>
```
## Community
Join the GNOME and gtk-rs community!
- [Matrix chat](https://matrix.to/#/#rust:gnome.org): chat with other developers using gtk-rs
- [Discourse forum](https://discourse.gnome.org/tag/rust): topics tagged with `rust` on the GNOME forum.
- [GNOME circle](https://circle.gnome.org/): take inspiration from applications and libraries already extending the GNOME ecosystem.
## Credits
- [Podcasts](https://gitlab.gnome.org/World/podcasts)
- [Shortwave](https://gitlab.gnome.org/World/Shortwave)
- [gtk-rust-template without libadwaita and blueprint](https://gitlab.gnome.org/World/Rust/gtk-rust-template)

View File

@ -10,12 +10,15 @@
"command": "notify",
"finish-args": [
"--share=ipc",
"--share=network",
"--socket=fallback-x11",
"--socket=wayland",
"--socket=session-bus",
"--device=dri",
"--env=RUST_LOG=notify=debug",
"--env=RUST_LOG=notify=debug,ntfy_daemon=debug",
"--env=G_MESSAGES_DEBUG=none",
"--env=RUST_BACKTRACE=1"
"--env=RUST_BACKTRACE=1",
"--talk-name=org.freedesktop.Notifications"
],
"build-options": {
"append-path": "/usr/lib/sdk/rust-stable/bin:/usr/lib/sdk/llvm16/bin",
@ -35,6 +38,17 @@
]
},
"modules": [
{
"name": "capnp",
"buildsystem": "cmake",
"sources": [
{
"type": "archive",
"url": "https://capnproto.org/capnproto-c++-0.10.4.tar.gz",
"sha256": "981e7ef6dbe3ac745907e55a78870fbb491c5d23abd4ebc04e20ec235af4458c"
}
]
},
{
"name": "notify",
"buildsystem": "meson",

View File

@ -1,59 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16px" height="16px" viewBox="0 0 16 16" version="1.1">
<defs>
<filter id="alpha" filterUnits="objectBoundingBox" x="0%" y="0%" width="100%" height="100%">
<feColorMatrix type="matrix" in="SourceGraphic" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
</filter>
<mask id="mask0">
<g filter="url(#alpha)">
<rect x="0" y="0" width="16" height="16" style="fill:rgb(0%,0%,0%);fill-opacity:0.1;stroke:none;"/>
</g>
</mask>
<clipPath id="clip1">
<rect x="0" y="0" width="192" height="152"/>
</clipPath>
<g id="surface10818" clip-path="url(#clip1)">
<path style="fill:none;stroke-width:0.99;stroke-linecap:butt;stroke-linejoin:miter;stroke:rgb(0%,0%,0%);stroke-opacity:1;stroke-dasharray:0.99,0.99;stroke-miterlimit:4;" d="M 123.503906 236 C 123.503906 268.863281 96.863281 295.503906 64 295.503906 C 31.136719 295.503906 4.496094 268.863281 4.496094 236 C 4.496094 203.136719 31.136719 176.496094 64 176.496094 C 96.863281 176.496094 123.503906 203.136719 123.503906 236 Z M 123.503906 236 " transform="matrix(1,0,0,1,8,-156)"/>
</g>
<mask id="mask1">
<g filter="url(#alpha)">
<rect x="0" y="0" width="16" height="16" style="fill:rgb(0%,0%,0%);fill-opacity:0.1;stroke:none;"/>
</g>
</mask>
<clipPath id="clip2">
<rect x="0" y="0" width="192" height="152"/>
</clipPath>
<g id="surface10821" clip-path="url(#clip2)">
<path style="fill:none;stroke-width:0.99;stroke-linecap:butt;stroke-linejoin:miter;stroke:rgb(0%,0%,0%);stroke-opacity:1;stroke-dasharray:0.99,0.99;stroke-miterlimit:4;" d="M 29.195312 180.496094 L 98.804688 180.496094 C 103.609375 180.496094 107.503906 184.046875 107.503906 188.425781 L 107.503906 283.574219 C 107.503906 287.953125 103.609375 291.503906 98.804688 291.503906 L 29.195312 291.503906 C 24.390625 291.503906 20.496094 287.953125 20.496094 283.574219 L 20.496094 188.425781 C 20.496094 184.046875 24.390625 180.496094 29.195312 180.496094 Z M 29.195312 180.496094 " transform="matrix(1,0,0,1,8,-156)"/>
</g>
<mask id="mask2">
<g filter="url(#alpha)">
<rect x="0" y="0" width="16" height="16" style="fill:rgb(0%,0%,0%);fill-opacity:0.1;stroke:none;"/>
</g>
</mask>
<clipPath id="clip3">
<rect x="0" y="0" width="192" height="152"/>
</clipPath>
<g id="surface10824" clip-path="url(#clip3)">
<path style="fill:none;stroke-width:0.99;stroke-linecap:butt;stroke-linejoin:miter;stroke:rgb(0%,0%,0%);stroke-opacity:1;stroke-dasharray:0.99,0.99;stroke-miterlimit:4;" d="M 20.417969 184.496094 L 107.582031 184.496094 C 111.957031 184.496094 115.503906 188.042969 115.503906 192.417969 L 115.503906 279.582031 C 115.503906 283.957031 111.957031 287.503906 107.582031 287.503906 L 20.417969 287.503906 C 16.042969 287.503906 12.496094 283.957031 12.496094 279.582031 L 12.496094 192.417969 C 12.496094 188.042969 16.042969 184.496094 20.417969 184.496094 Z M 20.417969 184.496094 " transform="matrix(1,0,0,1,8,-156)"/>
</g>
<mask id="mask3">
<g filter="url(#alpha)">
<rect x="0" y="0" width="16" height="16" style="fill:rgb(0%,0%,0%);fill-opacity:0.1;stroke:none;"/>
</g>
</mask>
<clipPath id="clip4">
<rect x="0" y="0" width="192" height="152"/>
</clipPath>
<g id="surface10827" clip-path="url(#clip4)">
<path style="fill:none;stroke-width:0.99;stroke-linecap:butt;stroke-linejoin:miter;stroke:rgb(0%,0%,0%);stroke-opacity:1;stroke-dasharray:0.99,0.99;stroke-miterlimit:4;" d="M 16.425781 200.496094 L 111.574219 200.496094 C 115.953125 200.496094 119.503906 204.390625 119.503906 209.195312 L 119.503906 278.804688 C 119.503906 283.609375 115.953125 287.503906 111.574219 287.503906 L 16.425781 287.503906 C 12.046875 287.503906 8.496094 283.609375 8.496094 278.804688 L 8.496094 209.195312 C 8.496094 204.390625 12.046875 200.496094 16.425781 200.496094 Z M 16.425781 200.496094 " transform="matrix(1,0,0,1,8,-156)"/>
</g>
</defs>
<g id="surface10764">
<rect x="0" y="0" width="16" height="16" style="fill:rgb(94.117647%,94.117647%,94.117647%);fill-opacity:1;stroke:none;"/>
<use xlink:href="#surface10818" transform="matrix(1,0,0,1,-168,-16)" mask="url(#mask0)"/>
<use xlink:href="#surface10821" transform="matrix(1,0,0,1,-168,-16)" mask="url(#mask1)"/>
<use xlink:href="#surface10824" transform="matrix(1,0,0,1,-168,-16)" mask="url(#mask2)"/>
<use xlink:href="#surface10827" transform="matrix(1,0,0,1,-168,-16)" mask="url(#mask3)"/>
</g>
<svg height="16px" viewBox="0 0 16 16" width="16px" xmlns="http://www.w3.org/2000/svg">
<g fill="none" stroke="#241f31">
<path d="m 2 2.324219 h 12 v 9 h -7.242188 l -2.6875 2.351562 v -2.351562 h -2.070312 z m 0 0" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"/>
<g stroke-linecap="square">
<path d="m 5.289062 8.355469 l 1.613282 -1.699219 l -1.613282 -1.78125"/>
<path d="m 8.613281 8.5625 h 2.128906"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 537 B

View File

@ -1,147 +1,75 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="128px" height="128px" viewBox="0 0 128 128" version="1.1">
<defs>
<filter id="alpha" filterUnits="objectBoundingBox" x="0%" y="0%" width="100%" height="100%">
<feColorMatrix type="matrix" in="SourceGraphic" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
</filter>
<mask id="mask0">
<g filter="url(#alpha)">
<rect x="0" y="0" width="128" height="128" style="fill:rgb(0%,0%,0%);fill-opacity:0.1;stroke:none;"/>
</g>
</mask>
<clipPath id="clip1">
<rect x="0" y="0" width="192" height="152"/>
</clipPath>
<g id="surface10726" clip-path="url(#clip1)">
<path style="fill:none;stroke-width:0.99;stroke-linecap:butt;stroke-linejoin:miter;stroke:rgb(0%,0%,0%);stroke-opacity:1;stroke-dasharray:0.99,0.99;stroke-miterlimit:4;" d="M 123.503906 236 C 123.503906 268.863281 96.863281 295.503906 64 295.503906 C 31.136719 295.503906 4.496094 268.863281 4.496094 236 C 4.496094 203.136719 31.136719 176.496094 64 176.496094 C 96.863281 176.496094 123.503906 203.136719 123.503906 236 Z M 123.503906 236 " transform="matrix(1,0,0,1,8,-156)"/>
</g>
<mask id="mask1">
<g filter="url(#alpha)">
<rect x="0" y="0" width="128" height="128" style="fill:rgb(0%,0%,0%);fill-opacity:0.1;stroke:none;"/>
</g>
</mask>
<clipPath id="clip2">
<rect x="0" y="0" width="192" height="152"/>
</clipPath>
<g id="surface10729" clip-path="url(#clip2)">
<path style="fill:none;stroke-width:0.99;stroke-linecap:butt;stroke-linejoin:miter;stroke:rgb(0%,0%,0%);stroke-opacity:1;stroke-dasharray:0.99,0.99;stroke-miterlimit:4;" d="M 29.195312 180.496094 L 98.804688 180.496094 C 103.609375 180.496094 107.503906 184.046875 107.503906 188.425781 L 107.503906 283.574219 C 107.503906 287.953125 103.609375 291.503906 98.804688 291.503906 L 29.195312 291.503906 C 24.390625 291.503906 20.496094 287.953125 20.496094 283.574219 L 20.496094 188.425781 C 20.496094 184.046875 24.390625 180.496094 29.195312 180.496094 Z M 29.195312 180.496094 " transform="matrix(1,0,0,1,8,-156)"/>
</g>
<mask id="mask2">
<g filter="url(#alpha)">
<rect x="0" y="0" width="128" height="128" style="fill:rgb(0%,0%,0%);fill-opacity:0.1;stroke:none;"/>
</g>
</mask>
<clipPath id="clip3">
<rect x="0" y="0" width="192" height="152"/>
</clipPath>
<g id="surface10732" clip-path="url(#clip3)">
<path style="fill:none;stroke-width:0.99;stroke-linecap:butt;stroke-linejoin:miter;stroke:rgb(0%,0%,0%);stroke-opacity:1;stroke-dasharray:0.99,0.99;stroke-miterlimit:4;" d="M 20.417969 184.496094 L 107.582031 184.496094 C 111.957031 184.496094 115.503906 188.042969 115.503906 192.417969 L 115.503906 279.582031 C 115.503906 283.957031 111.957031 287.503906 107.582031 287.503906 L 20.417969 287.503906 C 16.042969 287.503906 12.496094 283.957031 12.496094 279.582031 L 12.496094 192.417969 C 12.496094 188.042969 16.042969 184.496094 20.417969 184.496094 Z M 20.417969 184.496094 " transform="matrix(1,0,0,1,8,-156)"/>
</g>
<mask id="mask3">
<g filter="url(#alpha)">
<rect x="0" y="0" width="128" height="128" style="fill:rgb(0%,0%,0%);fill-opacity:0.1;stroke:none;"/>
</g>
</mask>
<clipPath id="clip4">
<rect x="0" y="0" width="192" height="152"/>
</clipPath>
<g id="surface10735" clip-path="url(#clip4)">
<path style="fill:none;stroke-width:0.99;stroke-linecap:butt;stroke-linejoin:miter;stroke:rgb(0%,0%,0%);stroke-opacity:1;stroke-dasharray:0.99,0.99;stroke-miterlimit:4;" d="M 16.425781 200.496094 L 111.574219 200.496094 C 115.953125 200.496094 119.503906 204.390625 119.503906 209.195312 L 119.503906 278.804688 C 119.503906 283.609375 115.953125 287.503906 111.574219 287.503906 L 16.425781 287.503906 C 12.046875 287.503906 8.496094 283.609375 8.496094 278.804688 L 8.496094 209.195312 C 8.496094 204.390625 12.046875 200.496094 16.425781 200.496094 Z M 16.425781 200.496094 " transform="matrix(1,0,0,1,8,-156)"/>
</g>
<mask id="mask5">
<g filter="url(#alpha)">
<rect x="0" y="0" width="128" height="128" style="fill:rgb(0%,0%,0%);fill-opacity:0.1;stroke:none;"/>
</g>
</mask>
<clipPath id="clip7">
<rect x="0" y="0" width="192" height="152"/>
</clipPath>
<g id="surface10726" clip-path="url(#clip7)">
<path style="fill:none;stroke-width:0.99;stroke-linecap:butt;stroke-linejoin:miter;stroke:rgb(0%,0%,0%);stroke-opacity:1;stroke-dasharray:0.99,0.99;stroke-miterlimit:4;" d="M 123.503906 236 C 123.503906 268.863281 96.863281 295.503906 64 295.503906 C 31.136719 295.503906 4.496094 268.863281 4.496094 236 C 4.496094 203.136719 31.136719 176.496094 64 176.496094 C 96.863281 176.496094 123.503906 203.136719 123.503906 236 Z M 123.503906 236 " transform="matrix(1,0,0,1,8,-156)"/>
</g>
<mask id="mask6">
<g filter="url(#alpha)">
<rect x="0" y="0" width="128" height="128" style="fill:rgb(0%,0%,0%);fill-opacity:0.1;stroke:none;"/>
</g>
</mask>
<clipPath id="clip8">
<rect x="0" y="0" width="192" height="152"/>
</clipPath>
<g id="surface10729" clip-path="url(#clip8)">
<path style="fill:none;stroke-width:0.99;stroke-linecap:butt;stroke-linejoin:miter;stroke:rgb(0%,0%,0%);stroke-opacity:1;stroke-dasharray:0.99,0.99;stroke-miterlimit:4;" d="M 29.195312 180.496094 L 98.804688 180.496094 C 103.609375 180.496094 107.503906 184.046875 107.503906 188.425781 L 107.503906 283.574219 C 107.503906 287.953125 103.609375 291.503906 98.804688 291.503906 L 29.195312 291.503906 C 24.390625 291.503906 20.496094 287.953125 20.496094 283.574219 L 20.496094 188.425781 C 20.496094 184.046875 24.390625 180.496094 29.195312 180.496094 Z M 29.195312 180.496094 " transform="matrix(1,0,0,1,8,-156)"/>
</g>
<mask id="mask7">
<g filter="url(#alpha)">
<rect x="0" y="0" width="128" height="128" style="fill:rgb(0%,0%,0%);fill-opacity:0.1;stroke:none;"/>
</g>
</mask>
<clipPath id="clip9">
<rect x="0" y="0" width="192" height="152"/>
</clipPath>
<g id="surface10732" clip-path="url(#clip9)">
<path style="fill:none;stroke-width:0.99;stroke-linecap:butt;stroke-linejoin:miter;stroke:rgb(0%,0%,0%);stroke-opacity:1;stroke-dasharray:0.99,0.99;stroke-miterlimit:4;" d="M 20.417969 184.496094 L 107.582031 184.496094 C 111.957031 184.496094 115.503906 188.042969 115.503906 192.417969 L 115.503906 279.582031 C 115.503906 283.957031 111.957031 287.503906 107.582031 287.503906 L 20.417969 287.503906 C 16.042969 287.503906 12.496094 283.957031 12.496094 279.582031 L 12.496094 192.417969 C 12.496094 188.042969 16.042969 184.496094 20.417969 184.496094 Z M 20.417969 184.496094 " transform="matrix(1,0,0,1,8,-156)"/>
</g>
<mask id="mask8">
<g filter="url(#alpha)">
<rect x="0" y="0" width="128" height="128" style="fill:rgb(0%,0%,0%);fill-opacity:0.1;stroke:none;"/>
</g>
</mask>
<clipPath id="clip10">
<rect x="0" y="0" width="192" height="152"/>
</clipPath>
<g id="surface10735" clip-path="url(#clip10)">
<path style="fill:none;stroke-width:0.99;stroke-linecap:butt;stroke-linejoin:miter;stroke:rgb(0%,0%,0%);stroke-opacity:1;stroke-dasharray:0.99,0.99;stroke-miterlimit:4;" d="M 16.425781 200.496094 L 111.574219 200.496094 C 115.953125 200.496094 119.503906 204.390625 119.503906 209.195312 L 119.503906 278.804688 C 119.503906 283.609375 115.953125 287.503906 111.574219 287.503906 L 16.425781 287.503906 C 12.046875 287.503906 8.496094 283.609375 8.496094 278.804688 L 8.496094 209.195312 C 8.496094 204.390625 12.046875 200.496094 16.425781 200.496094 Z M 16.425781 200.496094 " transform="matrix(1,0,0,1,8,-156)"/>
</g>
<clipPath id="clip6">
<rect x="0" y="0" width="128" height="128"/>
</clipPath>
<g id="surface10750" clip-path="url(#clip6)">
<rect x="0" y="0" width="128" height="128" style="fill:rgb(94.117647%,94.117647%,94.117647%);fill-opacity:1;stroke:none;"/>
<use xlink:href="#surface10726" transform="matrix(1,0,0,1,-8,-16)" mask="url(#mask5)"/>
<use xlink:href="#surface10729" transform="matrix(1,0,0,1,-8,-16)" mask="url(#mask6)"/>
<use xlink:href="#surface10732" transform="matrix(1,0,0,1,-8,-16)" mask="url(#mask7)"/>
<use xlink:href="#surface10735" transform="matrix(1,0,0,1,-8,-16)" mask="url(#mask8)"/>
<path style="fill:none;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke:rgb(38.431373%,62.7451%,91.764706%);stroke-opacity:1;stroke-miterlimit:4;" d="M 0 289 L 128 289 " transform="matrix(1,0,0,1,0,-172)"/>
</g>
<clipPath id="clip5">
<rect x="0" y="0" width="128" height="128"/>
</clipPath>
<g id="surface10753" clip-path="url(#clip5)" filter="url(#alpha)">
<use xlink:href="#surface10750"/>
</g>
<mask id="mask4">
<use xlink:href="#surface10753"/>
</mask>
<mask id="mask9">
<g filter="url(#alpha)">
<rect x="0" y="0" width="128" height="128" style="fill:rgb(0%,0%,0%);fill-opacity:0.8;stroke:none;"/>
</g>
</mask>
<linearGradient id="linear0" gradientUnits="userSpaceOnUse" x1="300" y1="235" x2="428" y2="235" gradientTransform="matrix(0.000000000000000023,0.37,-0.98462,0.00000000000000006,295.38501,-30.360001)">
<stop offset="0" style="stop-color:rgb(97.647059%,94.117647%,41.960785%);stop-opacity:1;"/>
<stop offset="1" style="stop-color:rgb(96.078432%,76.078433%,6.666667%);stop-opacity:1;"/>
</linearGradient>
<clipPath id="clip12">
<rect x="0" y="0" width="128" height="128"/>
</clipPath>
<g id="surface10747" clip-path="url(#clip12)">
<path style=" stroke:none;fill-rule:nonzero;fill:url(#linear0);" d="M 128 80.640625 L 128 128 L 0 128 L 0 80.640625 Z M 128 80.640625 "/>
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(0%,0%,0%);fill-opacity:1;" d="M 13.308594 80.640625 L 60.664062 128 L 81.878906 128 L 34.519531 80.640625 Z M 55.730469 80.640625 L 103.09375 128 L 124.308594 128 L 76.945312 80.640625 Z M 98.160156 80.640625 L 128 110.480469 L 128 89.269531 L 119.371094 80.640625 Z M 0 88.546875 L 0 109.761719 L 18.238281 128 L 39.453125 128 Z M 0 88.546875 "/>
</g>
<clipPath id="clip11">
<rect x="0" y="0" width="128" height="128"/>
</clipPath>
<g id="surface10752" clip-path="url(#clip11)">
<use xlink:href="#surface10747" mask="url(#mask9)"/>
</g>
</defs>
<g id="surface10672">
<rect x="0" y="0" width="128" height="128" style="fill:rgb(94.117647%,94.117647%,94.117647%);fill-opacity:1;stroke:none;"/>
<use xlink:href="#surface10726" transform="matrix(1,0,0,1,-8,-16)" mask="url(#mask0)"/>
<use xlink:href="#surface10729" transform="matrix(1,0,0,1,-8,-16)" mask="url(#mask1)"/>
<use xlink:href="#surface10732" transform="matrix(1,0,0,1,-8,-16)" mask="url(#mask2)"/>
<use xlink:href="#surface10735" transform="matrix(1,0,0,1,-8,-16)" mask="url(#mask3)"/>
<path style="fill:none;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke:rgb(38.431373%,62.7451%,91.764706%);stroke-opacity:1;stroke-miterlimit:4;" d="M 0 289 L 128 289 " transform="matrix(1,0,0,1,0,-172)"/>
<use xlink:href="#surface10752" mask="url(#mask4)"/>
</g>
<svg height="128px" viewBox="0 0 128 128" width="128px" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<filter id="a" height="100%" width="100%" x="0%" y="0%">
<feColorMatrix color-interpolation-filters="sRGB" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
</filter>
<linearGradient id="b" gradientUnits="userSpaceOnUse" x1="8.0000455" x2="120.0000455" y1="116.000468" y2="116.000468">
<stop offset="0" stop-color="#5a8067"/>
<stop offset="0.0357143" stop-color="#7bb28e"/>
<stop offset="0.0713653" stop-color="#6d9e7e"/>
<stop offset="0.928571" stop-color="#6d9e7e"/>
<stop offset="0.964286" stop-color="#7bb28e"/>
<stop offset="1" stop-color="#5a8067"/>
</linearGradient>
<linearGradient id="c" gradientUnits="userSpaceOnUse" x1="22.383152" x2="49.932594" y1="120.234802" y2="120.234802">
<stop offset="0" stop-color="#598066"/>
<stop offset="1" stop-color="#7db28f"/>
</linearGradient>
<clipPath id="d">
<rect height="128" width="128"/>
</clipPath>
<clipPath id="e">
<rect height="128" width="128"/>
</clipPath>
<mask id="f">
<g filter="url(#a)">
<g clip-path="url(#e)" filter="url(#a)">
<g clip-path="url(#d)">
<path d="m 16 40 h 96 c 4.417969 0 8 3.582031 8 8 v 60 c 0 4.417969 -3.582031 8 -8 8 h -96 c -4.417969 0 -8 -3.582031 -8 -8 v -60 c 0 -4.417969 3.582031 -8 8 -8 z m 0 0" fill="url(#b)"/>
<path d="m 16 28 h 96 c 4.417969 0 8 3.582031 8 8 v 68 c 0 4.417969 -3.582031 8 -8 8 h -96 c -4.417969 0 -8 -3.582031 -8 -8 v -68 c 0 -4.417969 3.582031 -8 8 -8 z m 0 0" fill="#98dcae"/>
<path d="m 17.230469 31.371094 h 93.539062 c 3.042969 0 5.511719 2.429687 5.511719 5.429687 v 66.398438 c 0 3 -2.46875 5.429687 -5.511719 5.429687 h -93.539062 c -3.042969 0 -5.511719 -2.429687 -5.511719 -5.429687 v -66.398438 c 0 -3 2.46875 -5.429687 5.511719 -5.429687 z m 0 0" fill="#2f483e"/>
<path d="m 22.382812 112 h 27.550782 l -27.550782 10.421875 z m 0 0" fill="#98dcaf"/>
<path d="m 22.382812 122.421875 l 27.550782 -10.421875 v 4 l -27.550782 10.417969 z m 0 0" fill="url(#c)"/>
<path d="m 95.785156 81.988281 v 5.703125 h -29.707031 v -5.703125 z m 0 0" fill="#91d0a7"/>
<path d="m 53.941406 70 l -4.035156 4.03125 l -17.691406 -17.691406 l 4.03125 -4.03125 z m 0 0" fill="#89c69d"/>
<path d="m 36.246094 87.691406 l -4.03125 -4.03125 l 17.691406 -17.691406 l 4.035156 4.03125 z m 0 0" fill="#89c49d"/>
<path d="m 25.921875 105.242188 l 32.722656 0.085937 l -32.722656 12.246094 z m 0 0" fill="#2f483e"/>
</g>
</g>
</g>
</mask>
<mask id="g">
<g filter="url(#a)">
<rect fill-opacity="0.8" height="184.32" width="184.32" x="-28.16" y="-28.16"/>
</g>
</mask>
<linearGradient id="h" gradientTransform="matrix(0 0.37 -0.98462 0 295.38501 -30.360001)" gradientUnits="userSpaceOnUse" x1="300" x2="428" y1="235" y2="235">
<stop offset="0" stop-color="#f9f06b"/>
<stop offset="1" stop-color="#f5c211"/>
</linearGradient>
<clipPath id="i">
<rect height="128" width="128"/>
</clipPath>
<clipPath id="j">
<rect height="128" width="128"/>
</clipPath>
<path d="m 16 40 h 96 c 4.417969 0 8 3.582031 8 8 v 60 c 0 4.417969 -3.582031 8 -8 8 h -96 c -4.417969 0 -8 -3.582031 -8 -8 v -60 c 0 -4.417969 3.582031 -8 8 -8 z m 0 0" fill="url(#b)"/>
<path d="m 16 28 h 96 c 4.417969 0 8 3.582031 8 8 v 68 c 0 4.417969 -3.582031 8 -8 8 h -96 c -4.417969 0 -8 -3.582031 -8 -8 v -68 c 0 -4.417969 3.582031 -8 8 -8 z m 0 0" fill="#98dcae"/>
<path d="m 17.230469 31.371094 h 93.539062 c 3.042969 0 5.511719 2.429687 5.511719 5.429687 v 66.398438 c 0 3 -2.46875 5.429687 -5.511719 5.429687 h -93.539062 c -3.042969 0 -5.511719 -2.429687 -5.511719 -5.429687 v -66.398438 c 0 -3 2.46875 -5.429687 5.511719 -5.429687 z m 0 0" fill="#2f483e"/>
<path d="m 22.382812 112 h 27.550782 l -27.550782 10.421875 z m 0 0" fill="#98dcaf"/>
<path d="m 22.382812 122.421875 l 27.550782 -10.421875 v 4 l -27.550782 10.417969 z m 0 0" fill="url(#c)"/>
<path d="m 95.785156 81.988281 v 5.703125 h -29.707031 v -5.703125 z m 0 0" fill="#91d0a7"/>
<path d="m 53.941406 70 l -4.035156 4.03125 l -17.691406 -17.691406 l 4.03125 -4.03125 z m 0 0" fill="#89c69d"/>
<path d="m 36.246094 87.691406 l -4.03125 -4.03125 l 17.691406 -17.691406 l 4.035156 4.03125 z m 0 0" fill="#89c49d"/>
<path d="m 25.921875 105.242188 l 32.722656 0.085937 l -32.722656 12.246094 z m 0 0" fill="#2f483e"/>
<g mask="url(#f)">
<g clip-path="url(#j)">
<g mask="url(#g)">
<g clip-path="url(#i)">
<path d="m 128 80.640625 v 47.359375 h -128 v -47.359375 z m 0 0" fill="url(#h)"/>
<path d="m 13.308594 80.640625 l 47.355468 47.359375 h 21.214844 l -47.359375 -47.359375 z m 42.421875 0 l 47.363281 47.359375 h 21.214844 l -47.363282 -47.359375 z m 42.429687 0 l 29.839844 29.839844 v -21.210938 l -8.628906 -8.628906 z m -98.160156 7.90625 v 21.214844 l 18.238281 18.238281 h 21.214844 z m 0 0"/>
</g>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 180 KiB

View File

@ -1,60 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="128px" height="128px" viewBox="0 0 128 128" version="1.1">
<defs>
<filter id="alpha" filterUnits="objectBoundingBox" x="0%" y="0%" width="100%" height="100%">
<feColorMatrix type="matrix" in="SourceGraphic" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
</filter>
<mask id="mask0">
<g filter="url(#alpha)">
<rect x="0" y="0" width="128" height="128" style="fill:rgb(0%,0%,0%);fill-opacity:0.1;stroke:none;"/>
</g>
</mask>
<clipPath id="clip1">
<rect x="0" y="0" width="192" height="152"/>
</clipPath>
<g id="surface10632" clip-path="url(#clip1)">
<path style="fill:none;stroke-width:0.99;stroke-linecap:butt;stroke-linejoin:miter;stroke:rgb(0%,0%,0%);stroke-opacity:1;stroke-dasharray:0.99,0.99;stroke-miterlimit:4;" d="M 123.503906 236 C 123.503906 268.863281 96.863281 295.503906 64 295.503906 C 31.136719 295.503906 4.496094 268.863281 4.496094 236 C 4.496094 203.136719 31.136719 176.496094 64 176.496094 C 96.863281 176.496094 123.503906 203.136719 123.503906 236 Z M 123.503906 236 " transform="matrix(1,0,0,1,8,-156)"/>
</g>
<mask id="mask1">
<g filter="url(#alpha)">
<rect x="0" y="0" width="128" height="128" style="fill:rgb(0%,0%,0%);fill-opacity:0.1;stroke:none;"/>
</g>
</mask>
<clipPath id="clip2">
<rect x="0" y="0" width="192" height="152"/>
</clipPath>
<g id="surface10635" clip-path="url(#clip2)">
<path style="fill:none;stroke-width:0.99;stroke-linecap:butt;stroke-linejoin:miter;stroke:rgb(0%,0%,0%);stroke-opacity:1;stroke-dasharray:0.99,0.99;stroke-miterlimit:4;" d="M 29.195312 180.496094 L 98.804688 180.496094 C 103.609375 180.496094 107.503906 184.046875 107.503906 188.425781 L 107.503906 283.574219 C 107.503906 287.953125 103.609375 291.503906 98.804688 291.503906 L 29.195312 291.503906 C 24.390625 291.503906 20.496094 287.953125 20.496094 283.574219 L 20.496094 188.425781 C 20.496094 184.046875 24.390625 180.496094 29.195312 180.496094 Z M 29.195312 180.496094 " transform="matrix(1,0,0,1,8,-156)"/>
</g>
<mask id="mask2">
<g filter="url(#alpha)">
<rect x="0" y="0" width="128" height="128" style="fill:rgb(0%,0%,0%);fill-opacity:0.1;stroke:none;"/>
</g>
</mask>
<clipPath id="clip3">
<rect x="0" y="0" width="192" height="152"/>
</clipPath>
<g id="surface10638" clip-path="url(#clip3)">
<path style="fill:none;stroke-width:0.99;stroke-linecap:butt;stroke-linejoin:miter;stroke:rgb(0%,0%,0%);stroke-opacity:1;stroke-dasharray:0.99,0.99;stroke-miterlimit:4;" d="M 20.417969 184.496094 L 107.582031 184.496094 C 111.957031 184.496094 115.503906 188.042969 115.503906 192.417969 L 115.503906 279.582031 C 115.503906 283.957031 111.957031 287.503906 107.582031 287.503906 L 20.417969 287.503906 C 16.042969 287.503906 12.496094 283.957031 12.496094 279.582031 L 12.496094 192.417969 C 12.496094 188.042969 16.042969 184.496094 20.417969 184.496094 Z M 20.417969 184.496094 " transform="matrix(1,0,0,1,8,-156)"/>
</g>
<mask id="mask3">
<g filter="url(#alpha)">
<rect x="0" y="0" width="128" height="128" style="fill:rgb(0%,0%,0%);fill-opacity:0.1;stroke:none;"/>
</g>
</mask>
<clipPath id="clip4">
<rect x="0" y="0" width="192" height="152"/>
</clipPath>
<g id="surface10641" clip-path="url(#clip4)">
<path style="fill:none;stroke-width:0.99;stroke-linecap:butt;stroke-linejoin:miter;stroke:rgb(0%,0%,0%);stroke-opacity:1;stroke-dasharray:0.99,0.99;stroke-miterlimit:4;" d="M 16.425781 200.496094 L 111.574219 200.496094 C 115.953125 200.496094 119.503906 204.390625 119.503906 209.195312 L 119.503906 278.804688 C 119.503906 283.609375 115.953125 287.503906 111.574219 287.503906 L 16.425781 287.503906 C 12.046875 287.503906 8.496094 283.609375 8.496094 278.804688 L 8.496094 209.195312 C 8.496094 204.390625 12.046875 200.496094 16.425781 200.496094 Z M 16.425781 200.496094 " transform="matrix(1,0,0,1,8,-156)"/>
</g>
</defs>
<g id="surface10578">
<rect x="0" y="0" width="128" height="128" style="fill:rgb(94.117647%,94.117647%,94.117647%);fill-opacity:1;stroke:none;"/>
<use xlink:href="#surface10632" transform="matrix(1,0,0,1,-8,-16)" mask="url(#mask0)"/>
<use xlink:href="#surface10635" transform="matrix(1,0,0,1,-8,-16)" mask="url(#mask1)"/>
<use xlink:href="#surface10638" transform="matrix(1,0,0,1,-8,-16)" mask="url(#mask2)"/>
<use xlink:href="#surface10641" transform="matrix(1,0,0,1,-8,-16)" mask="url(#mask3)"/>
<path style="fill:none;stroke-width:2;stroke-linecap:butt;stroke-linejoin:miter;stroke:rgb(38.431373%,62.7451%,91.764706%);stroke-opacity:1;stroke-miterlimit:4;" d="M 0 289 L 128 289 " transform="matrix(1,0,0,1,0,-172)"/>
</g>
<svg height="128px" viewBox="0 0 128 128" width="128px" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<linearGradient id="a" gradientUnits="userSpaceOnUse" x1="8.0000455" x2="120.0000455" y1="116.000468" y2="116.000468">
<stop offset="0" stop-color="#5a8067"/>
<stop offset="0.0357143" stop-color="#7bb28e"/>
<stop offset="0.0713653" stop-color="#6d9e7e"/>
<stop offset="0.928571" stop-color="#6d9e7e"/>
<stop offset="0.964286" stop-color="#7bb28e"/>
<stop offset="1" stop-color="#5a8067"/>
</linearGradient>
<linearGradient id="b" gradientUnits="userSpaceOnUse" x1="22.383152" x2="49.932594" y1="120.234802" y2="120.234802">
<stop offset="0" stop-color="#598066"/>
<stop offset="1" stop-color="#7db28f"/>
</linearGradient>
<path d="m 16 40 h 96 c 4.417969 0 8 3.582031 8 8 v 60 c 0 4.417969 -3.582031 8 -8 8 h -96 c -4.417969 0 -8 -3.582031 -8 -8 v -60 c 0 -4.417969 3.582031 -8 8 -8 z m 0 0" fill="url(#a)"/>
<path d="m 16 28 h 96 c 4.417969 0 8 3.582031 8 8 v 68 c 0 4.417969 -3.582031 8 -8 8 h -96 c -4.417969 0 -8 -3.582031 -8 -8 v -68 c 0 -4.417969 3.582031 -8 8 -8 z m 0 0" fill="#98dcae"/>
<path d="m 17.230469 31.371094 h 93.539062 c 3.042969 0 5.511719 2.429687 5.511719 5.429687 v 66.398438 c 0 3 -2.46875 5.429687 -5.511719 5.429687 h -93.539062 c -3.042969 0 -5.511719 -2.429687 -5.511719 -5.429687 v -66.398438 c 0 -3 2.46875 -5.429687 5.511719 -5.429687 z m 0 0" fill="#2f483e"/>
<path d="m 22.382812 112 h 27.550782 l -27.550782 10.421875 z m 0 0" fill="#98dcaf"/>
<path d="m 22.382812 122.421875 l 27.550782 -10.421875 v 4 l -27.550782 10.417969 z m 0 0" fill="url(#b)"/>
<path d="m 95.785156 81.988281 v 5.703125 h -29.707031 v -5.703125 z m 0 0" fill="#91d0a7"/>
<path d="m 53.941406 70 l -4.035156 4.03125 l -17.691406 -17.691406 l 4.03125 -4.03125 z m 0 0" fill="#89c69d"/>
<path d="m 36.246094 87.691406 l -4.03125 -4.03125 l 17.691406 -17.691406 l 4.035156 4.03125 z m 0 0" fill="#89c49d"/>
<path d="m 25.921875 105.242188 l 32.722656 0.085937 l -32.722656 12.246094 z m 0 0" fill="#2f483e"/>
</svg>

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" height="16px" viewBox="0 0 16 16" width="16px"><path d="m 3.984375 1 c -1.652344 0 -2.984375 1.332031 -2.984375 2.984375 v 8.03125 c 0 1.652344 1.332031 2.984375 2.984375 2.984375 h 8.03125 c 1.652344 0 2.984375 -1.332031 2.984375 -2.984375 v -8.03125 c 0 -1.652344 -1.332031 -2.984375 -2.984375 -2.984375 z m 0.515625 2 c 0.828125 0 1.5 0.671875 1.5 1.5 s -0.671875 1.5 -1.5 1.5 s -1.5 -0.671875 -1.5 -1.5 s 0.671875 -1.5 1.5 -1.5 z m 3.398438 3.429688 c 0.832031 0 1.5 0.671874 1.5 1.5 c 0 0.828124 -0.667969 1.5 -1.5 1.5 c -0.828126 0 -1.5 -0.671876 -1.5 -1.5 c 0 -0.828126 0.671874 -1.5 1.5 -1.5 z m 3.601562 3.570312 c 0.828125 0 1.5 0.671875 1.5 1.5 s -0.671875 1.5 -1.5 1.5 s -1.5 -0.671875 -1.5 -1.5 s 0.671875 -1.5 1.5 -1.5 z m 0 0"/></svg>

After

Width:  |  Height:  |  Size: 829 B

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" height="16px" viewBox="0 0 16 16" width="16px"><path d="m 3.984375 1 c -1.652344 0 -2.984375 1.332031 -2.984375 2.984375 v 8.03125 c 0 1.652344 1.332031 2.984375 2.984375 2.984375 h 8.03125 c 1.652344 0 2.984375 -1.332031 2.984375 -2.984375 v -8.03125 c 0 -1.652344 -1.332031 -2.984375 -2.984375 -2.984375 z m 0.515625 2 c 0.828125 0 1.5 0.671875 1.5 1.5 s -0.671875 1.5 -1.5 1.5 s -1.5 -0.671875 -1.5 -1.5 s 0.671875 -1.5 1.5 -1.5 z m 3.398438 3.429688 c 0.832031 0 1.5 0.671874 1.5 1.5 c 0 0.828124 -0.667969 1.5 -1.5 1.5 c -0.828126 0 -1.5 -0.671876 -1.5 -1.5 c 0 -0.828126 0.671874 -1.5 1.5 -1.5 z m 3.601562 3.570312 c 0.828125 0 1.5 0.671875 1.5 1.5 s -0.671875 1.5 -1.5 1.5 s -1.5 -0.671875 -1.5 -1.5 s 0.671875 -1.5 1.5 -1.5 z m 0 0"/></svg>

After

Width:  |  Height:  |  Size: 829 B

View File

@ -4,6 +4,7 @@ blueprints = custom_target('blueprints',
input: files(
'ui/window.blp',
'ui/shortcuts.blp',
'ui/subscription_info_dialog.blp',
),
output: '.',
command: [find_program('blueprint-compiler'), 'batch-compile', '@OUTPUT@', '@CURRENT_SOURCE_DIR@', '@INPUT@'],

View File

@ -4,6 +4,11 @@
<!-- see https://gtk-rs.org/gtk4-rs/git/docs/gtk4/struct.Application.html#automatic-resources -->
<file compressed="true" preprocess="xml-stripblanks" alias="gtk/help-overlay.ui">ui/shortcuts.ui</file>
<file compressed="true" preprocess="xml-stripblanks">ui/window.ui</file>
<file compressed="true" preprocess="xml-stripblanks">ui/subscription_info_dialog.ui</file>
<file compressed="true">style.css</file>
</gresource>
<gresource prefix="/com/ranfdev/Notify/icons/16x16/status/">
<file preprocess="xml-stripblanks" alias="dice3-symbolic.svg">../icons/dice3-symbolic.svg</file>
</gresource>
</gresources>

View File

@ -2,3 +2,30 @@
font-size: 36px;
font-weight: bold;
}
.chip {
min-height: 16px;
min-width: 16px;
padding: 3px 5px;
color: @theme_fg_color;
border-radius: 8px;
}
.chip--warning {
background: alpha(@yellow_2, 0.2);
}
.chip--info {
color: @blue_5;
background: alpha(@blue_2, 0.2);
}
.chip--degraded {
color: @orange_5;
background: alpha(@orange_2, 0.2);
}
.chip--danger {
background: alpha(@red_2, 0.2);
}
.chip--small {
font-size: 0.8rem;
}

View File

@ -0,0 +1,50 @@
using Gtk 4.0;
using Adw 1;
template $SubscriptionInfoDialog : Adw.Window {
modal: true;
title: "Subscription Info";
width-request: 240;
Adw.ToolbarView {
[top]
Adw.HeaderBar {}
Adw.Clamp {
Gtk.Box {
orientation: vertical;
spacing: 8;
margin-top: 8;
margin-bottom: 8;
margin-start: 8;
margin-end: 8;
Gtk.ListBox {
Adw.EntryRow display_name_entry {
title: "Display Name";
}
Adw.ActionRow {
title: "Topic";
subtitle-selectable: true;
subtitle: bind (template.subscription as <$TopicSubscription>).topic as <string>;
styles [
"property"
]
}
Adw.ActionRow {
title: "Server";
subtitle: bind (template.subscription as <$TopicSubscription>).server as <string>;
subtitle-selectable: true;
styles [
"property"
]
}
Adw.SwitchRow muted_switch_row {
title: "Muted";
}
styles [
"boxed-list"
]
}
}
}
}
}

View File

@ -20,60 +20,126 @@ menu primary_menu {
}
}
template $ExampleApplicationWindow : Adw.ApplicationWindow {
width-request: 240;
height-request: 480;
Adw.Breakpoint {
condition ("max-width: 400sp")
setters {
split_view.collapsed: true;
show_hello_btn.visible: true;
menu subscription_menu {
section {
item {
label: _("_Subscription Info");
action: "win.show-subscription-info";
}
}
Adw.NavigationSplitView split_view {
sidebar: Adw.NavigationPage {
title: "Sidebar";
child: Adw.ToolbarView {
[top]
Adw.HeaderBar {
[end]
MenuButton appmenu_button {
icon-name: "open-menu-symbolic";
menu-model: primary_menu;
primary: true;
tooltip-text: _("Main Menu");
}
}
section {
item {
label: _("_Clear all notifications");
action: "win.clear-notifications";
}
content: Adw.StatusPage {
title: "Sidebar";
child: Gtk.Button show_hello_btn {
label: "Show Hello Page";
visible: false;
styles [
"pill"
]
action-name: "navigation.push";
action-target: "'hello'";
};
};
};
};
content: Adw.NavigationPage {
title: "Hello Page";
tag: "hello";
child: Adw.ToolbarView {
[top]
Adw.HeaderBar headerbar {
// this will have a title label inherited from the Adw.NavigationPage
}
content: Adw.StatusPage {
title: "Hello World!";
description: "Hello to everyone!";
};
};
};
item {
label: _("_Unsubscribe");
action: "win.unsubscribe";
}
}
}
template $NotifyWindow : Adw.ApplicationWindow {
width-request: 240;
height-request: 360;
Adw.Breakpoint {
condition ("max-width: 640sp")
setters {
navigation_split_view.collapsed: true;
}
}
Adw.ToastOverlay toast_overlay {
Adw.NavigationSplitView navigation_split_view {
sidebar: Adw.NavigationPage {
title: "Topics";
child: Adw.ToolbarView {
[top]
Adw.HeaderBar {
[start]
Button {
icon-name: "list-add-symbolic";
clicked => $show_add_topic() swapped;
}
[end]
MenuButton appmenu_button {
icon-name: "open-menu-symbolic";
menu-model: primary_menu;
primary: true;
tooltip-text: _("Main Menu");
}
}
Gtk.Stack stack {
Adw.StatusPage welcome_view {
title: "Notify";
description: "Subscribe to one topic and start listening for notifications";
child: Gtk.Button {
label: "Subscribe To Topic";
clicked => $show_add_topic() swapped;
halign: center;
styles [
"suggested-action",
"pill"
]
};
}
ScrolledWindow list_view {
propagate-natural-height: true;
ListBox subscription_list {
styles [
"navigation-sidebar"
]
}
}
}
};
};
content: Adw.NavigationPage {
title: "Notifications";
Adw.ToolbarView subscription_view {
[top]
Adw.HeaderBar headerbar {
[end]
MenuButton subscription_menu_btn {
icon-name: "view-more-symbolic";
menu-model: subscription_menu;
tooltip-text: _("Subscription Menu");
}
}
[top]
Adw.Banner banner {
title: "Reconnecting...";
}
content: ScrolledWindow message_scroll {
propagate-natural-height: true;
vexpand: true;
Adw.Clamp {
ListBox message_list {
selection-mode: none;
show-separators: true;
styles [
"background"
]
}
}
};
[bottom]
Adw.Bin {
margin-top: 8;
margin-bottom: 8;
margin-start: 8;
margin-end: 8;
Adw.Clamp {
Entry entry {
placeholder-text: "Message...";
}
}
}
}
};
}
}
}

1
ntfy-daemon/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/target

2312
ntfy-daemon/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

31
ntfy-daemon/Cargo.toml Normal file
View File

@ -0,0 +1,31 @@
[package]
name = "ntfy-daemon"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[build-dependencies]
capnpc = "0.17.2"
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
capnp = "0.17.2"
capnp-rpc = "0.17.0"
futures = "0.3.0"
tokio = { version = "1.0.0", features = ["net", "rt", "macros", "parking_lot"]}
tokio-util = { version = "0.7.4", features = ["compat", "io"] }
clap = { version = "4.3.11", features = ["derive"] }
anyhow = "1.0.71"
tokio-stream = { version = "0.1.14", features = ["io-util", "time"] }
rusqlite = "0.29.0"
rand = "0.8.5"
reqwest = { version = "0.11.18", features = ["stream", "rustls-tls"]}
url = "2.4.0"
ashpd = "0.6.0"
generational-arena = "0.2.9"
tracing = "0.1.37"
thiserror = "1.0.49"
regex = "1.9.6"

5
ntfy-daemon/README.md Normal file
View File

@ -0,0 +1,5 @@
# ntfy-daemon
Rust crate providing a capnp-rpc interface to multiple ntfy servers.
Connections to the same server are multiplexed over http2.
Messages are received and stored in a sqlite database for persistance.

6
ntfy-daemon/build.rs Normal file
View File

@ -0,0 +1,6 @@
fn main() {
capnpc::CompilerCommand::new()
.file("src/ntfy.capnp")
.run()
.unwrap();
}

File diff suppressed because it is too large Load Diff

24
ntfy-daemon/src/lib.rs Normal file
View File

@ -0,0 +1,24 @@
pub mod message_repo;
pub mod models;
pub mod ntfy_proxy;
pub mod retry;
pub mod system_client;
pub mod ntfy_capnp {
include!(concat!(env!("OUT_DIR"), "/src/ntfy_capnp.rs"));
}
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error("topic {0} must not be empty and must contain only alphanumeric characters and _ (underscore)")]
InvalidTopic(String),
#[error("duplicate message")]
DuplicateMessage,
#[error("can't parse the minimum set of required fields from the message {0}")]
InvalidMinMessage(String, #[source] serde_json::Error),
#[error("can't parse the complete message {0}")]
InvalidMessage(String, #[source] serde_json::Error),
#[error("database error")]
Db(#[from] rusqlite::Error),
#[error("subscription not found while {0}")]
SubscriptionNotFound(String),
}

View File

@ -0,0 +1,29 @@
CREATE TABLE IF NOT EXISTS server (
id INTEGER PRIMARY KEY,
endpoint TEXT NOT NULL UNIQUE,
timeout INTEGER
);
CREATE TABLE IF NOT EXISTS subscription (
topic TEXT,
display_name TEXT,
muted INTEGER NOT NULL DEFAULT 0,
server INTEGER REFERENCES server(id),
archived INTEGER NOT NULL DEFAULT 0,
reserved INTEGER NOT NULL DEFAULT 0,
read_until INTEGER NOT NULL DEFAULT 0,
symbolic_icon TEXT,
PRIMARY KEY (server, topic)
);
CREATE TABLE IF NOT EXISTS message (
server INTEGER,
data TEXT NOT NULL,
topic TEXT AS (data ->> '$.topic'), -- For the FOREIGN KEY constraint
FOREIGN KEY (server, topic) REFERENCES subscription(server, topic) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS message_by_time ON message (data ->> '$.time');
-- I can't put a JSON expression inside a UNIQUE constraint,
-- but I can do it on a UNIQUE INDEX
CREATE UNIQUE INDEX IF NOT EXISTS server_and_message_id ON message (server, data ->> '$.id');

View File

@ -0,0 +1,206 @@
use std::{cell::RefCell, rc::Rc};
use rusqlite::{params, Connection, Result};
use tracing::info;
use crate::models;
use crate::Error;
#[derive(Clone, Debug)]
pub struct Db {
conn: Rc<RefCell<Connection>>,
}
impl Db {
pub fn connect(path: &str) -> Result<Self> {
let mut this = Self {
conn: Rc::new(RefCell::new(Connection::open(path)?)),
};
{
this.conn.borrow().execute_batch(
"PRAGMA foreign_keys = ON;
PRAGMA journal_mode = wal;",
)?;
}
this.migrate()?;
Ok(this)
}
fn migrate(&mut self) -> Result<()> {
{
self.conn
.borrow()
.execute_batch(include_str!("./migrations/00.sql"))?
};
Ok(())
}
fn get_or_insert_server(&mut self, server: &str) -> Result<i64> {
let mut conn = self.conn.borrow_mut();
let tx = conn.transaction()?;
let mut res = tx.query_row(
"SELECT id
FROM server
WHERE endpoint = ?1",
params![server,],
|row| {
let id: i64 = row.get(0)?;
Ok(id)
},
);
if let Err(rusqlite::Error::QueryReturnedNoRows) = res {
tx.execute(
"INSERT INTO server (id, endpoint) VALUES (NULL, ?1)",
params![server,],
)?;
res = Ok(tx.last_insert_rowid());
}
tx.commit()?;
res
}
pub fn insert_message(&mut self, server: &str, json_data: &str) -> Result<(), Error> {
let server_id = self.get_or_insert_server(server)?;
let res = self.conn.borrow().execute(
"INSERT INTO message (server, data) VALUES (?1, ?2)",
params![server_id, json_data],
);
match res {
Err(rusqlite::Error::SqliteFailure(_, Some(text)))
if text.starts_with("UNIQUE constraint failed") =>
{
Err(Error::DuplicateMessage)
}
Err(e) => Err(Error::Db(e)),
Ok(_) => Ok(()),
}
}
pub fn list_messages(
&self,
server: &str,
topic: &str,
since: u64,
) -> Result<Vec<String>, rusqlite::Error> {
let conn = self.conn.borrow();
let mut stmt = conn.prepare(
"
SELECT data
FROM subscription sub
JOIN server s ON sub.server = s.id
JOIN message m ON m.server = sub.server AND m.topic = sub.topic
WHERE s.endpoint = ?1 AND m.topic = ?2 AND m.data ->> 'time' >= ?3
ORDER BY m.data ->> 'time'
",
)?;
let msgs: Result<Vec<String>, _> = stmt
.query_map(params![server, topic, since], |row| Ok(row.get(0)?))?
.collect();
Ok(msgs?)
}
pub fn insert_subscription(&mut self, sub: models::Subscription) -> Result<(), Error> {
let server_id = self.get_or_insert_server(&sub.server)?;
self.conn.borrow().execute(
"INSERT INTO subscription (server, topic, display_name, reserved, muted, archived) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![
server_id,
sub.topic,
sub.display_name,
sub.reserved,
sub.muted,
sub.archived
],
)?;
Ok(())
}
pub fn remove_subscription(&mut self, server: &str, topic: &str) -> Result<(), Error> {
let server_id = self.get_or_insert_server(server)?;
let res = self.conn.borrow().execute(
"DELETE FROM subscription
WHERE server = ?1 AND topic = ?2",
params![server_id, topic],
)?;
if res <= 0 {
return Err(Error::SubscriptionNotFound("removing subscription".into()));
}
Ok(())
}
pub fn list_subscriptions(&mut self) -> Result<Vec<models::Subscription>, Error> {
let conn = self.conn.borrow();
let mut stmt = conn.prepare(
"SELECT server.endpoint, sub.topic, sub.display_name, sub.reserved, sub.muted, sub.archived, sub.symbolic_icon, sub.read_until
FROM subscription sub
JOIN server ON server.id = sub.server
ORDER BY server.endpoint, sub.display_name, sub.topic
",
)?;
let rows = stmt.query_map(params![], |row| {
Ok(models::Subscription {
server: row.get(0)?,
topic: row.get(1)?,
display_name: row.get(2)?,
reserved: row.get(3)?,
muted: row.get(4)?,
archived: row.get(5)?,
symbolic_icon: row.get(6)?,
read_until: row.get(7)?,
})
})?;
let subs: Result<Vec<_>, rusqlite::Error> = rows.collect();
Ok(subs?)
}
pub fn update_subscription(&mut self, sub: models::Subscription) -> Result<(), Error> {
let server_id = self.get_or_insert_server(&sub.server)?;
let res = self.conn.borrow().execute(
"UPDATE subscription
SET display_name = ?1, reserved = ?2, muted = ?3, archived = ?4, read_until = ?5
WHERE server = ?6 AND topic = ?7",
params![
sub.display_name,
sub.reserved,
sub.muted,
sub.archived,
sub.read_until,
server_id,
sub.topic,
],
)?;
if res <= 0 {
return Err(Error::SubscriptionNotFound("updating subscription".into()));
}
info!(info = ?sub, "stored subscription info");
Ok(())
}
pub fn update_read_until(
&mut self,
server: &str,
topic: &str,
value: u64,
) -> Result<(), Error> {
let server_id = self.get_or_insert_server(server).unwrap();
let conn = self.conn.borrow();
let res = conn.execute(
"UPDATE subscription
SET read_until = ?3
WHERE topic = ?2 AND server = ?1
",
params![server_id, topic, value],
)?;
if res <= 0 {
return Err(Error::SubscriptionNotFound("updating read_until".into()));
}
Ok(())
}
pub fn delete_messages(&mut self, server: &str, topic: &str) -> Result<(), Error> {
let server_id = self.get_or_insert_server(server).unwrap();
let conn = self.conn.borrow();
let res = conn.execute(
"DELETE FROM message
WHERE topic = ?2 AND server = ?1
",
params![server_id, topic],
)?;
if res <= 0 {
return Err(Error::SubscriptionNotFound("deleting messages".into()));
}
Ok(())
}
}

263
ntfy-daemon/src/models.rs Normal file
View File

@ -0,0 +1,263 @@
use std::collections::HashMap;
use std::sync::OnceLock;
use regex::Regex;
use serde::{Deserialize, Serialize};
use crate::Error;
static EMOJI_MAP: OnceLock<HashMap<String, String>> = OnceLock::new();
fn emoji_map() -> &'static HashMap<String, String> {
EMOJI_MAP.get_or_init(move || {
serde_json::from_str(include_str!("../data/mailer_emoji_map.json")).unwrap()
})
}
fn validate_topic(topic: &str) -> Result<&str, Error> {
let re = Regex::new(r"^([A-z]|[0-9]|_)+$").unwrap();
if re.is_match(topic) {
Ok(topic)
} else {
Err(Error::InvalidTopic(topic.to_string()))
}
}
#[derive(Default, Clone, Debug, Serialize, Deserialize)]
pub struct Message {
pub topic: String,
pub message: Option<String>,
pub time: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(default)]
#[serde(skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub priority: Option<i8>,
#[serde(skip_serializing_if = "Vec::is_empty")]
#[serde(default)]
pub attach: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub filename: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub delay: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub call: Option<String>,
#[serde(default)]
#[serde(skip_serializing_if = "Vec::is_empty")]
pub actions: Vec<Action>,
}
impl Message {
fn extend_with_emojis(&self, text: &mut String) {
// Add emojis
for t in &self.tags {
if let Some(emoji) = emoji_map().get(t) {
text.push_str(emoji);
}
}
}
pub fn display_title(&self) -> Option<String> {
self.title.as_ref().map(|title| {
let mut title_text = String::new();
self.extend_with_emojis(&mut title_text);
if !title_text.is_empty() {
title_text.push(' ');
}
title_text.push_str(title);
title_text
})
}
pub fn display_message(&self) -> Option<String> {
self.message.as_ref().map(|message| {
let mut out = String::new();
if self.title.is_none() {
self.extend_with_emojis(&mut out);
}
if !out.is_empty() {
out.push(' ');
}
out.push_str(message);
out
})
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct MinMessage {
pub id: String,
pub topic: String,
pub time: u64,
}
#[derive(Clone, Debug)]
pub struct Subscription {
pub server: String,
pub topic: String,
pub display_name: String,
pub muted: bool,
pub archived: bool,
pub reserved: bool,
pub symbolic_icon: Option<String>,
pub read_until: u64,
}
impl Subscription {
pub fn build_url(server: &str, topic: &str, since: u64) -> anyhow::Result<url::Url> {
let mut url = url::Url::parse(server)?;
url.path_segments_mut()
.map_err(|_| anyhow::anyhow!("url can't be base"))?
.push(&topic)
.push("json");
url.query_pairs_mut()
.append_pair("since", &since.to_string());
Ok(url)
}
pub fn validate(self) -> anyhow::Result<Self> {
validate_topic(&self.topic)?;
Self::build_url(&self.server, &self.topic, 0)?;
Ok(self)
}
pub fn builder(server: String, topic: String) -> SubscriptionBuilder {
SubscriptionBuilder::new(server, topic)
}
}
#[derive(Clone)]
pub struct SubscriptionBuilder {
server: String,
topic: String,
muted: bool,
archived: bool,
reserved: bool,
symbolic_icon: Option<String>,
display_name: String,
}
impl SubscriptionBuilder {
pub fn new(server: String, topic: String) -> Self {
Self {
server,
topic,
muted: false,
archived: false,
reserved: false,
symbolic_icon: None,
display_name: String::new(),
}
}
pub fn muted(mut self, muted: bool) -> Self {
self.muted = muted;
self
}
pub fn archived(mut self, archived: bool) -> Self {
self.archived = archived;
self
}
pub fn reserved(mut self, reserved: bool) -> Self {
self.reserved = reserved;
self
}
pub fn symbolic_icon(mut self, symbolic_icon: Option<String>) -> Self {
self.symbolic_icon = symbolic_icon;
self
}
pub fn display_name(mut self, display_name: String) -> Self {
self.display_name = display_name;
self
}
pub fn build(self) -> anyhow::Result<Subscription> {
let res = Subscription {
server: self.server,
topic: self.topic,
muted: self.muted,
archived: self.archived,
reserved: self.reserved,
symbolic_icon: self.symbolic_icon,
display_name: self.display_name,
read_until: 0,
};
res.validate()
}
}
fn default_method() -> String {
"POST".to_string()
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "action")]
pub enum Action {
#[serde(rename = "view")]
View {
label: String,
url: String,
#[serde(default)]
clear: bool,
},
#[serde(rename = "http")]
Http {
label: String,
url: String,
#[serde(default = "default_method")]
method: String,
#[serde(default)]
headers: HashMap<String, String>,
#[serde(default)]
body: String,
#[serde(default)]
clear: bool,
},
#[serde(rename = "broadcast")]
Broadcast {
label: String,
intent: Option<String>,
#[serde(default)]
extras: HashMap<String, String>,
#[serde(default)]
clear: bool,
},
}
#[derive(Debug, PartialEq, Copy, Clone, Default)]
pub enum Status {
#[default]
Down,
Degraded,
Up,
}
impl From<u8> for Status {
fn from(item: u8) -> Self {
match item {
0 => Status::Down,
1 => Status::Degraded,
2 => Status::Up,
_ => Status::Down,
}
}
}
impl From<Status> for u8 {
fn from(item: Status) -> Self {
match item {
Status::Down => 0,
Status::Degraded => 1,
Status::Up => 2,
}
}
}

View File

@ -0,0 +1,47 @@
@0x9663f4dd604afa35;
enum Status {
down @0;
degraded @1;
up @2;
}
interface WatchHandle {}
interface OutputChannel {
sendMessage @0 (message: Text);
sendStatus @1 (status: Status);
done @2 ();
}
interface NtfyProxy {
getServer @0 () -> (server: Text);
watch @1 (topic: Text, watcher: OutputChannel, since: UInt64) -> (handle: WatchHandle);
publish @2 (message: Text);
}
struct SubscriptionInfo {
server @0 :Text;
topic @1 :Text;
displayName @2 :Text;
muted @3 :Bool;
readUntil @4 :UInt64;
}
interface Subscription {
watch @0 (watcher: OutputChannel, since: UInt64) -> (handle: WatchHandle);
publish @1 (message: Text);
getInfo @2 () -> SubscriptionInfo;
updateInfo @3 (value: SubscriptionInfo);
updateReadUntil @4 (value: UInt64);
clearNotifications @5 ();
}
interface SystemNotifier {
subscribe @0 (server: Text, topic: Text) -> (subscription: Subscription);
unsubscribe @1 (server: Text, topic: Text);
listSubscriptions @2 () -> (list: List(Subscription));
}

View File

@ -0,0 +1,355 @@
use std::cell::RefCell;
use std::collections::HashMap;
use std::ops::ControlFlow;
use std::rc::{Rc, Weak};
use std::time::Duration;
use ashpd::desktop::network_monitor::NetworkMonitor;
use capnp::capability::Promise;
use capnp_rpc::pry;
use futures::future::RemoteHandle;
use futures::prelude::*;
use reqwest::header::HeaderValue;
use serde::{Deserialize, Serialize};
use tokio::io::AsyncBufReadExt;
use tokio::sync::mpsc;
use tokio_stream::wrappers::LinesStream;
use tracing::{debug, error, info, instrument, Instrument};
use crate::{
models,
ntfy_capnp::{ntfy_proxy, output_channel, watch_handle, Status},
Error,
};
const CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
const TIMEOUT: std::time::Duration = std::time::Duration::from_secs(240); // 4 minutes
static GLOBAL_MONITOR: tokio::sync::OnceCell<NetworkMonitor> = tokio::sync::OnceCell::const_new();
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "event")]
pub enum Event {
#[serde(rename = "open")]
Open {
id: String,
time: usize,
expires: Option<usize>,
topic: String,
},
#[serde(rename = "message")]
Message {
id: String,
expires: Option<usize>,
#[serde(flatten)]
message: models::Message,
},
#[serde(rename = "keepalive")]
KeepAlive {
id: String,
time: usize,
expires: Option<usize>,
topic: String,
},
}
fn build_client() -> anyhow::Result<reqwest::Client> {
Ok(reqwest::Client::builder()
.connect_timeout(CONNECT_TIMEOUT)
.pool_idle_timeout(TIMEOUT)
// rustls is used because HTTP 2 isn't discovered with native-tls.
// HTTP 2 is required to multiplex multiple requests over a single connection.
// You can check that the app is using a single connection to a server by doing
// ```
// ping ntfy.sh # to get the ip address
// netstat | grep $ip
// ```
.use_rustls_tls()
.build()?)
}
fn topic_request(endpoint: &str, topic: &str, since: u64) -> anyhow::Result<reqwest::Request> {
let url = models::Subscription::build_url(endpoint, topic, since)?;
let mut req = reqwest::Request::new(reqwest::Method::GET, url);
let headers = req.headers_mut();
headers.append(
"Content-Type",
HeaderValue::from_static("application/x-ndjson"),
);
headers.append("Transfer-Encoding", HeaderValue::from_static("chunked"));
Ok(req)
}
async fn response_lines(
res: impl tokio::io::AsyncBufRead,
) -> Result<impl futures::Stream<Item = Result<String, std::io::Error>>, reqwest::Error> {
let lines = LinesStream::new(res.lines());
Ok(lines)
}
pub enum BroadcasterEvent {
Stop,
Restart,
}
struct TopicListener {
endpoint: String,
topic: String,
status: Status,
output_channel: output_channel::Client,
since: u64,
client: reqwest::Client,
}
impl TopicListener {
fn new(
client: reqwest::Client,
endpoint: String,
topic: String,
since: u64,
output_channel: output_channel::Client,
) -> anyhow::Result<mpsc::Sender<ControlFlow<()>>> {
let (tx, mut rx) = mpsc::channel(8);
let mut this = Self {
endpoint,
topic,
status: Status::Down,
output_channel,
since,
client,
};
tokio::task::spawn_local(async move {
loop {
tokio::select! {
_ = this.run_supervised_loop().instrument(tracing::debug_span!("run_supervised_loop")) => {},
res = rx.recv() => match res {
Some(ControlFlow::Continue(_)) => {}
None | Some(ControlFlow::Break(_)) => {
break;
}
}
}
}
});
Ok(tx)
}
fn send_current_status(&mut self) -> impl Future<Output = anyhow::Result<()>> {
let mut req = self.output_channel.send_status_request();
req.get().set_status(self.status);
async move {
req.send().promise.await?;
Ok(())
}
}
#[instrument(skip_all)]
async fn recv_and_forward(&mut self) -> anyhow::Result<()> {
let req = topic_request(&self.endpoint, &self.topic, self.since)?;
let res = self.client.execute(req).await?;
let reader = tokio_util::io::StreamReader::new(
res.bytes_stream()
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string())),
);
let stream = response_lines(reader).await?;
tokio::pin!(stream);
self.status = Status::Up;
self.send_current_status().await.unwrap();
info!(topic = %&self.topic, "listening");
while let Some(msg) = stream.next().await {
let msg = msg?;
let min_msg = serde_json::from_str::<models::MinMessage>(&msg)
.map_err(|e| Error::InvalidMinMessage(msg.to_string(), e))?;
self.since = min_msg.time.max(self.since);
let event = serde_json::from_str(&msg)
.map_err(|e| Error::InvalidMessage(msg.to_string(), e))?;
match event {
Event::Message { .. } => {
debug!("message event");
let mut req = self.output_channel.send_message_request();
req.get().set_message(&msg);
req.send().promise.await?;
}
Event::KeepAlive { .. } => {
debug!("keepalive event");
}
Event::Open { .. } => {
debug!("open event");
}
}
}
Ok(())
}
async fn run_supervised_loop(&mut self) {
let retrier = || {
crate::retry::WaitExponentialRandom::builder()
.min(Duration::from_secs(1))
.max(Duration::from_secs(60 * 10))
.build()
};
let mut retry = retrier();
loop {
let start_time = std::time::Instant::now();
if let Err(e) = self.recv_and_forward().await {
let uptime = std::time::Instant::now().duration_since(start_time);
// Reset retry delay to minimum if uptime was decent enough
if uptime > Duration::from_secs(60 * 4) {
retry = retrier();
}
error!(error = ?e);
self.status = Status::Degraded;
self.send_current_status().await.unwrap();
info!(delay = ?retry.next_delay(), "restarting");
retry.wait().await;
} else {
break;
}
}
}
}
struct WatcherImpl {
topic: String,
all_topics: Weak<RefCell<HashMap<String, mpsc::Sender<ControlFlow<()>>>>>,
}
impl Drop for WatcherImpl {
fn drop(&mut self) {
if let Some(m) = self.all_topics.upgrade() {
debug!("Dropped WatcherImpl");
let mut m = m.borrow_mut();
let tx = m[&self.topic].clone();
tokio::task::spawn_local(async move {
tx.send(ControlFlow::Break(())).await.unwrap();
});
m.remove(&self.topic);
}
}
}
impl watch_handle::Server for WatcherImpl {}
// This is a proxy to the actual ntfy server. After a network issue, this will reconnect to the
// server and re-establish all watches.
pub struct NtfyProxyImpl {
endpoint: String,
watching: Rc<RefCell<HashMap<String, mpsc::Sender<ControlFlow<()>>>>>,
client: reqwest::Client,
_monitor_task: RemoteHandle<()>,
}
impl NtfyProxyImpl {
pub fn new(endpoint: String) -> NtfyProxyImpl {
let watching = Rc::new(RefCell::new(
HashMap::<String, mpsc::Sender<ControlFlow<()>>>::new(),
));
let watching_clone = Rc::downgrade(&watching);
let (f, handle) = async move {
let mut prev_available = false;
let monitor = GLOBAL_MONITOR
.get_or_init(|| async move { NetworkMonitor::new().await.unwrap() })
.await;
while let Ok(_) = monitor.receive_changed().await {
let available = monitor.is_available().await.unwrap();
if available && !prev_available {
info!("Refreshed");
if let Some(ws) = watching_clone.upgrade() {
for (_, w) in ws.borrow().iter() {
w.send(ControlFlow::Continue(())).await.unwrap();
}
}
}
prev_available = available;
}
}
.remote_handle();
tokio::task::spawn_local(f);
NtfyProxyImpl {
endpoint,
watching: watching.clone(),
client: build_client().unwrap(),
_monitor_task: handle,
}
}
fn _watch(
&mut self,
topic: String,
watcher: output_channel::Client,
since: u64,
) -> anyhow::Result<watch_handle::Client> {
if !{ self.watching.borrow().contains_key(&topic) } {
self.watching.borrow_mut().insert(
topic.clone(),
TopicListener::new(
self.client.clone(),
self.endpoint.clone(),
topic.clone(),
since,
watcher,
)?,
);
}
Ok(capnp_rpc::new_client(WatcherImpl {
topic,
all_topics: Rc::downgrade(&self.watching),
}))
}
fn _send_msg<'a>(
&'a mut self,
msg: &'a models::Message,
) -> impl Future<Output = Result<(), capnp::Error>> {
let client = reqwest::Client::new();
let json = serde_json::to_string(&msg).unwrap();
let req = client.post(&self.endpoint).body(json.clone());
async move {
info!(json = ?json, "sending message");
let res = req.send().await;
match res {
Err(e) => Err(capnp::Error::failed(e.to_string())),
Ok(res) => {
res.error_for_status()
.map_err(|e| capnp::Error::failed(e.to_string()))?;
Ok(())
}
}
}
}
}
impl ntfy_proxy::Server for NtfyProxyImpl {
fn publish(
&mut self,
params: ntfy_proxy::PublishParams,
_results: ntfy_proxy::PublishResults,
) -> capnp::capability::Promise<(), capnp::Error> {
let params = params.get();
let message = pry!(pry!(params).get_message());
let message: models::Message = serde_json::from_str(message).unwrap();
let res = self._send_msg(&message);
Promise::from_future(async move {
res.await.map_err(|e| capnp::Error::failed(e.to_string()))?;
Ok(())
})
}
fn watch(
&mut self,
params: ntfy_proxy::WatchParams,
mut results: ntfy_proxy::WatchResults,
) -> capnp::capability::Promise<(), capnp::Error> {
let topic = pry!(pry!(params.get()).get_topic());
let watcher = pry!(pry!(params.get()).get_watcher());
let since = pry!(params.get()).get_since();
let handle = pry!(self
._watch(topic.to_owned(), watcher, since.to_owned())
.map_err(|e| capnp::Error::failed(e.to_string())));
results.get().set_handle(handle);
Promise::ok(())
}
}

56
ntfy-daemon/src/retry.rs Normal file
View File

@ -0,0 +1,56 @@
use std::cmp;
use std::time::Duration;
use rand::prelude::*;
use tokio::time::sleep;
pub struct WaitExponentialRandom {
min: Duration,
max: Duration,
i: u64,
multiplier: u64,
}
pub struct WaitExponentialRandomBuilder {
inner: WaitExponentialRandom,
}
impl WaitExponentialRandomBuilder {
pub fn build(self) -> WaitExponentialRandom {
self.inner
}
pub fn min(mut self, duration: Duration) -> Self {
self.inner.min = duration;
self
}
pub fn max(mut self, duration: Duration) -> Self {
self.inner.max = duration;
self
}
pub fn multiplier(mut self, mul: u64) -> Self {
self.inner.multiplier = mul;
self
}
}
impl WaitExponentialRandom {
pub fn builder() -> WaitExponentialRandomBuilder {
WaitExponentialRandomBuilder {
inner: WaitExponentialRandom {
min: Duration::ZERO,
max: Duration::MAX,
i: 0,
multiplier: 1,
},
}
}
pub fn next_delay(&self) -> Duration {
let secs = (1 << self.i) * self.multiplier;
let secs = rand::thread_rng().gen_range(self.min.as_secs()..=secs);
let dur = Duration::from_secs(secs);
cmp::min(cmp::max(dur, self.min), self.max)
}
pub async fn wait(&mut self) {
sleep(self.next_delay()).await;
self.i += 1;
}
}

View File

@ -0,0 +1,504 @@
use std::cell::OnceCell;
use std::cell::{Cell, RefCell};
use std::rc::{Rc, Weak};
use std::time::Duration;
use std::{collections::HashMap, hash::Hash};
use ashpd::desktop::notification::{Notification, NotificationProxy};
use capnp::capability::Promise;
use capnp_rpc::{pry, rpc_twoparty_capnp, twoparty, RpcSystem};
use futures::future::join_all;
use futures::prelude::*;
use generational_arena::Arena;
use tokio::net::UnixListener;
use tracing::{error, info, warn};
use crate::models::Message;
use crate::Error;
use crate::{
message_repo::Db,
models::{self, MinMessage},
ntfy_capnp::ntfy_proxy,
ntfy_capnp::{output_channel, subscription, system_notifier, watch_handle, Status},
ntfy_proxy::NtfyProxyImpl,
};
const MESSAGE_THROTTLE: Duration = Duration::from_millis(150);
impl From<Error> for capnp::Error {
fn from(value: Error) -> Self {
capnp::Error::failed(format!("{:?}", value))
}
}
pub struct NotifyForwarder {
model: Rc<RefCell<models::Subscription>>,
db: Db,
watching: Weak<RefCell<Arena<output_channel::Client>>>,
status: Rc<Cell<Status>>,
}
impl NotifyForwarder {
pub fn new(
model: Rc<RefCell<models::Subscription>>,
db: Db,
watching: Weak<RefCell<Arena<output_channel::Client>>>,
status: Rc<Cell<Status>>,
) -> Self {
Self {
model,
db,
watching,
status,
}
}
}
impl output_channel::Server for NotifyForwarder {
// Stores the message, sends a system notification, forwards the message to watching clients
fn send_message(
&mut self,
params: output_channel::SendMessageParams,
_results: output_channel::SendMessageResults,
) -> capnp::capability::Promise<(), capnp::Error> {
let request = pry!(params.get());
let message = pry!(request.get_message());
// Store in database
let already_stored: bool = {
// If this fails parsing, the message is not valid at all.
// The server is probably misbehaving.
let min_message: MinMessage = pry!(serde_json::from_str(&message)
.map_err(|e| Error::InvalidMinMessage(message.to_string(), e)));
let model = self.model.borrow();
match self.db.insert_message(&model.server, message) {
Err(Error::DuplicateMessage) => {
warn!(min_message = ?min_message, "Received duplicate message");
true
}
Err(e) => {
error!(min_message = ?min_message, error = ?e, "Can't store the message");
false
}
_ => false,
}
};
if !already_stored {
// Show notification
// Our priority is to show notifications. If anything fails, panic.
if !{ self.model.borrow().muted } {
let msg: Message = pry!(serde_json::from_str(&message)
.map_err(|e| Error::InvalidMessage(message.to_string(), e)));
tokio::task::spawn_local(async move {
let proxy = match NotificationProxy::new().await {
Ok(p) => p,
Err(e) => {
panic!("Can't show notification: {:?}", e);
}
};
let title = msg.display_title();
let title = title.as_ref().map(|x| x.as_str()).unwrap_or(&msg.topic);
let n = Notification::new(&title).body(
msg.display_message()
.as_ref()
.map(|x| x.as_str())
.unwrap_or(""),
);
let notification_id = "com.ranfdev.Notify";
info!("Showing notification");
proxy.add_notification(notification_id, n).await.unwrap();
});
}
// Forward
if let Some(watching) = self.watching.upgrade() {
let watching = watching.borrow();
let futs = watching.iter().map(|(_id, w)| {
let mut req = w.send_message_request();
req.get().set_message(message);
async move {
if let Err(e) = req.send().promise.await {
error!(error = ?e, "Error forwarding");
}
}
});
tokio::task::spawn_local(join_all(futs));
}
}
Promise::from_future(async move {
// some backpressure
tokio::time::sleep(MESSAGE_THROTTLE).await;
Ok(())
})
}
fn send_status(
&mut self,
params: output_channel::SendStatusParams,
_: output_channel::SendStatusResults,
) -> capnp::capability::Promise<(), capnp::Error> {
let status = pry!(pry!(params.get()).get_status());
if let Some(watching) = self.watching.upgrade() {
for (_, w) in watching.borrow().iter() {
let mut req = w.send_status_request();
req.get().set_status(status);
tokio::task::spawn_local(async move {
req.send().promise.await.unwrap();
});
}
}
self.status.set(status);
Promise::ok(())
}
}
struct WatcherImpl {
id: generational_arena::Index,
watchers: Weak<RefCell<Arena<output_channel::Client>>>,
}
impl watch_handle::Server for WatcherImpl {}
impl Drop for WatcherImpl {
fn drop(&mut self) {
if let Some(w) = self.watchers.upgrade() {
w.borrow_mut().remove(self.id);
}
}
}
pub struct SubscriptionImpl {
model: Rc<RefCell<models::Subscription>>,
db: Db,
server: ntfy_proxy::Client,
server_watch_handle: OnceCell<watch_handle::Client>,
watchers: Rc<RefCell<Arena<output_channel::Client>>>,
status: Rc<Cell<Status>>,
}
impl SubscriptionImpl {
fn new(model: models::Subscription, server: ntfy_proxy::Client, db: Db) -> Self {
Self {
model: Rc::new(RefCell::new(model)),
server,
db,
watchers: Default::default(),
server_watch_handle: Default::default(),
status: Rc::new(Cell::new(Status::Down)),
}
}
fn output_channel(&self) -> NotifyForwarder {
NotifyForwarder::new(
self.model.clone(),
self.db.clone(),
Rc::downgrade(&self.watchers),
self.status.clone(),
)
}
}
impl subscription::Server for SubscriptionImpl {
fn watch(
&mut self,
params: subscription::WatchParams,
mut results: subscription::WatchResults,
) -> capnp::capability::Promise<(), capnp::Error> {
let watcher = pry!(pry!(params.get()).get_watcher());
let since = pry!(params.get()).get_since();
// Send old messages
let msgs = {
let model = self.model.borrow();
pry!(self
.db
.list_messages(&model.server, &model.topic, since)
.map_err(Error::Db))
};
let futs = msgs.into_iter().map(move |msg| {
let mut req = watcher.send_message_request();
req.get().set_message(&msg);
req.send().promise
});
let watcher = pry!(pry!(params.get()).get_watcher());
let mut req = watcher.send_status_request();
req.get().set_status(self.status.get());
let id = { self.watchers.borrow_mut().insert(watcher) };
results.get().set_handle(capnp_rpc::new_client(WatcherImpl {
id,
watchers: Rc::downgrade(&self.watchers),
}));
Promise::from_future(async move {
futures::future::try_join_all(futs).await?;
req.send().promise.await?;
Ok(())
})
}
fn publish(
&mut self,
params: subscription::PublishParams,
_results: subscription::PublishResults,
) -> capnp::capability::Promise<(), capnp::Error> {
let msg = pry!(pry!(params.get()).get_message());
let mut req = self.server.publish_request();
req.get().set_message(msg);
Promise::from_future(async move {
req.send().promise.await?;
Ok(())
})
}
fn get_info(
&mut self,
_: subscription::GetInfoParams,
mut results: subscription::GetInfoResults,
) -> capnp::capability::Promise<(), capnp::Error> {
let mut res = results.get();
let model = self.model.borrow();
res.set_server(&model.server);
res.set_display_name(&model.display_name);
res.set_topic(&model.topic);
res.set_muted(model.muted);
res.set_read_until(model.read_until);
Promise::ok(())
}
fn update_info(
&mut self,
params: subscription::UpdateInfoParams,
_results: subscription::UpdateInfoResults,
) -> capnp::capability::Promise<(), capnp::Error> {
let info = pry!(pry!(params.get()).get_value());
let mut model = self.model.borrow_mut();
model.display_name = pry!(info.get_display_name()).to_string();
model.muted = info.get_muted();
model.read_until = info.get_read_until();
pry!(self.db.update_subscription(model.clone()));
Promise::ok(())
}
fn clear_notifications(
&mut self,
_params: subscription::ClearNotificationsParams,
_results: subscription::ClearNotificationsResults,
) -> capnp::capability::Promise<(), capnp::Error> {
let model = self.model.borrow_mut();
pry!(self.db.delete_messages(&model.server, &model.topic));
Promise::ok(())
}
fn update_read_until(
&mut self,
params: subscription::UpdateReadUntilParams,
_: subscription::UpdateReadUntilResults,
) -> capnp::capability::Promise<(), capnp::Error> {
let value = pry!(params.get()).get_value();
let mut model = self.model.borrow_mut();
pry!(self
.db
.update_read_until(&model.server, &model.topic, value));
model.read_until = value;
Promise::ok(())
}
}
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
pub struct WatchKey {
server: String,
topic: String,
}
pub struct SystemNotifier {
servers: HashMap<String, ntfy_proxy::Client>,
watching: Rc<RefCell<HashMap<WatchKey, subscription::Client>>>,
db: Db,
}
impl SystemNotifier {
pub fn new(dbpath: &str) -> Self {
Self {
servers: HashMap::new(),
watching: Rc::new(RefCell::new(HashMap::new())),
db: Db::connect(dbpath).unwrap(),
}
}
fn watch(&mut self, sub: models::Subscription) -> Promise<subscription::Client, capnp::Error> {
let ntfy = self
.servers
.entry(sub.server.to_owned())
.or_insert_with(|| capnp_rpc::new_client(NtfyProxyImpl::new(sub.server.to_owned())));
let subscription = SubscriptionImpl::new(sub.clone(), ntfy.clone(), self.db.clone());
let mut req = ntfy.watch_request();
req.get().set_topic(&sub.topic);
req.get()
.set_watcher(capnp_rpc::new_client(subscription.output_channel()));
let res = req.send();
let handle = res.pipeline.get_handle();
subscription
.server_watch_handle
.set(handle)
.map_err(|_| "already set")
.unwrap();
let watching = self.watching.clone();
let subc: subscription::Client = capnp_rpc::new_client(subscription);
Promise::from_future(async move {
res.promise
.await
.map_err(|e| capnp::Error::failed(e.to_string()))?;
watching.borrow_mut().insert(
WatchKey {
server: sub.server.to_owned(),
topic: sub.topic.to_owned(),
},
subc.clone(),
);
Ok(subc)
})
}
pub fn watch_subscribed(&mut self) -> Promise<(), capnp::Error> {
let f: Vec<_> = pry!(self.db.list_subscriptions())
.into_iter()
.map(|m| self.watch(m.clone()))
.collect();
Promise::from_future(async move {
join_all(f.into_iter().map(|x| async move {
if let Err(e) = x.await {
error!(error = ?e, "Can't rewatch subscribed topic");
}
}))
.await;
Ok(())
})
}
}
impl system_notifier::Server for SystemNotifier {
fn subscribe(
&mut self,
params: system_notifier::SubscribeParams,
mut results: system_notifier::SubscribeResults,
) -> capnp::capability::Promise<(), capnp::Error> {
let topic = pry!(pry!(params.get()).get_topic());
let server: &str = pry!(pry!(params.get()).get_server());
let server = if server.is_empty() {
"https://ntfy.sh"
} else {
""
};
let subscription = pry!(
models::Subscription::builder(server.to_owned(), topic.to_owned())
.build()
.map_err(|e| capnp::Error::failed(e.to_string()))
);
let sub: Promise<subscription::Client, capnp::Error> = self.watch(subscription.clone());
let mut db = self.db.clone();
Promise::from_future(async move {
results.get().set_subscription(sub.await?);
db.insert_subscription(subscription).map_err(|e| {
capnp::Error::failed(format!("could not insert subscription: {}", e))
})?;
Ok(())
})
}
fn unsubscribe(
&mut self,
params: system_notifier::UnsubscribeParams,
_results: system_notifier::UnsubscribeResults,
) -> capnp::capability::Promise<(), capnp::Error> {
let topic = pry!(pry!(params.get()).get_topic());
let server = pry!(pry!(params.get()).get_server());
{
self.watching.borrow_mut().remove(&WatchKey {
server: server.to_string(),
topic: topic.to_string(),
});
pry!(self
.db
.remove_subscription(&server, &topic)
.map_err(|e| capnp::Error::failed(e.to_string())));
info!(server, topic, "Unsubscribed");
}
Promise::ok(())
}
fn list_subscriptions(
&mut self,
_: system_notifier::ListSubscriptionsParams,
mut results: system_notifier::ListSubscriptionsResults,
) -> capnp::capability::Promise<(), capnp::Error> {
let req = results.get();
let values = self.watching.borrow().values().cloned().collect::<Vec<_>>();
let mut list = req.init_list(values.len() as u32);
for (i, v) in values.iter().enumerate() {
use capnp::capability::FromClientHook;
list.set(i as u32, v.clone().clone().into_client_hook());
}
Promise::ok(())
}
}
pub fn start(socket_path: std::path::PathBuf, dbpath: &str) -> anyhow::Result<()> {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
let listener = rt.block_on(async move {
let _ = std::fs::remove_file(&socket_path);
UnixListener::bind(&socket_path).unwrap()
});
let dbpath = dbpath.to_owned();
let f = move || {
let local = tokio::task::LocalSet::new();
let mut system_notifier = SystemNotifier::new(&dbpath);
local.spawn_local(async move {
system_notifier.watch_subscribed().await.unwrap();
let system_client: system_notifier::Client = capnp_rpc::new_client(system_notifier);
loop {
match listener.accept().await {
Ok((stream, _addr)) => {
info!("client connected");
let (reader, writer) =
tokio_util::compat::TokioAsyncReadCompatExt::compat(stream).split();
let network = twoparty::VatNetwork::new(
reader,
writer,
rpc_twoparty_capnp::Side::Server,
Default::default(),
);
let rpc_system =
RpcSystem::new(Box::new(network), Some(system_client.clone().client));
tokio::task::spawn_local(rpc_system);
}
Err(e) => {
error!(error=%e);
}
}
}
});
rt.block_on(local);
};
std::thread::spawn(move || {
f();
});
Ok(())
}

2
rustfmt.toml Normal file
View File

@ -0,0 +1,2 @@
unstable_features = true
group_imports = "StdExternalCrate"

View File

@ -1,54 +1,51 @@
use gettextrs::gettext;
use tracing::{debug, info};
use adw::subclass::prelude::*;
use capnp_rpc::{rpc_twoparty_capnp, twoparty, RpcSystem};
use futures::{AsyncRead, AsyncReadExt, AsyncWrite};
use gettextrs::gettext;
use gio::SocketClient;
use gio::UnixSocketAddress;
use gtk::prelude::*;
use gtk::{gdk, gio, glib};
use ntfy_daemon::ntfy_capnp::system_notifier;
use tracing::{debug, info};
use crate::config::{APP_ID, PKGDATADIR, PROFILE, VERSION};
use crate::window::ExampleApplicationWindow;
use crate::widgets::*;
trait RW: AsyncRead + AsyncWrite {}
impl<T: AsyncRead + AsyncWrite> RW for T {}
mod imp {
use super::*;
use std::cell::RefCell;
use glib::WeakRef;
use once_cell::sync::OnceCell;
#[derive(Debug, Default)]
pub struct ExampleApplication {
pub window: OnceCell<WeakRef<ExampleApplicationWindow>>,
use super::*;
#[derive(Default)]
pub struct NotifyApplication {
pub window: RefCell<WeakRef<NotifyWindow>>,
pub hold_guard: OnceCell<gio::ApplicationHoldGuard>,
}
#[glib::object_subclass]
impl ObjectSubclass for ExampleApplication {
const NAME: &'static str = "ExampleApplication";
type Type = super::ExampleApplication;
impl ObjectSubclass for NotifyApplication {
const NAME: &'static str = "NotifyApplication";
type Type = super::NotifyApplication;
type ParentType = adw::Application;
}
impl ObjectImpl for ExampleApplication {}
impl ObjectImpl for NotifyApplication {}
impl ApplicationImpl for ExampleApplication {
impl ApplicationImpl for NotifyApplication {
fn activate(&self) {
debug!("AdwApplication<ExampleApplication>::activate");
debug!("AdwApplication<NotifyApplication>::activate");
self.parent_activate();
let app = self.obj();
if let Some(window) = self.window.get() {
let window = window.upgrade().unwrap();
window.present();
return;
}
let window = ExampleApplicationWindow::new(&app);
self.window
.set(window.downgrade())
.expect("Window already set.");
app.main_window().present();
}
fn startup(&self) {
debug!("AdwApplication<ExampleApplication>::startup");
debug!("AdwApplication<NotifyApplication>::startup");
self.parent_startup();
let app = self.obj();
@ -59,21 +56,56 @@ mod imp {
app.setup_gactions();
app.setup_accels();
}
fn command_line(&self, command_line: &gio::ApplicationCommandLine) -> glib::ExitCode {
let socket_path = glib::user_data_dir().join("com.ranfdev.Notify.socket");
debug!("AdwApplication<NotifyApplication>::command_line");
let arguments = command_line.arguments();
let is_daemon = arguments.get(1).map(|x| x.to_str()) == Some(Some("--daemon"));
let app = self.obj();
if self.hold_guard.get().is_none() {
self.obj().ensure_rpc_running(&socket_path);
}
glib::MainContext::default().spawn_local(async move {
super::NotifyApplication::run_in_background().await.unwrap();
});
if is_daemon {
return glib::ExitCode::SUCCESS;
}
{
let w = self.window.borrow();
if let Some(window) = w.upgrade() {
if window.is_visible() {
window.present();
return glib::ExitCode::SUCCESS;
}
}
}
app.build_window(&socket_path);
app.main_window().present();
glib::ExitCode::SUCCESS
}
}
impl GtkApplicationImpl for ExampleApplication {}
impl AdwApplicationImpl for ExampleApplication {}
impl GtkApplicationImpl for NotifyApplication {}
impl AdwApplicationImpl for NotifyApplication {}
}
glib::wrapper! {
pub struct ExampleApplication(ObjectSubclass<imp::ExampleApplication>)
pub struct NotifyApplication(ObjectSubclass<imp::NotifyApplication>)
@extends gio::Application, gtk::Application,
@implements gio::ActionMap, gio::ActionGroup;
}
impl ExampleApplication {
fn main_window(&self) -> ExampleApplicationWindow {
self.imp().window.get().unwrap().upgrade().unwrap()
impl NotifyApplication {
fn main_window(&self) -> NotifyWindow {
self.imp().window.borrow().upgrade().unwrap()
}
fn setup_gactions(&self) {
@ -105,7 +137,7 @@ impl ExampleApplication {
let provider = gtk::CssProvider::new();
provider.load_from_resource("/com/ranfdev/Notify/style.css");
if let Some(display) = gdk::Display::default() {
gtk::StyleContext::add_provider_for_display(
gtk::style_context_add_provider_for_display(
&display,
&provider,
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
@ -117,10 +149,7 @@ impl ExampleApplication {
let dialog = adw::AboutWindow::builder()
.application_icon(APP_ID)
.application_name("Notify")
// Insert your license of choice here
// .license_type(gtk::License::MitX11)
// Insert your website here
// .website("https://gitlab.gnome.org/bilelmoussaoui/notify/")
.license_type(gtk::License::Gpl30)
.version(VERSION)
.transient_for(&self.main_window())
.translator_credits(gettext("translator-credits"))
@ -133,18 +162,67 @@ impl ExampleApplication {
}
pub fn run(&self) -> glib::ExitCode {
info!("Notify ({})", APP_ID);
info!("Version: {} ({})", VERSION, PROFILE);
info!("Datadir: {}", PKGDATADIR);
info!(app_id = %APP_ID, version = %VERSION, profile = %PROFILE, datadir = %PKGDATADIR, "running");
ApplicationExtManual::run(self)
}
async fn run_in_background() -> ashpd::Result<()> {
let response = ashpd::desktop::background::Background::request()
.reason("Listen for coming notifications")
.auto_start(true)
.command(&["notify", "--daemon"])
.dbus_activatable(false)
.send()
.await?
.response()?;
info!(auto_start = %response.auto_start(), run_in_background = %response.run_in_background());
Ok(())
}
fn ensure_rpc_running(&self, socket_path: &Path) {
let dbpath = glib::user_data_dir().join("com.ranfdev.Notify.sqlite");
info!(database_path = %dbpath.display());
ntfy_daemon::system_client::start(socket.to_owned(), dbpath.to_str().unwrap()).unwrap();
self.imp().hold_guard.set(self.hold()).unwrap();
}
fn build_window(&self, socket_path: &Path) {
let address = UnixSocketAddress::new(socket_path);
let client = SocketClient::new();
let connection =
SocketClientExt::connect(&client, &address, gio::Cancellable::NONE).unwrap();
let rw = connection.into_async_read_write().unwrap();
let (reader, writer) = rw.split();
let rpc_network = Box::new(twoparty::VatNetwork::new(
reader,
writer,
rpc_twoparty_capnp::Side::Client,
Default::default(),
));
let mut rpc_system = RpcSystem::new(rpc_network, None);
let client: system_notifier::Client =
rpc_system.bootstrap(rpc_twoparty_capnp::Side::Server);
glib::MainContext::default().spawn_local(async move {
debug!("rpc_system started");
rpc_system.await.unwrap();
debug!("rpc_system stopped");
});
let window = NotifyWindow::new(self, client);
*self.imp().window.borrow_mut() = window.downgrade();
}
}
impl Default for ExampleApplication {
impl Default for NotifyApplication {
fn default() -> Self {
glib::Object::builder()
.property("application-id", APP_ID)
.property("flags", gio::ApplicationFlags::HANDLES_COMMAND_LINE)
.property("resource-base-path", "/com/ranfdev/Notify/")
.build()
}

28
src/async_utils.rs Normal file
View File

@ -0,0 +1,28 @@
use std::cell::Cell;
use std::rc::Rc;
use glib::Receiver;
use glib::SourceId;
use gtk::glib;
pub fn debounce_channel<T: 'static>(
duration: std::time::Duration,
source: Receiver<T>,
) -> Receiver<T> {
let (tx, rx) = glib::MainContext::channel(Default::default());
let scheduled = Rc::new(Cell::new(None::<SourceId>));
source.attach(None, move |data| {
if let Some(scheduled) = scheduled.take() {
scheduled.remove();
}
let tx = tx.clone();
let scheduled_clone = scheduled.clone();
let source_id = glib::source::timeout_add_local_once(duration, move || {
tx.send(data).unwrap();
scheduled_clone.take();
});
scheduled.set(Some(source_id));
glib::ControlFlow::Continue
});
rx
}

View File

@ -1,12 +1,14 @@
mod application;
#[rustfmt::skip]
mod config;
mod window;
mod async_utils;
mod subscription;
pub mod widgets;
use gettextrs::{gettext, LocaleCategory};
use gtk::{gio, glib};
use self::application::ExampleApplication;
use self::application::NotifyApplication;
use self::config::{GETTEXT_PACKAGE, LOCALEDIR, RESOURCES_FILE};
fn main() -> glib::ExitCode {
@ -23,6 +25,6 @@ fn main() -> glib::ExitCode {
let res = gio::Resource::load(RESOURCES_FILE).expect("Could not load gresource file");
gio::resources_register(&res);
let app = ExampleApplication::default();
let app = NotifyApplication::default();
app.run()
}

318
src/subscription.rs Normal file
View File

@ -0,0 +1,318 @@
use std::cell::{Cell, OnceCell, RefCell};
use std::rc::Rc;
use adw::prelude::*;
use capnp::capability::Promise;
use capnp_rpc::pry;
use glib::once_cell::sync::Lazy;
use glib::subclass::prelude::*;
use glib::subclass::Signal;
use glib::Properties;
use gtk::{gio, glib};
use ntfy_daemon::models;
use ntfy_daemon::ntfy_capnp::{output_channel, subscription, watch_handle, Status};
use tracing::{debug, debug_span, error, instrument};
struct TopicWatcher {
sub: glib::WeakRef<Subscription>,
}
impl output_channel::Server for TopicWatcher {
fn send_message(
&mut self,
params: output_channel::SendMessageParams,
_results: output_channel::SendMessageResults,
) -> capnp::capability::Promise<(), capnp::Error> {
if let Some(sub) = self.sub.upgrade() {
let request = pry!(params.get());
let message = pry!(request.get_message());
let msg: models::Message = serde_json::from_str(&message).unwrap();
sub.imp().messages.append(&glib::BoxedAnyObject::new(msg));
sub.update_unread_count();
Promise::ok(())
} else {
Promise::err(capnp::Error::failed("dead channel".to_string()))
}
}
fn send_status(
&mut self,
params: output_channel::SendStatusParams,
_: output_channel::SendStatusResults,
) -> capnp::capability::Promise<(), capnp::Error> {
if let Some(sub) = self.sub.upgrade() {
let status = pry!(pry!(params.get()).get_status());
sub.imp().status.set(status);
sub.notify_status();
Promise::ok(())
} else {
Promise::err(capnp::Error::failed("dead channel".to_string()))
}
}
}
impl Drop for TopicWatcher {
fn drop(&mut self) {
debug!("Dropped topic watcher");
}
}
mod imp {
use super::*;
#[derive(Properties)]
#[properties(wrapper_type = super::Subscription)]
pub struct Subscription {
#[property(get)]
pub display_name: RefCell<String>,
#[property(get)]
pub topic: RefCell<String>,
#[property(get)]
pub url: RefCell<String>,
#[property(get)]
pub server: RefCell<String>,
#[property(get = Self::get_status, type = u8)]
pub status: Rc<Cell<Status>>,
#[property(get)]
pub muted: Cell<bool>,
#[property(get)]
pub unread_count: Cell<u32>,
pub read_until: Cell<u64>,
pub messages: gio::ListStore,
pub client: OnceCell<subscription::Client>,
pub remote_handle: RefCell<Option<watch_handle::Client>>,
}
impl Subscription {
fn get_status(&self) -> u8 {
let s: u16 = Cell::get(&self.status).into();
s as u8
}
}
impl Default for Subscription {
fn default() -> Self {
Self {
display_name: Default::default(),
topic: Default::default(),
url: Default::default(),
muted: Default::default(),
server: Default::default(),
status: Rc::new(Cell::new(Status::Down)),
messages: gio::ListStore::new::<glib::BoxedAnyObject>(),
client: Default::default(),
unread_count: Default::default(),
read_until: Default::default(),
remote_handle: Default::default(),
}
}
}
#[glib::derived_properties]
impl ObjectImpl for Subscription {
fn signals() -> &'static [Signal] {
static SIGNALS: Lazy<Vec<Signal>> =
Lazy::new(|| vec![Signal::builder("awarded").build()]);
SIGNALS.as_ref()
}
}
#[glib::object_subclass]
impl ObjectSubclass for Subscription {
const NAME: &'static str = "TopicSubscription";
type Type = super::Subscription;
}
}
glib::wrapper! {
pub struct Subscription(ObjectSubclass<imp::Subscription>);
}
impl Subscription {
pub fn new(client: subscription::Client) -> Self {
let this: Self = glib::Object::builder().build();
let imp = this.imp();
if let Err(_) = imp.client.set(client) {
panic!();
};
let this_clone = this.clone();
glib::MainContext::default().spawn_local(async move {
match this_clone.load().await {
Ok(_) => {}
Err(e) => {
error!(error = %e, "loading subscription data");
}
}
});
this
}
fn init_info(
&self,
topic: &str,
server: &str,
muted: bool,
read_until: u64,
display_name: &str,
) {
let imp = self.imp();
imp.topic.replace(topic.to_string());
self.notify_topic();
imp.server.replace(server.to_string());
self.notify_server();
imp.muted.replace(muted);
self.notify_muted();
imp.read_until.replace(read_until);
self.notify_unread_count();
self._set_display_name(display_name.to_string());
}
fn load(&self) -> Promise<(), capnp::Error> {
let imp = self.imp();
let req_info = imp.client.get().unwrap().get_info_request();
let req_messages = {
let mut req = imp.client.get().unwrap().watch_request();
req.get().set_watcher(capnp_rpc::new_client(TopicWatcher {
sub: self.downgrade(),
}));
req
};
let this = self.clone();
Promise::from_future(async move {
let info = req_info.send().promise.await?;
let info = info.get()?;
this.init_info(
info.get_topic()?,
info.get_server()?,
info.get_muted(),
info.get_read_until(),
info.get_display_name()?,
);
let message_stream = req_messages.send().promise.await?;
let handle = message_stream.get()?.get_handle()?;
this.imp().remote_handle.replace(Some(handle));
Ok(())
})
}
fn _set_display_name(&self, value: String) {
let imp = self.imp();
let value = if value.is_empty() {
self.topic()
} else {
value
};
imp.display_name.replace(value);
self.notify_display_name();
}
#[instrument(skip_all)]
pub fn set_display_name(&self, value: String) -> Promise<(), capnp::Error> {
let this = self.clone();
Promise::from_future(async move {
this._set_display_name(value);
this.send_updated_info().await?;
Ok(())
})
}
fn send_updated_info(&self) -> Promise<(), capnp::Error> {
let imp = self.imp();
let mut req = imp.client.get().unwrap().update_info_request();
let mut val = pry!(req.get().get_value());
val.set_muted(imp.muted.get());
val.set_display_name(&*imp.display_name.borrow());
val.set_read_until(imp.read_until.get());
Promise::from_future(async move {
let _span = debug_span!("send_updated_info").entered();
debug!("sending");
req.send().promise.await?;
Ok(())
})
}
fn last_message(list: &gio::ListStore) -> Option<models::Message> {
let n = list.n_items();
let last = list
.item(n.checked_sub(1)?)
.and_downcast::<glib::BoxedAnyObject>()?;
let last = last.borrow::<models::Message>();
Some(last.clone())
}
fn update_unread_count(&self) {
let imp = self.imp();
if let Some(last) = Self::last_message(&imp.messages) {
if last.time > imp.read_until.get() {
imp.unread_count.set(1);
} else {
imp.unread_count.set(0);
}
} else {
imp.unread_count.set(0);
}
self.notify_unread_count();
}
pub fn set_muted(&self, value: bool) -> Promise<(), capnp::Error> {
let this = self.clone();
Promise::from_future(async move {
this.imp().muted.replace(value);
this.notify_muted();
this.send_updated_info().await?;
Ok(())
})
}
pub fn flag_all_as_read(&self) -> Promise<(), capnp::Error> {
let imp = self.imp();
let Some(last) = Self::last_message(&imp.messages) else {
return Promise::ok(());
};
let value = last.time;
let this = self.clone();
Promise::from_future(async move {
let mut req = this.imp().client.get().unwrap().update_read_until_request();
req.get().set_value(value);
req.send().promise.await?;
this.imp().read_until.set(value);
this.update_unread_count();
Ok(())
})
}
pub fn publish(&self, message: &str) -> Promise<(), capnp::Error> {
let imp = self.imp();
let mut req = imp.client.get().unwrap().publish_request();
let msg = serde_json::to_string(&models::Message {
topic: self.topic(),
message: Some(message.to_string()),
..models::Message::default()
})
.map_err(|e| capnp::Error::failed(e.to_string()));
req.get().set_message(&pry!(msg));
Promise::from_future(async move {
let _span = debug_span!("publish").entered();
debug!("sending");
req.send().promise.await?;
Ok(())
})
}
#[instrument(skip_all)]
pub fn clear_notifications(&self) -> Promise<(), capnp::Error> {
let imp = self.imp();
let req = imp.client.get().unwrap().clear_notifications_request();
let this = self.clone();
Promise::from_future(async move {
let _span = debug_span!("clear_notifications").entered();
debug!("sending");
req.send().promise.await?;
this.imp().messages.remove_all();
Ok(())
})
}
pub fn nice_status(&self) -> Status {
Status::try_from(self.imp().status.get() as u16).unwrap()
}
}

View File

@ -0,0 +1,180 @@
use adw::prelude::*;
use adw::subclass::prelude::*;
use glib::once_cell::sync::Lazy;
use glib::subclass::Signal;
use glib::Properties;
use gtk::gio;
use gtk::glib;
mod imp {
pub use super::*;
#[derive(Debug, Default, Properties)]
#[properties(wrapper_type = super::AddSubscriptionDialog)]
pub struct AddSubscriptionDialog {
#[property(name = "topic", get = |imp: &Self| imp.topic_entry.text(), type = glib::GString)]
pub topic_entry: adw::EntryRow,
#[property(name = "server", get = |imp: &Self| imp.server_entry.text(), type = glib::GString)]
pub server_entry: adw::EntryRow,
}
#[glib::object_subclass]
impl ObjectSubclass for AddSubscriptionDialog {
const NAME: &'static str = "AddSubscriptionDialog";
type Type = super::AddSubscriptionDialog;
type ParentType = adw::Window;
fn class_init(klass: &mut Self::Class) {
klass.add_binding_action(
gtk::gdk::Key::Escape,
gtk::gdk::ModifierType::empty(),
"window.close",
None,
);
klass.install_action("default.activate", None, |this, _, _| {
this.emit_subscribe_request();
this.close();
});
}
}
#[glib::derived_properties]
impl ObjectImpl for AddSubscriptionDialog {
fn signals() -> &'static [Signal] {
static SIGNALS: Lazy<Vec<Signal>> =
Lazy::new(|| vec![Signal::builder("subscribe-request").build()]);
SIGNALS.as_ref()
}
fn constructed(&self) {
self.parent_constructed();
let obj = self.obj().clone();
obj.build_ui();
}
}
impl WidgetImpl for AddSubscriptionDialog {}
impl WindowImpl for AddSubscriptionDialog {}
impl AdwWindowImpl for AddSubscriptionDialog {}
}
glib::wrapper! {
pub struct AddSubscriptionDialog(ObjectSubclass<imp::AddSubscriptionDialog>)
@extends gtk::Widget, gtk::Window, adw::Window,
@implements gio::ActionMap, gio::ActionGroup, gtk::Root;
}
impl AddSubscriptionDialog {
pub fn new() -> Self {
glib::Object::builder().build()
}
fn build_ui(&self) {
let imp = self.imp();
let obj = self.clone();
obj.set_title(Some("Subscribe To Topic"));
obj.set_modal(true);
obj.set_default_width(360);
let toolbar_view = adw::ToolbarView::new();
toolbar_view.add_top_bar(&adw::HeaderBar::new());
let content = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(12)
.margin_end(12)
.margin_start(12)
.margin_top(12)
.margin_bottom(12)
.build();
let clamp = adw::Clamp::new();
clamp.set_child(Some(&content));
let description = {
let d = gtk::Label::builder()
.label("Topics may not be password-protected, so choose a name that's not easy to guess. Once subscribed, you can PUT/POST notifications.")
.wrap(true)
.xalign(0.0)
.wrap_mode(gtk::pango::WrapMode::WordChar)
.build();
d.add_css_class("dim-label");
d
};
content.append(&description);
let topic_entry = {
let e = &imp.topic_entry;
e.set_title("Topic");
e.set_activates_default(true);
let rand_btn = {
let b = gtk::Button::builder()
.icon_name("dice3-symbolic")
.tooltip_text("Generate Name")
.valign(gtk::Align::Center)
.css_classes(["flat"])
.build();
let ec = e.clone();
b.connect_clicked(move |_| {
use rand::distributions::Alphanumeric;
use rand::{thread_rng, Rng};
let mut rng = thread_rng();
let chars: String = (0..10).map(|_| rng.sample(Alphanumeric) as char).collect();
ec.set_text(&chars);
});
b
};
e.add_suffix(&rand_btn);
e
};
// TODO: Reserved topics
/*let reserved_switch = {
adw::SwitchRow::builder()
.title("Reserved")
.subtitle("For Ntfy Pro users only")
.build()
};*/
let server_entry = &imp.server_entry;
server_entry.set_title("Server");
let expander_row = {
let e = adw::ExpanderRow::builder()
.title("Custom Server...")
.enable_expansion(false)
.show_enable_switch(true)
.build();
e.add_row(server_entry);
e
};
let list_box = {
let l = gtk::ListBox::new();
l.add_css_class("boxed-list");
l.append(topic_entry);
// l.append(&reserved_switch);
l.append(&expander_row);
l
};
content.append(&list_box);
let sub_btn = {
let b = gtk::Button::new();
b.set_label("Subscribe");
b.add_css_class("suggested-action");
b.add_css_class("pill");
b.set_halign(gtk::Align::Center);
let wc = obj.clone();
b.connect_clicked(move |_| {
wc.emit_subscribe_request();
wc.close();
});
b
};
content.append(&sub_btn);
toolbar_view.set_content(Some(&clamp));
obj.set_content(Some(&toolbar_view));
}
fn emit_subscribe_request(&self) {
self.emit_by_name::<()>("subscribe-request", &[]);
}
}

209
src/widgets/message_row.rs Normal file
View File

@ -0,0 +1,209 @@
use adw::prelude::*;
use adw::subclass::prelude::*;
use chrono::NaiveDateTime;
use gtk::{gio, glib};
use ntfy_daemon::models;
use tracing::error;
use crate::widgets::*;
mod imp {
use super::*;
#[derive(Debug, Default)]
pub struct MessageRow {}
#[glib::object_subclass]
impl ObjectSubclass for MessageRow {
const NAME: &'static str = "MessageRow";
type Type = super::MessageRow;
type ParentType = adw::Bin;
}
impl ObjectImpl for MessageRow {}
impl WidgetImpl for MessageRow {}
impl BinImpl for MessageRow {}
}
glib::wrapper! {
pub struct MessageRow(ObjectSubclass<imp::MessageRow>)
@extends gtk::Widget, adw::Bin;
}
impl MessageRow {
pub fn new(msg: models::Message) -> Self {
let this: Self = glib::Object::new();
this.build_ui(msg);
this
}
fn build_ui(&self, msg: models::Message) {
let top_box = gtk::Box::new(gtk::Orientation::Horizontal, 8);
let time = gtk::Label::builder()
.label(
&NaiveDateTime::from_timestamp_opt(msg.time as i64, 0)
.map(|time| time.format("%Y-%m-%d %H:%M:%S").to_string())
.unwrap_or_default(),
)
.wrap_mode(gtk::pango::WrapMode::WordChar)
.xalign(0.0)
.wrap(true)
.build();
time.add_css_class("caption");
top_box.append(&time);
if let Some(p) = msg.priority {
let text = format!(
"Priority: {}",
match p {
5 => "Max",
4 => "High",
3 => "Medium",
2 => "Low",
1 => "Min",
_ => "Invalid",
}
);
let priority = gtk::Label::builder()
.label(&text)
.wrap_mode(gtk::pango::WrapMode::WordChar)
.xalign(0.0)
.wrap(true)
.build();
priority.add_css_class("caption");
priority.add_css_class("chip");
if p == 5 {
priority.add_css_class("chip--danger")
} else if p == 4 {
priority.add_css_class("chip--warning")
}
top_box.append(&priority);
}
let b = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(8)
.margin_top(8)
.margin_bottom(8)
.margin_start(8)
.margin_end(8)
.build();
b.append(&top_box);
if let Some(title) = msg.display_title() {
let label = gtk::Label::builder()
.label(&title)
.wrap_mode(gtk::pango::WrapMode::WordChar)
.xalign(0.0)
.wrap(true)
.selectable(true)
.build();
label.add_css_class("heading");
b.append(&label);
}
if let Some(message) = msg.display_message() {
let label = gtk::Label::builder()
.label(&message)
.wrap_mode(gtk::pango::WrapMode::WordChar)
.xalign(0.0)
.wrap(true)
.selectable(true)
.build();
b.append(&label);
}
if msg.actions.len() > 0 {
let action_btns = gtk::Box::builder().spacing(8).build();
for a in msg.actions {
let btn = self.build_action_btn(a);
action_btns.append(&btn);
}
b.append(&action_btns);
}
if msg.tags.len() > 0 {
let mut tags_text = String::from("tags: ");
tags_text.push_str(&msg.tags.join(", "));
let tags = gtk::Label::builder()
.label(&tags_text)
.xalign(0.0)
.wrap(true)
.wrap_mode(gtk::pango::WrapMode::WordChar)
.build();
b.append(&tags);
}
self.set_child(Some(&b));
}
fn build_action_btn(&self, action: models::Action) -> gtk::Button {
let btn = gtk::Button::new();
match action {
models::Action::View { label, url, .. } => {
btn.set_label(&label);
btn.set_tooltip_text(Some(&format!("Go to {url}")));
btn.connect_clicked(move |_| {
gtk::UriLauncher::builder().uri(url.clone()).build().launch(
gtk::Window::NONE,
gio::Cancellable::NONE,
|_| {},
);
});
}
models::Action::Http {
label,
method,
url,
body,
headers,
..
} => {
btn.set_label(&label);
btn.set_tooltip_text(Some(&format!("Send HTTP {method} to {url}")));
let (tx, rx) = glib::MainContext::channel(Default::default());
let this = self.clone();
btn.connect_clicked({
let url = url.clone();
let method = method.clone();
move |_| {
let url = url.clone();
let method = method.clone();
let tx = tx.clone();
let body = body.clone();
let headers = headers.clone();
gio::spawn_blocking(move || {
let mut req = ureq::request(method.as_str(), url.as_str());
for (k, v) in headers.iter() {
req = req.set(&k, &v);
}
tx.send(req.send(body.as_bytes())).unwrap();
});
}
});
rx.attach(Some(&glib::MainContext::default()), move |res| {
let method = method.clone();
let url = url.clone();
this.spawn_with_near_toast(async move {
match res {
Err(e) => {
error!(error = ?e, "Error sending request");
Err(format!("Error sending HTTP {method} to {url}"))
}
Ok(_) => Ok(()),
}
});
glib::ControlFlow::Continue
});
}
models::Action::Broadcast { label, .. } => {
btn.set_label(&label);
btn.set_sensitive(false);
btn.set_tooltip_text(Some("Broadcast action only available on Android"));
}
}
btn
}
}

8
src/widgets/mod.rs Normal file
View File

@ -0,0 +1,8 @@
mod add_subscription_dialog;
mod message_row;
mod subscription_info_dialog;
mod window;
pub use add_subscription_dialog::AddSubscriptionDialog;
pub use message_row::*;
pub use subscription_info_dialog::SubscriptionInfoDialog;
pub use window::*;

View File

@ -0,0 +1,112 @@
use std::cell::RefCell;
use adw::prelude::*;
use adw::subclass::prelude::*;
use glib::Properties;
use gtk::gio;
use gtk::glib;
use crate::widgets::*;
mod imp {
pub use super::*;
#[derive(Debug, Default, Properties, gtk::CompositeTemplate)]
#[template(resource = "/com/ranfdev/Notify/ui/subscription_info_dialog.ui")]
#[properties(wrapper_type = super::SubscriptionInfoDialog)]
pub struct SubscriptionInfoDialog {
#[property(get, construct_only)]
pub subscription: RefCell<Option<crate::subscription::Subscription>>,
#[template_child]
pub display_name_entry: TemplateChild<adw::EntryRow>,
#[template_child]
pub muted_switch_row: TemplateChild<adw::SwitchRow>,
}
#[glib::object_subclass]
impl ObjectSubclass for SubscriptionInfoDialog {
const NAME: &'static str = "SubscriptionInfoDialog";
type Type = super::SubscriptionInfoDialog;
type ParentType = adw::Window;
fn class_init(klass: &mut Self::Class) {
klass.bind_template();
klass.add_binding_action(
gtk::gdk::Key::Escape,
gtk::gdk::ModifierType::empty(),
"window.close",
None,
);
}
// You must call `Widget`'s `init_template()` within `instance_init()`.
fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
obj.init_template();
}
}
#[glib::derived_properties]
impl ObjectImpl for SubscriptionInfoDialog {
fn constructed(&self) {
self.parent_constructed();
let this = self.obj().clone();
let (tx, rx) = glib::MainContext::channel(glib::Priority::default());
let rx =
crate::async_utils::debounce_channel(std::time::Duration::from_millis(500), rx);
rx.attach(None, move |entry| {
this.update_display_name(&entry);
glib::ControlFlow::Continue
});
let this = self.obj().clone();
self.display_name_entry
.set_text(&this.subscription().unwrap().display_name());
self.muted_switch_row
.set_active(this.subscription().unwrap().muted());
self.display_name_entry.connect_changed({
move |entry| {
tx.send(entry.clone()).unwrap();
}
});
let this = self.obj().clone();
self.muted_switch_row.connect_active_notify({
move |switch| {
this.update_muted(switch);
}
});
}
}
impl WidgetImpl for SubscriptionInfoDialog {}
impl WindowImpl for SubscriptionInfoDialog {}
impl AdwWindowImpl for SubscriptionInfoDialog {}
}
glib::wrapper! {
pub struct SubscriptionInfoDialog(ObjectSubclass<imp::SubscriptionInfoDialog>)
@extends gtk::Widget, gtk::Window, adw::Window,
@implements gio::ActionMap, gio::ActionGroup, gtk::Root;
}
impl SubscriptionInfoDialog {
pub fn new(subscription: crate::subscription::Subscription) -> Self {
let this = glib::Object::builder()
.property("subscription", subscription)
.build();
this
}
fn update_display_name(&self, entry: &impl IsA<gtk::Editable>) {
if let Some(sub) = self.subscription() {
let entry = entry.clone();
self.spawn_with_near_toast(async move {
let res = sub.set_display_name(entry.text().to_string()).await;
res
});
}
}
fn update_muted(&self, switch: &adw::SwitchRow) {
if let Some(sub) = self.subscription() {
let switch = switch.clone();
self.spawn_with_near_toast(async move { sub.set_muted(switch.is_active()).await })
}
}
}

483
src/widgets/window.rs Normal file
View File

@ -0,0 +1,483 @@
use std::cell::Cell;
use std::cell::OnceCell;
use adw::prelude::*;
use adw::subclass::prelude::*;
use futures::prelude::*;
use gtk::{gio, glib};
use ntfy_daemon::models;
use ntfy_daemon::ntfy_capnp::{system_notifier, Status};
use tracing::warn;
use crate::application::NotifyApplication;
use crate::config::{APP_ID, PROFILE};
use crate::subscription::Subscription;
use crate::widgets::*;
pub trait SpawnWithToast {
fn spawn_with_near_toast<T, R: std::fmt::Display>(
&self,
f: impl Future<Output = Result<T, R>> + 'static,
);
}
impl<W: glib::IsA<gtk::Widget>> SpawnWithToast for W {
fn spawn_with_near_toast<T, R: std::fmt::Display>(
&self,
f: impl Future<Output = Result<T, R>> + 'static,
) {
let p: Option<NotifyWindow> = self.ancestor(NotifyWindow::static_type()).and_downcast();
glib::MainContext::default().spawn_local(async move {
if let Err(e) = f.await {
if let Some(p) = p {
p.imp()
.toast_overlay
.add_toast(adw::Toast::builder().title(&e.to_string()).build())
}
}
});
}
}
mod imp {
use super::*;
#[derive(gtk::CompositeTemplate)]
#[template(resource = "/com/ranfdev/Notify/ui/window.ui")]
pub struct NotifyWindow {
#[template_child]
pub headerbar: TemplateChild<adw::HeaderBar>,
#[template_child]
pub message_list: TemplateChild<gtk::ListBox>,
#[template_child]
pub subscription_list: TemplateChild<gtk::ListBox>,
#[template_child]
pub entry: TemplateChild<gtk::Entry>,
#[template_child]
pub navigation_split_view: TemplateChild<adw::NavigationSplitView>,
#[template_child]
pub subscription_view: TemplateChild<adw::ToolbarView>,
#[template_child]
pub subscription_menu_btn: TemplateChild<gtk::MenuButton>,
pub subscription_list_model: gio::ListStore,
#[template_child]
pub toast_overlay: TemplateChild<adw::ToastOverlay>,
#[template_child]
pub stack: TemplateChild<gtk::Stack>,
#[template_child]
pub welcome_view: TemplateChild<adw::StatusPage>,
#[template_child]
pub list_view: TemplateChild<gtk::ScrolledWindow>,
#[template_child]
pub message_scroll: TemplateChild<gtk::ScrolledWindow>,
#[template_child]
pub banner: TemplateChild<adw::Banner>,
pub notifier: OnceCell<system_notifier::Client>,
pub conn: OnceCell<gio::SocketConnection>,
pub settings: gio::Settings,
pub banner_binding: Cell<Option<(Subscription, glib::SignalHandlerId)>>,
}
impl Default for NotifyWindow {
fn default() -> Self {
let this = Self {
headerbar: TemplateChild::default(),
message_list: TemplateChild::default(),
entry: TemplateChild::default(),
subscription_view: TemplateChild::default(),
navigation_split_view: TemplateChild::default(),
subscription_menu_btn: TemplateChild::default(),
subscription_list: TemplateChild::default(),
toast_overlay: TemplateChild::default(),
stack: TemplateChild::default(),
welcome_view: TemplateChild::default(),
list_view: TemplateChild::default(),
message_scroll: TemplateChild::default(),
banner: TemplateChild::default(),
subscription_list_model: gio::ListStore::new::<Subscription>(),
settings: gio::Settings::new(APP_ID),
notifier: OnceCell::new(),
conn: OnceCell::new(),
banner_binding: Cell::new(None),
};
this
}
}
#[gtk::template_callbacks]
impl NotifyWindow {
#[template_callback]
fn show_add_topic(&self, _btn: &gtk::Button) {
let dialog = AddSubscriptionDialog::new();
dialog.set_transient_for(Some(&self.obj().clone()));
dialog.present();
let this = self.obj().clone();
let dc = dialog.clone();
dialog.connect_local("subscribe-request", true, move |_| {
this.add_subscription(&dc.server(), &dc.topic());
None
});
}
}
#[glib::object_subclass]
impl ObjectSubclass for NotifyWindow {
const NAME: &'static str = "NotifyWindow";
type Type = super::NotifyWindow;
type ParentType = adw::ApplicationWindow;
fn class_init(klass: &mut Self::Class) {
klass.bind_template();
klass.bind_template_callbacks();
klass.install_action("win.unsubscribe", None, |this, _, _| {
this.unsubscribe();
});
klass.install_action("win.show-subscription-info", None, |this, _, _| {
this.show_subscription_info();
});
klass.install_action("win.clear-notifications", None, |this, _, _| {
/*spawn_local(this.subscription().clear_notifications().map_err(this.near_toast_overlay().error_handler()));*/
this.selected_subscription().map(|sub| {
this.spawn_with_near_toast(sub.clear_notifications());
});
});
//klass.bind_template_instance_callbacks();
}
// You must call `Widget`'s `init_template()` within `instance_init()`.
fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
obj.init_template();
}
}
impl ObjectImpl for NotifyWindow {
fn constructed(&self) {
self.parent_constructed();
let obj = self.obj();
// Devel Profile
if PROFILE == "Devel" {
obj.add_css_class("devel");
}
}
fn dispose(&self) {
self.dispose_template();
}
}
impl WidgetImpl for NotifyWindow {}
impl WindowImpl for NotifyWindow {
// Save window state on delete event
fn close_request(&self) -> glib::Propagation {
if let Err(err) = self.obj().save_window_size() {
warn!(error = %err, "Failed to save window state");
}
// Pass close request on to the parent
self.parent_close_request()
}
}
impl ApplicationWindowImpl for NotifyWindow {}
impl AdwApplicationWindowImpl for NotifyWindow {}
}
glib::wrapper! {
pub struct NotifyWindow(ObjectSubclass<imp::NotifyWindow>)
@extends gtk::Widget, gtk::Window, adw::Window, adw::ApplicationWindow,
@implements gio::ActionMap, gio::ActionGroup, gtk::Root;
}
impl NotifyWindow {
pub fn new(app: &NotifyApplication, notifier: system_notifier::Client) -> Self {
let obj: Self = glib::Object::builder().property("application", app).build();
if let Err(_) = obj.imp().notifier.set(notifier) {
panic!("setting notifier for first time");
};
// Load latest window state
obj.load_window_size();
obj.bind_message_list();
obj.connect_entry_changed();
obj.connect_items_changed();
obj.selected_subscription_changed(None);
obj.bind_flag_read();
obj
}
fn connect_entry_changed(&self) {
let imp = self.imp();
let this = self.clone();
imp.entry.connect_activate(move |entry| {
let p = this
.selected_subscription()
.unwrap()
.publish(entry.text().as_str());
entry.spawn_with_near_toast(async move { p.await });
});
}
fn show_subscription_info(&self) {
let sub = SubscriptionInfoDialog::new(self.selected_subscription().unwrap());
sub.set_transient_for(Some(self));
sub.present();
}
fn connect_items_changed(&self) {
let this = self.clone();
self.imp()
.subscription_list_model
.connect_items_changed(move |list, _, _, _| {
let imp = this.imp();
if list.n_items() == 0 {
imp.stack.set_visible_child(&*imp.welcome_view);
} else {
imp.stack.set_visible_child(&*imp.list_view);
}
});
}
fn add_subscription(&self, server: &str, topic: &str) {
let mut req = self.notifier().subscribe_request();
req.get().set_server(server);
req.get().set_topic(topic);
let res = req.send();
let this = self.clone();
self.spawn_with_near_toast(async move {
let imp = this.imp();
// Subscription::new will use the pipelined client to retrieve info about the subscription
let subscription = Subscription::new(res.pipeline.get_subscription());
// We want to still check if there were any errors adding the subscription.
res.promise.await?;
imp.subscription_list_model.append(&subscription);
let i = imp.subscription_list_model.n_items() - 1;
let row = imp.subscription_list.row_at_index(i as i32);
imp.subscription_list.select_row(row.as_ref());
Ok::<(), capnp::Error>(())
});
}
fn unsubscribe(&self) {
let mut req = self.notifier().unsubscribe_request();
let sub = self.selected_subscription().unwrap();
req.get().set_server(&sub.server());
req.get().set_topic(&sub.topic());
let res = req.send();
let this = self.clone();
self.spawn_with_near_toast(async move {
let imp = this.imp();
res.promise.await?;
if let Some(i) = imp.subscription_list_model.find(&sub) {
imp.subscription_list_model.remove(i);
}
Ok::<(), capnp::Error>(())
});
}
fn notifier(&self) -> &system_notifier::Client {
self.imp().notifier.get().unwrap()
}
fn selected_subscription(&self) -> Option<Subscription> {
let imp = self.imp();
imp.subscription_list
.selected_row()
.and_then(|row| imp.subscription_list_model.item(row.index() as u32))
.and_downcast::<Subscription>()
}
fn bind_message_list(&self) {
let imp = self.imp();
imp.subscription_list
.bind_model(Some(&imp.subscription_list_model), |obj| {
let sub = obj.downcast_ref::<Subscription>().unwrap();
Self::build_subscription_ui(&sub).upcast()
});
let this = self.clone();
imp.subscription_list.connect_row_selected(move |_, _row| {
this.selected_subscription_changed(this.selected_subscription().as_ref());
});
let this = self.clone();
let req = self.notifier().list_subscriptions_request();
let res = req.send();
self.spawn_with_near_toast(async move {
let list = res.promise.await?;
let list = list.get()?.get_list()?;
let imp = this.imp();
for sub in list {
imp.subscription_list_model.append(&Subscription::new(sub?));
}
Ok::<(), capnp::Error>(())
});
}
fn update_banner(&self, sub: Option<&Subscription>) {
let imp = self.imp();
if let Some(sub) = sub {
match sub.nice_status() {
Status::Degraded | Status::Down => imp.banner.set_revealed(true),
Status::Up => imp.banner.set_revealed(false),
}
} else {
imp.banner.set_revealed(false);
}
}
fn selected_subscription_changed(&self, sub: Option<&Subscription>) {
let imp = self.imp();
self.update_banner(sub);
if let Some((sub, id)) = imp.banner_binding.take() {
sub.disconnect(id);
}
if let Some(sub) = sub {
imp.navigation_split_view.set_show_content(true);
imp.message_list
.bind_model(Some(&sub.imp().messages), move |obj| {
let b = obj.downcast_ref::<glib::BoxedAnyObject>().unwrap();
let msg = b.borrow::<models::Message>();
MessageRow::new(msg.clone()).upcast()
});
imp.subscription_menu_btn.set_visible(true);
imp.entry.set_sensitive(true);
let this = self.clone();
imp.banner_binding.set(Some((
sub.clone(),
sub.connect_status_notify(move |sub| {
this.update_banner(Some(sub));
}),
)));
let this = self.clone();
glib::idle_add_local_once(move || {
this.flag_read();
});
} else {
imp.message_list
.bind_model(gio::ListModel::NONE, |_| adw::Bin::new().into());
imp.subscription_menu_btn.set_visible(false);
imp.entry.set_sensitive(false);
}
}
fn flag_read(&self) {
let vadj = self.imp().message_scroll.vadjustment();
// There is nothing to scroll, so the user viewed all the messages
if vadj.page_size() == vadj.upper()
|| ((vadj.page_size() + vadj.value() - vadj.upper()).abs() <= 1.0)
{
self.selected_subscription().map(|sub| {
self.spawn_with_near_toast(sub.flag_all_as_read());
});
}
}
fn build_chip(text: &str) -> gtk::Label {
let chip = gtk::Label::new(Some(text));
chip.add_css_class("chip");
chip.add_css_class("chip--small");
chip.set_margin_top(4);
chip.set_margin_bottom(4);
chip.set_margin_start(4);
chip.set_margin_end(4);
chip.set_halign(gtk::Align::Center);
chip.set_valign(gtk::Align::Center);
chip
}
fn build_subscription_ui(sub: &Subscription) -> impl glib::IsA<gtk::Widget> {
let b = gtk::Box::builder().spacing(8).build();
let label = gtk::Label::builder()
.xalign(0.0)
.wrap_mode(gtk::pango::WrapMode::WordChar)
.wrap(true)
.hexpand(true)
.build();
sub.bind_property("display-name", &label, "label")
.sync_create()
.build();
let counter_chip = Self::build_chip("1+");
counter_chip.add_css_class("chip--info");
counter_chip.set_visible(false);
let counter_chip_clone = counter_chip.clone();
sub.connect_unread_count_notify(move |sub| {
let c = sub.unread_count();
counter_chip_clone.set_visible(c > 0);
});
let status_chip = Self::build_chip("Degraded");
let status_chip_clone = status_chip.clone();
sub.connect_status_notify(move |sub| match sub.nice_status() {
Status::Degraded | Status::Down => {
status_chip_clone.add_css_class("chip--degraded");
status_chip_clone.set_visible(true);
}
_ => {
status_chip_clone.set_visible(false);
}
});
b.append(&counter_chip);
b.append(&label);
b.append(&status_chip);
b
}
fn save_window_size(&self) -> Result<(), glib::BoolError> {
let imp = self.imp();
let (width, height) = self.default_size();
imp.settings.set_int("window-width", width)?;
imp.settings.set_int("window-height", height)?;
imp.settings
.set_boolean("is-maximized", self.is_maximized())?;
Ok(())
}
fn bind_flag_read(&self) {
let imp = self.imp();
let this = self.clone();
imp.message_scroll.connect_edge_reached(move |_, pos_type| {
if pos_type == gtk::PositionType::Bottom {
this.flag_read();
}
});
let this = self.clone();
self.connect_is_active_notify(move |_| {
if this.is_active() {
this.flag_read();
}
});
}
fn load_window_size(&self) {
let imp = self.imp();
let width = imp.settings.int("window-width");
let height = imp.settings.int("window-height");
let is_maximized = imp.settings.boolean("is-maximized");
self.set_default_size(width, height);
if is_maximized {
self.maximize();
}
}
}

View File

@ -1,118 +0,0 @@
use adw::subclass::prelude::*;
use gtk::prelude::*;
use gtk::{gio, glib};
use crate::application::ExampleApplication;
use crate::config::{APP_ID, PROFILE};
mod imp {
use super::*;
#[derive(Debug, gtk::CompositeTemplate)]
#[template(resource = "/com/ranfdev/Notify/ui/window.ui")]
pub struct ExampleApplicationWindow {
#[template_child]
pub headerbar: TemplateChild<adw::HeaderBar>,
pub settings: gio::Settings,
}
impl Default for ExampleApplicationWindow {
fn default() -> Self {
Self {
headerbar: TemplateChild::default(),
settings: gio::Settings::new(APP_ID),
}
}
}
#[glib::object_subclass]
impl ObjectSubclass for ExampleApplicationWindow {
const NAME: &'static str = "ExampleApplicationWindow";
type Type = super::ExampleApplicationWindow;
type ParentType = adw::ApplicationWindow;
fn class_init(klass: &mut Self::Class) {
klass.bind_template();
}
// You must call `Widget`'s `init_template()` within `instance_init()`.
fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
obj.init_template();
}
}
impl ObjectImpl for ExampleApplicationWindow {
fn constructed(&self) {
self.parent_constructed();
let obj = self.obj();
// Devel Profile
if PROFILE == "Devel" {
obj.add_css_class("devel");
}
// Load latest window state
obj.load_window_size();
}
fn dispose(&self) {
self.dispose_template();
}
}
impl WidgetImpl for ExampleApplicationWindow {}
impl WindowImpl for ExampleApplicationWindow {
// Save window state on delete event
fn close_request(&self) -> gtk::Inhibit {
if let Err(err) = self.obj().save_window_size() {
tracing::warn!("Failed to save window state, {}", &err);
}
// Pass close request on to the parent
self.parent_close_request()
}
}
impl ApplicationWindowImpl for ExampleApplicationWindow {}
impl AdwApplicationWindowImpl for ExampleApplicationWindow {}
}
glib::wrapper! {
pub struct ExampleApplicationWindow(ObjectSubclass<imp::ExampleApplicationWindow>)
@extends gtk::Widget, gtk::Window, adw::Window, adw::ApplicationWindow,
@implements gio::ActionMap, gio::ActionGroup, gtk::Root;
}
impl ExampleApplicationWindow {
pub fn new(app: &ExampleApplication) -> Self {
glib::Object::builder().property("application", app).build()
}
fn save_window_size(&self) -> Result<(), glib::BoolError> {
let imp = self.imp();
let (width, height) = self.default_size();
imp.settings.set_int("window-width", width)?;
imp.settings.set_int("window-height", height)?;
imp.settings
.set_boolean("is-maximized", self.is_maximized())?;
Ok(())
}
fn load_window_size(&self) {
let imp = self.imp();
let width = imp.settings.int("window-width");
let height = imp.settings.int("window-height");
let is_maximized = imp.settings.boolean("is-maximized");
self.set_default_size(width, height);
if is_maximized {
self.maximize();
}
}
}