Files
RedisClipSync/剪切板同步.md

13 KiB
Raw Blame History

这是一个非常棒的功能扩展。这样你的主机不仅是“广播站”,还是所有从机的“剪切板收集中心”。

为了实现这个需求,我们需要对架构做如下调整:

  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

# ================= 基础配置 =================
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)

这个版本整合了双向逻辑。为了防止“死循环”(主机发给从机 -> 从机写入剪切板 -> 从机监听到变化又发回给主机),我们加了更严格的逻辑判断。

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. 目录结构

程序启动后,主机端会自动生成文件夹:

/
  - 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。

role=SLAVE
redis.host=192.168.1.100
# 接收主机同步的频道
slave.listen.channel=machine_a
# 发给主机时的身份标识 (决定了主机端生成的文件名)
slave.id=192.168.1.50

C. 主机配置

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 中追加写入:
[2024-05-20 10:00:01]
Password123

------------------

  • 注意:主机的剪切板不会变成 "Password123"(避免干扰主机操作,只做静默记录)。如果你希望主机剪切板也同步变,可以在 saveToFile 方法里调用 writeToClipboard(content)