Help us shape the future of Go! Take the 2026 Go Developer Survey

actnowkit

module
v0.12.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 21, 2026 License: Apache-2.0

README

actnowkit

Shared Go packages for the ActNow family of macOS apps (AudioSmith, Dicta, Auralink, VoxtNew, Pik, HereNow, CaptionCraft, Paralign, Act, Inboxly). They let the apps on one Mac find each other and share what can be shared — the Hugging Face model cache, remote-service API keys, general settings — without deleting each other's models or fighting over global shortcuts.

actnowkit is the reference implementation of the ActNow family protocol: a set of JSON files, flock(2) locks and Darwin notifications under ~/Library/Application Support/ActNow and the Hugging Face cache. An app may implement the protocol itself instead; the conformance package checks either.

  • Pure Go: builds with CGO_ENABLED=0. The macOS parts (Darwin notifications, LaunchServices, NSRunningApplication, code-signing Team ID) use purego; on other systems they compile to no-ops, so the logic is tested on Linux.
  • No UI, no dependency on MyGo, no i18n: packages export English and Chinese reference strings (family.Text) for apps to put in their own catalogues. updates takes MyGo's updater as two functions.
  • Status: v0.11 — hfhub is experimental; the other packages follow the protocol's first phase. APIs may change before v1.0.
go get github.com/hehehai/actnowkit@v0.11.0
go mod tidy   # adds purego, which only the darwin files import

Go 1.27 or later. Apple silicon and Intel Macs, macOS 12 or later.

Packages

Package What it does
family The family directory ($FAMILY), the JSON file rules (schema versions, unknown members kept, atomic replacement, corrupt files set aside), flock locks, change notifications (notify_post / notify_register_file_descriptor), the table of family apps and bundle-id variants.
registry Each app's manifest (apps/<bundle id>.json: version, path, capabilities, shortcuts, services), and discovery: manifests + LaunchServices + running processes → which family apps are installed and running.
models The shared model library ($HUB, the Hugging Face cache): resolving it, claims ("I use repo@commit", naming every model of the app that uses it), repo and blob locks compatible with huggingface_hub's .locks/, the installed check, delete decisions and deletion that keep other apps' models, tombstones, orphan clean-up, disk usage, the family pins table, model licence acceptances.
hfhub Experimental. A Hugging Face downloader on top of models: mirrors raced, parallel ranged downloads, resume (including another app's partial files), verification, and the family locks and claims.
mirror Files of GitHub releases (yt-dlp, QuickJS, ffmpeg) from github.com or the family's mirror https://mirror.actnow.dev/github: sources raced per file, a stalled, crawling or wrong source left for the next; the caller's checks unchanged.
settings settings.json (language, proxy, Hugging Face endpoints, model folder), "follow the family" flags, the first-run migration of an app's own values.
credentials Shared API keys: the vendor table, the login-Keychain item ActNow / vendor.<id> through /usr/bin/security, credentials.json (hint, fingerprint, users), the consent flow.
shortcuts Canonical key combinations, the conflict rule, who shows the conflict prompt, the family's default assignments.
runtime Running-process files (runtime/<bundle id>.json: loaded models, memory budget, pasting flag) and the family memory budget.
redact Secrets out of logs, error details and diagnostics: API keys and tokens in URL queries, presigned-URL signatures, user:pass@ in proxy URLs, Authorization headers, well-known key shapes.
sounds Cue sounds: the catalogue an app ships (catalog.json + audio, read from the app's fs.FS; actnowkit carries no audio), each event's choice (a catalogue sound, a macOS system sound, none), filters for a picker (group, tags, text, suggested uses), previews, and a low-latency player (AVAudioEngine on macOS).
licence The app's licence on this Mac, against actnow.dev (docs/licensing.md of the site): activation with a code, Ed25519 tokens bound to the Mac's device ID (IOPlatformUUID + the app's salt, hashed) and verified offline with the embedded ProductionKeys, the app's own Keychain item, refresh every 7 days, 30 days offline, clock-rollback detection, early-access (whitelist) codes stopping at paid_since, the device limit, the account page for removing a Mac, the app's own code-signature check (Team ID 779M533DL5), texts.
updates In-app updates on MyGo's updater API without MyGo's window: the state machine (checking, available, downloading with progress, ready to restart, failed), release notes in the app's language from actnow.dev's changelog API (English where a release has none), the history for Settings → About, automatic checks, and the "needs a paid licence" warning for early-access users.
listsel The row selection of the apps' log views: click to copy a line, Shift/⌘-click and drag to select rows, ⌘A, Esc; rows named by stable keys. No UI.
activity The activity cards at the sidebar's foot (the family's window spec §2): jobs grouped by kind with "done/total", a kind's finished card for 3 s, the stack's geometry (closed: the first card and two peeking under it; open: a list of four). No UI.
conformance Sample files with expected results, a fixture ($FAMILY + $HUB in temp dirs), a fake Hugging Face endpoint, and the scenario suite any implementation can run.
cmd/actnow-sounds list, check, subset (copy the sounds an app ships, with their catalogue), play (macOS) for a sound catalogue.
cmd/actnow-conformance Inspect a real family directory on a Mac: check, discover, claims, lock, watch, pins.

The protocol in brief

~/Library/Application Support/ActNow/      $FAMILY (0700; files 0600)
├── family.json                            created once
├── apps/<bundle id>.json                  each app's manifest (written by that app only)
├── settings.json                          shared settings (multi-writer)
├── credentials.json                       shared credential metadata, never secrets (multi-writer)
├── models/claims/<bundle id>.json         each app's model claims (that app only)
├── models/removed.json                    "deleted for all apps" tombstones (multi-writer)
├── models/licences.json                   model licence acceptances (multi-writer)
├── models/imported/<bundle id>.json       what the app brought over from Hugging Face caches (that app only)
├── models/hub/                            $HUB, the model library (by default; see below)
├── runtime/<bundle id>.json               running process state (that app only)
└── locks/<name>.lock                      flock locks of the multi-writer files

$HUB = $FAMILY/models/hub (or the family's models.root)       excluded from Time Machine unless models.backup is "on"
├── models--<owner>--<name>/{blobs,snapshots,refs}    the standard Hugging Face layout
├── models--<owner>--<name>/.actnow/blobs.json        verified size of each blob (the "installed" check compares)
├── .locks/models--<owner>--<name>/<blob>.lock        blob locks (the same files huggingface_hub locks)
├── .locks/models--<owner>--<name>/actnow-repo.lock   repo lock: shared to download or claim, exclusive to finish or delete
└── mlx-audio/<owner>_<name>/                         mlx-audio-swift's flat copies

Since v0.8.0 (spec v1.0.9) the library is no longer ~/.cache/huggingface/hub: HF_HUB_CACHE, HF_HOME and ~/.cache/huggingface/hub are only where Store.Import finds models to bring over (hard links on the same volume), once per app.

  • Every file has "schema": "actnow.<kind>/<major>". Readers ignore unknown members; writers keep them. A higher major is read-only.
  • Writes go through a temporary file and rename(2). Multi-writer files are read-modified-written under flock on locks/<name>.lock.
  • After writing, apps post dev.actnow.family.<topic> (apps, settings, credentials, models, runtime, memory, account). Notifications are hints: apps also re-read when activated.
  • A model is repo@commit. Files are deleted only when no other installed app claims the snapshot and no running app has it loaded.
  • Secrets live only in the login keychain.

Directories other than the real one (tests, ACTNOW_FAMILY_DIR) post notifications under dev.actnow.family.<hash of the directory>.<topic>, so test runs never wake the user's apps.

Using it in an app

At start-up
import (
	"github.com/hehehai/actnowkit/family"
	"github.com/hehehai/actnowkit/models"
	"github.com/hehehai/actnowkit/registry"
	"github.com/hehehai/actnowkit/settings"
)

const bundleID = "dev.hehehai.dicta" // the running bundle's id (….dev for dev builds)

fam := family.Default()
info, err := fam.Ensure(bundleID) // creates $FAMILY and family.json when missing
if err != nil || info.ReadOnly() {
	// keep working as before; a newer app owns the directory's format
}
fam.CleanAllTemps()

// Shared settings and the model library this app uses.
famSettings, _, _ := settings.Read(fam)
root, _ := famSettings.Get(settings.KeyModelsRoot)
hub, _ := models.ResolveHub(follow.Of(settings.KeyModelsRoot), root, own.ModelsFolder)

// The manifest: at every start, and when the version, shortcuts or services change.
registry.Write(fam, registry.Manifest{
	App: "dicta", BundleID: bundleID, Name: "Dicta", Version: version,
	Path: registry.BundlePath(), TeamID: registry.TeamID(), URLScheme: "dicta",
	Capabilities: []string{registry.CapModelsHF, registry.CapASRLocal, registry.CapLLMLocal, registry.CapMeeting},
	Models:       &registry.ModelsInfo{Root: hub},
	Links:        map[string]string{registry.LinkModels: "dicta://models"},
})

// Who else is here.
found, _ := registry.Discover(fam, registry.DefaultSystem)

store := &models.Store{
	Family: fam, Hub: hub, BundleID: bundleID, AppID: "dicta",
	Apps: func() map[string]models.AppState { return registry.States(found) },
}
store.ApplyTombstones() // drop claims on models another app deleted for all apps

// The library moved (v0.8.0): bring this app's models over from the
// Hugging Face caches, once; nothing is downloaded, the caches stay as they are.
backup := settings.ModelsBackup(famSettings)
store.Import(ctx, models.LegacyHubs(hub), backup, wanted...)
// The claims file lost with the family folder? Claim the installed models again
// (at start-up and when the app becomes active).
store.Heal(ctx, wanted...)
// Time Machine: the library is excluded unless models.backup is "on"
// (again when TopicSettings says it changed).
models.ApplyBackup(fam, hub, backup)

// Re-read on changes (and when the app becomes active).
w, _ := fam.Watch(family.TopicApps, family.TopicSettings, family.TopicModels, family.TopicMemory)
go func() {
	for topic := range w.C {
		reload(topic)
	}
}()
First run with the protocol (migration)
// Settings: merge the app's own values without overriding the user's choices.
outcomes, _ := settings.Migrate(ctx, fam, bundleID, map[string]settings.Local{
	settings.KeyLanguage:    {Value: own.Language, IsDefault: own.Language == ""},
	settings.KeyProxy:       {Value: own.Proxy, IsDefault: own.Proxy == ""},
	settings.KeyHFEndpoints: {Value: own.Endpoints, IsDefault: own.Endpoints == "auto"},
	settings.KeyModelsRoot:  {Value: own.ModelsFolder, IsDefault: own.ModelsFolder == "", InUse: hasModels},
})
for _, o := range outcomes {
	follow[o.Key] = o.Follow // keep the flags in the app's own settings
	if o.Conflict {
		// show settings.TextOtherValue once on that row
		// (for models.root: models.TextMoveModels)
	}
}

// Models: claim what of the catalogue is already installed. Disk only, no network.
for _, e := range catalogue {
	if models.Check(hub, models.Claim{Repo: e.Repo, Revision: e.Revision}).State == models.Installed {
		store.Claim(ctx, models.Claim{Repo: e.Repo, Revision: e.Revision, Model: e.ID, Role: models.RoleASR, Origin: models.OriginMigrated})
	}
}
Models: reuse, download, delete
// First-run reuse card: what of the recommended pack is already here, and from whom.
found := store.Survey(wanted...)            // found[i].By[0].Name == "AudioSmith"
store.Claim(ctx, claimsOf(found)...)        // "Use them": Origin models.OriginReused

// Download with the family locks; the result is claimed.
dl := hfhub.New(hfhub.Options{Store: store, Endpoints: settings.Endpoints(endpoints), Proxy: proxy})
err := dl.Download(ctx, hfhub.Entry{ID: "qwen3-asr-0.6b-4bit", Repo: repo, Revision: commit}, func(p hfhub.Progress) {
	if p.WaitingFor != "" { /* "Downloading in AudioSmith…" (models.TextDownloadingIn) */ }
})

// Delete: ask first who else uses it.
d, _ := store.Decide(models.Ref{Repo: repo, Revision: commit})
if d.InUse() {
	// models.TextRemoveTitle / TextRemoveBody with d.UserNames() and d.FreeBytes;
	// buttons: "Remove from Dicta" (models.ThisApp) or "Delete for all apps…" (models.AllApps)
}
res, err := store.Delete(ctx, d.Ref, models.ThisApp) // res.Deleted is false when others still use it
var busy *models.BusyError
if errors.As(err, &busy) { /* models.TextBusy: another app is downloading it */ }

// One catalogue model of several sharing a repo (an auxiliary tokenizer):
// claim with the model's id, and remove by it. The claim keeps the other
// models' names, and the files stay while any of them uses the repo.
store.Claim(ctx, models.Claim{Repo: tok, Revision: tokCommit, Model: "whisper-turbo", Aux: true})
res, err = store.DeleteModel(ctx, models.Ref{Repo: tok, Revision: tokCommit}, "whisper-turbo", models.ThisApp)
// res.OwnModels lists this app's other models still using it; store.Release only edits the claim.

// Storage page.
usage, _ := store.Usage()       // usage.Mine vs usage.Shared
orphans, _ := store.Orphans()   // models only uninstalled apps claimed; store.CleanOrphans(ctx, bid)

hfhub.Client.Delete(ctx, entry, scope) does this for an entry's main and auxiliary repos.

Locks: models.LockRepo / TryLockRepo (shared to download or claim, exclusive to finish or delete), HeldLock.Upgrade and HeldLock.Downgrade (not atomic: re-check after either), models.LockBlob / TryLockBlob.

Engines must be given the snapshot path (models.SnapshotDir(hub, ref)), never pick a snapshot themselves, and never resolve repos from ~/.cache/huggingface on their own: pass them hub too (for mlx-audio's copies).

models.Check compares each file's size with the verified size of its blob (.actnow/blobs.json, written by hfhub and Store.Import; an app's own downloader calls models.RecordBlobSizes) and with Claim.Sizes from the catalogue: a truncated file makes the model Partial (Status.Damaged).

Logs: pass every line, error detail and exported file through redact.Text (or wrap errors with redact.Error).

Shared API keys
cs := &credentials.Store{Family: fam, BundleID: bundleID} // Keychain: service "ActNow"

// Provider: the user saved a key with "Let other ActNow apps on this Mac use this key" checked.
cs.Save(ctx, "deepseek", key, credentials.SaveOptions{})
// keep credentials.Ref("deepseek") as the provider's credentialRef

// Consumer: offer it without reading the secret.
if e, ok := cs.Offer("deepseek"); ok {
	// credentials.TextUseKeyFrom: "Use the DeepSeek key from Auralink (sk-…a1b2)"
}
key, err := cs.Use(ctx, "deepseek")   // only after the user clicked "Use this key"
key, err = cs.Secret(ctx, "deepseek") // later reads; ErrNotConsented before Use

The Keychain is written with security add-generic-password -X <hex> on standard input (no escaping, nothing in the process list) and read with security find-generic-password -g (unambiguous for any secret).

Shortcuts
me := registry.HolderOf(myManifest)
for _, c := range shortcuts.FindConflicts(me, registry.Holders(found)) {
	// warning on the row; when c.Prompt, show shortcuts.TextConflict once per c.Key()
}
hits := shortcuts.UsedBy(recordedKeys, bundleID, registry.Holders(found)) // under the recorder

shortcuts.Defaults is the family's default assignment; its test fails if two defaults collide.

Memory
cap := famruntime.FamilyCap(famruntime.PhysicalMemory())
budget := famruntime.Budget(ownLimit, cap, famruntime.OthersLoadedBytes(fam, bundleID))
famruntime.Write(fam, famruntime.File{BundleID: bundleID, BudgetBytes: budget, Loaded: loaded})
// short on memory: famruntime.RequestMemory(fam); on family.TopicMemory unload models idle for famruntime.UnloadIdleAfter
// at quit: famruntime.Remove(fam, bundleID)

(famruntime is github.com/hehehai/actnowkit/runtime imported under another name.)

GitHub release downloads

Many users in China get GitHub's release files at a few KB/s. https://mirror.actnow.dev/github (Cloudflare R2, synced every 30 minutes, signatures and checksums checked when syncing) answers GitHub's paths for the releases the apps need, so an app only changes the base URL (its /releases/latest is JSON {"tag"} instead of a redirect; Latest reads both). The mirror is not trusted: the app checks what it gets exactly as before.

c := &mirror.Client{Sources: mirror.Sources(hfEndpoints), HTTP: httpClient}
tag, _, err := c.Latest(ctx, "yt-dlp/yt-dlp", validTag)          // sources raced
sums, from, err := mirror.Try(ctx, c, func(ctx context.Context, base string) ([]byte, error) {
	// a checksum file and its signature, from one source, checked together
})
file, from, err := c.File(ctx, "/yt-dlp/yt-dlp/releases/download/"+tag+"/yt-dlp_macos.zip",
	mirror.FileOptions{Dir: dir, SHA256: sum, Progress: report})

mirror.Sources reads the family's download source (network.hfEndpoints): automatic → github.com and the mirror, official → github.com, mirror → the mirror; $ACTNOW_GITHUB_SOURCES replaces them. File starts every source at once and keeps the first to deliver 256 KB; a source that then stalls for 20 s, stays under 32 KB/s for 30 s, fails or serves a file whose SHA-256 differs is left and the others race again (a stalled or crawling one once more, on a new connection). TextSourceAuto, TextSourceOfficial, TextSourceMirror and TextSourceNote are the setting's reference wordings.

Cue sounds

sounds plays the family's cue sounds and models their settings; the picker's UI is the app's. The sounds themselves are not in actnowkit: the family's library is a private repository (its audio comes from React Bits Sound Lab, licensed for use inside apps but not for redistribution on its own), and each app copies the sounds it offers into its bundle (go run github.com/hehehai/actnowkit/cmd/actnow-sounds subset -from … -to resources/sounds -groups … -uses …). A build without them (a source build of an open-source app) passes a nil catalogue and plays every event's Fallback system sound.

cat, err := sounds.Load(resources, "sounds")   // nil and ErrNoCatalog when the build has none
player := sounds.NewPlayer(cat, nil)            // nil: DefaultOutput (macOS: AVAudioEngine; elsewhere silent)

var recordStart = sounds.Event{ID: "record.start", Name: sounds.Text{En: "Start recording", Zh: "开始录音"},
	Default: "tally", Fallback: "Pop", Uses: []string{"record.start"}}

prefs := sounds.DefaultPrefs()                  // {On: true, Volume: 70}: kept in the app's own settings
player.Preload(prefs.Choice(recordStart))       // at start-up, off the main thread
player.PlayEvent(recordStart, prefs)            // returns at once
t := prefs.Target(cat, recordStart)             // what it plays; t.Length(cat) = how long it is heard

// The picker
f := sounds.Filter{Group: "notify", Tags: []string{"soft"}, Query: "bell"}
rows := cat.Filter(f)                           // + cat.GroupCounts(f), cat.TagCounts(f, true) for the chips
suggested := cat.Suggested(recordStart)         // its default, then the sounds of its uses
player.Preview(row.ID, "", prefs.Volume)        // ▶: cuts off the last preview
ch, left := player.Previewing()                 // ▶ in the accent on ch, redraw after left; StopPreview stops it
step := sounds.NearestVolumeStep(prefs.Volume)  // the volume menu's checked step (VolumeSteps), VolumeLevel its icon
prefs.Choose(recordStart, row.ID)               // or sounds.None, or sounds.System("Glass")
player.Preview(prefs.Choice(recordStart), recordStart.Fallback, prefs.Volume) // "Test"

A choice is "" (the default), "none", "system:<name>" or a sound id. Migrating an old setting that named a system sound: MigrateSystem(ev, name, wasDefault) gives users who never changed it the new default and keeps an explicit choice as system:<name>; MigrateClosest(cat, name) maps it to the sound that replaces it (Sound.Replaces). On macOS the catalogue's WAV files are decoded once (DecodeWAV) into an AVAudioEngine with 8 player nodes: a start takes microseconds and quick repeats overlap; the engine stops after 5 s without a sound. Other files use AVAudioPlayer, system sounds NSSound. Tests use sounds.Recorder.

Log selection

listsel.Model[K] is the selection behind every app's log view, so the same gestures do the same thing everywhere. K is a stable key of a row (the log's sequence number), never its place in the list. The view reports the primary button's press, moves and release with the row under the pointer, and draws IsSelected:

Gesture What happens
Click (no modifier, no drag) Release returns CopyRow: copy that line. The selection clears; the row becomes the anchor, not shown selected.
Shift-click The rows from the anchor to this one, in display order.
⌘-click (Ctrl elsewhere) Add the row or take it out; it becomes the anchor.
Press and move more than DragSlop (4 DIPs) Select from the row pressed to the row under the pointer, up or down; with ⌘ held, add them.
⌘A / Esc SelectAll(shown) / Clear() (the anchor stays).
Right-click, Control-click on macOS The context menu (the app's): not reported to the Model.
var sel listsel.Model[uint64]          // with the view's state

sel.Prune(shownKeys)                   // every build: lines filtered out or rotated away leave the selection
// pointer down (primary) on a row:
sel.Press(row.Seq, x, y, mods)         // mods: listsel.Extend (Shift) | listsel.Toggle (⌘)
// pointer moved while down:
if sel.Move(x, y, rowUnder, overRow, shownKeys) { redraw() }
// pointer up:
if in, k := sel.Release(shownKeys); in == listsel.CopyRow { copyLines(k) }
// Esc while dragging: sel.Cancel(); else sel.Clear()

picked := listsel.Items(&sel, shown, func(e Entry) uint64 { return e.Seq })
clipboard(listsel.Text(picked, format)) // lines end in "\n"; a single line has none
sel.Clear()                            // copying clears the selection

listsel.TextSelected, TextCopy, TextDeselect, TextCopyLine, TextCopySelected, TextCopyShown and TextCopied are the reference wordings (English and Chinese) for the apps' catalogues. The family's interaction spec (header, context menu, colours, tests) is VoxtNew's reference implementation.

Activity cards

activity.Tracker turns the app's unfinished jobs into the cards at the foot of the sidebar, the same way in every app; the app adds its own cards around them (a stopped engine first, then what the user started — a recording, a project being made —, then these, then downloads), words them and draws them.

var tr activity.Tracker                 // with the window's state

// a job ended (jobs.Updated):
tr.Ended(j.Kind, kindTitleOr(j), j.State == jobs.StateSucceeded)

// each build:
cards := tr.Cards(now, unfinished, kindTitle) // kindTitle("render.export") = "Exporting", "" none
for _, c := range cards {
	switch {
	case c.Finished: // "%s done", "%d items" (c.Done); it goes after 3 s
	case c.Total > 1: // "2/5" (c.Done, c.Total), c.Frac
	case c.Waiting: // "Waiting"
	default: // c.Frac: 0–1, or -1 going on
	}
}
if activity.Showing(cards) { c.After(time.Second) }

// the stack: activity.Height(n, open), activity.Poses(n, open) (Top, Y,
// Scale, Fade of each card drawn, the first last so it is on top),
// activity.CardHeight, CardGap, CardPeek, Show.
Licences (paid apps)
import "github.com/hehehai/actnowkit/licence"

lic, err := licence.New(licence.Config{
	App:        "captioncraft",                          // the site's slug
	Version:    version,                                 // mygo.App.Version()
	DeviceSalt: "dev.hehehai.captioncraft/9d1c4e7a5b20", // a constant of the app
	Keys:       licence.ProductionKeys(),                // embedded; never fetched
	Store:      licence.NewKeychainStore(bundleID),      // "<bundle id>.licence", not the shared item
})
if err != nil {
	return err
}
// Release builds: refuse a copy someone patched and signed again.
if releaseBuild && licence.VerifySelf(licence.TeamActNow) != nil {
	// treat as not licensed
}
st := lic.Status() // offline, at once: draw the first window from it
wake := lic.Run(ctx, func(st licence.Status) {
	// Usable → the app; Inactive/Revoked → activation page; Expired →
	// "needs renewal" (keep the data); WhitelistNotApplicable → "needs a
	// paid licence"; NeedsRefresh with ReasonUpdated or ReasonClock →
	// "connect to the internet". Texts: licence.StateText(st).Fill(…).
})
_ = wake // call after wake from sleep or when the network returns

// The activation page.
st, err = lic.Activate(ctx, typedCode)
if st.State == licence.DeviceLimit {
	// st.Devices (name, activated), st.ManageURL: Macs are removed on the site.
}
var apiErr *licence.APIError
if errors.As(err, &apiErr) {
	_ = licence.ErrorText(apiErr.Code) // wrong code, other app, revoked…
}
_ = lic.ManageURL(st.Token.CodeID) // "Remove a Mac…" opens this
_ = lic.EarlyAccessURL()           // "Request early access"
  • The token is checked offline at every launch; Run refreshes it when it is 7 days old, retries after 1, 5, 30 minutes and 2 hours when offline (Grace: still usable), and it stops working 30 days after the last refresh (Expired, ReasonOffline).
  • Early access: an app version at or above the site's paid_since refuses whitelist tokens; an early-access user who updates must go online once (NeedsRefresh, ReasonUpdated), so a paid_since set later still applies. Older builds keep working until the code expires.
  • A clock set back more than 10 minutes needs a refresh (ReasonClock); a Mac whose clock is simply wrong works after one, with the offset kept.
In-app updates
import "github.com/hehehai/actnowkit/updates"

up := updates.New(updates.Config{
	Current: version,
	Updater: updates.Funcs{
		EnabledFunc: mygo.Updater.Enabled,
		CheckFunc: func(ctx context.Context) (*updates.Release, error) {
			u, err := mygo.Updater.Check(ctx)
			if err != nil || u == nil {
				return nil, err
			}
			return &updates.Release{Version: u.Version, Notes: u.Notes, Date: u.Date, Install: u.Install}, nil
		},
	},
	History:  &updates.HistoryClient{App: "captioncraft"}, // actnow.dev's changelog API
	Language: func() string { return currentLanguage },    // "zh-Hans" or "en"
	Beta:     func() bool { return betaUpdates },
	Licence:  lic.Status,                                  // paid apps: the early-access warning
	Restart:  mygo.App.Relaunch,
	Log:      func(line string) { log.Print("updates: ", line) },
})
up.Subscribe(func(s updates.State) { /* the "update available" badge and panel */ })
up.AutoCheck(ctx, 24*time.Hour, func() bool { return autoCheck })

s := up.Check(ctx)        // Available: s.Release.Version, s.Notes (Markdown, the app's language)
if s.NeedsPaidLicence {   // early access and version ≥ paid_since
	_ = updates.TextNeedsPaid // the user may keep the version they have
}
s = up.Install(ctx, userAcceptedPaid) // Downloading (s.Fraction()) → Ready, or Failed (app unchanged)
_ = up.Restart()                      // Ready → the new version
history, _ := up.History(ctx)         // Settings → About → Changelog

Conformance

Run the scenario suite against your app's own implementation by wrapping it in an adapter:

func TestFamilyConformance(t *testing.T) {
	conformance.Run(t, func(t testing.TB, fx *conformance.Fixture, bundleID string) any {
		return newAdapter(fx.Family.Root, fx.Hub, bundleID) // implements conformance.ModelApp, CatalogApp, Downloader, SettingsApp…
	})
}

The scenarios cover model reuse without downloading, deletion protection (claims, locks, loaded models), repos shared by several models of one app, deleting for all apps (tombstones), concurrent and resumed downloads against another app, settings that follow the family, shortcut conflicts, cleaning up after an uninstalled app, and forward compatibility. conformance.KitApp is the adapter of actnowkit itself. conformance/testdata has a sample of every file with the result a reader must reach (cases.json), for implementations in any language.

On a Mac, go run github.com/hehehai/actnowkit/cmd/actnow-conformance check lists problems in the real family directory.

Development

go test -race ./...                          # Linux or macOS
ACTNOWKIT_MAC=1 go test -race ./...          # macOS: also the real notify, LaunchServices, security, scutil, audio (quiet)
ACTNOW_SOUNDS_DIR=../actnow-sounds go test ./sounds   # check a real sound catalogue
go vet ./... && staticcheck ./...

Tests never touch the real ~/Library/Application Support/ActNow or ~/.cache/huggingface: they use temporary directories (the Keychain tests a temporary keychain file).

Licence

Apache License 2.0 — see LICENSE and NOTICE.

Directories

Path Synopsis
Package activity is the logic of the ActNow apps' activity cards: the stack at the foot of the sidebar that says what is going on (the family's window spec §2).
Package activity is the logic of the ActNow apps' activity cards: the stack at the foot of the sidebar that says what is going on (the family's window spec §2).
cmd
actnow-conformance command
Command actnow-conformance inspects and exercises the ActNow family directory on a Mac, for developers of family apps:
Command actnow-conformance inspects and exercises the ActNow family directory on a Mac, for developers of family apps:
actnow-sounds command
Command actnow-sounds works with a catalogue of the family's sounds (a directory with catalog.json, as the private sound library and the apps' copies have):
Command actnow-sounds works with a catalogue of the family's sounds (a directory with catalog.json, as the private sound library and the apps' copies have):
Package conformance checks an implementation of the ActNow family protocol (spec §4–§11) against the reference behaviour, whether or not it uses actnowkit:
Package conformance checks an implementation of the ActNow family protocol (spec §4–§11) against the reference behaviour, whether or not it uses actnowkit:
Package credentials shares remote-service credentials between family apps (spec §8): one login-Keychain item per vendor account (service "ActNow", account "vendor.<id>", written with /usr/bin/security), and credentials.json for what is not secret (a hint, a fingerprint, which apps use it).
Package credentials shares remote-service credentials between family apps (spec §8): one login-Keychain item per vendor account (service "ActNow", account "vendor.<id>", written with /usr/bin/security), and credentials.json for what is not secret (a hint, a fingerprint, which apps use it).
Package family is the base of the ActNow family protocol (spec §4–§6.1): where the shared directory is, the JSON file conventions every shared file follows (schema versions, unknown fields kept, atomic replacement, corrupt files moved aside), flock(2) locks, Darwin change notifications, and the table of known family apps.
Package family is the base of the ActNow family protocol (spec §4–§6.1): where the shared directory is, the JSON file conventions every shared file follows (schema versions, unknown fields kept, atomic replacement, corrupt files moved aside), flock(2) locks, Darwin change notifications, and the table of known family apps.
Package hfhub downloads Hugging Face models into the family model library ($HUB, spec §7), reports what of them is on disk, verifies and deletes them, holding the family locks of package models and claiming what it downloaded.
Package hfhub downloads Hugging Face models into the family model library ($HUB, spec §7), reports what of them is on disk, verifies and deletes them, holding the family locks of package models and claiming what it downloaded.
internal
darwin
Package darwin calls the macOS libraries actnowkit needs (libSystem's notify(3), Foundation/AppKit through the Objective-C runtime, the Security framework) with purego: no cgo.
Package darwin calls the macOS libraries actnowkit needs (libSystem's notify(3), Foundation/AppKit through the Objective-C runtime, the Security framework) with purego: no cgo.
Package licence activates and checks an ActNow app's licence on this Mac, against actnow.dev (protocol: docs/licensing.md of the site).
Package licence activates and checks an ActNow app's licence on this Mac, against actnow.dev (protocol: docs/licensing.md of the site).
Package listsel is the row selection of the ActNow apps' log views (and any list of lines to copy): which rows are selected, the anchor a Shift-click extends from, and the pointer gesture that tells a click from a drag.
Package listsel is the row selection of the ActNow apps' log views (and any list of lines to copy): which rows are selected, the anchor a Shift-click extends from, and the pointer gesture that tells a click from a drag.
Package mirror downloads the files of GitHub releases from github.com or from the family's mirror, https://mirror.actnow.dev/github, which answers GitHub's paths for the releases the apps need (spec §9.6):
Package mirror downloads the files of GitHub releases from github.com or from the family's mirror, https://mirror.actnow.dev/github, which answers GitHub's paths for the releases the apps need (spec §9.6):
Package models is the family model library (spec §7): the Hugging Face cache ($HUB) shared by every family app, with claims ("I use repo@commit, keep it"), flock locks compatible with huggingface_hub's .locks/ directory, the deletion rules that keep one app from deleting another's models, the family pins table and model licence acceptances.
Package models is the family model library (spec §7): the Hugging Face cache ($HUB) shared by every family app, with claims ("I use repo@commit, keep it"), flock locks compatible with huggingface_hub's .locks/ directory, the deletion rules that keep one app from deleting another's models, the family pins table and model licence acceptances.
Package redact removes secrets from text an app logs, shows or exports (spec §13, v1.0.9): API keys and tokens in URL queries, signatures of presigned URLs, passwords in proxy and other URLs, and bearer tokens.
Package redact removes secrets from text an app logs, shows or exports (spec §13, v1.0.9): API keys and tokens in URL queries, signatures of presigned URLs, passwords in proxy and other URLs, and bearer tokens.
Package registry is family discovery (spec §6): each app writes its manifest apps/<bundle id>.json at every start, and finds the others from their manifests, LaunchServices (installed but never started) and NSRunningApplication (running).
Package registry is family discovery (spec §6): each app writes its manifest apps/<bundle id>.json at every start, and finds the others from their manifests, LaunchServices (installed but never started) and NSRunningApplication (running).
Package runtime is the family's running-process files and memory coordination (spec §11.1): each app that loads local models writes runtime/<bundle id>.json (pid, budget, loaded models, the "pasting" flag), and budgets its memory against what the other running apps hold.
Package runtime is the family's running-process files and memory coordination (spec §11.1): each app that loads local models writes runtime/<bundle id>.json (pid, budget, loaded models, the "pasting" flag), and budgets its memory against what the other running apps hold.
Package settings is the family's shared general settings (spec §9): settings.json (interface language, proxy, Hugging Face endpoints, model folder), each app's "follow the family" flags, and the first-run migration that merges an app's existing values without overriding the user's choices.
Package settings is the family's shared general settings (spec §9): settings.json (interface language, proxy, Hugging Face endpoints, model folder), each app's "follow the family" flags, and the first-run migration that merges an app's existing values without overriding the user's choices.
Package shortcuts coordinates the family's global shortcuts (spec §10): the canonical way to write a key combination, the conflict rule between two apps' shortcuts, who shows the prompt, and the family's default assignments.
Package shortcuts coordinates the family's global shortcuts (spec §10): the canonical way to write a key combination, the conflict rule between two apps' shortcuts, who shows the prompt, and the family's default assignments.
Package sounds is the family's cue sounds: a catalogue of sounds an app ships (catalog.json and the audio files, read from an fs.FS the app passes in: actnowkit carries no audio), the choice of a sound for each of the app's events (a sound of the catalogue, a macOS system sound, or none), filtering by group, tag and text for a picker, and a player with low latency for quick repeats (AVAudioPlayer on macOS through purego; a silent output elsewhere).
Package sounds is the family's cue sounds: a catalogue of sounds an app ships (catalog.json and the audio files, read from an fs.FS the app passes in: actnowkit carries no audio), the choice of a sound for each of the app's events (a sound of the catalogue, a macOS system sound, or none), filtering by group, tag and text for a picker, and a player with low latency for quick repeats (AVAudioPlayer on macOS through purego; a silent output elsewhere).
Package updates is the in-app updater of the ActNow apps, on top of MyGo's updater API (mygo.Updater.Check, Update.Install, App.Relaunch) without MyGo's update window: the apps draw their own panel.
Package updates is the in-app updater of the ActNow apps, on top of MyGo's updater API (mygo.Updater.Check, Update.Install, App.Relaunch) without MyGo's update window: the apps draw their own panel.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL