Compare commits

..

17 Commits

Author SHA1 Message Date
kdusek
7b695d7b7f Fix compatibility issues with Qt/PyQt6 versions
Some checks failed
build / build-win64 (push) Waiting to run
build / build-macos (push) Waiting to run
build / build-pip (push) Failing after 11s
- Add null check for message.message in search filter
- Handle missing colorScheme/colorSchemeChanged methods for older Qt versions
- Add display check to prevent hanging in headless environments
- Update build documentation with system package alternative
- Update PyInstaller spec for Python 3.12
- Improve run.sh script with venv management
2025-12-06 04:00:10 +01:00
kdusek
2b3d9eb07f Add search feature for Gotify alerts
Some checks failed
build / build-win64 (push) Waiting to run
build / build-macos (push) Waiting to run
build / build-pip (push) Failing after 12s
2025-12-06 03:10:41 +01:00
kdusek
d0941fd7ab Update gotify-tray.spec
Some checks failed
build / build-win64 (push) Waiting to run
build / build-macos (push) Waiting to run
build / build-pip (push) Failing after 13s
2025-12-06 03:06:03 +01:00
kdusek
efdc63e1ab Remove subject filtering, keep only priority buttons
Some checks failed
build / build-pip (push) Failing after 12s
build / build-win64 (push) Has been cancelled
build / build-macos (push) Has been cancelled
- Remove subject filter menu and related code
- Simplify filtering to priority groups only
- Keep Remove Filters button for priority reset
- Clean up unused code and UI elements
2025-12-01 18:03:54 +01:00
kdusek
4c3b6925e5 Add message filtering by priority and subject, with CRITICAL always visible
- Implement MessagesProxyModel for client-side filtering
- Add priority filter buttons (LOW 0-3, NORMAL 4-8, HIGH 9, CRITICAL 10 always shown)
- Add subject filter menu with checkable actions for message titles
- Add Remove Filters button to reset all filters
- Restore priority 10 persistent notification setting in options
- Fix settings dialog errors and update UI layouts
- Ensure CRITICAL priority messages cannot be filtered out but can toggle persistent pop-ups
2025-12-01 18:02:05 +01:00
kdusek
09f85c5902 Remove invalid pyqt6-qt6-multimedia dependency and add build.sh script for easy compilation
Some checks failed
build / build-pip (push) Failing after 26s
build / build-win64 (push) Has been cancelled
build / build-macos (push) Has been cancelled
2025-11-27 10:01:39 +01:00
kdusek
a797f4ccf1 Update documentation
Some checks failed
build / build-pip (push) Failing after 8s
build / build-win64 (push) Has been cancelled
build / build-macos (push) Has been cancelled
- Add repository details to AGENTS.md for customized repo
- Add customizations section to README.md describing new features:
  - Persistent notifications for priority 10 with flashing
  - Sound control for priority 10 only
  - Stacking multiple notifications
  - Tray icon click to close all
2025-11-26 15:17:43 +01:00
kdusek
2108568f50 Add persistent notifications for priority 10 messages
Some checks failed
build / build-pip (push) Failing after 1m13s
build / build-win64 (push) Has been cancelled
build / build-macos (push) Has been cancelled
- Implement custom PersistentNotification widget with flashing background
- Add settings for persistent priority 10 notifications and sound control
- Modify notification logic to show persistent pop-ups for priority 10
- Allow closing all persistent notifications via tray icon click
- Add AGENTS.md with type checking guidelines
- Configure pyright to suppress PyQt6 false positives
- Update UI in settings dialog for new options
- Add notification sound file
2025-11-26 15:10:50 +01:00
seird
4e4fd9cdc9 Merge pull request #45 from hydrargyrum/develop
mainwindow: add standard shortcut for "refresh" button
2025-04-05 19:05:26 +02:00
Hg
a8a03321f2 mainwindow: add standard shortcut for "refresh" button 2025-04-05 09:07:48 +02:00
dries.k
490044d9a7 update widgets_settings design 2025-03-27 18:32:58 +01:00
seird
a3ae246580 Merge pull request #44 from hydrargyrum/patch-1
widget_settings.ui: allow minimum priority of 0
2025-03-27 18:27:10 +01:00
hydrargyrum
0730c160f6 widget_settings.ui: allow minimum priority of 0
it's a valid priority for gotify

closes #43
2025-03-27 10:45:21 +01:00
dries.k
ed815fb459 fix typo in setup.py 2024-10-29 17:15:07 +01:00
dries.k
05da3a8295 v0.5.3 2024-10-29 17:10:20 +01:00
dries.k
f40f154f30 remove linux builds 2024-10-29 16:58:20 +01:00
dries.k
4e34c5e614 update github actions 2024-10-29 16:52:18 +01:00
31 changed files with 932 additions and 231 deletions

View File

@@ -12,11 +12,11 @@ jobs:
build-win64:
runs-on: windows-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: '3.10.8'
python-version: '3.13'
- name: Upgrade pip and enable wheel support
run: python -m pip install --upgrade pip setuptools wheel
- name: Install Requirements
@@ -30,49 +30,19 @@ jobs:
mv inno-output\gotify-tray-installer.exe gotify-tray-installer-win.exe
shell: cmd
- name: Upload artifact
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: gotify-tray-installer-win.exe
path: gotify-tray-installer-win.exe
build-ubuntu:
strategy:
matrix:
tag: [jammy]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.10.8'
- name: Upgrade pip and enable wheel support
run: python -m pip install --upgrade pip setuptools wheel
- uses: ruby/setup-ruby@v1
with:
ruby-version: '3.0'
- name: Build
run: |
pip install -r requirements.txt
pip install pyinstaller
gem install fpm
chmod +x build-linux.sh
./build-linux.sh deb
mv "dist/gotify-tray_$(cat version.txt)_amd64.deb" "gotify-tray_$(cat version.txt)_amd64_${{ matrix.tag }}.deb"
- name: Upload artifact
uses: actions/upload-artifact@v3
with:
path: "gotify-tray_*_amd64_${{ matrix.tag }}.deb"
build-macos:
runs-on: macos-12
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: '3.10.8'
python-version: '3.13'
- name: Upgrade pip and enable wheel support
run: python -m pip install --upgrade pip setuptools wheel
- name: Build
@@ -81,7 +51,7 @@ jobs:
brew install create-dmg
create-dmg --volname "Gotify Tray" --app-drop-link 0 0 --no-internet-enable "gotify-tray.dmg" "./dist/Gotify Tray.app"
- name: Upload artifact
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: gotify-tray.dmg
path: gotify-tray.dmg
@@ -89,11 +59,11 @@ jobs:
build-pip:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: '3.10.8'
python-version: '3.13'
- name: Upgrade pip and enable wheel support
run: python -m pip install --upgrade pip setuptools wheel
- name: install requirements
@@ -103,6 +73,6 @@ jobs:
- name: create pip package
run: python -m build
- name: Upload artifact
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
path: dist/gotify_tray-*.whl

View File

@@ -10,11 +10,11 @@ jobs:
build-win64:
runs-on: windows-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: '3.10.8'
python-version: '3.13'
- name: Upgrade pip and enable wheel support
run: python -m pip install --upgrade pip setuptools wheel
- name: Install Requirements
@@ -28,49 +28,19 @@ jobs:
mv inno-output\gotify-tray-installer.exe gotify-tray-installer-win.exe
shell: cmd
- name: Upload artifact
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: gotify-tray-installer-win.exe
path: gotify-tray-installer-win.exe
build-debian:
strategy:
matrix:
tag: [bullseye, bookworm]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.10.8'
- name: Upgrade pip and enable wheel support
run: python -m pip install --upgrade pip setuptools wheel
- uses: ruby/setup-ruby@v1
with:
ruby-version: '3.0'
- name: Build
run: |
pip install -r requirements.txt
pip install pyinstaller
gem install fpm
chmod +x build-linux.sh
./build-linux.sh deb
mv "dist/gotify-tray_$(cat version.txt)_amd64.deb" "gotify-tray_$(cat version.txt)_amd64_${{ matrix.tag }}.deb"
- name: Upload artifact
uses: actions/upload-artifact@v3
with:
name: gotify-tray_${{github.ref_name}}_amd64_${{ matrix.tag }}.deb
path: gotify-tray_${{github.ref_name}}_amd64_${{ matrix.tag }}.deb
build-macos:
runs-on: macos-12
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: '3.10.8'
python-version: '3.13'
- name: Upgrade pip and enable wheel support
run: python -m pip install --upgrade pip setuptools wheel
- name: Build
@@ -79,7 +49,7 @@ jobs:
brew install create-dmg
create-dmg --volname "Gotify Tray" --app-drop-link 0 0 --no-internet-enable "gotify-tray.dmg" "./dist/Gotify Tray.app"
- name: Upload artifact
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: gotify-tray.dmg
path: gotify-tray.dmg
@@ -92,11 +62,11 @@ jobs:
permissions:
id-token: write
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: '3.10.8'
python-version: '3.13'
- name: Upgrade pip and enable wheel support
run: python -m pip install --upgrade pip setuptools wheel
- name: install requirements
@@ -108,17 +78,17 @@ jobs:
- name: upload to pypi
uses: pypa/gh-action-pypi-publish@release/v1
- name: Upload artifact
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: gotify_tray-${{github.ref_name}}-py3-none-any.whl
path: dist/gotify_tray-${{github.ref_name}}-py3-none-any.whl
release:
runs-on: ubuntu-latest
needs: [build-win64, build-debian, build-macos, pypi]
needs: [build-win64, build-macos, pypi]
steps:
- uses: actions/checkout@v3
- uses: actions/download-artifact@v3
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
- name: Release
uses: marvinpinto/action-automatic-releases@latest
with:
@@ -127,6 +97,4 @@ jobs:
files: |
gotify-tray-installer-win.exe
gotify-tray.dmg
gotify-tray_${{github.ref_name}}_amd64_bullseye.deb
gotify-tray_${{github.ref_name}}_amd64_bookworm.deb
gotify_tray-${{github.ref_name}}-py3-none-any.whl

22
AGENTS.md Normal file
View File

@@ -0,0 +1,22 @@
# Agent Guidelines
## Repository
This project uses a customized repository at `http://192.168.88.97:3000/kadu/gotify-tray-customized.git`. Clone from this repository only for the latest changes and customizations.
## Type Checking
Run `pyright .` in the project root to perform static type checking with Pyright. Address any critical errors before committing changes.
Note: Pyright may report many Qt-related type issues that are false positives due to PyQt6 stubs limitations. Focus on logical errors rather than Qt API mismatches.
To ignore PyQt6-related errors, create a `pyrightconfig.json` in the project root with:
```json
{
"reportOptionalMemberAccess": false,
"reportAttributeAccessIssue": false,
"reportIncompatibleMethodOverride": false,
"reportArgumentType": false,
"reportAssignmentType": false,
"reportReturnType": false
}
```
This suppresses common PyQt6 false positives while still catching real issues.

View File

@@ -7,6 +7,16 @@ $ pip install -r requirements.txt
$ pip install pyinstaller
```
**Alternative: System packages (Debian/Ubuntu)**
If you prefer to use system packages instead of pip, install the required PyQt6 packages:
```shell
$ apt install python3-pyqt6 python3-pyqt6.qtwebsockets python3-pyqt6.qtmultimedia
```
Note: This may require specific Python versions and may not include the latest features.
Currently it's only possible to create installer packages from the pyinstaller output. For any target platform, first create the executable with pyinstaller:
```shell

View File

@@ -30,6 +30,13 @@ A tray notification application for receiving messages from a [Gotify server](ht
- Go through a history of all previously received messages.
- Receive missed messages after losing network connection.
## Customizations
- **Persistent Notifications for Priority 10**: Messages with priority 10 display as persistent pop-up windows that stay on screen until clicked, with a flashing background for attention.
- **Sound Notification Control**: Option to play notification sound only for priority 10 messages.
- **Multiple Persistent Notifications**: Multiple priority 10 messages stack vertically and can be closed all at once by clicking any one or the tray icon.
- **Enhanced UI Settings**: Added configurable options for persistent notifications and sound behavior in the settings dialog.
## Images

0
build-linux.sh Normal file → Executable file
View File

13
build.sh Executable file
View File

@@ -0,0 +1,13 @@
#!/bin/bash
# Clean previous builds
rm -rf dist build
# Install requirements
pip install -r requirements.txt
pip install pyinstaller
# Build
pyinstaller gotify-tray.spec
echo "Build complete. Executable in dist/gotify-tray/"

View File

@@ -8,7 +8,7 @@ logo = "gotify_tray/gui/images/logo.ico" if platform.system() != "Darwin" else "
a = Analysis(['gotify_tray/__main__.py'],
pathex=[os.getcwd()],
binaries=[],
binaries=[('/lib/x86_64-linux-gnu/libpython3.12.so', '.'), ('/lib/x86_64-linux-gnu/libpython3.12.so.1', '.')],
datas=[('gotify_tray/gui/images', 'gotify_tray/gui/images'), ('gotify_tray/gui/themes', 'gotify_tray/gui/themes')],
hiddenimports=[],
hookspath=[],

View File

@@ -1,11 +1,21 @@
def main():
import os
import sys
if "--version" in sys.argv:
from gotify_tray.__version__ import __version__
print(__version__)
else:
# Check for display before importing GUI modules
if not os.environ.get("DISPLAY"):
print(
"Error: No display environment detected. This application requires a graphical desktop environment to run.",
file=sys.stderr,
)
sys.exit(1)
from gotify_tray.gui import start_gui
start_gui()

View File

@@ -3,4 +3,4 @@ __description__ = (
"A tray notification application for receiving messages from a Gotify server."
)
__url__ = "https://github.com/seird/gotify-tray"
__version__ = "0.5.2"
__version__ = "0.5.3"

View File

@@ -16,6 +16,8 @@ DEFAULT_SETTINGS = {
"tray/notifications/duration_ms": 5000,
"tray/notifications/icon/show": True,
"tray/notifications/click": True,
"tray/notifications/priority10_persistent": True,
"tray/notifications/sound_only_priority10": True,
"tray/icon/unread": False,
"watchdog/enabled": True,
"watchdog/interval/s": 60,

View File

@@ -8,6 +8,7 @@ import tempfile
from gotify_tray import gotify
from gotify_tray.__version__ import __title__
from gotify_tray.database import Downloader, Settings
from .widgets.PersistentNotification import PersistentNotification
from gotify_tray.tasks import (
ClearCacheTask,
DeleteApplicationMessagesTask,
@@ -22,6 +23,7 @@ from gotify_tray.tasks import (
from gotify_tray.gui.themes import set_theme
from gotify_tray.utils import get_icon, verify_server
from PyQt6 import QtCore, QtGui, QtWidgets
from PyQt6.QtMultimedia import QSoundEffect
from ..__version__ import __title__
from .models import (
@@ -32,6 +34,7 @@ from .models import (
ApplicationProxyModel,
MessagesModel,
MessagesModelItem,
MessagesProxyModel,
MessageItemDataRole,
)
from .widgets import ImagePopup, MainWindow, MessageWidget, SettingsDialog, Tray
@@ -47,7 +50,9 @@ def init_logger(logger: logging.Logger):
else:
logging.disable()
logdir = QtCore.QStandardPaths.standardLocations(QtCore.QStandardPaths.StandardLocation.AppDataLocation)[0]
logdir = QtCore.QStandardPaths.standardLocations(
QtCore.QStandardPaths.StandardLocation.AppDataLocation
)[0]
if not os.path.exists(logdir):
os.mkdir(logdir)
logging.basicConfig(
@@ -57,6 +62,28 @@ def init_logger(logger: logging.Logger):
class MainApplication(QtWidgets.QApplication):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Initialize notification sound effect
self.notification_sound = QSoundEffect()
sound_path = os.path.join(
os.path.dirname(__file__), "images", "notification.wav"
)
self.notification_sound.setSource(QtCore.QUrl.fromLocalFile(sound_path))
self.notification_sound.setVolume(0.5) # Set volume (0.0 to 1.0)
self.persistent_notifications = []
self.next_y_offset = 0
def close_all_persistent_notifications(self):
for notification in self.persistent_notifications:
notification.close()
self.persistent_notifications.clear()
self.next_y_offset = 0
def _on_tray_activated(self, reason):
if reason == QtWidgets.QSystemTrayIcon.ActivationReason.Trigger:
self.close_all_persistent_notifications()
def init_ui(self):
self.gotify_client = gotify.GotifyClient(
settings.value("Server/url", type=str),
@@ -66,10 +93,17 @@ class MainApplication(QtWidgets.QApplication):
self.downloader = Downloader()
self.messages_model = MessagesModel()
self.messages_proxy_model = MessagesProxyModel()
self.messages_proxy_model.setSourceModel(self.messages_model)
self.application_model = ApplicationModel()
self.application_proxy_model = ApplicationProxyModel(self.application_model)
self.main_window = MainWindow(self.application_model, self.application_proxy_model, self.messages_model)
self.main_window = MainWindow(
self.application_model,
self.application_proxy_model,
self.messages_model,
self.messages_proxy_model,
)
self.main_window.show() # The initial .show() is necessary to get the correct sizes when adding MessageWigets
QtCore.QTimer.singleShot(0, self.main_window.hide)
@@ -77,12 +111,16 @@ class MainApplication(QtWidgets.QApplication):
self.tray = Tray()
self.tray.show()
self.tray.activated.connect(self._on_tray_activated)
self.first_connect = True
self.watchdog = ServerConnectionWatchdogTask(self.gotify_client)
self.link_callbacks()
self.main_window.priority_filter_changed.connect(
self.on_priority_filter_changed
)
self.init_shortcuts()
self.gotify_client.listen()
@@ -100,17 +138,30 @@ class MainApplication(QtWidgets.QApplication):
self.application_model.setItem(0, 0, ApplicationAllMessagesItem())
self.get_applications_task = GetApplicationsTask(self.gotify_client)
self.get_applications_task.success.connect(self.get_applications_success_callback)
self.get_applications_task.started.connect(self.main_window.disable_applications)
self.get_applications_task.finished.connect(self.main_window.enable_applications)
self.get_applications_task.success.connect(
self.get_applications_success_callback
)
self.get_applications_task.started.connect(
self.main_window.disable_applications
)
self.get_applications_task.finished.connect(
self.main_window.enable_applications
)
self.get_applications_task.start()
def get_applications_success_callback(
self, applications: list[gotify.GotifyApplicationModel],
self,
applications: list[gotify.GotifyApplicationModel],
):
for i, application in enumerate(applications):
icon = QtGui.QIcon(self.downloader.get_filename(f"{self.gotify_client.url}/{application.image}"))
self.application_model.setItem(i + 1, 0, ApplicationModelItem(application, icon))
icon = QtGui.QIcon(
self.downloader.get_filename(
f"{self.gotify_client.url}/{application.image}"
)
)
self.application_model.setItem(
i + 1, 0, ApplicationModelItem(application, icon)
)
def update_last_id(self, i: int):
if i > settings.value("message/last", type=int):
@@ -157,6 +208,9 @@ class MainApplication(QtWidgets.QApplication):
else:
self.gotify_client.stop()
def on_priority_filter_changed(self, priorities: set[int]):
self.messages_proxy_model.set_allowed_priorities(priorities)
def abort_get_messages_task(self):
"""
Abort any tasks that will result in new messages getting appended to messages_model
@@ -174,15 +228,24 @@ class MainApplication(QtWidgets.QApplication):
for task in aborted_tasks:
task.wait()
def application_selection_changed_callback(self, item: ApplicationModelItem | ApplicationAllMessagesItem):
def application_selection_changed_callback(
self, item: ApplicationModelItem | ApplicationAllMessagesItem
):
self.main_window.disable_buttons()
self.abort_get_messages_task()
self.messages_model.clear()
if isinstance(item, ApplicationModelItem):
self.get_application_messages_task = GetApplicationMessagesTask(item.data(ApplicationItemDataRole.ApplicationRole).id, self.gotify_client)
self.get_application_messages_task.message.connect(self.messages_model.append_message)
self.get_application_messages_task.finished.connect(self.main_window.enable_buttons)
self.get_application_messages_task = GetApplicationMessagesTask(
item.data(ApplicationItemDataRole.ApplicationRole).id,
self.gotify_client,
)
self.get_application_messages_task.message.connect(
self.messages_model.append_message
)
self.get_application_messages_task.finished.connect(
self.main_window.enable_buttons
)
self.get_application_messages_task.start()
elif isinstance(item, ApplicationAllMessagesItem):
@@ -191,10 +254,14 @@ class MainApplication(QtWidgets.QApplication):
self.get_messages_task.finished.connect(self.main_window.enable_buttons)
self.get_messages_task.start()
def add_message_to_model(self, message: gotify.GotifyMessageModel, process: bool = True):
def add_message_to_model(
self, message: gotify.GotifyMessageModel, process: bool = True
):
if self.application_model.itemFromId(message.appid):
application_index = self.main_window.currentApplicationIndex()
if selected_application_item := self.application_model.itemFromIndex(self.application_proxy_model.mapToSource(application_index)):
if selected_application_item := self.application_model.itemFromIndex(
self.application_proxy_model.mapToSource(application_index)
):
def insert_message_helper():
if isinstance(selected_application_item, ApplicationModelItem):
@@ -202,10 +269,14 @@ class MainApplication(QtWidgets.QApplication):
# -> Only insert the message if the appid matches the selected appid
if (
message.appid
== selected_application_item.data(ApplicationItemDataRole.ApplicationRole).id
== selected_application_item.data(
ApplicationItemDataRole.ApplicationRole
).id
):
self.messages_model.insert_message(0, message)
elif isinstance(selected_application_item, ApplicationAllMessagesItem):
elif isinstance(
selected_application_item, ApplicationAllMessagesItem
):
# "All messages' is selected
self.messages_model.insert_message(0, message)
@@ -216,10 +287,14 @@ class MainApplication(QtWidgets.QApplication):
else:
insert_message_helper()
else:
logger.error(f"App id {message.appid} could not be found. Refreshing applications.")
logger.error(
f"App id {message.appid} could not be found. Refreshing applications."
)
self.refresh_applications()
def new_message_callback(self, message: gotify.GotifyMessageModel, process: bool = True):
def new_message_callback(
self, message: gotify.GotifyMessageModel, process: bool = True
):
self.add_message_to_model(message, process=process)
# Don't show a notification if it's low priority or the window is active
@@ -237,21 +312,52 @@ class MainApplication(QtWidgets.QApplication):
self.tray.set_icon_unread()
# Get the application icon
if (
settings.value("tray/notifications/icon/show", type=bool)
and (application_item := self.application_model.itemFromId(message.appid))
if settings.value("tray/notifications/icon/show", type=bool) and (
application_item := self.application_model.itemFromId(message.appid)
):
icon = application_item.icon()
else:
icon = QtWidgets.QSystemTrayIcon.MessageIcon.Information
# Show notification
if message.priority == 10 and settings.value(
"tray/notifications/priority10_persistent", type=bool
):
# Create persistent notification
notification = PersistentNotification(
message.title or "",
message.message or "",
icon,
y_offset=self.next_y_offset,
flash=True,
)
notification.close_all_requested.connect(
self.close_all_persistent_notifications
)
self.persistent_notifications.append(notification)
notification.show()
self.next_y_offset += notification.height() + 10
else:
# Use system tray notification
msecs = settings.value("tray/notifications/duration_ms", type=int)
self.tray.showMessage(
message.title,
message.message,
icon,
msecs=settings.value("tray/notifications/duration_ms", type=int),
msecs=msecs,
)
# Play notification sound
if (
not settings.value("tray/notifications/sound_only_priority10", type=bool)
or message.priority == 10
):
if self.notification_sound.isLoaded():
self.notification_sound.play()
else:
# Try to play anyway (QSoundEffect will queue if not loaded yet)
self.notification_sound.play()
def delete_message_callback(self, message_item: MessagesModelItem):
self.delete_message_task = DeleteMessageTask(
message_item.data(MessageItemDataRole.MessageRole).id, self.gotify_client
@@ -299,7 +405,11 @@ class MainApplication(QtWidgets.QApplication):
# Update the message widget icons
for r in range(self.messages_model.rowCount()):
message_widget: MessageWidget = self.main_window.listView_messages.indexWidget(self.messages_model.index(r, 0))
message_widget: MessageWidget = (
self.main_window.listView_messages.indexWidget(
self.messages_model.index(r, 0)
)
)
message_widget.set_icons()
def settings_callback(self):
@@ -341,15 +451,22 @@ class MainApplication(QtWidgets.QApplication):
self.main_window.refresh.connect(self.refresh_applications)
self.main_window.delete_all.connect(self.delete_all_messages_callback)
self.main_window.application_selection_changed.connect(self.application_selection_changed_callback)
self.main_window.application_selection_changed.connect(
self.application_selection_changed_callback
)
self.main_window.delete_message.connect(self.delete_message_callback)
self.main_window.image_popup.connect(self.image_popup_callback)
self.main_window.hidden.connect(self.main_window_hidden_callback)
self.main_window.activated.connect(self.tray.revert_icon)
self.styleHints().colorSchemeChanged.connect(self.theme_change_requested_callback)
if hasattr(self.styleHints(), "colorSchemeChanged"):
self.styleHints().colorSchemeChanged.connect(
self.theme_change_requested_callback
)
self.messages_model.rowsInserted.connect(self.main_window.display_message_widgets)
self.messages_model.rowsInserted.connect(
self.main_window.display_message_widgets
)
self.gotify_client.opened.connect(self.listener_opened_callback)
self.gotify_client.closed.connect(self.listener_closed_callback)
@@ -366,7 +483,9 @@ class MainApplication(QtWidgets.QApplication):
def acquire_lock(self) -> bool:
temp_dir = tempfile.gettempdir()
lock_filename = os.path.join(temp_dir, __title__ + "-" + getpass.getuser() + ".lock")
lock_filename = os.path.join(
temp_dir, __title__ + "-" + getpass.getuser() + ".lock"
)
self.lock_file = QtCore.QLockFile(lock_filename)
self.lock_file.setStaleLockTime(0)
return self.lock_file.tryLock()

View File

@@ -1,6 +1,6 @@
# Form implementation generated from reading ui file 'gotify_tray/gui/designs\widget_main.ui'
# Form implementation generated from reading ui file 'gotify_tray/gui/designs/widget_main.ui'
#
# Created by: PyQt6 UI code generator 6.5.0
# Created by: PyQt6 UI code generator 6.10.0
#
# WARNING: Any manual changes made to this file will be lost when pyuic6 is
# run again. Do not edit this file unless you know what you are doing.
@@ -47,6 +47,40 @@ class Ui_MainWindow(object):
self.pb_delete_all.setObjectName("pb_delete_all")
self.horizontalLayout.addWidget(self.pb_delete_all)
self.verticalLayout_2.addLayout(self.horizontalLayout)
self.filtersLayout = QtWidgets.QHBoxLayout()
self.filtersLayout.setObjectName("filtersLayout")
self.label_priority = QtWidgets.QLabel(parent=self.verticalLayoutWidget)
self.label_priority.setObjectName("label_priority")
self.filtersLayout.addWidget(self.label_priority)
self.pb_low = QtWidgets.QPushButton(parent=self.verticalLayoutWidget)
self.pb_low.setCheckable(True)
self.pb_low.setChecked(True)
self.pb_low.setObjectName("pb_low")
self.filtersLayout.addWidget(self.pb_low)
self.pb_normal = QtWidgets.QPushButton(parent=self.verticalLayoutWidget)
self.pb_normal.setCheckable(True)
self.pb_normal.setChecked(True)
self.pb_normal.setObjectName("pb_normal")
self.filtersLayout.addWidget(self.pb_normal)
self.pb_high = QtWidgets.QPushButton(parent=self.verticalLayoutWidget)
self.pb_high.setCheckable(True)
self.pb_high.setChecked(True)
self.pb_high.setObjectName("pb_high")
self.filtersLayout.addWidget(self.pb_high)
self.pb_critical = QtWidgets.QPushButton(parent=self.verticalLayoutWidget)
self.pb_critical.setCheckable(True)
self.pb_critical.setChecked(True)
self.pb_critical.setObjectName("pb_critical")
self.filtersLayout.addWidget(self.pb_critical)
self.le_search = QtWidgets.QLineEdit(parent=self.verticalLayoutWidget)
self.le_search.setObjectName("le_search")
self.filtersLayout.addWidget(self.le_search)
spacerItem2 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum)
self.filtersLayout.addItem(spacerItem2)
self.pb_remove_filters = QtWidgets.QPushButton(parent=self.verticalLayoutWidget)
self.pb_remove_filters.setObjectName("pb_remove_filters")
self.filtersLayout.addWidget(self.pb_remove_filters)
self.verticalLayout_2.addLayout(self.filtersLayout)
self.listView_messages = QtWidgets.QListView(parent=self.verticalLayoutWidget)
self.listView_messages.setAutoScroll(True)
self.listView_messages.setEditTriggers(QtWidgets.QAbstractItemView.EditTrigger.NoEditTriggers)
@@ -66,6 +100,13 @@ class Ui_MainWindow(object):
_translate = QtCore.QCoreApplication.translate
MainWindow.setWindowTitle(_translate("MainWindow", "Form"))
self.label_application.setText(_translate("MainWindow", "Application"))
self.label_priority.setText(_translate("MainWindow", "Priority:"))
self.pb_low.setText(_translate("MainWindow", "LOW"))
self.pb_normal.setText(_translate("MainWindow", "NORMAL"))
self.pb_high.setText(_translate("MainWindow", "HIGH"))
self.pb_critical.setText(_translate("MainWindow", "CRITICAL"))
self.le_search.setPlaceholderText(_translate("MainWindow", "Search messages..."))
self.pb_remove_filters.setText(_translate("MainWindow", "Remove Filters"))
if __name__ == "__main__":

View File

@@ -90,6 +90,96 @@
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="filtersLayout">
<item>
<widget class="QLabel" name="label_priority">
<property name="text">
<string>Priority:</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="pb_low">
<property name="text">
<string>LOW</string>
</property>
<property name="checkable">
<bool>true</bool>
</property>
<property name="checked">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="pb_normal">
<property name="text">
<string>NORMAL</string>
</property>
<property name="checkable">
<bool>true</bool>
</property>
<property name="checked">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="pb_high">
<property name="text">
<string>HIGH</string>
</property>
<property name="checkable">
<bool>true</bool>
</property>
<property name="checked">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="pb_critical">
<property name="text">
<string>CRITICAL</string>
</property>
<property name="checkable">
<bool>true</bool>
</property>
<property name="checked">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QLineEdit" name="le_search">
<property name="placeholderText">
<string>Search messages...</string>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer_3">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QPushButton" name="pb_remove_filters">
<property name="text">
<string>Remove Filters</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QListView" name="listView_messages">
<property name="autoScroll">

View File

@@ -1,6 +1,6 @@
# Form implementation generated from reading ui file 'gotify_tray/gui/designs\widget_message.ui'
# Form implementation generated from reading ui file 'gotify_tray/gui/designs/widget_message.ui'
#
# Created by: PyQt6 UI code generator 6.5.0
# Created by: PyQt6 UI code generator 6.10.0
#
# WARNING: Any manual changes made to this file will be lost when pyuic6 is
# run again. Do not edit this file unless you know what you are doing.

View File

@@ -1,6 +1,6 @@
# Form implementation generated from reading ui file 'gotify_tray/gui/designs\widget_server.ui'
# Form implementation generated from reading ui file 'gotify_tray/gui/designs/widget_server.ui'
#
# Created by: PyQt6 UI code generator 6.5.0
# Created by: PyQt6 UI code generator 6.10.0
#
# WARNING: Any manual changes made to this file will be lost when pyuic6 is
# run again. Do not edit this file unless you know what you are doing.

View File

@@ -1,6 +1,6 @@
# Form implementation generated from reading ui file 'gotify_tray/gui/designs/widget_settings.ui'
#
# Created by: PyQt6 UI code generator 6.4.2
# Created by: PyQt6 UI code generator 6.10.0
#
# WARNING: Any manual changes made to this file will be lost when pyuic6 is
# run again. Do not edit this file unless you know what you are doing.
@@ -51,7 +51,7 @@ class Ui_Dialog(object):
self.cb_notify.setObjectName("cb_notify")
self.gridLayout_4.addWidget(self.cb_notify, 2, 0, 1, 3)
self.spin_priority = QtWidgets.QSpinBox(parent=self.groupBox_notifications)
self.spin_priority.setMinimum(1)
self.spin_priority.setMinimum(0)
self.spin_priority.setMaximum(10)
self.spin_priority.setProperty("value", 5)
self.spin_priority.setObjectName("spin_priority")
@@ -62,6 +62,9 @@ class Ui_Dialog(object):
self.cb_tray_icon_unread = QtWidgets.QCheckBox(parent=self.groupBox_notifications)
self.cb_tray_icon_unread.setObjectName("cb_tray_icon_unread")
self.gridLayout_4.addWidget(self.cb_tray_icon_unread, 4, 0, 1, 3)
self.cb_priority10_persistent = QtWidgets.QCheckBox(parent=self.groupBox_notifications)
self.cb_priority10_persistent.setObjectName("cb_priority10_persistent")
self.gridLayout_4.addWidget(self.cb_priority10_persistent, 5, 0, 1, 3)
self.verticalLayout_4.addWidget(self.groupBox_notifications)
self.groupBox_2 = QtWidgets.QGroupBox(parent=self.tab_general)
self.groupBox_2.setObjectName("groupBox_2")
@@ -290,6 +293,7 @@ class Ui_Dialog(object):
self.cb_notify.setText(_translate("Dialog", "Show a notification for missed messages after reconnecting"))
self.label_notification_priority.setText(_translate("Dialog", "Minimum priority to show notifications:"))
self.cb_tray_icon_unread.setText(_translate("Dialog", "Change the tray icon color when there are unread notifications"))
self.cb_priority10_persistent.setText(_translate("Dialog", "Show persistent notifications for priority 10 messages"))
self.groupBox_2.setTitle(_translate("Dialog", "Interface"))
self.cb_priority_colors.setToolTip(_translate("Dialog", "4..7 -> medium\n"
"8..10 -> high"))

View File

@@ -97,7 +97,7 @@
<item row="0" column="1">
<widget class="QSpinBox" name="spin_priority">
<property name="minimum">
<number>1</number>
<number>0</number>
</property>
<property name="maximum">
<number>10</number>
@@ -121,6 +121,13 @@
</property>
</widget>
</item>
<item row="5" column="0" colspan="3">
<widget class="QCheckBox" name="cb_priority10_persistent">
<property name="text">
<string>Show persistent notifications for priority 10 messages</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>

Binary file not shown.

View File

@@ -1,5 +1,4 @@
import enum
from typing import cast
from PyQt6 import QtCore, QtGui
from gotify_tray import gotify
@@ -39,3 +38,34 @@ class MessagesModel(QtGui.QStandardItemModel):
def itemFromIndex(self, index: QtCore.QModelIndex) -> MessagesModelItem:
return cast(MessagesModelItem, super(MessagesModel, self).itemFromIndex(index))
class MessagesProxyModel(QtCore.QSortFilterProxyModel):
def __init__(self, parent=None):
super().__init__(parent)
self.allowed_priorities = set(range(11)) # 0-10
self.text_filter = ""
def set_allowed_priorities(self, priorities: set[int]):
self.allowed_priorities = priorities
self.invalidateFilter()
def set_text_filter(self, text: str):
self.text_filter = text.lower()
self.invalidateFilter()
def filterAcceptsRow(
self, source_row: int, source_parent: QtCore.QModelIndex
) -> bool:
index = self.sourceModel().index(source_row, 0, source_parent)
item = self.sourceModel().itemFromIndex(index)
message = item.data(MessageItemDataRole.MessageRole)
priority = message.priority if message.priority is not None else 0
if self.allowed_priorities and priority not in self.allowed_priorities:
return False
if self.text_filter:
title = (message.title or "").lower()
msg = (message.message or "").lower()
if self.text_filter not in title and self.text_filter not in msg:
return False
return True

View File

@@ -5,4 +5,9 @@ from .ApplicationModel import (
ApplicationProxyModel,
ApplicationItemDataRole,
)
from .MessagesModel import MessagesModelItem, MessagesModel, MessageItemDataRole
from .MessagesModel import (
MessagesModelItem,
MessagesModel,
MessagesProxyModel,
MessageItemDataRole,
)

View File

@@ -3,14 +3,21 @@ from gotify_tray.utils import get_abs_path
themes = {
QtCore.Qt.ColorScheme.Dark: "dark",
QtCore.Qt.ColorScheme.Light: "light",
QtCore.Qt.ColorScheme.Unknown: "light",
2: "dark", # Dark
1: "light", # Light
0: "light", # Unknown
}
def set_theme(app: QtWidgets.QApplication):
theme = themes.get(app.styleHints().colorScheme(), "light")
if hasattr(app.styleHints(), "colorScheme"):
color_scheme = app.styleHints().colorScheme()
theme = themes.get(
color_scheme.value if hasattr(color_scheme, "value") else color_scheme,
"light",
)
else:
theme = "light" # Default to light theme if colorScheme not available
stylesheet = ""
with open(get_abs_path(f"gotify_tray/gui/themes/base.qss"), "r") as f:
@@ -23,5 +30,12 @@ def set_theme(app: QtWidgets.QApplication):
def get_theme_file(file: str) -> str:
app = QtCore.QCoreApplication.instance()
theme = themes.get(app.styleHints().colorScheme(), "light")
if hasattr(app.styleHints(), "colorScheme"):
color_scheme = app.styleHints().colorScheme()
theme = themes.get(
color_scheme.value if hasattr(color_scheme, "value") else color_scheme,
"light",
)
else:
theme = "light" # Default to light theme if colorScheme not available
return get_abs_path(f"gotify_tray/gui/themes/{theme}/{file}")

View File

@@ -5,6 +5,7 @@ from ..models import (
ApplicationModel,
MessagesModel,
MessagesModelItem,
MessagesProxyModel,
)
from . import MessageWidget
@@ -26,8 +27,15 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
image_popup = QtCore.pyqtSignal(str, QtCore.QPoint)
hidden = QtCore.pyqtSignal()
activated = QtCore.pyqtSignal()
priority_filter_changed = QtCore.pyqtSignal(set)
def __init__(self, application_model: ApplicationModel, application_proxy_model: QtCore.QSortFilterProxyModel, messages_model: MessagesModel):
def __init__(
self,
application_model: ApplicationModel,
application_proxy_model: QtCore.QSortFilterProxyModel,
messages_model: MessagesModel,
messages_proxy_model: MessagesProxyModel,
):
super(MainWindow, self).__init__()
self.setupUi(self)
@@ -38,9 +46,13 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
self.application_model = application_model
self.application_proxy_model = application_proxy_model
self.messages_model = messages_model
self.messages_proxy_model = messages_proxy_model
self.listView_applications.setModel(application_proxy_model)
self.listView_messages.setModel(messages_model)
self.listView_messages.setModel(messages_proxy_model)
self.messages_proxy_model.rowsInserted.connect(self.display_message_widgets)
self.messages_proxy_model.layoutChanged.connect(self.redisplay_message_widgets)
# Do not expand the applications listview when resizing
self.splitter.setStretchFactor(0, 0)
@@ -69,6 +81,33 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
self.link_callbacks()
# Setup filters
self.pb_low.toggled.connect(self.on_priority_button_toggled)
self.pb_normal.toggled.connect(self.on_priority_button_toggled)
self.pb_high.toggled.connect(self.on_priority_button_toggled)
# Critical is always shown, no toggle
# Set styles for priority buttons
button_style = "QPushButton { background-color: grey; border: 1px solid black; } QPushButton:checked { background-color: green; }"
self.pb_low.setStyleSheet(button_style)
self.pb_normal.setStyleSheet(button_style)
self.pb_high.setStyleSheet(button_style)
# Critical always green
self.pb_critical.setStyleSheet(
"QPushButton { background-color: green; border: 1px solid black; }"
)
self.pb_critical.setChecked(True)
self.pb_critical.setCheckable(False)
self.pb_remove_filters.clicked.connect(self.on_remove_filters_clicked)
self.le_search.returnPressed.connect(self.on_search_return_pressed)
# set refresh shortcut (usually ctrl-r)
# unfortunately this cannot be done with designer
self.pb_refresh.setShortcut(
QtGui.QKeySequence(QtGui.QKeySequence.StandardKey.Refresh)
)
def set_icons(self):
# Set button icons
self.pb_refresh.setIcon(QtGui.QIcon(get_theme_file("refresh.svg")))
@@ -102,20 +141,36 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
def set_error(self):
self.status_widget.set_error()
def display_message_widgets(self, parent: QtCore.QModelIndex, first: int, last: int):
for i in range(first, last+1):
if index := self.messages_model.index(i, 0, parent):
message_item = self.messages_model.itemFromIndex(index)
message: gotify.GotifyMessageModel = self.messages_model.data(index, MessageItemDataRole.MessageRole)
def display_message_widgets(
self, parent: QtCore.QModelIndex, first: int, last: int
):
for i in range(first, last + 1):
if proxy_index := self.messages_proxy_model.index(i, 0, parent):
source_index = self.messages_proxy_model.mapToSource(proxy_index)
message_item = self.messages_model.itemFromIndex(source_index)
message: gotify.GotifyMessageModel = message_item.data(
MessageItemDataRole.MessageRole
)
application_item = self.application_model.itemFromId(message.appid)
message_widget = MessageWidget(self.listView_messages, message_item, icon=application_item.icon())
message_widget = MessageWidget(
self.listView_messages, message_item, icon=application_item.icon()
)
message_widget.deletion_requested.connect(self.delete_message.emit)
message_widget.image_popup.connect(self.image_popup.emit)
self.listView_messages.setIndexWidget(index, message_widget)
self.listView_messages.setIndexWidget(proxy_index, message_widget)
def redisplay_message_widgets(self):
# Clear existing widgets
for row in range(self.messages_proxy_model.rowCount()):
index = self.messages_proxy_model.index(row, 0)
self.listView_messages.setIndexWidget(index, None)
# Redisplay for current visible rows
self.display_message_widgets(
QtCore.QModelIndex(), 0, self.messages_proxy_model.rowCount() - 1
)
def currentApplicationIndex(self) -> QtCore.QModelIndex:
return self.listView_applications.selectionModel().currentIndex()
@@ -123,13 +178,15 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
def application_selection_changed_callback(
self, current: QtCore.QModelIndex, previous: QtCore.QModelIndex
):
if item := self.application_model.itemFromIndex(self.application_proxy_model.mapToSource(current)):
if item := self.application_model.itemFromIndex(
self.application_proxy_model.mapToSource(current)
):
self.label_application.setText(item.text())
self.application_selection_changed.emit(item)
def delete_all_callback(self):
if (
self.messages_model.rowCount() == 0
self.messages_proxy_model.rowCount() == 0
or QtWidgets.QMessageBox.warning(
self,
"Are you sure?",
@@ -143,7 +200,9 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
return
index = self.currentApplicationIndex()
if item := self.application_model.itemFromIndex(self.application_proxy_model.mapToSource(index)):
if item := self.application_model.itemFromIndex(
self.application_proxy_model.mapToSource(index)
):
self.delete_all.emit(item)
def disable_applications(self):
@@ -152,7 +211,9 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
def enable_applications(self):
self.listView_applications.setEnabled(True)
self.listView_applications.setCurrentIndex(self.application_proxy_model.index(0, 0))
self.listView_applications.setCurrentIndex(
self.application_proxy_model.index(0, 0)
)
def disable_buttons(self):
self.pb_delete_all.setDisabled(True)
@@ -177,7 +238,9 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
self.pb_refresh.clicked.connect(self.refresh.emit)
self.pb_delete_all.clicked.connect(self.delete_all_callback)
self.listView_applications.selectionModel().currentChanged.connect(self.application_selection_changed_callback)
self.listView_applications.selectionModel().currentChanged.connect(
self.application_selection_changed_callback
)
def store_state(self):
settings.setValue("MainWindow/geometry", self.saveGeometry())
@@ -202,3 +265,27 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
self.activated.emit()
return super().eventFilter(object, event)
def on_priority_button_toggled(self, checked):
priorities = {10} # Critical always included
if self.pb_low.isChecked():
priorities.update({0, 1, 2, 3})
if self.pb_normal.isChecked():
priorities.update({4, 5, 6, 7, 8})
if self.pb_high.isChecked():
priorities.add(9)
self.priority_filter_changed.emit(priorities)
def on_remove_filters_clicked(self):
# Reset priority buttons
self.pb_low.setChecked(True)
self.pb_normal.setChecked(True)
self.pb_high.setChecked(True)
# Critical is always on
# Clear search
self.le_search.clear()
self.messages_proxy_model.set_text_filter("")
def on_search_return_pressed(self):
text = self.le_search.text()
self.messages_proxy_model.set_text_filter(text)

View File

@@ -0,0 +1,145 @@
import logging
from PyQt6 import QtCore, QtGui, QtWidgets
logger = logging.getLogger("gotify-tray")
class PersistentNotification(QtWidgets.QWidget):
close_all_requested = QtCore.pyqtSignal()
def __init__(
self,
title: str,
message: str,
icon: QtGui.QIcon | QtWidgets.QSystemTrayIcon.MessageIcon,
y_offset: int = 0,
flash: bool = False,
parent=None,
):
super().__init__(parent)
self.y_offset = y_offset
self.flash = flash
self.flash_state = False
self.original_stylesheet = ""
self.setWindowFlags(
QtCore.Qt.WindowType.Window
| QtCore.Qt.WindowType.FramelessWindowHint
| QtCore.Qt.WindowType.WindowStaysOnTopHint
)
self.setAttribute(QtCore.Qt.WidgetAttribute.WA_OpaquePaintEvent, True)
self.setAttribute(QtCore.Qt.WidgetAttribute.WA_ShowWithoutActivating)
# Layout
layout = QtWidgets.QHBoxLayout(self)
layout.setContentsMargins(10, 10, 10, 10)
layout.setSpacing(10)
# Icon
self.icon_label = QtWidgets.QLabel()
if isinstance(icon, QtGui.QIcon):
self.icon_label.setPixmap(icon.pixmap(32, 32))
else:
# For MessageIcon
if icon == QtWidgets.QSystemTrayIcon.MessageIcon.Information:
standard_icon = QtWidgets.QStyle.StandardPixmap.SP_MessageBoxInformation
elif icon == QtWidgets.QSystemTrayIcon.MessageIcon.Warning:
standard_icon = QtWidgets.QStyle.StandardPixmap.SP_MessageBoxWarning
elif icon == QtWidgets.QSystemTrayIcon.MessageIcon.Critical:
standard_icon = QtWidgets.QStyle.StandardPixmap.SP_MessageBoxCritical
else:
standard_icon = QtWidgets.QStyle.StandardPixmap.SP_MessageBoxInformation
system_icon = self.style().standardIcon(standard_icon)
self.icon_label.setPixmap(system_icon.pixmap(32, 32))
layout.addWidget(self.icon_label)
# Text
text_layout = QtWidgets.QVBoxLayout()
self.title_label = QtWidgets.QLabel(title)
self.title_label.setStyleSheet("font-weight: bold;")
text_layout.addWidget(self.title_label)
self.message_label = QtWidgets.QLabel(message)
self.message_label.setWordWrap(True)
text_layout.addWidget(self.message_label)
layout.addLayout(text_layout, 1)
# Close button
self.close_button = QtWidgets.QPushButton("×")
self.close_button.setFixedSize(20, 20)
self.close_button.clicked.connect(self._on_close)
layout.addWidget(self.close_button)
# Style
self.setAutoFillBackground(True)
self.setBackgroundRole(QtGui.QPalette.ColorRole.Window)
palette = self.palette()
palette.setColor(QtGui.QPalette.ColorRole.Window, QtGui.QColor("white"))
palette.setColor(QtGui.QPalette.ColorRole.WindowText, QtGui.QColor("red"))
self.setPalette(palette)
self.setStyleSheet("""
PersistentNotification {
border-radius: 10px;
border: 1px solid rgba(100, 100, 100, 200);
}
QPushButton {
background-color: transparent;
color: white;
border: none;
font-size: 16px;
}
QPushButton:hover {
background-color: rgba(100, 100, 100, 100);
}
""")
if self.flash:
self.flash_timer = QtCore.QTimer(self)
self.flash_timer.timeout.connect(self._toggle_flash)
# Size and position
self.adjustSize()
self.setFixedWidth(300)
self._position()
# Timer for fade out if not clicked, but since persistent, maybe not needed
# But to avoid staying forever if forgotten, perhaps auto-close after long time
# But user wants until clicked, so no timer.
def _position(self):
screen = QtWidgets.QApplication.primaryScreen().availableGeometry()
# Stack from bottom right
self.move(
screen.width() - self.width() - 10,
screen.height() - self.height() - 50 - self.y_offset,
)
def _toggle_flash(self):
self.flash_state = not self.flash_state
palette = self.palette()
if self.flash_state:
palette.setColor(QtGui.QPalette.ColorRole.Window, QtGui.QColor("red"))
palette.setColor(QtGui.QPalette.ColorRole.WindowText, QtGui.QColor("white"))
else:
palette.setColor(QtGui.QPalette.ColorRole.Window, QtGui.QColor("white"))
palette.setColor(QtGui.QPalette.ColorRole.WindowText, QtGui.QColor("red"))
self.setPalette(palette)
self.update()
self.repaint()
def mousePressEvent(self, event):
self._on_close()
def _on_close(self):
if self.flash:
self.flash_timer.stop()
self.close_all_requested.emit()
self.close()
def showEvent(self, event):
super().showEvent(event)
self.raise_()
if self.flash:
self.flash_timer.start(1000)

View File

@@ -41,29 +41,51 @@ class SettingsDialog(QtWidgets.QDialog, Ui_Dialog):
self.link_callbacks()
def initUI(self):
self.buttonBox.button(QtWidgets.QDialogButtonBox.StandardButton.Apply).setEnabled(False)
self.buttonBox.button(
QtWidgets.QDialogButtonBox.StandardButton.Apply
).setEnabled(False)
# Notifications
self.spin_priority.setValue(settings.value("tray/notifications/priority", type=int))
self.spin_priority.setValue(
settings.value("tray/notifications/priority", type=int)
)
self.spin_duration.setValue(settings.value("tray/notifications/duration_ms", type=int))
self.spin_duration.setValue(
settings.value("tray/notifications/duration_ms", type=int)
)
if platform.system() == "Windows":
# The notification duration setting is ignored by windows
self.label_notification_duration.hide()
self.spin_duration.hide()
self.label_notification_duration_ms.hide()
self.cb_notify.setChecked(settings.value("message/check_missed/notify", type=bool))
self.cb_notify.setChecked(
settings.value("message/check_missed/notify", type=bool)
)
self.cb_notification_click.setChecked(settings.value("tray/notifications/click", type=bool))
self.cb_notification_click.setChecked(
settings.value("tray/notifications/click", type=bool)
)
self.cb_tray_icon_unread.setChecked(settings.value("tray/icon/unread", type=bool))
self.cb_tray_icon_unread.setChecked(
settings.value("tray/icon/unread", type=bool)
)
self.cb_priority10_persistent.setChecked(
settings.value("tray/notifications/priority10_persistent", type=bool)
)
# Interface
self.cb_priority_colors.setChecked(settings.value("MessageWidget/priority_color", type=bool))
self.cb_image_urls.setChecked(settings.value("MessageWidget/image_urls", type=bool))
self.cb_priority_colors.setChecked(
settings.value("MessageWidget/priority_color", type=bool)
)
self.cb_image_urls.setChecked(
settings.value("MessageWidget/image_urls", type=bool)
)
self.cb_locale.setChecked(settings.value("locale", type=bool))
self.cb_sort_applications.setChecked(settings.value("ApplicationModel/sort", type=bool))
self.cb_sort_applications.setChecked(
settings.value("ApplicationModel/sort", type=bool)
)
# Logging
self.combo_logging.addItems(
@@ -81,18 +103,22 @@ class SettingsDialog(QtWidgets.QDialog, Ui_Dialog):
self.add_message_widget()
# Advanced
self.groupbox_image_popup.setChecked(settings.value("ImagePopup/enabled", type=bool))
self.groupbox_image_popup.setChecked(
settings.value("ImagePopup/enabled", type=bool)
)
self.spin_popup_w.setValue(settings.value("ImagePopup/w", type=int))
self.spin_popup_h.setValue(settings.value("ImagePopup/h", type=int))
self.label_cache.setText("0 MB")
self.compute_cache_size()
self.groupbox_watchdog.setChecked(settings.value("watchdog/enabled", type=bool))
self.spin_watchdog_interval.setValue(settings.value("watchdog/interval/s", type=int))
self.spin_watchdog_interval.setValue(
settings.value("watchdog/interval/s", type=int)
)
self.label_app_version.setText(__version__)
self.label_qt_version.setText(QtCore.QT_VERSION_STR)
self.label_app_icon.setPixmap(QtGui.QIcon(get_image("logo.ico")).pixmap(22,22))
self.label_qt_icon.setPixmap(QtGui.QIcon(get_image("qt.png")).pixmap(22,22))
self.label_app_icon.setPixmap(QtGui.QIcon(get_image("logo.ico")).pixmap(22, 22))
self.label_qt_icon.setPixmap(QtGui.QIcon(get_image("qt.png")).pixmap(22, 22))
def add_message_widget(self):
self.message_widget = MessageWidget(
@@ -113,18 +139,18 @@ class SettingsDialog(QtWidgets.QDialog, Ui_Dialog):
def compute_cache_size(self):
self.cache_size_task = CacheSizeTask()
self.cache_size_task.size.connect(lambda size: self.label_cache.setText(f"{round(size/1e6, 1)} MB"))
self.cache_size_task.size.connect(
lambda size: self.label_cache.setText(f"{round(size / 1e6, 1)} MB")
)
self.cache_size_task.start()
def set_value(self, key: str, value: Any, widget: QtWidgets.QWidget):
"""Set a Settings value, only if the widget's value_changed attribute has been set
"""
"""Set a Settings value, only if the widget's value_changed attribute has been set"""
if hasattr(widget, "value_changed"):
settings.setValue(key, value)
def connect_signal(self, signal: QtCore.pyqtBoundSignal, widget: QtWidgets.QWidget):
"""Connect to a signal and set the value_changed attribute for a widget on trigger
"""
"""Connect to a signal and set the value_changed attribute for a widget on trigger"""
signal.connect(lambda *args: self.setting_changed_callback(widget))
def change_server_info_callback(self):
@@ -132,13 +158,17 @@ class SettingsDialog(QtWidgets.QDialog, Ui_Dialog):
def setting_changed_callback(self, widget: QtWidgets.QWidget):
self.settings_changed = True
self.buttonBox.button(QtWidgets.QDialogButtonBox.StandardButton.Apply).setEnabled(True)
self.buttonBox.button(
QtWidgets.QDialogButtonBox.StandardButton.Apply
).setEnabled(True)
setattr(widget, "value_changed", True)
def change_font_callback(self, name: str):
label: QtWidgets.QLabel = getattr(self.message_widget, "label_" + name)
font, accepted = QtWidgets.QFontDialog.getFont(label.font(), self, f"Select a {name} font")
font, accepted = QtWidgets.QFontDialog.getFont(
label.font(), self, f"Select a {name} font"
)
if accepted:
self.setting_changed_callback(label)
@@ -146,7 +176,10 @@ class SettingsDialog(QtWidgets.QDialog, Ui_Dialog):
def export_callback(self):
fname = QtWidgets.QFileDialog.getSaveFileName(
self, "Export Settings", settings.value("export/path", type=str), "*",
self,
"Export Settings",
settings.value("export/path", type=str),
"*",
)[0]
if fname and os.path.exists(os.path.dirname(fname)):
self.export_settings_task = ExportSettingsTask(fname)
@@ -162,7 +195,10 @@ class SettingsDialog(QtWidgets.QDialog, Ui_Dialog):
def import_callback(self):
fname = QtWidgets.QFileDialog.getOpenFileName(
self, "Import Settings", settings.value("export/path", type=str), "*",
self,
"Import Settings",
settings.value("export/path", type=str),
"*",
)[0]
if fname and os.path.exists(fname):
self.import_settings_task = ImportSettingsTask(fname)
@@ -202,60 +238,130 @@ class SettingsDialog(QtWidgets.QDialog, Ui_Dialog):
self.label_cache.setText("0 MB")
def link_callbacks(self):
self.buttonBox.button(QtWidgets.QDialogButtonBox.StandardButton.Apply).clicked.connect(self.apply_settings)
self.buttonBox.button(
QtWidgets.QDialogButtonBox.StandardButton.Apply
).clicked.connect(self.apply_settings)
# Notifications
self.connect_signal(self.spin_priority.valueChanged, self.spin_priority)
self.connect_signal(self.spin_duration.valueChanged, self.spin_duration)
self.connect_signal(self.cb_notify.stateChanged, self.cb_notify)
self.connect_signal(self.cb_notification_click.stateChanged, self.cb_notification_click)
self.connect_signal(self.cb_tray_icon_unread.stateChanged, self.cb_tray_icon_unread)
self.connect_signal(
self.cb_notification_click.stateChanged, self.cb_notification_click
)
self.connect_signal(
self.cb_tray_icon_unread.stateChanged, self.cb_tray_icon_unread
)
self.connect_signal(
self.cb_priority10_persistent.stateChanged, self.cb_priority10_persistent
)
# Interface
self.connect_signal(self.cb_priority_colors.stateChanged, self.cb_priority_colors)
self.connect_signal(
self.cb_priority_colors.stateChanged, self.cb_priority_colors
)
self.connect_signal(self.cb_image_urls.stateChanged, self.cb_image_urls)
self.connect_signal(self.cb_locale.stateChanged, self.cb_locale)
self.connect_signal(self.cb_sort_applications.stateChanged, self.cb_sort_applications)
self.connect_signal(
self.cb_sort_applications.stateChanged, self.cb_sort_applications
)
# Server info
self.pb_change_server_info.clicked.connect(self.change_server_info_callback)
# Logging
self.connect_signal(self.combo_logging.currentTextChanged, self.combo_logging)
self.pb_open_log.clicked.connect(lambda: open_file(logger.root.handlers[0].baseFilename))
self.pb_open_log.clicked.connect(
lambda: open_file(logger.root.handlers[0].baseFilename)
)
# Fonts
self.pb_reset_fonts.clicked.connect(self.reset_fonts_callback)
self.pb_font_message_title.clicked.connect(lambda: self.change_font_callback("title"))
self.pb_font_message_date.clicked.connect(lambda: self.change_font_callback("date"))
self.pb_font_message_content.clicked.connect(lambda: self.change_font_callback("message"))
self.pb_font_message_title.clicked.connect(
lambda: self.change_font_callback("title")
)
self.pb_font_message_date.clicked.connect(
lambda: self.change_font_callback("date")
)
self.pb_font_message_content.clicked.connect(
lambda: self.change_font_callback("message")
)
# Advanced
self.pb_export.clicked.connect(self.export_callback)
self.pb_import.clicked.connect(self.import_callback)
self.pb_reset.clicked.connect(self.reset_callback)
self.connect_signal(self.groupbox_image_popup.toggled, self.groupbox_image_popup)
self.connect_signal(
self.groupbox_image_popup.toggled, self.groupbox_image_popup
)
self.connect_signal(self.spin_popup_w.valueChanged, self.spin_popup_w)
self.connect_signal(self.spin_popup_h.valueChanged, self.spin_popup_h)
self.pb_clear_cache.clicked.connect(self.clear_cache_callback)
self.pb_open_cache_dir.clicked.connect(lambda: open_file(Cache().directory()))
self.connect_signal(self.groupbox_watchdog.toggled, self.groupbox_watchdog)
self.connect_signal(self.spin_watchdog_interval.valueChanged, self.spin_watchdog_interval)
self.connect_signal(
self.spin_watchdog_interval.valueChanged, self.spin_watchdog_interval
)
def apply_settings(self):
# Priority
self.set_value("tray/notifications/priority", self.spin_priority.value(), self.spin_priority)
self.set_value("tray/notifications/duration_ms", self.spin_duration.value(), self.spin_duration)
self.set_value("message/check_missed/notify", self.cb_notify.isChecked(), self.cb_notify)
self.set_value("tray/notifications/click", self.cb_notification_click.isChecked(), self.cb_notification_click)
self.set_value("tray/icon/unread", self.cb_tray_icon_unread.isChecked(), self.cb_tray_icon_unread)
self.set_value(
"tray/notifications/priority",
self.spin_priority.value(),
self.spin_priority,
)
self.set_value(
"tray/notifications/duration_ms",
self.spin_duration.value(),
self.spin_duration,
)
self.set_value(
"message/check_missed/notify", self.cb_notify.isChecked(), self.cb_notify
)
self.set_value(
"tray/notifications/click",
self.cb_notification_click.isChecked(),
self.cb_notification_click,
)
self.set_value(
"tray/icon/unread",
self.cb_tray_icon_unread.isChecked(),
self.cb_tray_icon_unread,
)
self.set_value(
"tray/notifications/priority10_persistent",
self.cb_priority10_persistent.isChecked(),
self.cb_priority10_persistent,
)
# Interface
self.set_value("MessageWidget/priority_color", self.cb_priority_colors.isChecked(), self.cb_priority_colors)
self.set_value("MessageWidget/image_urls", self.cb_image_urls.isChecked(), self.cb_image_urls)
self.set_value(
"tray/notifications/priority10_persistent",
True, # Always persistent for priority 10
None,
)
self.set_value(
"tray/notifications/sound_only_priority10",
False, # Not sound only
None,
)
self.set_value(
"MessageWidget/priority_color",
self.cb_priority_colors.isChecked(),
self.cb_priority_colors,
)
self.set_value(
"MessageWidget/image_urls",
self.cb_image_urls.isChecked(),
self.cb_image_urls,
)
self.set_value("locale", self.cb_locale.isChecked(), self.cb_locale)
self.set_value("ApplicationModel/sort", self.cb_sort_applications.isChecked(), self.cb_sort_applications)
self.set_value(
"ApplicationModel/sort",
self.cb_sort_applications.isChecked(),
self.cb_sort_applications,
)
# Logging
selected_level = self.combo_logging.currentText()
@@ -267,18 +373,44 @@ class SettingsDialog(QtWidgets.QDialog, Ui_Dialog):
logger.setLevel(selected_level)
# Fonts
self.set_value("MessageWidget/font/title", self.message_widget.label_title.font().toString(), self.message_widget.label_title)
self.set_value("MessageWidget/font/date", self.message_widget.label_date.font().toString(), self.message_widget.label_date)
self.set_value("MessageWidget/font/message", self.message_widget.label_message.font().toString(), self.message_widget.label_message)
self.set_value(
"MessageWidget/font/title",
self.message_widget.label_title.font().toString(),
self.message_widget.label_title,
)
self.set_value(
"MessageWidget/font/date",
self.message_widget.label_date.font().toString(),
self.message_widget.label_date,
)
self.set_value(
"MessageWidget/font/message",
self.message_widget.label_message.font().toString(),
self.message_widget.label_message,
)
# Advanced
self.set_value("ImagePopup/enabled", self.groupbox_image_popup.isChecked(), self.groupbox_image_popup)
self.set_value(
"ImagePopup/enabled",
self.groupbox_image_popup.isChecked(),
self.groupbox_image_popup,
)
self.set_value("ImagePopup/w", self.spin_popup_w.value(), self.spin_popup_w)
self.set_value("ImagePopup/h", self.spin_popup_h.value(), self.spin_popup_h)
self.set_value("watchdog/enabled", self.groupbox_watchdog.isChecked(), self.groupbox_watchdog)
self.set_value("watchdog/interval/s", self.spin_watchdog_interval.value(), self.spin_watchdog_interval)
self.set_value(
"watchdog/enabled",
self.groupbox_watchdog.isChecked(),
self.groupbox_watchdog,
)
self.set_value(
"watchdog/interval/s",
self.spin_watchdog_interval.value(),
self.spin_watchdog_interval,
)
self.settings_changed = False
self.buttonBox.button(QtWidgets.QDialogButtonBox.StandardButton.Apply).setEnabled(False)
self.buttonBox.button(
QtWidgets.QDialogButtonBox.StandardButton.Apply
).setEnabled(False)
self.changes_applied = True

8
pyrightconfig.json Normal file
View File

@@ -0,0 +1,8 @@
{
"reportOptionalMemberAccess": false,
"reportAttributeAccessIssue": false,
"reportIncompatibleMethodOverride": false,
"reportArgumentType": false,
"reportAssignmentType": false,
"reportReturnType": false
}

View File

@@ -1,2 +1,3 @@
requests==2.32.3
pyqt6==6.7.1
requests
pyqt6>=6.7.1
pyqt6-qt6

15
run.sh Executable file
View File

@@ -0,0 +1,15 @@
#!/bin/bash
# Check if virtual environment exists
if [ ! -d "venv" ]; then
echo "Creating virtual environment..."
python3 -m venv venv
echo "Installing dependencies..."
source venv/bin/activate
pip install -r requirements.txt
else
source venv/bin/activate
fi
# Run the application
python -m gotify_tray

View File

@@ -15,7 +15,7 @@ with open("version.txt", "r") as f:
# What packages are required for this module to be executed?
REQUIRED = [
'requests==2.32.3', 'pyqt6==6.7.1'
'requests', 'pyqt6>=6.7.1'
]
# What packages are optional?
@@ -86,6 +86,7 @@ setup(
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.10',
'Programming Language :: Python :: 3.11',
'Programming Language :: Python :: 3.12'
'Programming Language :: Python :: 3.12',
'Programming Language :: Python :: 3.13'
]
)

View File

@@ -6,8 +6,8 @@ VSVersionInfo(
ffi=FixedFileInfo(
# filevers and prodvers should be always a tuple with four items: (1, 2, 3, 4)
# Set not needed items to zero 0.
filevers=(0, 5, 2, 0),
prodvers=(0, 5, 2, 0),
filevers=(0, 5, 3, 0),
prodvers=(0, 5, 3, 0),
# Contains a bitmask that specifies the valid bits 'flags'r
mask=0x3F,
# Contains a bitmask that specifies the Boolean attributes of the file.
@@ -34,12 +34,12 @@ VSVersionInfo(
StringStruct(u"Comments", u"Gotify Tray"),
StringStruct(u"CompanyName", u""),
StringStruct(u"FileDescription", u"Gotifiy Tray"),
StringStruct(u"FileVersion", u"0.5.2"),
StringStruct(u"FileVersion", u"0.5.3"),
StringStruct(u"InternalName", u"gotify-tray"),
StringStruct(u"LegalCopyright", u""),
StringStruct(u"OriginalFilename", u"gotify-tray.exe"),
StringStruct(u"ProductName", u"Gotify Tray"),
StringStruct(u"ProductVersion", u"0.5.2"),
StringStruct(u"ProductVersion", u"0.5.3"),
],
)
]

View File

@@ -1 +1 @@
0.5.2
0.5.3