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