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.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +99 -3
  3. data/bridge/README.md +70 -1
  4. data/bridge/everywhere/bridge.js +151 -0
  5. data/bridge/everywhere/native.css +61 -0
  6. data/lib/everywhere/agents_guide.rb +57 -0
  7. data/lib/everywhere/auth_handoff.rb +8 -2
  8. data/lib/everywhere/builders/desktop.rb +326 -0
  9. data/lib/everywhere/child_processes.rb +74 -0
  10. data/lib/everywhere/commands/build.rb +44 -5
  11. data/lib/everywhere/commands/dev.rb +426 -59
  12. data/lib/everywhere/commands/install.rb +93 -1
  13. data/lib/everywhere/commands/release.rb +2 -2
  14. data/lib/everywhere/config.rb +298 -4
  15. data/lib/everywhere/console.rb +117 -0
  16. data/lib/everywhere/desktop_assets.rb +150 -0
  17. data/lib/everywhere/dock/footer.rb +150 -0
  18. data/lib/everywhere/dock/screen.rb +114 -0
  19. data/lib/everywhere/dock/state.rb +59 -0
  20. data/lib/everywhere/dock.rb +238 -0
  21. data/lib/everywhere/emulator.rb +2 -2
  22. data/lib/everywhere/fatal.rb +20 -0
  23. data/lib/everywhere/line_pump.rb +89 -0
  24. data/lib/everywhere/log_filter.rb +37 -0
  25. data/lib/everywhere/native_helper.rb +38 -5
  26. data/lib/everywhere/paths.rb +62 -11
  27. data/lib/everywhere/relay.rb +77 -0
  28. data/lib/everywhere/shellout.rb +90 -15
  29. data/lib/everywhere/task_pool.rb +123 -0
  30. data/lib/everywhere/ui.rb +54 -11
  31. data/lib/everywhere/version.rb +1 -1
  32. data/support/agents/AGENTS.md +90 -0
  33. data/support/agents/frameworks/hanami-views.md +26 -0
  34. data/support/agents/frameworks/hanami.md +10 -0
  35. data/support/agents/frameworks/rails-views.md +98 -0
  36. data/support/agents/frameworks/rails.md +16 -0
  37. data/support/agents/frameworks/sinatra-views.md +24 -0
  38. data/support/agents/frameworks/sinatra.md +9 -0
  39. data/support/agents/modes/local.md +12 -0
  40. data/support/agents/modes/remote.md +14 -0
  41. data/support/desktop/README.md +121 -0
  42. data/support/{shell → desktop}/src-tauri/Cargo.lock +23 -0
  43. data/support/{shell → desktop}/src-tauri/Cargo.toml +19 -1
  44. data/support/{shell → desktop}/src-tauri/capabilities/default.json +7 -0
  45. data/support/desktop/src-tauri/gen/schemas/capabilities.json +1 -0
  46. data/support/desktop/src-tauri/src/extension_host.rs +53 -0
  47. data/support/desktop/src-tauri/src/extensions/mod.rs +39 -0
  48. data/support/{shell → desktop}/src-tauri/src/main.rs +355 -10
  49. data/support/{shell → desktop}/src-tauri/tauri.conf.json +2 -2
  50. data/support/{macos → release/macos}/notarize.sh +2 -2
  51. metadata +55 -22
  52. data/support/github/build.yml +0 -85
  53. data/support/shell/src-tauri/gen/schemas/capabilities.json +0 -1
  54. /data/support/{shell → desktop}/splash/index.html +0 -0
  55. /data/support/{shell → desktop}/src-tauri/build.rs +0 -0
  56. /data/support/{shell → desktop}/src-tauri/gen/schemas/acl-manifests.json +0 -0
  57. /data/support/{shell → desktop}/src-tauri/gen/schemas/desktop-schema.json +0 -0
  58. /data/support/{shell → desktop}/src-tauri/gen/schemas/macOS-schema.json +0 -0
  59. /data/support/{shell → desktop}/src-tauri/icons/icon.png +0 -0
  60. /data/support/{shell → desktop}/src-tauri/src/updater.rs +0 -0
  61. /data/support/{macos → release/macos}/entitlements.plist +0 -0
@@ -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
- def unbundled(&block)
12
- defined?(Bundler) ? Bundler.with_unbundled_env(&block) : yield
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 = unbundled { system(env, *cmd, chdir: chdir) }
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
- unbundled { system(*cmd, **opts) }
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
- unbundled { Open3.capture2e(*cmd) }
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
- unbundled do
47
- File.open(log, "w") do |logf|
48
- Open3.popen2e(env, *cmd) do |stdin, out, wait|
49
- stdin.close
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 $stdout.print(raw) unless filter
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 $stdout.puts(" #{UI.gray(UI.short_path(raw.rstrip))}")
70
- else $stdout.puts(shown)
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
- unbundled { Process.spawn(env, cmd, chdir: chdir, pgroup: true) }
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
- def phase(msg) = puts(phase_line(msg))
73
- def step(msg) = puts(step_line(msg))
74
- def substep(msg) = puts(substep_line(msg))
75
- def detail(msg) = puts(detail_line(msg))
76
- def ok(msg) = puts(ok_line(msg))
77
- def bad(msg) = puts(bad_line(msg))
78
- def warn(msg) = puts(warn_line(msg))
79
- def success(msg) = puts(success_line(msg))
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
- abort "#{red("✗")} #{red(msg)}"
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 = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
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,
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Everywhere
4
- VERSION = "0.4.0"
4
+ VERSION = "0.6.0"
5
5
 
6
6
  # Version of the @rubyeverywhere/bridge JS this gem ships. bridge/ in the
7
7
  # gem IS the npm package (served to Rails apps by Everywhere::Engine,
@@ -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.