├── .github ├── FUNDING.yml └── workflows │ ├── ci.yaml │ └── release.yaml ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── Justfile ├── LICENSE ├── Readme.md ├── Release.md ├── flake.lock ├── flake.nix ├── package.nix └── src ├── access_point.rs ├── adapter.rs ├── app.rs ├── auth.rs ├── cli.rs ├── config.rs ├── device.rs ├── event.rs ├── handler.rs ├── help.rs ├── known_network.rs ├── lib.rs ├── main.rs ├── network.rs ├── notification.rs ├── rfkill.rs ├── station.rs ├── tui.rs └── ui.rs /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: pythops 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: CI 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - "*" 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: dtolnay/rust-toolchain@stable 14 | with: 15 | toolchain: stable 16 | components: clippy rustfmt 17 | 18 | - name: Linting 19 | run: | 20 | cargo clippy --workspace --all-features -- -D warnings 21 | cargo fmt --all -- --check 22 | 23 | - name: Debug builds 24 | run: cargo build 25 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Release 3 | on: 4 | push: 5 | tags: 6 | - v[0-9]+.* 7 | jobs: 8 | build: 9 | permissions: 10 | contents: write 11 | continue-on-error: false 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | 17 | - uses: cargo-bins/cargo-binstall@main 18 | 19 | - name: Install the dependencies 20 | run: | 21 | sudo apt update 22 | sudo apt install -y musl-tools podman binutils-aarch64-linux-gnu musl-tools 23 | cargo binstall --no-confirm cross 24 | 25 | - name: Build for x86_64 26 | run: | 27 | CROSS_CONTAINER_ENGINE=podman cross build --target=x86_64-unknown-linux-gnu --release 28 | cp target/x86_64-unknown-linux-gnu/release/impala impala-x86_64-unknown-linux-gnu 29 | strip impala-x86_64-unknown-linux-gnu 30 | 31 | - name: Build for arm64 32 | run: | 33 | CROSS_CONTAINER_ENGINE=podman cross build --target=aarch64-unknown-linux-gnu --release 34 | cp target/aarch64-unknown-linux-gnu/release/impala impala-aarch64-unknown-linux-gnu 35 | aarch64-linux-gnu-strip impala-aarch64-unknown-linux-gnu 36 | 37 | - name: Upload Binary 38 | uses: softprops/action-gh-release@v2 39 | with: 40 | files: "impala*" 41 | body: | 42 | [Release.md](${{ github.server_url }}/${{ github.repository }}/blob/main/Release.md) 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "impala" 3 | version = "0.2.4" 4 | authors = ["Badr Badri "] 5 | license = "GPL-3.0" 6 | edition = "2021" 7 | description = "TUI for managing wifi" 8 | readme = "Readme.md" 9 | homepage = "https://github.com/pythops/impala" 10 | repository = "https://github.com/pythops/impala" 11 | 12 | [dependencies] 13 | async-channel = "2" 14 | crossterm = { version = "0.28", default-features = false, features = [ 15 | "event-stream", 16 | ] } 17 | ratatui = "0.29" 18 | tui-input = "0.11" 19 | tokio = { version = "1", features = ["full"] } 20 | futures = "0.3" 21 | dirs = "5" 22 | serde = { version = "1", features = ["derive"] } 23 | toml = { version = "0.8" } 24 | clap = { version = "4", features = ["derive", "cargo"] } 25 | terminal-light = "1" 26 | anyhow = "1" 27 | iwdrs = "0.1" 28 | chrono = "0.4" 29 | log = "0.4" 30 | env_logger = "0.11" 31 | 32 | [profile.release] 33 | strip = true 34 | lto = "fat" 35 | codegen-units = 1 36 | -------------------------------------------------------------------------------- /Justfile: -------------------------------------------------------------------------------- 1 | default: 2 | @just --list 3 | 4 | run: 5 | cargo build && sudo -E target/debug/impala 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | 635 | Copyright (C) 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | Copyright (C) 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . 675 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 |
2 |

TUI for managing wifi

3 |
4 | 5 | ## 📸 Demo 6 | 7 | ![](https://github.com/pythops/impala/assets/57548585/b96e7af4-cba4-49c7-a36f-12c83839134d) 8 | 9 | ## 💡 Prerequisites 10 | 11 | A Linux based OS with [iwd](https://iwd.wiki.kernel.org/) installed. 12 | 13 | > [!NOTE] 14 | > You might need to install [nerdfonts](https://www.nerdfonts.com/) for the icons to be displayed correctly. 15 | 16 | ## 🚀 Installation 17 | 18 | ### 📥 Binary release 19 | 20 | You can download the pre-built binaries from the release page [release page](https://github.com/pythops/impala/releases) 21 | 22 | ### 📦 crates.io 23 | 24 | You can install `impala` from [crates.io](https://crates.io/crates/impala) 25 | 26 | ```shell 27 | cargo install impala 28 | ``` 29 | 30 | ### 🐧Arch Linux 31 | 32 | You can install `impala` from the [official repositories](https://archlinux.org/packages/extra/x86_64/impala/) with using [pacman](https://wiki.archlinux.org/title/pacman). 33 | 34 | ```bash 35 | pacman -S impala 36 | ``` 37 | 38 | ### Nixpkgs 39 | 40 | ```shell 41 | nix-env -iA nixpkgs.impala 42 | ``` 43 | 44 | ### ⚒️ Build from source 45 | 46 | Run the following command: 47 | 48 | ```shell 49 | git clone https://github.com/pythops/impala 50 | cd impala 51 | cargo build --release 52 | ``` 53 | 54 | This will produce an executable file at `target/release/impala` that you can copy to a directory in your `$PATH`. 55 | 56 | ## 🪄 Usage 57 | 58 | ### Global 59 | 60 | `Tab` or `Shift + Tab`: Switch between different sections. 61 | 62 | `j` or `Down` : Scroll down. 63 | 64 | `k` or `Up`: Scroll up. 65 | 66 | `ctrl+r`: Switch adapter mode. 67 | 68 | `?`: Show help. 69 | 70 | `esc`: Dismiss the different pop-ups. 71 | 72 | `q` or `ctrl+c`: Quit the app. 73 | 74 | ### Device 75 | 76 | `i`: Show device information. 77 | 78 | `o`: Toggle device power. 79 | 80 | ### Station 81 | 82 | `s`: Start scanning. 83 | 84 | `Space`: Connect/Disconnect the network. 85 | 86 | ### Known Networks 87 | 88 | `a`: Enable/Disable auto-connect. 89 | 90 | `d`: Remove the network from the known networks list. 91 | 92 | ### Access Point 93 | 94 | `n`: Start a new access point. 95 | 96 | `x`: Stop the running access point. 97 | 98 | ## Custom keybindings 99 | 100 | Keybindings can be customized in the config file `$HOME/.config/impala/config.toml` 101 | 102 | ```toml 103 | 104 | switch = "r" 105 | mode = "station" 106 | 107 | [device] 108 | infos = "i" 109 | toggle_power = "o" 110 | 111 | [access_point] 112 | start = 'n' 113 | stop = 'x' 114 | 115 | [station] 116 | toggle_scanning = "s" 117 | toggle_connect = " " 118 | 119 | [station.known_network] 120 | toggle_autoconnect = "a" 121 | remove = "d" 122 | ``` 123 | 124 | ## ⚖️ License 125 | 126 | GPLv3 127 | -------------------------------------------------------------------------------- /Release.md: -------------------------------------------------------------------------------- 1 | ## v0.2.4 - 2024-11-17 2 | 3 | ### Added 4 | 5 | - Detect when the device is soft/hard blocked 6 | 7 | ### Fix 8 | 9 | - fg color for light mode background 10 | 11 | ## v0.2.3 - 2024-08-29 12 | 13 | ### Changed 14 | 15 | - Remove uid check before starting the app. 16 | 17 | ## v0.2.2 - 2024-08-28 18 | 19 | ### Update 20 | 21 | - Responsive layout 22 | 23 | ### Changed 24 | 25 | - using stdout instead of stderr for the terminal handler 26 | - set tick rate to 2sec 27 | 28 | ## v0.2.1 - 2024-06-27 29 | 30 | ### Added 31 | 32 | - Signal strength in % 33 | - Show Security and Frequency for connected network 34 | - Choose startup mode from cli or config 35 | 36 | ## v0.2 - 2024-06-17 37 | 38 | ### Added 39 | 40 | - Access Point mode 41 | - Show connected devices on Access Point mode 42 | - Turn On/Off device 43 | - Switch between AP and Station mode 44 | - Enable/Disable auto connect for known networks 45 | 46 | ## v0.1.1 - 2024-06-10 47 | 48 | ### Fixed 49 | 50 | - Crash when the vendor or the model of the adapter are absent 51 | 52 | ## v0.1 - 2024-06-09 53 | 54 | First release 🎉 55 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "nixpkgs": { 4 | "locked": { 5 | "lastModified": 1721949857, 6 | "narHash": "sha256-DID446r8KsmJhbCzx4el8d9SnPiE8qa6+eEQOJ40vR0=", 7 | "owner": "nixos", 8 | "repo": "nixpkgs", 9 | "rev": "a1cc729dcbc31d9b0d11d86dc7436163548a9665", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "owner": "nixos", 14 | "ref": "nixos-24.05", 15 | "repo": "nixpkgs", 16 | "type": "github" 17 | } 18 | }, 19 | "root": { 20 | "inputs": { 21 | "nixpkgs": "nixpkgs" 22 | } 23 | } 24 | }, 25 | "root": "root", 26 | "version": 7 27 | } 28 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | inputs.nixpkgs.url = "github:nixos/nixpkgs/nixos-24.05"; 3 | 4 | outputs = 5 | { self, nixpkgs }: 6 | { 7 | packages = 8 | nixpkgs.lib.genAttrs 9 | [ 10 | "x86_64-linux" 11 | "aarch64-linux" 12 | ] 13 | (system: rec { 14 | impala = nixpkgs.legacyPackages.${system}.callPackage ./package.nix { }; 15 | default = impala; 16 | }); 17 | }; 18 | } 19 | -------------------------------------------------------------------------------- /package.nix: -------------------------------------------------------------------------------- 1 | { lib, rustPlatform }: 2 | 3 | let 4 | cargo = (lib.importTOML ./Cargo.toml).package; 5 | in 6 | rustPlatform.buildRustPackage { 7 | pname = cargo.name; 8 | version = cargo.version; 9 | 10 | src = ./.; 11 | 12 | cargoLock = { 13 | lockFile = ./Cargo.lock; 14 | }; 15 | 16 | meta = { 17 | description = cargo.description; 18 | homepage = cargo.homepage; 19 | license = lib.licenses.gpl3Only; 20 | maintainers = with lib.maintainers; [ samuel-martineau ]; 21 | }; 22 | } 23 | -------------------------------------------------------------------------------- /src/access_point.rs: -------------------------------------------------------------------------------- 1 | use std::sync::{atomic::AtomicBool, Arc}; 2 | 3 | use iwdrs::session::Session; 4 | use tokio::sync::mpsc::UnboundedSender; 5 | 6 | use ratatui::{ 7 | layout::{Alignment, Constraint, Direction, Layout}, 8 | style::{Color, Style, Stylize}, 9 | text::Text, 10 | widgets::{Block, BorderType, Borders, Clear, Padding, Paragraph}, 11 | Frame, 12 | }; 13 | 14 | use crate::{ 15 | app::AppResult, 16 | event::Event, 17 | notification::{Notification, NotificationLevel}, 18 | }; 19 | use tui_input::Input; 20 | 21 | #[derive(Debug, Clone, Copy, PartialEq)] 22 | pub enum APFocusedSection { 23 | SSID, 24 | PSK, 25 | } 26 | 27 | #[derive(Debug, Clone)] 28 | pub struct AccessPoint { 29 | session: Arc, 30 | pub has_started: bool, 31 | pub name: Option, 32 | pub frequency: Option, 33 | pub is_scanning: Option, 34 | pub supported_ciphers: Option>, 35 | pub used_cipher: Option, 36 | pub ap_start: Arc, 37 | pub ssid: Input, 38 | pub psk: Input, 39 | pub focused_section: APFocusedSection, 40 | pub connected_devices: Vec, 41 | } 42 | 43 | impl AccessPoint { 44 | pub async fn new(session: Arc) -> AppResult { 45 | let iwd_access_point = session.access_point().unwrap(); 46 | let iwd_access_point_diagnotic = session.access_point_diagnostic(); 47 | 48 | let has_started = iwd_access_point.has_started().await?; 49 | let name = iwd_access_point.name().await?; 50 | let frequency = iwd_access_point.frequency().await?; 51 | let is_scanning = iwd_access_point.is_scanning().await.ok(); 52 | let supported_ciphers = iwd_access_point.pairwise_ciphers().await?; 53 | let used_cipher = iwd_access_point.group_cipher().await?; 54 | let ap_start = Arc::new(AtomicBool::new(false)); 55 | 56 | let ssid = Input::default(); 57 | let psk = Input::default(); 58 | let focused_section = APFocusedSection::SSID; 59 | 60 | let connected_devices = { 61 | if let Some(d) = iwd_access_point_diagnotic { 62 | match d.get().await { 63 | Ok(diagnostic) => diagnostic 64 | .iter() 65 | .map(|v| v["Address"].clone().trim_matches('"').to_string()) 66 | .collect(), 67 | Err(_) => Vec::new(), 68 | } 69 | } else { 70 | Vec::new() 71 | } 72 | }; 73 | 74 | Ok(Self { 75 | session, 76 | has_started, 77 | name, 78 | frequency, 79 | is_scanning, 80 | supported_ciphers, 81 | used_cipher, 82 | ap_start, 83 | ssid, 84 | psk, 85 | focused_section, 86 | connected_devices, 87 | }) 88 | } 89 | 90 | pub fn render_input(&self, frame: &mut Frame) { 91 | let popup_layout = Layout::default() 92 | .direction(Direction::Vertical) 93 | .constraints( 94 | [ 95 | Constraint::Percentage(45), 96 | Constraint::Min(7), 97 | Constraint::Percentage(45), 98 | ] 99 | .as_ref(), 100 | ) 101 | .split(frame.area()); 102 | 103 | let area = Layout::default() 104 | .direction(Direction::Horizontal) 105 | .constraints( 106 | [ 107 | Constraint::Length((frame.area().width - 80) / 2), 108 | Constraint::Min(80), 109 | Constraint::Length((frame.area().width - 80) / 2), 110 | ] 111 | .as_ref(), 112 | ) 113 | .split(popup_layout[1])[1]; 114 | 115 | let ((ssid_msg_area, ssid_input_area), (psk_msg_area, psk_input_area)) = { 116 | let chunks = Layout::default() 117 | .direction(Direction::Vertical) 118 | .constraints( 119 | [ 120 | Constraint::Length(2), 121 | Constraint::Length(1), 122 | Constraint::Length(1), 123 | Constraint::Length(1), 124 | Constraint::Length(2), 125 | ] 126 | .as_ref(), 127 | ) 128 | .split(area); 129 | 130 | let ssid_chunks = Layout::default() 131 | .direction(Direction::Horizontal) 132 | .constraints( 133 | [ 134 | Constraint::Length(1), 135 | Constraint::Percentage(45), 136 | Constraint::Percentage(45), 137 | Constraint::Length(1), 138 | ] 139 | .as_ref(), 140 | ) 141 | .split(chunks[1]); 142 | 143 | let psk_chunks = Layout::default() 144 | .direction(Direction::Horizontal) 145 | .constraints( 146 | [ 147 | Constraint::Length(1), 148 | Constraint::Percentage(45), 149 | Constraint::Percentage(45), 150 | Constraint::Length(1), 151 | ] 152 | .as_ref(), 153 | ) 154 | .split(chunks[3]); 155 | 156 | ( 157 | (ssid_chunks[1], ssid_chunks[2]), 158 | (psk_chunks[1], psk_chunks[2]), 159 | ) 160 | }; 161 | 162 | let ssid_text = match self.focused_section { 163 | APFocusedSection::SSID => Text::from("> SSID name"), 164 | _ => Text::from(" SSID name"), 165 | }; 166 | 167 | let psk_text = match self.focused_section { 168 | APFocusedSection::PSK => Text::from("> SSID password"), 169 | _ => Text::from(" SSID password"), 170 | }; 171 | 172 | let ssid_msg = Paragraph::new(ssid_text) 173 | .alignment(Alignment::Left) 174 | .style(Style::default().fg(Color::White)) 175 | .block(Block::new().padding(Padding::left(2))); 176 | 177 | let ssid_input = Paragraph::new(self.ssid.value()) 178 | .alignment(Alignment::Center) 179 | .style(Style::default().fg(Color::White)) 180 | .block(Block::new().style(Style::default().bg(Color::DarkGray))); 181 | 182 | let psk_msg = Paragraph::new(psk_text) 183 | .alignment(Alignment::Left) 184 | .style(Style::default().fg(Color::White)) 185 | .block(Block::new().padding(Padding::left(2))); 186 | 187 | let psk_input = Paragraph::new(self.psk.value()) 188 | .alignment(Alignment::Center) 189 | .style(Style::default().fg(Color::White)) 190 | .block(Block::new().style(Style::default().bg(Color::DarkGray))); 191 | 192 | frame.render_widget(Clear, area); 193 | 194 | frame.render_widget( 195 | Block::new() 196 | .borders(Borders::ALL) 197 | .border_type(BorderType::Thick) 198 | .style(Style::default().green()) 199 | .border_style(Style::default().fg(Color::Green)), 200 | area, 201 | ); 202 | frame.render_widget(ssid_msg, ssid_msg_area); 203 | frame.render_widget(ssid_input, ssid_input_area); 204 | 205 | frame.render_widget(psk_msg, psk_msg_area); 206 | frame.render_widget(psk_input, psk_input_area); 207 | } 208 | 209 | pub async fn refresh(&mut self) -> AppResult<()> { 210 | let iwd_access_point = self.session.access_point().unwrap(); 211 | let iwd_access_point_diagnotic = self.session.access_point_diagnostic(); 212 | 213 | self.has_started = iwd_access_point.has_started().await?; 214 | self.name = iwd_access_point.name().await?; 215 | self.frequency = iwd_access_point.frequency().await?; 216 | self.is_scanning = iwd_access_point.is_scanning().await.ok(); 217 | self.supported_ciphers = iwd_access_point.pairwise_ciphers().await?; 218 | self.used_cipher = iwd_access_point.group_cipher().await?; 219 | 220 | if let Some(d) = iwd_access_point_diagnotic { 221 | if let Ok(diagnostic) = d.get().await { 222 | self.connected_devices = diagnostic 223 | .iter() 224 | .map(|v| v["Address"].clone().trim_matches('"').to_string()) 225 | .collect() 226 | } 227 | } 228 | 229 | Ok(()) 230 | } 231 | 232 | pub async fn scan(&self, sender: UnboundedSender) -> AppResult<()> { 233 | let iwd_access_point = self.session.access_point().unwrap(); 234 | match iwd_access_point.scan().await { 235 | Ok(_) => Notification::send( 236 | "Start Scanning".to_string(), 237 | NotificationLevel::Info, 238 | sender, 239 | )?, 240 | Err(e) => Notification::send(e.to_string(), NotificationLevel::Error, sender.clone())?, 241 | } 242 | 243 | Ok(()) 244 | } 245 | 246 | pub async fn start(&self, sender: UnboundedSender) -> AppResult<()> { 247 | let iwd_access_point = self.session.access_point().unwrap(); 248 | match iwd_access_point 249 | .start(self.ssid.value(), self.psk.value()) 250 | .await 251 | { 252 | Ok(_) => Notification::send( 253 | format!("AP Started\nSSID: {}", self.ssid.value()), 254 | NotificationLevel::Info, 255 | sender, 256 | )?, 257 | Err(e) => Notification::send(e.to_string(), NotificationLevel::Error, sender.clone())?, 258 | } 259 | self.ap_start 260 | .store(false, std::sync::atomic::Ordering::Relaxed); 261 | 262 | Ok(()) 263 | } 264 | 265 | pub async fn stop(&self, sender: UnboundedSender) -> AppResult<()> { 266 | let iwd_access_point = self.session.access_point().unwrap(); 267 | match iwd_access_point.stop().await { 268 | Ok(_) => Notification::send("AP Stopped".to_string(), NotificationLevel::Info, sender)?, 269 | Err(e) => Notification::send(e.to_string(), NotificationLevel::Error, sender.clone())?, 270 | } 271 | 272 | Ok(()) 273 | } 274 | } 275 | -------------------------------------------------------------------------------- /src/adapter.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use anyhow::Context; 4 | 5 | use iwdrs::{adapter::Adapter as iwdAdapter, modes::Mode, session::Session}; 6 | use ratatui::{ 7 | layout::{Alignment, Constraint, Direction, Flex, Layout}, 8 | style::{Color, Style, Stylize}, 9 | text::Line, 10 | widgets::{Block, BorderType, Borders, Cell, Clear, List, Padding, Row, Table, TableState}, 11 | Frame, 12 | }; 13 | use tokio::sync::mpsc::UnboundedSender; 14 | 15 | use crate::{ 16 | app::{AppResult, ColorMode, FocusedBlock}, 17 | device::Device, 18 | event::Event, 19 | }; 20 | 21 | #[derive(Debug, Clone)] 22 | pub struct Adapter { 23 | pub adapter: iwdAdapter, 24 | pub is_powered: bool, 25 | pub name: String, 26 | pub model: Option, 27 | pub vendor: Option, 28 | pub supported_modes: Vec, 29 | pub device: Device, 30 | } 31 | 32 | impl Adapter { 33 | pub async fn new(session: Arc, sender: UnboundedSender) -> AppResult { 34 | let adapter = session.adapter().context("No adapter found")?; 35 | 36 | let is_powered = adapter.is_powered().await?; 37 | let name = adapter.name().await?; 38 | let model = adapter.model().await.ok(); 39 | let vendor = adapter.vendor().await.ok(); 40 | let supported_modes = adapter.supported_modes().await?; 41 | let device = Device::new(session.clone(), sender).await?; 42 | 43 | Ok(Self { 44 | adapter, 45 | is_powered, 46 | name, 47 | model, 48 | vendor, 49 | supported_modes, 50 | device, 51 | }) 52 | } 53 | 54 | pub async fn refresh(&mut self, sender: UnboundedSender) -> AppResult<()> { 55 | self.is_powered = self.adapter.is_powered().await?; 56 | self.device.refresh(sender).await?; 57 | Ok(()) 58 | } 59 | 60 | pub fn render(&self, frame: &mut Frame, color_mode: ColorMode, focused_block: FocusedBlock) { 61 | match self.device.mode { 62 | Mode::Station => { 63 | if self.device.station.is_some() { 64 | self.render_station_mode(frame, color_mode, focused_block); 65 | } 66 | } 67 | Mode::Ap => { 68 | if self.device.access_point.is_some() { 69 | self.render_access_point_mode(frame, color_mode, focused_block); 70 | } 71 | } 72 | _ => {} 73 | } 74 | } 75 | 76 | pub fn render_access_point_mode( 77 | &self, 78 | frame: &mut Frame, 79 | color_mode: ColorMode, 80 | focused_block: FocusedBlock, 81 | ) { 82 | let any_connected_devices = match self.device.access_point.as_ref() { 83 | Some(ap) => !ap.connected_devices.is_empty(), 84 | None => false, 85 | }; 86 | 87 | let (device_block, access_point_block, connected_devices_block) = { 88 | let chunks = Layout::default() 89 | .direction(Direction::Vertical) 90 | .constraints(if any_connected_devices { 91 | &[ 92 | Constraint::Percentage(33), 93 | Constraint::Percentage(33), 94 | Constraint::Percentage(33), 95 | ] 96 | } else { 97 | &[ 98 | Constraint::Percentage(50), 99 | Constraint::Percentage(50), 100 | Constraint::Fill(1), 101 | ] 102 | }) 103 | .margin(1) 104 | .split(frame.area()); 105 | (chunks[0], chunks[1], chunks[2]) 106 | }; 107 | 108 | // Device 109 | let row = Row::new(vec![ 110 | Line::from(self.device.name.clone()).centered(), 111 | Line::from("Access Point").centered(), 112 | { 113 | if self.device.is_powered { 114 | Line::from("On").centered() 115 | } else { 116 | Line::from("Off").centered() 117 | } 118 | }, 119 | Line::from(self.device.address.clone()).centered(), 120 | ]); 121 | 122 | let widths = [ 123 | Constraint::Length(15), 124 | Constraint::Length(12), 125 | Constraint::Length(7), 126 | Constraint::Length(17), 127 | ]; 128 | 129 | let device_table = Table::new(vec![row], widths) 130 | .header({ 131 | if focused_block == FocusedBlock::Device { 132 | Row::new(vec![ 133 | Line::from("Name").yellow().centered(), 134 | Line::from("Mode").yellow().centered(), 135 | Line::from("Powered").yellow().centered(), 136 | Line::from("Address").yellow().centered(), 137 | ]) 138 | .style(Style::new().bold()) 139 | .bottom_margin(1) 140 | } else { 141 | Row::new(vec![ 142 | Line::from("Name") 143 | .style(match color_mode { 144 | ColorMode::Dark => Style::default().fg(Color::White), 145 | ColorMode::Light => Style::default().fg(Color::Black), 146 | }) 147 | .centered(), 148 | Line::from("Mode") 149 | .style(match color_mode { 150 | ColorMode::Dark => Style::default().fg(Color::White), 151 | ColorMode::Light => Style::default().fg(Color::Black), 152 | }) 153 | .centered(), 154 | Line::from("Powered") 155 | .style(match color_mode { 156 | ColorMode::Dark => Style::default().fg(Color::White), 157 | ColorMode::Light => Style::default().fg(Color::Black), 158 | }) 159 | .centered(), 160 | Line::from("Address") 161 | .style(match color_mode { 162 | ColorMode::Dark => Style::default().fg(Color::White), 163 | ColorMode::Light => Style::default().fg(Color::Black), 164 | }) 165 | .centered(), 166 | ]) 167 | .style(Style::new().bold()) 168 | .bottom_margin(1) 169 | } 170 | }) 171 | .block( 172 | Block::default() 173 | .title(" Device ") 174 | .title_style({ 175 | if focused_block == FocusedBlock::Device { 176 | Style::default().bold() 177 | } else { 178 | Style::default() 179 | } 180 | }) 181 | .borders(Borders::ALL) 182 | .padding(Padding::horizontal(1)) 183 | .border_style({ 184 | if focused_block == FocusedBlock::Device { 185 | Style::default().fg(Color::Green) 186 | } else { 187 | Style::default() 188 | } 189 | }) 190 | .border_type({ 191 | if focused_block == FocusedBlock::Device { 192 | BorderType::Thick 193 | } else { 194 | BorderType::default() 195 | } 196 | }), 197 | ) 198 | .column_spacing(2) 199 | .flex(Flex::SpaceBetween) 200 | .style(match color_mode { 201 | ColorMode::Dark => Style::default().fg(Color::White), 202 | ColorMode::Light => Style::default().fg(Color::Black), 203 | }) 204 | .row_highlight_style(if focused_block == FocusedBlock::Device { 205 | Style::default().bg(Color::DarkGray).fg(Color::White) 206 | } else { 207 | Style::default() 208 | }); 209 | 210 | let mut device_state = TableState::default().with_selected(0); 211 | frame.render_stateful_widget(device_table, device_block, &mut device_state); 212 | 213 | // Access Point 214 | 215 | let ap_name = match self.device.access_point.as_ref() { 216 | Some(ap) => { 217 | if ap.has_started { 218 | ap.name.as_ref().unwrap().clone() 219 | } else { 220 | "-".to_string() 221 | } 222 | } 223 | None => "-".to_string(), 224 | }; 225 | 226 | let ap_frequency = match self.device.access_point.as_ref() { 227 | Some(ap) => { 228 | if ap.has_started { 229 | format!("{:.2} GHz", (ap.frequency.unwrap() as f32 / 1000.0)) 230 | } else { 231 | "-".to_string() 232 | } 233 | } 234 | None => "-".to_string(), 235 | }; 236 | 237 | let ap_used_cipher = match self.device.access_point.as_ref() { 238 | Some(ap) => { 239 | if ap.has_started { 240 | ap.used_cipher.as_ref().unwrap().clone() 241 | } else { 242 | "-".to_string() 243 | } 244 | } 245 | None => "-".to_string(), 246 | }; 247 | 248 | let ap_is_scanning = match self.device.access_point.as_ref() { 249 | Some(ap) => { 250 | if ap.has_started { 251 | match ap.is_scanning { 252 | Some(v) => v.to_string(), 253 | None => "-".to_string(), 254 | } 255 | } else { 256 | "-".to_string() 257 | } 258 | } 259 | 260 | None => "-".to_string(), 261 | }; 262 | 263 | let row = Row::new(vec![ 264 | Line::from( 265 | self.device 266 | .access_point 267 | .as_ref() 268 | .unwrap() 269 | .has_started 270 | .clone() 271 | .to_string(), 272 | ) 273 | .centered(), 274 | Line::from(ap_name).centered(), 275 | Line::from(ap_frequency).centered(), 276 | Line::from(ap_used_cipher).centered(), 277 | Line::from(ap_is_scanning).centered(), 278 | ]); 279 | 280 | let widths = [ 281 | Constraint::Length(10), 282 | Constraint::Length(20), 283 | Constraint::Length(10), 284 | Constraint::Length(10), 285 | Constraint::Length(10), 286 | ]; 287 | 288 | let access_point_table = Table::new(vec![row], widths) 289 | .header({ 290 | if focused_block == FocusedBlock::AccessPoint { 291 | Row::new(vec![ 292 | Line::from("Started").yellow().centered(), 293 | Line::from("SSID").yellow().centered(), 294 | Line::from("Frequency").yellow().centered(), 295 | Line::from("Cipher").yellow().centered(), 296 | Line::from("Scanning").yellow().centered(), 297 | ]) 298 | .style(Style::new().bold()) 299 | .bottom_margin(1) 300 | } else { 301 | Row::new(vec![ 302 | Line::from("Started") 303 | .style(match color_mode { 304 | ColorMode::Dark => Style::default().fg(Color::White), 305 | ColorMode::Light => Style::default().fg(Color::Black), 306 | }) 307 | .centered(), 308 | Line::from("SSID") 309 | .style(match color_mode { 310 | ColorMode::Dark => Style::default().fg(Color::White), 311 | ColorMode::Light => Style::default().fg(Color::Black), 312 | }) 313 | .centered(), 314 | Line::from("Frequency") 315 | .style(match color_mode { 316 | ColorMode::Dark => Style::default().fg(Color::White), 317 | ColorMode::Light => Style::default().fg(Color::Black), 318 | }) 319 | .centered(), 320 | Line::from("Cipher") 321 | .style(match color_mode { 322 | ColorMode::Dark => Style::default().fg(Color::White), 323 | ColorMode::Light => Style::default().fg(Color::Black), 324 | }) 325 | .centered(), 326 | Line::from("Scanning") 327 | .style(match color_mode { 328 | ColorMode::Dark => Style::default().fg(Color::White), 329 | ColorMode::Light => Style::default().fg(Color::Black), 330 | }) 331 | .centered(), 332 | ]) 333 | .style(Style::new().bold()) 334 | .bottom_margin(1) 335 | } 336 | }) 337 | .block( 338 | Block::default() 339 | .title(" Access Point ") 340 | .title_style({ 341 | if focused_block == FocusedBlock::AccessPoint { 342 | Style::default().bold() 343 | } else { 344 | Style::default() 345 | } 346 | }) 347 | .borders(Borders::ALL) 348 | .border_style({ 349 | if focused_block == FocusedBlock::AccessPoint { 350 | Style::default().fg(Color::Green) 351 | } else { 352 | Style::default() 353 | } 354 | }) 355 | .border_type({ 356 | if focused_block == FocusedBlock::AccessPoint { 357 | BorderType::Thick 358 | } else { 359 | BorderType::default() 360 | } 361 | }) 362 | .padding(Padding::horizontal(1)), 363 | ) 364 | .column_spacing(2) 365 | .flex(Flex::SpaceBetween) 366 | .style(match color_mode { 367 | ColorMode::Dark => Style::default().fg(Color::White), 368 | ColorMode::Light => Style::default().fg(Color::Black), 369 | }) 370 | .row_highlight_style(if focused_block == FocusedBlock::AccessPoint { 371 | Style::default().bg(Color::DarkGray).fg(Color::White) 372 | } else { 373 | Style::default() 374 | }); 375 | 376 | let mut access_point_state = TableState::default().with_selected(0); 377 | frame.render_stateful_widget( 378 | access_point_table, 379 | access_point_block, 380 | &mut access_point_state, 381 | ); 382 | 383 | // Connected devices 384 | if any_connected_devices { 385 | let devices = self 386 | .device 387 | .access_point 388 | .as_ref() 389 | .unwrap() 390 | .connected_devices 391 | .clone(); 392 | 393 | let connected_devices_list = List::new(devices) 394 | .block( 395 | Block::bordered() 396 | .title("Connected Devices") 397 | .title_style({ 398 | if focused_block == FocusedBlock::AccessPointConnectedDevices { 399 | Style::default().bold() 400 | } else { 401 | Style::default() 402 | } 403 | }) 404 | .borders(Borders::ALL) 405 | .border_style({ 406 | if focused_block == FocusedBlock::AccessPointConnectedDevices { 407 | Style::default().fg(Color::Green) 408 | } else { 409 | Style::default() 410 | } 411 | }) 412 | .border_type({ 413 | if focused_block == FocusedBlock::AccessPointConnectedDevices { 414 | BorderType::Thick 415 | } else { 416 | BorderType::default() 417 | } 418 | }) 419 | .padding(Padding::uniform(1)), 420 | ) 421 | .style(match color_mode { 422 | ColorMode::Dark => Style::default().fg(Color::White), 423 | ColorMode::Light => Style::default().fg(Color::Black), 424 | }); 425 | 426 | frame.render_widget(connected_devices_list, connected_devices_block); 427 | } 428 | } 429 | 430 | pub fn render_station_mode( 431 | &self, 432 | frame: &mut Frame, 433 | color_mode: ColorMode, 434 | focused_block: FocusedBlock, 435 | ) { 436 | let (device_block, station_block, known_networks_block, new_networks_block) = { 437 | let chunks = Layout::default() 438 | .direction(Direction::Vertical) 439 | .constraints([ 440 | Constraint::Length(5), 441 | Constraint::Length(5), 442 | Constraint::Min(10), 443 | Constraint::Fill(1), 444 | ]) 445 | .margin(1) 446 | .split(frame.area()); 447 | (chunks[0], chunks[1], chunks[2], chunks[3]) 448 | }; 449 | 450 | // Device 451 | let row = Row::new(vec![ 452 | Line::from(self.device.name.clone()).centered(), 453 | Line::from("station").centered(), 454 | { 455 | if self.device.is_powered { 456 | Line::from("On").centered() 457 | } else { 458 | Line::from("Off").centered() 459 | } 460 | }, 461 | Line::from(self.device.address.clone()).centered(), 462 | ]); 463 | 464 | let widths = [ 465 | Constraint::Length(15), 466 | Constraint::Length(8), 467 | Constraint::Length(10), 468 | Constraint::Length(17), 469 | ]; 470 | 471 | let device_table = Table::new(vec![row], widths) 472 | .header({ 473 | if focused_block == FocusedBlock::Device { 474 | Row::new(vec![ 475 | Line::from("Name").yellow().centered(), 476 | Line::from("Mode").yellow().centered(), 477 | Line::from("Powered").yellow().centered(), 478 | Line::from("Address").yellow().centered(), 479 | ]) 480 | .style(Style::new().bold()) 481 | .bottom_margin(1) 482 | } else { 483 | Row::new(vec![ 484 | Line::from("Name") 485 | .style(match color_mode { 486 | ColorMode::Dark => Style::default().fg(Color::White), 487 | ColorMode::Light => Style::default().fg(Color::Black), 488 | }) 489 | .centered(), 490 | Line::from("Mode") 491 | .style(match color_mode { 492 | ColorMode::Dark => Style::default().fg(Color::White), 493 | ColorMode::Light => Style::default().fg(Color::Black), 494 | }) 495 | .centered(), 496 | Line::from("Powered") 497 | .style(match color_mode { 498 | ColorMode::Dark => Style::default().fg(Color::White), 499 | ColorMode::Light => Style::default().fg(Color::Black), 500 | }) 501 | .centered(), 502 | Line::from("Address") 503 | .style(match color_mode { 504 | ColorMode::Dark => Style::default().fg(Color::White), 505 | ColorMode::Light => Style::default().fg(Color::Black), 506 | }) 507 | .centered(), 508 | ]) 509 | .style(Style::new().bold()) 510 | .bottom_margin(1) 511 | } 512 | }) 513 | .block( 514 | Block::default() 515 | .title(" Device ") 516 | .title_style({ 517 | if focused_block == FocusedBlock::Device { 518 | Style::default().bold() 519 | } else { 520 | Style::default() 521 | } 522 | }) 523 | .borders(Borders::ALL) 524 | .border_style({ 525 | if focused_block == FocusedBlock::Device { 526 | Style::default().fg(Color::Green) 527 | } else { 528 | Style::default() 529 | } 530 | }) 531 | .border_type({ 532 | if focused_block == FocusedBlock::Device { 533 | BorderType::Thick 534 | } else { 535 | BorderType::default() 536 | } 537 | }) 538 | .padding(Padding::horizontal(1)), 539 | ) 540 | .column_spacing(2) 541 | .flex(Flex::SpaceBetween) 542 | .style(match color_mode { 543 | ColorMode::Dark => Style::default().fg(Color::White), 544 | ColorMode::Light => Style::default().fg(Color::Black), 545 | }) 546 | .row_highlight_style(if focused_block == FocusedBlock::Device { 547 | Style::default().bg(Color::DarkGray).fg(Color::White) 548 | } else { 549 | Style::default() 550 | }); 551 | 552 | let mut device_state = TableState::default().with_selected(0); 553 | frame.render_stateful_widget(device_table, device_block, &mut device_state); 554 | 555 | // Station 556 | 557 | let station_frequency = { 558 | match self.device.station.as_ref() { 559 | Some(station) => { 560 | if station.state == "connected" { 561 | match station.diagnostic.get("Frequency") { 562 | Some(f) => { 563 | let f: f32 = f.parse().unwrap(); 564 | format!("{:.2} GHz", f / 1000.0) 565 | } 566 | None => String::from("-"), 567 | } 568 | } else { 569 | String::from("-") 570 | } 571 | } 572 | None => String::from("-"), 573 | } 574 | }; 575 | 576 | let station_security = { 577 | match self.device.station.as_ref() { 578 | Some(station) => { 579 | if station.state == "connected" { 580 | match station.diagnostic.get("Security") { 581 | Some(f) => f.trim_matches('"').to_string(), 582 | None => String::from("-"), 583 | } 584 | } else { 585 | String::from("-") 586 | } 587 | } 588 | None => String::from("-"), 589 | } 590 | }; 591 | 592 | let row = vec![ 593 | Line::from(self.device.station.as_ref().unwrap().state.clone()).centered(), 594 | Line::from( 595 | self.device 596 | .station 597 | .as_ref() 598 | .unwrap() 599 | .is_scanning 600 | .clone() 601 | .to_string(), 602 | ) 603 | .centered(), 604 | Line::from(station_frequency).centered(), 605 | Line::from(station_security).centered(), 606 | ]; 607 | 608 | let row = Row::new(row); 609 | 610 | let widths = [ 611 | Constraint::Length(12), 612 | Constraint::Length(10), 613 | Constraint::Length(10), 614 | Constraint::Length(15), 615 | ]; 616 | 617 | let station_table = Table::new(vec![row], widths) 618 | .header({ 619 | if focused_block == FocusedBlock::Station { 620 | Row::new(vec![ 621 | Line::from("State").yellow().centered(), 622 | Line::from("Scanning").yellow().centered(), 623 | Line::from("Frequency").yellow().centered(), 624 | Line::from("Security").yellow().centered(), 625 | ]) 626 | .style(Style::new().bold()) 627 | .bottom_margin(1) 628 | } else { 629 | Row::new(vec![ 630 | Line::from("State") 631 | .style(match color_mode { 632 | ColorMode::Dark => Style::default().fg(Color::White), 633 | ColorMode::Light => Style::default().fg(Color::Black), 634 | }) 635 | .centered(), 636 | Line::from("Scanning") 637 | .style(match color_mode { 638 | ColorMode::Dark => Style::default().fg(Color::White), 639 | ColorMode::Light => Style::default().fg(Color::Black), 640 | }) 641 | .centered(), 642 | Line::from("Frequency") 643 | .style(match color_mode { 644 | ColorMode::Dark => Style::default().fg(Color::White), 645 | ColorMode::Light => Style::default().fg(Color::Black), 646 | }) 647 | .centered(), 648 | Line::from("Security") 649 | .style(match color_mode { 650 | ColorMode::Dark => Style::default().fg(Color::White), 651 | ColorMode::Light => Style::default().fg(Color::Black), 652 | }) 653 | .centered(), 654 | ]) 655 | .style(Style::new().bold()) 656 | .bottom_margin(1) 657 | } 658 | }) 659 | .block( 660 | Block::default() 661 | .title(" Station ") 662 | .title_style({ 663 | if focused_block == FocusedBlock::Station { 664 | Style::default().bold() 665 | } else { 666 | Style::default() 667 | } 668 | }) 669 | .borders(Borders::ALL) 670 | .border_style({ 671 | if focused_block == FocusedBlock::Station { 672 | Style::default().fg(Color::Green) 673 | } else { 674 | Style::default() 675 | } 676 | }) 677 | .border_type({ 678 | if focused_block == FocusedBlock::Station { 679 | BorderType::Thick 680 | } else { 681 | BorderType::default() 682 | } 683 | }) 684 | .padding(Padding::horizontal(1)), 685 | ) 686 | .column_spacing(2) 687 | .flex(Flex::SpaceBetween) 688 | .style(match color_mode { 689 | ColorMode::Dark => Style::default().fg(Color::White), 690 | ColorMode::Light => Style::default().fg(Color::Black), 691 | }) 692 | .row_highlight_style(if focused_block == FocusedBlock::Station { 693 | Style::default().bg(Color::DarkGray).fg(Color::White) 694 | } else { 695 | Style::default() 696 | }); 697 | 698 | let mut station_state = TableState::default().with_selected(0); 699 | frame.render_stateful_widget(station_table, station_block, &mut station_state); 700 | 701 | // Known networks 702 | 703 | let rows: Vec = self 704 | .device 705 | .station 706 | .as_ref() 707 | .unwrap() 708 | .known_networks 709 | .iter() 710 | .map(|(net, signal)| { 711 | let net = net.known_network.as_ref().unwrap(); 712 | let signal = format!("{}%", { 713 | if *signal / 100 >= -50 { 714 | 100 715 | } else { 716 | 2 * (100 + signal / 100) 717 | } 718 | }); 719 | 720 | if let Some(connected_net) = 721 | &self.device.station.as_ref().unwrap().connected_network 722 | { 723 | if connected_net.name == net.name { 724 | let row = vec![ 725 | Line::from("󰸞").centered(), 726 | Line::from(net.name.clone()).centered(), 727 | Line::from(net.netowrk_type.clone()).centered(), 728 | Line::from(net.is_hidden.to_string()).centered(), 729 | Line::from(net.is_autoconnect.to_string()).centered(), 730 | Line::from(signal).centered(), 731 | ]; 732 | 733 | Row::new(row) 734 | } else { 735 | let row = vec![ 736 | Line::from(""), 737 | Line::from(net.name.clone()).centered(), 738 | Line::from(net.netowrk_type.clone()).centered(), 739 | Line::from(net.is_hidden.to_string()).centered(), 740 | Line::from(net.is_autoconnect.to_string()).centered(), 741 | Line::from(signal).centered(), 742 | ]; 743 | 744 | Row::new(row) 745 | } 746 | } else { 747 | let row = vec![ 748 | Line::from("").centered(), 749 | Line::from(net.name.clone()).centered(), 750 | Line::from(net.netowrk_type.clone()).centered(), 751 | Line::from(net.is_hidden.to_string()).centered(), 752 | Line::from(net.is_autoconnect.to_string()).centered(), 753 | Line::from(signal).centered(), 754 | ]; 755 | 756 | Row::new(row) 757 | } 758 | }) 759 | .collect(); 760 | 761 | let widths = [ 762 | Constraint::Length(2), 763 | Constraint::Length(25), 764 | Constraint::Length(8), 765 | Constraint::Length(6), 766 | Constraint::Length(12), 767 | Constraint::Length(6), 768 | ]; 769 | 770 | let known_networks_table = Table::new(rows, widths) 771 | .header({ 772 | if focused_block == FocusedBlock::KnownNetworks { 773 | Row::new(vec![ 774 | Line::from(""), 775 | Line::from("Name").yellow().centered(), 776 | Line::from("Security").yellow().centered(), 777 | Line::from("Hidden").yellow().centered(), 778 | Line::from("Auto Connect").yellow().centered(), 779 | Line::from("Signal").yellow().centered(), 780 | ]) 781 | .style(Style::new().bold()) 782 | .bottom_margin(1) 783 | } else { 784 | Row::new(vec![ 785 | Line::from(""), 786 | Line::from("Name") 787 | .style(match color_mode { 788 | ColorMode::Dark => Style::default().fg(Color::White), 789 | ColorMode::Light => Style::default().fg(Color::Black), 790 | }) 791 | .centered(), 792 | Line::from("Security") 793 | .style(match color_mode { 794 | ColorMode::Dark => Style::default().fg(Color::White), 795 | ColorMode::Light => Style::default().fg(Color::Black), 796 | }) 797 | .centered(), 798 | Line::from("Hidden") 799 | .style(match color_mode { 800 | ColorMode::Dark => Style::default().fg(Color::White), 801 | ColorMode::Light => Style::default().fg(Color::Black), 802 | }) 803 | .centered(), 804 | Line::from("Auto Connect") 805 | .style(match color_mode { 806 | ColorMode::Dark => Style::default().fg(Color::White), 807 | ColorMode::Light => Style::default().fg(Color::Black), 808 | }) 809 | .centered(), 810 | Line::from("Signal") 811 | .style(match color_mode { 812 | ColorMode::Dark => Style::default().fg(Color::White), 813 | ColorMode::Light => Style::default().fg(Color::Black), 814 | }) 815 | .centered(), 816 | ]) 817 | .style(Style::new().bold()) 818 | .bottom_margin(1) 819 | } 820 | }) 821 | .block( 822 | Block::default() 823 | .title(" Known Networks ") 824 | .title_style({ 825 | if focused_block == FocusedBlock::KnownNetworks { 826 | Style::default().bold() 827 | } else { 828 | Style::default() 829 | } 830 | }) 831 | .borders(Borders::ALL) 832 | .border_style({ 833 | if focused_block == FocusedBlock::KnownNetworks { 834 | Style::default().fg(Color::Green) 835 | } else { 836 | Style::default() 837 | } 838 | }) 839 | .border_type({ 840 | if focused_block == FocusedBlock::KnownNetworks { 841 | BorderType::Thick 842 | } else { 843 | BorderType::default() 844 | } 845 | }) 846 | .padding(Padding::horizontal(1)), 847 | ) 848 | .column_spacing(2) 849 | .flex(Flex::SpaceBetween) 850 | .style(match color_mode { 851 | ColorMode::Dark => Style::default().fg(Color::White), 852 | ColorMode::Light => Style::default().fg(Color::Black), 853 | }) 854 | .row_highlight_style(if focused_block == FocusedBlock::KnownNetworks { 855 | Style::default().bg(Color::DarkGray).fg(Color::White) 856 | } else { 857 | Style::default() 858 | }); 859 | 860 | frame.render_stateful_widget( 861 | known_networks_table, 862 | known_networks_block, 863 | &mut self 864 | .device 865 | .station 866 | .as_ref() 867 | .unwrap() 868 | .known_networks_state 869 | .clone(), 870 | ); 871 | 872 | // New networks 873 | 874 | let rows: Vec = self 875 | .device 876 | .station 877 | .as_ref() 878 | .unwrap() 879 | .new_networks 880 | .iter() 881 | .map(|(net, signal)| { 882 | Row::new(vec![ 883 | Line::from(net.name.clone()).centered(), 884 | Line::from(net.netowrk_type.clone()).centered(), 885 | Line::from({ 886 | let signal = { 887 | if *signal / 100 >= -50 { 888 | 100 889 | } else { 890 | 2 * (100 + signal / 100) 891 | } 892 | }; 893 | match signal { 894 | n if n >= 75 => format!("{:3}% 󰤨", signal), 895 | n if (50..75).contains(&n) => format!("{:3}% 󰤥", signal), 896 | n if (25..50).contains(&n) => format!("{:3}% 󰤢", signal), 897 | _ => format!("{:3}% 󰤟", signal), 898 | } 899 | }) 900 | .centered(), 901 | ]) 902 | }) 903 | .collect(); 904 | 905 | let widths = [ 906 | Constraint::Length(25), 907 | Constraint::Length(15), 908 | Constraint::Length(8), 909 | ]; 910 | 911 | let new_networks_table = Table::new(rows, widths) 912 | .header({ 913 | if focused_block == FocusedBlock::NewNetworks { 914 | Row::new(vec![ 915 | Line::from("Name").yellow().centered(), 916 | Line::from("Security").yellow().centered(), 917 | Line::from("Signal").yellow().centered(), 918 | ]) 919 | .style(Style::new().bold()) 920 | .bottom_margin(1) 921 | } else { 922 | Row::new(vec![ 923 | Line::from("Name") 924 | .style(match color_mode { 925 | ColorMode::Dark => Style::default().fg(Color::White), 926 | ColorMode::Light => Style::default().fg(Color::Black), 927 | }) 928 | .centered(), 929 | Line::from("Security") 930 | .style(match color_mode { 931 | ColorMode::Dark => Style::default().fg(Color::White), 932 | ColorMode::Light => Style::default().fg(Color::Black), 933 | }) 934 | .centered(), 935 | Line::from("Signal") 936 | .style(match color_mode { 937 | ColorMode::Dark => Style::default().fg(Color::White), 938 | ColorMode::Light => Style::default().fg(Color::Black), 939 | }) 940 | .centered(), 941 | ]) 942 | .style(Style::new().bold()) 943 | .bottom_margin(1) 944 | } 945 | }) 946 | .block( 947 | Block::default() 948 | .title(" New Networks ") 949 | .title_style({ 950 | if focused_block == FocusedBlock::NewNetworks { 951 | Style::default().bold() 952 | } else { 953 | Style::default() 954 | } 955 | }) 956 | .borders(Borders::ALL) 957 | .border_style({ 958 | if focused_block == FocusedBlock::NewNetworks { 959 | Style::default().fg(Color::Green) 960 | } else { 961 | Style::default() 962 | } 963 | }) 964 | .border_type({ 965 | if focused_block == FocusedBlock::NewNetworks { 966 | BorderType::Thick 967 | } else { 968 | BorderType::default() 969 | } 970 | }) 971 | .padding(Padding::horizontal(1)), 972 | ) 973 | .column_spacing(2) 974 | .flex(Flex::SpaceBetween) 975 | .style(match color_mode { 976 | ColorMode::Dark => Style::default().fg(Color::White), 977 | ColorMode::Light => Style::default().fg(Color::Black), 978 | }) 979 | .row_highlight_style(if focused_block == FocusedBlock::NewNetworks { 980 | Style::default().bg(Color::DarkGray).fg(Color::White) 981 | } else { 982 | Style::default() 983 | }); 984 | 985 | frame.render_stateful_widget( 986 | new_networks_table, 987 | new_networks_block, 988 | &mut self 989 | .device 990 | .station 991 | .as_ref() 992 | .unwrap() 993 | .new_networks_state 994 | .clone(), 995 | ); 996 | } 997 | 998 | pub fn render_adapter(&self, frame: &mut Frame, color_mode: ColorMode) { 999 | let popup_layout = Layout::default() 1000 | .direction(ratatui::layout::Direction::Vertical) 1001 | .constraints([ 1002 | Constraint::Fill(1), 1003 | Constraint::Length(9), 1004 | Constraint::Fill(5), 1005 | ]) 1006 | .flex(Flex::Start) 1007 | .split(frame.area()); 1008 | 1009 | let area = Layout::default() 1010 | .direction(Direction::Horizontal) 1011 | .constraints([ 1012 | Constraint::Fill(1), 1013 | Constraint::Min(80), 1014 | Constraint::Fill(1), 1015 | ]) 1016 | .split(popup_layout[1])[1]; 1017 | 1018 | let mut rows = vec![ 1019 | Row::new(vec![ 1020 | Cell::from("name").style(Style::default().bold().yellow()), 1021 | Cell::from(self.name.clone()), 1022 | ]), 1023 | Row::new(vec![ 1024 | Cell::from("address").style(Style::default().bold().yellow()), 1025 | Cell::from(self.device.address.clone()), 1026 | ]), 1027 | Row::new(vec![ 1028 | Cell::from("Supported modes").style(Style::default().bold().yellow()), 1029 | Cell::from(self.supported_modes.clone().join(" ")), 1030 | ]), 1031 | ]; 1032 | 1033 | if let Some(model) = &self.model { 1034 | rows.push(Row::new(vec![ 1035 | Cell::from("model").style(Style::default().bold().yellow()), 1036 | Cell::from(model.clone()), 1037 | ])) 1038 | } 1039 | 1040 | if let Some(vendor) = &self.vendor { 1041 | rows.push(Row::new(vec![ 1042 | Cell::from("vendor").style(Style::default().bold().yellow()), 1043 | Cell::from(vendor.clone()), 1044 | ])) 1045 | } 1046 | 1047 | let widths = [Constraint::Length(20), Constraint::Fill(1)]; 1048 | 1049 | let device_infos_table = Table::new(rows, widths) 1050 | .block( 1051 | Block::default() 1052 | .title(" Adapter Infos ") 1053 | .title_style(Style::default().bold()) 1054 | .title_alignment(Alignment::Center) 1055 | .padding(Padding::uniform(1)) 1056 | .borders(Borders::ALL) 1057 | .border_style(Style::default().fg(Color::Green)) 1058 | .border_type(BorderType::Thick), 1059 | ) 1060 | .column_spacing(3) 1061 | .style(match color_mode { 1062 | ColorMode::Dark => Style::default().fg(Color::White), 1063 | ColorMode::Light => Style::default().fg(Color::Black), 1064 | }) 1065 | .row_highlight_style(Style::default().bg(Color::DarkGray).fg(Color::White)); 1066 | 1067 | frame.render_widget(Clear, area); 1068 | frame.render_widget(device_infos_table, area); 1069 | } 1070 | } 1071 | -------------------------------------------------------------------------------- /src/app.rs: -------------------------------------------------------------------------------- 1 | use anyhow::anyhow; 2 | use ratatui::{ 3 | layout::{Alignment, Constraint, Direction, Layout}, 4 | style::{Color, Style, Stylize}, 5 | text::Text, 6 | widgets::{Block, BorderType, Borders, Clear, Padding, Paragraph}, 7 | Frame, 8 | }; 9 | use std::{ 10 | error::Error, 11 | process::{self, exit}, 12 | sync::{atomic::AtomicBool, Arc}, 13 | }; 14 | use tokio::sync::mpsc::UnboundedSender; 15 | use tui_input::Input; 16 | 17 | use async_channel::{Receiver, Sender}; 18 | use futures::FutureExt; 19 | use iwdrs::{agent::Agent, modes::Mode, session::Session}; 20 | 21 | use crate::{adapter::Adapter, event::Event, help::Help, notification::Notification}; 22 | 23 | pub type AppResult = std::result::Result>; 24 | 25 | #[derive(Debug, Clone, Copy, PartialEq)] 26 | pub enum FocusedBlock { 27 | Device, 28 | Station, 29 | AccessPoint, 30 | KnownNetworks, 31 | NewNetworks, 32 | Help, 33 | AuthKey, 34 | AdapterInfos, 35 | AccessPointInput, 36 | AccessPointConnectedDevices, 37 | } 38 | 39 | #[derive(Debug, Clone, Copy, PartialEq)] 40 | pub enum ColorMode { 41 | Dark, 42 | Light, 43 | } 44 | 45 | #[derive(Debug)] 46 | pub struct App { 47 | pub running: bool, 48 | pub focused_block: FocusedBlock, 49 | pub help: Help, 50 | pub color_mode: ColorMode, 51 | pub notifications: Vec, 52 | pub session: Arc, 53 | pub adapter: Adapter, 54 | pub agent_manager: iwdrs::agent::AgentManager, 55 | pub authentication_required: Arc, 56 | pub passkey_sender: Sender, 57 | pub cancel_signal_sender: Sender<()>, 58 | pub passkey_input: Input, 59 | pub show_password: bool, 60 | pub mode: Mode, 61 | pub selected_mode: Mode, 62 | pub current_mode: Mode, 63 | pub reset_mode: bool, 64 | } 65 | 66 | pub async fn request_confirmation( 67 | authentication_required: Arc, 68 | rx_key: Receiver, 69 | rx_cancel: Receiver<()>, 70 | ) -> Result> { 71 | authentication_required.store(true, std::sync::atomic::Ordering::Relaxed); 72 | 73 | tokio::select! { 74 | r = rx_key.recv() => { 75 | match r { 76 | Ok(key) => Ok(key), 77 | Err(_) => Err(anyhow!("Failed to receive the key").into()), 78 | } 79 | } 80 | 81 | r = rx_cancel.recv() => { 82 | match r { 83 | Ok(_) => { 84 | Err(anyhow!("Operation Canceled").into())}, 85 | Err(_) => Err(anyhow!("Failed to receive cancel signal").into()), 86 | } 87 | 88 | } 89 | 90 | } 91 | } 92 | 93 | impl App { 94 | pub async fn new(help: Help, mode: Mode, sender: UnboundedSender) -> AppResult { 95 | let session = { 96 | match iwdrs::session::Session::new().await { 97 | Ok(session) => Arc::new(session), 98 | Err(e) => { 99 | eprintln!("Can not access the iwd service {}", e); 100 | exit(1); 101 | } 102 | } 103 | }; 104 | 105 | let adapter = match Adapter::new(session.clone(), sender).await { 106 | Ok(v) => v, 107 | Err(e) => { 108 | eprintln!("{}", e); 109 | eprintln!("Make sure iwd daemon is up and running"); 110 | process::exit(1); 111 | } 112 | }; 113 | 114 | let current_mode = adapter.device.mode.clone(); 115 | 116 | let (passkey_sender, passkey_receiver) = async_channel::unbounded(); 117 | let show_password = false; 118 | let (cancel_signal_sender, cancel_signal_receiver) = async_channel::unbounded(); 119 | 120 | let authentication_required = Arc::new(AtomicBool::new(false)); 121 | let authentication_required_caller = authentication_required.clone(); 122 | 123 | let agent = Agent { 124 | request_passphrase_fn: Box::new(move || { 125 | { 126 | let auth_clone = authentication_required_caller.clone(); 127 | request_confirmation( 128 | auth_clone, 129 | passkey_receiver.clone(), 130 | cancel_signal_receiver.clone(), 131 | ) 132 | } 133 | .boxed() 134 | }), 135 | }; 136 | 137 | let agent_manager = session.register_agent(agent).await?; 138 | 139 | let color_mode = match terminal_light::luma() { 140 | Ok(luma) if luma > 0.6 => ColorMode::Light, 141 | Ok(_) => ColorMode::Dark, 142 | Err(_) => ColorMode::Dark, 143 | }; 144 | 145 | Ok(Self { 146 | running: true, 147 | focused_block: FocusedBlock::Device, 148 | help, 149 | color_mode, 150 | notifications: Vec::new(), 151 | session, 152 | adapter, 153 | agent_manager, 154 | authentication_required: authentication_required.clone(), 155 | passkey_sender, 156 | cancel_signal_sender, 157 | passkey_input: Input::default(), 158 | show_password, 159 | mode, 160 | selected_mode: Mode::Station, 161 | current_mode, 162 | reset_mode: false, 163 | }) 164 | } 165 | 166 | pub async fn reset(mode: Mode, sender: UnboundedSender) -> AppResult<()> { 167 | let session = { 168 | match iwdrs::session::Session::new().await { 169 | Ok(session) => Arc::new(session), 170 | Err(e) => return Err(anyhow!("Can not access the iwd service: {}", e).into()), 171 | } 172 | }; 173 | 174 | let adapter = match Adapter::new(session.clone(), sender).await { 175 | Ok(v) => v, 176 | Err(e) => { 177 | eprintln!("{}", e); 178 | eprintln!("Make sure iwd daemon is up and running"); 179 | process::exit(1); 180 | } 181 | }; 182 | 183 | adapter.device.set_mode(mode).await?; 184 | Ok(()) 185 | } 186 | 187 | pub fn render(&self, frame: &mut Frame) { 188 | let popup_layout = Layout::default() 189 | .direction(Direction::Vertical) 190 | .constraints([ 191 | Constraint::Fill(1), 192 | Constraint::Length(10), 193 | Constraint::Fill(1), 194 | ]) 195 | .flex(ratatui::layout::Flex::SpaceBetween) 196 | .split(frame.area()); 197 | 198 | let area = Layout::default() 199 | .direction(Direction::Horizontal) 200 | .constraints([ 201 | Constraint::Fill(1), 202 | Constraint::Length(50), 203 | Constraint::Fill(1), 204 | ]) 205 | .flex(ratatui::layout::Flex::SpaceBetween) 206 | .split(popup_layout[1])[1]; 207 | 208 | let chunks = Layout::default() 209 | .direction(Direction::Vertical) 210 | .constraints( 211 | [ 212 | Constraint::Length(1), 213 | Constraint::Length(3), 214 | Constraint::Length(1), 215 | Constraint::Length(1), 216 | Constraint::Length(1), 217 | Constraint::Length(1), 218 | Constraint::Length(1), 219 | ] 220 | .as_ref(), 221 | ) 222 | .split(area); 223 | 224 | let (message_area, station_choice_area, ap_choice_area, help_area) = 225 | (chunks[1], chunks[2], chunks[3], chunks[6]); 226 | 227 | let station_choice_area = Layout::default() 228 | .direction(Direction::Horizontal) 229 | .constraints([ 230 | Constraint::Length(2), 231 | Constraint::Fill(1), 232 | Constraint::Length(2), 233 | ]) 234 | .split(station_choice_area)[1]; 235 | 236 | let ap_choice_area = Layout::default() 237 | .direction(Direction::Horizontal) 238 | .constraints([ 239 | Constraint::Length(2), 240 | Constraint::Fill(1), 241 | Constraint::Length(2), 242 | ]) 243 | .split(ap_choice_area)[1]; 244 | 245 | let message_area = Layout::default() 246 | .direction(Direction::Horizontal) 247 | .constraints( 248 | [ 249 | Constraint::Length(2), 250 | Constraint::Fill(1), 251 | Constraint::Length(2), 252 | ] 253 | .as_ref(), 254 | ) 255 | .split(message_area)[1]; 256 | 257 | let (ap_text, station_text) = match self.selected_mode { 258 | Mode::Ap => match self.current_mode { 259 | Mode::Ap => ( 260 | Text::from(" Access Point (current)"), 261 | Text::from(" Station"), 262 | ), 263 | Mode::Station => ( 264 | Text::from(" Access Point"), 265 | Text::from(" Station (current)"), 266 | ), 267 | _ => (Text::from(" Access Point"), Text::from(" Station")), 268 | }, 269 | Mode::Station => match self.current_mode { 270 | Mode::Ap => ( 271 | Text::from(" Access Point (current)"), 272 | Text::from(" Station"), 273 | ), 274 | Mode::Station => ( 275 | Text::from(" Access Point"), 276 | Text::from(" Station (current)"), 277 | ), 278 | _ => (Text::from(" Access Point"), Text::from(" Station")), 279 | }, 280 | _ => panic!("unknwon mode"), 281 | }; 282 | 283 | let message = Paragraph::new("Select the desired mode:") 284 | .alignment(Alignment::Center) 285 | .style(Style::default().fg(Color::White)) 286 | .block(Block::new().padding(Padding::uniform(1))); 287 | 288 | let station_choice = Paragraph::new(station_text) 289 | .style(Style::default().fg(Color::White)) 290 | .block(Block::new().padding(Padding::horizontal(10))); 291 | 292 | let ap_choice = Paragraph::new(ap_text) 293 | .style(Style::default().fg(Color::White)) 294 | .block(Block::new().padding(Padding::horizontal(10))); 295 | 296 | let help = Paragraph::new( 297 | Text::from(" Scroll down: j | Scroll up: k | Enter: Confirm ") 298 | .style(Style::default().blue()), 299 | ) 300 | .alignment(Alignment::Center) 301 | .style(Style::default()) 302 | .block(Block::new().padding(Padding::horizontal(1))); 303 | 304 | frame.render_widget(Clear, area); 305 | 306 | frame.render_widget( 307 | Block::new() 308 | .borders(Borders::ALL) 309 | .border_type(BorderType::Rounded) 310 | .style(Style::default().green()) 311 | .border_style(Style::default().fg(Color::Green)), 312 | area, 313 | ); 314 | frame.render_widget(message, message_area); 315 | frame.render_widget(ap_choice, ap_choice_area); 316 | frame.render_widget(station_choice, station_choice_area); 317 | frame.render_widget(help, help_area); 318 | } 319 | 320 | pub async fn send_passkey(&mut self) -> AppResult<()> { 321 | let passkey: String = self.passkey_input.value().into(); 322 | self.passkey_sender.send(passkey).await?; 323 | self.authentication_required 324 | .store(false, std::sync::atomic::Ordering::Relaxed); 325 | self.passkey_input.reset(); 326 | Ok(()) 327 | } 328 | 329 | pub async fn cancel_auth(&mut self) -> AppResult<()> { 330 | self.cancel_signal_sender.send(()).await?; 331 | self.authentication_required 332 | .store(false, std::sync::atomic::Ordering::Relaxed); 333 | self.passkey_input.reset(); 334 | Ok(()) 335 | } 336 | 337 | pub async fn tick(&mut self, sender: UnboundedSender) -> AppResult<()> { 338 | self.notifications.retain(|n| n.ttl > 0); 339 | self.notifications.iter_mut().for_each(|n| n.ttl -= 1); 340 | 341 | self.adapter.refresh(sender).await?; 342 | 343 | Ok(()) 344 | } 345 | 346 | pub fn quit(&mut self) { 347 | self.running = false; 348 | } 349 | } 350 | -------------------------------------------------------------------------------- /src/auth.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{ 2 | layout::{Alignment, Constraint, Direction, Layout}, 3 | style::{Color, Style, Stylize}, 4 | widgets::{Block, BorderType, Borders, Clear, Padding, Paragraph}, 5 | Frame, 6 | }; 7 | 8 | pub struct Auth; 9 | 10 | impl Auth { 11 | pub fn render(&self, frame: &mut Frame, passkey: &str) { 12 | let popup_layout = Layout::default() 13 | .direction(Direction::Vertical) 14 | .constraints([ 15 | Constraint::Fill(1), 16 | Constraint::Length(8), 17 | Constraint::Fill(1), 18 | ]) 19 | .flex(ratatui::layout::Flex::SpaceBetween) 20 | .split(frame.area()); 21 | 22 | let area = Layout::default() 23 | .direction(Direction::Horizontal) 24 | .constraints([ 25 | Constraint::Fill(1), 26 | Constraint::Length(80), 27 | Constraint::Fill(1), 28 | ]) 29 | .flex(ratatui::layout::Flex::SpaceBetween) 30 | .split(popup_layout[1])[1]; 31 | 32 | let (text_area, passkey_area) = { 33 | let chunks = Layout::default() 34 | .direction(Direction::Vertical) 35 | .constraints([ 36 | Constraint::Length(1), 37 | Constraint::Length(3), 38 | Constraint::Length(1), 39 | Constraint::Length(1), 40 | ]) 41 | .split(area); 42 | 43 | let area1 = Layout::default() 44 | .direction(Direction::Horizontal) 45 | .constraints([ 46 | Constraint::Length(1), 47 | Constraint::Fill(1), 48 | Constraint::Length(1), 49 | ]) 50 | .split(chunks[1]); 51 | 52 | let area2 = Layout::default() 53 | .direction(Direction::Horizontal) 54 | .constraints([ 55 | Constraint::Percentage(20), 56 | Constraint::Fill(1), 57 | Constraint::Percentage(20), 58 | ]) 59 | .split(chunks[2]); 60 | 61 | (area1[1], area2[1]) 62 | }; 63 | 64 | let text = Paragraph::new("Enter the password") 65 | .alignment(Alignment::Center) 66 | .style(Style::default().fg(Color::White)) 67 | .block(Block::new().padding(Padding::uniform(1))); 68 | 69 | let passkey = Paragraph::new(passkey) 70 | .alignment(Alignment::Center) 71 | .style(Style::default().fg(Color::White)) 72 | .block(Block::new().style(Style::default().bg(Color::DarkGray))); 73 | 74 | frame.render_widget(Clear, area); 75 | 76 | frame.render_widget( 77 | Block::new() 78 | .borders(Borders::ALL) 79 | .border_type(BorderType::Thick) 80 | .style(Style::default().green()) 81 | .border_style(Style::default().fg(Color::Green)), 82 | area, 83 | ); 84 | frame.render_widget(text, text_area); 85 | frame.render_widget(passkey, passkey_area); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/cli.rs: -------------------------------------------------------------------------------- 1 | use clap::{arg, crate_version, Command}; 2 | 3 | pub fn cli() -> Command { 4 | Command::new("impala") 5 | .about("TUI For managing wifi") 6 | .version(crate_version!()) 7 | .arg( 8 | arg!(--mode ) 9 | .short('m') 10 | .required(false) 11 | .help("Device mode") 12 | .value_parser(["station", "ap"]), 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use toml; 2 | 3 | use dirs; 4 | use serde::Deserialize; 5 | 6 | #[derive(Deserialize, Debug)] 7 | pub struct Config { 8 | #[serde(default = "default_switch_mode")] 9 | pub switch: char, 10 | 11 | #[serde(default = "default_device_mode")] 12 | pub mode: String, 13 | 14 | #[serde(default)] 15 | pub device: Device, 16 | 17 | #[serde(default)] 18 | pub station: Station, 19 | 20 | #[serde(default)] 21 | pub ap: AccessPoint, 22 | } 23 | 24 | fn default_switch_mode() -> char { 25 | 'r' 26 | } 27 | 28 | fn default_device_mode() -> String { 29 | "station".to_string() 30 | } 31 | 32 | // Device 33 | #[derive(Deserialize, Debug)] 34 | pub struct Device { 35 | #[serde(default = "default_show_device_infos")] 36 | pub infos: char, 37 | pub toggle_power: char, 38 | } 39 | 40 | impl Default for Device { 41 | fn default() -> Self { 42 | Self { 43 | infos: 'i', 44 | toggle_power: 'o', 45 | } 46 | } 47 | } 48 | 49 | fn default_show_device_infos() -> char { 50 | 'i' 51 | } 52 | 53 | // Station 54 | #[derive(Deserialize, Debug)] 55 | pub struct Station { 56 | #[serde(default = "default_station_start_scanning")] 57 | pub start_scanning: char, 58 | 59 | #[serde(default = "default_station_toggle_connect")] 60 | pub toggle_connect: char, 61 | 62 | #[serde(default)] 63 | pub known_network: KnownNetwork, 64 | } 65 | 66 | impl Default for Station { 67 | fn default() -> Self { 68 | Self { 69 | start_scanning: 's', 70 | toggle_connect: ' ', 71 | known_network: KnownNetwork::default(), 72 | } 73 | } 74 | } 75 | 76 | fn default_station_start_scanning() -> char { 77 | 's' 78 | } 79 | 80 | fn default_station_toggle_connect() -> char { 81 | ' ' 82 | } 83 | 84 | #[derive(Deserialize, Debug)] 85 | pub struct KnownNetwork { 86 | #[serde(default = "default_station_remove_known_network")] 87 | pub remove: char, 88 | pub toggle_autoconnect: char, 89 | } 90 | 91 | impl Default for KnownNetwork { 92 | fn default() -> Self { 93 | Self { 94 | remove: 'd', 95 | toggle_autoconnect: 'a', 96 | } 97 | } 98 | } 99 | 100 | fn default_station_remove_known_network() -> char { 101 | 'd' 102 | } 103 | 104 | // Access Point 105 | #[derive(Deserialize, Debug)] 106 | pub struct AccessPoint { 107 | #[serde(default = "default_ap_start")] 108 | pub start: char, 109 | 110 | #[serde(default = "default_ap_stop")] 111 | pub stop: char, 112 | } 113 | 114 | impl Default for AccessPoint { 115 | fn default() -> Self { 116 | Self { 117 | start: 'n', 118 | stop: 'x', 119 | } 120 | } 121 | } 122 | 123 | fn default_ap_start() -> char { 124 | 'n' 125 | } 126 | 127 | fn default_ap_stop() -> char { 128 | 'x' 129 | } 130 | 131 | impl Config { 132 | pub fn new() -> Self { 133 | let conf_path = dirs::config_dir() 134 | .unwrap() 135 | .join("impala") 136 | .join("config.toml"); 137 | 138 | let config = std::fs::read_to_string(conf_path).unwrap_or_default(); 139 | let app_config: Config = toml::from_str(&config).unwrap(); 140 | 141 | app_config 142 | } 143 | } 144 | 145 | impl Default for Config { 146 | fn default() -> Self { 147 | Self::new() 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/device.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use anyhow::Context; 4 | use iwdrs::{device::Device as iwdDevice, modes::Mode, session::Session}; 5 | use tokio::sync::mpsc::UnboundedSender; 6 | 7 | use crate::{ 8 | access_point::AccessPoint, app::AppResult, event::Event, notification::Notification, 9 | station::Station, 10 | }; 11 | 12 | #[derive(Debug, Clone)] 13 | pub struct Device { 14 | session: Arc, 15 | pub device: iwdDevice, 16 | pub name: String, 17 | pub address: String, 18 | pub mode: Mode, 19 | pub is_powered: bool, 20 | pub station: Option, 21 | pub access_point: Option, 22 | } 23 | 24 | impl Device { 25 | pub async fn new(session: Arc, sender: UnboundedSender) -> AppResult { 26 | let device = session.device().context("No device found")?; 27 | 28 | let name = device.name().await?; 29 | let address = device.address().await?; 30 | let mode = device.get_mode().await?; 31 | let is_powered = device.is_powered().await?; 32 | 33 | let station = match session.station() { 34 | Some(_) => match Station::new(session.clone()).await { 35 | Ok(v) => Some(v), 36 | Err(e) => { 37 | Notification::send( 38 | e.to_string(), 39 | crate::notification::NotificationLevel::Error, 40 | sender.clone(), 41 | )?; 42 | None 43 | } 44 | }, 45 | None => None, 46 | }; 47 | 48 | let access_point = match session.access_point() { 49 | Some(_) => match AccessPoint::new(session.clone()).await { 50 | Ok(v) => Some(v), 51 | Err(e) => { 52 | Notification::send( 53 | e.to_string(), 54 | crate::notification::NotificationLevel::Error, 55 | sender, 56 | )?; 57 | None 58 | } 59 | }, 60 | None => None, 61 | }; 62 | 63 | Ok(Self { 64 | session, 65 | device, 66 | name, 67 | address, 68 | mode, 69 | is_powered, 70 | station, 71 | access_point, 72 | }) 73 | } 74 | 75 | pub async fn set_mode(&self, mode: Mode) -> AppResult<()> { 76 | self.device.set_mode(mode).await?; 77 | Ok(()) 78 | } 79 | 80 | pub async fn power_off(&self) -> AppResult<()> { 81 | self.device.set_power(false).await?; 82 | Ok(()) 83 | } 84 | 85 | pub async fn power_on(&self) -> AppResult<()> { 86 | self.device.set_power(true).await?; 87 | Ok(()) 88 | } 89 | 90 | pub async fn refresh(&mut self, sender: UnboundedSender) -> AppResult<()> { 91 | self.is_powered = self.device.is_powered().await?; 92 | let current_mode = self.device.get_mode().await?; 93 | 94 | match current_mode { 95 | Mode::Station => { 96 | match self.mode { 97 | Mode::Station => { 98 | // refresh exisiting station 99 | if let Some(station) = &mut self.station { 100 | station.refresh().await?; 101 | } 102 | } 103 | Mode::Ap => { 104 | // Switch mode from ap to station 105 | self.access_point = None; 106 | self.station = match self.session.station() { 107 | Some(_) => match Station::new(self.session.clone()).await { 108 | Ok(v) => Some(v), 109 | Err(e) => { 110 | Notification::send( 111 | e.to_string(), 112 | crate::notification::NotificationLevel::Error, 113 | sender, 114 | )?; 115 | None 116 | } 117 | }, 118 | None => None, 119 | }; 120 | } 121 | _ => {} 122 | } 123 | } 124 | Mode::Ap => { 125 | match self.mode { 126 | Mode::Station => { 127 | self.station = None; 128 | self.access_point = match self.session.access_point() { 129 | Some(_) => match AccessPoint::new(self.session.clone()).await { 130 | Ok(v) => Some(v), 131 | Err(e) => { 132 | Notification::send( 133 | e.to_string(), 134 | crate::notification::NotificationLevel::Error, 135 | sender, 136 | )?; 137 | None 138 | } 139 | }, 140 | None => None, 141 | }; 142 | } 143 | Mode::Ap => { 144 | // Switch mode 145 | if self.access_point.is_some() { 146 | self.access_point.as_mut().unwrap().refresh().await?; 147 | } 148 | } 149 | _ => {} 150 | } 151 | } 152 | _ => {} 153 | } 154 | 155 | self.mode = current_mode; 156 | Ok(()) 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /src/event.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use crossterm::event::{Event as CrosstermEvent, KeyEvent, MouseEvent}; 4 | use futures::{FutureExt, StreamExt}; 5 | use iwdrs::modes::Mode; 6 | use tokio::sync::mpsc; 7 | 8 | use crate::{app::AppResult, notification::Notification}; 9 | 10 | #[derive(Clone, Debug)] 11 | pub enum Event { 12 | Tick, 13 | Key(KeyEvent), 14 | Mouse(MouseEvent), 15 | Resize(u16, u16), 16 | Notification(Notification), 17 | Reset(Mode), 18 | } 19 | 20 | #[allow(dead_code)] 21 | #[derive(Debug)] 22 | pub struct EventHandler { 23 | pub sender: mpsc::UnboundedSender, 24 | pub receiver: mpsc::UnboundedReceiver, 25 | handler: tokio::task::JoinHandle<()>, 26 | } 27 | 28 | impl EventHandler { 29 | pub fn new(tick_rate: u64) -> Self { 30 | let tick_rate = Duration::from_millis(tick_rate); 31 | let (sender, receiver) = mpsc::unbounded_channel(); 32 | let _sender = sender.clone(); 33 | let handler = tokio::spawn(async move { 34 | let mut reader = crossterm::event::EventStream::new(); 35 | let mut tick = tokio::time::interval(tick_rate); 36 | loop { 37 | let tick_delay = tick.tick(); 38 | let crossterm_event = reader.next().fuse(); 39 | tokio::select! { 40 | _ = _sender.closed() => { 41 | break; 42 | } 43 | _ = tick_delay => { 44 | _sender.send(Event::Tick).unwrap(); 45 | } 46 | Some(Ok(evt)) = crossterm_event => { 47 | match evt { 48 | CrosstermEvent::Key(key) => { 49 | if key.kind == crossterm::event::KeyEventKind::Press { 50 | _sender.send(Event::Key(key)).unwrap(); 51 | } 52 | }, 53 | CrosstermEvent::Resize(x, y) => { 54 | _sender.send(Event::Resize(x, y)).unwrap(); 55 | }, 56 | _ => {} 57 | } 58 | } 59 | }; 60 | } 61 | }); 62 | Self { 63 | sender, 64 | receiver, 65 | handler, 66 | } 67 | } 68 | 69 | pub async fn next(&mut self) -> AppResult { 70 | self.receiver 71 | .recv() 72 | .await 73 | .ok_or(Box::new(std::io::Error::new( 74 | std::io::ErrorKind::Other, 75 | "This is an IO error", 76 | ))) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/handler.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use crate::access_point::APFocusedSection; 4 | use crate::app::{App, AppResult, FocusedBlock}; 5 | use crate::config::Config; 6 | use crate::event::Event; 7 | use crate::notification::Notification; 8 | 9 | use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; 10 | use iwdrs::modes::Mode; 11 | use tokio::sync::mpsc::UnboundedSender; 12 | use tui_input::backend::crossterm::EventHandler; 13 | 14 | pub async fn handle_key_events( 15 | key_event: KeyEvent, 16 | app: &mut App, 17 | sender: UnboundedSender, 18 | config: Arc, 19 | ) -> AppResult<()> { 20 | if app.reset_mode { 21 | match key_event.code { 22 | KeyCode::Char('q') => { 23 | app.quit(); 24 | } 25 | KeyCode::Char('c') | KeyCode::Char('C') => { 26 | if key_event.modifiers == KeyModifiers::CONTROL { 27 | app.quit(); 28 | } 29 | } 30 | 31 | KeyCode::Char('j') => { 32 | if app.selected_mode == Mode::Station { 33 | app.selected_mode = Mode::Ap; 34 | } 35 | } 36 | 37 | KeyCode::Char('k') => { 38 | if app.selected_mode == Mode::Ap { 39 | app.selected_mode = Mode::Station; 40 | } 41 | } 42 | 43 | KeyCode::Enter => { 44 | sender.send(Event::Reset(app.selected_mode.clone()))?; 45 | } 46 | 47 | _ => {} 48 | } 49 | return Ok(()); 50 | } 51 | 52 | match app.focused_block { 53 | FocusedBlock::AuthKey => match key_event.code { 54 | KeyCode::Enter => { 55 | app.send_passkey().await?; 56 | app.focused_block = FocusedBlock::Device; 57 | } 58 | 59 | KeyCode::Esc => { 60 | app.cancel_auth().await?; 61 | app.focused_block = FocusedBlock::Device; 62 | } 63 | 64 | KeyCode::Tab => { 65 | app.show_password = !app.show_password; 66 | } 67 | 68 | _ => { 69 | app.passkey_input 70 | .handle_event(&crossterm::event::Event::Key(key_event)); 71 | } 72 | }, 73 | FocusedBlock::AccessPointInput => match key_event.code { 74 | KeyCode::Enter => { 75 | if let Some(ap) = &mut app.adapter.device.access_point { 76 | ap.start(sender.clone()).await?; 77 | sender.send(Event::Reset(app.current_mode.clone()))?; 78 | app.focused_block = FocusedBlock::Device; 79 | } 80 | } 81 | 82 | KeyCode::Esc => { 83 | if let Some(ap) = &app.adapter.device.access_point { 84 | // Start AP 85 | ap.ap_start 86 | .store(false, std::sync::atomic::Ordering::Relaxed); 87 | } 88 | app.focused_block = FocusedBlock::AccessPoint; 89 | } 90 | KeyCode::Tab => { 91 | if let Some(ap) = &mut app.adapter.device.access_point { 92 | match ap.focused_section { 93 | APFocusedSection::SSID => ap.focused_section = APFocusedSection::PSK, 94 | APFocusedSection::PSK => ap.focused_section = APFocusedSection::SSID, 95 | } 96 | } 97 | } 98 | _ => { 99 | if let Some(ap) = &mut app.adapter.device.access_point { 100 | match ap.focused_section { 101 | APFocusedSection::SSID => ap 102 | .ssid 103 | .handle_event(&crossterm::event::Event::Key(key_event)), 104 | APFocusedSection::PSK => ap 105 | .psk 106 | .handle_event(&crossterm::event::Event::Key(key_event)), 107 | }; 108 | } 109 | } 110 | }, 111 | _ => { 112 | match key_event.code { 113 | KeyCode::Char('q') => { 114 | app.quit(); 115 | } 116 | KeyCode::Char('c') | KeyCode::Char('C') => { 117 | if key_event.modifiers == KeyModifiers::CONTROL { 118 | app.quit(); 119 | } 120 | } 121 | 122 | // Show help 123 | KeyCode::Char('?') => { 124 | app.focused_block = FocusedBlock::Help; 125 | } 126 | 127 | // Switch mode 128 | KeyCode::Char(c) 129 | if c == config.switch && key_event.modifiers == KeyModifiers::CONTROL => 130 | { 131 | app.reset_mode = true; 132 | } 133 | 134 | // Discard help popup 135 | KeyCode::Esc => { 136 | if app.focused_block == FocusedBlock::Help 137 | || app.focused_block == FocusedBlock::AdapterInfos 138 | { 139 | app.focused_block = FocusedBlock::Device; 140 | } 141 | } 142 | 143 | // Start Scan 144 | KeyCode::Char(c) if c == config.station.start_scanning => { 145 | match app.adapter.device.mode { 146 | Mode::Station => { 147 | app.adapter 148 | .device 149 | .station 150 | .as_mut() 151 | .unwrap() 152 | .scan(sender) 153 | .await? 154 | } 155 | Mode::Ap => { 156 | app.adapter 157 | .device 158 | .access_point 159 | .as_mut() 160 | .unwrap() 161 | .scan(sender) 162 | .await? 163 | } 164 | _ => {} 165 | }; 166 | } 167 | 168 | KeyCode::Tab => match app.adapter.device.mode { 169 | Mode::Station => match app.focused_block { 170 | FocusedBlock::Device => { 171 | app.focused_block = FocusedBlock::Station; 172 | } 173 | FocusedBlock::Station => { 174 | app.focused_block = FocusedBlock::KnownNetworks; 175 | } 176 | FocusedBlock::KnownNetworks => { 177 | app.focused_block = FocusedBlock::NewNetworks; 178 | } 179 | FocusedBlock::NewNetworks => { 180 | app.focused_block = FocusedBlock::Device; 181 | } 182 | _ => {} 183 | }, 184 | Mode::Ap => match app.focused_block { 185 | FocusedBlock::Device => { 186 | app.focused_block = FocusedBlock::AccessPoint; 187 | } 188 | FocusedBlock::AccessPoint => { 189 | if let Some(ap) = app.adapter.device.access_point.as_ref() { 190 | if ap.connected_devices.is_empty() { 191 | app.focused_block = FocusedBlock::Device; 192 | } else { 193 | app.focused_block = FocusedBlock::AccessPointConnectedDevices; 194 | } 195 | } 196 | } 197 | FocusedBlock::AccessPointConnectedDevices => { 198 | app.focused_block = FocusedBlock::Device; 199 | } 200 | FocusedBlock::AccessPointInput => { 201 | if let Some(ap) = &mut app.adapter.device.access_point { 202 | match ap.focused_section { 203 | APFocusedSection::SSID => { 204 | ap.focused_section = APFocusedSection::PSK 205 | } 206 | APFocusedSection::PSK => { 207 | ap.focused_section = APFocusedSection::SSID 208 | } 209 | }; 210 | } 211 | } 212 | _ => {} 213 | }, 214 | _ => {} 215 | }, 216 | 217 | KeyCode::BackTab => match app.adapter.device.mode { 218 | Mode::Station => match app.focused_block { 219 | FocusedBlock::Device => { 220 | app.focused_block = FocusedBlock::NewNetworks; 221 | } 222 | FocusedBlock::Station => { 223 | app.focused_block = FocusedBlock::Device; 224 | } 225 | FocusedBlock::KnownNetworks => { 226 | app.focused_block = FocusedBlock::Station; 227 | } 228 | FocusedBlock::NewNetworks => { 229 | app.focused_block = FocusedBlock::KnownNetworks; 230 | } 231 | _ => {} 232 | }, 233 | Mode::Ap => match app.focused_block { 234 | FocusedBlock::Device => { 235 | if let Some(ap) = app.adapter.device.access_point.as_ref() { 236 | if ap.connected_devices.is_empty() { 237 | app.focused_block = FocusedBlock::AccessPoint; 238 | } else { 239 | app.focused_block = FocusedBlock::AccessPointConnectedDevices; 240 | } 241 | } 242 | } 243 | FocusedBlock::AccessPoint => { 244 | app.focused_block = FocusedBlock::Device; 245 | } 246 | FocusedBlock::AccessPointConnectedDevices => { 247 | app.focused_block = FocusedBlock::AccessPoint; 248 | } 249 | FocusedBlock::AccessPointInput => { 250 | if let Some(ap) = &mut app.adapter.device.access_point { 251 | match ap.focused_section { 252 | APFocusedSection::SSID => { 253 | ap.focused_section = APFocusedSection::PSK 254 | } 255 | APFocusedSection::PSK => { 256 | ap.focused_section = APFocusedSection::SSID 257 | } 258 | }; 259 | } 260 | } 261 | _ => {} 262 | }, 263 | _ => {} 264 | }, 265 | 266 | _ => { 267 | match app.focused_block { 268 | FocusedBlock::Device => match key_event.code { 269 | KeyCode::Char(c) if c == config.device.infos => { 270 | app.focused_block = FocusedBlock::AdapterInfos; 271 | } 272 | 273 | KeyCode::Char(c) if c == config.device.toggle_power => { 274 | if app.adapter.device.is_powered { 275 | match app.adapter.device.power_off().await { 276 | Ok(()) => { 277 | sender.send(Event::Reset(app.current_mode.clone()))?; 278 | Notification::send( 279 | "Device Powered Off".to_string(), 280 | crate::notification::NotificationLevel::Info, 281 | sender.clone(), 282 | )?; 283 | } 284 | Err(e) => { 285 | Notification::send( 286 | e.to_string(), 287 | crate::notification::NotificationLevel::Error, 288 | sender.clone(), 289 | )?; 290 | } 291 | } 292 | } else { 293 | match app.adapter.device.power_on().await { 294 | Ok(()) => { 295 | sender.send(Event::Reset(app.current_mode.clone()))?; 296 | Notification::send( 297 | "Device Powered On".to_string(), 298 | crate::notification::NotificationLevel::Info, 299 | sender.clone(), 300 | )?; 301 | } 302 | Err(e) => { 303 | Notification::send( 304 | e.to_string(), 305 | crate::notification::NotificationLevel::Error, 306 | sender.clone(), 307 | )?; 308 | } 309 | } 310 | } 311 | } 312 | 313 | _ => {} 314 | }, 315 | 316 | _ => { 317 | match app.adapter.device.mode { 318 | Mode::Station => { 319 | match key_event.code { 320 | // Remove a known network 321 | KeyCode::Char(c) 322 | if c == config.station.known_network.remove 323 | && app.focused_block 324 | == FocusedBlock::KnownNetworks => 325 | { 326 | if let Some(net_index) = app 327 | .adapter 328 | .device 329 | .station 330 | .as_ref() 331 | .unwrap() 332 | .known_networks_state 333 | .selected() 334 | { 335 | let (net, _signal) = &app 336 | .adapter 337 | .device 338 | .station 339 | .as_ref() 340 | .unwrap() 341 | .known_networks[net_index]; 342 | 343 | let known_net = net.known_network.as_ref().unwrap(); 344 | known_net.forget(sender.clone()).await?; 345 | } 346 | } 347 | 348 | // Toggle autoconnect 349 | KeyCode::Char(c) 350 | if c == config 351 | .station 352 | .known_network 353 | .toggle_autoconnect 354 | && app.focused_block 355 | == FocusedBlock::KnownNetworks => 356 | { 357 | if let Some(net_index) = app 358 | .adapter 359 | .device 360 | .station 361 | .as_ref() 362 | .unwrap() 363 | .known_networks_state 364 | .selected() 365 | { 366 | let (net, _signal) = &app 367 | .adapter 368 | .device 369 | .station 370 | .as_ref() 371 | .unwrap() 372 | .known_networks[net_index]; 373 | 374 | let known_net = net.known_network.as_ref().unwrap(); 375 | known_net 376 | .toggle_autoconnect(sender.clone()) 377 | .await?; 378 | } 379 | } 380 | 381 | // Connect/Disconnect 382 | KeyCode::Char(c) if c == config.station.toggle_connect => { 383 | match app.focused_block { 384 | FocusedBlock::NewNetworks => { 385 | if let Some(net_index) = app 386 | .adapter 387 | .device 388 | .station 389 | .as_ref() 390 | .unwrap() 391 | .new_networks_state 392 | .selected() 393 | { 394 | let (net, _) = app 395 | .adapter 396 | .device 397 | .station 398 | .as_ref() 399 | .unwrap() 400 | .new_networks[net_index] 401 | .clone(); 402 | 403 | let mode = app.current_mode.clone(); 404 | tokio::spawn(async move { 405 | net.connect(sender.clone()) 406 | .await 407 | .unwrap(); 408 | 409 | sender 410 | .clone() 411 | .send(Event::Reset(mode)) 412 | .unwrap(); 413 | }); 414 | } 415 | } 416 | FocusedBlock::KnownNetworks => { 417 | match &app 418 | .adapter 419 | .device 420 | .station 421 | .as_ref() 422 | .unwrap() 423 | .connected_network 424 | { 425 | Some(connected_net) => { 426 | if let Some(selected_net_index) = app 427 | .adapter 428 | .device 429 | .station 430 | .as_ref() 431 | .unwrap() 432 | .known_networks_state 433 | .selected() 434 | { 435 | let (selected_net, _signal) = &app 436 | .adapter 437 | .device 438 | .station 439 | .as_ref() 440 | .unwrap() 441 | .known_networks 442 | [selected_net_index]; 443 | 444 | if selected_net.name 445 | == connected_net.name 446 | { 447 | app.adapter 448 | .device 449 | .station 450 | .as_ref() 451 | .unwrap() 452 | .disconnect(sender.clone()) 453 | .await?; 454 | } else { 455 | let net_index = app 456 | .adapter 457 | .device 458 | .station 459 | .as_ref() 460 | .unwrap() 461 | .known_networks 462 | .iter() 463 | .position(|(n, _s)| { 464 | n.name 465 | == selected_net.name 466 | }); 467 | 468 | if net_index.is_some() { 469 | let (net, _) = app 470 | .adapter 471 | .device 472 | .station 473 | .as_ref() 474 | .unwrap() 475 | .known_networks 476 | [net_index.unwrap()] 477 | .clone(); 478 | app.adapter 479 | .device 480 | .station 481 | .as_ref() 482 | .unwrap() 483 | .disconnect( 484 | sender.clone(), 485 | ) 486 | .await?; 487 | tokio::spawn(async move { 488 | net.connect( 489 | sender.clone(), 490 | ) 491 | .await 492 | .unwrap(); 493 | }); 494 | } 495 | } 496 | } 497 | } 498 | None => { 499 | if let Some(selected_net_index) = app 500 | .adapter 501 | .device 502 | .station 503 | .as_ref() 504 | .unwrap() 505 | .known_networks_state 506 | .selected() 507 | { 508 | let (selected_net, _signal) = &app 509 | .adapter 510 | .device 511 | .station 512 | .as_ref() 513 | .unwrap() 514 | .known_networks 515 | [selected_net_index]; 516 | let net_index = app 517 | .adapter 518 | .device 519 | .station 520 | .as_ref() 521 | .unwrap() 522 | .known_networks 523 | .iter() 524 | .position(|(n, _s)| { 525 | n.name == selected_net.name 526 | }); 527 | 528 | if net_index.is_some() { 529 | let (net, _) = app 530 | .adapter 531 | .device 532 | .station 533 | .as_ref() 534 | .unwrap() 535 | .known_networks 536 | [net_index.unwrap()] 537 | .clone(); 538 | tokio::spawn(async move { 539 | net.connect(sender.clone()) 540 | .await 541 | .unwrap(); 542 | }); 543 | } 544 | } 545 | } 546 | } 547 | } 548 | _ => {} 549 | } 550 | } 551 | 552 | // Scroll down 553 | KeyCode::Char('j') | KeyCode::Down => { 554 | match app.focused_block { 555 | FocusedBlock::KnownNetworks => { 556 | if !app 557 | .adapter 558 | .device 559 | .station 560 | .as_ref() 561 | .unwrap() 562 | .known_networks 563 | .is_empty() 564 | { 565 | let i = match app 566 | .adapter 567 | .device 568 | .station 569 | .as_ref() 570 | .unwrap() 571 | .known_networks_state 572 | .selected() 573 | { 574 | Some(i) => { 575 | if i < app 576 | .adapter 577 | .device 578 | .station 579 | .as_ref() 580 | .unwrap() 581 | .known_networks 582 | .len() 583 | - 1 584 | { 585 | i + 1 586 | } else { 587 | i 588 | } 589 | } 590 | None => 0, 591 | }; 592 | 593 | app.adapter 594 | .device 595 | .station 596 | .as_mut() 597 | .unwrap() 598 | .known_networks_state 599 | .select(Some(i)); 600 | } 601 | } 602 | FocusedBlock::NewNetworks => { 603 | if !app 604 | .adapter 605 | .device 606 | .station 607 | .as_ref() 608 | .unwrap() 609 | .new_networks 610 | .is_empty() 611 | { 612 | let i = match app 613 | .adapter 614 | .device 615 | .station 616 | .as_ref() 617 | .unwrap() 618 | .new_networks_state 619 | .selected() 620 | { 621 | Some(i) => { 622 | if i < app 623 | .adapter 624 | .device 625 | .station 626 | .as_ref() 627 | .unwrap() 628 | .new_networks 629 | .len() 630 | - 1 631 | { 632 | i + 1 633 | } else { 634 | i 635 | } 636 | } 637 | None => 0, 638 | }; 639 | 640 | app.adapter 641 | .device 642 | .station 643 | .as_mut() 644 | .unwrap() 645 | .new_networks_state 646 | .select(Some(i)); 647 | } 648 | } 649 | 650 | FocusedBlock::Help => { 651 | app.help.scroll_down(); 652 | } 653 | _ => {} 654 | } 655 | } 656 | 657 | KeyCode::Char('k') | KeyCode::Up => match app.focused_block 658 | { 659 | FocusedBlock::KnownNetworks => { 660 | if !app 661 | .adapter 662 | .device 663 | .station 664 | .as_ref() 665 | .unwrap() 666 | .known_networks 667 | .is_empty() 668 | { 669 | let i = match app 670 | .adapter 671 | .device 672 | .station 673 | .as_ref() 674 | .unwrap() 675 | .known_networks_state 676 | .selected() 677 | { 678 | Some(i) => { 679 | if i > 1 { 680 | i - 1 681 | } else { 682 | 0 683 | } 684 | } 685 | None => 0, 686 | }; 687 | 688 | app.adapter 689 | .device 690 | .station 691 | .as_mut() 692 | .unwrap() 693 | .known_networks_state 694 | .select(Some(i)); 695 | } 696 | } 697 | FocusedBlock::NewNetworks => { 698 | if !app 699 | .adapter 700 | .device 701 | .station 702 | .as_ref() 703 | .unwrap() 704 | .new_networks 705 | .is_empty() 706 | { 707 | let i = match app 708 | .adapter 709 | .device 710 | .station 711 | .as_ref() 712 | .unwrap() 713 | .new_networks_state 714 | .selected() 715 | { 716 | Some(i) => { 717 | if i > 1 { 718 | i - 1 719 | } else { 720 | 0 721 | } 722 | } 723 | None => 0, 724 | }; 725 | 726 | app.adapter 727 | .device 728 | .station 729 | .as_mut() 730 | .unwrap() 731 | .new_networks_state 732 | .select(Some(i)); 733 | } 734 | } 735 | FocusedBlock::Help => { 736 | app.help.scroll_up(); 737 | } 738 | _ => {} 739 | }, 740 | _ => {} 741 | } 742 | } 743 | Mode::Ap => match key_event.code { 744 | KeyCode::Char(c) if c == config.ap.start => { 745 | if let Some(ap) = &app.adapter.device.access_point { 746 | // Start AP 747 | ap.ap_start 748 | .store(true, std::sync::atomic::Ordering::Relaxed); 749 | } 750 | } 751 | KeyCode::Char(c) if c == config.ap.stop => { 752 | if let Some(ap) = &mut app.adapter.device.access_point { 753 | ap.stop(sender).await?; 754 | ap.connected_devices = Vec::new(); 755 | } 756 | } 757 | 758 | // Scroll down 759 | KeyCode::Char('j') | KeyCode::Down => { 760 | if app.focused_block == FocusedBlock::Help { 761 | app.help.scroll_down(); 762 | } 763 | } 764 | 765 | KeyCode::Char('k') | KeyCode::Up => { 766 | if app.focused_block == FocusedBlock::Help { 767 | app.help.scroll_up(); 768 | } 769 | } 770 | 771 | _ => {} 772 | }, 773 | _ => {} 774 | } 775 | } 776 | } 777 | } 778 | } 779 | } 780 | } 781 | Ok(()) 782 | } 783 | -------------------------------------------------------------------------------- /src/help.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use ratatui::{ 4 | layout::{Alignment, Constraint, Direction, Layout, Margin}, 5 | style::{Color, Style, Stylize}, 6 | widgets::{ 7 | Block, BorderType, Borders, Cell, Clear, Padding, Row, Scrollbar, ScrollbarOrientation, 8 | ScrollbarState, Table, TableState, 9 | }, 10 | Frame, 11 | }; 12 | 13 | use crate::{app::ColorMode, config::Config}; 14 | 15 | #[derive(Debug, Clone)] 16 | pub struct Help { 17 | block_height: usize, 18 | state: TableState, 19 | keys: Vec<(Cell<'static>, &'static str)>, 20 | } 21 | 22 | impl Help { 23 | pub fn new(config: Arc) -> Self { 24 | let mut state = TableState::new().with_offset(0); 25 | state.select(Some(0)); 26 | 27 | Self { 28 | block_height: 0, 29 | state, 30 | keys: vec![ 31 | ( 32 | Cell::from("## Global").style(Style::new().bold().fg(Color::Yellow)), 33 | "", 34 | ), 35 | (Cell::from("Esc").bold(), "Dismiss different pop-ups"), 36 | ( 37 | Cell::from("Tab or Shift+Tab").bold(), 38 | "Switch between different sections", 39 | ), 40 | (Cell::from("j or Down").bold(), "Scroll down"), 41 | (Cell::from("k or Up").bold(), "Scroll up"), 42 | ( 43 | Cell::from(format!("ctrl {}", config.switch)).bold(), 44 | "Switch adapter mode", 45 | ), 46 | (Cell::from("?").bold(), "Show help"), 47 | (Cell::from("q or ctrl+c").bold(), "Quit"), 48 | (Cell::from(""), ""), 49 | ( 50 | Cell::from("## Device").style(Style::new().bold().fg(Color::Yellow)), 51 | "", 52 | ), 53 | ( 54 | Cell::from(config.device.infos.to_string()).bold(), 55 | "Show device information", 56 | ), 57 | ( 58 | Cell::from(config.device.toggle_power.to_string()).bold(), 59 | "Toggle device power", 60 | ), 61 | (Cell::from(""), ""), 62 | ( 63 | Cell::from("## Station").style(Style::new().bold().fg(Color::Yellow)), 64 | "", 65 | ), 66 | ( 67 | Cell::from(config.station.start_scanning.to_string()).bold(), 68 | "Start scanning", 69 | ), 70 | ( 71 | Cell::from({ 72 | if config.station.toggle_connect == ' ' { 73 | "Space".to_string() 74 | } else { 75 | config.station.toggle_connect.to_string() 76 | } 77 | }) 78 | .bold(), 79 | "Connect/Disconnect the network", 80 | ), 81 | ( 82 | Cell::from("### Known Networks").style(Style::new().bold().fg(Color::Yellow)), 83 | "", 84 | ), 85 | ( 86 | Cell::from(config.station.known_network.remove.to_string()).bold(), 87 | "Remove the network from the known networks list", 88 | ), 89 | (Cell::from(""), ""), 90 | ( 91 | Cell::from("## Access Point").style(Style::new().bold().fg(Color::Yellow)), 92 | "", 93 | ), 94 | ( 95 | Cell::from(config.ap.start.to_string()).bold(), 96 | "Start a new access point", 97 | ), 98 | ( 99 | Cell::from(config.ap.stop.to_string()).bold(), 100 | "Stop the running access point", 101 | ), 102 | ], 103 | } 104 | } 105 | 106 | pub fn scroll_down(&mut self) { 107 | let i = match self.state.selected() { 108 | Some(i) => { 109 | if i >= self.keys.len().saturating_sub(self.block_height - 6) { 110 | i 111 | } else { 112 | i + 1 113 | } 114 | } 115 | None => 1, 116 | }; 117 | *self.state.offset_mut() = i; 118 | self.state.select(Some(i)); 119 | } 120 | pub fn scroll_up(&mut self) { 121 | let i = match self.state.selected() { 122 | Some(i) => { 123 | if i > 1 { 124 | i - 1 125 | } else { 126 | 0 127 | } 128 | } 129 | None => 1, 130 | }; 131 | *self.state.offset_mut() = i; 132 | self.state.select(Some(i)); 133 | } 134 | 135 | pub fn render(&mut self, frame: &mut Frame, color_mode: ColorMode) { 136 | let layout = Layout::default() 137 | .direction(Direction::Vertical) 138 | .constraints([ 139 | Constraint::Fill(1), 140 | Constraint::Length(20), 141 | Constraint::Fill(1), 142 | ]) 143 | .flex(ratatui::layout::Flex::SpaceBetween) 144 | .split(frame.area()); 145 | 146 | let block = Layout::default() 147 | .direction(Direction::Horizontal) 148 | .constraints([ 149 | Constraint::Fill(1), 150 | Constraint::Length(75), 151 | Constraint::Fill(1), 152 | ]) 153 | .flex(ratatui::layout::Flex::SpaceBetween) 154 | .split(layout[1])[1]; 155 | 156 | self.block_height = block.height as usize; 157 | 158 | let widths = [Constraint::Length(20), Constraint::Fill(1)]; 159 | let rows: Vec = self 160 | .keys 161 | .iter() 162 | .map(|key| { 163 | Row::new(vec![key.0.to_owned(), key.1.into()]).style(match color_mode { 164 | ColorMode::Dark => Style::default().fg(Color::White), 165 | ColorMode::Light => Style::default().fg(Color::Black), 166 | }) 167 | }) 168 | .collect(); 169 | let rows_len = self.keys.len().saturating_sub(self.block_height - 6); 170 | 171 | let table = Table::new(rows, widths).block( 172 | Block::default() 173 | .padding(Padding::uniform(2)) 174 | .title(" Help ") 175 | .title_style(Style::default().bold().fg(Color::Green)) 176 | .title_alignment(Alignment::Center) 177 | .borders(Borders::ALL) 178 | .style(Style::default()) 179 | .border_type(BorderType::Thick) 180 | .border_style(Style::default().fg(Color::Green)), 181 | ); 182 | 183 | frame.render_widget(Clear, block); 184 | frame.render_stateful_widget(table, block, &mut self.state); 185 | 186 | let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) 187 | .begin_symbol(Some("↑")) 188 | .end_symbol(Some("↓")); 189 | let mut scrollbar_state = 190 | ScrollbarState::new(rows_len).position(self.state.selected().unwrap_or_default()); 191 | frame.render_stateful_widget( 192 | scrollbar, 193 | block.inner(Margin { 194 | vertical: 1, 195 | horizontal: 0, 196 | }), 197 | &mut scrollbar_state, 198 | ); 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /src/known_network.rs: -------------------------------------------------------------------------------- 1 | use chrono::{DateTime, FixedOffset}; 2 | use iwdrs::known_netowk::KnownNetwork as iwdKnownNetwork; 3 | 4 | use tokio::sync::mpsc::UnboundedSender; 5 | 6 | use crate::{ 7 | app::AppResult, 8 | event::Event, 9 | notification::{Notification, NotificationLevel}, 10 | }; 11 | 12 | #[derive(Debug, Clone)] 13 | pub struct KnownNetwork { 14 | pub n: iwdKnownNetwork, 15 | pub name: String, 16 | pub netowrk_type: String, 17 | pub is_autoconnect: bool, 18 | pub is_hidden: bool, 19 | pub last_connected: Option>, 20 | } 21 | 22 | impl KnownNetwork { 23 | pub async fn new(n: iwdKnownNetwork) -> AppResult { 24 | let name = n.name().await?; 25 | let netowrk_type = n.network_type().await?; 26 | let is_autoconnect = n.get_autoconnect().await?; 27 | let is_hidden = n.hidden().await?; 28 | let last_connected = match n.last_connected_time().await { 29 | Ok(v) => DateTime::parse_from_rfc3339(&v).ok(), 30 | Err(_) => None, 31 | }; 32 | 33 | Ok(Self { 34 | n, 35 | name, 36 | netowrk_type, 37 | is_autoconnect, 38 | is_hidden, 39 | last_connected, 40 | }) 41 | } 42 | 43 | pub async fn forget(&self, sender: UnboundedSender) -> AppResult<()> { 44 | if let Err(e) = self.n.forget().await { 45 | Notification::send(e.to_string(), NotificationLevel::Error, sender.clone())?; 46 | return Ok(()); 47 | } 48 | 49 | Notification::send( 50 | "Network Removed".to_string(), 51 | NotificationLevel::Info, 52 | sender, 53 | )?; 54 | Ok(()) 55 | } 56 | 57 | pub async fn toggle_autoconnect(&self, sender: UnboundedSender) -> AppResult<()> { 58 | if self.is_autoconnect { 59 | match self.n.set_autoconnect(false).await { 60 | Ok(_) => { 61 | Notification::send( 62 | format!("Disable Autoconnect for: {}", self.name), 63 | NotificationLevel::Info, 64 | sender.clone(), 65 | )?; 66 | } 67 | Err(e) => { 68 | Notification::send(e.to_string(), NotificationLevel::Error, sender.clone())?; 69 | } 70 | } 71 | } else { 72 | match self.n.set_autoconnect(true).await { 73 | Ok(_) => { 74 | Notification::send( 75 | format!("Enable Autoconnect for: {}", self.name), 76 | NotificationLevel::Info, 77 | sender.clone(), 78 | )?; 79 | } 80 | Err(e) => { 81 | Notification::send(e.to_string(), NotificationLevel::Error, sender.clone())?; 82 | } 83 | } 84 | } 85 | Ok(()) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod app; 2 | 3 | pub mod event; 4 | 5 | pub mod ui; 6 | 7 | pub mod tui; 8 | 9 | pub mod handler; 10 | 11 | pub mod config; 12 | 13 | pub mod notification; 14 | 15 | pub mod help; 16 | 17 | pub mod device; 18 | 19 | pub mod station; 20 | 21 | pub mod network; 22 | 23 | pub mod known_network; 24 | 25 | pub mod auth; 26 | 27 | pub mod adapter; 28 | 29 | pub mod access_point; 30 | 31 | pub mod cli; 32 | 33 | pub mod rfkill; 34 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use impala::app::{App, AppResult}; 2 | use impala::config::Config; 3 | use impala::event::{Event, EventHandler}; 4 | use impala::handler::handle_key_events; 5 | use impala::help::Help; 6 | use impala::tui::Tui; 7 | use impala::{cli, rfkill}; 8 | use iwdrs::modes::Mode; 9 | use ratatui::backend::CrosstermBackend; 10 | use ratatui::Terminal; 11 | use std::io; 12 | use std::sync::Arc; 13 | 14 | #[tokio::main] 15 | async fn main() -> AppResult<()> { 16 | let args = cli::cli().get_matches(); 17 | 18 | rfkill::check()?; 19 | 20 | let config = Arc::new(Config::new()); 21 | 22 | let help = Help::new(config.clone()); 23 | 24 | let backend = CrosstermBackend::new(io::stdout()); 25 | let terminal = Terminal::new(backend)?; 26 | let events = EventHandler::new(2_000); 27 | let mut tui = Tui::new(terminal, events); 28 | tui.init()?; 29 | 30 | let mode = args.get_one::("mode").cloned(); 31 | let mode = mode.unwrap_or_else(|| config.mode.clone()); 32 | 33 | let mode = Mode::try_from(mode.as_str())?; 34 | 35 | if App::reset(mode.clone(), tui.events.sender.clone()) 36 | .await 37 | .is_err() 38 | { 39 | tui.exit()?; 40 | } 41 | 42 | let mut app = App::new(help.clone(), mode, tui.events.sender.clone()).await?; 43 | 44 | while app.running { 45 | tui.draw(&mut app)?; 46 | match tui.events.next().await? { 47 | Event::Tick => app.tick(tui.events.sender.clone()).await?, 48 | Event::Key(key_event) => { 49 | handle_key_events( 50 | key_event, 51 | &mut app, 52 | tui.events.sender.clone(), 53 | config.clone(), 54 | ) 55 | .await? 56 | } 57 | Event::Notification(notification) => { 58 | app.notifications.push(notification); 59 | } 60 | Event::Reset(mode) => { 61 | if App::reset(mode.clone(), tui.events.sender.clone()) 62 | .await 63 | .is_err() 64 | { 65 | tui.exit()?; 66 | } 67 | app = App::new(help.clone(), mode, tui.events.sender.clone()).await?; 68 | } 69 | _ => {} 70 | } 71 | } 72 | 73 | tui.exit()?; 74 | Ok(()) 75 | } 76 | -------------------------------------------------------------------------------- /src/network.rs: -------------------------------------------------------------------------------- 1 | use iwdrs::netowrk::Network as iwdNetwork; 2 | use tokio::sync::mpsc::UnboundedSender; 3 | 4 | use crate::{ 5 | app::AppResult, 6 | event::Event, 7 | known_network::KnownNetwork, 8 | notification::{Notification, NotificationLevel}, 9 | }; 10 | 11 | #[derive(Debug, Clone)] 12 | pub struct Network { 13 | pub n: iwdNetwork, 14 | pub name: String, 15 | pub netowrk_type: String, 16 | pub is_connected: bool, 17 | pub known_network: Option, 18 | } 19 | 20 | impl Network { 21 | pub async fn new(n: iwdNetwork) -> AppResult { 22 | let name = n.name().await?; 23 | let netowrk_type = n.network_type().await?; 24 | let is_connected = n.connected().await?; 25 | let known_network = { 26 | match n.known_network().await { 27 | Ok(v) => match v { 28 | Some(net) => Some(KnownNetwork::new(net).await.unwrap()), 29 | None => None, 30 | }, 31 | Err(_) => None, 32 | } 33 | }; 34 | 35 | Ok(Self { 36 | n, 37 | name, 38 | netowrk_type, 39 | is_connected, 40 | known_network, 41 | }) 42 | } 43 | 44 | pub async fn connect(&self, sender: UnboundedSender) -> AppResult<()> { 45 | match self.n.connect().await { 46 | Ok(_) => Notification::send( 47 | format!("Connected to {}", self.name), 48 | NotificationLevel::Info, 49 | sender, 50 | )?, 51 | Err(e) => { 52 | if e.to_string().contains("net.connman.iwd.Aborted") { 53 | Notification::send( 54 | "Connection canceled".to_string(), 55 | NotificationLevel::Info, 56 | sender, 57 | )? 58 | } else { 59 | Notification::send(e.to_string(), NotificationLevel::Error, sender)? 60 | } 61 | } 62 | } 63 | Ok(()) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/notification.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{ 2 | layout::{Alignment, Constraint, Direction, Layout, Rect}, 3 | style::{Color, Modifier, Style}, 4 | text::{Line, Text}, 5 | widgets::{Block, BorderType, Borders, Clear, Paragraph, Wrap}, 6 | Frame, 7 | }; 8 | use tokio::sync::mpsc::UnboundedSender; 9 | 10 | use crate::{app::AppResult, event::Event}; 11 | 12 | #[derive(Debug, Clone)] 13 | pub struct Notification { 14 | pub message: String, 15 | pub level: NotificationLevel, 16 | pub ttl: u16, 17 | } 18 | 19 | #[derive(Debug, Clone)] 20 | pub enum NotificationLevel { 21 | Error, 22 | Warning, 23 | Info, 24 | } 25 | 26 | impl Notification { 27 | pub fn render(&self, index: usize, frame: &mut Frame) { 28 | let (color, title) = match self.level { 29 | NotificationLevel::Info => (Color::Green, "Info"), 30 | NotificationLevel::Warning => (Color::Yellow, "Warning"), 31 | NotificationLevel::Error => (Color::Red, "Error"), 32 | }; 33 | 34 | let mut text = Text::from(vec![ 35 | Line::from(title).style(Style::new().fg(color).add_modifier(Modifier::BOLD)) 36 | ]); 37 | 38 | text.extend(Text::from(self.message.as_str())); 39 | 40 | let notification_height = text.height() as u16 + 2; 41 | let notification_width = text.width() as u16 + 4; 42 | 43 | let block = Paragraph::new(text) 44 | .alignment(Alignment::Center) 45 | .wrap(Wrap { trim: false }) 46 | .block( 47 | Block::default() 48 | .borders(Borders::ALL) 49 | .style(Style::default()) 50 | .border_type(BorderType::Thick) 51 | .border_style(Style::default().fg(color)), 52 | ); 53 | 54 | let area = notification_rect( 55 | index as u16, 56 | notification_height, 57 | notification_width, 58 | frame.area(), 59 | ); 60 | 61 | frame.render_widget(Clear, area); 62 | frame.render_widget(block, area); 63 | } 64 | pub fn send( 65 | message: String, 66 | level: NotificationLevel, 67 | sender: UnboundedSender, 68 | ) -> AppResult<()> { 69 | let notif = Notification { 70 | message, 71 | level, 72 | ttl: 1, 73 | }; 74 | 75 | sender.send(Event::Notification(notif))?; 76 | 77 | Ok(()) 78 | } 79 | } 80 | 81 | pub fn notification_rect(offset: u16, height: u16, width: u16, r: Rect) -> Rect { 82 | let popup_layout = Layout::default() 83 | .direction(Direction::Vertical) 84 | .constraints( 85 | [ 86 | Constraint::Length(height * offset), 87 | Constraint::Length(height), 88 | Constraint::Min(1), 89 | ] 90 | .as_ref(), 91 | ) 92 | .split(r); 93 | 94 | Layout::default() 95 | .direction(Direction::Horizontal) 96 | .constraints( 97 | [ 98 | Constraint::Min(1), 99 | Constraint::Length(width), 100 | Constraint::Length(2), 101 | ] 102 | .as_ref(), 103 | ) 104 | .split(popup_layout[1])[1] 105 | } 106 | -------------------------------------------------------------------------------- /src/rfkill.rs: -------------------------------------------------------------------------------- 1 | use std::fs; 2 | 3 | use crate::app::AppResult; 4 | 5 | pub fn check() -> AppResult<()> { 6 | let entries = fs::read_dir("/sys/class/rfkill/")?; 7 | 8 | for entry in entries { 9 | let entry = entry?; 10 | let entry_path = entry.path(); 11 | 12 | if let Some(_file_name) = entry_path.file_name() { 13 | let name = fs::read_to_string(entry_path.join("type"))?; 14 | 15 | if name.trim() == "wlan" { 16 | let state_path = entry_path.join("state"); 17 | let state = fs::read_to_string(state_path)?.trim().parse::()?; 18 | 19 | // https://www.kernel.org/doc/Documentation/ABI/stable/sysfs-class-rfkill 20 | match state { 21 | 0 => { 22 | eprintln!( 23 | r#" 24 | The wifi device is soft blocked 25 | Run the following command to unblock it 26 | $ sudo rfkill unblock wlan 27 | "# 28 | ); 29 | std::process::exit(1); 30 | } 31 | 2 => { 32 | eprintln!("The wifi device is hard blocked"); 33 | std::process::exit(1); 34 | } 35 | _ => {} 36 | } 37 | break; 38 | } 39 | } 40 | } 41 | Ok(()) 42 | } 43 | -------------------------------------------------------------------------------- /src/station.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, sync::Arc}; 2 | 3 | use futures::future::join_all; 4 | use iwdrs::session::Session; 5 | use ratatui::widgets::TableState; 6 | use tokio::sync::mpsc::UnboundedSender; 7 | 8 | use crate::{ 9 | app::AppResult, 10 | event::Event, 11 | network::Network, 12 | notification::{Notification, NotificationLevel}, 13 | }; 14 | 15 | #[derive(Debug, Clone)] 16 | pub struct Station { 17 | pub session: Arc, 18 | pub state: String, 19 | pub is_scanning: bool, 20 | pub connected_network: Option, 21 | pub new_networks: Vec<(Network, i16)>, 22 | pub known_networks: Vec<(Network, i16)>, 23 | pub known_networks_state: TableState, 24 | pub new_networks_state: TableState, 25 | pub diagnostic: HashMap, 26 | } 27 | 28 | impl Station { 29 | pub async fn new(session: Arc) -> AppResult { 30 | let iwd_station = session.station().unwrap(); 31 | let iwd_station_diagnostic = session.station_diagnostic(); 32 | 33 | let state = iwd_station.state().await?; 34 | let connected_network = { 35 | if let Some(n) = iwd_station.connected_network().await? { 36 | let network = Network::new(n.clone()).await?; 37 | Some(network) 38 | } else { 39 | None 40 | } 41 | }; 42 | 43 | let is_scanning = iwd_station.is_scanning().await?; 44 | let discovered_networks = iwd_station.discovered_networks().await?; 45 | let networks = { 46 | let collected_futures = discovered_networks 47 | .iter() 48 | .map(|(n, signal)| async { 49 | match Network::new(n.clone()).await { 50 | Ok(network) => Ok((network, signal.to_owned())), 51 | Err(e) => Err(e), 52 | } 53 | }) 54 | .collect::>(); 55 | let results = join_all(collected_futures).await; 56 | results 57 | .into_iter() 58 | .filter_map(Result::ok) 59 | .collect::>() 60 | }; 61 | 62 | let new_networks: Vec<(Network, i16)> = networks 63 | .clone() 64 | .into_iter() 65 | .filter(|(net, _signal)| net.known_network.is_none()) 66 | .collect(); 67 | 68 | let known_networks: Vec<(Network, i16)> = networks 69 | .into_iter() 70 | .filter(|(net, _signal)| net.known_network.is_some()) 71 | .collect(); 72 | 73 | let mut new_networks_state = TableState::default(); 74 | if new_networks.is_empty() { 75 | new_networks_state.select(None); 76 | } else { 77 | new_networks_state.select(Some(0)); 78 | } 79 | 80 | let mut known_networks_state = TableState::default(); 81 | 82 | if known_networks.is_empty() { 83 | known_networks_state.select(None); 84 | } else { 85 | known_networks_state.select(Some(0)); 86 | } 87 | 88 | let mut diagnostic: HashMap = HashMap::new(); 89 | 90 | if let Some(station_diagnostic) = iwd_station_diagnostic { 91 | if let Ok(d) = station_diagnostic.get().await { 92 | diagnostic = d; 93 | } 94 | } 95 | 96 | Ok(Self { 97 | session, 98 | state, 99 | is_scanning, 100 | connected_network, 101 | new_networks, 102 | known_networks, 103 | known_networks_state, 104 | new_networks_state, 105 | diagnostic, 106 | }) 107 | } 108 | 109 | pub async fn refresh(&mut self) -> AppResult<()> { 110 | let iwd_station = self.session.station().unwrap(); 111 | let iwd_station_diagnostic = self.session.station_diagnostic(); 112 | 113 | let state = iwd_station.state().await?; 114 | let is_scanning = iwd_station.is_scanning().await?; 115 | let connected_network = { 116 | if let Some(n) = iwd_station.connected_network().await? { 117 | let network = Network::new(n.clone()).await?; 118 | Some(network.to_owned()) 119 | } else { 120 | None 121 | } 122 | }; 123 | let discovered_networks = iwd_station.discovered_networks().await?; 124 | let networks = { 125 | let collected_futures = discovered_networks 126 | .iter() 127 | .map(|(n, signal)| async { 128 | match Network::new(n.clone()).await { 129 | Ok(network) => Ok((network, signal.to_owned())), 130 | Err(e) => Err(e), 131 | } 132 | }) 133 | .collect::>(); 134 | let results = join_all(collected_futures).await; 135 | results 136 | .into_iter() 137 | .filter_map(Result::ok) 138 | .collect::>() 139 | }; 140 | 141 | let new_networks: Vec<(Network, i16)> = networks 142 | .clone() 143 | .into_iter() 144 | .filter(|(net, _signal)| net.known_network.is_none()) 145 | .collect(); 146 | 147 | let known_networks: Vec<(Network, i16)> = networks 148 | .into_iter() 149 | .filter(|(net, _signal)| net.known_network.is_some()) 150 | .collect(); 151 | 152 | self.state = state; 153 | self.is_scanning = is_scanning; 154 | 155 | if self.new_networks.len() != new_networks.len() { 156 | let mut new_networks_state = TableState::default(); 157 | if new_networks.is_empty() { 158 | new_networks_state.select(None); 159 | } else { 160 | new_networks_state.select(Some(0)); 161 | } 162 | 163 | self.new_networks_state = new_networks_state; 164 | self.new_networks = new_networks; 165 | } else { 166 | self.new_networks.iter_mut().for_each(|(net, signal)| { 167 | let n = new_networks 168 | .iter() 169 | .find(|(refreshed_net, _signal)| refreshed_net.name == net.name); 170 | 171 | if let Some((_, refreshed_signal)) = n { 172 | *signal = *refreshed_signal; 173 | } 174 | }) 175 | } 176 | 177 | if self.known_networks.len() != known_networks.len() { 178 | let mut known_networks_state = TableState::default(); 179 | if known_networks.is_empty() { 180 | known_networks_state.select(None); 181 | } else { 182 | known_networks_state.select(Some(0)); 183 | } 184 | self.known_networks_state = known_networks_state; 185 | self.known_networks = known_networks; 186 | } else { 187 | self.known_networks.iter_mut().for_each(|(net, signal)| { 188 | let n = known_networks 189 | .iter() 190 | .find(|(refreshed_net, _signal)| refreshed_net.name == net.name); 191 | 192 | if let Some((refreshed_net, refreshed_signal)) = n { 193 | net.known_network.as_mut().unwrap().is_autoconnect = 194 | refreshed_net.known_network.as_ref().unwrap().is_autoconnect; 195 | *signal = *refreshed_signal; 196 | } 197 | }) 198 | } 199 | 200 | self.connected_network = connected_network; 201 | 202 | if let Some(station_diagnostic) = iwd_station_diagnostic { 203 | if let Ok(d) = station_diagnostic.get().await { 204 | self.diagnostic = d; 205 | } 206 | } 207 | 208 | Ok(()) 209 | } 210 | 211 | pub async fn scan(&self, sender: UnboundedSender) -> AppResult<()> { 212 | let iwd_station = self.session.station().unwrap(); 213 | match iwd_station.scan().await { 214 | Ok(_) => Notification::send( 215 | "Start Scanning".to_string(), 216 | NotificationLevel::Info, 217 | sender, 218 | )?, 219 | Err(e) => Notification::send(e.to_string(), NotificationLevel::Error, sender.clone())?, 220 | } 221 | 222 | Ok(()) 223 | } 224 | 225 | pub async fn disconnect(&self, sender: UnboundedSender) -> AppResult<()> { 226 | let iwd_station = self.session.station().unwrap(); 227 | match iwd_station.disconnect().await { 228 | Ok(_) => Notification::send( 229 | format!( 230 | "Disconnected from {}", 231 | self.connected_network.as_ref().unwrap().name 232 | ), 233 | NotificationLevel::Info, 234 | sender, 235 | )?, 236 | Err(e) => Notification::send(e.to_string(), NotificationLevel::Error, sender.clone())?, 237 | } 238 | Ok(()) 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /src/tui.rs: -------------------------------------------------------------------------------- 1 | use crate::app::{App, AppResult}; 2 | use crate::event::EventHandler; 3 | use crate::ui; 4 | use crossterm::event::{DisableMouseCapture, EnableMouseCapture}; 5 | use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}; 6 | use ratatui::backend::Backend; 7 | use ratatui::Terminal; 8 | use std::io; 9 | use std::panic; 10 | 11 | #[derive(Debug)] 12 | pub struct Tui { 13 | terminal: Terminal, 14 | pub events: EventHandler, 15 | } 16 | 17 | impl Tui { 18 | pub fn new(terminal: Terminal, events: EventHandler) -> Self { 19 | Self { terminal, events } 20 | } 21 | 22 | pub fn init(&mut self) -> AppResult<()> { 23 | terminal::enable_raw_mode()?; 24 | crossterm::execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture)?; 25 | 26 | let panic_hook = panic::take_hook(); 27 | panic::set_hook(Box::new(move |panic| { 28 | Self::reset().expect("failed to reset the terminal"); 29 | panic_hook(panic); 30 | })); 31 | 32 | self.terminal.hide_cursor()?; 33 | self.terminal.clear()?; 34 | Ok(()) 35 | } 36 | 37 | pub fn draw(&mut self, app: &mut App) -> AppResult<()> { 38 | self.terminal.draw(|frame| ui::render(app, frame))?; 39 | Ok(()) 40 | } 41 | 42 | fn reset() -> AppResult<()> { 43 | terminal::disable_raw_mode()?; 44 | crossterm::execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture)?; 45 | Ok(()) 46 | } 47 | 48 | pub fn exit(&mut self) -> AppResult<()> { 49 | Self::reset()?; 50 | self.terminal.show_cursor()?; 51 | Ok(()) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/ui.rs: -------------------------------------------------------------------------------- 1 | use std::sync::atomic::Ordering; 2 | 3 | use ratatui::Frame; 4 | 5 | use crate::app::{App, FocusedBlock}; 6 | 7 | use crate::auth::Auth; 8 | 9 | pub fn render(app: &mut App, frame: &mut Frame) { 10 | // Select mode 11 | if app.reset_mode { 12 | app.render(frame); 13 | } else { 14 | // App 15 | app.adapter.render(frame, app.color_mode, app.focused_block); 16 | 17 | if app.focused_block == FocusedBlock::AdapterInfos { 18 | app.adapter.render_adapter(frame, app.color_mode); 19 | } 20 | 21 | // Auth Popup 22 | if app.authentication_required.load(Ordering::Relaxed) { 23 | app.focused_block = FocusedBlock::AuthKey; 24 | let censored_password = "*".repeat(app.passkey_input.value().len()); 25 | if !app.show_password { 26 | Auth.render(frame, &censored_password); 27 | } else { 28 | Auth.render(frame, app.passkey_input.value()); 29 | } 30 | } 31 | 32 | // Access Point Popup 33 | if let Some(ap) = &app.adapter.device.access_point { 34 | if ap.ap_start.load(Ordering::Relaxed) { 35 | app.focused_block = FocusedBlock::AccessPointInput; 36 | ap.render_input(frame); 37 | } 38 | } 39 | 40 | // Help 41 | if let FocusedBlock::Help = app.focused_block { 42 | app.help.render(frame, app.color_mode); 43 | } 44 | 45 | // Notifications 46 | for (index, notification) in app.notifications.iter().enumerate() { 47 | notification.render(index, frame); 48 | } 49 | } 50 | } 51 | --------------------------------------------------------------------------------