discourse-systray 0.1.0 → 0.1.2
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.
- checksums.yaml +4 -4
- data/bin/discourse-systray +1 -1
- data/lib/discourse_systray/systray.rb +625 -414
- metadata +16 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 5210b401c239734ab826f42710f3d925e65e52d62b9a5f8b3da18d56b874469b
|
4
|
+
data.tar.gz: 838fac902e2cad6158ea5c5f52086047f4687b5871e1ce5c4b4989203d6e3eda
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: a4694037048949cbb48696ff280327b1d1f6f6349685de9e8a55fcfc81f27326e4812e526c0976c7615a0d0076543139c158744cd451841503b32f70d0963597
|
7
|
+
data.tar.gz: 7193b7995de0c0ff81ca6224c12d478b916bb3972ec896a4266cfbf7a12dc1881091f9cc28c434d3fc65f190e0902e031dbcca6bb66ff705cd92f7891a53e766
|
data/bin/discourse-systray
CHANGED
@@ -5,503 +5,714 @@ require "timeout"
|
|
5
5
|
require "fileutils"
|
6
6
|
require "json"
|
7
7
|
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
8
|
+
module ::DiscourseSystray
|
9
|
+
class Systray
|
10
|
+
CONFIG_DIR = File.expand_path("~/.config/discourse-systray")
|
11
|
+
CONFIG_FILE = File.join(CONFIG_DIR, "config.json")
|
12
|
+
OPTIONS = { debug: false, path: nil }
|
13
|
+
|
14
|
+
def self.load_or_prompt_config
|
15
|
+
OptionParser
|
16
|
+
.new do |opts|
|
17
|
+
opts.banner = "Usage: systray.rb [options]"
|
18
|
+
opts.on("--debug", "Enable debug mode") { OPTIONS[:debug] = true }
|
19
|
+
opts.on("--path PATH", "Set Discourse path") do |p|
|
20
|
+
OPTIONS[:path] = p
|
21
|
+
end
|
22
|
+
opts.on("--console", "Enable console mode") do
|
23
|
+
OPTIONS[:console] = true
|
24
|
+
end
|
25
|
+
opts.on("--attach", "Attach to existing systray") do
|
26
|
+
OPTIONS[:attach] = true
|
27
|
+
end
|
28
|
+
end
|
29
|
+
.parse!
|
30
|
+
FileUtils.mkdir_p(CONFIG_DIR) unless Dir.exist?(CONFIG_DIR)
|
18
31
|
|
19
|
-
|
20
|
-
|
21
|
-
|
32
|
+
if OPTIONS[:path]
|
33
|
+
save_config(path: OPTIONS[:path])
|
34
|
+
return OPTIONS[:path]
|
35
|
+
end
|
22
36
|
|
23
|
-
|
24
|
-
|
37
|
+
if File.exist?(CONFIG_FILE)
|
38
|
+
config = JSON.parse(File.read(CONFIG_FILE))
|
39
|
+
return config["path"] if config["path"] && Dir.exist?(config["path"])
|
40
|
+
end
|
25
41
|
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
42
|
+
# Show dialog to get path
|
43
|
+
dialog =
|
44
|
+
Gtk::FileChooserDialog.new(
|
45
|
+
title: "Select Discourse Directory",
|
46
|
+
parent: nil,
|
47
|
+
action: :select_folder,
|
48
|
+
buttons: [["Cancel", :cancel], ["Select", :accept]]
|
49
|
+
)
|
50
|
+
|
51
|
+
path = nil
|
52
|
+
if dialog.run == :accept
|
53
|
+
path = dialog.filename
|
54
|
+
save_config(path: path)
|
55
|
+
else
|
56
|
+
puts "No Discourse path specified. Exiting."
|
57
|
+
exit 1
|
58
|
+
end
|
30
59
|
|
31
|
-
|
32
|
-
|
33
|
-
return config["path"] if config["path"] && Dir.exist?(config["path"])
|
60
|
+
dialog.destroy
|
61
|
+
path
|
34
62
|
end
|
35
63
|
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
buttons: [["Cancel", :cancel], ["Select", :accept]]
|
43
|
-
)
|
64
|
+
def self.save_config(path:, window_geometry: nil)
|
65
|
+
config = { path: path }
|
66
|
+
config[:window_geometry] = window_geometry if window_geometry
|
67
|
+
File.write(CONFIG_FILE, JSON.generate(config))
|
68
|
+
nil # Prevent return value from being printed
|
69
|
+
end
|
44
70
|
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
71
|
+
def self.load_config
|
72
|
+
return {} unless File.exist?(CONFIG_FILE)
|
73
|
+
JSON.parse(File.read(CONFIG_FILE))
|
74
|
+
rescue JSON::ParserError
|
75
|
+
{}
|
76
|
+
end
|
77
|
+
BUFFER_SIZE = 2000
|
78
|
+
|
79
|
+
def initialize
|
80
|
+
@discourse_path = self.class.load_or_prompt_config unless OPTIONS[:attach]
|
81
|
+
@running = false
|
82
|
+
@ember_output = []
|
83
|
+
@unicorn_output = []
|
84
|
+
@processes = {}
|
85
|
+
@ember_running = false
|
86
|
+
@unicorn_running = false
|
87
|
+
@ember_line_count = 0
|
88
|
+
@unicorn_line_count = 0
|
89
|
+
@status_window = nil
|
52
90
|
end
|
53
91
|
|
54
|
-
|
55
|
-
|
56
|
-
|
92
|
+
def init_systray
|
93
|
+
@indicator = Gtk::StatusIcon.new
|
94
|
+
@indicator.pixbuf =
|
95
|
+
GdkPixbuf::Pixbuf.new(
|
96
|
+
file: File.join(File.dirname(__FILE__), "../../assets/discourse.png")
|
97
|
+
)
|
98
|
+
@indicator.tooltip_text = "Discourse Manager"
|
99
|
+
|
100
|
+
@indicator.signal_connect("popup-menu") do |tray, button, time|
|
101
|
+
menu = Gtk::Menu.new
|
102
|
+
|
103
|
+
# Create menu items with icons
|
104
|
+
start_item = Gtk::ImageMenuItem.new(label: "Start Discourse")
|
105
|
+
start_item.image =
|
106
|
+
Gtk::Image.new(icon_name: "media-playback-start", size: :menu)
|
107
|
+
|
108
|
+
stop_item = Gtk::ImageMenuItem.new(label: "Stop Discourse")
|
109
|
+
stop_item.image =
|
110
|
+
Gtk::Image.new(icon_name: "media-playback-stop", size: :menu)
|
111
|
+
|
112
|
+
status_item = Gtk::ImageMenuItem.new(label: "Show Status")
|
113
|
+
status_item.image =
|
114
|
+
Gtk::Image.new(icon_name: "utilities-system-monitor", size: :menu)
|
115
|
+
|
116
|
+
quit_item = Gtk::ImageMenuItem.new(label: "Quit")
|
117
|
+
quit_item.image =
|
118
|
+
Gtk::Image.new(icon_name: "application-exit", size: :menu)
|
119
|
+
|
120
|
+
# Add items in new order
|
121
|
+
menu.append(start_item)
|
122
|
+
menu.append(stop_item)
|
123
|
+
menu.append(Gtk::SeparatorMenuItem.new)
|
124
|
+
menu.append(status_item)
|
125
|
+
menu.append(Gtk::SeparatorMenuItem.new)
|
126
|
+
menu.append(quit_item)
|
127
|
+
|
128
|
+
start_item.signal_connect("activate") do
|
129
|
+
set_icon(:running)
|
130
|
+
start_discourse
|
131
|
+
@running = true
|
132
|
+
end
|
57
133
|
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
end
|
134
|
+
stop_item.signal_connect("activate") do
|
135
|
+
set_icon(:stopped)
|
136
|
+
stop_discourse
|
137
|
+
@running = false
|
138
|
+
end
|
64
139
|
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
{}
|
70
|
-
end
|
71
|
-
BUFFER_SIZE = 2000
|
72
|
-
|
73
|
-
def initialize
|
74
|
-
@discourse_path = self.class.load_or_prompt_config
|
75
|
-
@indicator = Gtk::StatusIcon.new
|
76
|
-
@indicator.pixbuf = GdkPixbuf::Pixbuf.new(file: File.join(File.dirname(__FILE__), "../../assets/discourse.png"))
|
77
|
-
@indicator.tooltip_text = "Discourse Manager"
|
78
|
-
@running = false
|
79
|
-
@ember_output = []
|
80
|
-
@unicorn_output = []
|
81
|
-
@processes = {}
|
82
|
-
@ember_running = false
|
83
|
-
@unicorn_running = false
|
84
|
-
@status_window = nil
|
85
|
-
|
86
|
-
# Create right-click menu
|
87
|
-
@indicator.signal_connect("popup-menu") do |tray, button, time|
|
88
|
-
menu = Gtk::Menu.new
|
89
|
-
|
90
|
-
# Create menu items with icons
|
91
|
-
start_item = Gtk::ImageMenuItem.new(label: "Start Discourse")
|
92
|
-
start_item.image =
|
93
|
-
Gtk::Image.new(icon_name: "media-playback-start", size: :menu)
|
94
|
-
|
95
|
-
stop_item = Gtk::ImageMenuItem.new(label: "Stop Discourse")
|
96
|
-
stop_item.image =
|
97
|
-
Gtk::Image.new(icon_name: "media-playback-stop", size: :menu)
|
98
|
-
|
99
|
-
status_item = Gtk::ImageMenuItem.new(label: "Show Status")
|
100
|
-
status_item.image =
|
101
|
-
Gtk::Image.new(icon_name: "utilities-system-monitor", size: :menu)
|
102
|
-
|
103
|
-
quit_item = Gtk::ImageMenuItem.new(label: "Quit")
|
104
|
-
quit_item.image =
|
105
|
-
Gtk::Image.new(icon_name: "application-exit", size: :menu)
|
106
|
-
|
107
|
-
# Add items in new order
|
108
|
-
menu.append(start_item)
|
109
|
-
menu.append(stop_item)
|
110
|
-
menu.append(Gtk::SeparatorMenuItem.new)
|
111
|
-
menu.append(status_item)
|
112
|
-
menu.append(Gtk::SeparatorMenuItem.new)
|
113
|
-
menu.append(quit_item)
|
114
|
-
|
115
|
-
start_item.signal_connect("activate") do
|
116
|
-
set_icon(:running)
|
117
|
-
start_discourse
|
118
|
-
@running = true
|
119
|
-
end
|
140
|
+
quit_item.signal_connect("activate") do
|
141
|
+
cleanup
|
142
|
+
Gtk.main_quit
|
143
|
+
end
|
120
144
|
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
@running = false
|
125
|
-
end
|
145
|
+
status_item.signal_connect("activate") { show_status_window }
|
146
|
+
|
147
|
+
menu.show_all
|
126
148
|
|
127
|
-
|
128
|
-
|
129
|
-
|
149
|
+
# Show/hide items based on running state - AFTER show_all
|
150
|
+
start_item.visible = !@running
|
151
|
+
stop_item.visible = @running
|
152
|
+
menu.popup(nil, nil, button, time)
|
130
153
|
end
|
154
|
+
end
|
131
155
|
|
132
|
-
|
156
|
+
def start_discourse
|
157
|
+
@ember_output.clear
|
158
|
+
@unicorn_output.clear
|
133
159
|
|
134
|
-
|
160
|
+
Dir.chdir(@discourse_path) do
|
161
|
+
@processes[:ember] = start_process("bin/ember-cli")
|
162
|
+
@ember_running = true
|
163
|
+
@processes[:unicorn] = start_process("bin/unicorn")
|
164
|
+
@unicorn_running = true
|
165
|
+
update_tab_labels if @notebook
|
166
|
+
end
|
167
|
+
end
|
135
168
|
|
136
|
-
|
137
|
-
|
138
|
-
stop_item.visible = @running
|
139
|
-
menu.popup(nil, nil, button, time)
|
169
|
+
def stop_discourse
|
170
|
+
cleanup
|
140
171
|
end
|
141
|
-
end
|
142
172
|
|
143
|
-
|
144
|
-
|
145
|
-
@unicorn_output.clear
|
173
|
+
def cleanup
|
174
|
+
return if @processes.empty?
|
146
175
|
|
147
|
-
|
148
|
-
@
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
176
|
+
# First disable updates to prevent race conditions
|
177
|
+
@view_timeouts&.values&.each do |id|
|
178
|
+
begin
|
179
|
+
GLib::Source.remove(id)
|
180
|
+
rescue StandardError => e
|
181
|
+
puts "Error removing timeout: #{e}" if OPTIONS[:debug]
|
182
|
+
end
|
183
|
+
end
|
184
|
+
@view_timeouts&.clear
|
155
185
|
|
156
|
-
|
157
|
-
|
158
|
-
|
186
|
+
# Then stop processes
|
187
|
+
@processes.each do |name, process|
|
188
|
+
begin
|
189
|
+
Process.kill("TERM", process[:pid])
|
190
|
+
# Wait for process to finish with timeout
|
191
|
+
Timeout.timeout(10) { process[:thread].join }
|
192
|
+
rescue StandardError => e
|
193
|
+
puts "Error stopping #{name}: #{e}" if OPTIONS[:debug]
|
194
|
+
end
|
195
|
+
end
|
196
|
+
@processes.clear
|
197
|
+
@ember_running = false
|
198
|
+
@unicorn_running = false
|
159
199
|
|
160
|
-
|
161
|
-
|
200
|
+
# Finally clean up UI elements
|
201
|
+
update_tab_labels if @notebook && !@notebook.destroyed?
|
162
202
|
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
# Wait for process to finish with timeout
|
167
|
-
Timeout.timeout(10) { process[:thread].join }
|
168
|
-
rescue StandardError => e
|
169
|
-
puts "Error stopping #{name}: #{e}" if OPTIONS[:debug]
|
203
|
+
if @status_window && !@status_window.destroyed?
|
204
|
+
@status_window.destroy
|
205
|
+
@status_window = nil
|
170
206
|
end
|
171
207
|
end
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
-
|
176
|
-
|
177
|
-
|
178
|
-
|
179
|
-
|
180
|
-
|
181
|
-
|
182
|
-
|
183
|
-
|
184
|
-
|
185
|
-
|
208
|
+
|
209
|
+
def start_process(command, console: false)
|
210
|
+
return start_console_process(command) if console
|
211
|
+
stdin, stdout, stderr, wait_thr = Open3.popen3(command)
|
212
|
+
|
213
|
+
# Create a monitor thread that will detect if process dies
|
214
|
+
monitor_thread =
|
215
|
+
Thread.new do
|
216
|
+
wait_thr.value # Wait for process to finish
|
217
|
+
is_ember = command.include?("ember-cli")
|
218
|
+
@ember_running = false if is_ember
|
219
|
+
@unicorn_running = false unless is_ember
|
220
|
+
GLib::Idle.add do
|
221
|
+
update_tab_labels if @notebook
|
222
|
+
false
|
186
223
|
end
|
187
224
|
end
|
188
|
-
@view_timeouts.clear
|
189
|
-
end
|
190
|
-
@status_window.destroy
|
191
|
-
@status_window = nil
|
192
|
-
end
|
193
|
-
end
|
194
|
-
|
195
|
-
def start_process(command)
|
196
|
-
stdin, stdout, stderr, wait_thr = Open3.popen3(command)
|
197
225
|
|
198
|
-
|
199
|
-
monitor_thread =
|
226
|
+
# Monitor stdout - send to both console and UX buffer
|
200
227
|
Thread.new do
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
|
206
|
-
|
207
|
-
|
228
|
+
while line = stdout.gets
|
229
|
+
buffer =
|
230
|
+
command.include?("ember-cli") ? @ember_output : @unicorn_output
|
231
|
+
publish_to_pipe(line)
|
232
|
+
puts "[OUT] #{line}" if OPTIONS[:debug]
|
233
|
+
buffer << line
|
234
|
+
buffer.shift if buffer.size > BUFFER_SIZE
|
235
|
+
# Force GUI update
|
236
|
+
GLib::Idle.add do
|
237
|
+
update_all_views
|
238
|
+
false
|
239
|
+
end
|
208
240
|
end
|
209
241
|
end
|
210
242
|
|
211
|
-
|
212
|
-
|
213
|
-
|
214
|
-
|
215
|
-
|
216
|
-
|
217
|
-
|
218
|
-
|
219
|
-
|
220
|
-
|
221
|
-
|
243
|
+
# Monitor stderr - send to both console and UX buffer
|
244
|
+
Thread.new do
|
245
|
+
while line = stderr.gets
|
246
|
+
buffer =
|
247
|
+
command.include?("ember-cli") ? @ember_output : @unicorn_output
|
248
|
+
publish_to_pipe("ERROR: #{line}")
|
249
|
+
puts "[ERR] #{line}" if OPTIONS[:debug]
|
250
|
+
buffer << line
|
251
|
+
buffer.shift if buffer.size > BUFFER_SIZE
|
252
|
+
# Force GUI update
|
253
|
+
GLib::Idle.add do
|
254
|
+
update_all_views
|
255
|
+
false
|
256
|
+
end
|
222
257
|
end
|
223
258
|
end
|
259
|
+
|
260
|
+
{
|
261
|
+
pid: wait_thr.pid,
|
262
|
+
stdin: stdin,
|
263
|
+
stdout: stdout,
|
264
|
+
stderr: stderr,
|
265
|
+
thread: wait_thr,
|
266
|
+
monitor: monitor_thread
|
267
|
+
}
|
224
268
|
end
|
225
269
|
|
226
|
-
|
227
|
-
|
228
|
-
|
229
|
-
|
230
|
-
|
231
|
-
|
232
|
-
|
233
|
-
|
234
|
-
|
235
|
-
|
236
|
-
|
270
|
+
def show_status_window
|
271
|
+
if @status_window&.visible?
|
272
|
+
@status_window.present
|
273
|
+
# Force window to current workspace in i3
|
274
|
+
if @status_window.window
|
275
|
+
@status_window.window.raise
|
276
|
+
if system("which i3-msg >/dev/null 2>&1")
|
277
|
+
# First move to current workspace, then focus
|
278
|
+
system(
|
279
|
+
"i3-msg '[id=#{@status_window.window.xid}] move workspace current'"
|
280
|
+
)
|
281
|
+
system("i3-msg '[id=#{@status_window.window.xid}] focus'")
|
282
|
+
end
|
237
283
|
end
|
284
|
+
return
|
238
285
|
end
|
239
|
-
end
|
240
286
|
|
241
|
-
|
242
|
-
|
243
|
-
|
244
|
-
|
245
|
-
|
246
|
-
thread: wait_thr,
|
247
|
-
monitor: monitor_thread
|
248
|
-
}
|
249
|
-
end
|
287
|
+
# Clean up any existing window
|
288
|
+
if @status_window
|
289
|
+
@status_window.destroy
|
290
|
+
@status_window = nil
|
291
|
+
end
|
250
292
|
|
251
|
-
|
252
|
-
|
253
|
-
|
254
|
-
#
|
255
|
-
|
256
|
-
|
257
|
-
|
258
|
-
|
259
|
-
|
260
|
-
|
261
|
-
|
262
|
-
|
263
|
-
|
293
|
+
@status_window = Gtk::Window.new("Discourse Status")
|
294
|
+
@status_window.set_wmclass("discourse-status", "Discourse Status")
|
295
|
+
|
296
|
+
# Load saved geometry or use defaults
|
297
|
+
config = self.class.load_config
|
298
|
+
if config["window_geometry"]
|
299
|
+
geo = config["window_geometry"]
|
300
|
+
@status_window.move(geo["x"], geo["y"])
|
301
|
+
@status_window.resize(geo["width"], geo["height"])
|
302
|
+
else
|
303
|
+
@status_window.set_default_size(800, 600)
|
304
|
+
@status_window.window_position = :center
|
305
|
+
end
|
306
|
+
@status_window.type_hint = :dialog
|
307
|
+
@status_window.set_role("discourse-status-dialog")
|
308
|
+
|
309
|
+
# Handle window destruction and hide
|
310
|
+
@status_window.signal_connect("delete-event") do
|
311
|
+
save_window_geometry
|
312
|
+
@status_window.hide
|
313
|
+
true # Prevent destruction
|
264
314
|
end
|
265
|
-
return
|
266
|
-
end
|
267
315
|
|
268
|
-
|
269
|
-
|
270
|
-
|
271
|
-
|
272
|
-
|
316
|
+
# Save position and size when window is moved or resized
|
317
|
+
@status_window.signal_connect("configure-event") do
|
318
|
+
save_window_geometry
|
319
|
+
false
|
320
|
+
end
|
273
321
|
|
274
|
-
|
275
|
-
@status_window.set_wmclass("discourse-status", "Discourse Status")
|
276
|
-
|
277
|
-
# Load saved geometry or use defaults
|
278
|
-
config = self.class.load_config
|
279
|
-
if config["window_geometry"]
|
280
|
-
geo = config["window_geometry"]
|
281
|
-
@status_window.move(geo["x"], geo["y"])
|
282
|
-
@status_window.resize(geo["width"], geo["height"])
|
283
|
-
else
|
284
|
-
@status_window.set_default_size(800, 600)
|
285
|
-
@status_window.window_position = :center
|
286
|
-
end
|
287
|
-
@status_window.type_hint = :dialog
|
288
|
-
@status_window.set_role("discourse-status-dialog")
|
289
|
-
|
290
|
-
# Handle window destruction and hide
|
291
|
-
@status_window.signal_connect("delete-event") do
|
292
|
-
save_window_geometry
|
293
|
-
@status_window.hide
|
294
|
-
true # Prevent destruction
|
295
|
-
end
|
322
|
+
@notebook = Gtk::Notebook.new
|
296
323
|
|
297
|
-
|
298
|
-
|
299
|
-
|
300
|
-
false
|
301
|
-
end
|
324
|
+
@ember_view = create_log_view(@ember_output)
|
325
|
+
@ember_label = create_status_label("Ember CLI", @ember_running)
|
326
|
+
@notebook.append_page(@ember_view, @ember_label)
|
302
327
|
|
303
|
-
|
328
|
+
@unicorn_view = create_log_view(@unicorn_output)
|
329
|
+
@unicorn_label = create_status_label("Unicorn", @unicorn_running)
|
330
|
+
@notebook.append_page(@unicorn_view, @unicorn_label)
|
304
331
|
|
305
|
-
|
306
|
-
|
307
|
-
|
332
|
+
@status_window.add(@notebook)
|
333
|
+
@status_window.show_all
|
334
|
+
end
|
308
335
|
|
309
|
-
|
310
|
-
|
311
|
-
|
336
|
+
def update_all_views
|
337
|
+
return unless @status_window && !@status_window.destroyed?
|
338
|
+
return unless @ember_view && @unicorn_view
|
339
|
+
return unless @ember_view.child && @unicorn_view.child
|
340
|
+
return if @ember_view.destroyed? || @unicorn_view.destroyed?
|
341
|
+
return if @ember_view.child.destroyed? || @unicorn_view.child.destroyed?
|
312
342
|
|
313
|
-
|
314
|
-
|
315
|
-
|
343
|
+
begin
|
344
|
+
if @ember_view.visible? && @ember_view.child.visible?
|
345
|
+
update_log_view(@ember_view.child, @ember_output)
|
346
|
+
end
|
347
|
+
if @unicorn_view.visible? && @unicorn_view.child.visible?
|
348
|
+
update_log_view(@unicorn_view.child, @unicorn_output)
|
349
|
+
end
|
350
|
+
rescue StandardError => e
|
351
|
+
puts "Error updating views: #{e}" if OPTIONS[:debug]
|
352
|
+
end
|
353
|
+
end
|
316
354
|
|
317
|
-
|
318
|
-
|
355
|
+
def create_log_view(buffer)
|
356
|
+
scroll = Gtk::ScrolledWindow.new
|
357
|
+
text_view = Gtk::TextView.new
|
358
|
+
text_view.editable = false
|
359
|
+
text_view.wrap_mode = :word
|
360
|
+
|
361
|
+
# Set white text on black background
|
362
|
+
text_view.override_background_color(:normal, Gdk::RGBA.new(0, 0, 0, 1))
|
363
|
+
text_view.override_color(:normal, Gdk::RGBA.new(1, 1, 1, 1))
|
364
|
+
|
365
|
+
# Create text tags for colors
|
366
|
+
_tag_table = text_view.buffer.tag_table
|
367
|
+
create_ansi_tags(text_view.buffer)
|
368
|
+
|
369
|
+
# Initial text
|
370
|
+
update_log_view(text_view, buffer)
|
371
|
+
|
372
|
+
# Store timeouts in instance variable for proper cleanup
|
373
|
+
@view_timeouts ||= {}
|
374
|
+
|
375
|
+
# Set up periodic refresh with validity check
|
376
|
+
timeout_id =
|
377
|
+
GLib::Timeout.add(500) do
|
378
|
+
if text_view&.parent.nil? || text_view.destroyed?
|
379
|
+
@view_timeouts.delete(text_view.object_id)
|
380
|
+
false # Stop the timeout if view is destroyed
|
381
|
+
else
|
382
|
+
begin
|
383
|
+
update_log_view(text_view, buffer)
|
384
|
+
rescue StandardError => e
|
385
|
+
puts "Error updating log view: #{e}" if OPTIONS[:debug]
|
386
|
+
end
|
387
|
+
true # Keep the timeout active
|
388
|
+
end
|
389
|
+
end
|
319
390
|
|
320
|
-
|
321
|
-
update_log_view(@unicorn_view.child, @unicorn_output)
|
322
|
-
end
|
391
|
+
@view_timeouts[text_view.object_id] = timeout_id
|
323
392
|
|
324
|
-
|
325
|
-
|
326
|
-
|
327
|
-
|
328
|
-
|
393
|
+
# Clean up timeout when view is destroyed
|
394
|
+
text_view.signal_connect("destroy") do
|
395
|
+
if timeout_id = @view_timeouts.delete(text_view.object_id)
|
396
|
+
begin
|
397
|
+
GLib::Source.remove(timeout_id)
|
398
|
+
rescue StandardError
|
399
|
+
nil
|
400
|
+
end
|
401
|
+
end
|
402
|
+
end
|
329
403
|
|
330
|
-
|
331
|
-
|
332
|
-
|
404
|
+
scroll.add(text_view)
|
405
|
+
scroll
|
406
|
+
end
|
333
407
|
|
334
|
-
|
335
|
-
|
336
|
-
|
408
|
+
def create_ansi_tags(buffer)
|
409
|
+
# Basic ANSI colors
|
410
|
+
{
|
411
|
+
"31" => "#ff6b6b", # Brighter red
|
412
|
+
"32" => "#87ff87", # Brighter green
|
413
|
+
"33" => "#ffff87", # Brighter yellow
|
414
|
+
"34" => "#87d7ff", # Brighter blue
|
415
|
+
"35" => "#ff87ff", # Brighter magenta
|
416
|
+
"36" => "#87ffff", # Brighter cyan
|
417
|
+
"37" => "#ffffff" # White
|
418
|
+
}.each do |code, color|
|
419
|
+
buffer.create_tag("ansi_#{code}", foreground: color)
|
420
|
+
end
|
421
|
+
|
422
|
+
# Add more tags for bold, etc
|
423
|
+
buffer.create_tag("bold", weight: :bold)
|
424
|
+
end
|
337
425
|
|
338
|
-
|
339
|
-
|
426
|
+
def update_log_view(text_view, buffer)
|
427
|
+
return if text_view.nil? || text_view.destroyed?
|
428
|
+
return if text_view.buffer.nil? || text_view.buffer.destroyed?
|
429
|
+
|
430
|
+
# Determine which offset counter to use
|
431
|
+
offset_var =
|
432
|
+
(
|
433
|
+
if buffer.equal?(@ember_output)
|
434
|
+
:@ember_line_count
|
435
|
+
else
|
436
|
+
:@unicorn_line_count
|
437
|
+
end
|
438
|
+
)
|
439
|
+
current_offset = instance_variable_get(offset_var)
|
440
|
+
|
441
|
+
# Don't call if we've already processed all lines
|
442
|
+
return if buffer.size <= current_offset
|
443
|
+
|
444
|
+
adj = text_view&.parent&.vadjustment
|
445
|
+
was_at_bottom = (adj && adj.value >= adj.upper - adj.page_size - 50)
|
446
|
+
old_value = adj ? adj.value : 0
|
447
|
+
|
448
|
+
# Process only the new lines
|
449
|
+
new_lines = buffer[current_offset..-1]
|
450
|
+
new_lines.each do |line|
|
451
|
+
ansi_segments =
|
452
|
+
line.scan(/\e\[([0-9;]*)m([^\e]*)|\e\[K([^\e]*)|([^\e]+)/)
|
453
|
+
|
454
|
+
ansi_segments.each do |codes, text_part, clear_part, plain|
|
455
|
+
chunk = text_part || clear_part || plain.to_s
|
456
|
+
chunk_start_iter = text_view.buffer.end_iter
|
457
|
+
text_view.buffer.insert(chunk_start_iter, chunk)
|
458
|
+
|
459
|
+
# For each ANSI code, apply tags
|
460
|
+
if codes
|
461
|
+
codes
|
462
|
+
.split(";")
|
463
|
+
.each do |code|
|
464
|
+
case code
|
465
|
+
when "1"
|
466
|
+
text_view.buffer.apply_tag(
|
467
|
+
"bold",
|
468
|
+
chunk_start_iter,
|
469
|
+
text_view.buffer.end_iter
|
470
|
+
)
|
471
|
+
when "31".."37"
|
472
|
+
text_view.buffer.apply_tag(
|
473
|
+
"ansi_#{code}",
|
474
|
+
chunk_start_iter,
|
475
|
+
text_view.buffer.end_iter
|
476
|
+
)
|
477
|
+
end
|
478
|
+
end
|
479
|
+
end
|
480
|
+
end
|
481
|
+
end
|
340
482
|
|
341
|
-
|
342
|
-
|
483
|
+
# Update our offset counter
|
484
|
+
instance_variable_set(offset_var, buffer.size)
|
343
485
|
|
344
|
-
|
345
|
-
|
346
|
-
|
347
|
-
|
348
|
-
@view_timeouts.delete(text_view.object_id)
|
349
|
-
false # Stop the timeout if view is destroyed
|
486
|
+
# Restore scroll position
|
487
|
+
if adj
|
488
|
+
if was_at_bottom
|
489
|
+
adj.value = adj.upper - adj.page_size
|
350
490
|
else
|
351
|
-
|
352
|
-
update_log_view(text_view, buffer)
|
353
|
-
rescue StandardError
|
354
|
-
nil
|
355
|
-
end
|
356
|
-
true # Keep the timeout active
|
491
|
+
adj.value = old_value
|
357
492
|
end
|
358
493
|
end
|
494
|
+
end
|
359
495
|
|
360
|
-
|
496
|
+
def create_status_label(text, running)
|
497
|
+
box = Gtk::Box.new(:horizontal, 5)
|
498
|
+
label = Gtk::Label.new(text)
|
499
|
+
status = Gtk::Label.new
|
500
|
+
color =
|
501
|
+
(
|
502
|
+
if running
|
503
|
+
Gdk::RGBA.new(0.2, 0.8, 0.2, 1)
|
504
|
+
else
|
505
|
+
Gdk::RGBA.new(0.8, 0.2, 0.2, 1)
|
506
|
+
end
|
507
|
+
)
|
508
|
+
status.override_color(:normal, color)
|
509
|
+
status.text = running ? "●" : "○"
|
510
|
+
box.pack_start(label, expand: false, fill: false, padding: 0)
|
511
|
+
box.pack_start(status, expand: false, fill: false, padding: 0)
|
512
|
+
box.show_all
|
513
|
+
box
|
514
|
+
end
|
515
|
+
|
516
|
+
def update_tab_labels
|
517
|
+
return unless @notebook && !@notebook.destroyed?
|
518
|
+
return unless @ember_label && @unicorn_label
|
519
|
+
return if @ember_label.destroyed? || @unicorn_label.destroyed?
|
361
520
|
|
362
|
-
|
363
|
-
|
364
|
-
|
521
|
+
[@ember_label, @unicorn_label].each do |label|
|
522
|
+
next unless label.children && label.children.length > 1
|
523
|
+
next if label.children[1].destroyed?
|
524
|
+
|
525
|
+
is_running = label == @ember_label ? @ember_running : @unicorn_running
|
365
526
|
begin
|
366
|
-
|
367
|
-
|
368
|
-
|
527
|
+
label.children[1].text = is_running ? "●" : "○"
|
528
|
+
label.children[1].override_color(
|
529
|
+
:normal,
|
530
|
+
Gdk::RGBA.new(
|
531
|
+
is_running ? 0.2 : 0.8,
|
532
|
+
is_running ? 0.8 : 0.2,
|
533
|
+
0.2,
|
534
|
+
1
|
535
|
+
)
|
536
|
+
)
|
537
|
+
rescue StandardError => e
|
538
|
+
puts "Error updating label: #{e}" if OPTIONS[:debug]
|
369
539
|
end
|
370
540
|
end
|
371
541
|
end
|
372
542
|
|
373
|
-
|
374
|
-
|
375
|
-
|
543
|
+
def save_window_geometry
|
544
|
+
return unless @status_window&.visible? && @status_window.window
|
545
|
+
|
546
|
+
x, y = @status_window.position
|
547
|
+
width, height = @status_window.size
|
376
548
|
|
377
|
-
|
378
|
-
|
379
|
-
|
380
|
-
|
381
|
-
|
382
|
-
|
383
|
-
|
384
|
-
|
385
|
-
|
386
|
-
"37" => "#ffffff" # White
|
387
|
-
}.each do |code, color|
|
388
|
-
buffer.create_tag("ansi_#{code}", foreground: color)
|
549
|
+
self.class.save_config(
|
550
|
+
path: @discourse_path,
|
551
|
+
window_geometry: {
|
552
|
+
"x" => x,
|
553
|
+
"y" => y,
|
554
|
+
"width" => width,
|
555
|
+
"height" => height
|
556
|
+
}
|
557
|
+
)
|
389
558
|
end
|
390
559
|
|
391
|
-
|
392
|
-
|
393
|
-
|
560
|
+
def set_icon(status)
|
561
|
+
icon_file = status == :running ? "discourse_running.png" : "discourse.png"
|
562
|
+
icon_path = File.join(File.dirname(__FILE__), "../../assets", icon_file)
|
563
|
+
@indicator.pixbuf = GdkPixbuf::Pixbuf.new(file: icon_path)
|
564
|
+
end
|
394
565
|
|
395
|
-
|
396
|
-
|
397
|
-
|
398
|
-
|
399
|
-
|
400
|
-
|
401
|
-
|
402
|
-
|
403
|
-
|
404
|
-
|
405
|
-
|
406
|
-
|
407
|
-
|
408
|
-
|
409
|
-
|
410
|
-
|
411
|
-
|
412
|
-
|
413
|
-
|
414
|
-
|
415
|
-
|
416
|
-
|
417
|
-
end
|
418
|
-
text_view.buffer.insert(iter, text)
|
419
|
-
elsif clear_line
|
420
|
-
text_view.buffer.insert(iter, clear_line)
|
421
|
-
else
|
422
|
-
text_view.buffer.insert(iter, plain || "")
|
566
|
+
def start_console_process(command)
|
567
|
+
stdin, stdout, stderr, wait_thr = Open3.popen3(command)
|
568
|
+
|
569
|
+
# Pipe stdout to console and add to buffer
|
570
|
+
Thread.new do
|
571
|
+
while line = stdout.gets
|
572
|
+
buffer =
|
573
|
+
command.include?("ember-cli") ? @ember_output : @unicorn_output
|
574
|
+
print line
|
575
|
+
buffer << line
|
576
|
+
buffer.shift if buffer.size > BUFFER_SIZE
|
577
|
+
end
|
578
|
+
end
|
579
|
+
|
580
|
+
# Pipe stderr to console and add to buffer
|
581
|
+
Thread.new do
|
582
|
+
while line = stderr.gets
|
583
|
+
buffer =
|
584
|
+
command.include?("ember-cli") ? @ember_output : @unicorn_output
|
585
|
+
STDERR.print line
|
586
|
+
buffer << line
|
587
|
+
buffer.shift if buffer.size > BUFFER_SIZE
|
423
588
|
end
|
424
589
|
end
|
590
|
+
|
591
|
+
{
|
592
|
+
pid: wait_thr.pid,
|
593
|
+
stdin: stdin,
|
594
|
+
stdout: stdout,
|
595
|
+
stderr: stderr,
|
596
|
+
thread: wait_thr
|
597
|
+
}
|
425
598
|
end
|
426
599
|
|
427
|
-
|
428
|
-
|
429
|
-
|
430
|
-
|
431
|
-
|
600
|
+
PIPE_PATH = "/tmp/discourse_systray_cmd"
|
601
|
+
PID_FILE = "/tmp/discourse_systray.pid"
|
602
|
+
|
603
|
+
def self.running?
|
604
|
+
return false unless File.exist?(PID_FILE)
|
605
|
+
pid = File.read(PID_FILE).to_i
|
606
|
+
Process.kill(0, pid)
|
607
|
+
true
|
608
|
+
rescue Errno::ESRCH, Errno::ENOENT
|
609
|
+
begin
|
610
|
+
File.unlink(PID_FILE)
|
611
|
+
rescue StandardError
|
612
|
+
nil
|
432
613
|
end
|
614
|
+
false
|
433
615
|
end
|
434
|
-
end
|
435
616
|
|
436
|
-
|
437
|
-
|
438
|
-
|
439
|
-
|
440
|
-
|
441
|
-
|
442
|
-
|
443
|
-
|
444
|
-
|
445
|
-
|
617
|
+
attr_reader :discourse_path
|
618
|
+
|
619
|
+
def self.run
|
620
|
+
new.run
|
621
|
+
end
|
622
|
+
|
623
|
+
def run
|
624
|
+
if OPTIONS[:attach]
|
625
|
+
require "rb-inotify"
|
626
|
+
|
627
|
+
notifier = INotify::Notifier.new
|
628
|
+
|
629
|
+
begin
|
630
|
+
pipe = File.open(PIPE_PATH, "r")
|
631
|
+
|
632
|
+
# Watch for pipe deletion
|
633
|
+
notifier.watch(File.dirname(PIPE_PATH), :delete) do |event|
|
634
|
+
if event.name == File.basename(PIPE_PATH)
|
635
|
+
puts "Pipe was deleted, exiting."
|
636
|
+
exit 0
|
637
|
+
end
|
638
|
+
end
|
639
|
+
|
640
|
+
# Read from pipe in a separate thread
|
641
|
+
reader =
|
642
|
+
Thread.new do
|
643
|
+
begin
|
644
|
+
while true
|
645
|
+
if IO.select([pipe], nil, nil, 0.5)
|
646
|
+
while line = pipe.gets
|
647
|
+
puts line
|
648
|
+
STDOUT.flush
|
649
|
+
end
|
650
|
+
end
|
651
|
+
|
652
|
+
sleep 0.1
|
653
|
+
unless File.exist?(PIPE_PATH)
|
654
|
+
puts "Pipe was deleted, exiting."
|
655
|
+
exit 0
|
656
|
+
end
|
657
|
+
end
|
658
|
+
rescue EOFError, IOError
|
659
|
+
puts "Pipe closed, exiting."
|
660
|
+
exit 0
|
661
|
+
end
|
662
|
+
end
|
663
|
+
|
664
|
+
# Handle notifications in main thread
|
665
|
+
notifier.run
|
666
|
+
rescue Errno::ENOENT
|
667
|
+
puts "Pipe doesn't exist, exiting."
|
668
|
+
exit 1
|
669
|
+
ensure
|
670
|
+
reader&.kill
|
671
|
+
pipe&.close
|
672
|
+
notifier&.close
|
446
673
|
end
|
447
|
-
|
448
|
-
|
449
|
-
status.text = running ? "●" : "○"
|
450
|
-
box.pack_start(label, expand: false, fill: false, padding: 0)
|
451
|
-
box.pack_start(status, expand: false, fill: false, padding: 0)
|
452
|
-
box.show_all
|
453
|
-
box
|
454
|
-
end
|
674
|
+
else
|
675
|
+
return if self.class.running?
|
455
676
|
|
456
|
-
|
457
|
-
return unless @notebook
|
458
|
-
@ember_label.children[1].text = @ember_running ? "●" : "○"
|
459
|
-
@ember_label.children[1].override_color(
|
460
|
-
:normal,
|
461
|
-
Gdk::RGBA.new(
|
462
|
-
@ember_running ? 0.2 : 0.8,
|
463
|
-
@ember_running ? 0.8 : 0.2,
|
464
|
-
0.2,
|
465
|
-
1
|
466
|
-
)
|
467
|
-
)
|
468
|
-
|
469
|
-
@unicorn_label.children[1].text = @unicorn_running ? "●" : "○"
|
470
|
-
@unicorn_label.children[1].override_color(
|
471
|
-
:normal,
|
472
|
-
Gdk::RGBA.new(
|
473
|
-
@unicorn_running ? 0.2 : 0.8,
|
474
|
-
@unicorn_running ? 0.8 : 0.2,
|
475
|
-
0.2,
|
476
|
-
1
|
477
|
-
)
|
478
|
-
)
|
479
|
-
end
|
677
|
+
system("mkfifo #{PIPE_PATH}") unless File.exist?(PIPE_PATH)
|
480
678
|
|
481
|
-
|
482
|
-
|
679
|
+
# Create named pipe and write PID file
|
680
|
+
system("mkfifo #{PIPE_PATH}") unless File.exist?(PIPE_PATH)
|
681
|
+
File.write(PID_FILE, Process.pid.to_s)
|
483
682
|
|
484
|
-
|
485
|
-
|
683
|
+
# Set up cleanup on exit
|
684
|
+
at_exit do
|
685
|
+
begin
|
686
|
+
File.unlink(PIPE_PATH) if File.exist?(PIPE_PATH)
|
687
|
+
File.unlink(PID_FILE) if File.exist?(PID_FILE)
|
688
|
+
rescue StandardError => e
|
689
|
+
puts "Error during cleanup: #{e}" if OPTIONS[:debug]
|
690
|
+
end
|
691
|
+
end
|
486
692
|
|
487
|
-
|
488
|
-
|
489
|
-
window_geometry: {
|
490
|
-
"x" => x,
|
491
|
-
"y" => y,
|
492
|
-
"width" => width,
|
493
|
-
"height" => height
|
494
|
-
}
|
495
|
-
)
|
496
|
-
end
|
693
|
+
# Initialize GTK
|
694
|
+
Gtk.init
|
497
695
|
|
498
|
-
|
499
|
-
|
500
|
-
icon_path = File.join(File.dirname(__FILE__), "../../assets", icon_file)
|
501
|
-
@indicator.pixbuf = GdkPixbuf::Pixbuf.new(file: icon_path)
|
502
|
-
end
|
696
|
+
# Setup systray icon and menu
|
697
|
+
init_systray
|
503
698
|
|
504
|
-
|
505
|
-
|
699
|
+
# Start GTK main loop
|
700
|
+
Gtk.main
|
701
|
+
end
|
702
|
+
end
|
703
|
+
|
704
|
+
def publish_to_pipe(msg)
|
705
|
+
return unless File.exist?(PIPE_PATH)
|
706
|
+
puts "Publish to pipe: #{msg}" if OPTIONS[:debug]
|
707
|
+
begin
|
708
|
+
File.open(PIPE_PATH, "w") { |f| f.puts(msg) }
|
709
|
+
rescue Errno::EPIPE, IOError => e
|
710
|
+
puts "Error writing to pipe: #{e}" if OPTIONS[:debug]
|
711
|
+
end
|
712
|
+
end
|
713
|
+
|
714
|
+
def handle_command(cmd)
|
715
|
+
puts "Received command: #{cmd}" if OPTIONS[:debug]
|
716
|
+
end
|
506
717
|
end
|
507
718
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: discourse-systray
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.1.
|
4
|
+
version: 0.1.2
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Sam Saffron
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2025-01-
|
11
|
+
date: 2025-01-26 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: gtk3
|
@@ -24,6 +24,20 @@ dependencies:
|
|
24
24
|
- - "~>"
|
25
25
|
- !ruby/object:Gem::Version
|
26
26
|
version: '4.2'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: rb-inotify
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: 0.10.1
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: 0.10.1
|
27
41
|
- !ruby/object:Gem::Dependency
|
28
42
|
name: bundler
|
29
43
|
requirement: !ruby/object:Gem::Requirement
|