diff --git a/.gitignore b/.gitignore index c9e6b038..b7d65008 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ Thumbs.db # Setup window build (spec 15.7) /app/dist-setup/ +/app/dist-phone/ # Design sync (claude.ai/design): staged scripts, build output, machine state /.ds-sync/ diff --git a/Cargo.lock b/Cargo.lock index abb5aeb4..16dd6717 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -18,6 +18,41 @@ dependencies = [ "generic-array", ] +[[package]] +name = "aead" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" +dependencies = [ + "crypto-common 0.2.2", + "inout 0.2.2", +] + +[[package]] +name = "aes" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" +dependencies = [ + "cipher 0.5.2", + "cpubits", + "cpufeatures 0.3.1", +] + +[[package]] +name = "aes-gcm" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f2b8006a0c83f52b62ba44a97b58bf76fe2f70a329e588f67f89691d93d498f" +dependencies = [ + "aead 0.6.1", + "aes", + "cipher 0.5.2", + "ctr", + "ctutils", + "ghash", +] + [[package]] name = "aho-corasick" version = "1.1.5" @@ -372,6 +407,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + [[package]] name = "base64" version = "0.21.7" @@ -506,6 +547,8 @@ dependencies = [ "hex", "lettre", "object_store", + "p256", + "reqwest", "rusqlite", "serde", "serde_json", @@ -513,6 +556,7 @@ dependencies = [ "tempfile", "thiserror 2.0.21", "tokio", + "tokio-tungstenite 0.30.0", "toml 1.1.6+spec-1.1.0", "tower", "tracing", @@ -559,6 +603,7 @@ dependencies = [ name = "botloftd" version = "0.13.0" dependencies = [ + "aes-gcm", "anyhow", "argon2", "axum", @@ -577,6 +622,7 @@ dependencies = [ "jiff", "jpeg-encoder", "libc", + "p256", "reqwest", "serde", "serde_json", @@ -789,7 +835,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", - "cipher", + "cipher 0.4.4", "cpufeatures 0.2.17", ] @@ -810,9 +856,9 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" dependencies = [ - "aead", + "aead 0.5.2", "chacha20 0.9.1", - "cipher", + "cipher 0.4.4", "poly1305", "zeroize", ] @@ -836,10 +882,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common 0.1.7", - "inout", + "inout 0.1.4", "zeroize", ] +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "inout 0.2.2", +] + [[package]] name = "clap" version = "4.6.7" @@ -889,6 +946,12 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "colorchoice" version = "1.0.5" @@ -980,6 +1043,12 @@ dependencies = [ "libc", ] +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1032,6 +1101,21 @@ version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" +[[package]] +name = "crypto-bigint" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" +dependencies = [ + "cpubits", + "ctutils", + "hybrid-array", + "num-traits", + "rand_core 0.10.1", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -1049,7 +1133,9 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ + "getrandom 0.4.3", "hybrid-array", + "rand_core 0.10.1", ] [[package]] @@ -1081,6 +1167,25 @@ version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" +[[package]] +name = "ctr" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" +dependencies = [ + "cipher 0.5.2", +] + +[[package]] +name = "ctutils" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" +dependencies = [ + "cmov", + "subtle", +] + [[package]] name = "darling" version = "0.24.1" @@ -1163,6 +1268,16 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "der" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a" +dependencies = [ + "const-oid", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -1230,6 +1345,7 @@ dependencies = [ "block-buffer 0.12.1", "const-oid", "crypto-common 0.2.2", + "ctutils", ] [[package]] @@ -1359,12 +1475,48 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ecdsa" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" +dependencies = [ + "der", + "digest 0.11.3", + "elliptic-curve", + "rfc6979", + "signature", + "spki", + "zeroize", +] + [[package]] name = "either" version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" +[[package]] +name = "elliptic-curve" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65" +dependencies = [ + "base16ct", + "crypto-bigint", + "crypto-common 0.2.2", + "digest 0.11.3", + "ff", + "group", + "hkdf", + "hybrid-array", + "pkcs8", + "rand_core 0.10.1", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "email-encoding" version = "0.4.2" @@ -1502,6 +1654,16 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "ff" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" +dependencies = [ + "rand_core 0.10.1", + "subtle", +] + [[package]] name = "field-offset" version = "0.3.6" @@ -1826,6 +1988,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "ghash" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5" +dependencies = [ + "polyval", +] + [[package]] name = "gio" version = "0.18.4" @@ -1922,6 +2093,17 @@ dependencies = [ "system-deps", ] +[[package]] +name = "group" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" +dependencies = [ + "ff", + "rand_core 0.10.1", + "subtle", +] + [[package]] name = "gtk" version = "0.18.2" @@ -2050,6 +2232,24 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "html5ever" version = "0.39.0" @@ -2117,7 +2317,9 @@ version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" dependencies = [ + "subtle", "typenum", + "zeroize", ] [[package]] @@ -2377,6 +2579,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "hybrid-array", +] + [[package]] name = "ipnet" version = "2.12.2" @@ -2644,7 +2855,7 @@ dependencies = [ "tokio", "tokio-rustls", "url", - "webpki-roots", + "webpki-roots 1.0.9", ] [[package]] @@ -3300,6 +3511,19 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "p256" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primefield", + "primeorder", + "sha2 0.11.0", +] + [[package]] name = "pango" version = "0.18.3" @@ -3441,6 +3665,16 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.34" @@ -3508,7 +3742,18 @@ checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" dependencies = [ "cpufeatures 0.2.17", "opaque-debug", - "universal-hash", + "universal-hash 0.5.1", +] + +[[package]] +name = "polyval" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd" +dependencies = [ + "cpubits", + "cpufeatures 0.3.1", + "universal-hash 0.6.1", ] [[package]] @@ -3556,6 +3801,32 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "primefield" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" +dependencies = [ + "crypto-bigint", + "crypto-common 0.2.2", + "ff", + "rand_core 0.10.1", + "subtle", + "zeroize", +] + +[[package]] +name = "primeorder" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06" +dependencies = [ + "elliptic-curve", + "primefield", + "serdect", + "wnaf", +] + [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -3900,6 +4171,16 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfc6979" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b" +dependencies = [ + "crypto-bigint", + "hmac", +] + [[package]] name = "rfd" version = "0.16.0" @@ -4155,6 +4436,20 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" +dependencies = [ + "base16ct", + "ctutils", + "der", + "hybrid-array", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -4358,6 +4653,16 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct", + "serde", +] + [[package]] name = "serialize-to-javascript" version = "0.1.2" @@ -4458,6 +4763,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "digest 0.11.3", + "rand_core 0.10.1", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -4562,6 +4877,16 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" +[[package]] +name = "spki" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef958a98b9d5da290cfc78946e9f3e61e1e62a18db0d92cac0b83cc161491a9" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "sqlite-wasm-rs" version = "0.5.5" @@ -5320,8 +5645,12 @@ checksum = "17a073bfed563fa236697a068031408a93cd9522e08abf9933ead3e73411bd71" dependencies = [ "futures-util", "log", + "rustls", + "rustls-pki-types", "tokio", + "tokio-rustls", "tungstenite 0.30.0", + "webpki-roots 0.26.11", ] [[package]] @@ -5634,6 +5963,8 @@ dependencies = [ "httparse", "log", "rand 0.10.3", + "rustls", + "rustls-pki-types", "sha1 0.11.0", "thiserror 2.0.21", ] @@ -5699,6 +6030,16 @@ dependencies = [ "subtle", ] +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -5986,6 +6327,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + [[package]] name = "webpki-roots" version = "1.0.9" @@ -6471,6 +6821,18 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wnaf" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "795ca18b3fdb5e62bf982199278341ddcf7ebf7d32e25e212ad05d496e95f6fa" +dependencies = [ + "ff", + "group", + "hybrid-array", + "primefield", +] + [[package]] name = "writeable" version = "0.6.4" diff --git a/app/package.json b/app/package.json index d085b2c6..392e4d2f 100644 --- a/app/package.json +++ b/app/package.json @@ -8,6 +8,7 @@ "scripts": { "dev": "vite", "build": "pnpm typecheck && vite build", + "build:phone": "vite build --mode phone", "preview": "vite preview", "tauri": "tauri", "bundle": "node scripts/sidecar.mjs && tauri build --config src-tauri/tauri.bundle.conf.json", @@ -28,6 +29,7 @@ "@tauri-apps/plugin-notification": "^2.5.0", "@tauri-apps/plugin-updater": "^2.13.0", "lucide-react": "^1.48.0", + "qrcode-generator": "2.0.4", "react": "^19.3.0", "react-dom": "^19.3.0", "react-markdown": "^10.1.0", diff --git a/app/pnpm-lock.yaml b/app/pnpm-lock.yaml index bd82fe4a..f50e8c59 100644 --- a/app/pnpm-lock.yaml +++ b/app/pnpm-lock.yaml @@ -187,6 +187,9 @@ importers: lucide-react: specifier: ^1.48.0 version: 1.48.0(react@19.3.0) + qrcode-generator: + specifier: 2.0.4 + version: 2.0.4 react: specifier: ^19.3.0 version: 19.3.0 @@ -1423,6 +1426,9 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qrcode-generator@2.0.4: + resolution: {integrity: sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g==} + react-dom@19.3.0: resolution: {integrity: sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==} peerDependencies: @@ -2822,6 +2828,8 @@ snapshots: punycode@2.3.1: {} + qrcode-generator@2.0.4: {} + react-dom@19.3.0(react@19.3.0): dependencies: react: 19.3.0 diff --git a/app/public-phone/apple-touch-icon.png b/app/public-phone/apple-touch-icon.png new file mode 100644 index 00000000..d392523b Binary files /dev/null and b/app/public-phone/apple-touch-icon.png differ diff --git a/app/public-phone/icon-192.png b/app/public-phone/icon-192.png new file mode 100644 index 00000000..178b781a Binary files /dev/null and b/app/public-phone/icon-192.png differ diff --git a/app/public-phone/icon-512.png b/app/public-phone/icon-512.png new file mode 100644 index 00000000..0c479f3a Binary files /dev/null and b/app/public-phone/icon-512.png differ diff --git a/app/public-phone/manifest.webmanifest b/app/public-phone/manifest.webmanifest new file mode 100644 index 00000000..e6c80bd6 --- /dev/null +++ b/app/public-phone/manifest.webmanifest @@ -0,0 +1,14 @@ +{ + "name": "Botloft", + "short_name": "Botloft", + "description": "Approve requests and answer questions from your bots.", + "start_url": "/m/", + "scope": "/m/", + "display": "standalone", + "background_color": "#0b0b0b", + "theme_color": "#0b0b0b", + "icons": [ + { "src": "/m/icon-192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "/m/icon-512.png", "sizes": "512x512", "type": "image/png" } + ] +} diff --git a/app/public-phone/sw.js b/app/public-phone/sw.js new file mode 100644 index 00000000..35d4e8dd --- /dev/null +++ b/app/public-phone/sw.js @@ -0,0 +1,92 @@ +// The phone's page keeps its own files for the next visit, and nothing else: +// never an answer from the server and never what a bot asked (spec 28.7). +const CACHE = "botloft-phone-1"; + +self.addEventListener("install", () => self.skipWaiting()); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches + .keys() + .then((names) => + Promise.all(names.filter((name) => name !== CACHE).map((name) => caches.delete(name))), + ) + .then(() => self.clients.claim()), + ); +}); + +// A push says only that something waits, with nothing in it (spec 28.8): the +// words are here, in the phone's language, and the details are in the page. +const WORDS = { + en: ["Botloft needs you", "Something is waiting for you."], + pt: ["O Botloft precisa de você", "Tem algo esperando você."], + es: ["Botloft te necesita", "Hay algo esperando."], +}; + +function words() { + const language = (self.navigator.language || "en").toLowerCase(); + if (language.startsWith("pt")) return WORDS.pt; + if (language.startsWith("es")) return WORDS.es; + return WORDS.en; +} + +self.addEventListener("push", (event) => { + const [title, body] = words(); + // One notice stands for all that wait; a new push brings it back up. + event.waitUntil( + self.registration.showNotification(title, { + body, + tag: "botloft-waiting", + renotify: true, + icon: "/m/icon-192.png", + badge: "/m/icon-192.png", + }), + ); +}); + +self.addEventListener("notificationclick", (event) => { + event.notification.close(); + event.waitUntil( + self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((windows) => { + const open = windows.find((window) => new URL(window.url).pathname.startsWith("/m")); + return open ? open.focus() : self.clients.openWindow("/m/"); + }), + ); +}); + +self.addEventListener("fetch", (event) => { + const { request } = event; + const url = new URL(request.url); + if ( + request.method !== "GET" || + url.origin !== self.location.origin || + !url.pathname.startsWith("/m/") + ) { + return; + } + // The built files have a hash in their names: once kept, they never change. + if (url.pathname.startsWith("/m/assets/")) { + event.respondWith( + caches.match(request).then( + (kept) => + kept || + fetch(request).then((fresh) => { + const copy = fresh.clone(); + caches.open(CACHE).then((cache) => cache.put(request, copy)); + return fresh; + }), + ), + ); + return; + } + // The page itself comes from the server when it can, and from here when not. + event.respondWith( + fetch(request) + .then((fresh) => { + const copy = fresh.clone(); + caches.open(CACHE).then((cache) => cache.put(request, copy)); + return fresh; + }) + .catch(() => caches.match(request).then((kept) => kept || caches.match("/m/"))), + ); +}); diff --git a/app/src/features/account/BackupSettings.tsx b/app/src/features/account/BackupSettings.tsx index 590dfa39..377caa3f 100644 --- a/app/src/features/account/BackupSettings.tsx +++ b/app/src/features/account/BackupSettings.tsx @@ -13,6 +13,7 @@ import { Callout } from "../../ui/Callout"; import { BackupRestore } from "./BackupRestore"; import { backupError } from "./backupError"; import { CloudAccount } from "./CloudAccount"; +import { MobilePhones } from "./MobilePhones"; import { PassphraseFields, usePassphrase } from "./PassphraseFields"; import { Section } from "./settingsParts"; @@ -26,6 +27,7 @@ export function BackupSettings() {
+

{b.exportIntro}

diff --git a/app/src/features/account/MobilePairing.tsx b/app/src/features/account/MobilePairing.tsx new file mode 100644 index 00000000..eb7d8f92 --- /dev/null +++ b/app/src/features/account/MobilePairing.tsx @@ -0,0 +1,102 @@ +// A code waiting to be scanned (spec 28.3, 28.7): the picture, the time left, +// and, once a phone scanned it, the name and the two codes to compare. + +import { useEffect, useRef, useState } from "react"; +import { useT } from "../../i18n"; +import type { MobilePairStarted, MobilePairing as Pending } from "../../lib/protocol.gen"; +import { useApi } from "../../store/context"; +import { Button } from "../../ui/Button"; +import { Callout } from "../../ui/Callout"; +import { QrCode } from "../../ui/QrCode"; +import { backupError } from "./backupError"; +import { clock, useSecondsLeft } from "./useMobile"; + +/** "482913" as "482 913", easier to compare by eye. */ +const spaced = (code: string) => `${code.slice(0, 3)} ${code.slice(3)}`; + +export function MobilePairing({ + pending, + started, + done, + lapsed, + refresh, +}: { + pending: Pending; + started: MobilePairStarted | null; + /** The owner is done with this code: refused, cancelled or connected. */ + done(): void; + /** It ran out. */ + lapsed(): void; + refresh(): Promise; +}) { + const m = useT().account.mobile; + const reasons = useT().account.cloud.reasons; + const api = useApi(); + const left = useSecondsLeft(pending.expiresAt); + const [accepting, setAccepting] = useState(false); + const [problem, setProblem] = useState(null); + + // The time is up: the daemon forgets the code, and this one goes away. + const over = useRef(false); + useEffect(() => { + if (left === 0 && !accepting && !over.current) { + over.current = true; + lapsed(); + void refresh(); + } + }, [left, accepting, lapsed, refresh]); + + const answer = async (accept: boolean) => { + setProblem(null); + setAccepting(accept); + try { + await api.call("mobile.pair_confirm", { pairId: pending.pairId, accept }); + if (!accept) { + done(); + } + } catch (failure) { + setAccepting(false); + setProblem(backupError(failure, reasons)); + } + }; + + const cancel = async () => { + done(); + await api.call("mobile.pair_cancel", { pairId: pending.pairId }).catch(() => null); + await refresh(); + }; + + return ( +
+ {pending.joined ? ( + <> +

{m.wants(pending.joined.name)}

+

{spaced(pending.joined.code)}

+

{m.compare(spaced(pending.joined.code))}

+
+ + +
+ + ) : ( + <> + {started && } +

{m.scan}

+

{m.expires(clock(left))}

+ + + )} + {problem && ( + + {problem} + + )} +
+ ); +} diff --git a/app/src/features/account/MobilePhones.tsx b/app/src/features/account/MobilePhones.tsx new file mode 100644 index 00000000..423a7967 --- /dev/null +++ b/app/src/features/account/MobilePhones.tsx @@ -0,0 +1,32 @@ +// Settings, "Backup", the phone block (spec 28.7): connect a phone by showing +// a code for it to scan, compare the two codes, and cut a phone off. + +import { Smartphone } from "lucide-react"; +import { useT } from "../../i18n"; +import { MobileSignedIn } from "./MobileSignedIn"; +import { Section } from "./settingsParts"; +import { useCloud } from "./useCloud"; +import { useMobile } from "./useMobile"; + +export function MobilePhones() { + const m = useT().account.mobile; + const { status: cloud } = useCloud(); + const { status, refresh } = useMobile(); + // No cloud set up, nothing to connect a phone to. + if (!cloud?.url) { + return null; + } + return ( +
+

+ + {m.intro} +

+ {!cloud.signedIn ? ( +

{m.needAccount}

+ ) : ( + status && + )} +
+ ); +} diff --git a/app/src/features/account/MobileSignedIn.tsx b/app/src/features/account/MobileSignedIn.tsx new file mode 100644 index 00000000..6e3ee043 --- /dev/null +++ b/app/src/features/account/MobileSignedIn.tsx @@ -0,0 +1,127 @@ +// The phone block once the computer is in the account (spec 28.7): the phones +// that are connected, and the way to connect another. + +import { useState } from "react"; +import { useT } from "../../i18n"; +import { when } from "../../lib/format"; +import type { MobilePairStarted, MobilePhone, MobileStatus } from "../../lib/protocol.gen"; +import { useApi } from "../../store/context"; +import { Button } from "../../ui/Button"; +import { Callout } from "../../ui/Callout"; +import { Confirm } from "../../ui/Confirm"; +import { backupError } from "./backupError"; +import { MobilePairing } from "./MobilePairing"; + +export function MobileSignedIn({ + status, + refresh, +}: { + status: MobileStatus; + refresh(): Promise; +}) { + const m = useT().account.mobile; + const reasons = useT().account.cloud.reasons; + const api = useApi(); + const [started, setStarted] = useState(null); + const [busy, setBusy] = useState(false); + const [problem, setProblem] = useState(null); + const [lapsed, setLapsed] = useState(false); + const [leaving, setLeaving] = useState(null); + + const connect = async () => { + setBusy(true); + setProblem(null); + setLapsed(false); + try { + setStarted(await api.call("mobile.pair_start")); + } catch (failure) { + setProblem(backupError(failure, reasons)); + } + setBusy(false); + }; + + const disconnect = async (phone: MobilePhone) => { + try { + await api.call("mobile.revoke", { phoneId: phone.id }); + } catch (failure) { + setProblem(backupError(failure, reasons)); + } + await refresh(); + }; + + // A code on screen: this window's own, or one left from before it opened. + const pending = status.pending; + const showing = pending && (!started || started.pairId === pending.pairId); + + return ( +
+ {status.phones.length === 0 ? ( +

{m.none}

+ ) : ( +
+

{m.phones}

+
    + {status.phones.map((phone) => ( +
  • +
    +

    {phone.name}

    +

    + {phone.online + ? m.online + : phone.lastSeenAt !== undefined + ? m.seen(when(phone.lastSeenAt)) + : m.neverSeen} +

    +
    + +
  • + ))} +
+

{m.warning}

+
+ )} + + {showing && pending ? ( + setStarted(null)} + lapsed={() => { + setStarted(null); + setLapsed(true); + }} + refresh={refresh} + /> + ) : ( +
+ {lapsed && ( +

+ {m.expired} +

+ )} + +
+ )} + + {problem && ( + + {problem} + + )} + {leaving && ( + disconnect(leaving)} + onClose={() => setLeaving(null)} + > + {m.disconnectText} + + )} +
+ ); +} diff --git a/app/src/features/account/mobile.test.tsx b/app/src/features/account/mobile.test.tsx new file mode 100644 index 00000000..05a0b0f9 --- /dev/null +++ b/app/src/features/account/mobile.test.tsx @@ -0,0 +1,86 @@ +import { act, cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { FakeBotloft } from "../../lib/fake"; +import { openFakeCloudLink } from "../../lib/fakeCloud"; +import { FakeHost } from "../../lib/fakeHost"; +import { joinFakePhone } from "../../lib/fakeMobile"; +import { crewOpened, renderApp } from "../../test/app"; + +afterEach(cleanup); + +async function openBackup() { + const fake = new FakeBotloft(); + const ops = fake.addCrew("Ops"); + fake.addBot(ops.id, "Scout"); + renderApp(fake, new FakeHost()); + await crewOpened("Ops"); + fireEvent.click(await screen.findByRole("button", { name: /Ana Lima/ })); + fireEvent.click(screen.getByRole("menuitem", { name: "Settings" })); + const dialog = screen.getByRole("dialog", { name: "Settings" }); + fireEvent.click(within(dialog).getByRole("tab", { name: "Backup" })); + return { fake, dialog }; +} + +/** Signs in through the screen, the way the owner does. */ +async function signIn(fake: FakeBotloft, dialog: HTMLElement) { + fireEvent.change(await within(dialog).findByLabelText("Your e-mail"), { + target: { value: "ana@exemplo.com" }, + }); + fireEvent.click(within(dialog).getByRole("button", { name: "Send me the link" })); + await within(dialog).findByText(/We sent a link to ana@exemplo.com/); + act(() => openFakeCloudLink(fake)); + await within(dialog).findByText("Signed in as ana@exemplo.com"); +} + +describe("the phone block in Settings", () => { + test("waits for the account, then shows a code, compares it and connects the phone", async () => { + const { fake, dialog } = await openBackup(); + await within(dialog).findByText("Sign in to the account above to use the phone."); + expect(within(dialog).queryByRole("button", { name: "Connect a phone" })).toBeNull(); + await signIn(fake, dialog); + + expect(await within(dialog).findByText("No phone connected yet.")).toBeTruthy(); + fireEvent.click(within(dialog).getByRole("button", { name: "Connect a phone" })); + // The code to scan is a picture, with the time it still works. + expect( + await within(dialog).findByRole("img", { name: "Code to scan with your phone" }), + ).toBeTruthy(); + expect(within(dialog).getByText(/The code works for [45]:[0-5][0-9] more/)).toBeTruthy(); + + // A phone scans it: its name, and the code to compare with the phone's screen. + act(() => joinFakePhone(fake, "Celular da Ana", "482913")); + expect(await within(dialog).findByText("Celular da Ana wants to connect.")).toBeTruthy(); + expect(within(dialog).getByText("482 913", { selector: "p.font-mono" })).toBeTruthy(); + expect(within(dialog).queryByRole("img", { name: "Code to scan with your phone" })).toBeNull(); + + fireEvent.click(within(dialog).getByRole("button", { name: "Connect" })); + const list = await within(dialog).findByRole("list"); + expect(within(list).getByText("Celular da Ana")).toBeTruthy(); + expect(within(list).getByText("Online now")).toBeTruthy(); + expect(within(dialog).getByRole("button", { name: "Connect a phone" })).toBeTruthy(); + + // Disconnecting asks first, and the phone is gone. + fireEvent.click(within(list).getByRole("button", { name: "Disconnect" })); + const confirm = await screen.findByRole("dialog", { name: "Disconnect Celular da Ana?" }); + fireEvent.click(within(confirm).getByRole("button", { name: "Disconnect" })); + await within(dialog).findByText("No phone connected yet."); + expect(fake.calls.some((call) => call.method === "mobile.revoke")).toBe(true); + }); + + test("cancelling or refusing leaves no phone and brings the button back", async () => { + const { fake, dialog } = await openBackup(); + await signIn(fake, dialog); + fireEvent.click(await within(dialog).findByRole("button", { name: "Connect a phone" })); + await within(dialog).findByRole("img", { name: "Code to scan with your phone" }); + fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" })); + await within(dialog).findByRole("button", { name: "Connect a phone" }); + expect((await fake.call("mobile.status")).pending).toBeUndefined(); + + fireEvent.click(within(dialog).getByRole("button", { name: "Connect a phone" })); + await within(dialog).findByRole("img", { name: "Code to scan with your phone" }); + act(() => joinFakePhone(fake)); + fireEvent.click(await within(dialog).findByRole("button", { name: "Not this one" })); + await within(dialog).findByRole("button", { name: "Connect a phone" }); + await waitFor(async () => expect((await fake.call("mobile.status")).phones).toEqual([])); + }); +}); diff --git a/app/src/features/account/useMobile.ts b/app/src/features/account/useMobile.ts new file mode 100644 index 00000000..ea513764 --- /dev/null +++ b/app/src/features/account/useMobile.ts @@ -0,0 +1,50 @@ +// What the Settings screen knows of the phones (spec 28.5): their status, +// kept current by the daemon's notifications, and the time left on a code. + +import { useCallback, useEffect, useState } from "react"; +import type { MobileStatus } from "../../lib/protocol.gen"; +import { useApi } from "../../store/context"; + +export function useMobile() { + const api = useApi(); + const [status, setStatus] = useState(null); + + const refresh = useCallback(async () => { + try { + setStatus(await api.call("mobile.status")); + } catch { + // The screen keeps what it had. + } + }, [api]); + + useEffect(() => { + void refresh(); + return api.subscribe((event) => { + if (event.name === "mobile.changed") { + setStatus(event.params); + } + }); + }, [api, refresh]); + + return { status, refresh }; +} + +/** Seconds left until `expiresAt`, counted down each second; 0 when it is past. */ +export function useSecondsLeft(expiresAt: number | undefined): number { + const [seconds, setSeconds] = useState(() => secondsUntil(expiresAt)); + useEffect(() => { + setSeconds(secondsUntil(expiresAt)); + const timer = setInterval(() => setSeconds(secondsUntil(expiresAt)), 1000); + return () => clearInterval(timer); + }, [expiresAt]); + return seconds; +} + +function secondsUntil(at: number | undefined): number { + return at === undefined ? 0 : Math.max(0, Math.ceil((at - Date.now()) / 1000)); +} + +/** "4:32". */ +export function clock(seconds: number): string { + return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`; +} diff --git a/app/src/i18n/en/account.ts b/app/src/i18n/en/account.ts index 5d8e67f3..26cb9996 100644 --- a/app/src/i18n/en/account.ts +++ b/app/src/i18n/en/account.ts @@ -175,6 +175,35 @@ export const account = { "The passphrase is gone from this computer, so automatic copies stopped. Turn them on again.", } as Record, }, + mobile: { + title: "Phone", + intro: + "Approve your bots' requests and answer their questions from anywhere. Chats and files stay only on this computer.", + needAccount: "Sign in to the account above to use the phone.", + warning: + "Whoever holds a connected phone, unlocked, can allow requests until you disconnect it.", + connect: "Connect a phone", + starting: "Preparing…", + qrLabel: "Code to scan with your phone", + scan: "Open your phone's camera and point it at this code.", + expires: (time: string) => `The code works for ${time} more.`, + expired: "The code ran out. Ask for a new one.", + cancel: "Cancel", + wants: (name: string) => `${name} wants to connect.`, + compare: (code: string) => `Check that the phone shows ${code} too, and only then connect it.`, + accept: "Connect", + refuse: "Not this one", + connecting: "Connecting…", + phones: "Connected phones", + none: "No phone connected yet.", + online: "Online now", + seen: (when: string) => `Seen ${when}`, + neverSeen: "Not seen yet", + disconnect: "Disconnect", + disconnectTitle: (name: string) => `Disconnect ${name}?`, + disconnectText: "The phone loses access right away. To use it again, connect it anew.", + failed: "Something went wrong", + }, settings: { title: "Settings", background: "In the background", diff --git a/app/src/i18n/en/index.ts b/app/src/i18n/en/index.ts index fc900f23..570deaa9 100644 --- a/app/src/i18n/en/index.ts +++ b/app/src/i18n/en/index.ts @@ -13,6 +13,7 @@ import { desktop } from "./desktop"; import { files } from "./files"; import { messages } from "./messages"; import { onboarding } from "./onboarding"; +import { phone } from "./phone"; import { questions } from "./questions"; import { routines } from "./routines"; import { screens } from "./screens"; @@ -39,6 +40,7 @@ export const en = { desktop, files, messages, + phone, questions, routines, screens, diff --git a/app/src/i18n/en/phone.ts b/app/src/i18n/en/phone.ts new file mode 100644 index 00000000..eb98d41b --- /dev/null +++ b/app/src/i18n/en/phone.ts @@ -0,0 +1,86 @@ +// The phone's page (spec 28.7): connecting it, and approving and answering +// from it. What the bots ask is data and never passes through here. + +export const phone = { + pair: { + title: "Connect this phone", + intro: + "This phone will approve your bots' requests and answer their questions, from anywhere. Chats and files stay on your computer.", + nameLabel: "Name of this phone", + connect: "Connect", + connecting: "Connecting…", + compare: (code: string) => + `Check that your computer shows ${code} too. Then press Connect on the computer.`, + waiting: "Waiting for you to accept on the computer…", + notALink: + "To connect a phone, point its camera at the code on your computer: Settings, Backup, Phone.", + expired: "The code ran out or was already used. Ask for a new one on the computer.", + offline: "Could not reach Botloft. Check the connection and try again.", + failed: "Could not connect. Ask for a new code on the computer.", + names: { iphone: "iPhone", ipad: "iPad", android: "Android phone", other: "Phone" }, + }, + inbox: { + title: "Waiting for you", + empty: "Nothing is waiting for you", + emptyBody: "When a bot needs you, it shows up here.", + loading: "Checking what is waiting…", + computerOff: "Your computer is off or has no internet. Requests keep waiting there.", + noConnection: "No connection. Trying again…", + thisPhone: "This phone", + inCrew: (crew: string) => `in ${crew}`, + }, + approval: { + asks: (bot: string) => `${bot} asks to`, + explanationBy: (bot: string) => `${bot} wrote this`, + noExplanation: (bot: string) => + `${bot} did not say what this is for. Consider denying it and asking.`, + showAll: "Show the whole request", + hideAll: "Hide it", + allow: "Allow", + deny: "Deny", + sending: "Sending…", + noteLabel: "Note for the bot (optional)", + cut: "Too long to read on a phone. You can deny it here, or answer it at the computer.", + atComputer: "This one needs you at the computer. Here you can only deny it.", + failed: "Not sent. Check the connection and try again.", + ended: { allowed: "Allowed", denied: "Denied", expired: "Ran out" }, + }, + question: { + asks: (bot: string) => `${bot} asks`, + pick: "Pick an answer", + answerLabel: "Your answer", + placeholder: "Write your answer", + send: "Answer", + sending: "Sending…", + dismiss: "Dismiss", + dismissHint: (bot: string) => + `Closes the question without telling ${bot}. To let it know, answer instead.`, + failed: "Not sent. Check the connection and try again.", + ended: { answered: "You answered", dismissed: "Dismissed" }, + }, + settings: { + notices: { + turnOn: "Turn on notices", + turnOff: "Turn off notices", + on: "Notices are on. You get one when a bot needs you.", + off: "Notices are off.", + blocked: "Notices are blocked for this page. Allow them in the browser's settings.", + unsupported: "This browser cannot give notices.", + }, + title: "This phone", + back: "Back", + name: "Name", + disconnect: "Disconnect this phone", + disconnectTitle: "Disconnect this phone?", + disconnectText: + "It stops receiving requests. To use it again, connect it anew on your computer.", + cancel: "Cancel", + install: "To get notices, add Botloft to your Home Screen: tap Share, then Add to Home Screen.", + }, + cut: { + revokedTitle: "This phone was disconnected", + leftTitle: "Phone disconnected", + body: "To use it again, connect it from Botloft on your computer: Settings, Backup, Phone.", + ok: "OK", + }, +}; diff --git a/app/src/i18n/es/account.ts b/app/src/i18n/es/account.ts index af957206..7c376c25 100644 --- a/app/src/i18n/es/account.ts +++ b/app/src/i18n/es/account.ts @@ -177,6 +177,37 @@ export const account: Messages["account"] = { "La contraseña ya no está en este equipo, así que las copias automáticas se detuvieron. Actívalas de nuevo.", } as Record, }, + mobile: { + title: "Móvil", + intro: + "Aprueba las solicitudes de tus bots y responde a sus preguntas desde cualquier lugar. Las conversaciones y los archivos se quedan solo en este ordenador.", + needAccount: "Inicia sesión en la cuenta de arriba para usar el móvil.", + warning: + "Quien tenga un móvil conectado y desbloqueado puede permitir solicitudes hasta que lo desconectes.", + connect: "Conectar un móvil", + starting: "Preparando…", + qrLabel: "Código para escanear con el móvil", + scan: "Abre la cámara del móvil y apúntala a este código.", + expires: (time: string) => `El código vale ${time} más.`, + expired: "El código venció. Pide otro.", + cancel: "Cancelar", + wants: (name: string) => `${name} quiere conectarse.`, + compare: (code: string) => + `Comprueba que el móvil muestre ${code} también, y solo entonces conéctalo.`, + accept: "Conectar", + refuse: "No es este", + connecting: "Conectando…", + phones: "Móviles conectados", + none: "Todavía no hay ningún móvil conectado.", + online: "En línea ahora", + seen: (when: string) => `Visto ${when}`, + neverSeen: "Aún no visto", + disconnect: "Desconectar", + disconnectTitle: (name: string) => `¿Desconectar ${name}?`, + disconnectText: + "El móvil pierde el acceso al instante. Para usarlo de nuevo, conéctalo otra vez.", + failed: "Algo salió mal", + }, settings: { title: "Configuración", background: "En segundo plano", diff --git a/app/src/i18n/es/index.ts b/app/src/i18n/es/index.ts index eb5c57b1..f566a000 100644 --- a/app/src/i18n/es/index.ts +++ b/app/src/i18n/es/index.ts @@ -14,6 +14,7 @@ import { desktop } from "./desktop"; import { files } from "./files"; import { messages } from "./messages"; import { onboarding } from "./onboarding"; +import { phone } from "./phone"; import { questions } from "./questions"; import { routines } from "./routines"; import { screens } from "./screens"; @@ -40,6 +41,7 @@ export const es: Messages = { desktop, files, messages, + phone, questions, routines, screens, diff --git a/app/src/i18n/es/phone.ts b/app/src/i18n/es/phone.ts new file mode 100644 index 00000000..05acadc5 --- /dev/null +++ b/app/src/i18n/es/phone.ts @@ -0,0 +1,88 @@ +// La página del móvil (spec 28.7). + +import type { Messages } from "../en"; + +export const phone: Messages["phone"] = { + pair: { + title: "Conectar este móvil", + intro: + "Este móvil aprobará las solicitudes de tus bots y responderá a sus preguntas, desde cualquier lugar. Las conversaciones y los archivos se quedan en tu ordenador.", + nameLabel: "Nombre de este móvil", + connect: "Conectar", + connecting: "Conectando…", + compare: (code: string) => + `Comprueba que el ordenador muestre ${code} también. Luego pulsa Conectar en el ordenador.`, + waiting: "Esperando a que aceptes en el ordenador…", + notALink: + "Para conectar un móvil, apunta su cámara al código que aparece en el ordenador: Ajustes, Copia de seguridad, Móvil.", + expired: "El código venció o ya se usó. Pide uno nuevo en el ordenador.", + offline: "No se pudo hablar con Botloft. Comprueba la conexión e inténtalo de nuevo.", + failed: "No se pudo conectar. Pide un código nuevo en el ordenador.", + names: { iphone: "iPhone", ipad: "iPad", android: "Móvil Android", other: "Móvil" }, + }, + inbox: { + title: "Esperándote", + empty: "Nada te está esperando", + emptyBody: "Cuando un bot te necesite, aparecerá aquí.", + loading: "Viendo qué está esperando…", + computerOff: "El ordenador está apagado o sin internet. Las solicitudes siguen esperando allí.", + noConnection: "Sin conexión. Intentando de nuevo…", + thisPhone: "Este móvil", + inCrew: (crew: string) => `en ${crew}`, + }, + approval: { + asks: (bot: string) => `${bot} pide`, + explanationBy: (bot: string) => `${bot} escribió esto`, + noExplanation: (bot: string) => `${bot} no dijo para qué sirve. Conviene denegar y preguntar.`, + showAll: "Mostrar la solicitud entera", + hideAll: "Ocultar", + allow: "Permitir", + deny: "Denegar", + sending: "Enviando…", + noteLabel: "Nota para el bot (opcional)", + cut: "Demasiado larga para leerla en el móvil. Aquí solo puedes denegarla, o responderla en el ordenador.", + atComputer: "Esta solicitud te necesita en el ordenador. Aquí solo puedes denegarla.", + failed: "No se envió. Comprueba la conexión e inténtalo de nuevo.", + ended: { allowed: "Permitido", denied: "Denegado", expired: "Venció" }, + }, + question: { + asks: (bot: string) => `${bot} pregunta`, + pick: "Elige una respuesta", + answerLabel: "Tu respuesta", + placeholder: "Escribe tu respuesta", + send: "Responder", + sending: "Enviando…", + dismiss: "Descartar", + dismissHint: (bot: string) => + `Cierra la pregunta sin avisar a ${bot}. Para que lo sepa, responde.`, + failed: "No se envió. Comprueba la conexión e inténtalo de nuevo.", + ended: { answered: "Respondiste", dismissed: "Descartada" }, + }, + settings: { + notices: { + turnOn: "Activar los avisos", + turnOff: "Desactivar los avisos", + on: "Los avisos están activados. Recibes uno cuando un bot te necesite.", + off: "Los avisos están desactivados.", + blocked: + "Los avisos están bloqueados para esta página. Permítelos en los ajustes del navegador.", + unsupported: "Este navegador no puede dar avisos.", + }, + title: "Este móvil", + back: "Volver", + name: "Nombre", + disconnect: "Desconectar este móvil", + disconnectTitle: "¿Desconectar este móvil?", + disconnectText: + "Deja de recibir solicitudes. Para usarlo de nuevo, conéctalo otra vez en el ordenador.", + cancel: "Cancelar", + install: + "Para recibir avisos, añade Botloft a la pantalla de inicio: toca Compartir y luego Añadir a pantalla de inicio.", + }, + cut: { + revokedTitle: "Este móvil fue desconectado", + leftTitle: "Móvil desconectado", + body: "Para usarlo de nuevo, conéctalo desde Botloft en el ordenador: Ajustes, Copia de seguridad, Móvil.", + ok: "OK", + }, +}; diff --git a/app/src/i18n/pt-BR/account.ts b/app/src/i18n/pt-BR/account.ts index 66c01026..501b8663 100644 --- a/app/src/i18n/pt-BR/account.ts +++ b/app/src/i18n/pt-BR/account.ts @@ -177,6 +177,35 @@ export const account: Messages["account"] = { "A senha sumiu deste computador, então as cópias automáticas pararam. Ligue de novo.", } as Record, }, + mobile: { + title: "Celular", + intro: + "Aprove os pedidos dos seus bots e responda às perguntas deles de qualquer lugar. As conversas e os arquivos ficam só neste computador.", + needAccount: "Entre na conta acima para usar o celular.", + warning: + "Quem estiver com um celular conectado e desbloqueado pode permitir pedidos até você desconectá-lo.", + connect: "Conectar um celular", + starting: "Preparando…", + qrLabel: "Código para ler com o celular", + scan: "Abra a câmera do celular e aponte para este código.", + expires: (time: string) => `O código vale por mais ${time}.`, + expired: "O código venceu. Peça outro.", + cancel: "Cancelar", + wants: (name: string) => `${name} quer se conectar.`, + compare: (code: string) => `Confira se o celular mostra ${code} também, e só então conecte.`, + accept: "Conectar", + refuse: "Não é este", + connecting: "Conectando…", + phones: "Celulares conectados", + none: "Nenhum celular conectado ainda.", + online: "Online agora", + seen: (when: string) => `Visto ${when}`, + neverSeen: "Ainda não visto", + disconnect: "Desconectar", + disconnectTitle: (name: string) => `Desconectar ${name}?`, + disconnectText: "O celular perde o acesso na hora. Para usar de novo, conecte outra vez.", + failed: "Algo deu errado", + }, settings: { title: "Configurações", background: "Em segundo plano", diff --git a/app/src/i18n/pt-BR/index.ts b/app/src/i18n/pt-BR/index.ts index 3dd9eb77..e3b376db 100644 --- a/app/src/i18n/pt-BR/index.ts +++ b/app/src/i18n/pt-BR/index.ts @@ -14,6 +14,7 @@ import { desktop } from "./desktop"; import { files } from "./files"; import { messages } from "./messages"; import { onboarding } from "./onboarding"; +import { phone } from "./phone"; import { questions } from "./questions"; import { routines } from "./routines"; import { screens } from "./screens"; @@ -40,6 +41,7 @@ export const ptBR: Messages = { desktop, files, messages, + phone, questions, routines, screens, diff --git a/app/src/i18n/pt-BR/phone.ts b/app/src/i18n/pt-BR/phone.ts new file mode 100644 index 00000000..97bba213 --- /dev/null +++ b/app/src/i18n/pt-BR/phone.ts @@ -0,0 +1,87 @@ +// A página do celular (spec 28.7). + +import type { Messages } from "../en"; + +export const phone: Messages["phone"] = { + pair: { + title: "Conectar este celular", + intro: + "Este celular vai aprovar os pedidos dos seus bots e responder às perguntas deles, de qualquer lugar. As conversas e os arquivos ficam no seu computador.", + nameLabel: "Nome deste celular", + connect: "Conectar", + connecting: "Conectando…", + compare: (code: string) => + `Confira se o computador mostra ${code} também. Depois aperte Conectar no computador.`, + waiting: "Esperando você aceitar no computador…", + notALink: + "Para conectar um celular, aponte a câmera dele para o código que aparece no computador: Configurações, Cópia de segurança, Celular.", + expired: "O código venceu ou já foi usado. Peça um novo no computador.", + offline: "Não foi possível falar com o Botloft. Confira a conexão e tente de novo.", + failed: "Não foi possível conectar. Peça um novo código no computador.", + names: { iphone: "iPhone", ipad: "iPad", android: "Celular Android", other: "Celular" }, + }, + inbox: { + title: "Esperando você", + empty: "Nada esperando você", + emptyBody: "Quando um bot precisar de você, aparece aqui.", + loading: "Vendo o que está esperando…", + computerOff: "O computador está desligado ou sem internet. Os pedidos continuam esperando lá.", + noConnection: "Sem conexão. Tentando de novo…", + thisPhone: "Este celular", + inCrew: (crew: string) => `em ${crew}`, + }, + approval: { + asks: (bot: string) => `${bot} pede para`, + explanationBy: (bot: string) => `${bot} escreveu isto`, + noExplanation: (bot: string) => `${bot} não disse para que serve. Vale negar e perguntar.`, + showAll: "Mostrar o pedido inteiro", + hideAll: "Esconder", + allow: "Permitir", + deny: "Negar", + sending: "Enviando…", + noteLabel: "Recado para o bot (opcional)", + cut: "Longo demais para ler no celular. Aqui você só pode negar, ou responder no computador.", + atComputer: "Este pedido precisa de você no computador. Aqui você só pode negar.", + failed: "Não foi enviado. Confira a conexão e tente de novo.", + ended: { allowed: "Permitido", denied: "Negado", expired: "Venceu" }, + }, + question: { + asks: (bot: string) => `${bot} pergunta`, + pick: "Escolha uma resposta", + answerLabel: "Sua resposta", + placeholder: "Escreva sua resposta", + send: "Responder", + sending: "Enviando…", + dismiss: "Descartar", + dismissHint: (bot: string) => `Fecha a pergunta sem avisar ${bot}. Para ele saber, responda.`, + failed: "Não foi enviado. Confira a conexão e tente de novo.", + ended: { answered: "Você respondeu", dismissed: "Descartada" }, + }, + settings: { + notices: { + turnOn: "Ligar os avisos", + turnOff: "Desligar os avisos", + on: "Os avisos estão ligados. Você recebe um quando um bot precisar de você.", + off: "Os avisos estão desligados.", + blocked: + "Os avisos estão bloqueados para esta página. Libere nas configurações do navegador.", + unsupported: "Este navegador não consegue dar avisos.", + }, + title: "Este celular", + back: "Voltar", + name: "Nome", + disconnect: "Desconectar este celular", + disconnectTitle: "Desconectar este celular?", + disconnectText: + "Ele deixa de receber pedidos. Para usar de novo, conecte outra vez no computador.", + cancel: "Cancelar", + install: + "Para receber avisos, ponha o Botloft na tela inicial: toque em Compartilhar e depois em Adicionar à Tela de Início.", + }, + cut: { + revokedTitle: "Este celular foi desconectado", + leftTitle: "Celular desconectado", + body: "Para usar de novo, conecte pelo Botloft no computador: Configurações, Cópia de segurança, Celular.", + ok: "OK", + }, +}; diff --git a/app/src/lib/fake.ts b/app/src/lib/fake.ts index 5e41da56..8d200dba 100644 --- a/app/src/lib/fake.ts +++ b/app/src/lib/fake.ts @@ -18,6 +18,7 @@ import { FakeDesktop } from "./fakeDesktop"; import { FakeFiles } from "./fakeFiles"; import { FakeLesson } from "./fakeLesson"; import { FakeMcp } from "./fakeMcp"; +import { mobileHandlers } from "./fakeMobile"; import { FakeQuestions } from "./fakeQuestions"; import { FakeReactions } from "./fakeReactions"; import { FakeRoutines } from "./fakeRoutines"; @@ -293,6 +294,7 @@ export class FakeBotloft implements BotloftApi { ...backupHandlers(this), ...cloudHandlers(this), ...autoBackupHandlers(this), + ...mobileHandlers(this), ...searchHandlers(this), }; } diff --git a/app/src/lib/fakeCloud.ts b/app/src/lib/fakeCloud.ts index 0db3d9b6..fa4d5771 100644 --- a/app/src/lib/fakeCloud.ts +++ b/app/src/lib/fakeCloud.ts @@ -4,6 +4,7 @@ import type { FakeBotloft, Handlers } from "./fake"; import { turnOffFakeAutoBackup } from "./fakeAutoBackup"; +import { forgetFakePhones } from "./fakeMobile"; import { type CloudCopy, type CloudStatus, RpcErrorCode } from "./protocol.gen"; import { RpcError } from "./rpc"; @@ -41,6 +42,11 @@ function signedIn(state: CloudState): void { if (!state.signedIn) throw refused("not_signed_in", "sign in to the account first"); } +/** Whether the fake is signed in to the account. */ +export function fakeSignedIn(fake: FakeBotloft): boolean { + return stateOf(fake).signedIn; +} + /** The owner opens the link in the e-mail: the waiting sign-in finishes. */ export function openFakeCloudLink(fake: FakeBotloft): void { const state = stateOf(fake); @@ -87,6 +93,7 @@ export function cloudHandlers(fake: FakeBotloft): Pick { }, "cloud.signout": () => { turnOffFakeAutoBackup(fake); + forgetFakePhones(fake); state.signedIn = false; state.email = null; state.pending = null; diff --git a/app/src/lib/fakeMobile.test.ts b/app/src/lib/fakeMobile.test.ts new file mode 100644 index 00000000..ff124c26 --- /dev/null +++ b/app/src/lib/fakeMobile.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { FakeBotloft } from "./fake"; +import { openFakeCloudLink } from "./fakeCloud"; +import { joinFakePhone } from "./fakeMobile"; + +describe("the fake's phones", () => { + it("needs the account, shows the code a phone brings, connects it and cuts it off", async () => { + const fake = new FakeBotloft(); + const seen: string[] = []; + fake.subscribe((event) => seen.push(event.name)); + + await expect(fake.call("mobile.pair_start")).rejects.toMatchObject({ reason: "not_signed_in" }); + await fake.call("cloud.signin", { email: "ana@exemplo.com" }); + openFakeCloudLink(fake); + + const started = await fake.call("mobile.pair_start"); + expect(started.url).toContain("#p="); + expect((await fake.call("mobile.status")).pending?.pairId).toBe(started.pairId); + await expect( + fake.call("mobile.pair_confirm", { pairId: started.pairId, accept: true }), + ).rejects.toMatchObject({ reason: "conflict" }); + + joinFakePhone(fake, "Celular da Ana", "482913"); + expect(seen).toContain("mobile.pair_request"); + expect((await fake.call("mobile.status")).pending?.joined).toEqual({ + name: "Celular da Ana", + code: "482913", + }); + await fake.call("mobile.pair_confirm", { pairId: started.pairId, accept: true }); + const status = await fake.call("mobile.status"); + expect(status.pending).toBeUndefined(); + expect(status.phones).toHaveLength(1); + expect(status.phones[0]?.name).toBe("Celular da Ana"); + + const gone = await fake.call("mobile.revoke", { phoneId: status.phones[0]?.id ?? "" }); + expect(gone.phones).toEqual([]); + await expect(fake.call("mobile.revoke", { phoneId: "dev_none" })).rejects.toMatchObject({ + reason: "not_found", + }); + }); + + it("is cleared when the account is left", async () => { + const fake = new FakeBotloft(); + await fake.call("cloud.signin", { email: "ana@exemplo.com" }); + openFakeCloudLink(fake); + const started = await fake.call("mobile.pair_start"); + joinFakePhone(fake); + await fake.call("mobile.pair_confirm", { pairId: started.pairId, accept: true }); + await fake.call("cloud.signout"); + expect((await fake.call("mobile.status")).phones).toEqual([]); + }); +}); diff --git a/app/src/lib/fakeMobile.ts b/app/src/lib/fakeMobile.ts new file mode 100644 index 00000000..0e8987bd --- /dev/null +++ b/app/src/lib/fakeMobile.ts @@ -0,0 +1,121 @@ +// The fake daemon's phones (spec 28), in memory. A code on screen waits until +// `joinFakePhone` plays a phone scanning it; nothing leaves the page. + +import type { FakeBotloft, Handlers } from "./fake"; +import { fakeSignedIn } from "./fakeCloud"; +import { + type MobilePairing, + type MobilePhone, + type MobileStatus, + RpcErrorCode, +} from "./protocol.gen"; +import { RpcError } from "./rpc"; + +type MobileMethods = Extract; + +const EXPIRES_IN = 300; + +interface MobileState { + phones: MobilePhone[]; + pending: MobilePairing | null; + next: number; +} + +const states = new WeakMap(); + +function stateOf(fake: FakeBotloft): MobileState { + let state = states.get(fake); + if (!state) { + state = { phones: [], pending: null, next: 1 }; + states.set(fake, state); + } + return state; +} + +function refused(code: number, reason: string, message: string): RpcError { + return new RpcError(code, message, reason); +} + +function statusOf(fake: FakeBotloft): MobileStatus { + const state = stateOf(fake); + const status: MobileStatus = { + relay: state.phones.length > 0 || state.pending ? "connected" : "off", + phones: state.phones.map((phone) => ({ ...phone })), + }; + if (state.pending) status.pending = { ...state.pending }; + return status; +} + +function told(fake: FakeBotloft): MobileStatus { + const status = statusOf(fake); + fake.emit({ name: "mobile.changed", params: status }); + return status; +} + +/** A phone scans the code on screen and shows `code` to be compared. */ +export function joinFakePhone(fake: FakeBotloft, name = "Celular da Ana", code = "482913"): void { + const { pending } = stateOf(fake); + if (!pending) return; + pending.joined = { name, code }; + fake.emit({ name: "mobile.pair_request", params: { pairId: pending.pairId, name, code } }); + told(fake); +} + +/** Signing out of the account takes every phone with it. */ +export function forgetFakePhones(fake: FakeBotloft): void { + const state = stateOf(fake); + state.phones = []; + state.pending = null; +} + +export function mobileHandlers(fake: FakeBotloft): Pick { + const state = stateOf(fake); + return { + "mobile.status": () => statusOf(fake), + "mobile.pair_start": () => { + if (!fakeSignedIn(fake)) { + throw refused(RpcErrorCode.validation, "not_signed_in", "sign in to the account first"); + } + const pairId = `pair${state.next++}`; + state.pending = { pairId, expiresAt: fake.now + EXPIRES_IN * 1000 }; + told(fake); + return { + pairId, + url: `https://cloud.botloft.example/m#p=${pairId}&s=secret&d=key`, + expiresIn: EXPIRES_IN, + }; + }, + "mobile.pair_cancel": ({ pairId }) => { + if (state.pending?.pairId === pairId) { + state.pending = null; + told(fake); + } + return null; + }, + "mobile.pair_confirm": ({ pairId, accept }) => { + const pending = state.pending; + if (!pending || pending.pairId !== pairId || !pending.joined) { + throw refused(RpcErrorCode.conflict, "conflict", "there is no phone waiting on that code"); + } + state.pending = null; + if (accept) { + state.phones.push({ + id: `dev_phone${state.next++}`, + name: pending.joined.name, + pairedAt: fake.now, + online: true, + }); + } + told(fake); + return null; + }, + "mobile.revoke": ({ phoneId }) => { + const before = state.phones.length; + state.phones = state.phones.filter((phone) => phone.id !== phoneId); + if (state.phones.length === before) { + throw refused(RpcErrorCode.notFound, "not_found", `phone ${phoneId}`); + } + return told(fake); + }, + }; +} diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts index b1742d49..218e2f5a 100644 --- a/app/src/lib/protocol.gen.ts +++ b/app/src/lib/protocol.gen.ts @@ -1341,6 +1341,128 @@ export type AutoBackupEnableParams = { passphrase: string, every: AutoBackupEver */ export type AutoBackupEveryParams = { every: AutoBackupEvery, }; +/** + * Whether the computer's connection to the relay is up. + */ +export type MobileRelay = "off" | "connecting" | "connected"; + +/** + * A phone the owner connected. + */ +export type MobilePhone = { id: string, name: string, +/** + * Unix time in milliseconds. + */ +pairedAt: number, +/** + * When the phone last sent anything, in Unix milliseconds. + */ +lastSeenAt?: number, online: boolean, }; + +/** + * A phone that opened the QR code and waits for the owner to compare the + * code and accept it. + */ +export type MobileJoined = { name: string, +/** + * Six digits, the same on the phone's screen. + */ +code: string, }; + +/** + * A QR code on screen. + */ +export type MobilePairing = { pairId: string, +/** + * Unix time in milliseconds the code stops working. + */ +expiresAt: number, joined?: MobileJoined, }; + +/** + * `mobile.status`, and the `mobile.changed` notification. + */ +export type MobileStatus = { relay: MobileRelay, phones: Array, pending?: MobilePairing, }; + +/** + * `mobile.pair_start`: what the QR code carries. + */ +export type MobilePairStarted = { pairId: string, +/** + * The address the QR code holds, `#` and all. + */ +url: string, +/** + * Seconds the code works. + */ +expiresIn: number, }; + +/** + * `mobile.pair_cancel`. + */ +export type MobilePairIdParams = { pairId: string, }; + +/** + * `mobile.pair_confirm`: the owner compared the codes. + */ +export type MobilePairConfirmParams = { pairId: string, accept: boolean, }; + +/** + * `mobile.revoke`. + */ +export type MobileRevokeParams = { phoneId: string, }; + +/** + * The `mobile.pair_request` notification: a phone opened the code. + */ +export type MobilePairRequest = { pairId: string, name: string, code: string, }; + +/** + * The bot that asks, as the phone shows it. + */ +export type CardBot = { name: string, color: string, }; + +/** + * A request waiting for the owner, as the phone shows it (spec 28.5). It + * carries what the owner needs to decide, and nothing else of the bot. + */ +export type ApprovalCard = { approvalId: ApprovalId, bot: CardBot, crew: string, +/** + * Unix time in milliseconds. + */ +createdAt: number, toolName: string, summary: string, +/** + * What the bot says the command is for; the bot wrote it. + */ +explanation?: string, +/** + * The command, the plan or the request, whole unless `cut`. + */ +text: string, +/** + * `text` is not the whole request: the phone may only deny it. + */ +cut: boolean, +/** + * The request is of a kind that is answered at the computer: the phone + * may only deny it. + */ +atComputer: boolean, }; + +/** + * A question waiting for the owner, as the phone shows it. + */ +export type QuestionCard = { questionId: QuestionId, bot: CardBot, crew: string, createdAt: number, text: string, options: Array, }; + +/** + * What the computer sends the phone, sealed (spec 28.3). + */ +export type ToPhone = { "t": "snapshot", approvals: Array, questions: Array, } | { "t": "approval.open", card: ApprovalCard, } | { "t": "approval.closed", approvalId: ApprovalId, status: ApprovalStatus, } | { "t": "question.open", card: QuestionCard, } | { "t": "question.closed", questionId: QuestionId, status: QuestionStatus, }; + +/** + * What the phone sends the computer, sealed (spec 28.3). + */ +export type FromPhone = { "t": "sync" } | { "t": "approval.answer", approvalId: ApprovalId, allow: boolean, note?: string, } | { "t": "question.answer", questionId: QuestionId, answer: string, } | { "t": "question.dismiss", questionId: QuestionId, }; + export type ApprovalItem = { approvalId: ApprovalId, toolName: string, summary: string, /** * What the bot says the command is for, as in `ToolItem`. The bot @@ -2064,6 +2186,11 @@ export interface RpcMethods { "autobackup.enable": { params: AutoBackupEnableParams; result: AutoBackupStatus }; "autobackup.set_every": { params: AutoBackupEveryParams; result: AutoBackupStatus }; "autobackup.disable": { params: undefined; result: AutoBackupStatus }; + "mobile.status": { params: undefined; result: MobileStatus }; + "mobile.pair_start": { params: undefined; result: MobilePairStarted }; + "mobile.pair_cancel": { params: MobilePairIdParams; result: null }; + "mobile.pair_confirm": { params: MobilePairConfirmParams; result: null }; + "mobile.revoke": { params: MobileRevokeParams; result: MobileStatus }; "routines.list": { params: RoutinesListParams; result: Array }; "routines.create": { params: RoutinesCreateParams; result: Routine }; "routines.update": { params: RoutinesUpdateParams; result: Routine }; @@ -2123,6 +2250,8 @@ export interface RpcNotifications { "cloud.progress": CloudProgress; "cloud.signin_expired": null; "autobackup.changed": AutoBackupStatus; + "mobile.changed": MobileStatus; + "mobile.pair_request": MobilePairRequest; "browser.changed": BrowserState; "browser.action": BrowserAction; "browser.frame": BrowserFrame; diff --git a/app/src/phone/ApprovalView.tsx b/app/src/phone/ApprovalView.tsx new file mode 100644 index 00000000..10b4aae2 --- /dev/null +++ b/app/src/phone/ApprovalView.tsx @@ -0,0 +1,110 @@ +// A request waiting for the owner, as the phone shows it (spec 28.5, 28.7): +// what the bot wants, what it says it is for, the whole request a tap away, +// and Allow or Deny. Allow is off for what is cut or belongs to the computer. + +import { useState } from "react"; +import { useT } from "../i18n"; +import { when } from "../lib/format"; +import type { ApprovalCard } from "../lib/protocol.gen"; +import { Callout } from "../ui/Callout"; +import { BotDot, PhoneButton } from "./parts"; + +export function ApprovalView({ + card, + sending, + answer, +}: { + card: ApprovalCard; + sending: boolean; + /** False if the answer could not be sent. */ + answer(allow: boolean, note?: string): Promise; +}) { + const t = useT().phone; + const a = t.approval; + const [all, setAll] = useState(false); + const [note, setNote] = useState(""); + const [failed, setFailed] = useState(false); + const [pressed, setPressed] = useState(null); + + const send = async (allow: boolean) => { + setFailed(false); + setPressed(allow); + const sent = await answer(allow, note); + setFailed(!sent); + }; + const canAllow = !card.cut && !card.atComputer; + + return ( +
+
+ +
+

{card.bot.name}

+

+ {t.inbox.inCrew(card.crew)} · {when(card.createdAt)} +

+
+
+ +
+

{a.asks(card.bot.name)}

+

{card.summary || card.toolName}

+
+ + {card.explanation ? ( +
+

{card.explanation}

+

{a.explanationBy(card.bot.name)}

+
+ ) : ( + canAllow && + )} + +
+ + {all && ( +
+            {card.text}
+          
+ )} +
+ + {card.cut && } + {!card.cut && card.atComputer && } + + + +
+ send(true)}> + {sending && pressed === true ? a.sending : a.allow} + + send(false)}> + {sending && pressed === false ? a.sending : a.deny} + +
+ {failed && ( +

+ {a.failed} +

+ )} +
+ ); +} diff --git a/app/src/phone/NoticesControl.tsx b/app/src/phone/NoticesControl.tsx new file mode 100644 index 00000000..96d69aa4 --- /dev/null +++ b/app/src/phone/NoticesControl.tsx @@ -0,0 +1,60 @@ +// Notices on this phone (spec 28.8): an invitation on the inbox while they +// are off, and the whole control on the page about this phone. + +import { useEffect, useState } from "react"; +import { useT } from "../i18n"; +import type { PhoneApi } from "./client"; +import type { NoticeState } from "./notices"; +import { PhoneButton } from "./parts"; + +export function Notices({ api, invite = false }: { api: PhoneApi; invite?: boolean }) { + const n = useT().phone.settings.notices; + const [state, setState] = useState(null); + const [busy, setBusy] = useState(false); + + useEffect(() => { + let live = true; + void api.noticeState().then((now) => live && setState(now)); + return () => { + live = false; + }; + }, [api]); + + const change = async (work: () => Promise) => { + setBusy(true); + setState(await work()); + setBusy(false); + }; + + // The Home Screen has its own words, and nothing else is for the inbox. + if (state === null || state === "home-screen" || (invite && state !== "off")) { + return null; + } + return ( +
+ {!invite && state === "on" &&

{n.on}

} + {!invite && state === "off" &&

{n.off}

} + {state === "blocked" &&

{n.blocked}

} + {state === "unsupported" &&

{n.unsupported}

} + {state === "off" && ( + change(api.turnOnNotices)} + className="flex-none" + > + {n.turnOn} + + )} + {!invite && state === "on" && ( + change(api.turnOffNotices)} + className="flex-none" + > + {n.turnOff} + + )} +
+ ); +} diff --git a/app/src/phone/PairScreen.tsx b/app/src/phone/PairScreen.tsx new file mode 100644 index 00000000..b21359e6 --- /dev/null +++ b/app/src/phone/PairScreen.tsx @@ -0,0 +1,98 @@ +// Connecting this phone (spec 28.3, 28.7): the owner scanned the code on the +// computer. Here the phone gets a name, joins, shows six digits to compare +// with the computer's, and waits for the owner to accept there. + +import { useState } from "react"; +import { useT } from "../i18n"; +import { Callout } from "../ui/Callout"; +import { type Pair, PairError, type PairFailure } from "./pair"; +import { PhoneButton } from "./parts"; +import type { Session } from "./store"; + +/** A name to start from, by the kind of phone this is. */ +export function guessName( + names: { iphone: string; ipad: string; android: string; other: string }, + agent = typeof navigator === "undefined" ? "" : navigator.userAgent, +): string { + if (/iPhone|iPod/.test(agent)) { + return names.iphone; + } + if (/iPad/.test(agent)) { + return names.ipad; + } + return /Android/.test(agent) ? names.android : names.other; +} + +const spaced = (code: string) => `${code.slice(0, 3)} ${code.slice(3)}`; + +export function PairScreen({ + pair, + onSession, +}: { + /** `null` when the page was not opened from a code. */ + pair: Pair | null; + onSession(session: Session): void; +}) { + const t = useT().phone; + const p = t.pair; + const [name, setName] = useState(() => guessName(p.names)); + const [step, setStep] = useState<"form" | "joining" | "waiting">("form"); + const [code, setCode] = useState(""); + const [failure, setFailure] = useState(null); + + const connect = async () => { + if (!pair) { + return; + } + setFailure(null); + setStep("joining"); + try { + const joined = await pair.join(name.trim() || guessName(p.names)); + setCode(joined.code); + setStep("waiting"); + onSession(await joined.collect()); + } catch (error) { + setFailure(error instanceof PairError ? error.reason : "failed"); + setStep("form"); + } + }; + + return ( +
+

{p.title}

+ {!pair ? ( +

{p.notALink}

+ ) : step === "waiting" ? ( + <> +

{spaced(code)}

+

{p.compare(spaced(code))}

+

+ {p.waiting} +

+ + ) : ( + <> +

{p.intro}

+ + + {step === "joining" ? p.connecting : p.connect} + + {failure && } + + )} +
+ ); +} diff --git a/app/src/phone/PhoneApp.tsx b/app/src/phone/PhoneApp.tsx new file mode 100644 index 00000000..9039b7b5 --- /dev/null +++ b/app/src/phone/PhoneApp.tsx @@ -0,0 +1,153 @@ +// The phone's page once it is connected (spec 28.7): what waits for the +// owner, oldest first, and a small screen about this phone. + +import { Settings, Smartphone } from "lucide-react"; +import { useState, useSyncExternalStore } from "react"; +import { useT } from "../i18n"; +import { Callout } from "../ui/Callout"; +import { Confirm } from "../ui/Confirm"; +import { ApprovalView } from "./ApprovalView"; +import type { PhoneApi } from "./client"; +import { Notices } from "./NoticesControl"; +import { PhoneButton } from "./parts"; +import { QuestionView } from "./QuestionView"; + +export function usePhone(api: PhoneApi) { + return useSyncExternalStore(api.subscribe, api.getState); +} + +/** Safari on an iPhone gives notices only to a page on the Home Screen. */ +function needsHomeScreen(): boolean { + if (typeof navigator === "undefined") { + return false; + } + const ios = /iPad|iPhone|iPod/.test(navigator.userAgent); + const standalone = (navigator as Navigator & { standalone?: boolean }).standalone === true; + return ios && !standalone && !window.matchMedia?.("(display-mode: standalone)").matches; +} + +export function PhoneApp({ api, onGone }: { api: PhoneApi; onGone?(): void }) { + const t = useT().phone; + const state = usePhone(api); + const [view, setView] = useState<"inbox" | "phone">("inbox"); + + if (state.session !== "ok") { + return ( + +
+

+ {state.session === "revoked" ? t.cut.revokedTitle : t.cut.leftTitle} +

+

{t.cut.body}

+ void api.disconnect().then(() => onGone?.())} + className="flex-none" + > + {t.cut.ok} + +
+
+ ); + } + if (view === "phone") { + return setView("inbox")} />; + } + + const waiting = state.approvals.length + state.questions.length; + return ( + +
+

{t.inbox.title}

+ +
+ + {state.computer === "offline" && } + {state.link !== "online" && } + {needsHomeScreen() && } + + +
+ {state.approvals.map((card) => ( + api.answerApproval(card.approvalId, allow, note)} + /> + ))} + {state.questions.map((card) => ( + api.answerQuestion(card.questionId, text)} + dismiss={() => api.dismissQuestion(card.questionId)} + /> + ))} + {waiting === 0 && ( +
+ + {state.loaded || state.computer === "offline" ? ( + <> +

{t.inbox.empty}

+

{t.inbox.emptyBody}

+ + ) : ( +

{t.inbox.loading}

+ )} +
+ )} +
+
+ ); +} + +function Shell({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function ThisPhone({ api, name, back }: { api: PhoneApi; name: string; back(): void }) { + const t = useT().phone; + const [leaving, setLeaving] = useState(false); + return ( + +
+ + {t.settings.back} + +

{t.settings.title}

+
+

+ {t.settings.name}: + {name} +

+ {needsHomeScreen() && } + + setLeaving(true)} className="flex-none"> + {t.settings.disconnect} + + {leaving && ( + api.disconnect()} + onClose={() => setLeaving(false)} + > + {t.settings.disconnectText} + + )} +
+ ); +} diff --git a/app/src/phone/QuestionView.tsx b/app/src/phone/QuestionView.tsx new file mode 100644 index 00000000..9bac2388 --- /dev/null +++ b/app/src/phone/QuestionView.tsx @@ -0,0 +1,92 @@ +// A question waiting for the owner (spec 23, 28.7): the text, the ready +// answers as buttons, a field for another one, and Dismiss. + +import { useState } from "react"; +import { useT } from "../i18n"; +import { when } from "../lib/format"; +import type { QuestionCard } from "../lib/protocol.gen"; +import { BotDot, PhoneButton, PhoneMarkdown } from "./parts"; + +export function QuestionView({ + card, + sending, + answer, + dismiss, +}: { + card: QuestionCard; + sending: boolean; + /** False if the answer could not be sent. */ + answer(text: string): Promise; + dismiss(): Promise; +}) { + const t = useT().phone; + const q = t.question; + const [text, setText] = useState(""); + const [failed, setFailed] = useState(false); + + const run = async (work: () => Promise) => { + setFailed(false); + setFailed(!(await work())); + }; + + return ( +
+
+ +
+

{q.asks(card.bot.name)}

+

+ {t.inbox.inCrew(card.crew)} · {when(card.createdAt)} +

+
+
+ + {card.text} + + {card.options.length > 0 && ( +
+ {q.pick} + {card.options.map((option) => ( + run(() => answer(option))} + className="flex-none" + > + {option} + + ))} +
+ )} + +