Skip to content

Commit c02725f

Browse files
committed
flatpak nonsense
1 parent e62533a commit c02725f

8 files changed

Lines changed: 199 additions & 41 deletions

File tree

.gitmodules

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[submodule "shared-modules"]
2+
path = shared-modules
3+
url = https://github.com/flathub/shared-modules.git

HACKING.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# Hacking on HackerTray
2+
3+
## Development Setup
4+
5+
```bash
6+
git clone --recurse-submodules https://github.com/captn3m0/hackertray.git
7+
cd hackertray
8+
uv run --with pytest python -m pytest
9+
```
10+
11+
## Flatpak
12+
13+
### Build
14+
15+
```bash
16+
flatpak install flathub org.gnome.Platform//49 org.gnome.Sdk//49
17+
flatpak-builder --install-deps-from=flathub --force-clean build-dir in.captnemo.hackertray.yml
18+
```
19+
20+
### Install locally
21+
22+
```bash
23+
flatpak-builder --user --install --force-clean build-dir in.captnemo.hackertray.yml
24+
flatpak run in.captnemo.hackertray
25+
```
26+
27+
### Install from remote flatpakref (once published)
28+
29+
A single-file flatpakref is hosted on GitHub Pages:
30+
31+
```bash
32+
flatpak install https://captnemo.in/hackertray/hackertray.flatpakref
33+
```
34+
35+
### Publish
36+
37+
**WIP**: Not published anywhere yet.
38+
39+
The Flatpak is built as a single-file bundle and attached to GitHub Releases. To generate a bundle locally:
40+
41+
```bash
42+
flatpak-builder --repo=repo --force-clean build-dir in.captnemo.hackertray.yml
43+
flatpak build-bundle repo hackertray.flatpak in.captnemo.hackertray
44+
```
45+
46+
Install from the bundle:
47+
48+
```bash
49+
flatpak install hackertray.flatpak
50+
```
51+
52+
## Release
53+
54+
1. Update version in `pyproject.toml`
55+
2. Update `CHANGELOG.md`
56+
3. Commit and tag: `git tag x.y.z && git push --tags`
57+
4. PyPI publish happens automatically via GitHub Actions (trusted publishing)
58+
5. Build and attach Flatpak bundle to the GitHub Release

hackertray/__init__.py

Lines changed: 88 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -42,55 +42,83 @@ def __init__(self, args):
4242
self.db = set()
4343

4444
# create an indicator applet
45-
self.ind = AppIndicator.Indicator.new("Hacker Tray", "hacker-tray", AppIndicator.IndicatorCategory.APPLICATION_STATUS )
45+
self.ind = AppIndicator.Indicator.new("Hacker Tray", "hacker-tray", AppIndicator.IndicatorCategory.APPLICATION_STATUS)
4646
self.ind.set_status(AppIndicator.IndicatorStatus.ACTIVE)
47-
self.ind.set_icon(self.get_icon_filename("hacker-tray.png"))
47+
self.ind.set_icon_theme_path(self._icon_theme_path())
48+
icon_name = "hacker-tray-light" if self._is_light_theme() else "hacker-tray"
49+
self.ind.set_icon(icon_name)
4850

4951
# create a menu
5052
self.menu = Gtk.Menu()
5153

52-
# The default state is false, and it toggles when you click on it
5354
self.commentState = args.comments
5455
self.reverse = args.reverse
56+
self.chrome_data_directory = args.chrome
5557

56-
# create items for the menu - refresh, quit and a separator
58+
# Resolve firefox: None = not requested, "auto" = detect, else = specific path
59+
self.firefox_explicit = args.firefox is not None and args.firefox != "auto"
60+
if args.firefox == "auto":
61+
self.firefox_data_directory = Firefox.default_firefox_profile_path()
62+
else:
63+
self.firefox_data_directory = args.firefox
64+
65+
# create items for the menu - separator, settings, about, refresh, quit
5766
menuSeparator = Gtk.SeparatorMenuItem()
5867
menuSeparator.show()
5968
self.add(menuSeparator)
6069

61-
btnComments = Gtk.CheckMenuItem.new_with_label("Show Comments")
70+
# Settings submenu
71+
settingsItem = Gtk.MenuItem.new_with_label("Settings")
72+
settingsMenu = Gtk.Menu()
73+
settingsItem.set_submenu(settingsMenu)
74+
75+
btnComments = Gtk.CheckMenuItem.new_with_label("Open Comments")
76+
btnComments.set_active(args.comments)
6277
btnComments.connect("toggled", self.toggleComments)
63-
self.add(btnComments)
78+
settingsMenu.append(btnComments)
6479
btnComments.show()
65-
btnComments.set_active(args.comments)
6680

67-
btnAbout = Gtk.MenuItem("About")
81+
btnReverse = Gtk.CheckMenuItem.new_with_label("Reverse Ordering")
82+
btnReverse.set_active(args.reverse)
83+
btnReverse.connect("toggled", self.toggleReverse)
84+
settingsMenu.append(btnReverse)
85+
btnReverse.show()
86+
87+
# Only show Firefox toggle if --firefox was unset or "auto"
88+
if not self.firefox_explicit:
89+
btnFirefox = Gtk.CheckMenuItem.new_with_label("Detect Firefox read items")
90+
btnFirefox.set_active(self.firefox_data_directory is not None)
91+
btnFirefox.connect("toggled", self.toggleFirefox)
92+
settingsMenu.append(btnFirefox)
93+
btnFirefox.show()
94+
95+
self.add(settingsItem)
96+
settingsItem.show()
97+
98+
btnAbout = Gtk.MenuItem.new_with_label("About")
6899
btnAbout.show()
69100
btnAbout.connect("activate", self.showAbout)
70101
self.add(btnAbout)
71102

72-
btnRefresh = Gtk.MenuItem("Refresh")
103+
btnRefresh = Gtk.MenuItem.new_with_label("Refresh")
73104
btnRefresh.show()
74-
# the last parameter is for not running the timer
75-
btnRefresh.connect("activate", self.refresh, True, args.chrome)
105+
btnRefresh.connect("activate", self.refresh, True)
76106
self.add(btnRefresh)
77107

78108
if Version.new_available():
79-
btnUpdate = Gtk.MenuItem("New Update Available")
109+
btnUpdate = Gtk.MenuItem.new_with_label("New Update Available")
80110
btnUpdate.show()
81111
btnUpdate.connect('activate', self.showUpdate)
82112
self.add(btnUpdate)
83113

84-
btnQuit = Gtk.MenuItem("Quit")
114+
btnQuit = Gtk.MenuItem.new_with_label("Quit")
85115
btnQuit.show()
86116
btnQuit.connect("activate", self.quit)
87117
self.add(btnQuit)
88118
self.menu.show()
89119
self.ind.set_menu(self.menu)
90120

91-
if args.firefox == "auto":
92-
args.firefox = Firefox.default_firefox_profile_path()
93-
self.refresh(chrome_data_directory=args.chrome, firefox_data_directory=args.firefox)
121+
self.refresh()
94122

95123
def add(self, item):
96124
if self.reverse:
@@ -102,6 +130,20 @@ def toggleComments(self, widget):
102130
"""Whether comments page is opened or not"""
103131
self.commentState = widget.get_active()
104132

133+
def toggleReverse(self, widget):
134+
self.reverse = widget.get_active()
135+
136+
def toggleFirefox(self, widget):
137+
if widget.get_active():
138+
try:
139+
self.firefox_data_directory = Firefox.default_firefox_profile_path()
140+
except RuntimeError:
141+
print("[+] Could not find Firefox profile")
142+
widget.set_active(False)
143+
return
144+
else:
145+
self.firefox_data_directory = None
146+
105147
def showUpdate(self, widget):
106148
"""Handle the update button"""
107149
webbrowser.open(HackerNewsApp.UPDATE_URL)
@@ -115,10 +157,9 @@ def showAbout(self, widget):
115157
# ToDo: Handle keyboard interrupt properly
116158
def quit(self, widget, data=None):
117159
""" Handler for the quit button"""
118-
l = list(self.db)
160+
l = list(self.db)[-200:]
119161
home = expanduser("~")
120162

121-
# truncate the file
122163
with open(home + '/.hackertray.json', 'w+') as file:
123164
file.write(json.dumps(l))
124165

@@ -165,28 +206,28 @@ def addItem(self, item):
165206
i.url = item['url']
166207
tooltip = "{url}\nPosted by {user} {timeago}".format(url=item['url'], user=item['user'], timeago=item['time_ago'])
167208
i.set_tooltip_text(tooltip)
168-
i.signal_id = i.connect('activate', self.open)
169209
i.hn_id = item['id']
170210
i.item_id = item['id']
211+
i.set_active(visited)
212+
i.signal_id = i.connect('activate', self.open)
171213
if self.reverse:
172214
self.menu.append(i)
173215
else:
174216
self.menu.prepend(i)
175217
i.show()
176-
i.set_active(visited)
177218

178-
def refresh(self, widget=None, no_timer=False, chrome_data_directory=None, firefox_data_directory=None):
219+
def refresh(self, widget=None, no_timer=False):
179220
"""Refreshes the menu """
180221
try:
181222
# Create an array of 20 false to denote matches in History
182223
searchResults = [False]*20
183224
data = list(reversed(HackerNews.getHomePage()[0:20]))
184225
urls = [item['url'] for item in data]
185-
if(chrome_data_directory):
186-
searchResults = self.mergeBoolArray(searchResults, Chrome.search(urls, chrome_data_directory))
226+
if self.chrome_data_directory:
227+
searchResults = self.mergeBoolArray(searchResults, Chrome.search(urls, self.chrome_data_directory))
187228

188-
if(firefox_data_directory):
189-
searchResults = self.mergeBoolArray(searchResults, Firefox.search(urls, firefox_data_directory))
229+
if self.firefox_data_directory:
230+
searchResults = self.mergeBoolArray(searchResults, Firefox.search(urls, self.firefox_data_directory))
190231

191232
# Remove all the current stories
192233
for i in self.menu.get_children():
@@ -206,18 +247,35 @@ def refresh(self, widget=None, no_timer=False, chrome_data_directory=None, firef
206247
finally:
207248
# Call every 10 minutes
208249
if not no_timer:
209-
GLib.timeout_add(10 * 30 * 1000, self.refresh, widget, no_timer, chrome_data_directory)
250+
GLib.timeout_add(10 * 30 * 1000, self.refresh)
210251

211252
# Merges two boolean arrays, using OR operation against each pair
212253
def mergeBoolArray(self, original, patch):
213254
for index, var in enumerate(original):
214255
original[index] = original[index] or patch[index]
215256
return original
216257

217-
def get_icon_filename(self, icon_name):
218-
ref = importlib.resources.files('hackertray.data') / 'hacker-tray.png'
219-
with importlib.resources.as_file(ref) as path:
220-
return str(path)
258+
@staticmethod
259+
def _icon_theme_path():
260+
"""Return the icon data dir as a host-accessible path.
261+
262+
AppIndicator sends this path over D-Bus to the tray host, which runs
263+
outside the Flatpak sandbox. Inside a Flatpak, /app/ paths are not
264+
accessible from the host, so we translate via /.flatpak-info."""
265+
data_dir = str(importlib.resources.files('hackertray.data'))
266+
if os.path.exists("/.flatpak-info"):
267+
import configparser
268+
info = configparser.ConfigParser()
269+
info.read("/.flatpak-info")
270+
app_path = info.get("Instance", "app-path")
271+
data_dir = app_path + data_dir.removeprefix("/app")
272+
return data_dir
273+
274+
@staticmethod
275+
def _is_light_theme():
276+
settings = Gtk.Settings.get_default()
277+
if settings and settings.get_property("gtk-application-prefer-dark-theme"):
278+
return False
221279

222280

223281
def main():
661 Bytes
Loading

hackertray/firefox.py

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,22 @@ class Firefox:
1212
@staticmethod
1313
def default_firefox_profile_path():
1414
config_home = os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))
15-
firefox_dir = Path(config_home) / "mozilla" / "firefox"
16-
profile_file_path = firefox_dir / "profiles.ini"
17-
if not profile_file_path.exists():
15+
candidates = [
16+
Path(config_home) / "mozilla" / "firefox",
17+
Path.home() / ".config" / "mozilla" / "firefox",
18+
Path.home() / ".mozilla" / "firefox",
19+
Path.home() / ".var" / "app" / "org.mozilla.firefox" / ".mozilla" / "firefox",
20+
]
21+
firefox_dir = None
22+
for candidate in candidates:
23+
if (candidate / "profiles.ini").exists():
24+
firefox_dir = candidate
25+
break
26+
if firefox_dir is None:
1827
raise RuntimeError("Couldn't find Firefox profiles.ini")
1928

2029
parser = configparser.ConfigParser()
21-
parser.read(profile_file_path)
30+
parser.read(firefox_dir / "profiles.ini")
2231

2332
# Prefer the active install's locked profile (modern Firefox)
2433
for section in parser.sections():
@@ -58,14 +67,9 @@ def search(urls, config_folder_path):
5867
os.unlink(tmp_path)
5968
db = conn.cursor()
6069

61-
total = db.execute('SELECT COUNT(*) FROM moz_places').fetchone()[0]
62-
print(f"[firefox] loaded {total} rows from moz_places")
63-
6470
result = []
6571
for url in urls:
6672
db.execute('SELECT url from moz_places WHERE url=:url', {"url": url})
67-
found = db.fetchone() is not None
68-
print(f"[firefox] {'HIT ' if found else 'MISS'} {url}")
69-
result.append(found)
73+
result.append(db.fetchone() is not None)
7074
conn.close()
7175
return result

in.captnemo.hackertray.yml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
app-id: in.captnemo.hackertray
2+
runtime: org.gnome.Platform
3+
runtime-version: "49"
4+
sdk: org.gnome.Sdk
5+
command: hackertray
6+
7+
finish-args:
8+
- --share=ipc
9+
- --share=network
10+
- --socket=x11
11+
- --socket=wayland
12+
- --talk-name=org.kde.StatusNotifierWatcher
13+
- --talk-name=com.canonical.indicator.application
14+
- --talk-name=org.ayatana.indicator.application
15+
- --filesystem=~/.mozilla/firefox:ro
16+
- --filesystem=~/.config/mozilla/firefox:ro
17+
- --filesystem=~/.var/app/org.mozilla.firefox/.mozilla/firefox:ro
18+
- --filesystem=~/.config/google-chrome:ro
19+
20+
modules:
21+
- shared-modules/libappindicator/libappindicator-gtk3-introspection-12.10.json
22+
23+
- name: hackertray
24+
buildsystem: simple
25+
build-commands:
26+
- pip3 install --no-deps --no-build-isolation --prefix=/app .
27+
- install -Dm644 hackertray/data/hacker-tray.png /app/share/icons/hicolor/24x24/apps/hacker-tray.png
28+
- install -Dm644 hackertray/data/hacker-tray-light.png /app/share/icons/hicolor/24x24/apps/hacker-tray-light.png
29+
sources:
30+
- type: dir
31+
path: .

pyproject.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,12 @@ Homepage = "https://captnemo.in/hackertray"
2121
[project.scripts]
2222
hackertray = "hackertray:main"
2323

24+
[tool.pytest.ini_options]
25+
testpaths = ["test"]
26+
2427
[tool.setuptools.packages.find]
2528
where = ["."]
2629
exclude = ["test*"]
2730

2831
[tool.setuptools.package-data]
29-
"hackertray.data" = ["hacker-tray.png"]
32+
"hackertray.data" = ["hacker-tray.png", "hacker-tray-light.png"]

shared-modules

Submodule shared-modules added at 2f1fb18

0 commit comments

Comments
 (0)