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
data/lib/everywhere/shellout.rb
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "ui"
|
|
4
|
+
require_relative "console"
|
|
5
|
+
require_relative "child_processes"
|
|
4
6
|
|
|
5
7
|
module Everywhere
|
|
6
8
|
# All external commands (tebako, cargo, the app's own bin/dev) run outside
|
|
@@ -8,27 +10,57 @@ module Everywhere
|
|
|
8
10
|
module Shellout
|
|
9
11
|
module_function
|
|
10
12
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
+
# The environment a child should get: this gem's bundle stripped back out,
|
|
14
|
+
# plus the caller's overrides.
|
|
15
|
+
#
|
|
16
|
+
# This is handed to spawn as its env argument rather than swapped into ENV,
|
|
17
|
+
# which is what Bundler.with_unbundled_env does (ENV.replace, twice, around
|
|
18
|
+
# the block). That is process-global mutable state, and `every dev` now runs
|
|
19
|
+
# the iOS and Android builds concurrently: two overlapping swaps race so that
|
|
20
|
+
# one child inherits the CLI's bundle after all — the exact thing unbundling
|
|
21
|
+
# exists to prevent — and ENV is left wrong for the rest of the session.
|
|
22
|
+
#
|
|
23
|
+
# A nil value tells spawn to REMOVE that key, which is most of what
|
|
24
|
+
# unbundling is. The resulting child environment is identical to what
|
|
25
|
+
# with_unbundled_env produced, since Bundler.unbundled_env is derived from
|
|
26
|
+
# the snapshot taken before Bundler was activated either way.
|
|
27
|
+
def child_env(overrides = {})
|
|
28
|
+
base = unbundled_base
|
|
29
|
+
delta = {}
|
|
30
|
+
ENV.each_key { |key| delta[key] = nil unless base.key?(key) }
|
|
31
|
+
base.each { |key, value| delta[key] = value unless ENV[key] == value }
|
|
32
|
+
delta.merge(overrides.transform_keys(&:to_s))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Bundler.unbundled_env derives from `original_env`, a snapshot frozen at
|
|
36
|
+
# load time, so this is genuinely constant for the life of the process.
|
|
37
|
+
def unbundled_base
|
|
38
|
+
@unbundled_base ||= defined?(Bundler) ? Bundler.unbundled_env : ENV.to_h
|
|
13
39
|
end
|
|
14
40
|
|
|
15
41
|
def run!(env, *cmd, chdir: Dir.pwd)
|
|
16
|
-
success =
|
|
42
|
+
success = system(child_env(env), *cmd, chdir: chdir)
|
|
17
43
|
UI.die!("command failed: #{cmd.join(" ")}") unless success
|
|
18
44
|
end
|
|
19
45
|
|
|
46
|
+
# quiet also detaches stdin. A quiet command is by definition one that has
|
|
47
|
+
# nothing to say to the terminal, and `every dev` reads single keystrokes
|
|
48
|
+
# from that same terminal — a child left holding stdin silently eats the
|
|
49
|
+
# keypresses the menu is waiting on.
|
|
20
50
|
def run?(*cmd, chdir: Dir.pwd, quiet: false)
|
|
21
51
|
opts = { chdir: chdir }
|
|
22
|
-
opts.update(out: File::NULL, err: File::NULL) if quiet
|
|
23
|
-
|
|
52
|
+
opts.update(in: File::NULL, out: File::NULL, err: File::NULL) if quiet
|
|
53
|
+
system(child_env, *cmd, **opts)
|
|
24
54
|
end
|
|
25
55
|
|
|
26
56
|
# Run a command and capture its combined stdout+stderr. Returns
|
|
27
57
|
# [output_string, Process::Status]. For probing tool output (codesign,
|
|
28
|
-
# stapler, notarytool) rather than driving a build.
|
|
58
|
+
# stapler, notarytool) rather than driving a build. A leading Hash is env
|
|
59
|
+
# overrides, matching Open3's own convention.
|
|
29
60
|
def capture(*cmd)
|
|
30
61
|
require "open3"
|
|
31
|
-
|
|
62
|
+
overrides = cmd.first.is_a?(Hash) ? cmd.shift : {}
|
|
63
|
+
Open3.capture2e(child_env(overrides), *cmd)
|
|
32
64
|
rescue Errno::ENOENT
|
|
33
65
|
["", nil]
|
|
34
66
|
end
|
|
@@ -40,18 +72,32 @@ module Everywhere
|
|
|
40
72
|
# prettify what a human sees; the on-disk log always gets the raw stream, so
|
|
41
73
|
# nothing is lost. Unrecognized lines (filter returns nil) are shown dimmed
|
|
42
74
|
# and indented so they read as background detail under the current step.
|
|
75
|
+
#
|
|
76
|
+
# The signature is deliberately unchanged: the builder tests stub this with
|
|
77
|
+
# fixed-signature lambdas, and cancellation is wired up out of band through
|
|
78
|
+
# ChildProcesses instead.
|
|
43
79
|
def run_logged!(env, cmd, log:, filter: nil)
|
|
44
80
|
require "open3"
|
|
45
81
|
success = false
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
82
|
+
File.open(log, "w") do |logf|
|
|
83
|
+
# Inside an `every dev` worker the build leads its own process group,
|
|
84
|
+
# for two reasons: a Ctrl-C at the terminal no longer reaches
|
|
85
|
+
# xcodebuild/gradlew behind the CLI's back (the CLI decides when a build
|
|
86
|
+
# dies), and teardown can TERM the whole compile tree with one signal.
|
|
87
|
+
# Outside dev this stays off, so Ctrl-C during `every build` still stops
|
|
88
|
+
# the compiler as it always has.
|
|
89
|
+
opts = ChildProcesses.isolated? ? { pgroup: true } : {}
|
|
90
|
+
Open3.popen2e(child_env(env), *cmd, **opts) do |stdin, out, wait|
|
|
91
|
+
stdin.close
|
|
92
|
+
ChildProcesses.track(wait.pid)
|
|
93
|
+
begin
|
|
50
94
|
out.each_line do |raw|
|
|
51
95
|
logf.write(raw)
|
|
52
96
|
emit(raw, filter)
|
|
53
97
|
end
|
|
54
98
|
success = wait.value.success?
|
|
99
|
+
ensure
|
|
100
|
+
ChildProcesses.untrack(wait.pid)
|
|
55
101
|
end
|
|
56
102
|
end
|
|
57
103
|
end
|
|
@@ -61,13 +107,13 @@ module Everywhere
|
|
|
61
107
|
# Route one raw line to the console: verbatim when there's no filter, else
|
|
62
108
|
# the filter's rendition (:drop swallows it, nil falls back to dim detail).
|
|
63
109
|
def emit(raw, filter)
|
|
64
|
-
return
|
|
110
|
+
return Console.print(raw) unless filter
|
|
65
111
|
|
|
66
112
|
shown = filter.call(raw)
|
|
67
113
|
case shown
|
|
68
114
|
when :drop, false then nil
|
|
69
|
-
when nil then
|
|
70
|
-
else
|
|
115
|
+
when nil then Console.puts(" #{UI.gray(UI.short_path(raw.rstrip))}")
|
|
116
|
+
else Console.puts(shown)
|
|
71
117
|
end
|
|
72
118
|
end
|
|
73
119
|
private_class_method :emit
|
|
@@ -75,7 +121,36 @@ module Everywhere
|
|
|
75
121
|
# pgroup: the child leads its own process group so teardown can TERM the
|
|
76
122
|
# whole tree (foreman + watchers) without signalling the CLI itself.
|
|
77
123
|
def spawn(env, cmd, chdir:)
|
|
78
|
-
|
|
124
|
+
Process.spawn(child_env(env), cmd, chdir: chdir, pgroup: true)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Same, but on a pseudo-terminal, returning [master_io, pid].
|
|
128
|
+
#
|
|
129
|
+
# `every dev` relays the dev server's and desktop shell's output itself so
|
|
130
|
+
# it can tag each line with its source and keep the dock pinned at the
|
|
131
|
+
# bottom. A plain pipe would work, but the children would see a non-TTY and
|
|
132
|
+
# foreman, cargo and friends would drop their colors and progress output; on
|
|
133
|
+
# a pty they behave exactly as they do when you run them by hand.
|
|
134
|
+
#
|
|
135
|
+
# PTY.spawn calls setsid, so the child leads its own session: a terminal
|
|
136
|
+
# SIGINT can't reach it, and Process.kill("TERM", -pid) still takes down the
|
|
137
|
+
# whole tree.
|
|
138
|
+
def spawn_pty(env, cmd, chdir:)
|
|
139
|
+
require "pty"
|
|
140
|
+
# PTY.spawn hands back the read and write ends of the pty MASTER. We never
|
|
141
|
+
# forward stdin, so the write end is closed immediately — the child keeps
|
|
142
|
+
# running because the read end still holds the master open.
|
|
143
|
+
reader, writer, pid = PTY.spawn(child_env(env), cmd, chdir: chdir)
|
|
144
|
+
writer.close
|
|
145
|
+
[reader, pid]
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# PTY is POSIX-only. Callers fall back to spawn + inherited fds.
|
|
149
|
+
def pty?
|
|
150
|
+
require "pty"
|
|
151
|
+
true
|
|
152
|
+
rescue LoadError
|
|
153
|
+
false
|
|
79
154
|
end
|
|
80
155
|
end
|
|
81
156
|
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "console"
|
|
4
|
+
require_relative "child_processes"
|
|
5
|
+
|
|
6
|
+
module Everywhere
|
|
7
|
+
# Named background tasks for the `every dev` key loop.
|
|
8
|
+
#
|
|
9
|
+
# Three guarantees, each of which fixes a specific way the old synchronous
|
|
10
|
+
# command misbehaved:
|
|
11
|
+
#
|
|
12
|
+
# * At most one live task per name. Pressing `i` twice must not run two
|
|
13
|
+
# iOS builds over the same staged work dir, which Builders::Ios rm_rf's
|
|
14
|
+
# and re-copies on every run.
|
|
15
|
+
# * No task failure can reach the main thread. A Swift compile error used to
|
|
16
|
+
# unwind through the key loop into Dev#call's `ensure teardown` and take
|
|
17
|
+
# the Rails server down with it.
|
|
18
|
+
# * Every task is cancellable. Its children lead their own process groups
|
|
19
|
+
# and are TERMed (then KILLed) at shutdown.
|
|
20
|
+
class TaskPool
|
|
21
|
+
# A task's failure is data, not control flow — hence `rescue Exception`, one
|
|
22
|
+
# of the few places it is the correct choice. MRI enqueues a SystemExit that
|
|
23
|
+
# escapes a non-main thread onto the MAIN thread and re-raises it there,
|
|
24
|
+
# wherever that thread happens to be. Since UI.die! raises Everywhere::Fatal
|
|
25
|
+
# (a SystemExit), `rescue StandardError` here would leave the original bug
|
|
26
|
+
# fully intact, just relocated. Verified:
|
|
27
|
+
#
|
|
28
|
+
# ruby -e 'Thread.new { raise SystemExit.new(2) }; sleep 1; puts "alive"'
|
|
29
|
+
# # => exits 2, never prints
|
|
30
|
+
GRACE = 5
|
|
31
|
+
|
|
32
|
+
def initialize(on_error: nil)
|
|
33
|
+
@lock = Mutex.new
|
|
34
|
+
@threads = {}
|
|
35
|
+
@failed = []
|
|
36
|
+
@stopping = false
|
|
37
|
+
@on_error = on_error
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# :started, :busy or :stopping.
|
|
41
|
+
def start(name, &block)
|
|
42
|
+
@lock.synchronize do
|
|
43
|
+
return :stopping if @stopping
|
|
44
|
+
return :busy if @threads[name]&.alive?
|
|
45
|
+
|
|
46
|
+
@threads[name] = spawn_worker(name, &block)
|
|
47
|
+
retag
|
|
48
|
+
:started
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def busy?(name) = @lock.synchronize { !!@threads[name]&.alive? }
|
|
53
|
+
|
|
54
|
+
def failed = @lock.synchronize { @failed.dup }
|
|
55
|
+
|
|
56
|
+
def any_running? = @lock.synchronize { @threads.each_value.any?(&:alive?) }
|
|
57
|
+
|
|
58
|
+
# Join every live task. Used by the headless path, where there is no key
|
|
59
|
+
# loop to keep the process alive and a failed build still has to be news.
|
|
60
|
+
def wait_all
|
|
61
|
+
loop do
|
|
62
|
+
live = @lock.synchronize { @threads.each_value.select(&:alive?) }
|
|
63
|
+
break if live.empty?
|
|
64
|
+
|
|
65
|
+
live.each(&:join)
|
|
66
|
+
end
|
|
67
|
+
Console.multiplexed = false
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Kill the tasks' children first, then the threads. Ordering matters: a
|
|
71
|
+
# worker blocked reading a build's output only unwinds once that build is
|
|
72
|
+
# gone, so signalling the children is what actually makes the joins return.
|
|
73
|
+
def shutdown(grace: GRACE)
|
|
74
|
+
live = @lock.synchronize do
|
|
75
|
+
@stopping = true
|
|
76
|
+
@threads.each_value.select(&:alive?)
|
|
77
|
+
end
|
|
78
|
+
return if live.empty?
|
|
79
|
+
|
|
80
|
+
live.each { |thread| ChildProcesses.terminate(thread, grace: grace) }
|
|
81
|
+
deadline = now + grace
|
|
82
|
+
live.each { |thread| thread.join([deadline - now, 0].max) }
|
|
83
|
+
live.select(&:alive?).each(&:kill)
|
|
84
|
+
Console.multiplexed = false
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
# Called while holding @lock; the new thread also takes @lock in its ensure.
|
|
90
|
+
# That is not a deadlock — the worker simply blocks until start returns —
|
|
91
|
+
# but it is why these two must not be collapsed into one synchronize block.
|
|
92
|
+
def spawn_worker(name, &block)
|
|
93
|
+
Thread.new do
|
|
94
|
+
# Set from inside the thread, as its first statement: assigning it after
|
|
95
|
+
# Thread.new returns races a task that fails immediately.
|
|
96
|
+
Thread.current.name = name.to_s
|
|
97
|
+
Thread.current.report_on_exception = false
|
|
98
|
+
Console.tag = name
|
|
99
|
+
ChildProcesses.isolate!
|
|
100
|
+
|
|
101
|
+
begin
|
|
102
|
+
block.call
|
|
103
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
104
|
+
stopping = @lock.synchronize { @failed << name; @stopping }
|
|
105
|
+
@on_error&.call(name, e, stopping)
|
|
106
|
+
ensure
|
|
107
|
+
@lock.synchronize { retag }
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Tag output by source only while two or more tasks are live: with one build
|
|
113
|
+
# running, the output is exactly what it has always been. Thread.current is
|
|
114
|
+
# excluded because a worker calling this from its own ensure is still
|
|
115
|
+
# #alive? and would keep tagging on for the task that just finished.
|
|
116
|
+
def retag
|
|
117
|
+
live = @threads.each_value.count { |t| t.alive? && t != Thread.current }
|
|
118
|
+
Console.multiplexed = live > 1
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
122
|
+
end
|
|
123
|
+
end
|
data/lib/everywhere/ui.rb
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "fatal"
|
|
4
|
+
require_relative "console"
|
|
5
|
+
|
|
3
6
|
module Everywhere
|
|
4
7
|
# Terminal output helpers. Colors follow the informal standard:
|
|
5
8
|
# on when stdout is a TTY or CLICOLOR_FORCE=1, always off when NO_COLOR is set.
|
|
@@ -69,14 +72,18 @@ module Everywhere
|
|
|
69
72
|
def warn_line(msg) = "#{yellow("!")} #{msg}"
|
|
70
73
|
def success_line(msg) = "#{green("✓")} #{bold(msg)}"
|
|
71
74
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
def
|
|
77
|
-
def
|
|
78
|
-
def
|
|
79
|
-
def
|
|
75
|
+
# Everything prints through Console, which serializes concurrent writers and
|
|
76
|
+
# gives the `every dev` dock a chance to repaint around each line. With no
|
|
77
|
+
# dock installed it is a plain $stdout.write, resolved per call so
|
|
78
|
+
# capture_io still works.
|
|
79
|
+
def phase(msg) = Console.puts(phase_line(msg))
|
|
80
|
+
def step(msg) = Console.puts(step_line(msg))
|
|
81
|
+
def substep(msg) = Console.puts(substep_line(msg))
|
|
82
|
+
def detail(msg) = Console.puts(detail_line(msg))
|
|
83
|
+
def ok(msg) = Console.puts(ok_line(msg))
|
|
84
|
+
def bad(msg) = Console.puts(bad_line(msg))
|
|
85
|
+
def warn(msg) = Console.puts(warn_line(msg))
|
|
86
|
+
def success(msg) = Console.puts(success_line(msg))
|
|
80
87
|
|
|
81
88
|
# An indented, dim outcome under a substep — e.g. "accepted", "valid on disk".
|
|
82
89
|
def note_line(msg, marker: "·", color: :gray)
|
|
@@ -84,15 +91,28 @@ module Everywhere
|
|
|
84
91
|
" #{tint.call(marker)} #{tint.call(msg)}"
|
|
85
92
|
end
|
|
86
93
|
|
|
87
|
-
def note(msg, marker: "·", color: :gray) = puts(note_line(msg, marker: marker, color: color))
|
|
94
|
+
def note(msg, marker: "·", color: :gray) = Console.puts(note_line(msg, marker: marker, color: color))
|
|
88
95
|
|
|
96
|
+
# Ends the current unit of work. At the top level Ruby exits 1 and prints
|
|
97
|
+
# nothing extra, so this behaves exactly like the Kernel#abort it replaced —
|
|
98
|
+
# but inside `every dev` the worker thread running the build catches the
|
|
99
|
+
# Fatal, marks that target failed, and hands you back the key menu with the
|
|
100
|
+
# dev server still serving. The message is the same string abort received,
|
|
101
|
+
# so every assert_raises(SystemExit) reading err.message is unaffected.
|
|
89
102
|
def die!(msg)
|
|
90
|
-
|
|
103
|
+
line = "#{red("✗")} #{red(msg)}"
|
|
104
|
+
Console.error(line)
|
|
105
|
+
raise Everywhere::Fatal.new(1, line)
|
|
91
106
|
end
|
|
92
107
|
|
|
93
108
|
# A yes/no fact, colored by truthiness (for signing receipts).
|
|
94
109
|
def yn(bool) = bool ? green("yes") : red("no")
|
|
95
110
|
|
|
111
|
+
# Braille spinner frames, shared by the transient Status line and the
|
|
112
|
+
# `every dev` dock. Ten frames at ~200ms is one revolution every two
|
|
113
|
+
# seconds — legible as motion without being distracting.
|
|
114
|
+
FRAMES = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
|
|
115
|
+
|
|
96
116
|
# --- transient status line -------------------------------------------------
|
|
97
117
|
|
|
98
118
|
# A single self-rewriting line for waits that produce no output — chiefly
|
|
@@ -104,8 +124,12 @@ module Everywhere
|
|
|
104
124
|
# line every HEADLESS_INTERVAL seconds. Callers MUST `clear` before writing
|
|
105
125
|
# anything else — `update` tracks whether a line is currently on screen so
|
|
106
126
|
# `clear` is cheap and idempotent.
|
|
127
|
+
#
|
|
128
|
+
# NOTE: this writes to its `io:` directly, NOT through Console, so it is not
|
|
129
|
+
# serialized against other writers. That's fine for its one caller
|
|
130
|
+
# (`every platform build`, single-threaded) — do not reuse it concurrently.
|
|
107
131
|
class Status
|
|
108
|
-
FRAMES =
|
|
132
|
+
FRAMES = UI::FRAMES
|
|
109
133
|
HEADLESS_INTERVAL = 30 # seconds between plain lines when not a TTY
|
|
110
134
|
|
|
111
135
|
def initialize(io: $stdout)
|
|
@@ -157,6 +181,25 @@ module Everywhere
|
|
|
157
181
|
"#{secs / 3600}h #{(secs % 3600) / 60}m"
|
|
158
182
|
end
|
|
159
183
|
|
|
184
|
+
# --- ANSI ------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
# Escape sequences a tool writes when it thinks it's on a terminal — which,
|
|
187
|
+
# since `every dev` relays its children over a pty, they all do. Two forms
|
|
188
|
+
# matter in practice: SGR color (CSI), and OSC-8 hyperlinks, which cargo
|
|
189
|
+
# wraps around the profile name in its own status lines.
|
|
190
|
+
ANSI = /
|
|
191
|
+
\e\][^\a\e]*(?:\a|\e\\) # OSC ... terminated by BEL or ST
|
|
192
|
+
|
|
|
193
|
+
\e\[[0-9;?]*[ -\/]*[@-~] # CSI ... final byte
|
|
194
|
+
|
|
|
195
|
+
\e[@-Z\\-_] # two-character escapes
|
|
196
|
+
/x
|
|
197
|
+
|
|
198
|
+
# For MATCHING, never for display: a filter that pattern-matches colored
|
|
199
|
+
# output has to look past the codes, but the colors are still what the
|
|
200
|
+
# developer wants to read.
|
|
201
|
+
def strip_ansi(str) = str.to_s.gsub(ANSI, "")
|
|
202
|
+
|
|
160
203
|
# --- path shortening ------------------------------------------------------
|
|
161
204
|
|
|
162
205
|
# Collapse the noisy machine paths that external tools echo (codesign,
|
data/lib/everywhere/version.rb
CHANGED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
## RubyEverywhere
|
|
2
|
+
|
|
3
|
+
This is a {{FRAMEWORK}} app that also ships as a native desktop and mobile app, via the
|
|
4
|
+
[`ruby_everywhere`](https://rubyeverywhere.com) gem. The desktop shell is Tauri, the mobile
|
|
5
|
+
shells are Hotwire Native, and all three are driven from one config file
|
|
6
|
+
(`config/everywhere.yml`), one CLI (`every`, aliased `rbe`), and one JavaScript API
|
|
7
|
+
(`Everywhere`) with server-side helpers to match.
|
|
8
|
+
|
|
9
|
+
**The web app is the source of truth.** You do not edit the shells — you edit the app, its
|
|
10
|
+
views, and the config. Full docs: <https://rubyeverywhere.com/docs>.
|
|
11
|
+
|
|
12
|
+
### How this app reaches its users: {{MODE}} mode
|
|
13
|
+
|
|
14
|
+
{{MODE_NOTES}}
|
|
15
|
+
|
|
16
|
+
{{FRAMEWORK_VIEWS}}
|
|
17
|
+
|
|
18
|
+
### config/everywhere.yml
|
|
19
|
+
|
|
20
|
+
Everything native is declared here: tabs, path rules, permissions, window chrome, menus,
|
|
21
|
+
tray items, colors, deep links, auto-updates. Top-level keys: `app`, `remote`, `appearance`,
|
|
22
|
+
`tabs`, `rules`, `permissions`, `menu`, `tray`, `window`, `native`, `auth`, `deep_linking`,
|
|
23
|
+
`build`, `updates`, `platforms`. Full reference:
|
|
24
|
+
<https://rubyeverywhere.com/docs/shared/everywhere-yml>.
|
|
25
|
+
|
|
26
|
+
Things that bite:
|
|
27
|
+
|
|
28
|
+
- **`app.bundle_id` is identity, not cosmetics.** It names the app-data directory on every
|
|
29
|
+
platform (and the macOS bundle id). Renaming it orphans users' local data and settings.
|
|
30
|
+
- **Permissions gate everything on mobile.** A permission that isn't declared under
|
|
31
|
+
`permissions:` resolves to `undeclared` and never prompts. `camera` and `location` must carry
|
|
32
|
+
the sentence iOS shows when asking, or the build fails.
|
|
33
|
+
- **`tabs:` and `rules:` deploy with the web app.** They're baked into the bundle *and* served
|
|
34
|
+
live from `/everywhere/ios_v1.json` and `/everywhere/android_v1.json`, so changing a tab or a
|
|
35
|
+
presentation rule ships with your next deploy — no app-store release.
|
|
36
|
+
- Most other keys (`window`, `menu`, `tray`, `permissions`, `appearance`) are read at build
|
|
37
|
+
time and do need a new build.
|
|
38
|
+
|
|
39
|
+
### The JavaScript bridge
|
|
40
|
+
|
|
41
|
+
One API across browser, desktop, and mobile. It degrades gracefully in a plain browser, so
|
|
42
|
+
you can call it unconditionally.
|
|
43
|
+
|
|
44
|
+
```js
|
|
45
|
+
import Everywhere from "@rubyeverywhere/bridge"
|
|
46
|
+
window.Everywhere = Everywhere
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Surface: `platform` (`"desktop" | "mobile" | "browser"`), `os`, `native`, `version`,
|
|
50
|
+
`notify()`, `confirm()`, `on()`, `visit()`, `menu()`, `reloadTabs()`, `resetApp()`, and the
|
|
51
|
+
namespaces `auth`, `clipboard`, `haptics`, `permissions`, `biometrics`, `instance`, `storage`,
|
|
52
|
+
`badge`, `desktop` (`desktop.invoke`), `window` (`minimize`/`maximize`/`close`/`startDragging`/…),
|
|
53
|
+
and `updates` (`check`/`install`/`setChannel`/`on`).
|
|
54
|
+
|
|
55
|
+
Reach for the bridge when something has to happen in response to JS. For anything you can
|
|
56
|
+
render, prefer the markup above — it's CSP-safe, correct on first paint, and works in a
|
|
57
|
+
browser without a code path of its own.
|
|
58
|
+
|
|
59
|
+
### Commands
|
|
60
|
+
|
|
61
|
+
Run these from the app root.
|
|
62
|
+
|
|
63
|
+
| Command | What it does |
|
|
64
|
+
| --- | --- |
|
|
65
|
+
| `every dev` | Dev server plus native shells on demand, live reload (`--desktop --ios --android --mobile --browser`) |
|
|
66
|
+
| `every doctor` | Check the toolchain before a first build |
|
|
67
|
+
| `every build` | Build the native shells — desktop, or `--ios` / `--android` |
|
|
68
|
+
| `every logs` | Stream shell logs from the iOS Simulator or an Android device |
|
|
69
|
+
| `every icon` | Generate `.icns` / `.ico` / Linux icons from a source PNG |
|
|
70
|
+
| `every release` | Sign, notarize, staple, and emit `dist/release.json` |
|
|
71
|
+
| `every publish` | Publish a release to the app's update bucket |
|
|
72
|
+
| `every clean` | Remove the shell build caches |
|
|
73
|
+
|
|
74
|
+
`every dev` never packages anything — it runs the ordinary dev server and points the shells
|
|
75
|
+
at it, so the edit-refresh loop is unchanged.
|
|
76
|
+
|
|
77
|
+
Two directories are build output, never source: `dist/` (artifacts) and `~/.rubyeverywhere`
|
|
78
|
+
(stamped shell projects and caches). Don't commit them, don't hand-edit them — the next build
|
|
79
|
+
overwrites both.
|
|
80
|
+
|
|
81
|
+
### Config `every install` touched
|
|
82
|
+
|
|
83
|
+
These exist so this codebase can also be compiled and shipped on-device, and several of them
|
|
84
|
+
look like mistakes if you don't know that. Leave them in place — full rationale at
|
|
85
|
+
<https://rubyeverywhere.com/docs>.
|
|
86
|
+
|
|
87
|
+
{{FRAMEWORK_NOTES}}
|
|
88
|
+
|
|
89
|
+
Re-running `every install` is idempotent and skips anything already in place, so it's the
|
|
90
|
+
right way to restore one of these if it goes missing.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
### Native chrome from your views
|
|
2
|
+
|
|
3
|
+
The ERB helpers (`native_app?`, `everywhere_nav_button`, `everywhere_fab`, …) ship with the
|
|
4
|
+
Rails engine, so they aren't available here. The markup contract behind them is public,
|
|
5
|
+
though, and the shell watches for it on every Turbo visit — write it directly:
|
|
6
|
+
|
|
7
|
+
- `data-everywhere-nav-button` (+ `-nav-title`, `-nav-icon`, `-nav-icon-ios`,
|
|
8
|
+
`-nav-icon-android`, `-nav-style`, `side`) — lifts a link or submit button into the top
|
|
9
|
+
navigation bar. Tapping the native control clicks your element.
|
|
10
|
+
- `data-everywhere-nav-menu` with `data-everywhere-menu-item` children (+ `-menu-title`,
|
|
11
|
+
`-menu-style`) — a native pull-down menu.
|
|
12
|
+
- `data-everywhere-menu` + `-menu-trigger` + `-menu-items` — an in-content action sheet.
|
|
13
|
+
- `data-everywhere-haptic` — a tap haptic.
|
|
14
|
+
- `data-everywhere-biometric-lock` (+ `-content`, `-locked`, `-unlock`, `-reason`,
|
|
15
|
+
`-passcode`) — the Face ID / Touch ID gate.
|
|
16
|
+
- `<meta name="everywhere:badge">` and `<meta name="everywhere:tab-badge">` (JSON
|
|
17
|
+
`{path, count}`) — app-icon and tab badges, CSP-safe and correct on first paint.
|
|
18
|
+
|
|
19
|
+
Detect the shell server-side from the User-Agent: the shells prepend
|
|
20
|
+
`RubyEverywhere/<version> (<os>)` to Hotwire Native's own marker. Client-side, use
|
|
21
|
+
`Everywhere.platform` / `Everywhere.native`.
|
|
22
|
+
|
|
23
|
+
`public/native.css`, served via `Rack::Static`, carries the styling helpers — including
|
|
24
|
+
`.everywhere-titlebar-inset` for `window.title_bar: overlay`. Hanami's CSP forbids inline
|
|
25
|
+
scripts, so every bit of this has to be attributes and external files; that's the same reason
|
|
26
|
+
the markup contract exists in the first place.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
- `native_boot.rb` — the packaged-app entry point. Keep it thin and keep it at the app root.
|
|
2
|
+
- `public/bridge.js` and `public/native.css` — the bridge, vendored and refreshed on each
|
|
3
|
+
`every install`. Serve `public/` via `Rack::Static` and load `/bridge.js` as an **external**
|
|
4
|
+
module; Hanami's CSP forbids inline scripts.
|
|
5
|
+
|
|
6
|
+
Rack apps vary too much for the CLI to auto-edit their boot files, so the rest is by hand and
|
|
7
|
+
`every install` prints it rather than guessing: override `DATABASE_URL` from
|
|
8
|
+
`ENV["NATIVE_STORAGE_DIR"]` in `config.ru` when `NATIVE_PACKAGED` is set, and migrate before
|
|
9
|
+
Hanami finalizes (`Hanami.prepare`, run the ROM migrations, then `require "hanami/boot"`).
|
|
10
|
+
Both only matter for on-device packaging.
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
### Native chrome from your views
|
|
2
|
+
|
|
3
|
+
`Everywhere::Engine` mixes a set of helpers into every view. **Prefer these over hand-written
|
|
4
|
+
markup or JavaScript.** They render a real link or button that works in a browser, and carry
|
|
5
|
+
the `data-everywhere-*` attributes the shell lifts into native chrome — tapping the native
|
|
6
|
+
control just clicks the element it mirrors, so behavior is defined once and CSP is never an
|
|
7
|
+
issue.
|
|
8
|
+
|
|
9
|
+
Icons are per-platform, like tabs: `icon:` is the shared fallback, `icons: { ios:, android: }`
|
|
10
|
+
names an SF Symbol and a Material Symbol respectively.
|
|
11
|
+
|
|
12
|
+
**Where am I?** Pick the narrowest predicate that's true of what you're branching on.
|
|
13
|
+
|
|
14
|
+
| Helper | |
|
|
15
|
+
| --- | --- |
|
|
16
|
+
| `native_app?` | any shell — iOS, Android, or desktop. "Not a browser tab." |
|
|
17
|
+
| `mobile_app?` | a phone: safe-area insets, the native tab bar, biometrics, the OAuth handoff |
|
|
18
|
+
| `desktop_app?` | the desktop window: title bar, menu bar, pointer |
|
|
19
|
+
| `native_platform` | `:ios`, `:android`, `:desktop`, or `nil` in a browser |
|
|
20
|
+
| `mobile_platform` | `:ios`, `:android`, or `nil` (including on desktop) |
|
|
21
|
+
| `native_version` | the shell's version as a `Gem::Version` — gate features that need a newer shell |
|
|
22
|
+
|
|
23
|
+
Detection is by User-Agent, so it's correct on the server before the JS bridge has booted.
|
|
24
|
+
Hiding a web nav in favor of native chrome is usually `mobile_app?`, not `native_app?` — the
|
|
25
|
+
desktop shell has no native navigation to replace it with.
|
|
26
|
+
|
|
27
|
+
**Navigation bar.** Renders in the page for browsers; the shell hides the in-page copy and
|
|
28
|
+
puts it in the top bar.
|
|
29
|
+
|
|
30
|
+
```erb
|
|
31
|
+
<%= everywhere_nav_button "New", new_note_path, icons: { ios: "plus", android: "add" } %>
|
|
32
|
+
<%= everywhere_nav_button "Back", notes_path, side: "left" %>
|
|
33
|
+
|
|
34
|
+
<%= form_with model: @note do |f| %>
|
|
35
|
+
<%= everywhere_submit_button "Save" %> <%# mirrors the form's submit into the nav bar %>
|
|
36
|
+
<% end %>
|
|
37
|
+
|
|
38
|
+
<%= everywhere_nav_menu do %> <%# ⋯ pull-down; a native UIMenu in the shell %>
|
|
39
|
+
<%= everywhere_menu_item "Share", share_path, icons: { ios: "square.and.arrow.up" } %>
|
|
40
|
+
<%= everywhere_menu_item "Delete", note_path(@note), method: :delete, style: "destructive" %>
|
|
41
|
+
<% end %>
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`everywhere_nav_button` takes `side:` (`"right"` / `"left"`), `style:` (`"done"`,
|
|
45
|
+
`"destructive"`), and `type: :submit`. `everywhere_menu_item` is a link by default; `method:`
|
|
46
|
+
makes it a Turbo method link, `type: :submit` with `form:` submits a form, and
|
|
47
|
+
`style: "destructive"` tints it natively.
|
|
48
|
+
|
|
49
|
+
**In-content controls.**
|
|
50
|
+
|
|
51
|
+
```erb
|
|
52
|
+
<%# floating action button — fixed, safe-area-aware, tap haptic in the shell %>
|
|
53
|
+
<%= everywhere_fab new_note_path, icon: :plus, label: "New note" %>
|
|
54
|
+
<%= everywhere_fab compose_path, icon: :pencil, label: "Compose", extended: true %>
|
|
55
|
+
|
|
56
|
+
<%# action sheet: native sheet in the shell, inline menu in a browser %>
|
|
57
|
+
<%= everywhere_menu "Options" do %>
|
|
58
|
+
<%= everywhere_menu_item "Edit", edit_post_path(@post) %>
|
|
59
|
+
<%= everywhere_menu_item "Delete", post_path(@post), method: :delete, style: "destructive" %>
|
|
60
|
+
<% end %>
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`everywhere_fab` accepts `icon:` (a built-in line glyph), `label:` (accessible name, plus a
|
|
64
|
+
visible pill with `extended: true`), `side:`, and `haptic:` — or a block for custom content.
|
|
65
|
+
|
|
66
|
+
**Badges.** Server-rendered meta tags the bridge applies on every Turbo visit; `0` clears.
|
|
67
|
+
|
|
68
|
+
```erb
|
|
69
|
+
<%= everywhere_badge Current.user.unread_count %> <%# app icon %>
|
|
70
|
+
<%= everywhere_tab_badge "/inbox", Current.user.unread_count %> <%# a tab, by its everywhere.yml path %>
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
**Biometric gate.** Keeps sensitive markup hidden until Face ID / Touch ID passes, and only
|
|
74
|
+
when the user has turned the device-local lock on. Browsers and the desktop shell render the
|
|
75
|
+
content plainly. Needs `biometrics` under `permissions:` in `config/everywhere.yml`.
|
|
76
|
+
|
|
77
|
+
```erb
|
|
78
|
+
<%= everywhere_biometric_lock reason: "Unlock account settings" do %>
|
|
79
|
+
<%# profile, password, sessions… %>
|
|
80
|
+
<% end %>
|
|
81
|
+
|
|
82
|
+
<div data-everywhere-biometric-toggle-row hidden>
|
|
83
|
+
<label>Require Face ID <%= everywhere_biometric_toggle %></label>
|
|
84
|
+
</div>
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
**Auth redirects.** In a controller, route post-sign-in redirects through the reset page so a
|
|
88
|
+
phone shell rebuilds its web views and re-fetches its tab bar:
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
redirect_to everywhere_auth_redirect(after_authentication_url)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
**Tab bar, per request.** `Everywhere.filter_tabs { |tabs, request| … }` in an initializer
|
|
95
|
+
varies the mobile tab bar — it shares the app's session; return `[]` to hide the bar.
|
|
96
|
+
|
|
97
|
+
Styling helpers live in `everywhere/native.css`, including `.everywhere-titlebar-inset` for
|
|
98
|
+
`window.title_bar: overlay`.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
- `native_boot.rb` — the packaged-app entry point. Keep it thin and keep it at the app root.
|
|
2
|
+
- `config/initializers/everywhere.rb` — packaged-only public-path and session-store setup.
|
|
3
|
+
- `config/boot.rb` — bootsnap guarded with `unless ENV["NATIVE_PACKAGED"]` (its cache dir is
|
|
4
|
+
read-only when packaged).
|
|
5
|
+
- `config/environments/production.rb` — `force_ssl` and `assume_ssl` are `false`, because a
|
|
6
|
+
packaged app's webview talks plain HTTP to `127.0.0.1`.
|
|
7
|
+
- `config/puma.rb` — keep-alives off when packaged; WebKit reuses connections Puma has closed.
|
|
8
|
+
- `config/database.yml` — the production SQLite path reads
|
|
9
|
+
`<%= ENV.fetch("NATIVE_STORAGE_DIR", "storage") %>`. Any database you add should use the same
|
|
10
|
+
lookup.
|
|
11
|
+
- `app/javascript/application.js` — imports the bridge and exposes it as `window.Everywhere`.
|
|
12
|
+
|
|
13
|
+
One rule that matters in every mode: the bridge is served and importmap-pinned by
|
|
14
|
+
`Everywhere::Engine` straight from the gem, so it updates with `bundle update ruby_everywhere`.
|
|
15
|
+
Don't vendor it into `vendor/javascript/` or pin it in `config/importmap.rb` — a local copy
|
|
16
|
+
shadows the engine's and freezes the app on an old bridge.
|