initial commit

This commit is contained in:
2026-08-27 15:38:55 +02:00
commit 0426b6eeec
23 changed files with 1635 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
{
"chat.tools.terminal.autoApprove": {
"odin": true
}
}
+28
View File
@@ -0,0 +1,28 @@
# clipsync
Open source clipboard sync between Android and Linux/Windows over LAN.
## Monorepo layout
- `android-app/` — Kotlin Android app (UI + background sync service)
- `desktop-daemon/` — Odin desktop daemon implementation
- `docs/` — protocol and architecture docs
## Current phase
This repository is currently in **scaffolding + protocol definition** phase.
## MVP target
1. LAN discovery
2. Pairing + trust persistence
3. Encrypted session
4. Text clipboard sync in both directions
5. Loop prevention and reconnect
## Next implementation steps
1. Initialize Android project in `android-app/`.
2. Implement desktop daemon network listener + discovery broadcaster in `desktop-daemon/`.
3. Implement protocol v1 message framing and handshake from [docs/protocol-v1.md](docs/protocol-v1.md).
4. Add clipboard watcher backends (Linux and Windows).
View File
+31
View File
@@ -0,0 +1,31 @@
# clipsync Android App
Android client scaffold is now initialized in this directory.
## Implemented scaffold
- Kotlin Android app module (`app/`)
- Launcher activity with service controls
- Foreground service (`SyncService`) with persistent notification
- UDP discovery listener loop (`UdpDiscoveryListener`) on default port `45821`
## Current package layout
- `app/src/main/java/org/clipsync/android/`
- `MainActivity.kt`
- `service/SyncService.kt`
- `discovery/UdpDiscoveryListener.kt`
## Build files
- `settings.gradle.kts`
- `build.gradle.kts`
- `app/build.gradle.kts`
## Next Android steps
1. Parse discovery JSON and keep an in-memory peer list for UI.
2. Add manual connect screen (`IP:port`) and connect action.
3. Implement TCP framing + HELLO/PAIR_REQUEST flow.
4. Add clipboard monitor/apply path.
5. Persist paired peers in local storage.
+49
View File
@@ -0,0 +1,49 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "org.clipsync.android"
compileSdk = 34
defaultConfig {
applicationId = "org.clipsync.android"
minSdk = 28
targetSdk = 34
versionCode = 1
versionName = "0.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
dependencies {
implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.appcompat:appcompat:1.7.0")
implementation("com.google.android.material:material:1.12.0")
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
implementation("androidx.lifecycle:lifecycle-service:2.8.4")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.2.1")
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
}
+1
View File
@@ -0,0 +1 @@
# clipsync Android app ProGuard rules
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:allowBackup="true"
android:icon="@android:drawable/sym_def_app_icon"
android:label="@string/app_name"
android:roundIcon="@android:drawable/sym_def_app_icon"
android:supportsRtl="true"
android:theme="@style/Theme.ClipSync">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".service.SyncService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="dataSync" />
</application>
</manifest>
@@ -0,0 +1,58 @@
package org.clipsync.android
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.widget.Button
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import org.clipsync.android.service.SyncService
class MainActivity : AppCompatActivity() {
private lateinit var statusText: TextView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
statusText = findViewById(R.id.statusText)
val startButton: Button = findViewById(R.id.startServiceButton)
val stopButton: Button = findViewById(R.id.stopServiceButton)
requestNotificationPermissionIfNeeded()
startButton.setOnClickListener {
val intent = Intent(this, SyncService::class.java).apply {
action = SyncService.ACTION_START
}
ContextCompat.startForegroundService(this, intent)
statusText.text = getString(R.string.status_running)
}
stopButton.setOnClickListener {
val intent = Intent(this, SyncService::class.java).apply {
action = SyncService.ACTION_STOP
}
startService(intent)
statusText.text = getString(R.string.status_stopped)
}
}
private fun requestNotificationPermissionIfNeeded() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
== PackageManager.PERMISSION_GRANTED
) return
ActivityCompat.requestPermissions(
this,
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
1001
)
}
}
@@ -0,0 +1,38 @@
package org.clipsync.android.discovery
import java.net.DatagramPacket
import java.net.DatagramSocket
import java.net.InetAddress
class UdpDiscoveryListener(
private val port: Int = 45821
) {
@Volatile
private var running: Boolean = true
fun runLoop() {
running = true
DatagramSocket(port, InetAddress.getByName("0.0.0.0")).use { socket ->
socket.soTimeout = 2000
val buffer = ByteArray(4096)
while (running && !Thread.currentThread().isInterrupted) {
try {
val packet = DatagramPacket(buffer, buffer.size)
socket.receive(packet)
val payload = String(packet.data, 0, packet.length)
if (payload.contains("\"type\":\"DISCOVERY_ANNOUNCE\"")) {
// TODO: parse JSON and publish peer to app state.
}
} catch (_: Exception) {
// Timeout/interruption expected in loop.
}
}
}
}
fun stop() {
running = false
}
}
@@ -0,0 +1,91 @@
package org.clipsync.android.service
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Intent
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import org.clipsync.android.R
import org.clipsync.android.discovery.UdpDiscoveryListener
import java.util.concurrent.Executors
import java.util.concurrent.Future
class SyncService : Service() {
private val executor = Executors.newSingleThreadExecutor()
private var discoveryFuture: Future<*>? = null
private val discoveryListener = UdpDiscoveryListener()
override fun onCreate() {
super.onCreate()
createNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_STOP -> {
stopDiscovery()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
else -> {
startForeground(NOTIFICATION_ID, buildNotification())
startDiscovery()
}
}
return START_STICKY
}
override fun onDestroy() {
stopDiscovery()
executor.shutdownNow()
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
private fun startDiscovery() {
if (discoveryFuture?.isDone == false) return
discoveryFuture = executor.submit { discoveryListener.runLoop() }
}
private fun stopDiscovery() {
discoveryListener.stop()
discoveryFuture?.cancel(true)
discoveryFuture = null
}
private fun buildNotification(): Notification {
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(getString(R.string.notification_title))
.setContentText(getString(R.string.notification_text))
.setSmallIcon(android.R.drawable.stat_notify_sync)
.setOngoing(true)
.build()
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = getSystemService(NotificationManager::class.java)
val channel = NotificationChannel(
CHANNEL_ID,
getString(R.string.channel_name),
NotificationManager.IMPORTANCE_LOW
).apply {
description = getString(R.string.channel_description)
}
manager.createNotificationChannel(channel)
}
companion object {
const val ACTION_START = "org.clipsync.android.action.START"
const val ACTION_STOP = "org.clipsync.android.action.STOP"
private const val CHANNEL_ID = "clipsync_sync"
private const val NOTIFICATION_ID = 4001
}
}
@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="24dp">
<TextView
android:id="@+id/titleText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/app_name"
android:textAppearance="@style/TextAppearance.Material3.HeadlineSmall"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/statusText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/status_stopped"
android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/titleText" />
<Button
android:id="@+id/startServiceButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="@string/start_service"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/statusText" />
<Button
android:id="@+id/stopServiceButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/stop_service"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/startServiceButton" />
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -0,0 +1,13 @@
<resources>
<string name="app_name">ClipSync</string>
<string name="status_running">Service running (discovery active)</string>
<string name="status_stopped">Service stopped</string>
<string name="start_service">Start Sync Service</string>
<string name="stop_service">Stop Sync Service</string>
<string name="channel_name">ClipSync Background Sync</string>
<string name="channel_description">Keeps discovery and sync session active</string>
<string name="notification_title">ClipSync running</string>
<string name="notification_text">Discovery and sync service active</string>
</resources>
@@ -0,0 +1,3 @@
<resources>
<style name="Theme.ClipSync" parent="Theme.Material3.DayNight.NoActionBar" />
</resources>
+4
View File
@@ -0,0 +1,4 @@
plugins {
id("com.android.application") version "8.5.2" apply false
id("org.jetbrains.kotlin.android") version "1.9.24" apply false
}
+4
View File
@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
+18
View File
@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "clipsync-android"
include(":app")
+11
View File
@@ -0,0 +1,11 @@
# clipsync Desktop Daemon (Odin)
This component runs in background and syncs clipboard with paired Android devices.
Initial milestone:
1. Start TCP server on default port `45821`.
2. Broadcast LAN discovery messages.
3. Handle v1 handshake and pairing state.
4. Send/receive text clipboard updates.
5. Persist paired peers and reconnect automatically.
+881
View File
@@ -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)
}
+98
View File
@@ -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
}
+39
View File
@@ -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,
}
}
+133
View File
@@ -0,0 +1,133 @@
# clipsync Protocol v1 (MVP)
This document defines an implementation-friendly first protocol for clipboard sync.
## Transport
- Discovery: UDP broadcast on port `45821`
- Session: TCP on port `45821`
- Payload encoding: UTF-8 JSON frames
- Frame format on TCP: 4-byte big-endian length prefix + JSON bytes
## Versioning
Each message includes:
- `version`: protocol version string, initially `"1"`
- `type`: message type
- `request_id`: unique string per request/response pair where relevant
- `device_id`: sender stable ID
- `timestamp_ms`: unix epoch milliseconds
## Discovery message (UDP)
`DISCOVERY_ANNOUNCE`
```json
{
"version": "1",
"type": "DISCOVERY_ANNOUNCE",
"device_id": "desktop-uuid",
"device_name": "workstation",
"platform": "linux|windows",
"tcp_port": 45821,
"capabilities": ["clipboard:text"],
"timestamp_ms": 0
}
```
Android listens for these packets and shows peers.
## Pairing flow (TCP)
### 1) HELLO
Both peers exchange:
- protocol version
- device info
- ephemeral nonce
### 2) PAIR_REQUEST
Unpaired side sends pairing request including:
- public key
- pairing method (`"pin"` or `"qr"`)
### 3) PAIR_CHALLENGE
Responder sends short code metadata (or QR payload hash) and challenge nonce.
### 4) PAIR_CONFIRM
Requester returns challenge response.
### 5) PAIR_ACCEPT
Responder confirms trust and stores remote public key.
After `PAIR_ACCEPT`, both devices persist:
- `peer_device_id`
- `peer_public_key`
- `paired_at`
## Session messages
### `CLIP_UPDATE`
```json
{
"version": "1",
"type": "CLIP_UPDATE",
"request_id": "req-123",
"device_id": "android-uuid",
"timestamp_ms": 0,
"origin_device_id": "android-uuid",
"content_type": "text/plain",
"encoding": "utf-8",
"content_hash": "sha256:...",
"content": "clipboard text",
"sequence": 42
}
```
Rules:
- Only sync `text/plain` in MVP.
- Ignore messages with known `content_hash` from recent history.
- Ignore message if `origin_device_id` equals local device id (echo prevention).
### `ACK`
Acknowledges accepted `request_id`.
### `ERROR`
Contains error code and message (`UNPAIRED`, `UNSUPPORTED_VERSION`, `INVALID_MESSAGE`, `RATE_LIMITED`).
### `PING` / `PONG`
Keepalive every 15 seconds. Drop and reconnect after timeout.
## Security baseline
- Reject non-paired peers for clipboard writes.
- Encrypt session channel (TLS or Noise).
- Enforce max payload size (e.g., 1 MiB for MVP text).
- Apply rate limits per peer.
## Current daemon behavior (implemented)
- Server sends `HELLO` immediately on TCP connect.
- First client message must be `HELLO` (else `ERROR: INVALID_MESSAGE`).
- After `HELLO`, server accepts either:
- `PAIR_REQUEST` and responds with `PAIR_ACCEPT` (while persisting peer trust), or
- `CLIP_UPDATE` for already-paired peers.
- `CLIP_UPDATE` from unknown peers is rejected with `ERROR: UNPAIRED`.
- While connected, server polls local clipboard and emits `CLIP_UPDATE` to the connected paired peer when local text changes.
- Incoming paired `CLIP_UPDATE` (`content_type = text/plain`) is applied to local OS clipboard.
- Inbound `CLIP_UPDATE` messages are deduplicated using `content_hash` (with a fallback hash for missing values).
- Inbound `CLIP_UPDATE` messages are rate-limited per peer; over-limit updates return `ERROR: RATE_LIMITED`.
- Keepalive is implemented: server sends periodic `PING`, responds to inbound `PING` with `PONG`, and drops idle sessions after timeout.
+39
View File
@@ -0,0 +1,39 @@
# Proposed Repository Layout
## Top-level
- `android-app/`
- `desktop-daemon/`
- `docs/`
## Android app layout
- `android-app/app/src/main/java/.../`
- `discovery/` UDP discovery listener and peer cache
- `pairing/` pairing workflow and trust store
- `sync/` persistent session and clipboard sync engine
- `service/` foreground/background lifecycle service
- `ui/` peer list, pairing, status
## Desktop daemon layout (Odin)
- `desktop-daemon/src/main.odin`
- `desktop-daemon/src/net/`
- discovery broadcaster/listener
- tcp session + framing
- `desktop-daemon/src/protocol/`
- message schemas and handlers
- `desktop-daemon/src/pairing/`
- key management + pairing flow
- `desktop-daemon/src/clipboard/`
- Linux backend
- Windows backend
- `desktop-daemon/src/storage/`
- paired peers file/db persistence
- `desktop-daemon/src/runtime/`
- daemon loop, reconnect, retry policies
## Config files planned
- `desktop-daemon/clipsync.toml` (optional override)
- Android app settings screen for default port and discovery toggles
+9
View File
@@ -0,0 +1,9 @@
package main
import "core:fmt"
DEFAULT_PORT :: 45821
main :: proc() {
fmt.printf("clipsync daemon scaffold listening on port %d\n", DEFAULT_PORT)
}