ruby_everywhere 0.4.0 → 0.6.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/README.md +99 -3
- data/bridge/README.md +70 -1
- data/bridge/everywhere/bridge.js +151 -0
- data/bridge/everywhere/native.css +61 -0
- data/lib/everywhere/agents_guide.rb +57 -0
- data/lib/everywhere/auth_handoff.rb +8 -2
- data/lib/everywhere/builders/desktop.rb +326 -0
- data/lib/everywhere/child_processes.rb +74 -0
- data/lib/everywhere/commands/build.rb +44 -5
- data/lib/everywhere/commands/dev.rb +426 -59
- data/lib/everywhere/commands/install.rb +93 -1
- data/lib/everywhere/commands/release.rb +2 -2
- data/lib/everywhere/config.rb +298 -4
- data/lib/everywhere/console.rb +117 -0
- data/lib/everywhere/desktop_assets.rb +150 -0
- data/lib/everywhere/dock/footer.rb +150 -0
- data/lib/everywhere/dock/screen.rb +114 -0
- data/lib/everywhere/dock/state.rb +59 -0
- data/lib/everywhere/dock.rb +238 -0
- data/lib/everywhere/emulator.rb +2 -2
- data/lib/everywhere/fatal.rb +20 -0
- data/lib/everywhere/line_pump.rb +89 -0
- data/lib/everywhere/log_filter.rb +37 -0
- data/lib/everywhere/native_helper.rb +38 -5
- data/lib/everywhere/paths.rb +62 -11
- data/lib/everywhere/relay.rb +77 -0
- data/lib/everywhere/shellout.rb +90 -15
- data/lib/everywhere/task_pool.rb +123 -0
- data/lib/everywhere/ui.rb +54 -11
- data/lib/everywhere/version.rb +1 -1
- data/support/agents/AGENTS.md +90 -0
- data/support/agents/frameworks/hanami-views.md +26 -0
- data/support/agents/frameworks/hanami.md +10 -0
- data/support/agents/frameworks/rails-views.md +98 -0
- data/support/agents/frameworks/rails.md +16 -0
- data/support/agents/frameworks/sinatra-views.md +24 -0
- data/support/agents/frameworks/sinatra.md +9 -0
- data/support/agents/modes/local.md +12 -0
- data/support/agents/modes/remote.md +14 -0
- data/support/desktop/README.md +121 -0
- data/support/{shell → desktop}/src-tauri/Cargo.lock +23 -0
- data/support/{shell → desktop}/src-tauri/Cargo.toml +19 -1
- data/support/{shell → desktop}/src-tauri/capabilities/default.json +7 -0
- data/support/desktop/src-tauri/gen/schemas/capabilities.json +1 -0
- data/support/desktop/src-tauri/src/extension_host.rs +53 -0
- data/support/desktop/src-tauri/src/extensions/mod.rs +39 -0
- data/support/{shell → desktop}/src-tauri/src/main.rs +355 -10
- data/support/{shell → desktop}/src-tauri/tauri.conf.json +2 -2
- data/support/{macos → release/macos}/notarize.sh +2 -2
- metadata +55 -22
- data/support/github/build.yml +0 -85
- data/support/shell/src-tauri/gen/schemas/capabilities.json +0 -1
- /data/support/{shell → desktop}/splash/index.html +0 -0
- /data/support/{shell → desktop}/src-tauri/build.rs +0 -0
- /data/support/{shell → desktop}/src-tauri/gen/schemas/acl-manifests.json +0 -0
- /data/support/{shell → desktop}/src-tauri/gen/schemas/desktop-schema.json +0 -0
- /data/support/{shell → desktop}/src-tauri/gen/schemas/macOS-schema.json +0 -0
- /data/support/{shell → desktop}/src-tauri/icons/icon.png +0 -0
- /data/support/{shell → desktop}/src-tauri/src/updater.rs +0 -0
- /data/support/{macos → release/macos}/entitlements.plist +0 -0
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "ui"
|
|
4
|
+
require_relative "console"
|
|
5
|
+
require_relative "dock/state"
|
|
6
|
+
require_relative "dock/footer"
|
|
7
|
+
require_relative "dock/screen"
|
|
8
|
+
|
|
9
|
+
module Everywhere
|
|
10
|
+
# The pinned status bar at the bottom of `every dev`.
|
|
11
|
+
#
|
|
12
|
+
# The old key menu was printed once, before the loop, and was gone from the
|
|
13
|
+
# screen within seconds of the Rails server starting to log. The dock keeps
|
|
14
|
+
# the keymap — and what each target is actually doing — permanently visible,
|
|
15
|
+
# while the logs scroll normally above it.
|
|
16
|
+
#
|
|
17
|
+
# It installs itself as Console's sink, so every write in the process (CLI
|
|
18
|
+
# chrome, relayed server output, filtered build output) is framed by an erase
|
|
19
|
+
# and a repaint. See Dock::Screen for why it repaints rather than reserving
|
|
20
|
+
# rows with a scroll region.
|
|
21
|
+
class Dock
|
|
22
|
+
TICK = 0.2
|
|
23
|
+
|
|
24
|
+
KEYS = [
|
|
25
|
+
["d", "desktop"],
|
|
26
|
+
["i", "iOS"],
|
|
27
|
+
["a", "Android"],
|
|
28
|
+
["b", "browser"],
|
|
29
|
+
["l", "logs"],
|
|
30
|
+
["r", "restart"],
|
|
31
|
+
["?", "help"],
|
|
32
|
+
["q", "quit"]
|
|
33
|
+
].freeze
|
|
34
|
+
|
|
35
|
+
HELP = [
|
|
36
|
+
["d", "relaunch the desktop shell (opens it if it isn't running)"],
|
|
37
|
+
["i", "rebuild, reinstall and relaunch on the iOS Simulator"],
|
|
38
|
+
["a", "rebuild, reinstall and relaunch on the Android emulator"],
|
|
39
|
+
["b", "open the app in your default browser"],
|
|
40
|
+
["l", "start/stop streaming iOS Simulator logs"],
|
|
41
|
+
["r", "restart the dev server only"],
|
|
42
|
+
["?", "hide this help"],
|
|
43
|
+
["q", "quit (Ctrl-C works too)"]
|
|
44
|
+
].freeze
|
|
45
|
+
|
|
46
|
+
# A dock needs room to be worth having, and a terminal that understands
|
|
47
|
+
# cursor movement. NO_COLOR is deliberately NOT a disqualifier: it governs
|
|
48
|
+
# SGR, not cursor control, and the footer's glyphs carry the meaning on
|
|
49
|
+
# their own.
|
|
50
|
+
MIN_ROWS = 12
|
|
51
|
+
MIN_COLS = 40
|
|
52
|
+
|
|
53
|
+
def self.open(targets, io: $stdout, keys: true, size: nil)
|
|
54
|
+
return Null.new(targets, keys: keys) unless drawable?(io, size)
|
|
55
|
+
|
|
56
|
+
new(targets, io: io, keys: keys, size: size).tap(&:install)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.drawable?(io, size)
|
|
60
|
+
return false if ENV["EVERY_NO_DOCK"]
|
|
61
|
+
return false unless io.respond_to?(:tty?) && io.tty?
|
|
62
|
+
return false if ENV["TERM"].to_s.empty? || ENV["TERM"] == "dumb"
|
|
63
|
+
|
|
64
|
+
rows, cols = size || Screen.new(io: io).size
|
|
65
|
+
rows >= MIN_ROWS && cols >= MIN_COLS
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def initialize(targets, io: $stdout, keys: true, size: nil)
|
|
69
|
+
@state = State.new(targets)
|
|
70
|
+
@screen = Screen.new(io: io, size: size)
|
|
71
|
+
@keys = keys
|
|
72
|
+
@help = false
|
|
73
|
+
@frame = 0
|
|
74
|
+
@dirty = true
|
|
75
|
+
@open = false
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
attr_reader :state
|
|
79
|
+
|
|
80
|
+
def install
|
|
81
|
+
@open = true
|
|
82
|
+
@screen.open
|
|
83
|
+
Console.dock = self
|
|
84
|
+
trap_resize
|
|
85
|
+
start_ticker
|
|
86
|
+
self
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# --- state --------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
def set(key, state, detail: nil)
|
|
92
|
+
@state.set(key, state, detail: detail)
|
|
93
|
+
@dirty = true
|
|
94
|
+
refresh
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def detail(key, text)
|
|
98
|
+
@state.detail(key, text)
|
|
99
|
+
@dirty = true
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def toggle_help
|
|
103
|
+
@help = !@help
|
|
104
|
+
@dirty = true
|
|
105
|
+
refresh
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# The dock draws the keymap permanently, so there is nothing to announce.
|
|
109
|
+
# Null overrides this to print the one-shot hint the old command used.
|
|
110
|
+
def announce_keys = nil
|
|
111
|
+
|
|
112
|
+
# --- drawing ------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
# Called by Console, which already holds the lock. Content must end at
|
|
115
|
+
# column 1, or the erase math for the next frame is off by a row.
|
|
116
|
+
def emit(text)
|
|
117
|
+
body = text.end_with?("\n") ? text : "#{text}\n"
|
|
118
|
+
@screen.frame(body, footer_rows)
|
|
119
|
+
@dirty = false
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def refresh
|
|
123
|
+
Console.synchronize do
|
|
124
|
+
next unless @open
|
|
125
|
+
|
|
126
|
+
@screen.frame("", footer_rows)
|
|
127
|
+
@dirty = false
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Wipe the footer and leave it wiped — for stderr, or an interactive child
|
|
132
|
+
# that needs the screen to itself.
|
|
133
|
+
def clear = @screen.clear
|
|
134
|
+
|
|
135
|
+
def close
|
|
136
|
+
return unless @open
|
|
137
|
+
|
|
138
|
+
# Order matters: drop the flag so the ticker stops drawing, let it finish
|
|
139
|
+
# the frame it may be mid-way through, and only then unhook and restore.
|
|
140
|
+
@open = false
|
|
141
|
+
@ticker&.join(TICK * 2)
|
|
142
|
+
@ticker&.kill
|
|
143
|
+
Console.dock = nil
|
|
144
|
+
Console.synchronize { @screen.close }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
private
|
|
148
|
+
|
|
149
|
+
# One column short of the terminal on purpose. A row filled to the last
|
|
150
|
+
# column leaves the cursor in a pending-wrap state whose handling varies
|
|
151
|
+
# between terminals, and a footer row that wraps costs the erase math a row.
|
|
152
|
+
def footer_rows
|
|
153
|
+
lines = Footer.render(@state.targets,
|
|
154
|
+
cols: [@screen.cols - 1, 1].max,
|
|
155
|
+
keys: (@keys ? KEYS : nil),
|
|
156
|
+
frame: @frame,
|
|
157
|
+
elapsed: ->(target) { @state.elapsed(target) },
|
|
158
|
+
rule: @screen.rows >= 24)
|
|
159
|
+
@help ? lines + help_rows : lines
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def help_rows
|
|
163
|
+
width = HELP.map { |key, _| key.length }.max
|
|
164
|
+
HELP.map { |key, desc| " #{UI.bold(key.ljust(width))} #{UI.dim(desc)}" }
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# An idle dev session should emit zero bytes per second, so only redraw when
|
|
168
|
+
# something changed or a spinner is actually turning.
|
|
169
|
+
def start_ticker
|
|
170
|
+
@ticker = Thread.new do
|
|
171
|
+
Thread.current.name = "dock"
|
|
172
|
+
Thread.current.report_on_exception = false
|
|
173
|
+
while @open
|
|
174
|
+
sleep TICK
|
|
175
|
+
next unless @open
|
|
176
|
+
|
|
177
|
+
# Screen#size re-reads the ioctl every frame, so a resize needs
|
|
178
|
+
# nothing more than a redraw.
|
|
179
|
+
if @resized
|
|
180
|
+
@resized = false
|
|
181
|
+
@dirty = true
|
|
182
|
+
end
|
|
183
|
+
busy = @state.busy?
|
|
184
|
+
@frame += 1 if busy
|
|
185
|
+
refresh if busy || @dirty
|
|
186
|
+
end
|
|
187
|
+
rescue StandardError
|
|
188
|
+
# A dock that can't draw must never be the reason a dev session dies.
|
|
189
|
+
@open = false
|
|
190
|
+
Console.dock = nil
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Trap handlers must not take a lock (Ruby raises ThreadError when the lock
|
|
195
|
+
# is held in trap context), so this only sets a flag; the ticker draws.
|
|
196
|
+
def trap_resize
|
|
197
|
+
Signal.trap("WINCH") { @resized = true }
|
|
198
|
+
rescue ArgumentError, StandardError
|
|
199
|
+
nil
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# The headless stand-in: same API, no escape sequences ever. Used for CI,
|
|
203
|
+
# pipes, `| tee`, dumb terminals and windows too small to spare the rows.
|
|
204
|
+
# It narrates transitions as ordinary lines so a piped log still reads.
|
|
205
|
+
#
|
|
206
|
+
# Note it never installs itself as Console's sink — output keeps going
|
|
207
|
+
# straight to $stdout, exactly as it did before the dock existed.
|
|
208
|
+
class Null
|
|
209
|
+
def initialize(targets, keys: true)
|
|
210
|
+
@state = State.new(targets)
|
|
211
|
+
@keys = keys
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
attr_reader :state
|
|
215
|
+
|
|
216
|
+
def install = self
|
|
217
|
+
|
|
218
|
+
def set(key, state, detail: nil)
|
|
219
|
+
target = @state.set(key, state, detail: detail) or return
|
|
220
|
+
UI.step("#{target.label} → #{[state, detail].compact.join(" ")}")
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def detail(_key, _text) = nil
|
|
224
|
+
def toggle_help = nil
|
|
225
|
+
def emit(text) = nil
|
|
226
|
+
def refresh = nil
|
|
227
|
+
def clear = nil
|
|
228
|
+
def close = nil
|
|
229
|
+
|
|
230
|
+
# The one-shot keymap the old command printed, kept for headless runs.
|
|
231
|
+
def announce_keys
|
|
232
|
+
return unless @keys
|
|
233
|
+
|
|
234
|
+
UI.note(KEYS.map { |key, desc| "#{UI.bold(key)} #{desc}" }.join(" · "), marker: "⌨", color: :cyan)
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
data/lib/everywhere/emulator.rb
CHANGED
|
@@ -372,7 +372,7 @@ module Everywhere
|
|
|
372
372
|
# go to /dev/null: they would otherwise land in the middle of the dev
|
|
373
373
|
# server's log, and nothing here reads them.
|
|
374
374
|
def spawn_emulator(argv)
|
|
375
|
-
pid =
|
|
375
|
+
pid = Process.spawn(Shellout.child_env, *argv, pgroup: true, out: File::NULL, err: File::NULL)
|
|
376
376
|
Process.detach(pid)
|
|
377
377
|
pid
|
|
378
378
|
end
|
|
@@ -384,7 +384,7 @@ module Everywhere
|
|
|
384
384
|
# Waiting on the child ourselves is the only way to put a ceiling on it, so
|
|
385
385
|
# the "never came up" path reaches a message a human can act on.
|
|
386
386
|
def bounded_wait(argv, timeout: BOOT_TIMEOUT)
|
|
387
|
-
pid =
|
|
387
|
+
pid = Process.spawn(Shellout.child_env, *argv, out: File::NULL, err: File::NULL)
|
|
388
388
|
deadline = Time.now + timeout
|
|
389
389
|
loop do
|
|
390
390
|
return true if Process.waitpid(pid, Process::WNOHANG)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Everywhere
|
|
4
|
+
# A failure that ends the current unit of work — not necessarily the process.
|
|
5
|
+
#
|
|
6
|
+
# It subclasses SystemExit deliberately. At the top level Ruby exits with
|
|
7
|
+
# #status and prints nothing (UI.die! has already written the ✗ line), so an
|
|
8
|
+
# uncaught Fatal behaves exactly like the Kernel#abort it replaces. Inside
|
|
9
|
+
# `every dev` a worker thread catches it instead and returns to the key menu,
|
|
10
|
+
# which is the whole point: a Swift typo must not take the Rails server down
|
|
11
|
+
# with it.
|
|
12
|
+
#
|
|
13
|
+
# CAUTION for anyone catching this in a thread: MRI enqueues a SystemExit that
|
|
14
|
+
# escapes a non-main thread onto the MAIN thread, which re-raises it wherever
|
|
15
|
+
# that thread happens to be. A worker must therefore `rescue Exception`, not
|
|
16
|
+
# `rescue Everywhere::Fatal` — see TaskPool.
|
|
17
|
+
class Fatal < SystemExit
|
|
18
|
+
def initialize(status = 1, message = nil) = super
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Everywhere
|
|
4
|
+
# Turns the raw byte stream off a pty master into whole lines.
|
|
5
|
+
#
|
|
6
|
+
# `every dev` relays the dev server and the desktop shell itself so it can tag
|
|
7
|
+
# each line with its source. That relay has to be line-oriented — a half-line
|
|
8
|
+
# written to the terminal would leave the cursor mid-row and the dock repaints
|
|
9
|
+
# from column 1 — but a pty hands over arbitrary chunks, so this buffers.
|
|
10
|
+
#
|
|
11
|
+
# Two pty-specific quirks it exists to absorb:
|
|
12
|
+
#
|
|
13
|
+
# * ONLCR. The slave's line discipline turns every LF into CRLF, so lines
|
|
14
|
+
# arrive as "text\r\n". The trailing CR is an artifact, not content.
|
|
15
|
+
# * In-place rewrites. cargo (and anything with a progress bar) redraws a
|
|
16
|
+
# row by writing CR and the new text without a newline. What the terminal
|
|
17
|
+
# would have shown is whatever followed the LAST CR, so that is what gets
|
|
18
|
+
# emitted — one settled line instead of thirty flickering ones.
|
|
19
|
+
#
|
|
20
|
+
# Pure: feed it strings, get lines back. No IO, no threads, no clock beyond
|
|
21
|
+
# the idle deadline the caller passes in.
|
|
22
|
+
class LinePump
|
|
23
|
+
# How long a partial line may sit unterminated before it is emitted anyway.
|
|
24
|
+
# Without this a progress bar that never writes a newline — or a prompt like
|
|
25
|
+
# "Overwrite? [Yn]" — would be invisible for as long as it mattered.
|
|
26
|
+
IDLE_FLUSH = 0.2
|
|
27
|
+
|
|
28
|
+
def initialize(idle_flush: IDLE_FLUSH, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
29
|
+
# Bytes in, text out. A pty hands over BINARY, and a chunk boundary can
|
|
30
|
+
# land in the middle of a multi-byte character, so buffering and splitting
|
|
31
|
+
# happen on bytes — UTF-8 is self-synchronizing, so a newline is never
|
|
32
|
+
# inside a character — and each settled line is transcoded on the way out.
|
|
33
|
+
@buffer = String.new(encoding: Encoding::BINARY)
|
|
34
|
+
@idle_flush = idle_flush
|
|
35
|
+
@clock = clock
|
|
36
|
+
@touched = nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Feed a chunk; returns the complete lines it produced (without newlines).
|
|
40
|
+
def <<(chunk)
|
|
41
|
+
@buffer << chunk.b
|
|
42
|
+
@touched = @clock.call
|
|
43
|
+
lines = []
|
|
44
|
+
while (index = @buffer.index("\n"))
|
|
45
|
+
raw = @buffer.slice!(0, index + 1)
|
|
46
|
+
line = settle(raw.chomp("\n"))
|
|
47
|
+
lines << line if line
|
|
48
|
+
end
|
|
49
|
+
lines
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Emit a still-unterminated partial line once it has gone quiet. Call this
|
|
53
|
+
# from the relay loop whenever a read times out.
|
|
54
|
+
def flush_idle
|
|
55
|
+
return [] if @buffer.empty?
|
|
56
|
+
return [] if @touched && (@clock.call - @touched) < @idle_flush
|
|
57
|
+
|
|
58
|
+
lines = drain
|
|
59
|
+
@touched = @clock.call
|
|
60
|
+
lines
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Emit whatever is left, terminated or not. For end-of-stream.
|
|
64
|
+
def drain
|
|
65
|
+
return [] if @buffer.empty?
|
|
66
|
+
|
|
67
|
+
line = settle(@buffer)
|
|
68
|
+
@buffer = +""
|
|
69
|
+
line ? [line] : []
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def pending? = !@buffer.empty?
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
# chomp the ONLCR artifact first, THEN collapse an in-place rewrite —
|
|
77
|
+
# order matters. Doing it the other way round reads "bar\r" as a rewrite to
|
|
78
|
+
# the empty string and swallows the line.
|
|
79
|
+
#
|
|
80
|
+
# scrub, not just force_encoding: a build tool can emit a stray byte that is
|
|
81
|
+
# not valid UTF-8, and a String that claims an encoding it doesn't satisfy
|
|
82
|
+
# raises the moment anything tries to concatenate a tag onto it.
|
|
83
|
+
def settle(raw)
|
|
84
|
+
line = raw.chomp("\r")
|
|
85
|
+
line = line[(line.rindex("\r") + 1)..] if line.include?("\r")
|
|
86
|
+
line.dup.force_encoding(Encoding::UTF_8).scrub
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -31,6 +31,7 @@ module Everywhere
|
|
|
31
31
|
when :tebako then tebako(line)
|
|
32
32
|
when :xcodebuild then xcodebuild(line)
|
|
33
33
|
when :gradle then gradle(line)
|
|
34
|
+
when :cargo then cargo(line)
|
|
34
35
|
end
|
|
35
36
|
end
|
|
36
37
|
|
|
@@ -227,6 +228,42 @@ module Everywhere
|
|
|
227
228
|
end
|
|
228
229
|
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength
|
|
229
230
|
|
|
231
|
+
# --- `every dev`'s desktop shell (cargo) ----------------------------------
|
|
232
|
+
|
|
233
|
+
# A cold `cargo run` compiles ~300 crates, one "Compiling foo v0.1.0" line
|
|
234
|
+
# each, plus a progress bar it redraws several times a second. None of that
|
|
235
|
+
# is actionable while you're waiting on your own app — but warnings, errors
|
|
236
|
+
# and the moment the shell actually starts very much are.
|
|
237
|
+
#
|
|
238
|
+
# Unlike xcodebuild and Gradle (which we run with --console=plain), cargo is
|
|
239
|
+
# talking to a pty here and colors everything, so its status lines start
|
|
240
|
+
# with SGR codes and carry OSC-8 hyperlinks. Match on the stripped text;
|
|
241
|
+
# print the original, since that color is cargo's and worth keeping.
|
|
242
|
+
#
|
|
243
|
+
# The fall-through returns the line verbatim rather than nil, so it is NOT
|
|
244
|
+
# dimmed as background detail: once the shell is up, everything arriving on
|
|
245
|
+
# this stream is the app's own output, and the body of a Rust compile error
|
|
246
|
+
# is exactly what you need to read in full.
|
|
247
|
+
def cargo(line)
|
|
248
|
+
plain = UI.strip_ansi(line)
|
|
249
|
+
case plain
|
|
250
|
+
when /\A\s*error(\[E\d+\])?[:\[]/
|
|
251
|
+
" #{UI.red(UI.short_path(plain))}"
|
|
252
|
+
when /\A\s*warning: /
|
|
253
|
+
" #{UI.yellow(UI.short_path(plain))}"
|
|
254
|
+
when /\A\s*Compiling /
|
|
255
|
+
once(:compiling) { UI.note_line("compiling the desktop shell (first run is slow)") }
|
|
256
|
+
when /\A\s*(Downloaded|Downloading|Updating|Building \[|Blocking|Locking)/
|
|
257
|
+
:drop
|
|
258
|
+
when /\A\s*Finished\b/
|
|
259
|
+
UI.note_line("desktop shell compiled", marker: "✓", color: :green)
|
|
260
|
+
when /\A\s*Running\b/
|
|
261
|
+
UI.note_line("desktop shell running", marker: "✓", color: :green)
|
|
262
|
+
else
|
|
263
|
+
line
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
230
267
|
# Show a line only the first time its key is seen; :drop the repeats. Keeps a
|
|
231
268
|
# long notarization from scrolling a wall of identical "waiting" lines (and
|
|
232
269
|
# notarytool's thrice-echoed submission id from showing three times).
|
|
@@ -10,25 +10,52 @@ module Everywhere
|
|
|
10
10
|
# does on the client:
|
|
11
11
|
#
|
|
12
12
|
# <%= link_to "Install the app", … unless native_app? %>
|
|
13
|
-
# <div class="<%= "native-inset" if
|
|
13
|
+
# <div class="<%= "native-inset" if mobile_app? %>">
|
|
14
14
|
# <% if native_version && native_version >= Gem::Version.new("1.2") %>
|
|
15
15
|
#
|
|
16
16
|
# Detection is by User-Agent: the RubyEverywhere shells prepend
|
|
17
17
|
# "RubyEverywhere/<version> (<os>)" to Hotwire Native's own
|
|
18
18
|
# "Hotwire Native iOS/Android" marker. Included into all Rails views by
|
|
19
19
|
# Everywhere::Engine.
|
|
20
|
+
#
|
|
21
|
+
# Pick the narrowest one that's true of what you're branching on. `native_app?`
|
|
22
|
+
# means "not a browser tab" and covers the desktop shell; `mobile_app?` means
|
|
23
|
+
# a phone, with a tab bar and safe-area insets; `desktop_app?` means a window,
|
|
24
|
+
# with a title bar and a menu bar. Safe-area padding is mobile_app?; hiding a
|
|
25
|
+
# web nav in favour of native chrome is usually mobile_app? too — the desktop
|
|
26
|
+
# shell has no native navigation to replace it with.
|
|
20
27
|
module NativeHelper
|
|
21
|
-
# True inside any RubyEverywhere native shell
|
|
28
|
+
# True inside any RubyEverywhere native shell — iOS, Android or desktop.
|
|
29
|
+
# Mirrors the bridge's `Everywhere.native`.
|
|
22
30
|
def native_app?
|
|
23
31
|
!native_platform.nil?
|
|
24
32
|
end
|
|
25
33
|
|
|
26
|
-
# :ios, :android, or nil in a plain browser. Reads
|
|
34
|
+
# :ios, :android, :desktop, or nil in a plain browser. Reads the shell's UA
|
|
27
35
|
# marker, so it's correct even before the JS bridge has booted.
|
|
28
36
|
def native_platform
|
|
29
37
|
Everywhere.native_platform_of(_everywhere_user_agent)
|
|
30
38
|
end
|
|
31
39
|
|
|
40
|
+
# True in the Tauri desktop shell. Use it for window chrome — reserving room
|
|
41
|
+
# for an overlay title bar, say — and for anything that assumes a pointer
|
|
42
|
+
# and a resizable window.
|
|
43
|
+
def desktop_app?
|
|
44
|
+
native_platform == :desktop
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# True in the iOS or Android shell, and the one to reach for when the
|
|
48
|
+
# affordance is a phone's: safe-area insets, the native tab bar, biometrics,
|
|
49
|
+
# the OAuth handoff. Mirrors the bridge's `Everywhere.platform === "mobile"`.
|
|
50
|
+
def mobile_app?
|
|
51
|
+
!mobile_platform.nil?
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# :ios, :android, or nil (including in the desktop shell).
|
|
55
|
+
def mobile_platform
|
|
56
|
+
Everywhere.mobile_platform_of(_everywhere_user_agent)
|
|
57
|
+
end
|
|
58
|
+
|
|
32
59
|
# The shell's version as a Gem::Version (from the "RubyEverywhere/<ver>"
|
|
33
60
|
# UA prefix), or nil outside the shell / when unparseable. Use it to gate
|
|
34
61
|
# features that need a newer shell than some users have installed.
|
|
@@ -49,8 +76,11 @@ module Everywhere
|
|
|
49
76
|
# redirect_to everywhere_auth_redirect(after_authentication_url)
|
|
50
77
|
#
|
|
51
78
|
# `to` may be a full URL or a path; only the same-origin path is forwarded.
|
|
79
|
+
# mobile_app?, not native_app?: the reset page exists to rebuild a phone
|
|
80
|
+
# shell's web views and re-fetch its tab bar. The desktop shell has neither,
|
|
81
|
+
# so a plain redirect is both correct and what it already did.
|
|
52
82
|
def everywhere_auth_redirect(to)
|
|
53
|
-
return to unless
|
|
83
|
+
return to unless mobile_app? && respond_to?(:everywhere_reset_path)
|
|
54
84
|
|
|
55
85
|
path = to.to_s.sub(%r{\Ahttps?://[^/]+}, "")
|
|
56
86
|
path = "/" if path.empty? || !path.start_with?("/")
|
|
@@ -104,7 +134,10 @@ module Everywhere
|
|
|
104
134
|
wrapper_class: nil, locked_class: nil,
|
|
105
135
|
message_class: nil, unlock_class: nil, &block)
|
|
106
136
|
content = respond_to?(:capture) ? capture(&block) : block.call
|
|
107
|
-
|
|
137
|
+
# mobile_app?: there's no Face ID on the desktop shell, so emitting the
|
|
138
|
+
# locked wrapper there would hide the content behind an unlock nothing can
|
|
139
|
+
# satisfy. Browsers and the desktop shell render it plainly.
|
|
140
|
+
return content unless mobile_app?
|
|
108
141
|
|
|
109
142
|
attrs = [%(data-everywhere-biometric-lock="#{_everywhere_attr(name)}")]
|
|
110
143
|
attrs << %(data-everywhere-biometric-reason="#{_everywhere_attr(reason)}") if reason
|
data/lib/everywhere/paths.rb
CHANGED
|
@@ -12,24 +12,28 @@ module Everywhere
|
|
|
12
12
|
File.expand_path("../..", __dir__)
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
# The generic Tauri shell bundled inside the gem: support/
|
|
16
|
-
# src-tauri app and the splash/ dir it serves
|
|
17
|
-
#
|
|
15
|
+
# The generic Tauri shell bundled inside the gem: support/desktop/ holds the
|
|
16
|
+
# src-tauri app and the splash/ dir it serves, alongside support/mobile/ios
|
|
17
|
+
# and support/mobile/android. This is the CANONICAL copy of the shell —
|
|
18
|
+
# external consumers (e.g. the build-runner repo) resolve it via
|
|
18
19
|
# `every shell-dir`. The CLI uses it unless the caller passes --shell-dir.
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
#
|
|
21
|
+
# The directory is named for the platform; the reader keeps the "shell"
|
|
22
|
+
# wording because that's what the flag, the command and every caller call it.
|
|
23
|
+
def bundled_desktop_dir
|
|
24
|
+
File.join(gem_root, "support", "desktop", "src-tauri")
|
|
21
25
|
end
|
|
22
26
|
|
|
23
27
|
# Absolute path to the shell's src-tauri directory, or nil if the bundled
|
|
24
28
|
# copy is missing (a corrupt/partial install).
|
|
25
29
|
def shell_dir
|
|
26
|
-
|
|
30
|
+
bundled_desktop_dir if File.exist?(File.join(bundled_desktop_dir, "tauri.conf.json"))
|
|
27
31
|
end
|
|
28
32
|
|
|
29
33
|
# Like #shell_dir but aborts with a helpful message when the shell is
|
|
30
34
|
# missing. The --shell-dir hint covers dev on a modified shell checkout.
|
|
31
35
|
def shell_dir!
|
|
32
|
-
shell_dir or UI.die!("couldn't find the bundled shell at #{
|
|
36
|
+
shell_dir or UI.die!("couldn't find the bundled shell at #{bundled_desktop_dir}; " \
|
|
33
37
|
"reinstall ruby_everywhere or pass --shell-dir")
|
|
34
38
|
end
|
|
35
39
|
|
|
@@ -126,10 +130,57 @@ module Everywhere
|
|
|
126
130
|
File.join(cache_dir, "android-fonts")
|
|
127
131
|
end
|
|
128
132
|
|
|
129
|
-
#
|
|
130
|
-
#
|
|
131
|
-
# the
|
|
132
|
-
#
|
|
133
|
+
# The stamped copy of the Tauri shell for one app, keyed by bundle id.
|
|
134
|
+
# Only apps that declare `native.desktop` get one — everyone else compiles
|
|
135
|
+
# the gem's own copy, which is identical for them and stays warm across
|
|
136
|
+
# projects (see cargo_target_dir). Persistent for the same reason the mobile
|
|
137
|
+
# work dirs are: cargo's incremental state lives inside it, and a cold Tauri
|
|
138
|
+
# build is minutes where a warm one is seconds.
|
|
139
|
+
def desktop_work_dir(bundle_id)
|
|
140
|
+
File.join(cache_dir, "desktop", bundle_id)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# CARGO_TARGET_DIR for a stamped shell. Per-app rather than shared, because
|
|
144
|
+
# the whole point of stamping is that this app's crate graph differs from
|
|
145
|
+
# every other app's — pointing them at one target dir would make cargo
|
|
146
|
+
# rebuild the world on every alternating build.
|
|
147
|
+
def desktop_target_dir(bundle_id)
|
|
148
|
+
File.join(cache_dir, "desktop-target", bundle_id)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Where `every dev` keeps the throwaway .app wrapper it runs the shell
|
|
152
|
+
# inside. macOS reads the application name from CFBundleName, so a bare
|
|
153
|
+
# `cargo run` binary shows up as the crate name — "example-shell" — in the
|
|
154
|
+
# menu bar, the About/Hide/Quit items and the Dock. The wrapper exists only
|
|
155
|
+
# to carry an Info.plist; the binary inside it is a hard link to cargo's.
|
|
156
|
+
def desktop_dev_app_dir(bundle_id)
|
|
157
|
+
File.join(cache_dir, "desktop-app", bundle_id)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Where `every dev` stages the app's Dock icon for the running shell. The
|
|
161
|
+
# shaped master is derived from app.icon, so it's a build product, not the
|
|
162
|
+
# app's own file — it belongs in the cache next to the rest of them.
|
|
163
|
+
def desktop_icon_dir(bundle_id)
|
|
164
|
+
File.join(cache_dir, "desktop-icon", bundle_id)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Where `every dev` stages native/desktop/assets for the running shell.
|
|
168
|
+
# A staged copy rather than the source tree on purpose: the packaged app
|
|
169
|
+
# reads ONE flat Resources/assets folder, and pointing dev at the nested
|
|
170
|
+
# source would quietly resolve assets/branding/logo.png in a build and not
|
|
171
|
+
# in dev. Staging runs the same DesktopAssets compiler both ways, so the two
|
|
172
|
+
# can't drift. Keyed by bundle id like the mobile work dirs.
|
|
173
|
+
def desktop_assets_dir(bundle_id)
|
|
174
|
+
File.join(cache_dir, "desktop-assets", bundle_id)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Where cargo writes the shell's build output (CARGO_TARGET_DIR) for apps on
|
|
178
|
+
# the fast path — the ones that declare no `native.desktop`. Redirected here
|
|
179
|
+
# so cargo never dumps a multi-GB target/ into the installed gem, and so the
|
|
180
|
+
# shell compiles warm across projects: with no app Rust in it the shell
|
|
181
|
+
# source really is identical for everyone, so one shared target dir is safe
|
|
182
|
+
# to reuse. An app that declares extensions gets desktop_target_dir instead,
|
|
183
|
+
# because that premise stops holding the moment its crate graph differs.
|
|
133
184
|
def cargo_target_dir
|
|
134
185
|
File.join(cache_dir, "shell-target")
|
|
135
186
|
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "console"
|
|
4
|
+
require_relative "line_pump"
|
|
5
|
+
require_relative "ui"
|
|
6
|
+
|
|
7
|
+
module Everywhere
|
|
8
|
+
# Reads one child's output off a pty and republishes it, one whole line at a
|
|
9
|
+
# time, tagged with its source.
|
|
10
|
+
#
|
|
11
|
+
# `every dev` used to hand the dev server and desktop shell its own stdout and
|
|
12
|
+
# let them write straight to the terminal. That made two things impossible:
|
|
13
|
+
# telling which of four concurrent things printed a line, and keeping anything
|
|
14
|
+
# pinned to the bottom of the screen. Relaying costs a thread per child and
|
|
15
|
+
# buys both.
|
|
16
|
+
#
|
|
17
|
+
# The full raw stream is also tee'd to a log file, matching what the builders
|
|
18
|
+
# already do with dist/ios-build.log — so nothing a filter hides is lost.
|
|
19
|
+
class Relay
|
|
20
|
+
CHUNK = 4096
|
|
21
|
+
POLL = 0.1
|
|
22
|
+
|
|
23
|
+
# on_line: an optional callback given every settled line before filtering.
|
|
24
|
+
# `every dev` uses it to notice the moment cargo actually starts the desktop
|
|
25
|
+
# shell — there is no other signal that the build turned into a running app.
|
|
26
|
+
def initialize(io, source:, log: nil, filter: nil, on_line: nil)
|
|
27
|
+
@io = io
|
|
28
|
+
@source = source
|
|
29
|
+
@filter = filter
|
|
30
|
+
@on_line = on_line
|
|
31
|
+
@log = log && File.open(log, "w")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Blocking; run it in a thread. Returns when the child closes the pty.
|
|
35
|
+
def run
|
|
36
|
+
Console.tag = @source
|
|
37
|
+
pump = LinePump.new
|
|
38
|
+
loop do
|
|
39
|
+
if @io.wait_readable(POLL)
|
|
40
|
+
chunk = @io.read_nonblock(CHUNK, exception: false)
|
|
41
|
+
break if chunk.nil? # EOF
|
|
42
|
+
|
|
43
|
+
publish(pump << chunk) unless chunk == :wait_readable
|
|
44
|
+
else
|
|
45
|
+
publish(pump.flush_idle)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
publish(pump.drain)
|
|
49
|
+
rescue Errno::EIO, IOError, Errno::EBADF
|
|
50
|
+
# A pty master raises EIO (not EOF) once the last slave closes, which is
|
|
51
|
+
# simply how a child exiting looks from this side.
|
|
52
|
+
publish(pump.drain) if pump
|
|
53
|
+
ensure
|
|
54
|
+
@log&.close
|
|
55
|
+
begin
|
|
56
|
+
@io.close
|
|
57
|
+
rescue StandardError
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def publish(lines)
|
|
65
|
+
lines.each do |line|
|
|
66
|
+
@log&.puts(line)
|
|
67
|
+
@on_line&.call(line)
|
|
68
|
+
shown = @filter ? @filter.call(line) : line
|
|
69
|
+
case shown
|
|
70
|
+
when :drop, false then nil
|
|
71
|
+
when nil then Console.puts(" #{UI.gray(UI.short_path(line))}")
|
|
72
|
+
else Console.puts(shown)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|