ruby_everywhere 0.1.14 → 0.2.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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/bridge/README.md +57 -2
  3. data/bridge/everywhere/bridge.js +803 -9
  4. data/bridge/package.json +1 -1
  5. data/lib/everywhere/builders/ios.rb +364 -0
  6. data/lib/everywhere/cli.rb +2 -0
  7. data/lib/everywhere/commands/build.rb +17 -1
  8. data/lib/everywhere/commands/clean.rb +19 -10
  9. data/lib/everywhere/commands/dev.rb +182 -19
  10. data/lib/everywhere/commands/doctor.rb +45 -3
  11. data/lib/everywhere/commands/icon.rb +14 -0
  12. data/lib/everywhere/commands/install.rb +37 -6
  13. data/lib/everywhere/commands/logs.rb +56 -0
  14. data/lib/everywhere/commands/platform/build.rb +74 -15
  15. data/lib/everywhere/commands/platform/runner.rb +10 -1
  16. data/lib/everywhere/commands/release.rb +1 -1
  17. data/lib/everywhere/commands/shell_dir.rb +11 -4
  18. data/lib/everywhere/config.rb +366 -1
  19. data/lib/everywhere/engine.rb +33 -1
  20. data/lib/everywhere/icon.rb +50 -0
  21. data/lib/everywhere/log_filter.rb +28 -2
  22. data/lib/everywhere/mobile_config_endpoint.rb +75 -0
  23. data/lib/everywhere/mobile_configs_controller.rb +46 -0
  24. data/lib/everywhere/native_helper.rb +156 -0
  25. data/lib/everywhere/paths.rb +41 -0
  26. data/lib/everywhere/raster.rb +17 -0
  27. data/lib/everywhere/shellout.rb +3 -1
  28. data/lib/everywhere/simulator.rb +74 -0
  29. data/lib/everywhere/ui.rb +82 -0
  30. data/lib/everywhere/version.rb +1 -1
  31. data/support/mobile/ios/App/App.xcconfig +6 -0
  32. data/support/mobile/ios/App/AppDelegate.swift +163 -0
  33. data/support/mobile/ios/App/Assets.xcassets/AccentColor.colorset/Contents.json +20 -0
  34. data/support/mobile/ios/App/Assets.xcassets/AppIcon.appiconset/AppIcon.png +0 -0
  35. data/support/mobile/ios/App/Assets.xcassets/AppIcon.appiconset/Contents.json +14 -0
  36. data/support/mobile/ios/App/Assets.xcassets/Contents.json +6 -0
  37. data/support/mobile/ios/App/Assets.xcassets/LaunchBackground.colorset/Contents.json +38 -0
  38. data/support/mobile/ios/App/Base.lproj/LaunchScreen.storyboard +32 -0
  39. data/support/mobile/ios/App/Bridge/BiometricsComponent.swift +276 -0
  40. data/support/mobile/ios/App/Bridge/HapticsComponent.swift +47 -0
  41. data/support/mobile/ios/App/Bridge/NotificationComponent.swift +56 -0
  42. data/support/mobile/ios/App/Bridge/PermissionsComponent.swift +142 -0
  43. data/support/mobile/ios/App/Bridge/StorageComponent.swift +63 -0
  44. data/support/mobile/ios/App/ErrorViewController.swift +64 -0
  45. data/support/mobile/ios/App/EverywhereConfig.swift +268 -0
  46. data/support/mobile/ios/App/EverywhereHost.swift +34 -0
  47. data/support/mobile/ios/App/Extensions/EverywhereExtensions.swift +32 -0
  48. data/support/mobile/ios/App/Info.plist +28 -0
  49. data/support/mobile/ios/App/Resources/everywhere.json +8 -0
  50. data/support/mobile/ios/App/Resources/path-configuration.json +19 -0
  51. data/support/mobile/ios/App/SceneDelegate.swift +443 -0
  52. data/support/mobile/ios/App.xcodeproj/project.pbxproj +454 -0
  53. data/support/mobile/ios/App.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  54. data/support/mobile/ios/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +14 -0
  55. data/support/mobile/ios/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme +77 -0
  56. data/support/mobile/ios/NativeExtensions/Package.swift +26 -0
  57. data/support/mobile/ios/NativeExtensions/Sources/NativeExtensions/Exports.swift +5 -0
  58. data/support/mobile/ios/README.md +66 -0
  59. data/support/shell/src-tauri/Cargo.lock +16 -0
  60. data/support/shell/src-tauri/Cargo.toml +1 -0
  61. data/support/shell/src-tauri/gen/schemas/acl-manifests.json +1 -1
  62. data/support/shell/src-tauri/gen/schemas/desktop-schema.json +42 -0
  63. data/support/shell/src-tauri/gen/schemas/macOS-schema.json +42 -0
  64. data/support/shell/src-tauri/src/main.rs +17 -0
  65. metadata +35 -1
@@ -2,48 +2,211 @@
2
2
 
3
3
  require "dry/cli"
4
4
  require "socket"
5
+ require "shellwords"
5
6
  require_relative "../shellout"
6
7
  require_relative "../paths"
8
+ require_relative "../builders/ios"
9
+ require_relative "../simulator"
10
+ require_relative "logs"
7
11
 
8
12
  module Everywhere
9
13
  module Commands
10
- # Run the app's normal dev server and open the desktop shell pointed at it.
11
- # No packaging: the shell skips the sidecar when NATIVE_DEV_URL is set,
12
- # so the edit-refresh loop is untouched Rails/Hanami.
14
+ # Run the app's normal dev server and open native shells pointed at it.
15
+ # No packaging: the desktop shell skips the sidecar when NATIVE_DEV_URL is
16
+ # set, and the iOS shell reads EVERYWHERE_DEV_URL at launch — so the
17
+ # edit-refresh loop is untouched Rails/Hanami either way.
18
+ #
19
+ # Target flags are additive (--desktop --ios --browser opens all three);
20
+ # with no flags the server boots and a key menu launches shells on demand.
13
21
  class Dev < Dry::CLI::Command
14
- desc "Run the dev server inside the desktop shell (live reload, no packaging)"
22
+ desc "Run the dev server with native shells on demand (live reload, no packaging)"
15
23
 
16
24
  option :port, default: "3000", desc: "Dev server port"
17
25
  option :shell_dir, default: nil, desc: "Path to the shell's src-tauri directory"
26
+ option :target, default: nil, desc: "Single shell to open: macos (desktop) or ios (Simulator)"
27
+ option :desktop, type: :boolean, default: false, desc: "Open the desktop shell"
28
+ option :ios, type: :boolean, default: false, desc: "Open the iOS Simulator shell"
29
+ option :browser, type: :boolean, default: false, desc: "Open the app in the default browser"
18
30
 
19
- def call(port: "3000", shell_dir: nil, **)
20
- framework = Framework.detect
21
- config = Everywhere::Config.load(framework.root)
22
- shell_dir ||= Everywhere::Paths.shell_dir!
31
+ def call(port: "3000", shell_dir: nil, target: nil, desktop: false, ios: false, browser: false, **)
32
+ @shell_dir = shell_dir
33
+ targets = requested_targets(target, desktop: desktop, ios: ios, browser: browser)
34
+ framework, @config = detect_app
35
+
36
+ start_server(framework, port)
37
+ @dev_url = if framework
38
+ "http://127.0.0.1:#{port}#{@config.entry_path}"
39
+ else
40
+ "#{@config.remote_url}#{@config.entry_path}"
41
+ end
42
+
43
+ # Headless (non-tty) runs can't take keystrokes, so keep the old
44
+ # default of the desktop shell.
45
+ targets << "desktop" if targets.empty? && !$stdin.tty?
46
+ targets.each { |t| launch(t) }
47
+
48
+ $stdin.tty? ? interact : wait_for_children
49
+ rescue Interrupt
50
+ nil
51
+ ensure
52
+ teardown
53
+ end
54
+
55
+ private
56
+
57
+ def requested_targets(target, desktop:, ios:, browser:)
58
+ targets = []
59
+ targets << "desktop" if desktop || (target && Everywhere::Config.os_of(target) != "ios")
60
+ targets << "ios" if ios || (target && Everywhere::Config.os_of(target) == "ios")
61
+ targets << "browser" if browser
62
+ targets
63
+ end
64
+
65
+ # The framework (nil for remote-only roots) and config. A root with
66
+ # neither a framework nor remote.url is a genuine error.
67
+ def detect_app
68
+ framework = begin
69
+ Framework.detect
70
+ rescue Everywhere::Error
71
+ nil
72
+ end
73
+ config = Everywhere::Config.load(framework ? framework.root : Dir.pwd)
74
+ unless framework || config.remote?
75
+ UI.die!("no supported framework here (Rails, Hanami, Sinatra) and no remote.url in " \
76
+ "config/everywhere.yml — nothing to run")
77
+ end
78
+ [framework, config]
79
+ end
80
+
81
+ def start_server(framework, port)
82
+ unless framework
83
+ # Remote-mode app with no local code (just everywhere.yml): nothing
84
+ # to serve, point the shells straight at the deployed app.
85
+ UI.step("#{UI.bold("remote")} mode → #{UI.cyan(@config.remote_url)} #{UI.dim("(no local server)")}")
86
+ return
87
+ end
23
88
 
24
- server_pid = nil
25
89
  if port_open?(port)
26
90
  UI.warn "something is ALREADY RUNNING on port #{port} — attaching to it instead of starting"
27
91
  UI.warn "#{UI.dim("your Procfile.dev/watchers are NOT running; stop that server or use --port if this isn't yours")}"
28
92
  else
29
93
  UI.step("starting #{UI.bold(framework.name)} dev server #{UI.dim("(" + framework.dev_command + ")")}")
30
- server_pid = Shellout.spawn({ "PORT" => port.to_s }, framework.dev_command, chdir: framework.root)
94
+ @server_pid = Shellout.spawn({ "PORT" => port.to_s }, framework.dev_command, chdir: framework.root)
95
+ @server_thread = Process.detach(@server_pid)
31
96
  wait_for_port(port)
32
97
  end
98
+ end
99
+
100
+ def launch(target)
101
+ case target
102
+ when "ios" then launch_ios
103
+ when "browser" then launch_browser
104
+ else launch_desktop
105
+ end
106
+ end
107
+
108
+ def launch_desktop
109
+ if @desktop_pid && alive?(@desktop_pid)
110
+ UI.warn "desktop shell already running"
111
+ return
112
+ end
113
+ dir = @shell_dir || Everywhere::Paths.shell_dir!
114
+ UI.step("opening desktop shell → #{UI.cyan(@dev_url)}")
115
+ @desktop_pid = Shellout.spawn({ "NATIVE_DEV_URL" => @dev_url, "NATIVE_CONFIG" => @config.to_shell_json,
116
+ "CARGO_TARGET_DIR" => Everywhere::Paths.cargo_target_dir },
117
+ "cargo run", chdir: dir)
118
+ @desktop_thread = Process.detach(@desktop_pid)
119
+ end
120
+
121
+ # Build the shell (Debug, warm after the first run), put it on a booted
122
+ # Simulator and hand it the dev URL. The Simulator shares the Mac's
123
+ # loopback, so 127.0.0.1 works as-is; the template's ATS exception
124
+ # covers plain http for local networking. Pressing i again rebuilds and
125
+ # relaunches, so it doubles as a native-shell reload.
126
+ def launch_ios
127
+ app = Builders::Ios.new(config: @config, root: @config.root, target: "ios-arm64")
128
+ .build!(dist_dir: File.expand_path("dist", @config.root), dev: true)
33
129
 
34
- dev_url = "http://127.0.0.1:#{port}#{config.entry_path}"
35
- UI.step("opening desktop shell → #{UI.cyan(dev_url)}")
36
- Shellout.run!({ "NATIVE_DEV_URL" => dev_url, "NATIVE_CONFIG" => config.to_shell_json,
37
- "CARGO_TARGET_DIR" => Everywhere::Paths.cargo_target_dir },
38
- "cargo", "run", chdir: shell_dir)
130
+ udid = Simulator.boot_default!
131
+ UI.step("installing #{UI.bold(File.basename(app))}")
132
+ Simulator.install(udid, app)
133
+ UI.step("launching #{UI.cyan(@dev_url)}")
134
+ Simulator.launch(udid, @config.bundle_id(target: "ios-arm64"), env: { "EVERYWHERE_DEV_URL" => @dev_url })
135
+ end
136
+
137
+ def launch_browser
138
+ UI.step("opening browser → #{UI.cyan(@dev_url)}")
139
+ opener = RUBY_PLATFORM.include?("darwin") ? "open" : "xdg-open"
140
+ Shellout.run?(opener, @dev_url, quiet: true)
141
+ end
142
+
143
+ # Toggle the `every logs --ios` stream, interleaved with the server
144
+ # output on this same terminal.
145
+ def toggle_logs
146
+ if @logs_pid && alive?(@logs_pid)
147
+ stop(@logs_pid)
148
+ @logs_pid = nil
149
+ UI.step("stopped iOS Simulator logs")
150
+ return
151
+ end
152
+
153
+ udid = Simulator.booted_udid
154
+ unless udid
155
+ UI.warn "no booted iOS Simulator — press #{UI.bold("i")} first"
156
+ return
157
+ end
158
+ UI.step("watching iOS Simulator logs #{UI.dim("(press l again to stop)")}")
159
+ @logs_pid = Shellout.spawn({}, Logs.new.argv(udid).shelljoin, chdir: Dir.pwd)
160
+ end
161
+
162
+ # Key menu. io/console's getch holds full raw mode while blocked, which
163
+ # clears OPOST and stair-steps every log line the server prints; stty
164
+ # -icanon -echo disables only line buffering and echo, leaving output
165
+ # processing (and Ctrl-C as SIGINT) intact.
166
+ def interact
167
+ UI.note("press #{UI.bold("d")} desktop · #{UI.bold("i")} iOS Simulator · #{UI.bold("b")} browser · " \
168
+ "#{UI.bold("l")} iOS logs · #{UI.bold("q")} quit", marker: "⌨", color: :cyan)
169
+ saved_tty = `stty -g`.chomp
170
+ system("stty", "-icanon", "-echo")
171
+ loop do
172
+ case $stdin.getc
173
+ when "d" then launch("desktop")
174
+ when "i" then launch("ios")
175
+ when "b" then launch("browser")
176
+ when "l" then toggle_logs
177
+ when "q", "\u0004", nil then break # q, Ctrl-D, EOF (Ctrl-C stays a real SIGINT)
178
+ end
179
+ end
39
180
  ensure
40
- if server_pid
41
- Process.kill("TERM", -Process.getpgid(server_pid)) rescue Process.kill("TERM", server_pid) rescue nil
42
- Process.wait(server_pid) rescue nil
181
+ system("stty", saved_tty) unless saved_tty.to_s.empty?
182
+ end
183
+
184
+ def wait_for_children
185
+ (@server_thread || @desktop_thread)&.join
186
+ end
187
+
188
+ def teardown
189
+ [@server_pid, @desktop_pid, @logs_pid].compact.each { |pid| stop(pid) }
190
+ end
191
+
192
+ def stop(pid)
193
+ return unless alive?(pid)
194
+
195
+ begin
196
+ Process.kill("TERM", -Process.getpgid(pid))
197
+ rescue StandardError
198
+ Process.kill("TERM", pid) rescue nil
43
199
  end
44
200
  end
45
201
 
46
- private
202
+ def alive?(pid)
203
+ Process.kill(0, pid)
204
+ true
205
+ rescue Errno::ESRCH
206
+ false
207
+ rescue Errno::EPERM
208
+ true
209
+ end
47
210
 
48
211
  def port_open?(port)
49
212
  TCPSocket.new("127.0.0.1", Integer(port)).close
@@ -2,19 +2,27 @@
2
2
 
3
3
  require "dry/cli"
4
4
  require_relative "../shellout"
5
+ require_relative "../paths"
5
6
 
6
7
  module Everywhere
7
8
  module Commands
8
- # Check that this machine can build desktop apps.
9
+ # Check that this machine can build native apps. Desktop checks always
10
+ # run; the iOS section joins in when the app's build.targets include an
11
+ # ios target, or explicitly via --target ios-*.
9
12
  class Doctor < Dry::CLI::Command
10
- desc "Check the toolchain (rbe-tebako, rust, brew deps)"
13
+ desc "Check the toolchain (rbe-tebako, rust, brew deps; Xcode for iOS targets)"
11
14
 
12
- def call(**)
15
+ option :root, default: ".", desc: "App root (its build.targets pick the sections)"
16
+ option :target, default: nil, desc: "Also check a specific target's toolchain (e.g. ios-arm64)"
17
+
18
+ def call(root: ".", target: nil, **)
13
19
  ok = true
14
20
  ok &= check("rbe-tebako installed") { Shellout.run?("rbe-tebako help > /dev/null 2>&1") }
15
21
  ok &= check("cargo installed") { Shellout.run?("cargo --version > /dev/null 2>&1") }
16
22
  ok &= check("homebrew bison") { !`brew --prefix bison 2>/dev/null`.strip.empty? }
17
23
 
24
+ ok &= ios_checks if ios_wanted?(root, target)
25
+
18
26
  puts
19
27
  ok ? UI.success("ready to build") : UI.bad("fix the items above (see https://rubyeverywhere.com/docs/diy/requirements)")
20
28
  exit(1) unless ok
@@ -22,6 +30,40 @@ module Everywhere
22
30
 
23
31
  private
24
32
 
33
+ def ios_wanted?(root, target)
34
+ return true if target && Everywhere::Config.os_of(target) == "ios"
35
+
36
+ Everywhere::Config.load(File.expand_path(root)).targets
37
+ .any? { |t| Everywhere::Config.os_of(t) == "ios" }
38
+ rescue StandardError
39
+ false
40
+ end
41
+
42
+ def ios_checks
43
+ puts
44
+ UI.step("iOS target")
45
+ ok = check("full Xcode selected") { Shellout.run?("xcodebuild -version > /dev/null 2>&1") }
46
+ ok &= check("iOS Simulator SDK") do
47
+ Shellout.run?("xcrun", "--sdk", "iphonesimulator", "--show-sdk-path", quiet: true)
48
+ end
49
+ # A runtime matching the SDK's iOS version specifically: a half-installed
50
+ # platform (download interrupted mid-registration) leaves older runtimes
51
+ # in place and fails builds with "No simulator runtime version … available"
52
+ # / "Platform Not Installed". `xcodebuild -downloadPlatform iOS` fixes it.
53
+ ok &= check("Simulator runtime matches the iOS SDK (xcodebuild -downloadPlatform iOS)") do
54
+ sdk, = Shellout.capture("xcrun", "--sdk", "iphonesimulator", "--show-sdk-version")
55
+ version = sdk.strip[/\A\d+\.\d+/]
56
+ # `runtime list` shows the disk images; `list runtimes` can lag on
57
+ # lazily-mounted ones, so accept a Ready image OR a registered runtime.
58
+ images, = Shellout.capture("xcrun", "simctl", "runtime", "list")
59
+ runtimes, = Shellout.capture("xcrun", "simctl", "list", "runtimes")
60
+ !version.nil? && (images.match?(/iOS #{Regexp.escape(version)}.*\(Ready\)/) ||
61
+ runtimes.include?("iOS #{version}"))
62
+ end
63
+ ok &= check("bundled iOS shell template") { !Everywhere::Paths.ios_dir.nil? }
64
+ ok
65
+ end
66
+
25
67
  def check(label)
26
68
  passed = yield
27
69
  passed ? UI.ok(label) : UI.bad(label)
@@ -36,6 +36,11 @@ module Everywhere
36
36
  Everywhere::Icon.write_icns(master, File.join(out, "AppIcon.icns"))
37
37
  UI.ok("macOS icon AppIcon.icns")
38
38
 
39
+ background = ios_background(root_path)
40
+ Everywhere::Icon.write_ios_appiconset(source, File.join(out, "ios", "AppIcon.appiconset"),
41
+ background: background)
42
+ UI.ok("iOS icon ios/AppIcon.appiconset #{UI.dim("(full-bleed 1024px, alpha flattened)")}")
43
+
39
44
  Everywhere::Icon.write_ico(source, File.join(out, "icon.ico"))
40
45
  UI.ok("Windows icon icon.ico")
41
46
 
@@ -57,6 +62,15 @@ module Everywhere
57
62
  default = File.join(root_path, "icon.png")
58
63
  File.exist?(default) ? default : nil
59
64
  end
65
+
66
+ # The iOS icon's flatten backdrop: the app's light background color when
67
+ # set, else Icon's white default.
68
+ def ios_background(root_path)
69
+ color = Everywhere::Config.load(root_path).background_color
70
+ Everywhere::Icon.hex_rgb(color && color["light"])
71
+ rescue StandardError
72
+ nil
73
+ end
60
74
  end
61
75
  end
62
76
  end
@@ -66,13 +66,15 @@ module Everywhere
66
66
  # Rack frameworks vary too much to auto-edit their boot files safely, so we
67
67
  # vendor the bridge and print the remaining wiring rather than guess.
68
68
  def vendor_bridge_to_public
69
- source = File.expand_path("../../../bridge/everywhere/bridge.js", __dir__)
70
69
  target_dir = app_file("public")
71
70
  FileUtils.mkdir_p(target_dir)
72
- target = File.join(target_dir, "bridge.js")
73
- fresh = !File.exist?(target) || File.read(target) != File.read(source)
74
- FileUtils.cp(source, target) if fresh
75
- change("vendor @rubyeverywhere/bridge to public/bridge.js", fresh)
71
+ %w[bridge.js native.css].each do |name|
72
+ source = File.expand_path("../../../bridge/everywhere/#{name}", __dir__)
73
+ target = File.join(target_dir, name)
74
+ fresh = !File.exist?(target) || File.read(target) != File.read(source)
75
+ FileUtils.cp(source, target) if fresh
76
+ change("vendor @rubyeverywhere/bridge to public/#{name}", fresh)
77
+ end
76
78
  end
77
79
 
78
80
  def guide_rack_manual_steps(db:, boot:, script:)
@@ -130,6 +132,13 @@ module Everywhere
130
132
  light: "#FFFFFF"
131
133
  dark: "#1C1B1A"
132
134
 
135
+ # Native permissions the app may request — only declared ones can prompt.
136
+ # camera/location values are the sentence iOS shows when asking (mandatory).
137
+ # permissions:
138
+ # notifications: true
139
+ # camera: "Scan QR codes to pair devices."
140
+ # location: "Find things near you."
141
+
133
142
  # Native menu-bar items — each one visits a route in your app (no native code).
134
143
  # menu:
135
144
  # - label: Settings…
@@ -149,12 +158,34 @@ module Everywhere
149
158
  # - label: Quit
150
159
  # action: quit
151
160
 
161
+ # Mobile tab bar (iOS today, Android soon). Served live from
162
+ # /everywhere/ios_v1.json too, so tab changes deploy with the app —
163
+ # no app-store release. Icons: ios = SF Symbol name.
164
+ # tabs:
165
+ # - name: Home
166
+ # path: /
167
+ # icons:
168
+ # ios: house
169
+ # - name: Settings
170
+ # path: /settings
171
+ # icons:
172
+ # ios: gear
173
+
174
+ # iOS-specific knobs (and native Swift you compile into the shell —
175
+ # see /docs/diy/native-extensions).
176
+ # native:
177
+ # ios:
178
+ # lazy_load_tabs: true # defer each tab's first load until it's tapped
179
+ # # (false = load every tab up front; omit = default)
180
+
152
181
  # Durable build knobs (CLI flags still override per-run).
153
182
  # build:
154
183
  # ruby: "4.0.6"
155
184
  # targets: # the platforms you ship — the CI matrix, expressed once
156
185
  # - macos-arm64
157
- # # - ios-arm64 # future mobile is remote mode only
186
+ # # - ios-arm64 # Hotwire Native shell; remote mode only. Local builds
187
+ # # # (`every build` / `every dev --ios`) work today;
188
+ # # # hosted platform builds for iOS aren't live yet.
158
189
  # # - android-arm64 # future — mobile is remote mode only
159
190
  # permissions: [notifications, filesystem:read, clipboard]
160
191
 
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/cli"
4
+ require_relative "../simulator"
5
+ require_relative "../ui"
6
+
7
+ module Everywhere
8
+ module Commands
9
+ # Tail the native shell's logs. iOS only so far: the Simulator's unified
10
+ # log, scoped to the shell process. Watches (log stream) by default;
11
+ # --last replays recent history (log show) and exits.
12
+ class Logs < Dry::CLI::Command
13
+ desc "Stream native shell logs (iOS Simulator; watches by default)"
14
+
15
+ option :ios, type: :boolean, default: false, desc: "iOS Simulator logs (the only target so far)"
16
+ option :last, default: nil, desc: 'Show recent history instead of watching (e.g. "5m", "1h")'
17
+ option :udid, default: nil, desc: "Simulator UDID (default: the booted one)"
18
+ option :verbose, type: :boolean, default: false, desc: "Everything the shell process logs, debug level included"
19
+
20
+ # The template contract fixes the product name (App.app), so the shell's
21
+ # process on the Simulator is always "App" — one predicate covers every
22
+ # Everywhere app without knowing which one is installed. Scoping to the
23
+ # process alone still yields ~100k lines/half hour of Apple-framework
24
+ # chatter (WebKit resource loads, activities); the default predicate
25
+ # keeps what a shell developer logs — NSLog (Foundation sender, no
26
+ # subsystem), Hotwire Native's subsystem, UIKit warnings — and drops the
27
+ # rest. --verbose falls back to the whole process.
28
+ PREDICATE = 'process == "App" AND eventType == logEvent AND NOT subsystem BEGINSWITH "com.apple"'
29
+ VERBOSE_PREDICATE = 'process == "App"'
30
+
31
+ def call(ios: false, last: nil, udid: nil, verbose: false, **)
32
+ UI.die!("only the iOS Simulator is supported so far — run: every logs --ios") unless ios
33
+
34
+ udid ||= Everywhere::Simulator.booted_udid
35
+ UI.die!("no booted iOS Simulator — start one with `every dev --ios`") unless udid
36
+
37
+ UI.step(last ? "iOS Simulator logs from the last #{UI.bold(last)}"
38
+ : "watching iOS Simulator logs #{UI.dim("(Ctrl-C to stop)")}")
39
+ exec(*argv(udid, last: last, verbose: verbose))
40
+ end
41
+
42
+ # simctl spawn runs the `log` tool inside the Simulator runtime — the
43
+ # supported way to read a Simulator's unified log from the host. `log show`
44
+ # spells verbosity --info/--debug where `log stream` takes --level.
45
+ def argv(udid, last: nil, verbose: false)
46
+ argv = ["xcrun", "simctl", "spawn", udid, "log"]
47
+ argv += if last
48
+ ["show", "--last", last, *(verbose ? ["--info", "--debug"] : [])]
49
+ else
50
+ ["stream", *(verbose ? ["--level", "debug"] : [])]
51
+ end
52
+ argv + ["--style", "compact", "--predicate", verbose ? VERBOSE_PREDICATE : PREDICATE]
53
+ end
54
+ end
55
+ end
56
+ end
@@ -49,13 +49,17 @@ module Everywhere
49
49
  ).manifest
50
50
 
51
51
  build = submit(client, targets, channel, signed_id, manifest)
52
- UI.success("submitted #{UI.bold(build["id"])} for #{targets.join(", ")}")
52
+ UI.success("submitted #{UI.bold(build["id"])} for #{targets.map { |t| UI.target_label(t) }.join(", ")}")
53
53
  if (build_url = build["url"] || "#{client.base_url}/builds/#{build["id"]}")
54
54
  UI.substep("watch it live → #{UI.cyan(build_url)}")
55
55
  end
56
56
 
57
57
  return unless wait
58
58
 
59
+ # Set expectations before the quiet part: a hosted macOS runner is not
60
+ # instant, and knowing that up front is the difference between "slow"
61
+ # and "broken".
62
+ UI.substep(UI.dim("hosted macOS runners usually start within a few minutes"))
59
63
  final = poll_and_tail(client, build["id"])
60
64
  report(client, final, output || File.join(root_path, "dist"), download)
61
65
  end
@@ -94,7 +98,7 @@ module Everywhere
94
98
  UI.bad("can't build yet — missing signing credentials:")
95
99
  Array(res.body["targets"]).each do |t|
96
100
  tgt = t["target"] || {}
97
- UI.step("#{tgt["os"]}-#{tgt["arch"]}/#{tgt["distribution_channel"]}: add #{Array(t["missing"]).join(", ")}")
101
+ UI.step("#{UI.os_label(tgt["os"])}-#{tgt["arch"]}/#{tgt["distribution_channel"]}: add #{Array(t["missing"]).join(", ")}")
98
102
  end
99
103
  UI.step("set them up → #{UI.cyan(res.body["manage_url"])}")
100
104
  exit 1
@@ -102,24 +106,74 @@ module Everywhere
102
106
  UI.die!("build submission failed (HTTP #{res.status}) #{res.body["error"]}")
103
107
  end
104
108
 
109
+ # How often we ask the Platform for new logs. A build spends most of its
110
+ # wall clock before the first log byte (GitHub queue + toolchain), so poll
111
+ # lazily until something is actually building, then tighten up so the log
112
+ # tails smoothly.
113
+ IDLE_POLL = 5
114
+ ACTIVE_POLL = 2
115
+ SPINNER_TICK = 0.2
116
+
105
117
  def poll_and_tail(client, build_id)
106
118
  printed = Hash.new("")
107
119
  last = {}
120
+ started = now
121
+ status = UI::Status.new
122
+
108
123
  loop do
109
124
  res = client.get("/cli/builds/#{build_id}/logs")
110
- if res.ok?
111
- artifacts = res.body["artifacts"] || []
112
- artifacts.each do |artifact|
113
- tail_log(artifact, printed)
114
- announce_status(artifact, last)
115
- end
116
- break if artifacts.any? && artifacts.all? { |a| terminal?(a["status"]) }
125
+ artifacts = res.ok? ? (res.body["artifacts"] || []) : []
126
+
127
+ status.clear
128
+ artifacts.each do |artifact|
129
+ tail_log(artifact, printed)
130
+ announce_phase(artifact, last)
117
131
  end
118
- sleep(2)
132
+ break if artifacts.any? && artifacts.all? { |a| terminal?(a["status"]) }
133
+
134
+ interval = artifacts.any? { |a| a["status"] == "running" } ? ACTIVE_POLL : IDLE_POLL
135
+ spin(status, artifacts, started, interval)
119
136
  end
137
+
120
138
  client.get("/cli/builds/#{build_id}").body
139
+ ensure
140
+ status&.clear
141
+ end
142
+
143
+ # Hold the status line alive for `interval` seconds. The spinner ticks far
144
+ # faster than we poll so the line reads as "alive" during the multi-minute
145
+ # pre-runner wait rather than freezing between requests.
146
+ def spin(status, artifacts, started, interval)
147
+ deadline = now + interval
148
+ loop do
149
+ status.update("#{waiting_summary(artifacts)} · #{UI.elapsed(now - started)} elapsed")
150
+ remaining = deadline - now
151
+ break if remaining <= 0
152
+
153
+ sleep([remaining, SPINNER_TICK].min)
154
+ end
155
+ end
156
+
157
+ # What to show on the status line. One target names its phase; several
158
+ # collapse to a count, since per-target detail already lands as permanent
159
+ # `announce_phase` lines.
160
+ def waiting_summary(artifacts)
161
+ pending = artifacts.reject { |a| terminal?(a["status"]) }
162
+ return "waiting for the build" if pending.empty?
163
+ return phase_text(pending.first) if pending.size == 1
164
+
165
+ "#{pending.size} of #{artifacts.size} targets in progress"
166
+ end
167
+
168
+ # `phase_label` is a newer field; fall back to the status so this still
169
+ # says something useful against an older Platform.
170
+ def phase_text(artifact)
171
+ label = artifact["phase_label"].to_s
172
+ label.empty? ? artifact["status"].to_s : label
121
173
  end
122
174
 
175
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
176
+
123
177
  def tail_log(artifact, printed)
124
178
  full = artifact["log"].to_s
125
179
  prev = printed[artifact["id"]]
@@ -129,11 +183,16 @@ module Everywhere
129
183
  printed[artifact["id"]] = full
130
184
  end
131
185
 
132
- def announce_status(artifact, last)
133
- return if last[artifact["id"]] == artifact["status"]
186
+ # A permanent line per boundary crossed. Keyed on phase, not status: the
187
+ # whole point is that `status` sits on `queued` through the dispatch, the
188
+ # GitHub queue wait, and toolchain setup — three stages the developer used
189
+ # to experience as one unexplained silence.
190
+ def announce_phase(artifact, last)
191
+ key = artifact["phase"] || artifact["status"]
192
+ return if last[artifact["id"]] == key
134
193
 
135
- UI.step("#{label(artifact["target"])} → #{artifact["status"]}")
136
- last[artifact["id"]] = artifact["status"]
194
+ UI.step("#{label(artifact["target"])} → #{phase_text(artifact)}")
195
+ last[artifact["id"]] = key
137
196
  end
138
197
 
139
198
  def report(client, build, out_dir, download)
@@ -173,7 +232,7 @@ module Everywhere
173
232
  end
174
233
 
175
234
  def label(target)
176
- target ? "#{target["os"]}-#{target["arch"]}" : "target"
235
+ target ? "#{UI.os_label(target["os"])}-#{target["arch"]}" : "target"
177
236
  end
178
237
 
179
238
  def terminal?(status) = %w[succeeded failed].include?(status)
@@ -43,7 +43,7 @@ module Everywhere
43
43
 
44
44
  job = get!("/runner/artifacts/#{artifact}")
45
45
  patch!("/runner/artifacts/#{artifact}", status: "running")
46
- remote_log("picked up #{UI.cyan("#{job.dig("target", "os")}-#{job.dig("target", "arch")}")} for #{UI.bold(job.dig("app", "name"))}")
46
+ remote_log("picked up #{UI.cyan("#{UI.os_label(job.dig("target", "os"))}-#{job.dig("target", "arch")}")} for #{UI.bold(job.dig("app", "name"))}")
47
47
 
48
48
  Dir.mktmpdir("every-runner") do |work|
49
49
  src = fetch_snapshot(work, job)
@@ -149,6 +149,7 @@ module Everywhere
149
149
  end
150
150
 
151
151
  def complete_success(zip, release_json)
152
+ report_phase("uploading")
152
153
  remote_log("uploading artifact #{UI.dim("(#{(File.size(zip) / 1024.0 / 1024.0).round(1)}MB)")}")
153
154
  signed_id = upload_artifact(zip)
154
155
  post!("/runner/artifacts/#{@artifact}/complete",
@@ -200,6 +201,14 @@ module Everywhere
200
201
  nil # never let a log hiccup kill the build
201
202
  end
202
203
 
204
+ # Progress ping for the phases that produce no log output — same contract
205
+ # as the workflow's curl steps, and just as non-fatal.
206
+ def report_phase(name)
207
+ @client.post("/runner/artifacts/#{@artifact}/phase", phase: name)
208
+ rescue Everywhere::Error
209
+ nil
210
+ end
211
+
203
212
  # A runner-orchestration line: distinct from the `every release` steps it
204
213
  # brackets (tagged "runner"), rendered once and forwarded verbatim — color
205
214
  # and all — to the web log.
@@ -49,7 +49,7 @@ module Everywhere
49
49
  # 1. Produce (or reuse) the signed-for-dev .app via the build path. The
50
50
  # notarize step below re-signs it inside-out with the Developer ID.
51
51
  unless skip_build
52
- UI.phase("Building #{app_name} for #{target}")
52
+ UI.phase("Building #{app_name} for #{UI.target_label(target)}")
53
53
  Build.new.call(root: root, ruby: ruby, entry: entry, app_name: app_name, target: target, shell_dir: shell_dir, **)
54
54
  end
55
55