diff --git a/.gitignore b/.gitignore
index e43b0f9..26dd223 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,4 @@
.DS_Store
+source/hook/build/
+source/hook/AppsManagerHook.dylib
+source/out/
diff --git a/README.md b/README.md
index f358e23..f39a34a 100644
--- a/README.md
+++ b/README.md
@@ -12,6 +12,7 @@
- IPA:[AppsManager_iOS27.ipa](AppsManager_iOS27.ipa)
- 校验文件 / Checksums:[SHA256SUMS.txt](SHA256SUMS.txt)
+- 源码 / Source:[source/](source/)(iOS 27 Hook、内置文件浏览器集成与构建脚本 / iOS 27 hook, file-browser integration and build scripts)
```text
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.
- 本仓库仅用于自用测试与数据迁移,请遵守目标 App 的许可协议和当地法律法规。
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
diff --git a/source/LICENSE b/source/LICENSE
new file mode 100644
index 0000000..f288702
--- /dev/null
+++ b/source/LICENSE
@@ -0,0 +1,674 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users. We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors. You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights. Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received. You must make sure that they, too, receive
+or can get the source code. And you must show them these terms so they
+know their rights.
+
+ Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+ For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software. For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+ Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so. This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software. The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable. Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products. If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+ Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary. To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Use with the GNU Affero General Public License.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+ Copyright (C)
+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+.
+
+ The GNU General Public License does not permit incorporating your program
+into proprietary programs. If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License. But first, please read
+.
diff --git a/source/README.md b/source/README.md
new file mode 100644
index 0000000..9486a37
--- /dev/null
+++ b/source/README.md
@@ -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...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 — — GPL-3.0-or-later.
+- **worse_query** by Taj C (forcequit) — — 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.
diff --git a/source/build_app.sh b/source/build_app.sh
new file mode 100755
index 0000000..9bb5909
--- /dev/null
+++ b/source/build_app.sh
@@ -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: /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
diff --git a/source/build_dylib.sh b/source/build_dylib.sh
new file mode 100755
index 0000000..5b1ab87
--- /dev/null
+++ b/source/build_dylib.sh
@@ -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"
diff --git a/source/hook/src/WBHook.m b/source/hook/src/WBHook.m
new file mode 100644
index 0000000..c2dfd1d
--- /dev/null
+++ b/source/hook/src/WBHook.m
@@ -0,0 +1,1724 @@
+//
+// WBHook.m
+// Apps Manager iOS 27 support hook
+//
+// Uses worse_query by Taj C (forcequit) and LaunchServices database parsing
+// adapted from WQ Files by kolbicz (GPL-3.0-or-later).
+//
+
+#import
+#import
+#import
+#import
+#import
+#import
+#import
+#import
+
+#import "WQAccess.h"
+#import "WQLaunchServices.h"
+
+extern ssize_t fsgetpath(char *buf, size_t buflen, fsid_t *fsid, uint64_t objid);
+
+static NSString *const WBStoreKey = @"WBLSStorePath";
+
+static NSString *WBLocalized(NSString *key)
+{
+ static NSDictionary *> *tables;
+ static dispatch_once_t once;
+ dispatch_once(&once, ^{
+ tables = @{
+ @"en": @{
+ @"preparing": @"Preparing app data access", @"starting": @"Starting…",
+ @"init_worse": @"Initializing worse_query…", @"locating_ls": @"Locating LaunchServices database…",
+ @"parsing_apps": @"Parsing application information…", @"building_list": @"Building application list…",
+ @"loaded_apps": @"Loaded %lu apps", @"scanning_containers": @"Scanning system containers (first run may take a while)…",
+ @"scanning_containers_short": @"Scanning system containers…", @"ls_found": @"LaunchServices database located",
+ @"init_failed": @"Initialization Failed", @"ok": @"OK",
+ @"no_group": @"No usable App Group; worse_query cannot run",
+ @"no_ls": @"LaunchServices database not found",
+ @"no_containers": @"The LaunchServices database returned no containers",
+ },
+ @"zh-Hans": @{
+ @"preparing": @"正在准备应用数据访问环境", @"starting": @"启动中…",
+ @"init_worse": @"正在初始化 worse_query…", @"locating_ls": @"正在定位 LaunchServices 数据库…",
+ @"parsing_apps": @"正在解析应用信息…", @"building_list": @"正在建立应用列表…",
+ @"loaded_apps": @"已加载 %lu 个应用", @"scanning_containers": @"正在扫描系统容器(首次可能较慢)…",
+ @"scanning_containers_short": @"正在扫描系统容器…", @"ls_found": @"已定位 LaunchServices 数据库",
+ @"init_failed": @"初始化失败", @"ok": @"好",
+ @"no_group": @"未找到可用的 App Group,无法执行 worse_query",
+ @"no_ls": @"未找到 LaunchServices 数据库",
+ @"no_containers": @"LaunchServices 数据库没有返回容器列表",
+ },
+ @"zh-Hant": @{
+ @"preparing": @"正在準備應用程式資料存取環境", @"starting": @"啟動中…",
+ @"init_worse": @"正在初始化 worse_query…", @"locating_ls": @"正在定位 LaunchServices 資料庫…",
+ @"parsing_apps": @"正在解析應用程式資訊…", @"building_list": @"正在建立應用程式列表…",
+ @"loaded_apps": @"已載入 %lu 個應用程式", @"scanning_containers": @"正在掃描系統容器(首次可能較慢)…",
+ @"scanning_containers_short": @"正在掃描系統容器…", @"ls_found": @"已定位 LaunchServices 資料庫",
+ @"init_failed": @"初始化失敗", @"ok": @"好",
+ @"no_group": @"找不到可用的 App Group,無法執行 worse_query",
+ @"no_ls": @"找不到 LaunchServices 資料庫",
+ @"no_containers": @"LaunchServices 資料庫沒有傳回容器列表",
+ },
+ @"ja": @{
+ @"preparing": @"アプリデータへのアクセスを準備中", @"starting": @"起動中…",
+ @"init_worse": @"worse_query を初期化中…", @"locating_ls": @"LaunchServices データベースを検索中…",
+ @"parsing_apps": @"アプリ情報を解析中…", @"building_list": @"アプリ一覧を作成中…",
+ @"loaded_apps": @"%lu 個のアプリを読み込みました", @"scanning_containers": @"システムコンテナをスキャン中(初回は時間がかかる場合があります)…",
+ @"scanning_containers_short": @"システムコンテナをスキャン中…", @"ls_found": @"LaunchServices データベースを検出",
+ @"init_failed": @"初期化に失敗しました", @"ok": @"OK",
+ @"no_group": @"使用可能な App Group がないため worse_query を実行できません",
+ @"no_ls": @"LaunchServices データベースが見つかりません",
+ @"no_containers": @"LaunchServices データベースがコンテナを返しませんでした",
+ },
+ @"ko": @{
+ @"preparing": @"앱 데이터 접근 준비 중", @"starting": @"시작 중…",
+ @"init_worse": @"worse_query 초기화 중…", @"locating_ls": @"LaunchServices 데이터베이스 찾는 중…",
+ @"parsing_apps": @"앱 정보 분석 중…", @"building_list": @"앱 목록 생성 중…",
+ @"loaded_apps": @"앱 %lu개 로드됨", @"scanning_containers": @"시스템 컨테이너 스캔 중(첫 실행은 오래 걸릴 수 있음)…",
+ @"scanning_containers_short": @"시스템 컨테이너 스캔 중…", @"ls_found": @"LaunchServices 데이터베이스 발견",
+ @"init_failed": @"초기화 실패", @"ok": @"확인",
+ @"no_group": @"사용 가능한 App Group이 없어 worse_query를 실행할 수 없습니다",
+ @"no_ls": @"LaunchServices 데이터베이스를 찾을 수 없습니다",
+ @"no_containers": @"LaunchServices 데이터베이스가 컨테이너를 반환하지 않았습니다",
+ },
+ @"es": @{
+ @"preparing": @"Preparando el acceso a los datos de las apps", @"starting": @"Iniciando…",
+ @"init_worse": @"Inicializando worse_query…", @"locating_ls": @"Localizando la base de datos de LaunchServices…",
+ @"parsing_apps": @"Analizando la información de las apps…", @"building_list": @"Creando la lista de apps…",
+ @"loaded_apps": @"%lu apps cargadas", @"scanning_containers": @"Escaneando contenedores del sistema (la primera vez puede tardar)…",
+ @"scanning_containers_short": @"Escaneando contenedores del sistema…", @"ls_found": @"Base de datos de LaunchServices localizada",
+ @"init_failed": @"Error de inicialización", @"ok": @"OK",
+ @"no_group": @"No hay un App Group utilizable; worse_query no puede ejecutarse",
+ @"no_ls": @"No se encontró la base de datos de LaunchServices",
+ @"no_containers": @"La base de datos de LaunchServices no devolvió contenedores",
+ },
+ @"fr": @{
+ @"preparing": @"Préparation de l’accès aux données des apps", @"starting": @"Démarrage…",
+ @"init_worse": @"Initialisation de worse_query…", @"locating_ls": @"Localisation de la base LaunchServices…",
+ @"parsing_apps": @"Analyse des informations des apps…", @"building_list": @"Création de la liste des apps…",
+ @"loaded_apps": @"%lu apps chargées", @"scanning_containers": @"Analyse des conteneurs système (la première fois peut être long)…",
+ @"scanning_containers_short": @"Analyse des conteneurs système…", @"ls_found": @"Base LaunchServices localisée",
+ @"init_failed": @"Échec de l’initialisation", @"ok": @"OK",
+ @"no_group": @"Aucun App Group utilisable ; worse_query ne peut pas s’exécuter",
+ @"no_ls": @"Base de données LaunchServices introuvable",
+ @"no_containers": @"La base LaunchServices n’a renvoyé aucun conteneur",
+ },
+ @"de": @{
+ @"preparing": @"App-Datenzugriff wird vorbereitet", @"starting": @"Startet…",
+ @"init_worse": @"worse_query wird initialisiert…", @"locating_ls": @"LaunchServices-Datenbank wird gesucht…",
+ @"parsing_apps": @"App-Informationen werden analysiert…", @"building_list": @"App-Liste wird erstellt…",
+ @"loaded_apps": @"%lu Apps geladen", @"scanning_containers": @"Systemcontainer werden gescannt (beim ersten Mal kann es dauern)…",
+ @"scanning_containers_short": @"Systemcontainer werden gescannt…", @"ls_found": @"LaunchServices-Datenbank gefunden",
+ @"init_failed": @"Initialisierung fehlgeschlagen", @"ok": @"OK",
+ @"no_group": @"Keine nutzbare App Group; worse_query kann nicht ausgeführt werden",
+ @"no_ls": @"LaunchServices-Datenbank nicht gefunden",
+ @"no_containers": @"Die LaunchServices-Datenbank hat keine Container zurückgegeben",
+ },
+ @"ru": @{
+ @"preparing": @"Подготовка доступа к данным приложений", @"starting": @"Запуск…",
+ @"init_worse": @"Инициализация worse_query…", @"locating_ls": @"Поиск базы LaunchServices…",
+ @"parsing_apps": @"Анализ информации о приложениях…", @"building_list": @"Создание списка приложений…",
+ @"loaded_apps": @"Загружено приложений: %lu", @"scanning_containers": @"Сканирование системных контейнеров (в первый раз может занять время)…",
+ @"scanning_containers_short": @"Сканирование системных контейнеров…", @"ls_found": @"База LaunchServices найдена",
+ @"init_failed": @"Ошибка инициализации", @"ok": @"ОК",
+ @"no_group": @"Нет доступной App Group; worse_query не может работать",
+ @"no_ls": @"База LaunchServices не найдена",
+ @"no_containers": @"База LaunchServices не вернула контейнеры",
+ },
+ @"ar": @{
+ @"preparing": @"جارٍ تحضير الوصول إلى بيانات التطبيقات", @"starting": @"جارٍ البدء…",
+ @"init_worse": @"جارٍ تهيئة worse_query…", @"locating_ls": @"جارٍ تحديد موقع قاعدة LaunchServices…",
+ @"parsing_apps": @"جارٍ تحليل معلومات التطبيقات…", @"building_list": @"جارٍ إنشاء قائمة التطبيقات…",
+ @"loaded_apps": @"تم تحميل %lu تطبيقًا", @"scanning_containers": @"جارٍ فحص حاويات النظام (قد يستغرق وقتًا في المرة الأولى)…",
+ @"scanning_containers_short": @"جارٍ فحص حاويات النظام…", @"ls_found": @"تم تحديد موقع قاعدة LaunchServices",
+ @"init_failed": @"فشل التهيئة", @"ok": @"حسنًا",
+ @"no_group": @"لا توجد App Group صالحة؛ لا يمكن تشغيل worse_query",
+ @"no_ls": @"لم يتم العثور على قاعدة LaunchServices",
+ @"no_containers": @"لم تُرجع قاعدة LaunchServices أي حاويات",
+ },
+ };
+ });
+ NSString *language = NSLocale.preferredLanguages.firstObject.lowercaseString ?: @"en";
+ NSString *code = @"en";
+ if ([language hasPrefix:@"zh-hans"] || [language hasPrefix:@"zh-cn"] || [language hasPrefix:@"zh-sg"]) code = @"zh-Hans";
+ else if ([language hasPrefix:@"zh-hant"] || [language hasPrefix:@"zh-tw"] || [language hasPrefix:@"zh-hk"] || [language hasPrefix:@"zh-mo"]) code = @"zh-Hant";
+ else if ([language hasPrefix:@"ja"]) code = @"ja";
+ else if ([language hasPrefix:@"ko"]) code = @"ko";
+ else if ([language hasPrefix:@"es"]) code = @"es";
+ else if ([language hasPrefix:@"fr"]) code = @"fr";
+ else if ([language hasPrefix:@"de"]) code = @"de";
+ else if ([language hasPrefix:@"ru"]) code = @"ru";
+ else if ([language hasPrefix:@"ar"]) code = @"ar";
+ NSDictionary *table = tables[code] ?: tables[@"en"];
+ return table[key] ?: tables[@"en"][key] ?: key;
+}
+
+#pragma mark - App model
+
+@interface WBAppInfo : NSObject
+@property (nonatomic, copy) NSString *bundleID;
+@property (nonatomic, copy) NSString *name;
+@property (nonatomic, copy) NSString *containerPath;
+@property (nonatomic, copy) NSString *bundlePath;
+@property (nonatomic, copy) NSString *version;
+@property (nonatomic) BOOL isSystem;
+@property (nonatomic) BOOL sizeKnown;
+@property (nonatomic) BOOL sizeQueued;
+@property (nonatomic) unsigned long long dataSize;
+@end
+
+@implementation WBAppInfo
+@end
+
+#pragma mark - Fake LSApplicationProxy
+
+@interface WBAppProxy : NSObject
+@property (nonatomic, strong) WBAppInfo *info;
+- (instancetype)initWithInfo:(WBAppInfo *)info;
+@end
+
+@implementation WBAppProxy
+
+- (instancetype)initWithInfo:(WBAppInfo *)info
+{
+ if ((self = [super init])) _info = info;
+ return self;
+}
+
+- (NSString *)bundleIdentifier { return self.info.bundleID; }
+- (NSString *)applicationIdentifier { return self.info.bundleID; }
+- (NSString *)localizedName { return self.info.name; }
+- (NSString *)localizedNameForContext:(id)ctx { return self.info.name; }
+- (NSString *)itemName { return self.info.name; }
+- (NSString *)bundleVersion { return self.info.version.length ? self.info.version : @"null"; }
+- (NSString *)shortVersionString
+{
+ if (self.info.version.length) return self.info.version;
+ NSString *version = nil;
+ if ([self.info.bundleID isEqualToString:NSBundle.mainBundle.bundleIdentifier]) {
+ version = [NSBundle.mainBundle objectForInfoDictionaryKey:@"CFBundleShortVersionString"]
+ ?: [NSBundle.mainBundle objectForInfoDictionaryKey:@"CFBundleVersion"];
+ }
+ if (!version.length && self.info.bundlePath.length) {
+ // A normal app cannot read another app's bundle. Ask worse_query for
+ // the .app first; if that is refused, try the bundle container above.
+ wq_grant(self.info.bundlePath);
+ NSBundle *bundle = [NSBundle bundleWithPath:self.info.bundlePath];
+ version = [bundle objectForInfoDictionaryKey:@"CFBundleShortVersionString"]
+ ?: [bundle objectForInfoDictionaryKey:@"CFBundleVersion"];
+ if (!version.length) {
+ wq_grant(self.info.bundlePath.stringByDeletingLastPathComponent);
+ bundle = [NSBundle bundleWithPath:self.info.bundlePath];
+ version = [bundle objectForInfoDictionaryKey:@"CFBundleShortVersionString"]
+ ?: [bundle objectForInfoDictionaryKey:@"CFBundleVersion"];
+ }
+ }
+ self.info.version = version.length ? version : @"null";
+ return self.info.version;
+}
+- (NSString *)applicationType { return self.info.isSystem ? @"System" : @"User"; }
+- (NSString *)bundleType { return @"APP"; }
+- (NSString *)platform { return @"iphoneos"; }
+- (NSString *)minimumOSVersion { return @"12.0"; }
+- (NSString *)sdkVersion { return @"17.0"; }
+- (NSString *)bundleExecutable { return self.info.bundlePath.lastPathComponent.stringByDeletingPathExtension; }
+- (NSString *)description { return [NSString stringWithFormat:@"", self.info.name, self.info.bundleID]; }
+- (NSUInteger)hash { return self.info.bundleID.hash; }
+- (BOOL)isEqual:(id)other {
+ if ([other isKindOfClass:WBAppProxy.class]) return [self.info.bundleID isEqualToString:((WBAppProxy *)other).info.bundleID];
+ return [super isEqual:other];
+}
+
+- (NSURL *)bundleURL { return self.info.bundlePath.length ? [NSURL fileURLWithPath:self.info.bundlePath isDirectory:YES] : nil; }
+- (NSURL *)bundleContainerURL { return self.bundleURL; }
+- (NSURL *)dataContainerURL { return self.info.containerPath.length ? [NSURL fileURLWithPath:self.info.containerPath isDirectory:YES] : nil; }
+
+- (NSNumber *)staticDiskUsage { return @(self.info.sizeKnown ? self.info.dataSize : 0); }
+- (NSNumber *)dynamicDiskUsage
+{
+ if (!self.info.sizeKnown && self.info.containerPath.length) {
+ unsigned long long bytes = 0;
+ wq_measure_tree(self.info.containerPath, nil, &bytes);
+ self.info.dataSize = bytes;
+ self.info.sizeKnown = YES;
+ }
+ return @(self.info.sizeKnown ? self.info.dataSize : 0);
+}
+- (long long)bundleModTime
+{
+ NSArray *subpaths = @[@"", @"Documents", @"Library"];
+ long long best = 0;
+ for (NSString *sub in subpaths) {
+ struct stat st;
+ NSString *path = sub.length ? [self.info.containerPath stringByAppendingPathComponent:sub] : self.info.containerPath;
+ BOOL ok = wq_stat(path, &st);
+ if (ok && st.st_mtimespec.tv_sec > best) best = st.st_mtimespec.tv_sec;
+ }
+ if (best > 0) return best - 978307200;
+ NSDictionary *attrs = [NSFileManager.defaultManager attributesOfItemAtPath:self.info.containerPath error:nil];
+ NSDate *date = attrs[NSFileModificationDate];
+ return date ? (long long)date.timeIntervalSinceReferenceDate : 0;
+}
+- (NSNumber *)applicationDSID { return nil; }
+- (NSNumber *)regulatoryFlags { return @0; }
+- (NSNumber *)installType { return @0; }
+- (NSArray *)deviceFamily { return @[@1, @2]; }
+
+- (NSDictionary *)entitlements
+{
+ NSString *team = @"WB00000000";
+ return @{
+ @"application-identifier": [NSString stringWithFormat:@"%@.%@", team, self.info.bundleID],
+ @"com.apple.developer.team-identifier": team,
+ @"get-task-allow": @NO,
+ };
+}
+- (NSDictionary *)groupContainerURLs
+{
+ id registry = [NSClassFromString(@"WBRegistry") performSelector:@selector(shared)];
+ return (NSDictionary *)[registry performSelector:@selector(groupContainersForApp:) withObject:self.info];
+}
+- (NSArray *)appTags { return @[]; }
+- (NSArray *)plugInKitPlugins { return @[]; }
+- (NSDictionary *)iconsDictionary { return nil; }
+- (id)iconDataForVariant:(id)variant { return nil; }
+- (id)iconIsPrerendered { return @NO; }
+- (id)containingBundle { return nil; }
+- (id)progress { return nil; }
+- (id)installProgress { return nil; }
+- (id)storeCohortMetadata { return nil; }
+- (id)vendorName { return nil; }
+- (id)teamIdentifier { return nil; }
+- (id)signerIdentity { return nil; }
+
+- (BOOL)isInstalled { return YES; }
+- (BOOL)isSystem { return self.info.isSystem; }
+- (BOOL)isContainerized { return YES; }
+- (BOOL)isHidden { return NO; }
+- (BOOL)isDeletable { return YES; }
+- (BOOL)isRestricted { return NO; }
+- (BOOL)isLaunchable { return YES; }
+- (BOOL)isPlaceholder { return NO; }
+- (BOOL)placeholder { return NO; }
+- (BOOL)isBetaApp { return NO; }
+- (BOOL)isAdHocCodeSigned { return NO; }
+- (BOOL)hasMIDBasedSINF { return NO; }
+- (BOOL)hasAppID { return YES; }
+- (BOOL)hasGroupContainers
+{
+ id registry = [NSClassFromString(@"WBRegistry") performSelector:@selector(shared)];
+ NSDictionary *groups = (NSDictionary *)[registry performSelector:@selector(groupContainersForApp:) withObject:self.info];
+ return groups.count > 0;
+}
+- (BOOL)hasParallelPlaceholder { return NO; }
+- (BOOL)isAppClip { return NO; }
+- (BOOL)isArcadeApp { return NO; }
+- (BOOL)isWebApp { return NO; }
+- (BOOL)isWrapped { return NO; }
+- (BOOL)isMarketplace { return NO; }
+- (BOOL)isEligibleForIcon { return YES; }
+- (BOOL)isUpdateAvailable { return NO; }
+- (BOOL)isValid { return YES; }
+- (BOOL)appStoreVendable { return YES; }
+- (BOOL)betaApp { return NO; }
+
+@end
+
+#pragma mark - Registry
+
+@interface WBRegistry : NSObject
+@property (atomic, readonly) BOOL ready;
+@property (atomic, copy) NSArray *apps;
+@property (atomic, copy) NSArray *proxies;
+- (WBAppInfo *)appForBundleID:(NSString *)bundleID;
+- (NSNumber *)cachedSizeForBundleID:(NSString *)bundleID;
+- (void)requestSizeForApp:(WBAppInfo *)app;
+- (void)refreshAllSizesWithCompletion:(void (^)(void))completion;
+- (void)refreshSizeForBundleID:(NSString *)bundleID;
+- (NSDictionary *)groupContainersForApp:(WBAppInfo *)app;
+- (void)prepareWithProgress:(void (^)(double fraction, NSString *status))progress
+ completion:(void (^)(BOOL ok, NSString *message))completion;
++ (instancetype)shared;
+@end
+
+@interface WBRegistry ()
+@property (atomic) BOOL ready;
+@property (atomic, strong) NSMutableDictionary *byBundleID;
+@property (atomic, strong) NSMutableDictionary *sizeCache;
+@property (atomic) BOOL refreshingAllSizes;
+@property (nonatomic, strong) dispatch_queue_t sizeQueue;
+@property (nonatomic, strong) NSDictionary *groupPathsByID;
+@property (nonatomic, strong) NSMutableDictionary *> *groupCache;
+@end
+
+@implementation WBRegistry
+
++ (instancetype)shared
+{
+ static WBRegistry *registry;
+ static dispatch_once_t once;
+ dispatch_once(&once, ^{ registry = [WBRegistry new]; });
+ return registry;
+}
+
+- (instancetype)init
+{
+ if ((self = [super init])) {
+ _byBundleID = [NSMutableDictionary new];
+ _sizeCache = [NSMutableDictionary new];
+ _sizeQueue = dispatch_queue_create("com.jbjf.appsmanager.size", DISPATCH_QUEUE_SERIAL);
+ _groupCache = [NSMutableDictionary new];
+ _apps = @[];
+ _proxies = @[];
+ }
+ return self;
+}
+
+- (WBAppInfo *)appForBundleID:(NSString *)bundleID
+{
+ if (!bundleID.length) return nil;
+ return self.byBundleID[bundleID];
+}
+
+- (NSNumber *)cachedSizeForBundleID:(NSString *)bundleID
+{
+ if (!bundleID.length) return nil;
+ WBAppInfo *app = self.byBundleID[bundleID];
+ if (app && app.sizeKnown) return @(app.dataSize);
+ return nil;
+}
+
+- (NSDictionary *)groupContainersForApp:(WBAppInfo *)app
+{
+ if (!app.bundleID.length) return @{};
+ @synchronized (self) {
+ NSDictionary *cached = self.groupCache[app.bundleID];
+ if (cached) return cached;
+ if (!self.groupPathsByID) {
+ NSMutableDictionary *paths = [NSMutableDictionary new];
+ NSString *root = @"/private/var/mobile/Containers/Shared/AppGroup";
+ for (NSString *name in wq_list(root, NULL)) {
+ if (name.length != 36) continue;
+ NSString *path = [root stringByAppendingPathComponent:name];
+ NSString *identifier = wq_container_label(path);
+ if ([identifier hasPrefix:@"group."]) paths[identifier] = path;
+ }
+ self.groupPathsByID = paths;
+ }
+ NSMutableDictionary *result = [NSMutableDictionary new];
+ for (NSString *groupID in self.groupPathsByID) {
+ NSString *bare = [groupID substringFromIndex:6];
+ BOOL match = [bare isEqualToString:app.bundleID] ||
+ [bare hasSuffix:[@"." stringByAppendingString:app.bundleID]] ||
+ [app.bundleID hasSuffix:[@"." stringByAppendingString:bare]];
+ if (match) result[groupID] = [NSURL fileURLWithPath:self.groupPathsByID[groupID] isDirectory:YES];
+ }
+ self.groupCache[app.bundleID] = result;
+ return result;
+ }
+}
+
+static NSString *WBPrettifyBundleID(NSString *bundleID)
+{
+ NSString *last = [bundleID componentsSeparatedByString:@"."].lastObject ?: bundleID;
+ NSString *name = [last stringByReplacingOccurrencesOfString:@"-" withString:@" "];
+ name = [name stringByReplacingOccurrencesOfString:@"_" withString:@" "];
+ if (name.length) name = [name stringByReplacingCharactersInRange:NSMakeRange(0, 1) withString:[name substringToIndex:1].uppercaseString];
+ return name.length ? name : bundleID;
+}
+
+- (void)requestSizeForApp:(WBAppInfo *)app
+{
+ if (!app || app.sizeKnown || app.sizeQueued || self.refreshingAllSizes) return;
+ app.sizeQueued = YES;
+ dispatch_async(self.sizeQueue, ^{
+ unsigned long long bytes = 0;
+ if (app.containerPath.length) {
+ wq_measure_tree(app.containerPath, nil, &bytes);
+ }
+ app.dataSize = bytes;
+ app.sizeKnown = YES;
+ app.sizeQueued = NO;
+ @synchronized (self) {
+ self.sizeCache[app.bundleID] = @(bytes);
+ }
+ dispatch_async(dispatch_get_main_queue(), ^{
+ [[NSNotificationCenter defaultCenter] postNotificationName:@"WBAppSizeDidUpdate" object:app];
+ });
+ });
+}
+
+// Re-measures every installed app's data container. The old sizes stay on
+// screen until each new value is ready, so pulling down does not blank the list.
+- (void)refreshAllSizesWithCompletion:(void (^)(void))completion
+{
+ NSArray *apps = self.apps;
+ if (!apps.count) {
+ if (completion) completion();
+ return;
+ }
+ self.refreshingAllSizes = YES;
+ for (WBAppInfo *app in apps) {
+ app.sizeKnown = NO;
+ app.sizeQueued = YES;
+ }
+ @synchronized (self) {
+ [self.sizeCache removeAllObjects];
+ }
+ dispatch_async(self.sizeQueue, ^{
+ for (WBAppInfo *app in apps) {
+ @autoreleasepool {
+ unsigned long long bytes = 0;
+ if (app.containerPath.length) {
+ wq_measure_tree(app.containerPath, nil, &bytes);
+ }
+ app.dataSize = bytes;
+ app.sizeKnown = YES;
+ app.sizeQueued = NO;
+ @synchronized (self) {
+ self.sizeCache[app.bundleID] = @(bytes);
+ }
+ dispatch_async(dispatch_get_main_queue(), ^{
+ [[NSNotificationCenter defaultCenter] postNotificationName:@"WBAppSizeDidUpdate" object:app];
+ });
+ }
+ }
+ self.refreshingAllSizes = NO;
+ if (completion) dispatch_async(dispatch_get_main_queue(), completion);
+ });
+}
+
+- (void)refreshSizeForBundleID:(NSString *)bundleID
+{
+ WBAppInfo *app = [self appForBundleID:bundleID];
+ if (!app) return;
+ app.sizeKnown = NO;
+ app.sizeQueued = NO;
+ [self requestSizeForApp:app];
+ dispatch_async(self.sizeQueue, ^{
+ NSLog(@"[WB] refreshed size %@: %llu", bundleID, app.dataSize);
+ });
+}
+
+static void (^gWBScanProgressBlock)(int64_t done, int64_t total, BOOL finished);
+static BOOL WBScanConfirmC(NSString *what, int64_t ids, double seconds) { return YES; }
+static void WBScanProgressC(int64_t done, int64_t total, BOOL finished)
+{
+ if (gWBScanProgressBlock) gWBScanProgressBlock(done, total, finished);
+}
+
+static NSString *WBFindStoreWithProgress(void (^progress)(double, NSString *))
+{
+ NSUserDefaults *defaults = NSUserDefaults.standardUserDefaults;
+ NSString *cached = [defaults stringForKey:WBStoreKey];
+ if (cached.length) {
+ struct stat st;
+ if (wq_stat(cached, &st) && S_ISREG(st.st_mode)) {
+ NSString *error = nil;
+ NSData *head = wq_read(cached, 4, &error);
+ if (head.length == 4 && memcmp(head.bytes, "bdsl", 4) == 0) return cached;
+ }
+ }
+
+ NSString *probePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"wb-inode-probe"];
+ int fd = open(probePath.fileSystemRepresentation, O_CREAT | O_EXCL | O_WRONLY, 0600);
+ if (fd < 0) return nil;
+ struct stat st;
+ struct statfs sfs;
+ int ok = fstat(fd, &st) == 0 && fstatfs(fd, &sfs) == 0;
+ close(fd);
+ unlink(probePath.fileSystemRepresentation);
+ if (!ok) return nil;
+
+ fsid_t fsid = sfs.f_fsid;
+ int64_t maxInode = st.st_ino + 200000;
+ const int64_t chunkSize = 32768;
+ int64_t chunks = (maxInode + chunkSize - 1) / chunkSize;
+ __block int64_t nextChunk = 0;
+ __block NSString *found = nil;
+ __block BOOL stop = NO;
+ NSLock *lock = [NSLock new];
+ CFAbsoluteTime started = CFAbsoluteTimeGetCurrent();
+ dispatch_apply(4, dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^(size_t worker) {
+ fsid_t localFSID = fsid;
+ char buf[1200];
+ for (;;) {
+ if (stop) return;
+ [lock lock];
+ int64_t c = nextChunk++;
+ [lock unlock];
+ if (c >= chunks) return;
+ int64_t last = maxInode - c * chunkSize;
+ int64_t first = MAX((int64_t)1, last - chunkSize + 1);
+ for (int64_t ino = last; ino >= first; ino--) {
+ if ((ino & 255) == 0 && (stop || CFAbsoluteTimeGetCurrent() - started > 20)) return;
+ if (fsgetpath(buf, sizeof(buf), &localFSID, (uint64_t)ino) <= 0) continue;
+ const char *name = strrchr(buf, '/');
+ name = name ? name + 1 : buf;
+ size_t len = strlen(name);
+ if (len > 33 && strncmp(name, "com.apple.LaunchServices-", 25) == 0 &&
+ strcmp(name + len - 8, ".csstore") == 0) {
+ [lock lock];
+ if (!found) {
+ found = [NSString stringWithUTF8String:buf];
+ stop = YES;
+ if (progress) progress(0.35, WBLocalized(@"ls_found"));
+ }
+ [lock unlock];
+ return;
+ }
+ }
+ }
+ });
+ if (found) {
+ [defaults setObject:found forKey:WBStoreKey];
+ [defaults synchronize];
+ return found;
+ }
+
+ // Fallback: enumerate InternalDaemon containers by inode, then look in
+ // each Library/Caches. This is slower (full scan) but only happens when
+ // the LaunchServices database is not near the newest inode.
+ if (progress) progress(0.10, WBLocalized(@"scanning_containers"));
+ gWBScanProgressBlock = [^(int64_t done, int64_t total, BOOL finished) {
+ if (progress && total > 0) progress(0.10 + 0.25 * ((double)done / (double)total), WBLocalized(@"scanning_containers_short"));
+ } copy];
+ wq_set_full_scan_ui(WBScanConfirmC, WBScanProgressC);
+ NSArray *daemons = wq_enumerate(@"/private/var/mobile/Containers/Data/InternalDaemon", 0, nil,
+ ^(int64_t done, int64_t total, NSUInteger foundCount) {
+ if (progress && total > 0) progress(0.10 + 0.25 * ((double)done / (double)total), WBLocalized(@"scanning_containers_short"));
+ });
+ gWBScanProgressBlock = nil;
+ for (NSString *daemon in daemons) {
+ NSString *caches = [daemon stringByAppendingPathComponent:@"Library/Caches"];
+ for (NSString *item in wq_list(caches, NULL)) {
+ if (![item hasPrefix:@"com.apple.LaunchServices-"] || ![item hasSuffix:@".csstore"]) continue;
+ NSString *candidate = [caches stringByAppendingPathComponent:item];
+ NSString *error = nil;
+ NSData *head = wq_read(candidate, 4, &error);
+ if (head.length == 4 && memcmp(head.bytes, "bdsl", 4) == 0) {
+ [defaults setObject:candidate forKey:WBStoreKey];
+ [defaults synchronize];
+ return candidate;
+ }
+ }
+ }
+ return nil;
+}
+
+- (void)prepareWithProgress:(void (^)(double, NSString *))progress
+ completion:(void (^)(BOOL, NSString *))completion
+{
+ dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{
+ if (progress) progress(0.02, WBLocalized(@"init_worse"));
+ NSString *group = wq_app_group();
+ if (!group.length) {
+ if (completion) completion(NO, WBLocalized(@"no_group"));
+ return;
+ }
+ if (progress) progress(0.08, WBLocalized(@"locating_ls"));
+ NSString *store = WBFindStoreWithProgress(progress);
+ if (!store.length) {
+ if (completion) completion(NO, WBLocalized(@"no_ls"));
+ return;
+ }
+ if (progress) progress(0.40, WBLocalized(@"parsing_apps"));
+ [NSUserDefaults.standardUserDefaults setObject:store forKey:@"WQLaunchServicesStore"];
+ [NSUserDefaults.standardUserDefaults synchronize];
+ NSArray *containers = wq_ls_container_paths(store);
+ wq_display_names_reset_miss();
+ wq_load_display_names();
+ if (!containers.count) {
+ if (completion) completion(NO, WBLocalized(@"no_containers"));
+ return;
+ }
+ if (progress) progress(0.65, WBLocalized(@"building_list"));
+
+ NSMutableDictionary *byID = [NSMutableDictionary new];
+ NSMutableArray *apps = [NSMutableArray new];
+ NSMutableArray *proxies = [NSMutableArray new];
+ for (NSString *container in containers) {
+ if (![container containsString:@"/Data/Application/"]) continue;
+ NSString *bundleID = wq_bundle_id_for_container(container);
+ if (!bundleID.length || byID[bundleID]) continue;
+ NSString *bundlePath = wq_bundle_path_for_id(bundleID);
+ if (!bundlePath.length || ![bundlePath.pathExtension isEqualToString:@"app"]) continue;
+ NSString *parent = bundlePath.stringByDeletingLastPathComponent;
+ if (![parent.stringByDeletingLastPathComponent isEqualToString:@"/private/var/containers/Bundle/Application"]) continue;
+
+ WBAppInfo *app = [WBAppInfo new];
+ app.bundleID = bundleID;
+ app.containerPath = container;
+ app.bundlePath = bundlePath;
+ app.isSystem = [bundleID hasPrefix:@"com.apple."];
+ NSString *name = wq_display_name(bundleID);
+ app.name = name.length ? name : WBPrettifyBundleID(bundleID);
+ byID[bundleID] = app;
+ [apps addObject:app];
+ [proxies addObject:[[WBAppProxy alloc] initWithInfo:app]];
+ }
+ [apps sortUsingComparator:^NSComparisonResult(WBAppInfo *a, WBAppInfo *b) {
+ return [a.name localizedStandardCompare:b.name];
+ }];
+ // Rebuild proxies in sorted order.
+ [proxies removeAllObjects];
+ for (WBAppInfo *app in apps) [proxies addObject:[[WBAppProxy alloc] initWithInfo:app]];
+
+ self.byBundleID = byID;
+ self.apps = apps;
+ self.proxies = proxies;
+ self.ready = YES;
+ if (progress) progress(1.0, [NSString stringWithFormat:WBLocalized(@"loaded_apps"), (unsigned long)apps.count]);
+ if (completion) completion(YES, nil);
+ });
+}
+
+@end
+
+#pragma mark - Progress UI
+
+@interface WBProgressViewController : UIViewController
+@property (nonatomic, strong) UILabel *statusLabel;
+@property (nonatomic, strong) UILabel *percentLabel;
+@property (nonatomic, strong) UIProgressView *progressView;
+- (void)updateFraction:(double)fraction status:(NSString *)status;
+@end
+
+@implementation WBProgressViewController
+
+- (void)viewDidLoad
+{
+ [super viewDidLoad];
+ self.view.backgroundColor = [UIColor colorWithRed:0.06 green:0.07 blue:0.09 alpha:1.0];
+ UIView *card = [UIView new];
+ card.translatesAutoresizingMaskIntoConstraints = NO;
+ card.backgroundColor = [UIColor colorWithRed:0.11 green:0.12 blue:0.15 alpha:1.0];
+ card.layer.cornerRadius = 16;
+ [self.view addSubview:card];
+
+ UILabel *title = [UILabel new];
+ title.translatesAutoresizingMaskIntoConstraints = NO;
+ title.text = @"Apps Manager";
+ title.font = [UIFont systemFontOfSize:22 weight:UIFontWeightBold];
+ title.textColor = UIColor.whiteColor;
+ [card addSubview:title];
+
+ UILabel *subtitle = [UILabel new];
+ subtitle.translatesAutoresizingMaskIntoConstraints = NO;
+ subtitle.text = WBLocalized(@"preparing");
+ subtitle.font = [UIFont systemFontOfSize:13 weight:UIFontWeightRegular];
+ subtitle.textColor = [UIColor colorWithWhite:0.65 alpha:1.0];
+ [card addSubview:subtitle];
+
+ self.progressView = [UIProgressView new];
+ self.progressView.translatesAutoresizingMaskIntoConstraints = NO;
+ self.progressView.progressTintColor = [UIColor colorWithRed:0.32 green:0.72 blue:1.0 alpha:1.0];
+ self.progressView.trackTintColor = [UIColor colorWithWhite:0.22 alpha:1.0];
+ [card addSubview:self.progressView];
+
+ self.statusLabel = [UILabel new];
+ self.statusLabel.translatesAutoresizingMaskIntoConstraints = NO;
+ self.statusLabel.font = [UIFont systemFontOfSize:12 weight:UIFontWeightRegular];
+ self.statusLabel.textColor = [UIColor colorWithWhite:0.75 alpha:1.0];
+ self.statusLabel.numberOfLines = 2;
+ self.statusLabel.text = WBLocalized(@"starting");
+ [card addSubview:self.statusLabel];
+
+ self.percentLabel = [UILabel new];
+ self.percentLabel.translatesAutoresizingMaskIntoConstraints = NO;
+ self.percentLabel.font = [UIFont monospacedDigitSystemFontOfSize:12 weight:UIFontWeightMedium];
+ self.percentLabel.textColor = [UIColor colorWithWhite:0.75 alpha:1.0];
+ self.percentLabel.textAlignment = NSTextAlignmentRight;
+ self.percentLabel.text = @"0%";
+ [card addSubview:self.percentLabel];
+
+ [NSLayoutConstraint activateConstraints:@[
+ [card.centerXAnchor constraintEqualToAnchor:self.view.centerXAnchor],
+ [card.centerYAnchor constraintEqualToAnchor:self.view.centerYAnchor],
+ [card.widthAnchor constraintEqualToConstant:300],
+ [card.heightAnchor constraintEqualToConstant:190],
+ [title.topAnchor constraintEqualToAnchor:card.topAnchor constant:24],
+ [title.leadingAnchor constraintEqualToAnchor:card.leadingAnchor constant:24],
+ [title.trailingAnchor constraintEqualToAnchor:card.trailingAnchor constant:-24],
+ [subtitle.topAnchor constraintEqualToAnchor:title.bottomAnchor constant:6],
+ [subtitle.leadingAnchor constraintEqualToAnchor:title.leadingAnchor],
+ [subtitle.trailingAnchor constraintEqualToAnchor:title.trailingAnchor],
+ [self.progressView.topAnchor constraintEqualToAnchor:subtitle.bottomAnchor constant:26],
+ [self.progressView.leadingAnchor constraintEqualToAnchor:title.leadingAnchor],
+ [self.progressView.trailingAnchor constraintEqualToAnchor:title.trailingAnchor],
+ [self.statusLabel.topAnchor constraintEqualToAnchor:self.progressView.bottomAnchor constant:14],
+ [self.statusLabel.leadingAnchor constraintEqualToAnchor:title.leadingAnchor],
+ [self.statusLabel.trailingAnchor constraintEqualToAnchor:self.percentLabel.leadingAnchor constant:-8],
+ [self.percentLabel.centerYAnchor constraintEqualToAnchor:self.statusLabel.centerYAnchor],
+ [self.percentLabel.trailingAnchor constraintEqualToAnchor:title.trailingAnchor],
+ [self.percentLabel.widthAnchor constraintEqualToConstant:44],
+ ]];
+}
+
+- (void)updateFraction:(double)fraction status:(NSString *)status
+{
+ dispatch_async(dispatch_get_main_queue(), ^{
+ double clamped = MAX(0.0, MIN(1.0, fraction));
+ [self.progressView setProgress:(float)clamped animated:YES];
+ self.percentLabel.text = [NSString stringWithFormat:@"%d%%", (int)round(clamped * 100)];
+ if (status.length) self.statusLabel.text = status;
+ });
+}
+
+@end
+
+#pragma mark - Hooks
+
+static NSArray *(*gOrigAllInstalled)(id, SEL);
+static NSArray *(*gOrigAllApplications)(id, SEL);
+static id (*gOrigProxyForIdentifier)(id, SEL, NSString *);
+static NSString *(*gOrigDataPath)(id, SEL);
+static BOOL (*gOrigLdidInstalled)(id, SEL);
+static void (*gOrigLoad)(id, SEL);
+static NSString *(*gOrigFileSizeString)(id, SEL);
+static void WBStartPreparation(void);
+static BOOL (*gOrigDidFinishLaunching)(id, SEL, UIApplication *, NSDictionary *);
+
+static BOOL WBHookDidFinishLaunching(id self, SEL _cmd, UIApplication *application, NSDictionary *launchOptions)
+{
+ BOOL result = gOrigDidFinishLaunching ? gOrigDidFinishLaunching(self, _cmd, application, launchOptions) : YES;
+ WBStartPreparation();
+ return result;
+}
+
+static __weak id gAppsTableViewController;
+static WBProgressViewController *gProgressController;
+static dispatch_semaphore_t gReadySemaphore;
+static BOOL gPrepared;
+static BOOL gPreparing;
+static BOOL gReloadScheduled;
+
+static NSArray *WBHookAllInstalled(id self, SEL _cmd)
+{
+ NSArray *proxies = WBRegistry.shared.proxies;
+ if (WBRegistry.shared.ready && proxies.count) return proxies;
+ return gOrigAllInstalled ? gOrigAllInstalled(self, _cmd) : @[];
+}
+
+static NSArray *WBHookAllApplications(id self, SEL _cmd)
+{
+ NSArray *proxies = WBRegistry.shared.proxies;
+ if (WBRegistry.shared.ready && proxies.count) return proxies;
+ return gOrigAllApplications ? gOrigAllApplications(self, _cmd) : @[];
+}
+
+static id WBHookProxyForIdentifier(id self, SEL _cmd, NSString *bundleID)
+{
+ WBAppInfo *info = [WBRegistry.shared appForBundleID:bundleID];
+ if (info && !info.isSystem) {
+ return [[WBAppProxy alloc] initWithInfo:info];
+ }
+ return gOrigProxyForIdentifier ? gOrigProxyForIdentifier(self, _cmd, bundleID) : nil;
+}
+
+static NSString *WBHookDataPath(id self, SEL _cmd)
+{
+ NSString *path = [NSHomeDirectory() stringByAppendingPathComponent:@"Documents/ADManager"];
+ [NSFileManager.defaultManager createDirectoryAtPath:path withIntermediateDirectories:YES attributes:nil error:nil];
+ return path;
+}
+
+static BOOL WBHookLdidInstalled(id self, SEL _cmd)
+{
+ // The original check only gates backups on a jailbreak ldid binary. The
+ // data copy itself does not need it; keychain helpers are not used here.
+ return YES;
+}
+
+static void WBReloadAppsTable(void)
+{
+ id vc = gAppsTableViewController;
+ if (!vc) return;
+ dispatch_async(dispatch_get_main_queue(), ^{
+ @try {
+ UITableView *table = [vc valueForKey:@"tableView"];
+ [table reloadData];
+ } @catch (__unused NSException *exception) {}
+ });
+}
+
+static void WBReloadOpenDetail(void)
+{
+ id appsVC = gAppsTableViewController;
+ if (!appsVC) return;
+ UINavigationController *nav = [(UIViewController *)appsVC navigationController];
+ UIViewController *top = nav.topViewController;
+ if (![top isKindOfClass:NSClassFromString(@"AppInfoTableViewController")]) return;
+ SEL sel = NSSelectorFromString(@"reload");
+ if ([top respondsToSelector:sel]) {
+ ((void (*)(id, SEL))objc_msgSend)(top, sel);
+ }
+}
+
+static BOOL gInDetailReload = NO;
+static void (*gOrigDetailReload)(id, SEL);
+static void WBHookDetailReload(id self, SEL _cmd)
+{
+ gInDetailReload = YES;
+ @try {
+ if (gOrigDetailReload) gOrigDetailReload(self, _cmd);
+ } @finally {
+ gInDetailReload = NO;
+ }
+}
+
+static void WBHookLoad(id self, SEL _cmd)
+{
+ gAppsTableViewController = self;
+ if (gReadySemaphore && !WBRegistry.shared.ready) {
+ dispatch_semaphore_wait(gReadySemaphore, dispatch_time(DISPATCH_TIME_NOW, 120 * NSEC_PER_SEC));
+ }
+ gOrigLoad(self, _cmd);
+}
+
+@protocol WBAppsTableRefresh
+- (void)reload;
+@end
+
+static UIRefreshControl *WBRefreshControlInTable(UITableView *table)
+{
+ if (table.refreshControl) return table.refreshControl;
+ for (UIView *view in table.subviews) {
+ if ([view isKindOfClass:UIRefreshControl.class]) return (UIRefreshControl *)view;
+ }
+ return nil;
+}
+
+static void WBHookHandleRefresh(id self, SEL _cmd, UIRefreshControl *sender)
+{
+ UITableViewController *controller = (UITableViewController *)self;
+ UITableView *table = controller.tableView;
+ UIRefreshControl *control = [sender isKindOfClass:UIRefreshControl.class]
+ ? sender : WBRefreshControlInTable(table);
+ // Start a full size scan and refresh the app list. The spinner stays until
+ // all new sizes are in; old values remain visible while the scan runs.
+ [WBRegistry.shared refreshAllSizesWithCompletion:^{
+ NSLog(@"[WB] full size refresh done");
+ WBReloadAppsTable();
+ [control endRefreshing];
+ }];
+ [(id)self reload];
+ [table layoutIfNeeded];
+}
+
+static NSString *WBHookFileSizeString(id self, SEL _cmd)
+{
+ if ([self isKindOfClass:NSClassFromString(@"ApplicationItem")]) {
+ if (gInDetailReload) return @"null";
+ NSString *bundleID = nil;
+ @try { bundleID = [self valueForKey:@"bundleId"]; } @catch (__unused NSException *exception) {}
+ WBAppInfo *info = [WBRegistry.shared appForBundleID:bundleID];
+ if (info && !info.isSystem) {
+ if (!info.sizeKnown) {
+ [WBRegistry.shared requestSizeForApp:info];
+ if (info.dataSize == 0) return @"";
+ }
+ return [NSByteCountFormatter stringFromByteCount:(long long)info.dataSize
+ countStyle:NSByteCountFormatterCountStyleFile];
+ }
+ }
+ return gOrigFileSizeString ? gOrigFileSizeString(self, _cmd) : @"";
+}
+
+static BOOL WBHookSignKCAccess(id self, SEL _cmd, id a, id b, id c) { return YES; }
+static void WBHookBackupKeychain(id self, SEL _cmd, NSString *path)
+{
+ [@[] writeToFile:path atomically:YES];
+}
+static void WBHookNoopVoid(id self, SEL _cmd) {}
+static void WBHookNoopVoid1(id self, SEL _cmd, id a) {}
+static void WBHookNoopVoid3(id self, SEL _cmd, id a, id b, id c) {}
+
+static id (*gOrigExceptionWithName)(id, SEL, NSString *, NSString *, NSDictionary *);
+static id (*gOrigExceptionInit)(id, SEL, NSString *, NSString *, NSDictionary *);
+static id WBHookExceptionWithName(id self, SEL _cmd, NSString *name, NSString *reason, NSDictionary *userInfo)
+{
+ NSLog(@"[WB] NSException named %@: %@", name, reason);
+ if ([reason containsString:@"insertObject"] || [reason containsString:@"nil object"]) {
+ NSLog(@"[WB] exception stack: %@", [NSThread callStackSymbols]);
+ }
+ return gOrigExceptionWithName ? gOrigExceptionWithName(self, _cmd, name, reason, userInfo) : nil;
+}
+static id WBHookExceptionInit(id self, SEL _cmd, NSString *name, NSString *reason, NSDictionary *userInfo)
+{
+ NSLog(@"[WB] NSException init %@: %@", name, reason);
+ return gOrigExceptionInit ? gOrigExceptionInit(self, _cmd, name, reason, userInfo) : nil;
+}
+static void (*gOrigRaiseFormat)(id, SEL, NSString *, ...);
+static void WBHookRaiseFormat(id self, SEL _cmd, NSString *format, ...)
+{
+ va_list ap;
+ va_start(ap, format);
+ NSString *reason = [[NSString alloc] initWithFormat:format arguments:ap];
+ va_end(ap);
+ NSLog(@"[WB] NSException raise: %@", reason);
+ if (gOrigRaiseFormat) {
+ va_list ap2;
+ va_start(ap2, format);
+ // Re-invoke the original with the same format and args.
+ typedef void (*RaiseFn)(id, SEL, NSString *, ...);
+ ((RaiseFn)gOrigRaiseFormat)(self, _cmd, @"%@", reason);
+ va_end(ap2);
+ }
+}
+
+static void (*gOrigCopyContainer)(id, SEL, id, id, id, id);
+static void WBHookCopyContainer(id self, SEL _cmd, id container, id bundleId, id toPath, id bundleApp)
+{
+ @try {
+ gOrigCopyContainer(self, _cmd, container, bundleId, toPath, bundleApp);
+ } @catch (NSException *exception) {
+ NSLog(@"[WB] copyDataContainer exception %@: %@ (container=%@ bundle=%@ to=%@ app=%@)",
+ exception.name, exception.reason, container, bundleId, toPath, bundleApp);
+ @throw;
+ }
+}
+
+static NSString *(*gOrigExceptionName)(id, SEL);
+static NSString *(*gOrigExceptionReason)(id, SEL);
+static NSString *WBHookExceptionName(id self, SEL _cmd)
+{
+ NSString *value = gOrigExceptionName ? gOrigExceptionName(self, _cmd) : nil;
+ NSString *reason = gOrigExceptionReason ? gOrigExceptionReason(self, @selector(reason)) : nil;
+ NSLog(@"[WB] exception name queried: %@ reason=%@", value, reason);
+ if ([reason containsString:@"insertObject"] || [reason containsString:@"nil object"]) {
+ NSLog(@"[WB] exception stack: %@", [NSThread callStackSymbols]);
+ }
+ return value;
+}
+static NSString *WBHookExceptionReason(id self, SEL _cmd)
+{
+ NSString *value = gOrigExceptionReason ? gOrigExceptionReason(self, _cmd) : nil;
+ NSLog(@"[WB] exception reason queried: %@", value);
+ return value;
+}
+
+static void (*gOrigSetAppProxy)(id, SEL, id);
+static void WBHookSetAppProxy(id self, SEL _cmd, id proxy)
+{
+ gOrigSetAppProxy(self, _cmd, proxy);
+ NSString *bundleID = nil;
+ @try { bundleID = [self valueForKey:@"bundleId"]; } @catch (__unused NSException *exception) {}
+ WBAppInfo *info = [WBRegistry.shared appForBundleID:bundleID];
+ if (info) {
+ @try {
+ [self setValue:info.containerPath forKey:@"documentPath"];
+ // setAppProxy: only fills these from lstat, which fails for other
+ // apps' bundle containers. Set them directly so the detail screen
+ // can still show the bundle path and modification date.
+ Ivar pathIvar = class_getInstanceVariable(NSClassFromString(@"FileItem"), "_filePath");
+ if (pathIvar) object_setIvar(self, pathIvar, info.bundlePath);
+ NSDictionary *attrs = [NSFileManager.defaultManager attributesOfItemAtPath:info.containerPath error:nil];
+ NSDate *date = attrs[NSFileModificationDate];
+ if (!date) {
+ struct stat st;
+ if (wq_stat(info.containerPath, &st)) date = [NSDate dateWithTimeIntervalSince1970:st.st_mtimespec.tv_sec];
+ }
+ if (date) {
+ Ivar dateIvar = class_getInstanceVariable(NSClassFromString(@"FileItem"), "_fileDate");
+ if (dateIvar) object_setIvar(self, dateIvar, date);
+ }
+ } @catch (__unused NSException *exception) {}
+ }
+}
+
+static NSString *(*gOrigByteCountString)(id, SEL, long long, NSInteger);
+static NSString *WBHookByteCountString(id self, SEL _cmd, long long byteCount, NSInteger style)
+{
+ if (byteCount <= 0) return @"";
+ return gOrigByteCountString ? gOrigByteCountString(self, _cmd, byteCount, style) : @"";
+}
+
+static void (*gOrigBackupApp)(id, SEL, id, id, void (^)(float), void (^)(BOOL, NSString *));
+static void WBHookBackupApp(id self, SEL _cmd, id app, id icon, void (^progress)(float), void (^completion)(BOOL, NSString *))
+{
+ if (!icon) {
+ icon = [UIImage imageWithContentsOfFile:[NSBundle.mainBundle pathForResource:@"noicon" ofType:@"png" inDirectory:@"images"]];
+ if (!icon) {
+ UIGraphicsImageRenderer *renderer = [[UIGraphicsImageRenderer alloc] initWithSize:CGSizeMake(1, 1)];
+ icon = [renderer imageWithActions:^(UIGraphicsImageRendererContext *ctx) {
+ [UIColor.whiteColor setFill];
+ [ctx fillRect:CGRectMake(0, 0, 1, 1)];
+ }];
+ }
+ }
+ NSString *bundleID = nil;
+ @try { bundleID = [app valueForKey:@"bundleId"]; } @catch (__unused NSException *exception) {}
+ WBAppInfo *info = [WBRegistry.shared appForBundleID:bundleID];
+ if (info) {
+ wq_grant(info.containerPath);
+ wq_grant([info.containerPath stringByAppendingPathComponent:@"Documents"]);
+ wq_grant([info.containerPath stringByAppendingPathComponent:@"Library"]);
+ for (NSURL *url in [WBRegistry.shared groupContainersForApp:info].allValues) {
+ if (url.path.length) wq_grant(url.path);
+ }
+ }
+ void (^wrapped)(BOOL, NSString *) = ^(BOOL ok, NSString *message) {
+ [WBRegistry.shared refreshSizeForBundleID:bundleID];
+ if (completion) completion(ok, message);
+ };
+ if (gOrigBackupApp) gOrigBackupApp(self, _cmd, app, icon, progress, wrapped);
+}
+
+static void (*gOrigAddObject)(id, SEL, id);
+static void (*gOrigInsertObject)(id, SEL, id, NSUInteger);
+static void WBHookAddObject(id self, SEL _cmd, id object)
+{
+ if (!object) {
+ NSLog(@"[WB] skipped nil addObject on %@", NSStringFromClass([self class]));
+ return;
+ }
+ if (gOrigAddObject) gOrigAddObject(self, _cmd, object);
+}
+static void WBHookInsertObject(id self, SEL _cmd, id object, NSUInteger index)
+{
+ if (!object) {
+ NSLog(@"[WB] skipped nil insertObject on %@", NSStringFromClass([self class]));
+ return;
+ }
+ if (gOrigInsertObject) gOrigInsertObject(self, _cmd, object, index);
+}
+
+static void (*gOrigRestoreApp)(id, SEL, id, id, void (^)(float), void (^)(BOOL, NSString *));
+static void WBHookRestoreApp(id self, SEL _cmd, id app, id backupInfo, void (^progress)(float), void (^completion)(BOOL, NSString *))
+{
+ NSString *bundleID = nil;
+ @try { bundleID = [app valueForKey:@"bundleId"]; } @catch (__unused NSException *exception) {}
+ WBAppInfo *info = [WBRegistry.shared appForBundleID:bundleID];
+ if (info) {
+ wq_grant(info.containerPath);
+ wq_grant([info.containerPath stringByAppendingPathComponent:@"Documents"]);
+ wq_grant([info.containerPath stringByAppendingPathComponent:@"Library"]);
+ for (NSURL *url in [WBRegistry.shared groupContainersForApp:info].allValues) {
+ if (url.path.length) wq_grant(url.path);
+ }
+ }
+ void (^finish)(BOOL, NSString *) = ^(BOOL ok, NSString *message) {
+ [WBRegistry.shared refreshSizeForBundleID:bundleID];
+ if (completion) completion(ok, message);
+ };
+ void (^wrapped)(BOOL, NSString *) = ^(BOOL ok, NSString *message) {
+ if (ok || !info || ![backupInfo isKindOfClass:NSDictionary.class]) {
+ finish(ok, message);
+ return;
+ }
+ // The original keychain restore inserts nil keychain entries after
+ // an empty keychain backup. Fall back to restoring Documents and
+ // Library from the already extracted backup folder.
+ NSString *name = backupInfo[@"name"];
+ if (!name.length) {
+ finish(NO, message ?: @"还原失败");
+ return;
+ }
+ NSString *srcApp = [[NSHomeDirectory() stringByAppendingPathComponent:@"Documents/ADManager"]
+ stringByAppendingPathComponent:name];
+ srcApp = [srcApp stringByAppendingPathComponent:bundleID];
+ NSFileManager *fm = NSFileManager.defaultManager;
+ if (![fm fileExistsAtPath:srcApp]) {
+ finish(NO, message ?: @"还原失败:未找到解压后的备份");
+ return;
+ }
+ wq_grant(info.containerPath);
+ NSError *error = nil;
+ BOOL success = YES;
+ for (NSString *part in @[@"Documents", @"Library"]) {
+ NSString *src = [srcApp stringByAppendingPathComponent:part];
+ NSString *dst = [info.containerPath stringByAppendingPathComponent:part];
+ if (![fm fileExistsAtPath:src]) continue;
+ [fm removeItemAtPath:dst error:nil];
+ if (![fm copyItemAtPath:src toPath:dst error:&error]) {
+ success = NO;
+ break;
+ }
+ }
+ if (progress) progress(1.0f);
+ finish(success, success ? nil : (error.localizedDescription ?: @"还原失败"));
+ };
+ if (gOrigRestoreApp) gOrigRestoreApp(self, _cmd, app, backupInfo, progress, wrapped);
+}
+
+static BOOL WBWipeDirectoryContents(NSString *path, unsigned long long *freed)
+{
+ NSArray *names = wq_list(path, NULL);
+ if (!names) return NO;
+ BOOL allOk = YES;
+ for (NSString *name in names) {
+ if ([name isEqualToString:@".com.apple.mobile_container_manager.metadata.plist"]) continue;
+ if ([name isEqualToString:@"StoreKit"]) continue; // keep receipts
+ NSString *child = [path stringByAppendingPathComponent:name];
+ struct stat st;
+ if (!wq_stat(child, &st)) { allOk = NO; continue; }
+ if (S_ISDIR(st.st_mode) && !wq_purge_contents(child, freed)) allOk = NO;
+ }
+ return allOk;
+}
+
+static void (*gOrigWipeDataContainer)(id, SEL, NSString *, NSString *, NSString *);
+static void WBHookWipeDataContainer(id self, SEL _cmd, NSString *path, NSString *bundleID, NSString *bundleApp)
+{
+ if (path.length) {
+ // The original implementation deletes with NSFileManager and only
+ // falls back to the missing /usr/bin/ADMHelper. Grant the container
+ // and use worse_query-backed deletion so other apps' data is removed.
+ wq_grant(path);
+ unsigned long long freed = 0;
+ BOOL purged = WBWipeDirectoryContents(path, &freed);
+ NSLog(@"[WB] wipe %@: purge=%d freed=%llu", path, purged, freed);
+ }
+ // Let the original clear cached preferences and handle any remaining
+ // root-level files.
+ if (gOrigWipeDataContainer) gOrigWipeDataContainer(self, _cmd, path, bundleID, bundleApp);
+}
+
+static void (*gOrigWipeApp)(id, SEL, id, void (^)(float), void (^)(BOOL, NSString *));
+static void WBHookWipeApp(id self, SEL _cmd, id app, void (^progress)(float), void (^completion)(BOOL, NSString *))
+{
+ NSString *bundleID = nil;
+ @try { bundleID = [app valueForKey:@"bundleId"]; } @catch (__unused NSException *exception) {}
+ void (^wrapped)(BOOL, NSString *) = ^(BOOL ok, NSString *message) {
+ [WBRegistry.shared refreshSizeForBundleID:bundleID];
+ if (completion) completion(ok, message);
+ };
+ if (gOrigWipeApp) gOrigWipeApp(self, _cmd, app, progress, wrapped);
+}
+
+extern void WBSetupWQFileUI(void);
+
+static __weak UIViewController *gFileBrowserController;
+
+@interface WBFileBrowserCloser : NSObject
+@end
+@implementation WBFileBrowserCloser
++ (void)close:(id)sender
+{
+ [gFileBrowserController dismissViewControllerAnimated:YES completion:nil];
+}
+@end
+
+static UIViewController *WBTopViewController(void)
+{
+ UIWindow *window = UIApplication.sharedApplication.keyWindow;
+ if (!window) {
+ for (UIScene *scene in UIApplication.sharedApplication.connectedScenes) {
+ if (![scene isKindOfClass:UIWindowScene.class]) continue;
+ for (UIWindow *candidate in ((UIWindowScene *)scene).windows) {
+ if (candidate.isKeyWindow) { window = candidate; break; }
+ }
+ }
+ }
+ UIViewController *top = window.rootViewController;
+ while (top.presentedViewController) top = top.presentedViewController;
+ return top;
+}
+
+static void WBPresentFileBrowser(NSString *path)
+{
+ if (!path.length) return;
+ dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 1 * NSEC_PER_SEC), dispatch_get_main_queue(), ^{
+ WBSetupWQFileUI();
+ Class dirClass = NSClassFromString(@"WQDirViewController");
+ id dirObject = [dirClass alloc];
+ SEL initSelector = NSSelectorFromString(@"initWithPath:title:");
+ typedef id (*InitFn)(id, SEL, NSString *, NSString *);
+ dirObject = ((InitFn)[dirObject methodForSelector:initSelector])(dirObject, initSelector, path, nil);
+ UIViewController *dir = (UIViewController *)dirObject;
+ Class navClass = NSClassFromString(@"WQNavigationController");
+ UINavigationController *nav = navClass ? [[navClass alloc] initWithRootViewController:dir]
+ : [[UINavigationController alloc] initWithRootViewController:dir];
+ [dir loadViewIfNeeded];
+ dir.view.backgroundColor = UIColor.systemBackgroundColor;
+ nav.view.backgroundColor = UIColor.systemBackgroundColor;
+ nav.navigationBar.translucent = NO;
+ UIBarButtonItem *done = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemDone
+ target:WBFileBrowserCloser.class
+ action:@selector(close:)];
+ dir.navigationItem.leftBarButtonItem = done;
+ nav.modalPresentationStyle = UIModalPresentationFullScreen;
+ gFileBrowserController = nav;
+ [WBTopViewController() presentViewController:nav animated:YES completion:^{
+ dir.navigationItem.leftBarButtonItem = done;
+ }];
+ });
+}
+
+#pragma mark - WQ file viewer hooks
+
+static BOOL (*gOrigCanOpenURL)(id, SEL, NSURL *);
+static void (*gOrigOpenURL)(id, SEL, NSURL *);
+static void (*gOrigOpenURLOptions)(id, SEL, NSURL *, NSDictionary *, void (^)(BOOL));
+
+static BOOL WBIsFilzaURL(NSURL *url)
+{
+ return [url.scheme.lowercaseString isEqualToString:@"filza"];
+}
+
+static NSString *WBPathFromFilzaURL(NSURL *url)
+{
+ NSString *string = url.absoluteString;
+ NSString *prefix = @"filza://view";
+ if ([string hasPrefix:prefix]) string = [string substringFromIndex:prefix.length];
+ return [string stringByRemovingPercentEncoding];
+}
+
+static BOOL WBHookCanOpenURL(id self, SEL _cmd, NSURL *url)
+{
+ if (WBIsFilzaURL(url)) return YES;
+ return gOrigCanOpenURL ? gOrigCanOpenURL(self, _cmd, url) : NO;
+}
+
+static void WBHookOpenURL(id self, SEL _cmd, NSURL *url)
+{
+ if (WBIsFilzaURL(url)) {
+ WBPresentFileBrowser(WBPathFromFilzaURL(url));
+ return;
+ }
+ if (gOrigOpenURL) gOrigOpenURL(self, _cmd, url);
+}
+
+static void WBHookOpenURLOptions(id self, SEL _cmd, NSURL *url, NSDictionary *options, void (^completion)(BOOL))
+{
+ if (WBIsFilzaURL(url)) {
+ WBPresentFileBrowser(WBPathFromFilzaURL(url));
+ if (completion) completion(YES);
+ return;
+ }
+ if (gOrigOpenURLOptions) gOrigOpenURLOptions(self, _cmd, url, options, completion);
+}
+
+static NSInteger (*gOrigWQNumberOfRows)(id, SEL, UITableView *, NSInteger);
+static NSInteger WBHookWQNumberOfRows(id self, SEL _cmd, UITableView *tableView, NSInteger section)
+{
+ NSInteger count = gOrigWQNumberOfRows ? gOrigWQNumberOfRows(self, _cmd, tableView, section) : 0;
+ if (count < 0 || count > 100000) {
+ NSLog(@"[WB] WQDirViewController rows=%ld section=%ld clamped", (long)count, (long)section);
+ return 0;
+ }
+ return count;
+}
+
+static void WBInstallHooks(void)
+{
+ Method exceptionMethod = class_getClassMethod(NSException.class, NSSelectorFromString(@"exceptionWithName:reason:userInfo:"));
+ if (exceptionMethod) {
+ gOrigExceptionWithName = (void *)method_getImplementation(exceptionMethod);
+ method_setImplementation(exceptionMethod, (IMP)WBHookExceptionWithName);
+ }
+ Method exceptionInit = class_getInstanceMethod(NSException.class, NSSelectorFromString(@"initWithName:reason:userInfo:"));
+ if (exceptionInit) {
+ gOrigExceptionInit = (void *)method_getImplementation(exceptionInit);
+ method_setImplementation(exceptionInit, (IMP)WBHookExceptionInit);
+ }
+ Method raiseFormat = class_getClassMethod(NSException.class, NSSelectorFromString(@"raise:format:"));
+ if (raiseFormat) {
+ gOrigRaiseFormat = (void *)method_getImplementation(raiseFormat);
+ method_setImplementation(raiseFormat, (IMP)WBHookRaiseFormat);
+ }
+ Method excName = class_getInstanceMethod(NSException.class, NSSelectorFromString(@"name"));
+ if (excName) {
+ gOrigExceptionName = (void *)method_getImplementation(excName);
+ method_setImplementation(excName, (IMP)WBHookExceptionName);
+ }
+ Method excReason = class_getInstanceMethod(NSException.class, NSSelectorFromString(@"reason"));
+ if (excReason) {
+ gOrigExceptionReason = (void *)method_getImplementation(excReason);
+ method_setImplementation(excReason, (IMP)WBHookExceptionReason);
+ }
+ Method byteCount = class_getInstanceMethod(NSByteCountFormatter.class, NSSelectorFromString(@"stringFromByteCount:countStyle:"));
+ if (byteCount) {
+ gOrigByteCountString = (void *)method_getImplementation(byteCount);
+ method_setImplementation(byteCount, (IMP)WBHookByteCountString);
+ }
+ Method byteCountClass = class_getClassMethod(NSByteCountFormatter.class, NSSelectorFromString(@"stringFromByteCount:countStyle:"));
+ if (byteCountClass) {
+ gOrigByteCountString = (void *)method_getImplementation(byteCountClass);
+ method_setImplementation(byteCountClass, (IMP)WBHookByteCountString);
+ }
+ Class appClass = UIApplication.class;
+ Method canOpen = class_getInstanceMethod(appClass, NSSelectorFromString(@"canOpenURL:"));
+ if (canOpen) {
+ gOrigCanOpenURL = (void *)method_getImplementation(canOpen);
+ method_setImplementation(canOpen, (IMP)WBHookCanOpenURL);
+ }
+ Method openURL = class_getInstanceMethod(appClass, NSSelectorFromString(@"openURL:"));
+ if (openURL) {
+ gOrigOpenURL = (void *)method_getImplementation(openURL);
+ method_setImplementation(openURL, (IMP)WBHookOpenURL);
+ }
+ Method openURLOptions = class_getInstanceMethod(appClass, NSSelectorFromString(@"openURL:options:completionHandler:"));
+ if (openURLOptions) {
+ gOrigOpenURLOptions = (void *)method_getImplementation(openURLOptions);
+ method_setImplementation(openURLOptions, (IMP)WBHookOpenURLOptions);
+ }
+ Class wqDir = NSClassFromString(@"WQDirViewController");
+ Method wqRows = class_getInstanceMethod(wqDir, NSSelectorFromString(@"tableView:numberOfRowsInSection:"));
+ if (wqRows) {
+ gOrigWQNumberOfRows = (void *)method_getImplementation(wqRows);
+ method_setImplementation(wqRows, (IMP)WBHookWQNumberOfRows);
+ }
+ Class workspace = NSClassFromString(@"LSApplicationWorkspace");
+ Method allInstalled = class_getInstanceMethod(workspace, NSSelectorFromString(@"allInstalledApplications"));
+ if (allInstalled) {
+ gOrigAllInstalled = (void *)method_getImplementation(allInstalled);
+ method_setImplementation(allInstalled, (IMP)WBHookAllInstalled);
+ }
+ Method allApplications = class_getInstanceMethod(workspace, NSSelectorFromString(@"allApplications"));
+ if (allApplications) {
+ gOrigAllApplications = (void *)method_getImplementation(allApplications);
+ method_setImplementation(allApplications, (IMP)WBHookAllApplications);
+ }
+ Class proxy = NSClassFromString(@"LSApplicationProxy");
+ Method proxyForID = class_getClassMethod(proxy, NSSelectorFromString(@"applicationProxyForIdentifier:"));
+ if (proxyForID) {
+ gOrigProxyForIdentifier = (void *)method_getImplementation(proxyForID);
+ method_setImplementation(proxyForID, (IMP)WBHookProxyForIdentifier);
+ }
+
+ Class backupList = NSClassFromString(@"BackupList");
+ Method backupApp = class_getInstanceMethod(backupList, NSSelectorFromString(@"backupApp:iconImage:progress:withCompletion:"));
+ if (backupApp) {
+ gOrigBackupApp = (void *)method_getImplementation(backupApp);
+ method_setImplementation(backupApp, (IMP)WBHookBackupApp);
+ }
+ Method restoreApp = class_getInstanceMethod(backupList, NSSelectorFromString(@"restoreApp:fromPathBackup:progress:withCompletion:"));
+ if (restoreApp) {
+ gOrigRestoreApp = (void *)method_getImplementation(restoreApp);
+ method_setImplementation(restoreApp, (IMP)WBHookRestoreApp);
+ }
+ Method copyMethod = class_getInstanceMethod(backupList, NSSelectorFromString(@"copyDataContainerPath:bundleId:toPath:bundleApp:"));
+ if (copyMethod) {
+ gOrigCopyContainer = (void *)method_getImplementation(copyMethod);
+ method_setImplementation(copyMethod, (IMP)WBHookCopyContainer);
+ }
+ Method dataPath = class_getClassMethod(backupList, NSSelectorFromString(@"dataPath"));
+ if (dataPath) {
+ gOrigDataPath = (void *)method_getImplementation(dataPath);
+ method_setImplementation(dataPath, (IMP)WBHookDataPath);
+ }
+ Method ldid = class_getClassMethod(backupList, NSSelectorFromString(@"IsldidInstalled"));
+ if (ldid) {
+ gOrigLdidInstalled = (void *)method_getImplementation(ldid);
+ method_setImplementation(ldid, (IMP)WBHookLdidInstalled);
+ }
+ struct { const char *name; IMP imp; } backupHooks[] = {
+ { "signKCAccessWithAccessGroups:applicationGroups:appId:", (IMP)WBHookSignKCAccess },
+ { "operationKCAccessBackupKeychain:", (IMP)WBHookBackupKeychain },
+ { "operationKCAccessWipeKeychain", (IMP)WBHookNoopVoid },
+ { "operationKCAccessRestoreKeychain:oldAppId:newAppId:teamId:applicationCroups:", (IMP)WBHookNoopVoid3 },
+ { "backupPasteboardWithTeamID:backupPath:container:", (IMP)WBHookNoopVoid3 },
+ { "wipePasteboardWithTeamID:", (IMP)WBHookNoopVoid1 },
+ { "restorePasteboardWithTeamID:pasteboards:backupPath:", (IMP)WBHookNoopVoid3 },
+ };
+ for (size_t i = 0; i < sizeof(backupHooks)/sizeof(backupHooks[0]); i++) {
+ Method m = class_getInstanceMethod(backupList, NSSelectorFromString([NSString stringWithUTF8String:backupHooks[i].name]));
+ if (m) method_setImplementation(m, backupHooks[i].imp);
+ }
+ Method wipeData = class_getInstanceMethod(backupList, NSSelectorFromString(@"wipeDataContainerPath:bundleId:bundleApp:"));
+ if (wipeData) {
+ gOrigWipeDataContainer = (void *)method_getImplementation(wipeData);
+ method_setImplementation(wipeData, (IMP)WBHookWipeDataContainer);
+ }
+ Method wipeApp = class_getInstanceMethod(backupList, NSSelectorFromString(@"wipeApp:progress:withCompletion:"));
+ if (wipeApp) {
+ gOrigWipeApp = (void *)method_getImplementation(wipeApp);
+ method_setImplementation(wipeApp, (IMP)WBHookWipeApp);
+ }
+
+ Class appsVC = NSClassFromString(@"AppsTableViewController");
+ Method load = class_getInstanceMethod(appsVC, NSSelectorFromString(@"load"));
+ if (load) {
+ gOrigLoad = (void *)method_getImplementation(load);
+ method_setImplementation(load, (IMP)WBHookLoad);
+ }
+ Method handleRefresh = class_getInstanceMethod(appsVC, NSSelectorFromString(@"handleRefresh:"));
+ if (handleRefresh) {
+ method_setImplementation(handleRefresh, (IMP)WBHookHandleRefresh);
+ }
+ Class fileItem = NSClassFromString(@"FileItem");
+ (void)fileItem;
+ Class appItem = NSClassFromString(@"ApplicationItem");
+ Method appSizeString = class_getInstanceMethod(appItem, NSSelectorFromString(@"fileSizeString"));
+ if (appSizeString) {
+ gOrigFileSizeString = (void *)method_getImplementation(appSizeString);
+ method_setImplementation(appSizeString, (IMP)WBHookFileSizeString);
+ }
+ Method setAppProxy = class_getInstanceMethod(appItem, NSSelectorFromString(@"setAppProxy:"));
+ if (setAppProxy) {
+ gOrigSetAppProxy = (void *)method_getImplementation(setAppProxy);
+ method_setImplementation(setAppProxy, (IMP)WBHookSetAppProxy);
+ }
+ Class appInfoVC = NSClassFromString(@"AppInfoTableViewController");
+ Method detailReload = class_getInstanceMethod(appInfoVC, NSSelectorFromString(@"reload"));
+ if (detailReload) {
+ gOrigDetailReload = (void *)method_getImplementation(detailReload);
+ method_setImplementation(detailReload, (IMP)WBHookDetailReload);
+ }
+
+ Class appDelegate = NSClassFromString(@"AppDelegate");
+ Method didFinish = class_getInstanceMethod(appDelegate, NSSelectorFromString(@"application:didFinishLaunchingWithOptions:"));
+ if (didFinish) {
+ gOrigDidFinishLaunching = (void *)method_getImplementation(didFinish);
+ method_setImplementation(didFinish, (IMP)WBHookDidFinishLaunching);
+ }
+
+ gReadySemaphore = dispatch_semaphore_create(0);
+ [[NSNotificationCenter defaultCenter] addObserverForName:@"WBAppSizeDidUpdate" object:nil queue:NSOperationQueue.mainQueue usingBlock:^(NSNotification *note) {
+ if (gReloadScheduled) return;
+ gReloadScheduled = YES;
+ dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 250 * NSEC_PER_MSEC), dispatch_get_main_queue(), ^{
+ gReloadScheduled = NO;
+ WBReloadAppsTable();
+ WBReloadOpenDetail();
+ });
+ }];
+}
+
+static void WBRunDebugBackup(void)
+{
+ NSArray *args = NSProcessInfo.processInfo.arguments;
+ if (![args containsObject:@"--wb-autobackup"]) return;
+ NSString *bundleID = @"com.jbjf.ave27";
+ for (NSString *arg in args) {
+ if ([arg hasPrefix:@"--wb-bundle="]) bundleID = [arg substringFromIndex:12];
+ }
+ WBAppInfo *info = [WBRegistry.shared appForBundleID:bundleID];
+ if (!info) { NSLog(@"[WB] autobackup: no app %@", bundleID); return; }
+ NSLog(@"[WB] autobackup: %@ container=%@", bundleID, info.containerPath);
+ wq_grant(info.containerPath);
+ Class itemClass = NSClassFromString(@"ApplicationItem");
+ id item = [[itemClass alloc] init];
+ [item setValue:bundleID forKey:@"bundleId"];
+ [item setValue:[[WBAppProxy alloc] initWithInfo:info] forKey:@"appProxy"];
+ [item setValue:info.containerPath forKey:@"documentPath"];
+ Class backupClass = NSClassFromString(@"BackupList");
+ id backup = [backupClass performSelector:@selector(sharedInstance)];
+ SEL sel = NSSelectorFromString(@"backupApp:iconImage:progress:withCompletion:");
+ typedef void (*BackupFn)(id, SEL, id, id, void (^)(float), void (^)(BOOL, NSString *));
+ BackupFn fn = (BackupFn)[backup methodForSelector:sel];
+ if (!fn) { NSLog(@"[WB] autobackup: no method"); return; }
+ UIImage *icon = [UIImage imageWithContentsOfFile:[NSBundle.mainBundle pathForResource:@"noicon" ofType:@"png" inDirectory:@"images"]];
+ if (!icon) {
+ UIGraphicsImageRenderer *renderer = [[UIGraphicsImageRenderer alloc] initWithSize:CGSizeMake(1, 1)];
+ icon = [renderer imageWithActions:^(UIGraphicsImageRendererContext *ctx) {
+ [UIColor.whiteColor setFill];
+ [ctx fillRect:CGRectMake(0, 0, 1, 1)];
+ }];
+ }
+ fn(backup, sel, item, nil, ^(float progress) {
+ NSLog(@"[WB] autobackup progress %.2f", progress);
+ }, ^(BOOL ok, NSString *message) {
+ NSLog(@"[WB] autobackup done ok=%d message=%@", ok, message);
+ NSArray *files = [NSFileManager.defaultManager contentsOfDirectoryAtPath:
+ [NSHomeDirectory() stringByAppendingPathComponent:@"Documents/ADManager"] error:nil];
+ NSLog(@"[WB] autobackup files: %@", files);
+ });
+}
+
+static void WBRunDebugRestore(void)
+{
+ NSArray *args = NSProcessInfo.processInfo.arguments;
+ if (![args containsObject:@"--wb-autorestore"]) return;
+ NSString *bundleID = @"com.jbjf.ave27";
+ for (NSString *arg in args) {
+ if ([arg hasPrefix:@"--wb-bundle="]) bundleID = [arg substringFromIndex:12];
+ }
+ WBAppInfo *info = [WBRegistry.shared appForBundleID:bundleID];
+ if (!info) return;
+ NSString *dir = [NSHomeDirectory() stringByAppendingPathComponent:@"Documents/ADManager"];
+ NSArray *files = [NSFileManager.defaultManager contentsOfDirectoryAtPath:dir error:nil];
+ NSString *backup = nil;
+ for (NSString *name in files) {
+ if ([name hasPrefix:[bundleID stringByAppendingString:@"_"]] && [name.pathExtension isEqualToString:@"adbk"]) backup = [dir stringByAppendingPathComponent:name];
+ }
+ if (!backup) { NSLog(@"[WB] autorestore: no backup"); return; }
+ NSLog(@"[WB] autorestore: %@ from %@", bundleID, backup.lastPathComponent);
+ wq_grant(info.containerPath);
+ wq_grant([info.containerPath stringByAppendingPathComponent:@"Documents"]);
+ wq_grant([info.containerPath stringByAppendingPathComponent:@"Library"]);
+ Class itemClass = NSClassFromString(@"ApplicationItem");
+ id item = [[itemClass alloc] init];
+ [item setValue:bundleID forKey:@"bundleId"];
+ [item setValue:[[WBAppProxy alloc] initWithInfo:info] forKey:@"appProxy"];
+ [item setValue:info.containerPath forKey:@"documentPath"];
+ Class backupClass = NSClassFromString(@"BackupList");
+ id backupList = [backupClass performSelector:@selector(sharedInstance)];
+ NSArray *backups = [backupList performSelector:@selector(backupsForApp:) withObject:bundleID];
+ NSLog(@"[WB] backupsForApp: %@", backups);
+ id backupInfo = backups.lastObject;
+ if (!backupInfo) { NSLog(@"[WB] autorestore: no backup info"); return; }
+ SEL sel = NSSelectorFromString(@"restoreApp:fromPathBackup:progress:withCompletion:");
+ typedef void (*RestoreFn)(id, SEL, id, id, void (^)(float), void (^)(BOOL, NSString *));
+ RestoreFn fn = (RestoreFn)[backupList methodForSelector:sel];
+ if (!fn) { NSLog(@"[WB] autorestore: no method"); return; }
+ fn(backupList, sel, item, backupInfo, ^(float progress) {
+ NSLog(@"[WB] autorestore progress %.2f", progress);
+ }, ^(BOOL ok, NSString *message) {
+ NSLog(@"[WB] autorestore done ok=%d message=%@", ok, message);
+ });
+}
+
+static void WBRunDebugWipe(void)
+{
+ NSArray *args = NSProcessInfo.processInfo.arguments;
+ if (![args containsObject:@"--wb-autowipe"]) return;
+ NSString *bundleID = @"com.jbjf.ave27";
+ for (NSString *arg in args) {
+ if ([arg hasPrefix:@"--wb-bundle="]) bundleID = [arg substringFromIndex:12];
+ }
+ WBAppInfo *info = [WBRegistry.shared appForBundleID:bundleID];
+ if (!info) { NSLog(@"[WB] autowipe: no app %@", bundleID); return; }
+ NSLog(@"[WB] autowipe: %@ at %@", bundleID, info.containerPath);
+ Class itemClass = NSClassFromString(@"ApplicationItem");
+ id item = [[itemClass alloc] init];
+ [item setValue:bundleID forKey:@"bundleId"];
+ [item setValue:[[WBAppProxy alloc] initWithInfo:info] forKey:@"appProxy"];
+ [item setValue:info.containerPath forKey:@"documentPath"];
+ Class backupClass = NSClassFromString(@"BackupList");
+ id backupList = [backupClass performSelector:@selector(sharedInstance)];
+ SEL sel = NSSelectorFromString(@"wipeApp:progress:withCompletion:");
+ typedef void (*WipeFn)(id, SEL, id, void (^)(float), void (^)(BOOL, NSString *));
+ WipeFn fn = (WipeFn)[backupList methodForSelector:sel];
+ if (!fn) { NSLog(@"[WB] autowipe: no method"); return; }
+ fn(backupList, sel, item, ^(float progress) {
+ NSLog(@"[WB] autowipe progress %.2f", progress);
+ }, ^(BOOL ok, NSString *message) {
+ NSLog(@"[WB] autowipe done ok=%d message=%@", ok, message);
+ WBAppInfo *updated = [WBRegistry.shared appForBundleID:bundleID];
+ NSLog(@"[WB] autowipe size=%llu known=%d", updated.dataSize, updated.sizeKnown);
+ });
+}
+
+static void WBRunDebugShowDetail(void)
+{
+ NSArray *args = NSProcessInfo.processInfo.arguments;
+ NSString *bundleID = nil;
+ for (NSString *arg in args) {
+ if ([arg hasPrefix:@"--wb-show-detail="]) bundleID = [arg substringFromIndex:17];
+ }
+ if ([args containsObject:@"--wb-show-settings"]) {
+ dispatch_async(dispatch_get_main_queue(), ^{
+ UIViewController *root = UIApplication.sharedApplication.keyWindow.rootViewController;
+ if ([root isKindOfClass:UITabBarController.class]) ((UITabBarController *)root).selectedIndex = 2;
+ });
+ return;
+ }
+ for (NSString *arg in args) {
+ if ([arg hasPrefix:@"--wb-url="]) {
+ NSString *path = [arg substringFromIndex:9];
+ NSString *string = [@"filza://view" stringByAppendingString:path];
+ dispatch_async(dispatch_get_main_queue(), ^{
+ [[UIApplication sharedApplication] openURL:[NSURL URLWithString:string]];
+ });
+ return;
+ }
+ }
+ for (NSString *arg in args) {
+ if ([arg hasPrefix:@"--wb-file="]) {
+ WBPresentFileBrowser([arg substringFromIndex:10]);
+ return;
+ }
+ }
+ WBAppInfo *info = [WBRegistry.shared appForBundleID:bundleID];
+ if (!info) return;
+ dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 2 * NSEC_PER_SEC), dispatch_get_main_queue(), ^{
+ Class itemClass = NSClassFromString(@"ApplicationItem");
+ id item = [[itemClass alloc] init];
+ [item setValue:bundleID forKey:@"bundleId"];
+ [item setValue:[[WBAppProxy alloc] initWithInfo:info] forKey:@"appProxy"];
+ [item setValue:info.containerPath forKey:@"documentPath"];
+ id vc = [[NSClassFromString(@"AppInfoTableViewController") alloc] init];
+ [vc setValue:item forKey:@"item"];
+ UIWindow *window = UIApplication.sharedApplication.keyWindow;
+ UIViewController *root = window.rootViewController;
+ NSLog(@"[WB] show detail root=%@ nav=%@", root, ((UITabBarController *)root).selectedViewController);
+ UINavigationController *nav = nil;
+ if ([root isKindOfClass:UITabBarController.class]) nav = (UINavigationController *)((UITabBarController *)root).selectedViewController;
+ if ([nav isKindOfClass:UINavigationController.class]) [nav pushViewController:vc animated:YES];
+ else [root presentViewController:vc animated:YES completion:nil];
+ });
+}
+
+static void WBRunDebugRefresh(void)
+{
+ if (![NSProcessInfo.processInfo.arguments containsObject:@"--wb-refresh-test"]) return;
+ dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 4 * NSEC_PER_SEC), dispatch_get_main_queue(), ^{
+ id vc = gAppsTableViewController;
+ if (!vc) { NSLog(@"[WB] debug refresh: no apps table"); return; }
+ UITableView *table = [(UITableViewController *)vc tableView];
+ UIRefreshControl *control = WBRefreshControlInTable(table);
+ [control beginRefreshing];
+ ((void (*)(id, SEL, id))objc_msgSend)(vc, NSSelectorFromString(@"handleRefresh:"), control);
+ NSLog(@"[WB] debug refresh started");
+ });
+}
+
+static void WBStartPreparation(void)
+{
+ if (gPreparing || gPrepared) return;
+ gPreparing = YES;
+
+ UIWindow *window = UIApplication.sharedApplication.keyWindow;
+ if (!window) {
+ for (UIScene *scene in UIApplication.sharedApplication.connectedScenes) {
+ if (![scene isKindOfClass:UIWindowScene.class]) continue;
+ for (UIWindow *candidate in ((UIWindowScene *)scene).windows) {
+ if (candidate.isKeyWindow) { window = candidate; break; }
+ }
+ }
+ }
+ UIViewController *root = window.rootViewController;
+ if (root && !gProgressController) {
+ gProgressController = [WBProgressViewController new];
+ gProgressController.modalPresentationStyle = UIModalPresentationOverFullScreen;
+ [root presentViewController:gProgressController animated:YES completion:nil];
+ }
+
+ [WBRegistry.shared prepareWithProgress:^(double fraction, NSString *status) {
+ [gProgressController updateFraction:fraction status:status];
+ } completion:^(BOOL ok, NSString *message) {
+ gPrepared = YES;
+ if (gReadySemaphore) dispatch_semaphore_signal(gReadySemaphore);
+ dispatch_async(dispatch_get_main_queue(), ^{
+ if (gProgressController) {
+ [gProgressController dismissViewControllerAnimated:YES completion:nil];
+ gProgressController = nil;
+ }
+ if (!ok && message.length) {
+ UIAlertController *alert = [UIAlertController alertControllerWithTitle:WBLocalized(@"init_failed")
+ message:message
+ preferredStyle:UIAlertControllerStyleAlert];
+ [alert addAction:[UIAlertAction actionWithTitle:WBLocalized(@"ok") style:UIAlertActionStyleDefault handler:nil]];
+ UIWindow *keyWindow = UIApplication.sharedApplication.keyWindow;
+ [keyWindow.rootViewController presentViewController:alert animated:YES completion:nil];
+ }
+ [[NSNotificationCenter defaultCenter] postNotificationName:@"WBRegistryReady" object:nil];
+ WBRunDebugBackup();
+ WBRunDebugRestore();
+ WBRunDebugWipe();
+ WBRunDebugShowDetail();
+ WBRunDebugRefresh();
+ });
+ }];
+}
+
+__attribute__((constructor))
+static void WBHookInit(void)
+{
+ @autoreleasepool {
+ WBInstallHooks();
+ dispatch_async(dispatch_get_main_queue(), ^{
+ [[NSNotificationCenter defaultCenter] addObserverForName:UIApplicationDidFinishLaunchingNotification
+ object:nil
+ queue:NSOperationQueue.mainQueue
+ usingBlock:^(__unused NSNotification *note) {
+ WBStartPreparation();
+ }];
+ });
+ }
+}
diff --git a/source/hook/src/WQAccess.h b/source/hook/src/WQAccess.h
new file mode 100644
index 0000000..3127e16
--- /dev/null
+++ b/source/hook/src/WQAccess.h
@@ -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
+#import "WQLaunchServices.h"
+#import
+
+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 *_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 *_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 *_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 *_Nullable expected,
+ NSDictionary *_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/)
+// 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 *runs, NSString *_Nullable *_Nullable errorText);
+BOOL wq_patch_if_unchanged(NSString *path, NSDictionary *runs,
+ NSDictionary *_Nullable expected, NSString *_Nullable *_Nullable errorText);
+BOOL wq_patch_if_unchanged_get_version(NSString *path, NSDictionary *runs,
+ NSDictionary *_Nullable expected,
+ NSDictionary *_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 *_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 *_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
diff --git a/source/hook/src/WQAccess.m b/source/hook/src/WQAccess.m
new file mode 100644
index 0000000..40cdc17
--- /dev/null
+++ b/source/hook/src/WQAccess.m
@@ -0,0 +1,2587 @@
+//
+// WQAccess.m
+// WQ Files
+//
+// Sandbox extension technique: worse_query by Taj C (forcequit),
+// https://github.com/forcequitOS/worse_query, GPL-3.0-or-later.
+//
+// Notes from testing on 27.0.1:
+// - An extension covers the folder it was issued for. A container root
+// extension was not enough to open a file two levels down for writing, so
+// writes always ask for the file's own folder first.
+// - /var/containers/Shared/SystemGroup itself gets no token; its children do.
+//
+
+#import "WQAccess.h"
+#import "WQCore.h"
+#import "WQAccessSafety.h"
+#import "WQLocalization.h"
+#import
+#import
+#import
+#import
+#import
+#import
+#import
+#import
+#import
+#import
+#import
+#import
+#import
+
+@implementation WQScanCancellation {
+ _Atomic BOOL _cancelled;
+}
+
+- (BOOL)isCancelled
+{
+ return atomic_load_explicit(&_cancelled, memory_order_relaxed);
+}
+
+- (void)cancel
+{
+ atomic_store_explicit(&_cancelled, YES, memory_order_relaxed);
+}
+
+@end
+
+#pragma mark - Log
+
+static NSMutableString *gLog;
+static NSLock *gLogLock;
+
+void wq_log(NSString *format, ...)
+{
+ static dispatch_once_t once;
+ dispatch_once(&once, ^{ gLog = [NSMutableString new]; gLogLock = [NSLock new]; });
+ va_list args;
+ va_start(args, format);
+ NSString *line = [[NSString alloc] initWithFormat:format arguments:args];
+ va_end(args);
+ NSLog(@"[wqfiles] %@", line);
+ [gLogLock lock];
+ [gLog appendFormat:@"%@\n", line];
+ [gLogLock unlock];
+}
+
+NSString *wq_log_text(void)
+{
+ [gLogLock lock];
+ NSString *text = [gLog copy] ?: @"";
+ [gLogLock unlock];
+ return text;
+}
+
+static NSString *wq_errno(int err) { return [NSString stringWithFormat:@"%s (errno %d)", strerror(err), err]; }
+
+#pragma mark - App group
+
+typedef struct __SecTask *SecTaskRef;
+extern SecTaskRef SecTaskCreateFromSelf(CFAllocatorRef allocator);
+extern CFTypeRef SecTaskCopyValueForEntitlement(SecTaskRef task, CFStringRef entitlement, CFErrorRef *error);
+
+NSString *wq_app_group(void)
+{
+ static NSString *group;
+ static dispatch_once_t once;
+ dispatch_once(&once, ^{
+ NSArray *groups = nil;
+ SecTaskRef task = SecTaskCreateFromSelf(kCFAllocatorDefault);
+ if (task) {
+ CFTypeRef value = SecTaskCopyValueForEntitlement(task, CFSTR("com.apple.security.application-groups"), NULL);
+ if (value) groups = (__bridge_transfer NSArray *)value;
+ CFRelease(task);
+ }
+ for (id candidate in groups) {
+ if (![candidate isKindOfClass:NSString.class]) continue;
+ NSURL *root = [NSFileManager.defaultManager containerURLForSecurityApplicationGroupIdentifier:candidate];
+ if (root) {
+ group = candidate;
+ if (!WQRecoverCacheSwapAtPath([root.path stringByAppendingPathComponent:@"Library"]))
+ wq_log(@"App-group cache recovery requires attention; preserved contents retained: %@ (%@)", root.path, wq_errno(errno));
+ break;
+ }
+ }
+ wq_log(@"App group: %@ (entitled: %@)", group ?: @"NONE",
+ groups.count ? [groups componentsJoinedByString:@", "] : @"none");
+ });
+ return group;
+}
+
+#pragma mark - worse_query
+
+typedef void *(*cq_create_fn)(void);
+typedef void (*cq_set_u64_fn)(void *, uint64_t);
+typedef void (*cq_set_ids_fn)(void *, xpc_object_t);
+typedef void *(*cq_result_fn)(void *);
+typedef void (*cq_free_fn)(void *);
+typedef char *(*cq_token_fn)(void *);
+typedef int64_t (*ext_consume_fn)(const char *);
+// The callback returns true to continue; a void block leaves that value undefined.
+typedef void (*cq_iterate_fn)(void *, bool (^)(void *));
+typedef const char *(*co_get_path_fn)(void *);
+typedef const char *(*co_get_id_fn)(void *);
+
+static void wq_release_handle(int64_t handle);
+
+static int64_t wq_query_token(NSString *group, BOOL altFlags)
+{
+ static void *mgr;
+ static cq_create_fn create;
+ static cq_set_u64_fn setClass, setFlags, setPart;
+ static cq_set_ids_fn setGroups;
+ static cq_result_fn single;
+ static cq_free_fn freeQuery;
+ static cq_token_fn copyToken;
+ static ext_consume_fn consume;
+ if (!mgr) {
+ mgr = dlopen("/usr/lib/system/libsystem_containermanager.dylib", RTLD_NOW | RTLD_LOCAL);
+ if (!mgr) return -2;
+ create = (cq_create_fn)dlsym(mgr, "container_query_create");
+ setClass = (cq_set_u64_fn)dlsym(mgr, "container_query_set_class");
+ setGroups = (cq_set_ids_fn)dlsym(mgr, "container_query_set_group_identifiers");
+ setFlags = (cq_set_u64_fn)dlsym(mgr, "container_query_operation_set_flags");
+ setPart = (cq_set_u64_fn)dlsym(mgr, "container_query_operation_set_part");
+ single = (cq_result_fn)dlsym(mgr, "container_query_get_single_result");
+ freeQuery = (cq_free_fn)dlsym(mgr, "container_query_free");
+ copyToken = (cq_token_fn)dlsym(mgr, "container_copy_sandbox_token");
+ consume = (ext_consume_fn)dlsym(RTLD_DEFAULT, "sandbox_extension_consume");
+ }
+ if (!create || !setClass || !setGroups || !setFlags || !setPart || !single || !freeQuery || !copyToken || !consume) return -2;
+
+ void *query = create();
+ if (!query) return -3;
+ char *token = NULL;
+ @try {
+ setClass(query, 7); // app group container
+ xpc_object_t ident = xpc_string_create(group.UTF8String);
+ setGroups(query, ident);
+ setPart(query, 3); // Library/Caches
+ setFlags(query, altFlags ? 0x0000000800000000ULL : 0x0000008000000000ULL);
+ void *result = single(query);
+ token = result ? copyToken(result) : NULL;
+ int64_t handle = token ? consume(token) : (result ? -6 : -5);
+ return token && handle < 0 ? -7 : handle;
+ } @finally {
+ if (token) free(token);
+ freeQuery(query);
+ }
+}
+
+BOOL wq_system_areas_reachable(void)
+{
+ // worse_query reaches System Data, the system groups and the App Groups
+ // parent only on iOS 27; on iOS 26 it reaches the individual app,
+ // extension, daemon and app group containers.
+ return NSProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 27;
+}
+
+static int64_t wq_query(NSString *target, BOOL altFlags)
+{
+ NSString *group = wq_app_group();
+ if (!group) return -1;
+ NSFileManager *files = NSFileManager.defaultManager;
+ NSURL *root = [files containerURLForSecurityApplicationGroupIdentifier:group];
+ if (!root) return -1;
+ NSString *libraryPath = [root.path stringByAppendingPathComponent:@"Library"];
+ if (![files createDirectoryAtPath:libraryPath withIntermediateDirectories:YES attributes:nil error:nil]) return -4;
+ int library = WQOpenDirectoryNoLinks(libraryPath);
+ if (library < 0) return -4;
+ int lock = WQOpenRegularAt(library, ".wq-caches-lock", O_RDWR | O_CREAT, 0600);
+ if (lock < 0 || flock(lock, LOCK_EX | LOCK_NB) != 0) {
+ if (lock >= 0) close(lock);
+ close(library);
+ return -4;
+ }
+ int64_t handle = -4;
+ @try {
+ // Keep the exact Caches swap point required by worse_query, but preserve
+ // its original inode and nested contents. Next acquisition recovers an
+ // interrupted swap from the durable marker, before doing another query.
+ if (WQCacheSwapBegin(library, target)) handle = wq_query_token(group, altFlags);
+ } @catch (NSException *exception) {
+ wq_log(@"token query exception: %@", exception.reason);
+ handle = -9;
+ } @finally {
+ if (!WQCacheSwapRecover(library)) {
+ wq_log(@"token cache recovery failed: %@; preserved contents retained", libraryPath);
+ if (handle >= 0) wq_release_handle(handle);
+ handle = -8;
+ }
+ close(lock);
+ close(library);
+ }
+ return handle;
+}
+
+static NSMutableDictionary *gGrants;
+static NSMutableArray *gGrantOrder; // oldest first
+// The kernel limits how many sandbox extensions a process may hold. Keep a
+// bounded working set and release the oldest; released paths are simply
+// granted again when they are next used.
+static const NSUInteger kMaxGrants = 192;
+
+static void wq_release_handle(int64_t handle)
+{
+ typedef int (*ext_release_fn)(int64_t);
+ static ext_release_fn release;
+ if (!release) release = (ext_release_fn)dlsym(RTLD_DEFAULT, "sandbox_extension_release");
+ if (release && handle >= 0) release(handle);
+}
+static NSRecursiveLock *gGrantLock;
+
+static NSString *wq_norm(NSString *path)
+{
+ NSString *p = path.stringByStandardizingPath;
+ if ([p hasPrefix:@"/var/"]) p = [@"/private" stringByAppendingString:p];
+ return p;
+}
+
+// Whether the target can now be opened. Only a sandbox denial (EPERM) counts
+// as "the token didn't help": EACCES is ordinary file permissions (root-owned
+// system containers), which a token never changes.
+static BOOL wq_grant_opens(NSString *path)
+{
+ int fd = open(path.fileSystemRepresentation, O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC);
+ if (fd >= 0) { close(fd); return YES; }
+ return errno != EPERM;
+}
+
+BOOL wq_grant(NSString *path)
+{
+ static dispatch_once_t once;
+ dispatch_once(&once, ^{
+ gGrants = [NSMutableDictionary new];
+ gGrantOrder = [NSMutableArray new];
+ gGrantLock = [NSRecursiveLock new];
+ });
+ NSString *key = wq_norm(path);
+ [gGrantLock lock];
+ NSNumber *known = gGrants[key];
+ if (known) {
+ [gGrantOrder removeObject:key]; // most recently used goes last
+ [gGrantOrder addObject:key];
+ [gGrantLock unlock];
+ return YES;
+ }
+ while (gGrantOrder.count >= kMaxGrants) {
+ NSString *oldest = gGrantOrder.firstObject;
+ wq_release_handle(gGrants[oldest].longLongValue);
+ [gGrants removeObjectForKey:oldest];
+ [gGrantOrder removeObjectAtIndex:0];
+ }
+ struct stat st;
+ if (lstat(key.fileSystemRepresentation, &st) != 0) {
+ [gGrantLock unlock];
+ return NO; // worse_query needs an existing target
+ }
+ // iOS 26 needs the alternative query flags; try the likely ones first so
+ // a grant costs one query, and the other set only when that fails. A
+ // token can also be issued and consumed yet not open the target (seen on
+ // 27.0.1 with an AltStore app group): check, and then try the other flags.
+ // The set that works is remembered for the rest of the run.
+ static int preferAlt = -1;
+ BOOL altFirst = preferAlt >= 0 ? (BOOL)preferAlt : !wq_system_areas_reachable();
+ int64_t handle = wq_query(key, altFirst);
+ BOOL usedAlt = altFirst;
+ if (handle >= 0 && !wq_grant_opens(key)) {
+ static BOOL logged;
+ if (!logged) {
+ logged = YES;
+ wq_log(@"grant: the token for %@ doesn't open it (%@ query flags); trying the other flags", key,
+ altFirst ? @"alternative" : @"standard");
+ }
+ wq_release_handle(handle);
+ handle = -8;
+ }
+ if (handle < 0 && handle != -1 && handle != -2) {
+ int64_t alt = wq_query(key, !altFirst);
+ if (alt >= 0 && !wq_grant_opens(key)) {
+ wq_release_handle(alt);
+ alt = -8;
+ }
+ if (alt >= 0) {
+ handle = alt;
+ usedAlt = !altFirst;
+ }
+ }
+ // Decided once, by the first grant that works: some targets refuse to
+ // open for their own reasons, and a per-target fallback already covers
+ // the rest, so flipping the preference back and forth only costs queries.
+ if (handle >= 0 && preferAlt < 0) {
+ preferAlt = usedAlt;
+ if (usedAlt != !wq_system_areas_reachable())
+ wq_log(@"grant: the %@ query flags work on this device", usedAlt ? @"alternative" : @"standard");
+ }
+ if (handle >= 0) { // failures are retried next time
+ gGrants[key] = @(handle);
+ [gGrantOrder addObject:key];
+ }
+ [gGrantLock unlock];
+ return handle >= 0;
+}
+
+static BOOL wq_is_denied(int err) { return err == EPERM || err == EACCES; }
+
+#pragma mark - Backups
+
+#ifdef WQ_ACCESS_TESTING
+static NSString *wq_test_backup_root;
+static NSString *wq_test_app_backup_root;
+static int64_t wq_test_volume_limit = -1;
+static void (^wq_test_mutation_hook)(NSString *stage, NSString *path);
+static void (^wq_test_tree_hook)(NSString *stage, NSString *path);
+static void (^wq_test_stream_hook)(NSString *stage, NSString *path);
+#define WQ_TREE_HOOK(stage, path) do { if (wq_test_tree_hook) wq_test_tree_hook(stage, path); } while (0)
+#define WQ_MUTATION_HOOK(stage, path) do { if (wq_test_mutation_hook) wq_test_mutation_hook(stage, path); } while (0)
+#define WQ_STREAM_HOOK(stage, path) do { if (wq_test_stream_hook) wq_test_stream_hook(stage, path); } while (0)
+#else
+#define WQ_MUTATION_HOOK(stage, path) do {} while (0)
+#define WQ_TREE_HOOK(stage, path) do {} while (0)
+#define WQ_STREAM_HOOK(stage, path) do {} while (0)
+#endif
+
+NSString *wq_backup_root(void)
+{
+#ifdef WQ_ACCESS_TESTING
+ if (wq_test_backup_root) return wq_test_backup_root;
+#endif
+ NSString *docs = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES).firstObject;
+ return [docs stringByAppendingPathComponent:@"Backups"];
+}
+
+NSString *wq_app_backup_root(void)
+{
+#ifdef WQ_ACCESS_TESTING
+ if (wq_test_app_backup_root) return wq_test_app_backup_root;
+#endif
+ NSString *docs = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES).firstObject;
+ return [docs stringByAppendingPathComponent:@"AppBackups"];
+}
+
+BOOL wq_is_backup_storage_path(NSString *path)
+{
+ NSString *original = WQCanonicalVarPath(path);
+ NSMutableArray *roots = [NSMutableArray new];
+ NSString *fileRoot = WQCanonicalVarPath(wq_backup_root());
+ NSString *appRoot = WQCanonicalVarPath(wq_app_backup_root());
+ if (fileRoot.length) [roots addObject:fileRoot];
+ if (appRoot.length) [roots addObject:appRoot];
+ for (NSString *root in roots) {
+ if ([original isEqualToString:root] || [original hasPrefix:[root stringByAppendingString:@"/"]]) return YES;
+ }
+ return NO;
+}
+
+static int wq_open(NSString *path, int flags, mode_t mode);
+static int wq_open_directory_flags(NSString *path, int flags);
+
+// Copies everything from fd `in` to fd `out` in chunks; fsyncs `out`.
+static BOOL wq_stream(int in, int out, NSString **errorText)
+{
+ static const size_t kChunk = 1 << 20;
+ uint8_t *buf = malloc(kChunk);
+ if (!buf) { if (errorText) *errorText = WQLocalized(@"Out of memory"); return NO; }
+ BOOL ok = YES;
+ for (;;) {
+ ssize_t n = read(in, buf, kChunk);
+ if (n < 0 && errno == EINTR) continue;
+ if (n < 0) { ok = NO; if (errorText) *errorText = wq_errno(errno); break; }
+ if (n == 0) break;
+ for (ssize_t done = 0; done < n; ) {
+ ssize_t w = write(out, buf + done, (size_t)(n - done));
+ if (w < 0 && errno == EINTR) continue;
+ if (w <= 0) { ok = NO; if (errorText) *errorText = wq_errno(w == 0 ? EIO : errno); break; }
+ done += w;
+ }
+ if (!ok) break;
+ }
+ free(buf);
+ if (ok && fsync(out) != 0) { ok = NO; if (errorText) *errorText = wq_errno(errno); }
+ return ok;
+}
+
+// Always durable, including own files. Safety artifacts themselves are protected
+// from generic overwrite/delete: recursively backing up Backups is unbounded.
+static BOOL wq_backup_fd(NSString *path, int fd, NSString **backupPath, NSString **errorText)
+{
+ if (errorText) *errorText = nil;
+ NSString *original = WQCanonicalVarPath(path);
+ NSString *root = WQCanonicalVarPath(wq_backup_root());
+ if (!WQAbsolutePathGrammar(original) || wq_is_backup_storage_path(original)) {
+ wq_log(@"backup: refusing invalid path or mutation of backup storage: %@", path);
+ if (errorText) *errorText = WQLocalized(@"The path is invalid or belongs to backup storage.");
+ return NO;
+ }
+ static NSDateFormatter *fmt;
+ static uint64_t counter;
+ static NSLock *lock;
+ static dispatch_once_t once;
+ dispatch_once(&once, ^{
+ fmt = [NSDateFormatter new];
+ fmt.locale = [NSLocale localeWithLocaleIdentifier:@"en_US_POSIX"];
+ fmt.calendar = [[NSCalendar alloc] initWithCalendarIdentifier:NSCalendarIdentifierGregorian];
+ fmt.dateFormat = @"yyyyMMdd-HHmmss-SSS";
+ lock = [NSLock new];
+ });
+ [lock lock];
+ NSString *stamp = counter == UINT64_MAX ? nil :
+ [NSString stringWithFormat:@"%@-%llu", [fmt stringFromDate:NSDate.date], (unsigned long long)++counter];
+ [lock unlock];
+ if (!stamp) {
+ if (errorText) *errorText = WQLocalized(@"Cannot allocate a backup name.");
+ return NO;
+ }
+ NSString *dest = [[root stringByAppendingPathComponent:stamp] stringByAppendingString:original];
+ int parent = WQCreateDirectoriesNoLinks(dest.stringByDeletingLastPathComponent);
+ if (parent < 0) {
+ if (errorText) *errorText = wq_errno(errno);
+ return NO;
+ }
+ const char *name = dest.lastPathComponent.fileSystemRepresentation;
+ NSString *error = nil;
+ BOOL ok = NO;
+ // APFS clone first: a copy-on-write snapshot is independent, durable, and
+ // does not require a full-size duplicate for every edited file.
+ if (fclonefileat(fd, parent, name, 0) == 0) {
+ int cloned = openat(parent, name, O_RDONLY | O_NOFOLLOW | O_CLOEXEC);
+ struct stat clonedStat = {0}, sourceStat = {0};
+ if (cloned >= 0 && fstat(cloned, &clonedStat) == 0 && fstat(fd, &sourceStat) == 0 &&
+ clonedStat.st_size == sourceStat.st_size && fsync(cloned) == 0) {
+ ok = YES;
+ } else {
+ error = wq_errno(errno);
+ }
+ if (cloned >= 0) close(cloned);
+ if (!ok) {
+ unlinkat(parent, name, 0);
+ wq_log(@"backup: clone verification failed for %@ (%@)", path, error ?: @"unknown error");
+ }
+ } else {
+ int cloneError = errno;
+ wq_log(@"backup: clone unavailable for %@ (%@); streaming", path, wq_errno(cloneError));
+ struct stat sourceStat = {0};
+ struct statfs space = {0};
+ if (fstat(fd, &sourceStat) == 0 && fstatfs(parent, &space) == 0) {
+ unsigned long long available = (unsigned long long)space.f_bavail * (unsigned long long)space.f_bsize;
+ if ((unsigned long long)sourceStat.st_size > available) {
+ error = WQLocalized(@"Not enough free space for a full backup copy");
+ wq_log(@"backup: %@ (%@)", path, error);
+ if (errorText) *errorText = error;
+ close(parent);
+ return NO;
+ }
+ }
+ int out = WQOpenRegularAt(parent, name, O_WRONLY | O_CREAT | O_EXCL, 0600);
+ if (out >= 0) {
+ ok = lseek(fd, 0, SEEK_SET) >= 0 && wq_stream(fd, out, &error);
+ if (close(out) != 0) ok = NO;
+ } else {
+ error = wq_errno(errno);
+ }
+ if (ok && fsync(parent) != 0) ok = NO;
+ if (!ok) unlinkat(parent, name, 0);
+ }
+ if (ok && fsync(parent) != 0) { ok = NO; error = wq_errno(errno); }
+ if (!ok) unlinkat(parent, name, 0);
+ close(parent);
+ wq_log(@"backup: %@ -> %@", path, ok ? dest : error ?: @"FAILED");
+ if (ok && backupPath) *backupPath = dest;
+ if (!ok && errorText) *errorText = error ?: WQLocalized(@"Backup copy failed.");
+ return ok;
+}
+
+NSString *wq_backup_original_path(NSString *backupPath)
+{
+ // Validate raw components before normalization, so traversal is never hidden.
+ if (!WQAbsolutePathGrammar(backupPath)) return nil;
+ NSString *rootPath = WQCanonicalVarPath(wq_backup_root());
+ if (!rootPath.length) return nil;
+ NSString *root = [rootPath stringByAppendingString:@"/"];
+ NSString *p = WQCanonicalVarPath(backupPath);
+ if (![p hasPrefix:root]) return nil;
+ NSArray *parts = [[p substringFromIndex:root.length] componentsSeparatedByString:@"/"];
+ if (parts.count < 2 || !WQBackupStampValid(parts[0])) return nil;
+ NSString *original = [@"/" stringByAppendingString:[[parts subarrayWithRange:NSMakeRange(1, parts.count - 1)] componentsJoinedByString:@"/"]];
+ return WQAbsolutePathGrammar(original) ? original : nil;
+}
+
+#pragma mark - File operations
+
+static NSDictionary *wq_version_for_stat(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),
+ @"changedSeconds": @(st->st_ctimespec.tv_sec), @"changedNanoseconds": @(st->st_ctimespec.tv_nsec) };
+}
+
+NSDictionary *wq_file_version(NSString *path)
+{
+ struct stat st;
+ if (!wq_stat(path, &st) || !S_ISREG(st.st_mode)) return nil;
+ return wq_version_for_stat(&st);
+}
+
+static BOOL wq_changed(NSString **errorText)
+{
+ if (errorText) *errorText = WQLocalized(@"The file changed after it was opened. Your edits were not written; reopen it and compare the changes.");
+ return NO;
+}
+
+typedef struct {
+ int fd;
+ int parent;
+ struct stat before;
+} WQMutation;
+
+static void wq_mutation_close(WQMutation *mutation)
+{
+ if (mutation->fd >= 0) close(mutation->fd);
+ if (mutation->parent >= 0) close(mutation->parent);
+ mutation->fd = mutation->parent = -1;
+}
+
+// Revalidate the descriptor, directory entry, and the current parent path. A
+// pathname swap never redirects backup, mutation, or rollback to the new inode.
+static BOOL wq_mutation_current(NSString *path, WQMutation *mutation, BOOL version, NSString **errorText)
+{
+ struct stat current, entry, parent, currentParent;
+ int checkParent = WQOpenDirectoryNoLinksFlags(path.stringByDeletingLastPathComponent, O_SEARCH);
+ BOOL ok = checkParent >= 0 && fstat(checkParent, ¤tParent) == 0 &&
+ fstat(mutation->parent, &parent) == 0 && WQSameObject(&parent, ¤tParent) &&
+ fstat(mutation->fd, ¤t) == 0 && S_ISREG(current.st_mode) &&
+ fstatat(mutation->parent, path.lastPathComponent.fileSystemRepresentation, &entry, AT_SYMLINK_NOFOLLOW) == 0 &&
+ S_ISREG(entry.st_mode) && WQSameObject(&entry, ¤t) && WQSameObject(¤t, &mutation->before);
+ if (checkParent >= 0) close(checkParent);
+ if (ok && version) ok = [wq_version_for_stat(¤t) isEqualToDictionary:wq_version_for_stat(&mutation->before)];
+ return ok ? YES : wq_changed(errorText);
+}
+
+static BOOL wq_mutation_open(NSString *path, NSDictionary *expected, WQMutation *mutation, int flags, NSString **errorText)
+{
+ *mutation = (WQMutation){ .fd = -1, .parent = -1 };
+ if (!WQAbsolutePathGrammar(WQCanonicalVarPath(path))) { if (errorText) *errorText = WQLocalized(@"Invalid file path"); return NO; }
+ if (wq_is_backup_storage_path(path)) {
+ if (errorText) *errorText = WQLocalized(@"Backup storage cannot be overwritten or deleted through the file browser.");
+ return NO;
+ }
+ NSString *parent = path.stringByDeletingLastPathComponent;
+ mutation->parent = WQOpenDirectoryNoLinksFlags(parent, O_SEARCH);
+ if (mutation->parent < 0 && wq_is_denied(errno) && wq_grant(parent)) mutation->parent = WQOpenDirectoryNoLinksFlags(parent, O_SEARCH);
+ if (mutation->parent >= 0) {
+ mutation->fd = WQOpenRegularAt(mutation->parent, path.lastPathComponent.fileSystemRepresentation, flags, 0);
+ if (mutation->fd < 0 && wq_is_denied(errno) && wq_grant(path))
+ mutation->fd = WQOpenRegularAt(mutation->parent, path.lastPathComponent.fileSystemRepresentation, flags, 0);
+ }
+ if (mutation->fd < 0) { if (errorText) *errorText = wq_errno(errno); wq_mutation_close(mutation); return NO; }
+ if (flock(mutation->fd, LOCK_EX | LOCK_NB) != 0) {
+ int err = errno;
+ // Some providers do not implement locks. Keep descriptor/version checks;
+ // this is advisory serialization, not atomicity against other writers.
+ if (err != ENOTSUP && err != EOPNOTSUPP && err != ENOSYS) {
+ if (errorText) *errorText = wq_errno(err);
+ wq_mutation_close(mutation); return NO;
+ }
+ wq_log(@"advisory locking unsupported: %@", path);
+ }
+ if (fstat(mutation->fd, &mutation->before) != 0 ||
+ (expected && ![wq_version_for_stat(&mutation->before) isEqualToDictionary:expected]) ||
+ !wq_mutation_current(path, mutation, YES, errorText)) {
+ wq_changed(errorText); wq_mutation_close(mutation); return NO;
+ }
+ WQ_MUTATION_HOOK(@"verified", path);
+ return YES;
+}
+
+static BOOL wq_mutation_backup(NSString *path, WQMutation *mutation, NSString **backup, NSString **errorText)
+{
+ if (!wq_mutation_current(path, mutation, YES, errorText)) return NO;
+ NSString *backupError = nil;
+ if (!wq_backup_fd(path, mutation->fd, backup, &backupError)) {
+ if (errorText) *errorText = WQLocalizedFormat(@"Backup of the current file failed, nothing was written: %@", backupError ?: WQLocalized(@"unknown error"));
+ return NO;
+ }
+ WQ_MUTATION_HOOK(@"backedUp", path);
+ return wq_mutation_current(path, mutation, YES, errorText);
+}
+
+static BOOL wq_restore_backup_fd(WQMutation *mutation, NSString *backup, NSString **errorText)
+{
+ int in = WQOpenRegularAt(AT_FDCWD, backup.fileSystemRepresentation, O_RDONLY, 0);
+ if (in < 0) { if (errorText) *errorText = wq_errno(errno); return NO; }
+ // This is still the original, locked descriptor, never a reopened pathname.
+ BOOL ok = lseek(mutation->fd, 0, SEEK_SET) >= 0 && ftruncate(mutation->fd, 0) == 0 &&
+ wq_stream(in, mutation->fd, errorText);
+ close(in);
+ return ok;
+}
+
+static BOOL wq_pread_exact(int fd, void *buffer, size_t length, off_t offset)
+{
+ size_t done = 0;
+ while (done < length) {
+ ssize_t n = pread(fd, (uint8_t *)buffer + done, length - done, offset + (off_t)done);
+ if (n < 0 && errno == EINTR) continue;
+ if (n <= 0) { if (n == 0) errno = EIO; return NO; }
+ done += (size_t)n;
+ }
+ return YES;
+}
+
+// Compare a complete intended document, or the complete backup with patches
+// overlaid in write order. Buffers stay bounded even for a very large hex file.
+static NSDictionary *wq_verified_saved_version(WQMutation *mutation, NSData *data,
+ NSDictionary *runs)
+{
+ struct stat before, after;
+ if (fstat(mutation->fd, &before) != 0 || !S_ISREG(before.st_mode) || before.st_size < 0) return nil;
+ uint8_t expected[65536], actual[65536];
+ BOOL ok = YES;
+ if (data) {
+ if ((unsigned long long)before.st_size != data.length) return nil;
+ for (unsigned long long offset = 0; offset < data.length && ok; ) {
+ size_t count = (size_t)MIN((unsigned long long)sizeof(expected), (unsigned long long)data.length - offset);
+ memcpy(expected, (const uint8_t *)data.bytes + (NSUInteger)offset, count);
+ ok = wq_pread_exact(mutation->fd, actual, count, (off_t)offset) &&
+ memcmp(expected, actual, count) == 0;
+ offset += count;
+ }
+ } else {
+ // A patch leaves every unedited byte untouched. The descriptor was
+ // pinned and version-checked; verifying the changed runs is sufficient
+ // and avoids reading the entire file for a one-byte edit.
+ if (!runs || (unsigned long long)before.st_size != (unsigned long long)mutation->before.st_size) return nil;
+ for (NSNumber *key in runs) {
+ NSData *run = runs[key];
+ unsigned long long start = key.unsignedLongLongValue;
+ if (!WQPatchRangeValid(start, run.length, (unsigned long long)before.st_size)) return nil;
+ for (NSUInteger done = 0; done < run.length && ok; ) {
+ size_t count = (size_t)MIN((NSUInteger)sizeof(expected), run.length - done);
+ ok = wq_pread_exact(mutation->fd, actual, count, (off_t)(start + done)) &&
+ memcmp((const uint8_t *)run.bytes + done, actual, count) == 0;
+ done += (NSUInteger)count;
+ }
+ if (!ok) break;
+ }
+ }
+ if (!ok || fstat(mutation->fd, &after) != 0 ||
+ ![wq_version_for_stat(&before) isEqualToDictionary:wq_version_for_stat(&after)]) return nil;
+ return wq_version_for_stat(&after);
+}
+
+static BOOL wq_mutation_finish(NSString *path, WQMutation *mutation, NSString *backup, BOOL ok,
+ NSString *failure, NSString **errorText, NSDictionary **savedVersion,
+ NSData *expectedData, NSDictionary *expectedRuns)
+{
+ if (savedVersion) *savedVersion = nil;
+ NSDictionary *verifiedVersion = nil;
+ BOOL rollbackAllowed = YES;
+ if (ok && fsync(mutation->fd) != 0) { ok = NO; failure = wq_errno(errno); }
+ WQ_MUTATION_HOOK(@"written", path);
+ if (ok && !wq_mutation_current(path, mutation, NO, errorText)) {
+ ok = NO; failure = WQLocalized(@"The target path was replaced during writing");
+ }
+ if (ok && (expectedData || expectedRuns)) {
+ verifiedVersion = wq_verified_saved_version(mutation, expectedData, expectedRuns);
+ if (!verifiedVersion || !wq_mutation_current(path, mutation, NO, errorText)) {
+ ok = NO;
+ rollbackAllowed = NO; // A concurrent writer may own the newer contents.
+ failure = WQLocalized(@"Saved contents could not be verified; possible concurrent modification");
+ }
+ }
+ if (!ok) {
+ NSString *rollbackError = nil;
+ BOOL restored = rollbackAllowed && backup && wq_restore_backup_fd(mutation, backup, &rollbackError);
+ if (errorText) *errorText = [NSString stringWithFormat:@"%@; %@. Backup: %@", failure ?: WQLocalized(@"Write failed"),
+ restored ? @"the original descriptor contents were restored" :
+ (rollbackAllowed ? [@"rollback failed: " stringByAppendingString:rollbackError ?: WQLocalized(@"unknown error")] :
+ @"no rollback was attempted to avoid overwriting concurrent changes"), backup ?: @"none"];
+ }
+ // Rollback must finish before close releases the lock. A close failure has
+ // no safe reopen/rollback path; retain the durable backup and report it.
+ if (close(mutation->fd) != 0 && ok) {
+ ok = NO;
+ if (errorText) *errorText = WQLocalizedFormat(@"Close failed: %@. Backup: %@", wq_errno(errno), backup);
+ }
+ mutation->fd = -1;
+ wq_mutation_close(mutation);
+ if (ok && savedVersion) *savedVersion = verifiedVersion;
+ return ok;
+}
+
+NSArray *wq_list(NSString *dir, NSString **errorText)
+{
+ NSError *error = nil;
+ NSArray *items = [NSFileManager.defaultManager contentsOfDirectoryAtPath:dir error:&error];
+ if (!items && wq_grant(dir)) items = [NSFileManager.defaultManager contentsOfDirectoryAtPath:dir error:&error];
+ if (!items && errorText) *errorText = error.localizedDescription ?: WQLocalized(@"Cannot list this folder");
+ return items;
+}
+
+static int wq_open(NSString *path, int flags, mode_t mode)
+{
+ if (flags & O_TRUNC) { errno = EINVAL; return -1; }
+ int fd = WQOpenRegularAt(AT_FDCWD, path.fileSystemRepresentation, flags, mode);
+ if (fd >= 0 || !wq_is_denied(errno)) return fd;
+ // The file's own folder first, then the file itself.
+ if (wq_grant(path.stringByDeletingLastPathComponent)) {
+ fd = WQOpenRegularAt(AT_FDCWD, path.fileSystemRepresentation, flags, mode);
+ if (fd >= 0 || !wq_is_denied(errno)) return fd;
+ }
+ if (!(flags & O_CREAT) && wq_grant(path)) fd = WQOpenRegularAt(AT_FDCWD, path.fileSystemRepresentation, flags, mode);
+ if (fd < 0) {
+ // Say why: a sandbox refusal (EPERM) and plain Unix permissions
+ // (EACCES: owner/mode don't allow it) need different fixes.
+ int err = errno;
+ struct stat st;
+ BOOL have = lstat(path.fileSystemRepresentation, &st) == 0;
+ wq_log(@"open %@ for %@ -> %@; file uid %d mode %o, we are uid %d", path,
+ (flags & O_ACCMODE) == O_RDONLY ? @"reading" : @"writing", wq_errno(err),
+ have ? (int)st.st_uid : -1, have ? (int)(st.st_mode & 07777) : 0, (int)getuid());
+ errno = err;
+ }
+ return fd;
+}
+
+NSData *wq_read(NSString *path, NSUInteger maxBytes, NSString **errorText)
+{
+ int fd = wq_open(path, O_RDONLY, 0);
+ if (fd < 0) {
+ if (errorText) *errorText = wq_errno(errno);
+ return nil;
+ }
+ NSMutableData *data = [NSMutableData new];
+ uint8_t buf[65536];
+ for (;;) {
+ size_t want = sizeof(buf);
+ if (maxBytes && data.length + want > maxBytes) want = maxBytes - data.length;
+ if (want == 0) break;
+ ssize_t n = read(fd, buf, want);
+ if (n < 0) {
+ if (errno == EINTR) continue;
+ if (errorText) *errorText = wq_errno(errno);
+ close(fd);
+ return nil;
+ }
+ if (n == 0) break;
+ [data appendBytes:buf length:(NSUInteger)n];
+ }
+ close(fd);
+ return data;
+}
+
+static BOOL wq_write_all(int fd, NSData *data)
+{
+ const uint8_t *p = data.bytes;
+ NSUInteger left = data.length;
+ while (left > 0) {
+ ssize_t n = write(fd, p, left);
+ if (n < 0) {
+ if (errno == EINTR) continue;
+ return NO;
+ }
+ if (n == 0) { errno = EIO; return NO; }
+ p += n;
+ left -= (NSUInteger)n;
+ }
+ return YES;
+}
+
+// Writes, then flushes and closes, reporting the first failure of the three.
+static BOOL wq_finish_fd(int fd, BOOL ok, int *err)
+{
+ if (ok && fsync(fd) != 0) { ok = NO; *err = errno; }
+ if (close(fd) != 0 && ok) { ok = NO; *err = errno; }
+ return ok;
+}
+
+static BOOL wq_write_checked(NSString *path, NSData *data, NSDictionary *expected,
+ NSDictionary **savedVersion, BOOL verify, NSString **errorText)
+{
+ if (savedVersion) *savedVersion = nil;
+ WQMutation mutation;
+ if (!wq_mutation_open(path, expected, &mutation, O_RDWR, errorText)) return NO;
+ NSString *backup = nil;
+ if (!wq_mutation_backup(path, &mutation, &backup, errorText)) { wq_mutation_close(&mutation); return NO; }
+ BOOL ok = lseek(mutation.fd, 0, SEEK_SET) >= 0 && ftruncate(mutation.fd, 0) == 0 && wq_write_all(mutation.fd, data);
+ NSString *failure = ok ? nil : wq_errno(errno);
+ return wq_mutation_finish(path, &mutation, backup, ok, failure, errorText, savedVersion, verify ? data : nil, nil);
+}
+
+BOOL wq_write_if_unchanged_get_version(NSString *path, NSData *data, NSDictionary *expected,
+ NSDictionary **savedVersion, NSString **errorText)
+{
+ return wq_write_checked(path, data, expected, savedVersion, YES, errorText);
+}
+
+BOOL wq_write_if_unchanged(NSString *path, NSData *data, NSDictionary *expected, NSString **errorText)
+{
+ return wq_write_checked(path, data, expected, nil, NO, errorText);
+}
+
+BOOL wq_write(NSString *path, NSData *data, NSString **errorText)
+{
+ return wq_write_if_unchanged(path, data, nil, errorText);
+}
+
+BOOL wq_write_from_file(NSString *path, NSString *source, NSString **errorText)
+{
+ return wq_write_from_file_with_backup(path, source, NULL, errorText);
+}
+
+BOOL wq_write_from_file_with_backup(NSString *path, NSString *source, NSString **backupUnit, NSString **errorText)
+{
+ if (backupUnit) *backupUnit = nil;
+ int in = WQOpenRegularAt(AT_FDCWD, source.fileSystemRepresentation, O_RDONLY, 0);
+ if (in < 0) { if (errorText) *errorText = wq_errno(errno); return NO; }
+ WQMutation mutation;
+ if (!wq_mutation_open(path, nil, &mutation, O_RDWR, errorText)) { close(in); return NO; }
+ struct stat src;
+ if (fstat(in, &src) != 0 || WQSameObject(&src, &mutation.before)) {
+ if (errorText) *errorText = WQLocalized(@"Source and destination are the same file");
+ close(in); wq_mutation_close(&mutation); return NO;
+ }
+ NSDictionary *sourceVersion = wq_version_for_stat(&src);
+ NSString *backup = nil;
+ if (!wq_mutation_backup(path, &mutation, &backup, errorText)) { close(in); wq_mutation_close(&mutation); return NO; }
+ WQ_STREAM_HOOK(@"ready", source);
+ NSString *error = nil;
+ BOOL ok = lseek(mutation.fd, 0, SEEK_SET) >= 0 && ftruncate(mutation.fd, 0) == 0 && wq_stream(in, mutation.fd, &error);
+ struct stat srcAfter = {0};
+ if (ok && (fstat(in, &srcAfter) != 0 ||
+ ![sourceVersion isEqualToDictionary:wq_version_for_stat(&srcAfter)])) {
+ ok = NO;
+ error = WQLocalized(@"The source changed while it was being copied");
+ }
+ if (!ok && !error) error = wq_errno(errno);
+ close(in);
+ BOOL finished = wq_mutation_finish(path, &mutation, backup, ok, error, errorText, nil, nil, nil);
+ if (finished && backupUnit && backup.length) {
+ // Backups//: the unit is the stamp folder.
+ NSString *root = WQCanonicalVarPath(wq_backup_root());
+ NSString *canonical = WQCanonicalVarPath(backup);
+ if (root.length && [canonical hasPrefix:[root stringByAppendingString:@"/"]]) {
+ NSString *stamp = [[canonical substringFromIndex:root.length + 1] componentsSeparatedByString:@"/"].firstObject;
+ if (stamp.length) *backupUnit = [root stringByAppendingPathComponent:stamp];
+ }
+ }
+ return finished;
+}
+
+BOOL wq_create_file_from(NSString *path, NSString *source, NSString **errorText)
+{
+ if (wq_is_backup_storage_path(path)) {
+ if (errorText) *errorText = WQLocalized(@"Backup storage cannot be modified through the file browser.");
+ return NO;
+ }
+ NSString *canonical = WQCanonicalVarPath(path);
+ if (!WQAbsolutePathGrammar(canonical)) {
+ if (errorText) *errorText = WQLocalized(@"Invalid file path");
+ return NO;
+ }
+ int in = WQOpenRegularAt(AT_FDCWD, source.fileSystemRepresentation, O_RDONLY, 0);
+ if (in < 0) { if (errorText) *errorText = wq_errno(errno); return NO; }
+ NSString *parentPath = path.stringByDeletingLastPathComponent;
+ int parent = wq_open_directory_flags(parentPath, O_SEARCH);
+ if (parent < 0) {
+ if (errorText) *errorText = wq_errno(errno);
+ close(in);
+ return NO;
+ }
+ const char *name = path.lastPathComponent.fileSystemRepresentation;
+ int fd = WQOpenRegularAt(parent, name, O_WRONLY | O_CREAT | O_EXCL, 0644);
+ if (fd < 0 && wq_is_denied(errno) && wq_grant(parentPath))
+ fd = WQOpenRegularAt(parent, name, O_WRONLY | O_CREAT | O_EXCL, 0644);
+ if (fd < 0) {
+ if (errorText) *errorText = wq_errno(errno);
+ close(parent); close(in);
+ return NO;
+ }
+ struct stat srcBefore = {0};
+ if (fstat(in, &srcBefore) != 0) {
+ close(fd); close(in); close(parent);
+ unlinkat(parent, name, 0);
+ if (errorText) *errorText = WQLocalized(@"Cannot inspect the copy source");
+ return NO;
+ }
+ NSDictionary *sourceVersion = wq_version_for_stat(&srcBefore);
+ WQ_STREAM_HOOK(@"ready", source);
+ NSString *error = nil;
+ BOOL ok = wq_stream(in, fd, &error);
+ struct stat srcAfter = {0};
+ if (ok && (fstat(in, &srcAfter) != 0 ||
+ ![sourceVersion isEqualToDictionary:wq_version_for_stat(&srcAfter)])) {
+ ok = NO;
+ error = WQLocalized(@"The source changed while it was being copied");
+ }
+ close(in);
+ if (close(fd) != 0 && ok) { ok = NO; error = wq_errno(errno); }
+ if (ok && fsync(parent) != 0) { ok = NO; error = wq_errno(errno); }
+ if (!ok) unlinkat(parent, name, 0);
+ close(parent);
+ wq_log(@"create: %@ from %@ -> %@", path, source.lastPathComponent, ok ? @"OK" : error);
+ if (!ok && errorText) *errorText = error;
+ return ok;
+}
+
+BOOL wq_copy_to_local(NSString *path, NSString *dest, NSString **errorText)
+{
+ if (!WQAbsolutePathGrammar(WQCanonicalVarPath(path)) || !WQAbsolutePathGrammar(WQCanonicalVarPath(dest))) {
+ if (errorText) *errorText = WQLocalized(@"Invalid copy path");
+ return NO;
+ }
+ if (wq_is_backup_storage_path(dest)) {
+ if (errorText) *errorText = WQLocalized(@"Backup storage cannot be modified through the file browser.");
+ return NO;
+ }
+ int in = wq_open(path, O_RDONLY, 0);
+ if (in < 0) { if (errorText) *errorText = wq_errno(errno); return NO; }
+ struct stat source, destination;
+ if (fstat(in, &source) != 0) { close(in); return NO; }
+ // stat intentionally follows a destination alias for this identity check.
+ if (stat(dest.fileSystemRepresentation, &destination) == 0 && WQSameObject(&source, &destination)) {
+ close(in); if (errorText) *errorText = WQLocalized(@"Source and destination are the same file"); return NO;
+ }
+ int parent = WQOpenDirectoryNoLinks(dest.stringByDeletingLastPathComponent);
+ if (parent < 0) { if (errorText) *errorText = wq_errno(errno); close(in); return NO; }
+ NSString *stage = [@".wq-local-copy-" stringByAppendingString:NSUUID.UUID.UUIDString];
+ int out = WQOpenRegularAt(parent, stage.fileSystemRepresentation, O_WRONLY | O_CREAT | O_EXCL, 0644);
+ if (out < 0) { if (errorText) *errorText = wq_errno(errno); close(parent); close(in); return NO; }
+ BOOL ok = wq_stream(in, out, errorText);
+ struct stat after;
+ if (ok && (fstat(in, &after) != 0 || ![wq_version_for_stat(&source) isEqualToDictionary:wq_version_for_stat(&after)])) {
+ ok = NO; if (errorText) *errorText = WQLocalized(@"The source changed while it was copied");
+ }
+ close(in);
+ if (close(out) != 0 && ok) { ok = NO; if (errorText) *errorText = wq_errno(errno); }
+ // Recheck aliases after the potentially long stream, still before replacing.
+ if (ok && fstatat(parent, dest.lastPathComponent.fileSystemRepresentation, &destination, 0) == 0 && WQSameObject(&source, &destination)) {
+ ok = NO; if (errorText) *errorText = WQLocalized(@"Source and destination are the same file");
+ }
+ if (ok) {
+ struct stat entry;
+ if (fstatat(parent, dest.lastPathComponent.fileSystemRepresentation, &entry, AT_SYMLINK_NOFOLLOW) == 0 &&
+ !S_ISREG(entry.st_mode) && !S_ISLNK(entry.st_mode)) {
+ ok = NO; if (errorText) *errorText = WQLocalized(@"Destination is not a regular file");
+ }
+ }
+ if (ok && renameat(parent, stage.fileSystemRepresentation, parent, dest.lastPathComponent.fileSystemRepresentation) != 0) {
+ ok = NO; if (errorText) *errorText = wq_errno(errno);
+ }
+ if (ok && fsync(parent) != 0) { ok = NO; if (errorText) *errorText = wq_errno(errno); }
+ if (!ok) unlinkat(parent, stage.fileSystemRepresentation, 0);
+ close(parent);
+ return ok;
+}
+
+NSString *wq_permission_hint(NSString *path, BOOL forWriting)
+{
+ struct stat st;
+ if (lstat(path.fileSystemRepresentation, &st) != 0) return nil;
+ uid_t me = getuid();
+ mode_t need = forWriting ? 2 : 4; // w / r
+ BOOL allowed = st.st_uid == me ? (st.st_mode >> 6 & need) != 0
+ : (st.st_gid == getgid() ? (st.st_mode >> 3 & need) != 0 : (st.st_mode & need) != 0);
+ if (allowed) return nil;
+ NSString *owner = st.st_uid == 0 ? @"root" : [NSString stringWithFormat:@"uid %d", (int)st.st_uid];
+ return WQLocalizedFormat(@"This file belongs to %@ (mode %o) and only its owner may %@ it. WQ Files reaches the folder, but runs as mobile (uid %d), so no app can %@ this file.",
+ owner, st.st_mode & 07777, forWriting ? WQLocalized(@"change") : WQLocalized(@"read"), (int)me, forWriting ? WQLocalized(@"change") : WQLocalized(@"open"));
+}
+
+NSData *wq_read_range(NSString *path, unsigned long long offset, NSUInteger length, NSString **errorText)
+{
+ int fd = wq_open(path, O_RDONLY, 0);
+ if (fd < 0) { if (errorText) *errorText = wq_errno(errno); return nil; }
+ NSMutableData *data = [NSMutableData dataWithLength:length];
+ NSUInteger got = 0;
+ while (got < length) {
+ ssize_t n = pread(fd, (uint8_t *)data.mutableBytes + got, length - got, (off_t)(offset + got));
+ if (n < 0 && errno == EINTR) continue;
+ if (n < 0) { if (errorText) *errorText = wq_errno(errno); close(fd); return nil; }
+ if (n == 0) break;
+ got += (NSUInteger)n;
+ }
+ close(fd);
+ data.length = got;
+ return data;
+}
+
+static BOOL wq_patch_checked(NSString *path, NSDictionary *runs,
+ NSDictionary *expected, NSDictionary **savedVersion, BOOL verify, NSString **errorText)
+{
+ if (savedVersion) *savedVersion = nil;
+ WQMutation mutation;
+ if (!wq_mutation_open(path, expected, &mutation, O_RDWR, errorText)) return NO;
+ unsigned long long size = (unsigned long long)mutation.before.st_size;
+ for (NSNumber *off in runs) {
+ if (!WQPatchRangeValid(off.unsignedLongLongValue, runs[off].length, size)) {
+ if (errorText) *errorText = WQLocalized(@"A change lies past the end of the file");
+ wq_mutation_close(&mutation); return NO;
+ }
+ }
+ NSString *backup = nil;
+ if (!wq_mutation_backup(path, &mutation, &backup, errorText)) { wq_mutation_close(&mutation); return NO; }
+ BOOL ok = YES;
+ int err = 0;
+ for (NSNumber *off in [runs.allKeys sortedArrayUsingSelector:@selector(compare:)]) {
+ NSData *run = runs[off];
+ for (NSUInteger done = 0; done < run.length; ) {
+ ssize_t n = pwrite(mutation.fd, (const uint8_t *)run.bytes + done, run.length - done, (off_t)(off.unsignedLongLongValue + done));
+ if (n < 0 && errno == EINTR) continue;
+ if (n <= 0) { ok = NO; err = n == 0 ? EIO : errno; break; }
+ done += (NSUInteger)n;
+ }
+ if (!ok) break;
+ }
+ return wq_mutation_finish(path, &mutation, backup, ok, ok ? nil : wq_errno(err), errorText,
+ savedVersion, nil, verify ? runs : nil);
+}
+
+BOOL wq_patch_if_unchanged_get_version(NSString *path, NSDictionary *runs,
+ NSDictionary *expected, NSDictionary **savedVersion, NSString **errorText)
+{
+ return wq_patch_checked(path, runs, expected, savedVersion, YES, errorText);
+}
+
+BOOL wq_patch_if_unchanged(NSString *path, NSDictionary *runs,
+ NSDictionary *expected, NSString **errorText)
+{
+ return wq_patch_checked(path, runs, expected, nil, NO, errorText);
+}
+
+BOOL wq_patch(NSString *path, NSDictionary *runs, NSString **errorText)
+{
+ return wq_patch_if_unchanged(path, runs, nil, errorText);
+}
+
+BOOL wq_backup_unit_deletable(NSString *path)
+{
+ NSString *canonical = WQCanonicalVarPath(path);
+ if (!WQAbsolutePathGrammar(canonical) || [canonical.lastPathComponent hasPrefix:@"."]) return NO;
+ NSString *parentPath = canonical.stringByDeletingLastPathComponent;
+ NSString *grandparentPath = parentPath.stringByDeletingLastPathComponent;
+ NSString *fileRoot = WQCanonicalVarPath(wq_backup_root());
+ NSString *appRoot = WQCanonicalVarPath(wq_app_backup_root());
+ if (fileRoot.length &&
+ [canonical hasPrefix:[fileRoot stringByAppendingString:@"/"]] &&
+ [parentPath isEqualToString:fileRoot]) return YES;
+ if (appRoot.length &&
+ [canonical hasPrefix:[appRoot stringByAppendingString:@"/"]] &&
+ ([parentPath isEqualToString:appRoot] || [grandparentPath isEqualToString:appRoot])) return YES;
+ return NO;
+}
+
+BOOL wq_delete_backup(NSString *path, NSString **errorText)
+{
+ if (!wq_backup_unit_deletable(path)) {
+ if (errorText) *errorText = WQLocalized(@"Only a complete backup unit can be deleted here.");
+ return NO;
+ }
+ NSString *canonical = WQCanonicalVarPath(path);
+ NSString *parentPath = canonical.stringByDeletingLastPathComponent;
+ int parent = wq_open_directory_flags(parentPath, O_SEARCH);
+ if (parent < 0) { if (errorText) *errorText = wq_errno(errno); return NO; }
+ const char *name = canonical.lastPathComponent.fileSystemRepresentation;
+ struct stat st;
+ if (fstatat(parent, name, &st, AT_SYMLINK_NOFOLLOW) != 0) {
+ if (errorText) *errorText = wq_errno(errno);
+ close(parent);
+ return NO;
+ }
+ int rc;
+ if (S_ISDIR(st.st_mode)) {
+ unsigned long long ignored = 0;
+ if (!wq_purge_contents_cancellable(canonical, &ignored, nil)) {
+ if (errorText) *errorText = WQLocalized(@"Could not remove everything inside the backup.");
+ close(parent);
+ return NO;
+ }
+ rc = unlinkat(parent, name, AT_REMOVEDIR);
+ } else {
+ rc = unlinkat(parent, name, 0);
+ }
+ int err = errno;
+ close(parent);
+ wq_log(@"delete backup: %@ -> %@", path, rc == 0 ? @"OK" : wq_errno(err));
+ if (rc != 0 && errorText) *errorText = wq_errno(err);
+ return rc == 0;
+}
+
+static int wq_open_directory_flags(NSString *path, int flags)
+{
+ int fd = WQOpenDirectoryNoLinksFlags(path, flags);
+ if (fd < 0 && wq_is_denied(errno) && wq_grant(path)) fd = WQOpenDirectoryNoLinksFlags(path, flags);
+ return fd;
+}
+
+static int wq_open_directory(NSString *path)
+{
+ return wq_open_directory_flags(path, O_RDONLY);
+}
+
+BOOL wq_create_file(NSString *path, NSData *data, NSString **errorText)
+{
+ if (wq_is_backup_storage_path(path)) {
+ if (errorText) *errorText = WQLocalized(@"Backup storage cannot be modified through the file browser.");
+ return NO;
+ }
+ if (!WQAbsolutePathGrammar(WQCanonicalVarPath(path))) {
+ if (errorText) *errorText = WQLocalized(@"Invalid file path");
+ return NO;
+ }
+ NSString *parentPath = path.stringByDeletingLastPathComponent;
+ int parent = wq_open_directory_flags(parentPath, O_SEARCH);
+ if (parent < 0) { if (errorText) *errorText = wq_errno(errno); return NO; }
+ const char *name = path.lastPathComponent.fileSystemRepresentation;
+ int fd = WQOpenRegularAt(parent, name, O_WRONLY | O_CREAT | O_EXCL, 0644);
+ if (fd < 0 && wq_is_denied(errno) && wq_grant(parentPath))
+ fd = WQOpenRegularAt(parent, name, O_WRONLY | O_CREAT | O_EXCL, 0644);
+ if (fd < 0) {
+ if (errorText) *errorText = wq_errno(errno);
+ close(parent);
+ return NO;
+ }
+ BOOL ok = wq_write_all(fd, data);
+ int err = errno;
+ ok = wq_finish_fd(fd, ok, &err);
+ if (ok && fsync(parent) != 0) { ok = NO; err = errno; }
+ if (!ok) unlinkat(parent, name, 0);
+ close(parent);
+ wq_log(@"create: %@ (%lu bytes) -> %@", path, (unsigned long)data.length, ok ? @"OK" : wq_errno(err));
+ if (!ok && errorText) *errorText = wq_errno(err);
+ return ok;
+}
+
+BOOL wq_create_dir(NSString *path, NSString **errorText)
+{
+ if (wq_is_backup_storage_path(path)) {
+ if (errorText) *errorText = WQLocalized(@"Backup storage cannot be modified through the file browser.");
+ return NO;
+ }
+ if (!WQAbsolutePathGrammar(WQCanonicalVarPath(path))) {
+ if (errorText) *errorText = WQLocalized(@"Invalid folder path");
+ return NO;
+ }
+ NSString *parentPath = path.stringByDeletingLastPathComponent;
+ int parent = wq_open_directory_flags(parentPath, O_SEARCH);
+ if (parent < 0) { if (errorText) *errorText = wq_errno(errno); return NO; }
+ const char *name = path.lastPathComponent.fileSystemRepresentation;
+ int rc = mkdirat(parent, name, 0755);
+ if (rc != 0 && wq_is_denied(errno) && wq_grant(parentPath))
+ rc = mkdirat(parent, name, 0755);
+ int err = errno;
+ if (rc == 0 && fsync(parent) != 0) { rc = -1; err = errno; }
+ close(parent);
+ wq_log(@"mkdir: %@ -> %@", path, rc == 0 ? @"OK" : wq_errno(err));
+ if (rc != 0 && errorText) *errorText = wq_errno(err);
+ return rc == 0;
+}
+
+BOOL wq_delete(NSString *path, NSString **errorText)
+{
+ if (wq_is_backup_storage_path(path)) {
+ if (errorText) *errorText = WQLocalized(@"Backup storage cannot be overwritten or deleted through the file browser.");
+ return NO;
+ }
+ if (!WQAbsolutePathGrammar(WQCanonicalVarPath(path))) {
+ if (errorText) *errorText = WQLocalized(@"Invalid file path");
+ return NO;
+ }
+ NSString *parentPath = path.stringByDeletingLastPathComponent;
+ int parent = wq_open_directory_flags(parentPath, O_SEARCH);
+ if (parent < 0) { if (errorText) *errorText = wq_errno(errno); return NO; }
+ const char *name = path.lastPathComponent.fileSystemRepresentation;
+ struct stat st;
+ if (fstatat(parent, name, &st, AT_SYMLINK_NOFOLLOW) != 0 &&
+ !(wq_is_denied(errno) && wq_grant(parentPath) &&
+ fstatat(parent, name, &st, AT_SYMLINK_NOFOLLOW) == 0)) {
+ if (errorText) *errorText = errno == ENOENT ? WQLocalized(@"Not found") : wq_errno(errno);
+ close(parent);
+ return NO;
+ }
+ if (S_ISREG(st.st_mode)) {
+ close(parent);
+ WQMutation mutation;
+ if (!wq_mutation_open(path, wq_version_for_stat(&st), &mutation, O_RDONLY, errorText)) return NO;
+ NSString *backup = nil;
+ if (!wq_mutation_backup(path, &mutation, &backup, errorText)) { wq_mutation_close(&mutation); return NO; }
+ int rc = unlinkat(mutation.parent, name, 0);
+ int err = errno;
+ wq_mutation_close(&mutation);
+ if (rc != 0 && errorText) *errorText = wq_errno(err);
+ wq_log(@"delete: %@ -> %@", path, rc == 0 ? @"OK" : wq_errno(err));
+ return rc == 0;
+ }
+ int flags = S_ISDIR(st.st_mode) ? AT_REMOVEDIR : 0;
+ int rc = unlinkat(parent, name, flags);
+ if (rc != 0 && wq_is_denied(errno) && wq_grant(parentPath))
+ rc = unlinkat(parent, name, flags);
+ int err = errno;
+ close(parent);
+ wq_log(@"delete: %@ -> %@", path, rc == 0 ? @"OK" : wq_errno(err));
+ if (rc != 0 && errorText) *errorText = err == ENOTEMPTY ? WQLocalized(@"Only empty folders can be deleted") : wq_errno(err);
+ return rc == 0;
+}
+
+BOOL wq_rename(NSString *path, NSString *newName, NSString **errorText)
+{
+ if (!WQAbsolutePathGrammar(WQCanonicalVarPath(path)) || newName.length == 0 || [newName containsString:@"/"] ||
+ [newName isEqualToString:@"."] || [newName isEqualToString:@".."] || [newName rangeOfString:@"\0"].location != NSNotFound) {
+ if (errorText) *errorText = WQLocalized(@"Invalid name");
+ return NO;
+ }
+ if (wq_is_backup_storage_path(path) ||
+ wq_is_backup_storage_path([path.stringByDeletingLastPathComponent stringByAppendingPathComponent:newName])) {
+ if (errorText) *errorText = WQLocalized(@"Backup storage cannot be renamed through the file browser.");
+ return NO;
+ }
+ NSString *parentPath = path.stringByDeletingLastPathComponent;
+ int parent = wq_open_directory_flags(parentPath, O_SEARCH);
+ if (parent < 0) { if (errorText) *errorText = wq_errno(errno); return NO; }
+ WQ_TREE_HOOK(@"renameReady", [parentPath stringByAppendingPathComponent:newName]);
+ int rc = renameatx_np(parent, path.lastPathComponent.fileSystemRepresentation, parent, newName.fileSystemRepresentation, RENAME_EXCL);
+ if (rc != 0 && wq_is_denied(errno) && wq_grant(parentPath))
+ rc = renameatx_np(parent, path.lastPathComponent.fileSystemRepresentation, parent, newName.fileSystemRepresentation, RENAME_EXCL);
+ int err = errno;
+ close(parent);
+ wq_log(@"rename: %@ -> %@: %@", path, newName, rc == 0 ? @"OK" : wq_errno(err));
+ if (rc != 0 && errorText) *errorText = err == EEXIST ? WQLocalized(@"An item with that name already exists") : wq_errno(err);
+ return rc == 0;
+}
+
+BOOL wq_move(NSString *path, NSString *destDir, NSString **errorText)
+{
+ NSString *source = WQCanonicalVarPath(path), *target = WQCanonicalVarPath(destDir);
+ NSString *name = source.lastPathComponent;
+ if (!WQAbsolutePathGrammar(source) || !WQAbsolutePathGrammar(target) || !name.length) {
+ if (errorText) *errorText = WQLocalized(@"Invalid path");
+ return NO;
+ }
+ NSString *sourceParent = source.stringByDeletingLastPathComponent;
+ if ([sourceParent isEqualToString:target]) {
+ if (errorText) *errorText = WQLocalized(@"It is already in this folder");
+ return NO;
+ }
+ if ([target isEqualToString:source] || [target hasPrefix:[source stringByAppendingString:@"/"]]) {
+ if (errorText) *errorText = WQLocalized(@"A folder can't be moved into itself");
+ return NO;
+ }
+ if (wq_is_backup_storage_path(source) || wq_is_backup_storage_path([target stringByAppendingPathComponent:name])) {
+ if (errorText) *errorText = WQLocalized(@"Backup storage cannot be changed through the file browser.");
+ return NO;
+ }
+ int from = wq_open_directory_flags(sourceParent, O_SEARCH);
+ if (from < 0) { if (errorText) *errorText = wq_errno(errno); return NO; }
+ int to = wq_open_directory_flags(target, O_SEARCH);
+ if (to < 0) { if (errorText) *errorText = wq_errno(errno); close(from); return NO; }
+ const char *n = name.fileSystemRepresentation;
+ // RENAME_EXCL: never replaces an item that is already there.
+ int rc = renameatx_np(from, n, to, n, RENAME_EXCL);
+ if (rc != 0 && wq_is_denied(errno)) {
+ wq_grant(sourceParent);
+ wq_grant(target);
+ wq_grant(source);
+ rc = renameatx_np(from, n, to, n, RENAME_EXCL);
+ }
+ int err = errno;
+ if (rc == 0) { fsync(to); fsync(from); }
+ close(from);
+ close(to);
+ wq_log(@"move: %@ -> %@: %@", path, destDir, rc == 0 ? @"OK" : wq_errno(err));
+ if (rc != 0 && errorText) {
+ *errorText = err == EEXIST ? WQLocalizedFormat(@"An item named \"%@\" is already there", name)
+ : err == EXDEV ? WQLocalized(@"Items can't be moved to another volume")
+ : wq_errno(err);
+ }
+ return rc == 0;
+}
+
+int wq_open_for_reading(NSString *path)
+{
+ return wq_open(path, O_RDONLY, 0);
+}
+
+// fsgetpath: resolve a filesystem object id to a path. Not in the public SDK.
+extern ssize_t fsgetpath(char *buf, size_t buflen, fsid_t *fsid, uint64_t objid);
+
+// Highest object id in use on the volume, roughly: APFS hands out ids in
+// increasing order, so a file created now gets one of the newest. Containers
+// of apps installed long after setup sit far above the ids of Apple's own
+// apps; a fixed limit misses them on a well-used device.
+static const int64_t kWQInodeMargin = 100000;
+
+static int64_t wq_volume_max_inode(fsid_t fsid, int64_t *probeInode)
+{
+ if (probeInode) *probeInode = 0;
+#ifdef WQ_ACCESS_TESTING
+ if (wq_test_volume_limit >= 0) return wq_test_volume_limit;
+#endif
+ NSArray *directories = @[
+ NSTemporaryDirectory() ?: @"",
+ [NSHomeDirectory() stringByAppendingPathComponent:@"Documents"],
+ [NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES) firstObject] ?: @"",
+ ];
+ for (NSString *directory in directories) {
+ if (!directory.length) continue;
+ NSString *probe = [directory stringByAppendingPathComponent:
+ [NSString stringWithFormat:@"wq-inode-%u", arc4random()]];
+ int fd = open(probe.fileSystemRepresentation, O_CREAT | O_EXCL | O_WRONLY, 0600);
+ if (fd < 0) {
+ wq_log(@"inode probe: cannot create %@ (%@)", probe, wq_errno(errno));
+ continue;
+ }
+ struct stat st = {0};
+ struct statfs sfs = {0};
+ BOOL ok = fstat(fd, &st) == 0 && fstatfs(fd, &sfs) == 0;
+ int err = errno;
+ close(fd);
+ unlink(probe.fileSystemRepresentation);
+ if (!ok) {
+ wq_log(@"inode probe: cannot inspect %@ (%@)", probe, wq_errno(err));
+ continue;
+ }
+ if (memcmp(&sfs.f_fsid, &fsid, sizeof(fsid)) != 0) {
+ wq_log(@"inode probe: %@ is on a different volume", probe);
+ continue;
+ }
+ if ((uint64_t)st.st_ino > (uint64_t)INT64_MAX - (uint64_t)kWQInodeMargin) return 0;
+ if (probeInode) *probeInode = (int64_t)st.st_ino;
+ return (int64_t)st.st_ino + kWQInodeMargin;
+ }
+ return 0;
+}
+
+// Scans object ids first...last once and collects the direct children of every
+// base in `bases` (each a /private/var/... path), as path -> object id. Uses at
+// most 4 worker threads (half the cores) at `qos`: a full scan of a well-used
+// device is millions of ids, and running it on every core made phones hot.
+// progress gets the ids done, the range size and the hits per base. Returns
+// one dictionary per base, or nil when cancelled.
+typedef void (^WQScanProgress)(int64_t done, int64_t total, const NSUInteger *counts);
+static NSArray *> *wq_scan_children(fsid_t fsid, int64_t firstId, int64_t lastId,
+ NSArray *bases,
+ WQScanCancellation *cancellation,
+ qos_class_t qos, BOOL allCores,
+ WQScanProgress progress)
+{
+ NSUInteger baseCount = MIN(bases.count, (NSUInteger)8);
+ // Heap tables: blocks can't capture C arrays.
+ char **prefixes = calloc(8, sizeof(char *));
+ size_t *prefixLens = calloc(8, sizeof(size_t));
+ NSUInteger *counts = calloc(8, sizeof(NSUInteger));
+ if (!prefixes || !prefixLens || !counts) { free(prefixes); free(prefixLens); free(counts); return nil; }
+ NSMutableArray *> *found = [NSMutableArray new];
+ for (NSUInteger i = 0; i < baseCount; i++) {
+ prefixes[i] = strdup(bases[i].fileSystemRepresentation);
+ prefixLens[i] = strlen(prefixes[i]);
+ [found addObject:[NSMutableDictionary new]];
+ }
+ if (firstId < 1) firstId = 1;
+ int64_t total = lastId >= firstId ? lastId - firstId + 1 : 0;
+ NSLock *lock = [NSLock new];
+ const int64_t chunk = 32768;
+ int64_t chunks = (total + chunk - 1) / chunk;
+ __block int64_t nextChunk = 0, done = 0;
+ NSUInteger cores = NSProcessInfo.processInfo.activeProcessorCount;
+ // A full scan the user agreed to wait for uses all but two cores; the
+ // short incremental scans stay at half the cores.
+ size_t workers = allCores ? (size_t)MAX((NSUInteger)2, MIN((NSUInteger)8, cores > 2 ? cores - 2 : 1))
+ : (size_t)MAX((NSUInteger)1, MIN((NSUInteger)4, cores / 2));
+ dispatch_apply(workers, dispatch_get_global_queue(qos, 0), ^(size_t __unused w) {
+ fsid_t id = fsid;
+ char buf[1200];
+ for (;;) {
+ if (cancellation.isCancelled) return;
+ [lock lock];
+ int64_t c = nextChunk++;
+ [lock unlock];
+ if (c >= chunks) return;
+ int64_t first = firstId + c * chunk, last = MIN(first + chunk - 1, lastId);
+ NSMutableDictionary *local[8] = {nil};
+ for (int64_t ino = first; ino <= last; ino++) {
+ if ((ino & 127) == 0 && cancellation.isCancelled) return;
+ ssize_t n = fsgetpath(buf, sizeof(buf), &id, (uint64_t)ino);
+ if (n <= 0) continue;
+ // fsgetpath returns /private/var/...; bases are normalized the same way.
+ for (NSUInteger i = 0; i < baseCount; i++) {
+ if (strncmp(buf, prefixes[i], prefixLens[i]) == 0 && buf[prefixLens[i]] == '/' &&
+ strchr(buf + prefixLens[i] + 1, '/') == NULL) {
+ if (!local[i]) local[i] = [NSMutableDictionary new];
+ local[i][[NSString stringWithUTF8String:buf]] = @(ino);
+ break;
+ }
+ }
+ }
+ NSUInteger snapshot[8] = {0};
+ [lock lock];
+ for (NSUInteger i = 0; i < baseCount; i++) {
+ if (local[i]) [found[i] addEntriesFromDictionary:local[i]];
+ snapshot[i] = counts[i] = found[i].count;
+ }
+ done += last - first + 1;
+ int64_t nowDone = done;
+ [lock unlock];
+ if (progress && !cancellation.isCancelled) progress(nowDone, total, snapshot);
+ }
+ });
+ for (NSUInteger i = 0; i < baseCount; i++) free(prefixes[i]);
+ free(prefixes);
+ free(prefixLens);
+ free(counts);
+ if (cancellation.isCancelled) return nil;
+ return found;
+}
+
+static NSArray *wq_sorted_paths(NSDictionary *hits)
+{
+ return [hits.allKeys sortedArrayUsingSelector:@selector(compare:)];
+}
+
+static void wq_check_scan_completion(fsid_t fsid, int64_t probeInode, NSString *what)
+{
+ if (probeInode <= 0) return;
+ int64_t afterProbe = 0;
+ if (wq_volume_max_inode(fsid, &afterProbe) <= 0 || afterProbe <= 0) {
+ // A completion probe failure must not turn a usable partial list
+ // into "no apps at all". The initial bound still found what it
+ // scanned; callers can refresh later.
+ wq_log(@"enumerate %@: completion probe unavailable; returning the scanned subset", what);
+ } else if (afterProbe > probeInode + kWQInodeMargin) {
+ wq_log(@"enumerate %@: inode IDs advanced beyond the scanned bound; the list may be incomplete until refreshed", what);
+ }
+}
+
+// The three container parents that can only be listed by scanning. One scan
+// collects all three and is reused for a while: the Cache Cleaner alone used to
+// scan the whole id range three times (apps, extensions, and the daemon scan
+// that finds the app name database).
+static NSArray *wq_shared_scan_roots(void)
+{
+ return @[ @"/private/var/mobile/Containers/Data/Application",
+ @"/private/var/mobile/Containers/Data/PluginKitPlugin",
+ @"/private/var/mobile/Containers/Data/InternalDaemon" ];
+}
+
+#pragma mark - Persistent container scan
+//
+// APFS hands out object ids in increasing order and never reuses them, so a
+// container created after a scan has an id above that scan's probe id. The
+// result is kept on disk with the probe id: the next scan only checks the
+// ids created since, and confirms each remembered container by resolving
+// its id again (gone or moved containers are dropped). A full scan still
+// runs once a week, or when the volume or its ids don't match.
+
+static const NSTimeInterval kWQFullScanInterval = 7 * 24 * 3600;
+static const NSInteger kWQScanCacheVersion = 1;
+
+static NSString *wq_scan_cache_path(void)
+{
+#ifdef WQ_ACCESS_TESTING
+ return nil;
+#else
+ NSString *dir = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES).firstObject;
+ return dir.length ? [dir stringByAppendingPathComponent:@"wq-container-scan.plist"] : nil;
+#endif
+}
+
+static NSDictionary *wq_scan_cache_load(fsid_t fsid, int64_t probeInode, NSTimeInterval now, NSString **whyFull)
+{
+ NSString *path = wq_scan_cache_path();
+ if (!path) return nil;
+ NSDictionary *cache = [NSDictionary dictionaryWithContentsOfFile:path];
+ if (![cache isKindOfClass:NSDictionary.class]) { *whyFull = @"no saved scan"; return nil; }
+ NSNumber *version = cache[@"version"], *f0 = cache[@"fsid0"], *f1 = cache[@"fsid1"], *probe = cache[@"probe"], *fullAt = cache[@"fullAt"];
+ NSDictionary *roots = cache[@"roots"];
+ for (id value in @[version ?: NSNull.null, f0 ?: NSNull.null, f1 ?: NSNull.null, probe ?: NSNull.null, fullAt ?: NSNull.null])
+ if (![value isKindOfClass:NSNumber.class]) { *whyFull = @"saved scan unreadable"; return nil; }
+ if (version.integerValue != kWQScanCacheVersion || ![roots isKindOfClass:NSDictionary.class]) { *whyFull = @"saved scan unreadable"; return nil; }
+ if (f0.intValue != fsid.val[0] || f1.intValue != fsid.val[1]) { *whyFull = @"different volume"; return nil; }
+ if (probe.longLongValue <= 0 || probe.longLongValue > probeInode) { *whyFull = @"volume ids were reset"; return nil; }
+ NSTimeInterval age = now - fullAt.doubleValue;
+ if (age < 0 || age >= kWQFullScanInterval) { *whyFull = @"weekly full scan"; return nil; }
+ for (NSString *root in wq_shared_scan_roots())
+ if (![roots[root] isKindOfClass:NSDictionary.class]) { *whyFull = @"saved scan unreadable"; return nil; }
+ return cache;
+}
+
+static void wq_scan_cache_save(fsid_t fsid, int64_t probeInode, NSTimeInterval fullAt, NSDictionary *roots)
+{
+ NSString *path = wq_scan_cache_path();
+ if (!path) return;
+ NSDictionary *cache = @{ @"version": @(kWQScanCacheVersion), @"fsid0": @(fsid.val[0]), @"fsid1": @(fsid.val[1]),
+ @"probe": @(probeInode), @"fullAt": @(fullAt), @"roots": roots };
+ if (![cache writeToFile:path atomically:YES]) wq_log(@"scan: cannot save the container list to %@", path);
+}
+
+// Drops remembered containers whose id no longer resolves to the same path.
+static NSMutableDictionary *wq_scan_verify(fsid_t fsid, NSDictionary *remembered, NSUInteger *dropped)
+{
+ NSMutableDictionary *kept = [NSMutableDictionary new];
+ char buf[1200];
+ for (NSString *path in remembered) {
+ NSNumber *ino = remembered[path];
+ if (![path isKindOfClass:NSString.class] || ![ino isKindOfClass:NSNumber.class]) { (*dropped)++; continue; }
+ fsid_t id = fsid;
+ ssize_t n = fsgetpath(buf, sizeof(buf), &id, ino.unsignedLongLongValue);
+ if (n > 0 && strcmp(buf, path.fileSystemRepresentation) == 0) kept[path] = ino;
+ else (*dropped)++;
+ }
+ return kept;
+}
+
+// One shared scan runs at a time, detached from the screen that started it:
+// leaving the screen no longer throws the work away. Waiters stop waiting when
+// cancelled; the scan finishes and is reused by the next caller.
+static const NSTimeInterval kWQSharedScanTTL = 600;
+static NSObject *gSharedScanGuard;
+static void (^gScanProgress)(int64_t, int64_t, NSUInteger);
+static NSUInteger gScanProgressIndex;
+static id gScanProgressOwner;
+
+static void wq_shared_scan_setup(void)
+{
+ static dispatch_once_t once;
+ dispatch_once(&once, ^{ gSharedScanGuard = [NSObject new]; });
+}
+
+static void wq_scan_slots_clear(void);
+
+void wq_enumerate_invalidate(void)
+{
+ wq_shared_scan_setup();
+ @synchronized (gSharedScanGuard) { wq_scan_slots_clear(); }
+}
+
+
+// The first few path components, with UUIDs collapsed, so the Log shows which
+// areas are visible without listing individual files.
+static NSString *wq_sample_area(NSString *path)
+{
+ NSArray *parts = path.pathComponents;
+ NSMutableArray *kept = [NSMutableArray new];
+ for (NSString *part in parts) {
+ if (kept.count >= 7) break;
+ BOOL uuid = part.length == 36 && [[NSUUID alloc] initWithUUIDString:part] != nil;
+ [kept addObject:uuid ? @"*" : part];
+ if (uuid && kept.count >= 6) break;
+ }
+ return [NSString pathWithComponents:kept];
+}
+
+// LaunchServices API: each app's and plug-in's data container, where the
+// system lets this app ask (it refuses on iOS 27).
+static NSDictionary *gLSAPILabels;
+
+static NSDictionary *> *wq_lsapi_list_containers(NSArray *roots, NSString **summary)
+{
+ Class workspaceClass = NSClassFromString(@"LSApplicationWorkspace");
+ SEL defaultSel = NSSelectorFromString(@"defaultWorkspace");
+ SEL appsSel = NSSelectorFromString(@"allInstalledApplications");
+ if (!workspaceClass || ![workspaceClass respondsToSelector:defaultSel]) { *summary = @"LaunchServices API: unavailable"; return nil; }
+ NSMutableDictionary *labels = [NSMutableDictionary new];
+ NSUInteger proxies = 0, plugins = 0;
+ @try {
+ id (*send)(id, SEL) = (id (*)(id, SEL))objc_msgSend;
+ id workspace = send(workspaceClass, defaultSel);
+ if (![workspace respondsToSelector:appsSel]) { *summary = @"LaunchServices API: no application list"; return nil; }
+ NSArray *apps = send(workspace, appsSel);
+ SEL containerSel = NSSelectorFromString(@"dataContainerURL"), idSel = NSSelectorFromString(@"bundleIdentifier");
+ SEL pluginsSel = NSSelectorFromString(@"plugInKitPlugins");
+ void (^add)(id) = ^(id proxy) {
+ if (![proxy respondsToSelector:containerSel] || ![proxy respondsToSelector:idSel]) return;
+ NSURL *url = send(proxy, containerSel);
+ NSString *bid = send(proxy, idSel);
+ if (![url isKindOfClass:NSURL.class] || ![bid isKindOfClass:NSString.class] || !url.path.length || !bid.length) return;
+ NSString *path = wq_norm(url.path);
+ if (!labels[path]) labels[path] = bid;
+ };
+ for (id app in [apps isKindOfClass:NSArray.class] ? apps : @[]) {
+ proxies++;
+ add(app);
+ NSArray *appPlugins = [app respondsToSelector:pluginsSel] ? send(app, pluginsSel) : nil;
+ for (id plugin in [appPlugins isKindOfClass:NSArray.class] ? appPlugins : @[]) { plugins++; add(plugin); }
+ }
+ } @catch (NSException *exception) {
+ *summary = [NSString stringWithFormat:@"LaunchServices API: %@", exception.reason];
+ return nil;
+ }
+ NSMutableDictionary *found = [NSMutableDictionary new];
+ for (NSString *root in roots) found[root] = [NSMutableArray new];
+ for (NSString *path in labels) [found[path.stringByDeletingLastPathComponent] addObject:path];
+ NSMutableDictionary *result = [NSMutableDictionary new];
+ for (NSString *root in roots) result[root] = [found[root] sortedArrayUsingSelector:@selector(compare:)];
+ *summary = [NSString stringWithFormat:@"LaunchServices API: %lu apps and %lu plug-ins, %lu data containers (%lu apps, %lu extensions)",
+ (unsigned long)proxies, (unsigned long)plugins, (unsigned long)labels.count,
+ (unsigned long)[result[roots[0]] count], (unsigned long)[result[roots[1]] count]];
+ @synchronized (NSNull.null) { gLSAPILabels = [labels copy]; }
+ return result;
+}
+
+// The container manager can list containers directly (instant, no scan). On
+// releases where it answers this app only with its own containers, the list
+// has no other apps and the fsgetpath scan is used instead. Classes are not
+// relied on: every class is asked and paths are sorted by their parent.
+static NSDictionary *> *wq_mcm_list_containers(NSArray *roots, NSString **summary)
+{
+ NSMutableDictionary *> *found = [NSMutableDictionary new];
+ for (NSString *root in roots) found[root] = [NSMutableSet new];
+ NSMutableArray *perClass = [NSMutableArray new];
+ void *mgr = dlopen("/usr/lib/system/libsystem_containermanager.dylib", RTLD_NOW | RTLD_LOCAL);
+ if (!mgr) { *summary = @"container manager unavailable"; return nil; }
+ cq_create_fn create = (cq_create_fn)dlsym(mgr, "container_query_create");
+ cq_set_u64_fn setClass = (cq_set_u64_fn)dlsym(mgr, "container_query_set_class");
+ cq_iterate_fn iterate = (cq_iterate_fn)dlsym(mgr, "container_query_iterate_results_sync");
+ cq_free_fn freeQuery = (cq_free_fn)dlsym(mgr, "container_query_free");
+ co_get_path_fn getPath = (co_get_path_fn)dlsym(mgr, "container_object_get_path");
+ if (!create || !setClass || !iterate || !freeQuery || !getPath) {
+ dlclose(mgr);
+ *summary = @"container manager query unavailable";
+ return nil;
+ }
+ for (uint64_t cls = 1; cls <= 14; cls++) {
+ void *query = create();
+ if (!query) continue;
+ __block NSUInteger count = 0;
+ setClass(query, cls);
+ @try {
+ iterate(query, ^bool(void *object) {
+ const char *path = object ? getPath(object) : NULL;
+ if (!path || !path[0]) return true;
+ count++;
+ NSString *norm = wq_norm([NSString stringWithUTF8String:path] ?: @"");
+ NSString *parent = norm.stringByDeletingLastPathComponent;
+ [found[parent] addObject:norm];
+ return true;
+ });
+ } @catch (NSException *exception) {
+ wq_log(@"scan: container manager class %llu: %@", cls, exception.reason);
+ }
+ freeQuery(query);
+ if (count) [perClass addObject:[NSString stringWithFormat:@"%llu:%lu", cls, (unsigned long)count]];
+ }
+ dlclose(mgr);
+ NSMutableDictionary *result = [NSMutableDictionary new];
+ for (NSString *root in roots) result[root] = [found[root].allObjects sortedArrayUsingSelector:@selector(compare:)];
+ *summary = [NSString stringWithFormat:@"container manager: %lu apps, %lu extensions, %lu daemons (by class %@)",
+ (unsigned long)[result[roots[0]] count], (unsigned long)[result[roots[1]] count],
+ (unsigned long)[result[roots[2]] count], perClass.count ? [perClass componentsJoinedByString:@" "] : @"none"];
+ return result;
+}
+
+// Samples ids across the volume. On some releases fsgetpath resolves only the
+// caller's own files; then a full scan takes minutes and finds nothing.
+static BOOL wq_fsgetpath_sees_others(fsid_t fsid, int64_t maxInode, double *idsPerSecond)
+{
+ NSMutableArray *own = [NSMutableArray arrayWithObject:wq_norm(NSHomeDirectory())];
+ NSString *bundle = wq_norm(NSBundle.mainBundle.bundlePath.stringByDeletingLastPathComponent);
+ if (bundle.length) [own addObject:bundle];
+ NSString *group = wq_app_group();
+ NSURL *groupURL = group ? [NSFileManager.defaultManager containerURLForSecurityApplicationGroupIdentifier:group] : nil;
+ if (groupURL.path.length) [own addObject:wq_norm(groupURL.path)];
+ int64_t stride = MAX((int64_t)1, maxInode / 20000);
+ NSUInteger sampled = 0, resolved = 0, foreign = 0;
+ NSCountedSet *areas = [NSCountedSet new];
+ char buf[1200];
+ CFAbsoluteTime started = CFAbsoluteTimeGetCurrent();
+ for (int64_t ino = 1 + (int64_t)arc4random_uniform((uint32_t)MIN(stride, (int64_t)UINT32_MAX)); ino <= maxInode; ino += stride) {
+ sampled++;
+ fsid_t id = fsid;
+ if (fsgetpath(buf, sizeof(buf), &id, (uint64_t)ino) <= 0) continue;
+ resolved++;
+ NSString *path = [NSString stringWithUTF8String:buf];
+ BOOL mine = NO;
+ for (NSString *prefix in own) if ([path hasPrefix:prefix]) { mine = YES; break; }
+ if (!mine) {
+ foreign++;
+ [areas addObject:wq_sample_area(path)];
+ }
+ }
+ wq_log(@"scan: fsgetpath sample: %lu of %lu ids resolve, %lu outside WQ Files (ids up to %lld, %.1f s)",
+ (unsigned long)resolved, (unsigned long)sampled, (unsigned long)foreign, (long long)maxInode,
+ CFAbsoluteTimeGetCurrent() - started);
+ if (areas.count) {
+ NSArray *top = [areas.allObjects sortedArrayUsingComparator:^NSComparisonResult(NSString *a, NSString *b) {
+ NSUInteger ca = [areas countForObject:a], cb = [areas countForObject:b];
+ return ca > cb ? NSOrderedAscending : ca < cb ? NSOrderedDescending : [a compare:b];
+ }];
+ NSMutableArray *parts = [NSMutableArray new];
+ for (NSString *area in [top subarrayWithRange:NSMakeRange(0, MIN(top.count, (NSUInteger)12))])
+ [parts addObject:[NSString stringWithFormat:@"%@ x%lu", area, (unsigned long)[areas countForObject:area]]];
+ wq_log(@"scan: resolvable areas: %@", [parts componentsJoinedByString:@", "]);
+ }
+ double elapsed = CFAbsoluteTimeGetCurrent() - started;
+ if (idsPerSecond) *idsPerSecond = elapsed > 0 ? sampled / elapsed : 0;
+ return foreign > 0;
+}
+
+// Full scans (every file id) take minutes on a well-used device. The UI
+// confirms them first and shows their progress; without a UI hook (or when
+// the app isn't in front) they don't start.
+static BOOL (*gFullScanConfirm)(NSString *what, int64_t ids, double seconds);
+static void (*gFullScanProgress)(int64_t done, int64_t total, BOOL finished);
+static WQScanCancellation *gFullScanStop;
+static _Atomic BOOL gFullScanDeclined;
+
+void wq_set_full_scan_ui(BOOL (*confirm)(NSString *what, int64_t ids, double seconds),
+ void (*progress)(int64_t done, int64_t total, BOOL finished))
+{
+ gFullScanConfirm = confirm;
+ gFullScanProgress = progress;
+}
+
+void wq_full_scan_stop(void)
+{
+ @synchronized (NSNull.null) { [gFullScanStop cancel]; }
+}
+
+BOOL wq_full_scan_was_declined(void)
+{
+ return atomic_load(&gFullScanDeclined);
+}
+
+// Asks before a full scan. Returns the scan's stop token, or nil when the user
+// (or the missing UI) declined.
+static WQScanCancellation *wq_full_scan_begin(NSString *what, int64_t ids, double seconds)
+{
+ // Without an App Group no container can be opened or named, so a long
+ // scan would only produce a list of unusable folders.
+ if (!wq_app_group()) {
+ atomic_store(&gFullScanDeclined, YES);
+ wq_log(@"scan: no App Group; full scan of %@ skipped", what);
+ return nil;
+ }
+ if (!gFullScanConfirm || !gFullScanConfirm(what, ids, seconds)) {
+ atomic_store(&gFullScanDeclined, YES);
+ wq_log(@"scan: full scan of %@ not started (%lld ids, about %.0f s)", what, (long long)ids, seconds);
+ return nil;
+ }
+ atomic_store(&gFullScanDeclined, NO);
+ WQScanCancellation *stop = [WQScanCancellation new];
+ @synchronized (NSNull.null) { gFullScanStop = stop; }
+ return stop;
+}
+
+static void wq_full_scan_end(WQScanCancellation *stop)
+{
+ @synchronized (NSNull.null) { if (gFullScanStop == stop) gFullScanStop = nil; }
+ if (gFullScanProgress) gFullScanProgress(0, 0, YES);
+ if (stop.isCancelled) wq_log(@"scan: full scan stopped");
+}
+
+// Rough duration of a full scan from a single-thread sample rate.
+static double wq_full_scan_estimate(int64_t ids, double sampleRate)
+{
+ NSUInteger cores = NSProcessInfo.processInfo.activeProcessorCount;
+ double workers = MAX(2.0, MIN(8.0, cores > 2 ? (double)cores - 2 : 1.0));
+ double rate = (sampleRate > 0 ? sampleRate : 15000.0) * workers * 0.8;
+ return ids / rate;
+}
+
+static NSArray *(*gContainerListProvider)(NSString *storeHint);
+
+void wq_set_container_list_provider(NSArray *(*provider)(NSString *storeHint))
+{
+ gContainerListProvider = provider;
+}
+
+static BOOL wq_is_ls_store_name(const char *path)
+{
+ const char *name = strrchr(path, '/');
+ name = name ? name + 1 : path;
+ size_t len = strlen(name);
+ return len > 33 && strncmp(name, "com.apple.LaunchServices-", 25) == 0 && strcmp(name + len - 8, ".csstore") == 0;
+}
+
+// The LaunchServices database is rewritten on every app install or update, so
+// its id is among the newest: scan down from the top and stop at the first.
+static const CFTimeInterval kWQStoreSearchLimit = 20;
+
+static NSString *wq_scan_for_ls_store(fsid_t fsid, int64_t maxInode, qos_class_t qos, WQScanProgress progress)
+{
+ CFAbsoluteTime started = CFAbsoluteTimeGetCurrent();
+ const int64_t chunk = 32768;
+ int64_t chunks = (maxInode + chunk - 1) / chunk;
+ __block int64_t nextChunk = 0, done = 0;
+ __block NSString *found = nil;
+ NSLock *lock = [NSLock new];
+ WQScanCancellation *stop = [WQScanCancellation new];
+ NSUInteger cores = NSProcessInfo.processInfo.activeProcessorCount;
+ size_t workers = (size_t)MAX((NSUInteger)1, MIN((NSUInteger)4, cores / 2));
+ dispatch_apply(workers, dispatch_get_global_queue(qos, 0), ^(size_t __unused w) {
+ fsid_t id = fsid;
+ char buf[1200];
+ NSUInteger none[8] = {0};
+ for (;;) {
+ if (stop.isCancelled) return;
+ [lock lock];
+ int64_t c = nextChunk++;
+ [lock unlock];
+ if (c >= chunks) return;
+ // Normally found among the newest ids within seconds; past the
+ // limit the full scan is the better way (and asks first).
+ if (CFAbsoluteTimeGetCurrent() - started > kWQStoreSearchLimit) { [stop cancel]; return; }
+ int64_t last = maxInode - c * chunk, first = MAX((int64_t)1, last - chunk + 1);
+ for (int64_t ino = last; ino >= first; ino--) {
+ if ((ino & 127) == 0 && stop.isCancelled) return;
+ if (fsgetpath(buf, sizeof(buf), &id, (uint64_t)ino) <= 0 || !wq_is_ls_store_name(buf)) continue;
+ [lock lock];
+ if (!found) found = [NSString stringWithUTF8String:buf];
+ [lock unlock];
+ [stop cancel];
+ return;
+ }
+ [lock lock];
+ done += last - first + 1;
+ int64_t nowDone = done;
+ [lock unlock];
+ if (progress && !stop.isCancelled) progress(nowDone, maxInode, none);
+ }
+ });
+ return found;
+}
+
+// The LaunchServices database keeps containers of earlier installs that no
+// longer exist. Drop those (lstat says ENOENT even without access); keep
+// anything that exists or can't be checked.
+static NSArray *wq_existing_containers(NSArray *paths, NSUInteger *dropped)
+{
+ NSMutableArray *kept = [NSMutableArray arrayWithCapacity:paths.count];
+ struct stat st;
+ for (NSString *path in paths) {
+ if (lstat(path.fileSystemRepresentation, &st) != 0 && errno == ENOENT) { (*dropped)++; continue; }
+ [kept addObject:path];
+ }
+ return kept;
+}
+
+// The App Data and Extensions lists from the LaunchServices database.
+static NSDictionary *> *wq_containers_from_ls_store(NSArray *roots, fsid_t fsid,
+ qos_class_t qos, WQScanProgress progress)
+{
+ if (!gContainerListProvider) return nil;
+ CFAbsoluteTime started = CFAbsoluteTimeGetCurrent();
+ NSArray *paths = gContainerListProvider(nil);
+ NSString *how = @"remembered location";
+ if (paths.count < 2) {
+ int64_t probeInode = 0;
+ int64_t maxInode = wq_volume_max_inode(fsid, &probeInode);
+ if (maxInode <= 0) return nil;
+ NSString *store = wq_scan_for_ls_store(fsid, maxInode, qos, progress);
+ if (!store) {
+ wq_log(@"scan: no LaunchServices database found by id (%.1f s)", CFAbsoluteTimeGetCurrent() - started);
+ return nil;
+ }
+ paths = gContainerListProvider(store);
+ how = [NSString stringWithFormat:@"found by id in %.1f s", CFAbsoluteTimeGetCurrent() - started];
+ if (paths.count < 2) {
+ wq_log(@"scan: the LaunchServices database at %@ couldn't be read or lists no containers", store);
+ return nil;
+ }
+ }
+ NSUInteger stale = 0;
+ NSMutableArray *normalized = [NSMutableArray arrayWithCapacity:paths.count];
+ for (NSString *path in paths) [normalized addObject:wq_norm(path)];
+ paths = wq_existing_containers(normalized, &stale);
+ if (stale) wq_log(@"scan: %lu containers in the LaunchServices database no longer exist (earlier installs); skipped", (unsigned long)stale);
+ NSMutableDictionary *byRoot = [NSMutableDictionary new];
+ for (NSString *root in roots) byRoot[root] = [NSMutableArray new];
+ for (NSString *path in paths) [byRoot[path.stringByDeletingLastPathComponent] addObject:path];
+ if ([byRoot[roots[0]] count] < 2) return nil;
+ NSMutableDictionary *result = [NSMutableDictionary new];
+ for (NSString *root in roots) result[root] = [byRoot[root] sortedArrayUsingSelector:@selector(compare:)];
+ wq_log(@"scan: %lu apps, %lu extensions from the LaunchServices database (%@); daemon containers aren't listed there",
+ (unsigned long)[result[roots[0]] count], (unsigned long)[result[roots[1]] count], how);
+ return result;
+}
+
+
+// inodeOnly: skip the container manager and LaunchServices shortcuts and scan
+// ids (iOS 26 Internal Daemons, which no database lists).
+static NSDictionary *> *wq_run_shared_scan(fsid_t fsid, qos_class_t qos, BOOL inodeOnly)
+{
+ NSArray *roots = wq_shared_scan_roots();
+ atomic_store(&gFullScanDeclined, NO);
+ if (inodeOnly) goto inodeScan;
+ {
+ NSString *mcmSummary = nil;
+ NSDictionary *> *listed = wq_mcm_list_containers(roots, &mcmSummary);
+ wq_log(@"scan: %@", mcmSummary);
+ // Other apps' containers listed: no scan needed. A list with only this
+ // app's own container is not enough.
+ if ([listed[roots[0]] count] > 1) return listed;
+ NSString *lsSummary = nil;
+ NSDictionary *> *lsListed = wq_lsapi_list_containers(roots, &lsSummary);
+ wq_log(@"scan: %@", lsSummary);
+ if (!wq_system_areas_reachable() && [lsListed[roots[0]] count] > 1) {
+ // The API doesn't list daemon containers; those still need the scan
+ // on releases where it works, so only skip it when apps are covered.
+ NSMutableDictionary *merged = [lsListed mutableCopy];
+ if (![merged[roots[2]] count]) merged[roots[2]] = @[];
+ return merged;
+ }
+ {
+ // A full scan is tens of millions of ids (minutes, up to most of an
+ // hour); the database lists the same app and extension containers.
+ WQScanProgress report = ^(int64_t done, int64_t total, const NSUInteger *__unused counts) {
+ void (^progress)(int64_t, int64_t, NSUInteger) = nil;
+ @synchronized (gSharedScanGuard) { progress = gScanProgress; }
+ if (progress) progress(done, total, 0);
+ };
+ NSDictionary *fromStore = wq_containers_from_ls_store(roots, fsid, qos, report);
+ if (fromStore) return fromStore;
+ }
+ }
+inodeScan:;
+ int64_t probeInode = 0;
+ int64_t maxInode = wq_volume_max_inode(fsid, &probeInode);
+ if (maxInode <= 0) {
+ wq_log(@"enumerate containers: dynamic inode limit unavailable; refusing to present a fixed-range partial scan as complete");
+ return nil;
+ }
+ if (maxInode > INT64_MAX - 32768) {
+ wq_log(@"enumerate containers: inode bound is too large to scan safely");
+ return nil;
+ }
+ NSTimeInterval now = NSDate.date.timeIntervalSince1970;
+ NSString *whyFull = nil;
+ NSDictionary *cache = wq_scan_cache_load(fsid, probeInode, now, &whyFull);
+ int64_t firstId = 1;
+ NSTimeInterval fullAt = now;
+ NSUInteger dropped = 0;
+ NSMutableArray *> *known = [NSMutableArray new];
+ for (NSString *root in roots) {
+ [known addObject:cache ? wq_scan_verify(fsid, cache[@"roots"][root], &dropped) : [NSMutableDictionary new]];
+ }
+ if (cache) {
+ firstId = [cache[@"probe"] longLongValue] + 1;
+ fullAt = [cache[@"fullAt"] doubleValue];
+ }
+ double sampleRate = 0;
+ if (!cache && !wq_fsgetpath_sees_others(fsid, maxInode, &sampleRate)) {
+ wq_log(@"scan: on this iOS version fsgetpath only resolves WQ Files' own files, so containers can't be found by scanning");
+ return nil;
+ }
+ WQScanCancellation *stop = nil;
+ if (!cache) {
+ stop = wq_full_scan_begin(inodeOnly ? WQLocalized(@"Internal Daemons") : WQLocalized(@"the app folders"), maxInode,
+ wq_full_scan_estimate(maxInode, sampleRate));
+ if (!stop) return nil;
+ qos = QOS_CLASS_USER_INITIATED;
+ }
+ CFAbsoluteTime started = CFAbsoluteTimeGetCurrent();
+ WQScanProgress report = ^(int64_t done, int64_t total, const NSUInteger *counts) {
+ void (^progress)(int64_t, int64_t, NSUInteger) = nil;
+ NSUInteger index = 0;
+ @synchronized (gSharedScanGuard) { progress = gScanProgress; index = gScanProgressIndex; }
+ if (progress) progress(done, total, (index < roots.count ? known[index].count : 0) + (index < 8 ? counts[index] : 0));
+ if (stop && gFullScanProgress) gFullScanProgress(done, total, NO);
+ };
+ NSArray *lists = wq_scan_children(fsid, firstId, maxInode, roots, stop, qos, stop != nil, report);
+ if (stop) wq_full_scan_end(stop);
+ if (!lists) return nil;
+ NSMutableDictionary *saved = [NSMutableDictionary new];
+ NSMutableDictionary *scan = [NSMutableDictionary new];
+ for (NSUInteger i = 0; i < roots.count; i++) {
+ [known[i] addEntriesFromDictionary:lists[i]];
+ saved[roots[i]] = known[i];
+ scan[roots[i]] = wq_sorted_paths(known[i]);
+ }
+ double seconds = CFAbsoluteTimeGetCurrent() - started;
+ int64_t ids = maxInode >= firstId ? maxInode - firstId + 1 : 0;
+ wq_log(@"scan: %@ ids %lld-%lld (%lld ids) in %.1f s (%.0f ids/s, %@); %lu apps, %lu extensions, %lu daemons%@",
+ cache ? @"new" : [NSString stringWithFormat:@"full (%@)", whyFull ?: @"no saved scan"],
+ (long long)firstId, (long long)maxInode, (long long)ids, seconds, seconds > 0 ? ids / seconds : 0,
+ qos == QOS_CLASS_USER_INITIATED ? @"foreground" : @"background",
+ (unsigned long)known[0].count, (unsigned long)known[1].count, (unsigned long)known[2].count,
+ dropped ? [NSString stringWithFormat:@"; %lu removed since the last scan", (unsigned long)dropped] : @"");
+ wq_check_scan_completion(fsid, probeInode, @"containers");
+ wq_scan_cache_save(fsid, probeInode, fullAt, saved);
+ return scan;
+}
+
+// One scan per slot runs at a time. On iOS 26 the Internal Daemons id scan
+// (most of an hour the first time) has its own slot, so the lists that come
+// from the LaunchServices database never wait for it.
+@interface WQScanSlot : NSObject
+@property (nonatomic, copy) NSDictionary *> *scan;
+@property (nonatomic) NSTimeInterval at;
+@property (nonatomic, strong) dispatch_group_t group;
+@property (nonatomic, strong) NSMutableDictionary *box;
+@end
+@implementation WQScanSlot
+@end
+
+static WQScanSlot *wq_scan_slot(BOOL inode)
+{
+ static WQScanSlot *slots[2];
+ static dispatch_once_t once;
+ dispatch_once(&once, ^{ slots[0] = [WQScanSlot new]; slots[1] = [WQScanSlot new]; });
+ return slots[inode ? 1 : 0];
+}
+
+static void wq_scan_slots_clear(void)
+{
+ for (int i = 0; i < 2; i++) {
+ WQScanSlot *slot = wq_scan_slot(i == 1);
+ slot.scan = nil;
+ slot.at = 0;
+ }
+}
+
+static NSArray *wq_shared_children(NSString *base, fsid_t fsid, WQScanCancellation *cancellation,
+ void (^progress)(int64_t, int64_t, NSUInteger))
+{
+ wq_shared_scan_setup();
+ NSUInteger baseIndex = [wq_shared_scan_roots() indexOfObject:base];
+ // iOS 26 lists apps and extensions from the LaunchServices database; only
+ // Internal Daemons need the (long) id scan.
+ BOOL inodeOnly = [base isEqualToString:@"/private/var/mobile/Containers/Data/InternalDaemon"];
+ WQScanSlot *slot = wq_scan_slot(inodeOnly);
+ id owner = [NSObject new];
+ dispatch_group_t group = nil;
+ NSMutableDictionary *box = nil;
+ @synchronized (gSharedScanGuard) {
+ if (slot.scan && NSDate.date.timeIntervalSinceReferenceDate - slot.at < kWQSharedScanTTL) {
+ NSArray *cached = slot.scan[base];
+ if (progress) progress(1, 1, cached.count);
+ return cached;
+ }
+ if (progress) {
+ gScanProgress = [progress copy];
+ gScanProgressIndex = baseIndex;
+ gScanProgressOwner = owner;
+ }
+ if (!slot.group) {
+ // A screen waiting for the result gets foreground priority; the
+ // worker count stays at half the cores either way.
+ qos_class_t qos = progress ? QOS_CLASS_USER_INITIATED : QOS_CLASS_UTILITY;
+ dispatch_group_t started = dispatch_group_create();
+ NSMutableDictionary *startedBox = [NSMutableDictionary new];
+ slot.group = started;
+ slot.box = startedBox;
+ dispatch_group_enter(started);
+ dispatch_async(dispatch_get_global_queue(qos, 0), ^{
+ NSDictionary *scan = nil;
+ @try {
+ scan = wq_run_shared_scan(fsid, qos, inodeOnly);
+ } @catch (NSException *exception) {
+ wq_log(@"scan: exception %@", exception.reason);
+ }
+ @synchronized (gSharedScanGuard) {
+ if (scan) {
+ startedBox[@"scan"] = scan;
+ slot.scan = scan;
+ slot.at = NSDate.date.timeIntervalSinceReferenceDate;
+ }
+ slot.group = nil;
+ slot.box = nil;
+ }
+ dispatch_group_leave(started);
+ });
+ }
+ group = slot.group;
+ box = slot.box;
+ }
+ BOOL cancelled = NO;
+ while (dispatch_group_wait(group, dispatch_time(DISPATCH_TIME_NOW, 200 * NSEC_PER_MSEC)) != 0) {
+ if (cancellation.isCancelled) { cancelled = YES; break; }
+ }
+ NSArray *result = nil;
+ @synchronized (gSharedScanGuard) {
+ if (gScanProgressOwner == owner) { gScanProgress = nil; gScanProgressOwner = nil; }
+ if (!cancelled) result = box[@"scan"][base];
+ }
+ return result;
+}
+
+NSArray *wq_enumerate(NSString *dir, int64_t maxInode, WQScanCancellation *cancellation,
+ void (^progress)(int64_t, int64_t, NSUInteger))
+{
+ if (cancellation.isCancelled) return nil;
+ NSString *base = wq_norm(dir); // /private/var/... form
+ struct stat baseStat = {0};
+ int baseError = lstat(base.fileSystemRepresentation, &baseStat);
+ if (baseError == 0 && !S_ISDIR(baseStat.st_mode)) {
+ wq_log(@"enumerate %@: base is not a directory", dir);
+ return nil;
+ }
+ if (baseError != 0 && errno != EACCES && errno != EPERM) {
+ wq_log(@"enumerate %@: base is missing (%@)", dir, wq_errno(errno));
+ return nil;
+ }
+ struct statfs sfs;
+ if (statfs(base.fileSystemRepresentation, &sfs) != 0) {
+ // The container parent may not even be stat-able without a token. In
+ // that case fall back to the data volume's fsid and let fsgetpath do
+ // the work; a denied parent is not the same thing as a missing parent.
+ if (statfs("/private/var/mobile", &sfs) != 0) {
+ wq_log(@"enumerate %@: statfs failed (%@)", dir, wq_errno(errno));
+ return nil;
+ }
+ }
+ fsid_t fsid = sfs.f_fsid;
+ BOOL dynamicBound = maxInode <= 0;
+ if (dynamicBound && [wq_shared_scan_roots() containsObject:base])
+ return wq_shared_children(base, fsid, cancellation, progress);
+ // iOS 26: a full scan takes about 40 minutes; the LaunchServices database
+ // lists the group containers of installed apps.
+ if (dynamicBound && !wq_system_areas_reachable() && gContainerListProvider &&
+ [base isEqualToString:@"/private/var/mobile/Containers/Shared/AppGroup"]) {
+ NSArray *all = gContainerListProvider(nil);
+ if (all.count) {
+ NSMutableArray *groups = [NSMutableArray new];
+ for (NSString *path in all)
+ if ([wq_norm(path).stringByDeletingLastPathComponent isEqualToString:base]) [groups addObject:wq_norm(path)];
+ NSUInteger stale = 0;
+ groups = [wq_existing_containers(groups, &stale) mutableCopy];
+ [groups sortUsingSelector:@selector(compare:)];
+ wq_log(@"enumerate %@: %lu group containers from the LaunchServices database%@", dir, (unsigned long)groups.count,
+ stale ? [NSString stringWithFormat:@" (%lu no longer exist, skipped)", (unsigned long)stale] : @"");
+ if (progress) progress(1, 1, groups.count);
+ return groups;
+ }
+ }
+ int64_t probeInode = 0;
+ if (dynamicBound) {
+ maxInode = wq_volume_max_inode(fsid, &probeInode);
+ if (maxInode <= 0) {
+ wq_log(@"enumerate %@: dynamic inode limit unavailable; refusing to present a fixed-range partial scan as complete", dir);
+ return nil;
+ }
+ }
+ if (maxInode > INT64_MAX - 32768) {
+ wq_log(@"enumerate %@: inode bound is too large to scan safely", dir);
+ return nil;
+ }
+ WQScanCancellation *stop = nil;
+ if (dynamicBound) {
+ stop = wq_full_scan_begin(base.lastPathComponent, maxInode, wq_full_scan_estimate(maxInode, 0));
+ if (!stop) return nil;
+ }
+ // Either the caller's cancellation or the scan window's Stop ends it.
+ WQScanCancellation *either = stop ?: cancellation;
+ if (stop && cancellation) {
+ either = [WQScanCancellation new];
+ dispatch_async(dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{
+ while (!either.isCancelled) {
+ if (stop.isCancelled || cancellation.isCancelled) { [either cancel]; break; }
+ usleep(200000);
+ }
+ });
+ }
+ NSArray *lists = wq_scan_children(fsid, 1, maxInode, @[base], either, QOS_CLASS_USER_INITIATED, stop != nil,
+ ^(int64_t done, int64_t total, const NSUInteger *counts) {
+ if (progress) progress(done, total, counts[0]);
+ if (stop && gFullScanProgress) gFullScanProgress(done, total, NO);
+ });
+ if (stop) { wq_full_scan_end(stop); [either cancel]; }
+ if (!lists) return nil;
+ if (dynamicBound) wq_check_scan_completion(fsid, probeInode, dir);
+ return wq_sorted_paths(lists.firstObject);
+}
+
+BOOL wq_stat(NSString *path, struct stat *st)
+{
+ if (lstat(path.fileSystemRepresentation, st) == 0) return YES;
+ if (!wq_is_denied(errno)) return NO;
+ if (wq_grant(path.stringByDeletingLastPathComponent) && lstat(path.fileSystemRepresentation, st) == 0) return YES;
+ return NO;
+}
+
+WQMeasureResult wq_measure_tree(NSString *dir, WQScanCancellation *cancellation, unsigned long long *bytes)
+{
+ if (cancellation.isCancelled) return WQMeasureResultCancelled;
+ struct stat st = {0};
+ if (!wq_stat(dir, &st)) {
+ struct stat direct = {0};
+ if (lstat(dir.fileSystemRepresentation, &direct) != 0 &&
+ (errno == ENOENT || errno == ENOTDIR)) return WQMeasureResultMissing;
+ return WQMeasureResultLimited;
+ }
+ if (!S_ISDIR(st.st_mode)) return WQMeasureResultLimited;
+ if (![NSFileManager.defaultManager contentsOfDirectoryAtPath:dir error:nil]) wq_grant(dir);
+
+ NSURL *root = [NSURL fileURLWithPath:dir isDirectory:YES];
+ NSArray *keys = @[NSURLIsRegularFileKey, NSURLIsSymbolicLinkKey,
+ NSURLFileSizeKey, NSURLIsDirectoryKey];
+ __block BOOL limited = NO;
+ NSDirectoryEnumerator *enumerator = [NSFileManager.defaultManager
+ enumeratorAtURL:root includingPropertiesForKeys:keys options:0
+ errorHandler:^BOOL(NSURL *errorURL, NSError *error) {
+ limited = YES;
+ wq_log(@"measure: cannot enumerate %@ (%@)", errorURL.path, error.localizedDescription ?: @"unknown error");
+ return YES;
+ }];
+ if (!enumerator) return WQMeasureResultLimited;
+
+ unsigned long long total = 0;
+ for (NSURL *item in enumerator) {
+ if (cancellation.isCancelled) return WQMeasureResultCancelled;
+ NSNumber *isLink = nil, *isRegular = nil, *size = nil;
+ NSError *error = nil;
+ if (![item getResourceValue:&isLink forKey:NSURLIsSymbolicLinkKey error:&error] ||
+ ![item getResourceValue:&isRegular forKey:NSURLIsRegularFileKey error:&error]) {
+ limited = YES;
+ continue;
+ }
+ if (isLink.boolValue || !isRegular.boolValue) continue;
+ if (![item getResourceValue:&size forKey:NSURLFileSizeKey error:&error]) {
+ limited = YES;
+ continue;
+ }
+ total += size.unsignedLongLongValue;
+ }
+ if (cancellation.isCancelled) return WQMeasureResultCancelled;
+ if (bytes) *bytes = total;
+ return limited ? WQMeasureResultLimited : WQMeasureResultKnown;
+}
+
+static BOOL wq_directory_current(NSString *path, int fd)
+{
+ int current = WQOpenDirectoryNoLinksFlags(path, O_SEARCH);
+ struct stat a, b;
+ BOOL ok = current >= 0 && fstat(current, &a) == 0 && fstat(fd, &b) == 0 && WQSameObject(&a, &b);
+ if (current >= 0) close(current);
+ return ok;
+}
+
+static BOOL wq_remove_entry(int parent, NSString *name, NSString *path, unsigned long long *freed,
+ WQScanCancellation *cancellation);
+
+// Purge uses fd-relative removal, so the path is only a swap detector. One
+// lstat is enough; reopening every ancestor for every child is needlessly
+// expensive on large caches.
+static BOOL wq_purge_root_matches(NSString *path, int fd)
+{
+ struct stat pathStat, fdStat;
+ return lstat(path.fileSystemRepresentation, &pathStat) == 0 &&
+ fstat(fd, &fdStat) == 0 && WQSameObject(&pathStat, &fdStat);
+}
+
+static BOOL wq_purge_fd(int fd, NSString *path, unsigned long long *freed, WQScanCancellation *cancellation)
+{
+ int listing = dup(fd);
+ if (listing < 0) return NO;
+ DIR *dir = fdopendir(listing);
+ if (!dir) { close(listing); return NO; }
+ BOOL ok = YES;
+ for (;;) {
+ if (cancellation.isCancelled) { ok = NO; break; }
+ errno = 0;
+ struct dirent *entry = readdir(dir);
+ if (!entry) { if (errno) ok = NO; break; }
+ if (!strcmp(entry->d_name, ".") || !strcmp(entry->d_name, "..")) continue;
+ NSString *name = [NSFileManager.defaultManager stringWithFileSystemRepresentation:entry->d_name length:strlen(entry->d_name)];
+ if (!name) { ok = NO; break; }
+ WQ_TREE_HOOK(@"purgeChild", path);
+ // Fail closed on changed ancestry; all removal remains fd-relative even
+ // in the final race window. Symlink children are unlinked, never followed.
+ if (!wq_purge_root_matches(path, fd)) { ok = NO; break; }
+ if (!wq_remove_entry(fd, name, [path stringByAppendingPathComponent:name], freed, cancellation)) ok = NO;
+ }
+ if (closedir(dir) != 0) ok = NO;
+ return ok;
+}
+
+static BOOL wq_remove_entry(int parent, NSString *name, NSString *path, unsigned long long *freed,
+ WQScanCancellation *cancellation)
+{
+ if (cancellation.isCancelled) return NO;
+ const char *item = name.fileSystemRepresentation;
+ struct stat before, current;
+ if (fstatat(parent, item, &before, AT_SYMLINK_NOFOLLOW) != 0) return errno == ENOENT;
+ BOOL directory = S_ISDIR(before.st_mode);
+ if (directory) {
+ int child = openat(parent, item, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC);
+ if (child < 0 && wq_is_denied(errno) && wq_grant(path))
+ child = openat(parent, item, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC);
+ if (child < 0) return NO;
+ BOOL ok = fstat(child, ¤t) == 0 && WQSameObject(&before, ¤t) &&
+ wq_purge_root_matches(path, child) && wq_purge_fd(child, path, freed, cancellation);
+ close(child);
+ if (!ok) return NO;
+ }
+ if (fstatat(parent, item, ¤t, AT_SYMLINK_NOFOLLOW) != 0 || !WQSameObject(&before, ¤t) ||
+ (current.st_mode & S_IFMT) != (before.st_mode & S_IFMT)) return NO;
+ int rc = unlinkat(parent, item, directory ? AT_REMOVEDIR : 0);
+ if (rc != 0 && wq_is_denied(errno) && wq_grant(path.stringByDeletingLastPathComponent)) {
+ if (fstatat(parent, item, ¤t, AT_SYMLINK_NOFOLLOW) != 0 || !WQSameObject(&before, ¤t)) return NO;
+ rc = unlinkat(parent, item, directory ? AT_REMOVEDIR : 0);
+ }
+ // A user-immutable or append-only flag (on the item or its folder) makes
+ // unlink fail with EPERM even for the owner. The owner may clear those
+ // flags, so clear them and try once more. System flags stay untouched.
+ if (rc != 0 && errno == EPERM &&
+ fstatat(parent, item, ¤t, AT_SYMLINK_NOFOLLOW) == 0 && WQSameObject(&before, ¤t)) {
+ const uint32_t userLocks = UF_IMMUTABLE | UF_APPEND;
+ struct stat parentStat = {0};
+ BOOL cleared = NO;
+ if (fstat(parent, &parentStat) == 0 && (parentStat.st_flags & userLocks))
+ cleared = fchflags(parent, parentStat.st_flags & ~userLocks) == 0;
+ if ((current.st_flags & userLocks) && !S_ISLNK(current.st_mode)) {
+ int flagsFd = openat(parent, item, O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC |
+ (directory ? O_DIRECTORY : 0));
+ struct stat opened;
+ if (flagsFd >= 0 && fstat(flagsFd, &opened) == 0 && WQSameObject(&before, &opened) &&
+ fchflags(flagsFd, opened.st_flags & ~userLocks) == 0) cleared = YES;
+ if (flagsFd >= 0) close(flagsFd);
+ }
+ if (cleared) {
+ wq_log(@"purge: cleared lock flags (item 0x%x, folder 0x%x) on %@",
+ current.st_flags, parentStat.st_flags, path);
+ rc = unlinkat(parent, item, directory ? AT_REMOVEDIR : 0);
+ } else {
+ errno = EPERM;
+ }
+ }
+ if (rc == 0 && !directory && freed) *freed += (unsigned long long)before.st_size;
+ if (rc != 0) {
+ int err = errno;
+ wq_log(@"purge: %@ -> %@ (flags 0x%x, uid %u, mode %o)", path, wq_errno(err),
+ current.st_flags, current.st_uid, current.st_mode & 07777);
+ }
+ return rc == 0;
+}
+
+BOOL wq_purge_contents_cancellable(NSString *dir, unsigned long long *freed, WQScanCancellation *cancellation)
+{
+ if (cancellation.isCancelled) return NO;
+ if (!WQAbsolutePathGrammar(WQCanonicalVarPath(dir))) return NO;
+ int fd = wq_open_directory(dir);
+ if (fd < 0) {
+ if (errno == ENOENT) return YES;
+ wq_log(@"purge: %@ -> unsafe ancestry or inaccessible (%@)", dir, wq_errno(errno));
+ return NO;
+ }
+ BOOL ok = wq_purge_fd(fd, dir, freed, cancellation);
+ close(fd);
+ return ok;
+}
+
+BOOL wq_purge_contents(NSString *dir, unsigned long long *freed)
+{
+ return wq_purge_contents_cancellable(dir, freed, nil);
+}
+
+// Flush a completed staging tree without following any links or opening special
+// files. This does not claim to snapshot a concurrently changing source tree.
+static BOOL wq_sync_entry(int parent, NSString *name)
+{
+ struct stat st;
+ if (fstatat(parent, name.fileSystemRepresentation, &st, AT_SYMLINK_NOFOLLOW) != 0) return NO;
+ if (S_ISLNK(st.st_mode)) return YES;
+ if (S_ISREG(st.st_mode)) {
+ int fd = WQOpenRegularAt(parent, name.fileSystemRepresentation, O_RDONLY, 0);
+ if (fd < 0) return NO;
+ struct stat current;
+ BOOL ok = fstat(fd, ¤t) == 0 && WQSameObject(&st, ¤t) && fsync(fd) == 0;
+ close(fd); return ok;
+ }
+ if (!S_ISDIR(st.st_mode)) { errno = EINVAL; return NO; }
+ int fd = openat(parent, name.fileSystemRepresentation, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC);
+ if (fd < 0) return NO;
+ int listing = dup(fd);
+ DIR *dir = listing >= 0 ? fdopendir(listing) : NULL;
+ if (!dir) { if (listing >= 0) close(listing); close(fd); return NO; }
+ BOOL ok = YES;
+ for (;;) {
+ errno = 0;
+ struct dirent *entry = readdir(dir);
+ if (!entry) { if (errno) ok = NO; break; }
+ if (!strcmp(entry->d_name, ".") || !strcmp(entry->d_name, "..")) continue;
+ NSString *child = [NSFileManager.defaultManager stringWithFileSystemRepresentation:entry->d_name length:strlen(entry->d_name)];
+ if (!child || !wq_sync_entry(fd, child)) { ok = NO; break; }
+ }
+ if (closedir(dir) != 0) ok = NO;
+ if (ok && fsync(fd) != 0) ok = NO;
+ close(fd);
+ return ok;
+}
+
+BOOL wq_copy_contents(NSString *srcDir, NSString *dstDir, NSString **errorText)
+{
+ srcDir = WQCanonicalVarPath(srcDir);
+ dstDir = WQCanonicalVarPath(dstDir);
+ if (!WQAbsolutePathGrammar(srcDir) || !WQAbsolutePathGrammar(dstDir) || [srcDir isEqual:dstDir] ||
+ [srcDir hasPrefix:[dstDir stringByAppendingString:@"/"]] || [dstDir hasPrefix:[srcDir stringByAppendingString:@"/"]]) {
+ if (errorText) *errorText = WQLocalized(@"Source and destination trees overlap or have invalid paths");
+ return NO;
+ }
+ NSFileManager *fm = NSFileManager.defaultManager;
+ int source = wq_open_directory(srcDir);
+ if (source < 0) { if (errorText) *errorText = wq_errno(errno); return NO; }
+ int destination = WQCreateDirectoriesNoLinksMode(dstDir, 0755);
+ if (destination < 0 && wq_is_denied(errno) && wq_grant(dstDir.stringByDeletingLastPathComponent))
+ destination = WQCreateDirectoriesNoLinksMode(dstDir, 0755);
+ struct stat srcStat, dstStat;
+ if (destination < 0 || fstat(source, &srcStat) != 0 || fstat(destination, &dstStat) != 0 || WQSameObject(&srcStat, &dstStat)) {
+ if (errorText) *errorText = WQLocalized(@"Cannot safely open distinct source and destination folders");
+ close(source); if (destination >= 0) close(destination); return NO;
+ }
+ NSError *error = nil;
+ NSArray *children = [fm contentsOfDirectoryAtPath:srcDir error:&error];
+ if (!children) {
+ if (errorText) *errorText = error.localizedDescription ?: WQLocalized(@"Cannot read the source folder");
+ close(source); close(destination); return NO;
+ }
+ BOOL allOk = YES;
+ NSString *firstFailure = nil;
+ for (NSString *name in children) {
+ if (!wq_directory_current(srcDir, source) || !wq_directory_current(dstDir, destination)) {
+ allOk = NO; firstFailure = firstFailure ?: WQLocalized(@"A copy root changed during copying"); break;
+ }
+ NSString *src = [srcDir stringByAppendingPathComponent:name];
+ NSString *dst = [dstDir stringByAppendingPathComponent:name];
+ NSString *stageName = [@".wq-copy-stage-" stringByAppendingString:NSUUID.UUID.UUIDString];
+ NSString *stagePath = [dstDir stringByAppendingPathComponent:stageName];
+ if (mkdirat(destination, stageName.fileSystemRepresentation, 0700) != 0) {
+ allOk = NO; firstFailure = firstFailure ?: wq_errno(errno); continue;
+ }
+ int stage = openat(destination, stageName.fileSystemRepresentation, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC);
+ if (stage < 0) { allOk = NO; firstFailure = firstFailure ?: wq_errno(errno); continue; }
+ NSString *newPath = [stagePath stringByAppendingPathComponent:@"New"];
+ WQ_TREE_HOOK(@"copyReady", src);
+ NSError *copyError = nil;
+ BOOL ok = wq_directory_current(stagePath, stage) && [fm copyItemAtPath:src toPath:newPath error:©Error];
+ if (!ok && wq_grant(src) && wq_directory_current(srcDir, source) && wq_directory_current(stagePath, stage)) {
+ if (wq_remove_entry(stage, @"New", newPath, NULL, nil)) {
+ NSError *retryError = nil;
+ ok = [fm copyItemAtPath:src toPath:newPath error:&retryError];
+ if (!ok) copyError = retryError ?: copyError;
+ }
+ }
+ if (ok) ok = wq_sync_entry(stage, @"New") && fsync(stage) == 0;
+ WQ_TREE_HOOK(@"copyStaged", dst);
+ if (ok) ok = wq_directory_current(srcDir, source) && wq_directory_current(dstDir, destination) && wq_directory_current(stagePath, stage);
+ BOOL installed = NO;
+ if (ok) {
+ struct stat previous;
+ BOOL exists = fstatat(destination, name.fileSystemRepresentation, &previous, AT_SYMLINK_NOFOLLOW) == 0;
+ if (!exists && errno != ENOENT) ok = NO;
+ if (ok) {
+ // Swap retains the previous destination at stage/New until the
+ // copy has completed and the new namespace has been flushed.
+ installed = renameatx_np(stage, "New", destination, name.fileSystemRepresentation, exists ? RENAME_SWAP : RENAME_EXCL) == 0;
+ ok = installed && fsync(destination) == 0 && fsync(stage) == 0;
+ }
+ }
+ NSString *failure = ok ? nil : copyError.localizedDescription ?: wq_errno(errno);
+ // Never erase the preserved destination if installation durability failed.
+ BOOL cleaned = !installed || ok;
+ if (cleaned) cleaned = wq_remove_entry(stage, @"New", newPath, NULL, nil);
+ close(stage);
+ if (cleaned) cleaned = unlinkat(destination, stageName.fileSystemRepresentation, AT_REMOVEDIR) == 0 && fsync(destination) == 0;
+ if (!ok || !cleaned) {
+ allOk = NO;
+ NSString *message = cleaned ? failure : [NSString stringWithFormat:@"%@; staging/recovery contents retained at %@", failure ?: wq_errno(errno), stagePath];
+ firstFailure = firstFailure ?: message;
+ wq_log(@"copy: %@ -> %@: %@", src, dst, message);
+ }
+ }
+ close(source); close(destination);
+ if (errorText && !allOk) *errorText = firstFailure;
+ wq_log(@"copy: %@ -> %@ (%lu items) %@", srcDir, dstDir, (unsigned long)children.count, allOk ? @"OK" : @"with errors");
+ return allOk;
+}
+
+// Container-manager enumeration is a second, permission-independent way to
+// map a container path to its identifier. The enumeration pattern is the one
+// used by terminal_worse (GPL-3.0); no part-domain traversal is involved. The UUID xattr and the root-only
+// metadata plist are the fast paths; this query is the fallback for devices
+// or containers where either is unavailable. Paths and identifiers stay in a
+// short-lived cache because labels are requested for every row.
+static NSDictionary *wq_container_identifier_map(void)
+{
+ static NSObject *lock;
+ static NSDictionary *cached;
+ static NSTimeInterval cachedAt;
+ static dispatch_once_t once;
+ dispatch_once(&once, ^{ lock = [NSObject new]; });
+ @synchronized (lock) {
+ NSTimeInterval now = NSDate.date.timeIntervalSinceReferenceDate;
+ if (cached && now - cachedAt < 60) return cached;
+
+ NSMutableDictionary *map = [NSMutableDictionary new];
+ void *mgr = dlopen("/usr/lib/system/libsystem_containermanager.dylib", RTLD_NOW | RTLD_LOCAL);
+ if (mgr) {
+ cq_create_fn create = (cq_create_fn)dlsym(mgr, "container_query_create");
+ cq_set_u64_fn setClass = (cq_set_u64_fn)dlsym(mgr, "container_query_set_class");
+ cq_iterate_fn iterate = (cq_iterate_fn)dlsym(mgr, "container_query_iterate_results_sync");
+ cq_free_fn freeQuery = (cq_free_fn)dlsym(mgr, "container_query_free");
+ co_get_path_fn getPath = (co_get_path_fn)dlsym(mgr, "container_object_get_path");
+ co_get_id_fn getIdentifier = (co_get_id_fn)dlsym(mgr, "container_object_get_identifier");
+ if (!getIdentifier) getIdentifier = (co_get_id_fn)dlsym(mgr, "container_result_get_identifier");
+ if (create && setClass && iterate && freeQuery && getPath) {
+ const uint64_t classes[] = { 0, 2, 5, 7, 8, 10, 11, 13 };
+ for (size_t i = 0; i < sizeof(classes) / sizeof(classes[0]); i++) {
+ void *query = create();
+ if (!query) continue;
+ setClass(query, classes[i]);
+ iterate(query, ^bool(void *object) {
+ if (!object) return true;
+ const char *path = getPath(object);
+ const char *ident = getIdentifier ? getIdentifier(object) : NULL;
+ if (!path || !path[0] || !ident || !ident[0]) return true;
+ NSString *p = [NSString stringWithUTF8String:path];
+ NSString *v = [NSString stringWithUTF8String:ident];
+ if (!p.length || !v.length) return true;
+ NSString *key = WQCanonicalVarPath(p);
+ if (key.length && !map[key]) map[key] = v;
+ return true;
+ });
+ freeQuery(query);
+ }
+ }
+ dlclose(mgr);
+ }
+ cached = map;
+ cachedAt = now;
+ return cached;
+ }
+}
+
+static NSString *(*gContainerLabelResolver)(NSString *path);
+
+void wq_set_container_label_resolver(NSString *(*resolver)(NSString *path))
+{
+ gContainerLabelResolver = resolver;
+}
+
+NSString *wq_container_label(NSString *dir)
+{
+ char buf[512];
+ ssize_t len = getxattr(dir.fileSystemRepresentation, "com.apple.containermanager.identifier", buf, sizeof(buf) - 1, 0, 0);
+ if (len > 0) {
+ buf[len] = 0;
+ return [NSString stringWithUTF8String:buf];
+ }
+ // Reading the attribute can need a token on the container: ask, try again.
+ if (wq_grant(dir)) {
+ len = getxattr(dir.fileSystemRepresentation, "com.apple.containermanager.identifier", buf, sizeof(buf) - 1, 0, 0);
+ if (len > 0) {
+ buf[len] = 0;
+ return [NSString stringWithUTF8String:buf];
+ }
+ }
+ // Prefer the container-manager query over the metadata plist: it needs no
+ // file permission and can also label containers whose plist is root-only.
+ NSString *fromAPI = nil;
+ @synchronized (NSNull.null) { fromAPI = gLSAPILabels[wq_norm(dir)]; }
+ if (fromAPI.length) return fromAPI;
+ NSString *queried = wq_container_identifier_map()[WQCanonicalVarPath(dir)];
+ if (queried.length) return queried;
+ // The LaunchServices database maps app and extension data containers to
+ // bundle ids (set by the app; this file doesn't depend on that module).
+ NSString *resolved = gContainerLabelResolver ? gContainerLabelResolver(WQCanonicalVarPath(dir)) : nil;
+ if (resolved.length) return resolved;
+ // The metadata plist is usually root-only (mode 600); only try it when the
+ // owner and mode let this app read it, so the Log isn't flooded.
+ NSString *meta = [dir stringByAppendingPathComponent:@".com.apple.mobile_container_manager.metadata.plist"];
+ if (wq_permission_hint(meta, NO)) return nil;
+ NSData *data = wq_read(meta, 256 * 1024, NULL);
+ if (!data) return nil;
+ NSDictionary *plist = [NSPropertyListSerialization propertyListWithData:data options:0 format:NULL error:nil];
+ id ident = [plist isKindOfClass:NSDictionary.class] ? plist[@"MCMMetadataIdentifier"] : nil;
+ return [ident isKindOfClass:NSString.class] ? ident : nil;
+}
diff --git a/source/hook/src/WQAccessSafety.h b/source/hook/src/WQAccessSafety.h
new file mode 100644
index 0000000..1d78772
--- /dev/null
+++ b/source/hook/src/WQAccessSafety.h
@@ -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
+#include
+#include
+#include
+#include
+#include
+#include
+
+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", ¤t, 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(¤t, &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 *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
diff --git a/source/hook/src/WQApps.h b/source/hook/src/WQApps.h
new file mode 100644
index 0000000..ec4cf50
--- /dev/null
+++ b/source/hook/src/WQApps.h
@@ -0,0 +1,11 @@
+#import
+
+// 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
+// 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
diff --git a/source/hook/src/WQApps.m b/source/hook/src/WQApps.m
new file mode 100644
index 0000000..2f498aa
--- /dev/null
+++ b/source/hook/src/WQApps.m
@@ -0,0 +1,1674 @@
+//
+// WQApps.m
+// WQ Files app backup and restore manager
+// GPL-3.0-or-later.
+//
+
+#import "WQApps.h"
+#import "WQDir.h"
+#import "WQUI.h"
+#import "WQSharing.h"
+#import "WQAccess.h"
+#import "WQLaunchServices.h"
+#import "WQCore.h"
+#import "WQAppsSupport.h"
+#import "WQArchive.h"
+#import
+#import
+#import
+#import
+
+#pragma mark - Apps manager
+
+@interface WQAppItem : NSObject
+@property (nonatomic, copy) NSString *container;
+@property (nonatomic, copy) NSString *bundleID;
+@property (nonatomic, copy) NSString *name;
+@property (nonatomic) unsigned long long bytes;
+@property (nonatomic) BOOL sizeKnown;
+@property (nonatomic) BOOL hasAccess;
+@property (nonatomic) BOOL hasIdentity;
+@property (nonatomic) dev_t containerDevice;
+@property (nonatomic) ino_t containerInode;
+@property (nonatomic, copy) NSArray *backups;
+@property (nonatomic) NSInteger installState; // WQInstallState
+@end
+@implementation WQAppItem
+@end
+
+@interface WQAppsViewController ()
+@end
+
+@implementation WQAppsViewController {
+ NSArray *_apps;
+ NSArray *_visibleApps;
+ NSString *_query;
+ NSString *_status;
+ BOOL _busy;
+ WQScanCancellation *_scanCancellation;
+ NSUInteger _scanGeneration;
+ BOOL _scanning;
+ NSArray *_indexTitles;
+ NSDictionary *_indexRows;
+ NSString *_restoreBundleID;
+ WQAppItem *_importApp;
+ NSTimeInterval _lastScanFinished;
+}
+
+- (instancetype)initRestoringBundleID:(NSString *)bundleID
+{
+ if ((self = [self init])) _restoreBundleID = [bundleID copy];
+ return self;
+}
+
+static NSString *const kWQAppsSortByNamePref = @"WQAppsSortByName";
+// 0 all apps, 1 Apple's, 2 everyone else's, 3 apps with a backup.
+static NSString *const kWQAppsFilterPref = @"WQAppsFilter";
+// 4 installed, 5 offloaded (issue #28).
+
+typedef NS_ENUM(NSInteger, WQInstallState) { WQInstallUnknown = 0, WQInstallInstalled, WQInstallOffloaded };
+
+// An offloaded app keeps a placeholder bundle (Info.plist and icons) but its
+// executable is removed. The bundle path comes from the LaunchServices
+// database; *why gets the reason for the Log.
+static WQInstallState wq_app_install_state(NSString *bundleID, NSString **why)
+{
+ NSString *path = bundleID.length ? wq_bundle_path_for_id(bundleID) : nil;
+ if (!path.length) { *why = @"no bundle path: not in the app database"; return WQInstallUnknown; }
+ // Built-in apps live on the read-only system volume, which this app can't
+ // read; they can't be offloaded (removable Apple apps live in bundle
+ // containers like everyone else's).
+ if ([path hasPrefix:@"/Applications/"] || [path hasPrefix:@"/System/"]) {
+ *why = @"built-in: system volume";
+ return WQInstallInstalled;
+ }
+ // Checking whether a path exists works even where reading is refused
+ // (iOS 26): the program is almost always named like the bundle
+ // (Drive.app/Drive), so finding it there settles "installed" without
+ // Info.plist.
+ struct stat guessed;
+ NSString *guess = [path stringByAppendingPathComponent:path.lastPathComponent.stringByDeletingPathExtension];
+ if (lstat(guess.fileSystemRepresentation, &guessed) == 0 && S_ISREG(guessed.st_mode)) {
+ *why = @"installed: executable present";
+ return WQInstallInstalled;
+ }
+ NSString *plistPath = [path stringByAppendingPathComponent:@"Info.plist"];
+ NSString *readError = nil;
+ // iOS 26 refuses every app bundle: after a few refusals in a row (and no
+ // success) stop trying, so the Log doesn't get a line per app.
+ static NSUInteger refusedInARow;
+ static BOOL anyRead;
+ @synchronized ([WQAppItem class]) {
+ if (!anyRead && refusedInARow >= 3) {
+ *why = @"Info.plist unreadable: this iOS refuses app bundles";
+ return WQInstallUnknown;
+ }
+ }
+ NSData *data = wq_read(plistPath, 4 * 1024 * 1024, &readError);
+ // The token may have to be for the bundle container (the .app's parent)
+ // rather than for the .app itself.
+ if (!data && (wq_grant(path.stringByDeletingLastPathComponent) || wq_grant(path)))
+ data = wq_read(plistPath, 4 * 1024 * 1024, &readError);
+ @synchronized ([WQAppItem class]) {
+ if (data) { anyRead = YES; refusedInARow = 0; }
+ else if ([readError containsString:@"not permitted"]) refusedInARow++;
+ }
+ id info = data ? [NSPropertyListSerialization propertyListWithData:data options:0 format:NULL error:nil] : nil;
+ if (![info isKindOfClass:NSDictionary.class]) {
+ struct stat st;
+ BOOL bundleThere = lstat(path.fileSystemRepresentation, &st) == 0;
+ *why = [NSString stringWithFormat:@"%@: %@ (%@)", data ? @"Info.plist not a dictionary" : @"Info.plist unreadable",
+ plistPath, data ? @"bad plist" : (readError ?: @"no reason")];
+ if (!data && !bundleThere) *why = [*why stringByAppendingString:@", bundle not visible"];
+ // What an unreadable bundle still shows, to find a sign of offloading
+ // that doesn't need Info.plist.
+ if (!data && bundleThere) {
+ NSMutableArray *seen = [NSMutableArray new];
+ for (NSString *part in @[@"Info.plist", @"_CodeSignature", @"SC_Info", @"embedded.mobileprovision", @"Frameworks", @"PlugIns"]) {
+ struct stat ps;
+ BOOL there = lstat([path stringByAppendingPathComponent:part].fileSystemRepresentation, &ps) == 0;
+ [seen addObject:[NSString stringWithFormat:@"%@%@", there ? @"+" : @"-", part]];
+ }
+ *why = [*why stringByAppendingFormat:@"; %@ guessed program %@ missing; %@", path.lastPathComponent,
+ guess.lastPathComponent, [seen componentsJoinedByString:@" "]];
+ }
+ return WQInstallUnknown;
+ }
+ NSString *executable = [info[@"CFBundleExecutable"] isKindOfClass:NSString.class] ? info[@"CFBundleExecutable"] : nil;
+ if (!executable.length || [executable containsString:@"/"]) { *why = @"no CFBundleExecutable: in Info.plist"; return WQInstallUnknown; }
+ NSString *exePath = [path stringByAppendingPathComponent:executable];
+ struct stat st;
+ if (lstat(exePath.fileSystemRepresentation, &st) == 0) { *why = @"installed: executable present"; return WQInstallInstalled; }
+ int err = errno;
+ if (wq_stat(exePath, &st)) { *why = @"installed: executable present"; return WQInstallInstalled; }
+ if (err == ENOENT) { *why = [NSString stringWithFormat:@"offloaded: %@ missing from %@", executable, path]; return WQInstallOffloaded; }
+ *why = [NSString stringWithFormat:@"executable unchecked: %@ (%s)", exePath, strerror(err)];
+ return WQInstallUnknown;
+}
+
+static BOOL wq_app_is_apple(WQAppItem *app)
+{
+ return [app.bundleID hasPrefix:@"com.apple."];
+}
+
+static NSString *wq_apps_index_letter(NSString *name)
+{
+ if (!name.length) return @"#";
+ NSString *first = [[name substringWithRange:[name rangeOfComposedCharacterSequenceAtIndex:0]]
+ stringByFoldingWithOptions:NSDiacriticInsensitiveSearch | NSCaseInsensitiveSearch locale:nil].uppercaseString;
+ unichar c = first.length ? [first characterAtIndex:0] : 0;
+ return (c >= 'A' && c <= 'Z') ? [NSString stringWithCharacters:&c length:1] : @"#";
+}
+
+static NSArray *kAppDataParts(void) { return @[@"Documents", @"Library", @"tmp"]; }
+static NSArray *kAppBackupParts(void) { return @[@"Documents", @"Library"]; }
+
+static NSString *wq_app_backup_root_for_bundle(NSString *bundleID)
+{
+ if (!WQAppsSupportBundleIDValid(bundleID)) return nil;
+ return [wq_app_backup_root() stringByAppendingPathComponent:bundleID];
+}
+
+// An app backup is AppBackups///{Documents,Library} plus a
+// manifest. It is built in a hidden staging folder and only renamed into
+// place, with the manifest, once every file was copied: a failed or
+// interrupted backup never shows up as one that can be restored.
+static NSString *const kBackupManifest = @".wqbackup.plist";
+static NSString *const kBackupStagingPrefix = @".incomplete-";
+static NSString *const kBackupReasonBeforeRestore = @"Before restore";
+
+static NSDictionary *wq_backup_manifest(NSString *backup)
+{
+ struct stat st;
+ NSString *path = [backup stringByAppendingPathComponent:kBackupManifest];
+ if (lstat(path.fileSystemRepresentation, &st) != 0 || !S_ISREG(st.st_mode)) return nil;
+ NSDictionary *manifest = [NSDictionary dictionaryWithContentsOfFile:path];
+ return [manifest isKindOfClass:NSDictionary.class] ? manifest : nil;
+}
+
+static BOOL wq_backup_root_child(NSString *path, NSString *bundleID)
+{
+ NSString *root = [wq_app_backup_root_for_bundle(bundleID) stringByStandardizingPath];
+ if (!root.length) return NO;
+ NSString *parent = [path.stringByDeletingLastPathComponent stringByStandardizingPath];
+ NSString *name = path.lastPathComponent;
+ return [parent isEqualToString:root] && name.length && ![name hasPrefix:@"."];
+}
+
+static BOOL wq_backup_parts_on_disk(NSString *backup, NSArray *expectedParts,
+ BOOL hasManifest, NSString **error)
+{
+ NSFileManager *fm = NSFileManager.defaultManager;
+ NSError *listError = nil;
+ NSArray *children = [fm contentsOfDirectoryAtPath:backup error:&listError];
+ if (!children) {
+ if (error) *error = listError.localizedDescription ?: WQLocalized(@"The backup folder cannot be read");
+ return NO;
+ }
+ NSMutableSet *actual = [NSMutableSet set];
+ for (NSString *name in children) {
+ if (hasManifest && [name isEqualToString:kBackupManifest]) continue;
+ if (![(@[@"Documents", @"Library"]) containsObject:name]) {
+ if (error) *error = WQLocalizedFormat(@"The backup contains an unexpected item: %@", name);
+ return NO;
+ }
+ NSString *part = [backup stringByAppendingPathComponent:name];
+ struct stat st;
+ if (lstat(part.fileSystemRepresentation, &st) != 0 || !S_ISDIR(st.st_mode)) {
+ if (error) *error = WQLocalizedFormat(@"The backup part %@ is not a directory", name);
+ return NO;
+ }
+ NSError *partError = nil;
+ if (![fm contentsOfDirectoryAtPath:part error:&partError]) {
+ if (error) *error = WQLocalizedFormat(@"The backup part %@ cannot be read: %@", name,
+ partError.localizedDescription ?: WQLocalized(@"unknown error"));
+ return NO;
+ }
+ [actual addObject:name];
+ }
+ if (![actual isEqualToSet:[NSSet setWithArray:expectedParts]]) {
+ if (error) *error = WQLocalized(@"The backup manifest does not match the on-disk parts");
+ return NO;
+ }
+ return YES;
+}
+
+// Strict validation is used for current backups and recovery material. A
+// no-manifest directory is accepted only as an explicitly unverified legacy
+// backup; it never supplies implicit purge instructions for recovery.
+static BOOL wq_validate_backup(NSString *backup, NSString *bundleID, BOOL allowLegacy,
+ NSArray **partsOut, NSDictionary **manifestOut,
+ BOOL *verifiedOut, NSString **error)
+{
+ if (!wq_backup_root_child(backup, bundleID)) {
+ if (error) *error = WQLocalized(@"The backup path is outside this app's backup folder");
+ return NO;
+ }
+ struct stat backupStat;
+ if (lstat(backup.fileSystemRepresentation, &backupStat) != 0 || !S_ISDIR(backupStat.st_mode)) {
+ if (error) *error = WQLocalized(@"The backup folder is missing");
+ return NO;
+ }
+ NSString *manifestPath = [backup stringByAppendingPathComponent:kBackupManifest];
+ struct stat manifestStat;
+ int manifestResult = lstat(manifestPath.fileSystemRepresentation, &manifestStat);
+ if (manifestResult != 0 && errno != ENOENT) {
+ if (error) *error = [NSString stringWithFormat:@"Cannot inspect the backup manifest (errno %d)", errno];
+ return NO;
+ }
+ BOOL hasManifest = manifestResult == 0;
+ if (hasManifest && !S_ISREG(manifestStat.st_mode)) {
+ if (error) *error = WQLocalized(@"The backup manifest is not a regular file");
+ return NO;
+ }
+ NSDictionary *manifest = hasManifest ? wq_backup_manifest(backup) : nil;
+ NSArray *parts = nil;
+ BOOL verified = NO;
+ if (hasManifest) {
+ NSString *manifestError = nil;
+ if (!WQAppsSupportManifestFields(manifest, bundleID, &parts, &manifestError)) {
+ if (error) *error = manifestError ? WQLocalized(manifestError) : WQLocalized(@"The backup manifest is invalid");
+ return NO;
+ }
+ verified = YES;
+ } else {
+ if (!allowLegacy) {
+ if (error) *error = WQLocalized(@"The safety backup has no typed manifest");
+ return NO;
+ }
+ NSMutableArray *legacyParts = [NSMutableArray new];
+ NSError *listError = nil;
+ NSArray *children = [NSFileManager.defaultManager contentsOfDirectoryAtPath:backup error:&listError];
+ if (!children) {
+ if (error) *error = listError.localizedDescription ?: WQLocalized(@"The legacy backup cannot be read");
+ return NO;
+ }
+ for (NSString *name in children) {
+ if (![(@[@"Documents", @"Library"]) containsObject:name]) {
+ if (error) *error = WQLocalizedFormat(@"The legacy backup contains an unexpected item: %@", name);
+ return NO;
+ }
+ [legacyParts addObject:name];
+ }
+ parts = [legacyParts sortedArrayUsingSelector:@selector(compare:)];
+ }
+ if (!wq_backup_parts_on_disk(backup, parts, hasManifest, error)) return NO;
+ if (partsOut) *partsOut = parts;
+ if (manifestOut) *manifestOut = manifest;
+ if (verifiedOut) *verifiedOut = verified;
+ return YES;
+}
+
+// Newest first. Current backups are ordered by their manifest timestamp and
+// numeric collision sequence, never by the display name's text ordering.
+static NSArray *wq_app_backups(NSString *bundleID)
+{
+ NSString *root = wq_app_backup_root_for_bundle(bundleID);
+ NSArray *names = [NSFileManager.defaultManager contentsOfDirectoryAtPath:root error:nil];
+ NSMutableArray *out = [NSMutableArray new];
+ for (NSString *name in names) {
+ if ([name hasPrefix:@"."]) continue;
+ NSString *path = [root stringByAppendingPathComponent:name];
+ struct stat st;
+ if (lstat(path.fileSystemRepresentation, &st) != 0 || !S_ISDIR(st.st_mode)) continue;
+ NSString *validationError = nil;
+ NSArray *parts = nil;
+ NSDictionary *manifest = nil;
+ BOOL verified = NO;
+ BOOL valid = wq_validate_backup(path, bundleID, YES, &parts, &manifest, &verified, &validationError);
+ BOOL hasManifest = lstat([[path stringByAppendingPathComponent:kBackupManifest] fileSystemRepresentation], &st) == 0;
+ if (!valid && !hasManifest) continue;
+ NSDate *created = [manifest[@"Created"] isKindOfClass:NSDate.class] ? manifest[@"Created"] :
+ [NSFileManager.defaultManager attributesOfItemAtPath:path error:nil][NSFileModificationDate] ?: NSDate.distantPast;
+ NSUInteger sequence = WQAppsSupportBackupSequence(name);
+ if (WQAppsSupportIsInteger(manifest[@"Sequence"]) && [manifest[@"Sequence"] unsignedIntegerValue])
+ sequence = [manifest[@"Sequence"] unsignedIntegerValue];
+ [out addObject:@{ @"path": path, @"name": name, @"verified": @(valid && verified),
+ @"reason": valid ? (manifest[@"Reason"] ?: @"") : (validationError ?: WQLocalized(@"invalid manifest")),
+ @"created": created, @"sequence": @(sequence) }];
+ }
+ [out sortUsingComparator:^NSComparisonResult(NSDictionary *a, NSDictionary *b) {
+ return WQAppsSupportCompareBackupMetadata(b[@"created"], [b[@"sequence"] unsignedIntegerValue], b[@"name"],
+ a[@"created"], [a[@"sequence"] unsignedIntegerValue], a[@"name"]);
+ }];
+ return out;
+}
+
+static NSString *wq_backup_label(NSDictionary *__unused backup)
+{
+ NSString *label = backup[@"name"];
+ if ([backup[@"reason"] length]) label = [label stringByAppendingFormat:@" (%@)", WQLocalized(backup[@"reason"])];
+ if (![backup[@"verified"] boolValue]) label = [label stringByAppendingString:WQLocalized(@" (older, unverified)")];
+ return label;
+}
+
+static BOOL wq_validate_container(NSString *container, NSString *bundleID, dev_t expectedDevice,
+ ino_t expectedInode, NSString **error)
+{
+ struct stat st;
+ if (lstat(container.fileSystemRepresentation, &st) != 0 || !S_ISDIR(st.st_mode)) {
+ if (error) *error = WQLocalized(@"The app container no longer exists or is not a directory");
+ return NO;
+ }
+ if (expectedDevice && expectedInode && (st.st_dev != expectedDevice || st.st_ino != expectedInode)) {
+ if (error) *error = WQLocalized(@"The app container was replaced after it was scanned");
+ return NO;
+ }
+ NSString *label = wq_container_label(container);
+ if (!label.length || ![label isEqualToString:bundleID]) {
+ if (error) *error = WQLocalized(@"The app container identity no longer matches the selected app");
+ return NO;
+ }
+ return YES;
+}
+
+static BOOL wq_copy_part_and_verify(NSString *src, NSString *dst, NSString **error)
+{
+ BOOL sourceBeforeExists = NO;
+ NSString *sourceBefore = WQAppsSupportTreeSignature(src, YES, &sourceBeforeExists, error);
+ if (!sourceBefore) return NO;
+ if (!wq_copy_contents(src, dst, error)) return NO;
+ BOOL srcExists = NO, dstExists = NO;
+ NSString *sourceAfter = WQAppsSupportTreeSignature(src, YES, &srcExists, error);
+ NSString *sourceShape = WQAppsSupportTreeSignature(src, NO, NULL, error);
+ NSString *destinationShape = WQAppsSupportTreeSignature(dst, NO, &dstExists, error);
+ if (!sourceAfter || !sourceShape || !destinationShape || !sourceBeforeExists || !srcExists || !dstExists ||
+ ![sourceBefore isEqualToString:sourceAfter] ||
+ ![sourceShape isEqualToString:destinationShape]) {
+ if (error && !*error) *error = WQLocalized(@"The copied tree could not be verified (the source changed or the copy is incomplete)");
+ return NO;
+ }
+ return YES;
+}
+
+static BOOL wq_purge_and_verify(NSString *dir, unsigned long long *freed, NSString **error)
+{
+ if (!wq_purge_contents(dir, freed)) return NO;
+ struct stat st;
+ if (!wq_stat(dir, &st)) return YES; // a missing part is an acceptable empty result
+ if (!S_ISDIR(st.st_mode)) {
+ if (error) *error = [NSString stringWithFormat:@"%@ is not a directory", dir.lastPathComponent];
+ return NO;
+ }
+ NSError *listError = nil;
+ NSArray *remaining = [NSFileManager.defaultManager contentsOfDirectoryAtPath:dir error:&listError];
+ if (!remaining) {
+ if (error) *error = listError.localizedDescription ?: WQLocalizedFormat(@"Cannot read %@", dir.lastPathComponent);
+ return NO;
+ }
+ if (remaining.count) {
+ if (error) *error = WQLocalizedFormat(@"Some items remain in %@; keep the app closed and try again", dir.lastPathComponent);
+ return NO;
+ }
+ return YES;
+}
+
+// Copies the app's Documents and Library into a new complete backup. Returns
+// its path, or nil with *error (nothing is left behind then).
+// Copies Documents and Library from `container` (an app's data container,
+// or for imports a local folder) into a new verified backup of bundleID.
+// `stillValid` (optional) re-checks the source before and after publishing.
+static NSString *wq_make_backup_from(NSString *bundleID, NSString *container, NSString *reason,
+ BOOL (^stillValid)(NSString **error), NSString **error)
+{
+ if (!WQAppsSupportBundleIDValid(bundleID)) {
+ if (error) *error = WQLocalized(@"The app identifier is not a valid bundle identifier");
+ return nil;
+ }
+ NSFileManager *fm = NSFileManager.defaultManager;
+ if (stillValid && !stillValid(error)) return nil;
+ NSString *root = wq_app_backup_root_for_bundle(bundleID);
+ if (!root.length) {
+ if (error) *error = WQLocalized(@"Cannot locate the app backup folder");
+ return nil;
+ }
+ [fm createDirectoryAtPath:root withIntermediateDirectories:YES attributes:nil error:nil];
+ for (NSString *name in [fm contentsOfDirectoryAtPath:root error:nil]) // leftovers of interrupted runs
+ if ([name hasPrefix:kBackupStagingPrefix]) [fm removeItemAtPath:[root stringByAppendingPathComponent:name] error:nil];
+
+ NSString *staging = [root stringByAppendingPathComponent:
+ [kBackupStagingPrefix stringByAppendingString:NSUUID.UUID.UUIDString]];
+ if (![fm createDirectoryAtPath:staging withIntermediateDirectories:NO attributes:nil error:nil]) {
+ if (error) *error = WQLocalized(@"Cannot create the backup folder");
+ return nil;
+ }
+ NSMutableArray *parts = [NSMutableArray new];
+ NSMutableDictionary *sourceStates = [NSMutableDictionary new];
+ NSMutableDictionary *partHashes = [NSMutableDictionary new];
+ BOOL ok = YES;
+ for (NSString *part in kAppBackupParts()) {
+ NSString *src = [container stringByAppendingPathComponent:part];
+ struct stat st;
+ if (lstat(src.fileSystemRepresentation, &st) != 0 && errno == ENOENT) continue; // the app has none
+ if (!wq_stat(src, &st)) { ok = NO; if (error) *error = WQLocalizedFormat(@"Cannot access %@", part); break; }
+ BOOL sourceExists = NO;
+ NSString *sourceState = WQAppsSupportTreeSignature(src, YES, &sourceExists, error);
+ if (!sourceState) { ok = NO; break; }
+ sourceStates[part] = sourceState;
+ NSString *stagedPart = [staging stringByAppendingPathComponent:part];
+ if (!wq_copy_part_and_verify(src, stagedPart, error)) { ok = NO; break; }
+ BOOL stagedExists = NO;
+ NSString *contentHash = WQAppsSupportTreeContentSignature(stagedPart, &stagedExists, error);
+ if (!contentHash || !stagedExists) {
+ ok = NO;
+ if (error && !*error) *error = WQLocalized(@"Cannot hash the staged backup part");
+ break;
+ }
+ partHashes[part] = contentHash;
+ [parts addObject:part];
+ }
+ if (ok) {
+ for (NSString *part in kAppBackupParts()) {
+ NSString *src = [container stringByAppendingPathComponent:part];
+ BOOL sourceExists = NO;
+ NSString *sourceState = WQAppsSupportTreeSignature(src, YES, &sourceExists, error);
+ NSString *before = sourceStates[part] ?: @"";
+ if (!sourceState || ![before isEqualToString:sourceState]) {
+ ok = NO;
+ if (error && !*error) *error = WQLocalized(@"The app data changed while it was being backed up; no snapshot-consistent copy was made");
+ break;
+ }
+ }
+ }
+ NSDate *created = NSDate.date;
+ NSDateFormatter *fmt = [NSDateFormatter new];
+ fmt.locale = [NSLocale localeWithLocaleIdentifier:@"en_US_POSIX"];
+ fmt.dateFormat = @"yyyy-MM-dd HH.mm.ss";
+ NSString *stamp = [fmt stringFromDate:created];
+ NSString *final = [root stringByAppendingPathComponent:stamp];
+ NSUInteger sequence = 1;
+ for (NSUInteger n = 2; [fm fileExistsAtPath:final]; n++) {
+ sequence = n;
+ final = [root stringByAppendingPathComponent:[NSString stringWithFormat:@"%@ (%lu)", stamp, (unsigned long)n]];
+ }
+ NSDictionary *manifest = @{ @"Complete": @YES, @"BundleID": bundleID, @"Created": created,
+ @"Parts": parts, @"PartHashes": partHashes,
+ @"Reason": reason ?: @"", @"Format": @1,
+ @"Sequence": @(sequence) };
+ if (ok && ![manifest writeToFile:[staging stringByAppendingPathComponent:kBackupManifest] atomically:YES]) {
+ ok = NO;
+ if (error) *error = WQLocalized(@"Cannot write the backup manifest");
+ }
+ BOOL published = NO;
+ if (ok) {
+ if (stillValid && !stillValid(error)) ok = NO;
+ }
+ if (ok) {
+ if (rename(staging.fileSystemRepresentation, final.fileSystemRepresentation) != 0) {
+ ok = NO;
+ if (error) *error = WQLocalizedFormat(@"Cannot finish the backup (errno %d)", errno);
+ } else {
+ published = YES;
+ NSString *validationError = nil;
+ if (!wq_validate_backup(final, bundleID, NO, NULL, NULL, NULL, &validationError) ||
+ (stillValid && !stillValid(&validationError))) {
+ ok = NO;
+ if (error) *error = validationError ?: WQLocalized(@"The app container changed while the backup was being finalized");
+ }
+ }
+ }
+ if (!ok) {
+ [fm removeItemAtPath:published ? final : staging error:nil];
+ wq_log(@"app backup %@: FAILED (%@)", bundleID, error && *error ? *error : @"?");
+ return nil;
+ }
+ wq_log(@"app backup %@ -> %@ (%@)", bundleID, final.lastPathComponent, [parts componentsJoinedByString:@", "]);
+ return final;
+}
+
+#pragma mark - Import as Backup
+
+static NSString *const kBackupReasonImported = @"Imported";
+
+static NSString *wq_make_app_backup(NSString *bundleID, NSString *container, dev_t expectedDevice,
+ ino_t expectedInode, NSString *reason, NSString **error)
+{
+ return wq_make_backup_from(bundleID, container, reason, ^BOOL(NSString **validationError) {
+ return wq_validate_container(container, bundleID, expectedDevice, expectedInode, validationError);
+ }, error);
+}
+
+static BOOL wq_dir_size_cancellable(NSString *dir, WQScanCancellation *cancellation,
+ unsigned long long *sizeOut, BOOL *completeOut)
+{
+ if (completeOut) *completeOut = NO;
+ if (sizeOut) *sizeOut = 0;
+ if (cancellation.isCancelled) return NO;
+ unsigned long long total = 0;
+ WQMeasureResult result = wq_measure_tree(dir, cancellation, &total);
+ if (result == WQMeasureResultCancelled) return NO;
+ if (sizeOut) *sizeOut = total;
+ if (completeOut) *completeOut = result != WQMeasureResultLimited;
+ return YES;
+}
+
+- (void)viewDidLoad
+{
+ [super viewDidLoad];
+ self.title = WQLocalizedNullable(@"Apps");
+ UISearchController *search = [[UISearchController alloc] initWithSearchResultsController:nil];
+ search.searchResultsUpdater = self;
+ search.obscuresBackgroundDuringPresentation = NO;
+ search.searchBar.placeholder = WQLocalizedNullable(@"Search apps");
+ self.navigationItem.rightBarButtonItem = [self sortButton];
+ self.navigationItem.searchController = search;
+ self.navigationItem.hidesSearchBarWhenScrolling = NO;
+ self.definesPresentationContext = YES;
+ self.tableView.tableHeaderView = wq_device_header(self.view.bounds.size.width);
+ self.refreshControl = [UIRefreshControl new];
+ [self.refreshControl addTarget:self action:@selector(userRefresh) forControlEvents:UIControlEventValueChanged];
+ [self scan];
+}
+
+// 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)viewWillAppear:(BOOL)animated
+{
+ [super viewWillAppear:animated];
+ if (_scanning && _scanCancellation.isCancelled) { [self scan]; return; }
+ if (_scanning || !_apps) return;
+ // The start screen keeps this screen between visits: rescan on its own
+ // only when the list is old (pull to refresh rescans any time). Backups
+ // may have been made or deleted elsewhere, so reread those (cheap).
+ if (NSDate.date.timeIntervalSinceReferenceDate - _lastScanFinished > 600.0) { [self scan]; return; }
+ [self refreshBackups];
+}
+
+- (void)refreshBackups
+{
+ NSArray *apps = _apps;
+ NSUInteger generation = _scanGeneration;
+ dispatch_async(wq_io(), ^{
+ NSMutableArray *lists = [NSMutableArray arrayWithCapacity:apps.count];
+ for (WQAppItem *app in apps) [lists addObject:app.bundleID.length ? wq_app_backups(app.bundleID) : @[]];
+ dispatch_async(dispatch_get_main_queue(), ^{
+ if (generation != self->_scanGeneration || self->_scanning) return;
+ BOOL changed = NO;
+ for (NSUInteger i = 0; i < apps.count; i++) {
+ if ([apps[i].backups isEqualToArray:lists[i]]) continue;
+ apps[i].backups = lists[i];
+ changed = YES;
+ }
+ if (!changed) return;
+ [self rebuildVisibleApps];
+ [self.tableView reloadData];
+ });
+ });
+}
+
+- (void)viewWillDisappear:(BOOL)animated
+{
+ [super viewWillDisappear:animated];
+ if (_scanning) [_scanCancellation cancel];
+}
+
+- (void)setBusy:(BOOL)busy status:(NSString *)status
+{
+ _busy = busy;
+ _status = status;
+ [self.tableView reloadData];
+}
+
+// Watchdog for the app scan: each step records what it's doing; a timer logs
+// any step that stays unchanged for 10 s, so a hang shows up in the Log.
+static NSObject *gAppScanStepLock;
+static NSString *gAppScanStep;
+static CFAbsoluteTime gAppScanStepAt;
+static NSUInteger gAppScanStepSerial;
+
+static void WQAppScanStep(NSString *step)
+{
+ static dispatch_once_t once;
+ dispatch_once(&once, ^{ gAppScanStepLock = [NSObject new]; });
+ @synchronized (gAppScanStepLock) {
+ gAppScanStep = [step copy];
+ gAppScanStepAt = CFAbsoluteTimeGetCurrent();
+ gAppScanStepSerial++;
+ }
+}
+
+static void WQAppScanWatch(WQScanCancellation *cancellation, NSUInteger lastSerial, NSUInteger reported)
+{
+ dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(5 * NSEC_PER_SEC)), dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{
+ NSString *step = nil;
+ NSUInteger serial = 0;
+ double age = 0;
+ @synchronized (gAppScanStepLock) {
+ step = gAppScanStep;
+ serial = gAppScanStepSerial;
+ age = CFAbsoluteTimeGetCurrent() - gAppScanStepAt;
+ }
+ if (!step) return; // scan finished
+ NSUInteger nowReported = serial == lastSerial ? reported : 0;
+ if (age >= 10 && nowReported < 6) {
+ wq_log(@"app scan: still %@ after %.0f s%@", step, age, cancellation.isCancelled ? @" (screen left)" : @"");
+ nowReported++;
+ }
+ WQAppScanWatch(cancellation, serial, nowReported);
+ });
+}
+
+- (void)scan
+{
+ [_scanCancellation cancel];
+ WQScanCancellation *cancellation = [WQScanCancellation new];
+ _scanCancellation = cancellation;
+ NSUInteger generation = ++_scanGeneration;
+ _scanning = YES;
+ [self setBusy:YES status:WQLocalizedNullable(@"Finding apps…")];
+ NSString *home = [NSHomeDirectory() stringByResolvingSymlinksInPath];
+ WQAppScanStep(@"finding app containers");
+ WQAppScanWatch(cancellation, 0, 0);
+ dispatch_async(wq_io(), ^{
+ CFAbsoluteTime scanStarted = CFAbsoluteTimeGetCurrent();
+ NSArray *containers = wq_enumerate(@"/private/var/mobile/Containers/Data/Application", 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->_status = WQLocalizedFormat(@"Finding apps… %lld%%", total ? (long long)(done * 100 / total) : 0);
+ [self.tableView reloadData];
+ });
+ });
+ if (!cancellation.isCancelled && !wq_display_names_loaded()) {
+ dispatch_async(dispatch_get_main_queue(), ^{
+ if (generation != self->_scanGeneration || cancellation.isCancelled) return;
+ self->_status = WQLocalized(@"Reading app names…");
+ [self.tableView reloadData];
+ });
+ WQAppScanStep(@"reading app names");
+ CFAbsoluteTime namesStarted = CFAbsoluteTimeGetCurrent();
+ wq_load_display_names();
+ wq_log(@"app scan: app names %@ in %.1f s", wq_display_names_loaded() ? @"loaded" : @"not loaded",
+ CFAbsoluteTimeGetCurrent() - namesStarted);
+ }
+ if (containers) wq_log(@"app scan: %lu containers found in %.1f s; measuring", (unsigned long)containers.count,
+ CFAbsoluteTimeGetCurrent() - scanStarted);
+ if (!containers && !cancellation.isCancelled) wq_log(@"app scan: container enumeration failed; see the preceding enumerate log");
+ else if (containers && containers.count == 0) wq_log(@"app scan: enumeration returned no container paths; check the base path and inode bound");
+ NSMutableArray *apps = [NSMutableArray new];
+ NSMutableArray *refused = [NSMutableArray new];
+ NSUInteger i = 0, missingLabels = 0, limited = 0, sizeLimited = 0;
+ NSUInteger installCounts[3] = {0}, installNotes = 0;
+ NSMutableDictionary *installReasons = [NSMutableDictionary new];
+ for (NSString *c in containers) {
+ if (cancellation.isCancelled) { WQAppScanStep(nil); return; }
+ i++;
+ if ([[c stringByResolvingSymlinksInPath] isEqualToString:home]) continue; // ourselves
+ dispatch_async(dispatch_get_main_queue(), ^{
+ if (generation != self->_scanGeneration || cancellation.isCancelled) return;
+ self->_status = WQLocalizedFormat(@"Measuring %lu/%lu…", (unsigned long)i, (unsigned long)containers.count);
+ [self.tableView reloadData];
+ });
+ NSString *shortName = c.lastPathComponent;
+ WQAppScanStep([NSString stringWithFormat:@"getting access to %@ (%lu/%lu)", shortName, (unsigned long)i, (unsigned long)containers.count]);
+ BOOL granted = wq_grant(c);
+ if (!granted) {
+ [refused addObject:[NSString stringWithFormat:@"%@ (%@)", c.lastPathComponent,
+ wq_bundle_id_for_container(c) ?: @"unknown app"]];
+ }
+ WQAppScanStep([NSString stringWithFormat:@"reading the identifier of %@", shortName]);
+ NSString *bid = wq_container_label(c);
+ if (!bid.length) {
+ missingLabels++;
+ if (missingLabels <= 3) wq_log(@"app scan: no container identifier for %@", c);
+ }
+ struct stat identity;
+ BOOL validBundle = WQAppsSupportBundleIDValid(bid);
+ BOOL hasIdentity = validBundle && wq_stat(c, &identity) && S_ISDIR(identity.st_mode);
+ if (!granted || !validBundle || !hasIdentity) limited++;
+ WQAppItem *app = [WQAppItem new];
+ app.container = c;
+ app.bundleID = bid;
+ app.hasAccess = granted;
+ app.hasIdentity = hasIdentity;
+ app.containerDevice = hasIdentity ? identity.st_dev : 0;
+ app.containerInode = hasIdentity ? identity.st_ino : 0;
+ if (bid.length) (void)wq_app_icon(bid); // warm the icon off the main thread
+ app.name = bid.length ? (wq_display_name(bid) ?: bid)
+ : WQLocalizedFormat(@"Unknown container (%@)", c.lastPathComponent);
+ app.bytes = 0;
+ app.sizeKnown = NO;
+ unsigned long long appSize = 0;
+ BOOL sizeKnown = NO;
+ WQAppScanStep([NSString stringWithFormat:@"measuring %@ (%@)", shortName, bid ?: @"no identifier"]);
+ if (granted && hasIdentity && !wq_dir_size_cancellable(c, cancellation, &appSize, &sizeKnown)) { WQAppScanStep(nil); return; }
+ app.bytes = appSize;
+ app.sizeKnown = sizeKnown;
+ if (granted && hasIdentity && !sizeKnown) sizeLimited++;
+ app.backups = bid.length ? wq_app_backups(bid) : @[];
+ NSString *why = nil;
+ app.installState = bid.length ? wq_app_install_state(bid, &why) : WQInstallUnknown;
+ installCounts[app.installState]++;
+ NSString *reasonKind = [why componentsSeparatedByString:@":"].firstObject ?: @"no identifier";
+ installReasons[reasonKind] = @([installReasons[reasonKind] unsignedIntegerValue] + 1);
+ // Every offloaded app (there are few, and they're the claim to check),
+ // and a few of the unknown ones.
+ if (app.installState == WQInstallOffloaded || (app.installState == WQInstallUnknown && installNotes < 6)) {
+ if (app.installState == WQInstallUnknown) installNotes++;
+ wq_log(@"install state: %@: %@", bid ?: c.lastPathComponent, why ?: @"no identifier");
+ }
+ [apps addObject:app];
+ }
+ WQAppScanStep(nil);
+ if (cancellation.isCancelled) return;
+ wq_log(@"app scan: %lu apps measured in %.1f s", (unsigned long)apps.count, CFAbsoluteTimeGetCurrent() - scanStarted);
+ wq_log(@"app scan: %lu installed, %lu offloaded, %lu unknown", (unsigned long)installCounts[WQInstallInstalled],
+ (unsigned long)installCounts[WQInstallOffloaded], (unsigned long)installCounts[WQInstallUnknown]);
+ NSMutableArray *reasonParts = [NSMutableArray new];
+ for (NSString *kind in [installReasons keysSortedByValueUsingSelector:@selector(compare:)].reverseObjectEnumerator)
+ [reasonParts addObject:[NSString stringWithFormat:@"%@ %@", installReasons[kind], kind]];
+ if (reasonParts.count) wq_log(@"install state by reason: %@", [reasonParts componentsJoinedByString:@", "]);
+ if (refused.count) wq_log(@"app scan: %lu containers refused access: %@", (unsigned long)refused.count,
+ [refused componentsJoinedByString:@", "]);
+ [apps sortUsingComparator:^NSComparisonResult(WQAppItem *a, WQAppItem *b) {
+ return a.bytes > b.bytes ? NSOrderedAscending : (a.bytes < b.bytes ? NSOrderedDescending : NSOrderedSame);
+ }];
+ NSString *note = nil;
+ if (!wq_app_group()) note = WQLocalized(@"WQ Files has no App Group, so it can't open other apps' folders. Re-sign it with a tool that keeps the App Group (see Signing in the README).");
+ else if (!containers && wq_full_scan_was_declined()) note = WQLocalized(@"Apps aren't listed yet: this needs a scan of all files. Pull down to start it.");
+ else if (!containers) note = WQLocalized(@"Container enumeration failed. See Log for the statfs/inode reason.");
+ else if (!containers.count) note = WQLocalized(@"No app containers were found. Refresh, and check Log for the enumeration reason.");
+ else if (limited || sizeLimited) note = WQLocalizedFormat(@"%lu containers · %lu with limited access, identity, size, or no identifier. See Log.", (unsigned long)apps.count, (unsigned long)(limited + sizeLimited));
+ dispatch_async(dispatch_get_main_queue(), ^{
+ if (generation != self->_scanGeneration || cancellation.isCancelled) return;
+ self->_scanning = NO;
+ self->_lastScanFinished = NSDate.date.timeIntervalSinceReferenceDate;
+ self->_apps = apps;
+ [self rebuildVisibleApps];
+ [self.refreshControl endRefreshing];
+ [self setBusy:NO status:note];
+ [self offerPendingRestore];
+ });
+ });
+}
+
+- (NSArray *)visibleApps
+{
+ return _visibleApps ?: @[];
+}
+
+- (void)rebuildVisibleApps
+{
+ NSArray *shown = _apps ?: @[];
+ NSInteger filter = [NSUserDefaults.standardUserDefaults integerForKey:kWQAppsFilterPref];
+ if (filter > 0) {
+ shown = [shown filteredArrayUsingPredicate:[NSPredicate predicateWithBlock:^BOOL(WQAppItem *a, NSDictionary *__unused b) {
+ if (filter == 1) return wq_app_is_apple(a);
+ if (filter == 2) return !wq_app_is_apple(a);
+ if (filter == 4) return a.installState == WQInstallInstalled;
+ if (filter == 5) return a.installState == WQInstallOffloaded;
+ return a.backups.count > 0;
+ }]];
+ }
+ if (_query.length) {
+ shown = [shown filteredArrayUsingPredicate:[NSPredicate predicateWithBlock:^BOOL(WQAppItem *a, NSDictionary *__unused b) {
+ return [a.name localizedCaseInsensitiveContainsString:self->_query] ||
+ [a.bundleID localizedCaseInsensitiveContainsString:self->_query];
+ }]];
+ }
+ BOOL byName = [NSUserDefaults.standardUserDefaults boolForKey:kWQAppsSortByNamePref];
+ _visibleApps = [shown sortedArrayUsingComparator:^NSComparisonResult(WQAppItem *a, WQAppItem *b) {
+ NSComparisonResult names = [(a.name ?: @"") localizedStandardCompare:(b.name ?: @"")];
+ if (byName || a.bytes == b.bytes) return names;
+ return a.bytes > b.bytes ? NSOrderedAscending : NSOrderedDescending;
+ }];
+ // Alphabet sidebar when sorted by name.
+ NSMutableArray *titles = [NSMutableArray new];
+ NSMutableDictionary *rows = [NSMutableDictionary new];
+ if (byName && _visibleApps.count >= 25) {
+ [_visibleApps enumerateObjectsUsingBlock:^(WQAppItem *app, NSUInteger row, BOOL *__unused stop) {
+ NSString *letter = wq_apps_index_letter(app.name);
+ if (!rows[letter]) { rows[letter] = @(row); [titles addObject:letter]; }
+ }];
+ }
+ _indexTitles = titles.count > 1 ? titles : nil;
+ _indexRows = rows;
+}
+
+- (NSArray *)sectionIndexTitlesForTableView:(UITableView *)tableView
+{
+ return (_busy && !_apps.count) ? nil : _indexTitles;
+}
+
+- (NSInteger)tableView:(UITableView *)tableView sectionForSectionIndexTitle:(NSString *)title atIndex:(NSInteger)index
+{
+ NSNumber *row = _indexRows[title];
+ if (row && row.unsignedIntegerValue < self.visibleApps.count)
+ [tableView scrollToRowAtIndexPath:[NSIndexPath indexPathForRow:row.integerValue inSection:0]
+ atScrollPosition:UITableViewScrollPositionTop animated:NO];
+ return -1; // already scrolled to the row; the list has one section
+}
+
+- (UIBarButtonItem *)sortButton
+{
+ __weak typeof(self) weakSelf = self;
+ UIDeferredMenuElement *items = [UIDeferredMenuElement elementWithUncachedProvider:^(void (^completion)(NSArray *)) {
+ BOOL byName = [NSUserDefaults.standardUserDefaults boolForKey:kWQAppsSortByNamePref];
+ UIAction *size = [UIAction actionWithTitle:WQLocalizedNullable(@"Size") image:nil identifier:nil handler:^(UIAction *__unused a) {
+ [NSUserDefaults.standardUserDefaults setBool:NO forKey:kWQAppsSortByNamePref];
+ [weakSelf resortApps];
+ }];
+ UIAction *name = [UIAction actionWithTitle:WQLocalizedNullable(@"Name") image:nil identifier:nil handler:^(UIAction *__unused a) {
+ [NSUserDefaults.standardUserDefaults setBool:YES forKey:kWQAppsSortByNamePref];
+ [weakSelf resortApps];
+ }];
+ size.state = byName ? UIMenuElementStateOff : UIMenuElementStateOn;
+ name.state = byName ? UIMenuElementStateOn : UIMenuElementStateOff;
+ size.subtitle = WQLocalized(@"Largest first");
+ name.subtitle = WQLocalized(@"A–Z");
+ UIMenu *sort = [UIMenu menuWithTitle:WQLocalizedNullable(@"Sort By") image:nil identifier:nil options:UIMenuOptionsDisplayInline children:@[size, name]];
+ NSInteger current = [NSUserDefaults.standardUserDefaults integerForKey:kWQAppsFilterPref];
+ NSArray *titles = @[WQLocalized(@"All Apps"), WQLocalized(@"Apple Apps"), WQLocalized(@"Other Apps"), WQLocalized(@"Backed Up"),
+ WQLocalized(@"Installed"), WQLocalized(@"Offloaded")];
+ // Installed/Offloaded only where bundles could be checked (not on iOS 26).
+ WQAppsViewController *strongSelf = weakSelf;
+ // Only offer Installed/Offloaded when nearly every app's state is
+ // known; otherwise those filters would quietly hide the unknown ones.
+ NSArray *all = strongSelf ? strongSelf->_apps : @[];
+ NSUInteger unknown = 0;
+ for (WQAppItem *app in all) if (app.installState == WQInstallUnknown) unknown++;
+ BOOL installKnown = all.count > 0 && unknown * 10 <= all.count;
+ NSMutableArray *filters = [NSMutableArray new];
+ for (NSInteger i = 0; i < (NSInteger)titles.count; i++) {
+ if (i >= 4 && !installKnown && i != current) continue;
+ UIAction *f = [UIAction actionWithTitle:WQLocalizedNullable(titles[(NSUInteger)i]) image:nil identifier:nil handler:^(UIAction *__unused a) {
+ [NSUserDefaults.standardUserDefaults setInteger:i forKey:kWQAppsFilterPref];
+ [weakSelf resortApps];
+ }];
+ f.state = i == current ? UIMenuElementStateOn : UIMenuElementStateOff;
+ [filters addObject:f];
+ }
+ UIMenu *show = [UIMenu menuWithTitle:WQLocalizedNullable(@"Show") image:nil identifier:nil options:UIMenuOptionsDisplayInline children:filters];
+ completion(@[sort, show]);
+ }];
+ return [[UIBarButtonItem alloc] initWithImage:[UIImage systemImageNamed:@"line.3.horizontal.decrease.circle"]
+ menu:[UIMenu menuWithChildren:@[items]]];
+}
+
+- (void)resortApps
+{
+ [self rebuildVisibleApps];
+ [self.tableView reloadData];
+ [self.tableView reloadSectionIndexTitles];
+}
+
+// Long-press: copy the bundle ID or container path, open the data folder.
+- (UIContextMenuConfiguration *)tableView:(UITableView *)tableView
+ contextMenuConfigurationForRowAtIndexPath:(NSIndexPath *)indexPath point:(CGPoint)point
+{
+ if (_busy || (NSUInteger)indexPath.row >= self.visibleApps.count) return nil;
+ WQAppItem *app = self.visibleApps[(NSUInteger)indexPath.row];
+ __weak typeof(self) weakSelf = self;
+ return [UIContextMenuConfiguration configurationWithIdentifier:nil previewProvider:nil
+ actionProvider:^UIMenu *(NSArray *__unused suggested) {
+ NSMutableArray *actions = [NSMutableArray new];
+ if (app.bundleID.length) {
+ [actions addObject:[UIAction actionWithTitle:WQLocalizedNullable(@"Copy Bundle ID") image:[UIImage systemImageNamed:@"tag"] identifier:nil
+ handler:^(UIAction *__unused a) { UIPasteboard.generalPasteboard.string = app.bundleID; }]];
+ }
+ [actions addObject:[UIAction actionWithTitle:WQLocalizedNullable(@"Copy Container Path") image:[UIImage systemImageNamed:@"doc.on.doc"] identifier:nil
+ handler:^(UIAction *__unused a) { UIPasteboard.generalPasteboard.string = app.container; }]];
+ if (app.hasAccess) {
+ [actions addObject:[UIAction actionWithTitle:WQLocalizedNullable(@"Open Data Folder") image:[UIImage systemImageNamed:@"folder"] identifier:nil
+ handler:^(UIAction *__unused a) {
+ [weakSelf.navigationController pushViewController:[[WQDirViewController alloc] initWithPath:app.container title:app.name]
+ animated:YES];
+ }]];
+ }
+ // iOS 26 gives no access to bundle containers (no token).
+ NSString *bundlePath = app.bundleID.length && wq_system_areas_reachable() && wq_display_names_loaded()
+ ? wq_bundle_path_for_id(app.bundleID) : nil;
+ if (bundlePath.length) {
+ [actions addObject:[UIAction actionWithTitle:WQLocalizedNullable(@"Open App Bundle") image:[UIImage systemImageNamed:@"app.badge"] identifier:nil
+ handler:^(UIAction *__unused a) {
+ [weakSelf.navigationController pushViewController:[[WQDirViewController alloc] initWithPath:bundlePath title:app.name]
+ animated:YES];
+ }]];
+ }
+ return [UIMenu menuWithChildren:actions];
+ }];
+}
+
+- (void)updateSearchResultsForSearchController:(UISearchController *)searchController
+{
+ _query = [searchController.searchBar.text stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceCharacterSet];
+ [self rebuildVisibleApps];
+ [self.tableView reloadData];
+}
+
+- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
+{
+ return (_busy && !_apps.count) ? 1 : (NSInteger)self.visibleApps.count;
+}
+
+- (NSString *)tableView:(UITableView *)tableView titleForHeaderInSection:(NSInteger)section
+{
+ if (_status) return _status;
+ unsigned long long total = 0;
+ for (WQAppItem *a in _apps) total += a.bytes;
+ if (_query.length || [NSUserDefaults.standardUserDefaults integerForKey:kWQAppsFilterPref] > 0)
+ return WQLocalizedFormat(@"%lu of %lu apps", (unsigned long)self.visibleApps.count, (unsigned long)_apps.count);
+ return WQLocalizedFormat(@"%lu apps · %@ of app data", (unsigned long)_apps.count, wq_size_text(total));
+}
+
+- (NSString *)tableView:(UITableView *)tableView titleForFooterInSection:(NSInteger)section
+{
+ return WQLocalized(@"Tap an app to back up, restore or clear its Documents and Library. Close the app first (swipe it away in the app switcher); operations are not snapshot-consistent while it runs. Backups are stored in WQFiles/AppBackups and are visible in the Files app. tmp is cleared when requested but is not backed up or restored.");
+}
+
+- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
+{
+ UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"app"]
+ ?: [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleSubtitle reuseIdentifier:@"app"];
+ UIListContentConfiguration *c = cell.defaultContentConfiguration;
+ if (_busy && !_apps.count) {
+ c.text = WQLocalizedNullable(@"Scanning…");
+ cell.contentConfiguration = c;
+ cell.accessoryType = UITableViewCellAccessoryNone;
+ return cell;
+ }
+ WQAppItem *app = self.visibleApps[(NSUInteger)indexPath.row];
+ c.text = app.name;
+ NSString *identifier = app.bundleID.length ? app.bundleID : app.container.lastPathComponent;
+ BOOL hasBackup = app.backups.count > 0;
+ if (!app.hasAccess) {
+ c.secondaryText = WQLocalizedFormat(@"Access unavailable · %@", identifier);
+ } else if (!app.bundleID.length) {
+ c.secondaryText = WQLocalizedFormat(@"%@ · %@ · no identifier", wq_size_text(app.bytes), identifier);
+ } else if (!app.hasIdentity) {
+ c.secondaryText = WQLocalizedFormat(@"%@ · %@ · container identity unavailable", wq_size_text(app.bytes), app.bundleID);
+ } else if (!app.sizeKnown) {
+ c.secondaryText = WQLocalizedFormat(@"size unavailable · %@%@", app.bundleID, hasBackup ? WQLocalized(@" · backed up") : @"");
+ } else {
+ c.secondaryText = WQLocalizedFormat(@"%@ · %@%@", wq_size_text(app.bytes), app.bundleID, hasBackup ? WQLocalized(@" · backed up") : @"");
+ }
+ if (app.installState == WQInstallOffloaded) c.secondaryText = [c.secondaryText stringByAppendingString:WQLocalized(@" · offloaded")];
+ c.secondaryTextProperties.color = UIColor.secondaryLabelColor;
+ c.image = wq_app_icon(app.bundleID) ?: [UIImage systemImageNamed:@"app.dashed"];
+ c.imageProperties.maximumSize = CGSizeMake(36, 36);
+ cell.contentConfiguration = c;
+ cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator;
+ return cell;
+}
+
+- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath
+{
+ [tableView deselectRowAtIndexPath:indexPath animated:YES];
+ if (_busy || !self.visibleApps.count) return;
+ WQAppItem *app = self.visibleApps[(NSUInteger)indexPath.row];
+ if (!app.hasAccess || !app.bundleID.length || !app.hasIdentity) {
+ NSString *why = app.hasAccess && app.bundleID.length && !app.hasIdentity
+ ? WQLocalized(@"The container identity is unavailable or changed. Backup, restore and clear are disabled for this entry.")
+ : app.hasAccess
+ ? WQLocalized(@"The container identifier is unavailable. Backup, restore and clear are disabled for this entry.")
+ : WQLocalized(@"Sandbox access is unavailable. Check the App Group and see Log; only the container path can be copied.");
+ UIAlertController *sheet = wq_alert_controller(app.name, [NSString stringWithFormat:@"%@\n\n%@", app.container, why], UIAlertControllerStyleActionSheet);
+ if (app.hasAccess) {
+ [sheet addAction:wq_alert_action(WQLocalized(@"Open Data Folder"), UIAlertActionStyleDefault, ^(UIAlertAction *__unused a) {
+ [self.navigationController pushViewController:[[WQDirViewController alloc] initWithPath:app.container title:app.name] animated:YES];
+ })];
+ }
+ [sheet addAction:wq_alert_action(WQLocalized(@"Copy Container Path"), UIAlertActionStyleDefault, ^(UIAlertAction *__unused a) {
+ UIPasteboard.generalPasteboard.string = app.container;
+ })];
+ [sheet addAction:wq_alert_action(WQLocalized(@"Cancel"), UIAlertActionStyleCancel, nil)];
+ UITableViewCell *cell = [tableView cellForRowAtIndexPath:indexPath];
+ sheet.popoverPresentationController.sourceView = cell ?: self.view;
+ sheet.popoverPresentationController.sourceRect = (cell ?: self.view).bounds;
+ [self presentViewController:sheet animated:YES completion:nil];
+ return;
+ }
+ UIAlertController *sheet = wq_alert_controller(app.name, [NSString stringWithFormat:@"%@\n%@", app.bundleID,
+ app.sizeKnown ? WQLocalizedFormat(@"%@ of data", wq_size_text(app.bytes)) : WQLocalized(@"size unavailable")], UIAlertControllerStyleActionSheet);
+ [sheet addAction:wq_alert_action(WQLocalized(@"Back Up Data"), UIAlertActionStyleDefault, ^(UIAlertAction *__unused a) {
+ [self backUp:app then:nil];
+ })];
+ [sheet addAction:wq_alert_action(WQLocalized(@"Import as Backup…"), UIAlertActionStyleDefault, ^(UIAlertAction *__unused a) {
+ [self pickImportFor:app];
+ })];
+ NSArray *backups = app.backups;
+ NSString *latest = backups.firstObject[@"path"];
+ if (latest) {
+ NSString *title = backups.count == 1 ? WQLocalizedFormat(@"Restore Backup (%@)", wq_backup_label(backups.firstObject))
+ : WQLocalizedFormat(@"Restore a Backup… (%lu)", (unsigned long)backups.count);
+ [sheet addAction:wq_alert_action(title, UIAlertActionStyleDefault, ^(UIAlertAction *__unused a) {
+ [self pickBackupToRestore:app from:[tableView cellForRowAtIndexPath:indexPath]];
+ })];
+ [sheet addAction:wq_alert_action(WQLocalized(@"Share Latest Backup (AirDrop…)"), UIAlertActionStyleDefault, ^(UIAlertAction *__unused a) {
+ UITableViewCell *c = [tableView cellForRowAtIndexPath:indexPath];
+ wq_share_folder(self, latest, [NSString stringWithFormat:@"%@ %@", app.name, latest.lastPathComponent], c);
+ })];
+ }
+ if (wq_deleting_allowed()) {
+ [sheet addAction:wq_alert_action(WQLocalized(@"Clear Data…"), UIAlertActionStyleDestructive, ^(UIAlertAction *__unused a) {
+ [self confirmClear:app];
+ })];
+ }
+ [sheet addAction:wq_alert_action(WQLocalized(@"Open Data Folder"), UIAlertActionStyleDefault, ^(UIAlertAction *__unused a) {
+ [self.navigationController pushViewController:[[WQDirViewController alloc] initWithPath:app.container title:app.name] animated:YES];
+ })];
+ [sheet addAction:wq_alert_action(WQLocalized(@"Copy Bundle ID"), UIAlertActionStyleDefault, ^(UIAlertAction *__unused a) {
+ UIPasteboard.generalPasteboard.string = app.bundleID;
+ })];
+ [sheet addAction:wq_alert_action(WQLocalized(@"Cancel"), UIAlertActionStyleCancel, nil)];
+ UITableViewCell *cell = [tableView cellForRowAtIndexPath:indexPath];
+ sheet.popoverPresentationController.sourceView = cell ?: self.view;
+ sheet.popoverPresentationController.sourceRect = (cell ?: self.view).bounds;
+ [self presentViewController:sheet animated:YES completion:nil];
+}
+
+- (void)finish:(NSString *)title message:(NSString *)message
+{
+ [self setBusy:NO status:nil];
+ wq_alert(self, title, message);
+ [self scan];
+}
+
+// Import as Backup: a folder or ZIP with Documents and/or Library (from the
+// Files app, another device's shared backup or another tool) becomes a
+// normal, verified backup of this app, ready to restore.
+- (void)pickImportFor:(WQAppItem *)app
+{
+ _importApp = app;
+ UIDocumentPickerViewController *picker = [[UIDocumentPickerViewController alloc]
+ initForOpeningContentTypes:@[UTTypeFolder, UTTypeZIP] asCopy:NO];
+ picker.delegate = self;
+ picker.allowsMultipleSelection = NO;
+ [self presentViewController:picker animated:YES completion:nil];
+}
+
+- (void)documentPicker:(UIDocumentPickerViewController *)controller didPickDocumentsAtURLs:(NSArray *)urls
+{
+ WQAppItem *app = _importApp;
+ _importApp = nil;
+ if (app && urls.firstObject) [self importBackupFrom:urls.firstObject for:app confirmedOwner:NO];
+}
+
+- (void)documentPickerWasCancelled:(UIDocumentPickerViewController *)controller
+{
+ _importApp = nil;
+}
+
+- (void)importBackupFrom:(NSURL *)url for:(WQAppItem *)app confirmedOwner:(BOOL)confirmed
+{
+ [self setBusy:YES status:WQLocalizedFormat(@"Importing a backup of %@…", app.name)];
+ NSString *bundleID = app.bundleID;
+ dispatch_async(wq_io(), ^{
+ BOOL scoped = [url startAccessingSecurityScopedResource];
+ __block NSString *error = nil, *dest = nil, *declared = nil;
+ __block NSArray *ignored = @[];
+ NSString *work = [NSTemporaryDirectory() stringByAppendingPathComponent:
+ [@"wq-import-" stringByAppendingString:NSUUID.UUID.UUIDString]];
+ NSFileManager *fm = NSFileManager.defaultManager;
+ NSError *coordinationError = nil;
+ // Coordinated, so a folder in iCloud Drive is downloaded first.
+ [[NSFileCoordinator new] coordinateReadingItemAtURL:url options:NSFileCoordinatorReadingWithoutChanges
+ error:&coordinationError byAccessor:^(NSURL *readURL) {
+ NSString *source = readURL.path;
+ BOOL isDir = NO;
+ [fm fileExistsAtPath:source isDirectory:&isDir];
+ if (!isDir) {
+ // A ZIP: unpack it locally first, with the usual safety checks.
+ struct statfs fs;
+ unsigned long long available = statfs(NSTemporaryDirectory().fileSystemRepresentation, &fs) == 0
+ ? (unsigned long long)fs.f_bavail * fs.f_bsize : 0;
+ unsigned long long reserve = 256ull * 1024 * 1024;
+ unsigned long long limit = available > reserve ? (available - reserve) / 2 : 0;
+ [fm createDirectoryAtPath:work withIntermediateDirectories:YES attributes:nil error:nil];
+ NSUInteger files = 0, skipped = 0;
+ if (!wq_zip_extract(source, work, wq_archive_posix_io(), limit, nil, nil, &files, &skipped, &error)) {
+ error = WQLocalizedFormat(@"The ZIP couldn't be unpacked: %@", error ?: WQLocalized(@"unknown error"));
+ return;
+ }
+ source = work;
+ }
+ NSString *root = WQAppsSupportImportRoot(source, &error);
+ if (!root) return;
+ declared = WQAppsSupportImportDeclaredBundle(root);
+ if (declared.length && ![declared isEqualToString:bundleID] && !confirmed) return; // ask first
+ ignored = WQAppsSupportImportIgnored(root);
+ dest = wq_make_backup_from(bundleID, root, kBackupReasonImported, nil, &error);
+ }];
+ if (coordinationError && !error && !dest) error = coordinationError.localizedDescription;
+ if (scoped) [url stopAccessingSecurityScopedResource];
+ [fm removeItemAtPath:work error:nil];
+ wq_log(@"import backup %@ from %@: %@%@", bundleID, url.lastPathComponent,
+ dest ? dest.lastPathComponent : (error ?: @"needs confirmation"),
+ ignored.count ? [NSString stringWithFormat:@" (left out: %@)", [ignored componentsJoinedByString:@", "]] : @"");
+ dispatch_async(dispatch_get_main_queue(), ^{
+ [self setBusy:NO status:nil];
+ if (!dest && !error && declared.length) {
+ UIAlertController *ask = wq_alert_controller(@"Different app?",
+ WQLocalizedFormat(@"This data says it belongs to %@, not %@ (%@). Importing it as a backup of %@ and restoring it can break the app.",
+ declared, app.name, bundleID, app.name), UIAlertControllerStyleAlert);
+ [ask addAction:wq_alert_action(@"Cancel", UIAlertActionStyleCancel, nil)];
+ [ask addAction:wq_alert_action(@"Import Anyway", UIAlertActionStyleDestructive, ^(UIAlertAction *__unused a) {
+ [self importBackupFrom:url for:app confirmedOwner:YES];
+ })];
+ [self presentViewController:ask animated:YES completion:nil];
+ return;
+ }
+ if (!dest) {
+ wq_alert(self, @"Not imported", WQLocalizedFormat(@"%@ Nothing was changed.", error ?: WQLocalized(@"The import failed.")));
+ return;
+ }
+ NSString *message = WQLocalizedFormat(@"Saved as backup %@ of %@. It's verified like any other backup and can be restored from Apps.%@",
+ dest.lastPathComponent, app.name,
+ ignored.count ? WQLocalizedFormat(@"\n\nLeft out (only Documents and Library are backed up): %@.",
+ [ignored componentsJoinedByString:@", "]) : @"");
+ UIAlertController *done = wq_alert_controller(@"Imported", message, UIAlertControllerStyleAlert);
+ [done addAction:wq_alert_action(@"Done", UIAlertActionStyleCancel, ^(UIAlertAction *__unused a) {
+ [self scan];
+ })];
+ [done addAction:wq_alert_action(@"Restore Now…", UIAlertActionStyleDefault, ^(UIAlertAction *__unused a) {
+ self->_restoreBundleID = bundleID; // offered once the list is read again
+ [self scan];
+ })];
+ [self presentViewController:done animated:YES completion:nil];
+ });
+ });
+}
+
+- (void)backUp:(WQAppItem *)app then:(void (^)(BOOL ok))next
+{
+ [self setBusy:YES status:WQLocalizedFormat(@"Backing up %@…", app.name)];
+ dispatch_async(wq_io(), ^{
+ NSString *error = nil;
+ NSString *dest = wq_make_app_backup(app.bundleID, app.container, app.containerDevice,
+ app.containerInode, nil, &error);
+ dispatch_async(dispatch_get_main_queue(), ^{
+ if (next) { next(dest != nil); return; }
+ [self finish:dest ? WQLocalized(@"Backed up") : WQLocalized(@"Backup failed")
+ message:dest ? WQLocalizedFormat(@"Saved to AppBackups/%@/%@", app.bundleID, dest.lastPathComponent)
+ : WQLocalizedFormat(@"Nothing was saved: %@", error ?: @"see Log")];
+ });
+ });
+}
+
+- (void)clear:(WQAppItem *)app
+{
+ [self setBusy:YES status:WQLocalizedFormat(@"Clearing %@…", app.name)];
+ dispatch_async(wq_io(), ^{
+ unsigned long long freed = 0;
+ NSString *error = nil;
+ BOOL ok = wq_validate_container(app.container, app.bundleID, app.containerDevice, app.containerInode, &error);
+ for (NSString *part in kAppDataParts()) {
+ if (!ok) break;
+ if (!wq_validate_container(app.container, app.bundleID, app.containerDevice, app.containerInode, &error) ||
+ !wq_purge_and_verify([app.container stringByAppendingPathComponent:part], &freed, &error)) ok = NO;
+ }
+ if (ok && !wq_validate_container(app.container, app.bundleID, app.containerDevice, app.containerInode, &error)) ok = NO;
+ wq_log(@"clear data: %@ freed %llu ok=%d", app.bundleID, freed, ok);
+ dispatch_async(dispatch_get_main_queue(), ^{
+ [self finish:ok ? WQLocalized(@"Data cleared") : WQLocalized(@"Partly cleared")
+ message:WQLocalizedFormat(@"Freed about %@.%@ Open %@ to start fresh.", wq_size_text(freed),
+ ok ? @"" : WQLocalizedFormat(@" %@. Keep the app closed and try again.", error ?: WQLocalized(@"Some items could not be removed (see Log)")), app.name)];
+ });
+ });
+}
+
+- (void)confirmClear:(WQAppItem *)app
+{
+ if (wq_refuse_if_deleting_off(self)) return;
+ UIAlertController *alert = wq_alert_controller(WQLocalizedFormat(@"Clear %@?", app.name),
+ @"Deletes everything in the app's Documents, Library and tmp, like a fresh install. "
+ "The optional backup covers Documents and Library only; tmp is discarded and cannot be restored. "
+ "Logins, settings and offline content are lost. Close the app first.", UIAlertControllerStyleAlert);
+ [alert addAction:wq_alert_action(WQLocalized(@"Back Up, Then Clear"), UIAlertActionStyleDestructive, ^(UIAlertAction *__unused a) {
+ [self backUp:app then:^(BOOL ok) {
+ if (ok) [self clear:app];
+ else [self finish:WQLocalized(@"Not cleared") message:WQLocalized(@"The backup did not complete, so nothing was deleted.")];
+ }];
+ })];
+ [alert addAction:wq_alert_action(WQLocalized(@"Clear Without Backup"), UIAlertActionStyleDestructive, ^(UIAlertAction *__unused a) {
+ [self clear:app];
+ })];
+ [alert addAction:wq_alert_action(WQLocalized(@"Cancel"), UIAlertActionStyleCancel, nil)];
+ [self presentViewController:alert animated:YES completion:nil];
+}
+
+- (void)viewDidAppear:(BOOL)animated
+{
+ [super viewDidAppear:animated];
+ if (_apps && !_scanning) [self offerPendingRestore]; // the list was read before the push finished
+}
+
+- (void)offerPendingRestore
+{
+ NSString *bid = _restoreBundleID;
+ if (!bid || !self.view.window || self.presentedViewController) return;
+ _restoreBundleID = nil;
+ WQAppItem *app = nil;
+ for (WQAppItem *candidate in _apps) if ([candidate.bundleID isEqualToString:bid]) { app = candidate; break; }
+ if (!app) {
+ wq_alert(self, @"App not found", WQLocalizedFormat(@"%@ isn't installed, or WQ Files can't reach its data. "
+ "A backup can only be restored into the installed app: install it, open it once, then try again.", bid));
+ } else if (!app.backups.count) {
+ wq_alert(self, @"No backup", WQLocalizedFormat(@"There is no complete backup of %@ to restore.", app.name));
+ } else {
+ [self pickBackupToRestore:app from:nil];
+ }
+}
+
+- (void)pickBackupToRestore:(WQAppItem *)app from:(UIView *)source
+{
+ NSArray *backups = app.backups;
+ if (backups.count == 1) { [self confirmRestore:app from:backups.firstObject]; return; }
+ UIAlertController *sheet = wq_alert_controller(WQLocalized(@"Restore which backup?"), app.name, UIAlertControllerStyleActionSheet);
+ for (NSDictionary *__unused b in backups) {
+ [sheet addAction:wq_alert_action(wq_backup_label(b), UIAlertActionStyleDefault, ^(UIAlertAction *__unused a) { [self confirmRestore:app from:b]; })];
+ }
+ [sheet addAction:wq_alert_action(WQLocalized(@"Cancel"), UIAlertActionStyleCancel, nil)];
+ sheet.popoverPresentationController.sourceView = source ?: self.view;
+ sheet.popoverPresentationController.sourceRect = (source ?: self.view).bounds;
+ [self presentViewController:sheet animated:YES completion:nil];
+}
+
+- (void)confirmRestore:(WQAppItem *)app from:(NSDictionary *)backup
+{
+ NSString *path = backup[@"path"];
+ NSString *warning = [backup[@"verified"] boolValue] ? @""
+ : WQLocalizedFormat(@"\n\nThis backup is unverified (%@). WQ Files will re-check its parts before changing anything.", backup[@"reason"] ?: WQLocalized(@"no typed manifest"));
+ NSString *body = wq_keep_before_restore()
+ ? WQLocalizedFormat(@"Replaces the app's current Documents and Library with the backup from %@; tmp is left untouched. The current data is backed up first, and put back if anything fails. Close the app first: a running app can make the operation non-snapshot-consistent.%@", wq_backup_label(backup), warning)
+ : WQLocalizedFormat(@"Replaces the app's current Documents and Library with the backup from %@; tmp is left untouched. The current data is backed up first, and put back if anything fails; after a successful restore that backup is deleted (Keep Data Before Restore is off). Close the app first: a running app can make the operation non-snapshot-consistent.%@", wq_backup_label(backup), warning);
+ UIAlertController *alert = wq_alert_controller(WQLocalizedFormat(@"Restore %@?", app.name), body, UIAlertControllerStyleAlert);
+ [alert addAction:wq_alert_action(WQLocalized(@"Cancel"), UIAlertActionStyleCancel, nil)];
+ [alert addAction:wq_alert_action(WQLocalized(@"Restore"), UIAlertActionStyleDestructive, ^(UIAlertAction *__unused a) {
+ [self setBusy:YES status:WQLocalizedFormat(@"Restoring %@…", app.name)];
+ dispatch_async(wq_io(), ^{
+ NSString *title = nil, *message = nil;
+ [self restore:app from:path title:&title message:&message];
+ dispatch_async(dispatch_get_main_queue(), ^{ [self finish:title message:message]; });
+ });
+ })];
+ [self presentViewController:alert animated:YES completion:nil];
+}
+
+// Replaces one part (Documents/Library) of the container with `src`.
+static BOOL wq_replace_part(NSString *src, NSString *dst, NSString **error)
+{
+ // Stage the source before purging the live destination. If a named safety
+ // part disappears or becomes unreadable, no live data may be purged first.
+ NSString *staging = [NSTemporaryDirectory() stringByAppendingPathComponent:
+ [NSString stringWithFormat:@"WQAppsRestore-%@", NSUUID.UUID.UUIDString]];
+ BOOL staged = wq_copy_part_and_verify(src, staging, error);
+ if (!staged) {
+ [NSFileManager.defaultManager removeItemAtPath:staging error:nil];
+ return NO;
+ }
+ unsigned long long ignored = 0;
+ BOOL ok = wq_purge_and_verify(dst, &ignored, error);
+ if (ok) {
+ ok = wq_copy_part_and_verify(staging, dst, error);
+ if (!ok && error && !*error) *error = [NSString stringWithFormat:@"Could not restore %@", dst.lastPathComponent];
+ }
+ [NSFileManager.defaultManager removeItemAtPath:staging error:nil];
+ return ok;
+}
+
+static NSString *wq_restore_journal_path(void)
+{
+ NSString *support = NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, NSUserDomainMask, YES).firstObject;
+ return [support stringByAppendingPathComponent:@"active-restore.plist"];
+}
+
+static BOOL wq_read_restore_journal(NSMutableDictionary **journalOut, BOOL *existsOut, NSString **error)
+{
+ if (journalOut) *journalOut = nil;
+ if (existsOut) *existsOut = NO;
+ NSString *path = wq_restore_journal_path();
+ if (![NSFileManager.defaultManager fileExistsAtPath:path]) return YES;
+ NSDictionary *journal = [NSDictionary dictionaryWithContentsOfFile:path];
+ if (![journal isKindOfClass:NSDictionary.class]) {
+ if (error) *error = @"The restore recovery journal is unreadable";
+ return NO;
+ }
+ if (existsOut) *existsOut = YES;
+ if (journalOut) *journalOut = [journal mutableCopy];
+ return YES;
+}
+
+static BOOL wq_write_restore_journal(NSDictionary *journal, NSString **error)
+{
+ NSString *path = wq_restore_journal_path();
+ if (![NSFileManager.defaultManager createDirectoryAtPath:path.stringByDeletingLastPathComponent
+ withIntermediateDirectories:YES attributes:nil error:nil]) {
+ if (error) *error = @"Cannot create the restore recovery folder";
+ return NO;
+ }
+ if (![journal writeToFile:path atomically:YES]) {
+ if (error) *error = @"Cannot write the restore recovery journal";
+ return NO;
+ }
+ return YES;
+}
+
+static BOOL wq_remove_restore_journal(NSString **error);
+
+static BOOL wq_begin_restore_journal(WQAppItem *app, NSString *backup, NSString *safety, NSString **error)
+{
+ if (!wq_backup_root_child(backup, app.bundleID) || !wq_backup_root_child(safety, app.bundleID)) {
+ if (error) *error = WQLocalized(@"The restore paths are not inside this app's backup folder");
+ return NO;
+ }
+ BOOL exists = NO;
+ NSMutableDictionary *existing = nil;
+ NSString *readError = nil;
+ if (!wq_read_restore_journal(&existing, &exists, &readError)) {
+ if (error) *error = readError;
+ return NO;
+ }
+ if (exists) {
+ WQAppsSupportJournalState state = WQAppsSupportJournalStateForDictionary(existing);
+ NSString *policyError = nil;
+ if (!WQAppsSupportJournalAllowsNewRestore(state, &policyError)) {
+ if (error) *error = policyError ? WQLocalized(policyError) : nil;
+ return NO;
+ }
+ if (state == WQAppsSupportJournalCommitted &&
+ !wq_remove_restore_journal(&readError)) {
+ if (error) *error = readError ?: @"Cannot remove the completed restore journal";
+ return NO;
+ }
+ }
+ NSDictionary *journal = @{ @"Format": @2, @"State": @"Active", @"BundleID": app.bundleID,
+ @"Container": app.container, @"Backup": backup, @"Safety": safety,
+ @"ContainerDevice": @(app.containerDevice), @"ContainerInode": @(app.containerInode),
+ @"Started": NSDate.date };
+ return wq_write_restore_journal(journal, error);
+}
+
+// Marking committed is the point of no return for a completed restore. It is
+// deliberately separate from removal: a committed journal must never cause a
+// later rollback, even if the file itself cannot be deleted right now.
+static BOOL wq_mark_restore_journal_committed(NSString **error)
+{
+ BOOL exists = NO;
+ NSMutableDictionary *journal = nil;
+ NSString *readError = nil;
+ if (!wq_read_restore_journal(&journal, &exists, &readError)) {
+ if (error) *error = readError;
+ return NO;
+ }
+ if (!exists) return YES;
+ journal[@"State"] = @"Committed";
+ return wq_write_restore_journal(journal, error);
+}
+
+static BOOL wq_remove_restore_journal(NSString **error)
+{
+ NSString *path = wq_restore_journal_path();
+ NSFileManager *fm = NSFileManager.defaultManager;
+ if (![fm fileExistsAtPath:path]) return YES;
+ NSError *removeError = nil;
+ if (![fm removeItemAtPath:path error:&removeError]) {
+ if (error) *error = removeError.localizedDescription ?: @"Cannot remove the restore recovery journal";
+ return NO;
+ }
+ return YES;
+}
+
+static BOOL wq_finish_restore_journal(NSString **error)
+{
+ if (!wq_mark_restore_journal_committed(error)) return NO;
+ return wq_remove_restore_journal(error);
+}
+
+static BOOL wq_validate_backup_hashes(NSString *backup, NSArray *parts,
+ NSDictionary *manifest, NSString **error)
+{
+ NSDictionary *hashes = manifest[@"PartHashes"];
+ if (!hashes) return YES; // backups made before content hashes existed
+ for (NSString *part in parts) {
+ NSString *expected = hashes[part];
+ if (![expected isKindOfClass:NSString.class] || !expected.length) {
+ if (error) *error = WQLocalizedFormat(@"The backup has no hash for %@", part);
+ return NO;
+ }
+ BOOL exists = NO;
+ NSString *actual = WQAppsSupportTreeContentSignature([backup stringByAppendingPathComponent:part],
+ &exists, error);
+ if (!actual || !exists || ![actual isEqualToString:expected]) {
+ if (error) *error = WQLocalizedFormat(@"The backup's %@ contents changed after it was created", part);
+ return NO;
+ }
+ }
+ return YES;
+}
+
+static BOOL wq_rollback_safety(NSString *container, NSString *bundleID, NSString *safety,
+ dev_t expectedDevice, ino_t expectedInode, NSString **error)
+{
+ NSArray *parts = nil;
+ NSDictionary *safetyManifest = nil;
+ if (!wq_validate_container(container, bundleID, expectedDevice, expectedInode, error) ||
+ !wq_validate_backup(safety, bundleID, NO, &parts, &safetyManifest, NULL, error) ||
+ !wq_validate_backup_hashes(safety, parts, safetyManifest, error)) return NO;
+ // Revalidate everything immediately before the first mutation. In
+ // particular, a missing named safety part is never interpreted as an
+ // instruction to purge the live part.
+ if (!wq_validate_container(container, bundleID, expectedDevice, expectedInode, error) ||
+ !wq_validate_backup(safety, bundleID, NO, &parts, &safetyManifest, NULL, error) ||
+ !wq_validate_backup_hashes(safety, parts, safetyManifest, error)) return NO;
+ BOOL ok = YES;
+ for (NSString *part in kAppBackupParts()) {
+ NSString *saved = [safety stringByAppendingPathComponent:part];
+ NSString *dst = [container stringByAppendingPathComponent:part];
+ if (!wq_validate_container(container, bundleID, expectedDevice, expectedInode, error)) {
+ ok = NO;
+ break;
+ }
+ if ([parts containsObject:part]) {
+ if (!wq_replace_part(saved, dst, error)) ok = NO;
+ } else {
+ unsigned long long ignored = 0;
+ if (!wq_purge_and_verify(dst, &ignored, error)) {
+ ok = NO;
+ }
+ }
+ if (!ok) break;
+ }
+ if (ok && !wq_validate_container(container, bundleID, expectedDevice, expectedInode, error)) ok = NO;
+ return ok;
+}
+
+// Called on the serial I/O queue at launch. An active journal means termination
+// may have left only some restored parts in place, so reapply the safety state.
+NSString *wq_recover_interrupted_restore(void)
+{
+ NSString *path = wq_restore_journal_path();
+ NSDictionary *journal = [NSDictionary dictionaryWithContentsOfFile:path];
+ if (!journal) return nil;
+ NSString *state = [journal[@"State"] isKindOfClass:NSString.class] ? journal[@"State"] : nil;
+ if ([state isEqualToString:@"Committed"]) {
+ NSString *removeError = nil;
+ if (!wq_remove_restore_journal(&removeError)) {
+ wq_log(@"restore recovery: committed journal cleanup failed: %@", removeError);
+ }
+ return nil;
+ }
+ if (![state isEqualToString:@"Active"]) {
+ wq_log(@"restore recovery: unknown journal state at %@", path);
+ return WQLocalized(@"An interrupted restore was found with an unknown recovery state. The journal was retained for manual recovery.");
+ }
+ NSString *bundleID = journal[@"BundleID"], *container = journal[@"Container"], *safety = journal[@"Safety"];
+ NSString *backup = journal[@"Backup"];
+ id deviceValue = journal[@"ContainerDevice"];
+ id inodeValue = journal[@"ContainerInode"];
+ NSString *journalError = nil;
+ BOOL stringFields = [bundleID isKindOfClass:NSString.class] && [container isKindOfClass:NSString.class] &&
+ [safety isKindOfClass:NSString.class] && [backup isKindOfClass:NSString.class];
+ BOOL identityFields = WQAppsSupportIsInteger(deviceValue) && WQAppsSupportIsInteger(inodeValue) &&
+ [deviceValue compare:@1] != NSOrderedAscending && [inodeValue compare:@1] != NSOrderedAscending;
+ dev_t device = identityFields ? [deviceValue unsignedLongLongValue] : 0;
+ ino_t inode = identityFields ? [inodeValue unsignedLongLongValue] : 0;
+ BOOL journalTyped = stringFields && WQAppsSupportIsInteger(journal[@"Format"]) && [journal[@"Format"] unsignedIntegerValue] == 2 &&
+ identityFields && bundleID.length && container.length && safety.length && backup.length && device && inode &&
+ wq_backup_root_child(backup, bundleID) && wq_backup_root_child(safety, bundleID) &&
+ wq_validate_container(container, bundleID, device, inode, &journalError);
+ if (!journalTyped) {
+ wq_log(@"restore recovery: invalid journal or incomplete safety backup at %@", path);
+ return [NSString stringWithFormat:WQLocalized(@"An interrupted restore was found, but its recovery journal is invalid. The journal was retained for manual recovery%@."),
+ journalError.length ? [NSString stringWithFormat:@" (%@)", journalError] : @""];
+ }
+ NSString *error = nil;
+ if (!wq_rollback_safety(container, bundleID, safety, device, inode, &error)) {
+ wq_log(@"restore recovery for %@: FAILED (%@)", bundleID, error);
+ return [NSString stringWithFormat:WQLocalized(@"Automatic recovery of %@ failed: %@. The safety backup is %@; see Log."),
+ bundleID, error ?: WQLocalized(@"unknown error"), safety.lastPathComponent];
+ }
+ NSString *commitError = nil;
+ NSString *removeError = nil;
+ if (!wq_mark_restore_journal_committed(&commitError)) {
+ wq_log(@"restore recovery for %@: recovered, journal commit failed (%@)", bundleID, commitError);
+ return [NSString stringWithFormat:WQLocalized(@"An interrupted restore of %@ was recovered, but its journal could not be committed (%@). It will be checked again at the next launch."),
+ bundleID, commitError ?: WQLocalized(@"unknown error")];
+ }
+ if (!wq_remove_restore_journal(&removeError)) {
+ wq_log(@"restore recovery for %@: recovered, cleanup pending (%@)", bundleID, removeError);
+ return [NSString stringWithFormat:WQLocalized(@"An interrupted restore of %@ was recovered. Its committed journal will be cleaned up at the next launch (%@)."),
+ bundleID, removeError ?: @"cleanup failed"];
+ }
+ wq_log(@"restore recovery for %@: OK", bundleID);
+ return [NSString stringWithFormat:WQLocalized(@"An interrupted restore of %@ was recovered from its safety backup."), bundleID];
+}
+
+- (void)restore:(WQAppItem *)app from:(NSString *)backup title:(NSString **)title message:(NSString **)message
+{
+ // 1. Validate all typed manifest fields and all required parts before any
+ // safety backup, journal, purge, or replacement is allowed.
+ NSArray *parts = nil;
+ NSDictionary *manifest = nil;
+ BOOL verified = NO;
+ NSString *error = nil;
+ if (!wq_validate_backup(backup, app.bundleID, YES, &parts, &manifest, &verified, &error)) {
+ *title = WQLocalized(@"Not restored");
+ *message = WQLocalizedFormat(@"The backup is not safe to restore (%@). Nothing was changed.", error ?: WQLocalized(@"invalid manifest"));
+ return;
+ }
+ if (!verified && !parts.count) {
+ *title = WQLocalized(@"Not restored");
+ *message = WQLocalized(@"The legacy backup is empty. Nothing was changed.");
+ return;
+ }
+ if (!wq_validate_container(app.container, app.bundleID, app.containerDevice, app.containerInode, &error)) {
+ *title = WQLocalized(@"Not restored");
+ *message = WQLocalizedFormat(@"The selected app container is no longer safe to use (%@). Nothing was changed.", error);
+ return;
+ }
+ // 2. Keep the current data and a durable recovery journal before touching
+ // anything, so both ordinary failures and process termination are covered.
+ NSString *safety = wq_make_app_backup(app.bundleID, app.container, app.containerDevice,
+ app.containerInode, kBackupReasonBeforeRestore, &error);
+ if (!safety) {
+ *title = WQLocalized(@"Not restored");
+ *message = WQLocalizedFormat(@"The current data couldn't be backed up first (%@). Nothing was changed.", error ?: WQLocalized(@"see Log"));
+ return;
+ }
+ if (!wq_begin_restore_journal(app, backup, safety, &error)) {
+ *title = WQLocalized(@"Not restored");
+ *message = [NSString stringWithFormat:@"%@. Nothing was changed; the safety backup was retained at %@ for manual recovery.",
+ error ?: WQLocalized(@"The recovery journal could not be written"), safety.lastPathComponent];
+ return;
+ }
+ // 3. Re-read and validate the selected backup immediately before the first
+ // mutation. A changed or incomplete source fails closed and retains the
+ // journal/safety material.
+ error = nil;
+ NSArray *preRestoreParts = nil;
+ NSDictionary *preRestoreManifest = nil;
+ if (!wq_validate_container(app.container, app.bundleID, app.containerDevice, app.containerInode, &error) ||
+ !wq_validate_backup(backup, app.bundleID, YES, &preRestoreParts, &preRestoreManifest, NULL, &error) ||
+ ![preRestoreParts isEqualToArray:parts] ||
+ !wq_validate_backup_hashes(backup, preRestoreParts, preRestoreManifest, &error)) {
+ error = error ?: WQLocalized(@"The selected backup changed before restore");
+ }
+ BOOL ok = error == nil;
+ NSDictionary *restorePlan = WQRestorePlan([parts containsObject:@"Documents"],
+ [parts containsObject:@"Library"]);
+ for (NSString *part in kAppBackupParts()) {
+ if (!ok) break;
+ NSString *src = [backup stringByAppendingPathComponent:part];
+ NSString *dst = [app.container stringByAppendingPathComponent:part];
+ if (!wq_validate_container(app.container, app.bundleID, app.containerDevice, app.containerInode, &error)) {
+ ok = NO;
+ break;
+ }
+ if ([restorePlan[part] boolValue]) {
+ if (!wq_replace_part(src, dst, &error)) { ok = NO; break; }
+ } else {
+ unsigned long long ignored = 0;
+ if (!wq_purge_and_verify(dst, &ignored, &error)) {
+ ok = NO;
+ break;
+ }
+ }
+ }
+ NSArray *postRestoreParts = nil;
+ if (ok && (!wq_validate_container(app.container, app.bundleID, app.containerDevice, app.containerInode, &error) ||
+ !wq_validate_backup(backup, app.bundleID, YES, &postRestoreParts, NULL, NULL, &error) ||
+ ![postRestoreParts isEqualToArray:parts])) {
+ ok = NO;
+ error = error ?: WQLocalized(@"The selected backup changed during restore");
+ }
+ if (ok) {
+ NSString *commitError = nil;
+ NSString *removeError = nil;
+ if (wq_mark_restore_journal_committed(&commitError)) {
+ BOOL removed = wq_remove_restore_journal(&removeError);
+ if (!removed) wq_log(@"restore %@: journal cleanup pending (%@)", app.bundleID, removeError);
+ wq_log(@"restore %@ from %@: OK", app.bundleID, backup.lastPathComponent);
+ *title = WQLocalized(@"Restored");
+ NSString *cleanup = removed ? @"" : WQLocalized(@" The recovery journal is committed and will be cleaned up at the next launch.");
+ // Keep Data Before Restore off: the safety backup was only needed
+ // while the restore could still fail. Kept while the journal still
+ // points at it.
+ NSString *discardError = nil;
+ if (!wq_keep_before_restore() && removed) {
+ if (wq_delete_backup(safety, &discardError)) {
+ wq_log(@"restore %@: removed the before-restore backup %@ (Keep Data Before Restore is off)",
+ app.bundleID, safety.lastPathComponent);
+ *message = WQLocalizedFormat(@"Open %@ to use the restored Documents and Library; tmp was not changed. The data from before was not kept (Keep Data Before Restore is off).", app.name);
+ return;
+ }
+ wq_log(@"restore %@: couldn't remove the before-restore backup %@ (%@)", app.bundleID,
+ safety.lastPathComponent, discardError);
+ }
+ *message = WQLocalizedFormat(@"Open %@ to use the restored Documents and Library. The data from before is kept as backup %@; tmp was not changed. Keep the app closed during this operation.%@", app.name, safety.lastPathComponent, cleanup);
+ return;
+ }
+ // The journal is still Active. Rolling back is the only safe outcome
+ // if it cannot be marked committed, because a completed restore must
+ // not be rolled back by the next launch.
+ error = WQLocalizedFormat(@"The restore finished, but its recovery journal could not be committed (%@)", commitError);
+ }
+ wq_log(@"restore %@ from %@: FAILED (%@), rolling back", app.bundleID, backup.lastPathComponent, error);
+ NSString *rollbackError = nil;
+ BOOL rolledBack = wq_rollback_safety(app.container, app.bundleID, safety,
+ app.containerDevice, app.containerInode, &rollbackError);
+ // Keep the journal when the rollback failed, so the next launch tries the
+ // recovery again instead of leaving the app half restored.
+ NSString *cleanupError = nil;
+ BOOL cleared = NO;
+ if (rolledBack) cleared = wq_finish_restore_journal(&cleanupError);
+ if (!rolledBack) cleanupError = @"kept for recovery at the next launch";
+ wq_log(@"restore %@: rollback %@, journal %@", app.bundleID,
+ rolledBack ? @"OK" : @"FAILED", cleared ? @"cleared" : cleanupError);
+ *title = WQLocalized(@"Restore failed");
+ *message = rolledBack
+ ? [NSString stringWithFormat:@"%@. The previous data was put back.%@", error ?: WQLocalized(@"Some files couldn't be written"),
+ cleared ? @"" : WQLocalized(@" Recovery will run again at the next launch because the journal could not be removed.")]
+ : [NSString stringWithFormat:@"%@, and putting the previous data back also failed (%@). It is in backup %@; see the Log.%@",
+ error ?: WQLocalized(@"Some files couldn't be written"), rollbackError ?: WQLocalized(@"see Log"), safety.lastPathComponent,
+ cleared ? @"" : WQLocalized(@" Recovery will run again at the next launch.")];
+}
+
+@end
diff --git a/source/hook/src/WQAppsSupport.h b/source/hook/src/WQAppsSupport.h
new file mode 100644
index 0000000..c55c2d7
--- /dev/null
+++ b/source/hook/src/WQAppsSupport.h
@@ -0,0 +1,431 @@
+#import
+#import
+#import
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+// 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 **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 @"";
+ }
+ 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 *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 @"";
+ }
+ 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 *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 *level = @[dir];
+ for (int depth = 0; depth <= 3 && level.count; depth++) {
+ NSMutableArray *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 *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;
+}
diff --git a/source/hook/src/WQArchive.h b/source/hook/src/WQArchive.h
new file mode 100644
index 0000000..33b3355
--- /dev/null
+++ b/source/hook/src/WQArchive.h
@@ -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
+#import
+
+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 *_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 *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
diff --git a/source/hook/src/WQArchive.m b/source/hook/src/WQArchive.m
new file mode 100644
index 0000000..a50b20b
--- /dev/null
+++ b/source/hook/src/WQArchive.m
@@ -0,0 +1,575 @@
+//
+// WQArchive.m
+// WQ Files
+//
+
+#import "WQArchive.h"
+#import "WQLocalization.h"
+#import
+#import
+#import
+#import
+#import
+#import
+
+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 *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 *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 *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 *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 *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 *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 *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;
+}
diff --git a/source/hook/src/WQCache.h b/source/hook/src/WQCache.h
new file mode 100644
index 0000000..e620f36
--- /dev/null
+++ b/source/hook/src/WQCache.h
@@ -0,0 +1,4 @@
+#import
+
+@interface WQCacheCleanerViewController : UITableViewController
+@end
diff --git a/source/hook/src/WQCache.m b/source/hook/src/WQCache.m
new file mode 100644
index 0000000..8378dc9
--- /dev/null
+++ b/source/hook/src/WQCache.m
@@ -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
+#import
+
+#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 *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 *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 *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 *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 *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 *_items;
+ NSArray *_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 *)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 *items = [NSMutableArray new];
+ NSMutableArray *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 *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 *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 *__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 *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 *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
diff --git a/source/hook/src/WQCacheSafety.h b/source/hook/src/WQCacheSafety.h
new file mode 100644
index 0000000..be692fd
--- /dev/null
+++ b/source/hook/src/WQCacheSafety.h
@@ -0,0 +1,78 @@
+#ifndef WQ_CACHE_SAFETY_H
+#define WQ_CACHE_SAFETY_H
+
+#import
+#import
+#import
+#import
+#import
+
+// 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 *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
diff --git a/source/hook/src/WQCore.h b/source/hook/src/WQCore.h
new file mode 100644
index 0000000..182c291
--- /dev/null
+++ b/source/hook/src/WQCore.h
@@ -0,0 +1,35 @@
+//
+// WQCore.h
+// WQ Files
+//
+// GPL-3.0-or-later.
+//
+
+#import
+
+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 *WQRestorePlan(BOOL hasDocuments, BOOL hasLibrary);
+
+NS_ASSUME_NONNULL_END
diff --git a/source/hook/src/WQCore.m b/source/hook/src/WQCore.m
new file mode 100644
index 0000000..3b72d92
--- /dev/null
+++ b/source/hook/src/WQCore.m
@@ -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 *WQRestorePlan(BOOL hasDocuments, BOOL hasLibrary)
+{
+ return @{ @"Documents": @(hasDocuments), @"Library": @(hasLibrary) };
+}
diff --git a/source/hook/src/WQDir.h b/source/hook/src/WQDir.h
new file mode 100644
index 0000000..ac68bed
--- /dev/null
+++ b/source/hook/src/WQDir.h
@@ -0,0 +1,15 @@
+//
+// WQDir.h
+// WQ Files folder browser interface
+//
+
+#import
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface WQDirViewController : UITableViewController
+@property (nonatomic) BOOL readOnly;
+- (instancetype)initWithPath:(NSString *)path title:(NSString *_Nullable)title;
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/source/hook/src/WQEditing.h b/source/hook/src/WQEditing.h
new file mode 100644
index 0000000..9d24b0a
--- /dev/null
+++ b/source/hook/src/WQEditing.h
@@ -0,0 +1,20 @@
+//
+// WQEditing.h
+// WQ Files
+//
+// GPL-3.0-or-later.
+//
+
+#import
+
+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
+- (BOOL)wq_needsLeaveConfirmation;
+- (void)confirmLeaveThen:(void (^)(void))proceed;
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/source/hook/src/WQHexViewer.h b/source/hook/src/WQHexViewer.h
new file mode 100644
index 0000000..764e2d0
--- /dev/null
+++ b/source/hook/src/WQHexViewer.h
@@ -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
+#import "WQEditing.h"
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface WQHexViewController : UITableViewController
+- (instancetype)initWithPath:(NSString *)path readOnly:(BOOL)readOnly;
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/source/hook/src/WQHexViewer.m b/source/hook/src/WQHexViewer.m
new file mode 100644
index 0000000..d55cce0
--- /dev/null
+++ b/source/hook/src/WQHexViewer.m
@@ -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
+
+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 *_pages;
+ NSMutableSet *_loading;
+ NSMutableDictionary *_edits; // offset -> new byte
+ WQEditState *_editState;
+ NSDictionary *_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 *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 *)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
diff --git a/source/hook/src/WQHexViewerHelpers.h b/source/hook/src/WQHexViewerHelpers.h
new file mode 100644
index 0000000..87f04b1
--- /dev/null
+++ b/source/hook/src/WQHexViewerHelpers.h
@@ -0,0 +1,58 @@
+//
+// WQHexViewerHelpers.h
+// WQ Files
+//
+// Small Foundation-portable validation helpers for the hex viewer.
+//
+
+#ifndef WQHexViewerHelpers_h
+#define WQHexViewerHelpers_h
+
+#import
+#include
+#include