discourse-systray 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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: []