ruby_everywhere 0.3.0 → 0.5.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 (125) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +339 -0
  3. data/bridge/README.md +70 -1
  4. data/bridge/everywhere/bridge.js +211 -3
  5. data/bridge/everywhere/native.css +61 -0
  6. data/lib/everywhere/android_resources.rb +217 -0
  7. data/lib/everywhere/android_sdk.rb +330 -0
  8. data/lib/everywhere/asset_catalog.rb +185 -0
  9. data/lib/everywhere/auth_handoff.rb +404 -0
  10. data/lib/everywhere/auth_token.rb +118 -0
  11. data/lib/everywhere/builders/android.rb +844 -0
  12. data/lib/everywhere/builders/desktop.rb +326 -0
  13. data/lib/everywhere/builders/ios.rb +40 -3
  14. data/lib/everywhere/child_processes.rb +74 -0
  15. data/lib/everywhere/commands/build.rb +95 -14
  16. data/lib/everywhere/commands/clean.rb +10 -5
  17. data/lib/everywhere/commands/dev.rb +462 -57
  18. data/lib/everywhere/commands/doctor.rb +89 -3
  19. data/lib/everywhere/commands/install.rb +21 -1
  20. data/lib/everywhere/commands/logs.rb +117 -8
  21. data/lib/everywhere/commands/release.rb +2 -2
  22. data/lib/everywhere/config.rb +703 -13
  23. data/lib/everywhere/console.rb +117 -0
  24. data/lib/everywhere/desktop_assets.rb +150 -0
  25. data/lib/everywhere/dock/footer.rb +150 -0
  26. data/lib/everywhere/dock/screen.rb +114 -0
  27. data/lib/everywhere/dock/state.rb +59 -0
  28. data/lib/everywhere/dock.rb +238 -0
  29. data/lib/everywhere/emulator.rb +405 -0
  30. data/lib/everywhere/engine.rb +12 -0
  31. data/lib/everywhere/fatal.rb +20 -0
  32. data/lib/everywhere/icon.rb +196 -0
  33. data/lib/everywhere/line_pump.rb +89 -0
  34. data/lib/everywhere/log_filter.rb +111 -0
  35. data/lib/everywhere/native_helper.rb +39 -9
  36. data/lib/everywhere/omniauth.rb +88 -0
  37. data/lib/everywhere/paths.rb +109 -11
  38. data/lib/everywhere/relay.rb +77 -0
  39. data/lib/everywhere/shellout.rb +90 -15
  40. data/lib/everywhere/task_pool.rb +123 -0
  41. data/lib/everywhere/ui.rb +54 -11
  42. data/lib/everywhere/version.rb +1 -1
  43. data/support/desktop/README.md +121 -0
  44. data/support/{shell → desktop}/src-tauri/Cargo.lock +23 -0
  45. data/support/{shell → desktop}/src-tauri/Cargo.toml +19 -1
  46. data/support/{shell → desktop}/src-tauri/capabilities/default.json +7 -0
  47. data/support/desktop/src-tauri/gen/schemas/capabilities.json +1 -0
  48. data/support/desktop/src-tauri/src/extension_host.rs +53 -0
  49. data/support/desktop/src-tauri/src/extensions/mod.rs +39 -0
  50. data/support/{shell → desktop}/src-tauri/src/main.rs +355 -10
  51. data/support/{shell → desktop}/src-tauri/tauri.conf.json +2 -2
  52. data/support/mobile/android/README.md +217 -0
  53. data/support/mobile/android/app/build.gradle.kts +156 -0
  54. data/support/mobile/android/app/everywhere.properties +8 -0
  55. data/support/mobile/android/app/native-packages.gradle.kts +16 -0
  56. data/support/mobile/android/app/proguard-rules.pro +21 -0
  57. data/support/mobile/android/app/src/debug/res/xml/network_security_config.xml +30 -0
  58. data/support/mobile/android/app/src/main/AndroidManifest.xml +42 -0
  59. data/support/mobile/android/app/src/main/assets/everywhere.json +9 -0
  60. data/support/mobile/android/app/src/main/assets/fonts/MaterialIcons-Regular.codepoints +2235 -0
  61. data/support/mobile/android/app/src/main/assets/fonts/MaterialIcons-Regular.ttf +0 -0
  62. data/support/mobile/android/app/src/main/assets/json/path-configuration.json +19 -0
  63. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/AuthFlow.kt +268 -0
  64. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereApplication.kt +178 -0
  65. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereConfig.kt +409 -0
  66. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereEvents.kt +68 -0
  67. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/EverywhereHost.kt +101 -0
  68. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/IconFont.kt +211 -0
  69. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MainActivity.kt +975 -0
  70. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/MainTabs.kt +201 -0
  71. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/WebBottomSheetFragment.kt +13 -0
  72. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/WebFragment.kt +36 -0
  73. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/BiometricsComponent.kt +535 -0
  74. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/HapticsComponent.kt +141 -0
  75. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/MenuComponent.kt +361 -0
  76. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/NotificationComponent.kt +151 -0
  77. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/PermissionsComponent.kt +271 -0
  78. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/StorageComponent.kt +101 -0
  79. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/bridge/WebControlChannel.kt +204 -0
  80. data/support/mobile/android/app/src/main/java/com/rubyeverywhere/shell/extensions/EverywhereExtensions.kt +51 -0
  81. data/support/mobile/android/app/src/main/res/color/bottom_navigation_active_indicator.xml +4 -0
  82. data/support/mobile/android/app/src/main/res/color/bottom_navigation_item.xml +11 -0
  83. data/support/mobile/android/app/src/main/res/drawable/ic_launcher_foreground.xml +26 -0
  84. data/support/mobile/android/app/src/main/res/drawable/ic_tab_placeholder.xml +28 -0
  85. data/support/mobile/android/app/src/main/res/layout/activity_main.xml +116 -0
  86. data/support/mobile/android/app/src/main/res/layout/bridge_menu_sheet.xml +83 -0
  87. data/support/mobile/android/app/src/main/res/layout/bridge_menu_sheet_item.xml +35 -0
  88. data/support/mobile/android/app/src/main/res/layout/tab_more_sheet.xml +48 -0
  89. data/support/mobile/android/app/src/main/res/layout/tab_more_sheet_item.xml +35 -0
  90. data/support/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +12 -0
  91. data/support/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +6 -0
  92. data/support/mobile/android/app/src/main/res/values/colors.xml +21 -0
  93. data/support/mobile/android/app/src/main/res/values/ic_launcher_background.xml +5 -0
  94. data/support/mobile/android/app/src/main/res/values/strings.xml +18 -0
  95. data/support/mobile/android/app/src/main/res/values/styles.xml +32 -0
  96. data/support/mobile/android/app/src/main/res/values/themes.xml +53 -0
  97. data/support/mobile/android/app/src/main/res/values-night/colors.xml +11 -0
  98. data/support/mobile/android/app/src/main/res/values-night/themes.xml +33 -0
  99. data/support/mobile/android/app/src/main/res/xml/network_security_config.xml +12 -0
  100. data/support/mobile/android/app/src/stamped/AndroidManifest.xml +34 -0
  101. data/support/mobile/android/build.gradle.kts +8 -0
  102. data/support/mobile/android/gradle/wrapper/gradle-wrapper.jar +0 -0
  103. data/support/mobile/android/gradle/wrapper/gradle-wrapper.properties +10 -0
  104. data/support/mobile/android/gradle.properties +14 -0
  105. data/support/mobile/android/gradlew +251 -0
  106. data/support/mobile/android/gradlew.bat +94 -0
  107. data/support/mobile/android/settings.gradle.kts +28 -0
  108. data/support/mobile/ios/App/AppDelegate.swift +5 -0
  109. data/support/mobile/ios/App/AuthFlow.swift +101 -0
  110. data/support/mobile/ios/App/EverywhereConfig.swift +84 -0
  111. data/support/mobile/ios/App/SceneDelegate.swift +103 -0
  112. data/support/mobile/ios/App.xcodeproj/project.pbxproj +4 -0
  113. data/support/mobile/ios/README.md +4 -1
  114. data/support/{macos → release/macos}/notarize.sh +2 -2
  115. metadata +97 -17
  116. data/support/github/build.yml +0 -85
  117. data/support/shell/src-tauri/gen/schemas/capabilities.json +0 -1
  118. /data/support/{shell → desktop}/splash/index.html +0 -0
  119. /data/support/{shell → desktop}/src-tauri/build.rs +0 -0
  120. /data/support/{shell → desktop}/src-tauri/gen/schemas/acl-manifests.json +0 -0
  121. /data/support/{shell → desktop}/src-tauri/gen/schemas/desktop-schema.json +0 -0
  122. /data/support/{shell → desktop}/src-tauri/gen/schemas/macOS-schema.json +0 -0
  123. /data/support/{shell → desktop}/src-tauri/icons/icon.png +0 -0
  124. /data/support/{shell → desktop}/src-tauri/src/updater.rs +0 -0
  125. /data/support/{macos → release/macos}/entitlements.plist +0 -0
@@ -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
@@ -177,6 +178,19 @@ function detectPlatform() {
177
178
 
178
179
  // --- desktop: the Tauri-based RubyEverywhere shell -------------------------
179
180
 
181
+ // Distinguishes concurrent desktop command calls. Only has to be unique within
182
+ // one page, so a counter is enough; the random suffix just keeps two rapid
183
+ // calls from colliding if the page is restored from bfcache mid-flight.
184
+ let commandSequence = 0
185
+ const COMMAND_TIMEOUT = 30000
186
+
187
+ // The shell's own window handle. Named so it can't shadow the global `window`
188
+ // inside the adapter below, where every other method still needs the real one.
189
+ function tauriWindow() {
190
+ const api = window.__TAURI__ && window.__TAURI__.window
191
+ return api && api.getCurrentWindow ? api.getCurrentWindow() : null
192
+ }
193
+
180
194
  const desktop = {
181
195
  notify({ title, body }) {
182
196
  return window.__TAURI__.event.emit("everywhere:notify", { title, body })
@@ -209,6 +223,56 @@ const desktop = {
209
223
  return window.__TAURI__.clipboardManager.readText()
210
224
  },
211
225
 
226
+ // Window controls, for apps that draw their own title bar (`window.title_bar:
227
+ // frameless`, and `overlay` on Windows/Linux where there's no native overlay
228
+ // style). Without a system title bar there's nothing to drag or click, so the
229
+ // page supplies both and calls these. One entry point rather than six methods
230
+ // because the Tauri window API is already uniform: every one of these is a
231
+ // no-arg call returning a promise.
232
+ //
233
+ // These are ACL-gated. The matching core:window:allow-* permissions have to be
234
+ // in BOTH capability sets (capabilities/default.json for local origins, and
235
+ // the mirrored list in authorize_remote_origin for remote mode) or they reject
236
+ // at runtime.
237
+ windowControl(action) {
238
+ const win = tauriWindow()
239
+ return win ? win[action]() : Promise.resolve(null)
240
+ },
241
+
242
+ // Call a Rust function the app keeps in native/desktop/. Events, not invoke:
243
+ // a RubyEverywhere page is always served over http (127.0.0.1 in local mode,
244
+ // your domain in remote mode) and Tauri v2 refuses invoke from any http
245
+ // origin — even the packaged app's own localhost server. See the comment on
246
+ // install_command_bridge in the shell's extension_host.rs.
247
+ //
248
+ // The id round-trips so concurrent calls can't take each other's answers,
249
+ // which a bare request/response event pair would otherwise allow.
250
+ desktopInvoke(name, payload) {
251
+ return new Promise((resolve, reject) => {
252
+ const id = `${commandSequence++}-${Math.random().toString(36).slice(2)}`
253
+ let off = null
254
+ const settle = (fn, value) => {
255
+ clearTimeout(timer)
256
+ if (off) off()
257
+ fn(value)
258
+ }
259
+ const timer = setTimeout(
260
+ () => settle(reject, new Error(`desktop command ${name} timed out`)),
261
+ COMMAND_TIMEOUT
262
+ )
263
+ off = this.on("command-result", (result) => {
264
+ if (!result || result.id !== id) return
265
+ if (result.ok) settle(resolve, result.value)
266
+ else settle(reject, new Error(result.error || `desktop command ${name} failed`))
267
+ })
268
+ window.__TAURI__.event.emit("everywhere:command", {
269
+ id,
270
+ name,
271
+ payload: payload === undefined ? null : payload
272
+ })
273
+ })
274
+ },
275
+
212
276
  // Ask the shell to check its update feed; resolve off whichever update
213
277
  // event answers first. The shell always replies to an explicit check
214
278
  // (available / none / error), the timeout is just a safety net.
@@ -294,6 +358,22 @@ const browser = {
294
358
  return null
295
359
  },
296
360
 
361
+ // A browser tab has no window of its own to drive — the one thing close()
362
+ // could map to (window.close) only works for script-opened windows, which
363
+ // this never is. Resolving null keeps a page that draws a custom title bar
364
+ // from throwing when it's opened in a plain browser.
365
+ windowControl() {
366
+ return Promise.resolve(null)
367
+ },
368
+
369
+ // There is no Rust here. Rejecting (rather than resolving null) is the honest
370
+ // answer: unlike the rest of the bridge, a desktop command has no web
371
+ // equivalent to degrade to, and silently resolving would let a page act on a
372
+ // result it never got. Gate on Everywhere.desktop.supported.
373
+ desktopInvoke(name) {
374
+ return Promise.reject(new Error(`desktop command ${name} is unavailable outside the desktop shell`))
375
+ },
376
+
297
377
  // Honest approximation: the Vibration API where it exists (Android
298
378
  // browsers); silently nothing elsewhere (iOS Safari has no equivalent).
299
379
  haptic(kind, value) {
@@ -532,8 +612,9 @@ const mobile = {
532
612
  // Ask the shell to re-fetch its native navigation config (tabs + path
533
613
  // rules) now, instead of waiting for the next app foreground. Call it after
534
614
  // 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.
615
+ // tab bar updates immediately. Uses the shell's control channel (a dedicated
616
+ // WKScriptMessageHandler on iOS, a WebMessageListener on Android), so it
617
+ // doesn't depend on a bridge component being mounted on the page.
537
618
  reloadTabs() {
538
619
  const control = nativeControlChannel()
539
620
  if (control) {
@@ -566,6 +647,16 @@ const mobile = {
566
647
  if (control) control.postMessage({ action: "clearInstance", ...(to ? { to } : {}) })
567
648
  },
568
649
 
650
+ // Open a provider sign-in in the system browser rather than this web view —
651
+ // where Google refuses to run at all and the rest lose the password manager
652
+ // and the user's existing session. The shell takes it from here: auth
653
+ // session, one-time handoff token, full reset onto the signed-in app.
654
+ authSignIn(path) {
655
+ const control = nativeControlChannel()
656
+ if (control) control.postMessage({ action: "authFlow", to: path })
657
+ else window.location.assign(path)
658
+ },
659
+
569
660
  // Real haptics via the shell's "everywhere--haptics" component; vibration
570
661
  // fallback while the handshake completes or on an older shell.
571
662
  haptic(kind, value) {
@@ -702,10 +793,43 @@ const mobile = {
702
793
  }
703
794
  }
704
795
 
796
+ // The shell's out-of-band command line: app-level actions (reset, tabs,
797
+ // badges, instance, auth) that must work on any page, whether or not a bridge
798
+ // component happens to be mounted on it. Each shell injects it its own way,
799
+ // and the two APIs disagree about what a message is:
800
+ //
801
+ // iOS — WKUserContentController.add(_:name:) hands us
802
+ // window.webkit.messageHandlers.everywhereControl, whose
803
+ // postMessage takes an *object* and serializes it across the
804
+ // boundary for us.
805
+ // Android — WebViewCompat.addWebMessageListener("everywhereControl") hands us
806
+ // window.everywhereControl, backed by a WebMessagePort, and a web
807
+ // message is *text*: postMessage(String). (Android has no user
808
+ // scripts either, so the shell injects this with
809
+ // addDocumentStartJavaScript — it's in place before page JS runs.)
810
+ //
811
+ // Normalizing here rather than at each call site is the point: the seven
812
+ // callers below all speak the object shape, and only this function knows which
813
+ // shell is listening. iOS gets its handler back untouched — it already is the
814
+ // normalized shape, so that path stays byte-for-byte what it was.
705
815
  function nativeControlChannel() {
706
- return window.webkit &&
816
+ if (typeof window === "undefined") return null
817
+
818
+ const webkit =
819
+ window.webkit &&
707
820
  window.webkit.messageHandlers &&
708
821
  window.webkit.messageHandlers.everywhereControl
822
+ if (webkit) return webkit
823
+
824
+ const android = window.everywhereControl
825
+ if (android && typeof android.postMessage === "function") {
826
+ // Built per call, not cached: a fresh document (a Turbo drive visit is
827
+ // not, but a real navigation is) gets a fresh injected object, and there's
828
+ // nothing here worth keeping alive across one.
829
+ return { postMessage: (message) => android.postMessage(JSON.stringify(message)) }
830
+ }
831
+
832
+ return null
709
833
  }
710
834
 
711
835
  // --- public surface ----------------------------------------------------------
@@ -719,6 +843,17 @@ const adapter = adapters[platform]
719
843
  // registered by the time app code first calls notify().
720
844
  if (platform === "mobile") installWebBridge()
721
845
 
846
+ // Publish the window chrome mode to CSS. The titlebar utilities in native.css
847
+ // key off this attribute, which is the only way they can know a mode that lives
848
+ // in everywhere.yml — and it keeps them inert everywhere else, since nothing
849
+ // stamps it in a browser tab, on mobile, or in a normally decorated window.
850
+ if (platform === "desktop" && typeof document !== "undefined") {
851
+ const titleBar = config.window && config.window.title_bar
852
+ if (titleBar && titleBar !== "decorated") {
853
+ document.documentElement.dataset.everywhereTitlebar = titleBar
854
+ }
855
+ }
856
+
722
857
  // Mutable so setChannel can keep updates.channel truthful without a reload.
723
858
  let updatesChannel = (config.updates && config.updates.channel) || null
724
859
 
@@ -790,6 +925,18 @@ export const Everywhere = {
790
925
  else this.visit(to || window.location.pathname)
791
926
  },
792
927
 
928
+ // Third-party sign-in (everywhere.yml `auth:`). You rarely need this: the
929
+ // shell already diverts the paths listed there, so a plain link to
930
+ // /auth/github does the right thing everywhere. Call it when the sign-in is
931
+ // driven by something the shell can't see as a visit — a fetch, a custom
932
+ // element, a button that has other work to do first.
933
+ auth: {
934
+ signIn(path) {
935
+ if (adapter.authSignIn) adapter.authSignIn(path)
936
+ else window.location.assign(path)
937
+ }
938
+ },
939
+
793
940
  clipboard: {
794
941
  write(text) {
795
942
  return adapter.clipboardWrite(text)
@@ -977,6 +1124,67 @@ export const Everywhere = {
977
1124
  }
978
1125
  },
979
1126
 
1127
+ // Rust the app keeps in native/desktop/ and declares under
1128
+ // `native.desktop.commands` in everywhere.yml:
1129
+ //
1130
+ // const ports = await Everywhere.desktop.invoke("scan_ports", { baud: 9600 })
1131
+ //
1132
+ // The payload arrives as serde_json::Value and whatever the command returns
1133
+ // comes back as the resolved value. Errors reject with the command's own
1134
+ // message. Rejects immediately off the desktop — there's no web fallback for
1135
+ // "talk to a serial port", so check `supported` first.
1136
+ desktop: {
1137
+ get supported() {
1138
+ return platform === "desktop"
1139
+ },
1140
+ invoke(name, payload) {
1141
+ return adapter.desktopInvoke(name, payload)
1142
+ }
1143
+ },
1144
+
1145
+ // Desktop window controls, for apps that draw their own title bar. Set
1146
+ // `window.title_bar: overlay` (macOS traffic lights float over your content)
1147
+ // or `frameless` (no system chrome at all) in everywhere.yml; frameless is
1148
+ // the one that needs these, since the page then owns dragging and the
1149
+ // minimize/maximize/close buttons:
1150
+ //
1151
+ // <header data-tauri-drag-region>
1152
+ // <button onclick="Everywhere.window.close()">×</button>
1153
+ // </header>
1154
+ //
1155
+ // Everything resolves to null in a browser tab and on mobile, where the OS
1156
+ // owns the frame — so the same markup is safe everywhere.
1157
+ window: {
1158
+ get supported() {
1159
+ return platform === "desktop"
1160
+ },
1161
+ minimize() {
1162
+ return adapter.windowControl("minimize")
1163
+ },
1164
+ maximize() {
1165
+ return adapter.windowControl("maximize")
1166
+ },
1167
+ unmaximize() {
1168
+ return adapter.windowControl("unmaximize")
1169
+ },
1170
+ // What a double-click on a title bar normally does.
1171
+ toggleMaximize() {
1172
+ return adapter.windowControl("toggleMaximize")
1173
+ },
1174
+ close() {
1175
+ return adapter.windowControl("close")
1176
+ },
1177
+ isMaximized() {
1178
+ return adapter.windowControl("isMaximized").then((maximized) => !!maximized)
1179
+ },
1180
+ // Call from a pointerdown handler on your custom title bar. Tauri's
1181
+ // data-tauri-drag-region attribute does this for you; this is the escape
1182
+ // hatch for a drag region you compute yourself.
1183
+ startDragging() {
1184
+ return adapter.windowControl("startDragging")
1185
+ }
1186
+ },
1187
+
980
1188
  updates: {
981
1189
  // True only when the shell actually has a feed to check (everywhere.yml
982
1190
  // updates: url + public_key made it into the shipped config).
@@ -201,3 +201,64 @@ html[data-bridge-platform] [data-everywhere-nav-menu] {
201
201
  @keyframes everywhere-sheet-rise {
202
202
  from { transform: translateY(1rem); opacity: 0; }
203
203
  }
204
+
205
+ /* ---------------------------------------------------------------------------
206
+ * Desktop window chrome — for `window.title_bar: overlay | frameless` in
207
+ * everywhere.yml. The bridge stamps data-everywhere-titlebar on <html>, so
208
+ * every rule here is scoped to the mode that needs it: on mobile, in a plain
209
+ * browser tab, and in a normal decorated window the attribute is absent and
210
+ * these classes do nothing. Safe to apply unconditionally.
211
+ * ------------------------------------------------------------------------- */
212
+
213
+ :root {
214
+ /* macOS traffic lights: a ~78px cluster inside a 28px band at the top left.
215
+ * Override both if you draw a taller bar. */
216
+ --everywhere-titlebar-height: 28px;
217
+ --everywhere-titlebar-controls: 78px;
218
+ }
219
+
220
+ /* Overlay mode: the system still draws (and positions) the traffic lights over
221
+ * your page. Reserve the band so your content doesn't slide underneath them.
222
+ * Frameless draws no controls at all, so it reserves nothing — the page owns
223
+ * that space and decides what goes there. */
224
+ [data-everywhere-titlebar="overlay"] .everywhere-titlebar-inset {
225
+ padding-top: var(--everywhere-titlebar-height);
226
+ }
227
+ [data-everywhere-titlebar="overlay"] .everywhere-titlebar-controls-inset {
228
+ padding-left: var(--everywhere-titlebar-controls);
229
+ }
230
+
231
+ /* A title-bar strip across the top of the window. Purely layout — the shell
232
+ * already makes the top 28px drag the window, so this needs no attribute:
233
+ *
234
+ * <header class="everywhere-titlebar">My App</header>
235
+ *
236
+ * Buttons and links inside it keep their clicks. Mark a region outside the
237
+ * strip with data-tauri-drag-region to make that draggable too, or set
238
+ * window.drag_height: 0 in everywhere.yml to take dragging over entirely.
239
+ *
240
+ * In overlay mode it clears the traffic lights; in frameless it starts at the
241
+ * left edge, because there's nothing there to clear. */
242
+ [data-everywhere-titlebar] .everywhere-titlebar {
243
+ display: flex;
244
+ align-items: center;
245
+ gap: 0.5rem;
246
+ height: var(--everywhere-titlebar-height);
247
+ user-select: none;
248
+ -webkit-user-select: none;
249
+ cursor: default;
250
+ }
251
+ [data-everywhere-titlebar="overlay"] .everywhere-titlebar {
252
+ padding-left: var(--everywhere-titlebar-controls);
253
+ }
254
+
255
+ /* Anything clickable inside the drag strip has to opt out, or the drag region
256
+ * swallows the click before the control ever sees it. */
257
+ [data-everywhere-titlebar] .everywhere-titlebar button,
258
+ [data-everywhere-titlebar] .everywhere-titlebar a,
259
+ [data-everywhere-titlebar] .everywhere-titlebar input,
260
+ [data-everywhere-titlebar] .everywhere-titlebar select,
261
+ [data-everywhere-titlebar] .everywhere-titlebar [data-tauri-drag-region="false"] {
262
+ -webkit-app-region: no-drag;
263
+ cursor: default;
264
+ }
@@ -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