ruby_everywhere 0.5.0 → 0.7.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 +13 -1
- data/exe/every +12 -1
- data/exe/rbe +12 -1
- data/lib/everywhere/agents_guide.rb +59 -0
- data/lib/everywhere/blake2b.rb +17 -1
- data/lib/everywhere/boot.rb +21 -4
- data/lib/everywhere/builders/android.rb +144 -30
- data/lib/everywhere/builders/desktop.rb +25 -17
- data/lib/everywhere/builders/ios.rb +244 -9
- data/lib/everywhere/cli.rb +2 -0
- data/lib/everywhere/commands/build.rb +113 -58
- data/lib/everywhere/commands/clean.rb +8 -3
- data/lib/everywhere/commands/dev.rb +85 -11
- data/lib/everywhere/commands/doctor.rb +138 -21
- data/lib/everywhere/commands/install.rb +121 -9
- data/lib/everywhere/commands/platform/build.rb +136 -27
- data/lib/everywhere/commands/platform/login.rb +16 -2
- data/lib/everywhere/commands/platform/runner.rb +113 -23
- data/lib/everywhere/commands/preview.rb +359 -0
- data/lib/everywhere/commands/publish.rb +20 -2
- data/lib/everywhere/commands/release.rb +145 -28
- data/lib/everywhere/commands/shell_dir.rb +2 -2
- data/lib/everywhere/config.rb +88 -3
- data/lib/everywhere/console.rb +2 -1
- data/lib/everywhere/engine.rb +24 -0
- data/lib/everywhere/framework.rb +21 -4
- data/lib/everywhere/ignore.rb +3 -1
- data/lib/everywhere/jump.rb +121 -0
- data/lib/everywhere/mobile_config_endpoint.rb +47 -0
- data/lib/everywhere/mobile_configs_controller.rb +46 -0
- data/lib/everywhere/paths.rb +13 -6
- data/lib/everywhere/platform/client.rb +27 -6
- data/lib/everywhere/platform/credentials.rb +18 -8
- data/lib/everywhere/platform/snapshot.rb +10 -0
- data/lib/everywhere/receipt.rb +55 -10
- data/lib/everywhere/shellout.rb +19 -0
- data/lib/everywhere/simulator.rb +12 -1
- 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/mobile/android/app/build.gradle.kts +29 -1
- data/support/mobile/ios/App/EverywhereConfig.swift +17 -5
- data/support/mobile/ios/App/SceneDelegate.swift +75 -8
- data/support/mobile/ios/App.xcodeproj/project.pbxproj +2 -2
- data/support/mobile/ios/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +0 -1
- data/support/mobile/ios/README.md +13 -6
- metadata +41 -6
|
@@ -4,6 +4,7 @@ require "rubygems/package"
|
|
|
4
4
|
require "zlib"
|
|
5
5
|
require "digest"
|
|
6
6
|
require_relative "../ignore"
|
|
7
|
+
require_relative "../ui"
|
|
7
8
|
|
|
8
9
|
module Everywhere
|
|
9
10
|
module Platform
|
|
@@ -38,10 +39,19 @@ module Everywhere
|
|
|
38
39
|
|
|
39
40
|
def add(tar, root, rel)
|
|
40
41
|
abs = File.join(root, rel)
|
|
42
|
+
# lstat first: File.stat follows the link, so a dangling symlink (a
|
|
43
|
+
# deleted bundle path, a checked-in link to an absent sibling repo)
|
|
44
|
+
# would raise Errno::ENOENT and abort the whole snapshot.
|
|
45
|
+
return Everywhere::UI.warn("skipping dangling symlink #{rel}") if File.lstat(abs).symlink? && !File.exist?(abs)
|
|
46
|
+
|
|
41
47
|
stat = File.stat(abs)
|
|
42
48
|
tar.add_file_simple(rel, stat.mode & 0o777, stat.size) do |io|
|
|
43
49
|
File.open(abs, "rb") { |f| IO.copy_stream(f, io) }
|
|
44
50
|
end
|
|
51
|
+
rescue Errno::ENOENT, Errno::EACCES => e
|
|
52
|
+
# The listing is a snapshot in time; a dev server or watcher can delete a
|
|
53
|
+
# tmp/ file between the glob and here. Losing it beats losing the build.
|
|
54
|
+
Everywhere::UI.warn("skipping #{rel} (#{e.class.name.split("::").last})")
|
|
45
55
|
end
|
|
46
56
|
end
|
|
47
57
|
end
|
data/lib/everywhere/receipt.rb
CHANGED
|
@@ -5,6 +5,7 @@ require "digest"
|
|
|
5
5
|
require_relative "version"
|
|
6
6
|
require_relative "shellout"
|
|
7
7
|
require_relative "ignore"
|
|
8
|
+
require_relative "paths"
|
|
8
9
|
|
|
9
10
|
module Everywhere
|
|
10
11
|
# Builds the machine-readable build receipt (release.json) — the same shape
|
|
@@ -22,14 +23,20 @@ module Everywhere
|
|
|
22
23
|
class Receipt
|
|
23
24
|
SCHEMA = 1
|
|
24
25
|
|
|
25
|
-
|
|
26
|
+
# source_checksum/lockfile_checksum are optional so a caller building one
|
|
27
|
+
# receipt per target hashes the tree ONCE and threads the digest through the
|
|
28
|
+
# rest — they describe the source, which is identical for every target.
|
|
29
|
+
def initialize(root:, config:, framework:, ruby:, target:, channel:, shell_dir: nil,
|
|
30
|
+
source_checksum: nil, lockfile_checksum: nil)
|
|
26
31
|
@root = File.expand_path(root)
|
|
27
32
|
@config = config
|
|
28
33
|
@framework = framework
|
|
29
34
|
@ruby = ruby
|
|
30
|
-
@target_str = target #
|
|
35
|
+
@target_str = target # bare "os-arch" — selects platforms: overrides
|
|
31
36
|
@target = parse_target(target, channel)
|
|
32
37
|
@shell_dir = shell_dir
|
|
38
|
+
@source_checksum = source_checksum
|
|
39
|
+
@lockfile_checksum = lockfile_checksum
|
|
33
40
|
end
|
|
34
41
|
|
|
35
42
|
# The deterministic build request.
|
|
@@ -48,7 +55,9 @@ module Everywhere
|
|
|
48
55
|
"lockfile_checksum" => lockfile_checksum
|
|
49
56
|
},
|
|
50
57
|
"target" => @target,
|
|
51
|
-
"permissions" => @config.permissions
|
|
58
|
+
"permissions" => @config.permissions,
|
|
59
|
+
"mode" => @config.mode,
|
|
60
|
+
"remote" => { "url" => @config.remote_url }.compact
|
|
52
61
|
}
|
|
53
62
|
end
|
|
54
63
|
|
|
@@ -105,10 +114,15 @@ module Everywhere
|
|
|
105
114
|
"cli" => Everywhere::VERSION,
|
|
106
115
|
"bridge" => bridge_version,
|
|
107
116
|
"shell" => shell_version,
|
|
108
|
-
|
|
117
|
+
# Only macOS targets are tebako-pressed; spawning rbe-tebako for a
|
|
118
|
+
# mobile receipt would cost a process to learn nothing.
|
|
119
|
+
"tebako" => macos? ? tebako_version : nil,
|
|
120
|
+
"hotwire_native" => hotwire_native_version
|
|
109
121
|
}
|
|
110
122
|
end
|
|
111
123
|
|
|
124
|
+
def macos? = @target["os"] == "macos"
|
|
125
|
+
|
|
112
126
|
def bridge_version
|
|
113
127
|
# Package installs: read the version straight from package.json.
|
|
114
128
|
[
|
|
@@ -145,11 +159,40 @@ module Everywhere
|
|
|
145
159
|
out[/version\s+([\d.]+)/i, 1]
|
|
146
160
|
end
|
|
147
161
|
|
|
162
|
+
# Which Hotwire Native the bundled mobile template pins. Read straight off
|
|
163
|
+
# the frozen templates in the gem (no spawn): iOS from the SPM lockfile,
|
|
164
|
+
# Android from the Gradle dependency line. Best-effort — a receipt is worth
|
|
165
|
+
# writing even when the template is missing or reshaped.
|
|
166
|
+
def hotwire_native_version
|
|
167
|
+
case @target["os"]
|
|
168
|
+
when "ios" then hotwire_native_ios_revision
|
|
169
|
+
when "android" then hotwire_native_android_version
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def hotwire_native_ios_revision
|
|
174
|
+
dir = Paths.ios_dir or return nil
|
|
175
|
+
resolved = File.join(dir, "App.xcodeproj", "project.xcworkspace", "xcshareddata", "swiftpm", "Package.resolved")
|
|
176
|
+
pin = JSON.parse(File.read(resolved))["pins"].find { |p| p["identity"].to_s.include?("hotwire-native-ios") }
|
|
177
|
+
pin&.dig("state", "revision")&.slice(0, 12)
|
|
178
|
+
rescue StandardError
|
|
179
|
+
nil
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def hotwire_native_android_version
|
|
183
|
+
dir = Paths.android_dir or return nil
|
|
184
|
+
File.read(File.join(dir, "app", "build.gradle.kts"))[/dev\.hotwire:core:([\d.]+)/, 1]
|
|
185
|
+
rescue StandardError
|
|
186
|
+
nil
|
|
187
|
+
end
|
|
188
|
+
|
|
148
189
|
# ---- checksums -----------------------------------------------------------
|
|
149
190
|
|
|
150
191
|
def lockfile_checksum
|
|
151
|
-
|
|
152
|
-
|
|
192
|
+
@lockfile_checksum ||= begin
|
|
193
|
+
lock = File.join(@root, "Gemfile.lock")
|
|
194
|
+
File.exist?(lock) ? "sha256:#{Digest::SHA256.file(lock).hexdigest}" : nil
|
|
195
|
+
end
|
|
153
196
|
end
|
|
154
197
|
|
|
155
198
|
def lockfile_gem_version(gem_name)
|
|
@@ -164,11 +207,13 @@ module Everywhere
|
|
|
164
207
|
# Uses the SAME ignore rules as the (future) snapshot, so the checksum
|
|
165
208
|
# describes exactly what ships.
|
|
166
209
|
def source_checksum
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
210
|
+
@source_checksum ||= begin
|
|
211
|
+
digest = Digest::SHA256.new
|
|
212
|
+
Ignore.for(@root).files(@root).each do |rel|
|
|
213
|
+
digest << rel << "\0" << Digest::SHA256.file(File.join(@root, rel)).hexdigest << "\n"
|
|
214
|
+
end
|
|
215
|
+
"sha256:#{digest.hexdigest}"
|
|
170
216
|
end
|
|
171
|
-
"sha256:#{digest.hexdigest}"
|
|
172
217
|
end
|
|
173
218
|
|
|
174
219
|
# ---- helpers -------------------------------------------------------------
|
data/lib/everywhere/shellout.rb
CHANGED
|
@@ -40,9 +40,27 @@ module Everywhere
|
|
|
40
40
|
|
|
41
41
|
def run!(env, *cmd, chdir: Dir.pwd)
|
|
42
42
|
success = system(child_env(env), *cmd, chdir: chdir)
|
|
43
|
+
# nil means the command never ran (missing binary), false that it failed.
|
|
44
|
+
UI.die!("#{cmd.first} not found — is it installed and on your PATH?") if success.nil?
|
|
43
45
|
UI.die!("command failed: #{cmd.join(" ")}") unless success
|
|
44
46
|
end
|
|
45
47
|
|
|
48
|
+
# Whether `name` resolves as an executable on the PATH children get (the
|
|
49
|
+
# unbundled one — a Bundler binstub visible to the CLI doesn't count).
|
|
50
|
+
def tool?(name)
|
|
51
|
+
return File.executable?(name) && !File.directory?(name) if name.include?("/")
|
|
52
|
+
|
|
53
|
+
path = (unbundled_base["PATH"] || ENV["PATH"]).to_s
|
|
54
|
+
exts = Gem.win_platform? ? [".exe", ".bat", ".cmd", ""] : [""]
|
|
55
|
+
path.split(File::PATH_SEPARATOR).any? do |dir|
|
|
56
|
+
exts.any? { |ext| File.executable?(File.join(dir, name + ext)) && !File.directory?(File.join(dir, name + ext)) }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def ensure_tool!(name, hint)
|
|
61
|
+
tool?(name) or UI.die!("#{name} not found — #{hint}")
|
|
62
|
+
end
|
|
63
|
+
|
|
46
64
|
# quiet also detaches stdin. A quiet command is by definition one that has
|
|
47
65
|
# nothing to say to the terminal, and `every dev` reads single keystrokes
|
|
48
66
|
# from that same terminal — a child left holding stdin silently eats the
|
|
@@ -78,6 +96,7 @@ module Everywhere
|
|
|
78
96
|
# ChildProcesses instead.
|
|
79
97
|
def run_logged!(env, cmd, log:, filter: nil)
|
|
80
98
|
require "open3"
|
|
99
|
+
ensure_tool!(cmd.first, "is it installed and on your PATH?")
|
|
81
100
|
success = false
|
|
82
101
|
File.open(log, "w") do |logf|
|
|
83
102
|
# Inside an `every dev` worker the build leads its own process group,
|
data/lib/everywhere/simulator.rb
CHANGED
|
@@ -56,11 +56,22 @@ module Everywhere
|
|
|
56
56
|
out, status = Shellout.capture("xcrun", "simctl", "list", "devices", "available", "-j")
|
|
57
57
|
return [] unless status&.success?
|
|
58
58
|
|
|
59
|
-
JSON.parse(out).fetch("devices", {})
|
|
59
|
+
JSON.parse(out).fetch("devices", {})
|
|
60
|
+
.sort_by { |runtime, _list| runtime_order(runtime) }
|
|
61
|
+
.flat_map { |_runtime, list| list }
|
|
60
62
|
rescue JSON::ParserError
|
|
61
63
|
[]
|
|
62
64
|
end
|
|
63
65
|
|
|
66
|
+
# Runtimes are keyed by identifier ("com.apple.CoreSimulator.SimRuntime.iOS-17-0"),
|
|
67
|
+
# so a plain string sort puts iOS-9-3 AFTER iOS-17-0 and "newest last" hands
|
|
68
|
+
# back the oldest runtime installed. Compare the version numerically, with
|
|
69
|
+
# the family (iOS/watchOS/…) as the primary key so the grouping is stable.
|
|
70
|
+
def runtime_order(runtime)
|
|
71
|
+
family, *version = runtime.to_s.split(".").last.to_s.split("-")
|
|
72
|
+
[family.to_s.downcase, version.map(&:to_i)]
|
|
73
|
+
end
|
|
74
|
+
|
|
64
75
|
def wait_until_booted(udid, timeout: 60)
|
|
65
76
|
deadline = Time.now + timeout
|
|
66
77
|
until Time.now > deadline
|
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.
|
|
@@ -0,0 +1,24 @@
|
|
|
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` carries the styling helpers, including `.everywhere-titlebar-inset` for
|
|
24
|
+
`window.title_bar: overlay`.
|
|
@@ -0,0 +1,9 @@
|
|
|
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`. Load it with `<script type="module" src="/bridge.js"></script>`.
|
|
4
|
+
|
|
5
|
+
Rack apps vary too much for the CLI to auto-edit their boot files, so the rest is by hand and
|
|
6
|
+
`every install` prints it rather than guessing: point the production SQLite path at
|
|
7
|
+
`ENV.fetch("NATIVE_STORAGE_DIR", "storage")`, and call
|
|
8
|
+
`Everywhere::Database.prepare!(__dir__)` from `config.ru` after loading the app. Both only
|
|
9
|
+
matter for on-device packaging.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
The app itself is compiled into the shipped binary and runs on the user's machine, rather than
|
|
2
|
+
the shells pointing at a deployed site. Shipping a change means cutting a new build and
|
|
3
|
+
release.
|
|
4
|
+
|
|
5
|
+
That trade brings constraints the docs cover properly — a read-only application filesystem
|
|
6
|
+
with a separate writable data directory, SQLite only, desktop only — so read
|
|
7
|
+
<https://rubyeverywhere.com/docs> before changing anything that writes to disk, boots the app,
|
|
8
|
+
or touches `native_boot.rb`. The short version: anything written at runtime belongs under
|
|
9
|
+
`ENV.fetch("NATIVE_STORAGE_DIR", "storage")`, and `ENV["NATIVE_PACKAGED"]` is the guard for
|
|
10
|
+
packaged-only behavior.
|
|
11
|
+
|
|
12
|
+
Everything below applies the same way in either mode.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
The native shells are a thin wrapper around the deployed site at `remote.url` in
|
|
2
|
+
`config/everywhere.yml`. Nothing about the app is compiled into them.
|
|
3
|
+
|
|
4
|
+
**So you are working on an ordinary web app.** Ship a change by deploying as you always have,
|
|
5
|
+
and users see it on their next launch — no rebuild, no app-store review. Normal databases,
|
|
6
|
+
background jobs, and hosting all apply, unchanged.
|
|
7
|
+
|
|
8
|
+
Only three things are native-specific, and they're all covered below: what
|
|
9
|
+
`config/everywhere.yml` declares, the view markup that becomes native chrome, and the
|
|
10
|
+
`Everywhere` JavaScript API. Of those, tab and path-rule changes deploy with the app; the rest
|
|
11
|
+
of `config/everywhere.yml` needs a new build of the shells.
|
|
12
|
+
|
|
13
|
+
Remote is also the only mode mobile supports — iOS and Android are always a shell around a
|
|
14
|
+
deployed app.
|
|
@@ -18,6 +18,15 @@ val everywhere = Properties().apply {
|
|
|
18
18
|
fun stamped(key: String, default: String): String =
|
|
19
19
|
everywhere.getProperty("everywhere.$key")?.takeIf { it.isNotBlank() } ?: default
|
|
20
20
|
|
|
21
|
+
// Release signing and the versionCode override arrive as environment, never as
|
|
22
|
+
// stamped properties: everywhere.properties lives in the work dir for the life
|
|
23
|
+
// of the app, and a keystore password written there outlives the build that
|
|
24
|
+
// needed it. `every build` resolves and passes these; a direct Gradle
|
|
25
|
+
// invocation can export them itself.
|
|
26
|
+
fun env(name: String): String? = System.getenv("EVERY_ANDROID_$name")?.takeIf { it.isNotBlank() }
|
|
27
|
+
|
|
28
|
+
val releaseKeystore = env("KEYSTORE")?.let { file(it) }
|
|
29
|
+
|
|
21
30
|
android {
|
|
22
31
|
// The namespace is the R class / BuildConfig package and is frozen. It is
|
|
23
32
|
// deliberately NOT the applicationId: apps rename their bundle id freely,
|
|
@@ -28,7 +37,7 @@ android {
|
|
|
28
37
|
defaultConfig {
|
|
29
38
|
applicationId = stamped("applicationId", "com.rubyeverywhere.app")
|
|
30
39
|
versionName = stamped("versionName", "0.1.0")
|
|
31
|
-
versionCode = stamped("versionCode", "1").toInt()
|
|
40
|
+
versionCode = (env("VERSION_CODE") ?: stamped("versionCode", "1")).toInt()
|
|
32
41
|
|
|
33
42
|
// minSdk 28 is Hotwire Native Android's floor and is not negotiable.
|
|
34
43
|
// It also clears API 26 for Typeface.Builder.setFontVariationSettings,
|
|
@@ -63,11 +72,30 @@ android {
|
|
|
63
72
|
getByName("main") { res.srcDir("src/stamped/res") }
|
|
64
73
|
}
|
|
65
74
|
|
|
75
|
+
signingConfigs {
|
|
76
|
+
// Created only when a keystore is in the environment: the default build
|
|
77
|
+
// is an unsigned release, and a signingConfig with a null storeFile is
|
|
78
|
+
// a configuration-time failure rather than an unsigned APK.
|
|
79
|
+
releaseKeystore?.let { keystore ->
|
|
80
|
+
create("release") {
|
|
81
|
+
storeFile = keystore
|
|
82
|
+
storePassword = env("KEYSTORE_PASSWORD")
|
|
83
|
+
keyAlias = env("KEY_ALIAS")
|
|
84
|
+
// keytool -genkeypair without -keypass gives the key the store's
|
|
85
|
+
// password; the CLI resolves the same fallback before it gets here.
|
|
86
|
+
keyPassword = env("KEY_PASSWORD") ?: env("KEYSTORE_PASSWORD")
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
66
91
|
buildTypes {
|
|
67
92
|
getByName("debug") {
|
|
68
93
|
isDebuggable = true
|
|
69
94
|
}
|
|
70
95
|
release {
|
|
96
|
+
if (releaseKeystore != null) {
|
|
97
|
+
signingConfig = signingConfigs.getByName("release")
|
|
98
|
+
}
|
|
71
99
|
isMinifyEnabled = false
|
|
72
100
|
proguardFiles(
|
|
73
101
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
|
@@ -145,20 +145,31 @@ struct EverywhereConfig: Decodable {
|
|
|
145
145
|
/// relaunches; release builds never read it.
|
|
146
146
|
private static let devOverrideURL: URL? = {
|
|
147
147
|
let key = "EverywhereDevURL"
|
|
148
|
-
if let dev = ProcessInfo.processInfo.environment["EVERYWHERE_DEV_URL"], let url =
|
|
148
|
+
if let dev = ProcessInfo.processInfo.environment["EVERYWHERE_DEV_URL"], let url = rootWorthy(dev) {
|
|
149
149
|
#if DEBUG
|
|
150
|
-
UserDefaults.standard.set(
|
|
150
|
+
UserDefaults.standard.set(url.absoluteString, forKey: key)
|
|
151
151
|
#endif
|
|
152
152
|
return url
|
|
153
153
|
}
|
|
154
154
|
#if DEBUG
|
|
155
|
-
if let saved = UserDefaults.standard.string(forKey: key), let url =
|
|
155
|
+
if let saved = UserDefaults.standard.string(forKey: key), let url = rootWorthy(saved) {
|
|
156
156
|
return url
|
|
157
157
|
}
|
|
158
158
|
#endif
|
|
159
159
|
return nil
|
|
160
160
|
}()
|
|
161
161
|
|
|
162
|
+
/// A string as an app ROOT: everything derived from a root (path config,
|
|
163
|
+
/// tab URLs) is built by appending paths, so a root carrying a query or
|
|
164
|
+
/// fragment would smear them onto every derived URL. Paths are kept —
|
|
165
|
+
/// dev URLs legitimately carry entry_path.
|
|
166
|
+
private static func rootWorthy(_ string: String) -> URL? {
|
|
167
|
+
guard var components = URLComponents(string: string) else { return nil }
|
|
168
|
+
components.query = nil
|
|
169
|
+
components.fragment = nil
|
|
170
|
+
return components.url
|
|
171
|
+
}
|
|
172
|
+
|
|
162
173
|
// MARK: Instance override (multi-instance apps)
|
|
163
174
|
|
|
164
175
|
/// UserDefaults key holding the user-chosen instance root. Multi-instance
|
|
@@ -186,9 +197,10 @@ struct EverywhereConfig: Decodable {
|
|
|
186
197
|
return true
|
|
187
198
|
}
|
|
188
199
|
guard let scheme = url.scheme?.lowercased(), ["https", "http"].contains(scheme),
|
|
189
|
-
url.host != nil
|
|
200
|
+
url.host != nil,
|
|
201
|
+
let root = rootWorthy(url.absoluteString)
|
|
190
202
|
else { return false }
|
|
191
|
-
UserDefaults.standard.set(
|
|
203
|
+
UserDefaults.standard.set(root.absoluteString, forKey: instanceKey)
|
|
192
204
|
return true
|
|
193
205
|
}
|
|
194
206
|
|