From 502971183bbb619e0dc32b60a6a8ac500119a146 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yago=20P=C3=A9rez?= <5002453+yagop@users.noreply.github.com> Date: Sat, 27 Jun 2026 15:24:32 +0200 Subject: [PATCH] feat(session): store session in macOS Keychain by default Add a sessionStore abstraction so session read/exists/delete go through a single seam, with two backends: a file (existing behaviour, used off macOS and when keychain is disabled) and the macOS login Keychain via the `security` CLI (no cgo, keeps the static binary). Keychain is the default on macOS; set `keychain: false` to opt out. An existing file session is migrated into the Keychain on first use and the file is removed; logout clears both. Co-Authored-By: Claude Opus 4.8 --- README.md | 7 +- cmd/tg/accounts.go | 10 +-- cmd/tg/app.go | 12 +-- cmd/tg/config.go | 5 ++ cmd/tg/logout.go | 13 +-- cmd/tg/session_keychain_darwin.go | 116 +++++++++++++++++++++++++ cmd/tg/session_keychain_darwin_test.go | 107 +++++++++++++++++++++++ cmd/tg/session_keychain_other.go | 9 ++ cmd/tg/session_store.go | 80 +++++++++++++++++ cmd/tg/session_store_test.go | 78 +++++++++++++++++ 10 files changed, 416 insertions(+), 21 deletions(-) create mode 100644 cmd/tg/session_keychain_darwin.go create mode 100644 cmd/tg/session_keychain_darwin_test.go create mode 100644 cmd/tg/session_keychain_other.go create mode 100644 cmd/tg/session_store.go create mode 100644 cmd/tg/session_store_test.go diff --git a/README.md b/README.md index f534023..d45d008 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,11 @@ binaries embed them at build time and present the session as a desktop client, s : `tg init --app-id APP_ID --app-hash APP_HASH`. The config is written to the `gotd` subdirectory of your config dir, e.g. -`~/.config/gotd/gotd.cli.yaml`. The session persists there too, so subsequent -commands run headless. +`~/.config/gotd/gotd.cli.yaml`. The session persists too, so subsequent commands +run headless. On macOS the session is stored in the login Keychain by default; an +existing file session is migrated into the Keychain automatically on first use. +Set `keychain: false` in the config to keep it in a file alongside the config +instead (useful for headless macOS). Other platforms always use a file. ### Login options diff --git a/cmd/tg/accounts.go b/cmd/tg/accounts.go index b3d3900..48fd160 100644 --- a/cmd/tg/accounts.go +++ b/cmd/tg/accounts.go @@ -4,7 +4,6 @@ import ( "fmt" "io" "os" - "path/filepath" "github.com/go-faster/errors" "github.com/spf13/cobra" @@ -50,7 +49,6 @@ func (a *app) newAccountsCmd() *cobra.Command { Long: "List configured accounts (with auth status), or add/remove named accounts.", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - dir := filepath.Dir(a.configPath) def := a.cfg.resolvedDefault() var res accountsResult for _, label := range a.cfg.labels() { @@ -58,13 +56,15 @@ func (a *app) newAccountsCmd() *cobra.Command { if err != nil { return err } - sessionPath := acc.sessionPath(dir, label, kindUser) - _, statErr := os.Stat(sessionPath) + hasSession, err := a.sessionStore(label, acc, kindUser).Exists(cmd.Context()) + if err != nil { + return errors.Wrapf(err, "check session for %s", label) + } res.Accounts = append(res.Accounts, accountStatus{ Label: label, AppID: acc.AppID, HasBot: acc.BotToken != "", - HasSession: statErr == nil, + HasSession: hasSession, Default: label == def, }) } diff --git a/cmd/tg/app.go b/cmd/tg/app.go index 813e676..9a8529c 100644 --- a/cmd/tg/app.go +++ b/cmd/tg/app.go @@ -2,7 +2,6 @@ package main import ( "context" - "path/filepath" "github.com/go-faster/errors" "github.com/spf13/cobra" @@ -10,7 +9,6 @@ import ( "github.com/gotd/contrib/middleware/floodwait" "github.com/gotd/log/logzap" - "github.com/gotd/td/session" "github.com/gotd/td/telegram" "github.com/gotd/td/telegram/dcs" "github.com/gotd/td/tg" @@ -189,12 +187,10 @@ func (a *app) optionsFor(st *accountState, rp runParams, d tg.UpdateDispatcher) } opts := telegram.Options{ - Logger: logzap.New(a.log.Named("tg")), - Device: deviceConfig(), - Middlewares: mw, - SessionStorage: &session.FileStorage{ - Path: st.acc.sessionPath(filepath.Dir(a.configPath), st.label, rp.auth.String()), - }, + Logger: logzap.New(a.log.Named("tg")), + Device: deviceConfig(), + Middlewares: mw, + SessionStorage: a.sessionStore(st.label, st.acc, rp.auth.String()), } if rp.updates { opts.UpdateHandler = d diff --git a/cmd/tg/config.go b/cmd/tg/config.go index f422413..1e5f87c 100644 --- a/cmd/tg/config.go +++ b/cmd/tg/config.go @@ -42,6 +42,11 @@ type Config struct { // Empty means the top-level "default" account. DefaultAccount string `yaml:"default_account,omitempty"` + // Keychain stores the session in the macOS Keychain instead of a file. + // nil (unset) defaults to on; set `keychain: false` to opt out (e.g. on + // headless macOS). Ignored off macOS, where sessions are always files. + Keychain *bool `yaml:"keychain,omitempty"` + // Accounts holds additional named accounts, usable via --account