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.
@@ -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