| @@ -0,0 +1,144 @@ |
| 1 | import Foundation |
| 2 | import KeycaskCore |
| 3 | |
| 4 | enum Clipboard { |
| 5 | struct Handoff: Codable, Equatable { |
| 6 | var secret: String |
| 7 | var previous: String |
| 8 | } |
| 9 | |
| 10 | struct Tool: Equatable { |
| 11 | let copy: [String] |
| 12 | let paste: [String] |
| 13 | } |
| 14 | |
| 15 | static let timeoutSeconds = 45 |
| 16 | |
| 17 | static func shouldRestore(secret: String, current: String?) -> Bool { |
| 18 | current == secret |
| 19 | } |
| 20 | |
| 21 | static func findTool( |
| 22 | path: String = ProcessInfo.processInfo.environment["PATH"] ?? "", |
| 23 | fileManager: FileManager = .default |
| 24 | ) -> Tool? { |
| 25 | #if os(Windows) |
| 26 | let separator: Character = ";" |
| 27 | let candidates: [(copy: [String], paste: [String])] = [ |
| 28 | (["clip.exe"], ["powershell.exe", "-NoProfile", "-Command", "Get-Clipboard -Raw"]) |
| 29 | ] |
| 30 | #elseif os(macOS) |
| 31 | let separator: Character = ":" |
| 32 | let candidates: [(copy: [String], paste: [String])] = [(["pbcopy"], ["pbpaste"])] |
| 33 | #else |
| 34 | let separator: Character = ":" |
| 35 | let candidates: [(copy: [String], paste: [String])] = [ |
| 36 | (["wl-copy"], ["wl-paste", "--no-newline"]), |
| 37 | ( |
| 38 | ["xclip", "-selection", "clipboard"], |
| 39 | ["xclip", "-selection", "clipboard", "-o"] |
| 40 | ), |
| 41 | ] |
| 42 | #endif |
| 43 | let dirs = path.split(separator: separator).map(String.init) |
| 44 | func locate(_ name: String) -> String? { |
| 45 | for dir in dirs { |
| 46 | let full = URL(fileURLWithPath: dir).appendingPathComponent(name).path |
| 47 | if fileManager.isExecutableFile(atPath: full) { return full } |
| 48 | } |
| 49 | return nil |
| 50 | } |
| 51 | for candidate in candidates { |
| 52 | guard let copy = locate(candidate.copy[0]), let paste = locate(candidate.paste[0]) |
| 53 | else { |
| 54 | continue |
| 55 | } |
| 56 | return Tool( |
| 57 | copy: [copy] + candidate.copy.dropFirst(), |
| 58 | paste: [paste] + candidate.paste.dropFirst()) |
| 59 | } |
| 60 | return nil |
| 61 | } |
| 62 | |
| 63 | static func read() throws -> String { |
| 64 | let tool = try requireTool() |
| 65 | let (status, output) = try runTool(tool.paste, input: nil) |
| 66 | guard status == 0 else { return "" } |
| 67 | return output |
| 68 | } |
| 69 | |
| 70 | static func write(_ text: String) throws { |
| 71 | let tool = try requireTool() |
| 72 | let (status, _) = try runTool(tool.copy, input: text) |
| 73 | guard status == 0 else { throw KeycaskError.failure("clipboard tool failed") } |
| 74 | } |
| 75 | |
| 76 | static func copyWithTimeout(_ secret: String, seconds: Int = timeoutSeconds) throws { |
| 77 | _ = try requireTool() |
| 78 | let handoff = Handoff(secret: secret, previous: try read()) |
| 79 | let process = Process() |
| 80 | process.executableURL = Bundle.main.executableURL |
| 81 | process.arguments = ["clipboard-daemon", String(seconds)] |
| 82 | process.standardOutput = FileHandle.nullDevice |
| 83 | process.standardError = FileHandle.nullDevice |
| 84 | let input = Pipe() |
| 85 | process.standardInput = input |
| 86 | do { |
| 87 | try process.run() |
| 88 | input.fileHandleForWriting.write(try JSONEncoder().encode(handoff)) |
| 89 | try input.fileHandleForWriting.close() |
| 90 | } catch { |
| 91 | throw KeycaskError.failure("start clipboard daemon: \(error)") |
| 92 | } |
| 93 | } |
| 94 | |
| 95 | static func runDaemon(seconds: Int) throws { |
| 96 | let data = FileHandle.standardInput.readDataToEndOfFile() |
| 97 | let handoff: Handoff |
| 98 | do { |
| 99 | handoff = try JSONDecoder().decode(Handoff.self, from: data) |
| 100 | } catch { |
| 101 | throw KeycaskError.failure("bad handoff") |
| 102 | } |
| 103 | try write(handoff.secret) |
| 104 | Thread.sleep(forTimeInterval: TimeInterval(seconds)) |
| 105 | let current = try? read() |
| 106 | guard shouldRestore(secret: handoff.secret, current: current) else { return } |
| 107 | try write(handoff.previous) |
| 108 | } |
| 109 | |
| 110 | private static func requireTool() throws -> Tool { |
| 111 | guard let tool = findTool() else { |
| 112 | #if os(Windows) |
| 113 | let hint = "clip.exe and powershell.exe" |
| 114 | #elseif os(macOS) |
| 115 | let hint = "pbcopy and pbpaste" |
| 116 | #else |
| 117 | let hint = "wl-clipboard or xclip" |
| 118 | #endif |
| 119 | throw KeycaskError.failure("no clipboard tool found: install \(hint)") |
| 120 | } |
| 121 | return tool |
| 122 | } |
| 123 | |
| 124 | private static func runTool(_ argv: [String], input: String?) throws -> (Int32, String) { |
| 125 | let process = Process() |
| 126 | process.executableURL = URL(fileURLWithPath: argv[0]) |
| 127 | process.arguments = Array(argv.dropFirst()) |
| 128 | let out = Pipe() |
| 129 | process.standardOutput = out |
| 130 | process.standardError = FileHandle.nullDevice |
| 131 | let inPipe = Pipe() |
| 132 | process.standardInput = inPipe |
| 133 | do { |
| 134 | try process.run() |
| 135 | } catch { |
| 136 | throw KeycaskError.failure("run \(argv[0]): \(error)") |
| 137 | } |
| 138 | if let input { inPipe.fileHandleForWriting.write(Data(input.utf8)) } |
| 139 | try? inPipe.fileHandleForWriting.close() |
| 140 | let data = out.fileHandleForReading.readDataToEndOfFile() |
| 141 | process.waitUntilExit() |
| 142 | return (process.terminationStatus, String(decoding: data, as: UTF8.self)) |
| 143 | } |
| 144 | } |