diff --git a/CLAUDE.md b/CLAUDE.md index ea89148..1b93f47 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,6 +31,8 @@ 앱에 따라 이 4칸 **뒤에** 최대 3칸이 더 붙는다(PRD F11). PDF 뷰어의 페이지 넘김, 음악 앱의 재생 제어처럼 `Tab`·`Enter`로는 아예 닿을 수 없는 동작이 있기 때문이다. +앱 식별자와 버튼 정의는 소스에 하드코딩하지 않고 Hana Cloud의 검증된 프로필을 +백그라운드에서 받아 last-known-good 캐시로 적용한다. 여기에 두 가지 선이 있다. - **앞 4칸의 순서와 자리는 어떤 앱에서도 바뀌지 않는다.** 사용자는 자리로 동작을 diff --git a/Cargo.lock b/Cargo.lock index 2a6b24f..6740ba3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -126,6 +126,29 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "aws-lc-rs" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + [[package]] name = "base64" version = "0.21.7" @@ -183,6 +206,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -323,6 +355,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -359,6 +393,23 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core", +] + [[package]] name = "chrono" version = "0.4.45" @@ -371,6 +422,15 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "cocoa" version = "0.26.1" @@ -410,6 +470,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "cookie" version = "0.18.2" @@ -496,7 +562,7 @@ dependencies = [ "alsa", "coreaudio-rs", "dasp_sample", - "jni", + "jni 0.21.1", "js-sys", "libc", "mach2 0.5.0", @@ -526,6 +592,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.1" @@ -560,6 +635,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "cssparser" version = "0.36.0" @@ -717,8 +801,19 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", ] [[package]] @@ -1011,6 +1106,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures-channel" version = "0.3.34" @@ -1018,6 +1119,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -1208,8 +1310,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1231,8 +1335,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", + "rand_core", + "wasm-bindgen", ] [[package]] @@ -1412,10 +1519,12 @@ dependencies = [ "enigo", "objc2", "objc2-app-kit", + "reqwest", "rodio", "serde", "serde_json", "serialport", + "sha2 0.11.0", "tauri", "tauri-build", "tauri-plugin-global-shortcut", @@ -1502,6 +1611,15 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.11.0" @@ -1522,6 +1640,21 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -1835,6 +1968,36 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.20", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + [[package]] name = "jni-sys" version = "0.3.1" @@ -1863,6 +2026,16 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + [[package]] name = "js-sys" version = "0.3.104" @@ -2012,6 +2185,12 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "mach2" version = "0.4.3" @@ -2539,6 +2718,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "option-ext" version = "0.2.0" @@ -2810,6 +2995,63 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.20", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.20", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.47" @@ -2831,6 +3073,32 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -2914,21 +3182,28 @@ checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ "base64 0.22.1", "bytes", + "futures-channel", "futures-core", "futures-util", "http", "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", "sync_wrapper", "tokio", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -2940,6 +3215,20 @@ dependencies = [ "web-sys", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rodio" version = "0.22.2" @@ -2980,6 +3269,81 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -2995,6 +3359,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "0.8.22" @@ -3052,6 +3425,29 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.1", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" version = "0.36.1" @@ -3266,8 +3662,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -3282,6 +3689,22 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "siphasher" version = "1.0.3" @@ -3394,6 +3817,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swift-rs" version = "1.0.8" @@ -3488,7 +3917,7 @@ dependencies = [ "gdkwayland-sys", "gdkx11-sys", "gtk", - "jni", + "jni 0.21.1", "libc", "log", "ndk", @@ -3544,7 +3973,7 @@ dependencies = [ "gtk", "heck 0.5.0", "http", - "jni", + "jni 0.21.1", "libc", "log", "mime", @@ -3616,7 +4045,7 @@ dependencies = [ "semver", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "syn 2.0.119", "tauri-utils", "thiserror 2.0.20", @@ -3681,7 +4110,7 @@ dependencies = [ "dpi", "gtk", "http", - "jni", + "jni 0.21.1", "objc2", "objc2-ui-kit", "objc2-web-kit", @@ -3704,7 +4133,7 @@ checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" dependencies = [ "gtk", "http", - "jni", + "jni 0.21.1", "log", "objc2", "objc2-app-kit", @@ -3889,6 +4318,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.19" @@ -4188,6 +4627,12 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -4375,6 +4820,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "web_atoms" version = "0.2.6" @@ -4431,6 +4886,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" @@ -4942,7 +5406,7 @@ dependencies = [ "gtk", "http", "javascriptcore-rs", - "jni", + "jni 0.21.1", "libc", "ndk", "objc2", @@ -4954,7 +5418,7 @@ dependencies = [ "once_cell", "percent-encoding", "raw-window-handle", - "sha2", + "sha2 0.10.9", "soup3", "tao-macros", "thiserror 2.0.20", @@ -5084,6 +5548,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zerotrie" version = "0.2.5" diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 5131418..5521351 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -23,6 +23,8 @@ enigo = "0.6.1" rodio = { version = "0.22.2", default-features = false, features = ["playback"] } chrono = { version = "0.4.45", default-features = false, features = ["clock", "std", "serde"] } serialport = "4.9.0" +reqwest = { version = "0.13.4", default-features = false, features = ["blocking", "rustls"] } +sha2 = "0.11.0" # 이후 단계에서 붙일 crate. 버전은 반드시 `cargo add`로 확정한다. # cargo add tauri-plugin-store # 사용자 프로필 영속화 (M3) diff --git a/apps/desktop/src-tauri/src/app_registry/cache.rs b/apps/desktop/src-tauri/src/app_registry/cache.rs new file mode 100644 index 0000000..9755932 --- /dev/null +++ b/apps/desktop/src-tauri/src/app_registry/cache.rs @@ -0,0 +1,427 @@ +use std::fs::{self, File, OpenOptions}; +use std::io::{ErrorKind, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use super::http::{Validators, read_limited, verify_sha256}; +use super::model::{ + AppEntry, AppProfile, INDEX_LIMIT, PROFILE_LIMIT, RegistryIndex, parse_index, parse_profile, +}; + +static NEXT_TEMP_FILE: AtomicU64 = AtomicU64::new(0); + +pub(crate) struct Cache { + root: PathBuf, +} + +pub(crate) struct CachedIndex { + pub(crate) index: RegistryIndex, + pub(crate) sha256: String, + pub(crate) validators: Validators, +} + +pub(crate) struct CachedProfile { + pub(crate) sha256: String, + pub(crate) profile: AppProfile, +} + +impl Cache { + pub(crate) fn new(root: PathBuf) -> Self { + Self { root } + } + + pub(crate) fn store_index( + &self, + raw: &[u8], + sha256: &str, + validators: &Validators, + ) -> Result { + verify_sha256(raw, sha256)?; + let index = parse_index(raw)?; + + if let Some(current) = self.load_latest_index()? { + if index.revision < current.index.revision { + return Err(format!( + "registry revision을 {}에서 {}로 되돌릴 수 없습니다.", + current.index.revision, index.revision + )); + } + if index.revision == current.index.revision && sha256 != current.sha256 { + return Err(format!( + "같은 registry revision {}에 서로 다른 내용이 있습니다.", + index.revision + )); + } + } + + let directory = self.indexes_dir(); + let index_path = directory.join(format!("{sha256}.json")); + write_immutable(&index_path, raw)?; + + let headers = serde_json::to_vec(validators) + .map_err(|error| format!("registry validator를 직렬화하지 못했습니다. ({error})"))?; + let headers_path = directory.join(format!("{sha256}.headers.json")); + if !headers_path.exists() { + write_immutable(&headers_path, &headers)?; + } + + Ok(index) + } + + pub(crate) fn load_latest_index(&self) -> Result, String> { + Ok(self.load_valid_indexes()?.into_iter().next()) + } + + fn load_valid_indexes(&self) -> Result, String> { + let entries = match fs::read_dir(self.indexes_dir()) { + Ok(entries) => entries, + Err(error) if error.kind() == ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => { + return Err(format!("registry cache 목록을 읽지 못했습니다. ({error})")); + } + }; + + let mut indexes = Vec::new(); + for entry in entries.flatten() { + let path = entry.path(); + let Some(sha256) = cached_hash(&path) else { + continue; + }; + let Ok(raw) = read_cache_file(&path, INDEX_LIMIT) else { + continue; + }; + if verify_sha256(&raw, sha256).is_err() { + continue; + } + let Ok(index) = parse_index(&raw) else { + continue; + }; + let validators = read_validators(&self.indexes_dir(), sha256); + indexes.push(CachedIndex { + index, + sha256: sha256.to_string(), + validators, + }); + } + + indexes.sort_by(|left, right| { + right + .index + .revision + .cmp(&left.index.revision) + .then_with(|| right.sha256.cmp(&left.sha256)) + }); + Ok(indexes) + } + + pub(crate) fn store_profile(&self, entry: &AppEntry, raw: &[u8]) -> Result { + verify_sha256(raw, &entry.sha256)?; + let profile = parse_profile(raw, &entry.id)?; + let path = self.profiles_dir().join(format!("{}.json", entry.sha256)); + write_immutable(&path, raw)?; + Ok(profile) + } + + pub(crate) fn load_profile(&self, entry: &AppEntry) -> Option { + let path = self.profiles_dir().join(format!("{}.json", entry.sha256)); + let raw = read_cache_file(&path, PROFILE_LIMIT).ok()?; + verify_sha256(&raw, &entry.sha256).ok()?; + parse_profile(&raw, &entry.id).ok() + } + + pub(crate) fn load_fallback_profile(&self, app_id: &str) -> Option { + for cached in self.load_valid_indexes().ok()? { + let Some(entry) = cached.index.apps.iter().find(|entry| entry.id == app_id) else { + continue; + }; + if let Some(profile) = self.load_profile(entry) { + return Some(CachedProfile { + sha256: entry.sha256.clone(), + profile, + }); + } + } + None + } + + fn indexes_dir(&self) -> PathBuf { + self.root.join("indexes") + } + + fn profiles_dir(&self) -> PathBuf { + self.root.join("profiles") + } +} + +fn cached_hash(path: &Path) -> Option<&str> { + let name = path.file_name()?.to_str()?; + let sha256 = name.strip_suffix(".json")?; + (sha256.len() == 64 + && sha256 + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))) + .then_some(sha256) +} + +fn read_cache_file(path: &Path, limit: usize) -> Result, String> { + let file = + File::open(path).map_err(|error| format!("registry cache를 열지 못했습니다. ({error})"))?; + read_limited(file, limit) +} + +fn read_validators(directory: &Path, sha256: &str) -> Validators { + let path = directory.join(format!("{sha256}.headers.json")); + read_cache_file(&path, 8 * 1024) + .ok() + .and_then(|raw| serde_json::from_slice(&raw).ok()) + .unwrap_or_default() +} + +fn write_immutable(path: &Path, raw: &[u8]) -> Result<(), String> { + let parent = path + .parent() + .ok_or_else(|| "registry cache 경로에 상위 폴더가 없습니다.".to_string())?; + fs::create_dir_all(parent) + .map_err(|error| format!("registry cache 폴더를 만들지 못했습니다. ({error})"))?; + + if path.exists() { + return verify_existing(path, raw); + } + + let sequence = NEXT_TEMP_FILE.fetch_add(1, Ordering::Relaxed); + let name = path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| "registry cache 파일명이 잘못됐습니다.".to_string())?; + let temporary = parent.join(format!(".{name}.tmp-{}-{sequence}", std::process::id())); + let mut file = OpenOptions::new() + .create_new(true) + .write(true) + .open(&temporary) + .map_err(|error| format!("registry 임시 cache를 만들지 못했습니다. ({error})"))?; + + let result = (|| { + file.write_all(raw) + .map_err(|error| format!("registry cache를 쓰지 못했습니다. ({error})"))?; + file.sync_all() + .map_err(|error| format!("registry cache를 동기화하지 못했습니다. ({error})"))?; + drop(file); + fs::rename(&temporary, path) + .map_err(|error| format!("registry cache를 확정하지 못했습니다. ({error})")) + })(); + + if result.is_err() && path.exists() && verify_existing(path, raw).is_ok() { + let _ = fs::remove_file(&temporary); + return Ok(()); + } + if result.is_err() { + let _ = fs::remove_file(&temporary); + } + result +} + +fn verify_existing(path: &Path, expected: &[u8]) -> Result<(), String> { + let actual = read_cache_file(path, expected.len())?; + if actual == expected { + Ok(()) + } else { + Err(format!( + "기존 registry cache 내용이 다릅니다. ({})", + path.display() + )) + } +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::sync::atomic::{AtomicU64, Ordering}; + + use super::*; + use crate::app_registry::http::sha256_hex; + use crate::app_registry::model::parse_index; + + static NEXT_DIRECTORY: AtomicU64 = AtomicU64::new(0); + + struct TestDirectory(PathBuf); + + impl TestDirectory { + fn new() -> Self { + let sequence = NEXT_DIRECTORY.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "hanbeon-hana-cloud-cache-{}-{sequence}", + std::process::id() + )); + fs::create_dir(&path).unwrap(); + Self(path) + } + } + + impl Drop for TestDirectory { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + fn profile_raw() -> Vec { + r#"{ + "schemaVersion": 1, + "id": "pdf-viewer", + "actions": [{ + "label": "다음 장", + "name": "페이지 넘기기", + "shortcut": { + "macos": "pagedown", + "windows": "pagedown", + "linux": "pagedown" + } + }] + }"# + .as_bytes() + .to_vec() + } + + fn index_raw(revision: u64, profile_sha256: &str) -> Vec { + format!( + r#"{{ + "schemaVersion": 1, + "revision": {revision}, + "apps": [{{ + "id": "pdf-viewer", + "name": "PDF 뷰어", + "path": "apps/pdf-viewer.json", + "sha256": "{profile_sha256}", + "match": {{ + "windows": {{ "executables": ["Acrobat.exe"] }} + }} + }}], + "boards": [] + }}"# + ) + .into_bytes() + } + + fn entry_for(raw: &[u8]) -> AppEntry { + parse_index(raw).unwrap().apps.remove(0) + } + + #[test] + fn stores_and_loads_a_hash_verified_profile() { + let directory = TestDirectory::new(); + let cache = Cache::new(directory.0.clone()); + let profile = profile_raw(); + let profile_sha256 = sha256_hex(&profile); + let index = index_raw(1, &profile_sha256); + let entry = entry_for(&index); + + assert_eq!( + cache.store_profile(&entry, &profile).unwrap().id, + "pdf-viewer" + ); + assert_eq!(cache.load_profile(&entry).unwrap().id, "pdf-viewer"); + assert!( + cache + .profiles_dir() + .join(format!("{profile_sha256}.json")) + .is_file() + ); + } + + #[test] + fn rejects_a_profile_whose_bytes_do_not_match_the_index_hash() { + let directory = TestDirectory::new(); + let cache = Cache::new(directory.0.clone()); + let index = index_raw(1, &"0".repeat(64)); + let entry = entry_for(&index); + + assert!(cache.store_profile(&entry, &profile_raw()).is_err()); + assert_eq!(cache.load_profile(&entry), None); + } + + #[test] + fn loads_the_highest_valid_revision_and_its_validators() { + let directory = TestDirectory::new(); + let cache = Cache::new(directory.0.clone()); + let profile_sha256 = sha256_hex(&profile_raw()); + let older = index_raw(2, &profile_sha256); + let newer = index_raw(3, &profile_sha256); + let newer_validators = Validators { + etag: Some("\"revision-3\"".into()), + last_modified: Some("Sun, 23 Aug 2026 00:00:00 GMT".into()), + }; + + cache + .store_index(&older, &sha256_hex(&older), &Validators::default()) + .unwrap(); + cache + .store_index(&newer, &sha256_hex(&newer), &newer_validators) + .unwrap(); + + let loaded = cache.load_latest_index().unwrap().unwrap(); + assert_eq!(loaded.index.revision, 3); + assert_eq!(loaded.sha256, sha256_hex(&newer)); + assert_eq!(loaded.validators, newer_validators); + } + + #[test] + fn falls_back_when_the_newest_cache_file_is_corrupt_and_ignores_temp_files() { + let directory = TestDirectory::new(); + let cache = Cache::new(directory.0.clone()); + let profile_sha256 = sha256_hex(&profile_raw()); + let older = index_raw(4, &profile_sha256); + cache + .store_index(&older, &sha256_hex(&older), &Validators::default()) + .unwrap(); + + fs::create_dir_all(cache.indexes_dir()).unwrap(); + fs::write( + cache.indexes_dir().join(format!("{}.json", "f".repeat(64))), + index_raw(99, &profile_sha256), + ) + .unwrap(); + fs::write(cache.indexes_dir().join("unfinished.tmp"), b"partial").unwrap(); + + let loaded = cache.load_latest_index().unwrap().unwrap(); + assert_eq!(loaded.index.revision, 4); + assert_eq!(loaded.sha256, sha256_hex(&older)); + } + + #[test] + fn index_store_rejects_a_mismatched_content_hash() { + let directory = TestDirectory::new(); + let cache = Cache::new(directory.0.clone()); + let profile_sha256 = sha256_hex(&profile_raw()); + let raw = index_raw(1, &profile_sha256); + + assert!( + cache + .store_index(&raw, &"0".repeat(64), &Validators::default()) + .is_err() + ); + assert!(cache.load_latest_index().unwrap().is_none()); + } + + #[test] + fn finds_an_older_verified_profile_when_the_latest_index_points_to_a_missing_update() { + let directory = TestDirectory::new(); + let cache = Cache::new(directory.0.clone()); + let profile = profile_raw(); + let old_sha256 = sha256_hex(&profile); + let old_index = index_raw(1, &old_sha256); + let old_entry = entry_for(&old_index); + + cache + .store_index(&old_index, &sha256_hex(&old_index), &Validators::default()) + .unwrap(); + cache.store_profile(&old_entry, &profile).unwrap(); + + let new_index = index_raw(2, &"0".repeat(64)); + cache + .store_index(&new_index, &sha256_hex(&new_index), &Validators::default()) + .unwrap(); + + let fallback = cache.load_fallback_profile("pdf-viewer").unwrap(); + assert_eq!(fallback.sha256, old_sha256); + assert_eq!(fallback.profile.id, "pdf-viewer"); + } +} diff --git a/apps/desktop/src-tauri/src/app_registry/http.rs b/apps/desktop/src-tauri/src/app_registry/http.rs new file mode 100644 index 0000000..de22462 --- /dev/null +++ b/apps/desktop/src-tauri/src/app_registry/http.rs @@ -0,0 +1,248 @@ +use std::io::Read; +use std::time::Duration; + +use reqwest::blocking::{Client, Response}; +use reqwest::header::{ETAG, IF_MODIFIED_SINCE, IF_NONE_MATCH, LAST_MODIFIED}; +use reqwest::redirect::Policy; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use super::model::{AppEntry, INDEX_LIMIT, PROFILE_LIMIT}; + +pub(crate) const REGISTRY_HOST: &str = "raw.githubusercontent.com"; +const REGISTRY_ROOT: &str = "https://raw.githubusercontent.com/dev-five-git/hana-cloud/main/"; +pub(crate) const INDEX_URL: &str = + "https://raw.githubusercontent.com/dev-five-git/hana-cloud/main/registry.json"; +const REQUEST_TIMEOUT: Duration = Duration::from_secs(5); +const MAX_REDIRECTS: usize = 5; + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct Validators { + pub(crate) etag: Option, + pub(crate) last_modified: Option, +} + +pub(crate) enum IndexFetch { + Modified { + raw: Vec, + sha256: String, + validators: Validators, + }, + NotModified, +} + +pub(crate) struct HttpClient { + client: Client, +} + +impl HttpClient { + pub(crate) fn new() -> Result { + let redirect = Policy::custom(|attempt| { + if attempt.previous().len() >= MAX_REDIRECTS || !allowed_registry_url(attempt.url()) { + attempt.stop() + } else { + attempt.follow() + } + }); + let client = Client::builder() + .timeout(REQUEST_TIMEOUT) + .redirect(redirect) + .user_agent("HanBeon/0.1 hana-cloud") + .build() + .map_err(|error| format!("registry HTTP client를 만들지 못했습니다. ({error})"))?; + Ok(Self { client }) + } + + pub(crate) fn fetch_index(&self, validators: &Validators) -> Result { + let mut request = self.client.get(INDEX_URL); + if let Some(etag) = validators.etag.as_deref() { + request = request.header(IF_NONE_MATCH, etag); + } + if let Some(last_modified) = validators.last_modified.as_deref() { + request = request.header(IF_MODIFIED_SINCE, last_modified); + } + + let response = request + .send() + .map_err(|error| format!("registry index를 요청하지 못했습니다. ({error})"))?; + if response.status() == reqwest::StatusCode::NOT_MODIFIED { + return Ok(IndexFetch::NotModified); + } + + let validators = response_validators(&response); + let raw = response_bytes(response, INDEX_LIMIT, "registry index")?; + let sha256 = sha256_hex(&raw); + Ok(IndexFetch::Modified { + raw, + sha256, + validators, + }) + } + + pub(crate) fn fetch_profile(&self, entry: &AppEntry) -> Result, String> { + let response = self + .client + .get(registry_url(&entry.path)?) + .send() + .map_err(|error| format!("'{}' profile을 요청하지 못했습니다. ({error})", entry.id))?; + let raw = response_bytes(response, PROFILE_LIMIT, "app profile")?; + verify_sha256(&raw, &entry.sha256)?; + Ok(raw) + } +} + +fn response_validators(response: &Response) -> Validators { + let text = |name| { + response + .headers() + .get(name) + .and_then(|value| value.to_str().ok()) + .map(ToOwned::to_owned) + }; + Validators { + etag: text(ETAG), + last_modified: text(LAST_MODIFIED), + } +} + +fn response_bytes(response: Response, limit: usize, resource: &str) -> Result, String> { + if !allowed_registry_url(response.url()) { + return Err(format!("{resource} 응답 host가 허용 범위를 벗어났습니다.")); + } + if !response.status().is_success() { + return Err(format!( + "{resource} 요청이 실패했습니다. (HTTP {})", + response.status() + )); + } + if response + .content_length() + .is_some_and(|length| length > limit as u64) + { + return Err(format!("{resource}가 {limit}바이트 제한을 넘었습니다.")); + } + read_limited(response, limit) +} + +pub(crate) fn registry_url(path: &str) -> Result { + if path.is_empty() + || path.contains('\\') + || path.contains(':') + || path.starts_with('/') + || path + .split('/') + .any(|part| part.is_empty() || part == "." || part == "..") + || !path + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'/' | b'-')) + { + return Err(format!("registry 상대 경로가 안전하지 않습니다. ({path})")); + } + + let base = reqwest::Url::parse(REGISTRY_ROOT) + .map_err(|error| format!("registry base URL이 잘못됐습니다. ({error})"))?; + let url = base + .join(path) + .map_err(|error| format!("registry URL을 만들지 못했습니다. ({error})"))?; + allowed_registry_url(&url) + .then_some(url) + .ok_or_else(|| "registry URL host가 허용 범위를 벗어났습니다.".to_string()) +} + +pub(crate) fn allowed_registry_url(url: &reqwest::Url) -> bool { + url.scheme() == "https" && url.host_str() == Some(REGISTRY_HOST) +} + +pub(crate) fn read_limited(mut reader: impl Read, limit: usize) -> Result, String> { + let mut bytes = Vec::with_capacity(limit.min(8192)); + reader + .by_ref() + .take(limit as u64 + 1) + .read_to_end(&mut bytes) + .map_err(|error| format!("registry 응답을 읽지 못했습니다. ({error})"))?; + if bytes.len() > limit { + return Err(format!("registry 응답이 {limit}바이트 제한을 넘었습니다.")); + } + Ok(bytes) +} + +pub(crate) fn sha256_hex(raw: &[u8]) -> String { + Sha256::digest(raw) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +pub(crate) fn verify_sha256(raw: &[u8], expected: &str) -> Result<(), String> { + let actual = sha256_hex(raw); + if actual == expected { + Ok(()) + } else { + Err(format!( + "registry SHA-256이 다릅니다. (예상 {expected}, 실제 {actual})" + )) + } +} + +#[cfg(test)] +mod tests { + use std::io::Cursor; + + use super::*; + + #[test] + fn registry_urls_are_fixed_to_https_and_the_hana_repository() { + let index = registry_url("registry.json").unwrap(); + let profile = registry_url("apps/pdf-viewer.json").unwrap(); + + assert_eq!(index.as_str(), INDEX_URL); + assert_eq!(profile.scheme(), "https"); + assert_eq!(profile.host_str(), Some(REGISTRY_HOST)); + assert_eq!( + profile.path(), + "/dev-five-git/hana-cloud/main/apps/pdf-viewer.json" + ); + } + + #[test] + fn registry_urls_reject_traversal_and_absolute_input() { + assert!(registry_url("../secrets.json").is_err()); + assert!(registry_url("https://example.com/profile.json").is_err()); + assert!(registry_url("apps\\profile.json").is_err()); + } + + #[test] + fn redirect_target_must_keep_https_and_the_registry_host() { + assert!(allowed_registry_url( + &reqwest::Url::parse(INDEX_URL).unwrap() + )); + assert!(!allowed_registry_url( + &reqwest::Url::parse( + "http://raw.githubusercontent.com/dev-five-git/hana-cloud/main/registry.json" + ) + .unwrap() + )); + assert!(!allowed_registry_url( + &reqwest::Url::parse("https://example.com/registry.json").unwrap() + )); + } + + #[test] + fn limited_reader_accepts_the_limit_and_rejects_one_byte_more() { + assert_eq!( + read_limited(Cursor::new(vec![7; 4]), 4).unwrap(), + vec![7; 4] + ); + assert!(read_limited(Cursor::new(vec![7; 5]), 4).is_err()); + } + + #[test] + fn sha256_must_match_before_a_profile_is_accepted() { + let raw = b"hana"; + let expected = sha256_hex(raw); + + assert!(verify_sha256(raw, &expected).is_ok()); + assert!(verify_sha256(raw, &"0".repeat(64)).is_err()); + } +} diff --git a/apps/desktop/src-tauri/src/app_registry/mod.rs b/apps/desktop/src-tauri/src/app_registry/mod.rs new file mode 100644 index 0000000..77d480e --- /dev/null +++ b/apps/desktop/src-tauri/src/app_registry/mod.rs @@ -0,0 +1,385 @@ +mod cache; +mod http; +mod model; + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::mpsc::{Receiver, RecvTimeoutError, SyncSender, TrySendError}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::{Duration, Instant}; + +use crate::focused_application::FocusedApplication; + +use self::cache::Cache; +use self::http::{HttpClient, IndexFetch, Validators}; +pub(crate) use self::model::ResolvedAction; +use self::model::{AppEntry, AppProfile, Platform, RegistryIndex, matches_application}; + +const REFRESH_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60); +const FAILURE_BACKOFF: Duration = Duration::from_secs(5 * 60); +const COMMAND_CAPACITY: usize = 32; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct RegistryPreset { + pub(crate) id: String, + pub(crate) name: String, + pub(crate) sha256: String, + pub(crate) actions: Vec, +} + +#[derive(Clone)] +pub(crate) struct Registry { + state: Arc>, + sender: SyncSender, +} + +#[derive(Default)] +struct RegistryState { + index: Option, + profiles: HashMap, + retry_after: HashMap, +} + +struct StoredProfile { + sha256: String, + profile: AppProfile, +} + +#[derive(Clone, Debug)] +enum Command { + FetchProfile(AppEntry), +} + +impl Registry { + pub(crate) fn spawn(cache_root: PathBuf) -> Self { + let cache = Cache::new(cache_root); + let cached = match cache.load_latest_index() { + Ok(cached) => cached, + Err(error) => { + eprintln!("Hana Cloud cache를 읽지 못했습니다. {error}"); + None + } + }; + let (index, validators) = cached.map_or_else( + || (None, Validators::default()), + |cached| (Some(cached.index), cached.validators), + ); + let profiles = index + .as_ref() + .map(|index| load_cached_profiles(&cache, index)) + .unwrap_or_default(); + + let state = Arc::new(Mutex::new(RegistryState { + index, + profiles, + retry_after: HashMap::new(), + })); + let (sender, receiver) = std::sync::mpsc::sync_channel(COMMAND_CAPACITY); + + let worker_state = Arc::clone(&state); + thread::spawn(move || run_worker(cache, worker_state, receiver, validators)); + + Self { state, sender } + } + + pub(crate) fn lookup(&self, app: &FocusedApplication) -> Option { + let mut fetch = None; + let preset = { + let mut state = self.state.lock().ok()?; + let entry = state + .index + .as_ref()? + .apps + .iter() + .find(|entry| matches_application(entry, app))? + .clone(); + + let current_profile = state + .profiles + .get(&entry.id) + .is_some_and(|stored| stored.sha256 == entry.sha256); + let preset = state.profiles.get(&entry.id).and_then(|stored| { + let actions = stored.profile.actions_for(Platform::current()); + (!actions.is_empty()).then(|| RegistryPreset { + id: entry.id.clone(), + name: entry.name.clone(), + sha256: stored.sha256.clone(), + actions, + }) + }); + + if !current_profile { + let retry_key = format!("{}:{}", entry.id, entry.sha256); + let now = Instant::now(); + if state + .retry_after + .get(&retry_key) + .is_none_or(|retry| *retry <= now) + { + state + .retry_after + .insert(retry_key.clone(), now + FAILURE_BACKOFF); + fetch = Some((retry_key, entry)); + } + } + preset + }; + + if let Some((retry_key, entry)) = fetch + && let Err(TrySendError::Full(_)) = self.sender.try_send(Command::FetchProfile(entry)) + && let Ok(mut state) = self.state.lock() + { + state + .retry_after + .insert(retry_key, Instant::now() + Duration::from_secs(1)); + } + + preset + } + + #[cfg(test)] + fn for_test( + index: RegistryIndex, + profiles: HashMap, + ) -> (Self, Receiver) { + let (sender, receiver) = std::sync::mpsc::sync_channel(8); + ( + Self { + state: Arc::new(Mutex::new(RegistryState { + index: Some(index), + profiles, + retry_after: HashMap::new(), + })), + sender, + }, + receiver, + ) + } +} + +fn load_cached_profiles(cache: &Cache, index: &RegistryIndex) -> HashMap { + index + .apps + .iter() + .filter_map(|entry| { + cache + .load_profile(entry) + .map(|profile| (entry.sha256.clone(), profile)) + .or_else(|| { + cache + .load_fallback_profile(&entry.id) + .map(|cached| (cached.sha256, cached.profile)) + }) + .map(|(sha256, profile)| (entry.id.clone(), StoredProfile { sha256, profile })) + }) + .collect() +} + +fn run_worker( + cache: Cache, + state: Arc>, + receiver: Receiver, + mut validators: Validators, +) { + let http = match HttpClient::new() { + Ok(http) => http, + Err(error) => { + eprintln!("Hana Cloud HTTP client를 시작하지 못했습니다. {error}"); + return; + } + }; + let mut next_refresh = Instant::now(); + + loop { + let timeout = next_refresh.saturating_duration_since(Instant::now()); + match receiver.recv_timeout(timeout) { + Ok(Command::FetchProfile(entry)) => { + if let Err(error) = fetch_profile(&http, &cache, &state, &entry) { + eprintln!("Hana Cloud app profile을 갱신하지 못했습니다. {error}"); + } + } + Err(RecvTimeoutError::Timeout) => { + let succeeded = refresh_index(&http, &cache, &state, &mut validators).is_ok(); + next_refresh = Instant::now() + + if succeeded { + REFRESH_INTERVAL + } else { + FAILURE_BACKOFF + }; + } + Err(RecvTimeoutError::Disconnected) => return, + } + } +} + +fn refresh_index( + http: &HttpClient, + cache: &Cache, + state: &Arc>, + validators: &mut Validators, +) -> Result<(), String> { + match http.fetch_index(validators)? { + IndexFetch::NotModified => Ok(()), + IndexFetch::Modified { + raw, + sha256, + validators: next_validators, + } => { + let index = cache.store_index(&raw, &sha256, &next_validators)?; + let profiles = load_cached_profiles(cache, &index); + let mut current = state + .lock() + .map_err(|_| "registry 메모리 상태를 갱신하지 못했습니다.".to_string())?; + current.index = Some(index); + current.profiles = profiles; + current.retry_after.clear(); + *validators = next_validators; + Ok(()) + } + } +} + +fn fetch_profile( + http: &HttpClient, + cache: &Cache, + state: &Arc>, + entry: &AppEntry, +) -> Result<(), String> { + let raw = http.fetch_profile(entry)?; + let profile = cache.store_profile(entry, &raw)?; + let mut current = state + .lock() + .map_err(|_| "registry 메모리 상태를 갱신하지 못했습니다.".to_string())?; + let still_current = current.index.as_ref().is_some_and(|index| { + index + .apps + .iter() + .any(|candidate| candidate.id == entry.id && candidate.sha256 == entry.sha256) + }); + if still_current { + current.profiles.insert( + entry.id.clone(), + StoredProfile { + sha256: entry.sha256.clone(), + profile, + }, + ); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app_registry::http::sha256_hex; + use crate::app_registry::model::{parse_index, parse_profile}; + + fn profile_raw() -> Vec { + r#"{ + "schemaVersion": 1, + "id": "pdf-viewer", + "actions": [{ + "label": "다음 장", + "name": "페이지 넘기기", + "shortcut": { + "macos": "pagedown", + "windows": "pagedown", + "linux": "pagedown" + } + }] + }"# + .as_bytes() + .to_vec() + } + + fn index_and_profile() -> (RegistryIndex, AppProfile, String) { + let profile_raw = profile_raw(); + let sha256 = sha256_hex(&profile_raw); + let index_raw = format!( + r#"{{ + "schemaVersion": 1, + "revision": 2, + "apps": [{{ + "id": "pdf-viewer", + "name": "PDF 뷰어", + "path": "apps/pdf-viewer.json", + "sha256": "{sha256}", + "match": {{ + "windows": {{ "executables": ["Acrobat.exe"] }} + }} + }}], + "boards": [] + }}"# + ); + ( + parse_index(index_raw.as_bytes()).unwrap(), + parse_profile(&profile_raw, "pdf-viewer").unwrap(), + sha256, + ) + } + + #[test] + fn cached_profile_resolves_immediately_without_a_worker_command() { + let (index, profile, sha256) = index_and_profile(); + let expected_sha256 = sha256.clone(); + let profiles = + HashMap::from([("pdf-viewer".to_string(), StoredProfile { sha256, profile })]); + let (registry, receiver) = Registry::for_test(index, profiles); + + let preset = registry + .lookup(&FocusedApplication::windows(7, "ACROBAT.EXE".into())) + .unwrap(); + + assert_eq!(preset.id, "pdf-viewer"); + assert_eq!(preset.name, "PDF 뷰어"); + assert_eq!(preset.sha256, expected_sha256); + assert_eq!(preset.actions.len(), 1); + assert_eq!(preset.actions[0].shortcut, "pagedown"); + assert!(receiver.try_recv().is_err()); + } + + #[test] + fn missing_profile_queues_only_one_fetch_during_repeated_focus_polls() { + let (index, _, _) = index_and_profile(); + let (registry, receiver) = Registry::for_test(index, HashMap::new()); + let app = FocusedApplication::windows(7, "Acrobat.exe".into()); + + assert_eq!(registry.lookup(&app), None); + assert_eq!(registry.lookup(&app), None); + + let Command::FetchProfile(entry) = receiver.try_recv().unwrap(); + assert_eq!(entry.id, "pdf-viewer"); + assert!(receiver.try_recv().is_err()); + } + + #[test] + fn unknown_application_does_not_queue_a_download() { + let (index, _, _) = index_and_profile(); + let (registry, receiver) = Registry::for_test(index, HashMap::new()); + + assert_eq!( + registry.lookup(&FocusedApplication::windows(8, "Unknown.exe".into())), + None + ); + assert!(receiver.try_recv().is_err()); + } + + #[test] + fn stale_last_known_good_profile_stays_active_while_its_update_is_queued() { + let (mut index, profile, sha256) = index_and_profile(); + index.apps[0].sha256 = "0".repeat(64); + let profiles = + HashMap::from([("pdf-viewer".to_string(), StoredProfile { sha256, profile })]); + let (registry, receiver) = Registry::for_test(index, profiles); + + let preset = registry + .lookup(&FocusedApplication::windows(7, "Acrobat.exe".into())) + .unwrap(); + + assert_eq!(preset.id, "pdf-viewer"); + assert_eq!(preset.actions[0].shortcut, "pagedown"); + let Command::FetchProfile(entry) = receiver.try_recv().unwrap(); + assert_eq!(entry.sha256, "0".repeat(64)); + } +} diff --git a/apps/desktop/src-tauri/src/app_registry/model.rs b/apps/desktop/src-tauri/src/app_registry/model.rs new file mode 100644 index 0000000..382ebb0 --- /dev/null +++ b/apps/desktop/src-tauri/src/app_registry/model.rs @@ -0,0 +1,748 @@ +use std::collections::HashSet; + +use serde::Deserialize; + +use crate::focused_application::{ApplicationIdentity, FocusedApplication}; + +pub(crate) const INDEX_LIMIT: usize = 256 * 1024; +pub(crate) const PROFILE_LIMIT: usize = 64 * 1024; +const MAX_APPS: usize = 1024; +const MAX_ALIASES: usize = 32; + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct RegistryIndex { + schema_version: u8, + pub(crate) revision: u64, + pub(crate) apps: Vec, + boards: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct AppEntry { + pub(crate) id: String, + pub(crate) name: String, + pub(crate) path: String, + pub(crate) sha256: String, + #[serde(rename = "match")] + matchers: AppMatchers, +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct AppMatchers { + macos: Option, + windows: Option, + linux: Option, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct MacOsMatchers { + bundle_ids: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct WindowsMatchers { + executables: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct LinuxMatchers { + desktop_ids: Vec, + wm_classes: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct BoardEntry { + id: String, + name: String, + manifest: String, + sha256: String, + detect: BoardDetection, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct BoardDetection { + usb: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct UsbMatch { + vid: String, + pid: String, + confidence: UsbConfidence, + #[serde(default)] + manufacturer_aliases: Vec, + #[serde(default)] + product_aliases: Vec, +} + +#[derive(Clone, Copy, Debug, Deserialize)] +#[serde(rename_all = "lowercase")] +enum UsbConfidence { + Exact, + Likely, + Ambiguous, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct AppProfile { + schema_version: u8, + pub(crate) id: String, + actions: Vec, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ProfileAction { + label: String, + name: String, + shortcut: PlatformShortcuts, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct PlatformShortcuts { + macos: Option, + windows: Option, + linux: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[allow(dead_code)] // 각 빌드는 현재 OS 외 variant를 만들지 않지만 검증 로직은 공유한다. +pub(crate) enum Platform { + MacOs, + Windows, + Linux, +} + +impl Platform { + pub(crate) fn current() -> Self { + #[cfg(target_os = "macos")] + { + Self::MacOs + } + #[cfg(target_os = "windows")] + { + Self::Windows + } + #[cfg(target_os = "linux")] + { + Self::Linux + } + #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] + { + Self::Linux + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ResolvedAction { + pub(crate) label: String, + pub(crate) name: String, + pub(crate) shortcut: String, +} + +impl AppProfile { + pub(crate) fn actions_for(&self, platform: Platform) -> Vec { + self.actions + .iter() + .filter_map(|action| { + let shortcut = match platform { + Platform::MacOs => action.shortcut.macos.as_ref(), + Platform::Windows => action.shortcut.windows.as_ref(), + Platform::Linux => action.shortcut.linux.as_ref(), + }?; + Some(ResolvedAction { + label: action.label.clone(), + name: action.name.clone(), + shortcut: shortcut.clone(), + }) + }) + .collect() + } +} + +pub(crate) fn parse_index(raw: &[u8]) -> Result { + if raw.len() > INDEX_LIMIT { + return Err(format!( + "registry가 {}바이트 제한을 넘었습니다.", + INDEX_LIMIT + )); + } + + let index: RegistryIndex = serde_json::from_slice(raw) + .map_err(|error| format!("registry JSON을 읽지 못했습니다. ({error})"))?; + validate_index(&index)?; + Ok(index) +} + +pub(crate) fn parse_profile(raw: &[u8], expected_id: &str) -> Result { + if raw.len() > PROFILE_LIMIT { + return Err(format!( + "app profile이 {}바이트 제한을 넘었습니다.", + PROFILE_LIMIT + )); + } + + let profile: AppProfile = serde_json::from_slice(raw) + .map_err(|error| format!("app profile JSON을 읽지 못했습니다. ({error})"))?; + validate_profile(&profile, expected_id)?; + Ok(profile) +} + +fn validate_index(index: &RegistryIndex) -> Result<(), String> { + if index.schema_version != 1 { + return Err("registry schemaVersion은 1이어야 합니다.".into()); + } + if index.revision == 0 { + return Err("registry revision은 1 이상이어야 합니다.".into()); + } + if index.apps.len() > MAX_APPS { + return Err(format!("registry apps는 {MAX_APPS}개를 넘을 수 없습니다.")); + } + let mut ids = HashSet::new(); + let mut bundle_ids = HashSet::new(); + let mut executables = HashSet::new(); + let mut desktop_ids = HashSet::new(); + let mut wm_classes = HashSet::new(); + + for entry in &index.apps { + if !valid_id(&entry.id) || !ids.insert(entry.id.as_str()) { + return Err(format!("app id가 잘못됐거나 중복됐습니다. ({})", entry.id)); + } + bounded_text(&entry.name, 100, "app name")?; + if !safe_app_path(&entry.path) { + return Err(format!("app path가 안전하지 않습니다. ({})", entry.path)); + } + if !valid_sha256(&entry.sha256) { + return Err(format!("app sha256이 잘못됐습니다. ({})", entry.id)); + } + + let mut platforms = 0; + if let Some(macos) = &entry.matchers.macos { + platforms += 1; + validate_aliases(&macos.bundle_ids, valid_bundle_id, "macOS bundleIds")?; + for value in &macos.bundle_ids { + if !bundle_ids.insert(value.as_str()) { + return Err(format!("macOS bundleId가 중복됐습니다. ({value})")); + } + } + } + if let Some(windows) = &entry.matchers.windows { + platforms += 1; + validate_aliases( + &windows.executables, + valid_windows_executable, + "Windows executables", + )?; + for value in &windows.executables { + let normalized = value.to_lowercase(); + if !executables.insert(normalized) { + return Err(format!("Windows executable이 중복됐습니다. ({value})")); + } + } + } + if let Some(linux) = &entry.matchers.linux { + platforms += 1; + validate_aliases(&linux.desktop_ids, valid_desktop_id, "Linux desktopIds")?; + validate_aliases(&linux.wm_classes, valid_wm_class, "Linux wmClasses")?; + for value in &linux.desktop_ids { + if !desktop_ids.insert(value.as_str()) { + return Err(format!("Linux desktopId가 중복됐습니다. ({value})")); + } + } + for value in &linux.wm_classes { + if !wm_classes.insert(value.as_str()) { + return Err(format!("Linux WM_CLASS가 중복됐습니다. ({value})")); + } + } + } + if platforms == 0 { + return Err(format!("app match가 비어 있습니다. ({})", entry.id)); + } + } + + let mut board_ids = HashSet::new(); + for entry in &index.boards { + if !valid_id(&entry.id) || !board_ids.insert(entry.id.as_str()) { + return Err(format!( + "board id가 잘못됐거나 중복됐습니다. ({})", + entry.id + )); + } + bounded_text(&entry.name, 100, "board name")?; + if !safe_board_path(&entry.manifest) { + return Err(format!( + "board manifest 경로가 안전하지 않습니다. ({})", + entry.manifest + )); + } + if !valid_sha256(&entry.sha256) { + return Err(format!("board sha256이 잘못됐습니다. ({})", entry.id)); + } + if entry.detect.usb.is_empty() { + return Err(format!("board detect.usb가 비어 있습니다. ({})", entry.id)); + } + for matcher in &entry.detect.usb { + if !valid_usb_id(&matcher.vid) || !valid_usb_id(&matcher.pid) { + return Err(format!( + "board USB VID/PID가 잘못됐습니다. ({}:{})", + matcher.vid, matcher.pid + )); + } + validate_usb_aliases(&matcher.manufacturer_aliases, "manufacturerAliases")?; + validate_usb_aliases(&matcher.product_aliases, "productAliases")?; + match matcher.confidence { + UsbConfidence::Exact | UsbConfidence::Likely | UsbConfidence::Ambiguous => {} + } + } + } + + Ok(()) +} + +fn validate_profile(profile: &AppProfile, expected_id: &str) -> Result<(), String> { + if profile.schema_version != 1 { + return Err("app profile schemaVersion은 1이어야 합니다.".into()); + } + if !valid_id(&profile.id) || profile.id != expected_id { + return Err(format!( + "app profile id가 registry와 다릅니다. (예상 {expected_id}, 실제 {})", + profile.id + )); + } + if !(1..=3).contains(&profile.actions.len()) { + return Err("app profile actions는 1~3개여야 합니다.".into()); + } + + for (index, action) in profile.actions.iter().enumerate() { + bounded_text(&action.label, 20, "action label")?; + bounded_text(&action.name, 60, "action name")?; + + let shortcuts = [ + action.shortcut.macos.as_deref(), + action.shortcut.windows.as_deref(), + action.shortcut.linux.as_deref(), + ]; + if shortcuts.iter().all(Option::is_none) { + return Err(format!("action shortcut이 비어 있습니다. ({index})")); + } + for shortcut in shortcuts.into_iter().flatten() { + if shortcut.is_empty() + || shortcut.len() > 64 + || shortcut.trim() != shortcut + || shortcut != shortcut.to_ascii_lowercase() + || !shortcut + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'+') + || crate::shortcut::parse(shortcut).is_none() + { + return Err(format!("지원하지 않는 shortcut입니다. ({shortcut})")); + } + } + } + + for left in 0..profile.actions.len() { + for right in (left + 1)..profile.actions.len() { + if profile.actions[left] == profile.actions[right] { + return Err("동일한 action을 두 번 등록할 수 없습니다.".into()); + } + } + } + + Ok(()) +} + +pub(crate) fn matches_application(entry: &AppEntry, app: &FocusedApplication) -> bool { + match app.identity() { + ApplicationIdentity::MacOs { bundle_id } => entry + .matchers + .macos + .as_ref() + .zip(bundle_id.as_ref()) + .is_some_and(|(aliases, value)| aliases.bundle_ids.contains(value)), + ApplicationIdentity::Windows { executable } => { + entry.matchers.windows.as_ref().is_some_and(|aliases| { + aliases + .executables + .iter() + .any(|value| value.to_lowercase() == executable.to_lowercase()) + }) + } + ApplicationIdentity::Linux { + desktop_id, + wm_classes, + } => entry.matchers.linux.as_ref().is_some_and(|aliases| { + desktop_id + .as_ref() + .is_some_and(|value| aliases.desktop_ids.contains(value)) + || wm_classes + .iter() + .any(|value| aliases.wm_classes.contains(value)) + }), + } +} + +fn validate_aliases( + aliases: &[String], + validate: fn(&str) -> bool, + field: &str, +) -> Result<(), String> { + if aliases.is_empty() || aliases.len() > MAX_ALIASES { + return Err(format!("{field}는 1~{MAX_ALIASES}개여야 합니다.")); + } + let mut unique = HashSet::new(); + if aliases + .iter() + .any(|value| !validate(value) || !unique.insert(value.as_str())) + { + return Err(format!("{field}에 잘못됐거나 중복된 값이 있습니다.")); + } + Ok(()) +} + +fn bounded_text(value: &str, maximum: usize, field: &str) -> Result<(), String> { + let length = value.chars().count(); + if length == 0 || length > maximum { + return Err(format!("{field} 길이는 1~{maximum}자여야 합니다.")); + } + Ok(()) +} + +fn valid_id(value: &str) -> bool { + let mut previous_separator = true; + for byte in value.bytes() { + if byte == b'.' || byte == b'-' { + if previous_separator { + return false; + } + previous_separator = true; + } else if byte.is_ascii_lowercase() || byte.is_ascii_digit() { + previous_separator = false; + } else { + return false; + } + } + !value.is_empty() && !previous_separator +} + +fn safe_app_path(value: &str) -> bool { + safe_registry_path(value, "apps/", ".json") +} + +fn safe_board_path(value: &str) -> bool { + safe_registry_path(value, "boards/", ".json") +} + +fn safe_registry_path(value: &str, prefix: &str, suffix: &str) -> bool { + value.starts_with(prefix) + && value.ends_with(suffix) + && !value.contains('\\') + && value + .split('/') + .all(|segment| !segment.is_empty() && segment != "." && segment != "..") + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'/' | b'-')) +} + +fn valid_usb_id(value: &str) -> bool { + value.len() == 4 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn validate_usb_aliases(aliases: &[String], field: &str) -> Result<(), String> { + let mut unique = HashSet::new(); + for alias in aliases { + bounded_text(alias, 100, field)?; + if !unique.insert(alias.as_str()) { + return Err(format!("board {field}에 중복된 값이 있습니다.")); + } + } + Ok(()) +} + +fn valid_sha256(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn valid_bundle_id(value: &str) -> bool { + value.len() <= 255 + && value.contains('.') + && value.split('.').all(|segment| { + let mut bytes = segment.bytes(); + bytes + .next() + .is_some_and(|byte| byte.is_ascii_alphanumeric()) + && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + }) +} + +fn valid_windows_executable(value: &str) -> bool { + value.len() <= 255 + && value.len() > 4 + && value[value.len() - 4..].eq_ignore_ascii_case(".exe") + && value.trim() == value + && !value.starts_with('.') + && !value + .chars() + .any(|character| character.is_control() || "<>:\"/\\|?*".contains(character)) +} + +fn valid_desktop_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= 255 + && !value.contains("..") + && value.bytes().enumerate().all(|(index, byte)| { + byte.is_ascii_alphanumeric() || (index > 0 && matches!(byte, b'.' | b'_' | b'-')) + }) +} + +fn valid_wm_class(value: &str) -> bool { + !value.is_empty() + && value.chars().count() <= 255 + && value.trim() == value + && !value.chars().any(char::is_control) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::focused_application::FocusedApplication; + + const PROFILE_HASH: &str = "739fdb18d93b143ffe6598c26177be73794f12052ad6018eca24d1912cbf22a8"; + + fn index_json(path: &str) -> String { + format!( + r#"{{ + "schemaVersion": 1, + "revision": 2, + "apps": [{{ + "id": "pdf-viewer", + "name": "PDF 뷰어", + "path": "{path}", + "sha256": "{PROFILE_HASH}", + "match": {{ + "macos": {{ "bundleIds": ["com.apple.Preview"] }}, + "windows": {{ "executables": ["Acrobat.exe"] }}, + "linux": {{ + "desktopIds": ["org.gnome.Evince.desktop"], + "wmClasses": ["evince", "Evince"] + }} + }} + }}], + "boards": [] + }}"# + ) + } + + fn profile_json(actions: &str) -> String { + format!( + r#"{{ + "schemaVersion": 1, + "id": "pdf-viewer", + "actions": [{actions}] + }}"# + ) + } + + #[test] + fn parses_the_published_index_and_matches_each_platform_identity() { + let index = parse_index(index_json("apps/pdf-viewer.json").as_bytes()).unwrap(); + let entry = &index.apps[0]; + + assert_eq!(entry.id, "pdf-viewer"); + assert!(matches_application( + entry, + &FocusedApplication::macos(1, Some("com.apple.Preview".into())) + )); + assert!(matches_application( + entry, + &FocusedApplication::windows(2, "ACROBAT.EXE".into()) + )); + assert!(matches_application( + entry, + &FocusedApplication::linux( + Some(3), + Some("org.gnome.Evince.desktop".into()), + vec!["unrelated".into()] + ) + )); + assert!(matches_application( + entry, + &FocusedApplication::linux(None, None, vec!["Evince".into()]) + )); + } + + #[test] + fn platform_identifiers_are_not_display_string_normalized() { + let index = parse_index(index_json("apps/pdf-viewer.json").as_bytes()).unwrap(); + let entry = &index.apps[0]; + + assert!(!matches_application( + entry, + &FocusedApplication::macos(1, Some("COM.APPLE.PREVIEW".into())) + )); + assert!(!matches_application( + entry, + &FocusedApplication::linux(None, None, vec!["e-v_i nce".into()]) + )); + } + + #[test] + fn rejects_an_unsafe_profile_path() { + let error = parse_index(index_json("apps/../boards/firmware.ino").as_bytes()).unwrap_err(); + assert!(error.contains("path")); + } + + #[test] + fn rejects_an_unknown_index_schema_version() { + let raw = index_json("apps/pdf-viewer.json") + .replace("\"schemaVersion\": 1", "\"schemaVersion\": 2"); + let error = parse_index(raw.as_bytes()).unwrap_err(); + assert!(error.contains("schemaVersion")); + } + + #[test] + fn rejects_a_board_without_the_registry_schema_fields() { + let raw = index_json("apps/pdf-viewer.json").replace("\"boards\": []", "\"boards\": [{}]"); + + assert!(parse_index(raw.as_bytes()).is_err()); + } + + #[test] + fn rejects_invalid_or_unknown_board_usb_fields() { + let board = format!( + r#"{{ + "id": "arduino-uno-r3", + "name": "Arduino Uno R3", + "manifest": "boards/arduino-uno-r3.json", + "sha256": "{PROFILE_HASH}", + "detect": {{ + "usb": [{{ + "vid": "2341", + "pid": "00ZZ", + "confidence": "exact", + "serialHandshake": true + }}] + }} + }}"# + ); + let raw = index_json("apps/pdf-viewer.json") + .replace("\"boards\": []", &format!("\"boards\": [{board}]")); + + assert!(parse_index(raw.as_bytes()).is_err()); + } + + #[test] + fn accepts_a_complete_board_registry_entry() { + let board = format!( + r#"{{ + "id": "arduino-uno-r3", + "name": "Arduino Uno R3", + "manifest": "boards/arduino-uno-r3.json", + "sha256": "{PROFILE_HASH}", + "detect": {{ + "usb": [{{ + "vid": "2341", + "pid": "0043", + "confidence": "exact", + "manufacturerAliases": ["Arduino LLC"], + "productAliases": ["Arduino Uno"] + }}] + }} + }}"# + ); + let raw = index_json("apps/pdf-viewer.json") + .replace("\"boards\": []", &format!("\"boards\": [{board}]")); + + let index = parse_index(raw.as_bytes()).unwrap(); + assert_eq!(index.boards.len(), 1); + } + + #[test] + fn parses_actions_for_each_platform_in_registry_order() { + let raw = profile_json( + r#"{ + "label": "다음 장", + "name": "페이지 넘기기", + "shortcut": { + "macos": "pagedown", + "windows": "pagedown", + "linux": "pagedown" + } + }, { + "label": "이전 장", + "name": "앞 페이지로", + "shortcut": { + "macos": "pageup", + "windows": "pageup" + } + }"#, + ); + let profile = parse_profile(raw.as_bytes(), "pdf-viewer").unwrap(); + + let windows = profile.actions_for(Platform::Windows); + assert_eq!(windows.len(), 2); + assert_eq!(windows[0].label, "다음 장"); + assert_eq!(windows[1].shortcut, "pageup"); + + let linux = profile.actions_for(Platform::Linux); + assert_eq!(linux.len(), 1); + assert_eq!(linux[0].shortcut, "pagedown"); + } + + #[test] + fn rejects_a_profile_with_the_wrong_id() { + let raw = profile_json( + r#"{ + "label": "다음 장", + "name": "페이지 넘기기", + "shortcut": { "macos": "pagedown" } + }"#, + ); + let error = parse_profile(raw.as_bytes(), "music-app").unwrap_err(); + assert!(error.contains("id")); + } + + #[test] + fn rejects_more_than_three_actions_or_an_unknown_shortcut() { + let action = r#"{ + "label": "동작", + "name": "테스트", + "shortcut": { "macos": "pagedown" } + }"#; + let too_many = profile_json(&[action, action, action, action].join(",")); + assert!(parse_profile(too_many.as_bytes(), "pdf-viewer").is_err()); + + let unknown = profile_json( + r#"{ + "label": "동작", + "name": "테스트", + "shortcut": { "macos": "shell+command" } + }"#, + ); + assert!(parse_profile(unknown.as_bytes(), "pdf-viewer").is_err()); + } + + #[test] + fn enforces_index_and_profile_size_limits_before_parsing() { + assert!(parse_index(&vec![b' '; INDEX_LIMIT + 1]).is_err()); + assert!(parse_profile(&vec![b' '; PROFILE_LIMIT + 1], "pdf-viewer").is_err()); + } +} diff --git a/apps/desktop/src-tauri/src/foreground.rs b/apps/desktop/src-tauri/src/foreground.rs index 545c812..83861ff 100644 --- a/apps/desktop/src-tauri/src/foreground.rs +++ b/apps/desktop/src-tauri/src/foreground.rs @@ -11,6 +11,7 @@ use std::time::Duration; use serde::Serialize; use tauri::{AppHandle, Emitter, Manager}; +use crate::app_registry::Registry; use crate::focused_application::{self, FocusedApplication}; use crate::occlusion::{self, MARGIN}; use crate::profile::Profile; @@ -39,7 +40,7 @@ fn log_enabled() -> bool { *ENABLED.get_or_init(|| std::env::var("HANBEON_LOG").is_ok()) } -pub fn watch(app: AppHandle, profile: Arc>, scanner: Scanner) { +pub fn watch(app: AppHandle, profile: Arc>, scanner: Scanner, registry: Registry) { thread::spawn(move || { let mut covered = false; let mut source = focused_application::system_source(); @@ -55,10 +56,7 @@ pub fn watch(app: AppHandle, profile: Arc>, scanner: Scanner) { let ours = is_ours(front.as_ref(), std::process::id() as i32); if !ours { - scanner.apply_preset( - &app, - front.as_ref().and_then(FocusedApplication::macos_bundle_id), - ); + scanner.apply_preset(&app, front.as_ref(), ®istry); } let (dim, percent) = profile diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 3200d89..83e0e0c 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -7,6 +7,7 @@ mod action; mod adapt; +mod app_registry; pub mod arduino; mod audio; mod emit; @@ -188,15 +189,24 @@ pub fn run() { let profile = Arc::new(Mutex::new(profile)); let scanner = Scanner::new(Arc::clone(&profile), audio, journal.clone()); + let registry = app_registry::Registry::spawn( + app.path().app_cache_dir()?.join("hana-cloud"), + ); let moves = window::MoveWatch::default(); app.manage(SharedProfile(Arc::clone(&profile))); app.manage(Arc::clone(&detector)); app.manage(scanner.clone()); + app.manage(registry.clone()); app.manage(moves.clone()); moves.watch(app.handle().clone(), Arc::clone(&profile)); - foreground::watch(app.handle().clone(), Arc::clone(&profile), scanner.clone()); + foreground::watch( + app.handle().clone(), + Arc::clone(&profile), + scanner.clone(), + registry, + ); scanner.start(app.handle().clone()); // Native serial starts at app launch. P/R edges share GestureDetector diff --git a/apps/desktop/src-tauri/src/preset.rs b/apps/desktop/src-tauri/src/preset.rs index 4210e00..7bd7db4 100644 --- a/apps/desktop/src-tauri/src/preset.rs +++ b/apps/desktop/src-tauri/src/preset.rs @@ -1,96 +1,59 @@ -//! 앱별 칸 프리셋. +//! Hana Cloud 앱 프로필을 스캔 칸으로 바꾼다. //! -//! 4칸만으로는 아예 닿을 수 없는 동작이 있다. PDF 뷰어의 페이지 넘김은 -//! `PageDown`이고, 음악 앱의 재생 제어는 미디어 키다. 둘 다 `Tab`·`Enter`로는 -//! 도달할 수 없어서, 그 앱에서는 컨트롤러가 사실상 아무 쓸모가 없었다. -//! -//! 프리셋은 내장으로만 둔다. 사용자가 키 조합을 직접 편집하는 화면은 스위치 -//! 하나로 조작하기에 너무 복잡하다. 대신 설정에서 통째로 끌 수 있다. +//! 배포 데이터는 `app_registry`에서 검증한 뒤 이 경계를 통과한다. 이 모듈은 +//! 다운로드나 파일 접근을 하지 않고, 이미 메모리에 있는 shortcut만 해석한다. use crate::action::{Action, Cell, Kind}; +use crate::app_registry::RegistryPreset; use crate::shortcut; -/// 앱별 칸의 상한. -/// -/// 칸이 늘면 한 바퀴가 길어지고 최악 대기가 그만큼 늘어난다. 머무름 덕분에 -/// 같은 칸을 반복해 쓰는 값은 싸지만, 처음 그 칸에 닿는 값은 매번 치른다. +/// 앱별 칸의 상한. 한 바퀴가 지나치게 길어지지 않게 registry schema와 함께 +/// 같은 값으로 고정한다. pub const MAX_EXTRAS: usize = 3; -/// 한 앱에 붙일 칸의 정의. `keys`는 `shortcut::parse`가 읽는 표기다. struct Extra { - label: &'static str, - name: &'static str, - keys: &'static str, + label: String, + name: String, + shortcut: shortcut::Shortcut, } -pub struct Preset { - /// 상태 줄에 그대로 뜨는 이름. - pub name: &'static str, - /// macOS 번들 식별자. - bundle_ids: &'static [&'static str], - extras: &'static [Extra], +pub struct PresetSelection { + pub key: String, + pub registry_id: String, + pub name: String, + extras: Vec, } -/// 내장 프리셋. -/// -/// 되돌릴 수 없는 동작은 넣지 않는다(PRD 원칙 3). PDF의 '파일 열기'는 파일 -/// 선택 대화상자를 띄우는데, 그 대화상자는 4칸으로 빠져나올 수 없어서 뺐다. -static PRESETS: &[Preset] = &[ - Preset { - name: "PDF 뷰어", - bundle_ids: &["com.apple.Preview", "com.adobe.Reader"], - extras: &[ - // 서로 되돌리기가 되도록 붙여 둔다. 잘못 넘겼을 때 한 칸만 - // 움직이면 되돌아온다. - Extra { - label: "다음 장", - name: "페이지 넘기기", - keys: "pagedown", - }, - Extra { - label: "이전 장", - name: "앞 페이지로", - keys: "pageup", - }, - ], - }, - Preset { - name: "음악 앱", - bundle_ids: &["com.apple.Music", "com.spotify.client"], - extras: &[ - Extra { - label: "재생/멈춤", - name: "한 번 더 누르면 되돌아옴", - keys: "mediaplaypause", - }, - Extra { - label: "다음 곡", - name: "다음 곡으로", - keys: "medianexttrack", - }, - Extra { - label: "이전 곡", - name: "이전 곡으로", - keys: "mediaprevtrack", - }, - ], - }, -]; - -/// 이 앱에 붙일 프리셋. -pub fn for_bundle(bundle_id: &str) -> Option<&'static Preset> { - PRESETS - .iter() - .find(|preset| preset.bundle_ids.contains(&bundle_id)) +impl PresetSelection { + pub fn from_registry(preset: RegistryPreset) -> Option { + let extras: Vec<_> = preset + .actions + .into_iter() + .take(MAX_EXTRAS) + .filter_map(|action| { + shortcut::parse(&action.shortcut).map(|shortcut| Extra { + label: action.label, + name: action.name, + shortcut, + }) + }) + .collect(); + if extras.is_empty() { + return None; + } + + Some(Self { + key: format!("hana-cloud:{}:{}", preset.id, preset.sha256), + registry_id: preset.id, + name: preset.name, + extras, + }) + } } -/// 앞 4칸에 프리셋의 칸을 붙인 전체 스캔 순서. -/// -/// 해석하지 못한 키는 칸을 만들지 않는다. 없는 키를 아무 키로 대신 보내면 -/// 사용자는 익힌 자리에서 다른 동작을 겪게 된다. -pub fn cells_for(preset: Option<&Preset>) -> Vec { +/// 앞 4칸에 프로필의 칸을 붙인 전체 스캔 순서. +pub fn cells_for(preset: Option<&PresetSelection>) -> Vec { let mut cells = crate::action::base_cells(); - let Some(preset) = preset else { return cells; }; @@ -100,27 +63,16 @@ pub fn cells_for(preset: Option<&Preset>) -> Vec { .iter() .position(|cell| cell.kind == Kind::Settings) .unwrap_or(cells.len()); - - let mut at = settings; - for extra in preset.extras.iter().take(MAX_EXTRAS) { - match shortcut::parse(extra.keys) { - Some(parsed) => { - cells.insert( - at, - Cell::new( - extra.label, - extra.name, - Kind::Extra, - Action::Shortcut(parsed), - ), - ); - at += 1; - } - None => eprintln!( - "'{}' 프리셋의 '{}' 칸을 만들지 못했습니다. 키 표기 '{}'를 해석할 수 없습니다.", - preset.name, extra.label, extra.keys + for (offset, extra) in preset.extras.iter().enumerate() { + cells.insert( + settings + offset, + Cell::new( + &extra.label, + &extra.name, + Kind::Extra, + Action::Shortcut(extra.shortcut.clone()), ), - } + ); } cells @@ -129,70 +81,61 @@ pub fn cells_for(preset: Option<&Preset>) -> Vec { #[cfg(test)] mod tests { use super::*; + use crate::app_registry::ResolvedAction; + + fn registry_preset() -> RegistryPreset { + RegistryPreset { + id: "pdf-viewer".into(), + name: "PDF 뷰어".into(), + sha256: "7".repeat(64), + actions: vec![ + ResolvedAction { + label: "다음 장".into(), + name: "페이지 넘기기".into(), + shortcut: "pagedown".into(), + }, + ResolvedAction { + label: "이전 장".into(), + name: "앞 페이지로".into(), + shortcut: "pageup".into(), + }, + ], + } + } #[test] - fn 프리셋이_없으면_앞_네_칸_그대로다() { + fn 프로필이_없으면_앞_네_칸_그대로다() { assert_eq!(cells_for(None).len(), 4); } #[test] - fn pdf_뷰어는_페이지_넘김_두_칸을_붙인다() { - let cells = cells_for(for_bundle("com.apple.Preview")); + fn registry_action을_순서대로_붙인다() { + let selection = PresetSelection::from_registry(registry_preset()).unwrap(); + let cells = cells_for(Some(&selection)); + assert_eq!(cells.len(), 6); assert_eq!(cells[3].label, "다음 장"); assert_eq!(cells[4].label, "이전 장"); + assert_eq!(selection.registry_id, "pdf-viewer"); + assert!(selection.key.contains(&"7".repeat(64))); } #[test] fn 앱별_칸을_붙여도_설정이_맨_끝이다() { - for bundle in ["com.apple.Preview", "com.apple.Music"] { - let cells = cells_for(for_bundle(bundle)); - assert_eq!(cells.last().map(|cell| cell.kind), Some(Kind::Settings)); - } + let selection = PresetSelection::from_registry(registry_preset()).unwrap(); + let cells = cells_for(Some(&selection)); + assert_eq!(cells.last().map(|cell| cell.kind), Some(Kind::Settings)); } #[test] - fn 음악_앱은_재생_제어_세_칸을_붙인다() { - let cells = cells_for(for_bundle("com.spotify.client")); - assert_eq!(cells.len(), 7); - } - - #[test] - fn 기본_칸은_어떤_프리셋에서도_같은_순서다() { - let base = crate::action::base_cells(); - for bundle in ["com.apple.Preview", "com.apple.Music"] { - let cells = cells_for(for_bundle(bundle)); - let kept: Vec<_> = cells - .iter() - .filter(|cell| cell.kind != Kind::Extra) - .cloned() - .collect(); - assert_eq!(kept, base); - } - } - - #[test] - fn 모르는_앱에는_붙이지_않는다() { - assert!(for_bundle("com.example.unknown").is_none()); - } - - #[test] - fn 내장_프리셋의_키는_모두_해석된다() { - // 해석되지 않는 표기를 넣어 두면 그 칸이 조용히 사라진다. - for preset in PRESETS { - assert!( - preset.extras.len() <= MAX_EXTRAS, - "'{}'의 칸이 상한을 넘었습니다", - preset.name - ); - for extra in preset.extras { - assert!( - shortcut::parse(extra.keys).is_some(), - "'{}'의 '{}' 키 표기를 해석할 수 없습니다", - preset.name, - extra.keys - ); - } - } + fn 해석할_수_없는_action뿐이면_프로필을_적용하지_않는다() { + let mut preset = registry_preset(); + preset.actions = vec![ResolvedAction { + label: "위험".into(), + name: "알 수 없는 키".into(), + shortcut: "shell+command".into(), + }]; + + assert!(PresetSelection::from_registry(preset).is_none()); } } diff --git a/apps/desktop/src-tauri/src/scan.rs b/apps/desktop/src-tauri/src/scan.rs index 5a5f999..2debade 100644 --- a/apps/desktop/src-tauri/src/scan.rs +++ b/apps/desktop/src-tauri/src/scan.rs @@ -17,6 +17,7 @@ //! 인자로 받는 순수 로직이라 단위 테스트로 고정할 수 있고, 키 주입은 락을 //! 놓은 뒤에 실행할 수 있다. +use std::collections::HashSet; use std::sync::{Arc, Mutex, OnceLock}; use std::thread; use std::time::{Duration, Instant}; @@ -26,7 +27,9 @@ use tauri::{AppHandle, Emitter, Manager}; use crate::action::{Action, Cell, Kind}; use crate::adapt::{Adapter, Adjustment, Limits, Sample}; +use crate::app_registry::Registry; use crate::audio::{Audio, Cue}; +use crate::focused_application::FocusedApplication; use crate::input::{Gesture, Judgement}; use crate::journal::{Event, Journal}; use crate::led::LedBridge; @@ -169,7 +172,12 @@ struct State { /// 선택한 직후에는 다시 옮기는 일이 가장 잦다. 그래서 `Enter`를 누르면 /// 이어서 순환하는 대신 첫 이동 칸부터 다시 시작한다. restart_next: bool, + /// 표시 이름과 별개인 프로필 버전 키. 같은 앱 프로필의 SHA가 바뀌어도 + /// 새 칸으로 갈아 끼우기 위해 쓴다. + preset_key: Option, preset: Option, + /// Hana Cloud 인식 완료 문구는 한 세션에 프로필 ID당 한 번만 알린다. + recognized_registry: HashSet, mode: Mode, interval: Duration, /// 현재 모드가 끝나는 시각. @@ -197,7 +205,9 @@ impl State { cells, order, restart_next: false, + preset_key: None, preset: None, + recognized_registry: HashSet::new(), mode: Mode::Scanning, interval, deadline: now + interval, @@ -237,9 +247,16 @@ impl State { /// /// 커서를 첫 칸으로 되돌린다. 칸 수가 달라진 자리에 커서를 그대로 두면 /// 사용자가 보고 있던 칸과 실제로 실행될 칸이 어긋난다. - fn apply_cells(&mut self, cells: Vec, preset: Option, now: Instant) { + fn apply_cells( + &mut self, + cells: Vec, + preset_key: Option, + preset: Option, + now: Instant, + ) { self.order = crate::action::scan_order(&cells); self.cells = cells; + self.preset_key = preset_key; self.preset = preset; self.position = 0; self.restart_next = false; @@ -497,7 +514,12 @@ impl Scanner { /// /// 바뀐 것이 없으면 아무것도 하지 않는다. 이 함수는 300ms마다 불리므로 /// 매번 상태를 갈아엎으면 커서가 제자리를 맴돌아 스캔이 진행되지 않는다. - pub fn apply_preset(&self, app: &AppHandle, bundle_id: Option<&str>) { + pub fn apply_preset( + &self, + app: &AppHandle, + focused: Option<&FocusedApplication>, + registry: &Registry, + ) { let enabled = self .profile .lock() @@ -505,28 +527,37 @@ impl Scanner { .unwrap_or(false); let preset = if enabled { - bundle_id.and_then(crate::preset::for_bundle) + focused + .and_then(|focused| registry.lookup(focused)) + .and_then(crate::preset::PresetSelection::from_registry) } else { None }; - let name = preset.map(|preset| preset.name.to_string()); + let key = preset.as_ref().map(|preset| preset.key.clone()); + let name = preset.as_ref().map(|preset| preset.name.clone()); + let registry_id = preset.as_ref().map(|preset| preset.registry_id.clone()); + let cells = crate::preset::cells_for(preset.as_ref()); - let snapshot = { + let (snapshot, first_recognition) = { let Ok(mut state) = self.state.lock() else { return; }; - if state.preset == name { + if state.preset_key == key { return; } + let first_recognition = registry_id + .as_ref() + .is_some_and(|id| state.recognized_registry.insert(id.clone())); let now = Instant::now(); - state.apply_cells(crate::preset::cells_for(preset), name.clone(), now); - state.snapshot(now) + state.apply_cells(cells, key, name.clone(), now); + (state.snapshot(now), first_recognition) }; - let message = match &name { - Some(name) => format!("{name} — 버튼 {}개 추가", snapshot.cells.len() - 4), - None => "앱별 버튼 없음".to_string(), - }; + let message = preset_message( + name.as_deref(), + snapshot.cells.len().saturating_sub(4), + first_recognition, + ); if log_enabled() { eprintln!("[preset] {message} (칸 {})", snapshot.cells.len()); @@ -739,6 +770,16 @@ impl Scanner { } } +fn preset_message(name: Option<&str>, extras: usize, first_recognition: bool) -> String { + match name { + Some(name) if first_recognition => { + format!("{name} 프로필 인식 완료 · 버튼 {extras}개 추가") + } + Some(name) => format!("{name} — 버튼 {extras}개 추가"), + None => "앱별 버튼 없음".to_string(), + } +} + /// `HANBEON_LOG=1`이면 상태 전이와 실행 결과를 stderr로 찍는다. /// /// 정량 검증(PRD 10절)에는 어차피 이벤트 기록이 필요하다. 지금은 진단용 @@ -817,6 +858,18 @@ mod tests { (State::new(&fixed_profile(), now), now) } + #[test] + fn hana_cloud_프로필은_처음에만_인식_완료로_알린다() { + assert_eq!( + preset_message(Some("PDF 뷰어"), 2, true), + "PDF 뷰어 프로필 인식 완료 · 버튼 2개 추가" + ); + assert_eq!( + preset_message(Some("PDF 뷰어"), 2, false), + "PDF 뷰어 — 버튼 2개 추가" + ); + } + /// 커서를 원하는 칸으로 옮긴다. fn move_to(state: &mut State, target: Action, start: Instant) -> Instant { let mut now = start; diff --git a/apps/desktop/src/components/settings/SettingsForm.tsx b/apps/desktop/src/components/settings/SettingsForm.tsx index 422f076..6931467 100644 --- a/apps/desktop/src/components/settings/SettingsForm.tsx +++ b/apps/desktop/src/components/settings/SettingsForm.tsx @@ -199,7 +199,7 @@ export function SettingsForm({ initial }: { initial: Profile }) {
- PDF 뷰어에서는 다음 장·이전 장이, 음악 앱에서는 재생/멈춤·다음 곡·이전 - 곡이 붙습니다. 칸이 늘면 한 바퀴가 길어지므로, 쓰지 않는다면 꺼 두는 - 편이 빠릅니다. + 기본 프로필에는 PDF 뷰어의 다음 장·이전 장과 음악 앱의 재생/멈춤·다음 + 곡·이전 곡이 있습니다. 칸이 늘면 한 바퀴가 길어지므로, 쓰지 않는다면 + 꺼 두는 편이 빠릅니다.
diff --git a/docs/PRD.md b/docs/PRD.md index e488f08..869425a 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -119,7 +119,7 @@ PC·태블릿 사용. 경수 손상으로 손가락 미세 조작은 어렵지 - **앱 전환** — 스위치만으로 다른 앱으로 넘어가기. MVP에서는 보호자가 대신 띄워 준다고 전제한다(아래 5.4). -- 사용자가 직접 만드는 앱별 칸(내장 프리셋만 제공) — M4 이후 +- 사용자가 직접 만드는 앱별 칸(Hana Cloud의 검토된 프로필만 제공) — M4 이후 - 마우스 포인터를 쓰는 명령 세트(위·아래·좌·우) — M4 이후 - Android(AccessibilityService + 오버레이) - 프로필 서버 동기화 — MVP는 로컬 저장만 제공 @@ -426,11 +426,12 @@ M4 이후에 자주 쓰는 앱을 바로 여는 칸을 검토한다. 지금 넣 음악 앱의 재생 제어는 미디어 키다. 둘 다 `Tab`·`Enter`로는 도달할 수 없어, 그 앱에서 컨트롤러는 사실상 쓸모가 없었다. -Wave 2의 활성 앱 조회는 macOS bundle ID, Windows 실행 파일 basename, Linux X11 -desktop ID와 `WM_CLASS`를 원시 값으로 읽는다. 기존 macOS 내장 프리셋은 bundle ID를 -계속 사용한다. Windows·Linux X11 식별자를 프리셋으로 인식·적용하거나 레지스트리를 -다운로드·캐싱하는 일은 이 Wave에 포함하지 않는다. Linux Wayland에서 안전한 -compositor/desktop 어댑터가 없으면 앱별 프리셋을 선택하지 않고 기본 4칸을 유지한다. +활성 앱 조회는 macOS bundle ID, Windows 실행 파일 basename, Linux X11 desktop ID와 +`WM_CLASS`를 원시 값으로 읽는다. Hana Cloud의 `registry.json`이 이 식별자를 앱 +프로필과 연결하고, HanBeon은 HTTPS host·크기·schema·SHA-256을 검증한 마지막 정상본을 +캐시한다. 포커스 확인 루프에서는 네트워크나 디스크를 기다리지 않는다. Linux +Wayland에서 안전한 compositor/desktop 어댑터가 없으면 앱별 프로필을 선택하지 않고 +기본 4칸을 유지한다. - 지금 앞에 있는 앱에 맞춰 **기본 칸과 설정 사이에 최대 3칸**을 더 붙인다. - **기본 칸의 순서와 자리는 어떤 앱에서도 바뀌지 않는다.** 사용자는 자리로 @@ -446,7 +447,7 @@ compositor/desktop 어댑터가 없으면 앱별 프리셋을 선택하지 않 대화상자를 띄우는데 그 대화상자를 4칸으로 빠져나올 수 없어 넣지 않았다. - 설정에서 통째로 끌 수 있다(원칙 1). -**내장 프리셋** +**Hana Cloud 기본 프로필** | 앱 | 붙는 칸 | 보내는 키 | | --- | --- | --- |