@parlor/shell-desktop 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +10 -2
- package/assets/icons/128x128.png +0 -0
- package/assets/icons/128x128@2x.png +0 -0
- package/assets/icons/32x32.png +0 -0
- package/assets/icons/icon.icns +0 -0
- package/assets/icons/icon.ico +0 -0
- package/dist/build.d.ts +3 -0
- package/dist/build.d.ts.map +1 -0
- package/dist/build.js +18 -0
- package/dist/build.js.map +1 -0
- package/dist/dev.d.ts +9 -0
- package/dist/dev.d.ts.map +1 -0
- package/dist/dev.js +28 -0
- package/dist/dev.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/prerequisites.d.ts +4 -0
- package/dist/prerequisites.d.ts.map +1 -0
- package/dist/prerequisites.js +12 -0
- package/dist/prerequisites.js.map +1 -0
- package/dist/project.d.ts +48 -0
- package/dist/project.d.ts.map +1 -0
- package/dist/project.js +243 -0
- package/dist/project.js.map +1 -0
- package/dist/target-triple.d.ts +3 -0
- package/dist/target-triple.d.ts.map +1 -0
- package/dist/target-triple.js +10 -0
- package/dist/target-triple.js.map +1 -0
- package/dist/tauri-cli.d.ts +9 -0
- package/dist/tauri-cli.d.ts.map +1 -0
- package/dist/tauri-cli.js +42 -0
- package/dist/tauri-cli.js.map +1 -0
- package/package.json +45 -3
- package/rust/Cargo.toml +26 -0
- package/rust/src/control.rs +104 -0
- package/rust/src/device_key.rs +282 -0
- package/rust/src/dialog.rs +22 -0
- package/rust/src/host.rs +338 -0
- package/rust/src/keystore.rs +442 -0
- package/rust/src/lib.rs +20 -0
- package/rust/src/secret_store.rs +42 -0
- package/rust/src/session.rs +100 -0
- package/rust/src/vault.rs +133 -0
package/rust/Cargo.toml
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "parlor-shell"
|
|
3
|
+
version = "0.0.0"
|
|
4
|
+
edition = "2024"
|
|
5
|
+
publish = false
|
|
6
|
+
|
|
7
|
+
[lib]
|
|
8
|
+
name = "parlor_shell"
|
|
9
|
+
|
|
10
|
+
[dependencies]
|
|
11
|
+
tauri = { version = "2", features = [] }
|
|
12
|
+
tauri-plugin-dialog = "2"
|
|
13
|
+
serde = { version = "1", features = ["derive"] }
|
|
14
|
+
serde_json = "1"
|
|
15
|
+
base64 = "0.22"
|
|
16
|
+
sha2 = "0.10"
|
|
17
|
+
|
|
18
|
+
[target.'cfg(target_os = "macos")'.dependencies]
|
|
19
|
+
security-framework = "3"
|
|
20
|
+
security-framework-sys = "2"
|
|
21
|
+
core-foundation = "0.10"
|
|
22
|
+
core-foundation-sys = "0.8"
|
|
23
|
+
objc2 = "0.6"
|
|
24
|
+
objc2-foundation = "0.3"
|
|
25
|
+
objc2-local-authentication = "0.3"
|
|
26
|
+
block2 = "0.6"
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
use std::io::{BufRead, BufReader, Write};
|
|
2
|
+
use std::os::unix::net::UnixStream;
|
|
3
|
+
use std::path::PathBuf;
|
|
4
|
+
use std::time::Duration;
|
|
5
|
+
|
|
6
|
+
use serde_json::Value;
|
|
7
|
+
|
|
8
|
+
pub const REPLY_TIMEOUT: Duration = Duration::from_secs(30);
|
|
9
|
+
|
|
10
|
+
pub fn home() -> Result<PathBuf, String> {
|
|
11
|
+
std::env::var_os("HOME")
|
|
12
|
+
.map(PathBuf::from)
|
|
13
|
+
.ok_or_else(|| "HOME is not set".to_string())
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
pub fn socket_path(home: &std::path::Path) -> PathBuf {
|
|
17
|
+
home.join(".parlor")
|
|
18
|
+
.join("host")
|
|
19
|
+
.join("run")
|
|
20
|
+
.join("control.sock")
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
pub fn exchange(socket: &std::path::Path, request: &Value) -> Result<Value, String> {
|
|
24
|
+
let mut stream = UnixStream::connect(socket).map_err(|e| e.to_string())?;
|
|
25
|
+
stream
|
|
26
|
+
.set_read_timeout(Some(REPLY_TIMEOUT))
|
|
27
|
+
.map_err(|e| e.to_string())?;
|
|
28
|
+
let line = format!("{request}\n");
|
|
29
|
+
stream
|
|
30
|
+
.write_all(line.as_bytes())
|
|
31
|
+
.map_err(|e| e.to_string())?;
|
|
32
|
+
let mut reply = String::new();
|
|
33
|
+
BufReader::new(stream)
|
|
34
|
+
.read_line(&mut reply)
|
|
35
|
+
.map_err(|e| e.to_string())?;
|
|
36
|
+
parse_reply(&reply)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
pub fn parse_reply(line: &str) -> Result<Value, String> {
|
|
40
|
+
if line.trim().is_empty() {
|
|
41
|
+
return Err("Parlor Host closed the connection.".to_string());
|
|
42
|
+
}
|
|
43
|
+
serde_json::from_str(line).map_err(|e| e.to_string())
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
pub fn request(socket: &std::path::Path, request: Value) -> Result<Value, String> {
|
|
47
|
+
let reply = exchange(socket, &request)?;
|
|
48
|
+
if reply.get("ok").and_then(Value::as_bool) == Some(true) {
|
|
49
|
+
return Ok(reply);
|
|
50
|
+
}
|
|
51
|
+
let error = reply
|
|
52
|
+
.get("error")
|
|
53
|
+
.and_then(Value::as_str)
|
|
54
|
+
.unwrap_or("Parlor Host refused the request.");
|
|
55
|
+
Err(error.to_string())
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
#[cfg(test)]
|
|
59
|
+
mod tests {
|
|
60
|
+
use super::*;
|
|
61
|
+
use serde_json::json;
|
|
62
|
+
use std::os::unix::net::UnixListener;
|
|
63
|
+
|
|
64
|
+
#[test]
|
|
65
|
+
fn socket_lives_under_the_home_host_run_directory() {
|
|
66
|
+
assert_eq!(
|
|
67
|
+
socket_path(std::path::Path::new("/Users/a")),
|
|
68
|
+
PathBuf::from("/Users/a/.parlor/host/run/control.sock")
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
#[test]
|
|
73
|
+
fn a_reply_is_one_json_line() {
|
|
74
|
+
assert_eq!(parse_reply("{\"ok\":true}\n").unwrap(), json!({"ok": true}));
|
|
75
|
+
assert!(parse_reply("").is_err());
|
|
76
|
+
assert!(parse_reply("not json").is_err());
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
#[test]
|
|
80
|
+
fn request_writes_one_line_and_surfaces_a_refusal() {
|
|
81
|
+
let dir = std::env::temp_dir().join(format!("ps-{}", std::process::id()));
|
|
82
|
+
std::fs::create_dir_all(&dir).unwrap();
|
|
83
|
+
let path = dir.join("c.sock");
|
|
84
|
+
let listener = UnixListener::bind(&path).unwrap();
|
|
85
|
+
let server = std::thread::spawn(move || {
|
|
86
|
+
let (mut stream, _) = listener.accept().unwrap();
|
|
87
|
+
let mut line = String::new();
|
|
88
|
+
BufReader::new(stream.try_clone().unwrap())
|
|
89
|
+
.read_line(&mut line)
|
|
90
|
+
.unwrap();
|
|
91
|
+
assert_eq!(
|
|
92
|
+
serde_json::from_str::<Value>(&line).unwrap(),
|
|
93
|
+
json!({"op": "status"})
|
|
94
|
+
);
|
|
95
|
+
stream
|
|
96
|
+
.write_all(b"{\"ok\":false,\"error\":\"declined_locally\"}\n")
|
|
97
|
+
.unwrap();
|
|
98
|
+
});
|
|
99
|
+
let outcome = request(&path, json!({"op": "status"}));
|
|
100
|
+
server.join().unwrap();
|
|
101
|
+
std::fs::remove_dir_all(&dir).ok();
|
|
102
|
+
assert_eq!(outcome, Err("declined_locally".to_string()));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
use base64::Engine;
|
|
2
|
+
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
|
3
|
+
use serde::Serialize;
|
|
4
|
+
use sha2::{Digest, Sha256};
|
|
5
|
+
use tauri::AppHandle;
|
|
6
|
+
|
|
7
|
+
use crate::keystore::{self, PublicPoint};
|
|
8
|
+
|
|
9
|
+
#[derive(Serialize, Debug, PartialEq)]
|
|
10
|
+
#[serde(rename_all = "camelCase")]
|
|
11
|
+
pub struct PublicJwk {
|
|
12
|
+
pub kty: &'static str,
|
|
13
|
+
pub crv: &'static str,
|
|
14
|
+
pub x: String,
|
|
15
|
+
pub y: String,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
#[derive(Serialize, Debug, PartialEq)]
|
|
19
|
+
#[serde(rename_all = "camelCase")]
|
|
20
|
+
pub struct KeyReport {
|
|
21
|
+
pub key_id: String,
|
|
22
|
+
pub public_key: PublicJwk,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
#[derive(Serialize, Debug, PartialEq)]
|
|
26
|
+
pub struct Proof {
|
|
27
|
+
pub signature: String,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
#[derive(Serialize, Debug, PartialEq)]
|
|
31
|
+
pub struct PublicKeys {
|
|
32
|
+
pub signing: KeyReport,
|
|
33
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
34
|
+
pub presence: Option<KeyReport>,
|
|
35
|
+
pub proof: Proof,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
#[derive(Serialize, Debug, PartialEq)]
|
|
39
|
+
pub struct Signature {
|
|
40
|
+
pub signature: String,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
pub fn encode(bytes: &[u8]) -> String {
|
|
44
|
+
URL_SAFE_NO_PAD.encode(bytes)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
pub fn thumbprint(x: &str, y: &str) -> String {
|
|
48
|
+
let canonical = format!(r#"{{"crv":"P-256","kty":"EC","x":"{x}","y":"{y}"}}"#);
|
|
49
|
+
encode(&Sha256::digest(canonical.as_bytes()))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
pub fn report_of(point: &PublicPoint) -> KeyReport {
|
|
53
|
+
let (x, y) = (encode(&point.x), encode(&point.y));
|
|
54
|
+
KeyReport {
|
|
55
|
+
key_id: thumbprint(&x, &y),
|
|
56
|
+
public_key: PublicJwk {
|
|
57
|
+
kty: "EC",
|
|
58
|
+
crv: "P-256",
|
|
59
|
+
x,
|
|
60
|
+
y,
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
fn point_json(key: &PublicJwk) -> String {
|
|
66
|
+
format!(
|
|
67
|
+
r#"{{"crv":"P-256","kty":"EC","x":"{}","y":"{}"}}"#,
|
|
68
|
+
key.x, key.y
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
pub fn enrollment_bytes(
|
|
73
|
+
challenge: &str,
|
|
74
|
+
signing: &KeyReport,
|
|
75
|
+
presence: Option<&KeyReport>,
|
|
76
|
+
) -> Vec<u8> {
|
|
77
|
+
let challenge = serde_json::to_string(challenge).unwrap_or_default();
|
|
78
|
+
let presence = match presence {
|
|
79
|
+
Some(report) => format!(
|
|
80
|
+
r#"{{"format":"es256-raw","id":"{}","publicKey":{}}}"#,
|
|
81
|
+
report.key_id,
|
|
82
|
+
point_json(&report.public_key)
|
|
83
|
+
),
|
|
84
|
+
None => "null".to_string(),
|
|
85
|
+
};
|
|
86
|
+
format!(
|
|
87
|
+
r#"{{"challenge":{challenge},"presence":{presence},"signing":{}}}"#,
|
|
88
|
+
point_json(&signing.public_key)
|
|
89
|
+
)
|
|
90
|
+
.into_bytes()
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
pub fn der_to_raw(der: &[u8]) -> Result<[u8; 64], String> {
|
|
94
|
+
fn length(bytes: &[u8], at: &mut usize) -> Result<usize, String> {
|
|
95
|
+
let first = *bytes.get(*at).ok_or("short signature")?;
|
|
96
|
+
*at += 1;
|
|
97
|
+
if first < 0x80 {
|
|
98
|
+
return Ok(first as usize);
|
|
99
|
+
}
|
|
100
|
+
let count = (first & 0x7f) as usize;
|
|
101
|
+
let mut value = 0usize;
|
|
102
|
+
for _ in 0..count {
|
|
103
|
+
value = (value << 8) | *bytes.get(*at).ok_or("short signature")? as usize;
|
|
104
|
+
*at += 1;
|
|
105
|
+
}
|
|
106
|
+
Ok(value)
|
|
107
|
+
}
|
|
108
|
+
fn integer(bytes: &[u8], at: &mut usize) -> Result<[u8; 32], String> {
|
|
109
|
+
if bytes.get(*at) != Some(&0x02) {
|
|
110
|
+
return Err("not an integer".to_string());
|
|
111
|
+
}
|
|
112
|
+
*at += 1;
|
|
113
|
+
let size = length(bytes, at)?;
|
|
114
|
+
let body = bytes.get(*at..*at + size).ok_or("short signature")?;
|
|
115
|
+
*at += size;
|
|
116
|
+
let trimmed = match body.iter().position(|byte| *byte != 0) {
|
|
117
|
+
Some(first) => &body[first..],
|
|
118
|
+
None => &[0u8][..],
|
|
119
|
+
};
|
|
120
|
+
if trimmed.len() > 32 {
|
|
121
|
+
return Err("integer too long".to_string());
|
|
122
|
+
}
|
|
123
|
+
let mut out = [0u8; 32];
|
|
124
|
+
out[32 - trimmed.len()..].copy_from_slice(trimmed);
|
|
125
|
+
Ok(out)
|
|
126
|
+
}
|
|
127
|
+
let mut at = 0;
|
|
128
|
+
if der.first() != Some(&0x30) {
|
|
129
|
+
return Err("not a sequence".to_string());
|
|
130
|
+
}
|
|
131
|
+
at += 1;
|
|
132
|
+
length(der, &mut at)?;
|
|
133
|
+
let r = integer(der, &mut at)?;
|
|
134
|
+
let s = integer(der, &mut at)?;
|
|
135
|
+
let mut raw = [0u8; 64];
|
|
136
|
+
raw[..32].copy_from_slice(&r);
|
|
137
|
+
raw[32..].copy_from_slice(&s);
|
|
138
|
+
Ok(raw)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
pub fn decode_payload(payload: &str) -> Result<Vec<u8>, String> {
|
|
142
|
+
URL_SAFE_NO_PAD.decode(payload).map_err(|e| e.to_string())
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async fn blocking<T: Send + 'static>(
|
|
146
|
+
work: impl FnOnce() -> Result<T, String> + Send + 'static,
|
|
147
|
+
) -> Result<T, String> {
|
|
148
|
+
tauri::async_runtime::spawn_blocking(work)
|
|
149
|
+
.await
|
|
150
|
+
.map_err(|e| e.to_string())?
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
#[tauri::command]
|
|
154
|
+
pub async fn parlor_device_key_public(
|
|
155
|
+
app: AppHandle,
|
|
156
|
+
presence: bool,
|
|
157
|
+
challenge: String,
|
|
158
|
+
) -> Result<PublicKeys, String> {
|
|
159
|
+
let identifier = app.config().identifier.clone();
|
|
160
|
+
blocking(move || {
|
|
161
|
+
let signing = report_of(&keystore::signing_key(&identifier)?.point()?);
|
|
162
|
+
let presence = if presence {
|
|
163
|
+
keystore::presence_key(&identifier)?
|
|
164
|
+
.map(|key| key.point().map(|point| report_of(&point)))
|
|
165
|
+
.transpose()?
|
|
166
|
+
} else {
|
|
167
|
+
None
|
|
168
|
+
};
|
|
169
|
+
let bytes = enrollment_bytes(&challenge, &signing, presence.as_ref());
|
|
170
|
+
let raw = keystore::signing_key(&identifier)?.sign(&bytes)?;
|
|
171
|
+
Ok(PublicKeys {
|
|
172
|
+
signing,
|
|
173
|
+
presence,
|
|
174
|
+
proof: Proof {
|
|
175
|
+
signature: encode(&raw),
|
|
176
|
+
},
|
|
177
|
+
})
|
|
178
|
+
})
|
|
179
|
+
.await
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
#[tauri::command]
|
|
183
|
+
pub async fn parlor_device_key_sign(
|
|
184
|
+
app: AppHandle,
|
|
185
|
+
key_id: String,
|
|
186
|
+
payload: String,
|
|
187
|
+
) -> Result<Signature, String> {
|
|
188
|
+
let identifier = app.config().identifier.clone();
|
|
189
|
+
blocking(move || {
|
|
190
|
+
let bytes = decode_payload(&payload)?;
|
|
191
|
+
let key = keystore::find(&identifier, &key_id)?;
|
|
192
|
+
Ok(Signature {
|
|
193
|
+
signature: encode(&key.sign(&bytes)?),
|
|
194
|
+
})
|
|
195
|
+
})
|
|
196
|
+
.await
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
#[cfg(test)]
|
|
200
|
+
mod tests {
|
|
201
|
+
use super::*;
|
|
202
|
+
|
|
203
|
+
fn report(x: &str, y: &str) -> KeyReport {
|
|
204
|
+
KeyReport {
|
|
205
|
+
key_id: thumbprint(x, y),
|
|
206
|
+
public_key: PublicJwk {
|
|
207
|
+
kty: "EC",
|
|
208
|
+
crv: "P-256",
|
|
209
|
+
x: x.to_string(),
|
|
210
|
+
y: y.to_string(),
|
|
211
|
+
},
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
#[test]
|
|
216
|
+
fn the_enrollment_bytes_match_the_canonical_json_of_the_web_adapter() {
|
|
217
|
+
let signing = report("AAA", "BBB");
|
|
218
|
+
let text = String::from_utf8(enrollment_bytes("chal", &signing, None)).unwrap();
|
|
219
|
+
assert_eq!(
|
|
220
|
+
text,
|
|
221
|
+
r#"{"challenge":"chal","presence":null,"signing":{"crv":"P-256","kty":"EC","x":"AAA","y":"BBB"}}"#
|
|
222
|
+
);
|
|
223
|
+
let presence = report("CCC", "DDD");
|
|
224
|
+
let text = String::from_utf8(enrollment_bytes("chal", &signing, Some(&presence))).unwrap();
|
|
225
|
+
assert_eq!(
|
|
226
|
+
text,
|
|
227
|
+
format!(
|
|
228
|
+
r#"{{"challenge":"chal","presence":{{"format":"es256-raw","id":"{}","publicKey":{{"crv":"P-256","kty":"EC","x":"CCC","y":"DDD"}}}},"signing":{{"crv":"P-256","kty":"EC","x":"AAA","y":"BBB"}}}}"#,
|
|
229
|
+
presence.key_id
|
|
230
|
+
)
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
#[test]
|
|
235
|
+
fn the_key_id_is_the_sha256_thumbprint_of_the_sorted_point() {
|
|
236
|
+
assert_eq!(
|
|
237
|
+
thumbprint("x", "y"),
|
|
238
|
+
encode(&Sha256::digest(
|
|
239
|
+
br#"{"crv":"P-256","kty":"EC","x":"x","y":"y"}"#
|
|
240
|
+
))
|
|
241
|
+
);
|
|
242
|
+
assert_eq!(thumbprint("x", "y").len(), 43);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
#[test]
|
|
246
|
+
fn der_signatures_become_raw_r_and_s() {
|
|
247
|
+
let mut der = vec![0x30, 0x44, 0x02, 0x20];
|
|
248
|
+
der.extend([1u8; 32]);
|
|
249
|
+
der.extend([0x02, 0x20]);
|
|
250
|
+
der.extend([2u8; 32]);
|
|
251
|
+
let raw = der_to_raw(&der).unwrap();
|
|
252
|
+
assert_eq!(&raw[..32], &[1u8; 32]);
|
|
253
|
+
assert_eq!(&raw[32..], &[2u8; 32]);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
#[test]
|
|
257
|
+
fn der_integers_lose_their_sign_padding_and_gain_leading_zeros() {
|
|
258
|
+
let mut der = vec![0x30, 0x26, 0x02, 0x21, 0x00];
|
|
259
|
+
der.extend([0xffu8; 32]);
|
|
260
|
+
der.extend([0x02, 0x01, 0x05]);
|
|
261
|
+
let raw = der_to_raw(&der).unwrap();
|
|
262
|
+
assert_eq!(&raw[..32], &[0xffu8; 32]);
|
|
263
|
+
assert_eq!(raw[63], 5);
|
|
264
|
+
assert_eq!(&raw[32..63], &[0u8; 31]);
|
|
265
|
+
assert!(der_to_raw(&[0x31, 0x00]).is_err());
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
#[test]
|
|
269
|
+
fn a_missing_presence_key_is_left_out_of_the_reply() {
|
|
270
|
+
let keys = PublicKeys {
|
|
271
|
+
signing: report("a", "b"),
|
|
272
|
+
presence: None,
|
|
273
|
+
proof: Proof {
|
|
274
|
+
signature: "s".to_string(),
|
|
275
|
+
},
|
|
276
|
+
};
|
|
277
|
+
let json = serde_json::to_value(&keys).unwrap();
|
|
278
|
+
assert!(json.get("presence").is_none());
|
|
279
|
+
assert_eq!(json["signing"]["publicKey"]["kty"], "EC");
|
|
280
|
+
assert_eq!(json["signing"]["keyId"], keys.signing.key_id);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
pub fn title(app_name: &str) -> String {
|
|
2
|
+
format!("Connect this Mac to {app_name}")
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
pub fn message(app_name: &str, account: &str, matching_code: &str) -> String {
|
|
6
|
+
format!(
|
|
7
|
+
"Let {app_name} use the agents on this Mac as {account}?\n\nCode: {matching_code}\n\nContinue only if you started this in {app_name}."
|
|
8
|
+
)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
#[cfg(test)]
|
|
12
|
+
mod tests {
|
|
13
|
+
use super::*;
|
|
14
|
+
|
|
15
|
+
#[test]
|
|
16
|
+
fn the_dialog_names_the_app_the_account_and_the_code() {
|
|
17
|
+
assert_eq!(title("Hello"), "Connect this Mac to Hello");
|
|
18
|
+
let text = message("Hello", "alice@example.com", "ABC-234");
|
|
19
|
+
assert!(text.contains("alice@example.com"));
|
|
20
|
+
assert!(text.contains("ABC-234"));
|
|
21
|
+
}
|
|
22
|
+
}
|