ruby_everywhere 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +99 -3
  3. data/bridge/README.md +70 -1
  4. data/bridge/everywhere/bridge.js +151 -0
  5. data/bridge/everywhere/native.css +61 -0
  6. data/lib/everywhere/agents_guide.rb +57 -0
  7. data/lib/everywhere/auth_handoff.rb +8 -2
  8. data/lib/everywhere/builders/desktop.rb +326 -0
  9. data/lib/everywhere/child_processes.rb +74 -0
  10. data/lib/everywhere/commands/build.rb +44 -5
  11. data/lib/everywhere/commands/dev.rb +426 -59
  12. data/lib/everywhere/commands/install.rb +93 -1
  13. data/lib/everywhere/commands/release.rb +2 -2
  14. data/lib/everywhere/config.rb +298 -4
  15. data/lib/everywhere/console.rb +117 -0
  16. data/lib/everywhere/desktop_assets.rb +150 -0
  17. data/lib/everywhere/dock/footer.rb +150 -0
  18. data/lib/everywhere/dock/screen.rb +114 -0
  19. data/lib/everywhere/dock/state.rb +59 -0
  20. data/lib/everywhere/dock.rb +238 -0
  21. data/lib/everywhere/emulator.rb +2 -2
  22. data/lib/everywhere/fatal.rb +20 -0
  23. data/lib/everywhere/line_pump.rb +89 -0
  24. data/lib/everywhere/log_filter.rb +37 -0
  25. data/lib/everywhere/native_helper.rb +38 -5
  26. data/lib/everywhere/paths.rb +62 -11
  27. data/lib/everywhere/relay.rb +77 -0
  28. data/lib/everywhere/shellout.rb +90 -15
  29. data/lib/everywhere/task_pool.rb +123 -0
  30. data/lib/everywhere/ui.rb +54 -11
  31. data/lib/everywhere/version.rb +1 -1
  32. data/support/agents/AGENTS.md +90 -0
  33. data/support/agents/frameworks/hanami-views.md +26 -0
  34. data/support/agents/frameworks/hanami.md +10 -0
  35. data/support/agents/frameworks/rails-views.md +98 -0
  36. data/support/agents/frameworks/rails.md +16 -0
  37. data/support/agents/frameworks/sinatra-views.md +24 -0
  38. data/support/agents/frameworks/sinatra.md +9 -0
  39. data/support/agents/modes/local.md +12 -0
  40. data/support/agents/modes/remote.md +14 -0
  41. data/support/desktop/README.md +121 -0
  42. data/support/{shell → desktop}/src-tauri/Cargo.lock +23 -0
  43. data/support/{shell → desktop}/src-tauri/Cargo.toml +19 -1
  44. data/support/{shell → desktop}/src-tauri/capabilities/default.json +7 -0
  45. data/support/desktop/src-tauri/gen/schemas/capabilities.json +1 -0
  46. data/support/desktop/src-tauri/src/extension_host.rs +53 -0
  47. data/support/desktop/src-tauri/src/extensions/mod.rs +39 -0
  48. data/support/{shell → desktop}/src-tauri/src/main.rs +355 -10
  49. data/support/{shell → desktop}/src-tauri/tauri.conf.json +2 -2
  50. data/support/{macos → release/macos}/notarize.sh +2 -2
  51. metadata +55 -22
  52. data/support/github/build.yml +0 -85
  53. data/support/shell/src-tauri/gen/schemas/capabilities.json +0 -1
  54. /data/support/{shell → desktop}/splash/index.html +0 -0
  55. /data/support/{shell → desktop}/src-tauri/build.rs +0 -0
  56. /data/support/{shell → desktop}/src-tauri/gen/schemas/acl-manifests.json +0 -0
  57. /data/support/{shell → desktop}/src-tauri/gen/schemas/desktop-schema.json +0 -0
  58. /data/support/{shell → desktop}/src-tauri/gen/schemas/macOS-schema.json +0 -0
  59. /data/support/{shell → desktop}/src-tauri/icons/icon.png +0 -0
  60. /data/support/{shell → desktop}/src-tauri/src/updater.rs +0 -0
  61. /data/support/{macos → release/macos}/entitlements.plist +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7ffb921df597efe047c25240e7d8b92aaad49c00c90d546020e053eac618739a
4
- data.tar.gz: 17315b4c5d91eced13b44d7147942d7d6357ee791b8622425ab655eb97b1ab27
3
+ metadata.gz: 1c87aa5a2416d070a8047b790f28c7230882999b7df4f7f584256ee96852f066
4
+ data.tar.gz: 6fa25612acf17e6d90b3302a4e7f93a8d5ded79fd8ffb39aec318b908df32bcf
5
5
  SHA512:
6
- metadata.gz: 71d9ba1ea63abe51a7262f02f069ef9f068d3192bb20800bdd0fba8bcd50f36f2daa83bb3f758e7273576b69b88be5541428b41f93021579cc1b5054532099ad
7
- data.tar.gz: 59632bacae03ade28233832d0193d2500e4e3f85c07b7e61a39f1e086cc398b40f66d48091ffc9e336a11e2f91994b666ea60fc7ab2cbef2b76252d6fb0ada60
6
+ metadata.gz: 69fb1bdb659b8b886d9b602e09e21d8016caf140ac401ee371d4b0eaab2e06f3b342fb7742e2684570b80e71768fb2c5ede34dca3f1d5b9015185b93b176ba59
7
+ data.tar.gz: 450a804d3bf9765ea664e0728c66df31019e5633393680120ef9ec7ba40def7e69f163b9aaaf6b2d2fcec948e62c5d3821b26bb553924666dfb57b7d1c6b29cd
data/README.md CHANGED
@@ -57,12 +57,14 @@ cd path/to/your/app
57
57
 
58
58
  every install # remote mode: a native shell around your deployed app
59
59
  every install --local # local mode: compile and ship the app itself
60
+ every install --agents # ...plus an AGENTS.md guide for AI coding agents
60
61
 
61
62
  every doctor # check the toolchain before your first build
62
63
 
63
64
  every dev # dev server + native shells on demand, live reload
64
65
  every dev --ios # ...straight into the iOS Simulator
65
66
  every dev --android # ...straight into the Android emulator
67
+ every dev --mobile # ...both at once, built in parallel
66
68
 
67
69
  every build # package the macOS .app
68
70
  every build --ios # build the iOS shell
@@ -75,9 +77,41 @@ in place. For Rails it does the surgery for you; for Sinatra and Hanami it
75
77
  vendors the bridge and prints the remaining wiring rather than guessing at your
76
78
  boot files.
77
79
 
80
+ Working with an AI coding agent? `--agents` writes an `AGENTS.md` — the format
81
+ Claude Code, Cursor, Copilot and Codex all read — covering the view helpers
82
+ (`native_app?`, `everywhere_nav_button`, `everywhere_fab`, the menus and badges),
83
+ `config/everywhere.yml`, the bridge API, and the `every` commands, so an agent
84
+ reaches for the right helper instead of hand-rolling markup. It's framework- and
85
+ mode-aware, and adds a one-line `CLAUDE.md` pointer if you don't already have
86
+ one. If you already have an `AGENTS.md`, only the block between
87
+ `<!-- rubyeverywhere:begin -->` and `<!-- rubyeverywhere:end -->` is written, so
88
+ re-running refreshes our section and leaves your own notes alone.
89
+
78
90
  `every dev` never packages anything: it runs your ordinary dev server and points
79
91
  the native shells at it, so the edit-refresh loop is untouched Rails.
80
92
 
93
+ It keeps a dock pinned to the bottom of the terminal showing what each target is
94
+ doing, while the logs scroll normally above it — ordinary scrollback, so you can
95
+ still scroll up and search a backtrace:
96
+
97
+ ```
98
+ web │ Started GET "/notes" for 127.0.0.1
99
+ ios │ ▸ compiling Swift sources
100
+ ────────────────────────────────────────────────────────────────
101
+ ● web :3000 ○ desktop ⠙ iOS 42s ✗ Android see dist/android-build.log
102
+ ⌨ d desktop · i iOS · a Android · b browser · l logs · r restart · ? help · q quit
103
+ ```
104
+
105
+ Every target runs on its own thread, so builds happen in parallel and the keys
106
+ keep working while one is going. A build that fails marks that target red and
107
+ leaves everything else — the dev server included — running; fix the error and
108
+ press the key again. `r` restarts just the dev server, `?` expands the full key
109
+ list, and each line is tagged with the source that printed it once more than one
110
+ thing is talking.
111
+
112
+ Set `EVERY_NO_DOCK=1` (or pipe the output anywhere) to get plain, unadorned
113
+ lines instead.
114
+
81
115
  ## Configuration
82
116
 
83
117
  Everything per-app lives in `config/everywhere.yml`. The native shell templates
@@ -101,6 +135,13 @@ appearance:
101
135
  remote:
102
136
  url: https://app.example.com # remote mode: the deployed app to wrap
103
137
 
138
+ window: # desktop window chrome
139
+ title_bar: overlay # decorated (default) | overlay | frameless
140
+ title: false # draw the title text
141
+ size: [1100, 750]
142
+ min_size: [600, 400]
143
+ drag_height: 28 # top strip that drags the window; 0 to opt out
144
+
104
145
  tabs: # the native mobile tab bar
105
146
  - name: Builds
106
147
  path: /builds
@@ -117,6 +158,12 @@ rules: # Hotwire Native path configuration
117
158
  properties:
118
159
  context: modal
119
160
 
161
+ native: # code compiled into the native shells
162
+ desktop:
163
+ commands: [scan_ports] # Rust in native/desktop/, callable from the page
164
+ crates:
165
+ serialport: "4.3"
166
+
120
167
  build:
121
168
  ruby: "4.0.6"
122
169
  targets: [macos-arm64, ios-arm64, android-arm64]
@@ -134,6 +181,53 @@ Also supported: `auth:` (native OAuth sign-in with a callback scheme),
134
181
  `native/ios/` that `every build` compiles into the shell, including SwiftUI
135
182
  screens, bridge components, a custom splash, and pinned SPM packages.
136
183
 
184
+ Desktop has the same seam in `native/desktop/`, shaped for what a desktop app
185
+ actually wants: not native screens (the whole UI is a webview) but the machine
186
+ underneath it. Drop in Rust, pin the crates you need, and call it from the page:
187
+
188
+ ```yaml
189
+ native:
190
+ desktop:
191
+ commands: [scan_ports]
192
+ setup: true # run native/desktop/setup.rs at boot
193
+ crates:
194
+ serialport: "4.3"
195
+ ```
196
+
197
+ ```rust
198
+ // native/desktop/serial.rs
199
+ pub fn scan_ports(_app: &tauri::AppHandle, payload: serde_json::Value)
200
+ -> Result<serde_json::Value, String> { … }
201
+ ```
202
+
203
+ ```js
204
+ const ports = await Everywhere.desktop.invoke("scan_ports", { baud: 9600 })
205
+ ```
206
+
207
+ Declaring anything under `native.desktop` has a real cost the mobile sections
208
+ don't: the desktop shell stops being one prebuilt binary shared by every app and
209
+ gets compiled per app, so the first build takes minutes instead of seconds. Apps
210
+ that declare nothing are untouched.
211
+
212
+ Images the desktop shell itself draws live in `native/desktop/assets/`, using the
213
+ same `<name>[@2x|@3x][~dark].png` filenames the mobile assets folders use.
214
+ Name one `tray` (or `tray-template` for a macOS template image) and it becomes
215
+ the menu-bar icon; a custom `splash:` can reference the rest as
216
+ `assets/logo.png`. These are for the shell, not your pages — images your HTML
217
+ references should keep coming from the asset pipeline.
218
+
219
+ `window:` shapes the desktop frame. `title_bar: overlay` is the macOS look where
220
+ the traffic lights float over your page and there's no title text — pair it with
221
+ the `.everywhere-titlebar-inset` class from `everywhere/native.css`, which
222
+ reserves exactly the space they sit in. `frameless` removes the system chrome
223
+ altogether; the page then draws its own bar and drives the buttons through
224
+ `Everywhere.window`. Windows and Linux have no overlay style, so `overlay`
225
+ behaves as `frameless` there.
226
+
227
+ Either way the window stays draggable with no markup: the shell makes the top
228
+ 28px of the page drag it (and double-click zoom it), while buttons and links up
229
+ there keep their clicks. `window.drag_height: 0` hands that back to you.
230
+
137
231
  Tabs and path rules ship two ways from that one source: baked into the app
138
232
  bundle for offline/first launch, and served live from
139
233
  `/everywhere/{ios,android}_v1.json`, so changing them deploys with your web app.
@@ -159,6 +253,7 @@ Everywhere.badge.setTab("/inbox", 3)
159
253
  Everywhere.permissions.request("camera")
160
254
  Everywhere.storage.set("theme", "dark")
161
255
  Everywhere.updates.check()
256
+ Everywhere.window.close() // desktop, for a page-drawn title bar
162
257
  ```
163
258
 
164
259
  Everything degrades gracefully — the same page works in a plain browser tab and
@@ -185,9 +280,9 @@ Plus `native_app?`, `native_platform`, `everywhere_badge`,
185
280
 
186
281
  | Command | What it does |
187
282
  | --- | --- |
188
- | `every install` | Prepare an app (boot stub, `config/everywhere.yml`, config tweaks) |
283
+ | `every install` | Prepare an app (boot stub, `config/everywhere.yml`, config tweaks) — `--local`, `--agents` |
189
284
  | `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`) |
285
+ | `every dev` | Dev server with native shells on demand (`--desktop --ios --android --mobile --browser`) |
191
286
  | `every logs` | Stream shell logs from the Simulator or Android device |
192
287
  | `every build` | Package natively — desktop, `--ios`, or `--android` |
193
288
  | `every icon` | Generate `.icns` / `.ico` / Linux icons from a source PNG |
@@ -237,9 +332,10 @@ and complete on its own.
237
332
  lib/everywhere/ the CLI, config, builders, and Rails engine
238
333
  exe/every, exe/rbe the executables
239
334
  bridge/ @rubyeverywhere/bridge — the JS half
240
- support/shell/ the Tauri desktop shell template
335
+ support/desktop/ the Tauri desktop shell template
241
336
  support/mobile/ios/ the Hotwire Native iOS shell template
242
337
  support/mobile/android/ the Hotwire Native Android shell template
338
+ support/agents/ the AGENTS.md guide `every install --agents` writes
243
339
  ```
244
340
 
245
341
  The shell templates have their own READMEs documenting the freeze rules — what
data/bridge/README.md CHANGED
@@ -135,6 +135,75 @@ tap, and `<meta name="everywhere:badge">` / `<meta name="everywhere:tab-badge">`
135
135
  tags (the `everywhere_badge` / `everywhere_tab_badge` Rails helpers) are
136
136
  applied on every Turbo visit.
137
137
 
138
+ ### Desktop extensions
139
+
140
+ Rust the app keeps in `native/desktop/` and declares under
141
+ `native.desktop.commands` in `everywhere.yml`:
142
+
143
+ ```js
144
+ Everywhere.desktop.supported // true in the desktop shell
145
+
146
+ const ports = await Everywhere.desktop.invoke("scan_ports", { baud: 9600 })
147
+ ```
148
+
149
+ The payload arrives in Rust as a `serde_json::Value` and whatever the command
150
+ returns comes back as the resolved value; a command that returns `Err` rejects
151
+ with its own message.
152
+
153
+ Unlike the rest of the bridge this does **not** degrade — there's no web
154
+ equivalent for "talk to a serial port", so off the desktop it rejects rather
155
+ than resolving something untrue. Gate on `Everywhere.desktop.supported`.
156
+
157
+ Under the hood this is an event round-trip, not Tauri's `invoke`: a
158
+ RubyEverywhere page is always served over http, and Tauri v2 refuses `invoke`
159
+ from any http origin — including the packaged app's own localhost server.
160
+
161
+ ### Window controls
162
+
163
+ Desktop apps that draw their own title bar — `window.title_bar: overlay` or
164
+ `frameless` in `everywhere.yml` — drive the window from the page:
165
+
166
+ ```js
167
+ Everywhere.window.supported // true in the desktop shell
168
+
169
+ Everywhere.window.minimize()
170
+ Everywhere.window.maximize()
171
+ Everywhere.window.unmaximize()
172
+ Everywhere.window.toggleMaximize() // what a title-bar double-click does
173
+ Everywhere.window.close()
174
+ await Everywhere.window.isMaximized() // Promise<boolean>
175
+ Everywhere.window.startDragging() // from a pointerdown on a custom bar
176
+ ```
177
+
178
+ `overlay` keeps the macOS traffic lights and only hides the title, so the page
179
+ just needs to stay clear of them; `frameless` removes every control, so the page
180
+ supplies its own.
181
+
182
+ **Dragging works out of the box.** With no system title bar left to grab, the
183
+ shell makes the top 28px of the page drag the window, and double-click there
184
+ zooms it — no markup required. Buttons, links and inputs in that strip keep
185
+ their clicks:
186
+
187
+ ```html
188
+ <header class="everywhere-titlebar">
189
+ <span>My App</span>
190
+ <button onclick="Everywhere.window.close()">×</button>
191
+ </header>
192
+ ```
193
+
194
+ To make something *outside* the strip draggable, mark it
195
+ `data-tauri-drag-region`. To own dragging yourself, set `window.drag_height: 0`
196
+ in `everywhere.yml` and call `startDragging()` from your own handlers.
197
+
198
+ `everywhere/native.css` ships `.everywhere-titlebar` (a drag strip that clears
199
+ the traffic lights in overlay mode) plus `.everywhere-titlebar-inset` and
200
+ `.everywhere-titlebar-controls-inset` padding utilities. They key off an
201
+ attribute the bridge stamps on `<html>`, so they're inert in a browser tab, on
202
+ mobile, and in a normal decorated window.
203
+
204
+ Everything here resolves to `null` off the desktop, so the same markup is safe
205
+ everywhere.
206
+
138
207
  ### Auto-updates
139
208
 
140
209
  Inside the desktop shell, apps with an update feed configured can check,
@@ -164,7 +233,7 @@ In a plain browser tab the updates API is inert: `check()` resolves
164
233
  ## Platforms
165
234
 
166
235
  - **desktop** — the Tauri-based RubyEverywhere shell. Notifications, dialogs,
167
- clipboard, shell events, and auto-updates are native.
236
+ clipboard, shell events, window controls, and auto-updates are native.
168
237
  - **browser** — honest web fallbacks: the Notification API, `window.confirm`,
169
238
  `navigator.clipboard`, DOM CustomEvents.
170
239
  - **mobile** — Hotwire Native (iOS shell). `notify()` delivers a native local
@@ -178,6 +178,19 @@ function detectPlatform() {
178
178
 
179
179
  // --- desktop: the Tauri-based RubyEverywhere shell -------------------------
180
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
+
181
194
  const desktop = {
182
195
  notify({ title, body }) {
183
196
  return window.__TAURI__.event.emit("everywhere:notify", { title, body })
@@ -210,6 +223,56 @@ const desktop = {
210
223
  return window.__TAURI__.clipboardManager.readText()
211
224
  },
212
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
+
213
276
  // Ask the shell to check its update feed; resolve off whichever update
214
277
  // event answers first. The shell always replies to an explicit check
215
278
  // (available / none / error), the timeout is just a safety net.
@@ -295,6 +358,22 @@ const browser = {
295
358
  return null
296
359
  },
297
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
+
298
377
  // Honest approximation: the Vibration API where it exists (Android
299
378
  // browsers); silently nothing elsewhere (iOS Safari has no equivalent).
300
379
  haptic(kind, value) {
@@ -764,6 +843,17 @@ const adapter = adapters[platform]
764
843
  // registered by the time app code first calls notify().
765
844
  if (platform === "mobile") installWebBridge()
766
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
+
767
857
  // Mutable so setChannel can keep updates.channel truthful without a reload.
768
858
  let updatesChannel = (config.updates && config.updates.channel) || null
769
859
 
@@ -1034,6 +1124,67 @@ export const Everywhere = {
1034
1124
  }
1035
1125
  },
1036
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
+
1037
1188
  updates: {
1038
1189
  // True only when the shell actually has a feed to check (everywhere.yml
1039
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,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "paths"
4
+ require_relative "ui"
5
+
6
+ module Everywhere
7
+ # The AGENTS.md guide `every install --agents` writes into an app, so AI coding
8
+ # agents (Claude Code, Cursor, Copilot, Codex) understand what RubyEverywhere is
9
+ # before they "fix" the config edits install made.
10
+ #
11
+ # The guide is prose, so it lives as markdown under support/agents/ rather than
12
+ # in a heredoc — code fences and #{} snippets are miserable to escape in Ruby,
13
+ # and the gemspec already globs support/**/*. Assembly is a shared body plus
14
+ # per-framework fragments (install notes, view helpers) and a mode fragment,
15
+ # joined by {{PLACEHOLDER}} gsub.
16
+ module AgentsGuide
17
+ module_function
18
+
19
+ # The generated section is fenced so a re-run can refresh it without touching
20
+ # anything the app's own authors wrote around it.
21
+ BEGIN_MARKER = "<!-- rubyeverywhere:begin -->"
22
+ END_MARKER = "<!-- rubyeverywhere:end -->"
23
+
24
+ PLACEHOLDER = /\{\{([A-Z_]+)\}\}/
25
+ LABELS = { "rails" => "Rails", "sinatra" => "Sinatra", "hanami" => "Hanami" }.freeze
26
+
27
+ def template_dir = File.join(Paths.gem_root, "support", "agents")
28
+
29
+ # The guide body for one app, with no markers around it.
30
+ def render(framework:, mode:)
31
+ substitute(read("AGENTS.md"),
32
+ "FRAMEWORK" => LABELS.fetch(framework),
33
+ "MODE" => mode,
34
+ "FRAMEWORK_NOTES" => read("frameworks", "#{framework}.md").strip,
35
+ "FRAMEWORK_VIEWS" => read("frameworks", "#{framework}-views.md").strip,
36
+ "MODE_NOTES" => read("modes", "#{mode}.md").strip)
37
+ end
38
+
39
+ # The body wrapped in the markers, ready to splice into an AGENTS.md.
40
+ def block(framework:, mode:)
41
+ "#{BEGIN_MARKER}\n#{render(framework: framework, mode: mode).strip}\n#{END_MARKER}\n"
42
+ end
43
+
44
+ # fetch, not [], so a typo'd placeholder blows up in the test suite instead of
45
+ # shipping "{{MDOE}}" to somebody's repo.
46
+ def substitute(body, values)
47
+ body.gsub(PLACEHOLDER) { values.fetch(Regexp.last_match(1)) }
48
+ end
49
+
50
+ def read(*parts)
51
+ path = File.join(template_dir, *parts)
52
+ File.exist?(path) or UI.die!("couldn't find the bundled AGENTS.md template at #{path}; " \
53
+ "reinstall ruby_everywhere")
54
+ File.read(path)
55
+ end
56
+ end
57
+ end
@@ -151,8 +151,14 @@ module Everywhere
151
151
  # A provider path requested from inside the shell's web view — the visits
152
152
  # the shell couldn't intercept natively (a `data-turbo="false"` link, or the
153
153
  # POST that OmniAuth 2 requires). Bounce to a page that can ask it to.
154
+ #
155
+ # mobile_platform_of, not native_platform_of: the whole handoff exists
156
+ # because a phone shell must open the provider in ASWebAuthenticationSession
157
+ # / Custom Tabs. The desktop shell already sends external links to the system
158
+ # browser, so diverting there would bounce sign-in to a page nothing can
159
+ # complete.
154
160
  def divert(env, config)
155
- return nil unless Everywhere.native_platform_of(env["HTTP_USER_AGENT"])
161
+ return nil unless Everywhere.mobile_platform_of(env["HTTP_USER_AGENT"])
156
162
  return nil unless config.oauth_path?(env["PATH_INFO"])
157
163
 
158
164
  redirect("#{NATIVE}?#{Rack::Utils.build_query("to" => path_with_query(env))}")
@@ -172,7 +178,7 @@ module Everywhere
172
178
  # Only ever the auth browser: inside the web view the app is already
173
179
  # holding the jar we would be handing it, and a stale marker there must
174
180
  # not divert an ordinary visit into a callback URL it can't open.
175
- marker = auth_marker(env) unless Everywhere.native_platform_of(env["HTTP_USER_AGENT"])
181
+ marker = auth_marker(env) unless Everywhere.mobile_platform_of(env["HTTP_USER_AGENT"])
176
182
  return @app.call(env) if marker.nil? || config.oauth_path?(env["PATH_INFO"]) || !navigation?(env)
177
183
 
178
184
  _, headers, body = @app.call(env)