discourse-systray 0.1.0

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 342570ecbf8771500b6be8dd6deba05ceb429b4465746c3fa2fc822dedbff311
4
+ data.tar.gz: c6a5dbc3ab88a256c2d9f5a9bda9def61ce26ec46a32280e4a81c144335f1017
5
+ SHA512:
6
+ metadata.gz: 183b3a72d39b1f6525d7a2ce533829349709192f8da6ede5d53ae231441a3064e37e04eaa94050ed3e63327784184c9b004049521c7a79fc6f2db688618aec96
7
+ data.tar.gz: 5c803012c9fdcaeea04465e8803e66f3531cec3c3487616256e24ede3b28d4e2a0e3a3e59c583fdb9a1a1618d411de98297b43b77ed7abd2c664e2a4a2e648de
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Your Name
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,56 @@
1
+ # Discourse Systray
2
+
3
+ A system tray application for managing local Discourse development instances.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ gem install discourse-systray
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ Simply run:
14
+
15
+ ```bash
16
+ discourse-systray
17
+ ```
18
+
19
+ Optional flags:
20
+ - `--debug`: Enable debug mode
21
+ - `--path PATH`: Set Discourse path
22
+
23
+ ### Features
24
+
25
+ - Start/Stop Discourse development environment from system tray
26
+ - Monitor Ember CLI and Unicorn server status
27
+ - View real-time logs with ANSI color support
28
+ - Status indicators for running services
29
+ - Clean process management and graceful shutdown
30
+
31
+ ### System Tray Menu
32
+
33
+ - **Start Discourse**: Launches both Ember CLI and Unicorn server
34
+ - **Stop Discourse**: Gracefully stops all running processes
35
+ - **Show Status**: Opens log viewer window
36
+ - **Quit**: Exits application and cleans up processes
37
+
38
+ ### Requirements
39
+
40
+ - Ruby >= 2.6.0
41
+ - GTK3
42
+ - Discourse development environment
43
+
44
+ ## Development
45
+
46
+ After checking out the repo, run `bundle install` to install dependencies.
47
+
48
+ To install this gem onto your local machine, run:
49
+ ```bash
50
+ gem build discourse-systray.gemspec
51
+ gem install ./discourse-systray-0.1.0.gem
52
+ ```
53
+
54
+ ## License
55
+
56
+ The gem is available as open source under the terms of the MIT License.
data/assets/circle.png ADDED
Binary file
Binary file
Binary file
data/assets/play.png ADDED
Binary file
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "discourse_systray/systray"
5
+
6
+ DiscourseSystemTray.new.run
@@ -0,0 +1,507 @@
1
+ require "gtk3"
2
+ require "open3"
3
+ require "optparse"
4
+ require "timeout"
5
+ require "fileutils"
6
+ require "json"
7
+
8
+ # Parse command line options
9
+ OPTIONS = { debug: false, path: nil }
10
+
11
+ OptionParser
12
+ .new do |opts|
13
+ opts.banner = "Usage: systray.rb [options]"
14
+ opts.on("--debug", "Enable debug mode") { OPTIONS[:debug] = true }
15
+ opts.on("--path PATH", "Set Discourse path") { |p| OPTIONS[:path] = p }
16
+ end
17
+ .parse!
18
+
19
+ class DiscourseSystemTray
20
+ CONFIG_DIR = File.expand_path("~/.config/discourse-systray")
21
+ CONFIG_FILE = File.join(CONFIG_DIR, "config.json")
22
+
23
+ def self.load_or_prompt_config
24
+ FileUtils.mkdir_p(CONFIG_DIR) unless Dir.exist?(CONFIG_DIR)
25
+
26
+ if OPTIONS[:path]
27
+ save_config(path: OPTIONS[:path])
28
+ return OPTIONS[:path]
29
+ end
30
+
31
+ if File.exist?(CONFIG_FILE)
32
+ config = JSON.parse(File.read(CONFIG_FILE))
33
+ return config["path"] if config["path"] && Dir.exist?(config["path"])
34
+ end
35
+
36
+ # Show dialog to get path
37
+ dialog =
38
+ Gtk::FileChooserDialog.new(
39
+ title: "Select Discourse Directory",
40
+ parent: nil,
41
+ action: :select_folder,
42
+ buttons: [["Cancel", :cancel], ["Select", :accept]]
43
+ )
44
+
45
+ path = nil
46
+ if dialog.run == :accept
47
+ path = dialog.filename
48
+ save_config(path: path)
49
+ else
50
+ puts "No Discourse path specified. Exiting."
51
+ exit 1
52
+ end
53
+
54
+ dialog.destroy
55
+ path
56
+ end
57
+
58
+ def self.save_config(path:, window_geometry: nil)
59
+ config = { path: path }
60
+ config[:window_geometry] = window_geometry if window_geometry
61
+ File.write(CONFIG_FILE, JSON.generate(config))
62
+ nil # Prevent return value from being printed
63
+ end
64
+
65
+ def self.load_config
66
+ return {} unless File.exist?(CONFIG_FILE)
67
+ JSON.parse(File.read(CONFIG_FILE))
68
+ rescue JSON::ParserError
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
120
+
121
+ stop_item.signal_connect("activate") do
122
+ set_icon(:stopped)
123
+ stop_discourse
124
+ @running = false
125
+ end
126
+
127
+ quit_item.signal_connect("activate") do
128
+ cleanup
129
+ Gtk.main_quit
130
+ end
131
+
132
+ status_item.signal_connect("activate") { show_status_window }
133
+
134
+ menu.show_all
135
+
136
+ # Show/hide items based on running state - AFTER show_all
137
+ start_item.visible = !@running
138
+ stop_item.visible = @running
139
+ menu.popup(nil, nil, button, time)
140
+ end
141
+ end
142
+
143
+ def start_discourse
144
+ @ember_output.clear
145
+ @unicorn_output.clear
146
+
147
+ Dir.chdir(@discourse_path) do
148
+ @processes[:ember] = start_process("bin/ember-cli")
149
+ @ember_running = true
150
+ @processes[:unicorn] = start_process("bin/unicorn")
151
+ @unicorn_running = true
152
+ update_tab_labels if @notebook
153
+ end
154
+ end
155
+
156
+ def stop_discourse
157
+ cleanup
158
+ end
159
+
160
+ def cleanup
161
+ return if @processes.empty?
162
+
163
+ @processes.each do |name, process|
164
+ begin
165
+ Process.kill("TERM", process[:pid])
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]
170
+ end
171
+ end
172
+ @processes.clear
173
+ @ember_running = false
174
+ @unicorn_running = false
175
+ update_tab_labels if @notebook
176
+
177
+ # Clean up window and timeouts on exit
178
+ if @status_window
179
+ # Clean up any existing timeouts
180
+ if @view_timeouts
181
+ @view_timeouts.values.each do |id|
182
+ begin
183
+ GLib::Source.remove(id)
184
+ rescue StandardError
185
+ nil
186
+ end
187
+ 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
+
198
+ # Create a monitor thread that will detect if process dies
199
+ monitor_thread =
200
+ Thread.new do
201
+ wait_thr.value # Wait for process to finish
202
+ is_ember = command.include?("ember-cli")
203
+ @ember_running = false if is_ember
204
+ @unicorn_running = false unless is_ember
205
+ GLib::Idle.add do
206
+ update_tab_labels if @notebook
207
+ false
208
+ end
209
+ end
210
+
211
+ # Monitor stdout
212
+ Thread.new do
213
+ while line = stdout.gets
214
+ buffer = command.include?("ember-cli") ? @ember_output : @unicorn_output
215
+ puts "[OUT] #{line}" if OPTIONS[:debug]
216
+ buffer << line
217
+ buffer.shift if buffer.size > BUFFER_SIZE
218
+ # Force GUI update
219
+ GLib::Idle.add do
220
+ update_all_views
221
+ false
222
+ end
223
+ end
224
+ end
225
+
226
+ # Monitor stderr
227
+ Thread.new do
228
+ while line = stderr.gets
229
+ buffer = command.include?("ember-cli") ? @ember_output : @unicorn_output
230
+ puts "[ERR] #{line}" if OPTIONS[:debug]
231
+ buffer << line
232
+ buffer.shift if buffer.size > BUFFER_SIZE
233
+ # Force GUI update
234
+ GLib::Idle.add do
235
+ update_all_views
236
+ false
237
+ end
238
+ end
239
+ end
240
+
241
+ {
242
+ pid: wait_thr.pid,
243
+ stdin: stdin,
244
+ stdout: stdout,
245
+ stderr: stderr,
246
+ thread: wait_thr,
247
+ monitor: monitor_thread
248
+ }
249
+ end
250
+
251
+ def show_status_window
252
+ if @status_window&.visible?
253
+ @status_window.present
254
+ # Force window to current workspace in i3
255
+ if @status_window.window
256
+ @status_window.window.raise
257
+ if system("which i3-msg >/dev/null 2>&1")
258
+ # First move to current workspace, then focus
259
+ system(
260
+ "i3-msg '[id=#{@status_window.window.xid}] move workspace current'"
261
+ )
262
+ system("i3-msg '[id=#{@status_window.window.xid}] focus'")
263
+ end
264
+ end
265
+ return
266
+ end
267
+
268
+ # Clean up any existing window
269
+ if @status_window
270
+ @status_window.destroy
271
+ @status_window = nil
272
+ end
273
+
274
+ @status_window = Gtk::Window.new("Discourse Status")
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
296
+
297
+ # Save position and size when window is moved or resized
298
+ @status_window.signal_connect("configure-event") do
299
+ save_window_geometry
300
+ false
301
+ end
302
+
303
+ @notebook = Gtk::Notebook.new
304
+
305
+ @ember_view = create_log_view(@ember_output)
306
+ @ember_label = create_status_label("Ember CLI", @ember_running)
307
+ @notebook.append_page(@ember_view, @ember_label)
308
+
309
+ @unicorn_view = create_log_view(@unicorn_output)
310
+ @unicorn_label = create_status_label("Unicorn", @unicorn_running)
311
+ @notebook.append_page(@unicorn_view, @unicorn_label)
312
+
313
+ @status_window.add(@notebook)
314
+ @status_window.show_all
315
+ end
316
+
317
+ def update_all_views
318
+ return unless @ember_view && @unicorn_view
319
+
320
+ update_log_view(@ember_view.child, @ember_output)
321
+ update_log_view(@unicorn_view.child, @unicorn_output)
322
+ end
323
+
324
+ def create_log_view(buffer)
325
+ scroll = Gtk::ScrolledWindow.new
326
+ text_view = Gtk::TextView.new
327
+ text_view.editable = false
328
+ text_view.wrap_mode = :word
329
+
330
+ # Set white text on black background
331
+ text_view.override_background_color(:normal, Gdk::RGBA.new(0, 0, 0, 1))
332
+ text_view.override_color(:normal, Gdk::RGBA.new(1, 1, 1, 1))
333
+
334
+ # Create text tags for colors
335
+ _tag_table = text_view.buffer.tag_table
336
+ create_ansi_tags(text_view.buffer)
337
+
338
+ # Initial text
339
+ update_log_view(text_view, buffer)
340
+
341
+ # Store timeouts in instance variable for proper cleanup
342
+ @view_timeouts ||= {}
343
+
344
+ # Set up periodic refresh with validity check
345
+ timeout_id =
346
+ GLib::Timeout.add(1000) do
347
+ if text_view&.parent.nil? || !text_view&.parent&.visible?
348
+ @view_timeouts.delete(text_view.object_id)
349
+ false # Stop the timeout if view is destroyed
350
+ else
351
+ begin
352
+ update_log_view(text_view, buffer)
353
+ rescue StandardError
354
+ nil
355
+ end
356
+ true # Keep the timeout active
357
+ end
358
+ end
359
+
360
+ @view_timeouts[text_view.object_id] = timeout_id
361
+
362
+ # Clean up timeout when view is destroyed
363
+ text_view.signal_connect("destroy") do
364
+ if timeout_id = @view_timeouts.delete(text_view.object_id)
365
+ begin
366
+ GLib::Source.remove(timeout_id)
367
+ rescue StandardError
368
+ nil
369
+ end
370
+ end
371
+ end
372
+
373
+ scroll.add(text_view)
374
+ scroll
375
+ end
376
+
377
+ def create_ansi_tags(buffer)
378
+ # Basic ANSI colors
379
+ {
380
+ "31" => "#ff6b6b", # Brighter red
381
+ "32" => "#87ff87", # Brighter green
382
+ "33" => "#ffff87", # Brighter yellow
383
+ "34" => "#87d7ff", # Brighter blue
384
+ "35" => "#ff87ff", # Brighter magenta
385
+ "36" => "#87ffff", # Brighter cyan
386
+ "37" => "#ffffff" # White
387
+ }.each do |code, color|
388
+ buffer.create_tag("ansi_#{code}", foreground: color)
389
+ end
390
+
391
+ # Add more tags for bold, etc
392
+ buffer.create_tag("bold", weight: :bold)
393
+ end
394
+
395
+ def update_log_view(text_view, buffer)
396
+ return if buffer.empty? || text_view.nil? || !text_view.visible?
397
+ return unless text_view.parent&.visible?
398
+
399
+ text_view.buffer.text = ""
400
+ iter = text_view.buffer.get_iter_at(offset: 0)
401
+
402
+ buffer.each do |line|
403
+ # Parse ANSI sequences
404
+ segments = line.scan(/\e\[([0-9;]*)m([^\e]*)|\e\[K([^\e]*)|([^\e]+)/)
405
+
406
+ segments.each do |codes, text, clear_line, plain|
407
+ if codes
408
+ codes
409
+ .split(";")
410
+ .each do |code|
411
+ case code
412
+ when "1"
413
+ text_view.buffer.apply_tag("bold", iter, iter)
414
+ when "31".."37"
415
+ text_view.buffer.apply_tag("ansi_#{code}", iter, iter)
416
+ end
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 || "")
423
+ end
424
+ end
425
+ end
426
+
427
+ # Scroll to bottom if near bottom
428
+ if text_view&.parent&.vadjustment
429
+ adj = text_view.parent.vadjustment
430
+ if adj.value >= adj.upper - adj.page_size - 50
431
+ adj.value = adj.upper - adj.page_size
432
+ end
433
+ end
434
+ end
435
+
436
+ def create_status_label(text, running)
437
+ box = Gtk::Box.new(:horizontal, 5)
438
+ label = Gtk::Label.new(text)
439
+ status = Gtk::Label.new
440
+ color =
441
+ (
442
+ if running
443
+ Gdk::RGBA.new(0.2, 0.8, 0.2, 1)
444
+ else
445
+ Gdk::RGBA.new(0.8, 0.2, 0.2, 1)
446
+ end
447
+ )
448
+ status.override_color(:normal, color)
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
455
+
456
+ def update_tab_labels
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
480
+
481
+ def save_window_geometry
482
+ return unless @status_window&.visible? && @status_window.window
483
+
484
+ x, y = @status_window.position
485
+ width, height = @status_window.size
486
+
487
+ self.class.save_config(
488
+ path: @discourse_path,
489
+ window_geometry: {
490
+ "x" => x,
491
+ "y" => y,
492
+ "width" => width,
493
+ "height" => height
494
+ }
495
+ )
496
+ end
497
+
498
+ def set_icon(status)
499
+ icon_file = status == :running ? "discourse_running.png" : "discourse.png"
500
+ icon_path = File.join(File.dirname(__FILE__), "../../assets", icon_file)
501
+ @indicator.pixbuf = GdkPixbuf::Pixbuf.new(file: icon_path)
502
+ end
503
+
504
+ def run
505
+ Gtk.main
506
+ end
507
+ end
metadata ADDED
@@ -0,0 +1,111 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: discourse-systray
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Sam Saffron
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2025-01-23 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: gtk3
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '4.2'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '4.2'
27
+ - !ruby/object:Gem::Dependency
28
+ name: bundler
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '2.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '2.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '13.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '13.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rspec
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '3.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '3.0'
69
+ description: A GTK3 system tray application that helps manage local Discourse development
70
+ instances
71
+ email:
72
+ - sam.saffron@gmail.com
73
+ executables:
74
+ - discourse-systray
75
+ extensions: []
76
+ extra_rdoc_files: []
77
+ files:
78
+ - LICENSE.txt
79
+ - README.md
80
+ - assets/circle.png
81
+ - assets/discourse.png
82
+ - assets/discourse_running.png
83
+ - assets/play.png
84
+ - bin/discourse-systray
85
+ - lib/discourse_systray/systray.rb
86
+ homepage: https://github.com/SamSaffron/discourse-systray
87
+ licenses:
88
+ - MIT
89
+ metadata:
90
+ homepage_uri: https://github.com/SamSaffron/discourse-systray
91
+ source_code_uri: https://github.com/SamSaffron/discourse-systray
92
+ post_install_message:
93
+ rdoc_options: []
94
+ require_paths:
95
+ - lib
96
+ required_ruby_version: !ruby/object:Gem::Requirement
97
+ requirements:
98
+ - - ">="
99
+ - !ruby/object:Gem::Version
100
+ version: 3.0.0
101
+ required_rubygems_version: !ruby/object:Gem::Requirement
102
+ requirements:
103
+ - - ">="
104
+ - !ruby/object:Gem::Version
105
+ version: '0'
106
+ requirements: []
107
+ rubygems_version: 3.5.22
108
+ signing_key:
109
+ specification_version: 4
110
+ summary: System tray application for managing Discourse development
111
+ test_files: []