merge: state-file (session state file, nefertty state output-of)
13 files · +716 -14
M .mockery.yml +7 -0
| @@ -30,3 +30,10 @@ packages: | |||
| 30 | 30 | dir: internal/adapters/wayland | |
| 31 | 31 | pkgname: wayland | |
| 32 | 32 | filename: proc_env_mock_test.go | |
| 33 | + | github.com/bnema/nefertty/internal/adapters/statefile: | |
| 34 | + | interfaces: | |
| 35 | + | procTree: | |
| 36 | + | config: | |
| 37 | + | dir: internal/adapters/statefile | |
| 38 | + | pkgname: statefile | |
| 39 | + | filename: proc_tree_mock_test.go | |
| @@ -30,3 +30,10 @@ packages: | |||||
| 30 | dir: internal/adapters/wayland | 30 | dir: internal/adapters/wayland | ||
| 31 | pkgname: wayland | 31 | pkgname: wayland | ||
| 32 | filename: proc_env_mock_test.go | 32 | filename: proc_env_mock_test.go | ||
| 33 | + | github.com/bnema/nefertty/internal/adapters/statefile: | |||
| 34 | + | interfaces: | |||
| 35 | + | procTree: | |||
| 36 | + | config: | |||
| 37 | + | dir: internal/adapters/statefile | |||
| 38 | + | pkgname: statefile | |||
| 39 | + | filename: proc_tree_mock_test.go | |||
M README.md +11 -0
| @@ -25,6 +25,17 @@ See `examples/config` for every key and its default. A missing file means defaul | |||
| 25 | 25 | ||
| 26 | 26 | Building requires sibling checkouts of purego-libwayland and purego-vulkan until they are published. | |
| 27 | 27 | ||
| 28 | + | ## State for scripts | |
| 29 | + | ||
| 30 | + | While it runs, NeferTTY keeps its state in `$XDG_RUNTIME_DIR/nefertty/<wayland socket>.json` and passes that path to the programs it starts as `NEFERTTY_STATE`. The file lists every output (active numbered workspace, workspace count, name of the workspace on screen), the focused output and window, and every window with its app ID, PID, output and workspace. | |
| 31 | + | ||
| 32 | + | ```sh | |
| 33 | + | nefertty state # the whole state as JSON | |
| 34 | + | nefertty state output-of "$PID" # the output of that process's window, or its nearest parent's | |
| 35 | + | ``` | |
| 36 | + | ||
| 37 | + | `output-of` finds application windows only; bars have no single output. | |
| 38 | + | ||
| 28 | 39 | ## Try it (headless) | |
| 29 | 40 | ||
| 30 | 41 | Run `nefertty --backend=headless --screenshot /tmp/nefertty-shots`, then connect with | |
| @@ -25,6 +25,17 @@ See `examples/config` for every key and its default. A missing file means defaul | |||||
| 25 | 25 | ||||
| 26 | Building requires sibling checkouts of purego-libwayland and purego-vulkan until they are published. | 26 | Building requires sibling checkouts of purego-libwayland and purego-vulkan until they are published. | ||
| 27 | 27 | ||||
| 28 | + | ## State for scripts | |||
| 29 | + | ||||
| 30 | + | While it runs, NeferTTY keeps its state in `$XDG_RUNTIME_DIR/nefertty/<wayland socket>.json` and passes that path to the programs it starts as `NEFERTTY_STATE`. The file lists every output (active numbered workspace, workspace count, name of the workspace on screen), the focused output and window, and every window with its app ID, PID, output and workspace. | |||
| 31 | + | ||||
| 32 | + | ```sh | |||
| 33 | + | nefertty state # the whole state as JSON | |||
| 34 | + | nefertty state output-of "$PID" # the output of that process's window, or its nearest parent's | |||
| 35 | + | ``` | |||
| 36 | + | ||||
| 37 | + | `output-of` finds application windows only; bars have no single output. | |||
| 38 | + | ||||
| 28 | ## Try it (headless) | 39 | ## Try it (headless) | ||
| 29 | 40 | ||||
| 30 | Run `nefertty --backend=headless --screenshot /tmp/nefertty-shots`, then connect with | 41 | Run `nefertty --backend=headless --screenshot /tmp/nefertty-shots`, then connect with | ||
M cmd/nefertty/main.go +56 -0
| @@ -2,17 +2,20 @@ package main | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "encoding/json" | |
| 5 | 6 | "errors" | |
| 6 | 7 | "flag" | |
| 7 | 8 | "fmt" | |
| 8 | 9 | "os" | |
| 9 | 10 | "os/signal" | |
| 10 | 11 | "runtime/debug" | |
| 12 | + | "strconv" | |
| 11 | 13 | "strings" | |
| 12 | 14 | "syscall" | |
| 13 | 15 | "time" | |
| 14 | 16 | ||
| 15 | 17 | "github.com/bnema/nefertty/internal/adapters/config" | |
| 18 | + | "github.com/bnema/nefertty/internal/adapters/statefile" | |
| 16 | 19 | "github.com/bnema/nefertty/internal/app" | |
| 17 | 20 | "github.com/bnema/nefertty/internal/logging" | |
| 18 | 21 | "github.com/bnema/nefertty/internal/ports" | |
| @@ -70,6 +73,9 @@ func run() error { | |||
| 70 | 73 | fmt.Println("ok: " + path) | |
| 71 | 74 | return nil | |
| 72 | 75 | } | |
| 76 | + | if len(os.Args) > 1 && os.Args[1] == "state" { | |
| 77 | + | return runState(os.Args[2:]) | |
| 78 | + | } | |
| 73 | 79 | if len(os.Args) == 2 && os.Args[1] == "version" { | |
| 74 | 80 | info, ok := debug.ReadBuildInfo() | |
| 75 | 81 | if !ok { | |
| @@ -214,3 +220,53 @@ func run() error { | |||
| 214 | 220 | log.Info().Str("reason", reason).AnErr("error", err).Dur("uptime", time.Since(start)).Msg("exit") | |
| 215 | 221 | return err | |
| 216 | 222 | } | |
| 223 | + | ||
| 224 | + | // runState prints the session state for scripts: | |
| 225 | + | // | |
| 226 | + | // nefertty state the whole state | |
| 227 | + | // nefertty state output-of <pid> the output showing that process's window | |
| 228 | + | // | |
| 229 | + | // The file is $NEFERTTY_STATE, else the one of $WAYLAND_DISPLAY. | |
| 230 | + | func runState(args []string) error { | |
| 231 | + | usage := usageError{fmt.Errorf("usage: state [output-of <pid>]")} | |
| 232 | + | pid := 0 | |
| 233 | + | switch { | |
| 234 | + | case len(args) == 0: | |
| 235 | + | case len(args) == 2 && args[0] == "output-of": | |
| 236 | + | n, err := strconv.Atoi(args[1]) | |
| 237 | + | if err != nil || n <= 0 { | |
| 238 | + | fmt.Fprintln(os.Stderr, usage) | |
| 239 | + | return usage | |
| 240 | + | } | |
| 241 | + | pid = n | |
| 242 | + | default: | |
| 243 | + | fmt.Fprintln(os.Stderr, usage) | |
| 244 | + | return usage | |
| 245 | + | } | |
| 246 | + | path := os.Getenv(statefile.Env) | |
| 247 | + | if path == "" { | |
| 248 | + | var err error | |
| 249 | + | if path, err = statefile.Path(os.Getenv("XDG_RUNTIME_DIR"), os.Getenv("WAYLAND_DISPLAY")); err != nil { | |
| 250 | + | fmt.Fprintln(os.Stderr, err) | |
| 251 | + | return err | |
| 252 | + | } | |
| 253 | + | } | |
| 254 | + | st, err := statefile.Read(path) | |
| 255 | + | if err != nil { | |
| 256 | + | fmt.Fprintln(os.Stderr, err) | |
| 257 | + | return err | |
| 258 | + | } | |
| 259 | + | var out any = st | |
| 260 | + | if pid != 0 { | |
| 261 | + | if out, err = statefile.OutputOf(st, pid); err != nil { | |
| 262 | + | fmt.Fprintln(os.Stderr, err) | |
| 263 | + | return err | |
| 264 | + | } | |
| 265 | + | } | |
| 266 | + | data, err := json.Marshal(out) | |
| 267 | + | if err != nil { | |
| 268 | + | return err | |
| 269 | + | } | |
| 270 | + | fmt.Println(string(data)) | |
| 271 | + | return nil | |
| 272 | + | } | |
| @@ -2,17 +2,20 @@ package main | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "encoding/json" | |||
| 5 | "errors" | 6 | "errors" | ||
| 6 | "flag" | 7 | "flag" | ||
| 7 | "fmt" | 8 | "fmt" | ||
| 8 | "os" | 9 | "os" | ||
| 9 | "os/signal" | 10 | "os/signal" | ||
| 10 | "runtime/debug" | 11 | "runtime/debug" | ||
| 12 | + | "strconv" | |||
| 11 | "strings" | 13 | "strings" | ||
| 12 | "syscall" | 14 | "syscall" | ||
| 13 | "time" | 15 | "time" | ||
| 14 | 16 | ||||
| 15 | "github.com/bnema/nefertty/internal/adapters/config" | 17 | "github.com/bnema/nefertty/internal/adapters/config" | ||
| 18 | + | "github.com/bnema/nefertty/internal/adapters/statefile" | |||
| 16 | "github.com/bnema/nefertty/internal/app" | 19 | "github.com/bnema/nefertty/internal/app" | ||
| 17 | "github.com/bnema/nefertty/internal/logging" | 20 | "github.com/bnema/nefertty/internal/logging" | ||
| 18 | "github.com/bnema/nefertty/internal/ports" | 21 | "github.com/bnema/nefertty/internal/ports" | ||
| @@ -70,6 +73,9 @@ func run() error { | |||||
| 70 | fmt.Println("ok: " + path) | 73 | fmt.Println("ok: " + path) | ||
| 71 | return nil | 74 | return nil | ||
| 72 | } | 75 | } | ||
| 76 | + | if len(os.Args) > 1 && os.Args[1] == "state" { | |||
| 77 | + | return runState(os.Args[2:]) | |||
| 78 | + | } | |||
| 73 | if len(os.Args) == 2 && os.Args[1] == "version" { | 79 | if len(os.Args) == 2 && os.Args[1] == "version" { | ||
| 74 | info, ok := debug.ReadBuildInfo() | 80 | info, ok := debug.ReadBuildInfo() | ||
| 75 | if !ok { | 81 | if !ok { | ||
| @@ -214,3 +220,53 @@ func run() error { | |||||
| 214 | log.Info().Str("reason", reason).AnErr("error", err).Dur("uptime", time.Since(start)).Msg("exit") | 220 | log.Info().Str("reason", reason).AnErr("error", err).Dur("uptime", time.Since(start)).Msg("exit") | ||
| 215 | return err | 221 | return err | ||
| 216 | } | 222 | } | ||
| 223 | + | ||||
| 224 | + | // runState prints the session state for scripts: | |||
| 225 | + | // | |||
| 226 | + | // nefertty state the whole state | |||
| 227 | + | // nefertty state output-of <pid> the output showing that process's window | |||
| 228 | + | // | |||
| 229 | + | // The file is $NEFERTTY_STATE, else the one of $WAYLAND_DISPLAY. | |||
| 230 | + | func runState(args []string) error { | |||
| 231 | + | usage := usageError{fmt.Errorf("usage: state [output-of <pid>]")} | |||
| 232 | + | pid := 0 | |||
| 233 | + | switch { | |||
| 234 | + | case len(args) == 0: | |||
| 235 | + | case len(args) == 2 && args[0] == "output-of": | |||
| 236 | + | n, err := strconv.Atoi(args[1]) | |||
| 237 | + | if err != nil || n <= 0 { | |||
| 238 | + | fmt.Fprintln(os.Stderr, usage) | |||
| 239 | + | return usage | |||
| 240 | + | } | |||
| 241 | + | pid = n | |||
| 242 | + | default: | |||
| 243 | + | fmt.Fprintln(os.Stderr, usage) | |||
| 244 | + | return usage | |||
| 245 | + | } | |||
| 246 | + | path := os.Getenv(statefile.Env) | |||
| 247 | + | if path == "" { | |||
| 248 | + | var err error | |||
| 249 | + | if path, err = statefile.Path(os.Getenv("XDG_RUNTIME_DIR"), os.Getenv("WAYLAND_DISPLAY")); err != nil { | |||
| 250 | + | fmt.Fprintln(os.Stderr, err) | |||
| 251 | + | return err | |||
| 252 | + | } | |||
| 253 | + | } | |||
| 254 | + | st, err := statefile.Read(path) | |||
| 255 | + | if err != nil { | |||
| 256 | + | fmt.Fprintln(os.Stderr, err) | |||
| 257 | + | return err | |||
| 258 | + | } | |||
| 259 | + | var out any = st | |||
| 260 | + | if pid != 0 { | |||
| 261 | + | if out, err = statefile.OutputOf(st, pid); err != nil { | |||
| 262 | + | fmt.Fprintln(os.Stderr, err) | |||
| 263 | + | return err | |||
| 264 | + | } | |||
| 265 | + | } | |||
| 266 | + | data, err := json.Marshal(out) | |||
| 267 | + | if err != nil { | |||
| 268 | + | return err | |||
| 269 | + | } | |||
| 270 | + | fmt.Println(string(data)) | |||
| 271 | + | return nil | |||
| 272 | + | } | |||
A internal/adapters/statefile/proc_tree_mock_test.go +96 -0
| @@ -0,0 +1,96 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package statefile | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | mock "github.com/stretchr/testify/mock" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // newMockprocTree creates a new instance of mockprocTree. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 12 | + | // The first argument is typically a *testing.T value. | |
| 13 | + | func newMockprocTree(t interface { | |
| 14 | + | mock.TestingT | |
| 15 | + | Cleanup(func()) | |
| 16 | + | }) *mockprocTree { | |
| 17 | + | mock := &mockprocTree{} | |
| 18 | + | mock.Mock.Test(t) | |
| 19 | + | ||
| 20 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 21 | + | ||
| 22 | + | return mock | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // mockprocTree is an autogenerated mock type for the procTree type | |
| 26 | + | type mockprocTree struct { | |
| 27 | + | mock.Mock | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | type mockprocTree_Expecter struct { | |
| 31 | + | mock *mock.Mock | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func (_m *mockprocTree) EXPECT() *mockprocTree_Expecter { | |
| 35 | + | return &mockprocTree_Expecter{mock: &_m.Mock} | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // Parent provides a mock function for the type mockprocTree | |
| 39 | + | func (_mock *mockprocTree) Parent(pid int) (int, bool) { | |
| 40 | + | ret := _mock.Called(pid) | |
| 41 | + | ||
| 42 | + | if len(ret) == 0 { | |
| 43 | + | panic("no return value specified for Parent") | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | var r0 int | |
| 47 | + | var r1 bool | |
| 48 | + | if returnFunc, ok := ret.Get(0).(func(int) (int, bool)); ok { | |
| 49 | + | return returnFunc(pid) | |
| 50 | + | } | |
| 51 | + | if returnFunc, ok := ret.Get(0).(func(int) int); ok { | |
| 52 | + | r0 = returnFunc(pid) | |
| 53 | + | } else { | |
| 54 | + | r0 = ret.Get(0).(int) | |
| 55 | + | } | |
| 56 | + | if returnFunc, ok := ret.Get(1).(func(int) bool); ok { | |
| 57 | + | r1 = returnFunc(pid) | |
| 58 | + | } else { | |
| 59 | + | r1 = ret.Get(1).(bool) | |
| 60 | + | } | |
| 61 | + | return r0, r1 | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | // mockprocTree_Parent_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Parent' | |
| 65 | + | type mockprocTree_Parent_Call struct { | |
| 66 | + | *mock.Call | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | // Parent is a helper method to define mock.On call | |
| 70 | + | // - pid int | |
| 71 | + | func (_e *mockprocTree_Expecter) Parent(pid any) *mockprocTree_Parent_Call { | |
| 72 | + | return &mockprocTree_Parent_Call{Call: _e.mock.On("Parent", pid)} | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func (_c *mockprocTree_Parent_Call) Run(run func(pid int)) *mockprocTree_Parent_Call { | |
| 76 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 77 | + | var arg0 int | |
| 78 | + | if args[0] != nil { | |
| 79 | + | arg0 = args[0].(int) | |
| 80 | + | } | |
| 81 | + | run( | |
| 82 | + | arg0, | |
| 83 | + | ) | |
| 84 | + | }) | |
| 85 | + | return _c | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func (_c *mockprocTree_Parent_Call) Return(n int, b bool) *mockprocTree_Parent_Call { | |
| 89 | + | _c.Call.Return(n, b) | |
| 90 | + | return _c | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func (_c *mockprocTree_Parent_Call) RunAndReturn(run func(pid int) (int, bool)) *mockprocTree_Parent_Call { | |
| 94 | + | _c.Call.Return(run) | |
| 95 | + | return _c | |
| 96 | + | } | |
| @@ -0,0 +1,96 @@ | |||||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | + | // github.com/vektra/mockery | |||
| 3 | + | // template: testify | |||
| 4 | + | ||||
| 5 | + | package statefile | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | mock "github.com/stretchr/testify/mock" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // newMockprocTree creates a new instance of mockprocTree. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 12 | + | // The first argument is typically a *testing.T value. | |||
| 13 | + | func newMockprocTree(t interface { | |||
| 14 | + | mock.TestingT | |||
| 15 | + | Cleanup(func()) | |||
| 16 | + | }) *mockprocTree { | |||
| 17 | + | mock := &mockprocTree{} | |||
| 18 | + | mock.Mock.Test(t) | |||
| 19 | + | ||||
| 20 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |||
| 21 | + | ||||
| 22 | + | return mock | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | // mockprocTree is an autogenerated mock type for the procTree type | |||
| 26 | + | type mockprocTree struct { | |||
| 27 | + | mock.Mock | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | type mockprocTree_Expecter struct { | |||
| 31 | + | mock *mock.Mock | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func (_m *mockprocTree) EXPECT() *mockprocTree_Expecter { | |||
| 35 | + | return &mockprocTree_Expecter{mock: &_m.Mock} | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // Parent provides a mock function for the type mockprocTree | |||
| 39 | + | func (_mock *mockprocTree) Parent(pid int) (int, bool) { | |||
| 40 | + | ret := _mock.Called(pid) | |||
| 41 | + | ||||
| 42 | + | if len(ret) == 0 { | |||
| 43 | + | panic("no return value specified for Parent") | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | var r0 int | |||
| 47 | + | var r1 bool | |||
| 48 | + | if returnFunc, ok := ret.Get(0).(func(int) (int, bool)); ok { | |||
| 49 | + | return returnFunc(pid) | |||
| 50 | + | } | |||
| 51 | + | if returnFunc, ok := ret.Get(0).(func(int) int); ok { | |||
| 52 | + | r0 = returnFunc(pid) | |||
| 53 | + | } else { | |||
| 54 | + | r0 = ret.Get(0).(int) | |||
| 55 | + | } | |||
| 56 | + | if returnFunc, ok := ret.Get(1).(func(int) bool); ok { | |||
| 57 | + | r1 = returnFunc(pid) | |||
| 58 | + | } else { | |||
| 59 | + | r1 = ret.Get(1).(bool) | |||
| 60 | + | } | |||
| 61 | + | return r0, r1 | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | // mockprocTree_Parent_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Parent' | |||
| 65 | + | type mockprocTree_Parent_Call struct { | |||
| 66 | + | *mock.Call | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | // Parent is a helper method to define mock.On call | |||
| 70 | + | // - pid int | |||
| 71 | + | func (_e *mockprocTree_Expecter) Parent(pid any) *mockprocTree_Parent_Call { | |||
| 72 | + | return &mockprocTree_Parent_Call{Call: _e.mock.On("Parent", pid)} | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | func (_c *mockprocTree_Parent_Call) Run(run func(pid int)) *mockprocTree_Parent_Call { | |||
| 76 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 77 | + | var arg0 int | |||
| 78 | + | if args[0] != nil { | |||
| 79 | + | arg0 = args[0].(int) | |||
| 80 | + | } | |||
| 81 | + | run( | |||
| 82 | + | arg0, | |||
| 83 | + | ) | |||
| 84 | + | }) | |||
| 85 | + | return _c | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | func (_c *mockprocTree_Parent_Call) Return(n int, b bool) *mockprocTree_Parent_Call { | |||
| 89 | + | _c.Call.Return(n, b) | |||
| 90 | + | return _c | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | func (_c *mockprocTree_Parent_Call) RunAndReturn(run func(pid int) (int, bool)) *mockprocTree_Parent_Call { | |||
| 94 | + | _c.Call.Return(run) | |||
| 95 | + | return _c | |||
| 96 | + | } | |||
A internal/adapters/statefile/statefile.go +206 -0
| @@ -0,0 +1,206 @@ | |||
| 1 | + | // Package statefile publishes core's state as JSON for scripts, and reads it | |
| 2 | + | // back for the `nefertty state` command. The JSON format is defined here, | |
| 3 | + | // not in ports: it is a public interface scripts depend on. | |
| 4 | + | package statefile | |
| 5 | + | ||
| 6 | + | import ( | |
| 7 | + | "context" | |
| 8 | + | "encoding/json" | |
| 9 | + | "errors" | |
| 10 | + | "fmt" | |
| 11 | + | "os" | |
| 12 | + | "path/filepath" | |
| 13 | + | "strconv" | |
| 14 | + | "strings" | |
| 15 | + | ||
| 16 | + | "github.com/bnema/nefertty/internal/ports" | |
| 17 | + | "github.com/bnema/zerowrap" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | // Env names the state file in the environment of processes nefertty starts. | |
| 21 | + | const Env = "NEFERTTY_STATE" | |
| 22 | + | ||
| 23 | + | // Path is the state file of the session on a wayland socket: one per | |
| 24 | + | // session, so nested or test instances never overwrite each other. | |
| 25 | + | func Path(runtimeDir, socket string) (string, error) { | |
| 26 | + | socket = filepath.Base(socket) | |
| 27 | + | if runtimeDir == "" || socket == "" || socket == "." || socket == "/" { | |
| 28 | + | return "", errors.New("XDG_RUNTIME_DIR and WAYLAND_DISPLAY must be set") | |
| 29 | + | } | |
| 30 | + | return filepath.Join(runtimeDir, "nefertty", socket+".json"), nil | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | // State is the file format. | |
| 34 | + | type State struct { | |
| 35 | + | Output string `json:"output"` | |
| 36 | + | Outputs []Output `json:"outputs"` | |
| 37 | + | Window *Window `json:"window"` | |
| 38 | + | Windows []Window `json:"windows"` | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // Output is one output: its active numbered workspace (0 while a hidden one | |
| 42 | + | // is shown), how many numbered workspaces it has, and the name of the | |
| 43 | + | // workspace on screen. | |
| 44 | + | type Output struct { | |
| 45 | + | Name string `json:"name"` | |
| 46 | + | Active int `json:"active"` | |
| 47 | + | Count int `json:"count"` | |
| 48 | + | Workspace string `json:"workspace"` | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // Window is one mapped window. | |
| 52 | + | type Window struct { | |
| 53 | + | ID uint64 `json:"id"` | |
| 54 | + | AppID string `json:"app_id"` | |
| 55 | + | PID int `json:"pid"` | |
| 56 | + | Output string `json:"output"` | |
| 57 | + | Workspace int `json:"workspace"` | |
| 58 | + | Visible bool `json:"visible"` | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func fromPorts(st ports.State) State { | |
| 62 | + | out := State{Output: st.Output, Outputs: make([]Output, 0, len(st.Outputs)), Windows: make([]Window, 0, len(st.Windows))} | |
| 63 | + | for _, o := range st.Outputs { | |
| 64 | + | out.Outputs = append(out.Outputs, Output{Name: o.Name, Active: o.Active, Count: o.Count, Workspace: o.Workspace}) | |
| 65 | + | } | |
| 66 | + | for _, w := range st.Windows { | |
| 67 | + | out.Windows = append(out.Windows, window(w)) | |
| 68 | + | } | |
| 69 | + | if st.Window != nil { | |
| 70 | + | w := window(*st.Window) | |
| 71 | + | out.Window = &w | |
| 72 | + | } | |
| 73 | + | return out | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func window(w ports.WindowState) Window { | |
| 77 | + | return Window{ID: uint64(w.ID), AppID: w.AppID, PID: w.PID, Output: w.Output, Workspace: w.Workspace, Visible: w.Visible} | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | // Run writes every state it receives to path until ctx ends, then removes | |
| 81 | + | // the file: a stale state must not outlive the session. | |
| 82 | + | func Run(ctx context.Context, path string, states <-chan ports.State, log zerowrap.Logger) error { | |
| 83 | + | if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { | |
| 84 | + | return err | |
| 85 | + | } | |
| 86 | + | // A file left by a crashed session on this socket is not ours. | |
| 87 | + | _ = os.Remove(path) | |
| 88 | + | defer os.Remove(path) | |
| 89 | + | for { | |
| 90 | + | select { | |
| 91 | + | case <-ctx.Done(): | |
| 92 | + | return nil | |
| 93 | + | case st := <-states: | |
| 94 | + | if err := write(path, fromPorts(st)); err != nil { | |
| 95 | + | // Scripts lose their state; the session goes on. | |
| 96 | + | log.Warn().Err(err).Str("path", path).Msg("write state") | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | // write replaces the file atomically: readers never see half a state. | |
| 103 | + | func write(path string, st State) error { | |
| 104 | + | data, err := json.Marshal(st) | |
| 105 | + | if err != nil { | |
| 106 | + | return err | |
| 107 | + | } | |
| 108 | + | f, err := os.CreateTemp(filepath.Dir(path), ".state-*.json") | |
| 109 | + | if err != nil { | |
| 110 | + | return err | |
| 111 | + | } | |
| 112 | + | _, werr := f.Write(append(data, '\n')) | |
| 113 | + | cerr := f.Close() | |
| 114 | + | if err := errors.Join(werr, cerr); err != nil { | |
| 115 | + | os.Remove(f.Name()) | |
| 116 | + | return err | |
| 117 | + | } | |
| 118 | + | return os.Rename(f.Name(), path) | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | // Read loads the state file. | |
| 122 | + | func Read(path string) (State, error) { | |
| 123 | + | var st State | |
| 124 | + | data, err := os.ReadFile(path) | |
| 125 | + | if err != nil { | |
| 126 | + | return st, err | |
| 127 | + | } | |
| 128 | + | return st, json.Unmarshal(data, &st) | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | // procTree finds the parent of a process. | |
| 132 | + | type procTree interface { | |
| 133 | + | Parent(pid int) (int, bool) | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | // linuxProcTree reads /proc/<pid>/stat. | |
| 137 | + | type linuxProcTree struct{} | |
| 138 | + | ||
| 139 | + | func (linuxProcTree) Parent(pid int) (int, bool) { | |
| 140 | + | data, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat") | |
| 141 | + | if err != nil { | |
| 142 | + | return 0, false | |
| 143 | + | } | |
| 144 | + | // "pid (comm) state ppid ...": comm may hold spaces and parentheses. | |
| 145 | + | i := strings.LastIndexByte(string(data), ')') | |
| 146 | + | if i < 0 { | |
| 147 | + | return 0, false | |
| 148 | + | } | |
| 149 | + | f := strings.Fields(string(data[i+1:])) | |
| 150 | + | if len(f) < 2 { | |
| 151 | + | return 0, false | |
| 152 | + | } | |
| 153 | + | ppid, err := strconv.Atoi(f[1]) | |
| 154 | + | return ppid, err == nil && ppid > 0 | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | // ErrNoWindow means no window belongs to the process or its ancestors. | |
| 158 | + | var ErrNoWindow = errors.New("no window for this process") | |
| 159 | + | ||
| 160 | + | // OutputOf finds the output showing a process: the window of the process | |
| 161 | + | // itself or of its nearest ancestor, e.g. the terminal of a shell. Only | |
| 162 | + | // application windows count: bars (layer surfaces) have no single output. | |
| 163 | + | // A process with windows on several outputs resolves to its focused window, | |
| 164 | + | // else a visible one. PIDs are those of the compositor's PID namespace. | |
| 165 | + | func OutputOf(st State, pid int) (Output, error) { | |
| 166 | + | return outputOf(st, pid, linuxProcTree{}) | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | func outputOf(st State, pid int, tree procTree) (Output, error) { | |
| 170 | + | byPID := map[int]Window{} | |
| 171 | + | for _, w := range st.Windows { | |
| 172 | + | if w.PID <= 0 { | |
| 173 | + | continue | |
| 174 | + | } | |
| 175 | + | cur, seen := byPID[w.PID] | |
| 176 | + | if !seen || better(st, w, cur) { | |
| 177 | + | byPID[w.PID] = w | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | // A depth limit guards against a cycle from a reused PID mid-walk. | |
| 181 | + | for range 64 { | |
| 182 | + | if w, ok := byPID[pid]; ok { | |
| 183 | + | for _, o := range st.Outputs { | |
| 184 | + | if o.Name == w.Output { | |
| 185 | + | return o, nil | |
| 186 | + | } | |
| 187 | + | } | |
| 188 | + | return Output{}, fmt.Errorf("output %q not in state", w.Output) | |
| 189 | + | } | |
| 190 | + | parent, ok := tree.Parent(pid) | |
| 191 | + | if !ok || parent == pid { | |
| 192 | + | break | |
| 193 | + | } | |
| 194 | + | pid = parent | |
| 195 | + | } | |
| 196 | + | return Output{}, ErrNoWindow | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | // better prefers the focused window, then a visible one. | |
| 200 | + | func better(st State, a, b Window) bool { | |
| 201 | + | focused := func(w Window) bool { return st.Window != nil && st.Window.ID == w.ID } | |
| 202 | + | if focused(a) != focused(b) { | |
| 203 | + | return focused(a) | |
| 204 | + | } | |
| 205 | + | return a.Visible && !b.Visible | |
| 206 | + | } | |
| @@ -0,0 +1,206 @@ | |||||
| 1 | + | // Package statefile publishes core's state as JSON for scripts, and reads it | |||
| 2 | + | // back for the `nefertty state` command. The JSON format is defined here, | |||
| 3 | + | // not in ports: it is a public interface scripts depend on. | |||
| 4 | + | package statefile | |||
| 5 | + | ||||
| 6 | + | import ( | |||
| 7 | + | "context" | |||
| 8 | + | "encoding/json" | |||
| 9 | + | "errors" | |||
| 10 | + | "fmt" | |||
| 11 | + | "os" | |||
| 12 | + | "path/filepath" | |||
| 13 | + | "strconv" | |||
| 14 | + | "strings" | |||
| 15 | + | ||||
| 16 | + | "github.com/bnema/nefertty/internal/ports" | |||
| 17 | + | "github.com/bnema/zerowrap" | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | // Env names the state file in the environment of processes nefertty starts. | |||
| 21 | + | const Env = "NEFERTTY_STATE" | |||
| 22 | + | ||||
| 23 | + | // Path is the state file of the session on a wayland socket: one per | |||
| 24 | + | // session, so nested or test instances never overwrite each other. | |||
| 25 | + | func Path(runtimeDir, socket string) (string, error) { | |||
| 26 | + | socket = filepath.Base(socket) | |||
| 27 | + | if runtimeDir == "" || socket == "" || socket == "." || socket == "/" { | |||
| 28 | + | return "", errors.New("XDG_RUNTIME_DIR and WAYLAND_DISPLAY must be set") | |||
| 29 | + | } | |||
| 30 | + | return filepath.Join(runtimeDir, "nefertty", socket+".json"), nil | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | // State is the file format. | |||
| 34 | + | type State struct { | |||
| 35 | + | Output string `json:"output"` | |||
| 36 | + | Outputs []Output `json:"outputs"` | |||
| 37 | + | Window *Window `json:"window"` | |||
| 38 | + | Windows []Window `json:"windows"` | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | // Output is one output: its active numbered workspace (0 while a hidden one | |||
| 42 | + | // is shown), how many numbered workspaces it has, and the name of the | |||
| 43 | + | // workspace on screen. | |||
| 44 | + | type Output struct { | |||
| 45 | + | Name string `json:"name"` | |||
| 46 | + | Active int `json:"active"` | |||
| 47 | + | Count int `json:"count"` | |||
| 48 | + | Workspace string `json:"workspace"` | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // Window is one mapped window. | |||
| 52 | + | type Window struct { | |||
| 53 | + | ID uint64 `json:"id"` | |||
| 54 | + | AppID string `json:"app_id"` | |||
| 55 | + | PID int `json:"pid"` | |||
| 56 | + | Output string `json:"output"` | |||
| 57 | + | Workspace int `json:"workspace"` | |||
| 58 | + | Visible bool `json:"visible"` | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func fromPorts(st ports.State) State { | |||
| 62 | + | out := State{Output: st.Output, Outputs: make([]Output, 0, len(st.Outputs)), Windows: make([]Window, 0, len(st.Windows))} | |||
| 63 | + | for _, o := range st.Outputs { | |||
| 64 | + | out.Outputs = append(out.Outputs, Output{Name: o.Name, Active: o.Active, Count: o.Count, Workspace: o.Workspace}) | |||
| 65 | + | } | |||
| 66 | + | for _, w := range st.Windows { | |||
| 67 | + | out.Windows = append(out.Windows, window(w)) | |||
| 68 | + | } | |||
| 69 | + | if st.Window != nil { | |||
| 70 | + | w := window(*st.Window) | |||
| 71 | + | out.Window = &w | |||
| 72 | + | } | |||
| 73 | + | return out | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | func window(w ports.WindowState) Window { | |||
| 77 | + | return Window{ID: uint64(w.ID), AppID: w.AppID, PID: w.PID, Output: w.Output, Workspace: w.Workspace, Visible: w.Visible} | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | // Run writes every state it receives to path until ctx ends, then removes | |||
| 81 | + | // the file: a stale state must not outlive the session. | |||
| 82 | + | func Run(ctx context.Context, path string, states <-chan ports.State, log zerowrap.Logger) error { | |||
| 83 | + | if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { | |||
| 84 | + | return err | |||
| 85 | + | } | |||
| 86 | + | // A file left by a crashed session on this socket is not ours. | |||
| 87 | + | _ = os.Remove(path) | |||
| 88 | + | defer os.Remove(path) | |||
| 89 | + | for { | |||
| 90 | + | select { | |||
| 91 | + | case <-ctx.Done(): | |||
| 92 | + | return nil | |||
| 93 | + | case st := <-states: | |||
| 94 | + | if err := write(path, fromPorts(st)); err != nil { | |||
| 95 | + | // Scripts lose their state; the session goes on. | |||
| 96 | + | log.Warn().Err(err).Str("path", path).Msg("write state") | |||
| 97 | + | } | |||
| 98 | + | } | |||
| 99 | + | } | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | // write replaces the file atomically: readers never see half a state. | |||
| 103 | + | func write(path string, st State) error { | |||
| 104 | + | data, err := json.Marshal(st) | |||
| 105 | + | if err != nil { | |||
| 106 | + | return err | |||
| 107 | + | } | |||
| 108 | + | f, err := os.CreateTemp(filepath.Dir(path), ".state-*.json") | |||
| 109 | + | if err != nil { | |||
| 110 | + | return err | |||
| 111 | + | } | |||
| 112 | + | _, werr := f.Write(append(data, '\n')) | |||
| 113 | + | cerr := f.Close() | |||
| 114 | + | if err := errors.Join(werr, cerr); err != nil { | |||
| 115 | + | os.Remove(f.Name()) | |||
| 116 | + | return err | |||
| 117 | + | } | |||
| 118 | + | return os.Rename(f.Name(), path) | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | // Read loads the state file. | |||
| 122 | + | func Read(path string) (State, error) { | |||
| 123 | + | var st State | |||
| 124 | + | data, err := os.ReadFile(path) | |||
| 125 | + | if err != nil { | |||
| 126 | + | return st, err | |||
| 127 | + | } | |||
| 128 | + | return st, json.Unmarshal(data, &st) | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | // procTree finds the parent of a process. | |||
| 132 | + | type procTree interface { | |||
| 133 | + | Parent(pid int) (int, bool) | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | // linuxProcTree reads /proc/<pid>/stat. | |||
| 137 | + | type linuxProcTree struct{} | |||
| 138 | + | ||||
| 139 | + | func (linuxProcTree) Parent(pid int) (int, bool) { | |||
| 140 | + | data, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat") | |||
| 141 | + | if err != nil { | |||
| 142 | + | return 0, false | |||
| 143 | + | } | |||
| 144 | + | // "pid (comm) state ppid ...": comm may hold spaces and parentheses. | |||
| 145 | + | i := strings.LastIndexByte(string(data), ')') | |||
| 146 | + | if i < 0 { | |||
| 147 | + | return 0, false | |||
| 148 | + | } | |||
| 149 | + | f := strings.Fields(string(data[i+1:])) | |||
| 150 | + | if len(f) < 2 { | |||
| 151 | + | return 0, false | |||
| 152 | + | } | |||
| 153 | + | ppid, err := strconv.Atoi(f[1]) | |||
| 154 | + | return ppid, err == nil && ppid > 0 | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | // ErrNoWindow means no window belongs to the process or its ancestors. | |||
| 158 | + | var ErrNoWindow = errors.New("no window for this process") | |||
| 159 | + | ||||
| 160 | + | // OutputOf finds the output showing a process: the window of the process | |||
| 161 | + | // itself or of its nearest ancestor, e.g. the terminal of a shell. Only | |||
| 162 | + | // application windows count: bars (layer surfaces) have no single output. | |||
| 163 | + | // A process with windows on several outputs resolves to its focused window, | |||
| 164 | + | // else a visible one. PIDs are those of the compositor's PID namespace. | |||
| 165 | + | func OutputOf(st State, pid int) (Output, error) { | |||
| 166 | + | return outputOf(st, pid, linuxProcTree{}) | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | func outputOf(st State, pid int, tree procTree) (Output, error) { | |||
| 170 | + | byPID := map[int]Window{} | |||
| 171 | + | for _, w := range st.Windows { | |||
| 172 | + | if w.PID <= 0 { | |||
| 173 | + | continue | |||
| 174 | + | } | |||
| 175 | + | cur, seen := byPID[w.PID] | |||
| 176 | + | if !seen || better(st, w, cur) { | |||
| 177 | + | byPID[w.PID] = w | |||
| 178 | + | } | |||
| 179 | + | } | |||
| 180 | + | // A depth limit guards against a cycle from a reused PID mid-walk. | |||
| 181 | + | for range 64 { | |||
| 182 | + | if w, ok := byPID[pid]; ok { | |||
| 183 | + | for _, o := range st.Outputs { | |||
| 184 | + | if o.Name == w.Output { | |||
| 185 | + | return o, nil | |||
| 186 | + | } | |||
| 187 | + | } | |||
| 188 | + | return Output{}, fmt.Errorf("output %q not in state", w.Output) | |||
| 189 | + | } | |||
| 190 | + | parent, ok := tree.Parent(pid) | |||
| 191 | + | if !ok || parent == pid { | |||
| 192 | + | break | |||
| 193 | + | } | |||
| 194 | + | pid = parent | |||
| 195 | + | } | |||
| 196 | + | return Output{}, ErrNoWindow | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | // better prefers the focused window, then a visible one. | |||
| 200 | + | func better(st State, a, b Window) bool { | |||
| 201 | + | focused := func(w Window) bool { return st.Window != nil && st.Window.ID == w.ID } | |||
| 202 | + | if focused(a) != focused(b) { | |||
| 203 | + | return focused(a) | |||
| 204 | + | } | |||
| 205 | + | return a.Visible && !b.Visible | |||
| 206 | + | } | |||
A internal/adapters/statefile/statefile_test.go +120 -0
| @@ -0,0 +1,120 @@ | |||
| 1 | + | package statefile | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "reflect" | |
| 9 | + | "testing" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/nefertty/internal/ports" | |
| 13 | + | "github.com/bnema/zerowrap" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | var sample = State{ | |
| 17 | + | Output: "DP-2", | |
| 18 | + | Outputs: []Output{{Name: "HDMI-A-1", Active: 1, Count: 1}, {Name: "DP-2", Active: 2, Count: 3, Workspace: "web"}}, | |
| 19 | + | Windows: []Window{{ID: 1, AppID: "foot", PID: 100, Output: "HDMI-A-1", Workspace: 1, Visible: true}, {ID: 2, AppID: "foot", PID: 200, Output: "DP-2", Workspace: 2, Visible: true}}, | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | // The file holds the latest state while the session runs; a stale file from | |
| 23 | + | // a crashed session goes at start, and the file is removed at exit. | |
| 24 | + | func TestRunWritesAndRemoves(t *testing.T) { | |
| 25 | + | path, err := Path(t.TempDir(), "wayland-9") | |
| 26 | + | if err != nil { | |
| 27 | + | t.Fatal(err) | |
| 28 | + | } | |
| 29 | + | if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { | |
| 30 | + | t.Fatal(err) | |
| 31 | + | } | |
| 32 | + | if err := os.WriteFile(path, []byte(`{"output":"stale"}`), 0o600); err != nil { | |
| 33 | + | t.Fatal(err) | |
| 34 | + | } | |
| 35 | + | states := make(chan ports.State, 1) | |
| 36 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 37 | + | done := make(chan error, 1) | |
| 38 | + | go func() { done <- Run(ctx, path, states, zerowrap.Default()) }() | |
| 39 | + | focused := ports.WindowState{ID: 2, AppID: "foot", PID: 200, Output: "DP-2", Workspace: 1, Visible: true} | |
| 40 | + | states <- ports.State{Output: "DP-2", Outputs: []ports.OutputState{{Name: "DP-2", Active: 1, Count: 1}}, Window: &focused, Windows: []ports.WindowState{focused}} | |
| 41 | + | want := State{Output: "DP-2", Outputs: []Output{{Name: "DP-2", Active: 1, Count: 1}}, Window: &Window{ID: 2, AppID: "foot", PID: 200, Output: "DP-2", Workspace: 1, Visible: true}, Windows: []Window{{ID: 2, AppID: "foot", PID: 200, Output: "DP-2", Workspace: 1, Visible: true}}} | |
| 42 | + | deadline := time.Now().Add(2 * time.Second) | |
| 43 | + | for { | |
| 44 | + | if got, err := Read(path); err == nil && got.Output != "stale" { | |
| 45 | + | if !reflect.DeepEqual(got, want) { | |
| 46 | + | t.Fatalf("%+v", got) | |
| 47 | + | } | |
| 48 | + | break | |
| 49 | + | } | |
| 50 | + | if time.Now().After(deadline) { | |
| 51 | + | t.Fatal("no state file") | |
| 52 | + | } | |
| 53 | + | time.Sleep(5 * time.Millisecond) | |
| 54 | + | } | |
| 55 | + | cancel() | |
| 56 | + | if err := <-done; err != nil { | |
| 57 | + | t.Fatal(err) | |
| 58 | + | } | |
| 59 | + | if _, err := os.Stat(path); !os.IsNotExist(err) { | |
| 60 | + | t.Fatal("state file left behind", err) | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func TestPath(t *testing.T) { | |
| 65 | + | if p, err := Path("/run/user/1", "/run/user/1/wayland-1"); err != nil || p != "/run/user/1/nefertty/wayland-1.json" { | |
| 66 | + | t.Fatal(p, err) | |
| 67 | + | } | |
| 68 | + | for _, c := range [][2]string{{"", "wayland-1"}, {"/run/user/1", ""}} { | |
| 69 | + | if _, err := Path(c[0], c[1]); err == nil { | |
| 70 | + | t.Fatal(c) | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // A shell finds its window through its ancestors. | |
| 76 | + | func TestOutputOfWalksParents(t *testing.T) { | |
| 77 | + | tree := newMockprocTree(t) | |
| 78 | + | // shell 300 → login 250 → terminal 200 (window on DP-2) | |
| 79 | + | tree.EXPECT().Parent(300).Return(250, true) | |
| 80 | + | tree.EXPECT().Parent(250).Return(200, true) | |
| 81 | + | o, err := outputOf(sample, 300, tree) | |
| 82 | + | if err != nil || o.Name != "DP-2" || o.Active != 2 || o.Count != 3 { | |
| 83 | + | t.Fatal(o, err) | |
| 84 | + | } | |
| 85 | + | // The window's own process needs no walk. | |
| 86 | + | if o, err := outputOf(sample, 100, newMockprocTree(t)); err != nil || o.Name != "HDMI-A-1" { | |
| 87 | + | t.Fatal(o, err) | |
| 88 | + | } | |
| 89 | + | // No window up to init. | |
| 90 | + | orphan := newMockprocTree(t) | |
| 91 | + | orphan.EXPECT().Parent(400).Return(1, true) | |
| 92 | + | orphan.EXPECT().Parent(1).Return(0, false) | |
| 93 | + | if _, err := outputOf(sample, 400, orphan); !errors.Is(err, ErrNoWindow) { | |
| 94 | + | t.Fatal(err) | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | // One process with windows on two outputs (a terminal server): the focused | |
| 99 | + | // window wins, else a visible one. | |
| 100 | + | func TestOutputOfPrefersFocusedWindow(t *testing.T) { | |
| 101 | + | st := sample | |
| 102 | + | st.Windows = []Window{ | |
| 103 | + | {ID: 1, PID: 100, Output: "HDMI-A-1", Visible: true}, | |
| 104 | + | {ID: 2, PID: 100, Output: "DP-2", Visible: false}, | |
| 105 | + | {ID: 3, PID: 100, Output: "DP-2", Visible: true}, | |
| 106 | + | } | |
| 107 | + | if o, _ := outputOf(st, 100, newMockprocTree(t)); o.Name != "HDMI-A-1" { | |
| 108 | + | t.Fatal(o) | |
| 109 | + | } | |
| 110 | + | st.Window = &st.Windows[2] | |
| 111 | + | if o, _ := outputOf(st, 100, newMockprocTree(t)); o.Name != "DP-2" { | |
| 112 | + | t.Fatal(o) | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | func TestLinuxProcTreeParent(t *testing.T) { | |
| 117 | + | if ppid, ok := (linuxProcTree{}).Parent(os.Getpid()); !ok || ppid != os.Getppid() { | |
| 118 | + | t.Fatal(ppid, ok) | |
| 119 | + | } | |
| 120 | + | } | |
| @@ -0,0 +1,120 @@ | |||||
| 1 | + | package statefile | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "os" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | "reflect" | |||
| 9 | + | "testing" | |||
| 10 | + | "time" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/nefertty/internal/ports" | |||
| 13 | + | "github.com/bnema/zerowrap" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | var sample = State{ | |||
| 17 | + | Output: "DP-2", | |||
| 18 | + | Outputs: []Output{{Name: "HDMI-A-1", Active: 1, Count: 1}, {Name: "DP-2", Active: 2, Count: 3, Workspace: "web"}}, | |||
| 19 | + | Windows: []Window{{ID: 1, AppID: "foot", PID: 100, Output: "HDMI-A-1", Workspace: 1, Visible: true}, {ID: 2, AppID: "foot", PID: 200, Output: "DP-2", Workspace: 2, Visible: true}}, | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | // The file holds the latest state while the session runs; a stale file from | |||
| 23 | + | // a crashed session goes at start, and the file is removed at exit. | |||
| 24 | + | func TestRunWritesAndRemoves(t *testing.T) { | |||
| 25 | + | path, err := Path(t.TempDir(), "wayland-9") | |||
| 26 | + | if err != nil { | |||
| 27 | + | t.Fatal(err) | |||
| 28 | + | } | |||
| 29 | + | if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { | |||
| 30 | + | t.Fatal(err) | |||
| 31 | + | } | |||
| 32 | + | if err := os.WriteFile(path, []byte(`{"output":"stale"}`), 0o600); err != nil { | |||
| 33 | + | t.Fatal(err) | |||
| 34 | + | } | |||
| 35 | + | states := make(chan ports.State, 1) | |||
| 36 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 37 | + | done := make(chan error, 1) | |||
| 38 | + | go func() { done <- Run(ctx, path, states, zerowrap.Default()) }() | |||
| 39 | + | focused := ports.WindowState{ID: 2, AppID: "foot", PID: 200, Output: "DP-2", Workspace: 1, Visible: true} | |||
| 40 | + | states <- ports.State{Output: "DP-2", Outputs: []ports.OutputState{{Name: "DP-2", Active: 1, Count: 1}}, Window: &focused, Windows: []ports.WindowState{focused}} | |||
| 41 | + | want := State{Output: "DP-2", Outputs: []Output{{Name: "DP-2", Active: 1, Count: 1}}, Window: &Window{ID: 2, AppID: "foot", PID: 200, Output: "DP-2", Workspace: 1, Visible: true}, Windows: []Window{{ID: 2, AppID: "foot", PID: 200, Output: "DP-2", Workspace: 1, Visible: true}}} | |||
| 42 | + | deadline := time.Now().Add(2 * time.Second) | |||
| 43 | + | for { | |||
| 44 | + | if got, err := Read(path); err == nil && got.Output != "stale" { | |||
| 45 | + | if !reflect.DeepEqual(got, want) { | |||
| 46 | + | t.Fatalf("%+v", got) | |||
| 47 | + | } | |||
| 48 | + | break | |||
| 49 | + | } | |||
| 50 | + | if time.Now().After(deadline) { | |||
| 51 | + | t.Fatal("no state file") | |||
| 52 | + | } | |||
| 53 | + | time.Sleep(5 * time.Millisecond) | |||
| 54 | + | } | |||
| 55 | + | cancel() | |||
| 56 | + | if err := <-done; err != nil { | |||
| 57 | + | t.Fatal(err) | |||
| 58 | + | } | |||
| 59 | + | if _, err := os.Stat(path); !os.IsNotExist(err) { | |||
| 60 | + | t.Fatal("state file left behind", err) | |||
| 61 | + | } | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | func TestPath(t *testing.T) { | |||
| 65 | + | if p, err := Path("/run/user/1", "/run/user/1/wayland-1"); err != nil || p != "/run/user/1/nefertty/wayland-1.json" { | |||
| 66 | + | t.Fatal(p, err) | |||
| 67 | + | } | |||
| 68 | + | for _, c := range [][2]string{{"", "wayland-1"}, {"/run/user/1", ""}} { | |||
| 69 | + | if _, err := Path(c[0], c[1]); err == nil { | |||
| 70 | + | t.Fatal(c) | |||
| 71 | + | } | |||
| 72 | + | } | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | // A shell finds its window through its ancestors. | |||
| 76 | + | func TestOutputOfWalksParents(t *testing.T) { | |||
| 77 | + | tree := newMockprocTree(t) | |||
| 78 | + | // shell 300 → login 250 → terminal 200 (window on DP-2) | |||
| 79 | + | tree.EXPECT().Parent(300).Return(250, true) | |||
| 80 | + | tree.EXPECT().Parent(250).Return(200, true) | |||
| 81 | + | o, err := outputOf(sample, 300, tree) | |||
| 82 | + | if err != nil || o.Name != "DP-2" || o.Active != 2 || o.Count != 3 { | |||
| 83 | + | t.Fatal(o, err) | |||
| 84 | + | } | |||
| 85 | + | // The window's own process needs no walk. | |||
| 86 | + | if o, err := outputOf(sample, 100, newMockprocTree(t)); err != nil || o.Name != "HDMI-A-1" { | |||
| 87 | + | t.Fatal(o, err) | |||
| 88 | + | } | |||
| 89 | + | // No window up to init. | |||
| 90 | + | orphan := newMockprocTree(t) | |||
| 91 | + | orphan.EXPECT().Parent(400).Return(1, true) | |||
| 92 | + | orphan.EXPECT().Parent(1).Return(0, false) | |||
| 93 | + | if _, err := outputOf(sample, 400, orphan); !errors.Is(err, ErrNoWindow) { | |||
| 94 | + | t.Fatal(err) | |||
| 95 | + | } | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | // One process with windows on two outputs (a terminal server): the focused | |||
| 99 | + | // window wins, else a visible one. | |||
| 100 | + | func TestOutputOfPrefersFocusedWindow(t *testing.T) { | |||
| 101 | + | st := sample | |||
| 102 | + | st.Windows = []Window{ | |||
| 103 | + | {ID: 1, PID: 100, Output: "HDMI-A-1", Visible: true}, | |||
| 104 | + | {ID: 2, PID: 100, Output: "DP-2", Visible: false}, | |||
| 105 | + | {ID: 3, PID: 100, Output: "DP-2", Visible: true}, | |||
| 106 | + | } | |||
| 107 | + | if o, _ := outputOf(st, 100, newMockprocTree(t)); o.Name != "HDMI-A-1" { | |||
| 108 | + | t.Fatal(o) | |||
| 109 | + | } | |||
| 110 | + | st.Window = &st.Windows[2] | |||
| 111 | + | if o, _ := outputOf(st, 100, newMockprocTree(t)); o.Name != "DP-2" { | |||
| 112 | + | t.Fatal(o) | |||
| 113 | + | } | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | func TestLinuxProcTreeParent(t *testing.T) { | |||
| 117 | + | if ppid, ok := (linuxProcTree{}).Parent(os.Getpid()); !ok || ppid != os.Getppid() { | |||
| 118 | + | t.Fatal(ppid, ok) | |||
| 119 | + | } | |||
| 120 | + | } | |||
M internal/adapters/wayland/slot_test.go +2 -2
| @@ -15,8 +15,8 @@ func TestWindowMappedCarriesSlot(t *testing.T) { | |||
| 15 | 15 | s, events, commands, dir := keyboardServer(t, func(s *Server) { s.env = env }) | |
| 16 | 16 | mapWindow := toplevelMapper(t, protocolClient(t, s, dir), events) | |
| 17 | 17 | // No slot pending: /proc is not read (the mock would fail on a call). | |
| 18 | - | if w := mapWindow(); w.Slot != "" { | |
| 19 | - | t.Fatalf("slot %q", w.Slot) | |
| 18 | + | if w := mapWindow(); w.Slot != "" || w.PID != os.Getpid() { | |
| 19 | + | t.Fatalf("slot %q pid %d", w.Slot, w.PID) | |
| 20 | 20 | } | |
| 21 | 21 | commands <- ports.SlotsPending{Pending: true} | |
| 22 | 22 | if w := mapWindow(); w.Slot != "7" { | |
| @@ -15,8 +15,8 @@ func TestWindowMappedCarriesSlot(t *testing.T) { | |||||
| 15 | s, events, commands, dir := keyboardServer(t, func(s *Server) { s.env = env }) | 15 | s, events, commands, dir := keyboardServer(t, func(s *Server) { s.env = env }) | ||
| 16 | mapWindow := toplevelMapper(t, protocolClient(t, s, dir), events) | 16 | mapWindow := toplevelMapper(t, protocolClient(t, s, dir), events) | ||
| 17 | // No slot pending: /proc is not read (the mock would fail on a call). | 17 | // No slot pending: /proc is not read (the mock would fail on a call). | ||
| 18 | - | if w := mapWindow(); w.Slot != "" { | 18 | + | if w := mapWindow(); w.Slot != "" || w.PID != os.Getpid() { |
| 19 | - | t.Fatalf("slot %q", w.Slot) | 19 | + | t.Fatalf("slot %q pid %d", w.Slot, w.PID) |
| 20 | } | 20 | } | ||
| 21 | commands <- ports.SlotsPending{Pending: true} | 21 | commands <- ports.SlotsPending{Pending: true} | ||
| 22 | if w := mapWindow(); w.Slot != "7" { | 22 | if w := mapWindow(); w.Slot != "7" { | ||
M internal/adapters/wayland/xdg.go +13 -5
| @@ -151,7 +151,7 @@ func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) { | |||
| 151 | 151 | } else if buffer && !w.mapped && x.acked { | |
| 152 | 152 | w.mapped = true | |
| 153 | 153 | slot := x.server.slotToken(r.Client()) | |
| 154 | - | x.server.emit(ports.WindowMapped{ID: w.id, AppID: w.appID, Slot: slot}) | |
| 154 | + | x.server.emit(ports.WindowMapped{ID: w.id, AppID: w.appID, Slot: slot, PID: r.Client().PID()}) | |
| 155 | 155 | x.server.log.Info().Uint64("id", uint64(w.id)).Str("app_id", w.appID).Str("slot", slot).Msg("window mapped") | |
| 156 | 156 | } else if !buffer && w.mapped { | |
| 157 | 157 | w.unmap() | |
| @@ -202,10 +202,18 @@ func (x *xdgSurface) AckConfigure(r *xdgshell.Surface, serial uint32) { | |||
| 202 | 202 | ||
| 203 | 203 | type top struct{ w *window } | |
| 204 | 204 | ||
| 205 | - | func (top) Destroy(*xdgshell.Toplevel) {} | |
| 206 | - | func (top) SetParent(*xdgshell.Toplevel, *xdgshell.Toplevel) {} | |
| 207 | - | func (t top) SetTitle(_ *xdgshell.Toplevel, title string) { t.w.title = title } | |
| 208 | - | func (t top) SetAppId(_ *xdgshell.Toplevel, appID string) { t.w.appID = appID } | |
| 205 | + | func (top) Destroy(*xdgshell.Toplevel) {} | |
| 206 | + | func (top) SetParent(*xdgshell.Toplevel, *xdgshell.Toplevel) {} | |
| 207 | + | func (t top) SetTitle(_ *xdgshell.Toplevel, title string) { t.w.title = title } | |
| 208 | + | func (t top) SetAppId(_ *xdgshell.Toplevel, appID string) { | |
| 209 | + | if appID == t.w.appID { | |
| 210 | + | return | |
| 211 | + | } | |
| 212 | + | t.w.appID = appID | |
| 213 | + | if t.w.mapped { | |
| 214 | + | t.w.xdg.server.emit(ports.WindowAppID{ID: t.w.id, AppID: appID}) | |
| 215 | + | } | |
| 216 | + | } | |
| 209 | 217 | func (top) ShowWindowMenu(*xdgshell.Toplevel, *wayland.Seat, uint32, int32, int32) {} | |
| 210 | 218 | func (top) Move(*xdgshell.Toplevel, *wayland.Seat, uint32) {} | |
| 211 | 219 | func (top) Resize(*xdgshell.Toplevel, *wayland.Seat, uint32, uint32) {} | |
| @@ -151,7 +151,7 @@ func (x *xdgSurface) GetToplevel(r *xdgshell.Surface, id uint32) { | |||||
| 151 | } else if buffer && !w.mapped && x.acked { | 151 | } else if buffer && !w.mapped && x.acked { | ||
| 152 | w.mapped = true | 152 | w.mapped = true | ||
| 153 | slot := x.server.slotToken(r.Client()) | 153 | slot := x.server.slotToken(r.Client()) | ||
| 154 | - | x.server.emit(ports.WindowMapped{ID: w.id, AppID: w.appID, Slot: slot}) | 154 | + | x.server.emit(ports.WindowMapped{ID: w.id, AppID: w.appID, Slot: slot, PID: r.Client().PID()}) |
| 155 | x.server.log.Info().Uint64("id", uint64(w.id)).Str("app_id", w.appID).Str("slot", slot).Msg("window mapped") | 155 | x.server.log.Info().Uint64("id", uint64(w.id)).Str("app_id", w.appID).Str("slot", slot).Msg("window mapped") | ||
| 156 | } else if !buffer && w.mapped { | 156 | } else if !buffer && w.mapped { | ||
| 157 | w.unmap() | 157 | w.unmap() | ||
| @@ -202,10 +202,18 @@ func (x *xdgSurface) AckConfigure(r *xdgshell.Surface, serial uint32) { | |||||
| 202 | 202 | ||||
| 203 | type top struct{ w *window } | 203 | type top struct{ w *window } | ||
| 204 | 204 | ||||
| 205 | - | func (top) Destroy(*xdgshell.Toplevel) {} | 205 | + | func (top) Destroy(*xdgshell.Toplevel) {} |
| 206 | - | func (top) SetParent(*xdgshell.Toplevel, *xdgshell.Toplevel) {} | 206 | + | func (top) SetParent(*xdgshell.Toplevel, *xdgshell.Toplevel) {} |
| 207 | - | func (t top) SetTitle(_ *xdgshell.Toplevel, title string) { t.w.title = title } | 207 | + | func (t top) SetTitle(_ *xdgshell.Toplevel, title string) { t.w.title = title } |
| 208 | - | func (t top) SetAppId(_ *xdgshell.Toplevel, appID string) { t.w.appID = appID } | 208 | + | func (t top) SetAppId(_ *xdgshell.Toplevel, appID string) { |
| 209 | + | if appID == t.w.appID { | |||
| 210 | + | return | |||
| 211 | + | } | |||
| 212 | + | t.w.appID = appID | |||
| 213 | + | if t.w.mapped { | |||
| 214 | + | t.w.xdg.server.emit(ports.WindowAppID{ID: t.w.id, AppID: appID}) | |||
| 215 | + | } | |||
| 216 | + | } | |||
| 209 | func (top) ShowWindowMenu(*xdgshell.Toplevel, *wayland.Seat, uint32, int32, int32) {} | 217 | func (top) ShowWindowMenu(*xdgshell.Toplevel, *wayland.Seat, uint32, int32, int32) {} | ||
| 210 | func (top) Move(*xdgshell.Toplevel, *wayland.Seat, uint32) {} | 218 | func (top) Move(*xdgshell.Toplevel, *wayland.Seat, uint32) {} | ||
| 211 | func (top) Resize(*xdgshell.Toplevel, *wayland.Seat, uint32, uint32) {} | 219 | func (top) Resize(*xdgshell.Toplevel, *wayland.Seat, uint32, uint32) {} | ||
M internal/app/app.go +21 -2
| @@ -16,6 +16,7 @@ import ( | |||
| 16 | 16 | "github.com/bnema/nefertty/internal/adapters/headlessinput" | |
| 17 | 17 | "github.com/bnema/nefertty/internal/adapters/launcher" | |
| 18 | 18 | "github.com/bnema/nefertty/internal/adapters/libinput" | |
| 19 | + | "github.com/bnema/nefertty/internal/adapters/statefile" | |
| 19 | 20 | "github.com/bnema/nefertty/internal/adapters/vulkan" | |
| 20 | 21 | "github.com/bnema/nefertty/internal/adapters/wayland" | |
| 21 | 22 | "github.com/bnema/nefertty/internal/adapters/xkb" | |
| @@ -73,10 +74,11 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 73 | 74 | spawn := make(chan ports.SpawnRequest, 32) | |
| 74 | 75 | scenes := make(chan []ports.Scene, 1) | |
| 75 | 76 | layouts := make(chan ports.Layout, 1) | |
| 77 | + | states := make(chan ports.State, 1) | |
| 76 | 78 | configErrors := make(chan error, 8) | |
| 77 | 79 | renderScenes := make(chan []ports.Scene, 1) | |
| 78 | 80 | contents := make(chan ports.SurfaceContent, 64) | |
| 79 | - | ch := core.Channels{Client: client, Input: input, Output: output, Config: configChanges, Commands: commands, Spawn: spawn, Scenes: scenes, Layouts: layouts, ConfigErrors: configErrors, Terminal: !opts.NoTerminal} | |
| 81 | + | ch := core.Channels{Client: client, Input: input, Output: output, Config: configChanges, Commands: commands, Spawn: spawn, Scenes: scenes, Layouts: layouts, State: states, ConfigErrors: configErrors, Terminal: !opts.NoTerminal} | |
| 80 | 82 | c, err := core.New(opts.Config, ch) | |
| 81 | 83 | if err != nil { | |
| 82 | 84 | return err | |
| @@ -93,7 +95,14 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 93 | 95 | return err | |
| 94 | 96 | } | |
| 95 | 97 | log.Info().Str("WAYLAND_DISPLAY", server.SocketName()).Msg("listening") | |
| 96 | - | child := launcher.New(launcher.ChildEnv(os.Environ(), server.SocketName(), runtimeDir), logging.For(ctx, "launcher")) | |
| 98 | + | childEnv := launcher.ChildEnv(os.Environ(), server.SocketName(), runtimeDir) | |
| 99 | + | statePath, err := statefile.Path(runtimeDir, server.SocketName()) | |
| 100 | + | if err != nil { | |
| 101 | + | log.Warn().Err(err).Msg("state file disabled") | |
| 102 | + | } else { | |
| 103 | + | childEnv = append(childEnv, statefile.Env+"="+statePath) | |
| 104 | + | } | |
| 105 | + | child := launcher.New(childEnv, logging.For(ctx, "launcher")) | |
| 97 | 106 | if inject != nil { | |
| 98 | 107 | inject(input) | |
| 99 | 108 | } | |
| @@ -157,6 +166,16 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 157 | 166 | go func() { defer workers.Done(); done <- server.Run(ctx) }() | |
| 158 | 167 | go func() { defer workers.Done(); done <- child.Run(ctx, spawn) }() | |
| 159 | 168 | go func() { defer workers.Done(); done <- c.Run(ctx) }() | |
| 169 | + | if statePath != "" { | |
| 170 | + | workers.Add(1) | |
| 171 | + | go func() { | |
| 172 | + | defer workers.Done() | |
| 173 | + | if err := statefile.Run(ctx, statePath, states, logging.For(ctx, "statefile")); err != nil { | |
| 174 | + | // Scripts lose their state; the session goes on. | |
| 175 | + | log.Warn().Err(err).Msg("state file disabled") | |
| 176 | + | } | |
| 177 | + | }() | |
| 178 | + | } | |
| 160 | 179 | go func() { defer workers.Done(); consumeScenes(ctx, scenes, configErrors, opts.testScenes, renderScenes) }() | |
| 161 | 180 | ||
| 162 | 181 | go func() { | |
| @@ -16,6 +16,7 @@ import ( | |||||
| 16 | "github.com/bnema/nefertty/internal/adapters/headlessinput" | 16 | "github.com/bnema/nefertty/internal/adapters/headlessinput" | ||
| 17 | "github.com/bnema/nefertty/internal/adapters/launcher" | 17 | "github.com/bnema/nefertty/internal/adapters/launcher" | ||
| 18 | "github.com/bnema/nefertty/internal/adapters/libinput" | 18 | "github.com/bnema/nefertty/internal/adapters/libinput" | ||
| 19 | + | "github.com/bnema/nefertty/internal/adapters/statefile" | |||
| 19 | "github.com/bnema/nefertty/internal/adapters/vulkan" | 20 | "github.com/bnema/nefertty/internal/adapters/vulkan" | ||
| 20 | "github.com/bnema/nefertty/internal/adapters/wayland" | 21 | "github.com/bnema/nefertty/internal/adapters/wayland" | ||
| 21 | "github.com/bnema/nefertty/internal/adapters/xkb" | 22 | "github.com/bnema/nefertty/internal/adapters/xkb" | ||
| @@ -73,10 +74,11 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||||
| 73 | spawn := make(chan ports.SpawnRequest, 32) | 74 | spawn := make(chan ports.SpawnRequest, 32) | ||
| 74 | scenes := make(chan []ports.Scene, 1) | 75 | scenes := make(chan []ports.Scene, 1) | ||
| 75 | layouts := make(chan ports.Layout, 1) | 76 | layouts := make(chan ports.Layout, 1) | ||
| 77 | + | states := make(chan ports.State, 1) | |||
| 76 | configErrors := make(chan error, 8) | 78 | configErrors := make(chan error, 8) | ||
| 77 | renderScenes := make(chan []ports.Scene, 1) | 79 | renderScenes := make(chan []ports.Scene, 1) | ||
| 78 | contents := make(chan ports.SurfaceContent, 64) | 80 | contents := make(chan ports.SurfaceContent, 64) | ||
| 79 | - | ch := core.Channels{Client: client, Input: input, Output: output, Config: configChanges, Commands: commands, Spawn: spawn, Scenes: scenes, Layouts: layouts, ConfigErrors: configErrors, Terminal: !opts.NoTerminal} | 81 | + | ch := core.Channels{Client: client, Input: input, Output: output, Config: configChanges, Commands: commands, Spawn: spawn, Scenes: scenes, Layouts: layouts, State: states, ConfigErrors: configErrors, Terminal: !opts.NoTerminal} |
| 80 | c, err := core.New(opts.Config, ch) | 82 | c, err := core.New(opts.Config, ch) | ||
| 81 | if err != nil { | 83 | if err != nil { | ||
| 82 | return err | 84 | return err | ||
| @@ -93,7 +95,14 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||||
| 93 | return err | 95 | return err | ||
| 94 | } | 96 | } | ||
| 95 | log.Info().Str("WAYLAND_DISPLAY", server.SocketName()).Msg("listening") | 97 | log.Info().Str("WAYLAND_DISPLAY", server.SocketName()).Msg("listening") | ||
| 96 | - | child := launcher.New(launcher.ChildEnv(os.Environ(), server.SocketName(), runtimeDir), logging.For(ctx, "launcher")) | 98 | + | childEnv := launcher.ChildEnv(os.Environ(), server.SocketName(), runtimeDir) |
| 99 | + | statePath, err := statefile.Path(runtimeDir, server.SocketName()) | |||
| 100 | + | if err != nil { | |||
| 101 | + | log.Warn().Err(err).Msg("state file disabled") | |||
| 102 | + | } else { | |||
| 103 | + | childEnv = append(childEnv, statefile.Env+"="+statePath) | |||
| 104 | + | } | |||
| 105 | + | child := launcher.New(childEnv, logging.For(ctx, "launcher")) | |||
| 97 | if inject != nil { | 106 | if inject != nil { | ||
| 98 | inject(input) | 107 | inject(input) | ||
| 99 | } | 108 | } | ||
| @@ -157,6 +166,16 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||||
| 157 | go func() { defer workers.Done(); done <- server.Run(ctx) }() | 166 | go func() { defer workers.Done(); done <- server.Run(ctx) }() | ||
| 158 | go func() { defer workers.Done(); done <- child.Run(ctx, spawn) }() | 167 | go func() { defer workers.Done(); done <- child.Run(ctx, spawn) }() | ||
| 159 | go func() { defer workers.Done(); done <- c.Run(ctx) }() | 168 | go func() { defer workers.Done(); done <- c.Run(ctx) }() | ||
| 169 | + | if statePath != "" { | |||
| 170 | + | workers.Add(1) | |||
| 171 | + | go func() { | |||
| 172 | + | defer workers.Done() | |||
| 173 | + | if err := statefile.Run(ctx, statePath, states, logging.For(ctx, "statefile")); err != nil { | |||
| 174 | + | // Scripts lose their state; the session goes on. | |||
| 175 | + | log.Warn().Err(err).Msg("state file disabled") | |||
| 176 | + | } | |||
| 177 | + | }() | |||
| 178 | + | } | |||
| 160 | go func() { defer workers.Done(); consumeScenes(ctx, scenes, configErrors, opts.testScenes, renderScenes) }() | 179 | go func() { defer workers.Done(); consumeScenes(ctx, scenes, configErrors, opts.testScenes, renderScenes) }() | ||
| 161 | 180 | ||||
| 162 | go func() { | 181 | go func() { | ||
M internal/core/core.go +18 -3
| @@ -28,6 +28,9 @@ type Channels struct { | |||
| 28 | 28 | // latest first (capacity 1, drained like Scenes). Input and cursors use it. | |
| 29 | 29 | Layouts chan ports.Layout | |
| 30 | 30 | ConfigErrors chan<- error | |
| 31 | + | // State, when set, receives a snapshot for scripts whenever it changes, | |
| 32 | + | // latest first (capacity 1, drained like Scenes). | |
| 33 | + | State chan ports.State | |
| 31 | 34 | // Terminal, when set, keeps a window on every workspace on screen: an | |
| 32 | 35 | // empty one gets the configured terminal (the terminal is the desktop). | |
| 33 | 36 | Terminal bool | |
| @@ -64,6 +67,9 @@ type Core struct { | |||
| 64 | 67 | // terms are the terminals spawned for empty workspaces, by SlotEnv | |
| 65 | 68 | // token, until their window maps. | |
| 66 | 69 | terms map[string]*termSpawn | |
| 70 | + | // clients holds the app ID and PID of mapped windows, for State. | |
| 71 | + | clients map[WindowID]ports.WindowMapped | |
| 72 | + | sentState ports.State | |
| 67 | 73 | } | |
| 68 | 74 | ||
| 69 | 75 | func keyName(s string) string { | |
| @@ -242,11 +248,11 @@ func (c *Core) spawnSlots(ctx context.Context, shown bool) error { | |||
| 242 | 248 | return nil | |
| 243 | 249 | } | |
| 244 | 250 | func New(cfg ports.Config, ch Channels) (*Core, error) { | |
| 245 | - | if cap(ch.Scenes) != 1 || (ch.Layouts != nil && cap(ch.Layouts) != 1) { | |
| 246 | - | return nil, fmt.Errorf("scenes and layouts must have capacity 1") | |
| 251 | + | if cap(ch.Scenes) != 1 || (ch.Layouts != nil && cap(ch.Layouts) != 1) || (ch.State != nil && cap(ch.State) != 1) { | |
| 252 | + | return nil, fmt.Errorf("scenes, layouts and state must have capacity 1") | |
| 247 | 253 | } | |
| 248 | 254 | // A placeholder screen holds windows until the first output arrives. | |
| 249 | - | c := &Core{screens: []*screen{{mon: NewMonitor(), scale: 1, cfgScale: 1}}, slots: map[slotKey]*slotState{}, terms: map[string]*termSpawn{}, ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}} | |
| 255 | + | c := &Core{screens: []*screen{{mon: NewMonitor(), scale: 1, cfgScale: 1}}, slots: map[slotKey]*slotState{}, terms: map[string]*termSpawn{}, clients: map[WindowID]ports.WindowMapped{}, ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}} | |
| 250 | 256 | if err := c.apply(cfg); err != nil { | |
| 251 | 257 | return nil, err | |
| 252 | 258 | } | |
| @@ -401,6 +407,7 @@ func (c *Core) publish(ctx context.Context) error { | |||
| 401 | 407 | c.focus = focus | |
| 402 | 408 | } | |
| 403 | 409 | latest(c.ch.Scenes, scenes) | |
| 410 | + | c.publishState() | |
| 404 | 411 | return nil | |
| 405 | 412 | } | |
| 406 | 413 | ||
| @@ -463,6 +470,7 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 463 | 470 | if c.spawnSlots(ctx, false) != nil || c.publishPending(ctx) != nil { | |
| 464 | 471 | return nil | |
| 465 | 472 | } | |
| 473 | + | c.publishState() | |
| 466 | 474 | for { | |
| 467 | 475 | select { | |
| 468 | 476 | case <-ctx.Done(): | |
| @@ -477,12 +485,19 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 477 | 485 | c.layerChanged = true | |
| 478 | 486 | c.setLayers(v.Layers) | |
| 479 | 487 | case ports.WindowMapped: | |
| 488 | + | c.clients[v.ID] = v | |
| 480 | 489 | if v.Slot == "" || (!c.placeSlotWindow(v.ID, v.Slot) && !c.placeTerminal(v.ID, v.Slot)) { | |
| 481 | 490 | if s, _ := c.screenOf(v.ID); s == nil { | |
| 482 | 491 | c.cur().mon.AddWindow(v.ID) | |
| 483 | 492 | } | |
| 484 | 493 | } | |
| 494 | + | case ports.WindowAppID: | |
| 495 | + | if info, ok := c.clients[v.ID]; ok { | |
| 496 | + | info.AppID = v.AppID | |
| 497 | + | c.clients[v.ID] = info | |
| 498 | + | } | |
| 485 | 499 | case ports.WindowUnmapped: | |
| 500 | + | delete(c.clients, v.ID) | |
| 486 | 501 | if s, _ := c.screenOf(v.ID); s != nil { | |
| 487 | 502 | s.mon.RemoveWindow(v.ID) | |
| 488 | 503 | } | |
| @@ -28,6 +28,9 @@ type Channels struct { | |||||
| 28 | // latest first (capacity 1, drained like Scenes). Input and cursors use it. | 28 | // latest first (capacity 1, drained like Scenes). Input and cursors use it. | ||
| 29 | Layouts chan ports.Layout | 29 | Layouts chan ports.Layout | ||
| 30 | ConfigErrors chan<- error | 30 | ConfigErrors chan<- error | ||
| 31 | + | // State, when set, receives a snapshot for scripts whenever it changes, | |||
| 32 | + | // latest first (capacity 1, drained like Scenes). | |||
| 33 | + | State chan ports.State | |||
| 31 | // Terminal, when set, keeps a window on every workspace on screen: an | 34 | // Terminal, when set, keeps a window on every workspace on screen: an | ||
| 32 | // empty one gets the configured terminal (the terminal is the desktop). | 35 | // empty one gets the configured terminal (the terminal is the desktop). | ||
| 33 | Terminal bool | 36 | Terminal bool | ||
| @@ -64,6 +67,9 @@ type Core struct { | |||||
| 64 | // terms are the terminals spawned for empty workspaces, by SlotEnv | 67 | // terms are the terminals spawned for empty workspaces, by SlotEnv | ||
| 65 | // token, until their window maps. | 68 | // token, until their window maps. | ||
| 66 | terms map[string]*termSpawn | 69 | terms map[string]*termSpawn | ||
| 70 | + | // clients holds the app ID and PID of mapped windows, for State. | |||
| 71 | + | clients map[WindowID]ports.WindowMapped | |||
| 72 | + | sentState ports.State | |||
| 67 | } | 73 | } | ||
| 68 | 74 | ||||
| 69 | func keyName(s string) string { | 75 | func keyName(s string) string { | ||
| @@ -242,11 +248,11 @@ func (c *Core) spawnSlots(ctx context.Context, shown bool) error { | |||||
| 242 | return nil | 248 | return nil | ||
| 243 | } | 249 | } | ||
| 244 | func New(cfg ports.Config, ch Channels) (*Core, error) { | 250 | func New(cfg ports.Config, ch Channels) (*Core, error) { | ||
| 245 | - | if cap(ch.Scenes) != 1 || (ch.Layouts != nil && cap(ch.Layouts) != 1) { | 251 | + | if cap(ch.Scenes) != 1 || (ch.Layouts != nil && cap(ch.Layouts) != 1) || (ch.State != nil && cap(ch.State) != 1) { |
| 246 | - | return nil, fmt.Errorf("scenes and layouts must have capacity 1") | 252 | + | return nil, fmt.Errorf("scenes, layouts and state must have capacity 1") |
| 247 | } | 253 | } | ||
| 248 | // A placeholder screen holds windows until the first output arrives. | 254 | // A placeholder screen holds windows until the first output arrives. | ||
| 249 | - | c := &Core{screens: []*screen{{mon: NewMonitor(), scale: 1, cfgScale: 1}}, slots: map[slotKey]*slotState{}, terms: map[string]*termSpawn{}, ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}} | 255 | + | c := &Core{screens: []*screen{{mon: NewMonitor(), scale: 1, cfgScale: 1}}, slots: map[slotKey]*slotState{}, terms: map[string]*termSpawn{}, clients: map[WindowID]ports.WindowMapped{}, ch: ch, pressed: map[string]bool{}, buttons: map[uint32]bool{}, sent: map[WindowID]ports.ConfigureWindow{}} |
| 250 | if err := c.apply(cfg); err != nil { | 256 | if err := c.apply(cfg); err != nil { | ||
| 251 | return nil, err | 257 | return nil, err | ||
| 252 | } | 258 | } | ||
| @@ -401,6 +407,7 @@ func (c *Core) publish(ctx context.Context) error { | |||||
| 401 | c.focus = focus | 407 | c.focus = focus | ||
| 402 | } | 408 | } | ||
| 403 | latest(c.ch.Scenes, scenes) | 409 | latest(c.ch.Scenes, scenes) | ||
| 410 | + | c.publishState() | |||
| 404 | return nil | 411 | return nil | ||
| 405 | } | 412 | } | ||
| 406 | 413 | ||||
| @@ -463,6 +470,7 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 463 | if c.spawnSlots(ctx, false) != nil || c.publishPending(ctx) != nil { | 470 | if c.spawnSlots(ctx, false) != nil || c.publishPending(ctx) != nil { | ||
| 464 | return nil | 471 | return nil | ||
| 465 | } | 472 | } | ||
| 473 | + | c.publishState() | |||
| 466 | for { | 474 | for { | ||
| 467 | select { | 475 | select { | ||
| 468 | case <-ctx.Done(): | 476 | case <-ctx.Done(): | ||
| @@ -477,12 +485,19 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 477 | c.layerChanged = true | 485 | c.layerChanged = true | ||
| 478 | c.setLayers(v.Layers) | 486 | c.setLayers(v.Layers) | ||
| 479 | case ports.WindowMapped: | 487 | case ports.WindowMapped: | ||
| 488 | + | c.clients[v.ID] = v | |||
| 480 | if v.Slot == "" || (!c.placeSlotWindow(v.ID, v.Slot) && !c.placeTerminal(v.ID, v.Slot)) { | 489 | if v.Slot == "" || (!c.placeSlotWindow(v.ID, v.Slot) && !c.placeTerminal(v.ID, v.Slot)) { | ||
| 481 | if s, _ := c.screenOf(v.ID); s == nil { | 490 | if s, _ := c.screenOf(v.ID); s == nil { | ||
| 482 | c.cur().mon.AddWindow(v.ID) | 491 | c.cur().mon.AddWindow(v.ID) | ||
| 483 | } | 492 | } | ||
| 484 | } | 493 | } | ||
| 494 | + | case ports.WindowAppID: | |||
| 495 | + | if info, ok := c.clients[v.ID]; ok { | |||
| 496 | + | info.AppID = v.AppID | |||
| 497 | + | c.clients[v.ID] = info | |||
| 498 | + | } | |||
| 485 | case ports.WindowUnmapped: | 499 | case ports.WindowUnmapped: | ||
| 500 | + | delete(c.clients, v.ID) | |||
| 486 | if s, _ := c.screenOf(v.ID); s != nil { | 501 | if s, _ := c.screenOf(v.ID); s != nil { | ||
| 487 | s.mon.RemoveWindow(v.ID) | 502 | s.mon.RemoveWindow(v.ID) | ||
| 488 | } | 503 | } | ||
M internal/core/screens_test.go +56 -2
| @@ -2,6 +2,7 @@ package core_test | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "reflect" | |
| 5 | 6 | "testing" | |
| 6 | 7 | ||
| 7 | 8 | "github.com/bnema/nefertty/internal/adapters/config" | |
| @@ -17,6 +18,7 @@ type multiRig struct { | |||
| 17 | 18 | commands chan ports.ClientCommand | |
| 18 | 19 | scenes chan []ports.Scene | |
| 19 | 20 | spawn chan ports.SpawnRequest | |
| 21 | + | state chan ports.State | |
| 20 | 22 | cfg ports.Config | |
| 21 | 23 | } | |
| 22 | 24 | ||
| @@ -45,9 +47,9 @@ func startRig(t *testing.T, terminal bool, edit func(*ports.Config), outs ...por | |||
| 45 | 47 | r := &multiRig{ | |
| 46 | 48 | client: make(chan ports.ClientEvent, 16), input: make(chan ports.InputEvent, 16), | |
| 47 | 49 | output: make(chan ports.OutputEvent, 4), reload: make(chan ports.ConfigChanged, 4), | |
| 48 | - | commands: make(chan ports.ClientCommand, 1024), scenes: make(chan []ports.Scene, 1), spawn: make(chan ports.SpawnRequest, 16), cfg: cfg, | |
| 50 | + | commands: make(chan ports.ClientCommand, 1024), scenes: make(chan []ports.Scene, 1), spawn: make(chan ports.SpawnRequest, 16), state: make(chan ports.State, 1), cfg: cfg, | |
| 49 | 51 | } | |
| 50 | - | c, err := core.New(cfg, core.Channels{Client: r.client, Input: r.input, Output: r.output, Config: r.reload, Commands: r.commands, Scenes: r.scenes, Spawn: r.spawn, Terminal: terminal}) | |
| 52 | + | c, err := core.New(cfg, core.Channels{Client: r.client, Input: r.input, Output: r.output, Config: r.reload, Commands: r.commands, Scenes: r.scenes, Spawn: r.spawn, State: r.state, Terminal: terminal}) | |
| 51 | 53 | if err != nil { | |
| 52 | 54 | t.Fatal(err) | |
| 53 | 55 | } | |
| @@ -446,3 +448,55 @@ func TestKeyboardScreenFocusSticksUntilPointerLeaves(t *testing.T) { | |||
| 446 | 448 | t.Fatal(got) | |
| 447 | 449 | } | |
| 448 | 450 | } | |
| 451 | + | ||
| 452 | + | func TestStateSnapshot(t *testing.T) { | |
| 453 | + | r := startMulti(t, nil, left, right) | |
| 454 | + | r.client <- ports.WindowMapped{ID: 1, AppID: "foot", PID: 100} | |
| 455 | + | receive(t, r.scenes) | |
| 456 | + | r.key(t, "Right", ports.ModAlt|ports.ModCtrl) | |
| 457 | + | <-r.state // the channel holds the latest snapshot only | |
| 458 | + | r.client <- ports.WindowMapped{ID: 2, AppID: "firefox", PID: 200} | |
| 459 | + | receive(t, r.scenes) | |
| 460 | + | st := receive(t, r.state) | |
| 461 | + | want := ports.State{ | |
| 462 | + | Output: "DP-2", | |
| 463 | + | Outputs: []ports.OutputState{{Name: "DP-1", Active: 1, Count: 1}, {Name: "DP-2", Active: 1, Count: 1}}, | |
| 464 | + | Windows: []ports.WindowState{ | |
| 465 | + | {ID: 1, AppID: "foot", PID: 100, Output: "DP-1", Workspace: 1, Visible: true}, | |
| 466 | + | {ID: 2, AppID: "firefox", PID: 200, Output: "DP-2", Workspace: 1, Visible: true}, | |
| 467 | + | }, | |
| 468 | + | } | |
| 469 | + | want.Window = &want.Windows[1] | |
| 470 | + | if !reflect.DeepEqual(st, want) { | |
| 471 | + | t.Fatalf("got %+v\nwant %+v", st, want) | |
| 472 | + | } | |
| 473 | + | // Moving to workspace 2 of DP-2: two workspaces there, window 2 off screen. | |
| 474 | + | r.input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModAlt, Pressed: true} | |
| 475 | + | receive(t, r.scenes) | |
| 476 | + | st = receive(t, r.state) | |
| 477 | + | if st.Outputs[1] != (ports.OutputState{Name: "DP-2", Active: 2, Count: 2}) || st.Windows[1].Visible || st.Window != nil { | |
| 478 | + | t.Fatalf("%+v", st) | |
| 479 | + | } | |
| 480 | + | } | |
| 481 | + | ||
| 482 | + | func TestStateFollowsAppIDAndHiddenWorkspace(t *testing.T) { | |
| 483 | + | r := startMulti(t, func(c *ports.Config) { | |
| 484 | + | c.Workspaces = []ports.WorkspaceConfig{{Name: "notes", Hidden: true}} | |
| 485 | + | c.Binds["Alt+n"] = "workspace notes" | |
| 486 | + | }, left) | |
| 487 | + | r.client <- ports.WindowMapped{ID: 1, PID: 100} | |
| 488 | + | receive(t, r.scenes) | |
| 489 | + | <-r.state | |
| 490 | + | // An app ID set after map reaches the state. | |
| 491 | + | r.client <- ports.WindowAppID{ID: 1, AppID: "foot"} | |
| 492 | + | receive(t, r.scenes) | |
| 493 | + | if st := receive(t, r.state); st.Windows[0].AppID != "foot" { | |
| 494 | + | t.Fatalf("%+v", st) | |
| 495 | + | } | |
| 496 | + | // A hidden workspace on screen: no active number, its name, window 1 off screen. | |
| 497 | + | r.key(t, "n", ports.ModAlt) | |
| 498 | + | st := receive(t, r.state) | |
| 499 | + | if st.Outputs[0].Active != 0 || st.Outputs[0].Workspace != "notes" || st.Windows[0].Visible { | |
| 500 | + | t.Fatalf("%+v", st) | |
| 501 | + | } | |
| 502 | + | } | |
| @@ -2,6 +2,7 @@ package core_test | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "reflect" | |||
| 5 | "testing" | 6 | "testing" | ||
| 6 | 7 | ||||
| 7 | "github.com/bnema/nefertty/internal/adapters/config" | 8 | "github.com/bnema/nefertty/internal/adapters/config" | ||
| @@ -17,6 +18,7 @@ type multiRig struct { | |||||
| 17 | commands chan ports.ClientCommand | 18 | commands chan ports.ClientCommand | ||
| 18 | scenes chan []ports.Scene | 19 | scenes chan []ports.Scene | ||
| 19 | spawn chan ports.SpawnRequest | 20 | spawn chan ports.SpawnRequest | ||
| 21 | + | state chan ports.State | |||
| 20 | cfg ports.Config | 22 | cfg ports.Config | ||
| 21 | } | 23 | } | ||
| 22 | 24 | ||||
| @@ -45,9 +47,9 @@ func startRig(t *testing.T, terminal bool, edit func(*ports.Config), outs ...por | |||||
| 45 | r := &multiRig{ | 47 | r := &multiRig{ | ||
| 46 | client: make(chan ports.ClientEvent, 16), input: make(chan ports.InputEvent, 16), | 48 | client: make(chan ports.ClientEvent, 16), input: make(chan ports.InputEvent, 16), | ||
| 47 | output: make(chan ports.OutputEvent, 4), reload: make(chan ports.ConfigChanged, 4), | 49 | output: make(chan ports.OutputEvent, 4), reload: make(chan ports.ConfigChanged, 4), | ||
| 48 | - | commands: make(chan ports.ClientCommand, 1024), scenes: make(chan []ports.Scene, 1), spawn: make(chan ports.SpawnRequest, 16), cfg: cfg, | 50 | + | commands: make(chan ports.ClientCommand, 1024), scenes: make(chan []ports.Scene, 1), spawn: make(chan ports.SpawnRequest, 16), state: make(chan ports.State, 1), cfg: cfg, |
| 49 | } | 51 | } | ||
| 50 | - | c, err := core.New(cfg, core.Channels{Client: r.client, Input: r.input, Output: r.output, Config: r.reload, Commands: r.commands, Scenes: r.scenes, Spawn: r.spawn, Terminal: terminal}) | 52 | + | c, err := core.New(cfg, core.Channels{Client: r.client, Input: r.input, Output: r.output, Config: r.reload, Commands: r.commands, Scenes: r.scenes, Spawn: r.spawn, State: r.state, Terminal: terminal}) |
| 51 | if err != nil { | 53 | if err != nil { | ||
| 52 | t.Fatal(err) | 54 | t.Fatal(err) | ||
| 53 | } | 55 | } | ||
| @@ -446,3 +448,55 @@ func TestKeyboardScreenFocusSticksUntilPointerLeaves(t *testing.T) { | |||||
| 446 | t.Fatal(got) | 448 | t.Fatal(got) | ||
| 447 | } | 449 | } | ||
| 448 | } | 450 | } | ||
| 451 | + | ||||
| 452 | + | func TestStateSnapshot(t *testing.T) { | |||
| 453 | + | r := startMulti(t, nil, left, right) | |||
| 454 | + | r.client <- ports.WindowMapped{ID: 1, AppID: "foot", PID: 100} | |||
| 455 | + | receive(t, r.scenes) | |||
| 456 | + | r.key(t, "Right", ports.ModAlt|ports.ModCtrl) | |||
| 457 | + | <-r.state // the channel holds the latest snapshot only | |||
| 458 | + | r.client <- ports.WindowMapped{ID: 2, AppID: "firefox", PID: 200} | |||
| 459 | + | receive(t, r.scenes) | |||
| 460 | + | st := receive(t, r.state) | |||
| 461 | + | want := ports.State{ | |||
| 462 | + | Output: "DP-2", | |||
| 463 | + | Outputs: []ports.OutputState{{Name: "DP-1", Active: 1, Count: 1}, {Name: "DP-2", Active: 1, Count: 1}}, | |||
| 464 | + | Windows: []ports.WindowState{ | |||
| 465 | + | {ID: 1, AppID: "foot", PID: 100, Output: "DP-1", Workspace: 1, Visible: true}, | |||
| 466 | + | {ID: 2, AppID: "firefox", PID: 200, Output: "DP-2", Workspace: 1, Visible: true}, | |||
| 467 | + | }, | |||
| 468 | + | } | |||
| 469 | + | want.Window = &want.Windows[1] | |||
| 470 | + | if !reflect.DeepEqual(st, want) { | |||
| 471 | + | t.Fatalf("got %+v\nwant %+v", st, want) | |||
| 472 | + | } | |||
| 473 | + | // Moving to workspace 2 of DP-2: two workspaces there, window 2 off screen. | |||
| 474 | + | r.input <- ports.KeyEvent{Keysym: "2", Keycode: 3, Mods: ports.ModAlt, Pressed: true} | |||
| 475 | + | receive(t, r.scenes) | |||
| 476 | + | st = receive(t, r.state) | |||
| 477 | + | if st.Outputs[1] != (ports.OutputState{Name: "DP-2", Active: 2, Count: 2}) || st.Windows[1].Visible || st.Window != nil { | |||
| 478 | + | t.Fatalf("%+v", st) | |||
| 479 | + | } | |||
| 480 | + | } | |||
| 481 | + | ||||
| 482 | + | func TestStateFollowsAppIDAndHiddenWorkspace(t *testing.T) { | |||
| 483 | + | r := startMulti(t, func(c *ports.Config) { | |||
| 484 | + | c.Workspaces = []ports.WorkspaceConfig{{Name: "notes", Hidden: true}} | |||
| 485 | + | c.Binds["Alt+n"] = "workspace notes" | |||
| 486 | + | }, left) | |||
| 487 | + | r.client <- ports.WindowMapped{ID: 1, PID: 100} | |||
| 488 | + | receive(t, r.scenes) | |||
| 489 | + | <-r.state | |||
| 490 | + | // An app ID set after map reaches the state. | |||
| 491 | + | r.client <- ports.WindowAppID{ID: 1, AppID: "foot"} | |||
| 492 | + | receive(t, r.scenes) | |||
| 493 | + | if st := receive(t, r.state); st.Windows[0].AppID != "foot" { | |||
| 494 | + | t.Fatalf("%+v", st) | |||
| 495 | + | } | |||
| 496 | + | // A hidden workspace on screen: no active number, its name, window 1 off screen. | |||
| 497 | + | r.key(t, "n", ports.ModAlt) | |||
| 498 | + | st := receive(t, r.state) | |||
| 499 | + | if st.Outputs[0].Active != 0 || st.Outputs[0].Workspace != "notes" || st.Windows[0].Visible { | |||
| 500 | + | t.Fatalf("%+v", st) | |||
| 501 | + | } | |||
| 502 | + | } | |||
A internal/core/state.go +67 -0
| @@ -0,0 +1,67 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "cmp" | |
| 5 | + | "reflect" | |
| 6 | + | "slices" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/nefertty/internal/ports" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // publishState sends the script-facing snapshot when it changed. | |
| 12 | + | func (c *Core) publishState() { | |
| 13 | + | if c.ch.State == nil { | |
| 14 | + | return | |
| 15 | + | } | |
| 16 | + | st := c.state() | |
| 17 | + | if reflect.DeepEqual(st, c.sentState) { | |
| 18 | + | return | |
| 19 | + | } | |
| 20 | + | c.sentState = st | |
| 21 | + | latest(c.ch.State, st) | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // state builds the snapshot. The placeholder screen is not an output. | |
| 25 | + | func (c *Core) state() ports.State { | |
| 26 | + | st := ports.State{Output: c.cur().name(), Outputs: []ports.OutputState{}, Windows: []ports.WindowState{}} | |
| 27 | + | focus, _ := c.cur().mon.Focused() | |
| 28 | + | for _, s := range c.screens { | |
| 29 | + | if s.name() == "" { | |
| 30 | + | continue | |
| 31 | + | } | |
| 32 | + | m := s.mon | |
| 33 | + | cur := m.Current() | |
| 34 | + | o := ports.OutputState{Name: s.name(), Count: numbered(m), Workspace: cur.Name} | |
| 35 | + | if i := indexOf(m.Workspaces, cur); i >= 0 { | |
| 36 | + | o.Active = i + 1 | |
| 37 | + | } | |
| 38 | + | st.Outputs = append(st.Outputs, o) | |
| 39 | + | for _, w := range m.all() { | |
| 40 | + | n := indexOf(m.Workspaces, w) + 1 | |
| 41 | + | for _, col := range w.Columns { | |
| 42 | + | for _, id := range col.Windows { | |
| 43 | + | info := c.clients[id] | |
| 44 | + | st.Windows = append(st.Windows, ports.WindowState{ID: id, AppID: info.AppID, PID: info.PID, Output: s.name(), Workspace: n, Visible: w == cur}) | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | slices.SortFunc(st.Windows, func(a, b ports.WindowState) int { return cmp.Compare(a.ID, b.ID) }) | |
| 50 | + | for i := range st.Windows { | |
| 51 | + | if st.Windows[i].ID == focus && focus != 0 { | |
| 52 | + | w := st.Windows[i] | |
| 53 | + | st.Window = &w | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | return st | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | // numbered counts numbered workspaces, without the trailing spare one | |
| 60 | + | // unless it is on screen. | |
| 61 | + | func numbered(m *Monitor) int { | |
| 62 | + | n := len(m.Workspaces) | |
| 63 | + | if last := m.Workspaces[n-1]; n > 1 && last.empty() && last.Name == "" && m.Current() != last { | |
| 64 | + | n-- | |
| 65 | + | } | |
| 66 | + | return n | |
| 67 | + | } | |
| @@ -0,0 +1,67 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "cmp" | |||
| 5 | + | "reflect" | |||
| 6 | + | "slices" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/nefertty/internal/ports" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // publishState sends the script-facing snapshot when it changed. | |||
| 12 | + | func (c *Core) publishState() { | |||
| 13 | + | if c.ch.State == nil { | |||
| 14 | + | return | |||
| 15 | + | } | |||
| 16 | + | st := c.state() | |||
| 17 | + | if reflect.DeepEqual(st, c.sentState) { | |||
| 18 | + | return | |||
| 19 | + | } | |||
| 20 | + | c.sentState = st | |||
| 21 | + | latest(c.ch.State, st) | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | // state builds the snapshot. The placeholder screen is not an output. | |||
| 25 | + | func (c *Core) state() ports.State { | |||
| 26 | + | st := ports.State{Output: c.cur().name(), Outputs: []ports.OutputState{}, Windows: []ports.WindowState{}} | |||
| 27 | + | focus, _ := c.cur().mon.Focused() | |||
| 28 | + | for _, s := range c.screens { | |||
| 29 | + | if s.name() == "" { | |||
| 30 | + | continue | |||
| 31 | + | } | |||
| 32 | + | m := s.mon | |||
| 33 | + | cur := m.Current() | |||
| 34 | + | o := ports.OutputState{Name: s.name(), Count: numbered(m), Workspace: cur.Name} | |||
| 35 | + | if i := indexOf(m.Workspaces, cur); i >= 0 { | |||
| 36 | + | o.Active = i + 1 | |||
| 37 | + | } | |||
| 38 | + | st.Outputs = append(st.Outputs, o) | |||
| 39 | + | for _, w := range m.all() { | |||
| 40 | + | n := indexOf(m.Workspaces, w) + 1 | |||
| 41 | + | for _, col := range w.Columns { | |||
| 42 | + | for _, id := range col.Windows { | |||
| 43 | + | info := c.clients[id] | |||
| 44 | + | st.Windows = append(st.Windows, ports.WindowState{ID: id, AppID: info.AppID, PID: info.PID, Output: s.name(), Workspace: n, Visible: w == cur}) | |||
| 45 | + | } | |||
| 46 | + | } | |||
| 47 | + | } | |||
| 48 | + | } | |||
| 49 | + | slices.SortFunc(st.Windows, func(a, b ports.WindowState) int { return cmp.Compare(a.ID, b.ID) }) | |||
| 50 | + | for i := range st.Windows { | |||
| 51 | + | if st.Windows[i].ID == focus && focus != 0 { | |||
| 52 | + | w := st.Windows[i] | |||
| 53 | + | st.Window = &w | |||
| 54 | + | } | |||
| 55 | + | } | |||
| 56 | + | return st | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | // numbered counts numbered workspaces, without the trailing spare one | |||
| 60 | + | // unless it is on screen. | |||
| 61 | + | func numbered(m *Monitor) int { | |||
| 62 | + | n := len(m.Workspaces) | |||
| 63 | + | if last := m.Workspaces[n-1]; n > 1 && last.empty() && last.Name == "" && m.Current() != last { | |||
| 64 | + | n-- | |||
| 65 | + | } | |||
| 66 | + | return n | |||
| 67 | + | } | |||
M internal/ports/messages.go +43 -0
| @@ -9,6 +9,8 @@ type WindowMapped struct { | |||
| 9 | 9 | AppID string | |
| 10 | 10 | // Slot is the SlotEnv value of the client process, empty if none. | |
| 11 | 11 | Slot string | |
| 12 | + | // PID is the client process, 0 when unknown. | |
| 13 | + | PID int | |
| 12 | 14 | } | |
| 13 | 15 | ||
| 14 | 16 | // SlotEnv is the environment variable nefertty sets on processes it spawns | |
| @@ -30,6 +32,14 @@ type WindowFullscreenRequest struct { | |||
| 30 | 32 | ||
| 31 | 33 | func (WindowFullscreenRequest) clientEvent() {} | |
| 32 | 34 | ||
| 35 | + | // WindowAppID carries wayland → core an app ID set after the window mapped. | |
| 36 | + | type WindowAppID struct { | |
| 37 | + | ID WindowID | |
| 38 | + | AppID string | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func (WindowAppID) clientEvent() {} | |
| 42 | + | ||
| 33 | 43 | // Layer is a wlr-layer-shell stacking layer. | |
| 34 | 44 | type Layer uint32 | |
| 35 | 45 | ||
| @@ -350,3 +360,36 @@ type SceneWindow struct { | |||
| 350 | 360 | // Borderless windows fill the usable width alone; no border is drawn. | |
| 351 | 361 | Borderless bool | |
| 352 | 362 | } | |
| 363 | + | ||
| 364 | + | // State carries core → state publisher a snapshot of what is on screen, for | |
| 365 | + | // scripts (bars, status lines). Workspace numbers count from 1. | |
| 366 | + | type State struct { | |
| 367 | + | // Output is the focused output; "" before the first one. | |
| 368 | + | Output string | |
| 369 | + | Outputs []OutputState | |
| 370 | + | // Window is the focused window, nil when none. | |
| 371 | + | Window *WindowState | |
| 372 | + | Windows []WindowState | |
| 373 | + | } | |
| 374 | + | ||
| 375 | + | // OutputState is one output: its workspace on screen and how many numbered | |
| 376 | + | // workspaces it has. Active is 0 while a hidden (named) workspace is shown. | |
| 377 | + | type OutputState struct { | |
| 378 | + | Name string | |
| 379 | + | Active int | |
| 380 | + | Count int | |
| 381 | + | Workspace string // name of the workspace on screen, "" if unnamed | |
| 382 | + | } | |
| 383 | + | ||
| 384 | + | // WindowState is one mapped window and where it is. | |
| 385 | + | type WindowState struct { | |
| 386 | + | ID WindowID | |
| 387 | + | AppID string | |
| 388 | + | PID int | |
| 389 | + | Output string | |
| 390 | + | // Workspace is the window's numbered workspace, 0 for a hidden one. | |
| 391 | + | Workspace int | |
| 392 | + | // Visible means on the workspace on screen (it may be behind a | |
| 393 | + | // fullscreen window). | |
| 394 | + | Visible bool | |
| 395 | + | } | |
| @@ -9,6 +9,8 @@ type WindowMapped struct { | |||||
| 9 | AppID string | 9 | AppID string | ||
| 10 | // Slot is the SlotEnv value of the client process, empty if none. | 10 | // Slot is the SlotEnv value of the client process, empty if none. | ||
| 11 | Slot string | 11 | Slot string | ||
| 12 | + | // PID is the client process, 0 when unknown. | |||
| 13 | + | PID int | |||
| 12 | } | 14 | } | ||
| 13 | 15 | ||||
| 14 | // SlotEnv is the environment variable nefertty sets on processes it spawns | 16 | // SlotEnv is the environment variable nefertty sets on processes it spawns | ||
| @@ -30,6 +32,14 @@ type WindowFullscreenRequest struct { | |||||
| 30 | 32 | ||||
| 31 | func (WindowFullscreenRequest) clientEvent() {} | 33 | func (WindowFullscreenRequest) clientEvent() {} | ||
| 32 | 34 | ||||
| 35 | + | // WindowAppID carries wayland → core an app ID set after the window mapped. | |||
| 36 | + | type WindowAppID struct { | |||
| 37 | + | ID WindowID | |||
| 38 | + | AppID string | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func (WindowAppID) clientEvent() {} | |||
| 42 | + | ||||
| 33 | // Layer is a wlr-layer-shell stacking layer. | 43 | // Layer is a wlr-layer-shell stacking layer. | ||
| 34 | type Layer uint32 | 44 | type Layer uint32 | ||
| 35 | 45 | ||||
| @@ -350,3 +360,36 @@ type SceneWindow struct { | |||||
| 350 | // Borderless windows fill the usable width alone; no border is drawn. | 360 | // Borderless windows fill the usable width alone; no border is drawn. | ||
| 351 | Borderless bool | 361 | Borderless bool | ||
| 352 | } | 362 | } | ||
| 363 | + | ||||
| 364 | + | // State carries core → state publisher a snapshot of what is on screen, for | |||
| 365 | + | // scripts (bars, status lines). Workspace numbers count from 1. | |||
| 366 | + | type State struct { | |||
| 367 | + | // Output is the focused output; "" before the first one. | |||
| 368 | + | Output string | |||
| 369 | + | Outputs []OutputState | |||
| 370 | + | // Window is the focused window, nil when none. | |||
| 371 | + | Window *WindowState | |||
| 372 | + | Windows []WindowState | |||
| 373 | + | } | |||
| 374 | + | ||||
| 375 | + | // OutputState is one output: its workspace on screen and how many numbered | |||
| 376 | + | // workspaces it has. Active is 0 while a hidden (named) workspace is shown. | |||
| 377 | + | type OutputState struct { | |||
| 378 | + | Name string | |||
| 379 | + | Active int | |||
| 380 | + | Count int | |||
| 381 | + | Workspace string // name of the workspace on screen, "" if unnamed | |||
| 382 | + | } | |||
| 383 | + | ||||
| 384 | + | // WindowState is one mapped window and where it is. | |||
| 385 | + | type WindowState struct { | |||
| 386 | + | ID WindowID | |||
| 387 | + | AppID string | |||
| 388 | + | PID int | |||
| 389 | + | Output string | |||
| 390 | + | // Workspace is the window's numbered workspace, 0 for a hidden one. | |||
| 391 | + | Workspace int | |||
| 392 | + | // Visible means on the workspace on screen (it may be behind a | |||
| 393 | + | // fullscreen window). | |||
| 394 | + | Visible bool | |||
| 395 | + | } | |||