LocalSend-compatible file-transfer TUI for headless Arch/Omarchy servers. - Pure-stdlib implementation of the LocalSend v2 protocol (discovery, HTTPS with matching cert fingerprint, send/receive, PIN). - Bubble Tea TUI: Devices, Transfers, Manage (received-file housekeeping) and Settings, theme-aware on Omarchy. - Dual-mode install.sh: curl-pipe download or build-from-clone. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
118 lines
2.7 KiB
Go
118 lines
2.7 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"sync"
|
|
|
|
"omarchy-send/internal/protocol"
|
|
)
|
|
|
|
// fileEntry tracks one file within a session.
|
|
type fileEntry struct {
|
|
meta protocol.FileMetadata
|
|
token string
|
|
done bool
|
|
}
|
|
|
|
// session is one accepted prepare-upload, holding per-file tokens and a cancel
|
|
// hook that aborts in-flight writes.
|
|
type session struct {
|
|
id string
|
|
peer protocol.DeviceInfo
|
|
ip string
|
|
files map[string]*fileEntry // by fileId
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
}
|
|
|
|
// sessionStore is the concurrency-safe registry of active sessions.
|
|
type sessionStore struct {
|
|
mu sync.Mutex
|
|
sessions map[string]*session
|
|
}
|
|
|
|
func newSessionStore() *sessionStore {
|
|
return &sessionStore{sessions: make(map[string]*session)}
|
|
}
|
|
|
|
// randToken returns a random 32-hex-char token/id.
|
|
func randToken() string {
|
|
b := make([]byte, 16)
|
|
_, _ = rand.Read(b)
|
|
return hex.EncodeToString(b)
|
|
}
|
|
|
|
// create builds a session for the given files and returns it plus the
|
|
// fileId->token map for the prepare-upload response.
|
|
func (s *sessionStore) create(peer protocol.DeviceInfo, ip string, files map[string]protocol.FileMetadata) (*session, map[string]string) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
sess := &session{
|
|
id: randToken(),
|
|
peer: peer,
|
|
ip: ip,
|
|
files: make(map[string]*fileEntry, len(files)),
|
|
ctx: ctx,
|
|
cancel: cancel,
|
|
}
|
|
tokens := make(map[string]string, len(files))
|
|
for fileID, meta := range files {
|
|
tok := randToken()
|
|
sess.files[fileID] = &fileEntry{meta: meta, token: tok}
|
|
tokens[fileID] = tok
|
|
}
|
|
|
|
s.mu.Lock()
|
|
s.sessions[sess.id] = sess
|
|
s.mu.Unlock()
|
|
return sess, tokens
|
|
}
|
|
|
|
// lookup returns the session and file entry for an upload, validating the token.
|
|
func (s *sessionStore) lookup(sessionID, fileID, token string) (*session, *fileEntry, bool) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
sess, ok := s.sessions[sessionID]
|
|
if !ok {
|
|
return nil, nil, false
|
|
}
|
|
fe, ok := sess.files[fileID]
|
|
if !ok || fe.token != token {
|
|
return nil, nil, false
|
|
}
|
|
return sess, fe, true
|
|
}
|
|
|
|
// cancel aborts a session's in-flight writes and removes it.
|
|
func (s *sessionStore) cancel(sessionID string) {
|
|
s.mu.Lock()
|
|
sess, ok := s.sessions[sessionID]
|
|
if ok {
|
|
delete(s.sessions, sessionID)
|
|
}
|
|
s.mu.Unlock()
|
|
if ok {
|
|
sess.cancel()
|
|
}
|
|
}
|
|
|
|
// complete marks a file done and, if all files are done, removes the session.
|
|
func (s *sessionStore) complete(sessionID, fileID string) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
sess, ok := s.sessions[sessionID]
|
|
if !ok {
|
|
return
|
|
}
|
|
if fe, ok := sess.files[fileID]; ok {
|
|
fe.done = true
|
|
}
|
|
for _, fe := range sess.files {
|
|
if !fe.done {
|
|
return
|
|
}
|
|
}
|
|
sess.cancel()
|
|
delete(s.sessions, sessionID)
|
|
}
|