├── .gitignore ├── .gitmodules ├── LICENSE ├── build-dist ├── dev-requirements.txt ├── icon ├── apart.png └── apart.svg ├── makefile ├── misc ├── apart-gtk.desktop ├── clean-container ├── com.github.alexheretic.pkexec.apart-gtk.policy ├── deb-build-packages └── docker-build-packages ├── readme.md ├── release ├── src ├── apart.css ├── apartcore.py ├── app.py ├── cloneentry.py ├── dialog.py ├── gtktools.py ├── historic_job.py ├── main.py ├── partinfo.py ├── progress.py ├── restoreentry.py ├── running_job.py ├── settings.py └── util.py ├── start ├── start-test-app └── test ├── mocklsblk ├── mocklsblk-livecd ├── mocklsblk-md ├── mockpcl ├── mockpcl.dd ├── mockpcl.ext2 ├── mockpcl.f2fs └── mockpcl.ntfs /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .idea 3 | target/ 4 | src/__pycache__/ 5 | dist/ 6 | build/ 7 | last-release/ 8 | history*yaml 9 | Cargo.lock 10 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "apart-core"] 2 | path = apart-core 3 | url = https://github.com/alexheretic/apart-core 4 | -------------------------------------------------------------------------------- /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 | {one line to give the program's name and a brief idea of what it does.} 635 | Copyright (C) {year} {name of author} 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 | {project} Copyright (C) {year} {fullname} 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 | -------------------------------------------------------------------------------- /build-dist: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -eu 4 | APART_CORE_VERSION="0.3.16" 5 | APART_CORE_SHA256='1cd9d2e59f0a50d539a9d67f23dddfc41fd24c3945d5a4b8a39f9f74f19832ba' 6 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 7 | 8 | cd "$DIR" 9 | rm -rf target 10 | mkdir -p "$DIR"/target/lib/apart-gtk/src 11 | 12 | echo "Building apart-core $APART_CORE_VERSION" 13 | cd "$DIR"/target 14 | curl -o core.tar.gz -L https://github.com/alexheretic/apart-core/archive/refs/tags/v$APART_CORE_VERSION.tar.gz 15 | sha256sum core.tar.gz 16 | echo "$APART_CORE_SHA256 core.tar.gz" | sha256sum -c 17 | tar xf core.tar.gz 18 | (cd apart-core-$APART_CORE_VERSION && cargo build --release) 19 | cp "${CARGO_TARGET_DIR:-"apart-core-$APART_CORE_VERSION/target"}/release/apart-core" lib/apart-gtk/ 20 | strip lib/apart-gtk/apart-core 21 | rm core.tar.gz 22 | rm -rf apart-core-$APART_CORE_VERSION 23 | 24 | 25 | echo 'Copy & compile sources' 26 | cd "$DIR"/target/lib/apart-gtk/src 27 | cp -r "$DIR"/src/* ./ 28 | python3 -m compileall ./ 29 | 30 | 31 | echo 'Copy misc & icons' 32 | cd "$DIR"/target 33 | mkdir -p share/applications 34 | cp "$DIR"/misc/*.desktop share/applications/ 35 | mkdir -p share/icons/hicolor/scalable/apps/ 36 | cp "$DIR"/icon/apart.svg share/icons/hicolor/scalable/apps/ 37 | mkdir -p share/icons/hicolor/48x48/apps/ 38 | cp "$DIR"/icon/apart.png share/icons/hicolor/48x48/apps/ 39 | mkdir -p share/polkit-1/actions 40 | cp "$DIR"/misc/*.policy share/polkit-1/actions/ 41 | mkdir -p bin 42 | ln -s ../lib/apart-gtk/src/app.py bin/apart-gtk 43 | 44 | 45 | command -v tree >/dev/null 2>&1 && tree "$DIR"/target || true 46 | -------------------------------------------------------------------------------- /dev-requirements.txt: -------------------------------------------------------------------------------- 1 | humanize>=0.5.1 2 | PyYAML>=5 3 | pyzmq>=15.3.0 4 | -------------------------------------------------------------------------------- /icon/apart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexheretic/apart-gtk/0d6e320df70deb8ea39768440f155e79cdcfb8cd/icon/apart.png -------------------------------------------------------------------------------- /icon/apart.svg: -------------------------------------------------------------------------------- 1 | 2 | 15 | 17 | 18 | 20 | image/svg+xml 21 | 23 | 24 | 25 | 26 | 27 | 48 | 51 | 52 | 54 | 60 | 63 | 67 | 68 | 69 | 71 | 75 | 79 | 83 | 84 | 86 | 90 | 91 | 93 | 97 | 98 | 102 | 106 | 110 | 114 | 119 | 123 | 127 | 128 | 129 | 134 | 138 | 142 | 143 | 144 | 145 | -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | 2 | PREFIX = /usr 3 | 4 | all: 5 | ./build-dist 6 | 7 | install: target 8 | mkdir -p $(DESTDIR)$(PREFIX) 9 | cp -R ./target/* $(DESTDIR)$(PREFIX)/ 10 | $(info consider running: sudo gtk-update-icon-cache $(DESTDIR)$(PREFIX)/share/icons/hicolor) 11 | 12 | uninstall: 13 | rm -f $(DESTDIR)$(PREFIX)/bin/apart-gtk 14 | rm -rf $(DESTDIR)$(PREFIX)/lib/apart-gtk 15 | rm -f $(DESTDIR)$(PREFIX)/share/applications/apart-gtk.desktop 16 | rm -f $(DESTDIR)$(PREFIX)/share/icons/hicolor/scalable/apps/apart.svg 17 | rm -f $(DESTDIR)$(PREFIX)/share/icons/hicolor/48x48/apps/apart.png 18 | rm -f $(DESTDIR)$(PREFIX)/share/polkit-1/actions/com.github.alexheretic.pkexec.apart-gtk.policy 19 | -------------------------------------------------------------------------------- /misc/apart-gtk.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Name=Apart 3 | GenericName=Partition Clone, Backup And Restore 4 | X-GNOME-FullName=Apart Partition Clone, Backup And Restore 5 | Comment=Backup/Clone partition and restore from saved images 6 | Exec=/usr/bin/apart-gtk 7 | Icon=apart 8 | Terminal=false 9 | Type=Application 10 | Categories=Filesystem; 11 | Keywords=Partition;Clone;Backup;Restore; 12 | StartupNotify=true 13 | -------------------------------------------------------------------------------- /misc/clean-container: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | ## Run on ubuntu container to cleanup for image creation 4 | 5 | apt autoremove -y 6 | apt autoclean -y 7 | rm -rf /tmp/installdir 8 | rm -rf apart.tar.gz 9 | rm -rf apart-gtk/ 10 | rm -f *.deb 11 | rm -f *.rpm 12 | -------------------------------------------------------------------------------- /misc/com.github.alexheretic.pkexec.apart-gtk.policy: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | Authentication is required to run Apart 8 | apart 9 | 10 | auth_admin 11 | auth_admin 12 | auth_admin 13 | 14 | /usr/lib/apart-gtk/apart-core 15 | 16 | 17 | -------------------------------------------------------------------------------- /misc/deb-build-packages: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | ## Run on ubuntu container to build packages 4 | 5 | set -eu 6 | APART_VERSION="" 7 | APART_SHA256="" 8 | while test $# -gt 0; do 9 | case "$1" in 10 | --version*) 11 | APART_VERSION=`echo $1 | sed -e 's/^[^=]*=//g'` 12 | shift 13 | ;; 14 | --sha256*) 15 | # sha256sum of tar file, ie 0.11 had f19b7f34bf6f1e62e82fca54a202142a4ab100344f2e88920e3032abff4eaba8 16 | APART_SHA256=`echo $1 | sed -e 's/^[^=]*=//g'` 17 | shift 18 | ;; 19 | esac 20 | done 21 | 22 | if [ -z "$APART_VERSION" ] || [ -z "$APART_SHA256" ]; then 23 | echo "Required arguments --version=VERSION --sha256=HASH" 24 | exit 1 25 | fi 26 | 27 | apt update 28 | apt install -y ruby-dev build-essential git libzmq3-dev curl pkg-config python3 rpm 29 | if ! command -v fpm >/dev/null 2>&1; then 30 | gem install dotenv -v 2.8.1 31 | gem install fpm 32 | fi 33 | 34 | if [ ! -f ~/.cargo/env ]; then 35 | curl https://sh.rustup.rs -sSf | sh -s -- --default-toolchain stable -y 36 | source ~/.cargo/env 37 | else 38 | source ~/.cargo/env 39 | rustup update 40 | fi 41 | 42 | rm -rf apart-gtk/ 43 | rm -rf /tmp/installdir 44 | 45 | echo "Downloading & verifying apart-gtk-v$APART_VERSION ..." 46 | curl -o apart.tar.gz -L https://github.com/alexheretic/apart-gtk/archive/refs/tags/v$APART_VERSION.tar.gz 47 | echo "$APART_SHA256 apart.tar.gz" | sha256sum -c 48 | tar xf apart.tar.gz 49 | cd apart-gtk-$APART_VERSION 50 | 51 | make 52 | mkdir -p /tmp/installdir 53 | make install DESTDIR=/tmp/installdir 54 | 55 | cd / 56 | 57 | fpm -s dir -t deb -n apart-gtk -v $APART_VERSION -C /tmp/installdir \ 58 | -p apart-gtk_VERSION_ARCH.deb \ 59 | -d policykit-1 \ 60 | -d "partclone >= 0.2.89" \ 61 | -d pigz \ 62 | -d zstd \ 63 | -d liblz4-tool \ 64 | -d "python3-humanize >= 0.5.1" \ 65 | -d "python3-zmq >= 15.3.0" \ 66 | -d "python3-yaml >= 3.11" \ 67 | -d "libgtk-3-0 >= 3.22" \ 68 | --license "GPL-3" \ 69 | -m "Alex Butler " \ 70 | --url "https://github.com/alexheretic/apart-gtk" \ 71 | --description "GUI for cloning & restoring disk partitions to & from compressed image files" \ 72 | usr/bin usr/lib usr/share 73 | 74 | fpm -s dir -t rpm -n apart-gtk -v $APART_VERSION -C /tmp/installdir \ 75 | -p apart-gtk-VERSION-1.ARCH.rpm \ 76 | -d polkit \ 77 | -d "partclone >= 0.2.89" \ 78 | -d pigz \ 79 | -d lz4 \ 80 | -d "zstd >= 1.2.0" \ 81 | -d "python3-humanize >= 0.5.1" \ 82 | -d "python3-zmq >= 15.3.0" \ 83 | -d "python3-yaml >= 3.11" \ 84 | -d "gtk3 >= 3.22" \ 85 | --license "GPL-3" \ 86 | -m "Alex Butler " \ 87 | --url "https://github.com/alexheretic/apart-gtk" \ 88 | --description "GUI for cloning & restoring disk partitions to & from compressed image files" \ 89 | usr/bin usr/lib usr/share 90 | -------------------------------------------------------------------------------- /misc/docker-build-packages: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -eu 4 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 5 | DOCKER_NAME="apart-pkg-build" 6 | APART_VERSION="" 7 | APART_SHA256="" 8 | while test $# -gt 0; do 9 | case "$1" in 10 | --version*) 11 | APART_VERSION=`echo $1 | sed -e 's/^[^=]*=//g'` 12 | shift 13 | ;; 14 | --sha256*) 15 | # sha256sum of tar file, ie 0.11 had f19b7f34bf6f1e62e82fca54a202142a4ab100344f2e88920e3032abff4eaba8 16 | APART_SHA256=`echo $1 | sed -e 's/^[^=]*=//g'` 17 | shift 18 | ;; 19 | esac 20 | done 21 | 22 | if [ -z "$APART_VERSION" ] || [ -z "$APART_SHA256" ]; then 23 | echo "Required arguments --version=VERSION --sha256=HASH" 24 | exit 1 25 | fi 26 | 27 | if [[ $(systemctl status docker | grep -c 'Active: inactive') -gt 0 ]]; then 28 | echo "docker is currently inactive, must be started" 29 | sudo systemctl start docker 30 | fi 31 | 32 | cd "$DIR/.." 33 | mkdir -p last-release 34 | cd last-release 35 | 36 | if [[ "$(docker images)" != *"apart/pkg-builder"* ]]; then 37 | echo "Creating apart/pkg-builder image, and using to build packages" 38 | docker run --name "$DOCKER_NAME-temp" -d ubuntu:18.04 tail -f /dev/null 39 | docker cp "$DIR/deb-build-packages" "$DOCKER_NAME-temp":/ 40 | docker cp "$DIR/clean-container" "$DOCKER_NAME-temp":/ 41 | docker exec -t "$DOCKER_NAME-temp" ./deb-build-packages --version=$APART_VERSION --sha256=$APART_SHA256 42 | docker cp "$DOCKER_NAME-temp":/apart-gtk_${APART_VERSION}_amd64.deb ./ 43 | docker cp "$DOCKER_NAME-temp":/apart-gtk-${APART_VERSION}-1.x86_64.rpm ./ 44 | docker exec -t "$DOCKER_NAME-temp" ./clean-container 45 | docker stop "$DOCKER_NAME-temp" 46 | docker export "$DOCKER_NAME-temp" | docker import - apart/pkg-builder 47 | docker rm $DOCKER_NAME-temp 48 | else 49 | echo "Using already created apart/pkg-builder image to build packages" 50 | trap "docker rm $DOCKER_NAME >/dev/null 2>&1" EXIT ERR 51 | docker create --name $DOCKER_NAME -t apart/pkg-builder ./deb-build-packages --version=$APART_VERSION --sha256=$APART_SHA256 52 | docker cp $DIR/deb-build-packages $DOCKER_NAME:/ 53 | docker start -a $DOCKER_NAME 54 | docker cp $DOCKER_NAME:/apart-gtk_${APART_VERSION}_amd64.deb ./ 55 | docker cp $DOCKER_NAME:/apart-gtk-${APART_VERSION}-1.x86_64.rpm ./ 56 | fi 57 | 58 | gpg --armor --detach-sign apart-gtk_${APART_VERSION}_amd64.deb 59 | gpg --armor --detach-sign apart-gtk-${APART_VERSION}-1.x86_64.rpm 60 | 61 | nautilus ./ 62 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | Apart GTK 4 | ========= 5 | Linux GUI for cloning & restoring disk partitions to & from compressed image files, using [partclone](http://partclone.org) to do the heavy lifting. 6 | 7 |
8 | 9 | ![Usage](https://raw.githubusercontent.com/alexheretic/apart-gtk/readme-images/apart-gtk-usage.gif?raw=true "Usage") 10 | 11 | ## Install 12 | * Arch: available on the [AUR](https://aur.archlinux.org/packages/apart-gtk) 13 | * Ubuntu/Debian: .deb package available [in releases](https://github.com/alexheretic/apart-gtk/releases) 14 | * Fedora: .rpm package available [in releases](https://github.com/alexheretic/apart-gtk/releases) 15 | 16 | If you have dependency issues, see the build sections for your distro. The GTK 3.22 requirement means you'll probably need a >= 2017 distro. 17 | 18 | ## Dependencies 19 | * python >= 3.5 20 | * python-gobject, GTK >= 3.22 21 | * pyzmq, humanize, pyyaml 22 | * polkit - for non-root usage 23 | * [apart-core](https://github.com/alexheretic/apart-core) 24 | * zeromq >= 4.1 25 | * util-linux >= 2.28.2 26 | * partclone 27 | * pigz 28 | * lz4 *(optional: adds compression option)* 29 | * zstd >= 1.2.0 *(optional: adds compression option)* 30 | 31 | ## Build on Arch 32 | `pacman -Syu --needed python python-gobject python-yaml python-pyzmq python-humanize gtk3 partclone zeromq rustup git pigz polkit lz4 zstd` 33 | 34 | Follow build steps below. 35 | 36 | ## Build on Ubuntu >= 17.04 37 | Build deps: `apt install build-essential git libzmq3-dev curl pkg-config python3` + `rustup` 38 | 39 | Run deps: `apt install policykit-1 partclone pigz python3-humanize python3-zmq python3-yaml libgtk-3-0 liblz4-tool zstd` 40 | 41 | Follow build steps below. 42 | 43 | ## Build on Fedora >= 25 44 | `dnf install git zeromq-devel rust cargo python3-zmq python3-yaml python3-humanize pigz polkit gtk3 lz4 zstd` 45 | 46 | Install partclone, ie with something like 47 | ```sh 48 | wget https://forensics.cert.org/fedora/cert/25/x86_64//partclone-0.2.90-1.fc25.x86_64.rpm 49 | rpm -Uvh partclone-0.2.90-1.fc25.x86_64.rpm 50 | ``` 51 | 52 | Follow build steps below. 53 | 54 | ## Build 55 | Run `make` having installed the above build dependencies 56 | 57 | ## Manual Install 58 | After building run `make install` which copies the build made in ./target to /usr 59 | ``` 60 | /usr 61 | ├─ bin 62 | │ └─ apart-gtk 63 | ├─ lib/apart-gtk 64 | │ ├─ apart-core 65 | │ └─ src 66 | │ ├─ app.py 67 | │ └─ ... python files 68 | └─ share 69 | ├─ applications/apart-gtk.desktop 70 | ├─ icons/hicolor/scalable/apps/apart.svg 71 | ├─ icons/hicolor/48x48/apps/apart.png 72 | └─ polkit-1/actions/com.github.alexheretic.pkexec.apart-gtk.policy 73 | ``` 74 | 75 | `make uninstall` can be used to remove these files 76 | 77 | ## Run in test mode 78 | With the dev dependencies installed run `./start-test-app` to run from src/ a version of the code with 79 | partclone & partition info mocked. This is useful for GUI development, as you can clone and restore without data risk. 80 | 81 | Simply using `./start` will run against real disks using a dev version of [apart-core](https://github.com/alexheretic/apart-core) useful when testing changes to the core. 82 | -------------------------------------------------------------------------------- /release: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -eu 4 | dir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 5 | 6 | rm -rf $dir/last-release 7 | mkdir $dir/last-release 8 | cd $dir/last-release 9 | 10 | tagname=$(../src/app.py -v | cut -d ' ' -f 2) # requires app.py to be runnable, ie dev dependencies 11 | if git rev-parse $tagname >/dev/null 2>&1 12 | then 13 | echo "tag $tagname already exists" >&2 14 | exit 1 15 | fi 16 | 17 | echo "Release $tagname" 18 | read -p "continue? [y/N] " -n 1 -r 19 | echo 20 | if ! [[ $REPLY =~ ^[^Nn]$ ]]; then 21 | exit 0 22 | fi 23 | 24 | git tag -s $tagname -m "Release $tagname" 25 | git push --tags 26 | 27 | curl -OL "https://github.com/alexheretic/apart-gtk/archive/refs/tags/$tagname.tar.gz" 28 | # gpg --armor --detach-sign "$tagname.tar.gz" 29 | 30 | sha256sum *tar.gz* 31 | SHA256=$(sha256sum *tar.gz | cut -d ' ' -f 1) 32 | echo "Pushed tag $tagname to repo, ready to add notes" 33 | echo "build deb with: misc/docker-build-packages --version=${tagname#?} --sha256=$SHA256" 34 | 35 | gio open "https://github.com/alexheretic/apart-gtk/releases/new?tag=$tagname" 36 | 37 | cd $dir 38 | -------------------------------------------------------------------------------- /src/apart.css: -------------------------------------------------------------------------------- 1 | .new-clone-options { 2 | margin: 6px; 3 | } 4 | .new-clone-options .dim-label { 5 | margin: 6px 6px 6px 0; 6 | } 7 | .new-clone-options filechooserbutton { 8 | min-width: 300px; 9 | } 10 | .new-clone-buttons button:last-child { 11 | margin-left: 6px; 12 | } 13 | 14 | .info-key, .info-val { 15 | padding-top: 5px; 16 | } 17 | .info-key { 18 | margin-right: 1ex; 19 | } 20 | .info-val { 21 | margin-right: 2ex; 22 | } 23 | 24 | .part-info { 25 | padding: 6px; 26 | } 27 | .part-info button { 28 | margin-left: 6px 29 | } 30 | 31 | .progress-view { 32 | background: @base_color; 33 | } 34 | .section-title { 35 | padding: 6px 6px 0; 36 | font-size: 100%; 37 | font-weight: bold; 38 | } 39 | .finished-jobs, .jobs { 40 | padding: 6px; 41 | } 42 | .finished-jobs separator { 43 | margin: 6px 0; 44 | } 45 | 46 | .job-stats { 47 | margin-top: -3px; 48 | } 49 | .job-stats spinner { 50 | margin-bottom: -3px; 51 | } 52 | 53 | .finished-job-stats { 54 | margin-top: -3px; 55 | margin-left: 2em; 56 | } 57 | 58 | .finished-job-title > image { 59 | padding-right: 6px; 60 | margin-bottom: 2px; 61 | } 62 | .job-buttons button { 63 | margin-right: 6px; 64 | } 65 | .job-buttons button:last-child { 66 | margin-right: 0; 67 | } 68 | 69 | .finish-label { 70 | padding: 0 1em; 71 | } 72 | 73 | .job-name { 74 | margin: 0 1ex; 75 | font-style: italic; 76 | font-weight: bold; 77 | } 78 | 79 | /* enforce sidebar style for themes that lack it, ie ambiance */ 80 | .frame row { 81 | padding: 10px 4px; 82 | } 83 | -------------------------------------------------------------------------------- /src/apartcore.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | import yaml 3 | import subprocess 4 | import sys 5 | import os 6 | import zmq 7 | from threading import Thread 8 | from typing import * 9 | from util import default_datetime_to_utc 10 | 11 | # True: print out messages <--> core 12 | LOG_MESSAGES = False 13 | 14 | 15 | class MessageListener: 16 | def __init__(self, on_message: Callable[[Dict], None], 17 | message_predicate: Callable[[Dict], bool] = lambda m: True, 18 | listen_to: 'ApartCore' = None, 19 | one_time: bool = False): 20 | self.one_time = one_time 21 | self.input_on_message = on_message 22 | 23 | self.message_predicate = message_predicate 24 | self.remove_fn = None 25 | if listen_to: 26 | self.listen_to(listen_to) 27 | 28 | def listen_to(self, core: 'ApartCore') -> 'MessageListener': 29 | self.remove_fn = core.register(self) 30 | return self 31 | 32 | def stop_listening(self) -> 'MessageListener': 33 | if self.remove_fn is not None: 34 | self.remove_fn() 35 | return self 36 | 37 | def on_message(self, msg: Dict) -> bool: 38 | """Return False implies stop listening""" 39 | self.input_on_message(msg) 40 | return not self.one_time 41 | 42 | 43 | class ApartCore(Thread): 44 | def __init__(self, listeners: List[MessageListener] = None, 45 | on_finish: Callable[[int], None] = lambda return_code: None): 46 | """Starts an apart-core command and starts listening for zmq messages on this new thread""" 47 | Thread.__init__(self, name='apart-core-runner') 48 | self.ipc_address = 'ipc:///tmp/apart-gtk-{}.ipc'.format(uuid.uuid4()) 49 | self.zmq_context = zmq.Context() 50 | self.socket = self.zmq_context.socket(zmq.PAIR) 51 | self.socket.setsockopt(zmq.RCVTIMEO, 100) 52 | self.socket.bind(self.ipc_address) 53 | self.on_finish = on_finish 54 | self.listeners = listeners or [] # List[MessageListener] 55 | 56 | if LOG_MESSAGES: 57 | self.register(MessageListener(lambda msg: print('apart-core ->\n {}'.format(str(msg))))) 58 | 59 | # Current default is apart-core binary stored in the directory above these sources 60 | apart_core_cmd = os.environ.get('APART_GTK_CORE_CMD') or \ 61 | os.path.abspath(os.path.dirname(os.path.realpath(__file__)) + '/../apart-core') 62 | 63 | try: 64 | if os.geteuid() == 0 or os.environ.get('APART_PARTCLONE_CMD'): 65 | self.process = subprocess.Popen([apart_core_cmd, self.ipc_address]) 66 | else: 67 | pkexec_args = ['pkexec'] 68 | lsblk_override = os.environ.get('APART_LSBLK_CMD') 69 | if lsblk_override: 70 | pkexec_args.append('env') 71 | pkexec_args.append('APART_LSBLK_CMD={}'.format(lsblk_override)) 72 | pkexec_args.extend([apart_core_cmd, self.ipc_address]) 73 | 74 | self.process = subprocess.Popen(pkexec_args) 75 | except FileNotFoundError: 76 | if os.geteuid() == 0: 77 | print('apart-core command not found at \'' + apart_core_cmd + '\'', file=sys.stderr) 78 | else: 79 | print('pkexec command not found, install polkit or run as root', file=sys.stderr) 80 | self.zmq_context.destroy() 81 | sys.exit(1) 82 | self.start() 83 | 84 | def run(self): 85 | while self.process.returncode is None: 86 | try: 87 | msg = yaml.safe_load(self.socket.recv_string()) 88 | except zmq.error.Again: 89 | # no messages received within timeout 90 | self.process.poll() 91 | continue 92 | default_datetime_to_utc(msg) 93 | to_remove = [] 94 | for listener in self.listeners: 95 | if listener.message_predicate(msg): 96 | if not listener.on_message(msg): 97 | to_remove.append(listener) 98 | for listener in to_remove: 99 | listener.stop_listening() 100 | if msg['type'] == 'status' and msg['status'] == 'dying': 101 | break 102 | self.process.poll() 103 | self.zmq_context.destroy() 104 | self.on_finish(self.process.returncode) 105 | 106 | def kill(self): 107 | if not self.zmq_context.closed: 108 | self.socket.send_string('type: kill-request') 109 | self.join() 110 | 111 | def send(self, message: str): 112 | if not self.zmq_context.closed: 113 | self.socket.send_string(message) 114 | if LOG_MESSAGES: 115 | print('apart-core <-\n----\n{}\n----'.format(message)) 116 | 117 | def register(self, message_listener: MessageListener) -> Callable[[], None]: 118 | """:return: remove function""" 119 | self.listeners.append(message_listener) 120 | return lambda: self.listeners.remove(message_listener) 121 | -------------------------------------------------------------------------------- /src/app.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import argparse 3 | import gi 4 | gi.require_version('Gtk', '3.0') # require version before other importing 5 | import os 6 | import signal 7 | from apartcore import ApartCore, MessageListener 8 | from main import CloneBody 9 | from typing import * 10 | from dialog import OkDialog 11 | from gi.repository import GLib, Gtk, Gdk 12 | 13 | # App versions, "major.minor", major => new stuff, minor => fixes 14 | __version__ = '0.29' 15 | 16 | 17 | class LoadingBody(Gtk.Grid): 18 | def __init__(self): 19 | Gtk.Grid.__init__(self) 20 | self.spinner = Gtk.Spinner(halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER, expand=True) 21 | self.spinner.start() 22 | self.add(self.spinner) 23 | 24 | 25 | class Window(Gtk.Window): 26 | def __init__(self): 27 | Gtk.Window.__init__(self, title='apart') 28 | self.dying = False 29 | self.status_listener = MessageListener( 30 | on_message=lambda m: GLib.idle_add(self.on_status_msg, m), 31 | message_predicate=lambda m: m['type'] == 'status') 32 | self.core = ApartCore(listeners=[self.status_listener], 33 | on_finish=lambda code: GLib.idle_add(self.on_delete)) 34 | self.sources = None 35 | self.sources_interest = [] # array of callbacks on sources update 36 | 37 | self.set_default_size(height=300, width=300 * 16/9) 38 | 39 | self.loading_body = LoadingBody() 40 | self.clone_body = None 41 | 42 | self.add(self.loading_body) 43 | 44 | self.connect('delete-event', self.on_delete) 45 | 46 | self.set_icon_name('apart') 47 | 48 | def register_interest_in_sources(self, on_update_callback: Callable[[Dict], None]): 49 | """ 50 | Register a callback to be run every time a new sources message is received 51 | Note callbacks are run on the GTK main thread 52 | Run callback immediately if sources are available 53 | """ 54 | if self.sources: 55 | on_update_callback(self.sources) 56 | self.sources_interest.append(on_update_callback) 57 | 58 | def on_status_msg(self, msg: Dict): 59 | if msg['status'] == 'dying': 60 | self.on_delete() 61 | elif msg['status'] == 'started': 62 | if msg['sources']: 63 | self.sources = msg['sources'] 64 | self.clone_body = CloneBody(self.core, 65 | sources=msg['sources'], 66 | z_options=msg['compression_options']) 67 | self.remove(self.loading_body) 68 | self.add(self.clone_body) 69 | self.clone_body.show_all() 70 | else: 71 | err_dialog = OkDialog(self, 72 | text='No partitions found', 73 | ok_button_text='Exit', 74 | message_type=Gtk.MessageType.ERROR) 75 | err_dialog.run() 76 | err_dialog.destroy() 77 | self.on_delete() 78 | elif self.clone_body and msg['status'] == 'running': 79 | self.sources = msg['sources'] 80 | # TODO move to sources_interest with a reliable way of getting toplevel 81 | self.clone_body.update_sources(msg['sources']) 82 | for callback in self.sources_interest: 83 | callback(self.sources) 84 | 85 | def on_delete(self, *args): 86 | if self.dying: 87 | return 88 | self.status_listener.stop_listening() 89 | if self.clone_body: 90 | self.remove(self.clone_body) 91 | self.clone_body.destroy() 92 | self.add(self.loading_body) 93 | self.core.kill() 94 | self.destroy() 95 | Gtk.main_quit() 96 | self.dying = True 97 | 98 | 99 | def main(): 100 | win = Window() 101 | # allow keyboard interrupt / nodemon to end program cleanly 102 | for sig in [signal.SIGINT, signal.SIGTERM, signal.SIGUSR2]: 103 | signal.signal(sig, lambda _s, _f: win.on_delete()) 104 | 105 | style_provider = Gtk.CssProvider() 106 | style_provider.load_from_path(os.path.dirname(os.path.realpath(__file__)) + "/apart.css") 107 | Gtk.StyleContext.add_provider_for_screen( 108 | Gdk.Screen.get_default(), 109 | style_provider, 110 | Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION 111 | ) 112 | 113 | win.show_all() 114 | Gtk.main() 115 | 116 | 117 | if __name__ == "__main__": 118 | parser = argparse.ArgumentParser( 119 | description='Apart GTK v{} GUI for cloning & restoring partitions'.format(__version__), 120 | prog='apart-gtk') 121 | parser.add_argument('--version', '-v', action='version', version='%(prog)s v{}'.format(__version__)) 122 | parser.parse_args() 123 | main() 124 | -------------------------------------------------------------------------------- /src/cloneentry.py: -------------------------------------------------------------------------------- 1 | import re 2 | from apartcore import ApartCore 3 | from partinfo import PartitionInfo 4 | from gi.repository import Gtk 5 | from typing import * 6 | 7 | invalid_name_re = re.compile(r'[^A-Za-z0-9 _-]') 8 | 9 | 10 | class CloneToImageEntry(Gtk.Box): 11 | def __init__(self, main_view: 'MainView', core: ApartCore, z_options: List[str]): 12 | Gtk.Box.__init__(self, 13 | orientation=Gtk.Orientation.VERTICAL, 14 | expand=True, 15 | halign=Gtk.Align.CENTER) 16 | self.main_view = main_view 17 | self.core = core 18 | 19 | self.title = Gtk.Label('', xalign=0.5) 20 | self.title.get_style_context().add_class('dim-label') 21 | 22 | self.name_label = Gtk.Label("Backup name", xalign=1.0) 23 | self.name_label.get_style_context().add_class('dim-label') 24 | self.name_entry = Gtk.Entry() 25 | self.name_entry.connect('changed', self.on_name_change) 26 | 27 | self.dir_label = Gtk.Label("Backup directory", xalign=1.0) 28 | self.dir_label.get_style_context().add_class('dim-label') 29 | self.dir_entry = Gtk.FileChooserButton(title='Select Backup Directory', 30 | action=Gtk.FileChooserAction.SELECT_FOLDER) 31 | 32 | ordered_z_options = [] 33 | for z_option in z_options: 34 | if z_option == 'uncompressed': 35 | ordered_z_options.append(z_option) 36 | for z_option in z_options: 37 | if z_option != 'uncompressed': 38 | ordered_z_options.append(z_option) 39 | 40 | z_store = Gtk.ListStore(str, str) 41 | for z_option in ordered_z_options: 42 | if z_option == 'uncompressed': 43 | z_store.append([z_option, 'None']) 44 | else: 45 | z_store.append([z_option, z_option]) 46 | 47 | z_renderer = Gtk.CellRendererText() 48 | self.z_label = Gtk.Label("Compression", xalign=1.0) 49 | self.z_label.get_style_context().add_class('dim-label') 50 | self.z_entry = Gtk.ComboBox.new_with_model(z_store) 51 | self.z_entry.pack_start(z_renderer, True) 52 | self.z_entry.add_attribute(z_renderer, 'text', 1) 53 | 54 | if 'zst' in ordered_z_options: 55 | self.z_entry.set_active(ordered_z_options.index('zst')) 56 | elif 'gz' in ordered_z_options: 57 | self.z_entry.set_active(ordered_z_options.index('gz')) 58 | elif 'lz4' in ordered_z_options: 59 | self.z_entry.set_active(ordered_z_options.index('lz4')) 60 | else: 61 | self.z_entry.set_active(0) 62 | 63 | self.z_entry.connect('changed', self.update_title) 64 | 65 | self.options = Gtk.Grid(row_spacing=6) 66 | self.options.get_style_context().add_class('new-clone-options') 67 | self.options.attach(self.title, left=0, top=0, width=2, height=1) 68 | self.options.attach_next_to(self.name_label, self.title, 69 | side=Gtk.PositionType.BOTTOM, width=1, height=1) 70 | self.options.attach_next_to(self.name_entry, self.name_label, 71 | side=Gtk.PositionType.RIGHT, width=1, height=1) 72 | 73 | self.options.attach_next_to(self.z_label, self.name_label, 74 | side=Gtk.PositionType.BOTTOM, width=1, height=1) 75 | self.options.attach_next_to(self.z_entry, self.z_label, 76 | side=Gtk.PositionType.RIGHT, width=1, height=1) 77 | 78 | self.options.attach_next_to(self.dir_label, self.z_label, 79 | side=Gtk.PositionType.BOTTOM, width=1, height=1) 80 | self.options.attach_next_to(self.dir_entry, self.dir_label, 81 | side=Gtk.PositionType.RIGHT, width=1, height=1) 82 | 83 | self.cancel_btn = Gtk.Button('Cancel') 84 | self.cancel_btn.connect('clicked', lambda v: self.main_view.show_progress()) 85 | self.start_btn = Gtk.Button('Create Image') 86 | self.start_btn.connect('clicked', lambda v: self.start_clone()) 87 | 88 | self.buttons = Gtk.Box(halign=Gtk.Align.END) 89 | self.buttons.get_style_context().add_class('new-clone-buttons') 90 | self.buttons.add(self.cancel_btn) 91 | self.buttons.add(self.start_btn) 92 | 93 | self.add(self.options) 94 | self.options.attach_next_to(self.buttons, self.dir_label, 95 | side=Gtk.PositionType.BOTTOM, width=2, height=1) 96 | 97 | self.last_part_info = None 98 | 99 | def use_defaults_for(self, part_info: PartitionInfo): 100 | default_backup_name = part_info.label() or part_info.name() 101 | self.name_entry.set_text(default_backup_name.replace(' ', '_')) 102 | 103 | self.last_part_info = part_info 104 | self.update_title() 105 | 106 | if part_info.is_mounted(): 107 | self.start_btn.set_sensitive(False) 108 | self.start_btn.set_tooltip_text('Partition is currently mounted') 109 | else: 110 | self.start_btn.set_sensitive(True) 111 | self.start_btn.set_tooltip_text(None) 112 | 113 | def update_title(self, *args: None): 114 | compression = True 115 | active = self.z_entry.get_active_iter() 116 | if active: 117 | model = self.z_entry.get_model() 118 | if model[active][0] == 'uncompressed': 119 | compression = False 120 | 121 | if self.last_part_info: 122 | if compression: 123 | self.title.set_text(self.last_part_info.dev_name() + ' ⟶ compressed image file') 124 | else: 125 | self.title.set_text(self.last_part_info.dev_name() + ' ⟶ uncompressed image file') 126 | 127 | def start_clone(self): 128 | backup_dir = self.dir_entry.get_filename() 129 | if not backup_dir or not self.last_part_info: 130 | return 131 | 132 | backup_name = self.backup_name() 133 | source = self.last_part_info.dev_name() 134 | 135 | compression = "" 136 | active = self.z_entry.get_active_iter() 137 | if active: 138 | model = self.z_entry.get_model() 139 | compression = "compression: {}".format(model[active][0]) 140 | 141 | self.core.send('type: clone\nsource: {}\ndestination: {}\nname: {}\n{}'.format(source, 142 | backup_dir, 143 | backup_name, 144 | compression)) 145 | self.main_view.show_progress(fade=True) 146 | 147 | def backup_name(self): 148 | return self.name_entry.get_text() or \ 149 | self.last_part_info and (self.last_part_info.label() or self.last_part_info.name()) 150 | 151 | def on_name_change(self, entry): 152 | entry.set_text(re.sub(invalid_name_re, '', entry.get_text())) 153 | if len(entry.get_text()) > 0 and self.last_part_info and not self.last_part_info.is_mounted(): 154 | self.start_btn.set_sensitive(True) 155 | else: 156 | self.start_btn.set_sensitive(False) 157 | -------------------------------------------------------------------------------- /src/dialog.py: -------------------------------------------------------------------------------- 1 | from gi.repository import Gtk 2 | 3 | 4 | def appropriate_icon(msg_type: Gtk.MessageType) -> str: 5 | if msg_type == Gtk.MessageType.ERROR: 6 | return 'dialog-error' 7 | if msg_type == Gtk.MessageType.WARNING: 8 | return 'dialog-warning' 9 | return 'dialog-information' 10 | 11 | 12 | class OkCancelDialog(Gtk.MessageDialog): 13 | def __init__(self, root: Gtk.Window, text: str, 14 | ok_button_text: str = Gtk.STOCK_OK, 15 | cancel_button_text: str = Gtk.STOCK_CANCEL, 16 | header: str = '', 17 | message_type: Gtk.MessageType = Gtk.MessageType.WARNING): 18 | Gtk.MessageDialog.__init__(self, root, 0, message_type=message_type) 19 | self.set_title(header) 20 | self.icon = Gtk.Image() 21 | self.icon.set_from_icon_name(appropriate_icon(message_type), Gtk.IconSize.LARGE_TOOLBAR) 22 | self.text = Gtk.Label(text) 23 | heading = Gtk.Box() 24 | heading.add(self.icon) 25 | heading.add(self.text) 26 | 27 | self.get_message_area().add(heading) 28 | self.get_message_area().set_spacing(0) 29 | self.add_button(cancel_button_text, Gtk.ResponseType.CANCEL) 30 | self.add_button(ok_button_text, Gtk.ResponseType.OK) 31 | self.show_all() 32 | 33 | 34 | class OkDialog(Gtk.MessageDialog): 35 | def __init__(self, root: Gtk.Window, text: str, 36 | ok_button_text: str = Gtk.STOCK_OK, 37 | header: str = '', 38 | message_type: Gtk.MessageType = Gtk.MessageType.ERROR): 39 | Gtk.MessageDialog.__init__(self, root, 0, message_type=message_type) 40 | self.set_title(header) 41 | self.icon = Gtk.Image() 42 | self.icon.set_from_icon_name(appropriate_icon(message_type), Gtk.IconSize.LARGE_TOOLBAR) 43 | self.text = Gtk.Label(text) 44 | heading = Gtk.Box() 45 | heading.add(self.icon) 46 | heading.add(self.text) 47 | 48 | self.get_message_area().add(heading) 49 | self.get_message_area().set_spacing(0) 50 | self.add_button(ok_button_text, Gtk.ResponseType.OK) 51 | self.show_all() 52 | -------------------------------------------------------------------------------- /src/gtktools.py: -------------------------------------------------------------------------------- 1 | from gi.repository import Gtk 2 | 3 | 4 | def rows(grid: Gtk.Grid) -> int: 5 | return max(map(lambda child: grid.child_get_property(child, 'top-attach'), grid.get_children()), default=-1) + 1 6 | 7 | 8 | class GridRowTenant: 9 | """Tool for managing one-time adding and later removing of exclusive owners of rows of a shared grid""" 10 | def __init__(self, grid: Gtk.Grid): 11 | self.grid = grid 12 | self.base_row = rows(grid) 13 | self.attached = [] 14 | 15 | def attach(self, widget, left=0, top=0, height=1, width=1): 16 | self.grid.attach(widget, left=left, top=self.base_row + top, height=height, width=width) 17 | self.attached.append(widget) 18 | if hasattr(self.grid, 'on_row_change'): 19 | self.grid.on_row_change() 20 | 21 | def all_row_numbers(self): 22 | return map(lambda c: self.grid.child_get_property(c, 'top-attach'), self.attached) 23 | 24 | def evict(self): 25 | for row in reversed(sorted(set(self.all_row_numbers()))): 26 | self.grid.remove_row(row) 27 | if hasattr(self.grid, 'on_row_change'): 28 | self.grid.on_row_change() 29 | 30 | top = self.grid.get_child_at(top=0, left=0) 31 | if top and type(top) is Gtk.Separator: 32 | top.hide() 33 | -------------------------------------------------------------------------------- /src/historic_job.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from gi.repository import Gtk, GLib 3 | import humanize 4 | from apartcore import ApartCore, MessageListener 5 | from dialog import OkCancelDialog, OkDialog 6 | from gtktools import GridRowTenant 7 | from partinfo import key_and_val 8 | import settings 9 | from util import * 10 | from typing import * 11 | 12 | FINISHED_JOB_COLUMNS = 3 13 | FORGET_TEXT = 'Clear' 14 | FORGET_TIP = 'Remove from history' 15 | RERUN_TIP = 'Run again' 16 | DELETE_TIP = 'Delete image file' 17 | DURATION_KEY = 'Runtime' 18 | 19 | 20 | class RevealState(Enum): 21 | REVEALED = 1 22 | HIDDEN = 2 23 | 24 | @classmethod 25 | def default(cls) -> 'RevealState': 26 | return cls.HIDDEN 27 | 28 | 29 | class SourceAvailability(Enum): 30 | AVAILABLE = 1 31 | GONE = 2 32 | MOUNTED = 3 33 | UUID_MISMATCH = 4 34 | 35 | 36 | class FinishedJob: 37 | def __init__(self, final_message: Dict, 38 | progress_view: 'ProgressAndHistoryView', 39 | core: ApartCore, 40 | icon_name: str, 41 | z_options: List[str], 42 | forget_on_rerun: bool = True): 43 | self.msg = final_message 44 | self.finish = self.msg['finish'] # datetime 45 | self.core = core 46 | self.z_options = z_options 47 | self.progress_view = progress_view 48 | self.tenant = None 49 | self.forget_on_rerun = forget_on_rerun 50 | self.extra_user_state = None # RevealState 51 | 52 | self.source_available = SourceAvailability.GONE 53 | 54 | self.duration = key_and_val(DURATION_KEY, str(round_to_second(self.msg['finish'] - 55 | self.msg['start']))) 56 | 57 | self.icon = Gtk.Image.new_from_icon_name(icon_name, Gtk.IconSize.LARGE_TOOLBAR) 58 | title_source = Gtk.Label('', xalign=0) 59 | title_name = Gtk.Label('', xalign=0) 60 | title_destination = Gtk.Label('', xalign=0) 61 | 62 | if final_message['type'].startswith('clone'): 63 | title_source.set_text(rm_dev(self.msg['source'])) 64 | title_name.set_text(extract_name(self.msg['destination'])) 65 | title_name.get_style_context().add_class("job-name") 66 | title_destination.set_text('⟶ ' + extract_directory(self.msg['destination'])) 67 | else: 68 | title_source.set_text(extract_filename(self.msg['source'])) 69 | title_destination.set_text(' ⟶ ' + rm_dev(self.msg['destination'])) 70 | 71 | self.title_inner_box = Gtk.Box(hexpand=True) 72 | self.title_inner_box.add(self.icon) 73 | self.title_inner_box.add(title_source) 74 | self.title_inner_box.add(title_name) 75 | self.title_inner_box.add(title_destination) 76 | self.title_inner_box.get_style_context().add_class('finished-job-title') 77 | self.title_inner_box.show_all() 78 | self.title_box = Gtk.EventBox(visible=True) 79 | self.title_box.add(self.title_inner_box) 80 | 81 | self.title_box.connect('button-press-event', self.on_row_click) 82 | 83 | self.finish_label = Gtk.Label('', halign=Gtk.Align.START, visible=True, hexpand=True) 84 | self.finish_label.get_style_context().add_class('finish-label') 85 | self.finish_label.get_style_context().add_class('dim-label') 86 | self.finish_box = Gtk.EventBox(visible=True) 87 | self.finish_box.add(self.finish_label) 88 | self.finish_box.connect('button-press-event', self.on_row_click) 89 | 90 | self.rerun_btn = Gtk.Button.new_from_icon_name('view-refresh-symbolic', 91 | Gtk.IconSize.SMALL_TOOLBAR) 92 | self.rerun_btn.set_tooltip_text(RERUN_TIP) 93 | self.rerun_btn.connect('clicked', self.rerun) 94 | self.forget_btn = Gtk.Button(FORGET_TEXT) 95 | self.forget_btn.set_tooltip_text(FORGET_TIP) 96 | self.forget_btn.connect('clicked', self.forget) 97 | self.buttons = Gtk.Box(visible=True, halign=Gtk.Align.END) 98 | self.buttons.add(self.rerun_btn) 99 | self.buttons.add(self.forget_btn) 100 | self.buttons.get_style_context().add_class('job-buttons') 101 | self.buttons.show_all() 102 | 103 | self.extra = Gtk.Revealer(transition_duration=settings.animation_duration_ms(), 104 | visible=True) 105 | self.extra.set_reveal_child(RevealState.default() is RevealState.REVEALED) 106 | 107 | self.compression_available = not final_message['type'].startswith('clone') or \ 108 | extract_compression_option(self.msg['destination']) in z_options 109 | self.update() 110 | 111 | def purpose(self) -> str: 112 | return '{} ⟶ {}'.format(rm_dev(self.msg['source']), 113 | extract_directory(self.msg['destination'])) 114 | 115 | def similar_to(self, other: 'FinishedJob') -> bool: 116 | """ 117 | :return True => other is similar enough for both not to need to appear in the history grid 118 | """ 119 | return type(self) == type(other) and self.purpose() == other.purpose() 120 | 121 | def remove_from_grid(self): 122 | if not self.tenant: 123 | raise Exception('Not added to a grid') 124 | self.tenant.evict() 125 | self.tenant = None 126 | 127 | def on_row_click(self, *args): 128 | self.toggle_reveal_extra() 129 | self.extra_user_state = RevealState.REVEALED if self.extra.get_reveal_child() \ 130 | else RevealState.HIDDEN 131 | 132 | def toggle_reveal_extra(self): 133 | revealed = self.extra.get_reveal_child() 134 | if revealed: 135 | self.extra.set_transition_type(Gtk.RevealerTransitionType.SLIDE_UP) 136 | else: 137 | self.extra.set_transition_type(Gtk.RevealerTransitionType.SLIDE_DOWN) 138 | self.extra.set_reveal_child(not revealed) 139 | 140 | def default_extra_reveal(self): 141 | """Return the extra reveal state to default or as user has indicated""" 142 | default = self.extra_user_state or RevealState.default() 143 | if default is RevealState.HIDDEN and self.extra.get_reveal_child() or \ 144 | default is RevealState.REVEALED and not self.extra.get_reveal_child(): 145 | self.toggle_reveal_extra() 146 | 147 | def reveal_extra(self): 148 | if not self.extra.get_reveal_child(): 149 | self.toggle_reveal_extra() 150 | 151 | def update(self): 152 | finished_delta = datetime.now(timezone.utc) - self.finish 153 | if finished_delta < timedelta(minutes=1): 154 | finished_str = "just now" 155 | else: 156 | finished_str = humanize.naturaltime(finished_delta) 157 | 158 | self.finish_label.set_text(finished_str) 159 | 160 | self.rerun_btn.set_sensitive(self.source_available == SourceAvailability.AVAILABLE 161 | and self.compression_available) 162 | tooltip = RERUN_TIP 163 | if self.source_available == SourceAvailability.MOUNTED: 164 | tooltip = rm_dev(self.msg['source']) + " is currently mounted" 165 | elif self.source_available == SourceAvailability.GONE: 166 | tooltip = rm_dev(self.msg['source']) + " is not currently available" 167 | elif self.source_available == SourceAvailability.UUID_MISMATCH: 168 | tooltip = 'current {} does not match cloned partition uuid'\ 169 | .format(rm_dev(self.msg['source'])) 170 | elif not self.compression_available: 171 | tooltip = extract_compression_option(self.msg['destination']) + \ 172 | ' compression is not installed' 173 | self.rerun_btn.set_tooltip_text(tooltip) 174 | 175 | def forget(self, button=None): 176 | self.progress_view.forget(self) 177 | 178 | def rerun(self, button=None): 179 | backup_dir = extract_directory(self.msg['destination']) 180 | backup_name = extract_name(self.msg['destination']) 181 | z_name = extract_compression_option(self.msg['destination']) 182 | self.core.send('type: clone\n' 183 | 'source: {}\n' 184 | 'destination: {}\n' 185 | 'name: {}\n' 186 | 'compression: {}'.format(self.msg['source'], 187 | backup_dir, 188 | backup_name, 189 | z_name)) 190 | if self.forget_on_rerun: 191 | self.forget() 192 | 193 | def add_to_grid(self, grid: Gtk.Grid): 194 | raise Exception('abstract') 195 | 196 | def on_source_update(self, sources: Dict): 197 | source_part_name = rm_dev(self.msg['source']) 198 | source_uuid = self.msg.get('source_uuid') 199 | for disk in sources: 200 | for part in disk['parts']: 201 | if part['name'] == source_part_name: 202 | if part['mounted']: 203 | self.source_available = SourceAvailability.MOUNTED 204 | elif source_uuid and part.get('uuid') != source_uuid: 205 | self.source_available = SourceAvailability.UUID_MISMATCH 206 | else: 207 | self.source_available = SourceAvailability.AVAILABLE 208 | return 209 | 210 | self.source_available = SourceAvailability.GONE 211 | 212 | 213 | class FailedClone(FinishedJob): 214 | def __init__(self, 215 | final_message: Dict, 216 | progress_view: 'ProgressAndHistoryView', 217 | core: ApartCore, 218 | z_options: List[str]): 219 | FinishedJob.__init__(self, 220 | final_message, 221 | progress_view, 222 | core, 223 | icon_name='dialog-error', 224 | z_options=z_options) 225 | self.fail_reason = key_and_val('Failed', self.msg['error']) 226 | self.stats = Gtk.VBox() 227 | self.stats.add(self.fail_reason) 228 | self.stats.add(self.duration) 229 | self.stats.get_style_context().add_class('finished-job-stats') 230 | self.stats.show_all() 231 | self.extra.add(self.stats) 232 | 233 | def add_to_grid(self, grid: Gtk.Grid): 234 | if self.tenant: 235 | raise Exception('Already added to a grid') 236 | tenant = self.tenant = GridRowTenant(grid) 237 | base = 0 238 | if tenant.base_row > 0: 239 | tenant.attach(Gtk.Separator(visible=True, hexpand=True), width=FINISHED_JOB_COLUMNS) 240 | base += 1 241 | tenant.attach(self.title_box, top=base) 242 | tenant.attach(self.finish_box, top=base, left=1) 243 | tenant.attach(self.buttons, top=base, left=2) 244 | tenant.attach(self.extra, top=base+1, width=FINISHED_JOB_COLUMNS) 245 | 246 | grid.get_toplevel().register_interest_in_sources(on_update_callback=self.on_source_update) 247 | 248 | 249 | class SuccessfulClone(FinishedJob): 250 | def __init__(self, 251 | final_message: Dict, 252 | progress_view: 'ProgressAndHistoryView', 253 | core: ApartCore, 254 | z_options: List[str]): 255 | FinishedJob.__init__(self, final_message, 256 | progress_view, 257 | core, 258 | icon_name='object-select-symbolic', 259 | forget_on_rerun=False, 260 | z_options=z_options) 261 | 262 | self.image_size = key_and_val('Image size', humanize.naturalsize(self.msg['image_size'], 263 | binary=True)) 264 | self.filename = key_and_val('Image file', extract_filename(self.msg['destination'])) 265 | 266 | self.source_uuid = None 267 | if self.msg.get('source_uuid'): 268 | self.source_uuid = key_and_val('Partition uuid', self.msg['source_uuid']) 269 | 270 | self.stats = Gtk.VBox() 271 | for stat in [self.filename, self.image_size, self.source_uuid, self.duration]: 272 | if stat: 273 | self.stats.add(stat) 274 | 275 | self.stats.get_style_context().add_class('finished-job-stats') 276 | self.stats.show_all() 277 | self.extra.add(self.stats) 278 | self.delete_image_btn = Gtk.Button.new_from_icon_name('user-trash-full-symbolic', 279 | Gtk.IconSize.SMALL_TOOLBAR) 280 | self.delete_image_btn.set_tooltip_text(DELETE_TIP) 281 | self.delete_image_btn.show_all() 282 | self.delete_image_btn.connect('clicked', self.delete_image) 283 | self.buttons.add(self.delete_image_btn) 284 | self.buttons.reorder_child(self.delete_image_btn, 0) 285 | 286 | def add_to_grid(self, grid: Gtk.Grid): 287 | if self.tenant: 288 | raise Exception('Already added to a grid') 289 | tenant = self.tenant = GridRowTenant(grid) 290 | base = 0 291 | if tenant.base_row > 0: 292 | tenant.attach(Gtk.Separator(visible=True, hexpand=True), width=FINISHED_JOB_COLUMNS) 293 | base += 1 294 | tenant.attach(self.title_box, top=base) 295 | tenant.attach(self.finish_box, top=base, left=1) 296 | tenant.attach(self.buttons, top=base, left=2) 297 | tenant.attach(self.extra, top=base + 1, width=FINISHED_JOB_COLUMNS) 298 | 299 | grid.get_toplevel().register_interest_in_sources(on_update_callback=self.on_source_update) 300 | 301 | def similar_to(self, other: FinishedJob) -> bool: 302 | """ 303 | As successful clones indicate space being taken up on the file system, it should only be 304 | lost from the history if another task overwrote the same file (which as it includes at 305 | to-minute timestamp should be rare) 306 | """ 307 | return FinishedJob.similar_to(self, other) and \ 308 | self.msg['destination'] == other.msg['destination'] 309 | 310 | def delete_image(self, arg=None): 311 | filename = self.msg['destination'] 312 | dialog = OkCancelDialog(self.delete_image_btn.get_toplevel(), 313 | header='Delete image file', 314 | text="Delete {}?".format(filename), 315 | message_type=Gtk.MessageType.WARNING) 316 | user_response = dialog.run() 317 | dialog.destroy() 318 | if user_response != Gtk.ResponseType.OK: 319 | return 320 | 321 | for btn in self.buttons.get_children(): 322 | btn.set_sensitive(False) 323 | btn.set_tooltip_text('Deleting...') 324 | 325 | def on_response(msg: Dict): 326 | if msg['type'] == 'deleted-clone': 327 | self.forget() 328 | else: # failed 329 | err_dialog = OkDialog(self.delete_image_btn.get_toplevel(), 330 | header='Delete failed', 331 | text='Could not delete {}: {}'.format(filename, msg['error']), 332 | message_type=Gtk.MessageType.ERROR) 333 | err_dialog.run() 334 | err_dialog.destroy() 335 | for btn in self.buttons.get_children(): 336 | btn.set_sensitive(True) 337 | self.forget_btn.set_tooltip_text(FORGET_TIP) 338 | self.rerun_btn.set_tooltip_text(RERUN_TIP) 339 | self.delete_image_btn.set_tooltip_text(DELETE_TIP) 340 | 341 | MessageListener(message_predicate=lambda m: m['type'] in ['deleted-clone', 342 | 'delete-clone-failed'] and 343 | m['file'] == filename, 344 | on_message=lambda m: GLib.idle_add(on_response, m), 345 | listen_to=self.core, 346 | one_time=True) 347 | 348 | self.core.send('type: delete-clone\nfile: ' + filename) 349 | 350 | 351 | class FailedRestore(FinishedJob): 352 | def __init__(self, final_message: 353 | Dict, 354 | progress_view: 'ProgressAndHistoryView', 355 | core: ApartCore, 356 | z_options: List[str]): 357 | FinishedJob.__init__(self, final_message, progress_view, core, icon_name='dialog-error', 358 | z_options=z_options) 359 | 360 | self.fail_reason = key_and_val('Failed', self.msg['error']) 361 | self.image_source = key_and_val('Restoring from', self.msg['source']) 362 | self.stats = Gtk.VBox() 363 | for stat in [self.fail_reason, self.image_source, self.duration]: 364 | self.stats.add(stat) 365 | self.stats.get_style_context().add_class('finished-job-stats') 366 | self.stats.show_all() 367 | self.extra.add(self.stats) 368 | # naive rerun is unsafe for restore jobs as /dev/abc1 may refer to different partition 369 | # than when last run 370 | self.rerun_btn.destroy() 371 | 372 | def add_to_grid(self, grid: Gtk.Grid): 373 | if self.tenant: 374 | raise Exception('Already added to a grid') 375 | tenant = self.tenant = GridRowTenant(grid) 376 | base = 0 377 | if tenant.base_row > 0: 378 | tenant.attach(Gtk.Separator(visible=True, hexpand=True), width=FINISHED_JOB_COLUMNS) 379 | base += 1 380 | tenant.attach(self.title_box, top=base) 381 | tenant.attach(self.finish_box, top=base, left=1) 382 | tenant.attach(self.buttons, top=base, left=2) 383 | tenant.attach(self.extra, top=base+1, width=FINISHED_JOB_COLUMNS) 384 | 385 | def purpose(self) -> str: 386 | """Note: used for similarity""" 387 | return 'Restore {}'.format(rm_dev(self.msg['destination'])) 388 | 389 | 390 | class SuccessfulRestore(FinishedJob): 391 | def __init__(self, 392 | final_message: Dict, 393 | progress_view: 'ProgressAndHistoryView', 394 | core: ApartCore, 395 | z_options: List[str]): 396 | FinishedJob.__init__(self, final_message, 397 | progress_view, 398 | core, 399 | icon_name='object-select-symbolic', 400 | forget_on_rerun=False, 401 | z_options=z_options) 402 | 403 | self.stats = Gtk.VBox() 404 | self.image_source = key_and_val('Restored from', self.msg['source']) 405 | for stat in [self.image_source, self.duration]: 406 | self.stats.add(stat) 407 | self.stats.get_style_context().add_class('finished-job-stats') 408 | self.stats.show_all() 409 | self.extra.add(self.stats) 410 | # naive rerun is unsafe for restore jobs as /dev/abc1 may refer to different partition 411 | # than when last run 412 | self.rerun_btn.destroy() 413 | 414 | def add_to_grid(self, grid: Gtk.Grid): 415 | if self.tenant: 416 | raise Exception('Already added to a grid') 417 | tenant = self.tenant = GridRowTenant(grid) 418 | base = 0 419 | if tenant.base_row > 0: 420 | tenant.attach(Gtk.Separator(visible=True, hexpand=True), width=FINISHED_JOB_COLUMNS) 421 | base += 1 422 | tenant.attach(self.title_box, top=base) 423 | tenant.attach(self.finish_box, top=base, left=1) 424 | tenant.attach(self.buttons, top=base, left=2) 425 | tenant.attach(self.extra, top=base + 1, width=FINISHED_JOB_COLUMNS) 426 | 427 | def purpose(self) -> str: 428 | """Note: used for similarity""" 429 | return 'Restored {}'.format(rm_dev(self.msg['destination'])) 430 | 431 | 432 | def create(final_message: Dict, 433 | progress_view: 'ProgressAndHistoryView', 434 | core: ApartCore, 435 | z_options: List[str]) -> FinishedJob: 436 | msg_type = final_message['type'] 437 | if msg_type == 'clone': 438 | return SuccessfulClone(final_message, 439 | progress_view=progress_view, 440 | core=core, 441 | z_options=z_options) 442 | elif msg_type == 'clone-failed': 443 | return FailedClone(final_message, 444 | progress_view=progress_view, 445 | core=core, 446 | z_options=z_options) 447 | elif msg_type == 'restore': 448 | return SuccessfulRestore(final_message, 449 | progress_view=progress_view, 450 | core=core, 451 | z_options=z_options) 452 | elif msg_type == 'restore-failed': 453 | return FailedRestore(final_message, 454 | progress_view=progress_view, 455 | core=core, 456 | z_options=z_options) 457 | raise Exception('Unknown type: ' + msg_type) 458 | -------------------------------------------------------------------------------- /src/main.py: -------------------------------------------------------------------------------- 1 | from apartcore import ApartCore 2 | from typing import * 3 | from cloneentry import CloneToImageEntry 4 | from partinfo import PartitionInfo 5 | from progress import ProgressAndHistoryView 6 | from gi.repository import Gtk 7 | from restoreentry import RestoreFromImageEntry 8 | import settings 9 | 10 | 11 | class CloneBody(Gtk.Box): 12 | def __init__(self, 13 | core: ApartCore, 14 | sources: List[Dict[str, Any]], 15 | z_options: List[str]): 16 | Gtk.Box.__init__(self) 17 | self.core = core 18 | 19 | right_panes = Gtk.VPaned(expand=True) 20 | self.main_view = MainView(core, z_options) 21 | self.info_view = ClonePartInfo(sources, core, self.main_view) 22 | right_panes.pack1(self.info_view, shrink=False) 23 | right_panes.pack2(self.main_view, shrink=False) 24 | 25 | self.side_bar_box = Gtk.EventBox() 26 | self.side_bar_box.add(Gtk.StackSidebar(stack=self.info_view)) 27 | self.side_bar_box.connect('button-press-event', self.side_bar_click) 28 | 29 | self.paned = Gtk.Paned(expand=True) 30 | self.paned.pack1(self.side_bar_box, shrink=False) 31 | self.paned.pack2(right_panes, shrink=False) 32 | 33 | self.add(self.paned) 34 | 35 | def update_sources(self, sources: List[Dict[str, Any]]): 36 | self.info_view.update_sources(sources) 37 | 38 | def side_bar_click(self, *args): 39 | self.core.send('type: status-request') 40 | 41 | 42 | class ClonePartInfo(Gtk.Stack): 43 | def __init__(self, sources: List[Dict[str, Any]], core: ApartCore, main_view: 'MainView'): 44 | Gtk.Stack.__init__(self) 45 | self.core = core 46 | self.sources = sources 47 | self.main_view = main_view 48 | self.updating = False 49 | 50 | self.connect('notify::visible-child', self.on_child_change) 51 | self.get_style_context().add_class('part-info') 52 | 53 | self.update_sources(sources) 54 | self.show_all() 55 | 56 | def on_child_change(self, *args): 57 | visible = self.get_visible_child() 58 | if visible: 59 | self.main_view.new_clone.use_defaults_for(visible) 60 | self.main_view.new_restore.use_defaults_for(visible) 61 | 62 | def update_sources(self, sources: List[Dict[str, Any]]): 63 | previous_visible = self.get_visible_child_name() 64 | 65 | parts = [] 66 | for source in sources: 67 | for part in source['parts']: 68 | # ignore partitions <= 1 MiB & swap 69 | if part['size'] > 1048576 and part.get('fstype') != "swap": 70 | parts.append(PartitionInfo(part, self.core, self.main_view)) 71 | 72 | changed_partition_info = False 73 | names = list(map(lambda p: p.name(), parts)) 74 | for child in self.get_children(): 75 | if self.child_get_property(child, 'name') not in names: 76 | child.destroy() 77 | changed_partition_info = True 78 | 79 | for info in parts: 80 | existing = self.get_child_by_name(info.name()) 81 | if not existing or (existing and existing.part != info.part): 82 | if existing: 83 | existing.destroy() 84 | self.add_titled(info, name=info.name(), title=info.title()) 85 | info.show_all() 86 | changed_partition_info = True 87 | 88 | if changed_partition_info and previous_visible and self.get_child_by_name(previous_visible): 89 | self.set_visible_child_name(previous_visible) 90 | 91 | 92 | class MainView(Gtk.Stack): 93 | def __init__(self, core: ApartCore, z_options: List[str]): 94 | Gtk.Stack.__init__(self) 95 | self.set_transition_type(Gtk.StackTransitionType.NONE) 96 | self.set_transition_duration(settings.animation_duration_ms()) 97 | self.new_clone = CloneToImageEntry(self, core, z_options) 98 | self.add_named(self.new_clone, name='new-clone') 99 | self.progress = ProgressAndHistoryView(core, z_options) 100 | self.add_named(self.progress, name='progress') 101 | self.new_restore = RestoreFromImageEntry(self, core, z_options) 102 | self.add_named(self.new_restore, name='new-restore') 103 | 104 | def show(self, name, fade: bool): 105 | if fade: 106 | self.set_visible_child_full(name, Gtk.StackTransitionType.CROSSFADE) 107 | else: 108 | self.set_visible_child_name(name) 109 | 110 | def show_progress(self, fade: bool = False): 111 | self.show('progress', fade) 112 | 113 | def show_new_clone(self, fade: bool = False): 114 | self.show('new-clone', fade) 115 | 116 | def show_new_restore(self, fade: bool = False): 117 | self.show('new-restore', fade) 118 | -------------------------------------------------------------------------------- /src/partinfo.py: -------------------------------------------------------------------------------- 1 | from typing import * 2 | from gi.repository import Gtk 3 | import humanize 4 | from apartcore import ApartCore 5 | 6 | 7 | def key_and_val(key: str, val: str, visible=False) -> Gtk.Box: 8 | box = Gtk.Box(visible=visible) 9 | key_label = Gtk.Label(key, visible=visible) 10 | key_label.get_style_context().add_class('info-key') 11 | key_label.get_style_context().add_class('dim-label') 12 | box.add(key_label) 13 | val_label = Gtk.Label(val, visible=visible) 14 | val_label.get_style_context().add_class('info-val') 15 | box.add(val_label) 16 | box.key_label = key_label 17 | box.value_label = val_label 18 | return box 19 | 20 | 21 | class PartitionInfo(Gtk.Box): 22 | def __init__(self, part: Dict[str, Any], core: ApartCore, main_view: 'MainView'): 23 | Gtk.Box.__init__(self) 24 | self.part = part 25 | self.core = core 26 | self.main_view = main_view 27 | 28 | self.add(key_and_val('Name', self.name())) 29 | self.add(key_and_val('Type', self.part.get('fstype', 'unknown'))) 30 | self.add(key_and_val('Label', self.part.get('label', 'none'))) 31 | self.add(key_and_val('Size', humanize.naturalsize(self.part['size'], binary=True))) 32 | self.clone_button = Gtk.Button("Clone", halign=Gtk.Align.END) 33 | self.restore_button = Gtk.Button("Restore", halign=Gtk.Align.END) 34 | if self.is_mounted(): 35 | self.clone_button.set_sensitive(False) 36 | self.clone_button.set_tooltip_text('Partition is currently mounted') 37 | self.restore_button.set_sensitive(False) 38 | self.restore_button.set_tooltip_text('Partition is currently mounted') 39 | else: 40 | self.clone_button.connect('clicked', lambda b: self.main_view.show_new_clone()) 41 | self.restore_button.connect('clicked', lambda b: self.main_view.show_new_restore()) 42 | buttons = Gtk.Box(hexpand=True, halign=Gtk.Align.END) 43 | buttons.add(self.clone_button) 44 | buttons.add(self.restore_button) 45 | self.add(buttons) 46 | main_view.connect('notify::visible-child', self.on_main_view_change) 47 | 48 | def name(self): 49 | return self.part['name'] 50 | 51 | def dev_name(self): 52 | return '/dev/' + self.name() 53 | 54 | def label(self): 55 | return self.part.get('label') 56 | 57 | def title(self): 58 | max_length = 10 59 | label = self.part.get('label', '').strip() 60 | if len(label) > max_length: 61 | label = label[:max_length-3].rstrip() + '...' 62 | return '{} {}'.format(self.name(), label) 63 | 64 | def on_main_view_change(self, main_view: Gtk.Stack, somearg): 65 | from cloneentry import CloneToImageEntry 66 | from restoreentry import RestoreFromImageEntry 67 | 68 | current_view = main_view.get_visible_child() 69 | if not self.is_mounted(): 70 | self.clone_button.set_sensitive(type(current_view) is not CloneToImageEntry) 71 | self.restore_button.set_sensitive(type(current_view) is not RestoreFromImageEntry) 72 | 73 | def is_mounted(self) -> bool: 74 | return self.part['mounted'] 75 | -------------------------------------------------------------------------------- /src/progress.py: -------------------------------------------------------------------------------- 1 | from typing import * 2 | from gi.repository import GLib, Gtk 3 | import logging 4 | from apartcore import ApartCore, MessageListener 5 | import historic_job 6 | from historic_job import FinishedJob 7 | import running_job 8 | from running_job import RunningJob 9 | import settings 10 | import sys 11 | import subprocess 12 | 13 | log = logging.getLogger('ProgressAndHistoryView') 14 | 15 | 16 | class ProgressAndHistoryView(Gtk.Stack): 17 | def __init__(self, core: ApartCore, z_options: List[str]): 18 | Gtk.Stack.__init__(self) 19 | self.core = core 20 | self.z_options = z_options 21 | 22 | self.get_style_context().add_class('progress-view') 23 | self.next_notification = NotificationHelper() 24 | 25 | self.nothing_label = Gtk.Label('Select a partition to clone', xalign=0.5, vexpand=True) 26 | self.nothing_label.get_style_context().add_class('dim-label') 27 | self.add(self.nothing_label) 28 | 29 | self.content = Gtk.VBox(valign=Gtk.Align.START) 30 | self.add(self.content) 31 | 32 | self.running_jobs_label = Gtk.Label('Running', halign=Gtk.Align.START) 33 | self.running_jobs_label.get_style_context().add_class('section-title') 34 | self.content.add(self.running_jobs_label) 35 | 36 | # self.running_jobs: Dict[str, RunningJob] = {} <- not compatible with 3.5 37 | self.running_jobs = {} 38 | self.running_jobs_grid = Gtk.Grid(orientation=Gtk.Orientation.VERTICAL, 39 | column_spacing=6, 40 | row_spacing=6) 41 | 42 | self.running_jobs_grid.get_style_context().add_class('jobs') 43 | self.content.add(self.running_jobs_grid) 44 | 45 | # self.finished_jobs: Dict[str, FinishedJob] = {} <- not compatible with 3.5 46 | self.finished_jobs = {} 47 | self.finished_jobs_label = Gtk.Label('History', halign=Gtk.Align.START) 48 | self.finished_jobs_label.get_style_context().add_class('section-title') 49 | self.content.add(self.finished_jobs_label) 50 | 51 | self.finished_jobs_grid = Gtk.Grid(orientation=Gtk.Orientation.VERTICAL, 52 | column_spacing=6) 53 | self.finished_jobs_grid.get_style_context().add_class('finished-jobs') 54 | self.content.add(self.finished_jobs_grid) 55 | 56 | self.show_all() 57 | 58 | self.listener = MessageListener(message_predicate=lambda m: m['type'] in ['clone', 59 | 'restore', 60 | 'clone-failed', 61 | 'restore-failed'], 62 | on_message=lambda m: GLib.idle_add(self.on_job_message, m), 63 | listen_to=core) 64 | 65 | GLib.timeout_add(interval=1000, function=self.update_jobs) 66 | self.connect('destroy', self.save_history) 67 | GLib.idle_add(self.read_history) 68 | 69 | def read_history(self): 70 | for historic_job_msg in settings.read_history(): 71 | try: 72 | self.finished_jobs[historic_job_msg['id']] = historic_job.create(historic_job_msg, 73 | progress_view=self, 74 | core=self.core, 75 | z_options=self.z_options) 76 | except KeyError as e: 77 | log.warning('Error constructing FinishedJob from historic data ' + str(e)) 78 | 79 | for job in sorted(self.finished_jobs.values(), 80 | key=lambda j: j.finish, 81 | reverse=True): 82 | job.add_to_grid(self.finished_jobs_grid) 83 | self.update_view() 84 | 85 | def new_running_job(self, msg: Dict) -> RunningJob: 86 | job = running_job.create(msg, self.core, on_finish=self.on_job_finish) 87 | self.running_jobs[msg['id']] = job 88 | job.add_to_grid(self.running_jobs_grid) 89 | return job 90 | 91 | def on_job_message(self, msg: Dict): 92 | job = self.running_jobs.get(msg['id']) or self.new_running_job(msg) 93 | job.handle_message(msg) 94 | self.update_view() 95 | 96 | def update_view(self): 97 | if self.running_jobs or self.finished_jobs: 98 | self.set_visible_child(self.content) 99 | else: 100 | self.set_visible_child(self.nothing_label) 101 | self.finished_jobs_label.set_visible(not not self.finished_jobs) 102 | self.finished_jobs_grid.set_visible(not not self.finished_jobs) 103 | self.running_jobs_label.set_visible(not not self.running_jobs) 104 | self.running_jobs_grid.set_visible(not not self.running_jobs) 105 | 106 | def update_jobs(self) -> bool: 107 | for job in self.running_jobs.values(): 108 | job.update() 109 | for job in self.finished_jobs.values(): 110 | job.update() 111 | return True 112 | 113 | def on_job_finish(self, final_msg: Dict): 114 | job_id = final_msg['id'] 115 | job = self.running_jobs[job_id] 116 | job.remove_from_grid() 117 | del self.running_jobs[job_id] 118 | job = historic_job.create(final_msg, progress_view=self, core=self.core, z_options=self.z_options) 119 | job.reveal_extra() # show extra details of newest finished job 120 | 121 | # remove all and re-add for ordering 122 | to_remove = [] 123 | for existing_id, existing_job in self.finished_jobs.items(): 124 | existing_job.remove_from_grid() 125 | if existing_job.similar_to(job): 126 | to_remove.append(existing_id) 127 | 128 | for remove_job_id in to_remove: 129 | del self.finished_jobs[remove_job_id] 130 | 131 | self.finished_jobs[job_id] = job 132 | for finished_job in sorted(self.finished_jobs.values(), 133 | key=lambda x: x.finish, 134 | reverse=True): 135 | finished_job.add_to_grid(self.finished_jobs_grid) 136 | if finished_job != job: 137 | finished_job.default_extra_reveal() 138 | 139 | self.update_view() 140 | 141 | if self.next_notification.enabled: 142 | if final_msg['type'] in ['clone', 'restore']: 143 | # success 144 | self.next_notification.successes += 1 145 | else: 146 | # ignore cancels, as they are manual 147 | if final_msg.get('error') != 'Cancelled': 148 | self.next_notification.failures += 1 149 | 150 | if not self.running_jobs: 151 | try: 152 | notification_subject = self.next_notification.subject() 153 | notification_body = self.next_notification.body() 154 | if notification_subject and notification_body: 155 | subprocess.call(['notify-send', 156 | '--icon={}'.format(self.next_notification.icon()), 157 | notification_subject, 158 | notification_body]) 159 | self.next_notification.reset_counts() 160 | except (OSError, ValueError) as _: 161 | print('Warn: Command `notify-send` failed, disabling desktop notification', 162 | file=sys.stderr) 163 | self.next_notification.enabled = False 164 | 165 | def forget(self, job: FinishedJob): 166 | del self.finished_jobs[job.msg['id']] 167 | job.remove_from_grid() 168 | self.update_view() 169 | 170 | def save_history(self, arg=None): 171 | history = list(map(lambda j: j.msg, self.finished_jobs.values())) 172 | settings.write_history(history) 173 | 174 | 175 | class NotificationHelper: 176 | """ 177 | Desktop notification helper 178 | 179 | >>> next_notification = NotificationHelper() 180 | >>> next_notification.subject() 181 | '' 182 | 183 | >>> next_notification = NotificationHelper() 184 | >>> next_notification.successes = 1 185 | >>> next_notification.subject() 186 | 'Apart tasks completed' 187 | 188 | >>> next_notification.body() 189 | '1 task has finished successfully' 190 | 191 | >>> next_notification.successes = 3 192 | 193 | >>> next_notification.body() 194 | '3 tasks have finished successfully' 195 | 196 | >>> next_notification.failures = 1 197 | >>> next_notification.body() 198 | '1 task has failed, 3 tasks have finished successfully' 199 | 200 | >>> next_notification = NotificationHelper() 201 | >>> next_notification.failures = 20 202 | >>> next_notification.subject() 203 | 'Apart tasks completed' 204 | 205 | >>> next_notification.body() 206 | '20 tasks have failed' 207 | 208 | >>> next_notification = NotificationHelper() 209 | >>> next_notification.successes = 20 210 | >>> next_notification.failures = 44 211 | >>> next_notification.reset_counts() 212 | >>> next_notification.successes 213 | 0 214 | >>> next_notification.failures 215 | 0 216 | 217 | >>> next_notification = NotificationHelper() 218 | >>> next_notification.successes = 3 219 | >>> next_notification.icon() 220 | 'object-select' 221 | >>> next_notification.failures = 1 222 | >>> next_notification.icon() 223 | 'dialog-warning' 224 | """ 225 | def __init__(self): 226 | self.successes = 0 227 | self.failures = 0 228 | self.enabled = True 229 | pass 230 | 231 | def subject(self) -> str: 232 | if self.successes + self.failures > 0: 233 | return 'Apart tasks completed' 234 | return '' 235 | 236 | def body(self) -> str: 237 | body = '' 238 | if self.failures: 239 | if self.failures == 1: 240 | body = '1 task has failed' 241 | else: 242 | body = '{} tasks have failed'.format(self.failures) 243 | 244 | if self.successes: 245 | if body: 246 | body += ', ' 247 | if self.successes == 1: 248 | body += '1 task has finished successfully' 249 | else: 250 | body += '{} tasks have finished successfully'.format(self.successes) 251 | 252 | return body 253 | 254 | def icon(self) -> str: 255 | if self.failures: 256 | return 'dialog-warning' 257 | if self.successes: 258 | return 'object-select' 259 | return '' 260 | 261 | def reset_counts(self): 262 | self.successes = 0 263 | self.failures = 0 264 | -------------------------------------------------------------------------------- /src/restoreentry.py: -------------------------------------------------------------------------------- 1 | from apartcore import ApartCore 2 | from dialog import OkCancelDialog 3 | from partinfo import PartitionInfo 4 | from gi.repository import Gtk 5 | from typing import * 6 | 7 | 8 | class RestoreFromImageEntry(Gtk.Box): 9 | def __init__(self, main_view: 'MainView', core: ApartCore, z_options: List[str]): 10 | Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL, expand=True, halign=Gtk.Align.CENTER) 11 | self.main_view = main_view 12 | self.core = core 13 | self.z_options = z_options 14 | 15 | self.title = Gtk.Label('', xalign=0.5) 16 | self.title.get_style_context().add_class('dim-label') 17 | 18 | self.image_label = Gtk.Label("Image File", xalign=1.0) 19 | self.image_label.get_style_context().add_class('dim-label') 20 | self.image_entry = Gtk.FileChooserButton(title='Select Image File') 21 | image_file_filter = Gtk.FileFilter() 22 | image_file_filter.set_name('Apart Image files') 23 | for z_option in z_options: 24 | image_file_filter.add_pattern('*.apt.*.{}'.format(z_option)) 25 | if z_option == 'zst': # also support .zstd from v0.14 26 | image_file_filter.add_pattern('*.apt.*.zstd') 27 | self.image_entry.add_filter(image_file_filter) 28 | self.image_entry.connect('selection-changed', lambda v: self.on_image_select()) 29 | 30 | self.options = Gtk.Grid(row_spacing=6) 31 | self.options.get_style_context().add_class('new-clone-options') 32 | self.options.attach(self.title, left=0, top=0, width=2, height=1) 33 | self.options.attach(self.image_label, left=0, top=1, width=1, height=1) 34 | self.options.attach(self.image_entry, left=1, top=1, width=1, height=1) 35 | 36 | self.cancel_btn = Gtk.Button('Cancel') 37 | self.cancel_btn.connect('clicked', lambda v: self.main_view.show_progress()) 38 | self.start_btn = Gtk.Button('Restore Partition') 39 | self.start_btn.set_sensitive(False) 40 | self.start_btn.connect('clicked', lambda v: self.user_confirm()) 41 | 42 | self.buttons = Gtk.Box(halign=Gtk.Align.END) 43 | self.buttons.get_style_context().add_class('new-clone-buttons') 44 | self.buttons.add(self.cancel_btn) 45 | self.buttons.add(self.start_btn) 46 | 47 | self.add(self.options) 48 | self.options.attach_next_to(self.buttons, sibling=self.image_label, 49 | side=Gtk.PositionType.BOTTOM, width=2, height=1) 50 | self.last_part_info = None 51 | 52 | def use_defaults_for(self, part_info: PartitionInfo): 53 | self.last_part_info = part_info 54 | self.update_title() 55 | self.set_start_sensitivity() 56 | 57 | def set_start_sensitivity(self): 58 | if self.last_part_info: 59 | if self.last_part_info.is_mounted(): 60 | self.start_btn.set_sensitive(False) 61 | self.start_btn.set_tooltip_text('Partition is currently mounted') 62 | elif not self.image_entry.get_filename(): 63 | self.start_btn.set_sensitive(False) 64 | self.start_btn.set_tooltip_text('Select an image file to restore from') 65 | else: 66 | self.start_btn.set_sensitive(True) 67 | self.start_btn.set_tooltip_text(None) 68 | 69 | def update_title(self): 70 | z_options = ', '.join(map(lambda z: '.' + z, self.z_options)) 71 | 72 | if self.last_part_info: 73 | self.title.set_text('Image file ({}) ⟶ {}'.format(z_options, self.last_part_info.dev_name())) 74 | 75 | def user_confirm(self): 76 | dialog = OkCancelDialog(self.get_toplevel(), 77 | header='Overwrite partition', 78 | text='Restoring this image will overwrite all current partition data', 79 | ok_button_text='Restore', 80 | message_type=Gtk.MessageType.WARNING) 81 | response = dialog.run() 82 | if response == Gtk.ResponseType.OK: 83 | self.start() 84 | dialog.destroy() 85 | 86 | def start(self): 87 | image_file = self.image_entry.get_filename() 88 | if not image_file or not self.last_part_info: 89 | return 90 | 91 | yaml_template = 'type: restore\nsource: {source}\ndestination: {destination}' 92 | self.core.send(yaml_template.format(source=image_file, 93 | destination=self.last_part_info.dev_name())) 94 | self.main_view.show_progress(fade=True) 95 | self.image_entry.unselect_all() 96 | 97 | def on_image_select(self): 98 | self.set_start_sensitivity() 99 | 100 | 101 | -------------------------------------------------------------------------------- /src/running_job.py: -------------------------------------------------------------------------------- 1 | from gi.repository import Gtk 2 | import humanize 3 | from apartcore import ApartCore 4 | from gtktools import GridRowTenant 5 | from partinfo import key_and_val 6 | from util import * 7 | from typing import * 8 | 9 | RUNNING_JOB_COLUMNS = 2 10 | 11 | 12 | class RunningJob: 13 | def __init__(self, core: ApartCore, on_finish: Callable[[Dict], None]): 14 | """:param on_finish: called when job has received it's final message, with the this message as an arg""" 15 | self.core = core 16 | self.on_finish = on_finish 17 | self.last_message = None # Dict 18 | self.fail_message = None # Dict 19 | self.tenant = None # GridRowTenant 20 | self.start = None # datetime 21 | self.cancelling = False 22 | self.syncing = None # Gtk.Box 23 | 24 | # row 1 25 | self.title_source = Gtk.Label('', xalign=0, visible=True) 26 | self.title_name = Gtk.Label('', xalign=0, visible=True) 27 | self.title_dest = Gtk.Label('', xalign=0, visible=True) 28 | self.title_box = Gtk.Box(visible=True) 29 | self.title_box.add(self.title_source) 30 | self.title_box.add(self.title_name) 31 | self.title_box.add(self.title_dest) 32 | self.cancel_btn = Gtk.Button('Cancel', visible=True, halign=Gtk.Align.END) 33 | self.cancel_btn.get_style_context().add_class('job-cancel-btn') 34 | self.cancel_btn.connect('clicked', self.cancel) 35 | 36 | # row 2 37 | self.rate = key_and_val('Rate', '') 38 | self.rate.show_all() 39 | self.elapsed = key_and_val('Elapsed', '') 40 | self.elapsed.show_all() 41 | self.estimated_completion = key_and_val('Remaining', '') 42 | self.stats = Gtk.Box(visible=True) 43 | self.stats.add(self.elapsed) 44 | self.stats.add(self.rate) 45 | self.stats.add(self.estimated_completion) 46 | self.stats.get_style_context().add_class('job-stats') 47 | 48 | # row 3 49 | self.progress_bar = Gtk.ProgressBar(hexpand=True, visible=True) 50 | 51 | def add_to_grid(self, grid: Gtk.Grid): 52 | if self.tenant: 53 | raise Exception('Already added to a grid') 54 | 55 | tenant = self.tenant = GridRowTenant(grid) 56 | tenant_top = 0 57 | if tenant.base_row > 0: 58 | tenant.attach(Gtk.Separator(visible=True), width=RUNNING_JOB_COLUMNS) 59 | tenant_top += 1 60 | 61 | tenant.attach(self.title_box, top=tenant_top) 62 | tenant.attach(self.cancel_btn, left=1, top=tenant_top) 63 | tenant.attach(self.progress_bar, top=tenant_top + 1, width=RUNNING_JOB_COLUMNS) 64 | tenant.attach(self.stats, top=tenant_top + 2, width=RUNNING_JOB_COLUMNS) 65 | 66 | def remove_from_grid(self): 67 | if not self.tenant: 68 | raise Exception('Not added to a grid') 69 | self.tenant.evict() 70 | self.tenant = None 71 | 72 | def handle_message(self, msg: Dict): 73 | if msg['type'] in ['clone', 'restore']: 74 | self.last_message = msg 75 | self.progress_bar.set_fraction(msg['complete']) 76 | if not self.start: 77 | self.start = msg['start'].replace(tzinfo=msg['start'].tzinfo or timezone.utc) 78 | self.update() 79 | if msg.get('finish'): 80 | self.finish() 81 | else: 82 | self.rate.value_label.set_text(msg.get('rate') or 'Initializing') 83 | if not self.syncing and self.last_message.get('syncing'): 84 | self.syncing = Gtk.Box() 85 | label = Gtk.Label("Syncing") 86 | label.get_style_context().add_class('info-key') 87 | label.get_style_context().add_class('dim-label') 88 | self.syncing.add(label) 89 | self.syncing.add(Gtk.Spinner(active=True)) 90 | self.syncing.show_all() 91 | self.stats.add(self.syncing) 92 | self.estimated_completion.hide() 93 | if not msg.get('rate'): 94 | self.rate.hide() 95 | 96 | elif msg['type'] in ['clone-failed', 'restore-failed']: 97 | self.fail_message = msg 98 | self.finish() 99 | 100 | def update(self) -> bool: 101 | if self.fail_message or self.last_message.get('finish'): 102 | return False 103 | 104 | elapsed_str = str(round_to_second(datetime.now(timezone.utc) - self.start)) 105 | self.elapsed.value_label.set_text(elapsed_str) 106 | if not self.cancelling and not self.syncing: 107 | self.update_remaining() 108 | return True 109 | 110 | def update_remaining(self): 111 | if self.last_message.get('estimated_finish'): 112 | estimated_remaining = self.last_message['estimated_finish'] - datetime.now(timezone.utc) 113 | if estimated_remaining < timedelta(seconds=5): 114 | estimated_remaining_str = 'a few seconds' 115 | else: 116 | estimated_remaining_str = humanize.naturaldelta(estimated_remaining) 117 | self.estimated_completion.value_label.set_text(estimated_remaining_str) 118 | self.estimated_completion.show_all() 119 | 120 | def cancel(self, *args): 121 | self.cancel_btn.set_sensitive(False) 122 | self.cancel_btn.set_tooltip_text("Cancelling") 123 | self.cancelling = True 124 | 125 | def finish(self): 126 | self.on_finish(self.fail_message or self.last_message) 127 | 128 | def finished(self) -> bool: 129 | return bool(self.fail_message or self.last_message and self.last_message.get('finish')) 130 | 131 | 132 | class RunningClone(RunningJob): 133 | """Display representation of a running partition clone""" 134 | def __init__(self, core: ApartCore, on_finish: Callable[[Dict], None]): 135 | RunningJob.__init__(self, core, on_finish) 136 | 137 | def cancel(self, *args): 138 | RunningJob.cancel(self, *args) 139 | self.core.send('type: cancel-clone\nid: {}'.format(self.last_message['id'])) 140 | 141 | def handle_message(self, msg: Dict): 142 | RunningJob.handle_message(self, msg) 143 | if not self.finished(): 144 | self.title_source.set_text(rm_dev(self.last_message['source'])) 145 | self.title_name.set_text(extract_name(self.last_message['destination'])) 146 | self.title_name.get_style_context().add_class("job-name") 147 | self.title_dest.set_text('⟶ ' + extract_directory(self.last_message['destination'])) 148 | 149 | 150 | class RunningRestore(RunningJob): 151 | """Display representation of a running partition restore""" 152 | def __init__(self, core: ApartCore, on_finish: Callable[[Dict], None]): 153 | RunningJob.__init__(self, core, on_finish) 154 | 155 | def cancel(self, *args): 156 | RunningJob.cancel(self, *args) 157 | self.core.send('type: cancel-restore\nid: {}'.format(self.last_message['id'])) 158 | 159 | def handle_message(self, msg: Dict): 160 | RunningJob.handle_message(self, msg) 161 | if not self.finished(): 162 | self.title_source.set_text(extract_filename(self.last_message['source'])) 163 | self.title_dest.set_text('⟶ ' + rm_dev(self.last_message['destination'])) 164 | 165 | 166 | def create(msg: Dict, core: ApartCore, on_finish: Callable[[Dict], None]) -> RunningJob: 167 | msg_type = msg['type'] 168 | if msg_type.startswith('clone'): 169 | return RunningClone(core, on_finish) 170 | elif msg_type.startswith('restore'): 171 | return RunningRestore(core, on_finish) 172 | raise Exception('Cannot create RunningJob from unknown type: ' + msg_type) 173 | -------------------------------------------------------------------------------- /src/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import * 3 | import yaml 4 | from util import default_datetime_to_utc 5 | 6 | 7 | def default_config_directory() -> str: 8 | return os.path.expanduser('~') + '/.config/apart-gtk' 9 | 10 | 11 | def config_directory() -> str: 12 | env_dir = os.environ.get('APART_GTK_CONFIG_DIR') 13 | if env_dir and env_dir.endswith('/'): 14 | env_dir = env_dir[:-1] 15 | return env_dir or default_config_directory() 16 | 17 | 18 | def history_path() -> str: 19 | return config_directory() + '/history.yaml' 20 | 21 | 22 | def read_history() -> List[Dict]: 23 | if not os.path.exists(history_path()): 24 | return [] 25 | with open(history_path(), 'r') as file: 26 | return default_datetime_to_utc(yaml.safe_load(file.read())) 27 | 28 | 29 | def write_history(history: List[Dict]): 30 | if not os.path.exists(history_path()): 31 | os.makedirs(os.path.dirname(history_path())) 32 | with open(history_path(), 'w') as file: 33 | file.write(yaml.safe_dump(history)) 34 | 35 | 36 | def animation_duration_ms() -> int: 37 | return 200 38 | -------------------------------------------------------------------------------- /src/util.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone, timedelta 2 | import re 3 | from typing import * 4 | 5 | filename_re = re.compile(r"/[^/]+$") 6 | name_re = re.compile(r"^.*/(([^/]+)-\d{4,}-\d\d-\d\dT\d{4}\.apt\..+\.(.+))$") 7 | source_re = re.compile(r"^/dev/") 8 | 9 | 10 | def extract_directory(path: str) -> str: 11 | """ 12 | >>> extract_directory('/mnt/backups/work-2017-05-03T1020.apt.dd.gz') 13 | '/mnt/backups' 14 | """ 15 | return re.sub(filename_re, '', path) 16 | 17 | 18 | def extract_filename(path: str) -> str: 19 | """ 20 | >>> extract_filename('/mnt/backups/work-2017-05-03T1020.apt.dd.gz') 21 | 'work-2017-05-03T1020.apt.dd.gz' 22 | """ 23 | m = re.fullmatch(name_re, path) 24 | return m.group(1) 25 | 26 | 27 | def extract_name(path: str) -> str: 28 | """ 29 | >>> extract_name('/mnt/backups/work-2017-05-03T1020.apt.dd.gz') 30 | 'work' 31 | """ 32 | m = re.fullmatch(name_re, path) 33 | return m.group(2) 34 | 35 | 36 | def extract_compression_option(path: str) -> str: 37 | """ 38 | >>> extract_compression_option('/mnt/backups/work-2017-05-03T1020.apt.dd.gz') 39 | 'gz' 40 | 41 | >>> extract_compression_option('/mnt/123/main-2017-05-03T1020.apt.dd.zstd') 42 | 'zst' 43 | """ 44 | m = re.fullmatch(name_re, path) 45 | z_option = m.group(3) 46 | if z_option == 'zstd': # fix v0.14 extension 47 | z_option = 'zst' 48 | return z_option 49 | 50 | 51 | def rm_dev(source: str) -> str: 52 | """ 53 | >>> rm_dev("/dev/sda1") 54 | 'sda1' 55 | """ 56 | return re.sub(source_re, '', source) 57 | 58 | 59 | def round_to_second(delta: timedelta) -> timedelta: 60 | micros = delta.microseconds 61 | truncated = delta - timedelta(microseconds=micros) 62 | if micros >= 500000: # round half up 63 | return truncated + timedelta(seconds=1) 64 | return truncated 65 | 66 | 67 | def default_datetime_to_utc(message): 68 | """Recursively add UTC as tz when missing, pyyaml seems to ignore yaml datetime 'Z' endings""" 69 | def handle(val, setter: Callable): 70 | if type(val) is datetime: 71 | setter(val.replace(tzinfo=val.tzinfo or timezone.utc)) 72 | elif isinstance(val, Dict): 73 | do_in_dict(val) 74 | elif isinstance(val, List): 75 | do_in_list(val) 76 | 77 | def do_in_list(values: List): 78 | for idx, val in enumerate(values): 79 | def overwrite(v): 80 | values[idx] = v 81 | handle(val, overwrite) 82 | 83 | def do_in_dict(values: Dict): 84 | for key, val in values.items(): 85 | def overwrite(v): 86 | values[key] = v 87 | handle(val, overwrite) 88 | 89 | if isinstance(message, Dict): 90 | do_in_dict(message) 91 | elif isinstance(message, List): 92 | do_in_list(message) 93 | else: 94 | raise Exception('Unknown type: ' + type(message)) 95 | return message 96 | -------------------------------------------------------------------------------- /start: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -eu 4 | dir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 5 | 6 | # run doctests 7 | python -m doctest "$dir"/src/*.py 8 | 9 | (cd "$dir"/apart-core && cargo build) 10 | 11 | now=$(date +%Y-%m-%dT%H) 12 | if [ -f ~/.config/apart-gtk/history.yaml ] && [ ! -f "$dir/history-backup-$now.yaml" ]; then 13 | echo "Backing up ~/.config/apart-gtk/history.yaml -> history-backup-$now.yaml" 14 | cp ~/.config/apart-gtk/history.yaml "$dir/history-backup-$now.yaml" 15 | fi 16 | 17 | RUST_BACKTRACE=full \ 18 | APART_GTK_CORE_CMD="${CARGO_TARGET_DIR:-$dir/apart-core/target}/debug/apart-core" \ 19 | RUST_LOG=info \ 20 | "$dir/src/app.py" 21 | -------------------------------------------------------------------------------- /start-test-app: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -eu 4 | dir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 5 | 6 | # run doctests 7 | python -m doctest "$dir"/src/*.py 8 | 9 | (cd "$dir"/apart-core && cargo build) 10 | 11 | now=$(date +%Y-%m-%dT%H) 12 | if [ -f ~/.config/apart-gtk/history.yaml ] && [ ! -f "$dir/history-backup-$now.yaml" ]; then 13 | echo "Backing up ~/.config/apart-gtk/history.yaml -> history-backup-$now.yaml" 14 | cp ~/.config/apart-gtk/history.yaml "$dir/history-backup-$now.yaml" 15 | fi 16 | 17 | RUST_BACKTRACE=full \ 18 | APART_PARTCLONE_CMD="$dir/test/mockpcl" \ 19 | APART_GTK_CORE_CMD="${CARGO_TARGET_DIR:-$dir/apart-core/target}/debug/apart-core" \ 20 | RUST_LOG=info \ 21 | APART_LSBLK_CMD="$dir/test/mocklsblk" \ 22 | "$dir/src/app.py" 23 | -------------------------------------------------------------------------------- /test/mocklsblk: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | ## Assume was called with expected args '-Jbo name,size,fstype,label,mountpoint' 4 | echo '{ 5 | "blockdevices": [ 6 | {"name": "sdx", "size": 750156374016, "fstype": null, "label": null, "mountpoint": null, "uuid": null, 7 | "children": [ 8 | {"name": "sdx1", "size": 104857600, "fstype": "ntfs", "label": "win reserved", "mountpoint": null, "uuid": "123-123-123"}, 9 | {"name": "sdx2", "size": 536766054400, "fstype": "ntfs", "label": "ssd", "mountpoint": null, "uuid": "234-234-234"}, 10 | {"name": "sdx3", "size": 181070200832, "fstype": "ext4", "label": "arch", "mountpoint": "/", "uuid": "345-345-345"}, 11 | {"name": "sdx4", "size": 1024, "fstype": null, "label": null, "mountpoint": null, "uuid": null}, 12 | {"name": "sdx5", "size": 32212254720, "fstype": null, "label": null, "mountpoint": null, "uuid": null} 13 | ] 14 | }, 15 | {"name": "sdy", "size": 62109253632, "fstype": null, "label": null, "mountpoint": null, "uuid": null, 16 | "children": [ 17 | {"name": "sdy1", "size": 524288000, "fstype": "ext2", "label": "boot", "mountpoint": null, "uuid": "456-456-456"}, 18 | {"name": "sdy2", "size": 2147483648, "fstype": "swap", "label": "swap", "mountpoint": null, "uuid": "567-567-567"}, 19 | {"name": "sdy3", "size": 59436433408, "fstype": "f2fs", "label": "main", "mountpoint": null, "uuid": "678-678-678"} 20 | ] 21 | } 22 | ] 23 | }' 24 | -------------------------------------------------------------------------------- /test/mocklsblk-livecd: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | ## lsblk output from a livecd in a VM, an example of no partitions being detected 3 | 4 | ## Assume was called with expected args '-Jbo name,size,fstype,label,mountpoint' 5 | echo '{ 6 | "blockdevices": [ 7 | {"name": "loop0", "size": 1553670144, "fstype": "squashfs", "label": null, "mountpoint": "/rofs"}, 8 | {"name": "sda", "size": 21474836480, "fstype": null, "label": null, "mountpoint": null}, 9 | {"name": "sr0", "size": 1609039872, "fstype": "iso9660", "label": "Ubuntu 17.04 amd64", "mountpoint": "/cdrom"} 10 | ] 11 | }' 12 | -------------------------------------------------------------------------------- /test/mocklsblk-md: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | ## lsblk output from a md raid env 3 | 4 | ## Assume was called with expected args '-Jbo name,size,fstype,label,mountpoint' 5 | echo '{ 6 | "blockdevices": [ 7 | {"name": "sdd", "size": 3000592982016, "fstype": null, "label": null, "mountpoint": null, 8 | "children": [ 9 | {"name": "sdd1", "size": 3000591450112, "fstype": "linux_raid_member", "label": "mediapc:0", "mountpoint": null, 10 | "children": [ 11 | {"name": "md0", "size": 6000913416192, "fstype": "ext4", "label": null, "mountpoint": "/mnt/raid"} 12 | ] 13 | } 14 | ] 15 | }, 16 | {"name": "sdb", "size": 3000592982016, "fstype": null, "label": null, "mountpoint": null, 17 | "children": [ 18 | {"name": "sdb1", "size": 3000591450112, "fstype": "linux_raid_member", "label": "mediapc:0", "mountpoint": null, 19 | "children": [ 20 | {"name": "md0", "size": 6000913416192, "fstype": "ext4", "label": null, "mountpoint": "/mnt/raid"} 21 | ] 22 | } 23 | ] 24 | }, 25 | {"name": "sdc", "size": 3000592982016, "fstype": null, "label": null, "mountpoint": null, 26 | "children": [ 27 | {"name": "sdc1", "size": 3000591450112, "fstype": "linux_raid_member", "label": "mediapc:0", "mountpoint": null, 28 | "children": [ 29 | {"name": "md0", "size": 6000913416192, "fstype": "ext4", "label": null, "mountpoint": "/mnt/raid"} 30 | ] 31 | } 32 | ] 33 | }, 34 | {"name": "sda", "size": 80026361856, "fstype": null, "label": null, "mountpoint": null, 35 | "children": [ 36 | {"name": "sda2", "size": 2149580800, "fstype": "swap", "label": null, "mountpoint": "[SWAP]"}, 37 | {"name": "sda5", "size": 12829000192, "fstype": "ext4", "label": null, "mountpoint": "/"}, 38 | {"name": "sda3", "size": 65046315008, "fstype": "ext4", "label": "home", "mountpoint": "/home"}, 39 | {"name": "sda1", "size": 1024, "fstype": null, "label": null, "mountpoint": null} 40 | ] 41 | } 42 | ] 43 | }' 44 | -------------------------------------------------------------------------------- /test/mockpcl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import argparse 3 | from datetime import timedelta 4 | from time import sleep 5 | import sys 6 | 7 | parser = argparse.ArgumentParser(description='Mock partclone impl') 8 | parser.add_argument('-s', help='source') 9 | parser.add_argument('-c', help='use stdout when not dd', action='store_true') 10 | parser.add_argument('-r', help='restore mode when not dd', action='store_true') 11 | parser.add_argument('-o', help='destination') 12 | parser.add_argument('--loops', help='numbers of loops to take pretending to clone') 13 | args = parser.parse_args() 14 | 15 | if not args.s and not args.o: 16 | raise Exception('Expected at least one argument -o DEST or -s SOURCE') 17 | 18 | loops = int(args.loops or 4) 19 | 20 | print('Partclone v0.2.89-mock http://partclone.org', file=sys.stderr) 21 | print('Starting to clone/restore ({}) to (-) with dd mode'.format(args.s), file=sys.stderr) 22 | print('Calculating bitmap... Please wait... ', file=sys.stderr, end='') 23 | sleep(0.1) 24 | print('done!', file=sys.stderr) 25 | print('File system: raw', file=sys.stderr) 26 | print('Device size: 32.2 GB = 62914560 Blocks', file=sys.stderr) 27 | print('Space in use: 32.2 GB = 62914560 Blocks', file=sys.stderr) 28 | print('Free Space: 0 Byte = 0 Blocks', file=sys.stderr) 29 | print('Block size: 512 Byte', file=sys.stderr, flush=True) 30 | 31 | 32 | def print_progress(remaining: timedelta, complete: float, rate: str): 33 | print(r'Elapsed: 00:00:12, Remaining: 0{remaining!s}, Completed: {complete:.2f}%, {rate},'.format(remaining=remaining, 34 | complete=complete, 35 | rate=rate), 36 | file=sys.stderr) 37 | print(r'current block: 3878912, total block: 62914560, Complete: {complete:.2f}'.format(complete=complete), 38 | file=sys.stderr, flush=True) 39 | 40 | for loop in range(loops): 41 | remaining_secs = loops - loop - 1 42 | loop_complete = (loop + 1) * 100 / loops 43 | mock_rate = '{:.2f}GB/min'.format(9 + loop / 10) 44 | 45 | print_progress(remaining=timedelta(seconds=remaining_secs), complete=loop_complete, rate=mock_rate) 46 | # print_move_term_up2() 47 | if not loop == loops - 1: 48 | sleep(1) 49 | 50 | print('Total Time: 00:00:58, Ave. Rate: 33.3GB/min, 100.00% completed!', file=sys.stderr) 51 | print('Syncing... ', file=sys.stderr, end='', flush=True) 52 | if args.o: 53 | sleep(2) # simulate restore syncing time 54 | else: 55 | print('some-data', file=sys.stdout, end='') # the actual partition data 56 | print('OK!', file=sys.stderr) 57 | 58 | if args.o: 59 | sys.stdin.read() 60 | -------------------------------------------------------------------------------- /test/mockpcl.dd: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 4 | 5 | $DIR/mockpcl $@ 6 | -------------------------------------------------------------------------------- /test/mockpcl.ext2: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 4 | 5 | $DIR/mockpcl $@ --loops 2 6 | -------------------------------------------------------------------------------- /test/mockpcl.f2fs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 4 | 5 | $DIR/mockpcl $@ --loops 8 6 | -------------------------------------------------------------------------------- /test/mockpcl.ntfs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 4 | 5 | $DIR/mockpcl $@ --loops 20 6 | --------------------------------------------------------------------------------