ruby_everywhere 0.1.8 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b3a05ca6c42372f257d9fdcbd1ff0e7b2becbceb64e533e671ab5b9b222f7fff
4
- data.tar.gz: 6905e9cc0e07e0cf221d0a9219b9ddcdbe43ddfee67d844d5d7d2824f92291c6
3
+ metadata.gz: 6e02deeb4f95a031a3a44bae0e87dbaece25f95a919ff817927e9eb5b611ffbb
4
+ data.tar.gz: db779e7819a864517b0a44a388c56b835eb724a2d697af653857936cab505ac1
5
5
  SHA512:
6
- metadata.gz: 755a57a26107f6f6f95d0ef30b14b9f83d67d172ac4aba0fbff629326e4db013b6aa094d16b558b9e84b147d8faff6e93a524df53b0976e5282e156026a87cd1
7
- data.tar.gz: 1ab5013257d9d4716c8ea27a23ada758474be29dae9cb34d204925dde1cd82fe3046faf10e92cd18242507c6cb7c911bdc3bea9d2dd358507389a4d3584dcf18
6
+ metadata.gz: a1567ed4df49616f1c05fe481812a62bc9f3eb64e816e4702756a0614e282027016a4e76ad62a3097fced1bde908a22fdba8ae6b6b6a46aa24cdc227bb8bf32e
7
+ data.tar.gz: ed2ecc0ff275c958291986eb8ea23b0e9fde277b62dcffc614c61dd84f2d6c4c626e8c93d07a8563b4755cb036f56f4a0107e00cc6c46280c6e61c551e259c1e
data/bridge/README.md ADDED
@@ -0,0 +1,77 @@
1
+ # @rubyeverywhere/bridge
2
+
3
+ One API for the browser, the RubyEverywhere desktop shell, and (soon) Hotwire
4
+ Native mobile apps — the JavaScript half of [RubyEverywhere](https://rubyeverywhere.com).
5
+
6
+ App code writes to this surface only; platform differences live in adapters
7
+ inside the package. Everything degrades gracefully: the same page works in a
8
+ plain browser tab and gains native powers inside a RubyEverywhere app.
9
+
10
+ ## Install
11
+
12
+ ```sh
13
+ npm install @rubyeverywhere/bridge
14
+ ```
15
+
16
+ Using RubyEverywhere? You don't need npm at all: the `ruby_everywhere` gem
17
+ ships this exact package. Rails apps get it served and importmap-pinned by the
18
+ gem's engine (updates arrive with `bundle update ruby_everywhere`); Sinatra and
19
+ Hanami apps get it vendored to `public/bridge.js` by `every install`.
20
+
21
+ ## Usage
22
+
23
+ ```js
24
+ import Everywhere from "@rubyeverywhere/bridge"
25
+
26
+ Everywhere.platform // "desktop" | "mobile" | "browser"
27
+ Everywhere.os // "macos" | "windows" | "linux" | "ios" | "android" | "chromeos" | "unknown"
28
+ Everywhere.native // true inside a RubyEverywhere app
29
+ Everywhere.version // app version; null in a plain browser tab
30
+
31
+ Everywhere.notify({ title, body }) // native notification / web Notification / console
32
+ Everywhere.confirm("Sure?") // Promise<boolean>, native dialog when possible
33
+ Everywhere.on("menu", handler) // shell events; returns an unsubscribe fn
34
+ Everywhere.visit("/settings") // Turbo.visit with location fallback
35
+
36
+ Everywhere.clipboard.write(text) // Promise<void>
37
+ Everywhere.clipboard.read() // Promise<string | null>
38
+ ```
39
+
40
+ ### Auto-updates
41
+
42
+ Inside the desktop shell, apps with an update feed configured can check,
43
+ install, and switch channels at runtime:
44
+
45
+ ```js
46
+ Everywhere.updates.supported // true when the shell has an update feed
47
+ Everywhere.updates.channel // effective channel ("stable", "beta", …)
48
+
49
+ const result = await Everywhere.updates.check()
50
+ // { available: true, version, notes, notesHtml } or { available: false }
51
+
52
+ Everywhere.updates.install() // download / verify / swap / relaunch
53
+ Everywhere.updates.setChannel("beta") // Promise<{ channel }>, persisted by the shell
54
+
55
+ Everywhere.updates.on("available", handler)
56
+ // events: "available" | "none" | "progress" | "ready" | "error" | "channel"
57
+ ```
58
+
59
+ `notes` is the release-notes markdown source; `notesHtml` is the same notes
60
+ pre-rendered to HTML from your signed update feed, ready for a changelog
61
+ modal: `el.innerHTML = notesHtml`.
62
+
63
+ In a plain browser tab the updates API is inert: `check()` resolves
64
+ `{ available: false, unsupported: true }` and nothing throws.
65
+
66
+ ## Platforms
67
+
68
+ - **desktop** — the Tauri-based RubyEverywhere shell. Notifications, dialogs,
69
+ clipboard, shell events, and auto-updates are native.
70
+ - **browser** — honest web fallbacks: the Notification API, `window.confirm`,
71
+ `navigator.clipboard`, DOM CustomEvents.
72
+ - **mobile** — Hotwire Native detection is live; behavior currently falls back
73
+ to the browser adapter until the bridge-component adapter lands.
74
+
75
+ ## License
76
+
77
+ MIT
@@ -17,9 +17,11 @@
17
17
  // Everywhere.visit("/settings") // Turbo.visit with location fallback
18
18
  //
19
19
  // Everywhere.updates.supported // true when the shell has an update feed
20
+ // Everywhere.updates.channel // effective channel ("stable", "beta", …)
20
21
  // Everywhere.updates.check() // Promise<{available, version?, notes?, notesHtml?}>
21
22
  // Everywhere.updates.install() // download/verify/swap/relaunch
22
- // Everywhere.updates.on("available" | "none" | "progress" | "ready" | "error", handler)
23
+ // Everywhere.updates.setChannel("beta")// Promise<{channel}>, persisted by the shell
24
+ // Everywhere.updates.on("available" | "none" | "progress" | "ready" | "error" | "channel", handler)
23
25
  //
24
26
  // notes is the markdown source; notesHtml is the same notes pre-rendered to
25
27
  // HTML (from the signed update feed — your own content), ready for a
@@ -110,6 +112,25 @@ const desktop = {
110
112
 
111
113
  updatesInstall() {
112
114
  return window.__TAURI__.event.emit("everywhere:update-install", {})
115
+ },
116
+
117
+ // Ask the shell to switch update channels. The shell validates, persists the
118
+ // choice (it survives relaunches and overrides everywhere.yml), and always
119
+ // answers: update-channel on success, update-error on rejection.
120
+ updatesSetChannel(channel) {
121
+ return new Promise((resolve, reject) => {
122
+ const offs = []
123
+ let timer = null
124
+ const settle = (fn) => (payload) => {
125
+ offs.forEach((off) => off())
126
+ clearTimeout(timer)
127
+ fn(payload)
128
+ }
129
+ offs.push(this.on("update-channel", settle((p) => resolve({ channel: p.channel }))))
130
+ offs.push(this.on("update-error", settle((p) => reject(new Error(p.message)))))
131
+ timer = setTimeout(settle(() => reject(new Error("channel change timed out"))), 10000)
132
+ window.__TAURI__.event.emit("everywhere:update-set-channel", { channel })
133
+ })
113
134
  }
114
135
  }
115
136
 
@@ -160,6 +181,11 @@ const browser = {
160
181
  updatesInstall() {
161
182
  console.log("[everywhere] updates unavailable in the browser")
162
183
  return Promise.resolve()
184
+ },
185
+
186
+ updatesSetChannel() {
187
+ console.log("[everywhere] update channel unavailable in the browser")
188
+ return Promise.resolve({ channel: null, unsupported: true })
163
189
  }
164
190
  }
165
191
 
@@ -182,6 +208,9 @@ const platform = detectPlatform()
182
208
  const os = detectOS()
183
209
  const adapter = adapters[platform]
184
210
 
211
+ // Mutable so setChannel can keep updates.channel truthful without a reload.
212
+ let updatesChannel = (config.updates && config.updates.channel) || null
213
+
185
214
  export const Everywhere = {
186
215
  platform,
187
216
  os,
@@ -228,7 +257,13 @@ export const Everywhere = {
228
257
  return platform === "desktop" && !!config.updates
229
258
  },
230
259
  version: config.version || null,
231
- channel: (config.updates && config.updates.channel) || null,
260
+
261
+ // The channel the shell is actually checking. The shell injects the
262
+ // effective value (a persisted user choice overrides everywhere.yml), and
263
+ // setChannel keeps it current without a reload.
264
+ get channel() {
265
+ return updatesChannel
266
+ },
232
267
 
233
268
  check() {
234
269
  return adapter.updatesCheck()
@@ -240,6 +275,15 @@ export const Everywhere = {
240
275
  return adapter.updatesInstall()
241
276
  },
242
277
 
278
+ // Switch the update feed channel (e.g. "stable" -> "beta"). Resolves once
279
+ // the shell has persisted it; follow with check() to scan the new channel.
280
+ setChannel(channel) {
281
+ return adapter.updatesSetChannel(channel).then((result) => {
282
+ if (result && result.channel) updatesChannel = result.channel
283
+ return result
284
+ })
285
+ },
286
+
243
287
  on(event, handler) {
244
288
  return adapter.on(`update-${event}`, handler)
245
289
  }
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "@rubyeverywhere/bridge",
3
+ "version": "0.3.0",
4
+ "description": "One API for browser, desktop, and mobile — the JS half of RubyEverywhere",
5
+ "type": "module",
6
+ "main": "everywhere/bridge.js",
7
+ "exports": { ".": "./everywhere/bridge.js" },
8
+ "files": ["everywhere/bridge.js", "README.md"],
9
+ "sideEffects": false,
10
+ "keywords": ["rubyeverywhere", "tauri", "hotwire", "turbo", "rails", "desktop", "native", "bridge"],
11
+ "homepage": "https://rubyeverywhere.com",
12
+ "license": "MIT",
13
+ "publishConfig": { "access": "public" }
14
+ }
@@ -0,0 +1,3 @@
1
+ # Pin drawn into apps by Everywhere::Engine (before the app's own importmap,
2
+ # which can therefore override it).
3
+ pin "@rubyeverywhere/bridge", to: "everywhere/bridge.js"
@@ -66,7 +66,7 @@ module Everywhere
66
66
  # Rack frameworks vary too much to auto-edit their boot files safely, so we
67
67
  # vendor the bridge and print the remaining wiring rather than guess.
68
68
  def vendor_bridge_to_public
69
- source = File.expand_path("../javascript/bridge.js", __dir__)
69
+ source = File.expand_path("../../../bridge/everywhere/bridge.js", __dir__)
70
70
  target_dir = app_file("public")
71
71
  FileUtils.mkdir_p(target_dir)
72
72
  target = File.join(target_dir, "bridge.js")
@@ -219,32 +219,36 @@ module Everywhere
219
219
  change("disable force_ssl for localhost", made)
220
220
  end
221
221
 
222
- # Vendor @rubyeverywhere/bridge (one JS API across browser/desktop/mobile)
223
- # and pin it for importmap. Re-running `every install` after a gem update
224
- # refreshes the vendored copy.
222
+ # @rubyeverywhere/bridge (one JS API across browser/desktop/mobile) is
223
+ # served and pinned by Everywhere::Engine straight from the gem, so the
224
+ # bridge updates with `bundle update ruby_everywhere`. Nothing to vendor —
225
+ # just migrate apps installed before the engine existed (their vendored
226
+ # copy and pin would shadow the engine's, freezing them on an old bridge)
227
+ # and wire the import.
225
228
  def install_bridge
226
- source = File.expand_path("../javascript/bridge.js", __dir__)
227
- target_dir = app_file("vendor", "javascript")
228
- target = File.join(target_dir, "@rubyeverywhere--bridge.js")
229
- FileUtils.mkdir_p(target_dir)
230
- # Stamp a machine-readable version marker on the vendored copy so the
231
- # build receipt (Receipt#bridge_version) can record which bridge shipped.
232
- # Comparing against the fully-stamped content keeps re-runs idempotent.
233
- desired = "// @rubyeverywhere/bridge version: #{Everywhere::BRIDGE_VERSION} (vendored by `every install`)\n#{File.read(source)}"
234
- fresh = !File.exist?(target) || File.read(target) != desired
235
- File.write(target, desired) if fresh
229
+ fresh = remove_vendored_bridge
236
230
 
237
231
  importmap = app_file("config", "importmap.rb")
238
232
  if File.exist?(importmap)
239
233
  contents = File.read(importmap)
240
- unless contents.include?("@rubyeverywhere/bridge")
241
- File.write(importmap, contents.chomp + "\npin \"@rubyeverywhere/bridge\", to: \"@rubyeverywhere--bridge.js\" # vendored by `every install`\n")
234
+ stale_pin = /^pin ["']@rubyeverywhere\/bridge["'],\s*to:\s*["']@rubyeverywhere--bridge\.js["'].*\n/
235
+ if contents.match?(stale_pin)
236
+ File.write(importmap, contents.gsub(stale_pin, ""))
242
237
  fresh = true
243
238
  end
244
239
  end
245
240
  wire_application_js(fresh)
246
241
  end
247
242
 
243
+ def remove_vendored_bridge
244
+ target = app_file("vendor", "javascript", "@rubyeverywhere--bridge.js")
245
+ return false unless File.exist?(target)
246
+
247
+ FileUtils.rm(target)
248
+ change("remove vendored bridge (now served by the ruby_everywhere engine)", true)
249
+ true
250
+ end
251
+
248
252
  def wire_application_js(fresh)
249
253
  app_js = app_file("app", "javascript", "application.js")
250
254
  if File.exist?(app_js)
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Everywhere
4
+ # Rails integration: serves @rubyeverywhere/bridge straight from the gem's
5
+ # bridge/ directory (the same files published to npm — single source) and
6
+ # contributes the importmap pin, so apps get bridge updates with
7
+ # `bundle update ruby_everywhere` — no vendored copy to refresh. Loaded only
8
+ # when Rails is present; Sinatra/Hanami apps keep the vendored
9
+ # public/bridge.js from `every install`.
10
+ class Engine < ::Rails::Engine
11
+ # bridge/ becomes an asset root, so bridge/everywhere/bridge.js gets the
12
+ # namespaced logical path "everywhere/bridge.js" (Propshaft and Sprockets
13
+ # both read config.assets.paths); Sprockets additionally needs the file
14
+ # declared precompilable.
15
+ initializer "everywhere.assets" do |app|
16
+ if app.config.respond_to?(:assets)
17
+ app.config.assets.paths << Engine.root.join("bridge").to_s
18
+ app.config.assets.precompile += %w[everywhere/bridge.js] if app.config.assets.respond_to?(:precompile)
19
+ end
20
+ end
21
+
22
+ # Runs before importmap-rails adds the app's config/importmap.rb, so an
23
+ # app-level pin (e.g. a legacy vendored copy) still overrides ours.
24
+ initializer "everywhere.importmap", before: "importmap" do |app|
25
+ if app.config.respond_to?(:importmap)
26
+ app.config.importmap.paths << Engine.root.join("config/importmap.rb")
27
+ end
28
+ end
29
+ end
30
+ end
@@ -116,10 +116,18 @@ module Everywhere
116
116
  File.join(@root, "vendor", "javascript", "@rubyeverywhere", "bridge", "package.json")
117
117
  ].each { |p| (v = json_version(p)) and return v }
118
118
 
119
- # Single-file vendored install: read a `// version: x.y.z` header marker
120
- # if `every install` stamped one (best-effort until it does).
119
+ # Legacy single-file vendored install: read the `// version: x.y.z`
120
+ # marker `every install` stamped on the copy.
121
121
  flat = File.join(@root, "vendor", "javascript", "@rubyeverywhere--bridge.js")
122
- File.exist?(flat) ? File.foreach(flat).first(6).join[/version:\s*([\d.]+)/i, 1] : nil
122
+ return File.foreach(flat).first(6).join[/version:\s*([\d.]+)/i, 1] if File.exist?(flat)
123
+
124
+ # Engine-served (Rails apps bundling ruby_everywhere): the bridge ships
125
+ # inside the gem, so it carries the gem's BRIDGE_VERSION. Best-effort —
126
+ # assumes the app bundles the same gem release that's running this build.
127
+ lock = File.join(@root, "Gemfile.lock")
128
+ return Everywhere::BRIDGE_VERSION if File.exist?(lock) && File.read(lock).match?(/^\s+ruby_everywhere\s/)
129
+
130
+ nil
123
131
  end
124
132
 
125
133
  def shell_version
@@ -1,11 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Everywhere
4
- VERSION = "0.1.8"
4
+ VERSION = "0.1.10"
5
5
 
6
- # Version of the vendored @rubyeverywhere/bridge JS this gem ships. Tracks
7
- # bridge/package.json bump it whenever lib/everywhere/javascript/bridge.js is
8
- # refreshed. `every install` stamps it into the vendored file so the build
9
- # receipt can record which bridge shipped; it versions independently of the CLI.
10
- BRIDGE_VERSION = "0.2.0"
6
+ # Version of the @rubyeverywhere/bridge JS this gem ships. bridge/ in the
7
+ # gem IS the npm package (served to Rails apps by Everywhere::Engine,
8
+ # vendored to public/ for Sinatra/Hanami), so its package.json is the single
9
+ # source of truth. The build receipt records it; it versions independently
10
+ # of the CLI.
11
+ BRIDGE_VERSION = File.read(
12
+ File.expand_path("../../bridge/package.json", __dir__)
13
+ )[/"version":\s*"([^"]+)"/, 1]
11
14
  end
data/lib/everywhere.rb CHANGED
@@ -7,6 +7,9 @@ require_relative "everywhere/config"
7
7
  require_relative "everywhere/framework"
8
8
  require_relative "everywhere/database"
9
9
  require_relative "everywhere/boot"
10
+ # Rails apps get the bridge served + pinned by the engine (Sinatra/Hanami
11
+ # vendor it to public/ instead — see `every install`).
12
+ require_relative "everywhere/engine" if defined?(::Rails::Engine)
10
13
 
11
14
  module Everywhere
12
15
  class Error < StandardError; end
@@ -143,6 +143,16 @@ fn main() {
143
143
  .plugin(tauri_plugin_clipboard_manager::init())
144
144
  .invoke_handler(tauri::generate_handler![notify_command])
145
145
  .setup(move |app| {
146
+ // A persisted user channel choice (Everywhere.updates.setChannel)
147
+ // overrides everywhere.yml's updates.channel. Fold it in before
148
+ // anything reads raw_json — the updater, the menu, and the
149
+ // __EVERYWHERE_CONFIG__ page injection all see the same channel.
150
+ let mut config = config;
151
+ if let Ok(mut value) = serde_json::from_str::<serde_json::Value>(&config.raw_json) {
152
+ updater::apply_channel_override(app.handle(), &mut value);
153
+ config.raw_json = value.to_string();
154
+ }
155
+
146
156
  // Bridge channel: pages emit events (the sanctioned webview->shell
147
157
  // path for remote-origin content in Tauri v2; invoke is reserved
148
158
  // for local content). Fire-and-forget, Strada-style.
@@ -10,10 +10,16 @@
10
10
  // codesign --verify --deep --strict -> Info.plist bundle_id + version match.
11
11
  //
12
12
  // Page JS drives it through bridge events (everywhere:update-check /
13
- // update-install in; update-available / -none / -progress / -ready / -error
14
- // out); the "Check for Updates…" menu item drives the same flows with native
15
- // dialogs. `updates.auto` picks the background behaviour: off | check |
16
- // download | install (silent swap staged in RAM-free renames on quit).
13
+ // update-install / update-set-channel in; update-available / -none /
14
+ // -progress / -ready / -error / -channel out); the "Check for Updates…" menu
15
+ // item drives the same flows with native dialogs. `updates.auto` picks the
16
+ // background behaviour: off | check | download | install (silent swap staged
17
+ // in RAM-free renames on quit).
18
+ //
19
+ // Channel: everywhere.yml's updates.channel is the default; a user choice made
20
+ // through the bridge (Everywhere.updates.setChannel) is persisted to the app
21
+ // data dir and overrides it from then on — the server-driven settings page
22
+ // decides who gets to see a "beta" option, the shell just honors the pick.
17
23
 
18
24
  use std::path::{Path, PathBuf};
19
25
  use std::process::Command;
@@ -61,10 +67,20 @@ struct Pending {
61
67
 
62
68
  pub struct UpdaterState {
63
69
  cfg: UpdateConfig,
70
+ // The live channel. Starts as cfg.channel (which already reflects a
71
+ // persisted override — see apply_channel_override) and moves when the page
72
+ // calls setChannel.
73
+ channel: Mutex<String>,
64
74
  pending: Mutex<Option<Pending>>,
65
75
  busy: AtomicBool,
66
76
  }
67
77
 
78
+ impl UpdaterState {
79
+ fn channel(&self) -> String {
80
+ self.channel.lock().unwrap().clone()
81
+ }
82
+ }
83
+
68
84
  impl UpdateConfig {
69
85
  // The shell subset of everywhere.yml's updates: section, from the same
70
86
  // raw config JSON the rest of the shell reads. None => updater disabled.
@@ -107,13 +123,31 @@ pub fn enabled(raw_json: &str) -> bool {
107
123
  UpdateConfig::parse(raw_json).is_some()
108
124
  }
109
125
 
126
+ // Fold the persisted user channel choice (if any) into the raw config value.
127
+ // main.rs calls this before the config is parsed OR injected into pages, so
128
+ // the updater, the menu, and __EVERYWHERE_CONFIG__.updates.channel all agree
129
+ // on the effective channel.
130
+ pub fn apply_channel_override(handle: &tauri::AppHandle, value: &mut serde_json::Value) {
131
+ if !value["updates"].is_object() {
132
+ return;
133
+ }
134
+ let bundle_id = value["bundle_id"].as_str().unwrap_or("com.rubyeverywhere.app").to_string();
135
+ let Some(path) = channel_file(handle, &bundle_id) else { return };
136
+ let Ok(saved) = std::fs::read_to_string(&path) else { return };
137
+ let saved = saved.trim();
138
+ if valid_channel(saved) {
139
+ value["updates"]["channel"] = serde_json::json!(saved);
140
+ }
141
+ }
142
+
110
143
  pub fn init(app: &tauri::App, raw_json: &str) {
111
144
  let Some(cfg) = UpdateConfig::parse(raw_json) else {
112
145
  return;
113
146
  };
114
147
  let auto = cfg.auto;
115
148
  let interval = cfg.interval;
116
- app.manage(UpdaterState { cfg, pending: Mutex::new(None), busy: AtomicBool::new(false) });
149
+ let channel = Mutex::new(cfg.channel.clone());
150
+ app.manage(UpdaterState { cfg, channel, pending: Mutex::new(None), busy: AtomicBool::new(false) });
117
151
 
118
152
  {
119
153
  let handle = app.handle().clone();
@@ -140,6 +174,14 @@ pub fn init(app: &tauri::App, raw_json: &str) {
140
174
  });
141
175
  });
142
176
  }
177
+ {
178
+ let handle = app.handle().clone();
179
+ app.listen_any("everywhere:update-set-channel", move |event| {
180
+ let payload = event.payload().to_string();
181
+ let handle = handle.clone();
182
+ std::thread::spawn(move || set_channel(&handle, &payload));
183
+ });
184
+ }
143
185
 
144
186
  if auto != Auto::Off {
145
187
  let handle = app.handle().clone();
@@ -156,13 +198,53 @@ pub fn init(app: &tauri::App, raw_json: &str) {
156
198
 
157
199
  // ---- flows ------------------------------------------------------------------
158
200
 
201
+ // Bridge-triggered channel switch (Everywhere.updates.setChannel). Validates,
202
+ // persists the choice so it survives relaunches, retargets the running
203
+ // updater, and always answers: update-channel on success, update-error on a
204
+ // bad request. A staged download from the old channel is discarded — it no
205
+ // longer represents what the user asked to run.
206
+ fn set_channel(handle: &tauri::AppHandle, payload: &str) {
207
+ let Some(state) = state(handle) else { return };
208
+ let requested = serde_json::from_str::<serde_json::Value>(payload)
209
+ .ok()
210
+ .and_then(|v| v["channel"].as_str().map(str::to_string));
211
+ let Some(channel) = requested.filter(|c| valid_channel(c)) else {
212
+ emit_error(handle, "invalid update channel (letters, digits, . _ - only)");
213
+ return;
214
+ };
215
+
216
+ if let Some(path) = channel_file(handle, &state.cfg.bundle_id) {
217
+ let write = path
218
+ .parent()
219
+ .map(|dir| std::fs::create_dir_all(dir).map_err(|e| e.to_string()))
220
+ .unwrap_or(Ok(()))
221
+ .and_then(|()| std::fs::write(&path, &channel).map_err(|e| e.to_string()));
222
+ if let Err(e) = write {
223
+ emit_error(handle, &format!("could not save channel choice: {e}"));
224
+ return;
225
+ }
226
+ }
227
+
228
+ let changed = {
229
+ let mut current = state.channel.lock().unwrap();
230
+ let changed = *current != channel;
231
+ *current = channel.clone();
232
+ changed
233
+ };
234
+ if changed {
235
+ *state.pending.lock().unwrap() = None;
236
+ }
237
+ println!("[updater] channel set to {channel}");
238
+ let _ = handle.emit_to("main", "everywhere:update-channel", serde_json::json!({ "channel": channel }));
239
+ }
240
+
159
241
  // Bridge-triggered check: always answers with an event (available/none/error).
160
242
  fn explicit_check(handle: &tauri::AppHandle) {
161
243
  let Some(state) = state(handle) else { return };
162
244
  if state.busy.swap(true, Ordering::SeqCst) {
163
245
  return;
164
246
  }
165
- match check(&state.cfg) {
247
+ match check(&state.cfg, &state.channel()) {
166
248
  Ok(Some(m)) => emit_available(handle, &m),
167
249
  Ok(None) => {
168
250
  let _ = handle.emit_to("main", "everywhere:update-none",
@@ -181,7 +263,7 @@ pub fn interactive_check(handle: &tauri::AppHandle) {
181
263
  if state.busy.swap(true, Ordering::SeqCst) {
182
264
  return;
183
265
  }
184
- let outcome = check(&state.cfg);
266
+ let outcome = check(&state.cfg, &state.channel());
185
267
  state.busy.store(false, Ordering::SeqCst);
186
268
 
187
269
  match outcome {
@@ -225,7 +307,7 @@ fn background_pass(handle: &tauri::AppHandle, auto: Auto) {
225
307
  return;
226
308
  }
227
309
  let result = (|| -> Result<(), String> {
228
- let Some(m) = check(&state.cfg)? else { return Ok(()) };
310
+ let Some(m) = check(&state.cfg, &state.channel())? else { return Ok(()) };
229
311
  emit_available(handle, &m);
230
312
  if auto >= Auto::Download && !is_staged(&state, &m) {
231
313
  let staged = download_and_stage(handle, &state.cfg, &m)?;
@@ -252,7 +334,7 @@ fn install_flow(handle: &tauri::AppHandle) -> Result<(), String> {
252
334
  let pending = match pending {
253
335
  Some(p) => p,
254
336
  None => {
255
- let m = check(&state.cfg)?.ok_or("already up to date")?;
337
+ let m = check(&state.cfg, &state.channel())?.ok_or("already up to date")?;
256
338
  emit_available(handle, &m);
257
339
  let staged = download_and_stage(handle, &state.cfg, &m)?;
258
340
  Pending { manifest: m, extracted_app: staged }
@@ -284,10 +366,10 @@ pub fn install_pending_on_exit(app: &tauri::AppHandle) {
284
366
 
285
367
  // ---- check ------------------------------------------------------------------
286
368
 
287
- fn check(cfg: &UpdateConfig) -> Result<Option<Manifest>, String> {
369
+ fn check(cfg: &UpdateConfig, channel: &str) -> Result<Option<Manifest>, String> {
288
370
  let url = format!(
289
371
  "{}/{}/{}/{}/latest.json?current={}",
290
- cfg.url, cfg.channel, os_name(), arch_name(), cfg.current
372
+ cfg.url, channel, os_name(), arch_name(), cfg.current
291
373
  );
292
374
  println!("[updater] checking {url}");
293
375
 
@@ -580,6 +662,19 @@ fn state(handle: &tauri::AppHandle) -> Option<tauri::State<'_, UpdaterState>> {
580
662
  handle.try_state::<UpdaterState>()
581
663
  }
582
664
 
665
+ // Where the user's channel choice lives: <data dir>/<bundle_id>/update-channel,
666
+ // a plain-text sibling of the updates/ staging dir.
667
+ fn channel_file(handle: &tauri::AppHandle, bundle_id: &str) -> Option<PathBuf> {
668
+ handle.path().data_dir().ok().map(|d| d.join(bundle_id).join("update-channel"))
669
+ }
670
+
671
+ // Channel names become a path segment of the feed URL — keep them boring.
672
+ fn valid_channel(s: &str) -> bool {
673
+ !s.is_empty()
674
+ && s.len() <= 64
675
+ && s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
676
+ }
677
+
583
678
  // notes = markdown source (also what the native dialog shows); notes_html =
584
679
  // pre-rendered HTML for in-app changelog UI. Both ride to the page untouched —
585
680
  // the feed is the developer's own signed content.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_everywhere
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.8
4
+ version: 0.1.10
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrea Fomera
@@ -75,6 +75,10 @@ executables:
75
75
  extensions: []
76
76
  extra_rdoc_files: []
77
77
  files:
78
+ - bridge/README.md
79
+ - bridge/everywhere/bridge.js
80
+ - bridge/package.json
81
+ - config/importmap.rb
78
82
  - exe/every
79
83
  - exe/rbe
80
84
  - lib/everywhere.rb
@@ -98,10 +102,10 @@ files:
98
102
  - lib/everywhere/commands/updates_keygen.rb
99
103
  - lib/everywhere/config.rb
100
104
  - lib/everywhere/database.rb
105
+ - lib/everywhere/engine.rb
101
106
  - lib/everywhere/framework.rb
102
107
  - lib/everywhere/icon.rb
103
108
  - lib/everywhere/ignore.rb
104
- - lib/everywhere/javascript/bridge.js
105
109
  - lib/everywhere/log_filter.rb
106
110
  - lib/everywhere/minisign.rb
107
111
  - lib/everywhere/paths.rb