feat(ports): add ImagePush wire message
Adds MsgImagePush (client-originated type 10) carrying a clipboard image's MIME type and bytes, plus a ClipboardReader port for the client-side seam that reads it. Bumps ProtocolVersion to 6 (strict equality negotiation). Lays the wire groundwork for transferring a clipboard image to a remote pane as a temp-file path (see docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md).
- commit
- 383c02c4ba3c41dbe5ea2387a2fe60b52c397d6c
- author
- brice · · 2026-07-04 14:47 UTC
- parent
- f87757e
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +189 -12
M internal/ports/frame.go +11 -10
| @@ -1,7 +1,7 @@ | |||
| 1 | 1 | package ports | |
| 2 | 2 | ||
| 3 | 3 | // ProtocolVersion is the current vev IPC wire protocol version. | |
| 4 | - | const ProtocolVersion uint16 = 5 | |
| 4 | + | const ProtocolVersion uint16 = 6 | |
| 5 | 5 | ||
| 6 | 6 | // MsgType identifies the kind of payload carried by a Frame. | |
| 7 | 7 | type MsgType uint8 | |
| @@ -10,15 +10,16 @@ type MsgType uint8 | |||
| 10 | 10 | // through 15, server-originated messages from 16, leaving room for growth in | |
| 11 | 11 | // each band. | |
| 12 | 12 | const ( | |
| 13 | - | MsgHello MsgType = 1 | |
| 14 | - | MsgInput MsgType = 2 | |
| 15 | - | MsgResize MsgType = 3 | |
| 16 | - | MsgDetach MsgType = 4 | |
| 17 | - | MsgPing MsgType = 5 | |
| 18 | - | MsgList MsgType = 6 | |
| 19 | - | MsgKill MsgType = 7 | |
| 20 | - | MsgTheme MsgType = 8 | |
| 21 | - | MsgAck MsgType = 9 | |
| 13 | + | MsgHello MsgType = 1 | |
| 14 | + | MsgInput MsgType = 2 | |
| 15 | + | MsgResize MsgType = 3 | |
| 16 | + | MsgDetach MsgType = 4 | |
| 17 | + | MsgPing MsgType = 5 | |
| 18 | + | MsgList MsgType = 6 | |
| 19 | + | MsgKill MsgType = 7 | |
| 20 | + | MsgTheme MsgType = 8 | |
| 21 | + | MsgAck MsgType = 9 | |
| 22 | + | MsgImagePush MsgType = 10 | |
| 22 | 23 | ||
| 23 | 24 | MsgWelcome MsgType = 16 | |
| 24 | 25 | MsgError MsgType = 17 | |
| @@ -1,7 +1,7 @@ | |||||
| 1 | package ports | 1 | package ports | ||
| 2 | 2 | ||||
| 3 | // ProtocolVersion is the current vev IPC wire protocol version. | 3 | // ProtocolVersion is the current vev IPC wire protocol version. | ||
| 4 | - | const ProtocolVersion uint16 = 5 | 4 | + | const ProtocolVersion uint16 = 6 |
| 5 | 5 | ||||
| 6 | // MsgType identifies the kind of payload carried by a Frame. | 6 | // MsgType identifies the kind of payload carried by a Frame. | ||
| 7 | type MsgType uint8 | 7 | type MsgType uint8 | ||
| @@ -10,15 +10,16 @@ type MsgType uint8 | |||||
| 10 | // through 15, server-originated messages from 16, leaving room for growth in | 10 | // through 15, server-originated messages from 16, leaving room for growth in | ||
| 11 | // each band. | 11 | // each band. | ||
| 12 | const ( | 12 | const ( | ||
| 13 | - | MsgHello MsgType = 1 | 13 | + | MsgHello MsgType = 1 |
| 14 | - | MsgInput MsgType = 2 | 14 | + | MsgInput MsgType = 2 |
| 15 | - | MsgResize MsgType = 3 | 15 | + | MsgResize MsgType = 3 |
| 16 | - | MsgDetach MsgType = 4 | 16 | + | MsgDetach MsgType = 4 |
| 17 | - | MsgPing MsgType = 5 | 17 | + | MsgPing MsgType = 5 |
| 18 | - | MsgList MsgType = 6 | 18 | + | MsgList MsgType = 6 |
| 19 | - | MsgKill MsgType = 7 | 19 | + | MsgKill MsgType = 7 |
| 20 | - | MsgTheme MsgType = 8 | 20 | + | MsgTheme MsgType = 8 |
| 21 | - | MsgAck MsgType = 9 | 21 | + | MsgAck MsgType = 9 |
| 22 | + | MsgImagePush MsgType = 10 | |||
| 22 | 23 | ||||
| 23 | MsgWelcome MsgType = 16 | 24 | MsgWelcome MsgType = 16 | ||
| 24 | MsgError MsgType = 17 | 25 | MsgError MsgType = 17 | ||
M internal/ports/frame_test.go +3 -2
| @@ -3,8 +3,8 @@ package ports | |||
| 3 | 3 | import "testing" | |
| 4 | 4 | ||
| 5 | 5 | func TestProtocolVersion(t *testing.T) { | |
| 6 | - | if ProtocolVersion != 5 { | |
| 7 | - | t.Fatalf("ProtocolVersion = %d, want 5", ProtocolVersion) | |
| 6 | + | if ProtocolVersion != 6 { | |
| 7 | + | t.Fatalf("ProtocolVersion = %d, want 6", ProtocolVersion) | |
| 8 | 8 | } | |
| 9 | 9 | } | |
| 10 | 10 | ||
| @@ -22,6 +22,7 @@ func TestMsgTypeUnique(t *testing.T) { | |||
| 22 | 22 | {"MsgKill", MsgKill}, | |
| 23 | 23 | {"MsgTheme", MsgTheme}, | |
| 24 | 24 | {"MsgAck", MsgAck}, | |
| 25 | + | {"MsgImagePush", MsgImagePush}, | |
| 25 | 26 | {"MsgWelcome", MsgWelcome}, | |
| 26 | 27 | {"MsgError", MsgError}, | |
| 27 | 28 | {"MsgOutput", MsgOutput}, | |
| @@ -3,8 +3,8 @@ package ports | |||||
| 3 | import "testing" | 3 | import "testing" | ||
| 4 | 4 | ||||
| 5 | func TestProtocolVersion(t *testing.T) { | 5 | func TestProtocolVersion(t *testing.T) { | ||
| 6 | - | if ProtocolVersion != 5 { | 6 | + | if ProtocolVersion != 6 { |
| 7 | - | t.Fatalf("ProtocolVersion = %d, want 5", ProtocolVersion) | 7 | + | t.Fatalf("ProtocolVersion = %d, want 6", ProtocolVersion) |
| 8 | } | 8 | } | ||
| 9 | } | 9 | } | ||
| 10 | 10 | ||||
| @@ -22,6 +22,7 @@ func TestMsgTypeUnique(t *testing.T) { | |||||
| 22 | {"MsgKill", MsgKill}, | 22 | {"MsgKill", MsgKill}, | ||
| 23 | {"MsgTheme", MsgTheme}, | 23 | {"MsgTheme", MsgTheme}, | ||
| 24 | {"MsgAck", MsgAck}, | 24 | {"MsgAck", MsgAck}, | ||
| 25 | + | {"MsgImagePush", MsgImagePush}, | |||
| 25 | {"MsgWelcome", MsgWelcome}, | 26 | {"MsgWelcome", MsgWelcome}, | ||
| 26 | {"MsgError", MsgError}, | 27 | {"MsgError", MsgError}, | ||
| 27 | {"MsgOutput", MsgOutput}, | 28 | {"MsgOutput", MsgOutput}, | ||
M internal/ports/mocks/mocks.go +95 -0
| @@ -1833,3 +1833,98 @@ func (_c *MockTimer_Stop_Call) RunAndReturn(run func() bool) *MockTimer_Stop_Cal | |||
| 1833 | 1833 | _c.Call.Return(run) | |
| 1834 | 1834 | return _c | |
| 1835 | 1835 | } | |
| 1836 | + | ||
| 1837 | + | // NewMockClipboardReader creates a new instance of MockClipboardReader. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 1838 | + | // The first argument is typically a *testing.T value. | |
| 1839 | + | func NewMockClipboardReader(t interface { | |
| 1840 | + | mock.TestingT | |
| 1841 | + | Cleanup(func()) | |
| 1842 | + | }) *MockClipboardReader { | |
| 1843 | + | mock := &MockClipboardReader{} | |
| 1844 | + | mock.Mock.Test(t) | |
| 1845 | + | ||
| 1846 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 1847 | + | ||
| 1848 | + | return mock | |
| 1849 | + | } | |
| 1850 | + | ||
| 1851 | + | // MockClipboardReader is an autogenerated mock type for the ClipboardReader type | |
| 1852 | + | type MockClipboardReader struct { | |
| 1853 | + | mock.Mock | |
| 1854 | + | } | |
| 1855 | + | ||
| 1856 | + | type MockClipboardReader_Expecter struct { | |
| 1857 | + | mock *mock.Mock | |
| 1858 | + | } | |
| 1859 | + | ||
| 1860 | + | func (_m *MockClipboardReader) EXPECT() *MockClipboardReader_Expecter { | |
| 1861 | + | return &MockClipboardReader_Expecter{mock: &_m.Mock} | |
| 1862 | + | } | |
| 1863 | + | ||
| 1864 | + | // ReadImage provides a mock function for the type MockClipboardReader | |
| 1865 | + | func (_mock *MockClipboardReader) ReadImage(ctx context.Context) (string, []byte, error) { | |
| 1866 | + | ret := _mock.Called(ctx) | |
| 1867 | + | ||
| 1868 | + | if len(ret) == 0 { | |
| 1869 | + | panic("no return value specified for ReadImage") | |
| 1870 | + | } | |
| 1871 | + | ||
| 1872 | + | var r0 string | |
| 1873 | + | var r1 []byte | |
| 1874 | + | var r2 error | |
| 1875 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) (string, []byte, error)); ok { | |
| 1876 | + | return returnFunc(ctx) | |
| 1877 | + | } | |
| 1878 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) string); ok { | |
| 1879 | + | r0 = returnFunc(ctx) | |
| 1880 | + | } else { | |
| 1881 | + | r0 = ret.Get(0).(string) | |
| 1882 | + | } | |
| 1883 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) []byte); ok { | |
| 1884 | + | r1 = returnFunc(ctx) | |
| 1885 | + | } else { | |
| 1886 | + | if ret.Get(1) != nil { | |
| 1887 | + | r1 = ret.Get(1).([]byte) | |
| 1888 | + | } | |
| 1889 | + | } | |
| 1890 | + | if returnFunc, ok := ret.Get(2).(func(context.Context) error); ok { | |
| 1891 | + | r2 = returnFunc(ctx) | |
| 1892 | + | } else { | |
| 1893 | + | r2 = ret.Error(2) | |
| 1894 | + | } | |
| 1895 | + | return r0, r1, r2 | |
| 1896 | + | } | |
| 1897 | + | ||
| 1898 | + | // MockClipboardReader_ReadImage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReadImage' | |
| 1899 | + | type MockClipboardReader_ReadImage_Call struct { | |
| 1900 | + | *mock.Call | |
| 1901 | + | } | |
| 1902 | + | ||
| 1903 | + | // ReadImage is a helper method to define mock.On call | |
| 1904 | + | // - ctx context.Context | |
| 1905 | + | func (_e *MockClipboardReader_Expecter) ReadImage(ctx any) *MockClipboardReader_ReadImage_Call { | |
| 1906 | + | return &MockClipboardReader_ReadImage_Call{Call: _e.mock.On("ReadImage", ctx)} | |
| 1907 | + | } | |
| 1908 | + | ||
| 1909 | + | func (_c *MockClipboardReader_ReadImage_Call) Run(run func(ctx context.Context)) *MockClipboardReader_ReadImage_Call { | |
| 1910 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 1911 | + | var arg0 context.Context | |
| 1912 | + | if args[0] != nil { | |
| 1913 | + | arg0 = args[0].(context.Context) | |
| 1914 | + | } | |
| 1915 | + | run( | |
| 1916 | + | arg0, | |
| 1917 | + | ) | |
| 1918 | + | }) | |
| 1919 | + | return _c | |
| 1920 | + | } | |
| 1921 | + | ||
| 1922 | + | func (_c *MockClipboardReader_ReadImage_Call) Return(mime string, data []byte, err error) *MockClipboardReader_ReadImage_Call { | |
| 1923 | + | _c.Call.Return(mime, data, err) | |
| 1924 | + | return _c | |
| 1925 | + | } | |
| 1926 | + | ||
| 1927 | + | func (_c *MockClipboardReader_ReadImage_Call) RunAndReturn(run func(ctx context.Context) (string, []byte, error)) *MockClipboardReader_ReadImage_Call { | |
| 1928 | + | _c.Call.Return(run) | |
| 1929 | + | return _c | |
| 1930 | + | } | |
| @@ -1833,3 +1833,98 @@ func (_c *MockTimer_Stop_Call) RunAndReturn(run func() bool) *MockTimer_Stop_Cal | |||||
| 1833 | _c.Call.Return(run) | 1833 | _c.Call.Return(run) | ||
| 1834 | return _c | 1834 | return _c | ||
| 1835 | } | 1835 | } | ||
| 1836 | + | ||||
| 1837 | + | // NewMockClipboardReader creates a new instance of MockClipboardReader. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 1838 | + | // The first argument is typically a *testing.T value. | |||
| 1839 | + | func NewMockClipboardReader(t interface { | |||
| 1840 | + | mock.TestingT | |||
| 1841 | + | Cleanup(func()) | |||
| 1842 | + | }) *MockClipboardReader { | |||
| 1843 | + | mock := &MockClipboardReader{} | |||
| 1844 | + | mock.Mock.Test(t) | |||
| 1845 | + | ||||
| 1846 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |||
| 1847 | + | ||||
| 1848 | + | return mock | |||
| 1849 | + | } | |||
| 1850 | + | ||||
| 1851 | + | // MockClipboardReader is an autogenerated mock type for the ClipboardReader type | |||
| 1852 | + | type MockClipboardReader struct { | |||
| 1853 | + | mock.Mock | |||
| 1854 | + | } | |||
| 1855 | + | ||||
| 1856 | + | type MockClipboardReader_Expecter struct { | |||
| 1857 | + | mock *mock.Mock | |||
| 1858 | + | } | |||
| 1859 | + | ||||
| 1860 | + | func (_m *MockClipboardReader) EXPECT() *MockClipboardReader_Expecter { | |||
| 1861 | + | return &MockClipboardReader_Expecter{mock: &_m.Mock} | |||
| 1862 | + | } | |||
| 1863 | + | ||||
| 1864 | + | // ReadImage provides a mock function for the type MockClipboardReader | |||
| 1865 | + | func (_mock *MockClipboardReader) ReadImage(ctx context.Context) (string, []byte, error) { | |||
| 1866 | + | ret := _mock.Called(ctx) | |||
| 1867 | + | ||||
| 1868 | + | if len(ret) == 0 { | |||
| 1869 | + | panic("no return value specified for ReadImage") | |||
| 1870 | + | } | |||
| 1871 | + | ||||
| 1872 | + | var r0 string | |||
| 1873 | + | var r1 []byte | |||
| 1874 | + | var r2 error | |||
| 1875 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) (string, []byte, error)); ok { | |||
| 1876 | + | return returnFunc(ctx) | |||
| 1877 | + | } | |||
| 1878 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) string); ok { | |||
| 1879 | + | r0 = returnFunc(ctx) | |||
| 1880 | + | } else { | |||
| 1881 | + | r0 = ret.Get(0).(string) | |||
| 1882 | + | } | |||
| 1883 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) []byte); ok { | |||
| 1884 | + | r1 = returnFunc(ctx) | |||
| 1885 | + | } else { | |||
| 1886 | + | if ret.Get(1) != nil { | |||
| 1887 | + | r1 = ret.Get(1).([]byte) | |||
| 1888 | + | } | |||
| 1889 | + | } | |||
| 1890 | + | if returnFunc, ok := ret.Get(2).(func(context.Context) error); ok { | |||
| 1891 | + | r2 = returnFunc(ctx) | |||
| 1892 | + | } else { | |||
| 1893 | + | r2 = ret.Error(2) | |||
| 1894 | + | } | |||
| 1895 | + | return r0, r1, r2 | |||
| 1896 | + | } | |||
| 1897 | + | ||||
| 1898 | + | // MockClipboardReader_ReadImage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReadImage' | |||
| 1899 | + | type MockClipboardReader_ReadImage_Call struct { | |||
| 1900 | + | *mock.Call | |||
| 1901 | + | } | |||
| 1902 | + | ||||
| 1903 | + | // ReadImage is a helper method to define mock.On call | |||
| 1904 | + | // - ctx context.Context | |||
| 1905 | + | func (_e *MockClipboardReader_Expecter) ReadImage(ctx any) *MockClipboardReader_ReadImage_Call { | |||
| 1906 | + | return &MockClipboardReader_ReadImage_Call{Call: _e.mock.On("ReadImage", ctx)} | |||
| 1907 | + | } | |||
| 1908 | + | ||||
| 1909 | + | func (_c *MockClipboardReader_ReadImage_Call) Run(run func(ctx context.Context)) *MockClipboardReader_ReadImage_Call { | |||
| 1910 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 1911 | + | var arg0 context.Context | |||
| 1912 | + | if args[0] != nil { | |||
| 1913 | + | arg0 = args[0].(context.Context) | |||
| 1914 | + | } | |||
| 1915 | + | run( | |||
| 1916 | + | arg0, | |||
| 1917 | + | ) | |||
| 1918 | + | }) | |||
| 1919 | + | return _c | |||
| 1920 | + | } | |||
| 1921 | + | ||||
| 1922 | + | func (_c *MockClipboardReader_ReadImage_Call) Return(mime string, data []byte, err error) *MockClipboardReader_ReadImage_Call { | |||
| 1923 | + | _c.Call.Return(mime, data, err) | |||
| 1924 | + | return _c | |||
| 1925 | + | } | |||
| 1926 | + | ||||
| 1927 | + | func (_c *MockClipboardReader_ReadImage_Call) RunAndReturn(run func(ctx context.Context) (string, []byte, error)) *MockClipboardReader_ReadImage_Call { | |||
| 1928 | + | _c.Call.Return(run) | |||
| 1929 | + | return _c | |||
| 1930 | + | } | |||
M internal/ports/ports.go +18 -0
| @@ -2,12 +2,18 @@ package ports | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 5 | 6 | "io" | |
| 6 | 7 | "time" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/bnema/vev/internal/domain" | |
| 9 | 10 | ) | |
| 10 | 11 | ||
| 12 | + | // ErrNoClipboardImage is returned by ClipboardReader.ReadImage when the | |
| 13 | + | // clipboard holds no image (or an unsupported type). Callers fall back to | |
| 14 | + | // forwarding whatever keystroke triggered the read. | |
| 15 | + | var ErrNoClipboardImage = errors.New("ports: no image on clipboard") | |
| 16 | + | ||
| 11 | 17 | // PTY is a running pseudo-terminal-backed child process. | |
| 12 | 18 | type PTY interface { | |
| 13 | 19 | io.ReadWriteCloser // Read = child output, Write = child input | |
| @@ -75,3 +81,15 @@ type Timer interface { | |||
| 75 | 81 | Reset(d time.Duration) bool | |
| 76 | 82 | Stop() bool | |
| 77 | 83 | } | |
| 84 | + | ||
| 85 | + | // ClipboardReader reads an image off the CLIENT-side system clipboard, for | |
| 86 | + | // forwarding to a remote session's focused pane. Implementations live in | |
| 87 | + | // internal/adapters (e.g. a wl-paste-backed one for Wayland); usecases only | |
| 88 | + | // see this interface. See | |
| 89 | + | // docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md. | |
| 90 | + | type ClipboardReader interface { | |
| 91 | + | // ReadImage returns the clipboard's image bytes and MIME type. It returns | |
| 92 | + | // ErrNoClipboardImage when the clipboard holds no image (or an | |
| 93 | + | // unsupported type). | |
| 94 | + | ReadImage(ctx context.Context) (mime string, data []byte, err error) | |
| 95 | + | } | |
| @@ -2,12 +2,18 @@ package ports | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "errors" | |||
| 5 | "io" | 6 | "io" | ||
| 6 | "time" | 7 | "time" | ||
| 7 | 8 | ||||
| 8 | "github.com/bnema/vev/internal/domain" | 9 | "github.com/bnema/vev/internal/domain" | ||
| 9 | ) | 10 | ) | ||
| 10 | 11 | ||||
| 12 | + | // ErrNoClipboardImage is returned by ClipboardReader.ReadImage when the | |||
| 13 | + | // clipboard holds no image (or an unsupported type). Callers fall back to | |||
| 14 | + | // forwarding whatever keystroke triggered the read. | |||
| 15 | + | var ErrNoClipboardImage = errors.New("ports: no image on clipboard") | |||
| 16 | + | ||||
| 11 | // PTY is a running pseudo-terminal-backed child process. | 17 | // PTY is a running pseudo-terminal-backed child process. | ||
| 12 | type PTY interface { | 18 | type PTY interface { | ||
| 13 | io.ReadWriteCloser // Read = child output, Write = child input | 19 | io.ReadWriteCloser // Read = child output, Write = child input | ||
| @@ -75,3 +81,15 @@ type Timer interface { | |||||
| 75 | Reset(d time.Duration) bool | 81 | Reset(d time.Duration) bool | ||
| 76 | Stop() bool | 82 | Stop() bool | ||
| 77 | } | 83 | } | ||
| 84 | + | ||||
| 85 | + | // ClipboardReader reads an image off the CLIENT-side system clipboard, for | |||
| 86 | + | // forwarding to a remote session's focused pane. Implementations live in | |||
| 87 | + | // internal/adapters (e.g. a wl-paste-backed one for Wayland); usecases only | |||
| 88 | + | // see this interface. See | |||
| 89 | + | // docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md. | |||
| 90 | + | type ClipboardReader interface { | |||
| 91 | + | // ReadImage returns the clipboard's image bytes and MIME type. It returns | |||
| 92 | + | // ErrNoClipboardImage when the clipboard holds no image (or an | |||
| 93 | + | // unsupported type). | |||
| 94 | + | ReadImage(ctx context.Context) (mime string, data []byte, err error) | |||
| 95 | + | } | |||
M internal/ports/wire.go +28 -0
| @@ -87,6 +87,14 @@ type Theme struct { | |||
| 87 | 87 | TrueColor bool | |
| 88 | 88 | } | |
| 89 | 89 | ||
| 90 | + | // ImagePush carries a clipboard image from a remote client, to be written to | |
| 91 | + | // a temp file and injected into the focused pane's PTY as a path (see | |
| 92 | + | // docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md). | |
| 93 | + | type ImagePush struct { | |
| 94 | + | Mime string | |
| 95 | + | Data []byte | |
| 96 | + | } | |
| 97 | + | ||
| 90 | 98 | // Detach asks the daemon to detach the current client without killing the | |
| 91 | 99 | // session. | |
| 92 | 100 | type Detach struct{} | |
| @@ -357,6 +365,26 @@ func UnmarshalInput(b []byte) (Input, error) { | |||
| 357 | 365 | return Input{InputSeq: seq, Data: r.rest()}, nil | |
| 358 | 366 | } | |
| 359 | 367 | ||
| 368 | + | // MarshalImagePush encodes m into an ImagePush message payload. | |
| 369 | + | func MarshalImagePush(m ImagePush) []byte { | |
| 370 | + | w := payloadWriter{} | |
| 371 | + | w.putString(m.Mime) | |
| 372 | + | w.putBytes(m.Data) | |
| 373 | + | return w.b | |
| 374 | + | } | |
| 375 | + | ||
| 376 | + | // UnmarshalImagePush decodes an ImagePush message payload. After the | |
| 377 | + | // length-prefixed mime string, the rest of the payload is data; there is no | |
| 378 | + | // length prefix for it. | |
| 379 | + | func UnmarshalImagePush(b []byte) (ImagePush, error) { | |
| 380 | + | r := payloadReader{b: b} | |
| 381 | + | mime, err := r.getString() | |
| 382 | + | if err != nil { | |
| 383 | + | return ImagePush{}, err | |
| 384 | + | } | |
| 385 | + | return ImagePush{Mime: mime, Data: r.rest()}, nil | |
| 386 | + | } | |
| 387 | + | ||
| 360 | 388 | // MarshalResize encodes m into a Resize message payload. | |
| 361 | 389 | func MarshalResize(m Resize) []byte { | |
| 362 | 390 | w := payloadWriter{} | |
| @@ -87,6 +87,14 @@ type Theme struct { | |||||
| 87 | TrueColor bool | 87 | TrueColor bool | ||
| 88 | } | 88 | } | ||
| 89 | 89 | ||||
| 90 | + | // ImagePush carries a clipboard image from a remote client, to be written to | |||
| 91 | + | // a temp file and injected into the focused pane's PTY as a path (see | |||
| 92 | + | // docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md). | |||
| 93 | + | type ImagePush struct { | |||
| 94 | + | Mime string | |||
| 95 | + | Data []byte | |||
| 96 | + | } | |||
| 97 | + | ||||
| 90 | // Detach asks the daemon to detach the current client without killing the | 98 | // Detach asks the daemon to detach the current client without killing the | ||
| 91 | // session. | 99 | // session. | ||
| 92 | type Detach struct{} | 100 | type Detach struct{} | ||
| @@ -357,6 +365,26 @@ func UnmarshalInput(b []byte) (Input, error) { | |||||
| 357 | return Input{InputSeq: seq, Data: r.rest()}, nil | 365 | return Input{InputSeq: seq, Data: r.rest()}, nil | ||
| 358 | } | 366 | } | ||
| 359 | 367 | ||||
| 368 | + | // MarshalImagePush encodes m into an ImagePush message payload. | |||
| 369 | + | func MarshalImagePush(m ImagePush) []byte { | |||
| 370 | + | w := payloadWriter{} | |||
| 371 | + | w.putString(m.Mime) | |||
| 372 | + | w.putBytes(m.Data) | |||
| 373 | + | return w.b | |||
| 374 | + | } | |||
| 375 | + | ||||
| 376 | + | // UnmarshalImagePush decodes an ImagePush message payload. After the | |||
| 377 | + | // length-prefixed mime string, the rest of the payload is data; there is no | |||
| 378 | + | // length prefix for it. | |||
| 379 | + | func UnmarshalImagePush(b []byte) (ImagePush, error) { | |||
| 380 | + | r := payloadReader{b: b} | |||
| 381 | + | mime, err := r.getString() | |||
| 382 | + | if err != nil { | |||
| 383 | + | return ImagePush{}, err | |||
| 384 | + | } | |||
| 385 | + | return ImagePush{Mime: mime, Data: r.rest()}, nil | |||
| 386 | + | } | |||
| 387 | + | ||||
| 360 | // MarshalResize encodes m into a Resize message payload. | 388 | // MarshalResize encodes m into a Resize message payload. | ||
| 361 | func MarshalResize(m Resize) []byte { | 389 | func MarshalResize(m Resize) []byte { | ||
| 362 | w := payloadWriter{} | 390 | w := payloadWriter{} | ||
M internal/ports/wire_test.go +34 -0
| @@ -140,6 +140,40 @@ func TestInputGoldenAndRoundTrip(t *testing.T) { | |||
| 140 | 140 | assertAllPrefixesFail(t, full[:8], UnmarshalInput) | |
| 141 | 141 | } | |
| 142 | 142 | ||
| 143 | + | func TestImagePushGoldenAndRoundTrip(t *testing.T) { | |
| 144 | + | tests := []struct { | |
| 145 | + | name string | |
| 146 | + | msg ImagePush | |
| 147 | + | want []byte | |
| 148 | + | }{ | |
| 149 | + | { | |
| 150 | + | name: "png data", | |
| 151 | + | msg: ImagePush{Mime: "image/png", Data: []byte{0x01, 0x02, 0x03}}, | |
| 152 | + | want: []byte{0x00, 0x09, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x2f, 0x70, 0x6e, 0x67, 0x01, 0x02, 0x03}, | |
| 153 | + | }, | |
| 154 | + | {name: "empty", msg: ImagePush{}, want: []byte{0x00, 0x00}}, | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | for _, tt := range tests { | |
| 158 | + | t.Run(tt.name, func(t *testing.T) { | |
| 159 | + | got := MarshalImagePush(tt.msg) | |
| 160 | + | if !bytes.Equal(got, tt.want) { | |
| 161 | + | t.Fatalf("MarshalImagePush() = %#v, want %#v", got, tt.want) | |
| 162 | + | } | |
| 163 | + | back, err := UnmarshalImagePush(got) | |
| 164 | + | if err != nil { | |
| 165 | + | t.Fatalf("UnmarshalImagePush() error = %v", err) | |
| 166 | + | } | |
| 167 | + | if !reflect.DeepEqual(back, tt.msg) { | |
| 168 | + | t.Fatalf("round trip = %#v, want %#v", back, tt.msg) | |
| 169 | + | } | |
| 170 | + | }) | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | full := MarshalImagePush(tests[0].msg) | |
| 174 | + | assertAllPrefixesFail(t, full[:11], UnmarshalImagePush) | |
| 175 | + | } | |
| 176 | + | ||
| 143 | 177 | func TestThemeGoldenAndRoundTrip(t *testing.T) { | |
| 144 | 178 | tests := []struct { | |
| 145 | 179 | name string | |
| @@ -140,6 +140,40 @@ func TestInputGoldenAndRoundTrip(t *testing.T) { | |||||
| 140 | assertAllPrefixesFail(t, full[:8], UnmarshalInput) | 140 | assertAllPrefixesFail(t, full[:8], UnmarshalInput) | ||
| 141 | } | 141 | } | ||
| 142 | 142 | ||||
| 143 | + | func TestImagePushGoldenAndRoundTrip(t *testing.T) { | |||
| 144 | + | tests := []struct { | |||
| 145 | + | name string | |||
| 146 | + | msg ImagePush | |||
| 147 | + | want []byte | |||
| 148 | + | }{ | |||
| 149 | + | { | |||
| 150 | + | name: "png data", | |||
| 151 | + | msg: ImagePush{Mime: "image/png", Data: []byte{0x01, 0x02, 0x03}}, | |||
| 152 | + | want: []byte{0x00, 0x09, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x2f, 0x70, 0x6e, 0x67, 0x01, 0x02, 0x03}, | |||
| 153 | + | }, | |||
| 154 | + | {name: "empty", msg: ImagePush{}, want: []byte{0x00, 0x00}}, | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | for _, tt := range tests { | |||
| 158 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 159 | + | got := MarshalImagePush(tt.msg) | |||
| 160 | + | if !bytes.Equal(got, tt.want) { | |||
| 161 | + | t.Fatalf("MarshalImagePush() = %#v, want %#v", got, tt.want) | |||
| 162 | + | } | |||
| 163 | + | back, err := UnmarshalImagePush(got) | |||
| 164 | + | if err != nil { | |||
| 165 | + | t.Fatalf("UnmarshalImagePush() error = %v", err) | |||
| 166 | + | } | |||
| 167 | + | if !reflect.DeepEqual(back, tt.msg) { | |||
| 168 | + | t.Fatalf("round trip = %#v, want %#v", back, tt.msg) | |||
| 169 | + | } | |||
| 170 | + | }) | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | full := MarshalImagePush(tests[0].msg) | |||
| 174 | + | assertAllPrefixesFail(t, full[:11], UnmarshalImagePush) | |||
| 175 | + | } | |||
| 176 | + | ||||
| 143 | func TestThemeGoldenAndRoundTrip(t *testing.T) { | 177 | func TestThemeGoldenAndRoundTrip(t *testing.T) { | ||
| 144 | tests := []struct { | 178 | tests := []struct { | ||
| 145 | name string | 179 | name string | ||