Files
tinycast/Tinycast/Platform/KeychainSecretStore.swift
T
notsoshu 850a519d42 Rebuild the AI Providers panel: a list with pages, a say over models, and per-tool launch settings (#1206)
* Show AI providers as a list beside the selected provider's detail

The Providers panel was one long form: every installed tool and API
connection stacked as rows, with status, quota and actions squeezed into
each. It now follows Mail's Accounts: the on-device model, the installed
commands and the API connections sit in a list, each with a one-line
state and, for installed tools, its switch. The selected provider shows
its status, account, usage, command path and models beside it, with a
filter once a list runs long. API connections are added from the list's
plus menu, removed with its minus, and still edited in the existing
connection editor.

* Choose which of a provider's models the model picker lists

A route like OpenCode offers hundreds of models, and every one of them
landed in the chat's model picker. Each provider's model list is now a
set of checkboxes, with Show All and Hide All above it. A provider
nobody has trimmed keeps listing everything, including models it adds
later; once trimmed, it lists only what was ticked. The default model
always stays listed, since the picker has to show what it holds, and a
removed connection forgets its choices. The choice is per Mac and stays
out of settings backups.

The provider switch moves from the list into the selected provider's
header, so it no longer sits on the list's blue selection, and model rows
drop their effort levels, which the chat's own picker already shows.

* Give Grok, Cursor and OpenCode their own marks

Grok borrowed X's mark, Cursor a pointer symbol and OpenCode a terminal
symbol. Each now has its own, in the Providers list and in the chat's
model picker. OpenCode's inner block is drawn with `opacity`: the asset
compiler drops `fill-opacity` without a warning, which filled the block
solid and turned the mark into a window with a title bar.

* Name ChatGPT's newer plans

Pro now comes in two sizes, which the account reports as "pro" and
"prolite" and ChatGPT sells as Pro 20x and Pro 5x. Business, Enterprise
and Edu each gained plan types that read as "Account".

* List a provider's models in a table

A Form realizes every row it holds, and OpenCode offers over four hundred
models, so its list stuttered while scrolling. The checkboxes now sit in
an NSTableView inside one Form row, which builds only the rows on screen.

* Switch the on-device model or an API connection off in place

Only an installed tool could be switched off; an API connection had to be
removed, key and all, to leave the model picker. The on-device model and
every API connection now have the same switch. A route that is off stays
configured but leaves every picker, the default model moves to a route
still on, and asking for it anyway is refused with a message. Removing a
connection forgets that it was off, and the choice stays out of backups.

* Launch an installed tool from a set path, with set variables

Tinycast finds each installed tool by asking a login shell, which is
right until a Mac has two copies or a shell it cannot read. A tool can
now carry a command path that replaces the lookup, and variables set for
that tool each time it starts. Nothing sets them yet; the next commit
adds the fields.

A path with nothing to run fails the check and the turn, never falling
back to the lookup, since that would hide the mistake the path was set to
fix. The reader's variables lie over the app's own, but never over one
Tinycast sets to keep a tool inside the chat. Names are stored in
settings and values in the login Keychain; neither travels in a backup,
which must not decide what this Mac launches. Only the tool that was
edited is checked again.

* Add an Advanced section to an installed tool's detail

The command path has a field and a Choose… button whose open panel shows
hidden folders, since `~/.local/bin` is one. Variables are rows of a name
and a hidden value, added and removed one at a time. A name Tinycast sets
itself says its value is not used, and one that is not a variable name
says why. Each field saves as it is left.

* Name a Claude model by its display name when the description has no version

Claude Code used to lead a model's description with its version, "Opus 5.5
· Best for everyday…", and Tinycast named the model from that. A newer
CLI puts the version in the display name and leaves only the blurb in the
description, so every model in the picker read "Claude Best for everyday,
complex tasks". The version is now taken from the description only when
it is there, and from the display name otherwise.

* End an edit in the Providers panel with a click outside the field

A field holds the keyboard until something else takes it, and blank form
space takes nothing. The panel now releases it the way every other pane
does, which is also what saves a field that is saved as it is left.

* Show Claude's account and Codex's command, and keep Advanced while a tool is off

Claude's detail now names the account it is signed in with and its plan,
the address hidden until clicked as Codex's is. Both come from the answer
that already lists Claude's models, so nothing more is asked of the CLI.
Codex's detail names the command it ran, which the other tools already
did, and the empty command path field shows it too.

A tool that is switched off keeps its Advanced section, so a wrong path
can be fixed before it is switched on.

* Give a provider's detail its own pages

A provider's status, its models and its advanced settings shared one
scrolling form, so OpenCode's four hundred models buried everything under
them. The detail now has Overview, Models and Advanced pages behind a
segmented control, as Mail's Accounts has. An API connection has the first
two, and the on-device model, with one page, shows no control. The chosen
page is kept from one provider to the next.

* Keep the accent colour on the providers panel's page control

An editor panel wraps its content in Liquid Glass, and a segmented control
drawn on glass goes grey and bordered. The divider beside the selected
page is always hidden, so in grey the control read as missing one. The
providers panel now draws its glass behind the content, which leaves the
control its accent colour; every other editor panel is unchanged.

* Keep the MCP editor's connection control from resizing

The system segmented control opens tight around its labels and widens the
first time its selection changes, so the HTTP and Command control grew
under the pointer and stayed that wide. The editor now uses the AppKit
control with each segment pinned to its label plus the inset the control
settles at, and reports its own size from those widths, so it opens at the
size it would have ended up.

* Say Automatic in an empty command path field

The empty field showed the path the lookup had found, which read as a
path already set, and repeated the Command row on Overview. It now says
Automatic; the found path is on Overview alone.

* Credit the three new marks, and take two from the sources already credited

Cursor's mark now comes from Simple Icons and Grok's from Lobe Icons, the
two sources NOTICE already names; both look as they did. OpenCode's is
drawn after the one in its own repository, since the libraries carry the
frame without the block inside it, and its licence travels with it.

* Document the Providers panel, its pages and a tool's overrides

The feature doc gains the rules that are now invariants: a route that is
off is off everywhere, a picker lists what was ticked, a set command path
wins or fails, and a reader's variable never replaces one Tinycast sets.
It describes the panel's list and pages, and says why three more AI keys
stay out of backups and settings.json. The UI doc records the panel's
sizes and the two things learned about controls in an editor panel. The
website explains the pages, the switch, and the Advanced page.

* Keep a tool's variables when the Keychain cannot be read

A failed read loaded the variables' names with blank values, and leaving
any field in Advanced then saved the blanks over the stored values. The
read now throws, the page says it could not read them and saves only the
command path, and a save whose check read fails is refused.
2026-09-29 00:57:58 +06:00

78 lines
3.2 KiB
Swift

import Foundation
import Security
/// Login-Keychain generic passwords, one item per account inside a caller-named scope.
struct KeychainSecretStore: Sendable {
enum StoreError: Error {
case invalidEncoding
case keychain(OSStatus)
}
private let service: String
/// Every scope the app stores under, named here so the whole keychain surface is one list.
static let aiAPIKeys = KeychainSecretStore(scope: "ai-api-keys")
static let mcpSecrets = KeychainSecretStore(scope: "mcp-secrets")
static let installedAIEnvironment = KeychainSecretStore(scope: "installed-ai-environment")
init(scope: String, bundleIdentifier: String? = Bundle.main.bundleIdentifier) {
service = "\(bundleIdentifier ?? "com.tinycast.app").\(scope)"
}
func secret(for account: UUID) throws -> String? {
var result: CFTypeRef?
let status = SecItemCopyMatching(
query(for: account, returningData: true) as CFDictionary, &result)
if status == errSecItemNotFound { return nil }
guard status == errSecSuccess else { throw StoreError.keychain(status) }
guard let data = result as? Data, let secret = String(data: data, encoding: .utf8) else {
throw StoreError.invalidEncoding
}
return secret
}
/// Presence check without `kSecReturn*`, so no secret bytes are materialized.
func hasSecret(for account: UUID) throws -> Bool {
let status = SecItemCopyMatching(
query(for: account, returningData: false) as CFDictionary, nil)
if status == errSecItemNotFound { return false }
guard status == errSecSuccess else { throw StoreError.keychain(status) }
return true
}
func setSecret(_ secret: String, for account: UUID) throws {
guard let data = secret.data(using: .utf8) else { throw StoreError.invalidEncoding }
let lookup = query(for: account, returningData: false)
let update = [kSecValueData as String: data]
let status = SecItemUpdate(lookup as CFDictionary, update as CFDictionary)
if status == errSecItemNotFound {
var addition = lookup
addition[kSecValueData as String] = data
let addStatus = SecItemAdd(addition as CFDictionary, nil)
guard addStatus == errSecSuccess else { throw StoreError.keychain(addStatus) }
} else if status != errSecSuccess {
throw StoreError.keychain(status)
}
}
func removeSecret(for account: UUID) throws {
let status = SecItemDelete(query(for: account, returningData: false) as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw StoreError.keychain(status)
}
}
private func query(for account: UUID, returningData: Bool) -> [String: Any] {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account.uuidString
]
if returningData {
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
}
return query
}
}