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.
- package/README.md +198 -198
- package/dist/api-client-BF6GDR7Q.js +12 -0
- package/dist/{chunk-WJKZWKER.js → chunk-63FVALWX.js} +1 -1
- package/dist/chunk-63FVALWX.js.map +1 -0
- package/dist/chunk-6GOJPFZ7.js +113 -0
- package/dist/chunk-6GOJPFZ7.js.map +1 -0
- package/dist/chunk-6UGTWBUW.js +89 -0
- package/dist/chunk-6UGTWBUW.js.map +1 -0
- package/dist/{chunk-2CVEPT6U.js → chunk-73NUWYUO.js} +2 -2
- package/dist/chunk-73NUWYUO.js.map +1 -0
- package/dist/chunk-QIV4VIXA.js +221 -0
- package/dist/chunk-QIV4VIXA.js.map +1 -0
- package/dist/{chunk-CPL3P2OF.js → chunk-QUXHHRRK.js} +2 -2
- package/dist/chunk-QUXHHRRK.js.map +1 -0
- package/dist/{chunk-ODOZM4QV.js → chunk-RQ2SC5HW.js} +72 -10
- package/dist/chunk-RQ2SC5HW.js.map +1 -0
- package/dist/chunk-U53QWUOR.js +727 -0
- package/dist/chunk-U53QWUOR.js.map +1 -0
- package/dist/chunk-VAQ73XPE.js +68 -0
- package/dist/chunk-VAQ73XPE.js.map +1 -0
- package/dist/chunk-XLJGCOVT.js +975 -0
- package/dist/chunk-XLJGCOVT.js.map +1 -0
- package/dist/{claude-watcher-N6GN6WHJ.js → claude-watcher-WKGBJYKN.js} +65 -3
- package/dist/claude-watcher-WKGBJYKN.js.map +1 -0
- package/dist/cli.js +495 -1846
- package/dist/cli.js.map +1 -1
- package/dist/{config-P5EM5L7N.js → config-ZOKAP2LJ.js} +3 -3
- package/dist/daemon-6DTCMOJB.js +28 -0
- package/dist/daemon-runner.js +338 -71
- package/dist/daemon-runner.js.map +1 -1
- package/dist/database-KQY5OSCS.js +9 -0
- package/dist/git-OGUSYBJS.js +16 -0
- package/dist/git-OGUSYBJS.js.map +1 -0
- package/dist/git-OUAHIOY2.js +110 -0
- package/dist/git-OUAHIOY2.js.map +1 -0
- package/dist/index.js.map +1 -1
- package/dist/{paths-INOKEM66.js → paths-MPOZBOKE.js} +2 -2
- package/dist/paths-MPOZBOKE.js.map +1 -0
- package/dist/project-OFU2W6MH.js +19 -0
- package/dist/project-OFU2W6MH.js.map +1 -0
- package/dist/shell-NZABRJLA.js +16 -0
- package/dist/shell-NZABRJLA.js.map +1 -0
- package/dist/skill-installer-F67OAOQN.js +121 -0
- package/dist/skill-installer-F67OAOQN.js.map +1 -0
- package/dist/{skill-usage-detector-EO26MRYV.js → skill-usage-detector-MSW5VWQZ.js} +2 -2
- package/dist/skill-usage-detector-MSW5VWQZ.js.map +1 -0
- package/dist/tray-WKFGUUTO.js +346 -0
- package/dist/tray-WKFGUUTO.js.map +1 -0
- package/package.json +62 -63
- package/scripts/postinstall.mjs +415 -364
- package/scripts/tray-helper-darwin +0 -0
- package/scripts/tray-helper-darwin.swift +180 -180
- package/scripts/tray-helper-linux.py +322 -0
- package/scripts/tray-helper-windows.cs +322 -0
- package/dist/api-client-KUQW7FSC.js +0 -12
- package/dist/chunk-2CVEPT6U.js.map +0 -1
- package/dist/chunk-CPL3P2OF.js.map +0 -1
- package/dist/chunk-ODOZM4QV.js.map +0 -1
- package/dist/chunk-WJKZWKER.js.map +0 -1
- package/dist/claude-watcher-N6GN6WHJ.js.map +0 -1
- package/dist/database-F3BFFZKG.js +0 -9
- package/dist/skill-usage-detector-EO26MRYV.js.map +0 -1
- package/dist/tray-UCAI2U2C.js +0 -408
- package/dist/tray-UCAI2U2C.js.map +0 -1
- /package/dist/{api-client-KUQW7FSC.js.map → api-client-BF6GDR7Q.js.map} +0 -0
- /package/dist/{config-P5EM5L7N.js.map → config-ZOKAP2LJ.js.map} +0 -0
- /package/dist/{database-F3BFFZKG.js.map → daemon-6DTCMOJB.js.map} +0 -0
- /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()
|