• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

captn3m0 / hackertray / 24267775776

10 Apr 2026 10:51PM UTC coverage: 55.344%. First build
24267775776

Pull #35

github

web-flow
Merge 4fc022aa9 into e35fdef4e
Pull Request #35: Adds MacOS and improved history support

260 of 489 new or added lines in 6 files covered. (53.17%)

290 of 524 relevant lines covered (55.34%)

0.8 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/hackertray/linux.py
1
#!/usr/bin/env python
2
"""Linux GTK system tray app for HackerTray."""
3

NEW
4
import logging
×
NEW
5
import signal
×
NEW
6
import urllib.error
×
NEW
7
import webbrowser
×
8

NEW
9
import gi
×
10

NEW
11
gi.require_version("Gtk", "3.0")
×
NEW
12
from gi.repository import Gtk, GLib
×
13

NEW
14
gi.require_version("AppIndicator3", "0.1")
×
NEW
15
from gi.repository import AppIndicator3 as AppIndicator
×
16

NEW
17
import importlib
×
NEW
18
import importlib.resources
×
NEW
19
import os
×
NEW
20
import configparser
×
21

NEW
22
from .hackernews import HackerNews
×
NEW
23
from .version import Version
×
24

NEW
25
logger = logging.getLogger(__name__)
×
26

27

NEW
28
class HackerNewsApp:
×
NEW
29
    HN_URL_PREFIX = "https://news.ycombinator.com/item?id="
×
NEW
30
    UPDATE_URL = "https://github.com/captn3m0/hackertray#upgrade"
×
NEW
31
    ABOUT_URL = "https://github.com/captn3m0/hackertray"
×
32

NEW
33
    def __init__(self, args):
×
34
        # create an indicator applet
NEW
35
        self.ind = AppIndicator.Indicator.new(
×
36
            "Hacker Tray",
37
            "hacker-tray",
38
            AppIndicator.IndicatorCategory.APPLICATION_STATUS,
39
        )
NEW
40
        self.ind.set_status(AppIndicator.IndicatorStatus.ACTIVE)
×
NEW
41
        self.ind.set_icon_theme_path(self._icon_theme_path())
×
NEW
42
        icon_name = "hacker-tray-light" if self._is_light_theme() else "hacker-tray"
×
NEW
43
        self.ind.set_icon(icon_name)
×
44

45
        # create a menu
NEW
46
        self.menu = Gtk.Menu()
×
47

NEW
48
        self.commentState = args.comments
×
NEW
49
        self.reverse = args.reverse
×
50

51
        # Discover browser history databases
NEW
52
        from .history import discover
×
53

NEW
54
        self._history_dbs = discover()
×
55

56
        # create items for the menu - separator, settings, about, refresh, quit
NEW
57
        menuSeparator = Gtk.SeparatorMenuItem()
×
NEW
58
        menuSeparator.show()
×
NEW
59
        self.add(menuSeparator)
×
60

61
        # Settings submenu
NEW
62
        settingsItem = Gtk.MenuItem.new_with_label("Settings")
×
NEW
63
        settingsMenu = Gtk.Menu()
×
NEW
64
        settingsItem.set_submenu(settingsMenu)
×
65

NEW
66
        btnComments = Gtk.CheckMenuItem.new_with_label("Open Comments")
×
NEW
67
        btnComments.set_active(args.comments)
×
NEW
68
        btnComments.connect("toggled", self.toggleComments)
×
NEW
69
        settingsMenu.append(btnComments)
×
NEW
70
        btnComments.show()
×
71

NEW
72
        btnReverse = Gtk.CheckMenuItem.new_with_label("Reverse Ordering")
×
NEW
73
        btnReverse.set_active(args.reverse)
×
NEW
74
        btnReverse.connect("toggled", self.toggleReverse)
×
NEW
75
        settingsMenu.append(btnReverse)
×
NEW
76
        btnReverse.show()
×
77

NEW
78
        self.add(settingsItem)
×
NEW
79
        settingsItem.show()
×
80

NEW
81
        btnAbout = Gtk.MenuItem.new_with_label("About")
×
NEW
82
        btnAbout.show()
×
NEW
83
        btnAbout.connect("activate", self.showAbout)
×
NEW
84
        self.add(btnAbout)
×
85

NEW
86
        btnRefresh = Gtk.MenuItem.new_with_label("Refresh")
×
NEW
87
        btnRefresh.show()
×
NEW
88
        btnRefresh.connect("activate", self.refresh, True)
×
NEW
89
        self.add(btnRefresh)
×
90

NEW
91
        if Version.new_available():
×
NEW
92
            btnUpdate = Gtk.MenuItem.new_with_label("New Update Available")
×
NEW
93
            btnUpdate.show()
×
NEW
94
            btnUpdate.connect("activate", self.showUpdate)
×
NEW
95
            self.add(btnUpdate)
×
96

NEW
97
        btnQuit = Gtk.MenuItem.new_with_label("Quit")
×
NEW
98
        btnQuit.show()
×
NEW
99
        btnQuit.connect("activate", self.quit)
×
NEW
100
        self.add(btnQuit)
×
NEW
101
        self.menu.show()
×
NEW
102
        self.ind.set_menu(self.menu)
×
103

NEW
104
        self.refresh()
×
105

NEW
106
    def add(self, item):
×
NEW
107
        if self.reverse:
×
NEW
108
            self.menu.prepend(item)
×
109
        else:
NEW
110
            self.menu.append(item)
×
111

NEW
112
    def toggleComments(self, widget):
×
113
        """Whether comments page is opened or not"""
NEW
114
        self.commentState = widget.get_active()
×
115

NEW
116
    def toggleReverse(self, widget):
×
NEW
117
        self.reverse = widget.get_active()
×
118

NEW
119
    def showUpdate(self, widget):
×
120
        """Handle the update button"""
NEW
121
        webbrowser.open(HackerNewsApp.UPDATE_URL)
×
122
        # Remove the update button once clicked
NEW
123
        self.menu.remove(widget)
×
124

NEW
125
    def showAbout(self, widget):
×
126
        """Handle the about btn"""
NEW
127
        webbrowser.open(HackerNewsApp.ABOUT_URL)
×
128

NEW
129
    def quit(self, widget, data=None):
×
130
        """Handler for the quit button"""
NEW
131
        Gtk.main_quit()
×
132

NEW
133
    def run(self):
×
NEW
134
        signal.signal(signal.SIGINT, self.quit)
×
NEW
135
        Gtk.main()
×
NEW
136
        return 0
×
137

NEW
138
    def open(self, widget, **args):
×
139
        """Opens the link in the web browser"""
140
        # We disconnect and reconnect the event in case we have
141
        # to set it to active and we don't want the signal to be processed
NEW
142
        if not widget.get_active():
×
NEW
143
            widget.disconnect(widget.signal_id)
×
NEW
144
            widget.set_active(True)
×
NEW
145
            widget.signal_id = widget.connect("activate", self.open)
×
146

NEW
147
        webbrowser.open(widget.url)
×
148

149
        # TODO: Add support for Shift+Click or Right Click
150
        # to do the opposite of the current commentState setting
NEW
151
        if self.commentState:
×
NEW
152
            webbrowser.open(self.HN_URL_PREFIX + str(widget.hn_id))
×
153

NEW
154
    def addItem(self, item):
×
155
        """Adds an item to the menu"""
156
        # This is in the case of YC Job Postings, which we skip
NEW
157
        if item["points"] == 0 or item["points"] is None:
×
NEW
158
            return
×
159

NEW
160
        points = (
×
161
            str(item["points"]).zfill(3) + "/" + str(item["comments_count"]).zfill(3)
162
        )
163

NEW
164
        i = Gtk.CheckMenuItem.new_with_label(label="(" + points + ")" + item["title"])
×
NEW
165
        label = i.get_child()
×
NEW
166
        label.set_markup(
×
167
            "<tt>"
168
            + points
169
            + "</tt> <span>"
170
            + item["title"]
171
            + "</span>".format(points=points, title=item["title"])
172
        )
NEW
173
        label.set_selectable(False)
×
174

NEW
175
        visited = item["history"]
×
NEW
176
        logger.debug("%s: %s", "visited" if visited else "unvisited", item["url"])
×
177

NEW
178
        i.url = item["url"]
×
NEW
179
        tooltip = "{url}\nPosted by {user} {timeago}".format(
×
180
            url=item["url"], user=item["user"], timeago=item["time_ago"]
181
        )
NEW
182
        i.set_tooltip_text(tooltip)
×
NEW
183
        i.hn_id = item["id"]
×
NEW
184
        i.item_id = item["id"]
×
NEW
185
        i.set_active(visited)
×
NEW
186
        i.signal_id = i.connect("activate", self.open)
×
NEW
187
        if self.reverse:
×
NEW
188
            self.menu.append(i)
×
189
        else:
NEW
190
            self.menu.prepend(i)
×
NEW
191
        i.show()
×
192

NEW
193
    def refresh(self, widget=None, no_timer=False):
×
194
        """Refreshes the menu"""
NEW
195
        try:
×
NEW
196
            data = list(reversed(HackerNews.getHomePage()[0:20]))
×
NEW
197
            urls = [item["url"] for item in data]
×
198

199
            # Search browser history
NEW
200
            from .history import search as history_search
×
201

NEW
202
            visited_urls = history_search(urls, self._history_dbs)
×
203

204
            # Remove all the current stories
NEW
205
            for i in self.menu.get_children():
×
NEW
206
                if hasattr(i, "url"):
×
NEW
207
                    self.menu.remove(i)
×
208

209
            # Add back all the refreshed news
NEW
210
            for item in data:
×
NEW
211
                item["history"] = item["url"] in visited_urls
×
NEW
212
                if item["url"].startswith("item?id="):
×
NEW
213
                    item["url"] = "https://news.ycombinator.com/" + item["url"]
×
214

NEW
215
                self.addItem(item)
×
216
        # Catch network errors
NEW
217
        except urllib.error.URLError as e:
×
NEW
218
            print("[+] There was an error in fetching news items")
×
219
        finally:
220
            # Call every 10 minutes
NEW
221
            if not no_timer:
×
NEW
222
                GLib.timeout_add(10 * 30 * 1000, self.refresh)
×
223

NEW
224
    @staticmethod
×
NEW
225
    def _icon_theme_path():
×
226
        """Return the icon data dir as a host-accessible path.
227

228
        AppIndicator sends this path over D-Bus to the tray host, which runs
229
        outside the Flatpak sandbox. Inside a Flatpak, /app/ paths are not
230
        accessible from the host, so we translate via /.flatpak-info."""
NEW
231
        data_dir = str(importlib.resources.files("hackertray.data"))
×
NEW
232
        if os.path.exists("/.flatpak-info"):
×
NEW
233
            import configparser
×
234

NEW
235
            info = configparser.ConfigParser()
×
NEW
236
            info.read("/.flatpak-info")
×
NEW
237
            app_path = info.get("Instance", "app-path")
×
NEW
238
            data_dir = app_path + data_dir.removeprefix("/app")
×
NEW
239
        return data_dir
×
240

NEW
241
    @staticmethod
×
NEW
242
    def _is_light_theme():
×
NEW
243
        settings = Gtk.Settings.get_default()
×
NEW
244
        if settings and settings.get_property("gtk-application-prefer-dark-theme"):
×
NEW
245
            return False
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc