diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fd86348554..de8acf2b8a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -185,46 +185,36 @@ jobs: - name: Environment run: podman exec agama bash -c "env | sort" - - name: Install Ruby gems - run: podman exec agama bash -c "cd /checkout/service && bundle config set --local path 'vendor/bundle' && bundle install" + - name: Build the frontend + run: podman exec agama bash -c "cd /checkout/web && npm install && make" - - name: Install the Agama D-Bus configuration - run: podman exec agama bash -c "cp /checkout/service/share/dbus.conf /usr/share/dbus-1/system.d/org.opensuse.Agama.conf" + - name: Install the frontend + run: podman exec agama bash -c "ln -snfv /checkout/web/dist /usr/share/cockpit/agama" - - name: Set a testing Agama configuration - # copy a simplified ALP config file, it skips the product selection at the beginning - run: podman exec agama bash -c "cp /checkout/playwright/config/agama.yaml /checkout/service/etc/agama.yaml" + # ./setup-service.sh will try setting up cockpit.socket + # which has a login page, so this local-session needs to be first + - name: Start Cockpit service + run: podman exec --detach agama /usr/libexec/cockpit-ws --local-session=/usr/bin/cockpit-bridge - - name: Start NetworkManager - # We need to run it manually as systemd dbus activation looks like failing - run: podman exec agama /usr/sbin/NetworkManager + - name: Setup service + run: podman exec agama bash -c "cd /checkout; ./setup-service.sh" - - name: Reload the D-Bus service - run: podman exec agama systemctl reload dbus + - name: Rust unit tests + run: podman exec agama bash -c "cd /checkout/rust; cargo test --verbose" - - name: Start the Agama D-Bus services - # TODO: here is a potential race condition, but as building the frontend - # takes quite long time it should never happen™ - run: podman exec agama bash -c "cd /checkout/service && (bundle exec bin/agamactl > service.log 2>&1 &)" + - name: Set a testing Agama configuration + # copy a simplified ALP config file, it skips the product selection at the beginning + run: podman exec agama bash -c "cp /checkout/playwright/config/agama.yaml /checkout/service/etc/agama.yaml" - - name: Build the frontend - run: podman exec agama bash -c "cd /checkout/web && npm install && make" + - name: Show NetworkManager log + run: podman exec agama journalctl -u NetworkManager - name: Show the D-Bus services log - run: podman exec agama cat /checkout/service/service.log + run: podman exec agama bash -c "journalctl | grep agama" - name: Check DBus socket run: podman exec agama ls -l /var/run/dbus/system_bus_socket - - name: Show journal - run: podman exec agama journalctl -b || echo "journal failed with $?" - - - name: Install the frontend - run: podman exec agama bash -c "ln -snfv /checkout/web/dist /usr/share/cockpit/agama" - - - name: Start Cockpit service - run: podman exec --detach agama /usr/libexec/cockpit-ws --local-session=/usr/bin/cockpit-bridge - - name: Run the Agama smoke test run: podman exec agama curl http://localhost:9090/cockpit/@localhost/agama/index.html @@ -236,6 +226,13 @@ jobs: # run the tests in the Chromium browser run: podman exec agama bash -c "cd /checkout/playwright && SKIP_LOGIN=true playwright test --trace on --project chromium" + - name: Again show the D-Bus services log + run: podman exec agama bash -c "journalctl | grep agama" + + - name: Show the complete journal + # maybe not necessary if the above filtered journalctl calls are enough + run: podman exec agama journalctl -b || echo "journal failed with $?" + - name: Upload the test results uses: actions/upload-artifact@v3 # run even when the previous step fails @@ -247,24 +244,6 @@ jobs: playwright/test-results/**/* /tmp/log/YaST2/y2log - cli_build: - runs-on: ubuntu-latest - - env: - CARGO_TERM_COLOR: always - - defaults: - run: - working-directory: ./rust - - steps: - - uses: actions/checkout@v3 - - name: Build - run: cargo build --verbose - - name: Run tests - run: cargo test --verbose - - finish: runs-on: ubuntu-latest diff --git a/README.md b/README.md index 61be537e8f..8d210fbb11 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,10 @@ Alternatively you can run a development server which works as a proxy for the cockpit server. See more details [in the documentation]( web/README.md#using-a-development-server). +Another alternative is to run source checkout inside container so system is not +affected by doing testing run beside real actions really done by installer. +See more details [in the documentation][doc/testing_using_container.md]. + * Start the services: * beware that Agama must run as root (like YaST does) to do hardware probing, partition the disks, install the software and so on. diff --git a/doc/locale_api.md b/doc/locale_api.md new file mode 100644 index 0000000000..ad8ceeaf4d --- /dev/null +++ b/doc/locale_api.md @@ -0,0 +1,295 @@ +# Legacy-free Locale Service + +Problem statement: (2023-03) + +> Agama currently has a separate Language service, although it's rather +> simplistic. It just allows to set the language of the installed system using +> Yast::Language.Set. And it's quite memory demanding for such an unimpressive +> task. + +> That service would be a nice candidate to be rewritten from scratch with no +> dependencies on YaST or Ruby. It's small enough and could give us a good +> overview on how much can we save. + +Original Plan: + +1. take the systemd APIs as a sensible starting point. +2. deviate only where we add value + +The problem with the original plan is that +the installer runs in one system (inst-sys, `/`) +and operates on another (target, `/mnt`) and we cannot use the full systemd +API. We may use `systemd-firstboot` instead but its API is much more limited. + +## Localization + +This design includes localized labels in the API. In other contexts that would +be a responsibility of the frontend, but here the backend has the +information, provided by _langtable_. + +(Languages, Territories and Timezones have localized names. Keyboards do not.) + +(Possible alternative: still include localized labels, but in a supplemental +method while the main method only provides the IDs (and English labels)) + +## Proposal + +A Proposal is what the installer proposes to the user +as settings to be applied to the target system. + +For example, when selecting the "German (Germany)" locale, +the timezone will be proposed to "Europe/Berlin". + +Design decision: put the proposal logic to the antecedent object, that is, +the Locale object will know how to change the Timezone object, +not the other way around (Timezone reacting to Locale value). + +### Overriding the User's Choice? + +
+ +If setting the locale proposes the keyboard, what do we do if the user first +changes the keyboard and _then_ the locale? + + +When Agama UI first shows up, it may show default choices like: + +> Locale: English (US), Keyboard: US + +Then we change the locale to Czech, and the keyboard is adjusted automatically: + +> Locale: Czech, Keyboard: Czech + +We tune the keyboard: + +> Locale: Czech, Keyboard: Czech (qwerty) + +When we then change the locale, the keyboard could stay the same, as we have +already touched it: + +> Locale: German, Keyboard: Czech (qwerty) +
+ +### Simple Design: Always Repropose + +We can easily afford throwing away the user's choice of keyboard layout and +simply set what we consider a good default for a newly set locale, because: + +1. it is just one setting (as opposed to whole partitioning layout) +2. the change will be visible in the UI, I assume + +### Detailed Design: Prioritize + +But other cases may not be as simple, so here's a generic design (NOT YET USED): + +All settings are wrapped in a `Priority` generic type (an Enum in Rust), +meaning, what is the source and importance of the setting: +- `Machine(data)` means the system has proposed it +- `Human(data)` means the user has made the choice + +In D-Bus, it is represented by wrapping the data in a struct, with a leading +byte* tagging the priority. For ease of recognition when watching bus traffic, +special numbers are used: +- `23` means Human, for the number of chromosome pairs +- `42` means Machine, as the famous Answer was given by Deep Thought, a machine + +In the following dump, we see that the locale was set by the user and the +system has adjusted the keyboard. + +``` +node ...Agama/Locale1 { + interface ...Agama.Locale1 { + properties: + readwrite (yas) Locale = (23, ['cs_CZ.UTF-8', 'de_DE.UTF-8']); + readwrite (y(ss)) X11Keyboard = (42, ('cz','qwerty)); + }; +}; +``` + +You may know a [similar settings in libzypp][resstatus] where it has 4 levels. + +*: maybe this is a crazy optimization? I am not too opposed to use strings for +this on the bus. + +[resstatus]: https://github.com/openSUSE/libzypp/blob/d441746c59f063b5d54833bfdebc48829b07feb5/zypp/ResStatus.h#L106 + + +## Interfaces + +### Language and Keyboard + +- when setting the locale, adjust the proposed package selection and keyboard + accordingly. And timezone. + +The general design of the proposal layer is + +- declarative, using read-write properties +- setting some properties will make changes in the proposal layer of other + properties of other objects + +I don't know: should the proposal be adjusted automatically as part of the property setter, or should it be explicit? + +So here, setting `Locale` below will set also `VConsoleKeyboard` here and + - Agama...Software...todo(...) + - Agama...Timezone...todo(...) + +For the first version of the API, let's keep things simple: + +**LocaleType** is just one string, the value for the `LANG` variable, like +`"cs_CZ.UTF-8"`. + +**VConsoleKeyboardType** is a string, for example +`"cz-qwerty"` or `"us"`. + +`systemd-firstboot` only has an option for the console keymap, but we have a +way to propagate it to X11, see [bsc#1046436](https://bugzilla.suse.com/show_bug.cgi?id=1046436) + +We don't expose the X11 keyboard, instead letting systemd do it via the +_convert_ parameter. + +(The other systemd keyboard settings are X11Model and X11Options, we don't +have UI or data for that) + +NOTE: _langtable_ on the other hand only deals with the X11 keyboards, +linking them to languages and territories. + +``` +# this is gdbus syntax BTW +node /org/opensuse/Agama/Locale1 { + interface org.opensuse.Agama.Locale1 { + methods: + # In the same order as in SupportedLocales, pairs of + # (english_labels, native_labels), where foo_labels + # is a pair of (language, territory) + LabelsForLocales( + out a((ss)(ss)) id_english_native # [(("Spanish", "Spain"), ("Español", "España")), (('English', 'United States'), ('English', 'United States'))] + ) + ListVConsoleKeyboards( + out as ids # like ["cz", "cz-qwerty", "gb-intl", "us", "us-dvorak",…] + ) + + # ProposeKeyboard(); # not needed? adjusted automatically, same object + # Sets Agama/TimeDate1's Timezone (but not LocalRTC, that's for Storage to say?) + ProposeTimeDate(); # different object but same service + ProposeSoftware(); # different service + + Commit(); + properties: + + # The locale service DOES NOT KNOW which locales are + # available for the product currently selected for installation. + # When the user chooses a product, SupportedLocales should be set. + # It affects the output of LabelsForLocales + # and the valid inputs for Locales. + readwrite as SupportedLocales = ["es_ES.UTF-8", "en_US.UTF-8"]; + + # NOTE: "as" has different meaning to systemd, + # we have a list of LANG settings, 1st gets passed to systemd, + # others affect package selection + readwrite as Locales = ['cs_CZ.UTF-8', 'de_DE.UTF-8']; + + readwrite s VConsoleKeyboard = 'cz-qwerty'; + }; +}; +``` + +#### Systemd + +
+ +For reference, the systemd API for Locale(Language) and Keyboard is this: + + +``` +$ gdbus introspect -y -d org.freedesktop.locale1 -o /org/freedesktop/locale1 +node /org/freedesktop/locale1 { + interface org.freedesktop.locale1 { + methods: + SetLocale(in as locale, + in b interactive); + SetVConsoleKeyboard(in s keymap, + in s keymap_toggle, + in b convert, + in b interactive); + SetX11Keyboard(in s layout, + in s model, + in s variant, + in s options, + in b convert, + in b interactive); +… +$ busctl --system introspect org.freedesktop.locale1 /org/freedesktop/locale1 +(all properties are read-only and emit PropertiesChanged) +.Locale property as 1 "LANG=en_US.UTF-8" +.VConsoleKeymap property s "cz-lat2-us" +.VConsoleKeymapToggle property s "" +.X11Layout property s "cz,us" +.X11Model property s "pc105" +.X11Options property s "terminate:ctrl_alt_bksp,grp:shift_togg… +.X11Variant property s "qwerty,basic" +``` + +
+ +### Timezone + +``` +node /org/opensuse/Agama/TimeDate1 { + interface org.opensuse.Agama.TimeDate1 { + methods: + ListTimezones( + in s display_locale # "de_DE.UTF-8" + out a(ss) id_label_pairs # [('Europe/Prague', 'Europa/Prag')] + ) + # success? do we need a specific return value other than some Error? + Commit(); + properties: + readwrite s Timezone = 'Europe/Prague'; + readwrite b LocalRTC = false; + }; +}; +``` + +#### Systemd + +
+ +For reference, the systemd API for Time and Timezone is this: + + +(I find `gdbus` verbose output better for methods and `busctl` terse output +better for properties) + +``` +$ gdbus introspect -y -d org.freedesktop.timedate1 -o /org/freedesktop/timedate1 +node /org/freedesktop/timedate1 { … + interface org.freedesktop.timedate1 { … + methods: + SetTime(in x usec_utc, + in b relative, + in b interactive); + SetTimezone(in s timezone, + in b interactive); + SetLocalRTC(in b local_rtc, + in b fix_system, + in b interactive); + SetNTP(in b use_ntp, + in b interactive); + ListTimezones(out as timezones); +… +$ busctl --system introspect org.freedesktop.timedate1 /org/freedesktop/timedate1 +NAME TYPE SIG RESULT/VALUE FLAGS +(properties are read only) +.CanNTP property b true - +.LocalRTC property b false emits-change +.NTP property b false emits-change +.NTPSynchronized property b false - +.RTCTimeUSec property t 1681214874000000 - +.TimeUSec property t 1681214874046139 - +.Timezone property s "Europe/Prague" emits-change +``` + +"LocalRTC" means "is the local time zone used for the real time clock", +so it's !hwclock_in_UTC + +
diff --git a/doc/testing_using_container.md b/doc/testing_using_container.md new file mode 100644 index 0000000000..e21d8ef68c --- /dev/null +++ b/doc/testing_using_container.md @@ -0,0 +1,47 @@ +## Testing Using Container + +To test complex change that affects multiple parts of agama it is possible to +run from sources using container that is used to run CI. + +Below is shell script that start container, provides web UI on port 9090 and +also gives root access to container for more testing. + +```sh +# https://build.opensuse.org/package/show/YaST:Head:Containers/agama-testing +CIMAGE=registry.opensuse.org/yast/head/containers/containers_tumbleweed/opensuse/agama-testing:latest +# rename this if you test multiple things +CNAME=agama +# the '?' here will report a shell error +# if you accidentally paste a command without setting the variable first +echo ${CNAME?} + +test -f service/agama.gemspec || echo "You should run this from a checkout of agama" + +# destroy the previous instance, can fail if there is no previous instance +podman stop ${CNAME?} +podman rm ${CNAME?} + +# Update our image +podman pull ${CIMAGE?} + +podman run --name ${CNAME?} \ + --privileged --detach --ipc=host \ + -v .:/checkout \ + -p 9090:9090 \ + ${CIMAGE?} + +# shortcut for the following +CEXEC="podman exec ${CNAME?} bash -c" + +${CEXEC?} "cd /checkout && ./setup.sh" + +# Now the CLI is in the same repo, just symlink it +${CEXEC?} "ln -sfv /checkout/./rust/target/debug/agama /usr/bin/agama" + +# Manually start cockpit as socket activation does not work with port forwarding +${CEXEC?} "systemctl start cockpit" + +# Optional: Interactive shell in the container +podman exec --tty --interactive ${CNAME?} bash + +``` diff --git a/rust/Cargo.lock b/rust/Cargo.lock index ad2bd8c210..eecc69875a 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + [[package]] name = "agama-cli" version = "1.0.0" @@ -17,6 +23,17 @@ dependencies = [ "thiserror", ] +[[package]] +name = "agama-dbus-server" +version = "0.1.0" +dependencies = [ + "agama-locale-data", + "anyhow", + "async-std", + "zbus", + "zbus_macros", +] + [[package]] name = "agama-derive" version = "1.0.0" @@ -44,6 +61,18 @@ dependencies = [ "zbus", ] +[[package]] +name = "agama-locale-data" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono-tz", + "flate2", + "quick-xml", + "regex", + "serde", +] + [[package]] name = "ahash" version = "0.8.3" @@ -320,6 +349,38 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "chrono" +version = "0.4.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e3c5919066adf22df73762e50cffcde3a758f2a848b113b586d1f86728b673b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "chrono-tz" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf9cc2b23599e6d7479755f3594285efb3f74a1bdca7a7374948bc831e23a552" +dependencies = [ + "chrono", + "chrono-tz-build", + "phf", +] + +[[package]] +name = "chrono-tz-build" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9998fb9f7e9b2111641485bf8beb32f92945f97f92a3d061f744cfef335f751" +dependencies = [ + "parse-zoneinfo", + "phf", + "phf_codegen", +] + [[package]] name = "clap" version = "4.1.8" @@ -397,6 +458,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b540bd8bc810d3885c6ea91e2018302f68baba2129ab3e88f32389ee9370880d" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-utils" version = "0.8.15" @@ -570,6 +640,16 @@ dependencies = [ "instant", ] +[[package]] +name = "flate2" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8a2db397cb1c8772f31494cb8917e48cd1e64f0fa7efac59fbd741a0a8ce841" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "form_urlencoded" version = "1.1.0" @@ -944,6 +1024,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "miniz_oxide" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b275950c28b37e794e8c55d88aeb5e139d0ce23fdbbeda68f8d7174abdf9e8fa" +dependencies = [ + "adler", +] + [[package]] name = "nix" version = "0.26.2" @@ -1126,12 +1215,59 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "parse-zoneinfo" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c705f256449c60da65e11ff6626e0c16a0a0b96aaa348de61376b249bc340f41" +dependencies = [ + "regex", +] + [[package]] name = "percent-encoding" version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "478c572c3d73181ff3c2539045f6eb99e5491218eae919370993b890cdbdd98e" +[[package]] +name = "phf" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928c6535de93548188ef63bb7c4036bd415cd8f36ad25af44b9789b2ee72a48c" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a56ac890c5e3ca598bbdeaa99964edb5b0258a583a9eb6ef4e89fc85d9224770" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1181c94580fa345f50f19d738aaa39c0ed30a600d95cb2d3e23f94266f14fbf" +dependencies = [ + "phf_shared", + "rand", +] + +[[package]] +name = "phf_shared" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1fb5f6f826b772a8d4c0394209441e7d37cbbb967ae9c7e0e8134365c9ee676" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.9" @@ -1221,6 +1357,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-xml" +version = "0.28.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce5e73202a820a31f8a0ee32ada5e21029c81fd9e3ebf668a40832e4219d9d1" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "quote" version = "1.0.23" @@ -1398,6 +1544,12 @@ dependencies = [ "digest", ] +[[package]] +name = "siphasher" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bd3e3206899af3f8b12af284fafc038cc1dc2b41d1b89dd17297221c5d225de" + [[package]] name = "slab" version = "0.4.8" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 8df317a4b6..4143977ed6 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -2,5 +2,7 @@ members = [ "agama-lib", "agama-cli", - "agama-derive" + "agama-derive", + "agama-locale-data", + "agama-dbus-server" ] diff --git a/rust/README.md b/rust/README.md index 565c496b25..df265fef77 100644 --- a/rust/README.md +++ b/rust/README.md @@ -1,8 +1,9 @@ -# Agama Command Line Interface +# Agama Command Line and D-Bus Interface This project aims to build a command-line interface for [Agama](https://github.com/yast/agama), a service-based Linux installer featuring a nice -web interface. +web interface. The second aim is D-Bus service that does not depend heavily on YaST to +reduce memory consumption and also provide better performance. ## Code organization @@ -10,11 +11,14 @@ We have set up [Cargo workspace](https://doc.rust-lang.org/book/ch14-03-cargo-wo three packages: * [agama-lib](./agama-lib): code that can be reused to access the - [Agama DBus API](https://github.com/yast/agama/blob/master/doc/dbus_api.md) and a + [Agama D-Bus API](https://github.com/yast/agama/blob/master/doc/dbus_api.md) and a model for the configuration settings. * [agama-cli](./agama-cli): code specific to the command line interface. * [agama-derive](./agama-derive): includes a [procedural macro](https://doc.rust-lang.org/reference/procedural-macros.html) to reduce the boilerplate code. +* [agama-locale-data](./agama-locale-data): specific library to provide data for localization D-Bus + API +* [agama-dbus-server](./agama-dbus-server): provides D-Bus API for services implemented in rust ## Status @@ -24,6 +28,8 @@ Agama CLI is still a work in progress, although it is already capable of doing a * Handling the auto-installation profiles. * Triggering the *probing* and the *installation* processes. +Agama D-Bus API is also a work in progress, but it is already used by Agama. + ## Installation You can grab the [RPM package](https://build.opensuse.org/package/show/YaST:Head:Agama/agama-cli) from @@ -32,14 +38,17 @@ the [YaST:Head:Agama](https://build.opensuse.org/project/show/YaST:Head:Agama) p If you prefer, you can install it from sources with [Cargo](https://doc.rust-lang.org/cargo/): ``` -git clone https://github.com/yast/agama-cli +git clone https://github.com/openSUSE/agama +cd rust cargo install --path . ``` ## Running -Take into account that you need to run `agama-cli` as root when you want to query or change the -Agama configuration. Assuming that the Agama D-Bus service is running, the next command +For D-Bus API just run as root agama-dbus-server binary and it will properly attach to D-Bus. + +For CLI take into account that you need to run `agama-cli` as root when you want to query or change +the Agama configuration. Assuming that the Agama D-Bus service is running, the next command prints the current settings using JSON (hint: you can use `jq` to make result look better): ``` @@ -102,3 +111,18 @@ frontend](./agama-cli/doc/backend-for-testing.md)*. ## Caveats * If no product is selected, the `probe` command fails. + +## Packaging + +Packaging files live in the `package' directory. Agama follows the +[Rust packaging guidelines](https://en.opensuse.org/openSUSE:Packaging_Rust_Software). +To test changes to the spec file, use a simple `osc branch YaST:Head:Agama agama-cli`. +and copy the modified spec file to that branch. +If it also needs specific code from a git branch, then modify `_service' file and +put the git branch name in the `` tag. Then run `osc service runall`. + +Note: for openSUSE Leap, `cargo_audit` [does not work][c_a_bug] with older Python, so comment out that +service section for the test build. +For the test build, use the usual `osc build' on modified sources. + +[c_a_bug]: https://github.com/openSUSE/obs-service-cargo_audit/pull/6 diff --git a/rust/agama-dbus-server/Cargo.toml b/rust/agama-dbus-server/Cargo.toml new file mode 100644 index 0000000000..6a38539f7c --- /dev/null +++ b/rust/agama-dbus-server/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "agama-dbus-server" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +anyhow = "1.0" +agama-locale-data = { path="../agama-locale-data" } +zbus = "3.7.0" +zbus_macros = "3.7.0" +async-std = { version = "1.12.0", features = ["attributes"]} diff --git a/rust/agama-dbus-server/src/error.rs b/rust/agama-dbus-server/src/error.rs new file mode 100644 index 0000000000..e2414d045d --- /dev/null +++ b/rust/agama-dbus-server/src/error.rs @@ -0,0 +1,21 @@ +use zbus_macros::DBusError; + +#[derive(DBusError, Debug)] +#[dbus_error(prefix = "org.opensuse.Agama.Locale1")] +pub enum Error { + #[dbus_error(zbus_error)] + ZBus(zbus::Error), + Anyhow(String), +} + +// This would be nice, but using it for a return type +// results in a confusing error message about +// error[E0277]: the trait bound `MyError: Serialize` is not satisfied +//type MyResult = Result; + +impl From for Error { + fn from(e: anyhow::Error) -> Self { + // {:#} includes causes + Self::Anyhow(format!("{:#}", e)) + } +} diff --git a/rust/agama-dbus-server/src/locale.rs b/rust/agama-dbus-server/src/locale.rs new file mode 100644 index 0000000000..06ecfa85b7 --- /dev/null +++ b/rust/agama-dbus-server/src/locale.rs @@ -0,0 +1,199 @@ +use crate::error::Error; +use anyhow::Context; +use std::process::Command; +use zbus::{dbus_interface, Connection, ConnectionBuilder}; + +pub struct Locale { + locales: Vec, + keymap: String, + timezone_id: String, + supported_locales: Vec, +} + +#[dbus_interface(name = "org.opensuse.Agama.Locale1")] +impl Locale { + // Can be `async` as well. + /// get labels for given locale. The first pair is english language and territory + /// and second one is localized one to target language from locale. + /// + /// Note: check how often it is used and if often, it can be easily cached + fn labels_for_locales(&self) -> Result, Error> { + const DEFAULT_LANG: &str = "en"; + let mut res = Vec::with_capacity(self.supported_locales.len()); + let languages = agama_locale_data::get_languages()?; + let territories = agama_locale_data::get_territories()?; + for locale in self.supported_locales.as_slice() { + let (loc_language, loc_territory) = agama_locale_data::parse_locale(locale.as_str())?; + + let language = languages.find_by_id(loc_language) + .context("language for passed locale not found")?; + let territory = territories.find_by_id(loc_territory) + .context("territory for passed locale not found")?; + + let default_ret = ( + language + .names + .name_for(DEFAULT_LANG) + .context("missing default translation for language")?, + territory + .names + .name_for(DEFAULT_LANG) + .context("missing default translation for territory")?, + ); + let localized_ret = ( + language + .names + .name_for(language.id.as_str()) + .context("missing native label for language")?, + territory + .names + .name_for(language.id.as_str()) + .context("missing native label for territory")?, + ); + res.push((default_ret, localized_ret)); + } + + Ok(res) + } + + #[dbus_interface(property)] + fn locales(&self) -> Vec { + return self.locales.to_owned(); + } + + #[dbus_interface(property)] + fn set_locales(&mut self, locales: Vec) -> zbus::fdo::Result<()> { + for loc in &locales { + if !self.supported_locales.contains(loc) { + return Err(zbus::fdo::Error::Failed(format!("Unsupported locale value '{loc}'"))) + } + } + self.locales = locales; + Ok(()) + } + + #[dbus_interface(property)] + fn supported_locales(&self) -> Vec { + self.supported_locales.to_owned() + } + + #[dbus_interface(property)] + fn set_supported_locales(&mut self, locales: Vec) -> Result<(), zbus::fdo::Error> { + self.supported_locales = locales; + // TODO: handle if current selected locale contain something that is no longer supported + Ok(()) + } + + /* support only keymaps for console for now + fn list_x11_keyboards(&self) -> Result, Error> { + let keyboards = agama_locale_data::get_xkeyboards()?; + let ret = keyboards + .keyboard.iter() + .map(|k| (k.id.clone(), k.description.clone())) + .collect(); + Ok(ret) + } + + fn set_x11_keyboard(&mut self, keyboard: &str) { + self.keyboard_id = keyboard.to_string(); + } + */ + + #[dbus_interface(name="ListVConsoleKeyboards")] + fn list_keyboards(&self) -> Result, Error> { + let res = agama_locale_data::get_key_maps()?; + Ok(res) + } + + #[dbus_interface(property, name="VConsoleKeyboard")] + fn keymap(&self) -> &str { + return &self.keymap.as_str(); + } + + #[dbus_interface(property, name="VConsoleKeyboard")] + fn set_keymap(&mut self, keyboard: &str) -> Result<(), zbus::fdo::Error> { + let exist = agama_locale_data::get_key_maps().unwrap().iter().find(|&k| k == keyboard).is_some(); + if !exist { + return Err(zbus::fdo::Error::Failed("Invalid keyboard value".to_string())) + } + self.keymap = keyboard.to_string(); + Ok(()) + } + + fn list_timezones(&self, locale: &str) -> Result, Error> { + let timezones = agama_locale_data::get_timezones(); + let localized = + agama_locale_data::get_timezone_parts()?.localize_timezones(locale, &timezones); + let ret = timezones.into_iter().zip(localized.into_iter()).collect(); + Ok(ret) + } + + #[dbus_interface(property)] + fn timezone(&self) -> &str { + return &self.timezone_id.as_str(); + } + + #[dbus_interface(property)] + fn set_timezone(&mut self, timezone: &str) -> Result<(), zbus::fdo::Error> { // NOTE: cannot use crate::Error as property expect this one + self.timezone_id = timezone.to_string(); + Ok(()) + } + + // TODO: what should be returned value for commit? + fn commit(&mut self) -> Result<(), Error> { + const ROOT: &str = "/mnt"; + Command::new("/usr/bin/systemd-firstboot") + .args(["root", ROOT, "--locale", self.locales.first().context("missing locale")?.as_str()]) + .status() + .context("Failed to execute systemd-firstboot")?; + Command::new("/usr/bin/systemd-firstboot") + .args(["root", ROOT, "--keymap", self.keymap.as_str()]) + .status() + .context("Failed to execute systemd-firstboot")?; + Command::new("/usr/bin/systemd-firstboot") + .args(["root", ROOT, "--timezone", self.timezone_id.as_str()]) + .status() + .context("Failed to execute systemd-firstboot")?; + + Ok(()) + } +} + + +impl Locale { + fn new() -> Locale { + Locale { + locales: vec!["en_US.UTF-8".to_string()], + keymap: "us".to_string(), + timezone_id: "America/Los_Angeles".to_string(), + supported_locales: vec!["en_US.UTF-8".to_string()], + } + } + + pub async fn start_service() -> Result> { + const ADDRESS : &str = "unix:path=/run/agama/bus"; + const SERVICE_NAME: &str = "org.opensuse.Agama.Locale1"; + const SERVICE_PATH: &str = "/org/opensuse/Agama/Locale1"; + + // First connect to the Agama bus, then serve our API, + // for better error reporting. + let conn = ConnectionBuilder::address(ADDRESS)? + .build() + .await + .context(format!("Connecting to the Agama bus at {ADDRESS}"))?; + + // When serving, request the service name _after_ exposing the main object + let locale = Locale::new(); + conn + .object_server() + .at(SERVICE_PATH, locale) + .await?; + conn + .request_name(SERVICE_NAME) + .await + .context(format!("Requesting name {SERVICE_NAME}"))?; + + Ok(conn) + } +} + diff --git a/rust/agama-dbus-server/src/main.rs b/rust/agama-dbus-server/src/main.rs new file mode 100644 index 0000000000..39aa557235 --- /dev/null +++ b/rust/agama-dbus-server/src/main.rs @@ -0,0 +1,14 @@ +pub mod error; +pub mod locale; + +use std::future::pending; + +#[async_std::main] +async fn main() -> Result<(), Box> { + let _con = crate::locale::Locale::start_service().await?; + + // Do other things or go to wait forever + pending::<()>().await; + + Ok(()) +} diff --git a/rust/agama-lib/src/lib.rs b/rust/agama-lib/src/lib.rs index c4803e1923..ab085a5f7b 100644 --- a/rust/agama-lib/src/lib.rs +++ b/rust/agama-lib/src/lib.rs @@ -16,8 +16,8 @@ use crate::error::ServiceError; use anyhow::Context; pub async fn connection() -> Result { - let path = "/run/agama/bus"; - let address = format!("unix:path={path}"); + const PATH : &str = "/run/agama/bus"; + let address : String = format!("unix:path={PATH}"); let conn = zbus::ConnectionBuilder::address(address.as_str())? .build() .await diff --git a/rust/agama-locale-data/Cargo.toml b/rust/agama-locale-data/Cargo.toml new file mode 100644 index 0000000000..46380041c5 --- /dev/null +++ b/rust/agama-locale-data/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "agama-locale-data" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +anyhow = "1.0" +serde = { version = "1.0.152", features = ["derive"] } +quick-xml = { version = "0.28.2", features = ["serialize"] } +flate2 = "1.0.25" +chrono-tz = "0.8.2" +regex = "1" diff --git a/rust/agama-locale-data/src/deprecated_timezones.rs b/rust/agama-locale-data/src/deprecated_timezones.rs new file mode 100644 index 0000000000..403f210859 --- /dev/null +++ b/rust/agama-locale-data/src/deprecated_timezones.rs @@ -0,0 +1,173 @@ +/// List of timezones which are deprecated and langtables missing translations for it +/// +/// Filtering it out also helps with returning smaller list of real timezones. +/// Sadly many libraries facing issues with deprecated timezones, see e.g. +/// +pub(crate) const DEPRECATED_TIMEZONES : &[&str]= &[ + "Africa/Asmera", // replaced by Africa/Asmara + "Africa/Timbuktu", // replaced by Africa/Bamako + "America/Argentina/ComodRivadavia", // replaced by America/Argentina/Catamarca + "America/Atka", // replaced by America/Adak + "America/Ciudad_Juarez", // failed to find replacement + "America/Coral_Harbour", // replaced by America/Atikokan + "America/Ensenada", // replaced by America/Tijuana + "America/Fort_Nelson", + "America/Fort_Wayne", // replaced by America/Indiana/Indianapolis + "America/Knox_IN", // replaced by America/Indiana/Knox + "America/Nuuk", + "America/Porto_Acre", // replaced by America/Rio_Branco + "America/Punta_Arenas", + "America/Rosario", + "America/Virgin", + "Antarctica/Troll", + "Asia/Ashkhabad", // looks like typo/wrong transcript, it should be Asia/Ashgabat + "Asia/Atyrau", + "Asia/Barnaul", + "Asia/Calcutta", // renamed to Asia/Kolkata + "Asia/Chita", + "Asia/Chungking", + "Asia/Dacca", + "Asia/Famagusta", + "Asia/Katmandu", + "Asia/Macao", + "Asia/Qostanay", + "Asia/Saigon", + "Asia/Srednekolymsk", + "Asia/Tel_Aviv", + "Asia/Thimbu", + "Asia/Tomsk", + "Asia/Ujung_Pandang", + "Asia/Ulan_Bator", + "Asia/Yangon", + "Atlantic/Faeroe", + "Atlantic/Jan_Mayen", + "Australia/ACT", + "Australia/Canberra", + "Australia/LHI", + "Australia/NSW", + "Australia/North", + "Australia/Queensland", + "Australia/South", + "Australia/Tasmania", + "Australia/Victoria", + "Australia/West", + "Australia/Yancowinna", + "Brazil/Acre", + "Brazil/DeNoronha", + "Brazil/East", + "Brazil/West", + "CET", + "CST6CDT", + "Canada/Atlantic", // all canada TZ was replaced by America ones + "Canada/Central", + "Canada/Eastern", + "Canada/Mountain", + "Canada/Newfoundland", + "Canada/Pacific", + "Canada/Saskatchewan", + "Canada/Yukon", + "Chile/Continental", // all Chile was replaced by continental America tz + "Chile/EasterIsland", + "Cuba", + "EET", + "EST", // not sure why it is not in langtable + "EST5EDT", + "Egypt", + "Eire", + "Etc/GMT", + "Etc/GMT+0", + "Etc/GMT+1", + "Etc/GMT+2", + "Etc/GMT+3", + "Etc/GMT+4", + "Etc/GMT+5", + "Etc/GMT+6", + "Etc/GMT+7", + "Etc/GMT+8", + "Etc/GMT+9", + "Etc/GMT+10", + "Etc/GMT+11", + "Etc/GMT+12", + "Etc/GMT-0", + "Etc/GMT-1", + "Etc/GMT-2", + "Etc/GMT-3", + "Etc/GMT-4", + "Etc/GMT-5", + "Etc/GMT-6", + "Etc/GMT-7", + "Etc/GMT-8", + "Etc/GMT-9", + "Etc/GMT-10", + "Etc/GMT-11", + "Etc/GMT-12", + "Etc/GMT-13", + "Etc/GMT-14", + "Etc/GMT0", + "Etc/Greenwich", + "Etc/UCT", + "Etc/UTC", + "Etc/Universal", + "Etc/Zulu", + "Europe/Astrakhan", + "Europe/Belfast", + "Europe/Kirov", + "Europe/Kyiv", + "Europe/Saratov", + "Europe/Tiraspol", + "Europe/Ulyanovsk", + "GB", + "GB-Eire", + "GMT", + "GMT+0", + "GMT-0", + "GMT0", + "Greenwich", + "HST", + "Hongkong", + "Iceland", + "Iran", + "Israel", + "Jamaica", + "Japan", + "Kwajalein", + "Libya", + "MET", + "Mexico/BajaNorte", + "Mexico/BajaSur", + "Mexico/General", + "MST", + "MST7MDT", + "NZ", + "NZ-CHAT", + "Navajo", + "Pacific/Bougainville", + "Pacific/Kanton", + "Pacific/Ponape", + "Pacific/Samoa", + "Pacific/Truk", + "Pacific/Yap", + "PRC", + "PST8PDT", + "Poland", + "Portugal", + "ROC", + "ROK", + "Singapore", + "Turkey", + "UCT", + "Universal", + "US/Aleutian", // all US/ replaced by America + "US/Central", + "US/East-Indiana", + "US/Eastern", + "US/Hawaii", + "US/Indiana-Starke", + "US/Michigan", + "US/Mountain", + "US/Pacific", + "US/Samoa", + "W-SU", + "WET", + "Zulu", +]; \ No newline at end of file diff --git a/rust/agama-locale-data/src/language.rs b/rust/agama-locale-data/src/language.rs new file mode 100644 index 0000000000..8502459307 --- /dev/null +++ b/rust/agama-locale-data/src/language.rs @@ -0,0 +1,23 @@ +use serde::Deserialize; + +use crate::ranked::{RankedTerritories, RankedLocales}; + +#[derive(Debug, Deserialize)] +pub struct Language { + #[serde(rename(deserialize = "languageId"))] + pub id: String, + pub territories: RankedTerritories, + pub locales: RankedLocales, + pub names: crate::localization::Localization +} + +#[derive(Debug, Deserialize)] +pub struct Languages { + pub language: Vec +} + +impl Languages { + pub fn find_by_id(&self, id: &str) -> Option<&Language> { + self.language.iter().find(|t| t.id == id) + } +} \ No newline at end of file diff --git a/rust/agama-locale-data/src/lib.rs b/rust/agama-locale-data/src/lib.rs new file mode 100644 index 0000000000..4a10c2489d --- /dev/null +++ b/rust/agama-locale-data/src/lib.rs @@ -0,0 +1,158 @@ +use anyhow::Context; +use serde::Deserialize; +use std::fs::File; +use std::io::BufRead; +use std::io::BufReader; +use std::process::Command; +use quick_xml::de::Deserializer; +use flate2::bufread::GzDecoder; +use regex::Regex; + +pub mod xkeyboard; +pub mod language; +pub mod localization; +pub mod territory; +pub mod timezone_part; +pub mod ranked; +pub mod deprecated_timezones; + +fn file_reader(file_path: &str) -> anyhow::Result { + let file = File::open(file_path) + .with_context(|| format!("Failed to read langtable-data ({})", file_path))?; + let reader = BufReader::new(GzDecoder::new(BufReader::new(file))); + Ok(reader) +} + +/// Gets list of X11 keyboards structs +pub fn get_xkeyboards() -> anyhow::Result { + const FILE_PATH: &str = "/usr/share/langtable/data/keyboards.xml.gz"; + let reader = file_reader(FILE_PATH)?; + let mut deserializer = Deserializer::from_reader(reader); + let ret = xkeyboard::XKeyboards::deserialize(&mut deserializer) + .context("Failed to deserialize keyboard entry")?; + Ok(ret) +} + +/// Gets list of available keymaps +/// +/// ## Examples +/// Requires working localectl. +/// +/// ```no_run +/// let key_maps = agama_locale_data::get_key_maps().unwrap(); +/// assert!(key_maps.contains(&"us".to_string())) +/// ``` +pub fn get_key_maps() -> anyhow::Result> { + const BINARY: &str = "/usr/bin/localectl"; + let output = Command::new(BINARY).arg("list-keymaps") + .output().context("failed to execute localectl list-maps")?.stdout; + let output = String::from_utf8(output).context("Strange localectl output formatting")?; + let ret = output.split('\n').map(|l| l.trim().to_string()).collect(); + + Ok(ret) +} + +/// Parses given locale to language and territory part +/// +/// /// ## Examples +/// +/// ``` +/// let result = agama_locale_data::parse_locale("en_US.UTF-8").unwrap(); +/// assert_eq!(result.0, "en"); +/// assert_eq!(result.1, "US") +/// ``` +pub fn parse_locale(locale: &str) -> anyhow::Result<(&str, &str)> { + let locale_regexp : Regex = Regex::new(r"^([[:alpha:]]+)_([[:alpha:]]+)").unwrap(); + let captures = locale_regexp.captures(locale).context("Failed to parse locale")?; + Ok((captures.get(1).unwrap().as_str(), captures.get(2).unwrap().as_str())) +} + +/// Returns struct which contain list of known languages +pub fn get_languages() -> anyhow::Result { + const FILE_PATH: &str = "/usr/share/langtable/data/languages.xml.gz"; + let reader = file_reader(FILE_PATH)?; + let mut deserializer = Deserializer::from_reader(reader); + let ret = language::Languages::deserialize(&mut deserializer) + .context("Failed to deserialize language entry")?; + Ok(ret) +} + +/// Returns struct which contain list of known territories +pub fn get_territories() -> anyhow::Result { + const FILE_PATH: &str = "/usr/share/langtable/data/territories.xml.gz"; + let reader = file_reader(FILE_PATH)?; + let mut deserializer = Deserializer::from_reader(reader); + let ret = territory::Territories::deserialize(&mut deserializer) + .context("Failed to deserialize territory entry")?; + Ok(ret) +} + +/// Returns struct which contain list of known parts of timezones. Useful for translation +pub fn get_timezone_parts() -> anyhow::Result { + const FILE_PATH: &str = "/usr/share/langtable/data/timezoneidparts.xml.gz"; + let reader = file_reader(FILE_PATH)?; + let mut deserializer = Deserializer::from_reader(reader); + let ret = timezone_part::TimezoneIdParts::deserialize(&mut deserializer) + .context("Failed to deserialize timezone part entry")?; + Ok(ret) +} + + +/// Gets list of non-deprecated timezones +pub fn get_timezones() -> Vec { + chrono_tz::TZ_VARIANTS.iter() + .filter(|&tz| !crate::deprecated_timezones::DEPRECATED_TIMEZONES.contains(&tz.name())) // Filter out deprecated asmera + .map(|e| e.name().to_string()) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_keyboards() { + let result = get_xkeyboards().unwrap(); + let first = result.keyboard.first().expect("no keyboards"); + assert_eq!(first.id, "ad") + } + + #[test] + fn test_get_languages() { + let result = get_languages().unwrap(); + let first = result.language.first().expect("no keyboards"); + assert_eq!(first.id, "aa") + } + + #[test] + fn test_get_territories() { + let result = get_territories().unwrap(); + let first = result.territory.first().expect("no keyboards"); + assert_eq!(first.id, "001") // looks strange, but it is meta id for whole world + } + + #[test] + fn test_get_timezone_parts() { + let result = get_timezone_parts().unwrap(); + let first = result.timezone_part.first().expect("no keyboards"); + assert_eq!(first.id, "Abidjan") + } + + #[test] + fn test_get_timezones() { + let result = get_timezones(); + assert_eq!(result.len(), 430); + let first = result.first().expect("no keyboards"); + assert_eq!(first, "Africa/Abidjan"); + // test that we filter out deprecates Asmera ( there is already recent Asmara) + let asmera = result.iter().find(|&t| *t == "Africa/Asmera".to_string()); + assert_eq!(asmera, None); + let asmara = result.iter().find(|&t| *t == "Africa/Asmara".to_string()); + assert_eq!(asmara, Some(&"Africa/Asmara".to_string())); + // here test that timezones from timezones matches ones in langtable ( as timezones can contain deprecated ones) + // so this test catch if there is new zone that is not translated or if a zone is become deprecated + let timezones = get_timezones(); + let localized = get_timezone_parts().unwrap().localize_timezones("de", &timezones); + let _res : Vec<(String, String)> = timezones.into_iter().zip(localized.into_iter()).collect(); + } +} diff --git a/rust/agama-locale-data/src/localization.rs b/rust/agama-locale-data/src/localization.rs new file mode 100644 index 0000000000..a3622589a9 --- /dev/null +++ b/rust/agama-locale-data/src/localization.rs @@ -0,0 +1,22 @@ +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub struct Localization { + pub name: Vec +} + +impl Localization { + pub fn name_for(&self, language: &str) -> Option { + let entry = self.name.iter() + .find(|n| n.language == language)?; + Some(entry.value.clone()) + } +} + +#[derive(Debug, Deserialize)] +pub struct LocalizationEntry { + #[serde(rename(deserialize = "languageId"))] + pub language: String, + #[serde(rename(deserialize = "trName"))] + pub value: String +} \ No newline at end of file diff --git a/rust/agama-locale-data/src/ranked.rs b/rust/agama-locale-data/src/ranked.rs new file mode 100644 index 0000000000..f5d26a2c60 --- /dev/null +++ b/rust/agama-locale-data/src/ranked.rs @@ -0,0 +1,43 @@ +//! Bigger rank means it is more important +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub struct RankedLanguage { + #[serde(rename(deserialize = "languageId"))] + pub id: String, + /// Bigger rank means it is more important + pub rank: u16 +} + +#[derive(Debug, Deserialize)] +pub struct RankedLanguages { + #[serde(default)] + pub language: Vec +} + +#[derive(Debug, Deserialize)] +pub struct RankedTerritory { + #[serde(rename(deserialize = "territoryId"))] + pub id: String, + /// Bigger rank means it is more important + pub rank: u16 +} + +#[derive(Debug, Deserialize)] +pub struct RankedTerritories { + #[serde(default)] + pub territory: Vec +} + +#[derive(Debug, Deserialize)] +pub struct RankedLocale { + #[serde(rename(deserialize = "localeId"))] + pub id: String, + pub rank: u16 +} + +#[derive(Debug, Deserialize)] +pub struct RankedLocales { + #[serde(default)] + pub locale: Vec +} diff --git a/rust/agama-locale-data/src/territory.rs b/rust/agama-locale-data/src/territory.rs new file mode 100644 index 0000000000..209afbe2b5 --- /dev/null +++ b/rust/agama-locale-data/src/territory.rs @@ -0,0 +1,20 @@ +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub struct Territory { + #[serde(rename(deserialize = "territoryId"))] + pub id: String, + pub languages: crate::ranked::RankedLanguages, + pub names: crate::localization::Localization +} + +#[derive(Debug, Deserialize)] +pub struct Territories { + pub territory: Vec +} + +impl Territories { + pub fn find_by_id(&self, id: &str) -> Option<&Territory> { + self.territory.iter().find(|t| t.id == id) + } +} \ No newline at end of file diff --git a/rust/agama-locale-data/src/timezone_part.rs b/rust/agama-locale-data/src/timezone_part.rs new file mode 100644 index 0000000000..256fd3000c --- /dev/null +++ b/rust/agama-locale-data/src/timezone_part.rs @@ -0,0 +1,66 @@ +use std::collections::HashMap; + +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub struct TimezoneIdPart { + #[serde(rename(deserialize = "timezoneIdPartId"))] + /// "Prague" + pub id: String, + /// [{language: "cs", value: "Praha"}, {"language": "de", value: "Prag"} ...] + pub names: crate::localization::Localization, +} + +// Timezone id parts are useful mainly for localization of timezones +// Just search each part of timezone for translation +#[derive(Debug, Deserialize)] +pub struct TimezoneIdParts { + #[serde(rename(deserialize = "timezoneIdPart"))] + pub timezone_part: Vec, +} + +impl TimezoneIdParts { + /// Localized given list of timezones to given language + /// # Examples + /// + /// ``` + /// let parts = agama_locale_data::get_timezone_parts().expect("missing timezone parts"); + /// let timezones = vec!["Europe/Prague".to_string(), "Europe/Berlin".to_string()]; + /// let result = vec!["Evropa/Praha".to_string(), "Evropa/Berlín".to_string()]; + /// assert_eq!(parts.localize_timezones("cs", &timezones), result); + /// ``` + pub fn localize_timezones(&self, language: &str, timezones: &Vec) -> Vec { + let mapping = self.construct_mapping(language); + timezones + .iter() + .map(|tz| self.translate_timezone(&mapping, tz)) + .collect() + } + + fn construct_mapping(&self, language: &str) -> HashMap { + let mut res: HashMap = HashMap::with_capacity(self.timezone_part.len()); + self.timezone_part + .iter() + .map(|part| (part.id.clone(), part.names.name_for(language))) + .for_each(|(time_id, names)| -> () { + // skip missing translations + if let Some(trans) = names { + res.insert(time_id, trans); + } + }); + return res; + } + + fn translate_timezone(&self, mapping: &HashMap, timezone: &str) -> String { + timezone + .split("/") + .map(|tzp| { + mapping + .get(&tzp.to_string()) + .expect(format!("Unknown timezone part {tzp}").as_str()) + .to_owned() + }) + .collect::>() + .join("/") + } +} diff --git a/rust/agama-locale-data/src/xkeyboard.rs b/rust/agama-locale-data/src/xkeyboard.rs new file mode 100644 index 0000000000..b762aead83 --- /dev/null +++ b/rust/agama-locale-data/src/xkeyboard.rs @@ -0,0 +1,21 @@ +use serde::Deserialize; + +use crate::ranked::{RankedTerritories, RankedLanguages}; + +#[derive(Debug, Deserialize)] +pub struct XKeyboard { + #[serde(rename(deserialize = "keyboardId"))] + /// like "layout(variant)", for example "us" or "ua(phonetic)" + pub id: String, + /// like "Ukrainian (phonetic)" + pub description: String, + pub ascii: bool, + pub comment: Option, + pub languages: RankedLanguages, + pub territories: RankedTerritories +} + +#[derive(Debug, Deserialize)] +pub struct XKeyboards { + pub keyboard: Vec +} diff --git a/rust/package/agama-cli.spec b/rust/package/agama-cli.spec index baf616ffa7..b57bca80c1 100644 --- a/rust/package/agama-cli.spec +++ b/rust/package/agama-cli.spec @@ -23,19 +23,35 @@ Summary: Agama command line interface # If you know the license, put it's SPDX string here. # Alternately, you can use cargo lock2rpmprovides to help generate this. License: GPL-2.0-only -Url: https://github.com/yast/agama-cli +Url: https://github.com/opensuse/agama Source0: agama.tar Source1: vendor.tar.zst # Generated by the cargo_vendor OBS service Source2: cargo_config BuildRequires: cargo-packaging BuildRequires: pkgconfig(openssl) +# used in tests for dbus service +BuildRequires: python-langtable-data +BuildRequires: dbus-1-common Requires: jsonnet Requires: lshw %description Command line program to interact with the agama service. +%package -n agama-dbus-server +# This will be set by osc services, that will run after this. +Version: 0 +Release: 0 +Summary: Agama Rust D-Bus service +License: GPL-2.0-only +Url: https://github.com/opensuse/agama +Requires: python-langtable-data +Requires: dbus-1-common + +%description -n agama-dbus-server +DBus service for agama project. It provides so far localization service. + %prep %autosetup -a1 -n agama mkdir .cargo @@ -49,8 +65,12 @@ cp %{SOURCE2} .cargo/config %install install -D -d -m 0755 %{buildroot}%{_bindir} install -m 0755 %{_builddir}/agama/target/release/agama %{buildroot}%{_bindir}/agama +install -m 0755 %{_builddir}/agama/target/release/agama %{buildroot}%{_bindir}/agama-dbus-server install -D -d -m 0755 %{buildroot}%{_datadir}/agama-cli install -m 0644 %{_builddir}/agama/agama-lib/share/profile.schema.json %{buildroot}%{_datadir}/agama-cli +install --directory %{buildroot}%{_datadir}/dbus-1/agama-services +install -m 0644 --target-directory=%{buildroot}%{_datadir}/dbus-1/agama-services %{_builddir}/agama/share/*.service + %check %{cargo_test} @@ -60,4 +80,8 @@ install -m 0644 %{_builddir}/agama/agama-lib/share/profile.schema.json %{buildro %dir %{_datadir}/agama-cli %{_datadir}/agama-cli/profile.schema.json +%files -n agama-dbus-server +%{_bindir}/agama-dbus-server +%{_datadir}/dbus-1/agama-services + %changelog diff --git a/rust/share/org.opensuse.Agama.Locale1.service b/rust/share/org.opensuse.Agama.Locale1.service new file mode 100644 index 0000000000..35ed50eef0 --- /dev/null +++ b/rust/share/org.opensuse.Agama.Locale1.service @@ -0,0 +1,4 @@ +[D-BUS Service] +Name=org.opensuse.Agama.Locale1 +Exec=/usr/bin/agama-dbus-server +User=root diff --git a/service/bin/agamactl b/service/bin/agamactl index 930a02e4c0..5caee1a663 100755 --- a/service/bin/agamactl +++ b/service/bin/agamactl @@ -63,33 +63,16 @@ def start_service(name) service_runner.run end -ORDERED_SERVICES = [:questions, :language, :software, :storage, :users, :manager].freeze - -# Normally the services are started by D-Bus activation. -# This starts all the services sequentially without relying on that. -# This is useful during development to have their log output on the terminal, -# not mixed with other syslog/journal messages. -# The downside is relying on an arbitrary delay, and a manually maintained ordering. -# -# @return [void] -# @see ORDERED_SERVICES -def start_all_services - ORDERED_SERVICES.map do |name| - puts "Starting #{name}:" - fork { exec("#{__FILE__} #{name}") } - sleep(7) - end -end +ORDERED_SERVICES = [:questions, :software, :storage, :users, :manager].freeze dbus_server_manager = Agama::DBus::ServerManager.new if ARGV.empty? - start_all_services - Signal.trap("SIGINT") do - puts "Stopping all services..." - exit - end - Process.wait + puts "ERROR: Using 'agamactl' to start all services no longer works." + puts "NOTE: It had race conditions all along and now there's a Rust service it can't reach too." + puts "NOTE: Use `systemctl start agama.service` instead" + puts "NOTE: which is setup by a) RPMs b) ./setup-service.sh" + exit 1 elsif ["-h", "--help"].include?(ARGV[0]) me = $PROGRAM_NAME puts "Usage:" diff --git a/service/lib/agama/dbus/clients/language.rb b/service/lib/agama/dbus/clients/locale.rb similarity index 58% rename from service/lib/agama/dbus/clients/language.rb rename to service/lib/agama/dbus/clients/locale.rb index 35a28515e4..ff27aaf19c 100644 --- a/service/lib/agama/dbus/clients/language.rb +++ b/service/lib/agama/dbus/clients/locale.rb @@ -24,54 +24,40 @@ module Agama module DBus module Clients - # D-Bus client for language configuration - class Language < Base + # D-Bus client for locale configuration + class Locale < Base def initialize super - @dbus_object = service.object("/org/opensuse/Agama/Language1") + @dbus_object = service.object("/org/opensuse/Agama/Locale1") @dbus_object.introspect end def service_name - @service_name ||= "org.opensuse.Agama.Language1" + @service_name ||= "org.opensuse.Agama.Locale1" end - # Available languages for the installation + # Sets the supported locales. It can differs per product. # - # @return [Array>] id and name of each language - def available_languages - dbus_object["org.opensuse.Agama.Language1"]["AvailableLanguages"].map { |l| l[0..1] } - end - - # Languages selected to install - # - # @return [Array] ids of the languages - def selected_languages - dbus_object["org.opensuse.Agama.Language1"]["MarkedForInstall"] - end - - # Selects the languages to install - # - # @param ids [Array] - def select_languages(ids) - dbus_object.ToInstall(ids) + # @param locales [Array] + def supported_locales=(locales) + dbus_object.supported_locales = locales end # Finishes the language installation def finish - dbus_object.Finish + dbus_object.Commit end - # Registers a callback to run when the language changes + # Registers a callback to run when the selected locales changes # # @note Signal subscription is done only once. Otherwise, the latest subscription overrides # the previous one. # - # @param block [Proc] Callback to run when a language is selected + # @param block [Proc] Callback to run when a locales are selected def on_language_selected(&block) on_properties_change(dbus_object) do |_, changes, _| - languages = changes["MarkedForInstall"] + languages = changes["Locales"] block.call(languages) end end diff --git a/service/lib/agama/dbus/software/manager.rb b/service/lib/agama/dbus/software/manager.rb index d290f453db..eac0ff69c9 100644 --- a/service/lib/agama/dbus/software/manager.rb +++ b/service/lib/agama/dbus/software/manager.rb @@ -22,7 +22,7 @@ require "dbus" require "agama/dbus/base_object" require "agama/dbus/with_service_status" -require "agama/dbus/clients/language" +require "agama/dbus/clients/locale" require "agama/dbus/clients/network" require "agama/dbus/interfaces/progress" require "agama/dbus/interfaces/service_status" @@ -135,7 +135,7 @@ def finish # Registers callback to be called def register_callbacks - lang_client = Agama::DBus::Clients::Language.new + lang_client = Agama::DBus::Clients::Locale.new lang_client.on_language_selected do |language_ids| backend.languages = language_ids end diff --git a/service/lib/agama/manager.rb b/service/lib/agama/manager.rb index 040724b103..2b214fabaa 100644 --- a/service/lib/agama/manager.rb +++ b/service/lib/agama/manager.rb @@ -25,7 +25,7 @@ require "agama/with_progress" require "agama/installation_phase" require "agama/service_status_recorder" -require "agama/dbus/clients/language" +require "agama/dbus/clients/locale" require "agama/dbus/clients/software" require "agama/dbus/clients/storage" require "agama/dbus/clients/users" @@ -124,9 +124,9 @@ def software # Language manager # - # @return [DBus::Clients::Language] + # @return [DBus::Clients::Locale] def language - @language ||= DBus::Clients::Language.new + @language ||= DBus::Clients::Locale.new end # Users client diff --git a/service/share/dbus.conf b/service/share/dbus.conf index da06b7e009..47a6ef1f4c 100644 --- a/service/share/dbus.conf +++ b/service/share/dbus.conf @@ -36,7 +36,7 @@ - + @@ -44,7 +44,7 @@ - + diff --git a/service/test/agama/dbus/clients/language_test.rb b/service/test/agama/dbus/clients/locale_test.rb similarity index 57% rename from service/test/agama/dbus/clients/language_test.rb rename to service/test/agama/dbus/clients/locale_test.rb index 93e469d180..21b47bcdce 100644 --- a/service/test/agama/dbus/clients/language_test.rb +++ b/service/test/agama/dbus/clients/locale_test.rb @@ -20,17 +20,17 @@ # find current contact information at www.suse.com. require_relative "../../../test_helper" -require "agama/dbus/clients/language" +require "agama/dbus/clients/locale" require "dbus" -describe Agama::DBus::Clients::Language do +describe Agama::DBus::Clients::Locale do before do allow(Agama::DBus::Bus).to receive(:current).and_return(bus) - allow(bus).to receive(:service).with("org.opensuse.Agama.Language1").and_return(service) - allow(service).to receive(:object).with("/org/opensuse/Agama/Language1") + allow(bus).to receive(:service).with("org.opensuse.Agama.Locale1").and_return(service) + allow(service).to receive(:object).with("/org/opensuse/Agama/Locale1") .and_return(dbus_object) allow(dbus_object).to receive(:introspect) - allow(dbus_object).to receive(:[]).with("org.opensuse.Agama.Language1") + allow(dbus_object).to receive(:[]).with("org.opensuse.Agama.Locale1") .and_return(lang_iface) end @@ -41,44 +41,13 @@ subject { described_class.new } - describe "#available_languages" do - before do - allow(lang_iface).to receive(:[]).with("AvailableLanguages").and_return( - [ - ["en_US", "English (US)", {}], - ["en_GB", "English (UK)", {}], - ["es_ES", "Español", {}] - ] - ) - end - - it "returns the id and name for all available languages" do - expect(subject.available_languages).to contain_exactly( - ["en_US", "English (US)"], - ["en_GB", "English (UK)"], - ["es_ES", "Español"] - ) - end - end - - describe "#selected_languages" do - before do - allow(lang_iface).to receive(:[]).with("MarkedForInstall").and_return(["en_US", "es_ES"]) - end - - it "returns the name of the selected languages" do - expect(subject.selected_languages).to contain_exactly("en_US", "es_ES") - end - end - - describe "#select_languages" do + describe "#supported_locales=" do # Using partial double because methods are dynamically added to the proxy object let(:dbus_object) { double(::DBus::ProxyObject) } - it "selects the given languages" do - expect(dbus_object).to receive(:ToInstall).with(["en_GB"]) - - subject.select_languages(["en_GB"]) + it "calls the D-Bus object" do + expect(dbus_object).to receive(:supported_locales=).with(["no", "se"]) + subject.supported_locales = ["no", "se"] end end @@ -86,7 +55,7 @@ let(:dbus_object) { double(::DBus::ProxyObject) } it "calls the D-Bus finish method" do - expect(dbus_object).to receive(:Finish) + expect(dbus_object).to receive(:Commit) subject.finish end end diff --git a/service/test/agama/manager_test.rb b/service/test/agama/manager_test.rb index 96d78aeada..cf57345473 100644 --- a/service/test/agama/manager_test.rb +++ b/service/test/agama/manager_test.rb @@ -48,7 +48,7 @@ write: nil, on_service_status_change: nil, valid?: true ) end - let(:language) { instance_double(Agama::DBus::Clients::Language, finish: nil) } + let(:locale) { instance_double(Agama::DBus::Clients::Locale, finish: nil) } let(:network) { instance_double(Agama::Network, install: nil) } let(:storage) do instance_double( @@ -61,7 +61,7 @@ before do allow(Agama::Network).to receive(:new).and_return(network) - allow(Agama::DBus::Clients::Language).to receive(:new).and_return(language) + allow(Agama::DBus::Clients::Locale).to receive(:new).and_return(locale) allow(Agama::DBus::Clients::Software).to receive(:new).and_return(software) allow(Agama::DBus::Clients::Storage).to receive(:new).and_return(storage) allow(Agama::DBus::Clients::Users).to receive(:new).and_return(users) @@ -119,7 +119,7 @@ expect(network).to receive(:install) expect(software).to receive(:install) expect(software).to receive(:finish) - expect(language).to receive(:finish) + expect(locale).to receive(:finish) expect(storage).to receive(:install) expect(storage).to receive(:finish) expect(users).to receive(:write) diff --git a/setup-service.sh b/setup-service.sh index 52417f1b15..d418919279 100755 --- a/setup-service.sh +++ b/setup-service.sh @@ -1,4 +1,4 @@ -#!/bin/sh +#!/bin/sh -x # Using a git checkout in the current directory, # set up the service (backend) part of agama @@ -32,13 +32,29 @@ sudosed() { sed -e "$1" "$2" | $SUDO tee "$3" > /dev/null } -# - Install the service dependencies +# - Install RPM dependencies + +# this repo can be removed once python-language-data reaches Factory +test -f /etc/zypp/repos.d/d_l_python.repo || \ + $SUDO zypper --non-interactive \ + addrepo https://download.opensuse.org/repositories/devel:/languages:/python/openSUSE_Tumbleweed/ d_l_python +$SUDO zypper --non-interactive --gpg-auto-import-keys install gcc gcc-c++ make openssl-devel ruby-devel \ + python-langtable-data \ + git augeas-devel jemalloc-devel || exit 1 + +# - Install service rubygem dependencies ( cd $MYDIR/service bundle config set --local path 'vendor/bundle' bundle install ) +# - build also rust service +( + cd $MYDIR/rust + cargo build +) + # - D-Bus configuration $SUDO cp -v $MYDIR/service/share/dbus.conf /usr/share/dbus-1/agama.conf @@ -54,9 +70,25 @@ $SUDO cp -v $MYDIR/service/share/dbus.conf /usr/share/dbus-1/agama.conf done sudosed "s@\(ExecStart\)=/usr/bin/@\1=$MYDIR/service/bin/@" \ systemd.service /usr/lib/systemd/system/agama.service +) + +# and same for rust service +( + cd $MYDIR/rust/share + DBUSDIR=/usr/share/dbus-1/agama-services + for SVC in org.opensuse.Agama*.service; do + # it is intention to use debug here to get more useful debugging output + sudosed "s@\(Exec\)=/usr/bin/@\1=$MYDIR/rust/target/debug/@" $SVC $DBUSDIR/$SVC + done +) + +# systemd reload and start of service +( $SUDO systemctl daemon-reload # Start the separate dbus-daemon for Agama - $SUDO systemctl start agama.service + # (in CI we run a custom cockpit-ws which replaces the cockpit.socket + # dependency, continue in that case) + $SUDO systemctl start agama.service || pgrep cockpit-ws ) # - Make sure NetworkManager is running diff --git a/setup.sh b/setup.sh index 00d47f9b19..a4e2c48f8d 100755 --- a/setup.sh +++ b/setup.sh @@ -18,15 +18,15 @@ else SUDO="" fi -# Install dependencies - -$SUDO zypper --non-interactive install gcc gcc-c++ make openssl-devel ruby-devel \ - 'npm>=18' git augeas-devel cockpit jemalloc-devel || exit 1 - # Backend setup $MYDIR/setup-service.sh +# Install Frontend dependencies + +$SUDO zypper --non-interactive --gpg-auto-import-keys install \ + make git 'npm>=18' cockpit || exit 1 + # Web Frontend $SUDO systemctl start cockpit diff --git a/web/cspell.json b/web/cspell.json index dec371fe84..c98e662255 100644 --- a/web/cspell.json +++ b/web/cspell.json @@ -25,6 +25,7 @@ "dasd", "dasds", "dbus", + "España", "filecontent", "filename", "fullname", diff --git a/web/src/client/language.js b/web/src/client/language.js index c486c27d43..d8a4e47a6f 100644 --- a/web/src/client/language.js +++ b/web/src/client/language.js @@ -22,9 +22,9 @@ // @ts-check import DBusClient from "./dbus"; -const LANGUAGE_SERVICE = "org.opensuse.Agama.Language1"; -const LANGUAGE_IFACE = "org.opensuse.Agama.Language1"; -const LANGUAGE_PATH = "/org/opensuse/Agama/Language1"; +const LANGUAGE_SERVICE = "org.opensuse.Agama.Locale1"; +const LANGUAGE_IFACE = "org.opensuse.Agama.Locale1"; +const LANGUAGE_PATH = "/org/opensuse/Agama/Locale1"; /** * @typedef {object} Language @@ -50,9 +50,12 @@ class LanguageClient { */ async getLanguages() { const proxy = await this.client.proxy(LANGUAGE_IFACE); - return proxy.AvailableLanguages.map(lang => { - const [id, name] = lang; - return { id, name }; + const locales = proxy.SupportedLocales; + const labels = await proxy.LabelsForLocales(); + return locales.map((locale, index) => { + // labels structure is [[en_lang, en_territory], [native_lang, native_territory]] + const [[en_lang,], [,]] = labels[index]; + return { id: locale, name: en_lang }; }); } @@ -63,7 +66,7 @@ class LanguageClient { */ async getSelectedLanguages() { const proxy = await this.client.proxy(LANGUAGE_IFACE); - return proxy.MarkedForInstall; + return proxy.Locales; } /** @@ -74,7 +77,7 @@ class LanguageClient { */ async setLanguages(langIDs) { const proxy = await this.client.proxy(LANGUAGE_IFACE); - return proxy.ToInstall(langIDs); + proxy.Locales = langIDs; } /** @@ -85,7 +88,7 @@ class LanguageClient { */ onLanguageChange(handler) { return this.client.onObjectChanged(LANGUAGE_PATH, LANGUAGE_IFACE, changes => { - const selected = changes.MarkedForInstall.v[0]; + const selected = changes.Locales.v[0]; handler(selected); }); } diff --git a/web/src/client/language.test.js b/web/src/client/language.test.js index e23399ed65..ebe05fb712 100644 --- a/web/src/client/language.test.js +++ b/web/src/client/language.test.js @@ -29,9 +29,10 @@ jest.mock("./dbus"); const langProxy = { wait: jest.fn(), - AvailableLanguages: [ - ["cs_CZ", "Cestina", {}] - ] + SupportedLocales: ["es_ES.UTF-8", "en_US.UTF-8"], + LabelsForLocales: jest.fn().mockResolvedValue( + [[["Spanish", "Spain"], ["Español", "España"]], [['English', 'United States'], ['English', 'United States']]] + ), }; jest.mock("./dbus"); @@ -47,6 +48,9 @@ describe("#getLanguages", () => { it("returns the list of available languages", async () => { const client = new LanguageClient(); const availableLanguages = await client.getLanguages(); - expect(availableLanguages).toEqual([{ id: "cs_CZ", name: "Cestina" }]); + expect(availableLanguages).toEqual([ + { id: "es_ES.UTF-8", name: "Spanish" }, + { id: "en_US.UTF-8", name: "English" } + ]); }); });