diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..f70bad9 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,818 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "desktop-ctl" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "dirs", + "libc", + "serde", + "serde_json", + "tokio", + "uuid", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "libredox" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" +dependencies = [ + "libc", +] + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..ebc8eac --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "desktop-ctl" +version = "0.1.0" +edition = "2021" +description = "Desktop control CLI for AI agents - screenshot, click, type, window management on Linux X11" +license = "MIT OR Apache-2.0" +repository = "https://github.com/user/agent-computer" + +[dependencies] +clap = { version = "4", features = ["derive", "env"] } +tokio = { version = "1", features = ["full"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +anyhow = "1" +dirs = "6" +libc = "0.2" +uuid = { version = "1", features = ["v4"] } diff --git a/src/cli/connection.rs b/src/cli/connection.rs new file mode 100644 index 0000000..736e061 --- /dev/null +++ b/src/cli/connection.rs @@ -0,0 +1,164 @@ +use std::io::{BufRead, BufReader, Write}; +use std::os::unix::net::UnixStream; +use std::os::unix::process::CommandExt; +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::thread; +use std::time::Duration; + +use anyhow::{bail, Context, Result}; + +use crate::cli::GlobalOpts; +use crate::core::protocol::{Request, Response}; + +fn socket_dir() -> PathBuf { + if let Ok(dir) = std::env::var("DESKTOP_CTL_SOCKET_DIR") { + return PathBuf::from(dir); + } + if let Ok(runtime) = std::env::var("XDG_RUNTIME_DIR") { + return PathBuf::from(runtime).join("desktop-ctl"); + } + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from("/tmp")) + .join(".desktop-ctl") +} + +fn socket_path(opts: &GlobalOpts) -> PathBuf { + if let Some(ref path) = opts.socket { + return path.clone(); + } + socket_dir().join(format!("{}.sock", opts.session)) +} + +fn pid_path(opts: &GlobalOpts) -> PathBuf { + socket_dir().join(format!("{}.pid", opts.session)) +} + +fn try_connect(opts: &GlobalOpts) -> Option { + UnixStream::connect(socket_path(opts)).ok() +} + +fn spawn_daemon(opts: &GlobalOpts) -> Result<()> { + let exe = std::env::current_exe() + .context("Failed to determine executable path")?; + + let sock_dir = socket_dir(); + std::fs::create_dir_all(&sock_dir) + .context("Failed to create socket directory")?; + + let mut cmd = Command::new(exe); + cmd.env("DESKTOP_CTL_DAEMON", "1") + .env("DESKTOP_CTL_SESSION", &opts.session) + .env("DESKTOP_CTL_SOCKET_PATH", socket_path(opts)) + .env("DESKTOP_CTL_PID_PATH", pid_path(opts)) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()); + + // Detach the daemon process on Unix + unsafe { + cmd.pre_exec(|| { + libc::setsid(); + Ok(()) + }); + } + + cmd.spawn().context("Failed to spawn daemon")?; + Ok(()) +} + +fn ensure_daemon(opts: &GlobalOpts) -> Result { + // Try connecting first + if let Some(stream) = try_connect(opts) { + return Ok(stream); + } + + // Spawn daemon + spawn_daemon(opts)?; + + // Retry with backoff + let max_retries = 20; + let base_delay = Duration::from_millis(50); + for i in 0..max_retries { + thread::sleep(base_delay * (i + 1).min(4)); + if let Some(stream) = try_connect(opts) { + return Ok(stream); + } + } + + bail!( + "Failed to connect to daemon after {} retries.\n\ + Socket path: {}", + max_retries, + socket_path(opts).display() + ); +} + +pub fn send_command(opts: &GlobalOpts, request: &Request) -> Result { + let mut stream = ensure_daemon(opts)?; + stream.set_read_timeout(Some(Duration::from_secs(30)))?; + stream.set_write_timeout(Some(Duration::from_secs(5)))?; + + // Send NDJSON request + let json = serde_json::to_string(request)?; + writeln!(stream, "{json}")?; + stream.flush()?; + + // Read NDJSON response + let mut reader = BufReader::new(&stream); + let mut line = String::new(); + reader.read_line(&mut line)?; + + let response: Response = serde_json::from_str(line.trim()) + .context("Failed to parse daemon response")?; + + Ok(response) +} + +pub fn start_daemon(opts: &GlobalOpts) -> Result<()> { + if try_connect(opts).is_some() { + println!("Daemon already running ({})", socket_path(opts).display()); + return Ok(()); + } + spawn_daemon(opts)?; + // Wait briefly and verify + thread::sleep(Duration::from_millis(200)); + if try_connect(opts).is_some() { + println!("Daemon started ({})", socket_path(opts).display()); + } else { + bail!("Daemon failed to start"); + } + Ok(()) +} + +pub fn stop_daemon(opts: &GlobalOpts) -> Result<()> { + match try_connect(opts) { + Some(mut stream) => { + let req = Request::new("shutdown"); + let json = serde_json::to_string(&req)?; + writeln!(stream, "{json}")?; + stream.flush()?; + println!("Daemon stopped"); + } + None => { + // Try to clean up stale socket + let path = socket_path(opts); + if path.exists() { + std::fs::remove_file(&path)?; + println!("Removed stale socket: {}", path.display()); + } else { + println!("Daemon not running"); + } + } + } + Ok(()) +} + +pub fn daemon_status(opts: &GlobalOpts) -> Result<()> { + if try_connect(opts).is_some() { + println!("Daemon running ({})", socket_path(opts).display()); + } else { + println!("Daemon not running"); + } + Ok(()) +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs new file mode 100644 index 0000000..de9a889 --- /dev/null +++ b/src/cli/mod.rs @@ -0,0 +1,321 @@ +mod connection; + +use clap::{Args, Parser, Subcommand}; +use std::path::PathBuf; +use anyhow::Result; + +use crate::core::protocol::{Request, Response}; + +#[derive(Parser)] +#[command(name = "desktop-ctl", version, about = "Desktop control CLI for AI agents")] +pub struct App { + #[command(flatten)] + pub global: GlobalOpts, + #[command(subcommand)] + pub command: Command, +} + +#[derive(Args)] +pub struct GlobalOpts { + /// Path to the daemon Unix socket + #[arg(long, global = true, env = "DESKTOP_CTL_SOCKET")] + pub socket: Option, + + /// Session name (allows multiple daemon instances) + #[arg(long, global = true, default_value = "default")] + pub session: String, + + /// Output as JSON + #[arg(long, global = true)] + pub json: bool, +} + +#[derive(Subcommand)] +pub enum Command { + /// Take a screenshot and list windows with @wN refs + Snapshot { + /// Draw bounding boxes and labels on the screenshot + #[arg(long)] + annotate: bool, + }, + /// Click a window ref or coordinates + Click { + /// @w1 or x,y coordinates + selector: String, + }, + /// Double-click a window ref or coordinates + Dblclick { + /// @w1 or x,y coordinates + selector: String, + }, + /// Type text into the focused window + Type { + /// Text to type + text: String, + }, + /// Press a key (e.g. enter, tab, escape) + Press { + /// Key name + key: String, + }, + /// Send a hotkey combination (e.g. ctrl c) + Hotkey { + /// Key names (e.g. ctrl shift t) + keys: Vec, + }, + /// Mouse operations + #[command(subcommand)] + Mouse(MouseCmd), + /// Focus a window by ref or name + Focus { + /// @w1 or window name substring + selector: String, + }, + /// Close a window by ref or name + Close { + /// @w1 or window name substring + selector: String, + }, + /// Move a window + MoveWindow { + /// @w1 or window name substring + selector: String, + /// X position + x: i32, + /// Y position + y: i32, + }, + /// Resize a window + ResizeWindow { + /// @w1 or window name substring + selector: String, + /// Width + w: u32, + /// Height + h: u32, + }, + /// List all windows (same as snapshot but without screenshot) + ListWindows, + /// Get screen resolution + GetScreenSize, + /// Get current mouse position + GetMousePosition, + /// Take a screenshot without window tree + Screenshot { + /// Save path (default: /tmp/desktop-ctl-{timestamp}.png) + path: Option, + /// Draw bounding boxes and labels + #[arg(long)] + annotate: bool, + }, + /// Launch an application + Launch { + /// Command to run + command: String, + /// Arguments + #[arg(trailing_var_arg = true)] + args: Vec, + }, + /// Daemon management (hidden - internal use) + #[command(hide = true)] + Daemon(DaemonCmd), +} + +#[derive(Subcommand)] +pub enum MouseCmd { + /// Move the mouse cursor + Move { + /// X coordinate + x: i32, + /// Y coordinate + y: i32, + }, + /// Scroll the mouse wheel + Scroll { + /// Amount (positive = down, negative = up) + amount: i32, + /// Axis: vertical or horizontal + #[arg(long, default_value = "vertical")] + axis: String, + }, + /// Drag from one position to another + Drag { + /// Start X + x1: i32, + /// Start Y + y1: i32, + /// End X + x2: i32, + /// End Y + y2: i32, + }, +} + +#[derive(Args)] +pub struct DaemonCmd { + #[command(subcommand)] + pub action: DaemonAction, +} + +#[derive(Subcommand)] +pub enum DaemonAction { + /// Start the daemon + Start, + /// Stop the daemon + Stop, + /// Show daemon status + Status, +} + +pub fn run() -> Result<()> { + let app = App::parse(); + + // Handle daemon subcommands that don't need a running daemon + if let Command::Daemon(ref cmd) = app.command { + return match cmd.action { + DaemonAction::Start => connection::start_daemon(&app.global), + DaemonAction::Stop => connection::stop_daemon(&app.global), + DaemonAction::Status => connection::daemon_status(&app.global), + }; + } + + // All other commands need a daemon connection + let request = build_request(&app.command)?; + let response = connection::send_command(&app.global, &request)?; + + if app.global.json { + println!("{}", serde_json::to_string_pretty(&response)?); + } else { + print_response(&app.command, &response)?; + } + + Ok(()) +} + +fn build_request(cmd: &Command) -> Result { + use serde_json::json; + let req = match cmd { + Command::Snapshot { annotate } => { + Request::new("snapshot") + .with_extra("annotate", json!(annotate)) + } + Command::Click { selector } => { + Request::new("click") + .with_extra("selector", json!(selector)) + } + Command::Dblclick { selector } => { + Request::new("dblclick") + .with_extra("selector", json!(selector)) + } + Command::Type { text } => { + Request::new("type") + .with_extra("text", json!(text)) + } + Command::Press { key } => { + Request::new("press") + .with_extra("key", json!(key)) + } + Command::Hotkey { keys } => { + Request::new("hotkey") + .with_extra("keys", json!(keys)) + } + Command::Mouse(sub) => match sub { + MouseCmd::Move { x, y } => { + Request::new("mouse-move") + .with_extra("x", json!(x)) + .with_extra("y", json!(y)) + } + MouseCmd::Scroll { amount, axis } => { + Request::new("mouse-scroll") + .with_extra("amount", json!(amount)) + .with_extra("axis", json!(axis)) + } + MouseCmd::Drag { x1, y1, x2, y2 } => { + Request::new("mouse-drag") + .with_extra("x1", json!(x1)) + .with_extra("y1", json!(y1)) + .with_extra("x2", json!(x2)) + .with_extra("y2", json!(y2)) + } + }, + Command::Focus { selector } => { + Request::new("focus") + .with_extra("selector", json!(selector)) + } + Command::Close { selector } => { + Request::new("close") + .with_extra("selector", json!(selector)) + } + Command::MoveWindow { selector, x, y } => { + Request::new("move-window") + .with_extra("selector", json!(selector)) + .with_extra("x", json!(x)) + .with_extra("y", json!(y)) + } + Command::ResizeWindow { selector, w, h } => { + Request::new("resize-window") + .with_extra("selector", json!(selector)) + .with_extra("w", json!(w)) + .with_extra("h", json!(h)) + } + Command::ListWindows => Request::new("list-windows"), + Command::GetScreenSize => Request::new("get-screen-size"), + Command::GetMousePosition => Request::new("get-mouse-position"), + Command::Screenshot { path, annotate } => { + let mut req = Request::new("screenshot") + .with_extra("annotate", json!(annotate)); + if let Some(p) = path { + req = req.with_extra("path", json!(p.to_string_lossy())); + } + req + } + Command::Launch { command, args } => { + Request::new("launch") + .with_extra("command", json!(command)) + .with_extra("args", json!(args)) + } + Command::Daemon(_) => unreachable!(), + }; + Ok(req) +} + +fn print_response(cmd: &Command, response: &Response) -> Result<()> { + if !response.success { + if let Some(ref err) = response.error { + eprintln!("Error: {err}"); + } + std::process::exit(1); + } + if let Some(ref data) = response.data { + // For snapshot, print compact text format + if matches!(cmd, Command::Snapshot { .. }) { + if let Some(screenshot) = data.get("screenshot").and_then(|v| v.as_str()) { + println!("Screenshot: {screenshot}"); + } + if let Some(windows) = data.get("windows").and_then(|v| v.as_array()) { + println!("Windows:"); + for w in windows { + let ref_id = w.get("ref_id").and_then(|v| v.as_str()).unwrap_or("?"); + let title = w.get("title").and_then(|v| v.as_str()).unwrap_or(""); + let focused = w.get("focused").and_then(|v| v.as_bool()).unwrap_or(false); + let minimized = w.get("minimized").and_then(|v| v.as_bool()).unwrap_or(false); + let x = w.get("x").and_then(|v| v.as_i64()).unwrap_or(0); + let y = w.get("y").and_then(|v| v.as_i64()).unwrap_or(0); + let width = w.get("width").and_then(|v| v.as_u64()).unwrap_or(0); + let height = w.get("height").and_then(|v| v.as_u64()).unwrap_or(0); + let state = if focused { "focused" } else if minimized { "hidden" } else { "visible" }; + let display_title = if title.len() > 30 { + format!("{}...", &title[..27]) + } else { + title.to_string() + }; + println!("@{:<4} {:<30} ({:<7}) {},{} {}x{}", ref_id, display_title, state, x, y, width, height); + } + } + } else { + // Generic: print JSON data + println!("{}", serde_json::to_string_pretty(data)?); + } + } + Ok(()) +} diff --git a/src/core/mod.rs b/src/core/mod.rs new file mode 100644 index 0000000..95ab91e --- /dev/null +++ b/src/core/mod.rs @@ -0,0 +1,4 @@ +pub mod protocol; +pub mod refs; +pub mod session; +pub mod types; diff --git a/src/core/protocol.rs b/src/core/protocol.rs new file mode 100644 index 0000000..3a165c2 --- /dev/null +++ b/src/core/protocol.rs @@ -0,0 +1,49 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Serialize, Deserialize)] +pub struct Request { + pub id: String, + pub action: String, + #[serde(flatten)] + pub extra: Value, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Response { + pub success: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +impl Request { + pub fn new(action: &str) -> Self { + Self { + id: format!("r{}", std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_micros() % 1_000_000), + action: action.to_string(), + extra: Value::Object(serde_json::Map::new()), + } + } + + pub fn with_extra(mut self, key: &str, value: Value) -> Self { + if let Value::Object(ref mut map) = self.extra { + map.insert(key.to_string(), value); + } + self + } +} + +impl Response { + pub fn ok(data: Value) -> Self { + Self { success: true, data: Some(data), error: None } + } + + pub fn err(msg: impl Into) -> Self { + Self { success: false, data: None, error: Some(msg.into()) } + } +} diff --git a/src/core/refs.rs b/src/core/refs.rs new file mode 100644 index 0000000..a18d59a --- /dev/null +++ b/src/core/refs.rs @@ -0,0 +1,75 @@ +use std::collections::HashMap; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[allow(dead_code)] +pub struct RefEntry { + pub xcb_id: u32, + pub app_class: String, + pub title: String, + pub pid: u32, + pub x: i32, + pub y: i32, + pub width: u32, + pub height: u32, + pub focused: bool, + pub minimized: bool, +} + +#[derive(Debug, Default)] +#[allow(dead_code)] +pub struct RefMap { + map: HashMap, + next_ref: usize, +} + +#[allow(dead_code)] +impl RefMap { + pub fn new() -> Self { + Self { map: HashMap::new(), next_ref: 1 } + } + + pub fn clear(&mut self) { + self.map.clear(); + self.next_ref = 1; + } + + pub fn insert(&mut self, entry: RefEntry) -> String { + let ref_id = format!("w{}", self.next_ref); + self.next_ref += 1; + self.map.insert(ref_id.clone(), entry); + ref_id + } + + /// Resolve a selector to a RefEntry. + /// Accepts: "@w1", "w1", "ref=w1", or a substring match on app_class/title. + pub fn resolve(&self, selector: &str) -> Option<&RefEntry> { + let normalized = selector + .strip_prefix('@') + .or_else(|| selector.strip_prefix("ref=")) + .unwrap_or(selector); + + // Try direct ref lookup + if let Some(entry) = self.map.get(normalized) { + return Some(entry); + } + + // Try substring match on app_class or title (case-insensitive) + let lower = selector.to_lowercase(); + self.map.values().find(|e| { + e.app_class.to_lowercase().contains(&lower) + || e.title.to_lowercase().contains(&lower) + }) + } + + /// Resolve a selector to the center coordinates of the window. + pub fn resolve_to_center(&self, selector: &str) -> Option<(i32, i32)> { + self.resolve(selector).map(|e| { + (e.x + e.width as i32 / 2, e.y + e.height as i32 / 2) + }) + } + + pub fn entries(&self) -> impl Iterator { + self.map.iter() + } +} diff --git a/src/core/session.rs b/src/core/session.rs new file mode 100644 index 0000000..77b7cb3 --- /dev/null +++ b/src/core/session.rs @@ -0,0 +1,45 @@ +use anyhow::{bail, Result}; + +pub enum SessionType { + X11, +} + +pub fn detect_session() -> Result { + let session_type = std::env::var("XDG_SESSION_TYPE").unwrap_or_default(); + + match session_type.as_str() { + "x11" => {} + "" => { + // No XDG_SESSION_TYPE set - check for DISPLAY as fallback + if std::env::var("DISPLAY").is_err() { + bail!( + "No X11 session detected.\n\ + XDG_SESSION_TYPE is not set and DISPLAY is not set.\n\ + desktop-ctl requires an X11 session. Wayland support coming in v0.2." + ); + } + } + "wayland" => { + bail!( + "Wayland session detected (XDG_SESSION_TYPE=wayland).\n\ + desktop-ctl currently supports X11 only. Wayland/Hyprland support coming in v0.2." + ); + } + other => { + bail!( + "Unsupported session type: {other}\n\ + desktop-ctl currently supports X11 only." + ); + } + } + + // Confirm DISPLAY is set for X11 + if std::env::var("DISPLAY").is_err() { + bail!( + "X11 session detected but DISPLAY is not set.\n\ + Ensure your X server is running and DISPLAY is exported." + ); + } + + Ok(SessionType::X11) +} diff --git a/src/core/types.rs b/src/core/types.rs new file mode 100644 index 0000000..3c6d36b --- /dev/null +++ b/src/core/types.rs @@ -0,0 +1,55 @@ +use serde::{Deserialize, Serialize}; + +#[allow(dead_code)] +#[derive(Debug, Serialize, Deserialize)] +pub struct Snapshot { + pub screenshot: String, + pub windows: Vec, +} + +#[allow(dead_code)] +#[derive(Debug, Serialize, Deserialize)] +pub struct WindowInfo { + pub ref_id: String, + pub xcb_id: u32, + pub title: String, + pub app_name: String, + pub x: i32, + pub y: i32, + pub width: u32, + pub height: u32, + pub focused: bool, + pub minimized: bool, +} + +impl std::fmt::Display for WindowInfo { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let state = if self.focused { + "focused" + } else if self.minimized { + "hidden" + } else { + "visible" + }; + write!( + f, + "@{:<4} {:<30} ({:<7}) {},{} {}x{}", + self.ref_id, + truncate(&self.title, 30), + state, + self.x, + self.y, + self.width, + self.height + ) + } +} + +#[allow(dead_code)] +fn truncate(s: &str, max: usize) -> String { + if s.len() <= max { + s.to_string() + } else { + format!("{}...", &s[..max - 3]) + } +} diff --git a/src/daemon/handler.rs b/src/daemon/handler.rs new file mode 100644 index 0000000..86e1a5f --- /dev/null +++ b/src/daemon/handler.rs @@ -0,0 +1,31 @@ +use std::sync::Arc; +use tokio::sync::Mutex; + +use crate::core::protocol::{Request, Response}; +use super::state::DaemonState; + +pub async fn handle_request( + request: &Request, + _state: &Arc>, +) -> Response { + match request.action.as_str() { + "snapshot" => { + Response::ok(serde_json::json!({ + "screenshot": "/tmp/desktop-ctl-placeholder.png", + "windows": [ + { + "ref_id": "w1", + "xcb_id": 0, + "title": "Placeholder Window", + "app_name": "placeholder", + "x": 0, "y": 0, "width": 1920, "height": 1080, + "focused": true, "minimized": false + } + ] + })) + } + action => { + Response::err(format!("Unknown action: {action}")) + } + } +} diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs new file mode 100644 index 0000000..32ff17c --- /dev/null +++ b/src/daemon/mod.rs @@ -0,0 +1,127 @@ +mod handler; +mod state; + +use std::path::PathBuf; +use std::sync::Arc; + +use anyhow::{Context, Result}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::UnixListener; +use tokio::sync::Mutex; + +use crate::core::session; +use state::DaemonState; + +pub fn run() -> Result<()> { + // Validate session before starting + session::detect_session()?; + + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()?; + + runtime.block_on(async_run()) +} + +async fn async_run() -> Result<()> { + let socket_path = std::env::var("DESKTOP_CTL_SOCKET_PATH") + .map(PathBuf::from) + .context("DESKTOP_CTL_SOCKET_PATH not set")?; + + let pid_path = std::env::var("DESKTOP_CTL_PID_PATH") + .map(PathBuf::from) + .ok(); + + // Clean up stale socket + if socket_path.exists() { + std::fs::remove_file(&socket_path)?; + } + + // Write PID file + if let Some(ref pid_path) = pid_path { + std::fs::write(pid_path, std::process::id().to_string())?; + } + + let listener = UnixListener::bind(&socket_path) + .context(format!("Failed to bind socket: {}", socket_path.display()))?; + + let session = std::env::var("DESKTOP_CTL_SESSION").unwrap_or_else(|_| "default".to_string()); + let state = Arc::new(Mutex::new(DaemonState::new(session, socket_path.clone()))); + + let shutdown = Arc::new(tokio::sync::Notify::new()); + let shutdown_clone = shutdown.clone(); + + // Accept loop + loop { + tokio::select! { + result = listener.accept() => { + match result { + Ok((stream, _addr)) => { + let state = state.clone(); + let shutdown = shutdown.clone(); + tokio::spawn(async move { + if let Err(e) = handle_connection(stream, state, shutdown).await { + eprintln!("Connection error: {e}"); + } + }); + } + Err(e) => { + eprintln!("Accept error: {e}"); + } + } + } + _ = shutdown_clone.notified() => { + break; + } + } + } + + // Cleanup + if socket_path.exists() { + let _ = std::fs::remove_file(&socket_path); + } + if let Some(ref pid_path) = pid_path { + let _ = std::fs::remove_file(pid_path); + } + + Ok(()) +} + +async fn handle_connection( + stream: tokio::net::UnixStream, + state: Arc>, + shutdown: Arc, +) -> Result<()> { + let (reader, mut writer) = stream.into_split(); + let mut reader = BufReader::new(reader); + let mut line = String::new(); + + reader.read_line(&mut line).await?; + let line = line.trim(); + if line.is_empty() { + return Ok(()); + } + + let request: crate::core::protocol::Request = serde_json::from_str(line)?; + + // Handle shutdown specially - notify before writing so the accept loop + // exits even if the client has already closed the connection. + if request.action == "shutdown" { + shutdown.notify_one(); + let response = crate::core::protocol::Response::ok( + serde_json::json!({"message": "Shutting down"}) + ); + let json = serde_json::to_string(&response)?; + // Ignore write errors - client may have already closed the connection. + let _ = writer.write_all(format!("{json}\n").as_bytes()).await; + let _ = writer.flush().await; + return Ok(()); + } + + let response = handler::handle_request(&request, &state).await; + let json = serde_json::to_string(&response)?; + writer.write_all(format!("{json}\n").as_bytes()).await?; + writer.flush().await?; + + Ok(()) +} diff --git a/src/daemon/state.rs b/src/daemon/state.rs new file mode 100644 index 0000000..bd4de1c --- /dev/null +++ b/src/daemon/state.rs @@ -0,0 +1,19 @@ +use std::path::PathBuf; +use crate::core::refs::RefMap; + +#[allow(dead_code)] +pub struct DaemonState { + pub session: String, + pub socket_path: PathBuf, + pub ref_map: RefMap, +} + +impl DaemonState { + pub fn new(session: String, socket_path: PathBuf) -> Self { + Self { + session, + socket_path, + ref_map: RefMap::new(), + } + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..2bbfe11 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,10 @@ +mod cli; +mod core; +mod daemon; + +fn main() -> anyhow::Result<()> { + if std::env::var("DESKTOP_CTL_DAEMON").is_ok() { + return daemon::run(); + } + cli::run() +}