feature,client: add serviceclientprefs for desktop client service launch (#20501)

Add serviceclientprefs, an optional feature that stores and loads the
desktop clients' saved service launch preferences, one file per login
profile.

- Add GET|POST /localapi/v0/prefs/service-clients to load and save the
  current profile's service client prefs.
- Add local client GetServiceClientPrefs and SetServiceClientPref that
  call the new local api endpoint.
- Store the prefs with the ipn/store FileStore at
  TailscaleVarRoot()/profile-data/<profileID>/service-client-prefs/<hex-encoded-key>,
  so DeleteProfile cleans them up for free. Fall back to an in-memory
  store when there's no var root.
- Register the feature and its local api route from build tagged files
  so the whole thing drops out under ts_omit_serviceclientprefs.
- Add the serviceclient package holding Pref and Prefs (saved client,
  username, database name, and last used time), so the local api client
  and desktop apps can import the types without the feature machinery.

Change-Id: I340a99c1b332d181fb1556fbf3e8003bb3b95a08
Updates: https://github.com/tailscale/tailscale/issues/20429

Signed-off-by: Rollie Ma <rollie@tailscale.com>
This commit is contained in:
Rollie Ma
2026-07-22 20:36:05 -07:00
committed by GitHub
parent 66bb4ac61f
commit f4978b4b9b
20 changed files with 597 additions and 3 deletions
+37
View File
@@ -0,0 +1,37 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ts_omit_serviceclientprefs
package local
import (
"context"
"net/http"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/feature/serviceclientprefs/serviceclient"
)
// GetServiceClientPrefs returns all of the current profile's [serviceclient.Prefs].
//
// API maturity: this method is not considered a stable API and is subject to change between releases.
func (lc *Client) GetServiceClientPrefs(ctx context.Context) (serviceclient.Prefs, error) {
body, err := lc.get200(ctx, "/localapi/v0/prefs/service-clients")
if err != nil {
return nil, err
}
return decodeJSON[serviceclient.Prefs](body)
}
// SetServiceClientPref merges the non-empty fields from an [apitype.ServiceClientPrefRequest] into the
// saved service client prefs for the current profile and returns the full updated set.
//
// API maturity: this method is not considered a stable API and is subject to change between releases.
func (lc *Client) SetServiceClientPref(ctx context.Context, req apitype.ServiceClientPrefRequest) (serviceclient.Prefs, error) {
body, err := lc.send(ctx, "POST", "/localapi/v0/prefs/service-clients", http.StatusOK, jsonBody(req))
if err != nil {
return nil, err
}
return decodeJSON[serviceclient.Prefs](body)
}
+17
View File
@@ -104,3 +104,20 @@ type OptionalFeatures struct {
// are not guaranteed to be present.)
Features map[string]bool
}
// ServiceClientPrefRequest is the body POSTed to the LocalAPI endpoint /localapi/v0/prefs/service-clients.
// Empty values for Client, Username, and DatabaseName mean "don't change this value".
type ServiceClientPrefRequest struct {
// Key is the identifier for the service client pref. Required. Format is "<serviceName>:<port>"
// where serviceName is a [tailcfg.ServiceName], e.g. "svc:my-db:5432".
Key string
// Client is the name of the client that the user picked in the service launch. Optional.
Client string `json:",omitzero"`
// Username is the username that the user entered in the service launch. Optional.
Username string `json:",omitzero"`
// DatabaseName is the database name that the user entered in the service launch. Optional.
DatabaseName string `json:",omitzero"`
}