ruby_everywhere 0.1.2 → 0.1.4
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 +4 -4
- data/lib/everywhere/cli.rb +2 -0
- data/lib/everywhere/commands/build.rb +48 -24
- data/lib/everywhere/commands/icon.rb +62 -0
- data/lib/everywhere/commands/install.rb +47 -2
- data/lib/everywhere/commands/release.rb +2 -2
- data/lib/everywhere/config.rb +39 -14
- data/lib/everywhere/icon.rb +159 -0
- data/lib/everywhere/png.rb +54 -0
- data/lib/everywhere/raster.rb +178 -0
- data/lib/everywhere/receipt.rb +4 -3
- data/lib/everywhere/version.rb +1 -1
- data/support/shell/src-tauri/Cargo.lock +26 -0
- data/support/shell/src-tauri/Cargo.toml +9 -0
- data/support/shell/src-tauri/src/main.rs +74 -16
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 19d066bb2584933621929b270c69e6205804d07441a6e4a8bfcc052cc6483e55
|
|
4
|
+
data.tar.gz: c337dfa569b91412f4cb03027006d3199c9f224ebe63e5c5a066396986729194
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b0865c4cc65cfdc86706332f2d00c704fcd361ffe72ed6bbc51100297640634d246a3537b2f4ed38318fbacacbbd490ff422e4b6449905d06918ce7663dbc2f5
|
|
7
|
+
data.tar.gz: 119672c4cf55ea3ea05e768c3eba161ee2aa6b81b6f5f68046c6327bc99f2dcd9897658d7320e0a0d7a58cbc64d8ac457a2522d6cd71434f1df8ef974367a783
|
data/lib/everywhere/cli.rb
CHANGED
|
@@ -5,6 +5,7 @@ require_relative "../everywhere"
|
|
|
5
5
|
require_relative "framework"
|
|
6
6
|
require_relative "commands/build"
|
|
7
7
|
require_relative "commands/dev"
|
|
8
|
+
require_relative "commands/icon"
|
|
8
9
|
require_relative "commands/doctor"
|
|
9
10
|
require_relative "commands/install"
|
|
10
11
|
require_relative "commands/release"
|
|
@@ -33,6 +34,7 @@ module Everywhere
|
|
|
33
34
|
register "install", Commands::Install
|
|
34
35
|
register "dev", Commands::Dev
|
|
35
36
|
register "build", Commands::Build
|
|
37
|
+
register "icon", Commands::Icon
|
|
36
38
|
register "release", Commands::Release
|
|
37
39
|
|
|
38
40
|
register "platform login", Commands::PlatformLogin
|
|
@@ -6,6 +6,7 @@ require "tmpdir"
|
|
|
6
6
|
require "shellwords"
|
|
7
7
|
require_relative "../shellout"
|
|
8
8
|
require_relative "../png"
|
|
9
|
+
require_relative "../icon"
|
|
9
10
|
require_relative "../paths"
|
|
10
11
|
|
|
11
12
|
module Everywhere
|
|
@@ -28,20 +29,23 @@ module Everywhere
|
|
|
28
29
|
option :ruby, default: "4.0.6", desc: "Ruby version to package"
|
|
29
30
|
option :entry, default: "native_boot.rb", desc: "Entry point script relative to root"
|
|
30
31
|
option :app_name, default: nil, desc: "Bundle name (default: capitalized app dir)"
|
|
32
|
+
option :target, default: "macos-arm64", desc: "Build target (os-arch) — selects any platforms: overrides"
|
|
31
33
|
option :shell_dir, default: nil, desc: "Path to the shell's src-tauri directory"
|
|
32
34
|
option :skip_press, type: :boolean, default: false, desc: "Reuse the existing pressed binary, only rebuild the .app"
|
|
33
35
|
|
|
34
36
|
def call(root: ".", output: nil, ruby: "4.0.6", entry: "native_boot.rb",
|
|
35
|
-
app_name: nil, shell_dir: nil, skip_press: false, **)
|
|
37
|
+
app_name: nil, target: "macos-arm64", shell_dir: nil, skip_press: false, **)
|
|
36
38
|
root_path = File.expand_path(root)
|
|
37
39
|
config = Everywhere::Config.load(root_path)
|
|
38
|
-
|
|
40
|
+
# The os of `target` selects any per-platform overrides (bundle id,
|
|
41
|
+
# version, name) — local builds always target the host, macOS today.
|
|
42
|
+
app_name ||= config.name(target: target)
|
|
39
43
|
|
|
40
44
|
# Remote mode: thin shell around an already-deployed app. No Rails app
|
|
41
45
|
# required locally, nothing to press — just shell + config in a bundle.
|
|
42
46
|
if config.remote?
|
|
43
47
|
UI.step("#{UI.bold("remote")} mode → #{UI.cyan(config.remote_url)} #{UI.dim("(no local server packaged)")}")
|
|
44
|
-
app_dir = bundle_app(nil, app_name, shell_dir, config, dist_dir: File.expand_path("dist", root_path))
|
|
48
|
+
app_dir = bundle_app(nil, app_name, shell_dir, config, target: target, dist_dir: File.expand_path("dist", root_path))
|
|
45
49
|
return
|
|
46
50
|
end
|
|
47
51
|
|
|
@@ -63,7 +67,7 @@ module Everywhere
|
|
|
63
67
|
press!(framework, entry, ruby, output_path)
|
|
64
68
|
end
|
|
65
69
|
|
|
66
|
-
bundle_app(output_path, app_name, shell_dir, config) if RUBY_PLATFORM.include?("darwin")
|
|
70
|
+
bundle_app(output_path, app_name, shell_dir, config, target: target) if RUBY_PLATFORM.include?("darwin")
|
|
67
71
|
|
|
68
72
|
UI.success("built #{output} (#{(File.size(output_path) / 1024.0 / 1024.0).round}MB)")
|
|
69
73
|
end
|
|
@@ -111,7 +115,7 @@ module Everywhere
|
|
|
111
115
|
# Assemble dist/<App Name>.app: the release shell binary + the pressed
|
|
112
116
|
# Rails binary (as "app-server") + Info.plist + icon. An .app bundle is
|
|
113
117
|
# just a directory layout, so we build it by hand — no tauri-cli needed.
|
|
114
|
-
def bundle_app(server_binary, app_name, shell_dir, config, dist_dir: nil)
|
|
118
|
+
def bundle_app(server_binary, app_name, shell_dir, config, target: "macos-arm64", dist_dir: nil)
|
|
115
119
|
shell_dir ||= Everywhere::Paths.shell_dir!
|
|
116
120
|
dist_dir ||= File.dirname(server_binary)
|
|
117
121
|
|
|
@@ -122,15 +126,40 @@ module Everywhere
|
|
|
122
126
|
end
|
|
123
127
|
UI.step("app icon: #{icon_source ? icon_source.sub("#{config.root}/", "") : "placeholder"}")
|
|
124
128
|
|
|
129
|
+
# macOS renders icons literally — no OS-applied rounding or margins — so
|
|
130
|
+
# a full-bleed square looks wrong next to native apps. Bake the Big Sur
|
|
131
|
+
# look (inset + squircle) into a shaped 1024px master and use it for
|
|
132
|
+
# both the compiled Dock icon and the bundle .icns. work/ is torn down
|
|
133
|
+
# when the .app is assembled.
|
|
134
|
+
work = Dir.mktmpdir("everywhere-icon")
|
|
135
|
+
macos_icon = icon_source
|
|
136
|
+
if icon_source
|
|
137
|
+
shaped = File.join(work, "AppIcon-macos.png")
|
|
138
|
+
if Everywhere::Icon.macos_master(icon_source, shaped)
|
|
139
|
+
macos_icon = shaped
|
|
140
|
+
else
|
|
141
|
+
UI.warn "couldn't read #{File.basename(icon_source)} (unusual PNG flavor) — using it unshaped"
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
125
145
|
# Tauri bakes icons/icon.png into the shell binary and sets it as the
|
|
126
146
|
# Dock icon at runtime (stomping the bundle's icns after launch), so the
|
|
127
|
-
# shell must be COMPILED with the app's icon. Swap
|
|
128
|
-
# restore the placeholder after; touch build.rs so
|
|
147
|
+
# shell must be COMPILED with the app's icon. Swap in the shaped master
|
|
148
|
+
# for the build, restore the placeholder after; touch build.rs so
|
|
149
|
+
# tauri-build re-embeds.
|
|
129
150
|
shell_icon = File.join(shell_dir, "icons", "icon.png")
|
|
130
151
|
placeholder_backup = "#{shell_icon}.placeholder"
|
|
131
152
|
if icon_source
|
|
132
153
|
FileUtils.cp(shell_icon, placeholder_backup) unless File.exist?(placeholder_backup)
|
|
133
|
-
|
|
154
|
+
# The shaped master is already RGBA; fall back to raw RGBA conversion
|
|
155
|
+
# if shaping failed, and to the placeholder if even that fails.
|
|
156
|
+
dock_ok = if macos_icon == icon_source
|
|
157
|
+
Everywhere::PNG.ensure_rgba(icon_source, shell_icon)
|
|
158
|
+
else
|
|
159
|
+
FileUtils.cp(macos_icon, shell_icon)
|
|
160
|
+
true
|
|
161
|
+
end
|
|
162
|
+
unless dock_ok
|
|
134
163
|
UI.warn "couldn't convert #{File.basename(icon_source)} to RGBA (unusual PNG flavor) — Dock icon will use the placeholder after launch"
|
|
135
164
|
FileUtils.cp(placeholder_backup, shell_icon)
|
|
136
165
|
end
|
|
@@ -162,7 +191,7 @@ module Everywhere
|
|
|
162
191
|
FileUtils.cp(shell_bin, File.join(macos, exe_name))
|
|
163
192
|
FileUtils.cp(server_binary, File.join(macos, "app-server")) if server_binary
|
|
164
193
|
|
|
165
|
-
File.write(File.join(resources, "everywhere.json"), config.to_shell_json)
|
|
194
|
+
File.write(File.join(resources, "everywhere.json"), config.to_shell_json(target: target))
|
|
166
195
|
if (splash = config.splash)
|
|
167
196
|
if File.exist?(splash)
|
|
168
197
|
FileUtils.cp(splash, File.join(resources, "splash.html"))
|
|
@@ -171,9 +200,9 @@ module Everywhere
|
|
|
171
200
|
UI.warn "splash #{splash} not found — using the default splash"
|
|
172
201
|
end
|
|
173
202
|
end
|
|
174
|
-
icns = make_icns(
|
|
203
|
+
icns = make_icns(macos_icon || File.join(shell_dir, "icons", "icon.png"), resources)
|
|
175
204
|
File.write(File.join(app_dir, "Contents", "Info.plist"),
|
|
176
|
-
info_plist(app_name, exe_name, icns, config.bundle_id, config.version))
|
|
205
|
+
info_plist(app_name, exe_name, icns, config.bundle_id(target: target), config.version(target: target)))
|
|
177
206
|
|
|
178
207
|
UI.step("signing #{File.basename(app_dir)}")
|
|
179
208
|
Shellout.run!({}, "codesign", "-s", "-", "-f", File.join(macos, "app-server")) if server_binary
|
|
@@ -185,24 +214,19 @@ module Everywhere
|
|
|
185
214
|
Shellout.run?("/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister -f #{app_dir.shellescape} 2>/dev/null")
|
|
186
215
|
|
|
187
216
|
UI.success("app bundle: #{app_dir.sub("#{Dir.pwd}/", "")} #{UI.dim("(open it!)")}")
|
|
217
|
+
ensure
|
|
218
|
+
FileUtils.remove_entry(work) if work && File.directory?(work)
|
|
188
219
|
end
|
|
189
220
|
|
|
190
|
-
#
|
|
221
|
+
# Write Resources/AppIcon.icns from a (shaped) PNG. Pure Ruby — no sips or
|
|
222
|
+
# iconutil — so it produces the same icns on any build host.
|
|
191
223
|
def make_icns(png, resources_dir)
|
|
192
224
|
return nil unless File.exist?(png)
|
|
193
225
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
[16, 32, 128, 256, 512].each do |size|
|
|
199
|
-
[[size, "icon_#{size}x#{size}.png"], [size * 2, "icon_#{size}x#{size}@2x.png"]].each do |px, name|
|
|
200
|
-
Shellout.run?("sips -z #{px} #{px} #{png.shellescape} --out #{File.join(iconset, name).shellescape} >/dev/null 2>&1")
|
|
201
|
-
end
|
|
202
|
-
end
|
|
203
|
-
out = File.join(resources_dir, "AppIcon.icns")
|
|
204
|
-
return "AppIcon" if Shellout.run?("iconutil -c icns #{iconset.shellescape} -o #{out.shellescape} >/dev/null 2>&1")
|
|
205
|
-
end
|
|
226
|
+
out = File.join(resources_dir, "AppIcon.icns")
|
|
227
|
+
Everywhere::Icon.write_icns(png, out) ? "AppIcon" : nil
|
|
228
|
+
rescue StandardError => e
|
|
229
|
+
UI.warn "icon generation failed (#{e.message}) — bundling without a custom icon"
|
|
206
230
|
nil
|
|
207
231
|
end
|
|
208
232
|
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry/cli"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require_relative "../icon"
|
|
6
|
+
require_relative "../config"
|
|
7
|
+
require_relative "../ui"
|
|
8
|
+
|
|
9
|
+
module Everywhere
|
|
10
|
+
module Commands
|
|
11
|
+
# Generate platform-native app icons from a single source PNG — the same
|
|
12
|
+
# pipeline `every build` uses, exposed on its own for previewing and CI.
|
|
13
|
+
class Icon < Dry::CLI::Command
|
|
14
|
+
desc "Generate macOS (.icns), Windows (.ico) and Linux icons from a source PNG"
|
|
15
|
+
|
|
16
|
+
argument :source, required: false, desc: "Source PNG (default: app.icon / icon.png from everywhere.yml)"
|
|
17
|
+
option :root, default: ".", desc: "App root directory (to resolve the default icon)"
|
|
18
|
+
option :output, default: nil, desc: "Output directory (default: dist/icons)"
|
|
19
|
+
|
|
20
|
+
def call(source: nil, root: ".", output: nil, **)
|
|
21
|
+
root_path = File.expand_path(root)
|
|
22
|
+
source ||= default_source(root_path)
|
|
23
|
+
UI.die!("no source PNG — pass one or set app.icon in everywhere.yml") unless source
|
|
24
|
+
UI.die!("source PNG not found: #{source}") unless File.exist?(source)
|
|
25
|
+
|
|
26
|
+
out = File.expand_path(output || File.join(root_path, "dist", "icons"))
|
|
27
|
+
FileUtils.mkdir_p(out)
|
|
28
|
+
UI.step("generating icons from #{File.basename(source)} → #{out.sub("#{Dir.pwd}/", "")}")
|
|
29
|
+
|
|
30
|
+
master = File.join(out, "AppIcon-macos.png")
|
|
31
|
+
unless Everywhere::Icon.macos_master(source, master)
|
|
32
|
+
UI.die!("couldn't read #{File.basename(source)} — expected an 8-bit RGB/RGBA PNG")
|
|
33
|
+
end
|
|
34
|
+
UI.ok("macOS master #{rel(master, out)} #{UI.dim("(inset + squircle, 1024px)")}")
|
|
35
|
+
|
|
36
|
+
Everywhere::Icon.write_icns(master, File.join(out, "AppIcon.icns"))
|
|
37
|
+
UI.ok("macOS icon AppIcon.icns")
|
|
38
|
+
|
|
39
|
+
Everywhere::Icon.write_ico(source, File.join(out, "icon.ico"))
|
|
40
|
+
UI.ok("Windows icon icon.ico")
|
|
41
|
+
|
|
42
|
+
pngs = Everywhere::Icon.write_png_set(source, File.join(out, "linux"))
|
|
43
|
+
UI.ok("Linux icons linux/ #{UI.dim("(#{pngs.size} sizes)")}")
|
|
44
|
+
|
|
45
|
+
UI.success("icons written to #{out.sub("#{Dir.pwd}/", "")}")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def rel(path, base)
|
|
51
|
+
path.sub("#{base}/", "")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def default_source(root_path)
|
|
55
|
+
Everywhere::Config.load(root_path).icon
|
|
56
|
+
rescue StandardError
|
|
57
|
+
default = File.join(root_path, "icon.png")
|
|
58
|
+
File.exist?(default) ? default : nil
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -102,17 +102,26 @@ module Everywhere
|
|
|
102
102
|
return change("create config/everywhere.yml", false) if File.exist?(config)
|
|
103
103
|
|
|
104
104
|
app_name = File.basename(@root).split(/[-_]/).map(&:capitalize).join(" ")
|
|
105
|
+
slug = app_name.downcase.gsub(/[^a-z0-9]+/, "-")
|
|
105
106
|
File.write(config, <<~YAML)
|
|
106
|
-
# RubyEverywhere app configuration
|
|
107
|
+
# RubyEverywhere app configuration.
|
|
108
|
+
# Full reference: https://rubyeverywhere.com/docs/shared/everywhere-yml
|
|
107
109
|
app:
|
|
108
110
|
name: #{app_name}
|
|
109
111
|
# Reverse-DNS app identifier: names the app-data directory on every
|
|
110
112
|
# platform (and the macOS bundle id). Set once, never change it.
|
|
111
|
-
bundle_id: com.example.#{
|
|
113
|
+
bundle_id: com.example.#{slug}
|
|
114
|
+
version: "0.1.0" # marketing version (CFBundleShortVersionString)
|
|
115
|
+
mode: local # "local" (compile & ship the app) or "remote" (native shell around a deployed site)
|
|
112
116
|
entry_path: / # initial url on app launch
|
|
113
117
|
# icon: icon.png # PNG used to generate app icons (defaults to icon.png at the app root if present)
|
|
114
118
|
# splash: splash.html # custom boot splash (HTML); window.__EVERYWHERE_CONFIG__ carries name/colors
|
|
115
119
|
|
|
120
|
+
# Remote mode only — the deployed URL the native shell opens.
|
|
121
|
+
# Set mode: remote above, then uncomment:
|
|
122
|
+
# remote:
|
|
123
|
+
# url: https://example.com
|
|
124
|
+
|
|
116
125
|
appearance:
|
|
117
126
|
# Colors take a hex string, or split by system theme:
|
|
118
127
|
# tint_color:
|
|
@@ -122,6 +131,42 @@ module Everywhere
|
|
|
122
131
|
background_color:
|
|
123
132
|
light: "#FFFFFF"
|
|
124
133
|
dark: "#1C1B1A"
|
|
134
|
+
|
|
135
|
+
# Native menu-bar items — each one visits a route in your app (no native code).
|
|
136
|
+
# menu:
|
|
137
|
+
# - label: Settings…
|
|
138
|
+
# path: /settings
|
|
139
|
+
# accelerator: CmdOrCtrl+,
|
|
140
|
+
# - separator
|
|
141
|
+
# - label: Documentation
|
|
142
|
+
# path: /help
|
|
143
|
+
|
|
144
|
+
# System tray / menu-bar-extra items — same shape as menu, plus action: show | quit.
|
|
145
|
+
# tray:
|
|
146
|
+
# - label: Open #{app_name}
|
|
147
|
+
# action: show
|
|
148
|
+
# - label: New…
|
|
149
|
+
# path: /new
|
|
150
|
+
# - separator
|
|
151
|
+
# - label: Quit
|
|
152
|
+
# action: quit
|
|
153
|
+
|
|
154
|
+
# Durable build knobs (CLI flags still override per-run).
|
|
155
|
+
# build:
|
|
156
|
+
# ruby: "4.0.6"
|
|
157
|
+
# targets: # the platforms you ship — the CI matrix, expressed once
|
|
158
|
+
# - macos-arm64
|
|
159
|
+
# # - ios-arm64 # future — mobile is remote mode only
|
|
160
|
+
# # - android-arm64 # future — mobile is remote mode only
|
|
161
|
+
# permissions: [notifications, filesystem:read, clipboard]
|
|
162
|
+
|
|
163
|
+
# Per-platform overrides. Keys are platform names (macos / ios / android /
|
|
164
|
+
# windows / linux); anything here overrides the app: defaults above for that
|
|
165
|
+
# platform — handy when a store needs a distinct bundle id or version.
|
|
166
|
+
# platforms:
|
|
167
|
+
# ios:
|
|
168
|
+
# bundle_id: com.example.#{slug}.ios
|
|
169
|
+
# version: "0.1.1"
|
|
125
170
|
YAML
|
|
126
171
|
change("create config/everywhere.yml", true)
|
|
127
172
|
end
|
|
@@ -37,7 +37,7 @@ module Everywhere
|
|
|
37
37
|
app_name: nil, shell_dir: nil, env_file: nil, skip_build: false, **)
|
|
38
38
|
root_path = File.expand_path(root)
|
|
39
39
|
config = Everywhere::Config.load(root_path)
|
|
40
|
-
app_name ||= config.name
|
|
40
|
+
app_name ||= config.name(target: target)
|
|
41
41
|
ruby ||= config.build_ruby || "4.0.6"
|
|
42
42
|
|
|
43
43
|
preflight!(target)
|
|
@@ -49,7 +49,7 @@ module Everywhere
|
|
|
49
49
|
# notarize step below re-signs it inside-out with the Developer ID.
|
|
50
50
|
unless skip_build
|
|
51
51
|
UI.step("building #{UI.bold(app_name)} for #{UI.cyan(target)}")
|
|
52
|
-
Build.new.call(root: root, ruby: ruby, entry: entry, app_name: app_name, shell_dir: shell_dir, **)
|
|
52
|
+
Build.new.call(root: root, ruby: ruby, entry: entry, app_name: app_name, target: target, shell_dir: shell_dir, **)
|
|
53
53
|
end
|
|
54
54
|
|
|
55
55
|
app_dir = Dir[File.join(dist_dir, "*.app")].max_by { |d| File.mtime(d) }
|
data/lib/everywhere/config.rb
CHANGED
|
@@ -31,15 +31,23 @@ module Everywhere
|
|
|
31
31
|
@root = root
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
# Every target (os-arch string) resolves to a single platform (its os), and
|
|
35
|
+
# a platform may override the app-level identity/version keys — see
|
|
36
|
+
# `platforms:` below. Pass `target:` to any of these accessors to apply the
|
|
37
|
+
# matching override; omit it for the shared app-level value.
|
|
38
|
+
def self.os_of(target) = target.to_s.split("-", 2).first
|
|
39
|
+
|
|
40
|
+
def name(target: nil)
|
|
41
|
+
resolved(target)["name"] || File.basename(File.expand_path(root)).split(/[-_]/).map(&:capitalize).join(" ")
|
|
36
42
|
end
|
|
37
43
|
|
|
38
44
|
# Reverse-DNS identifier: macOS bundle id, and the name of the per-user
|
|
39
45
|
# app-data directory on every platform. Set it explicitly and never change
|
|
40
|
-
# it — renaming moves users' data.
|
|
41
|
-
|
|
42
|
-
|
|
46
|
+
# it — renaming moves users' data. A platform may override it (e.g. the App
|
|
47
|
+
# Store often wants `com.example.app.ios`).
|
|
48
|
+
def bundle_id(target: nil)
|
|
49
|
+
resolved(target)["bundle_id"] ||
|
|
50
|
+
"com.rubyeverywhere.#{name(target: target).downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-|-\z/, "")}"
|
|
43
51
|
end
|
|
44
52
|
|
|
45
53
|
# App icon source: a PNG (ideally square, 1024px+). Explicit `app.icon`
|
|
@@ -67,9 +75,11 @@ module Everywhere
|
|
|
67
75
|
end
|
|
68
76
|
|
|
69
77
|
# Marketing/display version of the app (CFBundleShortVersionString, and the
|
|
70
|
-
# `version` recorded in the build receipt).
|
|
71
|
-
|
|
72
|
-
|
|
78
|
+
# `version` recorded in the build receipt). One shared `app.version` is the
|
|
79
|
+
# default for every target; a platform may override it when a store forces a
|
|
80
|
+
# different number. Defaults conservatively.
|
|
81
|
+
def version(target: nil)
|
|
82
|
+
resolved(target)["version"] || "0.1.0"
|
|
73
83
|
end
|
|
74
84
|
|
|
75
85
|
# The `build:` section — durable build knobs that were once `every build`
|
|
@@ -149,11 +159,13 @@ module Everywhere
|
|
|
149
159
|
end
|
|
150
160
|
|
|
151
161
|
# The subset the shell needs, shipped as JSON (env var in dev,
|
|
152
|
-
# Resources/everywhere.json inside a bundled .app).
|
|
153
|
-
|
|
162
|
+
# Resources/everywhere.json inside a bundled .app). Pass `target:` so the
|
|
163
|
+
# packaged config reflects the platform being built (its bundle id / name).
|
|
164
|
+
def to_shell_hash(target: nil)
|
|
154
165
|
{
|
|
155
|
-
"name" => name,
|
|
156
|
-
"bundle_id" => bundle_id,
|
|
166
|
+
"name" => name(target: target),
|
|
167
|
+
"bundle_id" => bundle_id(target: target),
|
|
168
|
+
"version" => version(target: target),
|
|
157
169
|
"mode" => mode,
|
|
158
170
|
"remote_url" => remote_url,
|
|
159
171
|
"entry_path" => entry_path,
|
|
@@ -164,9 +176,9 @@ module Everywhere
|
|
|
164
176
|
}.compact
|
|
165
177
|
end
|
|
166
178
|
|
|
167
|
-
def to_shell_json
|
|
179
|
+
def to_shell_json(target: nil)
|
|
168
180
|
require "json"
|
|
169
|
-
JSON.generate(to_shell_hash)
|
|
181
|
+
JSON.generate(to_shell_hash(target: target))
|
|
170
182
|
end
|
|
171
183
|
|
|
172
184
|
private
|
|
@@ -174,6 +186,19 @@ module Everywhere
|
|
|
174
186
|
def app = @data.fetch("app", nil) || {}
|
|
175
187
|
def appearance = @data.fetch("appearance", nil) || {}
|
|
176
188
|
|
|
189
|
+
# Per-platform overrides, keyed by os (macos / ios / android / windows /
|
|
190
|
+
# linux). Each value overrides the matching `app:` keys for that platform.
|
|
191
|
+
def platforms = @data.fetch("platforms", nil) || {}
|
|
192
|
+
|
|
193
|
+
# The app-level keys with the target's platform override merged on top.
|
|
194
|
+
# Blank override values are dropped (`.compact`) so they don't erase a
|
|
195
|
+
# default. Without a target — or without an override for that os — this is
|
|
196
|
+
# just the plain `app:` hash.
|
|
197
|
+
def resolved(target)
|
|
198
|
+
override = target && platforms[self.class.os_of(target)]
|
|
199
|
+
override.is_a?(Hash) ? app.merge(override.compact) : app
|
|
200
|
+
end
|
|
201
|
+
|
|
177
202
|
def normalize_color(value)
|
|
178
203
|
case value
|
|
179
204
|
when String
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require_relative "raster"
|
|
5
|
+
|
|
6
|
+
module Everywhere
|
|
7
|
+
# Turns one source PNG into platform-native app icons — no Tauri, no sips, no
|
|
8
|
+
# iconutil, no ImageMagick. Pure Ruby, so it runs the same locally and on the
|
|
9
|
+
# cloud build runners.
|
|
10
|
+
#
|
|
11
|
+
# The important part is macОS shaping. macOS renders an .icns *literally*: it
|
|
12
|
+
# does not round the corners or add margins the way iOS does. A full-bleed
|
|
13
|
+
# square PNG therefore shows up as a hard square, out of place next to every
|
|
14
|
+
# native app. So we bake the platform's look into the artwork ourselves —
|
|
15
|
+
# inset the icon to the Big Sur grid (~824px inside a 1024px canvas) and clip
|
|
16
|
+
# it to a superellipse ("squircle") with transparent corners.
|
|
17
|
+
#
|
|
18
|
+
# Windows (.ico) and Linux (hicolor PNGs) don't get the squircle — those
|
|
19
|
+
# desktops present square icons — so they're generated straight from the
|
|
20
|
+
# full-bleed source.
|
|
21
|
+
module Icon
|
|
22
|
+
# Big Sur icon-grid geometry, all as fractions of the 1024px canvas so it
|
|
23
|
+
# scales to any master size.
|
|
24
|
+
CANVAS = 1024
|
|
25
|
+
INSET = 0.8047 # icon body is 824/1024 of the canvas
|
|
26
|
+
CORNER_RATIO = 0.2237 # corner radius as a fraction of the body's side
|
|
27
|
+
EXPONENT = 5.0 # superellipse power — the iOS/macOS "squircle" corner
|
|
28
|
+
|
|
29
|
+
# .icns type codes -> pixel size. Mirrors what `iconutil` emits from a
|
|
30
|
+
# standard .iconset (retina variants included), so the result is
|
|
31
|
+
# indistinguishable from an Apple-toolchain icns.
|
|
32
|
+
ICNS_TYPES = [
|
|
33
|
+
["icp4", 16], ["icp5", 32], ["ic07", 128], ["ic08", 256], ["ic09", 512],
|
|
34
|
+
["ic11", 32], ["ic12", 64], ["ic13", 256], ["ic14", 512], ["ic10", 1024]
|
|
35
|
+
].freeze
|
|
36
|
+
|
|
37
|
+
ICO_SIZES = [16, 32, 48, 64, 128, 256].freeze
|
|
38
|
+
LINUX_SIZES = [16, 32, 48, 64, 128, 256, 512].freeze
|
|
39
|
+
|
|
40
|
+
module_function
|
|
41
|
+
|
|
42
|
+
# Write a macOS-shaped 1024px RGBA master PNG (padding + squircle) from a
|
|
43
|
+
# source PNG. Returns true, or false if the source PNG can't be read.
|
|
44
|
+
def macos_master(source, dest, canvas: CANVAS)
|
|
45
|
+
raster = Raster.decode_png(source) or return false
|
|
46
|
+
|
|
47
|
+
body = (canvas * INSET).round
|
|
48
|
+
offset = (canvas - body) / 2
|
|
49
|
+
|
|
50
|
+
# Fit the artwork inside the body square preserving aspect ratio (square
|
|
51
|
+
# sources fill it exactly; non-square ones are centred, not stretched).
|
|
52
|
+
fit = [body.to_f / raster.width, body.to_f / raster.height].min
|
|
53
|
+
sw = (raster.width * fit).round.clamp(1, body)
|
|
54
|
+
sh = (raster.height * fit).round.clamp(1, body)
|
|
55
|
+
scaled = raster.resample(sw, sh)
|
|
56
|
+
|
|
57
|
+
out = Raster.transparent(canvas, canvas)
|
|
58
|
+
out.paste!(scaled, offset + (body - sw) / 2, offset + (body - sh) / 2)
|
|
59
|
+
|
|
60
|
+
centre = canvas / 2.0
|
|
61
|
+
half = body / 2.0
|
|
62
|
+
radius = [body * CORNER_RATIO, half].min
|
|
63
|
+
straight = half - radius # half-extent of the straight edges
|
|
64
|
+
out.mask_region!(offset, offset, offset + body, offset + body) do |x, y|
|
|
65
|
+
squircle_coverage(x, y, centre, half, straight, radius, EXPONENT)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
out.write_png(dest)
|
|
69
|
+
true
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Build a macOS .icns from an already-shaped master PNG (ideally 1024px).
|
|
73
|
+
# Returns true on success.
|
|
74
|
+
def write_icns(master_png, dest)
|
|
75
|
+
master = Raster.decode_png(master_png) or return false
|
|
76
|
+
|
|
77
|
+
body = ICNS_TYPES.map do |type, size|
|
|
78
|
+
png = sized_png(master, size)
|
|
79
|
+
type.b + [png.bytesize + 8].pack("N") + png
|
|
80
|
+
end.join
|
|
81
|
+
|
|
82
|
+
File.binwrite(dest, "icns".b + [body.bytesize + 8].pack("N") + body)
|
|
83
|
+
true
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Build a Windows .ico (PNG-compressed entries, Vista+) from a full-bleed
|
|
87
|
+
# source PNG. Returns true on success.
|
|
88
|
+
def write_ico(source, dest, sizes: ICO_SIZES)
|
|
89
|
+
master = Raster.decode_png(source) or return false
|
|
90
|
+
|
|
91
|
+
images = sizes.map { |s| [s, sized_png(master, s)] }
|
|
92
|
+
offset = 6 + images.size * 16
|
|
93
|
+
entries = +"".b
|
|
94
|
+
blob = +"".b
|
|
95
|
+
images.each do |size, png|
|
|
96
|
+
dim = size >= 256 ? 0 : size # 0 means 256 in the ICONDIRENTRY
|
|
97
|
+
entries << [dim, dim, 0, 0, 1, 32, png.bytesize, offset].pack("CCCCvvVV")
|
|
98
|
+
blob << png
|
|
99
|
+
offset += png.bytesize
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
header = [0, 1, images.size].pack("vvv") # reserved, type=icon, count
|
|
103
|
+
File.binwrite(dest, header + entries + blob)
|
|
104
|
+
true
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Write a set of square PNGs (Linux/desktop use) named "<size>x<size>.png"
|
|
108
|
+
# into dest_dir from a full-bleed source. Returns the written paths.
|
|
109
|
+
def write_png_set(source, dest_dir, sizes: LINUX_SIZES)
|
|
110
|
+
master = Raster.decode_png(source) or return []
|
|
111
|
+
|
|
112
|
+
FileUtils.mkdir_p(dest_dir)
|
|
113
|
+
sizes.map do |size|
|
|
114
|
+
path = File.join(dest_dir, "#{size}x#{size}.png")
|
|
115
|
+
File.binwrite(path, sized_png(master, size))
|
|
116
|
+
path
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# PNG bytes of `master` at `size`x`size` (no-op resample when already sized).
|
|
121
|
+
def sized_png(master, size)
|
|
122
|
+
(size == master.width && size == master.height ? master : master.resample(size, size)).encode_png
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Coverage (0.0..1.0) of pixel (x, y) by the squircle: a rounded rectangle
|
|
126
|
+
# with continuous (superelliptical) corners. Interior pixels return 1.0,
|
|
127
|
+
# exterior 0.0, and boundary pixels are antialiased by 4x4 supersampling.
|
|
128
|
+
def squircle_coverage(x, y, centre, half, straight, radius, exponent)
|
|
129
|
+
c0 = squircle_inside?(x, y, centre, half, straight, radius, exponent)
|
|
130
|
+
c1 = squircle_inside?(x + 1, y, centre, half, straight, radius, exponent)
|
|
131
|
+
c2 = squircle_inside?(x, y + 1, centre, half, straight, radius, exponent)
|
|
132
|
+
c3 = squircle_inside?(x + 1, y + 1, centre, half, straight, radius, exponent)
|
|
133
|
+
return 1.0 if c0 && c1 && c2 && c3
|
|
134
|
+
return 0.0 unless c0 || c1 || c2 || c3
|
|
135
|
+
|
|
136
|
+
hit = 0
|
|
137
|
+
4.times do |i|
|
|
138
|
+
4.times do |j|
|
|
139
|
+
hit += 1 if squircle_inside?(x + (i + 0.5) / 4.0, y + (j + 0.5) / 4.0,
|
|
140
|
+
centre, half, straight, radius, exponent)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
hit / 16.0
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Is the continuous point (sx, sy) inside the squircle? Straight edges up to
|
|
147
|
+
# `straight` from centre, superelliptical corners of `radius` beyond that.
|
|
148
|
+
def squircle_inside?(sx, sy, centre, half, straight, radius, exponent)
|
|
149
|
+
u = (sx - centre).abs
|
|
150
|
+
v = (sy - centre).abs
|
|
151
|
+
return false if u > half || v > half
|
|
152
|
+
return true if u <= straight || v <= straight
|
|
153
|
+
|
|
154
|
+
du = (u - straight) / radius
|
|
155
|
+
dv = (v - straight) / radius
|
|
156
|
+
(du**exponent) + (dv**exponent) <= 1.0
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
data/lib/everywhere/png.rb
CHANGED
|
@@ -39,6 +39,60 @@ module Everywhere
|
|
|
39
39
|
true
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
+
# Decode an 8-bit RGB/RGBA PNG to a flat RGBA pixel buffer. Returns
|
|
43
|
+
# [width, height, bytes] (bytes = width*height*4, no per-scanline filter
|
|
44
|
+
# byte) or nil for flavors we don't handle (palette, 16-bit, interlaced,
|
|
45
|
+
# grayscale). This is the pixel gateway the Raster/Icon generators build on.
|
|
46
|
+
def decode_rgba(path)
|
|
47
|
+
data = File.binread(path)
|
|
48
|
+
return nil unless data.start_with?(SIGNATURE)
|
|
49
|
+
|
|
50
|
+
chunks = read_chunks(data)
|
|
51
|
+
ihdr = chunks.find { |type, _| type == "IHDR" }&.last or return nil
|
|
52
|
+
width, height, depth, color, _comp, _filter, interlace = ihdr.unpack("NNC5")
|
|
53
|
+
return nil unless depth == 8 && interlace.zero?
|
|
54
|
+
return nil unless color == 2 || color == 6 # RGB or RGBA
|
|
55
|
+
|
|
56
|
+
bpp = color == 6 ? 4 : 3
|
|
57
|
+
raw = Zlib::Inflate.inflate(chunks.select { |t, _| t == "IDAT" }.map(&:last).join)
|
|
58
|
+
stride = width * bpp
|
|
59
|
+
prev = "\0".b * stride
|
|
60
|
+
out = String.new(capacity: width * height * 4)
|
|
61
|
+
|
|
62
|
+
height.times do |y|
|
|
63
|
+
filter = raw.getbyte(y * (stride + 1))
|
|
64
|
+
line = raw.byteslice(y * (stride + 1) + 1, stride).dup
|
|
65
|
+
unfilter!(line, prev, filter, bpp)
|
|
66
|
+
if bpp == 4
|
|
67
|
+
out << line
|
|
68
|
+
else
|
|
69
|
+
x = 0
|
|
70
|
+
while x < width
|
|
71
|
+
out << line.byteslice(x * 3, 3) << "\xFF".b
|
|
72
|
+
x += 1
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
prev = line
|
|
76
|
+
end
|
|
77
|
+
[width, height, out]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Encode a flat RGBA pixel buffer to PNG bytes (filter 0, single IDAT).
|
|
81
|
+
def encode_rgba(width, height, rgba)
|
|
82
|
+
stride = width * 4
|
|
83
|
+
raw = String.new(capacity: (stride + 1) * height)
|
|
84
|
+
height.times do |y|
|
|
85
|
+
raw << "\0".b # filter: None
|
|
86
|
+
raw << rgba.byteslice(y * stride, stride)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
out = SIGNATURE.dup
|
|
90
|
+
out << chunk("IHDR", [width, height, 8, 6, 0, 0, 0].pack("NNC5"))
|
|
91
|
+
out << chunk("IDAT", Zlib::Deflate.deflate(raw, Zlib::BEST_COMPRESSION))
|
|
92
|
+
out << chunk("IEND", "")
|
|
93
|
+
out
|
|
94
|
+
end
|
|
95
|
+
|
|
42
96
|
def read_chunks(data)
|
|
43
97
|
chunks = []
|
|
44
98
|
pos = 8
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "png"
|
|
4
|
+
|
|
5
|
+
module Everywhere
|
|
6
|
+
# A mutable RGBA raster and the handful of pixel operations the icon
|
|
7
|
+
# generator needs — decode/encode PNG, high-quality resampling, pasting, and
|
|
8
|
+
# a superellipse ("squircle") alpha mask. Pure Ruby (zlib only): no
|
|
9
|
+
# ImageMagick, no sips, no Tauri. Works the same on any OS the CLI runs on.
|
|
10
|
+
#
|
|
11
|
+
# Pixels are stored as a flat 8-bit RGBA byte string (row-major, 4 bytes per
|
|
12
|
+
# pixel). Resampling is a separable area/box filter done in *premultiplied*
|
|
13
|
+
# alpha so transparent regions never bleed their colour into opaque edges.
|
|
14
|
+
class Raster
|
|
15
|
+
attr_reader :width, :height, :pixels
|
|
16
|
+
|
|
17
|
+
def initialize(width, height, pixels = nil)
|
|
18
|
+
@width = width
|
|
19
|
+
@height = height
|
|
20
|
+
@pixels = pixels || ("\x00".b * (width * height * 4))
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# A fully transparent canvas.
|
|
24
|
+
def self.transparent(width, height)
|
|
25
|
+
new(width, height)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Decode a PNG file into a Raster, or nil if it's a flavor PNG.decode_rgba
|
|
29
|
+
# can't read (palette, 16-bit, interlaced, grayscale).
|
|
30
|
+
def self.decode_png(path)
|
|
31
|
+
width, height, bytes = PNG.decode_rgba(path)
|
|
32
|
+
return nil unless width
|
|
33
|
+
|
|
34
|
+
new(width, height, bytes)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def encode_png
|
|
38
|
+
PNG.encode_rgba(width, height, pixels)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def write_png(path)
|
|
42
|
+
File.binwrite(path, encode_png)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Resample to a new size with a separable area filter. Great for the
|
|
46
|
+
# downscales an icon set is made of; degrades gracefully on upscale.
|
|
47
|
+
def resample(new_width, new_height)
|
|
48
|
+
return self if new_width == width && new_height == height
|
|
49
|
+
|
|
50
|
+
# Premultiplied float channels: pr = r * (a/255), etc.
|
|
51
|
+
pr = Array.new(width * height)
|
|
52
|
+
pg = Array.new(width * height)
|
|
53
|
+
pb = Array.new(width * height)
|
|
54
|
+
pa = Array.new(width * height)
|
|
55
|
+
i = 0
|
|
56
|
+
(width * height).times do |p|
|
|
57
|
+
a = pixels.getbyte(i + 3)
|
|
58
|
+
af = a / 255.0
|
|
59
|
+
pr[p] = pixels.getbyte(i) * af
|
|
60
|
+
pg[p] = pixels.getbyte(i + 1) * af
|
|
61
|
+
pb[p] = pixels.getbyte(i + 2) * af
|
|
62
|
+
pa[p] = a.to_f
|
|
63
|
+
i += 4
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Horizontal pass (width -> new_width), then vertical (height -> new_height).
|
|
67
|
+
wx = self.class.axis_weights(width, new_width)
|
|
68
|
+
hr = resample_axis(pr, width, height, new_width, wx, rows: true)
|
|
69
|
+
hg = resample_axis(pg, width, height, new_width, wx, rows: true)
|
|
70
|
+
hb = resample_axis(pb, width, height, new_width, wx, rows: true)
|
|
71
|
+
ha = resample_axis(pa, width, height, new_width, wx, rows: true)
|
|
72
|
+
|
|
73
|
+
wy = self.class.axis_weights(height, new_height)
|
|
74
|
+
vr = resample_axis(hr, new_width, height, new_height, wy, rows: false)
|
|
75
|
+
vg = resample_axis(hg, new_width, height, new_height, wy, rows: false)
|
|
76
|
+
vb = resample_axis(hb, new_width, height, new_height, wy, rows: false)
|
|
77
|
+
va = resample_axis(ha, new_width, height, new_height, wy, rows: false)
|
|
78
|
+
|
|
79
|
+
out = String.new(capacity: new_width * new_height * 4)
|
|
80
|
+
(new_width * new_height).times do |p|
|
|
81
|
+
a = va[p]
|
|
82
|
+
if a <= 0.001
|
|
83
|
+
out << "\x00\x00\x00\x00".b
|
|
84
|
+
else
|
|
85
|
+
out << [
|
|
86
|
+
(vr[p] * 255.0 / a).round.clamp(0, 255),
|
|
87
|
+
(vg[p] * 255.0 / a).round.clamp(0, 255),
|
|
88
|
+
(vb[p] * 255.0 / a).round.clamp(0, 255),
|
|
89
|
+
a.round.clamp(0, 255)
|
|
90
|
+
].pack("C4")
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
self.class.new(new_width, new_height, out)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Copy src into this raster with its top-left at (ox, oy), replacing pixels
|
|
97
|
+
# (the canvas is transparent underneath, so a plain copy is the composite).
|
|
98
|
+
def paste!(src, ox, oy)
|
|
99
|
+
row = src.width * 4
|
|
100
|
+
src.height.times do |y|
|
|
101
|
+
dst_index = ((oy + y) * width + ox) * 4
|
|
102
|
+
pixels[dst_index, row] = src.pixels.byteslice(y * row, row)
|
|
103
|
+
end
|
|
104
|
+
self
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Multiply the alpha channel of every pixel in [x0, x1) x [y0, y1) by the
|
|
108
|
+
# coverage the block returns for pixel (x, y) — a value in 0.0..1.0.
|
|
109
|
+
# Pixels outside the range keep their (already transparent) alpha.
|
|
110
|
+
def mask_region!(x0, y0, x1, y1)
|
|
111
|
+
y0.upto(y1 - 1) do |y|
|
|
112
|
+
base = y * width
|
|
113
|
+
x0.upto(x1 - 1) do |x|
|
|
114
|
+
cov = yield(x, y)
|
|
115
|
+
next if cov >= 1.0
|
|
116
|
+
|
|
117
|
+
idx = (base + x) * 4 + 3
|
|
118
|
+
a = pixels.getbyte(idx)
|
|
119
|
+
next if a.zero?
|
|
120
|
+
|
|
121
|
+
pixels.setbyte(idx, cov <= 0.0 ? 0 : (a * cov).round)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
self
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# For each output index along an axis of `n_out` pixels, the list of
|
|
128
|
+
# [input_index, weight] contributions, where weights sum to 1 (area filter:
|
|
129
|
+
# each output pixel is the average of the input pixels it overlaps).
|
|
130
|
+
def self.axis_weights(n_in, n_out)
|
|
131
|
+
scale = n_in.to_f / n_out
|
|
132
|
+
Array.new(n_out) do |i|
|
|
133
|
+
start = i * scale
|
|
134
|
+
finish = [start + scale, n_in.to_f].min # float slop can push this past n_in
|
|
135
|
+
weights = []
|
|
136
|
+
k = [start.floor, 0].max
|
|
137
|
+
while k < finish && k < n_in
|
|
138
|
+
lo = [k.to_f, start].max
|
|
139
|
+
hi = [k + 1.0, finish].min
|
|
140
|
+
overlap = hi - lo
|
|
141
|
+
weights << [k, overlap / scale] if overlap > 0
|
|
142
|
+
k += 1
|
|
143
|
+
end
|
|
144
|
+
weights
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
private
|
|
149
|
+
|
|
150
|
+
# Resample one float channel along a single axis. `rows: true` resizes each
|
|
151
|
+
# row from `n_in` to `n_out` columns; `rows: false` resizes each column.
|
|
152
|
+
def resample_axis(src, in_w, in_h, n_out, weights, rows:)
|
|
153
|
+
if rows
|
|
154
|
+
out = Array.new(n_out * in_h, 0.0)
|
|
155
|
+
in_h.times do |y|
|
|
156
|
+
src_base = y * in_w
|
|
157
|
+
out_base = y * n_out
|
|
158
|
+
n_out.times do |o|
|
|
159
|
+
acc = 0.0
|
|
160
|
+
weights[o].each { |k, wt| acc += src[src_base + k] * wt }
|
|
161
|
+
out[out_base + o] = acc
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
out
|
|
165
|
+
else
|
|
166
|
+
out = Array.new(in_w * n_out, 0.0)
|
|
167
|
+
in_w.times do |x|
|
|
168
|
+
n_out.times do |o|
|
|
169
|
+
acc = 0.0
|
|
170
|
+
weights[o].each { |k, wt| acc += src[k * in_w + x] * wt }
|
|
171
|
+
out[o * in_w + x] = acc
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
out
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
data/lib/everywhere/receipt.rb
CHANGED
|
@@ -27,6 +27,7 @@ module Everywhere
|
|
|
27
27
|
@config = config
|
|
28
28
|
@framework = framework
|
|
29
29
|
@ruby = ruby
|
|
30
|
+
@target_str = target # raw "os-arch" — selects platforms: overrides
|
|
30
31
|
@target = parse_target(target, channel)
|
|
31
32
|
@shell_dir = shell_dir
|
|
32
33
|
end
|
|
@@ -35,9 +36,9 @@ module Everywhere
|
|
|
35
36
|
def manifest
|
|
36
37
|
{
|
|
37
38
|
"app" => {
|
|
38
|
-
"name" => @config.name,
|
|
39
|
-
"bundle_id" => @config.bundle_id,
|
|
40
|
-
"version" => @config.version
|
|
39
|
+
"name" => @config.name(target: @target_str),
|
|
40
|
+
"bundle_id" => @config.bundle_id(target: @target_str),
|
|
41
|
+
"version" => @config.version(target: @target_str)
|
|
41
42
|
},
|
|
42
43
|
"framework" => framework_info,
|
|
43
44
|
"ruby" => @ruby,
|
data/lib/everywhere/version.rb
CHANGED
|
@@ -993,6 +993,9 @@ dependencies = [
|
|
|
993
993
|
name = "example-shell"
|
|
994
994
|
version = "0.1.0"
|
|
995
995
|
dependencies = [
|
|
996
|
+
"objc2",
|
|
997
|
+
"objc2-foundation",
|
|
998
|
+
"objc2-web-kit",
|
|
996
999
|
"serde_json",
|
|
997
1000
|
"tauri",
|
|
998
1001
|
"tauri-build",
|
|
@@ -2366,6 +2369,16 @@ dependencies = [
|
|
|
2366
2369
|
"objc2-core-foundation",
|
|
2367
2370
|
]
|
|
2368
2371
|
|
|
2372
|
+
[[package]]
|
|
2373
|
+
name = "objc2-javascript-core"
|
|
2374
|
+
version = "0.3.2"
|
|
2375
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
2376
|
+
checksum = "2a1e6550c4caed348956ce3370c9ffeca70bb1dbed4fa96112e7c6170e074586"
|
|
2377
|
+
dependencies = [
|
|
2378
|
+
"objc2",
|
|
2379
|
+
"objc2-core-foundation",
|
|
2380
|
+
]
|
|
2381
|
+
|
|
2369
2382
|
[[package]]
|
|
2370
2383
|
name = "objc2-quartz-core"
|
|
2371
2384
|
version = "0.3.2"
|
|
@@ -2378,6 +2391,17 @@ dependencies = [
|
|
|
2378
2391
|
"objc2-foundation",
|
|
2379
2392
|
]
|
|
2380
2393
|
|
|
2394
|
+
[[package]]
|
|
2395
|
+
name = "objc2-security"
|
|
2396
|
+
version = "0.3.2"
|
|
2397
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
2398
|
+
checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a"
|
|
2399
|
+
dependencies = [
|
|
2400
|
+
"bitflags 2.13.0",
|
|
2401
|
+
"objc2",
|
|
2402
|
+
"objc2-core-foundation",
|
|
2403
|
+
]
|
|
2404
|
+
|
|
2381
2405
|
[[package]]
|
|
2382
2406
|
name = "objc2-ui-kit"
|
|
2383
2407
|
version = "0.3.2"
|
|
@@ -2421,6 +2445,8 @@ dependencies = [
|
|
|
2421
2445
|
"objc2-app-kit",
|
|
2422
2446
|
"objc2-core-foundation",
|
|
2423
2447
|
"objc2-foundation",
|
|
2448
|
+
"objc2-javascript-core",
|
|
2449
|
+
"objc2-security",
|
|
2424
2450
|
]
|
|
2425
2451
|
|
|
2426
2452
|
[[package]]
|
|
@@ -14,6 +14,15 @@ tauri-plugin-notification = "2"
|
|
|
14
14
|
tauri-plugin-dialog = "2"
|
|
15
15
|
tauri-plugin-clipboard-manager = "2"
|
|
16
16
|
|
|
17
|
+
# macOS: set the WebView's `applicationNameForUserAgent`, which WKWebView
|
|
18
|
+
# natively APPENDS to its real default UA — so we get the true system UA plus
|
|
19
|
+
# our marker without ever reading or hardcoding the base string. Versions are
|
|
20
|
+
# pinned to match what wry 0.55 already resolves, so no duplicate objc2 trees.
|
|
21
|
+
[target.'cfg(target_os = "macos")'.dependencies]
|
|
22
|
+
objc2 = "0.6"
|
|
23
|
+
objc2-foundation = { version = "0.3", features = ["NSString", "NSUUID", "NSProcessInfo"] }
|
|
24
|
+
objc2-web-kit = { version = "0.3", features = ["WKWebViewConfiguration", "WKWebsiteDataStore"] }
|
|
25
|
+
|
|
17
26
|
[profile.release]
|
|
18
27
|
strip = true
|
|
19
28
|
lto = true
|
|
@@ -32,6 +32,7 @@ struct AppConfig {
|
|
|
32
32
|
raw_json: String,
|
|
33
33
|
name: String,
|
|
34
34
|
bundle_id: Option<String>,
|
|
35
|
+
version: Option<String>,
|
|
35
36
|
entry_path: String,
|
|
36
37
|
remote_url: Option<String>,
|
|
37
38
|
background_light: Option<(u8, u8, u8)>,
|
|
@@ -71,6 +72,7 @@ fn load_config() -> AppConfig {
|
|
|
71
72
|
raw_json: if value.is_null() { "{}".to_string() } else { value.to_string() },
|
|
72
73
|
name: value["name"].as_str().unwrap_or("RubyEverywhere App").to_string(),
|
|
73
74
|
bundle_id: value["bundle_id"].as_str().map(str::to_string),
|
|
75
|
+
version: value["version"].as_str().map(str::to_string),
|
|
74
76
|
entry_path: value["entry_path"].as_str().unwrap_or("/").to_string(),
|
|
75
77
|
remote_url: (value["mode"].as_str() == Some("remote"))
|
|
76
78
|
.then(|| value["remote_url"].as_str().map(str::to_string))
|
|
@@ -549,12 +551,72 @@ const INIT_SCRIPT: &str = r#"
|
|
|
549
551
|
})();
|
|
550
552
|
"#;
|
|
551
553
|
|
|
554
|
+
// The marker we append to the WebView's own user agent. We never build or
|
|
555
|
+
// hardcode the browser part of the UA — the WebView keeps producing its real
|
|
556
|
+
// default and this is added on the end, so anything the Ruby side sniffs (the
|
|
557
|
+
// `browser` gem, `request.variant`) still sees a genuine desktop browser. The
|
|
558
|
+
// contract with the app: `request.user_agent =~ %r{RubyEverywhere/([\d.]+)}`
|
|
559
|
+
// tells it the request came from the native shell and which app version (from
|
|
560
|
+
// everywhere.yml → the submitted build) made it. Version is absent only in bare
|
|
561
|
+
// `cargo run` dev where no config was passed.
|
|
562
|
+
fn ua_marker(config: &AppConfig) -> String {
|
|
563
|
+
let os = std::env::consts::OS;
|
|
564
|
+
match &config.version {
|
|
565
|
+
Some(v) => format!("RubyEverywhere/{v} ({os})"),
|
|
566
|
+
None => format!("RubyEverywhere ({os})"),
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// macOS: build the WKWebViewConfiguration ourselves so we can set
|
|
571
|
+
// `applicationNameForUserAgent` — WebKit appends it to the real default UA (the
|
|
572
|
+
// one clean, no-read way to *append* rather than replace). Because wry uses the
|
|
573
|
+
// config's own `websiteDataStore` when we supply a configuration (and ignores
|
|
574
|
+
// Tauri's `data_store_identifier`), we must reproduce the per-app persistent
|
|
575
|
+
// store here too, or logins would leak into the shared default store. Mirrors
|
|
576
|
+
// wry's own logic: identified store on macOS 14+, default otherwise / in dev.
|
|
577
|
+
#[cfg(target_os = "macos")]
|
|
578
|
+
fn macos_webview_configuration(
|
|
579
|
+
config: &AppConfig,
|
|
580
|
+
dev_mode: bool,
|
|
581
|
+
) -> objc2::rc::Retained<objc2_web_kit::WKWebViewConfiguration> {
|
|
582
|
+
use objc2::MainThreadMarker;
|
|
583
|
+
use objc2_foundation::{NSProcessInfo, NSString, NSUUID, NSOperatingSystemVersion};
|
|
584
|
+
use objc2_web_kit::{WKWebViewConfiguration, WKWebsiteDataStore};
|
|
585
|
+
|
|
586
|
+
let mtm = MainThreadMarker::new().expect("webview configuration must be built on the main thread");
|
|
587
|
+
let cfg = unsafe { WKWebViewConfiguration::new(mtm) };
|
|
588
|
+
|
|
589
|
+
unsafe {
|
|
590
|
+
cfg.setApplicationNameForUserAgent(Some(&NSString::from_str(&ua_marker(config))));
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// Cookie / session persistence, keyed by bundle_id so each app is isolated.
|
|
594
|
+
// Dev mode keeps the default store (matches the previous ephemeral-ish dev
|
|
595
|
+
// behaviour). dataStoreForIdentifier needs macOS 14+.
|
|
596
|
+
if !dev_mode {
|
|
597
|
+
let os14 = NSProcessInfo::processInfo().isOperatingSystemAtLeastVersion(
|
|
598
|
+
NSOperatingSystemVersion { majorVersion: 14, minorVersion: 0, patchVersion: 0 },
|
|
599
|
+
);
|
|
600
|
+
if os14 {
|
|
601
|
+
let id = config.bundle_id.as_deref().unwrap_or("com.rubyeverywhere.app");
|
|
602
|
+
let store = unsafe {
|
|
603
|
+
WKWebsiteDataStore::dataStoreForIdentifier(&NSUUID::from_bytes(data_store_id(id)), mtm)
|
|
604
|
+
};
|
|
605
|
+
unsafe { cfg.setWebsiteDataStore(&store) };
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
cfg
|
|
610
|
+
}
|
|
611
|
+
|
|
552
612
|
fn build_window(
|
|
553
613
|
app: &tauri::App,
|
|
554
614
|
config: &AppConfig,
|
|
555
615
|
title: &str,
|
|
556
616
|
url: WebviewUrl,
|
|
557
617
|
) -> tauri::Result<tauri::WebviewWindow> {
|
|
618
|
+
let dev_mode = std::env::var("NATIVE_DEV_URL").is_ok();
|
|
619
|
+
|
|
558
620
|
let mut builder = WebviewWindowBuilder::new(app, "main", url)
|
|
559
621
|
.title(title)
|
|
560
622
|
.inner_size(1100.0, 750.0)
|
|
@@ -566,23 +628,19 @@ fn build_window(
|
|
|
566
628
|
}
|
|
567
629
|
|
|
568
630
|
// Cookie / session persistence: give the webview a stable data store so
|
|
569
|
-
// logins survive relaunches. macOS
|
|
570
|
-
//
|
|
571
|
-
// ephemeral — a persistent cache would keep serving stale
|
|
572
|
-
|
|
631
|
+
// logins survive relaunches. macOS carries it (and the UA marker) on a
|
|
632
|
+
// custom WKWebViewConfiguration; Windows/Linux want an explicit directory.
|
|
633
|
+
// Dev mode stays ephemeral — a persistent cache would keep serving stale
|
|
634
|
+
// dev assets.
|
|
635
|
+
#[cfg(target_os = "macos")]
|
|
636
|
+
{
|
|
637
|
+
builder = builder.with_webview_configuration(macos_webview_configuration(config, dev_mode));
|
|
638
|
+
}
|
|
639
|
+
#[cfg(any(windows, target_os = "linux"))]
|
|
573
640
|
if !dev_mode {
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
builder = builder.
|
|
577
|
-
config.bundle_id.as_deref().unwrap_or("com.rubyeverywhere.app"),
|
|
578
|
-
));
|
|
579
|
-
}
|
|
580
|
-
#[cfg(any(windows, target_os = "linux"))]
|
|
581
|
-
{
|
|
582
|
-
if let Ok(dir) = app.path().data_dir() {
|
|
583
|
-
let id = config.bundle_id.clone().unwrap_or("com.rubyeverywhere.app".into());
|
|
584
|
-
builder = builder.data_directory(dir.join(id).join("webview"));
|
|
585
|
-
}
|
|
641
|
+
if let Ok(dir) = app.path().data_dir() {
|
|
642
|
+
let id = config.bundle_id.clone().unwrap_or("com.rubyeverywhere.app".into());
|
|
643
|
+
builder = builder.data_directory(dir.join(id).join("webview"));
|
|
586
644
|
}
|
|
587
645
|
}
|
|
588
646
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ruby_everywhere
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andrea Fomera
|
|
@@ -55,6 +55,7 @@ files:
|
|
|
55
55
|
- lib/everywhere/commands/build.rb
|
|
56
56
|
- lib/everywhere/commands/dev.rb
|
|
57
57
|
- lib/everywhere/commands/doctor.rb
|
|
58
|
+
- lib/everywhere/commands/icon.rb
|
|
58
59
|
- lib/everywhere/commands/install.rb
|
|
59
60
|
- lib/everywhere/commands/platform/auth_status.rb
|
|
60
61
|
- lib/everywhere/commands/platform/build.rb
|
|
@@ -65,6 +66,7 @@ files:
|
|
|
65
66
|
- lib/everywhere/config.rb
|
|
66
67
|
- lib/everywhere/database.rb
|
|
67
68
|
- lib/everywhere/framework.rb
|
|
69
|
+
- lib/everywhere/icon.rb
|
|
68
70
|
- lib/everywhere/ignore.rb
|
|
69
71
|
- lib/everywhere/javascript/bridge.js
|
|
70
72
|
- lib/everywhere/paths.rb
|
|
@@ -72,6 +74,7 @@ files:
|
|
|
72
74
|
- lib/everywhere/platform/credentials.rb
|
|
73
75
|
- lib/everywhere/platform/snapshot.rb
|
|
74
76
|
- lib/everywhere/png.rb
|
|
77
|
+
- lib/everywhere/raster.rb
|
|
75
78
|
- lib/everywhere/receipt.rb
|
|
76
79
|
- lib/everywhere/shellout.rb
|
|
77
80
|
- lib/everywhere/ui.rb
|