initial commit
This commit is contained in:
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"chat.tools.terminal.autoApprove": {
|
||||
"odin": true
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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")
|
||||
}
|
||||
Vendored
+1
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
android.nonTransitiveRClass=true
|
||||
@@ -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")
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user