omarchy-ui 0.0.7-x86_64-linux → 0.0.9-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: d389e6d9a8382cb8feec3a900ce2b4ee14001d214a069e96be8e7567e549f39e
4
- data.tar.gz: 77d33296ea0cc16f08a93bb94a17607a3b50d19e036f2649304a89fcc4b9f455
3
+ metadata.gz: 82c12f3f51c0d9242be7386a4994c4f3fe8b333d04489e4055b671054cb2f0e2
4
+ data.tar.gz: e693d5b68a5ad055b40fbb910b4bb6ef41b6de7405027396305f256ad744875d
5
5
  SHA512:
6
- metadata.gz: 2be91201712539398abbf26b03cc3298947d2ff040da32e7171009ef70fdb4127adb7ab5992088176a5f28d5f901cdf4bcdfec68e65c13123bcb3fc61c2206f7
7
- data.tar.gz: 89953562fbc3a943290234a297e5cd0f6ecf98b9f7b17a9188a5072716cf42e39e7281c724b97d2b4926df7aabd973485dbbc012ad3285447aaf36de9a7f5514
6
+ metadata.gz: 807cb5e30c33130681351065bc7fce902a7c4c125f1c6b47e8827db0ec6b49695745ab60d61da9475153f8abf1422940df280154af797ede39ef218a9097c449
7
+ data.tar.gz: c2f3532f53a5725e7c6e7cd9de667ebbb2dddd086204af8172f8b262c010e4fef2470b73716a7496c9859d396f43b22814e03926949944e2e1c080e1e64ca5a2
data/README.md CHANGED
@@ -10,7 +10,7 @@
10
10
  </p>
11
11
 
12
12
  <p align="center">
13
- <img alt="Omarchy UI 0.0.7" src="https://img.shields.io/badge/Omarchy_UI-0.0.7-b7ff5a?style=flat-square&labelColor=111711">
13
+ <img alt="Omarchy UI 0.0.9" src="https://img.shields.io/badge/Omarchy_UI-0.0.9-b7ff5a?style=flat-square&labelColor=111711">
14
14
  <img alt="Ruby 3.1 or newer" src="https://img.shields.io/badge/Ruby-3.1%2B-cc342d?style=flat-square&logo=ruby&logoColor=white">
15
15
  <img alt="Zui 0.0.10" src="https://img.shields.io/badge/Zui-0.0.10-7ee14b?style=flat-square&labelColor=111711">
16
16
  <img alt="241 components" src="https://img.shields.io/badge/components-241-75d943?style=flat-square&labelColor=111711">
@@ -46,27 +46,29 @@ and the Omarchy adapter.
46
46
  </table>
47
47
 
48
48
  > [!IMPORTANT]
49
- > Omarchy UI currently targets **Omarchy on x86-64 Linux** and is marked experimental. The reusable
50
- > Zui application module can also run with Zui's native host on Linux, macOS, and Windows.
49
+ > Omarchy UI currently targets **Omarchy on x86-64 Linux**. Reusable Zui application modules can
50
+ > also run through Zui's native host on Linux, macOS, and Windows.
51
51
 
52
52
  ## Choose a workflow
53
53
 
54
54
  | Build | Use it for | Primary surface | Delivery |
55
55
  | --- | --- | --- | --- |
56
- | Omarchy application | Full desktop tools and standalone interfaces | `app :main` | `omarchy_ui run` or `omarchy_ui bundle` |
57
- | Omarchy plugin | Bar widgets, panels, and shell-integrated tools | `bar_widget` and `panel` | `omarchy_ui validate` and `omarchy_ui push` |
56
+ | Omarchy application | Full desktop tools and standalone interfaces | `app :main` | `omarchy_ui new NAME`, then `run` or `bundle` |
57
+ | Omarchy plugin | Bar widgets, panels, and shell-integrated tools | `bar_widget` and `panel` | `omarchy_ui new NAME --plugin`, then `push` or `bundle` |
58
58
  | Portable Zui application | The same application UI outside Omarchy | `app :main` | The platform-neutral [Zui toolchain](https://github.com/AdamMusa/zui#developer-workflow) |
59
59
 
60
60
  ## Requirements
61
61
 
62
- - Omarchy on x86-64 Linux;
63
- - Ruby 3.1 or newer while installing the gem and developing applications;
64
- - optional Qt modules for components that use features such as Quick 3D or Multimedia.
62
+ - Omarchy on x86-64 Linux
63
+ - Ruby 3.1 or newer for installation and development
64
+ - Qt 6 development tools, CMake, Ninja, and a C++ compiler for `bundle`, `validate`, and `push`
65
+ - Optional Qt modules for components that use features such as Quick 3D or Multimedia
65
66
 
66
- The finished Omarchy bundle includes the Omarchy adapter, mruby executable, and a Zui-generated QML
67
- runtime tree-shaken to the components referenced by that application. The destination does not need
68
- Ruby or framework gems. A component fails with an explicit error when its required Qt module or
69
- resource is unavailable; Omarchy UI never substitutes a different component.
67
+ Omarchy UI turns Ruby and assets into an Omarchy-compatible application or plugin. It owns the
68
+ metadata, entry points, adapter, validation, tree-shaken and AOT-compiled Zui QML, and embedded mruby
69
+ runtime. Omarchy UI invokes the build tools; projects do not contain CMake files or distribution
70
+ scripts. The finished package does not require Ruby or framework gems. Components report a clear
71
+ error when a required Qt module or resource is unavailable.
70
72
 
71
73
  ## Quick start
72
74
 
@@ -78,20 +80,36 @@ gem install omarchy-ui
78
80
 
79
81
  The gem installs the compatible Zui version as a dependency.
80
82
 
81
- ### 2. Generate an application
83
+ ### 2. Generate a project
82
84
 
83
85
  ```bash
84
86
  omarchy_ui new Counter
85
87
  cd counter
86
88
  ```
87
89
 
90
+ For an integrated Omarchy plugin instead:
91
+
92
+ ```bash
93
+ omarchy_ui new "System Status" --plugin
94
+ cd system-status
95
+ ```
96
+
97
+ Both commands create a ready-to-run Ruby project. Developers write the interface and behavior;
98
+ Omarchy UI generates the Omarchy package without application-owned QML, shell entry points, or
99
+ handwritten manifests.
100
+
101
+ Each project includes a Ruby-native `config.rb` with its type, identity, version, entry point, and
102
+ plugin metadata. `bundle`, `validate`, and `push` use this single configuration.
103
+
88
104
  The generator creates a small, host-independent project:
89
105
 
90
106
  ```text
91
107
  counter/
108
+ ├── LICENSE
92
109
  ├── app.rb # reusable Zui application
93
110
  ├── components/
94
111
  │ └── welcome.rb # application-owned Ruby UI module
112
+ ├── config.rb # project identity and build settings
95
113
  ├── main.rb # Omarchy launcher
96
114
  └── README.md
97
115
  ```
@@ -102,8 +120,8 @@ counter/
102
120
  omarchy_ui run main.rb
103
121
  ```
104
122
 
105
- `run` bundles the project's Ruby entry point, asks Zui to tree-shake the QML runtime, prepares the Omarchy host,
106
- then launches it through Quickshell.
123
+ `run` bundles the project's Ruby entry point, asks Zui to tree-shake the QML runtime, prepares the
124
+ Omarchy host, and launches it through Quickshell.
107
125
 
108
126
  ## Application model
109
127
 
@@ -228,48 +246,17 @@ QML is not required.
228
246
 
229
247
  ## Omarchy plugins
230
248
 
231
- A Ruby-powered plugin is an ordinary Omarchy plugin repository with `manifest.json` and `main.rb`
232
- at its root. Omarchy UI adds the generated service, panel, bar-widget, framework, and runtime files
233
- while staging the plugin; do not commit generated copies to the application source.
249
+ Generate a plugin project with one command:
234
250
 
235
- ```text
236
- system-status/
237
- ├── manifest.json
238
- ├── main.rb
239
- └── assets/
251
+ ```bash
252
+ omarchy_ui new "System Status" --plugin
253
+ cd system-status
240
254
  ```
241
255
 
242
- ### Manifest
243
-
244
- This complete manifest declares the adapter surfaces used by the Ruby definition below:
245
-
246
- ```json
247
- {
248
- "schemaVersion": 1,
249
- "id": "example.system-status",
250
- "name": "System Status",
251
- "version": "0.1.0",
252
- "author": "Example Author",
253
- "license": "MIT",
254
- "description": "A Ruby-powered status panel for Omarchy.",
255
- "kinds": ["service", "bar-widget", "panel"],
256
- "keepLoaded": true,
257
- "entryPoints": {
258
- "service": "Service.qml",
259
- "barWidget": "BarWidget.qml",
260
- "panel": "Panel.qml"
261
- },
262
- "barWidget": {
263
- "displayName": "System Status",
264
- "description": "Open the current system status.",
265
- "category": "System",
266
- "allowMultiple": false,
267
- "defaultSection": "right"
268
- }
269
- }
270
- ```
256
+ Developers work in Ruby and add their own assets. Omarchy UI supplies the plugin metadata, shell
257
+ surfaces, native host, tree-shaken Zui package, validation, and installation lifecycle.
271
258
 
272
- ### Ruby entry point
259
+ The Ruby entry point uses ordinary Zui components:
273
260
 
274
261
  ```ruby
275
262
  # main.rb
@@ -294,36 +281,26 @@ OmarchyUI.plugin do
294
281
  end
295
282
  ```
296
283
 
297
- `bar_widget` declares the shell-owned bar surface. `panel :status` declares the panel opened by
298
- `open_panel :status`. The manifest must declare the corresponding entry points.
284
+ `bar_widget` and `panel :status` are the developer-facing surface declarations. Omarchy UI maps them
285
+ to the required shell entry points when it builds or stages the plugin.
299
286
 
300
287
  ### Validate and install
301
288
 
302
289
  ```bash
303
- omarchy_ui validate path/to/system-status
304
- omarchy_ui push path/to/system-status
290
+ omarchy_ui validate
291
+ omarchy_ui push
305
292
  ```
306
293
 
307
- `validate` checks a fully staged copy, including the bundled Ruby entry point and generated adapter.
308
- `push` then:
309
-
310
- 1. stages and validates the complete plugin;
311
- 2. backs up an existing installation when present;
312
- 3. installs it atomically under `~/.config/omarchy/plugins/<plugin-id>`;
313
- 4. enables the plugin; and
314
- 5. restarts the Omarchy shell.
294
+ `validate` generates and checks the plugin without modifying the Ruby project. `push` builds,
295
+ validates, installs, enables, and reloads it for development.
315
296
 
316
297
  During development, suppress either lifecycle action when needed:
317
298
 
318
299
  ```bash
319
- omarchy_ui push path/to/system-status --no-enable
320
- omarchy_ui push path/to/system-status --no-restart
300
+ omarchy_ui push --no-enable
301
+ omarchy_ui push --no-restart
321
302
  ```
322
303
 
323
- > [!WARNING]
324
- > Omarchy plugins execute as unsandboxed user code inside the shell environment. Review the Ruby,
325
- > assets, manifest, generated package, and provenance before installing a third-party plugin.
326
-
327
304
  ## Command-line reference
328
305
 
329
306
  ```text
@@ -332,10 +309,11 @@ omarchy_ui <command> [arguments]
332
309
 
333
310
  | Command | Arguments | Result |
334
311
  | --- | --- | --- |
335
- | `omarchy_ui new NAME` | One application name | Creates a new application directory using a URL-safe form of the name |
312
+ | `omarchy_ui new NAME` | One project name | Creates an Omarchy application project |
313
+ | `omarchy_ui new NAME --plugin` | One project name and `--plugin` | Creates an Omarchy plugin project |
336
314
  | `omarchy_ui run FILE` | One Ruby entry point | Runs an application through the Omarchy/Quickshell host |
337
- | `omarchy_ui bundle [DIRECTORY]` | Zero or one project directory | Creates `dist/<project-name>` with the application, Omarchy adapter, runtime, and tree-shaken Zui QML |
338
- | `omarchy_ui validate [DIRECTORY]` | Zero or one plugin directory | Stages the complete plugin and delegates validation to `omarchy plugin validate` |
315
+ | `omarchy_ui bundle [DIRECTORY]` | Zero or one project directory | Generates the distributable package and prints its location |
316
+ | `omarchy_ui validate [DIRECTORY]` | Zero or one plugin directory | Stages the plugin and delegates validation to `omarchy plugin validate` |
339
317
  | `omarchy_ui push [DIRECTORY]` | Optional `--no-enable` and `--no-restart` | Atomically installs and activates a validated plugin |
340
318
  | `omarchy_ui version` | None | Prints the installed Omarchy UI version |
341
319
 
@@ -344,38 +322,29 @@ not forward arbitrary Ruby arguments.
344
322
 
345
323
  ## Packaging
346
324
 
347
- Create a distributable application from the project root:
325
+ Create the distributable package from the project root:
348
326
 
349
327
  ```bash
350
328
  omarchy_ui bundle
351
- ./dist/my-application/run
352
329
  ```
353
330
 
354
- The bundle contains:
331
+ Omarchy UI assembles the Ruby source and assets, Omarchy entry points, native host, checksummed
332
+ runtime, and only the Zui components used by the project. It then AOT-compiles the generated QML into
333
+ a content-addressed Qt module and discards the generated component, control, theme, and font source
334
+ tree. Applications receive a direct launcher; plugins must pass Omarchy validation before the
335
+ command succeeds. Omarchy UI pins Zui 0.0.10 so the compiled UI matches the Zui core embedded in the
336
+ deterministic mruby runtime.
355
337
 
356
- ```text
357
- dist/my-application/
358
- ├── main.rb # bundled application source
359
- ├── assets/ # application-owned resources
360
- ├── App.qml # Omarchy host entry point
361
- ├── Service.qml # process and surface bridge
362
- ├── Panel.qml # shell panel adapter
363
- ├── BarWidget.qml # shell bar adapter
364
- ├── ControlNode.qml # generated Zui protocol renderer
365
- ├── Components/Builtins/ # only component adapters selected by Zui
366
- ├── Controls/ Theme/ Fonts/ # generated renderer dependencies
367
- ├── zui-tree-shake.json # auditable selected-component report
368
- ├── Commons@ Ui@ # links to Omarchy's shell modules
369
- ├── omarchy-ui-runtime # embedded mruby executable
370
- ├── omarchy-ui-runtime.sha256
371
- ├── RUNTIME_PROVENANCE.md
372
- └── run
373
- ```
338
+ Current Omarchy manifests resolve entry points by filename, so a package retains four minimal loader
339
+ files: `App.qml`, `Service.qml`, `Panel.qml`, and `BarWidget.qml`. They contain only an import and a
340
+ compiled type instance; the application interface is stored in native `.so` artifacts. The package
341
+ also includes `omarchy-ui-qml-bundle.json` with the module URI and exact Qt version, plus
342
+ `omarchy-ui-qml-bundle.sha256` for artifact verification. `QML_PROVENANCE.md` explains the native
343
+ artifacts for package reviewers. Compiled packages should be built against the Qt version shipped by
344
+ their target Omarchy release.
374
345
 
375
- Plugin bundles use the same runtime package and are validated against their manifest before the
376
- bundle command succeeds. Generated packages exclude `.git`, previous `dist` output, and stale
377
- adapter/runtime files before regenerating the minimal QML package. Omarchy UI pins Zui 0.0.10 so the
378
- tree-shaken QML exactly matches the Zui core embedded in the deterministic mruby runtime.
346
+ The source `config.rb` is the build configuration. Omarchy UI generates plugin manifests from it;
347
+ developers never maintain a separate manifest.
379
348
 
380
349
  ## Component catalog
381
350
 
@@ -442,10 +411,12 @@ Zui DSL · state · events · bindings · scheduler
442
411
  │
443
412
  │ versioned JSON render, patch, event, and effect protocol
444
413
  ▼
445
- Omarchy Service.qml process bridge ───── embedded mruby runtime
414
+ Omarchy process bridge ───────────────── embedded mruby runtime
446
415
  │
447
416
  ▼
448
- Zui ControlNode + native component catalog
417
+ Tree-shaken Zui host + component catalog
418
+ │
419
+ │ bundle: Qt AOT module + four loader shims
449
420
  │
450
421
  ▼
451
422
  App window · Omarchy panel · Omarchy bar widget
@@ -463,8 +434,9 @@ The dependency direction is deliberate:
463
434
  - **Applications own** Ruby source, assets, state, behavior, and reusable UI modules.
464
435
 
465
436
  At bundle, validation, and push time, Omarchy UI asks Zui to analyze the bundled Ruby syntax and
466
- generate only the required component adapters plus their renderer dependencies. The four Omarchy
467
- host files then replace Zui's generic desktop host. Read the full [QML ownership and adapter
437
+ generate only the required component adapters plus their renderer dependencies. Omarchy UI overlays
438
+ its host, compiles the complete generated QML graph into a content-addressed Qt module, and emits the
439
+ minimal loader files required by Omarchy. Read the full [QML ownership and adapter
468
440
  boundary](docs/qml-support.md).
469
441
 
470
442
  ## Runtime integrity
@@ -504,11 +476,11 @@ bodies are never recorded. Per-plugin logs rotate at 128 KiB under
504
476
  | `Ruby file not found` | Pass an existing entry point to `omarchy_ui run`, normally `main.rb` |
505
477
  | `Omarchy QML module not found` | Confirm the Omarchy shell installation provides `/usr/share/omarchy/shell/Commons` and `Ui` |
506
478
  | A component reports a missing module | Install or enable the Qt module required by that declared component |
507
- | Plugin validation fails | Run `omarchy_ui validate` and correct the manifest or staged project error before pushing |
479
+ | Plugin validation fails | Run `omarchy_ui validate` and correct the reported Ruby, asset, or generated-metadata error before pushing |
508
480
  | A pushed plugin is installed but inactive | Run `omarchy plugin enable <id>` and `omarchy restart shell` after resolving the reported shell error |
509
- | The bundle destination already exists | Review or move the existing `dist/<project-name>` directory before bundling again |
481
+ | The bundle destination already exists | Review or move the previously generated package before bundling again |
510
482
 
511
- Omarchy UI reports unsupported properties, components, events, paths, manifests, and runtime
483
+ Omarchy UI reports unsupported properties, components, events, paths, generated metadata, and runtime
512
484
  resources directly. Preserve the original error when opening an issue.
513
485
 
514
486
  ## Development
@@ -27,7 +27,7 @@ module OmarchyUI
27
27
  when "validate" then validate(arguments)
28
28
  when "version", "--version", "-v" then @out.puts(OmarchyUI::VERSION); 0
29
29
  else
30
- @err.puts("Usage: omarchy_ui <new NAME|run FILE|bundle [DIRECTORY]|push [DIRECTORY]|validate [DIRECTORY]|version>")
30
+ @err.puts("Usage: omarchy_ui <new NAME [--plugin]|run FILE|bundle [DIRECTORY]|push [DIRECTORY]|validate [DIRECTORY]|version>")
31
31
  command.nil? ? 0 : 64
32
32
  end
33
33
  rescue Interrupt
@@ -67,14 +67,30 @@ module OmarchyUI
67
67
  end
68
68
 
69
69
  def new_project(arguments)
70
+ plugin = !arguments.delete("--plugin").nil?
70
71
  name = arguments.shift || raise(ArgumentError, "new requires a project name")
71
- raise ArgumentError, "new accepts only an application name" unless arguments.empty?
72
+ raise ArgumentError, "new accepts a name and optional --plugin" unless arguments.empty?
72
73
  destination = File.expand_path(slug(name))
73
- Generator.new(path: destination, name: name).create
74
- @out.puts("Created standalone Omarchy UI app in #{destination}")
74
+ kind = plugin ? :plugin : :application
75
+ Generator.new(path: destination, name: name, kind:, author: default_author).create
76
+ label = plugin ? "Omarchy-compliant plugin" : "self-contained Omarchy application"
77
+ @out.puts("Created #{label} in #{destination}")
75
78
  0
76
79
  end
77
80
 
81
+ def default_author
82
+ configured = ENV["OMARCHY_UI_AUTHOR"].to_s.strip
83
+ return configured unless configured.empty?
84
+
85
+ output, _error, status = Open3.capture3("git", "config", "--get", "user.name")
86
+ git_author = output.to_s.strip
87
+ return git_author if status.success? && !git_author.empty?
88
+
89
+ ENV["USER"].to_s.strip.then { |user| user.empty? ? "Omarchy UI Developer" : user }
90
+ rescue SystemCallError
91
+ "Omarchy UI Developer"
92
+ end
93
+
78
94
  def slug(value)
79
95
  result = value.to_s.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-|\-\z/, "")
80
96
  raise ArgumentError, "name must contain letters or numbers" if result.empty?
@@ -83,6 +99,10 @@ module OmarchyUI
83
99
 
84
100
  def validate(arguments)
85
101
  source = File.expand_path(arguments.shift || Dir.pwd)
102
+ raise ArgumentError, "validate accepts one directory" unless arguments.empty?
103
+ config = ProjectConfig.load(source)
104
+ raise ArgumentError, "validate requires a plugin project" if config&.application?
105
+
86
106
  with_staged_project(source) do |staging|
87
107
  system("omarchy", "plugin", "validate", staging) ? 0 : 1
88
108
  end
@@ -91,15 +111,22 @@ module OmarchyUI
91
111
  def bundle_project(arguments)
92
112
  source = File.expand_path(arguments.shift || Dir.pwd)
93
113
  raise ArgumentError, "bundle accepts one directory" unless arguments.empty?
94
- raise ArgumentError, "main.rb not found: #{source}" unless File.file?(File.join(source, "main.rb"))
95
- destination = File.join(source, "dist", File.basename(source))
114
+ config = ProjectConfig.load(source)
115
+ entrypoint = config&.entrypoint || "main.rb"
116
+ raise ArgumentError, "#{entrypoint} not found: #{source}" unless File.file?(File.join(source, entrypoint))
117
+ precompiled = config.nil? && compiled_package?(source)
118
+ destination = File.join(source, "dist", config&.slug || File.basename(source))
96
119
  raise ArgumentError, "bundle destination already exists: #{destination}" if File.exist?(destination)
97
120
  FileUtils.mkdir_p(destination)
98
- entries = application_entries(source)
121
+ entries = precompiled ? package_entries(source) : application_entries(source, config:)
99
122
  FileUtils.cp_r(entries.map { |entry| File.join(source, entry) }, destination)
100
- bundle_ruby_entrypoint(source, destination)
101
- Runtime.install_package(destination)
102
- if File.file?(File.join(destination, "manifest.json"))
123
+ unless precompiled
124
+ config.write_manifest(destination) if config&.plugin?
125
+ bundle_ruby_entrypoint(source, destination, entrypoint:)
126
+ Runtime.install_package(destination, compiled: !config.nil?)
127
+ end
128
+ plugin = config ? config.plugin? : File.file?(File.join(destination, "manifest.json"))
129
+ if plugin
103
130
  raise ArgumentError, "bundled plugin validation failed" unless system("omarchy", "plugin", "validate", destination)
104
131
  @out.puts("Bundled plugin in #{destination}")
105
132
  return 0
@@ -115,7 +142,9 @@ module OmarchyUI
115
142
  app_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
116
143
  export OMARCHY_UI_RUNTIME="$app_dir/omarchy-ui-runtime"
117
144
  export OMARCHY_UI_PROJECT_DIR="$app_dir"
118
- export OMARCHY_UI_RUBY_PROGRAM="$app_dir/main.rb"
145
+ export OMARCHY_UI_RUBY_PROGRAM="$app_dir/#{entrypoint}"
146
+ export QML2_IMPORT_PATH="$app_dir${QML2_IMPORT_PATH:+:$QML2_IMPORT_PATH}"
147
+ export QML_IMPORT_PATH="$app_dir${QML_IMPORT_PATH:+:$QML_IMPORT_PATH}"
119
148
  export QT_LOGGING_RULES="${QT_LOGGING_RULES:+$QT_LOGGING_RULES;}qt.qpa.services.warning=false"
120
149
  exec quickshell --path "$app_dir/App.qml"
121
150
  SH
@@ -131,17 +160,33 @@ module OmarchyUI
131
160
  [ENV["QT_LOGGING_RULES"], "qt.qpa.services.warning=false"].compact.reject(&:empty?).join(";")
132
161
  end
133
162
 
134
- def application_entries(source)
163
+ def application_entries(source, config: ProjectConfig.load(source))
135
164
  generated = Runtime::GENERATED_ENTRIES + Runtime::AUDIT_FILES + %w[run Commons Ui]
165
+ generated.concat([ProjectConfig::FILE_NAME, "manifest.json"]) if config
136
166
  Dir.children(source).reject { |entry| %w[.git dist].include?(entry) || generated.include?(entry) }
137
167
  end
138
168
 
169
+ def package_entries(source)
170
+ Dir.children(source).reject { |entry| %w[.git dist].include?(entry) }
171
+ end
172
+
173
+ def compiled_package?(source)
174
+ report = File.join(source, QmlCompiler::REPORT)
175
+ return false unless File.file?(report)
176
+
177
+ QmlCompiler.verify!(source)
178
+ true
179
+ end
180
+
139
181
  def push(arguments)
140
182
  enable = !arguments.delete("--no-enable")
141
183
  restart = !arguments.delete("--no-restart")
142
184
  source = File.expand_path(arguments.shift || Dir.pwd)
185
+ raise ArgumentError, "push accepts one directory" unless arguments.empty?
186
+ config = ProjectConfig.load(source)
187
+ raise ArgumentError, "push requires a plugin project" if config&.application?
143
188
  manifest_path = File.join(source, "manifest.json")
144
- manifest = JSON.parse(File.read(manifest_path))
189
+ manifest = config ? config.manifest : JSON.parse(File.read(manifest_path))
145
190
  plugin_id = manifest.fetch("id")
146
191
  raise ArgumentError, "invalid plugin id" unless VALID_ID.match?(plugin_id)
147
192
  plugin_root = File.expand_path("~/.config/omarchy/plugins")
@@ -184,12 +229,18 @@ module OmarchyUI
184
229
 
185
230
  def stage_project(source, parent: nil, prefix: ".omarchy-ui-staging-")
186
231
  raise ArgumentError, "project directory not found: #{source}" unless File.directory?(source)
232
+ config = ProjectConfig.load(source)
233
+ precompiled = config.nil? && compiled_package?(source)
187
234
  staging = parent ? Dir.mktmpdir(prefix, parent) : Dir.mktmpdir(prefix)
188
- entries = application_entries(source)
235
+ entries = precompiled ? package_entries(source) : application_entries(source, config:)
189
236
  FileUtils.cp_r(entries.map { |entry| File.join(source, entry) }, staging) unless entries.empty?
190
- if File.file?(File.join(staging, "main.rb"))
191
- bundle_ruby_entrypoint(source, staging)
192
- Runtime.install_package(staging)
237
+ unless precompiled
238
+ config.write_manifest(staging) if config&.plugin?
239
+ entrypoint = config&.entrypoint || "main.rb"
240
+ if File.file?(File.join(staging, entrypoint))
241
+ bundle_ruby_entrypoint(source, staging, entrypoint:)
242
+ Runtime.install_package(staging, compiled: !config.nil?)
243
+ end
193
244
  end
194
245
  staging
195
246
  rescue StandardError
@@ -197,9 +248,11 @@ module OmarchyUI
197
248
  raise
198
249
  end
199
250
 
200
- def bundle_ruby_entrypoint(source, destination)
201
- entrypoint = File.join(source, "main.rb")
202
- File.write(File.join(destination, "main.rb"), SourceBundle.new(entrypoint, root: source).call)
251
+ def bundle_ruby_entrypoint(source, destination, entrypoint: "main.rb")
252
+ source_entrypoint = File.join(source, entrypoint)
253
+ destination_entrypoint = File.join(destination, entrypoint)
254
+ FileUtils.mkdir_p(File.dirname(destination_entrypoint))
255
+ File.write(destination_entrypoint, SourceBundle.new(source_entrypoint, root: source).call)
203
256
  end
204
257
 
205
258
  def activate_plugin(plugin_id)
@@ -4,9 +4,15 @@ require "fileutils"
4
4
 
5
5
  module OmarchyUI
6
6
  class Generator
7
- def initialize(path:, name: nil)
7
+ PROJECT_KINDS = %i[application plugin].freeze
8
+
9
+ def initialize(path:, name: nil, kind: :application, plugin_id: nil, author: nil)
8
10
  @path = File.expand_path(path)
9
11
  @name = name || File.basename(@path).split(/[-_]/).map(&:capitalize).join(" ")
12
+ @kind = kind.to_sym
13
+ @plugin_id = plugin_id || "local.#{project_slug}"
14
+ @author = author.to_s.strip.empty? ? "Omarchy UI Developer" : author.to_s.strip
15
+ raise ArgumentError, "unsupported project kind: #{@kind}" unless PROJECT_KINDS.include?(@kind)
10
16
  end
11
17
 
12
18
  def create
@@ -14,9 +20,11 @@ module OmarchyUI
14
20
  created = true
15
21
  FileUtils.mkdir_p(File.join(@path, "components"))
16
22
  File.write(File.join(@path, "main.rb"), main_program)
17
- File.write(File.join(@path, "app.rb"), application_program)
23
+ File.write(File.join(@path, "app.rb"), application_program) unless plugin?
24
+ File.write(File.join(@path, ProjectConfig::FILE_NAME), project_config)
18
25
  File.write(File.join(@path, "components", "welcome.rb"), welcome_component)
19
26
  File.write(File.join(@path, "README.md"), readme)
27
+ File.write(File.join(@path, "LICENSE"), license)
20
28
  @path
21
29
  rescue StandardError
22
30
  FileUtils.remove_entry(@path) if created && File.directory?(@path)
@@ -26,6 +34,8 @@ module OmarchyUI
26
34
  private
27
35
 
28
36
  def main_program
37
+ return plugin_program if plugin?
38
+
29
39
  <<~RUBY
30
40
  # frozen_string_literal: true
31
41
 
@@ -36,6 +46,33 @@ module OmarchyUI
36
46
  RUBY
37
47
  end
38
48
 
49
+ def plugin_program
50
+ <<~RUBY
51
+ # frozen_string_literal: true
52
+
53
+ require "omarchy_ui"
54
+ require_relative "components/welcome"
55
+
56
+ OmarchyUI.plugin(ui: #{constant_name}::UI) do
57
+ bar_widget do
58
+ row spacing: 7 do
59
+ text "#{@name}"
60
+ end
61
+ on_click { open_panel :#{surface_name} }
62
+ end
63
+
64
+ panel :#{surface_name} do
65
+ scroll width: 560, height: 620 do
66
+ welcome_card(
67
+ title: "#{@name}",
68
+ message: "Your Omarchy plugin is ready. Build the interface with Ruby and Zui components."
69
+ )
70
+ end
71
+ end
72
+ end
73
+ RUBY
74
+ end
75
+
39
76
  def application_program
40
77
  <<~RUBY
41
78
  # frozen_string_literal: true
@@ -80,6 +117,8 @@ module OmarchyUI
80
117
  end
81
118
 
82
119
  def readme
120
+ return plugin_readme if plugin?
121
+
83
122
  <<~MARKDOWN
84
123
  # #{@name}
85
124
 
@@ -92,10 +131,98 @@ module OmarchyUI
92
131
  ```
93
132
 
94
133
  Reusable Ruby UI components live in `components/` and load with ordinary Ruby
95
- `require_relative`. Run `omarchy_ui bundle` to create a self-contained distribution.
134
+ `require_relative`. Project identity and build settings live in `config.rb`.
135
+
136
+ ## Build
137
+
138
+ ```bash
139
+ omarchy_ui bundle
140
+ ```
141
+
142
+ Omarchy UI tree-shakes and compiles the UI, then generates the complete self-contained
143
+ application package required by Omarchy.
144
+ MARKDOWN
145
+ end
146
+
147
+ def plugin_readme
148
+ <<~MARKDOWN
149
+ # #{@name}
150
+
151
+ A pure Ruby Omarchy plugin built with Omarchy UI and Zui.
152
+
153
+ ## Validate
154
+
155
+ ```bash
156
+ omarchy_ui validate
157
+ ```
158
+
159
+ ## Install for development
160
+
161
+ ```bash
162
+ omarchy_ui push
163
+ ```
164
+
165
+ ## Build
166
+
167
+ ```bash
168
+ omarchy_ui bundle
169
+ ```
170
+
171
+ Write the plugin interface and behavior in Ruby under `main.rb` and `components/`.
172
+ Public plugin identity and release metadata live in `config.rb`. Omarchy UI tree-shakes and
173
+ compiles the UI, then generates and validates the complete Omarchy-compatible plugin package.
96
174
  MARKDOWN
97
175
  end
98
176
 
177
+ def project_config
178
+ config = ProjectConfig.generate(
179
+ name: @name,
180
+ slug: project_slug,
181
+ kind: @kind,
182
+ author: @author,
183
+ plugin_id: @plugin_id
184
+ )
185
+ config
186
+ end
187
+
188
+ def license
189
+ <<~TEXT
190
+ MIT License
191
+
192
+ Copyright (c) #{Time.now.year} #{@author}
193
+
194
+ Permission is hereby granted, free of charge, to any person obtaining a copy
195
+ of this software and associated documentation files (the "Software"), to deal
196
+ in the Software without restriction, including without limitation the rights
197
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
198
+ copies of the Software, and to permit persons to whom the Software is
199
+ furnished to do so, subject to the following conditions:
200
+
201
+ The above copyright notice and this permission notice shall be included in all
202
+ copies or substantial portions of the Software.
203
+
204
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
205
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
206
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
207
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
208
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
209
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
210
+ SOFTWARE.
211
+ TEXT
212
+ end
213
+
214
+ def plugin?
215
+ @kind == :plugin
216
+ end
217
+
218
+ def project_slug
219
+ File.basename(@path).downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-|\-\z/, "")
220
+ end
221
+
222
+ def surface_name
223
+ project_slug.tr("-", "_")
224
+ end
225
+
99
226
  def constant_name
100
227
  @name.gsub(/[^a-zA-Z0-9]+/, " ").split.map(&:capitalize).join.then do |name|
101
228
  name.empty? || name.match?(/\A\d/) ? "OmarchyApplication" : name
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module OmarchyUI
6
+ class ProjectConfig
7
+ FILE_NAME = "config.rb"
8
+ PROJECT_TYPES = %i[application plugin].freeze
9
+ SLUG = /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/
10
+ SECTIONS = %i[left center right].freeze
11
+
12
+ class Builder
13
+ FIELDS = %i[type id name slug version author license description entrypoint].freeze
14
+
15
+ FIELDS.each do |field|
16
+ define_method(field) { |value| @values[field] = value }
17
+ end
18
+
19
+ def initialize
20
+ @values = {}
21
+ end
22
+
23
+ def bar_widget(&definition)
24
+ widget = WidgetBuilder.new
25
+ widget.instance_eval(&definition)
26
+ @values[:bar_widget] = widget.to_h
27
+ end
28
+
29
+ def build(path: FILE_NAME)
30
+ ProjectConfig.new(@values, path:).tap(&:validate!)
31
+ end
32
+ end
33
+
34
+ class WidgetBuilder
35
+ FIELDS = %i[display_name description category default_section].freeze
36
+
37
+ FIELDS.each do |field|
38
+ define_method(field) { |value| @values[field] = value }
39
+ end
40
+
41
+ def initialize
42
+ @values = {}
43
+ end
44
+
45
+ def to_h
46
+ @values.dup
47
+ end
48
+ end
49
+
50
+ attr_reader :path
51
+
52
+ def self.define(&definition)
53
+ raise ArgumentError, "project config requires a block" unless definition
54
+
55
+ Builder.new.tap { |builder| builder.instance_eval(&definition) }.build
56
+ end
57
+
58
+ def self.generate(name:, slug:, kind:, author:, plugin_id: nil)
59
+ type = kind.to_sym
60
+ raise ArgumentError, "unsupported project type: #{type}" unless PROJECT_TYPES.include?(type)
61
+
62
+ lines = [
63
+ "# frozen_string_literal: true",
64
+ "",
65
+ "OmarchyUI.configure do",
66
+ " type #{type.inspect}"
67
+ ]
68
+ lines << " id #{(plugin_id || "local.#{slug}").inspect}" if type == :plugin
69
+ lines.concat([
70
+ " name #{name.inspect}",
71
+ " slug #{slug.inspect}",
72
+ " version \"0.1.0\""
73
+ ])
74
+ if type == :plugin
75
+ lines.concat([
76
+ " author #{author.inspect}",
77
+ " license \"MIT\"",
78
+ " description #{"A Ruby-powered #{name} plugin generated by Omarchy UI.".inspect}",
79
+ " entrypoint \"main.rb\"",
80
+ "",
81
+ " bar_widget do",
82
+ " display_name #{name.inspect}",
83
+ " description #{"Open #{name}.".inspect}",
84
+ " category \"System\"",
85
+ " default_section :right",
86
+ " end"
87
+ ])
88
+ else
89
+ lines << " entrypoint \"main.rb\""
90
+ end
91
+ lines << "end"
92
+ lines.join("\n") + "\n"
93
+ end
94
+
95
+ def self.load(root)
96
+ path = File.join(File.expand_path(root), FILE_NAME)
97
+ return nil unless File.file?(path)
98
+
99
+ value = Module.new.module_eval(File.read(path), path, 1)
100
+ unless value.is_a?(ProjectConfig)
101
+ raise ArgumentError, "#{FILE_NAME} must return an OmarchyUI::ProjectConfig"
102
+ end
103
+
104
+ new(value.to_h, path:).tap(&:validate!)
105
+ rescue SyntaxError, NameError, NoMethodError => error
106
+ raise ArgumentError, "invalid #{FILE_NAME}: #{error.message}"
107
+ end
108
+
109
+ def initialize(data, path:)
110
+ @data = data
111
+ @path = path
112
+ end
113
+
114
+ def type
115
+ value = @data[:type]
116
+ value.respond_to?(:to_sym) ? value.to_sym : value
117
+ end
118
+
119
+ def application? = type == :application
120
+ def plugin? = type == :plugin
121
+ def name = @data.fetch(:name)
122
+ def slug = @data.fetch(:slug)
123
+ def entrypoint = @data.fetch(:entrypoint)
124
+
125
+ def to_h
126
+ @data.dup
127
+ end
128
+
129
+ def manifest
130
+ raise ArgumentError, "project config describes an application, not a plugin" unless plugin?
131
+
132
+ widget = @data.fetch(:bar_widget)
133
+ {
134
+ "schemaVersion" => 1,
135
+ "id" => @data.fetch(:id),
136
+ "name" => name,
137
+ "version" => @data.fetch(:version),
138
+ "author" => @data.fetch(:author),
139
+ "license" => @data.fetch(:license),
140
+ "description" => @data.fetch(:description),
141
+ "kinds" => ["service", "bar-widget", "panel"],
142
+ "keepLoaded" => true,
143
+ "entryPoints" => {
144
+ "service" => "Service.qml",
145
+ "barWidget" => "BarWidget.qml",
146
+ "panel" => "Panel.qml"
147
+ },
148
+ "barWidget" => {
149
+ "displayName" => widget.fetch(:display_name),
150
+ "description" => widget.fetch(:description),
151
+ "category" => widget.fetch(:category),
152
+ "allowMultiple" => false,
153
+ "defaultSection" => widget.fetch(:default_section).to_s
154
+ }
155
+ }
156
+ end
157
+
158
+ def write_manifest(destination)
159
+ File.write(File.join(destination, "manifest.json"), JSON.pretty_generate(manifest) + "\n")
160
+ end
161
+
162
+ def validate!
163
+ raise ArgumentError, "invalid project config" unless @data.is_a?(Hash)
164
+ raise ArgumentError, "invalid project type" unless PROJECT_TYPES.include?(type)
165
+ required_string!(:name)
166
+ required_string!(:slug)
167
+ raise ArgumentError, "invalid project slug" unless SLUG.match?(@data[:slug])
168
+ required_string!(:version)
169
+ required_string!(:entrypoint)
170
+ validate_entrypoint!
171
+ validate_plugin! if plugin?
172
+ self
173
+ end
174
+
175
+ private
176
+
177
+ def required_string!(key, object = @data)
178
+ value = object[key]
179
+ raise ArgumentError, "invalid project config field: #{key}" unless value.is_a?(String) && !value.strip.empty?
180
+
181
+ value
182
+ end
183
+
184
+ def validate_entrypoint!
185
+ parts = entrypoint.split(/[\\\/]/)
186
+ if entrypoint.start_with?("/") || parts.empty? || parts.include?("..") || !entrypoint.end_with?(".rb")
187
+ raise ArgumentError, "invalid project entrypoint"
188
+ end
189
+ end
190
+
191
+ def validate_plugin!
192
+ %i[id author license description].each { |key| required_string!(key) }
193
+ raise ArgumentError, "invalid plugin id" unless VALID_ID.match?(@data[:id])
194
+
195
+ widget = @data[:bar_widget]
196
+ raise ArgumentError, "plugin project config is missing bar widget metadata" unless widget.is_a?(Hash)
197
+
198
+ %i[display_name description category].each { |key| required_string!(key, widget) }
199
+ section = widget[:default_section]
200
+ valid_section = section.respond_to?(:to_sym) && SECTIONS.include?(section.to_sym)
201
+ raise ArgumentError, "invalid default bar section" unless valid_section
202
+ end
203
+ end
204
+
205
+ def self.configure(&definition)
206
+ ProjectConfig.define(&definition)
207
+ end
208
+ end
@@ -0,0 +1,386 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "json"
6
+ require "open3"
7
+ require "pathname"
8
+ require "tmpdir"
9
+
10
+ module OmarchyUI
11
+ class QmlCompiler
12
+ FORMAT_VERSION = 1
13
+ ENTRY_TYPES = {
14
+ "App.qml" => "App",
15
+ "BarWidget.qml" => "BarWidget",
16
+ "Panel.qml" => "Panel",
17
+ "Service.qml" => "Service"
18
+ }.freeze
19
+ SOURCE_ENTRIES = (ENTRY_TYPES.keys + %w[ControlNode.qml Components Controls Theme Fonts]).freeze
20
+ COMPILED_ROOT = "OmarchyUI"
21
+ REPORT = "omarchy-ui-qml-bundle.json"
22
+ CHECKSUM = "omarchy-ui-qml-bundle.sha256"
23
+ PROVENANCE = "QML_PROVENANCE.md"
24
+
25
+ def self.compile!(path)
26
+ new(path).compile!
27
+ end
28
+
29
+ def self.verify!(path)
30
+ new(path).verify!
31
+ end
32
+
33
+ def initialize(path)
34
+ @path = File.expand_path(path)
35
+ end
36
+
37
+ def compile!
38
+ validate_sources!
39
+ tools = build_tools
40
+ qt_version = qt_version!(tools.fetch(:qtpaths))
41
+
42
+ Dir.mktmpdir("omarchy-ui-qml-compile-") do |temporary|
43
+ source = File.join(temporary, "source")
44
+ build = File.join(temporary, "build")
45
+ stage_sources(source)
46
+ qml_files = relative_files(source, "**/*.qml")
47
+ resources = relative_files(source, "Fonts/**/*") +
48
+ %w[Controls/qmldir Theme/qmldir].select { |entry| File.file?(File.join(source, entry)) }
49
+ fingerprint = source_fingerprint(source, qml_files + resources, qt_version:)
50
+ segment = "B#{fingerprint[0, 20]}"
51
+ uri = "OmarchyUI.Bundles.#{segment}"
52
+ target = "omarchy_ui_bundle_#{segment.downcase}"
53
+ module_path = File.join(*uri.split("."))
54
+
55
+ File.write(File.join(source, "CMakeLists.txt"), cmake_project(
56
+ qml_files:,
57
+ resources: resources.sort,
58
+ uri:,
59
+ target:,
60
+ module_path:
61
+ ))
62
+ configure!(tools.fetch(:qt_cmake), source, build)
63
+ build!(tools.fetch(:cmake), build)
64
+ deploy!(
65
+ build:,
66
+ module_path:,
67
+ target:,
68
+ uri:,
69
+ fingerprint:,
70
+ qml_files:,
71
+ resources:,
72
+ source:,
73
+ expected_qt_version: qt_version
74
+ )
75
+ end
76
+ end
77
+
78
+ def verify!
79
+ report_path = File.join(@path, REPORT)
80
+ raise ArgumentError, "compiled QML report is missing: #{report_path}" unless File.file?(report_path)
81
+
82
+ report = JSON.parse(File.read(report_path))
83
+ unless report["format"] == "qt-aot-qml-module" && report["format_version"] == FORMAT_VERSION
84
+ raise ArgumentError, "unsupported compiled QML package format"
85
+ end
86
+ ENTRY_TYPES.each_key do |entry|
87
+ raise ArgumentError, "compiled QML entry shim is missing: #{entry}" unless File.file?(File.join(@path, entry))
88
+ end
89
+ module_path = safe_relative_path(report.fetch("module_path"))
90
+ raise ArgumentError, "compiled QML qmldir is missing" unless File.file?(File.join(@path, module_path, "qmldir"))
91
+
92
+ artifacts = report.fetch("artifacts")
93
+ raise ArgumentError, "compiled QML package must contain two libraries" unless artifacts.length == 2
94
+ artifacts.each do |artifact|
95
+ relative = safe_relative_path(artifact.fetch("path"))
96
+ unless relative.start_with?("#{module_path}/") && relative.end_with?(".so")
97
+ raise ArgumentError, "compiled QML artifact is outside its module: #{relative}"
98
+ end
99
+ absolute = File.join(@path, relative)
100
+ raise ArgumentError, "compiled QML artifact is missing: #{relative}" unless File.file?(absolute)
101
+ unless File.size(absolute) == artifact.fetch("bytes") &&
102
+ Digest::SHA256.file(absolute).hexdigest == artifact.fetch("sha256")
103
+ raise ArgumentError, "compiled QML artifact checksum mismatch: #{relative}"
104
+ end
105
+ end
106
+ report
107
+ rescue JSON::ParserError, KeyError, TypeError => error
108
+ raise ArgumentError, "invalid compiled QML report: #{error.message}"
109
+ end
110
+
111
+ private
112
+
113
+ def safe_relative_path(path)
114
+ value = path.to_s
115
+ clean = File.expand_path(value, @path)
116
+ unless !value.empty? && !Pathname.new(value).absolute? && clean.start_with?("#{@path}/")
117
+ raise ArgumentError, "unsafe compiled QML path: #{value.inspect}"
118
+ end
119
+ clean.delete_prefix("#{@path}/")
120
+ end
121
+
122
+ def validate_sources!
123
+ missing = ENTRY_TYPES.keys.reject { |entry| File.file?(File.join(@path, entry)) }
124
+ missing << "ControlNode.qml" unless File.file?(File.join(@path, "ControlNode.qml"))
125
+ return if missing.empty?
126
+
127
+ raise ArgumentError, "cannot compile QML; generated files are missing: #{missing.join(', ')}"
128
+ end
129
+
130
+ def build_tools
131
+ {
132
+ qt_cmake: find_executable("/usr/lib/qt6/bin/qt-cmake", "qt-cmake"),
133
+ qtpaths: find_executable("/usr/lib/qt6/bin/qtpaths", "qtpaths6", "qtpaths"),
134
+ cmake: find_executable("cmake"),
135
+ ninja: find_executable("ninja"),
136
+ compiler: find_executable("c++", "g++", "clang++")
137
+ }
138
+ end
139
+
140
+ def qt_version!(qtpaths)
141
+ output, error, status = Open3.capture3(qtpaths, "--query", "QT_VERSION")
142
+ version = output.strip
143
+ return version if status.success? && !version.empty?
144
+
145
+ detail = error.strip
146
+ raise ArgumentError, "could not determine the Qt build version#{detail.empty? ? '' : ": #{detail}"}"
147
+ end
148
+
149
+ def find_executable(*candidates)
150
+ candidates.each do |candidate|
151
+ if candidate.include?(File::SEPARATOR)
152
+ return candidate if File.file?(candidate) && File.executable?(candidate)
153
+ next
154
+ end
155
+
156
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |directory|
157
+ path = File.join(directory, candidate)
158
+ return path if File.file?(path) && File.executable?(path)
159
+ end
160
+ end
161
+ raise ArgumentError, "compiled QML packaging requires #{candidates.first}"
162
+ end
163
+
164
+ def stage_sources(destination)
165
+ FileUtils.mkdir_p(destination)
166
+ SOURCE_ENTRIES.each do |entry|
167
+ source = File.join(@path, entry)
168
+ next unless File.exist?(source)
169
+
170
+ FileUtils.cp_r(source, File.join(destination, entry))
171
+ end
172
+ end
173
+
174
+ def relative_files(root, pattern)
175
+ Dir.glob(File.join(root, pattern), File::FNM_DOTMATCH)
176
+ .select { |path| File.file?(path) }
177
+ .map { |path| path.delete_prefix("#{root}/") }
178
+ .sort
179
+ end
180
+
181
+ def source_fingerprint(root, files, qt_version:)
182
+ digest = Digest::SHA256.new
183
+ digest << "omarchy-ui-qml-bundle\0" << FORMAT_VERSION.to_s << "\0"
184
+ digest << "qt\0" << qt_version << "\0"
185
+ files.sort.each do |relative|
186
+ digest << relative << "\0" << File.binread(File.join(root, relative)) << "\0"
187
+ end
188
+ digest.hexdigest
189
+ end
190
+
191
+ def cmake_project(qml_files:, resources:, uri:, target:, module_path:)
192
+ <<~CMAKE
193
+ cmake_minimum_required(VERSION 3.21)
194
+
195
+ project(OmarchyUICompiledBundle LANGUAGES CXX)
196
+
197
+ find_package(Qt6 6.8 REQUIRED COMPONENTS Core Qml Quick QuickControls2)
198
+ qt_standard_project_setup(REQUIRES 6.8)
199
+
200
+ qt_add_library(#{target} SHARED)
201
+ qt_add_qml_module(#{target}
202
+ URI #{uri}
203
+ VERSION 1.0
204
+ OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/#{module_path}"
205
+ QML_FILES
206
+ #{cmake_entries(qml_files)}
207
+ RESOURCES
208
+ #{cmake_entries(resources)}
209
+ DISCARD_QML_CONTENTS
210
+ NO_LINT
211
+ )
212
+
213
+ set(module_output "${CMAKE_BINARY_DIR}/#{module_path}")
214
+ set_target_properties(
215
+ #{target}
216
+ #{target}plugin
217
+ PROPERTIES
218
+ LIBRARY_OUTPUT_DIRECTORY "${module_output}"
219
+ BUILD_RPATH "\\$ORIGIN"
220
+ BUILD_WITH_INSTALL_RPATH TRUE
221
+ INSTALL_RPATH "\\$ORIGIN"
222
+ )
223
+
224
+ target_compile_options(
225
+ #{target}
226
+ PRIVATE
227
+ "-ffile-prefix-map=${CMAKE_CURRENT_SOURCE_DIR}=."
228
+ "-ffile-prefix-map=${CMAKE_CURRENT_BINARY_DIR}=."
229
+ )
230
+ target_compile_options(
231
+ #{target}plugin
232
+ PRIVATE
233
+ "-ffile-prefix-map=${CMAKE_CURRENT_SOURCE_DIR}=."
234
+ "-ffile-prefix-map=${CMAKE_CURRENT_BINARY_DIR}=."
235
+ )
236
+ target_link_options(#{target} PRIVATE "-Wl,--build-id=none")
237
+ target_link_options(#{target}plugin PRIVATE "-Wl,--build-id=none")
238
+ target_link_libraries(
239
+ #{target}
240
+ PRIVATE Qt6::Core Qt6::Qml Qt6::Quick Qt6::QuickControls2
241
+ )
242
+
243
+ file(WRITE "${CMAKE_BINARY_DIR}/qt-version.txt" "${Qt6Core_VERSION}\n")
244
+ CMAKE
245
+ end
246
+
247
+ def cmake_entries(entries)
248
+ entries.map { |entry| " #{cmake_string(entry)}" }.join("\n")
249
+ end
250
+
251
+ def cmake_string(value)
252
+ escaped = value.gsub("\\", "\\\\").gsub('"', '\\"')
253
+ %Q("#{escaped}")
254
+ end
255
+
256
+ def configure!(qt_cmake, source, build)
257
+ run_build_command!(
258
+ "QML configure",
259
+ qt_cmake,
260
+ "-S", source,
261
+ "-B", build,
262
+ "-G", "Ninja",
263
+ "-DCMAKE_BUILD_TYPE=Release"
264
+ )
265
+ end
266
+
267
+ def build!(cmake, build)
268
+ run_build_command!("QML compilation", cmake, "--build", build, "--parallel")
269
+ end
270
+
271
+ def run_build_command!(label, *command)
272
+ environment = {
273
+ "LC_ALL" => "C.UTF-8",
274
+ "SOURCE_DATE_EPOCH" => "1",
275
+ "TZ" => "UTC"
276
+ }
277
+ output, error, status = Open3.capture3(environment, *command)
278
+ return if status.success?
279
+
280
+ detail = "#{output}\n#{error}".lines.last(40).join.strip
281
+ raise ArgumentError, "#{label} failed#{detail.empty? ? '' : ":\n#{detail}"}"
282
+ end
283
+
284
+ def deploy!(build:, module_path:, target:, uri:, fingerprint:, qml_files:, resources:, source:,
285
+ expected_qt_version:)
286
+ built_module = File.join(build, module_path)
287
+ artifact_names = ["lib#{target}.so", "lib#{target}plugin.so", "qmldir"]
288
+ missing = artifact_names.reject { |name| File.file?(File.join(built_module, name)) }
289
+ unless missing.empty?
290
+ raise ArgumentError, "QML compilation did not produce: #{missing.join(', ')}"
291
+ end
292
+ qt_version = File.read(File.join(build, "qt-version.txt")).strip
293
+ unless qt_version == expected_qt_version
294
+ raise ArgumentError,
295
+ "Qt compiler mismatch: qtpaths reported #{expected_qt_version}, CMake found #{qt_version}"
296
+ end
297
+
298
+ compiled_root = File.join(@path, COMPILED_ROOT)
299
+ FileUtils.remove_entry(compiled_root) if File.directory?(compiled_root)
300
+ destination = File.join(@path, module_path)
301
+ FileUtils.mkdir_p(destination)
302
+ artifact_names.each { |name| FileUtils.cp(File.join(built_module, name), destination) }
303
+ qmldir = File.join(destination, "qmldir")
304
+ File.write(qmldir, File.read(qmldir).rstrip + "\n")
305
+
306
+ SOURCE_ENTRIES.each { |entry| remove_generated(File.join(@path, entry)) }
307
+ ENTRY_TYPES.each do |entry, type|
308
+ File.write(File.join(@path, entry), entry_shim(module_path, type, uri:))
309
+ end
310
+
311
+ artifact_paths = artifact_names.filter_map do |name|
312
+ next if name == "qmldir"
313
+
314
+ relative = File.join(module_path, name)
315
+ absolute = File.join(@path, relative)
316
+ {
317
+ "path" => relative,
318
+ "sha256" => Digest::SHA256.file(absolute).hexdigest,
319
+ "bytes" => File.size(absolute)
320
+ }
321
+ end
322
+ report = {
323
+ "format" => "qt-aot-qml-module",
324
+ "format_version" => FORMAT_VERSION,
325
+ "qt_version" => qt_version,
326
+ "module_uri" => uri,
327
+ "module_path" => module_path,
328
+ "source_fingerprint" => fingerprint,
329
+ "source_files" => qml_files.length,
330
+ "source_bytes" => (qml_files + resources).sum { |file| File.size(File.join(source, file)) },
331
+ "entry_shims" => ENTRY_TYPES.keys,
332
+ "artifacts" => artifact_paths
333
+ }
334
+ File.write(File.join(@path, REPORT), JSON.pretty_generate(report) + "\n")
335
+ checksums = artifact_paths.map { |artifact| "#{artifact.fetch('sha256')} #{artifact.fetch('path')}" }
336
+ File.write(File.join(@path, CHECKSUM), checksums.join("\n") + "\n")
337
+ File.write(File.join(@path, PROVENANCE), provenance(report))
338
+ report
339
+ end
340
+
341
+ def remove_generated(path)
342
+ File.directory?(path) ? FileUtils.remove_entry(path) : FileUtils.rm_f(path)
343
+ end
344
+
345
+ def entry_shim(module_path, type, uri:)
346
+ import = type == "App" ? uri : %Q("#{module_path}")
347
+ <<~QML
348
+ import QtQuick
349
+ import #{import} as Compiled
350
+
351
+ Compiled.#{type} {}
352
+ QML
353
+ end
354
+
355
+ def provenance(report)
356
+ artifacts = report.fetch("artifacts").map do |artifact|
357
+ "- `#{artifact.fetch('path')}` — `#{artifact.fetch('sha256')}`"
358
+ end.join("\n")
359
+ <<~MARKDOWN
360
+ # Compiled QML provenance
361
+
362
+ Omarchy UI generated this package's native Qt module from the tree-shaken Zui and
363
+ Omarchy host QML graph. Generated QML source contents were discarded after AOT compilation.
364
+
365
+ - Format: `#{report.fetch('format')}` version #{report.fetch('format_version')}
366
+ - Qt: `#{report.fetch('qt_version')}`
367
+ - Module: `#{report.fetch('module_uri')}`
368
+ - Source fingerprint: `#{report.fetch('source_fingerprint')}`
369
+
370
+ ## Artifacts
371
+
372
+ #{artifacts}
373
+
374
+ Verify the packaged libraries from the plugin directory:
375
+
376
+ ```bash
377
+ sha256sum --check #{CHECKSUM}
378
+ ```
379
+
380
+ `App.qml`, `Service.qml`, `Panel.qml`, and `BarWidget.qml` are the minimal loader shims
381
+ required by Omarchy's file-based entry-point contract. Application UI lives in the compiled
382
+ module recorded by `#{REPORT}`.
383
+ MARKDOWN
384
+ end
385
+ end
386
+ end
@@ -16,8 +16,14 @@ module OmarchyUI
16
16
  AUDIT_FILES = %w[
17
17
  omarchy-ui-runtime.sha256 runtime-provenance.json RUNTIME_PROVENANCE.md
18
18
  ].freeze
19
- GENERATED_ENTRIES = (ADAPTER_FILES + ZUI_GENERATED_ENTRIES +
20
- [TREE_SHAKE_REPORT, "omarchy-ui-runtime"]).freeze
19
+ GENERATED_ENTRIES = (ADAPTER_FILES + ZUI_GENERATED_ENTRIES + [
20
+ TREE_SHAKE_REPORT,
21
+ QmlCompiler::COMPILED_ROOT,
22
+ QmlCompiler::REPORT,
23
+ QmlCompiler::CHECKSUM,
24
+ QmlCompiler::PROVENANCE,
25
+ "omarchy-ui-runtime"
26
+ ]).freeze
21
27
 
22
28
  def executable
23
29
  override = ENV["OMARCHY_UI_RUNTIME"]
@@ -39,7 +45,7 @@ module OmarchyUI
39
45
  FileUtils.rm_f(temporary) if temporary && File.exist?(temporary)
40
46
  end
41
47
 
42
- def install_package(path, framework_root: FRAMEWORK_ROOT, project: path)
48
+ def install_package(path, framework_root: FRAMEWORK_ROOT, project: path, compiled: false)
43
49
  FileUtils.mkdir_p(path)
44
50
  GENERATED_ENTRIES.each do |entry|
45
51
  generated = File.join(path, entry)
@@ -67,6 +73,7 @@ module OmarchyUI
67
73
  source = File.join(framework_root, "vendor", "runtime", "x86_64-linux", file)
68
74
  FileUtils.cp(source, File.join(path, file)) if File.file?(source)
69
75
  end
76
+ QmlCompiler.compile!(path) if compiled
70
77
  path
71
78
  end
72
79
 
data/lib/omarchy_ui.rb CHANGED
@@ -11,7 +11,7 @@ rescue LoadError
11
11
  end
12
12
 
13
13
  module OmarchyUI
14
- VERSION = "0.0.7"
14
+ VERSION = "0.0.9"
15
15
  ZUI_VERSION = Zui::VERSION
16
16
  FRAMEWORK_ROOT = File.expand_path("..", __dir__)
17
17
 
@@ -126,5 +126,7 @@ module Zui
126
126
  end
127
127
  end
128
128
 
129
+ require_relative "omarchy_ui/project_config"
129
130
  require_relative "omarchy_ui/generator"
131
+ require_relative "omarchy_ui/qml_compiler"
130
132
  require_relative "omarchy_ui/runtime"
@@ -17,4 +17,4 @@ The workflow checks out every external input at a full commit digest, performs t
17
17
  builds, requires byte-identical executables, and signs the resulting SHA through GitHub's
18
18
  artifact attestation service. Machine-readable provenance is included in
19
19
  `runtime-provenance.json`; independent verification instructions are in
20
- [`docs/runtime-build.md`](../../../docs/runtime-build.md).
20
+ the [runtime build documentation](https://github.com/AdamMusa/omarchy-ui/blob/main/docs/runtime-build.md).
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.7
4
+ version: 0.0.9
5
5
  platform: x86_64-linux
6
6
  authors:
7
7
  - Adam Moussa Ali
@@ -39,6 +39,8 @@ files:
39
39
  - lib/omarchy_ui.rb
40
40
  - lib/omarchy_ui/cli.rb
41
41
  - lib/omarchy_ui/generator.rb
42
+ - lib/omarchy_ui/project_config.rb
43
+ - lib/omarchy_ui/qml_compiler.rb
42
44
  - lib/omarchy_ui/runtime.rb
43
45
  - vendor/runtime/x86_64-linux/RUNTIME_PROVENANCE.md
44
46
  - vendor/runtime/x86_64-linux/omarchy-ui-runtime