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
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,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
|