mirror of
https://github.com/black-ant/Ant-Browser.git
synced 2026-07-14 18:48:55 +08:00
channel: master version: 1.1.0 source-ref: master published-at-utc: 2026-03-20T14:14:07Z
383 lines
10 KiB
Go
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
|
|
}
|