ruby_everywhere 0.7.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.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/exe/every +2 -13
  3. data/exe/rbe +2 -13
  4. data/lib/everywhere/boot.rb +9 -2
  5. data/lib/everywhere/builders/android.rb +16 -42
  6. data/lib/everywhere/builders/base.rb +53 -0
  7. data/lib/everywhere/builders/desktop.rb +7 -23
  8. data/lib/everywhere/builders/ios.rb +49 -52
  9. data/lib/everywhere/builders/native_sources.rb +38 -0
  10. data/lib/everywhere/child_processes.rb +4 -4
  11. data/lib/everywhere/child_supervision.rb +172 -0
  12. data/lib/everywhere/clock.rb +12 -0
  13. data/lib/everywhere/commands/build.rb +20 -9
  14. data/lib/everywhere/commands/dev.rb +59 -284
  15. data/lib/everywhere/commands/doctor.rb +6 -6
  16. data/lib/everywhere/commands/install.rb +1 -0
  17. data/lib/everywhere/commands/platform/auth_status.rb +1 -0
  18. data/lib/everywhere/commands/platform/build.rb +5 -6
  19. data/lib/everywhere/commands/platform/login.rb +4 -4
  20. data/lib/everywhere/commands/platform/logout.rb +1 -0
  21. data/lib/everywhere/commands/platform/runner.rb +62 -7
  22. data/lib/everywhere/commands/preview.rb +24 -97
  23. data/lib/everywhere/commands/publish.rb +2 -0
  24. data/lib/everywhere/commands/release.rb +14 -11
  25. data/lib/everywhere/commands/shell_dir.rb +2 -0
  26. data/lib/everywhere/commands/updates_keygen.rb +25 -1
  27. data/lib/everywhere/config/app.rb +126 -0
  28. data/lib/everywhere/config/auth.rb +108 -0
  29. data/lib/everywhere/config/data.rb +50 -0
  30. data/lib/everywhere/config/deep_linking.rb +107 -0
  31. data/lib/everywhere/config/desktop_ui.rb +153 -0
  32. data/lib/everywhere/config/mobile.rb +211 -0
  33. data/lib/everywhere/config/native_desktop.rb +168 -0
  34. data/lib/everywhere/config/native_mobile.rb +337 -0
  35. data/lib/everywhere/config/shell.rb +57 -0
  36. data/lib/everywhere/config/updates.rb +63 -0
  37. data/lib/everywhere/config.rb +30 -1423
  38. data/lib/everywhere/desktop_dev_app.rb +138 -0
  39. data/lib/everywhere/dock/state.rb +3 -1
  40. data/lib/everywhere/entrypoint.rb +24 -0
  41. data/lib/everywhere/error.rb +8 -0
  42. data/lib/everywhere/framework.rb +2 -2
  43. data/lib/everywhere/host.rb +11 -0
  44. data/lib/everywhere/ignore.rb +11 -5
  45. data/lib/everywhere/line_pump.rb +3 -1
  46. data/lib/everywhere/minisign.rb +1 -0
  47. data/lib/everywhere/native_platform.rb +44 -0
  48. data/lib/everywhere/paths.rb +23 -7
  49. data/lib/everywhere/platform/client.rb +19 -1
  50. data/lib/everywhere/platform/snapshot.rb +6 -4
  51. data/lib/everywhere/plist.rb +17 -0
  52. data/lib/everywhere/png.rb +1 -0
  53. data/lib/everywhere/raw_tty.rb +51 -0
  54. data/lib/everywhere/s3.rb +1 -0
  55. data/lib/everywhere/shell_pages.rb +109 -0
  56. data/lib/everywhere/tab_filter.rb +36 -0
  57. data/lib/everywhere/task_pool.rb +3 -4
  58. data/lib/everywhere/ui.rb +6 -1
  59. data/lib/everywhere/version.rb +6 -4
  60. data/lib/everywhere.rb +1 -2
  61. data/support/mobile/ios/App.xcodeproj/project.pbxproj +0 -2
  62. data/support/release/macos/notarize.sh +3 -0
  63. metadata +24 -1
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "shellwords"
5
+ require_relative "desktop_assets"
6
+ require_relative "host"
7
+ require_relative "icon"
8
+ require_relative "paths"
9
+ require_relative "plist"
10
+ require_relative "ui"
11
+
12
+ module Everywhere
13
+ # What `every dev` stages under ~/.rubyeverywhere so a live `cargo run` shell
14
+ # looks like the app being built: the flat assets folder, the Dock icon, and
15
+ # on macOS a throwaway .app around the dev binary — plus the command that
16
+ # links the two together and launches it.
17
+ #
18
+ # None of this is packaging. `every build` writes the real, complete bundle;
19
+ # this is the subset a dev session needs, restaged on every launch so
20
+ # editing an asset and pressing `d` is enough to see it.
21
+ class DesktopDevApp
22
+ def initialize(config:)
23
+ @config = config
24
+ end
25
+
26
+ # native/desktop/assets/** → the staged flat folder the running shell
27
+ # reads. Runs the same compiler `every build` does, so an asset that
28
+ # resolves in dev resolves in the packaged app too. Returns
29
+ # [dir, errors]; the dir is returned even when the app ships no assets,
30
+ # because the shell only has to cope with it not existing.
31
+ def stage_desktop_assets
32
+ dest = Everywhere::Paths.desktop_assets_dir(@config.bundle_id)
33
+ source = File.join(@config.root, "native", "desktop", "assets")
34
+ _names, errors = Everywhere::DesktopAssets.write!(source, dest)
35
+ [dest, errors]
36
+ end
37
+
38
+ # A minimal .app around the dev binary, so macOS has a CFBundleName to
39
+ # read. Without it every menu that names the app says "example-shell" —
40
+ # the crate name — because an unbundled process has nothing else to go on.
41
+ # (Setting NSProcessInfo's processName at runtime does NOT fix this: the
42
+ # menu bar and the predefined About/Hide/Quit items are built from the
43
+ # bundle, and macOS has already decided by the time any Rust runs.)
44
+ #
45
+ # Only the plist matters here. No icon is stamped: the shell sets the Dock
46
+ # icon at runtime from NATIVE_ICON_PATH, because Tauri applies its own
47
+ # compiled-in icon after launch and would win over a bundle icns anyway.
48
+ # `every build` writes the real, complete plist — this is the dev subset.
49
+ def stage_dev_bundle
50
+ app = File.join(Everywhere::Paths.desktop_dev_app_dir(@config.bundle_id),
51
+ "#{dev_bundle_name}.app")
52
+ macos = File.join(app, "Contents", "MacOS")
53
+ FileUtils.mkdir_p(macos)
54
+ File.write(File.join(app, "Contents", "Info.plist"), dev_info_plist)
55
+ [app, File.join(macos, dev_exe_name)]
56
+ end
57
+
58
+ # Display names are free-form (spaces, punctuation, emoji all fine), but
59
+ # this one becomes a directory name — so the separators, and a leading dot
60
+ # that would hide or escape the bundle, are the only things taken out.
61
+ def dev_bundle_name
62
+ cleaned = @config.name.to_s.gsub(%r{[/\\]}, "-").sub(/\A\.+/, "").strip
63
+ cleaned.empty? ? "App" : cleaned
64
+ end
65
+
66
+ # Spaces are legal in CFBundleExecutable but a nuisance in every shell
67
+ # command that names it, so the executable follows the packaged app's
68
+ # convention and drops them.
69
+ def dev_exe_name = dev_bundle_name.gsub(/\s+/, "")
70
+
71
+ def dev_info_plist
72
+ name = Everywhere::Plist.escape(@config.name)
73
+ <<~PLIST
74
+ <?xml version="1.0" encoding="UTF-8"?>
75
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
76
+ <plist version="1.0">
77
+ <dict>
78
+ <key>CFBundleName</key><string>#{name}</string>
79
+ <key>CFBundleDisplayName</key><string>#{name}</string>
80
+ <key>CFBundleExecutable</key><string>#{Everywhere::Plist.escape(dev_exe_name)}</string>
81
+ <key>CFBundleIdentifier</key><string>#{Everywhere::Plist.escape(@config.bundle_id)}</string>
82
+ <key>CFBundlePackageType</key><string>APPL</string>
83
+ <key>CFBundleShortVersionString</key><string>#{Everywhere::Plist.escape(@config.version)}</string>
84
+ <key>NSHighResolutionCapable</key><true/>
85
+ </dict>
86
+ </plist>
87
+ PLIST
88
+ end
89
+
90
+ # The Dock icon for the running shell. `cargo run` produces a bare binary
91
+ # with no .app around it, so macOS has nothing to read an icon from and
92
+ # Tauri's compiled-in placeholder wins — which is how you end up staring
93
+ # at the gem's icon while testing your own app.
94
+ #
95
+ # Shaped with the same Big Sur inset + squircle the packaged .icns gets
96
+ # (Icon.macos_master), so the Dock looks identical in dev and in a release
97
+ # build rather than subtly rounder in one of them. Returns "" when the app
98
+ # has no icon — the shell reads that as "leave it alone".
99
+ def stage_desktop_icon
100
+ source = @config.icon
101
+ return "" unless source && File.exist?(source)
102
+
103
+ dest = File.join(Everywhere::Paths.desktop_icon_dir(@config.bundle_id), "icon.png")
104
+ FileUtils.mkdir_p(File.dirname(dest))
105
+ FileUtils.cp(source, dest) unless Everywhere::Icon.macos_master(source, dest)
106
+ dest
107
+ rescue StandardError => e
108
+ # A Dock icon is never worth failing a dev session over.
109
+ UI.warn("couldn't prepare the Dock icon (#{e.class}) — using the default")
110
+ ""
111
+ end
112
+
113
+ # macOS runs the shell from inside a throwaway .app so it has a name;
114
+ # everywhere else `cargo run` is still the whole story.
115
+ #
116
+ # Three steps in one command rather than three Ruby calls, because the
117
+ # link has to happen between the compile and the launch and we want ONE
118
+ # supervised child: cargo's progress relays into the dock, and exec then
119
+ # replaces it with the app, so the pid we're tracking is the window.
120
+ #
121
+ # A hard link, not a copy: same volume (both under ~/.rubyeverywhere), so
122
+ # it's free and instant, where copying a debug Tauri binary is ~100MB on
123
+ # every launch. cargo replaces the file on rebuild rather than writing
124
+ # through it, so the link is remade each time — hence `ln -f`.
125
+ def desktop_command(prepared)
126
+ return "cargo run" unless Host.darwin?
127
+
128
+ _app, exe = stage_dev_bundle
129
+ built = File.join(prepared.target_dir, "debug", "example-shell")
130
+ [
131
+ "cargo build",
132
+ "{ ln -f #{built.shellescape} #{exe.shellescape} 2>/dev/null || " \
133
+ "cp -f #{built.shellescape} #{exe.shellescape}; }",
134
+ "exec #{exe.shellescape}"
135
+ ].join(" && ")
136
+ end
137
+ end
138
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../clock"
4
+
3
5
  module Everywhere
4
6
  class Dock
5
7
  # What the dock knows about each thing `every dev` is running. Pure data
@@ -22,7 +24,7 @@ module Everywhere
22
24
  def busy? = BUSY.include?(state)
23
25
  end
24
26
 
25
- def initialize(keys, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
27
+ def initialize(keys, clock: -> { Clock.monotonic })
26
28
  @clock = clock
27
29
  @lock = Mutex.new
28
30
  ordered = ORDER & keys
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "cli"
4
+ require_relative "ui"
5
+
6
+ module Everywhere
7
+ # The body of both executables — `every` and `rbe` are the same CLI under two
8
+ # names, so exe/* stay one-liners that call in here.
9
+ module Entrypoint
10
+ module_function
11
+
12
+ def run(argv = ARGV)
13
+ Dry::CLI.new(Everywhere::CLI).call(arguments: argv)
14
+ rescue Interrupt
15
+ # ^C is an answer, not a crash: no backtrace, and the shell's own convention
16
+ # for "killed by SIGINT".
17
+ exit 130
18
+ rescue Everywhere::Error => e
19
+ # Everywhere::Fatal is a SystemExit, not an Error, so a die! that already
20
+ # printed its own line passes straight through here.
21
+ Everywhere::UI.die!(e.message)
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Everywhere
4
+ # Required on its own by every file that raises it, so each stays loadable
5
+ # standalone (the packaged runtime requires them piecemeal, without going
6
+ # through everywhere.rb).
7
+ class Error < StandardError; end
8
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "error"
4
+
3
5
  module Everywhere
4
6
  # Framework adapters keep both the CLI (dev/build) and the packaged runtime
5
7
  # (boot) framework-agnostic. Each adapter owns two sides of one framework:
@@ -223,6 +225,4 @@ module Everywhere
223
225
  def env_exports = { "HANAMI_ENV" => "production", "RACK_ENV" => "production" }
224
226
  end
225
227
  end
226
-
227
- class Error < StandardError; end
228
228
  end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Everywhere
4
+ # The machine the CLI is running on — never the machine it's building for
5
+ # (that's the --target, see Config.os_of and the receipt).
6
+ module Host
7
+ module_function
8
+
9
+ def darwin? = RUBY_PLATFORM.include?("darwin")
10
+ end
11
+ end
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Everywhere
4
- # Shared source-exclusion rules, read from `.everywhereignore` (dockerignore
5
- # style) when present, else a sensible default. ONE source of truth so the
4
+ # Shared source-exclusion rules: the built-in DEFAULTS, plus `.everywhereignore`
5
+ # (dockerignore style) appended when the app has one. ONE source of truth so the
6
6
  # build receipt's source checksum (Receipt) and the Platform snapshot
7
7
  # (`every platform build`, later) describe exactly the same set of files — a
8
8
  # checksum that didn't match what actually ships would be worse than none.
@@ -15,7 +15,8 @@ module Everywhere
15
15
  #
16
16
  # Rules are evaluated top to bottom and the LAST one that matches a path wins
17
17
  # (dockerignore semantics), so ordering matters: put `!` re-includes after the
18
- # broad exclude they carve out of.
18
+ # broad exclude they carve out of — and the app's file, coming last, can
19
+ # re-include anything DEFAULTS excluded.
19
20
  class Ignore
20
21
  FILE = ".everywhereignore"
21
22
  FLAGS = File::FNM_PATHNAME | File::FNM_DOTMATCH
@@ -29,12 +30,17 @@ module Everywhere
29
30
  *.key *.pem *.crt *.p12 *.mobileprovision
30
31
  ].freeze
31
32
 
33
+ # The app's file is layered ON TOP of DEFAULTS rather than replacing them:
34
+ # a project that adds one line to skip a fixture dir must not thereby start
35
+ # shipping .env and *.p12. Last match wins, so a user who really wants a
36
+ # defaulted-out file back can still `!` it in.
32
37
  def self.for(root)
33
38
  path = File.join(root, FILE)
39
+ return new(parse(DEFAULTS.join("\n"))) unless File.exist?(path)
40
+
34
41
  # UTF-8 explicitly (and scrubbed): under LANG=C the bytes come back
35
42
  # US-ASCII and a non-ASCII path pattern makes the matching raise.
36
- source = File.exist?(path) ? File.read(path, encoding: "UTF-8").scrub : DEFAULTS.join("\n")
37
- new(parse(source))
43
+ new(parse(DEFAULTS.join("\n")) + parse(File.read(path, encoding: "UTF-8").scrub))
38
44
  end
39
45
 
40
46
  # → [{ negated:, pattern: }, ...] in file order. Blank lines and whole-line
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "clock"
4
+
3
5
  module Everywhere
4
6
  # Turns the raw byte stream off a pty master into whole lines.
5
7
  #
@@ -25,7 +27,7 @@ module Everywhere
25
27
  # "Overwrite? [Yn]" — would be invisible for as long as it mattered.
26
28
  IDLE_FLUSH = 0.2
27
29
 
28
- def initialize(idle_flush: IDLE_FLUSH, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
30
+ def initialize(idle_flush: IDLE_FLUSH, clock: -> { Clock.monotonic })
29
31
  # Bytes in, text out. A pty hands over BINARY, and a chunk boundary can
30
32
  # land in the middle of a multi-byte character, so buffering and splitting
31
33
  # happen on bytes — UTF-8 is self-synchronizing, so a newline is never
@@ -4,6 +4,7 @@ require "openssl"
4
4
  require "base64"
5
5
  require "securerandom"
6
6
  require_relative "blake2b"
7
+ require_relative "error"
7
8
 
8
9
  module Everywhere
9
10
  # Minisign-compatible Ed25519 signing, stdlib-only (OpenSSL + our Blake2b).
@@ -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
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "error"
3
4
  require_relative "ui"
4
5
 
5
6
  module Everywhere
@@ -97,7 +98,7 @@ module Everywhere
97
98
  # tmpdir) on purpose: a stable project path keeps Xcode's incremental state
98
99
  # valid across builds, and users can open the stamped project in Xcode.
99
100
  def ios_work_dir(bundle_id)
100
- File.join(cache_dir, "ios", bundle_id)
101
+ File.join(cache_dir, "ios", segment!(bundle_id))
101
102
  end
102
103
 
103
104
  # Where xcodebuild writes DerivedData for iOS shell builds. Shared across
@@ -118,7 +119,7 @@ module Everywhere
118
119
  # incremental state is path-sensitive, and the stamped project opens in
119
120
  # Android Studio unmodified.
120
121
  def android_work_dir(application_id)
121
- File.join(cache_dir, "android", application_id)
122
+ File.join(cache_dir, "android", segment!(application_id))
122
123
  end
123
124
 
124
125
  # GRADLE_USER_HOME for shell builds. Redirected here so the multi-GB Gradle
@@ -144,7 +145,7 @@ module Everywhere
144
145
  # stays warm across projects. Persistent for the same reason the mobile
145
146
  # work dirs are: a cold Tauri build is minutes where a warm one is seconds.
146
147
  def desktop_work_dir(bundle_id)
147
- File.join(cache_dir, "desktop", bundle_id)
148
+ File.join(cache_dir, "desktop", segment!(bundle_id))
148
149
  end
149
150
 
150
151
  # CARGO_TARGET_DIR for a stamped shell. Per-app rather than shared, because
@@ -152,7 +153,7 @@ module Everywhere
152
153
  # every other app's — pointing them at one target dir would make cargo
153
154
  # rebuild the world on every alternating build.
154
155
  def desktop_target_dir(bundle_id)
155
- File.join(cache_dir, "desktop-target", bundle_id)
156
+ File.join(cache_dir, "desktop-target", segment!(bundle_id))
156
157
  end
157
158
 
158
159
  # Where `every dev` keeps the throwaway .app wrapper it runs the shell
@@ -161,14 +162,14 @@ module Everywhere
161
162
  # menu bar, the About/Hide/Quit items and the Dock. The wrapper exists only
162
163
  # to carry an Info.plist; the binary inside it is a hard link to cargo's.
163
164
  def desktop_dev_app_dir(bundle_id)
164
- File.join(cache_dir, "desktop-app", bundle_id)
165
+ File.join(cache_dir, "desktop-app", segment!(bundle_id))
165
166
  end
166
167
 
167
168
  # Where `every dev` stages the app's Dock icon for the running shell. The
168
169
  # shaped master is derived from app.icon, so it's a build product, not the
169
170
  # app's own file — it belongs in the cache next to the rest of them.
170
171
  def desktop_icon_dir(bundle_id)
171
- File.join(cache_dir, "desktop-icon", bundle_id)
172
+ File.join(cache_dir, "desktop-icon", segment!(bundle_id))
172
173
  end
173
174
 
174
175
  # Where `every dev` stages native/desktop/assets for the running shell.
@@ -178,7 +179,7 @@ module Everywhere
178
179
  # in dev. Staging runs the same DesktopAssets compiler both ways, so the two
179
180
  # can't drift. Keyed by bundle id like the mobile work dirs.
180
181
  def desktop_assets_dir(bundle_id)
181
- File.join(cache_dir, "desktop-assets", bundle_id)
182
+ File.join(cache_dir, "desktop-assets", segment!(bundle_id))
182
183
  end
183
184
 
184
185
  # Where cargo writes the shell's build output (CARGO_TARGET_DIR) for apps on
@@ -199,5 +200,20 @@ module Everywhere
199
200
  def app_bundle_dir
200
201
  File.join(cache_dir, "bundle")
201
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
202
218
  end
203
219
  end
@@ -8,6 +8,7 @@ begin
8
8
  rescue LoadError
9
9
  nil
10
10
  end
11
+ require_relative "../error"
11
12
  require_relative "../version"
12
13
 
13
14
  module Everywhere
@@ -92,7 +93,7 @@ module Everywhere
92
93
  when Net::HTTPRedirection
93
94
  raise Error, "too many redirects downloading #{url}" if redirects <= 0
94
95
 
95
- return download(res["location"], dest, redirects: redirects - 1)
96
+ return download(redirect_target(uri, res["location"]), dest, redirects: redirects - 1)
96
97
  when Net::HTTPSuccess
97
98
  File.open(dest, "wb") { |f| res.read_body { |chunk| f.write(chunk) } }
98
99
  else
@@ -107,6 +108,23 @@ module Everywhere
107
108
 
108
109
  private
109
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
+
110
128
  def http(uri, &block)
111
129
  Net::HTTP.start(uri.hostname, uri.port,
112
130
  use_ssl: uri.scheme == "https", open_timeout: OPEN_TIMEOUT, &block)
@@ -39,10 +39,12 @@ module Everywhere
39
39
 
40
40
  def add(tar, root, rel)
41
41
  abs = File.join(root, rel)
42
- # lstat first: File.stat follows the link, so a dangling symlink (a
43
- # deleted bundle path, a checked-in link to an absent sibling repo)
44
- # would raise Errno::ENOENT and abort the whole snapshot.
45
- return Everywhere::UI.warn("skipping dangling symlink #{rel}") if File.lstat(abs).symlink? && !File.exist?(abs)
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?
46
48
 
47
49
  stat = File.stat(abs)
48
50
  tar.add_file_simple(rel, stat.mode & 0o777, stat.size) do |io|
@@ -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("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
15
+ end
16
+ end
17
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "zlib"
4
+ require_relative "error"
4
5
 
5
6
  module Everywhere
6
7
  # Minimal PNG surgery: Tauri requires RGBA icons, users hand us whatever
@@ -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/s3.rb CHANGED
@@ -4,6 +4,7 @@ require "net/http"
4
4
  require "openssl"
5
5
  require "uri"
6
6
  require "time"
7
+ require_relative "error"
7
8
 
8
9
  module Everywhere
9
10
  # Minimal S3 client — just the four operations `every publish` needs