openvibers/openvibe.link Commits

Link Send: seal in Go, sync the clipboard, and receive without the window (#160)

OP OpenVibers committed 1 days ago · 0e9c43fcb4 · 18 files, +1591 −17

Changes

ddiff --git a/README.md b/README.md
iindex 1fded87..950f38a 100644
---- a/README.md
++++ b/README.md
@@@ -116,9 +116,10 @@ tasks this machine ran today, and what happened, and lets you pause everything,
share a port, or send a file, a note or copied text to another of your machines (sealed on this computer with
openvibe-sdk/locker; the other machine lists the sender and size and asks before saving). A transfer is uploaded in
parts, so a dropped connection continues where it stopped; on the same network it is handed over directly, and
--otherwise the relay holds only ciphertext. Clipboard sharing is a per-machine switch, off by default and text only. It is served only on
--loopback, with a per-run token in the address fragment that every API call must send; see [docs/panel.md](docs/panel.md)
--for the security rules.
++otherwise the relay holds only ciphertext. Clipboard sharing is a per-machine switch, off by default and text only;
++with auto-accept on, the daemon saves a received file to Downloads and puts received copied text on the clipboard
++without needing the window open. It is served only on loopback, with a per-run token in the address fragment that
++every API call must send; see [docs/panel.md](docs/panel.md) for the security rules.
**Your machines:** [openvibe.link/machines](https://openvibe.link/machines) opens the signed-in machines hub: each
paired machine, whether it is online, the roles it plays (Codes jobs, desks, your own models, a robot), and pairing a
ddiff --git a/docs/panel.md b/docs/panel.md
iindex 6101849..8b20e5e 100644
---- a/docs/panel.md
++++ b/docs/panel.md
@@@ -39,8 +39,8 @@ A restart makes a new port and a new token: an old link stops working.
| `POST /panel/api/shares` `{ "port": 3000 }` | Shares a port through Connect | Real for a share Connect already picked for this computer |
| `DELETE /panel/api/shares/:port` | Ends that share | Real |
| `POST /panel/api/first-run` `{ "uses": ["codes", "browser"] }` | The first-run answer: records it in `<state>/panel.json`, turns on a browser (`browser`) or the AI plugin (`models`), and stops asking. `codes` and `earn` are recorded only (Codes has no module switch yet, and Exchange confirms earning); `vibe` turns nothing on; `skip: true` only stops asking | Real; never turns on Terminal or Desktop |
--| `POST /panel/api/clipboard` `{ "on": true }` | The per-machine clipboard-sharing switch | Off by default, text only, kept in `<state>/panel.json` |
--| `POST /panel/api/send/auto-accept` `{ "on": true }` | Save items from the person's own machines without asking | Off by default, kept in `<state>/panel.json` |
++| `POST /panel/api/clipboard` `{ "on": true }` | The per-machine clipboard-sharing switch | Off by default, text only, kept in `<state>/panel.json`; while on, the daemon reads the clipboard and seals changes for the person's other machines, even when the window is closed |
++| `POST /panel/api/send/auto-accept` `{ "on": true }` | Save items from the person's own machines without asking | Off by default, kept in `<state>/panel.json`; the daemon saves files to Downloads and puts clipboard text on the clipboard while the window is closed |
| `GET /panel/api/send/targets` | Your other active machines that have a receive key | Real for Network-paired machines |
| `GET /panel/api/send/inbox` | Pending encrypted items | Real; the envelope remains ciphertext until you accept |
| `GET /panel/api/send/key` | This machine's receive key for opening an accepted envelope | Real; loopback panel token required |
@@@ -50,9 +50,17 @@ A restart makes a new port and a new token: an old link stops working.
| `POST /panel/api/files/:id/complete` | Removes an accepted item after the browser download starts | Real |
`state.clipboard` and `state.auto_accept` are the two per-machine switches; both are `false` until the person turns
--them on, and `panel.json` keeps them with the capability switches. A direct transfer on the same network is queued in
--memory by this machine's listener and appears in `send/inbox` beside the relay's items; the listener accepts only an
--HMAC from the owner's LAN secret, so another machine on the network cannot put anything there.
++them on, and `panel.json` keeps them with the capability switches. The daemon keeps the transfer loop running without
++the window: with auto-accept on it opens each item from this account's own machines, saves a file or note to
++`~/Downloads` (never replacing an existing file) and reports the save in activity; with clipboard sharing on it does
++the same for copied text, and seals each new clipboard change for the person's other machines. Clipboard text is
++capped at 64 KB and is never sent in clear. An item granted by another account through machine access stays in
++`send/inbox` for a person to answer, even when auto-accept is on.
++
++A direct transfer on the same network is queued in memory by this machine's listener and appears in `send/inbox`
++beside the relay's items; the listener accepts only an HMAC from the owner's LAN secret, so another machine on the
++network cannot put anything there. A granted transfer from another account uses the same listener with Network's
++signed `send` grant, and always waits for the person.
`state` omits what the Link cannot fill truthfully: `devices` (the paired account's machine list is not read here),
`gpu` (no GPU detection), and `earnings_today`. `activity` is the Link's own record of what it saw (an approval
ddiff --git a/internal/agent/panel.go b/internal/agent/panel.go
iindex e69de46..9f9c578 100644
---- a/internal/agent/panel.go
++++ b/internal/agent/panel.go
@@@ -125,6 +125,19 @@ func (n *Agent) panelSendOptions(ctx context.Context) panelSend {
}
}
}()
++ // The window can be closed; receiving and clipboard sync still run here while the person has the switches on.
++ // Auto-accept only keeps items from this account's own machines: a granted item from another account stays in
++ // the window for an answer.
++ service := send.NewService(send.ServiceOptions{
++ Relay: client, Direct: lan, Key: key, Principal: n.opt.Creds.Principal, Log: n.log,
++ Settings: func() send.Settings {
++ n.mu.Lock()
++ defer n.mu.Unlock()
++ return send.Settings{AutoAccept: n.panelAutoAccept, Clipboard: n.panelClipboard}
++ },
++ OnActivity: n.addActivity,
++ })
++ go service.Run(ctx)
return panelSend{
key: func() any {
return map[string]any{"private_key": key, "subject": map[string]string{"type": "link", "id": n.opt.Creds.Principal}}
ddiff --git a/internal/lockeropen/seal.go b/internal/lockeropen/seal.go
nnew file mode 100644
iindex 0000000..e6ce679
---- /dev/null
++++ b/internal/lockeropen/seal.go
@@@ -0,0 +1,291 @@
++// Seal writes the people lock emitted by openvibe-sdk/locker. The format is the SDK's: every byte here matches
++// what the SDK would write for the same key, salt, nonce and content (seal_test.go checks the shared vector).
++package lockeropen
++
++import (
++ "crypto/aes"
++ "crypto/cipher"
++ "crypto/ecdh"
++ "crypto/elliptic"
++ "crypto/hkdf"
++ "crypto/rand"
++ "crypto/sha256"
++ "encoding/base64"
++ "encoding/binary"
++ "encoding/json"
++ "errors"
++ "fmt"
++ "io"
++ "math/big"
++ "math/bits"
++)
++
++const (
++ minPaddedSize = 256
++ finalFlag = byte(1)
++ saltBytes = 16
++ keyBytes = 32
++ nonceBaseSize = 8
++ nonceSize = 12
++)
++
++// SealOptions fixes the values a test vector needs to be the same bytes every time. A caller sealing a real item
++// leaves them empty and gets fresh random ones.
++type SealOptions struct {
++ ContentKey []byte
++ NonceBase []byte
++ // EphemeralPrivate is the envelope's one ephemeral P-256 private key as a 32-byte scalar.
++ EphemeralPrivate []byte
++ Salt []byte
++ WrapNonce []byte
++ // Random is the source for whatever was not given above. Nil means crypto/rand.
++ Random io.Reader
++}
++
++// Seal builds a people-locked envelope addressed to one recipient. It is the sender's half of Open: a machine
++// that holds the private key for public opens it, and nobody else can.
++func Seal(content []byte, recipient Subject, public JWK, opt SealOptions) ([]byte, error) {
++ if public.Kty != "EC" || public.Crv != "P-256" || public.X == "" || public.Y == "" {
++ return nil, errors.New("the receiving machine has no P-256 key")
++ }
++ if len(content) > maxBytes {
++ return nil, errors.New("the item is too large to seal")
++ }
++ rnd := opt.Random
++ if rnd == nil {
++ rnd = rand.Reader
++ }
++ read := func(n int, given []byte) ([]byte, error) {
++ if given != nil {
++ if len(given) != n {
++ return nil, errors.New("invalid seal value")
++ }
++ return given, nil
++ }
++ b := make([]byte, n)
++ if _, err := io.ReadFull(rnd, b); err != nil {
++ return nil, err
++ }
++ return b, nil
++ }
++ contentKey, err := read(keyBytes, opt.ContentKey)
++ if err != nil {
++ return nil, err
++ }
++ nonceBase, err := read(nonceBaseSize, opt.NonceBase)
++ if err != nil {
++ return nil, err
++ }
++ salt, err := read(saltBytes, opt.Salt)
++ if err != nil {
++ return nil, err
++ }
++ wrapNonce, err := read(nonceSize, opt.WrapNonce)
++ if err != nil {
++ return nil, err
++ }
++ ephemeral, err := ephemeralKey(opt.EphemeralPrivate, rnd)
++ if err != nil {
++ return nil, err
++ }
++ recipientKey, err := thumbprint(public)
++ if err != nil {
++ return nil, err
++ }
++ shared, err := ephemeral.ECDH(recipientPublic(public))
++ if err != nil {
++ return nil, errors.New("the receiving machine has no usable P-256 key")
++ }
++ defer wipe(shared)
++ ephemeralJWK := ephemeralPublicJwk(ephemeral)
++ ephemeralJSON, _ := json.Marshal(map[string]string{"crv": "P-256", "kty": "EC", "x": ephemeralJWK.X, "y": ephemeralJWK.Y})
++ info := []byte(fmt.Sprintf("openvibe-locker people-slot v1|%s:%s|%s", recipient.Type, recipient.ID, recipientKey))
++ wrapKey, err := hkdf.Key(sha256.New, shared, salt, string(info), keyBytes)
++ if err != nil {
++ return nil, err
++ }
++ defer wipe(wrapKey)
++ aad := []byte(fmt.Sprintf("openvibe-locker people-slot v1|%s:%s|%s|%s|%s", recipient.Type, recipient.ID, recipientKey, ephemeralJSON, base64.StdEncoding.EncodeToString(salt)))
++ block, err := aes.NewCipher(wrapKey)
++ if err != nil {
++ return nil, err
++ }
++ gcm, err := cipher.NewGCM(block)
++ if err != nil {
++ return nil, err
++ }
++ wrapped := gcm.Seal(nil, wrapNonce, contentKey, aad)
++
++ padded := paddedSize(len(content))
++ header := map[string]any{
++ "version": lockerVersion,
++ "suite": suite,
++ "chunk_size": chunkSize,
++ "padded_size": padded,
++ "nonce_base": base64.StdEncoding.EncodeToString(nonceBase),
++ "lock": "people",
++ "slots": map[string]any{"people": []any{map[string]any{
++ "subject": map[string]string{"type": recipient.Type, "id": recipient.ID},
++ "algorithm": "ECDH-P256/HKDF-SHA256/AES-256-GCM",
++ "recipient_key": recipientKey,
++ "ephemeral_public_key": map[string]string{"kty": "EC", "crv": "P-256", "x": ephemeralJWK.X, "y": ephemeralJWK.Y},
++ "salt": base64.StdEncoding.EncodeToString(salt),
++ "nonce": base64.StdEncoding.EncodeToString(wrapNonce),
++ "wrapped_key": base64.StdEncoding.EncodeToString(wrapped),
++ }}},
++ }
++ headerBytes, err := json.Marshal(header)
++ if err != nil {
++ return nil, err
++ }
++ core, err := json.Marshal(map[string]any{
++ "version": lockerVersion,
++ "suite": suite,
++ "chunk_size": chunkSize,
++ "padded_size": padded,
++ "nonce_base": base64.StdEncoding.EncodeToString(nonceBase),
++ "lock": "people",
++ })
++ if err != nil {
++ return nil, err
++ }
++ plain := make([]byte, padded)
++ binary.BigEndian.PutUint64(plain[:8], uint64(len(content)))
++ copy(plain[8:], content)
++ defer wipe(plain)
++
++ block, err = aes.NewCipher(contentKey)
++ if err != nil {
++ return nil, err
++ }
++ gcm, err = cipher.NewGCM(block)
++ if err != nil {
++ return nil, err
++ }
++ out := make([]byte, 0, 8+len(headerBytes)+len(plain)+gcm.Overhead())
++ out = append(out, "OVLK"...)
++ out = binary.BigEndian.AppendUint32(out, uint32(len(headerBytes)))
++ out = append(out, headerBytes...)
++ count := (padded + chunkSize - 1) / chunkSize
++ for i := 0; i < count; i++ {
++ start := i * chunkSize
++ end := start + chunkSize
++ if end > padded {
++ end = padded
++ }
++ flags := byte(0)
++ if i == count-1 {
++ flags = finalFlag
++ }
++ nonce := make([]byte, nonceSize)
++ copy(nonce, nonceBase)
++ binary.BigEndian.PutUint32(nonce[8:], uint32(i))
++ chunkAad := make([]byte, len(core)+5)
++ copy(chunkAad, core)
++ binary.BigEndian.PutUint32(chunkAad[len(core):], uint32(i))
++ chunkAad[len(core)+4] = flags
++ ciphertext := gcm.Seal(nil, nonce, plain[start:end], chunkAad)
++ out = binary.BigEndian.AppendUint32(out, uint32(len(ciphertext)))
++ out = append(out, flags)
++ out = append(out, nonce...)
++ out = append(out, ciphertext...)
++ }
++ return out, nil
++}
++
++// Addressed reports whether an envelope names this machine's public key in a people slot, so Link can refuse an
++// item encrypted for another machine before it asks the person anything.
++func Addressed(envelope []byte, public JWK) bool {
++ h, _, err := readHeader(envelope)
++ if err != nil {
++ return false
++ }
++ want, err := thumbprint(public)
++ if err != nil {
++ return false
++ }
++ for _, slot := range h.Slots.People {
++ if slot.RecipientKey == want && slot.Algorithm == "ECDH-P256/HKDF-SHA256/AES-256-GCM" {
++ return true
++ }
++ }
++ return false
++}
++
++func readHeader(envelope []byte) (header, int, error) {
++ var h header
++ if len(envelope) < 8 || len(envelope) > maxBytes || string(envelope[:4]) != "OVLK" {
++ return h, 0, errors.New("invalid locker envelope")
++ }
++ headerLen := int(binary.BigEndian.Uint32(envelope[4:8]))
++ if headerLen < 2 || headerLen > 65536 || 8+headerLen > len(envelope) {
++ return h, 0, errors.New("invalid locker header")
++ }
++ if json.Unmarshal(envelope[8:8+headerLen], &h) != nil {
++ return h, 0, errors.New("invalid locker header")
++ }
++ return h, headerLen, nil
++}
++
++func ephemeralKey(scalar []byte, rnd io.Reader) (*ecdh.PrivateKey, error) {
++ if scalar != nil {
++ if len(scalar) != 32 {
++ return nil, errors.New("invalid ephemeral key")
++ }
++ return ecdh.P256().NewPrivateKey(scalar)
++ }
++ return ecdh.P256().GenerateKey(rnd)
++}
++
++func recipientPublic(public JWK) *ecdh.PublicKey {
++ x, errX := base64.RawURLEncoding.DecodeString(public.X)
++ y, errY := base64.RawURLEncoding.DecodeString(public.Y)
++ if errX != nil || errY != nil || len(x) != 32 || len(y) != 32 {
++ return nil
++ }
++ key, err := ecdh.P256().NewPublicKey(elliptic.Marshal(elliptic.P256(), new(big.Int).SetBytes(x), new(big.Int).SetBytes(y)))
++ if err != nil {
++ return nil
++ }
++ return key
++}
++
++func ephemeralPublicJwk(key *ecdh.PrivateKey) JWK {
++ pub := key.PublicKey().Bytes() // uncompressed point: 0x04 || X || Y
++ return JWK{
++ Kty: "EC", Crv: "P-256",
++ X: base64.RawURLEncoding.EncodeToString(pub[1:33]),
++ Y: base64.RawURLEncoding.EncodeToString(pub[33:]),
++ }
++}
++
++func thumbprint(public JWK) (string, error) {
++ jwk, err := json.Marshal(map[string]string{"crv": "P-256", "kty": "EC", "x": public.X, "y": public.Y})
++ if err != nil {
++ return "", err
++ }
++ sum := sha256.Sum256(jwk)
++ return base64.RawURLEncoding.EncodeToString(sum[:]), nil
++}
++
++func paddedSize(size int) int {
++ p := padme(8 + size)
++ if p < minPaddedSize {
++ return minPaddedSize
++ }
++ return p
++}
++
++// padme is Padmé's grid (the SDK's padme.js): round up to the next size the band allows.
++func padme(size int) int {
++ if size <= 0 {
++ return 0
++ }
++ if size == 1 {
++ return 1
++ }
++ band := bits.Len(uint(size)) - 1
++ steps := bits.Len(uint(band))
++ step := 1 << (band - steps)
++ return ((size + step - 1) / step) * step
++}
ddiff --git a/internal/lockeropen/seal_test.go b/internal/lockeropen/seal_test.go
nnew file mode 100644
iindex 0000000..af73eb9
---- /dev/null
++++ b/internal/lockeropen/seal_test.go
@@@ -0,0 +1,105 @@
++package lockeropen
++
++import (
++ "crypto/ecdsa"
++ "crypto/elliptic"
++ "crypto/rand"
++ "crypto/sha256"
++ "encoding/base64"
++ "encoding/hex"
++ "math/big"
++ "testing"
++)
++
++// The shared SDK vector: fixed keys, fixed bytes, and the envelope hash the SDK's own test checks. If Go and the
++// SDK ever write different bytes for the same input, this fails.
++const (
++ vectorContent = "The quick brown fox jumps over the lazy dog."
++ vectorEnvelopeID = "a07abdad22e7400831299bb6c363c50241c816bb49c361e555f90bd148690b47"
++)
++
++var vectorPublic = JWK{
++ Kty: "EC", Crv: "P-256",
++ X: "pNngpTSE3X3C_CGzfpAtenjliLPFZ9arBVNtI0ctPXw",
++ Y: "fXZT2JcDDiXEb6xH3LUrqlPakM3GQwe3aqbV_1vhVLI",
++ D: "cG9YgmeoPeaI8lXZRpijV5--2sC4rYWSqWbyGCYKPu0",
++}
++var vectorSubject = Subject{Type: "user", ID: "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"}
++
++// fixedRandom is the SDK's vectors.fixedRandom: the same xorshift32 stream, so the same salt and key bytes.
++type fixedRandom struct{ state uint32 }
++
++func (f *fixedRandom) Read(p []byte) (int, error) {
++ for i := range p {
++ f.state ^= f.state << 13
++ f.state ^= f.state >> 17
++ f.state ^= f.state << 5
++ p[i] = byte(f.state)
++ }
++ return len(p), nil
++}
++
++func TestSealMatchesTheSDKVector(t *testing.T) {
++ private, err := base64.RawURLEncoding.DecodeString(vectorPublic.D)
++ if err != nil {
++ t.Fatal(err)
++ }
++ envelope, err := Seal([]byte(vectorContent), vectorSubject, vectorPublic, SealOptions{
++ EphemeralPrivate: private,
++ Random: &fixedRandom{state: 0x1a2b3c4d},
++ })
++ if err != nil {
++ t.Fatal(err)
++ }
++ sum := sha256.Sum256(envelope)
++ if got := hex.EncodeToString(sum[:]); got != vectorEnvelopeID {
++ t.Fatalf("sealed envelope %s, the SDK writes %s", got, vectorEnvelopeID)
++ }
++ opened, err := Open(envelope, vectorPublic, vectorSubject)
++ if err != nil {
++ t.Fatal(err)
++ }
++ if string(opened) != vectorContent {
++ t.Fatalf("opened %q", opened)
++ }
++}
++
++func TestSealRoundTripForALinkMachine(t *testing.T) {
++ key := testKey(t)
++ recipient := Subject{Type: "link", ID: "lnk_01JAB2C3D4E5F6G7H8J9K0MNPQ"}
++ content := []byte(`{"name":"clipboard.txt"}` + "\x00hello from the other machine")
++ envelope, err := Seal(content, recipient, key, SealOptions{})
++ if err != nil {
++ t.Fatal(err)
++ }
++ if !Addressed(envelope, key) {
++ t.Fatal("the envelope does not name its recipient")
++ }
++ if Addressed(envelope, testKey(t)) {
++ t.Fatal("the envelope names a key it was not sealed for")
++ }
++ opened, err := Open(envelope, key, recipient)
++ if err != nil {
++ t.Fatal(err)
++ }
++ if string(opened) != string(content) {
++ t.Fatalf("opened %q", opened)
++ }
++ if _, err := Open(envelope, key, Subject{Type: "link", ID: "lnk_01JAB2C3D4E5F6G7H8J9K0MNPR"}); err == nil {
++ t.Fatal("another machine's subject opened the item")
++ }
++}
++
++func testKey(t *testing.T) JWK {
++ t.Helper()
++ private, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
++ if err != nil {
++ t.Fatal(err)
++ }
++ b64 := func(n *big.Int) string {
++ raw := make([]byte, 32)
++ n.FillBytes(raw)
++ return base64.RawURLEncoding.EncodeToString(raw)
++ }
++ return JWK{Kty: "EC", Crv: "P-256", X: b64(private.X), Y: b64(private.Y), D: b64(private.D)}
++}
ddiff --git a/internal/panel/ui/README.md b/internal/panel/ui/README.md
iindex 935109d..46bc203 100644
---- a/internal/panel/ui/README.md
++++ b/internal/panel/ui/README.md
@@@ -29,8 +29,8 @@ fragment: `/panel/#t=<token>`. The page sends that token as `X-Link-Panel` on ev
| `POST /panel/api/files/:id/decline` | — | Removes the incoming item. |
| `POST /panel/api/files/:id/complete` | — | Removes the accepted item after download starts. |
| `POST /panel/api/first-run` | `{ "uses": ["codes", "browser"], "skip": false }` | The first-run answer. Uses are `codes`, `browser`, `models`, `earn` and `vibe`. The daemon turns on what each says (`earn` stays off until it is confirmed on Exchange) and stops asking; `skip: true` only stops asking. |
--| `POST /panel/api/clipboard` | `{ "on": true }` | The per-machine clipboard-sharing switch, off by default; text only, sealed like every other item. |
--| `POST /panel/api/send/auto-accept` | `{ "on": true }` | Save incoming items from the person's own machines without asking, per machine. Off by default. |
++| `POST /panel/api/clipboard` | `{ "on": true }` | The per-machine clipboard-sharing switch, off by default; text only. The daemon seals each new clipboard change for the person's other machines while the window is closed. |
++| `POST /panel/api/send/auto-accept` | `{ "on": true }` | Save incoming items from the person's own machines without asking, per machine. Off by default; the daemon saves files to Downloads and clipboard text to the clipboard while the window is closed. |
## `GET /panel/api/state`
ddiff --git a/internal/panel/ui/panel.js b/internal/panel/ui/panel.js
iindex 3ac5a1f..6380a4e 100644
---- a/internal/panel/ui/panel.js
++++ b/internal/panel/ui/panel.js
@@@ -181,7 +181,6 @@
var drop = $('[data-lk-drop]'), say = $('[data-lk-drop-say]'), targetSelect = $('[data-lk-send-target]');
var sendKey = null, lockerModule = null;
var settings = { clipboard: false, auto_accept: false };
-- var seenInbox = {};
function panelApi(path, method, body) {
return fetch('/panel/api/' + path, { method: method || 'GET', headers: Object.assign({ 'X-Link-Panel': token }, body ? { 'Content-Type': 'application/json' } : {}),
body: body ? JSON.stringify(body) : undefined, credentials: 'same-origin' }).then(function (r) {
@@@ -239,7 +238,7 @@
var view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes), n = new DataView(view.buffer, view.byteOffset, view.byteLength).getUint32(0);
if (n < 2 || n > view.length - 4) throw new Error('The encrypted item is incomplete.');
var info = JSON.parse(new TextDecoder().decode(view.subarray(4, 4 + n))), fileBytes = view.subarray(4 + n);
-- if (info.kind === 'clipboard' && navigator.clipboard && navigator.clipboard.writeText) {
++ if (info.kind === 'clipboard' && settings.clipboard && navigator.clipboard && navigator.clipboard.writeText) {
var text = new TextDecoder().decode(fileBytes);
return navigator.clipboard.writeText(text).then(function () {
return panelApi('files/' + encodeURIComponent(item.id) + '/complete', 'POST', {}).then(function () {
@@@ -257,11 +256,7 @@
function loadInbox() {
panelApi('send/inbox').then(function (body) {
var list = $('[data-lk-send-inbox]');
-- (body.items || []).forEach(function (item) {
-- if (!settings.auto_accept || seenInbox[item.id]) return;
-- seenInbox[item.id] = true;
-- downloadAccepted(item).catch(function (e) { say(e.message || 'An item from one of your machines could not be opened.'); });
-- });
++ // Auto-accept runs in the daemon, even when this window is closed; doing it here too would save twice.
list.replaceChildren.apply(list, (body.items || []).map(function (item) {
var li = el('li', 'lk-send-item'), label = el('span', '', 'Encrypted item from ' + (item.sender_name || 'your machine') + ' · ' + size(item.bytes) + ' · ' + ago(item.created_at));
var actions = el('div'), accept = el('button', '', 'Accept'), decline = el('button', '', 'Decline');
ddiff --git a/internal/send/clipboard.go b/internal/send/clipboard.go
nnew file mode 100644
iindex 0000000..d1fcd82
---- /dev/null
++++ b/internal/send/clipboard.go
@@@ -0,0 +1,60 @@
++package send
++
++import (
++ "bytes"
++ "context"
++ "errors"
++ "os/exec"
++ "time"
++)
++
++// Clipboard is this computer's text clipboard. Link only ever reads and writes text: an image or a file copied
++// on one machine stays there.
++type Clipboard interface {
++ Read(ctx context.Context) (string, error)
++ Write(ctx context.Context, text string) error
++}
++
++// commandClipboard uses the clipboard tool the desktop already has, so Link adds no dependency and no C library.
++type commandClipboard struct {
++ read [][]string
++ write [][]string
++ tool string
++}
++
++func (c *commandClipboard) Read(parent context.Context) (string, error) {
++ ctx, cancel := context.WithTimeout(parent, 2*time.Second)
++ defer cancel()
++ for _, argv := range c.read {
++ if _, err := exec.LookPath(argv[0]); err != nil {
++ continue
++ }
++ out, err := exec.CommandContext(ctx, argv[0], argv[1:]...).Output()
++ if err == nil {
++ return string(out), nil
++ }
++ if ctx.Err() != nil {
++ return "", ctx.Err()
++ }
++ }
++ return "", errors.New(c.tool + " is not available on this computer")
++}
++
++func (c *commandClipboard) Write(parent context.Context, text string) error {
++ ctx, cancel := context.WithTimeout(parent, 5*time.Second)
++ defer cancel()
++ for _, argv := range c.write {
++ if _, err := exec.LookPath(argv[0]); err != nil {
++ continue
++ }
++ cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
++ cmd.Stdin = bytes.NewReader([]byte(text))
++ if err := cmd.Run(); err == nil {
++ return nil
++ }
++ if ctx.Err() != nil {
++ return ctx.Err()
++ }
++ }
++ return errors.New(c.tool + " is not available on this computer")
++}
ddiff --git a/internal/send/clipboard_darwin.go b/internal/send/clipboard_darwin.go
nnew file mode 100644
iindex 0000000..d029ccd
---- /dev/null
++++ b/internal/send/clipboard_darwin.go
@@@ -0,0 +1,11 @@
++//go:build darwin
++
++package send
++
++func SystemClipboard() Clipboard {
++ return &commandClipboard{
++ read: [][]string{{"pbpaste"}},
++ write: [][]string{{"pbcopy"}},
++ tool: "pbpaste and pbcopy",
++ }
++}
ddiff --git a/internal/send/clipboard_linux.go b/internal/send/clipboard_linux.go
nnew file mode 100644
iindex 0000000..642f2cc
---- /dev/null
++++ b/internal/send/clipboard_linux.go
@@@ -0,0 +1,21 @@
++//go:build linux
++
++package send
++
++// SystemClipboard prefers Wayland when the session is Wayland, and still falls back to X11 tools when it is not
++// installed (a Wayland session can run X11 applications, but a missing wl-clipboard is common).
++func SystemClipboard() Clipboard {
++ return &commandClipboard{
++ read: [][]string{
++ {"wl-paste", "--no-newline"},
++ {"xclip", "-selection", "clipboard", "-o"},
++ {"xsel", "--clipboard", "--output"},
++ },
++ write: [][]string{
++ {"wl-copy"},
++ {"xclip", "-selection", "clipboard", "-i"},
++ {"xsel", "--clipboard", "--input"},
++ },
++ tool: "wl-clipboard or xclip",
++ }
++}
ddiff --git a/internal/send/clipboard_other.go b/internal/send/clipboard_other.go
nnew file mode 100644
iindex 0000000..dbf18ba
---- /dev/null
++++ b/internal/send/clipboard_other.go
@@@ -0,0 +1,7 @@
++//go:build !linux && !darwin && !windows
++
++package send
++
++// SystemClipboard is absent on platforms without a clipboard Link can reach. Clipboard sync stays off there and
++// the window says so instead of pretending.
++func SystemClipboard() Clipboard { return nil }
ddiff --git a/internal/send/clipboard_test.go b/internal/send/clipboard_test.go
nnew file mode 100644
iindex 0000000..e724432
---- /dev/null
++++ b/internal/send/clipboard_test.go
@@@ -0,0 +1,49 @@
++package send
++
++import (
++ "context"
++ "os"
++ "path/filepath"
++ "runtime"
++ "testing"
++)
++
++func TestClipboardCommandsReadAndWriteText(t *testing.T) {
++ if runtime.GOOS == "windows" {
++ t.Skip("the test uses a POSIX shell as the clipboard tool")
++ }
++ out := filepath.Join(t.TempDir(), "clipboard.txt")
++ c := &commandClipboard{
++ read: [][]string{{"sh", "-c", "cat " + out}},
++ write: [][]string{{"sh", "-c", "cat > " + out}},
++ tool: "the test clipboard",
++ }
++ if err := os.WriteFile(out, []byte("first"), 0o600); err != nil {
++ t.Fatal(err)
++ }
++ got, err := c.Read(context.Background())
++ if err != nil || got != "first" {
++ t.Fatalf("read = %q, %v", got, err)
++ }
++ if err := c.Write(context.Background(), "second"); err != nil {
++ t.Fatal(err)
++ }
++ b, err := os.ReadFile(out)
++ if err != nil || string(b) != "second" {
++ t.Fatalf("written = %q, %v", b, err)
++ }
++}
++
++func TestClipboardWithoutAToolSaysSo(t *testing.T) {
++ c := &commandClipboard{
++ read: [][]string{{"openvibe-link-no-such-clipboard-tool"}},
++ write: [][]string{{"openvibe-link-no-such-clipboard-tool"}},
++ tool: "a clipboard tool",
++ }
++ if _, err := c.Read(context.Background()); err == nil {
++ t.Fatal("read without a tool succeeded")
++ }
++ if err := c.Write(context.Background(), "x"); err == nil {
++ t.Fatal("write without a tool succeeded")
++ }
++}
ddiff --git a/internal/send/clipboard_windows.go b/internal/send/clipboard_windows.go
nnew file mode 100644
iindex 0000000..dbc4fc2
---- /dev/null
++++ b/internal/send/clipboard_windows.go
@@@ -0,0 +1,11 @@
++//go:build windows
++
++package send
++
++func SystemClipboard() Clipboard {
++ return &commandClipboard{
++ read: [][]string{{"powershell", "-NoProfile", "-NonInteractive", "-Command", "[Console]::Out.Write((Get-Clipboard -Raw))"}},
++ write: [][]string{{"powershell", "-NoProfile", "-NonInteractive", "-Command", "[Console]::In.ReadToEnd() | Set-Clipboard"}},
++ tool: "PowerShell's clipboard",
++ }
++}
ddiff --git a/internal/send/files.go b/internal/send/files.go
nnew file mode 100644
iindex 0000000..a242420
---- /dev/null
++++ b/internal/send/files.go
@@@ -0,0 +1,144 @@
++package send
++
++import (
++ "encoding/binary"
++ "encoding/json"
++ "errors"
++ "fmt"
++ "os"
++ "path/filepath"
++ "strings"
++ "unicode"
++)
++
++// KindFile, KindNote and KindClipboard are the three things Link Send can carry. The kind is inside the sealed
++// payload, never in the relay's metadata: the receiving machine learns it only after opening the item.
++const (
++ KindFile = "file"
++ KindNote = "note"
++ KindClipboard = "clipboard"
++)
++
++// Content is what a sealed item carries: the name and type a person sees, then the bytes. It is sealed as one
++// blob, so the relay never sees the name, the type or the kind either.
++type Content struct {
++ Name string `json:"name"`
++ Mime string `json:"mime"`
++ Kind string `json:"kind,omitempty"`
++}
++
++type Packed struct {
++ Content
++ Data []byte
++}
++
++func Pack(name, mime string, data []byte) []byte {
++ return PackKind(name, mime, KindFile, data)
++}
++
++func PackKind(name, mime, kind string, data []byte) []byte {
++ if name == "" {
++ name = "received-file"
++ }
++ meta, _ := json.Marshal(Content{Name: name, Mime: mime, Kind: kind})
++ out := make([]byte, 4+len(meta)+len(data))
++ binary.BigEndian.PutUint32(out[:4], uint32(len(meta)))
++ copy(out[4:], meta)
++ copy(out[4+len(meta):], data)
++ return out
++}
++
++func Unpack(b []byte) (Packed, error) {
++ if len(b) < 4 {
++ return Packed{}, errors.New("the received item is incomplete")
++ }
++ n := int(binary.BigEndian.Uint32(b[:4]))
++ if n < 2 || n > len(b)-4 {
++ return Packed{}, errors.New("the received item is incomplete")
++ }
++ var c Content
++ if json.Unmarshal(b[4:4+n], &c) != nil {
++ return Packed{}, errors.New("the received item does not say what it is")
++ }
++ if c.Kind == "" {
++ c.Kind = KindFile
++ }
++ if c.Kind != KindFile && c.Kind != KindNote && c.Kind != KindClipboard {
++ return Packed{}, errors.New("the received item is not something Link sends")
++ }
++ return Packed{Content: c, Data: b[4+n:]}, nil
++}
++
++// SafeName keeps one path segment: no folder separators, no control characters, no leading dot, and short enough
++// for every filesystem Link runs on.
++func SafeName(name string) string {
++ name = strings.TrimSpace(name)
++ name = strings.Map(func(r rune) rune {
++ if r == '/' || r == '\\' || r == ':' || r == '*' || r == '?' || r == '"' || r == '<' || r == '>' || r == '|' || unicode.IsControl(r) {
++ return '_'
++ }
++ return r
++ }, name)
++ name = strings.TrimLeft(name, ".")
++ if name == "" {
++ return "received-file"
++ }
++ runes := []rune(name)
++ if len(runes) > 180 {
++ name = string(runes[:180])
++ }
++ return name
++}
++
++// DownloadsDir is where received files go unless the daemon was given another folder.
++func DownloadsDir() (string, error) {
++ home, err := os.UserHomeDir()
++ if err != nil || home == "" {
++ return "", errNoDownloads
++ }
++ return filepath.Join(home, "Downloads"), nil
++}
++
++// Save writes the item into dir without replacing an existing file: a second "report.pdf" becomes "report (2).pdf".
++// It returns the path it wrote.
++func Save(dir, name string, data []byte) (string, error) {
++ if dir == "" {
++ var err error
++ if dir, err = DownloadsDir(); err != nil {
++ return "", err
++ }
++ }
++ if err := os.MkdirAll(dir, 0o700); err != nil {
++ return "", fmt.Errorf("the Downloads folder could not be opened")
++ }
++ safe := SafeName(name)
++ ext := filepath.Ext(safe)
++ stem := strings.TrimSuffix(safe, ext)
++ for i := 0; i < 1000; i++ {
++ candidate := safe
++ if i > 0 {
++ candidate = fmt.Sprintf("%s (%d)%s", stem, i+1, ext)
++ }
++ path := filepath.Join(dir, candidate)
++ f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
++ if errors.Is(err, os.ErrExist) {
++ continue
++ }
++ if err != nil {
++ return "", fmt.Errorf("the file could not be saved in Downloads")
++ }
++ if _, err := f.Write(data); err != nil {
++ _ = f.Close()
++ _ = os.Remove(path)
++ return "", fmt.Errorf("the file could not be saved in Downloads")
++ }
++ if err := f.Close(); err != nil {
++ _ = os.Remove(path)
++ return "", fmt.Errorf("the file could not be saved in Downloads")
++ }
++ return path, nil
++ }
++ return "", fmt.Errorf("there are too many files with that name in Downloads")
++}
++
++var errNoDownloads = errors.New("this computer has no Downloads folder to save into")
ddiff --git a/internal/send/files_test.go b/internal/send/files_test.go
nnew file mode 100644
iindex 0000000..305ef97
---- /dev/null
++++ b/internal/send/files_test.go
@@@ -0,0 +1,99 @@
++package send
++
++import (
++ "os"
++ "path/filepath"
++ "runtime"
++ "strings"
++ "testing"
++)
++
++func TestPackedItemKeepsItsNameKindAndBytes(t *testing.T) {
++ envelope := PackKind("photo (1).png", "image/png", KindClipboard, []byte{0x89, 'P', 'N', 'G'})
++ got, err := Unpack(envelope)
++ if err != nil {
++ t.Fatal(err)
++ }
++ if got.Name != "photo (1).png" || got.Mime != "image/png" || got.Kind != KindClipboard {
++ t.Fatalf("content = %#v", got.Content)
++ }
++ if string(got.Data) != "\x89PNG" {
++ t.Fatalf("data = %q", got.Data)
++ }
++ if plain, err := Unpack(Pack("note.txt", "text/plain", []byte("hello"))); err != nil || plain.Kind != KindFile {
++ t.Fatalf("a v1 item without a kind = %#v, %v", plain, err)
++ }
++}
++
++func TestUnpackRefusesAnUnknownKindOrBrokenFrame(t *testing.T) {
++ bad := Pack("note.txt", "text/plain", []byte("hello"))
++ bad[0], bad[1], bad[2], bad[3] = 0, 0, 0, 200
++ if _, err := Unpack(bad); err == nil {
++ t.Fatal("accepted a frame longer than the item")
++ }
++ unknown := []byte(`{"name":"x","kind":"bogus"}`)
++ frame := make([]byte, 4+len(unknown))
++ frame[0], frame[1], frame[2], frame[3] = 0, 0, 0, byte(len(unknown))
++ copy(frame[4:], unknown)
++ if _, err := Unpack(frame); err == nil {
++ t.Fatal("accepted an unknown kind")
++ }
++}
++
++func TestSafeNameIsOnePathSegment(t *testing.T) {
++ cases := map[string]string{
++ "../../etc/passwd": "_.._etc_passwd",
++ `C:\Users\me\file.txt`: "C__Users_me_file.txt",
++ ".hidden": "hidden",
++ " ": "received-file",
++ "a\x00b.txt": "a_b.txt",
++ }
++ for in, want := range cases {
++ if got := SafeName(in); got != want {
++ t.Fatalf("SafeName(%q) = %q, want %q", in, got, want)
++ }
++ }
++ long := strings.Repeat("x", 300)
++ if got := SafeName(long); len([]rune(got)) != 180 {
++ t.Fatalf("a long name = %d runes, want 180", len([]rune(got)))
++ }
++}
++
++func TestSaveNeverReplacesAFile(t *testing.T) {
++ dir := t.TempDir()
++ first, err := Save(dir, "report.pdf", []byte("first"))
++ if err != nil {
++ t.Fatal(err)
++ }
++ second, err := Save(dir, "report.pdf", []byte("second"))
++ if err != nil {
++ t.Fatal(err)
++ }
++ if first == second || filepath.Base(second) != "report (2).pdf" {
++ t.Fatalf("paths = %q, %q", first, second)
++ }
++ b, err := os.ReadFile(second)
++ if err != nil || string(b) != "second" {
++ t.Fatalf("second file = %q, %v", b, err)
++ }
++ if runtime.GOOS != "windows" {
++ info, err := os.Stat(second)
++ if err != nil || info.Mode().Perm() != 0o600 {
++ t.Fatalf("saved mode = %v, %v", info.Mode().Perm(), err)
++ }
++ }
++}
++
++func TestSaveRefusesAnUnwritableDownloadsFolder(t *testing.T) {
++ if runtime.GOOS == "windows" {
++ t.Skip("the test uses a file where a folder should be")
++ }
++ dir := t.TempDir()
++ blocked := filepath.Join(dir, "not-a-folder")
++ if err := os.WriteFile(blocked, []byte("x"), 0o600); err != nil {
++ t.Fatal(err)
++ }
++ if _, err := Save(blocked, "file.txt", []byte("data")); err == nil {
++ t.Fatal("saved into a path that is a file")
++ }
++}
ddiff --git a/internal/send/service.go b/internal/send/service.go
nnew file mode 100644
iindex 0000000..ad23c01
---- /dev/null
++++ b/internal/send/service.go
@@@ -0,0 +1,383 @@
++package send
++
++import (
++ "context"
++ "encoding/base64"
++ "errors"
++ "fmt"
++ "io"
++ "log/slog"
++ "strings"
++ "sync"
++ "time"
++
++ "github.com/OpenVibers/OpenVibe.Link/internal/lockeropen"
++)
++
++// maxClipboardText is what one clipboard item may carry. A copied paragraph is tiny; a whole document is not what
++// clipboard sync is for.
++const maxClipboardText = 64 << 10
++
++// maxClipboardEnvelope bounds the base64 Locker envelope a clipboard item can have, with room for the header and
++// padding. While auto-accept is off, the service only opens items below it, so a large file is never marked accepted
++// just to learn that it is not clipboard text.
++const maxClipboardEnvelope = 160 << 10
++
++// Relay is the part of *Client the service uses, so the service can be tested without a network.
++type Relay interface {
++ Targets(ctx context.Context) ([]Machine, error)
++ Inbox(ctx context.Context) ([]Item, error)
++ Accept(ctx context.Context, id string) (string, error)
++ Complete(ctx context.Context, id string) error
++ Send(ctx context.Context, target, envelope string) error
++}
++
++// Direct is this machine's queue of items another of the owner's machines handed over on the local network. The
++// service only opens items whose sender is one of this account's machines; a granted transfer from another account
++// stays in the window for the person to answer.
++type Direct interface {
++ Items() []Incoming
++ Accept(id string) (string, error)
++ Complete(id string) error
++}
++
++type ServiceOptions struct {
++ Relay Relay
++ Direct Direct
++ Key JWK
++ Principal string
++ Downloads string
++ Clipboard Clipboard
++ Settings func() Settings
++ Log *slog.Logger
++ OnActivity func(string)
++ Now func() time.Time
++}
++
++// Service is Link Send's background half: it receives items for this machine when the person turned auto-accept on,
++// keeps the clipboard in step with their other machines when clipboard sync is on, and otherwise leaves everything
++// in the window for a person to answer. It never sends in clear and never opens an item before deciding to keep it.
++type Service struct {
++ opt ServiceOptions
++
++ mu sync.Mutex
++ handled map[string]bool
++ inspected map[string]Settings
++ failed map[string]bool
++ clipPrimed bool
++ clipLast string
++ clipKnown string
++}
++
++func NewService(opt ServiceOptions) *Service {
++ if opt.Log == nil {
++ opt.Log = slog.New(slog.NewTextHandler(io.Discard, nil))
++ }
++ if opt.Now == nil {
++ opt.Now = time.Now
++ }
++ if opt.Clipboard == nil {
++ opt.Clipboard = SystemClipboard()
++ }
++ return &Service{opt: opt, handled: map[string]bool{}, inspected: map[string]Settings{}, failed: map[string]bool{}}
++}
++
++// Run polls the relay and the direct queue, and reads this machine's clipboard while the person has that switch on,
++// until ctx ends. Registration and the direct listener belong to the window (internal/agent), not here.
++func (s *Service) Run(ctx context.Context) {
++ tick := time.NewTicker(2 * time.Second)
++ defer tick.Stop()
++ s.poll(ctx)
++ s.syncClipboard(ctx)
++ for {
++ select {
++ case <-ctx.Done():
++ return
++ case <-tick.C:
++ s.poll(ctx)
++ s.syncClipboard(ctx)
++ }
++ }
++}
++
++func (s *Service) settings() Settings {
++ if s.opt.Settings == nil {
++ return DefaultSettings()
++ }
++ return s.opt.Settings()
++}
++
++func (s *Service) activity(text string) {
++ if s.opt.OnActivity != nil && strings.TrimSpace(text) != "" {
++ s.opt.OnActivity(text)
++ }
++}
++
++type waiting struct {
++ id string
++ sender string
++ bytes int64
++ transport string
++}
++
++func (s *Service) poll(ctx context.Context) {
++ settings := s.settings()
++ if !settings.AutoAccept && !settings.Clipboard {
++ return
++ }
++ var items []waiting
++ if s.opt.Relay != nil {
++ relayItems, err := s.opt.Relay.Inbox(ctx)
++ if err != nil {
++ s.opt.Log.Debug("Link Send inbox could not be read", "err", err)
++ }
++ for _, item := range relayItems {
++ items = append(items, waiting{id: item.ID, sender: item.SenderName, bytes: item.Bytes, transport: "relay"})
++ }
++ }
++ var direct []Incoming
++ if s.opt.Direct != nil {
++ direct = s.opt.Direct.Items()
++ }
++ if len(direct) > 0 {
++ own, err := s.ownMachines(ctx)
++ if err != nil {
++ s.opt.Log.Debug("Link Send could not check its own machines", "err", err)
++ direct = nil
++ }
++ for _, item := range direct {
++ if !own[item.Sender] {
++ continue
++ }
++ items = append(items, waiting{id: item.ID, sender: item.SenderName, bytes: item.Bytes, transport: "direct"})
++ }
++ }
++ for _, item := range items {
++ if !s.shouldLook(settings, item.bytes) {
++ continue
++ }
++ s.mu.Lock()
++ done := s.handled[item.id] || s.failed[item.id]
++ before, seen := s.inspected[item.id]
++ s.mu.Unlock()
++ if done || (seen && before == settings) {
++ continue
++ }
++ s.handle(ctx, settings, item)
++ }
++}
++
++// shouldLook says whether an item could be one this machine handles with the switches as they are. When auto-accept
++// is off only a clipboard-sized item is opened, because only its kind can make it something to keep now.
++func (s *Service) shouldLook(settings Settings, bytes int64) bool {
++ if settings.AutoAccept {
++ return true
++ }
++ return settings.Clipboard && bytes > 0 && bytes <= maxClipboardEnvelope
++}
++
++func (s *Service) ownMachines(ctx context.Context) (map[string]bool, error) {
++ out := map[string]bool{}
++ if s.opt.Relay == nil {
++ return out, nil
++ }
++ machines, err := s.opt.Relay.Targets(ctx)
++ if err != nil {
++ return nil, err
++ }
++ for _, machine := range machines {
++ out[machine.ID] = true
++ }
++ return out, nil
++}
++
++func (s *Service) handle(ctx context.Context, settings Settings, item waiting) {
++ envelope, err := s.envelope(ctx, item)
++ if err != nil {
++ s.opt.Log.Debug("a waiting item could not be fetched", "id", item.id, "err", err)
++ return
++ }
++ raw, err := lockeropen.DecodeEnvelope(envelope)
++ if err != nil {
++ s.fail(item, "A transfer could not be opened; ask the other machine to send it again.")
++ return
++ }
++ plain, err := lockeropen.Open(raw, lockeropen.JWK{Kty: s.opt.Key.Kty, Crv: s.opt.Key.Crv, X: s.opt.Key.X, Y: s.opt.Key.Y, D: s.opt.Key.D},
++ lockeropen.Subject{Type: "link", ID: s.opt.Principal})
++ if err != nil {
++ s.fail(item, "A transfer was not encrypted for this machine, so Link could not open it.")
++ return
++ }
++ packed, err := Unpack(plain)
++ if err != nil {
++ s.fail(item, "A transfer did not arrive in full; ask the other machine to send it again.")
++ return
++ }
++ switch packed.Kind {
++ case KindClipboard:
++ if !settings.Clipboard {
++ s.inspect(item, settings)
++ return
++ }
++ if s.opt.Clipboard == nil {
++ s.inspect(item, settings)
++ return
++ }
++ if len(packed.Data) > maxClipboardText {
++ s.fail(item, "Copied text from another machine was too large to put on this clipboard.")
++ return
++ }
++ text := string(packed.Data)
++ if err := s.opt.Clipboard.Write(ctx, text); err != nil {
++ s.opt.Log.Warn("copied text could not be put on the clipboard", "err", err)
++ s.inspect(item, settings)
++ return
++ }
++ s.mu.Lock()
++ s.clipKnown, s.clipLast = text, text
++ s.mu.Unlock()
++ s.complete(ctx, item)
++ s.activity("Put copied text from " + orMachine(item.sender) + " on this computer's clipboard")
++ default:
++ if !settings.AutoAccept {
++ s.inspect(item, settings)
++ return
++ }
++ path, err := Save(s.opt.Downloads, packed.Name, packed.Data)
++ if err != nil {
++ s.fail(item, err.Error())
++ return
++ }
++ s.complete(ctx, item)
++ s.activity("Saved " + baseName(path) + " from " + orMachine(item.sender) + " to Downloads")
++ }
++}
++
++func (s *Service) envelope(ctx context.Context, item waiting) (string, error) {
++ if item.transport == "direct" {
++ if s.opt.Direct == nil {
++ return "", errors.New("the direct queue is not available")
++ }
++ return s.opt.Direct.Accept(item.id)
++ }
++ if s.opt.Relay == nil {
++ return "", errors.New("this machine is not paired for Link Send")
++ }
++ return s.opt.Relay.Accept(ctx, item.id)
++}
++
++func (s *Service) complete(ctx context.Context, item waiting) {
++ var err error
++ if item.transport == "direct" {
++ err = s.opt.Direct.Complete(item.id)
++ } else {
++ err = s.opt.Relay.Complete(ctx, item.id)
++ }
++ if err != nil {
++ // The item is already saved here; a lost completion only leaves the sender waiting until the relay expires it.
++ s.opt.Log.Warn("the relay did not learn an item was saved", "id", item.id, "err", err)
++ }
++ s.mu.Lock()
++ s.handled[item.id] = true
++ delete(s.inspected, item.id)
++ s.mu.Unlock()
++}
++
++// inspect remembers that this item was opened under these switches and left for the person. It is looked at again
++// only after a switch changes, so a file that is waiting does not get accepted on every poll.
++func (s *Service) inspect(item waiting, settings Settings) {
++ s.mu.Lock()
++ s.inspected[item.id] = settings
++ s.mu.Unlock()
++}
++
++func (s *Service) fail(item waiting, text string) {
++ s.mu.Lock()
++ s.failed[item.id] = true
++ s.mu.Unlock()
++ s.opt.Log.Warn("a Link Send item could not be received", "id", item.id)
++ s.activity(text)
++}
++
++// syncClipboard sends copied text to the person's other machines while the switch is on. Text only, and the first
++// thing it sees after the switch is turned on is only remembered, never sent.
++func (s *Service) syncClipboard(ctx context.Context) {
++ settings := s.settings()
++ if !settings.Clipboard || s.opt.Clipboard == nil || s.opt.Relay == nil {
++ s.mu.Lock()
++ s.clipPrimed = false
++ s.mu.Unlock()
++ return
++ }
++ text, err := s.opt.Clipboard.Read(ctx)
++ if err != nil {
++ return
++ }
++ s.mu.Lock()
++ if !s.clipPrimed {
++ s.clipPrimed, s.clipLast = true, text
++ s.mu.Unlock()
++ return
++ }
++ changed := text != s.clipLast
++ echo := text == s.clipKnown
++ if changed && !echo {
++ s.clipKnown = ""
++ }
++ s.clipLast = text
++ s.mu.Unlock()
++ if !changed || echo || strings.TrimSpace(text) == "" {
++ return
++ }
++ if len(text) > maxClipboardText {
++ s.activity("A copied text was larger than 64 KB, so it was not synced")
++ return
++ }
++ machines, err := s.opt.Relay.Targets(ctx)
++ if err != nil {
++ s.opt.Log.Debug("Link Send could not list this account's machines", "err", err)
++ return
++ }
++ sent := 0
++ for _, machine := range machines {
++ if machine.PublicKey.Kty != "EC" || machine.PublicKey.Crv != "P-256" || machine.PublicKey.X == "" || machine.PublicKey.Y == "" {
++ continue
++ }
++ sealed, err := lockeropen.Seal(PackKind("clipboard.txt", "text/plain;charset=utf-8", KindClipboard, []byte(text)),
++ lockeropen.Subject{Type: "link", ID: machine.ID},
++ lockeropen.JWK{Kty: machine.PublicKey.Kty, Crv: machine.PublicKey.Crv, X: machine.PublicKey.X, Y: machine.PublicKey.Y}, lockeropen.SealOptions{})
++ if err != nil {
++ s.opt.Log.Debug("a clipboard item could not be sealed", "machine", machine.ID, "err", err)
++ continue
++ }
++ if err := s.opt.Relay.Send(ctx, machine.ID, base64.StdEncoding.EncodeToString(sealed)); err != nil {
++ s.opt.Log.Debug("a clipboard item was not sent", "machine", machine.ID, "err", err)
++ continue
++ }
++ sent++
++ }
++ if sent > 0 {
++ s.activity("Synced this computer's clipboard to " + plural(sent, "other machine", "other machines"))
++ }
++}
++
++func orMachine(name string) string {
++ if strings.TrimSpace(name) == "" {
++ return "your other machine"
++ }
++ return name
++}
++
++func baseName(path string) string {
++ if i := strings.LastIndexAny(path, `/\`); i >= 0 {
++ return path[i+1:]
++ }
++ return path
++}
++
++func plural(n int, one, many string) string {
++ if n == 1 {
++ return fmt.Sprintf("%d %s", n, one)
++ }
++ return fmt.Sprintf("%d %s", n, many)
++}
ddiff --git a/internal/send/service_test.go b/internal/send/service_test.go
nnew file mode 100644
iindex 0000000..7a8c288
---- /dev/null
++++ b/internal/send/service_test.go
@@@ -0,0 +1,363 @@
++package send
++
++import (
++ "context"
++ "encoding/base64"
++ "errors"
++ "os"
++ "path/filepath"
++ "strings"
++ "testing"
++
++ "github.com/OpenVibers/OpenVibe.Link/internal/lockeropen"
++)
++
++var (
++ testMachine = "lnk_" + "01JAB2C3D4E5F6G7H8J9K0MNPQ"
++ peerMachine = "lnk_" + "01JAB2C3D4E5F6G7H8J9K0MNPR"
++ otherMachine = "lnk_" + "OTHER"
++)
++
++type fakeRelay struct {
++ targets []Machine
++ items []Item
++ envelopes map[string]string
++ accepted []string
++ completed []string
++ sent []sentItem
++ targetsErr error
++}
++
++type sentItem struct{ target, envelope string }
++
++func (f *fakeRelay) Targets(context.Context) ([]Machine, error) {
++ if f.targetsErr != nil {
++ return nil, f.targetsErr
++ }
++ return f.targets, nil
++}
++func (f *fakeRelay) Inbox(context.Context) ([]Item, error) { return f.items, nil }
++func (f *fakeRelay) Accept(_ context.Context, id string) (string, error) {
++ f.accepted = append(f.accepted, id)
++ envelope, ok := f.envelopes[id]
++ if !ok {
++ return "", errors.New("not waiting")
++ }
++ return envelope, nil
++}
++func (f *fakeRelay) Complete(_ context.Context, id string) error {
++ f.completed = append(f.completed, id)
++ return nil
++}
++func (f *fakeRelay) Send(_ context.Context, target, envelope string) error {
++ f.sent = append(f.sent, sentItem{target: target, envelope: envelope})
++ return nil
++}
++
++type fakeDirect struct {
++ items []Incoming
++ accepted []string
++ completed []string
++}
++
++func (f *fakeDirect) Items() []Incoming { return f.items }
++func (f *fakeDirect) Accept(id string) (string, error) {
++ f.accepted = append(f.accepted, id)
++ for _, item := range f.items {
++ if item.ID == id {
++ return item.Envelope, nil
++ }
++ }
++ return "", errors.New("not waiting")
++}
++func (f *fakeDirect) Complete(id string) error {
++ f.completed = append(f.completed, id)
++ return nil
++}
++
++type fakeClipboard struct {
++ text string
++ writes []string
++}
++
++func (f *fakeClipboard) Read(context.Context) (string, error) { return f.text, nil }
++func (f *fakeClipboard) Write(_ context.Context, text string) error {
++ f.writes = append(f.writes, text)
++ return nil
++}
++
++func lockerJWK(key JWK) lockeropen.JWK {
++ return lockeropen.JWK{Kty: key.Kty, Crv: key.Crv, X: key.X, Y: key.Y, D: key.D}
++}
++
++func testSealed(t *testing.T, key JWK, machine, kind, name string, data []byte) string {
++ t.Helper()
++ envelope, err := lockeropen.Seal(PackKind(name, "text/plain;charset=utf-8", kind, data),
++ lockeropen.Subject{Type: "link", ID: machine}, lockerJWK(key), lockeropen.SealOptions{})
++ if err != nil {
++ t.Fatal(err)
++ }
++ return base64.StdEncoding.EncodeToString(envelope)
++}
++
++func serviceKey(t *testing.T) JWK {
++ t.Helper()
++ key, err := LoadOrCreate(t.TempDir())
++ if err != nil {
++ t.Fatal(err)
++ }
++ return key
++}
++
++func serviceFor(t *testing.T, key JWK, relay Relay, direct Direct, settings Settings, clipboard Clipboard) (*Service, string) {
++ t.Helper()
++ downloads := t.TempDir()
++ service := NewService(ServiceOptions{
++ Relay: relay, Direct: direct, Key: key, Principal: testMachine,
++ Downloads: downloads, Clipboard: clipboard,
++ Settings: func() Settings { return settings },
++ })
++ return service, downloads
++}
++
++func TestAutoAcceptSavesAFileWithoutTheWindow(t *testing.T) {
++ key, err := LoadOrCreate(t.TempDir())
++ if err != nil {
++ t.Fatal(err)
++ }
++ envelope := testSealed(t, key, testMachine, KindFile, "report.txt", []byte("quarterly numbers"))
++ relay := &fakeRelay{
++ items: []Item{{ID: "snd_0123456789abcdef0123456789abcdef", SenderName: "Laptop", Bytes: int64(len(envelope))}},
++ envelopes: map[string]string{"snd_0123456789abcdef0123456789abcdef": envelope},
++ }
++ downloads := t.TempDir()
++ service := NewService(ServiceOptions{
++ Relay: relay, Key: key, Principal: testMachine, Downloads: downloads,
++ Settings: func() Settings { return Settings{AutoAccept: true} },
++ })
++ service.poll(context.Background())
++ b, err := os.ReadFile(filepath.Join(downloads, "report.txt"))
++ if err != nil || string(b) != "quarterly numbers" {
++ t.Fatalf("saved file = %q, %v", b, err)
++ }
++ if len(relay.completed) != 1 {
++ t.Fatalf("completed = %v", relay.completed)
++ }
++ service.poll(context.Background())
++ entries, _ := os.ReadDir(downloads)
++ if len(entries) != 1 || len(relay.completed) != 1 {
++ t.Fatalf("a second poll saved the item again: %d files, completed %v", len(entries), relay.completed)
++ }
++}
++
++func TestAutoAcceptOffLeavesAFileWaiting(t *testing.T) {
++ key, err := LoadOrCreate(t.TempDir())
++ if err != nil {
++ t.Fatal(err)
++ }
++ envelope := testSealed(t, key, testMachine, KindFile, "report.txt", []byte("kept for later"))
++ relay := &fakeRelay{
++ items: []Item{{ID: "snd_0123456789abcdef0123456789abcdef", SenderName: "Laptop", Bytes: int64(len(envelope))}},
++ envelopes: map[string]string{"snd_0123456789abcdef0123456789abcdef": envelope},
++ }
++ settings := Settings{}
++ service, downloads := serviceFor(t, serviceKey(t), relay, nil, settings, nil)
++ service.opt.Settings = func() Settings { return settings }
++ service.poll(context.Background())
++ if len(relay.accepted) != 0 || len(relay.completed) != 0 {
++ t.Fatalf("auto-accept off accepted %v, completed %v", relay.accepted, relay.completed)
++ }
++ if entries, _ := os.ReadDir(downloads); len(entries) != 0 {
++ t.Fatalf("auto-accept off saved %d files", len(entries))
++ }
++}
++
++func TestClipboardOffDoesNotConsumeAClipboardItem(t *testing.T) {
++ key, err := LoadOrCreate(t.TempDir())
++ if err != nil {
++ t.Fatal(err)
++ }
++ envelope := testSealed(t, key, testMachine, KindClipboard, "clipboard.txt", []byte("copied text"))
++ relay := &fakeRelay{
++ items: []Item{{ID: "snd_0123456789abcdef0123456789abcdef", SenderName: "Laptop", Bytes: int64(len(envelope))}},
++ envelopes: map[string]string{"snd_0123456789abcdef0123456789abcdef": envelope},
++ }
++ clip := &fakeClipboard{}
++ service, _ := serviceFor(t, key, relay, nil, Settings{AutoAccept: true}, clip)
++ service.poll(context.Background())
++ if len(clip.writes) != 0 {
++ t.Fatalf("clipboard switch off wrote %q", clip.writes)
++ }
++ if len(relay.completed) != 0 {
++ t.Fatalf("clipboard switch off completed the item: %v", relay.completed)
++ }
++}
++
++func TestClipboardOnPutsReceivedTextOnTheClipboard(t *testing.T) {
++ key, err := LoadOrCreate(t.TempDir())
++ if err != nil {
++ t.Fatal(err)
++ }
++ envelope := testSealed(t, key, testMachine, KindClipboard, "clipboard.txt", []byte("from the desktop"))
++ relay := &fakeRelay{
++ items: []Item{{ID: "snd_0123456789abcdef0123456789abcdef", SenderName: "Desktop", Bytes: int64(len(envelope))}},
++ envelopes: map[string]string{"snd_0123456789abcdef0123456789abcdef": envelope},
++ }
++ clip := &fakeClipboard{}
++ service, _ := serviceFor(t, key, relay, nil, Settings{AutoAccept: true, Clipboard: true}, clip)
++ service.poll(context.Background())
++ if len(clip.writes) != 1 || clip.writes[0] != "from the desktop" {
++ t.Fatalf("clipboard writes = %v", clip.writes)
++ }
++ if len(relay.completed) != 1 {
++ t.Fatalf("completed = %v", relay.completed)
++ }
++}
++
++func TestClipboardSyncSealsCopiedTextForEachMachine(t *testing.T) {
++ targetKey, err := LoadOrCreate(t.TempDir())
++ if err != nil {
++ t.Fatal(err)
++ }
++ relay := &fakeRelay{targets: []Machine{{
++ ID: peerMachine, Name: "Desktop", PublicKey: Public(targetKey),
++ }}}
++ clip := &fakeClipboard{text: "before"}
++ service, _ := serviceFor(t, serviceKey(t), relay, nil, Settings{Clipboard: true}, clip)
++ service.syncClipboard(context.Background()) // prime: the first text is never sent
++ clip.text = "copy this"
++ service.syncClipboard(context.Background())
++ if len(relay.sent) != 1 {
++ t.Fatalf("sent = %v", relay.sent)
++ }
++ raw, err := base64.StdEncoding.DecodeString(relay.sent[0].envelope)
++ if err != nil {
++ t.Fatal(err)
++ }
++ plain, err := lockeropen.Open(raw, lockerJWK(targetKey), lockeropen.Subject{Type: "link", ID: peerMachine})
++ if err != nil {
++ t.Fatal(err)
++ }
++ packed, err := Unpack(plain)
++ if err != nil || packed.Kind != KindClipboard || string(packed.Data) != "copy this" {
++ t.Fatalf("packed = %#v, %v", packed, err)
++ }
++}
++
++func TestClipboardEchoFromAnotherMachineIsNotSentBack(t *testing.T) {
++ relay := &fakeRelay{targets: []Machine{{
++ ID: peerMachine, Name: "Desktop",
++ PublicKey: JWK{Kty: "EC", Crv: "P-256", X: "x", Y: "y"},
++ }}}
++ clip := &fakeClipboard{text: "same"}
++ service, _ := serviceFor(t, serviceKey(t), relay, nil, Settings{Clipboard: true}, clip)
++ service.syncClipboard(context.Background())
++ service.mu.Lock()
++ service.clipKnown = "same"
++ service.mu.Unlock()
++ service.syncClipboard(context.Background())
++ if len(relay.sent) != 0 {
++ t.Fatalf("an echoed clipboard was sent back: %v", relay.sent)
++ }
++}
++
++func TestDirectItemFromAnotherAccountStaysForAPerson(t *testing.T) {
++ key, err := LoadOrCreate(t.TempDir())
++ if err != nil {
++ t.Fatal(err)
++ }
++ envelope := testSealed(t, key, testMachine, KindFile, "granted.txt", []byte("from someone else"))
++ direct := &fakeDirect{items: []Incoming{{ID: "lan_0123456789abcdef0123456789abcdef", Sender: otherMachine, SenderName: "Jo's laptop", Envelope: envelope}}}
++ relay := &fakeRelay{} // the sender is not one of this account's own machines
++ service, downloads := serviceFor(t, key, relay, direct, Settings{AutoAccept: true}, nil)
++ service.poll(context.Background())
++ if len(direct.accepted) != 0 || len(direct.completed) != 0 {
++ t.Fatalf("a granted item was auto-accepted: %v", direct.accepted)
++ }
++ if entries, _ := os.ReadDir(downloads); len(entries) != 0 {
++ t.Fatalf("a granted item was saved: %d files", len(entries))
++ }
++}
++
++func TestOwnDirectItemIsAutoAccepted(t *testing.T) {
++ key, err := LoadOrCreate(t.TempDir())
++ if err != nil {
++ t.Fatal(err)
++ }
++ envelope := testSealed(t, key, testMachine, KindFile, "from-phone.txt", []byte("hello"))
++ direct := &fakeDirect{items: []Incoming{{ID: "lan_0123456789abcdef0123456789abcdef", Sender: peerMachine, SenderName: "Phone", Envelope: envelope}}}
++ relay := &fakeRelay{targets: []Machine{{ID: peerMachine, Name: "Phone"}}}
++ service, downloads := serviceFor(t, key, relay, direct, Settings{AutoAccept: true}, nil)
++ service.poll(context.Background())
++ b, err := os.ReadFile(filepath.Join(downloads, "from-phone.txt"))
++ if err != nil || string(b) != "hello" {
++ t.Fatalf("saved = %q, %v", b, err)
++ }
++ if len(direct.completed) != 1 {
++ t.Fatalf("completed = %v", direct.completed)
++ }
++}
++
++func TestServiceWithoutSwitchesDoesNothing(t *testing.T) {
++ relay := &fakeRelay{items: []Item{{ID: "snd_0123456789abcdef0123456789abcdef", Bytes: 10}}}
++ service, _ := serviceFor(t, serviceKey(t), relay, nil, Settings{}, nil)
++ service.poll(context.Background())
++ if len(relay.accepted) != 0 || len(relay.completed) != 0 {
++ t.Fatalf("nothing should be touched with both switches off: %v %v", relay.accepted, relay.completed)
++ }
++}
++
++func TestClipboardSettingsStartOff(t *testing.T) {
++ if DefaultSettings() != (Settings{}) {
++ t.Fatalf("default settings = %#v, want both off", DefaultSettings())
++ }
++}
++
++func TestSealedItemKeepsItsKind(t *testing.T) {
++ if got := strings.TrimSpace(KindClipboard); got != "clipboard" {
++ t.Fatalf("kind = %q", got)
++ }
++}
++
++func TestClipboardOnlyOpensASmallItemOnceAndLeavesAFile(t *testing.T) {
++ key, err := LoadOrCreate(t.TempDir())
++ if err != nil {
++ t.Fatal(err)
++ }
++ envelope := testSealed(t, key, testMachine, KindFile, "tiny.txt", []byte("small file"))
++ relay := &fakeRelay{
++ items: []Item{{ID: "snd_0123456789abcdef0123456789abcdef", SenderName: "Laptop", Bytes: int64(len(envelope))}},
++ envelopes: map[string]string{"snd_0123456789abcdef0123456789abcdef": envelope},
++ }
++ settings := Settings{Clipboard: true}
++ service, downloads := serviceFor(t, key, relay, nil, settings, &fakeClipboard{})
++ service.opt.Settings = func() Settings { return settings }
++ service.poll(context.Background())
++ service.poll(context.Background())
++ if len(relay.accepted) != 1 {
++ t.Fatalf("a small file was accepted %d times, want once", len(relay.accepted))
++ }
++ if len(relay.completed) != 0 {
++ t.Fatalf("auto-accept off completed %v", relay.completed)
++ }
++ if entries, _ := os.ReadDir(downloads); len(entries) != 0 {
++ t.Fatalf("auto-accept off saved %d files", len(entries))
++ }
++ settings.AutoAccept = true
++ service.poll(context.Background())
++ if b, err := os.ReadFile(filepath.Join(downloads, "tiny.txt")); err != nil || string(b) != "small file" {
++ t.Fatalf("after auto-accept was turned on, saved = %q, %v", b, err)
++ }
++}
++
++func TestLargeItemIsNotOpenedWhileOnlyClipboardIsOn(t *testing.T) {
++ relay := &fakeRelay{
++ items: []Item{{ID: "snd_0123456789abcdef0123456789abcdef", SenderName: "Laptop", Bytes: int64(maxClipboardEnvelope + 1)}},
++ envelopes: map[string]string{"snd_0123456789abcdef0123456789abcdef": "not fetched"},
++ }
++ service, _ := serviceFor(t, serviceKey(t), relay, nil, Settings{Clipboard: true}, &fakeClipboard{})
++ service.poll(context.Background())
++ if len(relay.accepted) != 0 {
++ t.Fatalf("a large item was accepted just to check its kind: %v", relay.accepted)
++ }
++}
ddiff --git a/internal/send/settings.go b/internal/send/settings.go
nnew file mode 100644
iindex 0000000..08a70de
---- /dev/null
++++ b/internal/send/settings.go
@@@ -0,0 +1,13 @@
++package send
++
++// Settings are the two per-machine choices Link Send needs while it runs. Both start off. The daemon keeps them in
++// panel.json beside the capability switches (internal/agent), so this package only carries the snapshot and never
++// writes a second settings file.
++type Settings struct {
++ // AutoAccept saves items from the person's own machines without asking first.
++ AutoAccept bool
++ // Clipboard syncs copied text with the person's other machines. Text only, and off until turned on.
++ Clipboard bool
++}
++
++func DefaultSettings() Settings { return Settings{} }