feat(wayland): xdg-activation with focus-checked tokens
- commit
- cf943d9aa51e5f0744d3037d7fec72f5ee29d3f8
- author
- brice · · 2026-09-25 19:21 UTC
- parent
- 856d644
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
7 files · +286 -4
A internal/adapters/wayland/activation.go +111 -0
| @@ -0,0 +1,111 @@ | |||
| 1 | + | package wayland | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "crypto/rand" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/nefertty/internal/ports" | |
| 8 | + | "github.com/bnema/purego-libwayland/protocol/wayland" | |
| 9 | + | "github.com/bnema/purego-libwayland/protocol/xdgactivation" | |
| 10 | + | "github.com/bnema/purego-libwayland/server" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // xdg-activation: a client that has the user's attention (keyboard focus, | |
| 14 | + | // or the last key or button press) gets a token, and the client it hands | |
| 15 | + | // the token to (a link opened in the browser, a notification action) may | |
| 16 | + | // bring its window forward with it. Other tokens are issued but inert, so | |
| 17 | + | // a background app cannot steal the focus. | |
| 18 | + | ||
| 19 | + | // tokenLifetime is how long a token can activate a window. | |
| 20 | + | const tokenLifetime = 10 * time.Second | |
| 21 | + | ||
| 22 | + | // activationToken is an issued token; valid ones may activate a window. | |
| 23 | + | type activationToken struct { | |
| 24 | + | valid bool | |
| 25 | + | at time.Time | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func registerActivation(d *server.Display, s *Server) error { | |
| 29 | + | return xdgactivation.NewActivationV1Global(d, 1, func(c server.Client, v, id uint32) { | |
| 30 | + | _, _ = xdgactivation.NewActivationV1(c, int32(v), id, activation{s}) | |
| 31 | + | }) | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | type activation struct{ server *Server } | |
| 35 | + | ||
| 36 | + | func (activation) Destroy(*xdgactivation.ActivationV1) {} | |
| 37 | + | ||
| 38 | + | func (a activation) GetActivationToken(r *xdgactivation.ActivationV1, id uint32) { | |
| 39 | + | _, _ = xdgactivation.NewActivationTokenV1(r.Client(), r.Version(), id, &tokenRequest{server: a.server}) | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // Activate focuses the toplevel of surface when the token is valid. The | |
| 43 | + | // token is used up either way. | |
| 44 | + | func (a activation) Activate(_ *xdgactivation.ActivationV1, token string, surf *wayland.Surface) { | |
| 45 | + | s := a.server | |
| 46 | + | t, ok := s.tokens[token] | |
| 47 | + | delete(s.tokens, token) | |
| 48 | + | state := s.surfaceOf(surf) | |
| 49 | + | if !ok || !t.valid || time.Since(t.at) > tokenLifetime || state == nil { | |
| 50 | + | s.log.Debug().Bool("known", ok).Msg("activation refused") | |
| 51 | + | return | |
| 52 | + | } | |
| 53 | + | w := s.windows[state.root().windowID()] | |
| 54 | + | if w == nil || w.toplevel == nil || !w.mapped { | |
| 55 | + | return | |
| 56 | + | } | |
| 57 | + | s.log.Info().Uint64("id", uint64(w.id)).Msg("activation") | |
| 58 | + | s.emit(ports.WindowActivate{ID: w.id}) | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // tokenRequest collects a token's serial and surface until commit. | |
| 62 | + | type tokenRequest struct { | |
| 63 | + | server *Server | |
| 64 | + | serial uint32 | |
| 65 | + | hasSerial bool | |
| 66 | + | used bool | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | func (t *tokenRequest) SetSerial(_ *xdgactivation.ActivationTokenV1, serial uint32, _ *wayland.Seat) { | |
| 70 | + | t.serial, t.hasSerial = serial, true | |
| 71 | + | } | |
| 72 | + | func (*tokenRequest) SetAppId(*xdgactivation.ActivationTokenV1, string) {} | |
| 73 | + | func (*tokenRequest) SetSurface(*xdgactivation.ActivationTokenV1, *wayland.Surface) {} | |
| 74 | + | func (*tokenRequest) Destroy(*xdgactivation.ActivationTokenV1) {} | |
| 75 | + | ||
| 76 | + | // Commit issues the token. It is valid when the requesting client has the | |
| 77 | + | // keyboard focus or made the last press it names by serial. | |
| 78 | + | func (t *tokenRequest) Commit(r *xdgactivation.ActivationTokenV1) { | |
| 79 | + | if t.used { | |
| 80 | + | r.PostError(uint32(xdgactivation.ActivationTokenV1ErrorAlreadyUsed), "token already committed") | |
| 81 | + | return | |
| 82 | + | } | |
| 83 | + | t.used = true | |
| 84 | + | s := t.server | |
| 85 | + | c := r.Client() | |
| 86 | + | valid := s.focusClient() == c || (t.hasSerial && t.serial == s.press && s.pressClient == c) | |
| 87 | + | s.pruneTokens() | |
| 88 | + | token := rand.Text() | |
| 89 | + | s.tokens[token] = activationToken{valid: valid, at: time.Now()} | |
| 90 | + | r.SendDone(token) | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | // focusClient is the client with the keyboard focus. | |
| 94 | + | func (s *Server) focusClient() server.Client { | |
| 95 | + | if w := s.windows[s.focused]; w != nil && w.xdg.resource.Resource.Alive() { | |
| 96 | + | return w.xdg.resource.Client() | |
| 97 | + | } | |
| 98 | + | if l := s.layers[s.focused]; l != nil && l.resource.Resource.Alive() { | |
| 99 | + | return l.resource.Client() | |
| 100 | + | } | |
| 101 | + | return server.Client{} | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | // pruneTokens forgets expired tokens. | |
| 105 | + | func (s *Server) pruneTokens() { | |
| 106 | + | for k, t := range s.tokens { | |
| 107 | + | if time.Since(t.at) > tokenLifetime { | |
| 108 | + | delete(s.tokens, k) | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | } | |
| @@ -0,0 +1,111 @@ | |||||
| 1 | + | package wayland | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "crypto/rand" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/nefertty/internal/ports" | |||
| 8 | + | "github.com/bnema/purego-libwayland/protocol/wayland" | |||
| 9 | + | "github.com/bnema/purego-libwayland/protocol/xdgactivation" | |||
| 10 | + | "github.com/bnema/purego-libwayland/server" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // xdg-activation: a client that has the user's attention (keyboard focus, | |||
| 14 | + | // or the last key or button press) gets a token, and the client it hands | |||
| 15 | + | // the token to (a link opened in the browser, a notification action) may | |||
| 16 | + | // bring its window forward with it. Other tokens are issued but inert, so | |||
| 17 | + | // a background app cannot steal the focus. | |||
| 18 | + | ||||
| 19 | + | // tokenLifetime is how long a token can activate a window. | |||
| 20 | + | const tokenLifetime = 10 * time.Second | |||
| 21 | + | ||||
| 22 | + | // activationToken is an issued token; valid ones may activate a window. | |||
| 23 | + | type activationToken struct { | |||
| 24 | + | valid bool | |||
| 25 | + | at time.Time | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func registerActivation(d *server.Display, s *Server) error { | |||
| 29 | + | return xdgactivation.NewActivationV1Global(d, 1, func(c server.Client, v, id uint32) { | |||
| 30 | + | _, _ = xdgactivation.NewActivationV1(c, int32(v), id, activation{s}) | |||
| 31 | + | }) | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | type activation struct{ server *Server } | |||
| 35 | + | ||||
| 36 | + | func (activation) Destroy(*xdgactivation.ActivationV1) {} | |||
| 37 | + | ||||
| 38 | + | func (a activation) GetActivationToken(r *xdgactivation.ActivationV1, id uint32) { | |||
| 39 | + | _, _ = xdgactivation.NewActivationTokenV1(r.Client(), r.Version(), id, &tokenRequest{server: a.server}) | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // Activate focuses the toplevel of surface when the token is valid. The | |||
| 43 | + | // token is used up either way. | |||
| 44 | + | func (a activation) Activate(_ *xdgactivation.ActivationV1, token string, surf *wayland.Surface) { | |||
| 45 | + | s := a.server | |||
| 46 | + | t, ok := s.tokens[token] | |||
| 47 | + | delete(s.tokens, token) | |||
| 48 | + | state := s.surfaceOf(surf) | |||
| 49 | + | if !ok || !t.valid || time.Since(t.at) > tokenLifetime || state == nil { | |||
| 50 | + | s.log.Debug().Bool("known", ok).Msg("activation refused") | |||
| 51 | + | return | |||
| 52 | + | } | |||
| 53 | + | w := s.windows[state.root().windowID()] | |||
| 54 | + | if w == nil || w.toplevel == nil || !w.mapped { | |||
| 55 | + | return | |||
| 56 | + | } | |||
| 57 | + | s.log.Info().Uint64("id", uint64(w.id)).Msg("activation") | |||
| 58 | + | s.emit(ports.WindowActivate{ID: w.id}) | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | // tokenRequest collects a token's serial and surface until commit. | |||
| 62 | + | type tokenRequest struct { | |||
| 63 | + | server *Server | |||
| 64 | + | serial uint32 | |||
| 65 | + | hasSerial bool | |||
| 66 | + | used bool | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | func (t *tokenRequest) SetSerial(_ *xdgactivation.ActivationTokenV1, serial uint32, _ *wayland.Seat) { | |||
| 70 | + | t.serial, t.hasSerial = serial, true | |||
| 71 | + | } | |||
| 72 | + | func (*tokenRequest) SetAppId(*xdgactivation.ActivationTokenV1, string) {} | |||
| 73 | + | func (*tokenRequest) SetSurface(*xdgactivation.ActivationTokenV1, *wayland.Surface) {} | |||
| 74 | + | func (*tokenRequest) Destroy(*xdgactivation.ActivationTokenV1) {} | |||
| 75 | + | ||||
| 76 | + | // Commit issues the token. It is valid when the requesting client has the | |||
| 77 | + | // keyboard focus or made the last press it names by serial. | |||
| 78 | + | func (t *tokenRequest) Commit(r *xdgactivation.ActivationTokenV1) { | |||
| 79 | + | if t.used { | |||
| 80 | + | r.PostError(uint32(xdgactivation.ActivationTokenV1ErrorAlreadyUsed), "token already committed") | |||
| 81 | + | return | |||
| 82 | + | } | |||
| 83 | + | t.used = true | |||
| 84 | + | s := t.server | |||
| 85 | + | c := r.Client() | |||
| 86 | + | valid := s.focusClient() == c || (t.hasSerial && t.serial == s.press && s.pressClient == c) | |||
| 87 | + | s.pruneTokens() | |||
| 88 | + | token := rand.Text() | |||
| 89 | + | s.tokens[token] = activationToken{valid: valid, at: time.Now()} | |||
| 90 | + | r.SendDone(token) | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | // focusClient is the client with the keyboard focus. | |||
| 94 | + | func (s *Server) focusClient() server.Client { | |||
| 95 | + | if w := s.windows[s.focused]; w != nil && w.xdg.resource.Resource.Alive() { | |||
| 96 | + | return w.xdg.resource.Client() | |||
| 97 | + | } | |||
| 98 | + | if l := s.layers[s.focused]; l != nil && l.resource.Resource.Alive() { | |||
| 99 | + | return l.resource.Client() | |||
| 100 | + | } | |||
| 101 | + | return server.Client{} | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | // pruneTokens forgets expired tokens. | |||
| 105 | + | func (s *Server) pruneTokens() { | |||
| 106 | + | for k, t := range s.tokens { | |||
| 107 | + | if time.Since(t.at) > tokenLifetime { | |||
| 108 | + | delete(s.tokens, k) | |||
| 109 | + | } | |||
| 110 | + | } | |||
| 111 | + | } | |||
A internal/adapters/wayland/activation_test.go +106 -0
| @@ -0,0 +1,106 @@ | |||
| 1 | + | package wayland | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/nefertty/internal/ports" | |
| 8 | + | "github.com/bnema/purego-libwayland/protocol/xdgactivation" | |
| 9 | + | "github.com/bnema/wlturbo" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // tokenProxy receives an xdg_activation_token_v1.done token. | |
| 13 | + | type tokenProxy struct { | |
| 14 | + | wlturbo.BaseProxy | |
| 15 | + | tokens chan string | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | func (p *tokenProxy) Dispatch(e *wlturbo.Event) { p.tokens <- e.String() } | |
| 19 | + | ||
| 20 | + | // newToken commits a token request and returns the token. | |
| 21 | + | func newToken(t *testing.T, c *wlturbo.Display, manager uint32) string { | |
| 22 | + | t.Helper() | |
| 23 | + | id := c.AllocateID() | |
| 24 | + | p := &tokenProxy{tokens: make(chan string, 1)} | |
| 25 | + | p.SetID(id) | |
| 26 | + | c.Context().Register(p) | |
| 27 | + | requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestGetActivationToken, id) | |
| 28 | + | requestProtocol(t, c, id, xdgactivation.ActivationTokenV1RequestCommit) | |
| 29 | + | for deadline := time.Now().Add(2 * time.Second); time.Now().Before(deadline); { | |
| 30 | + | if err := c.Roundtrip(); err != nil { | |
| 31 | + | t.Fatal(err) | |
| 32 | + | } | |
| 33 | + | select { | |
| 34 | + | case tok := <-p.tokens: | |
| 35 | + | return tok | |
| 36 | + | default: | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | t.Fatal("no token") | |
| 40 | + | return "" | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func activated(t *testing.T, events <-chan ports.ClientEvent, want ports.WindowID) { | |
| 44 | + | t.Helper() | |
| 45 | + | deadline := time.After(2 * time.Second) | |
| 46 | + | for { | |
| 47 | + | select { | |
| 48 | + | case ev := <-events: | |
| 49 | + | if a, ok := ev.(ports.WindowActivate); ok { | |
| 50 | + | if a.ID != want { | |
| 51 | + | t.Fatalf("activated %d, want %d", a.ID, want) | |
| 52 | + | } | |
| 53 | + | return | |
| 54 | + | } | |
| 55 | + | case <-deadline: | |
| 56 | + | t.Fatal("no activation") | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func noActivation(t *testing.T, c *wlturbo.Display, events <-chan ports.ClientEvent) { | |
| 62 | + | t.Helper() | |
| 63 | + | if err := c.Roundtrip(); err != nil { | |
| 64 | + | t.Fatal(err) | |
| 65 | + | } | |
| 66 | + | deadline := time.After(100 * time.Millisecond) | |
| 67 | + | for { | |
| 68 | + | select { | |
| 69 | + | case ev := <-events: | |
| 70 | + | if _, ok := ev.(ports.WindowActivate); ok { | |
| 71 | + | t.Fatal("activated with an invalid token") | |
| 72 | + | } | |
| 73 | + | case <-deadline: | |
| 74 | + | return | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func TestActivationNeedsFocusedRequester(t *testing.T) { | |
| 80 | + | s, events, commands, dir := lifecycleServer(t) | |
| 81 | + | c := protocolClient(t, s, dir) | |
| 82 | + | mapWindow := surfaceMapper(t, c, events) | |
| 83 | + | first, _, _ := mapWindow() | |
| 84 | + | second, surf, _ := mapWindow() | |
| 85 | + | manager := bindProtocol(t, c, "xdg_activation_v1") | |
| 86 | + | ||
| 87 | + | // Without focus the token is inert. | |
| 88 | + | token := newToken(t, c, manager) | |
| 89 | + | requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestActivate, token, surf) | |
| 90 | + | noActivation(t, c, events) | |
| 91 | + | ||
| 92 | + | // The focused client's token activates, once. | |
| 93 | + | commands <- ports.FocusWindow{ID: first.ID} | |
| 94 | + | if err := c.Roundtrip(); err != nil { | |
| 95 | + | t.Fatal(err) | |
| 96 | + | } | |
| 97 | + | token = newToken(t, c, manager) | |
| 98 | + | requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestActivate, token, surf) | |
| 99 | + | activated(t, events, second.ID) | |
| 100 | + | requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestActivate, token, surf) | |
| 101 | + | noActivation(t, c, events) | |
| 102 | + | ||
| 103 | + | // Unknown tokens are ignored. | |
| 104 | + | requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestActivate, "not-a-token", surf) | |
| 105 | + | noActivation(t, c, events) | |
| 106 | + | } | |
| @@ -0,0 +1,106 @@ | |||||
| 1 | + | package wayland | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/nefertty/internal/ports" | |||
| 8 | + | "github.com/bnema/purego-libwayland/protocol/xdgactivation" | |||
| 9 | + | "github.com/bnema/wlturbo" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // tokenProxy receives an xdg_activation_token_v1.done token. | |||
| 13 | + | type tokenProxy struct { | |||
| 14 | + | wlturbo.BaseProxy | |||
| 15 | + | tokens chan string | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | func (p *tokenProxy) Dispatch(e *wlturbo.Event) { p.tokens <- e.String() } | |||
| 19 | + | ||||
| 20 | + | // newToken commits a token request and returns the token. | |||
| 21 | + | func newToken(t *testing.T, c *wlturbo.Display, manager uint32) string { | |||
| 22 | + | t.Helper() | |||
| 23 | + | id := c.AllocateID() | |||
| 24 | + | p := &tokenProxy{tokens: make(chan string, 1)} | |||
| 25 | + | p.SetID(id) | |||
| 26 | + | c.Context().Register(p) | |||
| 27 | + | requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestGetActivationToken, id) | |||
| 28 | + | requestProtocol(t, c, id, xdgactivation.ActivationTokenV1RequestCommit) | |||
| 29 | + | for deadline := time.Now().Add(2 * time.Second); time.Now().Before(deadline); { | |||
| 30 | + | if err := c.Roundtrip(); err != nil { | |||
| 31 | + | t.Fatal(err) | |||
| 32 | + | } | |||
| 33 | + | select { | |||
| 34 | + | case tok := <-p.tokens: | |||
| 35 | + | return tok | |||
| 36 | + | default: | |||
| 37 | + | } | |||
| 38 | + | } | |||
| 39 | + | t.Fatal("no token") | |||
| 40 | + | return "" | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func activated(t *testing.T, events <-chan ports.ClientEvent, want ports.WindowID) { | |||
| 44 | + | t.Helper() | |||
| 45 | + | deadline := time.After(2 * time.Second) | |||
| 46 | + | for { | |||
| 47 | + | select { | |||
| 48 | + | case ev := <-events: | |||
| 49 | + | if a, ok := ev.(ports.WindowActivate); ok { | |||
| 50 | + | if a.ID != want { | |||
| 51 | + | t.Fatalf("activated %d, want %d", a.ID, want) | |||
| 52 | + | } | |||
| 53 | + | return | |||
| 54 | + | } | |||
| 55 | + | case <-deadline: | |||
| 56 | + | t.Fatal("no activation") | |||
| 57 | + | } | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func noActivation(t *testing.T, c *wlturbo.Display, events <-chan ports.ClientEvent) { | |||
| 62 | + | t.Helper() | |||
| 63 | + | if err := c.Roundtrip(); err != nil { | |||
| 64 | + | t.Fatal(err) | |||
| 65 | + | } | |||
| 66 | + | deadline := time.After(100 * time.Millisecond) | |||
| 67 | + | for { | |||
| 68 | + | select { | |||
| 69 | + | case ev := <-events: | |||
| 70 | + | if _, ok := ev.(ports.WindowActivate); ok { | |||
| 71 | + | t.Fatal("activated with an invalid token") | |||
| 72 | + | } | |||
| 73 | + | case <-deadline: | |||
| 74 | + | return | |||
| 75 | + | } | |||
| 76 | + | } | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func TestActivationNeedsFocusedRequester(t *testing.T) { | |||
| 80 | + | s, events, commands, dir := lifecycleServer(t) | |||
| 81 | + | c := protocolClient(t, s, dir) | |||
| 82 | + | mapWindow := surfaceMapper(t, c, events) | |||
| 83 | + | first, _, _ := mapWindow() | |||
| 84 | + | second, surf, _ := mapWindow() | |||
| 85 | + | manager := bindProtocol(t, c, "xdg_activation_v1") | |||
| 86 | + | ||||
| 87 | + | // Without focus the token is inert. | |||
| 88 | + | token := newToken(t, c, manager) | |||
| 89 | + | requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestActivate, token, surf) | |||
| 90 | + | noActivation(t, c, events) | |||
| 91 | + | ||||
| 92 | + | // The focused client's token activates, once. | |||
| 93 | + | commands <- ports.FocusWindow{ID: first.ID} | |||
| 94 | + | if err := c.Roundtrip(); err != nil { | |||
| 95 | + | t.Fatal(err) | |||
| 96 | + | } | |||
| 97 | + | token = newToken(t, c, manager) | |||
| 98 | + | requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestActivate, token, surf) | |||
| 99 | + | activated(t, events, second.ID) | |||
| 100 | + | requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestActivate, token, surf) | |||
| 101 | + | noActivation(t, c, events) | |||
| 102 | + | ||||
| 103 | + | // Unknown tokens are ignored. | |||
| 104 | + | requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestActivate, "not-a-token", surf) | |||
| 105 | + | noActivation(t, c, events) | |||
| 106 | + | } | |||
M internal/adapters/wayland/globals.go +1 -0
| @@ -19,6 +19,7 @@ func registerGlobals(d *server.Display, o Options, s *Server) error { | |||
| 19 | 19 | func() error { return registerPointerConstraints(d, s) }, | |
| 20 | 20 | func() error { return registerTearing(d, s) }, | |
| 21 | 21 | func() error { return registerPresentation(d, s) }, | |
| 22 | + | func() error { return registerActivation(d, s) }, | |
| 22 | 23 | func() error { | |
| 23 | 24 | return wayland.NewCompositorGlobal(d, 6, func(c server.Client, v, id uint32) { wayland.NewCompositor(c, int32(v), id, compositor{s}) }) | |
| 24 | 25 | }, | |
| @@ -19,6 +19,7 @@ func registerGlobals(d *server.Display, o Options, s *Server) error { | |||||
| 19 | func() error { return registerPointerConstraints(d, s) }, | 19 | func() error { return registerPointerConstraints(d, s) }, | ||
| 20 | func() error { return registerTearing(d, s) }, | 20 | func() error { return registerTearing(d, s) }, | ||
| 21 | func() error { return registerPresentation(d, s) }, | 21 | func() error { return registerPresentation(d, s) }, | ||
| 22 | + | func() error { return registerActivation(d, s) }, | |||
| 22 | func() error { | 23 | func() error { | ||
| 23 | return wayland.NewCompositorGlobal(d, 6, func(c server.Client, v, id uint32) { wayland.NewCompositor(c, int32(v), id, compositor{s}) }) | 24 | return wayland.NewCompositorGlobal(d, 6, func(c server.Client, v, id uint32) { wayland.NewCompositor(c, int32(v), id, compositor{s}) }) | ||
| 24 | }, | 25 | }, | ||
M internal/adapters/wayland/server.go +7 -4
| @@ -62,10 +62,12 @@ type Server struct { | |||
| 62 | 62 | // lastFlip is each output's latest page flip. | |
| 63 | 63 | fifoSurfaces map[*surface]struct{} | |
| 64 | 64 | lastFlip map[string]time.Time | |
| 65 | - | surfaces map[*server.Resource]*surface | |
| 66 | - | buffers map[*server.Resource]clientBuffer | |
| 67 | - | dmabuf *dmabufGlobal | |
| 68 | - | serial uint32 | |
| 65 | + | // tokens are the issued xdg-activation tokens (activation.go). | |
| 66 | + | tokens map[string]activationToken | |
| 67 | + | surfaces map[*server.Resource]*surface | |
| 68 | + | buffers map[*server.Resource]clientBuffer | |
| 69 | + | dmabuf *dmabufGlobal | |
| 70 | + | serial uint32 | |
| 69 | 71 | // press is the serial of the last button or key press, sent to | |
| 70 | 72 | // pressClient: popup grabs must come from it. | |
| 71 | 73 | press uint32 | |
| @@ -154,6 +156,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) { | |||
| 154 | 156 | s := &Server{display: d, awaiting: map[string][]*wayland.Callback{}, frameDue: map[string]time.Time{}, frameReady: make(chan struct{}, 1), reports: map[string]ports.OutputPresented{}, env: linuxProcEnv{}, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]clientBuffer), windows: make(map[ports.WindowID]*window), layers: make(map[ports.WindowID]*layerSurface), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), contentReady: make(chan struct{}, 1), cursorReady: make(chan struct{}, 1), dataSources: map[*server.Resource]*clipSource{}, primarySources: map[*server.Resource]*clipSource{}, controlSources: map[*server.Resource]*clipSource{}, contentNotify: make(chan struct{}), keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), regions: map[*server.Resource]*region{}, relatives: map[server.Client][]*relativepointer.ZwpRelativePointerV1{}, constraints: map[*surface]*constraint{}, positioners: map[*server.Resource]*positioner{}, repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay} | |
| 155 | 157 | s.fractions = map[*surface]*fractionalscale.WpFractionalScaleV1{} | |
| 156 | 158 | s.fifoSurfaces, s.lastFlip = map[*surface]struct{}{}, map[string]time.Time{} | |
| 159 | + | s.tokens = map[string]activationToken{} | |
| 157 | 160 | if opts.Keymap != "" { | |
| 158 | 161 | fd, size, e := keymapFile(opts.Keymap) | |
| 159 | 162 | if e != nil { | |
| @@ -62,10 +62,12 @@ type Server struct { | |||||
| 62 | // lastFlip is each output's latest page flip. | 62 | // lastFlip is each output's latest page flip. | ||
| 63 | fifoSurfaces map[*surface]struct{} | 63 | fifoSurfaces map[*surface]struct{} | ||
| 64 | lastFlip map[string]time.Time | 64 | lastFlip map[string]time.Time | ||
| 65 | - | surfaces map[*server.Resource]*surface | 65 | + | // tokens are the issued xdg-activation tokens (activation.go). |
| 66 | - | buffers map[*server.Resource]clientBuffer | 66 | + | tokens map[string]activationToken |
| 67 | - | dmabuf *dmabufGlobal | 67 | + | surfaces map[*server.Resource]*surface |
| 68 | - | serial uint32 | 68 | + | buffers map[*server.Resource]clientBuffer |
| 69 | + | dmabuf *dmabufGlobal | |||
| 70 | + | serial uint32 | |||
| 69 | // press is the serial of the last button or key press, sent to | 71 | // press is the serial of the last button or key press, sent to | ||
| 70 | // pressClient: popup grabs must come from it. | 72 | // pressClient: popup grabs must come from it. | ||
| 71 | press uint32 | 73 | press uint32 | ||
| @@ -154,6 +156,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) { | |||||
| 154 | s := &Server{display: d, awaiting: map[string][]*wayland.Callback{}, frameDue: map[string]time.Time{}, frameReady: make(chan struct{}, 1), reports: map[string]ports.OutputPresented{}, env: linuxProcEnv{}, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]clientBuffer), windows: make(map[ports.WindowID]*window), layers: make(map[ports.WindowID]*layerSurface), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), contentReady: make(chan struct{}, 1), cursorReady: make(chan struct{}, 1), dataSources: map[*server.Resource]*clipSource{}, primarySources: map[*server.Resource]*clipSource{}, controlSources: map[*server.Resource]*clipSource{}, contentNotify: make(chan struct{}), keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), regions: map[*server.Resource]*region{}, relatives: map[server.Client][]*relativepointer.ZwpRelativePointerV1{}, constraints: map[*surface]*constraint{}, positioners: map[*server.Resource]*positioner{}, repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay} | 156 | s := &Server{display: d, awaiting: map[string][]*wayland.Callback{}, frameDue: map[string]time.Time{}, frameReady: make(chan struct{}, 1), reports: map[string]ports.OutputPresented{}, env: linuxProcEnv{}, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]clientBuffer), windows: make(map[ports.WindowID]*window), layers: make(map[ports.WindowID]*layerSurface), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), contentReady: make(chan struct{}, 1), cursorReady: make(chan struct{}, 1), dataSources: map[*server.Resource]*clipSource{}, primarySources: map[*server.Resource]*clipSource{}, controlSources: map[*server.Resource]*clipSource{}, contentNotify: make(chan struct{}), keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), regions: map[*server.Resource]*region{}, relatives: map[server.Client][]*relativepointer.ZwpRelativePointerV1{}, constraints: map[*surface]*constraint{}, positioners: map[*server.Resource]*positioner{}, repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay} | ||
| 155 | s.fractions = map[*surface]*fractionalscale.WpFractionalScaleV1{} | 157 | s.fractions = map[*surface]*fractionalscale.WpFractionalScaleV1{} | ||
| 156 | s.fifoSurfaces, s.lastFlip = map[*surface]struct{}{}, map[string]time.Time{} | 158 | s.fifoSurfaces, s.lastFlip = map[*surface]struct{}{}, map[string]time.Time{} | ||
| 159 | + | s.tokens = map[string]activationToken{} | |||
| 157 | if opts.Keymap != "" { | 160 | if opts.Keymap != "" { | ||
| 158 | fd, size, e := keymapFile(opts.Keymap) | 161 | fd, size, e := keymapFile(opts.Keymap) | ||
| 159 | if e != nil { | 162 | if e != nil { | ||
M internal/core/core.go +9 -0
| @@ -618,6 +618,15 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 618 | 618 | if s, _ := c.screenOf(v.ID); s != nil { | |
| 619 | 619 | s.mon.SetFullscreen(v.ID, v.Fullscreen) | |
| 620 | 620 | } | |
| 621 | + | case ports.WindowActivate: | |
| 622 | + | // Wayland checked the token: bring the window forward. | |
| 623 | + | if s, w := c.screenOf(v.ID); s != nil && c.popups[v.ID] == nil { | |
| 624 | + | if w != s.mon.Current() { | |
| 625 | + | s.mon.show(w) | |
| 626 | + | } | |
| 627 | + | w.FocusID(v.ID) | |
| 628 | + | c.focusScreen = c.screenIndex(s.name()) | |
| 629 | + | } | |
| 621 | 630 | } | |
| 622 | 631 | if err := c.publish(ctx); err != nil { | |
| 623 | 632 | return nil | |
| @@ -618,6 +618,15 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 618 | if s, _ := c.screenOf(v.ID); s != nil { | 618 | if s, _ := c.screenOf(v.ID); s != nil { | ||
| 619 | s.mon.SetFullscreen(v.ID, v.Fullscreen) | 619 | s.mon.SetFullscreen(v.ID, v.Fullscreen) | ||
| 620 | } | 620 | } | ||
| 621 | + | case ports.WindowActivate: | |||
| 622 | + | // Wayland checked the token: bring the window forward. | |||
| 623 | + | if s, w := c.screenOf(v.ID); s != nil && c.popups[v.ID] == nil { | |||
| 624 | + | if w != s.mon.Current() { | |||
| 625 | + | s.mon.show(w) | |||
| 626 | + | } | |||
| 627 | + | w.FocusID(v.ID) | |||
| 628 | + | c.focusScreen = c.screenIndex(s.name()) | |||
| 629 | + | } | |||
| 621 | } | 630 | } | ||
| 622 | if err := c.publish(ctx); err != nil { | 631 | if err := c.publish(ctx); err != nil { | ||
| 623 | return nil | 632 | return nil | ||
M internal/core/core_test.go +46 -0
| @@ -722,3 +722,49 @@ func TestFloatOverFullscreenHit(t *testing.T) { | |||
| 722 | 722 | t.Fatalf("pointer went to %v, want the dialog", v) | |
| 723 | 723 | } | |
| 724 | 724 | } | |
| 725 | + | ||
| 726 | + | // An activated window on another workspace comes on screen with focus. | |
| 727 | + | func TestWindowActivateShowsAndFocuses(t *testing.T) { | |
| 728 | + | cfg := config.Defaults() | |
| 729 | + | client := make(chan ports.ClientEvent, 8) | |
| 730 | + | input := make(chan ports.InputEvent, 8) | |
| 731 | + | output := make(chan ports.OutputEvent, 8) | |
| 732 | + | commands := make(chan ports.ClientCommand, 64) | |
| 733 | + | scenes := make(chan []ports.Scene, 1) | |
| 734 | + | c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}) | |
| 735 | + | if err != nil { | |
| 736 | + | t.Fatal(err) | |
| 737 | + | } | |
| 738 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 739 | + | defer cancel() | |
| 740 | + | go c.Run(ctx) | |
| 741 | + | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}} | |
| 742 | + | scene(t, scenes) | |
| 743 | + | client <- ports.WindowMapped{ID: 1} | |
| 744 | + | scene(t, scenes) | |
| 745 | + | // Window 2 opens on workspace 2, then the user goes back to 1. | |
| 746 | + | input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModSuper, Pressed: true} | |
| 747 | + | scene(t, scenes) | |
| 748 | + | client <- ports.WindowMapped{ID: 2} | |
| 749 | + | scene(t, scenes) | |
| 750 | + | input <- ports.KeyEvent{Keysym: "1", Keycode: 2, Mods: ports.ModSuper, Pressed: true} | |
| 751 | + | visible := func(sc ports.Scene) (shown []ports.SceneWindow) { | |
| 752 | + | for _, w := range sc.Windows { | |
| 753 | + | if !w.Hidden { | |
| 754 | + | shown = append(shown, w) | |
| 755 | + | } | |
| 756 | + | } | |
| 757 | + | return shown | |
| 758 | + | } | |
| 759 | + | if v := visible(scene(t, scenes)); len(v) != 1 || v[0].ID != 1 { | |
| 760 | + | t.Fatalf("workspace 1: %+v", v) | |
| 761 | + | } | |
| 762 | + | client <- ports.WindowActivate{ID: 2} | |
| 763 | + | v := visible(scene(t, scenes)) | |
| 764 | + | for len(v) != 1 || v[0].ID != 2 { | |
| 765 | + | v = visible(scene(t, scenes)) | |
| 766 | + | } | |
| 767 | + | if !v[0].Focused { | |
| 768 | + | t.Fatalf("after activate: %+v", v) | |
| 769 | + | } | |
| 770 | + | } | |
| @@ -722,3 +722,49 @@ func TestFloatOverFullscreenHit(t *testing.T) { | |||||
| 722 | t.Fatalf("pointer went to %v, want the dialog", v) | 722 | t.Fatalf("pointer went to %v, want the dialog", v) | ||
| 723 | } | 723 | } | ||
| 724 | } | 724 | } | ||
| 725 | + | ||||
| 726 | + | // An activated window on another workspace comes on screen with focus. | |||
| 727 | + | func TestWindowActivateShowsAndFocuses(t *testing.T) { | |||
| 728 | + | cfg := config.Defaults() | |||
| 729 | + | client := make(chan ports.ClientEvent, 8) | |||
| 730 | + | input := make(chan ports.InputEvent, 8) | |||
| 731 | + | output := make(chan ports.OutputEvent, 8) | |||
| 732 | + | commands := make(chan ports.ClientCommand, 64) | |||
| 733 | + | scenes := make(chan []ports.Scene, 1) | |||
| 734 | + | c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Scenes: scenes}) | |||
| 735 | + | if err != nil { | |||
| 736 | + | t.Fatal(err) | |||
| 737 | + | } | |||
| 738 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 739 | + | defer cancel() | |||
| 740 | + | go c.Run(ctx) | |||
| 741 | + | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}} | |||
| 742 | + | scene(t, scenes) | |||
| 743 | + | client <- ports.WindowMapped{ID: 1} | |||
| 744 | + | scene(t, scenes) | |||
| 745 | + | // Window 2 opens on workspace 2, then the user goes back to 1. | |||
| 746 | + | input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModSuper, Pressed: true} | |||
| 747 | + | scene(t, scenes) | |||
| 748 | + | client <- ports.WindowMapped{ID: 2} | |||
| 749 | + | scene(t, scenes) | |||
| 750 | + | input <- ports.KeyEvent{Keysym: "1", Keycode: 2, Mods: ports.ModSuper, Pressed: true} | |||
| 751 | + | visible := func(sc ports.Scene) (shown []ports.SceneWindow) { | |||
| 752 | + | for _, w := range sc.Windows { | |||
| 753 | + | if !w.Hidden { | |||
| 754 | + | shown = append(shown, w) | |||
| 755 | + | } | |||
| 756 | + | } | |||
| 757 | + | return shown | |||
| 758 | + | } | |||
| 759 | + | if v := visible(scene(t, scenes)); len(v) != 1 || v[0].ID != 1 { | |||
| 760 | + | t.Fatalf("workspace 1: %+v", v) | |||
| 761 | + | } | |||
| 762 | + | client <- ports.WindowActivate{ID: 2} | |||
| 763 | + | v := visible(scene(t, scenes)) | |||
| 764 | + | for len(v) != 1 || v[0].ID != 2 { | |||
| 765 | + | v = visible(scene(t, scenes)) | |||
| 766 | + | } | |||
| 767 | + | if !v[0].Focused { | |||
| 768 | + | t.Fatalf("after activate: %+v", v) | |||
| 769 | + | } | |||
| 770 | + | } | |||
M internal/ports/messages.go +6 -0
| @@ -39,6 +39,12 @@ type WindowFullscreenRequest struct { | |||
| 39 | 39 | ||
| 40 | 40 | func (WindowFullscreenRequest) clientEvent() {} | |
| 41 | 41 | ||
| 42 | + | // WindowActivate carries wayland → core a valid xdg-activation request: | |
| 43 | + | // show the window's workspace and focus it. | |
| 44 | + | type WindowActivate struct{ ID WindowID } | |
| 45 | + | ||
| 46 | + | func (WindowActivate) clientEvent() {} | |
| 47 | + | ||
| 42 | 48 | // WindowAppID carries wayland → core an app ID set after the window mapped. | |
| 43 | 49 | type WindowAppID struct { | |
| 44 | 50 | ID WindowID | |
| @@ -39,6 +39,12 @@ type WindowFullscreenRequest struct { | |||||
| 39 | 39 | ||||
| 40 | func (WindowFullscreenRequest) clientEvent() {} | 40 | func (WindowFullscreenRequest) clientEvent() {} | ||
| 41 | 41 | ||||
| 42 | + | // WindowActivate carries wayland → core a valid xdg-activation request: | |||
| 43 | + | // show the window's workspace and focus it. | |||
| 44 | + | type WindowActivate struct{ ID WindowID } | |||
| 45 | + | ||||
| 46 | + | func (WindowActivate) clientEvent() {} | |||
| 47 | + | ||||
| 42 | // WindowAppID carries wayland → core an app ID set after the window mapped. | 48 | // WindowAppID carries wayland → core an app ID set after the window mapped. | ||
| 43 | type WindowAppID struct { | 49 | type WindowAppID struct { | ||
| 44 | ID WindowID | 50 | ID WindowID | ||