| @@ -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 | } |