feat: add bottles-opener

This commit is contained in:
Felitendo committed 2026-09-21 12:16:46 +02:00
commit c831d860f3
27 files changed
+5710

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto
+15
View File
@@ -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']
+28
View File
@@ -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
+9
View File
@@ -0,0 +1,9 @@
# build artifacts
po/*.mo
doc/*.1
# packages
dist/
# python
__pycache__/
+23
View File
@@ -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 <command>` 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 <launcher> <file>` 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`.
+674
View File
@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
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.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
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 <https://www.gnu.org/licenses/>.
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:
<program> Copyright (C) <year> <name of author>
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
<https://www.gnu.org/licenses/>.
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
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+151
View File
@@ -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)
+107
View File
@@ -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 <ext,...> <program> [bottle]` | Open file types with a program |
| `… remove <ext,...> [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.
+128
View File
@@ -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
+314
View File
@@ -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 <EMAIL@ADDRESS>, 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 <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\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 <extension> <program> [bottle] [--open-with|--"
"default]"
msgstr ""
msgid "Usage: bottles-opener open <launcher> [file...]"
msgstr ""
msgid "Usage: bottles-opener remove <extension> [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 ""
+333
View File
@@ -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 <extension> <program> [bottle] [--open-with|--"
"default]"
msgstr ""
"Aufruf: bottles-opener add <Endung> <Programm> [Bottle] [--open-with|--"
"default]"
msgid "Usage: bottles-opener open <launcher> [file...]"
msgstr "Aufruf: bottles-opener open <Starter> [Datei...]"
msgid "Usage: bottles-opener remove <extension> [program] [bottle]"
msgstr "Aufruf: bottles-opener remove <Endung> [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"
+39
View File
@@ -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 ""
+26
View File
@@ -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
+12
View File
@@ -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
+24
View File
@@ -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" "$@"
+372
View File
@@ -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 <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 <extensions> <program> [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 <extension> <program> [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 <extensions> [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 <extension> [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 <ext> <program> [bottle]
$(bo_msg "Open a file type with a program")
remove <ext> [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 "$@"
+484
View File
@@ -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 <string>
# A desktop entry value. The scan already removed newlines.
_bo_entry_escape() {
printf '%s' "${1//\\/\\\\}"
}
# _bo_exec_arg <string>
# 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 <program index> <MIME types; separated> <launcher id> <display name>
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 <path>
# 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 <path> <content>
# 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 <path> <source>
_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 <wanted path>...
_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 <MIME type> <value>
_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 <extension>
# 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 <MIME type>
# 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
}
+281
View File
@@ -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 <ext> <state> <program index> <source> <install> <id> <bottle>
_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 <program index> [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 <extension> <install> <program id>
# 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 <index into BO_A_*> <state>
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 <index into BO_A_*>
# 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 <extension> <index into BO_P_*> [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 <program index> <extension>...
# 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
}
+264
View File
@@ -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-<id>.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 <msgid>
# 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 <msgid> [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 <path> - deleted on undo
# default <MIME type> <previous> 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 <kind> <key>
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 <kind> <key>
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 <kind>
# 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 <path> <content>
# 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
}
+165
View File
@@ -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 <Key> [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 <Key> [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 <Key> <default: yes|no>
bo_config_bool() {
_bo_config_lookup "$1" "$2"
_bo_is_true "$BO_CONFIG_VALUE"
}
# bo_config_set <Key> <Value>
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:
# <extension> <default|openwith|off> <install> <program id> <bottle name>
# 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 <ext> <state|remove> <install> <program id> <bottle name>
# 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
}
+151
View File
@@ -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 <file> [index]
# <file> 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("<IiiHHI", data, 0)
if compression != 0 or bpp not in (1, 4, 8, 24, 32):
raise ValueError("unsupported bitmap")
width = width or w
height = height or abs(h2) // 2
colours = struct.unpack_from("<I", data, 32)[0] or (1 << bpp if bpp <= 8 else 0)
palette_at = hdr
pixels_at = palette_at + colours * 4
stride = ((width * bpp + 31) // 32) * 4
mask_at = pixels_at + stride * height
mask_stride = ((width + 31) // 32) * 4
has_mask = len(data) >= 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("<HHH", ico, 0)
if reserved != 0 or kind != 1 or count == 0:
raise ValueError("not an icon")
entries = []
for i in range(count):
w, h, _c, _r, _p, bpp, size, offset = struct.unpack_from("<BBBBHHII", ico, 6 + 16 * i)
data = ico[offset:offset + size]
if data[:8] == PNG_SIG:
w = 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())
+220
View File
@@ -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: <pattern>|<ext>[=<switch>] ...
#
# 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: <ext>|<MIME type>|<description>|<magic>|<generic icon>|<flags>
#
# <magic>: string:<text> or big32:<hex>, matched at offset 0.
# <flags>: "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:<REAPER_PROJECT|audio-x-generic|"
"reason|application/x-reason-song|Reason song||audio-x-generic|"
"sesx|application/x-audition-session|Audition session||audio-x-generic|"
"gp|application/x-guitar-pro|Guitar Pro tablature|big32:0x504b0304|audio-x-generic|own"
"gpx|application/x-guitar-pro|Guitar Pro tablature|string:BCFS|audio-x-generic|own"
"gp5|application/x-guitar-pro|Guitar Pro tablature||audio-x-generic|"
"gp4|application/x-guitar-pro|Guitar Pro tablature||audio-x-generic|"
"gp3|application/x-guitar-pro|Guitar Pro tablature||audio-x-generic|"
"sib|application/x-sibelius-score|Sibelius score||audio-x-generic|"
"musx|application/x-finale-score|Finale score||audio-x-generic|"
"mus|application/x-finale-score|Finale score||audio-x-generic|"
# Images and design
"psb|application/x-photoshop-large-document|Photoshop large document||image-x-generic|"
"pse|application/x-photoshop-elements|Photoshop Elements document||image-x-generic|"
"indd|application/x-indesign-document|InDesign document||x-office-document|"
"indt|application/x-indesign-template|InDesign template||x-office-document|"
"idml|application/vnd.adobe.indesign-idml-package|InDesign markup||x-office-document|"
"lrcat|application/x-lightroom-catalog|Lightroom catalog||image-x-generic|"
"fla|application/x-adobe-animate|Animate document||video-x-generic|"
"afphoto|application/x-affinity-photo|Affinity Photo document||image-x-generic|"
"afdesign|application/x-affinity-designer|Affinity Designer document||image-x-generic|"
"afpub|application/x-affinity-publisher|Affinity Publisher document||x-office-document|"
"clip|application/x-clip-studio-paint|Clip Studio Paint file||image-x-generic|"
"pdn|application/x-paintdotnet|Paint.NET image||image-x-generic|"
"sai|application/x-paint-tool-sai|PaintTool SAI document||image-x-generic|"
"sai2|application/x-paint-tool-sai2|PaintTool SAI document||image-x-generic|"
# 3D and CAD
"skp|application/vnd.sketchup.skp|SketchUp model||x-office-drawing|"
"dwt|application/x-autocad-template|AutoCAD template||x-office-drawing|"
"3dm|application/x-rhino-model|Rhino model||x-office-drawing|"
"max|application/x-3ds-max-scene|3ds Max scene||x-office-drawing|"
"sldprt|application/x-solidworks-part|SolidWorks part||x-office-drawing|"
"sldasm|application/x-solidworks-assembly|SolidWorks assembly||x-office-drawing|"
"slddrw|application/x-solidworks-drawing|SolidWorks drawing||x-office-drawing|"
"pcbdoc|application/x-altium-pcb|Altium PCB||x-office-drawing|"
"schdoc|application/x-altium-schematic|Altium schematic||x-office-drawing|"
"prjpcb|application/x-altium-project|Altium project||x-office-drawing|"
# Video
"aep|application/x-after-effects-project|After Effects project||video-x-generic|"
"prproj|application/x-premiere-project|Premiere Pro project||video-x-generic|"
"veg|application/x-vegas-project|VEGAS project||video-x-generic|"
)
# bo_known_exts <windows path>
# 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 <extension>
# 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
}
+704
View File
@@ -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 <initial>
# 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 <label> <value>
# Padded by characters: printf pads by bytes, which "ü" gets wrong.
_bo_row() {
local label="$1" value="$2" pad
pad=$(( 30 - ${#label} ))
(( pad < 0 )) && pad=0
printf ' %s%*s %s\n' "$label" "$pad" '' "$value"
}
_bo_onoff() {
if [[ $1 == yes ]]; then
printf '%s%s%s' "$BO_C_GREEN" "$(bo_msg "ON")" "$BO_C_RESET"
else
printf '%s%s%s' "$BO_C_DIM" "$(bo_msg "OFF")" "$BO_C_RESET"
fi
}
BO_CLEARSEQ=''
_bo_clearseq() {
[[ -n $BO_CLEARSEQ ]] && return 0
BO_CLEARSEQ="$(clear 2>/dev/null)" || BO_CLEARSEQ=$'\033[H\033[2J'
}
# _bo_term_rows [reserved]
# How many list rows fit, in BO_ROWS.
BO_ROWS=20
_bo_term_rows() {
local size
size="$(stty size 2>/dev/null)" || size='24 80'
BO_ROWS=$(( ${size%% *} - ${1:-8} ))
(( BO_ROWS < 5 )) && BO_ROWS=5
}
# _bo_scroll <cursor> <top>
# The first row to show so the cursor is in view, in BO_TOP.
BO_TOP=0
_bo_scroll() {
BO_TOP=$2
(( $1 < BO_TOP )) && BO_TOP=$1
(( $1 >= BO_TOP + BO_ROWS )) && BO_TOP=$(( $1 - BO_ROWS + 1 ))
(( BO_TOP < 0 )) && BO_TOP=0
}
# ---------------------------------------------------------------------------
# Status
# ---------------------------------------------------------------------------
# bo_ui_status
# For `status` and the menu header, from a fresh scan.
bo_ui_status() {
local last exts nb=0 np install where=''
bo_assoc_compute
bo_assoc_counts
last="$(bo_state_read last_apply '')"
_bo_row "$(bo_msg "File opening")" "$(_bo_onoff "$CFG_ENABLED")"
printf '\n'
for install in "${BO_INSTALLS[@]}"; do
case "$install" in
native) where+="${where:+, }$(bo_msg "package")" ;;
flatpak) where+="${where:+, }Flatpak" ;;
esac
done
if [[ -z $where ]]; then
_bo_row "Bottles" "${BO_C_YELLOW}$(bo_msg "not found")${BO_C_RESET}"
elif [[ -n $BO_SCAN_ERROR ]]; then
_bo_row "Bottles" \
"${BO_C_RED}$(bo_msg "could not be read - is python3 with PyYAML installed?")${BO_C_RESET}"
else
_bo_row "Bottles" "$where"
fi
nb=${#BO_B_NAME[@]}
np=${#BO_P_ID[@]}
if (( nb == 1 )); then
_bo_row "$(bo_msg "Programs found")" "$(bo_msg "%d in 1 bottle" "$np")"
else
_bo_row "$(bo_msg "Programs found")" "$(bo_msg "%d in %d bottles" "$np" "$nb")"
fi
exts="$(bo_assoc_active_exts 10)"
if (( BO_N_ACTIVE )); then
_bo_row "$(bo_msg "File types")" "$exts"
else
_bo_row "$(bo_msg "File types")" "${BO_C_DIM}$(bo_msg "none yet - add one under File types")${BO_C_RESET}"
fi
if bo_watch_available; then
if bo_watch_enabled; then
_bo_row "$(bo_msg "New programs")" "$(_bo_onoff yes)"
else
_bo_row "$(bo_msg "New programs")" "$(_bo_onoff no)"
fi
fi
_bo_row "$(bo_msg "Last applied")" "$(bo_time_ago "$last")"
if [[ $CFG_ENABLED == yes ]] && (( BO_N_ACTIVE )); then
printf '\n %s%s%s\n' "$BO_C_DIM" \
"$(bo_msg "Double-click one of these files and it opens in its program.")" \
"$BO_C_RESET"
fi
}
# ---------------------------------------------------------------------------
# Settings
# ---------------------------------------------------------------------------
# Format: Key|default|label-msgid
BO_SETTINGS=(
"DetectKnown|yes|Find the file types of installed programs"
"FileIcons|yes|Give files the icon of their program"
"WatchNewPrograms|yes|Pick up newly installed programs"
)
# bo_ui_settings
# Returns 0 when something changed.
bo_ui_settings() {
local count=${#BO_SETTINGS[@]}
local -a names=() defaults=() labels=() values=()
local spec name default label i key frame row pad dirty=1 cursor=0 touched=0
local l_on l_off title hint shown
bo_msg_into "ON"; l_on="$BO_MSG_RESULT"
bo_msg_into "OFF"; l_off="$BO_MSG_RESULT"
for spec in "${BO_SETTINGS[@]}"; do
IFS='|' read -r name default label <<< "$spec"
names+=("$name"); defaults+=("$default")
bo_msg_into "$label"
labels+=("$BO_MSG_RESULT")
done
bo_msg_into "Settings"; title="$BO_MSG_RESULT"
bo_msg_into "Up/Down select - Space changes - q goes back"
hint="$BO_MSG_RESULT"
_bo_clearseq
while true; do
if (( dirty )); then
for i in "${!names[@]}"; do
_bo_config_lookup "${names[i]}" "${defaults[i]}"
values[i]="$BO_CONFIG_VALUE"
done
dirty=0
fi
frame="$BO_CLEARSEQ"$'\n'"${BO_C_BOLD}${BO_C_BLUE} ${title}${BO_C_RESET}"$'\n\n'
local marker selected="${BO_C_BLUE}▸${BO_C_RESET} "
for i in "${!names[@]}"; do
if _bo_is_true "${values[i]}"; then
shown="${BO_C_GREEN}${l_on}${BO_C_RESET}"
else
shown="${BO_C_DIM}${l_off}${BO_C_RESET}"
fi
pad=$(( 46 - ${#labels[i]} ))
(( pad < 0 )) && pad=0
if (( i == cursor )); then marker="$selected"; else marker=' '; fi
printf -v row ' %s%s%*s %s' "$marker" "${labels[i]}" "$pad" '' "$shown"
frame+="$row"$'\n'
done
frame+=$'\n'" ${BO_C_DIM}${hint}${BO_C_RESET}"$'\n'
printf '%s' "$frame"
key="$(bo_read_key)" || return "$(( ! touched ))"
case "$key" in
up|k) cursor=$(( (cursor - 1 + count) % count )) ;;
down|j) cursor=$(( (cursor + 1) % count )) ;;
space|enter|right|l|left|h)
if _bo_is_true "${values[cursor]}"; then
bo_config_set "${names[cursor]}" no
else
bo_config_set "${names[cursor]}" yes
fi || { bo_bad "$(bo_msg "Could not save the setting.")"; bo_pause; }
touched=1
dirty=1
;;
q|Q|escape) return "$(( ! touched ))" ;;
*) ;;
esac
done
}
# ---------------------------------------------------------------------------
# Picking a program
# ---------------------------------------------------------------------------
# bo_ui_pick_program <title>
# The chosen program's index in BO_PICKED; 1 for none.
BO_PICKED=-1
bo_ui_pick_program() {
local title="$1" count=${#BO_P_ID[@]} cursor=0 i key frame row hint
local -a labels=()
BO_PICKED=-1
if (( count == 0 )); then
printf '\n %s\n' "$(bo_msg "No programs found in any bottle.")"
bo_pause
return 1
fi
for i in "${!BO_P_ID[@]}"; do
bo_program_label "$i"
labels+=("$BO_LABEL")
done
bo_msg_into "Up/Down select - Enter picks - q cancels"
hint="$BO_MSG_RESULT"
_bo_clearseq
_bo_term_rows 7
local top=0
while true; do
_bo_scroll "$cursor" "$top"; top=$BO_TOP
frame="$BO_CLEARSEQ"$'\n'"${BO_C_BOLD}${BO_C_BLUE} ${title}${BO_C_RESET}"$'\n\n'
for i in "${!labels[@]}"; do
(( i < top || i >= top + BO_ROWS )) && continue
if (( i == cursor )); then
printf -v row ' %s %s' "${BO_C_BLUE}▸${BO_C_RESET}" "${labels[i]}"
else
printf -v row ' %s' "${labels[i]}"
fi
frame+="$row"$'\n'
done
frame+=$'\n'" ${BO_C_DIM}${hint}${BO_C_RESET}"$'\n'
printf '%s' "$frame"
key="$(bo_read_key)" || return 1
case "$key" in
up|k) cursor=$(( (cursor - 1 + count) % count )) ;;
down|j) cursor=$(( (cursor + 1) % count )) ;;
enter|space|right|l)
BO_PICKED=$cursor
return 0
;;
q|Q|escape|left|h) return 1 ;;
*) ;;
esac
done
}
# _bo_parse_exts <text>
# "flp, .FST *.fsc" -> BO_EXTS=(flp fst fsc). 1 with the bad one in BO_EXT.
BO_EXTS=()
_bo_parse_exts() {
local word
BO_EXTS=()
for word in ${1//,/ }; do
bo_ext_normalize "$word" || return 1
[[ " ${BO_EXTS[*]} " == *" $BO_EXT "* ]] || BO_EXTS+=("$BO_EXT")
done
}
# bo_ui_add_type
# Extensions, then the program. 0 when something was added.
bo_ui_add_type() {
local ext
printf '\n %s\n' "$(bo_msg "Which file extensions? For example: flp fst")"
printf ' > '
bo_ui_read_line '' || return 1
[[ -n ${BO_LINE_RESULT// /} ]] || return 1
if ! _bo_parse_exts "$BO_LINE_RESULT"; then
bo_bad "$(bo_msg "\"%s\" is not a file extension." "$BO_EXT")"
bo_pause
return 1
fi
bo_ui_pick_program "$(bo_msg "Which program opens %s?" ".${BO_EXTS[*]}")" || return 1
bo_assoc_compute
for ext in "${BO_EXTS[@]}"; do
bo_assoc_add "$ext" "$BO_PICKED" || {
bo_bad "$(bo_msg "Could not save the setting.")"
bo_pause
return 1
}
done
return 0
}
# ---------------------------------------------------------------------------
# Programs
# ---------------------------------------------------------------------------
# bo_ui_programs
# Programs and their types; Enter edits the list, custom extensions included.
# 0 when something changed.
bo_ui_programs() {
local key frame row pad i count cursor=0 top=0 dirty=1 touched=0 exts current
local title hint empty
local -a labels=() pexts=()
bo_msg_into "Programs"; title="$BO_MSG_RESULT"
bo_msg_into "Up/Down select - Enter edits its file types - q goes back"
hint="$BO_MSG_RESULT"
bo_msg_into "No programs found in any bottle."; empty="$BO_MSG_RESULT"
_bo_clearseq
printf '%s\n %s\n' "$BO_CLEARSEQ" "$(bo_msg "Looking at the bottles...")"
bo_config_load
bo_scan_once
count=${#BO_P_ID[@]}
for i in "${!BO_P_ID[@]}"; do
bo_program_label "$i"
labels+=("$BO_LABEL")
done
_bo_term_rows 7
while true; do
if (( dirty )); then
bo_config_load
bo_assoc_compute
pexts=()
for i in "${!labels[@]}"; do
pexts[i]="$(bo_program_exts "$i" 6)"
done
dirty=0
fi
frame="$BO_CLEARSEQ"$'\n'"${BO_C_BOLD}${BO_C_BLUE} ${title}${BO_C_RESET}"$'\n\n'
(( count == 0 )) && frame+=" ${BO_C_DIM}${empty}${BO_C_RESET}"$'\n'
_bo_scroll "$cursor" "$top"; top=$BO_TOP
local marker selected="${BO_C_BLUE}▸${BO_C_RESET} "
for i in "${!labels[@]}"; do
(( i < top || i >= top + BO_ROWS )) && continue
exts="${pexts[i]}"
[[ -n $exts ]] || exts="${BO_C_DIM}-${BO_C_RESET}"
pad=$(( 40 - ${#labels[i]} ))
(( pad < 0 )) && pad=0
if (( i == cursor )); then marker="$selected"; else marker=' '; fi
printf -v row ' %s%s%*s %s' "$marker" "${labels[i]}" "$pad" '' "$exts"
frame+="$row"$'\n'
done
frame+=$'\n'" ${BO_C_DIM}${hint}${BO_C_RESET}"$'\n'
printf '%s' "$frame"
key="$(bo_read_key)" || return "$(( ! touched ))"
case "$key" in
up|k) (( count )) && cursor=$(( (cursor - 1 + count) % count )) ;;
down|j) (( count )) && cursor=$(( (cursor + 1) % count )) ;;
enter|space|right|l)
(( count )) || continue
current="$(bo_program_exts "$cursor")"
current="${current//./}"
printf '\n %s\n' "$(bo_msg "File types for %s, separated by spaces:" "${BO_P_NAME[cursor]}")"
printf ' > '
bo_ui_read_line "$current" || continue
if ! _bo_parse_exts "$BO_LINE_RESULT"; then
bo_bad "$(bo_msg "\"%s\" is not a file extension." "$BO_EXT")"
bo_pause
continue
fi
bo_program_set_exts "$cursor" "${BO_EXTS[@]}" \
|| { bo_bad "$(bo_msg "Could not save the setting.")"; bo_pause; }
touched=1
dirty=1
;;
q|Q|escape|left|h) return "$(( ! touched ))" ;;
*) ;;
esac
done
}
# ---------------------------------------------------------------------------
# File types
# ---------------------------------------------------------------------------
# bo_ui_types
# 0 when something changed.
bo_ui_types() {
local key frame row pad i k count cursor=0 dirty=1 touched=0 label shown
local title hint legend warn empty
local l_default l_openwith l_off l_missing l_added
bo_msg_into "File types"; title="$BO_MSG_RESULT"
bo_msg_into "Up/Down select - Space switches - a adds one - Del removes - q goes back"
hint="$BO_MSG_RESULT"
bo_msg_into "\"double-click\" opens it; \"Open With\" only lists the program there."
legend="$BO_MSG_RESULT"
bo_msg_into "File opening is off - this is what it would do."
warn="$BO_MSG_RESULT"
bo_msg_into "No file types yet. Press a to add one."
empty="$BO_MSG_RESULT"
# Resolved once: in the loop, a fork per row per keypress.
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_msg_into "added"; l_added="$BO_MSG_RESULT"
local s_default="${BO_C_GREEN}${l_default}${BO_C_RESET}"
local s_openwith="${BO_C_GREEN}${l_openwith}${BO_C_RESET}"
local s_off="${BO_C_DIM}${l_off}${BO_C_RESET}"
local s_missing="${BO_C_YELLOW}${l_missing}${BO_C_RESET}"
_bo_clearseq
printf '%s\n %s\n' "$BO_CLEARSEQ" "$(bo_msg "Looking at the bottles...")"
bo_config_load
bo_scan_once
_bo_term_rows 11
local top=0
while true; do
if (( dirty )); then
bo_config_load
bo_assoc_compute
count=${#BO_A_ORDER[@]}
(( cursor >= count )) && cursor=$(( count > 0 ? count - 1 : 0 ))
dirty=0
fi
frame="$BO_CLEARSEQ"$'\n'"${BO_C_BOLD}${BO_C_BLUE} ${title}${BO_C_RESET}"$'\n\n'
if (( count == 0 )); then
frame+=" ${BO_C_DIM}${empty}${BO_C_RESET}"$'\n'
fi
_bo_scroll "$cursor" "$top"; top=$BO_TOP
(( top > 0 )) && frame+=" ${BO_C_DIM} ↑${BO_C_RESET}"$'\n'
local marker selected="${BO_C_BLUE}▸${BO_C_RESET} " n=-1
for k in "${BO_A_ORDER[@]}"; do
n=$(( n + 1 ))
(( n < top || n >= top + BO_ROWS )) && continue
i="${BO_A_PIDX[k]}"
if (( i >= 0 )); then
bo_program_label "$i"
label="$BO_LABEL"
else
label="${BO_A_BOTTLE[k]}"
fi
if (( i < 0 )); then
shown="$s_missing"
else
case "${BO_A_STATE[k]}" in
default) shown="$s_default" ;;
openwith) shown="$s_openwith" ;;
*) shown="$s_off" ;;
esac
fi
[[ ${BO_A_SRC[k]} == added ]] && shown+=" ${BO_C_DIM}(${l_added})${BO_C_RESET}"
printf -v row '%-9s %s' ".${BO_A_EXT[k]}" "$label"
pad=$(( 46 - ${#row} ))
(( pad < 0 )) && pad=0
if (( n == cursor )); then marker="$selected"; else marker=' '; fi
printf -v row ' %s%s%*s %s' "$marker" "$row" "$pad" '' "$shown"
frame+="$row"$'\n'
done
(( top + BO_ROWS < count )) && frame+=" ${BO_C_DIM} ↓${BO_C_RESET}"$'\n'
frame+=$'\n'
# Otherwise it reads as what is on.
if [[ $CFG_ENABLED != yes ]]; then
frame+=" ${BO_C_YELLOW}${warn}${BO_C_RESET}"$'\n'
fi
frame+=" ${BO_C_DIM}${legend}${BO_C_RESET}"$'\n'
frame+=" ${BO_C_DIM}${hint}${BO_C_RESET}"$'\n'
printf '%s' "$frame"
key="$(bo_read_key)" || return "$(( ! touched ))"
k="${BO_A_ORDER[cursor]:-}"
case "$key" in
up|k) (( count )) && cursor=$(( (cursor - 1 + count) % count )) ;;
down|j) (( count )) && cursor=$(( (cursor + 1) % count )) ;;
space|enter|right|l|left|h)
[[ -n $k ]] || continue
(( BO_A_PIDX[k] >= 0 )) || continue
case "${BO_A_STATE[k]}" in
default) bo_assoc_set "$k" openwith ;;
openwith) bo_assoc_set "$k" off ;;
*) bo_assoc_set "$k" default ;;
esac || { bo_bad "$(bo_msg "Could not save the setting.")"; bo_pause; }
touched=1
dirty=1
;;
a|A|+)
bo_ui_add_type && touched=1
dirty=1
;;
delete|backspace|d|D|x|-)
[[ -n $k ]] || continue
bo_assoc_remove "$k" || { bo_bad "$(bo_msg "Could not save the setting.")"; bo_pause; }
touched=1
dirty=1
;;
q|Q|escape) return "$(( ! touched ))" ;;
*) ;;
esac
done
}
# ---------------------------------------------------------------------------
# The menu
# ---------------------------------------------------------------------------
bo_ui_menu() {
local choice
bo_ui_term_raw
trap 'bo_ui_term_restore' EXIT INT TERM
while true; do
bo_config_load
# Scan again on every visit.
BO_SCANNED=0
clear 2>/dev/null || true
bo_head " $BO_PRETTY"
bo_ui_status
printf '\n'
printf ' [1] %s\n' "$(bo_msg "Turn file opening on or off")"
printf ' [2] %s\n' "$(bo_msg "Re-apply everything")"
printf ' [3] %s\n' "$(bo_msg "File types")"
printf ' [4] %s\n' "$(bo_msg "Programs")"
printf ' [5] %s\n' "$(bo_msg "Settings")"
printf ' [q] %s\n' "$(bo_msg "Quit")"
printf '\n > '
choice="$(bo_read_key)" || {
printf '\n'; bo_ui_term_restore; trap - EXIT INT TERM; return 0
}
case "$choice" in
enter|space|up|down|left|right|escape|delete|backspace) choice='' ;;
esac
printf '%s\n' "$choice"
case "$choice" in
1)
BO_UI_NEEDS_ACK=''
if [[ $CFG_ENABLED == yes ]]; then
bo_ui_cooked bo_do_disable
else
bo_ui_cooked bo_do_enable
fi
[[ -n $BO_UI_NEEDS_ACK ]] && bo_pause
;;
# The repair: everything taken back and written again.
2)
bo_ui_cooked bo_do_apply --rebuild
bo_pause
;;
# Quietly: the front screen shows the result. Problems wait.
3)
BO_UI_NEEDS_ACK=''
if bo_ui_types && [[ $CFG_ENABLED == yes ]]; then
bo_ui_cooked bo_do_apply --quiet
BO_QUIET=''
fi
[[ -n $BO_UI_NEEDS_ACK ]] && bo_pause
;;
4)
BO_UI_NEEDS_ACK=''
if bo_ui_programs && [[ $CFG_ENABLED == yes ]]; then
bo_ui_cooked bo_do_apply --quiet
BO_QUIET=''
fi
[[ -n $BO_UI_NEEDS_ACK ]] && bo_pause
;;
5)
BO_UI_NEEDS_ACK=''
if bo_ui_settings && [[ $CFG_ENABLED == yes ]]; then
bo_ui_cooked bo_do_apply --quiet
BO_QUIET=''
fi
[[ -n $BO_UI_NEEDS_ACK ]] && bo_pause
;;
q|Q) bo_ui_term_restore; trap - EXIT INT TERM; return 0 ;;
# Redraw. Not Escape: a mistyped arrow key must not quit.
*) ;;
esac
done
}
+316
View File
@@ -0,0 +1,316 @@
# shellcheck shell=bash
#
# File types: what the system already knows, what has to be taught to it, and
# which application opens what.
#
# Launchers claim MIME types, not extensions. An unknown extension is
# octet-stream like every unknown file, so it gets a type of its own here.
# Extension -> MIME type from the compiled globs2 files, minus our own types.
declare -A BO_SYS_MIME=()
BO_SYS_MIME_LOADED=0
bo_mime_system_load() {
local own='' dirs dir files=() ext mime
(( BO_SYS_MIME_LOADED )) && return 0
BO_SYS_MIME_LOADED=1
BO_SYS_MIME=()
# The types defined in our own package, space separated.
if [[ -r $BO_MIMEPKG ]]; then
own=" $(sed -n 's/.*<mime-type type="\([^"]*\)".*/\1/p' "$BO_MIMEPKG" | tr '\n' ' ')"
fi
[[ -r $BO_MIMEDIR/globs2 ]] && files+=("$BO_MIMEDIR/globs2")
IFS=: read -ra dirs <<< "${XDG_DATA_DIRS:-/usr/local/share:/usr/share}"
for dir in "${dirs[@]}"; do
[[ -n $dir && -r $dir/mime/globs2 ]] && files+=("$dir/mime/globs2")
done
(( ${#files[@]} )) || return 0
# weight:type:glob[:flags]; plain "*.ext" only, heaviest wins.
while IFS=$'\t' read -r ext mime; do
BO_SYS_MIME[$ext]="$mime"
# Ours can only be in the user's database.
done < <(awk -F: -v own="$own" -v userfile="$BO_MIMEDIR/globs2" '
/^#/ { next }
{
weight = $1 + 0; type = $2; glob = $3
if (FILENAME == userfile && index(own, " " type " ")) next
if (glob !~ /^\*\.[^*?[\]\/]+$/) next
ext = tolower(substr(glob, 3))
if (!(ext in best) || weight > best[ext]) { best[ext] = weight; seen[ext] = type }
}
END { for (e in seen) printf "%s\t%s\n", e, seen[e] }
' "${files[@]}" 2>/dev/null)
}
# bo_mime_for <extension>
# BO_MIME: the type the extension is claimed as. BO_MIME_OWN: 1 when this
# program defines that type.
BO_MIME=''
BO_MIME_OWN=0
bo_mime_for() {
local ext="$1"
bo_mime_system_load
if bo_known_type "$ext" && (( BO_KT_OWN )); then
BO_MIME="$BO_KT_MIME"
BO_MIME_OWN=1
elif [[ -n ${BO_SYS_MIME[$ext]:-} ]]; then
BO_MIME="${BO_SYS_MIME[$ext]}"
BO_MIME_OWN=0
else
bo_known_type "$ext"
BO_MIME="$BO_KT_MIME"
BO_MIME_OWN=1
fi
}
# bo_ext_valid <extension>
# Bottles' own rule.
bo_ext_valid() {
[[ $1 =~ ^[a-z0-9][a-z0-9_+-]{0,31}$ ]]
}
# bo_ext_normalize <input>
# ".FLP", "*.flp" and "flp" all mean the same thing. Result in BO_EXT.
BO_EXT=''
bo_ext_normalize() {
local e="${1,,}"
e="${e#\*}"
e="${e#.}"
BO_EXT="$e"
bo_ext_valid "$e"
}
_bo_xml() {
local s="$1"
s="${s//&/&amp;}"
s="${s//</&lt;}"
s="${s//>/&gt;}"
s="${s//\"/&quot;}"
printf '%s' "$s"
}
# _bo_system_type_xml <MIME type>
# The system's own definition of a type, as update-mime-database wrote it.
_bo_system_type_xml() {
local dir f
local -a dirs
IFS=: read -ra dirs <<< "${XDG_DATA_DIRS:-/usr/local/share:/usr/share}"
for dir in "${dirs[@]}"; do
for f in "$dir/mime/$1.xml" "$dir/mime/${1,,}.xml"; do
[[ -r $f ]] && { printf '%s\n' "$f"; return 0; }
done
done
return 1
}
# _bo_magic_xml <spec>
_bo_magic_xml() {
local kind="${1%%:*}" value="${1#*:}"
[[ -n $1 ]] || return 0
printf ' <magic priority="50">\n'
printf ' <match type="%s" offset="0" value="%s"/>\n' "$kind" "$(_bo_xml "$value")"
printf ' </magic>\n'
}
# bo_mime_package
# Every type in BO_PKG_ORDER, from BO_PKG_* set by bo_apply. A system type is
# copied whole with ours added: Qt and GLib read the description from the
# first directory defining the type, and the user's comes first.
bo_mime_package() {
local mime ext extra sysxml
printf '<?xml version="1.0" encoding="UTF-8"?>\n'
printf '<!-- Written by bottles-opener. Taken back by `bottles-opener disable`. -->\n'
printf '<mime-info xmlns="http://www.freedesktop.org/standards/shared-mime-info">\n'
for mime in "${BO_PKG_ORDER[@]}"; do
extra=''
for ext in ${BO_PKG_GLOBS[$mime]:-}; do
extra+=" <glob pattern=\"*.$(_bo_xml "$ext")\"/>"$'\n'
done
extra+="$(_bo_magic_xml "${BO_PKG_MAGIC[$mime]:-}")"
[[ -n ${BO_PKG_MAGIC[$mime]:-} ]] && extra+=$'\n'
if [[ -n ${BO_PKG_ICON[$mime]:-} ]]; then
extra+=" <icon name=\"$(_bo_xml "${BO_PKG_ICON[$mime]}")\"/>"$'\n'
fi
if sysxml="$(_bo_system_type_xml "$mime")"; then
awk -v extra="$extra" '
/^<\?xml/ || /<!--.*-->/ || /^[[:space:]]*<icon[[:space:]]/ { next }
/<\/mime-type>/ { printf "%s", extra; print " " $0; next }
{ print " " $0 }
' "$sysxml"
else
printf ' <mime-type type="%s">\n' "$(_bo_xml "$mime")"
printf ' <comment>%s</comment>\n' "$(_bo_xml "${BO_PKG_DESC[$mime]}")"
printf ' <generic-icon name="%s"/>\n' "$(_bo_xml "${BO_PKG_GICON[$mime]}")"
printf '%s' "$extra"
printf ' </mime-type>\n'
fi
done
printf '</mime-info>\n'
}
# ---------------------------------------------------------------------------
# Icons
# ---------------------------------------------------------------------------
# Files get the icon Windows shows for them, installed into hicolor.
# bo_png_size <file>
# A PNG's width, from its header.
bo_png_size() {
local -a b
read -ra b < <(od -An -tu1 -j0 -N24 -- "$1" 2>/dev/null | tr -s ' \n' ' ')
(( ${#b[@]} >= 24 )) || return 1
# \x89 P N G
(( b[0] == 137 && b[1] == 80 && b[2] == 78 && b[3] == 71 )) || return 1
printf '%d\n' $(( (b[16] << 24) | (b[17] << 16) | (b[18] << 8) | b[19] ))
}
# bo_icon_target <MIME type> <png>
# BO_ICON_NAME and BO_ICON_TARGET; 1 for an unusable png. Our own name: a theme
# would win over hicolor for the type's standard one.
BO_ICON_TARGET=''
BO_ICON_NAME=''
bo_icon_target() {
local mime="$1" src="$2" size s dir=''
BO_ICON_TARGET=''
[[ $src == /* && -f $src ]] || return 1
size="$(bo_png_size "$src")" || return 1
# The largest theme size the icon still fills.
for s in 16 22 24 32 48 64 128 256 512; do
(( size >= s )) && dir="${s}x${s}"
done
[[ -n $dir ]] || return 1
BO_ICON_NAME="bottles-opener-${mime//\//-}"
BO_ICON_TARGET="$BO_ICONDIR/$dir/mimetypes/$BO_ICON_NAME.png"
}
# bo_icon_png <install> <file> <index>
# The icon as PNG in BO_ICON_PNG; extracted once, then cached.
BO_ICON_PNG=''
bo_icon_png() {
local install="$1" src="$2" idx="${3:-0}" key out rc
BO_ICON_PNG=''
[[ -f $src ]] || return 1
if [[ ${src,,} == *.png ]]; then
BO_ICON_PNG="$src"
return 0
fi
key="$(printf '%s\t%s\t%s' "$src" "$idx" "$(stat -c '%s.%Y' -- "$src" 2>/dev/null)" \
| sha1sum | cut -c1-16)"
out="$BO_CACHEDIR/icons/$key.png"
[[ -s $out ]] && { BO_ICON_PNG="$out"; return 0; }
[[ -e $out.none ]] && return 1
mkdir -p "$BO_CACHEDIR/icons" 2>/dev/null || return 1
rc=127
if bo_have python3; then
python3 "$BO_LIBDIR/icon.py" "$src" "$idx" > "$out.tmp" 2>/dev/null
rc=$?
fi
# The Flatpak surely has icoextract.
if (( rc == 3 || rc == 127 )) && [[ $install == flatpak ]]; then
flatpak run --command=python3 "$BO_FLATPAK_ID" - "$src" "$idx" \
< "$BO_LIBDIR/icon.py" > "$out.tmp" 2>/dev/null
rc=$?
fi
if (( rc == 0 )) && [[ -s $out.tmp ]]; then
mv -f "$out.tmp" "$out"
BO_ICON_PNG="$out"
return 0
fi
rm -f "$out.tmp"
# Remember only a missing icon, not a missing icoextract.
(( rc == 4 )) && : > "$out.none"
return 1
}
# ---------------------------------------------------------------------------
# mimeapps.list
# ---------------------------------------------------------------------------
# Edited line by line: the rest is somebody's settings.
# bo_mimeapps_get <MIME type>
bo_mimeapps_get() {
[[ -r $BO_MIMEAPPS ]] || return 0
awk -v key="$1" '
/^[[:space:]]*\[/ { group = $0; gsub(/^[[:space:]]+|[[:space:]]+$/, "", group); next }
group == "[Default Applications]" {
line = $0
sub(/^[[:space:]]+/, "", line)
if (index(line, key "=") == 1) { print substr(line, length(key) + 2); exit }
}
' "$BO_MIMEAPPS"
}
# bo_mimeapps_set <MIME type> <value>
# An empty value takes the line out.
bo_mimeapps_set() {
local key="$1" value="$2" tmp file
mkdir -p "$(dirname "$BO_MIMEAPPS")" || return 1
[[ -e $BO_MIMEAPPS ]] || : > "$BO_MIMEAPPS" || return 1
# Keep a dotfiles symlink: replace its target, not the link.
file="$(readlink -f -- "$BO_MIMEAPPS")" || return 1
tmp="$(mktemp "${file}.XXXXXX")" || return 1
chmod --reference="$file" "$tmp" 2>/dev/null || chmod 0644 "$tmp"
awk -v key="$key" -v value="$value" '
function flush() {
if (ingroup && !done && value != "") { print key "=" value; done = 1 }
}
/^[[:space:]]*\[/ {
flush()
group = $0; gsub(/^[[:space:]]+|[[:space:]]+$/, "", group)
ingroup = (group == "[Default Applications]")
if (ingroup) seen = 1
print; next
}
ingroup {
line = $0
sub(/^[[:space:]]+/, "", line)
if (index(line, key "=") == 1) {
if (!done && value != "") { print key "=" value; done = 1 }
next
}
}
{ print }
END {
flush()
if (!seen && value != "") {
print "[Default Applications]"
print key "=" value
}
}
' "$file" > "$tmp" || { rm -f "$tmp"; return 1; }
mv -f "$tmp" "$file"
}
# bo_is_ours <mimeapps value>
# Whether it names only one of our launchers.
bo_is_ours() {
local v="${1%;}"
[[ $v == bottles-opener-*.desktop && $v != *';'* ]]
}
+237
View File
@@ -0,0 +1,237 @@
# shellcheck shell=bash
#
# Opening a file: what a launcher runs on a double click.
#
# The file is handed over as Explorer would: a Windows path (C:\ inside the
# bottle, Z:\ for the rest), since "/home/..." reads like a switch to many
# programs. The Flatpak gets it through the document portal first.
# bo_uri_to_path <argument>
# file:// URI to path, in BO_PATH. Returns 1 for anything not local.
BO_PATH=''
bo_uri_to_path() {
local s="$1"
case "$s" in
file://*)
s="${s#file://}"
# localhost is local; other hosts are not.
if [[ $s == localhost/* ]]; then
s="/${s#localhost/}"
elif [[ $s != /* ]]; then
BO_PATH="$1"
return 1
fi
# Percent-decoding; backslashes doubled for printf %b.
s="${s//\\/\\\\}"
s="${s//%/\\x}"
printf -v s '%b' "$s"
;;
esac
BO_PATH="$s"
}
# bo_winpath <bottle directory> <absolute path>
# The path through the drive with the longest matching target, in BO_WINPATH.
BO_WINPATH=''
bo_winpath() {
local bdir="$1" path="$2" dev target letter best='' best_target='' best_len=-1 len rest
BO_WINPATH=''
for dev in "$bdir"/dosdevices/?:; do
[[ -e $dev ]] || continue
target="$(readlink -f -- "$dev")" || continue
if [[ $target == / ]]; then
len=0
elif [[ $path == "$target" || $path == "$target"/* ]]; then
len=${#target}
else
continue
fi
if (( len > best_len )); then
best_len=$len
best="$dev"
best_target="$target"
fi
done
[[ -n $best ]] || return 1
letter="${best##*/}"
letter="${letter%:}"
if [[ $best_target == / ]]; then
rest="$path"
else
rest="${path#"$best_target"}"
fi
rest="${rest//\//\\}"
[[ $rest == \\* ]] || rest="\\$rest"
BO_WINPATH="${letter^^}:$rest"
}
# bo_open_fail <message>
# On the terminal, or as a notification when started from a file manager.
bo_open_fail() {
printf '%s: %s\n' "$BO_NAME" "$1" >&2
if [[ ! -t 2 ]] && bo_have notify-send; then
notify-send --app-name="$BO_PRETTY" --icon=dialog-error \
"$BO_PRETTY" "$1" > /dev/null 2>&1 || true
fi
return 1
}
# bo_cli_has_program_id <install>
# Whether bottles-cli knows --program-id (older ones only names). Read, not
# run: running it costs a second and a half per double click.
bo_cli_has_program_id() {
local cli loc
case "$1" in
native)
cli="$(command -v bottles-cli)" || return 1
;;
flatpak)
loc="$(flatpak info --show-location "$BO_FLATPAK_ID" 2>/dev/null)" || return 1
cli="$loc/files/bin/bottles-cli"
;;
esac
[[ -r $cli ]] && grep -q -- '--program-id' "$cli" 2>/dev/null
}
# _bo_win_args <template> <windows path>
# The arguments Windows would pass, in BO_WARGS: the registry's template with
# the file for %1. Office's /o "%u" and other placeholders are dropped. A
# template without %1 relies on DDE (Excel's /dde): then only the file.
BO_WARGS=()
_bo_win_args() {
local t="$1" file="$2" c j cur='' have=0 quoted=0 placed=0 tok last
local -a toks=()
for (( j = 0; j < ${#t}; j++ )); do
c="${t:j:1}"
if [[ $c == '"' ]]; then
quoted=$(( ! quoted )); have=1
elif [[ $c == [[:space:]] ]] && (( ! quoted )); then
(( have )) && toks+=("$cur")
cur=''; have=0
else
cur+="$c"; have=1
fi
done
(( have )) && toks+=("$cur")
BO_WARGS=()
for tok in "${toks[@]}"; do
if [[ $tok == *%[1lLvV]* ]]; then
tok="${tok//%1/"$file"}"; tok="${tok//%[lL]/"$file"}"; tok="${tok//%[vV]/"$file"}"
BO_WARGS+=("$tok")
placed=1
elif [[ $tok == *%[uU]* ]]; then
# ...and the switch in front of it.
last=$(( ${#BO_WARGS[@]} - 1 ))
if (( last >= 0 )) && [[ ${BO_WARGS[last]} == [/-]* && ${BO_WARGS[last]} != *%* ]]; then
unset "BO_WARGS[last]"
BO_WARGS=("${BO_WARGS[@]}")
fi
elif [[ $tok == *%[*0-9A-Za-z~]* ]]; then
continue
else
BO_WARGS+=("$tok")
fi
done
(( placed )) || BO_WARGS=("$file")
}
# bo_do_open <launcher id> [file...]
bo_do_open() {
local id="${1:-}" line install pid bottle bdir pname arg path ext template
local -a winargs=() cmd=()
shift || true
if [[ ! $id =~ ^[0-9a-f]{12}$ ]]; then
bo_open_fail "$(bo_msg "Usage: bottles-opener open <launcher> [file...]")"
return 1
fi
line="$(awk -F'\t' -v id="$id" '$1 == id { print; exit }' "$BO_LAUNCHERS" 2>/dev/null)"
if [[ -z $line ]]; then
bo_open_fail "$(bo_msg "This launcher is out of date. Run \"bottles-opener apply\" and try again.")"
return 1
fi
IFS=$'\t' read -r _ install pid bottle bdir pname <<< "$line"
for arg in "$@"; do
# Not a local file: passed as it is.
if ! bo_uri_to_path "$arg"; then
winargs+=("$arg")
continue
fi
path="$BO_PATH"
[[ $path == /* ]] || path="$PWD/$path"
if [[ ! -e $path ]]; then
bo_open_fail "$(bo_msg "The file %s does not exist." "$path")"
return 1
fi
path="$(readlink -f -- "$path")"
if [[ $install == flatpak && $path != "$bdir"/* ]]; then
# Writable, so the program can save it.
if ! path="$(flatpak document-export --app="$BO_FLATPAK_ID" \
--allow-read --allow-write -- "$path" 2>/dev/null)" || [[ -z $path ]]; then
bo_open_fail "$(bo_msg "Could not hand %s to the Bottles Flatpak." "$arg")"
return 1
fi
fi
if ! bo_winpath "$bdir" "$path"; then
bo_open_fail "$(bo_msg "No drive in the bottle \"%s\" reaches %s." "$bottle" "$path")"
return 1
fi
# With the switches Windows would use for this extension.
ext="${path##*/}"
ext="${ext##*.}"
template=''
[[ $path == */*.* ]] && template="$(awk -F'\t' -v id="$id" -v e="${ext,,}" \
'$1 == id && $2 == e { print $3; exit }' "$BO_COMMANDS" 2>/dev/null)"
_bo_win_args "$template" "$BO_WINPATH"
winargs+=("${BO_WARGS[@]}")
done
case "$install" in
native) cmd=(bottles-cli) ;;
flatpak) cmd=(flatpak run --command=bottles-cli "$BO_FLATPAK_ID") ;;
*)
bo_open_fail "$(bo_msg "This launcher is out of date. Run \"bottles-opener apply\" and try again.")"
return 1
;;
esac
cmd+=(run -b "$bottle")
if bo_cli_has_program_id "$install"; then
cmd+=(--program-id "$pid")
else
cmd+=(-p "$pname")
fi
cmd+=(-- "${winargs[@]}")
if [[ -n ${BO_DRY_RUN:-} ]]; then
printf '%q ' "${cmd[@]}"
printf '\n'
return 0
fi
exec "${cmd[@]}"
}
+452
View File
@@ -0,0 +1,452 @@
#!/usr/bin/env python3
#
# Lists the bottles and programs of one Bottles installation, and the file
# types each program registered in its bottle's registry - the way Windows
# knows them: extension -> ProgID -> open command, icon and description.
#
# Runs on the host, or inside the Bottles Flatpak fed through stdin, so it only
# needs what Bottles itself needs: Python and PyYAML.
#
# Usage: scan.py <data home> [cache dir]
#
# Output, one record per line, fields separated by \x1f:
# R <bottles directory>
# B <bottle name> <bottle directory> <bottle.yml>
# P <bottle name> <program id> <program name> <windows path> <icon>
# A <bottle name> <program id> <ext> <args> <icon file> <icon index> <description>
#
# Copyright (C) 2026 Felitendo
# SPDX-License-Identifier: GPL-3.0-or-later
import hashlib
import json
import os
import re
import sys
try:
import yaml
except ImportError:
sys.exit(3)
LOADER = getattr(yaml, "CSafeLoader", yaml.SafeLoader)
SEP = "\x1f"
CACHE_VERSION = 2
EXT_RE = re.compile(r"^[a-z0-9][a-z0-9_+-]{0,31}$")
# Registered by half of all installers, and never a document.
SKIP_EXTS = {
"exe", "dll", "lnk", "bat", "cmd", "com", "msi", "msp", "scr", "pif",
"url", "cpl", "sys", "ocx", "drv", "reg", "inf", "ini", "cab",
}
def load(path):
try:
with open(path, encoding="utf-8") as f:
return yaml.load(f, Loader=LOADER)
except (OSError, UnicodeDecodeError, yaml.YAMLError):
return None
def field(value):
text = "" if value is None else str(value)
for ch in ("\n", "\r", "\t", SEP):
text = text.replace(ch, " ")
return text
def emit(*values):
sys.stdout.write(SEP.join(field(v) for v in values) + "\n")
# ---------------------------------------------------------------------------
# Registry
# ---------------------------------------------------------------------------
def _unescape(s):
out, i = [], 0
while i < len(s):
c = s[i]
if c == "\\" and i + 1 < len(s):
n = s[i + 1]
if n == "x":
m = re.match(r"[0-9a-fA-F]{1,4}", s[i + 2:])
if m:
out.append(chr(int(m.group(0), 16)))
i += 2 + len(m.group(0))
continue
out.append({"n": "\n", "r": "\r", "t": "\t", "0": "\0"}.get(n, n))
i += 2
continue
out.append(c)
i += 1
return "".join(out)
def _string(raw):
"""A value's text, for the string kinds; None for anything else."""
if raw.startswith('"') and raw.endswith('"'):
return _unescape(raw[1:-1])
if raw.startswith('str(2):"') and raw.endswith('"'):
return _unescape(raw[8:-1])
if raw.startswith("hex(2):"):
try:
data = bytes(int(b, 16) for b in raw[7:].replace(" ", "").split(",") if b)
return data.decode("utf-16-le").rstrip("\0")
except ValueError:
return None
return None
def read_classes(path):
"""Software\\Classes of one hive: {lowercase subkey: {lowercase name: text}}."""
classes = {}
prefix = "software\\classes\\"
current = None
pending = None
try:
f = open(path, encoding="utf-8", errors="replace")
except OSError:
return classes
with f:
for line in f:
line = line.rstrip("\n")
# hex values run on over lines ending in a backslash
if pending is not None:
pending[1] += line.strip().rstrip("\\")
if not line.endswith("\\"):
name, raw = pending
pending = None
text = _string(raw)
if current is not None and text is not None:
classes.setdefault(current, {})[name] = text
continue
if line.startswith("["):
end = line.rfind("]")
key = _unescape(line[1:end]).lower() if end > 0 else ""
current = key[len(prefix):] if key.startswith(prefix) else None
# A key exists even with no values, and so do its parents.
if current:
parts = current.split("\\")
for n in range(1, len(parts) + 1):
classes.setdefault("\\".join(parts[:n]), {})
continue
if current is None or not line or line[0] not in '@"':
continue
if line.startswith("@="):
name, raw = "", line[2:]
else:
m = re.match(r'"((?:[^"\\]|\\.)*)"=(.*)$', line)
if not m:
continue
name, raw = _unescape(m.group(1)).lower(), m.group(2)
if raw.startswith("hex(2):") and raw.endswith("\\"):
pending = [name, raw.rstrip("\\")]
continue
text = _string(raw)
if text is not None:
classes.setdefault(current, {})[name] = text
return classes
def expand(text, users):
"""The environment variables installers put into paths."""
user = f"C:\\users\\{users}"
table = {
"programfiles": "C:\\Program Files",
"programw6432": "C:\\Program Files",
"programfiles(x86)": "C:\\Program Files (x86)",
"commonprogramfiles": "C:\\Program Files\\Common Files",
"commonprogramw6432": "C:\\Program Files\\Common Files",
"commonprogramfiles(x86)": "C:\\Program Files (x86)\\Common Files",
"systemroot": "C:\\windows",
"windir": "C:\\windows",
"systemdrive": "C:",
"programdata": "C:\\ProgramData",
"allusersprofile": "C:\\ProgramData",
"public": "C:\\users\\Public",
"userprofile": user,
"appdata": user + "\\AppData\\Roaming",
"localappdata": user + "\\AppData\\Local",
}
return re.sub(
r"%([^%]+)%",
lambda m: table.get(m.group(1).lower(), m.group(0)),
text,
)
def split_command(cmd):
"""(executable, rest) of a command line."""
cmd = cmd.strip()
if cmd.startswith('"'):
end = cmd.find('"', 1)
if end < 0:
return cmd[1:], ""
return cmd[1:end], cmd[end + 1:].strip()
m = re.match(r"(.*?\.(?:exe|com|bat|cmd))(?:\s+(.*))?$", cmd, re.I)
if m:
return m.group(1), (m.group(2) or "").strip()
exe, _, rest = cmd.partition(" ")
return exe, rest.strip()
def to_unix(bottle_dir, winpath):
"""A Windows path inside the bottle, found case-insensitively like Windows would."""
winpath = winpath.strip().strip('"')
if winpath.startswith("/"):
return winpath if os.path.exists(winpath) else None
m = re.match(r"^([A-Za-z]):[\\/]*(.*)$", winpath)
if not m:
return None
path = os.path.realpath(os.path.join(bottle_dir, "dosdevices", m.group(1).lower() + ":"))
for part in re.split(r"[\\/]+", m.group(2)):
if not part:
continue
candidate = os.path.join(path, part)
if not os.path.exists(candidate):
try:
lower = part.lower()
match = next(e for e in os.listdir(path) if e.lower() == lower)
except (OSError, StopIteration):
return None
candidate = os.path.join(path, match)
path = candidate
return path
def registered_types(bottle_dir, users):
"""{ext: (exe, args, icon file, icon index, description)} from both hives."""
system = read_classes(os.path.join(bottle_dir, "system.reg"))
user = read_classes(os.path.join(bottle_dir, "user.reg"))
def values(key):
merged = dict(system.get(key, {}))
merged.update(user.get(key, {}))
return merged
def default(key):
return values(key).get("", "").strip()
def has(key):
return key in system or key in user
keys = set(system) | set(user)
result = {}
for key in keys:
if not key.startswith(".") or "\\" in key:
continue
ext = key[1:]
if not EXT_RE.match(ext) or ext in SKIP_EXTS:
continue
progid = default(key).lower()
if not progid or not has(progid):
continue
curver = default(progid + "\\curver").lower()
if curver and has(curver):
progid = curver
# The default verb, else open, else any.
verb = default(progid + "\\shell").split(",")[0].strip().lower()
if not verb or not has(f"{progid}\\shell\\{verb}\\command"):
verb = "open"
command = default(f"{progid}\\shell\\{verb}\\command")
if not command:
verbs = sorted(
k for k in keys
if k.startswith(progid + "\\shell\\") and k.endswith("\\command")
)
command = default(verbs[0]) if verbs else ""
if not command:
continue
exe, args = split_command(expand(command, users))
icon_file, icon_index = "", "0"
icon = expand(default(progid + "\\defaulticon"), users).strip()
if icon and "%1" not in icon:
icon = icon.strip('"')
m = re.match(r'^"?(.*?)"?\s*,\s*(-?\d+)$', icon)
if m:
icon, icon_index = m.group(1), m.group(2)
icon_file = to_unix(bottle_dir, icon) or ""
desc = ""
for candidate in (values(progid).get("friendlytypename", ""), default(progid)):
candidate = candidate.strip()
if candidate and not candidate.startswith("@"):
desc = candidate
break
result[ext] = (exe, args, icon_file, icon_index, desc)
return result
def cached_types(bottle_dir, users, cache_dir):
"""registered_types, kept across runs until either hive changes."""
stamp = []
for hive in ("system.reg", "user.reg"):
try:
st = os.stat(os.path.join(bottle_dir, hive))
stamp.append([st.st_size, st.st_mtime_ns])
except OSError:
stamp.append(None)
cache = None
if cache_dir:
name = hashlib.sha1(bottle_dir.encode()).hexdigest()[:16] + ".json"
cache = os.path.join(cache_dir, name)
try:
with open(cache, encoding="utf-8") as f:
data = json.load(f)
if data.get("version") == CACHE_VERSION and data.get("stamp") == stamp:
return {k: tuple(v) for k, v in data["types"].items()}
except (OSError, ValueError, KeyError, AttributeError):
pass
types = registered_types(bottle_dir, users)
if cache:
try:
os.makedirs(cache_dir, exist_ok=True)
tmp = cache + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump({"version": CACHE_VERSION, "stamp": stamp, "types": types}, f)
os.replace(tmp, cache)
except OSError:
pass
return types
def windows_user(bottle_dir):
try:
names = os.listdir(os.path.join(bottle_dir, "drive_c", "users"))
except OSError:
return "user"
names = [n for n in names if n.lower() not in ("public", "default", "default user", "all users")]
return sorted(names)[0] if names else "user"
# ---------------------------------------------------------------------------
# Bottles
# ---------------------------------------------------------------------------
def main():
if len(sys.argv) < 2:
return 2
cache_dir = sys.argv[2] if len(sys.argv) > 2 else ""
base = os.path.join(sys.argv[1], "bottles")
root = os.path.join(base, "bottles")
data = load(os.path.join(base, "data.yml"))
if isinstance(data, dict):
custom = data.get("custom_bottles_path")
if (
isinstance(custom, str)
and custom
and not ("/run/user/" in custom and "/doc/" in custom)
and os.path.isdir(custom)
and os.access(custom, os.W_OK)
):
root = custom
emit("R", root)
try:
entries = sorted(os.listdir(root))
except OSError:
return 0
for entry in entries:
bottle_dir = os.path.join(root, entry)
if not os.path.isdir(bottle_dir):
continue
config_path = os.path.join(bottle_dir, "bottle.yml")
placeholder = os.path.join(bottle_dir, "placeholder.yml")
if os.path.exists(placeholder):
target = load(placeholder)
if not isinstance(target, dict) or not target.get("Path"):
continue
bottle_dir = str(target["Path"])
config_path = os.path.join(bottle_dir, "bottle.yml")
config = load(config_path)
if not isinstance(config, dict) or config.get("Environment") == "Steam":
continue
name = config.get("Name") or entry
emit("B", name, bottle_dir, config_path)
programs = config.get("External_Programs") or {}
if not isinstance(programs, dict):
continue
listed = []
for key, program in programs.items():
if not isinstance(program, dict) or program.get("removed"):
continue
path = program.get("path")
if not path:
continue
listed.append((
str(program.get("name") or program.get("executable") or key),
str(program.get("id") or key),
str(path),
program.get("icon") or "",
))
listed.sort(key=lambda p: p[0].lower())
for prog_name, prog_id, path, icon in listed:
emit("P", name, prog_id, prog_name, path, icon)
if not listed:
continue
# Which program each registered type belongs to: by its path, as
# Windows spells it or as it is on disk, and by file name when that
# names exactly one program.
by_path, by_real, by_base = {}, {}, {}
for _, prog_id, path, _ in listed:
by_path[path.lower().replace("/", "\\")] = prog_id
real = to_unix(bottle_dir, path)
if real:
by_real[os.path.realpath(real)] = prog_id
base_name = re.split(r"[\\/]", path)[-1].lower()
by_base.setdefault(base_name, []).append(prog_id)
types = cached_types(bottle_dir, windows_user(bottle_dir), cache_dir)
for ext in sorted(types):
exe, args, icon_file, icon_index, desc = types[ext]
prog_id = by_path.get(exe.lower().replace("/", "\\"))
if not prog_id:
real = to_unix(bottle_dir, exe)
prog_id = by_real.get(os.path.realpath(real)) if real else None
if not prog_id:
hits = by_base.get(re.split(r"[\\/]", exe)[-1].lower(), [])
prog_id = hits[0] if len(hits) == 1 else None
if prog_id:
emit("A", name, prog_id, ext, args, icon_file, icon_index, desc)
return 0
if __name__ == "__main__":
sys.exit(main())
+179
View File
@@ -0,0 +1,179 @@
# shellcheck shell=bash
#
# Finding Bottles, its bottles and the programs in them.
#
# Native (bottles-cli, ~/.local/share/bottles) and Flatpak (~/.var/app/...),
# possibly both. Everything is tagged with its install: it decides how a
# program is started.
# The installations present, "native" and/or "flatpak". Filled once per run.
BO_INSTALLS=()
BO_INSTALLS_DONE=0
bo_installs_detect() {
(( BO_INSTALLS_DONE )) && return 0
BO_INSTALLS_DONE=1
BO_INSTALLS=()
# For testing against a fixed set; not documented anywhere else.
if [[ -n ${BO_FORCE_INSTALLS+set} ]]; then
read -ra BO_INSTALLS <<< "$BO_FORCE_INSTALLS"
return 0
fi
bo_have bottles-cli && BO_INSTALLS+=(native)
if bo_have flatpak && flatpak info "$BO_FLATPAK_ID" > /dev/null 2>&1; then
BO_INSTALLS+=(flatpak)
fi
return 0
}
bo_install_present() {
local i
bo_installs_detect
for i in "${BO_INSTALLS[@]}"; do
[[ $i == "$1" ]] && return 0
done
return 1
}
# bo_install_data <install>
# The directory holding bottles/ for that installation.
bo_install_data() {
case "$1" in
native) printf '%s\n' "${BO_NATIVE_DATA:-$BO_XDG_DATA}" ;;
flatpak) printf '%s\n' "${BO_FLATPAK_DATA:-$HOME/.var/app/$BO_FLATPAK_ID/data}" ;;
esac
}
# ---------------------------------------------------------------------------
# The scan
# ---------------------------------------------------------------------------
BO_ROOTS=() # every bottles directory, for the watcher
BO_CONFS=() # every bottle.yml, for the watcher
BO_B_INSTALL=() # one entry per bottle
BO_B_NAME=()
BO_P_INSTALL=() # one entry per program
BO_P_BOTTLE=()
BO_P_BDIR=()
BO_P_ID=()
BO_P_NAME=()
BO_P_PATH=()
BO_P_ICON=()
BO_R_INSTALL=() # one entry per type a program registered in its bottle
BO_R_PID=()
BO_R_EXT=()
BO_R_ARGS=()
BO_R_ICON=()
BO_R_IDX=()
BO_R_DESC=()
declare -A BO_R_AT=() # ext \x1f install \x1f program id -> index into BO_R_*
declare -A BO_P_AT=() # install \x1f program id -> index into BO_P_*
# An install that could not be read, rather than one that is empty.
BO_SCAN_ERROR=''
BO_SCANNED=0
# _bo_scan_run <install> <data home>
# The host's Python first; the Flatpak's, which surely has PyYAML, as fallback.
_bo_scan_run() {
local install="$1" data="$2" rc=127
if bo_have python3; then
python3 "$BO_LIBDIR/scan.py" "$data" "$BO_CACHEDIR/registry"
rc=$?
fi
if (( rc == 3 || rc == 127 )) && [[ $install == flatpak ]]; then
flatpak run --command=python3 "$BO_FLATPAK_ID" - "$data" \
< "$BO_LIBDIR/scan.py" 2>/dev/null
rc=$?
fi
return "$rc"
}
bo_scan() {
local install data kind a b c d e f g out bdir
BO_ROOTS=(); BO_CONFS=()
BO_B_INSTALL=(); BO_B_NAME=()
BO_P_INSTALL=(); BO_P_BOTTLE=(); BO_P_BDIR=(); BO_P_ID=()
BO_P_NAME=(); BO_P_PATH=(); BO_P_ICON=()
BO_R_INSTALL=(); BO_R_PID=(); BO_R_EXT=(); BO_R_ARGS=()
BO_R_ICON=(); BO_R_IDX=(); BO_R_DESC=(); BO_R_AT=(); BO_P_AT=()
BO_SCAN_ERROR=''
bo_installs_detect
for install in "${BO_INSTALLS[@]}"; do
data="$(bo_install_data "$install")"
if ! out="$(_bo_scan_run "$install" "$data")"; then
BO_SCAN_ERROR="$install"
continue
fi
declare -A dirs=()
while IFS=$'\x1f' read -r kind a b c d e f g; do
case "$kind" in
R)
BO_ROOTS+=("$a")
;;
B)
BO_B_INSTALL+=("$install"); BO_B_NAME+=("$a")
BO_CONFS+=("$c")
dirs[$a]="$b"
;;
P)
bdir="${dirs[$a]:-}"
[[ -n $bdir && -n $b ]] || continue
BO_P_AT[$install$'\x1f'$b]=${#BO_P_ID[@]}
BO_P_INSTALL+=("$install"); BO_P_BOTTLE+=("$a")
BO_P_BDIR+=("$bdir"); BO_P_ID+=("$b")
BO_P_NAME+=("$c"); BO_P_PATH+=("$d"); BO_P_ICON+=("$e")
;;
A)
BO_R_AT[$c$'\x1f'$install$'\x1f'$b]=${#BO_R_EXT[@]}
BO_R_INSTALL+=("$install"); BO_R_PID+=("$b"); BO_R_EXT+=("$c")
BO_R_ARGS+=("$d"); BO_R_ICON+=("$e"); BO_R_IDX+=("$f")
BO_R_DESC+=("$g")
;;
esac
done <<< "$out"
unset dirs
done
BO_SCANNED=1
return 0
}
bo_scan_once() {
(( BO_SCANNED )) || bo_scan
}
# bo_program_find <install> <program id>
# Index into the BO_P_* arrays, in BO_FOUND; returns 1 when there is none.
BO_FOUND=-1
bo_program_find() {
BO_FOUND="${BO_P_AT[$1$'\x1f'$2]:--1}"
(( BO_FOUND >= 0 ))
}
# bo_program_label <index>
# "FL Studio · FL Studio", plus "(Flatpak)" when both installs exist.
BO_LABEL=''
bo_program_label() {
local i="$1"
BO_LABEL="${BO_P_NAME[i]} · ${BO_P_BOTTLE[i]}"
if [[ ${BO_P_INSTALL[i]} == flatpak ]] && bo_install_present native; then
BO_LABEL+=" (Flatpak)"
fi
}