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-29T11:32:04Z
576 lines
17 KiB
Go
576 lines
17 KiB
Go
package launchcode
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"ant-chrome/backend/internal/browser"
|
|
"ant-chrome/backend/internal/logger"
|
|
)
|
|
|
|
// ProfileWriteRequest 用于创建/更新实例配置。
|
|
// profile 为持久化配置;start 为本次自动启动的临时参数。
|
|
type ProfileWriteRequest struct {
|
|
Profile *browser.ProfileInput `json:"profile"`
|
|
LaunchCode string `json:"launchCode"`
|
|
AutoLaunch bool `json:"autoLaunch"`
|
|
Start *LaunchRequestParams `json:"start"`
|
|
}
|
|
|
|
type profileCreator interface {
|
|
CreateProfile(input browser.ProfileInput) (*browser.Profile, error)
|
|
}
|
|
|
|
type profileUpdater interface {
|
|
UpdateProfile(profileID string, input browser.ProfileInput) (*browser.Profile, error)
|
|
}
|
|
|
|
type profileDeleter interface {
|
|
DeleteProfile(profileID string) error
|
|
}
|
|
|
|
func (s *LaunchServer) handleProfiles(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
s.handleListProfiles(w, r)
|
|
case http.MethodPost:
|
|
s.handleCreateProfile(w, r)
|
|
default:
|
|
writeJSON(w, http.StatusMethodNotAllowed, map[string]interface{}{
|
|
"ok": false,
|
|
"error": "method not allowed",
|
|
})
|
|
}
|
|
}
|
|
|
|
func (s *LaunchServer) handleProfileByID(w http.ResponseWriter, r *http.Request) {
|
|
profileID, ok := parseProfilePathID(r.URL.Path)
|
|
if !ok {
|
|
writeJSON(w, http.StatusNotFound, map[string]interface{}{
|
|
"ok": false,
|
|
"error": "profile not found",
|
|
})
|
|
return
|
|
}
|
|
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
s.handleGetProfile(w, r, profileID)
|
|
case http.MethodPut:
|
|
s.handleUpdateProfile(w, r, profileID)
|
|
case http.MethodDelete:
|
|
s.handleDeleteProfile(w, r, profileID)
|
|
default:
|
|
writeJSON(w, http.StatusMethodNotAllowed, map[string]interface{}{
|
|
"ok": false,
|
|
"error": "method not allowed",
|
|
})
|
|
}
|
|
}
|
|
|
|
// handleCreateProfile POST /api/profiles
|
|
func (s *LaunchServer) handleCreateProfile(w http.ResponseWriter, r *http.Request) {
|
|
log := logger.New("LaunchServer")
|
|
startAt := time.Now()
|
|
|
|
req, status, errMsg := decodeProfileWriteRequest(r)
|
|
if errMsg != "" {
|
|
writeJSON(w, status, map[string]interface{}{
|
|
"ok": false,
|
|
"error": errMsg,
|
|
})
|
|
return
|
|
}
|
|
|
|
input := normalizeProfileInput(*req.Profile)
|
|
profile, launchCode, status, errMsg := s.createProfile(input, req.LaunchCode)
|
|
if errMsg != "" {
|
|
writeJSON(w, status, map[string]interface{}{
|
|
"ok": false,
|
|
"error": errMsg,
|
|
})
|
|
return
|
|
}
|
|
|
|
launchedProfile, launched, launchErr := s.maybeAutoLaunchProfile(profile, req)
|
|
if launchErr != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{
|
|
"ok": false,
|
|
"created": true,
|
|
"updated": false,
|
|
"launched": false,
|
|
"profileId": profile.ProfileId,
|
|
"profileName": profile.ProfileName,
|
|
"launchCode": launchCode,
|
|
"profile": profile,
|
|
"error": launchErr.Error(),
|
|
})
|
|
log.Warn("Profile API 创建后自动启动失败",
|
|
logger.F("profile_id", profile.ProfileId),
|
|
logger.F("profile_name", profile.ProfileName),
|
|
logger.F("launch_code", launchCode),
|
|
logger.F("duration_ms", time.Since(startAt).Milliseconds()),
|
|
logger.F("error", launchErr.Error()),
|
|
)
|
|
return
|
|
}
|
|
if launched {
|
|
mergeProfileRuntime(profile, launchedProfile)
|
|
s.SetActiveProfile(profile)
|
|
}
|
|
|
|
writeJSON(w, http.StatusCreated, s.profileWriteSuccessPayload(profile, launchCode, true, false, launched))
|
|
log.Info("Profile API 创建实例",
|
|
logger.F("profile_id", profile.ProfileId),
|
|
logger.F("profile_name", profile.ProfileName),
|
|
logger.F("launch_code", launchCode),
|
|
logger.F("auto_launch", launched),
|
|
logger.F("duration_ms", time.Since(startAt).Milliseconds()),
|
|
)
|
|
}
|
|
|
|
func (s *LaunchServer) handleListProfiles(w http.ResponseWriter, _ *http.Request) {
|
|
items, status, errMsg := s.listProfiles()
|
|
if errMsg != "" {
|
|
writeJSON(w, status, map[string]interface{}{
|
|
"ok": false,
|
|
"error": errMsg,
|
|
})
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"ok": true,
|
|
"count": len(items),
|
|
"items": items,
|
|
})
|
|
}
|
|
|
|
func (s *LaunchServer) handleGetProfile(w http.ResponseWriter, _ *http.Request, profileID string) {
|
|
profile, status, errMsg := s.profileSnapshotByID(profileID)
|
|
if errMsg != "" {
|
|
writeJSON(w, status, map[string]interface{}{
|
|
"ok": false,
|
|
"error": errMsg,
|
|
})
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"ok": true,
|
|
"profileId": profile.ProfileId,
|
|
"profileName": profile.ProfileName,
|
|
"launchCode": profile.LaunchCode,
|
|
"profile": profile,
|
|
})
|
|
}
|
|
|
|
func (s *LaunchServer) handleUpdateProfile(w http.ResponseWriter, r *http.Request, profileID string) {
|
|
log := logger.New("LaunchServer")
|
|
startAt := time.Now()
|
|
|
|
previous, status, errMsg := s.profileSnapshotByID(profileID)
|
|
if errMsg != "" {
|
|
writeJSON(w, status, map[string]interface{}{
|
|
"ok": false,
|
|
"error": errMsg,
|
|
})
|
|
return
|
|
}
|
|
|
|
req, status, errMsg := decodeProfileWriteRequest(r)
|
|
if errMsg != "" {
|
|
writeJSON(w, status, map[string]interface{}{
|
|
"ok": false,
|
|
"error": errMsg,
|
|
})
|
|
return
|
|
}
|
|
|
|
input := normalizeProfileInput(*req.Profile)
|
|
profile, launchCode, status, errMsg := s.updateProfile(profileID, input, req.LaunchCode, previous)
|
|
if errMsg != "" {
|
|
writeJSON(w, status, map[string]interface{}{
|
|
"ok": false,
|
|
"error": errMsg,
|
|
})
|
|
return
|
|
}
|
|
|
|
launchedProfile, launched, launchErr := s.maybeAutoLaunchProfile(profile, req)
|
|
if launchErr != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{
|
|
"ok": false,
|
|
"created": false,
|
|
"updated": true,
|
|
"launched": false,
|
|
"profileId": profile.ProfileId,
|
|
"profileName": profile.ProfileName,
|
|
"launchCode": launchCode,
|
|
"profile": profile,
|
|
"error": launchErr.Error(),
|
|
})
|
|
log.Warn("Profile API 更新后自动启动失败",
|
|
logger.F("profile_id", profile.ProfileId),
|
|
logger.F("profile_name", profile.ProfileName),
|
|
logger.F("launch_code", launchCode),
|
|
logger.F("duration_ms", time.Since(startAt).Milliseconds()),
|
|
logger.F("error", launchErr.Error()),
|
|
)
|
|
return
|
|
}
|
|
if launched {
|
|
mergeProfileRuntime(profile, launchedProfile)
|
|
s.SetActiveProfile(profile)
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, s.profileWriteSuccessPayload(profile, launchCode, false, true, launched))
|
|
log.Info("Profile API 更新实例",
|
|
logger.F("profile_id", profile.ProfileId),
|
|
logger.F("profile_name", profile.ProfileName),
|
|
logger.F("launch_code", launchCode),
|
|
logger.F("auto_launch", launched),
|
|
logger.F("duration_ms", time.Since(startAt).Milliseconds()),
|
|
)
|
|
}
|
|
|
|
func (s *LaunchServer) handleDeleteProfile(w http.ResponseWriter, _ *http.Request, profileID string) {
|
|
profile, status, errMsg := s.profileSnapshotByID(profileID)
|
|
if errMsg != "" {
|
|
writeJSON(w, status, map[string]interface{}{
|
|
"ok": false,
|
|
"error": errMsg,
|
|
})
|
|
return
|
|
}
|
|
if profile.Running {
|
|
writeJSON(w, http.StatusConflict, map[string]interface{}{
|
|
"ok": false,
|
|
"error": "running profile cannot be deleted",
|
|
})
|
|
return
|
|
}
|
|
|
|
if err := s.deleteProfileInternal(profileID); err != nil {
|
|
writeJSON(w, mapProfileWriteErrorStatus(err), map[string]interface{}{
|
|
"ok": false,
|
|
"error": err.Error(),
|
|
})
|
|
return
|
|
}
|
|
if s.service != nil {
|
|
_ = s.service.Remove(profileID)
|
|
}
|
|
s.ClearActiveProfile(profileID)
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"ok": true,
|
|
"deleted": true,
|
|
"profileId": profileID,
|
|
"profileName": profile.ProfileName,
|
|
"launchCode": profile.LaunchCode,
|
|
})
|
|
}
|
|
|
|
func (s *LaunchServer) createProfile(input browser.ProfileInput, requestedCode string) (*browser.Profile, string, int, string) {
|
|
profile, err := s.createProfileInternal(input)
|
|
if err != nil {
|
|
return nil, "", mapProfileWriteErrorStatus(err), err.Error()
|
|
}
|
|
if profile == nil {
|
|
return nil, "", http.StatusInternalServerError, "profile creation returned nil profile"
|
|
}
|
|
|
|
launchCode, status, errMsg := s.applyRequestedLaunchCode(profile.ProfileId, strings.TrimSpace(profile.LaunchCode), requestedCode)
|
|
if errMsg != "" {
|
|
_ = s.deleteCreatedProfile(profile.ProfileId)
|
|
return nil, "", status, errMsg
|
|
}
|
|
profile.LaunchCode = launchCode
|
|
return profile, launchCode, http.StatusCreated, ""
|
|
}
|
|
|
|
func (s *LaunchServer) updateProfile(profileID string, input browser.ProfileInput, requestedCode string, previous *browser.Profile) (*browser.Profile, string, int, string) {
|
|
profile, err := s.updateProfileInternal(profileID, input)
|
|
if err != nil {
|
|
return nil, "", mapProfileWriteErrorStatus(err), err.Error()
|
|
}
|
|
if profile == nil {
|
|
return nil, "", http.StatusInternalServerError, "profile update returned nil profile"
|
|
}
|
|
|
|
currentCode := ""
|
|
if previous != nil {
|
|
currentCode = strings.TrimSpace(previous.LaunchCode)
|
|
}
|
|
launchCode, status, errMsg := s.applyRequestedLaunchCode(profile.ProfileId, currentCode, requestedCode)
|
|
if errMsg != "" {
|
|
if rollbackErr := s.rollbackProfileUpdate(profileID, previous); rollbackErr != nil {
|
|
logger.New("LaunchServer").Warn("Profile API 更新回滚失败",
|
|
logger.F("profile_id", profileID),
|
|
logger.F("error", rollbackErr.Error()),
|
|
)
|
|
}
|
|
return nil, "", status, errMsg
|
|
}
|
|
profile.LaunchCode = launchCode
|
|
return profile, launchCode, http.StatusOK, ""
|
|
}
|
|
|
|
func (s *LaunchServer) maybeAutoLaunchProfile(profile *browser.Profile, req ProfileWriteRequest) (*browser.Profile, bool, error) {
|
|
if profile == nil || !req.AutoLaunch {
|
|
return nil, false, nil
|
|
}
|
|
|
|
params := LaunchRequestParams{}
|
|
if req.Start != nil {
|
|
params = LaunchRequestParams{
|
|
LaunchArgs: normalizeStringSlice(req.Start.LaunchArgs),
|
|
StartURLs: normalizeStringSlice(req.Start.StartURLs),
|
|
SkipDefaultStartURLs: req.Start.SkipDefaultStartURLs,
|
|
}
|
|
}
|
|
|
|
launchedProfile, err := s.launchProfile(profile.ProfileId, params)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
return launchedProfile, true, nil
|
|
}
|
|
|
|
func (s *LaunchServer) createProfileInternal(input browser.ProfileInput) (*browser.Profile, error) {
|
|
if creator, ok := s.starter.(profileCreator); ok {
|
|
return creator.CreateProfile(input)
|
|
}
|
|
if s.browserMgr != nil {
|
|
return s.browserMgr.Create(input)
|
|
}
|
|
return nil, http.ErrNotSupported
|
|
}
|
|
|
|
func (s *LaunchServer) updateProfileInternal(profileID string, input browser.ProfileInput) (*browser.Profile, error) {
|
|
if updater, ok := s.starter.(profileUpdater); ok {
|
|
return updater.UpdateProfile(profileID, input)
|
|
}
|
|
if s.browserMgr != nil {
|
|
return s.browserMgr.Update(profileID, input)
|
|
}
|
|
return nil, http.ErrNotSupported
|
|
}
|
|
|
|
func (s *LaunchServer) deleteCreatedProfile(profileID string) error {
|
|
if deleter, ok := s.starter.(profileDeleter); ok {
|
|
return deleter.DeleteProfile(profileID)
|
|
}
|
|
if s.browserMgr != nil {
|
|
return s.browserMgr.Delete(profileID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *LaunchServer) deleteProfileInternal(profileID string) error {
|
|
return s.deleteCreatedProfile(profileID)
|
|
}
|
|
|
|
func (s *LaunchServer) rollbackProfileUpdate(profileID string, previous *browser.Profile) error {
|
|
if previous == nil {
|
|
return nil
|
|
}
|
|
_, err := s.updateProfileInternal(profileID, profileToInput(previous))
|
|
return err
|
|
}
|
|
|
|
func (s *LaunchServer) listProfiles() ([]browser.Profile, int, string) {
|
|
if s.browserMgr == nil {
|
|
return nil, http.StatusServiceUnavailable, "profile catalog is not available"
|
|
}
|
|
|
|
items := s.browserMgr.List()
|
|
for i := range items {
|
|
items[i].LaunchCode = s.resolveProfileLaunchCode(items[i].ProfileId, items[i].LaunchCode)
|
|
}
|
|
return items, http.StatusOK, ""
|
|
}
|
|
|
|
func (s *LaunchServer) profileSnapshotByID(profileID string) (*browser.Profile, int, string) {
|
|
profileID = strings.TrimSpace(profileID)
|
|
if profileID == "" {
|
|
return nil, http.StatusNotFound, "profile not found"
|
|
}
|
|
if s.browserMgr == nil {
|
|
return nil, http.StatusServiceUnavailable, "profile catalog is not available"
|
|
}
|
|
|
|
s.browserMgr.Mutex.Lock()
|
|
profile, ok := s.browserMgr.Profiles[profileID]
|
|
var snapshot browser.Profile
|
|
if ok && profile != nil {
|
|
snapshot = *profile
|
|
}
|
|
s.browserMgr.Mutex.Unlock()
|
|
if !ok {
|
|
return nil, http.StatusNotFound, "profile not found"
|
|
}
|
|
|
|
snapshot.LaunchCode = s.resolveProfileLaunchCode(snapshot.ProfileId, snapshot.LaunchCode)
|
|
return &snapshot, http.StatusOK, ""
|
|
}
|
|
|
|
func (s *LaunchServer) applyRequestedLaunchCode(profileID, currentCode, requestedCode string) (string, int, string) {
|
|
currentCode = strings.TrimSpace(currentCode)
|
|
requestedCode = strings.TrimSpace(requestedCode)
|
|
if requestedCode == "" {
|
|
return s.resolveProfileLaunchCode(profileID, currentCode), http.StatusOK, ""
|
|
}
|
|
if s.service == nil {
|
|
return "", http.StatusServiceUnavailable, "launch code service is unavailable"
|
|
}
|
|
|
|
code, err := s.service.SetCode(profileID, requestedCode)
|
|
if err != nil {
|
|
return "", mapProfileWriteErrorStatus(err), err.Error()
|
|
}
|
|
return code, http.StatusOK, ""
|
|
}
|
|
|
|
func (s *LaunchServer) resolveProfileLaunchCode(profileID, currentCode string) string {
|
|
if trimmed := strings.TrimSpace(currentCode); trimmed != "" {
|
|
return trimmed
|
|
}
|
|
if s.service == nil || strings.TrimSpace(profileID) == "" {
|
|
return ""
|
|
}
|
|
code, err := s.service.EnsureCode(profileID)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return code
|
|
}
|
|
|
|
func (s *LaunchServer) profileWriteSuccessPayload(profile *browser.Profile, launchCode string, created bool, updated bool, launched bool) map[string]interface{} {
|
|
payload := map[string]interface{}{
|
|
"ok": true,
|
|
"created": created,
|
|
"updated": updated,
|
|
"launched": launched,
|
|
"profileId": profile.ProfileId,
|
|
"profileName": profile.ProfileName,
|
|
"launchCode": launchCode,
|
|
"profile": profile,
|
|
}
|
|
|
|
if !launched {
|
|
return payload
|
|
}
|
|
|
|
for key, value := range s.launchSuccessPayload(profile, launchCode) {
|
|
payload[key] = value
|
|
}
|
|
payload["created"] = created
|
|
payload["updated"] = updated
|
|
payload["launched"] = true
|
|
payload["profile"] = profile
|
|
return payload
|
|
}
|
|
|
|
func decodeProfileWriteRequest(r *http.Request) (ProfileWriteRequest, int, string) {
|
|
if r.Method != http.MethodPost && r.Method != http.MethodPut {
|
|
return ProfileWriteRequest{}, http.StatusMethodNotAllowed, "method not allowed"
|
|
}
|
|
|
|
var req ProfileWriteRequest
|
|
dec := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
|
|
dec.DisallowUnknownFields()
|
|
if err := dec.Decode(&req); err != nil {
|
|
return ProfileWriteRequest{}, http.StatusBadRequest, "invalid request body"
|
|
}
|
|
if req.Profile == nil {
|
|
return ProfileWriteRequest{}, http.StatusBadRequest, "profile is required"
|
|
}
|
|
return req, http.StatusOK, ""
|
|
}
|
|
|
|
func normalizeProfileInput(input browser.ProfileInput) browser.ProfileInput {
|
|
return browser.ProfileInput{
|
|
ProfileName: strings.TrimSpace(input.ProfileName),
|
|
UserDataDir: strings.TrimSpace(input.UserDataDir),
|
|
CoreId: strings.TrimSpace(input.CoreId),
|
|
FingerprintArgs: normalizeStringSlice(input.FingerprintArgs),
|
|
ProxyId: strings.TrimSpace(input.ProxyId),
|
|
ProxyConfig: strings.TrimSpace(input.ProxyConfig),
|
|
LaunchArgs: normalizeStringSlice(input.LaunchArgs),
|
|
Tags: normalizeStringSlice(input.Tags),
|
|
Keywords: normalizeStringSlice(input.Keywords),
|
|
GroupId: strings.TrimSpace(input.GroupId),
|
|
}
|
|
}
|
|
|
|
func profileToInput(profile *browser.Profile) browser.ProfileInput {
|
|
if profile == nil {
|
|
return browser.ProfileInput{}
|
|
}
|
|
return browser.ProfileInput{
|
|
ProfileName: strings.TrimSpace(profile.ProfileName),
|
|
UserDataDir: strings.TrimSpace(profile.UserDataDir),
|
|
CoreId: strings.TrimSpace(profile.CoreId),
|
|
FingerprintArgs: append([]string{}, profile.FingerprintArgs...),
|
|
ProxyId: strings.TrimSpace(profile.ProxyId),
|
|
ProxyConfig: strings.TrimSpace(profile.ProxyConfig),
|
|
LaunchArgs: append([]string{}, profile.LaunchArgs...),
|
|
Tags: append([]string{}, profile.Tags...),
|
|
Keywords: append([]string{}, profile.Keywords...),
|
|
GroupId: strings.TrimSpace(profile.GroupId),
|
|
}
|
|
}
|
|
|
|
func mergeProfileRuntime(target, runtimeProfile *browser.Profile) {
|
|
if target == nil || runtimeProfile == nil {
|
|
return
|
|
}
|
|
target.Running = runtimeProfile.Running
|
|
target.DebugPort = runtimeProfile.DebugPort
|
|
target.DebugReady = runtimeProfile.DebugReady
|
|
target.Pid = runtimeProfile.Pid
|
|
target.RuntimeWarning = runtimeProfile.RuntimeWarning
|
|
target.LastError = runtimeProfile.LastError
|
|
target.LastStartAt = runtimeProfile.LastStartAt
|
|
target.LastStopAt = runtimeProfile.LastStopAt
|
|
}
|
|
|
|
func parseProfilePathID(path string) (string, bool) {
|
|
path = strings.TrimPrefix(path, "/api/profiles/")
|
|
path = strings.TrimSpace(path)
|
|
if path == "" || strings.Contains(path, "/") {
|
|
return "", false
|
|
}
|
|
return path, true
|
|
}
|
|
|
|
func mapProfileWriteErrorStatus(err error) int {
|
|
if err == nil {
|
|
return http.StatusOK
|
|
}
|
|
|
|
msg := strings.ToLower(strings.TrimSpace(err.Error()))
|
|
switch {
|
|
case msg == strings.ToLower(strings.TrimSpace(http.ErrNotSupported.Error())):
|
|
return http.StatusServiceUnavailable
|
|
case strings.Contains(msg, "profile not found"):
|
|
return http.StatusNotFound
|
|
case strings.Contains(msg, "running profile cannot be deleted"):
|
|
return http.StatusConflict
|
|
case strings.Contains(msg, "launch code already exists"):
|
|
return http.StatusConflict
|
|
case strings.Contains(msg, "launch code format invalid"),
|
|
strings.Contains(msg, "launch code must be"):
|
|
return http.StatusBadRequest
|
|
case strings.Contains(msg, "实例数量已达上限"):
|
|
return http.StatusConflict
|
|
default:
|
|
return http.StatusInternalServerError
|
|
}
|
|
}
|