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,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require_relative "clock"
5
+ require_relative "child_processes"
6
+ require_relative "relay"
7
+ require_relative "shellout"
8
+ require_relative "ui"
9
+
10
+ module Everywhere
11
+ # The long-lived children a session keeps on a leash, one per key: `every
12
+ # dev`'s dev server, desktop shell and log stream; `every preview`'s server
13
+ # and tunnel.
14
+ #
15
+ # It sits above ChildProcesses — which knows how to signal one pid's process
16
+ # group but nothing about names — and beside Shellout, which knows how to
17
+ # spawn one child but never remembers it. What lives here is the
18
+ # session-scoped {key => pid} registry, and the teardown that must not let a
19
+ # child outlive the CLI that opened it.
20
+ #
21
+ # A mixin rather than a collaborator object: the registry has exactly one
22
+ # owner (the command), and these read as the command's own vocabulary —
23
+ # stop_child(:web), child_pid(:desktop).
24
+ module ChildSupervision
25
+ private
26
+
27
+ # Children are spawned from pool threads as well as the main one.
28
+ def init_children!
29
+ @pids = {}
30
+ @relays = {}
31
+ @children = Mutex.new
32
+ end
33
+
34
+ # Spawn a long-lived child on a pty and relay its output, one tagged line
35
+ # at a time. A pty rather than a pipe so foreman, cargo and friends still
36
+ # believe they're on a terminal and keep their colors; PTY.spawn also
37
+ # setsids, so a Ctrl-C at our terminal can't reach them and teardown
38
+ # decides when they die.
39
+ def supervise(key, env, cmd, chdir:, filter: nil, log: nil, on_line: nil)
40
+ io, pid = spawn_child(env, cmd, chdir: chdir)
41
+ thread = relay_thread(key, io, pid, filter: filter, log: log, on_line: on_line) if io
42
+ Process.detach(pid)
43
+ @children.synchronize do
44
+ @pids[key] = pid
45
+ @relays[key] = thread if thread
46
+ end
47
+ pid
48
+ end
49
+
50
+ # How this command puts a child into the world: [io, pid], where io is the
51
+ # side we read its output from — or nil for a child that was handed our own
52
+ # fds and prints for itself, which gets no relay. Overridden per command,
53
+ # because only the pty path is shared: what to do when there is no pty is
54
+ # exactly where dev and preview differ.
55
+ def spawn_child(env, cmd, chdir:)
56
+ Shellout.spawn_pty(env, cmd, chdir: chdir)
57
+ end
58
+
59
+ def relay_thread(key, io, pid, filter:, log:, on_line:)
60
+ Thread.new do
61
+ Thread.current.name = "relay-#{key}"
62
+ Thread.current.report_on_exception = false
63
+ begin
64
+ Relay.new(io, source: key, log: log, filter: filter, on_line: on_line).run
65
+ rescue StandardError => e
66
+ # Losing a relay costs us that child's output, nothing more. It must
67
+ # not surface later out of the join in teardown.
68
+ Kernel.warn("#{key} output relay stopped: #{e.class}: #{e.message}")
69
+ end
70
+ on_child_exit(key, pid)
71
+ end
72
+ end
73
+
74
+ # The child's output ended, which for a supervised child means it died.
75
+ # Nothing to do by default — the pid is still in the registry either way,
76
+ # and teardown reaps it.
77
+ def on_child_exit(key, pid); end
78
+
79
+ # TERM the child's whole process group, escalate to KILL, and wait for the
80
+ # relay to drain so its last lines aren't lost. The pid is dropped from the
81
+ # registry FIRST, so the relay knows this death was asked for.
82
+ def stop_child(key, grace: 5)
83
+ pid, thread = @children.synchronize { [@pids.delete(key), @relays.delete(key)] }
84
+ return unless pid
85
+
86
+ reap([pid], grace: grace)
87
+ thread&.join(1)
88
+ end
89
+
90
+ # TERM each process group, wait for them together rather than serially,
91
+ # then KILL the stragglers.
92
+ def reap(pids, grace: 5)
93
+ live = pids.compact.select { |pid| ChildProcesses.alive?(pid) }
94
+ return if live.empty?
95
+
96
+ live.each { |pid| ChildProcesses.signal(pid, "TERM") }
97
+ deadline = Clock.monotonic + grace
98
+ sleep(0.1) while live.any? { |pid| ChildProcesses.alive?(pid) } && Clock.monotonic < deadline
99
+ live.select { |pid| ChildProcesses.alive?(pid) }.each { |pid| ChildProcesses.signal(pid, "KILL") }
100
+ end
101
+
102
+ def child_pid(key) = @children.synchronize { @pids[key] }
103
+
104
+ # Teardown must never raise — it runs from an ensure, where an exception
105
+ # would mask whatever actually ended the session — and one failed step
106
+ # must not skip the rest, or a child survives the CLI that spawned it.
107
+ # That ensure can also run before init_children! ever did, which is why the
108
+ # registry is read through a nil guard.
109
+ #
110
+ # `before` is whatever the host has to shut down ahead of its children —
111
+ # for `every dev`, the dock's hold on the terminal and the build pool.
112
+ def teardown_children(grace: 2, before: [])
113
+ @stopping = true
114
+ pids, relays = @children ? @children.synchronize { [@pids.values, @relays.values] } : [[], []]
115
+ (before + [
116
+ # TERM everything, then give it a moment and KILL whatever is left.
117
+ # The CLI exits the instant this returns, so anything still alive is
118
+ # orphaned — and a windowed shell is exactly the kind of child that
119
+ # doesn't drop dead on the first signal.
120
+ -> { reap(pids, grace: grace) },
121
+ -> { relays.each { |thread| thread.join(1) } }
122
+ ]).each do |step|
123
+ step.call
124
+ rescue StandardError => e
125
+ Kernel.warn("teardown: #{e.class}: #{e.message}")
126
+ end
127
+ end
128
+
129
+ # Checked once, up front: Integer() would otherwise raise from inside
130
+ # port_open? — an ArgumentError backtrace after the dock has already
131
+ # taken over the terminal.
132
+ def validate_port!(port)
133
+ number = begin
134
+ Integer(port.to_s, 10)
135
+ rescue ArgumentError, TypeError
136
+ UI.die!("--port must be a number (got #{port.inspect})")
137
+ end
138
+ UI.die!("--port must be between 1 and 65535 (got #{number})") unless (1..65_535).cover?(number)
139
+ number.to_s
140
+ end
141
+
142
+ # Any connect error means "nothing listening yet" for our purposes —
143
+ # ECONNREFUSED is the common one, but a firewall or an exhausted fd table
144
+ # answers differently and must not abort the session.
145
+ def port_open?(port)
146
+ TCPSocket.new("127.0.0.1", Integer(port)).close
147
+ true
148
+ rescue SystemCallError
149
+ false
150
+ end
151
+
152
+ # Watches the child as well as the port: a dev server that dies on
153
+ # startup (no `bundle install`, a bin/dev whose foreman isn't installed)
154
+ # never opens the port, and waiting out the full timeout for it buries
155
+ # the error it already printed under a minute of silence.
156
+ #
157
+ # The hints are appended to the message as-is, for a caller that can say
158
+ # something more specific about why its server never came up.
159
+ def wait_for_port(port, pid: nil, timeout: 60, dead_hint: nil, slow_hint: nil)
160
+ deadline = Clock.monotonic + timeout
161
+ until port_open?(port)
162
+ if pid && !ChildProcesses.alive?(pid)
163
+ UI.die!("the dev server exited before it finished booting — scroll up for its output#{dead_hint}")
164
+ end
165
+ if Clock.monotonic > deadline
166
+ UI.die!("dev server never came up on port #{port} after #{timeout}s#{slow_hint}")
167
+ end
168
+ sleep 0.25
169
+ end
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Everywhere
4
+ # The one clock every deadline in the CLI measures against. Monotonic, so a
5
+ # timeout can't be lengthened or skipped by an NTP step or a DST change
6
+ # mid-build.
7
+ module Clock
8
+ module_function
9
+
10
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
11
+ end
12
+ end
@@ -5,10 +5,12 @@ require "fileutils"
5
5
  require "tmpdir"
6
6
  require "shellwords"
7
7
  require_relative "../shellout"
8
+ require_relative "../host"
8
9
  require_relative "../log_filter"
9
10
  require_relative "../png"
10
11
  require_relative "../icon"
11
12
  require_relative "../paths"
13
+ require_relative "../plist"
12
14
  require_relative "../desktop_assets"
13
15
  require_relative "../builders/ios"
14
16
  require_relative "../builders/android"
@@ -63,6 +65,17 @@ module Everywhere
63
65
  root_path = File.expand_path(root)
64
66
  config = Everywhere::Config.load(root_path)
65
67
 
68
+ # Before any builder runs: the bundle id names the work dirs they wipe
69
+ # and recreate, so a malformed one is a wrong path, not a wrong label.
70
+ identity_errors = config.identity_errors
71
+ UI.die!("app: in config/everywhere.yml:\n#{identity_errors.join("\n")}") unless identity_errors.empty?
72
+
73
+ # Baked into the shell here, so this is the last moment an unusable
74
+ # feed URL can still be a message instead of a shipped app that never
75
+ # updates.
76
+ updates_errors = config.updates_errors
77
+ UI.die!("updates: in config/everywhere.yml:\n#{updates_errors.join("\n")}") unless updates_errors.empty?
78
+
66
79
  # Builder dispatch on the target's os (build-engine.md §5): macOS is
67
80
  # the tebako+tauri path below; the mobile targets are Hotwire Native
68
81
  # shells, each built by its own builder. Anything else has no builder yet.
@@ -75,13 +88,13 @@ module Everywhere
75
88
  app = mobile_builder(target).new(config: config, root: root_path, target: target,
76
89
  template_dir: template_dir, **channel_option(target, channel))
77
90
  .build!(dist_dir: dist_dir, **mobile_options(target, device: device, format: format))
78
- UI.success("built #{rel(app, root_path)}")
91
+ UI.success("built #{UI.rel_path(app, root_path)}")
79
92
  return
80
93
  when "macos" # ...which is the rest of this method
81
94
  # Every step of it ends in codesign and an .app bundle, so a Linux
82
95
  # host can only fail — the question is whether it fails here or
83
96
  # thirty minutes in, at the first codesign.
84
- UI.die!("desktop builds are macOS-only for now") unless RUBY_PLATFORM.include?("darwin")
97
+ UI.die!("desktop builds are macOS-only for now") unless Host.darwin?
85
98
  else
86
99
  UI.die!("no builder for target #{target} — macos-*, ios-* and android-* are supported")
87
100
  end
@@ -124,15 +137,15 @@ module Everywhere
124
137
  UI.step("#{UI.bold(framework.name)} app at #{framework.root}")
125
138
 
126
139
  if skip_press
127
- UI.die!("--skip-press given but #{rel(output_path, root_path)} doesn't exist") unless File.exist?(output_path)
128
- UI.step("reusing pressed binary #{rel(output_path, root_path)}")
140
+ UI.die!("--skip-press given but #{UI.rel_path(output_path, root_path)} doesn't exist") unless File.exist?(output_path)
141
+ UI.step("reusing pressed binary #{UI.rel_path(output_path, root_path)}")
129
142
  else
130
143
  press!(framework, entry, ruby, output_path)
131
144
  end
132
145
 
133
146
  bundle_app(output_path, app_name, shell_dir, config, target: target)
134
147
 
135
- UI.success("built #{rel(output_path, root_path)} (#{(File.size(output_path) / 1024.0 / 1024.0).round}MB)")
148
+ UI.success("built #{UI.rel_path(output_path, root_path)} (#{(File.size(output_path) / 1024.0 / 1024.0).round}MB)")
136
149
  end
137
150
 
138
151
  private
@@ -193,8 +206,6 @@ module Everywhere
193
206
  target
194
207
  end
195
208
 
196
- def rel(path, root_path) = UI.short_path(path.sub("#{root_path}/", "").sub("#{Dir.pwd}/", ""))
197
-
198
209
  # The precompile step runs against the HOST Ruby — unlike tebako press,
199
210
  # which installs the app's gems itself inside the packaged Ruby. A fresh
200
211
  # machine (a hosted build runner, a brand-new checkout) has Gemfile.lock
@@ -400,6 +411,8 @@ module Everywhere
400
411
  # and CFBundleVersion come from `app.version` in everywhere.yml (via
401
412
  # config.version), so the bundle and the release.json receipt agree.
402
413
  def info_plist(app_name, exe_name, icon, identifier, version)
414
+ app_name, exe_name, icon, identifier, version =
415
+ [app_name, exe_name, icon, identifier, version].map { |v| Everywhere::Plist.escape(v) if v }
403
416
  icon_keys = icon ? " <key>CFBundleIconFile</key><string>#{icon}</string>\n" : ""
404
417
  <<~PLIST
405
418
  <?xml version="1.0" encoding="UTF-8"?>
@@ -420,8 +433,6 @@ module Everywhere
420
433
  PLIST
421
434
  end
422
435
 
423
- private
424
-
425
436
  # Tebako's output is thousands of lines; re-surface the warnings that
426
437
  # actually matter to the app developer after the build finishes.
427
438
  def surface_warnings(log)