This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

AGENTS.md: mention PRs from git.lassul.us forks #1

Test PR from the hermes account (fork on knot.lassul.us, no merge rights). Fine to close instead of merging.

Labels

None yet.

Participants 1
AT URI
at://did:plc:vhearieesyugq7ortfgncciv/sh.tangled.repo.pull/3mycakhkdy222
+389 -3653
Interdiff #1 → #2
+12 -12
flake.lock
··· 60 60 ] 61 61 }, 62 62 "locked": { 63 + "lastModified": 1792238066, 64 + "narHash": "sha256-eoBatHVF4jH7WNxR4Am7gGMn4e4V1M90O9iQFR5Vbrc=", 63 - "lastModified": 1792277475, 64 - "narHash": "sha256-EuurKVM/FdWMGxaoNAYstztDu5Dcq9FEbDOyjCvARJg=", 65 65 "owner": "lassulus", 66 66 "repo": "byoidc", 67 + "rev": "ccc087d194318290f9b749b3182c976285e4d758", 67 - "rev": "7954466b9c30aea79481c918f9c935eb76bea586", 68 68 "type": "github" 69 69 }, 70 70 "original": { ··· 313 313 "uv2nix": "uv2nix" 314 314 }, 315 315 "locked": { 316 + "lastModified": 1792283675, 317 + "narHash": "sha256-Ih3jyJfmFqBiYEjsxsNi/c5y6dXruzW8mJ8LijCqh50=", 316 - "lastModified": 1792379206, 317 - "narHash": "sha256-r/eEmVoDD2PoFQwWqOOYDS+5xIb8PxJ6vFauGLkXXco=", 318 318 "owner": "nousresearch", 319 319 "repo": "hermes-agent", 320 + "rev": "ccc695635094d7615373db267b77f894d978b1a0", 320 - "rev": "0b7d3df7b4b7c809a027d2a92a22bfd8627ddff6", 321 321 "type": "github" 322 322 }, 323 323 "original": { ··· 411 411 "treefmt-nix": "treefmt-nix_3" 412 412 }, 413 413 "locked": { 414 + "lastModified": 1792285027, 415 + "narHash": "sha256-ZZpV6tEZ22XiBDxUIbpBuWTx6vRKy2X41pGJfzd0HtU=", 414 - "lastModified": 1792365592, 415 - "narHash": "sha256-3hqNK8+5FllvUfqw8+8YnzAgQZqCmwLQIHrG+jNLMRk=", 416 416 "owner": "numtide", 417 417 "repo": "llm-agents.nix", 418 + "rev": "0ea5795ad05403dfb9e3fda8bfa01248e4ab81e7", 418 - "rev": "5a3be28f941d6cf6fc1538bd0a6136993c9e542e", 419 419 "type": "github" 420 420 }, 421 421 "original": { ··· 475 475 ] 476 476 }, 477 477 "locked": { 478 + "lastModified": 1791711434, 479 + "narHash": "sha256-LpIhxnuO0tYeZ4k7BbNMxsPs2zJpB4hWXO1jdhUuE7Q=", 478 - "lastModified": 1792309949, 479 - "narHash": "sha256-2DAEMk8YkV8OF6N2uw9jQ016cYoNkcgnq50AShwWxNw=", 480 480 "owner": "nix-community", 481 481 "repo": "nix-index-database", 482 + "rev": "9a2dd40780eeab8543aa97c59c26f11267025616", 482 - "rev": "1b4d1758fa6053c5d6284254a793134fa602fbff", 483 483 "type": "github" 484 484 }, 485 485 "original": {
+5 -13
configs/hermes-proxy.nix
··· 88 88 89 89 # Public door to hermes-voice on coaxmetal (machines/coaxmetal/hermes-voice.nix): 90 90 # the voice page for the phone. Everything but the files Chrome fetches 91 + # without cookies to install the page and verify the Android app needs a 92 + # login on id.lassul.us (oauth2-proxy below, via auth_request); logged-in 93 + # requests get hermes-voice's access token added. /delegate streams NDJSON 94 + # for the length of a Hermes turn, so the streaming settings apply. 91 - # without cookies to install the page needs a login on id.lassul.us 92 - # (oauth2-proxy below, via auth_request); logged-in requests get 93 - # hermes-voice's access token added. /app/ is the Android app's door: no 94 - # web login, the app's own bearer token goes through to hermes-voice, which 95 - # checks it. /delegate streams NDJSON for the length of a Hermes turn, so 96 - # the streaming settings apply. 97 95 services.nginx.virtualHosts.${assist} = { 98 96 enableACME = true; 99 97 forceSSL = true; ··· 105 103 locations = 106 104 lib.genAttrs 107 105 [ 106 + "= /.well-known/assetlinks.json" 108 107 "= /manifest.webmanifest" 109 108 "= /icon-192.png" 110 109 "= /icon-512.png" ··· 121 120 proxyPass = voiceUpstream; 122 121 extraConfig = streamingProxy + '' 123 122 include ${voiceAuthHeader}; 124 - ''; 125 - }; 126 - "/app/" = { 127 - recommendedProxySettings = true; 128 - proxyPass = "${voiceUpstream}/"; 129 - extraConfig = streamingProxy + '' 130 - auth_request off; 131 123 ''; 132 124 }; 133 125 "/oauth2/" = {
-7
configs/mailserver.nix
··· 99 99 imapMemoryLimit = 1024; 100 100 }; 101 101 102 - lass.backup.state.mailserver.folders = [ 103 - "/var/vmail" 104 - "/var/dkim" 105 - # global sieve dir from before stateVersion 5 106 - "/var/sieve" 107 - ]; 108 - 109 102 # nixos-mailserver mkForces virtualMail without homeMode; match its priority 110 103 users.users.virtualMail = lib.mkOverride 50 { homeMode = "2770"; }; 111 104
-13
configs/n8n.nix
··· 1 1 { 2 - pkgs, 3 2 ... 4 3 }: 5 4 { ··· 11 10 12 11 networking.firewall.interfaces.retiolum.allowedTCPPorts = [ 5678 ]; 13 12 networking.firewall.interfaces.wiregrill.allowedTCPPorts = [ 5678 ]; 14 - 15 - # DynamicUser state (config holds the credentials' encryption key); the 16 - # SQLite database is dumped consistently instead of copied. 17 - lass.backup.state.n8n = { 18 - folders = [ "/var/lib/private/n8n" ]; 19 - exclude = [ "/var/lib/private/n8n/.n8n/database.sqlite*" ]; 20 - preBackup = '' 21 - ${pkgs.sqlite}/bin/sqlite3 -cmd ".timeout 60000" /var/lib/private/n8n/.n8n/database.sqlite ".backup /var/backup/n8n.sqlite" 22 - ''; 23 - # WAL-mode SQLite readers need to write the -shm file 24 - readWritePaths = [ "/var/lib/private/n8n/.n8n" ]; 25 - }; 26 13 }
-2
configs/radicale.nix
··· 116 116 }; 117 117 }; 118 118 119 - lass.backup.state.radicale.folders = [ "/var/lib/radicale" ]; 120 - 121 119 systemd.services.radicale.serviceConfig = { 122 120 RuntimeDirectory = "radicale"; 123 121 LoadCredential = loadCredentials;
-95
configs/roundcube.nix
··· 1 - { config, ... }: 2 - let 3 - domain = "mail.lassul.us"; 4 - zitadelEnv = config.lass.zitadel.projects.mail.apps.roundcube.envFile.path; 5 - # Basic-auth introspection URL with the roundcube client's id and secret, 6 - # read by dovecot at config parse time. 7 - introspectionUrl = "/var/lib/dovecot-oauth2/introspection-url"; 8 - in 9 - { 10 - # Webmail on mail.lassul.us. Login only via Zitadel (id.lassul.us); roundcube 11 - # hands the access token to dovecot (IMAP, ManageSieve) and postfix (SMTP via 12 - # dovecot SASL) with OAUTHBEARER/XOAUTH2, so no mail password is stored. 13 - services.roundcube = { 14 - enable = true; 15 - hostName = domain; 16 - maxAttachmentSize = 25; 17 - plugins = [ 18 - "archive" 19 - "managesieve" 20 - "zipdownload" 21 - ]; 22 - extraConfig = '' 23 - $config['product_name'] = 'lassulus webmail'; 24 - $config['default_list_mode'] = 'threads'; 25 - $config['imap_host'] = 'ssl://${domain}:993'; 26 - $config['smtp_host'] = 'ssl://${domain}:465'; 27 - $config['managesieve_host'] = 'tls://${domain}:4190'; 28 - 29 - $oidc = parse_ini_file('${zitadelEnv}', false, INI_SCANNER_RAW); 30 - $config['oauth_provider'] = 'generic'; 31 - $config['oauth_provider_name'] = 'lassul.us ID'; 32 - $config['oauth_client_id'] = $oidc['CLIENT_ID']; 33 - $config['oauth_client_secret'] = $oidc['CLIENT_SECRET']; 34 - $config['oauth_config_uri'] = 'https://id.lassul.us/.well-known/openid-configuration'; 35 - $config['oauth_scope'] = 'openid email profile offline_access'; 36 - $config['oauth_identity_fields'] = ['email']; 37 - $config['oauth_login_redirect'] = true; 38 - ''; 39 - }; 40 - 41 - services.nginx.virtualHosts.${domain} = { 42 - enableACME = false; 43 - useACMEHost = domain; 44 - }; 45 - 46 - # Only lass may log in; dovecot maps the token's email to the mailbox. 47 - lass.zitadel.projects.mail = { 48 - access = "declarative"; 49 - users = [ "lass@lassul.us" ]; 50 - apps.roundcube = { 51 - redirectUris = [ "https://${domain}/index.php/login/oauth" ]; 52 - # roundcube sends the client secret in the token request body 53 - authMethod = "post"; 54 - refreshTokens = true; 55 - envFile.owner = "roundcube"; 56 - }; 57 - }; 58 - 59 - # Dovecot checks tokens at Zitadel's introspection endpoint as the roundcube 60 - # client. Zitadel reports a token as active only if its audience contains 61 - # this project, so tokens issued to other lassul.us ID apps are refused. 62 - services.dovecot2.settings = { 63 - auth_mechanisms = [ 64 - "oauthbearer" 65 - "xoauth2" 66 - ]; 67 - oauth2_introspection_mode = "post"; 68 - oauth2_introspection_url = "<${introspectionUrl}"; 69 - oauth2_force_introspection = true; 70 - oauth2_active_attribute = "active"; 71 - oauth2_active_value = "true"; 72 - oauth2_username_attribute = "email"; 73 - oauth2.ssl_client_ca_file = "/etc/ssl/certs/ca-certificates.crt"; 74 - }; 75 - 76 - systemd.services.dovecot-oauth2-url = { 77 - wants = [ "zitadel-reconcile.service" ]; 78 - after = [ "zitadel-reconcile.service" ]; 79 - before = [ "dovecot.service" ]; 80 - wantedBy = [ "dovecot.service" ]; 81 - serviceConfig = { 82 - Type = "oneshot"; 83 - RemainAfterExit = true; 84 - StateDirectory = "dovecot-oauth2"; 85 - StateDirectoryMode = "0700"; 86 - UMask = "0077"; 87 - EnvironmentFile = zitadelEnv; 88 - }; 89 - script = '' 90 - printf '%s' "https://$CLIENT_ID:$CLIENT_SECRET@id.lassul.us/oauth/v2/introspect" \ 91 - > ${introspectionUrl}.tmp 92 - mv ${introspectionUrl}.tmp ${introspectionUrl} 93 - ''; 94 - }; 95 - }
+68
configs/snappymail.nix
··· 1 + { config, pkgs, ... }: 2 + let 3 + snappymail = pkgs.snappymail; 4 + dataDir = "/var/lib/snappymail"; 5 + pool = "snappymail"; 6 + fpmSocket = config.services.phpfpm.pools.${pool}.socket; 7 + in 8 + { 9 + services.phpfpm.pools.${pool} = { 10 + user = "snappymail"; 11 + group = "snappymail"; 12 + settings = { 13 + "listen.owner" = "nginx"; 14 + "listen.group" = "nginx"; 15 + "pm" = "dynamic"; 16 + "pm.max_children" = 4; 17 + "pm.start_servers" = 1; 18 + "pm.min_spare_servers" = 1; 19 + "pm.max_spare_servers" = 2; 20 + }; 21 + phpOptions = '' 22 + upload_max_filesize = 25M 23 + post_max_size = 25M 24 + default_socket_timeout = 30 25 + max_execution_time = 60 26 + ''; 27 + phpEnv = { 28 + SNAPPYMAIL_DATA_PATH = dataDir; 29 + }; 30 + }; 31 + 32 + users.users.snappymail = { 33 + isSystemUser = true; 34 + group = "snappymail"; 35 + home = dataDir; 36 + createHome = true; 37 + }; 38 + users.groups.snappymail = { }; 39 + 40 + systemd.tmpfiles.rules = [ 41 + "d ${dataDir} 0750 snappymail snappymail -" 42 + ]; 43 + 44 + services.nginx.virtualHosts."mail.lassul.us" = { 45 + forceSSL = true; 46 + useACMEHost = "mail.lassul.us"; 47 + root = "${snappymail}"; 48 + locations."/" = { 49 + index = "index.php"; 50 + tryFiles = "$uri $uri/ /index.php$is_args$args"; 51 + }; 52 + locations."~ \\.php$" = { 53 + extraConfig = '' 54 + fastcgi_pass unix:${fpmSocket}; 55 + include ${pkgs.nginx}/conf/fastcgi.conf; 56 + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; 57 + fastcgi_param SNAPPYMAIL_DATA_PATH ${dataDir}; 58 + fastcgi_read_timeout 60s; 59 + ''; 60 + }; 61 + locations."~ /\\." = { 62 + extraConfig = "deny all;"; 63 + }; 64 + locations."^~ /data" = { 65 + extraConfig = "deny all;"; 66 + }; 67 + }; 68 + }
configs/ssh-redirect.nix

This file has not been changed.

-5
configs/timeful.nix
··· 21 21 package = pkgs.mongodb-ce; 22 22 }; 23 23 24 - lass.backup.state.timeful.preBackup = '' 25 - ${pkgs.mongodb-tools}/bin/mongodump --uri=mongodb://127.0.0.1:27017 \ 26 - --db=schej-it --gzip --archive=/var/backup/timeful.archive.gz 27 - ''; 28 - 29 24 clan.core.vars.generators.timeful = { 30 25 files.env = { }; 31 26 runtimeInputs = [ pkgs.openssl ];
-3
configs/vaultwarden.nix
··· 30 30 }; 31 31 }; 32 32 33 - # StateDirectory before stateVersion 24.11 34 - lass.backup.state.vaultwarden.folders = [ "/var/lib/bitwarden_rs" ]; 35 - 36 33 services.nginx.virtualHosts."vault.lassul.us" = { 37 34 enableACME = true; 38 35 forceSSL = true;
-24
configs/zitadel.nix
··· 3 3 port = 1411; 4 4 in 5 5 { 6 - self, 7 6 config, 8 7 lib, 9 8 pkgs, ··· 82 81 83 82 services.zitadel = { 84 83 enable = true; 85 - package = self.packages.${pkgs.stdenv.hostPlatform.system}.zitadel; 86 84 masterKeyFile = vars."masterkey".path; 87 85 settings = { 88 - # daily usage report to zitadel.com, on by default 89 - ServicePing.Enabled = false; 90 - # a new instance would otherwise send logins to the separate Login V2 app, which isn't deployed 91 - DefaultInstance.Features.LoginV2.Required = false; 92 86 Port = port; 93 87 ExternalDomain = domain; 94 88 ExternalPort = 443; ··· 124 118 }; 125 119 }; 126 120 }; 127 - 128 - # zitadel-invite (tools/zitadel-invite) as root, with the token of 129 - # zitadel-reconcile 130 - users.users.root.packages = [ 131 - (pkgs.writeShellScriptBin "zitadel-invite" '' 132 - export ZITADEL_URL=''${ZITADEL_URL:-https://${domain}} 133 - export ZITADEL_TOKEN_FILE=''${ZITADEL_TOKEN_FILE:-${cfg.steps.FirstInstance.PatPath}} 134 - exec ${lib.getExe self.packages.${pkgs.stdenv.hostPlatform.system}.zitadel-invite} "$@" 135 - '') 136 - ]; 137 121 138 122 lass.zitadel.loginPolicy = { 139 123 allowRegister = true; ··· 218 202 } 219 203 ]; 220 204 }; 221 - 222 - # users, passkeys, IdP links, OIDC apps; its secrets are encrypted with the 223 - # masterkey from clan vars 224 - lass.backup.state.zitadel.preBackup = '' 225 - mkdir -p /var/backup/postgresql 226 - ${pkgs.sudo}/bin/sudo -u postgres ${config.services.postgresql.package}/bin/pg_dump \ 227 - zitadel | ${pkgs.gzip}/bin/gzip > /var/backup/postgresql/zitadel.sql.gz 228 - ''; 229 205 230 206 # Zitadel serves the console, OIDC and its gRPC APIs on one port over h2c. 231 207 services.nginx.virtualHosts.${domain} = {
-173
modules/backup.nix
··· 1 - { 2 - config, 3 - lib, 4 - pkgs, 5 - ... 6 - }: 7 - let 8 - cfg = config.lass.backup; 9 - states = lib.attrValues cfg.state; 10 - key = config.clan.core.vars.generators.borgbackup.files."borgbackup.ssh.id25519".path; 11 - passphrase = config.clan.core.vars.generators.borgbackup-repokey.files.passphrase.path; 12 - 13 - # Runs every state's hook in its own script, keeps going when one fails 14 - # and fails at the end if any did. 15 - runHooks = 16 - hook: 17 - pkgs.writeShellScript "backup-${hook}" '' 18 - failed=() 19 - ${lib.concatMapStrings (state: '' 20 - echo "backup: ${hook} ${state.name}" 21 - ${pkgs.writeShellScript "backup-${hook}-${state.name}" '' 22 - set -xeo pipefail 23 - ${state.${hook}} 24 - ''} || failed+=(${lib.escapeShellArg state.name}) 25 - '') (lib.filter (state: state.${hook} != null) states)} 26 - if [ ''${#failed[@]} -gt 0 ]; then 27 - echo "backup: ${hook} failed for: ''${failed[*]}" >&2 28 - exit 1 29 - fi 30 - ''; 31 - in 32 - { 33 - options.lass.backup = { 34 - user = lib.mkOption { 35 - type = lib.types.nullOr lib.types.str; 36 - default = null; 37 - example = "u550643-sub1"; 38 - description = '' 39 - Storage box (sub-)account the machine backs up to, into ./borg in its 40 - home. Without it no backup job is generated, so modules can declare 41 - their state everywhere. 42 - ''; 43 - }; 44 - 45 - host = lib.mkOption { 46 - type = lib.types.str; 47 - default = "u550643.your-storagebox.de"; 48 - }; 49 - 50 - sshPort = lib.mkOption { 51 - type = lib.types.port; 52 - default = 23; 53 - }; 54 - 55 - knownHosts = lib.mkOption { 56 - type = lib.types.lines; 57 - default = '' 58 - [u550643.your-storagebox.de]:23 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIICf9svRenC/PLKIL9nk6K/pxQgoiFC41wTNvoIncOxs 59 - ''; 60 - description = "known_hosts lines pinning the host key of the repo's server."; 61 - }; 62 - 63 - state = lib.mkOption { 64 - default = { }; 65 - description = '' 66 - Backup needs of a service, declared next to it. Dumps go to 67 - /var/backup, which is always backed up. 68 - ''; 69 - type = lib.types.attrsOf ( 70 - lib.types.submodule ( 71 - { name, ... }: 72 - { 73 - options = { 74 - name = lib.mkOption { 75 - type = lib.types.str; 76 - default = name; 77 - }; 78 - folders = lib.mkOption { 79 - type = lib.types.listOf lib.types.str; 80 - default = [ ]; 81 - }; 82 - exclude = lib.mkOption { 83 - type = lib.types.listOf lib.types.str; 84 - default = [ ]; 85 - description = "borg exclude patterns."; 86 - }; 87 - preBackup = lib.mkOption { 88 - type = lib.types.nullOr lib.types.lines; 89 - default = null; 90 - description = '' 91 - Runs before the backup inside the read-only borg unit (as 92 - root), e.g. to dump a database to /var/backup. If it fails, 93 - no backup is made and the job fails. 94 - ''; 95 - }; 96 - postBackup = lib.mkOption { 97 - type = lib.types.nullOr lib.types.lines; 98 - default = null; 99 - description = '' 100 - Runs as root after the backup, also when it failed, e.g. to 101 - start a service stopped by preBackup. 102 - ''; 103 - }; 104 - readWritePaths = lib.mkOption { 105 - type = lib.types.listOf lib.types.str; 106 - default = [ ]; 107 - description = "Paths preBackup needs to write to."; 108 - }; 109 - }; 110 - } 111 - ) 112 - ); 113 - }; 114 - }; 115 - 116 - config = lib.mkIf (cfg.user != null) { 117 - clan.core.vars.generators.borgbackup = { 118 - files."borgbackup.ssh.id25519" = { }; 119 - files."borgbackup.ssh.id25519.pub".secret = false; 120 - runtimeInputs = with pkgs; [ 121 - coreutils 122 - openssh 123 - ]; 124 - script = '' 125 - ssh-keygen -t ed25519 -N "" -f $out/borgbackup.ssh.id25519 126 - ''; 127 - }; 128 - 129 - clan.core.vars.generators.borgbackup-repokey = { 130 - files.passphrase = { }; 131 - runtimeInputs = [ pkgs.openssl ]; 132 - script = '' 133 - openssl rand -hex 32 > "$out"/passphrase 134 - ''; 135 - }; 136 - 137 - systemd.tmpfiles.rules = [ "d /var/backup 0700 root root -" ]; 138 - 139 - # Archives are named and pruned by <hostname>-<job>. 140 - services.borgbackup.jobs.hetzner = { 141 - repo = "${cfg.user}@${cfg.host}:./borg"; 142 - paths = lib.unique ([ "/var/backup" ] ++ lib.concatMap (state: state.folders) states); 143 - exclude = lib.unique ([ "*.pyc" ] ++ lib.concatMap (state: state.exclude) states); 144 - readWritePaths = lib.unique ( 145 - [ "/var/backup" ] ++ lib.concatMap (state: state.readWritePaths) states 146 - ); 147 - preHook = "${runHooks "preBackup"}"; 148 - encryption = { 149 - mode = "repokey-blake2"; 150 - passCommand = "cat ${passphrase}"; 151 - }; 152 - compression = "auto,zstd"; 153 - startAt = "*-*-* 03:00:00"; 154 - environment.BORG_RSH = "ssh -oPort=${toString cfg.sshPort} -oUserKnownHostsFile=${pkgs.writeText "backup-known-hosts" cfg.knownHosts} -i ${key}"; 155 - prune.keep = { 156 - within = "1d"; 157 - daily = 7; 158 - weekly = 4; 159 - monthly = 0; 160 - }; 161 - }; 162 - 163 - # spreads the machines sharing the storage box over 03:00-04:00 164 - systemd.timers.borgbackup-job-hetzner.timerConfig = { 165 - RandomizedDelaySec = "1h"; 166 - FixedRandomDelay = true; 167 - }; 168 - 169 - systemd.services.borgbackup-job-hetzner.serviceConfig.ExecStopPost = lib.mkIf (lib.any ( 170 - state: state.postBackup != null 171 - ) states) [ "+${runHooks "postBackup"}" ]; 172 - }; 173 - }
-1
modules/default.nix
··· 1 1 _: { 2 2 imports = [ 3 - ./backup.nix 4 3 ./screenlock.nix 5 4 ./fetchWallpaper.nix 6 5 ./sync-containers3.nix
-15
modules/parley.nix
··· 137 137 PrivateDevices = true; 138 138 }; 139 139 }; 140 - 141 - # identity.key also lives in clan vars; the SQLite databases are dumped 142 - # consistently instead of copied. 143 - lass.backup.state.parley = { 144 - folders = [ "/var/lib/parley" ]; 145 - exclude = [ "/var/lib/parley/*.db*" ]; 146 - preBackup = '' 147 - mkdir -p /var/backup/parley 148 - for db in parley history; do 149 - ${pkgs.sqlite}/bin/sqlite3 -cmd ".timeout 60000" "/var/lib/parley/$db.db" ".backup /var/backup/parley/$db.db" 150 - done 151 - ''; 152 - # WAL-mode SQLite readers need to write the -shm file 153 - readWritePaths = [ "/var/lib/parley" ]; 154 - }; 155 140 }; 156 141 }
+1 -1
.github/workflows/assist-android.yml
··· 1 1 name: assist-android 2 2 3 + # Builds the Android wrapper of the voice page (packages.hermes-voice-android), 3 - # Builds the Android app of hermes-voice (packages.hermes-voice-android), 4 4 # signs it and publishes it as assist.apk of a new release 5 5 # assist-android-v<run number>, so Obtainium sees every build. 6 6 # Stable URL: https://github.com/lassulus/superconfig/releases/latest/download/assist.apk
configs/dns/knot.nix

This file has not been changed.

configs/dns/lassul.us.zone

This file has not been changed.

-4
configs/hass/default.nix
··· 3 3 imports = [ 4 4 ./zigbee.nix 5 5 ./monitoring.nix 6 - ./oidc.nix 7 6 ]; 8 7 9 8 # Runs on the gg23 router: the LAN (int0) is a trusted interface there ··· 52 51 }; 53 52 54 53 environment.systemPackages = [ pkgs.mosquitto ]; 55 - 56 - # retained messages; mosquitto.db is replaced atomically on every save 57 - lass.backup.state.mosquitto.folders = [ config.services.mosquitto.dataDir ]; 58 54 }
-37
configs/hass/oidc.nix
··· 1 - # Login via id.lassul.us (Zitadel project hass, configs/hass/proxy.nix) with 2 - # the auth_oidc custom component, the only login (auth_providers below). 3 - { config, pkgs, ... }: 4 - { 5 - # Loaded from /config/packages like configs/hass/monitoring.nix. 6 - # automatic_user_linking: the first login reuses the HA user whose username 7 - # equals the preferred_username claim; only users granted the project can 8 - # log in at all. 9 - virtualisation.oci-containers.containers.homeassistant = { 10 - volumes = [ 11 - "${pkgs.home-assistant-custom-components.auth_oidc}/custom_components/auth_oidc:/config/custom_components/auth_oidc:ro" 12 - "${pkgs.writeText "auth_oidc.yaml" '' 13 - auth_oidc: 14 - client_id: !env_var HASS_OIDC_CLIENT_ID 15 - discovery_url: https://id.lassul.us/.well-known/openid-configuration 16 - display_name: lassul.us ID 17 - features: 18 - automatic_user_linking: true 19 - include_groups_scope: false 20 - ''}:/config/packages/auth_oidc.yaml:ro" 21 - # No HA auth provider, so no username/password login. Packages can't set 22 - # it, configuration.yaml in the volume has once under homeassistant: 23 - # auth_providers: !include auth_providers.yaml 24 - # Long-lived access tokens are not tied to a provider and keep working. 25 - "${pkgs.writeText "auth_providers.yaml" (builtins.toJSON [ ])}:/config/auth_providers.yaml:ro" 26 - ]; 27 - environmentFiles = [ config.clan.core.vars.generators.hass-oidc.files.env.path ]; 28 - }; 29 - 30 - clan.core.vars.generators.hass-oidc = { 31 - files.env = { }; 32 - prompts.client-id.description = "Client id of the Zitadel app hass/web (CLIENT_ID in /var/lib/zitadel-apps/hass-web.env on neoprism)"; 33 - script = '' 34 - printf 'HASS_OIDC_CLIENT_ID=%s\n' "$(cat "$prompts"/client-id)" > "$out/env" 35 - ''; 36 - }; 37 - }
-12
configs/hass/proxy.nix
··· 8 8 proxyPass = "http://acheron.r:8123"; 9 9 }; 10 10 }; 11 - 12 - # Login via id.lassul.us (auth_oidc, configs/hass/oidc.nix), only for users 13 - # an admin gave a user grant on the project in the console. A public client 14 - # with PKCE; its client id is in the envFile and goes into the hass-oidc var 15 - # of acheron. 16 - lass.zitadel.projects.hass = { 17 - access = "managed"; 18 - apps.web = { 19 - redirectUris = [ "https://hass.lassul.us/auth/oidc/callback" ]; 20 - authMethod = "none"; 21 - }; 22 - }; 23 11 }
-7
configs/hass/zigbee.nix
··· 89 89 }; 90 90 }; 91 91 }; 92 - 93 - # devices, groups and the coordinator backup; database.db is JSON, not 94 - # SQLite, so a plain copy is fine 95 - lass.backup.state.zigbee2mqtt = { 96 - folders = [ "/var/lib/zigbee2mqtt" ]; 97 - exclude = [ "/var/lib/zigbee2mqtt/log" ]; 98 - }; 99 92 }
+2 -9
configs/monitoring/alerts.nix
··· 50 50 (alert "MemoryPressure" "rate(host_pressure_memory_some_seconds_total[10m]) > 0.1" "5m" 51 51 "{{ $labels.host }}: tasks stalled on memory {{ $value | humanizePercentage }} of the time" 52 52 ) 53 + (alert "OOMKill" "increase(host_memory_oom_kill_events_total[10m]) > 0" "0m" 53 - # Without a sample just before its window (a gap in VictoriaMetrics, 54 - # a new series) increase() returns the whole counter, so OOMKill and 55 - # NvmeMediaErrors require one via present_over_time. 56 - (alert "OOMKill" 57 - "increase(host_memory_oom_kill_events_total[10m]) > 0 and present_over_time(host_memory_oom_kill_events_total[1m] offset 10m)" 58 - "0m" 59 54 "{{ $labels.host }}: {{ $value }} processes OOM-killed in the last 10 minutes" 60 55 ) 61 56 # NVMe drives set critical_warning bit 0x04 (and so fail smart_status) ··· 69 64 (alert "NvmeCriticalWarning" "bitmap_and(smartctl_device_critical_warning, 251) != 0" "5m" 70 65 "{{ $labels.host }}: {{ $labels.device }} reports critical warning {{ $value }}" 71 66 ) 67 + (alert "NvmeMediaErrors" "increase(smartctl_device_media_errors[1h]) > 0" "0m" 72 - (alert "NvmeMediaErrors" 73 - "increase(smartctl_device_media_errors[1h]) > 0 and present_over_time(smartctl_device_media_errors[2m] offset 1h)" 74 - "0m" 75 68 "{{ $labels.host }}: {{ $labels.device }} had {{ $value }} new media errors in the last hour" 76 69 ) 77 70 (alert "NvmeSpareLow" "smartctl_device_available_spare < smartctl_device_available_spare_threshold"
-2
configs/paste/default.nix
··· 50 50 ''; 51 51 in 52 52 { 53 - # back up the state dirs of all items 54 - lass.backup.state.paste.folders = map dirOf (lib.attrValues items); 55 53 56 54 services.nginx.virtualHosts.cyberlocker = { 57 55 serverAliases = [ "c.r" ];
-1
machines/acheron/backup.nix
··· 1 - { lass.backup.user = "u550643-sub4"; }
-1
machines/acheron/config.nix
··· 7 7 ../../configs/hass 8 8 ../../configs/gg23/audio/hub.nix 9 9 ../../configs/gg23/internet.nix 10 - ./backup.nix 11 10 ]; 12 11 13 12 services.earlyoom.enable = true;
-1
machines/coaxmetal/backup.nix
··· 1 - { lass.backup.user = "u550643-sub2"; }
-1
machines/coaxmetal/config.nix
··· 23 23 ./strom.nix 24 24 ./hermes.nix 25 25 ./hermes-voice.nix 26 - ./backup.nix 27 26 ]; 28 27 29 28 system.stateVersion = "24.05";
+5 -25
machines/coaxmetal/hermes-voice.nix
··· 20 20 # 21 21 # SECURITY: the page drives Hermes, which has terminal tools and code 22 22 # execution as lass (machines/coaxmetal/hermes.nix). The access token 23 + # (configs/hermes-voice-vars.nix) is the gate on every API route; treat it 24 + # like the hermes-api key. 23 - # (configs/hermes-voice-vars.nix) and the Android app's token 24 - # (hermes-voice-app below) are the gates on every API route; treat both 25 - # like the hermes-api key. The app takes calls from the headset button with 26 - # the phone locked, approvals by voice included. 27 25 systemd.services.hermes-voice = { 28 26 description = "hermes-voice: live voice conversations with Hermes"; 29 27 wantedBy = [ "multi-user.target" ]; ··· 47 45 "%d/openai-key" 48 46 "--access-token-file" 49 47 "%d/access-token" 50 - "--app-token-file" 51 - "%d/app-token" 52 48 "--matrix-webhook" 53 49 "http://127.0.0.1:${toString webhookPort}/webhooks/voice-approval" 54 50 ]; ··· 56 52 "hermes-key:${config.clan.core.vars.generators.hermes-api.files."api_key".path}" 57 53 "openai-key:${config.clan.core.vars.generators.hermes-voice-openai.files."api-key".path}" 58 54 "access-token:${config.clan.core.vars.generators.hermes-voice.files."access-token".path}" 59 - "app-token:${config.clan.core.vars.generators.hermes-voice-app.files.token.path}" 60 55 ]; 61 56 DynamicUser = true; 62 57 Restart = "on-failure"; ··· 112 107 persist = true; 113 108 }; 114 109 110 + # Signing key of the Android wrapper (packages.hermes-voice-android); only 111 + # read when signing, so nothing is deployed. The fingerprint goes into the 112 + # page's assetlinks.json (tools/hermes-voice/flake-module.nix). 115 - # Bearer token of the Android app (tools/hermes-voice/android), checked by 116 - # hermes-voice next to the page's access token; the proxy passes it through 117 - # on assist.lassul.us/app/ without the web login (configs/hermes-proxy.nix). 118 - # Paste it into the app once: 119 - # clan vars get coaxmetal hermes-voice-app/token 120 - clan.core.vars.generators.hermes-voice-app = { 121 - files.token = { }; 122 - runtimeInputs = [ 123 - pkgs.coreutils 124 - pkgs.openssl 125 - ]; 126 - script = '' 127 - openssl rand -hex 32 | tr -d '\n' > "$out/token" 128 - ''; 129 - }; 130 - 131 - # Signing key of the Android app (packages.hermes-voice-android); only read 132 - # when signing, so nothing is deployed. 133 113 clan.core.vars.generators.hermes-voice-android-signing = { 134 114 files.keystore.deploy = false; 135 115 files.password.deploy = false;
-35
machines/coaxmetal/hermes.nix
··· 639 639 EOF 640 640 ''; 641 641 }; 642 - 643 - # The home without caches, toolchains and git clones; the SQLite databases 644 - # under .hermes are dumped consistently instead of copied. 645 - lass.backup.state.hermes = 646 - let 647 - home = config.services.hermes-agent.stateDir; 648 - in 649 - { 650 - folders = [ home ]; 651 - exclude = [ 652 - "${home}/.cache" 653 - "${home}/.cargo" 654 - "${home}/.gradle" 655 - "${home}/go" 656 - "${home}/.hermes/cache" 657 - "${home}/workspace" 658 - "${home}/.local/share/Trash" 659 - "${home}/*/node_modules" 660 - "${home}/*/target" 661 - "${home}/.hermes/*.db" 662 - "${home}/.hermes/*.db-wal" 663 - "${home}/.hermes/*.db-shm" 664 - ]; 665 - preBackup = '' 666 - rm -rf /var/backup/hermes 667 - cd ${home}/.hermes 668 - ${pkgs.findutils}/bin/find . -path ./cache -prune -o -type f -name '*.db' -printf '%P\n' | 669 - while read -r db; do 670 - mkdir -p "/var/backup/hermes/$(dirname "$db")" 671 - ${pkgs.sqlite}/bin/sqlite3 -cmd ".timeout 60000" "$db" ".backup '/var/backup/hermes/$db'" 672 - done 673 - ''; 674 - # WAL-mode SQLite readers need to write the -shm file 675 - readWritePaths = [ "${home}/.hermes" ]; 676 - }; 677 642 }
+118 -1
machines/neoprism/backup.nix
··· 1 + { config, pkgs, ... }: 2 + { 3 + clan.core.vars.generators.borgbackup = { 4 + files."borgbackup.ssh.id25519" = { }; 5 + files."borgbackup.ssh.id25519.pub".secret = false; 6 + runtimeInputs = with pkgs; [ 7 + coreutils 8 + openssh 9 + ]; 10 + script = '' 11 + ssh-keygen -t ed25519 -N "" -f $out/borgbackup.ssh.id25519 12 + ''; 13 + }; 14 + 15 + # Ensure backup directory exists for pg_dump 16 + systemd.tmpfiles.rules = [ 17 + "d /var/backup 0700 root root -" 18 + "d /var/backup/postgresql 0700 root root -" 19 + "d /var/backup/parley 0700 root root -" 20 + ]; 21 + 22 + services.borgbackup.jobs.hetzner = { 23 + paths = [ 24 + "/var/backup" 25 + "/var/lib/bitwarden_rs" 26 + "/var/lib/htgen-cyberlocker" 27 + "/var/lib/htgen-paste" 28 + "/var/lib/matrix-synapse/media_store/local_content" 29 + "/var/lib/radicale" 30 + "/home/bot" 31 + "/var/vmail" 32 + "/var/dkim" 33 + "/var/sieve" 34 + "/var/lib/heisenbridge" 35 + # identity.key also lives in clan vars; the SQLite databases are 36 + # dumped consistently to /var/backup/parley by the preHook. 37 + "/var/lib/parley" 38 + # WBO boards (paint.lassul.us). DynamicUser + StateDirectory make 39 + # /var/lib/whitebophir only a symlink, so back up the real directory. 40 + "/var/lib/private/whitebophir" 41 + # paperless (paperless.lassul.us) documents, index and classifier; the 42 + # SQLite database is dumped consistently to /var/backup by the preHook. 43 + "/var/lib/paperless" 44 + ]; 45 + exclude = [ 46 + "*.pyc" 47 + "/home/bot/.cache" 48 + "/home/bot/.nix-defexpr" 49 + "/home/bot/.nix-profile" 50 + "/var/lib/parley/*.db*" 51 + "/var/lib/paperless/db.sqlite3*" 52 + ]; 53 + repo = "u550643@u550643.your-storagebox.de:/./neoprism"; 54 + encryption.mode = "none"; 55 + compression = "auto,zstd"; 56 + startAt = "*-*-* 03:00:00"; 57 + environment.BORG_RSH = 58 + let 59 + knownHostsFile = pkgs.writeText "storagebox-known-hosts" '' 60 + [u550643.your-storagebox.de]:23 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIICf9svRenC/PLKIL9nk6K/pxQgoiFC41wTNvoIncOxs 61 + ''; 62 + in 63 + "ssh -oPort=23 -oUserKnownHostsFile=${knownHostsFile} -i ${ 64 + config.clan.core.vars.generators.borgbackup.files."borgbackup.ssh.id25519".path 65 + }"; 66 + 67 + readWritePaths = [ 68 + "/var/backup" 69 + "/var/lib/hedgedoc" 70 + # WAL-mode SQLite readers need to write the -shm file 71 + "/var/lib/parley" 72 + "/var/lib/paperless" 73 + ]; 74 + 75 + preHook = '' 76 + set -x 77 + # Dump matrix-synapse PostgreSQL database (14GB, compresses well) 78 + ${pkgs.sudo}/bin/sudo -u postgres ${config.services.postgresql.package}/bin/pg_dump \ 79 + matrix-synapse | ${pkgs.gzip}/bin/gzip > /var/backup/postgresql/matrix-synapse.sql.gz 80 + 81 + # Dump bridge PostgreSQL databases — portal mappings, sessions, tokens. 82 + # Losing these = relogin everywhere + every existing Matrix room orphaned. 83 + for db in mautrix-whatsapp mautrix-signal mautrix-telegram mautrix-discord; do 84 + ${pkgs.sudo}/bin/sudo -u postgres ${config.services.postgresql.package}/bin/pg_dump \ 85 + "$db" | ${pkgs.gzip}/bin/gzip > "/var/backup/postgresql/$db.sql.gz" 86 + done 87 + 88 + # Dump zitadel (id.lassul.us) PostgreSQL database: users, passkeys, 89 + # IdP links, OIDC apps. Its secrets are encrypted with the masterkey 90 + # from clan vars. 91 + ${pkgs.sudo}/bin/sudo -u postgres ${config.services.postgresql.package}/bin/pg_dump \ 92 + zitadel | ${pkgs.gzip}/bin/gzip > /var/backup/postgresql/zitadel.sql.gz 93 + 94 + # Dump hedgedoc SQLite database safely. It uses a rollback journal and 95 + # writes often during the day, so wait for its locks instead of failing. 96 + ${pkgs.sqlite}/bin/sqlite3 -cmd ".timeout 60000" /var/lib/hedgedoc/db.sqlite ".backup /var/backup/hedgedoc.sqlite" 97 + 98 + # Dump parley accounts + history SQLite databases safely 99 + for db in parley history; do 100 + ${pkgs.sqlite}/bin/sqlite3 -cmd ".timeout 60000" "/var/lib/parley/$db.db" ".backup /var/backup/parley/$db.db" 101 + done 102 + 103 + # Dump paperless SQLite database safely 104 + ${pkgs.sqlite}/bin/sqlite3 -cmd ".timeout 60000" /var/lib/paperless/db.sqlite3 ".backup /var/backup/paperless.sqlite3" 105 + 106 + # Dump timeful polls (when.lassul.us) from MongoDB 107 + ${pkgs.mongodb-tools}/bin/mongodump --uri=mongodb://127.0.0.1:27017 \ 108 + --db=schej-it --gzip --archive=/var/backup/timeful.archive.gz 109 + ''; 110 + 111 + prune.keep = { 112 + within = "1d"; 113 + daily = 7; 114 + weekly = 4; 115 + monthly = 0; 116 + }; 117 + }; 118 + } 1 - { lass.backup.user = "u550643-sub1"; }
+1 -11
machines/neoprism/config.nix
··· 9 9 ../../configs/ssh-redirect.nix 10 10 ../../configs/retiolum.nix 11 11 ../../configs/mailserver.nix 12 - ../../configs/roundcube.nix 12 + ../../configs/snappymail.nix 13 13 ../../configs/gsm-wiki.nix 14 14 ../../configs/pair-programming.nix 15 15 ../../configs/nginx.nix ··· 150 150 ip saddr 95.217.192.52 accept 151 151 ''; 152 152 153 - # not defined by any module 154 - lass.backup.state.bot-home = { 155 - folders = [ "/home/bot" ]; 156 - exclude = [ 157 - "/home/bot/.cache" 158 - "/home/bot/.nix-defexpr" 159 - "/home/bot/.nix-profile" 160 - ]; 161 - }; 162 - 163 153 # tank/radicle (radicle seed storage) is created by disko at install; on an 164 154 # already-provisioned host it must be created before deploying:
-1
machines/styx/backup.nix
··· 1 - { lass.backup.user = "u550643-sub3"; }
-1
machines/styx/config.nix
··· 14 14 ../../configs/ppp/umts-stick.nix 15 15 ../../configs/gg23/audio/room.nix 16 16 ../../configs/monitoring/server.nix 17 - ./backup.nix 18 17 ]; 19 18 20 19 environment.systemPackages = with pkgs; [
-1
machines/yellow/backup.nix
··· 1 - { lass.backup.user = "u550643-sub5"; }
-1
machines/yellow/config.nix
··· 16 16 ../../configs/services/flix 17 17 ../../configs/autoupdate.nix 18 18 ../../configs/services/archiver-bot.nix 19 - ./backup.nix 20 19 ]; 21 20 22 21 services.transmission.settings.peer-port = torrentport;
+2 -20
modules/zitadel/default.nix
··· 23 23 apps = lib.mapAttrs (_: app: { 24 24 inherit (app) redirectUris envFile; 25 25 authMethodType = "OIDC_AUTH_METHOD_TYPE_${lib.toUpper app.authMethod}"; 26 - grantTypes = [ 27 - "OIDC_GRANT_TYPE_AUTHORIZATION_CODE" 28 - ] 29 - ++ lib.optional app.refreshTokens "OIDC_GRANT_TYPE_REFRESH_TOKEN"; 30 26 }) project.apps; 31 27 }) cfg.projects; 32 28 } ··· 183 179 type = lib.types.enum [ 184 180 "basic" 185 181 "post" 186 - "none" 187 182 ]; 188 183 default = "basic"; 184 + description = "How the app sends its client secret to the token endpoint."; 189 - description = '' 190 - How the app sends its client secret to the token 191 - endpoint; "none" for a public client without a 192 - secret, which has to use PKCE. 193 - ''; 194 - }; 195 - refreshTokens = lib.mkOption { 196 - type = lib.types.bool; 197 - default = false; 198 - description = '' 199 - Allow the refresh_token grant. Zitadel hands out 200 - refresh tokens only when the app requests the 201 - offline_access scope. 202 - ''; 203 185 }; 204 186 envFile = { 205 187 path = lib.mkOption { 206 188 type = lib.types.str; 207 189 default = "${appsDir}/${project}-${name}.env"; 190 + description = "Env file with the app's client id and secret."; 208 - description = "Env file with the app's client id and secret (if any)."; 209 191 }; 210 192 owner = lib.mkOption { 211 193 type = lib.types.str;
+8 -10
modules/zitadel/reconcile.py
··· 221 221 oidc_config = { 222 222 "redirectUris": app["redirectUris"], 223 223 "responseTypes": ["OIDC_RESPONSE_TYPE_CODE"], 224 + "grantTypes": ["OIDC_GRANT_TYPE_AUTHORIZATION_CODE"], 224 - "grantTypes": app["grantTypes"], 225 225 "appType": "OIDC_APP_TYPE_WEB", 226 226 "authMethodType": app["authMethodType"], 227 227 } ··· 232 232 new = {"name": app_name, **oidc_config} 233 233 res = call("POST", f"{base}/oidc", new) 234 234 app_id, client_id = res["appId"], res["clientId"] 235 + secret = res["clientSecret"] 235 - secret = res.get("clientSecret") 236 236 print(f"app {project_name}/{app_name}: created") 237 237 else: 238 238 app_id = existing["id"] ··· 246 246 if saved["clientId"] == client_id: 247 247 secret = saved["clientSecret"] 248 248 # Zitadel shows a secret only when it creates it, so without a saved 249 + # copy for this client the app gets a new one. 250 + if secret is None: 249 - # copy for this client the app gets a new one. Public clients have 250 - # none. 251 - public = app["authMethodType"] == "OIDC_AUTH_METHOD_TYPE_NONE" 252 - if secret is None and not public: 253 251 path = f"{base}/{app_id}/oidc_config/_generate_client_secret" 254 252 secret = call("POST", path, {})["clientSecret"] 255 253 print(f"app {project_name}/{app_name}: new client secret") ··· 259 257 })) 260 258 env = app["envFile"] 261 259 uid = pwd.getpwnam(env["owner"]).pw_uid 260 + write_file(env["path"], uid, ( 261 + f"{env['clientIdVariable']}={client_id}\n" 262 + f"{env['clientSecretVariable']}={secret}\n" 263 + )) 262 - content = f"{env['clientIdVariable']}={client_id}\n" 263 - if not public: 264 - content += f"{env['clientSecretVariable']}={secret}\n" 265 - write_file(env["path"], uid, content) 266 264 267 265 if project["access"] != "declarative": 268 266 continue
+2 -2
pkgs/calino/package.nix
··· 14 14 # to allow the origin Calino is served from via CORS. 15 15 stdenv.mkDerivation (finalAttrs: { 16 16 pname = "calino"; 17 + version = "0.39.1"; 17 - version = "0.40.0"; 18 18 19 19 src = fetchFromGitHub { 20 20 owner = "Ivan-Malinovski"; 21 21 repo = "calino"; 22 22 tag = "v${finalAttrs.version}"; 23 + hash = "sha256-WSSLIRnoBXEBYVivy5Tjt446hhgExoxTg6vQYONe+u0="; 23 - hash = "sha256-jmprIOhVKAMm7a6nTE/BCQ2pslfUELMeN0hcAZDi2mU="; 24 24 }; 25 25 26 26 pnpmDeps = fetchPnpmDeps {
pkgs/tangled/appview-knot-members-did-web.patch

This file has not been changed.

pkgs/tangled/assets.json

This file has not been changed.

pkgs/tangled/package.nix

This file has not been changed.

pkgs/tangled/update.sh

This file has not been changed.

pkgs/tranquil-pds/package.nix

This file has not been changed.

-92
pkgs/zitadel/console.nix
··· 1 - { 2 - generateProtobufCode, 3 - jsDeps, 4 - version, 5 - zitadelRepo, 6 - }: 7 - 8 - { 9 - lib, 10 - stdenv, 11 - callPackage, 12 - 13 - grpc-gateway, 14 - nodejs, 15 - pnpmConfigHook, 16 - protoc-gen-grpc-web, 17 - }: 18 - 19 - let 20 - # console/buf.gen.yaml (`buf generate ../proto --include-imports 21 - # --include-wkt` upstream), run against protoImage. It needs protoc-gen-js 22 - # 4.0.2 to match the google-protobuf runtime of console/package.json. 23 - consoleProtobufGenerated = generateProtobufCode { 24 - pname = "zitadel-console"; 25 - inherit version; 26 - nativeBuildInputs = [ 27 - grpc-gateway 28 - protoc-gen-grpc-web 29 - (callPackage ./protoc-gen-js.nix { }) 30 - ]; 31 - workDir = "console"; 32 - bufArgs = "--include-imports --include-wkt"; 33 - outputPath = "src/app/proto/generated"; 34 - }; 35 - in 36 - stdenv.mkDerivation { 37 - pname = "zitadel-console"; 38 - inherit version; 39 - 40 - src = zitadelRepo; 41 - 42 - inherit (jsDeps) pnpmDeps; 43 - pnpmWorkspaces = [ 44 - "@zitadel/client" 45 - "@zitadel/console" 46 - "@zitadel/proto" 47 - ]; 48 - 49 - nativeBuildInputs = [ 50 - nodejs 51 - jsDeps.pnpm 52 - pnpmConfigHook 53 - ]; 54 - 55 - env.NODE_OPTIONS = "--max-old-space-size=8192"; 56 - 57 - preBuild = '' 58 - cp -r ${jsDeps.protoPackageGenerated}/* packages/zitadel-proto/ 59 - mkdir -p console/src/app/proto 60 - cp -r ${consoleProtobufGenerated} console/src/app/proto/generated 61 - chmod -R u+w packages/zitadel-proto console/src/app/proto 62 - ''; 63 - 64 - # The `build` scripts of the client package and the console, in the order 65 - # nx.json's targetDefaults run them. 66 - buildPhase = '' 67 - runHook preBuild 68 - 69 - pushd packages/zitadel-client 70 - pnpm run build 71 - popd 72 - 73 - pushd console 74 - pnpm run build 75 - popd 76 - 77 - runHook postBuild 78 - ''; 79 - 80 - installPhase = '' 81 - runHook preInstall 82 - cp -r console/dist/console "$out" 83 - runHook postInstall 84 - ''; 85 - 86 - meta = { 87 - description = "Management console for the ZITADEL identity and access management platform"; 88 - homepage = "https://zitadel.com/"; 89 - license = lib.licenses.agpl3Only; 90 - platforms = lib.platforms.linux ++ lib.platforms.darwin; 91 - }; 92 - }
-69
pkgs/zitadel/javascript.nix
··· 1 - { 2 - stdenv, 3 - 4 - buf, 5 - fetchPnpmDeps, 6 - nodejs, 7 - pnpm_10, 8 - pnpmConfigHook, 9 - 10 - protoImage, 11 - version, 12 - zitadelRepo, 13 - }: 14 - 15 - let 16 - pnpm = pnpm_10; 17 - 18 - # The workspace packages the console needs out of the repository-wide 19 - # pnpm-lock.yaml. 20 - pnpmDeps = fetchPnpmDeps { 21 - pname = "zitadel"; 22 - inherit version pnpm; 23 - src = zitadelRepo; 24 - fetcherVersion = 4; 25 - pnpmWorkspaces = [ 26 - "@zitadel/client" 27 - "@zitadel/console" 28 - "@zitadel/proto" 29 - ]; 30 - hash = "sha256-8eW45vFB295KGIxdGIu+gpC9YDwZj0V+CTBZjx3i8aU="; 31 - }; 32 - 33 - # packages/zitadel-proto, generated by @bufbuild/protoc-gen-es from its npm 34 - # dev dependencies against protoImage instead of the proto tree. 35 - protoPackageGenerated = stdenv.mkDerivation { 36 - pname = "zitadel-proto-buf-generated"; 37 - inherit version; 38 - 39 - src = zitadelRepo; 40 - 41 - inherit pnpmDeps; 42 - pnpmWorkspaces = [ "@zitadel/proto" ]; 43 - 44 - nativeBuildInputs = [ 45 - buf 46 - nodejs 47 - pnpm 48 - pnpmConfigHook 49 - ]; 50 - 51 - buildPhase = '' 52 - runHook preBuild 53 - cd packages/zitadel-proto 54 - export PATH="$PWD/node_modules/.bin:$PATH" 55 - HOME=$TMPDIR buf generate ${protoImage}#format=binpb 56 - runHook postBuild 57 - ''; 58 - 59 - installPhase = '' 60 - runHook preInstall 61 - mkdir -p $out 62 - cp -r es cjs types $out/ 63 - runHook postInstall 64 - ''; 65 - }; 66 - in 67 - { 68 - inherit pnpm pnpmDeps protoPackageGenerated; 69 - }
-214
pkgs/zitadel/package.nix
··· 1 - # Based on nixpkgs PR 561044 (https://github.com/NixOS/nixpkgs/pull/561044, 2 - # zitadel 4.17.3), without the separate Login V2 app. 3 - { 4 - stdenv, 5 - buildGoModule, 6 - callPackage, 7 - fetchFromGitHub, 8 - lib, 9 - 10 - buf, 11 - cacert, 12 - dart-sass, 13 - grpc-gateway, 14 - protoc-gen-connect-go, 15 - protoc-gen-go, 16 - protoc-gen-go-grpc, 17 - protoc-gen-validate, 18 - statik, 19 - }: 20 - 21 - let 22 - version = "4.19.5"; 23 - zitadelRepo = fetchFromGitHub { 24 - owner = "zitadel"; 25 - repo = "zitadel"; 26 - tag = "v${version}"; 27 - hash = "sha256-FFXsoBlbNTFT1FSxs/IomqTrtNGqsxq9Ck5TSDpSWHM="; 28 - }; 29 - goModulesHash = "sha256-8/TkV1JTKNSIknzEPbTDWzcOZQ6jCEkc6vSLLhdLYrs="; 30 - 31 - # protoc plugins inside the repository (`go install ./internal/protoc/...` 32 - # upstream) 33 - buildZitadelProtocGen = 34 - name: 35 - buildGoModule { 36 - pname = "protoc-gen-${name}"; 37 - inherit version; 38 - 39 - src = zitadelRepo; 40 - 41 - proxyVendor = true; 42 - vendorHash = goModulesHash; 43 - 44 - subPackages = [ "internal/protoc/protoc-gen-${name}" ]; 45 - 46 - doCheck = false; 47 - }; 48 - 49 - protoc-gen-authoption = buildZitadelProtocGen "authoption"; 50 - protoc-gen-zitadel = buildZitadelProtocGen "zitadel"; 51 - 52 - # Descriptor set of proto/. buf fetches the dependencies pinned in 53 - # proto/buf.lock from the Buf Schema Registry, so this is the one 54 - # fixed-output step; the code generators run offline against it. 55 - protoImage = stdenv.mkDerivation { 56 - pname = "zitadel-proto-image"; 57 - inherit version; 58 - 59 - src = zitadelRepo; 60 - 61 - nativeBuildInputs = [ 62 - buf 63 - cacert 64 - ]; 65 - 66 - buildPhase = '' 67 - runHook preBuild 68 - HOME=$TMPDIR buf build proto -o image.binpb 69 - runHook postBuild 70 - ''; 71 - 72 - installPhase = '' 73 - runHook preInstall 74 - cp image.binpb $out 75 - runHook postInstall 76 - ''; 77 - 78 - outputHashMode = "flat"; 79 - outputHash = "sha256-vsVaGO6ewwCMkK1Qh/8/JZblwICnszmfZxXAPFIAdOk="; 80 - }; 81 - 82 - generateProtobufCode = 83 - { 84 - pname, 85 - version, 86 - nativeBuildInputs ? [ ], 87 - bufArgs ? "", 88 - workDir ? ".", 89 - outputPath, 90 - }: 91 - stdenv.mkDerivation { 92 - pname = "${pname}-buf-generated"; 93 - inherit version; 94 - 95 - src = zitadelRepo; 96 - 97 - nativeBuildInputs = nativeBuildInputs ++ [ buf ]; 98 - 99 - buildPhase = '' 100 - runHook preBuild 101 - cd ${workDir} 102 - HOME=$TMPDIR buf generate ${protoImage}#format=binpb ${bufArgs} 103 - runHook postBuild 104 - ''; 105 - 106 - installPhase = '' 107 - runHook preInstall 108 - cp -r ${outputPath} $out 109 - runHook postInstall 110 - ''; 111 - }; 112 - 113 - jsDeps = callPackage ./javascript.nix { inherit protoImage version zitadelRepo; }; 114 - 115 - # the `generate-stubs` target of apps/api/project.json 116 - protobufGenerated = generateProtobufCode { 117 - pname = "zitadel"; 118 - inherit version; 119 - nativeBuildInputs = [ 120 - grpc-gateway 121 - protoc-gen-authoption 122 - protoc-gen-connect-go 123 - protoc-gen-go 124 - protoc-gen-go-grpc 125 - protoc-gen-validate 126 - protoc-gen-zitadel 127 - ]; 128 - outputPath = ".artifacts"; 129 - }; 130 - in 131 - buildGoModule (finalAttrs: { 132 - pname = "zitadel"; 133 - inherit version; 134 - 135 - src = zitadelRepo; 136 - 137 - nativeBuildInputs = [ 138 - dart-sass 139 - statik 140 - ]; 141 - 142 - postPatch = '' 143 - substituteInPlace internal/api/ui/login/static/resources/generate.go \ 144 - --replace-fail "pnpm sass" "sass" 145 - ''; 146 - 147 - proxyVendor = true; 148 - vendorHash = goModulesHash; 149 - ldflags = [ 150 - "-s" 151 - "-w" 152 - "-X 'github.com/zitadel/zitadel/cmd/build.version=${version}'" 153 - ]; 154 - 155 - # The other Go packages are docs snippets and test helpers. 156 - subPackages = [ "." ]; 157 - 158 - # The generate-stubs, generate-statik, generate-assets and build-console 159 - # targets of apps/api/project.json. 160 - preBuild = '' 161 - # zitadel warns on every invocation without a build date 162 - ldflags+=" -X github.com/zitadel/zitadel/cmd/build.date=$(date -u -d "@$SOURCE_DATE_EPOCH" +%Y-%m-%dT%H:%M:%SZ)" 163 - 164 - mkdir -p pkg/grpc 165 - cp -r ${protobufGenerated}/grpc/github.com/zitadel/zitadel/pkg/grpc/* pkg/grpc 166 - mkdir -p openapi/v2/zitadel 167 - cp -r ${protobufGenerated}/grpc/zitadel/ openapi/v2/zitadel 168 - 169 - go generate internal/api/ui/login/static/resources/generate.go 170 - go generate internal/api/ui/login/statik/generate.go 171 - go generate internal/notification/statik/generate.go 172 - go generate internal/statik/generate.go 173 - 174 - mkdir -p apps/docs/content/apis/assets 175 - go run internal/api/assets/generator/asset_generator.go \ 176 - -directory=internal/api/assets/generator/ \ 177 - -assets=apps/docs/content/apis/assets/assets.mdx 178 - 179 - find internal/api/ui/console/static -mindepth 1 ! -name gitkeep -delete 180 - cp -r ${finalAttrs.passthru.console}/* internal/api/ui/console/static 181 - ''; 182 - 183 - doCheck = false; 184 - 185 - installPhase = '' 186 - runHook preInstall 187 - install -Dm755 $GOPATH/bin/zitadel $out/bin/zitadel 188 - runHook postInstall 189 - ''; 190 - 191 - passthru = { 192 - console = callPackage (import ./console.nix { 193 - inherit 194 - generateProtobufCode 195 - jsDeps 196 - version 197 - zitadelRepo 198 - ; 199 - }) { }; 200 - inherit protoImage; 201 - inherit (jsDeps) pnpmDeps; 202 - }; 203 - 204 - meta = { 205 - description = "Identity and access management platform"; 206 - homepage = "https://zitadel.com/"; 207 - downloadPage = "https://github.com/zitadel/zitadel/releases"; 208 - changelog = "https://github.com/zitadel/zitadel/releases/tag/v${version}"; 209 - platforms = lib.platforms.linux ++ lib.platforms.darwin; 210 - license = lib.licenses.agpl3Only; 211 - sourceProvenance = [ lib.sourceTypes.fromSource ]; 212 - mainProgram = "zitadel"; 213 - }; 214 - })
-87
pkgs/zitadel/protoc-gen-js.nix
··· 1 - { 2 - stdenv, 3 - gcc14Stdenv, 4 - lib, 5 - buildBazelPackage, 6 - bazel_7, 7 - fetchFromGitHub, 8 - cctools, 9 - }: 10 - 11 - let 12 - # fails to build with gcc15, see https://github.com/NixOS/nixpkgs/issues/475586 13 - buildBazelPackage' = 14 - if stdenv.cc.isGNU then 15 - buildBazelPackage.override { 16 - stdenv = gcc14Stdenv; 17 - } 18 - else 19 - buildBazelPackage; 20 - 21 - # Since 4.0.0 the project resolves its dependencies with bzlmod. Bazel needs 22 - # the registry in the sandboxed build phase as well, so a checkout of the 23 - # Bazel Central Registry is pinned. 24 - registry = fetchFromGitHub { 25 - owner = "bazelbuild"; 26 - repo = "bazel-central-registry"; 27 - rev = "0f256a72067e42d62bb568cc2619f98deed139e2"; 28 - hash = "sha256-OcMLg0KiAQOJZLH8r+QkeQ9bxcEc4L0dCgyUv5PkLQk="; 29 - }; 30 - in 31 - buildBazelPackage' rec { 32 - pname = "protoc-gen-js"; 33 - version = "4.0.2"; 34 - 35 - src = fetchFromGitHub { 36 - owner = "protocolbuffers"; 37 - repo = "protobuf-javascript"; 38 - rev = "v${version}"; 39 - hash = "sha256-vFTR7dwquZht4st90bFQ9CJMYBPbL+DWO0uQ3xsXIBU="; 40 - }; 41 - 42 - bazel = bazel_7; 43 - bazelFlags = [ 44 - "--registry" 45 - "file://${registry}" 46 - ]; 47 - bazelTargets = [ "generator:protoc-gen-js" ]; 48 - bazelBuildFlags = lib.optionals stdenv.cc.isClang [ 49 - "--cxxopt=-x" 50 - "--cxxopt=c++" 51 - "--host_cxxopt=-x" 52 - "--host_cxxopt=c++" 53 - ]; 54 - removeRulesCC = false; 55 - 56 - env = lib.optionalAttrs stdenv.hostPlatform.isDarwin { 57 - LIBTOOL = "${cctools}/bin/libtool"; 58 - }; 59 - 60 - fetchAttrs = { 61 - # Repositories Bazel generates by inspecting the build host (store paths, 62 - # host CPU) stay out of the deps tarball; the build phase regenerates them. 63 - # With bzlmod their names carry the extension prefix (`~` on Bazel 7). 64 - preInstall = '' 65 - rm -rf "$bazelOut"/external/*[~+]{host_platform,local_config_*} 66 - ''; 67 - 68 - hash = "sha256-bc+VwWhHzXIblFGYf8gfRTZvAiBY4L9L9texuJlxRYI="; 69 - }; 70 - 71 - buildAttrs.installPhase = '' 72 - mkdir -p $out/bin 73 - install -Dm755 bazel-bin/generator/protoc-gen-js $out/bin/ 74 - ''; 75 - 76 - meta = { 77 - description = "Protobuf plugin for generating JavaScript code"; 78 - mainProgram = "protoc-gen-js"; 79 - homepage = "https://github.com/protocolbuffers/protobuf-javascript"; 80 - platforms = lib.platforms.linux ++ lib.platforms.darwin; 81 - license = with lib.licenses; [ 82 - asl20 83 - bsd3 84 - ]; 85 - sourceProvenance = [ lib.sourceTypes.fromSource ]; 86 - }; 87 - }
+1 -1
skills/hedgedoc/SKILL.md
··· 77 77 78 78 - `allowFreeURL = true` — pads can have custom slugs 79 79 - `defaultPermission = "freely"` — all pads are world-editable via URL 80 + - Edit replaces the entire pad content (full replacement, not partial edit) 80 - - Edit replaces the entire pad content (full replacement, not partial edit); empty content is refused 81 81 - Active browser sessions will see the change in real-time (pushed via socket.io) 82 82 - Pad IDs are from the URL: `https://pad.lassul.us/{PADID}` 83 83 - Edit uses UTF-16 code unit counting (JavaScript string length) for OT operations
+5 -14
skills/hedgedoc/pad-edit.py
··· 6 6 import urllib.request 7 7 import ssl 8 8 9 - USAGE = """Usage: pad-edit.py <pad-id> [file|-] 10 - Replace the pad's content with the file, or stdin when omitted or "-".""" 11 - 12 - # Before the bootstrap below, so --help needs no nix build or network. 13 - if len(sys.argv) < 2 or sys.argv[1].startswith("-"): 14 - help_asked = sys.argv[1:2] in (["-h"], ["--help"]) 15 - print(USAGE, file=sys.stdout if help_asked else sys.stderr) 16 - sys.exit(0 if help_asked else 1) 17 - 18 9 try: 19 10 import websocket 20 11 except ImportError: ··· 59 50 60 51 61 52 def main(): 53 + if len(sys.argv) < 2: 54 + print("Usage: pad-edit.py <pad-id> [file]", file=sys.stderr) 55 + print(" echo 'content' | pad-edit.py <pad-id>", file=sys.stderr) 56 + sys.exit(1) 57 + 62 58 pad_id = sys.argv[1] 63 59 64 60 # Read new content ··· 71 67 # Strip single trailing newline (added by shell heredoc/echo) 72 68 if new_content.endswith("\n"): 73 69 new_content = new_content[:-1] 74 - 75 - # An empty write would blank the pad. 76 - if not new_content: 77 - print("ERROR: Refusing to write empty content", file=sys.stderr) 78 - sys.exit(1) 79 70 80 71 # Step 1: Get session cookie 81 72 ctx = ssl.create_default_context()
+26 -3
tools/hermes-voice/flake-module.nix
··· 1 + { self, ... }: 1 2 { 2 3 perSystem = 4 + { lib, pkgs, ... }: 3 - { pkgs, ... }: 4 5 let 6 + # The Android wrapper's package name (android/AndroidManifest.xml). 7 + androidPackage = "us.lassul.assist"; 8 + # Written by `clan vars generate coaxmetal` (machines/coaxmetal/hermes-voice.nix); 9 + # until then there is no assetlinks.json and Chrome opens the app as a 10 + # Custom Tab. 11 + fingerprint = self + "/vars/per-machine/coaxmetal/hermes-voice-android-signing/fingerprint/value"; 12 + assetlinks = pkgs.writeText "assetlinks.json" ( 13 + builtins.toJSON [ 14 + { 15 + relation = [ "delegate_permission/common.handle_all_urls" ]; 16 + target = { 17 + namespace = "android_app"; 18 + package_name = androidPackage; 19 + sha256_cert_fingerprints = [ (lib.removeSuffix "\n" (builtins.readFile fingerprint)) ]; 20 + }; 21 + } 22 + ] 23 + ); 5 24 icon = pkgs.runCommand "hermes-voice-icon" { nativeBuildInputs = [ pkgs.imagemagick ]; } '' 6 25 mkdir $out 7 26 for size in 192 512; do ··· 14 33 cp -r ${./static} $out 15 34 chmod u+w $out 16 35 cp ${icon}/*.png $out/ 36 + ${lib.optionalString (builtins.pathExists fingerprint) '' 37 + mkdir $out/.well-known 38 + cp ${assetlinks} $out/.well-known/assetlinks.json 39 + ''} 17 40 ''; 18 41 # The SDK is unfree; it only builds the APK and is not part of any system. 19 42 inherit ··· 40 63 }).overrideAttrs 41 64 { passthru.usage = builtins.readFile ./usage.kdl; }; 42 65 66 + # The voice page as Android app and digital assistant (Trusted Web 67 + # Activity), unsigned. 43 - # The Android app: holds calls itself (WebRTC) and answers the headset's 44 - # voice button, unsigned. 45 68 packages.hermes-voice-android = android; 46 69 checks.hermes-voice-android = android; 47 70
+7 -18
tools/hermes-voice/hermes_voice.py
··· 31 31 route requires the page's own access token (--access-token-file), and serving 32 32 beyond loopback without one is refused. 33 33 34 - The Android app (android/) is a second client of the same routes: it holds 35 - the call itself (WebRTC, no page) and presents its own token 36 - (--app-token-file), which the proxy passes through without the web login. 37 - 38 34 Vendor contract: https://developers.openai.com/api/docs/guides/live-delegation 39 35 """ 40 36 ··· 50 46 import threading 51 47 import time 52 48 import urllib.error 53 - import urllib.parse 54 49 import urllib.request 55 50 from http import HTTPStatus 56 51 from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer ··· 124 119 125 120 Delegation policy: 126 121 Backend tools: 122 + - Hermes agent: a full AI agent with tools. It can run commands on the user's machines, read and edit files, manage the calendar, write and read messages in the user's Matrix chat, browse the web, search, and remember things across sessions. It is the one who actually does work and knows facts. 127 - - Hermes agent: a full AI agent with tools. It can run commands on the user's machines, read and edit files, manage the calendar, write and read messages in the user's Matrix chat, browse the web, search, and remember things across sessions. It is the one who actually does work and knows facts. You yourself cannot do anything. 128 123 129 124 Delegate to the backend when: 130 125 - The user asks a question that needs facts, current information, or careful reasoning. 126 + - The user asks you to do, check, find, make, fix, run or remember anything. 131 - - The user asks you to do, check, find, make, fix, run or remember anything, or to change or control something such as lights, devices, music or messages. Delegate every single time, also when it repeats or varies something done earlier in the call. 132 127 - A correction changes work already requested. 133 128 134 129 - The user says goodbye, says that is all, asks you to hang up, or says they are leaving. Hermes ends the call after your reply; you may still say a short goodbye, but never end the call yourself. ··· 137 132 - The user greets you, makes small talk, or asks you to repeat a result already provided. 138 133 - You need a brief clarification to understand the request. 139 134 135 + Delegate before giving an answer that depends on backend work. Do not guess the result while waiting; say briefly and cheerfully that you are on it, then wait for the result. 140 - Delegate before giving an answer that depends on backend work. Emit the delegation first; only together with it may you say briefly and cheerfully that you are on it, then wait for the result. Never say you are on it, say it is done or describe the action without delegating. Do not guess the result while waiting, and never report something as done until the backend result says so. 141 136 142 137 Approval policy: 143 138 Hermes sometimes needs the user's approval before it runs a command. When you are told so, explain in plain words what Hermes wants to do and ask whether to approve or deny it. When the user answers, delegate to the backend so the answer reaches Hermes. Never decide an approval yourself.""" ··· 562 557 config: Config 563 558 static: Path 564 559 access_token: "str | None" = None 565 - app_token: "str | None" = None 566 560 567 561 def authorized(self, also_hermes_key=False): 568 562 """Check the page's bearer token (or, where allowed, the API server key ··· 573 567 header = self.headers.get("Authorization", "") 574 568 scheme, _, token = header.partition(" ") 575 569 accepted = [self.access_token] 576 - if self.app_token: 577 - accepted.append(self.app_token) 578 570 if also_hermes_key: 579 571 accepted.append(self.config.hermes_key) 580 572 if scheme == "Bearer" and any( ··· 623 615 "/manifest.webmanifest": ("manifest.webmanifest", "application/manifest+json"), 624 616 "/icon-192.png": ("icon-192.png", "image/png"), 625 617 "/icon-512.png": ("icon-512.png", "image/png"), 618 + # Missing until the Android signing key exists. 619 + "/.well-known/assetlinks.json": (".well-known/assetlinks.json", "application/json"), 626 620 } 621 + if self.path not in names or not (self.static / names[self.path][0]).is_file(): 627 - # Query strings do not pick a different file. 628 - path = urllib.parse.urlsplit(self.path).path 629 - if path not in names or not (self.static / names[path][0]).is_file(): 630 622 self.send_error(HTTPStatus.NOT_FOUND) 631 623 return 624 + name, content_type = names[self.path] 632 - name, content_type = names[path] 633 625 body = (self.static / name).read_bytes() 634 626 self.send_response(HTTPStatus.OK) 635 627 self.send_header("Content-Type", content_type) ··· 827 819 parser.add_argument("--hermes-key-file", required=True, help="file holding API_SERVER_KEY") 828 820 parser.add_argument("--openai-key-file", required=True, help="file holding the OpenAI API key") 829 821 parser.add_argument("--access-token-file", help="file holding the token the page must present") 830 - parser.add_argument("--app-token-file", help="file holding the token the Android app presents") 831 822 parser.add_argument( 832 823 "--matrix-webhook", 833 824 help="Hermes webhook route (deliver_only to Matrix, signed with API_SERVER_KEY) that " ··· 859 850 Handler.static = Path(args.static) 860 851 if args.access_token_file: 861 852 Handler.access_token = Path(args.access_token_file).read_text().strip() 862 - if args.app_token_file: 863 - Handler.app_token = Path(args.app_token_file).read_text().strip() 864 853 server_class = ThreadingHTTPServer 865 854 if ":" in host: 866 855 # "::" is dual-stack on Linux (bindv6only=0).
-10
tools/zitadel-invite/flake-module.nix
··· 1 - { ... }: 2 - { 3 - perSystem = 4 - { pkgs, ... }: 5 - { 6 - packages.zitadel-invite = 7 - (pkgs.writers.writePython3Bin "zitadel-invite" { } ./zitadel_invite.py).overrideAttrs 8 - { passthru.usage = builtins.readFile ./usage.kdl; }; 9 - }; 10 - }
-18
tools/zitadel-invite/usage.kdl
··· 1 - bin "zitadel-invite" 2 - about "Invite a person to Zitadel projects: create the user, send the invite mail, add user grants" 3 - 4 - arg "<email>" help="Email address, also the username of a new user" 5 - arg "<project>..." help="Project name" 6 - flag "--name" help="\"First Last\" of a new user (default: the email's local part)" { 7 - arg "<name>" 8 - } 9 - flag "--resend" help="Send a new invite code to an existing user without a login" 10 - flag "--app-name" help="Application name in the invite mail" { 11 - arg "<name>" 12 - } 13 - flag "--url" help="Zitadel URL (env ZITADEL_URL)" { 14 - arg "<url>" 15 - } 16 - flag "--token-file" help="File with a personal access token (env ZITADEL_TOKEN_FILE, or the token in ZITADEL_TOKEN)" { 17 - arg "<path>" 18 - }
-159
tools/zitadel-invite/zitadel_invite.py
··· 1 - # Invites a person to projects of a Zitadel organization: creates a human 2 - # user for the email address (username = the address), lets Zitadel mail 3 - # them an invite code to set up a password, and adds a user grant on each 4 - # project. An existing user with that address only gets the missing grants. 5 - import argparse 6 - import json 7 - import os 8 - import sys 9 - import urllib.error 10 - import urllib.request 11 - 12 - parser = argparse.ArgumentParser( 13 - prog="zitadel-invite", 14 - description="Invite a person to Zitadel projects.", 15 - epilog="It needs the token of a user with the ORG_USER_MANAGER role.", 16 - ) 17 - parser.add_argument("email") 18 - parser.add_argument("projects", nargs="+", metavar="project") 19 - parser.add_argument("--name", help='"First Last", default: the local part') 20 - parser.add_argument( 21 - "--resend", 22 - action="store_true", 23 - help="send a new invite code to an existing user who has not set up " 24 - "a login yet", 25 - ) 26 - parser.add_argument( 27 - "--app-name", help="application name in the invite mail" 28 - ) 29 - parser.add_argument( 30 - "--url", 31 - default=os.environ.get("ZITADEL_URL"), 32 - help="Zitadel URL, e.g. https://id.example.org (env ZITADEL_URL)", 33 - ) 34 - parser.add_argument( 35 - "--token-file", 36 - default=os.environ.get("ZITADEL_TOKEN_FILE"), 37 - help="file with a personal access token (env ZITADEL_TOKEN_FILE, or " 38 - "the token itself in ZITADEL_TOKEN)", 39 - ) 40 - args = parser.parse_args() 41 - 42 - if not args.url: 43 - parser.error("--url or ZITADEL_URL is needed") 44 - if args.token_file: 45 - with open(args.token_file) as f: 46 - token = f.read().strip() 47 - else: 48 - token = os.environ.get("ZITADEL_TOKEN") 49 - if not token: 50 - parser.error("--token-file, ZITADEL_TOKEN_FILE or ZITADEL_TOKEN is needed") 51 - issuer = args.url.rstrip("/") 52 - 53 - 54 - def fetch(req, what): 55 - try: 56 - with urllib.request.urlopen(req) as res: 57 - return json.load(res) 58 - except urllib.error.HTTPError as e: 59 - sys.exit(f"{what}: {e.code} {e.read().decode()}") 60 - 61 - 62 - def call(method, path, body=None): 63 - return fetch(urllib.request.Request( 64 - issuer + path, 65 - method=method, 66 - headers={ 67 - "Authorization": "Bearer " + token, 68 - "Content-Type": "application/json", 69 - }, 70 - data=None if body is None else json.dumps(body).encode(), 71 - ), f"{method} {path}") 72 - 73 - 74 - def invite(user_id): 75 - send_code = {} 76 - if args.app_name: 77 - send_code["applicationName"] = args.app_name 78 - call("POST", f"/v2/users/{user_id}/invite_code", {"sendCode": send_code}) 79 - print(f"user {args.email}: invite mail sent") 80 - 81 - 82 - # check the projects before changing anything 83 - project_ids = {} 84 - for name in args.projects: 85 - found = [ 86 - p for p in call("POST", "/management/v1/projects/_search", { 87 - "queries": [{"nameQuery": { 88 - "name": name, 89 - "method": "TEXT_QUERY_METHOD_EQUALS", 90 - }}], 91 - }).get("result", []) 92 - if p["name"] == name 93 - ] 94 - if len(found) != 1: 95 - sys.exit(f"project {name}: {len(found)} matches") 96 - project_ids[name] = found[0]["id"] 97 - 98 - users = call("POST", "/management/v1/users/_search", {"queries": [ 99 - {"emailQuery": { 100 - "emailAddress": args.email, 101 - "method": "TEXT_QUERY_METHOD_EQUALS_IGNORE_CASE", 102 - }}, 103 - {"typeQuery": {"type": "TYPE_HUMAN"}}, 104 - ]}).get("result", []) 105 - if len(users) > 1: 106 - sys.exit(f"user {args.email}: {len(users)} users with this address") 107 - 108 - if users: 109 - user = users[0] 110 - user_id = user["id"] 111 - # Anyone can register with any address. Grants go only to users who 112 - # verified theirs, or who were created by an admin and have no login yet. 113 - initial = user.get("state") == "USER_STATE_INITIAL" 114 - verified = user["human"].get("email", {}).get("isEmailVerified", False) 115 - if not initial and not verified: 116 - sys.exit( 117 - f"user {args.email}: exists ({user['userName']}) with the " 118 - "address unverified, not granting" 119 - ) 120 - print(f"user {args.email}: exists ({user['userName']})") 121 - if args.resend: 122 - if initial: 123 - invite(user_id) 124 - else: 125 - print(f"user {args.email}: has a login already, no invite") 126 - else: 127 - full_name = args.name or args.email.split("@")[0] 128 - given, _, family = full_name.partition(" ") 129 - org_id = call("GET", "/management/v1/orgs/me")["org"]["id"] 130 - user_id = call("POST", "/v2/users/new", { 131 - "organizationId": org_id, 132 - "human": { 133 - "profile": { 134 - "givenName": given, 135 - # Zitadel requires one 136 - "familyName": family.strip() or given, 137 - "displayName": full_name, 138 - }, 139 - # verified by the invite code, without a separate mail 140 - "email": {"email": args.email, "returnCode": {}}, 141 - }, 142 - })["id"] 143 - print(f"user {args.email}: created") 144 - invite(user_id) 145 - 146 - granted = { 147 - g["projectId"] 148 - for g in call("POST", "/management/v1/users/grants/_search", { 149 - "queries": [{"userIdQuery": {"userId": user_id}}], 150 - }).get("result", []) 151 - } 152 - for name, project_id in project_ids.items(): 153 - if project_id in granted: 154 - print(f"project {name}: granted already") 155 - else: 156 - call("POST", f"/management/v1/users/{user_id}/grants", { 157 - "projectId": project_id, 158 - }) 159 - print(f"project {name}: granted")
+1 -5
configs/gg23/audio/hub.nix
··· 105 105 ''{% set col = states.switch | selectattr('entity_id', 'search', '^switch\\.audio_route_.*_${r}$') | map(attribute='entity_id') | list %}{% set ns.out = ns.out + [{"type": "vertical-stack", "cards": [{"type": "tile", "entity": "number.audio_volume_${r}", "name": "${rooms.${r}}", "icon": "mdi:speaker", "features": [{"type": "numeric-input", "style": "slider"}]}, ${ 106 106 allButton "All off" "mdi:volume-off" "switch.turn_off" "col" 107 107 }]}] %}''; 108 - # crossed out speaker when off, so on/off differ in shape too 109 108 routeCell = 110 109 r: 110 + ''{"type": "button", "entity": "switch.audio_route_" ~ k ~ "_${r}", "name": "${rooms.${r}}", "show_name": false, "icon": "mdi:speaker", "icon_height": "64px", "tap_action": {"action": "toggle"}, "hold_action": {"action": "none"}}''; 111 - let 112 - id = ''"switch.audio_route_" ~ k ~ "_${r}"''; 113 - in 114 - ''{"type": "button", "entity": ${id}, "name": "${rooms.${r}}", "show_name": false, "icon": ("mdi:speaker" if is_state(${id}, "on") else "mdi:speaker-off"), "icon_height": "64px", "tap_action": {"action": "toggle"}, "hold_action": {"action": "none"}}''; 115 111 matrix = lib.concatStrings ( 116 112 [ 117 113 "{% set ns = namespace(out=[]) %}"
+3 -29
configs/gg23/audio/matrix.py
··· 1 1 # Audio routing matrix: one Home Assistant switch (MQTT discovery) per 2 2 # source and speaker. While a switch is on, the source's PipeWire node is 3 3 # linked to the speaker's node. Sources are the fixed nodes from the config 4 + # plus every connected Bluetooth A2DP source (bluez_input.*). A new source 5 + # starts on the config's `rooms` of it, else on all speakers. Each speaker 4 - # plus every connected Bluetooth A2DP source (bluez_input.*; phones left on 5 - # the "off" profile are switched to A2DP). A new source starts on the 6 - # config's `rooms` of it, else on all speakers. Each speaker 7 6 # also gets a volume (MQTT number), applied to its node with wpctl. Usage: 8 7 # audio-matrix CONFIG.json [--dry-run], --dry-run prints the pw-link and 9 8 # wpctl calls without running them. ··· 23 22 24 23 events = queue.Queue() 25 24 25 + # PipeWire objects by id: nodes, ports and links 26 - # PipeWire objects by id: devices, nodes, ports and links 27 26 graph = {} 28 27 interesting = { 29 - "PipeWire:Interface:Device", 30 28 "PipeWire:Interface:Node", 31 29 "PipeWire:Interface:Port", 32 30 "PipeWire:Interface:Link", ··· 94 92 ) 95 93 src["nodes"].append(i) 96 94 return found 97 - 98 - 99 - # bluez devices whose profile was switched on, so a phone that turns it off 100 - # again is left alone 101 - profiled = set() 102 - 103 - 104 - # WirePlumber leaves a newly paired phone on the "off" profile when its A2DP 105 - # transport is not up yet at connect time, so it gets no bluez_input node. 106 - # Switch such devices to the A2DP (audio-gateway) profile once. 107 - def bluetooth_profiles(): 108 - for i, info in objects("Device"): 109 - if info["props"].get("device.api") != "bluez5" or i in profiled: 110 - continue 111 - params = info.get("params", {}) 112 - current = (params.get("Profile") or [{}])[0].get("name") 113 - gateway = [ 114 - p for p in params.get("EnumProfile", []) 115 - if p.get("name") == "audio-gateway" and p.get("available") != "no" 116 - ] 117 - if current == "off" and gateway: 118 - run("wpctl", "set-profile", i, gateway[0]["index"]) 119 - profiled.add(i) 120 95 121 96 122 97 # whether both nodes have their ports to link yet ··· 264 239 265 240 266 241 def sync(): 267 - bluetooth_profiles() 268 242 found = sources() 269 243 for key in [k for k in published if k not in found]: 270 244 unpublish(key)
-4
configs/gg23/audio/room.nix
··· 26 26 } 27 27 ]; 28 28 29 - # system-wide pipewire is only socket-activated; a room has no local client 30 - # after boot, so start it or the ROC source above never listens 31 - systemd.services.pipewire.wantedBy = [ "multi-user.target" ]; 32 - 33 29 # the hub sends over retiolum (see ./hub.nix), accept only acheron 34 30 networking.firewall.extraInputRules = '' 35 31 iifname "retiolum" ip saddr ${acheron} udp dport 10001-10003 accept
+1 -64
configs/services/flix/default.nix
··· 1 + { config, pkgs, ... }: 1 - { 2 - config, 3 - lib, 4 - pkgs, 5 - ... 6 - }: 7 2 { 8 3 # sabnzbd hard-depends on cheetah3, whose PyPI dist is `ct3` while nixpkgs 9 4 # sets pname="cheetah3". pythonMetadataCheckPhase then looks up metadata for ··· 470 465 systemd.tmpfiles.rules = [ 471 466 "e /var/download/usenet - - - 7d" 472 467 ]; 473 - 474 - # Configs without media, downloads, logs and image caches; the SQLite 475 - # databases are dumped consistently instead of copied. 476 - lass.backup.state.flix = 477 - let 478 - dbs = { 479 - jellyfin = "/var/lib/jellyfin/data/*.db"; 480 - sonarr = "${config.services.sonarr.dataDir}/sonarr.db"; 481 - radarr = "${config.services.radarr.dataDir}/radarr.db"; 482 - prowlarr = "/var/lib/private/prowlarr/prowlarr.db"; 483 - jellyseerr = "/var/lib/private/jellyseerr/config/db/db.sqlite3"; 484 - sabnzbd = "/var/lib/sabnzbd/admin/history1.db"; 485 - }; 486 - dirs = [ 487 - "/var/lib/jellyfin" 488 - config.services.sonarr.dataDir 489 - config.services.radarr.dataDir 490 - "/var/lib/private/prowlarr" 491 - "/var/lib/private/jellyseerr" 492 - "/var/lib/sabnzbd" 493 - ]; 494 - in 495 - { 496 - folders = dirs; 497 - exclude = [ 498 - "/var/lib/jellyfin/metadata" 499 - "/var/lib/jellyfin/log" 500 - "/var/lib/jellyfin/transcodes" 501 - "${config.services.sonarr.dataDir}/MediaCover" 502 - "${config.services.sonarr.dataDir}/logs" 503 - "${config.services.radarr.dataDir}/MediaCover" 504 - "${config.services.radarr.dataDir}/logs" 505 - "/var/lib/private/prowlarr/logs" 506 - "/var/lib/private/jellyseerr/config/cache" 507 - "/var/lib/private/jellyseerr/config/logs" 508 - "/var/lib/private/jellyseerr/config/db/db.sqlite3*" 509 - "/var/lib/sabnzbd/Downloads" 510 - "/var/lib/sabnzbd/logs" 511 - ] 512 - # the live databases, also the unneeded servarr logs.db 513 - ++ map (dir: "${dir}/*.db") dirs 514 - ++ map (dir: "${dir}/*.db-*") dirs; 515 - preBackup = '' 516 - rm -rf /var/backup/flix 517 - ${lib.concatStrings ( 518 - lib.mapAttrsToList (name: glob: '' 519 - mkdir -p /var/backup/flix/${name} 520 - for db in ${glob}; do 521 - # sqlite3 would create a missing database 522 - test -f "$db" 523 - ${pkgs.sqlite}/bin/sqlite3 -cmd ".timeout 60000" "$db" ".backup '/var/backup/flix/${name}/$(basename "$db")'" 524 - done 525 - '') dbs 526 - )} 527 - ''; 528 - # WAL-mode SQLite readers need to write the -shm file 529 - readWritePaths = map dirOf (lib.attrValues dbs); 530 - }; 531 468 }
configs/services/git/appview.nix

This file has not been changed.

configs/services/git/default.nix

This file has not been changed.

configs/services/git/knot.nix

This file has not been changed.

configs/services/git/knotmirror.nix

This file has not been changed.

configs/services/git/pds.nix

This file has not been changed.

-10
configs/services/matrix/default.nix
··· 120 120 } 121 121 ]; 122 122 }; 123 - 124 - lass.backup.state.matrix-synapse = { 125 - folders = [ "/var/lib/matrix-synapse/media_store/local_content" ]; 126 - # 14GB, compresses well 127 - preBackup = '' 128 - mkdir -p /var/backup/postgresql 129 - ${pkgs.sudo}/bin/sudo -u postgres ${config.services.postgresql.package}/bin/pg_dump \ 130 - matrix-synapse | ${pkgs.gzip}/bin/gzip > /var/backup/postgresql/matrix-synapse.sql.gz 131 - ''; 132 - }; 133 123 }
-10
configs/services/pad/module.nix
··· 80 80 systemd.services.hedgedoc.serviceConfig.Restart = "always"; 81 81 systemd.services.hedgedoc.environment.NODE_OPTIONS = "--max-old-space-size=512"; 82 82 83 - lass.backup.state.hedgedoc = { 84 - folders = [ config.services.hedgedoc.settings.uploadsPath ]; 85 - # The database uses a rollback journal and is written often during the 86 - # day, so the dump waits for its locks instead of failing. 87 - preBackup = '' 88 - ${pkgs.sqlite}/bin/sqlite3 -cmd ".timeout 60000" /var/lib/hedgedoc/db.sqlite ".backup /var/backup/hedgedoc.sqlite" 89 - ''; 90 - readWritePaths = [ "/var/lib/hedgedoc" ]; 91 - }; 92 - 93 83 systemd.services.hedgedoc.serviceConfig.EnvironmentFile = [ 94 84 config.clan.core.vars.generators.hedgedoc.files."hedgedoc.env".path 95 85 ];
-3
configs/services/paint/default.nix
··· 24 24 }; 25 25 }; 26 26 27 - # DynamicUser + StateDirectory make /var/lib/whitebophir only a symlink 28 - lass.backup.state.whitebophir.folders = [ "/var/lib/private/whitebophir" ]; 29 - 30 27 services.nginx.virtualHosts.${domain} = { 31 28 enableACME = true; 32 29 forceSSL = true;
-12
configs/services/paperless/default.nix
··· 39 39 }; 40 40 }; 41 41 42 - # documents, index and classifier; the SQLite database is dumped 43 - # consistently instead of copied 44 - lass.backup.state.paperless = { 45 - folders = [ "/var/lib/paperless" ]; 46 - exclude = [ "/var/lib/paperless/db.sqlite3*" ]; 47 - preBackup = '' 48 - ${pkgs.sqlite}/bin/sqlite3 -cmd ".timeout 60000" /var/lib/paperless/db.sqlite3 ".backup /var/backup/paperless.sqlite3" 49 - ''; 50 - # WAL-mode SQLite readers need to write the -shm file 51 - readWritePaths = [ "/var/lib/paperless" ]; 52 - }; 53 - 54 42 # Zitadel (id.lassul.us) login, only for users with a grant on the project. 55 43 # zitadel-reconcile writes the client id/secret to zitadelEnv. 56 44 lass.zitadel.projects.paperless = {
+9 -50
tools/hermes-voice/android/AndroidManifest.xml
··· 2 2 <manifest xmlns:android="http://schemas.android.com/apk/res/android" 3 3 package="us.lassul.assist"> 4 4 5 + <queries> 6 + <intent> 7 + <action android:name="android.support.customtabs.action.CustomTabsService" /> 8 + </intent> 9 + </queries> 5 - <!-- VoiceCall: WebRTC to gpt-live-1 and the hermes-voice API. --> 6 - <uses-permission android:name="android.permission.INTERNET" /> 7 - <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> 8 - <uses-permission android:name="android.permission.RECORD_AUDIO" /> 9 - <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" /> 10 - <!-- CallService: the call as a self-managed Telecom call, in the foreground. --> 11 - <uses-permission android:name="android.permission.MANAGE_OWN_CALLS" /> 12 - <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> 13 - <uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" /> 14 - <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" /> 15 - <uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> 16 10 17 11 <application 18 12 android:label="Assist" 19 13 android:icon="@mipmap/ic_launcher" 14 + android:allowBackup="false"> 20 - android:allowBackup="false" 21 - android:extractNativeLibs="true" 22 - android:theme="@android:style/Theme.DeviceDefault.DayNight"> 23 - <!-- Launcher: the current conversation and Call / Hang up. --> 24 15 <activity 16 + android:name=".AssistActivity" 17 + android:exported="true" 18 + android:theme="@android:style/Theme.Translucent.NoTitleBar"> 25 - android:name=".MainActivity" 26 - android:exported="true"> 27 19 <intent-filter> 28 20 <action android:name="android.intent.action.MAIN" /> 29 21 <category android:name="android.intent.category.LAUNCHER" /> 30 22 </intent-filter> 31 - </activity> 32 - <activity 33 - android:name=".SettingsActivity" 34 - android:label="Settings" 35 - android:exported="false" /> 36 - <activity 37 - android:name=".SessionsActivity" 38 - android:label="Sessions" 39 - android:exported="false" /> 40 - <activity 41 - android:name=".TranscriptActivity" 42 - android:exported="false" /> 43 - <!-- The headset's voice button (HFP AT+BVRA=1 makes HeadsetService 44 - send VOICE_COMMAND) and the assist gesture: start a call and 45 - vanish. Over the lock screen and waking the display, so the 46 - call starts from the foreground without an unlock. --> 47 - <activity 48 - android:name=".AssistActivity" 49 - android:exported="true" 50 - android:excludeFromRecents="true" 51 - android:noHistory="true" 52 - android:taskAffinity="" 53 - android:showWhenLocked="true" 54 - android:turnScreenOn="true" 55 - android:theme="@android:style/Theme.Translucent.NoTitleBar"> 56 23 <intent-filter> 57 24 <action android:name="android.intent.action.ASSIST" /> 58 25 <category android:name="android.intent.category.DEFAULT" /> 59 26 </intent-filter> 60 - <intent-filter> 61 - <action android:name="android.intent.action.VOICE_COMMAND" /> 62 - <category android:name="android.intent.category.DEFAULT" /> 63 - </intent-filter> 64 27 </activity> 65 - <service 66 - android:name=".CallService" 67 - android:exported="false" 68 - android:foregroundServiceType="phoneCall|microphone" /> 69 28 </application> 70 29 </manifest>
+12 -16
tools/hermes-voice/android/default.nix
··· 21 21 sdkRoot = "${sdk.androidsdk}/libexec/android-sdk"; 22 22 buildTools = "${sdkRoot}/build-tools/36.0.0"; 23 23 androidJar = "${sdkRoot}/platforms/android-36/android.jar"; 24 + browser = fetchurl { 25 + url = "https://dl.google.com/android/maven2/androidx/browser/browser/1.10.0/browser-1.10.0.aar"; 26 + hash = "sha256-+0DR7l/ZEMnREiAIaKlTQVanqZmrwo+7PGgVouOsUVA="; 24 - # WebRTC for Android (LiveKit's build of Google's libwebrtc): the Java API 25 - # and the native library, which needs none of the AAR's other files. 26 - webrtc = fetchurl { 27 - url = "https://repo1.maven.org/maven2/io/github/webrtc-sdk/android/150.7871.01/android-150.7871.01.aar"; 28 - hash = "sha256-ChYnsaSMK8F9mkDWL8R71FFm9EoxHpWRfxR8QC3jebA="; 29 27 }; 30 28 in 31 29 # Unsigned release APK; tools/hermes-voice/flake-module.nix has the signer. 30 + # Only androidx.browser's classes go in: the parts used here need none of its 31 + # dependencies. 32 - # arm64 only: the app runs on Pixels. 33 32 stdenvNoCC.mkDerivation { 34 33 pname = "hermes-voice-android"; 35 34 version = versionName; ··· 41 40 ]; 42 41 buildPhase = '' 43 42 runHook preBuild 43 + unzip -q ${browser} classes.jar -d browser 44 - unzip -q ${webrtc} classes.jar 'jni/arm64-v8a/*' -d webrtc 45 44 46 45 mkdir -p res/mipmap-xxxhdpi 47 46 cp ${icon}/icon-192.png res/mipmap-xxxhdpi/ic_launcher.png 48 47 ${buildTools}/aapt2 compile --dir res -o res.zip 49 48 ${buildTools}/aapt2 link -o unsigned.apk -I ${androidJar} \ 50 49 --manifest AndroidManifest.xml res.zip \ 50 + --min-sdk-version 29 --target-sdk-version 36 \ 51 - --min-sdk-version 34 --target-sdk-version 36 \ 52 51 --version-code ${toString versionCode} --version-name ${versionName} 53 52 54 53 javac -source 8 -target 8 -Xlint:-options -encoding UTF-8 \ 54 + -bootclasspath ${androidJar} -cp browser/classes.jar \ 55 - -bootclasspath ${androidJar} -cp webrtc/classes.jar \ 56 55 -d classes $(find src -name '*.java') 56 + ${buildTools}/d8 --release --min-api 29 --lib ${androidJar} --output . \ 57 + $(find classes -name '*.class') browser/classes.jar 57 - ${buildTools}/d8 --release --min-api 34 --lib ${androidJar} --output . \ 58 - $(find classes -name '*.class') webrtc/classes.jar 59 58 59 + touch -d @315532800 classes.dex 60 + TZ=UTC zip -X -D unsigned.apk classes.dex 60 - mkdir -p lib/arm64-v8a 61 - cp webrtc/jni/arm64-v8a/*.so lib/arm64-v8a/ 62 - touch -d @315532800 classes*.dex lib/arm64-v8a/*.so 63 - TZ=UTC zip -X -D unsigned.apk classes*.dex 64 - TZ=UTC zip -X -D -0 unsigned.apk lib/arm64-v8a/*.so 65 61 runHook postBuild 66 62 ''; 67 63 installPhase = ''
+8 -28
tools/hermes-voice/static/app.js
··· 98 98 : `conversation ${clock(total)} · ${money(total)}`; 99 99 } 100 100 101 - // Only the transcript scrolls (style.css); it follows the newest line unless 102 - // the user scrolled up. The controls above it change height after a render 103 - // (cost line, approval box), so a resize re-pins it too. A resize leaves 104 - // scrollTop alone; only moving up (the user) unpins. 105 - let logPinned = true; 106 - let logTop = 0; 107 - logList.addEventListener("scroll", () => { 108 - if (logList.scrollTop + logList.clientHeight >= logList.scrollHeight - 4) 109 - logPinned = true; 110 - else if (logList.scrollTop < logTop) logPinned = false; 111 - logTop = logList.scrollTop; 112 - }); 113 - new ResizeObserver(() => { 114 - if (logPinned) logList.scrollTop = logList.scrollHeight; 115 - }).observe(logList); 116 - 117 101 function renderLog() { 118 102 logList.replaceChildren( 119 103 ...state.lines.map((line) => { ··· 123 107 return item; 124 108 }), 125 109 ); 110 + window.scrollTo(0, document.body.scrollHeight); 126 - logList.scrollTop = logList.scrollHeight; 127 111 } 128 112 129 113 function addLine(speaker, text) { ··· 606 590 } 607 591 } 608 592 593 + talk.addEventListener("click", async () => { 594 + if (call) { 595 + talk.disabled = true; 596 + setStatus("Hanging up…"); 597 + call.close(); 598 + return; 599 + } 609 - async function startCall() { 610 600 call = new Call(); 611 601 talk.disabled = true; 612 602 talk.classList.add("live"); ··· 622 612 addLine("error", String(error.message || error)); 623 613 call.finish("failed to start"); 624 614 } 625 - } 626 - 627 - talk.addEventListener("click", () => { 628 - if (call) { 629 - talk.disabled = true; 630 - setStatus("Hanging up…"); 631 - call.close(); 632 - return; 633 - } 634 - startCall(); 635 615 }); 636 616 637 617 window.addEventListener("pagehide", () => call?.finish("page closed"));
+2 -28
tools/hermes-voice/static/style.css
··· 10 10 box-sizing: border-box; 11 11 } 12 12 13 - /* One screen high: only the transcript scrolls, so Talk/Hang up (and an 14 - approval) always stay in view. 100% of the containing block, not vh/dvh: 15 - in Chrome on Android those resolve taller than the visible area, and 16 - the document then scrolls to hide the browser bars. */ 17 - html, 18 - body { 19 - height: 100%; 20 - overflow: hidden; 21 - } 22 - 23 13 body { 24 14 margin: 0; 25 15 background: #111; ··· 31 21 32 22 main { 33 23 max-width: 40rem; 34 - height: 100%; 35 24 margin: 0 auto; 25 + padding: max(1rem, env(safe-area-inset-top)) 1rem 2rem; 36 - padding: max(1rem, env(safe-area-inset-top)) 1rem 37 - max(1rem, env(safe-area-inset-bottom)); 38 - display: flex; 39 - flex-direction: column; 40 - } 41 - 42 - main > :not(.log) { 43 - flex: none; 44 26 } 45 27 46 28 header { ··· 113 95 } 114 96 115 97 .log { 116 - flex: 1 1 0; 117 - min-height: 0; 118 - overflow-y: auto; 119 - /* A swipe past either end does not move the page. */ 120 - overscroll-behavior: contain; 121 98 list-style: none; 122 99 padding: 0; 100 + margin-top: 1.5rem; 123 - margin: 1.5rem 0 0; 124 101 } 125 102 126 103 .log li { ··· 158 135 } 159 136 160 137 .approval p { 161 - /* A long command scrolls here instead of pushing the buttons away. */ 162 - max-height: 30vh; 163 - overflow-y: auto; 164 138 margin: 0 0 0.6rem; 165 139 white-space: pre-wrap; 166 140 word-break: break-word;
-2
configs/services/matrix/bridges/heisenbridge.nix
··· 21 21 services.matrix-synapse.settings.app_service_config_files = [ 22 22 "/var/lib/heisenbridge/registration.yml" 23 23 ]; 24 - 25 - lass.backup.state.heisenbridge.folders = [ "/var/lib/heisenbridge" ]; 26 24 }
+1 -9
configs/services/matrix/bridges/mautrix-discord.nix
··· 1 + { ... }: 1 - { config, pkgs, ... }: 2 2 { 3 3 # The upstream module creates /var/lib/mautrix-discord with mode 0770 owned 4 4 # by mautrix-discord:mautrix-discord, while matrix-synapse only gets the ··· 62 62 } 63 63 ]; 64 64 }; 65 - 66 - # portal mappings, sessions, tokens: losing them means relogin and every 67 - # existing Matrix room orphaned 68 - lass.backup.state.mautrix-discord.preBackup = '' 69 - mkdir -p /var/backup/postgresql 70 - ${pkgs.sudo}/bin/sudo -u postgres ${config.services.postgresql.package}/bin/pg_dump \ 71 - mautrix-discord | ${pkgs.gzip}/bin/gzip > /var/backup/postgresql/mautrix-discord.sql.gz 72 - ''; 73 65 }
+1 -9
configs/services/matrix/bridges/mautrix-signal.nix
··· 1 + { pkgs, ... }: 1 - { config, pkgs, ... }: 2 2 { 3 3 services.mautrix-signal = { 4 4 enable = true; ··· 65 65 } 66 66 ]; 67 67 }; 68 - 69 - # portal mappings, sessions, tokens: losing them means relogin and every 70 - # existing Matrix room orphaned 71 - lass.backup.state.mautrix-signal.preBackup = '' 72 - mkdir -p /var/backup/postgresql 73 - ${pkgs.sudo}/bin/sudo -u postgres ${config.services.postgresql.package}/bin/pg_dump \ 74 - mautrix-signal | ${pkgs.gzip}/bin/gzip > /var/backup/postgresql/mautrix-signal.sql.gz 75 - ''; 76 68 }
+1 -14
configs/services/matrix/bridges/mautrix-telegram.nix
··· 1 + { self, config, ... }: 1 - { 2 - self, 3 - config, 4 - pkgs, 5 - ... 6 - }: 7 2 { 8 3 # mautrix-telegram is the Python (Telethon) bridge and pulls in python-olm 9 4 # → libolm 3.2.16, which nixpkgs marks insecure. The Go-based v2 bridges ··· 67 62 } 68 63 ]; 69 64 }; 70 - 71 - # portal mappings, sessions, tokens: losing them means relogin and every 72 - # existing Matrix room orphaned 73 - lass.backup.state.mautrix-telegram.preBackup = '' 74 - mkdir -p /var/backup/postgresql 75 - ${pkgs.sudo}/bin/sudo -u postgres ${config.services.postgresql.package}/bin/pg_dump \ 76 - mautrix-telegram | ${pkgs.gzip}/bin/gzip > /var/backup/postgresql/mautrix-telegram.sql.gz 77 - ''; 78 65 79 66 clan.core.vars.generators.mautrix-telegram = { 80 67 files.env = { };
+1 -9
configs/services/matrix/bridges/mautrix-whatsapp.nix
··· 1 + { pkgs, ... }: 1 - { config, pkgs, ... }: 2 2 { 3 3 services.mautrix-whatsapp = { 4 4 enable = true; ··· 72 72 } 73 73 ]; 74 74 }; 75 - 76 - # portal mappings, sessions, tokens: losing them means relogin and every 77 - # existing Matrix room orphaned 78 - lass.backup.state.mautrix-whatsapp.preBackup = '' 79 - mkdir -p /var/backup/postgresql 80 - ${pkgs.sudo}/bin/sudo -u postgres ${config.services.postgresql.package}/bin/pg_dump \ 81 - mautrix-whatsapp | ${pkgs.gzip}/bin/gzip > /var/backup/postgresql/mautrix-whatsapp.sql.gz 82 - ''; 83 75 }
-1
vars/per-machine/acheron/borgbackup/borgbackup.ssh.id25519.pub/value
··· 1 - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMh4k5nsPE5plrYU6QP3cExqJoVeeC8SiLHheWldSpkJ nixbld@coaxmetal
-1
vars/per-machine/coaxmetal/borgbackup/borgbackup.ssh.id25519.pub/value
··· 1 - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPy26aFabIaIZyX/NQZ/KwULO0BIN/4ky4vS95oDhkFv nixbld@coaxmetal
-1
vars/per-machine/styx/borgbackup/borgbackup.ssh.id25519.pub/value
··· 1 - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINn9AasDBQOhvezrzKYmtPpe5hnLrhHrkLgbk1WDK46F nixbld@coaxmetal
-1
vars/per-machine/yellow/borgbackup/borgbackup.ssh.id25519.pub/value
··· 1 - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIL2aPXHjb2NFCVdSUBAWIgLvv+VZPnGCgnTKkfAeSVAe nixbld@coaxmetal
-141
tools/hermes-voice/android/src/us/lassul/assist/Api.java
··· 1 - package us.lassul.assist; 2 - 3 - import android.content.Context; 4 - import android.content.SharedPreferences; 5 - import java.io.ByteArrayOutputStream; 6 - import java.io.IOException; 7 - import java.io.InputStream; 8 - import java.io.OutputStream; 9 - import java.net.HttpURLConnection; 10 - import java.net.URL; 11 - import java.nio.charset.StandardCharsets; 12 - import org.json.JSONException; 13 - import org.json.JSONObject; 14 - 15 - /** 16 - * hermes-voice's API (tools/hermes-voice/hermes_voice.py) behind assist.lassul.us/app/, where the 17 - * proxy passes the app's own bearer token through instead of asking for the web login. 18 - */ 19 - final class Api { 20 - private static final String BASE = "https://assist.lassul.us/app"; 21 - private static final String PREFS = "api"; 22 - 23 - /** An answer other than 2xx, with the server's error text. */ 24 - static final class Failure extends IOException { 25 - Failure(String message) { 26 - super(message); 27 - } 28 - } 29 - 30 - private final String token; 31 - 32 - private Api(String token) { 33 - this.token = token; 34 - } 35 - 36 - /** Null until the token is set (SetupActivity). */ 37 - static Api load(Context context) { 38 - String token = prefs(context).getString("token", null); 39 - return token == null || token.isEmpty() ? null : new Api(token); 40 - } 41 - 42 - static void saveToken(Context context, String token) { 43 - prefs(context).edit().putString("token", token.trim()).apply(); 44 - } 45 - 46 - static boolean hasToken(Context context) { 47 - return load(context) != null; 48 - } 49 - 50 - private static SharedPreferences prefs(Context context) { 51 - return context.getSharedPreferences(PREFS, Context.MODE_PRIVATE); 52 - } 53 - 54 - JSONObject get(String path) throws IOException, JSONException { 55 - HttpURLConnection connection = open(path, "GET"); 56 - try { 57 - return answer(connection); 58 - } finally { 59 - connection.disconnect(); 60 - } 61 - } 62 - 63 - JSONObject post(String path, JSONObject body) throws IOException, JSONException { 64 - HttpURLConnection connection = open(path, "POST"); 65 - try { 66 - send(connection, body); 67 - return answer(connection); 68 - } finally { 69 - connection.disconnect(); 70 - } 71 - } 72 - 73 - /** 74 - * A POST whose answer is read as a stream (/delegate's NDJSON); the caller reads and 75 - * disconnects. Throws Failure on an error answer. 76 - */ 77 - HttpURLConnection stream(String path, JSONObject body) throws IOException, JSONException { 78 - HttpURLConnection connection = open(path, "POST"); 79 - // The server writes a keepalive line every 10 s, so a long silence means it is gone. 80 - connection.setReadTimeout(60_000); 81 - send(connection, body); 82 - int status = connection.getResponseCode(); 83 - if (status / 100 != 2) { 84 - String error = errorOf(connection, status); 85 - connection.disconnect(); 86 - throw new Failure(error); 87 - } 88 - return connection; 89 - } 90 - 91 - private HttpURLConnection open(String path, String method) throws IOException { 92 - HttpURLConnection connection = (HttpURLConnection) new URL(BASE + path).openConnection(); 93 - connection.setRequestMethod(method); 94 - connection.setRequestProperty("Authorization", "Bearer " + token); 95 - connection.setConnectTimeout(10_000); 96 - connection.setReadTimeout(30_000); 97 - return connection; 98 - } 99 - 100 - private static void send(HttpURLConnection connection, JSONObject body) throws IOException { 101 - byte[] bytes = body.toString().getBytes(StandardCharsets.UTF_8); 102 - connection.setDoOutput(true); 103 - connection.setRequestProperty("Content-Type", "application/json"); 104 - connection.setFixedLengthStreamingMode(bytes.length); 105 - try (OutputStream out = connection.getOutputStream()) { 106 - out.write(bytes); 107 - } 108 - } 109 - 110 - private static JSONObject answer(HttpURLConnection connection) 111 - throws IOException, JSONException { 112 - int status = connection.getResponseCode(); 113 - if (status / 100 != 2) { 114 - throw new Failure(errorOf(connection, status)); 115 - } 116 - return new JSONObject(read(connection.getInputStream())); 117 - } 118 - 119 - private static String errorOf(HttpURLConnection connection, int status) { 120 - try { 121 - InputStream stream = connection.getErrorStream(); 122 - if (stream != null) { 123 - return new JSONObject(read(stream)).optString("error", "HTTP " + status); 124 - } 125 - } catch (IOException | JSONException ignored) { 126 - // The status says enough. 127 - } 128 - return "HTTP " + status; 129 - } 130 - 131 - private static String read(InputStream stream) throws IOException { 132 - ByteArrayOutputStream out = new ByteArrayOutputStream(); 133 - byte[] buffer = new byte[8192]; 134 - try (InputStream in = stream) { 135 - for (int n; (n = in.read(buffer)) > 0; ) { 136 - out.write(buffer, 0, n); 137 - } 138 - } 139 - return new String(out.toByteArray(), StandardCharsets.UTF_8); 140 - } 141 - }
+86 -10
tools/hermes-voice/android/src/us/lassul/assist/AssistActivity.java
··· 1 1 package us.lassul.assist; 2 2 3 3 import android.app.Activity; 4 + import android.content.ComponentName; 4 5 import android.content.Intent; 6 + import android.content.pm.PackageManager; 7 + import android.content.pm.ResolveInfo; 8 + import android.net.Uri; 9 + import android.os.Bundle; 10 + import androidx.browser.customtabs.CustomTabsClient; 11 + import androidx.browser.customtabs.CustomTabsIntent; 12 + import androidx.browser.customtabs.CustomTabsService; 13 + import androidx.browser.customtabs.CustomTabsServiceConnection; 14 + import androidx.browser.customtabs.CustomTabsSession; 15 + import androidx.browser.trusted.TrustedWebActivityIntentBuilder; 5 16 17 + /** Opens the voice page as a Trusted Web Activity, else as a Custom Tab or in the browser. */ 6 - /** 7 - * The headset's voice button (VOICE_COMMAND) and the assist gesture: starts a call and vanishes. 8 - * Starting it here, in onResume, is what lets CallService run in the foreground with the 9 - * microphone: Android allows that only while the app is in front, which this activity is for a 10 - * moment, over the lock screen too (showWhenLocked, turnScreenOn in the manifest). 11 - */ 12 18 public class AssistActivity extends Activity { 19 + private static final Uri URL = Uri.parse("https://assist.lassul.us/"); 20 + 21 + private CustomTabsServiceConnection connection; 22 + private boolean launched; 23 + 13 24 @Override 25 + protected void onCreate(Bundle state) { 26 + super.onCreate(state); 27 + String provider = twaProvider(); 28 + if (provider == null) { 29 + openCustomTab(); 30 + return; 31 + } 32 + connection = new CustomTabsServiceConnection() { 33 + @Override 34 + public void onCustomTabsServiceConnected(ComponentName name, CustomTabsClient client) { 35 + if (launched) { 36 + return; 37 + } 38 + client.warmup(0); 39 + CustomTabsSession session = client.newSession(null); 40 + if (session == null) { 41 + openCustomTab(); 42 + return; 43 + } 44 + launch(new TrustedWebActivityIntentBuilder(URL).build(session).getIntent()); 45 + } 46 + 47 + @Override 48 + public void onServiceDisconnected(ComponentName name) {} 49 + }; 50 + if (!CustomTabsClient.bindCustomTabsService(this, provider, connection)) { 51 + connection = null; 52 + openCustomTab(); 53 + } 54 + } 55 + 56 + /** The first browser offering Trusted Web Activities, null if none does. */ 57 + private String twaProvider() { 58 + Intent intent = new Intent(CustomTabsService.ACTION_CUSTOM_TABS_CONNECTION); 59 + for (ResolveInfo info : 60 + getPackageManager().queryIntentServices(intent, PackageManager.GET_RESOLVED_FILTER)) { 61 + if (info.filter != null 62 + && info.filter.hasCategory(CustomTabsService.TRUSTED_WEB_ACTIVITY_CATEGORY)) { 63 + return info.serviceInfo.packageName; 64 + } 65 + } 66 + return null; 67 + } 68 + 69 + /** A Custom Tab where supported; other browsers ignore its extras. */ 70 + private void openCustomTab() { 71 + launch(new CustomTabsIntent.Builder().build().intent.setData(URL)); 72 + } 73 + 74 + private void launch(Intent intent) { 75 + launched = true; 76 + startActivity(intent); 77 + } 78 + 79 + // Back from the page: this activity only sits below it in the task. 80 + @Override 81 + protected void onRestart() { 82 + super.onRestart(); 83 + if (launched) { 84 + finish(); 85 + } 86 + } 87 + 88 + @Override 89 + protected void onDestroy() { 90 + if (connection != null) { 91 + unbindService(connection); 92 + } 93 + super.onDestroy(); 14 - protected void onResume() { 15 - super.onResume(); 16 - startForegroundService(new Intent(this, CallService.class).setAction(CallService.ACTION_START)); 17 - finish(); 18 94 } 19 95 }
-303
tools/hermes-voice/android/src/us/lassul/assist/CallService.java
··· 1 - package us.lassul.assist; 2 - 3 - import android.Manifest; 4 - import android.app.Notification; 5 - import android.app.NotificationChannel; 6 - import android.app.NotificationManager; 7 - import android.app.PendingIntent; 8 - import android.app.Person; 9 - import android.app.Service; 10 - import android.content.ComponentName; 11 - import android.content.Intent; 12 - import android.content.pm.PackageManager; 13 - import android.content.pm.ServiceInfo; 14 - import android.media.AudioManager; 15 - import android.media.ToneGenerator; 16 - import android.net.Uri; 17 - import android.os.Bundle; 18 - import android.os.Handler; 19 - import android.os.IBinder; 20 - import android.os.Looper; 21 - import android.os.OutcomeReceiver; 22 - import android.telecom.CallAttributes; 23 - import android.telecom.CallControl; 24 - import android.telecom.CallControlCallback; 25 - import android.telecom.CallEndpoint; 26 - import android.telecom.CallEventCallback; 27 - import android.telecom.CallException; 28 - import android.telecom.DisconnectCause; 29 - import android.telecom.PhoneAccount; 30 - import android.telecom.PhoneAccountHandle; 31 - import android.telecom.TelecomManager; 32 - import android.util.Log; 33 - import java.util.List; 34 - import java.util.function.Consumer; 35 - 36 - /** 37 - * One call at a time, without a screen: a VoiceCall held as a self-managed Telecom call, so the 38 - * audio goes to the Bluetooth headset and its hang-up button (AT+CHUP) ends the call. In the 39 - * foreground with the microphone; stops when the call ends. A tone says the voice listens (or 40 - * that the call failed). 41 - */ 42 - public class CallService extends Service { 43 - static final String TAG = "Assist"; 44 - static final String ACTION_START = "us.lassul.assist.START"; 45 - static final String ACTION_HANG_UP = "us.lassul.assist.HANG_UP"; 46 - private static final String CHANNEL = "call"; 47 - private static final int NOTIFICATION = 1; 48 - 49 - private final Handler main = new Handler(Looper.getMainLooper()); 50 - private PhoneAccountHandle account; 51 - private CallControl telecom; 52 - private VoiceCall voice; 53 - // What the main screen shows (main thread); null without a call. 54 - private static String state; 55 - 56 - static String state() { 57 - return state; 58 - } 59 - 60 - private static void setState(String next) { 61 - state = next; 62 - Live.changed(); 63 - } 64 - 65 - @Override 66 - public void onCreate() { 67 - super.onCreate(); 68 - getSystemService(NotificationManager.class).createNotificationChannel( 69 - new NotificationChannel(CHANNEL, "Voice call", NotificationManager.IMPORTANCE_LOW)); 70 - account = new PhoneAccountHandle(new ComponentName(this, CallService.class), "assist"); 71 - getSystemService(TelecomManager.class).registerPhoneAccount( 72 - PhoneAccount.builder(account, "Assist") 73 - .setCapabilities(PhoneAccount.CAPABILITY_SELF_MANAGED 74 - | PhoneAccount.CAPABILITY_SUPPORTS_TRANSACTIONAL_OPERATIONS) 75 - .setSupportedUriSchemes(List.of(PhoneAccount.SCHEME_SIP)) 76 - .build()); 77 - } 78 - 79 - @Override 80 - public int onStartCommand(Intent intent, int flags, int startId) { 81 - String action = intent != null ? intent.getAction() : null; 82 - if (ACTION_HANG_UP.equals(action)) { 83 - if (voice != null) { 84 - voice.close(); 85 - } else { 86 - stopSelf(); 87 - } 88 - return START_NOT_STICKY; 89 - } 90 - if (voice != null) { 91 - // A call runs already: the button press changes nothing. 92 - return START_NOT_STICKY; 93 - } 94 - Api api = Api.load(this); 95 - if (api == null 96 - || checkSelfPermission(Manifest.permission.RECORD_AUDIO) 97 - != PackageManager.PERMISSION_GRANTED) { 98 - // startForegroundService() needs startForeground(); the microphone type would be 99 - // refused without the permission. 100 - startForeground(NOTIFICATION, setupNotification(), 101 - ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL); 102 - Log.w(TAG, "not set up: open Assist"); 103 - tone(ToneGenerator.TONE_PROP_NACK); 104 - main.postDelayed(new Runnable() { 105 - @Override 106 - public void run() { 107 - stopSelf(); 108 - } 109 - }, 1000); 110 - return START_NOT_STICKY; 111 - } 112 - startForeground(NOTIFICATION, callNotification(), 113 - ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL 114 - | ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE); 115 - setState("Connecting…"); 116 - voice = new VoiceCall(this, api, Conversation.load(this), new VoiceCall.Listener() { 117 - @Override 118 - public void onListening() { 119 - setState("Listening"); 120 - tone(ToneGenerator.TONE_PROP_ACK); 121 - } 122 - 123 - @Override 124 - public void onFinished(String reason, boolean failed) { 125 - voice = null; 126 - setState(null); 127 - if (failed) { 128 - tone(ToneGenerator.TONE_PROP_NACK); 129 - } 130 - disconnectTelecom(); 131 - // Let a failure tone play before the service, and with it the call audio, goes. 132 - main.postDelayed(new Runnable() { 133 - @Override 134 - public void run() { 135 - if (voice == null) { 136 - stopSelf(); 137 - } 138 - } 139 - }, failed ? 1000 : 0); 140 - } 141 - }); 142 - addTelecomCall(); 143 - return START_NOT_STICKY; 144 - } 145 - 146 - @Override 147 - public void onDestroy() { 148 - main.removeCallbacksAndMessages(null); 149 - if (voice != null) { 150 - voice.close(); 151 - } 152 - disconnectTelecom(); 153 - super.onDestroy(); 154 - } 155 - 156 - @Override 157 - public IBinder onBind(Intent intent) { 158 - return null; 159 - } 160 - 161 - /** The voice starts once Telecom has the call (audio routed), or without it if refused. */ 162 - private void addTelecomCall() { 163 - CallAttributes attributes = new CallAttributes.Builder( 164 - account, 165 - CallAttributes.DIRECTION_OUTGOING, 166 - "Hermes", 167 - Uri.fromParts(PhoneAccount.SCHEME_SIP, "hermes@assist.lassul.us", null)) 168 - .setCallType(CallAttributes.AUDIO_CALL) 169 - .build(); 170 - getSystemService(TelecomManager.class).addCall( 171 - attributes, 172 - getMainExecutor(), 173 - new OutcomeReceiver<CallControl, CallException>() { 174 - @Override 175 - public void onResult(CallControl control) { 176 - if (voice == null) { 177 - control.disconnect(new DisconnectCause(DisconnectCause.LOCAL), 178 - getMainExecutor(), logged("disconnect")); 179 - return; 180 - } 181 - telecom = control; 182 - control.setActive(getMainExecutor(), logged("setActive")); 183 - voice.start(); 184 - } 185 - 186 - @Override 187 - public void onError(CallException error) { 188 - Log.w(TAG, "telecom refused the call; the headset cannot hang up", error); 189 - if (voice != null) { 190 - voice.start(); 191 - } 192 - } 193 - }, 194 - new Control(), 195 - new Events()); 196 - } 197 - 198 - private void disconnectTelecom() { 199 - if (telecom != null) { 200 - telecom.disconnect(new DisconnectCause(DisconnectCause.LOCAL), getMainExecutor(), 201 - logged("disconnect")); 202 - telecom = null; 203 - } 204 - } 205 - 206 - private void tone(int tone) { 207 - final ToneGenerator generator = new ToneGenerator(AudioManager.STREAM_VOICE_CALL, 80); 208 - generator.startTone(tone, 300); 209 - main.postDelayed(new Runnable() { 210 - @Override 211 - public void run() { 212 - generator.release(); 213 - } 214 - }, 500); 215 - } 216 - 217 - private Notification callNotification() { 218 - PendingIntent hangUp = PendingIntent.getService(this, 0, 219 - new Intent(this, CallService.class).setAction(ACTION_HANG_UP), 220 - PendingIntent.FLAG_IMMUTABLE); 221 - return new Notification.Builder(this, CHANNEL) 222 - .setSmallIcon(android.R.drawable.sym_action_call) 223 - .setStyle(Notification.CallStyle.forOngoingCall( 224 - new Person.Builder().setName("Hermes").build(), hangUp)) 225 - .setOngoing(true) 226 - .build(); 227 - } 228 - 229 - private Notification setupNotification() { 230 - PendingIntent setup = PendingIntent.getActivity(this, 0, 231 - new Intent(this, SettingsActivity.class), PendingIntent.FLAG_IMMUTABLE); 232 - return new Notification.Builder(this, CHANNEL) 233 - .setSmallIcon(android.R.drawable.stat_notify_error) 234 - .setContentTitle("Assist is not set up") 235 - .setContentText("Open it to enter the token and allow the microphone") 236 - .setContentIntent(setup) 237 - .build(); 238 - } 239 - 240 - private static OutcomeReceiver<Void, CallException> logged(final String what) { 241 - return new OutcomeReceiver<Void, CallException>() { 242 - @Override 243 - public void onResult(Void result) {} 244 - 245 - @Override 246 - public void onError(CallException error) { 247 - Log.w(TAG, what + " failed", error); 248 - } 249 - }; 250 - } 251 - 252 - private final class Control implements CallControlCallback { 253 - @Override 254 - public void onSetActive(Consumer<Boolean> done) { 255 - done.accept(true); 256 - } 257 - 258 - @Override 259 - public void onSetInactive(Consumer<Boolean> done) { 260 - done.accept(true); 261 - } 262 - 263 - @Override 264 - public void onAnswer(int videoState, Consumer<Boolean> done) { 265 - done.accept(true); 266 - } 267 - 268 - // Telecom ended the call: the headset hung up (AT+CHUP) or another call took over. 269 - @Override 270 - public void onDisconnect(DisconnectCause cause, Consumer<Boolean> done) { 271 - Log.i(TAG, "telecom disconnect: " + cause); 272 - done.accept(true); 273 - telecom = null; 274 - if (voice != null) { 275 - voice.close(); 276 - } 277 - } 278 - 279 - @Override 280 - public void onCallStreamingStarted(Consumer<Boolean> done) { 281 - done.accept(false); 282 - } 283 - } 284 - 285 - private static final class Events implements CallEventCallback { 286 - @Override 287 - public void onCallEndpointChanged(CallEndpoint endpoint) { 288 - Log.i(TAG, "audio to " + endpoint); 289 - } 290 - 291 - @Override 292 - public void onAvailableCallEndpointsChanged(List<CallEndpoint> endpoints) {} 293 - 294 - @Override 295 - public void onMuteStateChanged(boolean muted) {} 296 - 297 - @Override 298 - public void onCallStreamingFailed(int reason) {} 299 - 300 - @Override 301 - public void onEvent(String event, Bundle extras) {} 302 - } 303 - }
-270
tools/hermes-voice/android/src/us/lassul/assist/Conversation.java
··· 1 - package us.lassul.assist; 2 - 3 - import android.content.Context; 4 - import android.content.SharedPreferences; 5 - import android.graphics.Color; 6 - import android.graphics.Typeface; 7 - import android.text.SpannableStringBuilder; 8 - import android.text.Spanned; 9 - import android.text.style.ForegroundColorSpan; 10 - import android.text.style.StyleSpan; 11 - import android.util.AtomicFile; 12 - import android.util.Log; 13 - import java.io.File; 14 - import java.io.FileOutputStream; 15 - import java.io.IOException; 16 - import java.nio.charset.StandardCharsets; 17 - import java.security.SecureRandom; 18 - import java.text.SimpleDateFormat; 19 - import java.util.ArrayList; 20 - import java.util.Arrays; 21 - import java.util.Collections; 22 - import java.util.Comparator; 23 - import java.util.Date; 24 - import java.util.List; 25 - import java.util.Locale; 26 - import java.util.TimeZone; 27 - import org.json.JSONArray; 28 - import org.json.JSONException; 29 - import org.json.JSONObject; 30 - 31 - /** 32 - * A conversation with Hermes across calls, like the web page's localStorage state: the 33 - * transcript, how much of it went to Hermes already, and Hermes's session id. Every conversation 34 - * is a file in conversations/; the current one continues with the next call unless the last call 35 - * is over an hour old or "New conversation" was chosen. 36 - */ 37 - final class Conversation { 38 - static final int ACCENT = Color.parseColor("#4fb477"); 39 - static final int LIVE = Color.parseColor("#d9534f"); 40 - static final int DIM = Color.parseColor("#8a8a8a"); 41 - private static final long NEW_AFTER_MS = 60 * 60 * 1000; 42 - private static final String PREFS = "conversation"; 43 - 44 - static final class Line { 45 - final String speaker; 46 - String text; 47 - 48 - Line(String speaker, String text) { 49 - this.speaker = speaker; 50 - this.text = text; 51 - } 52 - } 53 - 54 - final String hermesSession; 55 - final long started; 56 - final List<Line> lines = new ArrayList<>(); 57 - // Lines before this index went to Hermes (delegation or hang-up) already. 58 - int delegated; 59 - double billedSeconds; 60 - private final Context context; 61 - 62 - private Conversation(Context context, String hermesSession, long started) { 63 - this.context = context.getApplicationContext(); 64 - this.hermesSession = hermesSession; 65 - this.started = started; 66 - } 67 - 68 - /** The conversation the next call continues. */ 69 - static Conversation load(Context context) { 70 - SharedPreferences prefs = prefs(context); 71 - String current = prefs.getString("current", null); 72 - if (current != null 73 - && System.currentTimeMillis() - prefs.getLong("last_used", 0) < NEW_AFTER_MS) { 74 - Conversation saved = read(context, current); 75 - if (saved != null) { 76 - return saved; 77 - } 78 - } 79 - return new Conversation(context, newSessionId(), System.currentTimeMillis()); 80 - } 81 - 82 - /** The conversation shown on the main screen: the current one, however old; null if none. */ 83 - static Conversation current(Context context) { 84 - String current = prefs(context).getString("current", null); 85 - return current == null ? null : read(context, current); 86 - } 87 - 88 - /** The next call starts a new conversation (and Hermes session); the old one stays listed. */ 89 - static void startNew(Context context) { 90 - prefs(context).edit().remove("current").apply(); 91 - Live.changed(); 92 - } 93 - 94 - /** All conversations, newest first. */ 95 - static List<Conversation> all(Context context) { 96 - File[] files = directory(context).listFiles(); 97 - List<Conversation> out = new ArrayList<>(); 98 - for (File file : files == null ? new File[0] : files) { 99 - String name = file.getName(); 100 - if (name.endsWith(".json")) { 101 - Conversation conversation = 102 - read(context, name.substring(0, name.length() - ".json".length())); 103 - if (conversation != null) { 104 - out.add(conversation); 105 - } 106 - } 107 - } 108 - Collections.sort(out, new Comparator<Conversation>() { 109 - @Override 110 - public int compare(Conversation a, Conversation b) { 111 - return Long.compare(b.started, a.started); 112 - } 113 - }); 114 - return out; 115 - } 116 - 117 - static Conversation read(Context context, String hermesSession) { 118 - File file = file(context, hermesSession); 119 - if (!file.exists()) { 120 - return null; 121 - } 122 - try { 123 - JSONObject state = new JSONObject( 124 - new String(new AtomicFile(file).readFully(), StandardCharsets.UTF_8)); 125 - Conversation conversation = new Conversation(context, hermesSession, 126 - state.optLong("started", file.lastModified())); 127 - JSONArray lines = state.getJSONArray("lines"); 128 - for (int i = 0; i < lines.length(); i++) { 129 - JSONObject line = lines.getJSONObject(i); 130 - conversation.lines.add(new Line(line.getString("speaker"), line.getString("text"))); 131 - } 132 - conversation.delegated = state.optInt("delegated", conversation.lines.size()); 133 - conversation.billedSeconds = state.optDouble("billed_seconds", 0); 134 - return conversation; 135 - } catch (IOException | JSONException error) { 136 - Log.w(CallService.TAG, "unreadable conversation " + hermesSession, error); 137 - return null; 138 - } 139 - } 140 - 141 - /** voice-YYYYMMDD-<hex>, like the web page's. */ 142 - private static String newSessionId() { 143 - SimpleDateFormat day = new SimpleDateFormat("yyyyMMdd", Locale.ROOT); 144 - day.setTimeZone(TimeZone.getTimeZone("UTC")); 145 - String random = Integer.toHexString(new SecureRandom().nextInt()); 146 - return "voice-" + day.format(new Date()) + "-" + random; 147 - } 148 - 149 - void save() { 150 - AtomicFile file = new AtomicFile(file(context, hermesSession)); 151 - FileOutputStream out = null; 152 - try { 153 - JSONObject state = new JSONObject() 154 - .put("started", started) 155 - .put("lines", toJson(lines)) 156 - .put("delegated", delegated) 157 - .put("billed_seconds", billedSeconds); 158 - directory(context).mkdirs(); 159 - out = file.startWrite(); 160 - out.write(state.toString().getBytes(StandardCharsets.UTF_8)); 161 - file.finishWrite(out); 162 - } catch (IOException | JSONException error) { 163 - file.failWrite(out); 164 - Log.w(CallService.TAG, "conversation not saved", error); 165 - return; 166 - } 167 - prefs(context).edit() 168 - .putString("current", hermesSession) 169 - .putLong("last_used", System.currentTimeMillis()) 170 - .apply(); 171 - Live.changed(); 172 - } 173 - 174 - void add(String speaker, String text) { 175 - lines.add(new Line(speaker, text)); 176 - save(); 177 - } 178 - 179 - /** Lines since the last hand-over to Hermes, of the given speakers. */ 180 - List<Line> undelegated(String... speakers) { 181 - List<Line> out = new ArrayList<>(); 182 - List<String> wanted = Arrays.asList(speakers); 183 - for (Line line : lines.subList(Math.min(delegated, lines.size()), lines.size())) { 184 - if (wanted.contains(line.speaker)) { 185 - out.add(line); 186 - } 187 - } 188 - return out; 189 - } 190 - 191 - /** The first thing the user said, for the session list. */ 192 - String title() { 193 - for (Line line : lines) { 194 - if (line.speaker.equals("user") && !line.text.trim().isEmpty()) { 195 - return line.text.trim(); 196 - } 197 - } 198 - return "(nothing said)"; 199 - } 200 - 201 - /** The transcript as the web page shows it: colored speaker labels, Hermes dimmed. */ 202 - CharSequence render() { 203 - SpannableStringBuilder out = new SpannableStringBuilder(); 204 - for (Line line : lines) { 205 - String text = line.text.trim(); 206 - if (text.isEmpty()) { 207 - continue; 208 - } 209 - if (out.length() > 0) { 210 - out.append("\n\n"); 211 - } 212 - String label; 213 - int labelColor; 214 - int textColor = 0; 215 - switch (line.speaker) { 216 - case "user": 217 - label = "you: "; 218 - labelColor = ACCENT; 219 - break; 220 - case "assistant": 221 - label = "voice: "; 222 - labelColor = DIM; 223 - break; 224 - case "error": 225 - label = "error: "; 226 - labelColor = LIVE; 227 - textColor = LIVE; 228 - break; 229 - default: 230 - label = line.speaker + ": "; 231 - labelColor = ACCENT; 232 - textColor = DIM; 233 - break; 234 - } 235 - int start = out.length(); 236 - out.append(label); 237 - out.setSpan(new ForegroundColorSpan(labelColor), start, out.length(), 238 - Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 239 - out.setSpan(new StyleSpan(Typeface.BOLD), start, out.length(), 240 - Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 241 - int body = out.length(); 242 - out.append(text); 243 - if (textColor != 0) { 244 - out.setSpan(new ForegroundColorSpan(textColor), body, out.length(), 245 - Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 246 - } 247 - } 248 - return out; 249 - } 250 - 251 - static JSONArray toJson(List<Line> lines) throws JSONException { 252 - JSONArray out = new JSONArray(); 253 - for (Line line : lines) { 254 - out.put(new JSONObject().put("speaker", line.speaker).put("text", line.text)); 255 - } 256 - return out; 257 - } 258 - 259 - private static SharedPreferences prefs(Context context) { 260 - return context.getSharedPreferences(PREFS, Context.MODE_PRIVATE); 261 - } 262 - 263 - private static File directory(Context context) { 264 - return new File(context.getFilesDir(), "conversations"); 265 - } 266 - 267 - private static File file(Context context, String hermesSession) { 268 - return new File(directory(context), hermesSession + ".json"); 269 - } 270 - }
-28
tools/hermes-voice/android/src/us/lassul/assist/Live.java
··· 1 - package us.lassul.assist; 2 - 3 - import java.util.ArrayList; 4 - import java.util.List; 5 - 6 - /** 7 - * Tells the screens that the call state or a conversation changed, so they redraw while a call 8 - * runs. Main thread only; the app's screens and CallService share the process. 9 - */ 10 - final class Live { 11 - private static final List<Runnable> watchers = new ArrayList<>(); 12 - 13 - private Live() {} 14 - 15 - static void watch(Runnable watcher) { 16 - watchers.add(watcher); 17 - } 18 - 19 - static void unwatch(Runnable watcher) { 20 - watchers.remove(watcher); 21 - } 22 - 23 - static void changed() { 24 - for (Runnable watcher : new ArrayList<>(watchers)) { 25 - watcher.run(); 26 - } 27 - } 28 - }
-166
tools/hermes-voice/android/src/us/lassul/assist/MainActivity.java
··· 1 - package us.lassul.assist; 2 - 3 - import android.Manifest; 4 - import android.app.Activity; 5 - import android.content.Intent; 6 - import android.content.pm.PackageManager; 7 - import android.content.res.ColorStateList; 8 - import android.graphics.Typeface; 9 - import android.graphics.drawable.GradientDrawable; 10 - import android.os.Bundle; 11 - import android.view.Gravity; 12 - import android.view.Menu; 13 - import android.view.MenuItem; 14 - import android.view.View; 15 - import android.widget.Button; 16 - import android.widget.LinearLayout; 17 - import android.widget.ScrollView; 18 - import android.widget.TextView; 19 - import android.widget.Toast; 20 - 21 - /** 22 - * The launcher screen: the current conversation, live during a call, and a Call / Hang up 23 - * button. New conversation, the session list and the settings are in the menu. 24 - */ 25 - public class MainActivity extends Activity { 26 - private static final int NEW_CONVERSATION = 1; 27 - private static final int SESSIONS = 2; 28 - private static final int SETTINGS = 3; 29 - 30 - private TextView transcript; 31 - private ColorStateList textColor; 32 - private ScrollView scroll; 33 - private TextView status; 34 - private Button call; 35 - private final Runnable redraw = new Runnable() { 36 - @Override 37 - public void run() { 38 - refresh(); 39 - } 40 - }; 41 - 42 - @Override 43 - protected void onCreate(Bundle state) { 44 - super.onCreate(state); 45 - float dp = getResources().getDisplayMetrics().density; 46 - LinearLayout layout = new LinearLayout(this); 47 - layout.setOrientation(LinearLayout.VERTICAL); 48 - Ui.fitBars(this, layout, Ui.dp(this, 16)); 49 - 50 - transcript = new TextView(this); 51 - transcript.setTextSize(16); 52 - transcript.setLineSpacing(0, 1.15f); 53 - transcript.setTextIsSelectable(true); 54 - textColor = transcript.getTextColors(); 55 - scroll = new ScrollView(this); 56 - scroll.addView(transcript); 57 - layout.addView(scroll, 58 - new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 0, 1)); 59 - 60 - status = new TextView(this); 61 - status.setGravity(Gravity.CENTER); 62 - status.setTextColor(Conversation.DIM); 63 - status.setPadding(0, (int) (12 * dp), 0, (int) (12 * dp)); 64 - layout.addView(status); 65 - 66 - call = new Button(this); 67 - call.setTextColor(0xffffffff); 68 - call.setTextSize(20); 69 - call.setTypeface(Typeface.DEFAULT_BOLD); 70 - call.setAllCaps(false); 71 - call.setOnClickListener(new View.OnClickListener() { 72 - @Override 73 - public void onClick(View view) { 74 - if (CallService.state() != null) { 75 - startService(new Intent(MainActivity.this, CallService.class) 76 - .setAction(CallService.ACTION_HANG_UP)); 77 - } else if (!ready()) { 78 - startActivity(new Intent(MainActivity.this, SettingsActivity.class)); 79 - } else { 80 - startForegroundService(new Intent(MainActivity.this, CallService.class) 81 - .setAction(CallService.ACTION_START)); 82 - } 83 - } 84 - }); 85 - layout.addView(call, new LinearLayout.LayoutParams( 86 - LinearLayout.LayoutParams.MATCH_PARENT, (int) (64 * dp))); 87 - setContentView(layout); 88 - } 89 - 90 - @Override 91 - protected void onResume() { 92 - super.onResume(); 93 - Live.watch(redraw); 94 - refresh(); 95 - } 96 - 97 - @Override 98 - protected void onPause() { 99 - Live.unwatch(redraw); 100 - super.onPause(); 101 - } 102 - 103 - @Override 104 - public boolean onCreateOptionsMenu(Menu menu) { 105 - menu.add(Menu.NONE, NEW_CONVERSATION, 0, "New conversation"); 106 - menu.add(Menu.NONE, SESSIONS, 1, "Sessions"); 107 - menu.add(Menu.NONE, SETTINGS, 2, "Settings"); 108 - return true; 109 - } 110 - 111 - @Override 112 - public boolean onOptionsItemSelected(MenuItem item) { 113 - switch (item.getItemId()) { 114 - case NEW_CONVERSATION: 115 - if (CallService.state() != null) { 116 - // The running call would save its conversation right back. 117 - Toast.makeText(this, "Hang up first", Toast.LENGTH_SHORT).show(); 118 - } else { 119 - Conversation.startNew(this); 120 - } 121 - return true; 122 - case SESSIONS: 123 - startActivity(new Intent(this, SessionsActivity.class)); 124 - return true; 125 - case SETTINGS: 126 - startActivity(new Intent(this, SettingsActivity.class)); 127 - return true; 128 - default: 129 - return super.onOptionsItemSelected(item); 130 - } 131 - } 132 - 133 - private boolean ready() { 134 - return Api.hasToken(this) 135 - && checkSelfPermission(Manifest.permission.RECORD_AUDIO) 136 - == PackageManager.PERMISSION_GRANTED; 137 - } 138 - 139 - private void refresh() { 140 - String state = CallService.state(); 141 - boolean ready = ready(); 142 - GradientDrawable background = new GradientDrawable(); 143 - background.setCornerRadius(32 * getResources().getDisplayMetrics().density); 144 - background.setColor(state != null ? Conversation.LIVE : Conversation.ACCENT); 145 - call.setBackground(background); 146 - call.setText(state != null ? "Hang up" : ready ? "Call" : "Set up"); 147 - status.setText(state != null 148 - ? state 149 - : ready ? "Hold the headset's voice button, or tap Call" : "Not set up yet"); 150 - 151 - Conversation current = Conversation.current(this); 152 - if (current == null || current.lines.isEmpty()) { 153 - transcript.setText("New conversation. What you and Hermes say shows up here."); 154 - transcript.setTextColor(Conversation.DIM); 155 - } else { 156 - transcript.setText(current.render()); 157 - transcript.setTextColor(textColor); 158 - } 159 - scroll.post(new Runnable() { 160 - @Override 161 - public void run() { 162 - scroll.fullScroll(View.FOCUS_DOWN); 163 - } 164 - }); 165 - } 166 - }
-95
tools/hermes-voice/android/src/us/lassul/assist/SessionsActivity.java
··· 1 - package us.lassul.assist; 2 - 3 - import android.app.Activity; 4 - import android.content.Intent; 5 - import android.os.Bundle; 6 - import android.text.format.DateUtils; 7 - import android.view.Gravity; 8 - import android.view.View; 9 - import android.view.ViewGroup; 10 - import android.widget.AdapterView; 11 - import android.widget.ArrayAdapter; 12 - import android.widget.FrameLayout; 13 - import android.widget.ListView; 14 - import android.widget.TextView; 15 - import java.util.List; 16 - 17 - /** All conversations, newest first; one opens its transcript. */ 18 - public class SessionsActivity extends Activity { 19 - private ListView list; 20 - private final Runnable redraw = new Runnable() { 21 - @Override 22 - public void run() { 23 - refresh(); 24 - } 25 - }; 26 - 27 - @Override 28 - protected void onCreate(Bundle state) { 29 - super.onCreate(state); 30 - getActionBar().setDisplayHomeAsUpEnabled(true); 31 - list = new ListView(this); 32 - list.setOnItemClickListener(new AdapterView.OnItemClickListener() { 33 - @Override 34 - public void onItemClick(AdapterView<?> parent, View view, int position, long id) { 35 - Conversation conversation = (Conversation) parent.getItemAtPosition(position); 36 - startActivity(new Intent(SessionsActivity.this, TranscriptActivity.class) 37 - .putExtra(TranscriptActivity.SESSION, conversation.hermesSession)); 38 - } 39 - }); 40 - TextView empty = new TextView(this); 41 - empty.setText("No conversations yet."); 42 - empty.setTextColor(Conversation.DIM); 43 - empty.setGravity(Gravity.CENTER); 44 - FrameLayout root = new FrameLayout(this); 45 - root.addView(list); 46 - root.addView(empty); 47 - list.setEmptyView(empty); 48 - Ui.fitBars(this, root, 0); 49 - setContentView(root); 50 - } 51 - 52 - @Override 53 - protected void onResume() { 54 - super.onResume(); 55 - Live.watch(redraw); 56 - refresh(); 57 - } 58 - 59 - @Override 60 - protected void onPause() { 61 - Live.unwatch(redraw); 62 - super.onPause(); 63 - } 64 - 65 - @Override 66 - public boolean onNavigateUp() { 67 - finish(); 68 - return true; 69 - } 70 - 71 - private void refresh() { 72 - List<Conversation> conversations = Conversation.all(this); 73 - Conversation current = Conversation.current(this); 74 - final String currentSession = current == null ? null : current.hermesSession; 75 - list.setAdapter(new ArrayAdapter<Conversation>( 76 - this, android.R.layout.simple_list_item_2, android.R.id.text1, conversations) { 77 - @Override 78 - public View getView(int position, View convertView, ViewGroup parent) { 79 - View row = super.getView(position, convertView, parent); 80 - Conversation conversation = getItem(position); 81 - String when = DateUtils.formatDateTime(getContext(), conversation.started, 82 - DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_SHOW_TIME 83 - | DateUtils.FORMAT_ABBREV_MONTH); 84 - ((TextView) row.findViewById(android.R.id.text1)).setText( 85 - conversation.hermesSession.equals(currentSession) 86 - ? when + " · current" 87 - : when); 88 - TextView title = row.findViewById(android.R.id.text2); 89 - title.setText(conversation.title()); 90 - title.setMaxLines(2); 91 - return row; 92 - } 93 - }); 94 - } 95 - }
-110
tools/hermes-voice/android/src/us/lassul/assist/SettingsActivity.java
··· 1 - package us.lassul.assist; 2 - 3 - import android.Manifest; 4 - import android.app.Activity; 5 - import android.content.pm.PackageManager; 6 - import android.os.Bundle; 7 - import android.text.InputType; 8 - import android.view.View; 9 - import android.widget.Button; 10 - import android.widget.EditText; 11 - import android.widget.LinearLayout; 12 - import android.widget.ScrollView; 13 - import android.widget.TextView; 14 - 15 - /** 16 - * Setup: the app token (`clan vars get coaxmetal hermes-voice-app/token`) and the microphone and 17 - * notification permissions. 18 - */ 19 - public class SettingsActivity extends Activity { 20 - private TextView status; 21 - private EditText token; 22 - private Button permissions; 23 - 24 - @Override 25 - protected void onCreate(Bundle state) { 26 - super.onCreate(state); 27 - getActionBar().setDisplayHomeAsUpEnabled(true); 28 - int pad = (int) (16 * getResources().getDisplayMetrics().density); 29 - LinearLayout layout = new LinearLayout(this); 30 - layout.setOrientation(LinearLayout.VERTICAL); 31 - 32 - status = new TextView(this); 33 - status.setPadding(0, 0, 0, pad); 34 - layout.addView(status); 35 - 36 - token = new EditText(this); 37 - token.setHint("App token"); 38 - token.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD); 39 - // A password manager's popup over the field swallows the tap on Save. 40 - token.setImportantForAutofill(View.IMPORTANT_FOR_AUTOFILL_NO); 41 - layout.addView(token); 42 - layout.addView(button("Save token", new View.OnClickListener() { 43 - @Override 44 - public void onClick(View view) { 45 - if (token.getText().toString().trim().isEmpty()) { 46 - return; 47 - } 48 - Api.saveToken(SettingsActivity.this, token.getText().toString()); 49 - token.setText(""); 50 - refresh(); 51 - } 52 - })); 53 - 54 - permissions = button("Allow microphone and notifications", new View.OnClickListener() { 55 - @Override 56 - public void onClick(View view) { 57 - requestPermissions(new String[] { 58 - Manifest.permission.RECORD_AUDIO, Manifest.permission.POST_NOTIFICATIONS 59 - }, 0); 60 - } 61 - }); 62 - layout.addView(permissions); 63 - 64 - TextView help = new TextView(this); 65 - help.setPadding(0, pad, 0, 0); 66 - help.setTextColor(Conversation.DIM); 67 - help.setText("The token: clan vars get coaxmetal hermes-voice-app/token\n\n" 68 - + "Hold the headset's voice button to talk to Hermes, a short press hangs up;" 69 - + " a tone says it listens. Pick Assist when Android asks which app handles voice" 70 - + " commands. On GrapheneOS, Assist also needs the Network permission."); 71 - layout.addView(help); 72 - 73 - ScrollView scroll = new ScrollView(this); 74 - scroll.addView(layout); 75 - Ui.fitBars(this, scroll, pad); 76 - setContentView(scroll); 77 - } 78 - 79 - @Override 80 - protected void onResume() { 81 - super.onResume(); 82 - refresh(); 83 - } 84 - 85 - @Override 86 - public void onRequestPermissionsResult(int code, String[] permissions, int[] results) { 87 - refresh(); 88 - } 89 - 90 - @Override 91 - public boolean onNavigateUp() { 92 - finish(); 93 - return true; 94 - } 95 - 96 - private void refresh() { 97 - boolean mic = checkSelfPermission(Manifest.permission.RECORD_AUDIO) 98 - == PackageManager.PERMISSION_GRANTED; 99 - status.setText("Token: " + (Api.hasToken(this) ? "set" : "missing") 100 - + "\nMicrophone: " + (mic ? "allowed" : "not allowed")); 101 - permissions.setVisibility(mic ? View.GONE : View.VISIBLE); 102 - } 103 - 104 - private Button button(String label, View.OnClickListener action) { 105 - Button button = new Button(this); 106 - button.setText(label); 107 - button.setOnClickListener(action); 108 - return button; 109 - } 110 - }
-43
tools/hermes-voice/android/src/us/lassul/assist/TranscriptActivity.java
··· 1 - package us.lassul.assist; 2 - 3 - import android.app.Activity; 4 - import android.os.Bundle; 5 - import android.text.format.DateUtils; 6 - import android.widget.ScrollView; 7 - import android.widget.TextView; 8 - 9 - /** One conversation's transcript, read-only. */ 10 - public class TranscriptActivity extends Activity { 11 - static final String SESSION = "session"; 12 - 13 - @Override 14 - protected void onCreate(Bundle state) { 15 - super.onCreate(state); 16 - getActionBar().setDisplayHomeAsUpEnabled(true); 17 - Conversation conversation = Conversation.read(this, getIntent().getStringExtra(SESSION)); 18 - if (conversation == null) { 19 - finish(); 20 - return; 21 - } 22 - setTitle(DateUtils.formatDateTime(this, conversation.started, 23 - DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_SHOW_TIME 24 - | DateUtils.FORMAT_ABBREV_MONTH)); 25 - int pad = (int) (16 * getResources().getDisplayMetrics().density); 26 - TextView transcript = new TextView(this); 27 - transcript.setTextSize(16); 28 - transcript.setLineSpacing(0, 1.15f); 29 - transcript.setTextIsSelectable(true); 30 - transcript.setPadding(pad, pad, pad, pad); 31 - transcript.setText(conversation.render()); 32 - ScrollView scroll = new ScrollView(this); 33 - scroll.addView(transcript); 34 - Ui.fitBars(this, scroll, 0); 35 - setContentView(scroll); 36 - } 37 - 38 - @Override 39 - public boolean onNavigateUp() { 40 - finish(); 41 - return true; 42 - } 43 - }
-41
tools/hermes-voice/android/src/us/lassul/assist/Ui.java
··· 1 - package us.lassul.assist; 2 - 3 - import android.app.Activity; 4 - import android.graphics.Insets; 5 - import android.util.TypedValue; 6 - import android.view.View; 7 - import android.view.ViewGroup; 8 - import android.view.WindowInsets; 9 - 10 - /** Layout helpers shared by the screens. */ 11 - final class Ui { 12 - private Ui() {} 13 - 14 - static int dp(Activity activity, int value) { 15 - return (int) (value * activity.getResources().getDisplayMetrics().density); 16 - } 17 - 18 - /** 19 - * Pads a screen's root view clear of the status bar, the action bar, the navigation bar and 20 - * the keyboard: apps targeting Android 15+ are drawn edge to edge, under all of them. 21 - */ 22 - static void fitBars(Activity activity, View root, final int pad) { 23 - TypedValue size = new TypedValue(); 24 - activity.getTheme().resolveAttribute(android.R.attr.actionBarSize, size, true); 25 - final int actionBar = TypedValue.complexToDimensionPixelSize( 26 - size.data, activity.getResources().getDisplayMetrics()); 27 - if (root instanceof ViewGroup) { 28 - ((ViewGroup) root).setClipToPadding(false); 29 - } 30 - root.setOnApplyWindowInsetsListener(new View.OnApplyWindowInsetsListener() { 31 - @Override 32 - public WindowInsets onApplyWindowInsets(View view, WindowInsets insets) { 33 - Insets bars = insets.getInsets(WindowInsets.Type.systemBars() 34 - | WindowInsets.Type.displayCutout() | WindowInsets.Type.ime()); 35 - view.setPadding(bars.left + pad, bars.top + actionBar + pad / 2, 36 - bars.right + pad, bars.bottom + pad); 37 - return WindowInsets.CONSUMED; 38 - } 39 - }); 40 - } 41 - }
-908
tools/hermes-voice/android/src/us/lassul/assist/VoiceCall.java
··· 1 - package us.lassul.assist; 2 - 3 - import android.content.Context; 4 - import android.os.Handler; 5 - import android.os.Looper; 6 - import android.os.SystemClock; 7 - import android.util.Log; 8 - import java.io.BufferedReader; 9 - import java.io.InputStreamReader; 10 - import java.net.HttpURLConnection; 11 - import java.nio.ByteBuffer; 12 - import java.nio.ByteOrder; 13 - import java.nio.ShortBuffer; 14 - import java.nio.charset.StandardCharsets; 15 - import java.util.ArrayList; 16 - import java.util.Collections; 17 - import java.util.List; 18 - import java.util.concurrent.ExecutorService; 19 - import java.util.concurrent.Executors; 20 - import java.util.regex.Pattern; 21 - import org.json.JSONArray; 22 - import org.json.JSONException; 23 - import org.json.JSONObject; 24 - import org.webrtc.AudioSource; 25 - import org.webrtc.AudioTrack; 26 - import org.webrtc.AudioTrackSink; 27 - import org.webrtc.DataChannel; 28 - import org.webrtc.IceCandidate; 29 - import org.webrtc.MediaConstraints; 30 - import org.webrtc.MediaStream; 31 - import org.webrtc.MediaStreamTrack; 32 - import org.webrtc.PeerConnection; 33 - import org.webrtc.PeerConnectionFactory; 34 - import org.webrtc.RtpReceiver; 35 - import org.webrtc.SdpObserver; 36 - import org.webrtc.SessionDescription; 37 - import org.webrtc.audio.JavaAudioDeviceModule; 38 - 39 - /** 40 - * One call with gpt-live-1 over WebRTC, delegating to Hermes through hermes-voice: the web page's 41 - * Call (static/app.js) in Java, with the same events, delegation, voice approvals, end_call and 42 - * idle rules. Lives on the main thread; WebRTC and network callbacks are posted to it. 43 - */ 44 - final class VoiceCall { 45 - interface Listener { 46 - /** The voice model listens (session.started). */ 47 - void onListening(); 48 - 49 - /** The call is over; the reason is for the log. */ 50 - void onFinished(String reason, boolean failed); 51 - } 52 - 53 - // The voice model accepts at most 500 tokens per append; ~4 chars per token. 54 - private static final int APPEND_CHAR_LIMIT = 1400; 55 - // Transcript deltas can trail the delegation event; wait for the last words. 56 - private static final long DELEGATION_SETTLE_MS = 400; 57 - // Once Hermes ended the call (end_call item), the call closes when the voice has been quiet 58 - // this long after speaking the reply, at the latest after END_CALL_MAX_MS. 59 - private static final long END_CALL_QUIET_MS = 800; 60 - private static final long END_CALL_MAX_MS = 30_000; 61 - private static final long CLOSE_TIMEOUT_MS = 15_000; 62 - // Trickle is fine: the vendor answers with the candidates it has. 63 - private static final long ICE_TIMEOUT_MS = 3_000; 64 - private static final long TICK_MS = 100; 65 - // The voice counts as speaking while a sample above this came within SPEAKING_HOLD_MS. 66 - private static final int SPEAKING_PEAK = 1500; 67 - private static final long SPEAKING_HOLD_MS = 400; 68 - // Spoken answer to an approval question. Denials win, so "no, don't do it" never approves; 69 - // anything unclear is asked again. 70 - private static final Pattern DENY = Pattern.compile( 71 - "\\b(no|nope|deny|denied|don'?t|do not|stop|cancel|abort|reject|nein|nicht)\\b", 72 - Pattern.CASE_INSENSITIVE); 73 - private static final Pattern APPROVE = Pattern.compile( 74 - "\\b(yes|yeah|yep|approve|approved|go ahead|do it|okay|ok|sure|confirm|allow|ja)\\b", 75 - Pattern.CASE_INSENSITIVE); 76 - 77 - private static boolean webrtcLoaded; 78 - 79 - private final Context context; 80 - private final Api api; 81 - private final Conversation conversation; 82 - private final Listener listener; 83 - private final Handler main = new Handler(Looper.getMainLooper()); 84 - private final ExecutorService io = Executors.newCachedThreadPool(); 85 - 86 - private JavaAudioDeviceModule audio; 87 - private PeerConnectionFactory factory; 88 - private PeerConnection pc; 89 - private AudioSource source; 90 - private AudioTrack mic; 91 - private DataChannel events; 92 - private boolean offerSent; 93 - private long idleMs = 90_000; 94 - 95 - // Written by WebRTC's audio thread, read by tick(). 96 - private volatile long lastLoud; 97 - private long lastActivity = SystemClock.elapsedRealtime(); 98 - private boolean speaking; 99 - private String lastSpeaker; 100 - private Delegation delegation; 101 - private Approval approval; 102 - private boolean closed; 103 - private boolean closing; 104 - private boolean ending; 105 - private boolean endingSpoken; 106 - private int eventCounter; 107 - private double usageSeconds; 108 - 109 - private final Runnable sendOffer = new Runnable() { 110 - @Override 111 - public void run() { 112 - sendOffer(); 113 - } 114 - }; 115 - private final Runnable tick = new Runnable() { 116 - @Override 117 - public void run() { 118 - tick(); 119 - } 120 - }; 121 - private final Runnable closeTimedOut = new Runnable() { 122 - @Override 123 - public void run() { 124 - finish("close timed out", false); 125 - } 126 - }; 127 - private final Runnable endNow = new Runnable() { 128 - @Override 129 - public void run() { 130 - close(); 131 - } 132 - }; 133 - 134 - /** A Hermes turn: the /delegate stream for one delegation of the voice model. */ 135 - private static final class Delegation { 136 - final String id; 137 - final StringBuilder answer = new StringBuilder(); 138 - String failure; 139 - boolean endCall; 140 - volatile boolean aborted; 141 - volatile HttpURLConnection connection; 142 - 143 - Delegation(String id) { 144 - this.id = id; 145 - } 146 - } 147 - 148 - private static final class Approval { 149 - final String delegationId; 150 - final String code; 151 - 152 - Approval(String delegationId, String code) { 153 - this.delegationId = delegationId; 154 - this.code = code; 155 - } 156 - } 157 - 158 - VoiceCall(Context context, Api api, Conversation conversation, Listener listener) { 159 - this.context = context; 160 - this.api = api; 161 - this.conversation = conversation; 162 - this.listener = listener; 163 - } 164 - 165 - void start() { 166 - io.execute(new Runnable() { 167 - @Override 168 - public void run() { 169 - try { 170 - final long idle = api.get("/config").optLong("idle_seconds", 90) * 1000; 171 - main.post(new Runnable() { 172 - @Override 173 - public void run() { 174 - idleMs = idle; 175 - connect(); 176 - } 177 - }); 178 - } catch (Exception error) { 179 - failLater("Settings not loaded: " + error.getMessage()); 180 - } 181 - } 182 - }); 183 - } 184 - 185 - private void connect() { 186 - if (closed) { 187 - return; 188 - } 189 - if (!webrtcLoaded) { 190 - PeerConnectionFactory.initialize(PeerConnectionFactory.InitializationOptions 191 - .builder(context.getApplicationContext()) 192 - .createInitializationOptions()); 193 - webrtcLoaded = true; 194 - } 195 - audio = JavaAudioDeviceModule.builder(context) 196 - .setUseHardwareAcousticEchoCanceler(true) 197 - .setUseHardwareNoiseSuppressor(true) 198 - .createAudioDeviceModule(); 199 - factory = PeerConnectionFactory.builder() 200 - .setAudioDeviceModule(audio) 201 - .createPeerConnectionFactory(); 202 - PeerConnection.RTCConfiguration config = 203 - new PeerConnection.RTCConfiguration(new ArrayList<PeerConnection.IceServer>()); 204 - config.sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN; 205 - pc = factory.createPeerConnection(config, new PeerObserver()); 206 - if (pc == null) { 207 - fail("WebRTC refused the connection"); 208 - return; 209 - } 210 - source = factory.createAudioSource(new MediaConstraints()); 211 - mic = factory.createAudioTrack("mic", source); 212 - pc.addTrack(mic, Collections.singletonList("mic")); 213 - // The data channel must exist before the offer so its m-line is negotiated. 214 - events = pc.createDataChannel("oai-events", new DataChannel.Init()); 215 - events.registerObserver(new EventsObserver()); 216 - pc.createOffer(new Sdp("offer") { 217 - @Override 218 - public void onCreateSuccess(final SessionDescription offer) { 219 - main.post(new Runnable() { 220 - @Override 221 - public void run() { 222 - if (closed) { 223 - return; 224 - } 225 - pc.setLocalDescription(new Sdp("local description") { 226 - @Override 227 - public void onSetSuccess() { 228 - main.postDelayed(sendOffer, ICE_TIMEOUT_MS); 229 - } 230 - }, offer); 231 - } 232 - }); 233 - } 234 - }, new MediaConstraints()); 235 - } 236 - 237 - private void sendOffer() { 238 - if (offerSent || closed) { 239 - return; 240 - } 241 - offerSent = true; 242 - main.removeCallbacks(sendOffer); 243 - final JSONObject body; 244 - try { 245 - body = new JSONObject() 246 - .put("sdp", pc.getLocalDescription().description) 247 - .put("history", Conversation.toJson(conversation.lines)); 248 - } catch (JSONException error) { 249 - fail(error.toString()); 250 - return; 251 - } 252 - io.execute(new Runnable() { 253 - @Override 254 - public void run() { 255 - try { 256 - final String answer = api.post("/session", body).getString("sdp"); 257 - main.post(new Runnable() { 258 - @Override 259 - public void run() { 260 - if (closed) { 261 - return; 262 - } 263 - pc.setRemoteDescription(new Sdp("remote description"), 264 - new SessionDescription(SessionDescription.Type.ANSWER, answer)); 265 - main.postDelayed(tick, TICK_MS); 266 - } 267 - }); 268 - } catch (Exception error) { 269 - failLater(error.getMessage()); 270 - } 271 - } 272 - }); 273 - } 274 - 275 - private void tick() { 276 - if (closed) { 277 - return; 278 - } 279 - long now = SystemClock.elapsedRealtime(); 280 - long quiet = now - lastLoud; 281 - speaking = quiet < SPEAKING_HOLD_MS; 282 - if (speaking) { 283 - lastActivity = now; 284 - } 285 - if (ending && endingSpoken && quiet >= END_CALL_QUIET_MS) { 286 - close(); 287 - } else if (delegation == null && !speaking && now - lastActivity > idleMs) { 288 - Log.i(CallService.TAG, "hanging up (idle)"); 289 - close(); 290 - } 291 - main.postDelayed(tick, TICK_MS); 292 - } 293 - 294 - private boolean send(JSONObject event) { 295 - if (events == null || events.state() != DataChannel.State.OPEN) { 296 - return false; 297 - } 298 - try { 299 - eventCounter += 1; 300 - event.put("event_id", "evt_" + eventCounter); 301 - } catch (JSONException error) { 302 - return false; 303 - } 304 - byte[] bytes = event.toString().getBytes(StandardCharsets.UTF_8); 305 - return events.send(new DataChannel.Buffer(ByteBuffer.wrap(bytes), false)); 306 - } 307 - 308 - private void append(String type, String delegationId, String content) { 309 - try { 310 - send(new JSONObject() 311 - .put("type", type) 312 - .put("delegation_id", delegationId) 313 - .put("content", truncate(content))); 314 - } catch (JSONException error) { 315 - Log.w(CallService.TAG, "append failed", error); 316 - } 317 - } 318 - 319 - private void onEvent(JSONObject event) { 320 - switch (event.optString("type")) { 321 - case "session.started": 322 - listener.onListening(); 323 - break; 324 - case "session.input_transcript.delta": 325 - lastActivity = SystemClock.elapsedRealtime(); 326 - addTranscript("user", event.optString("delta")); 327 - break; 328 - case "session.output_transcript.delta": 329 - lastActivity = SystemClock.elapsedRealtime(); 330 - addTranscript("assistant", event.optString("delta")); 331 - if (ending) { 332 - endingSpoken = true; 333 - } 334 - break; 335 - case "session.delegation.created": 336 - JSONObject created = event.optJSONObject("delegation"); 337 - if (created != null) { 338 - delegate(created.optString("id")); 339 - } 340 - break; 341 - case "session.usage.updated": 342 - usageSeconds = usageOf(event, usageSeconds); 343 - break; 344 - case "error": 345 - JSONObject error = event.optJSONObject("error"); 346 - conversation.add("error", error != null 347 - ? error.optString("message", "voice model error") 348 - : "voice model error"); 349 - break; 350 - case "session.closed": 351 - usageSeconds = usageOf(event, usageSeconds); 352 - finish(event.optString("reason", "closed"), false); 353 - break; 354 - default: 355 - break; 356 - } 357 - } 358 - 359 - private static double usageOf(JSONObject event, double fallback) { 360 - JSONObject usage = event.optJSONObject("usage"); 361 - return usage != null ? usage.optDouble("seconds", fallback) : fallback; 362 - } 363 - 364 - // Deltas are appended exactly as received; a speaker change starts a new line. 365 - private void addTranscript(String speaker, String delta) { 366 - List<Conversation.Line> lines = conversation.lines; 367 - Conversation.Line last = lines.isEmpty() ? null : lines.get(lines.size() - 1); 368 - if (last != null && last.speaker.equals(speaker) && speaker.equals(lastSpeaker)) { 369 - last.text += delta; 370 - conversation.save(); 371 - } else { 372 - conversation.add(speaker, delta); 373 - } 374 - lastSpeaker = speaker; 375 - } 376 - 377 - private void delegate(String id) { 378 - // One Hermes turn at a time: a newer delegation (usually a correction) supersedes the 379 - // running one, and the server stops the old run. While Hermes waits for an approval, 380 - // the next delegation is the user's answer, not a new request. 381 - if (approval != null) { 382 - answerApproval(id); 383 - return; 384 - } 385 - final boolean replace = delegation != null; 386 - if (delegation != null) { 387 - abort(delegation); 388 - } 389 - final Delegation current = new Delegation(id); 390 - delegation = current; 391 - main.postDelayed(new Runnable() { 392 - @Override 393 - public void run() { 394 - runDelegation(current, replace); 395 - } 396 - }, DELEGATION_SETTLE_MS); 397 - } 398 - 399 - private void runDelegation(final Delegation current, boolean replace) { 400 - // A hang-up meanwhile sent these lines along itself. 401 - if (delegation != current || closed) { 402 - return; 403 - } 404 - List<Conversation.Line> lines = conversation.undelegated("user", "assistant"); 405 - boolean userSpoke = false; 406 - for (Conversation.Line line : lines) { 407 - userSpoke |= line.speaker.equals("user"); 408 - } 409 - if (!userSpoke) { 410 - List<Conversation.Line> dialogue = new ArrayList<>(); 411 - for (Conversation.Line line : conversation.lines) { 412 - if (line.speaker.equals("user") || line.speaker.equals("assistant")) { 413 - dialogue.add(line); 414 - } 415 - } 416 - lines = dialogue.subList(Math.max(0, dialogue.size() - 4), dialogue.size()); 417 - } 418 - conversation.delegated = conversation.lines.size(); 419 - conversation.save(); 420 - final JSONObject body; 421 - try { 422 - body = new JSONObject() 423 - .put("hermes_session", conversation.hermesSession) 424 - .put("lines", Conversation.toJson(lines)) 425 - .put("replace", replace); 426 - } catch (JSONException error) { 427 - return; 428 - } 429 - io.execute(new Runnable() { 430 - @Override 431 - public void run() { 432 - stream(current, body); 433 - } 434 - }); 435 - } 436 - 437 - /** Network thread: reads /delegate's NDJSON and posts each item to the main thread. */ 438 - private void stream(final Delegation current, JSONObject body) { 439 - String failure = null; 440 - HttpURLConnection connection = null; 441 - try { 442 - connection = api.stream("/delegate", body); 443 - current.connection = connection; 444 - if (current.aborted) { 445 - return; 446 - } 447 - BufferedReader reader = new BufferedReader( 448 - new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8)); 449 - for (String line; (line = reader.readLine()) != null; ) { 450 - // Blank lines are the server's keepalives. 451 - if (line.trim().isEmpty()) { 452 - continue; 453 - } 454 - final JSONObject item = new JSONObject(line); 455 - main.post(new Runnable() { 456 - @Override 457 - public void run() { 458 - onItem(current, item); 459 - } 460 - }); 461 - } 462 - } catch (Exception error) { 463 - if (current.aborted) { 464 - return; 465 - } 466 - failure = String.valueOf(error.getMessage()); 467 - } finally { 468 - if (connection != null) { 469 - connection.disconnect(); 470 - } 471 - } 472 - final String streamFailure = failure; 473 - main.post(new Runnable() { 474 - @Override 475 - public void run() { 476 - onDone(current, streamFailure); 477 - } 478 - }); 479 - } 480 - 481 - private void onItem(Delegation current, JSONObject item) { 482 - if (delegation != current) { 483 - return; 484 - } 485 - lastActivity = SystemClock.elapsedRealtime(); 486 - String type = item.optString("type"); 487 - if (type.equals("approval")) { 488 - askApproval(current.id, item); 489 - return; 490 - } 491 - // Anything after an approval request means Hermes moved on (answered here, from Matrix, 492 - // or by its own timeout). 493 - approval = null; 494 - if (type.equals("tool") && item.optString("status").equals("running")) { 495 - append("session.thinking.append", current.id, 496 - "Hermes is working on it: " + item.optString("label")); 497 - } else if (type.equals("end_call")) { 498 - current.endCall = true; 499 - } else if (type.equals("delta")) { 500 - current.answer.append(item.optString("text")); 501 - } else if (type.equals("done") && item.has("error") && !item.isNull("error")) { 502 - current.failure = item.optString("error"); 503 - } else if (type.equals("error")) { 504 - current.failure = item.optString("message"); 505 - } 506 - } 507 - 508 - private void onDone(Delegation current, String streamFailure) { 509 - if (delegation != current) { 510 - return; 511 - } 512 - delegation = null; 513 - approval = null; 514 - lastActivity = SystemClock.elapsedRealtime(); 515 - String failure = current.failure != null ? current.failure : streamFailure; 516 - String answer = current.answer.toString().trim(); 517 - if (failure != null && answer.isEmpty()) { 518 - conversation.add("error", failure); 519 - append("session.commentary.append", current.id, 520 - "The request to Hermes failed: " + failure); 521 - } else { 522 - String text = answer.isEmpty() ? "Hermes finished without an answer." : answer; 523 - conversation.add("hermes", text); 524 - for (String chunk : chunks(text)) { 525 - append("session.commentary.append", current.id, chunk); 526 - } 527 - } 528 - if (current.endCall) { 529 - // Hermes ended the call: close it once the voice has spoken the reply just handed over. 530 - ending = true; 531 - endingSpoken = false; 532 - main.postDelayed(endNow, END_CALL_MAX_MS); 533 - } 534 - } 535 - 536 - private void abort(final Delegation current) { 537 - current.aborted = true; 538 - io.execute(new Runnable() { 539 - @Override 540 - public void run() { 541 - HttpURLConnection connection = current.connection; 542 - if (connection != null) { 543 - connection.disconnect(); 544 - } 545 - } 546 - }); 547 - } 548 - 549 - private void askApproval(String id, JSONObject item) { 550 - approval = new Approval(id, item.isNull("code") ? null : item.optString("code", null)); 551 - String what = item.optString("description"); 552 - if (what.isEmpty()) { 553 - what = "run a command"; 554 - } 555 - String command = item.isNull("command") ? "" : item.optString("command"); 556 - conversation.add("hermes", 557 - "Approval needed: " + what + (command.isEmpty() ? "" : "\n" + command)); 558 - append("session.commentary.append", id, 559 - "Hermes needs the user's approval before it continues: " + what + "." 560 - + (command.isEmpty() ? "" : " The command is: " + command + ".") 561 - + " Ask the user whether to approve or deny it."); 562 - } 563 - 564 - private void answerApproval(final String id) { 565 - main.postDelayed(new Runnable() { 566 - @Override 567 - public void run() { 568 - if (closed) { 569 - return; 570 - } 571 - StringBuilder said = new StringBuilder(); 572 - for (Conversation.Line line : conversation.undelegated("user")) { 573 - said.append(line.text).append(' '); 574 - } 575 - conversation.delegated = conversation.lines.size(); 576 - conversation.save(); 577 - String choice = classifyApproval(said.toString()); 578 - if (choice == null) { 579 - append("session.commentary.append", id, 580 - "That was not a clear answer. Ask the user again: approve or deny?"); 581 - return; 582 - } 583 - respondApproval(choice, id); 584 - } 585 - }, DELEGATION_SETTLE_MS); 586 - } 587 - 588 - static String classifyApproval(String said) { 589 - if (DENY.matcher(said).find()) { 590 - return "deny"; 591 - } 592 - if (APPROVE.matcher(said).find()) { 593 - return "once"; 594 - } 595 - return null; 596 - } 597 - 598 - private void respondApproval(final String choice, final String replyId) { 599 - final Approval answered = approval; 600 - if (answered == null) { 601 - return; 602 - } 603 - approval = null; 604 - io.execute(new Runnable() { 605 - @Override 606 - public void run() { 607 - String message; 608 - boolean ok = false; 609 - try { 610 - api.post("/approve", new JSONObject() 611 - .put("code", answered.code == null ? JSONObject.NULL : answered.code) 612 - .put("choice", choice)); 613 - ok = true; 614 - message = choice.equals("deny") 615 - ? "Denied. Hermes will not run it." 616 - : "Approved. Hermes is continuing."; 617 - } catch (Exception error) { 618 - message = "Could not send the answer to Hermes: " + error.getMessage(); 619 - } 620 - final String reply = message; 621 - final boolean sent = ok; 622 - main.post(new Runnable() { 623 - @Override 624 - public void run() { 625 - conversation.add(sent ? "hermes" : "error", reply); 626 - // A fast turn can finish before this reply returns; its result was already 627 - // handed to the voice, so an "is continuing" would be stale. 628 - if (closed || (sent && delegation == null)) { 629 - return; 630 - } 631 - append("session.commentary.append", 632 - replyId != null ? replyId : answered.delegationId, reply); 633 - } 634 - }); 635 - } 636 - }); 637 - } 638 - 639 - /** Ends the call gracefully: the voice model closes the session and reports final usage. */ 640 - void close() { 641 - if (closed || closing) { 642 - return; 643 - } 644 - closing = true; 645 - try { 646 - if (!send(new JSONObject().put("type", "session.close"))) { 647 - finish("closed", false); 648 - return; 649 - } 650 - } catch (JSONException error) { 651 - finish("closed", false); 652 - return; 653 - } 654 - main.postDelayed(closeTimedOut, CLOSE_TIMEOUT_MS); 655 - } 656 - 657 - private void failLater(final String message) { 658 - main.post(new Runnable() { 659 - @Override 660 - public void run() { 661 - fail(message); 662 - } 663 - }); 664 - } 665 - 666 - private void fail(String message) { 667 - if (closed) { 668 - return; 669 - } 670 - Log.w(CallService.TAG, "call failed: " + message); 671 - conversation.add("error", String.valueOf(message)); 672 - finish("failed to start", true); 673 - } 674 - 675 - private void finish(String reason, boolean failed) { 676 - if (closed) { 677 - return; 678 - } 679 - closed = true; 680 - main.removeCallbacksAndMessages(null); 681 - hangUp(); 682 - // Only the stream is dropped; the server keeps following the Hermes run. 683 - if (delegation != null) { 684 - abort(delegation); 685 - delegation = null; 686 - } 687 - if (events != null) { 688 - events.unregisterObserver(); 689 - events.close(); 690 - events.dispose(); 691 - } 692 - if (pc != null) { 693 - pc.close(); 694 - pc.dispose(); 695 - } 696 - if (mic != null) { 697 - mic.dispose(); 698 - } 699 - if (source != null) { 700 - source.dispose(); 701 - } 702 - if (factory != null) { 703 - factory.dispose(); 704 - } 705 - if (audio != null) { 706 - audio.release(); 707 - } 708 - conversation.billedSeconds += usageSeconds; 709 - conversation.save(); 710 - io.shutdown(); 711 - Log.i(CallService.TAG, "call ended: " + reason); 712 - listener.onFinished(reason, failed); 713 - } 714 - 715 - // Tell the server the call is over, with what the user said since the last delegation: a 716 - // request spoken right before hanging up still reaches Hermes. 717 - private void hangUp() { 718 - List<Conversation.Line> lines = conversation.undelegated("user", "assistant"); 719 - conversation.delegated = conversation.lines.size(); 720 - conversation.save(); 721 - final JSONObject body; 722 - try { 723 - body = new JSONObject() 724 - .put("hermes_session", conversation.hermesSession) 725 - .put("lines", Conversation.toJson(lines)); 726 - } catch (JSONException error) { 727 - return; 728 - } 729 - io.execute(new Runnable() { 730 - @Override 731 - public void run() { 732 - try { 733 - api.post("/hangup", body); 734 - } catch (Exception error) { 735 - Log.w(CallService.TAG, "hang-up not delivered: " + error.getMessage()); 736 - } 737 - } 738 - }); 739 - } 740 - 741 - private static String truncate(String text) { 742 - return text.length() > APPEND_CHAR_LIMIT ? text.substring(0, APPEND_CHAR_LIMIT) : text; 743 - } 744 - 745 - /** Sentences packed into appends of at most APPEND_CHAR_LIMIT characters. */ 746 - static List<String> chunks(String text) { 747 - String clean = text.replaceAll("\\s+", " ").trim(); 748 - List<String> out = new ArrayList<>(); 749 - String current = ""; 750 - for (String sentence : clean.split("(?<=[.!?])\\s+")) { 751 - if (sentence.length() > APPEND_CHAR_LIMIT) { 752 - if (!current.isEmpty()) { 753 - out.add(current); 754 - } 755 - current = ""; 756 - for (int i = 0; i < sentence.length(); i += APPEND_CHAR_LIMIT) { 757 - out.add(sentence.substring(i, Math.min(sentence.length(), i + APPEND_CHAR_LIMIT))); 758 - } 759 - } else if (!current.isEmpty() 760 - && current.length() + 1 + sentence.length() > APPEND_CHAR_LIMIT) { 761 - out.add(current); 762 - current = sentence; 763 - } else { 764 - current = current.isEmpty() ? sentence : current + " " + sentence; 765 - } 766 - } 767 - if (!current.isEmpty()) { 768 - out.add(current); 769 - } 770 - return out; 771 - } 772 - 773 - /** Logs failures; a failed description ends the call. */ 774 - private class Sdp implements SdpObserver { 775 - private final String what; 776 - 777 - Sdp(String what) { 778 - this.what = what; 779 - } 780 - 781 - @Override 782 - public void onCreateSuccess(SessionDescription description) {} 783 - 784 - @Override 785 - public void onSetSuccess() {} 786 - 787 - @Override 788 - public void onCreateFailure(String error) { 789 - failLater(what + ": " + error); 790 - } 791 - 792 - @Override 793 - public void onSetFailure(String error) { 794 - failLater(what + ": " + error); 795 - } 796 - } 797 - 798 - private class PeerObserver implements PeerConnection.Observer { 799 - @Override 800 - public void onConnectionChange(PeerConnection.PeerConnectionState state) { 801 - if (state == PeerConnection.PeerConnectionState.FAILED 802 - || state == PeerConnection.PeerConnectionState.DISCONNECTED) { 803 - main.post(new Runnable() { 804 - @Override 805 - public void run() { 806 - finish("connection lost", false); 807 - } 808 - }); 809 - } 810 - } 811 - 812 - @Override 813 - public void onIceGatheringChange(PeerConnection.IceGatheringState state) { 814 - if (state == PeerConnection.IceGatheringState.COMPLETE) { 815 - main.post(sendOffer); 816 - } 817 - } 818 - 819 - // The voice's audio plays by itself; the sink only measures it, for speaking(). 820 - @Override 821 - public void onAddTrack(RtpReceiver receiver, MediaStream[] streams) { 822 - MediaStreamTrack track = receiver.track(); 823 - if (track instanceof AudioTrack) { 824 - ((AudioTrack) track).addSink(new AudioTrackSink() { 825 - @Override 826 - public void onData(ByteBuffer data, int bitsPerSample, int sampleRate, 827 - int channels, int frames, long timestamp) { 828 - if (bitsPerSample != 16) { 829 - return; 830 - } 831 - ShortBuffer samples = data.duplicate().order(ByteOrder.nativeOrder()) 832 - .asShortBuffer(); 833 - while (samples.hasRemaining()) { 834 - if (Math.abs(samples.get()) > SPEAKING_PEAK) { 835 - lastLoud = SystemClock.elapsedRealtime(); 836 - return; 837 - } 838 - } 839 - } 840 - }); 841 - } 842 - } 843 - 844 - @Override 845 - public void onSignalingChange(PeerConnection.SignalingState state) {} 846 - 847 - @Override 848 - public void onIceConnectionChange(PeerConnection.IceConnectionState state) {} 849 - 850 - @Override 851 - public void onIceConnectionReceivingChange(boolean receiving) {} 852 - 853 - @Override 854 - public void onIceCandidate(IceCandidate candidate) {} 855 - 856 - @Override 857 - public void onIceCandidatesRemoved(IceCandidate[] candidates) {} 858 - 859 - @Override 860 - public void onAddStream(MediaStream stream) {} 861 - 862 - @Override 863 - public void onRemoveStream(MediaStream stream) {} 864 - 865 - @Override 866 - public void onDataChannel(DataChannel channel) {} 867 - 868 - @Override 869 - public void onRenegotiationNeeded() {} 870 - } 871 - 872 - private class EventsObserver implements DataChannel.Observer { 873 - @Override 874 - public void onMessage(DataChannel.Buffer buffer) { 875 - byte[] bytes = new byte[buffer.data.remaining()]; 876 - buffer.data.get(bytes); 877 - final String text = new String(bytes, StandardCharsets.UTF_8); 878 - main.post(new Runnable() { 879 - @Override 880 - public void run() { 881 - if (closed) { 882 - return; 883 - } 884 - try { 885 - onEvent(new JSONObject(text)); 886 - } catch (JSONException error) { 887 - Log.w(CallService.TAG, "unreadable event", error); 888 - } 889 - } 890 - }); 891 - } 892 - 893 - @Override 894 - public void onStateChange() { 895 - if (events.state() == DataChannel.State.CLOSED) { 896 - main.post(new Runnable() { 897 - @Override 898 - public void run() { 899 - finish("connection closed", false); 900 - } 901 - }); 902 - } 903 - } 904 - 905 - @Override 906 - public void onBufferedAmountChange(long previous) {} 907 - } 908 - }

History

3 rounds 0 comments
Sign up or Login to add to the discussion
11 commits
Expand
pkgs.tranquil-pds: init at 0.7.0
configs.dns: _acme-challenge zone for *.pds.lassul.us
configs.services.git: tranquil PDS on neoprism with id.lassul.us login
configs.services.git: resolve the ACME zone via ns1
pkgs.tangled: init at 0-unstable-2026-10-18
configs.services.git: tangled knot and appview on git.lassul.us
configs.services.git: own knotmirror
configs.ssh-redirect: only reject 22 that wasn't redirected elsewhere
configs.services.git: knot owner, git ssh on port 22
configs.services.git: give the knot a memory limit
pkgs.tangled: appview asks knot2 for members by did:web
Checking mergeability…
Expand 0 comments
37 commits
Expand
tools.hermes-voice: headset button starts Assist listening
tools.hermes-voice: serve the page with a query string
tools.hermes-voice: headset hang-up button ends the call
configs.gg23.audio: switch phones left on the off profile to A2DP
configs.gg23.audio: crossed out speaker for routes that are off
modules.zitadel: refreshTokens option for OIDC apps
configs.roundcube: replace snappymail, log in via id.lassul.us
tools.hermes-voice: native Android app answers the headset button
tools.hermes-voice: always delegate action requests
skills.hedgedoc: pad-edit.py usage on --help, refuse empty content
flake.lock: update
modules.backup: per-service backup state, move neoprism paths into their services
modules.backup: encrypted repos in per-machine storage box sub-accounts
machines: backups for coaxmetal, styx, acheron and yellow
vars: update via generator borgbackup (machine: styx)
vars: update via generator borgbackup (machine: acheron)
vars: update via generator borgbackup (machine: yellow)
vars: update via generator borgbackup (machine: coaxmetal)
pkgs.zitadel: init at 4.19.5
configs.zitadel: run zitadel 4.19.5
configs.hass: log in via id.lassul.us (auth_oidc)
configs.hass: only allow the id.lassul.us login
tools.zitadel-invite: init
configs.gg23.audio.room: start pipewire at boot
configs.monitoring: OOMKill ignores data gaps
pkgs.calino: 0.39.1 -> 0.40.0
pkgs.tranquil-pds: init at 0.7.0
configs.dns: _acme-challenge zone for *.pds.lassul.us
configs.services.git: tranquil PDS on neoprism with id.lassul.us login
configs.services.git: resolve the ACME zone via ns1
pkgs.tangled: init at 0-unstable-2026-10-18
configs.services.git: tangled knot and appview on git.lassul.us
configs.services.git: own knotmirror
configs.ssh-redirect: only reject 22 that wasn't redirected elsewhere
configs.services.git: knot owner, git ssh on port 22
configs.services.git: give the knot a memory limit
pkgs.tangled: appview asks knot2 for members by did:web
Expand 0 comments
1 commit
Expand
AGENTS.md: mention PRs from git.lassul.us forks
Expand 0 comments