feat(daemon): compose remote view content locally
- commit
- 2a4b043afa0cc7ae7ee9bbba448a774b077ab62e
- author
- brice · · 2026-08-06 17:37 UTC
- parent
- 7d74423
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
8 files · +482 -17
M internal/usecase/daemon/attachment_owner.go +13 -6
| @@ -5,6 +5,8 @@ import ( | |||
| 5 | 5 | "sync" | |
| 6 | 6 | ||
| 7 | 7 | "github.com/bnema/vev/internal/domain" | |
| 8 | + | "github.com/bnema/vev/internal/ports" | |
| 9 | + | "github.com/bnema/vev/pkg/vt" | |
| 8 | 10 | ) | |
| 9 | 11 | ||
| 10 | 12 | // attachmentOwner is the sole mutable owner binding for an attached client. | |
| @@ -44,16 +46,21 @@ func remoteViewKeyForTarget(target domain.RemoteSessionTarget) (remoteViewKey, e | |||
| 44 | 46 | }, nil | |
| 45 | 47 | } | |
| 46 | 48 | ||
| 47 | - | // remoteView is intentionally attachment-facing only. Link state, remote VT | |
| 48 | - | // content, metadata, and warm lifecycle are added in Phase 4; keeping this | |
| 49 | - | // type free of local session state prevents accidental PTY/persistence reuse. | |
| 49 | + | // remoteView is intentionally attachment-facing only. Its private VT and | |
| 50 | + | // presentation metadata are the remote content boundary: neither carries a | |
| 51 | + | // local PTY, persistence, tab tree, or render coordinator. The remote-link | |
| 52 | + | // lifecycle owns writes to them in Phase 4; local composition only snapshots | |
| 53 | + | // them while holding this mutex. | |
| 50 | 54 | type remoteView struct { | |
| 51 | 55 | id remoteViewID | |
| 52 | 56 | key remoteViewKey | |
| 53 | 57 | ||
| 54 | - | mu sync.Mutex | |
| 55 | - | closed bool | |
| 56 | - | attachments map[*attachedClient]struct{} | |
| 58 | + | mu sync.Mutex | |
| 59 | + | closed bool | |
| 60 | + | attachments map[*attachedClient]struct{} | |
| 61 | + | screen *vt.Screen | |
| 62 | + | metadata ports.SessionMeta | |
| 63 | + | displayOrigin string | |
| 57 | 64 | } | |
| 58 | 65 | ||
| 59 | 66 | func (*remoteView) attachmentOwner() {} | |
| @@ -5,6 +5,8 @@ import ( | |||||
| 5 | "sync" | 5 | "sync" | ||
| 6 | 6 | ||||
| 7 | "github.com/bnema/vev/internal/domain" | 7 | "github.com/bnema/vev/internal/domain" | ||
| 8 | + | "github.com/bnema/vev/internal/ports" | |||
| 9 | + | "github.com/bnema/vev/pkg/vt" | |||
| 8 | ) | 10 | ) | ||
| 9 | 11 | ||||
| 10 | // attachmentOwner is the sole mutable owner binding for an attached client. | 12 | // attachmentOwner is the sole mutable owner binding for an attached client. | ||
| @@ -44,16 +46,21 @@ func remoteViewKeyForTarget(target domain.RemoteSessionTarget) (remoteViewKey, e | |||||
| 44 | }, nil | 46 | }, nil | ||
| 45 | } | 47 | } | ||
| 46 | 48 | ||||
| 47 | - | // remoteView is intentionally attachment-facing only. Link state, remote VT | 49 | + | // remoteView is intentionally attachment-facing only. Its private VT and |
| 48 | - | // content, metadata, and warm lifecycle are added in Phase 4; keeping this | 50 | + | // presentation metadata are the remote content boundary: neither carries a |
| 49 | - | // type free of local session state prevents accidental PTY/persistence reuse. | 51 | + | // local PTY, persistence, tab tree, or render coordinator. The remote-link |
| 52 | + | // lifecycle owns writes to them in Phase 4; local composition only snapshots | |||
| 53 | + | // them while holding this mutex. | |||
| 50 | type remoteView struct { | 54 | type remoteView struct { | ||
| 51 | id remoteViewID | 55 | id remoteViewID | ||
| 52 | key remoteViewKey | 56 | key remoteViewKey | ||
| 53 | 57 | ||||
| 54 | - | mu sync.Mutex | 58 | + | mu sync.Mutex |
| 55 | - | closed bool | 59 | + | closed bool |
| 56 | - | attachments map[*attachedClient]struct{} | 60 | + | attachments map[*attachedClient]struct{} |
| 61 | + | screen *vt.Screen | |||
| 62 | + | metadata ports.SessionMeta | |||
| 63 | + | displayOrigin string | |||
| 57 | } | 64 | } | ||
| 58 | 65 | ||||
| 59 | func (*remoteView) attachmentOwner() {} | 66 | func (*remoteView) attachmentOwner() {} | ||
M internal/usecase/daemon/attachment_owner_test.go +24 -0
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | ||
| 6 | 6 | "github.com/bnema/vev/internal/domain" | |
| 7 | 7 | "github.com/bnema/vev/internal/ports" | |
| 8 | + | "github.com/bnema/vev/pkg/vt" | |
| 8 | 9 | "github.com/stretchr/testify/require" | |
| 9 | 10 | ) | |
| 10 | 11 | ||
| @@ -88,6 +89,29 @@ func TestTransitionToRemoteViewPublishesOneAuthoritativeOwner(t *testing.T) { | |||
| 88 | 89 | require.Same(t, source, ac.previousOwner.Get()) | |
| 89 | 90 | } | |
| 90 | 91 | ||
| 92 | + | func TestRemoteViewFirstPaintComposesPrivateContentUnderLocalChrome(t *testing.T) { | |
| 93 | + | d, source, ac, sends := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 94 | + | view := &remoteView{ | |
| 95 | + | key: remoteViewKey{endpoint: "host", lifecycleID: domain.SessionLifecycleID{1}, sessionName: "remote"}, | |
| 96 | + | screen: vt.NewScreen(80, 22), | |
| 97 | + | } | |
| 98 | + | view.screen.Write([]byte("remote content")) | |
| 99 | + | d.mu.Lock() | |
| 100 | + | require.NoError(t, d.registerRemoteViewLocked(view)) | |
| 101 | + | d.mu.Unlock() | |
| 102 | + | ||
| 103 | + | token, err := d.transitionToRemoteView(source.attachmentToken(ac, ac.transport()), view) | |
| 104 | + | require.NoError(t, err) | |
| 105 | + | require.True(t, d.firstPaintForTransition(token)) | |
| 106 | + | ||
| 107 | + | output := mustApplyOutput(t, vt.NewScreen(80, 24), awaitOutputFrameWithoutSleep(t, sends)) | |
| 108 | + | require.True(t, output.Full) | |
| 109 | + | terminal := vt.NewScreen(80, 24) | |
| 110 | + | terminal.Write(output.Data) | |
| 111 | + | require.Contains(t, rowText(terminal.Snapshot().Row(0)), "remote") | |
| 112 | + | require.Contains(t, rowText(terminal.Snapshot().Row(1)), "remote content") | |
| 113 | + | } | |
| 114 | + | ||
| 91 | 115 | func TestShutdownRetiresRemoteViewsWithoutLocalSession(t *testing.T) { | |
| 92 | 116 | d := newTestDaemon(t, nil, stubClock{}) | |
| 93 | 117 | view := &remoteView{key: remoteViewKey{endpoint: "host", lifecycleID: domain.SessionLifecycleID{1}, sessionName: "remote"}} | |
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | 5 | ||||
| 6 | "github.com/bnema/vev/internal/domain" | 6 | "github.com/bnema/vev/internal/domain" | ||
| 7 | "github.com/bnema/vev/internal/ports" | 7 | "github.com/bnema/vev/internal/ports" | ||
| 8 | + | "github.com/bnema/vev/pkg/vt" | |||
| 8 | "github.com/stretchr/testify/require" | 9 | "github.com/stretchr/testify/require" | ||
| 9 | ) | 10 | ) | ||
| 10 | 11 | ||||
| @@ -88,6 +89,29 @@ func TestTransitionToRemoteViewPublishesOneAuthoritativeOwner(t *testing.T) { | |||||
| 88 | require.Same(t, source, ac.previousOwner.Get()) | 89 | require.Same(t, source, ac.previousOwner.Get()) | ||
| 89 | } | 90 | } | ||
| 90 | 91 | ||||
| 92 | + | func TestRemoteViewFirstPaintComposesPrivateContentUnderLocalChrome(t *testing.T) { | |||
| 93 | + | d, source, ac, sends := newManualSessionWithPTYs(t, newQuietPTY()) | |||
| 94 | + | view := &remoteView{ | |||
| 95 | + | key: remoteViewKey{endpoint: "host", lifecycleID: domain.SessionLifecycleID{1}, sessionName: "remote"}, | |||
| 96 | + | screen: vt.NewScreen(80, 22), | |||
| 97 | + | } | |||
| 98 | + | view.screen.Write([]byte("remote content")) | |||
| 99 | + | d.mu.Lock() | |||
| 100 | + | require.NoError(t, d.registerRemoteViewLocked(view)) | |||
| 101 | + | d.mu.Unlock() | |||
| 102 | + | ||||
| 103 | + | token, err := d.transitionToRemoteView(source.attachmentToken(ac, ac.transport()), view) | |||
| 104 | + | require.NoError(t, err) | |||
| 105 | + | require.True(t, d.firstPaintForTransition(token)) | |||
| 106 | + | ||||
| 107 | + | output := mustApplyOutput(t, vt.NewScreen(80, 24), awaitOutputFrameWithoutSleep(t, sends)) | |||
| 108 | + | require.True(t, output.Full) | |||
| 109 | + | terminal := vt.NewScreen(80, 24) | |||
| 110 | + | terminal.Write(output.Data) | |||
| 111 | + | require.Contains(t, rowText(terminal.Snapshot().Row(0)), "remote") | |||
| 112 | + | require.Contains(t, rowText(terminal.Snapshot().Row(1)), "remote content") | |||
| 113 | + | } | |||
| 114 | + | ||||
| 91 | func TestShutdownRetiresRemoteViewsWithoutLocalSession(t *testing.T) { | 115 | func TestShutdownRetiresRemoteViewsWithoutLocalSession(t *testing.T) { | ||
| 92 | d := newTestDaemon(t, nil, stubClock{}) | 116 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 93 | view := &remoteView{key: remoteViewKey{endpoint: "host", lifecycleID: domain.SessionLifecycleID{1}, sessionName: "remote"}} | 117 | view := &remoteView{key: remoteViewKey{endpoint: "host", lifecycleID: domain.SessionLifecycleID{1}, sessionName: "remote"}} | ||
M internal/usecase/daemon/client.go +3 -0
| @@ -788,6 +788,9 @@ func (d *Daemon) firstPaintForTransition(token attachmentConnectionToken) bool { | |||
| 788 | 788 | token.ac.captureFrames = nil | |
| 789 | 789 | token.ac.sendMu.Unlock() | |
| 790 | 790 | } | |
| 791 | + | if view, remote := token.owner.(*remoteView); remote { | |
| 792 | + | return d.paintRemoteView(view, token.ac, true, token) == paintEmitted | |
| 793 | + | } | |
| 791 | 794 | sess := token.localSession() | |
| 792 | 795 | if sess == nil { | |
| 793 | 796 | return false | |
| @@ -788,6 +788,9 @@ func (d *Daemon) firstPaintForTransition(token attachmentConnectionToken) bool { | |||||
| 788 | token.ac.captureFrames = nil | 788 | token.ac.captureFrames = nil | ||
| 789 | token.ac.sendMu.Unlock() | 789 | token.ac.sendMu.Unlock() | ||
| 790 | } | 790 | } | ||
| 791 | + | if view, remote := token.owner.(*remoteView); remote { | |||
| 792 | + | return d.paintRemoteView(view, token.ac, true, token) == paintEmitted | |||
| 793 | + | } | |||
| 791 | sess := token.localSession() | 794 | sess := token.localSession() | ||
| 792 | if sess == nil { | 795 | if sess == nil { | ||
| 793 | return false | 796 | return false | ||
M internal/usecase/daemon/remote_attachment_transition.go +93 -0
| @@ -71,3 +71,96 @@ func (d *Daemon) transitionToRemoteView(token attachmentConnectionToken, target | |||
| 71 | 71 | token.ac.recordPreviousOwner(source) | |
| 72 | 72 | return published, nil | |
| 73 | 73 | } | |
| 74 | + | ||
| 75 | + | // transitionFromRemoteView moves an attachment from one exact daemon-owned | |
| 76 | + | // remote view back to its previous local session. The remote view has no | |
| 77 | + | // coordinator lease, so only the target session coordinator is attached during | |
| 78 | + | // this frozen publication. | |
| 79 | + | func (d *Daemon) transitionFromRemoteView(token attachmentConnectionToken, source *remoteView, target *session) (attachmentConnectionToken, error) { | |
| 80 | + | if d == nil || source == nil || target == nil || token.ac == nil || token.transport.transport == nil { | |
| 81 | + | return attachmentConnectionToken{}, errAttachmentTransition | |
| 82 | + | } | |
| 83 | + | if token.effect != nil { | |
| 84 | + | d.endActionAttachmentEffect(token.effect, "remote-view-back") | |
| 85 | + | } | |
| 86 | + | frozen := freezeAttachmentEffectGates(token.ac) | |
| 87 | + | defer frozen.unfreeze() | |
| 88 | + | if !frozen.acquired || !frozen.drained { | |
| 89 | + | return attachmentConnectionToken{}, errAttachmentTransition | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | targetCore := target.core() | |
| 93 | + | if targetCore == nil { | |
| 94 | + | return attachmentConnectionToken{}, errAttachmentTransition | |
| 95 | + | } | |
| 96 | + | d.mu.Lock() | |
| 97 | + | if d.closing || !d.attachmentOwnerRegisteredByDaemonLocked(source) || d.sessions[targetCore.id] != target { | |
| 98 | + | d.mu.Unlock() | |
| 99 | + | return attachmentConnectionToken{}, errAttachmentTransition | |
| 100 | + | } | |
| 101 | + | d.notices.routingMu.Lock() | |
| 102 | + | target.mu.Lock() | |
| 103 | + | targetCoordinator := d.ensureAttachmentRenderCoordinatorPrelocked(target) | |
| 104 | + | if targetCoordinator == nil { | |
| 105 | + | target.mu.Unlock() | |
| 106 | + | d.notices.routingMu.Unlock() | |
| 107 | + | d.mu.Unlock() | |
| 108 | + | return attachmentConnectionToken{}, errAttachmentTransition | |
| 109 | + | } | |
| 110 | + | targetCoordinator.mu.Lock() | |
| 111 | + | // remoteView.mu is last: remote owners must never acquire daemon, session, | |
| 112 | + | // or coordinator locks while they are held. | |
| 113 | + | source.mu.Lock() | |
| 114 | + | unlock := func() { | |
| 115 | + | source.mu.Unlock() | |
| 116 | + | targetCoordinator.mu.Unlock() | |
| 117 | + | target.mu.Unlock() | |
| 118 | + | d.notices.routingMu.Unlock() | |
| 119 | + | d.mu.Unlock() | |
| 120 | + | } | |
| 121 | + | isCurrent := func() bool { | |
| 122 | + | _, sourceRegistered := source.attachments[token.ac] | |
| 123 | + | return sameAttachmentOwner(token.owner, source) && | |
| 124 | + | token.generation == token.ac.connectionGeneration.Load() && | |
| 125 | + | sameAttachmentOwner(token.ac.currentAttachmentOwner(), source) && | |
| 126 | + | token.ac.transportSnapshotCurrent(token.transport) && | |
| 127 | + | sourceRegistered && !source.closed && | |
| 128 | + | sameAttachmentOwner(token.ac.previousOwner.Get(), target) && | |
| 129 | + | !attachmentRegisteredLocked(target, token.ac) | |
| 130 | + | } | |
| 131 | + | if !isCurrent() || targetCoordinator.torndown { | |
| 132 | + | unlock() | |
| 133 | + | return attachmentConnectionToken{}, errAttachmentTransition | |
| 134 | + | } | |
| 135 | + | // Attach session membership before the coordinator lease. The attachment | |
| 136 | + | // gate is frozen and the local owner remains remote until the final owner | |
| 137 | + | // publication, so this temporary overlap is unobservable to effects. | |
| 138 | + | if !registerAttachmentSessionLocked(target, token.ac) { | |
| 139 | + | unlock() | |
| 140 | + | return attachmentConnectionToken{}, errAttachmentTransition | |
| 141 | + | } | |
| 142 | + | lease := targetCoordinator.attachWithReadinessLocked(token.ac, true) | |
| 143 | + | if lease == nil { | |
| 144 | + | unregisterAttachmentSessionLocked(target, token.ac) | |
| 145 | + | unlock() | |
| 146 | + | return attachmentConnectionToken{}, errAttachmentTransition | |
| 147 | + | } | |
| 148 | + | source.unregisterAttachmentLocked(token.ac) | |
| 149 | + | generation := token.ac.connectionGeneration.Add(1) | |
| 150 | + | token.ac.setAttachmentOwner(target) | |
| 151 | + | published := attachmentConnectionToken{ | |
| 152 | + | owner: target, | |
| 153 | + | ac: token.ac, | |
| 154 | + | generation: generation, | |
| 155 | + | transport: token.transport, | |
| 156 | + | lease: lease, | |
| 157 | + | rebase: true, | |
| 158 | + | } | |
| 159 | + | token.ac.publishFrozenAttachmentCapability(published) | |
| 160 | + | unlock() | |
| 161 | + | ||
| 162 | + | // Record the reverse toggle while the attachment gate is still frozen, so a | |
| 163 | + | // concurrent handoff cannot observe the new local owner without its history. | |
| 164 | + | token.ac.recordPreviousOwner(source) | |
| 165 | + | return published, nil | |
| 166 | + | } | |
| @@ -71,3 +71,96 @@ func (d *Daemon) transitionToRemoteView(token attachmentConnectionToken, target | |||||
| 71 | token.ac.recordPreviousOwner(source) | 71 | token.ac.recordPreviousOwner(source) | ||
| 72 | return published, nil | 72 | return published, nil | ||
| 73 | } | 73 | } | ||
| 74 | + | ||||
| 75 | + | // transitionFromRemoteView moves an attachment from one exact daemon-owned | |||
| 76 | + | // remote view back to its previous local session. The remote view has no | |||
| 77 | + | // coordinator lease, so only the target session coordinator is attached during | |||
| 78 | + | // this frozen publication. | |||
| 79 | + | func (d *Daemon) transitionFromRemoteView(token attachmentConnectionToken, source *remoteView, target *session) (attachmentConnectionToken, error) { | |||
| 80 | + | if d == nil || source == nil || target == nil || token.ac == nil || token.transport.transport == nil { | |||
| 81 | + | return attachmentConnectionToken{}, errAttachmentTransition | |||
| 82 | + | } | |||
| 83 | + | if token.effect != nil { | |||
| 84 | + | d.endActionAttachmentEffect(token.effect, "remote-view-back") | |||
| 85 | + | } | |||
| 86 | + | frozen := freezeAttachmentEffectGates(token.ac) | |||
| 87 | + | defer frozen.unfreeze() | |||
| 88 | + | if !frozen.acquired || !frozen.drained { | |||
| 89 | + | return attachmentConnectionToken{}, errAttachmentTransition | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | targetCore := target.core() | |||
| 93 | + | if targetCore == nil { | |||
| 94 | + | return attachmentConnectionToken{}, errAttachmentTransition | |||
| 95 | + | } | |||
| 96 | + | d.mu.Lock() | |||
| 97 | + | if d.closing || !d.attachmentOwnerRegisteredByDaemonLocked(source) || d.sessions[targetCore.id] != target { | |||
| 98 | + | d.mu.Unlock() | |||
| 99 | + | return attachmentConnectionToken{}, errAttachmentTransition | |||
| 100 | + | } | |||
| 101 | + | d.notices.routingMu.Lock() | |||
| 102 | + | target.mu.Lock() | |||
| 103 | + | targetCoordinator := d.ensureAttachmentRenderCoordinatorPrelocked(target) | |||
| 104 | + | if targetCoordinator == nil { | |||
| 105 | + | target.mu.Unlock() | |||
| 106 | + | d.notices.routingMu.Unlock() | |||
| 107 | + | d.mu.Unlock() | |||
| 108 | + | return attachmentConnectionToken{}, errAttachmentTransition | |||
| 109 | + | } | |||
| 110 | + | targetCoordinator.mu.Lock() | |||
| 111 | + | // remoteView.mu is last: remote owners must never acquire daemon, session, | |||
| 112 | + | // or coordinator locks while they are held. | |||
| 113 | + | source.mu.Lock() | |||
| 114 | + | unlock := func() { | |||
| 115 | + | source.mu.Unlock() | |||
| 116 | + | targetCoordinator.mu.Unlock() | |||
| 117 | + | target.mu.Unlock() | |||
| 118 | + | d.notices.routingMu.Unlock() | |||
| 119 | + | d.mu.Unlock() | |||
| 120 | + | } | |||
| 121 | + | isCurrent := func() bool { | |||
| 122 | + | _, sourceRegistered := source.attachments[token.ac] | |||
| 123 | + | return sameAttachmentOwner(token.owner, source) && | |||
| 124 | + | token.generation == token.ac.connectionGeneration.Load() && | |||
| 125 | + | sameAttachmentOwner(token.ac.currentAttachmentOwner(), source) && | |||
| 126 | + | token.ac.transportSnapshotCurrent(token.transport) && | |||
| 127 | + | sourceRegistered && !source.closed && | |||
| 128 | + | sameAttachmentOwner(token.ac.previousOwner.Get(), target) && | |||
| 129 | + | !attachmentRegisteredLocked(target, token.ac) | |||
| 130 | + | } | |||
| 131 | + | if !isCurrent() || targetCoordinator.torndown { | |||
| 132 | + | unlock() | |||
| 133 | + | return attachmentConnectionToken{}, errAttachmentTransition | |||
| 134 | + | } | |||
| 135 | + | // Attach session membership before the coordinator lease. The attachment | |||
| 136 | + | // gate is frozen and the local owner remains remote until the final owner | |||
| 137 | + | // publication, so this temporary overlap is unobservable to effects. | |||
| 138 | + | if !registerAttachmentSessionLocked(target, token.ac) { | |||
| 139 | + | unlock() | |||
| 140 | + | return attachmentConnectionToken{}, errAttachmentTransition | |||
| 141 | + | } | |||
| 142 | + | lease := targetCoordinator.attachWithReadinessLocked(token.ac, true) | |||
| 143 | + | if lease == nil { | |||
| 144 | + | unregisterAttachmentSessionLocked(target, token.ac) | |||
| 145 | + | unlock() | |||
| 146 | + | return attachmentConnectionToken{}, errAttachmentTransition | |||
| 147 | + | } | |||
| 148 | + | source.unregisterAttachmentLocked(token.ac) | |||
| 149 | + | generation := token.ac.connectionGeneration.Add(1) | |||
| 150 | + | token.ac.setAttachmentOwner(target) | |||
| 151 | + | published := attachmentConnectionToken{ | |||
| 152 | + | owner: target, | |||
| 153 | + | ac: token.ac, | |||
| 154 | + | generation: generation, | |||
| 155 | + | transport: token.transport, | |||
| 156 | + | lease: lease, | |||
| 157 | + | rebase: true, | |||
| 158 | + | } | |||
| 159 | + | token.ac.publishFrozenAttachmentCapability(published) | |||
| 160 | + | unlock() | |||
| 161 | + | ||||
| 162 | + | // Record the reverse toggle while the attachment gate is still frozen, so a | |||
| 163 | + | // concurrent handoff cannot observe the new local owner without its history. | |||
| 164 | + | token.ac.recordPreviousOwner(source) | |||
| 165 | + | return published, nil | |||
| 166 | + | } | |||
A internal/usecase/daemon/remote_attachment_transition_back_test.go +95 -0
| @@ -0,0 +1,95 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func remoteBackFixture(t *testing.T) (*Daemon, *session, *attachedClient, *remoteView, attachmentConnectionToken) { | |
| 11 | + | t.Helper() | |
| 12 | + | d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 13 | + | view := &remoteView{key: remoteViewKey{ | |
| 14 | + | endpoint: "host", | |
| 15 | + | lifecycleID: domain.SessionLifecycleID{1}, | |
| 16 | + | sessionName: "remote", | |
| 17 | + | }} | |
| 18 | + | d.mu.Lock() | |
| 19 | + | require.NoError(t, d.registerRemoteViewLocked(view)) | |
| 20 | + | d.mu.Unlock() | |
| 21 | + | ||
| 22 | + | token, err := d.transitionToRemoteView(local.attachmentToken(ac, ac.transport()), view) | |
| 23 | + | require.NoError(t, err) | |
| 24 | + | return d, local, ac, view, token | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func TestBackSessionForAttachmentReturnsFromRemoteViewToPreviousLocal(t *testing.T) { | |
| 28 | + | d, local, ac, view, token := remoteBackFixture(t) | |
| 29 | + | ||
| 30 | + | require.NoError(t, d.backSessionForAttachment(token)) | |
| 31 | + | ||
| 32 | + | require.Same(t, local, ac.currentAttachmentSession()) | |
| 33 | + | require.True(t, local.attachmentRegistered(ac)) | |
| 34 | + | require.False(t, view.attachmentRegistered(ac)) | |
| 35 | + | require.Same(t, view, ac.previousOwner.Get(), "reverse navigation records the remote owner for toggling back") | |
| 36 | + | ||
| 37 | + | current := local.attachmentToken(ac, ac.transport()) | |
| 38 | + | require.True(t, current.current()) | |
| 39 | + | require.NotNil(t, current.lease, "returning to a local owner restores its coordinator lease") | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func TestBackSessionForAttachmentFailsClosedForStaleOrNonLocalPredecessor(t *testing.T) { | |
| 43 | + | tests := []struct { | |
| 44 | + | name string | |
| 45 | + | prep func(*Daemon, *session, *attachedClient, *remoteView) | |
| 46 | + | check func(*testing.T, *session, *attachedClient, *remoteView) | |
| 47 | + | }{ | |
| 48 | + | { | |
| 49 | + | name: "stale local predecessor", | |
| 50 | + | prep: func(d *Daemon, local *session, _ *attachedClient, _ *remoteView) { | |
| 51 | + | replacement := &session{sessionCore: sessionCore{id: local.id, name: local.name}} | |
| 52 | + | d.mu.Lock() | |
| 53 | + | d.sessions[local.id] = replacement | |
| 54 | + | d.mu.Unlock() | |
| 55 | + | }, | |
| 56 | + | check: func(t *testing.T, _ *session, ac *attachedClient, view *remoteView) { | |
| 57 | + | require.Same(t, view, ac.currentAttachmentOwner()) | |
| 58 | + | require.True(t, view.attachmentRegistered(ac)) | |
| 59 | + | require.Nil(t, ac.previousOwner.Get(), "stale local history is discarded without changing ownership") | |
| 60 | + | }, | |
| 61 | + | }, | |
| 62 | + | { | |
| 63 | + | name: "non-local predecessor", | |
| 64 | + | prep: func(_ *Daemon, _ *session, ac *attachedClient, _ *remoteView) { | |
| 65 | + | ac.previousOwner.Set(&remoteView{id: 99}) | |
| 66 | + | }, | |
| 67 | + | check: func(t *testing.T, _ *session, ac *attachedClient, view *remoteView) { | |
| 68 | + | other := ac.previousOwner.Get() | |
| 69 | + | require.Same(t, view, ac.currentAttachmentOwner()) | |
| 70 | + | require.True(t, view.attachmentRegistered(ac)) | |
| 71 | + | require.IsType(t, &remoteView{}, other) | |
| 72 | + | }, | |
| 73 | + | }, | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | for _, tt := range tests { | |
| 77 | + | t.Run(tt.name, func(t *testing.T) { | |
| 78 | + | d, local, ac, view, token := remoteBackFixture(t) | |
| 79 | + | tt.prep(d, local, ac, view) | |
| 80 | + | ||
| 81 | + | require.NoError(t, d.backSessionForAttachment(token)) | |
| 82 | + | tt.check(t, local, ac, view) | |
| 83 | + | }) | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func TestBackSessionForAttachmentRejectsReboundRemoteToken(t *testing.T) { | |
| 88 | + | d, local, ac, view, token := remoteBackFixture(t) | |
| 89 | + | ac.replaceTransport(&closeTrackingTransport{}) | |
| 90 | + | ||
| 91 | + | require.NoError(t, d.backSessionForAttachment(token)) | |
| 92 | + | require.Same(t, view, ac.currentAttachmentOwner()) | |
| 93 | + | require.True(t, view.attachmentRegistered(ac)) | |
| 94 | + | require.Same(t, local, ac.previousOwner.Get(), "a stale token must not consume valid history") | |
| 95 | + | } | |
| @@ -0,0 +1,95 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/domain" | |||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func remoteBackFixture(t *testing.T) (*Daemon, *session, *attachedClient, *remoteView, attachmentConnectionToken) { | |||
| 11 | + | t.Helper() | |||
| 12 | + | d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |||
| 13 | + | view := &remoteView{key: remoteViewKey{ | |||
| 14 | + | endpoint: "host", | |||
| 15 | + | lifecycleID: domain.SessionLifecycleID{1}, | |||
| 16 | + | sessionName: "remote", | |||
| 17 | + | }} | |||
| 18 | + | d.mu.Lock() | |||
| 19 | + | require.NoError(t, d.registerRemoteViewLocked(view)) | |||
| 20 | + | d.mu.Unlock() | |||
| 21 | + | ||||
| 22 | + | token, err := d.transitionToRemoteView(local.attachmentToken(ac, ac.transport()), view) | |||
| 23 | + | require.NoError(t, err) | |||
| 24 | + | return d, local, ac, view, token | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | func TestBackSessionForAttachmentReturnsFromRemoteViewToPreviousLocal(t *testing.T) { | |||
| 28 | + | d, local, ac, view, token := remoteBackFixture(t) | |||
| 29 | + | ||||
| 30 | + | require.NoError(t, d.backSessionForAttachment(token)) | |||
| 31 | + | ||||
| 32 | + | require.Same(t, local, ac.currentAttachmentSession()) | |||
| 33 | + | require.True(t, local.attachmentRegistered(ac)) | |||
| 34 | + | require.False(t, view.attachmentRegistered(ac)) | |||
| 35 | + | require.Same(t, view, ac.previousOwner.Get(), "reverse navigation records the remote owner for toggling back") | |||
| 36 | + | ||||
| 37 | + | current := local.attachmentToken(ac, ac.transport()) | |||
| 38 | + | require.True(t, current.current()) | |||
| 39 | + | require.NotNil(t, current.lease, "returning to a local owner restores its coordinator lease") | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func TestBackSessionForAttachmentFailsClosedForStaleOrNonLocalPredecessor(t *testing.T) { | |||
| 43 | + | tests := []struct { | |||
| 44 | + | name string | |||
| 45 | + | prep func(*Daemon, *session, *attachedClient, *remoteView) | |||
| 46 | + | check func(*testing.T, *session, *attachedClient, *remoteView) | |||
| 47 | + | }{ | |||
| 48 | + | { | |||
| 49 | + | name: "stale local predecessor", | |||
| 50 | + | prep: func(d *Daemon, local *session, _ *attachedClient, _ *remoteView) { | |||
| 51 | + | replacement := &session{sessionCore: sessionCore{id: local.id, name: local.name}} | |||
| 52 | + | d.mu.Lock() | |||
| 53 | + | d.sessions[local.id] = replacement | |||
| 54 | + | d.mu.Unlock() | |||
| 55 | + | }, | |||
| 56 | + | check: func(t *testing.T, _ *session, ac *attachedClient, view *remoteView) { | |||
| 57 | + | require.Same(t, view, ac.currentAttachmentOwner()) | |||
| 58 | + | require.True(t, view.attachmentRegistered(ac)) | |||
| 59 | + | require.Nil(t, ac.previousOwner.Get(), "stale local history is discarded without changing ownership") | |||
| 60 | + | }, | |||
| 61 | + | }, | |||
| 62 | + | { | |||
| 63 | + | name: "non-local predecessor", | |||
| 64 | + | prep: func(_ *Daemon, _ *session, ac *attachedClient, _ *remoteView) { | |||
| 65 | + | ac.previousOwner.Set(&remoteView{id: 99}) | |||
| 66 | + | }, | |||
| 67 | + | check: func(t *testing.T, _ *session, ac *attachedClient, view *remoteView) { | |||
| 68 | + | other := ac.previousOwner.Get() | |||
| 69 | + | require.Same(t, view, ac.currentAttachmentOwner()) | |||
| 70 | + | require.True(t, view.attachmentRegistered(ac)) | |||
| 71 | + | require.IsType(t, &remoteView{}, other) | |||
| 72 | + | }, | |||
| 73 | + | }, | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | for _, tt := range tests { | |||
| 77 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 78 | + | d, local, ac, view, token := remoteBackFixture(t) | |||
| 79 | + | tt.prep(d, local, ac, view) | |||
| 80 | + | ||||
| 81 | + | require.NoError(t, d.backSessionForAttachment(token)) | |||
| 82 | + | tt.check(t, local, ac, view) | |||
| 83 | + | }) | |||
| 84 | + | } | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | func TestBackSessionForAttachmentRejectsReboundRemoteToken(t *testing.T) { | |||
| 88 | + | d, local, ac, view, token := remoteBackFixture(t) | |||
| 89 | + | ac.replaceTransport(&closeTrackingTransport{}) | |||
| 90 | + | ||||
| 91 | + | require.NoError(t, d.backSessionForAttachment(token)) | |||
| 92 | + | require.Same(t, view, ac.currentAttachmentOwner()) | |||
| 93 | + | require.True(t, view.attachmentRegistered(ac)) | |||
| 94 | + | require.Same(t, local, ac.previousOwner.Get(), "a stale token must not consume valid history") | |||
| 95 | + | } | |||
A internal/usecase/daemon/remote_render.go +198 -0
| @@ -0,0 +1,198 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/vev/internal/domain" | |
| 5 | + | "github.com/bnema/vev/internal/ports" | |
| 6 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 7 | + | "github.com/bnema/vev/pkg/renderer" | |
| 8 | + | "github.com/bnema/vev/pkg/vt" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | const remoteContentPaneID = "remote-content" | |
| 12 | + | ||
| 13 | + | type remoteViewRenderSnapshot struct { | |
| 14 | + | frame renderer.Frame | |
| 15 | + | cursor vt.CursorSnapshot | |
| 16 | + | title string | |
| 17 | + | tabs []ports.SessionTabMeta | |
| 18 | + | activeID domain.TabStableID | |
| 19 | + | origin string | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | // renderSnapshot is the remote-content ownership boundary. It copies every | |
| 23 | + | // mutable VT and metadata value while view.mu is held, then releases that lock | |
| 24 | + | // before any attachment, overlay, renderer, or transport work begins. | |
| 25 | + | func (v *remoteView) renderSnapshot(size domain.Size) (remoteViewRenderSnapshot, bool) { | |
| 26 | + | if v == nil || !size.Valid() { | |
| 27 | + | return remoteViewRenderSnapshot{}, false | |
| 28 | + | } | |
| 29 | + | v.mu.Lock() | |
| 30 | + | defer v.mu.Unlock() | |
| 31 | + | if v.closed { | |
| 32 | + | return remoteViewRenderSnapshot{}, false | |
| 33 | + | } | |
| 34 | + | if v.screen == nil { | |
| 35 | + | v.screen = vt.NewScreen(size.Cols, size.Rows) | |
| 36 | + | } else if v.screen.Frame.Width != size.Cols || v.screen.Frame.Height != size.Rows { | |
| 37 | + | v.screen.Resize(size.Cols, size.Rows) | |
| 38 | + | } | |
| 39 | + | screen := v.screen.Snapshot() | |
| 40 | + | frame := renderer.NewFrame(screen.Columns(), screen.Rows()) | |
| 41 | + | for y := range frame.Height { | |
| 42 | + | copy(frame.Row(y), screen.BorrowedRow(y)) | |
| 43 | + | } | |
| 44 | + | return remoteViewRenderSnapshot{ | |
| 45 | + | frame: frame, | |
| 46 | + | cursor: screen.Cursor(), | |
| 47 | + | title: screen.Title(), | |
| 48 | + | tabs: append([]ports.SessionTabMeta(nil), v.metadata.Tabs...), | |
| 49 | + | activeID: v.metadata.ActiveTabID, | |
| 50 | + | origin: v.displayOrigin, | |
| 51 | + | }, true | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func remoteStatusSnapshot(view *remoteView, snap remoteViewRenderSnapshot) statusSnapshot { | |
| 55 | + | name := "" | |
| 56 | + | if view != nil { | |
| 57 | + | name = view.key.sessionName | |
| 58 | + | if snap.origin == "" { | |
| 59 | + | snap.origin = view.key.endpoint | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | if snap.origin != "" { | |
| 63 | + | name += " at " + snap.origin | |
| 64 | + | } | |
| 65 | + | status := statusSnapshot{session: name, tabs: make([]statusTab, 0, max(1, len(snap.tabs)))} | |
| 66 | + | for _, tab := range snap.tabs { | |
| 67 | + | status.tabs = append(status.tabs, statusTab{ | |
| 68 | + | name: tab.Name, | |
| 69 | + | active: tab.ID == snap.activeID, | |
| 70 | + | attention: tab.Attention, | |
| 71 | + | }) | |
| 72 | + | } | |
| 73 | + | if len(status.tabs) == 0 { | |
| 74 | + | tabName := "1" | |
| 75 | + | if view != nil && view.key.sessionName != "" { | |
| 76 | + | tabName = view.key.sessionName | |
| 77 | + | } | |
| 78 | + | status.tabs = append(status.tabs, statusTab{name: tabName, paneTitle: snap.title, active: true}) | |
| 79 | + | } | |
| 80 | + | return status | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // captureRemoteViewRenderState builds one local chrome composition from an | |
| 84 | + | // immutable private remote VT snapshot. The one synthetic pane is deliberately | |
| 85 | + | // content-only: local pane titles, layout, and PTY state cannot leak into a | |
| 86 | + | // remote view. | |
| 87 | + | func (d *Daemon) captureRemoteViewRenderState(view *remoteView, ac *attachedClient, request renderCaptureRequest) (*capturedRenderState, bool) { | |
| 88 | + | if view == nil || ac == nil { | |
| 89 | + | return nil, false | |
| 90 | + | } | |
| 91 | + | window := ac.sizeSnapshot() | |
| 92 | + | content := contentSize(window) | |
| 93 | + | snapshot, ok := view.renderSnapshot(content) | |
| 94 | + | if !ok || !attachmentOwnerRegistered(view, ac) || !sameAttachmentOwner(ac.currentAttachmentOwner(), view) { | |
| 95 | + | return nil, false | |
| 96 | + | } | |
| 97 | + | placement := domain.Rect{Width: content.Cols, Height: content.Rows} | |
| 98 | + | bars := request.bars | |
| 99 | + | bars.status = remoteStatusSnapshot(view, snapshot) | |
| 100 | + | if d != nil { | |
| 101 | + | bars.attentionFrame = d.attentionFrame() | |
| 102 | + | bars.mru = d.recentSessions(nil) | |
| 103 | + | } | |
| 104 | + | state := &capturedRenderState{ | |
| 105 | + | attachment: ac, | |
| 106 | + | lease: request.lease, | |
| 107 | + | window: window, | |
| 108 | + | reset: request.reset, | |
| 109 | + | bars: bars, | |
| 110 | + | theme: bars.theme, | |
| 111 | + | styles: request.styles, | |
| 112 | + | styleGeneration: request.styleGeneration, | |
| 113 | + | overlays: request.overlays, | |
| 114 | + | preview: request.preview, | |
| 115 | + | layout: capturedTabLayout{area: placement, focus: remoteContentPaneID, valid: true}, | |
| 116 | + | floatingGeneration: 0, | |
| 117 | + | panes: []capturedPaneRenderState{{ | |
| 118 | + | id: remoteContentPaneID, | |
| 119 | + | frame: snapshot.frame, | |
| 120 | + | placement: layout.Placement{ID: remoteContentPaneID, Content: placement}, | |
| 121 | + | focused: true, | |
| 122 | + | damage: []renderer.Damage{renderer.FullRedraw()}, | |
| 123 | + | }}, | |
| 124 | + | cursor: capturedCursorInputs{ | |
| 125 | + | row: snapshot.cursor.Row, | |
| 126 | + | col: snapshot.cursor.Col, | |
| 127 | + | style: snapshot.cursor.Style, | |
| 128 | + | hasStyle: snapshot.cursor.StyleSet, | |
| 129 | + | visible: snapshot.cursor.Visible, | |
| 130 | + | renderable: placement.Width > 0 && placement.Height > 0, | |
| 131 | + | content: placement, | |
| 132 | + | }, | |
| 133 | + | } | |
| 134 | + | return state, true | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | // paintRemoteView composes daemon-owned bars and overlays over the private | |
| 138 | + | // remote terminal image. It uses the same output-state stream as local | |
| 139 | + | // rendering, but never consults local session, pane, or coordinator state. | |
| 140 | + | func (d *Daemon) paintRemoteView(view *remoteView, ac *attachedClient, reset bool, token attachmentConnectionToken) paintResult { | |
| 141 | + | if view == nil || ac == nil || !sameAttachmentOwner(token.owner, view) { | |
| 142 | + | return paintRejected | |
| 143 | + | } | |
| 144 | + | ticket, admitted := ac.beginAttachmentEffect(token) | |
| 145 | + | if !admitted { | |
| 146 | + | return paintRejected | |
| 147 | + | } | |
| 148 | + | token.effect = ticket | |
| 149 | + | marks := d.newRuntimeMarkBatch() | |
| 150 | + | marks.attachmentEffect = ticket | |
| 151 | + | defer func() { | |
| 152 | + | ticket.End() | |
| 153 | + | marks.flush() | |
| 154 | + | }() | |
| 155 | + | ||
| 156 | + | ac.sendMu.Lock() | |
| 157 | + | if !token.attachmentEffectCurrent() || ac.output == nil { | |
| 158 | + | ac.sendMu.Unlock() | |
| 159 | + | return paintRejected | |
| 160 | + | } | |
| 161 | + | ac.output.syncCapacityLocked() | |
| 162 | + | if ac.output.atCapacity() { | |
| 163 | + | ac.sendMu.Unlock() | |
| 164 | + | return paintBlockedCapacity | |
| 165 | + | } | |
| 166 | + | ac.initOverlays() | |
| 167 | + | overlays := ac.overlays.SnapshotForRender() | |
| 168 | + | defer overlays.Unlock() | |
| 169 | + | statusFeedback := overlays.statusFeedback | |
| 170 | + | if statusFeedback == "" && overlays.resizeActive { | |
| 171 | + | statusFeedback = "resize unavailable for remote view" | |
| 172 | + | } | |
| 173 | + | applied := ac.getAppliedTheme() | |
| 174 | + | bars := barState{statusFeedback: statusFeedback, theme: applied.Raw} | |
| 175 | + | capturedOverlays := capturedOverlayRenderState{ | |
| 176 | + | copyActive: overlays.copyActive, copySearchActive: overlays.copySearchModel != nil, | |
| 177 | + | pickerActive: overlays.pickerActive, paletteActive: overlays.paletteActive, promptActive: overlays.promptActive, | |
| 178 | + | resizeActive: overlays.resizeActive, statusFeedback: statusFeedback, | |
| 179 | + | } | |
| 180 | + | state, ok := d.captureRemoteViewRenderState(view, ac, renderCaptureRequest{ | |
| 181 | + | bars: bars, | |
| 182 | + | overlays: capturedOverlays, | |
| 183 | + | preview: snapshotPickerPreview(nil), | |
| 184 | + | styles: applied.Resolved.Styles, | |
| 185 | + | styleGeneration: applied.Generation, | |
| 186 | + | reset: reset, | |
| 187 | + | }) | |
| 188 | + | if !ok { | |
| 189 | + | ac.sendMu.Unlock() | |
| 190 | + | return paintRejected | |
| 191 | + | } | |
| 192 | + | captureOverlayLayers(state, overlays, d.currentPaletteConfig()) | |
| 193 | + | composed := composeFrame(*state, ac.pipelineCache, ac.pipelineScratch) | |
| 194 | + | if d.emitAttachmentFrame(view, ac, state, composed, &marks) { | |
| 195 | + | return paintEmitted | |
| 196 | + | } | |
| 197 | + | return paintRejected | |
| 198 | + | } | |
| @@ -0,0 +1,198 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "github.com/bnema/vev/internal/domain" | |||
| 5 | + | "github.com/bnema/vev/internal/ports" | |||
| 6 | + | "github.com/bnema/vev/internal/usecase/layout" | |||
| 7 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 8 | + | "github.com/bnema/vev/pkg/vt" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | const remoteContentPaneID = "remote-content" | |||
| 12 | + | ||||
| 13 | + | type remoteViewRenderSnapshot struct { | |||
| 14 | + | frame renderer.Frame | |||
| 15 | + | cursor vt.CursorSnapshot | |||
| 16 | + | title string | |||
| 17 | + | tabs []ports.SessionTabMeta | |||
| 18 | + | activeID domain.TabStableID | |||
| 19 | + | origin string | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | // renderSnapshot is the remote-content ownership boundary. It copies every | |||
| 23 | + | // mutable VT and metadata value while view.mu is held, then releases that lock | |||
| 24 | + | // before any attachment, overlay, renderer, or transport work begins. | |||
| 25 | + | func (v *remoteView) renderSnapshot(size domain.Size) (remoteViewRenderSnapshot, bool) { | |||
| 26 | + | if v == nil || !size.Valid() { | |||
| 27 | + | return remoteViewRenderSnapshot{}, false | |||
| 28 | + | } | |||
| 29 | + | v.mu.Lock() | |||
| 30 | + | defer v.mu.Unlock() | |||
| 31 | + | if v.closed { | |||
| 32 | + | return remoteViewRenderSnapshot{}, false | |||
| 33 | + | } | |||
| 34 | + | if v.screen == nil { | |||
| 35 | + | v.screen = vt.NewScreen(size.Cols, size.Rows) | |||
| 36 | + | } else if v.screen.Frame.Width != size.Cols || v.screen.Frame.Height != size.Rows { | |||
| 37 | + | v.screen.Resize(size.Cols, size.Rows) | |||
| 38 | + | } | |||
| 39 | + | screen := v.screen.Snapshot() | |||
| 40 | + | frame := renderer.NewFrame(screen.Columns(), screen.Rows()) | |||
| 41 | + | for y := range frame.Height { | |||
| 42 | + | copy(frame.Row(y), screen.BorrowedRow(y)) | |||
| 43 | + | } | |||
| 44 | + | return remoteViewRenderSnapshot{ | |||
| 45 | + | frame: frame, | |||
| 46 | + | cursor: screen.Cursor(), | |||
| 47 | + | title: screen.Title(), | |||
| 48 | + | tabs: append([]ports.SessionTabMeta(nil), v.metadata.Tabs...), | |||
| 49 | + | activeID: v.metadata.ActiveTabID, | |||
| 50 | + | origin: v.displayOrigin, | |||
| 51 | + | }, true | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func remoteStatusSnapshot(view *remoteView, snap remoteViewRenderSnapshot) statusSnapshot { | |||
| 55 | + | name := "" | |||
| 56 | + | if view != nil { | |||
| 57 | + | name = view.key.sessionName | |||
| 58 | + | if snap.origin == "" { | |||
| 59 | + | snap.origin = view.key.endpoint | |||
| 60 | + | } | |||
| 61 | + | } | |||
| 62 | + | if snap.origin != "" { | |||
| 63 | + | name += " at " + snap.origin | |||
| 64 | + | } | |||
| 65 | + | status := statusSnapshot{session: name, tabs: make([]statusTab, 0, max(1, len(snap.tabs)))} | |||
| 66 | + | for _, tab := range snap.tabs { | |||
| 67 | + | status.tabs = append(status.tabs, statusTab{ | |||
| 68 | + | name: tab.Name, | |||
| 69 | + | active: tab.ID == snap.activeID, | |||
| 70 | + | attention: tab.Attention, | |||
| 71 | + | }) | |||
| 72 | + | } | |||
| 73 | + | if len(status.tabs) == 0 { | |||
| 74 | + | tabName := "1" | |||
| 75 | + | if view != nil && view.key.sessionName != "" { | |||
| 76 | + | tabName = view.key.sessionName | |||
| 77 | + | } | |||
| 78 | + | status.tabs = append(status.tabs, statusTab{name: tabName, paneTitle: snap.title, active: true}) | |||
| 79 | + | } | |||
| 80 | + | return status | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | // captureRemoteViewRenderState builds one local chrome composition from an | |||
| 84 | + | // immutable private remote VT snapshot. The one synthetic pane is deliberately | |||
| 85 | + | // content-only: local pane titles, layout, and PTY state cannot leak into a | |||
| 86 | + | // remote view. | |||
| 87 | + | func (d *Daemon) captureRemoteViewRenderState(view *remoteView, ac *attachedClient, request renderCaptureRequest) (*capturedRenderState, bool) { | |||
| 88 | + | if view == nil || ac == nil { | |||
| 89 | + | return nil, false | |||
| 90 | + | } | |||
| 91 | + | window := ac.sizeSnapshot() | |||
| 92 | + | content := contentSize(window) | |||
| 93 | + | snapshot, ok := view.renderSnapshot(content) | |||
| 94 | + | if !ok || !attachmentOwnerRegistered(view, ac) || !sameAttachmentOwner(ac.currentAttachmentOwner(), view) { | |||
| 95 | + | return nil, false | |||
| 96 | + | } | |||
| 97 | + | placement := domain.Rect{Width: content.Cols, Height: content.Rows} | |||
| 98 | + | bars := request.bars | |||
| 99 | + | bars.status = remoteStatusSnapshot(view, snapshot) | |||
| 100 | + | if d != nil { | |||
| 101 | + | bars.attentionFrame = d.attentionFrame() | |||
| 102 | + | bars.mru = d.recentSessions(nil) | |||
| 103 | + | } | |||
| 104 | + | state := &capturedRenderState{ | |||
| 105 | + | attachment: ac, | |||
| 106 | + | lease: request.lease, | |||
| 107 | + | window: window, | |||
| 108 | + | reset: request.reset, | |||
| 109 | + | bars: bars, | |||
| 110 | + | theme: bars.theme, | |||
| 111 | + | styles: request.styles, | |||
| 112 | + | styleGeneration: request.styleGeneration, | |||
| 113 | + | overlays: request.overlays, | |||
| 114 | + | preview: request.preview, | |||
| 115 | + | layout: capturedTabLayout{area: placement, focus: remoteContentPaneID, valid: true}, | |||
| 116 | + | floatingGeneration: 0, | |||
| 117 | + | panes: []capturedPaneRenderState{{ | |||
| 118 | + | id: remoteContentPaneID, | |||
| 119 | + | frame: snapshot.frame, | |||
| 120 | + | placement: layout.Placement{ID: remoteContentPaneID, Content: placement}, | |||
| 121 | + | focused: true, | |||
| 122 | + | damage: []renderer.Damage{renderer.FullRedraw()}, | |||
| 123 | + | }}, | |||
| 124 | + | cursor: capturedCursorInputs{ | |||
| 125 | + | row: snapshot.cursor.Row, | |||
| 126 | + | col: snapshot.cursor.Col, | |||
| 127 | + | style: snapshot.cursor.Style, | |||
| 128 | + | hasStyle: snapshot.cursor.StyleSet, | |||
| 129 | + | visible: snapshot.cursor.Visible, | |||
| 130 | + | renderable: placement.Width > 0 && placement.Height > 0, | |||
| 131 | + | content: placement, | |||
| 132 | + | }, | |||
| 133 | + | } | |||
| 134 | + | return state, true | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | // paintRemoteView composes daemon-owned bars and overlays over the private | |||
| 138 | + | // remote terminal image. It uses the same output-state stream as local | |||
| 139 | + | // rendering, but never consults local session, pane, or coordinator state. | |||
| 140 | + | func (d *Daemon) paintRemoteView(view *remoteView, ac *attachedClient, reset bool, token attachmentConnectionToken) paintResult { | |||
| 141 | + | if view == nil || ac == nil || !sameAttachmentOwner(token.owner, view) { | |||
| 142 | + | return paintRejected | |||
| 143 | + | } | |||
| 144 | + | ticket, admitted := ac.beginAttachmentEffect(token) | |||
| 145 | + | if !admitted { | |||
| 146 | + | return paintRejected | |||
| 147 | + | } | |||
| 148 | + | token.effect = ticket | |||
| 149 | + | marks := d.newRuntimeMarkBatch() | |||
| 150 | + | marks.attachmentEffect = ticket | |||
| 151 | + | defer func() { | |||
| 152 | + | ticket.End() | |||
| 153 | + | marks.flush() | |||
| 154 | + | }() | |||
| 155 | + | ||||
| 156 | + | ac.sendMu.Lock() | |||
| 157 | + | if !token.attachmentEffectCurrent() || ac.output == nil { | |||
| 158 | + | ac.sendMu.Unlock() | |||
| 159 | + | return paintRejected | |||
| 160 | + | } | |||
| 161 | + | ac.output.syncCapacityLocked() | |||
| 162 | + | if ac.output.atCapacity() { | |||
| 163 | + | ac.sendMu.Unlock() | |||
| 164 | + | return paintBlockedCapacity | |||
| 165 | + | } | |||
| 166 | + | ac.initOverlays() | |||
| 167 | + | overlays := ac.overlays.SnapshotForRender() | |||
| 168 | + | defer overlays.Unlock() | |||
| 169 | + | statusFeedback := overlays.statusFeedback | |||
| 170 | + | if statusFeedback == "" && overlays.resizeActive { | |||
| 171 | + | statusFeedback = "resize unavailable for remote view" | |||
| 172 | + | } | |||
| 173 | + | applied := ac.getAppliedTheme() | |||
| 174 | + | bars := barState{statusFeedback: statusFeedback, theme: applied.Raw} | |||
| 175 | + | capturedOverlays := capturedOverlayRenderState{ | |||
| 176 | + | copyActive: overlays.copyActive, copySearchActive: overlays.copySearchModel != nil, | |||
| 177 | + | pickerActive: overlays.pickerActive, paletteActive: overlays.paletteActive, promptActive: overlays.promptActive, | |||
| 178 | + | resizeActive: overlays.resizeActive, statusFeedback: statusFeedback, | |||
| 179 | + | } | |||
| 180 | + | state, ok := d.captureRemoteViewRenderState(view, ac, renderCaptureRequest{ | |||
| 181 | + | bars: bars, | |||
| 182 | + | overlays: capturedOverlays, | |||
| 183 | + | preview: snapshotPickerPreview(nil), | |||
| 184 | + | styles: applied.Resolved.Styles, | |||
| 185 | + | styleGeneration: applied.Generation, | |||
| 186 | + | reset: reset, | |||
| 187 | + | }) | |||
| 188 | + | if !ok { | |||
| 189 | + | ac.sendMu.Unlock() | |||
| 190 | + | return paintRejected | |||
| 191 | + | } | |||
| 192 | + | captureOverlayLayers(state, overlays, d.currentPaletteConfig()) | |||
| 193 | + | composed := composeFrame(*state, ac.pipelineCache, ac.pipelineScratch) | |||
| 194 | + | if d.emitAttachmentFrame(view, ac, state, composed, &marks) { | |||
| 195 | + | return paintEmitted | |||
| 196 | + | } | |||
| 197 | + | return paintRejected | |||
| 198 | + | } | |||
M internal/usecase/daemon/render_pipeline.go +28 -10
| @@ -426,9 +426,19 @@ func commitDamageReceipts(receipts []damageReceipt) { | |||
| 426 | 426 | } | |
| 427 | 427 | } | |
| 428 | 428 | ||
| 429 | - | // emitFrame is the sole side-effecting half of the pipeline. The caller holds | |
| 430 | - | // sendMu for the complete capture/compose/emit transaction. | |
| 429 | + | // emitFrame is the local-session compatibility entry point for the sole | |
| 430 | + | // side-effecting half of the pipeline. The caller holds sendMu for the | |
| 431 | + | // complete capture/compose/emit transaction. | |
| 431 | 432 | func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRenderState, composed composedRenderFrame, batches ...*runtimeMarkBatch) bool { | |
| 433 | + | return d.emitAttachmentFrame(entry, ac, state, composed, batches...) | |
| 434 | + | } | |
| 435 | + | ||
| 436 | + | // emitAttachmentFrame commits a composed attachment frame only while its | |
| 437 | + | // exact owner binding remains current. It deliberately knows nothing about | |
| 438 | + | // session panes or remote VT state; those belong to capture. A coordinator | |
| 439 | + | // lease remains a local-session capability and is therefore rejected for | |
| 440 | + | // remote owners. | |
| 441 | + | func (d *Daemon) emitAttachmentFrame(owner attachmentOwner, ac *attachedClient, state *capturedRenderState, composed composedRenderFrame, batches ...*runtimeMarkBatch) bool { | |
| 432 | 442 | var ownedMarks runtimeMarkBatch | |
| 433 | 443 | var marks *runtimeMarkBatch | |
| 434 | 444 | if len(batches) != 0 { | |
| @@ -440,22 +450,20 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe | |||
| 440 | 450 | // follows emitFrame's release of attachment ownership. | |
| 441 | 451 | defer marks.flush() | |
| 442 | 452 | } | |
| 443 | - | if entry == nil || entry.core() == nil || ac == nil || state == nil { | |
| 453 | + | if normalizeAttachmentOwner(owner) == nil || ac == nil || state == nil { | |
| 444 | 454 | if ac != nil { | |
| 445 | 455 | ac.sendMu.Unlock() | |
| 446 | 456 | } | |
| 447 | 457 | return false | |
| 448 | 458 | } | |
| 449 | - | entry.core().mu.Lock() | |
| 450 | - | _, owned := entry.core().attachments[ac] | |
| 451 | - | entry.core().mu.Unlock() | |
| 452 | - | if !owned || state.attachment != ac || ac.currentAttachmentSession() != entry { | |
| 459 | + | if !attachmentOwnerRegistered(owner, ac) || state.attachment != ac || !sameAttachmentOwner(ac.currentAttachmentOwner(), owner) { | |
| 453 | 460 | ac.sendMu.Unlock() | |
| 454 | 461 | return false | |
| 455 | 462 | } | |
| 456 | 463 | if state.lease != nil { | |
| 464 | + | entry := localSession(owner) | |
| 457 | 465 | rc := attachmentRenderCoordinator(entry) | |
| 458 | - | if rc == nil || state.lease.attachment != ac || !rc.leaseCurrent(state.lease, true) { | |
| 466 | + | if entry == nil || rc == nil || state.lease.attachment != ac || !rc.leaseCurrent(state.lease, true) { | |
| 459 | 467 | ac.sendMu.Unlock() | |
| 460 | 468 | return false | |
| 461 | 469 | } | |
| @@ -476,7 +484,13 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe | |||
| 476 | 484 | endDiff(0, err == nil) | |
| 477 | 485 | if err != nil { | |
| 478 | 486 | ac.sendMu.Unlock() | |
| 479 | - | d.log.Error("render draw failed", "err", err, "session", entry.core().name) | |
| 487 | + | d.log.Error("render draw failed", "err", err, "session", attachmentOwnerName(owner)) | |
| 488 | + | entry := localSession(owner) | |
| 489 | + | if entry == nil { | |
| 490 | + | // A remote view has no session-scoped notice history or coordinator. | |
| 491 | + | // Its next remote publication will retry from the retained private VT. | |
| 492 | + | return true | |
| 493 | + | } | |
| 480 | 494 | // Without a coordinator reportError repaints synchronously. Suppress only | |
| 481 | 495 | // that nested notice repaint; leave the guard before returning so a later, | |
| 482 | 496 | // independent failed transaction can still notify the user. | |
| @@ -561,7 +575,11 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe | |||
| 561 | 575 | // admission first. Detachment freezes the gate and therefore cannot mutate | |
| 562 | 576 | // ownership until any enclosing admitted operation has also ended. | |
| 563 | 577 | if marks.attachmentEffect == nil { | |
| 564 | - | d.detachOnSendError(entry, ac, sendTr) | |
| 578 | + | if entry := localSession(owner); entry != nil { | |
| 579 | + | d.detachOnSendError(entry, ac, sendTr) | |
| 580 | + | } else if view, remote := owner.(*remoteView); remote { | |
| 581 | + | d.clientGoneRemote(view, attachmentOwnerToken(view, ac, sendTr), false) | |
| 582 | + | } | |
| 565 | 583 | } else { | |
| 566 | 584 | // Capture the exact admitted capability, including its coordinator | |
| 567 | 585 | // lease, before End permits a new attachment publication. Reserve | |
| @@ -426,9 +426,19 @@ func commitDamageReceipts(receipts []damageReceipt) { | |||||
| 426 | } | 426 | } | ||
| 427 | } | 427 | } | ||
| 428 | 428 | ||||
| 429 | - | // emitFrame is the sole side-effecting half of the pipeline. The caller holds | 429 | + | // emitFrame is the local-session compatibility entry point for the sole |
| 430 | - | // sendMu for the complete capture/compose/emit transaction. | 430 | + | // side-effecting half of the pipeline. The caller holds sendMu for the |
| 431 | + | // complete capture/compose/emit transaction. | |||
| 431 | func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRenderState, composed composedRenderFrame, batches ...*runtimeMarkBatch) bool { | 432 | func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRenderState, composed composedRenderFrame, batches ...*runtimeMarkBatch) bool { | ||
| 433 | + | return d.emitAttachmentFrame(entry, ac, state, composed, batches...) | |||
| 434 | + | } | |||
| 435 | + | ||||
| 436 | + | // emitAttachmentFrame commits a composed attachment frame only while its | |||
| 437 | + | // exact owner binding remains current. It deliberately knows nothing about | |||
| 438 | + | // session panes or remote VT state; those belong to capture. A coordinator | |||
| 439 | + | // lease remains a local-session capability and is therefore rejected for | |||
| 440 | + | // remote owners. | |||
| 441 | + | func (d *Daemon) emitAttachmentFrame(owner attachmentOwner, ac *attachedClient, state *capturedRenderState, composed composedRenderFrame, batches ...*runtimeMarkBatch) bool { | |||
| 432 | var ownedMarks runtimeMarkBatch | 442 | var ownedMarks runtimeMarkBatch | ||
| 433 | var marks *runtimeMarkBatch | 443 | var marks *runtimeMarkBatch | ||
| 434 | if len(batches) != 0 { | 444 | if len(batches) != 0 { | ||
| @@ -440,22 +450,20 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe | |||||
| 440 | // follows emitFrame's release of attachment ownership. | 450 | // follows emitFrame's release of attachment ownership. | ||
| 441 | defer marks.flush() | 451 | defer marks.flush() | ||
| 442 | } | 452 | } | ||
| 443 | - | if entry == nil || entry.core() == nil || ac == nil || state == nil { | 453 | + | if normalizeAttachmentOwner(owner) == nil || ac == nil || state == nil { |
| 444 | if ac != nil { | 454 | if ac != nil { | ||
| 445 | ac.sendMu.Unlock() | 455 | ac.sendMu.Unlock() | ||
| 446 | } | 456 | } | ||
| 447 | return false | 457 | return false | ||
| 448 | } | 458 | } | ||
| 449 | - | entry.core().mu.Lock() | 459 | + | if !attachmentOwnerRegistered(owner, ac) || state.attachment != ac || !sameAttachmentOwner(ac.currentAttachmentOwner(), owner) { |
| 450 | - | _, owned := entry.core().attachments[ac] | |||
| 451 | - | entry.core().mu.Unlock() | |||
| 452 | - | if !owned || state.attachment != ac || ac.currentAttachmentSession() != entry { | |||
| 453 | ac.sendMu.Unlock() | 460 | ac.sendMu.Unlock() | ||
| 454 | return false | 461 | return false | ||
| 455 | } | 462 | } | ||
| 456 | if state.lease != nil { | 463 | if state.lease != nil { | ||
| 464 | + | entry := localSession(owner) | |||
| 457 | rc := attachmentRenderCoordinator(entry) | 465 | rc := attachmentRenderCoordinator(entry) | ||
| 458 | - | if rc == nil || state.lease.attachment != ac || !rc.leaseCurrent(state.lease, true) { | 466 | + | if entry == nil || rc == nil || state.lease.attachment != ac || !rc.leaseCurrent(state.lease, true) { |
| 459 | ac.sendMu.Unlock() | 467 | ac.sendMu.Unlock() | ||
| 460 | return false | 468 | return false | ||
| 461 | } | 469 | } | ||
| @@ -476,7 +484,13 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe | |||||
| 476 | endDiff(0, err == nil) | 484 | endDiff(0, err == nil) | ||
| 477 | if err != nil { | 485 | if err != nil { | ||
| 478 | ac.sendMu.Unlock() | 486 | ac.sendMu.Unlock() | ||
| 479 | - | d.log.Error("render draw failed", "err", err, "session", entry.core().name) | 487 | + | d.log.Error("render draw failed", "err", err, "session", attachmentOwnerName(owner)) |
| 488 | + | entry := localSession(owner) | |||
| 489 | + | if entry == nil { | |||
| 490 | + | // A remote view has no session-scoped notice history or coordinator. | |||
| 491 | + | // Its next remote publication will retry from the retained private VT. | |||
| 492 | + | return true | |||
| 493 | + | } | |||
| 480 | // Without a coordinator reportError repaints synchronously. Suppress only | 494 | // Without a coordinator reportError repaints synchronously. Suppress only | ||
| 481 | // that nested notice repaint; leave the guard before returning so a later, | 495 | // that nested notice repaint; leave the guard before returning so a later, | ||
| 482 | // independent failed transaction can still notify the user. | 496 | // independent failed transaction can still notify the user. | ||
| @@ -561,7 +575,11 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe | |||||
| 561 | // admission first. Detachment freezes the gate and therefore cannot mutate | 575 | // admission first. Detachment freezes the gate and therefore cannot mutate | ||
| 562 | // ownership until any enclosing admitted operation has also ended. | 576 | // ownership until any enclosing admitted operation has also ended. | ||
| 563 | if marks.attachmentEffect == nil { | 577 | if marks.attachmentEffect == nil { | ||
| 564 | - | d.detachOnSendError(entry, ac, sendTr) | 578 | + | if entry := localSession(owner); entry != nil { |
| 579 | + | d.detachOnSendError(entry, ac, sendTr) | |||
| 580 | + | } else if view, remote := owner.(*remoteView); remote { | |||
| 581 | + | d.clientGoneRemote(view, attachmentOwnerToken(view, ac, sendTr), false) | |||
| 582 | + | } | |||
| 565 | } else { | 583 | } else { | ||
| 566 | // Capture the exact admitted capability, including its coordinator | 584 | // Capture the exact admitted capability, including its coordinator | ||
| 567 | // lease, before End permits a new attachment publication. Reserve | 585 | // lease, before End permits a new attachment publication. Reserve | ||
M internal/usecase/daemon/session_back.go +28 -1
| @@ -10,8 +10,35 @@ import ( | |||
| 10 | 10 | // owner is retained in the owner history but is not interpreted as a local | |
| 11 | 11 | // session; remote back navigation is published with remote transitions. | |
| 12 | 12 | func (d *Daemon) backSessionForAttachment(token attachmentConnectionToken) error { | |
| 13 | + | if d == nil || token.ac == nil { | |
| 14 | + | return nil | |
| 15 | + | } | |
| 16 | + | if view, remote := token.owner.(*remoteView); remote { | |
| 17 | + | previous := token.ac.previousOwner.Get() | |
| 18 | + | target := localSession(previous) | |
| 19 | + | if target == nil { | |
| 20 | + | // A non-local predecessor is not a valid reverse target. Keep it as | |
| 21 | + | // stable history for the owner-specific transition that can interpret it. | |
| 22 | + | return nil | |
| 23 | + | } | |
| 24 | + | if target.core() == nil || d.sessionByID(target.core().id) != target { | |
| 25 | + | token.ac.clearPreviousOwnerIf(previous) | |
| 26 | + | return nil | |
| 27 | + | } | |
| 28 | + | published, err := d.transitionFromRemoteView(token, view, target) | |
| 29 | + | if err != nil { | |
| 30 | + | if !token.current() { | |
| 31 | + | return nil | |
| 32 | + | } | |
| 33 | + | return err | |
| 34 | + | } | |
| 35 | + | d.touchMRU(target) | |
| 36 | + | d.firstPaintForTransition(published) | |
| 37 | + | return nil | |
| 38 | + | } | |
| 39 | + | ||
| 13 | 40 | current := token.localSession() | |
| 14 | - | if d == nil || current == nil || token.ac == nil { | |
| 41 | + | if current == nil { | |
| 15 | 42 | return nil | |
| 16 | 43 | } | |
| 17 | 44 | previous := token.ac.previousOwner.Get() | |
| @@ -10,8 +10,35 @@ import ( | |||||
| 10 | // owner is retained in the owner history but is not interpreted as a local | 10 | // owner is retained in the owner history but is not interpreted as a local | ||
| 11 | // session; remote back navigation is published with remote transitions. | 11 | // session; remote back navigation is published with remote transitions. | ||
| 12 | func (d *Daemon) backSessionForAttachment(token attachmentConnectionToken) error { | 12 | func (d *Daemon) backSessionForAttachment(token attachmentConnectionToken) error { | ||
| 13 | + | if d == nil || token.ac == nil { | |||
| 14 | + | return nil | |||
| 15 | + | } | |||
| 16 | + | if view, remote := token.owner.(*remoteView); remote { | |||
| 17 | + | previous := token.ac.previousOwner.Get() | |||
| 18 | + | target := localSession(previous) | |||
| 19 | + | if target == nil { | |||
| 20 | + | // A non-local predecessor is not a valid reverse target. Keep it as | |||
| 21 | + | // stable history for the owner-specific transition that can interpret it. | |||
| 22 | + | return nil | |||
| 23 | + | } | |||
| 24 | + | if target.core() == nil || d.sessionByID(target.core().id) != target { | |||
| 25 | + | token.ac.clearPreviousOwnerIf(previous) | |||
| 26 | + | return nil | |||
| 27 | + | } | |||
| 28 | + | published, err := d.transitionFromRemoteView(token, view, target) | |||
| 29 | + | if err != nil { | |||
| 30 | + | if !token.current() { | |||
| 31 | + | return nil | |||
| 32 | + | } | |||
| 33 | + | return err | |||
| 34 | + | } | |||
| 35 | + | d.touchMRU(target) | |||
| 36 | + | d.firstPaintForTransition(published) | |||
| 37 | + | return nil | |||
| 38 | + | } | |||
| 39 | + | ||||
| 13 | current := token.localSession() | 40 | current := token.localSession() | ||
| 14 | - | if d == nil || current == nil || token.ac == nil { | 41 | + | if current == nil { |
| 15 | return nil | 42 | return nil | ||
| 16 | } | 43 | } | ||
| 17 | previous := token.ac.previousOwner.Get() | 44 | previous := token.ac.previousOwner.Get() | ||