turbo_desktop-rails 0.0.1 → 0.2.2

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: 4b5500a9a8e4b248dc2b0f274ae9e45ea023ee609a7986ee5ec6882e23254537
4
- data.tar.gz: 1fea19f6e2056e7b6cceb27f6eb5a4188dfdfed791f373347f40301abbe0cc29
3
+ metadata.gz: 28aa3a8403d578104476a660dd9ee06a6c89e662f16264f7142451413ba32963
4
+ data.tar.gz: 99e458936a6838a91b3aa205ed21931436b726a14758ba52d6bebeb6e643a35e
5
5
  SHA512:
6
- metadata.gz: dd37b92667b4387a0b189a11b6abf1f2d4541ffa719807b46f7ff4b6c78a847294fea744be887d8053ffcdb4f8cb17dd3a9b44286b13960345fe70cf14f1c69e
7
- data.tar.gz: f0698610d97df906b146bbb7371209eb18554a9dc816af5fbc75fa07c11fffb7b8644e4b6d9d92ac6666edc125fc4296cda285c50bc2fe7ed3f7a6a6daff0e6c
6
+ metadata.gz: 846e2a35b02b73557bfcfb11cde442d93d5a3b32be010cfaf0433d78c375650c41c143c17a35f14ce9d8f34e87e183f95a3a5a79dbe6e220b23557f4d1e5525e
7
+ data.tar.gz: c04b4ac951f6b2176af91eb021e6278602b814db6a99e74bd1d015a6562bb6c8020a5b8901b8ee78c2cb1b848006b2c9eaf1d491bfdf2116f9394671227fd330
data/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.2 (2026-09-27)
4
+
5
+ Version aligned with the desktop shell's 0.2.2 release. The CLI now scaffolds
6
+ new apps with `gem "turbo_desktop-rails", "~> 0.2"`; it previously wrote
7
+ `"~> 0.1"`, which resolved to 0.1.1. First 0.2.x version published to RubyGems.
8
+ No gem-side API changes.
9
+
10
+ ## 0.2.1 (2026-07-29)
11
+
12
+ Version aligned with the desktop shell's 0.2.1 release, which fixes bridge
13
+ events (shell/sudo output streaming, drag & drop, component onReceive) never
14
+ reaching pages loaded from the app server. No gem-side API changes.
15
+
16
+ ## 0.2.0 (2026-07-29)
17
+
18
+ Version aligned with the desktop shell's 0.2.0 release (server auto-start,
19
+ Windows support, cross-platform sudo, drag & drop, file associations,
20
+ clipboard, launch-at-login). No gem-side API changes.
21
+
22
+ ## 0.1.1 (2026-07-27)
23
+
24
+ ### Added
25
+
26
+ - Install generator: `rails generate turbo_desktop:install` scaffolds the initializer.
27
+ - Dev Inspector support:
28
+ - `config.inspector_enabled` and the `turbo_desktop_inspector_meta_tag` view helper to enable
29
+ the in-app inspector overlay (dev only).
30
+ - The gem now serves the inspector's JavaScript **same-origin** at `/turbo-desktop/inspector.js`
31
+ (and its sub-modules), so the desktop shell can `import()` it without extra setup.
32
+ - `config.inspector_mount_path` to match a custom engine mount point.
33
+
34
+ ### Changed
35
+
36
+ - Minimum Ruby version is now 3.3.
37
+
3
38
  ## 0.0.1 (2026-03-22)
4
39
 
5
40
  - Initial release
data/README.md CHANGED
@@ -56,6 +56,33 @@ turbo_desktop_arch # => "aarch64", "x86_64", or nil
56
56
  ) %>
57
57
  ```
58
58
 
59
+ ### Desktop-only templates
60
+
61
+ Requests from the desktop app are marked with a Rails variant, so a whole
62
+ template can be written for it rather than branching inside a shared one:
63
+
64
+ ```
65
+ app/views/orders/show.html.erb # everyone
66
+ app/views/orders/show.html+desktop.erb # the desktop app
67
+ ```
68
+
69
+ Layouts work the same way — `app/views/layouts/application.html+desktop.erb`.
70
+ Rails falls back to the plain template wherever no variant exists, so this costs
71
+ nothing until you add one.
72
+
73
+ The block helpers above are still the right tool for a button or a nav bar. Reach
74
+ for a variant when the whole page differs.
75
+
76
+ Rename it, or turn it off, in the initializer:
77
+
78
+ ```ruby
79
+ TurboDesktop.configure do |config|
80
+ config.variant = :desktop # nil leaves variants alone
81
+ end
82
+ ```
83
+
84
+ It is added to any variants you have already set rather than replacing them.
85
+
59
86
  ### Path Configuration
60
87
 
61
88
  The gem mounts a path configuration endpoint at `/turbo-desktop/path-configuration.json`:
@@ -76,7 +103,7 @@ end
76
103
 
77
104
  ## Requirements
78
105
 
79
- - Ruby >= 3.1.0
106
+ - Ruby >= 3.3
80
107
  - Rails >= 7.0
81
108
  - turbo-rails >= 1.0
82
109
 
@@ -0,0 +1,44 @@
1
+ module TurboDesktop
2
+ # Serves the Dev Inspector's JavaScript from the Rails origin so the desktop
3
+ # shell's `turbo-desktop.js` can `import()` it same-origin.
4
+ #
5
+ # The shell points its WebView at your Rails app (server_url), so a relative
6
+ # `import("./inspector.js")` resolves against the Rails origin — which is why
7
+ # these assets must be served here, not from the bundled Tauri frontend.
8
+ #
9
+ # Only the fixed set of inspector modules is served (strict allow-list, so no
10
+ # path traversal). Enabled implicitly by mounting the engine; the meta-tag
11
+ # helper points the shell at these URLs.
12
+ class InspectorAssetsController < ActionController::Base
13
+ ASSET_ROOT = TurboDesktop::Engine.root.join("lib/turbo_desktop/inspector_assets").freeze
14
+
15
+ # Relative paths (as requested by the browser) → allowed. Anything else 404s.
16
+ ALLOWED = %w[
17
+ inspector.js
18
+ inspector/state.js
19
+ inspector/panel.js
20
+ inspector/bridge-tap.js
21
+ inspector/catalog.js
22
+ ].freeze
23
+
24
+ # GET /turbo-desktop/inspector.js
25
+ # GET /turbo-desktop/inspector/<module>.js
26
+ def show
27
+ rel = requested_asset
28
+ return head(:not_found) unless ALLOWED.include?(rel)
29
+
30
+ path = ASSET_ROOT.join(rel)
31
+ return head(:not_found) unless File.file?(path)
32
+
33
+ response.set_header("Cache-Control", "public, max-age=3600")
34
+ render body: File.read(path), content_type: "text/javascript"
35
+ end
36
+
37
+ private
38
+
39
+ def requested_asset
40
+ mod = params[:module]
41
+ mod.blank? ? "inspector.js" : "inspector/#{mod}"
42
+ end
43
+ end
44
+ end
data/config/routes.rb CHANGED
@@ -1,3 +1,7 @@
1
1
  TurboDesktop::Engine.routes.draw do
2
2
  get "path-configuration", to: "path_configurations#show", defaults: { format: :json }
3
+
4
+ # Dev Inspector assets, served same-origin so the desktop shell can import() them.
5
+ get "inspector.js", to: "inspector_assets#show", format: false
6
+ get "inspector/*module", to: "inspector_assets#show", format: false
3
7
  end
@@ -0,0 +1,30 @@
1
+ require "rails/generators/base"
2
+
3
+ module TurboDesktop
4
+ module Generators
5
+ class InstallGenerator < Rails::Generators::Base
6
+ source_root File.expand_path("templates", __dir__)
7
+
8
+ desc "Install Turbo Desktop into your Rails application"
9
+
10
+ def copy_initializer
11
+ template "initializer.rb.tt", "config/initializers/turbo_desktop.rb"
12
+ end
13
+
14
+ def mount_engine
15
+ route 'mount TurboDesktop::Engine => "/turbo-desktop"'
16
+ end
17
+
18
+ def show_next_steps
19
+ say ""
20
+ say "Turbo Desktop installed!", :green
21
+ say ""
22
+ say "Next steps:"
23
+ say " 1. npx turbo-desktop init # Scaffold the desktop shell"
24
+ say " 2. rails server # Start your Rails app"
25
+ say " 3. npx turbo-desktop dev # Launch the desktop app"
26
+ say ""
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,49 @@
1
+ # config/initializers/turbo_desktop.rb
2
+ #
3
+ # Configure how the Turbo Desktop native shell presents your Rails routes.
4
+ # This path configuration follows the same pattern as Hotwire Native (turbo-ios/turbo-android).
5
+ #
6
+ # Presentations:
7
+ # "default" — Navigate in the current window (standard Turbo Drive behavior)
8
+ # "modal" — Open in a modal/sheet window
9
+ # "new_window" — Open in a separate window
10
+ # "replace" — Replace the current page (no back button)
11
+ # "native" — Route to a fully native screen (handled by Rust/Tauri)
12
+ # "none" — Do nothing (handled by a bridge component)
13
+
14
+ TurboDesktop.configure do |config|
15
+ # Dev Inspector — an in-app overlay (Cmd/Ctrl+Shift+D) that surfaces available
16
+ # bridge components, a live web↔native message log, and the current path-config
17
+ # presentation. Enable it in development only:
18
+ config.inspector_enabled = Rails.env.development?
19
+
20
+ # Requests from the desktop app are marked with a Rails variant, so you can
21
+ # write a whole template for it instead of branching inside a shared one:
22
+ #
23
+ # app/views/orders/show.html.erb # everyone
24
+ # app/views/orders/show.html+desktop.erb # the desktop app
25
+ #
26
+ # Layouts work the same way (layouts/application.html+desktop.erb), and Rails
27
+ # falls back to the plain template wherever no variant exists. Rename it, or
28
+ # set it to nil to leave variants alone.
29
+ # config.variant = :desktop
30
+
31
+ config.path_configuration = {
32
+ settings: {
33
+ screenshots_enabled: false
34
+ },
35
+ rules: [
36
+ # Default: all pages navigate in the current window
37
+ { patterns: ["/"], properties: { presentation: "default" } },
38
+
39
+ # Forms open in a modal window
40
+ { patterns: ["/new$", "/edit$"], properties: { presentation: "modal" } },
41
+
42
+ # Example: settings could be a native screen
43
+ # { patterns: ["/settings"], properties: { presentation: "native" } },
44
+
45
+ # Example: external links open in a new window
46
+ # { patterns: ["^http"], properties: { presentation: "new_window" } },
47
+ ]
48
+ }
49
+ end
@@ -1,10 +1,18 @@
1
1
  module TurboDesktop
2
2
  class Configuration
3
- attr_accessor :path_configuration, :user_agent_pattern
3
+ attr_accessor :path_configuration, :user_agent_pattern, :inspector_enabled,
4
+ :inspector_mount_path, :variant
4
5
 
5
6
  def initialize
6
7
  @path_configuration = default_path_configuration
7
8
  @user_agent_pattern = /Turbo Desktop/
9
+ @inspector_enabled = false
10
+ # Rails variant set on requests from the desktop app, so views can be
11
+ # written as show.html+desktop.erb. Set to nil to leave variants alone.
12
+ @variant = :desktop
13
+ # Where the engine is mounted; the inspector meta tag advertises assets
14
+ # under this prefix. Override if you mount the engine elsewhere.
15
+ @inspector_mount_path = "/turbo-desktop"
8
16
  end
9
17
 
10
18
  def path_configuration_json
@@ -19,7 +27,7 @@ module TurboDesktop
19
27
  screenshots_enabled: false
20
28
  },
21
29
  rules: [
22
- { patterns: ["/"], properties: { presentation: "default" } }
30
+ { patterns: [ "/" ], properties: { presentation: "default" } }
23
31
  ]
24
32
  }
25
33
  end
@@ -11,6 +11,23 @@ module TurboDesktop
11
11
 
12
12
  included do
13
13
  helper_method :turbo_desktop_app?, :turbo_desktop_platform, :turbo_desktop_arch
14
+
15
+ before_action :set_turbo_desktop_variant
16
+ end
17
+
18
+ # The variant list a request should end up with, or nil to leave it alone.
19
+ #
20
+ # Kept separate from the controller so the decision can be exercised on its
21
+ # own. Adds to whatever variants are already set rather than replacing them:
22
+ # an app may well be using variants for something else.
23
+ def self.variant_for(current:, desktop:, configured:)
24
+ return nil if configured.blank? || !desktop
25
+
26
+ current = Array(current).map(&:to_sym)
27
+ configured = configured.to_sym
28
+ return nil if current.include?(configured)
29
+
30
+ current + [ configured ]
14
31
  end
15
32
 
16
33
  # Returns true if the request is from a Turbo Desktop app.
@@ -31,6 +48,27 @@ module TurboDesktop
31
48
  end
32
49
  end
33
50
 
51
+ # Mark desktop requests with a Rails variant, so a whole template can be
52
+ # written for the desktop app instead of branching inside a shared one:
53
+ #
54
+ # app/views/orders/show.html.erb # everyone
55
+ # app/views/orders/show.html+desktop.erb # the desktop app
56
+ #
57
+ # Layouts pick it up too — layouts/application.html+desktop.erb. Rails falls
58
+ # back to the plain template wherever no variant exists, so this costs
59
+ # nothing until you add one.
60
+ #
61
+ # Set config.variant to nil to turn it off.
62
+ def set_turbo_desktop_variant
63
+ variant = TurboDesktop::Detection.variant_for(
64
+ current: request.variant,
65
+ desktop: turbo_desktop_app?,
66
+ configured: TurboDesktop.configuration.variant
67
+ )
68
+
69
+ request.variant = variant if variant
70
+ end
71
+
34
72
  # Returns the architecture: "aarch64", "x86_64", or nil.
35
73
  def turbo_desktop_arch
36
74
  return nil unless turbo_desktop_app?
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Observes bridge traffic without altering it.
3
+ *
4
+ * install() wraps host.sendBridgeMessage so every outbound call is recorded
5
+ * then forwarded to the original exactly once, with its return/throw preserved.
6
+ * observeResponse() records inbound bridge-response payloads.
7
+ *
8
+ * Recording runs inside its own try/catch so a logging bug can never affect a
9
+ * real bridge call. No DOM access.
10
+ */
11
+ export class BridgeTap {
12
+ constructor(host, { onRecord, now = () => Date.now() } = {}) {
13
+ this.host = host;
14
+ this.onRecord = onRecord;
15
+ this.now = now;
16
+ this._installed = false;
17
+ this._original = null;
18
+ }
19
+
20
+ install() {
21
+ if (this._installed) return;
22
+ // Store the raw property so uninstall can restore the exact same reference.
23
+ this._original = this.host.sendBridgeMessage;
24
+ const original = this._original.bind(this.host);
25
+ const self = this;
26
+ this.host.sendBridgeMessage = function (component, event, data = {}) {
27
+ self._safeRecord({ direction: "out", component, event, data, ts: self.now() });
28
+ return original(component, event, data); // pass-through, exactly once
29
+ };
30
+ this._installed = true;
31
+ }
32
+
33
+ /** Record an inbound bridge-response payload. */
34
+ observeResponse(payload) {
35
+ if (!payload) return;
36
+ this._safeRecord({
37
+ direction: "in",
38
+ component: payload.component,
39
+ event: payload.event || "response",
40
+ data: payload.data !== undefined ? payload.data : payload,
41
+ ts: this.now(),
42
+ });
43
+ }
44
+
45
+ uninstall() {
46
+ if (this._installed && this._original) {
47
+ this.host.sendBridgeMessage = this._original;
48
+ this._installed = false;
49
+ }
50
+ }
51
+
52
+ _safeRecord(record) {
53
+ try {
54
+ if (this.onRecord) this.onRecord(record);
55
+ } catch (_e) {
56
+ /* recording must never break the host */
57
+ }
58
+ }
59
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Static catalog of built-in bridge components.
3
+ * Single source of truth for the Inspector's "Available" list and snippets.
4
+ * Each entry: { description, erb, stimulus }.
5
+ */
6
+ export const CATALOG = {
7
+ "notification": {
8
+ description: "Show native OS notifications.",
9
+ erb: `<button data-controller="notification"\n data-action="click->notification#notify"\n data-body="Saved!">Notify</button>`,
10
+ stimulus: `import { Controller } from "@hotwired/stimulus"\nexport default class extends TurboDesktop.stimulusBridge(Controller, "notification") {\n notify(e) { this.sendBridge("connect", { title: "My App", body: e.target.dataset.body }) }\n}`,
11
+ },
12
+ "menu-item": {
13
+ description: "Register an item in the native menu bar.",
14
+ erb: `<%= tag.button "Export PDF",\n **turbo_desktop_bridge("menu-item", title: "Export PDF", shortcut: "Cmd+E") %>`,
15
+ stimulus: `import { Controller } from "@hotwired/stimulus"\nexport default class extends TurboDesktop.stimulusBridge(Controller, "menu-item") {\n connect() { super.connect(); this.sendBridge("register", { title: "Export PDF", shortcut: "Cmd+E" }) }\n}`,
16
+ },
17
+ "file-picker": {
18
+ description: "Open a native file open/save dialog.",
19
+ erb: `<button data-controller="file-picker"\n data-action="click->file-picker#open">Choose file…</button>`,
20
+ stimulus: `import { Controller } from "@hotwired/stimulus"\nexport default class extends TurboDesktop.stimulusBridge(Controller, "file-picker") {\n open() { this.sendBridge("open", { multiple: false }) }\n receiveBridge(msg) { console.log("picked", msg.data) }\n}`,
21
+ },
22
+ "badge": {
23
+ description: "Set the dock / taskbar badge count.",
24
+ erb: `<span data-controller="badge" data-badge-count-value="3"></span>`,
25
+ stimulus: `import { Controller } from "@hotwired/stimulus"\nexport default class extends TurboDesktop.stimulusBridge(Controller, "badge") {\n static values = { count: Number }\n connect() { super.connect(); this.sendBridge("set", { count: this.countValue }) }\n}`,
26
+ },
27
+ "shortcut": {
28
+ description: "Register a global keyboard shortcut.",
29
+ erb: `<div data-controller="shortcut" data-shortcut-keys-value="CmdOrCtrl+K"></div>`,
30
+ stimulus: `import { Controller } from "@hotwired/stimulus"\nexport default class extends TurboDesktop.stimulusBridge(Controller, "shortcut") {\n static values = { keys: String }\n connect() { super.connect(); this.sendBridge("register", { keys: this.keysValue }) }\n receiveBridge() { /* fired when the shortcut is pressed */ }\n}`,
31
+ },
32
+ "shell": {
33
+ description: "Spawn and manage native shell processes.",
34
+ erb: `<button data-controller="shell" data-action="click->shell#run">Run</button>`,
35
+ stimulus: `import { Controller } from "@hotwired/stimulus"\nexport default class extends TurboDesktop.stimulusBridge(Controller, "shell") {\n run() { TurboDesktop.shell.spawn("job-1", "echo", ["hello"]) }\n}`,
36
+ },
37
+ "filesystem": {
38
+ description: "Read and write files through the native filesystem bridge.",
39
+ erb: `<button data-controller="fs" data-action="click->fs#read">Read file</button>`,
40
+ stimulus: `import { Controller } from "@hotwired/stimulus"\nexport default class extends TurboDesktop.stimulusBridge(Controller, "filesystem") {\n read() { this.sendBridge("read", { path: "~/notes.txt" }) }\n receiveBridge(msg) { console.log(msg.data) }\n}`,
41
+ },
42
+ "sudo": {
43
+ description: "Run a privileged command via the native elevation prompt.",
44
+ erb: `<button data-controller="sudo" data-action="click->sudo#elevate">Install</button>`,
45
+ stimulus: `import { Controller } from "@hotwired/stimulus"\nexport default class extends TurboDesktop.stimulusBridge(Controller, "sudo") {\n elevate() { this.sendBridge("execute", { command: "brew install foo" }) }\n}`,
46
+ },
47
+ "tray": {
48
+ description: "Add items to the system tray / menu-bar icon.",
49
+ erb: `<div data-controller="tray" data-tray-title-value="My App"></div>`,
50
+ stimulus: `import { Controller } from "@hotwired/stimulus"\nexport default class extends TurboDesktop.stimulusBridge(Controller, "tray") {\n static values = { title: String }\n connect() { super.connect(); this.sendBridge("set", { tooltip: this.titleValue }) }\n}`,
51
+ },
52
+ "deep-link": {
53
+ description: "Handle custom-scheme deep links opened from outside the app.",
54
+ erb: `<div data-controller="deep-link"></div>`,
55
+ stimulus: `import { Controller } from "@hotwired/stimulus"\nexport default class extends TurboDesktop.stimulusBridge(Controller, "deep-link") {\n receiveBridge(msg) { Turbo.visit(msg.data.path) }\n}`,
56
+ },
57
+ "updater": {
58
+ description: "Check for and apply native app updates.",
59
+ erb: `<button data-controller="updater" data-action="click->updater#check">Check for updates</button>`,
60
+ stimulus: `import { Controller } from "@hotwired/stimulus"\nexport default class extends TurboDesktop.stimulusBridge(Controller, "updater") {\n check() { this.sendBridge("check", {}) }\n receiveBridge(msg) { console.log("update status", msg.data) }\n}`,
61
+ },
62
+ };
63
+
64
+ for (const entry of Object.values(CATALOG)) Object.freeze(entry);
65
+ Object.freeze(CATALOG);
66
+
67
+ /** Names of every catalogued component. */
68
+ export function listComponents() {
69
+ return Object.keys(CATALOG);
70
+ }
71
+
72
+ /** Look up one component's metadata, or null if unknown. */
73
+ export function getComponent(name) {
74
+ return Object.prototype.hasOwnProperty.call(CATALOG, name) ? CATALOG[name] : null;
75
+ }
@@ -0,0 +1,148 @@
1
+ import { listComponents, getComponent } from "./catalog.js";
2
+
3
+ function escapeHtml(value) {
4
+ return String(value)
5
+ .replace(/&/g, "&amp;")
6
+ .replace(/</g, "&lt;")
7
+ .replace(/>/g, "&gt;");
8
+ }
9
+
10
+ function escapeAttr(value) {
11
+ return escapeHtml(value).replace(/"/g, "&quot;");
12
+ }
13
+
14
+ /**
15
+ * Shadow-DOM overlay that renders an InspectorState.
16
+ * Subscribes to state changes; never mutates state. No Tauri access.
17
+ */
18
+ export class InspectorPanel {
19
+ constructor(state, { document }) {
20
+ this.state = state;
21
+ this.document = document;
22
+ this.visible = false;
23
+ this.activeTab = "components";
24
+ this.filter = "";
25
+ this.hostEl = null;
26
+ this.root = null; // shadow root
27
+ this._unsub = null;
28
+ }
29
+
30
+ mount(parent) {
31
+ const host = this.document.createElement("div");
32
+ host.setAttribute("data-turbo-desktop-inspector", "");
33
+ host.style.cssText = "position:fixed;right:0;bottom:0;z-index:2147483647;";
34
+ this.root = host.attachShadow ? host.attachShadow({ mode: "open" }) : host;
35
+ this.hostEl = host;
36
+ parent.appendChild(host);
37
+ this._unsub = this.state.subscribe(() => this.render());
38
+ this.render();
39
+ this._applyVisibility();
40
+ return this;
41
+ }
42
+
43
+ unmount() {
44
+ if (this._unsub) this._unsub();
45
+ if (this.hostEl && this.hostEl.parentNode) this.hostEl.parentNode.removeChild(this.hostEl);
46
+ }
47
+
48
+ show() { this.visible = true; this._applyVisibility(); }
49
+ hide() { this.visible = false; this._applyVisibility(); }
50
+ toggle() { this.visible = !this.visible; this._applyVisibility(); }
51
+
52
+ selectTab(tab) { this.activeTab = tab; this.render(); }
53
+ setFilter(text) { this.filter = text || ""; this.render(); }
54
+
55
+ _applyVisibility() {
56
+ if (this.hostEl) this.hostEl.style.display = this.visible ? "block" : "none";
57
+ }
58
+
59
+ render() {
60
+ if (!this.root) return;
61
+ try {
62
+ this.root.innerHTML = this._html();
63
+ this._wire();
64
+ } catch (_e) {
65
+ this.root.innerHTML = "<div>&lt;unrenderable&gt;</div>";
66
+ }
67
+ }
68
+
69
+ _wire() {
70
+ const q = (sel) => this.root.querySelectorAll(sel);
71
+ q("[data-tab]").forEach((btn) => {
72
+ btn.addEventListener("click", () => this.selectTab(btn.dataset.tab));
73
+ });
74
+ const input = this.root.querySelector("[data-filter]");
75
+ if (input) input.addEventListener("input", (e) => this.setFilter(e.target.value));
76
+ }
77
+
78
+ _html() {
79
+ return `
80
+ <style>
81
+ :host { all: initial; }
82
+ .wrap { font: 12px/1.4 monospace; width: 420px; height: 320px; background:#111; color:#eee; border:1px solid #333; display:flex; flex-direction:column; }
83
+ .tabs { display:flex; }
84
+ .tabs button { flex:1; background:#222; color:#ccc; border:0; padding:6px; cursor:pointer; }
85
+ .tabs button[aria-selected="true"] { background:#0a84ff; color:#fff; }
86
+ .body { flex:1; overflow:auto; padding:8px; }
87
+ .row { padding:2px 0; border-bottom:1px solid #222; }
88
+ .muted { color:#888; }
89
+ input { width:100%; box-sizing:border-box; margin-bottom:6px; background:#000; color:#eee; border:1px solid #333; }
90
+ </style>
91
+ <div class="wrap">
92
+ <div class="tabs">
93
+ ${["components", "messages", "navigation", "shell"].map((t) =>
94
+ `<button data-tab="${t}" aria-selected="${this.activeTab === t}">${t}</button>`).join("")}
95
+ </div>
96
+ <div class="body">
97
+ ${this._panelHtml()}
98
+ </div>
99
+ </div>`;
100
+ }
101
+
102
+ _panelHtml() {
103
+ switch (this.activeTab) {
104
+ case "messages": return this._messagesHtml();
105
+ case "navigation": return this._navHtml();
106
+ case "shell": return this._shellHtml();
107
+ default: return this._componentsHtml();
108
+ }
109
+ }
110
+
111
+ _componentsHtml() {
112
+ const active = new Map(this.state.components().map((c) => [c.name, c]));
113
+ const rows = listComponents().map((name) => {
114
+ const c = getComponent(name);
115
+ const seen = active.get(name);
116
+ const tag = seen ? `<span>×${escapeHtml(seen.count)} (${escapeHtml(seen.lastEvent)})</span>` : `<span class="muted">available</span>`;
117
+ return `<div class="row"><strong>${escapeHtml(name)}</strong> ${tag}<br><span class="muted">${escapeHtml(c.description)}</span></div>`;
118
+ }).join("");
119
+ return `<div data-panel="components">${rows}</div>`;
120
+ }
121
+
122
+ _messagesHtml() {
123
+ const f = this.filter.toLowerCase();
124
+ const rows = this.state.messages
125
+ .filter((m) => !f || (m.component || "").toLowerCase().includes(f))
126
+ .map((m) => {
127
+ const arrow = m.direction === "out" ? "↑" : "↓";
128
+ return `<div class="row" data-message-row>${arrow} <strong>${escapeHtml(m.component)}</strong> ${escapeHtml(m.event)} <span class="muted">${escapeHtml(JSON.stringify(m.data))}</span></div>`;
129
+ }).join("");
130
+ return `<div data-panel="messages"><input data-filter placeholder="filter by component" value="${escapeAttr(this.filter)}">${rows}</div>`;
131
+ }
132
+
133
+ _navHtml() {
134
+ const n = this.state.nav;
135
+ return `<div data-panel="navigation"><div class="row">URL: ${escapeHtml(n.url || "—")}</div><div class="row">Presentation: <strong>${escapeHtml(n.presentation || "default")}</strong></div></div>`;
136
+ }
137
+
138
+ _shellHtml() {
139
+ const s = this.state.shell;
140
+ return `<div data-panel="shell">
141
+ <div class="row">Platform: ${escapeHtml(s.platform || "—")}</div>
142
+ <div class="row">Arch: ${escapeHtml(s.arch || "—")}</div>
143
+ <div class="row">Version: ${escapeHtml(s.version || "—")}</div>
144
+ <div class="row">Server: ${escapeHtml(s.serverUrl || "—")}</div>
145
+ <div class="row">Updater: ${escapeHtml(s.updater || "—")}</div>
146
+ </div>`;
147
+ }
148
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * In-memory model for the Dev Inspector.
3
+ * Holds a bounded ring buffer of message records plus a derived component
4
+ * summary and nav/shell facts. Emits "change" to subscribers. No DOM.
5
+ */
6
+ export class InspectorState {
7
+ constructor({ capacity = 200 } = {}) {
8
+ this.capacity = capacity;
9
+ this.messages = [];
10
+ this._components = new Map(); // name -> { count, lastEvent }
11
+ this.nav = { url: null, presentation: null };
12
+ this.shell = { platform: null, arch: null, version: null, serverUrl: null, updater: null };
13
+ this._listeners = new Set();
14
+ }
15
+
16
+ /** Append a record { direction, component, event, data, ts }. */
17
+ record(record) {
18
+ this.messages.push(record);
19
+ if (this.messages.length > this.capacity) this.messages.shift();
20
+
21
+ const prev = this._components.get(record.component) || { count: 0, lastEvent: null };
22
+ prev.count += 1;
23
+ prev.lastEvent = record.event;
24
+ this._components.set(record.component, prev);
25
+
26
+ this._emit();
27
+ }
28
+
29
+ /** Derived component summary as an array. */
30
+ components() {
31
+ return [...this._components.entries()].map(([name, v]) => ({ name, count: v.count, lastEvent: v.lastEvent }));
32
+ }
33
+
34
+ setNav(nav) {
35
+ this.nav = { ...this.nav, ...nav };
36
+ this._emit();
37
+ }
38
+
39
+ setShell(shell) {
40
+ this.shell = { ...this.shell, ...shell };
41
+ this._emit();
42
+ }
43
+
44
+ clear() {
45
+ this.messages = [];
46
+ this._components.clear();
47
+ this._emit();
48
+ }
49
+
50
+ /** Subscribe to changes; returns an unsubscribe function. */
51
+ subscribe(fn) {
52
+ this._listeners.add(fn);
53
+ return () => this._listeners.delete(fn);
54
+ }
55
+
56
+ _emit() {
57
+ for (const fn of this._listeners) {
58
+ try { fn(this); } catch (_e) { /* a bad subscriber must not break others */ }
59
+ }
60
+ }
61
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Dev Inspector entry point.
3
+ *
4
+ * startInspector(host, env) wires the inspector units onto a TurboDesktop-like
5
+ * host: it installs a BridgeTap on the host's sendBridgeMessage, subscribes to
6
+ * inbound bridge-response events, seeds shell facts, mounts the Shadow-DOM
7
+ * panel, and binds the toggle hotkey (Cmd/Ctrl+Shift+D).
8
+ *
9
+ * This module is loaded lazily by turbo-desktop.js only when the inspector gate
10
+ * passes, so it ships no code to production builds.
11
+ */
12
+ import { BridgeTap } from "./inspector/bridge-tap.js";
13
+ import { InspectorState } from "./inspector/state.js";
14
+ import { InspectorPanel } from "./inspector/panel.js";
15
+
16
+ export function startInspector(host, { doc = document, win = window } = {}) {
17
+ const state = new InspectorState();
18
+
19
+ const now = (win.Date && typeof win.Date.now === "function") ? () => win.Date.now() : () => 0;
20
+ const tap = new BridgeTap(host, { onRecord: (r) => state.record(r), now });
21
+ tap.install();
22
+
23
+ const internals = win.__TAURI_INTERNALS__;
24
+ if (internals && internals.event && typeof internals.event.listen === "function") {
25
+ internals.event.listen("bridge-response", (e) => tap.observeResponse(e && e.payload));
26
+ }
27
+
28
+ state.setShell({ platform: host.platform, version: host.version });
29
+ if (typeof host.getWindowInfo === "function") {
30
+ host.getWindowInfo().then((info) => { if (info) state.setShell(info); }).catch(() => {});
31
+ }
32
+
33
+ if (typeof host.proposeVisit === "function") {
34
+ const originalProposeVisit = host.proposeVisit.bind(host);
35
+ host.proposeVisit = async function (url, action) {
36
+ const result = await originalProposeVisit(url, action);
37
+ try {
38
+ state.setNav({ url, presentation: result && result.presentation ? result.presentation : "default" });
39
+ } catch (_e) { /* nav recording must never break navigation */ }
40
+ return result;
41
+ };
42
+ }
43
+
44
+ const panel = new InspectorPanel(state, { document: doc });
45
+ panel.mount(doc.body);
46
+
47
+ win.addEventListener("keydown", (e) => {
48
+ if ((e.metaKey || e.ctrlKey) && e.shiftKey && (e.key === "D" || e.key === "d")) {
49
+ e.preventDefault();
50
+ panel.toggle();
51
+ }
52
+ });
53
+
54
+ return { state, tap, panel };
55
+ }
56
+
57
+ export default startInspector;
@@ -1,3 +1,3 @@
1
1
  module TurboDesktop
2
- VERSION = "0.0.1"
2
+ VERSION = "0.2.2"
3
3
  end
@@ -26,12 +26,20 @@ module TurboDesktop
26
26
  end
27
27
  end
28
28
 
29
+ # Attribute name fragments we are willing to build a data-* attribute from.
30
+ # Values are escaped by the tag helpers, but names are interpolated, so they
31
+ # are restricted rather than escaped.
32
+ BRIDGE_NAME_PATTERN = /\A[a-zA-Z0-9_-]+\z/
33
+
29
34
  # Renders a bridge component data attribute.
30
35
  #
31
36
  # <%= tag.button "Export", **turbo_desktop_bridge("menu-item", title: "Export PDF", shortcut: "Cmd+E") %>
32
37
  def turbo_desktop_bridge(component, **options)
33
- attrs = { "data-turbo-desktop-bridge" => component }
38
+ validate_bridge_name!(component, "component")
39
+
40
+ attrs = { "data-turbo-desktop-bridge" => component.to_s }
34
41
  options.each do |key, value|
42
+ validate_bridge_name!(key, "option name")
35
43
  attrs["data-turbo-desktop-bridge-#{key}"] = value.to_s
36
44
  end
37
45
  attrs
@@ -46,5 +54,42 @@ module TurboDesktop
46
54
  def turbo_web_only(&block)
47
55
  capture(&block) unless turbo_desktop_app?
48
56
  end
57
+
58
+ # Returns true when the Dev Inspector is enabled in configuration.
59
+ def turbo_desktop_inspector?
60
+ TurboDesktop.configuration.inspector_enabled
61
+ end
62
+
63
+ # Emits the <meta> tag that enables the Dev Inspector in the browser, or nil
64
+ # when the inspector is disabled. Place in your layout <head>; it is a no-op
65
+ # in production unless you explicitly enable the inspector there.
66
+ #
67
+ # The tag also carries the same-origin URL of the inspector entry module
68
+ # (served by this engine) so the desktop shell's turbo-desktop.js can
69
+ # import() it instead of guessing a relative path.
70
+ #
71
+ # <%= turbo_desktop_inspector_meta_tag %>
72
+ def turbo_desktop_inspector_meta_tag
73
+ return nil unless turbo_desktop_inspector?
74
+
75
+ tag.meta(name: "turbo-desktop-inspector", content: "enabled",
76
+ data: { inspector_url: turbo_desktop_inspector_url })
77
+ end
78
+
79
+ # Same-origin URL of the inspector entry module, under the engine's mount
80
+ # path (configurable via config.inspector_mount_path).
81
+ def turbo_desktop_inspector_url
82
+ "#{TurboDesktop.configuration.inspector_mount_path.chomp("/")}/inspector.js"
83
+ end
84
+
85
+ private
86
+
87
+ def validate_bridge_name!(name, label)
88
+ return if name.to_s.match?(BRIDGE_NAME_PATTERN)
89
+
90
+ raise ArgumentError,
91
+ "turbo_desktop_bridge #{label} #{name.inspect} may only contain " \
92
+ "letters, numbers, hyphens and underscores"
93
+ end
49
94
  end
50
95
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: turbo_desktop-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
4
+ version: 0.2.2
5
5
  platform: ruby
6
6
  authors:
7
- - RaiderHQ
7
+ - aguspe
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-03-22 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: rails
@@ -48,13 +48,21 @@ files:
48
48
  - CHANGELOG.md
49
49
  - LICENSE
50
50
  - README.md
51
+ - app/controllers/turbo_desktop/inspector_assets_controller.rb
51
52
  - app/controllers/turbo_desktop/path_configurations_controller.rb
52
53
  - config/routes.rb
54
+ - lib/generators/turbo_desktop/install/install_generator.rb
55
+ - lib/generators/turbo_desktop/install/templates/initializer.rb.tt
53
56
  - lib/turbo_desktop-rails.rb
54
57
  - lib/turbo_desktop.rb
55
58
  - lib/turbo_desktop/configuration.rb
56
59
  - lib/turbo_desktop/detection.rb
57
60
  - lib/turbo_desktop/engine.rb
61
+ - lib/turbo_desktop/inspector_assets/inspector.js
62
+ - lib/turbo_desktop/inspector_assets/inspector/bridge-tap.js
63
+ - lib/turbo_desktop/inspector_assets/inspector/catalog.js
64
+ - lib/turbo_desktop/inspector_assets/inspector/panel.js
65
+ - lib/turbo_desktop/inspector_assets/inspector/state.js
58
66
  - lib/turbo_desktop/version.rb
59
67
  - lib/turbo_desktop/view_helpers.rb
60
68
  homepage: https://github.com/aguspe/turbo_desktop
@@ -72,14 +80,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
72
80
  requirements:
73
81
  - - ">="
74
82
  - !ruby/object:Gem::Version
75
- version: 3.1.0
83
+ version: 3.2.0
76
84
  required_rubygems_version: !ruby/object:Gem::Requirement
77
85
  requirements:
78
86
  - - ">="
79
87
  - !ruby/object:Gem::Version
80
88
  version: '0'
81
89
  requirements: []
82
- rubygems_version: 3.6.2
90
+ rubygems_version: 4.0.6
83
91
  specification_version: 4
84
92
  summary: Turbo Native for Desktop — Rails integration
85
93
  test_files: []