Ctrl K
ComunidadGuías…

codersdk/appname.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package codersdk

import (
	"cmp"
	"encoding/json"
	"maps"
	"slices"
	"strings"
	"unicode"

	"github.com/google/uuid"
	"golang.org/x/xerrors"

	utilstrings "github.com/coder/coder/v2/coderd/util/strings"
)

// maxAppNameLength caps an app name in runes.
const maxAppNameLength = 64

// AppFamilyName is the bounded set of groups an app name falls into, such as
// vscode or ssh. A name Coder does not recognize falls under unknown.
type AppFamilyName string

const (
	AppFamilyVSCode          AppFamilyName = "vscode"
	AppFamilyJetBrains       AppFamilyName = "jetbrains"
	AppFamilySSH             AppFamilyName = "ssh"
	AppFamilyReconnectingPTY AppFamilyName = "reconnecting_pty"
	AppFamilyPortForwarding  AppFamilyName = "port_forwarding"
	AppFamilySFTP            AppFamilyName = "sftp"
	AppFamilyUnknown         AppFamilyName = "unknown"
)

// appFamilyDisplayNames names each family. The VS Code family covers every
// fork, so its name sets it apart from the VS Code app.
var appFamilyDisplayNames = map[AppFamilyName]string{
	AppFamilyVSCode:          "VS Code Family",
	AppFamilyJetBrains:       "JetBrains",
	AppFamilySSH:             "SSH",
	AppFamilyReconnectingPTY: "Web Terminal",
	AppFamilyPortForwarding:  "Port Forwarding",
	AppFamilySFTP:            "SFTP",
	AppFamilyUnknown:         "Unknown",
}

// DisplayName returns the family's human-readable name.
func (f AppFamilyName) DisplayName() string {
	return appFamilyDisplayNames[f]
}

// AppFamilyDisplayNames returns a copy of every family's display name.
func AppFamilyDisplayNames() map[AppFamilyName]string {
	return maps.Clone(appFamilyDisplayNames)
}

// AppNameOverflow sums the unregistered app names past the limit of 64.
const AppNameOverflow = "overflow"

// SessionCountApp is one app's session count and how to present it.
type SessionCountApp struct {
	Count int64 `json:"count"`
	// DisplayName is the registry's name for a known app, otherwise the
	// normalized identifier itself.
	DisplayName string `json:"display_name"`
	// Icon is a bundled path under /icon/, empty if the app has none.
	Icon string `json:"icon,omitempty"`
	// Family is the group this app totals under.
	Family AppFamilyName `json:"family"`
}

type sessionApp struct {
	family      AppFamilyName
	displayName string
	icon        string
}

// sessionApps keys attribution and presentation by normalized registry module
// ID. An unregistered ID shows as-is under AppFamilyUnknown.
var sessionApps = map[string]sessionApp{
	// VS Code forks report their URI scheme, with hyphens folded.
	"vscode":          {AppFamilyVSCode, "VS Code", "/icon/code.svg"},
	"vscode_insiders": {AppFamilyVSCode, "VS Code Insiders", "/icon/code-insiders.svg"},
	"vscode_web":      {AppFamilyVSCode, "VS Code Web", "/icon/code.svg"},
	"code_server":     {AppFamilyVSCode, "code-server", "/icon/code.svg"},
	"code_oss":        {AppFamilyVSCode, "Code - OSS", ""},
	"cursor":          {AppFamilyVSCode, "Cursor", "/icon/cursor.svg"},
	// Reported by Windsurf builds from before the Devin Desktop rename.
	"windsurf":          {AppFamilyVSCode, "Windsurf", "/icon/windsurf.svg"},
	"positron":          {AppFamilyVSCode, "Positron", "/icon/positron.svg"},
	"vscodium":          {AppFamilyVSCode, "VSCodium", ""},
	"vscodium_insiders": {AppFamilyVSCode, "VSCodium Insiders", ""},
	"codium":            {AppFamilyVSCode, "VSCodium", ""},
	"antigravity":       {AppFamilyVSCode, "Antigravity", "/icon/antigravity.svg"},
	"trae":              {AppFamilyVSCode, "Trae", ""},
	"trae_cn":           {AppFamilyVSCode, "Trae CN", ""},
	"kiro":              {AppFamilyVSCode, "Kiro", "/icon/kiro.svg"},
	"devin":             {AppFamilyVSCode, "Devin Desktop", "/icon/devin.svg"},
	"devin_next":        {AppFamilyVSCode, "Devin Desktop Next", "/icon/devin.svg"},
	"jetbrains":         {AppFamilyJetBrains, "JetBrains", "/icon/jetbrains.svg"},
	// JetBrains plugins report one name per IDE, whatever its edition.
	"intellij":       {AppFamilyJetBrains, "IntelliJ IDEA", "/icon/intellij.svg"},
	"pycharm":        {AppFamilyJetBrains, "PyCharm", "/icon/pycharm.svg"},
	"goland":         {AppFamilyJetBrains, "GoLand", "/icon/goland.svg"},
	"webstorm":       {AppFamilyJetBrains, "WebStorm", "/icon/webstorm.svg"},
	"phpstorm":       {AppFamilyJetBrains, "PhpStorm", "/icon/phpstorm.svg"},
	"rubymine":       {AppFamilyJetBrains, "RubyMine", "/icon/rubymine.svg"},
	"clion":          {AppFamilyJetBrains, "CLion", "/icon/clion.svg"},
	"rider":          {AppFamilyJetBrains, "Rider", "/icon/rider.svg"},
	"rustrover":      {AppFamilyJetBrains, "RustRover", "/icon/rustrover.svg"},
	"datagrip":       {AppFamilyJetBrains, "DataGrip", "/icon/datagrip.svg"},
	"dataspell":      {AppFamilyJetBrains, "DataSpell", "/icon/dataspell.svg"},
	"mps":            {AppFamilyJetBrains, "MPS", ""},
	"android_studio": {AppFamilyJetBrains, "Android Studio", "/icon/android-studio.svg"},
	// Coder's own clients don't report sftp, but other clients can. The
	// family also holds usage from the old sftp_mins column.
	"sftp": {AppFamilySFTP, "SFTP", "/icon/terminal.svg"},
	// Zed speaks SSH, so it counts toward the SSH total.
	"zed":              {AppFamilySSH, "Zed", "/icon/zed.svg"},
	"ssh":              {AppFamilySSH, "SSH", "/icon/terminal.svg"},
	"reconnecting_pty": {AppFamilyReconnectingPTY, "Web Terminal", "/icon/terminal.svg"},
	"port_forwarding":  {AppFamilyPortForwarding, "Port Forwarding", ""},
}

// SessionCountApps pairs each count with its presentation, keyed by app name.
func SessionCountApps(counts map[string]int64) map[string]SessionCountApp {
	apps := make(map[string]SessionCountApp, len(counts))
	for appName, count := range counts {
		displayName, icon, family := SessionAppPresentation(appName)
		apps[appName] = SessionCountApp{
			Count:       count,
			DisplayName: displayName,
			Icon:        icon,
			Family:      family,
		}
	}
	return apps
}

// SessionAppPresentation returns registry metadata or an unknown-family label.
func SessionAppPresentation(appName string) (displayName, icon string, family AppFamilyName) {
	appName = NormalizeAppName(appName)
	if app, ok := sessionApps[appName]; ok {
		return app.displayName, app.icon, app.family
	}

	// Accounting labels are not registered apps and must not bypass app limits.
	switch appName {
	case string(AppFamilyUnknown):
		return "Unknown", "", AppFamilyUnknown
	case AppNameOverflow:
		return "Other apps", "", AppFamilyUnknown
	default:
		return appName, "", AppFamilyUnknown
	}
}

// SessionCountAppFamilies returns a copy of the registry's app-to-family mapping.
func SessionCountAppFamilies() map[string]AppFamilyName {
	families := make(map[string]AppFamilyName, len(sessionApps))
	for appName, app := range sessionApps {
		families[appName] = app.family
	}
	return families
}

// SumByFamily totals a per-app map by family. An unregistered name totals
// under AppFamilyUnknown.
func SumByFamily(byApp map[string]int64) map[AppFamilyName]int64 {
	byFamily := make(map[AppFamilyName]int64)
	for appName, value := range byApp {
		byFamily[AppNameFamily(appName)] += value
	}
	return byFamily
}

// AppNameFamily normalizes an app name and returns its family, or
// AppFamilyUnknown.
func AppNameFamily(appName string) AppFamilyName {
	if app, ok := sessionApps[NormalizeAppName(appName)]; ok {
		return app.family
	}
	return AppFamilyUnknown
}

// AppDisplayName returns the registry name of appName, or the normalized name
// if unregistered.
func AppDisplayName(appName string) string {
	appName = NormalizeAppName(appName)
	return cmp.Or(sessionApps[appName].displayName, appName)
}

// NormalizeAppName prepares a client-supplied app name for storage and
// lookup: it strips control characters, then trims, truncates, lowercases,
// and folds hyphens to underscores. Empty becomes AppFamilyUnknown.
// Stripping control characters covers both the null bytes Postgres TEXT
// rejects and escape sequences that would otherwise reach logs and terminals.
func NormalizeAppName(appName string) string {
	appName = strings.Map(func(r rune) rune {
		if unicode.IsControl(r) {
			return -1
		}
		return r
	}, appName)
	// Trim before truncating so padding does not spend the budget, and after
	// in case the cut lands in whitespace.
	appName = strings.TrimSpace(appName)
	appName = strings.TrimSpace(utilstrings.Truncate(appName, maxAppNameLength))
	if appName == "" {
		return string(AppFamilyUnknown)
	}
	return strings.ReplaceAll(strings.ToLower(appName), "-", "_")
}

// appMapValue is what an app-keyed JSONB payload holds.
type appMapValue interface {
	int64 | []uuid.UUID
}

// DecodeAppMap decodes an app-keyed JSONB payload. An absent or SQL NULL
// payload decodes as empty; a malformed one errors rather than reading as zero.
func DecodeAppMap[V appMapValue](raw json.RawMessage) (map[string]V, error) {
	var decoded map[string]V
	if len(raw) > 0 {
		if err := json.Unmarshal(raw, &decoded); err != nil {
			return nil, xerrors.Errorf("unmarshal session app map: %w", err)
		}
	}
	if decoded == nil {
		decoded = map[string]V{}
	}
	return decoded, nil
}

// SessionAppMethod derives a session app's method from its normalized name.
// Every reporter's app name implies its method, so none is stored.
func SessionAppMethod(appName string) ConnectionLogMethod {
	switch AppFamilyName(appName) {
	case AppFamilyReconnectingPTY:
		return ConnectionLogMethodReconnectingPTY
	case AppFamilyPortForwarding:
		return ConnectionLogMethodPortForwarding
	default:
		return ConnectionLogMethodSSH
	}
}

// FoldUnregisteredSessionApps applies the session app fold rule, which
// GetTemplateInsights mirrors in SQL. Registered apps, unknown, overflow, and
// the busiest TemplateInsightsMaxUnregisteredApps other apps keep their
// entries; the rest are added to overflow. Ties break by app name in byte
// order. It edits apps in place and returns the number of apps folded.
func FoldUnregisteredSessionApps(apps map[string]int64) int {
	var unregistered []string
	for app := range apps {
		if _, ok := sessionApps[app]; !ok && app != string(AppFamilyUnknown) && app != AppNameOverflow {
			unregistered = append(unregistered, app)
		}
	}
	if len(unregistered) <= TemplateInsightsMaxUnregisteredApps {
		return 0
	}
	slices.SortFunc(unregistered, func(a, b string) int {
		return cmp.Or(cmp.Compare(apps[b], apps[a]), strings.Compare(a, b))
	})
	folded := unregistered[TemplateInsightsMaxUnregisteredApps:]
	for _, app := range folded {
		apps[AppNameOverflow] += apps[app]
		delete(apps, app)
	}
	return len(folded)
}