From f02b6d968203bcce002fc564094bd6d7cf0084f2 Mon Sep 17 00:00:00 2001 From: liu <362165265@qq.com> Date: Sat, 31 Jan 2026 02:10:37 +0800 Subject: [PATCH] Initial commit: RedisClipSync clipboard sync via Redis --- .gitignore | 21 ++ README.md | 84 +++++++ config.master.properties.example | 16 ++ config.slave.properties.example | 16 ++ pom.xml | 61 +++++ src/main/java/RedisClipSync.java | 261 ++++++++++++++++++++++ 剪切板同步.md | 372 +++++++++++++++++++++++++++++++ 7 files changed, 831 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 config.master.properties.example create mode 100644 config.slave.properties.example create mode 100644 pom.xml create mode 100644 src/main/java/RedisClipSync.java create mode 100644 剪切板同步.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6fbb362 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..b2efac3 --- /dev/null +++ b/README.md @@ -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/.txt` 中追加一条带时间戳的记录,主机剪切板保持不变。 + +记录格式示例: + +``` +[2024-05-20 10:00:01] +Password123 + +------------------ +``` + +## 依赖 + +- Java 8+ +- Redis(用于 Pub/Sub 与连接) +- Maven 3.x(仅构建时) diff --git a/config.master.properties.example b/config.master.properties.example new file mode 100644 index 0000000..2a956b4 --- /dev/null +++ b/config.master.properties.example @@ -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/ diff --git a/config.slave.properties.example b/config.slave.properties.example new file mode 100644 index 0000000..81654d0 --- /dev/null +++ b/config.slave.properties.example @@ -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 diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..e694a9a --- /dev/null +++ b/pom.xml @@ -0,0 +1,61 @@ + + + 4.0.0 + + com.redisclipsync + redis-clip-sync + 1.0.0 + jar + Redis Clipboard Sync + Redis-based bidirectional clipboard sync: Master broadcasts to slaves, slaves upload to master and save to files. + + + UTF-8 + 1.8 + 1.8 + + + + + redis.clients + jedis + 3.9.0 + + + + + + + org.apache.maven.plugins + maven-jar-plugin + 3.3.0 + + + + RedisClipSync + + + + + + org.apache.maven.plugins + maven-assembly-plugin + 3.6.0 + + + + RedisClipSync + + + + jar-with-dependencies + + redis-clip-sync + false + + + + + diff --git a/src/main/java/RedisClipSync.java b/src/main/java/RedisClipSync.java new file mode 100644 index 0000000..2dbf354 --- /dev/null +++ b/src/main/java/RedisClipSync.java @@ -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); + } + } +} diff --git a/剪切板同步.md b/剪切板同步.md new file mode 100644 index 0000000..a42dedf --- /dev/null +++ b/剪切板同步.md @@ -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)`。