tools.hermes-voice: Android wrapper as digital assistant
packages.hermes-voice-android builds an unsigned APK (us.lassul.assist,
"Assist") that opens https://assist.lassul.us/ as a Trusted Web
Activity, so Chrome handles the mic permission and the stored access
token exactly as in the browser. Its single activity is the launcher
entry and handles android.intent.action.ASSIST. That is enough to be
offered under Default apps > Digital assistant app: the ASSISTANT
role's AssistantRoleBehavior (packages/modules/Permission) qualifies a
package either by an assist-capable VoiceInteractionService or by an
exported activity resolving ACTION_ASSIST, with no system-app check.
The activity binds the first browser offering the TWA category, opens
a session and launches the TWA intent; without such a browser it opens
a Custom Tab, which other browsers treat as a plain VIEW intent. It
stays below the page in the task and finishes when returned to.
The build is plain aapt2 + javac + d8 from androidenv (build-tools and
platform 36; the SDK is unfree, so androidenv comes from a nixpkgs
instance with allowUnfree and the license accepted). The only library
is androidx.browser 1.10.0's classes.jar (AAR from Google Maven, 289
KB): the client, session and TWA intent builder used here need none of
its dependencies (androidx.core is only referenced by launchUrl-style
helpers, which are not called). The APK is 72 KB and byte-identical
across rebuilds (fixed zip timestamps).
packages.hermes-voice-android-sign zipaligns and signs it with
apksigner (v1-v3, no .idsig) from a keystore and password file, by
default the packaged unsigned APK, and prints the certificate so it
can be compared with assetlinks.json.
configs.hermes-proxy: log in to assist.lassul.us via id.lassul.us
assist.lassul.us now sits behind oauth2-proxy on neoprism (provider
oidc, issuer https://id.lassul.us), wired in with nginx auth_request on
that vhost only. The nixpkgs nginx helper (oauth2-proxy.nginx) is not
used because it turns on services.nginx.recommendedProxySettings for
every vhost on the host; the same few locations are written out here.
Unauthenticated requests are redirected (307) to /oauth2/start with a
relative rd, which oauth2-proxy accepts without a whitelist-domain;
the provider button is skipped so it goes straight to Zitadel.
Access: the Zitadel project "assist" is declarative with a grant for
lass@lassul.us only, and its projectRoleCheck blocks everyone else at
the IdP. oauth2-proxy additionally allows only that address
(authenticated-emails-file, no email domains); its OIDC provider rejects
emails whose email_verified is false. The session user is the `sub`
claim. The client id/secret come from zitadel-reconcile's env file
(/var/lib/zitadel-apps/assist-web.env, OAUTH2_PROXY_CLIENT_ID/SECRET),
used as the unit's EnvironmentFile; the cookie secret from a new
neoprism vars generator (oauth2-proxy/cookie-secret, via LoadCredential).
Public without login, as Chrome fetches them without cookies to install
the page and verify the TWA: /.well-known/assetlinks.json,
/manifest.webmanifest, /icon-192.png, /icon-512.png.
After the login, nginx sets Authorization: Bearer <access token> towards
hermes-voice, from an include in /run written at boot by a root oneshot
(hermes-voice-auth-header, owned by nginx, mode 0600), so the token
never enters the store. For that the hermes-voice generator is now a
shared var (configs/hermes-voice-vars.nix, imported by coaxmetal and
neoprism); a shared generator gets a new value, so the token is rotated.
hermes-voice keeps checking the token, which still guards its retiolum
port; the gateway plugin keeps calling /approve on localhost with
API_SERVER_KEY.
The page no longer asks for or stores the token. Its fetches use
redirect: "manual", so an expired login (redirect to id.lassul.us,
which fetch cannot follow cross-origin) shows up as an opaqueredirect
and the page reloads into the login.
tools.hermes-voice: Android wrapper as digital assistant
packages.hermes-voice-android builds an unsigned APK (us.lassul.assist,
"Assist") that opens https://assist.lassul.us/ as a Trusted Web
Activity, so Chrome handles the mic permission and the stored access
token exactly as in the browser. Its single activity is the launcher
entry and handles android.intent.action.ASSIST. That is enough to be
offered under Default apps > Digital assistant app: the ASSISTANT
role's AssistantRoleBehavior (packages/modules/Permission) qualifies a
package either by an assist-capable VoiceInteractionService or by an
exported activity resolving ACTION_ASSIST, with no system-app check.
The activity binds the first browser offering the TWA category, opens
a session and launches the TWA intent; without such a browser it opens
a Custom Tab, which other browsers treat as a plain VIEW intent. It
stays below the page in the task and finishes when returned to.
The build is plain aapt2 + javac + d8 from androidenv (build-tools and
platform 36; the SDK is unfree, so androidenv comes from a nixpkgs
instance with allowUnfree and the license accepted). The only library
is androidx.browser 1.10.0's classes.jar (AAR from Google Maven, 289
KB): the client, session and TWA intent builder used here need none of
its dependencies (androidx.core is only referenced by launchUrl-style
helpers, which are not called). The APK is 72 KB and byte-identical
across rebuilds (fixed zip timestamps).
packages.hermes-voice-android-sign zipaligns and signs it with
apksigner (v1-v3, no .idsig) from a keystore and password file, by
default the packaged unsigned APK, and prints the certificate so it
can be compared with assetlinks.json.