Local apps: one person, one device

Some apps run for one person on their own device and have no sign-in of their own: a desktop window (battery/desktop), a background service the user runs (battery/service), or a phone app that runs its GoFastr server inside the app process and keeps its data on the device. A phone app whose users sign in to a central server is not one: its data lives on that server, and only the session secret stays local (localapp.LoadOrMintSecret). Each local app keeps the same three things in a private data directory. framework/localapp opens that directory, so every host gets the same file modes and the same first-run behaviour.

Open a data directory

9 lines
data, err := localapp.Open(dataDir)
if err != nil {
	log.Fatal(err)
}
app := framework.NewApp(
	framework.WithDB(data.DB),
	framework.WithSecret(data.Secret),
)
_ = app

dataDir must be an absolute path that the platform gives the app, such as Application Support on macOS or an Android app's files directory. Open creates it 0700 when it is missing and leaves the mode of an existing directory alone. It holds:

FileWhat it is
app.dbThe SQLite database (data.DB), created 0600 before SQLite opens it. localapp registers the sqlite3 driver itself, and any directory name works (a #, ? or % in it is escaped).
secretThe session secret (data.Secret), 32 bytes of crypto/rand as unpadded base64url, 0600. Sessions stay valid across restarts. A file in any other shape is refused.
identityThe installation's owner id (data.User), 16 bytes of crypto/rand as hex, 0600.

On first run Open mints the secret and the identity and publishes each one atomically: it writes an owner-only temporary file, then hard-links it into place, which fails if the file already exists. Two processes racing the first boot read the same values, and neither ever sees an empty file. A secret file other users can read (restored from a backup, say) is tightened to 0600 when Open reads it. A secret that is a symlink is refused instead, because tightening through it would change a file outside the data directory. A damaged file is refused, never replaced. A new secret would sign every session out, and a new identity would hide every row the old one owns. To start over, delete the file.

localapp.OpenDB(dir) and localapp.LoadOrMintSecret(dir) open just one piece, for a host that resolves the directory itself (the desktop battery's AppOptions).

The local identity

Owner-scoped CRUD (EntityConfig.Scope.OwnerField) needs an owner id even when nobody signs in. The host puts data.User into each request it serves with handler.SetUser. localapp.Install() sets the owner extractor that reads it back:

  • Install sets the extractor only when none is set, and returns true when another one (battery/auth's, set from its package init) owns identity.
  • When Install returns true, the host must not hand out the local identity at all. battery/auth marks a signed-out request with a nil user, and handler.GetUser cannot tell that apart from no decision, so a fallback would turn every signed-out visitor into the owner.
  • data.User.GetRoles() is ["admin"]: the one person who can reach the app owns it. The host decides which requests come from that person. The desktop battery hands the identity only to requests from its own window. An in-process mobile server has no socket, so every request comes from the app's own WebView.

localapp.Valid(id) checks the id grammar, and localapp.ForID(id) builds a User for a test.

Running with no server

A local app whose UI calls the App in-process, such as a native screen reading entities directly, needs no HTTP server at all. app.Run(ctx, framework.ServicesHost()) boots the App from the same data directory and opens no listener. Call localapp.Install() first, as a host that serves requests does, and stop if it returns true: without the extractor every owner-scoped call fails with "owner context required". Wait on app.ServicesReady(), then call app.MustCrudHandler(name) with handler.SetUser(ctx, data.User) in the context. Owner scoping applies exactly as it does to a request. See Application hosts for the lifecycle rules and a compiled example.

Common mistakes

  • Handing out the local identity when Install returned true. battery/auth owns identity in that binary. Serve the request as it arrives.
  • Passing a relative or working-directory path. Open refuses it. A desktop app launched from Finder and a phone app have no useful working directory.
  • Deleting identity to fix an error. That mints a new owner id and every stored row stays with the old one. Restore the file when you can.