From 524030ae79f5cffafef3ef09036064070e814879 Mon Sep 17 00:00:00 2001 From: Aylur Date: Tue, 5 Nov 2024 20:00:44 +0000 Subject: [PATCH] feat: greetd ipc client --- CONTRIBUTING.md | 1 - docs/default.nix | 6 + docs/guide/libraries/greet.md | 94 ++++++++++++ docs/vitepress.config.ts | 1 + flake.nix | 1 + lib/greet/cli.vala | 72 +++++++++ lib/greet/client.vala | 267 ++++++++++++++++++++++++++++++++++ lib/greet/config.vala.in | 6 + lib/greet/gir.py | 1 + lib/greet/meson.build | 109 ++++++++++++++ lib/greet/meson_options.txt | 11 ++ lib/greet/version | 1 + 12 files changed, 569 insertions(+), 1 deletion(-) create mode 100644 docs/guide/libraries/greet.md create mode 100644 lib/greet/cli.vala create mode 100644 lib/greet/client.vala create mode 100644 lib/greet/config.vala.in create mode 120000 lib/greet/gir.py create mode 100644 lib/greet/meson.build create mode 100644 lib/greet/meson_options.txt create mode 100644 lib/greet/version diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e09c3dc7..7b29bcfb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,7 +22,6 @@ Planned features, you could help with: - [niri ipc library](https://github.com/Aylur/astal/issues/8) - sway ipc library -- greetd ipc library - http request library abstraction over libsoup (mostly to be used in gjs and lua) - notification sending libnotify clone [#26](https://github.com/Aylur/astal/issues/26) - setting up [uncrustify](https://github.com/uncrustify/uncrustify) for Vala diff --git a/docs/default.nix b/docs/default.nix index ff7e8c9f..3eae6002 100644 --- a/docs/default.nix +++ b/docs/default.nix @@ -176,6 +176,12 @@ in version = ../lib/cava/version; authors = "kotontrion"; }} + ${genLib { + flakepkg = "greet"; + gir = "Greet"; + description = "IPC client for greetd"; + version = ../lib/greet/version; + }} ${genLib { flakepkg = "hyprland"; gir = "Hyprland"; diff --git a/docs/guide/libraries/greet.md b/docs/guide/libraries/greet.md new file mode 100644 index 00000000..f0cd012d --- /dev/null +++ b/docs/guide/libraries/greet.md @@ -0,0 +1,94 @@ +# Greet + +Library and CLI tool for sending requests to [greetd](https://sr.ht/~kennylevinsen/greetd/). + +## Installation + +1. install dependencies + +:::code-group + +```sh [ Arch] +sudo pacman -Syu meson vala json-glib gobject-introspection +``` + +```sh [ Fedora] +sudo dnf install meson vala valadoc json-glib-devel gobject-introspection-devel +``` + +```sh [ Ubuntu] +sudo apt install meson valac libjson-glib-dev gobject-introspection +``` + +::: + +::: info +Although `greetd` is not a direct build dependency, +it should be self-explanatory that the daemon is required to be available at runtime. +::: + +2. clone repo + +```sh +git clone https://github.com/aylur/astal.git +cd astal/lib/greet +``` + +3. install + +```sh +meson setup --prefix /usr build +meson install -C build +``` + +## Usage + +You can browse the [Greet reference](https://aylur.github.io/libastal/greet). + +### CLI + +```sh +astal-greet --help +``` + +### Library + +:::code-group + +```js [ JavaScript] +import Greet from "gi://AstalGreet" + +Greet.login("username", "password", "compositor", (_, res) => { + try { + Greet.login_finish(res) + } catch (err) { + printerr(err) + } +}) +``` + +```py [ Python] +# Not yet documented + +``` + +```lua [ Lua] +local Greet = require("lgi").require("AstalGreet") + +Greet.login("username", "password", "compositor", function (_, res) + local err = Greet.login_finish(res) + if err ~= nil then + print(err) + end +end) +``` + +```vala [ Vala] +try { + yield AstalGreet.login("username", "password", "compositor"); +} catch (Error err) { + printerr(err.message); +} +``` + +::: diff --git a/docs/vitepress.config.ts b/docs/vitepress.config.ts index f542a68e..7e16eb7a 100644 --- a/docs/vitepress.config.ts +++ b/docs/vitepress.config.ts @@ -105,6 +105,7 @@ export default defineConfig({ { text: "Battery", link: "/guide/libraries/battery" }, { text: "Bluetooth", link: "/guide/libraries/bluetooth" }, { text: "Cava", link: "/guide/libraries/cava" }, + { text: "Greet", link: "/guide/libraries/greet" }, { text: "Hyprland", link: "/guide/libraries/hyprland" }, { text: "Mpris", link: "/guide/libraries/mpris" }, { text: "Network", link: "/guide/libraries/network" }, diff --git a/flake.nix b/flake.nix index 734a1109..d7c52298 100644 --- a/flake.nix +++ b/flake.nix @@ -55,6 +55,7 @@ battery = mkPkg "astal-battery" ./lib/battery [json-glib]; bluetooth = mkPkg "astal-bluetooth" ./lib/bluetooth []; cava = mkPkg "astal-cava" ./lib/cava [(pkgs.callPackage ./nix/libcava.nix {})]; + greet = mkPkg "astal-greet" ./lib/greet [json-glib]; hyprland = mkPkg "astal-hyprland" ./lib/hyprland [json-glib]; mpris = mkPkg "astal-mpris" ./lib/mpris [gvfs json-glib]; network = mkPkg "astal-network" ./lib/network [networkmanager]; diff --git a/lib/greet/cli.vala b/lib/greet/cli.vala new file mode 100644 index 00000000..946ec728 --- /dev/null +++ b/lib/greet/cli.vala @@ -0,0 +1,72 @@ +static bool help; +static bool version; +static string username; +static string password; +static string cmd; +[CCode (array_length = false, array_null_terminated = true)] +static string[] env; + +const OptionEntry[] options = { + { "version", 'v', OptionFlags.NONE, OptionArg.NONE, ref version, null, null }, + { "help", 'h', OptionFlags.NONE, OptionArg.NONE, ref help, null, null }, + { "username", 'u', OptionFlags.NONE, OptionArg.STRING, ref username, null, null }, + { "password", 'p', OptionFlags.NONE, OptionArg.STRING, ref password, null, null }, + { "cmd", 'c', OptionFlags.NONE, OptionArg.STRING, ref cmd, null, null }, + { "env", 'e', OptionFlags.NONE, OptionArg.STRING_ARRAY, ref env, null, null }, + { null }, +}; + +async int main(string[] argv) { + try { + var opts = new OptionContext(); + opts.add_main_entries(options, null); + opts.set_help_enabled(false); + opts.set_ignore_unknown_options(false); + opts.parse(ref argv); + } catch (OptionError err) { + printerr (err.message); + return 1; + } + + if (help) { + print("Usage:\n"); + print(" %s [flags]\n\n", argv[0]); + print("Flags:\n"); + print(" -h, --help Print this help and exit\n"); + print(" -v, --version Print version number and exit\n"); + print(" -u, --username User to login to\n"); + print(" -p, --password Password of the user\n"); + print(" -c, --cmd Command to start the session with\n"); + print(" -e, --env Additional env vars to set for the session\n"); + return 0; + } + + if (version) { + printerr(AstalGreet.VERSION); + return 0; + } + + if (username == null) { + printerr("missing username\n"); + return 1; + } + + if (password == null) { + printerr("missing password\n"); + return 1; + } + + if (cmd == null) { + printerr("missing cmd\n"); + return 1; + } + + try { + yield AstalGreet.login_with_env(username, password, cmd, env); + } catch (Error err) { + printerr(err.message); + return 1; + } + + return 0; +} diff --git a/lib/greet/client.vala b/lib/greet/client.vala new file mode 100644 index 00000000..ee220b50 --- /dev/null +++ b/lib/greet/client.vala @@ -0,0 +1,267 @@ +namespace AstalGreet { +/** + * Shorthand for creating a session, posting the password, + * and starting the session with the given `cmd` + * which is parsed with [func@GLib.shell_parse_argv]. + * + * @param username User to login to + * @param password Password of the user + * @param cmd Command to start the session with + */ +public async void login( + string username, + string password, + string cmd +) throws GLib.Error { + yield login_with_env(username, password, cmd, {}); +} + +/** + * Same as [func@AstalGreet.login] but allow for setting additonal env + * in the form of `name=value` pairs. + * + * @param username User to login to + * @param password Password of the user + * @param cmd Command to start the session with + * @param env Additonal env vars to set for the session + */ +public async void login_with_env( + string username, + string password, + string cmd, + string[] env +) throws GLib.Error { + string[] argv; + Shell.parse_argv(cmd, out argv); + try { + yield new CreateSession(username).send(); + yield new PostAuthMesssage(password).send(); + yield new StartSession(argv, env).send(); + } catch (GLib.Error err) { + yield new CancelSession().send(); + throw err; + } +} + +/** + * Base Request type. + */ +public abstract class Request : Object { + protected abstract string type_name { get; } + + private string serialize() { + var node = Json.gobject_serialize(this); + var obj = node.get_object(); + obj.set_string_member("type", obj.get_string_member("type-name")); + obj.remove_member("type-name"); + + return Json.to_string(node, false); + } + + private int bytes_to_int(Bytes bytes) { + uint8[] data = (uint8[]) bytes.get_data(); + int value = 0; + + for (int i = 0; i < data.length; i++) { + value = (value << 8) | data[i]; + } + + return value; + } + + /** + * Send this request to greetd. + */ + public async Response send() throws GLib.Error { + var sock = Environment.get_variable("GREETD_SOCK"); + if (sock == null) { + throw new IOError.NOT_FOUND("greetd socket not found"); + } + + var addr = new UnixSocketAddress(sock); + var socket = new SocketClient(); + var conn = socket.connect(addr); + var payload = serialize(); + var ostream = new DataOutputStream(conn.get_output_stream()) { + byte_order = DataStreamByteOrder.HOST_ENDIAN, + }; + + ostream.put_int32(payload.length, null); + ostream.put_string(payload, null); + ostream.close(null); + + var istream = conn.get_input_stream(); + + var response_head = yield istream.read_bytes_async(4, Priority.DEFAULT, null); + var response_length = bytes_to_int(response_head); + var response_body = yield istream.read_bytes_async(response_length, Priority.DEFAULT, null); + + var response = (string)response_body.get_data(); + conn.close(null); + + var parser = new Json.Parser(); + parser.load_from_data(response); + var obj = parser.get_root().get_object(); + var type = obj.get_string_member("type"); + + print(@"$type: $response\n"); + + switch (type) { + case Success.TYPE: return new Success(obj); + case Error.TYPE: return new Error(obj); + case AuthMessage.TYPE: return new AuthMessage(obj); + default: throw new IOError.NOT_FOUND("unknown response type"); + } + } +} + +/** + * Creates a session and initiates a login attempted for the given user. + * The session is ready to be started if a success is returned. + */ +public class CreateSession : Request { + protected override string type_name { get { return "create_session"; } } + public string username { get; set; } + + public CreateSession(string username) { + Object(username: username); + } +} + +/** + * Answers an authentication message. + * If the message was informative (info, error), + * then a response does not need to be set in this message. + * The session is ready to be started if a success is returned. + */ +public class PostAuthMesssage : Request { + protected override string type_name { get { return "post_auth_message_response"; } } + public string response { get; set; } + + public PostAuthMesssage(string response) { + Object(response: response); + } +} + +/** + * Requests for the session to be started using the provided command line, + * adding the supplied environment to that created by PAM. + * The session will start after the greeter process terminates + */ +public class StartSession : Request { + protected override string type_name { get { return "start_session"; } } + public string[] cmd { get; set; } + public string[] env { get; set; } + + public StartSession(string[] cmd, string[] env = {}) { + Object(cmd: cmd, env: env); + } +} + +/** + * Cancels the session that is currently under configuration. + */ +public class CancelSession : Request { + internal override string type_name { get { return "cancel_session"; } } +} + +/** + * Base Response type. + */ +public abstract class Response : Object { + // nothing to do +} + +/** + * Indicates that the request succeeded. + */ +public class Success : Response { + internal const string TYPE = "success"; + + internal Success(Json.Object obj) { + // nothing to do + } +} + +/** + * Indicates that the request succeeded. + */ +public class Error : Response { + internal const string TYPE = "error"; + + public enum Type { + /** + * Indicates that authentication failed. + * This is not a fatal error, and is likely caused by incorrect credentials. + */ + AUTH_ERROR, + /** + * A general error. + * See the error description for more information. + */ + ERROR; + + internal static Type from_string(string str) throws IOError { + switch (str) { + case "auth_error": return Type.AUTH_ERROR; + case "error": return Type.ERROR; + default: throw new IOError.FAILED(@"unknown error_type: $str"); + } + } + } + + public Type error_type { get; private set; } + public string description { get; private set; } + + internal Error(Json.Object obj) throws IOError { + error_type = Type.from_string(obj.get_string_member("error_type")); + description = obj.get_string_member("description"); + } +} + +/** + * Indicates that the request succeeded. + */ +public class AuthMessage : Response { + internal const string TYPE = "auth_message"; + + public enum Type { + /** + * Indicates that input from the user should be + * visible when they answer this question. + */ + VISIBLE, + /** + * Indicates that input from the user should be + * considered secret when they answer this question. + */ + SECRET, + /** + * Indicates that this message is informative, not a question. + */ + INFO, + /** + * Indicates that this message is an error, not a question. + */ + ERROR; + + internal static Type from_string(string str) throws IOError { + switch (str) { + case "visible": return VISIBLE; + case "secret": return Type.SECRET; + case "info": return Type.INFO; + case "error": return Type.ERROR; + default: throw new IOError.FAILED(@"unknown message_type: $str"); + } + } + } + + public Type message_type { get; private set; } + public string message { get; private set; } + + internal AuthMessage(Json.Object obj) throws IOError { + message_type = Type.from_string(obj.get_string_member("auth_message_type")); + message = obj.get_string_member("auth_message"); + } +} +} diff --git a/lib/greet/config.vala.in b/lib/greet/config.vala.in new file mode 100644 index 00000000..333d735a --- /dev/null +++ b/lib/greet/config.vala.in @@ -0,0 +1,6 @@ +namespace AstalGreet { + public const int MAJOR_VERSION = @MAJOR_VERSION@; + public const int MINOR_VERSION = @MINOR_VERSION@; + public const int MICRO_VERSION = @MICRO_VERSION@; + public const string VERSION = "@VERSION@"; +} diff --git a/lib/greet/gir.py b/lib/greet/gir.py new file mode 120000 index 00000000..b5b4f1d0 --- /dev/null +++ b/lib/greet/gir.py @@ -0,0 +1 @@ +../gir.py \ No newline at end of file diff --git a/lib/greet/meson.build b/lib/greet/meson.build new file mode 100644 index 00000000..11321b01 --- /dev/null +++ b/lib/greet/meson.build @@ -0,0 +1,109 @@ +project( + 'astal-greet', + 'vala', + 'c', + version: run_command('cat', join_paths(meson.project_source_root(), 'version')).stdout().strip(), + meson_version: '>= 0.62.0', + default_options: [ + 'warning_level=2', + 'werror=false', + 'c_std=gnu11', + ], +) + +assert( + get_option('lib') or get_option('cli'), + 'Either lib or cli option must be set to true.', +) + +version_split = meson.project_version().split('.') +api_version = version_split[0] + '.' + version_split[1] +gir = 'AstalGreet-' + api_version + '.gir' +typelib = 'AstalGreet-' + api_version + '.typelib' + +config = configure_file( + input: 'config.vala.in', + output: 'config.vala', + configuration: { + 'VERSION': meson.project_version(), + 'MAJOR_VERSION': version_split[0], + 'MINOR_VERSION': version_split[1], + 'MICRO_VERSION': version_split[2], + }, +) + +deps = [ + dependency('glib-2.0'), + dependency('gobject-2.0'), + dependency('gio-unix-2.0'), + dependency('json-glib-1.0'), +] + +sources = [config] + files( + 'client.vala', +) + +if get_option('lib') + lib = library( + meson.project_name(), + sources, + dependencies: deps, + vala_args: ['--vapi-comments'], + vala_header: meson.project_name() + '.h', + vala_vapi: meson.project_name() + '-' + api_version + '.vapi', + version: meson.project_version(), + install: true, + install_dir: [true, true, true], + ) + + pkgs = [] + foreach dep : deps + pkgs += ['--pkg=' + dep.name()] + endforeach + + gir_tgt = custom_target( + gir, + command: [find_program('python3'), files('gir.py'), meson.project_name(), gir] + + pkgs + + sources, + input: sources, + depends: lib, + output: gir, + install: true, + install_dir: get_option('datadir') / 'gir-1.0', + ) + + custom_target( + typelib, + command: [ + find_program('g-ir-compiler'), + '--output', '@OUTPUT@', + '--shared-library', get_option('prefix') / get_option('libdir') / '@PLAINNAME@', + meson.current_build_dir() / gir, + ], + input: lib, + output: typelib, + depends: [lib, gir_tgt], + install: true, + install_dir: get_option('libdir') / 'girepository-1.0', + ) + + import('pkgconfig').generate( + lib, + name: meson.project_name(), + filebase: meson.project_name() + '-' + api_version, + version: meson.project_version(), + subdirs: meson.project_name(), + requires: deps, + install_dir: get_option('libdir') / 'pkgconfig', + ) +endif + +if get_option('cli') + executable( + meson.project_name(), + ['cli.vala', sources], + dependencies: deps, + install: true, + ) +endif diff --git a/lib/greet/meson_options.txt b/lib/greet/meson_options.txt new file mode 100644 index 00000000..f1102425 --- /dev/null +++ b/lib/greet/meson_options.txt @@ -0,0 +1,11 @@ +option( + 'lib', + type: 'boolean', + value: true, +) + +option( + 'cli', + type: 'boolean', + value: true, +) diff --git a/lib/greet/version b/lib/greet/version new file mode 100644 index 00000000..6e8bf73a --- /dev/null +++ b/lib/greet/version @@ -0,0 +1 @@ +0.1.0