omarchy-ui 0.0.9-x86_64-linux → 0.0.10-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: 82c12f3f51c0d9242be7386a4994c4f3fe8b333d04489e4055b671054cb2f0e2
4
- data.tar.gz: e693d5b68a5ad055b40fbb910b4bb6ef41b6de7405027396305f256ad744875d
3
+ metadata.gz: ba33f1f0b7fead5be0f82726faeeb3815b94386f679732ee4945d840374a1023
4
+ data.tar.gz: bc7bfb798a46491d22085e3f81cbde27da3b9f14149b1ec63bd3e36b182f83c7
5
5
  SHA512:
6
- metadata.gz: 807cb5e30c33130681351065bc7fce902a7c4c125f1c6b47e8827db0ec6b49695745ab60d61da9475153f8abf1422940df280154af797ede39ef218a9097c449
7
- data.tar.gz: c2f3532f53a5725e7c6e7cd9de667ebbb2dddd086204af8172f8b262c010e4fef2470b73716a7496c9859d396f43b22814e03926949944e2e1c080e1e64ca5a2
6
+ metadata.gz: c76b00784fdf4a0371269d79d570e10ece613faf94449f2e40025a7a5a7a45b53080b259a7a68cedc7e21c89cc03e628cc3e3301c04db806145db19cc2b0d6ed
7
+ data.tar.gz: f4511e5523b4a033be1d80682305d99e0502721186cf8330d3c4d10e332bf0e85233d96f1b59840c54f7679e34abe4b8018b170357170b1bff6e1e38f4f697e6
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.9" src="https://img.shields.io/badge/Omarchy_UI-0.0.9-b7ff5a?style=flat-square&labelColor=111711">
13
+ <img alt="Omarchy UI 0.0.10" src="https://img.shields.io/badge/Omarchy_UI-0.0.10-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">
@@ -294,6 +294,19 @@ omarchy_ui push
294
294
  `validate` generates and checks the plugin without modifying the Ruby project. `push` builds,
295
295
  validates, installs, enables, and reloads it for development.
296
296
 
297
+ To publish, target a separate public distribution repository. Omarchy UI rebuilds the thin package,
298
+ enforces the marketplace contract, pushes it, and prints the complete submission for review:
299
+
300
+ ```bash
301
+ omarchy_ui publish --repo YOUR_GITHUB_NAME/PLUGIN_REPOSITORY \
302
+ --category System --tags system,quickshell --create
303
+ ```
304
+
305
+ After reviewing the printed title, metadata, and five marketplace attestations, rerun the same
306
+ command with `--submit`. It creates a submission or refreshes an existing open one. This two-step
307
+ flow prevents accidental replacement of the source repository and ensures the ownership statement
308
+ is reviewed before it is posted.
309
+
297
310
  During development, suppress either lifecycle action when needed:
298
311
 
299
312
  ```bash
@@ -310,9 +323,10 @@ omarchy_ui <command> [arguments]
310
323
  | Command | Arguments | Result |
311
324
  | --- | --- | --- |
312
325
  | `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 |
326
+ | `omarchy_ui new NAME --plugin --github USER` | One project name, `--plugin`, and optional GitHub user | Creates a marketplace-ready Omarchy plugin project |
314
327
  | `omarchy_ui run FILE` | One Ruby entry point | Runs an application through the Omarchy/Quickshell host |
315
328
  | `omarchy_ui bundle [DIRECTORY]` | Zero or one project directory | Generates the distributable package and prints its location |
329
+ | `omarchy_ui publish [DIRECTORY]` | Required `--repo`, `--category`, `--tags`; optional `--create`, `--submit` | Builds and pushes a thin distribution repository, then previews or submits its marketplace issue |
316
330
  | `omarchy_ui validate [DIRECTORY]` | Zero or one plugin directory | Stages the plugin and delegates validation to `omarchy plugin validate` |
317
331
  | `omarchy_ui push [DIRECTORY]` | Optional `--no-enable` and `--no-restart` | Atomically installs and activates a validated plugin |
318
332
  | `omarchy_ui version` | None | Prints the installed Omarchy UI version |
@@ -328,20 +342,20 @@ Create the distributable package from the project root:
328
342
  omarchy_ui bundle
329
343
  ```
330
344
 
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.
337
-
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.
345
+ Omarchy UI assembles one bundled Ruby program and required assets, native runtime, manifest, and only
346
+ the Zui components used by the project. It AOT-compiles the generated QML into a content-addressed Qt
347
+ module, then discards the generated component, control, theme, and font source tree. Applications
348
+ receive a direct launcher; plugins must pass Omarchy validation before the command succeeds. Omarchy
349
+ UI pins Zui 0.0.10 so the compiled UI matches the Zui core embedded in the deterministic mruby
350
+ runtime.
351
+
352
+ Current Omarchy entry points resolve by filename, so a package retains only the required generated
353
+ loader shims. Applications keep `App.qml`; standard plugins keep `Service.qml`, `Panel.qml`, and
354
+ `BarWidget.qml`. Each shim contains only an import and a compiled type instance. The application
355
+ interface is authored in Ruby and stored in native `.so` artifacts. Distributable packages do not
356
+ include generated QML source, build reports, audit/provenance folders, checksums, tests, or CI files.
357
+ They also omit `config.rb` and Ruby files already folded into `main.rb`.
358
+ Compiled packages should be built against the Qt version shipped by their target Omarchy release.
345
359
 
346
360
  The source `config.rb` is the build configuration. Omarchy UI generates plugin manifests from it;
347
361
  developers never maintain a separate manifest.
@@ -416,7 +430,7 @@ Omarchy process bridge ───────────────── embed
416
430
 
417
431
  Tree-shaken Zui host + component catalog
418
432
 
419
- │ bundle: Qt AOT module + four loader shims
433
+ │ bundle: Qt AOT module + required loader shims
420
434
 
421
435
 
422
436
  App window · Omarchy panel · Omarchy bar widget
data/Service.qml CHANGED
@@ -499,4 +499,27 @@ Item {
499
499
  repeat: false
500
500
  onTriggered: root.startRuby()
501
501
  }
502
+
503
+ // A desktop surface is rendered by the same long-lived service that owns
504
+ // the Ruby runtime. Its DesktopStage adapter creates the layer-shell window;
505
+ // plugins keep all scene structure, state, and interaction in Ruby.
506
+ ControlNode {
507
+ id: desktopRenderer
508
+ visible: root.ready && root.rootId("desktop") !== ""
509
+ bridge: root
510
+ surfaceName: "desktop"
511
+ controlId: root.rootId(surfaceName)
512
+ }
513
+
514
+ IpcHandler {
515
+ target: root.manifest && root.manifest.id ? String(root.manifest.id) : "omarchy-ui"
516
+
517
+ function playPause(): string {
518
+ var desktopId = root.rootId("desktop")
519
+ if (desktopId === "") return "not-ready"
520
+ return root.sendEvent("desktop", desktopId, "shortcut", { name: "play_pause" })
521
+ ? "ok"
522
+ : "not-ready"
523
+ }
524
+ }
502
525
  }
@@ -23,11 +23,12 @@ module OmarchyUI
23
23
  when "run" then run_file(arguments)
24
24
  when "new" then new_project(arguments)
25
25
  when "bundle" then bundle_project(arguments)
26
+ when "publish" then publish(arguments)
26
27
  when "push" then push(arguments)
27
28
  when "validate" then validate(arguments)
28
29
  when "version", "--version", "-v" then @out.puts(OmarchyUI::VERSION); 0
29
30
  else
30
- @err.puts("Usage: omarchy_ui <new NAME [--plugin]|run FILE|bundle [DIRECTORY]|push [DIRECTORY]|validate [DIRECTORY]|version>")
31
+ @err.puts("Usage: omarchy_ui <new NAME [--plugin] [--github USER]|run FILE|bundle [DIRECTORY]|publish [DIRECTORY] --repo OWNER/REPO --category CATEGORY --tags TAGS [--create] [--submit]|push [DIRECTORY]|validate [DIRECTORY]|version>")
31
32
  command.nil? ? 0 : 64
32
33
  end
33
34
  rescue Interrupt
@@ -68,11 +69,14 @@ module OmarchyUI
68
69
 
69
70
  def new_project(arguments)
70
71
  plugin = !arguments.delete("--plugin").nil?
72
+ github_user = extract_option!(arguments, "--github")
71
73
  name = arguments.shift || raise(ArgumentError, "new requires a project name")
72
- raise ArgumentError, "new accepts a name and optional --plugin" unless arguments.empty?
74
+ raise ArgumentError, "new accepts a name, --plugin, and --github USER" unless arguments.empty?
75
+ raise ArgumentError, "--github is only valid with --plugin" if github_user && !plugin
73
76
  destination = File.expand_path(slug(name))
74
77
  kind = plugin ? :plugin : :application
75
- Generator.new(path: destination, name: name, kind:, author: default_author).create
78
+ github_user ||= default_github_user if plugin
79
+ Generator.new(path: destination, name: name, kind:, author: default_author, github_user:).create
76
80
  label = plugin ? "Omarchy-compliant plugin" : "self-contained Omarchy application"
77
81
  @out.puts("Created #{label} in #{destination}")
78
82
  0
@@ -91,6 +95,30 @@ module OmarchyUI
91
95
  "Omarchy UI Developer"
92
96
  end
93
97
 
98
+ def default_github_user
99
+ configured = ENV["OMARCHY_UI_GITHUB_USER"].to_s.strip
100
+ return configured unless configured.empty?
101
+
102
+ output, _error, status = Open3.capture3("gh", "api", "user", "--jq", ".login")
103
+ login = output.to_s.strip
104
+ return login if status.success? && !login.empty?
105
+
106
+ raise ArgumentError,
107
+ "plugin generation needs --github USER or OMARCHY_UI_GITHUB_USER (gh auth login can provide it automatically)"
108
+ rescue Errno::ENOENT
109
+ raise ArgumentError, "plugin generation needs --github USER or OMARCHY_UI_GITHUB_USER"
110
+ end
111
+
112
+ def extract_option!(arguments, name)
113
+ index = arguments.index(name)
114
+ return nil unless index
115
+
116
+ value = arguments[index + 1]
117
+ raise ArgumentError, "#{name} requires a value" if value.nil? || value.start_with?("--")
118
+ arguments.slice!(index, 2)
119
+ value
120
+ end
121
+
94
122
  def slug(value)
95
123
  result = value.to_s.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-|\-\z/, "")
96
124
  raise ArgumentError, "name must contain letters or numbers" if result.empty?
@@ -123,10 +151,12 @@ module OmarchyUI
123
151
  unless precompiled
124
152
  config.write_manifest(destination) if config&.plugin?
125
153
  bundle_ruby_entrypoint(source, destination, entrypoint:)
126
- Runtime.install_package(destination, compiled: !config.nil?)
154
+ Runtime.install_package(destination, compiled: true)
127
155
  end
156
+ prune_bundled_ruby_sources(destination, entrypoint:)
128
157
  plugin = config ? config.plugin? : File.file?(File.join(destination, "manifest.json"))
129
158
  if plugin
159
+ PluginPackage.validate!(destination)
130
160
  raise ArgumentError, "bundled plugin validation failed" unless system("omarchy", "plugin", "validate", destination)
131
161
  @out.puts("Bundled plugin in #{destination}")
132
162
  return 0
@@ -161,23 +191,56 @@ module OmarchyUI
161
191
  end
162
192
 
163
193
  def application_entries(source, config: ProjectConfig.load(source))
164
- generated = Runtime::GENERATED_ENTRIES + Runtime::AUDIT_FILES + %w[run Commons Ui]
194
+ generated = Runtime::GENERATED_ENTRIES + %w[run Commons Ui]
165
195
  generated.concat([ProjectConfig::FILE_NAME, "manifest.json"]) if config
166
- Dir.children(source).reject { |entry| %w[.git dist].include?(entry) || generated.include?(entry) }
196
+ Dir.children(source).reject do |entry|
197
+ (PluginPackage::DEVELOPMENT_ENTRIES + PluginPackage::SOURCE_CONTROL_ENTRIES).include?(entry) ||
198
+ generated.include?(entry)
199
+ end
167
200
  end
168
201
 
169
202
  def package_entries(source)
170
- Dir.children(source).reject { |entry| %w[.git dist].include?(entry) }
203
+ excluded = PluginPackage::DEVELOPMENT_ENTRIES + PluginPackage::SOURCE_CONTROL_ENTRIES +
204
+ Runtime::LEGACY_METADATA_FILES
205
+ Dir.children(source).reject { |entry| excluded.include?(entry) }
171
206
  end
172
207
 
173
208
  def compiled_package?(source)
174
- report = File.join(source, QmlCompiler::REPORT)
175
- return false unless File.file?(report)
209
+ compiled_root = File.join(source, QmlCompiler::COMPILED_ROOT, "Bundles")
210
+ shims = QmlCompiler::ENTRY_TYPES.keys.any? { |entry| File.file?(File.join(source, entry)) }
211
+ return false unless File.directory?(compiled_root) && shims
176
212
 
177
213
  QmlCompiler.verify!(source)
178
214
  true
179
215
  end
180
216
 
217
+ def publish(arguments)
218
+ repository = extract_option!(arguments, "--repo")
219
+ category = extract_option!(arguments, "--category")
220
+ tags = extract_option!(arguments, "--tags").to_s.split(",")
221
+ create = !arguments.delete("--create").nil?
222
+ submit = !arguments.delete("--submit").nil?
223
+ source = File.expand_path(arguments.shift || Dir.pwd)
224
+ raise ArgumentError, "publish accepts one directory and publish options" unless arguments.empty?
225
+ config = ProjectConfig.load(source)
226
+ raise ArgumentError, "publish requires an Omarchy UI plugin project" unless config&.plugin?
227
+
228
+ with_staged_project(source) do |staging|
229
+ raise ArgumentError, "staged plugin validation failed" unless system("omarchy", "plugin", "validate", staging)
230
+ PluginPublisher.publish!(
231
+ package: staging,
232
+ source:,
233
+ repository:,
234
+ category:,
235
+ tags:,
236
+ create:,
237
+ submit:,
238
+ out: @out
239
+ )
240
+ end
241
+ 0
242
+ end
243
+
181
244
  def push(arguments)
182
245
  enable = !arguments.delete("--no-enable")
183
246
  restart = !arguments.delete("--no-restart")
@@ -239,9 +302,11 @@ module OmarchyUI
239
302
  entrypoint = config&.entrypoint || "main.rb"
240
303
  if File.file?(File.join(staging, entrypoint))
241
304
  bundle_ruby_entrypoint(source, staging, entrypoint:)
242
- Runtime.install_package(staging, compiled: !config.nil?)
305
+ Runtime.install_package(staging, compiled: true)
243
306
  end
244
307
  end
308
+ prune_bundled_ruby_sources(staging, entrypoint: "main.rb") if File.file?(File.join(staging, "manifest.json"))
309
+ PluginPackage.validate!(staging) if File.file?(File.join(staging, "manifest.json"))
245
310
  staging
246
311
  rescue StandardError
247
312
  FileUtils.remove_entry(staging) if staging && File.exist?(staging)
@@ -255,6 +320,17 @@ module OmarchyUI
255
320
  File.write(destination_entrypoint, SourceBundle.new(source_entrypoint, root: source).call)
256
321
  end
257
322
 
323
+ def prune_bundled_ruby_sources(destination, entrypoint: "main.rb")
324
+ kept = File.expand_path(entrypoint, destination)
325
+ Dir.glob(File.join(destination, "**", "*.rb"), File::FNM_DOTMATCH).each do |path|
326
+ FileUtils.rm_f(path) unless File.expand_path(path) == kept
327
+ end
328
+ directories = Dir.glob(File.join(destination, "**", "*"), File::FNM_DOTMATCH)
329
+ .select { |path| File.directory?(path) && !File.symlink?(path) }
330
+ .sort_by { |path| -path.count(File::SEPARATOR) }
331
+ directories.each { |path| Dir.rmdir(path) if Dir.empty?(path) }
332
+ end
333
+
258
334
  def activate_plugin(plugin_id)
259
335
  return if system("omarchy", "plugin", "enable", plugin_id)
260
336
  system("omarchy-shell", "shell", "rescanPlugins")
@@ -5,12 +5,17 @@ require "fileutils"
5
5
  module OmarchyUI
6
6
  class Generator
7
7
  PROJECT_KINDS = %i[application plugin].freeze
8
+ GITHUB_USER = /\A(?!-)[A-Za-z0-9-]{1,39}(?<!-)\z/
8
9
 
9
- def initialize(path:, name: nil, kind: :application, plugin_id: nil, author: nil)
10
+ def initialize(path:, name: nil, kind: :application, plugin_id: nil, author: nil, github_user: nil)
10
11
  @path = File.expand_path(path)
11
12
  @name = name || File.basename(@path).split(/[-_]/).map(&:capitalize).join(" ")
12
13
  @kind = kind.to_sym
13
- @plugin_id = plugin_id || "local.#{project_slug}"
14
+ @github_user = github_user.to_s.strip
15
+ if plugin? && !GITHUB_USER.match?(@github_user)
16
+ raise ArgumentError, "plugin generation needs a valid GitHub username"
17
+ end
18
+ @plugin_id = plugin_id || "io.github.#{@github_user.downcase}.#{project_slug}"
14
19
  @author = author.to_s.strip.empty? ? "Omarchy UI Developer" : author.to_s.strip
15
20
  raise ArgumentError, "unsupported project kind: #{@kind}" unless PROJECT_KINDS.include?(@kind)
16
21
  end
@@ -124,6 +129,9 @@ module OmarchyUI
124
129
 
125
130
  A pure Ruby desktop application built with Zui and the Omarchy UI host.
126
131
 
132
+ The interface and behavior are authored entirely in Ruby. Omarchy UI generates QML only
133
+ as a temporary build input, compiles it, and ships no handwritten QML source.
134
+
127
135
  ## Run
128
136
 
129
137
  ```bash
@@ -140,7 +148,9 @@ module OmarchyUI
140
148
  ```
141
149
 
142
150
  Omarchy UI tree-shakes and compiles the UI, then generates the complete self-contained
143
- application package required by Omarchy.
151
+ application package required by Omarchy. The package contains the bundled Ruby program,
152
+ native runtime, compiled Qt module, and one generated `App.qml` loader shim—no build reports
153
+ or generated QML source tree.
144
154
  MARKDOWN
145
155
  end
146
156
 
@@ -150,6 +160,26 @@ module OmarchyUI
150
160
 
151
161
  A pure Ruby Omarchy plugin built with Omarchy UI and Zui.
152
162
 
163
+ The interface and behavior are authored entirely in Ruby. Omarchy UI generates QML only
164
+ as a temporary build input, compiles it, and ships no handwritten QML source.
165
+
166
+ ## Install
167
+
168
+ ```bash
169
+ omarchy plugin add https://github.com/#{@github_user}/#{project_slug}.git --enable
170
+ ```
171
+
172
+ ## Remove
173
+
174
+ ```bash
175
+ omarchy plugin remove #{@plugin_id}
176
+ ```
177
+
178
+ ## Dependencies
179
+
180
+ - Omarchy Quattro on x86-64 Linux
181
+ - No additional runtime dependency in the generated starter
182
+
153
183
  ## Validate
154
184
 
155
185
  ```bash
@@ -171,6 +201,9 @@ module OmarchyUI
171
201
  Write the plugin interface and behavior in Ruby under `main.rb` and `components/`.
172
202
  Public plugin identity and release metadata live in `config.rb`. Omarchy UI tree-shakes and
173
203
  compiles the UI, then generates and validates the complete Omarchy-compatible plugin package.
204
+ The package contains the bundled Ruby program, native runtime, compiled Qt module, manifest,
205
+ and only the generated loader shims named by that manifest—no build reports, audit folder,
206
+ tests, or generated QML source tree.
174
207
  MARKDOWN
175
208
  end
176
209
 
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module OmarchyUI
6
+ class PluginPackage
7
+ DEVELOPMENT_ENTRIES = %w[
8
+ .github .gitattributes .gitignore audit build config.rb dist docs qml-source spec test tests vendor
9
+ ].freeze
10
+ SOURCE_CONTROL_ENTRIES = %w[.git].freeze
11
+ README_PATTERN = /\Areadme(?:\.[^.]+)?\z/i
12
+ LICENSE_PATTERN = /\A(?:license|copying)(?:\.[^.]+)?\z/i
13
+ REQUIRED_MANIFEST_STRINGS = %w[id name version author license description].freeze
14
+
15
+ def self.validate!(path)
16
+ new(path).validate!
17
+ end
18
+
19
+ def initialize(path)
20
+ @path = File.expand_path(path)
21
+ end
22
+
23
+ def validate!
24
+ validate_root_files!
25
+ manifest = read_manifest
26
+ validate_manifest!(manifest)
27
+ validate_readme!(manifest)
28
+ validate_runtime!
29
+ validate_ruby_surface!
30
+ validate_qml_surface!(manifest)
31
+ QmlCompiler.verify!(@path)
32
+ manifest
33
+ end
34
+
35
+ private
36
+
37
+ def validate_root_files!
38
+ entries = Dir.children(@path)
39
+ forbidden = entries & (DEVELOPMENT_ENTRIES + Runtime::LEGACY_METADATA_FILES)
40
+ unless forbidden.empty?
41
+ raise ArgumentError, "plugin package contains development-only entries: #{forbidden.sort.join(', ')}"
42
+ end
43
+
44
+ manifests = Dir.glob(File.join(@path, "**", "manifest.json"), File::FNM_DOTMATCH)
45
+ .select { |path| File.file?(path) }
46
+ unless manifests == [File.join(@path, "manifest.json")]
47
+ raise ArgumentError, "plugin package must contain exactly one root manifest.json"
48
+ end
49
+
50
+ readmes = entries.grep(README_PATTERN).select { |entry| File.file?(File.join(@path, entry)) }
51
+ raise ArgumentError, "plugin package needs one root README" unless readmes.length == 1
52
+
53
+ licenses = entries.grep(LICENSE_PATTERN).select { |entry| File.file?(File.join(@path, entry)) }
54
+ raise ArgumentError, "plugin package needs one root license file" unless licenses.length == 1
55
+
56
+ symlinks = Dir.glob(File.join(@path, "**", "*"), File::FNM_DOTMATCH)
57
+ .select { |path| File.symlink?(path) }
58
+ unless symlinks.empty?
59
+ raise ArgumentError, "plugin package may not contain symbolic links"
60
+ end
61
+ end
62
+
63
+ def read_manifest
64
+ JSON.parse(File.read(File.join(@path, "manifest.json")))
65
+ rescue JSON::ParserError => error
66
+ raise ArgumentError, "invalid plugin manifest: #{error.message}"
67
+ end
68
+
69
+ def validate_manifest!(manifest)
70
+ unless manifest.is_a?(Hash) && manifest["schemaVersion"] == 1
71
+ raise ArgumentError, "plugin manifest must use schemaVersion 1"
72
+ end
73
+ REQUIRED_MANIFEST_STRINGS.each do |field|
74
+ value = manifest[field]
75
+ unless value.is_a?(String) && !value.strip.empty?
76
+ raise ArgumentError, "plugin manifest needs a non-empty #{field}"
77
+ end
78
+ end
79
+ plugin_id = manifest.fetch("id")
80
+ raise ArgumentError, "invalid plugin id" unless VALID_ID.match?(plugin_id)
81
+ if plugin_id.start_with?("omarchy.")
82
+ raise ArgumentError, "plugin id uses the reserved omarchy.* namespace"
83
+ end
84
+
85
+ entry_points = manifest["entryPoints"]
86
+ unless entry_points.is_a?(Hash) && !entry_points.empty?
87
+ raise ArgumentError, "plugin manifest needs entryPoints"
88
+ end
89
+ files = entry_points.values
90
+ unless files.all? { |file| file.is_a?(String) } && files.uniq.length == files.length
91
+ raise ArgumentError, "plugin manifest entryPoints must be unique file names"
92
+ end
93
+ unsupported = files - QmlCompiler::ENTRY_TYPES.keys
94
+ unless unsupported.empty?
95
+ raise ArgumentError, "unsupported plugin entryPoints: #{unsupported.join(', ')}"
96
+ end
97
+ end
98
+
99
+ def validate_readme!(manifest)
100
+ name = Dir.children(@path).find { |entry| README_PATTERN.match?(entry) }
101
+ readme = File.read(File.join(@path, name))
102
+ raise ArgumentError, "plugin README needs installation instructions" unless readme.match?(/install/i)
103
+ unless readme.match?(/(?:remove|uninstall)/i)
104
+ raise ArgumentError, "plugin README needs removal instructions"
105
+ end
106
+ unless readme.include?("omarchy plugin add")
107
+ raise ArgumentError, "plugin README needs an omarchy plugin add command"
108
+ end
109
+ unless readme.include?("omarchy plugin remove #{manifest.fetch('id')}")
110
+ raise ArgumentError, "plugin README removal command must use the manifest id"
111
+ end
112
+ end
113
+
114
+ def validate_runtime!
115
+ runtime = File.join(@path, "omarchy-ui-runtime")
116
+ unless File.file?(runtime) && File.executable?(runtime) && !File.symlink?(runtime)
117
+ raise ArgumentError, "plugin package needs an executable omarchy-ui-runtime"
118
+ end
119
+ end
120
+
121
+ def validate_ruby_surface!
122
+ entrypoint = File.join(@path, "main.rb")
123
+ unless File.file?(entrypoint) && !File.symlink?(entrypoint)
124
+ raise ArgumentError, "plugin package needs one bundled main.rb"
125
+ end
126
+
127
+ ruby_files = Dir.glob(File.join(@path, "**", "*.rb"), File::FNM_DOTMATCH)
128
+ .select { |path| File.file?(path) }
129
+ .map { |path| path.delete_prefix("#{@path}/") }
130
+ .sort
131
+ unless ruby_files == ["main.rb"]
132
+ raise ArgumentError, "plugin package may contain only the bundled main.rb Ruby program"
133
+ end
134
+ end
135
+
136
+ def validate_qml_surface!(manifest)
137
+ expected = manifest.fetch("entryPoints").values.sort
138
+ qml_files = Dir.glob(File.join(@path, "**", "*.qml"), File::FNM_DOTMATCH)
139
+ .select { |path| File.file?(path) }
140
+ .map { |path| path.delete_prefix("#{@path}/") }
141
+ .sort
142
+ unless qml_files == expected
143
+ raise ArgumentError, "plugin package may contain only manifest-declared generated QML shims"
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,263 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "open3"
6
+ require "tmpdir"
7
+
8
+ module OmarchyUI
9
+ class PluginPublisher
10
+ MARKETPLACE_REPOSITORY = "omacom/omarchy-plugin-marketplace"
11
+ CATEGORIES = [
12
+ "Appearance", "Desktop", "Developer Tools", "Hardware", "Kids", "Productivity", "System", "Widgets", "Other"
13
+ ].freeze
14
+ TAGS = %w[
15
+ ai bar education games hyprland kids launcher media power-management quickshell security system workspaces
16
+ ].freeze
17
+ REPOSITORY = /\A[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\z/
18
+
19
+ def self.publish!(**arguments)
20
+ new(**arguments).publish!
21
+ end
22
+
23
+ def initialize(package:, source:, repository:, category:, tags:, create: false, submit: false,
24
+ marketplace_repository: ENV.fetch("OMARCHY_UI_MARKETPLACE_REPOSITORY", MARKETPLACE_REPOSITORY),
25
+ out: $stdout)
26
+ @package = File.expand_path(package)
27
+ @source = File.expand_path(source)
28
+ @repository = repository.to_s.strip
29
+ @category = category.to_s.strip
30
+ @tags = Array(tags).map(&:to_s).map(&:strip).reject(&:empty?).uniq
31
+ @create = create
32
+ @submit = submit
33
+ @marketplace_repository = marketplace_repository.to_s.strip
34
+ @out = out
35
+ end
36
+
37
+ def publish!
38
+ validate_options!
39
+ manifest = PluginPackage.validate!(@package)
40
+ validate_install_url!
41
+ ensure_distinct_repository!
42
+ ensure_gh_authenticated!
43
+ ensure_distribution_repository!(manifest)
44
+ commit = publish_package!(manifest)
45
+ title = "[Plugin]: #{manifest.fetch('name')}"
46
+ body = submission_body(commit:)
47
+ show_submission(title, body)
48
+ if @submit
49
+ issue_url = submit_to_marketplace!(title, body)
50
+ @out.puts("Marketplace submission: #{issue_url}")
51
+ else
52
+ @out.puts("Review the submission above, then rerun with --submit to create or refresh it.")
53
+ end
54
+ @out.puts("Published #{@repository} at #{commit}")
55
+ commit
56
+ end
57
+
58
+ private
59
+
60
+ def validate_options!
61
+ raise ArgumentError, "publish requires --repo OWNER/REPOSITORY" unless REPOSITORY.match?(@repository)
62
+ raise ArgumentError, "invalid marketplace repository" unless REPOSITORY.match?(@marketplace_repository)
63
+ unless CATEGORIES.include?(@category)
64
+ raise ArgumentError, "publish category must be one of: #{CATEGORIES.join(', ')}"
65
+ end
66
+ unless (1..3).cover?(@tags.length) && (@tags - TAGS).empty?
67
+ raise ArgumentError, "publish needs one to three allowed tags: #{TAGS.join(', ')}"
68
+ end
69
+ end
70
+
71
+ def ensure_distinct_repository!
72
+ output, _error, status = capture("git", "-C", @source, "remote", "get-url", "origin")
73
+ return unless status.success?
74
+
75
+ source_repository = github_repository(output.strip)
76
+ if source_repository&.casecmp?(@repository)
77
+ raise ArgumentError,
78
+ "publish target must be a separate distribution repository; refusing to replace the source repository"
79
+ end
80
+ end
81
+
82
+ def validate_install_url!
83
+ readme_name = Dir.children(@package).find { |entry| PluginPackage::README_PATTERN.match?(entry) }
84
+ readme = File.read(File.join(@package, readme_name))
85
+ repository_url = "https://github.com/#{@repository}"
86
+ return if readme.downcase.include?(repository_url.downcase)
87
+
88
+ raise ArgumentError,
89
+ "plugin README install command must use the distribution repository #{repository_url}"
90
+ end
91
+
92
+ def ensure_gh_authenticated!
93
+ _output, error, status = capture("gh", "auth", "status")
94
+ return if status.success?
95
+
96
+ detail = error.strip.lines.last.to_s.strip
97
+ raise ArgumentError, "GitHub CLI authentication is required#{detail.empty? ? '' : ": #{detail}"}"
98
+ rescue Errno::ENOENT
99
+ raise ArgumentError, "publish requires the GitHub CLI (gh)"
100
+ end
101
+
102
+ def ensure_distribution_repository!(manifest)
103
+ output, error, status = capture(
104
+ "gh", "repo", "view", @repository,
105
+ "--json", "nameWithOwner,visibility", "--jq", "[.nameWithOwner,.visibility] | @tsv"
106
+ )
107
+ unless status.success?
108
+ unless @create
109
+ raise ArgumentError,
110
+ "distribution repository #{@repository} was not found; create it first or rerun with --create"
111
+ end
112
+ run!(
113
+ "gh", "repo", "create", @repository, "--public",
114
+ "--description", "#{manifest.fetch('name')} — an Omarchy plugin built entirely in Ruby"
115
+ )
116
+ output, error, status = capture(
117
+ "gh", "repo", "view", @repository,
118
+ "--json", "nameWithOwner,visibility", "--jq", "[.nameWithOwner,.visibility] | @tsv"
119
+ )
120
+ end
121
+ unless status.success?
122
+ raise ArgumentError, "could not inspect distribution repository: #{error.strip}"
123
+ end
124
+
125
+ _name, visibility = output.strip.split("\t", 2)
126
+ raise ArgumentError, "distribution repository must be public" unless visibility == "PUBLIC"
127
+ end
128
+
129
+ def publish_package!(manifest)
130
+ Dir.mktmpdir("omarchy-ui-publish-") do |temporary|
131
+ checkout = File.join(temporary, "repository")
132
+ run!("gh", "repo", "clone", @repository, checkout, "--", "--quiet")
133
+ guard_existing_package!(checkout, manifest)
134
+ replace_checkout!(checkout)
135
+ run!("git", "-C", checkout, "add", "-A")
136
+ _output, _error, clean = capture("git", "-C", checkout, "diff", "--cached", "--quiet")
137
+ unless clean.success?
138
+ run!(
139
+ "git", "-C", checkout, "commit", "-m",
140
+ "Publish #{manifest.fetch('name')} #{manifest.fetch('version')}"
141
+ )
142
+ run!("git", "-C", checkout, "push", "origin", "HEAD")
143
+ end
144
+ output, _error, _status = capture("git", "-C", checkout, "rev-parse", "HEAD")
145
+ output.strip
146
+ end
147
+ end
148
+
149
+ def guard_existing_package!(checkout, manifest)
150
+ entries = Dir.children(checkout) - [".git"]
151
+ return if entries.empty?
152
+
153
+ existing_manifest = File.join(checkout, "manifest.json")
154
+ unless File.file?(existing_manifest)
155
+ raise ArgumentError, "distribution repository is not empty and has no root manifest.json"
156
+ end
157
+ existing_id = JSON.parse(File.read(existing_manifest)).fetch("id")
158
+ return if existing_id == manifest.fetch("id")
159
+
160
+ raise ArgumentError,
161
+ "distribution repository contains plugin #{existing_id.inspect}, not #{manifest.fetch('id').inspect}"
162
+ rescue JSON::ParserError, KeyError => error
163
+ raise ArgumentError, "distribution repository has an invalid manifest: #{error.message}"
164
+ end
165
+
166
+ def replace_checkout!(checkout)
167
+ (Dir.children(checkout) - [".git"]).each do |entry|
168
+ FileUtils.remove_entry(File.join(checkout, entry))
169
+ end
170
+ Dir.children(@package).each do |entry|
171
+ FileUtils.cp_r(File.join(@package, entry), File.join(checkout, entry), preserve: true)
172
+ end
173
+ end
174
+
175
+ def submission_body(commit:)
176
+ <<~MARKDOWN
177
+ ### Repository URL
178
+
179
+ https://github.com/#{@repository}
180
+
181
+ ### Category
182
+
183
+ #{@category}
184
+
185
+ ### Tags
186
+
187
+ #{@tags.join(', ')}
188
+
189
+ ### Suggest a missing tag
190
+
191
+ _No response_
192
+
193
+ ### Maintainer notes
194
+
195
+ Built and published by Omarchy UI as a thin Ruby-authored package. Current review target: `#{commit}`.
196
+
197
+ ### Submission checklist
198
+
199
+ - [x] The repository is public and contains installation and removal instructions.
200
+ - [x] I have documented the plugin license and any external dependencies.
201
+ - [x] I confirm that I own or have permission to submit this plugin and its preview assets.
202
+ - [x] The plugin does not overwrite user configuration without explicit consent.
203
+ - [x] I understand that approval is for listing and is not a security review.
204
+ MARKDOWN
205
+ end
206
+
207
+ def show_submission(title, body)
208
+ @out.puts("Marketplace issue preview (#{@marketplace_repository}):")
209
+ @out.puts(title)
210
+ @out.puts(body)
211
+ end
212
+
213
+ def submit_to_marketplace!(title, body)
214
+ output, error, status = capture(
215
+ "gh", "issue", "list", "--repo", @marketplace_repository, "--state", "all", "--limit", "1000",
216
+ "--json", "number,body,state,url"
217
+ )
218
+ raise ArgumentError, "could not inspect marketplace submissions: #{error.strip}" unless status.success?
219
+
220
+ repository_url = "https://github.com/#{@repository}"
221
+ matching = JSON.parse(output).select { |issue| issue.fetch("body", "").include?(repository_url) }
222
+ open_issue = matching.find { |issue| issue.fetch("state") == "OPEN" }
223
+ if open_issue
224
+ run!(
225
+ "gh", "issue", "edit", open_issue.fetch("number").to_s,
226
+ "--repo", @marketplace_repository, "--title", title, "--body", body
227
+ )
228
+ return open_issue.fetch("url")
229
+ end
230
+ if matching.any?
231
+ raise ArgumentError,
232
+ "this repository already has a closed marketplace submission; use the marketplace verification/update form"
233
+ end
234
+
235
+ created, create_error, create_status = capture(
236
+ "gh", "issue", "create", "--repo", @marketplace_repository, "--title", title, "--body", body
237
+ )
238
+ raise ArgumentError, "marketplace submission failed: #{create_error.strip}" unless create_status.success?
239
+
240
+ created.lines.map(&:strip).find { |line| line.start_with?("https://") } || created.strip
241
+ rescue JSON::ParserError, KeyError => error
242
+ raise ArgumentError, "invalid marketplace response: #{error.message}"
243
+ end
244
+
245
+ def github_repository(url)
246
+ value = url.to_s.strip.sub(/\.git\z/, "")
247
+ match = value.match(%r{(?:github\.com[/:])([^/]+/[^/]+)\z}i)
248
+ match && match[1]
249
+ end
250
+
251
+ def capture(*command)
252
+ Open3.capture3(*command)
253
+ end
254
+
255
+ def run!(*command)
256
+ output, error, status = capture(*command)
257
+ return output if status.success?
258
+
259
+ detail = [output, error].join("\n").strip
260
+ raise ArgumentError, "command failed: #{command.first}#{detail.empty? ? '' : ": #{detail}"}"
261
+ end
262
+ end
263
+ end
@@ -191,6 +191,7 @@ module OmarchyUI
191
191
  def validate_plugin!
192
192
  %i[id author license description].each { |key| required_string!(key) }
193
193
  raise ArgumentError, "invalid plugin id" unless VALID_ID.match?(@data[:id])
194
+ raise ArgumentError, "plugin entrypoint must be main.rb" unless entrypoint == "main.rb"
194
195
 
195
196
  widget = @data[:bar_widget]
196
197
  raise ArgumentError, "plugin project config is missing bar widget metadata" unless widget.is_a?(Hash)
@@ -4,7 +4,6 @@ require "digest"
4
4
  require "fileutils"
5
5
  require "json"
6
6
  require "open3"
7
- require "pathname"
8
7
  require "tmpdir"
9
8
 
10
9
  module OmarchyUI
@@ -22,16 +21,17 @@ module OmarchyUI
22
21
  CHECKSUM = "omarchy-ui-qml-bundle.sha256"
23
22
  PROVENANCE = "QML_PROVENANCE.md"
24
23
 
25
- def self.compile!(path)
26
- new(path).compile!
24
+ def self.compile!(path, entry_files: ENTRY_TYPES.keys)
25
+ new(path, entry_files:).compile!
27
26
  end
28
27
 
29
28
  def self.verify!(path)
30
29
  new(path).verify!
31
30
  end
32
31
 
33
- def initialize(path)
32
+ def initialize(path, entry_files: nil)
34
33
  @path = File.expand_path(path)
34
+ @entry_types = entry_files ? select_entry_types(entry_files) : nil
35
35
  end
36
36
 
37
37
  def compile!
@@ -61,66 +61,107 @@ module OmarchyUI
61
61
  ))
62
62
  configure!(tools.fetch(:qt_cmake), source, build)
63
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
- )
64
+ deploy!(build:, module_path:, target:, uri:, expected_qt_version: qt_version)
75
65
  end
76
66
  end
77
67
 
78
68
  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"
69
+ entry_types = packaged_entry_types
70
+ module_path, module_uri = packaged_module
71
+ entry_types.each do |entry, type|
72
+ shim = File.read(File.join(@path, entry)).strip
73
+ expected = entry_shim(module_path, type, uri: module_uri).strip
74
+ raise ArgumentError, "invalid compiled QML entry shim: #{entry}" unless shim == expected
85
75
  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))
76
+
77
+ segment = File.basename(module_path)
78
+ target = "omarchy_ui_bundle_#{segment.downcase}"
79
+ artifact_names = ["lib#{target}.so", "lib#{target}plugin.so"]
80
+ module_directory = File.join(@path, module_path)
81
+ expected_module_entries = (artifact_names + ["qmldir"]).sort
82
+ unless Dir.children(module_directory).sort == expected_module_entries
83
+ raise ArgumentError, "compiled QML module contains unexpected files"
88
84
  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
85
+ artifacts = artifact_names.map do |name|
86
+ relative = File.join(module_path, name)
99
87
  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
88
+ {
89
+ "path" => relative,
90
+ "sha256" => Digest::SHA256.file(absolute).hexdigest,
91
+ "bytes" => File.size(absolute)
92
+ }
105
93
  end
106
- report
107
- rescue JSON::ParserError, KeyError, TypeError => error
108
- raise ArgumentError, "invalid compiled QML report: #{error.message}"
94
+ {
95
+ "format" => "qt-aot-qml-module",
96
+ "format_version" => FORMAT_VERSION,
97
+ "module_uri" => module_uri,
98
+ "module_path" => module_path,
99
+ "entry_shims" => entry_types.keys,
100
+ "artifacts" => artifacts
101
+ }
109
102
  end
110
103
 
111
104
  private
112
105
 
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}"
106
+ def select_entry_types(entry_files)
107
+ entries = Array(entry_files).map(&:to_s).uniq
108
+ unknown = entries - ENTRY_TYPES.keys
109
+ raise ArgumentError, "unsupported compiled QML entries: #{unknown.join(', ')}" unless unknown.empty?
110
+ raise ArgumentError, "compiled QML package needs at least one entry point" if entries.empty?
111
+
112
+ entries.to_h { |entry| [entry, ENTRY_TYPES.fetch(entry)] }
113
+ end
114
+
115
+ def packaged_entry_types
116
+ selected = if File.file?(File.join(@path, "manifest.json"))
117
+ manifest = JSON.parse(File.read(File.join(@path, "manifest.json")))
118
+ select_entry_types(manifest.fetch("entryPoints").values)
119
+ else
120
+ select_entry_types(ENTRY_TYPES.keys.select { |entry| File.file?(File.join(@path, entry)) })
121
+ end
122
+ actual = ENTRY_TYPES.keys.select { |entry| File.file?(File.join(@path, entry)) }
123
+ unless actual.sort == selected.keys.sort
124
+ raise ArgumentError, "compiled QML package contains unexpected entry shims"
118
125
  end
119
- clean.delete_prefix("#{@path}/")
126
+ selected
127
+ rescue JSON::ParserError, KeyError, TypeError => error
128
+ raise ArgumentError, "invalid plugin manifest: #{error.message}"
129
+ end
130
+
131
+ def packaged_module
132
+ compiled_root = File.join(@path, COMPILED_ROOT)
133
+ unless File.directory?(compiled_root) && !File.symlink?(compiled_root) &&
134
+ Dir.children(compiled_root) == ["Bundles"]
135
+ raise ArgumentError, "compiled QML root must contain only the Bundles directory"
136
+ end
137
+ bundles = File.join(compiled_root, "Bundles")
138
+ directories = if File.directory?(bundles) && !File.symlink?(bundles)
139
+ Dir.children(bundles).sort.filter_map do |entry|
140
+ path = File.join(bundles, entry)
141
+ path if File.directory?(path) && !File.symlink?(path)
142
+ end
143
+ else
144
+ []
145
+ end
146
+ raise ArgumentError, "compiled QML package must contain exactly one module" unless directories.length == 1
147
+
148
+ directory = directories.first
149
+ module_path = directory.delete_prefix("#{@path}/")
150
+ qmldir = File.join(directory, "qmldir")
151
+ unless File.file?(qmldir) && !File.symlink?(qmldir)
152
+ raise ArgumentError, "compiled QML qmldir is missing"
153
+ end
154
+ module_uri = File.read(qmldir)[/^module\s+(\S+)\s*$/, 1]
155
+ raise ArgumentError, "compiled QML module declaration is missing" unless module_uri
156
+ unless File.join(*module_uri.split(".")) == module_path
157
+ raise ArgumentError, "compiled QML module path does not match its URI"
158
+ end
159
+
160
+ [module_path, module_uri]
120
161
  end
121
162
 
122
163
  def validate_sources!
123
- missing = ENTRY_TYPES.keys.reject { |entry| File.file?(File.join(@path, entry)) }
164
+ missing = @entry_types.keys.reject { |entry| File.file?(File.join(@path, entry)) }
124
165
  missing << "ControlNode.qml" unless File.file?(File.join(@path, "ControlNode.qml"))
125
166
  return if missing.empty?
126
167
 
@@ -163,7 +204,7 @@ module OmarchyUI
163
204
 
164
205
  def stage_sources(destination)
165
206
  FileUtils.mkdir_p(destination)
166
- SOURCE_ENTRIES.each do |entry|
207
+ (@entry_types.keys + SOURCE_ENTRIES.drop(ENTRY_TYPES.length)).each do |entry|
167
208
  source = File.join(@path, entry)
168
209
  next unless File.exist?(source)
169
210
 
@@ -281,8 +322,7 @@ module OmarchyUI
281
322
  raise ArgumentError, "#{label} failed#{detail.empty? ? '' : ":\n#{detail}"}"
282
323
  end
283
324
 
284
- def deploy!(build:, module_path:, target:, uri:, fingerprint:, qml_files:, resources:, source:,
285
- expected_qt_version:)
325
+ def deploy!(build:, module_path:, target:, uri:, expected_qt_version:)
286
326
  built_module = File.join(build, module_path)
287
327
  artifact_names = ["lib#{target}.so", "lib#{target}plugin.so", "qmldir"]
288
328
  missing = artifact_names.reject { |name| File.file?(File.join(built_module, name)) }
@@ -304,38 +344,10 @@ module OmarchyUI
304
344
  File.write(qmldir, File.read(qmldir).rstrip + "\n")
305
345
 
306
346
  SOURCE_ENTRIES.each { |entry| remove_generated(File.join(@path, entry)) }
307
- ENTRY_TYPES.each do |entry, type|
347
+ @entry_types.each do |entry, type|
308
348
  File.write(File.join(@path, entry), entry_shim(module_path, type, uri:))
309
349
  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
350
+ verify!
339
351
  end
340
352
 
341
353
  def remove_generated(path)
@@ -352,35 +364,5 @@ module OmarchyUI
352
364
  QML
353
365
  end
354
366
 
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
367
  end
386
368
  end
@@ -12,18 +12,18 @@ module OmarchyUI
12
12
  ADAPTER_FILES = %w[Service.qml Panel.qml BarWidget.qml App.qml].freeze
13
13
  ZUI_GENERATED_ENTRIES = %w[Desktop.qml ControlNode.qml Components Controls Theme Fonts].freeze
14
14
  TREE_SHAKE_REPORT = "zui-tree-shake.json"
15
+ SHELL_COMPONENT_ROOT = File.expand_path("../../qml", __dir__)
16
+ SHELL_COMPONENTS = %w[DesktopStage.qml Positioned.qml].freeze
15
17
  FILES = (ADAPTER_FILES + %w[ControlNode.qml]).freeze
16
- AUDIT_FILES = %w[
18
+ LEGACY_METADATA_FILES = %W[
19
+ #{TREE_SHAKE_REPORT}
20
+ #{QmlCompiler::REPORT} #{QmlCompiler::CHECKSUM} #{QmlCompiler::PROVENANCE}
17
21
  omarchy-ui-runtime.sha256 runtime-provenance.json RUNTIME_PROVENANCE.md
18
22
  ].freeze
19
23
  GENERATED_ENTRIES = (ADAPTER_FILES + ZUI_GENERATED_ENTRIES + [
20
- TREE_SHAKE_REPORT,
21
24
  QmlCompiler::COMPILED_ROOT,
22
- QmlCompiler::REPORT,
23
- QmlCompiler::CHECKSUM,
24
- QmlCompiler::PROVENANCE,
25
25
  "omarchy-ui-runtime"
26
- ]).freeze
26
+ ] + LEGACY_METADATA_FILES).freeze
27
27
 
28
28
  def executable
29
29
  override = ENV["OMARCHY_UI_RUNTIME"]
@@ -51,29 +51,17 @@ module OmarchyUI
51
51
  generated = File.join(path, entry)
52
52
  File.directory?(generated) ? FileUtils.remove_entry(generated) : FileUtils.rm_f(generated)
53
53
  end
54
- report = install_tree_shaken_zui(path, project:)
54
+ install_tree_shaken_zui(path, project:)
55
55
  ADAPTER_FILES.each do |file|
56
56
  FileUtils.cp(File.join(framework_root, file), File.join(path, file))
57
57
  end
58
- File.write(File.join(path, TREE_SHAKE_REPORT), JSON.pretty_generate({
59
- "zui_version" => Zui::VERSION,
60
- "components" => report.components.map(&:to_s),
61
- "before_bytes" => report.before_bytes,
62
- "after_bytes" => report.after_bytes,
63
- "saved_bytes" => report.saved_bytes,
64
- "warnings" => report.warnings
65
- }) + "\n")
66
58
 
67
59
  if File.file?(BUNDLED)
68
60
  destination = File.join(path, "omarchy-ui-runtime")
69
61
  FileUtils.cp(BUNDLED, destination)
70
62
  FileUtils.chmod(0o755, destination)
71
63
  end
72
- AUDIT_FILES.each do |file|
73
- source = File.join(framework_root, "vendor", "runtime", "x86_64-linux", file)
74
- FileUtils.cp(source, File.join(path, file)) if File.file?(source)
75
- end
76
- QmlCompiler.compile!(path) if compiled
64
+ QmlCompiler.compile!(path, entry_files: package_entry_files(path)) if compiled
77
65
  path
78
66
  end
79
67
 
@@ -88,11 +76,51 @@ module OmarchyUI
88
76
  FileUtils.mkdir_p(native)
89
77
  Zui::Runtime.install_qml(qml)
90
78
  report = Zui::TreeShaker.new(project:, framework: qml, native:).shake!
79
+ install_shell_components(qml)
91
80
  FileUtils.rm_f([File.join(qml, "Desktop.qml"), File.join(qml, "Service.qml")])
92
81
  entries = Dir.children(qml).map { |entry| File.join(qml, entry) }
93
82
  FileUtils.cp_r(entries, destination) unless entries.empty?
94
83
  return report
95
84
  end
96
85
  end
86
+
87
+ def package_entry_files(path)
88
+ manifest_path = File.join(path, "manifest.json")
89
+ return ["App.qml"] unless File.file?(manifest_path)
90
+
91
+ manifest = JSON.parse(File.read(manifest_path))
92
+ entries = manifest.fetch("entryPoints").values.map(&:to_s).uniq
93
+ unknown = entries - QmlCompiler::ENTRY_TYPES.keys
94
+ raise ArgumentError, "unsupported manifest QML entry points: #{unknown.join(', ')}" unless unknown.empty?
95
+ raise ArgumentError, "manifest has no QML entry points" if entries.empty?
96
+
97
+ entries
98
+ rescue JSON::ParserError, KeyError, TypeError => error
99
+ raise ArgumentError, "invalid plugin manifest: #{error.message}"
100
+ end
101
+
102
+ def install_shell_components(qml)
103
+ builtins = File.join(qml, "Components", "Builtins")
104
+ FileUtils.mkdir_p(builtins)
105
+ SHELL_COMPONENTS.each do |file|
106
+ FileUtils.cp(File.join(SHELL_COMPONENT_ROOT, file), File.join(builtins, file))
107
+ end
108
+
109
+ control_node = File.join(qml, "ControlNode.qml")
110
+ source = File.read(control_node)
111
+ source = source.sub(
112
+ 'readonly property bool builtIn: ["text"',
113
+ 'readonly property bool builtIn: ["desktop_stage", "positioned", "text"'
114
+ )
115
+ source = source.sub(
116
+ 'readonly property bool structuralContainer: ["row"',
117
+ 'readonly property bool structuralContainer: ["desktop_stage", "positioned", "row"'
118
+ )
119
+ source = source.sub(
120
+ 'return bridge && bridge.projectDir ? bridge.projectDir + "/" + source : Qt.resolvedUrl(source)',
121
+ 'return bridge && bridge.projectDir ? "file://" + bridge.projectDir + "/" + source : Qt.resolvedUrl(source)'
122
+ )
123
+ File.write(control_node, source)
124
+ end
97
125
  end
98
126
  end
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.9"
14
+ VERSION = "0.0.10"
15
15
  ZUI_VERSION = Zui::VERSION
16
16
  FRAMEWORK_ROOT = File.expand_path("..", __dir__)
17
17
 
@@ -130,3 +130,5 @@ require_relative "omarchy_ui/project_config"
130
130
  require_relative "omarchy_ui/generator"
131
131
  require_relative "omarchy_ui/qml_compiler"
132
132
  require_relative "omarchy_ui/runtime"
133
+ require_relative "omarchy_ui/plugin_package"
134
+ require_relative "omarchy_ui/plugin_publisher"
@@ -0,0 +1,88 @@
1
+ import QtQuick
2
+ import Quickshell
3
+ import Quickshell.Wayland
4
+
5
+ PanelWindow {
6
+ id: window
7
+
8
+ required property var renderer
9
+ readonly property real requestedX: Number(renderer.prop("stage_x", 48))
10
+ readonly property real requestedY: Number(renderer.prop("stage_y", 220))
11
+ property real sceneX: requestedX
12
+ property real sceneY: requestedY
13
+
14
+ function boundedX(value) {
15
+ return Math.max(0, Math.min(width - stage.width, Number(value)))
16
+ }
17
+
18
+ function boundedY(value) {
19
+ return Math.max(0, Math.min(height - stage.height, Number(value)))
20
+ }
21
+
22
+ function send(name, payload) {
23
+ if (renderer.subscribed(name))
24
+ renderer.bridge.sendEvent(renderer.surfaceName, renderer.controlId, name, payload || {})
25
+ }
26
+
27
+ onRequestedXChanged: if (!sceneDrag.active) sceneX = boundedX(requestedX)
28
+ onRequestedYChanged: if (!sceneDrag.active) sceneY = boundedY(requestedY)
29
+
30
+ visible: renderer.prop("pinned", false) === true
31
+ anchors { top: true; right: true; bottom: true; left: true }
32
+ color: "transparent"
33
+ exclusionMode: ExclusionMode.Ignore
34
+ WlrLayershell.namespace: renderer.bridge && renderer.bridge.manifest && renderer.bridge.manifest.id
35
+ ? String(renderer.bridge.manifest.id) + "-desktop"
36
+ : "omarchy-ui-desktop"
37
+ WlrLayershell.layer: WlrLayer.Overlay
38
+ WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
39
+
40
+ // The rest of the full-screen surface remains click-through.
41
+ mask: Region { item: stage }
42
+
43
+ Item {
44
+ id: stage
45
+ x: window.boundedX(window.sceneX)
46
+ y: window.boundedY(window.sceneY)
47
+ width: Math.max(1, Number(renderer.prop("stage_width", 420)))
48
+ height: Math.max(1, Number(renderer.prop("stage_height", 420)))
49
+
50
+ DragHandler {
51
+ id: sceneDrag
52
+ target: null
53
+ enabled: renderer.prop("draggable", true) !== false
54
+ dragThreshold: 3
55
+ property real originX: 0
56
+ property real originY: 0
57
+
58
+ onActiveChanged: {
59
+ if (active) {
60
+ originX = stage.x
61
+ originY = stage.y
62
+ window.send("drag_start", { target_x: stage.x, target_y: stage.y })
63
+ } else {
64
+ window.sceneX = stage.x
65
+ window.sceneY = stage.y
66
+ window.send("drag_end", { target_x: stage.x, target_y: stage.y })
67
+ }
68
+ }
69
+
70
+ onTranslationChanged: {
71
+ if (!active) return
72
+ window.sceneX = window.boundedX(originX + translation.x)
73
+ window.sceneY = window.boundedY(originY + translation.y)
74
+ window.send("drag", { target_x: stage.x, target_y: stage.y })
75
+ }
76
+ }
77
+
78
+ HoverHandler {
79
+ enabled: sceneDrag.enabled
80
+ cursorShape: sceneDrag.active ? Qt.ClosedHandCursor : Qt.OpenHandCursor
81
+ }
82
+
83
+ Repeater {
84
+ model: renderer.node && Array.isArray(renderer.node.children) ? renderer.node.children : []
85
+ delegate: renderer.childDelegateComponent
86
+ }
87
+ }
88
+ }
@@ -0,0 +1,18 @@
1
+ import QtQuick
2
+
3
+ Item {
4
+ id: root
5
+
6
+ required property var renderer
7
+
8
+ x: Number(renderer.prop("position_x", 0))
9
+ y: Number(renderer.prop("position_y", 0))
10
+ width: Math.max(1, Number(renderer.prop("item_width", 1)))
11
+ height: Math.max(1, Number(renderer.prop("item_height", 1)))
12
+ clip: renderer.prop("clip", false) === true
13
+
14
+ Repeater {
15
+ model: renderer.node && Array.isArray(renderer.node.children) ? renderer.node.children : []
16
+ delegate: renderer.childDelegateComponent
17
+ }
18
+ }
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.9
4
+ version: 0.0.10
5
5
  platform: x86_64-linux
6
6
  authors:
7
7
  - Adam Moussa Ali
@@ -39,9 +39,13 @@ 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/plugin_package.rb
43
+ - lib/omarchy_ui/plugin_publisher.rb
42
44
  - lib/omarchy_ui/project_config.rb
43
45
  - lib/omarchy_ui/qml_compiler.rb
44
46
  - lib/omarchy_ui/runtime.rb
47
+ - qml/DesktopStage.qml
48
+ - qml/Positioned.qml
45
49
  - vendor/runtime/x86_64-linux/RUNTIME_PROVENANCE.md
46
50
  - vendor/runtime/x86_64-linux/omarchy-ui-runtime
47
51
  - vendor/runtime/x86_64-linux/omarchy-ui-runtime.sha256