commit c831d860f3ebb538c426a08adfab8037ed57a243 Author: Felitendo Date: Mon Sep 21 12:16:46 2026 +0200 feat: add bottles-opener diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..76f7714 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,15 @@ +# These are supported funding model platforms + +github: Felitendo +patreon: # Replace with a single Patreon username +open_collective: # Replace with a single Open Collective username +ko_fi: felitendo +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry +polar: # Replace with a single Polar username +buy_me_a_coffee: # Replace with a single Buy Me a Coffee username +thanks_dev: # Replace with a single thanks.dev username +custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..74aff10 --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,28 @@ +name: check + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + check: + name: syntax and shellcheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + # The Makefile skips whatever is missing rather than failing, so the + # tools it looks for have to be here or the check checks less than it + # appears to. + - name: Install the tools the Makefile looks for + run: | + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends \ + shellcheck gettext scdoc + + - run: make check + + - name: Build the catalogs and the man page + run: make build diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cde4a72 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +# build artifacts +po/*.mo +doc/*.1 + +# packages +dist/ + +# python +__pycache__/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e5b9b99 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,23 @@ +# CLAUDE.md + +## Style + +Keep everything short — replies, explanations, comments, docs. + +## Commits + +- English only. +- Conventional Commits prefix: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`, `ci:`, `build:`. +- Subject as short as possible. Imperative, lowercase, no trailing period. +- Body only when something genuinely cannot be inferred from the diff. +- Never add `Co-Authored-By`, "Generated with" or any other AI attribution to commits or PR descriptions. + +## Testing + +Never test against the real setup: `enable` and `apply` rewrite the user's file associations. + +- `scripts/sandbox.sh ` runs the checkout with every XDG dir in a throwaway sandbox. It + reads the real bottles, writes nothing outside the sandbox and leaves the watcher off. +- `BO_DRY_RUN=1 scripts/sandbox.sh open ` prints the launch command instead of + starting the program. Launcher ids are in the sandbox's `state/bottles-opener/launchers`. +- `make check` after every change. New strings: `make pot`, then translate them in `po/de.po`. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..019735e --- /dev/null +++ b/Makefile @@ -0,0 +1,151 @@ +# bottles-opener - build and install +# +# Everything here is plain shell and one small Python script; "building" only +# means compiling the gettext catalogs and rendering the man page. Both targets +# degrade to a no-op when msgfmt/scdoc are missing, so the tree stays usable +# for development without the build dependencies installed. + +# Overridable so a packager can pass the version it is actually building +# (`make VERSION=$pkgver`). The literal below is the fallback for builds +# straight from a checkout, and is what a release tag has to carry. +VERSION ?= 0.1.0 + +PREFIX ?= /usr +DESTDIR ?= +BINDIR ?= $(PREFIX)/bin +DATADIR ?= $(PREFIX)/share +LIBDIR ?= $(DATADIR)/bottles-opener/lib +LOCALEDIR ?= $(DATADIR)/locale +MANDIR ?= $(DATADIR)/man + +# Where systemd looks for user units. For a normal install into /usr this is +# asked of systemd itself, because the answer is not the same everywhere, and +# only guessed at when there is no systemd installed to ask. +# +# A build with a prefix of its own keeps the units under that prefix instead, +# and under share rather than lib: $XDG_DATA_HOME/systemd/user is a search path +# and ~/.local/lib/systemd/user is not, so an install into ~/.local that put +# the units in lib would leave the watcher impossible to enable. +ifeq ($(PREFIX),/usr) +USERUNITDIR ?= $(shell pkg-config --variable=systemduserunitdir systemd 2>/dev/null || echo /usr/lib/systemd/user) +else +USERUNITDIR ?= $(DATADIR)/systemd/user +endif + +LINGUAS := de +MOFILES := $(patsubst %,po/%.mo,$(LINGUAS)) +MANPAGE := doc/bottles-opener.1 + +LIBS := $(wildcard src/lib/*.sh) + +MSGFMT := $(shell command -v msgfmt 2>/dev/null) +SCDOC := $(shell command -v scdoc 2>/dev/null) + +.PHONY: all build install uninstall check pot clean version + +all: build + +build: $(MOFILES) $(MANPAGE) + +# The one place the version is written down, for everything that has to agree +# with it. +version: + @echo $(VERSION) + +po/%.mo: po/%.po +ifdef MSGFMT + $(MSGFMT) --check --output-file=$@ $< +else + @echo "msgfmt not found - skipping $@" +endif + +$(MANPAGE): doc/bottles-opener.1.scd +ifdef SCDOC + $(SCDOC) < $< > $@ +else + @echo "scdoc not found - skipping $@" +endif + +# Regenerates the template from the sources. The setting labels live in an +# array rather than in a call xgettext can see, so they are listed in +# po/extra.pot and merged in. +pot: + xgettext --language=Shell --from-code=UTF-8 -k --keyword=bo_msg --keyword=bo_msg_into \ + --package-name=bottles-opener --package-version=$(VERSION) \ + --msgid-bugs-address=https://github.com/Felitendo/bottles-opener/issues \ + --add-comments=TRANSLATORS --no-location --sort-output \ + --output=po/bottles-opener.pot.tmp src/bottles-opener $(LIBS) + msgcat --use-first --sort-output --output-file=po/bottles-opener.pot \ + po/bottles-opener.pot.tmp po/extra.pot + rm -f po/bottles-opener.pot.tmp + @for l in $(LINGUAS); do msgmerge --quiet --update --backup=none po/$$l.po po/bottles-opener.pot; done + +# Syntax-check every shell file, and run shellcheck when it is available. +check: + @set -e; for f in src/bottles-opener $(LIBS); do \ + bash -n "$$f" && echo "ok $$f"; \ + done + @for f in src/lib/*.py; do \ + python3 -c "import ast, sys; ast.parse(open(sys.argv[1]).read())" "$$f" && echo "ok $$f"; \ + done + @if command -v shellcheck >/dev/null 2>&1; then \ + shellcheck -x -e SC1090,SC1091 src/bottles-opener $(LIBS); \ + echo "ok shellcheck"; \ + else \ + echo "shellcheck not found - skipped"; \ + fi + @if command -v msgfmt >/dev/null 2>&1; then \ + for l in $(LINGUAS); do msgfmt --check --output-file=/dev/null po/$$l.po && echo "ok po/$$l.po"; done; \ + fi + +install: build + # executable + install -Dm755 src/bottles-opener "$(DESTDIR)$(BINDIR)/bottles-opener" + + # shell libraries and the scanner + install -d "$(DESTDIR)$(LIBDIR)" + install -Dm644 -t "$(DESTDIR)$(LIBDIR)" $(LIBS) src/lib/scan.py src/lib/icon.py + + # the version and the resolved paths are baked in at install time + sed -i -e 's|@VERSION@|$(VERSION)|g' \ + -e 's|@LIBDIR@|$(LIBDIR)|g' \ + -e 's|@LOCALEDIR@|$(LOCALEDIR)|g' \ + "$(DESTDIR)$(BINDIR)/bottles-opener" \ + "$(DESTDIR)$(LIBDIR)"/*.sh + + # user units: the watcher, and the one-shot it starts + install -Dm644 res/systemd/bottles-opener.service \ + "$(DESTDIR)$(USERUNITDIR)/bottles-opener.service" + install -Dm644 res/systemd/bottles-opener.path \ + "$(DESTDIR)$(USERUNITDIR)/bottles-opener.path" + sed -i -e 's|@BINDIR@|$(BINDIR)|g' \ + "$(DESTDIR)$(USERUNITDIR)/bottles-opener.service" + + # translations + @for l in $(LINGUAS); do \ + if [ -f "po/$$l.mo" ]; then \ + install -Dm644 "po/$$l.mo" \ + "$(DESTDIR)$(LOCALEDIR)/$$l/LC_MESSAGES/bottles-opener.mo"; \ + fi; \ + done + + # documentation + @if [ -f $(MANPAGE) ]; then \ + install -Dm644 $(MANPAGE) "$(DESTDIR)$(MANDIR)/man1/bottles-opener.1"; \ + fi + install -Dm644 README.md \ + "$(DESTDIR)$(DATADIR)/doc/bottles-opener/README.md" + +uninstall: + rm -f "$(DESTDIR)$(BINDIR)/bottles-opener" + rm -rf "$(DESTDIR)$(DATADIR)/bottles-opener" + rm -f "$(DESTDIR)$(USERUNITDIR)/bottles-opener.service" + rm -f "$(DESTDIR)$(USERUNITDIR)/bottles-opener.path" + rm -f "$(DESTDIR)$(MANDIR)/man1/bottles-opener.1" + rm -rf "$(DESTDIR)$(DATADIR)/doc/bottles-opener" + @for l in $(LINGUAS); do \ + rm -f "$(DESTDIR)$(LOCALEDIR)/$$l/LC_MESSAGES/bottles-opener.mo"; \ + done + +clean: + rm -f po/*.mo $(MANPAGE) diff --git a/README.md b/README.md new file mode 100644 index 0000000..a18c97e --- /dev/null +++ b/README.md @@ -0,0 +1,107 @@ +# bottles-opener + +bottles-opener is a CLI for linux that lets you double-click a file and have it open in its Windows program, inside its bottle. +Works with FL Studio, Microsoft Office, Photoshop, Ableton and anything else you run through [Bottles](https://usebottles.com). + +You install FL Studio in Bottles, double-click your `.flp` and... your file manager asks what an `.flp` is. This tool reads which files your Windows programs registered in their bottle - the same list Windows uses - teaches your system those types, gives them the program's icons and hands the file over the way Explorer would. One command and your files _✨just open✨_ (like on windows). + +## How to use + +Just run `bottles-opener`: + +``` + Bottles Opener + + File opening ON + + Bottles package + Programs found 5 in 2 bottles + File types .doc .docx .flp .fsc .fst .pptx .xlsx +31 + New programs ON + Last applied 2 minutes ago + + Double-click one of these files and it opens in its program. + + [1] Turn file opening on or off + [2] Re-apply everything + [3] File types + [4] Programs + [5] Settings + [q] Quit + + > +``` + +Press `[1]` and you're done. `[3]` lists every file type and lets you pick what opens it: + +``` + File types + + ▸ .docx Word · Office double-click + .flp FL Studio · FL Studio double-click + .psd Photoshop · Adobe Open With +``` + +`double-click` opens it. `Open With` only lists it in the right-click menu - that's what types get that already have an app on your system (`.docx` with LibreOffice), so nothing you picked gets taken away. Space switches. + +`[4]` lists your programs; Enter edits their file types, so you can add any extension you like. Or without the menu: + +```bash +bottles-opener add flp,fst "FL Studio" +``` + +## How to install + +**From source (for now)** + +```bash +make +sudo make install +bottles-opener enable +``` + +No root? `make install PREFIX=~/.local` works too. + +Needs `bash`, `python3` with PyYAML and icoextract (Bottles needs both itself) and `update-mime-database`. + +## How it works + +- **Which files:** read from the bottle's registry, like Windows does (`.flp` → `FL64.flp.26` → `FL64.exe "%1"`). Programs that registered nothing are matched against a built-in list: all of Office, FL Studio, Ableton, Cubase, Photoshop, Illustrator, Affinity, AutoCAD, SketchUp and more. +- **Unknown extensions** (`.flp`, `.msg`, `.als`, ...) get a MIME type in `~/.local/share/mime/packages/bottles-opener.xml`, named like in Windows ("FL Studio project file"). Where Linux means something else by an extension (`.mpp` is Musepack, `.fst` a tracker module) the file's signature tells them apart. +- **Icons:** the file gets the icon Windows shows, pulled out of the program. A type your system already knows keeps its icon until the program is its default. +- **Opening:** the path becomes the Windows path (`Z:\home\you\song.flp`), with the switches from the registry (Outlook `/f`, PowerPoint shows `/s`), started through `bottles-cli` with all your bottle's settings. +- **Flatpak Bottles:** the file goes through the document portal first, like `flatpak run --file-forwarding`. + +Everything written is recorded, so `bottles-opener disable` puts back exactly what was there. New programs are picked up by a systemd path unit; `bottles-opener apply` does the same by hand. + +## Why not just Bottles? + +Bottles 67 has "File Associations" per program, but it rejects extensions your system doesn't know (so `.flp`, `.msg`, `.als` are "invalid"), passes Linux paths, doesn't know which program opens what and doesn't touch icons. + +## Commands + +| Command | | +|---|---| +| `bottles-opener` | Interactive menu | +| `… enable` | Turn on, apply, start watching | +| `… disable` | Undo everything | +| `… apply` | Pick up new programs | +| `… apply --rebuild` | Redo from scratch | +| `… status` | What's set up | +| `… list` | All file types and what opens them | +| `… programs` | All programs and their file types | +| `… add [bottle]` | Open file types with a program | +| `… remove [program]` | Stop opening file types | + +## Building from source + +```bash +make +sudo make install +``` + +Optionally needs `msgfmt` (gettext) and `scdoc`. Supports `PREFIX` and `DESTDIR`. `make check` runs syntax checks and shellcheck. + +## License + +GPL-3.0-or-later. diff --git a/doc/bottles-opener.1.scd b/doc/bottles-opener.1.scd new file mode 100644 index 0000000..476f889 --- /dev/null +++ b/doc/bottles-opener.1.scd @@ -0,0 +1,128 @@ +bottles-opener(1) + +# NAME + +bottles-opener - double-click a file and it opens in its Windows program, inside its bottle + +# SYNOPSIS + +*bottles-opener* [_command_] + +# DESCRIPTION + +Sets up file opening for programs installed with Bottles: which files each +program opens, the MIME types the system is missing for them, their icons, and +a launcher that hands a file over as the Windows path the program expects. + +Without a command it shows an interactive menu. Nothing outside the user's home +is written to; running as root is refused. + +# COMMANDS + +*enable* + Turn on, set up every file type, start watching for new programs. + +*disable* + Turn off and put back everything that was changed, including the + previous default application of each type. + +*apply* [*--rebuild*] + Set up what appeared since the last run. *--rebuild* takes everything + back first and writes it again. + +*status* + What is set up. + +*list* + Every file type and what opens it. + +*programs* + Every program and the file types it opens. + +*add* _ext_[,_ext_...] _program_ [_bottle_] [*--open-with*|*--default*] + Open file types with a program, named as Bottles lists it or by id. + +*remove* _ext_[,_ext_...] [_program_ [_bottle_]] + Stop opening file types. A detected type is switched off rather than + forgotten, so it does not come back. + +*-h*, *--help*, *-V*, *--version* + Help and version. + +# FILE TYPES + +Found in three places, later ones winning: + +. The bottle's registry, as Windows reads it: extension, ProgID (following + *CurVer*), the default verb's command, *DefaultIcon* and *FriendlyTypeName*. +. A built-in list for programs that registered nothing: Microsoft Office, + FL Studio, Ableton Live, Cubase, Photoshop, AutoCAD and others. +. What was set by hand, in the menu or with *add* and *remove*. + +Each type is *double-click* (the default application), *Open With* (only +offered there) or *off*. A detected type the system does not know becomes the +default; one it knows only gets *Open With*, since another application has it. +If the default is changed in the desktop's settings later, that choice stays. + +An extension the system does not know gets a MIME type in +_~/.local/share/mime/packages/bottles-opener.xml_, described as in Windows. +Where the system uses an extension for something else (*.mpp*, *.fst*, *.gp*, +*.gpx*), the type is defined anyway, with the file's signature to tell the two +apart. + +# ICONS + +A file gets the icon Windows shows for it: *DefaultIcon* extracted from the +program with icoextract, or the icon Bottles saved for the program. It is +installed into the hicolor theme under a name of this program's own. + +A type the system knows keeps its icon until the program is its default. It is +then copied whole into the package with the new icon, because Qt and GLib read +a type's description from the first directory that defines it. + +# OPENING + +The launcher, _~/.local/share/applications/bottles-opener-_id_.desktop_, is +hidden from menus and runs *bottles-opener open*. The file's path is mapped +through the bottle's drive letters (*C:* inside the bottle, *Z:* for the rest) +and passed with the registry's switches for its extension, e.g. Outlook's +*/f* or PowerPoint's */s*. A command Windows completes over DDE gets only the +file. The program is started with *bottles-cli run*. + +With the Bottles Flatpak a file outside the bottle is exported through the +document portal first. The program can write that file, but nothing next to it. + +# NEW PROGRAMS + +A systemd user path unit watches the bottles directories, each _bottle.yml_ +and the system's MIME packages. Without a systemd user session, run *apply*. + +# FILES + +_~/.config/bottles-opener/config_, _~/.config/bottles-opener/types_ + Settings and types changed by hand. + +_~/.local/state/bottles-opener/_ + *ledger* (every change, for *disable*), *launchers* and *commands* (read + by *open*). + +_~/.cache/bottles-opener/_ + Parsed registries and extracted icons. Safe to delete. + +# ENVIRONMENT + +*NO_COLOR* + Disables colour. + +# REQUIREMENTS + +Bash 4.2+, coreutils, Python 3 with PyYAML and icoextract (both Bottles +dependencies), *update-mime-database*. Optional: *notify-send*. + +# SEE ALSO + +*bottles-cli*(1), *update-mime-database*(1), *flatpak-document-export*(1) + +# AUTHORS + +Felitendo. https://github.com/Felitendo/bottles-opener diff --git a/po/bottles-opener.pot b/po/bottles-opener.pot new file mode 100644 index 0000000..a14ed1c --- /dev/null +++ b/po/bottles-opener.pot @@ -0,0 +1,314 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the bottles-opener package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: bottles-opener 0.1.0\n" +"Report-Msgid-Bugs-To: https://github.com/Felitendo/bottles-opener/issues\n" +"POT-Creation-Date: 2026-09-21 11:30+0200\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +#, sh-printf-format +msgid "\"%s\" is not a file extension." +msgstr "" + +msgid "\"double-click\" opens it; \"Open With\" only lists the program there." +msgstr "" + +msgid "%d days ago" +msgstr "" + +msgid "%d hours ago" +msgstr "" + +#, sh-printf-format +msgid "%d in %d bottles" +msgstr "" + +#, sh-printf-format +msgid "%d in 1 bottle" +msgstr "" + +msgid "%d minutes ago" +msgstr "" + +msgid "1 day ago" +msgstr "" + +msgid "1 hour ago" +msgstr "" + +msgid "1 minute ago" +msgstr "" + +#, sh-printf-format +msgid "Already up to date for %s." +msgstr "" + +msgid "" +"Bottles could not be read - is python3 with PyYAML installed? Nothing was " +"changed." +msgstr "" + +msgid "" +"Bottles is not installed - neither the package nor the Flatpak was found." +msgstr "" + +msgid "Commands:" +msgstr "" + +#, sh-printf-format +msgid "Could not hand %s to the Bottles Flatpak." +msgstr "" + +msgid "Could not save the setting." +msgstr "" + +msgid "Could not start the watcher for newly installed programs." +msgstr "" + +msgid "Double-click one of these files and it opens in its program." +msgstr "" + +msgid "File opening" +msgstr "" + +msgid "File opening is off - this is what it would do." +msgstr "" + +msgid "File opening is off and everything that was changed has been put back." +msgstr "" + +msgid "File opening is off. Turn it on first." +msgstr "" + +#, sh-printf-format +msgid "File opening is on for %s." +msgstr "" + +msgid "File types" +msgstr "" + +#, sh-printf-format +msgid "File types for %s, separated by spaces:" +msgstr "" + +msgid "Find the file types of installed programs" +msgstr "" + +msgid "Give files the icon of their program" +msgstr "" + +msgid "Last applied" +msgstr "" + +msgid "List every file type and what opens it" +msgstr "" + +msgid "List every program and the file types it opens" +msgstr "" + +msgid "Looking at the bottles..." +msgstr "" + +#, sh-printf-format +msgid "More than one program is called \"%s\" - name the bottle as well." +msgstr "" + +msgid "New programs" +msgstr "" + +#, sh-printf-format +msgid "No drive in the bottle \"%s\" reaches %s." +msgstr "" + +msgid "" +"No file types are set up yet. Add one with \"bottles-opener add\" or under " +"File types in the menu." +msgstr "" + +msgid "No file types yet." +msgstr "" + +msgid "No file types yet. Press a to add one." +msgstr "" + +#, sh-printf-format +msgid "" +"No program called \"%s\" was found. \"bottles-opener programs\" lists them " +"all." +msgstr "" + +msgid "No programs found in any bottle." +msgstr "" + +msgid "" +"No systemd user session - newly installed programs will not be picked up on " +"their own." +msgstr "" + +#, sh-printf-format +msgid "Not overwriting %s - it was not written by this program." +msgstr "" + +#, sh-printf-format +msgid "Nothing opens .%s." +msgstr "" + +msgid "OFF" +msgstr "" + +msgid "ON" +msgstr "" + +msgid "Open With" +msgstr "" + +msgid "Open a file type with a program" +msgstr "" + +#, sh-printf-format +msgid "Opens files in %s from the bottle \"%s\"" +msgstr "" + +msgid "Pick up newly installed programs" +msgstr "" + +msgid "Pick up programs installed since the last run" +msgstr "" + +msgid "Press any key to continue..." +msgstr "" + +msgid "Programs" +msgstr "" + +msgid "Programs found" +msgstr "" + +msgid "Quit" +msgstr "" + +msgid "Re-apply everything" +msgstr "" + +msgid "Saved." +msgstr "" + +msgid "Saved. Turn file opening on with \"bottles-opener enable\"." +msgstr "" + +msgid "Settings" +msgstr "" + +msgid "Show the version" +msgstr "" + +msgid "Show this help" +msgstr "" + +msgid "Show what is set up" +msgstr "" + +msgid "Stop opening a file type" +msgstr "" + +msgid "Take everything back and write it again" +msgstr "" + +#, sh-printf-format +msgid "The file %s does not exist." +msgstr "" + +msgid "" +"This launcher is out of date. Run \"bottles-opener apply\" and try again." +msgstr "" + +msgid "Turn file opening on" +msgstr "" + +msgid "Turn file opening on or off" +msgstr "" + +msgid "Turn it off and put everything back" +msgstr "" + +#, sh-printf-format +msgid "Unknown command: %s" +msgstr "" + +msgid "Up/Down select - Enter edits its file types - q goes back" +msgstr "" + +msgid "Up/Down select - Enter picks - q cancels" +msgstr "" + +msgid "Up/Down select - Space changes - q goes back" +msgstr "" + +msgid "" +"Up/Down select - Space switches - a adds one - Del removes - q goes back" +msgstr "" + +msgid "Usage: bottles-opener [command]" +msgstr "" + +msgid "" +"Usage: bottles-opener add [bottle] [--open-with|--" +"default]" +msgstr "" + +msgid "Usage: bottles-opener open [file...]" +msgstr "" + +msgid "Usage: bottles-opener remove [program] [bottle]" +msgstr "" + +msgid "Which file extensions? For example: flp fst" +msgstr "" + +#, sh-printf-format +msgid "Which program opens %s?" +msgstr "" + +msgid "Without a command an interactive menu is shown." +msgstr "" + +msgid "added" +msgstr "" + +msgid "could not be read - is python3 with PyYAML installed?" +msgstr "" + +msgid "double-click" +msgstr "" + +msgid "just now" +msgstr "" + +msgid "never" +msgstr "" + +msgid "none yet - add one under File types" +msgstr "" + +msgid "not found" +msgstr "" + +msgid "off" +msgstr "" + +msgid "package" +msgstr "" + +msgid "program not found" +msgstr "" diff --git a/po/de.po b/po/de.po new file mode 100644 index 0000000..62aad73 --- /dev/null +++ b/po/de.po @@ -0,0 +1,333 @@ +# German translation for bottles-opener. +# Copyright (C) 2026 Felitendo +# This file is distributed under the same license as bottles-opener. +# +msgid "" +msgstr "" +"Project-Id-Version: bottles-opener 0.1.0\n" +"Report-Msgid-Bugs-To: https://github.com/Felitendo/bottles-opener/issues\n" +"POT-Creation-Date: 2026-09-21 11:30+0200\n" +"PO-Revision-Date: 2026-09-21 00:00+0200\n" +"Last-Translator: Felitendo\n" +"Language-Team: German\n" +"Language: de\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +#, sh-printf-format +msgid "\"%s\" is not a file extension." +msgstr "„%s“ ist keine Dateiendung." + +msgid "\"double-click\" opens it; \"Open With\" only lists the program there." +msgstr "" +"„Doppelklick“ öffnet die Datei; „Öffnen mit“ listet das Programm nur dort " +"auf." + +msgid "%d days ago" +msgstr "vor %d Tagen" + +msgid "%d hours ago" +msgstr "vor %d Stunden" + +#, sh-printf-format +msgid "%d in %d bottles" +msgstr "%d in %d Bottles" + +#, sh-printf-format +msgid "%d in 1 bottle" +msgstr "%d in 1 Bottle" + +msgid "%d minutes ago" +msgstr "vor %d Minuten" + +msgid "1 day ago" +msgstr "vor 1 Tag" + +msgid "1 hour ago" +msgstr "vor 1 Stunde" + +msgid "1 minute ago" +msgstr "vor 1 Minute" + +#, sh-printf-format +msgid "Already up to date for %s." +msgstr "Für %s ist schon alles eingerichtet." + +msgid "" +"Bottles could not be read - is python3 with PyYAML installed? Nothing was " +"changed." +msgstr "" +"Bottles konnte nicht gelesen werden - ist python3 mit PyYAML installiert? Es " +"wurde nichts geändert." + +msgid "" +"Bottles is not installed - neither the package nor the Flatpak was found." +msgstr "" +"Bottles ist nicht installiert - weder das Paket noch das Flatpak wurde " +"gefunden." + +msgid "Commands:" +msgstr "Befehle:" + +#, sh-printf-format +msgid "Could not hand %s to the Bottles Flatpak." +msgstr "%s konnte nicht an das Bottles-Flatpak übergeben werden." + +msgid "Could not save the setting." +msgstr "Die Einstellung konnte nicht gespeichert werden." + +msgid "Could not start the watcher for newly installed programs." +msgstr "" +"Die Überwachung für neu installierte Programme konnte nicht gestartet werden." + +msgid "Double-click one of these files and it opens in its program." +msgstr "Ein Doppelklick auf eine dieser Dateien öffnet sie in ihrem Programm." + +msgid "File opening" +msgstr "Dateien öffnen" + +msgid "File opening is off - this is what it would do." +msgstr "Dateien öffnen ist aus - so würde es aussehen." + +msgid "File opening is off and everything that was changed has been put back." +msgstr "" +"Dateien öffnen ist aus und alles, was geändert wurde, ist wiederhergestellt." + +msgid "File opening is off. Turn it on first." +msgstr "Dateien öffnen ist aus. Schalte es zuerst ein." + +#, sh-printf-format +msgid "File opening is on for %s." +msgstr "Dateien öffnen ist an für %s." + +msgid "File types" +msgstr "Dateitypen" + +#, sh-printf-format +msgid "File types for %s, separated by spaces:" +msgstr "Dateitypen für %s, durch Leerzeichen getrennt:" + +msgid "Find the file types of installed programs" +msgstr "Dateitypen installierter Programme erkennen" + +msgid "Give files the icon of their program" +msgstr "Dateien das Symbol ihres Programms geben" + +msgid "Last applied" +msgstr "Zuletzt angewendet" + +msgid "List every file type and what opens it" +msgstr "Alle Dateitypen auflisten und womit sie geöffnet werden" + +msgid "List every program and the file types it opens" +msgstr "Alle Programme und ihre Dateitypen auflisten" + +msgid "Looking at the bottles..." +msgstr "Bottles werden durchsucht..." + +#, sh-printf-format +msgid "More than one program is called \"%s\" - name the bottle as well." +msgstr "Mehrere Programme heißen „%s“ - gib auch die Bottle an." + +msgid "New programs" +msgstr "Neue Programme" + +#, sh-printf-format +msgid "No drive in the bottle \"%s\" reaches %s." +msgstr "Kein Laufwerk in der Bottle „%s“ erreicht %s." + +msgid "" +"No file types are set up yet. Add one with \"bottles-opener add\" or under " +"File types in the menu." +msgstr "" +"Noch keine Dateitypen eingerichtet. Füge einen mit „bottles-opener add“ oder " +"im Menü unter Dateitypen hinzu." + +msgid "No file types yet." +msgstr "Noch keine Dateitypen." + +msgid "No file types yet. Press a to add one." +msgstr "Noch keine Dateitypen. Drücke a, um einen hinzuzufügen." + +#, sh-printf-format +msgid "" +"No program called \"%s\" was found. \"bottles-opener programs\" lists them " +"all." +msgstr "" +"Kein Programm namens „%s“ gefunden. „bottles-opener programs“ listet alle " +"auf." + +msgid "No programs found in any bottle." +msgstr "In keiner Bottle wurden Programme gefunden." + +msgid "" +"No systemd user session - newly installed programs will not be picked up on " +"their own." +msgstr "" +"Keine systemd-Benutzersitzung - neu installierte Programme werden nicht von " +"selbst erkannt." + +#, sh-printf-format +msgid "Not overwriting %s - it was not written by this program." +msgstr "" +"%s wird nicht überschrieben - die Datei stammt nicht von diesem Programm." + +#, sh-printf-format +msgid "Nothing opens .%s." +msgstr "Nichts öffnet .%s." + +msgid "OFF" +msgstr "AUS" + +msgid "ON" +msgstr "AN" + +msgid "Open With" +msgstr "Öffnen mit" + +msgid "Open a file type with a program" +msgstr "Einen Dateityp mit einem Programm öffnen" + +#, sh-printf-format +msgid "Opens files in %s from the bottle \"%s\"" +msgstr "Öffnet Dateien in %s aus der Bottle „%s“" + +msgid "Pick up newly installed programs" +msgstr "Neu installierte Programme erkennen" + +msgid "Pick up programs installed since the last run" +msgstr "Seit dem letzten Lauf installierte Programme übernehmen" + +msgid "Press any key to continue..." +msgstr "Beliebige Taste drücken, um fortzufahren..." + +msgid "Programs" +msgstr "Programme" + +msgid "Programs found" +msgstr "Gefundene Programme" + +msgid "Quit" +msgstr "Beenden" + +msgid "Re-apply everything" +msgstr "Alles neu anwenden" + +msgid "Saved." +msgstr "Gespeichert." + +msgid "Saved. Turn file opening on with \"bottles-opener enable\"." +msgstr "Gespeichert. Schalte Dateien öffnen mit „bottles-opener enable“ ein." + +msgid "Settings" +msgstr "Einstellungen" + +msgid "Show the version" +msgstr "Version anzeigen" + +msgid "Show this help" +msgstr "Diese Hilfe anzeigen" + +msgid "Show what is set up" +msgstr "Anzeigen, was eingerichtet ist" + +msgid "Stop opening a file type" +msgstr "Einen Dateityp nicht mehr öffnen" + +msgid "Take everything back and write it again" +msgstr "Alles zurücknehmen und neu schreiben" + +#, sh-printf-format +msgid "The file %s does not exist." +msgstr "Die Datei %s existiert nicht." + +msgid "" +"This launcher is out of date. Run \"bottles-opener apply\" and try again." +msgstr "" +"Dieser Starter ist veraltet. Führe „bottles-opener apply“ aus und versuche " +"es noch einmal." + +msgid "Turn file opening on" +msgstr "Dateien öffnen einschalten" + +msgid "Turn file opening on or off" +msgstr "Dateien öffnen ein- oder ausschalten" + +msgid "Turn it off and put everything back" +msgstr "Ausschalten und alles wiederherstellen" + +#, sh-printf-format +msgid "Unknown command: %s" +msgstr "Unbekannter Befehl: %s" + +msgid "Up/Down select - Enter edits its file types - q goes back" +msgstr "Hoch/Runter wählt - Enter bearbeitet die Dateitypen - q geht zurück" + +msgid "Up/Down select - Enter picks - q cancels" +msgstr "Hoch/Runter wählt - Enter übernimmt - q bricht ab" + +msgid "Up/Down select - Space changes - q goes back" +msgstr "Hoch/Runter wählt - Leertaste ändert - q geht zurück" + +msgid "" +"Up/Down select - Space switches - a adds one - Del removes - q goes back" +msgstr "" +"Hoch/Runter wählt - Leertaste schaltet um - a fügt hinzu - Entf entfernt - q " +"geht zurück" + +msgid "Usage: bottles-opener [command]" +msgstr "Aufruf: bottles-opener [Befehl]" + +msgid "" +"Usage: bottles-opener add [bottle] [--open-with|--" +"default]" +msgstr "" +"Aufruf: bottles-opener add [Bottle] [--open-with|--" +"default]" + +msgid "Usage: bottles-opener open [file...]" +msgstr "Aufruf: bottles-opener open [Datei...]" + +msgid "Usage: bottles-opener remove [program] [bottle]" +msgstr "Aufruf: bottles-opener remove [Programm] [Bottle]" + +msgid "Which file extensions? For example: flp fst" +msgstr "Welche Dateiendungen? Zum Beispiel: flp fst" + +#, sh-printf-format +msgid "Which program opens %s?" +msgstr "Welches Programm öffnet %s?" + +msgid "Without a command an interactive menu is shown." +msgstr "Ohne Befehl wird ein interaktives Menü angezeigt." + +msgid "added" +msgstr "hinzugefügt" + +msgid "could not be read - is python3 with PyYAML installed?" +msgstr "konnte nicht gelesen werden - ist python3 mit PyYAML installiert?" + +msgid "double-click" +msgstr "Doppelklick" + +msgid "just now" +msgstr "gerade eben" + +msgid "never" +msgstr "nie" + +msgid "none yet - add one under File types" +msgstr "noch keine - unter Dateitypen hinzufügen" + +msgid "not found" +msgstr "nicht gefunden" + +msgid "off" +msgstr "aus" + +msgid "package" +msgstr "Paket" + +msgid "program not found" +msgstr "Programm nicht gefunden" diff --git a/po/extra.pot b/po/extra.pot new file mode 100644 index 0000000..01acada --- /dev/null +++ b/po/extra.pot @@ -0,0 +1,39 @@ +# Messages xgettext cannot find on its own: the setting labels are kept in an +# array (src/lib/menu.sh, BO_SETTINGS) and looked up by value, and the parser +# loses track inside the (( )) conditions of bo_time_ago (src/lib/common.sh). +msgid "" +msgstr "" +"Content-Type: text/plain; charset=UTF-8\n" + +msgid "Find the file types of installed programs" +msgstr "" + +msgid "Give files the icon of their program" +msgstr "" + +msgid "Pick up newly installed programs" +msgstr "" + +msgid "never" +msgstr "" + +msgid "just now" +msgstr "" + +msgid "1 minute ago" +msgstr "" + +msgid "%d minutes ago" +msgstr "" + +msgid "1 hour ago" +msgstr "" + +msgid "%d hours ago" +msgstr "" + +msgid "1 day ago" +msgstr "" + +msgid "%d days ago" +msgstr "" diff --git a/res/systemd/bottles-opener.path b/res/systemd/bottles-opener.path new file mode 100644 index 0000000..70e263b --- /dev/null +++ b/res/systemd/bottles-opener.path @@ -0,0 +1,26 @@ +[Unit] +Description=Watch Bottles for newly installed programs +Documentation=man:bottles-opener(1) + +[Path] +# A bottle that is created or deleted. Where the bottles are depends on how +# Bottles was installed: a distribution package keeps them in the user's data +# directory, the Flatpak in its own. A directory that does not exist on this +# system costs nothing - systemd watches the nearest parent that does and picks +# the path up when it appears. +PathModified=%h/.local/share/bottles/bottles +PathModified=%h/.var/app/com.usebottles.bottles/data/bottles/bottles + +# A program installed into a bottle that already exists turns up in that +# bottle's bottle.yml, and Bottles can be told to keep its bottles somewhere +# else entirely. Neither can be written down here: `enable` lists them in a +# drop-in, and every run of the service keeps that list current. + +# A type the system defines is copied whole to change its icon; a +# shared-mime-info update refreshes the copy. +PathModified=/usr/share/mime/packages + +Unit=bottles-opener.service + +[Install] +WantedBy=default.target diff --git a/res/systemd/bottles-opener.service b/res/systemd/bottles-opener.service new file mode 100644 index 0000000..f3821b9 --- /dev/null +++ b/res/systemd/bottles-opener.service @@ -0,0 +1,12 @@ +[Unit] +Description=Set up file opening for programs installed with Bottles +Documentation=man:bottles-opener(1) + +[Service] +Type=oneshot +ExecStart=@BINDIR@/bottles-opener apply --quiet +# Reading every bottle.yml is quick; one that takes longer than this has found +# something pathological and should not be left running. +TimeoutStartSec=120 +Nice=10 +IOSchedulingClass=idle diff --git a/scripts/sandbox.sh b/scripts/sandbox.sh new file mode 100755 index 0000000..4045b3c --- /dev/null +++ b/scripts/sandbox.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Runs the checkout against a throwaway XDG tree. Reads the real bottles, writes nowhere else. +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +box="${BO_SANDBOX:-${TMPDIR:-/tmp}/bottles-opener-sandbox}" + +# The real bottles, resolved before the XDG dirs are pointed away. +export BO_NATIVE_DATA="${XDG_DATA_HOME:-$HOME/.local/share}" +export BO_FLATPAK_DATA="$HOME/.var/app/com.usebottles.bottles/data" + +export XDG_CONFIG_HOME="$box/config" XDG_DATA_HOME="$box/data" +export XDG_STATE_HOME="$box/state" XDG_CACHE_HOME="$box/cache" +export BO_LIBDIR="$root/src/lib" BO_NO_WATCH=1 + +# Translations, when built. +if [[ -f $root/po/de.mo ]]; then + mkdir -p "$box/locale/de/LC_MESSAGES" + cp -f "$root/po/de.mo" "$box/locale/de/LC_MESSAGES/bottles-opener.mo" + export BO_LOCALEDIR="$box/locale" +fi + +mkdir -p "$XDG_CONFIG_HOME" "$XDG_DATA_HOME" "$XDG_STATE_HOME" "$XDG_CACHE_HOME" +exec "$root/src/bottles-opener" "$@" diff --git a/src/bottles-opener b/src/bottles-opener new file mode 100755 index 0000000..6e7d89d --- /dev/null +++ b/src/bottles-opener @@ -0,0 +1,372 @@ +#!/usr/bin/env bash +# +# bottles-opener - double-click a file and it opens in its Windows program, +# inside its bottle +# +# Copyright (C) 2026 Felitendo +# SPDX-License-Identifier: GPL-3.0-or-later + +set -uo pipefail + +BO_LIBDIR="${BO_LIBDIR:-@LIBDIR@}" + +# What the launchers run; a desktop session's PATH may differ. +BO_SELF="$(readlink -f -- "${BASH_SOURCE[0]}")" + +for _mod in common config scan known mime assoc apply open menu; do + # shellcheck source=/dev/null + if ! source "$BO_LIBDIR/$_mod.sh"; then + printf 'bottles-opener: cannot load %s/%s.sh\n' "$BO_LIBDIR" "$_mod" >&2 + exit 14 + fi +done +unset _mod + +# As root this would set up root's file types, not the user's. +if [[ $EUID -eq 0 && -z ${BO_ALLOW_ROOT:-} ]]; then + printf 'bottles-opener: run this as your own user, not as root.\n' >&2 + exit 2 +fi + +# --------------------------------------------------------------------------- +# Commands +# --------------------------------------------------------------------------- + +_bo_need_bottles() { + bo_installs_detect + (( ${#BO_INSTALLS[@]} )) && return 0 + bo_bad "$(bo_msg "Bottles is not installed - neither the package nor the Flatpak was found.")" + return 1 +} + +_bo_report() { + if (( BO_N_ACTIVE == 0 )); then + bo_note "$(bo_msg "No file types are set up yet. Add one with \"bottles-opener add\" or under File types in the menu.")" + else + bo_ok "$(bo_msg "File opening is on for %s." "$(bo_assoc_active_exts 12)")" + fi +} + +bo_do_enable() { + _bo_need_bottles || return 1 + + bo_config_set Enabled yes || { + bo_bad "$(bo_msg "Could not save the setting.")" + return 1 + } + bo_config_load + + bo_apply || return 1 + _bo_report + + if [[ $CFG_WATCH == yes ]]; then + if bo_watch_available; then + bo_watch_enable || bo_note "$(bo_msg "Could not start the watcher for newly installed programs.")" + else + bo_note "$(bo_msg "No systemd user session - newly installed programs will not be picked up on their own.")" + fi + fi +} + +bo_do_disable() { + bo_config_set Enabled no || { + bo_bad "$(bo_msg "Could not save the setting.")" + return 1 + } + + bo_revert + bo_watch_disable + + bo_ok "$(bo_msg "File opening is off and everything that was changed has been put back.")" +} + +bo_do_apply() { + local rebuild=0 arg + + for arg in "$@"; do + case "$arg" in + --rebuild) rebuild=1 ;; + --quiet) BO_QUIET=1 ;; + esac + done + + bo_config_load + + if [[ $CFG_ENABLED != yes ]]; then + # The watcher, while switched off. + [[ -n $BO_QUIET ]] && return 0 + bo_note "$(bo_msg "File opening is off. Turn it on first.")" + return 1 + fi + + # The repair: whatever drifted is written again. + if (( rebuild )); then + bo_revert + fi + + bo_apply || return 1 + + if (( rebuild || BO_CHANGES )); then + _bo_report + elif (( BO_N_ACTIVE )); then + bo_ok "$(bo_msg "Already up to date for %s." "$(bo_assoc_active_exts 12)")" + else + _bo_report + fi + + if [[ $CFG_WATCH == yes ]] && bo_watch_available; then + if bo_watch_enabled; then + bo_watch_refresh + else + bo_watch_enable || true + fi + elif [[ $CFG_WATCH != yes ]] && bo_watch_enabled; then + bo_watch_disable + fi +} + +bo_do_status() { + bo_config_load + bo_head " $BO_PRETTY" + bo_ui_status + printf '\n' +} + +# bo_do_list +# Every file type and what opens it. +bo_do_list() { + local k i label state pad l_default l_openwith l_off l_missing + + bo_config_load + bo_assoc_compute + + if (( ${#BO_A_ORDER[@]} == 0 )); then + bo_note "$(bo_msg "No file types yet.")" + return 0 + fi + + bo_msg_into "double-click"; l_default="$BO_MSG_RESULT" + bo_msg_into "Open With"; l_openwith="$BO_MSG_RESULT" + bo_msg_into "off"; l_off="$BO_MSG_RESULT" + bo_msg_into "program not found"; l_missing="$BO_MSG_RESULT" + + bo_head " $(bo_msg "File types")" + + for k in "${BO_A_ORDER[@]}"; do + i="${BO_A_PIDX[k]}" + if (( i >= 0 )); then + bo_program_label "$i" + label="$BO_LABEL" + case "${BO_A_STATE[k]}" in + default) state="$l_default" ;; + openwith) state="$l_openwith" ;; + *) state="$l_off" ;; + esac + else + label="${BO_A_BOTTLE[k]}" + state="$l_missing" + fi + printf -v label '%-9s %s' ".${BO_A_EXT[k]}" "$label" + pad=$(( 46 - ${#label} )) + (( pad < 0 )) && pad=0 + printf ' %s%*s %s\n' "$label" "$pad" '' "$state" + done + printf '\n' +} + +# bo_do_programs +# Every program in every bottle, with the file types it opens. +bo_do_programs() { + local i pad label + + bo_config_load + _bo_need_bottles || return 1 + bo_assoc_compute + + if (( ${#BO_P_ID[@]} == 0 )); then + bo_note "$(bo_msg "No programs found in any bottle.")" + return 0 + fi + + bo_head " $(bo_msg "Programs")" + for i in "${!BO_P_ID[@]}"; do + bo_program_label "$i" + label="$BO_LABEL" + pad=$(( 46 - ${#label} )) + (( pad < 0 )) && pad=0 + printf ' %s%*s %s%s%s\n' "$label" "$pad" '' "$BO_C_DIM" "$(bo_program_exts "$i")" "$BO_C_RESET" + done + printf '\n' +} + +# _bo_find_program [bottle] +# By name or id, optionally within one bottle. Index in BO_FOUND. +_bo_find_program() { + local want="$1" bottle="${2:-}" i + local -a hits=() + + for i in "${!BO_P_ID[@]}"; do + [[ -n $bottle && ${BO_P_BOTTLE[i]} != "$bottle" ]] && continue + if [[ ${BO_P_ID[i]} == "$want" || ${BO_P_NAME[i],,} == "${want,,}" ]]; then + hits+=("$i") + fi + done + + if (( ${#hits[@]} == 1 )); then + BO_FOUND="${hits[0]}" + return 0 + fi + + if (( ${#hits[@]} == 0 )); then + bo_bad "$(bo_msg "No program called \"%s\" was found. \"bottles-opener programs\" lists them all." "$want")" + else + bo_bad "$(bo_msg "More than one program is called \"%s\" - name the bottle as well." "$want")" + fi + return 1 +} + +# bo_do_add [bottle] [--open-with|--default] +# Extensions separated by commas: flp,fst. +bo_do_add() { + local state='' arg ext i + local -a pos=() + + for arg in "$@"; do + case "$arg" in + --open-with) state=openwith ;; + --default) state=default ;; + *) pos+=("$arg") ;; + esac + done + + if (( ${#pos[@]} < 2 )); then + bo_bad "$(bo_msg "Usage: bottles-opener add [bottle] [--open-with|--default]")" + return 1 + fi + + if ! _bo_parse_exts "${pos[0]}" || (( ${#BO_EXTS[@]} == 0 )); then + bo_bad "$(bo_msg "\"%s\" is not a file extension." "${BO_EXT:-${pos[0]}}")" + return 1 + fi + + bo_config_load + _bo_need_bottles || return 1 + bo_scan_once + _bo_find_program "${pos[1]}" "${pos[2]:-}" || return 1 + # Saved: bo_assoc_compute looks programs up through BO_FOUND as well. + i=$BO_FOUND + + bo_assoc_compute + for ext in "${BO_EXTS[@]}"; do + bo_assoc_add "$ext" "$i" "$state" || { + bo_bad "$(bo_msg "Could not save the setting.")" + return 1 + } + done + + if [[ $CFG_ENABLED == yes ]]; then + bo_do_apply + else + bo_ok "$(bo_msg "Saved. Turn file opening on with \"bottles-opener enable\".")" + fi +} + +# bo_do_remove [program] [bottle] +# With every program that opens them, or only the one named. +bo_do_remove() { + local k n=0 i ext + + if (( $# < 1 )) || ! _bo_parse_exts "$1" || (( ${#BO_EXTS[@]} == 0 )); then + bo_bad "$(bo_msg "Usage: bottles-opener remove [program] [bottle]")" + return 1 + fi + + bo_config_load + bo_scan_once + local only=-1 + if (( $# >= 2 )); then + _bo_find_program "$2" "${3:-}" || return 1 + only=$BO_FOUND + fi + + bo_assoc_compute + for ext in "${BO_EXTS[@]}"; do + for k in "${!BO_A_EXT[@]}"; do + [[ ${BO_A_EXT[k]} == "$ext" && ${BO_A_STATE[k]} != off ]] || continue + i="${BO_A_PIDX[k]}" + (( only < 0 || only == i )) || continue + bo_assoc_remove "$k" && n=$(( n + 1 )) + done + done + + if (( n == 0 )); then + bo_note "$(bo_msg "Nothing opens .%s." "${BO_EXTS[*]}")" + return 1 + fi + + if [[ $CFG_ENABLED == yes ]]; then + bo_do_apply + else + bo_ok "$(bo_msg "Saved.")" + fi +} + +bo_do_help() { + cat <<- EOF + $BO_PRETTY $BO_VERSION + + $(bo_msg "Usage: bottles-opener [command]") + + $(bo_msg "Commands:") + enable $(bo_msg "Turn file opening on") + disable $(bo_msg "Turn it off and put everything back") + apply $(bo_msg "Pick up programs installed since the last run") + apply --rebuild $(bo_msg "Take everything back and write it again") + status $(bo_msg "Show what is set up") + list $(bo_msg "List every file type and what opens it") + programs $(bo_msg "List every program and the file types it opens") + add [bottle] + $(bo_msg "Open a file type with a program") + remove [program] $(bo_msg "Stop opening a file type") + -h, --help $(bo_msg "Show this help") + -V, --version $(bo_msg "Show the version") + + $(bo_msg "Without a command an interactive menu is shown.") + EOF +} + +# --------------------------------------------------------------------------- +# Dispatch +# --------------------------------------------------------------------------- + +main() { + local cmd="${1:-}" + [[ $# -gt 0 ]] && shift + + case "$cmd" in + enable) bo_do_enable "$@" ;; + disable) bo_do_disable "$@" ;; + apply|refresh) bo_do_apply "$@" ;; + status) bo_do_status "$@" ;; + list) bo_do_list "$@" ;; + programs) bo_do_programs "$@" ;; + add) bo_do_add "$@" ;; + remove) bo_do_remove "$@" ;; + + # What the launchers run. Not in the help: nobody types it. + open) bo_do_open "$@" ;; + + -h|--help|help) bo_do_help ;; + -V|--version) printf '%s %s\n' "$BO_NAME" "$BO_VERSION" ;; + + '') bo_ui_menu ;; + *) + bo_bad "$(bo_msg "Unknown command: %s" "$cmd")" + printf '\n' + bo_do_help + exit 1 + ;; + esac +} + +main "$@" diff --git a/src/lib/apply.sh b/src/lib/apply.sh new file mode 100644 index 0000000..e409bcc --- /dev/null +++ b/src/lib/apply.sh @@ -0,0 +1,484 @@ +# shellcheck shell=bash +# +# Writing it all down, and taking it back. +# +# bo_apply converges: works out what should exist, writes what differs, takes +# back what the ledger has and is no longer wanted. Idempotent, so the watcher +# can call it any time. Undoing is converging on nothing. + +BO_CHANGES=0 + +# Stable, unique per program, nothing to quote. +bo_launcher_id() { + printf '%s\t%s' "$1" "$2" | sha1sum | cut -c1-12 +} + +# _bo_entry_escape +# A desktop entry value. The scan already removed newlines. +_bo_entry_escape() { + printf '%s' "${1//\\/\\\\}" +} + +# _bo_exec_arg +# One Exec argument, escaped for both layers: string value and Exec quoting. +_bo_exec_arg() { + local s="$1" + + s="${s//%/%%}" + if [[ $s =~ ^[A-Za-z0-9_./+%-]+$ ]]; then + printf '%s' "$s" + return + fi + + s="${s//\\/\\\\}" + s="${s//\"/\\\"}" + s="${s//\`/\\\`}" + s="${s//\$/\\\$}" + # and the string layer on top + s="${s//\\/\\\\}" + printf '"%s"' "$s" +} + +# bo_launcher_entry +bo_launcher_entry() { + local i="$1" mimes="$2" id="$3" name="$4" icon exe wmclass comment + + icon="${BO_P_ICON[i]}" + if [[ $icon == /* && ! -f $icon ]] || [[ -z $icon ]]; then + icon="com.usebottles.bottles" + fi + + exe="${BO_P_PATH[i]//\\//}" + exe="${exe##*/}" + wmclass="${exe,,}" + + comment="$(bo_msg "Opens files in %s from the bottle \"%s\"" "${BO_P_NAME[i]}" "${BO_P_BOTTLE[i]}")" + + printf '[Desktop Entry]\n' + printf 'Type=Application\n' + printf 'Name=%s\n' "$(_bo_entry_escape "$name")" + printf 'Comment=%s\n' "$(_bo_entry_escape "$comment")" + printf 'Icon=%s\n' "$(_bo_entry_escape "$icon")" + printf 'Exec=%s open %s %%f\n' "$(_bo_exec_arg "$BO_SELF")" "$id" + printf 'Terminal=false\n' + # Hidden: Bottles has the menu entry. Still opens its types and shows under + # "Open With", as the spec intends. + printf 'NoDisplay=true\n' + printf 'MimeType=%s\n' "$mimes" + [[ -n $wmclass ]] && printf 'StartupWMClass=%s\n' "$(_bo_entry_escape "$wmclass")" + printf 'X-Bottles-Opener-Id=%s\n' "$id" +} + +# _bo_owned +# Ours to overwrite: in the ledger, or carrying our mark (a lost ledger). +# Icons have no mark. +_bo_owned() { + local path="$1" + [[ -e $path ]] || return 0 + bo_ledger_has file "$path" && return 0 + case "$path" in + *.desktop) grep -q '^X-Bottles-Opener-Id=' "$path" 2>/dev/null ;; + *.xml) grep -q 'Written by bottles-opener' "$path" 2>/dev/null ;; + *) return 1 ;; + esac +} + +# _bo_file_put +# Writes and records a file of ours. Never overwrites somebody else's. +_bo_file_put() { + local path="$1" content="$2" rc + + if ! _bo_owned "$path"; then + bo_bad "$(bo_msg "Not overwriting %s - it was not written by this program." "$path")" + return 1 + fi + + bo_write_if_changed "$path" "$content" + rc=$? + (( rc == 2 )) && return 1 + (( rc == 0 )) && _bo_mark_dirty "$path" + bo_ledger_add file "$path" + return 0 +} + +# _bo_file_copy +_bo_file_copy() { + local path="$1" src="$2" + + _bo_owned "$path" || return 1 + + if ! cmp -s -- "$src" "$path" 2>/dev/null; then + mkdir -p "$(dirname "$path")" 2>/dev/null || return 1 + cp -f -- "$src" "$path" 2>/dev/null || return 1 + _bo_mark_dirty "$path" + fi + bo_ledger_add file "$path" +} + +# What changed, so only those caches are rebuilt. BO_CHANGES: any of it. +BO_DIRTY_MIME=0 +BO_DIRTY_APPS=0 +BO_DIRTY_ICONS=0 + +_bo_mark_dirty() { + BO_CHANGES=1 + case "$1" in + "$BO_MIMEPKG") BO_DIRTY_MIME=1 ;; + "$BO_APPDIR"/*) BO_DIRTY_APPS=1 ;; + "$BO_ICONDIR"/*) BO_DIRTY_ICONS=1 ;; + esac +} + +# bo_icons_changed +# KIconLoader's iconChanged signal. +bo_icons_changed() { + if bo_have dbus-send; then + dbus-send --session --type=signal /KIconLoader \ + org.kde.KIconLoader.iconChanged int32:0 > /dev/null 2>&1 || true + elif bo_have gdbus; then + gdbus emit --session --object-path /KIconLoader \ + --signal org.kde.KIconLoader.iconChanged 0 > /dev/null 2>&1 || true + fi +} + +# bo_refresh +# Rebuilds the desktop's caches. Every tool is optional. +bo_refresh() { + if (( BO_DIRTY_MIME )) && bo_have update-mime-database; then + mkdir -p "$BO_MIMEDIR/packages" 2>/dev/null + update-mime-database "$BO_MIMEDIR" > /dev/null 2>&1 || true + fi + + if (( BO_DIRTY_APPS )) && bo_have update-desktop-database; then + update-desktop-database "$BO_APPDIR" > /dev/null 2>&1 || true + fi + + if (( BO_DIRTY_ICONS )) && [[ -d $BO_ICONDIR ]] && bo_have gtk-update-icon-cache; then + # The user's hicolor has no index.theme of its own, hence -t. + gtk-update-icon-cache -q -t -f "$BO_ICONDIR" > /dev/null 2>&1 || true + fi + + # Running KDE apps remember icons they did not find; this makes them look + # again, as System Settings does after an icon theme change. + if (( BO_DIRTY_ICONS )); then + bo_icons_changed + fi + + # KDE's cache, rebuilt now rather than eventually. + if (( BO_DIRTY_MIME || BO_DIRTY_APPS )); then + local k + for k in kbuildsycoca6 kbuildsycoca5; do + if bo_have "$k"; then + "$k" > /dev/null 2>&1 || true + break + fi + done + fi + + BO_DIRTY_MIME=0; BO_DIRTY_APPS=0; BO_DIRTY_ICONS=0 +} + +# bo_apply +bo_apply() { + local k i ext mime id path name src + local -a want_files=() launcher_lines=() command_lines=() + declare -A prog_mimes=() lid=() defaults=() default_prog=() names=() dup=() + declare -A icon_src=() icon_idx=() icon_prog=() icon_rank=() + declare -A BO_PKG_DESC=() BO_PKG_MAGIC=() BO_PKG_GICON=() BO_PKG_GLOBS=() BO_PKG_ICON=() + BO_PKG_ORDER=() + + BO_CHANGES=0 + bo_config_load + bo_assoc_compute + + # Unreadable looks like empty, and converging on empty removes everything. + if [[ -n $BO_SCAN_ERROR ]]; then + bo_bad "$(bo_msg "Bottles could not be read - is python3 with PyYAML installed? Nothing was changed.")" + return 1 + fi + + for k in "${BO_A_ORDER[@]}"; do + i="${BO_A_PIDX[k]}" + [[ ${BO_A_STATE[k]} != off && $i -ge 0 ]] || continue + ext="${BO_A_EXT[k]}" + + bo_mime_for "$ext" + mime="$BO_MIME" + + [[ -n ${lid[$i]:-} ]] || lid[$i]="$(bo_launcher_id "${BO_P_INSTALL[i]}" "${BO_P_ID[i]}")" + [[ ";${prog_mimes[$i]:-}" == *";$mime;"* ]] || prog_mimes[$i]+="$mime;" + + if [[ ${BO_A_STATE[k]} == default && -z ${default_prog[$mime]:-} ]]; then + default_prog[$mime]="$i" + fi + + # How Windows starts the program for this extension. + [[ -n ${BO_A_ARGS[k]} ]] \ + && command_lines+=("${lid[$i]}"$'\t'"$ext"$'\t'"${BO_A_ARGS[k]}") + + if (( BO_MIME_OWN )); then + if [[ -z ${BO_PKG_DESC[$mime]+set} ]]; then + bo_known_type "$ext" + BO_PKG_ORDER+=("$mime") + BO_PKG_DESC[$mime]="${BO_A_DESC[k]:-$BO_KT_DESC}" + BO_PKG_MAGIC[$mime]="$BO_KT_MAGIC" + BO_PKG_GICON[$mime]="$BO_KT_ICON" + BO_PKG_GLOBS[$mime]='' + fi + [[ " ${BO_PKG_GLOBS[$mime]} " == *" $ext "* ]] \ + || BO_PKG_GLOBS[$mime]+="${BO_PKG_GLOBS[$mime]:+ }$ext" + fi + + # As in Windows: the icon of the default program. A system type keeps + # its own until then; one of ours takes the first program's. + if [[ $CFG_ICONS == yes ]] && { (( BO_MIME_OWN )) || [[ ${BO_A_STATE[k]} == default ]]; }; then + local rank=1 + [[ ${BO_A_STATE[k]} == default ]] && rank=2 + [[ -n ${BO_A_ICON[k]} ]] && rank=$(( rank + 2 )) + if (( rank > ${icon_rank[$mime]:-0} )); then + icon_rank[$mime]=$rank + icon_prog[$mime]=$i + if [[ -n ${BO_A_ICON[k]} ]]; then + icon_src[$mime]="${BO_A_ICON[k]}"; icon_idx[$mime]="${BO_A_IDX[k]}" + else + icon_src[$mime]="${BO_P_ICON[i]}"; icon_idx[$mime]=0 + fi + fi + fi + done + + # The same name twice in "Open With" gets the bottle added. + for i in "${!prog_mimes[@]}"; do + name="${BO_P_NAME[i]}" + [[ -n ${names[$name]+set} ]] && dup[$name]=1 + names[$name]=1 + done + + # Icons. A system type goes into the package just for its icon. + for mime in "${!icon_src[@]}"; do + i="${icon_prog[$mime]}" + src="${icon_src[$mime]}" + # No Windows icon to be had: the program's own, as Bottles saved it. + if ! bo_icon_png "${BO_P_INSTALL[i]}" "$src" "${icon_idx[$mime]}"; then + [[ $src != "${BO_P_ICON[i]}" ]] || continue + bo_icon_png "${BO_P_INSTALL[i]}" "${BO_P_ICON[i]}" 0 || continue + fi + bo_icon_target "$mime" "$BO_ICON_PNG" || continue + _bo_file_copy "$BO_ICON_TARGET" "$BO_ICON_PNG" || continue + want_files+=("$BO_ICON_TARGET") + BO_PKG_ICON[$mime]="$BO_ICON_NAME" + [[ -n ${BO_PKG_DESC[$mime]+set} ]] || BO_PKG_ORDER+=("$mime") + done + + if (( ${#BO_PKG_ORDER[@]} )); then + _bo_file_put "$BO_MIMEPKG" "$(bo_mime_package)" && want_files+=("$BO_MIMEPKG") + fi + + # The launchers. + for i in "${!prog_mimes[@]}"; do + id="${lid[$i]}" + path="$BO_APPDIR/bottles-opener-$id.desktop" + name="${BO_P_NAME[i]}" + [[ -n ${dup[$name]+set} ]] && name="$name (${BO_P_BOTTLE[i]})" + + _bo_file_put "$path" "$(bo_launcher_entry "$i" "${prog_mimes[$i]}" "$id" "$name")" \ + || continue + want_files+=("$path") + + launcher_lines+=("$(printf '%s\t%s\t%s\t%s\t%s\t%s' "$id" "${BO_P_INSTALL[i]}" \ + "${BO_P_ID[i]}" "${BO_P_BOTTLE[i]}" "${BO_P_BDIR[i]}" "${BO_P_NAME[i]}")") + + for mime in "${!default_prog[@]}"; do + [[ ${default_prog[$mime]} == "$i" ]] && defaults[$mime]="bottles-opener-$id.desktop" + done + done + + # What `open` reads. + mkdir -p "$BO_STATEDIR" 2>/dev/null + if (( ${#launcher_lines[@]} )); then + printf '%s\n' "${launcher_lines[@]}" > "$BO_LAUNCHERS" + else + rm -f -- "$BO_LAUNCHERS" + fi + if (( ${#command_lines[@]} )); then + printf '%s\n' "${command_lines[@]}" > "$BO_COMMANDS" + else + rm -f -- "$BO_COMMANDS" + fi + + _bo_prune_files "${want_files[@]}" + + # Defaults, once the launchers they name exist. + for mime in "${!defaults[@]}"; do + _bo_default_set "$mime" "${defaults[$mime]};" + done + local m + while IFS= read -r m; do + [[ -n $m && -z ${defaults[$m]:-} ]] && _bo_default_restore "$m" + done < <(bo_ledger_list default) + + bo_refresh + + bo_assoc_counts + bo_state_write last_apply "$(date +%s)" + return 0 +} + +# _bo_prune_files ... +_bo_prune_files() { + local f w keep + while IFS= read -r f; do + [[ -n $f ]] || continue + keep=0 + for w in "$@"; do + [[ $w == "$f" ]] && { keep=1; break; } + done + (( keep )) && continue + + # Only the file; the directory may have been there before. + if [[ -e $f ]]; then + rm -f -- "$f" && _bo_mark_dirty "$f" + fi + bo_ledger_forget file "$f" + done < <(bo_ledger_list file) +} + +# _bo_default_set +_bo_default_set() { + local mime="$1" value="$2" current + + current="$(bo_mimeapps_get "$mime")" + [[ $current == "$value" ]] && { bo_ledger_add default "$mime" "-"; return 0; } + + # Set here before, changed since in the desktop's settings: that choice + # stands. See bo_default_claim. + if [[ -n $current ]] && ! bo_is_ours "$current" && bo_ledger_has default "$mime"; then + return 0 + fi + + # What was there before, unless one of ours. + if bo_is_ours "$current" || [[ -z $current ]]; then + bo_ledger_add default "$mime" "-" + else + bo_ledger_add default "$mime" "$current" + fi + + bo_mimeapps_set "$mime" "$value" && { BO_CHANGES=1; BO_DIRTY_APPS=1; } +} + +# bo_default_claim +# Made the default by hand: take the type back even from a later choice, by +# forgetting the record. The next apply records the current one instead. +bo_default_claim() { + local current + + bo_mime_for "$1" + current="$(bo_mimeapps_get "$BO_MIME")" + # Still ours: keep the record of what came first. + bo_is_ours "$current" && return 0 + bo_ledger_forget default "$BO_MIME" +} + +# _bo_default_restore +# Puts back what was there, if the default is still ours. +_bo_default_restore() { + local mime="$1" prev current + + prev="$(bo_ledger_detail default "$mime")" + current="$(bo_mimeapps_get "$mime")" + + if bo_is_ours "$current"; then + [[ $prev == "-" ]] && prev='' + bo_mimeapps_set "$mime" "$prev" && { BO_CHANGES=1; BO_DIRTY_APPS=1; } + fi + bo_ledger_forget default "$mime" +} + +# bo_revert +# Takes back every recorded change. +bo_revert() { + local m + + BO_CHANGES=0 + _bo_prune_files + while IFS= read -r m; do + [[ -n $m ]] && _bo_default_restore "$m" + done < <(bo_ledger_list default) + rm -f -- "$BO_LAUNCHERS" "$BO_COMMANDS" + [[ -s $BO_LEDGER ]] || rm -f -- "$BO_LEDGER" + + bo_refresh + return 0 +} + +# --------------------------------------------------------------------------- +# The watcher +# --------------------------------------------------------------------------- +# A systemd path unit on the bottles directories and every bottle.yml. What the +# shipped unit cannot know goes into a drop-in. + +BO_UNIT_PATH="bottles-opener.path" +BO_UNIT_SERVICE="bottles-opener.service" +BO_UNIT_DROPIN="${BO_XDG_CONFIG}/systemd/user/${BO_UNIT_PATH}.d" + +bo_watch_available() { + [[ -z ${BO_NO_WATCH:-} ]] || return 1 + bo_have systemctl && [[ -n ${XDG_RUNTIME_DIR:-} || -S "/run/user/$(id -u)/bus" ]] +} + +bo_watch_enabled() { + bo_watch_available || return 1 + systemctl --user is-enabled --quiet "$BO_UNIT_PATH" 2>/dev/null +} + +# _bo_watch_dropin_write +# 0 when it changed and systemd needs a reload, 1 when not. +_bo_watch_dropin_write() { + local content="[Path]"$'\n' p + + bo_scan_once + for p in "${BO_ROOTS[@]}"; do + # The unit watches the default places already. + [[ $p == "$HOME/.local/share/bottles/bottles" ]] && continue + [[ $p == "$HOME/.var/app/$BO_FLATPAK_ID/data/bottles/bottles" ]] && continue + # % is a unit file specifier. + content+="PathModified=${p//%/%%}"$'\n' + done + for p in "${BO_CONFS[@]}"; do + content+="PathChanged=${p//%/%%}"$'\n' + done + + bo_write_if_changed "$BO_UNIT_DROPIN/bottles.conf" "$content" +} + +_bo_watch_dropin_remove() { + [[ -e "$BO_UNIT_DROPIN/bottles.conf" ]] || return 1 + rm -f -- "$BO_UNIT_DROPIN/bottles.conf" + rmdir "$BO_UNIT_DROPIN" 2>/dev/null || true + return 0 +} + +bo_watch_enable() { + bo_watch_available || return 1 + _bo_watch_dropin_write + systemctl --user daemon-reload > /dev/null 2>&1 || true + systemctl --user enable --now "$BO_UNIT_PATH" > /dev/null 2>&1 || return 1 + return 0 +} + +# bo_watch_refresh +# Keeps a running watcher in step with the bottles that exist now. +bo_watch_refresh() { + bo_watch_available || return 0 + _bo_watch_dropin_write || return 0 + systemctl --user daemon-reload > /dev/null 2>&1 || true + systemctl --user restart "$BO_UNIT_PATH" > /dev/null 2>&1 || true + return 0 +} + +bo_watch_disable() { + bo_watch_available || return 0 + systemctl --user disable --now "$BO_UNIT_PATH" > /dev/null 2>&1 || true + _bo_watch_dropin_remove && systemctl --user daemon-reload > /dev/null 2>&1 + return 0 +} diff --git a/src/lib/assoc.sh b/src/lib/assoc.sh new file mode 100644 index 0000000..bb8381a --- /dev/null +++ b/src/lib/assoc.sh @@ -0,0 +1,281 @@ +# shellcheck shell=bash +# +# Which program opens which file type. +# +# Three sources, later ones winning: what each program registered in its +# bottle's registry, the table in known.sh, and what was set by hand in the +# types file. +# +# States: +# default opens on a double click +# openwith only offered under "Open With" +# off left alone +# +# Used by bo_apply to write everything and by the screens to show it, so what +# they show is what happens. + +BO_A_EXT=() # extension, without the dot +BO_A_STATE=() # default | openwith | off +BO_A_PIDX=() # index into BO_P_*, -1 for a program that is gone +BO_A_SRC=() # detected | added +BO_A_INSTALL=() +BO_A_PID=() +BO_A_BOTTLE=() +BO_A_ARGS=() # Windows argument template, empty for plain "%1" +BO_A_ICON=() # file holding the type's Windows icon, empty for none +BO_A_IDX=() # its DefaultIcon index +BO_A_DESC=() # the type's name in Windows, empty for none + +BO_A_ORDER=() # indices into BO_A_*, sorted by extension then program + +# _bo_assoc_push +_bo_assoc_push() { + local r="${BO_R_AT[$1$'\x1f'$5$'\x1f'$6]:-}" + + BO_A_EXT+=("$1"); BO_A_STATE+=("$2"); BO_A_PIDX+=("$3"); BO_A_SRC+=("$4") + BO_A_INSTALL+=("$5"); BO_A_PID+=("$6"); BO_A_BOTTLE+=("$7") + + if [[ -n $r ]]; then + BO_A_ARGS+=("${BO_R_ARGS[r]}"); BO_A_ICON+=("${BO_R_ICON[r]}") + BO_A_IDX+=("${BO_R_IDX[r]}"); BO_A_DESC+=("${BO_R_DESC[r]}") + else + BO_A_ARGS+=("${BO_KNOWN_ARGS[$1]:-}"); BO_A_ICON+=(''); BO_A_IDX+=(0); BO_A_DESC+=('') + fi +} + +bo_assoc_compute() { + local i j k r ext key + declare -A at=() has_default=() + + BO_A_EXT=(); BO_A_STATE=(); BO_A_PIDX=(); BO_A_SRC=() + BO_A_INSTALL=(); BO_A_PID=(); BO_A_BOTTLE=() + BO_A_ARGS=(); BO_A_ICON=(); BO_A_IDX=(); BO_A_DESC=(); BO_A_ORDER=() + + bo_scan_once + + # Detected: registered in the bottle, or known from the table. "auto" is + # settled below, once every default set by hand is known. + if [[ $CFG_DETECT == yes ]]; then + for r in "${!BO_R_EXT[@]}"; do + bo_program_find "${BO_R_INSTALL[r]}" "${BO_R_PID[r]}" || continue + i=$BO_FOUND + key="${BO_R_EXT[r]}"$'\x1f'"${BO_R_INSTALL[r]}"$'\x1f'"${BO_R_PID[r]}" + [[ -n ${at[$key]+set} ]] && continue + at[$key]=${#BO_A_EXT[@]} + BO_KNOWN_ARGS=() + _bo_assoc_push "${BO_R_EXT[r]}" auto "$i" detected \ + "${BO_R_INSTALL[r]}" "${BO_R_PID[r]}" "${BO_P_BOTTLE[i]}" + done + + for i in "${!BO_P_ID[@]}"; do + bo_known_exts "${BO_P_PATH[i]}" || continue + for ext in $BO_KNOWN; do + key="$ext"$'\x1f'"${BO_P_INSTALL[i]}"$'\x1f'"${BO_P_ID[i]}" + [[ -n ${at[$key]+set} ]] && continue + at[$key]=${#BO_A_EXT[@]} + _bo_assoc_push "$ext" auto "$i" detected \ + "${BO_P_INSTALL[i]}" "${BO_P_ID[i]}" "${BO_P_BOTTLE[i]}" + done + done + fi + + # Set by hand. + for j in "${!BO_T_EXT[@]}"; do + key="${BO_T_EXT[j]}"$'\x1f'"${BO_T_INSTALL[j]}"$'\x1f'"${BO_T_PID[j]}" + if [[ -n ${at[$key]+set} ]]; then + BO_A_STATE[at[$key]]="${BO_T_STATE[j]}" + continue + fi + + i=-1 + bo_program_find "${BO_T_INSTALL[j]}" "${BO_T_PID[j]}" && i=$BO_FOUND + + # Switched off for a program nothing detects any more: nothing to say. + [[ $i -lt 0 && ${BO_T_STATE[j]} == off ]] && continue + + at[$key]=${#BO_A_EXT[@]} + BO_KNOWN_ARGS=() + (( i >= 0 )) && bo_known_exts "${BO_P_PATH[i]}" + _bo_assoc_push "${BO_T_EXT[j]}" "${BO_T_STATE[j]}" "$i" added \ + "${BO_T_INSTALL[j]}" "${BO_T_PID[j]}" "${BO_T_BOTTLE[j]}" + done + + # One default per extension: the first one set by hand. + for k in "${!BO_A_EXT[@]}"; do + [[ ${BO_A_STATE[k]} == default && ${BO_A_PIDX[k]} -ge 0 ]] || continue + ext="${BO_A_EXT[k]}" + if [[ -n ${has_default[$ext]+set} ]]; then + BO_A_STATE[k]=openwith + else + has_default[$ext]=1 + fi + done + + # A detected type is the default when this program teaches the system the + # type - nothing else could open it. A type the system knows already + # belongs to some application somebody chose, and only gets "Open With". + for k in "${!BO_A_EXT[@]}"; do + [[ ${BO_A_STATE[k]} == auto ]] || continue + ext="${BO_A_EXT[k]}" + bo_mime_for "$ext" + if (( BO_MIME_OWN )) && [[ -z ${has_default[$ext]+set} ]]; then + BO_A_STATE[k]=default + has_default[$ext]=1 + else + BO_A_STATE[k]=openwith + fi + done + + # Sorted for display. + if (( ${#BO_A_EXT[@]} )); then + local label + while IFS=$'\x1f' read -r _ _ k; do + BO_A_ORDER+=("$k") + done < <( + for k in "${!BO_A_EXT[@]}"; do + if (( BO_A_PIDX[k] >= 0 )); then + label="${BO_P_NAME[BO_A_PIDX[k]]}" + else + label="~" + fi + printf '%s\x1f%s\x1f%s\n' "${BO_A_EXT[k]}" "${label,,}" "$k" + done | LC_ALL=C sort -t $'\x1f' -k1,1 -k2,2 + ) + fi +} + +# bo_assoc_counts +# BO_N_ACTIVE: types that are on, with their program present. +BO_N_ACTIVE=0 + +bo_assoc_counts() { + local k + BO_N_ACTIVE=0 + for k in "${!BO_A_EXT[@]}"; do + [[ ${BO_A_STATE[k]} != off && ${BO_A_PIDX[k]} -ge 0 ]] \ + && BO_N_ACTIVE=$(( BO_N_ACTIVE + 1 )) + done +} + +# bo_assoc_active_exts [max] +# The extensions that are on, sorted, deduplicated. With a maximum, the rest +# is counted: ".flp .fst .doc +12". +bo_assoc_active_exts() { + local max="${1:-0}" k n=0 out='' + declare -A seen=() + for k in "${BO_A_ORDER[@]}"; do + [[ ${BO_A_STATE[k]} != off && ${BO_A_PIDX[k]} -ge 0 ]] || continue + [[ -n ${seen[${BO_A_EXT[k]}]+set} ]] && continue + seen[${BO_A_EXT[k]}]=1 + n=$(( n + 1 )) + (( max > 0 && n > max )) && continue + out+="${out:+ }.${BO_A_EXT[k]}" + done + (( max > 0 && n > max )) && out+=" +$(( n - max ))" + printf '%s\n' "$out" +} + +# bo_program_exts [max] +# The extensions that are on for one program, like bo_assoc_active_exts. +bo_program_exts() { + local i="$1" max="${2:-0}" k n=0 out='' + for k in "${BO_A_ORDER[@]}"; do + [[ ${BO_A_PIDX[k]} == "$i" && ${BO_A_STATE[k]} != off ]] || continue + n=$(( n + 1 )) + (( max > 0 && n > max )) && continue + out+="${out:+ }.${BO_A_EXT[k]}" + done + (( max > 0 && n > max )) && out+=" +$(( n - max ))" + printf '%s\n' "$out" +} + +# _bo_assoc_demote +# Every other program that is the default for the extension becomes an +# "Open With" entry. +_bo_assoc_demote() { + local ext="$1" install="$2" pid="$3" o + + for o in "${!BO_A_EXT[@]}"; do + [[ ${BO_A_EXT[o]} == "$ext" && ${BO_A_STATE[o]} == default ]] || continue + [[ ${BO_A_INSTALL[o]} == "$install" && ${BO_A_PID[o]} == "$pid" ]] && continue + bo_types_set "$ext" openwith "${BO_A_INSTALL[o]}" \ + "${BO_A_PID[o]}" "${BO_A_BOTTLE[o]}" || return 1 + done +} + +# bo_assoc_set +bo_assoc_set() { + local k="$1" state="$2" + + if [[ $state == default ]]; then + _bo_assoc_demote "${BO_A_EXT[k]}" "${BO_A_INSTALL[k]}" "${BO_A_PID[k]}" || return 1 + fi + + bo_types_set "${BO_A_EXT[k]}" "$state" "${BO_A_INSTALL[k]}" \ + "${BO_A_PID[k]}" "${BO_A_BOTTLE[k]}" || return 1 + + [[ $state == default ]] && bo_default_claim "${BO_A_EXT[k]}" + return 0 +} + +# bo_assoc_remove +# Added by hand: gone. Detected: switched off, or detection brings it back. +bo_assoc_remove() { + local k="$1" how=remove + [[ ${BO_A_SRC[k]} == detected ]] && how=off + bo_types_set "${BO_A_EXT[k]}" "$how" "${BO_A_INSTALL[k]}" \ + "${BO_A_PID[k]}" "${BO_A_BOTTLE[k]}" +} + +# bo_assoc_add [state] +# Without a state: the default for a type only this program can open, unless +# another program is already; "Open With" for one the system knows. +bo_assoc_add() { + local ext="$1" i="$2" state="${3:-}" k + + if [[ -z $state ]]; then + bo_mime_for "$ext" + state=default + (( BO_MIME_OWN )) || state=openwith + for k in "${!BO_A_EXT[@]}"; do + if [[ ${BO_A_EXT[k]} == "$ext" && ${BO_A_STATE[k]} == default \ + && ${BO_A_PIDX[k]} -ge 0 && ${BO_A_PIDX[k]} -ne $i ]]; then + state=openwith + break + fi + done + fi + + if [[ $state == default ]]; then + _bo_assoc_demote "$ext" "${BO_P_INSTALL[i]}" "${BO_P_ID[i]}" || return 1 + fi + + bo_types_set "$ext" "$state" "${BO_P_INSTALL[i]}" "${BO_P_ID[i]}" "${BO_P_BOTTLE[i]}" \ + || return 1 + + [[ $state == default ]] && bo_default_claim "$ext" + return 0 +} + +# bo_program_set_exts ... +# Makes exactly these the types the program opens: new ones are added, ones +# left out are removed. Returns 1 when something could not be saved. +bo_program_set_exts() { + local i="$1" k ext + shift + local want=" $* " + + for k in "${BO_A_ORDER[@]}"; do + [[ ${BO_A_PIDX[k]} == "$i" && ${BO_A_STATE[k]} != off ]] || continue + [[ $want == *" ${BO_A_EXT[k]} "* ]] && continue + bo_assoc_remove "$k" || return 1 + done + + for ext in "$@"; do + for k in "${BO_A_ORDER[@]}"; do + [[ ${BO_A_PIDX[k]} == "$i" && ${BO_A_EXT[k]} == "$ext" && ${BO_A_STATE[k]} != off ]] \ + && continue 2 + done + bo_assoc_add "$ext" "$i" || return 1 + done +} diff --git a/src/lib/common.sh b/src/lib/common.sh new file mode 100644 index 0000000..9bb587e --- /dev/null +++ b/src/lib/common.sh @@ -0,0 +1,264 @@ +# shellcheck shell=bash +# +# Paths, translations, output helpers and the change ledger. + +BO_VERSION="@VERSION@" +BO_NAME="bottles-opener" +BO_PRETTY="Bottles Opener" + +BO_LIBDIR="${BO_LIBDIR:-@LIBDIR@}" +BO_LOCALEDIR="${BO_LOCALEDIR:-@LOCALEDIR@}" + +BO_XDG_CONFIG="${XDG_CONFIG_HOME:-$HOME/.config}" +BO_XDG_DATA="${XDG_DATA_HOME:-$HOME/.local/share}" +BO_XDG_STATE="${XDG_STATE_HOME:-$HOME/.local/state}" + +BO_CONFDIR="${BO_CONFDIR:-${BO_XDG_CONFIG}/${BO_NAME}}" +BO_CONFIG="${BO_CONFIG:-${BO_CONFDIR}/config}" +BO_TYPES="${BO_TYPES:-${BO_CONFDIR}/types}" +BO_STATEDIR="${BO_STATEDIR:-${BO_XDG_STATE}/${BO_NAME}}" +BO_CACHEDIR="${BO_CACHEDIR:-${XDG_CACHE_HOME:-$HOME/.cache}/${BO_NAME}}" + +# Launchers, each named bottles-opener-.desktop. +BO_APPDIR="${BO_XDG_DATA}/applications" + +# The user's MIME database; update-mime-database compiles packages/. +BO_MIMEDIR="${BO_XDG_DATA}/mime" +BO_MIMEPKG="${BO_MIMEDIR}/packages/${BO_NAME}.xml" + +# hicolor: the theme every other theme falls back to. +BO_ICONDIR="${BO_XDG_DATA}/icons/hicolor" + +# Default applications. Every desktop reads this one. +BO_MIMEAPPS="${BO_XDG_CONFIG}/mimeapps.list" + +# Every change, so `disable` undoes exactly that. See bo_ledger_add. +BO_LEDGER="${BO_STATEDIR}/ledger" + +# Which launcher starts what, read by `open`. Exec lines carry only the id: +# no bottle name to quote. +BO_LAUNCHERS="${BO_STATEDIR}/launchers" + +# The Windows arguments per launcher and extension, as the registry has them. +BO_COMMANDS="${BO_STATEDIR}/commands" + +BO_FLATPAK_ID="com.usebottles.bottles" + +# --------------------------------------------------------------------------- +# Translations +# --------------------------------------------------------------------------- + +export TEXTDOMAIN="bottles-opener" +export TEXTDOMAINDIR="${BO_LOCALEDIR}" + +bo_ui_locale() { + local l="${BO_UI_LOCALE:-}" + + if [[ -z $l ]]; then + l="${LC_ALL:-}" + [[ -z $l ]] && l="${LC_MESSAGES:-}" + [[ -z $l ]] && l="${LANG:-}" + fi + + # systemd's file, and Debian's. + if [[ -z $l ]]; then + local f + for f in /etc/locale.conf /etc/default/locale; do + [[ -r $f ]] || continue + l="$(sed -n 's/^LANG=//p' "$f" | tr -d '"' | head -n1)" + [[ -n $l ]] && break + done + fi + + printf '%s\n' "${l:-C}" +} + +# Resolved once, and lookups memoized: each is a fork, and screens redraw per +# keypress. +BO_LOCALE='' + +declare -A BO_MSG_CACHE=() + +BO_MSG_RESULT='' + +# bo_msg_into +# Lookup into BO_MSG_RESULT, without a fork. +bo_msg_into() { + local msgid="$1" + + [[ -n $BO_LOCALE ]] || BO_LOCALE="$(bo_ui_locale)" + + if [[ -n ${BO_MSG_CACHE[$msgid]+set} ]]; then + BO_MSG_RESULT="${BO_MSG_CACHE[$msgid]}" + return 0 + fi + + BO_MSG_RESULT="$(LC_ALL="$BO_LOCALE" LANGUAGE="${BO_LOCALE%%.*}" gettext -- "$msgid" 2>/dev/null)" + [[ -n $BO_MSG_RESULT ]] || BO_MSG_RESULT="$msgid" + BO_MSG_CACHE[$msgid]="$BO_MSG_RESULT" + return 0 +} + +# bo_msg [printf args...] +bo_msg() { + local msgid="$1" + shift + + bo_msg_into "$msgid" + + # Without arguments it is text, not a format: a % would break printf. + if (( $# == 0 )); then + printf '%s' "$BO_MSG_RESULT" + return + fi + + # shellcheck disable=SC2059 # the format string is the translated message + printf -- "$BO_MSG_RESULT" "$@" +} + +# --------------------------------------------------------------------------- +# Output +# --------------------------------------------------------------------------- + +# Decided once: inside $(...) stdout is always a pipe. +BO_INTERACTIVE='' +[[ -t 1 ]] && BO_INTERACTIVE=1 + +if [[ -n $BO_INTERACTIVE && -z ${NO_COLOR:-} ]]; then + BO_C_RESET=$'\033[0m' + BO_C_BOLD=$'\033[1m' + BO_C_DIM=$'\033[2m' + BO_C_BLUE=$'\033[38;2;23;147;209m' + BO_C_GREEN=$'\033[32m' + BO_C_YELLOW=$'\033[33m' + BO_C_RED=$'\033[31m' +else + BO_C_RESET='' BO_C_BOLD='' BO_C_DIM='' BO_C_BLUE='' + BO_C_GREEN='' BO_C_YELLOW='' BO_C_RED='' +fi + +# Set by bo_bad and bo_note: the menu waits for a key before redrawing. +BO_UI_NEEDS_ACK='' + +# --quiet, for the systemd unit. Errors still go to stderr. +BO_QUIET='' + +bo_say() { [[ -n $BO_QUIET ]] || printf '%s\n' "$*"; } +bo_head() { printf '\n%s%s%s\n\n' "$BO_C_BOLD$BO_C_BLUE" "$*" "$BO_C_RESET"; } +bo_ok() { [[ -n $BO_QUIET ]] || printf '%s✔%s %s\n' "$BO_C_GREEN" "$BO_C_RESET" "$*"; } +bo_bad() { BO_UI_NEEDS_ACK=1; printf '%s✘%s %s\n' "$BO_C_RED" "$BO_C_RESET" "$*" >&2; } +bo_note() { BO_UI_NEEDS_ACK=1; [[ -n $BO_QUIET ]] || printf '%s•%s %s\n' "$BO_C_DIM" "$BO_C_RESET" "$*"; } + +bo_have() { command -v "$1" > /dev/null 2>&1; } + +# "x minutes ago" for a unix timestamp, "never" for none. +bo_time_ago() { + local ts="$1" now delta + + [[ $ts =~ ^[0-9]+$ ]] || { bo_msg "never"; printf '\n'; return; } + + now="$(date +%s)" + delta=$(( now - ts )) + (( delta < 0 )) && delta=0 + + if (( delta < 60 )); then bo_msg "just now" + elif (( delta < 120 )); then bo_msg "1 minute ago" + elif (( delta < 3600 )); then bo_msg "%d minutes ago" "$(( delta / 60 ))" + elif (( delta < 7200 )); then bo_msg "1 hour ago" + elif (( delta < 86400 )); then bo_msg "%d hours ago" "$(( delta / 3600 ))" + elif (( delta < 172800 )); then bo_msg "1 day ago" + else bo_msg "%d days ago" "$(( delta / 86400 ))" + fi + printf '\n' +} + +# --------------------------------------------------------------------------- +# State +# --------------------------------------------------------------------------- + +bo_state_read() { + local key="$1" default="${2:-}" + if [[ -r "$BO_STATEDIR/$key" ]]; then + cat "$BO_STATEDIR/$key" + else + printf '%s\n' "$default" + fi +} + +bo_state_write() { + local key="$1" + shift + mkdir -p "$BO_STATEDIR" 2>/dev/null || return 1 + printf '%s\n' "$*" > "$BO_STATEDIR/$key" +} + +# --------------------------------------------------------------------------- +# The change ledger +# --------------------------------------------------------------------------- +# Every change, so undoing never touches what this did not write. A set, tab +# separated: +# file - deleted on undo +# default put back on undo, "-" for none + +bo_ledger_add() { + local kind="$1" key="$2" detail="${3:--}" + mkdir -p "$BO_STATEDIR" 2>/dev/null || return 1 + + # Keep the value from before the first time. + if [[ $kind == default ]] && bo_ledger_has default "$key"; then + return 0 + fi + + bo_ledger_forget "$kind" "$key" + printf '%s\t%s\t%s\n' "$kind" "$key" "$detail" >> "$BO_LEDGER" +} + +# bo_ledger_has +bo_ledger_has() { + [[ -f $BO_LEDGER ]] || return 1 + awk -F'\t' -v k="$1" -v p="$2" '$1 == k && $2 == p { found = 1 } END { exit !found }' \ + "$BO_LEDGER" +} + +# bo_ledger_detail +bo_ledger_detail() { + [[ -f $BO_LEDGER ]] || return 1 + awk -F'\t' -v k="$1" -v p="$2" '$1 == k && $2 == p { d = $3; f = 1 } END { if (!f) exit 1; print d }' \ + "$BO_LEDGER" +} + +bo_ledger_forget() { + local kind="$1" key="$2" tmp + [[ -f $BO_LEDGER ]] || return 0 + + tmp="$(mktemp "${BO_LEDGER}.XXXXXX")" || return 1 + awk -F'\t' -v k="$kind" -v p="$key" '!($1 == k && $2 == p)' "$BO_LEDGER" > "$tmp" 2>/dev/null \ + && mv -f "$tmp" "$BO_LEDGER" || rm -f "$tmp" + return 0 +} + +# bo_ledger_list +# Every key of that kind, one per line. +bo_ledger_list() { + [[ -f $BO_LEDGER ]] || return 0 + awk -F'\t' -v k="$1" '$1 == k { print $2 }' "$BO_LEDGER" +} + +# bo_write_if_changed +# 0 written, 1 already so, 2 failed. An unchanged file keeps its timestamp, so +# desktops do not rebuild their caches for nothing. +bo_write_if_changed() { + local path="$1" content="$2" current='' + + # $(...) drops the final newline. + [[ $content == *$'\n' ]] || content+=$'\n' + + # Byte for byte: $(< file) would drop trailing newlines. + [[ -r $path ]] && IFS= read -rd '' current < "$path" + + [[ $current == "$content" ]] && return 1 + + mkdir -p "$(dirname "$path")" 2>/dev/null || return 2 + printf '%s' "$content" > "$path" || return 2 + return 0 +} diff --git a/src/lib/config.sh b/src/lib/config.sh new file mode 100644 index 0000000..393b1a7 --- /dev/null +++ b/src/lib/config.sh @@ -0,0 +1,165 @@ +# shellcheck shell=bash +# +# Reading and writing ~/.config/bottles-opener/config and .../types. +# Written by the interface, never by hand. Parsed, not sourced. + +BO_CONFIG_CACHE='' +BO_CONFIG_CACHED=0 + +_bo_config_slurp() { + (( BO_CONFIG_CACHED )) && return 0 + BO_CONFIG_CACHE='' + [[ -r $BO_CONFIG ]] && BO_CONFIG_CACHE="$(< "$BO_CONFIG")" + BO_CONFIG_CACHED=1 + return 0 +} + +# _bo_config_lookup [default] +# Into BO_CONFIG_VALUE, without a fork: the settings screen reads every key per +# frame. +BO_CONFIG_VALUE='' + +_bo_config_lookup() { + local key="$1" default="${2:-}" val='' line + + BO_CONFIG_VALUE="$default" + [[ -r $BO_CONFIG ]] || return 0 + _bo_config_slurp + + while IFS= read -r line; do + [[ $line == *"$key"* ]] || continue + [[ $line =~ ^[[:space:]]*"$key"[[:space:]]*=(.*)$ ]] || continue + val="${BASH_REMATCH[1]}" + done <<< "$BO_CONFIG_CACHE" + + val="${val%%#*}" + val="${val#"${val%%[![:space:]]*}"}" + val="${val%"${val##*[![:space:]]}"}" + val="${val%\"}" + val="${val#\"}" + + [[ -n $val ]] && BO_CONFIG_VALUE="$val" + return 0 +} + +# bo_config_get [default] +bo_config_get() { + _bo_config_lookup "$@" + printf '%s\n' "$BO_CONFIG_VALUE" +} + +_bo_is_true() { + case "${1,,}" in + yes|y|true|1|on|enabled) return 0 ;; + *) return 1 ;; + esac +} + +# bo_config_bool +bo_config_bool() { + _bo_config_lookup "$1" "$2" + _bo_is_true "$BO_CONFIG_VALUE" +} + +# bo_config_set +bo_config_set() { + local key="$1" value="$2" tmp + + if [[ ! -e $BO_CONFIG ]]; then + mkdir -p "$(dirname "$BO_CONFIG")" || return 1 + { + printf '# %s\n' "$BO_PRETTY" + printf '#\n' + printf '# Written by `%s`. Nothing here needs editing by hand -\n' "$BO_NAME" + printf '# every option is reachable from `%s` itself.\n' "$BO_NAME" + } > "$BO_CONFIG" || return 1 + fi + [[ -w $BO_CONFIG ]] || return 1 + + tmp="$(mktemp "${BO_CONFIG}.XXXXXX")" || return 1 + chmod --reference="$BO_CONFIG" "$tmp" 2>/dev/null || chmod 0644 "$tmp" + + if grep -qE "^[[:space:]]*#?[[:space:]]*${key}[[:space:]]*=" "$BO_CONFIG"; then + awk -v key="$key" -v value="$value" ' + !done && $0 ~ "^[[:space:]]*#?[[:space:]]*" key "[[:space:]]*=" { + print key "=" value; done = 1; next + } + # drop any further occurrences so the file cannot grow duplicates + $0 ~ "^[[:space:]]*" key "[[:space:]]*=" { next } + { print } + ' "$BO_CONFIG" > "$tmp" || { rm -f "$tmp"; return 1; } + else + cat "$BO_CONFIG" > "$tmp" || { rm -f "$tmp"; return 1; } + printf '%s=%s\n' "$key" "$value" >> "$tmp" + fi + + mv -f "$tmp" "$BO_CONFIG" + BO_CONFIG_CACHED=0 +} + +# --------------------------------------------------------------------------- +# Resolved settings +# --------------------------------------------------------------------------- + +bo_config_load() { + CFG_ENABLED=no; bo_config_bool Enabled no && CFG_ENABLED=yes + CFG_DETECT=no; bo_config_bool DetectKnown yes && CFG_DETECT=yes + CFG_ICONS=no; bo_config_bool FileIcons yes && CFG_ICONS=yes + CFG_WATCH=no; bo_config_bool WatchNewPrograms yes && CFG_WATCH=yes + bo_types_load +} + +# --------------------------------------------------------------------------- +# The types file +# --------------------------------------------------------------------------- +# Types changed by hand. Detected ones have no line: they follow detection. +# Tab separated, no field empty: +# +# The program is found by install and id; the bottle name is for reading. + +BO_T_EXT=() +BO_T_STATE=() +BO_T_INSTALL=() +BO_T_PID=() +BO_T_BOTTLE=() + +bo_types_load() { + local ext state install pid bottle + + BO_T_EXT=(); BO_T_STATE=(); BO_T_INSTALL=(); BO_T_PID=(); BO_T_BOTTLE=() + [[ -r $BO_TYPES ]] || return 0 + + while IFS=$'\t' read -r ext state install pid bottle; do + [[ -z $ext || $ext == '#'* || -z $pid ]] && continue + case "$state" in default|openwith|off) ;; *) continue ;; esac + BO_T_EXT+=("$ext"); BO_T_STATE+=("$state"); BO_T_INSTALL+=("$install") + BO_T_PID+=("$pid"); BO_T_BOTTLE+=("$bottle") + done < "$BO_TYPES" +} + +# bo_types_set +# Replaces the line for that extension and program, or takes it out. +bo_types_set() { + local ext="$1" state="$2" install="$3" pid="$4" bottle="$5" tmp + + mkdir -p "$(dirname "$BO_TYPES")" || return 1 + if [[ ! -e $BO_TYPES ]]; then + { + printf '# %s - file types changed by hand\n' "$BO_PRETTY" + printf '#\n' + printf '# Written by `%s`. Nothing here needs editing by hand.\n' "$BO_NAME" + } > "$BO_TYPES" || return 1 + fi + + tmp="$(mktemp "${BO_TYPES}.XXXXXX")" || return 1 + awk -F'\t' -v e="$ext" -v i="$install" -v p="$pid" \ + '!($1 == e && $3 == i && $4 == p)' "$BO_TYPES" > "$tmp" \ + || { rm -f "$tmp"; return 1; } + + if [[ $state != remove ]]; then + printf '%s\t%s\t%s\t%s\t%s\n' "$ext" "$state" "$install" "$pid" "$bottle" >> "$tmp" + fi + + mv -f "$tmp" "$BO_TYPES" + bo_types_load +} diff --git a/src/lib/icon.py b/src/lib/icon.py new file mode 100644 index 0000000..41fe93d --- /dev/null +++ b/src/lib/icon.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +# +# Writes one icon of a Windows program as PNG to stdout - the largest image of +# it, the way Explorer would pick it for a big view. +# +# Usage: icon.py [index] +# an .exe or .dll, an .ico, or a .png +# [index] Windows' DefaultIcon index: n >= 0 is the n-th icon, -n the icon +# with resource id n +# +# Needs icoextract for executables, which Bottles depends on. Exit codes: +# 3 icoextract missing, 4 no such icon. +# +# Copyright (C) 2026 Felitendo +# SPDX-License-Identifier: GPL-3.0-or-later + +import struct +import sys +import zlib + +PNG_SIG = b"\x89PNG\r\n\x1a\n" + + +def png_encode(width, height, rgba): + def chunk(kind, data): + return ( + struct.pack(">I", len(data)) + kind + data + + struct.pack(">I", zlib.crc32(kind + data) & 0xFFFFFFFF) + ) + + stride = width * 4 + raw = b"".join(b"\0" + rgba[y * stride:(y + 1) * stride] for y in range(height)) + return ( + PNG_SIG + + chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0)) + + chunk(b"IDAT", zlib.compress(raw, 9)) + + chunk(b"IEND", b"") + ) + + +def dib_to_png(data, width, height): + """An icon's bitmap image: BITMAPINFOHEADER, palette, colour, AND mask.""" + (hdr, w, h2, _planes, bpp, compression) = struct.unpack_from("= mask_at + mask_stride * height + + out = bytearray(width * height * 4) + any_alpha = False + + for y in range(height): + row = pixels_at + (height - 1 - y) * stride + for x in range(width): + if bpp == 32: + b, g, r, a = data[row + x * 4:row + x * 4 + 4] + any_alpha = any_alpha or a != 0 + elif bpp == 24: + b, g, r = data[row + x * 3:row + x * 3 + 3] + a = 255 + else: + bit = x * bpp + byte = data[row + bit // 8] + index = (byte >> (8 - bpp - bit % 8)) & ((1 << bpp) - 1) + b, g, r = data[palette_at + index * 4:palette_at + index * 4 + 3] + a = 255 + o = (y * width + x) * 4 + out[o:o + 4] = bytes((r, g, b, a)) + + # Without alpha of its own the AND mask says what is transparent. + if has_mask and not (bpp == 32 and any_alpha): + for y in range(height): + row = mask_at + (height - 1 - y) * mask_stride + for x in range(width): + if data[row + x // 8] & (0x80 >> (x % 8)): + out[(y * width + x) * 4 + 3] = 0 + + return png_encode(width, height, bytes(out)) + + +def ico_to_png(ico): + reserved, kind, count = struct.unpack_from("I", data, 16)[0] + h = struct.unpack_from(">I", data, 20)[0] + bpp = bpp or 32 + entries.append((w or 256, h or 256, bpp or 32, data)) + + # Largest first, then the most colours. + for w, h, _bpp, data in sorted(entries, key=lambda e: (e[0], e[2]), reverse=True): + try: + return data if data[:8] == PNG_SIG else dib_to_png(data, w, h) + except (ValueError, struct.error, IndexError): + continue + raise ValueError("no usable image") + + +def main(): + if len(sys.argv) < 2: + return 2 + path = sys.argv[1] + index = int(sys.argv[2]) if len(sys.argv) > 2 and sys.argv[2] else 0 + + try: + with open(path, "rb") as f: + head = f.read(8) + except OSError: + return 4 + + try: + if head == PNG_SIG: + with open(path, "rb") as f: + png = f.read() + elif head[:4] == b"\0\0\1\0": + with open(path, "rb") as f: + png = ico_to_png(f.read()) + else: + try: + import icoextract + except ImportError: + return 3 + extractor = icoextract.IconExtractor(path) + if index < 0: + ico = extractor.get_icon(resource_id=-index) + else: + ico = extractor.get_icon(num=index) + png = ico_to_png(ico.getvalue()) + except Exception: # anything a broken or unusual file can throw + return 4 + + sys.stdout.buffer.write(png) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/lib/known.sh b/src/lib/known.sh new file mode 100644 index 0000000..ea2757a --- /dev/null +++ b/src/lib/known.sh @@ -0,0 +1,220 @@ +# shellcheck shell=bash +# +# Programs whose file types are known without asking. +# +# The bottle's registry is asked first (scan.py): whatever an installer +# registered there is what Windows would open. This table is for programs +# that registered nothing - portable installs, copied folders. + +# Format: |[=] ... +# +# The pattern matches the program's Windows path, lowercased, with / for \. +# A switch goes in front of the file, as in `OUTLOOK.EXE /f "file.msg"`. +BO_KNOWN_PROGRAMS=( + # Microsoft Office + "*/winword.exe|doc docx docm dot dotx dotm rtf odt" + "*/excel.exe|xls xlsx xlsm xlsb xlt xltx xltm xlam xla csv ods" + "*/powerpnt.exe|ppt pptx pptm pot potx potm pps=/s ppsx=/s ppsm=/s ppa ppam odp" + "*/outlook.exe|msg=/f oft=/t eml=/eml vcf=/v ics=/ical vcs=/vcal" + "*/msaccess.exe|accdb accde accdr accdt mdb mde" + "*/mspub.exe|pub" + "*/visio.exe|vsd vsdx vsdm vss vssx vssm vst vstx vstm" + "*/winproj.exe|mpp mpt" + "*/onenote.exe|one onepkg onetoc2" + + # Music + "*/fl64.exe|flp fsc fst" + "*/fl.exe|flp fsc fst" + "*/ableton live *.exe|als alc adg" + "*/cubase*.exe|cpr" + "*/nuendo*.exe|npr" + "*/studio one.exe|song" + "*/reaper.exe|rpp" + "*/reason.exe|reason" + "*/adobe audition.exe|sesx" + "*/guitarpro*.exe|gp gpx gp5 gp4 gp3" + "*/sibelius.exe|sib" + "*/finale.exe|musx mus" + "*/foobar2000.exe|mp3 flac ogg opus m4a wav ape wv" + + # Images and design + "*/photoshop.exe|psd psb" + "*/photoshopelementseditor.exe|psd pse" + "*/illustrator.exe|ai" + "*/indesign.exe|indd indt idml" + "*/lightroom.exe|lrcat" + "*/animate.exe|fla" + "*/affinity*/photo*.exe|afphoto" + "*/affinity*/designer*.exe|afdesign" + "*/affinity*/publisher*.exe|afpub" + "*/clipstudiopaint.exe|clip" + "*/coreldrw.exe|cdr" + "*/paintdotnet.exe|pdn" + "*/sai.exe|sai" + "*/sai2.exe|sai2" + + # 3D and CAD + "*/sketchup.exe|skp" + "*/acad.exe|dwg dxf dwt" + "*/acadlt.exe|dwg dxf dwt" + "*/rhino.exe|3dm" + "*/3dsmax.exe|max" + "*/sldworks.exe|sldprt sldasm slddrw" + "*/x2.exe|pcbdoc schdoc prjpcb" + + # Video + "*/afterfx.exe|aep" + "*/adobe premiere pro.exe|prproj" + "*/vegas*.exe|veg" + + # Everything else + "*/acrobat.exe|pdf" + "*/acrord32.exe|pdf" + "*/notepad++.exe|txt log" + "*/7zfm.exe|7z zip rar" + "*/winrar.exe|rar zip 7z" +) + +# Types for extensions the system does not know. +# +# Format: ||||| +# +# : string: or big32:, matched at offset 0. +# : "own" defines the type even where the system has the extension +# already, as something else - .mpp is Musepack audio there, not a Project +# file. The magic tells the two apart. +BO_KNOWN_TYPES=( + # Office + "ppa|application/x-ms-powerpoint-addin|PowerPoint add-in||x-office-presentation|" + "msg|application/vnd.ms-outlook|Outlook message||x-office-document|" + "oft|application/x-ms-outlook-template|Outlook template||x-office-document|" + "accdb|application/x-ms-access-database|Access database||x-office-spreadsheet|" + "accde|application/x-ms-access-database|Access database||x-office-spreadsheet|" + "accdr|application/x-ms-access-database|Access database||x-office-spreadsheet|" + "accdt|application/x-ms-access-template|Access template||x-office-spreadsheet|" + "mde|application/x-ms-access-database|Access database||x-office-spreadsheet|" + "mpp|application/vnd.ms-project|Project file|big32:0xd0cf11e0|x-office-spreadsheet|own" + "mpt|application/x-ms-project-template|Project template||x-office-spreadsheet|" + "one|application/onenote|OneNote section||x-office-document|" + "onepkg|application/x-onenote-package|OneNote package||x-office-document|" + "onetoc2|application/x-onenote-toc|OneNote table of contents||x-office-document|" + + # Music + "flp|application/x-flstudio-project|FL Studio project|string:FLhd|audio-x-generic|" + "fsc|application/x-flstudio-score|FL Studio score||audio-x-generic|" + "fst|application/x-flstudio-preset|FL Studio preset|string:FLhd|audio-x-generic|own" + "als|application/x-ableton-live-set|Ableton Live set||audio-x-generic|" + "alc|application/x-ableton-live-clip|Ableton Live clip||audio-x-generic|" + "adg|application/x-ableton-device-group|Ableton device group||audio-x-generic|" + "cpr|application/x-cubase-project|Cubase project||audio-x-generic|" + "npr|application/x-nuendo-project|Nuendo project||audio-x-generic|" + "song|application/x-studio-one-song|Studio One song||audio-x-generic|" + "rpp|application/x-reaper-project|REAPER project|string: +# BO_KNOWN: the extensions, space separated. BO_KNOWN_ARGS[ext]: the Windows +# argument template for those that need a switch. +BO_KNOWN='' +declare -A BO_KNOWN_ARGS=() + +bo_known_exts() { + local path="${1,,}" rule pattern entry ext + path="${path//\\//}" + BO_KNOWN='' + BO_KNOWN_ARGS=() + + for rule in "${BO_KNOWN_PROGRAMS[@]}"; do + pattern="${rule%%|*}" + # shellcheck disable=SC2053 # the right-hand side is a pattern on purpose + [[ $path == $pattern ]] || continue + for entry in ${rule#*|}; do + ext="${entry%%=*}" + [[ " $BO_KNOWN " == *" $ext "* ]] && continue + BO_KNOWN="${BO_KNOWN:+$BO_KNOWN }$ext" + [[ $entry == *=* ]] && BO_KNOWN_ARGS[$ext]="${entry#*=} \"%1\"" + done + done + [[ -n $BO_KNOWN ]] +} + +# bo_known_type +# BO_KT_MIME, _DESC, _MAGIC, _ICON and _OWN for an extension. Anything not in +# the table gets a type named after the extension and this program, so it +# cannot collide with a real one. Returns 1 for those. +BO_KT_MIME='' +BO_KT_DESC='' +BO_KT_MAGIC='' +BO_KT_ICON='' +BO_KT_OWN=0 + +declare -A BO_KT_INDEX=() + +bo_known_type() { + local ext="$1" entry e mime desc magic icon flags + + if (( ${#BO_KT_INDEX[@]} == 0 )); then + for entry in "${BO_KNOWN_TYPES[@]}"; do + BO_KT_INDEX[${entry%%|*}]="$entry" + done + fi + + if [[ -n ${BO_KT_INDEX[$ext]:-} ]]; then + IFS='|' read -r e mime desc magic icon flags <<< "${BO_KT_INDEX[$ext]}" + BO_KT_MIME="$mime"; BO_KT_DESC="$desc" + BO_KT_MAGIC="$magic"; BO_KT_ICON="$icon" + BO_KT_OWN=0 + [[ $flags == *own* ]] && BO_KT_OWN=1 + return 0 + fi + + BO_KT_MIME="application/x-bottles-opener-${ext}" + BO_KT_DESC="${ext^^} file" + BO_KT_MAGIC='' + BO_KT_ICON='application-x-generic' + BO_KT_OWN=0 + return 1 +} diff --git a/src/lib/menu.sh b/src/lib/menu.sh new file mode 100644 index 0000000..b6055ee --- /dev/null +++ b/src/lib/menu.sh @@ -0,0 +1,704 @@ +# shellcheck shell=bash +# +# The interactive front end. + +# Non-canonical mode for the whole interface: between two `read -sn1` bash +# goes back to canonical mode, where a backspace gets eaten. +BO_TERM_SAVED='' + +bo_ui_term_raw() { + bo_have stty || return 0 + [[ -t 0 ]] || return 0 + [[ -n $BO_TERM_SAVED ]] && return 0 + + BO_TERM_SAVED="$(stty -g 2>/dev/null)" || { BO_TERM_SAVED=''; return 0; } + stty -icanon -echo min 1 time 0 2>/dev/null || true +} + +bo_ui_term_restore() { + [[ -n $BO_TERM_SAVED ]] || return 0 + stty "$BO_TERM_SAVED" 2>/dev/null || true + BO_TERM_SAVED='' +} + +# Runs an action in normal line mode. +bo_ui_cooked() { + bo_ui_term_restore + "$@" + local rc=$? + bo_ui_term_raw + return $rc +} + +# bo_read_key +# One keypress as a name. Escape sequences are read whole. +bo_read_key() { + local k rest tail + + IFS= read -rsn1 k || return 1 + + case "$k" in + $'\e') + if IFS= read -rsn2 -t 0.05 rest; then + case "$rest" in + '[A') printf 'up\n' ;; + '[B') printf 'down\n' ;; + '[C') printf 'right\n' ;; + '[D') printf 'left\n' ;; + '[3') + IFS= read -rsn1 -t 0.05 tail || true + printf 'delete\n' + ;; + *) printf 'escape\n' ;; + esac + else + printf 'escape\n' + fi + ;; + ''|$'\r') printf 'enter\n' ;; + $'\x7f'|$'\b') printf 'backspace\n' ;; + ' ') printf 'space\n' ;; + *) printf '%s\n' "$k" ;; + esac +} + +# bo_ui_read_line +# A line editor into BO_LINE_RESULT. `read -r` would break the following +# `read -sn1`. +BO_LINE_RESULT='' + +bo_ui_read_line() { + local buf="${1:-}" key + + BO_LINE_RESULT='' + printf '%s' "$buf" + + while true; do + key="$(bo_read_key)" || { printf '\n'; return 1; } + + case "$key" in + enter) + printf '\n' + BO_LINE_RESULT="$buf" + return 0 + ;; + escape) + printf '\n' + return 1 + ;; + backspace) + if [[ -n $buf ]]; then + buf="${buf%?}" + printf '\b \b' + fi + ;; + space) + buf+=' ' + printf ' ' + ;; + up|down|left|right|delete) ;; + *) + [[ ${#key} -eq 1 ]] || continue + buf+="$key" + printf '%s' "$key" + ;; + esac + done +} + +bo_pause() { + printf '\n %s' "$(bo_msg "Press any key to continue...")" + read -rsn1 _ || true + printf '\n' +} + +# _bo_row