raintron 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 +7 -0
- data/bin/bump +30 -0
- data/bin/raintron +6 -0
- data/lib/app_config/app_config.rb +33 -0
- data/lib/cli/cli.rb +69 -0
- data/lib/commands/doctor.rb +39 -0
- data/lib/commands/icons.rb +34 -0
- data/lib/commands/install.rb +153 -0
- data/lib/commands/press.rb +105 -0
- data/lib/generator/generator.rb +39 -0
- data/lib/icons/icns_writer.rb +42 -0
- data/lib/icons/ico_writer.rb +29 -0
- data/lib/icons/icon_generator.rb +66 -0
- data/lib/icons/png_decoder.rb +138 -0
- data/lib/icons/png_writer.rb +37 -0
- data/lib/icons/renderer.rb +39 -0
- data/lib/icons/resampler.rb +39 -0
- data/lib/launcher/launcher.rb +98 -0
- data/lib/launcher/loading.html +323 -0
- data/lib/preflight/preflight.rb +43 -0
- data/lib/raintron.rb +24 -0
- data/lib/shims/console_shim.rb +19 -0
- data/lib/shims/packed_fs_shim.rb +65 -0
- data/lib/shims/shims.rb +34 -0
- data/lib/version.rb +5 -0
- data/lib/window_chrome/window_chrome.rb +60 -0
- data/templates/app/Contents/Info.plist.erb +24 -0
- data/templates/app/Contents/MacOS/launch.erb +6 -0
- data/templates/bin/desktop.erb +22 -0
- data/templates/github/workflows/build.yml.erb +100 -0
- data/templates/icons/app.desktop.erb +10 -0
- metadata +118 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'zlib'
|
|
4
|
+
|
|
5
|
+
module Raintron
|
|
6
|
+
# Decodes a PNG into raw RGBA bytes, for the "bring your own logo" path. Pure Ruby,
|
|
7
|
+
# stdlib Zlib only. Scoped to what a real-world logo export needs: 8-bit RGB/RGBA,
|
|
8
|
+
# non-interlaced. Anything else (16-bit, palette, Adam7 interlacing, or a non-PNG
|
|
9
|
+
# file entirely -- including JPEG, whose decode is not feasibly zero-dependency)
|
|
10
|
+
# raises Raintron::UnsupportedSourceError with a clear message rather than silently
|
|
11
|
+
# producing a wrong image.
|
|
12
|
+
module PngDecoder
|
|
13
|
+
SIGNATURE = "\x89PNG\r\n\x1a\n".b
|
|
14
|
+
JPEG_MAGIC = "\xff\xd8\xff".b
|
|
15
|
+
|
|
16
|
+
def self.decode(bytes)
|
|
17
|
+
raise_unless_png!(bytes)
|
|
18
|
+
|
|
19
|
+
chunks = read_chunks(bytes)
|
|
20
|
+
ihdr = chunks.find { |type, _| type == 'IHDR' }
|
|
21
|
+
raise Raintron::UnsupportedSourceError, 'PNG has no IHDR chunk' unless ihdr
|
|
22
|
+
|
|
23
|
+
width, height, channels = decode_ihdr(ihdr[1])
|
|
24
|
+
|
|
25
|
+
idat = chunks.select { |type, _| type == 'IDAT' }.map { |_, data| data }.join
|
|
26
|
+
raw = Zlib::Inflate.inflate(idat)
|
|
27
|
+
|
|
28
|
+
{ width:, height:, rgba: unfilter(raw:, width:, height:, channels:) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.raise_unless_png!(bytes)
|
|
32
|
+
return if bytes.byteslice(0, 8) == SIGNATURE
|
|
33
|
+
|
|
34
|
+
if bytes.byteslice(0, 3) == JPEG_MAGIC
|
|
35
|
+
raise Raintron::UnsupportedSourceError,
|
|
36
|
+
'JPEG sources are not supported (decoding JPEG is not feasible without a real dependency). ' \
|
|
37
|
+
'Convert your logo to PNG first, then pass that.'
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
raise Raintron::UnsupportedSourceError, 'not a PNG file -- convert your logo to PNG first, then pass that.'
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.decode_ihdr(ihdr_data)
|
|
44
|
+
width, height, bit_depth, color_type, _compression, _filter, interlace = ihdr_data.unpack('N2C5')
|
|
45
|
+
|
|
46
|
+
unless bit_depth == 8 && [2, 6].include?(color_type) && interlace.zero?
|
|
47
|
+
raise Raintron::UnsupportedSourceError,
|
|
48
|
+
"unsupported PNG (bit depth #{bit_depth}, color type #{color_type}, interlace #{interlace}) -- " \
|
|
49
|
+
'only 8-bit RGB or RGBA, non-interlaced PNGs are supported. Convert your source image first ' \
|
|
50
|
+
'(e.g. re-export as a plain 8-bit PNG).'
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
[width, height, color_type == 6 ? 4 : 3]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.read_chunks(bytes)
|
|
57
|
+
chunks = []
|
|
58
|
+
offset = 8
|
|
59
|
+
while offset < bytes.bytesize
|
|
60
|
+
length = bytes.byteslice(offset, 4).unpack1('N')
|
|
61
|
+
type = bytes.byteslice(offset + 4, 4)
|
|
62
|
+
data = bytes.byteslice(offset + 8, length)
|
|
63
|
+
chunks << [type, data]
|
|
64
|
+
offset += 8 + length + 4 # + 4 for the CRC we don't verify
|
|
65
|
+
break if type == 'IEND'
|
|
66
|
+
end
|
|
67
|
+
chunks
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Reverses PNG's per-scanline filtering (None/Sub/Up/Average/Paeth -- filter types
|
|
71
|
+
# 0-4) to recover raw pixel bytes, then expands RGB to RGBA if needed so every
|
|
72
|
+
# other module in this gem can assume 4 bytes/pixel.
|
|
73
|
+
def self.unfilter(raw:, width:, height:, channels:)
|
|
74
|
+
stride = width * channels
|
|
75
|
+
prior = Array.new(stride, 0)
|
|
76
|
+
out = Array.new(height * stride)
|
|
77
|
+
pos = 0
|
|
78
|
+
|
|
79
|
+
height.times do |row|
|
|
80
|
+
filter_type = raw.getbyte(pos)
|
|
81
|
+
pos += 1
|
|
82
|
+
current = raw.byteslice(pos, stride).bytes
|
|
83
|
+
pos += stride
|
|
84
|
+
|
|
85
|
+
recon = reconstruct_row(filter_type:, current:, prior:, bpp: channels, stride:)
|
|
86
|
+
out[row * stride, stride] = recon
|
|
87
|
+
prior = recon
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
to_rgba(out, channels)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def self.reconstruct_row(filter_type:, current:, prior:, bpp:, stride:)
|
|
94
|
+
recon = Array.new(stride)
|
|
95
|
+
stride.times do |index|
|
|
96
|
+
left = index >= bpp ? recon[index - bpp] : 0
|
|
97
|
+
above = prior[index]
|
|
98
|
+
corner = index >= bpp ? prior[index - bpp] : 0
|
|
99
|
+
recon[index] = (current[index] + predictor(filter_type, left, above, corner)) & 0xff
|
|
100
|
+
end
|
|
101
|
+
recon
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def self.predictor(filter_type, left, above, corner)
|
|
105
|
+
case filter_type
|
|
106
|
+
when 0 then 0
|
|
107
|
+
when 1 then left
|
|
108
|
+
when 2 then above
|
|
109
|
+
when 3 then (left + above) / 2
|
|
110
|
+
when 4 then paeth(left, above, corner)
|
|
111
|
+
else raise Raintron::UnsupportedSourceError, "unsupported PNG filter type #{filter_type}"
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def self.paeth(left, above, corner)
|
|
116
|
+
base = left + above - corner
|
|
117
|
+
diff_left = (base - left).abs
|
|
118
|
+
diff_above = (base - above).abs
|
|
119
|
+
diff_corner = (base - corner).abs
|
|
120
|
+
|
|
121
|
+
return left if diff_left <= diff_above && diff_left <= diff_corner
|
|
122
|
+
return above if diff_above <= diff_corner
|
|
123
|
+
|
|
124
|
+
corner
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def self.to_rgba(bytes_array, channels)
|
|
128
|
+
return bytes_array.pack('C*') if channels == 4
|
|
129
|
+
|
|
130
|
+
rgba = String.new(encoding: Encoding::BINARY)
|
|
131
|
+
bytes_array.each_slice(3) { |r, g, b| rgba << [r, g, b, 255].pack('C4') }
|
|
132
|
+
rgba
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
private_class_method :raise_unless_png!, :decode_ihdr, :read_chunks, :unfilter, :reconstruct_row, :predictor,
|
|
136
|
+
:paeth, :to_rgba
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'zlib'
|
|
4
|
+
|
|
5
|
+
module Raintron
|
|
6
|
+
# Encodes raw RGBA pixel bytes as a PNG. Stdlib Zlib only -- no image library.
|
|
7
|
+
module PngWriter
|
|
8
|
+
SIGNATURE = "\x89PNG\r\n\x1a\n".b
|
|
9
|
+
|
|
10
|
+
# rgba: binary String, width*height*4 bytes, row-major top-to-bottom, R,G,B,A.
|
|
11
|
+
def self.encode(width:, height:, rgba:)
|
|
12
|
+
ihdr = [width, height, 8, 6, 0, 0, 0].pack('N2C5')
|
|
13
|
+
idat = Zlib::Deflate.deflate(filter_scanlines(width:, height:, rgba:), Zlib::BEST_COMPRESSION)
|
|
14
|
+
|
|
15
|
+
SIGNATURE + chunk('IHDR', ihdr) + chunk('IDAT', idat) + chunk('IEND', ''.b)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Filter type 0 (None) on every scanline: PNG requires a 1-byte filter-type prefix
|
|
19
|
+
# per row even when no filtering is applied.
|
|
20
|
+
def self.filter_scanlines(width:, height:, rgba:)
|
|
21
|
+
row_bytes = width * 4
|
|
22
|
+
raw = String.new(encoding: Encoding::BINARY)
|
|
23
|
+
height.times do |y|
|
|
24
|
+
raw << "\x00".b
|
|
25
|
+
raw << rgba.byteslice(y * row_bytes, row_bytes)
|
|
26
|
+
end
|
|
27
|
+
raw
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.chunk(type, data)
|
|
31
|
+
type_and_data = type.b + data.b
|
|
32
|
+
[data.bytesize].pack('N') + type_and_data + [Zlib.crc32(type_and_data)].pack('N')
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private_class_method :filter_scanlines, :chunk
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Raintron
|
|
4
|
+
# Procedural placeholder icon (no source image): a shape rendered directly at each
|
|
5
|
+
# target size, so no resizing is ever needed for this path.
|
|
6
|
+
module Renderer
|
|
7
|
+
def self.render(size:, background: [0, 152, 252, 255], foreground: [255, 255, 255, 255], shape: :circle)
|
|
8
|
+
background_bytes = background.pack('C4')
|
|
9
|
+
foreground_bytes = foreground.pack('C4')
|
|
10
|
+
|
|
11
|
+
rgba = String.new(encoding: Encoding::BINARY)
|
|
12
|
+
size.times do |row|
|
|
13
|
+
size.times do |col|
|
|
14
|
+
rgba << (inside?(shape, col, row, size) ? foreground_bytes : background_bytes)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
rgba
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.inside?(shape, col, row, size)
|
|
21
|
+
center = (size - 1) / 2.0
|
|
22
|
+
|
|
23
|
+
case shape
|
|
24
|
+
when :circle
|
|
25
|
+
radius = size * 0.32
|
|
26
|
+
dcol = col - center
|
|
27
|
+
drow = row - center
|
|
28
|
+
((dcol * dcol) + (drow * drow)) <= (radius * radius)
|
|
29
|
+
when :square
|
|
30
|
+
half = size * 0.32
|
|
31
|
+
(col - center).abs <= half && (row - center).abs <= half
|
|
32
|
+
else
|
|
33
|
+
raise ArgumentError, "unknown shape: #{shape.inspect}"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private_class_method :inside?
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Raintron
|
|
4
|
+
# Pure-arithmetic area-averaging (box) resize -- no image library. Used only for
|
|
5
|
+
# the "bring your own logo" path (the no-source placeholder path renders each size
|
|
6
|
+
# directly, so it never needs to resize anything).
|
|
7
|
+
module Resampler
|
|
8
|
+
def self.resize(rgba:, src_w:, src_h:, dst_w:, dst_h:)
|
|
9
|
+
return rgba if src_w == dst_w && src_h == dst_h
|
|
10
|
+
|
|
11
|
+
out = String.new(encoding: Encoding::BINARY)
|
|
12
|
+
dst_h.times do |dst_y|
|
|
13
|
+
y0 = (dst_y * src_h) / dst_h
|
|
14
|
+
y1 = [((dst_y + 1) * src_h) / dst_h, y0 + 1].max
|
|
15
|
+
dst_w.times do |dst_x|
|
|
16
|
+
x0 = (dst_x * src_w) / dst_w
|
|
17
|
+
x1 = [((dst_x + 1) * src_w) / dst_w, x0 + 1].max
|
|
18
|
+
out << average_box(rgba:, src_w:, x_range: x0...x1, y_range: y0...y1)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
out
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.average_box(rgba:, src_w:, x_range:, y_range:)
|
|
25
|
+
sums = [0, 0, 0, 0]
|
|
26
|
+
count = 0
|
|
27
|
+
y_range.each do |y|
|
|
28
|
+
x_range.each do |x|
|
|
29
|
+
index = ((y * src_w) + x) * 4
|
|
30
|
+
4.times { |channel| sums[channel] += rgba.getbyte(index + channel) }
|
|
31
|
+
count += 1
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
sums.map { |sum| (sum / count.to_f).round }.pack('C4')
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private_class_method :average_box
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Raintron
|
|
4
|
+
# Opens a Raindeer app in a native desktop window. The server runs as a real forked
|
|
5
|
+
# child process, not a Thread inside this same process: webview_ruby's native run
|
|
6
|
+
# loop holds Ruby's GVL for as long as the window is open, which would starve a
|
|
7
|
+
# Thread-based server; a separate OS process sidesteps that entirely. `fork` (never
|
|
8
|
+
# Process.spawn of a separate script path) is mandatory once packed by Tebako, since
|
|
9
|
+
# a resolvable script path for a second process to exec doesn't exist inside a
|
|
10
|
+
# packed image -- the whole app lives in a single executable.
|
|
11
|
+
#
|
|
12
|
+
# The window opens straight away, on Raindown's rain cloud while the server boots:
|
|
13
|
+
# loading.html, self-contained and opened as a data: URL, as nothing's serving yet,
|
|
14
|
+
# with the app's URL as its fragment. The page polls that URL itself and switches to
|
|
15
|
+
# the app once it answers -- polling can't happen here, since the window's run loop
|
|
16
|
+
# holds the GVL from the moment it opens.
|
|
17
|
+
class Launcher
|
|
18
|
+
LOADING_PAGE = File.expand_path('loading.html', __dir__)
|
|
19
|
+
PARENT_CHECK_SECONDS = 0.5
|
|
20
|
+
|
|
21
|
+
def initialize(title:, size: [900, 600], host: '127.0.0.1', port: 4133, app_root: Dir.pwd, &server_block)
|
|
22
|
+
@title = title
|
|
23
|
+
@width, @height = size
|
|
24
|
+
@host = host
|
|
25
|
+
@port = port
|
|
26
|
+
@app_root = app_root
|
|
27
|
+
@server_block = server_block
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def run
|
|
31
|
+
start_server
|
|
32
|
+
open_window
|
|
33
|
+
ensure
|
|
34
|
+
stop_server
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Public (and independently callable) so the server half can be exercised
|
|
38
|
+
# headlessly in tests, with no window and no webview_ruby involved at all.
|
|
39
|
+
def start_server
|
|
40
|
+
# Set before fork, unconditionally (not just when packed): the child inherits
|
|
41
|
+
# it via fork's memory copy, and Shims::PwdOverride uses it to answer Dir.pwd
|
|
42
|
+
# correctly regardless of what directory bin/desktop was actually launched from.
|
|
43
|
+
ENV['RAINTRON_APP_ROOT'] ||= @app_root
|
|
44
|
+
@pid = fork do
|
|
45
|
+
stop_with_parent(Process.ppid)
|
|
46
|
+
Raintron::Shims.apply!(packed: Raintron.packed?)
|
|
47
|
+
@server_block.call
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Base64, not percent-encoding, so the page's own # characters (its colours) can't
|
|
52
|
+
# be read as the start of the fragment.
|
|
53
|
+
def loading_url
|
|
54
|
+
"data:text/html;charset=utf-8;base64,#{[File.read(LOADING_PAGE)].pack('m0')}#http://#{@host}:#{@port}/"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def open_window
|
|
58
|
+
# Required only here, only in the parent, only after fork: macOS's Cocoa/WebKit
|
|
59
|
+
# frameworks (which this pulls in) are not fork-safe, so the child must never
|
|
60
|
+
# have loaded them.
|
|
61
|
+
require 'webview_ruby'
|
|
62
|
+
|
|
63
|
+
webview = WebviewRuby::Webview.new
|
|
64
|
+
webview.set_title(@title)
|
|
65
|
+
webview.set_size(@width, @height)
|
|
66
|
+
hide_titlebar(webview) if AppConfig.titlebar(app_root: @app_root) == 'hidden'
|
|
67
|
+
webview.navigate(loading_url)
|
|
68
|
+
webview.run
|
|
69
|
+
webview.destroy
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def stop_server
|
|
73
|
+
return unless @pid
|
|
74
|
+
|
|
75
|
+
Process.kill('TERM', @pid)
|
|
76
|
+
Process.wait(@pid)
|
|
77
|
+
rescue Errno::ESRCH, Errno::ECHILD
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
# The server stops itself once the window's process is gone. On macOS that process ends in [NSApp terminate:] when
|
|
84
|
+
# its window closes, which exits without returning to Ruby, so #run's ensure never stops the server; this covers
|
|
85
|
+
# that, and a crash or a kill of the window's process too.
|
|
86
|
+
def stop_with_parent(parent)
|
|
87
|
+
Thread.new do
|
|
88
|
+
sleep PARENT_CHECK_SECONDS while Process.ppid == parent
|
|
89
|
+
exit!
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def hide_titlebar(webview)
|
|
94
|
+
require_relative '../window_chrome/window_chrome'
|
|
95
|
+
WindowChrome.hide_titlebar(webview)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|