ruby_everywhere 0.6.0 → 0.8.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 +4 -4
- data/exe/every +2 -2
- data/exe/rbe +2 -2
- data/lib/everywhere/agents_guide.rb +3 -1
- data/lib/everywhere/blake2b.rb +17 -1
- data/lib/everywhere/boot.rb +30 -6
- data/lib/everywhere/builders/android.rb +152 -64
- data/lib/everywhere/builders/base.rb +53 -0
- data/lib/everywhere/builders/desktop.rb +32 -40
- data/lib/everywhere/builders/ios.rb +268 -36
- data/lib/everywhere/builders/native_sources.rb +38 -0
- data/lib/everywhere/child_processes.rb +4 -4
- data/lib/everywhere/child_supervision.rb +172 -0
- data/lib/everywhere/cli.rb +2 -0
- data/lib/everywhere/clock.rb +12 -0
- data/lib/everywhere/commands/build.rb +132 -66
- data/lib/everywhere/commands/clean.rb +8 -3
- data/lib/everywhere/commands/dev.rb +91 -242
- data/lib/everywhere/commands/doctor.rb +138 -21
- data/lib/everywhere/commands/install.rb +49 -8
- data/lib/everywhere/commands/platform/auth_status.rb +1 -0
- data/lib/everywhere/commands/platform/build.rb +141 -33
- data/lib/everywhere/commands/platform/login.rb +20 -6
- data/lib/everywhere/commands/platform/logout.rb +1 -0
- data/lib/everywhere/commands/platform/runner.rb +170 -25
- data/lib/everywhere/commands/preview.rb +286 -0
- data/lib/everywhere/commands/publish.rb +22 -2
- data/lib/everywhere/commands/release.rb +155 -35
- data/lib/everywhere/commands/shell_dir.rb +4 -2
- data/lib/everywhere/commands/updates_keygen.rb +25 -1
- data/lib/everywhere/config/app.rb +126 -0
- data/lib/everywhere/config/auth.rb +108 -0
- data/lib/everywhere/config/data.rb +50 -0
- data/lib/everywhere/config/deep_linking.rb +107 -0
- data/lib/everywhere/config/desktop_ui.rb +153 -0
- data/lib/everywhere/config/mobile.rb +211 -0
- data/lib/everywhere/config/native_desktop.rb +168 -0
- data/lib/everywhere/config/native_mobile.rb +337 -0
- data/lib/everywhere/config/shell.rb +57 -0
- data/lib/everywhere/config/updates.rb +63 -0
- data/lib/everywhere/config.rb +59 -1367
- data/lib/everywhere/console.rb +2 -1
- data/lib/everywhere/desktop_dev_app.rb +138 -0
- data/lib/everywhere/dock/state.rb +3 -1
- data/lib/everywhere/engine.rb +24 -0
- data/lib/everywhere/entrypoint.rb +24 -0
- data/lib/everywhere/error.rb +8 -0
- data/lib/everywhere/framework.rb +23 -6
- data/lib/everywhere/host.rb +11 -0
- data/lib/everywhere/ignore.rb +13 -5
- data/lib/everywhere/jump.rb +121 -0
- data/lib/everywhere/line_pump.rb +3 -1
- data/lib/everywhere/minisign.rb +1 -0
- data/lib/everywhere/mobile_config_endpoint.rb +47 -0
- data/lib/everywhere/mobile_configs_controller.rb +46 -0
- data/lib/everywhere/native_platform.rb +44 -0
- data/lib/everywhere/paths.rb +36 -13
- data/lib/everywhere/platform/client.rb +46 -7
- data/lib/everywhere/platform/credentials.rb +18 -8
- data/lib/everywhere/platform/snapshot.rb +12 -0
- data/lib/everywhere/plist.rb +17 -0
- data/lib/everywhere/png.rb +1 -0
- data/lib/everywhere/raw_tty.rb +51 -0
- data/lib/everywhere/receipt.rb +55 -10
- data/lib/everywhere/s3.rb +1 -0
- data/lib/everywhere/shell_pages.rb +109 -0
- data/lib/everywhere/shellout.rb +19 -0
- data/lib/everywhere/simulator.rb +12 -1
- data/lib/everywhere/tab_filter.rb +36 -0
- data/lib/everywhere/task_pool.rb +3 -4
- data/lib/everywhere/ui.rb +6 -1
- data/lib/everywhere/version.rb +6 -4
- data/lib/everywhere.rb +1 -2
- data/support/mobile/android/app/build.gradle.kts +29 -1
- data/support/mobile/ios/App/EverywhereConfig.swift +17 -5
- data/support/mobile/ios/App/SceneDelegate.swift +75 -8
- data/support/mobile/ios/App.xcodeproj/project.pbxproj +2 -4
- data/support/mobile/ios/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +0 -1
- data/support/mobile/ios/README.md +13 -6
- data/support/release/macos/notarize.sh +3 -0
- metadata +40 -1
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Everywhere
|
|
4
|
+
class << self
|
|
5
|
+
# The desktop shell's marker. WKWebView APPENDS
|
|
6
|
+
# `applicationNameForUserAgent` to the real system UA, so this arrives
|
|
7
|
+
# alongside a normal Safari UA rather than replacing it — hence a pattern
|
|
8
|
+
# rather than a prefix check. The version is absent when everywhere.yml
|
|
9
|
+
# declares none.
|
|
10
|
+
#
|
|
11
|
+
# macOS only for now: the marker is set in macos_webview_configuration, and
|
|
12
|
+
# Windows/Linux have no append-only equivalent (their user_agent() REPLACES
|
|
13
|
+
# the string, and fabricating a whole UA is worse than not marking it).
|
|
14
|
+
# Desktop only ships macOS today; revisit when it doesn't.
|
|
15
|
+
DESKTOP_UA = %r{\bRubyEverywhere(?:/[\w.\-]+)? \((?:macos|windows|linux)\)}
|
|
16
|
+
|
|
17
|
+
MOBILE_PLATFORMS = %i[ios android].freeze
|
|
18
|
+
|
|
19
|
+
# :ios, :android, :desktop or nil, from a User-Agent string. Present from
|
|
20
|
+
# the very first request, before any JS has run. Shared by the view helpers
|
|
21
|
+
# and the auth middleware.
|
|
22
|
+
#
|
|
23
|
+
# Mobile is checked FIRST and the order matters: the mobile shells prepend
|
|
24
|
+
# the same "RubyEverywhere/<version> (<os>)" marker to Hotwire Native's own,
|
|
25
|
+
# so a mobile UA matches DESKTOP_UA too.
|
|
26
|
+
def native_platform_of(user_agent)
|
|
27
|
+
ua = user_agent.to_s
|
|
28
|
+
return :ios if ua.include?("Hotwire Native iOS")
|
|
29
|
+
return :android if ua.include?("Hotwire Native Android")
|
|
30
|
+
return :desktop if ua.match?(DESKTOP_UA)
|
|
31
|
+
|
|
32
|
+
nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Just the phone shells. Anything that leans on a mobile-only affordance —
|
|
36
|
+
# the OAuth handoff to ASWebAuthenticationSession / Custom Tabs, biometric
|
|
37
|
+
# gating — has to ask for this rather than "is this a native shell", because
|
|
38
|
+
# the desktop shell answers yes to that and has neither.
|
|
39
|
+
def mobile_platform_of(user_agent)
|
|
40
|
+
platform = native_platform_of(user_agent)
|
|
41
|
+
platform if MOBILE_PLATFORMS.include?(platform)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
data/lib/everywhere/paths.rb
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "error"
|
|
4
|
+
require_relative "ui"
|
|
5
|
+
|
|
3
6
|
module Everywhere
|
|
4
7
|
# Filesystem locations the CLI resolves relative to the gem itself, so
|
|
5
8
|
# `every dev`/`build`/`release` work in any project the gem is installed into.
|
|
@@ -83,15 +86,19 @@ module Everywhere
|
|
|
83
86
|
end
|
|
84
87
|
|
|
85
88
|
# Per-user cache dir. Keeps heavy build state out of the gem and the app.
|
|
89
|
+
# `~` needs a home to expand against, and a container run with --user has
|
|
90
|
+
# neither HOME nor a passwd entry.
|
|
86
91
|
def cache_dir
|
|
87
92
|
File.expand_path(ENV["RUBYEVERYWHERE_HOME"] || "~/.rubyeverywhere")
|
|
93
|
+
rescue ArgumentError
|
|
94
|
+
UI.die!("couldn't resolve your home directory — set HOME or RUBYEVERYWHERE_HOME")
|
|
88
95
|
end
|
|
89
96
|
|
|
90
97
|
# The stamped copy of the iOS template for one app. Persistent (not a
|
|
91
98
|
# tmpdir) on purpose: a stable project path keeps Xcode's incremental state
|
|
92
99
|
# valid across builds, and users can open the stamped project in Xcode.
|
|
93
100
|
def ios_work_dir(bundle_id)
|
|
94
|
-
File.join(cache_dir, "ios", bundle_id)
|
|
101
|
+
File.join(cache_dir, "ios", segment!(bundle_id))
|
|
95
102
|
end
|
|
96
103
|
|
|
97
104
|
# Where xcodebuild writes DerivedData for iOS shell builds. Shared across
|
|
@@ -112,7 +119,7 @@ module Everywhere
|
|
|
112
119
|
# incremental state is path-sensitive, and the stamped project opens in
|
|
113
120
|
# Android Studio unmodified.
|
|
114
121
|
def android_work_dir(application_id)
|
|
115
|
-
File.join(cache_dir, "android", application_id)
|
|
122
|
+
File.join(cache_dir, "android", segment!(application_id))
|
|
116
123
|
end
|
|
117
124
|
|
|
118
125
|
# GRADLE_USER_HOME for shell builds. Redirected here so the multi-GB Gradle
|
|
@@ -130,14 +137,15 @@ module Everywhere
|
|
|
130
137
|
File.join(cache_dir, "android-fonts")
|
|
131
138
|
end
|
|
132
139
|
|
|
133
|
-
# The
|
|
134
|
-
#
|
|
135
|
-
#
|
|
136
|
-
#
|
|
137
|
-
#
|
|
138
|
-
#
|
|
140
|
+
# The staged copy of the Tauri shell for one app, keyed by bundle id.
|
|
141
|
+
# Every app builds from one of these — cargo never runs inside the
|
|
142
|
+
# installed gem, which may be root-owned — but only apps that declare
|
|
143
|
+
# `native.desktop` get their Rust stamped in; everyone else's copy is
|
|
144
|
+
# identical to the template and shares cargo_target_dir so the compile
|
|
145
|
+
# stays warm across projects. Persistent for the same reason the mobile
|
|
146
|
+
# work dirs are: a cold Tauri build is minutes where a warm one is seconds.
|
|
139
147
|
def desktop_work_dir(bundle_id)
|
|
140
|
-
File.join(cache_dir, "desktop", bundle_id)
|
|
148
|
+
File.join(cache_dir, "desktop", segment!(bundle_id))
|
|
141
149
|
end
|
|
142
150
|
|
|
143
151
|
# CARGO_TARGET_DIR for a stamped shell. Per-app rather than shared, because
|
|
@@ -145,7 +153,7 @@ module Everywhere
|
|
|
145
153
|
# every other app's — pointing them at one target dir would make cargo
|
|
146
154
|
# rebuild the world on every alternating build.
|
|
147
155
|
def desktop_target_dir(bundle_id)
|
|
148
|
-
File.join(cache_dir, "desktop-target", bundle_id)
|
|
156
|
+
File.join(cache_dir, "desktop-target", segment!(bundle_id))
|
|
149
157
|
end
|
|
150
158
|
|
|
151
159
|
# Where `every dev` keeps the throwaway .app wrapper it runs the shell
|
|
@@ -154,14 +162,14 @@ module Everywhere
|
|
|
154
162
|
# menu bar, the About/Hide/Quit items and the Dock. The wrapper exists only
|
|
155
163
|
# to carry an Info.plist; the binary inside it is a hard link to cargo's.
|
|
156
164
|
def desktop_dev_app_dir(bundle_id)
|
|
157
|
-
File.join(cache_dir, "desktop-app", bundle_id)
|
|
165
|
+
File.join(cache_dir, "desktop-app", segment!(bundle_id))
|
|
158
166
|
end
|
|
159
167
|
|
|
160
168
|
# Where `every dev` stages the app's Dock icon for the running shell. The
|
|
161
169
|
# shaped master is derived from app.icon, so it's a build product, not the
|
|
162
170
|
# app's own file — it belongs in the cache next to the rest of them.
|
|
163
171
|
def desktop_icon_dir(bundle_id)
|
|
164
|
-
File.join(cache_dir, "desktop-icon", bundle_id)
|
|
172
|
+
File.join(cache_dir, "desktop-icon", segment!(bundle_id))
|
|
165
173
|
end
|
|
166
174
|
|
|
167
175
|
# Where `every dev` stages native/desktop/assets for the running shell.
|
|
@@ -171,7 +179,7 @@ module Everywhere
|
|
|
171
179
|
# in dev. Staging runs the same DesktopAssets compiler both ways, so the two
|
|
172
180
|
# can't drift. Keyed by bundle id like the mobile work dirs.
|
|
173
181
|
def desktop_assets_dir(bundle_id)
|
|
174
|
-
File.join(cache_dir, "desktop-assets", bundle_id)
|
|
182
|
+
File.join(cache_dir, "desktop-assets", segment!(bundle_id))
|
|
175
183
|
end
|
|
176
184
|
|
|
177
185
|
# Where cargo writes the shell's build output (CARGO_TARGET_DIR) for apps on
|
|
@@ -192,5 +200,20 @@ module Everywhere
|
|
|
192
200
|
def app_bundle_dir
|
|
193
201
|
File.join(cache_dir, "bundle")
|
|
194
202
|
end
|
|
203
|
+
|
|
204
|
+
# Bundle ids and application ids come from the app's everywhere.yml and are
|
|
205
|
+
# used as ONE path segment under the cache — directories the builders rm_rf
|
|
206
|
+
# wholesale. Config#identity_errors rejects a malformed id before a build
|
|
207
|
+
# starts; this is the backstop for every other caller. Raise, not die!:
|
|
208
|
+
# Paths is reachable from the packaged app's runtime.
|
|
209
|
+
def segment!(value)
|
|
210
|
+
segment = value.to_s
|
|
211
|
+
if segment.empty? || segment.include?("/") || segment.include?("\\") || segment.include?("..")
|
|
212
|
+
raise Everywhere::Error, "#{segment.inspect} can't be a directory name (app.bundle_id in " \
|
|
213
|
+
"config/everywhere.yml must be reverse-DNS, like com.example.app)"
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
segment
|
|
217
|
+
end
|
|
195
218
|
end
|
|
196
219
|
end
|
|
@@ -3,6 +3,12 @@
|
|
|
3
3
|
require "net/http"
|
|
4
4
|
require "json"
|
|
5
5
|
require "uri"
|
|
6
|
+
begin
|
|
7
|
+
require "openssl" # optional stdlib; NETWORK_ERRORS below adapts if it's absent
|
|
8
|
+
rescue LoadError
|
|
9
|
+
nil
|
|
10
|
+
end
|
|
11
|
+
require_relative "../error"
|
|
6
12
|
require_relative "../version"
|
|
7
13
|
|
|
8
14
|
module Everywhere
|
|
@@ -27,6 +33,19 @@ module Everywhere
|
|
|
27
33
|
end
|
|
28
34
|
|
|
29
35
|
class Client
|
|
36
|
+
# Everything a flaky link, a hung host or a TLS-intercepting proxy can
|
|
37
|
+
# throw at us. OpenSSL is an optional stdlib (net/http requires it
|
|
38
|
+
# best-effort), so its error class is only listed when it loaded.
|
|
39
|
+
NETWORK_ERRORS = [
|
|
40
|
+
SocketError, EOFError,
|
|
41
|
+
Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::ETIMEDOUT,
|
|
42
|
+
Errno::EHOSTUNREACH, Errno::ENETUNREACH, Errno::EPIPE,
|
|
43
|
+
Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout,
|
|
44
|
+
*(defined?(OpenSSL::SSL::SSLError) ? [OpenSSL::SSL::SSLError] : [])
|
|
45
|
+
].freeze
|
|
46
|
+
|
|
47
|
+
OPEN_TIMEOUT = 15
|
|
48
|
+
|
|
30
49
|
def self.base_url(explicit = nil)
|
|
31
50
|
(explicit || ENV["EVERYWHERE_PLATFORM_URL"] || DEFAULT_URL).chomp("/")
|
|
32
51
|
end
|
|
@@ -56,8 +75,8 @@ module Everywhere
|
|
|
56
75
|
req.body_stream = io
|
|
57
76
|
http(uri) { |conn| conn.request(req) }
|
|
58
77
|
end
|
|
59
|
-
rescue
|
|
60
|
-
raise Error, "upload to #{uri.host} failed (#{e.class})"
|
|
78
|
+
rescue *NETWORK_ERRORS => e
|
|
79
|
+
raise Error, "upload to #{uri.host} failed (#{e.class}: #{e.message})"
|
|
61
80
|
end
|
|
62
81
|
|
|
63
82
|
# GET a (possibly redirecting, signed) URL and stream it to dest. Sends the
|
|
@@ -74,7 +93,7 @@ module Everywhere
|
|
|
74
93
|
when Net::HTTPRedirection
|
|
75
94
|
raise Error, "too many redirects downloading #{url}" if redirects <= 0
|
|
76
95
|
|
|
77
|
-
return download(res["location"], dest, redirects: redirects - 1)
|
|
96
|
+
return download(redirect_target(uri, res["location"]), dest, redirects: redirects - 1)
|
|
78
97
|
when Net::HTTPSuccess
|
|
79
98
|
File.open(dest, "wb") { |f| res.read_body { |chunk| f.write(chunk) } }
|
|
80
99
|
else
|
|
@@ -83,12 +102,32 @@ module Everywhere
|
|
|
83
102
|
end
|
|
84
103
|
end
|
|
85
104
|
dest
|
|
105
|
+
rescue *NETWORK_ERRORS => e
|
|
106
|
+
raise Error, "download from #{uri.host} failed (#{e.class}: #{e.message})"
|
|
86
107
|
end
|
|
87
108
|
|
|
88
109
|
private
|
|
89
110
|
|
|
111
|
+
# A signed-blob redirect is expected to leave the platform host, but it
|
|
112
|
+
# may never leave TLS: an http:// hop would put the artifact — and the
|
|
113
|
+
# snapshot or credentials inside it — on the wire in the clear. Only a
|
|
114
|
+
# base that was already http (`every platform --url http://localhost`)
|
|
115
|
+
# has nothing to downgrade from. Location is resolved against the current
|
|
116
|
+
# hop, which is also what makes a relative one work.
|
|
117
|
+
def redirect_target(from, location)
|
|
118
|
+
target = URI.join(from, location.to_s)
|
|
119
|
+
if from.scheme == "https" && target.scheme != "https"
|
|
120
|
+
raise Error, "refusing an https → #{target.scheme} redirect to #{target.host}"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
target.to_s
|
|
124
|
+
rescue URI::Error => e
|
|
125
|
+
raise Error, "bad redirect from #{from.host} (#{e.message})"
|
|
126
|
+
end
|
|
127
|
+
|
|
90
128
|
def http(uri, &block)
|
|
91
|
-
Net::HTTP.start(uri.hostname, uri.port,
|
|
129
|
+
Net::HTTP.start(uri.hostname, uri.port,
|
|
130
|
+
use_ssl: uri.scheme == "https", open_timeout: OPEN_TIMEOUT, &block)
|
|
92
131
|
end
|
|
93
132
|
|
|
94
133
|
def request(klass, path, body = nil)
|
|
@@ -102,10 +141,10 @@ module Everywhere
|
|
|
102
141
|
req.body = JSON.generate(body)
|
|
103
142
|
end
|
|
104
143
|
|
|
105
|
-
res =
|
|
144
|
+
res = http(uri) { |conn| conn.request(req) }
|
|
106
145
|
Response.new(res.code.to_i, parse(res.body))
|
|
107
|
-
rescue
|
|
108
|
-
raise Error, "couldn't reach the Platform at #{@base_url} (#{e.class})"
|
|
146
|
+
rescue *NETWORK_ERRORS => e
|
|
147
|
+
raise Error, "couldn't reach the Platform at #{@base_url} (#{e.class}: #{e.message})"
|
|
109
148
|
end
|
|
110
149
|
|
|
111
150
|
def parse(body)
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "fileutils"
|
|
5
|
+
require_relative "../ui"
|
|
5
6
|
|
|
6
7
|
module Everywhere
|
|
7
8
|
module Platform
|
|
@@ -22,15 +23,20 @@ module Everywhere
|
|
|
22
23
|
end
|
|
23
24
|
|
|
24
25
|
def self.load
|
|
25
|
-
data =
|
|
26
|
-
if File.exist?(path)
|
|
27
|
-
JSON.parse(File.read(path)) rescue {}
|
|
28
|
-
else
|
|
29
|
-
{}
|
|
30
|
-
end
|
|
26
|
+
data = File.exist?(path) ? read_or_warn(path) : {}
|
|
31
27
|
new(data.is_a?(Hash) ? data : {})
|
|
32
28
|
end
|
|
33
29
|
|
|
30
|
+
# A corrupt store used to look identical to "logged out", which sends
|
|
31
|
+
# people re-running login forever. Say which file to delete.
|
|
32
|
+
def self.read_or_warn(path)
|
|
33
|
+
JSON.parse(File.read(path))
|
|
34
|
+
rescue JSON::ParserError, SystemCallError, IOError => e
|
|
35
|
+
Everywhere::UI.warn("ignoring unreadable credentials at #{path} (#{e.class}) — " \
|
|
36
|
+
"delete it and run `every platform login`")
|
|
37
|
+
{}
|
|
38
|
+
end
|
|
39
|
+
|
|
34
40
|
def initialize(data)
|
|
35
41
|
@data = data
|
|
36
42
|
end
|
|
@@ -63,8 +69,12 @@ module Everywhere
|
|
|
63
69
|
dir = File.dirname(path)
|
|
64
70
|
FileUtils.mkdir_p(dir)
|
|
65
71
|
File.chmod(0o700, dir)
|
|
66
|
-
|
|
67
|
-
|
|
72
|
+
# Create it 0600 rather than write-then-chmod: the token must never exist
|
|
73
|
+
# on disk world-readable, even for an instant.
|
|
74
|
+
File.open(path, File::WRONLY | File::CREAT | File::TRUNC, 0o600) do |f|
|
|
75
|
+
f.write("#{JSON.pretty_generate(@data)}\n")
|
|
76
|
+
end
|
|
77
|
+
File.chmod(0o600, path) # the mode above only applies on create; tighten stores from older versions
|
|
68
78
|
end
|
|
69
79
|
end
|
|
70
80
|
end
|
|
@@ -4,6 +4,7 @@ require "rubygems/package"
|
|
|
4
4
|
require "zlib"
|
|
5
5
|
require "digest"
|
|
6
6
|
require_relative "../ignore"
|
|
7
|
+
require_relative "../ui"
|
|
7
8
|
|
|
8
9
|
module Everywhere
|
|
9
10
|
module Platform
|
|
@@ -38,10 +39,21 @@ module Everywhere
|
|
|
38
39
|
|
|
39
40
|
def add(tar, root, rel)
|
|
40
41
|
abs = File.join(root, rel)
|
|
42
|
+
# lstat, and never follow: File.stat would read through the link, so a
|
|
43
|
+
# dangling one (a deleted bundle path, a checked-in link to an absent
|
|
44
|
+
# sibling repo) raises Errno::ENOENT and aborts the whole snapshot —
|
|
45
|
+
# and a LIVE one pointing outside the tree (link → ~/.ssh/id_rsa) would
|
|
46
|
+
# pack and upload its target's contents under an innocent name.
|
|
47
|
+
return Everywhere::UI.warn("skipping symlink #{rel}") if File.lstat(abs).symlink?
|
|
48
|
+
|
|
41
49
|
stat = File.stat(abs)
|
|
42
50
|
tar.add_file_simple(rel, stat.mode & 0o777, stat.size) do |io|
|
|
43
51
|
File.open(abs, "rb") { |f| IO.copy_stream(f, io) }
|
|
44
52
|
end
|
|
53
|
+
rescue Errno::ENOENT, Errno::EACCES => e
|
|
54
|
+
# The listing is a snapshot in time; a dev server or watcher can delete a
|
|
55
|
+
# tmp/ file between the glob and here. Losing it beats losing the build.
|
|
56
|
+
Everywhere::UI.warn("skipping #{rel} (#{e.class.name.split("::").last})")
|
|
45
57
|
end
|
|
46
58
|
end
|
|
47
59
|
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Everywhere
|
|
4
|
+
# Escaping for the plists and entitlements the CLI writes by hand (there are
|
|
5
|
+
# four: `every build`'s Info.plist, `every dev`'s dev bundle, and the iOS
|
|
6
|
+
# builder's entitlements + export options). Every value interpolated into them
|
|
7
|
+
# comes from everywhere.yml or from Apple, so an unescaped `&` or `<` is at
|
|
8
|
+
# best a plist macOS refuses to parse and at worst extra keys of someone
|
|
9
|
+
# else's choosing.
|
|
10
|
+
module Plist
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def escape(value)
|
|
14
|
+
value.to_s.gsub("&", "&").gsub("<", "<").gsub(">", ">")
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
data/lib/everywhere/png.rb
CHANGED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "shellout"
|
|
4
|
+
|
|
5
|
+
module Everywhere
|
|
6
|
+
# A terminal that reports keys as they're pressed, put back the way it was
|
|
7
|
+
# found on the way out.
|
|
8
|
+
#
|
|
9
|
+
# io/console's getch holds full raw mode while blocked, which clears OPOST
|
|
10
|
+
# and stair-steps every log line we relay; stty -icanon -echo disables only
|
|
11
|
+
# line buffering and echo, leaving output processing (and Ctrl-C as SIGINT)
|
|
12
|
+
# intact.
|
|
13
|
+
module RawTty
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def with
|
|
17
|
+
# stty is POSIX-only, and the non-pty spawn path otherwise works on
|
|
18
|
+
# Windows: without it the menu simply stays line-buffered (press d,
|
|
19
|
+
# then Enter) rather than the session dying on Errno::ENOENT.
|
|
20
|
+
saved_tty = saved_tty_state
|
|
21
|
+
system("stty", "-icanon", "-echo") if saved_tty
|
|
22
|
+
trap_exit_signals
|
|
23
|
+
yield
|
|
24
|
+
ensure
|
|
25
|
+
system("stty", saved_tty) if saved_tty
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# The tty settings to put back on the way out, or nil when there's no
|
|
29
|
+
# usable stty to read them with — the one signal both the raw-mode setup
|
|
30
|
+
# and its restore key off.
|
|
31
|
+
def saved_tty_state
|
|
32
|
+
return nil unless Shellout.tool?("stty")
|
|
33
|
+
|
|
34
|
+
state = `stty -g`.chomp
|
|
35
|
+
state.empty? ? nil : state
|
|
36
|
+
rescue StandardError
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The dock hides the cursor and the key loop puts the tty in -icanon
|
|
41
|
+
# -echo; a SIGTERM that skipped our ensure blocks would leave the user's
|
|
42
|
+
# shell in that state. exit(1) from a trap unwinds normally, which is all
|
|
43
|
+
# this needs to do — a handler must stay this small, because anything that
|
|
44
|
+
# takes a lock (Console, the dock) raises ThreadError in trap context.
|
|
45
|
+
def trap_exit_signals
|
|
46
|
+
%w[TERM HUP].each { |sig| Signal.trap(sig) { exit(1) } }
|
|
47
|
+
rescue ArgumentError
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
data/lib/everywhere/receipt.rb
CHANGED
|
@@ -5,6 +5,7 @@ require "digest"
|
|
|
5
5
|
require_relative "version"
|
|
6
6
|
require_relative "shellout"
|
|
7
7
|
require_relative "ignore"
|
|
8
|
+
require_relative "paths"
|
|
8
9
|
|
|
9
10
|
module Everywhere
|
|
10
11
|
# Builds the machine-readable build receipt (release.json) — the same shape
|
|
@@ -22,14 +23,20 @@ module Everywhere
|
|
|
22
23
|
class Receipt
|
|
23
24
|
SCHEMA = 1
|
|
24
25
|
|
|
25
|
-
|
|
26
|
+
# source_checksum/lockfile_checksum are optional so a caller building one
|
|
27
|
+
# receipt per target hashes the tree ONCE and threads the digest through the
|
|
28
|
+
# rest — they describe the source, which is identical for every target.
|
|
29
|
+
def initialize(root:, config:, framework:, ruby:, target:, channel:, shell_dir: nil,
|
|
30
|
+
source_checksum: nil, lockfile_checksum: nil)
|
|
26
31
|
@root = File.expand_path(root)
|
|
27
32
|
@config = config
|
|
28
33
|
@framework = framework
|
|
29
34
|
@ruby = ruby
|
|
30
|
-
@target_str = target #
|
|
35
|
+
@target_str = target # bare "os-arch" — selects platforms: overrides
|
|
31
36
|
@target = parse_target(target, channel)
|
|
32
37
|
@shell_dir = shell_dir
|
|
38
|
+
@source_checksum = source_checksum
|
|
39
|
+
@lockfile_checksum = lockfile_checksum
|
|
33
40
|
end
|
|
34
41
|
|
|
35
42
|
# The deterministic build request.
|
|
@@ -48,7 +55,9 @@ module Everywhere
|
|
|
48
55
|
"lockfile_checksum" => lockfile_checksum
|
|
49
56
|
},
|
|
50
57
|
"target" => @target,
|
|
51
|
-
"permissions" => @config.permissions
|
|
58
|
+
"permissions" => @config.permissions,
|
|
59
|
+
"mode" => @config.mode,
|
|
60
|
+
"remote" => { "url" => @config.remote_url }.compact
|
|
52
61
|
}
|
|
53
62
|
end
|
|
54
63
|
|
|
@@ -105,10 +114,15 @@ module Everywhere
|
|
|
105
114
|
"cli" => Everywhere::VERSION,
|
|
106
115
|
"bridge" => bridge_version,
|
|
107
116
|
"shell" => shell_version,
|
|
108
|
-
|
|
117
|
+
# Only macOS targets are tebako-pressed; spawning rbe-tebako for a
|
|
118
|
+
# mobile receipt would cost a process to learn nothing.
|
|
119
|
+
"tebako" => macos? ? tebako_version : nil,
|
|
120
|
+
"hotwire_native" => hotwire_native_version
|
|
109
121
|
}
|
|
110
122
|
end
|
|
111
123
|
|
|
124
|
+
def macos? = @target["os"] == "macos"
|
|
125
|
+
|
|
112
126
|
def bridge_version
|
|
113
127
|
# Package installs: read the version straight from package.json.
|
|
114
128
|
[
|
|
@@ -145,11 +159,40 @@ module Everywhere
|
|
|
145
159
|
out[/version\s+([\d.]+)/i, 1]
|
|
146
160
|
end
|
|
147
161
|
|
|
162
|
+
# Which Hotwire Native the bundled mobile template pins. Read straight off
|
|
163
|
+
# the frozen templates in the gem (no spawn): iOS from the SPM lockfile,
|
|
164
|
+
# Android from the Gradle dependency line. Best-effort — a receipt is worth
|
|
165
|
+
# writing even when the template is missing or reshaped.
|
|
166
|
+
def hotwire_native_version
|
|
167
|
+
case @target["os"]
|
|
168
|
+
when "ios" then hotwire_native_ios_revision
|
|
169
|
+
when "android" then hotwire_native_android_version
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def hotwire_native_ios_revision
|
|
174
|
+
dir = Paths.ios_dir or return nil
|
|
175
|
+
resolved = File.join(dir, "App.xcodeproj", "project.xcworkspace", "xcshareddata", "swiftpm", "Package.resolved")
|
|
176
|
+
pin = JSON.parse(File.read(resolved))["pins"].find { |p| p["identity"].to_s.include?("hotwire-native-ios") }
|
|
177
|
+
pin&.dig("state", "revision")&.slice(0, 12)
|
|
178
|
+
rescue StandardError
|
|
179
|
+
nil
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def hotwire_native_android_version
|
|
183
|
+
dir = Paths.android_dir or return nil
|
|
184
|
+
File.read(File.join(dir, "app", "build.gradle.kts"))[/dev\.hotwire:core:([\d.]+)/, 1]
|
|
185
|
+
rescue StandardError
|
|
186
|
+
nil
|
|
187
|
+
end
|
|
188
|
+
|
|
148
189
|
# ---- checksums -----------------------------------------------------------
|
|
149
190
|
|
|
150
191
|
def lockfile_checksum
|
|
151
|
-
|
|
152
|
-
|
|
192
|
+
@lockfile_checksum ||= begin
|
|
193
|
+
lock = File.join(@root, "Gemfile.lock")
|
|
194
|
+
File.exist?(lock) ? "sha256:#{Digest::SHA256.file(lock).hexdigest}" : nil
|
|
195
|
+
end
|
|
153
196
|
end
|
|
154
197
|
|
|
155
198
|
def lockfile_gem_version(gem_name)
|
|
@@ -164,11 +207,13 @@ module Everywhere
|
|
|
164
207
|
# Uses the SAME ignore rules as the (future) snapshot, so the checksum
|
|
165
208
|
# describes exactly what ships.
|
|
166
209
|
def source_checksum
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
210
|
+
@source_checksum ||= begin
|
|
211
|
+
digest = Digest::SHA256.new
|
|
212
|
+
Ignore.for(@root).files(@root).each do |rel|
|
|
213
|
+
digest << rel << "\0" << Digest::SHA256.file(File.join(@root, rel)).hexdigest << "\n"
|
|
214
|
+
end
|
|
215
|
+
"sha256:#{digest.hexdigest}"
|
|
170
216
|
end
|
|
171
|
-
"sha256:#{digest.hexdigest}"
|
|
172
217
|
end
|
|
173
218
|
|
|
174
219
|
# ---- helpers -------------------------------------------------------------
|
data/lib/everywhere/s3.rb
CHANGED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Everywhere
|
|
4
|
+
class << self
|
|
5
|
+
# The tiny page served at /everywhere/reset. Auth flows redirect the native
|
|
6
|
+
# app here so it resets cleanly (fresh web views, re-fetched tabs) before
|
|
7
|
+
# continuing — the standard Hotwire Native "reset the app" pattern. It talks
|
|
8
|
+
# to the shell's control channel directly (no bridge/importmap dependency);
|
|
9
|
+
# in a plain browser it just forwards to the target. `to` is constrained to
|
|
10
|
+
# a same-origin path.
|
|
11
|
+
#
|
|
12
|
+
# Both shells are addressed inline, and differently, for the same reason
|
|
13
|
+
# the bridge normalizes them: WKWebView's message handler takes an object,
|
|
14
|
+
# while Android's WebMessageListener channel takes a string. Reaching for
|
|
15
|
+
# the bridge here instead would trade that one line for an importmap
|
|
16
|
+
# dependency on a page whose whole job is to work before anything loads.
|
|
17
|
+
def mobile_reset_html(to)
|
|
18
|
+
require "json"
|
|
19
|
+
target = to.to_s
|
|
20
|
+
target = "/" unless target.start_with?("/") && !target.start_with?("//")
|
|
21
|
+
# script_safe, not to_json: this lands inside <script>, and outside Rails
|
|
22
|
+
# nothing escapes "</script>" in a plain JSON string.
|
|
23
|
+
encoded = JSON.generate(target, script_safe: true)
|
|
24
|
+
|
|
25
|
+
<<~HTML
|
|
26
|
+
<!DOCTYPE html><html><head><meta charset="utf-8"><title>One moment…</title>
|
|
27
|
+
<meta name="viewport" content="width=device-width,initial-scale=1"></head>
|
|
28
|
+
<body><script>
|
|
29
|
+
(function(){var to=#{encoded};var msg={action:"reset",to:to};
|
|
30
|
+
var ios=window.webkit&&window.webkit.messageHandlers&&window.webkit.messageHandlers.everywhereControl;
|
|
31
|
+
var android=window.everywhereControl;
|
|
32
|
+
if(ios){ios.postMessage(msg);}
|
|
33
|
+
else if(android&&android.postMessage){android.postMessage(JSON.stringify(msg));}
|
|
34
|
+
else{window.location.replace(to);}})();
|
|
35
|
+
</script></body></html>
|
|
36
|
+
HTML
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The page served at /everywhere/jump/leave — the way OUT of a Jump
|
|
40
|
+
# preview. Once Jump re-roots onto a previewed app (instance.set), every
|
|
41
|
+
# Jump surface — launcher, scanner, tabs — resolves against the previewed
|
|
42
|
+
# app, so the escape hatch has to be served by the previewed app itself:
|
|
43
|
+
# this gem is the one thing guaranteed to be there. Posts clearInstance on
|
|
44
|
+
# the shell's control channel; shells that aren't multi-instance ignore
|
|
45
|
+
# it, and a plain browser just goes home.
|
|
46
|
+
# Fires clearInstance only once VISIBLE. This page rides in the previewed
|
|
47
|
+
# app's tab bar; shells preload tabs eagerly, but a preloaded webview is
|
|
48
|
+
# offscreen and reports visibilityState "hidden" — firing on load would
|
|
49
|
+
# bounce every preview the instant it connects, and a tap on the tab
|
|
50
|
+
# never re-proposes a visit, so visibility flipping is the ONLY signal a
|
|
51
|
+
# tab tap gives the page. The button is a belt for browsers and any
|
|
52
|
+
# webview that lies about visibility.
|
|
53
|
+
def mobile_jump_leave_html
|
|
54
|
+
<<~HTML
|
|
55
|
+
<!DOCTYPE html><html><head><meta charset="utf-8"><title>Jump</title>
|
|
56
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
57
|
+
<style>
|
|
58
|
+
body{font-family:-apple-system,system-ui,sans-serif;margin:0;min-height:100vh;
|
|
59
|
+
display:flex;flex-direction:column;align-items:center;justify-content:center;
|
|
60
|
+
gap:12px;padding:24px;text-align:center;background:#fff;color:#171717}
|
|
61
|
+
@media(prefers-color-scheme:dark){body{background:#171717;color:#fafafa}}
|
|
62
|
+
p{margin:0;opacity:.6}
|
|
63
|
+
button{font:inherit;font-weight:600;font-size:18px;color:#fff;background:#dc2626;
|
|
64
|
+
border:0;border-radius:12px;padding:16px 32px}
|
|
65
|
+
</style></head>
|
|
66
|
+
<body>
|
|
67
|
+
<p>Returning to Jump…</p>
|
|
68
|
+
<button onclick="everywhereJumpLeave()">Return to Jump</button>
|
|
69
|
+
<script>
|
|
70
|
+
function everywhereJumpLeave(){var msg={action:"clearInstance"};
|
|
71
|
+
var ios=window.webkit&&window.webkit.messageHandlers&&window.webkit.messageHandlers.everywhereControl;
|
|
72
|
+
var android=window.everywhereControl;
|
|
73
|
+
if(ios){ios.postMessage(msg);}
|
|
74
|
+
else if(android&&android.postMessage){android.postMessage(JSON.stringify(msg));}
|
|
75
|
+
else{window.location.replace("/");}}
|
|
76
|
+
if(document.visibilityState==="visible"){everywhereJumpLeave();}
|
|
77
|
+
else{document.addEventListener("visibilitychange",function(){
|
|
78
|
+
if(document.visibilityState==="visible"){everywhereJumpLeave();}});}
|
|
79
|
+
</script></body></html>
|
|
80
|
+
HTML
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# The page served at /everywhere/auth/native. The shell normally diverts a
|
|
84
|
+
# provider path natively, before the request is ever made; this covers the
|
|
85
|
+
# visits it can't see — a `data-turbo="false"` link, or the POST OmniAuth 2
|
|
86
|
+
# requires — by asking the shell, from the page, to open the auth session.
|
|
87
|
+
# In a browser it just continues to the provider.
|
|
88
|
+
def mobile_auth_html(to)
|
|
89
|
+
require "json"
|
|
90
|
+
target = to.to_s
|
|
91
|
+
target = "/" unless target.start_with?("/") && !target.start_with?("//")
|
|
92
|
+
# script_safe, as in mobile_reset_html: `to` is request input.
|
|
93
|
+
encoded = JSON.generate(target, script_safe: true)
|
|
94
|
+
|
|
95
|
+
<<~HTML
|
|
96
|
+
<!DOCTYPE html><html><head><meta charset="utf-8"><title>Signing in…</title>
|
|
97
|
+
<meta name="viewport" content="width=device-width,initial-scale=1"></head>
|
|
98
|
+
<body><p>Opening secure sign-in…</p><script>
|
|
99
|
+
(function(){var to=#{encoded};var msg={action:"authFlow",to:to};
|
|
100
|
+
var ios=window.webkit&&window.webkit.messageHandlers&&window.webkit.messageHandlers.everywhereControl;
|
|
101
|
+
var android=window.everywhereControl;
|
|
102
|
+
if(ios){ios.postMessage(msg);}
|
|
103
|
+
else if(android&&android.postMessage){android.postMessage(JSON.stringify(msg));}
|
|
104
|
+
else{window.location.replace(to);}})();
|
|
105
|
+
</script></body></html>
|
|
106
|
+
HTML
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|