diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a024a052..0d68d68b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -72,6 +72,14 @@ session also changes the VM suite `module`, which logs in to Plasma on a machine with the module and checks the daemon the unit runs. Every other VM suite runs on machines built with the module too. +Three files are generated from the Rust types and committed: `schema.json` and +`schema.asc.json` (`just jsonschema`), and `nix/komorebi-options.nix`, which types the +module's `config` (`just nixgen`, which needs `schemars-nixgen`, `cargo install +schemars-nixgen`). A change to what `komorebi.json` or an `applications.json` can +say regenerates them in the same commit, and the check `schema-fresh` fails when +it does not. The first line of the options file is the hash of the schema it was +made from, which is how the check tells without running `schemars-nixgen`. + ## VM tests A change that needs a real KWin to be checked comes with a VM test, next to diff --git a/Cargo.lock b/Cargo.lock index 1c172eec..a0aae36d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1099,6 +1099,8 @@ dependencies = [ "komorebi-client", "lazy_static", "pastey", + "schemars 1.2.3", + "serde_json_lenient", "sysinfo", "version", ] diff --git a/README.md b/README.md index 4b9b3233..85a4e45d 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,9 @@ package.json: `tsc` and `node` come from the dev shell), inside `nix develop`. `nix flake check` runs the same checks as derivations, plus `cargo deny`, the TOML and Nix formatting, the tests under `cargo nextest`, and `nixos-module-eval`, which evaluates the NixOS module in real NixOS systems, on -Linux and on an Apple silicon Mac. On Linux it also boots every VM test, which +Linux and on an Apple silicon Mac, `schema-fresh`, which compares the schemas and +the options made from them with the Rust types, and `nix-options-validation`, +which checks the example configuration, in Nix and in JSON, against the schema. On Linux it also boots every VM test, which needs KVM. A Mac runs the tests against the fake desktop, since KWin only exists on Linux. `nix fmt` formats every file the checks look at. @@ -204,7 +206,10 @@ can be in files of their own, which `app_specific_configuration_path` names. `workspace-layout-rule` makes a workspace change layout with the number of windows it has, as the file's `layout_rules` do, and `generate-static-config` prints the settings the daemon -has as a `komorebi.json`. +has as a `komorebi.json`. The Rust types that read the file describe it as a JSON Schema +(`schema.json`, written by `just jsonschema` and printed by `static-config-schema`), and the +module's `config` option is typed from that schema (`nix/komorebi-options.nix`, written by +`just nixgen`), so a configuration written in Nix is checked when it is evaluated. A program such as a status bar can subscribe to the daemon's notifications: it listens on a socket of its own, and after each event and each message, the diff --git a/docs/cli/application-specific-configuration-schema.md b/docs/cli/application-specific-configuration-schema.md new file mode 100644 index 00000000..4e22a2f9 --- /dev/null +++ b/docs/cli/application-specific-configuration-schema.md @@ -0,0 +1,10 @@ +```text +Generate a JSON Schema for applications.json + +Usage: application-specific-configuration-schema + +Options: + -h, --help + Print help + +``` diff --git a/docs/cli/notification-schema.md b/docs/cli/notification-schema.md new file mode 100644 index 00000000..e0614bfd --- /dev/null +++ b/docs/cli/notification-schema.md @@ -0,0 +1,10 @@ +```text +Generate a JSON Schema of subscription notifications + +Usage: notification-schema + +Options: + -h, --help + Print help + +``` diff --git a/docs/cli/socket-schema.md b/docs/cli/socket-schema.md new file mode 100644 index 00000000..246b70ed --- /dev/null +++ b/docs/cli/socket-schema.md @@ -0,0 +1,10 @@ +```text +Generate a JSON Schema of socket messages + +Usage: socket-schema + +Options: + -h, --help + Print help + +``` diff --git a/docs/cli/static-config-schema.md b/docs/cli/static-config-schema.md new file mode 100644 index 00000000..d72836e8 --- /dev/null +++ b/docs/cli/static-config-schema.md @@ -0,0 +1,10 @@ +```text +Generate a JSON Schema of the static configuration file + +Usage: static-config-schema + +Options: + -h, --help + Print help + +``` diff --git a/docs/dev/ARCHITECTURE.md b/docs/dev/ARCHITECTURE.md index 2f1fa555..edd1c7c1 100644 --- a/docs/dev/ARCHITECTURE.md +++ b/docs/dev/ARCHITECTURE.md @@ -683,6 +683,19 @@ defaults. A **rule** is an entry of one of the process-wide lists. A workspace's `layout_rules`, `work_area_offset_rules` and `window_container_behaviour_rules` are fields of the [`Workspace`], which [`Workspace::update`] reads each time it tiles. +The same types describe the file as a JSON Schema, which `schemars` derives: `komorebic +static-config-schema` prints the schema of [`StaticConfig`], `application-specific-configuration-schema` +that of an applications file, and `notification-schema` and `socket-schema` those of a +[`Notification`] and of a [`SocketMessage`]; the daemon answers the same four questions on its +socket (the fourth is the schema of a list of applications files, where `komorebic` prints the +schema of one). +The schemas of the first two are committed (`schema.json`, `schema.asc.json`, written by `just +jsonschema`), and so are the options the module's `config` is typed with +(`nix/komorebi-options.nix`, which `schemars-nixgen` makes from `schema.json` and `just nixgen` +gives the hash of the schema as its first line). The check `schema-fresh` compares the schemas with +what the Rust types print and the hash with the schema, so a change to a key that leaves one of +them behind fails. + One `komorebi.json` is meant for every platform komorebi runs on, so [`StaticConfig`] has no `deny_unknown_fields`: a key it does not read is dropped, and the keys of other platforms that mean nothing on KDE Plasma are fields that nothing reads. `StaticConfig::from(&WindowManager)` @@ -695,6 +708,9 @@ goes the other way, for `GenerateStaticConfig`. | The example in the docs is the example file, which loads | test | `the_example_in_the_docs_is_the_example_file`, `fixtures_load_round_trip_and_only_lose_windows_only_keys` | none | | A path to an applications file that is not there is skipped, and one that is there and is broken is an error | framework path: `handle_asc_file` returns early when the path does not exist, and parses the file otherwise | `a_missing_application_specific_configuration_is_skipped_not_fatal`, `an_application_specific_configuration_that_exists_but_is_broken_is_still_an_error`, `a_file_that_is_not_there_is_skipped_and_one_that_is_broken_is_an_error` | none | | The rules of an applications file and of the configuration add up | framework path: both are appended to the process-wide lists | `the_rules_of_the_configuration_and_of_the_file_add_up`, `every_file_of_a_list_is_read` | none | +| `schema.json` and `schema.asc.json` are what the Rust types print | Nix check `schema-fresh`, and a test that compares them with `komorebic`'s output | `schema-fresh`, `the_schema_files_of_the_repository_are_what_the_subcommands_print` | `just jsonschema` | +| The options the module is typed with are made from the committed schema | the hash of `schema.json` in the first line of `nix/komorebi-options.nix`, compared by `schema-fresh` | `schema-fresh` | `just nixgen` | +| The example, in Nix and in JSON, follows the schema, and says the same in both | Nix check `nix-options-validation`: the options evaluated against the Nix example, both examples checked with `check-jsonschema`, and one contained in the other | `nix-options-validation` | none | | A file that cannot be read changes nothing | framework path: [`StaticConfig::reload`] reads and parses the file before it applies anything | `a_configuration_that_cannot_be_read_changes_nothing`, `the_daemon_follows_the_file_it_is_given_when_the_file_changes` | none | | The daemon applies a change to its file, written in place or replaced by a rename | framework path: the watcher sends `ReloadStaticConfiguration` to the daemon's own socket | `the_daemon_follows_the_file_it_is_given_when_the_file_changes`, `vm-module` | none: the file has to be the one the daemon was started with | | A monitor takes the entry its display index preference gives it, else the first nobody has | framework path: [`StaticConfig::postload`] and [`StaticConfig::reload`] choose the entry by serial number id first | `a_monitor_takes_the_configuration_its_display_index_preference_gives_it` | none | @@ -711,9 +727,16 @@ The flake exports one module under two names, `nixosModules.default` and `nixosModules.komorebi`: [`nixos-module.nix`] and the flake's overlay, which builds `komorebi-full` (both programs) with the flake's nixpkgs and Rust toolchain. Its options are `enable`, `package` (by default `pkgs.komorebi-full`, from the overlay), `logLevel`, -`configFile`, and `execStart`, which is read-only: the module sets it to `bin/komorebi` of the -package with `--log-level`, and `--config` with `configFile` when that is set. With -`services.komorebi.enable`, the module: +`configFile`, `config`, `applicationSpecificConfiguration`, and `execStart`, which is read-only: the +module sets it to `bin/komorebi` of the package with `--log-level`, and `--config` with a +`komorebi.json` when `configFile` or `config` is set. `config` is typed from +`nix/komorebi-options.nix`, the options `schemars-nixgen` makes from the schema of `komorebi.json`; +the module writes it as JSON, with no null in it, and `configFile` and the written file both go +through `check-jsonschema` against `schema.json` when the system is built (comment lines taken out +first, since the checker reads plain JSON). `applicationSpecificConfiguration.sources` are files +fetched from GitHub that become `app_specific_configuration_path`; the module has none by default. +`internal.configAttrs` is the configuration as the attribute set the options make, for the tests. +With `services.komorebi.enable`, the module: - installs the package in the system profile; - defines the user unit `komorebi.service`, which every user of the machine has. Plasma's @@ -734,7 +757,9 @@ module's unit. | komorebi is enabled only on a machine with Plasma 6 | evaluation error: an assertion of the module | `nixos-module-eval` ("without Plasma 6, an assertion fails") | none | | The log level is one the daemon accepts | evaluation error: `logLevel` is an enum of the daemon's `--log-level` values | `nixos-module-eval` ("a log level the daemon does not have does not evaluate") | none | | The unit runs `execStart`, and nothing but the module sets it | evaluation error: the option is read-only | `nixos-module-eval` ("execStart cannot be set", "the unit runs execStart") | a unit of your own | -| The daemon is configured by `configFile` when it is set | framework path: `execStart` passes it as `--config`, which comes before the config home | `nixos-module-eval` ("configFile reaches execStart and the unit as --config"), `vm-module` | none: with no `configFile` the daemon reads the file of the config home | +| The daemon is configured by `configFile` when it is set, else by `config` | framework path: `execStart` passes the checked file as `--config`, which comes before the config home | `nixos-module-eval` ("configFile reaches execStart and the unit as --config", "config reaches execStart ..."), `vm-module` | none: with neither the daemon reads the file of the config home | +| A configuration with a layout or a value komorebi does not have does not build | evaluation error: `config` is typed from the schema; and `check-jsonschema` fails the build for a file that does not follow it | `nixos-module-eval` ("a layout komorebi does not have does not evaluate"), `nix-options-validation` | `configFile` with a file the schema accepts | +| The sources of application specific configuration reach the daemon only while `applicationSpecificConfiguration.enable` is set | framework path: the module adds `app_specific_configuration_path` to the file it writes only when `enable` is set and there are sources | `nixos-module-eval` ("with applicationSpecificConfiguration.enable = false, the sources are left out of config", "with the sources turned off and no config, there is nothing to write") | none | | `komorebi` and `komorebic` are on every user's `PATH` | the module installs the package in the system profile | `nixos-module-eval`; every VM suite runs `komorebic` | none | | Every Plasma session starts komorebi, once KWin's service has started | systemd: the user unit is wanted by `plasma-workspace.target`, after `plasma-core.target` | `nixos-module-eval`, `vm-module` | `systemctl --user mask komorebi` | | A daemon that fails or is killed is started again | systemd: `Restart=on-failure` | `vm-module` (`SIGKILL`) | none | @@ -996,7 +1021,13 @@ A window is shown, and ends up on a tile: with several monitors, [`komorebi/tests/hot_reload.rs`](../../komorebi/tests/hot_reload.rs) the daemon following a file, and [`komorebi/tests/config_parity.rs`](../../komorebi/tests/config_parity.rs) the files of other - platforms and the example, which load; [`komorebi/tests/asc.rs`](../../komorebi/tests/asc.rs) + platforms and the example, which load; + [`komorebi/tests/schemas.rs`](../../komorebi/tests/schemas.rs) the schemas the daemon answers + with; [`schema.json`](../../schema.json), [`schema.asc.json`](../../schema.asc.json) and + [`nix/komorebi-options.nix`](../../nix/komorebi-options.nix), which are generated; + [`nix/tests/default.nix`](../../nix/tests/default.nix) and + [`nix/tests/komorebi-example.nix`](../../nix/tests/komorebi-example.nix), the check of the options + and the example it evaluates them against; [`komorebi/tests/asc.rs`](../../komorebi/tests/asc.rs) the applications files; [`komorebi/src/core/asc.rs`](../../komorebi/src/core/asc.rs) their format. - [`komorebi/src/process_event.rs`](../../komorebi/src/process_event.rs): what each event diff --git a/docs/dev/DESIGN.md b/docs/dev/DESIGN.md index f35e41df..20d961f5 100644 --- a/docs/dev/DESIGN.md +++ b/docs/dev/DESIGN.md @@ -354,3 +354,12 @@ Because one `komorebi.json` is meant to work on every platform, and the file it one that another platform has: a configuration written for macOS names `applications.mac.json`, which a KDE desktop does not have. The daemon warns and goes on. A file that exists and is not an applications.json is a mistake in something the user wrote, and is an error. + +## Why does `nix/komorebi-options.nix` start with the hash of the schema? + +Because the check that the options are fresh cannot make them again. `schemars-nixgen` makes +`nix/komorebi-options.nix` from `schema.json`, and it is not in the dev shell or in CI (it is +installed with `cargo install`). `just nixgen` writes the hash of `schema.json` as the first line +of the file, and the check `schema-fresh` compares that with the hash of the committed schema, and +the schema with what `komorebic static-config-schema` prints. A change to a key that leaves the +schema or the options behind fails the check, with no tool but the build. diff --git a/docs/installation.md b/docs/installation.md index 75e8bd2f..f700648e 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -72,21 +72,46 @@ the repository's `rust-toolchain.toml` selects, whatever nixpkgs your system use | `services.komorebi.enable` | `false` | Installs komorebi and starts it with every Plasma session. | | `services.komorebi.package` | `pkgs.komorebi-full` | The package with `bin/komorebi` and `bin/komorebic`; the unit runs the first, and both are installed. | | `services.komorebi.logLevel` | `"info"` | The daemon's `--log-level`: `"error"`, `"warn"`, `"info"`, `"debug"` or `"trace"`. | -| `services.komorebi.configFile` | `null` | A `komorebi.json` for the daemon. The unit passes it to komorebi as `--config`, so the daemon reads it and not the `komorebi.json` of `~/.config/komorebi`. The file is in the Nix store: komorebi reads a new one when it starts again. | -| `services.komorebi.execStart` | read only | The command line the unit starts komorebi with, built from `package`, `logLevel` and `configFile`. A unit of your own that runs komorebi uses it to start komorebi the same way. | +| `services.komorebi.config` | `null` | The configuration, as Nix: every key of `komorebi.json`, typed from the schema. The module writes it as a `komorebi.json`, checks that against the schema, and the unit passes it to komorebi as `--config`. Ignored if `configFile` is set. | +| `services.komorebi.configFile` | `null` | A `komorebi.json` for the daemon, checked against the schema when the system is built. The unit passes it to komorebi as `--config`, so the daemon reads it and not the `komorebi.json` of `~/.config/komorebi`. The file is in the Nix store: komorebi reads a new one when it starts again. | +| `services.komorebi.applicationSpecificConfiguration` | `enable = true`, `sources = [ ]` | Files of [application specific configuration](usage/application-specific-configuration.md) fetched from GitHub (`owner`, `repo`, `rev`, `path` and `hash` each), which become the `app_specific_configuration_path` of the configuration `config` makes. There are none by default. | +| `services.komorebi.execStart` | read only | The command line the unit starts komorebi with, built from `package`, `logLevel` and `configFile` or `config`. A unit of your own that runs komorebi uses it to start komorebi the same way. | -The file for `configFile` is written like any other `komorebi.json` -([Configuration](usage/configuration.md)): +`config` takes the keys of `komorebi.json` ([Configuration](usage/configuration.md)) as Nix, and a +value of the wrong kind, or a layout that does not exist, is an error when the configuration is +evaluated: ```nix { services.komorebi = { enable = true; - configFile = ./komorebi.json; + config = { + default_workspace_padding = 15; + monitors = [ + { + workspaces = [ + { name = "web"; layout = "BSP"; } + { name = "code"; layout = "Columns"; } + ]; + } + ]; + }; }; } ``` +`configFile` takes a `komorebi.json` you wrote: + +```nix +{ + services.komorebi.configFile = ./komorebi.json; +} +``` + +Comment lines (starting with `//`) are taken out of the file before it is checked, which is why +the file can have them. With both, `configFile` wins, and the module warns that `config` is +ignored. + ### Running it `plasma-workspace.target` starts komorebi when you log in. In a session that was already diff --git a/docs/usage/configuration.md b/docs/usage/configuration.md index 5eb13be4..71652323 100644 --- a/docs/usage/configuration.md +++ b/docs/usage/configuration.md @@ -187,3 +187,32 @@ index of the workspace, the number of containers and the layout; [`named-workspace-layout-rule`](../cli/named-workspace-layout-rule.md) takes the name of the workspace instead. A rule for a count that has one replaces it. The clear commands take every rule of the workspace away and leave it with the layout it has. + +## The schema + +The Rust types that read `komorebi.json` also describe it as a JSON Schema, which an editor can +check the file against, and which the NixOS module is typed from. [`schema.json`](../../schema.json) +in the repository is the schema of `komorebi.json`, [`schema.asc.json`](../../schema.asc.json) the +schema of an [`applications.json`](application-specific-configuration.md), and the commands print +them (and two more), without a daemon: + +```sh +komorebic static-config-schema +komorebic application-specific-configuration-schema +komorebic notification-schema +komorebic socket-schema +``` + +[`notification-schema`](../cli/notification-schema.md) is the schema of what a +[subscriber](subscribing-to-notifications.md) is sent, and +[`socket-schema`](../cli/socket-schema.md) the schema of a message. To check a file by hand: + +```sh +komorebic static-config-schema > schema.json +check-jsonschema --schemafile schema.json ~/.config/komorebi/komorebi.json +``` + +`check-jsonschema` reads plain JSON, so comments in the file have to come out first. The schema +does not stop komorebi from reading a file that does not follow it: a key it does not have is +dropped, as [above](#one-file-for-every-platform). + diff --git a/flake.nix b/flake.nix index 8a55e303..dd0904e3 100644 --- a/flake.nix +++ b/flake.nix @@ -99,6 +99,15 @@ ; komorebi = extractBinary "komorebi"; komorebic = extractBinary "komorebic"; + # komorebic with the constant commit of the checks, whose output the schemas are + # checked against: it is the same for every commit + komorebicForChecks = craneLib.buildPackage ( + individualCrateArgs + // { + pname = "komorebic-for-checks"; + cargoExtraArgs = "--package komorebic --bin komorebic"; + } + ); }; in flake-parts.lib.mkFlake { inherit inputs; } { @@ -216,6 +225,30 @@ vm-lib = vm.lib; + # The schemas, the options made from one of them and the example are what the Rust types + # say: `just jsonschema` and `just nixgen` write them + schema-fresh = + pkgs.runCommand "komorebi-schema-fresh" + { + nativeBuildInputs = [ + build.komorebicForChecks + pkgs.diffutils + ]; + } + '' + komorebic static-config-schema | diff -u ${./schema.json} - \ + || { echo "schema.json is stale: run just jsonschema"; exit 1; } + komorebic application-specific-configuration-schema | diff -u ${./schema.asc.json} - \ + || { echo "schema.asc.json is stale: run just jsonschema"; exit 1; } + expected=$(sha256sum ${./schema.json} | cut -d' ' -f1) + head -n 1 ${./nix/komorebi-options.nix} | grep -qx "# schema-sha256: $expected" \ + || { echo "nix/komorebi-options.nix is stale: run just nixgen"; exit 1; } + touch $out + ''; + + # the options the module is typed with, evaluated against the example configuration + nix-options-validation = import ./nix/tests/default.nix { inherit pkgs; }; + # The NixOS module in real NixOS systems, evaluated and never built, so a Mac checks it # too. A VM suite, `vm-module`, runs it. nixos-module-eval = import ./nix/tests/nixos-module.nix { @@ -282,6 +315,7 @@ pkgs.cargo-deny pkgs.cargo-nextest + pkgs.check-jsonschema pkgs.jq pkgs.just pkgs.ripgrep diff --git a/justfile b/justfile index 3726a9ca..daf1d00a 100644 --- a/justfile +++ b/justfile @@ -23,6 +23,21 @@ lint: test -z "$(rg 'dbg!' --type rust .)" || (echo "dbg! macro not allowed"; false) test -z "$(rg 'println!' --type rust ./komorebi/src)" || (echo "println! macro not allowed"; false) +# the JSON Schemas of komorebi.json and of applications.json, from the Rust types +jsonschema: + cargo run --package komorebic --locked -- static-config-schema > schema.json + cargo run --package komorebic --locked -- application-specific-configuration-schema > schema.asc.json + +# the options of the module's `config`, from schema.json, with the hash of the schema they were +# made from in the first line (the check `schema-fresh` compares it); needs schemars-nixgen +# (`cargo install schemars-nixgen`), which the dev shell does not have +nixgen: + schemars-nixgen schema.json -o nix/komorebi-options.nix --name komorebi --description "komorebi for KDE Plasma configuration" + printf '# schema-sha256: %s\n' "$(nix hash file --type sha256 --base16 schema.json)" | cat - nix/komorebi-options.nix > nix/komorebi-options.nix.new + mv nix/komorebi-options.nix.new nix/komorebi-options.nix + nix fmt nix/komorebi-options.nix + nix build -L --no-link ".#checks.$(nix eval --raw --impure --expr builtins.currentSystem).nix-options-validation" + # one VM test, on this machine: `just vm kwin`. Linux boots the guest under KVM through the # flake check; a Mac builds the guest on a builder and runs the driver (see docs/dev/vm-tests.md) vm name: diff --git a/komorebi/src/core/mod.rs b/komorebi/src/core/mod.rs index 857fcf9f..201e0f25 100644 --- a/komorebi/src/core/mod.rs +++ b/komorebi/src/core/mod.rs @@ -496,6 +496,10 @@ pub enum SocketMessage { RemoveSubscriberSocket(String), // AddSubscriberPipe(String), // RemoveSubscriberPipe(String), + ApplicationSpecificConfigurationSchema, + NotificationSchema, + SocketSchema, + StaticConfigSchema, GenerateStaticConfig, DebugWindow(u32), } diff --git a/komorebi/src/process_command.rs b/komorebi/src/process_command.rs index 3e6c0fd8..03e2fb05 100644 --- a/komorebi/src/process_command.rs +++ b/komorebi/src/process_command.rs @@ -16,6 +16,10 @@ //! ([`RuleDebug`]), as JSON, or nothing for a window that is not there. //! - **GenerateStaticConfig**: the window manager's settings as a `komorebi.json` //! ([`StaticConfig`]), as JSON. +//! - **StaticConfigSchema**, **ApplicationSpecificConfigurationSchema**, **NotificationSchema** +//! and **SocketSchema**: the JSON Schema of `komorebi.json`, of a list of applications files, +//! of a [`Notification`] and of a [`SocketMessage`]. They answer nothing when the daemon is +//! built without the `schemars` feature. //! //! The others answer nothing: //! @@ -1625,6 +1629,44 @@ impl WindowManager { SocketMessage::UnmanagedWindowOperationBehaviour(behaviour) => { self.unmanaged_window_operation_behaviour = behaviour; } + SocketMessage::ApplicationSpecificConfigurationSchema => { + #[cfg(feature = "schemars")] + { + let asc = schemars::schema_for!( + Vec + ); + let schema = serde_json::to_string_pretty(&asc)?; + + reply.write_all(schema.as_bytes())?; + } + } + SocketMessage::NotificationSchema => { + #[cfg(feature = "schemars")] + { + let notification = schemars::schema_for!(Notification); + let schema = serde_json::to_string_pretty(¬ification)?; + + reply.write_all(schema.as_bytes())?; + } + } + SocketMessage::SocketSchema => { + #[cfg(feature = "schemars")] + { + let socket_message = schemars::schema_for!(SocketMessage); + let schema = serde_json::to_string_pretty(&socket_message)?; + + reply.write_all(schema.as_bytes())?; + } + } + SocketMessage::StaticConfigSchema => { + #[cfg(feature = "schemars")] + { + let static_config = schemars::schema_for!(StaticConfig); + let schema = serde_json::to_string_pretty(&static_config)?; + + reply.write_all(schema.as_bytes())?; + } + } SocketMessage::AddSubscriberSocket(ref socket) => { let mut sockets = SUBSCRIPTION_SOCKETS.lock(); let socket_path = DATA_DIR.join(socket); diff --git a/komorebi/tests/schemas.rs b/komorebi/tests/schemas.rs new file mode 100644 index 00000000..195ca0c8 --- /dev/null +++ b/komorebi/tests/schemas.rs @@ -0,0 +1,50 @@ +//! The JSON Schemas the daemon answers with: `StaticConfigSchema`, +//! `ApplicationSpecificConfigurationSchema`, `NotificationSchema` and `SocketSchema`, against an +//! in-memory desktop. `komorebic` prints the same schemas without the daemon; the daemon answers +//! on its socket for a client that has none. + +use komorebi::backend::fake::FakeBackend; +use komorebi::backend::with_backend; +use komorebi::core::SocketMessage; +use komorebi::window_manager::WindowManager; +use std::sync::Arc; + +fn answer(message: SocketMessage) -> serde_json::Value { + let fake = Arc::new(FakeBackend::with_one_monitor()); + let (_sender, receiver) = crossbeam_channel::bounded(1); + let socket = std::path::PathBuf::from(format!("/tmp/kms-{}.sock", std::process::id())); + + let mut reply = Vec::new(); + with_backend(fake, || { + let mut wm = WindowManager::new(receiver, Some(socket.clone())).unwrap(); + wm.init().unwrap(); + wm.process_command(message, &mut reply) + }) + .unwrap(); + let _ = std::fs::remove_file(&socket); + + serde_json::from_slice(&reply).expect("the answer is JSON") +} + +#[test] +fn the_daemon_answers_with_the_schema_of_komorebi_json() { + let schema = answer(SocketMessage::StaticConfigSchema); + + assert_eq!(schema["title"], "StaticConfig"); + assert!(schema["properties"]["monitors"].is_object()); + assert!(schema["properties"]["app_specific_configuration_path"].is_object()); +} + +#[test] +fn the_daemon_answers_with_the_schema_of_a_list_of_applications_files() { + let schema = answer(SocketMessage::ApplicationSpecificConfigurationSchema); + + assert_eq!(schema["type"], "array"); + assert!(schema["$defs"]["AscApplicationRules"].is_object()); +} + +#[test] +fn the_daemon_answers_with_the_schemas_of_what_it_sends_and_of_what_it_takes() { + assert!(answer(SocketMessage::NotificationSchema)["properties"]["state"].is_object()); + assert!(answer(SocketMessage::SocketSchema)["oneOf"].is_array()); +} diff --git a/komorebic/Cargo.toml b/komorebic/Cargo.toml index 80ab86df..324fb84d 100644 --- a/komorebic/Cargo.toml +++ b/komorebic/Cargo.toml @@ -16,11 +16,13 @@ dirs = { workspace = true } fs-tail = "0.1" lazy_static = { workspace = true } pastey = { workspace = true } +schemars = { workspace = true, optional = true } +serde_json = { workspace = true } sysinfo = { workspace = true } [features] default = ["schemars"] -schemars = ["komorebi-client/default"] +schemars = ["dep:schemars", "komorebi-client/default"] [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(FALSE)'] } diff --git a/komorebic/src/main.rs b/komorebic/src/main.rs index f2096819..6b41585c 100644 --- a/komorebic/src/main.rs +++ b/komorebic/src/main.rs @@ -84,7 +84,10 @@ //! The others work without the daemon: `start` runs `komorebi` in the background, with the //! `komorebi.json` it is given with `--config`, `configuration` shows the path of the //! `komorebi.json` the daemon reads, `log` follows its log, `data-directory` shows where its -//! socket is, and `completions` prints a completion script for a shell. The hidden `docgen` writes the help of every subcommand as Markdown, wrapped +//! socket is, `completions` prints a completion script for a shell, and `static-config-schema`, +//! `application-specific-configuration-schema`, `notification-schema` and `socket-schema` print +//! the JSON Schema of `komorebi.json`, of an `applications.json`, of a notification and of a +//! message. The hidden `docgen` writes the help of every subcommand as Markdown, wrapped //! at 100 columns whatever the width of the terminal, which is how `docs/cli` is made. use chrono::Utc; @@ -1029,6 +1032,15 @@ enum SubCommand { MouseFollowsFocus(MouseFollowsFocus), /// Toggle mouse follows focus on all workspaces ToggleMouseFollowsFocus, + /// Generate a JSON Schema for applications.json + #[clap(alias = "asc-schema")] + ApplicationSpecificConfigurationSchema, + /// Generate a JSON Schema of subscription notifications + NotificationSchema, + /// Generate a JSON Schema of socket messages + SocketSchema, + /// Generate a JSON Schema of the static configuration file + StaticConfigSchema, /// Generates a static configuration JSON file based on the current window manager state GenerateStaticConfig, } @@ -1622,6 +1634,38 @@ fn main() -> eyre::Result<()> { SubCommand::MouseFollowsFocus(args) => { send_message(&SocketMessage::MouseFollowsFocus(args.boolean_state.into()))?; } + SubCommand::ApplicationSpecificConfigurationSchema => { + #[cfg(feature = "schemars")] + { + let asc = schemars::schema_for!(komorebi_client::ApplicationSpecificConfiguration); + let schema = serde_json::to_string_pretty(&asc)?; + println!("{schema}"); + } + } + SubCommand::NotificationSchema => { + #[cfg(feature = "schemars")] + { + let notification = schemars::schema_for!(komorebi_client::Notification); + let schema = serde_json::to_string_pretty(¬ification)?; + println!("{schema}"); + } + } + SubCommand::SocketSchema => { + #[cfg(feature = "schemars")] + { + let socket_message = schemars::schema_for!(SocketMessage); + let schema = serde_json::to_string_pretty(&socket_message)?; + println!("{schema}"); + } + } + SubCommand::StaticConfigSchema => { + #[cfg(feature = "schemars")] + { + let socket_message = schemars::schema_for!(komorebi_client::StaticConfig); + let schema = serde_json::to_string_pretty(&socket_message)?; + println!("{schema}"); + } + } SubCommand::SubscribeSocket(args) => { send_message(&SocketMessage::AddSubscriberSocket(args.socket))?; } diff --git a/komorebic/tests/cli.rs b/komorebic/tests/cli.rs index 7bd1cbf2..c5d0c9b0 100644 --- a/komorebic/tests/cli.rs +++ b/komorebic/tests/cli.rs @@ -692,6 +692,50 @@ fn a_config_home_that_is_not_a_directory_is_refused() { ); } +/// The JSON `komorebic` prints for a subcommand that needs no daemon +fn printed(args: &[&str]) -> serde_json::Value { + let runtime = Runtime::new(); + + let output = runtime.komorebic(args); + + assert!(output.status.success(), "{args:?}: {output:?}"); + serde_json::from_str(&stdout(&output)).unwrap_or_else(|error| panic!("{args:?}: {error}")) +} + +#[test] +fn the_schema_subcommands_print_a_json_schema_without_a_daemon() { + let configuration = printed(&["static-config-schema"]); + assert!(configuration["properties"]["resize_delta"].is_object()); + assert!(configuration["$defs"]["MonitorConfig"].is_object()); + + // `asc-schema` is another name for it + let applications = printed(&["application-specific-configuration-schema"]); + assert_eq!(applications, printed(&["asc-schema"])); + assert!(applications["$defs"]["AscApplicationRules"]["properties"]["floating"].is_object()); + + assert!(printed(&["notification-schema"])["$defs"]["WindowManagerEvent"].is_object()); + assert!(printed(&["socket-schema"])["oneOf"].is_array()); +} + +#[test] +fn the_schema_files_of_the_repository_are_what_the_subcommands_print() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap(); + + for (file, subcommand) in [ + ("schema.json", "static-config-schema"), + ( + "schema.asc.json", + "application-specific-configuration-schema", + ), + ] { + let runtime = Runtime::new(); + let printed = stdout(&runtime.komorebic(&[subcommand])); + let committed = std::fs::read_to_string(root.join(file)).unwrap(); + + assert_eq!(printed, committed, "{file} is stale: run `just jsonschema`"); + } +} + #[test] fn the_monitor_subcommands_send_what_they_are_given() { sends( @@ -813,6 +857,7 @@ fn docgen_writes_the_help_of_every_subcommand_in_a_text_fence() { [ "adjust-container-padding.md", "adjust-workspace-padding.md", + "application-specific-configuration-schema.md", "cancel-preselect.md", "change-layout.md", "clear-named-workspace-layout-rules.md", @@ -881,6 +926,7 @@ fn docgen_writes_the_help_of_every_subcommand_in_a_text_fence() { "named-workspace-padding.md", "named-workspace-tiling.md", "new-workspace.md", + "notification-schema.md", "preselect-direction.md", "promote-focus.md", "promote-swap.md", @@ -902,10 +948,12 @@ fn docgen_writes_the_help_of_every_subcommand_in_a_text_fence() { "send-to-workspace.md", "session-float-rule.md", "session-float-rules.md", + "socket-schema.md", "stack-all.md", "stack.md", "start.md", "state.md", + "static-config-schema.md", "stop.md", "subscribe-socket.md", "swap-workspaces-with-monitor.md", diff --git a/nix/komorebi-options.nix b/nix/komorebi-options.nix new file mode 100644 index 00000000..05c85793 --- /dev/null +++ b/nix/komorebi-options.nix @@ -0,0 +1,514 @@ +# schema-sha256: 2bb6a47d645d5027d15ea70545473ac3dd417e8a2d7ac932d3c593d71a539dbd +{ lib, ... }: +let + defaultLayout = ( + lib.types.enum [ + "BSP" + "Columns" + "Rows" + "VerticalStack" + "HorizontalStack" + "UltrawideVerticalStack" + "Grid" + "RightMainVerticalStack" + "Scrolling" + ] + ); + floatingLayerBehaviour = ( + lib.types.enum [ + "Tile" + "Float" + ] + ); + idWithIdentifier = ( + lib.types.submodule { + options = { + id = lib.mkOption { + type = lib.types.str; + description = "Target identifier"; + }; + kind = lib.mkOption { + type = ( + lib.types.enum [ + "Exe" + "Class" + "Title" + "Path" + ] + ); + description = "Kind of identifier to target"; + }; + matching_strategy = lib.mkOption { + type = ( + lib.types.nullOr ( + lib.types.enum [ + "Legacy" + "Equals" + "StartsWith" + "EndsWith" + "Contains" + "Regex" + "DoesNotEndWith" + "DoesNotStartWith" + "DoesNotEqual" + "DoesNotContain" + ] + ) + ); + default = null; + description = "Matching strategy to use"; + }; + }; + } + ); + layoutOptions = ( + lib.types.submodule { + options = { + column_ratios = lib.mkOption { + type = (lib.types.nullOr (lib.types.listOf (lib.types.nullOr lib.types.number))); + default = null; + description = "Column width ratios (up to MAX_RATIOS values between 0.1 and 0.9)\n\n- Used by Columns layout: ratios for each column width\n- Used by Grid layout: ratios for column widths\n- Used by BSP, VerticalStack, RightMainVerticalStack: column_ratios[0] as primary split ratio\n- Used by HorizontalStack: column_ratios[0] as primary split ratio (top area height)\n- Used by UltrawideVerticalStack: column_ratios[0] as center ratio, column_ratios[1] as left ratio\n\nColumns without a ratio share remaining space equally.\nExample: `[0.3, 0.4, 0.3]` for 30%-40%-30% columns"; + }; + grid = lib.mkOption { + type = ( + lib.types.nullOr ( + lib.types.submodule { + options = { + rows = lib.mkOption { + type = lib.types.int; + description = "Maximum number of rows per grid column"; + }; + }; + } + ) + ); + default = null; + description = "Options related to the Grid layout"; + }; + row_ratios = lib.mkOption { + type = (lib.types.nullOr (lib.types.listOf (lib.types.nullOr lib.types.number))); + default = null; + description = "Row height ratios (up to MAX_RATIOS values between 0.1 and 0.9)\n\n- Used by Rows layout: ratios for each row height\n- Used by Grid layout: ratios for row heights\n\nRows without a ratio share remaining space equally.\nExample: `[0.5, 0.5]` for 50%-50% rows"; + }; + scrolling = lib.mkOption { + type = ( + lib.types.nullOr ( + lib.types.submodule { + options = { + center_focused_column = lib.mkOption { + type = (lib.types.nullOr lib.types.bool); + default = null; + description = "With an odd number of visible columns, keep the focused window column centered"; + }; + columns = lib.mkOption { + type = lib.types.int; + description = "Desired number of visible columns (default: 3)"; + }; + }; + } + ) + ); + default = null; + description = "Options related to the Scrolling layout"; + }; + }; + } + ); + matchingRule = ( + lib.types.oneOf [ + idWithIdentifier + (lib.types.listOf idWithIdentifier) + ] + ); + pathBuf = lib.types.str; + placement = ( + lib.types.enum [ + "None" + "Center" + "CenterAndResize" + ] + ); + rect = ( + lib.types.submodule { + options = { + bottom = lib.mkOption { + type = lib.types.int; + description = "Height of the rectangle (from the top point)"; + }; + left = lib.mkOption { + type = lib.types.int; + description = "Left point of the rectangle"; + }; + right = lib.mkOption { + type = lib.types.int; + description = "Width of the recentangle (from the left point)"; + }; + top = lib.mkOption { + type = lib.types.int; + description = "Top point of the rectangle"; + }; + }; + } + ); + windowContainerBehaviour = ( + lib.types.enum [ + "Create" + "Append" + ] + ); +in +{ + options.komorebi = lib.mkOption { + type = lib.types.submodule { + options = { + app_specific_configuration_path = lib.mkOption { + type = ( + lib.types.nullOr ( + lib.types.oneOf [ + pathBuf + (lib.types.listOf pathBuf) + ] + ) + ); + default = null; + description = "Path to an applications.json, or a list of them, with rules for the applications that\nneed them: which windows to ignore, to manage and to float"; + }; + cross_boundary_behaviour = lib.mkOption { + type = ( + lib.types.nullOr ( + lib.types.enum [ + "Workspace" + "Monitor" + ] + ) + ); + default = "Monitor"; + description = "Determine what happens when an action is called on a window at a monitor boundary"; + }; + cross_monitor_move_behaviour = lib.mkOption { + type = ( + lib.types.nullOr ( + lib.types.enum [ + "Swap" + "Insert" + "NoOp" + ] + ) + ); + default = "Swap"; + description = "Determine what happens when a window is moved across a monitor boundary"; + }; + default_container_padding = lib.mkOption { + type = (lib.types.nullOr lib.types.int); + default = 5; + description = "Global default container padding"; + }; + default_workspace_padding = lib.mkOption { + type = (lib.types.nullOr lib.types.int); + default = 5; + description = "Global default workspace padding"; + }; + display_index_preferences = lib.mkOption { + type = (lib.types.nullOr (lib.types.attrsOf lib.types.str)); + default = null; + description = "Set display index preferences"; + }; + float_override = lib.mkOption { + type = (lib.types.nullOr lib.types.bool); + default = false; + description = "Enable or disable float override, which makes it so every new window opens in floating mode"; + }; + float_override_placement = lib.mkOption { + type = (lib.types.nullOr placement); + default = null; + description = "Determines the `Placement` to be used when spawning a window with float override active"; + }; + float_rule_placement = lib.mkOption { + type = (lib.types.nullOr placement); + default = null; + description = "Determines the `Placement` to be used when spawning a window that matches a\n'floating_applications' rule"; + }; + floating_applications = lib.mkOption { + type = (lib.types.nullOr (lib.types.listOf matchingRule)); + default = null; + description = "Identify applications which should be managed as floating windows"; + }; + floating_layer_behaviour = lib.mkOption { + type = (lib.types.nullOr floatingLayerBehaviour); + default = "Tile"; + description = "Determines what happens on a new window when on the `FloatingLayer`"; + }; + floating_layer_placement = lib.mkOption { + type = (lib.types.nullOr placement); + default = "Center"; + description = "Determines the `Placement` to be used when spawning a window on the floating layer with the\n`FloatingLayerBehaviour` set to `FloatingLayerBehaviour::Float`"; + }; + floating_window_aspect_ratio = lib.mkOption { + type = ( + lib.types.nullOr ( + lib.types.oneOf [ + (lib.types.enum [ + "Ultrawide" + "Widescreen" + "Standard" + ]) + (lib.types.listOf lib.types.int) + ] + ) + ); + default = null; + description = "Aspect ratio to resize with when toggling floating mode for a window"; + }; + global_work_area_offset = lib.mkOption { + type = (lib.types.nullOr rect); + default = null; + description = "Global work area (space used for tiling) offset"; + }; + ignore_rules = lib.mkOption { + type = (lib.types.nullOr (lib.types.listOf matchingRule)); + default = null; + description = "Individual window floating rules"; + }; + layout_defaults = lib.mkOption { + type = ( + lib.types.nullOr ( + lib.types.attrsOf ( + lib.types.submodule { + options = { + layout_options = lib.mkOption { + type = (lib.types.nullOr layoutOptions); + default = null; + description = "Default layout options for this layout"; + }; + layout_options_rules = lib.mkOption { + type = (lib.types.nullOr (lib.types.attrsOf layoutOptions)); + default = null; + description = "Threshold-based layout options rules in the format of threshold => options.\nWhen container count >= threshold, the highest matching threshold's options\nfully replace the base `layout_options`."; + }; + }; + } + ) + ) + ); + default = null; + description = "Per-layout default options and rules, keyed by layout name.\nApplied as fallback when a workspace does not define its own layout_options or layout_options_rules.\nIf a workspace defines either setting, all global defaults for that layout are completely replaced."; + }; + manage_rules = lib.mkOption { + type = (lib.types.nullOr (lib.types.listOf matchingRule)); + default = null; + description = "Individual window force-manage rules"; + }; + monitors = lib.mkOption { + type = ( + lib.types.nullOr ( + lib.types.listOf ( + lib.types.submodule { + options = { + container_padding = lib.mkOption { + type = (lib.types.nullOr lib.types.int); + default = null; + description = "Container padding"; + }; + floating_layer_behaviour = lib.mkOption { + type = (lib.types.nullOr floatingLayerBehaviour); + default = "Tile"; + description = "Determine what happens to a new window when the Floating workspace layer is active"; + }; + window_based_work_area_offset = lib.mkOption { + type = (lib.types.nullOr rect); + default = null; + description = "Window based work area offset"; + }; + window_based_work_area_offset_limit = lib.mkOption { + type = (lib.types.nullOr lib.types.int); + default = 1; + description = "Open window limit after which the window based work area offset will no longer be applied"; + }; + window_hiding_position = lib.mkOption { + type = ( + lib.types.nullOr ( + lib.types.enum [ + "BottomLeft" + "BottomRight" + ] + ) + ); + default = null; + description = "Accepted so that a configuration written for another platform loads, and ignored: there is\nno hiding position on KDE Plasma"; + }; + work_area_offset = lib.mkOption { + type = (lib.types.nullOr rect); + default = null; + description = "Monitor-specific work area offset"; + }; + workspace_padding = lib.mkOption { + type = (lib.types.nullOr lib.types.int); + default = null; + description = "Workspace padding (default: global)"; + }; + workspaces = lib.mkOption { + type = ( + lib.types.listOf ( + lib.types.submodule { + options = { + apply_window_based_work_area_offset = lib.mkOption { + type = (lib.types.nullOr lib.types.bool); + default = true; + description = "Apply this monitor's window-based work area offset"; + }; + container_padding = lib.mkOption { + type = (lib.types.nullOr lib.types.int); + default = null; + description = "Container padding (default: global)"; + }; + float_override = lib.mkOption { + type = (lib.types.nullOr lib.types.bool); + default = false; + description = "Enable or disable float override, which makes it so every new window opens in floating mode"; + }; + floating_layer_behaviour = lib.mkOption { + type = (lib.types.nullOr floatingLayerBehaviour); + default = "Tile"; + description = "Determine what happens to a new window when the Floating workspace layer is active"; + }; + layout = lib.mkOption { + type = (lib.types.nullOr defaultLayout); + default = "BSP"; + description = "Layout"; + }; + layout_flip = lib.mkOption { + type = ( + lib.types.nullOr ( + lib.types.enum [ + "Horizontal" + "Vertical" + "HorizontalAndVertical" + ] + ) + ); + default = null; + description = "Specify an axis on which to flip the selected layout"; + }; + layout_options = lib.mkOption { + type = (lib.types.nullOr layoutOptions); + default = null; + description = "Layout-specific options"; + }; + layout_options_rules = lib.mkOption { + type = (lib.types.nullOr (lib.types.attrsOf layoutOptions)); + default = null; + description = "Threshold-based layout options rules in the format of threshold => options.\nWhen container count >= threshold, the highest matching threshold's options\nfully replace the base `layout_options`.\nThis follows the same threshold logic as `layout_rules`."; + }; + layout_rules = lib.mkOption { + type = (lib.types.nullOr (lib.types.attrsOf defaultLayout)); + default = null; + description = "Layout rules in the format of threshold => layout"; + }; + name = lib.mkOption { + type = lib.types.str; + description = "Name"; + }; + tile = lib.mkOption { + type = (lib.types.nullOr lib.types.bool); + default = true; + description = "Enable or disable tiling for the workspace"; + }; + window_container_behaviour = lib.mkOption { + type = (lib.types.nullOr windowContainerBehaviour); + default = "Create"; + description = "Determine what happens when a new window is opened"; + }; + window_container_behaviour_rules = lib.mkOption { + type = (lib.types.nullOr (lib.types.attrsOf windowContainerBehaviour)); + default = null; + description = "Window container behavior rules in the format of threshold => behavior"; + }; + work_area_offset = lib.mkOption { + type = (lib.types.nullOr rect); + default = null; + description = "Workspace specific work area offset"; + }; + work_area_offset_rules = lib.mkOption { + type = (lib.types.nullOr (lib.types.attrsOf rect)); + default = null; + description = "Work area offset rules in the format of threshold => Rect (default: None)"; + }; + workspace_padding = lib.mkOption { + type = (lib.types.nullOr lib.types.int); + default = null; + description = "Workspace padding (default: global)"; + }; + }; + } + ) + ); + description = "Workspace configurations"; + }; + }; + } + ) + ) + ); + default = null; + description = "Monitor and workspace configurations"; + }; + monocle_focus_behaviour = lib.mkOption { + type = ( + lib.types.nullOr ( + lib.types.enum [ + "Cycle" + "NoOp" + ] + ) + ); + default = "NoOp"; + description = "Determine what happens when focusing in a direction while a monocle container is active"; + }; + mouse_follows_focus = lib.mkOption { + type = (lib.types.nullOr lib.types.bool); + default = true; + description = "Enable or disable mouse follows focus"; + }; + resize_delta = lib.mkOption { + type = (lib.types.nullOr lib.types.int); + default = 50; + description = "Delta to resize windows by"; + }; + tabbed_applications = lib.mkOption { + type = (lib.types.nullOr (lib.types.listOf lib.types.str)); + default = null; + description = "Accepted so that a configuration written for another platform loads, and ignored: a KWin\nwindow has no native tabs"; + }; + titleless_applications = lib.mkOption { + type = (lib.types.nullOr (lib.types.listOf lib.types.str)); + default = null; + description = "Accepted so that a configuration written for another platform loads, and ignored: a KWin\nwindow is always a window"; + }; + toggle_float_placement = lib.mkOption { + type = (lib.types.nullOr placement); + default = "CenterAndResize"; + description = "Determines the placement of a new window when toggling to float"; + }; + unmanaged_window_operation_behaviour = lib.mkOption { + type = ( + lib.types.nullOr ( + lib.types.enum [ + "Op" + "NoOp" + ] + ) + ); + default = "Op"; + description = "Determine what happens when commands are sent while an unmanaged window is in the foreground"; + }; + window_container_behaviour = lib.mkOption { + type = (lib.types.nullOr windowContainerBehaviour); + default = "Create"; + description = "Determine what happens when a new window is opened"; + }; + }; + }; + default = { }; + description = "komorebi for KDE Plasma configuration"; + }; +} diff --git a/nix/nixos-module.nix b/nix/nixos-module.nix index fbef4626..01c2b2f7 100644 --- a/nix/nixos-module.nix +++ b/nix/nixos-module.nix @@ -11,6 +11,83 @@ }: let cfg = config.services.komorebi; + + jsonFormat = pkgs.formats.json { }; + + # The options `just nixgen` makes from `schema.json`, which type `config` + komorebiOptionsModule = import ./komorebi-options.nix { inherit lib; }; + + # the Nix module system uses null for an optional field that is not set, and the schema wants + # the field to be absent + filterNulls = + value: + if value == null then + null + else if builtins.isList value then + map filterNulls (builtins.filter (x: x != null) value) + else if builtins.isAttrs value then + lib.filterAttrs (_n: v: v != null) (builtins.mapAttrs (_n: v: filterNulls v) value) + else + value; + + # A komorebi.json, checked against the schema the Rust types make (`schema.json`). Comment + # lines are taken out first: komorebi reads JSON with comments, and the schema checker does not. + validateJsonFile = + { + name, + jsonFile, + }: + pkgs.runCommand "validated-${name}" { nativeBuildInputs = [ pkgs.check-jsonschema ]; } '' + grep -v '^\s*//' ${jsonFile} > clean.json + check-jsonschema --schemafile ${../schema.json} clean.json + cp clean.json $out + ''; + + fetchAppSpecificSource = + source: + pkgs.fetchurl { + url = "https://raw.githubusercontent.com/${source.owner}/${source.repo}/${source.rev}/${source.path}"; + inherit (source) hash; + }; + + applicationSpecificConfigFiles = map fetchAppSpecificSource cfg.applicationSpecificConfiguration.sources; + + # one file is a path, several are a list of paths + appSpecificPaths = + if builtins.length applicationSpecificConfigFiles == 1 then + "${builtins.head applicationSpecificConfigFiles}" + else + map (f: "${f}") applicationSpecificConfigFiles; + + shouldUseAppSpecific = + cfg.applicationSpecificConfiguration.enable && applicationSpecificConfigFiles != [ ]; + + # What `config` and `applicationSpecificConfiguration` make: the komorebi.json as an attribute + # set, with no null in it, or null when there is nothing to write + configAttrs = + if cfg.config == null && !shouldUseAppSpecific then + null + else + filterNulls ( + (if cfg.config == null then { } else cfg.config) + // lib.optionalAttrs shouldUseAppSpecific { app_specific_configuration_path = appSpecificPaths; } + ); + + # the file the daemon is given: the one `configFile` names, or the one the options make, each + # checked against the schema + komorebiConfigFile = + if cfg.configFile != null then + validateJsonFile { + name = "komorebi.json"; + jsonFile = cfg.configFile; + } + else if configAttrs != null then + validateJsonFile { + name = "komorebi.json"; + jsonFile = jsonFormat.generate "komorebi.json" configAttrs; + } + else + null; in { options.services.komorebi = { @@ -44,12 +121,110 @@ in default = null; example = lib.literalExpression "./komorebi.json"; description = '' - The `komorebi.json` the daemon is configured by. The unit starts komorebi with it, as - `--config`, in place of the `komorebi.json` the daemon looks for in `~/.config/komorebi` + The `komorebi.json` the daemon is configured by, checked against the schema of + `komorebi.json` when the system is built. The unit starts komorebi with it, as `--config`, + in place of the `komorebi.json` the daemon looks for in `~/.config/komorebi` (`$KOMOREBI_CONFIG_HOME`). The file is in the Nix store, so it does not change under the daemon: a new file is read when the daemon starts again. `komorebic configuration` still shows the file of the config home, which the daemon does not read while this is set. With - no `configFile`, the daemon reads the file of the config home, if there is one. + neither `configFile` nor `config`, the daemon reads the file of the config home, if there + is one. Ignored by `config`, which is ignored when this is set. + ''; + }; + + config = lib.mkOption { + type = lib.types.nullOr komorebiOptionsModule.options.komorebi.type; + default = null; + example = lib.literalExpression '' + { + default_workspace_padding = 15; + monitors = [ + { + workspaces = [ + { name = "web"; layout = "BSP"; } + { name = "code"; layout = "Columns"; } + ]; + } + ]; + } + ''; + description = '' + The configuration of komorebi, written as Nix: every key of `komorebi.json`, typed from + the schema, so a layout that does not exist or a value of the wrong kind does not + evaluate. The module writes it as a `komorebi.json`, checks that against the schema, and + starts komorebi with it as `--config`. Ignored if `configFile` is set. A key you leave + out is the default the schema gives it, if it gives one, and the daemon's own otherwise. + ''; + }; + + applicationSpecificConfiguration = { + enable = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Whether to use the sources of application specific configuration."; + }; + + sources = lib.mkOption { + type = lib.types.listOf ( + lib.types.submodule { + options = { + owner = lib.mkOption { + type = lib.types.str; + description = "GitHub repository owner."; + example = "LGUG2Z"; + }; + repo = lib.mkOption { + type = lib.types.str; + description = "GitHub repository name."; + example = "komorebi-application-specific-configuration"; + }; + rev = lib.mkOption { + type = lib.types.str; + description = "Git revision (commit hash, tag, or branch)."; + example = "09ebdcd95780b168cecde2f2d49096a58e91365f"; + }; + path = lib.mkOption { + type = lib.types.str; + default = "applications.json"; + description = "Path to the JSON file within the repository."; + }; + hash = lib.mkOption { + type = lib.types.str; + description = "SHA256 hash of the file, for reproducibility."; + example = "sha256-gfYubJpVEU6vxdd/vqujQhQX8mdAEE9ImugqdIyTQKk="; + }; + }; + } + ); + default = [ ]; + description = '' + Files of application specific configuration, each fetched from GitHub, which the module + gives the daemon as `app_specific_configuration_path` of the configuration it writes + (it replaces the value `config` has for that key). There are none by default: no + community file for KDE Plasma is known. Ignored when `configFile` is set. + ''; + example = lib.literalExpression '' + [ + { + owner = "myuser"; + repo = "my-komorebi-rules"; + rev = "0123456789abcdef0123456789abcdef01234567"; + path = "applications.json"; + hash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; + } + ] + ''; + }; + }; + + internal.configAttrs = lib.mkOption { + type = lib.types.nullOr lib.types.attrs; + readOnly = true; + internal = true; + description = '' + What `config` and `applicationSpecificConfiguration` make, before it is written as JSON: + the `komorebi.json` as an attribute set with no null in it, or null when neither is set. + The tests of the module read it, so that they need not build the file. ''; }; @@ -58,8 +233,8 @@ in readOnly = true; description = '' The command line the unit starts komorebi with: `bin/komorebi` of `package`, with - `logLevel`, and with `--config` and `configFile` when that is set. The module sets it, - and setting it anywhere else is an error. A unit of your own that runs komorebi somewhere + `logLevel`, and with `--config` and the checked `komorebi.json` when `configFile` or + `config` is set. The module sets it, and setting it anywhere else is an error. A unit of your own that runs komorebi somewhere else, as the VM tests do in their headless sessions, starts it with this, the way the module does. ''; @@ -74,11 +249,12 @@ in "--log-level" cfg.logLevel ] - ++ lib.optionals (cfg.configFile != null) [ + ++ lib.optionals (komorebiConfigFile != null) [ "--config" - "${cfg.configFile}" + "${komorebiConfigFile}" ] ); + services.komorebi.internal.configAttrs = configAttrs; } (lib.mkIf cfg.enable { @@ -92,6 +268,14 @@ in } ]; + warnings = + lib.optional ( + cfg.configFile != null && cfg.config != null + ) "services.komorebi.config is ignored: services.komorebi.configFile is set." + ++ + lib.optional (cfg.configFile != null && cfg.applicationSpecificConfiguration.sources != [ ]) + "services.komorebi.applicationSpecificConfiguration is ignored: services.komorebi.configFile is set."; + environment.systemPackages = [ cfg.package ]; # A user unit, so it runs for every user who logs in to Plasma. plasma-workspace.target is diff --git a/nix/tests/default.nix b/nix/tests/default.nix new file mode 100644 index 00000000..696979af --- /dev/null +++ b/nix/tests/default.nix @@ -0,0 +1,57 @@ +# `nix-options-validation`: the options `just nixgen` makes from `schema.json`, evaluated against +# the example configuration, whose JSON is checked against the schema, and against the example +# file of the docs. Run with `nix build -f nix/tests/default.nix`, or as a check of the flake. +{ + pkgs ? import { }, +}: +let + lib = pkgs.lib; + + # the Nix module system uses null for an optional field that is not set, and the schema wants + # the field to be absent + filterNulls = + value: + if value == null then + null + else if builtins.isList value then + map filterNulls (builtins.filter (x: x != null) value) + else if builtins.isAttrs value then + lib.filterAttrs (_n: v: v != null) (builtins.mapAttrs (_n: v: filterNulls v) value) + else + value; + + evaluated = + (lib.evalModules { + modules = [ + (import ../komorebi-options.nix) + (import ./komorebi-example.nix) + ]; + }).config.komorebi; + + evaluatedJson = pkgs.writeText "komorebi-example-from-nix.json" ( + builtins.toJSON (filterNulls evaluated) + ); + + exampleJson = ../../docs/komorebi.example.json; + schema = ../../schema.json; +in +pkgs.runCommand "nix-options-validation" + { + nativeBuildInputs = [ + pkgs.check-jsonschema + pkgs.jq + ]; + } + '' + echo "Validating the example, written in Nix, against the schema..." + check-jsonschema --schemafile ${schema} ${evaluatedJson} + + echo "Validating the example file of the docs against the schema..." + check-jsonschema --schemafile ${schema} ${exampleJson} + + echo "Checking that the two examples say the same..." + jq -e --slurpfile example ${exampleJson} 'contains($example[0])' ${evaluatedJson} > /dev/null + + echo "All validations passed!" + touch $out + '' diff --git a/nix/tests/komorebi-example.nix b/nix/tests/komorebi-example.nix new file mode 100644 index 00000000..039ab08b --- /dev/null +++ b/nix/tests/komorebi-example.nix @@ -0,0 +1,55 @@ +# Nix equivalent of docs/komorebi.example.json +# This tests that users can write the exact same config in Nix +{ ... }: +{ + komorebi = { + app_specific_configuration_path = "$HOME/.config/komorebi/applications.json"; + cross_monitor_move_behaviour = "Insert"; + default_workspace_padding = 15; + default_container_padding = 15; + floating_window_aspect_ratio = "Widescreen"; + floating_layer_behaviour = "Float"; + resize_delta = 100; + ignore_rules = [ ]; + floating_applications = [ ]; + manage_rules = [ ]; + monitors = [ + { + workspaces = [ + { + name = "I"; + layout = "BSP"; + } + { + name = "II"; + layout = "VerticalStack"; + } + { + name = "III"; + layout = "HorizontalStack"; + } + { + name = "IV"; + layout = "UltrawideVerticalStack"; + } + { + name = "V"; + layout = "Rows"; + } + { + name = "VI"; + layout = "Grid"; + } + { + name = "VII"; + layout = "RightMainVerticalStack"; + } + { + name = "VIII"; + layout = "Scrolling"; + } + ]; + } + ]; + }; +} diff --git a/nix/tests/nixos-module.nix b/nix/tests/nixos-module.nix index 558b40ce..1110b780 100644 --- a/nix/tests/nixos-module.nix +++ b/nix/tests/nixos-module.nix @@ -83,6 +83,131 @@ let } ); + withConfig = eval { + imports = [ plasma ]; + services.komorebi = { + enable = true; + config = { + default_workspace_padding = 20; + window_container_behaviour = "Append"; + float_override_placement = "Center"; + monocle_focus_behaviour = "Cycle"; + monitors = [ + { + floating_layer_behaviour = "Tile"; + workspaces = [ + { + name = "web"; + layout = "BSP"; + } + { + name = "code"; + layout = "Columns"; + window_container_behaviour_rules."2" = "Append"; + float_override = false; + floating_layer_behaviour = "Float"; + } + ]; + } + ]; + }; + }; + }; + + # a layout komorebi does not have + badLayout = eval { + imports = [ plasma ]; + services.komorebi = { + enable = true; + config.monitors = [ + { + workspaces = [ + { + name = "x"; + layout = "Nope"; + } + ]; + } + ]; + }; + }; + + withSource = eval ( + { pkgs, ... }: + { + imports = [ plasma ]; + services.komorebi = { + enable = true; + config.resize_delta = 70; + applicationSpecificConfiguration.sources = [ + { + owner = "someone"; + repo = "komorebi-rules"; + rev = "0123456789abcdef0123456789abcdef01234567"; + hash = pkgs.lib.fakeHash; + } + ]; + }; + } + ); + + # the same source and config, with the sources turned off + withSourceOff = eval ( + { pkgs, ... }: + { + imports = [ plasma ]; + services.komorebi = { + enable = true; + config.resize_delta = 70; + applicationSpecificConfiguration = { + enable = false; + sources = [ + { + owner = "someone"; + repo = "komorebi-rules"; + rev = "0123456789abcdef0123456789abcdef01234567"; + hash = pkgs.lib.fakeHash; + } + ]; + }; + }; + } + ); + + # the sources turned off, and no config: nothing is left to write + onlySourceOff = eval ( + { pkgs, ... }: + { + imports = [ plasma ]; + services.komorebi = { + enable = true; + applicationSpecificConfiguration = { + enable = false; + sources = [ + { + owner = "someone"; + repo = "komorebi-rules"; + rev = "0123456789abcdef0123456789abcdef01234567"; + hash = pkgs.lib.fakeHash; + } + ]; + }; + }; + } + ); + + bothConfigs = eval ( + { pkgs, ... }: + { + imports = [ plasma ]; + services.komorebi = { + enable = true; + configFile = pkgs.writeText "komorebi.json" "{}"; + config.resize_delta = 70; + }; + } + ); + unknownLevel = eval { imports = [ plasma ]; services.komorebi = { @@ -196,11 +321,87 @@ let { name = "configFile reaches execStart and the unit as --config, after the log level"; ok = - withConfigFile.services.komorebi.execStart - == "${withConfigFile.services.komorebi.package}/bin/komorebi --log-level info --config ${withConfigFile.services.komorebi.configFile}" + lib.hasPrefix "${withConfigFile.services.komorebi.package}/bin/komorebi --log-level info --config /nix/store/" withConfigFile.services.komorebi.execStart && (unit withConfigFile).serviceConfig.ExecStart == withConfigFile.services.komorebi.execStart; got = (unit withConfigFile).serviceConfig.ExecStart; } + { + name = "with neither a configFile nor a config, there is nothing to write"; + ok = enabled.services.komorebi.internal.configAttrs == null; + } + { + name = "config is the komorebi.json the daemon is given, with no null left in it"; + ok = + withConfig.services.komorebi.internal.configAttrs.default_workspace_padding == 20 + && + map (w: w.name) (lib.head withConfig.services.komorebi.internal.configAttrs.monitors).workspaces + == [ + "web" + "code" + ] + && !(lib.hasInfix "null" (builtins.toJSON withConfig.services.komorebi.internal.configAttrs)); + got = withConfig.services.komorebi.internal.configAttrs; + } + { + name = "the keys of the stacks, the float override, monocle and the floating layer are typed options"; + ok = + withConfig.services.komorebi.internal.configAttrs.window_container_behaviour == "Append" + && withConfig.services.komorebi.internal.configAttrs.float_override_placement == "Center" + && withConfig.services.komorebi.internal.configAttrs.monocle_focus_behaviour == "Cycle" + && + (lib.head withConfig.services.komorebi.internal.configAttrs.monitors).floating_layer_behaviour + == "Tile" + && + (lib.elemAt (lib.head withConfig.services.komorebi.internal.configAttrs.monitors).workspaces 1) + .window_container_behaviour_rules."2" == "Append"; + got = withConfig.services.komorebi.internal.configAttrs; + } + { + name = "config reaches execStart as a komorebi.json that was checked against the schema"; + ok = + lib.hasInfix " --config " withConfig.services.komorebi.execStart + && lib.hasSuffix "-validated-komorebi.json" withConfig.services.komorebi.execStart + && (unit withConfig).serviceConfig.ExecStart == withConfig.services.komorebi.execStart; + got = withConfig.services.komorebi.execStart; + } + { + name = "configFile is checked against the schema too"; + ok = lib.hasSuffix "-validated-komorebi.json" withConfigFile.services.komorebi.execStart; + got = withConfigFile.services.komorebi.execStart; + } + { + name = "a layout komorebi does not have does not evaluate"; + ok = !(evaluates (builtins.deepSeq badLayout.services.komorebi.internal.configAttrs true)); + } + { + name = "a source of application specific configuration is the daemon's app_specific_configuration_path"; + ok = + lib.hasSuffix "-applications.json" withSource.services.komorebi.internal.configAttrs.app_specific_configuration_path + && withSource.services.komorebi.internal.configAttrs.resize_delta == 70; + got = withSource.services.komorebi.internal.configAttrs.app_specific_configuration_path; + } + { + name = "with applicationSpecificConfiguration.enable = false, the sources are left out of config"; + ok = + withSource.services.komorebi.internal.configAttrs ? app_specific_configuration_path + && !(withSourceOff.services.komorebi.internal.configAttrs ? app_specific_configuration_path) + && withSourceOff.services.komorebi.internal.configAttrs.resize_delta == 70; + got = withSourceOff.services.komorebi.internal.configAttrs; + } + { + name = "with the sources turned off and no config, there is nothing to write and no --config"; + ok = + onlySourceOff.services.komorebi.internal.configAttrs == null + && !(lib.hasInfix "--config" onlySourceOff.services.komorebi.execStart); + got = onlySourceOff.services.komorebi.execStart; + } + { + name = "with a configFile, config is ignored, and the module says so"; + ok = + lib.any (lib.hasInfix "services.komorebi.config is ignored") bothConfigs.warnings + && lib.hasSuffix "-validated-komorebi.json" bothConfigs.services.komorebi.execStart; + got = bothConfigs.warnings; + } { name = "a log level the daemon does not have does not evaluate"; ok = !(evaluates unknownLevel.services.komorebi.logLevel); diff --git a/nix/tests/vm/suites/commands/test.py b/nix/tests/vm/suites/commands/test.py index 1fa95ec2..376feb8d 100644 --- a/nix/tests/vm/suites/commands/test.py +++ b/nix/tests/vm/suites/commands/test.py @@ -426,6 +426,10 @@ for args in [ "data-directory", "configuration", "generate-static-config", + "static-config-schema", + "application-specific-configuration-schema", + "notification-schema", + "socket-schema", "session-float-rules", ]: checks.check(f"komorebic {args}", kde.run(f"komorebic {args}")[0] == 0) diff --git a/nix/tests/vm/suites/module/default.nix b/nix/tests/vm/suites/module/default.nix index f39658c3..2ac9d924 100644 --- a/nix/tests/vm/suites/module/default.nix +++ b/nix/tests/vm/suites/module/default.nix @@ -2,24 +2,25 @@ tier = "fast"; kind = "session"; timeoutMinutes = 10; - # the unit starts the daemon with this file, as `--config`: two named workspaces, and a padding - # that moves the tile of the first window away from the edge of the screen - node = - { pkgs, ... }: - { - services.komorebi.configFile = pkgs.writeText "komorebi.json" '' - { - "default_workspace_padding": 40, - "default_container_padding": 0, - "monitors": [ - { - "workspaces": [ - { "name": "web", "layout": "BSP" }, - { "name": "code", "layout": "Columns" } - ] - } - ] - } - ''; - }; + # the module writes this as a komorebi.json, checks it against the schema, and the unit starts + # the daemon with it, as `--config`: two named workspaces, and a padding that moves the tile of + # the first window away from the edge of the screen + node.services.komorebi.config = { + default_workspace_padding = 40; + default_container_padding = 0; + monitors = [ + { + workspaces = [ + { + name = "web"; + layout = "BSP"; + } + { + name = "code"; + layout = "Columns"; + } + ]; + } + ]; + }; } diff --git a/nix/tests/vm/suites/module/test.py b/nix/tests/vm/suites/module/test.py index 173fc992..1ff8a71f 100644 --- a/nix/tests/vm/suites/module/test.py +++ b/nix/tests/vm/suites/module/test.py @@ -1,11 +1,11 @@ # The NixOS module runs komorebi in a real Plasma session: nothing in this test starts the daemon. # What only a real session can tell: that logging in starts the module's user unit through # `plasma-workspace.target`, that the daemon it runs reaches KWin from there and tiles a window -# opened in the session, that it runs with the module's log level, its `configFile` and the PATH -# of the user's session, that systemd starts it again when it is killed, and that a clean stop is -# not a failure. Once the unit is stopped, the daemon is started with a `komorebi.json` of the -# user's, which it follows when the file changes. -# The options and the unit the module writes are checked without a machine by `nixos-module-eval` +# opened in the session, that it runs with the module's log level, the `komorebi.json` the module +# wrote from its `config`, and the PATH of the user's session, that systemd starts it again when +# it is killed, and that a clean stop is not a failure. Once the unit is stopped, the daemon is +# started with a `komorebi.json` of the user's, which it follows when the file changes. The +# options and the unit the module writes are checked without a machine by `nixos-module-eval` # (`nix/tests/nixos-module.nix`). import json import shlex @@ -65,7 +65,7 @@ print(f" the unit runs: {' '.join(command)}") checks.check("the unit runs bin/komorebi", command[0].endswith("/bin/komorebi")) checks.check("... at the module's log level", command[1:3] == ["--log-level", "debug"]) checks.check( - "... with the module's configFile as its --config", + "... with the komorebi.json the module wrote as its --config", len(command) == 5 and command[3] == "--config" and command[4].endswith("-komorebi.json"), ) environment = kde.sh(f"tr '\\0' '\\n' < /proc/{pid}/environ").splitlines() diff --git a/schema.asc.json b/schema.asc.json new file mode 100644 index 00000000..9d61b986 --- /dev/null +++ b/schema.asc.json @@ -0,0 +1,201 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ApplicationSpecificConfiguration", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/AscApplicationRulesOrSchema" + }, + "$defs": { + "ApplicationIdentifier": { + "description": "Application identifier", + "oneOf": [ + { + "description": "Executable name", + "type": "string", + "const": "Exe" + }, + { + "description": "Role or subrole", + "type": "string", + "const": "Class" + }, + { + "description": "Window title", + "type": "string", + "const": "Title" + }, + { + "description": "Executable path", + "type": "string", + "const": "Path" + } + ] + }, + "AscApplicationRules": { + "description": "Rules that determine how an application is handled", + "type": "object", + "properties": { + "floating": { + "description": "Rules to manage specific windows as floating windows", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/$defs/MatchingRule" + } + }, + "ignore": { + "description": "Rules to ignore specific windows", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/$defs/MatchingRule" + } + }, + "manage": { + "description": "Rules to forcibly manage specific windows", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/$defs/MatchingRule" + } + }, + "tabbed": { + "description": "Rules to identify applications which are title-less - only accepts Simple Exe rules", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/$defs/MatchingRule" + } + }, + "titleless": { + "description": "Rules to identify applications which are title-less - only accepts Simple Exe rules", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/$defs/MatchingRule" + } + } + } + }, + "AscApplicationRulesOrSchema": { + "anyOf": [ + { + "$ref": "#/$defs/AscApplicationRules" + }, + { + "type": "string" + } + ] + }, + "IdWithIdentifier": { + "description": "Rule for matching applications", + "type": "object", + "properties": { + "id": { + "description": "Target identifier", + "type": "string" + }, + "kind": { + "description": "Kind of identifier to target", + "$ref": "#/$defs/ApplicationIdentifier" + }, + "matching_strategy": { + "description": "Matching strategy to use", + "anyOf": [ + { + "$ref": "#/$defs/MatchingStrategy" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "kind", + "id" + ] + }, + "MatchingRule": { + "description": "Rule for matching applications", + "anyOf": [ + { + "description": "Simple matching rule which must evaluate to true", + "$ref": "#/$defs/IdWithIdentifier" + }, + { + "description": "Composite matching rule where all conditions must evaluate to true", + "type": "array", + "items": { + "$ref": "#/$defs/IdWithIdentifier" + } + } + ] + }, + "MatchingStrategy": { + "description": "Strategy for matching identifiers", + "oneOf": [ + { + "description": "Should not be used, only kept for backward compatibility", + "type": "string", + "const": "Legacy" + }, + { + "description": "Equals", + "type": "string", + "const": "Equals" + }, + { + "description": "Starts With", + "type": "string", + "const": "StartsWith" + }, + { + "description": "Ends With", + "type": "string", + "const": "EndsWith" + }, + { + "description": "Contains", + "type": "string", + "const": "Contains" + }, + { + "description": "Regex", + "type": "string", + "const": "Regex" + }, + { + "description": "Does not end with", + "type": "string", + "const": "DoesNotEndWith" + }, + { + "description": "Does not start with", + "type": "string", + "const": "DoesNotStartWith" + }, + { + "description": "Does not equal", + "type": "string", + "const": "DoesNotEqual" + }, + { + "description": "Does not contain", + "type": "string", + "const": "DoesNotContain" + } + ] + } + } +} diff --git a/schema.json b/schema.json new file mode 100644 index 00000000..c0e9cd65 --- /dev/null +++ b/schema.json @@ -0,0 +1,1100 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "StaticConfig", + "description": "The `komorebi.json` static configuration file reference for komorebi for KDE Plasma", + "type": "object", + "properties": { + "app_specific_configuration_path": { + "description": "Path to an applications.json, or a list of them, with rules for the applications that\nneed them: which windows to ignore, to manage and to float", + "anyOf": [ + { + "$ref": "#/$defs/AppSpecificConfigurationPath" + }, + { + "type": "null" + } + ] + }, + "cross_boundary_behaviour": { + "description": "Determine what happens when an action is called on a window at a monitor boundary", + "anyOf": [ + { + "$ref": "#/$defs/CrossBoundaryBehaviour" + }, + { + "type": "null" + } + ], + "default": "Monitor" + }, + "cross_monitor_move_behaviour": { + "description": "Determine what happens when a window is moved across a monitor boundary", + "anyOf": [ + { + "$ref": "#/$defs/MoveBehaviour" + }, + { + "type": "null" + } + ], + "default": "Swap" + }, + "default_container_padding": { + "description": "Global default container padding", + "type": [ + "integer", + "null" + ], + "format": "int32", + "default": 5 + }, + "default_workspace_padding": { + "description": "Global default workspace padding", + "type": [ + "integer", + "null" + ], + "format": "int32", + "default": 5 + }, + "display_index_preferences": { + "description": "Set display index preferences", + "type": [ + "object", + "null" + ], + "additionalProperties": false, + "patternProperties": { + "^\\d+$": { + "type": "string" + } + } + }, + "float_override": { + "description": "Enable or disable float override, which makes it so every new window opens in floating mode", + "type": [ + "boolean", + "null" + ], + "default": false + }, + "float_override_placement": { + "description": "Determines the `Placement` to be used when spawning a window with float override active", + "anyOf": [ + { + "$ref": "#/$defs/Placement" + }, + { + "type": "null" + } + ] + }, + "float_rule_placement": { + "description": "Determines the `Placement` to be used when spawning a window that matches a\n'floating_applications' rule", + "anyOf": [ + { + "$ref": "#/$defs/Placement" + }, + { + "type": "null" + } + ] + }, + "floating_applications": { + "description": "Identify applications which should be managed as floating windows", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/$defs/MatchingRule" + } + }, + "floating_layer_behaviour": { + "description": "Determines what happens on a new window when on the `FloatingLayer`", + "anyOf": [ + { + "$ref": "#/$defs/FloatingLayerBehaviour" + }, + { + "type": "null" + } + ], + "default": "Tile" + }, + "floating_layer_placement": { + "description": "Determines the `Placement` to be used when spawning a window on the floating layer with the\n`FloatingLayerBehaviour` set to `FloatingLayerBehaviour::Float`", + "anyOf": [ + { + "$ref": "#/$defs/Placement" + }, + { + "type": "null" + } + ], + "default": "Center" + }, + "floating_window_aspect_ratio": { + "description": "Aspect ratio to resize with when toggling floating mode for a window", + "anyOf": [ + { + "$ref": "#/$defs/AspectRatio" + }, + { + "type": "null" + } + ] + }, + "global_work_area_offset": { + "description": "Global work area (space used for tiling) offset", + "anyOf": [ + { + "$ref": "#/$defs/Rect" + }, + { + "type": "null" + } + ] + }, + "ignore_rules": { + "description": "Individual window floating rules", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/$defs/MatchingRule" + } + }, + "layout_defaults": { + "description": "Per-layout default options and rules, keyed by layout name.\nApplied as fallback when a workspace does not define its own layout_options or layout_options_rules.\nIf a workspace defines either setting, all global defaults for that layout are completely replaced.", + "type": [ + "object", + "null" + ], + "additionalProperties": { + "$ref": "#/$defs/LayoutDefaultEntry" + } + }, + "manage_rules": { + "description": "Individual window force-manage rules", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/$defs/MatchingRule" + } + }, + "monitors": { + "description": "Monitor and workspace configurations", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/$defs/MonitorConfig" + } + }, + "monocle_focus_behaviour": { + "description": "Determine what happens when focusing in a direction while a monocle container is active", + "anyOf": [ + { + "$ref": "#/$defs/MonocleFocusBehaviour" + }, + { + "type": "null" + } + ], + "default": "NoOp" + }, + "mouse_follows_focus": { + "description": "Enable or disable mouse follows focus", + "type": [ + "boolean", + "null" + ], + "default": true + }, + "resize_delta": { + "description": "Delta to resize windows by", + "type": [ + "integer", + "null" + ], + "format": "int32", + "default": 50 + }, + "tabbed_applications": { + "description": "Accepted so that a configuration written for another platform loads, and ignored: a KWin\nwindow has no native tabs", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "titleless_applications": { + "description": "Accepted so that a configuration written for another platform loads, and ignored: a KWin\nwindow is always a window", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "toggle_float_placement": { + "description": "Determines the placement of a new window when toggling to float", + "anyOf": [ + { + "$ref": "#/$defs/Placement" + }, + { + "type": "null" + } + ], + "default": "CenterAndResize" + }, + "unmanaged_window_operation_behaviour": { + "description": "Determine what happens when commands are sent while an unmanaged window is in the foreground", + "anyOf": [ + { + "$ref": "#/$defs/OperationBehaviour" + }, + { + "type": "null" + } + ], + "default": "Op" + }, + "window_container_behaviour": { + "description": "Determine what happens when a new window is opened", + "anyOf": [ + { + "$ref": "#/$defs/WindowContainerBehaviour" + }, + { + "type": "null" + } + ], + "default": "Create" + } + }, + "$defs": { + "AppSpecificConfigurationPath": { + "description": "Path(s) to application-specific configuration file(s)", + "anyOf": [ + { + "description": "A single applications.json file", + "$ref": "#/$defs/PathBuf" + }, + { + "description": "Multiple applications.json files", + "type": "array", + "items": { + "$ref": "#/$defs/PathBuf" + } + } + ] + }, + "ApplicationIdentifier": { + "description": "Application identifier", + "oneOf": [ + { + "description": "Executable name", + "type": "string", + "const": "Exe" + }, + { + "description": "Role or subrole", + "type": "string", + "const": "Class" + }, + { + "description": "Window title", + "type": "string", + "const": "Title" + }, + { + "description": "Executable path", + "type": "string", + "const": "Path" + } + ] + }, + "AspectRatio": { + "description": "Aspect ratio for temporarily floating windows", + "anyOf": [ + { + "title": "Predefined", + "description": "A predefined aspect ratio", + "$ref": "#/$defs/PredefinedAspectRatio" + }, + { + "title": "Custom", + "description": "A custom W:H aspect ratio", + "type": "array", + "maxItems": 2, + "minItems": 2, + "prefixItems": [ + { + "type": "integer", + "format": "int32" + }, + { + "type": "integer", + "format": "int32" + } + ] + } + ] + }, + "Axis": { + "description": "Axis on which to perform an operation", + "oneOf": [ + { + "description": "Horizontal axis", + "type": "string", + "const": "Horizontal" + }, + { + "description": "Vertical axis", + "type": "string", + "const": "Vertical" + }, + { + "description": "Both horizontal and vertical axes", + "type": "string", + "const": "HorizontalAndVertical" + } + ] + }, + "CrossBoundaryBehaviour": { + "description": "Behavior when an action would cross a monitor boundary", + "oneOf": [ + { + "description": "Attempt to perform actions across a workspace boundary", + "type": "string", + "const": "Workspace" + }, + { + "description": "Attempt to perform actions across a monitor boundary", + "type": "string", + "const": "Monitor" + } + ] + }, + "DefaultLayout": { + "description": "A predefined komorebi layout", + "oneOf": [ + { + "description": "BSP Layout\n\n```text\n+-------+-----+\n| | |\n| +--+--+\n| | |--|\n+-------+--+--+\n```", + "type": "string", + "const": "BSP" + }, + { + "description": "Columns Layout\n\n```text\n+--+--+--+--+\n| | | | |\n| | | | |\n| | | | |\n+--+--+--+--+\n```", + "type": "string", + "const": "Columns" + }, + { + "description": "Rows Layout\n\n```text\n+-----------+\n|-----------|\n|-----------|\n|-----------|\n+-----------+\n```", + "type": "string", + "const": "Rows" + }, + { + "description": "Vertical Stack Layout\n\n```text\n+-------+-----+\n| | |\n| +-----+\n| | |\n+-------+-----+\n```", + "type": "string", + "const": "VerticalStack" + }, + { + "description": "Horizontal Stack Layout\n\n```text\n+------+------+\n| |\n|------+------+\n| | |\n+------+------+\n```", + "type": "string", + "const": "HorizontalStack" + }, + { + "description": "Ultrawide Vertical Stack Layout\n\n```text\n+-----+-----------+-----+\n| | | |\n| | +-----+\n| | | |\n| | +-----+\n| | | |\n+-----+-----------+-----+\n```", + "type": "string", + "const": "UltrawideVerticalStack" + }, + { + "description": "Grid Layout\n\n```text\n+-----+-----+ +---+---+---+ +---+---+---+ +---+---+---+\n| | | | | | | | | | | | | | |\n| | | | | | | | | | | | | +---+\n+-----+-----+ | +---+---+ +---+---+---+ +---+---| |\n| | | | | | | | | | | | | +---+\n| | | | | | | | | | | | | | |\n+-----+-----+ +---+---+---+ +---+---+---+ +---+---+---+\n 4 windows 5 windows 6 windows 7 windows\n```", + "type": "string", + "const": "Grid" + }, + { + "description": "Right Main Vertical Stack Layout\n\n```text\n+-----+-------+\n| | |\n+-----+ |\n| | |\n+-----+-------+\n```", + "type": "string", + "const": "RightMainVerticalStack" + }, + { + "description": "Scrolling Layout\n\n```text\n+--+--+--+--+--+--+\n| | | |\n| | | |\n| | | |\n+--+--+--+--+--+--+\n```", + "type": "string", + "const": "Scrolling" + } + ] + }, + "FloatingLayerBehaviour": { + "description": "Floating layer behavior when a new window is opened", + "oneOf": [ + { + "description": "Tile new windows (unless they match a float rule or float override is active)", + "type": "string", + "const": "Tile" + }, + { + "description": "Float new windows", + "type": "string", + "const": "Float" + } + ] + }, + "GridLayoutOptions": { + "description": "Options for the Grid layout", + "type": "object", + "properties": { + "rows": { + "description": "Maximum number of rows per grid column", + "type": "integer", + "format": "uint", + "minimum": 0 + } + }, + "required": [ + "rows" + ] + }, + "IdWithIdentifier": { + "description": "Rule for matching applications", + "type": "object", + "properties": { + "id": { + "description": "Target identifier", + "type": "string" + }, + "kind": { + "description": "Kind of identifier to target", + "$ref": "#/$defs/ApplicationIdentifier" + }, + "matching_strategy": { + "description": "Matching strategy to use", + "anyOf": [ + { + "$ref": "#/$defs/MatchingStrategy" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "kind", + "id" + ] + }, + "LayoutDefaultEntry": { + "description": "Per-layout default options entry for the `layout_defaults` global setting.\nContains both base layout options and threshold-based layout options rules.", + "type": "object", + "properties": { + "layout_options": { + "description": "Default layout options for this layout", + "anyOf": [ + { + "$ref": "#/$defs/LayoutOptions" + }, + { + "type": "null" + } + ] + }, + "layout_options_rules": { + "description": "Threshold-based layout options rules in the format of threshold => options.\nWhen container count >= threshold, the highest matching threshold's options\nfully replace the base `layout_options`.", + "type": [ + "object", + "null" + ], + "additionalProperties": false, + "patternProperties": { + "^\\d+$": { + "$ref": "#/$defs/LayoutOptions" + } + } + } + } + }, + "LayoutOptions": { + "description": "Options for specific layouts", + "type": "object", + "properties": { + "column_ratios": { + "description": "Column width ratios (up to MAX_RATIOS values between 0.1 and 0.9)\n\n- Used by Columns layout: ratios for each column width\n- Used by Grid layout: ratios for column widths\n- Used by BSP, VerticalStack, RightMainVerticalStack: column_ratios[0] as primary split ratio\n- Used by HorizontalStack: column_ratios[0] as primary split ratio (top area height)\n- Used by UltrawideVerticalStack: column_ratios[0] as center ratio, column_ratios[1] as left ratio\n\nColumns without a ratio share remaining space equally.\nExample: `[0.3, 0.4, 0.3]` for 30%-40%-30% columns", + "type": [ + "array", + "null" + ], + "default": null, + "items": { + "type": [ + "number", + "null" + ], + "format": "float" + }, + "maxItems": 5, + "minItems": 5 + }, + "grid": { + "description": "Options related to the Grid layout", + "anyOf": [ + { + "$ref": "#/$defs/GridLayoutOptions" + }, + { + "type": "null" + } + ] + }, + "row_ratios": { + "description": "Row height ratios (up to MAX_RATIOS values between 0.1 and 0.9)\n\n- Used by Rows layout: ratios for each row height\n- Used by Grid layout: ratios for row heights\n\nRows without a ratio share remaining space equally.\nExample: `[0.5, 0.5]` for 50%-50% rows", + "type": [ + "array", + "null" + ], + "default": null, + "items": { + "type": [ + "number", + "null" + ], + "format": "float" + }, + "maxItems": 5, + "minItems": 5 + }, + "scrolling": { + "description": "Options related to the Scrolling layout", + "anyOf": [ + { + "$ref": "#/$defs/ScrollingLayoutOptions" + }, + { + "type": "null" + } + ] + } + } + }, + "MatchingRule": { + "description": "Rule for matching applications", + "anyOf": [ + { + "description": "Simple matching rule which must evaluate to true", + "$ref": "#/$defs/IdWithIdentifier" + }, + { + "description": "Composite matching rule where all conditions must evaluate to true", + "type": "array", + "items": { + "$ref": "#/$defs/IdWithIdentifier" + } + } + ] + }, + "MatchingStrategy": { + "description": "Strategy for matching identifiers", + "oneOf": [ + { + "description": "Should not be used, only kept for backward compatibility", + "type": "string", + "const": "Legacy" + }, + { + "description": "Equals", + "type": "string", + "const": "Equals" + }, + { + "description": "Starts With", + "type": "string", + "const": "StartsWith" + }, + { + "description": "Ends With", + "type": "string", + "const": "EndsWith" + }, + { + "description": "Contains", + "type": "string", + "const": "Contains" + }, + { + "description": "Regex", + "type": "string", + "const": "Regex" + }, + { + "description": "Does not end with", + "type": "string", + "const": "DoesNotEndWith" + }, + { + "description": "Does not start with", + "type": "string", + "const": "DoesNotStartWith" + }, + { + "description": "Does not equal", + "type": "string", + "const": "DoesNotEqual" + }, + { + "description": "Does not contain", + "type": "string", + "const": "DoesNotContain" + } + ] + }, + "MonitorConfig": { + "description": "Monitor configuration", + "type": "object", + "properties": { + "container_padding": { + "description": "Container padding", + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "floating_layer_behaviour": { + "description": "Determine what happens to a new window when the Floating workspace layer is active", + "anyOf": [ + { + "$ref": "#/$defs/FloatingLayerBehaviour" + }, + { + "type": "null" + } + ], + "default": "Tile" + }, + "window_based_work_area_offset": { + "description": "Window based work area offset", + "anyOf": [ + { + "$ref": "#/$defs/Rect" + }, + { + "type": "null" + } + ] + }, + "window_based_work_area_offset_limit": { + "description": "Open window limit after which the window based work area offset will no longer be applied", + "type": [ + "integer", + "null" + ], + "format": "int", + "default": 1 + }, + "window_hiding_position": { + "description": "Accepted so that a configuration written for another platform loads, and ignored: there is\nno hiding position on KDE Plasma", + "anyOf": [ + { + "$ref": "#/$defs/WindowHidingPosition" + }, + { + "type": "null" + } + ] + }, + "work_area_offset": { + "description": "Monitor-specific work area offset", + "anyOf": [ + { + "$ref": "#/$defs/Rect" + }, + { + "type": "null" + } + ] + }, + "workspace_padding": { + "description": "Workspace padding (default: global)", + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "workspaces": { + "description": "Workspace configurations", + "type": "array", + "items": { + "$ref": "#/$defs/WorkspaceConfig" + } + } + }, + "required": [ + "workspaces" + ] + }, + "MonocleFocusBehaviour": { + "description": "Behavior when focusing in a direction while a monocle container is active", + "oneOf": [ + { + "description": "Cycle the monocle container to the next/previous container in the workspace", + "type": "string", + "const": "Cycle" + }, + { + "description": "Do nothing, allowing focus to fall through to cross-monitor logic", + "type": "string", + "const": "NoOp" + } + ] + }, + "MoveBehaviour": { + "description": "Move behavior when the operation works across a monitor boundary", + "oneOf": [ + { + "description": "Swap the window container with the window container at the edge of the adjacent monitor", + "type": "string", + "const": "Swap" + }, + { + "description": "Insert the window container into the focused workspace on the adjacent monitor", + "type": "string", + "const": "Insert" + }, + { + "description": "Do nothing if trying to move a window container in the direction of an adjacent monitor", + "type": "string", + "const": "NoOp" + } + ] + }, + "OperationBehaviour": { + "description": "Operation behavior for temporarily unmanaged and floating windows", + "oneOf": [ + { + "description": "Process commands on temporarily unmanaged/floated windows", + "type": "string", + "const": "Op" + }, + { + "description": "Ignore commands on temporarily unmanaged/floated windows", + "type": "string", + "const": "NoOp" + } + ] + }, + "PathBuf": { + "description": "A file system path. Environment variables like $VAR are automatically resolved.", + "type": "string" + }, + "Placement": { + "description": "Placement behavior for floating windows", + "oneOf": [ + { + "description": "Does not change the size or position of the window", + "type": "string", + "const": "None" + }, + { + "description": "Center the window without changing the size", + "type": "string", + "const": "Center" + }, + { + "description": "Center the window and resize it according to the `AspectRatio`", + "type": "string", + "const": "CenterAndResize" + } + ] + }, + "PredefinedAspectRatio": { + "description": "Predefined aspect ratio", + "oneOf": [ + { + "description": "21:9", + "type": "string", + "const": "Ultrawide" + }, + { + "description": "16:9", + "type": "string", + "const": "Widescreen" + }, + { + "description": "4:3", + "type": "string", + "const": "Standard" + } + ] + }, + "Rect": { + "description": "Rectangle dimensions", + "type": "object", + "properties": { + "bottom": { + "description": "Height of the rectangle (from the top point)", + "type": "integer", + "format": "int32" + }, + "left": { + "description": "Left point of the rectangle", + "type": "integer", + "format": "int32" + }, + "right": { + "description": "Width of the recentangle (from the left point)", + "type": "integer", + "format": "int32" + }, + "top": { + "description": "Top point of the rectangle", + "type": "integer", + "format": "int32" + } + }, + "required": [ + "left", + "top", + "right", + "bottom" + ] + }, + "ScrollingLayoutOptions": { + "description": "Options for the Scrolling layout", + "type": "object", + "properties": { + "center_focused_column": { + "description": "With an odd number of visible columns, keep the focused window column centered", + "type": [ + "boolean", + "null" + ] + }, + "columns": { + "description": "Desired number of visible columns (default: 3)", + "type": "integer", + "format": "uint", + "minimum": 0 + } + }, + "required": [ + "columns" + ] + }, + "WindowContainerBehaviour": { + "description": "Window container behavior when a new window is opened", + "oneOf": [ + { + "description": "Create a new container for each new window", + "type": "string", + "const": "Create" + }, + { + "description": "Append new windows to the focused window container", + "type": "string", + "const": "Append" + } + ] + }, + "WindowHidingPosition": { + "description": "Window hiding position", + "oneOf": [ + { + "description": "Hide windows in the bottom left corner of the screen", + "type": "string", + "const": "BottomLeft" + }, + { + "description": "Hide windows in the bottom right corner of the screen", + "type": "string", + "const": "BottomRight" + } + ] + }, + "WorkspaceConfig": { + "description": "Workspace configuration", + "type": "object", + "properties": { + "apply_window_based_work_area_offset": { + "description": "Apply this monitor's window-based work area offset", + "type": [ + "boolean", + "null" + ], + "default": true + }, + "container_padding": { + "description": "Container padding (default: global)", + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "float_override": { + "description": "Enable or disable float override, which makes it so every new window opens in floating mode", + "type": [ + "boolean", + "null" + ], + "default": false + }, + "floating_layer_behaviour": { + "description": "Determine what happens to a new window when the Floating workspace layer is active", + "anyOf": [ + { + "$ref": "#/$defs/FloatingLayerBehaviour" + }, + { + "type": "null" + } + ], + "default": "Tile" + }, + "layout": { + "description": "Layout", + "anyOf": [ + { + "$ref": "#/$defs/DefaultLayout" + }, + { + "type": "null" + } + ], + "default": "BSP" + }, + "layout_flip": { + "description": "Specify an axis on which to flip the selected layout", + "anyOf": [ + { + "$ref": "#/$defs/Axis" + }, + { + "type": "null" + } + ] + }, + "layout_options": { + "description": "Layout-specific options", + "anyOf": [ + { + "$ref": "#/$defs/LayoutOptions" + }, + { + "type": "null" + } + ] + }, + "layout_options_rules": { + "description": "Threshold-based layout options rules in the format of threshold => options.\nWhen container count >= threshold, the highest matching threshold's options\nfully replace the base `layout_options`.\nThis follows the same threshold logic as `layout_rules`.", + "type": [ + "object", + "null" + ], + "additionalProperties": false, + "patternProperties": { + "^\\d+$": { + "$ref": "#/$defs/LayoutOptions" + } + } + }, + "layout_rules": { + "description": "Layout rules in the format of threshold => layout", + "type": [ + "object", + "null" + ], + "additionalProperties": false, + "patternProperties": { + "^\\d+$": { + "$ref": "#/$defs/DefaultLayout" + } + } + }, + "name": { + "description": "Name", + "type": "string" + }, + "tile": { + "description": "Enable or disable tiling for the workspace", + "type": [ + "boolean", + "null" + ], + "default": true + }, + "window_container_behaviour": { + "description": "Determine what happens when a new window is opened", + "anyOf": [ + { + "$ref": "#/$defs/WindowContainerBehaviour" + }, + { + "type": "null" + } + ], + "default": "Create" + }, + "window_container_behaviour_rules": { + "description": "Window container behavior rules in the format of threshold => behavior", + "type": [ + "object", + "null" + ], + "additionalProperties": false, + "patternProperties": { + "^\\d+$": { + "$ref": "#/$defs/WindowContainerBehaviour" + } + } + }, + "work_area_offset": { + "description": "Workspace specific work area offset", + "anyOf": [ + { + "$ref": "#/$defs/Rect" + }, + { + "type": "null" + } + ] + }, + "work_area_offset_rules": { + "description": "Work area offset rules in the format of threshold => Rect (default: None)", + "type": [ + "object", + "null" + ], + "additionalProperties": false, + "patternProperties": { + "^\\d+$": { + "$ref": "#/$defs/Rect" + } + } + }, + "workspace_padding": { + "description": "Workspace padding (default: global)", + "type": [ + "integer", + "null" + ], + "format": "int32" + } + }, + "required": [ + "name" + ] + } + } +}