feat: add read-only CDP target viewing

This commit is contained in:
QiuSW
2026-07-27 10:01:07 +08:00
parent 7816be2a3d
commit 519d3ef636
11 changed files with 668 additions and 18 deletions
+296 -2
View File
@@ -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 {