initial commit
This commit is contained in:
@@ -0,0 +1,881 @@
|
||||
package main
|
||||
|
||||
import "core:fmt"
|
||||
import cjson "core:encoding/json"
|
||||
import cnet "core:net"
|
||||
import "core:os"
|
||||
import "core:strings"
|
||||
import "core:thread"
|
||||
import "core:time"
|
||||
|
||||
DEFAULT_PORT :: 45821
|
||||
MAX_FRAME_BYTES :: 1024 * 1024 // 1 MiB
|
||||
HELLO_JSON :: string(`{"version":"1","type":"HELLO","device_id":"desktop-dev","device_name":"clipsync-desktop","platform":"linux","tcp_port":45821,"timestamp_ms":0,"nonce":"todo-nonce"}`)
|
||||
DISCOVERY_PERIOD :: 2 * time.Second
|
||||
PAIRED_PEERS_FILE :: "desktop-daemon/paired-peers.json"
|
||||
SOCKET_RECV_TIMEOUT :: 1 * time.Second
|
||||
CLIP_MIN_INTERVAL :: 250 * time.Millisecond
|
||||
KEEPALIVE_PING_INTERVAL :: 15 * time.Second
|
||||
KEEPALIVE_IDLE_TIMEOUT :: 45 * time.Second
|
||||
|
||||
server_clip_sequence: i64
|
||||
server_ping_sequence: i64
|
||||
|
||||
Peer_Runtime_State :: struct {
|
||||
device_id: string,
|
||||
last_clip_update_at: time.Time,
|
||||
has_last_clip_update_at: bool,
|
||||
last_content_hash: string,
|
||||
has_last_content_hash: bool,
|
||||
}
|
||||
|
||||
Paired_Peer :: struct {
|
||||
peer_device_id: string `json:"peer_device_id"`,
|
||||
peer_device_name:string `json:"peer_device_name"`,
|
||||
peer_platform: string `json:"peer_platform"`,
|
||||
peer_public_key: string `json:"peer_public_key"`,
|
||||
paired_at: i64 `json:"paired_at"`,
|
||||
}
|
||||
|
||||
Paired_Peers_Doc :: struct {
|
||||
peers: []Paired_Peer `json:"peers"`,
|
||||
}
|
||||
|
||||
Pair_Request :: struct {
|
||||
version: string `json:"version"`,
|
||||
message_type: string `json:"type"`,
|
||||
request_id: string `json:"request_id"`,
|
||||
device_id: string `json:"device_id"`,
|
||||
device_name: string `json:"device_name"`,
|
||||
platform: string `json:"platform"`,
|
||||
public_key: string `json:"public_key"`,
|
||||
pairing_method: string `json:"pairing_method"`,
|
||||
timestamp_ms: i64 `json:"timestamp_ms"`,
|
||||
}
|
||||
|
||||
Pair_Accept :: struct {
|
||||
version: string `json:"version"`,
|
||||
message_type: string `json:"type"`,
|
||||
request_id: string `json:"request_id"`,
|
||||
device_id: string `json:"device_id"`,
|
||||
timestamp_ms: i64 `json:"timestamp_ms"`,
|
||||
status: string `json:"status"`,
|
||||
}
|
||||
|
||||
Message_Header :: struct {
|
||||
version: string `json:"version"`,
|
||||
message_type: string `json:"type"`,
|
||||
request_id: string `json:"request_id"`,
|
||||
device_id: string `json:"device_id"`,
|
||||
}
|
||||
|
||||
Clip_Update :: struct {
|
||||
version: string `json:"version"`,
|
||||
message_type: string `json:"type"`,
|
||||
request_id: string `json:"request_id"`,
|
||||
device_id: string `json:"device_id"`,
|
||||
timestamp_ms: i64 `json:"timestamp_ms"`,
|
||||
origin_device_id: string `json:"origin_device_id"`,
|
||||
content_type: string `json:"content_type"`,
|
||||
encoding: string `json:"encoding"`,
|
||||
content_hash: string `json:"content_hash"`,
|
||||
content: string `json:"content"`,
|
||||
sequence: i64 `json:"sequence"`,
|
||||
}
|
||||
|
||||
Ping_Message :: struct {
|
||||
version: string `json:"version"`,
|
||||
message_type: string `json:"type"`,
|
||||
request_id: string `json:"request_id"`,
|
||||
device_id: string `json:"device_id"`,
|
||||
timestamp_ms: i64 `json:"timestamp_ms"`,
|
||||
}
|
||||
|
||||
Pong_Message :: struct {
|
||||
version: string `json:"version"`,
|
||||
message_type: string `json:"type"`,
|
||||
request_id: string `json:"request_id"`,
|
||||
device_id: string `json:"device_id"`,
|
||||
timestamp_ms: i64 `json:"timestamp_ms"`,
|
||||
}
|
||||
|
||||
paired_peers: [dynamic]Paired_Peer
|
||||
peer_runtime_states: [dynamic]Peer_Runtime_State
|
||||
|
||||
find_peer_index :: proc(device_id: string) -> int {
|
||||
for peer, idx in paired_peers {
|
||||
if peer.peer_device_id == device_id {
|
||||
return idx
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
is_paired_peer :: proc(device_id: string) -> bool {
|
||||
return find_peer_index(device_id) >= 0
|
||||
}
|
||||
|
||||
find_peer_runtime_state :: proc(device_id: string) -> ^Peer_Runtime_State {
|
||||
for i in 0..<len(peer_runtime_states) {
|
||||
if peer_runtime_states[i].device_id == device_id {
|
||||
return &peer_runtime_states[i]
|
||||
}
|
||||
}
|
||||
|
||||
append(&peer_runtime_states, Peer_Runtime_State{device_id = fmt.aprintf("%s", device_id)})
|
||||
return &peer_runtime_states[len(peer_runtime_states)-1]
|
||||
}
|
||||
|
||||
save_paired_peers :: proc() -> bool {
|
||||
doc := Paired_Peers_Doc{peers = paired_peers[:]}
|
||||
data, err := cjson.marshal(doc)
|
||||
if err != nil {
|
||||
fmt.printf("failed to encode paired peers: %v\n", err)
|
||||
return false
|
||||
}
|
||||
defer delete(data)
|
||||
|
||||
write_err := os.write_entire_file(PAIRED_PEERS_FILE, data)
|
||||
if write_err != nil {
|
||||
fmt.printf("failed to write paired peers file %q: %v\n", PAIRED_PEERS_FILE, write_err)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
load_paired_peers :: proc() {
|
||||
data, err := os.read_entire_file(PAIRED_PEERS_FILE, context.allocator)
|
||||
if err != nil {
|
||||
fmt.printf("paired peers file not loaded (%q): %v\n", PAIRED_PEERS_FILE, err)
|
||||
return
|
||||
}
|
||||
defer delete(data)
|
||||
|
||||
doc: Paired_Peers_Doc
|
||||
if uerr := cjson.unmarshal(data, &doc); uerr != nil {
|
||||
fmt.printf("paired peers file parse failed: %v\n", uerr)
|
||||
return
|
||||
}
|
||||
|
||||
append_elems(&paired_peers, ..doc.peers)
|
||||
fmt.printf("loaded %d paired peer(s)\n", len(paired_peers))
|
||||
}
|
||||
|
||||
upsert_paired_peer :: proc(peer: Paired_Peer) {
|
||||
idx := find_peer_index(peer.peer_device_id)
|
||||
if idx >= 0 {
|
||||
paired_peers[idx] = peer
|
||||
} else {
|
||||
append(&paired_peers, peer)
|
||||
}
|
||||
|
||||
_ = save_paired_peers()
|
||||
}
|
||||
|
||||
platform_name :: proc() -> string {
|
||||
when ODIN_OS == .Windows {
|
||||
return "windows"
|
||||
} else {
|
||||
return "linux"
|
||||
}
|
||||
}
|
||||
|
||||
discovery_announce_payload :: proc(port: int) -> string {
|
||||
return fmt.tprintf(
|
||||
`{"version":"1","type":"DISCOVERY_ANNOUNCE","device_id":"desktop-dev","device_name":"clipsync-desktop","platform":"%s","tcp_port":%d,"capabilities":["clipboard:text"],"timestamp_ms":0}`,
|
||||
platform_name(),
|
||||
port,
|
||||
)
|
||||
}
|
||||
|
||||
run_discovery_broadcaster :: proc() {
|
||||
socket, err := cnet.make_unbound_udp_socket(.IP4)
|
||||
if err != nil {
|
||||
fmt.printf("discovery broadcaster socket create failed: %v\n", err)
|
||||
return
|
||||
}
|
||||
defer cnet.close(socket)
|
||||
|
||||
if opt_err := cnet.set_option(socket, .Broadcast, true); opt_err != nil {
|
||||
fmt.printf("discovery broadcaster failed to enable broadcast: %v\n", opt_err)
|
||||
return
|
||||
}
|
||||
|
||||
broadcast := cnet.Endpoint{
|
||||
address = cnet.IP4_Address{255, 255, 255, 255},
|
||||
port = DEFAULT_PORT,
|
||||
}
|
||||
|
||||
fmt.printf("discovery broadcaster enabled on udp://255.255.255.255:%d\n", DEFAULT_PORT)
|
||||
|
||||
for {
|
||||
payload := discovery_announce_payload(DEFAULT_PORT)
|
||||
written, send_err := cnet.send_udp(socket, transmute([]byte)payload, broadcast)
|
||||
if send_err != nil {
|
||||
fmt.printf("discovery announce send failed: %v\n", send_err)
|
||||
} else {
|
||||
fmt.printf("discovery announce sent (%d bytes)\n", written)
|
||||
}
|
||||
|
||||
time.sleep(DISCOVERY_PERIOD)
|
||||
}
|
||||
}
|
||||
|
||||
run_discovery_listener :: proc() {
|
||||
socket, err := cnet.make_bound_udp_socket(cnet.IP4_Any, DEFAULT_PORT)
|
||||
if err != nil {
|
||||
fmt.printf("discovery listener bind failed on udp://0.0.0.0:%d: %v\n", DEFAULT_PORT, err)
|
||||
return
|
||||
}
|
||||
defer cnet.close(socket)
|
||||
|
||||
fmt.printf("discovery listener active on udp://0.0.0.0:%d\n", DEFAULT_PORT)
|
||||
|
||||
buf: [2048]byte
|
||||
for {
|
||||
read, source, recv_err := cnet.recv_udp(socket, buf[:])
|
||||
if recv_err != nil {
|
||||
fmt.printf("discovery recv failed: %v\n", recv_err)
|
||||
continue
|
||||
}
|
||||
if read <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
msg := string(buf[:read])
|
||||
if strings.contains(msg, `"type":"DISCOVERY_ANNOUNCE"`) {
|
||||
fmt.printf("discovery packet from %v (%d bytes)\n", source, read)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Framing_Error :: enum {
|
||||
None,
|
||||
Frame_Too_Large,
|
||||
Connection_Closed,
|
||||
Socket_Recv_Timeout,
|
||||
Socket_Recv_Failed,
|
||||
Socket_Send_Failed,
|
||||
}
|
||||
|
||||
Inbound_Hello :: struct {
|
||||
version: string `json:"version"`,
|
||||
message_type: string `json:"type"`,
|
||||
request_id: string `json:"request_id"`,
|
||||
device_id: string `json:"device_id"`,
|
||||
device_name: string `json:"device_name"`,
|
||||
platform: string `json:"platform"`,
|
||||
tcp_port: int `json:"tcp_port"`,
|
||||
timestamp_ms: i64 `json:"timestamp_ms"`,
|
||||
nonce: string `json:"nonce"`,
|
||||
}
|
||||
|
||||
Ack_Message :: struct {
|
||||
version: string `json:"version"`,
|
||||
message_type: string `json:"type"`,
|
||||
request_id: string `json:"request_id"`,
|
||||
device_id: string `json:"device_id"`,
|
||||
timestamp_ms: i64 `json:"timestamp_ms"`,
|
||||
status: string `json:"status"`,
|
||||
}
|
||||
|
||||
Error_Message :: struct {
|
||||
version: string `json:"version"`,
|
||||
message_type: string `json:"type"`,
|
||||
request_id: string `json:"request_id"`,
|
||||
device_id: string `json:"device_id"`,
|
||||
timestamp_ms: i64 `json:"timestamp_ms"`,
|
||||
error_code: string `json:"error_code"`,
|
||||
error_message: string `json:"error_message"`,
|
||||
}
|
||||
|
||||
encode_json_message :: proc(v: any, allocator := context.allocator) -> ([]byte, bool) {
|
||||
data, err := cjson.marshal(v, allocator=allocator)
|
||||
if err != nil {
|
||||
fmt.printf("json encode failed: %v\n", err)
|
||||
return nil, false
|
||||
}
|
||||
return data, true
|
||||
}
|
||||
|
||||
send_protocol_error :: proc(client: cnet.TCP_Socket, request_id, error_code, error_message: string) {
|
||||
msg := Error_Message{
|
||||
version = "1",
|
||||
message_type = "ERROR",
|
||||
request_id = request_id,
|
||||
device_id = "desktop-dev",
|
||||
timestamp_ms = 0,
|
||||
error_code = error_code,
|
||||
error_message = error_message,
|
||||
}
|
||||
|
||||
data, ok := encode_json_message(msg)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
defer delete(data)
|
||||
|
||||
if ferr := send_frame(client, data); ferr != .None {
|
||||
fmt.printf("failed to send ERROR frame: %v\n", ferr)
|
||||
}
|
||||
}
|
||||
|
||||
send_protocol_ack :: proc(client: cnet.TCP_Socket, request_id: string) -> bool {
|
||||
msg := Ack_Message{
|
||||
version = "1",
|
||||
message_type = "ACK",
|
||||
request_id = request_id,
|
||||
device_id = "desktop-dev",
|
||||
timestamp_ms = 0,
|
||||
status = "ok",
|
||||
}
|
||||
|
||||
data, ok := encode_json_message(msg)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
defer delete(data)
|
||||
|
||||
if ferr := send_frame(client, data); ferr != .None {
|
||||
fmt.printf("failed to send ACK frame: %v\n", ferr)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
send_ping :: proc(client: cnet.TCP_Socket) -> bool {
|
||||
server_ping_sequence += 1
|
||||
msg := Ping_Message{
|
||||
version = "1",
|
||||
message_type = "PING",
|
||||
request_id = fmt.tprintf("srv-ping-%d", server_ping_sequence),
|
||||
device_id = "desktop-dev",
|
||||
timestamp_ms = 0,
|
||||
}
|
||||
|
||||
data, ok := encode_json_message(msg)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
defer delete(data)
|
||||
|
||||
if ferr := send_frame(client, data); ferr != .None {
|
||||
fmt.printf("failed to send PING frame: %v\n", ferr)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
send_pong :: proc(client: cnet.TCP_Socket, request_id: string) -> bool {
|
||||
msg := Pong_Message{
|
||||
version = "1",
|
||||
message_type = "PONG",
|
||||
request_id = request_id,
|
||||
device_id = "desktop-dev",
|
||||
timestamp_ms = 0,
|
||||
}
|
||||
|
||||
data, ok := encode_json_message(msg)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
defer delete(data)
|
||||
|
||||
if ferr := send_frame(client, data); ferr != .None {
|
||||
fmt.printf("failed to send PONG frame: %v\n", ferr)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
send_pair_accept :: proc(client: cnet.TCP_Socket, request_id: string) -> bool {
|
||||
msg := Pair_Accept{
|
||||
version = "1",
|
||||
message_type = "PAIR_ACCEPT",
|
||||
request_id = request_id,
|
||||
device_id = "desktop-dev",
|
||||
timestamp_ms = 0,
|
||||
status = "paired",
|
||||
}
|
||||
|
||||
data, ok := encode_json_message(msg)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
defer delete(data)
|
||||
|
||||
if ferr := send_frame(client, data); ferr != .None {
|
||||
fmt.printf("failed to send PAIR_ACCEPT frame: %v\n", ferr)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
run_capture_command :: proc(command: []string) -> (string, bool) {
|
||||
desc := os.Process_Desc{command = command}
|
||||
state, stdout, stderr, exec_err := os.process_exec(desc, context.allocator)
|
||||
defer delete(stdout)
|
||||
defer delete(stderr)
|
||||
|
||||
if exec_err != nil || !state.exited || state.exit_code != 0 {
|
||||
return "", false
|
||||
}
|
||||
|
||||
trimmed := strings.trim_right(string(stdout), "\r\n")
|
||||
return fmt.aprintf("%s", trimmed), true
|
||||
}
|
||||
|
||||
run_stdin_command :: proc(command: []string, stdin_text: string) -> bool {
|
||||
stdin_r, stdin_w, pipe_err := os.pipe()
|
||||
if pipe_err != nil {
|
||||
return false
|
||||
}
|
||||
defer os.close(stdin_r)
|
||||
|
||||
desc := os.Process_Desc{
|
||||
command = command,
|
||||
stdin = stdin_r,
|
||||
}
|
||||
|
||||
process, start_err := os.process_start(desc)
|
||||
if start_err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
_, write_err := os.write(stdin_w, transmute([]byte)stdin_text)
|
||||
if write_err != nil {
|
||||
_, _ = os.process_wait(process)
|
||||
return false
|
||||
}
|
||||
|
||||
_ = os.close(stdin_w)
|
||||
|
||||
state, wait_err := os.process_wait(process)
|
||||
if wait_err != nil || !state.exited || state.exit_code != 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
read_local_clipboard_text :: proc() -> (string, bool) {
|
||||
when ODIN_OS == .Windows {
|
||||
return run_capture_command([]string{"powershell", "-NoProfile", "-Command", "Get-Clipboard"})
|
||||
} else {
|
||||
if text, ok := run_capture_command([]string{"wl-paste", "--no-newline"}); ok {
|
||||
return text, true
|
||||
}
|
||||
if text, ok := run_capture_command([]string{"xclip", "-selection", "clipboard", "-o"}); ok {
|
||||
return text, true
|
||||
}
|
||||
if text, ok := run_capture_command([]string{"xsel", "--clipboard", "--output"}); ok {
|
||||
return text, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
set_local_clipboard_text :: proc(text: string) -> bool {
|
||||
when ODIN_OS == .Windows {
|
||||
return run_stdin_command([]string{"powershell", "-NoProfile", "-Command", "$input | Set-Clipboard"}, text)
|
||||
} else {
|
||||
if run_stdin_command([]string{"wl-copy"}, text) {
|
||||
return true
|
||||
}
|
||||
if run_stdin_command([]string{"xclip", "-selection", "clipboard"}, text) {
|
||||
return true
|
||||
}
|
||||
if run_stdin_command([]string{"xsel", "--clipboard", "--input"}, text) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
send_clip_update :: proc(client: cnet.TCP_Socket, request_device_id: string, clipboard_text: string) -> bool {
|
||||
server_clip_sequence += 1
|
||||
msg := Clip_Update{
|
||||
version = "1",
|
||||
message_type = "CLIP_UPDATE",
|
||||
request_id = fmt.tprintf("srv-clip-%d", server_clip_sequence),
|
||||
device_id = "desktop-dev",
|
||||
timestamp_ms = 0,
|
||||
origin_device_id = "desktop-dev",
|
||||
content_type = "text/plain",
|
||||
encoding = "utf-8",
|
||||
content_hash = fmt.tprintf("len:%d", len(clipboard_text)),
|
||||
content = clipboard_text,
|
||||
sequence = server_clip_sequence,
|
||||
}
|
||||
|
||||
data, ok := encode_json_message(msg)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
defer delete(data)
|
||||
|
||||
if ferr := send_frame(client, data); ferr != .None {
|
||||
fmt.printf("failed to send outgoing CLIP_UPDATE to %q: %v\n", request_device_id, ferr)
|
||||
return false
|
||||
}
|
||||
|
||||
fmt.printf("sent outgoing CLIP_UPDATE to %q (%d bytes text)\n", request_device_id, len(clipboard_text))
|
||||
return true
|
||||
}
|
||||
|
||||
maybe_emit_local_clipboard_update :: proc(client: cnet.TCP_Socket, request_device_id: string, last_sent_clipboard: ^string) {
|
||||
if !is_paired_peer(request_device_id) {
|
||||
return
|
||||
}
|
||||
|
||||
clipboard_text, ok := read_local_clipboard_text()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
defer delete(clipboard_text)
|
||||
|
||||
if clipboard_text == last_sent_clipboard^ {
|
||||
return
|
||||
}
|
||||
|
||||
if send_clip_update(client, request_device_id, clipboard_text) {
|
||||
last_sent_clipboard^ = fmt.aprintf("%s", clipboard_text)
|
||||
}
|
||||
}
|
||||
|
||||
handle_pair_request :: proc(client: cnet.TCP_Socket, payload: []byte) -> bool {
|
||||
pair_request: Pair_Request
|
||||
if uerr := cjson.unmarshal(payload, &pair_request); uerr != nil {
|
||||
fmt.printf("invalid PAIR_REQUEST json payload: %v\n", uerr)
|
||||
send_protocol_error(client, "", "INVALID_MESSAGE", "unable to parse PAIR_REQUEST JSON")
|
||||
return false
|
||||
}
|
||||
|
||||
if pair_request.version != "1" || pair_request.message_type != "PAIR_REQUEST" {
|
||||
send_protocol_error(client, pair_request.request_id, "INVALID_MESSAGE", "expected PAIR_REQUEST")
|
||||
return false
|
||||
}
|
||||
if len(pair_request.device_id) == 0 || len(pair_request.public_key) == 0 {
|
||||
send_protocol_error(client, pair_request.request_id, "INVALID_MESSAGE", "PAIR_REQUEST missing required fields")
|
||||
return false
|
||||
}
|
||||
|
||||
upsert_paired_peer(Paired_Peer{
|
||||
peer_device_id = pair_request.device_id,
|
||||
peer_device_name = pair_request.device_name,
|
||||
peer_platform = pair_request.platform,
|
||||
peer_public_key = pair_request.public_key,
|
||||
paired_at = 0,
|
||||
})
|
||||
|
||||
fmt.printf("paired peer stored: id=%q method=%q\n", pair_request.device_id, pair_request.pairing_method)
|
||||
return send_pair_accept(client, pair_request.request_id)
|
||||
}
|
||||
|
||||
handle_clip_update :: proc(client: cnet.TCP_Socket, payload: []byte, request_device_id: string) -> (applied_text: string, applied: bool) {
|
||||
clip_update: Clip_Update
|
||||
if uerr := cjson.unmarshal(payload, &clip_update); uerr != nil {
|
||||
fmt.printf("invalid CLIP_UPDATE json payload: %v\n", uerr)
|
||||
send_protocol_error(client, "", "INVALID_MESSAGE", "unable to parse CLIP_UPDATE JSON")
|
||||
return "", false
|
||||
}
|
||||
|
||||
if clip_update.version != "1" || clip_update.message_type != "CLIP_UPDATE" {
|
||||
send_protocol_error(client, clip_update.request_id, "INVALID_MESSAGE", "expected CLIP_UPDATE")
|
||||
return "", false
|
||||
}
|
||||
|
||||
if !is_paired_peer(request_device_id) {
|
||||
send_protocol_error(client, clip_update.request_id, "UNPAIRED", "device is not paired")
|
||||
fmt.printf("rejected CLIP_UPDATE from unpaired device_id=%q\n", request_device_id)
|
||||
return "", false
|
||||
}
|
||||
|
||||
peer_state := find_peer_runtime_state(request_device_id)
|
||||
if peer_state.has_last_clip_update_at {
|
||||
elapsed := time.since(peer_state.last_clip_update_at)
|
||||
if elapsed < CLIP_MIN_INTERVAL {
|
||||
send_protocol_error(client, clip_update.request_id, "RATE_LIMITED", "clip updates are arriving too quickly")
|
||||
fmt.printf("rate-limited CLIP_UPDATE from device_id=%q (elapsed=%v)\n", request_device_id, elapsed)
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
incoming_hash := clip_update.content_hash
|
||||
if len(incoming_hash) == 0 {
|
||||
incoming_hash = fmt.tprintf("fallback-len:%d", len(clip_update.content))
|
||||
}
|
||||
|
||||
if peer_state.has_last_content_hash && peer_state.last_content_hash == incoming_hash {
|
||||
fmt.printf("deduped CLIP_UPDATE from device_id=%q hash=%q\n", request_device_id, incoming_hash)
|
||||
_ = send_protocol_ack(client, clip_update.request_id)
|
||||
return "", false
|
||||
}
|
||||
|
||||
if clip_update.content_type != "text/plain" {
|
||||
send_protocol_error(client, clip_update.request_id, "INVALID_MESSAGE", "unsupported content_type")
|
||||
return "", false
|
||||
}
|
||||
|
||||
if !set_local_clipboard_text(clip_update.content) {
|
||||
send_protocol_error(client, clip_update.request_id, "INVALID_MESSAGE", "failed to write local clipboard")
|
||||
fmt.printf("failed to apply CLIP_UPDATE to local clipboard for device_id=%q\n", request_device_id)
|
||||
return "", false
|
||||
}
|
||||
|
||||
fmt.printf("applied CLIP_UPDATE from paired device_id=%q content_type=%q bytes=%d\n", request_device_id, clip_update.content_type, len(clip_update.content))
|
||||
peer_state.last_clip_update_at = time.now()
|
||||
peer_state.has_last_clip_update_at = true
|
||||
peer_state.last_content_hash = fmt.aprintf("%s", incoming_hash)
|
||||
peer_state.has_last_content_hash = true
|
||||
_ = send_protocol_ack(client, clip_update.request_id)
|
||||
return clip_update.content, true
|
||||
}
|
||||
|
||||
handle_post_hello_message :: proc(client: cnet.TCP_Socket, payload: []byte, hello_device_id: string, last_sent_clipboard: ^string, awaiting_pong: ^bool) {
|
||||
header: Message_Header
|
||||
if uerr := cjson.unmarshal(payload, &header); uerr != nil {
|
||||
send_protocol_error(client, "", "INVALID_MESSAGE", "unable to parse message header")
|
||||
return
|
||||
}
|
||||
|
||||
if header.version != "1" {
|
||||
send_protocol_error(client, header.request_id, "UNSUPPORTED_VERSION", "protocol version is not supported")
|
||||
return
|
||||
}
|
||||
|
||||
if len(header.device_id) > 0 && header.device_id != hello_device_id {
|
||||
send_protocol_error(client, header.request_id, "INVALID_MESSAGE", "device_id mismatch with HELLO")
|
||||
return
|
||||
}
|
||||
|
||||
switch header.message_type {
|
||||
case "PAIR_REQUEST":
|
||||
_ = handle_pair_request(client, payload)
|
||||
case "CLIP_UPDATE":
|
||||
content, ok := handle_clip_update(client, payload, hello_device_id)
|
||||
if ok {
|
||||
last_sent_clipboard^ = fmt.aprintf("%s", content)
|
||||
}
|
||||
case "PING":
|
||||
_ = send_pong(client, header.request_id)
|
||||
case "PONG":
|
||||
awaiting_pong^ = false
|
||||
case:
|
||||
send_protocol_error(client, header.request_id, "INVALID_MESSAGE", "unsupported message type")
|
||||
}
|
||||
}
|
||||
|
||||
validate_hello :: proc(hello: Inbound_Hello) -> (valid: bool, code: string, message: string) {
|
||||
if hello.version != "1" {
|
||||
return false, "UNSUPPORTED_VERSION", "protocol version is not supported"
|
||||
}
|
||||
if hello.message_type != "HELLO" {
|
||||
return false, "INVALID_MESSAGE", "expected HELLO as first message"
|
||||
}
|
||||
if len(hello.device_id) == 0 {
|
||||
return false, "INVALID_MESSAGE", "missing device_id"
|
||||
}
|
||||
if len(hello.nonce) == 0 {
|
||||
return false, "INVALID_MESSAGE", "missing nonce"
|
||||
}
|
||||
|
||||
return true, "", ""
|
||||
}
|
||||
|
||||
handle_first_client_message :: proc(client: cnet.TCP_Socket, payload: []byte) {
|
||||
hello: Inbound_Hello
|
||||
if uerr := cjson.unmarshal(payload, &hello); uerr != nil {
|
||||
fmt.printf("invalid HELLO json payload: %v\n", uerr)
|
||||
send_protocol_error(client, "", "INVALID_MESSAGE", "unable to parse HELLO JSON")
|
||||
return
|
||||
}
|
||||
|
||||
valid, code, message := validate_hello(hello)
|
||||
if !valid {
|
||||
fmt.printf("HELLO validation failed: %s (%s)\n", code, message)
|
||||
send_protocol_error(client, hello.request_id, code, message)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.printf("HELLO accepted from device_id=%q name=%q platform=%q\n", hello.device_id, hello.device_name, hello.platform)
|
||||
_ = send_protocol_ack(client, hello.request_id)
|
||||
last_sent_clipboard := ""
|
||||
last_peer_activity_at := time.now()
|
||||
last_ping_sent_at := time.now()
|
||||
awaiting_pong := false
|
||||
|
||||
for {
|
||||
next_payload, ferr := recv_frame(client)
|
||||
if ferr != .None {
|
||||
if ferr == .Socket_Recv_Timeout {
|
||||
if time.since(last_peer_activity_at) >= KEEPALIVE_IDLE_TIMEOUT {
|
||||
fmt.printf("disconnecting idle peer device_id=%q after %v\n", hello.device_id, KEEPALIVE_IDLE_TIMEOUT)
|
||||
return
|
||||
}
|
||||
|
||||
if !awaiting_pong && time.since(last_ping_sent_at) >= KEEPALIVE_PING_INTERVAL {
|
||||
if send_ping(client) {
|
||||
awaiting_pong = true
|
||||
last_ping_sent_at = time.now()
|
||||
}
|
||||
}
|
||||
|
||||
maybe_emit_local_clipboard_update(client, hello.device_id, &last_sent_clipboard)
|
||||
continue
|
||||
}
|
||||
fmt.printf("client message loop ended: %v\n", ferr)
|
||||
return
|
||||
}
|
||||
|
||||
last_peer_activity_at = time.now()
|
||||
awaiting_pong = false
|
||||
handle_post_hello_message(client, next_payload, hello.device_id, &last_sent_clipboard, &awaiting_pong)
|
||||
delete(next_payload)
|
||||
}
|
||||
}
|
||||
|
||||
u32_to_be_bytes :: proc(v: u32) -> [4]byte {
|
||||
return [4]byte{
|
||||
byte((v >> 24) & 0xff),
|
||||
byte((v >> 16) & 0xff),
|
||||
byte((v >> 8) & 0xff),
|
||||
byte(v & 0xff),
|
||||
}
|
||||
}
|
||||
|
||||
be_bytes_to_u32 :: proc(prefix: [4]byte) -> u32 {
|
||||
return (u32(prefix[0]) << 24) |
|
||||
(u32(prefix[1]) << 16) |
|
||||
(u32(prefix[2]) << 8) |
|
||||
u32(prefix[3])
|
||||
}
|
||||
|
||||
recv_exact :: proc(socket: cnet.TCP_Socket, out: []byte) -> Framing_Error {
|
||||
offset := 0
|
||||
for offset < len(out) {
|
||||
read, err := cnet.recv_tcp(socket, out[offset:])
|
||||
if err != nil {
|
||||
if err == .Timeout {
|
||||
if offset == 0 {
|
||||
return .Socket_Recv_Timeout
|
||||
}
|
||||
continue
|
||||
}
|
||||
return .Socket_Recv_Failed
|
||||
}
|
||||
if read == 0 {
|
||||
return .Connection_Closed
|
||||
}
|
||||
offset += read
|
||||
}
|
||||
return .None
|
||||
}
|
||||
|
||||
send_all :: proc(socket: cnet.TCP_Socket, data: []byte) -> Framing_Error {
|
||||
offset := 0
|
||||
for offset < len(data) {
|
||||
written, err := cnet.send_tcp(socket, data[offset:])
|
||||
if err != nil {
|
||||
return .Socket_Send_Failed
|
||||
}
|
||||
if written == 0 {
|
||||
return .Connection_Closed
|
||||
}
|
||||
offset += written
|
||||
}
|
||||
return .None
|
||||
}
|
||||
|
||||
send_frame :: proc(socket: cnet.TCP_Socket, payload: []byte) -> Framing_Error {
|
||||
if len(payload) > MAX_FRAME_BYTES {
|
||||
return .Frame_Too_Large
|
||||
}
|
||||
|
||||
prefix := u32_to_be_bytes(u32(len(payload)))
|
||||
if err := send_all(socket, prefix[:]); err != .None {
|
||||
return err
|
||||
}
|
||||
return send_all(socket, payload)
|
||||
}
|
||||
|
||||
recv_frame :: proc(socket: cnet.TCP_Socket, allocator := context.allocator) -> ([]byte, Framing_Error) {
|
||||
len_prefix: [4]byte
|
||||
if err := recv_exact(socket, len_prefix[:]); err != .None {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
size := int(be_bytes_to_u32(len_prefix))
|
||||
if size > MAX_FRAME_BYTES {
|
||||
return nil, .Frame_Too_Large
|
||||
}
|
||||
|
||||
buf := make([]byte, size, allocator)
|
||||
if size == 0 {
|
||||
return buf, .None
|
||||
}
|
||||
|
||||
if err := recv_exact(socket, buf); err != .None {
|
||||
delete(buf)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return buf, .None
|
||||
}
|
||||
|
||||
handle_client :: proc(client: cnet.TCP_Socket, source: cnet.Endpoint) {
|
||||
defer cnet.close(client)
|
||||
fmt.printf("client connected from %v\n", source)
|
||||
_ = cnet.set_option(client, .Receive_Timeout, SOCKET_RECV_TIMEOUT)
|
||||
|
||||
if err := send_frame(client, transmute([]byte)HELLO_JSON); err != .None {
|
||||
fmt.printf("failed to send HELLO: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
payload, err := recv_frame(client)
|
||||
if err != .None {
|
||||
fmt.printf("failed to receive frame: %v\n", err)
|
||||
return
|
||||
}
|
||||
defer delete(payload)
|
||||
|
||||
fmt.printf("received first frame (%d bytes)\n", len(payload))
|
||||
handle_first_client_message(client, payload)
|
||||
}
|
||||
|
||||
run_tcp_listener :: proc(port: int) {
|
||||
endpoint := cnet.Endpoint{address = cnet.IP4_Any, port = port}
|
||||
listener, lerr := cnet.listen_tcp(endpoint)
|
||||
if lerr != nil {
|
||||
fmt.printf("failed to listen on port %d: %v\n", port, lerr)
|
||||
return
|
||||
}
|
||||
defer cnet.close(listener)
|
||||
|
||||
fmt.printf("clipsync daemon listening on tcp://0.0.0.0:%d\n", port)
|
||||
|
||||
for {
|
||||
client, source, aerr := cnet.accept_tcp(listener)
|
||||
if aerr != nil {
|
||||
fmt.printf("accept failed: %v\n", aerr)
|
||||
continue
|
||||
}
|
||||
|
||||
handle_client(client, source)
|
||||
}
|
||||
}
|
||||
|
||||
main :: proc() {
|
||||
load_paired_peers()
|
||||
|
||||
thread.run(run_discovery_broadcaster)
|
||||
thread.run(run_discovery_listener)
|
||||
|
||||
run_tcp_listener(DEFAULT_PORT)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package clipsync_net
|
||||
|
||||
import cnet "core:net"
|
||||
|
||||
// TCP framing for protocol v1:
|
||||
// [4-byte big-endian length][UTF-8 JSON payload]
|
||||
|
||||
MAX_FRAME_BYTES :: 1024 * 1024 // 1 MiB
|
||||
|
||||
Framing_Error :: enum {
|
||||
None,
|
||||
Frame_Too_Large,
|
||||
Connection_Closed,
|
||||
Socket_Recv_Failed,
|
||||
Socket_Send_Failed,
|
||||
}
|
||||
|
||||
u32_to_be_bytes :: proc(v: u32) -> [4]byte {
|
||||
return [4]byte{
|
||||
byte((v >> 24) & 0xff),
|
||||
byte((v >> 16) & 0xff),
|
||||
byte((v >> 8) & 0xff),
|
||||
byte(v & 0xff),
|
||||
}
|
||||
}
|
||||
|
||||
be_bytes_to_u32 :: proc(prefix: [4]byte) -> u32 {
|
||||
return (u32(prefix[0]) << 24) |
|
||||
(u32(prefix[1]) << 16) |
|
||||
(u32(prefix[2]) << 8) |
|
||||
u32(prefix[3])
|
||||
}
|
||||
|
||||
recv_exact :: proc(socket: cnet.TCP_Socket, out: []byte) -> Framing_Error {
|
||||
offset := 0
|
||||
for offset < len(out) {
|
||||
read, err := cnet.recv_tcp(socket, out[offset:])
|
||||
if err != nil {
|
||||
return .Socket_Recv_Failed
|
||||
}
|
||||
if read == 0 {
|
||||
return .Connection_Closed
|
||||
}
|
||||
offset += read
|
||||
}
|
||||
return .None
|
||||
}
|
||||
|
||||
send_all :: proc(socket: cnet.TCP_Socket, data: []byte) -> Framing_Error {
|
||||
offset := 0
|
||||
for offset < len(data) {
|
||||
written, err := cnet.send_tcp(socket, data[offset:])
|
||||
if err != nil {
|
||||
return .Socket_Send_Failed
|
||||
}
|
||||
if written == 0 {
|
||||
return .Connection_Closed
|
||||
}
|
||||
offset += written
|
||||
}
|
||||
return .None
|
||||
}
|
||||
|
||||
send_frame :: proc(socket: cnet.TCP_Socket, payload: []byte) -> Framing_Error {
|
||||
if len(payload) > MAX_FRAME_BYTES {
|
||||
return .Frame_Too_Large
|
||||
}
|
||||
|
||||
prefix := u32_to_be_bytes(u32(len(payload)))
|
||||
if err := send_all(socket, prefix[:]); err != .None {
|
||||
return err
|
||||
}
|
||||
return send_all(socket, payload)
|
||||
}
|
||||
|
||||
recv_frame :: proc(socket: cnet.TCP_Socket, allocator := context.allocator) -> ([]byte, Framing_Error) {
|
||||
len_prefix: [4]byte
|
||||
if err := recv_exact(socket, len_prefix[:]); err != .None {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
size := int(be_bytes_to_u32(len_prefix))
|
||||
if size > MAX_FRAME_BYTES {
|
||||
return nil, .Frame_Too_Large
|
||||
}
|
||||
|
||||
buf := make([]byte, size, allocator)
|
||||
if size == 0 {
|
||||
return buf, .None
|
||||
}
|
||||
|
||||
if err := recv_exact(socket, buf); err != .None {
|
||||
delete(buf)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return buf, .None
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package protocol
|
||||
|
||||
// Protocol message type names for clipsync v1.
|
||||
Message_Type :: enum {
|
||||
HELLO,
|
||||
PAIR_REQUEST,
|
||||
PAIR_CHALLENGE,
|
||||
PAIR_CONFIRM,
|
||||
PAIR_ACCEPT,
|
||||
CLIP_UPDATE,
|
||||
ACK,
|
||||
ERROR,
|
||||
PING,
|
||||
PONG,
|
||||
}
|
||||
|
||||
HELLO_Message :: struct {
|
||||
version: string,
|
||||
message_type: string,
|
||||
device_id: string,
|
||||
device_name: string,
|
||||
platform: string,
|
||||
tcp_port: int,
|
||||
timestamp_ms: i64,
|
||||
nonce: string,
|
||||
}
|
||||
|
||||
make_hello_message :: proc(device_id, device_name, platform, nonce: string, tcp_port: int) -> HELLO_Message {
|
||||
return HELLO_Message{
|
||||
version = "1",
|
||||
message_type = "HELLO",
|
||||
device_id = device_id,
|
||||
device_name = device_name,
|
||||
platform = platform,
|
||||
tcp_port = tcp_port,
|
||||
timestamp_ms = 0,
|
||||
nonce = nonce,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user