diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 4fcf50c..419bf55 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -10,6 +10,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
+ with:
+ persist-credentials: false
- uses: actions/setup-python@v6
with:
python-version: "3.11"
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index b91ede2..1b4cb76 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -8,11 +8,43 @@ on:
jobs:
test:
- runs-on: ubuntu-latest
strategy:
+ fail-fast: false
matrix:
+ os: [ubuntu-latest, macos-latest]
python-version: ["3.11", "3.12", "3.13", "3.14"]
+ runs-on: ${{ matrix.os }}
+ env:
+ CI: "true"
steps:
- uses: actions/checkout@v6
- - uses: astral-sh/setup-uv@v7
- - run: uv run --python ${{ matrix.python-version }} --with pytest python -m pytest
+ with:
+ persist-credentials: false
+ - uses: astral-sh/setup-uv@v8.0.0
+ - name: Run tests
+ run: >
+ uv run --python ${{ matrix.python-version }}
+ --with pytest --with pytest-cov
+ python -m pytest
+ --cov=hackertray
+ --cov-report=lcov:coverage.lcov
+ --cov-report=markdown-append:$GITHUB_STEP_SUMMARY
+ - name: Upload to Coveralls
+ if: matrix.python-version == '3.14'
+ uses: coverallsapp/github-action@v2
+ with:
+ file: coverage.lcov
+ format: lcov
+ flag-name: run-${{ matrix.os }}-${{ matrix.python-version }}
+ parallel: true
+
+ coveralls-finish:
+ needs: test
+ if: ${{ always() }}
+ runs-on: ubuntu-latest
+ steps:
+ - name: Coveralls finished
+ uses: coverallsapp/github-action@v2
+ with:
+ parallel-finished: true
+ carryforward: "run-ubuntu-latest-3.14,run-macos-latest-3.14"
diff --git a/.gitmodules b/.gitmodules
deleted file mode 100644
index 1e7a990..0000000
--- a/.gitmodules
+++ /dev/null
@@ -1,3 +0,0 @@
-[submodule "shared-modules"]
- path = shared-modules
- url = https://github.com/flathub/shared-modules.git
diff --git a/.python-version b/.python-version
new file mode 100644
index 0000000..6324d40
--- /dev/null
+++ b/.python-version
@@ -0,0 +1 @@
+3.14
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fd1a4d0..8f32553 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,12 +3,11 @@ This file will only list released and supported versions, usually skipping over
5.0.0
=====
-* Migrated from setup.py to pyproject.toml
+* Upgrades for modern python (3.11+), using uv and pyproject.toml.
* Switched from requests to urllib (zero external dependencies)
-* Switched from custom appindicator shim to native AppIndicator3 via GObject introspection
-* Migrated icon loading to importlib.resources
-* Modernized version detection using importlib.metadata
-* Requires Python 3.11+
+* Switched to AppIndicator3 on Linux
+* Adds MacOS support
+* Improved history scanning, works for dozens of browsers
4.0.2
=====
diff --git a/README.md b/README.md
index bba9dde..1ec3953 100644
--- a/README.md
+++ b/README.md
@@ -4,9 +4,9 @@
[](https://coveralls.io/github/captn3m0/hackertray?branch=master)

-HackerTray is a simple [Hacker News](https://news.ycombinator.com/) Linux application
-that lets you view top HN stories in your System Tray. It uses appindicator where available,
-but provides a Gtk StatusIcon fallback in case AppIndicator is not available.
+HackerTray is a cross-platform [Hacker News](https://news.ycombinator.com/) application
+that lets you view top HN stories in your System Tray. On Linux it uses appindicator where available
+(with a Gtk StatusIcon fallback). On macOS it uses a native status bar menu via pyobjc.
The inspiration for this came from [Hacker Bar](https://web.archive.org/web/20131126173924/http://hackerbarapp.com/) (now dead), which was Mac-only.
@@ -47,34 +47,19 @@ HackerTray will automatically check the latest version on startup, and inform yo
HackerTray accepts its various options via the command line. Run `hackertray -h` to see all options. Currently the following switches are supported:
1. `-c`: Enables comments support. Clicking on links will also open the comments page on HN. Can be switched off via the UI, but the setting is not remembered.
-2. `--chrome PROFILE-PATH`: Specifying a profile path to a chrome directory will make HackerTray read the Chrome History file to mark links as read. Links are checked once every 5 minutes, which is when the History file is copied (to override the lock in case Chrome is open), searched using sqlite and deleted. This feature is still experimental.
-3. `--firefox PROFILE-PATH`: Specify path to a firefox profile directory. HackerTray will read your firefox history from this profile, and use it to mark links as read. Pass `auto` as PROFILE-PATH to automatically read the default profile and use that.
-4. `--reverse` (or `-r`). Switches the order for the elements in the menu, so Quit is at top. Use this if your system bar is at the bottom of the screen.
+2. `--reverse` (or `-r`): Switches the order for the elements in the menu, so Quit is at top. Use this if your system bar is at the bottom of the screen.
+3. `--verbose`: Enable debug logging.
-Note that the `--chrome` and `--firefox` options are independent, and can be used together. However, they cannot be specified multiple times (so reading from 2 chrome profiles is not possible).
+Browser history is automatically discovered from all installed browsers (Chrome, Firefox, Safari, Brave, Edge, Arc, and many more). All profiles are searched.
-### Google Chrome Profile Path
-
-Where your Profile is stored depends on [which version of chrome you are using](https://chromium.googlesource.com/chromium/src.git/+/62.0.3202.58/docs/user_data_dir.md#linux):
-
-- [Chrome Stable] `~/.config/google-chrome/Default`
-- [Chrome Beta] `~/.config/google-chrome-beta/Default`
-- [Chrome Dev] `~/.config/google-chrome-unstable/Default`
-- [Chromium] `~/.config/chromium/Default`
-
-Replace `Default` with `Profile 1`, `Profile 2` or so on if you use multiple profiles on Chrome. Note that the `--chrome` option accepts a `PROFILE-PATH`, not the History file itself. Also note that sometimes `~` might not be set, so you might need to use the complete path (such as `/home/nemo/.config/google-chrome/Default/`).
-
-### Firefox Profile Path
-
-The default firefox profile path is `~/.mozilla/firefox/*.default`, where `*` denotes a random 8 digit string. You can also read `~/.mozilla/firefox/profiles.ini` to get a list of profiles. Alternatively, just pass `auto` and HackerTray will pick the default profile automatically.
+Options can also be set in `~/.config/hackertray/hackertray.ini` (or `~/.config/hackertray.ini`):
## Features
1. Minimalist Approach to HN
2. Opens links in your default browser
-3. Remembers which links you opened, even if you opened them outside of HackerTray
-4. Shows Points/Comment count in a simple format
-5. Reads your Google Chrome/Firefox History file to determine which links you've already read (even if you may not have opened them via HackerTray)
+3. Shows Points/Comment count in a simple format
+4. Reads your browser history to mark which links you've already visited
### Troubleshooting
@@ -101,7 +86,8 @@ On every launch, a request is made to `https://pypi.python.org/pypi/hackertray/j
## Credits
-- Mark Rickert for [Hacker Bar](http://hackerbarapp.com/) (No longer active)
+- Mark Rickert for [Hacker Bar](https://github.com/MohawkApps/Hacker-Bar) (No longer active, MIT License). The macOS port references Hacker Bar's original design.
+- [browser-history](https://github.com/browser-history/browser-history) (Apache 2.0) — browser history discovery patterns were informed by this project.
- [Giridaran Manivannan](https://github.com/ace03uec) for troubleshooting instructions.
- [@cheeaun](https://github.com/cheeaun) for the [Unofficial Hacker News API](https://github.com/cheeaun/node-hnapi/)
diff --git a/hackertray/__init__.py b/hackertray/__init__.py
index 4296291..565c1bb 100644
--- a/hackertray/__init__.py
+++ b/hackertray/__init__.py
@@ -1,290 +1,94 @@
#!/usr/bin/env python
import os
-import urllib.error
-import subprocess
-import importlib
-import importlib.resources
-
-if(os.environ.get('CI') != 'true'):
- import gi
- gi.require_version('Gtk', '3.0')
- from gi.repository import Gtk,GLib
- import webbrowser
- gi.require_version('AppIndicator3', '0.1')
- from gi.repository import AppIndicator3 as AppIndicator
-
-import json
+import sys
import argparse
-from os.path import expanduser
-import signal
+import configparser
+import logging
+from pathlib import Path
from .hackernews import HackerNews
-from .chrome import Chrome
-from .firefox import Firefox
from .version import Version
+_IS_MACOS = sys.platform == "darwin"
-class HackerNewsApp:
- HN_URL_PREFIX = "https://news.ycombinator.com/item?id="
- UPDATE_URL = "https://github.com/captn3m0/hackertray#upgrade"
- ABOUT_URL = "https://github.com/captn3m0/hackertray"
-
- def __init__(self, args):
- # Load the database
- home = expanduser("~")
- with open(home + '/.hackertray.json', 'a+') as content_file:
- content_file.seek(0)
- content = content_file.read()
- try:
- self.db = set(json.loads(content))
- except ValueError:
- self.db = set()
-
- # create an indicator applet
- self.ind = AppIndicator.Indicator.new("Hacker Tray", "hacker-tray", AppIndicator.IndicatorCategory.APPLICATION_STATUS)
- self.ind.set_status(AppIndicator.IndicatorStatus.ACTIVE)
- self.ind.set_icon_theme_path(self._icon_theme_path())
- icon_name = "hacker-tray-light" if self._is_light_theme() else "hacker-tray"
- self.ind.set_icon(icon_name)
-
- # create a menu
- self.menu = Gtk.Menu()
-
- self.commentState = args.comments
- self.reverse = args.reverse
- self.chrome_data_directory = args.chrome
-
- # Resolve firefox: None = not requested, "auto" = detect, else = specific path
- self.firefox_explicit = args.firefox is not None and args.firefox != "auto"
- if args.firefox == "auto":
- self.firefox_data_directory = Firefox.default_firefox_profile_path()
- else:
- self.firefox_data_directory = args.firefox
-
- # create items for the menu - separator, settings, about, refresh, quit
- menuSeparator = Gtk.SeparatorMenuItem()
- menuSeparator.show()
- self.add(menuSeparator)
-
- # Settings submenu
- settingsItem = Gtk.MenuItem.new_with_label("Settings")
- settingsMenu = Gtk.Menu()
- settingsItem.set_submenu(settingsMenu)
-
- btnComments = Gtk.CheckMenuItem.new_with_label("Open Comments")
- btnComments.set_active(args.comments)
- btnComments.connect("toggled", self.toggleComments)
- settingsMenu.append(btnComments)
- btnComments.show()
-
- btnReverse = Gtk.CheckMenuItem.new_with_label("Reverse Ordering")
- btnReverse.set_active(args.reverse)
- btnReverse.connect("toggled", self.toggleReverse)
- settingsMenu.append(btnReverse)
- btnReverse.show()
-
- # Only show Firefox toggle if --firefox was unset or "auto"
- if not self.firefox_explicit:
- btnFirefox = Gtk.CheckMenuItem.new_with_label("Detect Firefox read items")
- btnFirefox.set_active(self.firefox_data_directory is not None)
- btnFirefox.connect("toggled", self.toggleFirefox)
- settingsMenu.append(btnFirefox)
- btnFirefox.show()
-
- self.add(settingsItem)
- settingsItem.show()
-
- btnAbout = Gtk.MenuItem.new_with_label("About")
- btnAbout.show()
- btnAbout.connect("activate", self.showAbout)
- self.add(btnAbout)
-
- btnRefresh = Gtk.MenuItem.new_with_label("Refresh")
- btnRefresh.show()
- btnRefresh.connect("activate", self.refresh, True)
- self.add(btnRefresh)
-
- if Version.new_available():
- btnUpdate = Gtk.MenuItem.new_with_label("New Update Available")
- btnUpdate.show()
- btnUpdate.connect('activate', self.showUpdate)
- self.add(btnUpdate)
-
- btnQuit = Gtk.MenuItem.new_with_label("Quit")
- btnQuit.show()
- btnQuit.connect("activate", self.quit)
- self.add(btnQuit)
- self.menu.show()
- self.ind.set_menu(self.menu)
-
- self.refresh()
-
- def add(self, item):
- if self.reverse:
- self.menu.prepend(item)
- else:
- self.menu.append(item)
-
- def toggleComments(self, widget):
- """Whether comments page is opened or not"""
- self.commentState = widget.get_active()
- def toggleReverse(self, widget):
- self.reverse = widget.get_active()
+def _load_config():
+ """Load config from ~/.config/hackertray/hackertray.ini or ~/.config/hackertray.ini."""
+ config_dir = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
+ candidates = [
+ config_dir / "hackertray" / "hackertray.ini",
+ config_dir / "hackertray.ini",
+ ]
- def toggleFirefox(self, widget):
- if widget.get_active():
- try:
- self.firefox_data_directory = Firefox.default_firefox_profile_path()
- except RuntimeError:
- print("[+] Could not find Firefox profile")
- widget.set_active(False)
- return
- else:
- self.firefox_data_directory = None
+ cp = configparser.ConfigParser()
+ for path in candidates:
+ if path.is_file():
+ cp.read(path)
+ break
- def showUpdate(self, widget):
- """Handle the update button"""
- webbrowser.open(HackerNewsApp.UPDATE_URL)
- # Remove the update button once clicked
- self.menu.remove(widget)
+ if "hackertray" not in cp:
+ return {}
- def showAbout(self, widget):
- """Handle the about btn"""
- webbrowser.open(HackerNewsApp.ABOUT_URL)
+ section = cp["hackertray"]
+ defaults = {}
- # ToDo: Handle keyboard interrupt properly
- def quit(self, widget, data=None):
- """ Handler for the quit button"""
- l = list(self.db)[-200:]
- home = expanduser("~")
+ # Boolean options
+ for key in ("comments", "reverse", "verbose"):
+ if key in section:
+ defaults[key] = section.getboolean(key)
+ if "macos-icon-color" in section:
+ defaults["macos_icon_color"] = section["macos-icon-color"]
- with open(home + '/.hackertray.json', 'w+') as file:
- file.write(json.dumps(l))
+ return defaults
- Gtk.main_quit()
- def run(self):
- signal.signal(signal.SIGINT, self.quit)
- Gtk.main()
- return 0
-
- def open(self, widget, **args):
- """Opens the link in the web browser"""
- # We disconnect and reconnect the event in case we have
- # to set it to active and we don't want the signal to be processed
- if not widget.get_active():
- widget.disconnect(widget.signal_id)
- widget.set_active(True)
- widget.signal_id = widget.connect('activate', self.open)
-
- self.db.add(widget.item_id)
- webbrowser.open(widget.url)
-
- # TODO: Add support for Shift+Click or Right Click
- # to do the opposite of the current commentState setting
- if self.commentState:
- webbrowser.open(self.HN_URL_PREFIX + str(widget.hn_id))
-
- def addItem(self, item):
- """Adds an item to the menu"""
- # This is in the case of YC Job Postings, which we skip
- if item['points'] == 0 or item['points'] is None:
- return
-
- points = str(item['points']).zfill(3) + "/" + str(item['comments_count']).zfill(3)
-
- i = Gtk.CheckMenuItem.new_with_label(label="(" + points + ")"+item['title'])
- label = i.get_child()
- label.set_markup("" + points + " "+item['title']+"".format(points=points, title=item['title']))
- label.set_selectable(False)
-
- visited = item['history'] or item['id'] in self.db
- print(f"[ui] {'visited' if visited else 'unvisited'}: {item['url']}")
-
- i.url = item['url']
- tooltip = "{url}\nPosted by {user} {timeago}".format(url=item['url'], user=item['user'], timeago=item['time_ago'])
- i.set_tooltip_text(tooltip)
- i.hn_id = item['id']
- i.item_id = item['id']
- i.set_active(visited)
- i.signal_id = i.connect('activate', self.open)
- if self.reverse:
- self.menu.append(i)
- else:
- self.menu.prepend(i)
- i.show()
-
- def refresh(self, widget=None, no_timer=False):
- """Refreshes the menu """
- try:
- # Create an array of 20 false to denote matches in History
- searchResults = [False]*20
- data = list(reversed(HackerNews.getHomePage()[0:20]))
- urls = [item['url'] for item in data]
- if self.chrome_data_directory:
- searchResults = self.mergeBoolArray(searchResults, Chrome.search(urls, self.chrome_data_directory))
-
- if self.firefox_data_directory:
- searchResults = self.mergeBoolArray(searchResults, Firefox.search(urls, self.firefox_data_directory))
-
- # Remove all the current stories
- for i in self.menu.get_children():
- if hasattr(i, 'url'):
- self.menu.remove(i)
-
- # Add back all the refreshed news
- for index, item in enumerate(data):
- item['history'] = searchResults[index]
- if item['url'].startswith('item?id='):
- item['url'] = "https://news.ycombinator.com/" + item['url']
-
- self.addItem(item)
- # Catch network errors
- except urllib.error.URLError as e:
- print("[+] There was an error in fetching news items")
- finally:
- # Call every 10 minutes
- if not no_timer:
- GLib.timeout_add(10 * 30 * 1000, self.refresh)
-
- # Merges two boolean arrays, using OR operation against each pair
- def mergeBoolArray(self, original, patch):
- for index, var in enumerate(original):
- original[index] = original[index] or patch[index]
- return original
-
- @staticmethod
- def _icon_theme_path():
- """Return the icon data dir as a host-accessible path.
+def main():
+ parser = argparse.ArgumentParser(description="Hacker News in your System Tray")
+ parser.add_argument("-v", "--version", action="version", version=Version.current())
+ parser.add_argument(
+ "-c",
+ "--comments",
+ dest="comments",
+ default=False,
+ action="store_true",
+ help="Load the HN comments link for the article as well",
+ )
+ parser.add_argument(
+ "-r",
+ "--reverse",
+ dest="reverse",
+ default=False,
+ action="store_true",
+ help="Reverse the order of items. Use if your status bar is at the bottom of the screen",
+ )
+ parser.add_argument(
+ "--macos-icon-color",
+ dest="macos_icon_color",
+ default="orange",
+ choices=["black", "white", "none", "orange", "green"],
+ help="Status bar icon background color (macOS only)",
+ )
+ parser.add_argument(
+ "--verbose",
+ dest="verbose",
+ default=False,
+ action="store_true",
+ help="Enable debug logging",
+ )
+
+ # Load config file defaults, CLI flags take precedence
+ config_defaults = _load_config()
+ parser.set_defaults(**config_defaults)
- AppIndicator sends this path over D-Bus to the tray host, which runs
- outside the Flatpak sandbox. Inside a Flatpak, /app/ paths are not
- accessible from the host, so we translate via /.flatpak-info."""
- data_dir = str(importlib.resources.files('hackertray.data'))
- if os.path.exists("/.flatpak-info"):
- import configparser
- info = configparser.ConfigParser()
- info.read("/.flatpak-info")
- app_path = info.get("Instance", "app-path")
- data_dir = app_path + data_dir.removeprefix("/app")
- return data_dir
+ args = parser.parse_args()
- @staticmethod
- def _is_light_theme():
- settings = Gtk.Settings.get_default()
- if settings and settings.get_property("gtk-application-prefer-dark-theme"):
- return False
+ if _IS_MACOS:
+ from .macos import main_macos
+ main_macos(args)
+ else:
+ from .linux import HackerNewsApp
-def main():
- parser = argparse.ArgumentParser(description='Hacker News in your System Tray')
- parser.add_argument('-v', '--version', action='version', version=Version.current())
- parser.add_argument('-c', '--comments', dest='comments', default=False, action='store_true', help="Load the HN comments link for the article as well")
- parser.add_argument('--chrome', dest='chrome', help="Specify a Google Chrome Profile directory to use for matching chrome history")
- parser.add_argument('--firefox', dest='firefox', help="Specify a Firefox Profile directory to use for matching firefox history. Pass auto to automatically pick the default profile")
- parser.add_argument('-r', '--reverse', dest='reverse', default=False, action='store_true', help="Reverse the order of items. Use if your status bar is at the bottom of the screen")
- args = parser.parse_args()
- indicator = HackerNewsApp(args)
- indicator.run()
+ indicator = HackerNewsApp(args)
+ indicator.run()
diff --git a/hackertray/chrome.py b/hackertray/chrome.py
deleted file mode 100644
index 4335ec9..0000000
--- a/hackertray/chrome.py
+++ /dev/null
@@ -1,33 +0,0 @@
-
-import sqlite3
-import shutil
-import tempfile
-import os
-import sys
-
-
-class Chrome:
-
- @staticmethod
- def search(urls, config_folder_path):
- file_name = os.path.abspath(config_folder_path + '/History')
- if not os.path.isfile(file_name):
- print("ERROR: Could not find Chrome history file", file=sys.stderr)
- sys.exit(1)
- fd, tmp_path = tempfile.mkstemp(prefix='hackertray_chrome_')
- try:
- os.close(fd)
- shutil.copyfile(file_name, tmp_path)
- src = sqlite3.connect(tmp_path)
- conn = sqlite3.connect(":memory:")
- src.backup(conn)
- src.close()
- finally:
- os.unlink(tmp_path)
- db = conn.cursor()
- result = []
- for url in urls:
- db.execute('SELECT url from urls WHERE url=:url', {"url": url})
- result.append(db.fetchone() is not None)
- conn.close()
- return result
diff --git a/hackertray/firefox.py b/hackertray/firefox.py
deleted file mode 100644
index c9f153e..0000000
--- a/hackertray/firefox.py
+++ /dev/null
@@ -1,75 +0,0 @@
-import sqlite3
-import shutil
-import tempfile
-import os
-import sys
-from pathlib import Path
-import configparser
-
-class Firefox:
- HISTORY_FILE_NAME = '/places.sqlite'
-
- @staticmethod
- def default_firefox_profile_path():
- config_home = os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))
- candidates = [
- Path(config_home) / "mozilla" / "firefox",
- Path.home() / ".config" / "mozilla" / "firefox",
- Path.home() / ".mozilla" / "firefox",
- Path.home() / ".var" / "app" / "org.mozilla.firefox" / ".mozilla" / "firefox",
- ]
- firefox_dir = None
- for candidate in candidates:
- if (candidate / "profiles.ini").exists():
- firefox_dir = candidate
- break
- if firefox_dir is None:
- raise RuntimeError("Couldn't find Firefox profiles.ini")
-
- parser = configparser.ConfigParser()
- parser.read(firefox_dir / "profiles.ini")
-
- # Prefer the active install's locked profile (modern Firefox)
- for section in parser.sections():
- if section.startswith("Install") and parser.has_option(section, "Default"):
- rel_path = parser[section]["Default"]
- profile_path = firefox_dir / rel_path
- if profile_path.is_dir():
- return str(profile_path)
-
- # Fall back to the section marked Default=1
- for section in parser.sections():
- if parser.has_option(section, "Default") and parser[section]["Default"] == "1":
- if parser.has_option(section, "IsRelative") and parser[section]["IsRelative"] == "1":
- profile_path = firefox_dir / parser[section]["Path"]
- else:
- profile_path = Path(parser[section]["Path"])
- if profile_path.is_dir():
- return str(profile_path)
-
- raise RuntimeError("Couldn't find default Firefox profile")
-
- @staticmethod
- def search(urls, config_folder_path):
- file_name = os.path.abspath(config_folder_path + Firefox.HISTORY_FILE_NAME)
- if not os.path.isfile(file_name):
- print("ERROR: Could not find Firefox history file, using %s" % file_name)
- sys.exit(1)
- fd, tmp_path = tempfile.mkstemp(prefix='hackertray_firefox_')
- try:
- os.close(fd)
- shutil.copyfile(file_name, tmp_path)
- src = sqlite3.connect(tmp_path)
- conn = sqlite3.connect(":memory:")
- src.backup(conn)
- src.close()
- finally:
- os.unlink(tmp_path)
- db = conn.cursor()
-
- result = []
- for url in urls:
- db.execute('SELECT url from moz_places WHERE url=:url', {"url": url})
- result.append(db.fetchone() is not None)
- conn.close()
- return result
diff --git a/hackertray/hackernews.py b/hackertray/hackernews.py
index 20cb3b1..c851f02 100644
--- a/hackertray/hackernews.py
+++ b/hackertray/hackernews.py
@@ -2,13 +2,10 @@
import json
import urllib.request
-urls = [
- 'https://node-hnapi.herokuapp.com/'
-]
+urls = ["https://node-hnapi.herokuapp.com/"]
class HackerNews:
-
@staticmethod
def getHomePage():
random.shuffle(urls)
diff --git a/hackertray/history.py b/hackertray/history.py
new file mode 100644
index 0000000..14fc2c4
--- /dev/null
+++ b/hackertray/history.py
@@ -0,0 +1,327 @@
+"""Browser history discovery and search.
+
+Discovers all browser history databases on the system using glob patterns,
+then searches them for visited URLs. Supports Chromium-based, Firefox-based,
+and Safari browsers.
+"""
+
+from __future__ import annotations
+
+import enum
+import logging
+import os
+import re
+import shutil
+import sqlite3
+import sys
+import tempfile
+from dataclasses import dataclass
+from pathlib import Path
+
+logger = logging.getLogger(__name__)
+
+_IS_MACOS = sys.platform == "darwin"
+_IS_LINUX = sys.platform.startswith("linux")
+
+_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
+
+
+# ── Schema types ──────────────────────────────────────────────────────
+
+
+class HistorySchema(enum.Enum):
+ """Database schema for a browser family."""
+
+ CHROMIUM = ("History", "urls", "url")
+ FIREFOX = ("places.sqlite", "moz_places", "url")
+ SAFARI = ("History.db", "history_items", "url")
+
+ def __init__(self, db_file: str, table: str, column: str):
+ self.db_file = db_file
+ self.table = table
+ self.column = column
+
+
+# ── Glob-based discovery ──────────────────────────────────────────────
+
+# Each entry: (schema, label, {platform: [glob patterns relative to ~]})
+# Globs use * to match any profile directory.
+
+_BROWSERS: list[tuple[HistorySchema, str, dict[str, list[str]]]] = [
+ # Chromium-based
+ (
+ HistorySchema.CHROMIUM,
+ "Chrome",
+ {
+ "macos": ["Library/Application Support/Google/Chrome/*/History"],
+ "linux": [".config/google-chrome/*/History"],
+ },
+ ),
+ (
+ HistorySchema.CHROMIUM,
+ "Chromium",
+ {
+ "macos": ["Library/Application Support/Chromium/*/History"],
+ "linux": [".config/chromium/*/History"],
+ },
+ ),
+ (
+ HistorySchema.CHROMIUM,
+ "Brave",
+ {
+ "macos": [
+ "Library/Application Support/BraveSoftware/Brave-Browser/*/History"
+ ],
+ "linux": [".config/BraveSoftware/Brave-Browser/*/History"],
+ },
+ ),
+ (
+ HistorySchema.CHROMIUM,
+ "Edge",
+ {
+ "macos": ["Library/Application Support/Microsoft Edge/*/History"],
+ "linux": [
+ ".config/microsoft-edge/*/History",
+ ".config/microsoft-edge-dev/*/History",
+ ],
+ },
+ ),
+ (
+ HistorySchema.CHROMIUM,
+ "Vivaldi",
+ {
+ "macos": ["Library/Application Support/Vivaldi/*/History"],
+ "linux": [".config/vivaldi/*/History"],
+ },
+ ),
+ (
+ HistorySchema.CHROMIUM,
+ "Arc",
+ {
+ "macos": ["Library/Application Support/Arc/User Data/*/History"],
+ },
+ ),
+ (
+ HistorySchema.CHROMIUM,
+ "Opera",
+ {
+ "macos": ["Library/Application Support/com.operasoftware.Opera/History"],
+ "linux": [".config/opera/History"],
+ },
+ ),
+ (
+ HistorySchema.CHROMIUM,
+ "Opera GX",
+ {
+ "macos": ["Library/Application Support/com.operasoftware.OperaGX/History"],
+ },
+ ),
+ (
+ HistorySchema.CHROMIUM,
+ "Yandex",
+ {
+ "macos": ["Library/Application Support/Yandex/YandexBrowser/*/History"],
+ "linux": [".config/yandex-browser/*/History"],
+ },
+ ),
+ (
+ HistorySchema.CHROMIUM,
+ "Sidekick",
+ {
+ "macos": ["Library/Application Support/Sidekick/*/History"],
+ "linux": [".config/sidekick/*/History"],
+ },
+ ),
+ (
+ HistorySchema.CHROMIUM,
+ "Thorium",
+ {
+ "macos": ["Library/Application Support/Thorium/*/History"],
+ "linux": [".config/thorium/*/History"],
+ },
+ ),
+ (
+ HistorySchema.CHROMIUM,
+ "Epic",
+ {
+ "macos": ["Library/Application Support/HiddenReflex/Epic/*/History"],
+ },
+ ),
+ # Firefox-based
+ (
+ HistorySchema.FIREFOX,
+ "Firefox",
+ {
+ "macos": [
+ "Library/Application Support/Firefox/Profiles/*/places.sqlite",
+ "Library/Application Support/Firefox/*/places.sqlite",
+ ],
+ "linux": [
+ ".mozilla/firefox/*/places.sqlite",
+ ".var/app/org.mozilla.firefox/.mozilla/firefox/*/places.sqlite",
+ ],
+ },
+ ),
+ (
+ HistorySchema.FIREFOX,
+ "LibreWolf",
+ {
+ "macos": ["Library/Application Support/LibreWolf/*/places.sqlite"],
+ "linux": [
+ ".librewolf/*/places.sqlite",
+ ".var/app/io.gitlab.librewolf-community/.librewolf/*/places.sqlite",
+ ],
+ },
+ ),
+ (
+ HistorySchema.FIREFOX,
+ "Waterfox",
+ {
+ "macos": ["Library/Application Support/Waterfox/*/places.sqlite"],
+ "linux": [".waterfox/*/places.sqlite"],
+ },
+ ),
+ (
+ HistorySchema.FIREFOX,
+ "Zen",
+ {
+ "macos": [
+ "Library/Application Support/zen/Profiles/*/places.sqlite",
+ "Library/Application Support/zen/*/places.sqlite",
+ ],
+ "linux": [".zen/*/places.sqlite"],
+ },
+ ),
+ (
+ HistorySchema.FIREFOX,
+ "Floorp",
+ {
+ "macos": ["Library/Application Support/Floorp/*/places.sqlite"],
+ "linux": [".floorp/*/places.sqlite"],
+ },
+ ),
+ # Safari
+ (
+ HistorySchema.SAFARI,
+ "Safari",
+ {
+ "macos": ["Library/Safari/History.db"],
+ },
+ ),
+]
+
+_PLATFORM_KEY = "macos" if _IS_MACOS else "linux" if _IS_LINUX else None
+
+
+# ── Database access ───────────────────────────────────────────────────
+
+
+class DatabaseError(Exception):
+ """Raised when a history database cannot be read."""
+
+
+@dataclass(frozen=True)
+class HistoryDB:
+ """A discovered browser history database."""
+
+ label: str
+ schema: HistorySchema
+ path: Path
+
+ def search(self, urls: list[str]) -> set[str]:
+ """Return the subset of urls found in this history database."""
+ if not urls:
+ return set()
+ return _query_urls(self.path, self.schema.table, self.schema.column, urls)
+
+
+def _open_readonly(db_path: Path) -> sqlite3.Connection:
+ """Copy a database to memory to avoid lock contention."""
+ fd, tmp_path = tempfile.mkstemp(prefix="hackertray_")
+ try:
+ os.close(fd)
+ shutil.copyfile(db_path, tmp_path)
+ src = sqlite3.connect(tmp_path)
+ conn = sqlite3.connect(":memory:")
+ src.backup(conn)
+ src.close()
+ except (OSError, sqlite3.Error) as e:
+ raise DatabaseError(f"Failed to read {db_path}: {e}") from e
+ finally:
+ try:
+ os.unlink(tmp_path)
+ except OSError:
+ pass
+ return conn
+
+
+def _query_urls(db_path: Path, table: str, column: str, urls: list[str]) -> set[str]:
+ """Check which URLs exist in a browser history SQLite database."""
+ if not _IDENTIFIER_RE.match(table):
+ raise ValueError(f"Invalid table name: {table!r}")
+ if not _IDENTIFIER_RE.match(column):
+ raise ValueError(f"Invalid column name: {column!r}")
+
+ if not db_path.is_file():
+ raise DatabaseError(f"History database not found: {db_path}")
+
+ conn = _open_readonly(db_path)
+ try:
+ cursor = conn.cursor()
+ cursor.execute("CREATE TEMP TABLE _ht_lookup (url TEXT PRIMARY KEY)")
+ cursor.executemany(
+ "INSERT OR IGNORE INTO _ht_lookup (url) VALUES (?)", [(u,) for u in urls]
+ )
+ query = f'SELECT l.url FROM _ht_lookup l INNER JOIN "{table}" h ON l.url = h."{column}"'
+ return {row[0] for row in cursor.execute(query)}
+ finally:
+ conn.close()
+
+
+# ── Public API ────────────────────────────────────────────────────────
+
+
+def discover(home: Path | None = None, platform: str | None = None) -> list[HistoryDB]:
+ """Discover all browser history databases on this system.
+
+ Returns a list of HistoryDB instances, one per database file found.
+ """
+ if home is None:
+ home = Path.home()
+
+ key = platform or _PLATFORM_KEY
+ results: list[HistoryDB] = []
+ for schema, label, platform_globs in _BROWSERS:
+ patterns = platform_globs.get(key, []) if key else []
+ for pattern in patterns:
+ for db_path in sorted(home.glob(pattern)):
+ if db_path.is_file():
+ logger.debug("Found %s history: %s", label, db_path)
+ results.append(HistoryDB(label=label, schema=schema, path=db_path))
+ return results
+
+
+def search(urls: list[str], databases: list[HistoryDB] | None = None) -> set[str]:
+ """Search browser history for the given URLs.
+
+ Args:
+ urls: URLs to look up.
+ databases: Databases to search. If None, discovers all databases.
+
+ Returns:
+ Set of URLs found in any browser's history.
+ """
+ if not urls:
+ return set()
+
+ if databases is None:
+ databases = discover()
+
+ found: set[str] = set()
+ for db in databases:
+ try:
+ found |= db.search(urls)
+ except (DatabaseError, Exception) as e:
+ logger.debug("Error searching %s (%s): %s", db.label, db.path, e)
+ continue
+ return found
diff --git a/hackertray/linux.py b/hackertray/linux.py
new file mode 100644
index 0000000..edd3a4b
--- /dev/null
+++ b/hackertray/linux.py
@@ -0,0 +1,245 @@
+#!/usr/bin/env python
+"""Linux GTK system tray app for HackerTray."""
+
+import logging
+import signal
+import urllib.error
+import webbrowser
+
+import gi
+
+gi.require_version("Gtk", "3.0")
+from gi.repository import Gtk, GLib
+
+gi.require_version("AppIndicator3", "0.1")
+from gi.repository import AppIndicator3 as AppIndicator
+
+import importlib
+import importlib.resources
+import os
+import configparser
+
+from .hackernews import HackerNews
+from .version import Version
+
+logger = logging.getLogger(__name__)
+
+
+class HackerNewsApp:
+ HN_URL_PREFIX = "https://news.ycombinator.com/item?id="
+ UPDATE_URL = "https://github.com/captn3m0/hackertray#upgrade"
+ ABOUT_URL = "https://github.com/captn3m0/hackertray"
+
+ def __init__(self, args):
+ # create an indicator applet
+ self.ind = AppIndicator.Indicator.new(
+ "Hacker Tray",
+ "hacker-tray",
+ AppIndicator.IndicatorCategory.APPLICATION_STATUS,
+ )
+ self.ind.set_status(AppIndicator.IndicatorStatus.ACTIVE)
+ self.ind.set_icon_theme_path(self._icon_theme_path())
+ icon_name = "hacker-tray-light" if self._is_light_theme() else "hacker-tray"
+ self.ind.set_icon(icon_name)
+
+ # create a menu
+ self.menu = Gtk.Menu()
+
+ self.commentState = args.comments
+ self.reverse = args.reverse
+
+ # Discover browser history databases
+ from .history import discover
+
+ self._history_dbs = discover()
+
+ # create items for the menu - separator, settings, about, refresh, quit
+ menuSeparator = Gtk.SeparatorMenuItem()
+ menuSeparator.show()
+ self.add(menuSeparator)
+
+ # Settings submenu
+ settingsItem = Gtk.MenuItem.new_with_label("Settings")
+ settingsMenu = Gtk.Menu()
+ settingsItem.set_submenu(settingsMenu)
+
+ btnComments = Gtk.CheckMenuItem.new_with_label("Open Comments")
+ btnComments.set_active(args.comments)
+ btnComments.connect("toggled", self.toggleComments)
+ settingsMenu.append(btnComments)
+ btnComments.show()
+
+ btnReverse = Gtk.CheckMenuItem.new_with_label("Reverse Ordering")
+ btnReverse.set_active(args.reverse)
+ btnReverse.connect("toggled", self.toggleReverse)
+ settingsMenu.append(btnReverse)
+ btnReverse.show()
+
+ self.add(settingsItem)
+ settingsItem.show()
+
+ btnAbout = Gtk.MenuItem.new_with_label("About")
+ btnAbout.show()
+ btnAbout.connect("activate", self.showAbout)
+ self.add(btnAbout)
+
+ btnRefresh = Gtk.MenuItem.new_with_label("Refresh")
+ btnRefresh.show()
+ btnRefresh.connect("activate", self.refresh, True)
+ self.add(btnRefresh)
+
+ if Version.new_available():
+ btnUpdate = Gtk.MenuItem.new_with_label("New Update Available")
+ btnUpdate.show()
+ btnUpdate.connect("activate", self.showUpdate)
+ self.add(btnUpdate)
+
+ btnQuit = Gtk.MenuItem.new_with_label("Quit")
+ btnQuit.show()
+ btnQuit.connect("activate", self.quit)
+ self.add(btnQuit)
+ self.menu.show()
+ self.ind.set_menu(self.menu)
+
+ self.refresh()
+
+ def add(self, item):
+ if self.reverse:
+ self.menu.prepend(item)
+ else:
+ self.menu.append(item)
+
+ def toggleComments(self, widget):
+ """Whether comments page is opened or not"""
+ self.commentState = widget.get_active()
+
+ def toggleReverse(self, widget):
+ self.reverse = widget.get_active()
+
+ def showUpdate(self, widget):
+ """Handle the update button"""
+ webbrowser.open(HackerNewsApp.UPDATE_URL)
+ # Remove the update button once clicked
+ self.menu.remove(widget)
+
+ def showAbout(self, widget):
+ """Handle the about btn"""
+ webbrowser.open(HackerNewsApp.ABOUT_URL)
+
+ def quit(self, widget, data=None):
+ """Handler for the quit button"""
+ Gtk.main_quit()
+
+ def run(self):
+ signal.signal(signal.SIGINT, self.quit)
+ Gtk.main()
+ return 0
+
+ def open(self, widget, **args):
+ """Opens the link in the web browser"""
+ # We disconnect and reconnect the event in case we have
+ # to set it to active and we don't want the signal to be processed
+ if not widget.get_active():
+ widget.disconnect(widget.signal_id)
+ widget.set_active(True)
+ widget.signal_id = widget.connect("activate", self.open)
+
+ webbrowser.open(widget.url)
+
+ # TODO: Add support for Shift+Click or Right Click
+ # to do the opposite of the current commentState setting
+ if self.commentState:
+ webbrowser.open(self.HN_URL_PREFIX + str(widget.hn_id))
+
+ def addItem(self, item):
+ """Adds an item to the menu"""
+ # This is in the case of YC Job Postings, which we skip
+ if item["points"] == 0 or item["points"] is None:
+ return
+
+ points = (
+ str(item["points"]).zfill(3) + "/" + str(item["comments_count"]).zfill(3)
+ )
+
+ i = Gtk.CheckMenuItem.new_with_label(label="(" + points + ")" + item["title"])
+ label = i.get_child()
+ label.set_markup(
+ ""
+ + points
+ + " "
+ + item["title"]
+ + "".format(points=points, title=item["title"])
+ )
+ label.set_selectable(False)
+
+ visited = item["history"]
+ logger.debug("%s: %s", "visited" if visited else "unvisited", item["url"])
+
+ i.url = item["url"]
+ tooltip = "{url}\nPosted by {user} {timeago}".format(
+ url=item["url"], user=item["user"], timeago=item["time_ago"]
+ )
+ i.set_tooltip_text(tooltip)
+ i.hn_id = item["id"]
+ i.item_id = item["id"]
+ i.set_active(visited)
+ i.signal_id = i.connect("activate", self.open)
+ if self.reverse:
+ self.menu.append(i)
+ else:
+ self.menu.prepend(i)
+ i.show()
+
+ def refresh(self, widget=None, no_timer=False):
+ """Refreshes the menu"""
+ try:
+ data = list(reversed(HackerNews.getHomePage()[0:20]))
+ urls = [item["url"] for item in data]
+
+ # Search browser history
+ from .history import search as history_search
+
+ visited_urls = history_search(urls, self._history_dbs)
+
+ # Remove all the current stories
+ for i in self.menu.get_children():
+ if hasattr(i, "url"):
+ self.menu.remove(i)
+
+ # Add back all the refreshed news
+ for item in data:
+ item["history"] = item["url"] in visited_urls
+ if item["url"].startswith("item?id="):
+ item["url"] = "https://news.ycombinator.com/" + item["url"]
+
+ self.addItem(item)
+ # Catch network errors
+ except urllib.error.URLError as e:
+ print("[+] There was an error in fetching news items")
+ finally:
+ # Call every 10 minutes
+ if not no_timer:
+ GLib.timeout_add(10 * 30 * 1000, self.refresh)
+
+ @staticmethod
+ def _icon_theme_path():
+ """Return the icon data dir as a host-accessible path.
+
+ AppIndicator sends this path over D-Bus to the tray host, which runs
+ outside the Flatpak sandbox. Inside a Flatpak, /app/ paths are not
+ accessible from the host, so we translate via /.flatpak-info."""
+ data_dir = str(importlib.resources.files("hackertray.data"))
+ if os.path.exists("/.flatpak-info"):
+ import configparser
+
+ info = configparser.ConfigParser()
+ info.read("/.flatpak-info")
+ app_path = info.get("Instance", "app-path")
+ data_dir = app_path + data_dir.removeprefix("/app")
+ return data_dir
+
+ @staticmethod
+ def _is_light_theme():
+ settings = Gtk.Settings.get_default()
+ if settings and settings.get_property("gtk-application-prefer-dark-theme"):
+ return False
diff --git a/hackertray/macos.py b/hackertray/macos.py
new file mode 100644
index 0000000..0248924
--- /dev/null
+++ b/hackertray/macos.py
@@ -0,0 +1,401 @@
+#!/usr/bin/env python
+"""Native macOS status bar app for HackerTray using pyobjc."""
+
+import logging
+import webbrowser
+import signal
+import urllib.error
+from threading import Thread
+
+logger = logging.getLogger(__name__)
+
+import objc
+from AppKit import (
+ NSApplication,
+ NSStatusBar,
+ NSMenu,
+ NSMenuItem,
+ NSFont,
+ NSFontAttributeName,
+ NSForegroundColorAttributeName,
+ NSBackgroundColorAttributeName,
+ NSColor,
+ NSImage,
+ NSMutableAttributedString,
+ NSBezierPath,
+ NSTimer,
+ NSVariableStatusItemLength,
+ NSApp,
+ NSOnState,
+ NSOffState,
+ NSApplicationActivationPolicyAccessory,
+)
+from Foundation import (
+ NSObject,
+ NSMakeRange,
+ NSMakeRect,
+ NSMakeSize,
+ NSMakePoint,
+ NSDictionary,
+)
+from PyObjCTools import AppHelper
+
+from .hackernews import HackerNews
+from .version import Version
+
+
+HN_URL_PREFIX = "https://news.ycombinator.com/item?id="
+UPDATE_URL = "https://github.com/captn3m0/hackertray#upgrade"
+ABOUT_URL = "https://github.com/captn3m0/hackertray"
+
+
+class HackerTrayDelegate(NSObject):
+ def init(self):
+ self = objc.super(HackerTrayDelegate, self).init()
+ if self is None:
+ return None
+ self._comment_state = False
+ self._reverse = False
+ self._history_dbs = []
+ self._pending_data = None
+ self._last_data = None # last fetched data for live redraws
+ return self
+
+ @objc.python_method
+ def configure(self, args):
+ from . import history
+
+ self._comment_state = args.comments
+ self._reverse = args.reverse
+ self._icon_color = getattr(args, "macos_icon_color", "orange") or "orange"
+
+ # Discover all browser history databases
+ self._history_dbs = history.discover()
+
+ def applicationDidFinishLaunching_(self, notification):
+ # Status bar item
+ self._status_item = NSStatusBar.systemStatusBar().statusItemWithLength_(
+ NSVariableStatusItemLength
+ )
+ if self._icon_color == "none":
+ self._status_item.setTitle_("Y")
+ self._status_item.button().setFont_(NSFont.boldSystemFontOfSize_(14.0))
+ else:
+ self._status_item.button().setImage_(self._make_yc_icon())
+ logger.debug("Status bar item created (icon_color=%s)", self._icon_color)
+
+ # Menu
+ self._menu = NSMenu.alloc().init()
+ self._menu.setAutoenablesItems_(False)
+
+ # Add a loading item so the menu isn't empty
+ loading = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_(
+ "Loading...", None, ""
+ )
+ loading.setEnabled_(False)
+ self._menu.addItem_(loading)
+
+ self._status_item.setMenu_(self._menu)
+
+ # Kick off first refresh
+ self.refresh_(None)
+
+ # Refresh every 5 minutes
+ NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_(
+ 300.0, self, b"refresh:", None, True
+ )
+
+ # Timer to allow Python signal handling (Ctrl+C)
+ NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_(
+ 1.0, self, b"signalCheck:", None, True
+ )
+
+ def signalCheck_(self, timer):
+ """No-op timer that gives Python a chance to handle signals."""
+ pass
+
+ def refresh_(self, timer):
+ """Refresh news items in a background thread."""
+ logger.debug("refresh_ called, spawning background fetch thread")
+ Thread(target=self._fetch_and_update, daemon=True).start()
+
+ @objc.python_method
+ def _fetch_and_update(self):
+ try:
+ logger.debug("Fetching HN homepage...")
+ data = list(reversed(HackerNews.getHomePage()[0:20]))
+ logger.debug("Got %d items from HN", len(data))
+ urls = [item["url"] for item in data]
+
+ # Search browser history
+ from . import history
+
+ visited_urls = history.search(urls, self._history_dbs)
+ logger.debug("Found %d visited URLs", len(visited_urls))
+
+ for item in data:
+ item["history"] = item["url"] in visited_urls
+ if item["url"].startswith("item?id="):
+ item["url"] = "https://news.ycombinator.com/" + item["url"]
+
+ # Stash data and poke the main thread
+ self._pending_data = data
+ logger.debug("Posting applyPendingData: to main thread")
+ self.performSelectorOnMainThread_withObject_waitUntilDone_(
+ b"applyPendingData:", None, False
+ )
+ except urllib.error.URLError as e:
+ logger.error("URL error fetching news: %s", e)
+ except Exception as e:
+ logger.error("Error during refresh: %s", e, exc_info=True)
+
+ def applyPendingData_(self, ignored):
+ """Called on the main thread to rebuild the menu from _pending_data."""
+ logger.debug("applyPendingData_ called on main thread")
+ data = self._pending_data
+ if data is None:
+ logger.debug("No pending data, skipping")
+ return
+ self._pending_data = None
+ self._last_data = data
+ self._rebuild_menu(data)
+
+ @objc.python_method
+ def _rebuild_menu(self, data):
+ logger.debug("_rebuild_menu: rebuilding with %d items", len(data))
+ self._menu.removeAllItems()
+
+ items_to_add = list(data)
+ if self._reverse:
+ items_to_add = list(reversed(items_to_add))
+
+ for item in items_to_add:
+ if item["points"] == 0 or item["points"] is None:
+ continue
+ mi = self._make_news_item(item)
+ self._menu.addItem_(mi)
+
+ # -- Separator --
+ self._menu.addItem_(NSMenuItem.separatorItem())
+
+ # Refresh
+ refresh_item = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_(
+ "Refresh", b"refresh:", ""
+ )
+ refresh_item.setTarget_(self)
+ self._menu.addItem_(refresh_item)
+
+ # Settings submenu
+ settings_item = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_(
+ "Settings", None, ""
+ )
+ settings_menu = NSMenu.alloc().init()
+ settings_menu.setAutoenablesItems_(False)
+
+ comments_item = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_(
+ "Open Comments", b"toggleComments:", ""
+ )
+ comments_item.setTarget_(self)
+ comments_item.setState_(NSOnState if self._comment_state else NSOffState)
+ settings_menu.addItem_(comments_item)
+
+ reverse_item = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_(
+ "Reverse Ordering", b"toggleReverse:", ""
+ )
+ reverse_item.setTarget_(self)
+ reverse_item.setState_(NSOnState if self._reverse else NSOffState)
+ settings_menu.addItem_(reverse_item)
+
+ settings_item.setSubmenu_(settings_menu)
+ self._menu.addItem_(settings_item)
+
+ # About
+ about_item = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_(
+ "About HackerTray", b"showAbout:", ""
+ )
+ about_item.setTarget_(self)
+ self._menu.addItem_(about_item)
+
+ # -- Separator + Quit --
+ self._menu.addItem_(NSMenuItem.separatorItem())
+ quit_item = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_(
+ "Quit", b"quit:", ""
+ )
+ quit_item.setTarget_(self)
+ self._menu.addItem_(quit_item)
+ logger.debug(
+ "_rebuild_menu: done, menu has %d items", self._menu.numberOfItems()
+ )
+
+ @objc.python_method
+ def _make_news_item(self, item):
+ """Create a styled menu item mimicking Hacker Bar's layout."""
+ visited = item["history"]
+ logger.debug("%s: %s", "visited" if visited else "unvisited", item["url"])
+
+ points = str(item["points"]).zfill(3)
+ comments = str(item["comments_count"]).zfill(3)
+ title = item["title"]
+
+ # Build attributed string: "123/456 Title"
+ mono = NSFont.monospacedDigitSystemFontOfSize_weight_(13.0, 0.0)
+ regular = NSFont.systemFontOfSize_(13.0)
+
+ score_str = f"{points}/{comments} "
+ full_str = score_str + title
+
+ attr_str = NSMutableAttributedString.alloc().initWithString_(full_str)
+ attr_str.addAttribute_value_range_(
+ NSFontAttributeName, mono, NSMakeRange(0, len(score_str))
+ )
+ attr_str.addAttribute_value_range_(
+ NSForegroundColorAttributeName,
+ NSColor.secondaryLabelColor(),
+ NSMakeRange(0, len(score_str)),
+ )
+ # Gray background on comment count
+ comment_start = len(points) + 1 # after "123/"
+ attr_str.addAttribute_value_range_(
+ NSBackgroundColorAttributeName,
+ NSColor.colorWithSRGBRed_green_blue_alpha_(0.5, 0.5, 0.5, 0.15),
+ NSMakeRange(comment_start, len(comments)),
+ )
+
+ title_start = len(score_str)
+ attr_str.addAttribute_value_range_(
+ NSFontAttributeName, regular, NSMakeRange(title_start, len(title))
+ )
+
+ # Orange background on "Show HN" tag
+ show_hn_tag = "Show HN"
+ if title.startswith("Show HN:"):
+ attr_str.addAttribute_value_range_(
+ NSBackgroundColorAttributeName,
+ NSColor.colorWithSRGBRed_green_blue_alpha_(1.0, 0.6, 0.0, 0.15),
+ NSMakeRange(title_start, len(show_hn_tag)),
+ )
+
+ mi = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_(
+ full_str, b"openLink:", ""
+ )
+ mi.setAttributedTitle_(attr_str)
+ mi.setTarget_(self)
+
+ tooltip = "{url}\nPosted by {user} {timeago}".format(
+ url=item["url"], user=item.get("user", ""), timeago=item.get("time_ago", "")
+ )
+ mi.setToolTip_(tooltip)
+
+ mi.setRepresentedObject_(
+ {
+ "url": item["url"],
+ "hn_id": item["id"],
+ "item_id": item["id"],
+ }
+ )
+
+ if visited:
+ mi.setState_(NSOnState)
+
+ return mi
+
+ def openLink_(self, sender):
+ info = sender.representedObject()
+ if info is None:
+ return
+ url = info["url"]
+ item_id = info["item_id"]
+ hn_id = info["hn_id"]
+
+ webbrowser.open(url)
+
+ if self._comment_state:
+ webbrowser.open(HN_URL_PREFIX + str(hn_id))
+
+ # Redraw to show visited checkmark
+ if self._last_data is not None:
+ self._rebuild_menu(self._last_data)
+
+ def toggleComments_(self, sender):
+ self._comment_state = not self._comment_state
+ sender.setState_(NSOnState if self._comment_state else NSOffState)
+
+ def toggleReverse_(self, sender):
+ self._reverse = not self._reverse
+ if self._last_data is not None:
+ self._rebuild_menu(self._last_data)
+
+ def showAbout_(self, sender):
+ webbrowser.open(ABOUT_URL)
+
+ def showUpdate_(self, sender):
+ webbrowser.open(UPDATE_URL)
+
+ def quit_(self, sender):
+ AppHelper.stopEventLoop()
+
+ @objc.python_method
+ def _make_yc_icon(self):
+ """Create a YC-style icon: colored rounded-rect with contrasting Y."""
+ bg_colors = {
+ "orange": (1.0, 0.4, 0.0), # #FF6600
+ "black": (0.0, 0.0, 0.0),
+ "white": (1.0, 1.0, 1.0),
+ "green": (0.0, 0.5, 0.0),
+ }
+ fg_colors = {
+ "orange": NSColor.whiteColor(),
+ "black": NSColor.whiteColor(),
+ "white": NSColor.blackColor(),
+ "green": NSColor.whiteColor(),
+ }
+ r, g, b = bg_colors[self._icon_color]
+ bg = NSColor.colorWithSRGBRed_green_blue_alpha_(r, g, b, 1.0)
+ fg = fg_colors[self._icon_color]
+
+ size = 18.0
+ img = NSImage.alloc().initWithSize_(NSMakeSize(size, size))
+ img.lockFocus()
+
+ bg.setFill()
+ NSBezierPath.bezierPathWithRoundedRect_xRadius_yRadius_(
+ NSMakeRect(0, 0, size, size), 3.0, 3.0
+ ).fill()
+
+ attrs = NSDictionary.dictionaryWithObjects_forKeys_(
+ [NSFont.monospacedSystemFontOfSize_weight_(13.0, 0.0), fg],
+ [NSFontAttributeName, NSForegroundColorAttributeName],
+ )
+ y_str = NSMutableAttributedString.alloc().initWithString_attributes_("Y", attrs)
+ str_size = y_str.size()
+ y_str.drawAtPoint_(
+ NSMakePoint(
+ (size - str_size.width) / 2.0,
+ (size - str_size.height) / 2.0,
+ )
+ )
+
+ img.unlockFocus()
+ img.setTemplate_(False)
+ return img
+
+
+def main_macos(args):
+ logging.basicConfig(
+ level=logging.DEBUG if args.verbose else logging.WARNING,
+ format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
+ )
+ app = NSApplication.sharedApplication()
+ app.setActivationPolicy_(NSApplicationActivationPolicyAccessory)
+
+ delegate = HackerTrayDelegate.alloc().init()
+ delegate.configure(args)
+ app.setDelegate_(delegate)
+
+ # Ctrl+C handler
+ def handle_sigint(*_):
+ AppHelper.stopEventLoop()
+
+ signal.signal(signal.SIGINT, handle_sigint)
+
+ AppHelper.runEventLoop()
diff --git a/hackertray/version.py b/hackertray/version.py
index ab5767d..00e8326 100644
--- a/hackertray/version.py
+++ b/hackertray/version.py
@@ -3,13 +3,14 @@
import urllib.error
from importlib.metadata import version, PackageNotFoundError
+
class Version:
PYPI_URL = "https://pypi.python.org/pypi/hackertray/json"
@staticmethod
def latest():
with urllib.request.urlopen(Version.PYPI_URL) as r:
- return json.loads(r.read())['info']['version']
+ return json.loads(r.read())["info"]["version"]
@staticmethod
def current():
@@ -24,7 +25,9 @@ def new_available():
latest = Version.latest()
current = Version.current()
try:
- if pkg_resources.parse_version(latest) > pkg_resources.parse_version(current):
+ if pkg_resources.parse_version(latest) > pkg_resources.parse_version(
+ current
+ ):
print("[+] New version " + latest + " is available")
return True
else:
diff --git a/pyproject.toml b/pyproject.toml
index 3cc9e24..c76f7fb 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -13,7 +13,10 @@ authors = [
]
keywords = ["hacker news", "hn", "tray", "system tray", "icon", "hackertray"]
requires-python = ">=3.11"
-dependencies = []
+dependencies = [
+ "pyobjc-core>=10.0; sys_platform == 'darwin'",
+ "pyobjc-framework-Cocoa>=10.0; sys_platform == 'darwin'",
+]
[project.urls]
Homepage = "https://captnemo.in/hackertray"
@@ -30,3 +33,9 @@ exclude = ["test*"]
[tool.setuptools.package-data]
"hackertray.data" = ["hacker-tray.png", "hacker-tray-light.png"]
+
+[dependency-groups]
+dev = [
+ "pytest>=9.0.2",
+ "pytest-cov>=7.1.0",
+]
diff --git a/shared-modules b/shared-modules
deleted file mode 160000
index 2f1fb18..0000000
--- a/shared-modules
+++ /dev/null
@@ -1 +0,0 @@
-Subproject commit 2f1fb187252a619f4775e3126395584041b071fb
diff --git a/test/chrome_test.py b/test/chrome_test.py
deleted file mode 100644
index 1b30683..0000000
--- a/test/chrome_test.py
+++ /dev/null
@@ -1,15 +0,0 @@
-import unittest
-import os
-
-from hackertray import Chrome
-
-class ChromeTest(unittest.TestCase):
- def runTest(self):
- config_folder_path = os.getcwd()+'/test/'
- data = Chrome.search([
- "https://github.com/",
- "https://news.ycombinator.com/",
- "https://github.com/captn3m0/hackertray",
- "http://invalid_url/"],
- config_folder_path)
- self.assertTrue(data == [True,True,True,False])
\ No newline at end of file
diff --git a/test/conftest.py b/test/conftest.py
new file mode 100644
index 0000000..979fb5e
--- /dev/null
+++ b/test/conftest.py
@@ -0,0 +1,20 @@
+"""Block all network access during tests.
+
+Any urllib.request.urlopen call that isn't mocked will raise immediately
+instead of hitting the network.
+"""
+
+import urllib.request
+
+import pytest
+
+
+def _blocked_urlopen(*args, **kwargs):
+ raise RuntimeError(
+ f"Network access not allowed in tests (urlopen called with {args!r})"
+ )
+
+
+@pytest.fixture(autouse=True)
+def _no_network(monkeypatch):
+ monkeypatch.setattr(urllib.request, "urlopen", _blocked_urlopen)
diff --git a/test/firefox_test.py b/test/firefox_test.py
deleted file mode 100644
index b111d18..0000000
--- a/test/firefox_test.py
+++ /dev/null
@@ -1,50 +0,0 @@
-import unittest
-import os
-import tempfile
-import shutil
-from pathlib import Path
-
-from hackertray import Firefox
-
-class FirefoxTest(unittest.TestCase):
- def test_history(self):
- config_folder_path = os.getcwd()+'/test/'
- data = Firefox.search([
- "http://www.hckrnews.com/",
- "http://www.google.com/",
- "http://wiki.ubuntu.com/",
- "http://invalid_url/"],
- config_folder_path)
- self.assertTrue(data == [True,True,True,False])
-
- def test_default(self):
- with tempfile.TemporaryDirectory() as tmpdir:
- firefox_dir = Path(tmpdir) / "mozilla" / "firefox"
- profile_dir = firefox_dir / "x0ran0o9.default"
- profile_dir.mkdir(parents=True)
-
- # Copy test places.sqlite into the fake profile
- shutil.copy(
- Path(__file__).parent / "places.sqlite",
- profile_dir / "places.sqlite",
- )
-
- profiles_ini = firefox_dir / "profiles.ini"
- profiles_ini.write_text(
- "[Profile1]\n"
- "Name=default\n"
- "IsRelative=1\n"
- "Path=x0ran0o9.default\n"
- "Default=1\n"
- )
-
- old_val = os.environ.get("XDG_CONFIG_HOME")
- try:
- os.environ["XDG_CONFIG_HOME"] = tmpdir
- result = Firefox.default_firefox_profile_path()
- self.assertEqual(result, str(profile_dir))
- finally:
- if old_val is None:
- os.environ.pop("XDG_CONFIG_HOME", None)
- else:
- os.environ["XDG_CONFIG_HOME"] = old_val
diff --git a/test/history_test.py b/test/history_test.py
new file mode 100644
index 0000000..f709b35
--- /dev/null
+++ b/test/history_test.py
@@ -0,0 +1,308 @@
+import unittest
+import tempfile
+import shutil
+from pathlib import Path
+
+from hackertray.history import (
+ HistoryDB,
+ HistorySchema,
+ DatabaseError,
+ discover,
+ search,
+ _query_urls,
+)
+
+FIXTURES = Path(__file__).parent
+
+
+class TestChromiumSearch(unittest.TestCase):
+ def setUp(self):
+ self.db = HistoryDB(
+ label="Chrome", schema=HistorySchema.CHROMIUM, path=FIXTURES / "History"
+ )
+
+ def test_search_matches(self):
+ found = self.db.search(
+ [
+ "https://github.com/",
+ "https://news.ycombinator.com/",
+ "https://github.com/captn3m0/hackertray",
+ "http://invalid_url/",
+ ]
+ )
+ self.assertEqual(
+ found,
+ {
+ "https://github.com/",
+ "https://news.ycombinator.com/",
+ "https://github.com/captn3m0/hackertray",
+ },
+ )
+
+ def test_empty_urls(self):
+ self.assertEqual(self.db.search([]), set())
+
+ def test_no_matches(self):
+ found = self.db.search(
+ [
+ "https://nonexistent.example.com/",
+ "https://also-not-there.example.org/page",
+ ]
+ )
+ self.assertEqual(found, set())
+
+ def test_missing_db_raises(self):
+ db = HistoryDB(
+ label="Chrome",
+ schema=HistorySchema.CHROMIUM,
+ path=Path("/nonexistent/History"),
+ )
+ with self.assertRaises(DatabaseError):
+ db.search(["https://example.com/"])
+
+ def test_copied_fixture(self):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ shutil.copy(FIXTURES / "History", Path(tmpdir) / "History")
+ db = HistoryDB(
+ label="Chrome",
+ schema=HistorySchema.CHROMIUM,
+ path=Path(tmpdir) / "History",
+ )
+ found = db.search(["https://github.com/", "http://invalid_url/"])
+ self.assertEqual(found, {"https://github.com/"})
+
+
+class TestFirefoxSearch(unittest.TestCase):
+ def setUp(self):
+ self.db = HistoryDB(
+ label="Firefox",
+ schema=HistorySchema.FIREFOX,
+ path=FIXTURES / "places.sqlite",
+ )
+
+ def test_search_matches(self):
+ found = self.db.search(
+ [
+ "http://www.hckrnews.com/",
+ "http://www.google.com/",
+ "http://wiki.ubuntu.com/",
+ "http://invalid_url/",
+ ]
+ )
+ self.assertEqual(
+ found,
+ {
+ "http://www.hckrnews.com/",
+ "http://www.google.com/",
+ "http://wiki.ubuntu.com/",
+ },
+ )
+
+ def test_copied_fixture(self):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ shutil.copy(FIXTURES / "places.sqlite", Path(tmpdir) / "places.sqlite")
+ db = HistoryDB(
+ label="Firefox",
+ schema=HistorySchema.FIREFOX,
+ path=Path(tmpdir) / "places.sqlite",
+ )
+ found = db.search(["http://www.hckrnews.com/", "http://invalid_url/"])
+ self.assertEqual(found, {"http://www.hckrnews.com/"})
+
+
+class TestSafariSearch(unittest.TestCase):
+ def setUp(self):
+ self.db = HistoryDB(
+ label="Safari",
+ schema=HistorySchema.SAFARI,
+ path=FIXTURES / "safari" / "History.db",
+ )
+
+ def test_search_matches(self):
+ found = self.db.search(
+ [
+ "https://github.com/",
+ "https://news.ycombinator.com/",
+ "https://example.com/test",
+ "https://nonexistent.example.com/",
+ ]
+ )
+ self.assertEqual(
+ found,
+ {
+ "https://github.com/",
+ "https://news.ycombinator.com/",
+ "https://example.com/test",
+ },
+ )
+
+ def test_empty_urls(self):
+ self.assertEqual(self.db.search([]), set())
+
+ def test_no_matches(self):
+ found = self.db.search(["https://nothing.example.com/"])
+ self.assertEqual(found, set())
+
+ def test_missing_db_raises(self):
+ db = HistoryDB(
+ label="Safari",
+ schema=HistorySchema.SAFARI,
+ path=Path("/nonexistent/History.db"),
+ )
+ with self.assertRaises(DatabaseError):
+ db.search(["https://example.com/"])
+
+
+class TestDiscover(unittest.TestCase):
+ def _setup_and_discover(self, rel_path, fixture, platform):
+ """Create a fake browser profile and run discover."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ home = Path(tmpdir) / "home"
+ dest = home / rel_path
+ dest.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copy(FIXTURES / fixture, dest)
+ return discover(home=home, platform=platform)
+
+ def test_discover_finds_firefox_macos(self):
+ dbs = self._setup_and_discover(
+ "Library/Application Support/Firefox/abc12345.default-release/places.sqlite",
+ "places.sqlite", "macos")
+ firefox_dbs = [db for db in dbs if db.label == "Firefox"]
+ self.assertEqual(len(firefox_dbs), 1)
+ self.assertEqual(firefox_dbs[0].schema, HistorySchema.FIREFOX)
+
+ def test_discover_finds_firefox_linux(self):
+ dbs = self._setup_and_discover(
+ ".mozilla/firefox/abc12345.default-release/places.sqlite",
+ "places.sqlite", "linux")
+ firefox_dbs = [db for db in dbs if db.label == "Firefox"]
+ self.assertEqual(len(firefox_dbs), 1)
+ self.assertEqual(firefox_dbs[0].schema, HistorySchema.FIREFOX)
+
+ def test_discover_finds_chrome_macos(self):
+ dbs = self._setup_and_discover(
+ "Library/Application Support/Google/Chrome/Default/History",
+ "History", "macos")
+ chrome_dbs = [db for db in dbs if db.label == "Chrome"]
+ self.assertEqual(len(chrome_dbs), 1)
+ self.assertEqual(chrome_dbs[0].schema, HistorySchema.CHROMIUM)
+
+ def test_discover_finds_chrome_linux(self):
+ dbs = self._setup_and_discover(
+ ".config/google-chrome/Default/History",
+ "History", "linux")
+ chrome_dbs = [db for db in dbs if db.label == "Chrome"]
+ self.assertEqual(len(chrome_dbs), 1)
+ self.assertEqual(chrome_dbs[0].schema, HistorySchema.CHROMIUM)
+
+ def test_discover_finds_safari(self):
+ dbs = self._setup_and_discover(
+ "Library/Safari/History.db",
+ "safari/History.db", "macos")
+ safari_dbs = [db for db in dbs if db.label == "Safari"]
+ self.assertEqual(len(safari_dbs), 1)
+ self.assertEqual(safari_dbs[0].schema, HistorySchema.SAFARI)
+
+ def test_discover_empty_home(self):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ home = Path(tmpdir) / "emptyhome"
+ home.mkdir()
+ for platform in ("macos", "linux"):
+ dbs = discover(home=home, platform=platform)
+ self.assertEqual(dbs, [])
+
+ def test_discover_multiple_chrome_profiles_macos(self):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ home = Path(tmpdir) / "home"
+ for profile in ("Default", "Profile 1"):
+ d = home / "Library" / "Application Support" / "Google" / "Chrome" / profile
+ d.mkdir(parents=True)
+ shutil.copy(FIXTURES / "History", d / "History")
+ dbs = discover(home=home, platform="macos")
+ chrome_dbs = [db for db in dbs if db.label == "Chrome"]
+ self.assertEqual(len(chrome_dbs), 2)
+
+ def test_discover_multiple_chrome_profiles_linux(self):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ home = Path(tmpdir) / "home"
+ for profile in ("Default", "Profile 1"):
+ d = home / ".config" / "google-chrome" / profile
+ d.mkdir(parents=True)
+ shutil.copy(FIXTURES / "History", d / "History")
+ dbs = discover(home=home, platform="linux")
+ chrome_dbs = [db for db in dbs if db.label == "Chrome"]
+ self.assertEqual(len(chrome_dbs), 2)
+
+
+class TestSearch(unittest.TestCase):
+ def test_search_empty_urls(self):
+ self.assertEqual(search([]), set())
+
+ def test_search_with_databases(self):
+ db = HistoryDB(
+ label="Chrome", schema=HistorySchema.CHROMIUM, path=FIXTURES / "History"
+ )
+ found = search(["https://github.com/", "http://nope.example.com/"], [db])
+ self.assertEqual(found, {"https://github.com/"})
+
+ def test_search_auto_discover(self):
+ # With no databases installed in a fake home, returns empty
+ with tempfile.TemporaryDirectory() as tmpdir:
+ home = Path(tmpdir) / "empty"
+ home.mkdir()
+ found = search(["https://github.com/"], discover(home=home))
+ self.assertEqual(found, set())
+
+ def test_search_skips_broken_db(self):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ bad_db = Path(tmpdir) / "History"
+ bad_db.write_bytes(b"not a sqlite database")
+ broken = HistoryDB(
+ label="Broken", schema=HistorySchema.CHROMIUM, path=bad_db
+ )
+ good = HistoryDB(
+ label="Chrome", schema=HistorySchema.CHROMIUM, path=FIXTURES / "History"
+ )
+ found = search(["https://github.com/"], [broken, good])
+ self.assertEqual(found, {"https://github.com/"})
+
+ def test_search_skips_missing_db(self):
+ missing = HistoryDB(
+ label="Gone",
+ schema=HistorySchema.CHROMIUM,
+ path=Path("/nonexistent/History"),
+ )
+ found = search(["https://github.com/"], [missing])
+ self.assertEqual(found, set())
+
+
+class TestQueryValidation(unittest.TestCase):
+ def test_invalid_table_name(self):
+ with self.assertRaises(ValueError, msg="Invalid table name"):
+ _query_urls(FIXTURES / "History", "urls; DROP TABLE--", "url", ["x"])
+
+ def test_invalid_column_name(self):
+ with self.assertRaises(ValueError, msg="Invalid column name"):
+ _query_urls(FIXTURES / "History", "urls", "url; DROP--", ["x"])
+
+ def test_corrupt_file_raises(self):
+ with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
+ f.write(b"not a sqlite database")
+ f.flush()
+ with self.assertRaises(DatabaseError):
+ _query_urls(Path(f.name), "urls", "url", ["https://x.com/"])
+
+
+class TestHistorySchema(unittest.TestCase):
+ def test_chromium_schema(self):
+ self.assertEqual(HistorySchema.CHROMIUM.db_file, "History")
+ self.assertEqual(HistorySchema.CHROMIUM.table, "urls")
+ self.assertEqual(HistorySchema.CHROMIUM.column, "url")
+
+ def test_firefox_schema(self):
+ self.assertEqual(HistorySchema.FIREFOX.db_file, "places.sqlite")
+ self.assertEqual(HistorySchema.FIREFOX.table, "moz_places")
+
+ def test_safari_schema(self):
+ self.assertEqual(HistorySchema.SAFARI.db_file, "History.db")
+ self.assertEqual(HistorySchema.SAFARI.table, "history_items")
diff --git a/test/hn_test.py b/test/hn_test.py
index 07bec2d..2405a30 100644
--- a/test/hn_test.py
+++ b/test/hn_test.py
@@ -1,11 +1,34 @@
+import json
import unittest
+from pathlib import Path
+from unittest import mock
+
from hackertray import HackerNews
+FIXTURE = json.loads((Path(__file__).parent / "news_fixture.json").read_text())
+
+
class HNTest(unittest.TestCase):
- def runTest(self):
- data = HackerNews.getHomePage()
- self.assertTrue(len(data) > 0)
+ def test_parses_stories(self):
+ with mock.patch("hackertray.hackernews.urllib.request.urlopen") as m:
+ m.return_value.__enter__ = lambda s: s
+ m.return_value.__exit__ = mock.Mock(return_value=False)
+ m.return_value.read.return_value = json.dumps(FIXTURE).encode()
+
+ data = HackerNews.getHomePage()
+
+ self.assertEqual(len(data), len(FIXTURE))
+ self.assertEqual(data[0]["id"], FIXTURE[0]["id"])
+ self.assertEqual(data[0]["title"], FIXTURE[0]["title"])
+
+ def test_items_have_required_fields(self):
+ for item in FIXTURE:
+ self.assertIn("id", item)
+ self.assertIn("title", item)
+ self.assertIn("url", item)
+ self.assertIn("points", item)
+ self.assertIn("comments_count", item)
-if __name__ == '__main__':
- unittest.main()
\ No newline at end of file
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/macos_ui_test.py b/test/macos_ui_test.py
new file mode 100644
index 0000000..5174ea9
--- /dev/null
+++ b/test/macos_ui_test.py
@@ -0,0 +1,378 @@
+import sys
+import unittest
+from unittest import mock
+
+import pytest
+
+pytestmark = pytest.mark.skipif(sys.platform != "darwin", reason="macOS-only tests")
+
+if sys.platform == "darwin":
+ from AppKit import (
+ NSApplication,
+ NSApplicationActivationPolicyAccessory,
+ NSMenu,
+ NSOnState,
+ NSOffState,
+ )
+ from hackertray.macos import HackerTrayDelegate
+
+ # Need an NSApplication instance for NSMenu to work
+ _app = NSApplication.sharedApplication()
+ _app.setActivationPolicy_(NSApplicationActivationPolicyAccessory)
+
+
+def _make_item(
+ id=1,
+ title="Test Story",
+ url="https://example.com",
+ points=42,
+ comments_count=10,
+ history=False,
+ user="testuser",
+ time_ago="1 hour ago",
+):
+ return {
+ "id": id,
+ "title": title,
+ "url": url,
+ "points": points,
+ "comments_count": comments_count,
+ "history": history,
+ "user": user,
+ "time_ago": time_ago,
+ }
+
+
+def _make_delegate():
+ d = HackerTrayDelegate.alloc().init()
+ d._menu = NSMenu.alloc().init()
+ d._menu.setAutoenablesItems_(False)
+ return d
+
+
+def _news_items(menu):
+ """Return menu items that represent news stories (have representedObject)."""
+ return [
+ menu.itemAtIndex_(i)
+ for i in range(menu.numberOfItems())
+ if menu.itemAtIndex_(i).representedObject() is not None
+ ]
+
+
+def _item_titled(menu, title):
+ """Find a menu item by exact title."""
+ for i in range(menu.numberOfItems()):
+ mi = menu.itemAtIndex_(i)
+ if mi.title() == title:
+ return mi
+ return None
+
+
+class TestMenuStructure(unittest.TestCase):
+ def setUp(self):
+ self.d = _make_delegate()
+ self.data = [
+ _make_item(id=i, title=f"Story {i}", points=i * 10 + 10)
+ for i in range(1, 4)
+ ]
+ self.d._rebuild_menu(self.data)
+
+ def test_news_items_count(self):
+ self.assertEqual(len(_news_items(self.d._menu)), 3)
+
+ def test_has_refresh(self):
+ self.assertIsNotNone(_item_titled(self.d._menu, "Refresh"))
+
+ def test_has_settings(self):
+ self.assertIsNotNone(_item_titled(self.d._menu, "Settings"))
+
+ def test_has_about(self):
+ self.assertIsNotNone(_item_titled(self.d._menu, "About HackerTray"))
+
+ def test_has_quit(self):
+ self.assertIsNotNone(_item_titled(self.d._menu, "Quit"))
+
+ def test_separators_present(self):
+ seps = [
+ self.d._menu.itemAtIndex_(i)
+ for i in range(self.d._menu.numberOfItems())
+ if self.d._menu.itemAtIndex_(i).isSeparatorItem()
+ ]
+ self.assertEqual(len(seps), 2)
+
+ def test_no_visit_hn_item(self):
+ self.assertIsNone(_item_titled(self.d._menu, "Visit news.ycombinator.com"))
+
+
+class TestNewsItemFormat(unittest.TestCase):
+ def test_title_contains_points_and_title(self):
+ d = _make_delegate()
+ d._rebuild_menu([_make_item(points=7, comments_count=3, title="Hello World")])
+ mi = _news_items(d._menu)[0]
+ self.assertIn("007/003", mi.title())
+ self.assertIn("Hello World", mi.title())
+
+ def test_attributed_title_set(self):
+ d = _make_delegate()
+ d._rebuild_menu([_make_item()])
+ mi = _news_items(d._menu)[0]
+ self.assertIsNotNone(mi.attributedTitle())
+
+ def test_tooltip_contains_url_and_user(self):
+ d = _make_delegate()
+ d._rebuild_menu([_make_item(url="https://ex.com", user="alice")])
+ mi = _news_items(d._menu)[0]
+ self.assertIn("https://ex.com", mi.toolTip())
+ self.assertIn("alice", mi.toolTip())
+
+ def test_represented_object(self):
+ d = _make_delegate()
+ d._rebuild_menu([_make_item(id=99, url="https://ex.com")])
+ info = _news_items(d._menu)[0].representedObject()
+ self.assertEqual(info["url"], "https://ex.com")
+ self.assertEqual(info["hn_id"], 99)
+ self.assertEqual(info["item_id"], 99)
+
+ def test_zero_points_skipped(self):
+ d = _make_delegate()
+ d._rebuild_menu([_make_item(points=0)])
+ self.assertEqual(len(_news_items(d._menu)), 0)
+
+ def test_none_points_skipped(self):
+ d = _make_delegate()
+ d._rebuild_menu([_make_item(points=None)])
+ self.assertEqual(len(_news_items(d._menu)), 0)
+
+
+class TestVisitedState(unittest.TestCase):
+ def test_history_true_sets_checkmark(self):
+ d = _make_delegate()
+ d._rebuild_menu([_make_item(history=True)])
+ mi = _news_items(d._menu)[0]
+ self.assertEqual(mi.state(), NSOnState)
+
+ def test_history_false_no_checkmark(self):
+ d = _make_delegate()
+ d._rebuild_menu([_make_item(history=False)])
+ mi = _news_items(d._menu)[0]
+ self.assertEqual(mi.state(), NSOffState)
+
+ def test_open_link_opens_browser(self):
+ d = _make_delegate()
+ data = [_make_item(id=7, url="https://ex.com")]
+ d._rebuild_menu(data)
+ d._last_data = data
+ mi = _news_items(d._menu)[0]
+ with mock.patch("webbrowser.open") as m:
+ d.openLink_(mi)
+ m.assert_called_with("https://ex.com")
+
+ def test_open_link_with_comments(self):
+ d = _make_delegate()
+ d._comment_state = True
+ data = [_make_item(id=5, url="https://ex.com")]
+ d._rebuild_menu(data)
+ d._last_data = data
+ mi = _news_items(d._menu)[0]
+ with mock.patch("webbrowser.open") as m:
+ d.openLink_(mi)
+ calls = [c[0][0] for c in m.call_args_list]
+ self.assertIn("https://ex.com", calls)
+ self.assertIn("https://news.ycombinator.com/item?id=5", calls)
+
+
+class TestReverseOrdering(unittest.TestCase):
+ def _titles(self, menu):
+ return [mi.representedObject()["hn_id"] for mi in _news_items(menu)]
+
+ def test_default_order(self):
+ d = _make_delegate()
+ data = [_make_item(id=i, points=10) for i in [1, 2, 3]]
+ d._rebuild_menu(data)
+ self.assertEqual(self._titles(d._menu), [1, 2, 3])
+
+ def test_reverse_order(self):
+ d = _make_delegate()
+ d._reverse = True
+ data = [_make_item(id=i, points=10) for i in [1, 2, 3]]
+ d._rebuild_menu(data)
+ self.assertEqual(self._titles(d._menu), [3, 2, 1])
+
+ def test_toggle_reverse_redraws(self):
+ d = _make_delegate()
+ data = [_make_item(id=i, points=10) for i in [1, 2, 3]]
+ d._rebuild_menu(data)
+ d._last_data = data
+ self.assertEqual(self._titles(d._menu), [1, 2, 3])
+
+ # Simulate toggle
+ fake_sender = _item_titled(d._menu, "Reverse Ordering")
+ d.toggleReverse_(fake_sender)
+ self.assertTrue(d._reverse)
+ self.assertEqual(self._titles(d._menu), [3, 2, 1])
+
+
+class TestToggleComments(unittest.TestCase):
+ def test_toggle_on(self):
+ d = _make_delegate()
+ self.assertFalse(d._comment_state)
+ d._rebuild_menu([_make_item()])
+ item = _item_titled(d._menu, "Open Comments")
+ # Submenu item - get from settings submenu
+ settings = _item_titled(d._menu, "Settings")
+ sub = settings.submenu()
+ comments = sub.itemAtIndex_(0)
+ d.toggleComments_(comments)
+ self.assertTrue(d._comment_state)
+ self.assertEqual(comments.state(), NSOnState)
+
+ def test_toggle_off(self):
+ d = _make_delegate()
+ d._comment_state = True
+ d._rebuild_menu([_make_item()])
+ settings = _item_titled(d._menu, "Settings")
+ comments = settings.submenu().itemAtIndex_(0)
+ d.toggleComments_(comments)
+ self.assertFalse(d._comment_state)
+ self.assertEqual(comments.state(), NSOffState)
+
+
+class TestSettingsSubmenu(unittest.TestCase):
+ def test_comments_initial_state_off(self):
+ d = _make_delegate()
+ d._rebuild_menu([_make_item()])
+ settings = _item_titled(d._menu, "Settings")
+ comments = settings.submenu().itemAtIndex_(0)
+ self.assertEqual(comments.title(), "Open Comments")
+ self.assertEqual(comments.state(), NSOffState)
+
+ def test_comments_initial_state_on(self):
+ d = _make_delegate()
+ d._comment_state = True
+ d._rebuild_menu([_make_item()])
+ settings = _item_titled(d._menu, "Settings")
+ comments = settings.submenu().itemAtIndex_(0)
+ self.assertEqual(comments.state(), NSOnState)
+
+ def test_reverse_initial_state(self):
+ d = _make_delegate()
+ d._reverse = True
+ d._rebuild_menu([_make_item()])
+ settings = _item_titled(d._menu, "Settings")
+ reverse = settings.submenu().itemAtIndex_(1)
+ self.assertEqual(reverse.title(), "Reverse Ordering")
+ self.assertEqual(reverse.state(), NSOnState)
+
+
+class TestConfigure(unittest.TestCase):
+ def test_configure_basic(self):
+ d = _make_delegate()
+ args = mock.Mock(comments=True, reverse=True, macos_icon_color="orange")
+ with mock.patch("hackertray.history.discover", return_value=[]):
+ d.configure(args)
+ self.assertTrue(d._comment_state)
+ self.assertTrue(d._reverse)
+
+ def test_configure_discovers_databases(self):
+ d = _make_delegate()
+ args = mock.Mock(comments=False, reverse=False, macos_icon_color="orange")
+ fake_db = mock.Mock()
+ with mock.patch("hackertray.history.discover", return_value=[fake_db]):
+ d.configure(args)
+ self.assertEqual(d._history_dbs, [fake_db])
+
+
+import json
+from pathlib import Path
+
+_FIXTURE = json.loads((Path(__file__).parent / "news_fixture.json").read_text())
+
+
+def _launch_delegate(args):
+ """Create a delegate, configure it, and run applicationDidFinishLaunching_.
+
+ Calls applicationDidFinishLaunching_ directly (bypassing AppHelper.runEventLoop)
+ to exercise status bar item creation, icon setup, and timer scheduling.
+ The background refresh thread is suppressed — we populate the menu
+ synchronously with fixture data afterwards.
+ """
+ fixture_data = [dict(item, history=False) for item in _FIXTURE]
+
+ delegate = HackerTrayDelegate.alloc().init()
+ with mock.patch("hackertray.history.discover", return_value=[]):
+ delegate.configure(args)
+
+ # Suppress the background thread spawned by refresh_
+ with mock.patch("hackertray.macos.Thread"):
+ delegate.applicationDidFinishLaunching_(None)
+
+ # Synchronously populate the menu with fixture data
+ delegate._rebuild_menu(fixture_data)
+ delegate._last_data = fixture_data
+
+ return delegate
+
+
+class TestAppLaunchIntegration(unittest.TestCase):
+ """Launch the real macOS app via main_macos, let the run loop tick, then stop."""
+
+ def test_app_launches_and_populates_menu(self):
+ args = mock.Mock(
+ comments=False,
+ reverse=False,
+ macos_icon_color="orange",
+ verbose=False,
+ )
+ delegate = _launch_delegate(args)
+
+ self.assertIsInstance(delegate, HackerTrayDelegate)
+
+ # Status bar item was created with an icon
+ self.assertIsNotNone(delegate._status_item)
+ self.assertIsNotNone(delegate._status_item.button().image())
+
+ # Menu was populated with news items (fixture has 29 with non-zero points)
+ menu = delegate._menu
+ news = _news_items(menu)
+ self.assertEqual(len(news), 29)
+
+ # Verify real fixture titles made it through
+ titles = [mi.title() for mi in news]
+ self.assertTrue(any("Intel 486" in t for t in titles))
+ self.assertTrue(any("Show HN" in t for t in titles))
+
+ # Standard menu items present
+ self.assertIsNotNone(_item_titled(menu, "Refresh"))
+ self.assertIsNotNone(_item_titled(menu, "Quit"))
+
+ def test_app_launches_with_reverse(self):
+ args = mock.Mock(
+ comments=False,
+ reverse=True,
+ macos_icon_color="orange",
+ verbose=False,
+ )
+ delegate = _launch_delegate(args)
+
+ menu = delegate._menu
+ news = _news_items(menu)
+
+ # With reverse=True, items should be in reversed order
+ ids = [mi.representedObject()["hn_id"] for mi in news]
+ # The fixture data is reversed by _rebuild_menu when _reverse is True
+ fixture_ids = [
+ item["id"] for item in _FIXTURE if item["points"] and item["points"] != 0
+ ]
+ self.assertEqual(ids, list(reversed(fixture_ids)))
+
+ def test_app_launches_with_text_icon(self):
+ args = mock.Mock(
+ comments=False,
+ reverse=False,
+ macos_icon_color="none",
+ verbose=False,
+ )
+ delegate = _launch_delegate(args)
+
+ self.assertEqual(delegate._status_item.button().title(), "Y")
+ self.assertIsNone(delegate._status_item.button().image())
diff --git a/test/news_fixture.json b/test/news_fixture.json
new file mode 100644
index 0000000..851a1db
--- /dev/null
+++ b/test/news_fixture.json
@@ -0,0 +1 @@
+[{"id":47718812,"title":"Why do we tell ourselves scary stories about AI?","points":18,"user":"lschueller","time":1775831735,"time_ago":"32 minutes ago","comments_count":25,"type":"link","url":"https://www.quantamagazine.org/why-do-we-tell-ourselves-scary-stories-about-ai-20260410/","domain":"quantamagazine.org"},{"id":47676174,"title":"Mysteries of Dropbox: Property-Based Testing of a Distributed Sync Service [pdf]","points":40,"user":"JackeJR","time":1775572868,"time_ago":"3 days ago","comments_count":5,"type":"link","url":"https://www.cis.upenn.edu/~bcpierce/papers/mysteriesofdropbox.pdf","domain":"cis.upenn.edu"},{"id":47718470,"title":"Code is run more than read (2023)","points":11,"user":"facundo_olano","time":1775830368,"time_ago":"an hour ago","comments_count":0,"type":"link","url":"https://olano.dev/blog/code-is-run-more-than-read/","domain":"olano.dev"},{"id":47718830,"title":"CPU-Z and HWMonitor Compromised","points":36,"user":"Wingy","time":1775831849,"time_ago":"30 minutes ago","comments_count":5,"type":"link","url":"https://old.reddit.com/r/pcmasterrace/comments/1sh4e5l/warning_hwmonitor_163_download_on_the_official/","domain":"old.reddit.com"},{"id":47716490,"title":"FBI used iPhone notification data to retrieve deleted Signal messages","points":274,"user":"01-_-","time":1775820544,"time_ago":"4 hours ago","comments_count":121,"type":"link","url":"https://9to5mac.com/2026/04/09/fbi-used-iphone-notification-data-to-retrieve-deleted-signal-messages/","domain":"9to5mac.com"},{"id":47716809,"title":"Intel 486 CPU announced April 10, 1989","points":103,"user":"jnord","time":1775822614,"time_ago":"3 hours ago","comments_count":80,"type":"link","url":"https://dfarq.homeip.net/intel-486-cpu-announced-april-10-1989/","domain":"dfarq.homeip.net"},{"id":47704804,"title":"How NASA built Artemis II’s fault-tolerant computer","points":509,"user":"speckx","time":1775747560,"time_ago":"a day ago","comments_count":191,"type":"link","url":"https://cacm.acm.org/news/how-nasa-built-artemis-iis-fault-tolerant-computer/","domain":"cacm.acm.org"},{"id":47713495,"title":"A new trick brings stability to quantum operations","points":190,"user":"joko42","time":1775793897,"time_ago":"11 hours ago","comments_count":45,"type":"link","url":"https://ethz.ch/en/news-and-events/eth-news/news/2026/04/a-new-trick-brings-stability-to-quantum-operations.html","domain":"ethz.ch"},{"id":47712718,"title":"I still prefer MCP over skills","points":324,"user":"gmays","time":1775786508,"time_ago":"13 hours ago","comments_count":273,"type":"link","url":"https://david.coffee/i-still-prefer-mcp-over-skills/","domain":"david.coffee"},{"id":47717973,"title":"The effects of caffeine consumption do not decay with a ~5 hour half-life","points":52,"user":"swah","time":1775828319,"time_ago":"an hour ago","comments_count":37,"type":"link","url":"https://www.lesswrong.com/posts/vefsxkGWkEMmDcZ7v/the-effects-of-caffeine-consumption-do-not-decay-with-a-5","domain":"lesswrong.com"},{"id":47674894,"title":"Model-Based Testing for Dungeons & Dragons","points":79,"user":"Firfi","time":1775567741,"time_ago":"3 days ago","comments_count":30,"type":"link","url":"https://www.loskutoff.com/blog/model-based-testing-dnd/","domain":"loskutoff.com"},{"id":47717587,"title":"OpenAI backs Illinois bill that would limit when AI labs can be held liable","points":320,"user":"smurda","time":1775826523,"time_ago":"2 hours ago","comments_count":219,"type":"link","url":"https://www.wired.com/story/openai-backs-bill-exempt-ai-firms-model-harm-lawsuits/","domain":"wired.com"},{"id":47714273,"title":"Penguin 'Toxicologists' Find PFAS Chemicals in Remote Patagonia","points":73,"user":"giuliomagnifico","time":1775801746,"time_ago":"9 hours ago","comments_count":27,"type":"link","url":"https://www.ucdavis.edu/health/news/penguin-toxicologists-find-pfas-chemicals-remote-patagonia","domain":"ucdavis.edu"},{"id":47690609,"title":"Deterministic Primality Testing for Limited Bit Width","points":5,"user":"ibobev","time":1775657979,"time_ago":"2 days ago","comments_count":0,"type":"link","url":"https://www.jeremykun.com/2026/04/07/deterministic-miller-rabin/","domain":"jeremykun.com"},{"id":47708818,"title":"Native Instant Space Switching on macOS","points":579,"user":"PaulHoule","time":1775764106,"time_ago":"19 hours ago","comments_count":282,"type":"link","url":"https://arhan.sh/blog/native-instant-space-switching-on-macos/","domain":"arhan.sh"},{"id":47678844,"title":"Show HN: Marimo pair – Reactive Python notebooks as environments for agents","points":62,"user":"manzt","time":1775584066,"time_ago":"3 days ago","comments_count":12,"type":"link","url":"https://github.com/marimo-team/marimo-pair","domain":"github.com"},{"id":47714573,"title":"Artemis II and the invisible hazard on the way to the Moon","points":59,"user":"zeristor","time":1775804657,"time_ago":"8 hours ago","comments_count":46,"type":"link","url":"https://www.ansto.gov.au/news/artemis-ii-and-invisible-hazard-on-way-to-moon-part-1","domain":"ansto.gov.au"},{"id":47712656,"title":"We've raised $17M to build what comes after Git","points":229,"user":"ellieh","time":1775785978,"time_ago":"13 hours ago","comments_count":492,"type":"link","url":"https://blog.gitbutler.com/series-a","domain":"blog.gitbutler.com"},{"id":47708697,"title":"Show HN: QVAC SDK, a universal JavaScript SDK for building local AI applications","points":20,"user":"qvac","time":1775763488,"time_ago":"19 hours ago","comments_count":4,"type":"link","url":"item?id=47708697"},{"id":47718458,"title":"Tech job relocation market is recovering. The competition is growing faster","points":16,"user":"andrewstetsenko","time":1775830319,"time_ago":"an hour ago","comments_count":0,"type":"link","url":"https://relocateme.substack.com/p/the-tech-relocation-job-market-is","domain":"relocateme.substack.com"},{"id":47715339,"title":"Show HN: Keeper – embedded secret store for Go (help me break it)","points":48,"user":"babawere","time":1775811213,"time_ago":"6 hours ago","comments_count":26,"type":"link","url":"https://github.com/agberohq/keeper","domain":"github.com"},{"id":47675906,"title":"Generative art over the years","points":202,"user":"evakhoury","time":1775571923,"time_ago":"3 days ago","comments_count":50,"type":"link","url":"https://blog.veitheller.de/Generative_art_over_the_years.html","domain":"blog.veitheller.de"},{"id":47713744,"title":"CollectWise (YC F24) Is Hiring","points":null,"user":null,"time":1775796185,"time_ago":"10 hours ago","comments_count":0,"type":"job","url":"https://www.ycombinator.com/companies/collectwise/jobs/Ktc6m6o-ai-agent-engineer","domain":"ycombinator.com"},{"id":47686046,"title":"The Art of Risk Management (2017)","points":38,"user":"walterbell","time":1775628944,"time_ago":"2 days ago","comments_count":11,"type":"link","url":"https://www.bcg.com/publications/2017/finance-function-excellence-corporate-development-art-risk-management","domain":"bcg.com"},{"id":47709158,"title":"Charcuterie – Visual similarity Unicode explorer","points":278,"user":"rickcarlino","time":1775765527,"time_ago":"19 hours ago","comments_count":64,"type":"link","url":"https://charcuterie.elastiq.ch/","domain":"charcuterie.elastiq.ch"},{"id":47680005,"title":"RAM Has a Design Flaw from 1966. I Bypassed It [video]","points":326,"user":"surprisetalk","time":1775589272,"time_ago":"3 days ago","comments_count":114,"type":"link","url":"https://www.youtube.com/watch?v=KKbgulTp3FE","domain":"youtube.com"},{"id":47707477,"title":"Old laptops in a colo as low cost servers","points":354,"user":"argentum47","time":1775758959,"time_ago":"21 hours ago","comments_count":207,"type":"link","url":"https://colaptop.pages.dev/","domain":"colaptop.pages.dev"},{"id":47706140,"title":"Unfolder for Mac – A 3D model unfolding tool for creating papercraft","points":285,"user":"codazoda","time":1775753926,"time_ago":"a day ago","comments_count":55,"type":"link","url":"https://www.unfolder.app/","domain":"unfolder.app"},{"id":47718114,"title":"US summons bank bosses over cyber risks from Anthropic's latest AI model","points":10,"user":"ascold","time":1775828837,"time_ago":"an hour ago","comments_count":1,"type":"link","url":"https://www.theguardian.com/technology/2026/apr/10/us-summoned-bank-bosses-to-discuss-cyber-risks-posed-by-anthropic-latest-ai-model","domain":"theguardian.com"},{"id":47707632,"title":"Instant 1.0, a backend for AI-coded apps","points":187,"user":"stopachka","time":1775759459,"time_ago":"21 hours ago","comments_count":97,"type":"link","url":"https://www.instantdb.com/essays/architecture","domain":"instantdb.com"}]
\ No newline at end of file
diff --git a/test/safari/History.db b/test/safari/History.db
new file mode 100644
index 0000000..7848299
Binary files /dev/null and b/test/safari/History.db differ
diff --git a/test/version_test.py b/test/version_test.py
index 0ff4292..1d4ffa5 100644
--- a/test/version_test.py
+++ b/test/version_test.py
@@ -1,10 +1,24 @@
+import json
import unittest
+from unittest import mock
+
from hackertray import Version
+
class VersionTest(unittest.TestCase):
- def runTest(self):
- version = Version.latest()
- assert version
+ def test_latest(self):
+ fake_response = json.dumps({"info": {"version": "9.9.9"}}).encode()
+ with mock.patch("hackertray.version.urllib.request.urlopen") as m:
+ m.return_value.__enter__ = lambda s: s
+ m.return_value.__exit__ = mock.Mock(return_value=False)
+ m.return_value.read.return_value = fake_response
+ version = Version.latest()
+ self.assertEqual(version, "9.9.9")
+
+ def test_current(self):
+ version = Version.current()
+ self.assertIsInstance(version, str)
+
-if __name__ == '__main__':
- unittest.main()
\ No newline at end of file
+if __name__ == "__main__":
+ unittest.main()
diff --git a/uv.lock b/uv.lock
index d2c77fd..7adf1f5 100644
--- a/uv.lock
+++ b/uv.lock
@@ -3,10 +3,296 @@ revision = 3
requires-python = ">=3.11"
[options]
-exclude-newer = "2026-03-23T16:36:16.327460101Z"
+exclude-newer = "2026-04-03T15:52:14.933796379Z"
exclude-newer-span = "P7D"
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
+]
+
+[[package]]
+name = "coverage"
+version = "7.13.5"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/4b/37/d24c8f8220ff07b839b2c043ea4903a33b0f455abe673ae3c03bbdb7f212/coverage-7.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d", size = 219381, upload-time = "2026-03-17T10:30:14.68Z" },
+ { url = "https://files.pythonhosted.org/packages/35/8b/cd129b0ca4afe886a6ce9d183c44d8301acbd4ef248622e7c49a23145605/coverage-7.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587", size = 219880, upload-time = "2026-03-17T10:30:16.231Z" },
+ { url = "https://files.pythonhosted.org/packages/55/2f/e0e5b237bffdb5d6c530ce87cc1d413a5b7d7dfd60fb067ad6d254c35c76/coverage-7.13.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642", size = 250303, upload-time = "2026-03-17T10:30:17.748Z" },
+ { url = "https://files.pythonhosted.org/packages/92/be/b1afb692be85b947f3401375851484496134c5554e67e822c35f28bf2fbc/coverage-7.13.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b", size = 252218, upload-time = "2026-03-17T10:30:19.804Z" },
+ { url = "https://files.pythonhosted.org/packages/da/69/2f47bb6fa1b8d1e3e5d0c4be8ccb4313c63d742476a619418f85740d597b/coverage-7.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686", size = 254326, upload-time = "2026-03-17T10:30:21.321Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/d0/79db81da58965bd29dabc8f4ad2a2af70611a57cba9d1ec006f072f30a54/coverage-7.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743", size = 256267, upload-time = "2026-03-17T10:30:23.094Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/32/d0d7cc8168f91ddab44c0ce4806b969df5f5fdfdbb568eaca2dbc2a04936/coverage-7.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75", size = 250430, upload-time = "2026-03-17T10:30:25.311Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/06/a055311d891ddbe231cd69fdd20ea4be6e3603ffebddf8704b8ca8e10a3c/coverage-7.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209", size = 252017, upload-time = "2026-03-17T10:30:27.284Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/f6/d0fd2d21e29a657b5f77a2fe7082e1568158340dceb941954f776dce1b7b/coverage-7.13.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a", size = 250080, upload-time = "2026-03-17T10:30:29.481Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/ab/0d7fb2efc2e9a5eb7ddcc6e722f834a69b454b7e6e5888c3a8567ecffb31/coverage-7.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e", size = 253843, upload-time = "2026-03-17T10:30:31.301Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/6f/7467b917bbf5408610178f62a49c0ed4377bb16c1657f689cc61470da8ce/coverage-7.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd", size = 249802, upload-time = "2026-03-17T10:30:33.358Z" },
+ { url = "https://files.pythonhosted.org/packages/75/2c/1172fb689df92135f5bfbbd69fc83017a76d24ea2e2f3a1154007e2fb9f8/coverage-7.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8", size = 250707, upload-time = "2026-03-17T10:30:35.2Z" },
+ { url = "https://files.pythonhosted.org/packages/67/21/9ac389377380a07884e3b48ba7a620fcd9dbfaf1d40565facdc6b36ec9ef/coverage-7.13.5-cp311-cp311-win32.whl", hash = "sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf", size = 221880, upload-time = "2026-03-17T10:30:36.775Z" },
+ { url = "https://files.pythonhosted.org/packages/af/7f/4cd8a92531253f9d7c1bbecd9fa1b472907fb54446ca768c59b531248dc5/coverage-7.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9", size = 222816, upload-time = "2026-03-17T10:30:38.891Z" },
+ { url = "https://files.pythonhosted.org/packages/12/a6/1d3f6155fb0010ca68eba7fe48ca6c9da7385058b77a95848710ecf189b1/coverage-7.13.5-cp311-cp311-win_arm64.whl", hash = "sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028", size = 221483, upload-time = "2026-03-17T10:30:40.463Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" },
+ { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" },
+ { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" },
+ { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" },
+ { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" },
+ { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" },
+ { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" },
+ { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" },
+ { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" },
+ { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" },
+ { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" },
+ { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" },
+ { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" },
+ { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" },
+ { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" },
+ { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" },
+ { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" },
+ { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" },
+ { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" },
+ { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" },
+ { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" },
+ { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" },
+ { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" },
+ { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" },
+ { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" },
+ { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" },
+ { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" },
+ { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" },
+]
+
+[package.optional-dependencies]
+toml = [
+ { name = "tomli", marker = "python_full_version <= '3.11'" },
+]
+
[[package]]
name = "hackertray"
version = "5.0.0"
source = { editable = "." }
+dependencies = [
+ { name = "pyobjc-core", marker = "sys_platform == 'darwin'" },
+ { name = "pyobjc-framework-cocoa", marker = "sys_platform == 'darwin'" },
+]
+
+[package.dev-dependencies]
+dev = [
+ { name = "pytest" },
+ { name = "pytest-cov" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "pyobjc-core", marker = "sys_platform == 'darwin'", specifier = ">=10.0" },
+ { name = "pyobjc-framework-cocoa", marker = "sys_platform == 'darwin'", specifier = ">=10.0" },
+]
+
+[package.metadata.requires-dev]
+dev = [
+ { name = "pytest", specifier = ">=9.0.2" },
+ { name = "pytest-cov", specifier = ">=7.1.0" },
+]
+
+[[package]]
+name = "iniconfig"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
+]
+
+[[package]]
+name = "packaging"
+version = "26.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
+]
+
+[[package]]
+name = "pluggy"
+version = "1.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
+]
+
+[[package]]
+name = "pygments"
+version = "2.20.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
+]
+
+[[package]]
+name = "pyobjc-core"
+version = "12.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b8/b6/d5612eb40be4fd5ef88c259339e6313f46ba67577a95d86c3470b951fce0/pyobjc_core-12.1.tar.gz", hash = "sha256:2bb3903f5387f72422145e1466b3ac3f7f0ef2e9960afa9bcd8961c5cbf8bd21", size = 1000532, upload-time = "2025-11-14T10:08:28.292Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/95/df/d2b290708e9da86d6e7a9a2a2022b91915cf2e712a5a82e306cb6ee99792/pyobjc_core-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c918ebca280925e7fcb14c5c43ce12dcb9574a33cccb889be7c8c17f3bcce8b6", size = 671263, upload-time = "2025-11-14T09:31:35.231Z" },
+ { url = "https://files.pythonhosted.org/packages/64/5a/6b15e499de73050f4a2c88fff664ae154307d25dc04da8fb38998a428358/pyobjc_core-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:818bcc6723561f207e5b5453efe9703f34bc8781d11ce9b8be286bb415eb4962", size = 678335, upload-time = "2025-11-14T09:32:20.107Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/d2/29e5e536adc07bc3d33dd09f3f7cf844bf7b4981820dc2a91dd810f3c782/pyobjc_core-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:01c0cf500596f03e21c23aef9b5f326b9fb1f8f118cf0d8b66749b6cf4cbb37a", size = 677370, upload-time = "2025-11-14T09:33:05.273Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/f0/4b4ed8924cd04e425f2a07269943018d43949afad1c348c3ed4d9d032787/pyobjc_core-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:177aaca84bb369a483e4961186704f64b2697708046745f8167e818d968c88fc", size = 719586, upload-time = "2025-11-14T09:33:53.302Z" },
+ { url = "https://files.pythonhosted.org/packages/25/98/9f4ed07162de69603144ff480be35cd021808faa7f730d082b92f7ebf2b5/pyobjc_core-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:844515f5d86395b979d02152576e7dee9cc679acc0b32dc626ef5bda315eaa43", size = 670164, upload-time = "2025-11-14T09:34:37.458Z" },
+ { url = "https://files.pythonhosted.org/packages/62/50/dc076965c96c7f0de25c0a32b7f8aa98133ed244deaeeacfc758783f1f30/pyobjc_core-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:453b191df1a4b80e756445b935491b974714456ae2cbae816840bd96f86db882", size = 712204, upload-time = "2025-11-14T09:35:24.148Z" },
+]
+
+[[package]]
+name = "pyobjc-framework-cocoa"
+version = "12.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pyobjc-core" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/02/a3/16ca9a15e77c061a9250afbae2eae26f2e1579eb8ca9462ae2d2c71e1169/pyobjc_framework_cocoa-12.1.tar.gz", hash = "sha256:5556c87db95711b985d5efdaaf01c917ddd41d148b1e52a0c66b1a2e2c5c1640", size = 2772191, upload-time = "2025-11-14T10:13:02.069Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3f/07/5760735c0fffc65107e648eaf7e0991f46da442ac4493501be5380e6d9d4/pyobjc_framework_cocoa-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f52228bcf38da64b77328787967d464e28b981492b33a7675585141e1b0a01e6", size = 383812, upload-time = "2025-11-14T09:40:53.169Z" },
+ { url = "https://files.pythonhosted.org/packages/95/bf/ee4f27ec3920d5c6fc63c63e797c5b2cc4e20fe439217085d01ea5b63856/pyobjc_framework_cocoa-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:547c182837214b7ec4796dac5aee3aa25abc665757b75d7f44f83c994bcb0858", size = 384590, upload-time = "2025-11-14T09:41:17.336Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/31/0c2e734165abb46215797bd830c4bdcb780b699854b15f2b6240515edcc6/pyobjc_framework_cocoa-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5a3dcd491cacc2f5a197142b3c556d8aafa3963011110102a093349017705118", size = 384689, upload-time = "2025-11-14T09:41:41.478Z" },
+ { url = "https://files.pythonhosted.org/packages/23/3b/b9f61be7b9f9b4e0a6db18b3c35c4c4d589f2d04e963e2174d38c6555a92/pyobjc_framework_cocoa-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:914b74328c22d8ca261d78c23ef2befc29776e0b85555973927b338c5734ca44", size = 388843, upload-time = "2025-11-14T09:42:05.719Z" },
+ { url = "https://files.pythonhosted.org/packages/59/bb/f777cc9e775fc7dae77b569254570fe46eb842516b3e4fe383ab49eab598/pyobjc_framework_cocoa-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:03342a60fc0015bcdf9b93ac0b4f457d3938e9ef761b28df9564c91a14f0129a", size = 384932, upload-time = "2025-11-14T09:42:29.771Z" },
+ { url = "https://files.pythonhosted.org/packages/58/27/b457b7b37089cad692c8aada90119162dfb4c4a16f513b79a8b2b022b33b/pyobjc_framework_cocoa-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6ba1dc1bfa4da42d04e93d2363491275fb2e2be5c20790e561c8a9e09b8cf2cc", size = 388970, upload-time = "2025-11-14T09:42:53.964Z" },
+]
+
+[[package]]
+name = "pytest"
+version = "9.0.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "iniconfig" },
+ { name = "packaging" },
+ { name = "pluggy" },
+ { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
+]
+
+[[package]]
+name = "pytest-cov"
+version = "7.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "coverage", extra = ["toml"] },
+ { name = "pluggy" },
+ { name = "pytest" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
+]
+
+[[package]]
+name = "tomli"
+version = "2.4.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" },
+ { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" },
+ { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" },
+ { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" },
+ { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" },
+ { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" },
+ { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" },
+ { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" },
+ { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" },
+ { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" },
+ { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" },
+ { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" },
+ { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" },
+ { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" },
+ { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" },
+ { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" },
+ { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" },
+ { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" },
+ { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" },
+ { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" },
+ { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" },
+ { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" },
+ { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" },
+ { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" },
+ { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" },
+ { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
+]