这是一个非常棒的功能扩展。这样你的主机不仅是“广播站”,还是所有从机的“剪切板收集中心”。 为了实现这个需求,我们需要对架构做如下调整: 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)`。