Files
Ant-Browser/backend/internal/launchcode/selector.go
T
Ant Browser Release Bot fc5a6fadbe publish: 1.1.0 snapshot (ff46de5)
channel: master
version: 1.1.0
source-ref: master
published-at-utc: 2026-03-20T14:14:07Z
2026-03-20 22:14:08 +08:00

383 lines
10 KiB
Go

package launchcode
import (
"ant-chrome/backend/internal/browser"
"fmt"
"net/http"
"sort"
"strings"
)
const (
launchMatchModeUnique = "unique"
launchMatchModeFirst = "first"
launchMatchModeAll = "all"
)
// LaunchSelector 定义实例选择条件。
// 推荐在 POST /api/launch 中通过 selector 传入,兼容旧版 top-level code 用法。
type LaunchSelector struct {
Code string `json:"code,omitempty"`
Key string `json:"key,omitempty"`
ProfileID string `json:"profileId,omitempty"`
ProfileName string `json:"profileName,omitempty"`
Keyword string `json:"keyword,omitempty"`
Keywords []string `json:"keywords,omitempty"`
Tag string `json:"tag,omitempty"`
Tags []string `json:"tags,omitempty"`
GroupID string `json:"groupId,omitempty"`
MatchMode string `json:"matchMode,omitempty"`
}
func mergeLaunchSelector(req LaunchRequest) LaunchSelector {
var nested LaunchSelector
if req.Selector != nil {
nested = *req.Selector
}
return normalizeLaunchSelector(LaunchSelector{
Code: firstNonEmpty(nested.Code, req.Code),
Key: firstNonEmpty(nested.Key, req.Key),
ProfileID: firstNonEmpty(nested.ProfileID, req.ProfileID),
ProfileName: firstNonEmpty(nested.ProfileName, req.ProfileName),
Keywords: appendSelectorTerms(nil, "", nested.Keywords, nested.Keyword, req.Keyword, req.Keywords),
Tags: appendSelectorTerms(nil, nested.Tag, nested.Tags, req.Tag, req.Tags),
GroupID: firstNonEmpty(nested.GroupID, req.GroupID),
MatchMode: firstNonEmpty(nested.MatchMode, req.MatchMode),
})
}
func normalizeLaunchSelector(selector LaunchSelector) LaunchSelector {
selector.Code = normalizeCode(selector.Code)
selector.Key = strings.TrimSpace(selector.Key)
selector.Keywords = normalizeSelectorTerms(appendSelectorTerms(nil, "", selector.Keywords, selector.Keyword))
selector.Tags = normalizeSelectorTerms(appendSelectorTerms(nil, selector.Tag, selector.Tags))
selector.ProfileID = strings.TrimSpace(selector.ProfileID)
selector.ProfileName = strings.TrimSpace(selector.ProfileName)
selector.GroupID = strings.TrimSpace(selector.GroupID)
selector.MatchMode = strings.ToLower(strings.TrimSpace(selector.MatchMode))
if selector.MatchMode == "" {
selector.MatchMode = defaultLaunchMatchMode(selector)
}
selector.Keyword = ""
selector.Tag = ""
return selector
}
func (selector LaunchSelector) IsEmpty() bool {
return selector.Code == "" &&
selector.Key == "" &&
selector.ProfileID == "" &&
selector.ProfileName == "" &&
selector.GroupID == "" &&
len(selector.Keywords) == 0 &&
len(selector.Tags) == 0
}
func (selector LaunchSelector) OnlyCode() bool {
return selector.Code != "" &&
selector.Key == "" &&
selector.ProfileID == "" &&
selector.ProfileName == "" &&
selector.GroupID == "" &&
len(selector.Keywords) == 0 &&
len(selector.Tags) == 0
}
func (selector LaunchSelector) Validate() error {
switch selector.MatchMode {
case "", launchMatchModeUnique, launchMatchModeFirst, launchMatchModeAll:
return nil
default:
return fmt.Errorf("matchMode must be unique, first or all")
}
}
func defaultLaunchMatchMode(selector LaunchSelector) string {
if selector.Code != "" || selector.Key != "" || len(selector.Keywords) > 0 {
return launchMatchModeFirst
}
return launchMatchModeUnique
}
func (s *LaunchServer) findProfilesBySelector(selector LaunchSelector) ([]browser.Profile, int, string) {
if selector.IsEmpty() {
return nil, http.StatusBadRequest, "selector is required"
}
if err := selector.Validate(); err != nil {
return nil, http.StatusBadRequest, err.Error()
}
if s.browserMgr == nil {
return nil, http.StatusInternalServerError, "advanced profile selector is not available"
}
snapshots := s.profileSnapshots()
if len(snapshots) == 0 {
return nil, http.StatusNotFound, "profile selector matched no instance"
}
if selector.Code != "" {
profileID, err := s.service.Resolve(selector.Code)
if err != nil {
return nil, http.StatusNotFound, "launch code not found"
}
filtered := make([]browser.Profile, 0, 1)
for _, item := range snapshots {
if item.ProfileId == profileID {
filtered = append(filtered, item)
break
}
}
snapshots = filtered
}
if selector.ProfileID != "" {
snapshots = filterProfiles(snapshots, func(item browser.Profile) bool {
return item.ProfileId == selector.ProfileID
})
}
if selector.ProfileName != "" {
snapshots = filterProfiles(snapshots, func(item browser.Profile) bool {
return strings.EqualFold(strings.TrimSpace(item.ProfileName), selector.ProfileName)
})
}
if selector.GroupID != "" {
snapshots = filterProfiles(snapshots, func(item browser.Profile) bool {
return strings.TrimSpace(item.GroupId) == selector.GroupID
})
}
if len(selector.Tags) > 0 {
snapshots = filterProfiles(snapshots, func(item browser.Profile) bool {
return profileHasAllTags(item, selector.Tags)
})
}
fuzzyQueries := selector.Keywords
if selector.Key != "" {
exactMatches := filterProfiles(snapshots, func(item browser.Profile) bool {
return profileHasExactKeyword(item, selector.Key)
})
if len(exactMatches) > 0 {
snapshots = exactMatches
} else {
fuzzyQueries = normalizeSelectorTerms(append([]string{selector.Key}, fuzzyQueries...))
}
}
if len(fuzzyQueries) > 0 {
snapshots = filterProfiles(snapshots, func(item browser.Profile) bool {
return profileMatchesAllKeywordQueries(item, fuzzyQueries)
})
}
if len(snapshots) == 0 {
if selector.OnlyCode() {
return nil, http.StatusNotFound, "launch code not found"
}
return nil, http.StatusNotFound, "profile selector matched no instance"
}
sortProfilesForSelector(snapshots)
return snapshots, http.StatusOK, ""
}
func (s *LaunchServer) findProfileBySelector(selector LaunchSelector) (browser.Profile, int, string) {
snapshots, status, errMsg := s.findProfilesBySelector(selector)
if errMsg != "" {
return browser.Profile{}, status, errMsg
}
if len(snapshots) > 1 && selector.MatchMode != launchMatchModeFirst {
return browser.Profile{}, http.StatusConflict, buildAmbiguousSelectorError(snapshots)
}
return snapshots[0], http.StatusOK, ""
}
func (s *LaunchServer) profileSnapshots() []browser.Profile {
if s.browserMgr == nil {
return nil
}
s.browserMgr.Mutex.Lock()
items := make([]browser.Profile, 0, len(s.browserMgr.Profiles))
for _, profile := range s.browserMgr.Profiles {
if profile == nil {
continue
}
items = append(items, *profile)
}
s.browserMgr.Mutex.Unlock()
if s.service != nil {
for i := range items {
if code, err := s.service.EnsureCode(items[i].ProfileId); err == nil {
items[i].LaunchCode = code
}
}
}
return items
}
func filterProfiles(items []browser.Profile, keep func(browser.Profile) bool) []browser.Profile {
filtered := make([]browser.Profile, 0, len(items))
for _, item := range items {
if keep(item) {
filtered = append(filtered, item)
}
}
return filtered
}
func profileHasAllTags(profile browser.Profile, required []string) bool {
if len(required) == 0 {
return true
}
if len(profile.Tags) == 0 {
return false
}
for _, want := range required {
found := false
for _, tag := range profile.Tags {
if strings.EqualFold(strings.TrimSpace(tag), want) {
found = true
break
}
}
if !found {
return false
}
}
return true
}
func profileHasExactKeyword(profile browser.Profile, expected string) bool {
expected = strings.TrimSpace(expected)
if expected == "" || len(profile.Keywords) == 0 {
return false
}
for _, keyword := range profile.Keywords {
if strings.EqualFold(strings.TrimSpace(keyword), expected) {
return true
}
}
return false
}
func profileMatchesAllKeywordQueries(profile browser.Profile, queries []string) bool {
if len(queries) == 0 {
return true
}
if len(profile.Keywords) == 0 {
return false
}
for _, query := range queries {
queryLower := strings.ToLower(query)
found := false
for _, keyword := range profile.Keywords {
if strings.Contains(strings.ToLower(strings.TrimSpace(keyword)), queryLower) {
found = true
break
}
}
if !found {
return false
}
}
return true
}
func sortProfilesForSelector(items []browser.Profile) {
sort.Slice(items, func(i, j int) bool {
leftName := strings.ToLower(strings.TrimSpace(items[i].ProfileName))
rightName := strings.ToLower(strings.TrimSpace(items[j].ProfileName))
if leftName != rightName {
return leftName < rightName
}
return items[i].ProfileId < items[j].ProfileId
})
}
func buildAmbiguousSelectorError(items []browser.Profile) string {
const maxPreview = 5
parts := make([]string, 0, minInt(len(items), maxPreview))
for i := 0; i < len(items) && i < maxPreview; i++ {
label := strings.TrimSpace(items[i].ProfileName)
if label == "" {
label = items[i].ProfileId
}
if items[i].LaunchCode != "" {
parts = append(parts, fmt.Sprintf("%s[id=%s, code=%s]", label, items[i].ProfileId, items[i].LaunchCode))
continue
}
parts = append(parts, fmt.Sprintf("%s[id=%s]", label, items[i].ProfileId))
}
suffix := ""
if len(items) > maxPreview {
suffix = fmt.Sprintf(" ... and %d more", len(items)-maxPreview)
}
return fmt.Sprintf("selector matched %d profiles: %s%s; use code/profileId or add groupId/tags/keywords, or set matchMode=first", len(items), strings.Join(parts, ", "), suffix)
}
func appendSelectorTerms(dst []string, single string, many []string, moreSinglesAndSlices ...interface{}) []string {
if trimmed := strings.TrimSpace(single); trimmed != "" {
dst = append(dst, trimmed)
}
dst = append(dst, many...)
for _, item := range moreSinglesAndSlices {
switch v := item.(type) {
case string:
if trimmed := strings.TrimSpace(v); trimmed != "" {
dst = append(dst, trimmed)
}
case []string:
dst = append(dst, v...)
}
}
return dst
}
func normalizeSelectorTerms(items []string) []string {
if len(items) == 0 {
return nil
}
seen := make(map[string]struct{}, len(items))
result := make([]string, 0, len(items))
for _, item := range items {
trimmed := strings.TrimSpace(item)
if trimmed == "" {
continue
}
key := strings.ToLower(trimmed)
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
result = append(result, trimmed)
}
if len(result) == 0 {
return nil
}
return result
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}