Add iOS 27 hook source, browser integration and build scripts

This commit is contained in:
admin committed 2026-11-12 02:27:33 +08:00
1 parent f5a5603375
commit c1d0293404
43 files changed
+17046

No files matched your search

+3
View File
@@ -1 +1,4 @@
.DS_Store .DS_Store
source/hook/build/
source/hook/AppsManagerHook.dylib
source/out/
+3
View File
@@ -12,6 +12,7 @@
- IPA:[AppsManager_iOS27.ipa](AppsManager_iOS27.ipa) - IPA:[AppsManager_iOS27.ipa](AppsManager_iOS27.ipa)
- 校验文件 / Checksums:[SHA256SUMS.txt](SHA256SUMS.txt) - 校验文件 / Checksums:[SHA256SUMS.txt](SHA256SUMS.txt)
- 源码 / Source:[source/](source/)(iOS 27 Hook、内置文件浏览器集成与构建脚本 / iOS 27 hook, file-browser integration and build scripts)
```text ```text
eebc7b69884aaf302dbd9a0750f9b47f96800867fe5a316b83be9957facbd12c AppsManager_iOS27.ipa eebc7b69884aaf302dbd9a0750f9b47f96800867fe5a316b83be9957facbd12c AppsManager_iOS27.ipa
@@ -76,6 +77,8 @@ eebc7b69884aaf302dbd9a0750f9b47f96800867fe5a316b83be9957facbd12c AppsManager_iO
When a third-party app bundle cannot be read, its version or icon may show `null` / a placeholder; this does not affect backup, restore, or file browsing. When a third-party app bundle cannot be read, its version or icon may show `null` / a placeholder; this does not affect backup, restore, or file browsing.
- 本仓库仅用于自用测试与数据迁移,请遵守目标 App 的许可协议和当地法律法规。 - 本仓库仅用于自用测试与数据迁移,请遵守目标 App 的许可协议和当地法律法规。
This repository is intended for personal testing and data migration only. Respect the target apps' licenses and applicable laws. This repository is intended for personal testing and data migration only. Respect the target apps' licenses and applicable laws.
- 源码中的 WQ Files 相关部分遵循 GPL-3.0-or-later,详见 [source/LICENSE](source/LICENSE)。
WQ Files-derived source is licensed under GPL-3.0-or-later; see [source/LICENSE](source/LICENSE).
## 校验 / Checksum ## 校验 / Checksum
+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>.
+112
View File
@@ -0,0 +1,112 @@
# AppsManager iOS 27 Source
**中文:** 这是 **Apps Manager** 的 iOS 27 非官方分支的源码部分。应用名仍为 **AppsManager**,仓库名为 **AppsManager27**。
**English:** This is the source portion of the unofficial iOS 27 fork of **Apps Manager**. The app name remains **AppsManager**; the repository is named **AppsManager27**.
> 本目录只包含 iOS 27 兼容 Hook、内置文件浏览器集成和构建脚本。原版 Apps Manager 的应用本体源码是闭源的,不在此仓库中;构建时需要你自己提供原始 IPA 中解包出的 `ADManager.app`,以及签名证书和描述文件。
> This directory contains only the iOS 27 compatibility hook, the built-in file browser integration, and build scripts. The original Apps Manager app source is proprietary and is not included. You must provide the extracted `ADManager.app` from the original IPA, plus your own signing certificate and provisioning profile.
## 目录结构 / Layout
```text
source/
├── hook/src/
│ ├── WBHook.m # AppsManager iOS 27 hook and feature fixes
│ ├── main.m # WQ Files browser UI, modified for AppsManager
│ └── WQ*.m / WQ*.h # WQ Files file browser, editors, media, ZIP, etc.
├── build_dylib.sh # Compile AppsManagerHook.dylib
├── build_app.sh # Inject the dylib, sign the app bundle
├── LICENSE # GPL-3.0 (WQ Files-derived source)
└── README.md
```
## 主要修改 / Main changes
- Hook `LSApplicationWorkspace` and `LSApplicationProxy` to expose app containers.
- Parse the LaunchServices CSStore database for bundle/container/name mapping.
- Grant per-container access through `worse_query`.
- Real app data sizes, pull-to-refresh full size scan.
- Backup / restore with fallback for the keychain nil-array failure.
- Wipe user data using `worse_query`-backed recursive deletion.
- Recalculate the affected app size after backup, restore and wipe.
- Detail page version/path/date/app-group support.
- Replace the `filza://` prompt with the built-in WQ Files browser.
- Present media through the native `AVPlayerViewController`.
## 构建要求 / Build requirements
- macOS with Xcode command line tools and an iPhoneOS SDK.
- `insert_dylib` / `idylib` available in `PATH`, or set `INSERT_DYLIB`.
- An original `ADManager.app` extracted from the IPA you are allowed to modify.
- A code signing identity and a `.mobileprovision` for your target device(s).
- An entitlements plist that includes the App Group used by `worse_query`.
## 构建 / Build
### 1. Compile the dylib
```sh
cd source
./build_dylib.sh
```
Output: `source/hook/AppsManagerHook.dylib`
### 2. Build and sign the app
```sh
export APP_SOURCE="/path/to/Payload/ADManager.app"
export PROVISION="/path/to/your.mobileprovision"
export SIGN_IDENTITY="Apple Distribution: Your Name (TEAMID)"
export ENTITLEMENTS="/path/to/entitlements.plist"
export KEYCHAIN="/path/to/signing.keychain" # optional
export INSERT_DYLIB="/path/to/idylib" # optional if already in PATH
./build_app.sh
```
Output: `source/out/AppsManager.app`
The script:
1. Copies the original app bundle.
2. Injects `AppsManagerHook.dylib` into `ADManager`.
3. Sets `CFBundleIdentifier` to `com.jbjf.appsmanager` by default.
4. Embeds your provisioning profile.
5. Signs the dylib and the app with your identity.
You can override `BUNDLE_ID` and `OUT_DIR` if needed.
## 签名与权限 / Signing and entitlements
The published IPA is signed for a personal certificate and is not installable on arbitrary devices. Re-sign with your own certificate.
For `worse_query` and App Group access, the app needs the relevant entitlements from your provisioning profile, including an App Group. The original build used an App Group such as:
```text
group.<your-team-id>.<your-app-id>.01
```
The exact value must match your own profile. Do not reuse someone else's certificate, profile, or App Group.
## 第三方代码 / Third-party code
This source includes and links code from:
- **WQ Files** by kolbicz — <https://github.com/kolbicz/WQFiles> — GPL-3.0-or-later.
- **worse_query** by Taj C (forcequit) — <https://github.com/forcequitOS/worse_query> — GPL-3.0-or-later.
The WQ Files-derived files retain their copyright headers. See `LICENSE` for the full GPL-3.0 text. The AppsManager hook source in this directory is distributed under the same GPL-3.0-or-later terms to the extent required by the combined work.
## 测试 / Tested
- iPhone 18 Pro Max (iPhone19,7)
- iOS 27.0.1 (24A446)
## 说明 / Notes
- Activation remains trial; no activation bypass is included.
- Keychain data cannot be wiped due to sandbox/permission restrictions.
- Some third-party bundles cannot be read; version/icon may be `null` or a placeholder.
- Use this source only for personal testing and data migration. Respect the original apps' licenses and applicable laws.
+69
View File
@@ -0,0 +1,69 @@
#!/bin/sh
# Build and sign AppsManager.app after build_dylib.sh has produced the dylib.
#
# Required environment:
# APP_SOURCE Path to the original extracted ADManager.app
# PROVISION Path to a .mobileprovision for the target device(s)
# SIGN_IDENTITY Code signing identity, e.g. "Apple Distribution: Name (TEAMID)"
#
# Optional:
# ENTITLEMENTS Path to an entitlements plist (needed for App Group access)
# KEYCHAIN Keychain path for codesign
# INSERT_DYLIB Path to the insert_dylib/idylib tool (default: idylib)
# OUT_DIR Output directory (default: <source>/out)
# BUNDLE_ID CFBundleIdentifier (default: com.jbjf.appsmanager)
set -eu
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
APP_SOURCE=${APP_SOURCE:?Set APP_SOURCE to the extracted original ADManager.app}
PROVISION=${PROVISION:?Set PROVISION to your .mobileprovision}
SIGN_IDENTITY=${SIGN_IDENTITY:?Set SIGN_IDENTITY to your code signing identity}
ENTITLEMENTS=${ENTITLEMENTS:-}
KEYCHAIN=${KEYCHAIN:-}
INSERT_DYLIB=${INSERT_DYLIB:-idylib}
OUT_DIR=${OUT_DIR:-"$ROOT/out"}
BUNDLE_ID=${BUNDLE_ID:-com.jbjf.appsmanager}
DYLIB="$ROOT/hook/AppsManagerHook.dylib"
APP="$OUT_DIR/AppsManager.app"
if [ ! -f "$DYLIB" ]; then
echo "Missing $DYLIB; run build_dylib.sh first." >&2
exit 1
fi
if [ ! -d "$APP_SOURCE" ]; then
echo "APP_SOURCE is not a directory: $APP_SOURCE" >&2
exit 1
fi
if ! command -v "$INSERT_DYLIB" >/dev/null 2>&1; then
echo "insert_dylib tool not found: $INSERT_DYLIB" >&2
exit 1
fi
rm -rf "$APP"
mkdir -p "$OUT_DIR"
cp -R "$APP_SOURCE" "$APP"
cp "$DYLIB" "$APP/AppsManagerHook.dylib"
cp "$PROVISION" "$APP/embedded.mobileprovision"
plutil -replace CFBundleIdentifier -string "$BUNDLE_ID" "$APP/Info.plist"
plutil -replace CFBundleDisplayName -string "Apps Manager" "$APP/Info.plist"
plutil -replace CFBundleName -string "AppsManager" "$APP/Info.plist"
cp "$APP/ADManager" "$APP/ADManager.orig"
"$INSERT_DYLIB" --inplace --overwrite --strip-codesig --all-yes \
"@executable_path/AppsManagerHook.dylib" "$APP/ADManager"
rm -f "$APP/ADManager.orig"
set -- -f -s "$SIGN_IDENTITY"
if [ -n "$KEYCHAIN" ]; then
set -- "$@" --keychain "$KEYCHAIN"
fi
if [ -n "$ENTITLEMENTS" ]; then
set -- "$@" --entitlements "$ENTITLEMENTS" --generate-entitlement-der
fi
codesign "$@" "$APP/AppsManagerHook.dylib"
codesign "$@" "$APP"
echo "built $APP"
otool -L "$APP/ADManager" | head -8
+33
View File
@@ -0,0 +1,33 @@
#!/bin/sh
# Build the AppsManagerHook.dylib.
# Requires macOS with Xcode command line tools and an iPhoneOS SDK.
set -eu
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
SRC="$ROOT/hook/src"
OUT="$ROOT/hook/build"
rm -rf "$OUT"
mkdir -p "$OUT"
cd "$SRC"
CFLAGS="-arch arm64 -miphoneos-version-min=15.0 -fobjc-arc -I. -O1"
FILES="WQCore WQLocalization WQAccess WQLaunchServices WQUI WQSharing WQApps WQCache WQPlistEditor WQHexViewer WQSQLiteViewer WQArchive WQSyntax WQMediaStream WBHook"
for f in $FILES; do
echo "compile $f"
xcrun -sdk iphoneos clang $CFLAGS -c "$f.m" -o "$OUT/$f.o"
done
echo "compile main"
xcrun -sdk iphoneos clang $CFLAGS -Dmain=wqfiles_unused_main -c main.m -o "$OUT/main.o"
echo "link"
xcrun -sdk iphoneos clang -arch arm64 -miphoneos-version-min=15.0 -dynamiclib \
-o "$ROOT/hook/AppsManagerHook.dylib" "$OUT"/*.o \
-framework Foundation -framework UIKit -framework Security \
-framework UniformTypeIdentifiers -framework AVKit -framework AVFoundation \
-framework CoreMedia -framework CoreGraphics -framework QuartzCore \
-lsqlite3 -lz
echo "built $ROOT/hook/AppsManagerHook.dylib"
File diff suppressed because it is too large. Load diff
+193
View File
@@ -0,0 +1,193 @@
//
// WQAccess.h
// WQ Files
//
// File access outside the sandbox through the worse_query technique by
// Taj C (forcequit), https://github.com/forcequitOS/worse_query,
// GPL-3.0-or-later. This reimplementation is GPL-3.0-or-later as well.
//
#import <Foundation/Foundation.h>
#import "WQLaunchServices.h"
#import <sys/stat.h>
NS_ASSUME_NONNULL_BEGIN
// Log shared with the UI.
void wq_log(NSString *format, ...) NS_FORMAT_FUNCTION(1, 2);
NSString *wq_log_text(void);
// Picks the app group the signer actually gave us. Nil when there is none.
NSString *_Nullable wq_app_group(void);
// Asks for a sandbox extension covering path (once per path). Returns YES
// when one is held for it.
BOOL wq_grant(NSString *path);
// Directory listing; asks for access when the plain call is refused.
NSArray<NSString *> *_Nullable wq_list(NSString *dir, NSString *_Nullable *_Nullable errorText);
// Reads a whole file (up to maxBytes; 0 = no limit).
NSData *_Nullable wq_read(NSString *path, NSUInteger maxBytes, NSString *_Nullable *_Nullable errorText);
// A file's identity, size and change timestamps, for optimistic concurrency.
NSDictionary<NSString *, NSNumber *> *_Nullable wq_file_version(NSString *path);
// Overwrites a file in place (same inode, owner and xattrs). The previous
// contents are backed up first, and a failed write attempts to roll back.
// Mutation/backup/rollback use one verified regular-file descriptor and an
// advisory lock where supported. The _if_unchanged forms reject a stale expected
// version before mutation; nil keeps the legacy unconditional behavior. This is
// not an atomic transaction against uncooperative external writers or crashes.
// Backup-storage files cannot be overwritten/deleted through these generic APIs.
BOOL wq_write(NSString *path, NSData *data, NSString *_Nullable *_Nullable errorText);
BOOL wq_write_if_unchanged(NSString *path, NSData *data, NSDictionary<NSString *, NSNumber *> *_Nullable expected,
NSString *_Nullable *_Nullable errorText);
// Verifies saved bytes against the intended contents while the descriptor stays
// locked, returning that descriptor's version (never a later pathname sample).
// The returned snapshot still detects later external changes on the next save.
BOOL wq_write_if_unchanged_get_version(NSString *path, NSData *data,
NSDictionary<NSString *, NSNumber *> *_Nullable expected,
NSDictionary<NSString *, NSNumber *> *_Nullable *_Nullable savedVersion,
NSString *_Nullable *_Nullable errorText);
// Same as wq_write / wq_create_file, but streamed from a local file (for large
// files; nothing is held in memory). A failed create leaves no partial file.
BOOL wq_write_from_file(NSString *path, NSString *source, NSString *_Nullable *_Nullable errorText);
// As wq_write_from_file; *backupUnit gets the backup folder (Backups/<stamp>)
// holding the previous contents, so a caller may remove it after success.
BOOL wq_write_from_file_with_backup(NSString *path, NSString *source, NSString *_Nullable *_Nullable backupUnit,
NSString *_Nullable *_Nullable errorText);
BOOL wq_create_file_from(NSString *path, NSString *source, NSString *_Nullable *_Nullable errorText);
// Streams a regular file into a staged local path, then replaces the destination.
// Same-file aliases are refused; failed streaming leaves the destination intact.
BOOL wq_copy_to_local(NSString *path, NSString *dest, NSString *_Nullable *_Nullable errorText);
// When Unix permissions (not the sandbox) keep this app out of `path`: an
// explanation for the user; nil if the owner and mode allow it.
NSString *_Nullable wq_permission_hint(NSString *path, BOOL forWriting);
// Reads `length` bytes at `offset` (fewer at the end of the file).
NSData *_Nullable wq_read_range(NSString *path, unsigned long long offset, NSUInteger length,
NSString *_Nullable *_Nullable errorText);
// Overwrites bytes in place: offset -> replacement bytes. The file keeps its
// size; it is backed up first, and flush/close errors are reported.
BOOL wq_patch(NSString *path, NSDictionary<NSNumber *, NSData *> *runs, NSString *_Nullable *_Nullable errorText);
BOOL wq_patch_if_unchanged(NSString *path, NSDictionary<NSNumber *, NSData *> *runs,
NSDictionary<NSString *, NSNumber *> *_Nullable expected, NSString *_Nullable *_Nullable errorText);
BOOL wq_patch_if_unchanged_get_version(NSString *path, NSDictionary<NSNumber *, NSData *> *runs,
NSDictionary<NSString *, NSNumber *> *_Nullable expected,
NSDictionary<NSString *, NSNumber *> *_Nullable *_Nullable savedVersion,
NSString *_Nullable *_Nullable errorText);
// Creates a new file or folder inside dir.
BOOL wq_create_file(NSString *path, NSData *data, NSString *_Nullable *_Nullable errorText);
BOOL wq_create_dir(NSString *path, NSString *_Nullable *_Nullable errorText);
// Deletes a file (backed up first) or an empty folder.
BOOL wq_delete(NSString *path, NSString *_Nullable *_Nullable errorText);
// YES for a folder that represents one backup unit: a stamp directly under
// the file backup root, or a bundle/date folder directly under the app backup
// root. Used by the backup browser's multi-selection.
BOOL wq_backup_unit_deletable(NSString *path);
// Explicit, user-confirmed removal of one backup unit. Unlike wq_delete, this
// is allowed inside a backup store and removes the whole subtree. No safety
// copy is made.
BOOL wq_delete_backup(NSString *path, NSString *_Nullable *_Nullable errorText);
BOOL wq_rename(NSString *path, NSString *newName, NSString *_Nullable *_Nullable errorText);
// Moves a file or folder into destDir (same volume), keeping its name. Never
// replaces an existing item; refuses moves into itself and into backups.
BOOL wq_move(NSString *path, NSString *destDir, NSString *_Nullable *_Nullable errorText);
// Read-only descriptor for a regular file (asks for access when refused), or -1.
int wq_open_for_reading(NSString *path);
// lstat with access retry. Returns NO if the item does not exist.
BOOL wq_stat(NSString *path, struct stat *st);
// Thread-safe cancellation for expensive filesystem scans.
@interface WQScanCancellation : NSObject
@property (atomic, readonly, getter=isCancelled) BOOL cancelled;
- (void)cancel;
@end
// Discovers direct children of dir by brute-forcing inode numbers with
// fsgetpath (not sandbox-gated), the way KAMUI/bad_query_list do. Needs no
// token on dir. maxInode bounds the scan (0 = up to the newest id on the
// volume); progress is called off the main thread, from several threads.
// Returns absolute child paths, sorted, or nil on failure/cancellation, including
// when the dynamic bound cannot be discovered. No implicit fixed-range fallback.
// With maxInode 0, the App Data, Extensions and Internal Daemons parents are
// scanned together in one pass, reused for 10 minutes (or until
// wq_enumerate_invalidate) and saved: later scans only check the ids created
// since the saved one (a full scan runs weekly). The scan keeps running when
// its caller cancels; concurrent callers wait for that one scan.
// Forget the shared container scan (App Data, Extensions, Internal Daemons),
// so the next wq_enumerate of those scans the ids created since. Call on a
// user's refresh.
void wq_enumerate_invalidate(void);
// NO on iOS 26: there worse_query can't reach System Data, the system groups
// or the App Groups parent folder (only the individual containers).
BOOL wq_system_areas_reachable(void);
NSArray<NSString *> *_Nullable wq_enumerate(NSString *dir, int64_t maxInode,
WQScanCancellation *_Nullable cancellation,
void (^_Nullable progress)(int64_t done, int64_t total, NSUInteger found));
typedef NS_ENUM(NSInteger, WQMeasureResult) {
WQMeasureResultMissing,
WQMeasureResultKnown,
WQMeasureResultLimited,
WQMeasureResultCancelled,
};
// Total byte size of regular files below dir. A missing directory is Missing
// (size 0); an unreadable subtree makes the result Limited instead of silently
// undercounting. Grants access when the root cannot be listed.
WQMeasureResult wq_measure_tree(NSString *dir, WQScanCancellation *_Nullable cancellation,
unsigned long long *_Nullable bytes);
// Deletes every item *inside* dir (dir itself stays). No backup — for caches.
// Adds freed bytes to *freed. A missing dir counts as done; NO if dir cannot
// be read or listed, ancestry contains links (except canonical /var), the target
// changes, or some items could not be removed. Child links are not followed.
BOOL wq_purge_contents(NSString *dir, unsigned long long *freed);
// Cancellable variant for long cache cleans; returns NO when cancelled.
BOOL wq_purge_contents_cancellable(NSString *dir, unsigned long long *freed,
WQScanCancellation *_Nullable cancellation);
// Copies every item inside srcDir into dstDir (replacing same-named items).
// Each child is staged/flushed before installation; old children survive failed
// copies. This is not an all-children transaction: callers still own tree rollback.
// Either side may be another app's container. Returns NO if anything failed.
BOOL wq_copy_contents(NSString *srcDir, NSString *dstDir, NSString *_Nullable *_Nullable errorText);
// MCM container identifier (bundle or group id) of a container folder.
NSString *_Nullable wq_container_label(NSString *dir);
// Extra source for wq_container_label, tried after the identifier attribute and
// the container-manager table: path (/private/var/...) -> identifier or nil.
// iOS 26: where a full scan takes far too long, the App Data and Extensions
// lists come from this provider (the LaunchServices database). storeHint is a
// database path the scan found, or nil for the remembered one.
// Full scans (every file id; minutes on a well-used device) only start after
// `confirm` returns YES; it's called on a background thread with what is being
// listed, the number of ids and a rough duration. `progress` reports a running
// full scan and its end (finished = YES). Without these hooks no full scan runs.
void wq_set_full_scan_ui(BOOL (*_Nullable confirm)(NSString *what, int64_t ids, double seconds),
void (*_Nullable progress)(int64_t done, int64_t total, BOOL finished));
// Stops the running full scan (the scan window's Stop button).
void wq_full_scan_stop(void);
// Whether the last listing failed because a full scan wasn't confirmed.
BOOL wq_full_scan_was_declined(void);
void wq_set_container_list_provider(NSArray<NSString *> *_Nullable (*_Nullable provider)(NSString *_Nullable storeHint));
void wq_set_container_label_resolver(NSString *_Nullable (*_Nullable resolver)(NSString *path));
// Backups of overwritten or deleted files, mirrored by original path.
NSString *wq_backup_root(void);
// App backups live in Documents/AppBackups, a sibling of file backups.
NSString *wq_app_backup_root(void);
// YES inside either backup store. Generic create/write/delete/rename must be
// refused there so a backup cannot be recursively backed up or silently
// turned into a different restore source.
BOOL wq_is_backup_storage_path(NSString *path);
NSString *_Nullable wq_backup_original_path(NSString *backupPath);
NS_ASSUME_NONNULL_END
File diff suppressed because it is too large. Load diff
+323
View File
@@ -0,0 +1,323 @@
// Portable implementation helpers for WQAccess; no privileged access is performed here.
#ifndef WQ_ACCESS_SAFETY_H
#define WQ_ACCESS_SAFETY_H
#import <Foundation/Foundation.h>
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <sys/file.h>
#include <sys/stat.h>
#include <unistd.h>
static inline BOOL WQSameObject(const struct stat *a, const struct stat *b)
{
return a->st_dev == b->st_dev && a->st_ino == b->st_ino;
}
// Only Apple's documented /var alias is allowed. Do not standardize away '..'
// before walking: every actual ancestor must be opened without following links.
static inline NSString *WQCanonicalVarPath(NSString *path)
{
if ([path isEqualToString:@"/var"] || [path hasPrefix:@"/var/"])
return [@"/private" stringByAppendingString:path];
return path;
}
static inline int WQOpenDirectoryNoLinksFlags(NSString *path, int flags)
{
path = WQCanonicalVarPath(path);
if (![path hasPrefix:@"/"] || [path rangeOfString:@"\0"].location != NSNotFound) { errno = EINVAL; return -1; }
// Ancestors need traversal, not enumeration. A file/folder-specific sandbox
// extension need not grant read-directory rights on all container parents.
int fd = open("/", O_SEARCH | O_CLOEXEC);
if (fd < 0) return -1;
for (NSString *part in [path componentsSeparatedByString:@"/"]) {
if (!part.length) continue;
if ([part isEqualToString:@"."] || [part isEqualToString:@".."]) { close(fd); errno = EINVAL; return -1; }
int next = openat(fd, part.fileSystemRepresentation, O_SEARCH | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC);
int err = errno;
close(fd);
if (next < 0) { errno = err; return -1; }
fd = next;
}
if (flags == O_SEARCH) return fd;
int result = openat(fd, ".", flags | O_DIRECTORY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC);
int err = errno;
close(fd);
if (result < 0) errno = err;
return result;
}
static inline int WQOpenDirectoryNoLinks(NSString *path)
{
return WQOpenDirectoryNoLinksFlags(path, O_RDONLY);
}
static inline BOOL WQSyncDirectory(int fd)
{
int readable = openat(fd, ".", O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
if (readable < 0) return NO;
BOOL ok = fsync(readable) == 0;
int err = errno;
if (close(readable) != 0 && ok) { ok = NO; err = errno; }
if (!ok) errno = err;
return ok;
}
static inline int WQOpenRegularAt(int parent, const char *name, int flags, mode_t mode)
{
// Never allow truncation before the file type has been verified.
if (flags & O_TRUNC) { errno = EINVAL; return -1; }
struct stat entry;
if (fstatat(parent, name, &entry, AT_SYMLINK_NOFOLLOW) == 0) {
if (!S_ISREG(entry.st_mode)) { errno = EINVAL; return -1; }
} else if (errno != ENOENT || !(flags & O_CREAT)) return -1;
int fd = openat(parent, name, flags | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC, mode);
if (fd < 0) return -1;
struct stat st = {0};
if (fstat(fd, &st) != 0 || !S_ISREG(st.st_mode)) {
int err = errno;
if (!S_ISREG(st.st_mode)) err = EINVAL;
close(fd); errno = err; return -1;
}
return fd;
}
static inline BOOL WQWriteBytes(int fd, NSData *data)
{
const uint8_t *bytes = data.bytes;
size_t done = 0;
while (done < data.length) {
ssize_t n = write(fd, bytes + done, data.length - done);
if (n < 0 && errno == EINTR) continue;
if (n <= 0) { if (n == 0) errno = EIO; return NO; }
done += (size_t)n;
}
return fsync(fd) == 0;
}
// A durable intent file precedes the rename. The preserved directory itself is
// never removed. Recovery accepts only our exact symlink or the original inode.
//
// Durability: this runs for every access grant (a Cache Cleaner scan makes
// over a thousand), so only the marker's contents are flushed. That flush is
// what recovery relies on: a marker that can't be read while the preserved
// directory exists must fail closed, so its contents have to reach the disk
// before Caches is renamed. The directory operations themselves need no flush:
// after an app crash or kill they are already in place, and APFS commits
// changes to one directory in order, so a power loss leaves one of the states
// WQCacheSwapRecover handles.
static const char *const WQCacheMarker = ".wq-caches-swap.plist";
static const char *const WQCacheMarkerTemp = ".wq-caches-swap.plist.tmp";
static const char *const WQCacheSaved = ".wq-caches-preserved";
// An invalid marker means Begin never completed the marker write, so no swap
// should have started. It is safe to discard only when Caches is still a real
// directory and no preserved directory exists. Otherwise fail closed.
static inline BOOL WQCacheInvalidMarkerCanBeDiscarded(int library)
{
struct stat saved, caches;
if (fstatat(library, WQCacheSaved, &saved, AT_SYMLINK_NOFOLLOW) == 0) { errno = EEXIST; return NO; }
if (errno != ENOENT) return NO;
if (fstatat(library, "Caches", &caches, AT_SYMLINK_NOFOLLOW) != 0) return NO;
if (!S_ISDIR(caches.st_mode)) { errno = EINVAL; return NO; }
if (unlinkat(library, WQCacheMarker, 0) != 0) return NO;
return fsync(library) == 0;
}
static inline BOOL WQCacheSwapRecover(int library)
{
int marker = WQOpenRegularAt(library, WQCacheMarker, O_RDONLY, 0);
if (marker < 0) {
if (errno != ENOENT) return NO;
struct stat saved;
if (fstatat(library, WQCacheSaved, &saved, AT_SYMLINK_NOFOLLOW) == 0) { errno = EEXIST; return NO; }
return errno == ENOENT;
}
struct stat ms;
if (fstat(marker, &ms) != 0 || ms.st_size <= 0 || ms.st_size > 65536) {
close(marker);
errno = EINVAL;
return WQCacheInvalidMarkerCanBeDiscarded(library);
}
NSMutableData *data = [NSMutableData dataWithLength:(NSUInteger)ms.st_size];
size_t done = 0;
while (done < data.length) {
ssize_t n = read(marker, (uint8_t *)data.mutableBytes + done, data.length - done);
if (n < 0 && errno == EINTR) continue;
if (n <= 0) {
close(marker);
errno = EIO;
return WQCacheInvalidMarkerCanBeDiscarded(library);
}
done += (size_t)n;
}
close(marker);
id record = [NSPropertyListSerialization propertyListWithData:data options:0 format:NULL error:nil];
if (![record isKindOfClass:NSDictionary.class] || ![record[@"Format"] isEqual:@1] ||
![record[@"Device"] isKindOfClass:NSNumber.class] || ![record[@"Inode"] isKindOfClass:NSNumber.class] ||
![record[@"Target"] isKindOfClass:NSString.class]) {
errno = EINVAL;
return WQCacheInvalidMarkerCanBeDiscarded(library);
}
struct stat original = {0}, current = {0};
original.st_dev = [record[@"Device"] intValue];
original.st_ino = [record[@"Inode"] unsignedLongLongValue];
BOOL savedExists = fstatat(library, WQCacheSaved, &original, AT_SYMLINK_NOFOLLOW) == 0;
if (!savedExists && errno != ENOENT) return NO;
struct stat expected = {0};
expected.st_dev = [record[@"Device"] intValue];
expected.st_ino = [record[@"Inode"] unsignedLongLongValue];
if (savedExists && (!S_ISDIR(original.st_mode) || !WQSameObject(&original, &expected))) { errno = EINVAL; return NO; }
BOOL cachesExists = fstatat(library, "Caches", &current, AT_SYMLINK_NOFOLLOW) == 0;
if (!cachesExists && errno != ENOENT) return NO;
if (savedExists) {
if (cachesExists) {
char link[65536];
ssize_t n = S_ISLNK(current.st_mode) ? readlinkat(library, "Caches", link, sizeof(link) - 1) : -1;
if (n < 0) { errno = EEXIST; return NO; }
link[n] = 0;
if (![record[@"Target"] isEqualToString:[NSString stringWithUTF8String:link]]) { errno = EEXIST; return NO; }
if (unlinkat(library, "Caches", 0) != 0) return NO;
}
if (renameatx_np(library, WQCacheSaved, library, "Caches", RENAME_EXCL) != 0) return NO;
} else if (!cachesExists || !S_ISDIR(current.st_mode) || !WQSameObject(&current, &expected)) {
errno = EINVAL; return NO;
}
return unlinkat(library, WQCacheMarker, 0) == 0;
}
// Normal startup performs no writes. Only an existing recovery marker or saved
// directory opens the same cross-process lock used by token issuance.
static inline BOOL WQRecoverCacheSwapAtPath(NSString *libraryPath)
{
int library = WQOpenDirectoryNoLinks(libraryPath);
if (library < 0) return errno == ENOENT;
struct stat st;
BOOL marker = fstatat(library, WQCacheMarker, &st, AT_SYMLINK_NOFOLLOW) == 0;
int markerError = marker ? 0 : errno;
BOOL saved = fstatat(library, WQCacheSaved, &st, AT_SYMLINK_NOFOLLOW) == 0;
int savedError = saved ? 0 : errno;
if ((!marker && markerError != ENOENT) || (!saved && savedError != ENOENT)) { close(library); return NO; }
if (!marker && !saved) { close(library); return YES; }
int lock = WQOpenRegularAt(library, ".wq-caches-lock", O_RDWR | O_CREAT, 0600);
BOOL ok = lock >= 0 && flock(lock, LOCK_EX | LOCK_NB) == 0 && WQCacheSwapRecover(library);
int error = errno;
if (lock >= 0) close(lock);
close(library);
if (!ok) errno = error;
return ok;
}
static inline BOOL WQCacheSwapBegin(int library, NSString *target)
{
if (!WQCacheSwapRecover(library)) return NO;
struct stat st;
if (fstatat(library, "Caches", &st, AT_SYMLINK_NOFOLLOW) != 0) {
if (errno != ENOENT || mkdirat(library, "Caches", 0755) != 0 ||
fstatat(library, "Caches", &st, AT_SYMLINK_NOFOLLOW) != 0) return NO;
}
if (!S_ISDIR(st.st_mode)) { errno = EINVAL; return NO; }
NSDictionary *record = @{ @"Format": @1, @"Device": @(st.st_dev), @"Inode": @(st.st_ino), @"Target": target };
NSData *data = [NSPropertyListSerialization dataWithPropertyList:record format:NSPropertyListBinaryFormat_v1_0 options:0 error:nil];
if (!data) { errno = EINVAL; return NO; }
// A failed marker write must never leave a partial marker behind: write a
// temporary sibling, flush it, then atomically rename it into place.
unlinkat(library, WQCacheMarkerTemp, 0);
int marker = WQOpenRegularAt(library, WQCacheMarkerTemp, O_WRONLY | O_CREAT | O_EXCL, 0600);
if (marker < 0) return NO;
BOOL durable = WQWriteBytes(marker, data); // flushes the marker's contents
if (close(marker) != 0) durable = NO;
if (!durable) {
unlinkat(library, WQCacheMarkerTemp, 0);
return NO;
}
if (renameatx_np(library, WQCacheMarkerTemp, library, WQCacheMarker, RENAME_EXCL) != 0) {
unlinkat(library, WQCacheMarkerTemp, 0);
return NO;
}
if (renameatx_np(library, "Caches", library, WQCacheSaved, RENAME_EXCL) != 0) return NO;
return symlinkat(target.fileSystemRepresentation, library, "Caches") == 0;
}
static inline BOOL WQAbsolutePathGrammar(NSString *path)
{
if (![path hasPrefix:@"/"] || path.length < 2 || [path rangeOfString:@"\0"].location != NSNotFound) return NO;
NSArray *parts = [path componentsSeparatedByString:@"/"];
for (NSUInteger i = 1; i < parts.count; i++) {
NSString *part = parts[i];
if (!part.length || [part isEqualToString:@"."] || [part isEqualToString:@".."]) return NO;
}
return YES;
}
static inline int WQCreateDirectoriesNoLinksMode(NSString *path, mode_t mode)
{
path = WQCanonicalVarPath(path);
if (!WQAbsolutePathGrammar(path)) { errno = EINVAL; return -1; }
int fd = open("/", O_SEARCH | O_CLOEXEC);
if (fd < 0) return -1;
for (NSString *part in [path componentsSeparatedByString:@"/"]) {
if (!part.length) continue;
int next = openat(fd, part.fileSystemRepresentation, O_SEARCH | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC);
if (next < 0 && errno == ENOENT) {
if (mkdirat(fd, part.fileSystemRepresentation, mode) != 0 && errno != EEXIST) { int err = errno; close(fd); errno = err; return -1; }
if (!WQSyncDirectory(fd)) { int err = errno; close(fd); errno = err; return -1; }
next = openat(fd, part.fileSystemRepresentation, O_SEARCH | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC);
}
int err = errno;
close(fd);
if (next < 0) { errno = err; return -1; }
fd = next;
}
int result = openat(fd, ".", O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC);
int err = errno;
close(fd);
if (result < 0) errno = err;
return result;
}
static inline int WQCreateDirectoriesNoLinks(NSString *path)
{
return WQCreateDirectoriesNoLinksMode(path, 0700);
}
static inline BOOL WQBackupStampValid(NSString *stamp)
{
if (stamp.length < 15) return NO;
static NSCache<NSString *, NSNumber *> *cache;
static dispatch_once_t once;
dispatch_once(&once, ^{
cache = [NSCache new];
cache.countLimit = 1024;
});
NSNumber *known = [cache objectForKey:stamp];
if (known) return known.boolValue;
// Recognize the historical second/millisecond stamps and the current
// millisecond-counter format. No arbitrary directory can become a restore.
NSString *pattern = @"^[0-9]{8}-[0-9]{6}(-[0-9]{3}(-[1-9][0-9]*)?)?$";
NSRange match = [stamp rangeOfString:pattern options:NSRegularExpressionSearch];
if (match.location != 0 || match.length != stamp.length) return NO;
NSArray *parts = [stamp componentsSeparatedByString:@"-"];
if (parts.count == 4) {
uint64_t number = 0;
for (NSUInteger i = 0; i < [parts[3] length]; i++) {
unsigned digit = [parts[3] characterAtIndex:i] - '0';
if (number > (UINT64_MAX - digit) / 10) return NO;
number = number * 10 + digit;
}
}
NSDateFormatter *fmt = [NSDateFormatter new];
fmt.locale = [NSLocale localeWithLocaleIdentifier:@"en_US_POSIX"];
fmt.calendar = [[NSCalendar alloc] initWithCalendarIdentifier:NSCalendarIdentifierGregorian];
fmt.timeZone = [NSTimeZone timeZoneForSecondsFromGMT:0];
fmt.lenient = NO;
fmt.dateFormat = @"yyyyMMdd-HHmmss";
NSString *datePart = [stamp substringToIndex:15];
NSDate *date = [fmt dateFromString:datePart];
BOOL valid = date && [[fmt stringFromDate:date] isEqualToString:datePart];
[cache setObject:@(valid) forKey:stamp];
return valid;
}
#endif
+11
View File
@@ -0,0 +1,11 @@
#import <UIKit/UIKit.h>
// Reapplies the pre-restore safety state after an interrupted restore.
// Call on the serial I/O queue before presenting the result to the user.
NSString *_Nullable wq_recover_interrupted_restore(void);
@interface WQAppsViewController : UITableViewController <UISearchResultsUpdating>
// Opens Apps and, once the list is read, offers to restore this app's backup
// (from the App Backups long-press menu).
- (nonnull instancetype)initRestoringBundleID:(nonnull NSString *)bundleID;
@end
File diff suppressed because it is too large. Load diff
+431
View File
@@ -0,0 +1,431 @@
#import <Foundation/Foundation.h>
#import <CoreFoundation/CoreFoundation.h>
#import <CommonCrypto/CommonDigest.h>
#include <errno.h>
#include <fcntl.h>
#include <limits.h>
#include <string.h>
#include <stdint.h>
#include <sys/stat.h>
#include <unistd.h>
// Portable, side-effect-free validation shared by the Apps implementation and
// its macOS fixture tests. Filesystem agreement is checked by WQApps.m.
static inline BOOL WQAppsSupportIsBool(id value)
{
return [value isKindOfClass:NSNumber.class] &&
CFGetTypeID((__bridge CFTypeRef)value) == CFBooleanGetTypeID();
}
static inline BOOL WQAppsSupportBundleIDValid(NSString *bundleID)
{
if (![bundleID isKindOfClass:NSString.class] || bundleID.length < 3 || bundleID.length > 255) return NO;
if ([bundleID hasPrefix:@"."] || [bundleID hasSuffix:@"."] || [bundleID containsString:@".."]) return NO;
static NSCharacterSet *bad;
static dispatch_once_t once;
dispatch_once(&once, ^{
bad = [[NSCharacterSet characterSetWithCharactersInString:
@"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-_"] invertedSet];
});
return [bundleID containsString:@"."] && [bundleID rangeOfCharacterFromSet:bad].location == NSNotFound;
}
static inline BOOL WQAppsSupportIsInteger(id value)
{
if (![value isKindOfClass:NSNumber.class]) return NO;
if (CFGetTypeID((__bridge CFTypeRef)value) == CFBooleanGetTypeID()) return NO;
const char *type = [(NSNumber *)value objCType];
return strcmp(type, @encode(char)) == 0 || strcmp(type, @encode(unsigned char)) == 0 ||
strcmp(type, @encode(short)) == 0 || strcmp(type, @encode(unsigned short)) == 0 ||
strcmp(type, @encode(int)) == 0 || strcmp(type, @encode(unsigned int)) == 0 ||
strcmp(type, @encode(long)) == 0 || strcmp(type, @encode(unsigned long)) == 0 ||
strcmp(type, @encode(long long)) == 0 || strcmp(type, @encode(unsigned long long)) == 0;
}
static inline BOOL WQAppsSupportManifestFailure(NSString **errorOut, NSString *message)
{
if (errorOut) *errorOut = message;
return NO;
}
static inline BOOL WQAppsSupportManifestFields(NSDictionary *manifest, NSString *bundleID,
NSArray<NSString *> **partsOut, NSString **errorOut)
{
if (![manifest isKindOfClass:NSDictionary.class]) {
return WQAppsSupportManifestFailure(errorOut, @"The backup manifest is not a dictionary");
}
if (!WQAppsSupportIsBool(manifest[@"Complete"]) || ![manifest[@"Complete"] boolValue]) {
return WQAppsSupportManifestFailure(errorOut, @"The backup manifest is not marked complete");
}
if (!WQAppsSupportIsInteger(manifest[@"Format"]) || [manifest[@"Format"] unsignedIntegerValue] != 1) {
return WQAppsSupportManifestFailure(errorOut, @"The backup manifest format is unsupported");
}
if (![manifest[@"BundleID"] isKindOfClass:NSString.class] ||
![(NSString *)manifest[@"BundleID"] length] ||
![(NSString *)manifest[@"BundleID"] isEqualToString:bundleID]) {
return WQAppsSupportManifestFailure(errorOut, @"The backup manifest belongs to another app");
}
if (![manifest[@"Created"] isKindOfClass:NSDate.class]) {
return WQAppsSupportManifestFailure(errorOut, @"The backup manifest has no valid creation timestamp");
}
id reason = manifest[@"Reason"];
if (reason && ![reason isKindOfClass:NSString.class]) {
return WQAppsSupportManifestFailure(errorOut, @"The backup manifest reason is malformed");
}
id sequence = manifest[@"Sequence"];
if (sequence && (!WQAppsSupportIsInteger(sequence) || [sequence compare:@1] == NSOrderedAscending)) {
return WQAppsSupportManifestFailure(errorOut, @"The backup manifest sequence is malformed");
}
NSArray *parts = manifest[@"Parts"];
if (![parts isKindOfClass:NSArray.class]) {
return WQAppsSupportManifestFailure(errorOut, @"The backup manifest parts are malformed");
}
NSSet *allowed = [NSSet setWithObjects:@"Documents", @"Library", nil];
NSMutableSet *seen = [NSMutableSet setWithCapacity:parts.count];
for (id part in parts) {
if (![part isKindOfClass:NSString.class] || ![allowed containsObject:part] || [seen containsObject:part]) {
return WQAppsSupportManifestFailure(errorOut, @"The backup manifest contains an invalid or duplicate part");
}
[seen addObject:part];
}
NSMutableArray *canonical = [NSMutableArray arrayWithCapacity:seen.count];
for (NSString *part in @[@"Documents", @"Library"])
if ([seen containsObject:part]) [canonical addObject:part];
id hashes = manifest[@"PartHashes"];
if (hashes) {
if (![hashes isKindOfClass:NSDictionary.class]) {
return WQAppsSupportManifestFailure(errorOut, @"The backup manifest hashes are malformed");
}
NSDictionary *partHashes = hashes;
if (![[NSSet setWithArray:partHashes.allKeys] isEqualToSet:seen]) {
return WQAppsSupportManifestFailure(errorOut, @"The backup manifest hashes do not match its parts");
}
for (id key in partHashes) {
id hash = partHashes[key];
if (![key isKindOfClass:NSString.class] || ![allowed containsObject:key] ||
![hash isKindOfClass:NSString.class] || ![hash length]) {
return WQAppsSupportManifestFailure(errorOut, @"The backup manifest has an invalid part hash");
}
}
}
if (partsOut) *partsOut = canonical;
return YES;
}
static inline void WQAppsSupportHashU64(CC_SHA256_CTX *ctx, uint64_t value)
{
uint8_t bytes[8];
for (NSUInteger i = 0; i < 8; i++) bytes[i] = (uint8_t)(value >> (56 - 8 * i));
CC_SHA256_Update(ctx, bytes, (CC_LONG)sizeof(bytes));
}
static inline void WQAppsSupportHashBlob(CC_SHA256_CTX *ctx, const void *bytes, NSUInteger length)
{
WQAppsSupportHashU64(ctx, (uint64_t)length);
if (length) CC_SHA256_Update(ctx, bytes, (CC_LONG)length);
}
// Deterministic SHA-256 over length-prefixed tree metadata. `includeIdentity`
// adds inode/mtime/ctime for source-stability checks; the shape variant omits
// them and is suitable for comparing a source with its copy. A digest keeps
// memory bounded and makes filenames containing delimiters unambiguous.
static inline NSString *WQAppsSupportTreeSignature(NSString *path, BOOL includeIdentity,
BOOL *exists, NSString **errorOut)
{
struct stat rootStat;
if (lstat(path.fileSystemRepresentation, &rootStat) != 0) {
if (errno == ENOENT) {
if (exists) *exists = NO;
return @"<missing>";
}
if (errorOut) *errorOut = [NSString stringWithFormat:@"Cannot inspect %@", path.lastPathComponent];
return nil;
}
if (!S_ISDIR(rootStat.st_mode)) {
if (errorOut) *errorOut = [NSString stringWithFormat:@"%@ is not a directory", path.lastPathComponent];
return nil;
}
NSError *listError = nil;
if (![NSFileManager.defaultManager contentsOfDirectoryAtPath:path error:&listError]) {
if (errorOut) *errorOut = listError.localizedDescription ?: [NSString stringWithFormat:@"Cannot read %@", path.lastPathComponent];
return nil;
}
if (exists) *exists = YES;
NSMutableArray<NSString *> *relativePaths = [NSMutableArray new];
for (NSString *relative in [NSFileManager.defaultManager enumeratorAtPath:path])
[relativePaths addObject:relative];
[relativePaths sortUsingSelector:@selector(compare:)];
CC_SHA256_CTX ctx;
CC_SHA256_Init(&ctx);
for (NSString *relative in relativePaths) {
NSString *item = [path stringByAppendingPathComponent:relative];
struct stat itemStat;
if (lstat(item.fileSystemRepresentation, &itemStat) != 0) {
if (errorOut) *errorOut = [NSString stringWithFormat:@"Cannot inspect %@", relative];
return nil;
}
unsigned type = (unsigned)(itemStat.st_mode & S_IFMT);
const char *name = relative.UTF8String;
NSUInteger nameLength = name ? strlen(name) : 0;
if (!name) {
if (errorOut) *errorOut = [NSString stringWithFormat:@"Cannot encode %@", relative];
return nil;
}
uint8_t tag = includeIdentity ? 'I' : 'S';
CC_SHA256_Update(&ctx, &tag, 1);
WQAppsSupportHashBlob(&ctx, name, nameLength);
WQAppsSupportHashU64(&ctx, type);
WQAppsSupportHashU64(&ctx, (uint64_t)(itemStat.st_mode & 07777));
if (includeIdentity) {
WQAppsSupportHashU64(&ctx, (uint64_t)itemStat.st_ino);
WQAppsSupportHashU64(&ctx, (uint64_t)itemStat.st_size);
WQAppsSupportHashU64(&ctx, (uint64_t)itemStat.st_mtimespec.tv_sec);
WQAppsSupportHashU64(&ctx, (uint64_t)itemStat.st_mtimespec.tv_nsec);
WQAppsSupportHashU64(&ctx, (uint64_t)itemStat.st_ctimespec.tv_sec);
WQAppsSupportHashU64(&ctx, (uint64_t)itemStat.st_ctimespec.tv_nsec);
} else if (type == S_IFREG) {
WQAppsSupportHashU64(&ctx, (uint64_t)itemStat.st_size);
} else if (type == S_IFLNK) {
char target[PATH_MAX];
ssize_t length = readlink(item.fileSystemRepresentation, target, sizeof(target) - 1);
if (length < 0 || length >= (ssize_t)sizeof(target) - 1) {
if (errorOut) *errorOut = [NSString stringWithFormat:@"Cannot inspect link %@", relative];
return nil;
}
WQAppsSupportHashBlob(&ctx, target, (NSUInteger)length);
}
}
unsigned char digest[CC_SHA256_DIGEST_LENGTH];
CC_SHA256_Final(digest, &ctx);
NSMutableString *hex = [NSMutableString stringWithCapacity:CC_SHA256_DIGEST_LENGTH * 2];
for (NSUInteger i = 0; i < CC_SHA256_DIGEST_LENGTH; i++) [hex appendFormat:@"%02x", digest[i]];
return hex;
}
// Content-level digest for backup verification: metadata plus every regular
// file's bytes. Reading is streamed; a file that changes while it is hashed
// fails with an error rather than producing a torn digest.
static inline NSString *WQAppsSupportTreeContentSignature(NSString *path, BOOL *exists, NSString **errorOut)
{
struct stat rootStat;
if (lstat(path.fileSystemRepresentation, &rootStat) != 0) {
if (errno == ENOENT) {
if (exists) *exists = NO;
return @"<missing>";
}
if (errorOut) *errorOut = [NSString stringWithFormat:@"Cannot inspect %@", path.lastPathComponent];
return nil;
}
if (!S_ISDIR(rootStat.st_mode)) {
if (errorOut) *errorOut = [NSString stringWithFormat:@"%@ is not a directory", path.lastPathComponent];
return nil;
}
NSError *listError = nil;
if (![NSFileManager.defaultManager contentsOfDirectoryAtPath:path error:&listError]) {
if (errorOut) *errorOut = listError.localizedDescription ?: [NSString stringWithFormat:@"Cannot read %@", path.lastPathComponent];
return nil;
}
if (exists) *exists = YES;
NSMutableArray<NSString *> *relativePaths = [NSMutableArray new];
for (NSString *relative in [NSFileManager.defaultManager enumeratorAtPath:path])
[relativePaths addObject:relative];
[relativePaths sortUsingSelector:@selector(compare:)];
CC_SHA256_CTX ctx;
CC_SHA256_Init(&ctx);
uint8_t buffer[65536];
for (NSString *relative in relativePaths) {
NSString *item = [path stringByAppendingPathComponent:relative];
struct stat itemStat;
if (lstat(item.fileSystemRepresentation, &itemStat) != 0) {
if (errorOut) *errorOut = [NSString stringWithFormat:@"Cannot inspect %@", relative];
return nil;
}
const char *name = relative.UTF8String;
if (!name) {
if (errorOut) *errorOut = [NSString stringWithFormat:@"Cannot encode %@", relative];
return nil;
}
uint8_t tag = 'C';
CC_SHA256_Update(&ctx, &tag, 1);
WQAppsSupportHashBlob(&ctx, name, strlen(name));
WQAppsSupportHashU64(&ctx, (uint64_t)(itemStat.st_mode & S_IFMT));
WQAppsSupportHashU64(&ctx, (uint64_t)(itemStat.st_mode & 07777));
WQAppsSupportHashU64(&ctx, (uint64_t)itemStat.st_size);
if (S_ISREG(itemStat.st_mode)) {
int fd = open(item.fileSystemRepresentation, O_RDONLY | O_NOFOLLOW | O_CLOEXEC);
if (fd < 0) {
if (errorOut) *errorOut = [NSString stringWithFormat:@"Cannot read %@", relative];
return nil;
}
struct stat after;
BOOL ok = fstat(fd, &after) == 0 && S_ISREG(after.st_mode) &&
after.st_dev == itemStat.st_dev && after.st_ino == itemStat.st_ino;
for (;;) {
if (!ok) break;
ssize_t n = read(fd, buffer, sizeof(buffer));
if (n < 0) {
if (errno == EINTR) continue;
ok = NO;
break;
}
if (n == 0) break;
CC_SHA256_Update(&ctx, buffer, (CC_LONG)n);
}
struct stat finalStat;
if (ok && (fstat(fd, &finalStat) != 0 ||
finalStat.st_size != itemStat.st_size ||
finalStat.st_mtimespec.tv_sec != itemStat.st_mtimespec.tv_sec ||
finalStat.st_mtimespec.tv_nsec != itemStat.st_mtimespec.tv_nsec ||
finalStat.st_ctimespec.tv_sec != itemStat.st_ctimespec.tv_sec ||
finalStat.st_ctimespec.tv_nsec != itemStat.st_ctimespec.tv_nsec)) ok = NO;
int err = errno;
close(fd);
if (!ok) {
if (errorOut) *errorOut = [NSString stringWithFormat:@"%@ changed while it was read", relative];
errno = err;
return nil;
}
} else if (S_ISLNK(itemStat.st_mode)) {
char target[PATH_MAX];
ssize_t length = readlink(item.fileSystemRepresentation, target, sizeof(target) - 1);
if (length < 0 || length >= (ssize_t)sizeof(target) - 1) {
if (errorOut) *errorOut = [NSString stringWithFormat:@"Cannot inspect link %@", relative];
return nil;
}
WQAppsSupportHashBlob(&ctx, target, (NSUInteger)length);
}
}
unsigned char digest[CC_SHA256_DIGEST_LENGTH];
CC_SHA256_Final(digest, &ctx);
NSMutableString *hex = [NSMutableString stringWithCapacity:CC_SHA256_DIGEST_LENGTH * 2];
for (NSUInteger i = 0; i < CC_SHA256_DIGEST_LENGTH; i++) [hex appendFormat:@"%02x", digest[i]];
return hex;
}
static inline NSUInteger WQAppsSupportBackupSequence(NSString *name)
{
if (![name isKindOfClass:NSString.class] || !name.length) return 0;
if ([name hasSuffix:@")"]) {
NSRange open = [name rangeOfString:@" (" options:NSBackwardsSearch];
if (open.location != NSNotFound && open.location + 3 < name.length) {
NSString *digits = [name substringWithRange:NSMakeRange(open.location + 2, name.length - open.location - 3)];
NSCharacterSet *nonDigits = [[NSCharacterSet decimalDigitCharacterSet] invertedSet];
if (digits.length && [digits rangeOfCharacterFromSet:nonDigits].location == NSNotFound) {
NSUInteger sequence = digits.integerValue;
if (sequence > 0) return sequence;
}
}
}
return 1;
}
static inline NSComparisonResult WQAppsSupportCompareBackupMetadata(NSDate *dateA, NSUInteger sequenceA, NSString *nameA,
NSDate *dateB, NSUInteger sequenceB, NSString *nameB)
{
NSComparisonResult dateOrder = [dateA compare:dateB];
if (dateOrder != NSOrderedSame) return dateOrder;
if (sequenceA < sequenceB) return NSOrderedAscending;
if (sequenceA > sequenceB) return NSOrderedDescending;
return [nameA compare:nameB options:NSNumericSearch];
}
typedef NS_ENUM(NSInteger, WQAppsSupportJournalState) {
WQAppsSupportJournalMissing,
WQAppsSupportJournalActive,
WQAppsSupportJournalCommitted,
WQAppsSupportJournalUnknown,
};
static inline WQAppsSupportJournalState WQAppsSupportJournalStateForDictionary(NSDictionary *journal)
{
if (![journal isKindOfClass:NSDictionary.class]) return WQAppsSupportJournalMissing;
id state = journal[@"State"];
if (![state isKindOfClass:NSString.class]) return WQAppsSupportJournalUnknown;
if ([state isEqualToString:@"Active"]) return WQAppsSupportJournalActive;
if ([state isEqualToString:@"Committed"]) return WQAppsSupportJournalCommitted;
return WQAppsSupportJournalUnknown;
}
// A new restore may start only when no recovery is pending. A committed
// journal is safe to clean up and replace; an Active or unknown journal must
// be recovered or explicitly discarded first.
static inline BOOL WQAppsSupportJournalAllowsNewRestore(WQAppsSupportJournalState state, NSString **error)
{
if (state == WQAppsSupportJournalActive) {
if (error) *error = @"An interrupted restore is still active. Recover or discard it before starting another restore";
return NO;
}
if (state == WQAppsSupportJournalUnknown) {
if (error) *error = @"The existing restore recovery journal has an unknown state";
return NO;
}
return YES;
}
#pragma mark - Import as Backup
// The folder inside an import that holds Documents and/or Library: the
// picked folder itself, or the shallowest one below it (a shared backup ZIP
// or another tool's export usually wraps them in a folder). Several at the
// same depth are ambiguous.
static inline NSString *WQAppsSupportImportRoot(NSString *dir, NSString **error)
{
NSFileManager *fm = NSFileManager.defaultManager;
NSArray<NSString *> *level = @[dir];
for (int depth = 0; depth <= 3 && level.count; depth++) {
NSMutableArray<NSString *> *found = [NSMutableArray new], *next = [NSMutableArray new];
for (NSString *candidate in level) {
BOOL has = NO;
for (NSString *part in @[@"Documents", @"Library"]) {
struct stat st;
if (lstat([candidate stringByAppendingPathComponent:part].fileSystemRepresentation, &st) == 0 && S_ISDIR(st.st_mode))
has = YES;
}
if (has) { [found addObject:candidate]; continue; }
for (NSString *name in [[fm contentsOfDirectoryAtPath:candidate error:nil] sortedArrayUsingSelector:@selector(compare:)]) {
if ([name hasPrefix:@"."] || [name isEqualToString:@"__MACOSX"]) continue;
NSString *child = [candidate stringByAppendingPathComponent:name];
struct stat st;
if (lstat(child.fileSystemRepresentation, &st) == 0 && S_ISDIR(st.st_mode)) [next addObject:child];
}
}
if (found.count == 1) return found.firstObject;
if (found.count > 1) {
NSMutableArray *names = [NSMutableArray new];
for (NSString *f in found) [names addObject:[f substringFromIndex:MIN(dir.length + 1, f.length)]];
if (error) *error = [NSString stringWithFormat:NSLocalizedString(@"It holds data of several apps (%@). Pick one of these folders instead.", nil),
[names componentsJoinedByString:@", "]];
return nil;
}
level = next;
}
if (error) *error = NSLocalizedString(@"No Documents or Library folder was found in it.", nil);
return nil;
}
// The bundle ID the imported data says it belongs to, if it says: a WQ Files
// backup's checklist or a container's metadata file.
static inline NSString *WQAppsSupportImportDeclaredBundle(NSString *root)
{
NSDictionary *manifest = [NSDictionary dictionaryWithContentsOfFile:[root stringByAppendingPathComponent:@".wqbackup.plist"]];
if ([manifest[@"BundleID"] isKindOfClass:NSString.class]) return manifest[@"BundleID"];
NSDictionary *metadata = [NSDictionary dictionaryWithContentsOfFile:
[root stringByAppendingPathComponent:@".com.apple.mobile_container_manager.metadata.plist"]];
if ([metadata[@"MCMMetadataIdentifier"] isKindOfClass:NSString.class]) return metadata[@"MCMMetadataIdentifier"];
return nil;
}
// Items next to Documents/Library that an import leaves out.
static inline NSArray<NSString *> *WQAppsSupportImportIgnored(NSString *root)
{
NSMutableArray *ignored = [NSMutableArray new];
for (NSString *name in [[NSFileManager.defaultManager contentsOfDirectoryAtPath:root error:nil] sortedArrayUsingSelector:@selector(compare:)]) {
if ([@[@"Documents", @"Library", @".wqbackup.plist", @".com.apple.mobile_container_manager.metadata.plist", @".DS_Store"] containsObject:name]) continue;
[ignored addObject:name];
}
return ignored;
}
+58
View File
@@ -0,0 +1,58 @@
//
// WQArchive.h
// WQ Files
//
// Minimal ZIP writer and reader (stored and deflate, via zlib). Files are
// read through a small I/O table, so the same code works on other apps'
// containers (through WQAccess) and on plain files in host tests.
//
// Writing: ZIP files up to 4 GB with at most 65535 entries. Reading also
// understands ZIP64. Extraction never creates symbolic links, refuses entry
// names that would leave the target folder, checks every entry's size and
// CRC, and writes only into the (local, empty) folder it is given.
//
#import <Foundation/Foundation.h>
#import <sys/stat.h>
NS_ASSUME_NONNULL_BEGIN
@interface WQArchiveIO : NSObject
// lstat-like: does not follow a final symbolic link. NO if missing.
@property (nonatomic, copy) BOOL (^stat)(NSString *path, struct stat *st);
// Names of the entries in a folder, or nil.
@property (nonatomic, copy) NSArray<NSString *> *_Nullable (^list)(NSString *dir);
// A read-only descriptor for a regular file (not following links), or -1.
@property (nonatomic, copy) int (^openRead)(NSString *path);
@end
// Plain POSIX calls; for local files and tests.
WQArchiveIO *wq_archive_posix_io(void);
typedef BOOL (^WQArchiveCancelled)(void);
// Bytes read (writing) or written (extracting) so far.
typedef void (^WQArchiveProgress)(unsigned long long bytes);
// Writes a ZIP of `paths` (files and folders, recursively; each one appears
// under its own name at the top of the archive) to the new local file
// `zipPath`. Symbolic links and special files are left out and counted in
// *skipped. A failed or cancelled write removes zipPath.
BOOL wq_zip_write(NSArray<NSString *> *paths, NSString *zipPath, WQArchiveIO *io,
WQArchiveCancelled _Nullable cancelled, WQArchiveProgress _Nullable progress,
NSUInteger *_Nullable skipped, NSString *_Nullable *_Nullable errorText);
// Extracts the ZIP at zipPath (read through io) into the existing local folder
// outDir. Refuses archives that would unpack to more than maxBytes, encrypted
// entries and unsupported compression. Symbolic links in the archive are left
// out (counted in *skipped); macOS "__MACOSX" metadata is ignored. On failure
// outDir may hold a partial result; the caller removes it.
BOOL wq_zip_extract(NSString *zipPath, NSString *outDir, WQArchiveIO *io, unsigned long long maxBytes,
WQArchiveCancelled _Nullable cancelled, WQArchiveProgress _Nullable progress,
NSUInteger *_Nullable files, NSUInteger *_Nullable skipped,
NSString *_Nullable *_Nullable errorText);
// A safe relative path for an entry name ("a/b/c"; backslashes count as
// separators), or nil for absolute names, "." / ".." components or empty names.
NSString *_Nullable wq_zip_safe_entry_path(NSString *name);
NS_ASSUME_NONNULL_END
+575
View File
@@ -0,0 +1,575 @@
//
// WQArchive.m
// WQ Files
//
#import "WQArchive.h"
#import "WQLocalization.h"
#import <dirent.h>
#import <fcntl.h>
#import <sys/time.h>
#import <time.h>
#import <unistd.h>
#import <zlib.h>
static const size_t kWQZipChunk = 256 * 1024;
static const uint32_t kWQZipMax32 = 0xFFFFFFFFu;
@implementation WQArchiveIO
@end
WQArchiveIO *wq_archive_posix_io(void)
{
WQArchiveIO *io = [WQArchiveIO new];
io.stat = ^BOOL(NSString *path, struct stat *st) { return lstat(path.fileSystemRepresentation, st) == 0; };
io.list = ^NSArray<NSString *> *(NSString *dir) {
return [NSFileManager.defaultManager contentsOfDirectoryAtPath:dir error:nil];
};
io.openRead = ^int(NSString *path) {
return open(path.fileSystemRepresentation, O_RDONLY | O_NOFOLLOW | O_CLOEXEC);
};
return io;
}
#pragma mark Little-endian helpers
static void wq_put16(NSMutableData *d, uint16_t v) { uint8_t b[2] = { (uint8_t)v, (uint8_t)(v >> 8) }; [d appendBytes:b length:2]; }
static void wq_put32(NSMutableData *d, uint32_t v)
{
uint8_t b[4] = { (uint8_t)v, (uint8_t)(v >> 8), (uint8_t)(v >> 16), (uint8_t)(v >> 24) };
[d appendBytes:b length:4];
}
static uint16_t wq_rd16(const uint8_t *p) { return (uint16_t)(p[0] | (p[1] << 8)); }
static uint32_t wq_rd32(const uint8_t *p) { return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24); }
static uint64_t wq_rd64(const uint8_t *p) { return (uint64_t)wq_rd32(p) | ((uint64_t)wq_rd32(p + 4) << 32); }
static BOOL wq_write_fd(int fd, const void *buf, size_t n)
{
const uint8_t *p = buf;
while (n > 0) {
ssize_t w = write(fd, p, n);
if (w < 0 && errno == EINTR) continue;
if (w <= 0) return NO;
p += w; n -= (size_t)w;
}
return YES;
}
static BOOL wq_pread_all(int fd, void *buf, size_t n, off_t offset)
{
uint8_t *p = buf;
while (n > 0) {
ssize_t r = pread(fd, p, n, offset);
if (r < 0 && errno == EINTR) continue;
if (r <= 0) return NO;
p += r; n -= (size_t)r; offset += r;
}
return YES;
}
static ssize_t wq_read_some(int fd, void *buf, size_t n)
{
for (;;) {
ssize_t r = read(fd, buf, n);
if (r < 0 && errno == EINTR) continue;
return r;
}
}
static void wq_dos_time(time_t t, uint16_t *dosTime, uint16_t *dosDate)
{
struct tm tm;
localtime_r(&t, &tm);
if (tm.tm_year < 80) { *dosTime = 0; *dosDate = (1 << 5) | 1; return; } // 1980-01-01
if (tm.tm_year > 207) tm.tm_year = 207;
*dosTime = (uint16_t)((tm.tm_hour << 11) | (tm.tm_min << 5) | (tm.tm_sec / 2));
*dosDate = (uint16_t)(((tm.tm_year - 80) << 9) | ((tm.tm_mon + 1) << 5) | tm.tm_mday);
}
static time_t wq_from_dos_time(uint16_t dosTime, uint16_t dosDate)
{
struct tm tm = {0};
tm.tm_year = ((dosDate >> 9) & 0x7f) + 80;
tm.tm_mon = ((dosDate >> 5) & 0x0f) - 1;
tm.tm_mday = dosDate & 0x1f;
tm.tm_hour = (dosTime >> 11) & 0x1f;
tm.tm_min = (dosTime >> 5) & 0x3f;
tm.tm_sec = (dosTime & 0x1f) * 2;
tm.tm_isdst = -1;
if (tm.tm_mon < 0 || tm.tm_mday == 0) return 0;
return mktime(&tm);
}
#pragma mark Writing
@interface WQZipEntry : NSObject
@property (nonatomic) NSData *name;
@property (nonatomic) BOOL dir;
@property (nonatomic) uint16_t method, time, date;
@property (nonatomic) uint32_t crc, mode;
@property (nonatomic) uint64_t csize, usize, offset;
@end
@implementation WQZipEntry
@end
typedef struct {
int out;
uint64_t offset;
unsigned long long bytesRead;
NSUInteger skipped;
NSMutableArray<WQZipEntry *> *entries;
WQArchiveIO *io;
WQArchiveCancelled cancelled;
WQArchiveProgress progress;
NSString *error;
} WQZipWriter;
static BOOL wq_zip_fail(WQZipWriter *w, NSString *message)
{
if (!w->error) w->error = message;
return NO;
}
static BOOL wq_zip_too_big(WQZipWriter *w)
{
return wq_zip_fail(w, WQLocalized(@"The archive would be larger than 4 GB, which isn't supported yet."));
}
static BOOL wq_zip_local_header(WQZipWriter *w, WQZipEntry *e)
{
if (w->offset >= kWQZipMax32) return wq_zip_too_big(w);
if (w->entries.count >= 0xFFFF) return wq_zip_fail(w, WQLocalized(@"Too many items for one ZIP (more than 65535)."));
e.offset = w->offset;
NSMutableData *h = [NSMutableData dataWithCapacity:30 + e.name.length];
wq_put32(h, 0x04034b50);
wq_put16(h, 20); // version needed
wq_put16(h, 0x0800); // UTF-8 names
wq_put16(h, e.method);
wq_put16(h, e.time);
wq_put16(h, e.date);
wq_put32(h, 0); wq_put32(h, 0); wq_put32(h, 0); // crc and sizes, patched later
wq_put16(h, (uint16_t)e.name.length);
wq_put16(h, 0);
[h appendData:e.name];
if (!wq_write_fd(w->out, h.bytes, h.length)) return wq_zip_fail(w, @(strerror(errno)));
w->offset += h.length;
[w->entries addObject:e];
return YES;
}
static BOOL wq_zip_add_file(WQZipWriter *w, NSString *path, NSString *name, const struct stat *st)
{
int in = w->io.openRead(path);
if (in < 0) return wq_zip_fail(w, WQLocalizedFormat(@"Can't read %@ (%s)", name, strerror(errno)));
WQZipEntry *e = [WQZipEntry new];
e.name = [name dataUsingEncoding:NSUTF8StringEncoding];
e.method = 8;
e.mode = st->st_mode & 0777;
uint16_t t, d; wq_dos_time(st->st_mtimespec.tv_sec, &t, &d); e.time = t; e.date = d;
if (!wq_zip_local_header(w, e)) { close(in); return NO; }
z_stream z = {0};
if (deflateInit2(&z, Z_DEFAULT_COMPRESSION, Z_DEFLATED, -MAX_WBITS, 8, Z_DEFAULT_STRATEGY) != Z_OK) {
close(in); return wq_zip_fail(w, WQLocalized(@"Can't start compression"));
}
uint8_t *inBuf = malloc(kWQZipChunk), *outBuf = malloc(kWQZipChunk);
BOOL ok = inBuf && outBuf;
if (!ok) wq_zip_fail(w, WQLocalized(@"Out of memory"));
uLong crc = crc32(0, NULL, 0);
uint64_t usize = 0, csize = 0;
BOOL eof = NO;
while (ok && !eof) {
if (w->cancelled && w->cancelled()) { ok = wq_zip_fail(w, @"Cancelled"); break; }
ssize_t n = wq_read_some(in, inBuf, kWQZipChunk);
if (n < 0) { ok = wq_zip_fail(w, WQLocalizedFormat(@"Can't read %@ (%s)", name, strerror(errno))); break; }
eof = n == 0;
crc = crc32(crc, inBuf, (uInt)n);
usize += (uint64_t)n;
w->bytesRead += (unsigned long long)n;
if (usize >= kWQZipMax32) { ok = wq_zip_too_big(w); break; }
z.next_in = inBuf; z.avail_in = (uInt)n;
int flush = eof ? Z_FINISH : Z_NO_FLUSH;
int rc;
do {
z.next_out = outBuf; z.avail_out = (uInt)kWQZipChunk;
rc = deflate(&z, flush);
if (rc == Z_STREAM_ERROR) { ok = wq_zip_fail(w, WQLocalized(@"Compression failed")); break; }
size_t have = kWQZipChunk - z.avail_out;
if (have && !wq_write_fd(w->out, outBuf, have)) { ok = wq_zip_fail(w, @(strerror(errno))); break; }
csize += have;
} while (z.avail_out == 0);
if (ok && w->progress) w->progress(w->bytesRead);
}
deflateEnd(&z);
free(inBuf); free(outBuf);
close(in);
if (!ok) return NO;
w->offset += csize;
if (w->offset >= kWQZipMax32) return wq_zip_too_big(w);
e.crc = (uint32_t)crc; e.csize = csize; e.usize = usize;
NSMutableData *patch = [NSMutableData dataWithCapacity:12];
wq_put32(patch, e.crc); wq_put32(patch, (uint32_t)csize); wq_put32(patch, (uint32_t)usize);
if (pwrite(w->out, patch.bytes, 12, (off_t)e.offset + 14) != 12) return wq_zip_fail(w, @(strerror(errno)));
return YES;
}
static BOOL wq_zip_add(WQZipWriter *w, NSString *path, NSString *name, NSUInteger depth)
{
if (w->cancelled && w->cancelled()) return wq_zip_fail(w, @"Cancelled");
if (depth > 64) return wq_zip_fail(w, WQLocalized(@"Folders are nested too deeply"));
struct stat st;
if (!w->io.stat(path, &st)) return wq_zip_fail(w, WQLocalizedFormat(@"Can't read %@ (%s)", name, strerror(errno)));
if (S_ISREG(st.st_mode)) return wq_zip_add_file(w, path, name, &st);
if (!S_ISDIR(st.st_mode)) { w->skipped++; return YES; } // links and special files
WQZipEntry *e = [WQZipEntry new];
e.name = [[name stringByAppendingString:@"/"] dataUsingEncoding:NSUTF8StringEncoding];
e.dir = YES;
e.mode = st.st_mode & 0777;
uint16_t t, d; wq_dos_time(st.st_mtimespec.tv_sec, &t, &d); e.time = t; e.date = d;
e.crc = 0;
if (!wq_zip_local_header(w, e)) return NO;
NSArray<NSString *> *children = w->io.list(path);
if (!children) return wq_zip_fail(w, WQLocalizedFormat(@"Can't list %@", name));
for (NSString *child in [children sortedArrayUsingSelector:@selector(localizedStandardCompare:)]) {
if (!wq_zip_add(w, [path stringByAppendingPathComponent:child], [name stringByAppendingFormat:@"/%@", child], depth + 1))
return NO;
}
return YES;
}
static BOOL wq_zip_central_directory(WQZipWriter *w)
{
uint64_t cdStart = w->offset;
NSMutableData *cd = [NSMutableData new];
for (WQZipEntry *e in w->entries) {
wq_put32(cd, 0x02014b50);
wq_put16(cd, (3 << 8) | 20); // made by Unix, ZIP 2.0
wq_put16(cd, 20);
wq_put16(cd, 0x0800);
wq_put16(cd, e.method);
wq_put16(cd, e.time);
wq_put16(cd, e.date);
wq_put32(cd, e.crc);
wq_put32(cd, (uint32_t)e.csize);
wq_put32(cd, (uint32_t)e.usize);
wq_put16(cd, (uint16_t)e.name.length);
wq_put16(cd, 0); wq_put16(cd, 0); wq_put16(cd, 0); wq_put16(cd, 0);
uint32_t type = e.dir ? S_IFDIR : S_IFREG;
wq_put32(cd, ((type | e.mode) << 16) | (e.dir ? 0x10 : 0));
wq_put32(cd, (uint32_t)e.offset);
[cd appendData:e.name];
}
uint32_t cdLength = (uint32_t)cd.length;
if (cdStart + cd.length >= kWQZipMax32) return wq_zip_too_big(w);
wq_put32(cd, 0x06054b50);
wq_put16(cd, 0); wq_put16(cd, 0);
wq_put16(cd, (uint16_t)w->entries.count);
wq_put16(cd, (uint16_t)w->entries.count);
wq_put32(cd, cdLength);
wq_put32(cd, (uint32_t)cdStart);
wq_put16(cd, 0);
if (!wq_write_fd(w->out, cd.bytes, cd.length)) return wq_zip_fail(w, @(strerror(errno)));
return YES;
}
BOOL wq_zip_write(NSArray<NSString *> *paths, NSString *zipPath, WQArchiveIO *io,
WQArchiveCancelled cancelled, WQArchiveProgress progress,
NSUInteger *skipped, NSString **errorText)
{
if (paths.count == 0) { if (errorText) *errorText = WQLocalized(@"Nothing to compress"); return NO; }
int out = open(zipPath.fileSystemRepresentation, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, 0644);
if (out < 0) { if (errorText) *errorText = @(strerror(errno)); return NO; }
NSMutableArray *entries = [NSMutableArray new];
NSString *error = nil;
WQZipWriter w = { .out = out, .entries = entries, .io = io, .cancelled = cancelled, .progress = progress };
BOOL ok = YES;
NSMutableSet<NSString *> *topNames = [NSMutableSet new];
for (NSString *path in paths) {
NSString *name = path.lastPathComponent;
if (!name.length || [topNames containsObject:name]) { ok = wq_zip_fail(&w, WQLocalized(@"Two selected items have the same name")); break; }
[topNames addObject:name];
if (!(ok = wq_zip_add(&w, path, name, 0))) break;
}
if (ok) ok = wq_zip_central_directory(&w);
if (ok && fsync(out) != 0) ok = wq_zip_fail(&w, @(strerror(errno)));
error = w.error;
if (close(out) != 0 && ok) { ok = NO; error = @(strerror(errno)); }
if (!ok) unlink(zipPath.fileSystemRepresentation);
if (skipped) *skipped = w.skipped;
if (!ok && errorText) *errorText = error ?: WQLocalized(@"Could not create the ZIP");
return ok;
}
#pragma mark Reading
NSString *wq_zip_safe_entry_path(NSString *name)
{
if (!name.length || [name rangeOfString:@"\0"].location != NSNotFound) return nil;
NSString *slashed = [name stringByReplacingOccurrencesOfString:@"\\" withString:@"/"];
if ([slashed hasPrefix:@"/"]) return nil;
if (slashed.length >= 2 && [slashed characterAtIndex:1] == ':') return nil; // "C:/…"
NSMutableArray<NSString *> *parts = [NSMutableArray new];
for (NSString *part in [slashed componentsSeparatedByString:@"/"]) {
if (!part.length) continue;
if ([part isEqualToString:@"."] || [part isEqualToString:@".."]) return nil;
if (strlen(part.fileSystemRepresentation ?: "") > 255) return nil;
[parts addObject:part];
}
return parts.count ? [parts componentsJoinedByString:@"/"] : nil;
}
typedef struct {
NSString *name;
uint16_t flags, method, time, date, madeBy;
uint32_t crc, external;
uint64_t csize, usize, offset;
} WQZipItem;
static NSString *wq_zip_decode_name(const uint8_t *p, uint16_t len, uint16_t flags)
{
NSData *raw = [NSData dataWithBytes:p length:len];
NSString *s = [[NSString alloc] initWithData:raw encoding:NSUTF8StringEncoding];
if (!s && !(flags & 0x0800))
s = [[NSString alloc] initWithData:raw encoding:CFStringConvertEncodingToNSStringEncoding(kCFStringEncodingDOSLatinUS)];
return s;
}
static BOOL wq_zip_read_directory(int fd, uint64_t fileSize, WQZipItem **itemsOut, NSUInteger *countOut, NSString **error)
{
size_t tailLen = (size_t)MIN(fileSize, (uint64_t)(22 + 65535));
if (tailLen < 22) { *error = WQLocalized(@"Not a ZIP file"); return NO; }
uint8_t *tail = malloc(tailLen);
if (!tail || !wq_pread_all(fd, tail, tailLen, (off_t)(fileSize - tailLen))) { free(tail); *error = WQLocalized(@"Can't read the ZIP file"); return NO; }
ssize_t eocd = -1;
for (ssize_t i = (ssize_t)tailLen - 22; i >= 0; i--) {
if (wq_rd32(tail + i) == 0x06054b50) { eocd = i; break; }
}
if (eocd < 0) { free(tail); *error = WQLocalized(@"Not a ZIP file (no directory found)"); return NO; }
uint64_t eocdPos = fileSize - tailLen + (uint64_t)eocd;
uint64_t count = wq_rd16(tail + eocd + 10);
uint64_t cdSize = wq_rd32(tail + eocd + 12);
uint64_t cdOffset = wq_rd32(tail + eocd + 16);
free(tail);
if ((count == 0xFFFF || cdSize == kWQZipMax32 || cdOffset == kWQZipMax32) && eocdPos >= 20) {
uint8_t loc[20], rec[56];
if (wq_pread_all(fd, loc, 20, (off_t)(eocdPos - 20)) && wq_rd32(loc) == 0x07064b50) {
uint64_t recPos = wq_rd64(loc + 8);
if (recPos + 56 <= fileSize && wq_pread_all(fd, rec, 56, (off_t)recPos) && wq_rd32(rec) == 0x06064b50) {
count = wq_rd64(rec + 32);
cdSize = wq_rd64(rec + 40);
cdOffset = wq_rd64(rec + 48);
}
}
}
if (cdOffset > fileSize || cdSize > fileSize - cdOffset || cdSize > 256ull * 1024 * 1024 || count > cdSize / 46 + 1) {
*error = WQLocalized(@"The ZIP file is damaged (bad directory)");
return NO;
}
uint8_t *cd = malloc(cdSize ? (size_t)cdSize : 1);
if (!cd || (cdSize && !wq_pread_all(fd, cd, (size_t)cdSize, (off_t)cdOffset))) { free(cd); *error = WQLocalized(@"Can't read the ZIP directory"); return NO; }
WQZipItem *items = calloc((size_t)MAX(count, 1), sizeof(WQZipItem));
if (!items) { free(cd); *error = WQLocalized(@"Out of memory"); return NO; }
uint64_t pos = 0;
NSUInteger n = 0;
BOOL ok = YES;
for (uint64_t i = 0; i < count; i++) {
if (pos + 46 > cdSize || wq_rd32(cd + pos) != 0x02014b50) { ok = NO; break; }
const uint8_t *p = cd + pos;
uint16_t nameLen = wq_rd16(p + 28), extraLen = wq_rd16(p + 30), commentLen = wq_rd16(p + 32);
if (pos + 46 + nameLen + extraLen + commentLen > cdSize) { ok = NO; break; }
WQZipItem it = {0};
it.madeBy = wq_rd16(p + 4);
it.flags = wq_rd16(p + 8);
it.method = wq_rd16(p + 10);
it.time = wq_rd16(p + 12);
it.date = wq_rd16(p + 14);
it.crc = wq_rd32(p + 16);
it.csize = wq_rd32(p + 20);
it.usize = wq_rd32(p + 24);
it.external = wq_rd32(p + 38);
it.offset = wq_rd32(p + 42);
// ZIP64 extra field: the 64-bit values for the fields marked 0xFFFFFFFF, in order.
const uint8_t *x = p + 46 + nameLen, *xEnd = x + extraLen;
while (x + 4 <= xEnd) {
uint16_t xid = wq_rd16(x), xlen = wq_rd16(x + 2);
if (x + 4 + xlen > xEnd) break;
if (xid == 0x0001) {
const uint8_t *v = x + 4, *vEnd = v + xlen;
if (it.usize == kWQZipMax32 && v + 8 <= vEnd) { it.usize = wq_rd64(v); v += 8; }
if (it.csize == kWQZipMax32 && v + 8 <= vEnd) { it.csize = wq_rd64(v); v += 8; }
if (it.offset == kWQZipMax32 && v + 8 <= vEnd) { it.offset = wq_rd64(v); v += 8; }
}
x += 4 + xlen;
}
it.name = wq_zip_decode_name(p + 46, nameLen, it.flags);
if (!it.name) { ok = NO; break; }
items[n] = it;
// Keep the name alive inside the C array.
CFBridgingRetain(it.name);
n++;
pos += 46 + nameLen + extraLen + commentLen;
}
free(cd);
if (!ok) {
for (NSUInteger i = 0; i < n; i++) CFBridgingRelease((__bridge CFTypeRef)items[i].name);
free(items);
*error = WQLocalized(@"The ZIP file is damaged (bad entry)");
return NO;
}
*itemsOut = items;
*countOut = n;
return YES;
}
static void wq_zip_free_items(WQZipItem *items, NSUInteger n)
{
for (NSUInteger i = 0; i < n; i++) CFBridgingRelease((__bridge CFTypeRef)items[i].name);
free(items);
}
static BOOL wq_zip_extract_entry(int fd, uint64_t fileSize, const WQZipItem *it, int out,
unsigned long long *written, unsigned long long maxBytes,
WQArchiveCancelled cancelled, WQArchiveProgress progress, NSString **error)
{
uint8_t lh[30];
if (it->offset + 30 > fileSize || !wq_pread_all(fd, lh, 30, (off_t)it->offset) || wq_rd32(lh) != 0x04034b50) {
*error = WQLocalized(@"The ZIP file is damaged (bad entry header)"); return NO;
}
uint64_t dataPos = it->offset + 30 + wq_rd16(lh + 26) + wq_rd16(lh + 28);
if (dataPos > fileSize || it->csize > fileSize - dataPos) { *error = WQLocalized(@"The ZIP file is damaged (entry out of range)"); return NO; }
uint8_t *inBuf = malloc(kWQZipChunk), *outBuf = malloc(kWQZipChunk);
if (!inBuf || !outBuf) { free(inBuf); free(outBuf); *error = WQLocalized(@"Out of memory"); return NO; }
z_stream z = {0};
BOOL inflating = it->method == 8;
if (inflating && inflateInit2(&z, -MAX_WBITS) != Z_OK) { free(inBuf); free(outBuf); *error = WQLocalized(@"Can't start decompression"); return NO; }
uLong crc = crc32(0, NULL, 0);
uint64_t remaining = it->csize, produced = 0;
off_t readPos = (off_t)dataPos;
BOOL ok = YES, done = it->usize == 0 && it->csize == 0 && !inflating;
while (ok && !done) {
if (cancelled && cancelled()) { *error = @"Cancelled"; ok = NO; break; }
if (z.avail_in == 0 && remaining > 0) {
size_t chunk = (size_t)MIN(remaining, (uint64_t)kWQZipChunk);
if (!wq_pread_all(fd, inBuf, chunk, readPos)) { *error = WQLocalized(@"Can't read the ZIP file"); ok = NO; break; }
readPos += (off_t)chunk; remaining -= chunk;
z.next_in = inBuf; z.avail_in = (uInt)chunk;
}
const uint8_t *data;
size_t have;
if (inflating) {
z.next_out = outBuf; z.avail_out = (uInt)kWQZipChunk;
int rc = inflate(&z, Z_NO_FLUSH);
if (rc == Z_STREAM_END) done = YES;
else if (rc == Z_BUF_ERROR && z.avail_in == 0 && remaining == 0) {
*error = WQLocalized(@"The ZIP file is damaged (data ends early)"); ok = NO; break;
} else if (rc != Z_OK && rc != Z_BUF_ERROR) {
*error = WQLocalized(@"The ZIP file is damaged (bad compressed data)"); ok = NO; break;
}
data = outBuf; have = kWQZipChunk - z.avail_out;
} else {
data = z.next_in; have = z.avail_in;
z.avail_in = 0;
done = remaining == 0;
}
produced += have;
if (produced > it->usize) { *error = WQLocalized(@"The ZIP file is damaged (entry larger than stated)"); ok = NO; break; }
if (*written + have > maxBytes) { *error = WQLocalized(@"Not enough free space to extract this ZIP"); ok = NO; break; }
if (have && !wq_write_fd(out, data, have)) { *error = @(strerror(errno)); ok = NO; break; }
crc = crc32(crc, data, (uInt)have);
*written += have;
if (progress && have) progress(*written);
}
if (inflating) inflateEnd(&z);
free(inBuf); free(outBuf);
if (ok && (produced != it->usize || (uint32_t)crc != it->crc)) {
*error = WQLocalized(@"The ZIP file is damaged (checksum mismatch)"); ok = NO;
}
return ok;
}
BOOL wq_zip_extract(NSString *zipPath, NSString *outDir, WQArchiveIO *io, unsigned long long maxBytes,
WQArchiveCancelled cancelled, WQArchiveProgress progress,
NSUInteger *filesOut, NSUInteger *skippedOut, NSString **errorText)
{
NSString *error = nil;
int fd = io.openRead(zipPath);
if (fd < 0) { if (errorText) *errorText = @(strerror(errno)); return NO; }
struct stat st;
if (fstat(fd, &st) != 0 || !S_ISREG(st.st_mode)) { close(fd); if (errorText) *errorText = WQLocalized(@"Not a regular file"); return NO; }
uint64_t fileSize = (uint64_t)st.st_size;
WQZipItem *items = NULL;
NSUInteger count = 0;
if (!wq_zip_read_directory(fd, fileSize, &items, &count, &error)) {
close(fd); if (errorText) *errorText = error; return NO;
}
// Check everything before writing anything.
unsigned long long declared = 0;
NSMutableArray<NSString *> *safeNames = [NSMutableArray arrayWithCapacity:count];
BOOL ok = YES;
for (NSUInteger i = 0; i < count && ok; i++) {
WQZipItem *it = &items[i];
NSString *safe = wq_zip_safe_entry_path(it->name);
if (!safe) {
ok = NO; error = WQLocalizedFormat(@"The ZIP contains an unsafe name (%@); nothing was extracted.", it->name);
break;
}
[safeNames addObject:safe];
if (it->flags & 0x0001) { ok = NO; error = WQLocalized(@"This ZIP is encrypted, which isn't supported."); break; }
BOOL isDir = [it->name hasSuffix:@"/"] || [it->name hasSuffix:@"\\"];
if (!isDir && it->method != 0 && it->method != 8) {
ok = NO; error = WQLocalizedFormat(@"This ZIP uses a compression method that isn't supported (%u).", it->method);
break;
}
if (it->method == 0 && it->csize != it->usize) { ok = NO; error = WQLocalized(@"The ZIP file is damaged (bad sizes)"); break; }
declared += it->usize;
}
if (ok && declared > maxBytes) { ok = NO; error = WQLocalized(@"Not enough free space to extract this ZIP"); }
NSUInteger files = 0, skipped = 0;
unsigned long long written = 0;
NSFileManager *fm = NSFileManager.defaultManager;
for (NSUInteger i = 0; i < count && ok; i++) {
WQZipItem *it = &items[i];
NSString *rel = safeNames[i];
if ([rel isEqualToString:@"__MACOSX"] || [rel hasPrefix:@"__MACOSX/"]) continue;
BOOL unixMade = (it->madeBy >> 8) == 3;
mode_t mode = unixMade ? (mode_t)(it->external >> 16) : 0;
if (unixMade && S_ISLNK(mode)) { skipped++; continue; }
BOOL isDir = [it->name hasSuffix:@"/"] || [it->name hasSuffix:@"\\"] || (unixMade && S_ISDIR(mode)) || (it->external & 0x10);
NSString *target = [outDir stringByAppendingPathComponent:rel];
if (isDir) {
if (![fm createDirectoryAtPath:target withIntermediateDirectories:YES attributes:nil error:nil]) {
ok = NO; error = WQLocalizedFormat(@"Can't create the folder %@", rel);
}
continue;
}
NSString *parent = target.stringByDeletingLastPathComponent;
if (![fm createDirectoryAtPath:parent withIntermediateDirectories:YES attributes:nil error:nil]) {
ok = NO; error = WQLocalizedFormat(@"Can't create the folder for %@", rel); break;
}
int out = open(target.fileSystemRepresentation, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, 0644);
if (out < 0) {
ok = NO;
error = errno == EEXIST ? WQLocalizedFormat(@"The ZIP contains %@ twice", rel)
: WQLocalizedFormat(@"Can't create %@ (%s)", rel, strerror(errno));
break;
}
NSString *entryError = nil;
ok = wq_zip_extract_entry(fd, fileSize, it, out, &written, maxBytes, cancelled, progress, &entryError);
if (ok) {
time_t when = wq_from_dos_time(it->time, it->date);
if (when > 0) {
struct timeval tv[2] = { { when, 0 }, { when, 0 } };
futimes(out, tv);
}
}
if (close(out) != 0 && ok) { ok = NO; entryError = @(strerror(errno)); }
if (!ok) error = entryError ?: WQLocalized(@"Extraction failed");
else files++;
}
close(fd);
wq_zip_free_items(items, count);
if (filesOut) *filesOut = files;
if (skippedOut) *skippedOut = skipped;
if (!ok && errorText) *errorText = error ?: WQLocalized(@"Extraction failed");
return ok;
}
+4
View File
@@ -0,0 +1,4 @@
#import <UIKit/UIKit.h>
@interface WQCacheCleanerViewController : UITableViewController <UISearchResultsUpdating>
@end
+933
View File
@@ -0,0 +1,933 @@
//
// WQCache.m
// WQ Files cache cleaner
// GPL-3.0-or-later.
//
#import "WQCache.h"
#import "WQDir.h"
#import "WQUI.h"
#import "WQAccess.h"
#import "WQLaunchServices.h"
#import "WQCacheSafety.h"
#import <errno.h>
#import <sys/stat.h>
#pragma mark - Cache cleaner
static NSString *const kCrashLogsPath =
@"/private/var/containers/Shared/SystemGroup/systemgroup.com.apple.osanalytics/Library/Logs/CrashReporter";
@interface WQCacheTargetRecord : NSObject
@property (nonatomic, copy) NSString *path;
@property (nonatomic, copy) NSString *relativePath;
@property (nonatomic) BOOL existed;
@property (nonatomic) BOOL identityKnown;
@property (nonatomic) dev_t device;
@property (nonatomic) ino_t inode;
@property (nonatomic) mode_t mode;
@end
@implementation WQCacheTargetRecord
@end
@interface WQCacheItem : NSObject
@property (nonatomic, copy) NSString *name; // app name or "Crash Logs"
@property (nonatomic, copy) NSString *bundleID; // for the icon; nil for logs
@property (nonatomic, copy) NSArray<NSString *> *targets; // dirs to purge
@property (nonatomic) unsigned long long cacheBytes;
@property (nonatomic) unsigned long long tempBytes;
@property (nonatomic) BOOL cacheKnown;
@property (nonatomic) BOOL tempKnown;
@property (nonatomic) BOOL limited;
@property (nonatomic) BOOL crashLogs;
@property (nonatomic, copy) NSString *containerPath;
@property (nonatomic) BOOL containerIdentityKnown;
@property (nonatomic) dev_t containerDevice;
@property (nonatomic) ino_t containerInode;
@property (nonatomic) mode_t containerMode;
@property (nonatomic, copy) NSArray<WQCacheTargetRecord *> *targetRecords;
@property (nonatomic) BOOL selected;
@property (nonatomic, readonly) unsigned long long bytes;
@end
@implementation WQCacheItem
- (unsigned long long)bytes { return self.cacheBytes + self.tempBytes; }
@end
static void WQCacheSelectionCategories(NSArray<WQCacheItem *> *items,
BOOL *hasApps,
BOOL *hasCrashLogs,
NSUInteger *appCount)
{
BOOL apps = NO, crashLogs = NO;
NSUInteger count = 0;
for (WQCacheItem *item in items) {
if (item.crashLogs) {
crashLogs = YES;
} else {
apps = YES;
count++;
}
}
if (hasApps) *hasApps = apps;
if (hasCrashLogs) *hasCrashLogs = crashLogs;
if (appCount) *appCount = count;
}
static NSString *WQCacheConfirmationScope(NSArray<WQCacheItem *> *items)
{
BOOL hasApps = NO, hasCrashLogs = NO;
NSUInteger appCount = 0;
WQCacheSelectionCategories(items, &hasApps, &hasCrashLogs, &appCount);
if (hasApps && hasCrashLogs) {
return WQLocalizedFormat(appCount == 1
? @"Delete Library/Caches and tmp for %lu app, plus Crash Logs"
: @"Delete Library/Caches and tmp for %lu apps, plus Crash Logs",
(unsigned long)appCount);
}
if (hasApps) {
return WQLocalizedFormat(appCount == 1
? @"Delete Library/Caches and tmp for %lu app"
: @"Delete Library/Caches and tmp for %lu apps",
(unsigned long)appCount);
}
if (hasCrashLogs) return WQLocalized(@"Delete Crash Logs");
return WQLocalized(@"Delete the selected cache targets");
}
static NSString *WQCacheFooterScope(NSArray<WQCacheItem *> *items)
{
BOOL hasApps = NO, hasCrashLogs = NO;
WQCacheSelectionCategories(items, &hasApps, &hasCrashLogs, NULL);
if (hasApps && hasCrashLogs) {
return WQLocalized(@"Clears each selected app's Library/Caches and tmp, and deletes selected Crash Logs. Apps rebuild their caches as you use them; some keep offline content (music, maps, downloads) there and will download it again. No backup is kept.");
}
if (hasApps) {
return WQLocalized(@"Clears each selected app's Library/Caches and tmp. Apps rebuild their caches as you use them; some keep offline content (music, maps, downloads) there and will download it again. No backup is kept.");
}
if (hasCrashLogs) return WQLocalized(@"Deletes selected Crash Logs. This cannot be undone. No backup is kept.");
return WQLocalized(@"Select app caches or Crash Logs to see the deletion scope. No backup is kept.");
}
typedef NS_ENUM(NSInteger, WQCacheMeasurementState) {
WQCacheMeasurementMissing,
WQCacheMeasurementKnown,
WQCacheMeasurementLimited,
WQCacheMeasurementCancelled,
};
static WQCacheMeasurementState WQCacheMeasureDirectory(NSString *path,
WQScanCancellation *cancellation,
unsigned long long *bytes)
{
unsigned long long total = 0;
WQMeasureResult result = wq_measure_tree(path, cancellation, &total);
switch (result) {
case WQMeasureResultMissing:
return WQCacheMeasurementMissing;
case WQMeasureResultKnown:
if (bytes) *bytes = total;
return WQCacheMeasurementKnown;
case WQMeasureResultCancelled:
return WQCacheMeasurementCancelled;
case WQMeasureResultLimited:
wq_log(@"cache scan: limited tree measurement for %@", path);
return WQCacheMeasurementLimited;
}
return WQCacheMeasurementLimited;
}
typedef NS_ENUM(NSInteger, WQCachePathState) {
WQCachePathMissing,
WQCachePathExisting,
WQCachePathInaccessible,
};
static WQCachePathState WQCacheInspectPath(NSString *path, struct stat *st)
{
if (wq_stat(path, st)) return WQCachePathExisting;
struct stat direct = {0};
if (lstat(path.fileSystemRepresentation, &direct) == 0) {
if (st) *st = direct;
return WQCachePathExisting;
}
if (errno == ENOENT || errno == ENOTDIR) return WQCachePathMissing;
return WQCachePathInaccessible;
}
static WQCacheTargetRecord *WQCacheTargetRecordForPath(NSString *path, NSString *relativePath)
{
WQCacheTargetRecord *record = [WQCacheTargetRecord new];
record.path = path;
record.relativePath = relativePath;
struct stat st = {0};
WQCachePathState state = WQCacheInspectPath(path, &st);
if (state == WQCachePathExisting) {
record.existed = YES;
record.identityKnown = YES;
record.device = st.st_dev;
record.inode = st.st_ino;
record.mode = st.st_mode & S_IFMT;
}
return record;
}
static void WQCacheCaptureContainerIdentity(WQCacheItem *item)
{
struct stat st = {0};
if (item.containerPath.length && wq_stat(item.containerPath, &st) && S_ISDIR(st.st_mode)) {
item.containerIdentityKnown = YES;
item.containerDevice = st.st_dev;
item.containerInode = st.st_ino;
item.containerMode = st.st_mode & S_IFMT;
}
}
static BOOL WQCacheRecordIdentityMatches(WQCacheTargetRecord *record, NSString **reason)
{
struct stat st = {0};
WQCachePathState state = WQCacheInspectPath(record.path, &st);
if (!record.existed) {
if (state == WQCachePathMissing) return YES;
if (reason) *reason = [NSString stringWithFormat:@"%@ appeared after the scan", record.path];
return NO;
}
if (state != WQCachePathExisting || !record.identityKnown ||
st.st_dev != record.device || st.st_ino != record.inode ||
(st.st_mode & S_IFMT) != record.mode) {
if (reason) *reason = [NSString stringWithFormat:@"%@ changed after the scan", record.path];
return NO;
}
return YES;
}
static BOOL WQCacheCrashPathIsSafe(NSString *path)
{
NSString *expected = [kCrashLogsPath stringByStandardizingPath];
return [[path stringByStandardizingPath] isEqualToString:expected] &&
WQCacheSafetyHasNoSymlinkAncestry(path, YES) &&
[WQCacheSafetyCanonicalPath(path) isEqualToString:expected];
}
static BOOL WQCacheValidateItem(WQCacheItem *item, NSString **reason)
{
if (item.limited) {
if (reason) *reason = WQLocalized(@"the scan had limited access");
return NO;
}
if (item.crashLogs) {
if (item.targetRecords.count != 1 || !WQCacheCrashPathIsSafe(item.targets.firstObject)) {
if (reason) *reason = WQLocalized(@"the crash-log target is not the expected canonical directory");
return NO;
}
return WQCacheRecordIdentityMatches(item.targetRecords.firstObject, reason);
}
if (item.targetRecords.count != 2 || item.targets.count != 2 ||
![item.targets[0] isEqualToString:item.targetRecords[0].path] ||
![item.targets[1] isEqualToString:item.targetRecords[1].path]) {
if (reason) *reason = WQLocalized(@"the scanned cache targets are incomplete");
return NO;
}
if (!item.containerIdentityKnown || !WQCacheSafetyHasNoSymlinkAncestry(item.containerPath, NO)) {
if (reason) *reason = WQLocalized(@"the app container identity or ancestry is unavailable");
return NO;
}
struct stat container = {0};
if (WQCacheInspectPath(item.containerPath, &container) != WQCachePathExisting ||
!S_ISDIR(container.st_mode) || container.st_dev != item.containerDevice ||
container.st_ino != item.containerInode || (container.st_mode & S_IFMT) != item.containerMode) {
if (reason) *reason = [NSString stringWithFormat:@"%@ changed after the scan", item.name];
return NO;
}
NSString *label = wq_container_label(item.containerPath);
if (!item.bundleID.length || ![label isEqualToString:item.bundleID]) {
if (reason) *reason = [NSString stringWithFormat:@"%@ no longer has the scanned container identity", item.name];
return NO;
}
for (WQCacheTargetRecord *record in item.targetRecords) {
if (!WQCacheSafetyTargetMatchesContainer(record.path, item.containerPath, record.relativePath)) {
if (reason) *reason = [NSString stringWithFormat:@"%@ has unsafe path ancestry", record.path];
return NO;
}
if (!WQCacheRecordIdentityMatches(record, reason)) return NO;
}
return YES;
}
// Row: selection circle, icon, name with "cache · temp" below, size in a
// fixed right-hand column so long names never push it out.
@interface WQCleanerCell : UITableViewCell
@property (nonatomic, strong) UIImageView *checkView, *iconView;
@property (nonatomic, strong) UILabel *nameLabel, *detailLabel, *sizeLabel;
@end
@implementation WQCleanerCell
- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier
{
if ((self = [super initWithStyle:style reuseIdentifier:reuseIdentifier])) {
_checkView = [UIImageView new];
_checkView.contentMode = UIViewContentModeScaleAspectFit;
_iconView = [UIImageView new];
_iconView.contentMode = UIViewContentModeScaleAspectFit;
_iconView.layer.cornerRadius = 8;
_iconView.layer.cornerCurve = kCACornerCurveContinuous;
_iconView.clipsToBounds = YES;
_nameLabel = [UILabel new];
_nameLabel.font = [UIFont preferredFontForTextStyle:UIFontTextStyleBody];
_nameLabel.lineBreakMode = NSLineBreakByTruncatingMiddle;
_detailLabel = [UILabel new];
_detailLabel.font = [UIFont preferredFontForTextStyle:UIFontTextStyleFootnote];
_detailLabel.textColor = UIColor.secondaryLabelColor;
_detailLabel.lineBreakMode = NSLineBreakByTruncatingMiddle;
_sizeLabel = [UILabel new];
_sizeLabel.font = [UIFont monospacedDigitSystemFontOfSize:15 weight:UIFontWeightSemibold];
// Trailing edge in both layout directions.
_sizeLabel.textAlignment = [UIView userInterfaceLayoutDirectionForSemanticContentAttribute:self.semanticContentAttribute]
== UIUserInterfaceLayoutDirectionRightToLeft ? NSTextAlignmentLeft : NSTextAlignmentRight;
for (UIView *v in @[_checkView, _iconView, _nameLabel, _detailLabel, _sizeLabel]) {
v.translatesAutoresizingMaskIntoConstraints = NO;
[self.contentView addSubview:v];
}
[_sizeLabel setContentCompressionResistancePriority:UILayoutPriorityRequired forAxis:UILayoutConstraintAxisHorizontal];
[_sizeLabel setContentHuggingPriority:UILayoutPriorityRequired forAxis:UILayoutConstraintAxisHorizontal];
UILayoutGuide *m = self.contentView.layoutMarginsGuide;
[NSLayoutConstraint activateConstraints:@[
[_checkView.leadingAnchor constraintEqualToAnchor:m.leadingAnchor],
[_checkView.centerYAnchor constraintEqualToAnchor:self.contentView.centerYAnchor],
[_checkView.widthAnchor constraintEqualToConstant:24],
[_checkView.heightAnchor constraintEqualToConstant:24],
[_iconView.leadingAnchor constraintEqualToAnchor:_checkView.trailingAnchor constant:12],
[_iconView.centerYAnchor constraintEqualToAnchor:self.contentView.centerYAnchor],
[_iconView.widthAnchor constraintEqualToConstant:36],
[_iconView.heightAnchor constraintEqualToConstant:36],
[_sizeLabel.trailingAnchor constraintEqualToAnchor:m.trailingAnchor],
[_sizeLabel.centerYAnchor constraintEqualToAnchor:self.contentView.centerYAnchor],
[_sizeLabel.widthAnchor constraintGreaterThanOrEqualToConstant:76],
[_nameLabel.leadingAnchor constraintEqualToAnchor:_iconView.trailingAnchor constant:12],
[_nameLabel.trailingAnchor constraintLessThanOrEqualToAnchor:_sizeLabel.leadingAnchor constant:-8],
[_nameLabel.topAnchor constraintEqualToAnchor:self.contentView.topAnchor constant:11],
[_detailLabel.leadingAnchor constraintEqualToAnchor:_nameLabel.leadingAnchor],
[_detailLabel.trailingAnchor constraintLessThanOrEqualToAnchor:_sizeLabel.leadingAnchor constant:-8],
[_detailLabel.topAnchor constraintEqualToAnchor:_nameLabel.bottomAnchor constant:2],
[_detailLabel.bottomAnchor constraintEqualToAnchor:self.contentView.bottomAnchor constant:-11],
]];
}
return self;
}
@end
// Summary card: reclaimable total, a bar for the selected share, status.
@interface WQSummaryCell : UITableViewCell
@property (nonatomic, strong) UILabel *captionLabel, *totalLabel, *statusLabel;
@property (nonatomic, strong) UIProgressView *bar;
@property (nonatomic, strong) UIActivityIndicatorView *spinner;
@end
@implementation WQSummaryCell
- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier
{
if ((self = [super initWithStyle:style reuseIdentifier:reuseIdentifier])) {
self.selectionStyle = UITableViewCellSelectionStyleNone;
_captionLabel = [UILabel new];
_captionLabel.font = [UIFont preferredFontForTextStyle:UIFontTextStyleSubheadline];
_captionLabel.textColor = UIColor.secondaryLabelColor;
_totalLabel = [UILabel new];
_totalLabel.font = [[UIFontMetrics metricsForTextStyle:UIFontTextStyleLargeTitle]
scaledFontForFont:[UIFont systemFontOfSize:34 weight:UIFontWeightBold]];
_totalLabel.adjustsFontForContentSizeCategory = YES;
_bar = [[UIProgressView alloc] initWithProgressViewStyle:UIProgressViewStyleDefault];
_bar.progressTintColor = UIColor.systemBlueColor;
_statusLabel = [UILabel new];
_statusLabel.font = [UIFont preferredFontForTextStyle:UIFontTextStyleFootnote];
_statusLabel.textColor = UIColor.secondaryLabelColor;
_statusLabel.numberOfLines = 0;
_spinner = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleMedium];
_spinner.hidesWhenStopped = YES;
UIStackView *captionRow = [[UIStackView alloc] initWithArrangedSubviews:@[_captionLabel, _spinner]];
captionRow.spacing = 8;
captionRow.alignment = UIStackViewAlignmentCenter;
UIStackView *stack = [[UIStackView alloc] initWithArrangedSubviews:@[captionRow, _totalLabel, _bar, _statusLabel]];
stack.axis = UILayoutConstraintAxisVertical;
stack.spacing = 6;
[stack setCustomSpacing:10 afterView:_totalLabel];
stack.translatesAutoresizingMaskIntoConstraints = NO;
[self.contentView addSubview:stack];
UILayoutGuide *m = self.contentView.layoutMarginsGuide;
[NSLayoutConstraint activateConstraints:@[
[stack.leadingAnchor constraintEqualToAnchor:m.leadingAnchor],
[stack.trailingAnchor constraintEqualToAnchor:m.trailingAnchor],
[stack.topAnchor constraintEqualToAnchor:self.contentView.topAnchor constant:14],
[stack.bottomAnchor constraintEqualToAnchor:self.contentView.bottomAnchor constant:-14],
]];
}
return self;
}
@end
@implementation WQCacheCleanerViewController {
NSArray<WQCacheItem *> *_items;
NSArray<WQCacheItem *> *_visibleItems;
NSString *_query;
NSString *_status; // scan / clean progress, nil when idle
NSString *_result; // last clean result
BOOL _scanning, _cleaning, _scanLimited, _cleanRequiresRescan;
WQScanCancellation *_scanCancellation;
WQScanCancellation *_cleanCancellation;
NSUInteger _scanGeneration;
NSTimeInterval _lastScanFinished;
}
- (instancetype)init { return [super initWithStyle:UITableViewStyleInsetGrouped]; }
- (void)viewDidLoad
{
[super viewDidLoad];
self.title = WQLocalizedNullable(@"Cache Cleaner");
self.tableView.tableHeaderView = wq_device_header(self.view.bounds.size.width);
UISearchController *search = [[UISearchController alloc] initWithSearchResultsController:nil];
search.searchResultsUpdater = self;
search.obscuresBackgroundDuringPresentation = NO;
search.searchBar.placeholder = WQLocalizedNullable(@"Search apps");
self.navigationItem.searchController = search;
self.navigationItem.hidesSearchBarWhenScrolling = NO;
self.definesPresentationContext = YES;
self.refreshControl = [UIRefreshControl new];
[self.refreshControl addTarget:self action:@selector(userRefresh) forControlEvents:UIControlEventValueChanged];
[self scan];
}
- (void)viewWillAppear:(BOOL)animated
{
[super viewWillAppear:animated];
if (_scanning && _scanCancellation.isCancelled) {
[self scan];
return;
}
// The screen is kept between visits: rescan on its own only when the
// list is old; pull to refresh rescans any time.
if (!_scanning && !_cleaning && _lastScanFinished > 0 &&
NSDate.date.timeIntervalSinceReferenceDate - _lastScanFinished > 600.0) {
[self scan];
}
}
- (void)viewWillDisappear:(BOOL)animated
{
[super viewWillDisappear:animated];
if (_scanning) [_scanCancellation cancel];
if (_cleaning) [_cleanCancellation cancel];
[self.refreshControl endRefreshing];
}
- (NSArray<WQCacheItem *> *)visibleItems
{
return _visibleItems ?: @[];
}
- (void)rebuildVisibleItems
{
if (!_query.length) {
_visibleItems = _items;
return;
}
_visibleItems = [_items filteredArrayUsingPredicate:[NSPredicate predicateWithBlock:^BOOL(WQCacheItem *it, NSDictionary *__unused b) {
return [it.name localizedCaseInsensitiveContainsString:self->_query] ||
(it.bundleID && [it.bundleID localizedCaseInsensitiveContainsString:self->_query]);
}]];
}
- (void)updateSearchResultsForSearchController:(UISearchController *)searchController
{
_query = [searchController.searchBar.text stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceCharacterSet];
[self rebuildVisibleItems];
[self.tableView reloadData];
[self updateButtons];
}
- (void)setStatus:(NSString *)status
{
_status = status;
[self.tableView reloadSections:[NSIndexSet indexSetWithIndex:0] withRowAnimation:UITableViewRowAnimationNone];
}
- (void)updateButtons
{
if (_scanning) {
self.navigationItem.rightBarButtonItems = nil;
return;
}
if (_cleaning) {
UIBarButtonItem *stop = [[UIBarButtonItem alloc] initWithTitle:WQLocalizedNullable(@"Stop") style:UIBarButtonItemStylePlain
target:self action:@selector(stopClean)];
self.navigationItem.rightBarButtonItems = @[stop];
return;
}
NSUInteger n = 0;
for (WQCacheItem *it in _items) if (it.selected) n++;
UIBarButtonItem *clean = [[UIBarButtonItem alloc] initWithTitle:WQLocalizedNullable(@"Clean") style:wq_primary_button_style()
target:self action:@selector(confirmClean)];
clean.enabled = n > 0 && !_cleanRequiresRescan;
NSArray *visible = self.visibleItems;
BOOL allVisible = visible.count > 0;
for (WQCacheItem *it in visible) if (it.limited || !it.selected) { allVisible = NO; break; }
__weak typeof(self) weakSelf = self;
UIAction *__unused all = [UIAction actionWithTitle:WQLocalizedNullable(@"Select All") image:[UIImage systemImageNamed:@"checkmark.circle"]
identifier:nil handler:^(UIAction *__unused a) { [weakSelf selectWhere:^BOOL(WQCacheItem *__unused it) { return YES; }]; }];
UIAction *none = [UIAction actionWithTitle:WQLocalizedNullable(@"Select None") image:[UIImage systemImageNamed:@"circle"]
identifier:nil handler:^(UIAction *__unused a) { [weakSelf selectWhere:^BOOL(WQCacheItem *__unused it) { return NO; }]; }];
UIAction *large = [UIAction actionWithTitle:WQLocalizedNullable(@"Select Large Caches (50 MB+)") image:[UIImage systemImageNamed:@"arrow.up.circle"]
identifier:nil handler:^(UIAction *__unused a) {
[weakSelf selectWhere:^BOOL(WQCacheItem *__unused it) { return it.bytes >= 50ull * 1000 * 1000; }];
}];
all.state = allVisible ? UIMenuElementStateOn : UIMenuElementStateOff;
UIBarButtonItem *select = [[UIBarButtonItem alloc] initWithTitle:WQLocalizedNullable(@"Select")
menu:[UIMenu menuWithChildren:@[all, none, large]]];
select.enabled = visible.count > 0;
self.navigationItem.rightBarButtonItems = @[clean, select];
}
// Pull to refresh: rescan the containers instead of reusing the shared scan,
// and look for the app name database again if it wasn't found before.
- (void)userRefresh
{
wq_enumerate_invalidate();
wq_display_names_reset_miss();
[self scan];
}
- (void)scan
{
[_scanCancellation cancel];
WQScanCancellation *cancellation = [WQScanCancellation new];
_scanCancellation = cancellation;
NSUInteger generation = ++_scanGeneration;
_scanning = YES;
_scanLimited = NO;
_cleanRequiresRescan = NO;
_result = nil;
[self updateButtons];
[self setStatus:WQLocalizedNullable(@"Finding apps…")];
dispatch_async(wq_io(), ^{
NSMutableArray<WQCacheItem *> *items = [NSMutableArray new];
NSMutableArray<NSString *> *emptyRoots = [NSMutableArray new];
BOOL scanLimited = NO;
NSUInteger failedEnumerations = 0;
NSUInteger limitedContainers = 0;
NSUInteger limitedMeasurements = 0;
NSUInteger missingIdentities = 0;
NSArray *roots = @[@"/private/var/mobile/Containers/Data/Application",
@"/private/var/mobile/Containers/Data/PluginKitPlugin"];
for (NSString *root in roots) {
if (cancellation.isCancelled) return;
NSString *kind = [root.lastPathComponent isEqualToString:@"Application"] ? @"apps" : @"extensions";
NSArray *containers = wq_enumerate(root, 0, cancellation, ^(int64_t done, int64_t total, NSUInteger __unused f) {
dispatch_async(dispatch_get_main_queue(), ^{
if (generation != self->_scanGeneration || cancellation.isCancelled) return;
[self setStatus:WQLocalizedFormat(@"Finding %@… %lld%%", kind, (long long)(done * 100 / total))];
});
});
if (!containers) {
if (cancellation.isCancelled) return;
scanLimited = YES;
failedEnumerations++;
wq_log(@"cache scan: %@ container enumeration failed", kind);
continue;
}
if (!cancellation.isCancelled && !wq_display_names_loaded()) {
dispatch_async(dispatch_get_main_queue(), ^{
if (generation != self->_scanGeneration || cancellation.isCancelled) return;
[self setStatus:WQLocalizedNullable(@"Reading app names…")];
});
wq_load_display_names();
}
if (!containers.count) [emptyRoots addObject:kind];
NSUInteger idx = 0;
for (NSString *c in containers) {
if (cancellation.isCancelled) return;
idx++;
if (idx % 10 == 0 || idx == containers.count) {
dispatch_async(dispatch_get_main_queue(), ^{
if (generation != self->_scanGeneration || cancellation.isCancelled) return;
[self setStatus:WQLocalizedFormat(@"Measuring %@ %lu of %lu…", kind,
(unsigned long)idx, (unsigned long)containers.count)];
});
}
BOOL safeContainer = WQCacheSafetyHasNoSymlinkAncestry(c, NO);
BOOL granted = safeContainer && wq_grant(c); // one token for the whole container
NSString *caches = [c stringByAppendingPathComponent:@"Library/Caches"];
NSString *tmp = [c stringByAppendingPathComponent:@"tmp"];
unsigned long long cacheBytes = 0, tempBytes = 0;
BOOL safeCaches = safeContainer &&
WQCacheSafetyTargetMatchesContainer(caches, c, @"Library/Caches");
BOOL safeTmp = safeContainer &&
WQCacheSafetyTargetMatchesContainer(tmp, c, @"tmp");
WQCacheMeasurementState cacheState = granted && safeCaches
? WQCacheMeasureDirectory(caches, cancellation, &cacheBytes)
: WQCacheMeasurementLimited;
if (cacheState == WQCacheMeasurementCancelled) return;
WQCacheMeasurementState tempState = granted && safeTmp
? WQCacheMeasureDirectory(tmp, cancellation, &tempBytes)
: WQCacheMeasurementLimited;
if (tempState == WQCacheMeasurementCancelled) return;
if (!granted || !safeContainer) {
scanLimited = YES;
limitedContainers++;
wq_log(@"cache scan: %@ (%@) %@", c.lastPathComponent, wq_bundle_id_for_container(c) ?: @"unknown app",
safeContainer ? @"refused access" : @"failed the path safety check");
}
if (cacheState == WQCacheMeasurementLimited || tempState == WQCacheMeasurementLimited) {
scanLimited = YES;
limitedMeasurements++;
}
BOOL cacheKnown = cacheState != WQCacheMeasurementLimited;
BOOL tempKnown = tempState != WQCacheMeasurementLimited;
if (cacheKnown && tempKnown && cacheBytes + tempBytes < 64 * 1024) continue;
WQCacheItem *item = [WQCacheItem new];
NSString *bid = granted ? wq_container_label(c) : nil;
item.bundleID = bid;
if (bid) (void)wq_app_icon(bid); // warm the icon off the main thread
item.name = wq_display_name(bid) ?: bid ?: c.lastPathComponent;
item.targets = @[caches, tmp];
item.cacheBytes = cacheBytes;
item.tempBytes = tempBytes;
item.cacheKnown = cacheKnown;
item.tempKnown = tempKnown;
item.limited = !cacheKnown || !tempKnown;
item.containerPath = c;
item.targetRecords = @[
WQCacheTargetRecordForPath(caches, @"Library/Caches"),
WQCacheTargetRecordForPath(tmp, @"tmp"),
];
WQCacheCaptureContainerIdentity(item);
if (!bid.length || !item.containerIdentityKnown) {
item.limited = YES;
scanLimited = YES;
missingIdentities++;
}
item.selected = !item.limited;
[items addObject:item];
}
}
if (cancellation.isCancelled) return;
unsigned long long crash = 0;
// Crash logs are in a system group, which worse_query reaches on iOS 27
// only. On iOS 26 the section is left out instead of reported as limited.
BOOL crashReachable = wq_system_areas_reachable();
BOOL safeCrashPath = crashReachable && WQCacheCrashPathIsSafe(kCrashLogsPath);
WQCacheMeasurementState crashState = !crashReachable ? WQCacheMeasurementMissing
: safeCrashPath ? WQCacheMeasureDirectory(kCrashLogsPath, cancellation, &crash)
: WQCacheMeasurementLimited;
if (crashState == WQCacheMeasurementCancelled) return;
if (crashState == WQCacheMeasurementLimited) {
scanLimited = YES;
limitedMeasurements++;
}
if (crashState == WQCacheMeasurementLimited || crash > 0) {
WQCacheItem *logs = [WQCacheItem new];
logs.name = WQLocalized(@"Crash Logs");
logs.targets = @[kCrashLogsPath];
logs.cacheBytes = crash;
logs.cacheKnown = crashState != WQCacheMeasurementLimited;
logs.crashLogs = YES;
logs.limited = crashState == WQCacheMeasurementLimited;
logs.targetRecords = @[WQCacheTargetRecordForPath(kCrashLogsPath, nil)];
logs.selected = !logs.limited;
[items addObject:logs];
}
[items sortUsingComparator:^NSComparisonResult(WQCacheItem *a, WQCacheItem *b) {
return a.bytes > b.bytes ? NSOrderedAscending : (a.bytes < b.bytes ? NSOrderedDescending : NSOrderedSame);
}];
NSMutableArray<NSString *> *notes = [NSMutableArray new];
if (failedEnumerations) {
[notes addObject:WQLocalizedFormat(failedEnumerations == 1
? @"%lu container enumeration failed" : @"%lu container enumerations failed",
(unsigned long)failedEnumerations)];
}
if (limitedContainers) {
[notes addObject:WQLocalizedFormat(limitedContainers == 1
? @"%lu container had limited access" : @"%lu containers had limited access",
(unsigned long)limitedContainers)];
}
if (limitedMeasurements) {
[notes addObject:WQLocalizedFormat(limitedMeasurements == 1
? @"%lu cache tree measurement was limited" : @"%lu cache tree measurements were limited",
(unsigned long)limitedMeasurements)];
}
if (missingIdentities) {
[notes addObject:WQLocalizedFormat(missingIdentities == 1
? @"%lu container had no identifier or identity" : @"%lu containers had no identifier or identity",
(unsigned long)missingIdentities)];
}
NSString *note = nil;
if (scanLimited) {
[notes addObject:WQLocalized(@"limited rows are excluded; the remaining rows can still be cleaned")];
note = WQLocalizedFormat(@"Scan incomplete: %@. See Log.", [notes componentsJoinedByString:@"; "]);
} else if (emptyRoots.count) {
note = WQLocalizedFormat(emptyRoots.count == 1 ? @"No %@ container found." : @"No %@ containers found.",
[emptyRoots componentsJoinedByString:@" or "]);
}
dispatch_async(dispatch_get_main_queue(), ^{
if (generation != self->_scanGeneration || cancellation.isCancelled) return;
self->_items = items;
[self rebuildVisibleItems];
self->_scanning = NO;
self->_scanLimited = scanLimited;
self->_status = note;
self->_lastScanFinished = NSDate.date.timeIntervalSinceReferenceDate;
[self.refreshControl endRefreshing];
[self.tableView reloadData];
[self updateButtons];
});
});
}
#pragma mark Table
- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView { return 2; }
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
{
return section == 0 ? 1 : (NSInteger)self.visibleItems.count;
}
- (NSString *)tableView:(UITableView *)tableView titleForHeaderInSection:(NSInteger)section
{
if (section == 0 || !_items.count) return nil;
NSUInteger shown = self.visibleItems.count;
NSUInteger appCount = 0;
for (WQCacheItem *item in _items) if (!item.crashLogs) appCount++;
if (!appCount) return WQLocalized(@"Crash Logs");
return _query.length ? WQLocalizedFormat(@"%lu of %lu", (unsigned long)shown, (unsigned long)_items.count)
: WQLocalizedFormat(@"%lu apps and extensions", (unsigned long)appCount);
}
- (NSString *)tableView:(UITableView *)tableView titleForFooterInSection:(NSInteger)section
{
if (section == 0 || !_items.count) return nil;
NSArray<WQCacheItem *> *selected = [_items filteredArrayUsingPredicate:
[NSPredicate predicateWithFormat:@"selected == YES"]];
return [WQCacheFooterScope(selected) stringByAppendingString:WQLocalized(@" Close running apps first.")];
}
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
if (indexPath.section == 0) {
WQSummaryCell *cell = [tableView dequeueReusableCellWithIdentifier:@"summary"]
?: [[WQSummaryCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"summary"];
unsigned long long total = 0, selected = 0;
NSUInteger n = 0;
for (WQCacheItem *it in _items) { total += it.bytes; if (it.selected) { selected += it.bytes; n++; } }
BOOL busy = _scanning || _cleaning;
cell.captionLabel.text = busy ? (_cleaning ? WQLocalized(@"Cleaning") : WQLocalized(@"Scanning")) : WQLocalized(@"Reclaimable");
if (busy) [cell.spinner startAnimating]; else [cell.spinner stopAnimating];
cell.totalLabel.text = (_scanning && !_items.count) ? @"–" : wq_size_text(total);
cell.bar.progress = total ? (float)((double)selected / (double)total) : 0;
cell.bar.hidden = _scanning && !_items.count;
cell.statusLabel.text = _status ?: _result
?: WQLocalizedFormat(n == 1 ? @"%@ selected in %lu item" : @"%@ selected in %lu items",
wq_size_text(selected), (unsigned long)n);
return cell;
}
WQCleanerCell *cell = [tableView dequeueReusableCellWithIdentifier:@"item"]
?: [[WQCleanerCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"item"];
WQCacheItem *item = self.visibleItems[(NSUInteger)indexPath.row];
cell.nameLabel.text = item.name;
if (item.crashLogs) {
cell.detailLabel.text = item.cacheKnown ? WQLocalized(@"Crash Logs · osanalytics") : WQLocalized(@"Crash Logs · access limited");
} else {
NSString *cache = item.cacheKnown ? wq_size_text(item.cacheBytes) : WQLocalized(@"unknown");
NSString *temp = item.tempKnown ? wq_size_text(item.tempBytes) : WQLocalized(@"unknown");
cell.detailLabel.text = WQLocalizedFormat(@"cache %@ · temp %@%@", cache, temp,
item.limited ? WQLocalized(@" · access limited") : @"");
}
cell.sizeLabel.text = wq_size_text(item.bytes);
cell.iconView.image = wq_app_icon(item.bundleID)
?: [UIImage systemImageNamed:item.bundleID ? @"app.dashed" : @"exclamationmark.triangle"];
cell.iconView.tintColor = UIColor.secondaryLabelColor;
cell.checkView.image = [UIImage systemImageNamed:item.selected ? @"checkmark.circle.fill" : @"circle"];
cell.checkView.tintColor = item.selected ? UIColor.systemBlueColor : UIColor.tertiaryLabelColor;
return cell;
}
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath
{
[tableView deselectRowAtIndexPath:indexPath animated:YES];
if (indexPath.section == 0 || _scanning || _cleaning) return;
WQCacheItem *item = self.visibleItems[(NSUInteger)indexPath.row];
if (item.limited) {
wq_alert(self, @"Not cleanable",
@"This row has incomplete access or identity information. Scan again while the app is closed, or use its file browser entry to inspect it.");
return;
}
item.selected = !item.selected;
[tableView reloadRowsAtIndexPaths:@[indexPath, [NSIndexPath indexPathForRow:0 inSection:0]]
withRowAnimation:UITableViewRowAnimationNone];
[self updateButtons];
}
- (void)openFolder:(NSString *)path title:(NSString *)title
{
[self.navigationController pushViewController:[[WQDirViewController alloc] initWithPath:path title:title] animated:YES];
}
- (UIContextMenuConfiguration *)tableView:(UITableView *)tableView
contextMenuConfigurationForRowAtIndexPath:(NSIndexPath *)indexPath point:(CGPoint)point
{
// Not during a scan or clean: the browser would show a tree that changes underneath it.
if (indexPath.section == 0 || _scanning || _cleaning) return nil;
WQCacheItem *item = self.visibleItems[(NSUInteger)indexPath.row];
__weak typeof(self) weakSelf = self;
return [UIContextMenuConfiguration configurationWithIdentifier:nil previewProvider:nil
actionProvider:^UIMenu *(NSArray<UIMenuElement *> *__unused suggested) {
NSMutableArray *actions = [NSMutableArray new];
if (item.crashLogs) {
NSString *root = item.targets.firstObject;
[actions addObject:[UIAction actionWithTitle:WQLocalizedNullable(@"Show Crash Logs in File Browser") image:[UIImage systemImageNamed:@"folder"]
identifier:nil handler:^(UIAction *__unused a) { [weakSelf openFolder:root title:WQLocalizedNullable(@"Crash Logs")]; }]];
} else {
NSString *caches = item.targets[0], *tmp = item.targets[1];
[actions addObject:[UIAction actionWithTitle:WQLocalizedNullable(@"Show Caches in File Browser") image:[UIImage systemImageNamed:@"folder"]
identifier:nil handler:^(UIAction *__unused a) {
[weakSelf openFolder:caches title:[item.name stringByAppendingString:WQLocalized(@" · Caches")]];
}]];
if (item.tempBytes > 0)
[actions addObject:[UIAction actionWithTitle:WQLocalizedNullable(@"Show tmp in File Browser") image:[UIImage systemImageNamed:@"folder"]
identifier:nil handler:^(UIAction *__unused a) {
[weakSelf openFolder:tmp title:[item.name stringByAppendingString:WQLocalized(@" · tmp")]];
}]];
[actions addObject:[UIAction actionWithTitle:WQLocalizedNullable(@"Show App Data in File Browser") image:[UIImage systemImageNamed:@"folder.badge.gearshape"]
identifier:nil handler:^(UIAction *__unused a) {
[weakSelf openFolder:caches.stringByDeletingLastPathComponent.stringByDeletingLastPathComponent title:item.name];
}]];
}
return [UIMenu menuWithTitle:item.name children:actions];
}];
}
#pragma mark Actions
// Applies to the rows the current search shows.
- (void)selectWhere:(BOOL (^)(WQCacheItem *item))rule
{
for (WQCacheItem *it in self.visibleItems) it.selected = !it.limited && rule(it);
[self.tableView reloadData];
[self updateButtons];
}
- (void)stopClean
{
[_cleanCancellation cancel];
[self setStatus:WQLocalizedNullable(@"Stopping after the current item…")];
}
- (void)confirmClean
{
if (wq_refuse_if_deleting_off(self)) return;
NSMutableArray<WQCacheItem *> *selected = [NSMutableArray new];
unsigned long long sel = 0;
for (WQCacheItem *item in _items) {
if (!item.selected) continue;
[selected addObject:item];
sel += item.bytes;
}
if (!selected.count) return;
NSString *msg = WQLocalizedFormat(@"%@, about %@? This cannot be undone. Close the selected apps first; you can stop between items.", WQCacheConfirmationScope(selected), wq_size_text(sel));
UIAlertController *alert = wq_alert_controller(@"Clean caches?", msg, UIAlertControllerStyleAlert);
[alert addAction:wq_alert_action(@"Cancel", UIAlertActionStyleCancel, nil)];
[alert addAction:wq_alert_action(@"Clean", UIAlertActionStyleDestructive, ^(UIAlertAction *__unused a) {
[self clean];
})];
[self presentViewController:alert animated:YES completion:nil];
}
- (void)clean
{
NSArray<WQCacheItem *> *selected = [_items filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"selected == YES"]];
WQScanCancellation *cancellation = [WQScanCancellation new];
_cleanCancellation = cancellation;
_cleaning = YES;
[self updateButtons];
[self setStatus:WQLocalizedNullable(@"Cleaning…")];
dispatch_async(wq_io(), ^{
unsigned long long freed = 0;
BOOL allOk = YES;
BOOL cancelled = NO;
NSString *validationFailure = nil;
for (WQCacheItem *item in selected) {
if (cancellation.isCancelled) { cancelled = YES; break; }
NSString *reason = nil;
if (!WQCacheValidateItem(item, &reason)) {
allOk = NO;
validationFailure = validationFailure ?: [NSString stringWithFormat:@"%@ (%@)", item.name, reason ?: @"identity changed"];
wq_log(@"clean: refusing %@ because %@", item.name, reason ?: @"identity validation failed");
}
}
if (validationFailure) {
dispatch_async(dispatch_get_main_queue(), ^{
self->_cleaning = NO;
self->_cleanCancellation = nil;
self->_scanLimited = YES;
self->_cleanRequiresRescan = YES;
self->_status = nil;
self->_result = WQLocalizedFormat(@"Clean cancelled: %@. Scan again before retrying.", validationFailure);
[self.tableView reloadData];
[self updateButtons];
});
return;
}
NSUInteger i = 0;
for (WQCacheItem *item in selected) {
if (cancellation.isCancelled) { cancelled = YES; break; }
i++;
dispatch_async(dispatch_get_main_queue(), ^{
[self setStatus:WQLocalizedFormat(@"Cleaning %lu of %lu: %@", (unsigned long)i, (unsigned long)selected.count, item.name)];
});
for (WQCacheTargetRecord *record in item.targetRecords) {
if (cancellation.isCancelled) { cancelled = YES; break; }
NSString *reason = nil;
if (!WQCacheValidateItem(item, &reason)) {
allOk = NO;
validationFailure = [NSString stringWithFormat:@"%@ (%@)", item.name, reason ?: @"identity changed"];
wq_log(@"clean: stopped before %@ because %@", record.path, reason ?: @"identity validation failed");
break;
}
if (!wq_purge_contents_cancellable(record.path, &freed, cancellation)) {
allOk = NO;
if (cancellation.isCancelled) { cancelled = YES; break; }
}
}
if (validationFailure || cancelled) break;
}
wq_log(@"clean: freed %llu bytes, allOk=%d cancelled=%d", freed, allOk, cancelled);
dispatch_async(dispatch_get_main_queue(), ^{
self->_cleaning = NO;
self->_cleanCancellation = nil;
if (cancelled) {
self->_status = nil;
[self scan];
self->_result = WQLocalizedFormat(@"Stopped. Freed %@.%@", wq_size_text(freed),
allOk ? @"" : WQLocalized(@" Some items could not be removed (see Log)."));
return;
}
if (validationFailure) {
self->_scanLimited = YES;
self->_cleanRequiresRescan = YES;
self->_status = nil;
self->_result = WQLocalizedFormat(@"Clean stopped: %@. Scan again before retrying.", validationFailure);
[self.tableView reloadData];
[self updateButtons];
} else {
self->_status = nil;
[self scan];
self->_result = WQLocalizedFormat(@"Freed %@.%@", wq_size_text(freed),
allOk ? @"" : WQLocalized(@" Some items could not be removed (see Log)."));
}
});
});
}
@end
+78
View File
@@ -0,0 +1,78 @@
#ifndef WQ_CACHE_SAFETY_H
#define WQ_CACHE_SAFETY_H
#import <Foundation/Foundation.h>
#import <errno.h>
#import <limits.h>
#import <stdlib.h>
#import <sys/stat.h>
// Checks every existing component, including the final component when present,
// without following links. A missing final component is allowed for scan-time
// snapshots, but a missing ancestor is never accepted.
static inline BOOL WQCacheSafetyHasNoSymlinkAncestry(NSString *path, BOOL allowMissingFinal)
{
if (!path.isAbsolutePath || !path.length) return NO;
NSArray<NSString *> *components = path.pathComponents;
if (components.count < 2 || ![components.firstObject isEqualToString:@"/"]) return NO;
NSString *prefix = @"/";
for (NSUInteger i = 1; i < components.count; i++) {
if ([components[i] isEqualToString:@"."] || [components[i] isEqualToString:@".."]) return NO;
prefix = [prefix stringByAppendingPathComponent:components[i]];
struct stat st = {0};
if (lstat(prefix.fileSystemRepresentation, &st) != 0) {
if (allowMissingFinal && i == components.count - 1 && errno == ENOENT) return YES;
return NO;
}
if (S_ISLNK(st.st_mode)) return NO;
}
return YES;
}
// Resolve an existing path with the filesystem's canonical spelling. For a
// missing final component, resolve its existing parent and append the final
// component without following it. realpath() is intentional here: on macOS,
// Foundation may retain /var while the filesystem's no-follow spelling is
// /private/var.
static inline NSString *WQCacheSafetyCanonicalPath(NSString *path)
{
NSString *standard = [path stringByStandardizingPath];
char resolved[PATH_MAX];
if (realpath(standard.fileSystemRepresentation, resolved)) {
return [NSString stringWithUTF8String:resolved] ?: standard;
}
NSString *parent = [standard stringByDeletingLastPathComponent];
NSString *last = standard.lastPathComponent;
if (realpath(parent.fileSystemRepresentation, resolved)) {
NSString *resolvedParent = [NSString stringWithUTF8String:resolved];
return [resolvedParent stringByAppendingPathComponent:last];
}
return standard;
}
// The lexical shape, no-follow ancestry, and canonical shape must all agree.
// `relativePath` is the expected target below the canonical container.
static inline BOOL WQCacheSafetyTargetMatchesContainer(NSString *target,
NSString *container,
NSString *relativePath)
{
if (!relativePath.length || relativePath.isAbsolutePath) return NO;
for (NSString *component in relativePath.pathComponents) {
if ([component isEqualToString:@"."] || [component isEqualToString:@".."]) return NO;
}
NSString *lexicalTarget = [target stringByStandardizingPath];
NSString *lexicalExpected = [[container stringByAppendingPathComponent:relativePath]
stringByStandardizingPath];
if (![lexicalTarget isEqualToString:lexicalExpected]) return NO;
if (!WQCacheSafetyHasNoSymlinkAncestry(target, YES)) return NO;
if (!WQCacheSafetyHasNoSymlinkAncestry(container, NO)) return NO;
NSString *canonicalContainer = WQCacheSafetyCanonicalPath(container);
NSString *canonicalTarget = WQCacheSafetyCanonicalPath(target);
NSString *canonicalExpected = [canonicalContainer stringByAppendingPathComponent:relativePath];
return [canonicalTarget isEqualToString:canonicalExpected];
}
#endif
+35
View File
@@ -0,0 +1,35 @@
//
// WQCore.h
// WQ Files
//
// GPL-3.0-or-later.
//
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
// Main-thread edit state shared by all three editors. A successful completion
// clears dirty only when no newer revision was made after beginSave.
@interface WQEditState : NSObject
@property (nonatomic, readonly) NSUInteger revision;
@property (nonatomic, readonly, getter=isDirty) BOOL dirty;
@property (nonatomic, readonly, getter=isSaving) BOOL saving;
- (void)noteChange;
- (NSUInteger)beginSave;
- (void)finishSave:(BOOL)success revision:(NSUInteger)savedRevision;
- (void)discard;
@end
// Parses pairs of ASCII hex digits separated by whitespace. Property-list data
// may also contain angle brackets; the hex editor does not accept them.
NSData *_Nullable WQParseHex(NSString *text, BOOL allowAngleBrackets);
// Overflow-safe validation for an in-place patch range.
BOOL WQPatchRangeValid(unsigned long long offset, NSUInteger length, unsigned long long fileSize);
// Documents and Library are always represented. YES means copy from the
// backup; NO means deliberately purge the current part during restore.
NSDictionary<NSString *, NSNumber *> *WQRestorePlan(BOOL hasDocuments, BOOL hasLibrary);
NS_ASSUME_NONNULL_END
+75
View File
@@ -0,0 +1,75 @@
//
// WQCore.m
// WQ Files
//
// GPL-3.0-or-later.
//
#import "WQCore.h"
@implementation WQEditState
- (void)noteChange
{
_revision++;
_dirty = YES;
}
- (NSUInteger)beginSave
{
_saving = YES;
return _revision;
}
- (void)finishSave:(BOOL)success revision:(NSUInteger)savedRevision
{
_saving = NO;
if (success && _revision == savedRevision) _dirty = NO;
}
- (void)discard
{
_revision++;
_dirty = NO;
}
@end
static BOOL WQHexValue(unichar c, uint8_t *value)
{
if (c >= '0' && c <= '9') *value = (uint8_t)(c - '0');
else if (c >= 'a' && c <= 'f') *value = (uint8_t)(c - 'a' + 10);
else if (c >= 'A' && c <= 'F') *value = (uint8_t)(c - 'A' + 10);
else return NO;
return YES;
}
NSData *WQParseHex(NSString *text, BOOL allowAngleBrackets)
{
NSMutableData *out = [NSMutableData new];
int high = -1;
for (NSUInteger i = 0; i < text.length; i++) {
unichar c = [text characterAtIndex:i];
if ([NSCharacterSet.whitespaceAndNewlineCharacterSet characterIsMember:c] ||
(allowAngleBrackets && (c == '<' || c == '>'))) continue;
uint8_t value = 0;
if (!WQHexValue(c, &value)) return nil;
if (high < 0) high = value;
else {
uint8_t byte = (uint8_t)((high << 4) | value);
[out appendBytes:&byte length:1];
high = -1;
}
}
return high < 0 ? out : nil;
}
BOOL WQPatchRangeValid(unsigned long long offset, NSUInteger length, unsigned long long fileSize)
{
return offset <= fileSize && length <= fileSize - offset;
}
NSDictionary<NSString *, NSNumber *> *WQRestorePlan(BOOL hasDocuments, BOOL hasLibrary)
{
return @{ @"Documents": @(hasDocuments), @"Library": @(hasLibrary) };
}
+15
View File
@@ -0,0 +1,15 @@
//
// WQDir.h
// WQ Files folder browser interface
//
#import <UIKit/UIKit.h>
NS_ASSUME_NONNULL_BEGIN
@interface WQDirViewController : UITableViewController <UIDocumentPickerDelegate, UISearchResultsUpdating>
@property (nonatomic) BOOL readOnly;
- (instancetype)initWithPath:(NSString *)path title:(NSString *_Nullable)title;
@end
NS_ASSUME_NONNULL_END
+20
View File
@@ -0,0 +1,20 @@
//
// WQEditing.h
// WQ Files
//
// GPL-3.0-or-later.
//
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
// Editors implement this so Back, Home and the interactive edge swipe protect
// unsaved changes. Root plist editors prompt for a dirty document; child
// plist editors intentionally keep the shared document's edits.
@protocol WQUnsavedEditing <NSObject>
- (BOOL)wq_needsLeaveConfirmation;
- (void)confirmLeaveThen:(void (^)(void))proceed;
@end
NS_ASSUME_NONNULL_END
+19
View File
@@ -0,0 +1,19 @@
//
// WQHexViewer.h
// WQ Files
//
// Hex viewer and editor for files of any size: reads in pages on demand,
// go to offset, find hex or text, overwrite bytes in place (wq_patch, with a
// backup first). Never changes the file's size.
//
#import <UIKit/UIKit.h>
#import "WQEditing.h"
NS_ASSUME_NONNULL_BEGIN
@interface WQHexViewController : UITableViewController <WQUnsavedEditing>
- (instancetype)initWithPath:(NSString *)path readOnly:(BOOL)readOnly;
@end
NS_ASSUME_NONNULL_END
+526
View File
@@ -0,0 +1,526 @@
//
// WQHexViewer.m
// WQ Files
//
// GPL-3.0-or-later.
//
#import "WQHexViewer.h"
#import "WQHexViewerHelpers.h"
#import "WQAccess.h"
#import "WQCore.h"
#import "WQUI.h"
#import <sys/stat.h>
static const NSUInteger kPageSize = 64 * 1024;
static const NSUInteger kSearchChunk = 1024 * 1024;
static BOOL wqh_is_visible(UIViewController *host)
{
if (!host.isViewLoaded || !host.view.window) return NO;
UINavigationController *navigation = host.navigationController;
return !navigation || navigation.visibleViewController == host;
}
static void wqh_alert(UIViewController *host, NSString *title, NSString *message)
{
dispatch_async(dispatch_get_main_queue(), ^{
if (!wqh_is_visible(host)) return;
UIViewController *presented = host.presentedViewController;
if (presented && ![presented isKindOfClass:UIAlertController.class]) return;
void (^present)(void) = ^{
if (!wqh_is_visible(host) || host.presentedViewController) return;
UIAlertController *a = wq_alert_controller(title, message, UIAlertControllerStyleAlert);
[a addAction:wq_alert_action(@"OK", UIAlertActionStyleDefault, nil)];
[host presentViewController:a animated:YES completion:nil];
};
if (presented) [presented dismissViewControllerAnimated:YES completion:present];
else present();
});
}
@implementation WQHexViewController {
NSString *_path;
BOOL _readOnly;
unsigned long long _size;
NSUInteger _perRow;
int _offsetDigits; // hex digits in the offset column
NSCache<NSNumber *, NSData *> *_pages;
NSMutableSet<NSNumber *> *_loading;
NSMutableDictionary<NSNumber *, NSNumber *> *_edits; // offset -> new byte
WQEditState *_editState;
NSDictionary<NSString *, NSNumber *> *_version;
unsigned long long _matchOffset;
NSUInteger _matchLength;
NSData *_lastSearchNeedle;
NSUInteger _searchGeneration;
NSString *_status;
dispatch_queue_t _queue;
}
- (instancetype)initWithPath:(NSString *)path readOnly:(BOOL)readOnly
{
if ((self = [super initWithStyle:UITableViewStylePlain])) {
_path = [path copy];
_readOnly = readOnly;
_perRow = 8;
_offsetDigits = 8;
_pages = [NSCache new];
_pages.countLimit = 64; // 4 MB
_loading = [NSMutableSet new];
_edits = [NSMutableDictionary new];
_editState = [WQEditState new];
_queue = dispatch_queue_create("wqfiles.hex", DISPATCH_QUEUE_SERIAL);
self.title = path.lastPathComponent;
}
return self;
}
- (void)viewDidLoad
{
[super viewDidLoad];
self.navigationItem.largeTitleDisplayMode = UINavigationItemLargeTitleDisplayModeNever;
// Offsets, hex and ASCII columns read left to right in every language
// (the rows are forced too, in cellForRow).
self.tableView.semanticContentAttribute = UISemanticContentAttributeForceLeftToRight;
self.tableView.separatorStyle = UITableViewCellSeparatorStyleNone;
self.tableView.rowHeight = 20;
__weak typeof(self) weakSelf = self;
self.navigationItem.backAction = [UIAction actionWithHandler:^(UIAction *__unused a) { [weakSelf leave]; }];
NSString *path = _path;
dispatch_async(_queue, ^{
struct stat st;
BOOL ok = wq_stat(path, &st) && S_ISREG(st.st_mode);
NSDictionary *version = ok ? wq_file_version(path) : nil;
dispatch_async(dispatch_get_main_queue(), ^{
self->_version = version;
self->_size = ok ? (unsigned long long)st.st_size : 0;
[self layoutColumns];
if (wq_permission_hint(path, YES)) self->_readOnly = YES;
self->_status = ok ? nil : WQLocalized(@"Cannot open this file.");
[self updateBar];
[self.tableView reloadData];
});
});
[self updateBar];
}
- (void)viewDidDisappear:(BOOL)animated
{
[super viewDidDisappear:animated];
_searchGeneration++;
_lastSearchNeedle = nil;
_matchLength = 0;
_status = nil;
}
- (UIFont *)rowFont
{
return [UIFont monospacedSystemFontOfSize:12 weight:UIFontWeightRegular];
}
// As many bytes per row as fit (in steps of 4), and an offset column only as
// wide as the file needs.
- (void)layoutColumns
{
int digits = 4;
for (unsigned long long last = _size ? _size - 1 : 0; digits < 16 && (last >> (4 * digits)); digits++) {}
CGFloat charWidth = [@"0" sizeWithAttributes:@{ NSFontAttributeName: self.rowFont }].width;
CGFloat avail = self.tableView.bounds.size.width - 16; // 8 pt margins on each side
if (charWidth <= 0 || avail <= 0) return;
NSInteger chars = (NSInteger)floor(avail / charWidth);
// A row is: offset, 2 spaces, "xx " per byte, 1 space, 1 character per byte.
NSInteger fit = (chars - digits - 3) / 4;
NSUInteger per = (NSUInteger)MAX(4, MIN(32, fit - fit % 4));
if (per != _perRow || digits != _offsetDigits) {
_perRow = per;
_offsetDigits = digits;
[self.tableView reloadData];
}
}
- (void)viewWillLayoutSubviews
{
[super viewWillLayoutSubviews];
[self layoutColumns];
}
- (void)updateBar
{
NSString *size = [NSByteCountFormatter stringFromByteCount:(long long)_size countStyle:NSByteCountFormatterCountStyleFile];
NSString *prompt = _status ?: WQLocalizedFormat(@"%@ · %llu bytes%@%@", size, _size,
_edits.count ? WQLocalizedFormat(@" · %lu changed", (unsigned long)_edits.count) : @"",
_editState.saving ? WQLocalized(@" · Saving…") : @"");
self.navigationItem.prompt = prompt;
NSMutableArray *items = [NSMutableArray array];
if (!_readOnly) {
UIBarButtonItem *save = [[UIBarButtonItem alloc] initWithTitle:WQLocalizedNullable(@"Save") style:wq_primary_button_style()
target:self action:@selector(save)];
save.enabled = _edits.count > 0 && !_editState.saving;
[items addObject:save];
}
__weak typeof(self) weakSelf = self;
UIAction *discard = [UIAction actionWithTitle:WQLocalizedNullable(@"Discard Changes") image:[UIImage systemImageNamed:@"arrow.uturn.backward"] identifier:nil
handler:^(UIAction *__unused a) { [weakSelf discard]; }];
if (_editState.saving) discard.attributes = UIMenuElementAttributesDisabled;
UIMenu *menu = [UIMenu menuWithChildren:@[
[UIAction actionWithTitle:WQLocalizedNullable(@"Go to Offset…") image:[UIImage systemImageNamed:@"arrow.right.to.line"] identifier:nil
handler:^(UIAction *__unused a) { [weakSelf goToOffset]; }],
[UIAction actionWithTitle:WQLocalizedNullable(@"Find Hex Bytes…") image:[UIImage systemImageNamed:@"magnifyingglass"] identifier:nil
handler:^(UIAction *__unused a) { [weakSelf promptFindHex:YES]; }],
[UIAction actionWithTitle:WQLocalizedNullable(@"Find Text…") image:[UIImage systemImageNamed:@"text.magnifyingglass"] identifier:nil
handler:^(UIAction *__unused a) { [weakSelf promptFindHex:NO]; }],
discard,
]];
[items addObject:[[UIBarButtonItem alloc] initWithImage:[UIImage systemImageNamed:@"ellipsis.circle"] menu:menu]];
self.navigationItem.rightBarButtonItems = items;
}
#pragma mark Data
// The file byte at `offset` (an edit if there is one), or -1 while its page
// is still being read.
- (int)byteAt:(unsigned long long)offset
{
NSNumber *edit = _edits[@(offset)];
if (edit) return edit.intValue;
return [self originalByteAt:offset];
}
- (int)originalByteAt:(unsigned long long)offset
{
NSNumber *page = @(offset / kPageSize);
NSData *data = [_pages objectForKey:page];
if (!data) { [self loadPage:page]; return -1; }
NSUInteger i = (NSUInteger)(offset % kPageSize);
return i < data.length ? ((const uint8_t *)data.bytes)[i] : -1;
}
- (void)loadPage:(NSNumber *)page
{
if ([_loading containsObject:page]) return;
[_loading addObject:page];
NSString *path = _path;
dispatch_async(_queue, ^{
NSString *error = nil;
NSData *data = wq_read_range(path, page.unsignedLongLongValue * kPageSize, kPageSize, &error);
dispatch_async(dispatch_get_main_queue(), ^{
[self->_loading removeObject:page];
if (!data) {
self->_status = wq_permission_hint(path, NO) ? WQLocalized(@"Only the owner may read this file.") : WQLocalizedFormat(@"Read failed: %@", error);
[self updateBar];
return;
}
[self->_pages setObject:data forKey:page];
[self.tableView reloadRowsAtIndexPaths:self.tableView.indexPathsForVisibleRows
withRowAnimation:UITableViewRowAnimationNone];
});
});
}
#pragma mark Table
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
{
return (NSInteger)((_size + _perRow - 1) / _perRow);
}
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"h"]
?: [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"h"];
cell.semanticContentAttribute = UISemanticContentAttributeForceLeftToRight;
cell.contentView.semanticContentAttribute = UISemanticContentAttributeForceLeftToRight;
UIFont *font = self.rowFont;
unsigned long long start = (unsigned long long)indexPath.row * _perRow;
NSMutableAttributedString *line = [[NSMutableAttributedString alloc]
initWithString:[NSString stringWithFormat:@"%0*llx ", _offsetDigits, start]
attributes:@{ NSFontAttributeName: font, NSForegroundColorAttributeName: UIColor.secondaryLabelColor }];
NSMutableString *ascii = [NSMutableString new];
NSMutableArray<NSValue *> *asciiMarks = [NSMutableArray new];
for (NSUInteger i = 0; i < _perRow; i++) {
unsigned long long off = start + i;
if (off >= _size) { [line appendAttributedString:[[NSAttributedString alloc] initWithString:@" " attributes:@{ NSFontAttributeName: font }]]; continue; }
int b = [self byteAt:off];
UIColor *color = UIColor.labelColor;
BOOL edited = _edits[@(off)] != nil;
BOOL match = _matchLength && off >= _matchOffset && off < _matchOffset + _matchLength;
if (edited) color = UIColor.systemOrangeColor;
NSMutableDictionary *attrs = [@{ NSFontAttributeName: font, NSForegroundColorAttributeName: color } mutableCopy];
if (match) attrs[NSBackgroundColorAttributeName] = [UIColor.systemYellowColor colorWithAlphaComponent:0.45];
NSString *hex = b < 0 ? @"·· " : [NSString stringWithFormat:@"%02x ", b];
[line appendAttributedString:[[NSAttributedString alloc] initWithString:hex attributes:attrs]];
if (edited || match) [asciiMarks addObject:[NSValue valueWithRange:NSMakeRange(ascii.length, 1)]];
[ascii appendFormat:@"%c", (b >= 0x20 && b < 0x7f) ? b : '.'];
}
NSMutableAttributedString *asc = [[NSMutableAttributedString alloc] initWithString:[@" " stringByAppendingString:ascii]
attributes:@{ NSFontAttributeName: font, NSForegroundColorAttributeName: UIColor.secondaryLabelColor }];
for (NSValue *v in asciiMarks) {
NSRange r = v.rangeValue;
[asc addAttribute:NSForegroundColorAttributeName value:UIColor.systemOrangeColor range:NSMakeRange(r.location + 1, 1)];
}
[line appendAttributedString:asc];
UIListContentConfiguration *c = [UIListContentConfiguration cellConfiguration];
c.attributedText = line;
c.textProperties.numberOfLines = 1;
c.directionalLayoutMargins = NSDirectionalEdgeInsetsMake(0, 8, 0, 8);
cell.contentConfiguration = c;
cell.selectionStyle = _readOnly ? UITableViewCellSelectionStyleNone : UITableViewCellSelectionStyleDefault;
return cell;
}
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath
{
[tableView deselectRowAtIndexPath:indexPath animated:YES];
if (_readOnly || _editState.saving) return;
unsigned long long start = (unsigned long long)indexPath.row * _perRow;
NSUInteger count = (NSUInteger)MIN((unsigned long long)_perRow, _size - start);
NSMutableString *current = [NSMutableString new];
for (NSUInteger i = 0; i < count; i++) {
int b = [self byteAt:start + i];
if (b < 0) return; // page not loaded yet
[current appendFormat:i ? @" %02x" : @"%02x", b];
}
UIAlertController *a = wq_alert_controller(WQLocalizedFormat(@"Bytes at 0x%llx", start), WQLocalizedFormat(@"Overwrite these %lu bytes (hex). The file size stays the same.", (unsigned long)count), UIAlertControllerStyleAlert);
[a addTextFieldWithConfigurationHandler:^(UITextField *f) {
f.text = current;
f.font = [UIFont monospacedSystemFontOfSize:14 weight:UIFontWeightRegular];
f.autocapitalizationType = UITextAutocapitalizationTypeNone;
f.autocorrectionType = UITextAutocorrectionTypeNo;
}];
[a addAction:wq_alert_action(@"Cancel", UIAlertActionStyleCancel, nil)];
[a addAction:wq_alert_action(@"Set", UIAlertActionStyleDefault, ^(UIAlertAction *__unused x) {
NSData *bytes = WQParseHex(a.textFields.firstObject.text ?: @"", NO);
if (bytes.length != count) {
wqh_alert(self, @"Not changed", WQLocalizedFormat(@"Enter exactly %lu bytes in hex.", (unsigned long)count));
return;
}
const uint8_t *nb = bytes.bytes;
for (NSUInteger i = 0; i < count; i++) {
unsigned long long off = start + i;
if ([self originalByteAt:off] == nb[i]) [self->_edits removeObjectForKey:@(off)];
else self->_edits[@(off)] = @(nb[i]);
}
[self->_editState noteChange];
[self updateBar];
[self.tableView reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone];
})];
[self presentViewController:a animated:YES completion:nil];
}
#pragma mark Save, discard, leave
- (BOOL)wq_needsLeaveConfirmation
{
return _edits.count > 0 || _editState.saving;
}
- (NSDictionary<NSNumber *, NSData *> *)runs
{
NSMutableDictionary *runs = [NSMutableDictionary new];
NSArray *offsets = [_edits.allKeys sortedArrayUsingSelector:@selector(compare:)];
unsigned long long runStart = 0;
NSMutableData *run = nil;
for (NSNumber *o in offsets) {
unsigned long long off = o.unsignedLongLongValue;
uint8_t b = (uint8_t)_edits[o].intValue;
if (run && off == runStart + run.length) { [run appendBytes:&b length:1]; continue; }
if (run) runs[@(runStart)] = run;
runStart = off;
run = [NSMutableData dataWithBytes:&b length:1];
}
if (run) runs[@(runStart)] = run;
return runs;
}
- (BOOL)blockWhileSaving
{
if (!_editState.saving) return NO;
wqh_alert(self, @"Save in progress", @"Wait for the save to finish before leaving or changing these edits.");
return YES;
}
- (void)save
{
if (!_edits.count || [self blockWhileSaving]) return;
if (!_version) {
wqh_alert(self, @"Save unavailable", @"The file version could not be verified. Close and reopen it before saving.");
return;
}
NSDictionary *runs = [self runs];
UIAlertController *a = wq_alert_controller(@"Save changes?", WQLocalizedFormat(@"%lu bytes in %@ are overwritten in place. The current file is backed up first.", (unsigned long)_edits.count, _path), UIAlertControllerStyleAlert);
[a addAction:wq_alert_action(@"Cancel", UIAlertActionStyleCancel, nil)];
[a addAction:wq_alert_action(@"Save", UIAlertActionStyleDestructive, ^(UIAlertAction *__unused x) {
NSDictionary *expectedVersion = [self->_version copy];
if (!expectedVersion) {
wqh_alert(self, @"Save unavailable", @"The file version could not be verified. Close and reopen it before saving.");
return;
}
NSUInteger savedRevision = [self->_editState beginSave];
[self updateBar];
NSString *path = self->_path;
dispatch_async(self->_queue, ^{
NSString *error = nil;
NSDictionary *savedVersion = nil;
BOOL ok = wq_patch_if_unchanged_get_version(path, runs, expectedVersion, &savedVersion, &error);
dispatch_async(dispatch_get_main_queue(), ^{
[self->_editState finishSave:ok revision:savedRevision];
if (!ok) {
[self updateBar];
wqh_alert(self, @"Save failed", error ?: wq_permission_hint(path, YES) ?: @"Unknown error");
return;
}
self->_version = savedVersion;
self->_status = nil;
if (self->_editState.revision == savedRevision) [self->_edits removeAllObjects];
[self->_pages removeAllObjects];
[self updateBar];
[self.tableView reloadData];
});
});
})];
[self presentViewController:a animated:YES completion:nil];
}
- (void)discard
{
if ([self blockWhileSaving]) return;
[_edits removeAllObjects];
[_editState discard];
[self updateBar];
[self.tableView reloadData];
}
// Home button: ask before throwing away unsaved changes.
- (void)confirmLeaveThen:(void (^)(void))proceed
{
if ([self blockWhileSaving]) return;
if (!_edits.count) { proceed(); return; }
UIAlertController *a = wq_alert_controller(@"Discard changes?", @"Your byte changes have not been saved.", UIAlertControllerStyleAlert);
[a addAction:wq_alert_action(@"Keep Editing", UIAlertActionStyleCancel, nil)];
[a addAction:wq_alert_action(@"Discard", UIAlertActionStyleDestructive, ^(UIAlertAction *__unused x) {
[self->_edits removeAllObjects];
[self->_editState discard];
proceed();
})];
[self presentViewController:a animated:YES completion:nil];
}
- (void)leave
{
[self confirmLeaveThen:^{
[self.navigationController popViewControllerAnimated:YES];
}];
}
#pragma mark Navigation and search
- (void)scrollToOffset:(unsigned long long)offset
{
if (_size == 0) return;
if (offset >= _size) offset = _size - 1;
NSInteger row = (NSInteger)(offset / _perRow);
[self.tableView scrollToRowAtIndexPath:[NSIndexPath indexPathForRow:row inSection:0]
atScrollPosition:UITableViewScrollPositionMiddle animated:YES];
}
- (void)goToOffset
{
UIAlertController *a = wq_alert_controller(@"Go to Offset", @"Hex (0x1a2b) or decimal", UIAlertControllerStyleAlert);
[a addTextFieldWithConfigurationHandler:^(UITextField *f) {
f.placeholder = @"0x0";
f.autocapitalizationType = UITextAutocapitalizationTypeNone;
f.autocorrectionType = UITextAutocorrectionTypeNo;
}];
[a addAction:wq_alert_action(@"Cancel", UIAlertActionStyleCancel, nil)];
[a addAction:wq_alert_action(@"Go", UIAlertActionStyleDefault, ^(UIAlertAction *__unused x) {
NSString *t = [a.textFields.firstObject.text stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceCharacterSet];
unsigned long long off = 0;
if (!WQHexParseOffset(t, &off)) {
wqh_alert(self, @"Invalid offset", @"Enter a complete non-negative decimal value or a 0x-prefixed hexadecimal value.");
return;
}
if (_size == 0) {
wqh_alert(self, @"Offset unavailable", @"The file is empty or its size is still loading.");
return;
}
if (off >= _size) {
wqh_alert(self, @"Offset out of range", WQLocalizedFormat(@"Enter an offset from 0x0 through 0x%llx.", _size - 1));
return;
}
[self scrollToOffset:off];
})];
[self presentViewController:a animated:YES completion:nil];
}
- (void)promptFindHex:(BOOL)hex
{
UIAlertController *a = wq_alert_controller(hex ? @"Find Hex Bytes" : @"Find Text", @"Searches the saved file forward from the top of the screen.", UIAlertControllerStyleAlert);
[a addTextFieldWithConfigurationHandler:^(UITextField *f) {
f.placeholder = hex ? @"de ad be ef" : WQLocalized(@"text");
f.autocapitalizationType = UITextAutocapitalizationTypeNone;
f.autocorrectionType = UITextAutocorrectionTypeNo;
}];
[a addAction:wq_alert_action(@"Cancel", UIAlertActionStyleCancel, nil)];
[a addAction:wq_alert_action(@"Find", UIAlertActionStyleDefault, ^(UIAlertAction *__unused x) {
NSString *t = a.textFields.firstObject.text ?: @"";
if (!WQHexSearchInputWithinLimit(t)) {
wqh_alert(self, @"Search too large", @"Use at most 1 MiB of UTF-8 search input.");
return;
}
NSData *needle = hex ? WQParseHex(t, NO) : [t dataUsingEncoding:NSUTF8StringEncoding];
if (needle.length == 0) { wqh_alert(self, @"Nothing to find", hex ? @"Enter hex bytes, e.g. de ad be ef." : @"Enter some text."); return; }
BOOL sameNeedle = self->_lastSearchNeedle && [self->_lastSearchNeedle isEqualToData:needle];
NSInteger topRow = NSIntegerMax;
for (NSIndexPath *indexPath in self.tableView.indexPathsForVisibleRows) {
if (indexPath.section == 0) topRow = MIN(topRow, indexPath.row);
}
if (topRow == NSIntegerMax) topRow = 0;
unsigned long long from = (sameNeedle && self->_matchLength && self->_matchOffset < ULLONG_MAX)
? self->_matchOffset + 1 : (unsigned long long)topRow * self->_perRow;
if (!sameNeedle) self->_matchLength = 0;
self->_lastSearchNeedle = [needle copy];
[self search:needle from:from];
})];
[self presentViewController:a animated:YES completion:nil];
}
- (void)search:(NSData *)needle from:(unsigned long long)from
{
NSUInteger generation = ++_searchGeneration;
_status = WQLocalized(@"Searching…");
[self updateBar];
NSString *path = _path;
unsigned long long size = _size;
NSUInteger requestLength = kSearchChunk + needle.length - 1;
dispatch_async(_queue, ^{
unsigned long long found = ULLONG_MAX;
NSString *error = nil;
for (unsigned long long pos = from; pos < size && found == ULLONG_MAX; ) {
NSUInteger length = (NSUInteger)MIN((unsigned long long)requestLength, size - pos);
NSData *chunk = wq_read_range(path, pos, length, &error);
if (!chunk || chunk.length < needle.length) break;
NSRange r = [chunk rangeOfData:needle options:0 range:NSMakeRange(0, chunk.length)];
if (r.location != NSNotFound) found = pos + r.location;
if (size - pos <= kSearchChunk) break;
pos += kSearchChunk;
}
dispatch_async(dispatch_get_main_queue(), ^{
if (generation != self->_searchGeneration || !wqh_is_visible(self)) return;
self->_status = nil;
[self updateBar];
if (found == ULLONG_MAX) {
self->_matchLength = 0;
[self.tableView reloadData];
wqh_alert(self, @"Not found", error ?: WQLocalizedFormat(@"No match after offset 0x%llx.", from));
return;
}
self->_matchOffset = found;
self->_matchLength = needle.length;
[self.tableView reloadData];
[self scrollToOffset:found];
});
});
}
@end
+58
View File
@@ -0,0 +1,58 @@
//
// WQHexViewerHelpers.h
// WQ Files
//
// Small Foundation-portable validation helpers for the hex viewer.
//
#ifndef WQHexViewerHelpers_h
#define WQHexViewerHelpers_h
#import <Foundation/Foundation.h>
#include <errno.h>
#include <stdlib.h>
#include <string.h>
// Keep both the search needle and the range-read overlap bounded before either
// can be materialized. This is a UTF-8 input limit, not a UI character limit.
static const NSUInteger WQHexSearchMaximumInputBytes = 1024UL * 1024UL;
static inline BOOL WQHexSearchInputWithinLimit(NSString *text)
{
return text && [text lengthOfBytesUsingEncoding:NSUTF8StringEncoding] <= WQHexSearchMaximumInputBytes;
}
// Parses the complete trimmed input as an unsigned decimal or 0x-prefixed
// hexadecimal offset. The caller remains responsible for checking file range.
static inline BOOL WQHexParseOffset(NSString *text, unsigned long long *offset)
{
if (!text || !offset) return NO;
NSString *trimmed = [text stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet];
if (!trimmed.length) return NO;
BOOL hex = [trimmed hasPrefix:@"0x"] || [trimmed hasPrefix:@"0X"];
NSString *digits = hex ? [trimmed substringFromIndex:2] : trimmed;
if (!digits.length) return NO;
unichar first = [digits characterAtIndex:0];
if (first == '-' || first == '+') return NO;
for (NSUInteger i = 0; i < digits.length; i++) {
unichar c = [digits characterAtIndex:i];
BOOL valid = c >= '0' && c <= '9';
if (hex) valid = valid || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F');
if (!valid) return NO;
}
const char *input = digits.UTF8String;
NSUInteger inputBytes = [digits lengthOfBytesUsingEncoding:NSUTF8StringEncoding];
if (!input || strlen(input) != inputBytes) return NO;
errno = 0;
char *end = NULL;
unsigned long long parsed = strtoull(input, &end, hex ? 16 : 10);
if (errno == ERANGE || end == input || !end || *end != '\0') return NO;
*offset = parsed;
return YES;
}
#endif
+39
View File
@@ -0,0 +1,39 @@
//
// WQLaunchServices.h
// WQ Files
//
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
// Human app name for a bundle id, from LaunchServices' database (read from
// lsd's container), a built-in table of Apple services, or the parent app.
NSString *_Nullable wq_display_name(NSString *_Nullable bundleID);
// Bundle id of an app or extension data container (/private/var/mobile/
// Containers/Data/...) or an app bundle container, from the same
// LaunchServices database. For iOS 27.2, where the containers' identifier
// attribute can't be read. Nil if unknown.
NSString *_Nullable wq_bundle_id_for_container(NSString *path);
// Whether the name database is loaded; loading it (blocking, off the main
// thread) the first time can take a while, so screens show a status for it.
BOOL wq_display_names_loaded(void);
void wq_load_display_names(void);
// After a user's refresh: search for a database that wasn't found before
// right away instead of waiting a day.
void wq_display_names_reset_miss(void);
// Every app and extension data container the LaunchServices database lists,
// read from storeHint (remembered when it parses) or the remembered location.
// Never scans. Nil when there's no usable database.
NSArray<NSString *> *_Nullable wq_ls_container_paths(NSString *_Nullable storeHint);
// The .app bundle of an installed app, from the same database (for icons
// where iOS won't hand them out). Nil if unknown.
NSString *_Nullable wq_bundle_path_for_id(NSString *bundleID);
// The app bundle containers (/private/var/containers/Bundle/Application/
// <UUID>) of installed apps, from the same database; empty if unknown.
// wq_bundle_id_for_container also labels these.
NSArray<NSString *> *wq_ls_bundle_containers(void);
NS_ASSUME_NONNULL_END
+897
View File
@@ -0,0 +1,897 @@
//
// WQLaunchServices.m
// WQ Files
// GPL-3.0-or-later.
//
#import "WQLaunchServices.h"
#import "WQLocalization.h"
#import "WQAccess.h"
#import <stdlib.h>
#import <string.h>
#import <sys/stat.h>
#pragma mark - App names from the LaunchServices database
// LaunchServices' own API refuses this app on 27.x (OSStatus -54) and the app
// bundles cannot be opened, but lsd keeps its database (CSStore2, magic
// "bdsl") in an Internal Daemon container that we can read. In it, Bundle
// records are 0x250 bytes long, preceded by [u32][u32 0x250]: +0x0c bundle
// id, +0x6c bundle name, +0x74 display name, each a string reference
// (string id << 2). Strings are stored as [u32 id][u32 length][UTF-8][NUL].
// Names that exist only in localized form (most Apple apps) are not decoded;
// the built-in table below covers those.
static NSString *const kWQLSStoreKey = @"WQLaunchServicesStore";
// When the database wasn't found: don't search again for a day (each search
// lists every daemon container), unless the user refreshes.
static NSString *const kWQLSMissKey = @"WQLaunchServicesStoreMissedAt";
static const NSTimeInterval kWQLSMissRetry = 24 * 60 * 60;
// These limits keep a malformed or unexpectedly large database from turning a
// synchronous display-name lookup into an unbounded read/parse operation.
static const size_t kWQLSMinStoreBytes = 0x1000;
static const size_t kWQLSMaxStoreBytes = 64u * 1024u * 1024u;
static const uint32_t kWQLSMaxStringID = 0x01000000u;
static const NSUInteger kWQLSMaxBundleRecords = 100000;
static const NSUInteger kWQLSMaxDecodedStrings = 200000;
static const NSUInteger kWQLSMaxDecodedStringBytes = 8u * 1024u * 1024u;
static NSObject *wq_ls_lock(void)
{
static NSObject *lock;
static dispatch_once_t once;
dispatch_once(&once, ^{ lock = [NSObject new]; });
return lock;
}
// The regression tests still exercise discovery through the daemon list.
#ifdef WQ_LS_TESTING
static BOOL wq_ls_test_daemon_search = YES;
#else
static const BOOL wq_ls_test_daemon_search = NO;
#endif
static NSString *wq_find_ls_store(BOOL *searched);
static BOOL wq_ls_candidate_info(NSString *path, struct stat *st);
static BOOL wq_ls_stat_equal(const struct stat *a, const struct stat *b)
{
return a->st_dev == b->st_dev && a->st_ino == b->st_ino &&
a->st_size == b->st_size &&
a->st_mtimespec.tv_sec == b->st_mtimespec.tv_sec &&
a->st_mtimespec.tv_nsec == b->st_mtimespec.tv_nsec;
}
static NSDictionary *wq_ls_signature(const struct stat *st)
{
return @{
@"device": @(st->st_dev), @"inode": @(st->st_ino), @"size": @(st->st_size),
@"modifiedSeconds": @(st->st_mtimespec.tv_sec),
@"modifiedNanoseconds": @(st->st_mtimespec.tv_nsec)
};
}
static BOOL wq_ls_signature_equal(NSDictionary *a, NSDictionary *b)
{
return a && b && [a isEqualToDictionary:b];
}
static NSString *wq_ls_error(NSString *path, NSString *reason)
{
return [NSString stringWithFormat:@"%@ (%@)", path ?: @"<none>", reason ?: @"unknown error"];
}
static BOOL wq_ls_candidate_info(NSString *path, struct stat *st)
{
if (!path.length || !wq_stat(path, st) || !S_ISREG(st->st_mode) ||
st->st_size < (off_t)kWQLSMinStoreBytes || st->st_size > (off_t)kWQLSMaxStoreBytes) {
return NO;
}
NSString *error = nil;
NSData *header = wq_read(path, 4, &error);
if (header.length != 4 || memcmp(header.bytes, "bdsl", 4) != 0) return NO;
return YES;
}
static BOOL wq_ls_is_newer(NSString *path, const struct stat *st,
NSString *bestPath, const struct stat *best)
{
if (!bestPath) return YES;
if (st->st_mtimespec.tv_sec != best->st_mtimespec.tv_sec)
return st->st_mtimespec.tv_sec > best->st_mtimespec.tv_sec;
if (st->st_mtimespec.tv_nsec != best->st_mtimespec.tv_nsec)
return st->st_mtimespec.tv_nsec > best->st_mtimespec.tv_nsec;
if (st->st_size != best->st_size) return st->st_size > best->st_size;
return [path compare:bestPath] == NSOrderedAscending;
}
static void wq_ls_consider_candidate(NSString *path, NSString **bestPath,
struct stat *best, NSMutableSet<NSString *> *seen)
{
if (!path.length || [seen containsObject:path]) return;
[seen addObject:path];
struct stat st;
if (!wq_ls_candidate_info(path, &st)) return;
if (wq_ls_is_newer(path, &st, *bestPath, best)) {
*bestPath = path;
*best = st;
}
}
// *searched: the daemon containers were actually listed (a failed or cancelled
// scan says nothing about whether the database exists).
static NSString *wq_find_ls_store(BOOL *searched)
{
if (searched) *searched = NO;
NSString *cached = [NSUserDefaults.standardUserDefaults stringForKey:kWQLSStoreKey];
NSString *bestPath = nil;
struct stat best = {0};
struct stat cachedStat;
BOOL cachedValid = cached.length && wq_ls_candidate_info(cached, &cachedStat);
NSMutableSet<NSString *> *seen = [NSMutableSet set];
if (cachedValid) wq_ls_consider_candidate(cached, &bestPath, &best, seen);
// Listing the daemon containers is a full id scan (minutes, and it asks
// first); the database is found by the container scan among the newest
// ids and remembered, so names only use the remembered location.
if (!wq_ls_test_daemon_search) {
if (searched) *searched = cachedValid;
return cachedValid ? cached : nil;
}
NSArray *daemons = wq_enumerate(@"/private/var/mobile/Containers/Data/InternalDaemon", 0, nil, nil);
if (daemons && searched) *searched = YES;
if (daemons) {
for (NSString *container in daemons) {
NSString *caches = [container stringByAppendingPathComponent:@"Library/Caches"];
NSArray *items = wq_list(caches, NULL);
for (NSString *name in [items sortedArrayUsingSelector:@selector(compare:)]) {
if (![name hasPrefix:@"com.apple.LaunchServices-"] || ![name hasSuffix:@".csstore"]) continue;
wq_ls_consider_candidate([caches stringByAppendingPathComponent:name], &bestPath, &best, seen);
}
}
}
// Keep a valid cached path usable if enumeration is temporarily unavailable.
if (!bestPath && cachedValid) {
bestPath = cached;
}
return bestPath;
}
// Record layouts by release. Offsets are from the record body (after the
// 8-byte header). iOS 26 records are 0x14 bytes shorter, with the container
// path and the fields after it 4 bytes earlier than on 27.0.
typedef struct {
const char *label;
uint32_t bundleSize, bundleID, container, name, displayName, bundlePath;
uint32_t pluginSize, pluginBundleID, pluginContainer;
} WQLSLayout;
static const WQLSLayout kWQLSLayouts[] = {
{ "27.0", 0x250, 0x0c, 0x68, 0x6c, 0x74, 0x00, 0x118, 0x00, 0x54 },
{ "26", 0x23c, 0x0c, 0x64, 0x68, 0x70, 0x00, 0x104, 0x00, 0x50 },
// iOS 26.5 (from a device's layout hint, issue #37): same record sizes,
// container fields 4 bytes earlier than on 26.7. Whether the bundle id
// moved too isn't known, so both positions are tried.
{ "26.5", 0x23c, 0x0c, 0x60, 0x64, 0x6c, 0x00, 0x104, 0x00, 0x4c },
{ "26.5b", 0x23c, 0x08, 0x60, 0x64, 0x6c, 0x00, 0x104, 0x00, 0x4c },
// iOS 27.2 (from a device's layout hint): container fields 4 bytes later
// than on 27.0. Whether the bundle id moved too isn't known yet, so both
// positions are tried; the one that maps the most containers wins.
{ "27.2", 0x25c, 0x0c, 0x6c, 0x70, 0x78, 0x00, 0x120, 0x00, 0x58 },
{ "27.2b", 0x25c, 0x10, 0x6c, 0x70, 0x78, 0x00, 0x120, 0x04, 0x58 },
{ "27.2c", 0x25c, 0x0c, 0x6c, 0x70, 0x78, 0x00, 0x120, 0x04, 0x58 },
{ "27.2d", 0x25c, 0x10, 0x6c, 0x70, 0x78, 0x00, 0x120, 0x00, 0x58 },
};
static const size_t kWQLSLayoutCount = sizeof(kWQLSLayouts) / sizeof(kWQLSLayouts[0]);
static const size_t kWQLSMaxRecordSize = 0x25c;
static uint32_t wq_rd32(const uint8_t *p) { uint32_t v; memcpy(&v, p, 4); return v; }
static NSString *wq_cs_string(const uint8_t *b, size_t n, size_t o)
{
if (o > n || n - o < 8) return nil;
uint32_t len = wq_rd32(b + o + 4);
// Strings are padded with zero bytes to a multiple of 4. One whose length
// is already a multiple of 4 has no terminator (container paths are 84 or
// 88 bytes), so only shorter ones must end in a zero byte. A string must
// still end before the buffer does.
if (len == 0 || len >= 0x800 || len >= n - o - 8 || (len % 4 && b[o + 8 + len] != 0)) return nil;
if (memchr(b + o + 8, 0, len)) return nil;
NSString *s = [[NSString alloc] initWithBytes:b + o + 8 length:len encoding:NSUTF8StringEncoding];
return s;
}
static BOOL wq_looks_like_bundle_id(NSString *s)
{
static NSCharacterSet *bad;
static dispatch_once_t once;
dispatch_once(&once, ^{
bad = [[NSCharacterSet characterSetWithCharactersInString:
@"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-_"] invertedSet];
});
return s.length > 2 && [s containsString:@"."] && ![s hasPrefix:@"."]
&& [s rangeOfCharacterFromSet:bad].location == NSNotFound;
}
// Unicode format characters include bidi overrides/isolates, zero-width
// characters, and BOM-like markers. They are replaced visibly while ordinary
// international letters, accents, and scripts remain unchanged.
static BOOL wq_is_name_format_scalar(uint32_t c)
{
return c == 0x00ad || (c >= 0x0600 && c <= 0x0605) || c == 0x061c || c == 0x06dd ||
c == 0x070f || (c >= 0x0890 && c <= 0x0891) || c == 0x08e2 || c == 0x180e ||
(c >= 0x200b && c <= 0x200f) || (c >= 0x202a && c <= 0x202e) ||
(c >= 0x2060 && c <= 0x206f) || c == 0xfeff;
}
static NSString *wq_safe_name(NSString *value)
{
if (!value.length) return nil;
NSMutableString *safe = [NSMutableString stringWithCapacity:value.length];
for (NSUInteger i = 0; i < value.length;) {
uint32_t scalar = [value characterAtIndex:i++];
if (scalar >= 0xd800 && scalar <= 0xdbff) {
if (i >= value.length) return nil;
unichar low = [value characterAtIndex:i++];
if (low < 0xdc00 || low > 0xdfff) return nil;
scalar = 0x10000u + ((scalar - 0xd800u) << 10) + (low - 0xdc00u);
} else if (scalar >= 0xdc00 && scalar <= 0xdfff) {
return nil;
}
if (wq_is_name_format_scalar(scalar) || scalar == 0x2028 || scalar == 0x2029) {
[safe appendString:@"\uFFFD"];
} else if (scalar <= 0xffff) {
[safe appendFormat:@"%C", (unichar)scalar];
} else {
scalar -= 0x10000u;
[safe appendFormat:@"%C%C", (unichar)(0xd800u + (scalar >> 10)),
(unichar)(0xdc00u + (scalar & 0x3ffu))];
}
}
NSString *normalized = [safe precomposedStringWithCanonicalMapping];
normalized = [normalized stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet];
if (!normalized.length || [normalized hasPrefix:@"/"] || [normalized containsString:@".app/"]) return nil;
if ([normalized rangeOfCharacterFromSet:NSCharacterSet.controlCharacterSet].location != NSNotFound) return nil;
return normalized;
}
// Data container path -> bundle id. Apps: the Bundle record's +0x68 field;
// extensions: 0x118-byte plugin records with the bundle id at +0x00 and the
// path at +0x54. On iOS 27.2 the containers' identifier attribute can't be
// read, so this is how their folders get a bundle id (and a name).
static BOOL wq_is_data_container_path(NSString *path)
{
static NSArray<NSString *> *prefixes;
static dispatch_once_t once;
dispatch_once(&once, ^{
prefixes = @[@"/private/var/mobile/Containers/Data/Application/",
@"/private/var/mobile/Containers/Data/PluginKitPlugin/"];
});
for (NSString *prefix in prefixes) {
if (![path hasPrefix:prefix] || path.length != prefix.length + 36) continue;
return [[NSUUID alloc] initWithUUIDString:[path substringFromIndex:prefix.length]] != nil;
}
return NO;
}
// Diagnostics: how many container paths the store holds, and which record
// sizes and offsets refer to them. Only sizes and offsets are logged (no
// names), so a Log from a release with a new layout shows where it moved.
static int wq_u32_cmp(const void *a, const void *b)
{
uint32_t x = *(const uint32_t *)a, y = *(const uint32_t *)b;
return x < y ? -1 : x > y;
}
// String refs of the data container paths stored in the database, sorted;
// independent of the record layout.
static NSMutableData *wq_ls_container_path_refs(const uint8_t *b, size_t n)
{
NSMutableData *refData = [NSMutableData new];
static const char needle[] = "/var/mobile/Containers/Data/";
const size_t needleLen = sizeof(needle) - 1;
const uint8_t *cursor = b, *end = b + n;
while (cursor < end) {
const uint8_t *hit = memmem(cursor, (size_t)(end - cursor), needle, needleLen);
if (!hit) break;
cursor = hit + needleLen;
const uint8_t *start = hit;
if ((size_t)(hit - b) >= 8 && memcmp(hit - 8, "/private", 8) == 0) start = hit - 8;
size_t o = (size_t)(start - b);
if (o < 8 || o % 4) continue;
uint32_t len = wq_rd32(start - 4);
if (len < needleLen || len > 0x100 || len > (size_t)(end - start)) continue;
uint32_t ref = wq_rd32(start - 8) << 2;
if (ref) [refData appendBytes:&ref length:sizeof(ref)];
if (refData.length / sizeof(uint32_t) >= 20000) break;
}
NSUInteger count = refData.length / sizeof(uint32_t);
if (count) qsort(refData.mutableBytes, count, sizeof(uint32_t), wq_u32_cmp);
return refData;
}
static void wq_ls_log_summary(const uint8_t *b, size_t n, NSString *layout, NSUInteger bundleRecords,
NSUInteger pluginRecords, NSUInteger names, NSUInteger mapped)
{
NSMutableData *refData = wq_ls_container_path_refs(b, n);
NSUInteger pathCount = refData.length / sizeof(uint32_t);
uint32_t *refs = refData.mutableBytes;
wq_log(@"names: store %.1f MB, layout %@: %lu bundle and %lu plugin records, %lu names, %lu of %lu container paths mapped",
n / 1048576.0, layout, (unsigned long)bundleRecords, (unsigned long)pluginRecords, (unsigned long)names,
(unsigned long)mapped, (unsigned long)pathCount);
if (!pathCount || mapped * 2 >= pathCount) return;
// Vote for (record size, offset of the container reference).
NSCountedSet<NSNumber *> *votes = [NSCountedSet new];
for (size_t q = 0; q + 4 <= n; q += 4) {
uint32_t v = wq_rd32(b + q);
if (!v || (v & 3) || !bsearch(&v, refs, pathCount, sizeof(uint32_t), wq_u32_cmp)) continue;
for (size_t k = 0; k < 0x400 && q >= 8 + k; k += 4) {
uint32_t size = wq_rd32(b + q - 8 - k + 4);
if (size >= 0x40 && size <= 0x800 && size % 4 == 0 && k < size)
[votes addObject:@((uint64_t)size << 16 | k)];
}
}
NSArray *top = [votes.allObjects sortedArrayUsingComparator:^NSComparisonResult(NSNumber *x, NSNumber *y) {
NSUInteger cx = [votes countForObject:x], cy = [votes countForObject:y];
return cx > cy ? NSOrderedAscending : cx < cy ? NSOrderedDescending : [x compare:y];
}];
NSMutableArray *parts = [NSMutableArray new];
for (NSNumber *key in [top subarrayWithRange:NSMakeRange(0, MIN(top.count, (NSUInteger)8))]) {
uint64_t value = key.unsignedLongLongValue;
[parts addObject:[NSString stringWithFormat:@"0x%llx@0x%llx x%lu", value >> 16, value & 0xffff,
(unsigned long)[votes countForObject:key]]];
}
wq_log(@"names: layout hint (record size@container offset): %@", [parts componentsJoinedByString:@", "]);
}
static BOOL wq_parse_ls_store(NSData *data, NSDictionary **displayOut,
NSDictionary **bundleNameOut, NSDictionary **containersOut,
NSDictionary **bundlePathsOut)
{
if (bundlePathsOut) *bundlePathsOut = nil;
if (displayOut) *displayOut = nil;
if (bundleNameOut) *bundleNameOut = nil;
if (containersOut) *containersOut = nil;
const uint8_t *b = data.bytes;
size_t n = data.length;
if (n < kWQLSMinStoreBytes || !b || memcmp(b, "bdsl", 4) != 0) {
wq_log(@"names: not a LaunchServices store");
return NO;
}
NSRange heapRange = [data rangeOfData:[NSData dataWithBytes:"<string>" length:8]
options:0 range:NSMakeRange(0, n)];
size_t heap = heapRange.location == NSNotFound ? 0 : heapRange.location;
// Pass 1: Bundle and plug-in records of every known layout, and which
// string ids they need. The layout that maps the most containers wins.
const uint32_t kIDs = kWQLSMaxStringID;
size_t bitsetBytes = kIDs / 8;
uint8_t *need = calloc(bitsetBytes, 1), *inHeap = calloc(bitsetBytes, 1);
NSMutableArray<NSMutableData *> *layoutRecs = [NSMutableArray new], *layoutPlugins = [NSMutableArray new];
for (size_t l = 0; l < kWQLSLayoutCount; l++) {
[layoutRecs addObject:[NSMutableData new]];
[layoutPlugins addObject:[NSMutableData new]]; // pairs: bundle id ref, container path ref
}
if (!need || !inHeap) {
free(need);
free(inHeap);
wq_log(@"names: LaunchServices parser allocation failed");
return NO;
}
BOOL tooManyRecords = NO;
for (size_t o = 0; o + 8 + kWQLSMaxRecordSize <= n && !tooManyRecords; o += 4) {
uint32_t size = wq_rd32(b + o + 4);
const uint8_t *u = b + o + 8;
for (size_t l = 0; l < kWQLSLayoutCount; l++) {
const WQLSLayout *L = &kWQLSLayouts[l];
if (size == L->pluginSize) {
NSMutableData *plugins = layoutPlugins[l];
uint32_t p[2] = { wq_rd32(u + L->pluginBundleID), wq_rd32(u + L->pluginContainer) };
if (p[0] && !(p[0] & 3) && (p[0] >> 2) < kIDs && p[1] && !(p[1] & 3) && (p[1] >> 2) < kIDs &&
plugins.length / sizeof(p) < kWQLSMaxBundleRecords) {
[plugins appendBytes:p length:sizeof(p)];
for (int i = 0; i < 2; i++) need[(p[i] >> 2) / 8] |= 1 << ((p[i] >> 2) % 8);
}
}
if (size != L->bundleSize) continue;
NSMutableData *recs = layoutRecs[l];
if (recs.length / (5 * sizeof(uint32_t)) >= kWQLSMaxBundleRecords) {
tooManyRecords = YES;
break;
}
uint32_t r[5] = { wq_rd32(u + L->bundleID), wq_rd32(u + L->name), wq_rd32(u + L->displayName), wq_rd32(u + L->container),
wq_rd32(u + L->bundlePath) };
if (!r[0] || (r[0] & 3) || (r[0] >> 2) >= kIDs) continue;
[recs appendBytes:r length:sizeof(r)];
for (int i = 0; i < 5; i++) {
if (r[i] && !(r[i] & 3) && (r[i] >> 2) < kIDs)
need[(r[i] >> 2) / 8] |= 1 << ((r[i] >> 2) % 8);
}
}
}
if (tooManyRecords) {
free(need);
free(inHeap);
wq_log(@"names: LaunchServices store exceeds record limit");
return NO;
}
// Pass 2: the strings, preferring copies inside the string table. On
// iOS 26 container paths live in a second table whose ids overlap the
// string table's, so path-shaped strings are also kept by id on their own
// (an id with two different paths is unusable).
NSMutableDictionary<NSNumber *, NSString *> *strings = [NSMutableDictionary new];
NSMutableDictionary<NSNumber *, id> *pathStrings = [NSMutableDictionary new];
NSMutableDictionary<NSNumber *, id> *appPathStrings = [NSMutableDictionary new];
NSUInteger decodedStringBytes = 0;
BOOL tooManyStrings = NO;
for (size_t o = 0; o <= n - 8; o += 4) {
uint32_t sid = wq_rd32(b + o);
if (!sid || sid >= kIDs || !(need[sid / 8] & (1 << (sid % 8)))) continue;
BOOL here = o >= heap, had = strings[@(sid)] != nil;
BOOL keepGeneral = !had || (here && !(inHeap[sid / 8] & (1 << (sid % 8))));
uint32_t peekLen = o + 8 <= n ? wq_rd32(b + o + 4) : 0;
BOOL maybePath = peekLen >= 6 && peekLen < 0x400 && o + 8 < n && b[o + 8] == '/';
if (!keepGeneral && !maybePath) continue;
NSString *s = wq_cs_string(b, n, o);
if (!s) continue;
if (maybePath) {
NSMutableDictionary *table = [s containsString:@"/mobile/Containers/Data/"] ? pathStrings
: ([s hasSuffix:@".app"] || [s hasSuffix:@".app/"]) ? appPathStrings : nil;
id previous = table[@(sid)];
if (table && !previous) table[@(sid)] = s;
else if (table && ![previous isEqual:s]) table[@(sid)] = NSNull.null;
}
if (!keepGeneral) continue;
if (!had && (strings.count >= kWQLSMaxDecodedStrings ||
s.length > kWQLSMaxDecodedStringBytes - decodedStringBytes)) {
tooManyStrings = YES;
break;
}
strings[@(sid)] = s;
if (!had) decodedStringBytes += s.length;
if (here) inHeap[sid / 8] |= 1 << (sid % 8);
}
free(need);
free(inHeap);
if (tooManyStrings) {
wq_log(@"names: LaunchServices store exceeds string limit");
return NO;
}
NSUInteger anyRecords = 0, anyPlugins = 0;
for (size_t l = 0; l < kWQLSLayoutCount; l++) {
anyRecords += layoutRecs[l].length / (5 * sizeof(uint32_t));
anyPlugins += layoutPlugins[l].length / (2 * sizeof(uint32_t));
}
if (!anyRecords) {
wq_ls_log_summary(b, n, @"none", 0, anyPlugins, 0, 0);
wq_log(@"names: LaunchServices store contains no bundle records");
return NO;
}
NSString *(^pathFor)(uint32_t) = ^NSString *(uint32_t ref) {
id path = pathStrings[@(ref >> 2)];
if (path == NSNull.null) return nil;
return path ?: strings[@(ref >> 2)];
};
NSDictionary *display = nil, *bundleName = nil, *containers = nil, *bundlePaths = nil;
size_t chosen = 0;
for (size_t l = 0; l < kWQLSLayoutCount; l++) {
NSMutableDictionary *layoutDisplay = [NSMutableDictionary new], *layoutBundleName = [NSMutableDictionary new];
NSMutableDictionary *layoutBundlePaths = [NSMutableDictionary new];
NSMutableDictionary<NSString *, id> *layoutContainers = [NSMutableDictionary new];
// A path claimed by two different bundle ids is left out rather than guessed.
// Plug-in records only claim extension containers and bundle records
// only app containers, so a record type that happens to share a size
// can't knock out the other table's paths.
void (^claim)(NSString *, NSString *, NSString *) = ^(NSString *path, NSString *bid, NSString *kind) {
// iOS 26 stores container paths with a trailing slash; lookups
// use the /private/var form without one.
if ([path hasSuffix:@"/"]) path = [path substringToIndex:path.length - 1];
if ([path hasPrefix:@"/var/"]) path = [@"/private" stringByAppendingString:path];
if (!wq_is_data_container_path(path) || !wq_looks_like_bundle_id(bid) || ![path containsString:kind]) return;
id known = layoutContainers[path];
if (!known) layoutContainers[path] = bid;
else if (![known isEqual:bid]) layoutContainers[path] = NSNull.null;
};
NSData *plugins = layoutPlugins[l], *recs = layoutRecs[l];
const uint32_t *pr = plugins.bytes;
for (NSUInteger i = 0; i < plugins.length / sizeof(uint32_t); i += 2)
claim(pathFor(pr[i + 1]), strings[@(pr[i] >> 2)], @"/Data/PluginKitPlugin/");
const uint32_t *r = recs.bytes;
for (NSUInteger i = 0; i < recs.length / sizeof(uint32_t); i += 5) {
NSString *bid = strings[@(r[i] >> 2)];
if (!wq_looks_like_bundle_id(bid)) continue;
if (r[i + 3] && !(r[i + 3] & 3)) claim(pathFor(r[i + 3]), bid, @"/Data/Application/");
NSString *nm = (r[i + 1] & 3) ? nil : strings[@(r[i + 1] >> 2)];
NSString *dn = (r[i + 2] & 3) ? nil : strings[@(r[i + 2] >> 2)];
NSString *safeDisplay = wq_safe_name(dn);
NSString *safeBundleName = wq_safe_name(nm);
if (safeDisplay && !layoutDisplay[bid]) layoutDisplay[bid] = safeDisplay;
if (safeBundleName && !layoutBundleName[bid]) layoutBundleName[bid] = safeBundleName;
id appPath = (r[i + 4] && !(r[i + 4] & 3)) ? appPathStrings[@(r[i + 4] >> 2)] : nil;
if ([appPath isKindOfClass:NSString.class] && !layoutBundlePaths[bid]) {
NSString *clean = [appPath hasSuffix:@"/"] ? [appPath substringToIndex:[appPath length] - 1] : appPath;
if ([clean hasPrefix:@"/var/"]) clean = [@"/private" stringByAppendingString:clean];
layoutBundlePaths[bid] = clean;
}
}
for (NSString *path in layoutContainers.allKeys)
if (layoutContainers[path] == NSNull.null) [layoutContainers removeObjectForKey:path];
if (!containers || layoutContainers.count > containers.count ||
(layoutContainers.count == containers.count && layoutDisplay.count > display.count)) {
display = layoutDisplay;
bundleName = layoutBundleName;
bundlePaths = layoutBundlePaths;
containers = layoutContainers;
chosen = l;
}
}
// A few Apple apps' names, to check the name fields of a new layout.
NSMutableArray *samples = [NSMutableArray new];
for (NSString *bid in @[@"com.apple.mobilesafari", @"com.apple.Preferences", @"com.apple.camera", @"com.apple.mobilenotes"]) {
NSString *name = display[bid] ?: bundleName[bid];
if (name) [samples addObject:[NSString stringWithFormat:@"%@ = %@", bid, name]];
}
// The container scan and the name loader each parse the same store; log
// its summary once per distinct result, not once per parse.
static NSString *lastSummary;
NSString *summary = [NSString stringWithFormat:@"%zu %zu %lu %lu", n, chosen, (unsigned long)display.count,
(unsigned long)containers.count];
BOOL newSummary;
@synchronized (NSObject.class) {
newSummary = ![summary isEqualToString:lastSummary];
if (newSummary) lastSummary = summary;
}
if (newSummary && samples.count) wq_log(@"names: sample: %@", [samples componentsJoinedByString:@", "]);
if (newSummary) wq_ls_log_summary(b, n, @(kWQLSLayouts[chosen].label), layoutRecs[chosen].length / (5 * sizeof(uint32_t)),
layoutPlugins[chosen].length / (2 * sizeof(uint32_t)), display.count, containers.count);
// A layout that maps only a small part of the container paths in the store
// is the wrong one (its names are wrong too): don't use the store at all,
// so the app list falls back to the scan instead of showing a few apps.
NSUInteger storePaths = wq_ls_container_path_refs(b, n).length / sizeof(uint32_t);
if (storePaths >= 20 && containers.count * 2 < storePaths) {
if (newSummary)
wq_log(@"names: layout %s maps only %lu of %lu container paths; this iOS version's database layout isn't known, so it isn't used",
kWQLSLayouts[chosen].label, (unsigned long)containers.count, (unsigned long)storePaths);
return NO;
}
if (bundlePathsOut) *bundlePathsOut = [bundlePaths copy];
if (displayOut) *displayOut = [display copy];
if (bundleNameOut) *bundleNameOut = [bundleName copy];
if (containersOut) *containersOut = [containers copy];
return YES;
}
static NSDictionary *gLSDisplay;
static NSDictionary *gLSBundleName;
static NSDictionary *gLSContainers;
static NSDictionary *gLSBundlePaths;
static NSString *gLSStorePath;
static NSDictionary *gLSStoreSignature;
static BOOL gLSLoaded;
static NSUInteger gLSFailureCount;
static NSTimeInterval gLSNextRetryAt;
static void wq_ls_clear_snapshot_if_changed(BOOL changed)
{
if (!changed) return;
gLSDisplay = nil;
gLSBundleName = nil;
gLSContainers = nil;
gLSBundlePaths = nil;
gLSStorePath = nil;
gLSStoreSignature = nil;
gLSLoaded = NO;
}
static void wq_ls_note_failure(NSTimeInterval now, NSString *reason)
{
gLSFailureCount = MIN(gLSFailureCount + 1, (NSUInteger)6);
NSUInteger shift = gLSFailureCount > 1 ? gLSFailureCount - 1 : 0;
NSTimeInterval delay = (NSTimeInterval)(1u << MIN(shift, (NSUInteger)5));
gLSNextRetryAt = now + delay;
if (reason) wq_log(@"names: %@; retrying in %.0f s", reason, delay);
}
static void wq_load_ls_names_locked(void)
{
NSTimeInterval now = NSDate.date.timeIntervalSinceReferenceDate;
BOOL currentChanged = NO;
if (gLSLoaded && gLSStorePath) {
struct stat current;
if (wq_ls_candidate_info(gLSStorePath, &current)) {
NSDictionary *signature = wq_ls_signature(&current);
currentChanged = !wq_ls_signature_equal(signature, gLSStoreSignature);
if (!currentChanged) return;
} else {
currentChanged = YES;
}
} else if (!gLSLoaded && now < gLSNextRetryAt) {
return;
} else if (!gLSLoaded && ![NSUserDefaults.standardUserDefaults stringForKey:kWQLSStoreKey].length) {
NSDate *missed = [NSUserDefaults.standardUserDefaults objectForKey:kWQLSMissKey];
if ([missed isKindOfClass:NSDate.class] && now - missed.timeIntervalSinceReferenceDate < kWQLSMissRetry &&
now >= missed.timeIntervalSinceReferenceDate) {
gLSNextRetryAt = missed.timeIntervalSinceReferenceDate + kWQLSMissRetry;
return;
}
}
BOOL hadLocation = [NSUserDefaults.standardUserDefaults stringForKey:kWQLSStoreKey].length > 0;
BOOL searched = NO;
NSString *path = wq_find_ls_store(&searched);
if (!path) {
wq_ls_clear_snapshot_if_changed(currentChanged);
if (!hadLocation && searched) {
// Never found, after a complete search: wait a day (or a refresh).
[NSUserDefaults.standardUserDefaults setObject:NSDate.date forKey:kWQLSMissKey];
gLSNextRetryAt = now + kWQLSMissRetry;
wq_log(@"names: LaunchServices database not found; searching again in a day or on refresh");
} else {
// A known database that's briefly unreadable, or a scan that failed.
wq_ls_note_failure(now, @"LaunchServices database not found");
}
return;
}
[NSUserDefaults.standardUserDefaults removeObjectForKey:kWQLSMissKey];
struct stat before;
if (!wq_ls_candidate_info(path, &before)) {
wq_ls_clear_snapshot_if_changed(currentChanged);
wq_ls_note_failure(now, wq_ls_error(path, @"candidate is no longer valid"));
return;
}
NSDictionary *beforeSignature = wq_ls_signature(&before);
BOOL snapshotChanged = !gLSLoaded || ![path isEqualToString:gLSStorePath] ||
!wq_ls_signature_equal(beforeSignature, gLSStoreSignature);
if (gLSLoaded && [path isEqualToString:gLSStorePath] &&
wq_ls_signature_equal(beforeSignature, gLSStoreSignature)) {
gLSFailureCount = 0;
gLSNextRetryAt = 0;
return;
}
NSString *error = nil;
NSData *data = wq_read(path, kWQLSMaxStoreBytes + 1, &error);
if (!data || data.length > kWQLSMaxStoreBytes) {
wq_ls_clear_snapshot_if_changed(snapshotChanged);
wq_ls_note_failure(now, wq_ls_error(path, error ?: @"store exceeds size limit"));
return;
}
NSDictionary *display = nil, *bundleName = nil, *containers = nil, *bundlePaths = nil;
if (!wq_parse_ls_store(data, &display, &bundleName, &containers, &bundlePaths)) {
wq_ls_clear_snapshot_if_changed(snapshotChanged);
wq_ls_note_failure(now, wq_ls_error(path, @"store rejected by bounded parser"));
return;
}
// Do not publish a snapshot that was changed while it was being read or parsed.
struct stat after;
if (!wq_ls_candidate_info(path, &after) || !wq_ls_stat_equal(&before, &after)) {
wq_ls_clear_snapshot_if_changed(snapshotChanged);
wq_ls_note_failure(now, wq_ls_error(path, @"store changed during read"));
return;
}
// Dictionaries are immutable copies and are assigned only after a complete,
// validated parse. Readers therefore see either the previous snapshot or this one.
gLSDisplay = [display copy] ?: @{};
gLSBundleName = [bundleName copy] ?: @{};
gLSContainers = [containers copy] ?: @{};
gLSBundlePaths = [bundlePaths copy] ?: @{};
gLSStorePath = [path copy];
gLSStoreSignature = [beforeSignature copy];
gLSLoaded = YES;
gLSFailureCount = 0;
gLSNextRetryAt = 0;
[NSUserDefaults.standardUserDefaults setObject:gLSStorePath forKey:kWQLSStoreKey];
}
// Apple's own apps and services: LaunchServices refuses this app on 27.x
// (OSStatus -54) and /Applications is not listable, so names come from here.
static NSDictionary<NSString *, NSString *> *wq_builtin_names(void)
{
static NSDictionary *names;
static dispatch_once_t once;
dispatch_once(&once, ^{
names = @{
@"com.apple.AppStore": @"App Store", @"com.apple.mobilesafari": @"Safari", @"com.apple.weather": @"Weather",
@"com.apple.news": @"News", @"com.apple.stocks": @"Stocks", @"com.apple.mobilemail": @"Mail",
@"com.apple.MobileSMS": @"Messages", @"com.apple.mobilephone": @"Phone", @"com.apple.mobileslideshow": @"Photos",
@"com.apple.camera": @"Camera", @"com.apple.mobilecal": @"Calendar", @"com.apple.mobilenotes": @"Notes",
@"com.apple.reminders": @"Reminders", @"com.apple.Maps": @"Maps", @"com.apple.Music": @"Music",
@"com.apple.tv": @"TV", @"com.apple.podcasts": @"Podcasts", @"com.apple.iBooks": @"Books",
@"com.apple.Health": @"Health", @"com.apple.Passbook": @"Wallet", @"com.apple.Preferences": @"Settings",
@"com.apple.mobiletimer": @"Clock", @"com.apple.calculator": @"Calculator", @"com.apple.compass": @"Compass",
@"com.apple.VoiceMemos": @"Voice Memos", @"com.apple.facetime": @"FaceTime", @"com.apple.MobileAddressBook": @"Contacts",
@"com.apple.DocumentsApp": @"Files", @"com.apple.findmy": @"Find My", @"com.apple.Home": @"Home",
@"com.apple.shortcuts": @"Shortcuts", @"com.apple.Translate": @"Translate", @"com.apple.Fitness": @"Fitness",
@"com.apple.Bridge": @"Watch", @"com.apple.freeform": @"Freeform", @"com.apple.journal": @"Journal",
@"com.apple.Passwords": @"Passwords", @"com.apple.measure": @"Measure", @"com.apple.Magnifier": @"Magnifier",
@"com.apple.tips": @"Tips", @"com.apple.siri": @"Siri", @"com.apple.games": @"Games", @"com.apple.gamecenter": @"Game Center",
@"com.apple.Image-Playground": @"Image Playground", @"com.apple.Photos": @"Photos", @"com.apple.Keynote": @"Keynote",
@"com.apple.Pages": @"Pages", @"com.apple.Numbers": @"Numbers", @"com.apple.mobileme.fmip1": @"Find iPhone",
@"com.apple.store.Jolly": @"Apple Store", @"com.apple.supportapp": @"Apple Support", @"com.apple.Diagnostics": @"Diagnostics",
@"com.apple.PosterBoard": @"Wallpaper", @"com.apple.MercuryPoster": @"Wallpaper (Mercury)",
@"com.apple.WallpaperKit.CollectionsPoster": @"Wallpaper Collections",
@"com.apple.PhotosUIPrivate.PhotosPosterProvider": @"Photo Wallpaper",
@"com.apple.chrono.WidgetRenderer-Default": @"Widgets", @"com.apple.ScreenshotServicesService": @"Screenshots",
@"com.apple.Sharing.AirDrop": @"AirDrop", @"com.apple.AppleMediaServicesUI": @"Apple Media Services",
@"com.apple.AppleMediaServices": @"Apple Media Services", @"com.apple.ClarityCamera": @"Assistive Access Camera",
@"com.apple.ClarityPhotos": @"Assistive Access Photos", @"com.apple.SystemVoiceAssistant": @"Voice Assistant",
@"com.apple.ShazamEventsApp": @"Shazam", @"com.apple.ScreenTimeWidgetApplication": @"Screen Time",
@"com.apple.CBRemoteSetup": @"Remote Setup", @"com.apple.DemoApp": @"Demo", @"com.apple.AAUIViewService": @"Apple Account",
@"com.apple.AccountAuthenticationDialog": @"Account Sign-In", @"com.apple.StoreKitUISceneService": @"App Store Sheets",
@"com.apple.NewDeviceOutreachApp": @"New Device Setup", @"com.apple.PDUIApp": @"Printing", @"com.apple.EventViewService": @"Events",
@"com.apple.MXUIService": @"Media Picker", @"com.apple.ctkui": @"Smart Card", @"com.apple.InCallService": @"Call Screen",
@"com.apple.springboard": @"SpringBoard", @"com.apple.SafariViewService": @"Safari View", @"com.apple.mobilesms.compose": @"Message Compose",
@"com.apple.CompanionViewService": @"Companion", @"com.apple.musicrecognition": @"Music Recognition",
@"com.apple.FontInstallViewService": @"Font Install", @"com.apple.CMViewSrvc": @"Continuity Markup",
@"com.apple.AppleCashStickerPackHost": @"Apple Cash Stickers", @"com.apple.TVSetupUIService": @"Apple TV Setup",
@"com.apple.HearingApp": @"Hearing", @"com.apple.CompanionSetup": @"Companion Setup",
@"com.apple.CarPlayWallpaper": @"CarPlay Wallpaper", @"com.apple.CloudKit.ShareBear": @"iCloud Sharing",
@"com.apple.EnhancedLogging": @"Enhanced Logging", @"com.apple.tcc.AuthorizationPromptService": @"Privacy Prompts",
@"com.apple.WebContentFilter.remoteUI.WebContentAnalysisUI": @"Content Filter",
@"com.apple.SystemPaperViewService": @"Quick Note", @"com.apple.HeadphoneProxService": @"Headphones",
@"com.apple.datadetectors.DDActionsService": @"Data Detectors", @"com.apple.ServicesPaymentAngel": @"Apple Pay",
@"com.apple.HomeCaptiveViewService": @"Wi-Fi Login", @"com.apple.DiagnosticsService": @"Diagnostics",
@"com.apple.CarTirePressure": @"Tire Pressure", @"com.apple.FCAuthenticationUI": @"Authentication",
@"com.apple.icloud.FindMyDevice.FindMyExtensionContainer": @"Find My",
@"com.apple.messages.RemoteAlerts": @"Messages Alerts",
@"com.apple.AccessoryNotifications.SourceSelection": @"Accessory Source",
@"com.apple.printcenter": @"Print Center", @"com.apple.BarcodeScanner": @"Code Scanner",
@"com.apple.chrono.WidgetRenderer-Snapshots": @"Widget Snapshots",
@"com.apple.AMSEngagementViewService": @"Apple Media Services",
@"com.apple.appManagedFeatures.Restrictions": @"Restrictions", @"com.apple.CarRadio": @"Car Radio",
@"com.apple.PassbookUIService": @"Wallet Sheets", @"com.apple.Spotlight": @"Spotlight", @"com.apple.InputUI": @"Keyboard",
};
});
return names;
}
static NSString *wq_known_name_locked(NSString *bundleID)
{
wq_load_ls_names_locked();
return gLSDisplay[bundleID] ?: wq_builtin_names()[bundleID] ?: gLSBundleName[bundleID];
}
BOOL wq_display_names_loaded(void)
{
@synchronized (wq_ls_lock()) { return gLSLoaded; }
}
void wq_load_display_names(void)
{
@synchronized (wq_ls_lock()) { wq_load_ls_names_locked(); }
}
void wq_display_names_reset_miss(void)
{
@synchronized (wq_ls_lock()) {
[NSUserDefaults.standardUserDefaults removeObjectForKey:kWQLSMissKey];
gLSNextRetryAt = 0;
gLSFailureCount = 0;
}
}
static NSString *const kWQBundleContainerRoot = @"/private/var/containers/Bundle/Application/";
// App bundle containers (<root>/<UUID>, holding Name.app) -> bundle id, built
// from the bundle paths; rebuilt when a new database snapshot is published.
static NSDictionary<NSString *, NSString *> *wq_bundle_containers_locked(void)
{
static NSDictionary *source, *map;
wq_load_ls_names_locked();
if (map && source == gLSBundlePaths) return map;
NSMutableDictionary *built = [NSMutableDictionary new];
[gLSBundlePaths enumerateKeysAndObjectsUsingBlock:^(NSString *bid, NSString *path, BOOL *__unused stop) {
if (![bid isKindOfClass:NSString.class] || ![path isKindOfClass:NSString.class]) return;
NSString *app = path.stringByStandardizingPath;
if ([app hasPrefix:@"/var/"]) app = [@"/private" stringByAppendingString:app];
if (![app hasPrefix:kWQBundleContainerRoot] || ![app.pathExtension isEqualToString:@"app"]) return;
NSString *container = app.stringByDeletingLastPathComponent;
// Only <root>/<UUID>/Name.app: extensions and nested apps stay out.
if (![container.stringByDeletingLastPathComponent isEqualToString:
[kWQBundleContainerRoot substringToIndex:kWQBundleContainerRoot.length - 1]]) return;
if (!built[container] || bid.length < [built[container] length]) built[container] = bid;
}];
source = gLSBundlePaths;
map = [built copy];
return map;
}
NSString *wq_bundle_id_for_container(NSString *path)
{
if (!path.length) return nil;
NSString *key = path.stringByStandardizingPath;
if ([key hasPrefix:@"/var/"]) key = [@"/private" stringByAppendingString:key];
@synchronized (wq_ls_lock()) {
wq_load_ls_names_locked();
NSString *bid = gLSContainers[key];
if (!bid && [key hasPrefix:kWQBundleContainerRoot]) bid = wq_bundle_containers_locked()[key];
return [bid isKindOfClass:NSString.class] ? bid : nil;
}
}
NSArray<NSString *> *wq_ls_bundle_containers(void)
{
@synchronized (wq_ls_lock()) {
return [wq_bundle_containers_locked().allKeys sortedArrayUsingSelector:@selector(compare:)];
}
}
NSString *wq_display_name(NSString *bundleID)
{
if (!bundleID) return nil;
@synchronized (wq_ls_lock()) {
NSString *name = wq_known_name_locked(bundleID);
if (name) return WQLocalized(name);
// Unknown id: use the closest parent app (com.apple.news.widget -> News).
NSArray *parts = [bundleID componentsSeparatedByString:@"."];
for (NSInteger n = (NSInteger)parts.count - 1; n >= 2; n--) {
NSString *prefix = [[parts subarrayWithRange:NSMakeRange(0, (NSUInteger)n)] componentsJoinedByString:@"."];
NSString *parent = wq_known_name_locked(prefix);
if (parent) {
NSString *suffix = wq_safe_name(parts.lastObject);
NSString *display = suffix.length ? [NSString stringWithFormat:@"%@ \u2013 %@", parent, suffix] : parent;
return WQLocalized(display);
}
}
return nil;
}
}
NSArray<NSString *> *wq_ls_container_paths(NSString *storeHint)
{
// No lock and no scan here: the container scan calls this while a name
// load may be waiting for that scan.
NSUserDefaults *defaults = NSUserDefaults.standardUserDefaults;
NSString *path = storeHint.length ? storeHint : [defaults stringForKey:kWQLSStoreKey];
if (!path.length) return nil;
NSString *error = nil;
NSData *data = wq_read(path, kWQLSMaxStoreBytes + 1, &error);
if (!data || data.length > kWQLSMaxStoreBytes) {
wq_log(@"names: cannot read %@ (%@)", path, error ?: @"too large");
return nil;
}
NSDictionary *containers = nil;
if (!wq_parse_ls_store(data, NULL, NULL, &containers, NULL) || containers.count < 2) return nil;
if (storeHint.length) {
[defaults setObject:storeHint forKey:kWQLSStoreKey];
[defaults removeObjectForKey:kWQLSMissKey];
}
// App group containers aren't mapped to an owner here; the store lists
// the group containers of installed apps, which is what the list needs.
NSMutableSet<NSString *> *all = [NSMutableSet setWithArray:containers.allKeys];
static const char needle[] = "/mobile/Containers/Shared/AppGroup/";
const uint8_t *bytes = data.bytes, *cursor = bytes, *end = bytes + data.length;
while (cursor < end) {
const uint8_t *hit = memmem(cursor, (size_t)(end - cursor), needle, sizeof(needle) - 1);
if (!hit) break;
cursor = hit + sizeof(needle) - 1;
if ((size_t)(end - cursor) < 36) break;
NSString *uuid = [[NSString alloc] initWithBytes:cursor length:36 encoding:NSASCIIStringEncoding];
if (!uuid || ![[NSUUID alloc] initWithUUIDString:uuid]) continue;
[all addObject:[@"/private/var/mobile/Containers/Shared/AppGroup/" stringByAppendingString:uuid.uppercaseString]];
}
return all.allObjects;
}
NSString *wq_bundle_path_for_id(NSString *bundleID)
{
if (!bundleID.length) return nil;
@synchronized (wq_ls_lock()) {
wq_load_ls_names_locked();
NSString *path = gLSBundlePaths[bundleID];
return [path isKindOfClass:NSString.class] ? path : nil;
}
}
+18
View File
@@ -0,0 +1,18 @@
//
// WQLocalization.h
// WQ Files
//
// Shared runtime localization helpers. English source strings are the keys;
// zh-Hans.lproj supplies the Chinese translations and en.lproj is the
// identity fallback.
//
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
NSString *WQLocalized(NSString *key);
NSString *_Nullable WQLocalizedNullable(NSString *_Nullable text);
NSString *WQLocalizedFormat(NSString *key, ...) NS_FORMAT_FUNCTION(1, 2);
NS_ASSUME_NONNULL_END
+27
View File
@@ -0,0 +1,27 @@
//
// WQLocalization.m
// WQ Files
//
#import "WQLocalization.h"
NSString *WQLocalized(NSString *key)
{
if (!key.length) return key;
return NSLocalizedString(key, nil);
}
NSString *WQLocalizedNullable(NSString *text)
{
return text ? WQLocalized(text) : nil;
}
NSString *WQLocalizedFormat(NSString *key, ...)
{
va_list args;
va_start(args, key);
NSString *format = WQLocalized(key);
NSString *text = [[NSString alloc] initWithFormat:format arguments:args];
va_end(args);
return text;
}
+35
View File
@@ -0,0 +1,35 @@
//
// WQMediaStream.h
// WQ Files
//
// Plays media from other apps' folders without copying them first. The
// player gets a custom URL; AVFoundation asks this object for byte ranges,
// which WQ Files reads itself (the media process has no access to those
// folders). If the item fails, onFailure runs once, so the caller can fall
// back to copying the file.
//
#import <AVFoundation/AVFoundation.h>
NS_ASSUME_NONNULL_BEGIN
typedef NSData *_Nullable (^WQMediaReadRange)(unsigned long long offset, NSUInteger length, NSString *_Nullable *_Nullable error);
@interface WQMediaStream : NSObject <AVAssetResourceLoaderDelegate>
// The type identifier the player is told for a file name ("public.mpeg-4"),
// or nil when the extension isn't one that streams this way.
+ (nullable NSString *)contentTypeForFileName:(NSString *)name;
// A player item that reads `size` bytes through `read`. The item keeps the
// stream alive. onFailure (main queue) gets the reason once if the item fails.
+ (nullable AVPlayerItem *)playerItemForName:(NSString *)name size:(unsigned long long)size
read:(WQMediaReadRange)read
onFailure:(void (^)(NSString *reason))onFailure;
// For tests: answers one data request range synchronously.
- (nullable NSData *)dataForOffset:(unsigned long long)offset length:(NSUInteger)length error:(NSString *_Nullable *_Nullable)error;
@end
NS_ASSUME_NONNULL_END
+134
View File
@@ -0,0 +1,134 @@
//
// WQMediaStream.m
// WQ Files
//
#import "WQMediaStream.h"
#import <UniformTypeIdentifiers/UniformTypeIdentifiers.h>
#import <objc/runtime.h>
static NSString *const kWQMediaScheme = @"wqmedia";
static const NSUInteger kWQMediaChunk = 512 * 1024;
static char kWQMediaStreamKey;
@interface WQMediaStream ()
@property (nonatomic, copy) NSString *contentType;
@property (nonatomic) unsigned long long size;
@property (nonatomic, copy) WQMediaReadRange read;
@property (nonatomic, copy) void (^onFailure)(NSString *reason);
@property (nonatomic, weak) AVPlayerItem *item;
@property (nonatomic) BOOL failed;
@end
@implementation WQMediaStream {
dispatch_queue_t _readQueue;
}
+ (NSString *)contentTypeForFileName:(NSString *)name
{
NSString *ext = name.pathExtension.lowercaseString;
// Playlists reference other files; those can't be served from here.
if (!ext.length || [ext isEqualToString:@"m3u8"]) return nil;
UTType *type = [UTType typeWithFilenameExtension:ext];
if (!type || !([type conformsToType:UTTypeAudiovisualContent])) return nil;
return type.identifier;
}
+ (AVPlayerItem *)playerItemForName:(NSString *)name size:(unsigned long long)size
read:(WQMediaReadRange)read onFailure:(void (^)(NSString *))onFailure
{
NSString *contentType = [self contentTypeForFileName:name];
if (!contentType || size == 0) return nil;
WQMediaStream *stream = [WQMediaStream new];
stream.contentType = contentType;
stream.size = size;
stream.read = read;
stream.onFailure = onFailure;
// The extension stays on the URL: AVFoundation also looks at it.
NSString *escaped = [name.lastPathComponent stringByAddingPercentEncodingWithAllowedCharacters:NSCharacterSet.URLPathAllowedCharacterSet];
NSURL *url = [NSURL URLWithString:[NSString stringWithFormat:@"%@://media/%@", kWQMediaScheme, escaped ?: @"media"]];
if (!url) return nil;
AVURLAsset *asset = [AVURLAsset URLAssetWithURL:url options:nil];
[asset.resourceLoader setDelegate:stream queue:dispatch_queue_create("wq.media.loader", DISPATCH_QUEUE_SERIAL)];
AVPlayerItem *item = [AVPlayerItem playerItemWithAsset:asset];
stream.item = item;
// The resource loader holds its delegate weakly: the item keeps it.
objc_setAssociatedObject(item, &kWQMediaStreamKey, stream, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
[item addObserver:stream forKeyPath:@"status" options:NSKeyValueObservingOptionNew context:&kWQMediaStreamKey];
return item;
}
- (instancetype)init
{
if ((self = [super init])) _readQueue = dispatch_queue_create("wq.media.read", DISPATCH_QUEUE_SERIAL);
return self;
}
- (void)dealloc
{
@try { [_item removeObserver:self forKeyPath:@"status" context:&kWQMediaStreamKey]; } @catch (NSException *__unused e) {}
}
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context
{
if (context != &kWQMediaStreamKey) { [super observeValueForKeyPath:keyPath ofObject:object change:change context:context]; return; }
AVPlayerItem *item = object;
if (item.status != AVPlayerItemStatusFailed) return;
NSString *reason = item.error.localizedDescription ?: @"the player failed";
NSError *underlying = item.error.userInfo[NSUnderlyingErrorKey];
if (underlying) reason = [reason stringByAppendingFormat:@" (%@ %ld)", underlying.domain, (long)underlying.code];
dispatch_async(dispatch_get_main_queue(), ^{
if (self.failed) return;
self.failed = YES;
if (self.onFailure) self.onFailure(reason);
});
}
- (NSData *)dataForOffset:(unsigned long long)offset length:(NSUInteger)length error:(NSString **)error
{
if (offset >= _size) return [NSData data];
unsigned long long left = _size - offset;
NSUInteger want = (NSUInteger)MIN((unsigned long long)length, left);
return _read(offset, want, error);
}
- (BOOL)resourceLoader:(AVAssetResourceLoader *)resourceLoader
shouldWaitForLoadingOfRequestedResource:(AVAssetResourceLoadingRequest *)request
{
if (![request.request.URL.scheme isEqualToString:kWQMediaScheme]) return NO;
AVAssetResourceLoadingContentInformationRequest *info = request.contentInformationRequest;
if (info) {
info.contentType = _contentType;
info.contentLength = (long long)_size;
info.byteRangeAccessSupported = YES;
}
AVAssetResourceLoadingDataRequest *data = request.dataRequest;
if (!data) { [request finishLoading]; return YES; }
// Reads happen off the loader queue, in chunks, so a seek can cancel a
// long request between chunks.
dispatch_async(_readQueue, ^{
long long start = data.currentOffset;
long long end = data.requestsAllDataToEndOfResource ? (long long)self->_size
: data.requestedOffset + (long long)data.requestedLength;
end = MIN(end, (long long)self->_size);
long long offset = start;
while (offset < end) {
if (request.isCancelled || request.isFinished) return;
NSUInteger length = (NSUInteger)MIN((long long)kWQMediaChunk, end - offset);
NSString *error = nil;
NSData *chunk = [self dataForOffset:(unsigned long long)offset length:length error:&error];
if (!chunk.length) {
if (request.isCancelled || request.isFinished) return;
[request finishLoadingWithError:[NSError errorWithDomain:@"WQMediaStream" code:1
userInfo:@{NSLocalizedDescriptionKey: error ?: @"The file could not be read"}]];
return;
}
[data respondWithData:chunk];
offset += (long long)chunk.length;
}
if (!request.isCancelled && !request.isFinished) [request finishLoading];
});
return YES;
}
@end
+23
View File
@@ -0,0 +1,23 @@
//
// WQPlistEditor.h
// WQ Files
//
// Property list editor: browse dictionaries and arrays, edit values by type,
// add, delete, rename and reorder. Saves in the file's original format through
// wq_write_if_unchanged_get_version (backup first, verified contents).
//
#import <UIKit/UIKit.h>
#import "WQEditing.h"
NS_ASSUME_NONNULL_BEGIN
@interface WQPlistEditorViewController : UITableViewController <WQUnsavedEditing>
// Loads `path` itself. openAsText / openAsHex (optional) switch to the other
// viewers from the "Open As" menu.
- (instancetype)initWithPath:(NSString *)path readOnly:(BOOL)readOnly;
@property (nonatomic, copy, nullable) void (^openAsText)(void);
@property (nonatomic, copy, nullable) void (^openAsHex)(void);
@end
NS_ASSUME_NONNULL_END
+738
View File
@@ -0,0 +1,738 @@
//
// WQPlistEditor.m
// WQ Files
//
// GPL-3.0-or-later.
//
#import "WQPlistEditor.h"
#import "WQAccess.h"
#import "WQCore.h"
#import "WQUI.h"
#import "WQPlistEditorSupport.h"
typedef NS_ENUM(NSInteger, WQPType) {
WQPTypeString, WQPTypeInteger, WQPTypeReal, WQPTypeBoolean, WQPTypeDate, WQPTypeData,
WQPTypeDictionary, WQPTypeArray, WQPTypeUID, WQPTypeUnknown,
};
static WQPType wqp_type(id v)
{
if ([v isKindOfClass:NSString.class]) return WQPTypeString;
if ([v isKindOfClass:NSNumber.class]) {
if (CFGetTypeID((__bridge CFTypeRef)v) == CFBooleanGetTypeID()) return WQPTypeBoolean;
return CFNumberIsFloatType((__bridge CFNumberRef)v) ? WQPTypeReal : WQPTypeInteger;
}
if ([v isKindOfClass:NSDate.class]) return WQPTypeDate;
if ([v isKindOfClass:NSData.class]) return WQPTypeData;
if ([v isKindOfClass:NSDictionary.class]) return WQPTypeDictionary;
if ([v isKindOfClass:NSArray.class]) return WQPTypeArray;
if (WQPlistIsKeyedArchiveUID(v)) return WQPTypeUID;
return WQPTypeUnknown;
}
static NSString *wqp_type_name(WQPType t)
{
switch (t) {
case WQPTypeString: return WQLocalized(@"String");
case WQPTypeInteger: return WQLocalized(@"Number");
case WQPTypeReal: return WQLocalized(@"Real");
case WQPTypeBoolean: return WQLocalized(@"Boolean");
case WQPTypeDate: return WQLocalized(@"Date");
case WQPTypeData: return WQLocalized(@"Data");
case WQPTypeDictionary: return WQLocalized(@"Dictionary");
case WQPTypeArray: return WQLocalized(@"Array");
case WQPTypeUID: return WQLocalized(@"Reference");
case WQPTypeUnknown: return @"?";
}
return @"?";
}
static NSString *wqp_hex(NSData *data, NSUInteger max)
{
const uint8_t *b = data.bytes;
NSUInteger n = MIN(data.length, max);
NSMutableString *s = [NSMutableString stringWithCapacity:n * 3];
for (NSUInteger i = 0; i < n; i++) [s appendFormat:i ? @" %02x" : @"%02x", b[i]];
if (data.length > max) [s appendString:@" …"];
return s;
}
static NSISO8601DateFormatter *wqp_dates(void)
{
static NSISO8601DateFormatter *f;
static dispatch_once_t once;
dispatch_once(&once, ^{ f = [NSISO8601DateFormatter new]; });
return f;
}
static NSString *wqp_summary(id v)
{
switch (wqp_type(v)) {
case WQPTypeString: return WQLocalized(WQPBoundedStringPreview(v));
case WQPTypeInteger: case WQPTypeReal: return [v stringValue];
case WQPTypeBoolean: return [v boolValue] ? WQLocalized(@"YES") : WQLocalized(@"NO");
case WQPTypeDate: return [wqp_dates() stringFromDate:v];
case WQPTypeData: return WQLocalizedFormat(@"%lu bytes · %@", (unsigned long)[v length], wqp_hex(v, 24));
case WQPTypeDictionary: return WQLocalizedFormat(@"%lu keys", (unsigned long)[v count]);
case WQPTypeArray: return WQLocalizedFormat(@"%lu items", (unsigned long)[v count]);
case WQPTypeUID: return WQLocalized(@"keyed-archive reference");
case WQPTypeUnknown: return WQPBoundedStringPreview([v description]);
}
return @"";
}
static NSString *wqp_scalar_text(id value, WQPType t)
{
if (t == WQPTypeData) return wqp_hex(value, WQPMaxDataBytes);
if (t == WQPTypeDate) return [wqp_dates() stringFromDate:value];
if ([value isKindOfClass:NSString.class]) return value;
if ([value isKindOfClass:NSNumber.class]) return [value stringValue];
return @"";
}
static id wqp_default(WQPType t)
{
switch (t) {
case WQPTypeString: return @"";
case WQPTypeInteger: return @0;
case WQPTypeReal: return @0.0;
case WQPTypeBoolean: return @NO;
case WQPTypeDate: return NSDate.date;
case WQPTypeData: return [NSData data];
case WQPTypeDictionary: return [NSMutableDictionary dictionary];
case WQPTypeArray: return [NSMutableArray array];
case WQPTypeUID: return @"";
case WQPTypeUnknown: return @"";
}
return @"";
}
static void wqp_alert(UIViewController *host, NSString *title, NSString *message)
{
UIAlertController *a = wq_alert_controller(title, message, UIAlertControllerStyleAlert);
[a addAction:wq_alert_action(@"OK", UIAlertActionStyleDefault, nil)];
[host presentViewController:a animated:YES completion:nil];
}
#pragma mark - Document
@interface WQPlistDocument : NSObject
@property (nonatomic, copy) NSString *path;
@property (nonatomic, strong) id root;
@property (nonatomic) NSPropertyListFormat format;
@property (nonatomic) BOOL readOnly;
@property (nonatomic, strong) WQEditState *edit;
@property (nonatomic, strong) NSDictionary<NSString *, NSNumber *> *version;
@end
@implementation WQPlistDocument
@end
#pragma mark - Editor
@interface WQPlistEditorViewController ()
@property (nonatomic, strong) WQPlistDocument *doc;
@property (nonatomic, strong) id container; // NSMutableDictionary or NSMutableArray
@property (nonatomic, copy) NSArray *keys; // sorted keys (dictionary) or nil (array)
@property (nonatomic, copy) NSString *status; // loading / error text
@property (nonatomic) BOOL isRoot;
@end
@implementation WQPlistEditorViewController {
NSArray *_sortedKeyCache;
BOOL _keysDirty;
}
- (instancetype)initWithPath:(NSString *)path readOnly:(BOOL)readOnly
{
if ((self = [super initWithStyle:UITableViewStyleInsetGrouped])) {
_doc = [WQPlistDocument new];
_doc.path = path;
_doc.readOnly = readOnly;
_doc.edit = [WQEditState new];
_isRoot = YES;
_status = WQLocalized(@"Loading…");
self.title = path.lastPathComponent;
}
return self;
}
- (instancetype)initWithDocument:(WQPlistDocument *)doc container:(id)container title:(NSString *)title
{
if ((self = [super initWithStyle:UITableViewStyleInsetGrouped])) {
_doc = doc;
_container = container;
self.title = title;
}
return self;
}
- (void)viewDidLoad
{
[super viewDidLoad];
self.navigationItem.largeTitleDisplayMode = UINavigationItemLargeTitleDisplayModeNever;
__weak typeof(self) weakSelf = self;
if (self.isRoot) {
self.navigationItem.backAction = [UIAction actionWithHandler:^(UIAction *__unused a) { [weakSelf leave]; }];
[self load];
} else {
self.navigationItem.backAction = [UIAction actionWithHandler:^(UIAction *__unused a) {
if ([weakSelf blockWhileSaving]) return;
[weakSelf.navigationController popViewControllerAnimated:YES];
}];
}
[self updateBar];
}
- (void)viewWillAppear:(BOOL)animated
{
[super viewWillAppear:animated];
[self refresh];
}
- (void)load
{
NSString *path = self.doc.path;
dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{
NSString *error = nil;
NSDictionary *__unused before = wq_file_version(path);
NSData *data = wq_read(path, WQPMaxSerializedBytes, &error);
NSDictionary *version = wq_file_version(path);
if (data && (!before || ![before isEqualToDictionary:version])) {
data = nil;
error = WQLocalized(@"The file changed while it was being read. Open it again.");
}
NSPropertyListFormat format = NSPropertyListBinaryFormat_v1_0;
NSError *parseError = nil;
id root = data ? [NSPropertyListSerialization propertyListWithData:data
options:NSPropertyListMutableContainersAndLeaves
format:&format error:&parseError] : nil;
dispatch_async(dispatch_get_main_queue(), ^{
self.doc.version = version;
if (!root) {
self.status = data ? WQLocalizedFormat(@"Not a property list: %@", parseError.localizedDescription)
: (wq_permission_hint(path, NO) ?: WQLocalizedFormat(@"Cannot read this file: %@", error));
} else if (![root isKindOfClass:NSMutableDictionary.class] && ![root isKindOfClass:NSMutableArray.class]) {
// A plist whose root is a single value: wrap it for display only.
self.doc.format = format;
self.doc.root = root;
self.doc.readOnly = YES;
self.container = [NSMutableArray arrayWithObject:root];
self.status = nil;
} else {
if (wq_permission_hint(path, YES)) self.doc.readOnly = YES; // readable, but only the owner may write
if (!WQPlistFormatIsWritable(format)) self.doc.readOnly = YES; // OpenStep/unknown format
self.doc.root = root;
self.doc.format = format;
self.container = root;
self.status = nil;
}
[self refresh];
});
});
}
- (void)refresh
{
if ([self.container isKindOfClass:NSDictionary.class]) {
if (!_sortedKeyCache || _keysDirty) {
_sortedKeyCache = [[self.container allKeys] sortedArrayUsingSelector:@selector(localizedStandardCompare:)];
_keysDirty = NO;
}
self.keys = _sortedKeyCache;
} else {
self.keys = nil;
_sortedKeyCache = nil;
_keysDirty = NO;
}
[self updateBar];
[self.tableView reloadData];
}
- (BOOL)isArray
{
return [self.container isKindOfClass:NSArray.class];
}
- (void)updateBar
{
NSMutableArray *items = [NSMutableArray array];
if (!self.doc.readOnly && self.container) {
UIBarButtonItem *save = [[UIBarButtonItem alloc] initWithTitle:WQLocalizedNullable(@"Save") style:wq_primary_button_style()
target:self action:@selector(save)];
save.enabled = self.doc.edit.dirty && !self.doc.edit.saving;
[items addObject:save];
UIBarButtonItem *add = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemAdd
target:self action:@selector(add)];
add.enabled = !self.doc.edit.saving;
[items addObject:add];
}
NSMutableArray *menu = [NSMutableArray array];
__weak typeof(self) weakSelf = self;
if (self.isArray && !self.doc.readOnly && !self.doc.edit.saving && self.container) {
[menu addObject:[UIAction actionWithTitle:WQLocalizedNullable(self.tableView.editing ? @"Done Reordering" : @"Reorder Items")
image:[UIImage systemImageNamed:@"arrow.up.arrow.down"] identifier:nil
handler:^(UIAction *__unused a) {
[weakSelf.tableView setEditing:!weakSelf.tableView.editing animated:YES];
[weakSelf updateBar];
}]];
}
if (self.openAsText) [menu addObject:[UIAction actionWithTitle:WQLocalizedNullable(@"Open as XML Text") image:[UIImage systemImageNamed:@"doc.plaintext"]
identifier:nil handler:^(UIAction *__unused a) { [weakSelf leaveThen:weakSelf.openAsText]; }]];
if (self.openAsHex) [menu addObject:[UIAction actionWithTitle:WQLocalizedNullable(@"Open in Hex Viewer") image:[UIImage systemImageNamed:@"number"]
identifier:nil handler:^(UIAction *__unused a) { [weakSelf leaveThen:weakSelf.openAsHex]; }]];
if (menu.count)
[items addObject:[[UIBarButtonItem alloc] initWithImage:[UIImage systemImageNamed:@"ellipsis.circle"]
menu:[UIMenu menuWithChildren:menu]]];
self.navigationItem.rightBarButtonItems = items;
}
#pragma mark Leaving and saving
- (BOOL)wq_needsLeaveConfirmation
{
// Ordinary dirty child navigation keeps the shared edits, but no editor
// may leave while the shared document is being written.
return self.doc.edit.saving || (self.isRoot && self.doc.edit.dirty);
}
- (void)leave
{
[self leaveThen:nil];
}
- (BOOL)blockWhileSaving
{
if (!self.doc.edit.saving) return NO;
wqp_alert(self, @"Save in progress", @"Wait for the current save to finish before leaving or making more changes.");
return YES;
}
// Asks before throwing away unsaved changes, then pops to the folder and runs `then`.
- (void)leaveThen:(void (^)(void))then
{
if ([self blockWhileSaving]) return;
UINavigationController *nav = self.navigationController;
NSUInteger rootIndex = [nav.viewControllers indexOfObjectPassingTest:^BOOL(UIViewController *vc, NSUInteger __unused i, BOOL *__unused stop) {
return [vc isKindOfClass:WQPlistEditorViewController.class] && ((WQPlistEditorViewController *)vc).isRoot &&
((WQPlistEditorViewController *)vc).doc == self.doc;
}];
void (^go)(void) = ^{
if (rootIndex != NSNotFound && rootIndex > 0)
[nav popToViewController:nav.viewControllers[rootIndex - 1] animated:then == nil];
if (then) then();
};
if (!self.doc.edit.dirty) { go(); return; }
UIAlertController *a = wq_alert_controller(@"Discard changes?", @"Your edits to this property list have not been saved.", UIAlertControllerStyleAlert);
[a addAction:wq_alert_action(@"Keep Editing", UIAlertActionStyleCancel, nil)];
[a addAction:wq_alert_action(@"Discard", UIAlertActionStyleDestructive, ^(UIAlertAction *__unused x) {
[self.doc.edit discard];
go();
})];
[self presentViewController:a animated:YES completion:nil];
}
// Home button: ask before throwing away unsaved changes.
- (void)confirmLeaveThen:(void (^)(void))proceed
{
if ([self blockWhileSaving]) return;
if (!self.doc.edit.dirty) { proceed(); return; }
UIAlertController *a = wq_alert_controller(@"Discard changes?", @"Your edits to this property list have not been saved.", UIAlertControllerStyleAlert);
[a addAction:wq_alert_action(@"Keep Editing", UIAlertActionStyleCancel, nil)];
[a addAction:wq_alert_action(@"Discard", UIAlertActionStyleDestructive, ^(UIAlertAction *__unused x) {
[self.doc.edit discard];
proceed();
})];
[self presentViewController:a animated:YES completion:nil];
}
- (void)save
{
if ([self blockWhileSaving] || self.doc.readOnly || !self.doc.edit.dirty) return;
WQPlistDocument *doc = self.doc;
if (!WQPlistFormatIsWritable(doc.format)) {
wqp_alert(self, @"Read-only format", @"This property list uses an OpenStep/unknown format that cannot be saved by the editor. Use Open as XML Text and choose a supported format explicitly.");
return;
}
if (!doc.version) {
wqp_alert(self, @"Cannot save safely", @"The current file version is unavailable. Reopen the file before saving again.");
return;
}
NSString *fmt = WQLocalized(WQPlistFormatName(doc.format));
UIAlertController *a = wq_alert_controller(@"Save changes?", WQLocalizedFormat(@"%@ is overwritten in place (%@ format). The current version is backed up first.", doc.path, fmt), UIAlertControllerStyleAlert);
[a addAction:wq_alert_action(@"Cancel", UIAlertActionStyleCancel, nil)];
[a addAction:wq_alert_action(@"Save", UIAlertActionStyleDestructive, ^(UIAlertAction *__unused x) {
NSUInteger savedRevision = [doc.edit beginSave];
NSDictionary *expectedVersion = [doc.version copy];
NSString *path = [doc.path copy];
NSPropertyListFormat format = doc.format;
id root = doc.root;
UINavigationController *navigationController = self.navigationController;
__weak WQPlistEditorViewController *weakSelf = self;
[self refresh];
dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{
NSError *serializationError = nil;
NSString *writeError = nil;
BOOL ok = NO;
NSDictionary *savedVersion = nil;
NSString *copyError = nil;
id snapshot = WQPlistImmutableCopy(root, &copyError);
NSData *data = snapshot ? [NSPropertyListSerialization dataWithPropertyList:snapshot
format:format
options:0
error:&serializationError] : nil;
if (!snapshot) {
writeError = copyError ?: WQLocalized(@"The edited property list contains an unsupported value.");
} else if (!data) {
writeError = serializationError.localizedDescription ?: WQLocalized(@"The edited property list is not valid.");
} else if (!WQPSerializedDataWithinLimit(data)) {
writeError = WQLocalizedFormat(@"The serialized property list is too large (maximum %lu MiB).", (unsigned long)(WQPMaxSerializedBytes / (1024 * 1024)));
} else {
NSError *validationError = nil;
id check = [NSPropertyListSerialization propertyListWithData:data
options:NSPropertyListImmutable
format:NULL
error:&validationError];
if (!check) {
writeError = validationError.localizedDescription ?: WQLocalized(@"The serialized property list could not be validated.");
} else {
ok = wq_write_if_unchanged_get_version(path, data, expectedVersion, &savedVersion, &writeError);
}
}
dispatch_async(dispatch_get_main_queue(), ^{
[doc.edit finishSave:ok revision:savedRevision];
// Returned while the saved bytes were verified on the locked
// descriptor; a later external change is rejected next save.
if (ok) doc.version = savedVersion;
WQPlistEditorViewController *origin = weakSelf;
UINavigationController *nav = navigationController ?: origin.navigationController;
WQPlistEditorViewController *visible = nil;
for (UIViewController *controller in nav.viewControllers) {
if (![controller isKindOfClass:WQPlistEditorViewController.class]) continue;
WQPlistEditorViewController *editor = (WQPlistEditorViewController *)controller;
if (editor.doc != doc) continue;
[editor refresh];
if (controller == nav.visibleViewController) visible = editor;
}
if (!visible && origin.doc == doc) {
[origin refresh];
if (origin.viewIfLoaded.window) visible = origin;
}
if (!ok && visible.viewIfLoaded.window) {
NSString *hint = wq_permission_hint(path, YES);
wqp_alert(visible, @"Save failed", hint ?: writeError ?: @"Unknown error");
}
});
});
})];
[self presentViewController:a animated:YES completion:nil];
}
- (void)changed
{
if (self.doc.readOnly || self.doc.edit.saving) return;
// Callers have already changed the data. Record it even during a save: the
// newer revision keeps the document dirty when that save finishes. The
// guard above also protects stale UI callbacks from changing readonly or
// frozen documents.
_keysDirty = YES;
[self.doc.edit noteChange];
[self refresh];
}
#pragma mark Table
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
{
return self.status ? 0 : (NSInteger)[self.container count];
}
- (NSString *)tableView:(UITableView *)tableView titleForFooterInSection:(NSInteger)section
{
if (self.status) return self.status;
if (!self.isRoot) return nil;
NSString *fmt = WQLocalized(WQPlistFormatName(self.doc.format));
if (self.doc.readOnly) {
NSString *reason = WQPlistFormatIsWritable(self.doc.format) ? WQLocalized(@"read only") : WQLocalized(@"read only · writing is not supported");
return WQLocalizedFormat(@"%@ property list · %@", fmt, reason);
}
return WQLocalizedFormat(@"%@ property list. Tap a value to edit it, swipe to delete or rename, + to add.", fmt);
}
- (id)keyAt:(NSInteger)row
{
return self.isArray ? @(row) : self.keys[(NSUInteger)row];
}
- (id)valueAt:(NSInteger)row
{
return self.isArray ? self.container[(NSUInteger)row] : self.container[self.keys[(NSUInteger)row]];
}
- (void)setValue:(id)value at:(NSInteger)row
{
if (self.doc.readOnly || self.doc.edit.saving) return;
if (self.isArray) self.container[(NSUInteger)row] = value;
else self.container[self.keys[(NSUInteger)row]] = value;
}
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"plist"];
if (!cell) cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"plist"];
cell.accessoryView = nil;
cell.accessoryType = UITableViewCellAccessoryNone;
cell.selectionStyle = UITableViewCellSelectionStyleDefault;
UIListContentConfiguration *c = [UIListContentConfiguration subtitleCellConfiguration];
id value = [self valueAt:indexPath.row];
WQPType t = wqp_type(value);
c.text = self.isArray ? WQLocalizedFormat(@"Item %ld", (long)indexPath.row) : [self keyAt:indexPath.row];
c.secondaryText = WQLocalizedFormat(@"%@ · %@", wqp_type_name(t), wqp_summary(value));
c.secondaryTextProperties.numberOfLines = 3;
c.secondaryTextProperties.color = UIColor.secondaryLabelColor;
if (t == WQPTypeDictionary || t == WQPTypeArray) {
cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator;
} else if (t == WQPTypeBoolean) {
UISwitch *sw = [UISwitch new];
sw.on = [value boolValue];
sw.enabled = !self.doc.readOnly && !self.doc.edit.saving;
sw.tag = indexPath.row;
[sw addTarget:self action:@selector(boolChanged:) forControlEvents:UIControlEventValueChanged];
cell.accessoryView = sw;
cell.selectionStyle = UITableViewCellSelectionStyleNone;
}
cell.contentConfiguration = c;
return cell;
}
- (void)boolChanged:(UISwitch *)sw
{
if (self.doc.readOnly || self.doc.edit.saving) {
sw.on = [[self valueAt:sw.tag] boolValue];
return;
}
[self setValue:@(sw.on) at:sw.tag];
[self changed];
}
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath
{
[tableView deselectRowAtIndexPath:indexPath animated:YES];
if (self.doc.edit.saving) return;
id value = [self valueAt:indexPath.row];
WQPType t = wqp_type(value);
NSString *name = self.isArray ? WQLocalizedFormat(@"Item %ld", (long)indexPath.row) : [self keyAt:indexPath.row];
if (t == WQPTypeDictionary || t == WQPTypeArray) {
WQPlistEditorViewController *child = [[WQPlistEditorViewController alloc] initWithDocument:self.doc container:value title:name];
[self.navigationController pushViewController:child animated:YES];
return;
}
if (t == WQPTypeUID) {
wqp_alert(self, @"Reference value", @"Keyed-archive references are read-only in this editor. Edit the archived data through its owning key instead.");
return;
}
if (t == WQPTypeBoolean || self.doc.readOnly || self.doc.edit.saving) return;
NSInteger row = indexPath.row;
[self editValueOfType:t current:value title:name done:^(id newValue) {
[self setValue:newValue at:row];
[self changed];
}];
}
// Asks for a scalar value of type `t`; `done` gets the parsed value.
- (void)editValueOfType:(WQPType)t current:(id)current title:(NSString *)title done:(void (^)(id value))done
{
if (self.doc.readOnly || self.doc.edit.saving) return;
if (t == WQPTypeUID) return;
if (t == WQPTypeData && [current length] > WQPMaxDataBytes) {
wqp_alert(self, @"Too large to edit here", @"Data over 4 KB can't be edited in this editor.");
return;
}
if (t == WQPTypeString && [current length] > WQPMaxStringInputCharacters) {
wqp_alert(self, @"Too large to edit here", @"Strings over 64 KB can't be edited in this editor.");
return;
}
NSString *hint = nil;
switch (t) {
case WQPTypeInteger: hint = WQLocalized(@"Whole number"); break;
case WQPTypeReal: hint = WQLocalized(@"Number"); break;
case WQPTypeDate: hint = WQLocalized(@"ISO 8601, e.g. 2026-11-09T15:00:00Z"); break;
case WQPTypeData: hint = WQLocalized(@"Hex bytes, e.g. 01 ab ff"); break;
default: break;
}
UIAlertController *a = wq_alert_controller(title, [wqp_type_name(t) stringByAppendingString:hint ? [@" · " stringByAppendingString:hint] : @""], UIAlertControllerStyleAlert);
[a addTextFieldWithConfigurationHandler:^(UITextField *f) {
f.text = wqp_scalar_text(current, t);
f.autocapitalizationType = UITextAutocapitalizationTypeNone;
f.autocorrectionType = UITextAutocorrectionTypeNo;
f.clearButtonMode = UITextFieldViewModeAlways;
if (t == WQPTypeInteger) f.keyboardType = UIKeyboardTypeNumbersAndPunctuation;
if (t == WQPTypeReal) f.keyboardType = UIKeyboardTypeDecimalPad;
}];
[a addAction:wq_alert_action(@"Cancel", UIAlertActionStyleCancel, nil)];
[a addAction:wq_alert_action(@"Set", UIAlertActionStyleDefault, ^(UIAlertAction *__unused x) {
NSString *text = a.textFields.firstObject.text ?: @"";
id value = nil;
if (t != WQPTypeData && !WQPStringInputWithinLimit(text)) {
wqp_alert(self, @"Value too large", @"Scalar values are limited to 64 KB in this editor.");
return;
}
if (t == WQPTypeData && !WQPDataInputWithinLimit(text)) {
wqp_alert(self, @"Value too large", @"Hex input is limited to 16 KB in this editor.");
return;
}
switch (t) {
case WQPTypeString: value = text; break;
case WQPTypeInteger: {
NSScanner *sc = [NSScanner scannerWithString:text];
long long n;
if ([sc scanLongLong:&n] && sc.isAtEnd) value = @(n);
break;
}
case WQPTypeReal: {
NSScanner *sc = [NSScanner scannerWithString:text];
double d;
if ([sc scanDouble:&d] && sc.isAtEnd) value = @(d);
break;
}
case WQPTypeDate: value = [wqp_dates() dateFromString:text]; break;
case WQPTypeData: {
NSData *data = WQParseHex(text, YES);
if (data.length > WQPMaxDataBytes) {
wqp_alert(self, @"Value too large", @"Data values are limited to 4 KB in this editor.");
return;
}
value = data;
break;
}
default: break;
}
if (!value) { wqp_alert(self, @"Not a valid value", WQLocalizedFormat(@"Expected: %@", hint ?: wqp_type_name(t))); return; }
done(value);
})];
[self presentViewController:a animated:YES completion:nil];
}
#pragma mark Adding, deleting, renaming, moving
- (void)add
{
if (self.doc.readOnly || self.doc.edit.saving) return;
UIAlertController *sheet = wq_alert_controller(@"Add", @"Type of the new value", UIAlertControllerStyleActionSheet);
for (WQPType t = WQPTypeString; t <= WQPTypeArray; t++) {
[sheet addAction:wq_alert_action(wqp_type_name(t), UIAlertActionStyleDefault, ^(UIAlertAction *__unused x) {
[self addValueOfType:t];
})];
}
[sheet addAction:wq_alert_action(@"Cancel", UIAlertActionStyleCancel, nil)];
sheet.popoverPresentationController.barButtonItem = self.navigationItem.rightBarButtonItems.count > 1
? self.navigationItem.rightBarButtonItems[1] : self.navigationItem.rightBarButtonItems.firstObject;
[self presentViewController:sheet animated:YES completion:nil];
}
- (void)addValueOfType:(WQPType)t
{
if (self.doc.readOnly || self.doc.edit.saving) return;
void (^insert)(NSString *key) = ^(NSString *key) {
if (self.doc.readOnly || self.doc.edit.saving) return;
id value = wqp_default(t);
void (^put)(id) = ^(id v) {
if (self.doc.readOnly || self.doc.edit.saving) return;
if (self.isArray) {
[self.container addObject:v];
} else {
if (!key.length) return; // dictionary insertion always has a validated key
NSMutableDictionary *dictionary = self.container;
dictionary[key] = v;
}
[self changed];
};
if (t == WQPTypeDictionary || t == WQPTypeArray || t == WQPTypeBoolean) { put(value); return; }
[self editValueOfType:t current:value title:key ?: @"New Item" done:put];
};
if (self.isArray) { insert(nil); return; }
[self askKey:nil title:WQLocalizedNullable(@"Key") done:insert];
}
- (void)askKey:(NSString *)current title:(NSString *)title done:(void (^)(NSString *key))done
{
if (self.doc.readOnly || self.doc.edit.saving) return;
UIAlertController *a = wq_alert_controller(title, nil, UIAlertControllerStyleAlert);
[a addTextFieldWithConfigurationHandler:^(UITextField *f) {
f.text = current;
f.placeholder = WQLocalizedNullable(@"Key");
f.autocapitalizationType = UITextAutocapitalizationTypeNone;
f.autocorrectionType = UITextAutocorrectionTypeNo;
}];
[a addAction:wq_alert_action(@"Cancel", UIAlertActionStyleCancel, nil)];
[a addAction:wq_alert_action(@"OK", UIAlertActionStyleDefault, ^(UIAlertAction *__unused x) {
NSString *key = a.textFields.firstObject.text ?: @"";
if (key.length == 0) return;
if (![key isEqualToString:current] && self.container[key]) {
wqp_alert(self, @"Key exists", WQLocalizedFormat(@"There is already a key named \"%@\".", key));
return;
}
done(key);
})];
[self presentViewController:a animated:YES completion:nil];
}
- (UISwipeActionsConfiguration *)tableView:(UITableView *)tableView
trailingSwipeActionsConfigurationForRowAtIndexPath:(NSIndexPath *)indexPath
{
if (self.doc.readOnly || self.doc.edit.saving) return nil;
NSInteger row = indexPath.row;
UIContextualAction *del = [UIContextualAction contextualActionWithStyle:UIContextualActionStyleDestructive title:WQLocalizedNullable(@"Delete")
handler:^(UIContextualAction *__unused action, UIView *__unused view, void (^completion)(BOOL)) {
if (self.doc.readOnly || self.doc.edit.saving) { completion(NO); return; }
if (self.isArray) [self.container removeObjectAtIndex:(NSUInteger)row];
else [self.container removeObjectForKey:self.keys[(NSUInteger)row]];
[self changed];
completion(YES);
}];
if (self.isArray) return [UISwipeActionsConfiguration configurationWithActions:@[del]];
UIContextualAction *rename = [UIContextualAction contextualActionWithStyle:UIContextualActionStyleNormal title:WQLocalizedNullable(@"Rename")
handler:^(UIContextualAction *__unused action, UIView *__unused view, void (^completion)(BOOL)) {
if (self.doc.readOnly || self.doc.edit.saving) { completion(NO); return; }
NSString *old = self.keys[(NSUInteger)row];
[self askKey:old title:WQLocalizedNullable(@"Rename Key") done:^(NSString *key) {
if (self.doc.readOnly || self.doc.edit.saving) return;
if ([key isEqualToString:old]) return;
id v = self.container[old];
[self.container removeObjectForKey:old];
self.container[key] = v;
[self changed];
}];
completion(YES);
}];
return [UISwipeActionsConfiguration configurationWithActions:@[del, rename]];
}
- (BOOL)tableView:(UITableView *)tableView canMoveRowAtIndexPath:(NSIndexPath *)indexPath
{
return self.isArray && !self.doc.readOnly && !self.doc.edit.saving;
}
- (UITableViewCellEditingStyle)tableView:(UITableView *)tableView editingStyleForRowAtIndexPath:(NSIndexPath *)indexPath
{
return UITableViewCellEditingStyleNone;
}
- (BOOL)tableView:(UITableView *)tableView shouldIndentWhileEditingRowAtIndexPath:(NSIndexPath *)indexPath
{
return NO;
}
- (void)tableView:(UITableView *)tableView moveRowAtIndexPath:(NSIndexPath *)from toIndexPath:(NSIndexPath *)to
{
if (self.doc.readOnly || self.doc.edit.saving) return;
id v = self.container[(NSUInteger)from.row];
[self.container removeObjectAtIndex:(NSUInteger)from.row];
[self.container insertObject:v atIndex:(NSUInteger)to.row];
[self.doc.edit noteChange];
dispatch_async(dispatch_get_main_queue(), ^{ [self refresh]; });
}
@end
+119
View File
@@ -0,0 +1,119 @@
//
// WQPlistEditorSupport.h
// WQ Files
//
// Portable limits shared by the property-list editor and its regression tests.
//
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
enum {
WQPMaxDisplayedStringCharacters = 512,
WQPMaxStringInputCharacters = 64 * 1024,
WQPMaxDataBytes = 4 * 1024,
WQPMaxDataInputCharacters = 16 * 1024,
WQPMaxSerializedBytes = 64 * 1024 * 1024,
};
static inline BOOL WQPStringInputWithinLimit(NSString *text)
{
return text.length <= WQPMaxStringInputCharacters;
}
static inline BOOL WQPDataInputWithinLimit(NSString *text)
{
return text.length <= WQPMaxDataInputCharacters;
}
static inline BOOL WQPSerializedDataWithinLimit(NSData *data)
{
return data.length <= WQPMaxSerializedBytes;
}
// CoreFoundation can write only XML and binary property lists. OpenStep and
// unknown formats are readable but must be treated as read-only documents.
static inline BOOL WQPlistFormatIsWritable(NSPropertyListFormat format)
{
return format == NSPropertyListXMLFormat_v1_0 || format == NSPropertyListBinaryFormat_v1_0;
}
static inline NSString *WQPlistFormatName(NSPropertyListFormat format)
{
switch (format) {
case NSPropertyListXMLFormat_v1_0: return @"XML";
case NSPropertyListBinaryFormat_v1_0: return @"Binary";
case NSPropertyListOpenStepFormat: return @"OpenStep";
default: return @"Unknown";
}
}
static inline NSString *WQPBoundedStringPreview(NSString *value)
{
if (value.length == 0) return @"(empty)";
if (value.length <= WQPMaxDisplayedStringCharacters) return value;
NSString *suffix = [NSString stringWithFormat:@" … (%lu chars)", (unsigned long)value.length];
NSUInteger prefixLength = suffix.length < WQPMaxDisplayedStringCharacters
? WQPMaxDisplayedStringCharacters - suffix.length : 0;
return [[value substringToIndex:prefixLength] stringByAppendingString:suffix];
}
// NSKeyedArchiver stores object references as CFKeyedArchiverUID values. They
// are not NSNumber/NSString/NSData, and they do not implement -stringValue or
// NSCopying. They are nevertheless valid property-list leaves and must be
// preserved when the document is re-serialized.
static inline BOOL WQPlistIsKeyedArchiveUID(id value)
{
if (!value) return NO;
NSString *className = NSStringFromClass([value class]);
if (![className isEqualToString:@"__NSCFType"] && ![className containsString:@"KeyedArchiverUID"])
return NO;
NSString *description = [value description];
return [description hasPrefix:@"<CFKeyedArchiverUID "];
}
// Immutable, serializable copy of the editor's mutable document. Keyed-archive
// UID leaves are immutable and are passed through unchanged; unsupported
// objects fail the copy instead of reaching NSPropertyListSerialization.
static inline id _Nullable WQPlistImmutableCopy(id value, NSString *_Nullable *_Nullable errorOut)
{
if (!value) {
if (errorOut) *errorOut = @"The property list contains a nil value.";
return nil;
}
if (WQPlistIsKeyedArchiveUID(value)) return value;
if ([value isKindOfClass:NSDictionary.class]) {
NSDictionary *source = value;
NSMutableDictionary *copy = [NSMutableDictionary dictionaryWithCapacity:source.count];
for (id key in source) {
if (![key isKindOfClass:NSString.class]) {
if (errorOut) *errorOut = @"A dictionary key is not a string.";
return nil;
}
id child = WQPlistImmutableCopy(source[key], errorOut);
if (!child) return nil;
copy[key] = child;
}
return [copy copy];
}
if ([value isKindOfClass:NSArray.class]) {
NSArray *source = value;
NSMutableArray *copy = [NSMutableArray arrayWithCapacity:source.count];
for (id childValue in source) {
id child = WQPlistImmutableCopy(childValue, errorOut);
if (!child) return nil;
[copy addObject:child];
}
return [copy copy];
}
if ([value isKindOfClass:NSString.class] || [value isKindOfClass:NSNumber.class] ||
[value isKindOfClass:NSDate.class] || [value isKindOfClass:NSData.class]) {
return [value copy];
}
if (errorOut) *errorOut = @"The property list contains an unsupported value.";
return nil;
}
NS_ASSUME_NONNULL_END
+23
View File
@@ -0,0 +1,23 @@
//
// WQSQLiteViewer.h
// WQ Files
//
// Read-only SQLite viewer: tables and views, rows, single-row details,
// read-only SQL queries and CSV export. Works on a private copy of the
// database (and its -wal file) in WQ Files' temporary folder, so the app's
// own files are never opened for writing and no journal files appear in its
// container.
//
#import <UIKit/UIKit.h>
NS_ASSUME_NONNULL_BEGIN
@interface WQSQLiteViewController : UITableViewController
- (instancetype)initWithPath:(NSString *)path;
@end
// Whether the first bytes of a file are an SQLite 3 header.
BOOL wq_is_sqlite_header(NSData *head);
NS_ASSUME_NONNULL_END
+636
View File
@@ -0,0 +1,636 @@
//
// WQSQLiteViewer.m
// WQ Files
//
// Read-only SQLite viewer. The database and its -wal file are copied into a
// private temporary folder first (a -shm file is rebuilt by SQLite there),
// then opened with PRAGMA query_only. Every statement is checked with
// sqlite3_stmt_readonly before it runs.
//
#import "WQSQLiteViewer.h"
#import "WQAccess.h"
#import "WQSharing.h"
#import "WQUI.h"
#import <sqlite3.h>
BOOL wq_is_sqlite_header(NSData *head)
{
return head.length >= 16 && memcmp(head.bytes, "SQLite format 3\0", 16) == 0;
}
static const NSUInteger kWQSQLitePage = 100;
static const NSUInteger kWQSQLiteQueryLimit = 1000;
#pragma mark - Snapshot
// A private copy of the database. All SQLite calls run on its serial queue.
@interface WQSQLiteSnapshot : NSObject
@property (nonatomic, readonly) dispatch_queue_t queue;
@property (nonatomic, readonly, nullable) sqlite3 *db;
@property (nonatomic, copy, readonly) NSString *sourcePath;
@property (nonatomic, readonly) BOOL copiedWAL;
@end
@implementation WQSQLiteSnapshot {
NSString *_dir;
}
// Streams `src` into `dst` through wq_read_range (which asks for access when
// needed). Returns NO with an error text on failure.
static BOOL wq_sqlite_copy(NSString *src, NSString *dst, NSString **error)
{
if (![NSFileManager.defaultManager createFileAtPath:dst contents:nil attributes:nil]) {
*error = WQLocalized(@"cannot create the temporary copy");
return NO;
}
NSFileHandle *out = [NSFileHandle fileHandleForWritingAtPath:dst];
if (!out) { *error = WQLocalized(@"cannot write the temporary copy"); return NO; }
const NSUInteger chunk = 1024 * 1024;
unsigned long long offset = 0;
BOOL ok = YES;
@try {
for (;;) {
NSString *readError = nil;
NSData *data = wq_read_range(src, offset, chunk, &readError);
if (!data) { *error = readError ?: WQLocalized(@"read failed"); ok = NO; break; }
if (data.length) [out writeData:data];
offset += data.length;
if (data.length < chunk) break;
}
} @catch (NSException *exception) {
*error = exception.reason ?: WQLocalized(@"write failed");
ok = NO;
}
[out closeFile];
return ok;
}
- (nullable instancetype)initWithPath:(NSString *)path error:(NSString **)error
{
if (!(self = [super init])) return nil;
_sourcePath = [path copy];
_queue = dispatch_queue_create("wqfiles.sqlite", DISPATCH_QUEUE_SERIAL);
_dir = [NSTemporaryDirectory() stringByAppendingPathComponent:
[@"wq-sqlite-" stringByAppendingString:NSUUID.UUID.UUIDString]];
if (![NSFileManager.defaultManager createDirectoryAtPath:_dir withIntermediateDirectories:YES attributes:nil error:nil]) {
*error = WQLocalized(@"cannot create a temporary folder");
return nil;
}
NSString *copy = [_dir stringByAppendingPathComponent:@"db.sqlite"];
if (!wq_sqlite_copy(path, copy, error)) return nil;
// Recent changes of WAL databases are still in the -wal file. The -shm
// index is not copied: SQLite rebuilds it next to the copy.
NSString *wal = [path stringByAppendingString:@"-wal"];
struct stat st;
if (wq_stat(wal, &st) && S_ISREG(st.st_mode) && st.st_size > 0) {
NSString *walError = nil;
_copiedWAL = wq_sqlite_copy(wal, [copy stringByAppendingString:@"-wal"], &walError);
if (!_copiedWAL) wq_log(@"sqlite: -wal of %@ not copied (%@); recent changes may be missing", path, walError);
}
// Read-write on the private copy so SQLite can apply the WAL there;
// query_only and the per-statement check keep it read-only in practice.
sqlite3 *db = NULL;
int rc = sqlite3_open_v2(copy.fileSystemRepresentation, &db, SQLITE_OPEN_READWRITE | SQLITE_OPEN_NOMUTEX, NULL);
if (rc != SQLITE_OK) {
*error = db ? @(sqlite3_errmsg(db)) : WQLocalized(@"cannot open the database");
if (db) sqlite3_close(db);
return nil;
}
sqlite3_exec(db, "PRAGMA query_only = ON", NULL, NULL, NULL);
// Fail early on files that only look like databases.
sqlite3_stmt *probe = NULL;
rc = sqlite3_prepare_v2(db, "SELECT count(*) FROM sqlite_master", -1, &probe, NULL);
if (rc == SQLITE_OK) rc = sqlite3_step(probe);
sqlite3_finalize(probe);
if (rc != SQLITE_ROW) {
*error = @(sqlite3_errmsg(db));
sqlite3_close(db);
return nil;
}
_db = db;
return self;
}
- (void)dealloc
{
if (_db) sqlite3_close(_db);
if (_dir) [NSFileManager.defaultManager removeItemAtPath:_dir error:nil];
}
@end
#pragma mark - Values
static NSString *wq_sqlite_quote_identifier(NSString *name)
{
return [NSString stringWithFormat:@"\"%@\"", [name stringByReplacingOccurrencesOfString:@"\"" withString:@"\"\""]];
}
static NSString *wq_sqlite_hex(const uint8_t *bytes, int length, int limit)
{
NSMutableString *hex = [NSMutableString new];
for (int i = 0; i < length && i < limit; i++) [hex appendFormat:@"%02x%s", bytes[i], (i % 4 == 3) ? " " : ""];
if (length > limit) [hex appendString:@"…"];
return hex;
}
// A column value as display text; `full` shows long text and blobs in more detail.
static NSString *wq_sqlite_value(sqlite3_stmt *stmt, int column, BOOL full)
{
switch (sqlite3_column_type(stmt, column)) {
case SQLITE_NULL: return WQLocalized(@"NULL");
case SQLITE_INTEGER: return [NSString stringWithFormat:@"%lld", sqlite3_column_int64(stmt, column)];
case SQLITE_FLOAT: return [NSString stringWithFormat:@"%.15g", sqlite3_column_double(stmt, column)];
case SQLITE_BLOB: {
const uint8_t *bytes = sqlite3_column_blob(stmt, column);
int length = sqlite3_column_bytes(stmt, column);
if (length >= 8 && bytes && memcmp(bytes, "bplist0", 7) == 0)
return WQLocalizedFormat(@"<binary plist, %d bytes>", length);
NSString *hex = bytes ? wq_sqlite_hex(bytes, length, full ? 512 : 16) : @"";
return WQLocalizedFormat(@"<%d bytes> %@", length, hex);
}
default: {
const unsigned char *text = sqlite3_column_text(stmt, column);
NSString *s = text ? [NSString stringWithUTF8String:(const char *)text] : nil;
if (!s) return WQLocalized(@"<text not UTF-8>");
if (!full && s.length > 200) s = [[s substringToIndex:200] stringByAppendingString:@"…"];
return s;
}
}
}
static NSString *wq_sqlite_csv_field(sqlite3_stmt *stmt, int column)
{
if (sqlite3_column_type(stmt, column) == SQLITE_NULL) return @"";
if (sqlite3_column_type(stmt, column) == SQLITE_BLOB) {
const uint8_t *bytes = sqlite3_column_blob(stmt, column);
int length = sqlite3_column_bytes(stmt, column);
NSData *data = bytes ? [NSData dataWithBytes:bytes length:(NSUInteger)length] : [NSData data];
return [@"base64:" stringByAppendingString:[data base64EncodedStringWithOptions:0]];
}
NSString *value = wq_sqlite_value(stmt, column, YES);
if ([value rangeOfCharacterFromSet:[NSCharacterSet characterSetWithCharactersInString:@",\"\n\r"]].location == NSNotFound)
return value;
return [NSString stringWithFormat:@"\"%@\"", [value stringByReplacingOccurrencesOfString:@"\"" withString:@"\"\""]];
}
#pragma mark - Row detail
@interface WQSQLiteRowViewController : UITableViewController
- (instancetype)initWithColumns:(NSArray<NSString *> *)columns values:(NSArray<NSString *> *)values title:(NSString *)title;
@end
@implementation WQSQLiteRowViewController {
NSArray<NSString *> *_columns;
NSArray<NSString *> *_values;
}
- (instancetype)initWithColumns:(NSArray<NSString *> *)columns values:(NSArray<NSString *> *)values title:(NSString *)title
{
if ((self = [super initWithStyle:UITableViewStyleInsetGrouped])) {
_columns = [columns copy];
_values = [values copy];
self.title = title;
}
return self;
}
- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView { return (NSInteger)_columns.count; }
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { return 1; }
- (NSString *)tableView:(UITableView *)tableView titleForHeaderInSection:(NSInteger)section { return _columns[(NSUInteger)section]; }
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"v"]
?: [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"v"];
UIListContentConfiguration *c = cell.defaultContentConfiguration;
c.text = _values[(NSUInteger)indexPath.section];
c.textProperties.font = [UIFont monospacedSystemFontOfSize:14 weight:UIFontWeightRegular];
c.textProperties.numberOfLines = 0;
cell.contentConfiguration = c;
cell.selectionStyle = UITableViewCellSelectionStyleNone;
return cell;
}
- (UIContextMenuConfiguration *)tableView:(UITableView *)tableView
contextMenuConfigurationForRowAtIndexPath:(NSIndexPath *)indexPath point:(CGPoint)point
{
NSString *value = _values[(NSUInteger)indexPath.section];
return [UIContextMenuConfiguration configurationWithIdentifier:nil previewProvider:nil
actionProvider:^UIMenu *(NSArray<UIMenuElement *> *__unused suggested) {
return [UIMenu menuWithChildren:@[[UIAction actionWithTitle:WQLocalizedNullable(@"Copy") image:[UIImage systemImageNamed:@"doc.on.doc"]
identifier:nil handler:^(UIAction *__unused a) {
UIPasteboard.generalPasteboard.string = value;
}]]];
}];
}
@end
#pragma mark - Rows (a table, a view or a query)
@interface WQSQLiteRowsViewController : UITableViewController
- (instancetype)initWithSnapshot:(WQSQLiteSnapshot *)snapshot sql:(NSString *)sql title:(NSString *)title
exportName:(nullable NSString *)exportName limit:(NSUInteger)limit;
@end
@implementation WQSQLiteRowsViewController {
WQSQLiteSnapshot *_snapshot;
NSString *_sql;
NSString *_exportName;
NSUInteger _limit; // 0 = page through everything
NSArray<NSString *> *_columns;
NSMutableArray<NSArray<NSString *> *> *_rows;
BOOL _loading, _done;
NSString *_error;
}
- (instancetype)initWithSnapshot:(WQSQLiteSnapshot *)snapshot sql:(NSString *)sql title:(NSString *)title
exportName:(NSString *)exportName limit:(NSUInteger)limit
{
if ((self = [super initWithStyle:UITableViewStylePlain])) {
_snapshot = snapshot;
_sql = [sql copy];
_exportName = [exportName copy];
_limit = limit;
_rows = [NSMutableArray new];
self.title = title;
}
return self;
}
- (void)viewDidLoad
{
[super viewDidLoad];
if (_exportName) {
self.navigationItem.rightBarButtonItem = [[UIBarButtonItem alloc] initWithTitle:WQLocalizedNullable(@"CSV") style:UIBarButtonItemStylePlain
target:self action:@selector(exportCSV:)];
}
[self loadMore];
}
- (void)loadMore
{
if (_loading || _done) return;
_loading = YES;
NSUInteger offset = _rows.count;
NSUInteger page = _limit ? _limit : kWQSQLitePage;
WQSQLiteSnapshot *snapshot = _snapshot;
// Tables page with LIMIT/OFFSET; a query (which may be a PRAGMA or EXPLAIN,
// and can't be wrapped) runs as typed and stops after `_limit` rows.
NSString *sql = _limit ? _sql : [NSString stringWithFormat:@"SELECT * FROM (%@) LIMIT %lu OFFSET %lu",
_sql, (unsigned long)page, (unsigned long)offset];
dispatch_async(snapshot.queue, ^{
NSMutableArray *columns = [NSMutableArray new];
NSMutableArray *rows = [NSMutableArray new];
NSString *error = nil;
sqlite3_stmt *stmt = NULL;
if (sqlite3_prepare_v2(snapshot.db, sql.UTF8String, -1, &stmt, NULL) != SQLITE_OK) {
error = @(sqlite3_errmsg(snapshot.db));
} else if (!sqlite3_stmt_readonly(stmt)) {
error = WQLocalized(@"Only read-only statements can run here.");
} else {
int count = sqlite3_column_count(stmt);
for (int i = 0; i < count; i++) [columns addObject:@(sqlite3_column_name(stmt, i) ?: "?")];
int rc = SQLITE_DONE;
while (rows.count < page && (rc = sqlite3_step(stmt)) == SQLITE_ROW) {
NSMutableArray *values = [NSMutableArray arrayWithCapacity:(NSUInteger)count];
for (int i = 0; i < count; i++) [values addObject:wq_sqlite_value(stmt, i, YES)];
[rows addObject:values];
}
if (rows.count < page && rc != SQLITE_DONE) error = @(sqlite3_errmsg(snapshot.db));
}
sqlite3_finalize(stmt);
dispatch_async(dispatch_get_main_queue(), ^{
self->_loading = NO;
if (error) { self->_error = error; self->_done = YES; [self.tableView reloadData]; return; }
if (!self->_columns) self->_columns = columns;
[self->_rows addObjectsFromArray:rows];
if (rows.count < page || self->_limit) self->_done = YES;
[self.tableView reloadData];
});
});
}
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
{
return (_error || (!_rows.count && _done)) ? 1 : (NSInteger)_rows.count;
}
- (NSString *)tableView:(UITableView *)tableView titleForHeaderInSection:(NSInteger)section
{
if (_error || !_columns) return nil;
NSString *more = _done ? @"" : @"+";
if (_limit && _rows.count >= _limit) more = WQLocalizedFormat(@" (first %lu)", (unsigned long)_limit);
return WQLocalizedFormat(@"%lu%@ rows · %lu columns", (unsigned long)_rows.count, more, (unsigned long)_columns.count);
}
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"row"]
?: [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleSubtitle reuseIdentifier:@"row"];
UIListContentConfiguration *c = cell.defaultContentConfiguration;
if (_error || !_rows.count) {
c.text = _error ?: WQLocalized(@"No rows");
c.textProperties.color = _error ? UIColor.systemRedColor : UIColor.secondaryLabelColor;
c.textProperties.numberOfLines = 0;
cell.contentConfiguration = c;
cell.accessoryType = UITableViewCellAccessoryNone;
return cell;
}
NSArray<NSString *> *values = _rows[(NSUInteger)indexPath.row];
NSMutableArray *parts = [NSMutableArray new];
for (NSUInteger i = 0; i < values.count && i < _columns.count; i++) {
NSString *v = values[i];
if (v.length > 80) v = [[v substringToIndex:80] stringByAppendingString:@"…"];
[parts addObject:[NSString stringWithFormat:@"%@: %@", _columns[i], v]];
}
c.text = parts.firstObject;
c.secondaryText = parts.count > 1 ? [[parts subarrayWithRange:NSMakeRange(1, parts.count - 1)] componentsJoinedByString:@" · "] : nil;
c.textProperties.font = [UIFont monospacedSystemFontOfSize:14 weight:UIFontWeightRegular];
c.secondaryTextProperties.numberOfLines = 2;
c.secondaryTextProperties.color = UIColor.secondaryLabelColor;
cell.contentConfiguration = c;
cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator;
if ((NSUInteger)indexPath.row + 20 >= _rows.count) [self loadMore];
return cell;
}
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath
{
[tableView deselectRowAtIndexPath:indexPath animated:YES];
if (_error || (NSUInteger)indexPath.row >= _rows.count) return;
WQSQLiteRowViewController *detail = [[WQSQLiteRowViewController alloc]
initWithColumns:_columns values:_rows[(NSUInteger)indexPath.row]
title:WQLocalizedFormat(@"Row %ld", (long)indexPath.row + 1)];
[self.navigationController pushViewController:detail animated:YES];
}
- (void)exportCSV:(UIBarButtonItem *)sender
{
WQSQLiteSnapshot *snapshot = _snapshot;
NSString *sql = _sql;
NSString *name = [[_exportName stringByReplacingOccurrencesOfString:@"/" withString:@"_"] stringByAppendingPathExtension:@"csv"];
NSString *dir = [NSTemporaryDirectory() stringByAppendingPathComponent:@"wq-sqlite-export"];
[NSFileManager.defaultManager createDirectoryAtPath:dir withIntermediateDirectories:YES attributes:nil error:nil];
NSString *out = [dir stringByAppendingPathComponent:name];
sender.enabled = NO;
dispatch_async(snapshot.queue, ^{
NSString *error = nil;
[NSFileManager.defaultManager createFileAtPath:out contents:nil attributes:nil];
NSFileHandle *file = [NSFileHandle fileHandleForWritingAtPath:out];
sqlite3_stmt *stmt = NULL;
if (!file) error = WQLocalized(@"cannot create the CSV file");
else if (sqlite3_prepare_v2(snapshot.db, sql.UTF8String, -1, &stmt, NULL) != SQLITE_OK) error = @(sqlite3_errmsg(snapshot.db));
else if (!sqlite3_stmt_readonly(stmt)) error = WQLocalized(@"Only read-only statements can run here.");
else {
int count = sqlite3_column_count(stmt);
NSMutableArray *header = [NSMutableArray new];
for (int i = 0; i < count; i++) [header addObject:@(sqlite3_column_name(stmt, i) ?: "?")];
[file writeData:[[[header componentsJoinedByString:@","] stringByAppendingString:@"\r\n"] dataUsingEncoding:NSUTF8StringEncoding]];
while (sqlite3_step(stmt) == SQLITE_ROW) {
@autoreleasepool {
NSMutableArray *fields = [NSMutableArray arrayWithCapacity:(NSUInteger)count];
for (int i = 0; i < count; i++) [fields addObject:wq_sqlite_csv_field(stmt, i)];
[file writeData:[[[fields componentsJoinedByString:@","] stringByAppendingString:@"\r\n"] dataUsingEncoding:NSUTF8StringEncoding]];
}
}
}
sqlite3_finalize(stmt);
[file closeFile];
dispatch_async(dispatch_get_main_queue(), ^{
sender.enabled = YES;
if (error) { wq_alert(self, @"Cannot export", error); return; }
wq_share(self, out, nil);
});
});
}
@end
#pragma mark - Query
@interface WQSQLiteQueryViewController : UIViewController
- (instancetype)initWithSnapshot:(WQSQLiteSnapshot *)snapshot;
@end
@implementation WQSQLiteQueryViewController {
WQSQLiteSnapshot *_snapshot;
UITextView *_textView;
}
- (instancetype)initWithSnapshot:(WQSQLiteSnapshot *)snapshot
{
if ((self = [super initWithNibName:nil bundle:nil])) {
_snapshot = snapshot;
self.title = WQLocalizedNullable(@"SQL");
}
return self;
}
- (void)viewDidLoad
{
[super viewDidLoad];
self.view.backgroundColor = UIColor.systemBackgroundColor;
_textView = [UITextView new];
_textView.translatesAutoresizingMaskIntoConstraints = NO;
_textView.font = [UIFont monospacedSystemFontOfSize:15 weight:UIFontWeightRegular];
_textView.semanticContentAttribute = UISemanticContentAttributeForceLeftToRight; // SQL stays left to right
_textView.textAlignment = NSTextAlignmentLeft;
_textView.autocapitalizationType = UITextAutocapitalizationTypeNone;
_textView.autocorrectionType = UITextAutocorrectionTypeNo;
_textView.smartQuotesType = UITextSmartQuotesTypeNo;
_textView.smartDashesType = UITextSmartDashesTypeNo;
_textView.text = @"SELECT name, type FROM sqlite_master";
[self.view addSubview:_textView];
UILayoutGuide *g = self.view.safeAreaLayoutGuide;
[NSLayoutConstraint activateConstraints:@[
[_textView.topAnchor constraintEqualToAnchor:g.topAnchor],
[_textView.bottomAnchor constraintEqualToAnchor:self.view.keyboardLayoutGuide.topAnchor],
[_textView.leadingAnchor constraintEqualToAnchor:g.leadingAnchor constant:8],
[_textView.trailingAnchor constraintEqualToAnchor:g.trailingAnchor constant:-8],
]];
self.navigationItem.rightBarButtonItem = [[UIBarButtonItem alloc] initWithTitle:WQLocalizedNullable(@"Run") style:UIBarButtonItemStyleProminent
target:self action:@selector(run)];
[_textView becomeFirstResponder];
}
- (void)run
{
NSString *sql = [_textView.text stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet];
while ([sql hasSuffix:@";"]) sql = [[sql substringToIndex:sql.length - 1] stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet];
if (!sql.length) return;
// Only a single read-only statement: checked before anything runs.
WQSQLiteSnapshot *snapshot = _snapshot;
__block NSString *error = nil;
dispatch_sync(snapshot.queue, ^{
sqlite3_stmt *stmt = NULL;
const char *tail = NULL;
if (sqlite3_prepare_v2(snapshot.db, sql.UTF8String, -1, &stmt, &tail) != SQLITE_OK) error = @(sqlite3_errmsg(snapshot.db));
else if (!stmt) error = WQLocalized(@"Nothing to run.");
else if (!sqlite3_stmt_readonly(stmt)) error = WQLocalized(@"Only read-only statements (SELECT, read-only PRAGMA, EXPLAIN) can run here.");
else if (tail && *tail) {
NSString *rest = [@(tail) stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet];
if (rest.length) error = WQLocalized(@"Run one statement at a time.");
}
else if (sqlite3_column_count(stmt) == 0) error = WQLocalized(@"This statement returns no rows.");
sqlite3_finalize(stmt);
});
if (error) { wq_alert(self, @"Cannot run", error); return; }
WQSQLiteRowsViewController *rows = [[WQSQLiteRowsViewController alloc] initWithSnapshot:snapshot sql:sql title:WQLocalizedNullable(@"Result")
exportName:@"query" limit:kWQSQLiteQueryLimit];
[self.navigationController pushViewController:rows animated:YES];
}
@end
#pragma mark - Tables
@implementation WQSQLiteViewController {
NSString *_path;
WQSQLiteSnapshot *_snapshot;
NSArray<NSDictionary *> *_tables; // name, type, rows (NSNumber, filled in later)
NSMutableDictionary<NSString *, NSNumber *> *_counts;
NSString *_error;
BOOL _loading;
}
- (instancetype)initWithPath:(NSString *)path
{
if ((self = [super initWithStyle:UITableViewStyleInsetGrouped])) {
_path = [path copy];
_counts = [NSMutableDictionary new];
_loading = YES;
self.title = path.lastPathComponent;
}
return self;
}
- (void)viewDidLoad
{
[super viewDidLoad];
self.navigationItem.rightBarButtonItem = [[UIBarButtonItem alloc] initWithTitle:WQLocalizedNullable(@"SQL") style:UIBarButtonItemStylePlain
target:self action:@selector(openQuery)];
self.navigationItem.rightBarButtonItem.enabled = NO;
NSString *path = _path;
dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{
NSString *error = nil;
WQSQLiteSnapshot *snapshot = [[WQSQLiteSnapshot alloc] initWithPath:path error:&error];
NSMutableArray *tables = [NSMutableArray new];
if (snapshot) {
dispatch_sync(snapshot.queue, ^{
sqlite3_stmt *stmt = NULL;
if (sqlite3_prepare_v2(snapshot.db, "SELECT name, type FROM sqlite_master WHERE type IN ('table','view') ORDER BY type, name",
-1, &stmt, NULL) == SQLITE_OK) {
while (sqlite3_step(stmt) == SQLITE_ROW) {
const unsigned char *name = sqlite3_column_text(stmt, 0), *type = sqlite3_column_text(stmt, 1);
if (name && type) [tables addObject:@{ @"name": @((const char *)name), @"type": @((const char *)type) }];
}
}
sqlite3_finalize(stmt);
});
}
if (!snapshot) wq_log(@"sqlite: cannot open %@ (%@)", path, error);
dispatch_async(dispatch_get_main_queue(), ^{
self->_loading = NO;
self->_snapshot = snapshot;
self->_tables = tables;
self->_error = snapshot ? nil : error;
self.navigationItem.rightBarButtonItem.enabled = snapshot != nil;
[self.tableView reloadData];
if (snapshot) [self countRows];
});
});
}
// Row counts for tables, one at a time in the background (big tables take a while).
- (void)countRows
{
WQSQLiteSnapshot *snapshot = _snapshot;
NSArray *tables = _tables;
__weak typeof(self) weakSelf = self;
dispatch_async(snapshot.queue, ^{
for (NSDictionary *table in tables) {
if (!weakSelf) return;
if (![table[@"type"] isEqualToString:@"table"]) continue;
NSString *sql = [@"SELECT count(*) FROM " stringByAppendingString:wq_sqlite_quote_identifier(table[@"name"])];
sqlite3_stmt *stmt = NULL;
long long count = -1;
if (sqlite3_prepare_v2(snapshot.db, sql.UTF8String, -1, &stmt, NULL) == SQLITE_OK && sqlite3_step(stmt) == SQLITE_ROW)
count = sqlite3_column_int64(stmt, 0);
sqlite3_finalize(stmt);
NSString *name = table[@"name"];
dispatch_async(dispatch_get_main_queue(), ^{
WQSQLiteViewController *strongSelf = weakSelf;
if (!strongSelf || count < 0) return;
strongSelf->_counts[name] = @(count);
[strongSelf.tableView reloadData];
});
}
});
}
- (void)openQuery
{
if (!_snapshot) return;
[self.navigationController pushViewController:[[WQSQLiteQueryViewController alloc] initWithSnapshot:_snapshot] animated:YES];
}
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
{
return (_loading || _error || !_tables.count) ? 1 : (NSInteger)_tables.count;
}
- (NSString *)tableView:(UITableView *)tableView titleForHeaderInSection:(NSInteger)section
{
return _tables.count ? WQLocalized(@"Tables and views") : nil;
}
- (NSString *)tableView:(UITableView *)tableView titleForFooterInSection:(NSInteger)section
{
if (!_snapshot) return nil;
NSString *wal = _snapshot.copiedWAL ? WQLocalized(@" and its -wal file") : @"";
return WQLocalizedFormat(@"Read-only. This is a copy of the database%@ made when it was opened; the app's own file is not changed. Close the app first for an up-to-date copy.", wal);
}
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"t"]
?: [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleValue1 reuseIdentifier:@"t"];
UIListContentConfiguration *c = [UIListContentConfiguration valueCellConfiguration];
cell.accessoryType = UITableViewCellAccessoryNone;
if (_loading) {
c.text = WQLocalizedNullable(@"Copying and opening…");
} else if (_error) {
c.text = [WQLocalized(@"Cannot open this database: ") stringByAppendingString:_error];
c.textProperties.color = UIColor.systemRedColor;
c.textProperties.numberOfLines = 0;
} else if (!_tables.count) {
c.text = WQLocalizedNullable(@"No tables");
c.textProperties.color = UIColor.secondaryLabelColor;
} else {
NSDictionary *table = _tables[(NSUInteger)indexPath.row];
BOOL view = [table[@"type"] isEqualToString:@"view"];
c.text = table[@"name"];
c.image = [UIImage systemImageNamed:view ? @"eye" : @"tablecells"];
NSNumber *count = _counts[table[@"name"]];
c.secondaryText = view ? WQLocalized(@"view") : (count ? WQLocalizedFormat(@"%@ rows", count) : @"…");
cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator;
}
cell.contentConfiguration = c;
return cell;
}
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath
{
[tableView deselectRowAtIndexPath:indexPath animated:YES];
if (!_snapshot || (NSUInteger)indexPath.row >= _tables.count) return;
NSString *name = _tables[(NSUInteger)indexPath.row][@"name"];
NSString *sql = [@"SELECT * FROM " stringByAppendingString:wq_sqlite_quote_identifier(name)];
WQSQLiteRowsViewController *rows = [[WQSQLiteRowsViewController alloc] initWithSnapshot:_snapshot sql:sql title:name
exportName:name limit:0];
[self.navigationController pushViewController:rows animated:YES];
}
@end
+14
View File
@@ -0,0 +1,14 @@
//
// WQSharing.h
// WQ Files temporary exports and share sheets
//
#import <UIKit/UIKit.h>
NS_ASSUME_NONNULL_BEGIN
void wq_share(UIViewController *host, NSString *path, UIView *_Nullable source);
void wq_share_folder(UIViewController *host, NSString *dir, NSString *_Nullable zipName, UIView *_Nullable source);
void wq_share_cancel(UIViewController *host);
NS_ASSUME_NONNULL_END
+239
View File
@@ -0,0 +1,239 @@
//
// WQSharing.m
// WQ Files temporary exports and share sheets
// GPL-3.0-or-later.
//
#import "WQSharing.h"
#import "WQUI.h"
#import "WQAccess.h"
#import <objc/runtime.h>
#import <sys/stat.h>
@interface WQSharePreparation : NSObject
@property (nonatomic, weak) UIViewController *host;
@property (nonatomic, weak) UIAlertController *hud;
@property (nonatomic, copy) NSString *exportDir;
@property (atomic, getter=isCancelled) BOOL cancelled;
@end
@implementation WQSharePreparation
@end
static char kWQSharePreparationKey;
static char kWQShareInFlightKey;
static NSString *wq_unique_export_directory(void)
{
NSString *dir = [NSTemporaryDirectory() stringByAppendingPathComponent:
[@"WQShare-" stringByAppendingString:NSUUID.UUID.UUIDString]];
NSError *error = nil;
return [NSFileManager.defaultManager createDirectoryAtPath:dir withIntermediateDirectories:YES
attributes:nil error:&error] ? dir : nil;
}
static void wq_remove_export(NSString *exportDir)
{
if (exportDir.length) [NSFileManager.defaultManager removeItemAtPath:exportDir error:nil];
}
static BOOL wq_share_host_is_active(UIViewController *host)
{
if (!host || !host.isViewLoaded || !host.view.window) return NO;
UIWindowScene *scene = host.view.window.windowScene;
if (scene && scene.activationState != UISceneActivationStateForegroundActive) return NO;
UINavigationController *nav = host.navigationController;
if (nav && nav.topViewController != host) return NO;
if (host.presentedViewController && !host.presentedViewController.isBeingDismissed) return NO;
return YES;
}
static BOOL wq_shareable_regular_file(NSString *path)
{
if (!path.length) return NO;
struct stat st;
// Do not follow a final symlink: wq_copy_to_local uses O_NOFOLLOW too.
if (lstat(path.fileSystemRepresentation, &st) == 0) return S_ISREG(st.st_mode);
if (wq_grant(path) && lstat(path.fileSystemRepresentation, &st) == 0) return S_ISREG(st.st_mode);
return NO;
}
static void wq_clear_preparation(UIViewController *host, WQSharePreparation *preparation)
{
if (!host) return;
if (objc_getAssociatedObject(host, &kWQSharePreparationKey) == preparation) {
objc_setAssociatedObject(host, &kWQSharePreparationKey, nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
}
static BOOL wq_share_in_flight(UIViewController *host)
{
return host && (objc_getAssociatedObject(host, &kWQSharePreparationKey) ||
objc_getAssociatedObject(host, &kWQShareInFlightKey));
}
static void wq_clear_in_flight(UIViewController *host)
{
if (!host) return;
objc_setAssociatedObject(host, &kWQShareInFlightKey, nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
static BOOL wq_present_share(UIViewController *host, NSString *file, NSString *exportDir, UIView *source)
{
if (!wq_share_host_is_active(host)) {
wq_remove_export(exportDir);
return NO;
}
UIActivityViewController *share = [[UIActivityViewController alloc]
initWithActivityItems:@[[NSURL fileURLWithPath:file]] applicationActivities:nil];
UIView *anchor = source.window ? source : host.view;
share.popoverPresentationController.sourceView = anchor;
share.popoverPresentationController.sourceRect = anchor.bounds;
share.completionWithItemsHandler = ^(UIActivityType __unused activityType, BOOL __unused completed,
NSArray *__unused returnedItems, NSError *__unused activityError) {
wq_remove_export(exportDir);
};
[host presentViewController:share animated:YES completion:^{
// A presentation rejected after the visibility check must not strand the export.
if (!share.presentingViewController) wq_remove_export(exportDir);
}];
return YES;
}
// Cancel folder preparation when its browser is popped or otherwise abandoned.
// The export is removed by the worker's completion, after any in-progress copy has stopped.
void wq_share_cancel(UIViewController *host)
{
if (!host) return;
wq_clear_in_flight(host);
WQSharePreparation *preparation = objc_getAssociatedObject(host, &kWQSharePreparationKey);
preparation.cancelled = YES;
if (host) objc_setAssociatedObject(host, &kWQSharePreparationKey, nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
UIAlertController *hud = preparation.hud;
if (hud && host.presentedViewController == hud) {
[host dismissViewControllerAnimated:NO completion:nil];
}
}
// Copies a file into a unique temporary directory so concurrent shares cannot
// overwrite one another. The export is removed when the share sheet finishes.
void wq_share(UIViewController *host, NSString *path, UIView *source)
{
if (!host) return;
if (wq_share_in_flight(host)) {
wq_alert(host, @"Share in progress", @"Wait for the current share to finish before starting another.");
return;
}
objc_setAssociatedObject(host, &kWQShareInFlightKey, @YES, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
__weak UIViewController *weakHost = host;
dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{
NSString *error = nil;
NSString *outDir = nil;
NSString *tmp = nil;
BOOL ok = NO;
if (!wq_shareable_regular_file(path)) {
error = @"Only regular files can be shared.";
} else {
outDir = wq_unique_export_directory();
tmp = outDir ? [outDir stringByAppendingPathComponent:path.lastPathComponent] : nil;
ok = tmp && wq_copy_to_local(path, tmp, &error);
}
dispatch_async(dispatch_get_main_queue(), ^{
UIViewController *strongHost = weakHost;
if (!ok) {
wq_remove_export(outDir);
wq_clear_in_flight(strongHost);
if (wq_share_host_is_active(strongHost)) {
wq_alert(strongHost, @"Cannot share", error ?: @"Could not create a temporary export folder");
}
return;
}
if (!wq_share_host_is_active(strongHost) ||
!wq_present_share(strongHost, tmp, outDir, source)) {
wq_remove_export(outDir);
}
wq_clear_in_flight(strongHost);
});
});
}
static NSString *wq_zip_name(NSString *dir);
// Zips a folder (the system's own zipper via NSFileCoordinator) and opens the
// share sheet, so a backup can go out over AirDrop, Files, Mail…
void wq_share_folder(UIViewController *host, NSString *dir, NSString *zipName, UIView *source)
{
if (!wq_share_host_is_active(host)) return;
if (wq_share_in_flight(host)) {
wq_alert(host, @"Share in progress", @"Wait for the current share to finish before starting another.");
return;
}
WQSharePreparation *preparation = [WQSharePreparation new];
preparation.host = host;
UIAlertController *hud = wq_alert_controller(@"Preparing…", @"Compressing the folder", UIAlertControllerStyleAlert);
[hud addAction:wq_alert_action(@"Cancel", UIAlertActionStyleCancel, ^(UIAlertAction *__unused action) {
preparation.cancelled = YES;
objc_setAssociatedObject(host, &kWQSharePreparationKey, nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
[host dismissViewControllerAnimated:YES completion:nil];
})];
preparation.hud = hud;
objc_setAssociatedObject(host, &kWQSharePreparationKey, preparation, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
[host presentViewController:hud animated:YES completion:nil];
dispatch_async(wq_io(), ^{
if (preparation.isCancelled) return;
NSString *resolvedName = zipName ?: wq_zip_name(dir);
NSString *safe = [[resolvedName componentsSeparatedByCharactersInSet:
[NSCharacterSet characterSetWithCharactersInString:@"/:"]] componentsJoinedByString:@"-"];
NSString *outDir = wq_unique_export_directory();
preparation.exportDir = outDir;
NSString *zip = outDir ? [outDir stringByAppendingPathComponent:[safe stringByAppendingPathExtension:@"zip"]] : nil;
NSFileManager *fm = NSFileManager.defaultManager;
wq_grant(dir);
__block NSError *copyError = nil;
NSError *coordError = nil;
if (zip && !preparation.isCancelled) {
[[NSFileCoordinator new] coordinateReadingItemAtURL:[NSURL fileURLWithPath:dir]
options:NSFileCoordinatorReadingForUploading
error:&coordError byAccessor:^(NSURL *zipped) {
if (!preparation.isCancelled) [fm copyItemAtURL:zipped toURL:[NSURL fileURLWithPath:zip] error:&copyError];
}];
}
BOOL ok = zip && !preparation.isCancelled && [fm fileExistsAtPath:zip];
NSString *why = (coordError ?: copyError).localizedDescription ?: @"Could not create the ZIP";
wq_log(@"share zip: %@ -> %@ %@", dir, zip.lastPathComponent, ok ? @"OK" : why);
dispatch_async(dispatch_get_main_queue(), ^{
UIViewController *strongHost = preparation.host;
BOOL hudPresented = strongHost && strongHost.presentedViewController == hud;
void (^finish)(void) = ^{
BOOL stale = preparation.isCancelled || !wq_share_host_is_active(strongHost);
wq_clear_preparation(strongHost, preparation);
if (!ok || stale) {
wq_remove_export(outDir);
if (!stale && wq_share_host_is_active(strongHost)) {
wq_alert(strongHost, @"Cannot share", why);
}
return;
}
if (!wq_present_share(strongHost, zip, outDir, source)) wq_remove_export(outDir);
};
if (hudPresented) [strongHost dismissViewControllerAnimated:YES completion:finish];
else finish();
});
});
}
// "Santander 2026-11-09 14.02.11" for AppBackups/<bundle id>/<date>, else the folder name.
static NSString *wq_zip_name(NSString *dir)
{
NSArray *parts = dir.pathComponents;
NSUInteger i = [parts indexOfObject:@"AppBackups"];
if (i != NSNotFound && i + 2 == parts.count - 1) {
NSString *bid = parts[i + 1];
return [NSString stringWithFormat:@"%@ %@", wq_display_name(bid) ?: bid, parts.lastObject];
}
if (i != NSNotFound && i + 1 == parts.count - 1) {
NSString *bid = parts.lastObject;
return [NSString stringWithFormat:@"%@ backups", wq_display_name(bid) ?: bid];
}
return dir.lastPathComponent;
}
+42
View File
@@ -0,0 +1,42 @@
//
// WQSyntax.h
// WQ Files
//
// Small syntax highlighter for the text viewer: one regular expression per
// language finds comments, strings, numbers, keywords and tags left to right
// (so a "//" inside a string stays part of the string). Foundation only; the
// viewer maps the token kinds to colors.
//
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
typedef NS_ENUM(uint8_t, WQSyntaxKind) {
WQSyntaxComment = 1,
WQSyntaxString,
WQSyntaxKey, // JSON keys, CSS properties, INI/YAML keys
WQSyntaxKeyword,
WQSyntaxNumber,
WQSyntaxTag, // XML/HTML tag names, CSS selectors
WQSyntaxAttribute, // XML attributes
WQSyntaxPreprocessor, // #include, @media, shebang
};
typedef struct {
NSRange range;
WQSyntaxKind kind;
} WQSyntaxToken;
// Larger texts are shown plain (matching would take too long).
extern const NSUInteger kWQSyntaxMaxLength;
// The language for a file, from its name (and for files without an extension,
// a "#!" first line), or nil for plain text.
NSString *_Nullable wq_syntax_language(NSString *fileName, NSString *_Nullable text);
// Tokens (WQSyntaxToken structs, in text order) for text in language, or nil
// when the language is unknown, the text is too long, or cancelled() says so.
NSData *_Nullable wq_syntax_tokens(NSString *text, NSString *language, BOOL (^_Nullable cancelled)(void));
NS_ASSUME_NONNULL_END
+295
View File
@@ -0,0 +1,295 @@
//
// WQSyntax.m
// WQ Files
//
#import "WQSyntax.h"
const NSUInteger kWQSyntaxMaxLength = 1024 * 1024;
// Each rule is @[kind, pattern], the pattern with non-capturing groups only;
// the combined expression wraps every rule in one capture group, so the group
// that matched gives the token kind. Earlier rules win where two could start
// at one place.
static NSString *const kDQ = @"\"(?:[^\"\\\\\\n]|\\\\.)*\""; // "…" on one line, with escapes
static NSString *const kSQ = @"'(?:[^'\\\\\\n]|\\\\.)*'";
static NSString *const kBQ = @"`(?:[^`\\\\]|\\\\.)*`";
static NSString *const kNumber = @"\\b(?:0[xX][0-9a-fA-F_]+|0[bB][01_]+|\\d[\\d_]*(?:\\.\\d+)?(?:[eE][+-]?\\d+)?)[uUlLfF]*\\b";
static NSString *const kSlashComments = @"//[^\\n]*|/\\*[\\s\\S]*?(?:\\*/|$(?![\\s\\S]))";
static NSString *const kHashComment = @"#[^\\n]*";
static NSString *wq_words(NSString *words)
{
NSArray *list = [words componentsSeparatedByCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet];
list = [list filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"length > 0"]];
return [NSString stringWithFormat:@"\\b(?:%@)\\b", [list componentsJoinedByString:@"|"]];
}
static NSString *const kCKeywords =
@"auto break case char const continue default do double else enum extern float for goto if inline int long "
"register restrict return short signed sizeof static struct switch typedef union unsigned void volatile while "
"bool true false NULL nullptr class namespace template typename public private protected virtual override "
"new delete this try catch throw using operator const_cast static_cast dynamic_cast reinterpret_cast "
"id self super nil Nil YES NO BOOL SEL IMP instancetype nonatomic atomic strong weak copy assign readonly readwrite "
"nullable nonnull _Nullable _Nonnull __weak __strong __block";
static NSString *const kObjCDirectives = @"@(?:interface|implementation|end|protocol|property|synthesize|dynamic|class|"
"selector|encode|optional|required|import|autoreleasepool|synchronized|try|catch|finally|throw|available)\\b";
static NSString *const kJSKeywords =
@"async await break case catch class const continue debugger default delete do else export extends false finally "
"for function if import in instanceof let new null of return static super switch this throw true try typeof "
"undefined var void while with yield interface type enum implements readonly private public protected "
"abstract declare namespace as any number string boolean never unknown";
static NSString *const kSwiftKeywords =
@"associatedtype class deinit enum extension fileprivate func import init inout internal let open operator private "
"protocol public rethrows static struct subscript typealias var break case continue default defer do else "
"fallthrough for guard if in repeat return switch where while as catch false is nil super self Self throw "
"throws true try async await some any final lazy override mutating weak unowned convenience required";
static NSString *const kJavaKeywords =
@"abstract assert boolean break byte case catch char class const continue default do double else enum extends "
"final finally float for goto if implements import instanceof int interface long native new package private "
"protected public return short static strictfp super switch synchronized this throw throws transient try void "
"volatile while true false null var val fun object when is in override data sealed companion suspend";
static NSString *const kGoKeywords =
@"break case chan const continue default defer else fallthrough for func go goto if import interface map "
"package range return select struct switch type var true false nil iota";
static NSString *const kRustKeywords =
@"as async await break const continue crate dyn else enum extern false fn for if impl in let loop match mod move "
"mut pub ref return self Self static struct super trait true type unsafe use where while";
static NSString *const kPythonKeywords =
@"and as assert async await break class continue def del elif else except False finally for from global if "
"import in is lambda None nonlocal not or pass raise return True try while with yield self";
static NSString *const kShellKeywords =
@"if then else elif fi case esac for select while until do done in function return exit export local readonly "
"declare unset shift source alias echo printf cd test set eval exec trap break continue true false";
static NSString *const kRubyKeywords =
@"alias and begin break case class def defined do else elsif end ensure false for if in module next nil not or "
"redo rescue retry return self super then true undef unless until when while yield require attr_accessor";
static NSString *const kSQLKeywords =
@"select from where and or not insert into values update set delete create table index view trigger drop alter "
"add column primary key foreign references unique null default join left right inner outer full cross on as "
"order by group having limit offset distinct union all exists in is like glob between case when then else end "
"begin commit rollback transaction pragma integer text real blob numeric asc desc if replace without rowid "
"autoincrement check constraint collate cast";
static NSString *const kLuaKeywords =
@"and break do else elseif end false for function goto if in local nil not or repeat return then true until while";
// Rules per language family: @[@[kind, pattern], …] and regex options.
static NSArray *wq_language_rules(NSString *language, NSRegularExpressionOptions *options)
{
*options = NSRegularExpressionAnchorsMatchLines;
if ([language isEqualToString:@"xml"]) {
return @[
@[@(WQSyntaxComment), @"<!--[\\s\\S]*?(?:-->|$(?![\\s\\S]))"],
@[@(WQSyntaxString), @"<!\\[CDATA\\[[\\s\\S]*?\\]\\]>"],
@[@(WQSyntaxPreprocessor), @"<[?!][^>]*>"],
@[@(WQSyntaxTag), @"</?[A-Za-z_][\\w:.-]*|/?>"],
@[@(WQSyntaxAttribute), @"\\b[A-Za-z_][\\w:.-]*(?=\\s*=\\s*[\"'])"],
@[@(WQSyntaxString), @"\"[^\"<]*\"|'[^'<]*'"],
@[@(WQSyntaxKeyword), @"&#?\\w+;"],
];
}
if ([language isEqualToString:@"json"]) {
return @[
@[@(WQSyntaxComment), kSlashComments], // JSON5 / JSONC
@[@(WQSyntaxKey), [kDQ stringByAppendingString:@"(?=\\s*:)"]],
@[@(WQSyntaxString), kDQ],
@[@(WQSyntaxKeyword), @"\\b(?:true|false|null)\\b"],
@[@(WQSyntaxNumber), @"-?\\b\\d+(?:\\.\\d+)?(?:[eE][+-]?\\d+)?\\b"],
];
}
if ([language isEqualToString:@"css"]) {
return @[
@[@(WQSyntaxComment), @"/\\*[\\s\\S]*?(?:\\*/|$(?![\\s\\S]))"],
@[@(WQSyntaxString), [NSString stringWithFormat:@"%@|%@", kDQ, kSQ]],
@[@(WQSyntaxPreprocessor), @"@[\\w-]+"],
@[@(WQSyntaxKey), @"(?<![\\w-])-{0,2}[A-Za-z][\\w-]*(?=\\s*:(?![:\\w]*\\s*\\{))"],
@[@(WQSyntaxNumber), @"#[0-9a-fA-F]{3,8}\\b|-?\\b\\d+(?:\\.\\d+)?(?:px|em|rem|%|vh|vw|s|ms|pt|deg)?"],
@[@(WQSyntaxKeyword), @"!important\\b"],
@[@(WQSyntaxTag), @"[.#]?[A-Za-z_][\\w-]*(?=[^{};]*\\{)"],
];
}
if ([language isEqualToString:@"sql"]) {
*options |= NSRegularExpressionCaseInsensitive;
return @[
@[@(WQSyntaxComment), @"--[^\\n]*|/\\*[\\s\\S]*?(?:\\*/|$(?![\\s\\S]))"],
@[@(WQSyntaxString), @"'(?:[^']|'')*'"],
@[@(WQSyntaxKey), @"\"(?:[^\"]|\"\")*\"|`[^`]*`|\\[[^\\]\\n]*\\]"],
@[@(WQSyntaxKeyword), wq_words(kSQLKeywords)],
@[@(WQSyntaxNumber), kNumber],
];
}
if ([language isEqualToString:@"ini"]) {
return @[
@[@(WQSyntaxComment), @"^\\s*[#;][^\\n]*"],
@[@(WQSyntaxTag), @"^\\s*\\[[^\\]\\n]*\\]"],
@[@(WQSyntaxKey), @"^\\s*[\\w.$-]+(?=\\s*[=:])"],
@[@(WQSyntaxString), [NSString stringWithFormat:@"%@|%@", kDQ, kSQ]],
@[@(WQSyntaxKeyword), @"\\b(?:true|false|yes|no|on|off|null)\\b"],
@[@(WQSyntaxNumber), kNumber],
];
}
if ([language isEqualToString:@"yaml"]) {
return @[
@[@(WQSyntaxComment), @"(?:^|(?<=\\s))#[^\\n]*"],
@[@(WQSyntaxPreprocessor), @"^(?:---|\\.\\.\\.)\\s*$"],
@[@(WQSyntaxKey), @"^\\s*(?:-\\s+)?[^\\s#:\\-\"'][^:#\\n]*(?=:(?:\\s|$))"],
@[@(WQSyntaxString), [NSString stringWithFormat:@"%@|%@", kDQ, kSQ]],
@[@(WQSyntaxKeyword), @"\\b(?:true|false|yes|no|on|off|null)\\b|~"],
@[@(WQSyntaxNumber), kNumber],
];
}
if ([language isEqualToString:@"markdown"]) {
return @[
@[@(WQSyntaxString), @"^```[\\s\\S]*?(?:^```|$(?![\\s\\S]))|`[^`\\n]+`"],
@[@(WQSyntaxKeyword), @"^#{1,6}[^\\n]*"],
@[@(WQSyntaxTag), @"\\[[^\\]\\n]*\\]\\([^)\\n]*\\)"],
@[@(WQSyntaxAttribute), @"\\*\\*[^*\\n]+\\*\\*|__[^_\\n]+__"],
@[@(WQSyntaxComment), @"^>[^\\n]*"],
@[@(WQSyntaxNumber), @"^\\s*(?:[-*+]|\\d+\\.)(?=\\s)"],
];
}
// Hash-comment languages.
NSDictionary *hash = @{ @"python": kPythonKeywords, @"shell": kShellKeywords, @"ruby": kRubyKeywords };
if (hash[language]) {
NSString *strings = [language isEqualToString:@"python"]
? [NSString stringWithFormat:@"(?:[rRbBfFuU]{0,2})(?:\"\"\"[\\s\\S]*?(?:\"\"\"|$(?![\\s\\S]))|'''[\\s\\S]*?(?:'''|$(?![\\s\\S]))|%@|%@)", kDQ, kSQ]
: [NSString stringWithFormat:@"%@|%@|%@", kDQ, kSQ, kBQ];
return @[
@[@(WQSyntaxPreprocessor), @"\\A#![^\\n]*"],
@[@(WQSyntaxComment), [language isEqualToString:@"shell"] ? @"(?:^|(?<=[\\s;]))#[^\\n]*" : kHashComment],
@[@(WQSyntaxString), strings],
@[@(WQSyntaxPreprocessor), [language isEqualToString:@"python"] ? @"^\\s*@[\\w.]+" : @"\\$\\{[^}\\n]*\\}|\\$[\\w@#?$!*-]"],
@[@(WQSyntaxKeyword), wq_words(hash[language])],
@[@(WQSyntaxNumber), kNumber],
];
}
if ([language isEqualToString:@"lua"]) {
return @[
@[@(WQSyntaxComment), @"--\\[\\[[\\s\\S]*?(?:\\]\\]|$(?![\\s\\S]))|--[^\\n]*"],
@[@(WQSyntaxString), [NSString stringWithFormat:@"\\[\\[[\\s\\S]*?\\]\\]|%@|%@", kDQ, kSQ]],
@[@(WQSyntaxKeyword), wq_words(kLuaKeywords)],
@[@(WQSyntaxNumber), kNumber],
];
}
// C-like languages.
NSDictionary *cLike = @{ @"c": kCKeywords, @"js": kJSKeywords, @"swift": kSwiftKeywords, @"java": kJavaKeywords,
@"go": kGoKeywords, @"rust": kRustKeywords };
if (cLike[language]) {
BOOL c = [language isEqualToString:@"c"];
NSString *strings = c ? [NSString stringWithFormat:@"@?%@|%@", kDQ, kSQ]
: [language isEqualToString:@"swift"] ? [NSString stringWithFormat:@"\"\"\"[\\s\\S]*?(?:\"\"\"|$(?![\\s\\S]))|%@", kDQ]
: [NSString stringWithFormat:@"%@|%@|%@", kDQ, kSQ, kBQ];
return @[
@[@(WQSyntaxComment), kSlashComments],
@[@(WQSyntaxString), strings],
@[@(WQSyntaxPreprocessor), c ? [@"^\\s*#\\s*\\w+|" stringByAppendingString:kObjCDirectives]
: @"^\\s*#!\\[?[^\\n]*|@[A-Za-z_]\\w*"],
@[@(WQSyntaxKeyword), wq_words(cLike[language])],
@[@(WQSyntaxNumber), kNumber],
];
}
return nil;
}
NSString *wq_syntax_language(NSString *fileName, NSString *text)
{
static NSDictionary<NSString *, NSString *> *byExtension;
static dispatch_once_t once;
dispatch_once(&once, ^{
NSMutableDictionary *map = [NSMutableDictionary new];
void (^add)(NSString *, NSString *) = ^(NSString *language, NSString *extensions) {
for (NSString *e in [extensions componentsSeparatedByString:@" "]) map[e] = language;
};
add(@"xml", @"xml plist html htm xhtml svg xib storyboard entitlements mobileconfig xsd xsl rss atom");
add(@"json", @"json jsonc json5 geojson webmanifest har ipynb");
add(@"css", @"css scss less");
add(@"sql", @"sql");
add(@"ini", @"ini cfg conf toml properties env gitconfig editorconfig");
add(@"yaml", @"yaml yml");
add(@"markdown", @"md markdown");
add(@"python", @"py pyw pyi");
add(@"shell", @"sh bash zsh ksh command profile bashrc zshrc");
add(@"ruby", @"rb gemspec podspec");
add(@"lua", @"lua");
add(@"c", @"c h m mm cc cpp cxx hpp hh hxx metal strings");
add(@"js", @"js mjs cjs jsx ts tsx");
add(@"swift", @"swift");
add(@"java", @"java kt kts gradle groovy scala dart cs");
add(@"go", @"go");
add(@"rust", @"rs");
byExtension = map;
});
NSString *ext = fileName.pathExtension.lowercaseString;
if (!ext.length && [fileName hasPrefix:@"."]) ext = [fileName substringFromIndex:1].lowercaseString; // .zshrc
NSString *language = ext.length ? byExtension[ext] : nil;
if (language) return language;
if (!text.length) return nil;
// No known extension: a shebang, or text that looks like XML or JSON.
NSString *head = [text substringToIndex:MIN(text.length, (NSUInteger)200)];
NSString *trimmed = [head stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet];
if ([trimmed hasPrefix:@"#!"]) {
NSString *line = [trimmed componentsSeparatedByString:@"\n"].firstObject;
if ([line containsString:@"python"]) return @"python";
if ([line containsString:@"ruby"]) return @"ruby";
if ([line containsString:@"node"]) return @"js";
if ([line containsString:@"lua"]) return @"lua";
return @"shell";
}
if ([trimmed hasPrefix:@"<?xml"] || [trimmed hasPrefix:@"<!DOCTYPE"] || [trimmed hasPrefix:@"<plist"]) return @"xml";
if (([trimmed hasPrefix:@"{"] || [trimmed hasPrefix:@"["]) && ext.length == 0 &&
[NSJSONSerialization JSONObjectWithData:[text dataUsingEncoding:NSUTF8StringEncoding] ?: [NSData data]
options:NSJSONReadingFragmentsAllowed error:nil])
return @"json";
return nil;
}
NSData *wq_syntax_tokens(NSString *text, NSString *language, BOOL (^cancelled)(void))
{
if (text.length > kWQSyntaxMaxLength) return nil;
static NSMutableDictionary<NSString *, id> *cache;
static NSMutableDictionary<NSString *, NSArray *> *kindsCache;
static NSObject *lock;
static dispatch_once_t once;
dispatch_once(&once, ^{ cache = [NSMutableDictionary new]; kindsCache = [NSMutableDictionary new]; lock = [NSObject new]; });
NSRegularExpression *regex = nil;
NSArray<NSNumber *> *kinds = nil;
@synchronized (lock) {
id cached = cache[language];
if (!cached) {
NSRegularExpressionOptions options = 0;
NSArray *rules = wq_language_rules(language, &options);
NSMutableArray *parts = [NSMutableArray new], *ruleKinds = [NSMutableArray new];
for (NSArray *rule in rules) {
[parts addObject:[NSString stringWithFormat:@"(%@)", rule[1]]];
[ruleKinds addObject:rule[0]];
}
NSError *error = nil;
NSRegularExpression *built = rules.count
? [NSRegularExpression regularExpressionWithPattern:[parts componentsJoinedByString:@"|"] options:options error:&error]
: nil;
if (rules.count && !built) NSLog(@"WQSyntax: %@ pattern failed: %@", language, error);
cached = built ?: NSNull.null;
cache[language] = cached;
if (built) kindsCache[language] = ruleKinds;
}
if (cached != NSNull.null) { regex = cached; kinds = kindsCache[language]; }
}
if (!regex) return nil;
NSMutableData *tokens = [NSMutableData new];
__block BOOL stopped = NO;
__block NSUInteger checked = 0;
[regex enumerateMatchesInString:text options:0 range:NSMakeRange(0, text.length)
usingBlock:^(NSTextCheckingResult *match, NSMatchingFlags __unused flags, BOOL *stop) {
if (cancelled && ++checked % 512 == 0 && cancelled()) { stopped = YES; *stop = YES; return; }
if (!match || match.range.length == 0) return;
for (NSUInteger g = 1; g < match.numberOfRanges && g <= kinds.count; g++) {
NSRange r = [match rangeAtIndex:g];
if (r.location == NSNotFound) continue;
WQSyntaxToken token = { r, (WQSyntaxKind)kinds[g - 1].unsignedCharValue };
[tokens appendBytes:&token length:sizeof(token)];
break;
}
}];
return stopped ? nil : tokens;
}
+37
View File
@@ -0,0 +1,37 @@
//
// WQUI.h
// WQ Files shared UI helpers
//
#import <UIKit/UIKit.h>
#import "WQLocalization.h"
NS_ASSUME_NONNULL_BEGIN
static inline UIBarButtonItemStyle wq_primary_button_style(void)
{
if (@available(iOS 26.0, *)) return UIBarButtonItemStyleProminent;
return UIBarButtonItemStylePlain;
}
dispatch_queue_t wq_io(void);
void wq_alert(UIViewController *host, NSString *title, NSString *_Nullable message);
// Settings → Allow Deleting (on by default). When off, every delete, clear and
// clean action is hidden or refused.
BOOL wq_deleting_allowed(void);
void wq_set_deleting_allowed(BOOL allowed);
// Settings → Keep Data Before Restore (on by default). When off, the safety
// backup an app restore makes first is deleted once the restore succeeded.
BOOL wq_keep_before_restore(void);
void wq_set_keep_before_restore(BOOL keep);
// YES (after telling the user) when deleting is turned off.
BOOL wq_refuse_if_deleting_off(UIViewController *host);
UIAlertController *wq_alert_controller(NSString *_Nullable title, NSString *_Nullable message, UIAlertControllerStyle style);
UIAlertAction *wq_alert_action(NSString *title, UIAlertActionStyle style, void (^_Nullable handler)(UIAlertAction *));
NSString *wq_size_text(unsigned long long size);
UIView *wq_device_header(CGFloat width);
// "iPhone 17 Pro Max (iPhone18,2) · iOS 27.0.1 (24A446)"
NSString *wq_device_line(void);
UIImage *_Nullable wq_app_icon(NSString *_Nullable bundleID);
NS_ASSUME_NONNULL_END
+354
View File
@@ -0,0 +1,354 @@
//
// WQUI.m
// WQ Files shared UI helpers
// GPL-3.0-or-later.
//
#import "WQUI.h"
#import "WQAccess.h"
#import "WQLaunchServices.h"
#import <fcntl.h>
#import <sys/sysctl.h>
#import <sys/utsname.h>
// "iPhone 17 Pro Max (iPhone18,2)\niOS 27.0.1 (24A446)"
NSString *wq_device_line(void)
{
struct utsname u;
NSString *machine = (uname(&u) == 0) ? @(u.machine) : @"unknown";
char build[64] = {0};
size_t len = sizeof(build);
NSString *buildStr = (sysctlbyname("kern.osversion", build, &len, NULL, 0) == 0) ? @(build) : nil;
NSDictionary *names = @{
@"iPhone17,1": @"iPhone 16 Pro", @"iPhone17,2": @"iPhone 16 Pro Max",
@"iPhone17,3": @"iPhone 16", @"iPhone17,4": @"iPhone 16 Plus", @"iPhone17,5": @"iPhone 16e",
@"iPhone18,1": @"iPhone 17 Pro", @"iPhone18,2": @"iPhone 17 Pro Max",
@"iPhone18,3": @"iPhone 17", @"iPhone18,4": @"iPhone Air",
};
NSString *marketing = names[machine];
NSString *model = marketing ? [NSString stringWithFormat:@"%@ (%@)", marketing, machine] : machine;
NSString *ver = UIDevice.currentDevice.systemVersion;
return buildStr ? [NSString stringWithFormat:@"%@ · iOS %@ (%@)", model, ver, buildStr]
: [NSString stringWithFormat:@"%@ · iOS %@", model, ver];
}
// A small centered label with the device/iOS line, for a table header.
UIView *wq_device_header(CGFloat width)
{
UIView *box = [[UIView alloc] initWithFrame:CGRectMake(0, 0, width, 50)];
UILabel *header = [UILabel new];
header.numberOfLines = 0;
header.textAlignment = NSTextAlignmentCenter;
header.font = [UIFont monospacedSystemFontOfSize:11 weight:UIFontWeightMedium];
header.textColor = UIColor.secondaryLabelColor;
header.backgroundColor = UIColor.secondarySystemBackgroundColor;
header.text = wq_device_line();
header.frame = CGRectMake(0, 0, width, 34);
header.autoresizingMask = UIViewAutoresizingFlexibleWidth;
[box addSubview:header]; // 16 pt of clear space below the strip
return box;
}
// Private: app icon by bundle id (in UIKit; guarded with respondsToSelector).
@interface UIImage (WQPrivate)
+ (UIImage *)_applicationIconImageForBundleIdentifier:(NSString *)bid format:(int)fmt scale:(CGFloat)scale;
@end
// Small grey fingerprint of an icon, to recognise iOS's blank placeholder.
static NSData *wq_icon_fingerprint(UIImage *img)
{
const size_t s = 12;
uint8_t px[12 * 12 * 4] = {0};
CGColorSpaceRef cs = CGColorSpaceCreateDeviceRGB();
CGContextRef ctx = CGBitmapContextCreate(px, s, s, 8, s * 4, cs, (CGBitmapInfo)kCGImageAlphaPremultipliedLast);
CGColorSpaceRelease(cs);
if (!ctx) return nil;
if (img.CGImage) CGContextDrawImage(ctx, CGRectMake(0, 0, s, s), img.CGImage);
CGContextRelease(ctx);
return [NSData dataWithBytes:px length:sizeof(px)];
}
static UIImage *wq_raw_icon(NSString *bundleID)
{
if (![UIImage respondsToSelector:@selector(_applicationIconImageForBundleIdentifier:format:scale:)]) return nil;
CGFloat scale = 2.0; // Safe fallback for background preparation without UI context.
if (NSThread.isMainThread) {
for (UIScene *scene in UIApplication.sharedApplication.connectedScenes) {
if ([scene isKindOfClass:UIWindowScene.class] && scene.activationState == UISceneActivationStateForegroundActive) {
scale = ((UIWindowScene *)scene).screen.scale;
break;
}
}
}
return [UIImage _applicationIconImageForBundleIdentifier:bundleID format:2 scale:scale];
}
// The real icon for bundleID, or nil when iOS only has its placeholder.
static UIImage *wq_real_icon(NSString *bundleID)
{
static NSData *placeholder;
static dispatch_once_t once;
dispatch_once(&once, ^{ placeholder = wq_icon_fingerprint(wq_raw_icon(@"cz.kolbi.wqfiles.no-such-app")); });
UIImage *raw = wq_raw_icon(bundleID);
if (!raw || !raw.CGImage) return nil;
NSData *fp = wq_icon_fingerprint(raw);
if (!placeholder || fp.length != placeholder.length) return raw;
// Near-identical to the placeholder (allow small rendering differences).
const uint8_t *a = fp.bytes, *b = placeholder.bytes;
unsigned long diff = 0;
for (NSUInteger i = 0; i < fp.length; i++) diff += (unsigned long)abs((int)a[i] - (int)b[i]);
return diff / fp.length < 8 ? nil : raw;
}
// The icon from the app's own bundle, for when iOS only hands out its
// placeholder (iOS 26 refuses this app's icon requests). Reads the primary
// icon names from Info.plist and loads the matching PNG files.
// The first few outcomes go to the Log, to see why icons are missing.
static void wq_icon_note(NSString *bundleID, NSString *what)
{
static NSUInteger notes;
@synchronized (NSNull.null) {
if (notes >= 8) return;
notes++;
}
wq_log(@"icon: %@: %@", bundleID, what);
}
static UIImage *wq_bundle_icon(NSString *bundleID)
{
NSString *path = wq_bundle_path_for_id(bundleID);
if (!path.length) { wq_icon_note(bundleID, @"no bundle path in the app database"); return nil; }
NSString *plistPath = [path stringByAppendingPathComponent:@"Info.plist"];
NSDictionary *info = [NSDictionary dictionaryWithContentsOfFile:plistPath];
if (!info && wq_grant(path)) info = [NSDictionary dictionaryWithContentsOfFile:plistPath];
if (![info isKindOfClass:NSDictionary.class]) {
int fd = open(plistPath.fileSystemRepresentation, O_RDONLY);
int err = fd < 0 ? errno : 0;
if (fd >= 0) close(fd);
wq_icon_note(bundleID, [NSString stringWithFormat:@"can't read %@ (%s)", plistPath, err ? strerror(err) : "not a plist"]);
return nil;
}
NSMutableArray<NSString *> *names = [NSMutableArray new];
for (NSString *key in @[@"CFBundleIcons", @"CFBundleIcons~ipad"]) {
NSDictionary *primary = [info[key] isKindOfClass:NSDictionary.class] ? info[key][@"CFBundlePrimaryIcon"] : nil;
if (![primary isKindOfClass:NSDictionary.class]) continue;
if ([primary[@"CFBundleIconName"] isKindOfClass:NSString.class]) [names addObject:primary[@"CFBundleIconName"]];
NSArray *files = [primary[@"CFBundleIconFiles"] isKindOfClass:NSArray.class] ? primary[@"CFBundleIconFiles"] : @[];
for (id file in files.reverseObjectEnumerator) if ([file isKindOfClass:NSString.class]) [names addObject:file];
}
if ([info[@"CFBundleIconFile"] isKindOfClass:NSString.class]) [names addObject:info[@"CFBundleIconFile"]];
[names addObjectsFromArray:@[@"AppIcon60x60", @"AppIcon"]];
// Only the PNG files apps ship next to their asset catalog. Loading from
// the catalog (UIImage imageNamed:inBundle:) aborts inside UIKit when an
// entry exists but can't be rendered (crash on 27.0.1, build 21).
for (NSString *name in names) {
NSString *base = name.stringByDeletingPathExtension;
for (NSString *suffix in @[@"@3x.png", @"@2x.png", @"@2x~ipad.png", @"~ipad.png", @".png"]) {
UIImage *image = [UIImage imageWithContentsOfFile:[path stringByAppendingPathComponent:[base stringByAppendingString:suffix]]];
if (image.CGImage) {
wq_icon_note(bundleID, [NSString stringWithFormat:@"loaded %@%@ from the app", base, suffix]);
return image;
}
}
}
wq_icon_note(bundleID, [NSString stringWithFormat:@"no icon PNG in %@ (tried %@)", path.lastPathComponent,
[names componentsJoinedByString:@", "]]);
return nil;
}
static UIImage *wq_round_icon(UIImage *raw)
{
CGFloat s = 40;
UIGraphicsImageRenderer *r = [[UIGraphicsImageRenderer alloc] initWithSize:CGSizeMake(s, s)];
return [r imageWithActions:^(UIGraphicsImageRendererContext *__unused ctx) {
[[UIBezierPath bezierPathWithRoundedRect:CGRectMake(0, 0, s, s) cornerRadius:s * 0.225] addClip];
[raw drawInRect:CGRectMake(0, 0, s, s)];
}];
}
// Symbol tile for system services and extensions that have no icon at all,
// picked from keywords in the bundle id.
static UIImage *wq_service_tile(NSString *bundleID)
{
static NSArray<NSArray *> *rules;
static NSCache *tiles;
static dispatch_once_t once;
dispatch_once(&once, ^{
tiles = [NSCache new];
UIColor *blue = UIColor.systemBlueColor, *green = UIColor.systemGreenColor, *red = UIColor.systemRedColor,
*orange = UIColor.systemOrangeColor, *purple = UIColor.systemPurpleColor, *indigo = UIColor.systemIndigoColor,
*teal = UIColor.systemTealColor, *pink = UIColor.systemPinkColor, *gray = UIColor.systemGrayColor;
// keywords (lower case, any match), symbol, colour
rules = @[
@[@[@"emoji"], @"face.smiling.inverse", orange],
@[@[@"keyboard", @"inputui", @"textinput", @"dictation"], @"keyboard.fill", gray],
@[@[@"airdrop", @"sharing", @"sharesheet"], @"antenna.radiowaves.left.and.right", blue],
@[@[@"widget", @"chrono"], @"square.grid.2x2.fill", blue],
@[@[@"screenshot", @"screencapture", @"screenrecord"], @"camera.viewfinder", indigo],
@[@[@"poster", @"wallpaper"], @"photo.on.rectangle.angled", purple],
@[@[@"siri", @"assistant"], @"waveform", indigo],
@[@[@"camera"], @"camera.fill", gray],
@[@[@"photo"], @"photo.fill", orange],
@[@[@"music", @"audio", @"shazam", @"musicrecognition"], @"music.note", pink],
@[@[@"headphone", @"airpods", @"bluetooth"], @"headphones", gray],
@[@[@"hearing"], @"ear.fill", blue],
@[@[@"weather"], @"cloud.sun.fill", teal],
@[@[@"location", @"maps", @"navigation"], @"location.fill", blue],
@[@[@"health", @"fitness", @"workout"], @"heart.fill", red],
@[@[@"pay", @"wallet", @"passbook", @"cash", @"finance"], @"creditcard.fill", green],
@[@[@"mail"], @"envelope.fill", blue],
@[@[@"message", @"sms", @"imessage"], @"message.fill", green],
@[@[@"facetime", @"video"], @"video.fill", green],
@[@[@"phone", @"call", @"telephony"], @"phone.fill", green],
@[@[@"notification", @"usernotification", @"bulletin"], @"bell.fill", red],
@[@[@"shortcut", @"workflow", @"intent"], @"square.on.square", indigo],
@[@[@"safari", @"web"], @"safari.fill", blue],
@[@[@"news", @"stocks"], @"newspaper.fill", red],
@[@[@"calendar", @"event"], @"calendar", red],
@[@[@"note", @"paper"], @"note.text", orange],
@[@[@"reminder"], @"checklist", blue],
@[@[@"game"], @"gamecontroller.fill", purple],
@[@[@"auth", @"account", @"appsso", @"password", @"keychain", @"tcc", @"privacy"], @"lock.fill", gray],
@[@[@"print"], @"printer.fill", gray],
@[@[@"clarity", @"accessib"], @"accessibility", blue],
@[@[@"home"], @"house.fill", orange],
@[@[@"findmy", @"fmd", @"fmip"], @"location.circle.fill", green],
@[@[@"carplay", @"carradio", @"cartrip", @"car."], @"car.fill", gray],
@[@[@"tvremote", @"tvsetup", @"com.apple.tv"], @"tv.fill", gray],
@[@[@"book"], @"book.fill", orange],
@[@[@"podcast"], @"mic.fill", purple],
@[@[@"spotlight", @"search"], @"magnifyingglass", gray],
@[@[@"diagnostic", @"logging", @"crash", @"analytics"], @"stethoscope", gray],
@[@[@"font"], @"textformat", gray],
@[@[@"translate"], @"character.bubble.fill", blue],
@[@[@"file", @"document", @"cloud"], @"folder.fill", blue],
@[@[@"setup", @"buddy", @"onboarding"], @"gearshape.2.fill", gray],
];
});
NSString *key = bundleID.lowercaseString;
NSString *symbol = @"gearshape.fill";
UIColor *color = UIColor.systemGrayColor;
for (NSArray *rule in rules) {
BOOL hit = NO;
for (NSString *word in rule[0]) if ([key containsString:word]) { hit = YES; break; }
if (hit) { symbol = rule[1]; color = rule[2]; break; }
}
NSString *cacheKey = [symbol stringByAppendingString:color.description];
UIImage *tile = [tiles objectForKey:cacheKey];
if (tile) return tile;
CGFloat s = 40;
UIGraphicsImageRenderer *r = [[UIGraphicsImageRenderer alloc] initWithSize:CGSizeMake(s, s)];
UIColor *fill = [color resolvedColorWithTraitCollection:[UITraitCollection traitCollectionWithUserInterfaceStyle:UIUserInterfaceStyleLight]];
tile = [r imageWithActions:^(UIGraphicsImageRendererContext *__unused ctx) {
[fill setFill];
[[UIBezierPath bezierPathWithRoundedRect:CGRectMake(0, 0, s, s) cornerRadius:s * 0.225] fill];
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:19 weight:UIImageSymbolWeightSemibold];
UIImage *g = [[UIImage systemImageNamed:symbol withConfiguration:cfg] ?: [UIImage systemImageNamed:@"gearshape.fill" withConfiguration:cfg]
imageWithTintColor:UIColor.whiteColor renderingMode:UIImageRenderingModeAlwaysOriginal];
[g drawInRect:CGRectMake((s - g.size.width) / 2, (s - g.size.height) / 2, g.size.width, g.size.height)];
}];
[tiles setObject:tile forKey:cacheKey];
return tile;
}
// Rounded app icon for a bundle id, cached. Falls back to the closest parent
// app's icon (extensions: com.foo.app.widget -> com.foo.app), then to a
// neutral service tile. Nil only without a bundle id.
UIImage *wq_app_icon(NSString *bundleID)
{
if (!bundleID.length) return nil;
static NSCache *cache;
static dispatch_once_t once;
dispatch_once(&once, ^{ cache = [NSCache new]; });
UIImage *cached = [cache objectForKey:bundleID];
if (cached) return cached;
// The bundle fallback reads files and may ask for access: never on the
// main thread. Lists warm the cache from their background passes; a
// main-thread miss gets the tile without caching it.
BOOL background = !NSThread.isMainThread;
UIImage *system = wq_real_icon(bundleID);
if (system && background) wq_icon_note(bundleID, @"iOS returned an icon");
UIImage *raw = system ?: (background ? wq_bundle_icon(bundleID) : nil);
NSArray *parts = [bundleID componentsSeparatedByString:@"."];
for (NSInteger n = (NSInteger)parts.count - 1; !raw && n >= 3; n--)
{
NSString *parent = [[parts subarrayWithRange:NSMakeRange(0, (NSUInteger)n)] componentsJoinedByString:@"."];
raw = wq_real_icon(parent) ?: (background ? wq_bundle_icon(parent) : nil);
}
UIImage *icon = raw ? wq_round_icon(raw) : wq_service_tile(bundleID);
if (raw || background) [cache setObject:icon forKey:bundleID];
return icon;
}
dispatch_queue_t wq_io(void)
{
static dispatch_queue_t queue;
static dispatch_once_t once;
dispatch_once(&once, ^{ queue = dispatch_queue_create("wqfiles.io", DISPATCH_QUEUE_SERIAL); });
return queue;
}
static NSString *const kWQDeletingDisabledPref = @"WQDeletingDisabled";
BOOL wq_deleting_allowed(void)
{
return ![NSUserDefaults.standardUserDefaults boolForKey:kWQDeletingDisabledPref];
}
void wq_set_deleting_allowed(BOOL allowed)
{
[NSUserDefaults.standardUserDefaults setBool:!allowed forKey:kWQDeletingDisabledPref];
}
static NSString *const kWQDiscardBeforeRestorePref = @"WQDiscardBeforeRestore";
BOOL wq_keep_before_restore(void)
{
return ![NSUserDefaults.standardUserDefaults boolForKey:kWQDiscardBeforeRestorePref];
}
void wq_set_keep_before_restore(BOOL keep)
{
[NSUserDefaults.standardUserDefaults setBool:!keep forKey:kWQDiscardBeforeRestorePref];
}
BOOL wq_refuse_if_deleting_off(UIViewController *host)
{
if (wq_deleting_allowed()) return NO;
UIAlertController *alert = wq_alert_controller(@"Deleting is turned off",
@"Turn on Allow Deleting on the start screen of WQ Files to delete, clear or clean.", UIAlertControllerStyleAlert);
[alert addAction:wq_alert_action(@"OK", UIAlertActionStyleDefault, nil)];
[host presentViewController:alert animated:YES completion:nil];
return YES;
}
UIAlertController *wq_alert_controller(NSString *title, NSString *message, UIAlertControllerStyle style)
{
return [UIAlertController alertControllerWithTitle:WQLocalizedNullable(title)
message:WQLocalizedNullable(message)
preferredStyle:style];
}
UIAlertAction *wq_alert_action(NSString *title, UIAlertActionStyle style, void (^handler)(UIAlertAction *))
{
return [UIAlertAction actionWithTitle:WQLocalized(title) style:style handler:handler];
}
void wq_alert(UIViewController *host, NSString *title, NSString *message)
{
if (!host || !host.isViewLoaded || !host.view.window) return;
UIWindowScene *scene = host.view.window.windowScene;
if (scene && scene.activationState != UISceneActivationStateForegroundActive) return;
UINavigationController *nav = host.navigationController;
if (nav && nav.topViewController != host) return;
if (host.presentedViewController && !host.presentedViewController.isBeingDismissed) return;
UIAlertController *alert = wq_alert_controller(title, message, UIAlertControllerStyleAlert);
[alert addAction:wq_alert_action(@"OK", UIAlertActionStyleDefault, nil)];
[host presentViewController:alert animated:YES completion:nil];
}
NSString *wq_size_text(unsigned long long size)
{
if (size == 0) return @"0 KB";
return [NSByteCountFormatter stringFromByteCount:(long long)size countStyle:NSByteCountFormatterCountStyleFile];
}
File diff suppressed because it is too large. Load diff