discourse-systray 0.1.1 → 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 +624 -427
- 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,517 +5,714 @@ require "timeout"
|
|
5
5
|
require "fileutils"
|
6
6
|
require "json"
|
7
7
|
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
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)
|
22
31
|
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
32
|
+
if OPTIONS[:path]
|
33
|
+
save_config(path: OPTIONS[:path])
|
34
|
+
return OPTIONS[:path]
|
35
|
+
end
|
27
36
|
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
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
|
32
41
|
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
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
|
41
59
|
|
42
|
-
|
43
|
-
|
44
|
-
path = dialog.filename
|
45
|
-
save_config(path: path)
|
46
|
-
else
|
47
|
-
puts "No Discourse path specified. Exiting."
|
48
|
-
exit 1
|
60
|
+
dialog.destroy
|
61
|
+
path
|
49
62
|
end
|
50
63
|
|
51
|
-
|
52
|
-
|
53
|
-
|
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
|
54
70
|
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
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
|
90
|
+
end
|
61
91
|
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
Gtk::
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
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
|
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
|
120
133
|
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
134
|
+
stop_item.signal_connect("activate") do
|
135
|
+
set_icon(:stopped)
|
136
|
+
stop_discourse
|
137
|
+
@running = false
|
138
|
+
end
|
126
139
|
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
140
|
+
quit_item.signal_connect("activate") do
|
141
|
+
cleanup
|
142
|
+
Gtk.main_quit
|
143
|
+
end
|
131
144
|
|
132
|
-
|
145
|
+
status_item.signal_connect("activate") { show_status_window }
|
133
146
|
|
134
|
-
|
147
|
+
menu.show_all
|
135
148
|
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
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)
|
153
|
+
end
|
140
154
|
end
|
141
|
-
end
|
142
155
|
|
143
|
-
|
144
|
-
|
145
|
-
|
156
|
+
def start_discourse
|
157
|
+
@ember_output.clear
|
158
|
+
@unicorn_output.clear
|
146
159
|
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
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
|
153
167
|
end
|
154
|
-
end
|
155
168
|
|
156
|
-
|
157
|
-
|
158
|
-
|
169
|
+
def stop_discourse
|
170
|
+
cleanup
|
171
|
+
end
|
159
172
|
|
160
|
-
|
161
|
-
|
173
|
+
def cleanup
|
174
|
+
return if @processes.empty?
|
162
175
|
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
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
|
169
183
|
end
|
170
|
-
|
171
|
-
@view_timeouts&.clear
|
184
|
+
@view_timeouts&.clear
|
172
185
|
|
173
|
-
|
174
|
-
|
175
|
-
|
176
|
-
|
177
|
-
|
178
|
-
|
179
|
-
|
180
|
-
|
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
|
181
195
|
end
|
182
|
-
|
183
|
-
|
184
|
-
|
185
|
-
@unicorn_running = false
|
196
|
+
@processes.clear
|
197
|
+
@ember_running = false
|
198
|
+
@unicorn_running = false
|
186
199
|
|
187
|
-
|
188
|
-
|
200
|
+
# Finally clean up UI elements
|
201
|
+
update_tab_labels if @notebook && !@notebook.destroyed?
|
189
202
|
|
190
|
-
|
191
|
-
|
192
|
-
|
203
|
+
if @status_window && !@status_window.destroyed?
|
204
|
+
@status_window.destroy
|
205
|
+
@status_window = nil
|
206
|
+
end
|
193
207
|
end
|
194
|
-
end
|
195
208
|
|
196
|
-
|
197
|
-
|
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
|
223
|
+
end
|
224
|
+
end
|
198
225
|
|
199
|
-
|
200
|
-
monitor_thread =
|
226
|
+
# Monitor stdout - send to both console and UX buffer
|
201
227
|
Thread.new do
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
|
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
|
209
240
|
end
|
210
241
|
end
|
211
242
|
|
212
|
-
|
213
|
-
|
214
|
-
|
215
|
-
|
216
|
-
|
217
|
-
|
218
|
-
|
219
|
-
|
220
|
-
|
221
|
-
|
222
|
-
|
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
|
223
257
|
end
|
224
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
|
+
}
|
225
268
|
end
|
226
269
|
|
227
|
-
|
228
|
-
|
229
|
-
|
230
|
-
|
231
|
-
|
232
|
-
|
233
|
-
|
234
|
-
|
235
|
-
|
236
|
-
|
237
|
-
|
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
|
238
283
|
end
|
284
|
+
return
|
239
285
|
end
|
240
|
-
end
|
241
286
|
|
242
|
-
|
243
|
-
|
244
|
-
|
245
|
-
|
246
|
-
|
247
|
-
thread: wait_thr,
|
248
|
-
monitor: monitor_thread
|
249
|
-
}
|
250
|
-
end
|
287
|
+
# Clean up any existing window
|
288
|
+
if @status_window
|
289
|
+
@status_window.destroy
|
290
|
+
@status_window = nil
|
291
|
+
end
|
251
292
|
|
252
|
-
|
253
|
-
|
254
|
-
|
255
|
-
#
|
256
|
-
|
257
|
-
|
258
|
-
|
259
|
-
|
260
|
-
|
261
|
-
|
262
|
-
|
263
|
-
|
264
|
-
|
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
|
265
314
|
end
|
266
|
-
return
|
267
|
-
end
|
268
315
|
|
269
|
-
|
270
|
-
|
271
|
-
|
272
|
-
|
273
|
-
|
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
|
274
321
|
|
275
|
-
|
276
|
-
@status_window.set_wmclass("discourse-status", "Discourse Status")
|
277
|
-
|
278
|
-
# Load saved geometry or use defaults
|
279
|
-
config = self.class.load_config
|
280
|
-
if config["window_geometry"]
|
281
|
-
geo = config["window_geometry"]
|
282
|
-
@status_window.move(geo["x"], geo["y"])
|
283
|
-
@status_window.resize(geo["width"], geo["height"])
|
284
|
-
else
|
285
|
-
@status_window.set_default_size(800, 600)
|
286
|
-
@status_window.window_position = :center
|
287
|
-
end
|
288
|
-
@status_window.type_hint = :dialog
|
289
|
-
@status_window.set_role("discourse-status-dialog")
|
290
|
-
|
291
|
-
# Handle window destruction and hide
|
292
|
-
@status_window.signal_connect("delete-event") do
|
293
|
-
save_window_geometry
|
294
|
-
@status_window.hide
|
295
|
-
true # Prevent destruction
|
296
|
-
end
|
322
|
+
@notebook = Gtk::Notebook.new
|
297
323
|
|
298
|
-
|
299
|
-
|
300
|
-
|
301
|
-
false
|
302
|
-
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)
|
303
327
|
|
304
|
-
|
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)
|
305
331
|
|
306
|
-
|
307
|
-
|
308
|
-
|
332
|
+
@status_window.add(@notebook)
|
333
|
+
@status_window.show_all
|
334
|
+
end
|
309
335
|
|
310
|
-
|
311
|
-
|
312
|
-
|
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?
|
313
342
|
|
314
|
-
|
315
|
-
|
316
|
-
|
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
|
317
354
|
|
318
|
-
|
319
|
-
|
320
|
-
|
321
|
-
|
322
|
-
|
323
|
-
|
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
|
324
390
|
|
325
|
-
|
326
|
-
|
327
|
-
|
328
|
-
|
329
|
-
|
330
|
-
|
391
|
+
@view_timeouts[text_view.object_id] = timeout_id
|
392
|
+
|
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
|
331
402
|
end
|
332
|
-
rescue StandardError => e
|
333
|
-
puts "Error updating views: #{e}" if OPTIONS[:debug]
|
334
|
-
end
|
335
|
-
end
|
336
403
|
|
337
|
-
|
338
|
-
|
339
|
-
|
340
|
-
text_view.editable = false
|
341
|
-
text_view.wrap_mode = :word
|
404
|
+
scroll.add(text_view)
|
405
|
+
scroll
|
406
|
+
end
|
342
407
|
|
343
|
-
|
344
|
-
|
345
|
-
|
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
|
346
421
|
|
347
|
-
|
348
|
-
|
349
|
-
|
422
|
+
# Add more tags for bold, etc
|
423
|
+
buffer.create_tag("bold", weight: :bold)
|
424
|
+
end
|
350
425
|
|
351
|
-
|
352
|
-
|
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
|
353
482
|
|
354
|
-
|
355
|
-
|
483
|
+
# Update our offset counter
|
484
|
+
instance_variable_set(offset_var, buffer.size)
|
356
485
|
|
357
|
-
|
358
|
-
|
359
|
-
|
360
|
-
|
361
|
-
@view_timeouts.delete(text_view.object_id)
|
362
|
-
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
|
363
490
|
else
|
364
|
-
|
365
|
-
update_log_view(text_view, buffer)
|
366
|
-
rescue StandardError
|
367
|
-
nil
|
368
|
-
end
|
369
|
-
true # Keep the timeout active
|
491
|
+
adj.value = old_value
|
370
492
|
end
|
371
493
|
end
|
494
|
+
end
|
372
495
|
|
373
|
-
|
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?
|
374
520
|
|
375
|
-
|
376
|
-
|
377
|
-
|
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
|
378
526
|
begin
|
379
|
-
|
380
|
-
|
381
|
-
|
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]
|
382
539
|
end
|
383
540
|
end
|
384
541
|
end
|
385
542
|
|
386
|
-
|
387
|
-
|
388
|
-
|
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
|
389
548
|
|
390
|
-
|
391
|
-
|
392
|
-
|
393
|
-
|
394
|
-
|
395
|
-
|
396
|
-
|
397
|
-
|
398
|
-
|
399
|
-
"37" => "#ffffff" # White
|
400
|
-
}.each do |code, color|
|
401
|
-
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
|
+
)
|
402
558
|
end
|
403
559
|
|
404
|
-
|
405
|
-
|
406
|
-
|
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
|
407
565
|
|
408
|
-
|
409
|
-
|
410
|
-
|
411
|
-
|
412
|
-
|
413
|
-
|
414
|
-
|
415
|
-
|
416
|
-
|
417
|
-
|
418
|
-
|
419
|
-
|
420
|
-
|
421
|
-
|
422
|
-
|
423
|
-
|
424
|
-
|
425
|
-
|
426
|
-
|
427
|
-
|
428
|
-
|
429
|
-
|
430
|
-
end
|
431
|
-
end
|
432
|
-
text_view.buffer.insert(iter, text)
|
433
|
-
elsif clear_line
|
434
|
-
text_view.buffer.insert(iter, clear_line)
|
435
|
-
else
|
436
|
-
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
|
437
588
|
end
|
438
589
|
end
|
590
|
+
|
591
|
+
{
|
592
|
+
pid: wait_thr.pid,
|
593
|
+
stdin: stdin,
|
594
|
+
stdout: stdout,
|
595
|
+
stderr: stderr,
|
596
|
+
thread: wait_thr
|
597
|
+
}
|
439
598
|
end
|
440
599
|
|
441
|
-
|
442
|
-
|
443
|
-
|
444
|
-
|
445
|
-
|
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
|
446
613
|
end
|
614
|
+
false
|
447
615
|
end
|
448
|
-
end
|
449
616
|
|
450
|
-
|
451
|
-
|
452
|
-
|
453
|
-
|
454
|
-
|
455
|
-
|
456
|
-
|
457
|
-
|
458
|
-
|
459
|
-
|
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
|
460
673
|
end
|
461
|
-
|
462
|
-
|
463
|
-
status.text = running ? "●" : "○"
|
464
|
-
box.pack_start(label, expand: false, fill: false, padding: 0)
|
465
|
-
box.pack_start(status, expand: false, fill: false, padding: 0)
|
466
|
-
box.show_all
|
467
|
-
box
|
468
|
-
end
|
674
|
+
else
|
675
|
+
return if self.class.running?
|
469
676
|
|
470
|
-
|
471
|
-
return unless @notebook
|
472
|
-
@ember_label.children[1].text = @ember_running ? "●" : "○"
|
473
|
-
@ember_label.children[1].override_color(
|
474
|
-
:normal,
|
475
|
-
Gdk::RGBA.new(
|
476
|
-
@ember_running ? 0.2 : 0.8,
|
477
|
-
@ember_running ? 0.8 : 0.2,
|
478
|
-
0.2,
|
479
|
-
1
|
480
|
-
)
|
481
|
-
)
|
482
|
-
|
483
|
-
@unicorn_label.children[1].text = @unicorn_running ? "●" : "○"
|
484
|
-
@unicorn_label.children[1].override_color(
|
485
|
-
:normal,
|
486
|
-
Gdk::RGBA.new(
|
487
|
-
@unicorn_running ? 0.2 : 0.8,
|
488
|
-
@unicorn_running ? 0.8 : 0.2,
|
489
|
-
0.2,
|
490
|
-
1
|
491
|
-
)
|
492
|
-
)
|
493
|
-
end
|
677
|
+
system("mkfifo #{PIPE_PATH}") unless File.exist?(PIPE_PATH)
|
494
678
|
|
495
|
-
|
496
|
-
|
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)
|
497
682
|
|
498
|
-
|
499
|
-
|
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
|
500
692
|
|
501
|
-
|
502
|
-
|
503
|
-
window_geometry: {
|
504
|
-
"x" => x,
|
505
|
-
"y" => y,
|
506
|
-
"width" => width,
|
507
|
-
"height" => height
|
508
|
-
}
|
509
|
-
)
|
510
|
-
end
|
693
|
+
# Initialize GTK
|
694
|
+
Gtk.init
|
511
695
|
|
512
|
-
|
513
|
-
|
514
|
-
icon_path = File.join(File.dirname(__FILE__), "../../assets", icon_file)
|
515
|
-
@indicator.pixbuf = GdkPixbuf::Pixbuf.new(file: icon_path)
|
516
|
-
end
|
696
|
+
# Setup systray icon and menu
|
697
|
+
init_systray
|
517
698
|
|
518
|
-
|
519
|
-
|
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
|
520
717
|
end
|
521
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
|