Files
RedisClipSync/剪切板同步.md

373 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
这是一个非常棒的功能扩展。这样你的主机不仅是“广播站”,还是所有从机的“剪切板收集中心”。
为了实现这个需求,我们需要对架构做如下调整:
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)`