ruby_everywhere 0.3.0 → 0.4.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 (88) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +255 -0
  3. data/bridge/everywhere/bridge.js +60 -3
  4. data/lib/everywhere/android_resources.rb +217 -0
  5. data/lib/everywhere/android_sdk.rb +330 -0
  6. data/lib/everywhere/asset_catalog.rb +185 -0
  7. data/lib/everywhere/auth_handoff.rb +398 -0
  8. data/lib/everywhere/auth_token.rb +118 -0
  9. data/lib/everywhere/builders/android.rb +844 -0
  10. data/lib/everywhere/builders/ios.rb +40 -3
  11. data/lib/everywhere/commands/build.rb +51 -9
  12. data/lib/everywhere/commands/clean.rb +10 -5
  13. data/lib/everywhere/commands/dev.rb +46 -8
  14. data/lib/everywhere/commands/doctor.rb +89 -3
  15. data/lib/everywhere/commands/install.rb +1 -1
  16. data/lib/everywhere/commands/logs.rb +117 -8
  17. data/lib/everywhere/config.rb +409 -13
  18. data/lib/everywhere/emulator.rb +405 -0
  19. data/lib/everywhere/engine.rb +12 -0
  20. data/lib/everywhere/icon.rb +196 -0
  21. data/lib/everywhere/log_filter.rb +74 -0
  22. data/lib/everywhere/native_helper.rb +2 -5
  23. data/lib/everywhere/omniauth.rb +88 -0
  24. data/lib/everywhere/paths.rb +47 -0
  25. data/lib/everywhere/version.rb +1 -1
  26. data/support/mobile/android/README.md +217 -0
  27. data/support/mobile/android/app/build.gradle.kts +156 -0
  28. data/support/mobile/android/app/everywhere.properties +8 -0
  29. data/support/mobile/android/app/native-packages.gradle.kts +16 -0
  30. data/support/mobile/android/app/proguard-rules.pro +21 -0
  31. data/support/mobile/android/app/src/debug/res/xml/network_security_config.xml +30 -0
  32. data/support/mobile/android/app/src/main/AndroidManifest.xml +42 -0
  33. data/support/mobile/android/app/src/main/assets/everywhere.json +9 -0
  34. data/support/mobile/android/app/src/main/assets/fonts/MaterialIcons-Regular.codepoints +2235 -0
  35. data/support/mobile/android/app/src/main/assets/fonts/MaterialIcons-Regular.ttf +0 -0
  36. data/support/mobile/android/app/src/main/assets/json/path-configuration.json +19 -0
  37. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/AuthFlow.kt +268 -0
  38. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereApplication.kt +178 -0
  39. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereConfig.kt +409 -0
  40. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereEvents.kt +68 -0
  41. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereHost.kt +101 -0
  42. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/IconFont.kt +211 -0
  43. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MainActivity.kt +975 -0
  44. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MainTabs.kt +201 -0
  45. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/WebBottomSheetFragment.kt +13 -0
  46. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/WebFragment.kt +36 -0
  47. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/BiometricsComponent.kt +535 -0
  48. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/HapticsComponent.kt +141 -0
  49. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/MenuComponent.kt +361 -0
  50. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/NotificationComponent.kt +151 -0
  51. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/PermissionsComponent.kt +271 -0
  52. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/StorageComponent.kt +101 -0
  53. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/WebControlChannel.kt +204 -0
  54. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/extensions/EverywhereExtensions.kt +51 -0
  55. data/support/mobile/android/app/src/main/res/color/bottom_navigation_active_indicator.xml +4 -0
  56. data/support/mobile/android/app/src/main/res/color/bottom_navigation_item.xml +11 -0
  57. data/support/mobile/android/app/src/main/res/drawable/ic_launcher_foreground.xml +26 -0
  58. data/support/mobile/android/app/src/main/res/drawable/ic_tab_placeholder.xml +28 -0
  59. data/support/mobile/android/app/src/main/res/layout/activity_main.xml +116 -0
  60. data/support/mobile/android/app/src/main/res/layout/bridge_menu_sheet.xml +83 -0
  61. data/support/mobile/android/app/src/main/res/layout/bridge_menu_sheet_item.xml +35 -0
  62. data/support/mobile/android/app/src/main/res/layout/tab_more_sheet.xml +48 -0
  63. data/support/mobile/android/app/src/main/res/layout/tab_more_sheet_item.xml +35 -0
  64. data/support/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +12 -0
  65. data/support/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +6 -0
  66. data/support/mobile/android/app/src/main/res/values/colors.xml +21 -0
  67. data/support/mobile/android/app/src/main/res/values/ic_launcher_background.xml +5 -0
  68. data/support/mobile/android/app/src/main/res/values/strings.xml +18 -0
  69. data/support/mobile/android/app/src/main/res/values/styles.xml +32 -0
  70. data/support/mobile/android/app/src/main/res/values/themes.xml +53 -0
  71. data/support/mobile/android/app/src/main/res/values-night/colors.xml +11 -0
  72. data/support/mobile/android/app/src/main/res/values-night/themes.xml +33 -0
  73. data/support/mobile/android/app/src/main/res/xml/network_security_config.xml +12 -0
  74. data/support/mobile/android/app/src/stamped/AndroidManifest.xml +34 -0
  75. data/support/mobile/android/build.gradle.kts +8 -0
  76. data/support/mobile/android/gradle/wrapper/gradle-wrapper.jar +0 -0
  77. data/support/mobile/android/gradle/wrapper/gradle-wrapper.properties +10 -0
  78. data/support/mobile/android/gradle.properties +14 -0
  79. data/support/mobile/android/gradlew +251 -0
  80. data/support/mobile/android/gradlew.bat +94 -0
  81. data/support/mobile/android/settings.gradle.kts +28 -0
  82. data/support/mobile/ios/App/AppDelegate.swift +5 -0
  83. data/support/mobile/ios/App/AuthFlow.swift +101 -0
  84. data/support/mobile/ios/App/EverywhereConfig.swift +84 -0
  85. data/support/mobile/ios/App/SceneDelegate.swift +103 -0
  86. data/support/mobile/ios/App.xcodeproj/project.pbxproj +4 -0
  87. data/support/mobile/ios/README.md +4 -1
  88. metadata +67 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b255975aa8724cb4980606ddbde415eea3a238c193ffce8530028ef5fdc63c24
4
- data.tar.gz: 386145d42d750984da4fa800ea9b63417371937dbf67d49642a229cd92cfbb71
3
+ metadata.gz: 7ffb921df597efe047c25240e7d8b92aaad49c00c90d546020e053eac618739a
4
+ data.tar.gz: 17315b4c5d91eced13b44d7147942d7d6357ee791b8622425ab655eb97b1ab27
5
5
  SHA512:
6
- metadata.gz: 6af24db6e60c693ae855fa47832564074d887c18248a63be23c343c5e85c7d84cf4210017ba6bb26dae9950bb6b1394255f02cbea4d278f9407952a22f37dd6c
7
- data.tar.gz: 368f12504a2a5748aa30eaf641eadf5b85bbd54b99e0f372015b9621510f01dc5779ca6e3e37cfab594083768815e40e48200e2c3649195c5b5e800cc7cf5557
6
+ metadata.gz: 71d9ba1ea63abe51a7262f02f069ef9f068d3192bb20800bdd0fba8bcd50f36f2daa83bb3f758e7273576b69b88be5541428b41f93021579cc1b5054532099ad
7
+ data.tar.gz: 59632bacae03ade28233832d0193d2500e4e3f85c07b7e61a39f1e086cc398b40f66d48091ffc9e336a11e2f91994b666ea60fc7ab2cbef2b76252d6fb0ada60
data/README.md ADDED
@@ -0,0 +1,255 @@
1
+ # RubyEverywhere
2
+
3
+ **Ship desktop and mobile apps from the Ruby web apps you already know how to build.**
4
+
5
+ [rubyeverywhere.com](https://rubyeverywhere.com) · by [Andrea Fomera](https://afomera.dev)
6
+
7
+ RubyEverywhere takes a Rails (or Sinatra, or Hanami) app and gives it a native
8
+ life on the platforms your users are already on:
9
+
10
+ - **Desktop** — a [Tauri](https://tauri.app)-based shell. In *local* mode your
11
+ whole app is pressed into a single-file binary with
12
+ [Tebako](https://github.com/tamatebako/tebako) and runs on-device, no Ruby
13
+ install required; in *remote* mode the shell is a thin native wrapper around
14
+ your deployed app.
15
+ - **iOS** — a [Hotwire Native](https://native.hotwired.dev) shell with a real
16
+ tab bar, nav-bar buttons, menus, action sheets, notifications, haptics,
17
+ biometrics, deep links, and native sign-in.
18
+ - **Android** — a feature-for-feature Hotwire Native Android port of the same
19
+ shell, stamped from the same `config/everywhere.yml`.
20
+
21
+ One config file, one CLI, one JavaScript API. The web app stays the source of
22
+ truth — most of what you'd normally rebuild natively (tabs, routes, menus,
23
+ badges) deploys with your Rails app rather than through an app-store review.
24
+
25
+ ## Install
26
+
27
+ ```sh
28
+ gem install ruby_everywhere
29
+ ```
30
+
31
+ This gives you two equivalent executables — `every` and `rbe` — plus the
32
+ packaging toolchain (the `rbe-tebako` fork) as a dependency.
33
+
34
+ Or add it to your app's Gemfile, which also wires up the Rails engine (bridge
35
+ asset serving, importmap pin, view helpers, the live mobile config endpoint):
36
+
37
+ ```ruby
38
+ gem "ruby_everywhere"
39
+ ```
40
+
41
+ Requires Ruby >= 3.2.
42
+
43
+ **The gem is free, and it always will be.** Everything here — the CLI, the
44
+ desktop shell, both mobile shells, the bridge — is MIT licensed and yours to
45
+ use, forever, with no account and no key. Local builds run on a Mac today
46
+ (pressing, code signing, Xcode and the Android SDK all want a macOS host).
47
+ If you don't own one, or you'd rather not keep a build toolchain healthy, the
48
+ [RubyEverywhere Platform](https://rubyeverywhere.com) is a paid service coming
49
+ soon that runs the exact same builds for you — see
50
+ [Hosted builds](#hosted-builds) below. It's a convenience, never a gate: nothing
51
+ in this gem is held back for it.
52
+
53
+ ## Quick start
54
+
55
+ ```sh
56
+ cd path/to/your/app
57
+
58
+ every install # remote mode: a native shell around your deployed app
59
+ every install --local # local mode: compile and ship the app itself
60
+
61
+ every doctor # check the toolchain before your first build
62
+
63
+ every dev # dev server + native shells on demand, live reload
64
+ every dev --ios # ...straight into the iOS Simulator
65
+ every dev --android # ...straight into the Android emulator
66
+
67
+ every build # package the macOS .app
68
+ every build --ios # build the iOS shell
69
+ every build --android # build the APK/AAB
70
+ ```
71
+
72
+ `every install` is idempotent — it adds `config/everywhere.yml`, a boot stub,
73
+ and the framework-specific config tweaks it needs, and skips anything already
74
+ in place. For Rails it does the surgery for you; for Sinatra and Hanami it
75
+ vendors the bridge and prints the remaining wiring rather than guessing at your
76
+ boot files.
77
+
78
+ `every dev` never packages anything: it runs your ordinary dev server and points
79
+ the native shells at it, so the edit-refresh loop is untouched Rails.
80
+
81
+ ## Configuration
82
+
83
+ Everything per-app lives in `config/everywhere.yml`. The native shell templates
84
+ are frozen — the CLI stamps a copy per app and only ever writes the identity,
85
+ config, icon, and color files, so a shell upgrade never collides with your app.
86
+
87
+ ```yaml
88
+ app:
89
+ name: My Really Awesome App
90
+ bundle_id: com.example.myapp # set it once; renaming moves users' data
91
+ version: 1.2.0
92
+ entry_path: /dashboard # where the app opens
93
+ icon: icon.png
94
+
95
+ appearance:
96
+ tint_color: "#CC342D"
97
+ background_color: # one hex string, or split by system theme
98
+ light: "#FAF9F7"
99
+ dark: "#1C1B1A"
100
+
101
+ remote:
102
+ url: https://app.example.com # remote mode: the deployed app to wrap
103
+
104
+ tabs: # the native mobile tab bar
105
+ - name: Builds
106
+ path: /builds
107
+ icons:
108
+ ios: hammer # SF Symbol
109
+ android: build # Material icon
110
+
111
+ permissions: # only declared permissions can ever prompt
112
+ notifications: true
113
+ camera: "Scan QR codes to pair devices." # the sentence iOS shows
114
+
115
+ rules: # Hotwire Native path configuration
116
+ - patterns: ["/preferences$"]
117
+ properties:
118
+ context: modal
119
+
120
+ build:
121
+ ruby: "4.0.6"
122
+ targets: [macos-arm64, ios-arm64, android-arm64]
123
+
124
+ updates: # self-hosted, signed auto-updates
125
+ url: https://updates.example.com
126
+ public_key: RWQ…
127
+ auto: check # off | check | download | install
128
+ ```
129
+
130
+ Also supported: `auth:` (native OAuth sign-in with a callback scheme),
131
+ `deep_linking:` (universal links / App Links — the gem serves the
132
+ `apple-app-site-association` and `assetlinks.json` files for you), and
133
+ `native:` for "supernative" extensions — Swift you keep in your app repo under
134
+ `native/ios/` that `every build` compiles into the shell, including SwiftUI
135
+ screens, bridge components, a custom splash, and pinned SPM packages.
136
+
137
+ Tabs and path rules ship two ways from that one source: baked into the app
138
+ bundle for offline/first launch, and served live from
139
+ `/everywhere/{ios,android}_v1.json`, so changing them deploys with your web app.
140
+ `Everywhere.filter_tabs` lets you vary the tab bar per request — it shares the
141
+ app's session, so signed-out users can get a different bar without a release.
142
+
143
+ ## The JavaScript bridge
144
+
145
+ The gem ships `@rubyeverywhere/bridge` — one API for the browser, the desktop
146
+ shell, and the mobile shells. Rails apps get it served and importmap-pinned
147
+ automatically; Sinatra and Hanami get it vendored to `public/bridge.js`.
148
+
149
+ ```js
150
+ import Everywhere from "@rubyeverywhere/bridge"
151
+
152
+ Everywhere.platform // "desktop" | "mobile" | "browser"
153
+ Everywhere.native // true inside a RubyEverywhere app
154
+
155
+ Everywhere.notify({ title, body })
156
+ Everywhere.menu({ title, items })
157
+ Everywhere.haptics.impact("medium")
158
+ Everywhere.badge.setTab("/inbox", 3)
159
+ Everywhere.permissions.request("camera")
160
+ Everywhere.storage.set("theme", "dark")
161
+ Everywhere.updates.check()
162
+ ```
163
+
164
+ Everything degrades gracefully — the same page works in a plain browser tab and
165
+ gains native powers inside the app. See
166
+ [`bridge/README.md`](bridge/README.md) for the full surface.
167
+
168
+ Most native chrome needs no JavaScript at all. Tag ordinary markup and the
169
+ bridge lifts it into real native controls; tapping the native control just
170
+ `.click()`s the element it mirrors, so behavior stays defined once in the DOM.
171
+ Rails ships helpers that emit exactly that markup:
172
+
173
+ ```erb
174
+ <%= everywhere_nav_button "New", new_note_path, icons: { ios: "plus", android: "add" } %>
175
+ <%= everywhere_fab new_note_path %>
176
+ <%= everywhere_nav_menu "More" do %>
177
+ <%= everywhere_menu_item "Delete", note_path(@note), method: :delete, style: "destructive" %>
178
+ <% end %>
179
+ ```
180
+
181
+ Plus `native_app?`, `native_platform`, `everywhere_badge`,
182
+ `everywhere_biometric_lock`, and friends.
183
+
184
+ ## Commands
185
+
186
+ | Command | What it does |
187
+ | --- | --- |
188
+ | `every install` | Prepare an app (boot stub, `config/everywhere.yml`, config tweaks) |
189
+ | `every doctor` | Check the toolchain — Tebako, Rust, brew deps, Xcode, Android SDK/JDK |
190
+ | `every dev` | Dev server with native shells on demand (`--desktop --ios --android --browser`) |
191
+ | `every logs` | Stream shell logs from the Simulator or Android device |
192
+ | `every build` | Package natively — desktop, `--ios`, or `--android` |
193
+ | `every icon` | Generate `.icns` / `.ico` / Linux icons from a source PNG |
194
+ | `every release` | Sign, notarize, staple, and emit a `release.json` receipt |
195
+ | `every updates keygen` | Generate a minisign-compatible update-signing keypair |
196
+ | `every publish` | Publish a release to your update bucket |
197
+ | `every clean` | Remove the shell build caches |
198
+ | `every shell-dir` | Print the path of the bundled shell template |
199
+ | `every platform …` | Hosted builds: `login`, `logout`, `auth status`, `build` |
200
+
201
+ Run `every` with no arguments for the full list, or `every <command> --help` for
202
+ a command's flags.
203
+
204
+ ## Auto-updates
205
+
206
+ Desktop releases can update themselves from a bucket you own, with no service in
207
+ the middle:
208
+
209
+ ```sh
210
+ every updates keygen # once, per app
211
+ every release # build, sign, notarize, staple
212
+ every publish --notes-file NOTES.md # sign the manifest, upload, flip latest.json
213
+ ```
214
+
215
+ The public key ships in the app; the secret key never leaves your machine. Apps
216
+ can check, install, and switch channels at runtime via `Everywhere.updates`.
217
+
218
+ ## Hosted builds
219
+
220
+ Don't have a Mac, or want iOS and Android built without babysitting Xcode, the
221
+ Android SDK, and a signing setup? The **RubyEverywhere Platform** — a paid
222
+ service, coming soon — runs the same builds on managed machines and hands back
223
+ the artifacts. The CLI already speaks to it:
224
+
225
+ ```sh
226
+ every platform login
227
+ every platform build --target ios-arm64,android-arm64
228
+ ```
229
+
230
+ Same gem, same `config/everywhere.yml`, same output — you're paying for the
231
+ build machines and the toolchain upkeep, not for features. The gem stays free
232
+ and complete on its own.
233
+
234
+ ## Project layout
235
+
236
+ ```
237
+ lib/everywhere/ the CLI, config, builders, and Rails engine
238
+ exe/every, exe/rbe the executables
239
+ bridge/ @rubyeverywhere/bridge — the JS half
240
+ support/shell/ the Tauri desktop shell template
241
+ support/mobile/ios/ the Hotwire Native iOS shell template
242
+ support/mobile/android/ the Hotwire Native Android shell template
243
+ ```
244
+
245
+ The shell templates have their own READMEs documenting the freeze rules — what
246
+ the CLI stamps per app and what it must never touch:
247
+ [iOS](support/mobile/ios/README.md) · [Android](support/mobile/android/README.md).
248
+
249
+ ## Contributing
250
+
251
+ Bug reports and pull requests are welcome. Run the test suite with `rake test`.
252
+
253
+ ## License
254
+
255
+ Released under the [MIT License](LICENSE.txt). Copyright © 2026 Andrea Fomera.
@@ -16,6 +16,7 @@
16
16
  // Everywhere.on("menu", handler) // shell events; returns unsubscribe fn
17
17
  // Everywhere.visit("/settings") // Turbo.visit with location fallback
18
18
  // Everywhere.reloadTabs() // re-fetch native tab/nav config (mobile)
19
+ // Everywhere.auth.signIn("/auth/github") // provider sign-in in the system browser (mobile)
19
20
  //
20
21
  // Everywhere.haptics.impact("medium") // light | medium | heavy | soft | rigid
21
22
  // Everywhere.haptics.notification("success") // success | warning | error
@@ -532,8 +533,9 @@ const mobile = {
532
533
  // Ask the shell to re-fetch its native navigation config (tabs + path
533
534
  // rules) now, instead of waiting for the next app foreground. Call it after
534
535
  // a change that affects which tabs should show — signing in or out — so the
535
- // tab bar updates immediately. Uses a dedicated WKScriptMessageHandler, so
536
- // it doesn't depend on a bridge component being mounted on the page.
536
+ // tab bar updates immediately. Uses the shell's control channel (a dedicated
537
+ // WKScriptMessageHandler on iOS, a WebMessageListener on Android), so it
538
+ // doesn't depend on a bridge component being mounted on the page.
537
539
  reloadTabs() {
538
540
  const control = nativeControlChannel()
539
541
  if (control) {
@@ -566,6 +568,16 @@ const mobile = {
566
568
  if (control) control.postMessage({ action: "clearInstance", ...(to ? { to } : {}) })
567
569
  },
568
570
 
571
+ // Open a provider sign-in in the system browser rather than this web view —
572
+ // where Google refuses to run at all and the rest lose the password manager
573
+ // and the user's existing session. The shell takes it from here: auth
574
+ // session, one-time handoff token, full reset onto the signed-in app.
575
+ authSignIn(path) {
576
+ const control = nativeControlChannel()
577
+ if (control) control.postMessage({ action: "authFlow", to: path })
578
+ else window.location.assign(path)
579
+ },
580
+
569
581
  // Real haptics via the shell's "everywhere--haptics" component; vibration
570
582
  // fallback while the handshake completes or on an older shell.
571
583
  haptic(kind, value) {
@@ -702,10 +714,43 @@ const mobile = {
702
714
  }
703
715
  }
704
716
 
717
+ // The shell's out-of-band command line: app-level actions (reset, tabs,
718
+ // badges, instance, auth) that must work on any page, whether or not a bridge
719
+ // component happens to be mounted on it. Each shell injects it its own way,
720
+ // and the two APIs disagree about what a message is:
721
+ //
722
+ // iOS — WKUserContentController.add(_:name:) hands us
723
+ // window.webkit.messageHandlers.everywhereControl, whose
724
+ // postMessage takes an *object* and serializes it across the
725
+ // boundary for us.
726
+ // Android — WebViewCompat.addWebMessageListener("everywhereControl") hands us
727
+ // window.everywhereControl, backed by a WebMessagePort, and a web
728
+ // message is *text*: postMessage(String). (Android has no user
729
+ // scripts either, so the shell injects this with
730
+ // addDocumentStartJavaScript — it's in place before page JS runs.)
731
+ //
732
+ // Normalizing here rather than at each call site is the point: the seven
733
+ // callers below all speak the object shape, and only this function knows which
734
+ // shell is listening. iOS gets its handler back untouched — it already is the
735
+ // normalized shape, so that path stays byte-for-byte what it was.
705
736
  function nativeControlChannel() {
706
- return window.webkit &&
737
+ if (typeof window === "undefined") return null
738
+
739
+ const webkit =
740
+ window.webkit &&
707
741
  window.webkit.messageHandlers &&
708
742
  window.webkit.messageHandlers.everywhereControl
743
+ if (webkit) return webkit
744
+
745
+ const android = window.everywhereControl
746
+ if (android && typeof android.postMessage === "function") {
747
+ // Built per call, not cached: a fresh document (a Turbo drive visit is
748
+ // not, but a real navigation is) gets a fresh injected object, and there's
749
+ // nothing here worth keeping alive across one.
750
+ return { postMessage: (message) => android.postMessage(JSON.stringify(message)) }
751
+ }
752
+
753
+ return null
709
754
  }
710
755
 
711
756
  // --- public surface ----------------------------------------------------------
@@ -790,6 +835,18 @@ export const Everywhere = {
790
835
  else this.visit(to || window.location.pathname)
791
836
  },
792
837
 
838
+ // Third-party sign-in (everywhere.yml `auth:`). You rarely need this: the
839
+ // shell already diverts the paths listed there, so a plain link to
840
+ // /auth/github does the right thing everywhere. Call it when the sign-in is
841
+ // driven by something the shell can't see as a visit — a fetch, a custom
842
+ // element, a button that has other work to do first.
843
+ auth: {
844
+ signIn(path) {
845
+ if (adapter.authSignIn) adapter.authSignIn(path)
846
+ else window.location.assign(path)
847
+ }
848
+ },
849
+
793
850
  clipboard: {
794
851
  write(text) {
795
852
  return adapter.clipboardWrite(text)
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Everywhere
6
+ # Compiles an app's native/android/assets/ tree into Android drawable
7
+ # resources so native code can reach them by name — R.drawable.logo in Kotlin,
8
+ # @drawable/logo in a layout.
9
+ #
10
+ # Deliberately the same filename API as the iOS side (see AssetCatalog), so one
11
+ # assets/ tree can be authored once and dropped into both native/ios/ and
12
+ # native/android/: <name>[@2x|@3x][~dark].<ext>. What differs is where the
13
+ # markers land, because Android expresses both as directory qualifiers rather
14
+ # than as catalog metadata:
15
+ #
16
+ # logo.png → res/drawable/logo.png (density-independent baseline)
17
+ # logo@2x.png → res/drawable-xhdpi/logo.png (2x = 320dpi)
18
+ # logo@3x.png → res/drawable-xxhdpi/logo.png (3x = 480dpi)
19
+ # logo~dark.png → res/drawable-night/logo.png
20
+ # logo@2x~dark.png→ res/drawable-night-xhdpi/logo.png
21
+ #
22
+ # Sub-directories organise the source tree but do NOT namespace the result —
23
+ # res/ is flat, and asset names are global on iOS too, so assets/branding/
24
+ # logo.png is R.drawable.logo either way.
25
+ module AndroidResources
26
+ # Raster formats aapt2 accepts as-is. WebP is included (and PDF/SVG are not)
27
+ # because that is the split Android itself draws — the reverse of iOS.
28
+ RASTER = %w[.png .jpg .jpeg .webp].freeze
29
+
30
+ # A drawable written as XML: vectors, shapes, layer lists, selectors. Scale
31
+ # -free, so the density qualifiers above don't apply to them.
32
+ VECTOR = %w[.xml].freeze
33
+ EXTENSIONS = (RASTER + VECTOR).freeze
34
+
35
+ # iOS's @2x/@3x in Android's density buckets. 1x is plain `drawable`, not
36
+ # `drawable-mdpi`: both mean 160dpi, but the unqualified directory is also
37
+ # the fallback every density falls back TO, which is what a single-file
38
+ # asset needs to be.
39
+ DENSITIES = { nil => nil, "2x" => "xhdpi", "3x" => "xxhdpi" }.freeze
40
+
41
+ # Written by the Android builder itself (adaptive launcher icon layers) or
42
+ # shipped in the frozen template's own res/. Both would collide in the
43
+ # merged resource table, so they're refused by name here — a merge conflict
44
+ # from aapt2 arrives minutes into a Gradle run and names no fix.
45
+ RESERVED = %w[
46
+ ic_launcher ic_launcher_round ic_launcher_foreground ic_launcher_background
47
+ ic_tab_placeholder
48
+ ].freeze
49
+
50
+ # An Android resource entry is a field on the generated R class, so the name
51
+ # has to be a Java identifier — and aapt2 additionally rejects uppercase in
52
+ # a res/ filename. Hyphens and dots are legal on iOS and never here.
53
+ VALID_NAME = /\A[a-z][a-z0-9_]*\z/
54
+
55
+ # Reserved words can't name a field on R. `val`/`fun` are fine (Kotlin reads
56
+ # R from Java), so this is the Java list, not the Kotlin one.
57
+ JAVA_KEYWORDS = %w[
58
+ abstract assert boolean break byte case catch char class const continue default do double else
59
+ enum extends final finally float for goto if implements import instanceof int interface long
60
+ native new package private protected public return short static strictfp super switch
61
+ synchronized this throw throws transient try void volatile while
62
+ ].freeze
63
+
64
+ class << self
65
+ # Copy every image under `source` into `res` as a drawable resource.
66
+ # Returns [names, errors]; nothing is written when errors is non-empty, so
67
+ # a broken assets/ tree can't half-stamp the resource dirs.
68
+ def write!(source, res)
69
+ variants, errors = plan(source)
70
+ return [[], errors] unless errors.empty?
71
+
72
+ variants.each do |variant|
73
+ dir = File.join(res, variant[:dir])
74
+ FileUtils.mkdir_p(dir)
75
+ FileUtils.cp(variant[:path], File.join(dir, "#{variant[:name]}#{variant[:extension]}"))
76
+ end
77
+
78
+ [variants.map { |variant| variant[:name] }.uniq.sort, []]
79
+ end
80
+
81
+ private
82
+
83
+ # Errors are collected (not raised) so one `every build` reports every
84
+ # problem in the tree instead of the first.
85
+ def plan(source)
86
+ errors = []
87
+ files = Dir.glob(File.join(source, "**", "*"))
88
+ .select { |path| File.file?(path) && !File.basename(path).start_with?(".") }
89
+ .sort
90
+ images, other = files.partition { |path| EXTENSIONS.include?(File.extname(path).downcase) }
91
+
92
+ other.each { |path| errors << unsupported(path, source) }
93
+ variants = images.filter_map { |path| variant(path, source, errors) }
94
+ errors.concat(clash_errors(variants))
95
+
96
+ [variants, errors]
97
+ end
98
+
99
+ def unsupported(path, source)
100
+ relative = relative(path, source)
101
+ case File.extname(path).downcase
102
+ when ".svg", ".pdf"
103
+ "#{relative}: Android has no runtime SVG or PDF renderer — export a PNG, or convert it to " \
104
+ "a VectorDrawable (Android Studio: right-click res → New → Vector Asset) and ship the .xml"
105
+ else
106
+ "#{relative}: unsupported image type — use #{EXTENSIONS.join(", ")}"
107
+ end
108
+ end
109
+
110
+ def variant(path, source, errors)
111
+ relative = relative(path, source)
112
+ extension = File.extname(path).downcase
113
+ stem = File.basename(path, File.extname(path))
114
+
115
+ # A nine-patch is `logo.9.png`, whose stem carries a dot that no valid
116
+ # resource name may — so it would otherwise fail with a naming error
117
+ # that says nothing about what the author actually tried to do.
118
+ if stem.end_with?(".9")
119
+ errors << "#{relative}: nine-patch drawables aren't supported yet — ship a plain #{extension} " \
120
+ "(or a VectorDrawable .xml, which stretches without one)"
121
+ return nil
122
+ end
123
+
124
+ name, scale, dark = parse(stem)
125
+
126
+ unless name.match?(VALID_NAME)
127
+ errors << "#{relative}: #{name.inspect} isn't a usable Android resource name — use lowercase " \
128
+ "letters, digits and _, starting with a letter (#{suggestion(name).inspect} would " \
129
+ "work). Unlike iOS, Android has no room for capitals, hyphens or dots here"
130
+ return nil
131
+ end
132
+ if JAVA_KEYWORDS.include?(name)
133
+ errors << "#{relative}: #{name.inspect} is a Java keyword, so it can't name a field on R — " \
134
+ "rename it (#{"#{name}_image".inspect})"
135
+ return nil
136
+ end
137
+ if RESERVED.include?(name)
138
+ errors << "#{relative}: #{name} is reserved by the shell (launcher icon and tab placeholder) — " \
139
+ "rename it"
140
+ return nil
141
+ end
142
+ if scale && VECTOR.include?(extension)
143
+ errors << "#{relative}: an XML drawable is scale-free — drop the @#{scale} suffix"
144
+ return nil
145
+ end
146
+
147
+ { name: name, extension: extension, dir: directory(scale, dark), source_dir: File.dirname(relative),
148
+ scale: scale, dark: dark, vector: VECTOR.include?(extension), path: path, relative: relative }
149
+ end
150
+
151
+ # Trailing @2x/@3x and ~dark markers, in either order, off the stem —
152
+ # character for character AssetCatalog#parse, because the two platforms
153
+ # read the same filenames.
154
+ def parse(stem)
155
+ name = stem.dup
156
+ scale = nil
157
+ dark = false
158
+ loop do
159
+ if name.sub!(/~dark\z/, "")
160
+ dark = true
161
+ elsif name.sub!(/@([23])x\z/, "")
162
+ scale = "#{Regexp.last_match(1)}x"
163
+ else
164
+ break
165
+ end
166
+ end
167
+ [name, scale, dark]
168
+ end
169
+
170
+ # Qualifier order is fixed by the platform and is not alphabetical: UI mode
171
+ # (night) comes before density. `drawable-xhdpi-night` is not a directory
172
+ # Android will ever match — it's silently ignored, which reads as "my dark
173
+ # asset doesn't work" with nothing in the log.
174
+ def directory(scale, dark)
175
+ ["drawable", dark ? "night" : nil, DENSITIES.fetch(scale)].compact.join("-")
176
+ end
177
+
178
+ # What the author probably meant, so the error can show it rather than
179
+ # describe it. Not applied automatically: R.drawable.<name> is written by
180
+ # hand in Kotlin, so a name we invented is a name nobody can guess.
181
+ def suggestion(name)
182
+ candidate = name.gsub(/([a-z0-9])([A-Z])/, '\1_\2').downcase.gsub(/[^a-z0-9]+/, "_")
183
+ .gsub(/_+/, "_").delete_prefix("_").delete_suffix("_")
184
+ candidate.match?(/\A[a-z]/) ? candidate : "image_#{candidate}"
185
+ end
186
+
187
+ def clash_errors(variants)
188
+ errors = []
189
+ variants.group_by { |variant| variant[:name] }.each do |name, group|
190
+ dirs = group.map { |variant| variant[:source_dir] }.uniq
191
+ if dirs.length > 1
192
+ listed = dirs.map { |dir| dir == "." ? "assets/" : "assets/#{dir}/" }.join(" and ")
193
+ errors << "#{name}: same asset name in #{listed} — res/ is flat, so both would become " \
194
+ "R.drawable.#{name}. Rename one"
195
+ end
196
+ if group.map { |variant| variant[:vector] }.uniq.length > 1
197
+ errors << "#{name}: mixes an XML drawable with bitmaps — pick one per asset"
198
+ end
199
+
200
+ # Same destination directory and same name: aapt2 sees two files
201
+ # claiming one resource, whatever their extensions.
202
+ group.group_by { |variant| variant[:dir] }.each_value do |clash|
203
+ next if clash.length < 2
204
+
205
+ errors << "#{name}: #{clash.map { |variant| variant[:relative] }.join(" and ")} both become " \
206
+ "res/#{clash.first[:dir]}/#{name}"
207
+ end
208
+ end
209
+ errors
210
+ end
211
+
212
+ def relative(path, source)
213
+ path.delete_prefix("#{source}/")
214
+ end
215
+ end
216
+ end
217
+ end