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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a6b1d170c35f358a45bf58c160ce9f152df7eeeb34b94e493fd7a34ab2adbbc9
4
+ data.tar.gz: bfe651149441d068ee3b4aee2eee117e5ab060efbb18700a441daca3e466d046
5
+ SHA512:
6
+ metadata.gz: 3a2ce70b82026b74d7aed41899b35fdd3f78af7b61f88d094bc8d428f4423b73da96e1d244f71e50ef1a7df58c583698f293af3098ec3082f6facbdb8a832b35
7
+ data.tar.gz: 47dbe0916121d40a47734ebfe0a7b0aff18cf63046d32f6e0708867b5474044f89bf5c06834db474fe208fc9fea71d1976c94e96325112886ffd9d84f9b441be
data/bin/bump ADDED
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Bumps the minor version, like 1.0.5 to 1.1.0, or with --patch the patch version, like 1.0.5 to 1.0.6, and names the
5
+ # changelog's Unreleased changes after it, then runs bundle update, so Gemfile.lock has it too, and commits the version,
6
+ # the changelog and Gemfile.lock (when git tracks it) as "Release v1.1.0", leaving any other changes out of it.
7
+ # bin/bump
8
+ # bin/bump --patch
9
+ # bin/bump -p
10
+
11
+ Dir.chdir(File.expand_path('..', __dir__))
12
+ path = 'lib/version.rb'
13
+ source = File.read(path)
14
+ version = source[/VERSION = '(\d+\.\d+\.\d+)'/, 1] or abort("There's no VERSION like '1.2.3' in #{path}")
15
+
16
+ major, minor, patch = version.split('.').map(&:to_i)
17
+ bumped = "#{major}.#{minor + 1}.0"
18
+ bumped = "#{major}.#{minor}.#{patch + 1}" if ARGV.intersect?(%w[--patch -p])
19
+ File.write(path, source.sub("VERSION = '#{version}'", "VERSION = '#{bumped}'"))
20
+ puts "Bumped #{version} to #{bumped}"
21
+
22
+ changelog = File.read('CHANGELOG.md')
23
+ released = changelog.sub(/^## Unreleased$/, "## #{bumped}")
24
+ File.write('CHANGELOG.md', released)
25
+ files = [path]
26
+ files << 'Gemfile.lock' unless `git ls-files Gemfile.lock`.empty?
27
+ files << 'CHANGELOG.md' unless released == changelog
28
+
29
+ system('bundle', 'update', '--all', exception: true)
30
+ system('git', 'commit', '-m', "Release v#{bumped}", '--', *files, exception: true)
data/bin/raintron ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'cli/cli'
5
+
6
+ Raintron::CLI.run(ARGV)
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module Raintron
6
+ # Reads a consuming app's own config/config.yaml so bin/desktop and `raintron press`
7
+ # stay in sync with whatever `rain server` actually uses, instead of hardcoding
8
+ # host/port separately from the app's real configuration.
9
+ module AppConfig
10
+ DEFAULT_HOST = '127.0.0.1'
11
+ DEFAULT_PORT = 4133
12
+
13
+ def self.host(app_root: Dir.pwd)
14
+ read(app_root).fetch('host', DEFAULT_HOST)
15
+ end
16
+
17
+ def self.port(app_root: Dir.pwd)
18
+ read(app_root).fetch('port', DEFAULT_PORT)
19
+ end
20
+
21
+ # "hidden" for a window without a title bar, on macOS, which the page takes the place of; see WindowChrome.
22
+ def self.titlebar(app_root: Dir.pwd)
23
+ read(app_root).fetch('titlebar', 'native')
24
+ end
25
+
26
+ def self.read(app_root)
27
+ path = File.join(app_root, 'config', 'config.yaml')
28
+ return {} unless File.exist?(path)
29
+
30
+ YAML.safe_load_file(path) || {}
31
+ end
32
+ end
33
+ end
data/lib/cli/cli.rb ADDED
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'trees'
4
+ require_relative '../raintron'
5
+ require_relative '../generator/generator'
6
+ require_relative '../commands/install'
7
+ require_relative '../commands/press'
8
+ require_relative '../commands/doctor'
9
+ require_relative '../commands/icons'
10
+
11
+ module Raintron
12
+ # `trees`' Trie#match requires the ENTIRE args array to be consumed by one defined
13
+ # path -- it has no concept of trailing/optional args at all (verified directly
14
+ # against ~/dev/trees/lib/trie.rb's #full_match, which fails the whole match if any
15
+ # token is left over). So trees is used only to route the bare verb (a single
16
+ # token); everything after it is handed to the command UNSPLIT, and each command
17
+ # uses OptionParser#parse (non-destructive) to separate its own flags from any
18
+ # positional argument -- OptionParser knows which flags consume a following value
19
+ # (e.g. `--width 900` is two tokens), which a naive "starts with -" split does not.
20
+ module CLI
21
+ extend Trees
22
+
23
+ class << self
24
+ attr_accessor :argv
25
+ end
26
+
27
+ line('install') do
28
+ summary { 'Wire desktop-app capability into the current Raindeer app.' }
29
+ execute { Commands::Install.run(argv: CLI.argv) }
30
+ end
31
+
32
+ line('press') do
33
+ summary { 'Pack the app into a native executable with Tebako.' }
34
+ execute { Commands::Press.run(argv: CLI.argv) }
35
+ end
36
+
37
+ line('icons') do
38
+ summary { 'Regenerate app icons (.icns/.ico/.png).' }
39
+ execute { Commands::Icons.run(argv: CLI.argv) }
40
+ end
41
+
42
+ line('doctor') do
43
+ summary { 'Check native toolkit prerequisites for webview_ruby.' }
44
+ execute { Commands::Doctor.run }
45
+ end
46
+
47
+ singleton_class.send(:alias_method, :dispatch, :run)
48
+
49
+ def self.run(argv)
50
+ return usage if argv.empty?
51
+
52
+ verb, *rest = argv
53
+ self.argv = rest
54
+
55
+ dispatch([verb]) || unknown_command(verb)
56
+ end
57
+
58
+ def self.usage
59
+ warn 'usage: raintron <install|press|icons|doctor> [options]'
60
+ exit(1)
61
+ end
62
+
63
+ def self.unknown_command(verb)
64
+ warn "raintron: unknown command #{verb.inspect}"
65
+ warn 'Available commands: install, press, icons, doctor'
66
+ exit(1)
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'stringio'
4
+ require_relative '../preflight/preflight'
5
+
6
+ module Raintron
7
+ module Commands
8
+ # Runs the native-toolkit preflight check on demand, with explicit pass/fail
9
+ # framing (Preflight.check! itself only warns on failure and is silent on
10
+ # success, since it's designed to run quietly at Gemfile-eval time).
11
+ class Doctor
12
+ def self.run
13
+ new.run
14
+ end
15
+
16
+ def run
17
+ warnings = capture_warnings { Preflight.check! }
18
+
19
+ if warnings.empty?
20
+ puts 'raintron doctor: OK -- native toolkit prerequisites for webview_ruby look good.'
21
+ else
22
+ warnings.each { |warning| warn warning }
23
+ exit(1)
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def capture_warnings
30
+ original = $stderr
31
+ $stderr = StringIO.new
32
+ yield
33
+ $stderr.string.split("\n\n").reject(&:empty?)
34
+ ensure
35
+ $stderr = original
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+
5
+ module Raintron
6
+ module Commands
7
+ # Regenerates icons standalone (without the rest of `raintron install`), e.g.
8
+ # after swapping in a new source logo.
9
+ class Icons
10
+ def self.run(argv: [])
11
+ new.run(argv:)
12
+ end
13
+
14
+ def run(argv:)
15
+ options, positional = parse(argv)
16
+ name = positional.first || options[:name] || File.basename(Dir.pwd)
17
+
18
+ paths = IconGenerator.generate(output_dir: 'icons', basename: name, source: options[:source])
19
+ puts "Wrote #{paths.values.join(', ')}"
20
+ end
21
+
22
+ private
23
+
24
+ def parse(argv)
25
+ options = {}
26
+ positional = OptionParser.new do |parser|
27
+ parser.on('--name NAME') { |value| options[:name] = value }
28
+ parser.on('--source PATH') { |value| options[:source] = value }
29
+ end.parse(argv)
30
+ [options, positional]
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+ require 'fileutils'
5
+
6
+ module Raintron
7
+ module Commands
8
+ # Wires desktop-app capability into the raindeer app in the current directory:
9
+ # bin/desktop, a macOS .app bundle, icons, and a Tebako packing CI workflow.
10
+ # Idempotent -- re-running skips files that already exist unless --force.
11
+ class Install
12
+ DEFAULT_WIDTH = 900
13
+ DEFAULT_HEIGHT = 600
14
+
15
+ def self.run(argv: [])
16
+ new.run(argv:)
17
+ end
18
+
19
+ def run(argv:)
20
+ options, positional = parse(argv)
21
+ app_name = resolve_app_name(options, positional)
22
+ bundle_dir = "#{camelize(app_name)}.app"
23
+ force = options[:force] || false
24
+
25
+ generator = Generator.new(force:)
26
+ render_templates(generator:, app_name:, bundle_dir:, options:)
27
+ install_icons(app_name:, bundle_dir:, source: options[:icon], force:)
28
+ insert_gemfile_guard!
29
+
30
+ puts "raintron installed. Run `bundle exec bin/desktop` to launch #{app_name}."
31
+ end
32
+
33
+ private
34
+
35
+ # OptionParser#parse (non-destructive) returns the leftover non-option
36
+ # arguments -- e.g. a bare app name -- rather than raising or discarding them,
37
+ # unlike a naive "does this token start with -" split (which mishandles any
38
+ # flag that takes a following value, like `--width 900`).
39
+ def parse(argv)
40
+ options = {}
41
+ positional = OptionParser.new do |parser|
42
+ parser.on('--name NAME') { |value| options[:name] = value }
43
+ parser.on('--width N', Integer) { |value| options[:width] = value }
44
+ parser.on('--height N', Integer) { |value| options[:height] = value }
45
+ parser.on('--bundle-id ID') { |value| options[:bundle_id] = value }
46
+ parser.on('--icon PATH') { |value| options[:icon] = value }
47
+ parser.on('--force') { options[:force] = true }
48
+ end.parse(argv)
49
+ [options, positional]
50
+ end
51
+
52
+ def resolve_app_name(options, positional)
53
+ name = positional.first || options[:name] || default_name
54
+ return name unless positional.empty? && options[:name].nil? && $stdin.tty?
55
+
56
+ prompt_for_name(name)
57
+ end
58
+
59
+ def render_templates(generator:, app_name:, bundle_dir:, options:)
60
+ bundle_id = options[:bundle_id] || "dev.local.#{app_name}"
61
+ width = options[:width] || DEFAULT_WIDTH
62
+ height = options[:height] || DEFAULT_HEIGHT
63
+
64
+ generator.render('bin/desktop.erb', 'bin/desktop', app_name:, width:, height:)
65
+ generator.render('app/Contents/Info.plist.erb', "#{bundle_dir}/Contents/Info.plist", app_name:, bundle_id:)
66
+ generator.render('app/Contents/MacOS/launch.erb', "#{bundle_dir}/Contents/MacOS/launch", {})
67
+ generator.render('icons/app.desktop.erb', "icons/#{app_name}.desktop", app_name:)
68
+ generator.render('github/workflows/build.yml.erb', '.github/workflows/build.yml',
69
+ app_name:, ruby_version: detect_ruby_version)
70
+ end
71
+
72
+ def default_name
73
+ File.basename(Dir.pwd)
74
+ end
75
+
76
+ def prompt_for_name(default)
77
+ print "App name [#{default}]: "
78
+ input = $stdin.gets&.strip
79
+ input.nil? || input.empty? ? default : input
80
+ end
81
+
82
+ def camelize(name)
83
+ name.split(/[-_]/).map { |part| part[0].upcase + part[1..].to_s }.join
84
+ end
85
+
86
+ def detect_ruby_version
87
+ File.read('.ruby-version').strip
88
+ rescue Errno::ENOENT
89
+ RUBY_VERSION
90
+ end
91
+
92
+ def install_icons(app_name:, bundle_dir:, source:, force:)
93
+ icons_dir = File.join(Dir.pwd, 'icons')
94
+ icns_path = File.join(icons_dir, "#{app_name}.icns")
95
+
96
+ if File.exist?(icns_path) && !force
97
+ warn "skip icons/#{app_name}.icns (already exists; pass --force to overwrite)"
98
+ else
99
+ IconGenerator.generate(output_dir: icons_dir, basename: app_name, source:)
100
+ end
101
+
102
+ resources_dir = "#{bundle_dir}/Contents/Resources"
103
+ FileUtils.mkdir_p(resources_dir)
104
+ FileUtils.cp(icns_path, File.join(resources_dir, 'AppIcon.icns'))
105
+ end
106
+
107
+ # Inserts a self-contained toolkit check into the consuming app's Gemfile.
108
+ #
109
+ # This can NOT be a `require 'preflight/preflight'` delegating to Raintron's own
110
+ # Preflight module, however appealing that would be for keeping the check in one
111
+ # place -- verified directly by running this end-to-end: Bundler evaluates the
112
+ # Gemfile DSL top-to-bottom to build the dependency list BEFORE it resolves and
113
+ # activates any gem (including one declared later in the very same Gemfile), so
114
+ # a `require` of another Gemfile-declared gem's code fails every single time,
115
+ # not just occasionally. The logic is duplicated here as inline text instead;
116
+ # `raintron doctor` and this snippet can drift, but a working duplicate beats a
117
+ # DRY snippet that breaks every subsequent `bundle` command.
118
+ #
119
+ # Inserted AFTER any leading comment block (magic comments like
120
+ # `# frozen_string_literal: true` only take effect before the first real line
121
+ # of code -- verified directly: prepending code above one silently disables it
122
+ # for the rest of the file), never before it.
123
+ def insert_gemfile_guard!
124
+ path = 'Gemfile'
125
+ return unless File.exist?(path)
126
+
127
+ guard = <<~RUBY
128
+ case RbConfig::CONFIG['host_os']
129
+ when /darwin/
130
+ unless system('xcrun --find clang++', out: File::NULL, err: File::NULL)
131
+ warn 'warning: webview_ruby needs the Xcode Command Line Tools to compile its native ' \\
132
+ "extension. Install them with: xcode-select --install"
133
+ end
134
+ when /linux/
135
+ unless system('pkg-config --exists gtk+-3.0 webkit2gtk-4.0', out: File::NULL, err: File::NULL)
136
+ warn 'warning: webview_ruby needs GTK3 + WebKit2GTK development headers to compile its ' \\
137
+ 'native extension, e.g.: sudo apt-get install -y build-essential libgtk-3-dev libwebkit2gtk-4.0-dev'
138
+ end
139
+ end
140
+
141
+ RUBY
142
+ content = File.read(path)
143
+ return if content.include?(guard)
144
+
145
+ lines = content.lines
146
+ insert_at = lines.index { |line| !line.start_with?('#') && !line.strip.empty? } || lines.size
147
+ new_content = (lines[0...insert_at] + [guard] + lines[insert_at..]).join
148
+
149
+ File.write(path, new_content)
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+ require 'fileutils'
5
+
6
+ module Raintron
7
+ module Commands
8
+ # Wraps `tebako press` with sensible defaults, so consumers don't need to
9
+ # memorize its flags. Validated this session against a real pressed build that
10
+ # boots and serves HTTP 200.
11
+ class Press
12
+ DEFAULT_OUTPUT_DIR = 'dist'
13
+
14
+ def self.run(argv: [])
15
+ new.run(argv:)
16
+ end
17
+
18
+ def run(argv:)
19
+ options = parse(argv)
20
+
21
+ abort_unless_tebako_available!
22
+ abort_unless_entry_point_exists!
23
+
24
+ ruby_version = options[:ruby] || detect_ruby_version
25
+ output = options[:output] || default_output
26
+
27
+ FileUtils.mkdir_p(File.dirname(output))
28
+
29
+ command = [
30
+ 'tebako', 'press',
31
+ '-r', '.',
32
+ '-e', 'bin/desktop',
33
+ '-o', output,
34
+ '-R', ruby_version,
35
+ '--format', 'dwarfs'
36
+ ]
37
+
38
+ warn_if_windows
39
+
40
+ puts "$ #{command.join(' ')}"
41
+ exit(1) unless system(*command)
42
+ end
43
+
44
+ private
45
+
46
+ def parse(argv)
47
+ options = {}
48
+ OptionParser.new do |parser|
49
+ parser.on('-R VERSION', '--ruby VERSION') { |value| options[:ruby] = value }
50
+ parser.on('-o PATH', '--output PATH') { |value| options[:output] = value }
51
+ end.parse(argv)
52
+ options
53
+ end
54
+
55
+ def abort_unless_tebako_available!
56
+ return if system('which tebako', out: File::NULL, err: File::NULL)
57
+
58
+ warn 'tebako is not on PATH. Install it first: https://tebako.org/install.sh'
59
+ exit(1)
60
+ end
61
+
62
+ def abort_unless_entry_point_exists!
63
+ return if File.exist?('bin/desktop')
64
+
65
+ warn 'bin/desktop not found. Run `raintron install` first.'
66
+ exit(1)
67
+ end
68
+
69
+ def detect_ruby_version
70
+ return File.read('.ruby-version').strip if File.exist?('.ruby-version')
71
+ return tool_versions_ruby if File.exist?('.tool-versions')
72
+
73
+ RUBY_VERSION
74
+ rescue Errno::ENOENT
75
+ RUBY_VERSION
76
+ end
77
+
78
+ def tool_versions_ruby
79
+ line = File.readlines('.tool-versions').find { |l| l.start_with?('ruby ') }
80
+ line ? line.split[1] : RUBY_VERSION
81
+ end
82
+
83
+ def default_output
84
+ app_name = File.basename(Dir.pwd)
85
+ os = case RbConfig::CONFIG['host_os']
86
+ when /darwin/ then 'macos'
87
+ when /linux/ then 'linux'
88
+ when /mswin|mingw|cygwin/ then 'windows'
89
+ else RbConfig::CONFIG['host_os']
90
+ end
91
+ ext = os == 'windows' ? '.exe' : ''
92
+ File.join(DEFAULT_OUTPUT_DIR, "#{app_name}-#{os}#{ext}")
93
+ end
94
+
95
+ def warn_if_windows
96
+ return unless RbConfig::CONFIG['host_os'] =~ /mswin|mingw|cygwin/
97
+
98
+ warn 'warning: Tebako\'s Windows release leg is still "in flight" upstream, and ' \
99
+ "webview_ruby's own native-extension build fails under Windows' MinGW toolchain " \
100
+ '(its Edge backend needs C++/WinRT headers only MSVC + the Windows SDK provide). ' \
101
+ 'This press is not expected to succeed yet.'
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'erb'
4
+ require 'fileutils'
5
+
6
+ module Raintron
7
+ # Renders raintron's ERB templates into a consuming app's directory. Skips files
8
+ # that already exist (so re-running `raintron install` is safe) unless force: true.
9
+ class Generator
10
+ TEMPLATES_ROOT = File.expand_path('../../templates', __dir__)
11
+
12
+ def initialize(destination_root: Dir.pwd, force: false, templates_root: TEMPLATES_ROOT)
13
+ @destination_root = destination_root
14
+ @force = force
15
+ @templates_root = templates_root
16
+ end
17
+
18
+ # template_path: path under templates/, WITH the .erb suffix (e.g. 'bin/desktop.erb').
19
+ # destination_path: path under destination_root, WITHOUT .erb (e.g. 'bin/desktop').
20
+ # Returns the written path, or nil if skipped.
21
+ def render(template_path, destination_path, vars = {})
22
+ source = File.join(@templates_root, template_path)
23
+ dest = File.join(@destination_root, destination_path)
24
+
25
+ if File.exist?(dest) && !@force
26
+ warn "skip #{destination_path} (already exists; pass --force to overwrite)"
27
+ return nil
28
+ end
29
+
30
+ content = ERB.new(File.read(source), trim_mode: '-').result_with_hash(vars)
31
+
32
+ FileUtils.mkdir_p(File.dirname(dest))
33
+ File.write(dest, content)
34
+ File.chmod(0o755, dest) if File.executable?(source)
35
+
36
+ dest
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raintron
4
+ # Apple .icns container -- replaces shelling out to `iconutil`. Format: magic "icns"
5
+ # + big-endian u32 total length, then chunks of (4-byte OSType + big-endian u32
6
+ # chunk length INCLUDING this 8-byte header + a raw PNG payload). Modern macOS
7
+ # accepts PNG payloads in these slots (cited: Wikipedia's Apple Icon Image Format
8
+ # page, hexbase.dev, mdsteele/rust-icns, Pillow's IcnsImagePlugin -- there's no
9
+ # official public Apple spec).
10
+ #
11
+ # This exact type/size table replicates `iconutil -c icns`'s own .iconset mapping.
12
+ # An earlier draft omitted the icp4/icp5 @1x slots (16px/32px); a real regression
13
+ # (electron-builder#9940) shows that omission renders broken/scrambled icons in
14
+ # Finder list view and Trash at small sizes, so they're included here deliberately.
15
+ module IcnsWriter
16
+ SIZE_MAP = [
17
+ ['icp4', 16], # 16@1x
18
+ ['ic11', 32], # 16@2x
19
+ ['icp5', 32], # 32@1x
20
+ ['ic12', 64], # 32@2x
21
+ ['ic07', 128], # 128@1x
22
+ ['ic13', 256], # 128@2x
23
+ ['ic08', 256], # 256@1x
24
+ ['ic14', 512], # 256@2x
25
+ ['ic09', 512], # 512@1x
26
+ ['ic10', 1024] # 512@2x
27
+ ].freeze
28
+
29
+ # pngs_by_size: { pixel_size => png_bytes }
30
+ def self.encode(pngs_by_size:)
31
+ body = String.new(encoding: Encoding::BINARY)
32
+ SIZE_MAP.each do |type, size|
33
+ png = pngs_by_size.fetch(size)
34
+ body << type.b
35
+ body << [8 + png.bytesize].pack('N')
36
+ body << png
37
+ end
38
+
39
+ 'icns'.b + [8 + body.bytesize].pack('N') + body
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raintron
4
+ # Windows .ico container. Vista+ PNG-compressed ICONDIRENTRYs -- valid since Windows
5
+ # Vista, verified this session by building one and confirming `file` reports a
6
+ # well-formed multi-size icon resource.
7
+ module IcoWriter
8
+ DEFAULT_SIZES = [16, 24, 32, 48, 64, 128, 256].freeze
9
+
10
+ # entries: [{ size: Integer, png: String }, ...]
11
+ def self.encode(entries:)
12
+ header = [0, 1, entries.size].pack('v3')
13
+ offset = header.bytesize + (16 * entries.size)
14
+ directory = String.new(encoding: Encoding::BINARY)
15
+ data = String.new(encoding: Encoding::BINARY)
16
+
17
+ entries.each do |entry|
18
+ size = entry.fetch(:size)
19
+ png = entry.fetch(:png)
20
+ wh = size < 256 ? size : 0 # 0 means 256, per the ICO spec
21
+ directory << [wh, wh, 0, 0, 1, 32, png.bytesize, offset].pack('C4v2V2')
22
+ data << png
23
+ offset += png.bytesize
24
+ end
25
+
26
+ header + directory + data
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require_relative 'png_writer'
5
+ require_relative 'png_decoder'
6
+ require_relative 'resampler'
7
+ require_relative 'renderer'
8
+ require_relative 'ico_writer'
9
+ require_relative 'icns_writer'
10
+
11
+ module Raintron
12
+ # Orchestrates the icon writers: renders (or decodes+resizes) every distinct pixel
13
+ # size any format needs exactly once, then hands the result to each format's writer.
14
+ class IconGenerator
15
+ # The union of every size IcnsWriter, IcoWriter, and the single Linux PNG need.
16
+ ALL_SIZES = [16, 24, 32, 48, 64, 128, 256, 512, 1024].freeze
17
+ LINUX_PNG_SIZE = 512
18
+
19
+ def self.generate(output_dir:, basename: 'icon', source: nil, **render_opts)
20
+ new(source:, **render_opts).write_all(dir: output_dir, basename:)
21
+ end
22
+
23
+ def initialize(source: nil, background: [0, 152, 252, 255], foreground: [255, 255, 255, 255], shape: :circle)
24
+ @source = source
25
+ @background = background
26
+ @foreground = foreground
27
+ @shape = shape
28
+ end
29
+
30
+ def write_all(dir:, basename: 'icon')
31
+ FileUtils.mkdir_p(dir)
32
+ pngs = rendered_pngs
33
+
34
+ paths = {}
35
+
36
+ paths[:icns] = File.join(dir, "#{basename}.icns")
37
+ File.binwrite(paths[:icns], IcnsWriter.encode(pngs_by_size: pngs))
38
+
39
+ paths[:ico] = File.join(dir, "#{basename}.ico")
40
+ entries = IcoWriter::DEFAULT_SIZES.map { |size| { size:, png: pngs.fetch(size) } }
41
+ File.binwrite(paths[:ico], IcoWriter.encode(entries:))
42
+
43
+ paths[:png] = File.join(dir, "#{basename}.png")
44
+ File.binwrite(paths[:png], pngs.fetch(LINUX_PNG_SIZE))
45
+
46
+ paths
47
+ end
48
+
49
+ private
50
+
51
+ def rendered_pngs
52
+ @rendered_pngs ||= ALL_SIZES.to_h { |size| [size, PngWriter.encode(width: size, height: size, rgba: rgba_for(size))] }
53
+ end
54
+
55
+ def rgba_for(size)
56
+ return Renderer.render(size:, background: @background, foreground: @foreground, shape: @shape) unless @source
57
+
58
+ Resampler.resize(rgba: decoded_source[:rgba], src_w: decoded_source[:width], src_h: decoded_source[:height],
59
+ dst_w: size, dst_h: size)
60
+ end
61
+
62
+ def decoded_source
63
+ @decoded_source ||= PngDecoder.decode(File.binread(@source))
64
+ end
65
+ end
66
+ end