From c1d0293404e8dd71566b583a0436a62fd27eed34 Mon Sep 17 00:00:00 2001 From: admin Date: Thu, 12 Nov 2026 02:27:33 +0800 Subject: [PATCH] Add iOS 27 hook source, browser integration and build scripts --- .gitignore | 3 + README.md | 3 + source/LICENSE | 674 +++++ source/README.md | 112 + source/build_app.sh | 69 + source/build_dylib.sh | 33 + source/hook/src/WBHook.m | 1724 +++++++++++++ source/hook/src/WQAccess.h | 193 ++ source/hook/src/WQAccess.m | 2587 +++++++++++++++++++ source/hook/src/WQAccessSafety.h | 323 +++ source/hook/src/WQApps.h | 11 + source/hook/src/WQApps.m | 1674 +++++++++++++ source/hook/src/WQAppsSupport.h | 431 ++++ source/hook/src/WQArchive.h | 58 + source/hook/src/WQArchive.m | 575 +++++ source/hook/src/WQCache.h | 4 + source/hook/src/WQCache.m | 933 +++++++ source/hook/src/WQCacheSafety.h | 78 + source/hook/src/WQCore.h | 35 + source/hook/src/WQCore.m | 75 + source/hook/src/WQDir.h | 15 + source/hook/src/WQEditing.h | 20 + source/hook/src/WQHexViewer.h | 19 + source/hook/src/WQHexViewer.m | 526 ++++ source/hook/src/WQHexViewerHelpers.h | 58 + source/hook/src/WQLaunchServices.h | 39 + source/hook/src/WQLaunchServices.m | 897 +++++++ source/hook/src/WQLocalization.h | 18 + source/hook/src/WQLocalization.m | 27 + source/hook/src/WQMediaStream.h | 35 + source/hook/src/WQMediaStream.m | 134 + source/hook/src/WQPlistEditor.h | 23 + source/hook/src/WQPlistEditor.m | 738 ++++++ source/hook/src/WQPlistEditorSupport.h | 119 + source/hook/src/WQSQLiteViewer.h | 23 + source/hook/src/WQSQLiteViewer.m | 636 +++++ source/hook/src/WQSharing.h | 14 + source/hook/src/WQSharing.m | 239 ++ source/hook/src/WQSyntax.h | 42 + source/hook/src/WQSyntax.m | 295 +++ source/hook/src/WQUI.h | 37 + source/hook/src/WQUI.m | 354 +++ source/hook/src/main.m | 3143 ++++++++++++++++++++++++ 43 files changed, 17046 insertions(+) create mode 100644 source/LICENSE create mode 100644 source/README.md create mode 100755 source/build_app.sh create mode 100755 source/build_dylib.sh create mode 100644 source/hook/src/WBHook.m create mode 100644 source/hook/src/WQAccess.h create mode 100644 source/hook/src/WQAccess.m create mode 100644 source/hook/src/WQAccessSafety.h create mode 100644 source/hook/src/WQApps.h create mode 100644 source/hook/src/WQApps.m create mode 100644 source/hook/src/WQAppsSupport.h create mode 100644 source/hook/src/WQArchive.h create mode 100644 source/hook/src/WQArchive.m create mode 100644 source/hook/src/WQCache.h create mode 100644 source/hook/src/WQCache.m create mode 100644 source/hook/src/WQCacheSafety.h create mode 100644 source/hook/src/WQCore.h create mode 100644 source/hook/src/WQCore.m create mode 100644 source/hook/src/WQDir.h create mode 100644 source/hook/src/WQEditing.h create mode 100644 source/hook/src/WQHexViewer.h create mode 100644 source/hook/src/WQHexViewer.m create mode 100644 source/hook/src/WQHexViewerHelpers.h create mode 100644 source/hook/src/WQLaunchServices.h create mode 100644 source/hook/src/WQLaunchServices.m create mode 100644 source/hook/src/WQLocalization.h create mode 100644 source/hook/src/WQLocalization.m create mode 100644 source/hook/src/WQMediaStream.h create mode 100644 source/hook/src/WQMediaStream.m create mode 100644 source/hook/src/WQPlistEditor.h create mode 100644 source/hook/src/WQPlistEditor.m create mode 100644 source/hook/src/WQPlistEditorSupport.h create mode 100644 source/hook/src/WQSQLiteViewer.h create mode 100644 source/hook/src/WQSQLiteViewer.m create mode 100644 source/hook/src/WQSharing.h create mode 100644 source/hook/src/WQSharing.m create mode 100644 source/hook/src/WQSyntax.h create mode 100644 source/hook/src/WQSyntax.m create mode 100644 source/hook/src/WQUI.h create mode 100644 source/hook/src/WQUI.m create mode 100644 source/hook/src/main.m 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 +#include + +// Keep both the search needle and the range-read overlap bounded before either +// can be materialized. This is a UTF-8 input limit, not a UI character limit. +static const NSUInteger WQHexSearchMaximumInputBytes = 1024UL * 1024UL; + +static inline BOOL WQHexSearchInputWithinLimit(NSString *text) +{ + return text && [text lengthOfBytesUsingEncoding:NSUTF8StringEncoding] <= WQHexSearchMaximumInputBytes; +} + +// Parses the complete trimmed input as an unsigned decimal or 0x-prefixed +// hexadecimal offset. The caller remains responsible for checking file range. +static inline BOOL WQHexParseOffset(NSString *text, unsigned long long *offset) +{ + if (!text || !offset) return NO; + NSString *trimmed = [text stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet]; + if (!trimmed.length) return NO; + + BOOL hex = [trimmed hasPrefix:@"0x"] || [trimmed hasPrefix:@"0X"]; + NSString *digits = hex ? [trimmed substringFromIndex:2] : trimmed; + if (!digits.length) return NO; + + unichar first = [digits characterAtIndex:0]; + if (first == '-' || first == '+') return NO; + for (NSUInteger i = 0; i < digits.length; i++) { + unichar c = [digits characterAtIndex:i]; + BOOL valid = c >= '0' && c <= '9'; + if (hex) valid = valid || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); + if (!valid) return NO; + } + + const char *input = digits.UTF8String; + NSUInteger inputBytes = [digits lengthOfBytesUsingEncoding:NSUTF8StringEncoding]; + if (!input || strlen(input) != inputBytes) return NO; + + errno = 0; + char *end = NULL; + unsigned long long parsed = strtoull(input, &end, hex ? 16 : 10); + if (errno == ERANGE || end == input || !end || *end != '\0') return NO; + *offset = parsed; + return YES; +} + +#endif diff --git a/source/hook/src/WQLaunchServices.h b/source/hook/src/WQLaunchServices.h new file mode 100644 index 0000000..d8787e0 --- /dev/null +++ b/source/hook/src/WQLaunchServices.h @@ -0,0 +1,39 @@ +// +// WQLaunchServices.h +// WQ Files +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +// Human app name for a bundle id, from LaunchServices' database (read from +// lsd's container), a built-in table of Apple services, or the parent app. +NSString *_Nullable wq_display_name(NSString *_Nullable bundleID); + +// Bundle id of an app or extension data container (/private/var/mobile/ +// Containers/Data/...) or an app bundle container, from the same +// LaunchServices database. For iOS 27.2, where the containers' identifier +// attribute can't be read. Nil if unknown. +NSString *_Nullable wq_bundle_id_for_container(NSString *path); + +// Whether the name database is loaded; loading it (blocking, off the main +// thread) the first time can take a while, so screens show a status for it. +BOOL wq_display_names_loaded(void); +void wq_load_display_names(void); +// After a user's refresh: search for a database that wasn't found before +// right away instead of waiting a day. +void wq_display_names_reset_miss(void); +// Every app and extension data container the LaunchServices database lists, +// read from storeHint (remembered when it parses) or the remembered location. +// Never scans. Nil when there's no usable database. +NSArray *_Nullable wq_ls_container_paths(NSString *_Nullable storeHint); +// The .app bundle of an installed app, from the same database (for icons +// where iOS won't hand them out). Nil if unknown. +NSString *_Nullable wq_bundle_path_for_id(NSString *bundleID); +// The app bundle containers (/private/var/containers/Bundle/Application/ +// ) of installed apps, from the same database; empty if unknown. +// wq_bundle_id_for_container also labels these. +NSArray *wq_ls_bundle_containers(void); + +NS_ASSUME_NONNULL_END diff --git a/source/hook/src/WQLaunchServices.m b/source/hook/src/WQLaunchServices.m new file mode 100644 index 0000000..2d95424 --- /dev/null +++ b/source/hook/src/WQLaunchServices.m @@ -0,0 +1,897 @@ +// +// WQLaunchServices.m +// WQ Files +// GPL-3.0-or-later. +// + +#import "WQLaunchServices.h" +#import "WQLocalization.h" +#import "WQAccess.h" +#import +#import +#import + +#pragma mark - App names from the LaunchServices database + +// LaunchServices' own API refuses this app on 27.x (OSStatus -54) and the app +// bundles cannot be opened, but lsd keeps its database (CSStore2, magic +// "bdsl") in an Internal Daemon container that we can read. In it, Bundle +// records are 0x250 bytes long, preceded by [u32][u32 0x250]: +0x0c bundle +// id, +0x6c bundle name, +0x74 display name, each a string reference +// (string id << 2). Strings are stored as [u32 id][u32 length][UTF-8][NUL]. +// Names that exist only in localized form (most Apple apps) are not decoded; +// the built-in table below covers those. + +static NSString *const kWQLSStoreKey = @"WQLaunchServicesStore"; +// When the database wasn't found: don't search again for a day (each search +// lists every daemon container), unless the user refreshes. +static NSString *const kWQLSMissKey = @"WQLaunchServicesStoreMissedAt"; +static const NSTimeInterval kWQLSMissRetry = 24 * 60 * 60; + +// These limits keep a malformed or unexpectedly large database from turning a +// synchronous display-name lookup into an unbounded read/parse operation. +static const size_t kWQLSMinStoreBytes = 0x1000; +static const size_t kWQLSMaxStoreBytes = 64u * 1024u * 1024u; +static const uint32_t kWQLSMaxStringID = 0x01000000u; +static const NSUInteger kWQLSMaxBundleRecords = 100000; +static const NSUInteger kWQLSMaxDecodedStrings = 200000; +static const NSUInteger kWQLSMaxDecodedStringBytes = 8u * 1024u * 1024u; + +static NSObject *wq_ls_lock(void) +{ + static NSObject *lock; + static dispatch_once_t once; + dispatch_once(&once, ^{ lock = [NSObject new]; }); + return lock; +} + +// The regression tests still exercise discovery through the daemon list. +#ifdef WQ_LS_TESTING +static BOOL wq_ls_test_daemon_search = YES; +#else +static const BOOL wq_ls_test_daemon_search = NO; +#endif + +static NSString *wq_find_ls_store(BOOL *searched); +static BOOL wq_ls_candidate_info(NSString *path, struct stat *st); + +static BOOL wq_ls_stat_equal(const struct stat *a, const struct stat *b) +{ + return a->st_dev == b->st_dev && a->st_ino == b->st_ino && + a->st_size == b->st_size && + a->st_mtimespec.tv_sec == b->st_mtimespec.tv_sec && + a->st_mtimespec.tv_nsec == b->st_mtimespec.tv_nsec; +} + +static NSDictionary *wq_ls_signature(const struct stat *st) +{ + return @{ + @"device": @(st->st_dev), @"inode": @(st->st_ino), @"size": @(st->st_size), + @"modifiedSeconds": @(st->st_mtimespec.tv_sec), + @"modifiedNanoseconds": @(st->st_mtimespec.tv_nsec) + }; +} + +static BOOL wq_ls_signature_equal(NSDictionary *a, NSDictionary *b) +{ + return a && b && [a isEqualToDictionary:b]; +} + +static NSString *wq_ls_error(NSString *path, NSString *reason) +{ + return [NSString stringWithFormat:@"%@ (%@)", path ?: @"", reason ?: @"unknown error"]; +} + +static BOOL wq_ls_candidate_info(NSString *path, struct stat *st) +{ + if (!path.length || !wq_stat(path, st) || !S_ISREG(st->st_mode) || + st->st_size < (off_t)kWQLSMinStoreBytes || st->st_size > (off_t)kWQLSMaxStoreBytes) { + return NO; + } + NSString *error = nil; + NSData *header = wq_read(path, 4, &error); + if (header.length != 4 || memcmp(header.bytes, "bdsl", 4) != 0) return NO; + return YES; +} + +static BOOL wq_ls_is_newer(NSString *path, const struct stat *st, + NSString *bestPath, const struct stat *best) +{ + if (!bestPath) return YES; + if (st->st_mtimespec.tv_sec != best->st_mtimespec.tv_sec) + return st->st_mtimespec.tv_sec > best->st_mtimespec.tv_sec; + if (st->st_mtimespec.tv_nsec != best->st_mtimespec.tv_nsec) + return st->st_mtimespec.tv_nsec > best->st_mtimespec.tv_nsec; + if (st->st_size != best->st_size) return st->st_size > best->st_size; + return [path compare:bestPath] == NSOrderedAscending; +} + +static void wq_ls_consider_candidate(NSString *path, NSString **bestPath, + struct stat *best, NSMutableSet *seen) +{ + if (!path.length || [seen containsObject:path]) return; + [seen addObject:path]; + + struct stat st; + if (!wq_ls_candidate_info(path, &st)) return; + if (wq_ls_is_newer(path, &st, *bestPath, best)) { + *bestPath = path; + *best = st; + } +} + +// *searched: the daemon containers were actually listed (a failed or cancelled +// scan says nothing about whether the database exists). +static NSString *wq_find_ls_store(BOOL *searched) +{ + if (searched) *searched = NO; + NSString *cached = [NSUserDefaults.standardUserDefaults stringForKey:kWQLSStoreKey]; + NSString *bestPath = nil; + struct stat best = {0}; + struct stat cachedStat; + BOOL cachedValid = cached.length && wq_ls_candidate_info(cached, &cachedStat); + NSMutableSet *seen = [NSMutableSet set]; + if (cachedValid) wq_ls_consider_candidate(cached, &bestPath, &best, seen); + + // Listing the daemon containers is a full id scan (minutes, and it asks + // first); the database is found by the container scan among the newest + // ids and remembered, so names only use the remembered location. + if (!wq_ls_test_daemon_search) { + if (searched) *searched = cachedValid; + return cachedValid ? cached : nil; + } + NSArray *daemons = wq_enumerate(@"/private/var/mobile/Containers/Data/InternalDaemon", 0, nil, nil); + if (daemons && searched) *searched = YES; + if (daemons) { + for (NSString *container in daemons) { + NSString *caches = [container stringByAppendingPathComponent:@"Library/Caches"]; + NSArray *items = wq_list(caches, NULL); + for (NSString *name in [items sortedArrayUsingSelector:@selector(compare:)]) { + if (![name hasPrefix:@"com.apple.LaunchServices-"] || ![name hasSuffix:@".csstore"]) continue; + wq_ls_consider_candidate([caches stringByAppendingPathComponent:name], &bestPath, &best, seen); + } + } + } + + // Keep a valid cached path usable if enumeration is temporarily unavailable. + if (!bestPath && cachedValid) { + bestPath = cached; + } + return bestPath; +} + +// Record layouts by release. Offsets are from the record body (after the +// 8-byte header). iOS 26 records are 0x14 bytes shorter, with the container +// path and the fields after it 4 bytes earlier than on 27.0. +typedef struct { + const char *label; + uint32_t bundleSize, bundleID, container, name, displayName, bundlePath; + uint32_t pluginSize, pluginBundleID, pluginContainer; +} WQLSLayout; +static const WQLSLayout kWQLSLayouts[] = { + { "27.0", 0x250, 0x0c, 0x68, 0x6c, 0x74, 0x00, 0x118, 0x00, 0x54 }, + { "26", 0x23c, 0x0c, 0x64, 0x68, 0x70, 0x00, 0x104, 0x00, 0x50 }, + // iOS 26.5 (from a device's layout hint, issue #37): same record sizes, + // container fields 4 bytes earlier than on 26.7. Whether the bundle id + // moved too isn't known, so both positions are tried. + { "26.5", 0x23c, 0x0c, 0x60, 0x64, 0x6c, 0x00, 0x104, 0x00, 0x4c }, + { "26.5b", 0x23c, 0x08, 0x60, 0x64, 0x6c, 0x00, 0x104, 0x00, 0x4c }, + // iOS 27.2 (from a device's layout hint): container fields 4 bytes later + // than on 27.0. Whether the bundle id moved too isn't known yet, so both + // positions are tried; the one that maps the most containers wins. + { "27.2", 0x25c, 0x0c, 0x6c, 0x70, 0x78, 0x00, 0x120, 0x00, 0x58 }, + { "27.2b", 0x25c, 0x10, 0x6c, 0x70, 0x78, 0x00, 0x120, 0x04, 0x58 }, + { "27.2c", 0x25c, 0x0c, 0x6c, 0x70, 0x78, 0x00, 0x120, 0x04, 0x58 }, + { "27.2d", 0x25c, 0x10, 0x6c, 0x70, 0x78, 0x00, 0x120, 0x00, 0x58 }, +}; +static const size_t kWQLSLayoutCount = sizeof(kWQLSLayouts) / sizeof(kWQLSLayouts[0]); +static const size_t kWQLSMaxRecordSize = 0x25c; + +static uint32_t wq_rd32(const uint8_t *p) { uint32_t v; memcpy(&v, p, 4); return v; } + +static NSString *wq_cs_string(const uint8_t *b, size_t n, size_t o) +{ + if (o > n || n - o < 8) return nil; + uint32_t len = wq_rd32(b + o + 4); + // Strings are padded with zero bytes to a multiple of 4. One whose length + // is already a multiple of 4 has no terminator (container paths are 84 or + // 88 bytes), so only shorter ones must end in a zero byte. A string must + // still end before the buffer does. + if (len == 0 || len >= 0x800 || len >= n - o - 8 || (len % 4 && b[o + 8 + len] != 0)) return nil; + if (memchr(b + o + 8, 0, len)) return nil; + NSString *s = [[NSString alloc] initWithBytes:b + o + 8 length:len encoding:NSUTF8StringEncoding]; + return s; +} + +static BOOL wq_looks_like_bundle_id(NSString *s) +{ + static NSCharacterSet *bad; + static dispatch_once_t once; + dispatch_once(&once, ^{ + bad = [[NSCharacterSet characterSetWithCharactersInString: + @"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-_"] invertedSet]; + }); + return s.length > 2 && [s containsString:@"."] && ![s hasPrefix:@"."] + && [s rangeOfCharacterFromSet:bad].location == NSNotFound; +} + +// Unicode format characters include bidi overrides/isolates, zero-width +// characters, and BOM-like markers. They are replaced visibly while ordinary +// international letters, accents, and scripts remain unchanged. +static BOOL wq_is_name_format_scalar(uint32_t c) +{ + return c == 0x00ad || (c >= 0x0600 && c <= 0x0605) || c == 0x061c || c == 0x06dd || + c == 0x070f || (c >= 0x0890 && c <= 0x0891) || c == 0x08e2 || c == 0x180e || + (c >= 0x200b && c <= 0x200f) || (c >= 0x202a && c <= 0x202e) || + (c >= 0x2060 && c <= 0x206f) || c == 0xfeff; +} + +static NSString *wq_safe_name(NSString *value) +{ + if (!value.length) return nil; + NSMutableString *safe = [NSMutableString stringWithCapacity:value.length]; + for (NSUInteger i = 0; i < value.length;) { + uint32_t scalar = [value characterAtIndex:i++]; + if (scalar >= 0xd800 && scalar <= 0xdbff) { + if (i >= value.length) return nil; + unichar low = [value characterAtIndex:i++]; + if (low < 0xdc00 || low > 0xdfff) return nil; + scalar = 0x10000u + ((scalar - 0xd800u) << 10) + (low - 0xdc00u); + } else if (scalar >= 0xdc00 && scalar <= 0xdfff) { + return nil; + } + + if (wq_is_name_format_scalar(scalar) || scalar == 0x2028 || scalar == 0x2029) { + [safe appendString:@"\uFFFD"]; + } else if (scalar <= 0xffff) { + [safe appendFormat:@"%C", (unichar)scalar]; + } else { + scalar -= 0x10000u; + [safe appendFormat:@"%C%C", (unichar)(0xd800u + (scalar >> 10)), + (unichar)(0xdc00u + (scalar & 0x3ffu))]; + } + } + + NSString *normalized = [safe precomposedStringWithCanonicalMapping]; + normalized = [normalized stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet]; + if (!normalized.length || [normalized hasPrefix:@"/"] || [normalized containsString:@".app/"]) return nil; + if ([normalized rangeOfCharacterFromSet:NSCharacterSet.controlCharacterSet].location != NSNotFound) return nil; + return normalized; +} + +// Data container path -> bundle id. Apps: the Bundle record's +0x68 field; +// extensions: 0x118-byte plugin records with the bundle id at +0x00 and the +// path at +0x54. On iOS 27.2 the containers' identifier attribute can't be +// read, so this is how their folders get a bundle id (and a name). +static BOOL wq_is_data_container_path(NSString *path) +{ + static NSArray *prefixes; + static dispatch_once_t once; + dispatch_once(&once, ^{ + prefixes = @[@"/private/var/mobile/Containers/Data/Application/", + @"/private/var/mobile/Containers/Data/PluginKitPlugin/"]; + }); + for (NSString *prefix in prefixes) { + if (![path hasPrefix:prefix] || path.length != prefix.length + 36) continue; + return [[NSUUID alloc] initWithUUIDString:[path substringFromIndex:prefix.length]] != nil; + } + return NO; +} + +// Diagnostics: how many container paths the store holds, and which record +// sizes and offsets refer to them. Only sizes and offsets are logged (no +// names), so a Log from a release with a new layout shows where it moved. +static int wq_u32_cmp(const void *a, const void *b) +{ + uint32_t x = *(const uint32_t *)a, y = *(const uint32_t *)b; + return x < y ? -1 : x > y; +} + +// String refs of the data container paths stored in the database, sorted; +// independent of the record layout. +static NSMutableData *wq_ls_container_path_refs(const uint8_t *b, size_t n) +{ + NSMutableData *refData = [NSMutableData new]; + static const char needle[] = "/var/mobile/Containers/Data/"; + const size_t needleLen = sizeof(needle) - 1; + const uint8_t *cursor = b, *end = b + n; + while (cursor < end) { + const uint8_t *hit = memmem(cursor, (size_t)(end - cursor), needle, needleLen); + if (!hit) break; + cursor = hit + needleLen; + const uint8_t *start = hit; + if ((size_t)(hit - b) >= 8 && memcmp(hit - 8, "/private", 8) == 0) start = hit - 8; + size_t o = (size_t)(start - b); + if (o < 8 || o % 4) continue; + uint32_t len = wq_rd32(start - 4); + if (len < needleLen || len > 0x100 || len > (size_t)(end - start)) continue; + uint32_t ref = wq_rd32(start - 8) << 2; + if (ref) [refData appendBytes:&ref length:sizeof(ref)]; + if (refData.length / sizeof(uint32_t) >= 20000) break; + } + NSUInteger count = refData.length / sizeof(uint32_t); + if (count) qsort(refData.mutableBytes, count, sizeof(uint32_t), wq_u32_cmp); + return refData; +} + +static void wq_ls_log_summary(const uint8_t *b, size_t n, NSString *layout, NSUInteger bundleRecords, + NSUInteger pluginRecords, NSUInteger names, NSUInteger mapped) +{ + NSMutableData *refData = wq_ls_container_path_refs(b, n); + NSUInteger pathCount = refData.length / sizeof(uint32_t); + uint32_t *refs = refData.mutableBytes; + wq_log(@"names: store %.1f MB, layout %@: %lu bundle and %lu plugin records, %lu names, %lu of %lu container paths mapped", + n / 1048576.0, layout, (unsigned long)bundleRecords, (unsigned long)pluginRecords, (unsigned long)names, + (unsigned long)mapped, (unsigned long)pathCount); + if (!pathCount || mapped * 2 >= pathCount) return; + + // Vote for (record size, offset of the container reference). + NSCountedSet *votes = [NSCountedSet new]; + for (size_t q = 0; q + 4 <= n; q += 4) { + uint32_t v = wq_rd32(b + q); + if (!v || (v & 3) || !bsearch(&v, refs, pathCount, sizeof(uint32_t), wq_u32_cmp)) continue; + for (size_t k = 0; k < 0x400 && q >= 8 + k; k += 4) { + uint32_t size = wq_rd32(b + q - 8 - k + 4); + if (size >= 0x40 && size <= 0x800 && size % 4 == 0 && k < size) + [votes addObject:@((uint64_t)size << 16 | k)]; + } + } + NSArray *top = [votes.allObjects sortedArrayUsingComparator:^NSComparisonResult(NSNumber *x, NSNumber *y) { + NSUInteger cx = [votes countForObject:x], cy = [votes countForObject:y]; + return cx > cy ? NSOrderedAscending : cx < cy ? NSOrderedDescending : [x compare:y]; + }]; + NSMutableArray *parts = [NSMutableArray new]; + for (NSNumber *key in [top subarrayWithRange:NSMakeRange(0, MIN(top.count, (NSUInteger)8))]) { + uint64_t value = key.unsignedLongLongValue; + [parts addObject:[NSString stringWithFormat:@"0x%llx@0x%llx x%lu", value >> 16, value & 0xffff, + (unsigned long)[votes countForObject:key]]]; + } + wq_log(@"names: layout hint (record size@container offset): %@", [parts componentsJoinedByString:@", "]); +} + +static BOOL wq_parse_ls_store(NSData *data, NSDictionary **displayOut, + NSDictionary **bundleNameOut, NSDictionary **containersOut, + NSDictionary **bundlePathsOut) +{ + if (bundlePathsOut) *bundlePathsOut = nil; + if (displayOut) *displayOut = nil; + if (bundleNameOut) *bundleNameOut = nil; + if (containersOut) *containersOut = nil; + + const uint8_t *b = data.bytes; + size_t n = data.length; + if (n < kWQLSMinStoreBytes || !b || memcmp(b, "bdsl", 4) != 0) { + wq_log(@"names: not a LaunchServices store"); + return NO; + } + NSRange heapRange = [data rangeOfData:[NSData dataWithBytes:"" length:8] + options:0 range:NSMakeRange(0, n)]; + size_t heap = heapRange.location == NSNotFound ? 0 : heapRange.location; + + // Pass 1: Bundle and plug-in records of every known layout, and which + // string ids they need. The layout that maps the most containers wins. + const uint32_t kIDs = kWQLSMaxStringID; + size_t bitsetBytes = kIDs / 8; + uint8_t *need = calloc(bitsetBytes, 1), *inHeap = calloc(bitsetBytes, 1); + NSMutableArray *layoutRecs = [NSMutableArray new], *layoutPlugins = [NSMutableArray new]; + for (size_t l = 0; l < kWQLSLayoutCount; l++) { + [layoutRecs addObject:[NSMutableData new]]; + [layoutPlugins addObject:[NSMutableData new]]; // pairs: bundle id ref, container path ref + } + if (!need || !inHeap) { + free(need); + free(inHeap); + wq_log(@"names: LaunchServices parser allocation failed"); + return NO; + } + + BOOL tooManyRecords = NO; + for (size_t o = 0; o + 8 + kWQLSMaxRecordSize <= n && !tooManyRecords; o += 4) { + uint32_t size = wq_rd32(b + o + 4); + const uint8_t *u = b + o + 8; + for (size_t l = 0; l < kWQLSLayoutCount; l++) { + const WQLSLayout *L = &kWQLSLayouts[l]; + if (size == L->pluginSize) { + NSMutableData *plugins = layoutPlugins[l]; + uint32_t p[2] = { wq_rd32(u + L->pluginBundleID), wq_rd32(u + L->pluginContainer) }; + if (p[0] && !(p[0] & 3) && (p[0] >> 2) < kIDs && p[1] && !(p[1] & 3) && (p[1] >> 2) < kIDs && + plugins.length / sizeof(p) < kWQLSMaxBundleRecords) { + [plugins appendBytes:p length:sizeof(p)]; + for (int i = 0; i < 2; i++) need[(p[i] >> 2) / 8] |= 1 << ((p[i] >> 2) % 8); + } + } + if (size != L->bundleSize) continue; + NSMutableData *recs = layoutRecs[l]; + if (recs.length / (5 * sizeof(uint32_t)) >= kWQLSMaxBundleRecords) { + tooManyRecords = YES; + break; + } + uint32_t r[5] = { wq_rd32(u + L->bundleID), wq_rd32(u + L->name), wq_rd32(u + L->displayName), wq_rd32(u + L->container), + wq_rd32(u + L->bundlePath) }; + if (!r[0] || (r[0] & 3) || (r[0] >> 2) >= kIDs) continue; + [recs appendBytes:r length:sizeof(r)]; + for (int i = 0; i < 5; i++) { + if (r[i] && !(r[i] & 3) && (r[i] >> 2) < kIDs) + need[(r[i] >> 2) / 8] |= 1 << ((r[i] >> 2) % 8); + } + } + } + if (tooManyRecords) { + free(need); + free(inHeap); + wq_log(@"names: LaunchServices store exceeds record limit"); + return NO; + } + + // Pass 2: the strings, preferring copies inside the string table. On + // iOS 26 container paths live in a second table whose ids overlap the + // string table's, so path-shaped strings are also kept by id on their own + // (an id with two different paths is unusable). + NSMutableDictionary *strings = [NSMutableDictionary new]; + NSMutableDictionary *pathStrings = [NSMutableDictionary new]; + NSMutableDictionary *appPathStrings = [NSMutableDictionary new]; + NSUInteger decodedStringBytes = 0; + BOOL tooManyStrings = NO; + for (size_t o = 0; o <= n - 8; o += 4) { + uint32_t sid = wq_rd32(b + o); + if (!sid || sid >= kIDs || !(need[sid / 8] & (1 << (sid % 8)))) continue; + BOOL here = o >= heap, had = strings[@(sid)] != nil; + BOOL keepGeneral = !had || (here && !(inHeap[sid / 8] & (1 << (sid % 8)))); + uint32_t peekLen = o + 8 <= n ? wq_rd32(b + o + 4) : 0; + BOOL maybePath = peekLen >= 6 && peekLen < 0x400 && o + 8 < n && b[o + 8] == '/'; + if (!keepGeneral && !maybePath) continue; + NSString *s = wq_cs_string(b, n, o); + if (!s) continue; + if (maybePath) { + NSMutableDictionary *table = [s containsString:@"/mobile/Containers/Data/"] ? pathStrings + : ([s hasSuffix:@".app"] || [s hasSuffix:@".app/"]) ? appPathStrings : nil; + id previous = table[@(sid)]; + if (table && !previous) table[@(sid)] = s; + else if (table && ![previous isEqual:s]) table[@(sid)] = NSNull.null; + } + if (!keepGeneral) continue; + if (!had && (strings.count >= kWQLSMaxDecodedStrings || + s.length > kWQLSMaxDecodedStringBytes - decodedStringBytes)) { + tooManyStrings = YES; + break; + } + strings[@(sid)] = s; + if (!had) decodedStringBytes += s.length; + if (here) inHeap[sid / 8] |= 1 << (sid % 8); + } + free(need); + free(inHeap); + if (tooManyStrings) { + wq_log(@"names: LaunchServices store exceeds string limit"); + return NO; + } + NSUInteger anyRecords = 0, anyPlugins = 0; + for (size_t l = 0; l < kWQLSLayoutCount; l++) { + anyRecords += layoutRecs[l].length / (5 * sizeof(uint32_t)); + anyPlugins += layoutPlugins[l].length / (2 * sizeof(uint32_t)); + } + if (!anyRecords) { + wq_ls_log_summary(b, n, @"none", 0, anyPlugins, 0, 0); + wq_log(@"names: LaunchServices store contains no bundle records"); + return NO; + } + + NSString *(^pathFor)(uint32_t) = ^NSString *(uint32_t ref) { + id path = pathStrings[@(ref >> 2)]; + if (path == NSNull.null) return nil; + return path ?: strings[@(ref >> 2)]; + }; + NSDictionary *display = nil, *bundleName = nil, *containers = nil, *bundlePaths = nil; + size_t chosen = 0; + for (size_t l = 0; l < kWQLSLayoutCount; l++) { + NSMutableDictionary *layoutDisplay = [NSMutableDictionary new], *layoutBundleName = [NSMutableDictionary new]; + NSMutableDictionary *layoutBundlePaths = [NSMutableDictionary new]; + NSMutableDictionary *layoutContainers = [NSMutableDictionary new]; + // A path claimed by two different bundle ids is left out rather than guessed. + // Plug-in records only claim extension containers and bundle records + // only app containers, so a record type that happens to share a size + // can't knock out the other table's paths. + void (^claim)(NSString *, NSString *, NSString *) = ^(NSString *path, NSString *bid, NSString *kind) { + // iOS 26 stores container paths with a trailing slash; lookups + // use the /private/var form without one. + if ([path hasSuffix:@"/"]) path = [path substringToIndex:path.length - 1]; + if ([path hasPrefix:@"/var/"]) path = [@"/private" stringByAppendingString:path]; + if (!wq_is_data_container_path(path) || !wq_looks_like_bundle_id(bid) || ![path containsString:kind]) return; + id known = layoutContainers[path]; + if (!known) layoutContainers[path] = bid; + else if (![known isEqual:bid]) layoutContainers[path] = NSNull.null; + }; + NSData *plugins = layoutPlugins[l], *recs = layoutRecs[l]; + const uint32_t *pr = plugins.bytes; + for (NSUInteger i = 0; i < plugins.length / sizeof(uint32_t); i += 2) + claim(pathFor(pr[i + 1]), strings[@(pr[i] >> 2)], @"/Data/PluginKitPlugin/"); + const uint32_t *r = recs.bytes; + for (NSUInteger i = 0; i < recs.length / sizeof(uint32_t); i += 5) { + NSString *bid = strings[@(r[i] >> 2)]; + if (!wq_looks_like_bundle_id(bid)) continue; + if (r[i + 3] && !(r[i + 3] & 3)) claim(pathFor(r[i + 3]), bid, @"/Data/Application/"); + NSString *nm = (r[i + 1] & 3) ? nil : strings[@(r[i + 1] >> 2)]; + NSString *dn = (r[i + 2] & 3) ? nil : strings[@(r[i + 2] >> 2)]; + NSString *safeDisplay = wq_safe_name(dn); + NSString *safeBundleName = wq_safe_name(nm); + if (safeDisplay && !layoutDisplay[bid]) layoutDisplay[bid] = safeDisplay; + if (safeBundleName && !layoutBundleName[bid]) layoutBundleName[bid] = safeBundleName; + id appPath = (r[i + 4] && !(r[i + 4] & 3)) ? appPathStrings[@(r[i + 4] >> 2)] : nil; + if ([appPath isKindOfClass:NSString.class] && !layoutBundlePaths[bid]) { + NSString *clean = [appPath hasSuffix:@"/"] ? [appPath substringToIndex:[appPath length] - 1] : appPath; + if ([clean hasPrefix:@"/var/"]) clean = [@"/private" stringByAppendingString:clean]; + layoutBundlePaths[bid] = clean; + } + } + for (NSString *path in layoutContainers.allKeys) + if (layoutContainers[path] == NSNull.null) [layoutContainers removeObjectForKey:path]; + if (!containers || layoutContainers.count > containers.count || + (layoutContainers.count == containers.count && layoutDisplay.count > display.count)) { + display = layoutDisplay; + bundleName = layoutBundleName; + bundlePaths = layoutBundlePaths; + containers = layoutContainers; + chosen = l; + } + } + // A few Apple apps' names, to check the name fields of a new layout. + NSMutableArray *samples = [NSMutableArray new]; + for (NSString *bid in @[@"com.apple.mobilesafari", @"com.apple.Preferences", @"com.apple.camera", @"com.apple.mobilenotes"]) { + NSString *name = display[bid] ?: bundleName[bid]; + if (name) [samples addObject:[NSString stringWithFormat:@"%@ = %@", bid, name]]; + } + // The container scan and the name loader each parse the same store; log + // its summary once per distinct result, not once per parse. + static NSString *lastSummary; + NSString *summary = [NSString stringWithFormat:@"%zu %zu %lu %lu", n, chosen, (unsigned long)display.count, + (unsigned long)containers.count]; + BOOL newSummary; + @synchronized (NSObject.class) { + newSummary = ![summary isEqualToString:lastSummary]; + if (newSummary) lastSummary = summary; + } + if (newSummary && samples.count) wq_log(@"names: sample: %@", [samples componentsJoinedByString:@", "]); + if (newSummary) wq_ls_log_summary(b, n, @(kWQLSLayouts[chosen].label), layoutRecs[chosen].length / (5 * sizeof(uint32_t)), + layoutPlugins[chosen].length / (2 * sizeof(uint32_t)), display.count, containers.count); + // A layout that maps only a small part of the container paths in the store + // is the wrong one (its names are wrong too): don't use the store at all, + // so the app list falls back to the scan instead of showing a few apps. + NSUInteger storePaths = wq_ls_container_path_refs(b, n).length / sizeof(uint32_t); + if (storePaths >= 20 && containers.count * 2 < storePaths) { + if (newSummary) + wq_log(@"names: layout %s maps only %lu of %lu container paths; this iOS version's database layout isn't known, so it isn't used", + kWQLSLayouts[chosen].label, (unsigned long)containers.count, (unsigned long)storePaths); + return NO; + } + if (bundlePathsOut) *bundlePathsOut = [bundlePaths copy]; + if (displayOut) *displayOut = [display copy]; + if (bundleNameOut) *bundleNameOut = [bundleName copy]; + if (containersOut) *containersOut = [containers copy]; + return YES; +} + +static NSDictionary *gLSDisplay; +static NSDictionary *gLSBundleName; +static NSDictionary *gLSContainers; +static NSDictionary *gLSBundlePaths; +static NSString *gLSStorePath; +static NSDictionary *gLSStoreSignature; +static BOOL gLSLoaded; +static NSUInteger gLSFailureCount; +static NSTimeInterval gLSNextRetryAt; + +static void wq_ls_clear_snapshot_if_changed(BOOL changed) +{ + if (!changed) return; + gLSDisplay = nil; + gLSBundleName = nil; + gLSContainers = nil; + gLSBundlePaths = nil; + gLSStorePath = nil; + gLSStoreSignature = nil; + gLSLoaded = NO; +} + +static void wq_ls_note_failure(NSTimeInterval now, NSString *reason) +{ + gLSFailureCount = MIN(gLSFailureCount + 1, (NSUInteger)6); + NSUInteger shift = gLSFailureCount > 1 ? gLSFailureCount - 1 : 0; + NSTimeInterval delay = (NSTimeInterval)(1u << MIN(shift, (NSUInteger)5)); + gLSNextRetryAt = now + delay; + if (reason) wq_log(@"names: %@; retrying in %.0f s", reason, delay); +} + +static void wq_load_ls_names_locked(void) +{ + NSTimeInterval now = NSDate.date.timeIntervalSinceReferenceDate; + BOOL currentChanged = NO; + if (gLSLoaded && gLSStorePath) { + struct stat current; + if (wq_ls_candidate_info(gLSStorePath, ¤t)) { + NSDictionary *signature = wq_ls_signature(¤t); + currentChanged = !wq_ls_signature_equal(signature, gLSStoreSignature); + if (!currentChanged) return; + } else { + currentChanged = YES; + } + } else if (!gLSLoaded && now < gLSNextRetryAt) { + return; + } else if (!gLSLoaded && ![NSUserDefaults.standardUserDefaults stringForKey:kWQLSStoreKey].length) { + NSDate *missed = [NSUserDefaults.standardUserDefaults objectForKey:kWQLSMissKey]; + if ([missed isKindOfClass:NSDate.class] && now - missed.timeIntervalSinceReferenceDate < kWQLSMissRetry && + now >= missed.timeIntervalSinceReferenceDate) { + gLSNextRetryAt = missed.timeIntervalSinceReferenceDate + kWQLSMissRetry; + return; + } + } + + BOOL hadLocation = [NSUserDefaults.standardUserDefaults stringForKey:kWQLSStoreKey].length > 0; + BOOL searched = NO; + NSString *path = wq_find_ls_store(&searched); + if (!path) { + wq_ls_clear_snapshot_if_changed(currentChanged); + if (!hadLocation && searched) { + // Never found, after a complete search: wait a day (or a refresh). + [NSUserDefaults.standardUserDefaults setObject:NSDate.date forKey:kWQLSMissKey]; + gLSNextRetryAt = now + kWQLSMissRetry; + wq_log(@"names: LaunchServices database not found; searching again in a day or on refresh"); + } else { + // A known database that's briefly unreadable, or a scan that failed. + wq_ls_note_failure(now, @"LaunchServices database not found"); + } + return; + } + [NSUserDefaults.standardUserDefaults removeObjectForKey:kWQLSMissKey]; + + struct stat before; + if (!wq_ls_candidate_info(path, &before)) { + wq_ls_clear_snapshot_if_changed(currentChanged); + wq_ls_note_failure(now, wq_ls_error(path, @"candidate is no longer valid")); + return; + } + NSDictionary *beforeSignature = wq_ls_signature(&before); + BOOL snapshotChanged = !gLSLoaded || ![path isEqualToString:gLSStorePath] || + !wq_ls_signature_equal(beforeSignature, gLSStoreSignature); + if (gLSLoaded && [path isEqualToString:gLSStorePath] && + wq_ls_signature_equal(beforeSignature, gLSStoreSignature)) { + gLSFailureCount = 0; + gLSNextRetryAt = 0; + return; + } + + NSString *error = nil; + NSData *data = wq_read(path, kWQLSMaxStoreBytes + 1, &error); + if (!data || data.length > kWQLSMaxStoreBytes) { + wq_ls_clear_snapshot_if_changed(snapshotChanged); + wq_ls_note_failure(now, wq_ls_error(path, error ?: @"store exceeds size limit")); + return; + } + + NSDictionary *display = nil, *bundleName = nil, *containers = nil, *bundlePaths = nil; + if (!wq_parse_ls_store(data, &display, &bundleName, &containers, &bundlePaths)) { + wq_ls_clear_snapshot_if_changed(snapshotChanged); + wq_ls_note_failure(now, wq_ls_error(path, @"store rejected by bounded parser")); + return; + } + + // Do not publish a snapshot that was changed while it was being read or parsed. + struct stat after; + if (!wq_ls_candidate_info(path, &after) || !wq_ls_stat_equal(&before, &after)) { + wq_ls_clear_snapshot_if_changed(snapshotChanged); + wq_ls_note_failure(now, wq_ls_error(path, @"store changed during read")); + return; + } + + // Dictionaries are immutable copies and are assigned only after a complete, + // validated parse. Readers therefore see either the previous snapshot or this one. + gLSDisplay = [display copy] ?: @{}; + gLSBundleName = [bundleName copy] ?: @{}; + gLSContainers = [containers copy] ?: @{}; + gLSBundlePaths = [bundlePaths copy] ?: @{}; + gLSStorePath = [path copy]; + gLSStoreSignature = [beforeSignature copy]; + gLSLoaded = YES; + gLSFailureCount = 0; + gLSNextRetryAt = 0; + [NSUserDefaults.standardUserDefaults setObject:gLSStorePath forKey:kWQLSStoreKey]; +} + +// Apple's own apps and services: LaunchServices refuses this app on 27.x +// (OSStatus -54) and /Applications is not listable, so names come from here. +static NSDictionary *wq_builtin_names(void) +{ + static NSDictionary *names; + static dispatch_once_t once; + dispatch_once(&once, ^{ + names = @{ + @"com.apple.AppStore": @"App Store", @"com.apple.mobilesafari": @"Safari", @"com.apple.weather": @"Weather", + @"com.apple.news": @"News", @"com.apple.stocks": @"Stocks", @"com.apple.mobilemail": @"Mail", + @"com.apple.MobileSMS": @"Messages", @"com.apple.mobilephone": @"Phone", @"com.apple.mobileslideshow": @"Photos", + @"com.apple.camera": @"Camera", @"com.apple.mobilecal": @"Calendar", @"com.apple.mobilenotes": @"Notes", + @"com.apple.reminders": @"Reminders", @"com.apple.Maps": @"Maps", @"com.apple.Music": @"Music", + @"com.apple.tv": @"TV", @"com.apple.podcasts": @"Podcasts", @"com.apple.iBooks": @"Books", + @"com.apple.Health": @"Health", @"com.apple.Passbook": @"Wallet", @"com.apple.Preferences": @"Settings", + @"com.apple.mobiletimer": @"Clock", @"com.apple.calculator": @"Calculator", @"com.apple.compass": @"Compass", + @"com.apple.VoiceMemos": @"Voice Memos", @"com.apple.facetime": @"FaceTime", @"com.apple.MobileAddressBook": @"Contacts", + @"com.apple.DocumentsApp": @"Files", @"com.apple.findmy": @"Find My", @"com.apple.Home": @"Home", + @"com.apple.shortcuts": @"Shortcuts", @"com.apple.Translate": @"Translate", @"com.apple.Fitness": @"Fitness", + @"com.apple.Bridge": @"Watch", @"com.apple.freeform": @"Freeform", @"com.apple.journal": @"Journal", + @"com.apple.Passwords": @"Passwords", @"com.apple.measure": @"Measure", @"com.apple.Magnifier": @"Magnifier", + @"com.apple.tips": @"Tips", @"com.apple.siri": @"Siri", @"com.apple.games": @"Games", @"com.apple.gamecenter": @"Game Center", + @"com.apple.Image-Playground": @"Image Playground", @"com.apple.Photos": @"Photos", @"com.apple.Keynote": @"Keynote", + @"com.apple.Pages": @"Pages", @"com.apple.Numbers": @"Numbers", @"com.apple.mobileme.fmip1": @"Find iPhone", + @"com.apple.store.Jolly": @"Apple Store", @"com.apple.supportapp": @"Apple Support", @"com.apple.Diagnostics": @"Diagnostics", + @"com.apple.PosterBoard": @"Wallpaper", @"com.apple.MercuryPoster": @"Wallpaper (Mercury)", + @"com.apple.WallpaperKit.CollectionsPoster": @"Wallpaper Collections", + @"com.apple.PhotosUIPrivate.PhotosPosterProvider": @"Photo Wallpaper", + @"com.apple.chrono.WidgetRenderer-Default": @"Widgets", @"com.apple.ScreenshotServicesService": @"Screenshots", + @"com.apple.Sharing.AirDrop": @"AirDrop", @"com.apple.AppleMediaServicesUI": @"Apple Media Services", + @"com.apple.AppleMediaServices": @"Apple Media Services", @"com.apple.ClarityCamera": @"Assistive Access Camera", + @"com.apple.ClarityPhotos": @"Assistive Access Photos", @"com.apple.SystemVoiceAssistant": @"Voice Assistant", + @"com.apple.ShazamEventsApp": @"Shazam", @"com.apple.ScreenTimeWidgetApplication": @"Screen Time", + @"com.apple.CBRemoteSetup": @"Remote Setup", @"com.apple.DemoApp": @"Demo", @"com.apple.AAUIViewService": @"Apple Account", + @"com.apple.AccountAuthenticationDialog": @"Account Sign-In", @"com.apple.StoreKitUISceneService": @"App Store Sheets", + @"com.apple.NewDeviceOutreachApp": @"New Device Setup", @"com.apple.PDUIApp": @"Printing", @"com.apple.EventViewService": @"Events", + @"com.apple.MXUIService": @"Media Picker", @"com.apple.ctkui": @"Smart Card", @"com.apple.InCallService": @"Call Screen", + @"com.apple.springboard": @"SpringBoard", @"com.apple.SafariViewService": @"Safari View", @"com.apple.mobilesms.compose": @"Message Compose", + @"com.apple.CompanionViewService": @"Companion", @"com.apple.musicrecognition": @"Music Recognition", + @"com.apple.FontInstallViewService": @"Font Install", @"com.apple.CMViewSrvc": @"Continuity Markup", + @"com.apple.AppleCashStickerPackHost": @"Apple Cash Stickers", @"com.apple.TVSetupUIService": @"Apple TV Setup", + @"com.apple.HearingApp": @"Hearing", @"com.apple.CompanionSetup": @"Companion Setup", + @"com.apple.CarPlayWallpaper": @"CarPlay Wallpaper", @"com.apple.CloudKit.ShareBear": @"iCloud Sharing", + @"com.apple.EnhancedLogging": @"Enhanced Logging", @"com.apple.tcc.AuthorizationPromptService": @"Privacy Prompts", + @"com.apple.WebContentFilter.remoteUI.WebContentAnalysisUI": @"Content Filter", + @"com.apple.SystemPaperViewService": @"Quick Note", @"com.apple.HeadphoneProxService": @"Headphones", + @"com.apple.datadetectors.DDActionsService": @"Data Detectors", @"com.apple.ServicesPaymentAngel": @"Apple Pay", + @"com.apple.HomeCaptiveViewService": @"Wi-Fi Login", @"com.apple.DiagnosticsService": @"Diagnostics", + @"com.apple.CarTirePressure": @"Tire Pressure", @"com.apple.FCAuthenticationUI": @"Authentication", + @"com.apple.icloud.FindMyDevice.FindMyExtensionContainer": @"Find My", + @"com.apple.messages.RemoteAlerts": @"Messages Alerts", + @"com.apple.AccessoryNotifications.SourceSelection": @"Accessory Source", + @"com.apple.printcenter": @"Print Center", @"com.apple.BarcodeScanner": @"Code Scanner", + @"com.apple.chrono.WidgetRenderer-Snapshots": @"Widget Snapshots", + @"com.apple.AMSEngagementViewService": @"Apple Media Services", + @"com.apple.appManagedFeatures.Restrictions": @"Restrictions", @"com.apple.CarRadio": @"Car Radio", + @"com.apple.PassbookUIService": @"Wallet Sheets", @"com.apple.Spotlight": @"Spotlight", @"com.apple.InputUI": @"Keyboard", + }; + }); + return names; +} + +static NSString *wq_known_name_locked(NSString *bundleID) +{ + wq_load_ls_names_locked(); + return gLSDisplay[bundleID] ?: wq_builtin_names()[bundleID] ?: gLSBundleName[bundleID]; +} + +BOOL wq_display_names_loaded(void) +{ + @synchronized (wq_ls_lock()) { return gLSLoaded; } +} + +void wq_load_display_names(void) +{ + @synchronized (wq_ls_lock()) { wq_load_ls_names_locked(); } +} + +void wq_display_names_reset_miss(void) +{ + @synchronized (wq_ls_lock()) { + [NSUserDefaults.standardUserDefaults removeObjectForKey:kWQLSMissKey]; + gLSNextRetryAt = 0; + gLSFailureCount = 0; + } +} + +static NSString *const kWQBundleContainerRoot = @"/private/var/containers/Bundle/Application/"; + +// App bundle containers (/, holding Name.app) -> bundle id, built +// from the bundle paths; rebuilt when a new database snapshot is published. +static NSDictionary *wq_bundle_containers_locked(void) +{ + static NSDictionary *source, *map; + wq_load_ls_names_locked(); + if (map && source == gLSBundlePaths) return map; + NSMutableDictionary *built = [NSMutableDictionary new]; + [gLSBundlePaths enumerateKeysAndObjectsUsingBlock:^(NSString *bid, NSString *path, BOOL *__unused stop) { + if (![bid isKindOfClass:NSString.class] || ![path isKindOfClass:NSString.class]) return; + NSString *app = path.stringByStandardizingPath; + if ([app hasPrefix:@"/var/"]) app = [@"/private" stringByAppendingString:app]; + if (![app hasPrefix:kWQBundleContainerRoot] || ![app.pathExtension isEqualToString:@"app"]) return; + NSString *container = app.stringByDeletingLastPathComponent; + // Only //Name.app: extensions and nested apps stay out. + if (![container.stringByDeletingLastPathComponent isEqualToString: + [kWQBundleContainerRoot substringToIndex:kWQBundleContainerRoot.length - 1]]) return; + if (!built[container] || bid.length < [built[container] length]) built[container] = bid; + }]; + source = gLSBundlePaths; + map = [built copy]; + return map; +} + +NSString *wq_bundle_id_for_container(NSString *path) +{ + if (!path.length) return nil; + NSString *key = path.stringByStandardizingPath; + if ([key hasPrefix:@"/var/"]) key = [@"/private" stringByAppendingString:key]; + @synchronized (wq_ls_lock()) { + wq_load_ls_names_locked(); + NSString *bid = gLSContainers[key]; + if (!bid && [key hasPrefix:kWQBundleContainerRoot]) bid = wq_bundle_containers_locked()[key]; + return [bid isKindOfClass:NSString.class] ? bid : nil; + } +} + +NSArray *wq_ls_bundle_containers(void) +{ + @synchronized (wq_ls_lock()) { + return [wq_bundle_containers_locked().allKeys sortedArrayUsingSelector:@selector(compare:)]; + } +} + +NSString *wq_display_name(NSString *bundleID) +{ + if (!bundleID) return nil; + @synchronized (wq_ls_lock()) { + NSString *name = wq_known_name_locked(bundleID); + if (name) return WQLocalized(name); + // Unknown id: use the closest parent app (com.apple.news.widget -> News). + NSArray *parts = [bundleID componentsSeparatedByString:@"."]; + for (NSInteger n = (NSInteger)parts.count - 1; n >= 2; n--) { + NSString *prefix = [[parts subarrayWithRange:NSMakeRange(0, (NSUInteger)n)] componentsJoinedByString:@"."]; + NSString *parent = wq_known_name_locked(prefix); + if (parent) { + NSString *suffix = wq_safe_name(parts.lastObject); + NSString *display = suffix.length ? [NSString stringWithFormat:@"%@ \u2013 %@", parent, suffix] : parent; + return WQLocalized(display); + } + } + return nil; + } +} + +NSArray *wq_ls_container_paths(NSString *storeHint) +{ + // No lock and no scan here: the container scan calls this while a name + // load may be waiting for that scan. + NSUserDefaults *defaults = NSUserDefaults.standardUserDefaults; + NSString *path = storeHint.length ? storeHint : [defaults stringForKey:kWQLSStoreKey]; + if (!path.length) return nil; + NSString *error = nil; + NSData *data = wq_read(path, kWQLSMaxStoreBytes + 1, &error); + if (!data || data.length > kWQLSMaxStoreBytes) { + wq_log(@"names: cannot read %@ (%@)", path, error ?: @"too large"); + return nil; + } + NSDictionary *containers = nil; + if (!wq_parse_ls_store(data, NULL, NULL, &containers, NULL) || containers.count < 2) return nil; + if (storeHint.length) { + [defaults setObject:storeHint forKey:kWQLSStoreKey]; + [defaults removeObjectForKey:kWQLSMissKey]; + } + // App group containers aren't mapped to an owner here; the store lists + // the group containers of installed apps, which is what the list needs. + NSMutableSet *all = [NSMutableSet setWithArray:containers.allKeys]; + static const char needle[] = "/mobile/Containers/Shared/AppGroup/"; + const uint8_t *bytes = data.bytes, *cursor = bytes, *end = bytes + data.length; + while (cursor < end) { + const uint8_t *hit = memmem(cursor, (size_t)(end - cursor), needle, sizeof(needle) - 1); + if (!hit) break; + cursor = hit + sizeof(needle) - 1; + if ((size_t)(end - cursor) < 36) break; + NSString *uuid = [[NSString alloc] initWithBytes:cursor length:36 encoding:NSASCIIStringEncoding]; + if (!uuid || ![[NSUUID alloc] initWithUUIDString:uuid]) continue; + [all addObject:[@"/private/var/mobile/Containers/Shared/AppGroup/" stringByAppendingString:uuid.uppercaseString]]; + } + return all.allObjects; +} + +NSString *wq_bundle_path_for_id(NSString *bundleID) +{ + if (!bundleID.length) return nil; + @synchronized (wq_ls_lock()) { + wq_load_ls_names_locked(); + NSString *path = gLSBundlePaths[bundleID]; + return [path isKindOfClass:NSString.class] ? path : nil; + } +} diff --git a/source/hook/src/WQLocalization.h b/source/hook/src/WQLocalization.h new file mode 100644 index 0000000..e7213b1 --- /dev/null +++ b/source/hook/src/WQLocalization.h @@ -0,0 +1,18 @@ +// +// WQLocalization.h +// WQ Files +// +// Shared runtime localization helpers. English source strings are the keys; +// zh-Hans.lproj supplies the Chinese translations and en.lproj is the +// identity fallback. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +NSString *WQLocalized(NSString *key); +NSString *_Nullable WQLocalizedNullable(NSString *_Nullable text); +NSString *WQLocalizedFormat(NSString *key, ...) NS_FORMAT_FUNCTION(1, 2); + +NS_ASSUME_NONNULL_END diff --git a/source/hook/src/WQLocalization.m b/source/hook/src/WQLocalization.m new file mode 100644 index 0000000..87e3b4e --- /dev/null +++ b/source/hook/src/WQLocalization.m @@ -0,0 +1,27 @@ +// +// WQLocalization.m +// WQ Files +// + +#import "WQLocalization.h" + +NSString *WQLocalized(NSString *key) +{ + if (!key.length) return key; + return NSLocalizedString(key, nil); +} + +NSString *WQLocalizedNullable(NSString *text) +{ + return text ? WQLocalized(text) : nil; +} + +NSString *WQLocalizedFormat(NSString *key, ...) +{ + va_list args; + va_start(args, key); + NSString *format = WQLocalized(key); + NSString *text = [[NSString alloc] initWithFormat:format arguments:args]; + va_end(args); + return text; +} diff --git a/source/hook/src/WQMediaStream.h b/source/hook/src/WQMediaStream.h new file mode 100644 index 0000000..06a1377 --- /dev/null +++ b/source/hook/src/WQMediaStream.h @@ -0,0 +1,35 @@ +// +// WQMediaStream.h +// WQ Files +// +// Plays media from other apps' folders without copying them first. The +// player gets a custom URL; AVFoundation asks this object for byte ranges, +// which WQ Files reads itself (the media process has no access to those +// folders). If the item fails, onFailure runs once, so the caller can fall +// back to copying the file. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +typedef NSData *_Nullable (^WQMediaReadRange)(unsigned long long offset, NSUInteger length, NSString *_Nullable *_Nullable error); + +@interface WQMediaStream : NSObject + +// The type identifier the player is told for a file name ("public.mpeg-4"), +// or nil when the extension isn't one that streams this way. ++ (nullable NSString *)contentTypeForFileName:(NSString *)name; + +// A player item that reads `size` bytes through `read`. The item keeps the +// stream alive. onFailure (main queue) gets the reason once if the item fails. ++ (nullable AVPlayerItem *)playerItemForName:(NSString *)name size:(unsigned long long)size + read:(WQMediaReadRange)read + onFailure:(void (^)(NSString *reason))onFailure; + +// For tests: answers one data request range synchronously. +- (nullable NSData *)dataForOffset:(unsigned long long)offset length:(NSUInteger)length error:(NSString *_Nullable *_Nullable)error; + +@end + +NS_ASSUME_NONNULL_END diff --git a/source/hook/src/WQMediaStream.m b/source/hook/src/WQMediaStream.m new file mode 100644 index 0000000..fa647d2 --- /dev/null +++ b/source/hook/src/WQMediaStream.m @@ -0,0 +1,134 @@ +// +// WQMediaStream.m +// WQ Files +// + +#import "WQMediaStream.h" +#import +#import + +static NSString *const kWQMediaScheme = @"wqmedia"; +static const NSUInteger kWQMediaChunk = 512 * 1024; +static char kWQMediaStreamKey; + +@interface WQMediaStream () +@property (nonatomic, copy) NSString *contentType; +@property (nonatomic) unsigned long long size; +@property (nonatomic, copy) WQMediaReadRange read; +@property (nonatomic, copy) void (^onFailure)(NSString *reason); +@property (nonatomic, weak) AVPlayerItem *item; +@property (nonatomic) BOOL failed; +@end + +@implementation WQMediaStream { + dispatch_queue_t _readQueue; +} + ++ (NSString *)contentTypeForFileName:(NSString *)name +{ + NSString *ext = name.pathExtension.lowercaseString; + // Playlists reference other files; those can't be served from here. + if (!ext.length || [ext isEqualToString:@"m3u8"]) return nil; + UTType *type = [UTType typeWithFilenameExtension:ext]; + if (!type || !([type conformsToType:UTTypeAudiovisualContent])) return nil; + return type.identifier; +} + ++ (AVPlayerItem *)playerItemForName:(NSString *)name size:(unsigned long long)size + read:(WQMediaReadRange)read onFailure:(void (^)(NSString *))onFailure +{ + NSString *contentType = [self contentTypeForFileName:name]; + if (!contentType || size == 0) return nil; + WQMediaStream *stream = [WQMediaStream new]; + stream.contentType = contentType; + stream.size = size; + stream.read = read; + stream.onFailure = onFailure; + // The extension stays on the URL: AVFoundation also looks at it. + NSString *escaped = [name.lastPathComponent stringByAddingPercentEncodingWithAllowedCharacters:NSCharacterSet.URLPathAllowedCharacterSet]; + NSURL *url = [NSURL URLWithString:[NSString stringWithFormat:@"%@://media/%@", kWQMediaScheme, escaped ?: @"media"]]; + if (!url) return nil; + AVURLAsset *asset = [AVURLAsset URLAssetWithURL:url options:nil]; + [asset.resourceLoader setDelegate:stream queue:dispatch_queue_create("wq.media.loader", DISPATCH_QUEUE_SERIAL)]; + AVPlayerItem *item = [AVPlayerItem playerItemWithAsset:asset]; + stream.item = item; + // The resource loader holds its delegate weakly: the item keeps it. + objc_setAssociatedObject(item, &kWQMediaStreamKey, stream, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + [item addObserver:stream forKeyPath:@"status" options:NSKeyValueObservingOptionNew context:&kWQMediaStreamKey]; + return item; +} + +- (instancetype)init +{ + if ((self = [super init])) _readQueue = dispatch_queue_create("wq.media.read", DISPATCH_QUEUE_SERIAL); + return self; +} + +- (void)dealloc +{ + @try { [_item removeObserver:self forKeyPath:@"status" context:&kWQMediaStreamKey]; } @catch (NSException *__unused e) {} +} + +- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context +{ + if (context != &kWQMediaStreamKey) { [super observeValueForKeyPath:keyPath ofObject:object change:change context:context]; return; } + AVPlayerItem *item = object; + if (item.status != AVPlayerItemStatusFailed) return; + NSString *reason = item.error.localizedDescription ?: @"the player failed"; + NSError *underlying = item.error.userInfo[NSUnderlyingErrorKey]; + if (underlying) reason = [reason stringByAppendingFormat:@" (%@ %ld)", underlying.domain, (long)underlying.code]; + dispatch_async(dispatch_get_main_queue(), ^{ + if (self.failed) return; + self.failed = YES; + if (self.onFailure) self.onFailure(reason); + }); +} + +- (NSData *)dataForOffset:(unsigned long long)offset length:(NSUInteger)length error:(NSString **)error +{ + if (offset >= _size) return [NSData data]; + unsigned long long left = _size - offset; + NSUInteger want = (NSUInteger)MIN((unsigned long long)length, left); + return _read(offset, want, error); +} + +- (BOOL)resourceLoader:(AVAssetResourceLoader *)resourceLoader + shouldWaitForLoadingOfRequestedResource:(AVAssetResourceLoadingRequest *)request +{ + if (![request.request.URL.scheme isEqualToString:kWQMediaScheme]) return NO; + AVAssetResourceLoadingContentInformationRequest *info = request.contentInformationRequest; + if (info) { + info.contentType = _contentType; + info.contentLength = (long long)_size; + info.byteRangeAccessSupported = YES; + } + AVAssetResourceLoadingDataRequest *data = request.dataRequest; + if (!data) { [request finishLoading]; return YES; } + // Reads happen off the loader queue, in chunks, so a seek can cancel a + // long request between chunks. + dispatch_async(_readQueue, ^{ + long long start = data.currentOffset; + long long end = data.requestsAllDataToEndOfResource ? (long long)self->_size + : data.requestedOffset + (long long)data.requestedLength; + end = MIN(end, (long long)self->_size); + long long offset = start; + while (offset < end) { + if (request.isCancelled || request.isFinished) return; + NSUInteger length = (NSUInteger)MIN((long long)kWQMediaChunk, end - offset); + NSString *error = nil; + NSData *chunk = [self dataForOffset:(unsigned long long)offset length:length error:&error]; + if (!chunk.length) { + if (request.isCancelled || request.isFinished) return; + [request finishLoadingWithError:[NSError errorWithDomain:@"WQMediaStream" code:1 + userInfo:@{NSLocalizedDescriptionKey: error ?: @"The file could not be read"}]]; + return; + } + [data respondWithData:chunk]; + offset += (long long)chunk.length; + } + if (!request.isCancelled && !request.isFinished) [request finishLoading]; + }); + return YES; +} + +@end diff --git a/source/hook/src/WQPlistEditor.h b/source/hook/src/WQPlistEditor.h new file mode 100644 index 0000000..1640c6e --- /dev/null +++ b/source/hook/src/WQPlistEditor.h @@ -0,0 +1,23 @@ +// +// WQPlistEditor.h +// WQ Files +// +// Property list editor: browse dictionaries and arrays, edit values by type, +// add, delete, rename and reorder. Saves in the file's original format through +// wq_write_if_unchanged_get_version (backup first, verified contents). +// + +#import +#import "WQEditing.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface WQPlistEditorViewController : UITableViewController +// Loads `path` itself. openAsText / openAsHex (optional) switch to the other +// viewers from the "Open As" menu. +- (instancetype)initWithPath:(NSString *)path readOnly:(BOOL)readOnly; +@property (nonatomic, copy, nullable) void (^openAsText)(void); +@property (nonatomic, copy, nullable) void (^openAsHex)(void); +@end + +NS_ASSUME_NONNULL_END diff --git a/source/hook/src/WQPlistEditor.m b/source/hook/src/WQPlistEditor.m new file mode 100644 index 0000000..888d0f3 --- /dev/null +++ b/source/hook/src/WQPlistEditor.m @@ -0,0 +1,738 @@ +// +// WQPlistEditor.m +// WQ Files +// +// GPL-3.0-or-later. +// + +#import "WQPlistEditor.h" +#import "WQAccess.h" +#import "WQCore.h" +#import "WQUI.h" +#import "WQPlistEditorSupport.h" + +typedef NS_ENUM(NSInteger, WQPType) { + WQPTypeString, WQPTypeInteger, WQPTypeReal, WQPTypeBoolean, WQPTypeDate, WQPTypeData, + WQPTypeDictionary, WQPTypeArray, WQPTypeUID, WQPTypeUnknown, +}; + +static WQPType wqp_type(id v) +{ + if ([v isKindOfClass:NSString.class]) return WQPTypeString; + if ([v isKindOfClass:NSNumber.class]) { + if (CFGetTypeID((__bridge CFTypeRef)v) == CFBooleanGetTypeID()) return WQPTypeBoolean; + return CFNumberIsFloatType((__bridge CFNumberRef)v) ? WQPTypeReal : WQPTypeInteger; + } + if ([v isKindOfClass:NSDate.class]) return WQPTypeDate; + if ([v isKindOfClass:NSData.class]) return WQPTypeData; + if ([v isKindOfClass:NSDictionary.class]) return WQPTypeDictionary; + if ([v isKindOfClass:NSArray.class]) return WQPTypeArray; + if (WQPlistIsKeyedArchiveUID(v)) return WQPTypeUID; + return WQPTypeUnknown; +} + +static NSString *wqp_type_name(WQPType t) +{ + switch (t) { + case WQPTypeString: return WQLocalized(@"String"); + case WQPTypeInteger: return WQLocalized(@"Number"); + case WQPTypeReal: return WQLocalized(@"Real"); + case WQPTypeBoolean: return WQLocalized(@"Boolean"); + case WQPTypeDate: return WQLocalized(@"Date"); + case WQPTypeData: return WQLocalized(@"Data"); + case WQPTypeDictionary: return WQLocalized(@"Dictionary"); + case WQPTypeArray: return WQLocalized(@"Array"); + case WQPTypeUID: return WQLocalized(@"Reference"); + case WQPTypeUnknown: return @"?"; + } + return @"?"; +} + +static NSString *wqp_hex(NSData *data, NSUInteger max) +{ + const uint8_t *b = data.bytes; + NSUInteger n = MIN(data.length, max); + NSMutableString *s = [NSMutableString stringWithCapacity:n * 3]; + for (NSUInteger i = 0; i < n; i++) [s appendFormat:i ? @" %02x" : @"%02x", b[i]]; + if (data.length > max) [s appendString:@" …"]; + return s; +} + +static NSISO8601DateFormatter *wqp_dates(void) +{ + static NSISO8601DateFormatter *f; + static dispatch_once_t once; + dispatch_once(&once, ^{ f = [NSISO8601DateFormatter new]; }); + return f; +} + +static NSString *wqp_summary(id v) +{ + switch (wqp_type(v)) { + case WQPTypeString: return WQLocalized(WQPBoundedStringPreview(v)); + case WQPTypeInteger: case WQPTypeReal: return [v stringValue]; + case WQPTypeBoolean: return [v boolValue] ? WQLocalized(@"YES") : WQLocalized(@"NO"); + case WQPTypeDate: return [wqp_dates() stringFromDate:v]; + case WQPTypeData: return WQLocalizedFormat(@"%lu bytes · %@", (unsigned long)[v length], wqp_hex(v, 24)); + case WQPTypeDictionary: return WQLocalizedFormat(@"%lu keys", (unsigned long)[v count]); + case WQPTypeArray: return WQLocalizedFormat(@"%lu items", (unsigned long)[v count]); + case WQPTypeUID: return WQLocalized(@"keyed-archive reference"); + case WQPTypeUnknown: return WQPBoundedStringPreview([v description]); + } + return @""; +} + +static NSString *wqp_scalar_text(id value, WQPType t) +{ + if (t == WQPTypeData) return wqp_hex(value, WQPMaxDataBytes); + if (t == WQPTypeDate) return [wqp_dates() stringFromDate:value]; + if ([value isKindOfClass:NSString.class]) return value; + if ([value isKindOfClass:NSNumber.class]) return [value stringValue]; + return @""; +} + +static id wqp_default(WQPType t) +{ + switch (t) { + case WQPTypeString: return @""; + case WQPTypeInteger: return @0; + case WQPTypeReal: return @0.0; + case WQPTypeBoolean: return @NO; + case WQPTypeDate: return NSDate.date; + case WQPTypeData: return [NSData data]; + case WQPTypeDictionary: return [NSMutableDictionary dictionary]; + case WQPTypeArray: return [NSMutableArray array]; + case WQPTypeUID: return @""; + case WQPTypeUnknown: return @""; + } + return @""; +} + +static void wqp_alert(UIViewController *host, NSString *title, NSString *message) +{ + UIAlertController *a = wq_alert_controller(title, message, UIAlertControllerStyleAlert); + [a addAction:wq_alert_action(@"OK", UIAlertActionStyleDefault, nil)]; + [host presentViewController:a animated:YES completion:nil]; +} + +#pragma mark - Document + +@interface WQPlistDocument : NSObject +@property (nonatomic, copy) NSString *path; +@property (nonatomic, strong) id root; +@property (nonatomic) NSPropertyListFormat format; +@property (nonatomic) BOOL readOnly; +@property (nonatomic, strong) WQEditState *edit; +@property (nonatomic, strong) NSDictionary *version; +@end + +@implementation WQPlistDocument +@end + +#pragma mark - Editor + +@interface WQPlistEditorViewController () +@property (nonatomic, strong) WQPlistDocument *doc; +@property (nonatomic, strong) id container; // NSMutableDictionary or NSMutableArray +@property (nonatomic, copy) NSArray *keys; // sorted keys (dictionary) or nil (array) +@property (nonatomic, copy) NSString *status; // loading / error text +@property (nonatomic) BOOL isRoot; +@end + +@implementation WQPlistEditorViewController { + NSArray *_sortedKeyCache; + BOOL _keysDirty; +} + +- (instancetype)initWithPath:(NSString *)path readOnly:(BOOL)readOnly +{ + if ((self = [super initWithStyle:UITableViewStyleInsetGrouped])) { + _doc = [WQPlistDocument new]; + _doc.path = path; + _doc.readOnly = readOnly; + _doc.edit = [WQEditState new]; + _isRoot = YES; + _status = WQLocalized(@"Loading…"); + self.title = path.lastPathComponent; + } + return self; +} + +- (instancetype)initWithDocument:(WQPlistDocument *)doc container:(id)container title:(NSString *)title +{ + if ((self = [super initWithStyle:UITableViewStyleInsetGrouped])) { + _doc = doc; + _container = container; + self.title = title; + } + return self; +} + +- (void)viewDidLoad +{ + [super viewDidLoad]; + self.navigationItem.largeTitleDisplayMode = UINavigationItemLargeTitleDisplayModeNever; + __weak typeof(self) weakSelf = self; + if (self.isRoot) { + self.navigationItem.backAction = [UIAction actionWithHandler:^(UIAction *__unused a) { [weakSelf leave]; }]; + [self load]; + } else { + self.navigationItem.backAction = [UIAction actionWithHandler:^(UIAction *__unused a) { + if ([weakSelf blockWhileSaving]) return; + [weakSelf.navigationController popViewControllerAnimated:YES]; + }]; + } + [self updateBar]; +} + +- (void)viewWillAppear:(BOOL)animated +{ + [super viewWillAppear:animated]; + [self refresh]; +} + +- (void)load +{ + NSString *path = self.doc.path; + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + NSString *error = nil; + NSDictionary *__unused before = wq_file_version(path); + NSData *data = wq_read(path, WQPMaxSerializedBytes, &error); + NSDictionary *version = wq_file_version(path); + if (data && (!before || ![before isEqualToDictionary:version])) { + data = nil; + error = WQLocalized(@"The file changed while it was being read. Open it again."); + } + NSPropertyListFormat format = NSPropertyListBinaryFormat_v1_0; + NSError *parseError = nil; + id root = data ? [NSPropertyListSerialization propertyListWithData:data + options:NSPropertyListMutableContainersAndLeaves + format:&format error:&parseError] : nil; + dispatch_async(dispatch_get_main_queue(), ^{ + self.doc.version = version; + if (!root) { + self.status = data ? WQLocalizedFormat(@"Not a property list: %@", parseError.localizedDescription) + : (wq_permission_hint(path, NO) ?: WQLocalizedFormat(@"Cannot read this file: %@", error)); + } else if (![root isKindOfClass:NSMutableDictionary.class] && ![root isKindOfClass:NSMutableArray.class]) { + // A plist whose root is a single value: wrap it for display only. + self.doc.format = format; + self.doc.root = root; + self.doc.readOnly = YES; + self.container = [NSMutableArray arrayWithObject:root]; + self.status = nil; + } else { + if (wq_permission_hint(path, YES)) self.doc.readOnly = YES; // readable, but only the owner may write + if (!WQPlistFormatIsWritable(format)) self.doc.readOnly = YES; // OpenStep/unknown format + self.doc.root = root; + self.doc.format = format; + self.container = root; + self.status = nil; + } + [self refresh]; + }); + }); +} + +- (void)refresh +{ + if ([self.container isKindOfClass:NSDictionary.class]) { + if (!_sortedKeyCache || _keysDirty) { + _sortedKeyCache = [[self.container allKeys] sortedArrayUsingSelector:@selector(localizedStandardCompare:)]; + _keysDirty = NO; + } + self.keys = _sortedKeyCache; + } else { + self.keys = nil; + _sortedKeyCache = nil; + _keysDirty = NO; + } + [self updateBar]; + [self.tableView reloadData]; +} + +- (BOOL)isArray +{ + return [self.container isKindOfClass:NSArray.class]; +} + +- (void)updateBar +{ + NSMutableArray *items = [NSMutableArray array]; + if (!self.doc.readOnly && self.container) { + UIBarButtonItem *save = [[UIBarButtonItem alloc] initWithTitle:WQLocalizedNullable(@"Save") style:wq_primary_button_style() + target:self action:@selector(save)]; + save.enabled = self.doc.edit.dirty && !self.doc.edit.saving; + [items addObject:save]; + UIBarButtonItem *add = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemAdd + target:self action:@selector(add)]; + add.enabled = !self.doc.edit.saving; + [items addObject:add]; + } + NSMutableArray *menu = [NSMutableArray array]; + __weak typeof(self) weakSelf = self; + if (self.isArray && !self.doc.readOnly && !self.doc.edit.saving && self.container) { + [menu addObject:[UIAction actionWithTitle:WQLocalizedNullable(self.tableView.editing ? @"Done Reordering" : @"Reorder Items") + image:[UIImage systemImageNamed:@"arrow.up.arrow.down"] identifier:nil + handler:^(UIAction *__unused a) { + [weakSelf.tableView setEditing:!weakSelf.tableView.editing animated:YES]; + [weakSelf updateBar]; + }]]; + } + if (self.openAsText) [menu addObject:[UIAction actionWithTitle:WQLocalizedNullable(@"Open as XML Text") image:[UIImage systemImageNamed:@"doc.plaintext"] + identifier:nil handler:^(UIAction *__unused a) { [weakSelf leaveThen:weakSelf.openAsText]; }]]; + if (self.openAsHex) [menu addObject:[UIAction actionWithTitle:WQLocalizedNullable(@"Open in Hex Viewer") image:[UIImage systemImageNamed:@"number"] + identifier:nil handler:^(UIAction *__unused a) { [weakSelf leaveThen:weakSelf.openAsHex]; }]]; + if (menu.count) + [items addObject:[[UIBarButtonItem alloc] initWithImage:[UIImage systemImageNamed:@"ellipsis.circle"] + menu:[UIMenu menuWithChildren:menu]]]; + self.navigationItem.rightBarButtonItems = items; +} + +#pragma mark Leaving and saving + +- (BOOL)wq_needsLeaveConfirmation +{ + // Ordinary dirty child navigation keeps the shared edits, but no editor + // may leave while the shared document is being written. + return self.doc.edit.saving || (self.isRoot && self.doc.edit.dirty); +} + +- (void)leave +{ + [self leaveThen:nil]; +} + +- (BOOL)blockWhileSaving +{ + if (!self.doc.edit.saving) return NO; + wqp_alert(self, @"Save in progress", @"Wait for the current save to finish before leaving or making more changes."); + return YES; +} + +// Asks before throwing away unsaved changes, then pops to the folder and runs `then`. +- (void)leaveThen:(void (^)(void))then +{ + if ([self blockWhileSaving]) return; + UINavigationController *nav = self.navigationController; + NSUInteger rootIndex = [nav.viewControllers indexOfObjectPassingTest:^BOOL(UIViewController *vc, NSUInteger __unused i, BOOL *__unused stop) { + return [vc isKindOfClass:WQPlistEditorViewController.class] && ((WQPlistEditorViewController *)vc).isRoot && + ((WQPlistEditorViewController *)vc).doc == self.doc; + }]; + void (^go)(void) = ^{ + if (rootIndex != NSNotFound && rootIndex > 0) + [nav popToViewController:nav.viewControllers[rootIndex - 1] animated:then == nil]; + if (then) then(); + }; + if (!self.doc.edit.dirty) { go(); return; } + UIAlertController *a = wq_alert_controller(@"Discard changes?", @"Your edits to this property list have not been saved.", UIAlertControllerStyleAlert); + [a addAction:wq_alert_action(@"Keep Editing", UIAlertActionStyleCancel, nil)]; + [a addAction:wq_alert_action(@"Discard", UIAlertActionStyleDestructive, ^(UIAlertAction *__unused x) { + [self.doc.edit discard]; + go(); + })]; + [self presentViewController:a animated:YES completion:nil]; +} + +// Home button: ask before throwing away unsaved changes. +- (void)confirmLeaveThen:(void (^)(void))proceed +{ + if ([self blockWhileSaving]) return; + if (!self.doc.edit.dirty) { proceed(); return; } + UIAlertController *a = wq_alert_controller(@"Discard changes?", @"Your edits to this property list have not been saved.", UIAlertControllerStyleAlert); + [a addAction:wq_alert_action(@"Keep Editing", UIAlertActionStyleCancel, nil)]; + [a addAction:wq_alert_action(@"Discard", UIAlertActionStyleDestructive, ^(UIAlertAction *__unused x) { + [self.doc.edit discard]; + proceed(); + })]; + [self presentViewController:a animated:YES completion:nil]; +} + +- (void)save +{ + if ([self blockWhileSaving] || self.doc.readOnly || !self.doc.edit.dirty) return; + WQPlistDocument *doc = self.doc; + if (!WQPlistFormatIsWritable(doc.format)) { + wqp_alert(self, @"Read-only format", @"This property list uses an OpenStep/unknown format that cannot be saved by the editor. Use Open as XML Text and choose a supported format explicitly."); + return; + } + if (!doc.version) { + wqp_alert(self, @"Cannot save safely", @"The current file version is unavailable. Reopen the file before saving again."); + return; + } + NSString *fmt = WQLocalized(WQPlistFormatName(doc.format)); + UIAlertController *a = wq_alert_controller(@"Save changes?", WQLocalizedFormat(@"%@ is overwritten in place (%@ format). The current version is backed up first.", doc.path, fmt), UIAlertControllerStyleAlert); + [a addAction:wq_alert_action(@"Cancel", UIAlertActionStyleCancel, nil)]; + [a addAction:wq_alert_action(@"Save", UIAlertActionStyleDestructive, ^(UIAlertAction *__unused x) { + NSUInteger savedRevision = [doc.edit beginSave]; + NSDictionary *expectedVersion = [doc.version copy]; + NSString *path = [doc.path copy]; + NSPropertyListFormat format = doc.format; + id root = doc.root; + UINavigationController *navigationController = self.navigationController; + __weak WQPlistEditorViewController *weakSelf = self; + [self refresh]; + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + NSError *serializationError = nil; + NSString *writeError = nil; + BOOL ok = NO; + NSDictionary *savedVersion = nil; + NSString *copyError = nil; + id snapshot = WQPlistImmutableCopy(root, ©Error); + NSData *data = snapshot ? [NSPropertyListSerialization dataWithPropertyList:snapshot + format:format + options:0 + error:&serializationError] : nil; + if (!snapshot) { + writeError = copyError ?: WQLocalized(@"The edited property list contains an unsupported value."); + } else if (!data) { + writeError = serializationError.localizedDescription ?: WQLocalized(@"The edited property list is not valid."); + } else if (!WQPSerializedDataWithinLimit(data)) { + writeError = WQLocalizedFormat(@"The serialized property list is too large (maximum %lu MiB).", (unsigned long)(WQPMaxSerializedBytes / (1024 * 1024))); + } else { + NSError *validationError = nil; + id check = [NSPropertyListSerialization propertyListWithData:data + options:NSPropertyListImmutable + format:NULL + error:&validationError]; + if (!check) { + writeError = validationError.localizedDescription ?: WQLocalized(@"The serialized property list could not be validated."); + } else { + ok = wq_write_if_unchanged_get_version(path, data, expectedVersion, &savedVersion, &writeError); + } + } + dispatch_async(dispatch_get_main_queue(), ^{ + [doc.edit finishSave:ok revision:savedRevision]; + // Returned while the saved bytes were verified on the locked + // descriptor; a later external change is rejected next save. + if (ok) doc.version = savedVersion; + + WQPlistEditorViewController *origin = weakSelf; + UINavigationController *nav = navigationController ?: origin.navigationController; + WQPlistEditorViewController *visible = nil; + for (UIViewController *controller in nav.viewControllers) { + if (![controller isKindOfClass:WQPlistEditorViewController.class]) continue; + WQPlistEditorViewController *editor = (WQPlistEditorViewController *)controller; + if (editor.doc != doc) continue; + [editor refresh]; + if (controller == nav.visibleViewController) visible = editor; + } + if (!visible && origin.doc == doc) { + [origin refresh]; + if (origin.viewIfLoaded.window) visible = origin; + } + if (!ok && visible.viewIfLoaded.window) { + NSString *hint = wq_permission_hint(path, YES); + wqp_alert(visible, @"Save failed", hint ?: writeError ?: @"Unknown error"); + } + }); + }); + })]; + [self presentViewController:a animated:YES completion:nil]; +} + +- (void)changed +{ + if (self.doc.readOnly || self.doc.edit.saving) return; + // Callers have already changed the data. Record it even during a save: the + // newer revision keeps the document dirty when that save finishes. The + // guard above also protects stale UI callbacks from changing readonly or + // frozen documents. + _keysDirty = YES; + [self.doc.edit noteChange]; + [self refresh]; +} + +#pragma mark Table + +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section +{ + return self.status ? 0 : (NSInteger)[self.container count]; +} + +- (NSString *)tableView:(UITableView *)tableView titleForFooterInSection:(NSInteger)section +{ + if (self.status) return self.status; + if (!self.isRoot) return nil; + NSString *fmt = WQLocalized(WQPlistFormatName(self.doc.format)); + if (self.doc.readOnly) { + NSString *reason = WQPlistFormatIsWritable(self.doc.format) ? WQLocalized(@"read only") : WQLocalized(@"read only · writing is not supported"); + return WQLocalizedFormat(@"%@ property list · %@", fmt, reason); + } + return WQLocalizedFormat(@"%@ property list. Tap a value to edit it, swipe to delete or rename, + to add.", fmt); +} + +- (id)keyAt:(NSInteger)row +{ + return self.isArray ? @(row) : self.keys[(NSUInteger)row]; +} + +- (id)valueAt:(NSInteger)row +{ + return self.isArray ? self.container[(NSUInteger)row] : self.container[self.keys[(NSUInteger)row]]; +} + +- (void)setValue:(id)value at:(NSInteger)row +{ + if (self.doc.readOnly || self.doc.edit.saving) return; + if (self.isArray) self.container[(NSUInteger)row] = value; + else self.container[self.keys[(NSUInteger)row]] = value; +} + +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath +{ + UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"plist"]; + if (!cell) cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"plist"]; + cell.accessoryView = nil; + cell.accessoryType = UITableViewCellAccessoryNone; + cell.selectionStyle = UITableViewCellSelectionStyleDefault; + UIListContentConfiguration *c = [UIListContentConfiguration subtitleCellConfiguration]; + id value = [self valueAt:indexPath.row]; + WQPType t = wqp_type(value); + c.text = self.isArray ? WQLocalizedFormat(@"Item %ld", (long)indexPath.row) : [self keyAt:indexPath.row]; + c.secondaryText = WQLocalizedFormat(@"%@ · %@", wqp_type_name(t), wqp_summary(value)); + c.secondaryTextProperties.numberOfLines = 3; + c.secondaryTextProperties.color = UIColor.secondaryLabelColor; + if (t == WQPTypeDictionary || t == WQPTypeArray) { + cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator; + } else if (t == WQPTypeBoolean) { + UISwitch *sw = [UISwitch new]; + sw.on = [value boolValue]; + sw.enabled = !self.doc.readOnly && !self.doc.edit.saving; + sw.tag = indexPath.row; + [sw addTarget:self action:@selector(boolChanged:) forControlEvents:UIControlEventValueChanged]; + cell.accessoryView = sw; + cell.selectionStyle = UITableViewCellSelectionStyleNone; + } + cell.contentConfiguration = c; + return cell; +} + +- (void)boolChanged:(UISwitch *)sw +{ + if (self.doc.readOnly || self.doc.edit.saving) { + sw.on = [[self valueAt:sw.tag] boolValue]; + return; + } + [self setValue:@(sw.on) at:sw.tag]; + [self changed]; +} + +- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath +{ + [tableView deselectRowAtIndexPath:indexPath animated:YES]; + if (self.doc.edit.saving) return; + id value = [self valueAt:indexPath.row]; + WQPType t = wqp_type(value); + NSString *name = self.isArray ? WQLocalizedFormat(@"Item %ld", (long)indexPath.row) : [self keyAt:indexPath.row]; + if (t == WQPTypeDictionary || t == WQPTypeArray) { + WQPlistEditorViewController *child = [[WQPlistEditorViewController alloc] initWithDocument:self.doc container:value title:name]; + [self.navigationController pushViewController:child animated:YES]; + return; + } + if (t == WQPTypeUID) { + wqp_alert(self, @"Reference value", @"Keyed-archive references are read-only in this editor. Edit the archived data through its owning key instead."); + return; + } + if (t == WQPTypeBoolean || self.doc.readOnly || self.doc.edit.saving) return; + NSInteger row = indexPath.row; + [self editValueOfType:t current:value title:name done:^(id newValue) { + [self setValue:newValue at:row]; + [self changed]; + }]; +} + +// Asks for a scalar value of type `t`; `done` gets the parsed value. +- (void)editValueOfType:(WQPType)t current:(id)current title:(NSString *)title done:(void (^)(id value))done +{ + if (self.doc.readOnly || self.doc.edit.saving) return; + if (t == WQPTypeUID) return; + if (t == WQPTypeData && [current length] > WQPMaxDataBytes) { + wqp_alert(self, @"Too large to edit here", @"Data over 4 KB can't be edited in this editor."); + return; + } + if (t == WQPTypeString && [current length] > WQPMaxStringInputCharacters) { + wqp_alert(self, @"Too large to edit here", @"Strings over 64 KB can't be edited in this editor."); + return; + } + NSString *hint = nil; + switch (t) { + case WQPTypeInteger: hint = WQLocalized(@"Whole number"); break; + case WQPTypeReal: hint = WQLocalized(@"Number"); break; + case WQPTypeDate: hint = WQLocalized(@"ISO 8601, e.g. 2026-11-09T15:00:00Z"); break; + case WQPTypeData: hint = WQLocalized(@"Hex bytes, e.g. 01 ab ff"); break; + default: break; + } + UIAlertController *a = wq_alert_controller(title, [wqp_type_name(t) stringByAppendingString:hint ? [@" · " stringByAppendingString:hint] : @""], UIAlertControllerStyleAlert); + [a addTextFieldWithConfigurationHandler:^(UITextField *f) { + f.text = wqp_scalar_text(current, t); + f.autocapitalizationType = UITextAutocapitalizationTypeNone; + f.autocorrectionType = UITextAutocorrectionTypeNo; + f.clearButtonMode = UITextFieldViewModeAlways; + if (t == WQPTypeInteger) f.keyboardType = UIKeyboardTypeNumbersAndPunctuation; + if (t == WQPTypeReal) f.keyboardType = UIKeyboardTypeDecimalPad; + }]; + [a addAction:wq_alert_action(@"Cancel", UIAlertActionStyleCancel, nil)]; + [a addAction:wq_alert_action(@"Set", UIAlertActionStyleDefault, ^(UIAlertAction *__unused x) { + NSString *text = a.textFields.firstObject.text ?: @""; + id value = nil; + if (t != WQPTypeData && !WQPStringInputWithinLimit(text)) { + wqp_alert(self, @"Value too large", @"Scalar values are limited to 64 KB in this editor."); + return; + } + if (t == WQPTypeData && !WQPDataInputWithinLimit(text)) { + wqp_alert(self, @"Value too large", @"Hex input is limited to 16 KB in this editor."); + return; + } + switch (t) { + case WQPTypeString: value = text; break; + case WQPTypeInteger: { + NSScanner *sc = [NSScanner scannerWithString:text]; + long long n; + if ([sc scanLongLong:&n] && sc.isAtEnd) value = @(n); + break; + } + case WQPTypeReal: { + NSScanner *sc = [NSScanner scannerWithString:text]; + double d; + if ([sc scanDouble:&d] && sc.isAtEnd) value = @(d); + break; + } + case WQPTypeDate: value = [wqp_dates() dateFromString:text]; break; + case WQPTypeData: { + NSData *data = WQParseHex(text, YES); + if (data.length > WQPMaxDataBytes) { + wqp_alert(self, @"Value too large", @"Data values are limited to 4 KB in this editor."); + return; + } + value = data; + break; + } + default: break; + } + if (!value) { wqp_alert(self, @"Not a valid value", WQLocalizedFormat(@"Expected: %@", hint ?: wqp_type_name(t))); return; } + done(value); + })]; + [self presentViewController:a animated:YES completion:nil]; +} + +#pragma mark Adding, deleting, renaming, moving + +- (void)add +{ + if (self.doc.readOnly || self.doc.edit.saving) return; + UIAlertController *sheet = wq_alert_controller(@"Add", @"Type of the new value", UIAlertControllerStyleActionSheet); + for (WQPType t = WQPTypeString; t <= WQPTypeArray; t++) { + [sheet addAction:wq_alert_action(wqp_type_name(t), UIAlertActionStyleDefault, ^(UIAlertAction *__unused x) { + [self addValueOfType:t]; + })]; + } + [sheet addAction:wq_alert_action(@"Cancel", UIAlertActionStyleCancel, nil)]; + sheet.popoverPresentationController.barButtonItem = self.navigationItem.rightBarButtonItems.count > 1 + ? self.navigationItem.rightBarButtonItems[1] : self.navigationItem.rightBarButtonItems.firstObject; + [self presentViewController:sheet animated:YES completion:nil]; +} + +- (void)addValueOfType:(WQPType)t +{ + if (self.doc.readOnly || self.doc.edit.saving) return; + void (^insert)(NSString *key) = ^(NSString *key) { + if (self.doc.readOnly || self.doc.edit.saving) return; + id value = wqp_default(t); + void (^put)(id) = ^(id v) { + if (self.doc.readOnly || self.doc.edit.saving) return; + if (self.isArray) { + [self.container addObject:v]; + } else { + if (!key.length) return; // dictionary insertion always has a validated key + NSMutableDictionary *dictionary = self.container; + dictionary[key] = v; + } + [self changed]; + }; + if (t == WQPTypeDictionary || t == WQPTypeArray || t == WQPTypeBoolean) { put(value); return; } + [self editValueOfType:t current:value title:key ?: @"New Item" done:put]; + }; + if (self.isArray) { insert(nil); return; } + [self askKey:nil title:WQLocalizedNullable(@"Key") done:insert]; +} + +- (void)askKey:(NSString *)current title:(NSString *)title done:(void (^)(NSString *key))done +{ + if (self.doc.readOnly || self.doc.edit.saving) return; + UIAlertController *a = wq_alert_controller(title, nil, UIAlertControllerStyleAlert); + [a addTextFieldWithConfigurationHandler:^(UITextField *f) { + f.text = current; + f.placeholder = WQLocalizedNullable(@"Key"); + f.autocapitalizationType = UITextAutocapitalizationTypeNone; + f.autocorrectionType = UITextAutocorrectionTypeNo; + }]; + [a addAction:wq_alert_action(@"Cancel", UIAlertActionStyleCancel, nil)]; + [a addAction:wq_alert_action(@"OK", UIAlertActionStyleDefault, ^(UIAlertAction *__unused x) { + NSString *key = a.textFields.firstObject.text ?: @""; + if (key.length == 0) return; + if (![key isEqualToString:current] && self.container[key]) { + wqp_alert(self, @"Key exists", WQLocalizedFormat(@"There is already a key named \"%@\".", key)); + return; + } + done(key); + })]; + [self presentViewController:a animated:YES completion:nil]; +} + +- (UISwipeActionsConfiguration *)tableView:(UITableView *)tableView + trailingSwipeActionsConfigurationForRowAtIndexPath:(NSIndexPath *)indexPath +{ + if (self.doc.readOnly || self.doc.edit.saving) return nil; + NSInteger row = indexPath.row; + UIContextualAction *del = [UIContextualAction contextualActionWithStyle:UIContextualActionStyleDestructive title:WQLocalizedNullable(@"Delete") + handler:^(UIContextualAction *__unused action, UIView *__unused view, void (^completion)(BOOL)) { + if (self.doc.readOnly || self.doc.edit.saving) { completion(NO); return; } + if (self.isArray) [self.container removeObjectAtIndex:(NSUInteger)row]; + else [self.container removeObjectForKey:self.keys[(NSUInteger)row]]; + [self changed]; + completion(YES); + }]; + if (self.isArray) return [UISwipeActionsConfiguration configurationWithActions:@[del]]; + UIContextualAction *rename = [UIContextualAction contextualActionWithStyle:UIContextualActionStyleNormal title:WQLocalizedNullable(@"Rename") + handler:^(UIContextualAction *__unused action, UIView *__unused view, void (^completion)(BOOL)) { + if (self.doc.readOnly || self.doc.edit.saving) { completion(NO); return; } + NSString *old = self.keys[(NSUInteger)row]; + [self askKey:old title:WQLocalizedNullable(@"Rename Key") done:^(NSString *key) { + if (self.doc.readOnly || self.doc.edit.saving) return; + if ([key isEqualToString:old]) return; + id v = self.container[old]; + [self.container removeObjectForKey:old]; + self.container[key] = v; + [self changed]; + }]; + completion(YES); + }]; + return [UISwipeActionsConfiguration configurationWithActions:@[del, rename]]; +} + +- (BOOL)tableView:(UITableView *)tableView canMoveRowAtIndexPath:(NSIndexPath *)indexPath +{ + return self.isArray && !self.doc.readOnly && !self.doc.edit.saving; +} + +- (UITableViewCellEditingStyle)tableView:(UITableView *)tableView editingStyleForRowAtIndexPath:(NSIndexPath *)indexPath +{ + return UITableViewCellEditingStyleNone; +} + +- (BOOL)tableView:(UITableView *)tableView shouldIndentWhileEditingRowAtIndexPath:(NSIndexPath *)indexPath +{ + return NO; +} + +- (void)tableView:(UITableView *)tableView moveRowAtIndexPath:(NSIndexPath *)from toIndexPath:(NSIndexPath *)to +{ + if (self.doc.readOnly || self.doc.edit.saving) return; + id v = self.container[(NSUInteger)from.row]; + [self.container removeObjectAtIndex:(NSUInteger)from.row]; + [self.container insertObject:v atIndex:(NSUInteger)to.row]; + [self.doc.edit noteChange]; + dispatch_async(dispatch_get_main_queue(), ^{ [self refresh]; }); +} + +@end diff --git a/source/hook/src/WQPlistEditorSupport.h b/source/hook/src/WQPlistEditorSupport.h new file mode 100644 index 0000000..e92c611 --- /dev/null +++ b/source/hook/src/WQPlistEditorSupport.h @@ -0,0 +1,119 @@ +// +// WQPlistEditorSupport.h +// WQ Files +// +// Portable limits shared by the property-list editor and its regression tests. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +enum { + WQPMaxDisplayedStringCharacters = 512, + WQPMaxStringInputCharacters = 64 * 1024, + WQPMaxDataBytes = 4 * 1024, + WQPMaxDataInputCharacters = 16 * 1024, + WQPMaxSerializedBytes = 64 * 1024 * 1024, +}; + +static inline BOOL WQPStringInputWithinLimit(NSString *text) +{ + return text.length <= WQPMaxStringInputCharacters; +} + +static inline BOOL WQPDataInputWithinLimit(NSString *text) +{ + return text.length <= WQPMaxDataInputCharacters; +} + +static inline BOOL WQPSerializedDataWithinLimit(NSData *data) +{ + return data.length <= WQPMaxSerializedBytes; +} + +// CoreFoundation can write only XML and binary property lists. OpenStep and +// unknown formats are readable but must be treated as read-only documents. +static inline BOOL WQPlistFormatIsWritable(NSPropertyListFormat format) +{ + return format == NSPropertyListXMLFormat_v1_0 || format == NSPropertyListBinaryFormat_v1_0; +} + +static inline NSString *WQPlistFormatName(NSPropertyListFormat format) +{ + switch (format) { + case NSPropertyListXMLFormat_v1_0: return @"XML"; + case NSPropertyListBinaryFormat_v1_0: return @"Binary"; + case NSPropertyListOpenStepFormat: return @"OpenStep"; + default: return @"Unknown"; + } +} + +static inline NSString *WQPBoundedStringPreview(NSString *value) +{ + if (value.length == 0) return @"(empty)"; + if (value.length <= WQPMaxDisplayedStringCharacters) return value; + + NSString *suffix = [NSString stringWithFormat:@" … (%lu chars)", (unsigned long)value.length]; + NSUInteger prefixLength = suffix.length < WQPMaxDisplayedStringCharacters + ? WQPMaxDisplayedStringCharacters - suffix.length : 0; + return [[value substringToIndex:prefixLength] stringByAppendingString:suffix]; +} + +// NSKeyedArchiver stores object references as CFKeyedArchiverUID values. They +// are not NSNumber/NSString/NSData, and they do not implement -stringValue or +// NSCopying. They are nevertheless valid property-list leaves and must be +// preserved when the document is re-serialized. +static inline BOOL WQPlistIsKeyedArchiveUID(id value) +{ + if (!value) return NO; + NSString *className = NSStringFromClass([value class]); + if (![className isEqualToString:@"__NSCFType"] && ![className containsString:@"KeyedArchiverUID"]) + return NO; + NSString *description = [value description]; + return [description hasPrefix:@" + +NS_ASSUME_NONNULL_BEGIN + +@interface WQSQLiteViewController : UITableViewController +- (instancetype)initWithPath:(NSString *)path; +@end + +// Whether the first bytes of a file are an SQLite 3 header. +BOOL wq_is_sqlite_header(NSData *head); + +NS_ASSUME_NONNULL_END diff --git a/source/hook/src/WQSQLiteViewer.m b/source/hook/src/WQSQLiteViewer.m new file mode 100644 index 0000000..e21d688 --- /dev/null +++ b/source/hook/src/WQSQLiteViewer.m @@ -0,0 +1,636 @@ +// +// WQSQLiteViewer.m +// WQ Files +// +// Read-only SQLite viewer. The database and its -wal file are copied into a +// private temporary folder first (a -shm file is rebuilt by SQLite there), +// then opened with PRAGMA query_only. Every statement is checked with +// sqlite3_stmt_readonly before it runs. +// + +#import "WQSQLiteViewer.h" +#import "WQAccess.h" +#import "WQSharing.h" +#import "WQUI.h" +#import + +BOOL wq_is_sqlite_header(NSData *head) +{ + return head.length >= 16 && memcmp(head.bytes, "SQLite format 3\0", 16) == 0; +} + +static const NSUInteger kWQSQLitePage = 100; +static const NSUInteger kWQSQLiteQueryLimit = 1000; + +#pragma mark - Snapshot + +// A private copy of the database. All SQLite calls run on its serial queue. +@interface WQSQLiteSnapshot : NSObject +@property (nonatomic, readonly) dispatch_queue_t queue; +@property (nonatomic, readonly, nullable) sqlite3 *db; +@property (nonatomic, copy, readonly) NSString *sourcePath; +@property (nonatomic, readonly) BOOL copiedWAL; +@end + +@implementation WQSQLiteSnapshot { + NSString *_dir; +} + +// Streams `src` into `dst` through wq_read_range (which asks for access when +// needed). Returns NO with an error text on failure. +static BOOL wq_sqlite_copy(NSString *src, NSString *dst, NSString **error) +{ + if (![NSFileManager.defaultManager createFileAtPath:dst contents:nil attributes:nil]) { + *error = WQLocalized(@"cannot create the temporary copy"); + return NO; + } + NSFileHandle *out = [NSFileHandle fileHandleForWritingAtPath:dst]; + if (!out) { *error = WQLocalized(@"cannot write the temporary copy"); return NO; } + const NSUInteger chunk = 1024 * 1024; + unsigned long long offset = 0; + BOOL ok = YES; + @try { + for (;;) { + NSString *readError = nil; + NSData *data = wq_read_range(src, offset, chunk, &readError); + if (!data) { *error = readError ?: WQLocalized(@"read failed"); ok = NO; break; } + if (data.length) [out writeData:data]; + offset += data.length; + if (data.length < chunk) break; + } + } @catch (NSException *exception) { + *error = exception.reason ?: WQLocalized(@"write failed"); + ok = NO; + } + [out closeFile]; + return ok; +} + +- (nullable instancetype)initWithPath:(NSString *)path error:(NSString **)error +{ + if (!(self = [super init])) return nil; + _sourcePath = [path copy]; + _queue = dispatch_queue_create("wqfiles.sqlite", DISPATCH_QUEUE_SERIAL); + _dir = [NSTemporaryDirectory() stringByAppendingPathComponent: + [@"wq-sqlite-" stringByAppendingString:NSUUID.UUID.UUIDString]]; + if (![NSFileManager.defaultManager createDirectoryAtPath:_dir withIntermediateDirectories:YES attributes:nil error:nil]) { + *error = WQLocalized(@"cannot create a temporary folder"); + return nil; + } + NSString *copy = [_dir stringByAppendingPathComponent:@"db.sqlite"]; + if (!wq_sqlite_copy(path, copy, error)) return nil; + // Recent changes of WAL databases are still in the -wal file. The -shm + // index is not copied: SQLite rebuilds it next to the copy. + NSString *wal = [path stringByAppendingString:@"-wal"]; + struct stat st; + if (wq_stat(wal, &st) && S_ISREG(st.st_mode) && st.st_size > 0) { + NSString *walError = nil; + _copiedWAL = wq_sqlite_copy(wal, [copy stringByAppendingString:@"-wal"], &walError); + if (!_copiedWAL) wq_log(@"sqlite: -wal of %@ not copied (%@); recent changes may be missing", path, walError); + } + // Read-write on the private copy so SQLite can apply the WAL there; + // query_only and the per-statement check keep it read-only in practice. + sqlite3 *db = NULL; + int rc = sqlite3_open_v2(copy.fileSystemRepresentation, &db, SQLITE_OPEN_READWRITE | SQLITE_OPEN_NOMUTEX, NULL); + if (rc != SQLITE_OK) { + *error = db ? @(sqlite3_errmsg(db)) : WQLocalized(@"cannot open the database"); + if (db) sqlite3_close(db); + return nil; + } + sqlite3_exec(db, "PRAGMA query_only = ON", NULL, NULL, NULL); + // Fail early on files that only look like databases. + sqlite3_stmt *probe = NULL; + rc = sqlite3_prepare_v2(db, "SELECT count(*) FROM sqlite_master", -1, &probe, NULL); + if (rc == SQLITE_OK) rc = sqlite3_step(probe); + sqlite3_finalize(probe); + if (rc != SQLITE_ROW) { + *error = @(sqlite3_errmsg(db)); + sqlite3_close(db); + return nil; + } + _db = db; + return self; +} + +- (void)dealloc +{ + if (_db) sqlite3_close(_db); + if (_dir) [NSFileManager.defaultManager removeItemAtPath:_dir error:nil]; +} + +@end + +#pragma mark - Values + +static NSString *wq_sqlite_quote_identifier(NSString *name) +{ + return [NSString stringWithFormat:@"\"%@\"", [name stringByReplacingOccurrencesOfString:@"\"" withString:@"\"\""]]; +} + +static NSString *wq_sqlite_hex(const uint8_t *bytes, int length, int limit) +{ + NSMutableString *hex = [NSMutableString new]; + for (int i = 0; i < length && i < limit; i++) [hex appendFormat:@"%02x%s", bytes[i], (i % 4 == 3) ? " " : ""]; + if (length > limit) [hex appendString:@"…"]; + return hex; +} + +// A column value as display text; `full` shows long text and blobs in more detail. +static NSString *wq_sqlite_value(sqlite3_stmt *stmt, int column, BOOL full) +{ + switch (sqlite3_column_type(stmt, column)) { + case SQLITE_NULL: return WQLocalized(@"NULL"); + case SQLITE_INTEGER: return [NSString stringWithFormat:@"%lld", sqlite3_column_int64(stmt, column)]; + case SQLITE_FLOAT: return [NSString stringWithFormat:@"%.15g", sqlite3_column_double(stmt, column)]; + case SQLITE_BLOB: { + const uint8_t *bytes = sqlite3_column_blob(stmt, column); + int length = sqlite3_column_bytes(stmt, column); + if (length >= 8 && bytes && memcmp(bytes, "bplist0", 7) == 0) + return WQLocalizedFormat(@"", length); + NSString *hex = bytes ? wq_sqlite_hex(bytes, length, full ? 512 : 16) : @""; + return WQLocalizedFormat(@"<%d bytes> %@", length, hex); + } + default: { + const unsigned char *text = sqlite3_column_text(stmt, column); + NSString *s = text ? [NSString stringWithUTF8String:(const char *)text] : nil; + if (!s) return WQLocalized(@""); + if (!full && s.length > 200) s = [[s substringToIndex:200] stringByAppendingString:@"…"]; + return s; + } + } +} + +static NSString *wq_sqlite_csv_field(sqlite3_stmt *stmt, int column) +{ + if (sqlite3_column_type(stmt, column) == SQLITE_NULL) return @""; + if (sqlite3_column_type(stmt, column) == SQLITE_BLOB) { + const uint8_t *bytes = sqlite3_column_blob(stmt, column); + int length = sqlite3_column_bytes(stmt, column); + NSData *data = bytes ? [NSData dataWithBytes:bytes length:(NSUInteger)length] : [NSData data]; + return [@"base64:" stringByAppendingString:[data base64EncodedStringWithOptions:0]]; + } + NSString *value = wq_sqlite_value(stmt, column, YES); + if ([value rangeOfCharacterFromSet:[NSCharacterSet characterSetWithCharactersInString:@",\"\n\r"]].location == NSNotFound) + return value; + return [NSString stringWithFormat:@"\"%@\"", [value stringByReplacingOccurrencesOfString:@"\"" withString:@"\"\""]]; +} + +#pragma mark - Row detail + +@interface WQSQLiteRowViewController : UITableViewController +- (instancetype)initWithColumns:(NSArray *)columns values:(NSArray *)values title:(NSString *)title; +@end + +@implementation WQSQLiteRowViewController { + NSArray *_columns; + NSArray *_values; +} + +- (instancetype)initWithColumns:(NSArray *)columns values:(NSArray *)values title:(NSString *)title +{ + if ((self = [super initWithStyle:UITableViewStyleInsetGrouped])) { + _columns = [columns copy]; + _values = [values copy]; + self.title = title; + } + return self; +} + +- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView { return (NSInteger)_columns.count; } +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { return 1; } +- (NSString *)tableView:(UITableView *)tableView titleForHeaderInSection:(NSInteger)section { return _columns[(NSUInteger)section]; } + +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath +{ + UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"v"] + ?: [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"v"]; + UIListContentConfiguration *c = cell.defaultContentConfiguration; + c.text = _values[(NSUInteger)indexPath.section]; + c.textProperties.font = [UIFont monospacedSystemFontOfSize:14 weight:UIFontWeightRegular]; + c.textProperties.numberOfLines = 0; + cell.contentConfiguration = c; + cell.selectionStyle = UITableViewCellSelectionStyleNone; + return cell; +} + +- (UIContextMenuConfiguration *)tableView:(UITableView *)tableView + contextMenuConfigurationForRowAtIndexPath:(NSIndexPath *)indexPath point:(CGPoint)point +{ + NSString *value = _values[(NSUInteger)indexPath.section]; + return [UIContextMenuConfiguration configurationWithIdentifier:nil previewProvider:nil + actionProvider:^UIMenu *(NSArray *__unused suggested) { + return [UIMenu menuWithChildren:@[[UIAction actionWithTitle:WQLocalizedNullable(@"Copy") image:[UIImage systemImageNamed:@"doc.on.doc"] + identifier:nil handler:^(UIAction *__unused a) { + UIPasteboard.generalPasteboard.string = value; + }]]]; + }]; +} + +@end + +#pragma mark - Rows (a table, a view or a query) + +@interface WQSQLiteRowsViewController : UITableViewController +- (instancetype)initWithSnapshot:(WQSQLiteSnapshot *)snapshot sql:(NSString *)sql title:(NSString *)title + exportName:(nullable NSString *)exportName limit:(NSUInteger)limit; +@end + +@implementation WQSQLiteRowsViewController { + WQSQLiteSnapshot *_snapshot; + NSString *_sql; + NSString *_exportName; + NSUInteger _limit; // 0 = page through everything + NSArray *_columns; + NSMutableArray *> *_rows; + BOOL _loading, _done; + NSString *_error; +} + +- (instancetype)initWithSnapshot:(WQSQLiteSnapshot *)snapshot sql:(NSString *)sql title:(NSString *)title + exportName:(NSString *)exportName limit:(NSUInteger)limit +{ + if ((self = [super initWithStyle:UITableViewStylePlain])) { + _snapshot = snapshot; + _sql = [sql copy]; + _exportName = [exportName copy]; + _limit = limit; + _rows = [NSMutableArray new]; + self.title = title; + } + return self; +} + +- (void)viewDidLoad +{ + [super viewDidLoad]; + if (_exportName) { + self.navigationItem.rightBarButtonItem = [[UIBarButtonItem alloc] initWithTitle:WQLocalizedNullable(@"CSV") style:UIBarButtonItemStylePlain + target:self action:@selector(exportCSV:)]; + } + [self loadMore]; +} + +- (void)loadMore +{ + if (_loading || _done) return; + _loading = YES; + NSUInteger offset = _rows.count; + NSUInteger page = _limit ? _limit : kWQSQLitePage; + WQSQLiteSnapshot *snapshot = _snapshot; + // Tables page with LIMIT/OFFSET; a query (which may be a PRAGMA or EXPLAIN, + // and can't be wrapped) runs as typed and stops after `_limit` rows. + NSString *sql = _limit ? _sql : [NSString stringWithFormat:@"SELECT * FROM (%@) LIMIT %lu OFFSET %lu", + _sql, (unsigned long)page, (unsigned long)offset]; + dispatch_async(snapshot.queue, ^{ + NSMutableArray *columns = [NSMutableArray new]; + NSMutableArray *rows = [NSMutableArray new]; + NSString *error = nil; + sqlite3_stmt *stmt = NULL; + if (sqlite3_prepare_v2(snapshot.db, sql.UTF8String, -1, &stmt, NULL) != SQLITE_OK) { + error = @(sqlite3_errmsg(snapshot.db)); + } else if (!sqlite3_stmt_readonly(stmt)) { + error = WQLocalized(@"Only read-only statements can run here."); + } else { + int count = sqlite3_column_count(stmt); + for (int i = 0; i < count; i++) [columns addObject:@(sqlite3_column_name(stmt, i) ?: "?")]; + int rc = SQLITE_DONE; + while (rows.count < page && (rc = sqlite3_step(stmt)) == SQLITE_ROW) { + NSMutableArray *values = [NSMutableArray arrayWithCapacity:(NSUInteger)count]; + for (int i = 0; i < count; i++) [values addObject:wq_sqlite_value(stmt, i, YES)]; + [rows addObject:values]; + } + if (rows.count < page && rc != SQLITE_DONE) error = @(sqlite3_errmsg(snapshot.db)); + } + sqlite3_finalize(stmt); + dispatch_async(dispatch_get_main_queue(), ^{ + self->_loading = NO; + if (error) { self->_error = error; self->_done = YES; [self.tableView reloadData]; return; } + if (!self->_columns) self->_columns = columns; + [self->_rows addObjectsFromArray:rows]; + if (rows.count < page || self->_limit) self->_done = YES; + [self.tableView reloadData]; + }); + }); +} + +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section +{ + return (_error || (!_rows.count && _done)) ? 1 : (NSInteger)_rows.count; +} + +- (NSString *)tableView:(UITableView *)tableView titleForHeaderInSection:(NSInteger)section +{ + if (_error || !_columns) return nil; + NSString *more = _done ? @"" : @"+"; + if (_limit && _rows.count >= _limit) more = WQLocalizedFormat(@" (first %lu)", (unsigned long)_limit); + return WQLocalizedFormat(@"%lu%@ rows · %lu columns", (unsigned long)_rows.count, more, (unsigned long)_columns.count); +} + +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath +{ + UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"row"] + ?: [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleSubtitle reuseIdentifier:@"row"]; + UIListContentConfiguration *c = cell.defaultContentConfiguration; + if (_error || !_rows.count) { + c.text = _error ?: WQLocalized(@"No rows"); + c.textProperties.color = _error ? UIColor.systemRedColor : UIColor.secondaryLabelColor; + c.textProperties.numberOfLines = 0; + cell.contentConfiguration = c; + cell.accessoryType = UITableViewCellAccessoryNone; + return cell; + } + NSArray *values = _rows[(NSUInteger)indexPath.row]; + NSMutableArray *parts = [NSMutableArray new]; + for (NSUInteger i = 0; i < values.count && i < _columns.count; i++) { + NSString *v = values[i]; + if (v.length > 80) v = [[v substringToIndex:80] stringByAppendingString:@"…"]; + [parts addObject:[NSString stringWithFormat:@"%@: %@", _columns[i], v]]; + } + c.text = parts.firstObject; + c.secondaryText = parts.count > 1 ? [[parts subarrayWithRange:NSMakeRange(1, parts.count - 1)] componentsJoinedByString:@" · "] : nil; + c.textProperties.font = [UIFont monospacedSystemFontOfSize:14 weight:UIFontWeightRegular]; + c.secondaryTextProperties.numberOfLines = 2; + c.secondaryTextProperties.color = UIColor.secondaryLabelColor; + cell.contentConfiguration = c; + cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator; + if ((NSUInteger)indexPath.row + 20 >= _rows.count) [self loadMore]; + return cell; +} + +- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath +{ + [tableView deselectRowAtIndexPath:indexPath animated:YES]; + if (_error || (NSUInteger)indexPath.row >= _rows.count) return; + WQSQLiteRowViewController *detail = [[WQSQLiteRowViewController alloc] + initWithColumns:_columns values:_rows[(NSUInteger)indexPath.row] + title:WQLocalizedFormat(@"Row %ld", (long)indexPath.row + 1)]; + [self.navigationController pushViewController:detail animated:YES]; +} + +- (void)exportCSV:(UIBarButtonItem *)sender +{ + WQSQLiteSnapshot *snapshot = _snapshot; + NSString *sql = _sql; + NSString *name = [[_exportName stringByReplacingOccurrencesOfString:@"/" withString:@"_"] stringByAppendingPathExtension:@"csv"]; + NSString *dir = [NSTemporaryDirectory() stringByAppendingPathComponent:@"wq-sqlite-export"]; + [NSFileManager.defaultManager createDirectoryAtPath:dir withIntermediateDirectories:YES attributes:nil error:nil]; + NSString *out = [dir stringByAppendingPathComponent:name]; + sender.enabled = NO; + dispatch_async(snapshot.queue, ^{ + NSString *error = nil; + [NSFileManager.defaultManager createFileAtPath:out contents:nil attributes:nil]; + NSFileHandle *file = [NSFileHandle fileHandleForWritingAtPath:out]; + sqlite3_stmt *stmt = NULL; + if (!file) error = WQLocalized(@"cannot create the CSV file"); + else if (sqlite3_prepare_v2(snapshot.db, sql.UTF8String, -1, &stmt, NULL) != SQLITE_OK) error = @(sqlite3_errmsg(snapshot.db)); + else if (!sqlite3_stmt_readonly(stmt)) error = WQLocalized(@"Only read-only statements can run here."); + else { + int count = sqlite3_column_count(stmt); + NSMutableArray *header = [NSMutableArray new]; + for (int i = 0; i < count; i++) [header addObject:@(sqlite3_column_name(stmt, i) ?: "?")]; + [file writeData:[[[header componentsJoinedByString:@","] stringByAppendingString:@"\r\n"] dataUsingEncoding:NSUTF8StringEncoding]]; + while (sqlite3_step(stmt) == SQLITE_ROW) { + @autoreleasepool { + NSMutableArray *fields = [NSMutableArray arrayWithCapacity:(NSUInteger)count]; + for (int i = 0; i < count; i++) [fields addObject:wq_sqlite_csv_field(stmt, i)]; + [file writeData:[[[fields componentsJoinedByString:@","] stringByAppendingString:@"\r\n"] dataUsingEncoding:NSUTF8StringEncoding]]; + } + } + } + sqlite3_finalize(stmt); + [file closeFile]; + dispatch_async(dispatch_get_main_queue(), ^{ + sender.enabled = YES; + if (error) { wq_alert(self, @"Cannot export", error); return; } + wq_share(self, out, nil); + }); + }); +} + +@end + +#pragma mark - Query + +@interface WQSQLiteQueryViewController : UIViewController +- (instancetype)initWithSnapshot:(WQSQLiteSnapshot *)snapshot; +@end + +@implementation WQSQLiteQueryViewController { + WQSQLiteSnapshot *_snapshot; + UITextView *_textView; +} + +- (instancetype)initWithSnapshot:(WQSQLiteSnapshot *)snapshot +{ + if ((self = [super initWithNibName:nil bundle:nil])) { + _snapshot = snapshot; + self.title = WQLocalizedNullable(@"SQL"); + } + return self; +} + +- (void)viewDidLoad +{ + [super viewDidLoad]; + self.view.backgroundColor = UIColor.systemBackgroundColor; + _textView = [UITextView new]; + _textView.translatesAutoresizingMaskIntoConstraints = NO; + _textView.font = [UIFont monospacedSystemFontOfSize:15 weight:UIFontWeightRegular]; + _textView.semanticContentAttribute = UISemanticContentAttributeForceLeftToRight; // SQL stays left to right + _textView.textAlignment = NSTextAlignmentLeft; + _textView.autocapitalizationType = UITextAutocapitalizationTypeNone; + _textView.autocorrectionType = UITextAutocorrectionTypeNo; + _textView.smartQuotesType = UITextSmartQuotesTypeNo; + _textView.smartDashesType = UITextSmartDashesTypeNo; + _textView.text = @"SELECT name, type FROM sqlite_master"; + [self.view addSubview:_textView]; + UILayoutGuide *g = self.view.safeAreaLayoutGuide; + [NSLayoutConstraint activateConstraints:@[ + [_textView.topAnchor constraintEqualToAnchor:g.topAnchor], + [_textView.bottomAnchor constraintEqualToAnchor:self.view.keyboardLayoutGuide.topAnchor], + [_textView.leadingAnchor constraintEqualToAnchor:g.leadingAnchor constant:8], + [_textView.trailingAnchor constraintEqualToAnchor:g.trailingAnchor constant:-8], + ]]; + self.navigationItem.rightBarButtonItem = [[UIBarButtonItem alloc] initWithTitle:WQLocalizedNullable(@"Run") style:UIBarButtonItemStyleProminent + target:self action:@selector(run)]; + [_textView becomeFirstResponder]; +} + +- (void)run +{ + NSString *sql = [_textView.text stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet]; + while ([sql hasSuffix:@";"]) sql = [[sql substringToIndex:sql.length - 1] stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet]; + if (!sql.length) return; + // Only a single read-only statement: checked before anything runs. + WQSQLiteSnapshot *snapshot = _snapshot; + __block NSString *error = nil; + dispatch_sync(snapshot.queue, ^{ + sqlite3_stmt *stmt = NULL; + const char *tail = NULL; + if (sqlite3_prepare_v2(snapshot.db, sql.UTF8String, -1, &stmt, &tail) != SQLITE_OK) error = @(sqlite3_errmsg(snapshot.db)); + else if (!stmt) error = WQLocalized(@"Nothing to run."); + else if (!sqlite3_stmt_readonly(stmt)) error = WQLocalized(@"Only read-only statements (SELECT, read-only PRAGMA, EXPLAIN) can run here."); + else if (tail && *tail) { + NSString *rest = [@(tail) stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet]; + if (rest.length) error = WQLocalized(@"Run one statement at a time."); + } + else if (sqlite3_column_count(stmt) == 0) error = WQLocalized(@"This statement returns no rows."); + sqlite3_finalize(stmt); + }); + if (error) { wq_alert(self, @"Cannot run", error); return; } + WQSQLiteRowsViewController *rows = [[WQSQLiteRowsViewController alloc] initWithSnapshot:snapshot sql:sql title:WQLocalizedNullable(@"Result") + exportName:@"query" limit:kWQSQLiteQueryLimit]; + [self.navigationController pushViewController:rows animated:YES]; +} + +@end + +#pragma mark - Tables + +@implementation WQSQLiteViewController { + NSString *_path; + WQSQLiteSnapshot *_snapshot; + NSArray *_tables; // name, type, rows (NSNumber, filled in later) + NSMutableDictionary *_counts; + NSString *_error; + BOOL _loading; +} + +- (instancetype)initWithPath:(NSString *)path +{ + if ((self = [super initWithStyle:UITableViewStyleInsetGrouped])) { + _path = [path copy]; + _counts = [NSMutableDictionary new]; + _loading = YES; + self.title = path.lastPathComponent; + } + return self; +} + +- (void)viewDidLoad +{ + [super viewDidLoad]; + self.navigationItem.rightBarButtonItem = [[UIBarButtonItem alloc] initWithTitle:WQLocalizedNullable(@"SQL") style:UIBarButtonItemStylePlain + target:self action:@selector(openQuery)]; + self.navigationItem.rightBarButtonItem.enabled = NO; + NSString *path = _path; + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + NSString *error = nil; + WQSQLiteSnapshot *snapshot = [[WQSQLiteSnapshot alloc] initWithPath:path error:&error]; + NSMutableArray *tables = [NSMutableArray new]; + if (snapshot) { + dispatch_sync(snapshot.queue, ^{ + sqlite3_stmt *stmt = NULL; + if (sqlite3_prepare_v2(snapshot.db, "SELECT name, type FROM sqlite_master WHERE type IN ('table','view') ORDER BY type, name", + -1, &stmt, NULL) == SQLITE_OK) { + while (sqlite3_step(stmt) == SQLITE_ROW) { + const unsigned char *name = sqlite3_column_text(stmt, 0), *type = sqlite3_column_text(stmt, 1); + if (name && type) [tables addObject:@{ @"name": @((const char *)name), @"type": @((const char *)type) }]; + } + } + sqlite3_finalize(stmt); + }); + } + if (!snapshot) wq_log(@"sqlite: cannot open %@ (%@)", path, error); + dispatch_async(dispatch_get_main_queue(), ^{ + self->_loading = NO; + self->_snapshot = snapshot; + self->_tables = tables; + self->_error = snapshot ? nil : error; + self.navigationItem.rightBarButtonItem.enabled = snapshot != nil; + [self.tableView reloadData]; + if (snapshot) [self countRows]; + }); + }); +} + +// Row counts for tables, one at a time in the background (big tables take a while). +- (void)countRows +{ + WQSQLiteSnapshot *snapshot = _snapshot; + NSArray *tables = _tables; + __weak typeof(self) weakSelf = self; + dispatch_async(snapshot.queue, ^{ + for (NSDictionary *table in tables) { + if (!weakSelf) return; + if (![table[@"type"] isEqualToString:@"table"]) continue; + NSString *sql = [@"SELECT count(*) FROM " stringByAppendingString:wq_sqlite_quote_identifier(table[@"name"])]; + sqlite3_stmt *stmt = NULL; + long long count = -1; + if (sqlite3_prepare_v2(snapshot.db, sql.UTF8String, -1, &stmt, NULL) == SQLITE_OK && sqlite3_step(stmt) == SQLITE_ROW) + count = sqlite3_column_int64(stmt, 0); + sqlite3_finalize(stmt); + NSString *name = table[@"name"]; + dispatch_async(dispatch_get_main_queue(), ^{ + WQSQLiteViewController *strongSelf = weakSelf; + if (!strongSelf || count < 0) return; + strongSelf->_counts[name] = @(count); + [strongSelf.tableView reloadData]; + }); + } + }); +} + +- (void)openQuery +{ + if (!_snapshot) return; + [self.navigationController pushViewController:[[WQSQLiteQueryViewController alloc] initWithSnapshot:_snapshot] animated:YES]; +} + +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section +{ + return (_loading || _error || !_tables.count) ? 1 : (NSInteger)_tables.count; +} + +- (NSString *)tableView:(UITableView *)tableView titleForHeaderInSection:(NSInteger)section +{ + return _tables.count ? WQLocalized(@"Tables and views") : nil; +} + +- (NSString *)tableView:(UITableView *)tableView titleForFooterInSection:(NSInteger)section +{ + if (!_snapshot) return nil; + NSString *wal = _snapshot.copiedWAL ? WQLocalized(@" and its -wal file") : @""; + return WQLocalizedFormat(@"Read-only. This is a copy of the database%@ made when it was opened; the app's own file is not changed. Close the app first for an up-to-date copy.", wal); +} + +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath +{ + UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"t"] + ?: [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleValue1 reuseIdentifier:@"t"]; + UIListContentConfiguration *c = [UIListContentConfiguration valueCellConfiguration]; + cell.accessoryType = UITableViewCellAccessoryNone; + if (_loading) { + c.text = WQLocalizedNullable(@"Copying and opening…"); + } else if (_error) { + c.text = [WQLocalized(@"Cannot open this database: ") stringByAppendingString:_error]; + c.textProperties.color = UIColor.systemRedColor; + c.textProperties.numberOfLines = 0; + } else if (!_tables.count) { + c.text = WQLocalizedNullable(@"No tables"); + c.textProperties.color = UIColor.secondaryLabelColor; + } else { + NSDictionary *table = _tables[(NSUInteger)indexPath.row]; + BOOL view = [table[@"type"] isEqualToString:@"view"]; + c.text = table[@"name"]; + c.image = [UIImage systemImageNamed:view ? @"eye" : @"tablecells"]; + NSNumber *count = _counts[table[@"name"]]; + c.secondaryText = view ? WQLocalized(@"view") : (count ? WQLocalizedFormat(@"%@ rows", count) : @"…"); + cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator; + } + cell.contentConfiguration = c; + return cell; +} + +- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath +{ + [tableView deselectRowAtIndexPath:indexPath animated:YES]; + if (!_snapshot || (NSUInteger)indexPath.row >= _tables.count) return; + NSString *name = _tables[(NSUInteger)indexPath.row][@"name"]; + NSString *sql = [@"SELECT * FROM " stringByAppendingString:wq_sqlite_quote_identifier(name)]; + WQSQLiteRowsViewController *rows = [[WQSQLiteRowsViewController alloc] initWithSnapshot:_snapshot sql:sql title:name + exportName:name limit:0]; + [self.navigationController pushViewController:rows animated:YES]; +} + +@end diff --git a/source/hook/src/WQSharing.h b/source/hook/src/WQSharing.h new file mode 100644 index 0000000..8047252 --- /dev/null +++ b/source/hook/src/WQSharing.h @@ -0,0 +1,14 @@ +// +// WQSharing.h +// WQ Files temporary exports and share sheets +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +void wq_share(UIViewController *host, NSString *path, UIView *_Nullable source); +void wq_share_folder(UIViewController *host, NSString *dir, NSString *_Nullable zipName, UIView *_Nullable source); +void wq_share_cancel(UIViewController *host); + +NS_ASSUME_NONNULL_END diff --git a/source/hook/src/WQSharing.m b/source/hook/src/WQSharing.m new file mode 100644 index 0000000..5769a05 --- /dev/null +++ b/source/hook/src/WQSharing.m @@ -0,0 +1,239 @@ +// +// WQSharing.m +// WQ Files temporary exports and share sheets +// GPL-3.0-or-later. +// + +#import "WQSharing.h" +#import "WQUI.h" +#import "WQAccess.h" +#import +#import + +@interface WQSharePreparation : NSObject +@property (nonatomic, weak) UIViewController *host; +@property (nonatomic, weak) UIAlertController *hud; +@property (nonatomic, copy) NSString *exportDir; +@property (atomic, getter=isCancelled) BOOL cancelled; +@end + +@implementation WQSharePreparation +@end + +static char kWQSharePreparationKey; +static char kWQShareInFlightKey; + +static NSString *wq_unique_export_directory(void) +{ + NSString *dir = [NSTemporaryDirectory() stringByAppendingPathComponent: + [@"WQShare-" stringByAppendingString:NSUUID.UUID.UUIDString]]; + NSError *error = nil; + return [NSFileManager.defaultManager createDirectoryAtPath:dir withIntermediateDirectories:YES + attributes:nil error:&error] ? dir : nil; +} + +static void wq_remove_export(NSString *exportDir) +{ + if (exportDir.length) [NSFileManager.defaultManager removeItemAtPath:exportDir error:nil]; +} + +static BOOL wq_share_host_is_active(UIViewController *host) +{ + if (!host || !host.isViewLoaded || !host.view.window) return NO; + UIWindowScene *scene = host.view.window.windowScene; + if (scene && scene.activationState != UISceneActivationStateForegroundActive) return NO; + UINavigationController *nav = host.navigationController; + if (nav && nav.topViewController != host) return NO; + if (host.presentedViewController && !host.presentedViewController.isBeingDismissed) return NO; + return YES; +} + +static BOOL wq_shareable_regular_file(NSString *path) +{ + if (!path.length) return NO; + struct stat st; + // Do not follow a final symlink: wq_copy_to_local uses O_NOFOLLOW too. + if (lstat(path.fileSystemRepresentation, &st) == 0) return S_ISREG(st.st_mode); + if (wq_grant(path) && lstat(path.fileSystemRepresentation, &st) == 0) return S_ISREG(st.st_mode); + return NO; +} + +static void wq_clear_preparation(UIViewController *host, WQSharePreparation *preparation) +{ + if (!host) return; + if (objc_getAssociatedObject(host, &kWQSharePreparationKey) == preparation) { + objc_setAssociatedObject(host, &kWQSharePreparationKey, nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + } +} + +static BOOL wq_share_in_flight(UIViewController *host) +{ + return host && (objc_getAssociatedObject(host, &kWQSharePreparationKey) || + objc_getAssociatedObject(host, &kWQShareInFlightKey)); +} + +static void wq_clear_in_flight(UIViewController *host) +{ + if (!host) return; + objc_setAssociatedObject(host, &kWQShareInFlightKey, nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC); +} + +static BOOL wq_present_share(UIViewController *host, NSString *file, NSString *exportDir, UIView *source) +{ + if (!wq_share_host_is_active(host)) { + wq_remove_export(exportDir); + return NO; + } + UIActivityViewController *share = [[UIActivityViewController alloc] + initWithActivityItems:@[[NSURL fileURLWithPath:file]] applicationActivities:nil]; + UIView *anchor = source.window ? source : host.view; + share.popoverPresentationController.sourceView = anchor; + share.popoverPresentationController.sourceRect = anchor.bounds; + share.completionWithItemsHandler = ^(UIActivityType __unused activityType, BOOL __unused completed, + NSArray *__unused returnedItems, NSError *__unused activityError) { + wq_remove_export(exportDir); + }; + [host presentViewController:share animated:YES completion:^{ + // A presentation rejected after the visibility check must not strand the export. + if (!share.presentingViewController) wq_remove_export(exportDir); + }]; + return YES; +} + +// Cancel folder preparation when its browser is popped or otherwise abandoned. +// The export is removed by the worker's completion, after any in-progress copy has stopped. +void wq_share_cancel(UIViewController *host) +{ + if (!host) return; + wq_clear_in_flight(host); + WQSharePreparation *preparation = objc_getAssociatedObject(host, &kWQSharePreparationKey); + preparation.cancelled = YES; + if (host) objc_setAssociatedObject(host, &kWQSharePreparationKey, nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + UIAlertController *hud = preparation.hud; + if (hud && host.presentedViewController == hud) { + [host dismissViewControllerAnimated:NO completion:nil]; + } +} + +// Copies a file into a unique temporary directory so concurrent shares cannot +// overwrite one another. The export is removed when the share sheet finishes. +void wq_share(UIViewController *host, NSString *path, UIView *source) +{ + if (!host) return; + if (wq_share_in_flight(host)) { + wq_alert(host, @"Share in progress", @"Wait for the current share to finish before starting another."); + return; + } + objc_setAssociatedObject(host, &kWQShareInFlightKey, @YES, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + __weak UIViewController *weakHost = host; + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + NSString *error = nil; + NSString *outDir = nil; + NSString *tmp = nil; + BOOL ok = NO; + if (!wq_shareable_regular_file(path)) { + error = @"Only regular files can be shared."; + } else { + outDir = wq_unique_export_directory(); + tmp = outDir ? [outDir stringByAppendingPathComponent:path.lastPathComponent] : nil; + ok = tmp && wq_copy_to_local(path, tmp, &error); + } + dispatch_async(dispatch_get_main_queue(), ^{ + UIViewController *strongHost = weakHost; + if (!ok) { + wq_remove_export(outDir); + wq_clear_in_flight(strongHost); + if (wq_share_host_is_active(strongHost)) { + wq_alert(strongHost, @"Cannot share", error ?: @"Could not create a temporary export folder"); + } + return; + } + if (!wq_share_host_is_active(strongHost) || + !wq_present_share(strongHost, tmp, outDir, source)) { + wq_remove_export(outDir); + } + wq_clear_in_flight(strongHost); + }); + }); +} + +static NSString *wq_zip_name(NSString *dir); + +// Zips a folder (the system's own zipper via NSFileCoordinator) and opens the +// share sheet, so a backup can go out over AirDrop, Files, Mail… +void wq_share_folder(UIViewController *host, NSString *dir, NSString *zipName, UIView *source) +{ + if (!wq_share_host_is_active(host)) return; + if (wq_share_in_flight(host)) { + wq_alert(host, @"Share in progress", @"Wait for the current share to finish before starting another."); + return; + } + WQSharePreparation *preparation = [WQSharePreparation new]; + preparation.host = host; + UIAlertController *hud = wq_alert_controller(@"Preparing…", @"Compressing the folder", UIAlertControllerStyleAlert); + [hud addAction:wq_alert_action(@"Cancel", UIAlertActionStyleCancel, ^(UIAlertAction *__unused action) { + preparation.cancelled = YES; + objc_setAssociatedObject(host, &kWQSharePreparationKey, nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + [host dismissViewControllerAnimated:YES completion:nil]; + })]; + preparation.hud = hud; + objc_setAssociatedObject(host, &kWQSharePreparationKey, preparation, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + [host presentViewController:hud animated:YES completion:nil]; + dispatch_async(wq_io(), ^{ + if (preparation.isCancelled) return; + NSString *resolvedName = zipName ?: wq_zip_name(dir); + NSString *safe = [[resolvedName componentsSeparatedByCharactersInSet: + [NSCharacterSet characterSetWithCharactersInString:@"/:"]] componentsJoinedByString:@"-"]; + NSString *outDir = wq_unique_export_directory(); + preparation.exportDir = outDir; + NSString *zip = outDir ? [outDir stringByAppendingPathComponent:[safe stringByAppendingPathExtension:@"zip"]] : nil; + NSFileManager *fm = NSFileManager.defaultManager; + wq_grant(dir); + __block NSError *copyError = nil; + NSError *coordError = nil; + if (zip && !preparation.isCancelled) { + [[NSFileCoordinator new] coordinateReadingItemAtURL:[NSURL fileURLWithPath:dir] + options:NSFileCoordinatorReadingForUploading + error:&coordError byAccessor:^(NSURL *zipped) { + if (!preparation.isCancelled) [fm copyItemAtURL:zipped toURL:[NSURL fileURLWithPath:zip] error:©Error]; + }]; + } + BOOL ok = zip && !preparation.isCancelled && [fm fileExistsAtPath:zip]; + NSString *why = (coordError ?: copyError).localizedDescription ?: @"Could not create the ZIP"; + wq_log(@"share zip: %@ -> %@ %@", dir, zip.lastPathComponent, ok ? @"OK" : why); + dispatch_async(dispatch_get_main_queue(), ^{ + UIViewController *strongHost = preparation.host; + BOOL hudPresented = strongHost && strongHost.presentedViewController == hud; + void (^finish)(void) = ^{ + BOOL stale = preparation.isCancelled || !wq_share_host_is_active(strongHost); + wq_clear_preparation(strongHost, preparation); + if (!ok || stale) { + wq_remove_export(outDir); + if (!stale && wq_share_host_is_active(strongHost)) { + wq_alert(strongHost, @"Cannot share", why); + } + return; + } + if (!wq_present_share(strongHost, zip, outDir, source)) wq_remove_export(outDir); + }; + if (hudPresented) [strongHost dismissViewControllerAnimated:YES completion:finish]; + else finish(); + }); + }); +} + +// "Santander 2026-11-09 14.02.11" for AppBackups//, else the folder name. +static NSString *wq_zip_name(NSString *dir) +{ + NSArray *parts = dir.pathComponents; + NSUInteger i = [parts indexOfObject:@"AppBackups"]; + if (i != NSNotFound && i + 2 == parts.count - 1) { + NSString *bid = parts[i + 1]; + return [NSString stringWithFormat:@"%@ %@", wq_display_name(bid) ?: bid, parts.lastObject]; + } + if (i != NSNotFound && i + 1 == parts.count - 1) { + NSString *bid = parts.lastObject; + return [NSString stringWithFormat:@"%@ backups", wq_display_name(bid) ?: bid]; + } + return dir.lastPathComponent; +} diff --git a/source/hook/src/WQSyntax.h b/source/hook/src/WQSyntax.h new file mode 100644 index 0000000..f8e0d38 --- /dev/null +++ b/source/hook/src/WQSyntax.h @@ -0,0 +1,42 @@ +// +// WQSyntax.h +// WQ Files +// +// Small syntax highlighter for the text viewer: one regular expression per +// language finds comments, strings, numbers, keywords and tags left to right +// (so a "//" inside a string stays part of the string). Foundation only; the +// viewer maps the token kinds to colors. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +typedef NS_ENUM(uint8_t, WQSyntaxKind) { + WQSyntaxComment = 1, + WQSyntaxString, + WQSyntaxKey, // JSON keys, CSS properties, INI/YAML keys + WQSyntaxKeyword, + WQSyntaxNumber, + WQSyntaxTag, // XML/HTML tag names, CSS selectors + WQSyntaxAttribute, // XML attributes + WQSyntaxPreprocessor, // #include, @media, shebang +}; + +typedef struct { + NSRange range; + WQSyntaxKind kind; +} WQSyntaxToken; + +// Larger texts are shown plain (matching would take too long). +extern const NSUInteger kWQSyntaxMaxLength; + +// The language for a file, from its name (and for files without an extension, +// a "#!" first line), or nil for plain text. +NSString *_Nullable wq_syntax_language(NSString *fileName, NSString *_Nullable text); + +// Tokens (WQSyntaxToken structs, in text order) for text in language, or nil +// when the language is unknown, the text is too long, or cancelled() says so. +NSData *_Nullable wq_syntax_tokens(NSString *text, NSString *language, BOOL (^_Nullable cancelled)(void)); + +NS_ASSUME_NONNULL_END diff --git a/source/hook/src/WQSyntax.m b/source/hook/src/WQSyntax.m new file mode 100644 index 0000000..6ed406b --- /dev/null +++ b/source/hook/src/WQSyntax.m @@ -0,0 +1,295 @@ +// +// WQSyntax.m +// WQ Files +// + +#import "WQSyntax.h" + +const NSUInteger kWQSyntaxMaxLength = 1024 * 1024; + +// Each rule is @[kind, pattern], the pattern with non-capturing groups only; +// the combined expression wraps every rule in one capture group, so the group +// that matched gives the token kind. Earlier rules win where two could start +// at one place. + +static NSString *const kDQ = @"\"(?:[^\"\\\\\\n]|\\\\.)*\""; // "…" on one line, with escapes +static NSString *const kSQ = @"'(?:[^'\\\\\\n]|\\\\.)*'"; +static NSString *const kBQ = @"`(?:[^`\\\\]|\\\\.)*`"; +static NSString *const kNumber = @"\\b(?:0[xX][0-9a-fA-F_]+|0[bB][01_]+|\\d[\\d_]*(?:\\.\\d+)?(?:[eE][+-]?\\d+)?)[uUlLfF]*\\b"; +static NSString *const kSlashComments = @"//[^\\n]*|/\\*[\\s\\S]*?(?:\\*/|$(?![\\s\\S]))"; +static NSString *const kHashComment = @"#[^\\n]*"; + +static NSString *wq_words(NSString *words) +{ + NSArray *list = [words componentsSeparatedByCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet]; + list = [list filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"length > 0"]]; + return [NSString stringWithFormat:@"\\b(?:%@)\\b", [list componentsJoinedByString:@"|"]]; +} + +static NSString *const kCKeywords = + @"auto break case char const continue default do double else enum extern float for goto if inline int long " + "register restrict return short signed sizeof static struct switch typedef union unsigned void volatile while " + "bool true false NULL nullptr class namespace template typename public private protected virtual override " + "new delete this try catch throw using operator const_cast static_cast dynamic_cast reinterpret_cast " + "id self super nil Nil YES NO BOOL SEL IMP instancetype nonatomic atomic strong weak copy assign readonly readwrite " + "nullable nonnull _Nullable _Nonnull __weak __strong __block"; +static NSString *const kObjCDirectives = @"@(?:interface|implementation|end|protocol|property|synthesize|dynamic|class|" + "selector|encode|optional|required|import|autoreleasepool|synchronized|try|catch|finally|throw|available)\\b"; +static NSString *const kJSKeywords = + @"async await break case catch class const continue debugger default delete do else export extends false finally " + "for function if import in instanceof let new null of return static super switch this throw true try typeof " + "undefined var void while with yield interface type enum implements readonly private public protected " + "abstract declare namespace as any number string boolean never unknown"; +static NSString *const kSwiftKeywords = + @"associatedtype class deinit enum extension fileprivate func import init inout internal let open operator private " + "protocol public rethrows static struct subscript typealias var break case continue default defer do else " + "fallthrough for guard if in repeat return switch where while as catch false is nil super self Self throw " + "throws true try async await some any final lazy override mutating weak unowned convenience required"; +static NSString *const kJavaKeywords = + @"abstract assert boolean break byte case catch char class const continue default do double else enum extends " + "final finally float for goto if implements import instanceof int interface long native new package private " + "protected public return short static strictfp super switch synchronized this throw throws transient try void " + "volatile while true false null var val fun object when is in override data sealed companion suspend"; +static NSString *const kGoKeywords = + @"break case chan const continue default defer else fallthrough for func go goto if import interface map " + "package range return select struct switch type var true false nil iota"; +static NSString *const kRustKeywords = + @"as async await break const continue crate dyn else enum extern false fn for if impl in let loop match mod move " + "mut pub ref return self Self static struct super trait true type unsafe use where while"; +static NSString *const kPythonKeywords = + @"and as assert async await break class continue def del elif else except False finally for from global if " + "import in is lambda None nonlocal not or pass raise return True try while with yield self"; +static NSString *const kShellKeywords = + @"if then else elif fi case esac for select while until do done in function return exit export local readonly " + "declare unset shift source alias echo printf cd test set eval exec trap break continue true false"; +static NSString *const kRubyKeywords = + @"alias and begin break case class def defined do else elsif end ensure false for if in module next nil not or " + "redo rescue retry return self super then true undef unless until when while yield require attr_accessor"; +static NSString *const kSQLKeywords = + @"select from where and or not insert into values update set delete create table index view trigger drop alter " + "add column primary key foreign references unique null default join left right inner outer full cross on as " + "order by group having limit offset distinct union all exists in is like glob between case when then else end " + "begin commit rollback transaction pragma integer text real blob numeric asc desc if replace without rowid " + "autoincrement check constraint collate cast"; +static NSString *const kLuaKeywords = + @"and break do else elseif end false for function goto if in local nil not or repeat return then true until while"; + +// Rules per language family: @[@[kind, pattern], …] and regex options. +static NSArray *wq_language_rules(NSString *language, NSRegularExpressionOptions *options) +{ + *options = NSRegularExpressionAnchorsMatchLines; + if ([language isEqualToString:@"xml"]) { + return @[ + @[@(WQSyntaxComment), @"|$(?![\\s\\S]))"], + @[@(WQSyntaxString), @""], + @[@(WQSyntaxPreprocessor), @"<[?!][^>]*>"], + @[@(WQSyntaxTag), @""], + @[@(WQSyntaxAttribute), @"\\b[A-Za-z_][\\w:.-]*(?=\\s*=\\s*[\"'])"], + @[@(WQSyntaxString), @"\"[^\"<]*\"|'[^'<]*'"], + @[@(WQSyntaxKeyword), @"&#?\\w+;"], + ]; + } + if ([language isEqualToString:@"json"]) { + return @[ + @[@(WQSyntaxComment), kSlashComments], // JSON5 / JSONC + @[@(WQSyntaxKey), [kDQ stringByAppendingString:@"(?=\\s*:)"]], + @[@(WQSyntaxString), kDQ], + @[@(WQSyntaxKeyword), @"\\b(?:true|false|null)\\b"], + @[@(WQSyntaxNumber), @"-?\\b\\d+(?:\\.\\d+)?(?:[eE][+-]?\\d+)?\\b"], + ]; + } + if ([language isEqualToString:@"css"]) { + return @[ + @[@(WQSyntaxComment), @"/\\*[\\s\\S]*?(?:\\*/|$(?![\\s\\S]))"], + @[@(WQSyntaxString), [NSString stringWithFormat:@"%@|%@", kDQ, kSQ]], + @[@(WQSyntaxPreprocessor), @"@[\\w-]+"], + @[@(WQSyntaxKey), @"(?[^\\n]*"], + @[@(WQSyntaxNumber), @"^\\s*(?:[-*+]|\\d+\\.)(?=\\s)"], + ]; + } + // Hash-comment languages. + NSDictionary *hash = @{ @"python": kPythonKeywords, @"shell": kShellKeywords, @"ruby": kRubyKeywords }; + if (hash[language]) { + NSString *strings = [language isEqualToString:@"python"] + ? [NSString stringWithFormat:@"(?:[rRbBfFuU]{0,2})(?:\"\"\"[\\s\\S]*?(?:\"\"\"|$(?![\\s\\S]))|'''[\\s\\S]*?(?:'''|$(?![\\s\\S]))|%@|%@)", kDQ, kSQ] + : [NSString stringWithFormat:@"%@|%@|%@", kDQ, kSQ, kBQ]; + return @[ + @[@(WQSyntaxPreprocessor), @"\\A#![^\\n]*"], + @[@(WQSyntaxComment), [language isEqualToString:@"shell"] ? @"(?:^|(?<=[\\s;]))#[^\\n]*" : kHashComment], + @[@(WQSyntaxString), strings], + @[@(WQSyntaxPreprocessor), [language isEqualToString:@"python"] ? @"^\\s*@[\\w.]+" : @"\\$\\{[^}\\n]*\\}|\\$[\\w@#?$!*-]"], + @[@(WQSyntaxKeyword), wq_words(hash[language])], + @[@(WQSyntaxNumber), kNumber], + ]; + } + if ([language isEqualToString:@"lua"]) { + return @[ + @[@(WQSyntaxComment), @"--\\[\\[[\\s\\S]*?(?:\\]\\]|$(?![\\s\\S]))|--[^\\n]*"], + @[@(WQSyntaxString), [NSString stringWithFormat:@"\\[\\[[\\s\\S]*?\\]\\]|%@|%@", kDQ, kSQ]], + @[@(WQSyntaxKeyword), wq_words(kLuaKeywords)], + @[@(WQSyntaxNumber), kNumber], + ]; + } + // C-like languages. + NSDictionary *cLike = @{ @"c": kCKeywords, @"js": kJSKeywords, @"swift": kSwiftKeywords, @"java": kJavaKeywords, + @"go": kGoKeywords, @"rust": kRustKeywords }; + if (cLike[language]) { + BOOL c = [language isEqualToString:@"c"]; + NSString *strings = c ? [NSString stringWithFormat:@"@?%@|%@", kDQ, kSQ] + : [language isEqualToString:@"swift"] ? [NSString stringWithFormat:@"\"\"\"[\\s\\S]*?(?:\"\"\"|$(?![\\s\\S]))|%@", kDQ] + : [NSString stringWithFormat:@"%@|%@|%@", kDQ, kSQ, kBQ]; + return @[ + @[@(WQSyntaxComment), kSlashComments], + @[@(WQSyntaxString), strings], + @[@(WQSyntaxPreprocessor), c ? [@"^\\s*#\\s*\\w+|" stringByAppendingString:kObjCDirectives] + : @"^\\s*#!\\[?[^\\n]*|@[A-Za-z_]\\w*"], + @[@(WQSyntaxKeyword), wq_words(cLike[language])], + @[@(WQSyntaxNumber), kNumber], + ]; + } + return nil; +} + +NSString *wq_syntax_language(NSString *fileName, NSString *text) +{ + static NSDictionary *byExtension; + static dispatch_once_t once; + dispatch_once(&once, ^{ + NSMutableDictionary *map = [NSMutableDictionary new]; + void (^add)(NSString *, NSString *) = ^(NSString *language, NSString *extensions) { + for (NSString *e in [extensions componentsSeparatedByString:@" "]) map[e] = language; + }; + add(@"xml", @"xml plist html htm xhtml svg xib storyboard entitlements mobileconfig xsd xsl rss atom"); + add(@"json", @"json jsonc json5 geojson webmanifest har ipynb"); + add(@"css", @"css scss less"); + add(@"sql", @"sql"); + add(@"ini", @"ini cfg conf toml properties env gitconfig editorconfig"); + add(@"yaml", @"yaml yml"); + add(@"markdown", @"md markdown"); + add(@"python", @"py pyw pyi"); + add(@"shell", @"sh bash zsh ksh command profile bashrc zshrc"); + add(@"ruby", @"rb gemspec podspec"); + add(@"lua", @"lua"); + add(@"c", @"c h m mm cc cpp cxx hpp hh hxx metal strings"); + add(@"js", @"js mjs cjs jsx ts tsx"); + add(@"swift", @"swift"); + add(@"java", @"java kt kts gradle groovy scala dart cs"); + add(@"go", @"go"); + add(@"rust", @"rs"); + byExtension = map; + }); + NSString *ext = fileName.pathExtension.lowercaseString; + if (!ext.length && [fileName hasPrefix:@"."]) ext = [fileName substringFromIndex:1].lowercaseString; // .zshrc + NSString *language = ext.length ? byExtension[ext] : nil; + if (language) return language; + if (!text.length) return nil; + // No known extension: a shebang, or text that looks like XML or JSON. + NSString *head = [text substringToIndex:MIN(text.length, (NSUInteger)200)]; + NSString *trimmed = [head stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet]; + if ([trimmed hasPrefix:@"#!"]) { + NSString *line = [trimmed componentsSeparatedByString:@"\n"].firstObject; + if ([line containsString:@"python"]) return @"python"; + if ([line containsString:@"ruby"]) return @"ruby"; + if ([line containsString:@"node"]) return @"js"; + if ([line containsString:@"lua"]) return @"lua"; + return @"shell"; + } + if ([trimmed hasPrefix:@" kWQSyntaxMaxLength) return nil; + static NSMutableDictionary *cache; + static NSMutableDictionary *kindsCache; + static NSObject *lock; + static dispatch_once_t once; + dispatch_once(&once, ^{ cache = [NSMutableDictionary new]; kindsCache = [NSMutableDictionary new]; lock = [NSObject new]; }); + NSRegularExpression *regex = nil; + NSArray *kinds = nil; + @synchronized (lock) { + id cached = cache[language]; + if (!cached) { + NSRegularExpressionOptions options = 0; + NSArray *rules = wq_language_rules(language, &options); + NSMutableArray *parts = [NSMutableArray new], *ruleKinds = [NSMutableArray new]; + for (NSArray *rule in rules) { + [parts addObject:[NSString stringWithFormat:@"(%@)", rule[1]]]; + [ruleKinds addObject:rule[0]]; + } + NSError *error = nil; + NSRegularExpression *built = rules.count + ? [NSRegularExpression regularExpressionWithPattern:[parts componentsJoinedByString:@"|"] options:options error:&error] + : nil; + if (rules.count && !built) NSLog(@"WQSyntax: %@ pattern failed: %@", language, error); + cached = built ?: NSNull.null; + cache[language] = cached; + if (built) kindsCache[language] = ruleKinds; + } + if (cached != NSNull.null) { regex = cached; kinds = kindsCache[language]; } + } + if (!regex) return nil; + NSMutableData *tokens = [NSMutableData new]; + __block BOOL stopped = NO; + __block NSUInteger checked = 0; + [regex enumerateMatchesInString:text options:0 range:NSMakeRange(0, text.length) + usingBlock:^(NSTextCheckingResult *match, NSMatchingFlags __unused flags, BOOL *stop) { + if (cancelled && ++checked % 512 == 0 && cancelled()) { stopped = YES; *stop = YES; return; } + if (!match || match.range.length == 0) return; + for (NSUInteger g = 1; g < match.numberOfRanges && g <= kinds.count; g++) { + NSRange r = [match rangeAtIndex:g]; + if (r.location == NSNotFound) continue; + WQSyntaxToken token = { r, (WQSyntaxKind)kinds[g - 1].unsignedCharValue }; + [tokens appendBytes:&token length:sizeof(token)]; + break; + } + }]; + return stopped ? nil : tokens; +} diff --git a/source/hook/src/WQUI.h b/source/hook/src/WQUI.h new file mode 100644 index 0000000..c779bc0 --- /dev/null +++ b/source/hook/src/WQUI.h @@ -0,0 +1,37 @@ +// +// WQUI.h +// WQ Files shared UI helpers +// + +#import +#import "WQLocalization.h" + +NS_ASSUME_NONNULL_BEGIN + +static inline UIBarButtonItemStyle wq_primary_button_style(void) +{ + if (@available(iOS 26.0, *)) return UIBarButtonItemStyleProminent; + return UIBarButtonItemStylePlain; +} + +dispatch_queue_t wq_io(void); +void wq_alert(UIViewController *host, NSString *title, NSString *_Nullable message); +// Settings → Allow Deleting (on by default). When off, every delete, clear and +// clean action is hidden or refused. +BOOL wq_deleting_allowed(void); +void wq_set_deleting_allowed(BOOL allowed); +// Settings → Keep Data Before Restore (on by default). When off, the safety +// backup an app restore makes first is deleted once the restore succeeded. +BOOL wq_keep_before_restore(void); +void wq_set_keep_before_restore(BOOL keep); +// YES (after telling the user) when deleting is turned off. +BOOL wq_refuse_if_deleting_off(UIViewController *host); +UIAlertController *wq_alert_controller(NSString *_Nullable title, NSString *_Nullable message, UIAlertControllerStyle style); +UIAlertAction *wq_alert_action(NSString *title, UIAlertActionStyle style, void (^_Nullable handler)(UIAlertAction *)); +NSString *wq_size_text(unsigned long long size); +UIView *wq_device_header(CGFloat width); +// "iPhone 17 Pro Max (iPhone18,2) · iOS 27.0.1 (24A446)" +NSString *wq_device_line(void); +UIImage *_Nullable wq_app_icon(NSString *_Nullable bundleID); + +NS_ASSUME_NONNULL_END diff --git a/source/hook/src/WQUI.m b/source/hook/src/WQUI.m new file mode 100644 index 0000000..84e3c80 --- /dev/null +++ b/source/hook/src/WQUI.m @@ -0,0 +1,354 @@ +// +// WQUI.m +// WQ Files shared UI helpers +// GPL-3.0-or-later. +// + +#import "WQUI.h" +#import "WQAccess.h" +#import "WQLaunchServices.h" +#import +#import +#import + +// "iPhone 17 Pro Max (iPhone18,2)\niOS 27.0.1 (24A446)" +NSString *wq_device_line(void) +{ + struct utsname u; + NSString *machine = (uname(&u) == 0) ? @(u.machine) : @"unknown"; + char build[64] = {0}; + size_t len = sizeof(build); + NSString *buildStr = (sysctlbyname("kern.osversion", build, &len, NULL, 0) == 0) ? @(build) : nil; + NSDictionary *names = @{ + @"iPhone17,1": @"iPhone 16 Pro", @"iPhone17,2": @"iPhone 16 Pro Max", + @"iPhone17,3": @"iPhone 16", @"iPhone17,4": @"iPhone 16 Plus", @"iPhone17,5": @"iPhone 16e", + @"iPhone18,1": @"iPhone 17 Pro", @"iPhone18,2": @"iPhone 17 Pro Max", + @"iPhone18,3": @"iPhone 17", @"iPhone18,4": @"iPhone Air", + }; + NSString *marketing = names[machine]; + NSString *model = marketing ? [NSString stringWithFormat:@"%@ (%@)", marketing, machine] : machine; + NSString *ver = UIDevice.currentDevice.systemVersion; + return buildStr ? [NSString stringWithFormat:@"%@ · iOS %@ (%@)", model, ver, buildStr] + : [NSString stringWithFormat:@"%@ · iOS %@", model, ver]; +} + +// A small centered label with the device/iOS line, for a table header. +UIView *wq_device_header(CGFloat width) +{ + UIView *box = [[UIView alloc] initWithFrame:CGRectMake(0, 0, width, 50)]; + UILabel *header = [UILabel new]; + header.numberOfLines = 0; + header.textAlignment = NSTextAlignmentCenter; + header.font = [UIFont monospacedSystemFontOfSize:11 weight:UIFontWeightMedium]; + header.textColor = UIColor.secondaryLabelColor; + header.backgroundColor = UIColor.secondarySystemBackgroundColor; + header.text = wq_device_line(); + header.frame = CGRectMake(0, 0, width, 34); + header.autoresizingMask = UIViewAutoresizingFlexibleWidth; + [box addSubview:header]; // 16 pt of clear space below the strip + return box; +} + +// Private: app icon by bundle id (in UIKit; guarded with respondsToSelector). +@interface UIImage (WQPrivate) ++ (UIImage *)_applicationIconImageForBundleIdentifier:(NSString *)bid format:(int)fmt scale:(CGFloat)scale; +@end + +// Small grey fingerprint of an icon, to recognise iOS's blank placeholder. +static NSData *wq_icon_fingerprint(UIImage *img) +{ + const size_t s = 12; + uint8_t px[12 * 12 * 4] = {0}; + CGColorSpaceRef cs = CGColorSpaceCreateDeviceRGB(); + CGContextRef ctx = CGBitmapContextCreate(px, s, s, 8, s * 4, cs, (CGBitmapInfo)kCGImageAlphaPremultipliedLast); + CGColorSpaceRelease(cs); + if (!ctx) return nil; + if (img.CGImage) CGContextDrawImage(ctx, CGRectMake(0, 0, s, s), img.CGImage); + CGContextRelease(ctx); + return [NSData dataWithBytes:px length:sizeof(px)]; +} + +static UIImage *wq_raw_icon(NSString *bundleID) +{ + if (![UIImage respondsToSelector:@selector(_applicationIconImageForBundleIdentifier:format:scale:)]) return nil; + CGFloat scale = 2.0; // Safe fallback for background preparation without UI context. + if (NSThread.isMainThread) { + for (UIScene *scene in UIApplication.sharedApplication.connectedScenes) { + if ([scene isKindOfClass:UIWindowScene.class] && scene.activationState == UISceneActivationStateForegroundActive) { + scale = ((UIWindowScene *)scene).screen.scale; + break; + } + } + } + return [UIImage _applicationIconImageForBundleIdentifier:bundleID format:2 scale:scale]; +} + +// The real icon for bundleID, or nil when iOS only has its placeholder. +static UIImage *wq_real_icon(NSString *bundleID) +{ + static NSData *placeholder; + static dispatch_once_t once; + dispatch_once(&once, ^{ placeholder = wq_icon_fingerprint(wq_raw_icon(@"cz.kolbi.wqfiles.no-such-app")); }); + UIImage *raw = wq_raw_icon(bundleID); + if (!raw || !raw.CGImage) return nil; + NSData *fp = wq_icon_fingerprint(raw); + if (!placeholder || fp.length != placeholder.length) return raw; + // Near-identical to the placeholder (allow small rendering differences). + const uint8_t *a = fp.bytes, *b = placeholder.bytes; + unsigned long diff = 0; + for (NSUInteger i = 0; i < fp.length; i++) diff += (unsigned long)abs((int)a[i] - (int)b[i]); + return diff / fp.length < 8 ? nil : raw; +} + +// The icon from the app's own bundle, for when iOS only hands out its +// placeholder (iOS 26 refuses this app's icon requests). Reads the primary +// icon names from Info.plist and loads the matching PNG files. +// The first few outcomes go to the Log, to see why icons are missing. +static void wq_icon_note(NSString *bundleID, NSString *what) +{ + static NSUInteger notes; + @synchronized (NSNull.null) { + if (notes >= 8) return; + notes++; + } + wq_log(@"icon: %@: %@", bundleID, what); +} + +static UIImage *wq_bundle_icon(NSString *bundleID) +{ + NSString *path = wq_bundle_path_for_id(bundleID); + if (!path.length) { wq_icon_note(bundleID, @"no bundle path in the app database"); return nil; } + NSString *plistPath = [path stringByAppendingPathComponent:@"Info.plist"]; + NSDictionary *info = [NSDictionary dictionaryWithContentsOfFile:plistPath]; + if (!info && wq_grant(path)) info = [NSDictionary dictionaryWithContentsOfFile:plistPath]; + if (![info isKindOfClass:NSDictionary.class]) { + int fd = open(plistPath.fileSystemRepresentation, O_RDONLY); + int err = fd < 0 ? errno : 0; + if (fd >= 0) close(fd); + wq_icon_note(bundleID, [NSString stringWithFormat:@"can't read %@ (%s)", plistPath, err ? strerror(err) : "not a plist"]); + return nil; + } + NSMutableArray *names = [NSMutableArray new]; + for (NSString *key in @[@"CFBundleIcons", @"CFBundleIcons~ipad"]) { + NSDictionary *primary = [info[key] isKindOfClass:NSDictionary.class] ? info[key][@"CFBundlePrimaryIcon"] : nil; + if (![primary isKindOfClass:NSDictionary.class]) continue; + if ([primary[@"CFBundleIconName"] isKindOfClass:NSString.class]) [names addObject:primary[@"CFBundleIconName"]]; + NSArray *files = [primary[@"CFBundleIconFiles"] isKindOfClass:NSArray.class] ? primary[@"CFBundleIconFiles"] : @[]; + for (id file in files.reverseObjectEnumerator) if ([file isKindOfClass:NSString.class]) [names addObject:file]; + } + if ([info[@"CFBundleIconFile"] isKindOfClass:NSString.class]) [names addObject:info[@"CFBundleIconFile"]]; + [names addObjectsFromArray:@[@"AppIcon60x60", @"AppIcon"]]; + // Only the PNG files apps ship next to their asset catalog. Loading from + // the catalog (UIImage imageNamed:inBundle:) aborts inside UIKit when an + // entry exists but can't be rendered (crash on 27.0.1, build 21). + for (NSString *name in names) { + NSString *base = name.stringByDeletingPathExtension; + for (NSString *suffix in @[@"@3x.png", @"@2x.png", @"@2x~ipad.png", @"~ipad.png", @".png"]) { + UIImage *image = [UIImage imageWithContentsOfFile:[path stringByAppendingPathComponent:[base stringByAppendingString:suffix]]]; + if (image.CGImage) { + wq_icon_note(bundleID, [NSString stringWithFormat:@"loaded %@%@ from the app", base, suffix]); + return image; + } + } + } + wq_icon_note(bundleID, [NSString stringWithFormat:@"no icon PNG in %@ (tried %@)", path.lastPathComponent, + [names componentsJoinedByString:@", "]]); + return nil; +} + +static UIImage *wq_round_icon(UIImage *raw) +{ + CGFloat s = 40; + UIGraphicsImageRenderer *r = [[UIGraphicsImageRenderer alloc] initWithSize:CGSizeMake(s, s)]; + return [r imageWithActions:^(UIGraphicsImageRendererContext *__unused ctx) { + [[UIBezierPath bezierPathWithRoundedRect:CGRectMake(0, 0, s, s) cornerRadius:s * 0.225] addClip]; + [raw drawInRect:CGRectMake(0, 0, s, s)]; + }]; +} + +// Symbol tile for system services and extensions that have no icon at all, +// picked from keywords in the bundle id. +static UIImage *wq_service_tile(NSString *bundleID) +{ + static NSArray *rules; + static NSCache *tiles; + static dispatch_once_t once; + dispatch_once(&once, ^{ + tiles = [NSCache new]; + UIColor *blue = UIColor.systemBlueColor, *green = UIColor.systemGreenColor, *red = UIColor.systemRedColor, + *orange = UIColor.systemOrangeColor, *purple = UIColor.systemPurpleColor, *indigo = UIColor.systemIndigoColor, + *teal = UIColor.systemTealColor, *pink = UIColor.systemPinkColor, *gray = UIColor.systemGrayColor; + // keywords (lower case, any match), symbol, colour + rules = @[ + @[@[@"emoji"], @"face.smiling.inverse", orange], + @[@[@"keyboard", @"inputui", @"textinput", @"dictation"], @"keyboard.fill", gray], + @[@[@"airdrop", @"sharing", @"sharesheet"], @"antenna.radiowaves.left.and.right", blue], + @[@[@"widget", @"chrono"], @"square.grid.2x2.fill", blue], + @[@[@"screenshot", @"screencapture", @"screenrecord"], @"camera.viewfinder", indigo], + @[@[@"poster", @"wallpaper"], @"photo.on.rectangle.angled", purple], + @[@[@"siri", @"assistant"], @"waveform", indigo], + @[@[@"camera"], @"camera.fill", gray], + @[@[@"photo"], @"photo.fill", orange], + @[@[@"music", @"audio", @"shazam", @"musicrecognition"], @"music.note", pink], + @[@[@"headphone", @"airpods", @"bluetooth"], @"headphones", gray], + @[@[@"hearing"], @"ear.fill", blue], + @[@[@"weather"], @"cloud.sun.fill", teal], + @[@[@"location", @"maps", @"navigation"], @"location.fill", blue], + @[@[@"health", @"fitness", @"workout"], @"heart.fill", red], + @[@[@"pay", @"wallet", @"passbook", @"cash", @"finance"], @"creditcard.fill", green], + @[@[@"mail"], @"envelope.fill", blue], + @[@[@"message", @"sms", @"imessage"], @"message.fill", green], + @[@[@"facetime", @"video"], @"video.fill", green], + @[@[@"phone", @"call", @"telephony"], @"phone.fill", green], + @[@[@"notification", @"usernotification", @"bulletin"], @"bell.fill", red], + @[@[@"shortcut", @"workflow", @"intent"], @"square.on.square", indigo], + @[@[@"safari", @"web"], @"safari.fill", blue], + @[@[@"news", @"stocks"], @"newspaper.fill", red], + @[@[@"calendar", @"event"], @"calendar", red], + @[@[@"note", @"paper"], @"note.text", orange], + @[@[@"reminder"], @"checklist", blue], + @[@[@"game"], @"gamecontroller.fill", purple], + @[@[@"auth", @"account", @"appsso", @"password", @"keychain", @"tcc", @"privacy"], @"lock.fill", gray], + @[@[@"print"], @"printer.fill", gray], + @[@[@"clarity", @"accessib"], @"accessibility", blue], + @[@[@"home"], @"house.fill", orange], + @[@[@"findmy", @"fmd", @"fmip"], @"location.circle.fill", green], + @[@[@"carplay", @"carradio", @"cartrip", @"car."], @"car.fill", gray], + @[@[@"tvremote", @"tvsetup", @"com.apple.tv"], @"tv.fill", gray], + @[@[@"book"], @"book.fill", orange], + @[@[@"podcast"], @"mic.fill", purple], + @[@[@"spotlight", @"search"], @"magnifyingglass", gray], + @[@[@"diagnostic", @"logging", @"crash", @"analytics"], @"stethoscope", gray], + @[@[@"font"], @"textformat", gray], + @[@[@"translate"], @"character.bubble.fill", blue], + @[@[@"file", @"document", @"cloud"], @"folder.fill", blue], + @[@[@"setup", @"buddy", @"onboarding"], @"gearshape.2.fill", gray], + ]; + }); + NSString *key = bundleID.lowercaseString; + NSString *symbol = @"gearshape.fill"; + UIColor *color = UIColor.systemGrayColor; + for (NSArray *rule in rules) { + BOOL hit = NO; + for (NSString *word in rule[0]) if ([key containsString:word]) { hit = YES; break; } + if (hit) { symbol = rule[1]; color = rule[2]; break; } + } + NSString *cacheKey = [symbol stringByAppendingString:color.description]; + UIImage *tile = [tiles objectForKey:cacheKey]; + if (tile) return tile; + CGFloat s = 40; + UIGraphicsImageRenderer *r = [[UIGraphicsImageRenderer alloc] initWithSize:CGSizeMake(s, s)]; + UIColor *fill = [color resolvedColorWithTraitCollection:[UITraitCollection traitCollectionWithUserInterfaceStyle:UIUserInterfaceStyleLight]]; + tile = [r imageWithActions:^(UIGraphicsImageRendererContext *__unused ctx) { + [fill setFill]; + [[UIBezierPath bezierPathWithRoundedRect:CGRectMake(0, 0, s, s) cornerRadius:s * 0.225] fill]; + UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:19 weight:UIImageSymbolWeightSemibold]; + UIImage *g = [[UIImage systemImageNamed:symbol withConfiguration:cfg] ?: [UIImage systemImageNamed:@"gearshape.fill" withConfiguration:cfg] + imageWithTintColor:UIColor.whiteColor renderingMode:UIImageRenderingModeAlwaysOriginal]; + [g drawInRect:CGRectMake((s - g.size.width) / 2, (s - g.size.height) / 2, g.size.width, g.size.height)]; + }]; + [tiles setObject:tile forKey:cacheKey]; + return tile; +} + +// Rounded app icon for a bundle id, cached. Falls back to the closest parent +// app's icon (extensions: com.foo.app.widget -> com.foo.app), then to a +// neutral service tile. Nil only without a bundle id. +UIImage *wq_app_icon(NSString *bundleID) +{ + if (!bundleID.length) return nil; + static NSCache *cache; + static dispatch_once_t once; + dispatch_once(&once, ^{ cache = [NSCache new]; }); + UIImage *cached = [cache objectForKey:bundleID]; + if (cached) return cached; + // The bundle fallback reads files and may ask for access: never on the + // main thread. Lists warm the cache from their background passes; a + // main-thread miss gets the tile without caching it. + BOOL background = !NSThread.isMainThread; + UIImage *system = wq_real_icon(bundleID); + if (system && background) wq_icon_note(bundleID, @"iOS returned an icon"); + UIImage *raw = system ?: (background ? wq_bundle_icon(bundleID) : nil); + NSArray *parts = [bundleID componentsSeparatedByString:@"."]; + for (NSInteger n = (NSInteger)parts.count - 1; !raw && n >= 3; n--) + { + NSString *parent = [[parts subarrayWithRange:NSMakeRange(0, (NSUInteger)n)] componentsJoinedByString:@"."]; + raw = wq_real_icon(parent) ?: (background ? wq_bundle_icon(parent) : nil); + } + UIImage *icon = raw ? wq_round_icon(raw) : wq_service_tile(bundleID); + if (raw || background) [cache setObject:icon forKey:bundleID]; + return icon; +} + +dispatch_queue_t wq_io(void) +{ + static dispatch_queue_t queue; + static dispatch_once_t once; + dispatch_once(&once, ^{ queue = dispatch_queue_create("wqfiles.io", DISPATCH_QUEUE_SERIAL); }); + return queue; +} + +static NSString *const kWQDeletingDisabledPref = @"WQDeletingDisabled"; + +BOOL wq_deleting_allowed(void) +{ + return ![NSUserDefaults.standardUserDefaults boolForKey:kWQDeletingDisabledPref]; +} + +void wq_set_deleting_allowed(BOOL allowed) +{ + [NSUserDefaults.standardUserDefaults setBool:!allowed forKey:kWQDeletingDisabledPref]; +} + +static NSString *const kWQDiscardBeforeRestorePref = @"WQDiscardBeforeRestore"; + +BOOL wq_keep_before_restore(void) +{ + return ![NSUserDefaults.standardUserDefaults boolForKey:kWQDiscardBeforeRestorePref]; +} + +void wq_set_keep_before_restore(BOOL keep) +{ + [NSUserDefaults.standardUserDefaults setBool:!keep forKey:kWQDiscardBeforeRestorePref]; +} + +BOOL wq_refuse_if_deleting_off(UIViewController *host) +{ + if (wq_deleting_allowed()) return NO; + UIAlertController *alert = wq_alert_controller(@"Deleting is turned off", + @"Turn on Allow Deleting on the start screen of WQ Files to delete, clear or clean.", UIAlertControllerStyleAlert); + [alert addAction:wq_alert_action(@"OK", UIAlertActionStyleDefault, nil)]; + [host presentViewController:alert animated:YES completion:nil]; + return YES; +} + +UIAlertController *wq_alert_controller(NSString *title, NSString *message, UIAlertControllerStyle style) +{ + return [UIAlertController alertControllerWithTitle:WQLocalizedNullable(title) + message:WQLocalizedNullable(message) + preferredStyle:style]; +} + +UIAlertAction *wq_alert_action(NSString *title, UIAlertActionStyle style, void (^handler)(UIAlertAction *)) +{ + return [UIAlertAction actionWithTitle:WQLocalized(title) style:style handler:handler]; +} + +void wq_alert(UIViewController *host, NSString *title, NSString *message) +{ + if (!host || !host.isViewLoaded || !host.view.window) return; + UIWindowScene *scene = host.view.window.windowScene; + if (scene && scene.activationState != UISceneActivationStateForegroundActive) return; + UINavigationController *nav = host.navigationController; + if (nav && nav.topViewController != host) return; + if (host.presentedViewController && !host.presentedViewController.isBeingDismissed) return; + UIAlertController *alert = wq_alert_controller(title, message, UIAlertControllerStyleAlert); + [alert addAction:wq_alert_action(@"OK", UIAlertActionStyleDefault, nil)]; + [host presentViewController:alert animated:YES completion:nil]; +} + +NSString *wq_size_text(unsigned long long size) +{ + if (size == 0) return @"0 KB"; + return [NSByteCountFormatter stringFromByteCount:(long long)size countStyle:NSByteCountFormatterCountStyleFile]; +} diff --git a/source/hook/src/main.m b/source/hook/src/main.m new file mode 100644 index 0000000..812a098 --- /dev/null +++ b/source/hook/src/main.m @@ -0,0 +1,3143 @@ +// +// main.m +// WQ Files +// +// Proof-of-concept file browser for iOS 27 that reaches other containers +// through the worse_query sandbox escape by Taj C (forcequit), +// https://github.com/forcequitOS/worse_query, GPL-3.0-or-later. +// This app is GPL-3.0-or-later as well (see LICENSE). +// + +#import +#import +#import +#import +#import "WQAccess.h" +#import "WQLaunchServices.h" +#import "WQUI.h" +#import "WQSharing.h" +#import "WQDir.h" +#import "WQApps.h" +#import "WQCache.h" +#import "WQCore.h" +#import "WQPlistEditor.h" +#import "WQPlistEditorSupport.h" +#import "WQHexViewer.h" +#import "WQSQLiteViewer.h" +#import "WQArchive.h" +#import "WQSyntax.h" +#import "WQMediaStream.h" +#import +#import +#import + +static BOOL wq_looks_like_uuid(NSString *name) +{ + return name.length == 36 && [[NSUUID alloc] initWithUUIDString:name] != nil; +} + +static BOOL wq_regular_file_for_ui(NSString *path) +{ + struct stat st; + if (!wq_stat(path, &st)) return NO; + // Symbolic links are deliberately not followed: the file APIs use + // O_NOFOLLOW and the UI must offer the same contract. + return S_ISREG(st.st_mode); +} + +#pragma mark - Item + +@interface WQItem : NSObject +@property (nonatomic, copy) NSString *path; +@property (nonatomic, copy) NSString *name; +@property (nonatomic, copy) NSString *label; +@property (nonatomic, copy) NSString *bundleID; // for the app icon +@property (nonatomic) BOOL isDir; +@property (nonatomic) BOOL isLink; +@property (nonatomic) BOOL isRegular; +@property (nonatomic) unsigned long long size; +@property (nonatomic) mode_t mode; +@property (nonatomic) uid_t uid; +@property (nonatomic, strong) NSDate *modified; +@property (nonatomic, strong) NSDate *created; +@property (nonatomic) NSInteger childCount; // folders, with Show Item Counts on; -1 unknown +@end + +@implementation WQItem +- (instancetype)init +{ + if ((self = [super init])) _childCount = -1; + return self; +} +@end + +// Settings → Show Item Counts (off by default): lists every subfolder, which +// is slow in other apps' containers. +static NSString *const kWQShowItemCountsPref = @"WQShowItemCounts"; +static BOOL wq_show_item_counts(void) +{ + return [NSUserDefaults.standardUserDefaults boolForKey:kWQShowItemCountsPref]; +} + +#pragma mark - File viewer + +typedef NS_ENUM(NSInteger, WQOpenMode) { WQOpenAuto, WQOpenPlist, WQOpenText, WQOpenHex, WQOpenSQLite, WQOpenMedia }; +// Opens a file in the fitting viewer (Auto: chosen from its first bytes). +static void wq_open_file(UINavigationController *nav, NSString *path, BOOL readOnly, WQOpenMode mode); + +typedef NS_ENUM(NSInteger, WQViewKind) { WQViewText, WQViewPlist, WQViewImage, WQViewHex }; + +static const NSUInteger kViewLimit = 4 * 1024 * 1024; +static const NSUInteger kHexLimit = 64 * 1024; + +// Parsing and image creation happen on the I/O queue; the main thread only +// installs the prepared display. +@interface WQFileDisplay : NSObject +@property (nonatomic) BOOL truncated; +@property (nonatomic, strong) NSString *plistText; +@property (nonatomic) NSPropertyListFormat plistFormat; +@property (nonatomic, strong) UIImage *image; +@property (nonatomic, strong) NSString *text; +@property (nonatomic, strong) NSString *hexText; +@end +@implementation WQFileDisplay +@end + +static NSString *wq_file_hex_dump(NSData *data, BOOL truncated) +{ + NSUInteger len = MIN(data.length, kHexLimit); + const uint8_t *b = data.bytes; + NSMutableString *out = [NSMutableString stringWithCapacity:len * 4]; + for (NSUInteger off = 0; off < len; off += 16) { + [out appendFormat:@"%08lx ", (unsigned long)off]; + NSMutableString *ascii = [NSMutableString new]; + for (NSUInteger i = 0; i < 16; i++) { + if (off + i < len) { + uint8_t c = b[off + i]; + [out appendFormat:@"%02x ", c]; + [ascii appendFormat:@"%c", (c >= 0x20 && c < 0x7f) ? c : '.']; + } else { + [out appendString:@" "]; + } + } + [out appendFormat:@" %@\n", ascii]; + } + if (data.length > len || truncated) [out appendString:@"\n… (only the beginning is shown)\n"]; + return out; +} + +@interface WQFileViewController : UIViewController +@property (nonatomic) BOOL readOnly; +- (instancetype)initWithPath:(NSString *)path; +@end + +@implementation WQFileViewController { + NSString *_path; + UITextView *_textView; + UIImageView *_imageView; + UIBarButtonItem *_editButton; + WQViewKind _kind; + NSPropertyListFormat _plistFormat; + BOOL _truncated; + WQEditState *_editState; + NSDictionary *_version; + NSString *_syntaxLanguage; + NSUInteger _syntaxGeneration; +} + +static UIColor *wq_syntax_color(WQSyntaxKind kind) +{ + switch (kind) { + case WQSyntaxComment: return UIColor.systemGrayColor; + case WQSyntaxString: return UIColor.systemRedColor; + case WQSyntaxKey: return UIColor.systemTealColor; + case WQSyntaxKeyword: return UIColor.systemPinkColor; + case WQSyntaxNumber: return UIColor.systemPurpleColor; + case WQSyntaxTag: return UIColor.systemBlueColor; + case WQSyntaxAttribute: return UIColor.systemOrangeColor; + case WQSyntaxPreprocessor: return UIColor.systemBrownColor; + } + return UIColor.labelColor; +} + +// Colors the text in the background; edits recolor after a short pause. +- (void)highlightAfter:(NSTimeInterval)delay +{ + NSString *language = _syntaxLanguage; + if (!language) return; + NSUInteger generation = ++_syntaxGeneration; + __weak typeof(self) weakSelf = self; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delay * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + WQFileViewController *strongSelf = weakSelf; + if (!strongSelf || generation != strongSelf->_syntaxGeneration) return; + NSString *text = [strongSelf->_textView.text copy]; + if (text.length > kWQSyntaxMaxLength) return; + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + NSData *tokens = wq_syntax_tokens(text, language, ^BOOL { + WQFileViewController *s = weakSelf; + return !s || generation != s->_syntaxGeneration; + }); + if (!tokens) return; + dispatch_async(dispatch_get_main_queue(), ^{ + WQFileViewController *s = weakSelf; + if (!s || generation != s->_syntaxGeneration) return; + UITextView *view = s->_textView; + // Not while the keyboard is composing (marked text), or if the text moved on. + if (view.markedTextRange) { [s highlightAfter:0.5]; return; } + if (![view.text isEqualToString:text]) return; + NSTextStorage *storage = view.textStorage; + NSRange all = NSMakeRange(0, storage.length); + [storage beginEditing]; + [storage addAttribute:NSForegroundColorAttributeName value:UIColor.labelColor range:all]; + const WQSyntaxToken *t = tokens.bytes; + for (NSUInteger i = 0; i < tokens.length / sizeof(WQSyntaxToken); i++) + if (NSMaxRange(t[i].range) <= storage.length) + [storage addAttribute:NSForegroundColorAttributeName value:wq_syntax_color(t[i].kind) range:t[i].range]; + [storage endEditing]; + NSMutableDictionary *typing = [view.typingAttributes mutableCopy]; + typing[NSForegroundColorAttributeName] = UIColor.labelColor; + view.typingAttributes = typing; + }); + }); + }); +} + + +- (instancetype)initWithPath:(NSString *)path +{ + if ((self = [super initWithNibName:nil bundle:nil])) { + _path = [path copy]; + _editState = [WQEditState new]; + self.title = path.lastPathComponent; + } + return self; +} + +- (void)viewDidLoad +{ + [super viewDidLoad]; + self.view.backgroundColor = UIColor.systemBackgroundColor; + _textView = [UITextView new]; + _textView.translatesAutoresizingMaskIntoConstraints = NO; + _textView.font = [UIFont monospacedSystemFontOfSize:12 weight:UIFontWeightRegular]; + // File contents stay left-to-right in right-to-left languages, like code editors. + _textView.semanticContentAttribute = UISemanticContentAttributeForceLeftToRight; + _textView.textAlignment = NSTextAlignmentLeft; + _textView.editable = NO; + _textView.autocorrectionType = UITextAutocorrectionTypeNo; + _textView.autocapitalizationType = UITextAutocapitalizationTypeNone; + _textView.smartQuotesType = UITextSmartQuotesTypeNo; + _textView.smartDashesType = UITextSmartDashesTypeNo; + _textView.alwaysBounceVertical = YES; + _textView.delegate = self; + [self.view addSubview:_textView]; + __weak typeof(self) weakSelf = self; + self.navigationItem.backAction = [UIAction actionWithHandler:^(UIAction *__unused a) { [weakSelf leave]; }]; + _imageView = [UIImageView new]; + _imageView.translatesAutoresizingMaskIntoConstraints = NO; + _imageView.contentMode = UIViewContentModeScaleAspectFit; + _imageView.hidden = YES; + [self.view addSubview:_imageView]; + UILayoutGuide *g = self.view.safeAreaLayoutGuide; + for (UIView *v in @[_textView, _imageView]) { + [NSLayoutConstraint activateConstraints:@[ + [v.topAnchor constraintEqualToAnchor:g.topAnchor], + [v.bottomAnchor constraintEqualToAnchor:self.view.keyboardLayoutGuide.topAnchor], + [v.leadingAnchor constraintEqualToAnchor:g.leadingAnchor], + [v.trailingAnchor constraintEqualToAnchor:g.trailingAnchor], + ]]; + } + UIBarButtonItem *share = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemAction + target:self action:@selector(share:)]; + UIMenu *openAs = [UIMenu menuWithTitle:WQLocalizedNullable(@"Open As") children:@[ + [UIAction actionWithTitle:WQLocalizedNullable(@"Plist Editor") image:[UIImage systemImageNamed:@"list.bullet.indent"] identifier:nil + handler:^(UIAction *__unused a) { [weakSelf reopenAs:WQOpenPlist]; }], + [UIAction actionWithTitle:WQLocalizedNullable(@"Hex Viewer") image:[UIImage systemImageNamed:@"number"] identifier:nil + handler:^(UIAction *__unused a) { [weakSelf reopenAs:WQOpenHex]; }], + [UIAction actionWithTitle:WQLocalizedNullable(@"SQLite Viewer") image:[UIImage systemImageNamed:@"tablecells"] identifier:nil + handler:^(UIAction *__unused a) { [weakSelf reopenAs:WQOpenSQLite]; }], + [UIAction actionWithTitle:WQLocalizedNullable(@"Media Player") image:[UIImage systemImageNamed:@"play.rectangle"] identifier:nil + handler:^(UIAction *__unused a) { [weakSelf reopenAs:WQOpenMedia]; }], + ]]; + UIBarButtonItem *more = [[UIBarButtonItem alloc] initWithImage:[UIImage systemImageNamed:@"ellipsis.circle"] menu:openAs]; + self.navigationItem.rightBarButtonItems = @[share, more]; + _textView.text = WQLocalizedNullable(@"Loading…"); + [self load]; +} + +- (void)load +{ + NSString *path = _path; + dispatch_async(wq_io(), ^{ + NSString *error = nil; + struct stat st; + BOOL exists = wq_stat(path, &st); + NSDictionary *__unused before = wq_file_version(path); + NSData *data = wq_read(path, kViewLimit + 1, &error); + NSDictionary *version = wq_file_version(path); + if (data && (!before || !version || ![before isEqualToDictionary:version])) { + data = nil; + error = version ? @"The file changed while it was being read. Open it again." + : @"Could not establish a stable file version. Open it again."; + } + WQFileDisplay *display = nil; + if (data) { + display = [WQFileDisplay new]; + display.truncated = data.length > kViewLimit; + NSData *view = display.truncated ? [data subdataWithRange:NSMakeRange(0, kViewLimit)] : data; + if (!display.truncated) { + NSPropertyListFormat format = 0; + id plist = [NSPropertyListSerialization propertyListWithData:view options:0 format:&format error:nil]; + if (plist) { + NSData *xml = [NSPropertyListSerialization dataWithPropertyList:plist + format:NSPropertyListXMLFormat_v1_0 + options:0 error:nil]; + NSString *text = xml ? [[NSString alloc] initWithData:xml encoding:NSUTF8StringEncoding] : nil; + if (text) { + display.plistFormat = format; + display.plistText = text; + } + } + } + if (!display.plistText) { + display.image = [UIImage imageWithData:view]; + if (!display.image) { + NSString *text = [[NSString alloc] initWithData:view encoding:NSUTF8StringEncoding]; + if (text && ![text containsString:@"\0"]) display.text = text; + else display.hexText = wq_file_hex_dump(view, display.truncated); + } + } + } + dispatch_async(dispatch_get_main_queue(), ^{ + self->_version = version; + if (!display) { + NSString *hint = wq_permission_hint(path, NO); + self->_textView.text = WQLocalizedFormat(@"Cannot read this file.\n\n%@", hint ?: error); + return; + } + NSString *info = exists + ? [NSString stringWithFormat:@"%@ · mode %o · uid %u", wq_size_text((unsigned long long)st.st_size), + st.st_mode & 07777, st.st_uid] + : @""; + self.navigationItem.prompt = info.length ? info : nil; + if (wq_permission_hint(path, YES)) self->_readOnly = YES; // only the owner may write it + [self showDisplay:display]; + }); + }); +} + +- (void)showDisplay:(WQFileDisplay *)display +{ + _truncated = display.truncated; + if (display.plistText) { + _kind = WQViewPlist; + _plistFormat = display.plistFormat; + _textView.text = display.plistText; + _syntaxLanguage = @"xml"; + [self highlightAfter:0]; + if (!WQPlistFormatIsWritable(display.plistFormat)) { + _readOnly = YES; + NSString *prompt = self.navigationItem.prompt; + NSString *reason = WQLocalizedFormat(@"%@ · read only", WQLocalized(WQPlistFormatName(display.plistFormat))); + self.navigationItem.prompt = prompt.length ? [prompt stringByAppendingFormat:@" · %@", reason] : reason; + } + if (!_readOnly) [self enableEditing]; + return; + } + if (display.image) { + _kind = WQViewImage; + _imageView.image = display.image; + _imageView.hidden = NO; + _textView.hidden = YES; + return; + } + if (display.text) { + _kind = WQViewText; + _textView.text = display.text; + _syntaxLanguage = _truncated ? nil : wq_syntax_language(_path.lastPathComponent, display.text); + [self highlightAfter:0]; + if (!_truncated && !_readOnly) [self enableEditing]; + return; + } + _kind = WQViewHex; + _textView.text = display.hexText ?: @""; +} + +- (void)updateEditButton +{ + _editButton.title = _textView.editable ? WQLocalized(@"Save") : WQLocalized(@"Edit"); + _editButton.style = _textView.editable ? wq_primary_button_style() : UIBarButtonItemStylePlain; + _editButton.enabled = !_editState.saving; +} + +- (void)enableEditing +{ + if (_editButton) return; + _editButton = [[UIBarButtonItem alloc] initWithTitle:WQLocalizedNullable(@"Edit") style:UIBarButtonItemStylePlain + target:self action:@selector(toggleEdit:)]; + NSArray *items = self.navigationItem.rightBarButtonItems; + self.navigationItem.rightBarButtonItems = [items arrayByAddingObject:_editButton]; +} + +- (void)textViewDidChange:(UITextView *)textView +{ + if (_editState.saving) return; + [_editState noteChange]; + [self highlightAfter:0.4]; +} + +- (BOOL)blockWhileSaving +{ + if (!_editState.saving) return NO; + wq_alert(self, @"Save in progress", @"Wait for the current save to finish before leaving or making more changes."); + return YES; +} + +- (BOOL)wq_needsLeaveConfirmation +{ + return _editState.dirty || _editState.saving; +} + +- (void)confirmLeaveThen:(void (^)(void))proceed +{ + if ([self blockWhileSaving]) return; + if (!_editState.dirty) { proceed(); return; } + UIAlertController *alert = wq_alert_controller(@"Discard changes?", @"Your text changes have not been saved.", UIAlertControllerStyleAlert); + [alert addAction:wq_alert_action(@"Keep Editing", UIAlertActionStyleCancel, nil)]; + [alert addAction:wq_alert_action(@"Discard", UIAlertActionStyleDestructive, ^(UIAlertAction *__unused a) { + [self->_editState discard]; + self->_textView.editable = NO; + [self->_textView resignFirstResponder]; + [self updateEditButton]; + proceed(); + })]; + [self presentViewController:alert animated:YES completion:nil]; +} + +- (void)leave +{ + [self confirmLeaveThen:^{ [self.navigationController popViewControllerAnimated:YES]; }]; +} + +- (void)toggleEdit:(UIBarButtonItem *)sender +{ + if ([self blockWhileSaving]) return; + if (!_textView.editable) { + _textView.editable = YES; + [self updateEditButton]; + [_textView becomeFirstResponder]; + return; + } + if (!_editState.dirty) { + _textView.editable = NO; + [_textView resignFirstResponder]; + [self updateEditButton]; + return; + } + if (!_version) { + wq_alert(self, @"Cannot save", @"The file has no verified version snapshot. Reopen it before saving."); + return; + } + NSData *newData = nil; + if (_kind == WQViewPlist) { + NSData *xml = [_textView.text dataUsingEncoding:NSUTF8StringEncoding]; + NSError *error = nil; + id plist = xml ? [NSPropertyListSerialization propertyListWithData:xml options:0 format:NULL error:&error] : nil; + if (!plist) { + wq_alert(self, @"Not a valid property list", error.localizedDescription ?: @"Check the XML."); + return; + } + newData = [NSPropertyListSerialization dataWithPropertyList:plist format:_plistFormat options:0 error:&error]; + if (!newData) { + wq_alert(self, @"Cannot encode", error.localizedDescription ?: @""); + return; + } + } else { + newData = [_textView.text dataUsingEncoding:NSUTF8StringEncoding]; + } + if (!newData) { + wq_alert(self, @"Cannot encode", @"The text could not be encoded as UTF-8."); + return; + } + NSString *message = WQLocalizedFormat(@"%@ will be overwritten in place. The current version is backed up to Backups first.", _path); + UIAlertController *confirm = wq_alert_controller(@"Save changes?", message, UIAlertControllerStyleAlert); + [confirm addAction:wq_alert_action(@"Cancel", UIAlertActionStyleCancel, nil)]; + [confirm addAction:wq_alert_action(@"Save", UIAlertActionStyleDestructive, ^(UIAlertAction *__unused a) { + NSUInteger savedRevision = [self->_editState beginSave]; + self->_textView.editable = NO; + [self->_textView resignFirstResponder]; + [self updateEditButton]; + NSString *path = self->_path; + NSDictionary *expectedVersion = [self->_version copy]; + dispatch_async(wq_io(), ^{ + NSString *error = nil; + NSDictionary *newVersion = nil; + BOOL ok = wq_write_if_unchanged_get_version(path, newData, expectedVersion, &newVersion, &error); + dispatch_async(dispatch_get_main_queue(), ^{ + [self->_editState finishSave:ok revision:savedRevision]; + if (ok) self->_version = newVersion; + self->_textView.editable = self->_editState.dirty; + [self updateEditButton]; + if (!ok) wq_alert(self, @"Save failed", error ?: wq_permission_hint(path, YES) ?: @"Unknown error"); + }); + }); + })]; + [self presentViewController:confirm animated:YES completion:nil]; +} + +- (void)share:(UIBarButtonItem *)sender +{ + wq_share(self, _path, nil); +} + +- (void)reopenAs:(WQOpenMode)mode +{ + [self confirmLeaveThen:^{ + UINavigationController *nav = self.navigationController; + NSString *path = self->_path; + BOOL readOnly = self->_readOnly; + [nav popViewControllerAnimated:NO]; + wq_open_file(nav, path, readOnly, mode); + }]; +} + +@end + +#pragma mark - Choosing a viewer + +static WQOpenMode wq_sniff(NSData *head) +{ + const uint8_t *b = head.bytes; + NSUInteger n = head.length; + if (n >= 7 && memcmp(b, "bplist0", 7) == 0) return WQOpenPlist; + if (wq_is_sqlite_header(head)) return WQOpenSQLite; + if ((n >= 8 && memcmp(b, "\x89PNG", 4) == 0) || (n >= 3 && b[0] == 0xff && b[1] == 0xd8 && b[2] == 0xff) || + (n >= 4 && memcmp(b, "GIF8", 4) == 0) || (n >= 12 && memcmp(b + 4, "ftyp", 4) == 0)) + return WQOpenText; // images: the file viewer renders them + if (n == 0) return WQOpenText; + if (memchr(b, 0, n)) return WQOpenHex; + NSString *text = nil; + for (NSUInteger cut = 0; cut < 4 && !text && cut < n; cut++) // the sample may end mid-character + text = [[NSString alloc] initWithBytes:b length:n - cut encoding:NSUTF8StringEncoding]; + if (!text) return WQOpenHex; + if ([text containsString:@" *exts; + static dispatch_once_t once; + dispatch_once(&once, ^{ + exts = [NSSet setWithArray:@[@"mp3", @"m4a", @"m4b", @"aac", @"wav", @"aif", @"aiff", @"caf", @"flac", @"alac", + @"mp4", @"m4v", @"mov", @"3gp", @"3g2", @"m3u8"]]; + }); + return [exts containsObject:name.pathExtension.lowercaseString]; +} + +// Copies the file to WQ Files' temporary folder (the previous copy is +// removed) and calls back on the main queue with the local URL or an error. +static void wq_media_copy(NSString *path, void (^done)(NSURL *_Nullable local, NSString *_Nullable error)) +{ + dispatch_async(wq_io(), ^{ + NSString *dir = [NSTemporaryDirectory() stringByAppendingPathComponent:@"wq-media"]; + NSFileManager *fm = NSFileManager.defaultManager; + [fm removeItemAtPath:dir error:nil]; + [fm createDirectoryAtPath:dir withIntermediateDirectories:YES attributes:nil error:nil]; + NSString *local = [dir stringByAppendingPathComponent:path.lastPathComponent]; + NSString *error = nil; + BOOL ok = wq_copy_to_local(path, local, &error); + wq_log(@"media: copy %@ -> %@", path.lastPathComponent, ok ? @"OK" : error); + dispatch_async(dispatch_get_main_queue(), ^{ + done(ok ? [NSURL fileURLWithPath:local] : nil, ok ? nil : (error ?: WQLocalized(@"The file could not be read."))); + }); + }); +} + +// Plays audio and video. It streams the file through WQ Files (the media +// process can't read other apps' folders itself), so playback starts at once +// and seeking reads only what's needed. Types that can't stream that way, and +// files whose stream fails, are copied to the temporary folder and played +// from there. +static void wq_open_media(UINavigationController *nav, NSString *path, BOOL (^stillWanted)(void)) +{ + UIViewController *host = nav.topViewController; + dispatch_async(wq_io(), ^{ + struct stat st; + BOOL regular = wq_stat(path, &st) && S_ISREG(st.st_mode); + unsigned long long size = regular ? (unsigned long long)st.st_size : 0; + dispatch_async(dispatch_get_main_queue(), ^{ + if (!stillWanted()) return; + [AVAudioSession.sharedInstance setCategory:AVAudioSessionCategoryPlayback error:nil]; + AVPlayerViewController *player = [AVPlayerViewController new]; + player.title = path.lastPathComponent; + __weak AVPlayerViewController *weakPlayer = player; + // Fallback: copy, then swap the copy into the same player. + void (^playCopy)(NSString *) = ^(NSString *why) { + wq_log(@"media: %@: %@; copying it first", path.lastPathComponent, why); + AVPlayerViewController *p = weakPlayer; + if (p && !p.player) p.player = [AVPlayer new]; + UIAlertController *hud = wq_alert_controller(@"Preparing…", path.lastPathComponent, UIAlertControllerStyleAlert); + UIViewController *presenter = p.viewIfLoaded.window ? p : host; + [presenter presentViewController:hud animated:YES completion:nil]; + wq_media_copy(path, ^(NSURL *local, NSString *error) { + void (^finish)(void) = ^{ + AVPlayerViewController *q = weakPlayer; + if (!local) { + wq_alert(q.viewIfLoaded.window ? q : host, @"Cannot play", error); + return; + } + if (!q) return; + [q.player replaceCurrentItemWithPlayerItem:[AVPlayerItem playerItemWithURL:local]]; + [q.player play]; + }; + if (presenter.presentedViewController == hud) [presenter dismissViewControllerAnimated:YES completion:finish]; + else finish(); + }); + }; + AVPlayerItem *item = nil; + if (regular) { + item = [WQMediaStream playerItemForName:path.lastPathComponent size:size + read:^NSData *(unsigned long long offset, NSUInteger length, NSString **error) { + return wq_read_range(path, offset, length, error); + } onFailure:^(NSString *reason) { + if (weakPlayer) playCopy([@"streaming failed: " stringByAppendingString:reason]); + }]; + } + [nav pushViewController:player animated:YES]; + if (item) { + wq_log(@"media: streaming %@ (%@, %@)", path.lastPathComponent, + [WQMediaStream contentTypeForFileName:path.lastPathComponent], wq_size_text(size)); + player.player = [AVPlayer playerWithPlayerItem:item]; + [player.player play]; + } else { + playCopy(regular ? @"type can't be streamed" : @"not a regular file or not readable"); + } + }); + }); +} + +static void wq_open_file(UINavigationController *nav, NSString *path, BOOL readOnly, WQOpenMode mode) +{ + if (!nav || !path.length) return; + // All calls originate on the main thread. A newer explicit or automatic + // open invalidates older sniff requests, and leaving the originating screen + // also prevents a late push. + NSUInteger request = ++wq_open_generation; + UIViewController *origin = nav.topViewController; + void (^push)(WQOpenMode) = ^(WQOpenMode m) { + if (request != wq_open_generation || nav.topViewController != origin) return; + if (m == WQOpenMedia) { + wq_open_media(nav, path, ^BOOL { return request == wq_open_generation && nav.topViewController == origin; }); + return; + } + UIViewController *vc; + if (m == WQOpenPlist) { + WQPlistEditorViewController *editor = [[WQPlistEditorViewController alloc] initWithPath:path readOnly:readOnly]; + __weak UINavigationController *weakNav = nav; + editor.openAsText = ^{ wq_open_file(weakNav, path, readOnly, WQOpenText); }; + editor.openAsHex = ^{ wq_open_file(weakNav, path, readOnly, WQOpenHex); }; + vc = editor; + } else if (m == WQOpenHex) { + vc = [[WQHexViewController alloc] initWithPath:path readOnly:readOnly]; + } else if (m == WQOpenSQLite) { + vc = [[WQSQLiteViewController alloc] initWithPath:path]; + } else { + WQFileViewController *file = [[WQFileViewController alloc] initWithPath:path]; + file.readOnly = readOnly; + vc = file; + } + [nav pushViewController:vc animated:YES]; + }; + if (!wq_regular_file_for_ui(path)) { + UIViewController *host = nav.topViewController; + struct stat st; + BOOL link = wq_stat(path, &st) && S_ISLNK(st.st_mode); + if (host) wq_alert(host, link ? @"Symbolic link" : @"Cannot open this item", + link ? @"WQ Files does not follow symbolic links for viewing, sharing or editing." + : @"Only regular files can be viewed."); + return; + } + if (mode == WQOpenAuto && wq_is_media_name(path)) mode = WQOpenMedia; + if (mode != WQOpenAuto) { push(mode); return; } + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + NSData *head = wq_permission_hint(path, NO) ? nil : wq_read_range(path, 0, 4096, NULL); + WQOpenMode m = head ? wq_sniff(head) : WQOpenText; // unreadable: the file viewer shows why + dispatch_async(dispatch_get_main_queue(), ^{ push(m); }); + }); +} + +#pragma mark - Folder + +static NSString *wq_folder_date_text(NSDate *date) +{ + static NSDateFormatter *formatter; + static dispatch_once_t once; + dispatch_once(&once, ^{ + formatter = [NSDateFormatter new]; + formatter.dateStyle = NSDateFormatterShortStyle; + formatter.timeStyle = NSDateFormatterShortStyle; + formatter.locale = NSLocale.autoupdatingCurrentLocale; + }); + return date ? [formatter stringFromDate:date] : @""; +} + +#pragma mark - Folder sorting + +// One sort order for all folders, remembered. Each key starts in its natural +// direction (A–Z, newest first, largest first); picking it again reverses it. +typedef NS_ENUM(NSInteger, WQSortKey) { WQSortName, WQSortModified, WQSortCreated, WQSortSize }; +static NSString *const kWQSortKeyPref = @"WQFolderSortKey"; +static NSString *const kWQSortAscendingPref = @"WQFolderSortAscending"; +static NSString *const kWQFoldersFirstPref = @"WQFolderSortFoldersFirst"; + +static WQSortKey wq_sort_key(void) +{ + NSInteger key = [NSUserDefaults.standardUserDefaults integerForKey:kWQSortKeyPref]; + return key >= WQSortName && key <= WQSortSize ? (WQSortKey)key : WQSortName; +} + +static BOOL wq_sort_ascending(void) +{ + id value = [NSUserDefaults.standardUserDefaults objectForKey:kWQSortAscendingPref]; + return value ? [value boolValue] : YES; +} + +static BOOL wq_folders_first(void) +{ + id value = [NSUserDefaults.standardUserDefaults objectForKey:kWQFoldersFirstPref]; + return value ? [value boolValue] : YES; +} + +static void wq_choose_sort(WQSortKey key) +{ + NSUserDefaults *d = NSUserDefaults.standardUserDefaults; + BOOL ascending = key == wq_sort_key() ? !wq_sort_ascending() : key == WQSortName; + [d setInteger:key forKey:kWQSortKeyPref]; + [d setBool:ascending forKey:kWQSortAscendingPref]; +} + +static NSString *wq_sort_direction_text(WQSortKey key, BOOL ascending) +{ + switch (key) { + case WQSortName: return ascending ? WQLocalized(@"A–Z") : WQLocalized(@"Z–A"); + case WQSortSize: return ascending ? WQLocalized(@"Smallest first") : WQLocalized(@"Largest first"); + default: return ascending ? WQLocalized(@"Oldest first") : WQLocalized(@"Newest first"); + } +} + +static NSArray *wq_sorted_items(NSArray *items) +{ + WQSortKey key = wq_sort_key(); + BOOL ascending = wq_sort_ascending(), foldersFirst = wq_folders_first(); + return [items sortedArrayUsingComparator:^NSComparisonResult(WQItem *a, WQItem *b) { + if (foldersFirst && a.isDir != b.isDir) return a.isDir ? NSOrderedAscending : NSOrderedDescending; + NSComparisonResult r = NSOrderedSame; + switch (key) { + case WQSortName: break; + case WQSortModified: r = [(a.modified ?: NSDate.distantPast) compare:(b.modified ?: NSDate.distantPast)]; break; + case WQSortCreated: r = [(a.created ?: NSDate.distantPast) compare:(b.created ?: NSDate.distantPast)]; break; + case WQSortSize: r = a.size < b.size ? NSOrderedAscending : a.size > b.size ? NSOrderedDescending : NSOrderedSame; break; + } + NSComparisonResult byName = [(a.label ?: a.name ?: @"") localizedStandardCompare:(b.label ?: b.name ?: @"")]; + if (key == WQSortName) r = byName; + if (r == NSOrderedSame) return byName; + return ascending ? r : (NSComparisonResult)-r; + }]; +} + +// Sidebar letter for an entry: its first letter without accents, "#" for the rest. +static NSString *wq_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] : @"#"; +} + +// Items picked with Move, waiting for "Move Here" in another folder. +static NSArray *gWQMovePaths; +static NSString *const kWQMoveChanged = @"WQMoveChanged"; + +static void wq_set_move_paths(NSArray *paths) +{ + gWQMovePaths = paths.count ? [paths copy] : nil; + [NSNotificationCenter.defaultCenter postNotificationName:kWQMoveChanged object:nil]; +} + +// ZIP reading and writing through WQAccess, so other apps' files work too. +static WQArchiveIO *wq_app_archive_io(void) +{ + WQArchiveIO *io = [WQArchiveIO new]; + io.stat = ^BOOL(NSString *path, struct stat *st) { return wq_stat(path, st); }; + io.list = ^NSArray *(NSString *dir) { return wq_list(dir, NULL); }; + io.openRead = ^int(NSString *path) { return wq_open_for_reading(path); }; + return io; +} + +static NSString *wq_temp_work_dir(NSString *prefix) +{ + NSString *dir = [NSTemporaryDirectory() stringByAppendingPathComponent: + [NSString stringWithFormat:@"%@-%@", prefix, NSUUID.UUID.UUIDString]]; + return [NSFileManager.defaultManager createDirectoryAtPath:dir withIntermediateDirectories:YES attributes:nil error:nil] ? dir : nil; +} + +@interface WQDirViewController () +- (BOOL)wq_wantsToolbar; +@end + +@implementation WQDirViewController { + NSString *_path; + NSArray *_all; // everything in the folder + NSArray *_items; // what the search lets through (shown) + NSString *_query; + UILabel *_pathLabel; + UIView *_headerBox; + NSString *_scanStatus; + CGFloat _headerWidth; + NSString *_error; + BOOL _labels; + BOOL _backupManager; + BOOL _backupStorage; + BOOL _selectingBackups; + NSMutableSet *_selectedBackups; + UIBarButtonItem *_selectItem; + UIBarButtonItem *_menuItem; + UIBarButtonItem *_doneItem; + UIBarButtonItem *_deleteItem; + UIBarButtonItem *_allItem; + NSString *_baseTitle; + WQScanCancellation *_scanCancellation; + NSUInteger _scanGeneration; + BOOL _scanInFlight; + BOOL _scanNeedsRefresh; + NSTimeInterval _lastReloadFinished; + NSArray *_indexTitles; // sidebar letters, when sorted by name + NSDictionary *_indexRows; // letter -> first row + BOOL _selectingFiles; + BOOL _importingFolder; + NSMutableOrderedSet *_selectedFiles; + UIBarButtonItem *_fileSelectItem; + UIBarButtonItem *_fileDoneItem; + UIBarButtonItem *_fileAllItem; +} + +- (instancetype)initWithPath:(NSString *)path title:(NSString *)title +{ + if ((self = [super initWithStyle:UITableViewStylePlain])) { + _path = [path copy]; + self.title = title ?: (path.lastPathComponent.length ? path.lastPathComponent : @"/"); + _baseTitle = self.title; + _selectedBackups = [NSMutableSet new]; + _selectedFiles = [NSMutableOrderedSet new]; + // Container parents: show bundle/group identifiers instead of UUIDs. + NSString *parent = path.lastPathComponent; + _labels = [@[@"Application", @"AppGroup", @"PluginKitPlugin", @"InternalDaemon", @"System"] containsObject:parent]; + NSString *appBackups = wq_app_backup_root(); + NSString *fileBackups = wq_backup_root(); + _backupManager = appBackups.length && + ([_path isEqualToString:appBackups] || [_path hasPrefix:[appBackups stringByAppendingString:@"/"]]); + _backupStorage = _backupManager || + (fileBackups.length && ([_path isEqualToString:fileBackups] || [_path hasPrefix:[fileBackups stringByAppendingString:@"/"]])); + } + return self; +} + +- (void)viewDidLoad +{ + [super viewDidLoad]; + self.refreshControl = [UIRefreshControl new]; + [self.refreshControl addTarget:self action:@selector(userRefresh) forControlEvents:UIControlEventValueChanged]; + __weak typeof(self) weakSelf = self; + NSString *path = _path; + NSMutableArray *menuItems = [NSMutableArray new]; + if (!self.readOnly && !_backupStorage) { + [menuItems addObject:[UIAction actionWithTitle:WQLocalizedNullable(@"New Folder") image:[UIImage systemImageNamed:@"folder.badge.plus"] identifier:nil + handler:^(UIAction *__unused a) { [weakSelf promptNewFolder]; }]]; + [menuItems addObject:[UIAction actionWithTitle:WQLocalizedNullable(@"Import Files") image:[UIImage systemImageNamed:@"square.and.arrow.down"] identifier:nil + handler:^(UIAction *__unused a) { [weakSelf importFile]; }]]; + [menuItems addObject:[UIAction actionWithTitle:WQLocalizedNullable(@"Import Folder") image:[UIImage systemImageNamed:@"folder.badge.plus"] identifier:nil + handler:^(UIAction *__unused a) { [weakSelf importFolder]; }]]; + } + [menuItems addObject:[UIAction actionWithTitle:WQLocalizedNullable(@"Copy Path") image:[UIImage systemImageNamed:@"doc.on.doc"] identifier:nil + handler:^(UIAction *__unused a) { UIPasteboard.generalPasteboard.string = path; }]]; + // Built when the menu opens, so the checkmarks show the current order. + [menuItems addObject:[UIDeferredMenuElement elementWithUncachedProvider:^(void (^completion)(NSArray *)) { + WQSortKey current = wq_sort_key(); + BOOL ascending = wq_sort_ascending(); + NSMutableArray *sortActions = [NSMutableArray new]; + NSArray *titles = @[@"Name", @"Date Modified", @"Date Created", @"Size"]; + for (NSInteger key = WQSortName; key <= WQSortSize; key++) { + UIAction *action = [UIAction actionWithTitle:WQLocalizedNullable(titles[(NSUInteger)key]) image:nil identifier:nil handler:^(UIAction *__unused a) { + wq_choose_sort((WQSortKey)key); + [weakSelf resortAfterChange]; + }]; + if (key == current) { + action.state = UIMenuElementStateOn; + action.subtitle = wq_sort_direction_text((WQSortKey)key, ascending); + } + [sortActions addObject:action]; + } + UIAction *folders = [UIAction actionWithTitle:WQLocalizedNullable(@"Folders on Top") image:nil identifier:nil handler:^(UIAction *__unused a) { + [NSUserDefaults.standardUserDefaults setBool:!wq_folders_first() forKey:kWQFoldersFirstPref]; + [weakSelf resortAfterChange]; + }]; + folders.state = wq_folders_first() ? UIMenuElementStateOn : UIMenuElementStateOff; + UIMenu *order = [UIMenu menuWithTitle:@"" image:nil identifier:nil options:UIMenuOptionsDisplayInline children:@[folders]]; + UIMenu *sort = [UIMenu menuWithTitle:WQLocalizedNullable(@"Sort By") image:[UIImage systemImageNamed:@"arrow.up.arrow.down"] identifier:nil options:0 + children:[sortActions arrayByAddingObject:order]]; + sort.subtitle = WQLocalizedFormat(@"%@, %@", WQLocalizedNullable(titles[(NSUInteger)current]), wq_sort_direction_text(current, ascending)); + completion(@[sort]); + }]]; + _menuItem = [[UIBarButtonItem alloc] initWithImage:[UIImage systemImageNamed:@"ellipsis.circle"] + menu:[UIMenu menuWithChildren:menuItems]]; + if (_backupStorage) { + _selectItem = [[UIBarButtonItem alloc] initWithTitle:WQLocalizedNullable(@"Select") style:UIBarButtonItemStylePlain + target:self action:@selector(enterBackupSelection)]; + _selectItem.enabled = NO; + } else if ([self canEditHere]) { + _fileSelectItem = [[UIBarButtonItem alloc] initWithTitle:WQLocalizedNullable(@"Select") style:UIBarButtonItemStylePlain + target:self action:@selector(enterFileSelection)]; + } + [self restoreRightItems]; + [NSNotificationCenter.defaultCenter addObserver:self selector:@selector(updateToolbar) + name:kWQMoveChanged object:nil]; + [self updateToolbar]; + [self updateHeader]; + UISearchController *search = [[UISearchController alloc] initWithSearchResultsController:nil]; + search.searchResultsUpdater = self; + search.obscuresBackgroundDuringPresentation = NO; + search.searchBar.placeholder = _labels ? WQLocalized(@"Search names and bundle IDs") : WQLocalized(@"Search this folder"); + search.searchBar.autocapitalizationType = UITextAutocapitalizationTypeNone; + search.searchBar.autocorrectionType = UITextAutocorrectionTypeNo; + self.navigationItem.searchController = search; + self.navigationItem.hidesSearchBarWhenScrolling = NO; + self.definesPresentationContext = YES; + [self reload]; +} + +- (void)viewWillAppear:(BOOL)animated +{ + [super viewWillAppear:animated]; + [self updateToolbar]; + if (_scanInFlight) return; + NSTimeInterval age = NSDate.date.timeIntervalSinceReferenceDate - _lastReloadFinished; + if (_scanNeedsRefresh || _lastReloadFinished <= 0 || age > 2.0) [self reload]; +} + +- (void)viewWillDisappear:(BOOL)animated +{ + [super viewWillDisappear:animated]; + if (_selectingBackups) [self exitBackupSelection]; + if (_selectingFiles) [self exitFileSelection]; + if (_scanInFlight) { + _scanNeedsRefresh = YES; + [_scanCancellation cancel]; + } + _scanStatus = nil; + [self.refreshControl endRefreshing]; + [self updateHeader]; +} + +- (void)viewDidDisappear:(BOOL)animated +{ + [super viewDidDisappear:animated]; + if (!self.presentedViewController && self.navigationController.topViewController != self) { + wq_share_cancel(self); + } +} + +// Device strip, then the full path in normal-size text that wraps at the +// slashes (the navigation bar's prompt shrank it until unreadable). Tap to copy. +- (void)updateHeader +{ + CGFloat width = self.tableView.bounds.size.width ?: self.view.bounds.size.width; + if (!_pathLabel) { + _pathLabel = [UILabel new]; + _pathLabel.numberOfLines = 0; + _pathLabel.font = [UIFont monospacedSystemFontOfSize:12 weight:UIFontWeightRegular]; + _pathLabel.semanticContentAttribute = UISemanticContentAttributeForceLeftToRight; + _pathLabel.textAlignment = NSTextAlignmentLeft; // paths read left to right in every language + _pathLabel.textColor = UIColor.secondaryLabelColor; + _pathLabel.userInteractionEnabled = YES; + [_pathLabel addGestureRecognizer:[[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(copyPath)]]; + } + if (!_headerBox) { + _headerBox = wq_device_header(width); + [_headerBox addSubview:_pathLabel]; + self.tableView.tableHeaderView = _headerBox; + } + // Zero-width spaces after each slash let lines break between components. + NSString *breakable = [_path stringByReplacingOccurrencesOfString:@"/" withString:@"/\u200B"]; + _pathLabel.text = _scanStatus ? [NSString stringWithFormat:@"%@\n%@", breakable, _scanStatus] : breakable; + CGFloat side = 16, top = 42; + CGSize fit = [_pathLabel sizeThatFits:CGSizeMake(width - 2 * side, CGFLOAT_MAX)]; + CGFloat height = top + ceil(fit.height) + 10; + CGFloat oldHeight = _headerBox.frame.size.height; + _headerBox.frame = CGRectMake(0, 0, width, height); + _pathLabel.frame = CGRectMake(side, top, width - 2 * side, ceil(fit.height)); + _headerWidth = width; + if (fabs(height - oldHeight) > 0.5) self.tableView.tableHeaderView = _headerBox; +} + +- (void)viewDidLayoutSubviews +{ + [super viewDidLayoutSubviews]; + if (fabs(self.tableView.bounds.size.width - _headerWidth) > 0.5) [self updateHeader]; +} + +- (void)copyPath +{ + UIPasteboard.generalPasteboard.string = _path; + _scanStatus = WQLocalized(@"Path copied"); + [self updateHeader]; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + if ([self->_scanStatus isEqualToString:WQLocalized(@"Path copied")]) { self->_scanStatus = nil; [self updateHeader]; } + }); +} + +- (void)updateSearchResultsForSearchController:(UISearchController *)searchController +{ + NSString *q = [searchController.searchBar.text stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceCharacterSet]; + _query = q.length ? q : nil; + [self applyFilter]; + [self.tableView reloadData]; +} + +// App name, bundle or group ID, or file name; case and accents ignored. +- (void)applyFilter +{ + NSArray *shown = _all ?: @[]; + if (_query) { + NSStringCompareOptions opts = NSCaseInsensitiveSearch | NSDiacriticInsensitiveSearch; + NSString *q = _query; + shown = [shown filteredArrayUsingPredicate:[NSPredicate predicateWithBlock:^BOOL(WQItem *item, NSDictionary *__unused b) { + for (NSString *field in @[item.label ?: @"", item.name ?: @"", item.bundleID ?: @""]) + if ([field rangeOfString:q options:opts].location != NSNotFound) return YES; + return NO; + }]]; + } + _items = wq_sorted_items(shown); + // Alphabet sidebar: only for longer lists sorted by name. + NSMutableArray *titles = [NSMutableArray new]; + NSMutableDictionary *rows = [NSMutableDictionary new]; + if (wq_sort_key() == WQSortName && _items.count >= 25) { + [_items enumerateObjectsUsingBlock:^(WQItem *item, NSUInteger row, BOOL *__unused stop) { + NSString *letter = wq_index_letter(item.label ?: item.name); + if (!rows[letter]) { rows[letter] = @(row); [titles addObject:letter]; } + }]; + } + _indexTitles = titles.count > 1 ? titles : nil; + _indexRows = rows; +} + +- (NSArray *)sectionIndexTitlesForTableView:(UITableView *)tableView +{ + return _selectingBackups || _selectingFiles || _error ? nil : _indexTitles; +} + +- (NSInteger)tableView:(UITableView *)tableView sectionForSectionIndexTitle:(NSString *)title atIndex:(NSInteger)index +{ + NSNumber *row = _indexRows[title]; + if (row && row.unsignedIntegerValue < _items.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 +} + +- (void)resortAfterChange +{ + [self applyFilter]; + [self.tableView reloadData]; + [self.tableView reloadSectionIndexTitles]; +} + +#pragma mark Backup multi-selection + +- (NSArray *)backupUnitPathsInItems +{ + NSMutableArray *paths = [NSMutableArray new]; + for (WQItem *item in _items) + if (wq_backup_unit_deletable(item.path)) [paths addObject:item.path]; + return paths; +} + +- (void)updateBackupSelectionAvailability +{ + _selectItem.enabled = !_selectingBackups && [self backupUnitPathsInItems].count > 0; +} + +- (void)updateBackupSelectionBar +{ + _deleteItem.enabled = _selectedBackups.count > 0; + _allItem.enabled = _selectedBackups.count < [self backupUnitPathsInItems].count; +} + +- (void)enterBackupSelection +{ + if ([self backupUnitPathsInItems].count == 0) { + wq_alert(self, @"Nothing to select", @"Only complete backup folders can be selected here."); + return; + } + _selectingBackups = YES; + [_selectedBackups removeAllObjects]; + if (!_doneItem) { + _doneItem = [[UIBarButtonItem alloc] initWithTitle:WQLocalizedNullable(@"Done") style:UIBarButtonItemStyleProminent + target:self action:@selector(exitBackupSelection)]; + _deleteItem = [[UIBarButtonItem alloc] initWithTitle:WQLocalizedNullable(@"Delete") style:UIBarButtonItemStylePlain + target:self action:@selector(deleteSelectedBackups)]; + _deleteItem.tintColor = UIColor.systemRedColor; + _allItem = [[UIBarButtonItem alloc] initWithImage:[UIImage systemImageNamed:@"checkmark.circle"] + style:UIBarButtonItemStylePlain + target:self action:@selector(selectAllBackups)]; + _allItem.accessibilityLabel = WQLocalizedNullable(@"Select All"); + } + self.refreshControl.enabled = NO; + self.tableView.allowsMultipleSelection = YES; + self.title = WQLocalizedNullable(@"Select Backups"); + self.navigationItem.rightBarButtonItems = @[_allItem, _deleteItem, _doneItem]; + [self updateBackupSelectionBar]; + [self.tableView reloadData]; +} + +- (void)exitBackupSelection +{ + if (!_selectingBackups) return; + _selectingBackups = NO; + [_selectedBackups removeAllObjects]; + self.tableView.allowsMultipleSelection = NO; + self.refreshControl.enabled = YES; + self.title = _baseTitle; + [self restoreRightItems]; + [self.tableView reloadData]; + [self updateBackupSelectionAvailability]; +} + +- (void)selectAllBackups +{ + NSArray *paths = [self backupUnitPathsInItems]; + [_selectedBackups addObjectsFromArray:paths]; + [self updateBackupSelectionBar]; + [self.tableView reloadData]; +} + +- (void)deleteSelectedBackups +{ + if (wq_refuse_if_deleting_off(self)) return; + NSArray *paths = [_selectedBackups.allObjects sortedArrayUsingSelector:@selector(compare:)]; + if (paths.count == 0) return; + NSString *message = paths.count == 1 + ? WQLocalizedFormat(@"This permanently removes %lu complete backup and everything inside it. It cannot be undone.", + (unsigned long)paths.count) + : WQLocalizedFormat(@"This permanently removes %lu complete backups and everything inside them. It cannot be undone.", + (unsigned long)paths.count); + UIAlertController *alert = wq_alert_controller(@"Delete backups?", message, UIAlertControllerStyleAlert); + [alert addAction:wq_alert_action(@"Cancel", UIAlertActionStyleCancel, nil)]; + [alert addAction:wq_alert_action(@"Delete", UIAlertActionStyleDestructive, ^(UIAlertAction *__unused a) { + [self exitBackupSelection]; + [self runIO:^BOOL(NSString **error) { + NSString *first = nil; + for (NSString *path in paths) { + NSString *failure = nil; + if (!wq_delete_backup(path, &failure) && first == nil) + first = failure ?: @"Could not remove a backup."; + } + if (first) { + if (error) *error = [NSString stringWithFormat:@"%@ Some selected backups may still exist.", first]; + return NO; + } + return YES; + } failure:WQLocalizedNullable(@"Cannot delete backups")]; + })]; + [self presentViewController:alert animated:YES completion:nil]; +} + +#pragma mark File selection, Move, Compress, Extract + +// Ordinary folders where files may be created, moved and deleted: not +// read-only areas, not backup storage, not the container lists themselves. +- (BOOL)canEditHere +{ + return !self.readOnly && !_backupStorage && !_labels; +} + +- (void)restoreRightItems +{ + if (_backupStorage && _selectItem) self.navigationItem.rightBarButtonItems = @[_selectItem, _menuItem]; + else if (_fileSelectItem) self.navigationItem.rightBarButtonItems = @[_fileSelectItem, _menuItem]; + else self.navigationItem.rightBarButtonItems = @[_menuItem]; +} + +- (BOOL)wq_wantsToolbar +{ + return _selectingFiles || gWQMovePaths.count > 0; +} + +- (BOOL)canMoveHere +{ + if (![self canEditHere] || _error || !gWQMovePaths.count) return NO; + for (NSString *path in gWQMovePaths) { + if ([path.stringByDeletingLastPathComponent isEqualToString:_path]) return NO; + if ([_path isEqualToString:path] || [_path hasPrefix:[path stringByAppendingString:@"/"]]) return NO; + } + return YES; +} + +- (void)updateToolbar +{ + _fileSelectItem.enabled = !_error && _all.count > 0; + UIBarButtonItem *flex = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemFlexibleSpace target:nil action:nil]; + if (_selectingFiles) { + BOOL any = _selectedFiles.count > 0; + UIBarButtonItem *move = [[UIBarButtonItem alloc] initWithTitle:WQLocalizedNullable(@"Move") style:UIBarButtonItemStylePlain + target:self action:@selector(moveSelectedFiles)]; + UIBarButtonItem *zip = [[UIBarButtonItem alloc] initWithTitle:WQLocalizedNullable(@"Compress") style:UIBarButtonItemStylePlain + target:self action:@selector(compressSelectedFiles)]; + UIBarButtonItem *del = [[UIBarButtonItem alloc] initWithTitle:WQLocalizedNullable(@"Delete") style:UIBarButtonItemStylePlain + target:self action:@selector(deleteSelectedFiles)]; + del.tintColor = UIColor.systemRedColor; + move.enabled = zip.enabled = del.enabled = any; + self.toolbarItems = wq_deleting_allowed() ? @[move, flex, zip, flex, del] : @[move, flex, zip]; + } else if (gWQMovePaths.count) { + UIBarButtonItem *cancel = [[UIBarButtonItem alloc] initWithTitle:WQLocalizedNullable(@"Cancel") style:UIBarButtonItemStylePlain + target:self action:@selector(cancelMove)]; + UILabel *label = [UILabel new]; + label.font = [UIFont preferredFontForTextStyle:UIFontTextStyleFootnote]; + label.textColor = UIColor.secondaryLabelColor; + label.text = gWQMovePaths.count == 1 + ? WQLocalizedFormat(@"Moving “%@”", gWQMovePaths.firstObject.lastPathComponent) + : WQLocalizedFormat(@"Moving %lu items", (unsigned long)gWQMovePaths.count); + label.lineBreakMode = NSLineBreakByTruncatingMiddle; + [label sizeToFit]; + CGRect frame = label.frame; + frame.size.width = MIN(frame.size.width, 180); + label.frame = frame; + UIBarButtonItem *info = [[UIBarButtonItem alloc] initWithCustomView:label]; + UIBarButtonItem *here = [[UIBarButtonItem alloc] initWithTitle:WQLocalizedNullable(@"Move Here") style:UIBarButtonItemStyleProminent + target:self action:@selector(moveHere)]; + here.enabled = [self canMoveHere]; + self.toolbarItems = @[cancel, flex, info, flex, here]; + } else { + self.toolbarItems = nil; + } + if (self.navigationController.topViewController == self) + [self.navigationController setToolbarHidden:![self wq_wantsToolbar] animated:YES]; +} + +- (void)updateFileSelectionTitle +{ + self.title = _selectedFiles.count + ? WQLocalizedFormat(@"%lu Selected", (unsigned long)_selectedFiles.count) : WQLocalized(@"Select Items"); + _fileAllItem.enabled = _selectedFiles.count < _items.count; + [self updateToolbar]; +} + +- (void)enterFileSelection +{ + if (_error || _items.count == 0) return; + _selectingFiles = YES; + [_selectedFiles removeAllObjects]; + if (!_fileDoneItem) { + _fileDoneItem = [[UIBarButtonItem alloc] initWithTitle:WQLocalizedNullable(@"Done") style:UIBarButtonItemStyleProminent + target:self action:@selector(exitFileSelection)]; + _fileAllItem = [[UIBarButtonItem alloc] initWithImage:[UIImage systemImageNamed:@"checkmark.circle"] + style:UIBarButtonItemStylePlain + target:self action:@selector(selectAllFiles)]; + _fileAllItem.accessibilityLabel = WQLocalizedNullable(@"Select All"); + } + self.refreshControl.enabled = NO; + self.navigationItem.rightBarButtonItems = @[_fileDoneItem, _fileAllItem]; + [self updateFileSelectionTitle]; + [self.tableView reloadData]; + [self.tableView reloadSectionIndexTitles]; +} + +- (void)exitFileSelection +{ + if (!_selectingFiles) return; + _selectingFiles = NO; + [_selectedFiles removeAllObjects]; + self.refreshControl.enabled = YES; + self.title = _baseTitle; + [self restoreRightItems]; + [self updateToolbar]; + [self.tableView reloadData]; + [self.tableView reloadSectionIndexTitles]; +} + +- (void)selectAllFiles +{ + for (WQItem *item in _items) [_selectedFiles addObject:item.path]; + [self updateFileSelectionTitle]; + [self.tableView reloadData]; +} + +- (NSArray *)selectedFilePaths +{ + return _selectedFiles.array.copy; +} + +- (void)moveSelectedFiles +{ + NSArray *paths = [self selectedFilePaths]; + if (!paths.count) return; + [self exitFileSelection]; + [self startMove:paths]; +} + +- (void)startMove:(NSArray *)paths +{ + wq_set_move_paths(paths); + wq_alert(self, paths.count == 1 ? WQLocalized(@"Move") : WQLocalizedFormat(@"Move %lu items", (unsigned long)paths.count), + @"Open the folder you want to move to, then tap Move Here at the bottom. Any app's folder works."); +} + +- (void)cancelMove +{ + wq_set_move_paths(nil); +} + +- (void)moveHere +{ + NSArray *paths = gWQMovePaths; + if (![self canMoveHere]) return; + NSString *dest = _path; + wq_set_move_paths(nil); + [self runIO:^BOOL(NSString **error) { + NSUInteger failed = 0; + NSString *first = nil; + for (NSString *path in paths) { + NSString *e = nil; + if (!wq_move(path, dest, &e)) { + failed++; + if (!first) first = [NSString stringWithFormat:@"%@: %@", path.lastPathComponent, e ?: @"failed"]; + } + } + if (failed && error) { + *error = paths.count == 1 ? first + : [NSString stringWithFormat:@"%lu of %lu items weren't moved.\n\n%@", (unsigned long)failed, (unsigned long)paths.count, first]; + } + return failed == 0; + } failure:WQLocalizedNullable(@"Cannot move")]; +} + +- (void)deleteSelectedFiles +{ + if (wq_refuse_if_deleting_off(self)) return; + NSArray *paths = [self selectedFilePaths]; + if (!paths.count) return; + NSString *title = paths.count == 1 ? WQLocalizedFormat(@"Delete “%@”?", paths.firstObject.lastPathComponent) + : WQLocalizedFormat(@"Delete %lu items?", (unsigned long)paths.count); + UIAlertController *alert = wq_alert_controller(title, @"Files are copied to Backups first. Folders are deleted only when they are empty.", UIAlertControllerStyleAlert); + [alert addAction:wq_alert_action(@"Cancel", UIAlertActionStyleCancel, nil)]; + [alert addAction:wq_alert_action(@"Delete", UIAlertActionStyleDestructive, ^(UIAlertAction *__unused a) { + [self exitFileSelection]; + [self runIO:^BOOL(NSString **error) { + NSUInteger failed = 0; + NSString *first = nil; + for (NSString *path in paths) { + NSString *e = nil; + if (!wq_delete(path, &e)) { + failed++; + if (!first) first = [NSString stringWithFormat:@"%@: %@", path.lastPathComponent, e ?: @"failed"]; + } + } + if (failed && error) { + *error = paths.count == 1 ? first + : [NSString stringWithFormat:@"%lu of %lu items weren't deleted.\n\n%@", (unsigned long)failed, (unsigned long)paths.count, first]; + } + return failed == 0; + } failure:WQLocalizedNullable(@"Cannot delete")]; + })]; + [self presentViewController:alert animated:YES completion:nil]; +} + +// "Name.zip", or "Name 2.zip" when that is taken in this folder. +- (NSString *)unusedName:(NSString *)base extension:(NSString *)extension +{ + NSMutableSet *taken = [NSMutableSet new]; + for (WQItem *item in _all) [taken addObject:item.name.lowercaseString]; + for (NSUInteger n = 1; n < 10000; n++) { + NSString *stem = n == 1 ? base : [NSString stringWithFormat:@"%@ %lu", base, (unsigned long)n]; + NSString *name = extension.length ? [stem stringByAppendingPathExtension:extension] : stem; + if (![taken containsObject:name.lowercaseString]) return name; + } + return [NSString stringWithFormat:@"%@ %@", base, NSUUID.UUID.UUIDString]; +} + +// Runs a long ZIP job with a progress alert that can cancel it. +- (void)runArchiveJob:(NSString *)title failure:(NSString *)failureTitle + work:(BOOL (^)(WQArchiveCancelled cancelled, WQArchiveProgress progress, NSString **error, NSString **note))work +{ + WQScanCancellation *cancellation = [WQScanCancellation new]; + UIAlertController *hud = wq_alert_controller(title, @"Starting…", UIAlertControllerStyleAlert); + [hud addAction:wq_alert_action(@"Cancel", UIAlertActionStyleCancel, ^(UIAlertAction *__unused a) { [cancellation cancel]; })]; + [self presentViewController:hud animated:YES completion:nil]; + __block CFAbsoluteTime lastUpdate = 0; + WQArchiveProgress progress = ^(unsigned long long bytes) { + CFAbsoluteTime now = CFAbsoluteTimeGetCurrent(); + if (now - lastUpdate < 0.25) return; + lastUpdate = now; + NSString *text = wq_size_text(bytes); + dispatch_async(dispatch_get_main_queue(), ^{ hud.message = text; }); + }; + WQArchiveCancelled cancelled = ^BOOL { return cancellation.isCancelled; }; + dispatch_async(wq_io(), ^{ + NSString *error = nil, *note = nil; + BOOL ok = work(cancelled, progress, &error, ¬e); + BOOL wasCancelled = cancellation.isCancelled; + dispatch_async(dispatch_get_main_queue(), ^{ + void (^finish)(void) = ^{ + if (!ok && !wasCancelled) wq_alert(self, failureTitle, error ?: @"Unknown error"); + else if (ok && note) wq_alert(self, title, note); + [self reload]; + }; + if (self.presentedViewController == hud) [self dismissViewControllerAnimated:YES completion:finish]; + else finish(); + }); + }); +} + +- (void)compressSelectedFiles +{ + NSArray *paths = [self selectedFilePaths]; + if (!paths.count) return; + [self exitFileSelection]; + [self compressPaths:paths]; +} + +- (void)compressPaths:(NSArray *)paths +{ + NSString *base = paths.count == 1 ? paths.firstObject.lastPathComponent : @"Archive"; + NSString *zipName = [self unusedName:base extension:@"zip"]; + NSString *dest = [_path stringByAppendingPathComponent:zipName]; + [self runArchiveJob:WQLocalized(@"Compressing") failure:WQLocalizedNullable(@"Cannot compress") + work:^BOOL(WQArchiveCancelled cancelled, WQArchiveProgress progress, NSString **error, NSString **note) { + NSString *work = wq_temp_work_dir(@"wq-zip"); + if (!work) { *error = @"Can't create a temporary folder"; return NO; } + NSString *local = [work stringByAppendingPathComponent:zipName]; + NSUInteger skipped = 0; + BOOL ok = wq_zip_write(paths, local, wq_app_archive_io(), cancelled, progress, &skipped, error); + if (ok && cancelled()) ok = NO; + if (ok) ok = wq_create_file_from(dest, local, error); + [NSFileManager.defaultManager removeItemAtPath:work error:nil]; + wq_log(@"compress: %lu item(s) -> %@: %@", (unsigned long)paths.count, dest, ok ? @"OK" : *error); + if (ok && skipped) + *note = skipped == 1 + ? WQLocalizedFormat(@"Created %@. %lu symbolic link or special file was left out.", + zipName, (unsigned long)skipped) + : WQLocalizedFormat(@"Created %@. %lu symbolic links or special files were left out.", + zipName, (unsigned long)skipped); + return ok; + }]; +} + +- (void)extractZip:(NSString *)zipPath +{ + NSString *base = zipPath.lastPathComponent.stringByDeletingPathExtension; + NSString *folder = [self unusedName:base.length ? base : @"Archive" extension:nil]; + NSString *dest = [_path stringByAppendingPathComponent:folder]; + [self runArchiveJob:WQLocalized(@"Extracting") failure:WQLocalizedNullable(@"Cannot extract") + work:^BOOL(WQArchiveCancelled cancelled, WQArchiveProgress progress, NSString **error, NSString **note) { + NSString *work = wq_temp_work_dir(@"wq-unzip"); + if (!work) { *error = @"Can't create a temporary folder"; return NO; } + NSString *local = [work stringByAppendingPathComponent:folder]; + [NSFileManager.defaultManager createDirectoryAtPath:local withIntermediateDirectories:NO attributes:nil error:nil]; + // The files exist twice for a moment (temporary copy, then in place). + struct statfs fs; + unsigned long long available = statfs(work.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; + NSUInteger files = 0, skipped = 0; + BOOL ok = wq_zip_extract(zipPath, local, wq_app_archive_io(), limit, cancelled, progress, &files, &skipped, error); + if (ok && cancelled()) ok = NO; + if (ok) ok = wq_create_dir(dest, error); + if (ok && !wq_copy_contents(local, dest, error)) { + ok = NO; + *error = WQLocalizedFormat(@"%@\n\nSome files may already be in %@.", *error ?: WQLocalized(@"Copying failed."), folder); + } + [NSFileManager.defaultManager removeItemAtPath:work error:nil]; + wq_log(@"extract: %@ -> %@: %@ (%lu files, %lu skipped)", zipPath, dest, ok ? @"OK" : *error, + (unsigned long)files, (unsigned long)skipped); + if (ok && skipped) { + NSString *key; + if (files == 1 && skipped == 1) + key = @"Extracted %lu file into %@. %lu symbolic link in the ZIP was left out."; + else if (files == 1) + key = @"Extracted %lu file into %@. %lu symbolic links in the ZIP were left out."; + else if (skipped == 1) + key = @"Extracted %lu files into %@. %lu symbolic link in the ZIP was left out."; + else + key = @"Extracted %lu files into %@. %lu symbolic links in the ZIP were left out."; + *note = WQLocalizedFormat(key, (unsigned long)files, folder, (unsigned long)skipped); + } + return ok; + }]; +} + +// Pull to refresh: container lists are scanned again instead of reused. +- (void)userRefresh +{ + wq_enumerate_invalidate(); + wq_display_names_reset_miss(); + [self reload]; +} + +- (void)reload +{ + [_scanCancellation cancel]; + WQScanCancellation *cancellation = [WQScanCancellation new]; + _scanCancellation = cancellation; + NSUInteger generation = ++_scanGeneration; + _scanInFlight = YES; + _scanNeedsRefresh = NO; + _scanStatus = nil; + [self.refreshControl endRefreshing]; + [self updateHeader]; + NSString *path = _path; + BOOL labels = _labels; + BOOL enumerable = [@[@"/private/var/mobile/Containers/Data/Application", + @"/var/mobile/Containers/Data/Application", + @"/private/var/mobile/Containers/Data/PluginKitPlugin", + @"/var/mobile/Containers/Data/PluginKitPlugin", + @"/private/var/mobile/Containers/Data/InternalDaemon", + @"/var/mobile/Containers/Data/InternalDaemon", + // Listable on iOS 27; on iOS 26 only by scanning. + @"/private/var/mobile/Containers/Shared/AppGroup", + @"/var/mobile/Containers/Shared/AppGroup"] containsObject:path]; + dispatch_async(wq_io(), ^{ + NSString *error = nil; + NSArray *names = wq_list(path, &error); + NSArray *childPaths = nil; + // App bundles: the app database knows every installed app's bundle + // container, so no scan is needed when the folder can't be listed. + BOOL bundleRoot = [path isEqualToString:@"/private/var/containers/Bundle/Application"] || + [path isEqualToString:@"/var/containers/Bundle/Application"]; + if (!names && bundleRoot && !cancellation.isCancelled) { + NSArray *bundles = wq_ls_bundle_containers(); + if (bundles.count) childPaths = bundles; + else wq_log(@"app bundles: can't list %@ (%@) and the app database lists no bundles", path, error ?: @"no reason"); + } + // Parent gives no token on 27.x: discover children with fsgetpath. + if (!names && !childPaths && enumerable && !cancellation.isCancelled) { + dispatch_async(dispatch_get_main_queue(), ^{ + if (generation != self->_scanGeneration || cancellation.isCancelled) return; + self->_scanStatus = WQLocalized(@"Scanning inodes…"); + [self updateHeader]; + }); + CFAbsoluteTime scanStarted = CFAbsoluteTimeGetCurrent(); + childPaths = wq_enumerate(path, 0, cancellation, ^(int64_t done, int64_t total, NSUInteger __unused f) { + // Time left, once the rate is known: an iOS 26 id scan can take most of an hour. + double elapsed = CFAbsoluteTimeGetCurrent() - scanStarted; + NSString *left = @""; + if (elapsed > 5 && done > 0 && done < total) { + double remaining = elapsed * (double)(total - done) / (double)done; + left = remaining >= 90 ? WQLocalizedFormat(@" · about %.0f min left", ceil(remaining / 60)) + : WQLocalized(@" · less than 2 min left"); + } + dispatch_async(dispatch_get_main_queue(), ^{ + if (generation != self->_scanGeneration || cancellation.isCancelled) return; + self->_scanStatus = WQLocalizedFormat(@"Scanning inodes… %lld%% · %lu found%@", total ? (long long)(done * 100 / total) : 0, (unsigned long)f, left); + [self updateHeader]; + }); + }); + dispatch_async(dispatch_get_main_queue(), ^{ + if (generation != self->_scanGeneration || cancellation.isCancelled) return; + self->_scanStatus = nil; + [self updateHeader]; + }); + } + if (cancellation.isCancelled) { + dispatch_async(dispatch_get_main_queue(), ^{ + if (generation != self->_scanGeneration) return; + self->_scanInFlight = NO; + self->_scanNeedsRefresh = YES; + self->_scanStatus = nil; + [self.refreshControl endRefreshing]; + [self updateHeader]; + }); + return; + } + NSMutableArray *items = [NSMutableArray new]; + NSUInteger labelIssues = 0; + NSArray *entries = names ?: childPaths; + BOOL haveFullPaths = names == nil; + NSUInteger entryIndex = 0, entryCount = entries.count; + for (NSString *entry in entries) { + entryIndex++; + // Each container needs a token and its identifier: hundreds take a + // few seconds, so show where it is. + if (haveFullPaths && (entryIndex == 1 || entryIndex % 10 == 0)) { + NSUInteger shown = entryIndex; + dispatch_async(dispatch_get_main_queue(), ^{ + if (generation != self->_scanGeneration || cancellation.isCancelled) return; + self->_scanStatus = WQLocalizedFormat(@"Reading containers… %lu of %lu", (unsigned long)shown, (unsigned long)entryCount); + [self updateHeader]; + }); + } + if (cancellation.isCancelled) { + dispatch_async(dispatch_get_main_queue(), ^{ + if (generation != self->_scanGeneration) return; + self->_scanInFlight = NO; + self->_scanNeedsRefresh = YES; + self->_scanStatus = nil; + [self.refreshControl endRefreshing]; + [self updateHeader]; + }); + return; + } + WQItem *item = [WQItem new]; + item.name = haveFullPaths ? entry.lastPathComponent : entry; + item.path = haveFullPaths ? entry : [path stringByAppendingPathComponent:entry]; + NSString *name = item.name; + // Enumerated containers need a token before they can be stat'd. + if (haveFullPaths) { + item.isDir = YES; + BOOL token = wq_grant(item.path); + NSString *bid = token ? wq_container_label(item.path) : nil; + if (!bid && bundleRoot) bid = wq_bundle_id_for_container(item.path); + item.bundleID = bid; + if (bid) (void)wq_app_icon(bid); // warm the icon off the main thread + item.label = token || bid ? (wq_display_name(bid) ?: bid ?: WQLocalized(@"(no label)")) : WQLocalized(@"NO TOKEN"); + if (!token && labelIssues < 3) { + wq_log(@"label: no token for %@", item.path); + labelIssues++; + } else if (token && !bid.length && labelIssues < 3) { + // Once per container per launch; refreshes would repeat it. + static NSMutableSet *reported; + @synchronized ([WQItem class]) { + if (!reported) reported = [NSMutableSet new]; + if (![reported containsObject:item.path]) { + [reported addObject:item.path]; + wq_log(@"label: no container identifier for %@", item.path); + } + } + labelIssues++; + } + } + struct stat st; + if (lstat(item.path.fileSystemRepresentation, &st) == 0) { + item.isLink = S_ISLNK(st.st_mode); + item.isDir = S_ISDIR(st.st_mode); + item.isRegular = S_ISREG(st.st_mode); + item.size = (unsigned long long)st.st_size; + item.mode = st.st_mode; + item.uid = st.st_uid; + item.modified = [NSDate dateWithTimeIntervalSince1970:st.st_mtimespec.tv_sec]; + item.created = [NSDate dateWithTimeIntervalSince1970:st.st_birthtimespec.tv_sec]; + } + if (!haveFullPaths && labels && item.isDir && wq_looks_like_uuid(name)) { + NSString *bid = wq_container_label(item.path); + item.bundleID = bid; + if (bid) (void)wq_app_icon(bid); // warm the icon off the main thread + item.label = wq_display_name(bid) ?: bid; + } + if (!haveFullPaths && item.isDir && !item.bundleID) { + // App backups: AppBackups//... + if ([path.lastPathComponent isEqualToString:@"AppBackups"]) { + item.bundleID = name; + item.label = wq_display_name(name) ?: name; + } + // File backups mirror the original path; label container UUIDs + // from the live container they came from. + NSString *live = wq_backup_original_path(item.path); + if (live && wq_looks_like_uuid(name)) { + NSString *bid = wq_container_label(live); + if (bid) { item.bundleID = bid; item.label = wq_display_name(bid) ?: bid; (void)wq_app_icon(bid); } + } + if ([path isEqualToString:wq_backup_root().stringByDeletingLastPathComponent]) { + if ([name isEqualToString:@"Backups"]) item.label = WQLocalized(@"File Backups"); + if ([name isEqualToString:@"AppBackups"]) item.label = WQLocalized(@"App Backups"); + } + } + [items addObject:item]; + } + dispatch_async(dispatch_get_main_queue(), ^{ + if (generation != self->_scanGeneration) return; + self->_scanInFlight = NO; + if (cancellation.isCancelled) { + self->_scanNeedsRefresh = YES; + self->_scanStatus = nil; + [self.refreshControl endRefreshing]; + [self updateHeader]; + return; + } + self->_all = items; + [self applyFilter]; + [self countChildrenOf:items generation:generation cancellation:cancellation]; + self->_error = (names || childPaths != nil) ? nil + : (error.length ? error + : !wq_app_group() ? 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).") + : wq_full_scan_was_declined() ? WQLocalized(@"Not listed yet: this needs a scan of all files. Pull down to start it.") + : WQLocalized(@"Cannot list this folder. Container enumeration failed; see Log.")); + self->_scanStatus = nil; + self->_lastReloadFinished = NSDate.date.timeIntervalSinceReferenceDate; + [self.refreshControl endRefreshing]; + [self updateHeader]; + [self.tableView reloadData]; + [self updateBackupSelectionAvailability]; + if (self->_selectingBackups) [self updateBackupSelectionBar]; + if (self->_selectingFiles) { + NSMutableSet *present = [NSMutableSet new]; + for (WQItem *item in items) [present addObject:item.path]; + for (NSString *path in self->_selectedFiles.array.copy) + if (![present containsObject:path]) [self->_selectedFiles removeObject:path]; + [self updateFileSelectionTitle]; + } + [self updateToolbar]; + }); + }); +} + +// Show Item Counts: list each subfolder in the background after the folder +// appears; visible rows update as counts arrive. Container lists are left out +// (hundreds of other apps' folders). +- (void)countChildrenOf:(NSArray *)items generation:(NSUInteger)generation + cancellation:(WQScanCancellation *)cancellation +{ + if (!wq_show_item_counts() || _labels) return; + static dispatch_queue_t queue; + static dispatch_once_t once; + dispatch_once(&once, ^{ queue = dispatch_queue_create("wq.item-counts", DISPATCH_QUEUE_SERIAL); }); + __weak typeof(self) weakSelf = self; + void (^refresh)(void) = ^{ + WQDirViewController *strongSelf = weakSelf; + if (!strongSelf || generation != strongSelf->_scanGeneration || strongSelf->_error) return; + NSArray *visible = strongSelf.tableView.indexPathsForVisibleRows; + if (visible.count) [strongSelf.tableView reconfigureRowsAtIndexPaths:visible]; + }; + dispatch_async(queue, ^{ + CFAbsoluteTime lastRefresh = CFAbsoluteTimeGetCurrent(); + for (WQItem *item in items) { + if (cancellation.isCancelled || !weakSelf) return; + if (!item.isDir || item.isLink) continue; + NSArray *names = wq_list(item.path, NULL); + NSInteger count = names ? (NSInteger)names.count : -1; + dispatch_async(dispatch_get_main_queue(), ^{ item.childCount = count; }); + if (CFAbsoluteTimeGetCurrent() - lastRefresh > 0.3) { + lastRefresh = CFAbsoluteTimeGetCurrent(); + dispatch_async(dispatch_get_main_queue(), refresh); + } + } + dispatch_async(dispatch_get_main_queue(), refresh); + }); +} + +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section +{ + return _error ? 1 : (NSInteger)_items.count; +} + +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath +{ + UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"item"] + ?: [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleSubtitle reuseIdentifier:@"item"]; + UIListContentConfiguration *c = cell.defaultContentConfiguration; + if (_error) { + c.text = WQLocalizedNullable(@"Cannot open this folder"); + BOOL container = [_path containsString:@"/Containers/"] || [_path containsString:@"/SystemGroup/"]; + c.secondaryText = container ? _error + : WQLocalizedFormat(@"%@\n\nThis method only opens containers. Use App Data, App Groups, Extension Data, Internal Daemons or System Groups for the folders inside /private/var.", _error); + c.secondaryTextProperties.numberOfLines = 0; + c.image = [UIImage systemImageNamed:@"exclamationmark.triangle"]; + cell.contentConfiguration = c; + cell.accessoryType = UITableViewCellAccessoryNone; + return cell; + } + WQItem *item = _items[(NSUInteger)indexPath.row]; + c.text = item.label ?: item.name; + BOOL byCreated = wq_sort_key() == WQSortCreated; + NSString *date = wq_folder_date_text(byCreated ? item.created : item.modified); + NSString *detail = item.isDir ? date : WQLocalizedFormat(@"%@ · %@", wq_size_text(item.size), date); + if (item.isDir && item.childCount >= 0) { + NSString *items = item.childCount == 1 + ? WQLocalizedFormat(@"%ld item", (long)item.childCount) + : WQLocalizedFormat(@"%ld items", (long)item.childCount); + detail = WQLocalizedFormat(@"%@ · %@", date, items); + } + // Container lists: the bundle id (issue #27); the folder's UUID is in its path. + if (item.label) detail = WQLocalizedFormat(@"%@\n%@", item.bundleID.length ? item.bundleID : item.name, detail); + if (item.isLink) detail = [WQLocalized(@"symlink · ") stringByAppendingString:detail]; + c.secondaryText = detail; + c.secondaryTextProperties.color = UIColor.secondaryLabelColor; + UIImage *appIcon = item.bundleID ? wq_app_icon(item.bundleID) : nil; + c.image = appIcon ?: [UIImage systemImageNamed:item.isDir ? @"folder.fill" : (item.isLink ? @"link" : @"doc")]; + if (appIcon) c.imageProperties.maximumSize = CGSizeMake(36, 36); + cell.contentConfiguration = c; + if (_selectingFiles) { + cell.accessoryType = [_selectedFiles containsObject:item.path] + ? UITableViewCellAccessoryCheckmark : UITableViewCellAccessoryNone; + cell.selectionStyle = UITableViewCellSelectionStyleDefault; + } else if (_selectingBackups) { + BOOL selectable = wq_backup_unit_deletable(item.path); + cell.accessoryType = selectable && [_selectedBackups containsObject:item.path] + ? UITableViewCellAccessoryCheckmark : UITableViewCellAccessoryNone; + cell.selectionStyle = selectable ? UITableViewCellSelectionStyleDefault : UITableViewCellSelectionStyleNone; + } else { + cell.accessoryType = item.isDir ? UITableViewCellAccessoryDisclosureIndicator : UITableViewCellAccessoryNone; + cell.selectionStyle = UITableViewCellSelectionStyleDefault; + } + return cell; +} + +- (NSIndexPath *)tableView:(UITableView *)tableView willSelectRowAtIndexPath:(NSIndexPath *)indexPath +{ + if (_error || indexPath.section != 0 || (NSUInteger)indexPath.row >= _items.count) return nil; + if (!_selectingBackups) return indexPath; + WQItem *item = _items[(NSUInteger)indexPath.row]; + return wq_backup_unit_deletable(item.path) ? indexPath : nil; +} + +- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath +{ + if (_selectingFiles) { + [tableView deselectRowAtIndexPath:indexPath animated:NO]; + if (indexPath.section != 0 || (NSUInteger)indexPath.row >= _items.count) return; + NSString *path = _items[(NSUInteger)indexPath.row].path; + if ([_selectedFiles containsObject:path]) [_selectedFiles removeObject:path]; + else [_selectedFiles addObject:path]; + [tableView reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone]; + [self updateFileSelectionTitle]; + return; + } + if (_selectingBackups) { + if (indexPath.section != 0 || (NSUInteger)indexPath.row >= _items.count) return; + WQItem *selected = _items[(NSUInteger)indexPath.row]; + if (!wq_backup_unit_deletable(selected.path)) return; + [_selectedBackups addObject:selected.path]; + [tableView reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone]; + [self updateBackupSelectionBar]; + return; + } + [tableView deselectRowAtIndexPath:indexPath animated:YES]; + if (_error) return; + if (indexPath.section != 0 || (NSUInteger)indexPath.row >= _items.count) return; + WQItem *item = _items[(NSUInteger)indexPath.row]; + UIViewController *next; + if (item.isLink) { + wq_alert(self, @"Symbolic link", + @"WQ Files does not follow symbolic links for viewing, sharing or editing. Use Copy Path to open the target explicitly."); + return; + } + if (item.isDir) { + WQDirViewController *dir = [[WQDirViewController alloc] initWithPath:item.path title:item.label ?: item.name]; + dir.readOnly = self.readOnly; + next = dir; + } else { + if (!item.isRegular) { + wq_alert(self, @"Cannot open this item", item.isLink + ? @"Symbolic links are not followed for viewing, sharing or editing." + : @"Only regular files can be viewed."); + return; + } + _scanNeedsRefresh = YES; + wq_open_file(self.navigationController, item.path, self.readOnly, WQOpenAuto); + return; + } + [self.navigationController pushViewController:next animated:YES]; +} + +- (void)tableView:(UITableView *)tableView didDeselectRowAtIndexPath:(NSIndexPath *)indexPath +{ + if (!_selectingBackups) return; + if (indexPath.section != 0 || (NSUInteger)indexPath.row >= _items.count) return; + WQItem *item = _items[(NSUInteger)indexPath.row]; + [_selectedBackups removeObject:item.path]; + [tableView reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone]; + [self updateBackupSelectionBar]; +} + +// Swipe left to delete, with the same rules and confirmation as the +// long-press menu (files are backed up first; backups only as whole units). +- (UISwipeActionsConfiguration *)deleteSwipeForRowAtIndexPath:(NSIndexPath *)indexPath +{ + if (_selectingBackups || _selectingFiles || _error || !wq_deleting_allowed()) return nil; + if (indexPath.section != 0 || (NSUInteger)indexPath.row >= _items.count) return nil; + WQItem *item = _items[(NSUInteger)indexPath.row]; + BOOL backupUnit = item.isDir && wq_backup_unit_deletable(item.path); + BOOL regular = !self.readOnly && !_backupStorage; + if (!backupUnit && !regular) return nil; + NSString *itemPath = [item.path copy]; + __weak typeof(self) weakSelf = self; + UIContextualAction *action = [UIContextualAction contextualActionWithStyle:UIContextualActionStyleDestructive + title:WQLocalizedNullable(@"Delete") handler:^(UIContextualAction *__unused a, UIView *__unused view, void (^done)(BOOL)) { + WQDirViewController *strongSelf = weakSelf; + WQItem *current = nil; + if (strongSelf && (NSUInteger)indexPath.row < strongSelf->_items.count && + [strongSelf->_items[(NSUInteger)indexPath.row].path isEqualToString:itemPath]) + current = strongSelf->_items[(NSUInteger)indexPath.row]; + // The row stays until the confirmed delete reloads the folder. + done(NO); + if (!current) return; + if (backupUnit) [strongSelf confirmDeleteBackup:current]; + else [strongSelf confirmDelete:current]; + }]; + action.image = [UIImage systemImageNamed:@"trash"]; + UISwipeActionsConfiguration *config = [UISwipeActionsConfiguration configurationWithActions:@[action]]; + config.performsFirstActionWithFullSwipe = YES; + return config; +} + +- (UISwipeActionsConfiguration *)tableView:(UITableView *)tableView + trailingSwipeActionsConfigurationForRowAtIndexPath:(NSIndexPath *)indexPath +{ + return [self deleteSwipeForRowAtIndexPath:indexPath]; +} + +- (UISwipeActionsConfiguration *)tableView:(UITableView *)tableView + leadingSwipeActionsConfigurationForRowAtIndexPath:(NSIndexPath *)indexPath +{ + // Delete is on the left swipe only (issue #36). + return [UISwipeActionsConfiguration configurationWithActions:@[]]; +} + +- (UIContextMenuConfiguration *)tableView:(UITableView *)tableView + contextMenuConfigurationForRowAtIndexPath:(NSIndexPath *)indexPath point:(CGPoint)point +{ + if (_selectingBackups || _selectingFiles || _error) return nil; + if (indexPath.section != 0 || (NSUInteger)indexPath.row >= _items.count) return nil; + WQItem *item = _items[(NSUInteger)indexPath.row]; + NSString *itemPath = [item.path copy]; + __weak typeof(self) weakSelf = self; + WQItem *(^currentItem)(void) = ^WQItem *{ + WQDirViewController *strongSelf = weakSelf; + if (!strongSelf || indexPath.section != 0 || (NSUInteger)indexPath.row >= strongSelf->_items.count) return nil; + WQItem *current = strongSelf->_items[(NSUInteger)indexPath.row]; + return [current.path isEqualToString:itemPath] ? current : nil; + }; + return [UIContextMenuConfiguration configurationWithIdentifier:nil previewProvider:nil + actionProvider:^UIMenu *(NSArray *__unused suggested) { + WQItem *current = currentItem(); + WQDirViewController *strongSelf = weakSelf; + if (!current || !strongSelf) return nil; + NSMutableArray *actions = [NSMutableArray new]; + if (current.isDir) { + [actions addObject:[UIAction actionWithTitle:WQLocalizedNullable(@"Share as ZIP (AirDrop…)") image:[UIImage systemImageNamed:@"square.and.arrow.up"] + identifier:nil handler:^(UIAction *__unused a) { + WQItem *latest = currentItem(); + UITableViewCell *cell = [weakSelf.tableView cellForRowAtIndexPath:indexPath]; + if (latest && weakSelf) wq_share_folder(weakSelf, latest.path, nil, cell); + }]]; + } + if (!current.isDir && current.isRegular) { + [actions addObject:[UIAction actionWithTitle:WQLocalizedNullable(@"Share") image:[UIImage systemImageNamed:@"square.and.arrow.up"] + identifier:nil handler:^(UIAction *__unused a) { + WQItem *latest = currentItem(); + UITableViewCell *cell = [weakSelf.tableView cellForRowAtIndexPath:indexPath]; + if (latest && weakSelf) wq_share(weakSelf, latest.path, cell); + }]]; + NSString *original = wq_backup_original_path(current.path); + if (original) { + [actions addObject:[UIAction actionWithTitle:WQLocalizedNullable(@"Restore to Original Location") + image:[UIImage systemImageNamed:@"arrow.uturn.backward"] + identifier:nil handler:^(UIAction *__unused a) { + WQItem *latest = currentItem(); + if (latest && weakSelf) [weakSelf restoreBackup:latest.path to:original]; + }]]; + } + } + if (!current.isDir && current.isRegular) { + BOOL ro = weakSelf.readOnly; + [actions addObject:[UIMenu menuWithTitle:WQLocalizedNullable(@"Open As") image:[UIImage systemImageNamed:@"arrow.up.forward.app"] identifier:nil + options:0 children:@[ + [UIAction actionWithTitle:WQLocalizedNullable(@"Plist Editor") image:[UIImage systemImageNamed:@"list.bullet.indent"] identifier:nil + handler:^(UIAction *__unused a) { WQDirViewController *strongSelf = weakSelf; WQItem *latest = currentItem(); if (strongSelf && latest) wq_open_file(strongSelf.navigationController, latest.path, ro, WQOpenPlist); }], + [UIAction actionWithTitle:WQLocalizedNullable(@"Text / Image") image:[UIImage systemImageNamed:@"doc.plaintext"] identifier:nil + handler:^(UIAction *__unused a) { WQDirViewController *strongSelf = weakSelf; WQItem *latest = currentItem(); if (strongSelf && latest) wq_open_file(strongSelf.navigationController, latest.path, ro, WQOpenText); }], + [UIAction actionWithTitle:WQLocalizedNullable(@"Hex Viewer") image:[UIImage systemImageNamed:@"number"] identifier:nil + handler:^(UIAction *__unused a) { WQDirViewController *strongSelf = weakSelf; WQItem *latest = currentItem(); if (strongSelf && latest) wq_open_file(strongSelf.navigationController, latest.path, ro, WQOpenHex); }], + [UIAction actionWithTitle:WQLocalizedNullable(@"SQLite Viewer") image:[UIImage systemImageNamed:@"tablecells"] identifier:nil + handler:^(UIAction *__unused a) { WQDirViewController *strongSelf = weakSelf; WQItem *latest = currentItem(); if (strongSelf && latest) wq_open_file(strongSelf.navigationController, latest.path, ro, WQOpenSQLite); }], + [UIAction actionWithTitle:WQLocalizedNullable(@"Media Player") image:[UIImage systemImageNamed:@"play.rectangle"] identifier:nil + handler:^(UIAction *__unused a) { WQDirViewController *strongSelf = weakSelf; WQItem *latest = currentItem(); if (strongSelf && latest) wq_open_file(strongSelf.navigationController, latest.path, ro, WQOpenMedia); }], + ]]]; + } + [actions addObject:[UIAction actionWithTitle:WQLocalizedNullable(@"Copy Path") image:[UIImage systemImageNamed:@"doc.on.doc"] + identifier:nil handler:^(UIAction *__unused a) { + WQItem *latest = currentItem(); + if (latest) UIPasteboard.generalPasteboard.string = latest.path; + }]]; + if (current.label) { + [actions addObject:[UIAction actionWithTitle:WQLocalizedNullable(@"Copy Identifier") image:[UIImage systemImageNamed:@"tag"] + identifier:nil handler:^(UIAction *__unused a) { + WQItem *latest = currentItem(); + if (latest) UIPasteboard.generalPasteboard.string = latest.bundleID ?: latest.label; + }]]; + } + // App backups (AppBackups/[/]): restore through Apps, + // which knows the installed app's container. + NSString *backupParent = current.path.stringByDeletingLastPathComponent; + NSString *restoreID = [backupParent isEqualToString:wq_app_backup_root()] ? current.name + : [backupParent.stringByDeletingLastPathComponent isEqualToString:wq_app_backup_root()] ? backupParent.lastPathComponent : nil; + if (current.isDir && restoreID.length) { + [actions addObject:[UIAction actionWithTitle:WQLocalizedNullable(@"Restore Backup…") image:[UIImage systemImageNamed:@"clock.arrow.circlepath"] + identifier:nil handler:^(UIAction *__unused a) { + [weakSelf.navigationController pushViewController:[[WQAppsViewController alloc] initRestoringBundleID:restoreID] + animated:YES]; + }]]; + } + if (current.isDir && wq_backup_unit_deletable(current.path) && wq_deleting_allowed()) { + UIAction *remove = [UIAction actionWithTitle:WQLocalizedNullable(@"Delete Backup…") image:[UIImage systemImageNamed:@"trash"] + identifier:nil handler:^(UIAction *__unused a) { + WQItem *latest = currentItem(); + if (latest) [weakSelf confirmDeleteBackup:latest]; + }]; + remove.attributes = UIMenuElementAttributesDestructive; + [actions addObject:remove]; + } + if ([strongSelf canEditHere]) { + if (!current.isDir && current.isRegular && + [current.name.pathExtension caseInsensitiveCompare:@"zip"] == NSOrderedSame) { + [actions addObject:[UIAction actionWithTitle:WQLocalizedNullable(@"Extract") image:[UIImage systemImageNamed:@"archivebox"] + identifier:nil handler:^(UIAction *__unused a) { + WQItem *latest = currentItem(); + if (latest) [weakSelf extractZip:latest.path]; + }]]; + } + [actions addObject:[UIAction actionWithTitle:WQLocalizedNullable(@"Compress") image:[UIImage systemImageNamed:@"doc.zipper"] + identifier:nil handler:^(UIAction *__unused a) { + WQItem *latest = currentItem(); + if (latest) [weakSelf compressPaths:@[latest.path]]; + }]]; + [actions addObject:[UIAction actionWithTitle:WQLocalizedNullable(@"Move…") image:[UIImage systemImageNamed:@"folder"] + identifier:nil handler:^(UIAction *__unused a) { + WQItem *latest = currentItem(); + if (latest) [weakSelf startMove:@[latest.path]]; + }]]; + } + if (!strongSelf.readOnly && !strongSelf->_backupStorage) { + [actions addObject:[UIAction actionWithTitle:WQLocalizedNullable(@"Rename") image:[UIImage systemImageNamed:@"pencil"] + identifier:nil handler:^(UIAction *__unused a) { WQItem *latest = currentItem(); if (latest) [weakSelf promptRename:latest]; }]]; + UIAction *del = [UIAction actionWithTitle:WQLocalizedNullable(@"Delete") image:[UIImage systemImageNamed:@"trash"] + identifier:nil handler:^(UIAction *__unused a) { WQItem *latest = currentItem(); if (latest) [weakSelf confirmDelete:latest]; }]; + del.attributes = UIMenuElementAttributesDestructive; + if (wq_deleting_allowed()) [actions addObject:del]; + } + return [UIMenu menuWithChildren:actions]; + }]; +} + +#pragma mark Actions + +- (void)runIO:(BOOL (^)(NSString **error))work failure:(NSString *)failureTitle +{ + dispatch_async(wq_io(), ^{ + NSString *error = nil; + BOOL ok = work(&error); + dispatch_async(dispatch_get_main_queue(), ^{ + if (!ok) wq_alert(self, failureTitle, error ?: @"Unknown error"); + [self reload]; + }); + }); +} + +- (void)promptNewFolder +{ + UIAlertController *alert = wq_alert_controller(@"New Folder", _path, UIAlertControllerStyleAlert); + [alert addTextFieldWithConfigurationHandler:^(UITextField *f) { f.placeholder = WQLocalizedNullable(@"Name"); }]; + [alert addAction:wq_alert_action(@"Cancel", UIAlertActionStyleCancel, nil)]; + [alert addAction:wq_alert_action(@"Create", UIAlertActionStyleDefault, ^(UIAlertAction *__unused a) { + NSString *name = alert.textFields.firstObject.text; + if (name.length == 0 || [name containsString:@"/"]) return; + NSString *path = [self->_path stringByAppendingPathComponent:name]; + [self runIO:^BOOL(NSString **error) { return wq_create_dir(path, error); } failure:WQLocalizedNullable(@"Cannot create folder")]; + })]; + [self presentViewController:alert animated:YES completion:nil]; +} + +- (void)promptRename:(WQItem *)item +{ + UIAlertController *alert = wq_alert_controller(@"Rename", item.path, UIAlertControllerStyleAlert); + [alert addTextFieldWithConfigurationHandler:^(UITextField *f) { f.text = item.name; }]; + [alert addAction:wq_alert_action(@"Cancel", UIAlertActionStyleCancel, nil)]; + [alert addAction:wq_alert_action(@"Rename", UIAlertActionStyleDefault, ^(UIAlertAction *__unused a) { + NSString *name = alert.textFields.firstObject.text; + [self runIO:^BOOL(NSString **error) { return wq_rename(item.path, name, error); } failure:WQLocalizedNullable(@"Cannot rename")]; + })]; + [self presentViewController:alert animated:YES completion:nil]; +} + +- (void)confirmDeleteBackup:(WQItem *)item +{ + if (wq_refuse_if_deleting_off(self)) return; + BOOL bundle = [item.path.stringByDeletingLastPathComponent isEqualToString:wq_app_backup_root()]; + NSString *message = bundle + ? WQLocalizedFormat(@"%@\n\nThis removes every backup for this app. It cannot be undone.", item.path) + : WQLocalizedFormat(@"%@\n\nThis removes the selected backup. It cannot be undone.", item.path); + UIAlertController *alert = wq_alert_controller(@"Delete backup?", message, UIAlertControllerStyleAlert); + [alert addAction:wq_alert_action(@"Cancel", UIAlertActionStyleCancel, nil)]; + [alert addAction:wq_alert_action(@"Delete", UIAlertActionStyleDestructive, ^(UIAlertAction *__unused a) { + [self runIO:^BOOL(NSString **error) { return wq_delete_backup(item.path, error); } failure:WQLocalizedNullable(@"Cannot delete backup")]; + })]; + [self presentViewController:alert animated:YES completion:nil]; +} + +- (void)confirmDelete:(WQItem *)item +{ + if (wq_refuse_if_deleting_off(self)) return; + NSString *message; + if (item.isLink) { + message = WQLocalizedFormat(@"%@\n\nRemoves the symbolic link only; its target is not changed.", item.path); + } else if (item.isDir) { + message = WQLocalizedFormat(@"%@\n\nOnly empty folders can be deleted.", item.path); + } else if (item.isRegular) { + message = WQLocalizedFormat(@"%@\n\nA copy is kept in Backups.", item.path); + } else { + message = WQLocalizedFormat(@"%@\n\nThis special file is removed; no backup is kept.", item.path); + } + UIAlertController *alert = wq_alert_controller(WQLocalizedFormat(@"Delete \"%@\"?", item.name), message, UIAlertControllerStyleAlert); + [alert addAction:wq_alert_action(@"Cancel", UIAlertActionStyleCancel, nil)]; + [alert addAction:wq_alert_action(@"Delete", UIAlertActionStyleDestructive, ^(UIAlertAction *__unused a) { + [self runIO:^BOOL(NSString **error) { return wq_delete(item.path, error); } failure:WQLocalizedNullable(@"Cannot delete")]; + })]; + [self presentViewController:alert animated:YES completion:nil]; +} + +- (void)restoreBackup:(NSString *)backup to:(NSString *)original +{ + NSString *message = wq_keep_before_restore() + ? WQLocalizedFormat(@"Write this backup back to\n%@?\n\nIf a file is there now, it is backed up first.", original) + : WQLocalizedFormat(@"Write this backup back to\n%@?\n\nIf a file is there now, it is backed up first and that backup is deleted after a successful restore (Keep Data Before Restore is off).", original); + UIAlertController *alert = wq_alert_controller(@"Restore", message, UIAlertControllerStyleAlert); + [alert addAction:wq_alert_action(@"Cancel", UIAlertActionStyleCancel, nil)]; + [alert addAction:wq_alert_action(@"Restore", UIAlertActionStyleDestructive, ^(UIAlertAction *__unused a) { + [self runIO:^BOOL(NSString **error) { + struct stat st; + if (!wq_stat(original, &st)) return wq_create_file_from(original, backup, error); + NSString *unit = nil; + if (!wq_write_from_file_with_backup(original, backup, &unit, error)) return NO; + // Keep Data Before Restore off: drop the copy of what was replaced. + if (!wq_keep_before_restore() && unit) { + NSString *discardError = nil; + if (wq_delete_backup(unit, &discardError)) + wq_log(@"restore %@: removed the before-restore backup %@ (Keep Data Before Restore is off)", + original.lastPathComponent, unit.lastPathComponent); + else + wq_log(@"restore %@: couldn't remove the before-restore backup %@ (%@)", + original.lastPathComponent, unit.lastPathComponent, discardError); + } + return YES; + } failure:WQLocalizedNullable(@"Restore failed")]; + })]; + [self presentViewController:alert animated:YES completion:nil]; +} + +- (void)importFile +{ + UIDocumentPickerViewController *picker = [[UIDocumentPickerViewController alloc] + initForOpeningContentTypes:@[UTTypeItem] asCopy:YES]; + picker.allowsMultipleSelection = YES; + picker.delegate = self; + _importingFolder = NO; + [self presentViewController:picker animated:YES completion:nil]; +} + +- (void)importFolder +{ + // Folders can't be handed over as a copy; the picker grants temporary + // access to the folder itself, which is copied with everything inside. + UIDocumentPickerViewController *picker = [[UIDocumentPickerViewController alloc] + initForOpeningContentTypes:@[UTTypeFolder] asCopy:NO]; + picker.allowsMultipleSelection = YES; + picker.delegate = self; + _importingFolder = YES; + [self presentViewController:picker animated:YES completion:nil]; +} + +// A name in dir that nothing uses yet: "Name", "Name 2", … (keeps the extension). +static NSString *wq_free_name_in(NSString *dir, NSString *name, NSMutableSet *claimed) +{ + NSString *ext = name.pathExtension, *stem = name.stringByDeletingPathExtension; + if (!stem.length) { stem = name; ext = @""; } + for (NSUInteger n = 1; n < 10000; n++) { + NSString *candidate = n == 1 ? name : [NSString stringWithFormat:@"%@ %lu", stem, (unsigned long)n]; + if (n > 1 && ext.length) candidate = [candidate stringByAppendingPathExtension:ext]; + struct stat st; + if ([claimed containsObject:candidate.lowercaseString] || + wq_stat([dir stringByAppendingPathComponent:candidate], &st)) continue; + [claimed addObject:candidate.lowercaseString]; + return candidate; + } + return nil; +} + +// Shows a progress alert while work runs on the IO queue, then reports. +- (void)runBusy:(NSString *)title failure:(NSString *)failureTitle + work:(BOOL (^)(NSString **error, NSString **note))work +{ + UIAlertController *hud = wq_alert_controller(title, @"Please wait…", UIAlertControllerStyleAlert); + [self presentViewController:hud animated:YES completion:nil]; + dispatch_async(wq_io(), ^{ + NSString *error = nil, *note = nil; + BOOL ok = work(&error, ¬e); + dispatch_async(dispatch_get_main_queue(), ^{ + void (^finish)(void) = ^{ + if (!ok) wq_alert(self, failureTitle, error ?: @"Unknown error"); + else if (note) wq_alert(self, title, note); + [self reload]; + }; + if (self.presentedViewController == hud) [self dismissViewControllerAnimated:YES completion:finish]; + else finish(); + }); + }); +} + +- (void)documentPicker:(UIDocumentPickerViewController *)controller didPickDocumentsAtURLs:(NSArray *)urls +{ + if (!urls.count) return; + if (_importingFolder) { [self importFolders:urls]; return; } + // One file: the existing flow, which offers to replace a file of the same name. + if (urls.count == 1) { [self importSingleFile:urls.firstObject.path]; return; } + // Several files: same-named ones get a free name ("Name 2.ext") instead of + // a question per file. The picker's local copies are removed afterwards. + NSString *dir = _path; + NSArray *sources = [urls valueForKey:@"path"]; + [self runBusy:WQLocalized(@"Importing") failure:WQLocalizedNullable(@"Import failed") work:^BOOL(NSString **error, NSString **note) { + NSMutableSet *claimed = [NSMutableSet new]; + NSUInteger done = 0, renamed = 0; + NSString *first = nil; + for (NSString *source in sources) { + NSString *name = wq_free_name_in(dir, source.lastPathComponent, claimed); + NSString *e = nil; + BOOL ok = name && wq_create_file_from([dir stringByAppendingPathComponent:name], source, &e); + [NSFileManager.defaultManager removeItemAtPath:source error:nil]; + if (ok) { done++; if (![name isEqualToString:source.lastPathComponent]) renamed++; } + else if (!first) first = [NSString stringWithFormat:@"%@: %@", source.lastPathComponent, e ?: @"no free name"]; + } + wq_log(@"import: %lu of %lu files into %@", (unsigned long)done, (unsigned long)sources.count, dir); + if (done < sources.count) { + *error = [NSString stringWithFormat:@"%lu of %lu files were imported.\n\n%@", + (unsigned long)done, (unsigned long)sources.count, first]; + return NO; + } + if (renamed) *note = WQLocalizedFormat(@"Imported %lu files. %lu had a name that was already taken and got a number added.", + (unsigned long)done, (unsigned long)renamed); + return YES; + }]; +} + +- (void)importFolders:(NSArray *)urls +{ + NSString *dir = _path; + [self runBusy:WQLocalized(@"Importing") failure:WQLocalizedNullable(@"Import failed") work:^BOOL(NSString **error, NSString **note) { + NSMutableSet *claimed = [NSMutableSet new]; + NSMutableArray *renamed = [NSMutableArray new]; + for (NSURL *url in urls) { + BOOL scoped = [url startAccessingSecurityScopedResource]; + NSString *name = wq_free_name_in(dir, url.lastPathComponent, claimed); + __block BOOL ok = NO; + __block NSString *e = nil; + if (name) { + NSString *dest = [dir stringByAppendingPathComponent:name]; + // A coordinated read makes cloud folders download first. + NSError *coordError = nil; + [[NSFileCoordinator new] coordinateReadingItemAtURL:url options:0 error:&coordError byAccessor:^(NSURL *readable) { + NSString *copyError = nil; + ok = wq_create_dir(dest, ©Error) && wq_copy_contents(readable.path, dest, ©Error); + e = copyError; + }]; + if (coordError) e = e ?: coordError.localizedDescription; + if (!ok && e && wq_stat(dest, &(struct stat){0})) + e = [NSString stringWithFormat:@"%@\n\nPart of it may already be in %@.", e, name]; + } else { + e = WQLocalized(@"No free name in this folder"); + } + if (scoped) [url stopAccessingSecurityScopedResource]; + wq_log(@"import folder: %@ -> %@/%@: %@", url.path, dir, name, ok ? @"OK" : e); + if (!ok) { *error = [NSString stringWithFormat:@"%@: %@", url.lastPathComponent, e ?: @"failed"]; return NO; } + if (![name isEqualToString:url.lastPathComponent]) [renamed addObject:name]; + } + if (renamed.count) + *note = WQLocalizedFormat(@"A folder with the same name already existed, so the import is named %@.", + [renamed componentsJoinedByString:@", "]); + return YES; + }]; +} + +- (void)importSingleFile:(NSString *)source +{ + // The picker hands over a local copy (asCopy). It is streamed into place, + // never read into memory, and the existence check runs off the main thread. + NSString *dest = [_path stringByAppendingPathComponent:source.lastPathComponent]; + void (^cleanup)(void) = ^{ [NSFileManager.defaultManager removeItemAtPath:source error:nil]; }; + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + struct stat st; + BOOL exists = wq_stat(dest, &st); + dispatch_async(dispatch_get_main_queue(), ^{ + if (!exists) { + [self runIO:^BOOL(NSString **error) { + BOOL ok = wq_create_file_from(dest, source, error); + cleanup(); + return ok; + } failure:WQLocalizedNullable(@"Import failed")]; + return; + } + UIAlertController *alert = wq_alert_controller(@"Replace existing file?", WQLocalizedFormat(@"%@ already exists. It is backed up before being replaced.", dest), UIAlertControllerStyleAlert); + [alert addAction:wq_alert_action(@"Cancel", UIAlertActionStyleCancel, ^(UIAlertAction *__unused a) { cleanup(); })]; + [alert addAction:wq_alert_action(@"Replace", UIAlertActionStyleDestructive, ^(UIAlertAction *__unused a) { + [self runIO:^BOOL(NSString **error) { + BOOL ok = wq_write_from_file(dest, source, error); + cleanup(); + return ok; + } failure:WQLocalizedNullable(@"Import failed")]; + })]; + [self presentViewController:alert animated:YES completion:nil]; + }); + }); +} + +@end + +#pragma mark - System groups + +// The SystemGroup parent gets no token on 27.0.1, so it cannot be listed. +// Known container names are checked one by one (lstat works without access). +@interface WQSystemGroupsViewController : UITableViewController +@end + +@implementation WQSystemGroupsViewController { + NSArray *_names; + BOOL _listed; +} + +static NSString *const kSystemGroupRoot = @"/private/var/containers/Shared/SystemGroup"; + +- (void)viewDidLoad +{ + [super viewDidLoad]; + self.title = WQLocalizedNullable(@"System Groups"); + self.navigationItem.rightBarButtonItem = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemAdd + target:self action:@selector(tryName)]; + dispatch_async(wq_io(), ^{ + NSArray *listed = wq_list(kSystemGroupRoot, NULL); + NSArray *names = listed; + if (!names) { + NSArray *known = @[ + @"systemgroup.com.apple.configurationprofiles", @"systemgroup.com.apple.mobilegestaltcache", + @"systemgroup.com.apple.osanalytics", @"systemgroup.com.apple.lsd.iconscache", + @"systemgroup.com.apple.lsd.identifiers", @"systemgroup.com.apple.ManagedSettings", + @"systemgroup.com.apple.sharedpclogging", @"systemgroup.com.apple.media.shared.books", + @"systemgroup.com.apple.nsurlstoragedresources", @"systemgroup.com.apple.pisco.suinfo", + @"systemgroup.com.apple.icloud.findmydevice.managed", @"systemgroup.com.apple.mobilestoragemounter", + @"systemgroup.com.apple.mobileactivationd", @"systemgroup.com.apple.sensorkit", + @"systemgroup.com.apple.powerlog", @"systemgroup.com.apple.diagnosticextensionsd", + @"systemgroup.com.apple.mediaanalysisd", @"systemgroup.com.apple.translationd", + @"systemgroup.com.apple.chronod", @"systemgroup.com.apple.coreaudio", + @"systemgroup.com.apple.ManagedConfiguration.ExternalTrust", @"systemgroup.com.apple.cfprefsd", + ]; + NSMutableArray *found = [NSMutableArray new]; + for (NSString *name in known) { + struct stat st; + NSString *p = [kSystemGroupRoot stringByAppendingPathComponent:name]; + if (lstat(p.fileSystemRepresentation, &st) == 0) [found addObject:name]; + } + names = found; + } + dispatch_async(dispatch_get_main_queue(), ^{ + self->_listed = listed != nil; + self->_names = [names sortedArrayUsingSelector:@selector(compare:)]; + [self.tableView reloadData]; + }); + }); +} + +- (NSString *)tableView:(UITableView *)tableView titleForFooterInSection:(NSInteger)section +{ + if (!_names) return WQLocalized(@"Loading…"); + return _listed ? nil : WQLocalized(@"This folder cannot be listed on this iOS version. Known names that exist are shown; tap + to try another name."); +} + +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { return (NSInteger)_names.count; } + +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath +{ + UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"g"] + ?: [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"g"]; + UIListContentConfiguration *c = cell.defaultContentConfiguration; + c.text = [_names[(NSUInteger)indexPath.row] stringByReplacingOccurrencesOfString:@"systemgroup." withString:@""]; + c.image = [UIImage systemImageNamed:@"folder.fill"]; + cell.contentConfiguration = c; + cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator; + return cell; +} + +- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath +{ + [tableView deselectRowAtIndexPath:indexPath animated:YES]; + NSString *name = _names[(NSUInteger)indexPath.row]; + [self.navigationController pushViewController:[[WQDirViewController alloc] + initWithPath:[kSystemGroupRoot stringByAppendingPathComponent:name] title:nil] animated:YES]; +} + +- (void)tryName +{ + UIAlertController *alert = wq_alert_controller(@"Open System Group", nil, UIAlertControllerStyleAlert); + [alert addTextFieldWithConfigurationHandler:^(UITextField *f) { + f.text = @"systemgroup.com.apple."; + f.autocapitalizationType = UITextAutocapitalizationTypeNone; + f.autocorrectionType = UITextAutocorrectionTypeNo; + }]; + [alert addAction:wq_alert_action(@"Cancel", UIAlertActionStyleCancel, nil)]; + [alert addAction:wq_alert_action(@"Open", UIAlertActionStyleDefault, ^(UIAlertAction *__unused a) { + NSString *name = alert.textFields.firstObject.text; + if (name.length == 0 || [name containsString:@"/"]) return; + NSString *path = [kSystemGroupRoot stringByAppendingPathComponent:name]; + struct stat st; + if (lstat(path.fileSystemRepresentation, &st) != 0) { + wq_alert(self, @"Not found", path); + return; + } + [self.navigationController pushViewController:[[WQDirViewController alloc] initWithPath:path title:nil] animated:YES]; + })]; + [self presentViewController:alert animated:YES completion:nil]; +} + +@end + +#pragma mark - Log + +@interface WQLogViewController : UIViewController +@end + +@implementation WQLogViewController { + UITextView *_textView; +} + +- (void)viewDidLoad +{ + [super viewDidLoad]; + self.title = WQLocalizedNullable(@"Log"); + self.view.backgroundColor = UIColor.systemBackgroundColor; + _textView = [[UITextView alloc] initWithFrame:self.view.bounds]; + _textView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + _textView.editable = NO; + _textView.font = [UIFont monospacedSystemFontOfSize:11 weight:UIFontWeightRegular]; + _textView.semanticContentAttribute = UISemanticContentAttributeForceLeftToRight; + _textView.textAlignment = NSTextAlignmentLeft; // log lines stay left to right + [self.view addSubview:_textView]; + self.navigationItem.rightBarButtonItem = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemAction + target:self action:@selector(share:)]; +} + +// App version, device and iOS first, so a Log someone sends in says where it +// came from. +- (NSString *)fullLog +{ + NSDictionary *info = NSBundle.mainBundle.infoDictionary; + return WQLocalizedFormat(@"WQ Files %@ (%@)\n%@\n\n%@", + info[@"CFBundleShortVersionString"] ?: @"?", info[@"CFBundleVersion"] ?: @"?", wq_device_line(), wq_log_text()); +} + +- (void)viewWillAppear:(BOOL)animated +{ + [super viewWillAppear:animated]; + _textView.text = [self fullLog]; +} + +- (void)share:(UIBarButtonItem *)sender +{ + UIActivityViewController *share = [[UIActivityViewController alloc] initWithActivityItems:@[[self fullLog]] + applicationActivities:nil]; + share.popoverPresentationController.barButtonItem = sender; + [self presentViewController:share animated:YES completion:nil]; +} + +@end + +#pragma mark - Navigation + +// Adds a Home button next to Back on screens two or more levels deep. Editors +// guard Back, Home and the interactive edge swipe through WQUnsavedEditing. +@interface WQNavigationController : UINavigationController +@end + +@implementation WQNavigationController + +- (void)viewDidLoad +{ + [super viewDidLoad]; + self.delegate = self; + self.interactivePopGestureRecognizer.delegate = self; +} + +- (void)navigationController:(UINavigationController *)nav willShowViewController:(UIViewController *)vc animated:(BOOL)animated +{ + BOOL toolbar = [vc isKindOfClass:WQDirViewController.class] && [(WQDirViewController *)vc wq_wantsToolbar]; + [nav setToolbarHidden:!toolbar animated:animated]; + NSUInteger depth = [nav.viewControllers indexOfObject:vc]; + if (depth == NSNotFound || depth < 2) { + if (vc.navigationItem.leftBarButtonItem.tag == 0x484f4d45) vc.navigationItem.leftBarButtonItem = nil; + return; + } + if (vc.navigationItem.leftBarButtonItem) return; + UIBarButtonItem *home = [[UIBarButtonItem alloc] initWithImage:[UIImage systemImageNamed:@"house"] + style:UIBarButtonItemStylePlain target:self action:@selector(goHome)]; + home.tag = 0x484f4d45; // "HOME" + home.accessibilityLabel = WQLocalizedNullable(@"Home"); + vc.navigationItem.leftItemsSupplementBackButton = YES; + vc.navigationItem.leftBarButtonItem = home; +} + +- (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer +{ + if (gestureRecognizer != self.interactivePopGestureRecognizer) return YES; + if (self.viewControllers.count <= 1) return NO; + UIViewController *editor = (UIViewController *)self.topViewController; + if (![editor conformsToProtocol:@protocol(WQUnsavedEditing)] || !editor.wq_needsLeaveConfirmation) return YES; + __weak typeof(self) weakSelf = self; + [editor confirmLeaveThen:^{ [weakSelf popViewControllerAnimated:YES]; }]; + return NO; +} + +- (void)goHome +{ + UIViewController *top = (UIViewController *)self.topViewController; + __weak typeof(self) weakSelf = self; + void (^home)(void) = ^{ [weakSelf popToRootViewControllerAnimated:YES]; }; + if ([top conformsToProtocol:@protocol(WQUnsavedEditing)]) [top confirmLeaveThen:home]; + else home(); +} + +@end + +#pragma mark - Root + +@interface WQRootViewController : UITableViewController +@end + +static NSString *const kWQLanguagePref = @"WQLanguage"; + +// The language picked in WQ Files, or nil for the device's. +static NSString *wq_chosen_language(void) +{ + NSString *code = [NSUserDefaults.standardUserDefaults stringForKey:kWQLanguagePref]; + return code.length ? code : nil; +} + +// Each language in its own name, so it can be found whatever is shown now. +static NSString *wq_language_name(NSString *code) +{ + if (!code.length) return WQLocalized(@"System Default"); + NSString *name = [[NSLocale localeWithLocaleIdentifier:code] localizedStringForLanguageCode:code]; + if ([code isEqualToString:@"zh-Hans"]) name = @"简体中文"; + return name.length ? [[name substringToIndex:1].uppercaseString stringByAppendingString:[name substringFromIndex:1]] : code; +} + +// iOS only offers Settings → WQ Files → Language when the device has more +// than one preferred language, so WQ Files has its own list, built from the +// languages the app ships. The choice is this app's AppleLanguages default, +// which iOS applies at launch (text and right-to-left layout alike), so it +// takes effect when WQ Files restarts. +@interface WQLanguageViewController : UITableViewController +@end + +@implementation WQLanguageViewController { + NSArray *_codes; +} + +- (instancetype)init +{ + if ((self = [super initWithStyle:UITableViewStyleInsetGrouped])) { + self.title = WQLocalized(@"Language"); + // localizations can name a language twice (the .lproj folder and the + // CFBundleLocalizations entry, sometimes spelled differently): one + // row per language. + NSMutableArray *codes = [NSMutableArray new]; + NSMutableSet *seen = [NSMutableSet new]; + for (NSString *code in NSBundle.mainBundle.localizations) { + NSString *canonical = [NSLocale canonicalLanguageIdentifierFromString:code]; + if ([code isEqualToString:@"Base"] || [seen containsObject:canonical]) continue; + [seen addObject:canonical]; + [codes addObject:code]; + } + [codes sortUsingComparator:^NSComparisonResult(NSString *x, NSString *y) { + return [wq_language_name(x) localizedCaseInsensitiveCompare:wq_language_name(y)]; + }]; + _codes = codes; + } + return self; +} + +- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView { return 2; } + +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section +{ + return section == 0 ? 1 : (NSInteger)_codes.count; +} + +- (NSString *)tableView:(UITableView *)tableView titleForFooterInSection:(NSInteger)section +{ + return section == 1 ? WQLocalized(@"The new language is used the next time WQ Files starts.") : nil; +} + +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath +{ + UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"l"] + ?: [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleSubtitle reuseIdentifier:@"l"]; + UIListContentConfiguration *c = [UIListContentConfiguration subtitleCellConfiguration]; + NSString *code = indexPath.section == 0 ? @"" : _codes[(NSUInteger)indexPath.row]; + c.text = wq_language_name(code); + // Like iOS: each language in its own name, and in the one shown now. + NSString *shownLanguage = NSBundle.mainBundle.preferredLocalizations.firstObject ?: @"en"; + if (code.length) { + NSString *here = [[NSLocale localeWithLocaleIdentifier:shownLanguage] localizedStringForLanguageCode:code]; + if (here.length && [here localizedCaseInsensitiveCompare:c.text] != NSOrderedSame) c.secondaryText = here; + } else { + NSString *device = [NSLocale preferredLanguages].firstObject; + if (device.length) c.secondaryText = [[NSLocale localeWithLocaleIdentifier:shownLanguage] localizedStringForLanguageCode:device]; + } + c.secondaryTextProperties.color = UIColor.secondaryLabelColor; + cell.contentConfiguration = c; + cell.accessoryType = [code isEqualToString:wq_chosen_language() ?: @""] ? UITableViewCellAccessoryCheckmark + : UITableViewCellAccessoryNone; + return cell; +} + +- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath +{ + [tableView deselectRowAtIndexPath:indexPath animated:YES]; + NSString *code = indexPath.section == 0 ? @"" : _codes[(NSUInteger)indexPath.row]; + if ([code isEqualToString:wq_chosen_language() ?: @""]) return; + NSUserDefaults *defaults = NSUserDefaults.standardUserDefaults; + if (code.length) { + [defaults setObject:code forKey:kWQLanguagePref]; + [defaults setObject:@[code] forKey:@"AppleLanguages"]; + } else { + [defaults removeObjectForKey:kWQLanguagePref]; + [defaults removeObjectForKey:@"AppleLanguages"]; + } + [tableView reloadData]; + UIAlertController *restart = wq_alert_controller(@"Restart WQ Files", + @"The new language is used the next time WQ Files starts.", UIAlertControllerStyleAlert); + [restart addAction:wq_alert_action(@"Later", UIAlertActionStyleCancel, nil)]; + [restart addAction:wq_alert_action(@"Quit Now", UIAlertActionStyleDefault, ^(UIAlertAction *__unused a) { + [NSUserDefaults.standardUserDefaults synchronize]; + exit(0); + })]; + [self presentViewController:restart animated:YES completion:nil]; +} + +@end + +@implementation WQRootViewController { + NSArray *> *_locations; // title, path, symbol + // Kept between visits so Apps and the Cache Cleaner show their last list + // right away instead of scanning every container again. + WQAppsViewController *_apps; + WQCacheCleanerViewController *_cacheCleaner; +} + +- (void)didReceiveMemoryWarning +{ + [super didReceiveMemoryWarning]; + if (!_apps.parentViewController) _apps = nil; + if (!_cacheCleaner.parentViewController) _cacheCleaner = nil; +} + +- (instancetype)init +{ + if ((self = [super initWithStyle:UITableViewStyleInsetGrouped])) { + self.title = WQLocalizedNullable(@"WQ Files"); + _locations = @[ + @[@"App Data", @"/private/var/mobile/Containers/Data/Application", @"square.grid.2x2"], + @[@"App Groups", @"/private/var/mobile/Containers/Shared/AppGroup", @"square.stack.3d.up"], + @[@"Extension Data", @"/private/var/mobile/Containers/Data/PluginKitPlugin", @"puzzlepiece.extension"], + @[@"Internal Daemons", @"/private/var/mobile/Containers/Data/InternalDaemon", @"gearshape.2"], + ]; + // iOS 26 gives no access to the bundle containers (no token). + if (wq_system_areas_reachable()) + _locations = [_locations arrayByAddingObjectsFromArray:@[ + @[@"App Bundles", @"/private/var/containers/Bundle/Application", @"app.badge"], + @[@"System Data", @"/private/var/containers/Data/System", @"cpu"]]]; + } + return self; +} + +- (void)viewDidLoad +{ + [super viewDidLoad]; + self.navigationController.navigationBar.prefersLargeTitles = YES; + self.tableView.tableFooterView = [self authorFooter]; + dispatch_async(wq_io(), ^{ + NSString *recovery = wq_recover_interrupted_restore(); + if (recovery) { + dispatch_async(dispatch_get_main_queue(), ^{ + wq_alert(self, @"Restore recovery", recovery); + }); + } + (void)wq_app_group(); // resolves (and logs) the group off the main thread + }); +} + +- (void)viewWillAppear:(BOOL)animated +{ + [super viewWillAppear:animated]; + // The Language row shows the choice made in the language list. + if (self.isViewLoaded && self.tableView.numberOfSections > 2) + [self.tableView reloadRowsAtIndexPaths:@[[NSIndexPath indexPathForRow:3 inSection:2]] withRowAnimation:UITableViewRowAnimationNone]; +} + +// Signing check, once the start screen is on screen (an alert presented +// while it is still loading would be dropped). +- (void)viewDidAppear:(BOOL)animated +{ + [super viewDidAppear:animated]; + static BOOL checked; + if (checked) return; + checked = YES; + dispatch_async(wq_io(), ^{ + NSString *group = wq_app_group(); + if (group) return; + dispatch_async(dispatch_get_main_queue(), ^{ + UIAlertController *alert = wq_alert_controller(@"Signing problem: no App Group", @"WQ Files needs an App Group to reach other apps' folders, and the tool that signed it removed it. " + @"Without it nothing can be opened, and app lists stay empty or show \"NO TOKEN\".\n\n" + @"Re-install WQ Files with AltStore, SideStore or Impactor (they create the App Group; Sideloadly doesn't), " + @"or with a provisioning profile that contains one. Wildcard certificates (as used by many " + @"Feather/ESign setups) can't.", UIAlertControllerStyleAlert); + [alert addAction:wq_alert_action(@"Signing Help", UIAlertActionStyleDefault, ^(UIAlertAction *__unused a) { + [UIApplication.sharedApplication openURL:[NSURL URLWithString:@"https://github.com/kolbicz/WQFiles#signing-the-app-group"] + options:@{} completionHandler:nil]; + })]; + [alert addAction:wq_alert_action(@"OK", UIAlertActionStyleCancel, nil)]; + [self presentViewController:alert animated:YES completion:nil]; + }); + }); +} + +// "by kolbicz" with GitHub and X links, under the last section. +- (UIView *)authorFooter +{ + UIView *footer = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 320, 128)]; + UILabel *by = [UILabel new]; + NSDictionary *info = NSBundle.mainBundle.infoDictionary; + by.text = WQLocalizedFormat(@"WQ Files %@ (%@) by kolbicz", + info[@"CFBundleShortVersionString"] ?: @"?", info[@"CFBundleVersion"] ?: @"?"); + by.font = [UIFont preferredFontForTextStyle:UIFontTextStyleFootnote]; + by.textColor = UIColor.secondaryLabelColor; + + UIButton *(^link)(NSString *, NSString *, NSString *) = ^UIButton *(NSString *title, NSString *symbol, NSString *url) { + UIButtonConfiguration *cfg = [UIButtonConfiguration tintedButtonConfiguration]; + cfg.title = title; + cfg.image = [UIImage systemImageNamed:symbol]; + cfg.imagePadding = 6; + cfg.cornerStyle = UIButtonConfigurationCornerStyleCapsule; + cfg.buttonSize = UIButtonConfigurationSizeSmall; + return [UIButton buttonWithConfiguration:cfg primaryAction:[UIAction actionWithHandler:^(UIAction *__unused a) { + [UIApplication.sharedApplication openURL:[NSURL URLWithString:url] options:@{} completionHandler:nil]; + }]]; + }; + UIStackView *links = [[UIStackView alloc] initWithArrangedSubviews:@[ + link(@"GitHub", @"chevron.left.forwardslash.chevron.right", @"https://github.com/kolbicz"), + link(@"@_kolbicz", @"at", @"https://x.com/_kolbicz"), + ]]; + links.spacing = 12; + + UIButtonConfiguration *creditCfg = [UIButtonConfiguration plainButtonConfiguration]; + creditCfg.attributedTitle = [[NSAttributedString alloc] initWithString:WQLocalized(@"Sandbox escape: worse_query by @forcequitOS") + attributes:@{NSFontAttributeName: [UIFont preferredFontForTextStyle:UIFontTextStyleFootnote]}]; + creditCfg.buttonSize = UIButtonConfigurationSizeSmall; + UIButton *credit = [UIButton buttonWithConfiguration:creditCfg primaryAction:[UIAction actionWithHandler:^(UIAction *__unused a) { + [UIApplication.sharedApplication openURL:[NSURL URLWithString:@"https://x.com/forcequitOS"] options:@{} completionHandler:nil]; + }]]; + + UIStackView *stack = [[UIStackView alloc] initWithArrangedSubviews:@[by, links, credit]]; + stack.axis = UILayoutConstraintAxisVertical; + stack.alignment = UIStackViewAlignmentCenter; + stack.spacing = 8; + stack.translatesAutoresizingMaskIntoConstraints = NO; + [footer addSubview:stack]; + [NSLayoutConstraint activateConstraints:@[ + [stack.centerXAnchor constraintEqualToAnchor:footer.centerXAnchor], + [stack.topAnchor constraintEqualToAnchor:footer.topAnchor constant:8], + ]]; + return footer; +} + +- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView { return 3; } + +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section +{ + // System Groups is the extra last row, on iOS 27 only. + if (section == 2) return 4; + return section == 0 ? (NSInteger)_locations.count + (wq_system_areas_reachable() ? 1 : 0) : 6; +} + +- (NSString *)tableView:(UITableView *)tableView titleForHeaderInSection:(NSInteger)section +{ + return section == 0 ? WQLocalized(@"Containers") : section == 1 ? WQLocalized(@"Tools") : WQLocalized(@"Settings"); +} + +- (NSString *)tableView:(UITableView *)tableView titleForFooterInSection:(NSInteger)section +{ + if (section == 0 && !wq_system_areas_reachable()) + return WQLocalized(@"On iOS 26, System Data and System Groups can't be reached (worse_query covers them on iOS 27 only), and Internal Daemons need a scan of every file id: the first one can take most of an hour on a well-used device, later ones only check new files."); + if (section == 2) + return [NSString stringWithFormat:@"%@\n\n%@", + WQLocalized(@"Allow Deleting: when off, nothing can be deleted: no Delete or swipe to delete in folders and backups, no Clear Data in Apps and no cleaning in the Cache Cleaner. Saving, moving and restoring still work.\n\nShow Item Counts: shows how many items each folder holds. Every subfolder has to be listed for this, which slows down folders with many subfolders, especially in other apps' data."), + WQLocalized(@"Keep Data Before Restore: restoring an app or file backup first backs up what's there now, so a failed restore can be undone. When off, that backup is deleted once the restore succeeded.")]; + return section == 1 + ? WQLocalized(@"Overwritten and deleted files are copied to Backups first (also visible in the Files app). Long-press a backup to restore it.") + : nil; +} + +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath +{ + UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"r"] + ?: [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"r"]; + UIListContentConfiguration *c = cell.defaultContentConfiguration; + cell.accessoryView = nil; + cell.selectionStyle = UITableViewCellSelectionStyleDefault; + if (indexPath.section == 2 && indexPath.row == 3) { + UIListContentConfiguration *v = [UIListContentConfiguration valueCellConfiguration]; + v.text = WQLocalized(@"Language"); + v.image = [UIImage systemImageNamed:@"globe"]; + v.secondaryText = wq_language_name(wq_chosen_language()); + cell.contentConfiguration = v; + cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator; + return cell; + } + if (indexPath.section == 2) { + cell.accessoryType = UITableViewCellAccessoryNone; + NSInteger row = indexPath.row; + c.text = WQLocalized(@[@"Allow Deleting", @"Show Item Counts", @"Keep Data Before Restore"][(NSUInteger)row]); + c.image = [UIImage systemImageNamed:@[@"trash.slash", @"number", @"clock.arrow.circlepath"][(NSUInteger)row]]; + UISwitch *toggle = [UISwitch new]; + toggle.on = row == 1 ? wq_show_item_counts() : row == 2 ? wq_keep_before_restore() : wq_deleting_allowed(); + [toggle addAction:[UIAction actionWithHandler:^(UIAction *action) { + BOOL on = ((UISwitch *)action.sender).isOn; + if (row == 1) [NSUserDefaults.standardUserDefaults setBool:on forKey:kWQShowItemCountsPref]; + else if (row == 2) wq_set_keep_before_restore(on); + else wq_set_deleting_allowed(on); + }] forControlEvents:UIControlEventValueChanged]; + cell.contentConfiguration = c; + cell.accessoryView = toggle; + cell.selectionStyle = UITableViewCellSelectionStyleNone; + return cell; + } + if (indexPath.section == 0) { + if ((NSUInteger)indexPath.row < _locations.count) { + NSArray *loc = _locations[(NSUInteger)indexPath.row]; + c.text = WQLocalizedNullable(loc[0]); + c.image = [UIImage systemImageNamed:loc[2]]; + } else { + c.text = WQLocalizedNullable(@"System Groups"); + c.image = [UIImage systemImageNamed:@"lock.square.stack"]; + } + } else { + NSArray *tools = @[@[@"Apps", @"square.grid.3x3"], @[@"Cache Cleaner", @"trash"], @[@"Go to Path…", @"arrow.right.circle"], + @[@"App Backups", @"clock.arrow.circlepath"], @[@"Backups", @"clock.arrow.circlepath"], @[@"Log", @"doc.plaintext"]]; + c.text = WQLocalizedNullable(tools[(NSUInteger)indexPath.row][0]); + c.image = [UIImage systemImageNamed:tools[(NSUInteger)indexPath.row][1]]; + } + cell.contentConfiguration = c; + cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator; + return cell; +} + +- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath +{ + [tableView deselectRowAtIndexPath:indexPath animated:YES]; + if (indexPath.section == 2 && indexPath.row == 3) { + [self.navigationController pushViewController:[WQLanguageViewController new] animated:YES]; + return; + } + if (indexPath.section == 2) return; + UIViewController *next = nil; + if (indexPath.section == 0) { + if ((NSUInteger)indexPath.row < _locations.count) { + NSArray *loc = _locations[(NSUInteger)indexPath.row]; + next = [[WQDirViewController alloc] initWithPath:loc[1] title:loc[0]]; + } else { + next = [WQSystemGroupsViewController new]; + } + } else if (indexPath.row == 0) { + if (!_apps || _apps.parentViewController) _apps = [WQAppsViewController new]; + next = _apps; + } else if (indexPath.row == 1) { + if (!_cacheCleaner || _cacheCleaner.parentViewController) _cacheCleaner = [WQCacheCleanerViewController new]; + next = _cacheCleaner; + } else if (indexPath.row == 2) { + [self goToPath]; + return; + } else if (indexPath.row == 3) { + [NSFileManager.defaultManager createDirectoryAtPath:wq_app_backup_root() withIntermediateDirectories:YES attributes:nil error:nil]; + next = [[WQDirViewController alloc] initWithPath:wq_app_backup_root() title:WQLocalizedNullable(@"App Backups")]; + } else if (indexPath.row == 4) { + [NSFileManager.defaultManager createDirectoryAtPath:wq_backup_root() withIntermediateDirectories:YES attributes:nil error:nil]; + next = [[WQDirViewController alloc] initWithPath:wq_backup_root() title:WQLocalizedNullable(@"Backups")]; + } else { + next = [WQLogViewController new]; + } + [self.navigationController pushViewController:next animated:YES]; +} + +- (void)goToPath +{ + UIAlertController *alert = wq_alert_controller(@"Go to Path", @"A folder or file path", UIAlertControllerStyleAlert); + [alert addTextFieldWithConfigurationHandler:^(UITextField *f) { + f.text = UIPasteboard.generalPasteboard.hasStrings && [UIPasteboard.generalPasteboard.string hasPrefix:@"/"] + ? UIPasteboard.generalPasteboard.string : @"/private/var/"; + f.autocapitalizationType = UITextAutocapitalizationTypeNone; + f.autocorrectionType = UITextAutocorrectionTypeNo; + f.clearButtonMode = UITextFieldViewModeAlways; + }]; + [alert addAction:wq_alert_action(@"Cancel", UIAlertActionStyleCancel, nil)]; + [alert addAction:wq_alert_action(@"Open", UIAlertActionStyleDefault, ^(UIAlertAction *__unused a) { + NSString *path = alert.textFields.firstObject.text.stringByStandardizingPath; + if (![path hasPrefix:@"/"]) return; + dispatch_async(wq_io(), ^{ + struct stat st; + BOOL exists = wq_stat(path, &st); + BOOL isDir = exists && S_ISDIR(st.st_mode); + if (exists && S_ISLNK(st.st_mode)) { + struct stat target; + isDir = stat(path.fileSystemRepresentation, &target) == 0 && S_ISDIR(target.st_mode); + } + dispatch_async(dispatch_get_main_queue(), ^{ + if (!exists) { + wq_alert(self, @"Not found", path); + return; + } + if (!isDir) { wq_open_file(self.navigationController, path, NO, WQOpenAuto); return; } + [self.navigationController pushViewController:[[WQDirViewController alloc] initWithPath:path title:nil] + animated:YES]; + }); + }); + })]; + [self presentViewController:alert animated:YES completion:nil]; +} + +@end + +#pragma mark - App + +@interface WQSceneDelegate : UIResponder +@property (nonatomic, strong) UIWindow *window; +@end + +@implementation WQSceneDelegate + +- (void)scene:(UIScene *)scene willConnectToSession:(UISceneSession *)session options:(UISceneConnectionOptions *)options +{ + UIWindow *window = [[UIWindow alloc] initWithWindowScene:(UIWindowScene *)scene]; + window.rootViewController = [[WQNavigationController alloc] initWithRootViewController:[WQRootViewController new]]; + [window makeKeyAndVisible]; + self.window = window; +} + +@end + +#pragma mark - Full scan: confirmation and progress window + +static UIViewController *wq_top_view_controller(void) +{ + for (UIScene *scene in UIApplication.sharedApplication.connectedScenes) { + if (![scene isKindOfClass:UIWindowScene.class] || scene.activationState != UISceneActivationStateForegroundActive) continue; + for (UIWindow *window in ((UIWindowScene *)scene).windows) { + if (!window.isKeyWindow) continue; + UIViewController *top = window.rootViewController; + while (top.presentedViewController && !top.presentedViewController.isBeingDismissed) top = top.presentedViewController; + return top; + } + } + return nil; +} + +static NSString *wq_duration_text(double seconds) +{ + if (seconds < 90) return WQLocalized(@"about a minute"); + if (seconds < 3600) return WQLocalizedFormat(@"about %.0f minutes", ceil(seconds / 60)); + return WQLocalizedFormat(@"about %.1f hours", seconds / 3600); +} + +static NSString *wq_count_text(int64_t n) +{ + if (n >= 1000000) return WQLocalizedFormat(@"%.0f million", n / 1e6); + return [NSNumberFormatter localizedStringFromNumber:@(n) numberStyle:NSNumberFormatterDecimalStyle]; +} + +@interface WQScanWindowController : UIViewController +- (void)updateDone:(int64_t)done total:(int64_t)total; +@end + +@implementation WQScanWindowController { + UIProgressView *_bar; + UILabel *_status; + CFAbsoluteTime _started; +} + +- (void)viewDidLoad +{ + [super viewDidLoad]; + self.view.backgroundColor = UIColor.systemBackgroundColor; + _started = CFAbsoluteTimeGetCurrent(); + UIImageView *icon = [[UIImageView alloc] initWithImage:[UIImage systemImageNamed:@"magnifyingglass"]]; + icon.preferredSymbolConfiguration = [UIImageSymbolConfiguration configurationWithPointSize:40 weight:UIImageSymbolWeightRegular]; + icon.tintColor = UIColor.tintColor; + UILabel *title = [UILabel new]; + title.text = WQLocalizedNullable(@"Scanning all files"); + title.font = [UIFont preferredFontForTextStyle:UIFontTextStyleTitle2]; + title.adjustsFontForContentSizeCategory = YES; + UILabel *text = [UILabel new]; + text.numberOfLines = 0; + text.textAlignment = NSTextAlignmentCenter; + text.font = [UIFont preferredFontForTextStyle:UIFontTextStyleBody]; + text.textColor = UIColor.secondaryLabelColor; + text.text = WQLocalizedNullable(@"iOS doesn't let WQ Files list these folders directly, so it checks every file on this device once. This is only slow the first time: later scans only check new files.\n\nKeep WQ Files open. Switching to another app or locking the screen pauses the scan."); + _bar = [[UIProgressView alloc] initWithProgressViewStyle:UIProgressViewStyleDefault]; + _status = [UILabel new]; + _status.font = [UIFont monospacedDigitSystemFontOfSize:[UIFont preferredFontForTextStyle:UIFontTextStyleBody].pointSize + weight:UIFontWeightRegular]; + _status.textAlignment = NSTextAlignmentCenter; + _status.text = WQLocalizedNullable(@"Starting…"); + UIButton *hide = [UIButton buttonWithConfiguration:UIButtonConfiguration.borderedButtonConfiguration primaryAction: + [UIAction actionWithTitle:WQLocalizedNullable(@"Hide") image:nil identifier:nil handler:^(UIAction *__unused a) { + [self dismissViewControllerAnimated:YES completion:nil]; + }]]; + UIButtonConfiguration *stopConfig = UIButtonConfiguration.borderedButtonConfiguration; + stopConfig.baseForegroundColor = UIColor.systemRedColor; + UIButton *stop = [UIButton buttonWithConfiguration:stopConfig primaryAction: + [UIAction actionWithTitle:WQLocalizedNullable(@"Stop Scan") image:nil identifier:nil handler:^(UIAction *__unused a) { + wq_full_scan_stop(); + [self dismissViewControllerAnimated:YES completion:nil]; + }]]; + UIStackView *buttons = [[UIStackView alloc] initWithArrangedSubviews:@[hide, stop]]; + buttons.spacing = 12; + buttons.distribution = UIStackViewDistributionFillEqually; + UILabel *note = [UILabel new]; + note.numberOfLines = 0; + note.textAlignment = NSTextAlignmentCenter; + note.font = [UIFont preferredFontForTextStyle:UIFontTextStyleFootnote]; + note.textColor = UIColor.tertiaryLabelColor; + note.text = WQLocalizedNullable(@"Hide keeps the scan running; the list fills in when it's done."); + UIStackView *stack = [[UIStackView alloc] initWithArrangedSubviews:@[icon, title, text, _bar, _status, buttons, note]]; + stack.axis = UILayoutConstraintAxisVertical; + stack.alignment = UIStackViewAlignmentCenter; + stack.spacing = 16; + stack.translatesAutoresizingMaskIntoConstraints = NO; + [self.view addSubview:stack]; + UILayoutGuide *g = self.view.safeAreaLayoutGuide; + [NSLayoutConstraint activateConstraints:@[ + [stack.centerYAnchor constraintEqualToAnchor:g.centerYAnchor], + [stack.leadingAnchor constraintEqualToAnchor:g.leadingAnchor constant:24], + [stack.trailingAnchor constraintEqualToAnchor:g.trailingAnchor constant:-24], + [_bar.widthAnchor constraintEqualToAnchor:stack.widthAnchor], + [buttons.widthAnchor constraintEqualToAnchor:stack.widthAnchor], + [text.widthAnchor constraintEqualToAnchor:stack.widthAnchor], + [note.widthAnchor constraintEqualToAnchor:stack.widthAnchor], + ]]; +} + +- (void)updateDone:(int64_t)done total:(int64_t)total +{ + if (total <= 0) return; + float fraction = (float)((double)done / (double)total); + [_bar setProgress:fraction animated:NO]; + double elapsed = CFAbsoluteTimeGetCurrent() - _started; + NSString *left = @""; + if (elapsed > 5 && done > 0 && done < total) { + double remaining = elapsed * (double)(total - done) / (double)done; + left = WQLocalizedFormat(@" · %@ left", wq_duration_text(remaining)); + } + _status.text = [NSString stringWithFormat:@"%.0f%%%@", fraction * 100, left]; +} + +@end + +static WQScanWindowController *gScanWindow; +static UIBackgroundTaskIdentifier gScanBackgroundTask; // 0 is UIBackgroundTaskInvalid + +// Called on a scan thread: asks on the main thread and waits for the answer. +static BOOL wq_confirm_full_scan(NSString *what, int64_t ids, double seconds) +{ + dispatch_semaphore_t answered = dispatch_semaphore_create(0); + __block BOOL start = NO; + dispatch_async(dispatch_get_main_queue(), ^{ + UIViewController *top = wq_top_view_controller(); + if (!top) { dispatch_semaphore_signal(answered); return; } // not in front: don't scan unasked + NSString *message = WQLocalizedFormat(@"To list %@, WQ Files has to check every file on this device once: %@ files, %@.\n\nThis is only needed the first time; later scans only check new files and take seconds.\n\nKeep WQ Files open while it scans.", what, wq_count_text(ids), wq_duration_text(seconds)); + UIAlertController *alert = wq_alert_controller(@"Scan all files?", message, UIAlertControllerStyleAlert); + [alert addAction:wq_alert_action(@"Not Now", UIAlertActionStyleCancel, ^(UIAlertAction *__unused a) { + dispatch_semaphore_signal(answered); + })]; + [alert addAction:wq_alert_action(@"Start Scan", UIAlertActionStyleDefault, ^(UIAlertAction *__unused a) { + start = YES; + // A little extra time when the user switches away (iOS grants about 30 s). + if (gScanBackgroundTask == UIBackgroundTaskInvalid) { + gScanBackgroundTask = [UIApplication.sharedApplication beginBackgroundTaskWithName:@"WQ full scan" expirationHandler:^{ + [UIApplication.sharedApplication endBackgroundTask:gScanBackgroundTask]; + gScanBackgroundTask = UIBackgroundTaskInvalid; + }]; + } + WQScanWindowController *window = [WQScanWindowController new]; + window.modalPresentationStyle = UIModalPresentationPageSheet; + window.sheetPresentationController.detents = @[UISheetPresentationControllerDetent.mediumDetent, + UISheetPresentationControllerDetent.largeDetent]; + window.sheetPresentationController.prefersGrabberVisible = YES; + gScanWindow = window; + dispatch_semaphore_signal(answered); + // After the alert has gone (presenting during its dismissal fails). + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.4 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + UIViewController *host = wq_top_view_controller(); + if (gScanWindow == window && host && host != window) [host presentViewController:window animated:YES completion:nil]; + }); + })]; + [top presentViewController:alert animated:YES completion:nil]; + }); + dispatch_semaphore_wait(answered, DISPATCH_TIME_FOREVER); + return start; +} + +static void wq_full_scan_progress(int64_t done, int64_t total, BOOL finished) +{ + dispatch_async(dispatch_get_main_queue(), ^{ + if (!finished) { [gScanWindow updateDone:done total:total]; return; } + WQScanWindowController *window = gScanWindow; + gScanWindow = nil; + if (window.presentingViewController && !window.isBeingDismissed) [window dismissViewControllerAnimated:YES completion:nil]; + if (gScanBackgroundTask != UIBackgroundTaskInvalid) { + [UIApplication.sharedApplication endBackgroundTask:gScanBackgroundTask]; + gScanBackgroundTask = UIBackgroundTaskInvalid; + } + }); +} + +void WBSetupWQFileUI(void) +{ + wq_set_container_label_resolver(wq_bundle_id_for_container); + wq_set_container_list_provider(wq_ls_container_paths); + wq_set_full_scan_ui(wq_confirm_full_scan, wq_full_scan_progress); +} + +@interface WQAppDelegate : UIResponder +@end + +@implementation WQAppDelegate + +- (UISceneConfiguration *)application:(UIApplication *)application +configurationForConnectingSceneSession:(UISceneSession *)session + options:(UISceneConnectionOptions *)options +{ + UISceneConfiguration *config = [[UISceneConfiguration alloc] initWithName:@"Default" sessionRole:session.role]; + config.delegateClass = WQSceneDelegate.class; + return config; +} + +@end + +int main(int argc, char *argv[]) +{ + @autoreleasepool { + // Container folders get their bundle id from the LaunchServices database + // when the identifier attribute can't be read (iOS 27.2). + wq_set_container_label_resolver(wq_bundle_id_for_container); + wq_set_container_list_provider(wq_ls_container_paths); + wq_set_full_scan_ui(wq_confirm_full_scan, wq_full_scan_progress); + return UIApplicationMain(argc, argv, nil, NSStringFromClass(WQAppDelegate.class)); + } +}