krz/keycask

Password manager: Swift core library, CLI for macOS/Linux/Windows, iOS/macOS app. cli password-manager swift

Commit 4261024032

4261024032edbbab3f6dd00c250ca8da36a2a13c

parent: ae55bc8935

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-17 16:31 UTC

Add clipboard support: clip, generate --copy, timed clear

Layout: unified · split

Sources/keycask/Clipboard.swift added +144
@@ -0,0 +1,144 @@
1import Foundation
2import KeycaskCore
3
4enum 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}
Sources/keycask/Commands/Clip.swift added +20
@@ -0,0 +1,20 @@
1import ArgumentParser
2import KeycaskCore
3
4struct Clip: ParsableCommand {
5 static let configuration = CommandConfiguration(
6 abstract: "Copy a field to the clipboard. Clears after \(Clipboard.timeoutSeconds) seconds."
7 )
8
9 @OptionGroup var global: GlobalOptions
10 @Argument(help: "Entry id or name.") var ref: String
11 @Option(name: .long, help: "Field to copy (default password).") var field = "password"
12
13 func run() throws {
14 let open = try OpenVault.load(global)
15 let entry = try open.vault.resolve(ref)
16 let value = try Output.field(entry, named: field)
17 try Clipboard.copyWithTimeout(value)
18 print("copied \(field) of \(entry.name); clears in \(Clipboard.timeoutSeconds)s")
19 }
20}
Sources/keycask/Commands/ClipboardDaemon.swift added +12
@@ -0,0 +1,12 @@
1import ArgumentParser
2
3struct ClipboardDaemon: ParsableCommand {
4 static let configuration = CommandConfiguration(
5 commandName: "clipboard-daemon", shouldDisplay: false)
6
7 @Argument var seconds: Int
8
9 func run() throws {
10 try Clipboard.runDaemon(seconds: seconds)
11 }
12}
Sources/keycask/Commands/Generate.swift +3 −1
@@ -21,7 +21,9 @@ struct Generate: ParsableCommand {
21 words.map { Generator.passphrase(words: $0) } 21 words.map { Generator.passphrase(words: $0) }
22 ?? Generator.password(length: length ?? Generator.defaultLength) 22 ?? Generator.password(length: length ?? Generator.defaultLength)
23 if copy { 23 if copy {
24 throw KeycaskError.failure("clipboard not available") 24 try Clipboard.copyWithTimeout(secret)
25 print("copied; clears in \(Clipboard.timeoutSeconds)s")
26 return
25 } 27 }
26 print(secret) 28 print(secret)
27 } 29 }
Sources/keycask/Keycask.swift +1
@@ -11,6 +11,7 @@ struct Keycask: ParsableCommand {
11 abstract: "Command-line password manager. One passphrase-encrypted vault file.", 11 abstract: "Command-line password manager. One passphrase-encrypted vault file.",
12 subcommands: [ 12 subcommands: [
13 Init.self, Add.self, Show.self, Ls.self, Find.self, Edit.self, Rm.self, Generate.self, 13 Init.self, Add.self, Show.self, Ls.self, Find.self, Edit.self, Rm.self, Generate.self,
14 Clip.self, ClipboardDaemon.self,
14 ] 15 ]
15 ) 16 )
16 17
Tests/KeycaskCLITests/ClipboardTests.swift added +60
@@ -0,0 +1,60 @@
1import Foundation
2import Testing
3
4@testable import keycask
5
6@Suite struct ClipboardTests {
7 @Test func restoresOnlyWhenClipboardStillHoldsTheSecret() {
8 #expect(Clipboard.shouldRestore(secret: "s", current: "s"))
9 #expect(!Clipboard.shouldRestore(secret: "s", current: "user pasted"))
10 #expect(!Clipboard.shouldRestore(secret: "s", current: nil))
11 }
12
13 @Test func handoffRoundTrips() throws {
14 let h = Clipboard.Handoff(secret: "s3cret", previous: "old")
15 let data = try JSONEncoder().encode(h)
16 #expect(try JSONDecoder().decode(Clipboard.Handoff.self, from: data) == h)
17 }
18
19 @Test func findToolScansPathInOrder() throws {
20 let dir = FileManager.default.temporaryDirectory
21 .appendingPathComponent("keycask-clip-\(UUID().uuidString)")
22 try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
23 #expect(Clipboard.findTool(path: dir.path) == nil)
24
25 #if os(macOS)
26 let names = ["pbcopy", "pbpaste"]
27 #elseif os(Windows)
28 let names = ["clip.exe", "powershell.exe"]
29 #else
30 let names = ["xclip"]
31 #endif
32 for n in names {
33 let f = dir.appendingPathComponent(n)
34 try Data("#!/bin/sh\n".utf8).write(to: f)
35 try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: f.path)
36 }
37 let tool = Clipboard.findTool(path: dir.path)
38 #expect(tool != nil)
39 #expect(tool?.copy.first?.hasPrefix(dir.path) == true)
40 }
41
42 @Test func daemonWithoutHandoffFails() throws {
43 let cli = try CLI()
44 let r = try cli.run(["clipboard-daemon", "1"], stdin: "not json", passphrase: nil)
45 #expect(r.status == 1)
46 }
47
48 @Test func daemonIsHiddenFromHelp() throws {
49 let cli = try CLI()
50 let r = try cli.run(["--help"], passphrase: nil)
51 #expect(!r.stdout.contains("clipboard-daemon"))
52 #expect(r.stdout.contains("clip"))
53 }
54
55 @Test func clipOfMissingEntryIsNotFoundBeforeTouchingClipboard() throws {
56 let cli = try CLI.initialized()
57 let r = try cli.run(["clip", "nope"])
58 #expect(r.status == 3)
59 }
60}