feat: add read-only CDP target viewing
This commit is contained in:
@@ -5,18 +5,26 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"chub/internal/domain"
|
||||
)
|
||||
|
||||
const remoteDebugPortAttempts = 100
|
||||
const (
|
||||
remoteDebugPortAttempts = 100
|
||||
remoteDebugTargetsMaxBytes = 1 << 20
|
||||
remoteDebugTargetsMaxCount = 200
|
||||
remoteDebugTargetTextMax = 4096
|
||||
)
|
||||
|
||||
var (
|
||||
ErrRemoteDebugPortUnavailable = errors.New("remote debugging port is unavailable")
|
||||
@@ -28,6 +36,16 @@ type RemoteDebugEndpoint struct {
|
||||
Browser string
|
||||
}
|
||||
|
||||
// RemoteDebugTarget is a read-only summary of one target returned by CDP
|
||||
// /json/list. URL is safe to display but must never be treated as a control
|
||||
// endpoint; its userinfo, query, and fragment have been removed.
|
||||
type RemoteDebugTarget struct {
|
||||
ID string
|
||||
Type string
|
||||
Title string
|
||||
URL string
|
||||
}
|
||||
|
||||
type RemoteDebugEndpointInspector interface {
|
||||
InspectRemoteDebugEndpoint(context.Context, domain.BrowserKind, string) (RemoteDebugEndpoint, error)
|
||||
WaitForRemoteDebugEndpoint(context.Context, domain.BrowserKind, string) (RemoteDebugEndpoint, error)
|
||||
@@ -39,6 +57,12 @@ type RemoteDebugPortAllocator interface {
|
||||
FindAvailableRemoteDebugPort(context.Context, int) (int, error)
|
||||
}
|
||||
|
||||
// RemoteDebugTargetInspector reads CDP targets only from a verified loopback
|
||||
// endpoint. It intentionally exposes no WebSocket URL or browser-control API.
|
||||
type RemoteDebugTargetInspector interface {
|
||||
ListRemoteDebugTargets(context.Context, domain.BrowserKind, int) ([]RemoteDebugTarget, error)
|
||||
}
|
||||
|
||||
type CDPInspector struct {
|
||||
readFile func(string) ([]byte, error)
|
||||
client *http.Client
|
||||
@@ -47,7 +71,10 @@ type CDPInspector struct {
|
||||
func NewCDPInspector() *CDPInspector {
|
||||
return &CDPInspector{
|
||||
readFile: os.ReadFile,
|
||||
client: &http.Client{Transport: &http.Transport{Proxy: nil}},
|
||||
client: &http.Client{
|
||||
Transport: &http.Transport{Proxy: nil},
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +143,66 @@ func (i *CDPInspector) WaitForRemoteDebugPort(ctx context.Context, kind domain.B
|
||||
})
|
||||
}
|
||||
|
||||
// ListRemoteDebugTargets first validates the browser kind at the supplied
|
||||
// loopback port, then reads its bounded /json/list response. It never follows
|
||||
// redirects or exposes the response's webSocketDebuggerUrl field.
|
||||
func (i *CDPInspector) ListRemoteDebugTargets(ctx context.Context, kind domain.BrowserKind, port int) ([]RemoteDebugTarget, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if i == nil || i.client == nil || !kind.Valid() || !domain.ValidRemoteDebugPort(port) {
|
||||
return nil, ErrRemoteDebugEndpointUnavailable
|
||||
}
|
||||
if _, err := i.InspectRemoteDebugPort(ctx, kind, port); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://127.0.0.1:"+strconv.Itoa(port)+"/json/list", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: build target request", ErrRemoteDebugEndpointUnavailable)
|
||||
}
|
||||
response, err := i.client.Do(request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: query targets", ErrRemoteDebugEndpointUnavailable)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%w: target status %d", ErrRemoteDebugEndpointUnavailable, response.StatusCode)
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(response.Body, remoteDebugTargetsMaxBytes+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: read targets", ErrRemoteDebugEndpointUnavailable)
|
||||
}
|
||||
if len(data) > remoteDebugTargetsMaxBytes {
|
||||
return nil, fmt.Errorf("%w: target response exceeds %d bytes", ErrRemoteDebugEndpointUnavailable, remoteDebugTargetsMaxBytes)
|
||||
}
|
||||
var rawTargets []struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &rawTargets); err != nil {
|
||||
return nil, fmt.Errorf("%w: decode targets", ErrRemoteDebugEndpointUnavailable)
|
||||
}
|
||||
if len(rawTargets) > remoteDebugTargetsMaxCount {
|
||||
return nil, fmt.Errorf("%w: target count exceeds %d", ErrRemoteDebugEndpointUnavailable, remoteDebugTargetsMaxCount)
|
||||
}
|
||||
targets := make([]RemoteDebugTarget, 0, len(rawTargets))
|
||||
for _, raw := range rawTargets {
|
||||
id := strings.TrimSpace(raw.ID)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
targets = append(targets, RemoteDebugTarget{
|
||||
ID: truncateRemoteDebugTargetText(id),
|
||||
Type: truncateRemoteDebugTargetText(strings.TrimSpace(raw.Type)),
|
||||
Title: truncateRemoteDebugTargetText(strings.TrimSpace(raw.Title)),
|
||||
URL: sanitizeRemoteDebugTargetURL(raw.URL),
|
||||
})
|
||||
}
|
||||
return targets, nil
|
||||
}
|
||||
|
||||
func waitForRemoteDebugEndpoint(ctx context.Context, inspect func(context.Context) (RemoteDebugEndpoint, error)) (RemoteDebugEndpoint, error) {
|
||||
const pollInterval = 50 * time.Millisecond
|
||||
for {
|
||||
@@ -180,3 +267,26 @@ func browserVersionMatchesKind(browser string, kind domain.BrowserKind) bool {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeRemoteDebugTargetURL(value string) string {
|
||||
parsed, err := url.Parse(strings.TrimSpace(value))
|
||||
if err != nil || parsed == nil {
|
||||
return ""
|
||||
}
|
||||
parsed.User = nil
|
||||
parsed.RawQuery = ""
|
||||
parsed.ForceQuery = false
|
||||
parsed.Fragment = ""
|
||||
return truncateRemoteDebugTargetText(parsed.String())
|
||||
}
|
||||
|
||||
func truncateRemoteDebugTargetText(value string) string {
|
||||
if len(value) <= remoteDebugTargetTextMax {
|
||||
return value
|
||||
}
|
||||
cut := remoteDebugTargetTextMax
|
||||
for cut > 0 && !utf8.RuneStart(value[cut]) {
|
||||
cut--
|
||||
}
|
||||
return value[:cut]
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"chub/internal/domain"
|
||||
@@ -93,3 +94,76 @@ func TestCDPInspectorFindsNextAvailablePort(t *testing.T) {
|
||||
t.Fatalf("port = %d, want %d", port, start+1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCDPInspectorListsVerifiedTargetsWithoutControlEndpoints(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/json/version":
|
||||
_, _ = w.Write([]byte(`{"Browser":"Chrome/136.0.0.0"}`))
|
||||
case "/json/list":
|
||||
_, _ = w.Write([]byte(`[
|
||||
{"id":"page-1","type":"page","title":"运营后台","url":"https://user:secret@example.com/orders?token=hidden#section","webSocketDebuggerUrl":"ws://127.0.0.1/devtools/page/page-1"},
|
||||
{"id":"worker-1","type":"service_worker","title":"","url":"chrome-extension://example/worker.js?key=hidden"}
|
||||
]`))
|
||||
default:
|
||||
t.Fatalf("path = %q", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
parsed, err := url.Parse(server.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, portText, err := net.SplitHostPort(parsed.Host)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
port, _ := strconv.Atoi(portText)
|
||||
|
||||
targets, err := NewCDPInspector().ListRemoteDebugTargets(context.Background(), domain.BrowserChrome, port)
|
||||
if err != nil || len(targets) != 2 {
|
||||
t.Fatalf("ListRemoteDebugTargets() = %#v, %v", targets, err)
|
||||
}
|
||||
if targets[0].ID != "page-1" || targets[0].Type != "page" || targets[0].Title != "运营后台" || targets[0].URL != "https://example.com/orders" {
|
||||
t.Fatalf("first target = %#v", targets[0])
|
||||
}
|
||||
if targets[1].URL != "chrome-extension://example/worker.js" {
|
||||
t.Fatalf("second target URL = %q", targets[1].URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCDPInspectorRejectsMalformedOrOversizedTargetList(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
status int
|
||||
response string
|
||||
}{
|
||||
{name: "malformed JSON", status: http.StatusOK, response: `not-json`},
|
||||
{name: "too many targets", status: http.StatusOK, response: `[` + strings.Repeat(`{"id":"page","type":"page"},`, remoteDebugTargetsMaxCount) + `{"id":"last","type":"page"}]`},
|
||||
{name: "non-success status", status: http.StatusServiceUnavailable, response: `temporarily unavailable`},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/json/version" {
|
||||
_, _ = w.Write([]byte(`{"Browser":"Chrome/136.0.0.0"}`))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(test.status)
|
||||
_, _ = w.Write([]byte(test.response))
|
||||
}))
|
||||
defer server.Close()
|
||||
parsed, err := url.Parse(server.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, portText, err := net.SplitHostPort(parsed.Host)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
port, _ := strconv.Atoi(portText)
|
||||
if _, err := NewCDPInspector().ListRemoteDebugTargets(context.Background(), domain.BrowserChrome, port); !errors.Is(err, ErrRemoteDebugEndpointUnavailable) {
|
||||
t.Fatalf("ListRemoteDebugTargets() error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+296
-2
@@ -11,6 +11,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"chub/internal/domain"
|
||||
"gioui.org/io/key"
|
||||
@@ -113,6 +114,19 @@ type InstanceRefreshResult struct {
|
||||
// browser processes.
|
||||
type InstanceRefresher func(context.Context, []InstanceRow) ([]InstanceRefreshResult, error)
|
||||
|
||||
// CDPTarget contains the safe, read-only fields displayed for one Chromium
|
||||
// target. URL must already be stripped of userinfo, query, and fragment.
|
||||
type CDPTarget struct {
|
||||
ID string
|
||||
Type string
|
||||
Title string
|
||||
URL string
|
||||
}
|
||||
|
||||
// InstanceTabInspector performs a bounded, read-only target lookup for one
|
||||
// current instance snapshot. It must not send browser-control commands.
|
||||
type InstanceTabInspector func(context.Context, InstanceRow) ([]CDPTarget, error)
|
||||
|
||||
type instanceStartState struct {
|
||||
request uint64
|
||||
running bool
|
||||
@@ -149,6 +163,14 @@ type instanceRefreshResult struct {
|
||||
err error
|
||||
}
|
||||
|
||||
type instanceTabsResult struct {
|
||||
request uint64
|
||||
instanceID string
|
||||
fingerprint string
|
||||
targets []CDPTarget
|
||||
err error
|
||||
}
|
||||
|
||||
type managedExitKey struct {
|
||||
instanceID string
|
||||
launchGeneration uint64
|
||||
@@ -280,6 +302,7 @@ type Shell struct {
|
||||
editPort widget.Editor
|
||||
editBrowserKind widget.Enum
|
||||
editDirPick widget.Clickable
|
||||
editTabs widget.Clickable
|
||||
editSave widget.Clickable
|
||||
editCancel widget.Clickable
|
||||
editBlocker widget.Clickable
|
||||
@@ -311,6 +334,7 @@ type Shell struct {
|
||||
|
||||
list widget.List
|
||||
settingsList widget.List
|
||||
tabsList widget.List
|
||||
rows []InstanceRow
|
||||
selectedInstanceID string
|
||||
rowClicks map[string]*widget.Clickable
|
||||
@@ -326,6 +350,7 @@ type Shell struct {
|
||||
stopResults chan instanceStopResult
|
||||
refreshClickState instanceRefreshState
|
||||
refreshResults chan instanceRefreshResult
|
||||
tabsResults chan instanceTabsResult
|
||||
managedExitMu sync.Mutex
|
||||
managedExitResults []ManagedInstanceExit
|
||||
pendingManagedExit map[managedExitKey]ManagedInstanceExit
|
||||
@@ -344,12 +369,25 @@ type Shell struct {
|
||||
editFocusPending bool
|
||||
focusRestoreID string
|
||||
focusStartID string
|
||||
tabsOpen bool
|
||||
tabsLoading bool
|
||||
tabsRequest uint64
|
||||
tabsInstanceID string
|
||||
tabsFingerprint string
|
||||
tabsTargets []CDPTarget
|
||||
tabsFeedback string
|
||||
tabsCancel context.CancelFunc
|
||||
tabsClose widget.Clickable
|
||||
tabsBlocker widget.Clickable
|
||||
tabsFocusPending bool
|
||||
tabsReturnFocus bool
|
||||
onSave func(SettingsState)
|
||||
onInstancesChanged func([]InstanceRow)
|
||||
onProxiesChanged func([]ProxyOption)
|
||||
instanceStarter InstanceStarter
|
||||
instanceStopper InstanceStopper
|
||||
instanceRefresher InstanceRefresher
|
||||
instanceTabInspector InstanceTabInspector
|
||||
pathSearcher PathSearcher
|
||||
invalidate func()
|
||||
searches map[PathField]*pathSearchState
|
||||
@@ -370,7 +408,7 @@ func NewShell(theme *material.Theme) *Shell {
|
||||
PathEdgeExecutable: {},
|
||||
PathDefaultUserData: {},
|
||||
PathLogDirectory: {},
|
||||
}, rowClicks: make(map[string]*widget.Clickable), startClicks: make(map[string]*widget.Clickable), editClicks: make(map[string]*widget.Clickable), deleteClicks: make(map[string]*widget.Clickable), proxyPickerChoices: make(map[string]*widget.Clickable), startStates: make(map[string]*instanceStartState), stopStates: make(map[string]*instanceStopState), pendingManagedExit: make(map[managedExitKey]ManagedInstanceExit), nextInstance: 4, startResults: make(chan instanceStartResult, 8), stopResults: make(chan instanceStopResult, 8), refreshResults: make(chan instanceRefreshResult, 1), searchResults: make(chan pathSearchResult, 8), directoryResults: make(chan directoryPickResult, 1)}
|
||||
}, rowClicks: make(map[string]*widget.Clickable), startClicks: make(map[string]*widget.Clickable), editClicks: make(map[string]*widget.Clickable), deleteClicks: make(map[string]*widget.Clickable), proxyPickerChoices: make(map[string]*widget.Clickable), startStates: make(map[string]*instanceStartState), stopStates: make(map[string]*instanceStopState), pendingManagedExit: make(map[managedExitKey]ManagedInstanceExit), nextInstance: 4, startResults: make(chan instanceStartResult, 8), stopResults: make(chan instanceStopResult, 8), refreshResults: make(chan instanceRefreshResult, 1), tabsResults: make(chan instanceTabsResult, 1), searchResults: make(chan pathSearchResult, 8), directoryResults: make(chan directoryPickResult, 1)}
|
||||
s.chromePath.SetText(`C:\Program Files\Google\Chrome\Application\chrome.exe`)
|
||||
s.edgePath.SetText(`C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`)
|
||||
s.dataDir.SetText(`C:\Users\Public\chub\profiles`)
|
||||
@@ -383,6 +421,7 @@ func NewShell(theme *material.Theme) *Shell {
|
||||
s.editPort.ReadOnly = true
|
||||
s.list.Axis = layout.Vertical
|
||||
s.settingsList.Axis = layout.Vertical
|
||||
s.tabsList.Axis = layout.Vertical
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -471,6 +510,11 @@ func (s *Shell) OnRefreshInstances(refresher InstanceRefresher, invalidate func(
|
||||
s.invalidate = invalidate
|
||||
}
|
||||
|
||||
func (s *Shell) OnInspectTabs(inspector InstanceTabInspector, invalidate func()) {
|
||||
s.instanceTabInspector = inspector
|
||||
s.invalidate = invalidate
|
||||
}
|
||||
|
||||
func (s *Shell) OnPathSearch(searcher PathSearcher, invalidate func()) {
|
||||
s.pathSearcher = searcher
|
||||
s.invalidate = invalidate
|
||||
@@ -487,6 +531,7 @@ func (s *Shell) Layout(gtx layout.Context) layout.Dimensions {
|
||||
s.consumeStartResults()
|
||||
s.consumeStopResults()
|
||||
s.consumeRefreshResults()
|
||||
s.consumeTabsResults()
|
||||
s.consumeManagedExitResults()
|
||||
s.presentUnexpectedExitIfReady()
|
||||
s.consumeKeyboard(gtx)
|
||||
@@ -510,7 +555,7 @@ func (s *Shell) Layout(gtx layout.Context) layout.Dimensions {
|
||||
}),
|
||||
)
|
||||
}
|
||||
if !s.unexpectedExitOpen && s.pendingDeleteID == "" && s.pendingProxyDelete == "" && s.editingID == "" && !s.proxyPicker.open {
|
||||
if !s.unexpectedExitOpen && s.pendingDeleteID == "" && s.pendingProxyDelete == "" && s.editingID == "" && !s.proxyPicker.open && !s.tabsOpen {
|
||||
return mainLayout(gtx)
|
||||
}
|
||||
if s.unexpectedExitOpen {
|
||||
@@ -539,6 +584,13 @@ func (s *Shell) Layout(gtx layout.Context) layout.Dimensions {
|
||||
)
|
||||
}
|
||||
if s.editingID != "" {
|
||||
if s.tabsOpen {
|
||||
return layout.Stack{Alignment: layout.Center}.Layout(gtx,
|
||||
layout.Expanded(mainLayout),
|
||||
layout.Stacked(s.editDialog),
|
||||
layout.Stacked(s.tabsDialog),
|
||||
)
|
||||
}
|
||||
if !s.proxyPicker.open {
|
||||
return layout.Stack{Alignment: layout.Center}.Layout(gtx,
|
||||
layout.Expanded(mainLayout),
|
||||
@@ -628,6 +680,8 @@ func (s *Shell) consumeKeyboard(gtx layout.Context) {
|
||||
s.cancelProxyDelete()
|
||||
} else if s.proxyPicker.open {
|
||||
s.proxyPicker.open = false
|
||||
} else if s.tabsOpen {
|
||||
s.closeTabs(true)
|
||||
} else if s.pendingEditDiscard {
|
||||
s.pendingEditDiscard = false
|
||||
} else if s.editingID != "" {
|
||||
@@ -663,6 +717,14 @@ func (s *Shell) consumeRowKeyboard(gtx layout.Context, id string) {
|
||||
func (s *Shell) consumeEditControls(gtx layout.Context) {
|
||||
for s.editBlocker.Clicked(gtx) {
|
||||
}
|
||||
if s.tabsOpen {
|
||||
for s.tabsBlocker.Clicked(gtx) {
|
||||
}
|
||||
for s.tabsClose.Clicked(gtx) {
|
||||
s.closeTabs(true)
|
||||
}
|
||||
return
|
||||
}
|
||||
if s.pendingEditDiscard {
|
||||
for s.editDiscardBlocker.Clicked(gtx) {
|
||||
}
|
||||
@@ -683,12 +745,16 @@ func (s *Shell) consumeEditControls(gtx layout.Context) {
|
||||
for s.editCancel.Clicked(gtx) {
|
||||
s.cancelEdit()
|
||||
}
|
||||
for s.editTabs.Clicked(gtx) {
|
||||
s.requestTabs()
|
||||
}
|
||||
for s.editSave.Clicked(gtx) {
|
||||
s.saveEdit()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Shell) beginEdit(id string) {
|
||||
s.closeTabs(false)
|
||||
row, ok := s.instanceRow(id)
|
||||
if !ok {
|
||||
s.instanceFeedback = "找不到要编辑的实例。"
|
||||
@@ -810,6 +876,7 @@ func normalizeTargetURL(value string) (string, error) {
|
||||
}
|
||||
|
||||
func (s *Shell) closeEdit(feedback string) {
|
||||
s.closeTabs(false)
|
||||
id := s.editingID
|
||||
s.editingID = ""
|
||||
s.pendingEditDiscard = false
|
||||
@@ -1223,6 +1290,10 @@ func (s *Shell) editPreferredPortField(gtx layout.Context, locked bool) layout.D
|
||||
|
||||
func (s *Shell) editFeedbackMessage(locked bool) string {
|
||||
label := s.editFeedback
|
||||
row, exists := s.instanceRow(s.editingID)
|
||||
if label == "" && (!exists || !s.canInspectTabs(row)) {
|
||||
label = "查看标签页需要处于已验证的运行或外部关联状态,并具有实际调试端口。"
|
||||
}
|
||||
if label == "" && locked {
|
||||
label = "运行中或外部关联实例的浏览器类型和 User Data Dir 已锁定。"
|
||||
}
|
||||
@@ -1230,7 +1301,25 @@ func (s *Shell) editFeedbackMessage(locked bool) string {
|
||||
}
|
||||
|
||||
func (s *Shell) editActions(gtx layout.Context) layout.Dimensions {
|
||||
row, canInspect := s.instanceRow(s.editingID)
|
||||
canInspect = canInspect && s.canInspectTabs(row) && !s.tabsLoading
|
||||
tabsGtx := gtx
|
||||
if !canInspect {
|
||||
tabsGtx = gtx.Disabled()
|
||||
}
|
||||
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
style := material.Button(s.theme, &s.editTabs, "查看标签页")
|
||||
style.Background = color.NRGBA{R: 230, G: 232, B: 235, A: 255}
|
||||
style.Color = s.theme.Palette.Fg
|
||||
dims := style.Layout(tabsGtx)
|
||||
if s.tabsReturnFocus {
|
||||
gtx.Execute(key.FocusCmd{Tag: &s.editTabs})
|
||||
s.tabsReturnFocus = false
|
||||
}
|
||||
return dims
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
style := material.Button(s.theme, &s.editCancel, "取消")
|
||||
style.Background = color.NRGBA{R: 230, G: 232, B: 235, A: 255}
|
||||
@@ -1242,6 +1331,211 @@ func (s *Shell) editActions(gtx layout.Context) layout.Dimensions {
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Shell) canInspectTabs(row InstanceRow) bool {
|
||||
return domain.ValidRemoteDebugPort(row.RemoteDebugPort) && (row.Status == "运行中" || row.Status == "外部已关联")
|
||||
}
|
||||
|
||||
func tabsSnapshotFingerprint(row InstanceRow) string {
|
||||
return strings.Join([]string{row.ID, row.Browser, row.Status, strconv.Itoa(row.RemoteDebugPort)}, "\x00")
|
||||
}
|
||||
|
||||
func (s *Shell) requestTabs() {
|
||||
row, exists := s.instanceRow(s.editingID)
|
||||
if !exists || !s.canInspectTabs(row) {
|
||||
s.editFeedback = "查看标签页需要处于已验证的运行或外部关联状态,并具有实际调试端口。"
|
||||
return
|
||||
}
|
||||
if s.tabsLoading {
|
||||
return
|
||||
}
|
||||
s.tabsRequest++
|
||||
request := s.tabsRequest
|
||||
fingerprint := tabsSnapshotFingerprint(row)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
s.tabsCancel = cancel
|
||||
s.tabsOpen = true
|
||||
s.tabsLoading = true
|
||||
s.tabsInstanceID = row.ID
|
||||
s.tabsFingerprint = fingerprint
|
||||
s.tabsTargets = nil
|
||||
s.tabsFeedback = ""
|
||||
s.tabsFocusPending = true
|
||||
if s.instanceTabInspector == nil {
|
||||
cancel()
|
||||
s.tabsCancel = nil
|
||||
s.tabsLoading = false
|
||||
s.tabsFeedback = "CDP 页面目标服务尚未准备好。"
|
||||
return
|
||||
}
|
||||
inspector := s.instanceTabInspector
|
||||
invalidate := s.invalidate
|
||||
go func() {
|
||||
targets, err := inspector(ctx, row)
|
||||
cancel()
|
||||
s.tabsResults <- instanceTabsResult{request: request, instanceID: row.ID, fingerprint: fingerprint, targets: targets, err: err}
|
||||
if invalidate != nil {
|
||||
invalidate()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *Shell) consumeTabsResults() {
|
||||
for {
|
||||
select {
|
||||
case result := <-s.tabsResults:
|
||||
if !s.tabsOpen || result.request != s.tabsRequest || result.instanceID != s.tabsInstanceID || result.fingerprint != s.tabsFingerprint {
|
||||
continue
|
||||
}
|
||||
s.tabsLoading = false
|
||||
s.tabsCancel = nil
|
||||
row, exists := s.instanceRow(result.instanceID)
|
||||
if !exists || !s.canInspectTabs(row) || tabsSnapshotFingerprint(row) != result.fingerprint {
|
||||
s.tabsTargets = nil
|
||||
s.tabsFeedback = "实例状态或调试端口已经变化;请关闭后重新打开标签页。"
|
||||
continue
|
||||
}
|
||||
if result.err != nil {
|
||||
s.tabsTargets = nil
|
||||
s.tabsFeedback = fmt.Sprintf("无法读取标签页:%v", result.err)
|
||||
continue
|
||||
}
|
||||
s.tabsTargets = append([]CDPTarget(nil), result.targets...)
|
||||
s.tabsFeedback = ""
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Shell) closeTabs(returnFocus bool) {
|
||||
if s.tabsCancel != nil {
|
||||
s.tabsCancel()
|
||||
s.tabsCancel = nil
|
||||
}
|
||||
s.tabsRequest++
|
||||
s.tabsOpen = false
|
||||
s.tabsLoading = false
|
||||
s.tabsInstanceID = ""
|
||||
s.tabsFingerprint = ""
|
||||
s.tabsTargets = nil
|
||||
s.tabsFeedback = ""
|
||||
s.tabsFocusPending = false
|
||||
if returnFocus && s.editingID != "" {
|
||||
s.tabsReturnFocus = true
|
||||
} else if !returnFocus {
|
||||
s.tabsReturnFocus = false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Shell) tabsDialog(gtx layout.Context) layout.Dimensions {
|
||||
if !s.tabsOpen {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
gtx.Constraints.Min = gtx.Constraints.Max
|
||||
return s.tabsBlocker.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
defer clip.Rect{Max: gtx.Constraints.Min}.Push(gtx.Ops).Pop()
|
||||
paint.Fill(gtx.Ops, color.NRGBA{R: 0, G: 0, B: 0, A: 110})
|
||||
return layout.Center.Layout(gtx, s.tabsDialogCard)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Shell) tabsDialogCard(gtx layout.Context) layout.Dimensions {
|
||||
if maxWidth := gtx.Dp(720); gtx.Constraints.Max.X > maxWidth {
|
||||
gtx.Constraints.Max.X = maxWidth
|
||||
}
|
||||
return layout.Background{}.Layout(gtx,
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
rect := image.Rectangle{Max: gtx.Constraints.Min}
|
||||
if rect.Empty() {
|
||||
return layout.Dimensions{Size: rect.Max}
|
||||
}
|
||||
paint.FillShape(gtx.Ops, instanceFormWorkspaceBorder, clip.UniformRRect(rect, gtx.Dp(10)).Op(gtx.Ops))
|
||||
inner := rect.Inset(gtx.Dp(1))
|
||||
if !inner.Empty() {
|
||||
paint.FillShape(gtx.Ops, s.theme.Palette.Bg, clip.UniformRRect(inner, gtx.Dp(9)).Op(gtx.Ops))
|
||||
}
|
||||
return layout.Dimensions{Size: rect.Max}
|
||||
},
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Inset{Top: unit.Dp(20), Right: unit.Dp(24), Bottom: unit.Dp(20), Left: unit.Dp(24)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
row, _ := s.instanceRow(s.tabsInstanceID)
|
||||
caption := "只读查看经验证的 loopback CDP 页面目标。"
|
||||
if row.Name != "" {
|
||||
caption = fmt.Sprintf("%s · 实际端口 %d · 只读查看", row.Name, row.RemoteDebugPort)
|
||||
}
|
||||
children := []layout.FlexChild{
|
||||
layout.Rigid(material.H6(s.theme, "标签页(CDP 页面目标)").Layout),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
||||
layout.Rigid(material.Caption(s.theme, caption).Layout),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(14)}.Layout),
|
||||
}
|
||||
if s.tabsLoading {
|
||||
children = append(children, layout.Rigid(s.instanceFormFeedback("正在读取已验证的本地 CDP 页面目标…")))
|
||||
} else if s.tabsFeedback != "" {
|
||||
children = append(children, layout.Rigid(s.instanceFormFeedback(s.tabsFeedback)))
|
||||
} else if len(s.tabsTargets) == 0 {
|
||||
children = append(children, layout.Rigid(s.instanceFormFeedback("未发现可显示的 CDP 页面目标。")))
|
||||
} else {
|
||||
children = append(children, layout.Rigid(s.tabsTargetList))
|
||||
}
|
||||
children = append(children,
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(16)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.E.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
style := material.Button(s.theme, &s.tabsClose, "关闭")
|
||||
dims := style.Layout(gtx)
|
||||
if s.tabsFocusPending {
|
||||
gtx.Execute(key.FocusCmd{Tag: &s.tabsClose})
|
||||
s.tabsFocusPending = false
|
||||
}
|
||||
return dims
|
||||
})
|
||||
}),
|
||||
)
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Shell) tabsTargetList(gtx layout.Context) layout.Dimensions {
|
||||
height := gtx.Dp(280)
|
||||
if gtx.Constraints.Max.Y < height {
|
||||
height = gtx.Constraints.Max.Y
|
||||
}
|
||||
gtx.Constraints.Min.Y = height
|
||||
gtx.Constraints.Max.Y = height
|
||||
return material.List(s.theme, &s.tabsList).Layout(gtx, len(s.tabsTargets), func(gtx layout.Context, index int) layout.Dimensions {
|
||||
return layout.Inset{Bottom: unit.Dp(6)}.Layout(gtx, s.tabsTargetRow(s.tabsTargets[index]))
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Shell) tabsTargetRow(target CDPTarget) layout.Widget {
|
||||
return s.settingsSurface(instanceFormComponentBorder, unit.Dp(6), unit.Dp(8), func(gtx layout.Context) layout.Dimensions {
|
||||
targetType := strings.TrimSpace(target.Type)
|
||||
if targetType == "" {
|
||||
targetType = "page"
|
||||
}
|
||||
title := strings.TrimSpace(target.Title)
|
||||
if title == "" {
|
||||
title = "(无标题)"
|
||||
}
|
||||
url := strings.TrimSpace(target.URL)
|
||||
if url == "" {
|
||||
url = "—"
|
||||
}
|
||||
titleStyle := material.Body1(s.theme, title)
|
||||
titleStyle.MaxLines = 1
|
||||
urlStyle := material.Body2(s.theme, url)
|
||||
urlStyle.MaxLines = 2
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(material.Caption(s.theme, targetType).Layout),
|
||||
layout.Rigid(titleStyle.Layout),
|
||||
layout.Rigid(urlStyle.Layout),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Shell) editDiscardConfirmation(gtx layout.Context) layout.Dimensions {
|
||||
gtx.Constraints.Min = gtx.Constraints.Max
|
||||
return s.editDiscardBlocker.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
|
||||
@@ -580,6 +580,83 @@ func TestShellIgnoresRefreshResultAfterEditableFieldsChange(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellReadsTabsAsynchronouslyAndRestoresEditFocus(t *testing.T) {
|
||||
shell := NewShell(material.NewTheme())
|
||||
target := shell.rows[0]
|
||||
seen := make(chan InstanceRow, 1)
|
||||
shell.OnInspectTabs(func(_ context.Context, row InstanceRow) ([]CDPTarget, error) {
|
||||
seen <- row
|
||||
return []CDPTarget{{ID: "page-1", Type: "page", Title: "运营后台", URL: "https://example.com/orders"}}, nil
|
||||
}, nil)
|
||||
|
||||
shell.beginEdit(target.ID)
|
||||
shell.requestTabs()
|
||||
if !shell.tabsOpen || !shell.tabsLoading || shell.tabsInstanceID != target.ID {
|
||||
t.Fatalf("tabs state after request = open %v loading %v instance %q", shell.tabsOpen, shell.tabsLoading, shell.tabsInstanceID)
|
||||
}
|
||||
select {
|
||||
case row := <-seen:
|
||||
if row.ID != target.ID || row.RemoteDebugPort != target.RemoteDebugPort {
|
||||
t.Fatalf("tabs snapshot = %#v", row)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("tab inspector was not invoked")
|
||||
}
|
||||
var result instanceTabsResult
|
||||
select {
|
||||
case result = <-shell.tabsResults:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("tab inspector did not produce a result")
|
||||
}
|
||||
shell.tabsResults <- result
|
||||
shell.consumeTabsResults()
|
||||
if shell.tabsLoading || shell.tabsFeedback != "" || len(shell.tabsTargets) != 1 || shell.tabsTargets[0].Title != "运营后台" {
|
||||
t.Fatalf("tabs result state = loading %v feedback %q targets %#v", shell.tabsLoading, shell.tabsFeedback, shell.tabsTargets)
|
||||
}
|
||||
shell.closeTabs(true)
|
||||
if shell.tabsOpen || !shell.tabsReturnFocus {
|
||||
t.Fatalf("tabs close state = open %v return focus %v", shell.tabsOpen, shell.tabsReturnFocus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellRejectsStaleTabsResultWhenRuntimeChanges(t *testing.T) {
|
||||
shell := NewShell(material.NewTheme())
|
||||
target := shell.rows[0]
|
||||
shell.OnInspectTabs(func(_ context.Context, _ InstanceRow) ([]CDPTarget, error) {
|
||||
return []CDPTarget{{ID: "page-1", Type: "page", Title: "运营后台", URL: "https://example.com/orders"}}, nil
|
||||
}, nil)
|
||||
shell.beginEdit(target.ID)
|
||||
shell.requestTabs()
|
||||
var result instanceTabsResult
|
||||
select {
|
||||
case result = <-shell.tabsResults:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("tab inspector did not produce a result")
|
||||
}
|
||||
shell.rows[0].RemoteDebugPort++
|
||||
shell.tabsResults <- result
|
||||
shell.consumeTabsResults()
|
||||
if len(shell.tabsTargets) != 0 || !strings.Contains(shell.tabsFeedback, "状态或调试端口已经变化") {
|
||||
t.Fatalf("stale tabs result state = targets %#v feedback %q", shell.tabsTargets, shell.tabsFeedback)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellAllowsTabInspectionOnlyForVerifiedRuntimeRows(t *testing.T) {
|
||||
shell := NewShell(material.NewTheme())
|
||||
if !shell.canInspectTabs(shell.rows[0]) || !shell.canInspectTabs(shell.rows[2]) {
|
||||
t.Fatal("verified running and external rows should allow read-only tabs inspection")
|
||||
}
|
||||
for _, row := range []InstanceRow{
|
||||
{Status: "启动中", RemoteDebugPort: 9666},
|
||||
{Status: "已退出", RemoteDebugPort: 9666},
|
||||
{Status: "运行中", RemoteDebugPort: 0},
|
||||
} {
|
||||
if shell.canInspectTabs(row) {
|
||||
t.Fatalf("row unexpectedly allows tab inspection: %#v", row)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellEditsExitedInstanceAndPreservesRuntimePort(t *testing.T) {
|
||||
shell := NewShell(material.NewTheme())
|
||||
target := shell.rows[3]
|
||||
|
||||
Reference in New Issue
Block a user