Initial commit: RedisClipSync clipboard sync via Redis
This commit is contained in:
21
.gitignore
vendored
Normal file
21
.gitignore
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
# Maven
|
||||
target/
|
||||
pom.xml.tag
|
||||
*.jar
|
||||
!**/src/main/**/target/
|
||||
!**/src/test/**/target/
|
||||
|
||||
# 本地配置(含可能敏感信息)
|
||||
config.properties
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
*.iml
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
84
README.md
Normal file
84
README.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# Redis Clipboard Sync
|
||||
|
||||
基于 Redis 的双向剪切板同步:主机将本地剪切板广播到从机,从机将本地剪切板上传到主机并按从机 ID 追加写入 TXT 文件。
|
||||
|
||||
## 构建
|
||||
|
||||
```bash
|
||||
mvn clean package
|
||||
```
|
||||
|
||||
在 `target/` 下生成 `redis-clip-sync.jar`(已包含 Jedis 依赖,可直接运行)。
|
||||
|
||||
## 运行
|
||||
|
||||
确保工作目录包含 `config.properties`,或通过参数/系统属性指定配置路径:
|
||||
|
||||
```bash
|
||||
java -jar target/redis-clip-sync.jar
|
||||
# 或指定配置
|
||||
java -jar target/redis-clip-sync.jar /path/to/config.properties
|
||||
java -Dconfig=/path/to/config.properties -jar target/redis-clip-sync.jar
|
||||
```
|
||||
|
||||
## 配置
|
||||
|
||||
- **config.properties**:主配置,字段与 `剪切板同步.md` 一致。
|
||||
- **config.master.properties.example**:主机示例,复制为 `config.properties` 后修改。
|
||||
- **config.slave.properties.example**:从机示例,复制为 `config.properties` 后修改。
|
||||
|
||||
### 主机 (Master)
|
||||
|
||||
```properties
|
||||
role=MASTER
|
||||
redis.host=127.0.0.1
|
||||
redis.port=6379
|
||||
redis.password=
|
||||
|
||||
master.target.channels=machine_a,machine_b
|
||||
master.save.dir=./clipboard_logs/
|
||||
```
|
||||
|
||||
### 从机 (Slave)
|
||||
|
||||
```properties
|
||||
role=SLAVE
|
||||
redis.host=192.168.1.100
|
||||
slave.listen.channel=machine_a
|
||||
slave.id=192.168.1.50
|
||||
```
|
||||
|
||||
`slave.id` 决定主机端生成的文件名(如 `192.168.1.50.txt`)。
|
||||
|
||||
## 目录结构
|
||||
|
||||
主机启动后会自动创建保存目录:
|
||||
|
||||
```
|
||||
/
|
||||
- redis-clip-sync.jar
|
||||
- config.properties
|
||||
- clipboard_logs/ # 自动创建
|
||||
- 192.168.1.50.txt # 从机 A 的记录
|
||||
- 192.168.1.51.txt # 从机 B 的记录
|
||||
```
|
||||
|
||||
## 功能验证
|
||||
|
||||
1. **主机 → 从机**:主机复制一段文字(如 "Hello Slave"),从机剪切板应自动更新为该内容,且从机不会把该内容再上传回主机(防回环)。
|
||||
2. **从机 → 主机**:从机复制一段文字(如 "Password123"),主机会在 `./clipboard_logs/<slave.id>.txt` 中追加一条带时间戳的记录,主机剪切板保持不变。
|
||||
|
||||
记录格式示例:
|
||||
|
||||
```
|
||||
[2024-05-20 10:00:01]
|
||||
Password123
|
||||
|
||||
------------------
|
||||
```
|
||||
|
||||
## 依赖
|
||||
|
||||
- Java 8+
|
||||
- Redis(用于 Pub/Sub 与连接)
|
||||
- Maven 3.x(仅构建时)
|
||||
16
config.master.properties.example
Normal file
16
config.master.properties.example
Normal file
@@ -0,0 +1,16 @@
|
||||
# 主机示例配置:复制为 config.properties 后修改
|
||||
|
||||
# 角色:MASTER(主机) 或 SLAVE(从机)
|
||||
role=MASTER
|
||||
|
||||
# Redis 服务器地址
|
||||
redis.host=127.0.0.1
|
||||
# Redis 服务器端口
|
||||
redis.port=6379
|
||||
# Redis 密码(无密码则留空)
|
||||
redis.password=
|
||||
|
||||
# 目标频道列表:主机将剪贴板内容推送到这些频道,多个频道用逗号分隔
|
||||
master.target.channels=machine_a,machine_b
|
||||
# 剪贴板日志保存目录:用于持久化剪贴板历史记录
|
||||
master.save.dir=./clipboard_logs/
|
||||
16
config.slave.properties.example
Normal file
16
config.slave.properties.example
Normal file
@@ -0,0 +1,16 @@
|
||||
# 从机示例配置:复制为 config.properties 后修改
|
||||
|
||||
# 角色:MASTER(主机) 或 SLAVE(从机)
|
||||
role=SLAVE
|
||||
|
||||
# Redis 服务器地址(需填写主机所连接的 Redis 服务器地址)
|
||||
redis.host=192.168.1.100
|
||||
# Redis 服务器端口
|
||||
redis.port=6379
|
||||
# Redis 密码(无密码则留空)
|
||||
redis.password=
|
||||
|
||||
# 监听频道:从机订阅此频道以接收剪贴板数据(需与主机的某个目标频道一致)
|
||||
slave.listen.channel=machine_a
|
||||
# 从机唯一标识ID:用于区分不同的从机设备(建议使用IP或主机名)
|
||||
slave.id=192.168.1.50
|
||||
61
pom.xml
Normal file
61
pom.xml
Normal file
@@ -0,0 +1,61 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>com.redisclipsync</groupId>
|
||||
<artifactId>redis-clip-sync</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<packaging>jar</packaging>
|
||||
<name>Redis Clipboard Sync</name>
|
||||
<description>Redis-based bidirectional clipboard sync: Master broadcasts to slaves, slaves upload to master and save to files.</description>
|
||||
|
||||
<properties>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<maven.compiler.source>1.8</maven.compiler.source>
|
||||
<maven.compiler.target>1.8</maven.compiler.target>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>redis.clients</groupId>
|
||||
<artifactId>jedis</artifactId>
|
||||
<version>3.9.0</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-jar-plugin</artifactId>
|
||||
<version>3.3.0</version>
|
||||
<configuration>
|
||||
<archive>
|
||||
<manifest>
|
||||
<mainClass>RedisClipSync</mainClass>
|
||||
</manifest>
|
||||
</archive>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-assembly-plugin</artifactId>
|
||||
<version>3.6.0</version>
|
||||
<configuration>
|
||||
<archive>
|
||||
<manifest>
|
||||
<mainClass>RedisClipSync</mainClass>
|
||||
</manifest>
|
||||
</archive>
|
||||
<descriptorRefs>
|
||||
<descriptorRef>jar-with-dependencies</descriptorRef>
|
||||
</descriptorRefs>
|
||||
<finalName>redis-clip-sync</finalName>
|
||||
<appendAssemblyId>false</appendAssemblyId>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
261
src/main/java/RedisClipSync.java
Normal file
261
src/main/java/RedisClipSync.java
Normal file
@@ -0,0 +1,261 @@
|
||||
import redis.clients.jedis.Jedis;
|
||||
import redis.clients.jedis.JedisPubSub;
|
||||
|
||||
import java.awt.*;
|
||||
import java.awt.datatransfer.*;
|
||||
import java.io.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
import java.util.Properties;
|
||||
|
||||
public class RedisClipSync {
|
||||
|
||||
// 配置项
|
||||
private static String ROLE;
|
||||
private static String REDIS_HOST;
|
||||
private static int REDIS_PORT;
|
||||
private static String REDIS_PASS;
|
||||
|
||||
// 主机专用配置
|
||||
private static String[] MASTER_TARGETS;
|
||||
private static String MASTER_SAVE_DIR;
|
||||
|
||||
// 从机专用配置
|
||||
private static String SLAVE_LISTEN_CHANNEL;
|
||||
private static String SLAVE_ID; // 用于生成文件名,如 192.168.1.5.txt
|
||||
|
||||
// 公共上传通道
|
||||
private static final String UPLOAD_CHANNEL = "global_clip_upload";
|
||||
|
||||
// 状态控制
|
||||
private static String lastContent = "";
|
||||
private static volatile boolean isRemoteUpdate = false; // 锁标记,防止回环
|
||||
|
||||
public static void main(String[] args) {
|
||||
String configPath = args.length > 0 ? args[0] : (System.getProperty("config") != null ? System.getProperty("config") : "config.properties");
|
||||
loadConfig(configPath);
|
||||
createDirIfMaster();
|
||||
if (!checkRedisConnection()) {
|
||||
System.err.println("请检查 config 中 redis.host / redis.port / redis.password 是否与 Redis 服务器一致,且本机能否访问该 Redis。");
|
||||
System.exit(1);
|
||||
}
|
||||
|
||||
System.out.println(">>> JClipSync Pro 启动 <<<");
|
||||
System.out.println("角色: " + ROLE);
|
||||
|
||||
if ("MASTER".equalsIgnoreCase(ROLE)) {
|
||||
// 1. 监听本地 -> 广播给从机
|
||||
startClipboardPoller("MASTER", RedisClipSync::publishToSlaves);
|
||||
// 2. 监听上传通道 -> 写入文件
|
||||
startMasterUploadListener();
|
||||
} else {
|
||||
// 1. 监听 Redis -> 写入本地
|
||||
startSlaveDownloadListener();
|
||||
// 2. 监听本地 -> 上传给主机 (存文件)
|
||||
startClipboardPoller("SLAVE", RedisClipSync::uploadToMaster);
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 公共模块: 剪切板轮询
|
||||
// ==========================================
|
||||
interface ClipCallback { void onCopy(String content); }
|
||||
|
||||
private static void startClipboardPoller(String sourceName, ClipCallback callback) {
|
||||
Clipboard clipboard = Toolkit.getDefaultToolkit().getSystemClipboard();
|
||||
new Thread(() -> {
|
||||
System.out.println("[" + sourceName + "] 剪切板监听已启动...");
|
||||
while (true) {
|
||||
try {
|
||||
Thread.sleep(500);
|
||||
// 如果刚被远程写入,则跳过本次检测,防止死循环
|
||||
if (isRemoteUpdate) {
|
||||
isRemoteUpdate = false;
|
||||
// 更新本地缓存,认为当前内容是已知的
|
||||
Transferable c = clipboard.getContents(null);
|
||||
if (c != null && c.isDataFlavorSupported(DataFlavor.stringFlavor)) {
|
||||
lastContent = (String) c.getTransferData(DataFlavor.stringFlavor);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
Transferable contents = clipboard.getContents(null);
|
||||
if (contents != null && contents.isDataFlavorSupported(DataFlavor.stringFlavor)) {
|
||||
String currentText = (String) contents.getTransferData(DataFlavor.stringFlavor);
|
||||
|
||||
if (!currentText.equals(lastContent)) {
|
||||
lastContent = currentText;
|
||||
callback.onCopy(currentText);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) { /* 忽略 */ }
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Master 逻辑
|
||||
// ==========================================
|
||||
|
||||
// 动作:广播给所有从机
|
||||
private static void publishToSlaves(String content) {
|
||||
System.out.println("[Master] 本地复制,广播中...");
|
||||
Jedis jedis = getJedis();
|
||||
try {
|
||||
for (String channel : MASTER_TARGETS) {
|
||||
String ch = channel.trim();
|
||||
if (!ch.isEmpty()) {
|
||||
jedis.publish(ch, content);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
} finally {
|
||||
if (jedis != null) jedis.close();
|
||||
}
|
||||
}
|
||||
|
||||
// 动作:监听上传通道并存文件
|
||||
private static void startMasterUploadListener() {
|
||||
new Thread(() -> {
|
||||
while (true) {
|
||||
try (Jedis jedis = getJedis()) {
|
||||
System.out.println("[Master] 等待从机上传数据...");
|
||||
jedis.subscribe(new JedisPubSub() {
|
||||
@Override
|
||||
public void onMessage(String channel, String message) {
|
||||
// 消息格式约定: "SLAVE_ID|||CONTENT"
|
||||
String[] parts = message.split("\\|\\|\\|", 2);
|
||||
if (parts.length == 2) {
|
||||
String slaveId = parts[0];
|
||||
String content = parts[1];
|
||||
saveToFile(slaveId, content);
|
||||
}
|
||||
}
|
||||
}, UPLOAD_CHANNEL);
|
||||
} catch (Exception e) {
|
||||
System.err.println("[Master] Redis 断开,重连中... 原因: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
try { Thread.sleep(3000); } catch (InterruptedException ig) { Thread.currentThread().interrupt(); }
|
||||
}
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
// 动作:保存到文件
|
||||
private static void saveToFile(String fileName, String content) {
|
||||
try {
|
||||
File dir = new File(MASTER_SAVE_DIR);
|
||||
if (!dir.exists()) dir.mkdirs();
|
||||
|
||||
File file = new File(dir, fileName + ".txt");
|
||||
|
||||
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
|
||||
String logEntry = String.format("[%s]\n%s\n\n------------------\n\n", sdf.format(new Date()), content);
|
||||
|
||||
Files.write(file.toPath(), logEntry.getBytes(StandardCharsets.UTF_8), StandardOpenOption.CREATE, StandardOpenOption.APPEND);
|
||||
|
||||
System.out.println("[Master] 已保存来自 [" + fileName + "] 的数据");
|
||||
} catch (IOException e) {
|
||||
System.err.println("文件写入失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Slave 逻辑
|
||||
// ==========================================
|
||||
|
||||
// 动作:上传到 Master
|
||||
private static void uploadToMaster(String content) {
|
||||
System.out.println("[Slave] 检测到复制,正在上传给主机...");
|
||||
try (Jedis jedis = getJedis()) {
|
||||
String payload = SLAVE_ID + "|||" + content;
|
||||
jedis.publish(UPLOAD_CHANNEL, payload);
|
||||
} catch (Exception e) {
|
||||
System.err.println("上传失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// 动作:接收主机指令
|
||||
private static void startSlaveDownloadListener() {
|
||||
new Thread(() -> {
|
||||
while (true) {
|
||||
try (Jedis jedis = getJedis()) {
|
||||
System.out.println("[Slave] 监听主机同步...");
|
||||
jedis.subscribe(new JedisPubSub() {
|
||||
@Override
|
||||
public void onMessage(String channel, String message) {
|
||||
System.out.println("[Slave] 收到主机内容,写入剪切板");
|
||||
writeToClipboard(message);
|
||||
}
|
||||
}, SLAVE_LISTEN_CHANNEL);
|
||||
} catch (Exception e) {
|
||||
try { Thread.sleep(3000); } catch (InterruptedException ig) { Thread.currentThread().interrupt(); }
|
||||
}
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
private static void writeToClipboard(String text) {
|
||||
isRemoteUpdate = true; // 标记:这是远程来的,不要回传!
|
||||
try {
|
||||
Clipboard clipboard = Toolkit.getDefaultToolkit().getSystemClipboard();
|
||||
clipboard.setContents(new StringSelection(text), null);
|
||||
} catch (Exception e) {
|
||||
isRemoteUpdate = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 辅助工具
|
||||
// ==========================================
|
||||
private static Jedis getJedis() {
|
||||
Jedis jedis = new Jedis(REDIS_HOST, REDIS_PORT);
|
||||
if (REDIS_PASS != null && !REDIS_PASS.isEmpty()) jedis.auth(REDIS_PASS);
|
||||
return jedis;
|
||||
}
|
||||
|
||||
/** 启动前检查 Redis 是否可连 */
|
||||
private static boolean checkRedisConnection() {
|
||||
try (Jedis jedis = getJedis()) {
|
||||
jedis.ping();
|
||||
System.out.println("[连接] Redis " + REDIS_HOST + ":" + REDIS_PORT + " 连接成功");
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
System.err.println("[连接] Redis 连接失败: " + e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void createDirIfMaster() {
|
||||
if ("MASTER".equalsIgnoreCase(ROLE)) {
|
||||
File dir = new File(MASTER_SAVE_DIR);
|
||||
if (!dir.exists()) dir.mkdirs();
|
||||
}
|
||||
}
|
||||
|
||||
private static void loadConfig(String configPath) {
|
||||
try (FileInputStream fis = new FileInputStream(configPath)) {
|
||||
Properties props = new Properties();
|
||||
props.load(new InputStreamReader(fis, StandardCharsets.UTF_8));
|
||||
|
||||
ROLE = props.getProperty("role", "SLAVE");
|
||||
REDIS_HOST = props.getProperty("redis.host", "127.0.0.1");
|
||||
REDIS_PORT = Integer.parseInt(props.getProperty("redis.port", "6379"));
|
||||
REDIS_PASS = props.getProperty("redis.password", "");
|
||||
|
||||
MASTER_TARGETS = props.getProperty("master.target.channels", "").split(",");
|
||||
MASTER_SAVE_DIR = props.getProperty("master.save.dir", "./clipboard_logs/");
|
||||
|
||||
SLAVE_LISTEN_CHANNEL = props.getProperty("slave.listen.channel", "default");
|
||||
SLAVE_ID = props.getProperty("slave.id", SLAVE_LISTEN_CHANNEL);
|
||||
|
||||
} catch (Exception e) {
|
||||
System.err.println("配置加载失败,请检查 " + configPath + ": " + e.getMessage());
|
||||
System.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
372
剪切板同步.md
Normal file
372
剪切板同步.md
Normal file
@@ -0,0 +1,372 @@
|
||||
这是一个非常棒的功能扩展。这样你的主机不仅是“广播站”,还是所有从机的“剪切板收集中心”。
|
||||
|
||||
为了实现这个需求,我们需要对架构做如下调整:
|
||||
|
||||
1. **双向通信**:从机 (Slave) 也需要监听本地剪切板,并将内容发回 Redis。
|
||||
2. **上传通道**:新增一个公共的 Redis 频道(如 `clip_upload_channel`),所有从机都往这里发。
|
||||
3. **主机接收**:主机 (Master) 启动一个额外的 Redis 监听线程,收到数据后写入文件。
|
||||
|
||||
以下是修改后的完整方案和代码。
|
||||
|
||||
---
|
||||
|
||||
### 1. 修改后的架构逻辑
|
||||
|
||||
* **Master (主机)**:
|
||||
* 线程 1:监听本地剪切板 -> 广播给指定 Slave (原功能)。
|
||||
* **线程 2 (新)**:订阅 `clip_upload_channel` -> 解析消息 -> **写入 TXT 文件**。
|
||||
|
||||
|
||||
* **Slave (从机)**:
|
||||
* 线程 1:订阅自己的频道 -> 写入本地剪切板 (原功能)。
|
||||
* **线程 2 (新)**:监听本地剪切板 -> 加上本机标识 -> 发布到 `clip_upload_channel`。
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
### 2. 配置文件更新 (`config.properties`)
|
||||
|
||||
我们需要在配置文件中明确从机的“名称”(用于生成文件名,你可以填 IP)。
|
||||
|
||||
```properties
|
||||
# ================= 基础配置 =================
|
||||
role=MASTER
|
||||
redis.host=127.0.0.1
|
||||
redis.port=6379
|
||||
redis.password=
|
||||
|
||||
# ================= 同步配置 =================
|
||||
# 【从机配置】
|
||||
# 1. 监听哪个频道接收主机的命令
|
||||
slave.listen.channel=machine_a
|
||||
# 2. (新) 本机标识 (可以是IP,也可以是别名)。
|
||||
# 主机收到数据后,会保存为 "这个名字.txt" (例如 192.168.1.50.txt)
|
||||
slave.id=192.168.1.50
|
||||
|
||||
# 【主机配置】
|
||||
# 1. 同步目标列表
|
||||
master.target.channels=machine_a,machine_b
|
||||
# 2. (新) 上传数据保存的根目录 (不填默认在 jar 包同级目录)
|
||||
master.save.dir=./clipboard_logs/
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. 完整代码 (`RedisClipSync.java`)
|
||||
|
||||
这个版本整合了双向逻辑。为了防止“死循环”(主机发给从机 -> 从机写入剪切板 -> 从机监听到变化又发回给主机),我们加了更严格的逻辑判断。
|
||||
|
||||
```java
|
||||
import redis.clients.jedis.Jedis;
|
||||
import redis.clients.jedis.JedisPubSub;
|
||||
|
||||
import java.awt.*;
|
||||
import java.awt.datatransfer.*;
|
||||
import java.io.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
import java.util.Properties;
|
||||
|
||||
public class RedisClipSync {
|
||||
|
||||
// 配置项
|
||||
private static String ROLE;
|
||||
private static String REDIS_HOST;
|
||||
private static int REDIS_PORT;
|
||||
private static String REDIS_PASS;
|
||||
|
||||
// 主机专用配置
|
||||
private static String[] MASTER_TARGETS;
|
||||
private static String MASTER_SAVE_DIR;
|
||||
|
||||
// 从机专用配置
|
||||
private static String SLAVE_LISTEN_CHANNEL;
|
||||
private static String SLAVE_ID; // 用于生成文件名,如 192.168.1.5.txt
|
||||
|
||||
// 公共上传通道
|
||||
private static final String UPLOAD_CHANNEL = "global_clip_upload";
|
||||
|
||||
// 状态控制
|
||||
private static String lastContent = "";
|
||||
private static volatile boolean isRemoteUpdate = false; // 锁标记,防止回环
|
||||
|
||||
public static void main(String[] args) {
|
||||
loadConfig();
|
||||
createDirIfMaster();
|
||||
|
||||
System.out.println(">>> JClipSync Pro 启动 <<<");
|
||||
System.out.println("角色: " + ROLE);
|
||||
|
||||
if ("MASTER".equalsIgnoreCase(ROLE)) {
|
||||
// 1. 监听本地 -> 广播给从机
|
||||
startClipboardPoller("MASTER", (content) -> {
|
||||
publishToSlaves(content);
|
||||
});
|
||||
// 2. 监听上传通道 -> 写入文件
|
||||
startMasterUploadListener();
|
||||
} else {
|
||||
// 1. 监听 Redis -> 写入本地
|
||||
startSlaveDownloadListener();
|
||||
// 2. 监听本地 -> 上传给主机 (存文件)
|
||||
startClipboardPoller("SLAVE", (content) -> {
|
||||
uploadToMaster(content);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 公共模块: 剪切板轮询
|
||||
// ==========================================
|
||||
interface ClipCallback { void onCopy(String content); }
|
||||
|
||||
private static void startClipboardPoller(String sourceName, ClipCallback callback) {
|
||||
Clipboard clipboard = Toolkit.getDefaultToolkit().getSystemClipboard();
|
||||
new Thread(() -> {
|
||||
System.out.println("[" + sourceName + "] 剪切板监听已启动...");
|
||||
while (true) {
|
||||
try {
|
||||
Thread.sleep(500);
|
||||
// 如果刚被远程写入,则跳过本次检测,防止死循环
|
||||
if (isRemoteUpdate) {
|
||||
isRemoteUpdate = false;
|
||||
// 更新本地缓存,认为当前内容是已知的
|
||||
Transferable c = clipboard.getContents(null);
|
||||
if(c != null && c.isDataFlavorSupported(DataFlavor.stringFlavor)) {
|
||||
lastContent = (String) c.getTransferData(DataFlavor.stringFlavor);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
Transferable contents = clipboard.getContents(null);
|
||||
if (contents != null && contents.isDataFlavorSupported(DataFlavor.stringFlavor)) {
|
||||
String currentText = (String) contents.getTransferData(DataFlavor.stringFlavor);
|
||||
|
||||
if (!currentText.equals(lastContent)) {
|
||||
lastContent = currentText;
|
||||
// 触发回调
|
||||
callback.onCopy(currentText);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) { /* 忽略 */ }
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Master 逻辑
|
||||
// ==========================================
|
||||
|
||||
// 动作:广播给所有从机
|
||||
private static void publishToSlaves(String content) {
|
||||
System.out.println("[Master] 本地复制,广播中...");
|
||||
Jedis jedis = getJedis();
|
||||
try {
|
||||
for (String channel : MASTER_TARGETS) {
|
||||
jedis.publish(channel.trim(), content);
|
||||
}
|
||||
} catch(Exception e) { e.printStackTrace(); }
|
||||
finally { if(jedis!=null) jedis.close(); }
|
||||
}
|
||||
|
||||
// 动作:监听上传通道并存文件
|
||||
private static void startMasterUploadListener() {
|
||||
new Thread(() -> {
|
||||
while(true) {
|
||||
try (Jedis jedis = getJedis()) {
|
||||
System.out.println("[Master] 等待从机上传数据...");
|
||||
jedis.subscribe(new JedisPubSub() {
|
||||
@Override
|
||||
public void onMessage(String channel, String message) {
|
||||
// 消息格式约定: "SLAVE_ID|||CONTENT"
|
||||
String[] parts = message.split("\\|\\|\\|", 2);
|
||||
if (parts.length == 2) {
|
||||
String slaveId = parts[0];
|
||||
String content = parts[1];
|
||||
saveToFile(slaveId, content);
|
||||
}
|
||||
}
|
||||
}, UPLOAD_CHANNEL);
|
||||
} catch (Exception e) {
|
||||
System.err.println("[Master] Redis 断开,重连中...");
|
||||
try { Thread.sleep(3000); } catch (InterruptedException ig) {}
|
||||
}
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
// 动作:保存到文件
|
||||
private static void saveToFile(String fileName, String content) {
|
||||
try {
|
||||
// 确保目录存在
|
||||
File dir = new File(MASTER_SAVE_DIR);
|
||||
if (!dir.exists()) dir.mkdirs();
|
||||
|
||||
// 文件路径:MASTER_SAVE_DIR/192.168.1.50.txt
|
||||
File file = new File(dir, fileName + ".txt");
|
||||
|
||||
// 格式:[时间] 内容
|
||||
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
|
||||
String logEntry = String.format("[%s]\n%s\n\n------------------\n\n", sdf.format(new Date()), content);
|
||||
|
||||
// 追加写入 (Append)
|
||||
Files.write(file.toPath(), logEntry.getBytes(StandardCharsets.UTF_8), StandardOpenOption.CREATE, StandardOpenOption.APPEND);
|
||||
|
||||
System.out.println("[Master] 已保存来自 [" + fileName + "] 的数据");
|
||||
} catch (IOException e) {
|
||||
System.err.println("文件写入失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Slave 逻辑
|
||||
// ==========================================
|
||||
|
||||
// 动作:上传到 Master
|
||||
private static void uploadToMaster(String content) {
|
||||
System.out.println("[Slave] 检测到复制,正在上传给主机...");
|
||||
try (Jedis jedis = getJedis()) {
|
||||
// 使用 ||| 作为分隔符,防止内容里有普通的 |
|
||||
String payload = SLAVE_ID + "|||" + content;
|
||||
jedis.publish(UPLOAD_CHANNEL, payload);
|
||||
} catch (Exception e) {
|
||||
System.err.println("上传失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// 动作:接收主机指令
|
||||
private static void startSlaveDownloadListener() {
|
||||
new Thread(() -> {
|
||||
while(true) {
|
||||
try (Jedis jedis = getJedis()) {
|
||||
System.out.println("[Slave] 监听主机同步...");
|
||||
jedis.subscribe(new JedisPubSub() {
|
||||
@Override
|
||||
public void onMessage(String channel, String message) {
|
||||
System.out.println("[Slave] 收到主机内容,写入剪切板");
|
||||
writeToClipboard(message);
|
||||
}
|
||||
}, SLAVE_LISTEN_CHANNEL);
|
||||
} catch (Exception e) {
|
||||
try { Thread.sleep(3000); } catch (InterruptedException ig) {}
|
||||
}
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
private static void writeToClipboard(String text) {
|
||||
isRemoteUpdate = true; // 标记:这是远程来的,不要回传!
|
||||
try {
|
||||
Clipboard clipboard = Toolkit.getDefaultToolkit().getSystemClipboard();
|
||||
clipboard.setContents(new StringSelection(text), null);
|
||||
} catch (Exception e) { isRemoteUpdate = false; }
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 辅助工具
|
||||
// ==========================================
|
||||
private static Jedis getJedis() {
|
||||
Jedis jedis = new Jedis(REDIS_HOST, REDIS_PORT);
|
||||
if (REDIS_PASS != null && !REDIS_PASS.isEmpty()) jedis.auth(REDIS_PASS);
|
||||
return jedis;
|
||||
}
|
||||
|
||||
private static void createDirIfMaster() {
|
||||
if ("MASTER".equalsIgnoreCase(ROLE)) {
|
||||
File dir = new File(MASTER_SAVE_DIR);
|
||||
if(!dir.exists()) dir.mkdirs();
|
||||
}
|
||||
}
|
||||
|
||||
private static void loadConfig() {
|
||||
try (FileInputStream fis = new FileInputStream("config.properties")) {
|
||||
Properties props = new Properties();
|
||||
props.load(fis);
|
||||
|
||||
ROLE = props.getProperty("role", "SLAVE");
|
||||
REDIS_HOST = props.getProperty("redis.host", "127.0.0.1");
|
||||
REDIS_PORT = Integer.parseInt(props.getProperty("redis.port", "6379"));
|
||||
REDIS_PASS = props.getProperty("redis.password", "");
|
||||
|
||||
MASTER_TARGETS = props.getProperty("master.target.channels", "").split(",");
|
||||
MASTER_SAVE_DIR = props.getProperty("master.save.dir", "./clipboard_logs/");
|
||||
|
||||
SLAVE_LISTEN_CHANNEL = props.getProperty("slave.listen.channel", "default");
|
||||
// 如果没填 ID,默认用 listen channel 名字
|
||||
SLAVE_ID = props.getProperty("slave.id", SLAVE_LISTEN_CHANNEL);
|
||||
|
||||
} catch (Exception e) {
|
||||
System.err.println("配置加载失败,请检查 config.properties");
|
||||
System.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. 部署与使用说明
|
||||
|
||||
#### A. 目录结构
|
||||
|
||||
程序启动后,主机端会自动生成文件夹:
|
||||
|
||||
```text
|
||||
/
|
||||
- sync.jar
|
||||
- config.properties
|
||||
- clipboard_logs/ <-- 自动生成
|
||||
- 192.168.1.50.txt <-- 从机 A 的记录
|
||||
- 192.168.1.51.txt <-- 从机 B 的记录
|
||||
|
||||
```
|
||||
|
||||
#### B. 从机配置 (`config.properties`)
|
||||
|
||||
如果你希望主机生成的文件名是 IP 地址,请务必在 `slave.id` 填入 IP。
|
||||
|
||||
```properties
|
||||
role=SLAVE
|
||||
redis.host=192.168.1.100
|
||||
# 接收主机同步的频道
|
||||
slave.listen.channel=machine_a
|
||||
# 发给主机时的身份标识 (决定了主机端生成的文件名)
|
||||
slave.id=192.168.1.50
|
||||
|
||||
```
|
||||
|
||||
#### C. 主机配置
|
||||
|
||||
```properties
|
||||
role=MASTER
|
||||
redis.host=127.0.0.1
|
||||
# 同步给哪些机器 (控制下发)
|
||||
master.target.channels=machine_a,machine_b
|
||||
# 保存接收文件的路径
|
||||
master.save.dir=./clipboard_logs/
|
||||
|
||||
```
|
||||
|
||||
### 5. 功能验证
|
||||
|
||||
1. **主机 -> 从机**:主机复制 "Hello Slave",从机剪切板自动更新。
|
||||
2. **从机 -> 主机**:
|
||||
* 从机 (192.168.1.50) 复制 "Password123"。
|
||||
* 从机剪切板保持 "Password123"。
|
||||
* 主机会在 `./clipboard_logs/192.168.1.50.txt` 中追加写入:
|
||||
```text
|
||||
[2024-05-20 10:00:01]
|
||||
Password123
|
||||
|
||||
------------------
|
||||
|
||||
```
|
||||
|
||||
|
||||
* **注意**:主机的剪切板**不会**变成 "Password123"(避免干扰主机操作,只做静默记录)。如果你希望主机剪切板也同步变,可以在 `saveToFile` 方法里调用 `writeToClipboard(content)`。
|
||||
Reference in New Issue
Block a user