omarchy-ui 0.0.2-x86_64-linux → 0.0.4-x86_64-linux

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: d8c832cd4301f1b237ef13b46fea6eed08cafdae66a1411eae88a887a59a5cf0
4
- data.tar.gz: 60b933f9795e67a7b6c1ce2d8e90cb15716dbc9821250bbd8e85a4c7e840d1f1
3
+ metadata.gz: b39c650e3541f41aea662dccf79373f790cc9fc84466b065ccb7b8836d74739e
4
+ data.tar.gz: 985d17d6917e19b2fc10996d7f20d8a99e6dd49bb5160a88aa7122220056e025
5
5
  SHA512:
6
- metadata.gz: 415009fe051699cef4821e70e394dec94b883aa86dfd325a8c2e8afd38a7a3076c9ec21df6efea747ae791b715c5a5de92ef837ccef84f458683f49d805e295e
7
- data.tar.gz: 3a017f0872adcec2dff15d35f03e7ac40a2d463dcee56a978e2e514aee187c804ecfce9e8573e3cf1b4312fad5364ec0686af3e7e9e6f7f9eab9ea09a45a1eab
6
+ metadata.gz: cfa24ef79ecfdffcb0e00c4243b26600a4df4e2b4cf197aad307bceef54011631c482a15d055b2c88710acda2e8baceba0ebec1106ae2c290850016ea70793b9
7
+ data.tar.gz: bf1ed984d03aea86c9db37cdd3dc1556faad23d23fef443d81669770a3e7431e87aa8632932794be1ad820117a98181990764c6401cb3de44513b9cc07d8e768
data/ControlNode.qml CHANGED
@@ -40,6 +40,13 @@ Loader {
40
40
  return icons[key] || key
41
41
  }
42
42
 
43
+ function escapeAutoText(value) {
44
+ return String(value === undefined || value === null ? "" : value)
45
+ .replace(/&/g, "&")
46
+ .replace(/</g, "&lt;")
47
+ .replace(/>/g, "&gt;")
48
+ }
49
+
43
50
  function easingType(name) {
44
51
  var easings = {
45
52
  linear: Easing.Linear,
@@ -224,6 +231,7 @@ Loader {
224
231
 
225
232
  Text {
226
233
  text: String(root.prop("text", ""))
234
+ textFormat: Text.PlainText
227
235
  color: root.prop("color", root.foreground)
228
236
  font.family: root.fontFamily
229
237
  font.pixelSize: {
@@ -243,6 +251,7 @@ Loader {
243
251
 
244
252
  Text {
245
253
  text: root.iconGlyph(root.prop("name", root.prop("text", "")))
254
+ textFormat: Text.PlainText
246
255
  color: root.foreground
247
256
  font.family: root.fontFamily
248
257
  font.pixelSize: Number(root.prop("size", Style.font.icon))
@@ -253,9 +262,9 @@ Loader {
253
262
  id: buttonComponent
254
263
 
255
264
  OmarchyUi.Button {
256
- text: String(root.prop("text", ""))
265
+ text: root.escapeAutoText(root.prop("text", ""))
257
266
  iconText: root.iconGlyph(root.prop("icon", ""))
258
- tooltipText: String(root.prop("tooltip", ""))
267
+ tooltipText: root.escapeAutoText(root.prop("tooltip", ""))
259
268
  selected: root.prop("selected", false) === true
260
269
  active: root.prop("active", false) === true
261
270
  hasCursor: root.prop("cursor", false) === true
@@ -461,7 +470,7 @@ Loader {
461
470
  Component {
462
471
  id: actionButtonComponent
463
472
  OmarchyUi.PanelActionButton {
464
- iconText: root.iconGlyph(root.prop("icon", "")); tooltipText: String(root.prop("tooltip", ""))
473
+ iconText: root.iconGlyph(root.prop("icon", "")); tooltipText: root.escapeAutoText(root.prop("tooltip", ""))
465
474
  bordered: root.prop("bordered", false) === true; focusable: root.prop("focusable", false) === true
466
475
  hasCursor: root.prop("cursor", false) === true
467
476
  size: Number(root.prop("size", 28)); foreground: root.prop("foreground", root.foreground)
@@ -475,7 +484,7 @@ Loader {
475
484
  Component {
476
485
  id: toggleComponent
477
486
  OmarchyUi.Toggle {
478
- label: String(root.prop("label", "")); description: String(root.prop("description", ""))
487
+ label: root.escapeAutoText(root.prop("label", "")); description: root.escapeAutoText(root.prop("description", ""))
479
488
  checked: root.prop("checked", false) === true; hasCursor: root.prop("cursor", false) === true
480
489
  rounded: root.prop("rounded", Style.cornerRadius > 0) === true
481
490
  foreground: root.prop("foreground", root.foreground); accent: root.prop("accent", Color.accent)
@@ -522,7 +531,7 @@ Loader {
522
531
  Component {
523
532
  id: numberFieldComponent
524
533
  OmarchyUi.NumberField {
525
- label: String(root.prop("label", "")); value: Number(root.prop("value", 0))
534
+ label: root.escapeAutoText(root.prop("label", "")); value: Number(root.prop("value", 0))
526
535
  from: Number(root.prop("from", 0)); to: Number(root.prop("to", 100)); stepSize: Number(root.prop("step", 1))
527
536
  foreground: root.prop("foreground", root.foreground); accent: root.prop("accent", Color.accent)
528
537
  fontFamily: String(root.prop("font_family", root.fontFamily)); fontSize: Number(root.prop("font_size", Style.font.body))
@@ -551,7 +560,7 @@ Loader {
551
560
  Component {
552
561
  id: dropdownComponent
553
562
  OmarchyUi.Dropdown {
554
- label: String(root.prop("label", "")); value: String(root.prop("value", "")); options: root.prop("options", [])
563
+ label: root.escapeAutoText(root.prop("label", "")); value: String(root.prop("value", "")); options: root.prop("options", [])
555
564
  implicitWidth: Number(root.prop("width", 240)); foreground: root.prop("foreground", root.foreground)
556
565
  background: root.prop("background", Color.popups.background); popupBorder: root.prop("popup_border", Color.popups.border)
557
566
  accent: root.prop("accent", Color.accent); fontFamily: String(root.prop("font_family", root.fontFamily))
@@ -565,7 +574,7 @@ Loader {
565
574
  Component {
566
575
  id: multiSelectComponent
567
576
  OmarchyUi.MultiSelect {
568
- label: String(root.prop("label", "")); values: root.prop("values", []); options: root.prop("options", [])
577
+ label: root.escapeAutoText(root.prop("label", "")); values: root.prop("values", []); options: root.prop("options", [])
569
578
  placeholderText: String(root.prop("placeholder", "Search...")); enabled: root.prop("enabled", true) !== false
570
579
  optionsCommand: root.prop("options_command", []); optionsCommandCwd: String(root.prop("options_command_cwd", ""))
571
580
  emptyText: String(root.prop("empty_text", "No options")); noSelectionText: String(root.prop("no_selection_text", "None selected"))
@@ -604,12 +613,12 @@ Loader {
604
613
  }
605
614
 
606
615
  Component { id: separatorComponent; OmarchyUi.PanelSeparator { foreground: root.foreground; strength: Number(root.prop("strength", 0.12)) } }
607
- Component { id: sectionHeaderComponent; OmarchyUi.PanelSectionHeader { text: String(root.prop("text", "")); foreground: root.foreground; fontFamily: root.fontFamily } }
616
+ Component { id: sectionHeaderComponent; OmarchyUi.PanelSectionHeader { text: root.escapeAutoText(root.prop("text", "")); foreground: root.foreground; fontFamily: root.fontFamily } }
608
617
 
609
618
  Component {
610
619
  id: searchableDropdownComponent
611
620
  OmarchyUi.SearchableDropdown {
612
- label: String(root.prop("label", "")); value: String(root.prop("value", "")); options: root.prop("options", [])
621
+ label: root.escapeAutoText(root.prop("label", "")); value: String(root.prop("value", "")); options: root.prop("options", [])
613
622
  placeholderText: String(root.prop("placeholder", "Search...")); emptyText: String(root.prop("empty_text", "No matches"))
614
623
  triggerLabel: String(root.prop("trigger_label", "")); implicitWidth: Number(root.prop("width", 240))
615
624
  foreground: root.prop("foreground", root.foreground); background: root.prop("background", Color.popups.background)
@@ -640,7 +649,7 @@ Loader {
640
649
  Component {
641
650
  id: panelHeroComponent
642
651
  OmarchyUi.PanelHero {
643
- title: String(root.prop("title", "")); meta: String(root.prop("meta", "")); detail: String(root.prop("detail", ""))
652
+ title: root.escapeAutoText(root.prop("title", "")); meta: root.escapeAutoText(root.prop("meta", "")); detail: root.escapeAutoText(root.prop("detail", ""))
644
653
  iconSize: Number(root.prop("icon_size", Style.font.display)); iconOpacity: Number(root.prop("icon_opacity", 1))
645
654
  metaOpacity: Number(root.prop("meta_opacity", 1)); foreground: root.prop("foreground", root.foreground)
646
655
  fontFamily: String(root.prop("font_family", root.fontFamily))
@@ -665,7 +674,7 @@ Loader {
665
674
  Component {
666
675
  id: widgetButtonComponent
667
676
  OmarchyUi.WidgetButton {
668
- text: String(root.prop("text", "")); tooltipText: String(root.prop("tooltip", "")); active: root.prop("active", false) === true
677
+ text: root.escapeAutoText(root.prop("text", "")); tooltipText: root.escapeAutoText(root.prop("tooltip", "")); active: root.prop("active", false) === true
669
678
  dimmed: root.prop("dimmed", false) === true; concealed: root.prop("concealed", false) === true
670
679
  interactive: root.prop("interactive", true) !== false; pressable: root.prop("pressable", true) !== false
671
680
  fontFamily: String(root.prop("font_family", root.fontFamily)); fontSize: Number(root.prop("font_size", Style.font.body))
@@ -720,10 +729,10 @@ Loader {
720
729
  id: rowContent
721
730
  anchors.centerIn: parent
722
731
  spacing: Style.spacing.controlGap
723
- Text { visible: text !== ""; text: root.iconGlyph(typeof modelData === "object" ? modelData[listControl.iconField] : ""); color: root.foreground; font.family: root.fontFamily }
732
+ Text { visible: text !== ""; text: root.iconGlyph(typeof modelData === "object" ? modelData[listControl.iconField] : ""); textFormat: Text.PlainText; color: root.foreground; font.family: root.fontFamily }
724
733
  Column {
725
- Text { text: String(typeof modelData === "object" ? (modelData[listControl.labelField] ?? value) : modelData); color: root.foreground; font.family: root.fontFamily }
726
- Text { visible: text !== ""; text: String(typeof modelData === "object" ? (modelData[listControl.descriptionField] || "") : ""); color: Qt.darker(root.foreground, 1.4); font.family: root.fontFamily; font.pixelSize: Style.font.caption }
734
+ Text { text: String(typeof modelData === "object" ? (modelData[listControl.labelField] ?? value) : modelData); textFormat: Text.PlainText; color: root.foreground; font.family: root.fontFamily }
735
+ Text { visible: text !== ""; text: String(typeof modelData === "object" ? (modelData[listControl.descriptionField] || "") : ""); textFormat: Text.PlainText; color: Qt.darker(root.foreground, 1.4); font.family: root.fontFamily; font.pixelSize: Style.font.caption }
727
736
  }
728
737
  }
729
738
  MouseArea {
@@ -738,7 +747,7 @@ Loader {
738
747
 
739
748
  Text {
740
749
  anchors.centerIn: parent; visible: listControl.count === 0
741
- text: String(root.prop("empty_text", "No items")); color: Qt.darker(root.foreground, 1.4); font.family: root.fontFamily
750
+ text: String(root.prop("empty_text", "No items")); textFormat: Text.PlainText; color: Qt.darker(root.foreground, 1.4); font.family: root.fontFamily
742
751
  }
743
752
  }
744
753
  }
data/README.md CHANGED
@@ -25,35 +25,38 @@ end
25
25
 
26
26
  ## Install
27
27
 
28
- On Omarchy x86-64, install the gem and start building:
28
+ On Omarchy x86-64, install the gem:
29
29
 
30
30
  ```bash
31
31
  gem install omarchy-ui
32
- omarchy_ui new "My App"
33
32
  ```
34
33
 
35
- The gem contains everything required to create, launch, and bundle an application. Developers do
34
+ The gem contains everything required to launch and bundle an application. Developers do
36
35
  not need to install a separate runtime or copy framework files. Bundled applications require only
37
36
  an Omarchy computer to run.
38
37
 
39
- ## Create and run an application
38
+ ## Start an application
40
39
 
41
40
  ```bash
42
- omarchy_ui new "My App"
43
- cd my-app
41
+ omarchy_ui new MyApp
42
+ cd myapp
44
43
  omarchy_ui launch main.rb
45
44
  ```
46
45
 
47
- The standalone generator creates no plugin manifest or copied runtime files:
46
+ A new standalone project contains only application-owned files:
48
47
 
49
48
  ```text
50
- my-app/
51
- ├── Components/
52
- │ └── Welcome.qml
49
+ myapp/
50
+ ├── components/
51
+ │ └── welcome.rb
53
52
  ├── README.md
54
53
  └── main.rb
55
54
  ```
56
55
 
56
+ The generated application UI is entirely Ruby. `main.rb` loads reusable Ruby components from
57
+ `components/`; no QML source is generated into the application project. The framework creates the
58
+ required native bridge files only when launching or bundling the app.
59
+
57
60
  `launch` opens a compositor-managed window. Drag its title bar or use Super+drag, and close it
58
61
  with Super+W. For protocol debugging without a window, use `omarchy_ui run main.rb`.
59
62
 
@@ -63,10 +66,10 @@ From an application directory:
63
66
 
64
67
  ```bash
65
68
  omarchy_ui bundle
66
- ./dist/my-app/run
69
+ ./dist/myapp/run
67
70
  ```
68
71
 
69
- `bundle` creates a self-contained application under `dist/<project-name>/`. The generated `run`
72
+ `bundle` creates a self-contained application under `dist/<project-name>/`. Its `run`
70
73
  launcher works on another Omarchy computer without Ruby or the `omarchy-ui` gem.
71
74
 
72
75
  An Omarchy Shell plugin is a separate packaging mode. It requires `manifest.json` so the shell
@@ -306,15 +309,6 @@ Component names, files, properties, events, IDs, effects, values, message sizes,
306
309
  limits are validated. Commands use argv arrays without a shell. Applications and plugins run with
307
310
  the current user's permissions, so review third-party code before installing it.
308
311
 
309
- ## Omarchy Phone example
310
-
311
- `examples/omarchy-phone` demonstrates reactive controls, background discovery, safe commands,
312
- ADB pairing and connection, scrcpy launching, iPhone discovery, and UxPlay AirPlay mirroring.
313
-
314
- ```bash
315
- omarchy_ui launch examples/omarchy-phone/main.rb
316
- ```
317
-
318
312
  ## Development and verification
319
313
 
320
314
  ```bash
@@ -324,7 +318,7 @@ ruby script/benchmark.rb
324
318
  ```
325
319
 
326
320
  The suite covers state, bindings, repeated structures, event persistence, component schemas,
327
- animation tracks and sequences, tasks, command safety, standalone project generation, packaging,
321
+ animation tracks and sequences, tasks, command safety, standalone projects, packaging,
328
322
  manifests, component contracts, linting, and the phone backend.
329
323
 
330
324
  ## License
data/Service.qml CHANGED
@@ -24,6 +24,8 @@ Item {
24
24
  property int revision: 0
25
25
  property int restartDelayMs: 500
26
26
  property int eventSequence: 0
27
+ readonly property int maxStringLength: 16384
28
+ readonly property int maxCollectionItems: 256
27
29
 
28
30
  property var allowedTypes: ({})
29
31
  property var allowedProperties: ({})
@@ -41,6 +43,26 @@ Item {
41
43
  return value !== null && typeof value === "object" && !Array.isArray(value)
42
44
  }
43
45
 
46
+ function boundedValue(value, depth) {
47
+ if (depth > 8) return false
48
+ if (value === null || typeof value === "number" || typeof value === "boolean") return true
49
+ if (typeof value === "string") return value.length <= maxStringLength
50
+ if (Array.isArray(value)) {
51
+ if (value.length > maxCollectionItems) return false
52
+ for (var i = 0; i < value.length; i++) if (!boundedValue(value[i], depth + 1)) return false
53
+ return true
54
+ }
55
+ if (plainObject(value)) {
56
+ var keys = Object.keys(value)
57
+ if (keys.length > maxCollectionItems) return false
58
+ for (var k = 0; k < keys.length; k++) {
59
+ if (keys[k].length > 128 || !boundedValue(value[keys[k]], depth + 1)) return false
60
+ }
61
+ return true
62
+ }
63
+ return false
64
+ }
65
+
44
66
  function validateNode(node, index, depth, count) {
45
67
  if (!plainObject(node) || depth > 32 || count.value >= 2000) return false
46
68
  if (!allowedTypes[String(node.type || "")] || !validId(node.id)) return false
@@ -52,8 +74,7 @@ Item {
52
74
  for (var key in props) {
53
75
  if (!whitelist[key]) return false
54
76
  var value = props[key]
55
- if (value !== null && typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean" && !Array.isArray(value) && !plainObject(value))
56
- return false
77
+ if (!boundedValue(value, 0)) return false
57
78
  }
58
79
 
59
80
  var children = node.children === undefined ? [] : node.children
@@ -225,8 +246,7 @@ Item {
225
246
  if (message.op !== "set" || typeof message.property !== "string") return reject("invalid patch")
226
247
  if (!allowedProperties[node.type][message.property]) return reject("patch target rejected")
227
248
  var value = message.value
228
- if (value !== null && typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean" && !Array.isArray(value) && !plainObject(value))
229
- return reject("patch value rejected")
249
+ if (!boundedValue(value, 0)) return reject("patch value rejected")
230
250
 
231
251
  var animation = message.animation
232
252
  if (animation !== undefined && !validAnimation(animation)) return reject("patch animation rejected")
@@ -292,14 +312,15 @@ Item {
292
312
  } else if (message.type === "ack") {
293
313
  // Acknowledgements are consumed by benchmarks and future diagnostics.
294
314
  } else if (message.type === "handler_error" || message.type === "runtime_error" || message.type === "protocol_error") {
295
- lastError = String(message.message || message.code || "Ruby runtime error")
315
+ lastError = String(message.message || message.code || "Ruby runtime error").slice(0, 512)
296
316
  } else {
297
317
  reject("unknown message type")
298
318
  }
299
319
  }
300
320
 
301
321
  function handleEffect(message) {
302
- if (typeof message.name !== "string" || !plainObject(message.payload || {}))
322
+ if (typeof message.name !== "string" || message.name.length > 64
323
+ || !plainObject(message.payload || {}) || !boundedValue(message.payload || {}, 0))
303
324
  return reject("invalid effect")
304
325
  var payload = message.payload || {}
305
326
  effectReceived(message.name, payload)
@@ -313,7 +334,7 @@ Item {
313
334
  }
314
335
 
315
336
  function reject(reason) {
316
- lastError = String(reason || "protocol error")
337
+ lastError = String(reason || "protocol error").slice(0, 512)
317
338
  console.warn("omarchy-ui bridge:", lastError)
318
339
  return false
319
340
  }
@@ -342,7 +363,8 @@ Item {
342
363
  }
343
364
 
344
365
  function sendEvent(surfaceName, controlId, eventName, payload) {
345
- if (!rubyProcess.running || !validId(controlId) || !/^[a-z][a-z0-9_]{0,63}$/.test(eventName)) return false
366
+ if (!rubyProcess.running || !validId(controlId) || !/^[a-z][a-z0-9_]{0,63}$/.test(eventName)
367
+ || !plainObject(payload || {}) || !boundedValue(payload || {}, 0)) return false
346
368
  var target = nodeIndex[String(controlId)]
347
369
  var definition = target ? componentDefinitions[String(target.type)] : null
348
370
  var subscriptions = target && Array.isArray(target.events) ? target.events : []
@@ -190,16 +190,50 @@ module OmarchyUI
190
190
  end
191
191
 
192
192
  def reconcile_structure(structure)
193
+ previous_children = structure.last_children
193
194
  structure.node.children.dup.each { |child| unregister_subtree(child) }
194
195
  structure.node.children.clear
195
196
  @builder.rebuild(structure.node, &structure.renderer)
196
197
  children = structure.node.children.map(&:to_h)
197
198
  return if children == structure.last_children
199
+ if patchable_trees?(previous_children, children)
200
+ emit_property_patches(previous_children, children)
201
+ structure.last_children = children
202
+ return
203
+ end
198
204
  structure.last_children = children
199
205
  emit("v" => PROTOCOL_VERSION, "type" => "patch", "op" => "replace_children",
200
206
  "id" => structure.node.id, "children" => children)
201
207
  end
202
208
 
209
+ def patchable_trees?(previous, current)
210
+ return false unless previous.length == current.length
211
+ previous.zip(current).all? do |before, after|
212
+ before["id"] == after["id"] && before["type"] == after["type"] &&
213
+ before.fetch("events", []) == after.fetch("events", []) &&
214
+ before.fetch("props", {}).keys.sort == after.fetch("props", {}).keys.sort &&
215
+ patchable_trees?(before.fetch("children", []), after.fetch("children", []))
216
+ end
217
+ end
218
+
219
+ def emit_property_patches(previous, current)
220
+ previous.zip(current).each do |before, after|
221
+ before.fetch("props", {}).each do |property, old_value|
222
+ value = after.fetch("props", {}).fetch(property)
223
+ next if value == old_value
224
+ next if echoed_input_patch?(after.fetch("id"), property, value)
225
+ emit("v" => PROTOCOL_VERSION, "type" => "patch", "op" => "set",
226
+ "id" => after.fetch("id"), "property" => property, "value" => value)
227
+ end
228
+ emit_property_patches(before.fetch("children", []), after.fetch("children", []))
229
+ end
230
+ end
231
+
232
+ def echoed_input_patch?(control_id, property, value)
233
+ @active_event && @active_event["event"] == "input" && @active_event["id"] == control_id &&
234
+ %w[text value].include?(property) && @active_event.dig("payload", "value") == value
235
+ end
236
+
203
237
  def unregister_subtree(node)
204
238
  node.children.each { |child| unregister_subtree(child) }
205
239
  @nodes.delete(node.id)
@@ -212,7 +246,12 @@ module OmarchyUI
212
246
  key = [message.fetch("id"), message.fetch("event")]
213
247
  handler = @handlers[key]
214
248
  raise ProtocolError, "unknown event target: #{key.join('/')}" unless handler
215
- @builder.instance_exec(message["payload"] || {}, &handler)
249
+ begin
250
+ @active_event = message
251
+ @builder.instance_exec(message["payload"] || {}, &handler)
252
+ ensure
253
+ @active_event = nil
254
+ end
216
255
  acknowledgement = {
217
256
  "v" => PROTOCOL_VERSION, "type" => "ack", "seq" => message["seq"],
218
257
  "id" => message.fetch("id"), "event" => message.fetch("event")
@@ -259,6 +298,7 @@ module OmarchyUI
259
298
  def emit(message)
260
299
  return unless @running && @output
261
300
  encoded = JSON.generate(message)
301
+ raise ProtocolError, "outgoing message exceeds #{MAX_MESSAGE_BYTES} bytes" if encoded.bytesize > MAX_MESSAGE_BYTES
262
302
  @write_lock.synchronize { @output.puts(encoded) }
263
303
  end
264
304
 
@@ -16,11 +16,12 @@ module OmarchyUI
16
16
  def initialize(application)
17
17
  @application = application
18
18
  @stack = []
19
+ @dynamic_scopes = []
19
20
  end
20
21
 
21
22
  def component(type, id: nil, **props, &block)
22
23
  definition = @application.components.fetch(type)
23
- node = @application.build_node(type, explicit_id: id, props:)
24
+ node = @application.build_node(type, explicit_id: id || scoped_id(type), props:)
24
25
  append(node)
25
26
  if block
26
27
  raise ArgumentError, "#{type} is not a container" unless definition.container
@@ -64,7 +65,8 @@ module OmarchyUI
64
65
  definition = @application.components.fetch(type)
65
66
  raise ArgumentError, "dynamic component must be a container: #{type}" unless definition.container
66
67
 
67
- node = component(type, id:, **props, &renderer)
68
+ node = component(type, id:, **props)
69
+ within_dynamic(node, &renderer)
68
70
  @application.register_structure(node, renderer)
69
71
  node
70
72
  end
@@ -166,7 +168,7 @@ module OmarchyUI
166
168
  def every(seconds, immediate: false, &block) = @application.schedule(:every, interval: seconds, immediate:, &block)
167
169
  def async(&block) = @application.schedule(:async, &block)
168
170
  def run_command(argv, **options) = Command.run(argv, **options)
169
- def rebuild(node, &renderer) = within(node, &renderer)
171
+ def rebuild(node, &renderer) = within_dynamic(node, &renderer)
170
172
  def open_panel(name) = @application.emit_effect("open_panel", "surface" => name.to_s)
171
173
 
172
174
  def close_panel(name = nil)
@@ -175,6 +177,20 @@ module OmarchyUI
175
177
 
176
178
  private
177
179
 
180
+ def scoped_id(type)
181
+ return nil if @dynamic_scopes.empty?
182
+ scope = @dynamic_scopes.last
183
+ scope[:sequence] += 1
184
+ "#{scope.fetch(:id)}.#{type}.#{scope.fetch(:sequence)}"
185
+ end
186
+
187
+ def within_dynamic(node, &block)
188
+ @dynamic_scopes.push({ id: node.id, sequence: 0 })
189
+ within(node, &block)
190
+ ensure
191
+ @dynamic_scopes.pop
192
+ end
193
+
178
194
  def surface(name, id:, options: {}, &block)
179
195
  raise ArgumentError, "surface requires a block" unless block
180
196
  node = @application.build_node(:container, explicit_id: id)
@@ -192,7 +208,8 @@ module OmarchyUI
192
208
  def input_component(type, property, value, id:, props:, handler: nil)
193
209
  props = props.merge(property => value) unless value.equal?(UNSET)
194
210
  node = component(type, id:, **props)
195
- @application.register_handler(node.id, :change, handler) if handler
211
+ event = type == :text_field ? :input : :change
212
+ @application.register_handler(node.id, event, handler) if handler
196
213
  node
197
214
  end
198
215
 
@@ -53,6 +53,8 @@ module OmarchyUI
53
53
 
54
54
  project_dir = File.dirname(file)
55
55
  Dir.mktmpdir("omarchy-ui-app-") do |runtime_dir|
56
+ runtime_program = File.join(runtime_dir, "main.rb")
57
+ File.write(runtime_program, SourceBundle.new(file, root: project_dir).call)
56
58
  Project::RUNTIME_FILES.each do |name|
57
59
  source = File.file?(File.join(project_dir, name)) ? File.join(project_dir, name) : File.join(FRAMEWORK_ROOT, name)
58
60
  FileUtils.cp(source, runtime_dir)
@@ -65,7 +67,7 @@ module OmarchyUI
65
67
 
66
68
  environment = ENV.to_h.merge(
67
69
  "OMARCHY_UI_PROJECT_DIR" => project_dir,
68
- "OMARCHY_UI_RUBY_PROGRAM" => file,
70
+ "OMARCHY_UI_RUBY_PROGRAM" => runtime_program,
69
71
  "OMARCHY_UI_RUNTIME" => Runtime.executable,
70
72
  "QT_LOGGING_RULES" => qt_logging_rules
71
73
  )
@@ -103,12 +105,16 @@ module OmarchyUI
103
105
  destination = File.join(source, "dist", File.basename(source))
104
106
  raise ArgumentError, "bundle destination already exists: #{destination}" if File.exist?(destination)
105
107
  FileUtils.mkdir_p(destination)
106
- entries = Dir.children(source).reject { |entry| %w[.git dist].include?(entry) }
108
+ entries = application_entries(source)
107
109
  FileUtils.cp_r(entries.map { |entry| File.join(source, entry) }, destination)
110
+ bundle_ruby_entrypoint(source, destination)
108
111
  Project.install_runtime(destination)
109
- runtime = File.join(destination, "omarchy-ui-runtime")
110
- FileUtils.cp(Runtime::BUNDLED, runtime)
111
- FileUtils.chmod(0o755, runtime)
112
+ if File.file?(File.join(destination, "manifest.json"))
113
+ raise ArgumentError, "bundled plugin validation failed" unless system("omarchy", "plugin", "validate", destination)
114
+ @out.puts("Bundled plugin in #{destination}")
115
+ return 0
116
+ end
117
+
112
118
  %w[Commons Ui].each do |module_name|
113
119
  FileUtils.ln_s(File.join("/usr/share/omarchy/shell", module_name), File.join(destination, module_name))
114
120
  end
@@ -135,11 +141,15 @@ module OmarchyUI
135
141
  [ENV["QT_LOGGING_RULES"], "qt.qpa.services.warning=false"].compact.reject(&:empty?).join(";")
136
142
  end
137
143
 
144
+ def application_entries(source)
145
+ generated = Project::RUNTIME_FILES + Project::RUNTIME_AUDIT_FILES + %w[omarchy-ui-runtime run Commons Ui]
146
+ Dir.children(source).reject { |entry| %w[.git dist].include?(entry) || generated.include?(entry) }
147
+ end
148
+
138
149
  def push(arguments)
139
150
  enable = !arguments.delete("--no-enable")
140
151
  restart = !arguments.delete("--no-restart")
141
152
  source = File.expand_path(arguments.shift || Dir.pwd)
142
- Runtime.install_shared
143
153
  manifest_path = File.join(source, "manifest.json")
144
154
  manifest = JSON.parse(File.read(manifest_path))
145
155
  plugin_id = manifest.fetch("id")
@@ -185,15 +195,23 @@ module OmarchyUI
185
195
  def stage_project(source, parent: nil, prefix: ".omarchy-ui-staging-")
186
196
  raise ArgumentError, "project directory not found: #{source}" unless File.directory?(source)
187
197
  staging = parent ? Dir.mktmpdir(prefix, parent) : Dir.mktmpdir(prefix)
188
- entries = Dir.children(source).reject { |entry| entry == ".git" }
198
+ entries = application_entries(source)
189
199
  FileUtils.cp_r(entries.map { |entry| File.join(source, entry) }, staging) unless entries.empty?
190
- Project.install_runtime(staging) if File.file?(File.join(staging, "main.rb"))
200
+ if File.file?(File.join(staging, "main.rb"))
201
+ bundle_ruby_entrypoint(source, staging)
202
+ Project.install_runtime(staging)
203
+ end
191
204
  staging
192
205
  rescue StandardError
193
206
  FileUtils.remove_entry(staging) if staging && File.exist?(staging)
194
207
  raise
195
208
  end
196
209
 
210
+ def bundle_ruby_entrypoint(source, destination)
211
+ entrypoint = File.join(source, "main.rb")
212
+ File.write(File.join(destination, "main.rb"), SourceBundle.new(entrypoint, root: source).call)
213
+ end
214
+
197
215
  def activate_plugin(plugin_id)
198
216
  return if system("omarchy", "plugin", "enable", plugin_id)
199
217
  system("omarchy-shell", "shell", "rescanPlugins")
@@ -5,6 +5,7 @@ require "timeout"
5
5
 
6
6
  module OmarchyUI
7
7
  class CommandTimeout < StandardError; end
8
+ class CommandOutputLimit < StandardError; end
8
9
 
9
10
  CommandResult = Struct.new(:stdout, :stderr, :status, keyword_init: true) do
10
11
  def success? = status.success?
@@ -14,8 +15,12 @@ module OmarchyUI
14
15
  module Command
15
16
  module_function
16
17
 
17
- def run(argv, env: {}, chdir: nil, input: "", timeout: nil)
18
+ DEFAULT_MAX_OUTPUT_BYTES = 1_048_576
19
+
20
+ def run(argv, env: {}, chdir: nil, input: "", timeout: nil, max_output_bytes: DEFAULT_MAX_OUTPUT_BYTES)
18
21
  arguments = normalize_argv(argv)
22
+ output_limit = Integer(max_output_bytes)
23
+ raise ArgumentError, "max_output_bytes must be positive" unless output_limit.positive?
19
24
  options = {}
20
25
  options[:chdir] = File.expand_path(chdir) if chdir
21
26
  stdin = stdout = stderr = wait_thread = nil
@@ -23,10 +28,15 @@ module OmarchyUI
23
28
  stdin, stdout, stderr, wait_thread = child_stdin, child_stdout, child_stderr, child_wait
24
29
  stdin.write(input.to_s)
25
30
  stdin.close
26
- stdout_reader = Thread.new { stdout.read }
27
- stderr_reader = Thread.new { stderr.read }
31
+ stdout_reader = Thread.new { bounded_read(stdout, output_limit) }
32
+ stderr_reader = Thread.new { bounded_read(stderr, output_limit) }
28
33
  status = timeout ? Timeout.timeout(Float(timeout)) { wait_thread.value } : wait_thread.value
29
- return CommandResult.new(stdout: stdout_reader.value, stderr: stderr_reader.value, status:)
34
+ stdout_value, stdout_limited = stdout_reader.value
35
+ stderr_value, stderr_limited = stderr_reader.value
36
+ if stdout_limited || stderr_limited
37
+ raise CommandOutputLimit, "command output exceeded #{output_limit} bytes: #{arguments.first}"
38
+ end
39
+ return CommandResult.new(stdout: stdout_value, stderr: stderr_value, status:)
30
40
  rescue Timeout::Error
31
41
  terminate(wait_thread)
32
42
  stdout_reader&.join(1)
@@ -37,6 +47,24 @@ module OmarchyUI
37
47
  [stdin, stdout, stderr].each { |stream| stream&.close unless stream&.closed? }
38
48
  end
39
49
 
50
+ def bounded_read(stream, limit)
51
+ output = +""
52
+ exceeded = false
53
+ loop do
54
+ chunk = stream.readpartial(16_384)
55
+ remaining = limit - output.bytesize
56
+ if remaining.positive?
57
+ output << chunk.byteslice(0, remaining)
58
+ exceeded = true if chunk.bytesize > remaining
59
+ else
60
+ exceeded = true
61
+ end
62
+ end
63
+ rescue EOFError
64
+ [output, exceeded]
65
+ end
66
+ private_class_method :bounded_read
67
+
40
68
  def normalize_argv(argv)
41
69
  raise ArgumentError, "command must be an argv array" unless argv.is_a?(Array) && !argv.empty?
42
70
  argv.map do |argument|
@@ -5,6 +5,7 @@ require "fileutils"
5
5
  module OmarchyUI
6
6
  class Project
7
7
  RUNTIME_FILES = %w[Service.qml ControlNode.qml Panel.qml BarWidget.qml App.qml].freeze
8
+ RUNTIME_AUDIT_FILES = %w[omarchy-ui-runtime.sha256 RUNTIME_PROVENANCE.md].freeze
8
9
 
9
10
  def initialize(path:, name: nil, framework_root: FRAMEWORK_ROOT)
10
11
  @path = File.expand_path(path)
@@ -15,9 +16,9 @@ module OmarchyUI
15
16
  def create
16
17
  raise ArgumentError, "destination already exists: #{@path}" if File.exist?(@path)
17
18
  created = true
18
- FileUtils.mkdir_p(File.join(@path, "Components"))
19
+ FileUtils.mkdir_p(File.join(@path, "components"))
19
20
  File.write(File.join(@path, "main.rb"), main_program)
20
- File.write(File.join(@path, "Components", "Welcome.qml"), welcome_component)
21
+ File.write(File.join(@path, "components", "welcome.rb"), welcome_component)
21
22
  File.write(File.join(@path, "README.md"), readme)
22
23
  @path
23
24
  rescue StandardError
@@ -28,7 +29,7 @@ module OmarchyUI
28
29
  def self.install_runtime(path, framework_root: FRAMEWORK_ROOT)
29
30
  RUNTIME_FILES.each do |file|
30
31
  destination = File.join(path, file)
31
- FileUtils.cp(File.join(framework_root, file), destination) unless File.exist?(destination)
32
+ FileUtils.cp(File.join(framework_root, file), destination)
32
33
  end
33
34
  bundled_runtime = File.join(framework_root, "vendor", "runtime", "x86_64-linux", "omarchy-ui-runtime")
34
35
  if File.file?(bundled_runtime)
@@ -36,6 +37,10 @@ module OmarchyUI
36
37
  FileUtils.cp(bundled_runtime, destination)
37
38
  FileUtils.chmod(0o755, destination)
38
39
  end
40
+ RUNTIME_AUDIT_FILES.each do |file|
41
+ source = File.join(framework_root, "vendor", "runtime", "x86_64-linux", file)
42
+ FileUtils.cp(source, File.join(path, file)) if File.file?(source)
43
+ end
39
44
  FileUtils.mkdir_p(File.join(path, "Components"))
40
45
  end
41
46
 
@@ -45,63 +50,37 @@ module OmarchyUI
45
50
  <<~RUBY
46
51
  # frozen_string_literal: true
47
52
 
48
- require "omarchy_ui" unless Object.const_defined?(:OmarchyUI)
53
+ require "omarchy_ui"
54
+ require_relative "components/welcome"
49
55
 
50
56
  OmarchyUI.plugin do
51
- register_component :welcome,
52
- qml: "Welcome.qml",
53
- properties: %i[title message]
54
-
55
57
  app :main, title: "#{@name}", width: 760, height: 520 do
56
- component :welcome,
58
+ welcome_card(
57
59
  title: "Welcome to #{@name}",
58
60
  message: "This is the official Omarchy UI framework."
61
+ )
59
62
  end
60
63
  end
61
64
  RUBY
62
65
  end
63
66
 
64
67
  def welcome_component
65
- <<~QML
66
- import QtQuick
67
- import qs.Commons
68
- import qs.Ui
69
-
70
- BorderSurface {
71
- id: root
72
-
73
- property string title: "Welcome"
74
- property string message: "This is the official Omarchy UI framework."
75
-
76
- implicitWidth: 520
77
- implicitHeight: 220
78
- color: Color.popups.background
79
- radius: Style.cornerRadius
80
- borderSpec: Border.controlSpec("normal", Color.foreground, Color.accent)
81
-
82
- Column {
83
- anchors.centerIn: parent
84
- spacing: Style.spacing.lg
85
-
86
- Text {
87
- anchors.horizontalCenter: parent.horizontalCenter
88
- text: root.title
89
- color: Color.foreground
90
- font.family: Style.font.family
91
- font.pixelSize: Style.font.heading
92
- font.bold: true
93
- }
94
-
95
- Text {
96
- anchors.horizontalCenter: parent.horizontalCenter
97
- text: root.message
98
- color: Color.foreground
99
- font.family: Style.font.family
100
- font.pixelSize: Style.font.body
101
- }
102
- }
103
- }
104
- QML
68
+ <<~RUBY
69
+ # frozen_string_literal: true
70
+
71
+ module WelcomeComponent
72
+ def welcome_card(title:, message:)
73
+ container padding: 24, bordered: true do
74
+ column spacing: 12 do
75
+ text title, style: :heading
76
+ text message, wrap: true
77
+ end
78
+ end
79
+ end
80
+ end
81
+
82
+ OmarchyUI::Builder.include(WelcomeComponent)
83
+ RUBY
105
84
  end
106
85
 
107
86
  def readme
@@ -119,8 +98,10 @@ module OmarchyUI
119
98
  The shared `omarchy-ui-runtime` must be installed on `PATH`. No system Ruby,
120
99
  application manifest, or copied framework QML files are required.
121
100
 
122
- Edit `main.rb` for application state and behavior. Custom QML adapters live in
123
- `Components/`; `Welcome.qml` is included as a working example.
101
+ Edit `main.rb` for application state and behavior. Reusable Ruby UI components live in
102
+ `components/` and load with ordinary Ruby `require_relative`; `welcome.rb` is included as a
103
+ working example. `omarchy_ui bundle` generates
104
+ the native QML bridge and embeds the runtime for distribution.
124
105
  MARKDOWN
125
106
  end
126
107
  end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OmarchyUI
4
+ class SourceBundle
5
+ REQUIRE_RELATIVE = /\A\s*require_relative\s*(?:\(\s*)?["']([^"']+)["']\s*\)?\s*(?:#.*)?\z/
6
+ EMBEDDED_FRAMEWORK_REQUIRE = /\A\s*require\s*(?:\(\s*)?["']omarchy_ui["']\s*\)?\s*(?:#.*)?\z/
7
+
8
+ def initialize(entrypoint, root: File.dirname(entrypoint))
9
+ @entrypoint = File.expand_path(entrypoint)
10
+ @root = File.expand_path(root)
11
+ @loaded = {}
12
+ @loading = []
13
+ end
14
+
15
+ def call
16
+ expand(@entrypoint)
17
+ end
18
+
19
+ private
20
+
21
+ def expand(path)
22
+ path = ruby_path(File.expand_path(path))
23
+ ensure_inside_root!(path)
24
+ return "" if @loaded[path]
25
+ raise ArgumentError, "circular require_relative: #{relative(path)}" if @loading.include?(path)
26
+ raise ArgumentError, "required Ruby file not found: #{relative(path)}" unless File.file?(path)
27
+
28
+ @loading << path
29
+ source = File.readlines(path, chomp: true).map do |line|
30
+ match = REQUIRE_RELATIVE.match(line)
31
+ if match
32
+ expand(File.expand_path(match[1], File.dirname(path)))
33
+ elsif EMBEDDED_FRAMEWORK_REQUIRE.match?(line)
34
+ ""
35
+ else
36
+ line
37
+ end
38
+ end.join("\n")
39
+ @loaded[path] = true
40
+ "# source: #{relative(path)}\n#{source}\n"
41
+ ensure
42
+ @loading.pop if @loading.last == path
43
+ end
44
+
45
+ def ruby_path(path)
46
+ File.extname(path).empty? ? "#{path}.rb" : path
47
+ end
48
+
49
+ def ensure_inside_root!(path)
50
+ return if path == @root || path.start_with?("#{@root}#{File::SEPARATOR}")
51
+
52
+ raise ArgumentError, "require_relative escapes the application directory: #{path}"
53
+ end
54
+
55
+ def relative(path)
56
+ path.delete_prefix("#{@root}#{File::SEPARATOR}")
57
+ end
58
+ end
59
+ end
data/lib/omarchy_ui.rb CHANGED
@@ -11,11 +11,12 @@ require_relative "omarchy_ui/component_registry"
11
11
  require_relative "omarchy_ui/components"
12
12
  require_relative "omarchy_ui/builder"
13
13
  require_relative "omarchy_ui/application"
14
+ require_relative "omarchy_ui/source_bundle"
14
15
  require_relative "omarchy_ui/project"
15
16
  require_relative "omarchy_ui/runtime"
16
17
 
17
18
  module OmarchyUI
18
- VERSION = "0.0.2"
19
+ VERSION = "0.0.4"
19
20
  FRAMEWORK_ROOT = File.expand_path("..", __dir__)
20
21
 
21
22
  def self.plugin(&definition)
@@ -0,0 +1,10 @@
1
+ # Omarchy UI runtime provenance
2
+
3
+ - Omarchy UI source: https://github.com/AdamMusa/omarchy-ui
4
+ - Omarchy UI source revision: `1a516ce535156c15b90d685b030443257e79fe57`
5
+ - mruby source revision: `831da26b9021de0369d17b71b5667e2941a1a32d`
6
+ - Target: x86-64 Linux
7
+ - SHA-256: `65214c89ca9ba4787d574e48b17b774cf59bb589712bc5565d2e3794877b9e47`
8
+
9
+ Rebuild and verification instructions are committed at
10
+ [`docs/runtime-build.md`](https://github.com/AdamMusa/omarchy-ui/blob/1a516ce535156c15b90d685b030443257e79fe57/docs/runtime-build.md).
@@ -0,0 +1 @@
1
+ 65214c89ca9ba4787d574e48b17b774cf59bb589712bc5565d2e3794877b9e47 omarchy-ui-runtime
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: omarchy-ui
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.2
4
+ version: 0.0.4
5
5
  platform: x86_64-linux
6
6
  authors:
7
7
  - Adam Moussa Ali
@@ -38,10 +38,13 @@ files:
38
38
  - lib/omarchy_ui/protocol.rb
39
39
  - lib/omarchy_ui/runtime.rb
40
40
  - lib/omarchy_ui/scheduler.rb
41
+ - lib/omarchy_ui/source_bundle.rb
41
42
  - lib/omarchy_ui/state_store.rb
42
43
  - lib/omarchy_ui/value.rb
43
44
  - manifest.json
45
+ - vendor/runtime/x86_64-linux/RUNTIME_PROVENANCE.md
44
46
  - vendor/runtime/x86_64-linux/omarchy-ui-runtime
47
+ - vendor/runtime/x86_64-linux/omarchy-ui-runtime.sha256
45
48
  homepage: https://github.com/AdamMusa/omarchy-ui
46
49
  licenses:
47
50
  - MIT