skillo 0.2.6 → 0.2.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +198 -198
  2. package/dist/api-client-BF6GDR7Q.js +12 -0
  3. package/dist/{chunk-WJKZWKER.js → chunk-63FVALWX.js} +1 -1
  4. package/dist/chunk-63FVALWX.js.map +1 -0
  5. package/dist/chunk-6GOJPFZ7.js +113 -0
  6. package/dist/chunk-6GOJPFZ7.js.map +1 -0
  7. package/dist/chunk-6UGTWBUW.js +89 -0
  8. package/dist/chunk-6UGTWBUW.js.map +1 -0
  9. package/dist/{chunk-2CVEPT6U.js → chunk-73NUWYUO.js} +2 -2
  10. package/dist/chunk-73NUWYUO.js.map +1 -0
  11. package/dist/chunk-QIV4VIXA.js +221 -0
  12. package/dist/chunk-QIV4VIXA.js.map +1 -0
  13. package/dist/{chunk-CPL3P2OF.js → chunk-QUXHHRRK.js} +2 -2
  14. package/dist/chunk-QUXHHRRK.js.map +1 -0
  15. package/dist/{chunk-ODOZM4QV.js → chunk-RQ2SC5HW.js} +72 -10
  16. package/dist/chunk-RQ2SC5HW.js.map +1 -0
  17. package/dist/chunk-U53QWUOR.js +727 -0
  18. package/dist/chunk-U53QWUOR.js.map +1 -0
  19. package/dist/chunk-VAQ73XPE.js +68 -0
  20. package/dist/chunk-VAQ73XPE.js.map +1 -0
  21. package/dist/chunk-XLJGCOVT.js +975 -0
  22. package/dist/chunk-XLJGCOVT.js.map +1 -0
  23. package/dist/{claude-watcher-N6GN6WHJ.js → claude-watcher-WKGBJYKN.js} +65 -3
  24. package/dist/claude-watcher-WKGBJYKN.js.map +1 -0
  25. package/dist/cli.js +495 -1846
  26. package/dist/cli.js.map +1 -1
  27. package/dist/{config-P5EM5L7N.js → config-ZOKAP2LJ.js} +3 -3
  28. package/dist/daemon-6DTCMOJB.js +28 -0
  29. package/dist/daemon-runner.js +338 -71
  30. package/dist/daemon-runner.js.map +1 -1
  31. package/dist/database-KQY5OSCS.js +9 -0
  32. package/dist/git-OGUSYBJS.js +16 -0
  33. package/dist/git-OGUSYBJS.js.map +1 -0
  34. package/dist/git-OUAHIOY2.js +110 -0
  35. package/dist/git-OUAHIOY2.js.map +1 -0
  36. package/dist/index.js.map +1 -1
  37. package/dist/{paths-INOKEM66.js → paths-MPOZBOKE.js} +2 -2
  38. package/dist/paths-MPOZBOKE.js.map +1 -0
  39. package/dist/project-OFU2W6MH.js +19 -0
  40. package/dist/project-OFU2W6MH.js.map +1 -0
  41. package/dist/shell-NZABRJLA.js +16 -0
  42. package/dist/shell-NZABRJLA.js.map +1 -0
  43. package/dist/skill-installer-F67OAOQN.js +121 -0
  44. package/dist/skill-installer-F67OAOQN.js.map +1 -0
  45. package/dist/{skill-usage-detector-EO26MRYV.js → skill-usage-detector-MSW5VWQZ.js} +2 -2
  46. package/dist/skill-usage-detector-MSW5VWQZ.js.map +1 -0
  47. package/dist/tray-WKFGUUTO.js +346 -0
  48. package/dist/tray-WKFGUUTO.js.map +1 -0
  49. package/package.json +62 -63
  50. package/scripts/postinstall.mjs +415 -364
  51. package/scripts/tray-helper-darwin +0 -0
  52. package/scripts/tray-helper-darwin.swift +180 -180
  53. package/scripts/tray-helper-linux.py +322 -0
  54. package/scripts/tray-helper-windows.cs +322 -0
  55. package/dist/api-client-KUQW7FSC.js +0 -12
  56. package/dist/chunk-2CVEPT6U.js.map +0 -1
  57. package/dist/chunk-CPL3P2OF.js.map +0 -1
  58. package/dist/chunk-ODOZM4QV.js.map +0 -1
  59. package/dist/chunk-WJKZWKER.js.map +0 -1
  60. package/dist/claude-watcher-N6GN6WHJ.js.map +0 -1
  61. package/dist/database-F3BFFZKG.js +0 -9
  62. package/dist/skill-usage-detector-EO26MRYV.js.map +0 -1
  63. package/dist/tray-UCAI2U2C.js +0 -408
  64. package/dist/tray-UCAI2U2C.js.map +0 -1
  65. /package/dist/{api-client-KUQW7FSC.js.map → api-client-BF6GDR7Q.js.map} +0 -0
  66. /package/dist/{config-P5EM5L7N.js.map → config-ZOKAP2LJ.js.map} +0 -0
  67. /package/dist/{database-F3BFFZKG.js.map → daemon-6DTCMOJB.js.map} +0 -0
  68. /package/dist/{paths-INOKEM66.js.map → database-KQY5OSCS.js.map} +0 -0
File without changes
@@ -1,180 +1,180 @@
1
- /**
2
- * Skillo Tray Helper for macOS
3
- *
4
- * Native Swift menu bar icon. Communicates with Node via stdin/stdout JSON lines.
5
- *
6
- * Protocol (compatible with systray2):
7
- * stdin <- JSON menu config (first line) + {"type":"update-menu","menu":{...}} for updates
8
- * stdout -> {"type":"ready"} on launch, {"type":"clicked","item":{...},"seq_id":N} on click
9
- */
10
-
11
- import Cocoa
12
-
13
- // MARK: - JSON models
14
-
15
- struct TrayMenuItem: Codable {
16
- let title: String
17
- let tooltip: String
18
- var enabled: Bool?
19
- var checked: Bool?
20
- var hidden: Bool?
21
- var items: [TrayMenuItem]?
22
- }
23
-
24
- struct MenuConfig: Codable {
25
- let icon: String
26
- let title: String
27
- let tooltip: String
28
- let items: [TrayMenuItem]
29
- }
30
-
31
- struct UpdateAction: Codable {
32
- let type: String
33
- let menu: MenuConfig?
34
- }
35
-
36
- struct ClickEvent: Codable {
37
- let type: String
38
- let item: TrayMenuItem
39
- let seq_id: Int
40
- }
41
-
42
- // MARK: - Tray controller
43
-
44
- class TrayController: NSObject, NSApplicationDelegate {
45
- var statusItem: NSStatusItem!
46
- var flatItems: [(TrayMenuItem, Int)] = []
47
-
48
- func applicationDidFinishLaunching(_ notification: Notification) {
49
- statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
50
- statusItem.button?.title = "S"
51
-
52
- // Emit ready
53
- writeLine("{\"type\": \"ready\"}")
54
-
55
- // Start reading stdin on background thread
56
- startStdinReader()
57
- }
58
-
59
- // MARK: stdin reader
60
-
61
- func startStdinReader() {
62
- DispatchQueue.global(qos: .userInitiated).async { [weak self] in
63
- let handle = FileHandle.standardInput
64
- var buf = ""
65
-
66
- while true {
67
- guard let data = try? handle.availableData, !data.isEmpty else {
68
- // stdin closed — parent exited
69
- DispatchQueue.main.async { NSApp.terminate(nil) }
70
- return
71
- }
72
- guard let chunk = String(data: data, encoding: .utf8) else { continue }
73
- buf += chunk
74
-
75
- while let newline = buf.firstIndex(of: "\n") {
76
- let line = String(buf[buf.startIndex..<newline]).trimmingCharacters(in: .whitespacesAndNewlines)
77
- buf = String(buf[buf.index(after: newline)...])
78
- if !line.isEmpty {
79
- DispatchQueue.main.async { self?.handleLine(line) }
80
- }
81
- }
82
- }
83
- }
84
- }
85
-
86
- func handleLine(_ line: String) {
87
- guard let data = line.data(using: .utf8) else { return }
88
-
89
- // Try update action
90
- if let action = try? JSONDecoder().decode(UpdateAction.self, from: data),
91
- action.type == "update-menu",
92
- let menu = action.menu {
93
- applyMenu(menu)
94
- return
95
- }
96
-
97
- // Try initial menu config
98
- if let menu = try? JSONDecoder().decode(MenuConfig.self, from: data) {
99
- applyMenu(menu)
100
- }
101
- }
102
-
103
- // MARK: Menu rendering
104
-
105
- func applyMenu(_ config: MenuConfig) {
106
- flatItems = []
107
-
108
- // Icon
109
- if let btn = statusItem.button {
110
- if !config.icon.isEmpty, let imgData = Data(base64Encoded: config.icon) {
111
- let img = NSImage(data: imgData)
112
- img?.size = NSSize(width: 18, height: 18)
113
- img?.isTemplate = true
114
- btn.image = img
115
- btn.title = ""
116
- } else if !config.title.isEmpty {
117
- btn.image = nil
118
- btn.title = config.title
119
- }
120
- btn.toolTip = config.tooltip
121
- }
122
-
123
- // Menu items
124
- let menu = NSMenu()
125
- var seq = 0
126
-
127
- for item in config.items {
128
- if item.title == "<SEPARATOR>" {
129
- menu.addItem(NSMenuItem.separator())
130
- } else {
131
- let mi = NSMenuItem(title: item.title, action: nil, keyEquivalent: "")
132
- mi.toolTip = item.tooltip
133
- mi.tag = seq
134
-
135
- if item.enabled == true {
136
- mi.target = self
137
- mi.action = #selector(onItemClick(_:))
138
- } else {
139
- mi.isEnabled = false
140
- }
141
-
142
- if item.checked == true { mi.state = .on }
143
- if item.hidden == true { mi.isHidden = true }
144
-
145
- menu.addItem(mi)
146
- }
147
- flatItems.append((item, seq))
148
- seq += 1
149
- }
150
-
151
- statusItem.menu = menu
152
- }
153
-
154
- // MARK: Click handler
155
-
156
- @objc func onItemClick(_ sender: NSMenuItem) {
157
- let seq = sender.tag
158
- guard seq < flatItems.count else { return }
159
- let (item, _) = flatItems[seq]
160
- if let jsonData = try? JSONEncoder().encode(ClickEvent(type: "clicked", item: item, seq_id: seq)),
161
- let jsonStr = String(data: jsonData, encoding: .utf8) {
162
- writeLine(jsonStr)
163
- }
164
- }
165
-
166
- // MARK: stdout
167
-
168
- func writeLine(_ s: String) {
169
- let out = s + "\n"
170
- FileHandle.standardOutput.write(out.data(using: .utf8)!)
171
- }
172
- }
173
-
174
- // MARK: - Main
175
-
176
- let app = NSApplication.shared
177
- app.setActivationPolicy(.accessory)
178
- let controller = TrayController()
179
- app.delegate = controller
180
- app.run()
1
+ /**
2
+ * Skillo Tray Helper for macOS
3
+ *
4
+ * Native Swift menu bar icon. Communicates with Node via stdin/stdout JSON lines.
5
+ *
6
+ * Protocol (compatible with systray2):
7
+ * stdin <- JSON menu config (first line) + {"type":"update-menu","menu":{...}} for updates
8
+ * stdout -> {"type":"ready"} on launch, {"type":"clicked","item":{...},"seq_id":N} on click
9
+ */
10
+
11
+ import Cocoa
12
+
13
+ // MARK: - JSON models
14
+
15
+ struct TrayMenuItem: Codable {
16
+ let title: String
17
+ let tooltip: String
18
+ var enabled: Bool?
19
+ var checked: Bool?
20
+ var hidden: Bool?
21
+ var items: [TrayMenuItem]?
22
+ }
23
+
24
+ struct MenuConfig: Codable {
25
+ let icon: String
26
+ let title: String
27
+ let tooltip: String
28
+ let items: [TrayMenuItem]
29
+ }
30
+
31
+ struct UpdateAction: Codable {
32
+ let type: String
33
+ let menu: MenuConfig?
34
+ }
35
+
36
+ struct ClickEvent: Codable {
37
+ let type: String
38
+ let item: TrayMenuItem
39
+ let seq_id: Int
40
+ }
41
+
42
+ // MARK: - Tray controller
43
+
44
+ class TrayController: NSObject, NSApplicationDelegate {
45
+ var statusItem: NSStatusItem!
46
+ var flatItems: [(TrayMenuItem, Int)] = []
47
+
48
+ func applicationDidFinishLaunching(_ notification: Notification) {
49
+ statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
50
+ statusItem.button?.title = "S"
51
+
52
+ // Emit ready
53
+ writeLine("{\"type\": \"ready\"}")
54
+
55
+ // Start reading stdin on background thread
56
+ startStdinReader()
57
+ }
58
+
59
+ // MARK: stdin reader
60
+
61
+ func startStdinReader() {
62
+ DispatchQueue.global(qos: .userInitiated).async { [weak self] in
63
+ let handle = FileHandle.standardInput
64
+ var buf = ""
65
+
66
+ while true {
67
+ guard let data = try? handle.availableData, !data.isEmpty else {
68
+ // stdin closed — parent exited
69
+ DispatchQueue.main.async { NSApp.terminate(nil) }
70
+ return
71
+ }
72
+ guard let chunk = String(data: data, encoding: .utf8) else { continue }
73
+ buf += chunk
74
+
75
+ while let newline = buf.firstIndex(of: "\n") {
76
+ let line = String(buf[buf.startIndex..<newline]).trimmingCharacters(in: .whitespacesAndNewlines)
77
+ buf = String(buf[buf.index(after: newline)...])
78
+ if !line.isEmpty {
79
+ DispatchQueue.main.async { self?.handleLine(line) }
80
+ }
81
+ }
82
+ }
83
+ }
84
+ }
85
+
86
+ func handleLine(_ line: String) {
87
+ guard let data = line.data(using: .utf8) else { return }
88
+
89
+ // Try update action
90
+ if let action = try? JSONDecoder().decode(UpdateAction.self, from: data),
91
+ action.type == "update-menu",
92
+ let menu = action.menu {
93
+ applyMenu(menu)
94
+ return
95
+ }
96
+
97
+ // Try initial menu config
98
+ if let menu = try? JSONDecoder().decode(MenuConfig.self, from: data) {
99
+ applyMenu(menu)
100
+ }
101
+ }
102
+
103
+ // MARK: Menu rendering
104
+
105
+ func applyMenu(_ config: MenuConfig) {
106
+ flatItems = []
107
+
108
+ // Icon
109
+ if let btn = statusItem.button {
110
+ if !config.icon.isEmpty, let imgData = Data(base64Encoded: config.icon) {
111
+ let img = NSImage(data: imgData)
112
+ img?.size = NSSize(width: 18, height: 18)
113
+ img?.isTemplate = true
114
+ btn.image = img
115
+ btn.title = ""
116
+ } else if !config.title.isEmpty {
117
+ btn.image = nil
118
+ btn.title = config.title
119
+ }
120
+ btn.toolTip = config.tooltip
121
+ }
122
+
123
+ // Menu items
124
+ let menu = NSMenu()
125
+ var seq = 0
126
+
127
+ for item in config.items {
128
+ if item.title == "<SEPARATOR>" {
129
+ menu.addItem(NSMenuItem.separator())
130
+ } else {
131
+ let mi = NSMenuItem(title: item.title, action: nil, keyEquivalent: "")
132
+ mi.toolTip = item.tooltip
133
+ mi.tag = seq
134
+
135
+ if item.enabled == true {
136
+ mi.target = self
137
+ mi.action = #selector(onItemClick(_:))
138
+ } else {
139
+ mi.isEnabled = false
140
+ }
141
+
142
+ if item.checked == true { mi.state = .on }
143
+ if item.hidden == true { mi.isHidden = true }
144
+
145
+ menu.addItem(mi)
146
+ }
147
+ flatItems.append((item, seq))
148
+ seq += 1
149
+ }
150
+
151
+ statusItem.menu = menu
152
+ }
153
+
154
+ // MARK: Click handler
155
+
156
+ @objc func onItemClick(_ sender: NSMenuItem) {
157
+ let seq = sender.tag
158
+ guard seq < flatItems.count else { return }
159
+ let (item, _) = flatItems[seq]
160
+ if let jsonData = try? JSONEncoder().encode(ClickEvent(type: "clicked", item: item, seq_id: seq)),
161
+ let jsonStr = String(data: jsonData, encoding: .utf8) {
162
+ writeLine(jsonStr)
163
+ }
164
+ }
165
+
166
+ // MARK: stdout
167
+
168
+ func writeLine(_ s: String) {
169
+ let out = s + "\n"
170
+ FileHandle.standardOutput.write(out.data(using: .utf8)!)
171
+ }
172
+ }
173
+
174
+ // MARK: - Main
175
+
176
+ let app = NSApplication.shared
177
+ app.setActivationPolicy(.accessory)
178
+ let controller = TrayController()
179
+ app.delegate = controller
180
+ app.run()
@@ -0,0 +1,322 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Skillo Tray Helper for Linux
4
+
5
+ Native GTK system tray icon using AppIndicator or StatusIcon fallback.
6
+ Communicates with Node.js via stdin/stdout JSON lines.
7
+
8
+ Protocol:
9
+ stdout -> {"type":"ready"} on launch
10
+ stdin <- JSON menu config (first line) + {"type":"update-menu","menu":{...}} for updates
11
+ stdout -> {"type":"clicked","item":{...},"seq_id":N} on click
12
+ stdin EOF -> clean exit
13
+
14
+ Requires: python3-gi (PyGObject), optionally gir1.2-ayatanaappindicator3-0.1
15
+ """
16
+
17
+ import json
18
+ import sys
19
+ import os
20
+ import base64
21
+ import tempfile
22
+ import shutil
23
+ import signal
24
+ import atexit
25
+
26
+ try:
27
+ import gi
28
+ except ImportError:
29
+ sys.stderr.write(
30
+ "Error: PyGObject (python3-gi) is required.\n"
31
+ "Install with:\n"
32
+ " Ubuntu/Debian: sudo apt install python3-gi gir1.2-ayatanaappindicator3-0.1\n"
33
+ " Fedora: sudo dnf install python3-gobject libayatana-appindicator-gtk3\n"
34
+ " Arch: sudo pacman -S python-gobject libayatana-appindicator\n"
35
+ )
36
+ sys.exit(1)
37
+
38
+ try:
39
+ gi.require_version("Gtk", "3.0")
40
+ from gi.repository import Gtk, GLib, GdkPixbuf, Gio
41
+ except (ValueError, ImportError):
42
+ sys.stderr.write(
43
+ "Error: GTK 3.0 is required but not found.\n"
44
+ "Install with:\n"
45
+ " Ubuntu/Debian: sudo apt install python3-gi gir1.2-gtk-3.0\n"
46
+ " Fedora: sudo dnf install python3-gobject gtk3\n"
47
+ " Arch: sudo pacman -S python-gobject gtk3\n"
48
+ )
49
+ sys.exit(1)
50
+
51
+ # Try AppIndicator (preferred on modern Linux desktops)
52
+ AppIndicator = None
53
+ try:
54
+ gi.require_version("AyatanaAppIndicator3", "0.1")
55
+ from gi.repository import AyatanaAppIndicator3 as AppIndicator
56
+ except (ValueError, ImportError):
57
+ try:
58
+ gi.require_version("AppIndicator3", "0.1")
59
+ from gi.repository import AppIndicator3 as AppIndicator
60
+ except (ValueError, ImportError):
61
+ AppIndicator = None
62
+
63
+
64
+ class TrayHelper:
65
+ def __init__(self):
66
+ self.indicator = None
67
+ self.status_icon = None
68
+ self.menu = None
69
+ self.flat_items = []
70
+ self.stdin_buf = ""
71
+ self.icon_dir = os.path.join(os.path.expanduser("~"), ".skillo")
72
+ os.makedirs(self.icon_dir, exist_ok=True)
73
+ # Use a private temp directory for icon files to avoid symlink TOCTOU attacks
74
+ self.icon_tmp_dir = tempfile.mkdtemp(prefix="skillo-tray-", dir=self.icon_dir)
75
+ # AppIndicator requires a freedesktop icon theme directory structure
76
+ self.icon_theme_dir = os.path.join(self.icon_tmp_dir, "hicolor", "22x22", "apps")
77
+ os.makedirs(self.icon_theme_dir, exist_ok=True)
78
+ self.icon_name = "skillo-tray" # icon name without extension
79
+ self.icon_path = os.path.join(self.icon_theme_dir, self.icon_name + ".png")
80
+ # StatusIcon fallback uses raw path
81
+ self.icon_raw_path = os.path.join(self.icon_tmp_dir, "icon.png")
82
+ atexit.register(lambda: shutil.rmtree(self.icon_tmp_dir, ignore_errors=True))
83
+ self.initialized = False
84
+ self.headless = False # True when no tray backend is available (Wayland)
85
+
86
+ def start(self):
87
+ # Handle SIGTERM/SIGINT gracefully
88
+ signal.signal(signal.SIGTERM, lambda *_: Gtk.main_quit())
89
+ signal.signal(signal.SIGINT, lambda *_: Gtk.main_quit())
90
+
91
+ self.setup_indicator()
92
+
93
+ # Watch stdin via GLib IO watch (no threads needed)
94
+ # Include ERR — some kernels fire ERR instead of HUP on broken pipe
95
+ GLib.io_add_watch(
96
+ sys.stdin,
97
+ GLib.PRIORITY_DEFAULT,
98
+ GLib.IOCondition.IN | GLib.IOCondition.HUP | GLib.IOCondition.ERR,
99
+ self.on_stdin,
100
+ )
101
+
102
+ # Emit ready on first main loop iteration (after io_add_watch is active)
103
+ GLib.idle_add(self._emit_ready)
104
+
105
+ Gtk.main()
106
+
107
+ def _emit_ready(self):
108
+ self.write_line('{"type":"ready"}')
109
+ return False # don't repeat
110
+
111
+ def setup_indicator(self):
112
+ if AppIndicator is not None:
113
+ try:
114
+ self.indicator = AppIndicator.Indicator.new(
115
+ "skillo-tray",
116
+ "application-default-icon",
117
+ AppIndicator.IndicatorCategory.APPLICATION_STATUS,
118
+ )
119
+ self.indicator.set_status(AppIndicator.IndicatorStatus.ACTIVE)
120
+ self.indicator.set_title("Skillo")
121
+ # Set a default empty menu (required by AppIndicator)
122
+ menu = Gtk.Menu()
123
+ placeholder = Gtk.MenuItem(label="Loading...")
124
+ placeholder.set_sensitive(False)
125
+ menu.append(placeholder)
126
+ menu.show_all()
127
+ self.indicator.set_menu(menu)
128
+ return
129
+ except Exception:
130
+ self.indicator = None
131
+
132
+ # Fallback to StatusIcon (deprecated but still works on X11)
133
+ try:
134
+ self.status_icon = Gtk.StatusIcon()
135
+ self.status_icon.set_from_icon_name("application-default-icon")
136
+ self.status_icon.set_tooltip_text("Skillo")
137
+ self.status_icon.set_visible(True)
138
+ self.status_icon.connect("popup-menu", self.on_popup)
139
+ except Exception:
140
+ # No tray backend available (e.g. Wayland without AppIndicator extension)
141
+ # Run headless — still respond to stdin so Node.js doesn't hang
142
+ sys.stderr.write(
143
+ "Warning: System tray not available in this environment.\n"
144
+ "On GNOME/Wayland, install: gnome-shell-extension-appindicator\n"
145
+ " Ubuntu/Debian: sudo apt install gnome-shell-extension-appindicator gir1.2-ayatanaappindicator3-0.1\n"
146
+ " Fedora: sudo dnf install gnome-shell-extension-appindicator libayatana-appindicator-gtk3\n"
147
+ " Arch: sudo pacman -S libayatana-appindicator\n"
148
+ "Skillo will run without a tray icon.\n"
149
+ )
150
+ self.headless = True
151
+
152
+ def on_popup(self, icon, button, time):
153
+ """StatusIcon fallback popup handler."""
154
+ if self.menu:
155
+ self.menu.popup(None, None, Gtk.StatusIcon.position_menu, icon, button, time)
156
+
157
+ def on_stdin(self, source, condition):
158
+ if condition & (GLib.IOCondition.HUP | GLib.IOCondition.ERR):
159
+ Gtk.main_quit()
160
+ return False
161
+
162
+ try:
163
+ data = source.readline()
164
+ except Exception:
165
+ Gtk.main_quit()
166
+ return False
167
+
168
+ if not data:
169
+ Gtk.main_quit()
170
+ return False
171
+
172
+ self.stdin_buf += data
173
+ while "\n" in self.stdin_buf:
174
+ line, self.stdin_buf = self.stdin_buf.split("\n", 1)
175
+ line = line.strip()
176
+ if line:
177
+ self.process_line(line)
178
+
179
+ return True # keep watching
180
+
181
+ def process_line(self, line):
182
+ try:
183
+ data = json.loads(line)
184
+ except json.JSONDecodeError:
185
+ return
186
+
187
+ if not isinstance(data, dict):
188
+ return
189
+
190
+ # Check if it's an update-menu action
191
+ if data.get("type") == "update-menu" and "menu" in data:
192
+ self.apply_menu(data["menu"])
193
+ return
194
+
195
+ # Otherwise treat as initial menu config
196
+ if "items" in data:
197
+ self.apply_menu(data)
198
+
199
+ def apply_menu(self, config):
200
+ if not isinstance(config, dict):
201
+ return
202
+ if self.headless:
203
+ return
204
+
205
+ # Update icon (atomic write to avoid race conditions)
206
+ try:
207
+ icon_b64 = config.get("icon", "")
208
+ if icon_b64:
209
+ icon_bytes = base64.b64decode(icon_b64)
210
+ fd, tmp_path = tempfile.mkstemp(dir=self.icon_tmp_dir, suffix=".png")
211
+ try:
212
+ with os.fdopen(fd, "wb") as f:
213
+ f.write(icon_bytes)
214
+ os.replace(tmp_path, self.icon_path) # atomic rename
215
+ except Exception:
216
+ try:
217
+ os.unlink(tmp_path)
218
+ except OSError:
219
+ pass
220
+ raise
221
+
222
+ if self.indicator:
223
+ # AppIndicator expects icon name (not path) + theme directory
224
+ self.indicator.set_icon_theme_path(self.icon_tmp_dir)
225
+ self.indicator.set_icon_full(self.icon_name, "Skillo")
226
+ elif self.status_icon:
227
+ # Also write raw icon for StatusIcon pixbuf loading
228
+ fd2, tmp2 = tempfile.mkstemp(dir=self.icon_tmp_dir, suffix=".png")
229
+ try:
230
+ with os.fdopen(fd2, "wb") as f2:
231
+ f2.write(icon_bytes)
232
+ os.replace(tmp2, self.icon_raw_path)
233
+ except Exception:
234
+ try:
235
+ os.unlink(tmp2)
236
+ except OSError:
237
+ pass
238
+ loader = GdkPixbuf.PixbufLoader()
239
+ try:
240
+ loader.write(icon_bytes)
241
+ loader.close()
242
+ pixbuf = loader.get_pixbuf()
243
+ except Exception:
244
+ try:
245
+ loader.close()
246
+ except Exception:
247
+ pass
248
+ pixbuf = None
249
+ if pixbuf:
250
+ pixbuf = pixbuf.scale_simple(22, 22, GdkPixbuf.InterpType.BILINEAR)
251
+ self.status_icon.set_from_pixbuf(pixbuf)
252
+ except Exception:
253
+ pass
254
+
255
+ # Update tooltip
256
+ try:
257
+ tooltip = config.get("tooltip", "")
258
+ if tooltip:
259
+ if self.indicator:
260
+ self.indicator.set_title(tooltip)
261
+ elif self.status_icon:
262
+ self.status_icon.set_tooltip_text(tooltip)
263
+ except Exception:
264
+ pass
265
+
266
+ # Build menu
267
+ menu = Gtk.Menu()
268
+ self.flat_items = []
269
+ seq = 0
270
+
271
+ for item in config.get("items", []):
272
+ if not isinstance(item, dict):
273
+ seq += 1
274
+ continue
275
+
276
+ title = item.get("title", "")
277
+
278
+ if title == "<SEPARATOR>":
279
+ menu.append(Gtk.SeparatorMenuItem())
280
+ else:
281
+ hidden = item.get("hidden", False)
282
+ if hidden:
283
+ seq += 1
284
+ continue
285
+
286
+ mi = Gtk.MenuItem(label=title)
287
+ enabled = item.get("enabled", True)
288
+
289
+ if not enabled:
290
+ mi.set_sensitive(False)
291
+ else:
292
+ captured_seq = seq
293
+ captured_item = item
294
+ mi.connect("activate", self.on_click, captured_seq, captured_item)
295
+
296
+ self.flat_items.append((item, seq))
297
+ menu.append(mi)
298
+
299
+ seq += 1
300
+
301
+ menu.show_all()
302
+
303
+ if self.indicator:
304
+ self.indicator.set_menu(menu)
305
+ elif self.status_icon:
306
+ self.menu = menu
307
+
308
+ def on_click(self, widget, seq_id, item):
309
+ event = {"type": "clicked", "item": item, "seq_id": seq_id}
310
+ self.write_line(json.dumps(event))
311
+
312
+ def write_line(self, s):
313
+ try:
314
+ sys.stdout.write(s + "\n")
315
+ sys.stdout.flush()
316
+ except Exception:
317
+ pass
318
+
319
+
320
+ if __name__ == "__main__":
321
+ helper = TrayHelper()
322
+ helper.start()