turbo_desktop-rails 0.0.1 → 0.1.1
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 +4 -4
- data/CHANGELOG.md +16 -0
- data/app/controllers/turbo_desktop/inspector_assets_controller.rb +44 -0
- data/config/routes.rb +4 -0
- data/lib/generators/turbo_desktop/install/install_generator.rb +30 -0
- data/lib/generators/turbo_desktop/install/templates/initializer.rb.tt +38 -0
- data/lib/turbo_desktop/configuration.rb +6 -2
- data/lib/turbo_desktop/inspector_assets/inspector/bridge-tap.js +59 -0
- data/lib/turbo_desktop/inspector_assets/inspector/catalog.js +75 -0
- data/lib/turbo_desktop/inspector_assets/inspector/panel.js +148 -0
- data/lib/turbo_desktop/inspector_assets/inspector/state.js +61 -0
- data/lib/turbo_desktop/inspector_assets/inspector.js +57 -0
- data/lib/turbo_desktop/version.rb +1 -1
- data/lib/turbo_desktop/view_helpers.rb +27 -0
- metadata +12 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d6f899e2fab9c576ab27546066a9b1b28d38a7ec488b0ead48b3d42c2586007d
|
|
4
|
+
data.tar.gz: 16d18b4814b4fbc97355affe91a24b147e6fec059e818065234c39516cbaa784
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ae547aa2c461c50bb2b920d581c02219ea0a981b3a2d7720a4c00b6b6f234172234913319fda4e22ab47bbf5aa6eaf0376286c0f4ffc984fb8d66ed33bb82587
|
|
7
|
+
data.tar.gz: 5ed76a338f38a62c6f850aa76def83eaf88226ba16eb69ba7b690a62db7e0c2a4f0e9a9484b2b050d0c557b1e5f2b0b0fa2686cc36f2ae2f9fffb16ddcb7c1d5
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.1 (2026-07-27)
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- Install generator: `rails generate turbo_desktop:install` scaffolds the initializer.
|
|
8
|
+
- Dev Inspector support:
|
|
9
|
+
- `config.inspector_enabled` and the `turbo_desktop_inspector_meta_tag` view helper to enable
|
|
10
|
+
the in-app inspector overlay (dev only).
|
|
11
|
+
- The gem now serves the inspector's JavaScript **same-origin** at `/turbo-desktop/inspector.js`
|
|
12
|
+
(and its sub-modules), so the desktop shell can `import()` it without extra setup.
|
|
13
|
+
- `config.inspector_mount_path` to match a custom engine mount point.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- Minimum Ruby version is now 3.3.
|
|
18
|
+
|
|
3
19
|
## 0.0.1 (2026-03-22)
|
|
4
20
|
|
|
5
21
|
- Initial release
|
|
@@ -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,38 @@
|
|
|
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
|
+
config.path_configuration = {
|
|
21
|
+
settings: {
|
|
22
|
+
screenshots_enabled: false
|
|
23
|
+
},
|
|
24
|
+
rules: [
|
|
25
|
+
# Default: all pages navigate in the current window
|
|
26
|
+
{ patterns: ["/"], properties: { presentation: "default" } },
|
|
27
|
+
|
|
28
|
+
# Forms open in a modal window
|
|
29
|
+
{ patterns: ["/new$", "/edit$"], properties: { presentation: "modal" } },
|
|
30
|
+
|
|
31
|
+
# Example: settings could be a native screen
|
|
32
|
+
# { patterns: ["/settings"], properties: { presentation: "native" } },
|
|
33
|
+
|
|
34
|
+
# Example: external links open in a new window
|
|
35
|
+
# { patterns: ["^http"], properties: { presentation: "new_window" } },
|
|
36
|
+
]
|
|
37
|
+
}
|
|
38
|
+
end
|
|
@@ -1,10 +1,14 @@
|
|
|
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, :inspector_mount_path
|
|
4
4
|
|
|
5
5
|
def initialize
|
|
6
6
|
@path_configuration = default_path_configuration
|
|
7
7
|
@user_agent_pattern = /Turbo Desktop/
|
|
8
|
+
@inspector_enabled = false
|
|
9
|
+
# Where the engine is mounted; the inspector meta tag advertises assets
|
|
10
|
+
# under this prefix. Override if you mount the engine elsewhere.
|
|
11
|
+
@inspector_mount_path = "/turbo-desktop"
|
|
8
12
|
end
|
|
9
13
|
|
|
10
14
|
def path_configuration_json
|
|
@@ -19,7 +23,7 @@ module TurboDesktop
|
|
|
19
23
|
screenshots_enabled: false
|
|
20
24
|
},
|
|
21
25
|
rules: [
|
|
22
|
-
{ patterns: ["/"], properties: { presentation: "default" } }
|
|
26
|
+
{ patterns: [ "/" ], properties: { presentation: "default" } }
|
|
23
27
|
]
|
|
24
28
|
}
|
|
25
29
|
end
|
|
@@ -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, "&")
|
|
6
|
+
.replace(/</g, "<")
|
|
7
|
+
.replace(/>/g, ">");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function escapeAttr(value) {
|
|
11
|
+
return escapeHtml(value).replace(/"/g, """);
|
|
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><unrenderable></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;
|
|
@@ -46,5 +46,32 @@ module TurboDesktop
|
|
|
46
46
|
def turbo_web_only(&block)
|
|
47
47
|
capture(&block) unless turbo_desktop_app?
|
|
48
48
|
end
|
|
49
|
+
|
|
50
|
+
# Returns true when the Dev Inspector is enabled in configuration.
|
|
51
|
+
def turbo_desktop_inspector?
|
|
52
|
+
TurboDesktop.configuration.inspector_enabled
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Emits the <meta> tag that enables the Dev Inspector in the browser, or nil
|
|
56
|
+
# when the inspector is disabled. Place in your layout <head>; it is a no-op
|
|
57
|
+
# in production unless you explicitly enable the inspector there.
|
|
58
|
+
#
|
|
59
|
+
# The tag also carries the same-origin URL of the inspector entry module
|
|
60
|
+
# (served by this engine) so the desktop shell's turbo-desktop.js can
|
|
61
|
+
# import() it instead of guessing a relative path.
|
|
62
|
+
#
|
|
63
|
+
# <%= turbo_desktop_inspector_meta_tag %>
|
|
64
|
+
def turbo_desktop_inspector_meta_tag
|
|
65
|
+
return nil unless turbo_desktop_inspector?
|
|
66
|
+
|
|
67
|
+
tag.meta(name: "turbo-desktop-inspector", content: "enabled",
|
|
68
|
+
data: { inspector_url: turbo_desktop_inspector_url })
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Same-origin URL of the inspector entry module, under the engine's mount
|
|
72
|
+
# path (configurable via config.inspector_mount_path).
|
|
73
|
+
def turbo_desktop_inspector_url
|
|
74
|
+
"#{TurboDesktop.configuration.inspector_mount_path.chomp("/")}/inspector.js"
|
|
75
|
+
end
|
|
49
76
|
end
|
|
50
77
|
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.
|
|
4
|
+
version: 0.1.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- RaiderHQ
|
|
8
8
|
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date:
|
|
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.
|
|
83
|
+
version: 3.3.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:
|
|
90
|
+
rubygems_version: 4.0.6
|
|
83
91
|
specification_version: 4
|
|
84
92
|
summary: Turbo Native for Desktop — Rails integration
|
|
85
93
|
test_files: []
|