From 2caab921fa375455c89f4be5ec700a194d3ab360 Mon Sep 17 00:00:00 2001 From: Dani Sarfati Date: Sat, 20 Jun 2026 09:53:29 -0400 Subject: [PATCH 1/2] =?UTF-8?q?iris-gui:=20App=20Store=20polish=20?= =?UTF-8?q?=E2=80=94=20keyboard=20capture,=20sandbox=20CHD=20fold,=20licen?= =?UTF-8?q?ses,=20UI=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keyboard capture (input.rs): - Lock the framebuffer focus filter so Tab / arrows / Esc reach the guest instead of being eaten by egui focus navigation. - Forward Ctrl+C/X/V (egui-winit otherwise swallows them as clipboard commands), map F5, and let Ctrl+Alt+F11 send a bare F11 to IRIX. Sandbox CHD consolidation (the fold needs folder, not file, access): - handle.rs surfaces the fold error instead of swallowing it. - Start + disk-assignment preflight (dir_writable probe) and a ChdGrantModal that prompts for a recursive folder grant, with a dedicated-folder tip. - Auto-consolidate on a clean guest power-off (cpu_stopped edge), since macOS Cmd+Q bypasses the close-time fold (winit 0.29 has no applicationShouldTerminate). Local sandboxed build (test the App Sandbox without the App Store): - installer/iris-gui-sandbox-local.entitlements + `build-macos.sh appstore` (builds --features appstore, signs with app-sandbox). GUI: - Fix tofu glyphs: status dots are now sized bullets (U+2022); replaced U+25CF/2713/2717/2715/1F5C2/23FB and U+2192 with rendering equivalents. - Build-features panel reports "jit: off (sandbox)" at runtime. - Rename-machine is now a modal (the in-menu text box reset every frame). - Config editor fills the pane when idle; header shows "Configuration — ". - Help: Licenses... shows BSD-3-Clause (IRIS) plus GPL-3.0 (libchdman-rs/CHD) with source links, and Privacy policy... — both embedded via include_str!. - Reworded "the serial console is NOT the network", removed "for App Review", renamed "Network test" to "Serial console". LICENSE-GPL3.txt brought onto this branch (CHD builds are conveyed under GPL-3.0). Findings written up under rules/gui and rules/macos. Co-Authored-By: Claude Opus 4.8 (1M context) --- LICENSE-GPL3.txt | 674 ++++++++++++++++++ installer/iris-gui-sandbox-local.entitlements | 57 ++ iris-gui/src/config_ui.rs | 73 +- iris-gui/src/handle.rs | 29 +- iris-gui/src/input.rs | 58 +- iris-gui/src/main.rs | 558 +++++++++++++-- ...passes-close-intercept-fold-on-poweroff.md | 67 ++ ...-default-font-glyph-coverage-avoid-tofu.md | 55 ++ ...egui-steals-tab-arrows-esc-and-ctrl-cxv.md | 91 +++ ...-fold-needs-folder-grant-not-file-grant.md | 98 +++ ...p-sandbox-locally-without-the-app-store.md | 57 ++ scripts/build-macos.sh | 49 +- 12 files changed, 1767 insertions(+), 99 deletions(-) create mode 100644 LICENSE-GPL3.txt create mode 100644 installer/iris-gui-sandbox-local.entitlements create mode 100644 rules/gui/cmd-q-bypasses-close-intercept-fold-on-poweroff.md create mode 100644 rules/gui/egui-default-font-glyph-coverage-avoid-tofu.md create mode 100644 rules/gui/keyboard-capture-egui-steals-tab-arrows-esc-and-ctrl-cxv.md create mode 100644 rules/macos/chd-fold-needs-folder-grant-not-file-grant.md create mode 100644 rules/macos/test-the-app-sandbox-locally-without-the-app-store.md diff --git a/LICENSE-GPL3.txt b/LICENSE-GPL3.txt new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE-GPL3.txt @@ -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/installer/iris-gui-sandbox-local.entitlements b/installer/iris-gui-sandbox-local.entitlements new file mode 100644 index 0000000..477c8d0 --- /dev/null +++ b/installer/iris-gui-sandbox-local.entitlements @@ -0,0 +1,57 @@ + + + + + + com.apple.security.app-sandbox + + + + com.apple.security.cs.allow-jit + + + + com.apple.security.device.camera + + + + com.apple.security.files.user-selected.read-write + + com.apple.security.files.bookmarks.app-scope + + + + com.apple.security.network.client + + com.apple.security.network.server + + + diff --git a/iris-gui/src/config_ui.rs b/iris-gui/src/config_ui.rs index 2f7ab7e..db4761e 100644 --- a/iris-gui/src/config_ui.rs +++ b/iris-gui/src/config_ui.rs @@ -172,6 +172,11 @@ pub enum ConfigAction { pub struct TabOutcome { pub action: ConfigAction, pub net: NetworkOutcome, + /// A SCSI image/disc path changed this frame (typed or picked) — mark dirty. + pub disks_changed: bool, + /// A SCSI image/disc path was just assigned via the Browse picker — the cue + /// to (re)check CHD folder-grant permissions (see `check_chd_folder_grants`). + pub disk_picked: bool, } pub fn show_tab( @@ -185,10 +190,10 @@ pub fn show_tab( ) -> TabOutcome { ScrollArea::vertical().show(ui, |ui| match tab { Tab::General => TabOutcome { action: show_general(ui, cfg), ..Default::default() }, - Tab::Disks => { show_disks(ui, cfg); TabOutcome::default() } + Tab::Disks => { let e = show_disks(ui, cfg); TabOutcome { disks_changed: e.changed, disk_picked: e.picked, ..Default::default() } } Tab::Network => { let net = show_network(ui, cfg, host, disk_folders, pcap_ifaces); - TabOutcome { action: net.action, net } + TabOutcome { action: net.action, net, ..Default::default() } } Tab::Memory => { show_memory(ui, cfg); TabOutcome::default() } Tab::Display => { show_display(ui, cfg); TabOutcome::default() } @@ -279,7 +284,8 @@ fn show_display(ui: &mut Ui, cfg: &mut MachineConfig) { }); } -fn show_disks(ui: &mut Ui, cfg: &mut MachineConfig) { +fn show_disks(ui: &mut Ui, cfg: &mut MachineConfig) -> PathEdit { + let mut edit = PathEdit::default(); ui.heading("SCSI devices"); ui.horizontal(|ui| { ui.label("IDs 1–7. CD-ROMs typically use 4–6."); @@ -314,9 +320,11 @@ fn show_disks(ui: &mut Ui, cfg: &mut MachineConfig) { if let Some(dev) = cfg.scsi.get_mut(&id) { Grid::new(("scsi_grid", id)).num_columns(2).striped(true).show(ui, |ui| { ui.label("Image path"); - path_row(ui, ("scsi_path", id), &mut dev.path, + let e = path_row(ui, ("scsi_path", id), &mut dev.path, if dev.scratch { Pick::SaveFile } else { Pick::OpenFile }, DISK_FILTERS); + edit.changed |= e.changed; + edit.picked |= e.picked; ui.end_row(); if dev.path.ends_with(".chd") && !build_features::CHD { ui.label(""); @@ -324,6 +332,28 @@ fn show_disks(ui: &mut Ui, cfg: &mut MachineConfig) { .color(Color32::from_rgb(230, 140, 70))); ui.end_row(); } + // Active copy-on-write overlay for a compressed CHD: show exactly + // which `.diff.chd` is in use (the path honours IRIS_CHD_DIFF_DIR, + // so on the sandbox build this is the container sidecar) and its + // size, so it's unambiguous that changes are landing here and that + // this is the file folded back into the disk on a clean exit. + if build_features::CHD && dev.path.ends_with(".chd") { + let diff = iris::chd_disk::diff_path_for(Path::new(&dev.path)); + if let Ok(meta) = std::fs::metadata(&diff) { + let mb = meta.len() as f64 / (1024.0 * 1024.0); + ui.label("Active overlay"); + ui.horizontal(|ui| { + ui.label(RichText::new(format!("{} ({mb:.1} MB)", diff.display())) + .weak().small()) + .on_hover_text("This CHD's session changes accumulate here until they're \ + folded back into the disk on a clean exit."); + if ui.small_button("📂").on_hover_text("Reveal in file manager").clicked() { + reveal_in_file_manager(&diff.to_string_lossy()); + } + }); + ui.end_row(); + } + } ui.label("Type"); let was_cd = dev.cdrom; @@ -379,7 +409,9 @@ fn show_disks(ui: &mut Ui, cfg: &mut MachineConfig) { let mut drop_idx: Option = None; for (i, disc) in dev.discs.iter_mut().enumerate() { ui.horizontal(|ui| { - path_row(ui, ("disc", id, i), disc, Pick::OpenFile, DISK_FILTERS); + let e = path_row(ui, ("disc", id, i), disc, Pick::OpenFile, DISK_FILTERS); + edit.changed |= e.changed; + edit.picked |= e.picked; if ui.button("×").clicked() { drop_idx = Some(i); } }); } @@ -391,6 +423,7 @@ fn show_disks(ui: &mut Ui, cfg: &mut MachineConfig) { } } if let Some(id) = to_delete { cfg.scsi.remove(&id); } + edit } /// A soft-invalid subnet the user just entered, surfaced to the app so it can @@ -696,7 +729,7 @@ fn show_network( if let Some(nfs) = cfg.nfs.as_mut() { Grid::new("nfs_grid").num_columns(2).striped(true).show(ui, |ui| { ui.label("Shared dir"); - out.changed |= path_row(ui, "nfs_shared", &mut nfs.shared_dir, Pick::Dir, ANY_FILTERS); + out.changed |= path_row(ui, "nfs_shared", &mut nfs.shared_dir, Pick::Dir, ANY_FILTERS).changed; ui.end_row(); ui.label("NFS version"); ComboBox::from_id_salt("nfs_ver") @@ -722,7 +755,7 @@ fn show_network( if disk_folders.is_empty() { ui.label(RichText::new( "On the App Store build the shared folder must live somewhere the app has been \ - granted. Grant a disk folder first (File → \"Grant a disk folder…\"), then create \ + granted. Grant a disk folder first (File » \"Grant a disk folder…\"), then create \ a shared folder inside it here — or pick any folder above to grant it directly.") .weak()); } else { @@ -1024,20 +1057,29 @@ pub fn reveal_in_file_manager(path: &str) { } } -/// A TextEdit + 📁 Browse button that updates `value` in place. Returns whether -/// `value` changed this frame, so callers can mark the config dirty (typed text -/// or a Browse pick — both must persist). +/// Outcome of a [`path_row`]: whether `value` changed this frame (typed text or +/// a Browse pick — both must persist), and whether the change specifically came +/// from the Browse *picker*. The latter is the "assignment" moment — the only +/// safe time to (re)check folder-grant permissions, since reacting to every +/// keystroke would pop a dialog mid-typing. +#[derive(Default, Clone, Copy)] +struct PathEdit { + changed: bool, + picked: bool, +} + +/// A TextEdit + 📁 Browse button that updates `value` in place. See [`PathEdit`]. fn path_row( ui: &mut Ui, id: impl std::hash::Hash, value: &mut String, mode: Pick, filters: &[(&str, &[&str])], -) -> bool { - let mut changed = false; +) -> PathEdit { + let mut out = PathEdit::default(); ui.push_id(id, |ui| { ui.horizontal(|ui| { - changed |= ui.add(TextEdit::singleline(value).desired_width(320.0)).changed(); + out.changed |= ui.add(TextEdit::singleline(value).desired_width(320.0)).changed(); if ui.button("📁").on_hover_text("Browse…").clicked() { let mut d = rfd::FileDialog::new(); // Start the dialog in the existing path's directory if any. @@ -1064,7 +1106,8 @@ fn path_row( }; if let Some(p) = picked { *value = p.to_string_lossy().into_owned(); - changed = true; + out.changed = true; + out.picked = true; } } // Reveal an existing path in the host file manager (Finder, Explorer, @@ -1076,7 +1119,7 @@ fn path_row( } }); }); - changed + out } /// Same as `path_row` but for `Option` — Browse populates Some, diff --git a/iris-gui/src/handle.rs b/iris-gui/src/handle.rs index b8b6db2..8dbff33 100644 --- a/iris-gui/src/handle.rs +++ b/iris-gui/src/handle.rs @@ -424,14 +424,27 @@ fn worker_loop( *ps2_slot.lock() = None; cycles = None; m.stop(); - synced = m - .sync_chd_disks( - &mut |disk, total, fraction| { - let _ = evt_tx.send(Evt::SyncProgress { disk, total, fraction }); - }, - &|| false, - ) - .unwrap_or(0); + synced = match m.sync_chd_disks( + &mut |disk, total, fraction| { + let _ = evt_tx.send(Evt::SyncProgress { disk, total, fraction }); + }, + &|| false, + ) { + Ok(n) => n, + Err(e) => { + // Don't swallow this. The fold writes a `.synctmp.chd` + // beside the base and atomically renames it over the + // base — both need write access to the *folder*, which + // under the macOS App Sandbox a file-scoped grant (just + // picking the disk image) does not convey. Surfaced so + // the diff isn't silently left unmerged and the disk + // never shrinks. + let _ = evt_tx.send(Evt::Error(format!( + "couldn't compact CHD disks on exit: {e} — grant the disk's \ + folder (File » \"Grant a disk folder…\") so IRIS can write beside it"))); + 0 + } + }; // `m` dropped here → fully torn down. } let _ = evt_tx.send(Evt::Stopped); diff --git a/iris-gui/src/input.rs b/iris-gui/src/input.rs index 7e79fee..878e429 100644 --- a/iris-gui/src/input.rs +++ b/iris-gui/src/input.rs @@ -64,6 +64,7 @@ pub fn pump(ctx: &egui::Context, fb_clicked: bool, ps2: &Ps2Controller, state: & let mut buttons = state.last_buttons; let mut mods = state.last_mods; let mut keys: Vec<(KeyCode, bool)> = Vec::new(); + let mut f11_to_guest = false; ctx.input(|i| { if !state.captured { @@ -90,9 +91,31 @@ pub fn pump(ctx: &egui::Context, fb_clicked: bool, ps2: &Ps2Controller, state: & match ev { // Raw relative motion (eframe → DeviceEvent::MouseMotion). Event::MouseMoved(d) => { dx += d.x; dy += d.y; } - Event::Key { key, pressed, .. } => { - if let Some(kc) = map_key(*key) { keys.push((kc, *pressed)); } + Event::Key { key, pressed, repeat, .. } => { + if *key == Key::F11 { + // Plain F11 is the GUI's fullscreen toggle and is never + // forwarded. Ctrl+Alt+F11 is the escape hatch that delivers + // a real F11 to IRIX — recorded here on the press edge and + // sent (as a bare F11) after the modifier diff below. + if *pressed && !*repeat && i.modifiers.ctrl && i.modifiers.alt { + f11_to_guest = true; + } + } else if let Some(kc) = map_key(*key) { + keys.push((kc, *pressed)); + } } + // egui-winit swallows Ctrl/Cmd + C/X/V into clipboard *commands* + // (Cut/Copy/Paste) and never emits the underlying Key event — so + // on Linux and Windows, where `command == ctrl`, the guest would + // never see Ctrl+C (SIGINT in a shell), Ctrl+X, or Ctrl+V. Re- + // synthesise the bare letter as a tap; the held Ctrl is already + // sent by the modifier diff below, so the guest forms the full + // chord. Gated on `ctrl` so macOS Cmd+C/X/V — where real Ctrl+C + // still arrives as a normal Key, and the Cmd combo has no guest + // meaning — is left to the host clipboard. + Event::Copy if i.modifiers.ctrl => { keys.push((KeyCode::KeyC, true)); keys.push((KeyCode::KeyC, false)); } + Event::Cut if i.modifiers.ctrl => { keys.push((KeyCode::KeyX, true)); keys.push((KeyCode::KeyX, false)); } + Event::Paste(_) if i.modifiers.ctrl => { keys.push((KeyCode::KeyV, true)); keys.push((KeyCode::KeyV, false)); } Event::MouseWheel { unit, delta, .. } => { let lines = match unit { MouseWheelUnit::Line => delta.y, @@ -156,6 +179,26 @@ pub fn pump(ctx: &egui::Context, fb_clicked: bool, ps2: &Ps2Controller, state: & // ---- key events ---- for (kc, pressed) in keys { ps2.push_kb(kc, pressed); } + // Ctrl+Alt+F11 → a *bare* F11 to the guest. Plain F11 is swallowed by the + // GUI's fullscreen toggle, so this chord is the only path for F11 into IRIX. + // The modifier diff above has left the chord's Ctrl+Alt (and any Shift/Cmd) + // pressed in the guest, so lift whatever is held, tap F11, then re-press — + // IRIX sees an unmodified F11. `state.last_mods` is left untouched, so the + // next frame's diff stays consistent (no spurious modifier press/release). + if f11_to_guest { + let held = state.last_mods; + if held.shift { ps2.push_kb(KeyCode::ShiftLeft, false); } + if held.ctrl { ps2.push_kb(KeyCode::ControlLeft, false); } + if held.alt { ps2.push_kb(KeyCode::AltLeft, false); } + if held.mac_cmd { ps2.push_kb(KeyCode::SuperLeft, false); } + ps2.push_kb(KeyCode::F11, true); + ps2.push_kb(KeyCode::F11, false); + if held.shift { ps2.push_kb(KeyCode::ShiftLeft, true); } + if held.ctrl { ps2.push_kb(KeyCode::ControlLeft, true); } + if held.alt { ps2.push_kb(KeyCode::AltLeft, true); } + if held.mac_cmd { ps2.push_kb(KeyCode::SuperLeft, true); } + } + // ---- mouse: raw per-frame delta + button diff + scroll. ---- let (mdx, mdy, mdz) = (dx as i32, dy as i32, dz as i32); if buttons != state.last_buttons || mdx != 0 || mdy != 0 || mdz != 0 { @@ -295,11 +338,12 @@ fn map_key(k: Key) -> Option { // guest forms '|' and '?'). Without them those keys send nothing. Key::Pipe => KeyCode::Backslash, Key::Questionmark => KeyCode::Slash, - // F-keys (egui has no F5; iris likely doesn't need F13+ either) - Key::F1 => KeyCode::F1, Key::F2 => KeyCode::F2, Key::F3 => KeyCode::F3, - Key::F4 => KeyCode::F4, Key::F6 => KeyCode::F6, Key::F7 => KeyCode::F7, - Key::F8 => KeyCode::F8, Key::F9 => KeyCode::F9, Key::F10 => KeyCode::F10, - // F11 is consumed by the GUI (fullscreen toggle); don't forward. + // F-keys. F11 is reserved by the GUI (fullscreen toggle), so it isn't + // forwarded; iris's PS/2 scancode set stops at F12, so F13+ are dropped. + Key::F1 => KeyCode::F1, Key::F2 => KeyCode::F2, Key::F3 => KeyCode::F3, + Key::F4 => KeyCode::F4, Key::F5 => KeyCode::F5, Key::F6 => KeyCode::F6, + Key::F7 => KeyCode::F7, Key::F8 => KeyCode::F8, Key::F9 => KeyCode::F9, + Key::F10 => KeyCode::F10, Key::F12 => KeyCode::F12, _ => return None, }) diff --git a/iris-gui/src/main.rs b/iris-gui/src/main.rs index e260e24..d8ef4c2 100644 --- a/iris-gui/src/main.rs +++ b/iris-gui/src/main.rs @@ -159,6 +159,12 @@ struct App { fullscreen: bool, stop_modal: Option, missing_modal: Option, + /// Set on Start when attached CHDs sit in folders we can't write (App Store + /// sandbox) — prompts the user to grant the folder so on-exit fold can work. + chd_grant_modal: Option, + /// One-shot: the user chose "Start without compacting", so the next + /// `start_emulator` skips the CHD folder-grant preflight. Consumed on read. + skip_chd_grant_check: bool, /// Set when the user clicks "Use embedded PROM"; drives a confirmation modal. confirm_embedded_prom: bool, /// Host interface networks (from `if-addrs`), used by the Networking tab for @@ -172,6 +178,9 @@ struct App { /// If true, central panel shows the tabbed config editor; otherwise the /// welcome/status summary panel (default — most config lives in menus). show_config_editor: bool, + /// In-progress name in the "Rename machine" modal (`Some` while it's open). + /// Kept as App state, not a per-frame local, so typed input persists. + rename_buffer: Option, /// Whether the "Check networking" diagnosis window is open. show_net_check: bool, save_state_name: String, @@ -219,6 +228,9 @@ struct App { /// exactly once on that transition. Reset to true on Start so the initial /// idle-at-PROM state doesn't count as a halt. prev_cpu_halted: bool, + /// Previous frame's `cpu_stopped`, to edge-trigger the auto-consolidation + /// when the guest powers itself off (a clean IRIX shutdown). + prev_cpu_stopped: bool, /// Active "Test Camera" preview (None when the window is closed). Opening it /// starts host-camera capture; dropping it releases the device. camera_test: Option, @@ -234,6 +246,9 @@ struct App { show_help_info: bool, /// Whether the "Mount the shared folder in IRIX" Help window is open. show_nfs_help: bool, + /// Whether the License / Privacy Help windows are open. + show_license: bool, + show_privacy: bool, /// Active "Synchronizing disks…" job (folding CHD diffs back into bases on a /// clean exit); `Some` shows the modal. `None` when no sync is in flight. syncing: Option, @@ -311,6 +326,38 @@ fn disk_readable(path: &str) -> bool { matches!(f.read(&mut probe), Ok(n) if n >= 1) } +/// True if we can create (and remove) a file *in* `dir` — i.e. we hold write +/// access to the directory itself, not merely to the files already inside it. +/// +/// This matters for the exit-time CHD fold ("Synchronizing disks…"): it writes +/// a `.synctmp.chd` beside the base and atomically renames it over the base, +/// both of which need directory write access. Under the macOS App Sandbox a +/// *file*-scoped grant (the user picking a disk image) does NOT convey that — +/// only a folder grant (a directory security-scoped bookmark) does. We probe a +/// real create because, as with [`disk_readable`], the sandbox can let a path +/// `stat()` yet deny the write. The probe file is uniquely named and removed +/// immediately. +fn dir_writable(dir: &std::path::Path) -> bool { + let probe = dir.join(format!(".iris-write-probe-{}", std::process::id())); + match std::fs::File::create(&probe) { + Ok(_) => { + let _ = std::fs::remove_file(&probe); + true + } + Err(_) => false, + } +} + +/// True if a granted folder is currently *reachable* — we can list it. Under the +/// sandbox this is gated by an active security-scoped grant, so it's a cheap, +/// non-writing liveness check (unlike [`dir_writable`], safe to call every frame +/// while a menu is open). Since we only ever mint recursive read-write directory +/// grants, "reachable" implies "writable", so this tells the user whether a +/// recorded folder grant is actually in effect right now. +fn folder_accessible(path: &str) -> bool { + std::fs::read_dir(path).is_ok() +} + /// Modal shown when one or more SCSI image files are missing on Start. /// `Machine::new` would otherwise call `std::process::exit(1)` and take /// the whole GUI down with it. @@ -318,6 +365,24 @@ struct MissingDiskModal { missing: Vec, } +/// One attached CHD whose containing folder the app can't write to, so its +/// exit-time fold would be denied (see [`dir_writable`]). +struct ChdNeedsGrant { + id: u8, + path: String, + /// Absolute parent directory that needs a folder grant. + dir: String, +} + +/// Modal shown on Start (App Store build) when attached CHD disks live in +/// folders the sandbox hasn't granted directory write access to. Without that +/// grant the copy-on-write `.diff.chd` can never be folded back into the base on +/// exit, so the disk's footprint only ever grows. Lets the user grant the +/// folder(s) up front, or start anyway and forgo on-exit compaction. +struct ChdGrantModal { + disks: Vec, +} + impl App { fn new(mut prefs: GuiSettings) -> Self { // Resolution order on startup: @@ -381,12 +446,15 @@ impl App { toast: None, stop_modal: None, missing_modal: None, + chd_grant_modal: None, + skip_chd_grant_check: false, confirm_embedded_prom: false, net_ifaces: netplan::gather_host_ifaces(), net_sanity_modal: None, new_machine, create_disk: CreateDiskDialog::default(), show_config_editor: false, + rename_buffer: None, show_net_check: false, save_state_name: "snap1".into(), restore_state_name: "snap1".into(), @@ -397,6 +465,7 @@ impl App { pending_fb_snap: false, input_state: input::InputState::default(), prev_cpu_halted: true, + prev_cpu_stopped: false, camera_test: None, camera_test_tex: None, camera_test_seq: 0, @@ -404,6 +473,8 @@ impl App { serial_input: String::new(), show_help_info: false, show_nfs_help: false, + show_license: false, + show_privacy: false, syncing: None, sync_then_close: false, cow_discard_confirm: None, @@ -506,6 +577,19 @@ impl App { self.missing_modal = Some(MissingDiskModal { missing }); return; } + // Preflight: under the App Sandbox, folding a compressed CHD's `.diff.chd` + // back into its base on exit needs write access to the disk's *folder*, + // which picking the disk file alone doesn't grant. If any attached CHD is + // in a folder we can't write, prompt the user to grant it now — otherwise + // the disk silently never shrinks. "Start without compacting" sets the + // one-shot bypass so the user can proceed regardless. + if !std::mem::take(&mut self.skip_chd_grant_check) { + let needs_grant = self.chd_dirs_needing_grant(); + if !needs_grant.is_empty() { + self.chd_grant_modal = Some(ChdGrantModal { disks: needs_grant }); + return; + } + } // Surface the embedded-PROM fallback so it's clear the start did // happen (iris::prom::Prom::from_file_or_embedded handles this // transparently — we just echo it to the toast). @@ -536,6 +620,9 @@ impl App { // Assume halted at boot (idle at the PROM) so the auto-release only // fires on a later running→halted transition, not at startup. self.prev_cpu_halted = true; + // Fresh run isn't powered off; a later running→stopped edge means the + // guest shut itself down (drives the auto-consolidation below). + self.prev_cpu_stopped = false; // If the NVRAM has no Ethernet MAC, IRIX won't attach ec0 — networking, // System Manager, and Disk Manager all fail. Offer to set one up, and // hold the machine at the PROM by interrupting autoboot (see the Esc @@ -598,7 +685,18 @@ impl App { /// shared subfolder under it are all accessible from one grant. Persists a /// directory security-scoped bookmark and asserts access immediately. fn grant_disk_folder(&mut self) { - if let Some(dir) = rfd::FileDialog::new().pick_folder() { + self.grant_disk_folder_at(None); + } + + /// As [`grant_disk_folder`](Self::grant_disk_folder), but opens the picker + /// already pointed at `start_dir` (e.g. the folder of a CHD that needs a + /// grant), so the user just confirms it. + fn grant_disk_folder_at(&mut self, start_dir: Option<&str>) { + let mut dialog = rfd::FileDialog::new(); + if let Some(d) = start_dir { + if !d.is_empty() { dialog = dialog.set_directory(d); } + } + if let Some(dir) = dialog.pick_folder() { let path = dir.to_string_lossy().into_owned(); if !self.prefs.disk_folders.contains(&path) { self.prefs.disk_folders.push(path.clone()); @@ -609,6 +707,43 @@ impl App { } } + /// App Store preflight: attached CHD disks (non-scratch HDDs) whose folder + /// we can't write, so their on-exit `.diff.chd` fold would be denied. Empty + /// off the sandbox build, where folders are reachable directly. + fn chd_dirs_needing_grant(&self) -> Vec { + if !cfg!(feature = "appstore") { + return Vec::new(); + } + let mut out = Vec::new(); + for (&id, dev) in &self.cfg.scsi { + // CD-ROM CHDs are read-only (no diff); scratch disks live in the + // writable container. Only writable HDD CHDs ever get folded. + if dev.scratch || dev.cdrom { continue; } + if !dev.path.ends_with(".chd") { continue; } + let Some(parent) = std::path::Path::new(&dev.path).parent() else { continue }; + if !dir_writable(parent) { + out.push(ChdNeedsGrant { + id, + path: dev.path.clone(), + dir: parent.to_string_lossy().into_owned(), + }); + } + } + out.sort_by_key(|c| c.id); + out + } + + /// Pop the folder-grant modal if any attached CHD is in a folder we can't + /// write. Called right after a disk is assigned (Browse pick), so the user + /// resolves permissions up front. No-op if already prompting or all good. + fn check_chd_folder_grants(&mut self) { + if self.chd_grant_modal.is_some() { return; } + let needs = self.chd_dirs_needing_grant(); + if !needs.is_empty() { + self.chd_grant_modal = Some(ChdGrantModal { disks: needs }); + } + } + /// SCSI-menu section: per-disk Commit / Discard for copy-on-write overlays. /// Only offered while the machine is stopped — the disk files are closed /// then, so applying or discarding can't corrupt a running guest. @@ -740,34 +875,27 @@ impl App { } let mut want_switch: Option = None; for name in names { - let marker = if active.as_deref() == Some(&name) { "● " } else { " " }; - if ui.button(format!("{marker}{name}")).clicked() { + // selectable_label highlights the active machine — no + // marker glyph (a leading ● rendered as tofu). + let is_active = active.as_deref() == Some(name.as_str()); + if ui.selectable_label(is_active, name.as_str()).clicked() { want_switch = Some(name); ui.close_menu(); } } if let Some(n) = want_switch { self.switch_to(&n); } }); - ui.menu_button("Rename current…", |ui| { - let cur = self.prefs.active_machine.clone(); - if let Some(name) = cur { - ui.label(format!("Current: {name}")); - let mut new_name = name.clone(); - ui.text_edit_singleline(&mut new_name); - if ui.button("Rename").clicked() && !new_name.trim().is_empty() && new_name != name { - let n = self.prefs.unique_name(new_name.trim()); - if let Some(cfg) = self.prefs.machines.remove(&name) { - self.prefs.machines.insert(n.clone(), cfg); - self.prefs.active_machine = Some(n.clone()); - let _ = self.prefs.save(); - self.toast(format!("renamed -> '{n}'")); - } - ui.close_menu(); - } - } else { - ui.label(RichText::new("(no active machine)").weak()); - } - }); + if ui.add_enabled(self.prefs.active_machine.is_some(), + egui::Button::new("Rename current…")) + .on_disabled_hover_text("No active machine") + .clicked() + { + // Open the rename modal seeded with the current name. (A + // text box inside the menu can't work — the menu closure + // re-runs each frame and would reset the buffer.) + self.rename_buffer = self.prefs.active_machine.clone(); + ui.close_menu(); + } let active = self.prefs.active_machine.clone(); if ui.add_enabled(active.is_some(), egui::Button::new("Delete current machine")).clicked() { if let Some(name) = active { @@ -828,13 +956,27 @@ impl App { ui.close_menu(); } let folders = self.prefs.disk_folders.clone(); + if folders.is_empty() { + ui.label(RichText::new("(no folders granted yet)").weak().small()); + } for f in &folders { ui.horizontal(|ui| { + // Live access state: green bullet if the grant is in + // effect this session, red if it lapsed (re-grant to + // fix). U+2022 (bullet) renders; U+25CF (●) and the + // check/cross dingbats are tofu in egui's label font. + let live = folder_accessible(f); + let (color, tip) = if live { + (Color32::from_rgb(120, 200, 120), "Access is active — IRIS can read/write here") + } else { + (Color32::from_rgb(220, 140, 90), "No access right now — re-grant this folder") + }; + ui.label(RichText::new("\u{2022}").size(15.0).color(color)).on_hover_text(tip); ui.label(RichText::new(f).weak()); if ui.small_button("📂").on_hover_text("Reveal in Finder").clicked() { config_ui::reveal_in_file_manager(f); } - if ui.small_button("✕").on_hover_text("Revoke").clicked() { + if ui.small_button("\u{00D7}").on_hover_text("Revoke").clicked() { self.prefs.disk_folders.retain(|x| x != f); self.prefs.bookmarks.remove(f); let _ = self.prefs.save(); @@ -1004,7 +1146,7 @@ impl App { self.open_camera_test(); ui.close_menu(); } - if ui.add_enabled(running, egui::Button::new("🌐 Network test (serial console)…")) + if ui.add_enabled(running, egui::Button::new("Serial console…")) .on_hover_text("Connect to the emulator's loopback serial server (127.0.0.1:8881)") .on_disabled_hover_text("Start a machine first") .clicked() @@ -1016,7 +1158,7 @@ impl App { self.show_help_info = true; ui.close_menu(); } - if ui.button("🗂 Mount the shared folder in IRIX…") + if ui.button("📂 Mount the shared folder in IRIX…") .on_hover_text("The exact mount command for the NFS share") .clicked() { @@ -1024,6 +1166,19 @@ impl App { ui.close_menu(); } ui.separator(); + ui.label(RichText::new("Legal").strong()); + if ui.button("Licenses…") + .on_hover_text("BSD 3-Clause (IRIS) — plus GPL-3.0 for the CHD backend when built in") + .clicked() + { + self.show_license = true; + ui.close_menu(); + } + if ui.button("Privacy policy…").clicked() { + self.show_privacy = true; + ui.close_menu(); + } + ui.separator(); ui.label(RichText::new("Authors").strong()); ui.label("Original: techomancer"); ui.label("iris-gui fork: Dani Sarfati (danifunker)"); @@ -1041,8 +1196,15 @@ impl App { use iris::build_features as bf; ui.label(format!(" chd: {}", if bf::CHD { "on" } else { "off" })); ui.label(format!(" camera: {}", if bf::CAMERA { "on" } else { "off" })); - ui.label(format!(" jit: {}", if bf::JIT { "on" } else { "off" })); - ui.label(format!(" rex-jit: {}", if bf::REX_JIT { "on" } else { "off" })); + // jit/rex-jit are compile-time features, but the sandbox (App + // Store) build forces interpreter-only at runtime via IRIS_NO_JIT + // (Cranelift's non-MAP_JIT pages get killed under the sandbox). + // Report the runtime reality so a compiled-in "jit: on" doesn't + // read as "the JIT is running" when it can't be. + let jit_off = std::env::var_os("IRIS_NO_JIT").is_some(); + let jit_state = |feat: bool| if jit_off { "off (sandbox)" } else if feat { "on" } else { "off" }; + ui.label(format!(" jit: {}", jit_state(bf::JIT))); + ui.label(format!(" rex-jit: {}", jit_state(bf::REX_JIT))); ui.label(format!(" lightning: {}", if bf::LIGHTNING { "on (no debug)" } else { "off" })); }); }); @@ -1108,6 +1270,7 @@ impl App { if self.input_state.captured { ui.label(RichText::new("Mouse/Keyboard Captured").color(Color32::LIGHT_GREEN)); ui.label(RichText::new("To disable: Ctrl+Alt+Esc").weak()); + ui.label(RichText::new("Send F11 to IRIX: Ctrl+Alt+F11").weak()); } else { ui.label(RichText::new("Mouse/Keyboard Capture Disabled").weak()); if ui @@ -1310,7 +1473,11 @@ impl App { }; let mut want_check = false; ui.horizontal(|ui| { - ui.label(RichText::new("\u{25CF}").color(net_color)).on_hover_text(net_tip); + // U+2022 (bullet), sized up as a status light. NOT U+25CF (●), + // which renders as tofu — egui's label font chain (Ubuntu-Light → + // NotoEmoji → emoji-icon-font) has no filled circle; U+25CF is only + // in Hack, which is monospace-only. See rules/gui/egui-…-tofu.md. + ui.label(RichText::new("\u{2022}").size(18.0).color(net_color)).on_hover_text(net_tip); ui.label("NET").on_hover_text(net_tip); if running && ui.small_button("check").on_hover_text("Diagnose guest networking").clicked() { want_check = true; @@ -1490,6 +1657,7 @@ impl App { new_fb_scale = size.y * zoom / tex_size.y; } let mut fb_rect = None; + let captured = self.input_state.captured; ui.centered_and_justified(|ui| { let response = ui.add( egui::Image::new((tex.id(), size)).fit_to_exact_size(size).sense(egui::Sense::click()) @@ -1498,6 +1666,27 @@ impl App { // to us instead of routing them to other widgets when // the user clicks into the FB. if response.clicked() { response.request_focus(); fb_clicked = true; } + // While captured, keep focus pinned to the framebuffer and claim + // the focus-navigation keys. By default egui's focus engine + // treats Tab / arrow keys / Esc as widget navigation: it moves + // focus off the framebuffer (and the widget it lands on can then + // swallow later keystrokes), so those keys never reach the guest. + // Locking the filter tells egui they belong to us — they stay in + // the event stream and pump() forwards them. Plain Esc still + // reaches the guest; the Ctrl+Alt+Esc release chord is detected + // globally in pump(), before any key is forwarded. + if captured { + if !response.has_focus() { response.request_focus(); } + ui.memory_mut(|m| m.set_focus_lock_filter( + response.id, + egui::EventFilter { + tab: true, + horizontal_arrows: true, + vertical_arrows: true, + escape: true, + }, + )); + } fb_rect = Some(response.rect); }); @@ -1514,7 +1703,7 @@ impl App { p.text( rect.center(), egui::Align2::CENTER_CENTER, - "⏻ Powered off", + "Powered off", egui::FontId::proportional(26.0), Color32::from_rgba_unmultiplied(235, 235, 235, 235), ); @@ -1536,6 +1725,26 @@ impl App { } self.prev_cpu_halted = halted; + // Auto-consolidate on a clean guest power-off. A guest `poweroff` stops + // the CPU thread (`cpu_stopped`) while the GUI still considers the + // machine running — the worker isn't told, and only a user STOP sets + // `running=false`, so `running && cpu_stopped` uniquely identifies "the + // guest shut itself down". Fold any pending CHD overlay back into its + // base NOW so consolidation happens even when the user later quits via + // Cmd+Q (which bypasses the close-time fold — winit 0.29 doesn't hook + // applicationShouldTerminate). Edge-triggered → fires once per power-off. + // (`Evt::PowerOff` is never emitted — it awaits a core subscribe API — so + // we key off the status-derived `cpu_stopped`, same as the overlay.) + let stopped = self.emu.status.cpu_stopped; + if stopped && !self.prev_cpu_stopped && self.emu.is_running() + && self.emu.has_pending_chd_sync() && self.syncing.is_none() + { + self.toast("guest powered off — synchronizing disks…"); + self.syncing = Some(SyncJob { disk: 0, total: 0, fraction: 0.0 }); + self.emu.send(Cmd::SyncDisks); + } + self.prev_cpu_stopped = stopped; + // Pump egui input → PS/2 controller. Mouse/keyboard only reach the // guest while captured (click the framebuffer to capture, Ctrl+Alt+Esc // to release), so menu clicks and config typing don't leak in. @@ -1546,6 +1755,38 @@ impl App { } + /// Apply a rename of the active machine to `new_name` (from the modal). + /// No-op for an empty/unchanged name or when there's no active machine. + fn apply_rename(&mut self, new_name: &str) { + let new = new_name.trim(); + let Some(old) = self.prefs.active_machine.clone() else { return; }; + if new.is_empty() || new == old { return; } + let n = self.prefs.unique_name(new); + if let Some(cfg) = self.prefs.machines.remove(&old) { + self.prefs.machines.insert(n.clone(), cfg); + self.prefs.active_machine = Some(n.clone()); + let _ = self.prefs.save(); + self.toast(format!("renamed to '{n}'")); + } + } + + /// Heading + tabbed config editor body, used both as the right side panel + /// (while a machine runs) and full-width in the central panel (while idle). + /// The header names the active machine ("default" if unset). + fn config_editor_panel(&mut self, ui: &mut egui::Ui) { + let machine = self.prefs.active_machine.as_deref().unwrap_or("default").to_string(); + ui.horizontal(|ui| { + ui.heading(format!("Configuration — {machine}")); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("×").on_hover_text("Hide config editor").clicked() { + self.show_config_editor = false; + } + }); + }); + ui.separator(); + egui::ScrollArea::vertical().show(ui, |ui| self.central_tabs(ui)); + } + fn central_tabs(&mut self, ui: &mut egui::Ui) { let prev_tab = self.tab; ui.horizontal_wrapped(|ui| { @@ -1572,6 +1813,11 @@ impl App { ConfigAction::RefreshPcapIfaces => self.refresh_pcap_ifaces(), ConfigAction::None => {} } + if out.disks_changed { self.mark_dirty(); } + // A disk image was just picked: check up front whether its folder is + // grantable, so the user handles permissions at assignment time rather + // than discovering at exit that the disk can't be compacted. + if out.disk_picked { self.check_chd_folder_grants(); } if out.net.changed { self.mark_dirty(); } if out.net.forwards_changed && self.emu.is_running() { // Rebind the running NAT's listeners so a forward added/removed now @@ -1696,7 +1942,7 @@ impl App { ui.colored_label(Color32::from_rgb(200, 80, 80), e); } else if connected { ui.colored_label(Color32::from_rgb(90, 170, 90), - format!("● connected to {}", serial_console::SERIAL_ADDR)); + format!("\u{2022} connected to {}", serial_console::SERIAL_ADDR)); } else { ui.label("disconnected"); } @@ -1753,9 +1999,84 @@ impl App { } } + /// License + privacy texts, embedded at compile time so they're viewable in + /// the sandboxed App Store build (which can't read repo files at runtime). + /// Paths are relative to this source file (`iris-gui/src/main.rs`). + fn license_window(&mut self, ctx: &egui::Context) { + const LICENSE_BSD: &str = include_str!("../../LICENSE"); + // The CHD backend (libchdman-rs) is GPL-3.0, so any CHD build is conveyed + // under GPL-3.0 — mirrors the release pipeline shipping LICENSE-GPL3.txt + // alongside LICENSE. Shown only when CHD support is actually built in. + const LICENSE_GPL3: &str = include_str!("../../LICENSE-GPL3.txt"); + if !self.show_license { return; } + let chd = iris::build_features::CHD; + let mut open = true; + egui::Window::new("License") + .open(&mut open) + .default_width(640.0) + .default_height(520.0) + .collapsible(false) + .resizable(true) + .show(ctx, |ui| { + ui.label("IRIS itself is licensed under the BSD 3-Clause License."); + if chd { + ui.label( + "This build includes CHD disk support via libchdman-rs, which is licensed \ + under the GNU GPL-3.0 — so the combined binary is conveyed under GPL-3.0. \ + Both licenses apply and are shown below."); + } + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.label("Source:"); + ui.hyperlink_to("danifunker/iris", "https://github.com/danifunker/iris"); + if chd { + ui.label("·"); + ui.hyperlink_to("libchdman-rs", "https://crates.io/crates/libchdman-rs"); + } + }); + ui.separator(); + egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { + ui.label(RichText::new("IRIS — BSD 3-Clause License").strong()); + ui.add_space(2.0); + ui.label(RichText::new(LICENSE_BSD).monospace()); + if chd { + ui.add_space(12.0); + ui.separator(); + ui.label(RichText::new("CHD backend (libchdman-rs) — GNU GPL-3.0").strong()); + ui.horizontal(|ui| { + ui.label("Full text also at"); + ui.hyperlink_to("gnu.org/licenses/gpl-3.0", + "https://www.gnu.org/licenses/gpl-3.0.html"); + }); + ui.add_space(2.0); + ui.label(RichText::new(LICENSE_GPL3).monospace()); + } + }); + }); + if !open { self.show_license = false; } + } + + fn privacy_window(&mut self, ctx: &egui::Context) { + const PRIVACY_TEXT: &str = include_str!("../../PRIVACY.md"); + if !self.show_privacy { return; } + let mut open = true; + egui::Window::new("Privacy policy") + .open(&mut open) + .default_width(560.0) + .default_height(440.0) + .collapsible(false) + .resizable(true) + .show(ctx, |ui| { + egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { + ui.label(PRIVACY_TEXT); + }); + }); + if !open { self.show_privacy = false; } + } + /// Explains the camera (IndyCam) and networking features — what they do and - /// how to use them (for end users), and what host capabilities they use and - /// why (for App Review). Opened from Help → "How camera & networking work". + /// how to use them, and what host capabilities they use and why. Opened from + /// Help → "How camera & networking work". fn help_info_window(&mut self, ctx: &egui::Context) { if !self.show_help_info { return; @@ -1782,7 +2103,7 @@ impl App { ui.label("• Or set Video-In > Source = camera, boot IRIX, and run an IndyCam app like vino/cam."); ui.label("• On first use macOS asks for camera permission. Closing the preview releases the camera."); ui.add_space(6.0); - ui.label(RichText::new("Privacy / for App Review").strong()); + ui.label(RichText::new("Privacy").strong()); ui.label( "Camera frames are used only as the emulated video input — IRIS never records, \ stores, or transmits them. It uses the public AVFoundation API with the \ @@ -1799,10 +2120,14 @@ impl App { ); ui.add_space(6.0); ui.label(RichText::new("How to use it").strong()); - ui.label("• The guest serial console (ttyd1) and PROM monitor are exposed on loopback"); - ui.label(" TCP (127.0.0.1:8881 / 8888) so you can attach a terminal."); - ui.label("• Help > Diagnostics > Network test opens an in-app viewer of that console."); - ui.label("• Optional inbound port-forwards (Networking tab) let you reach guest services."); + ui.label("• Inbound port-forwards (Networking tab) let you reach guest network services."); + ui.add_space(6.0); + ui.label(RichText::new("The serial console is NOT the network").strong()); + ui.label("• Help > Diagnostics > Serial console is the guest's serial terminal — for login"); + ui.label(" and the PROM monitor — and works the same with or without guest networking."); + ui.label("• It's carried over host loopback TCP (127.0.0.1:8881 = console, 8888 = PROM"); + ui.label(" monitor) only as the in-app viewer's transport; that loopback is not the"); + ui.label(" guest's network connection."); ui.add_space(6.0); ui.label(RichText::new("Guest IP & subnets").strong()); ui.label("• IRIS's NAT is a router on one subnet; the gateway is the .1 of that subnet"); @@ -1813,7 +2138,7 @@ impl App { ui.label("• No port-forwards exist by default; '+ Add forward' maps a host port to a guest"); ui.label(" port (inbound, host to guest) — e.g. to telnet into the guest."); ui.add_space(6.0); - ui.label(RichText::new("Privacy / for App Review").strong()); + ui.label(RichText::new("Privacy").strong()); ui.label( "Outbound guest traffic uses com.apple.security.network.client. The loopback \ serial/monitor servers and any inbound port-forwards use \ @@ -1870,7 +2195,7 @@ impl App { ui.label(RichText::new("NFS file sharing isn't enabled yet.").strong()); ui.add_space(4.0); ui.label( - "Turn it on under Configuration → Networking → NFS share, pick a folder \ + "Turn it on under Configuration » Networking » NFS share, pick a folder \ to share, then reopen this window for the exact mount command."); return; }; @@ -1883,7 +2208,7 @@ impl App { if nfs.shared_dir.trim().is_empty() { ui.label(RichText::new( - "No folder is selected yet — pick one under Configuration → Networking → \ + "No folder is selected yet — pick one under Configuration » Networking » \ NFS share first.").color(Color32::from_rgb(0xd9, 0x4a, 0x3d))); } else { ui.label(RichText::new(format!("Sharing: {}", nfs.shared_dir)).weak()); @@ -1932,7 +2257,7 @@ impl App { ui.code(format!("mount -o {pin} {gw}:/ /shared")); ui.add_space(4.0); ui.label(RichText::new( - "Still stuck? Use Help → \"How camera & networking work\" and the NET light's \ + "Still stuck? Use Help » \"How camera & networking work\" and the NET light's \ Check button to confirm the guest is on IRIS's subnet first — NFS can't mount \ until networking is up.").weak()); @@ -2194,8 +2519,11 @@ impl eframe::App for App { } } - // F11 toggles fullscreen. - if ctx.input(|i| i.key_pressed(egui::Key::F11)) { + // F11 toggles fullscreen — except Ctrl+Alt+F11, which is reserved as the + // way to send a real F11 keystroke through to the guest (plain F11 never + // reaches IRIX because this toggle eats it). input::pump forwards that + // chord to the guest while captured. + if ctx.input(|i| i.key_pressed(egui::Key::F11) && !(i.modifiers.ctrl && i.modifiers.alt)) { self.fullscreen = !self.fullscreen; ctx.send_viewport_cmd(ViewportCommand::Fullscreen(self.fullscreen)); } @@ -2222,25 +2550,17 @@ impl eframe::App for App { .show(ctx, |ui| self.control_panel(ui, ctx)); self.network_check_window(ctx); - // Config editor lives in a collapsible side panel so the emulator - // screen (central panel) is never hidden by it. The toolbar's - // "Edit config… / Hide config editor" toggle drives the collapse; - // `show_animated` slides it in/out. + // Config editor placement depends on whether a machine is running: + // - RUNNING: a resizable right side panel, so you can edit alongside + // the live emulator screen (which stays visible in the centre). + // - IDLE: it takes the WHOLE central area instead (below), hiding the + // welcome/info screen — no cramped split when there's nothing to + // watch. The toolbar's "Edit config…" toggle drives both. + let config_in_side_panel = self.show_config_editor && self.emu.is_running(); egui::SidePanel::right("config_editor") .resizable(true) .default_width(420.0) - .show_animated(ctx, self.show_config_editor, |ui| { - ui.horizontal(|ui| { - ui.heading("Configuration"); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.button("×").on_hover_text("Hide config editor").clicked() { - self.show_config_editor = false; - } - }); - }); - ui.separator(); - egui::ScrollArea::vertical().show(ui, |ui| self.central_tabs(ui)); - }); + .show_animated(ctx, config_in_side_panel, |ui| self.config_editor_panel(ui)); // Zero the central panel's inner margin so the emulated display reaches // the window edges — every reclaimed pixel makes the (tall, 5:4) picture @@ -2249,11 +2569,15 @@ impl eframe::App for App { let central_frame = egui::Frame::central_panel(&ctx.style()) .inner_margin(egui::Margin::ZERO); egui::CentralPanel::default().frame(central_frame).show(ctx, |ui| { - // The central panel always shows the emulator screen when the - // machine is running (the REX3 framebuffer), falling back to the - // welcome / status summary when idle. The config editor no longer - // takes this space — it's the side panel above. - if self.emu.is_running() { + if self.show_config_editor && !self.emu.is_running() { + // Idle + editing: config fills the whole pane (welcome hidden). + // A small margin gives it breathing room (the central frame is + // edge-to-edge for the framebuffer). + input::force_release(ui.ctx(), &mut self.input_state); + egui::Frame::none() + .inner_margin(egui::Margin::symmetric(10.0, 8.0)) + .show(ui, |ui| self.config_editor_panel(ui)); + } else if self.emu.is_running() { self.framebuffer_panel(ui); } else { // Size the window for the standard 1280×1024 display at the @@ -2274,6 +2598,36 @@ impl eframe::App for App { } }); + // Rename-machine modal: a text box + OK/Cancel. The buffer is App state + // (`rename_buffer`), so typed input persists across frames — a text box + // inside the menu can't, because the menu closure resets it each frame. + let mut rename_result: Option> = None; // outer Some=action; inner Some=new name, None=cancel + if let Some(buf) = &mut self.rename_buffer { + egui::Window::new("Rename machine") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + ui.label("New machine name:"); + let resp = ui.add(egui::TextEdit::singleline(buf).desired_width(260.0)); + resp.request_focus(); + let entered = resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + ui.add_space(6.0); + ui.horizontal(|ui| { + let ok = ui.add(egui::Button::new("OK").fill(Color32::from_rgb(60, 90, 140))).clicked(); + if (ok || entered) && !buf.trim().is_empty() { + rename_result = Some(Some(buf.clone())); + } + if ui.button("Cancel").clicked() { rename_result = Some(None); } + }); + }); + } + match rename_result { + Some(Some(name)) => { self.apply_rename(&name); self.rename_buffer = None; } + Some(None) => { self.rename_buffer = None; } + None => {} + } + // New machine dialog. self.new_machine.show(ctx); if let Some(result) = self.new_machine.take_result() { @@ -2306,6 +2660,8 @@ impl eframe::App for App { // Help → "How camera & networking work" explainer. self.help_info_window(ctx); + self.license_window(ctx); + self.privacy_window(ctx); // Help → "Mount the shared folder in IRIX" — the NFS mount command. self.nfs_help_window(ctx); @@ -2482,6 +2838,78 @@ impl eframe::App for App { } } + // CHD folder-grant modal (App Store): attached CHDs sit in folders we + // can't write, so their on-exit fold would be denied. Offer to grant + // each folder, or start anyway and forgo compaction. + enum ChdGrantChoice { None, Cancel, StartAnyway, Grant(String) } + let mut chd_choice = ChdGrantChoice::None; + if let Some(modal) = &self.chd_grant_modal { + egui::Window::new("Grant folder access to compact disks") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + ui.label(RichText::new("These CHD disks are in folders IRIS can't write to:").strong()); + for d in &modal.disks { + ui.label(format!("• scsi{}: {}", d.id, d.path)); + } + ui.add_space(8.0); + ui.label(RichText::new( + "A compressed CHD records changes in a sidecar while running, then folds \ + them back into the disk when you quit (\"Synchronizing disks…\"). That \ + fold needs write access to the disk's folder — not just the file — so \ + without it the sidecar only grows and the disk never shrinks.").weak()); + ui.add_space(6.0); + ui.label(RichText::new( + "Tip: a folder grant is recursive — IRIS gains access to everything in \ + that folder for as long as it's granted. To keep that exposure minimal, \ + put each disk image in its own dedicated folder and grant only that.") + .italics().weak()); + ui.add_space(6.0); + ui.label(RichText::new("Grant the containing folder so IRIS can compact it on exit:").weak()); + // One button per distinct folder — a recursive grant covers + // every disk under it. + let mut dirs: Vec<&str> = modal.disks.iter().map(|d| d.dir.as_str()).collect(); + dirs.sort_unstable(); + dirs.dedup(); + for dir in dirs { + if ui.button(format!("Grant \"{dir}\"…")).clicked() { + chd_choice = ChdGrantChoice::Grant(dir.to_string()); + } + } + ui.add_space(4.0); + ui.horizontal(|ui| { + if ui.button("Cancel").clicked() { chd_choice = ChdGrantChoice::Cancel; } + if ui.add(egui::Button::new("Start without compacting") + .fill(Color32::from_rgb(60, 90, 140))).clicked() + { + chd_choice = ChdGrantChoice::StartAnyway; + } + }); + }); + } + match chd_choice { + ChdGrantChoice::None => {} + ChdGrantChoice::Cancel => { self.chd_grant_modal = None; } + ChdGrantChoice::StartAnyway => { + self.chd_grant_modal = None; + self.skip_chd_grant_check = true; // one-shot bypass + self.start_emulator(); + } + ChdGrantChoice::Grant(dir) => { + self.grant_disk_folder_at(Some(&dir)); + // Re-probe: drop disks whose folder is now writable. When all are + // satisfied, dismiss and start; otherwise leave the rest listed. + if let Some(modal) = &mut self.chd_grant_modal { + modal.disks.retain(|d| !dir_writable(std::path::Path::new(&d.dir))); + if modal.disks.is_empty() { + self.chd_grant_modal = None; + self.start_emulator(); + } + } + } + } + // The window starts hidden so the first frame(s) can fit it to the // monitor (the launcher fit, when not running) before it's shown — // avoiding a visible open-then-resize. Reveal one frame after the fit diff --git a/rules/gui/cmd-q-bypasses-close-intercept-fold-on-poweroff.md b/rules/gui/cmd-q-bypasses-close-intercept-fold-on-poweroff.md new file mode 100644 index 0000000..796e259 --- /dev/null +++ b/rules/gui/cmd-q-bypasses-close-intercept-fold-on-poweroff.md @@ -0,0 +1,67 @@ +# macOS Cmd+Q bypasses the close-intercept — fold CHD overlays on power-off, not just quit + +Status: confirmed fix (2026-06-20). The CHD "Synchronizing disks…" fold wasn't +happening when a user cleanly shut IRIX down and then quit the app **without +pressing STOP**. + +## Root cause + +The exit-time fold runs from the close-intercept in `App::update`: + +```rust +if ctx.input(|i| i.viewport().close_requested()) { + if !self.sync_then_close && self.emu.has_pending_chd_sync() { /* SyncDisks + CancelClose */ } +} +``` + +That only fires on `WindowEvent::CloseRequested`. **winit 0.29.15 (what eframe +0.29 pulls in) does not hook `applicationShouldTerminate`** — grep its +`platform_impl/macos/` for it: zero hits. So on macOS the **app-menu Quit / Cmd+Q** +calls `[NSApp terminate:]`, which exits the process *without* emitting a window +CloseRequested. The event loop never runs the intercept → no fold. (The red +close button and the in-app File » Quit *do* work — File » Quit sends +`ViewportCommand::Close`, which becomes a CloseRequested.) + +Diagnosis shortcut that pinned it: the **STOP button** fold (`request_stop` → +`has_pending_chd_sync` → `SyncDisks`) *did* consolidate, which proves +`chd_sync_pending` is true in the shutdown state. So the fold logic was fine; only +the quit path was being missed. + +## Fix + +Fold at **power-off time**, not quit time — keyed off **`cpu_stopped`**, NOT +`Evt::PowerOff`. Gotcha: `Evt::PowerOff` is declared but **never emitted** (see +the comment at `handle.rs:43` — it awaits a core `subscribe_events` API), so a +first attempt that hooked it was dead code and did nothing. The signal that +actually moves is the status field `cpu_stopped` (the same one that draws the +"Powered off" overlay): + +- A guest `poweroff` writes the IOC front-panel power register (`ioc.rs:575`) → + `MachineEvent::PowerOff` → the core dispatch thread calls `machine.stop()` + (iris-gui sets `IRIS_NO_EXIT_ON_POWEROFF=1` so the process survives). The CPU + thread stops → the status tick reports `cpu_stopped = true`. +- The worker is NOT told (only a user STOP raises `Evt::Stopped`), so the GUI's + `running` stays true. Thus **`running && cpu_stopped`** uniquely means "the + guest shut itself down" (a user STOP sets `running=false` and folds via + `request_stop` anyway). + +So in `framebuffer_panel`, edge-trigger on `cpu_stopped` (a `prev_cpu_stopped` +field, reset to false at Start): when `cpu_stopped && !prev && is_running() && +has_pending_chd_sync() && syncing.is_none()`, kick off `Cmd::SyncDisks` with the +"Synchronizing disks…" modal. By the time the user quits — however they quit — +the disk is already consolidated. COW-mode disks are unaffected (`pending_sync` +returns `None` when `cow`). + +The status loop keeps ticking after the core's `machine.stop()` because the +worker's `cycles` is only cleared on `Cmd::Stop`/`SyncDisks`, not on a guest +power-off — so `cpu_stopped` and `chd_sync_pending` stay live and accurate. + +## Residual gap (not fixed) + +Quitting via **Cmd+Q while the guest is still running** (or halted at the PROM +without a power-off) still bypasses the fold, because that path never reaches the +close-intercept and never raises `Evt::PowerOff`. Quitting mid-run is already +discouraged (see `dont-run-iris-long`). The complete fix would be an +`NSApplicationDelegate applicationShouldTerminate:` override (via objc2, as in +`macos_sandbox.rs`) that defers termination until the fold completes — deferred +because winit owns the app lifecycle and the interop is fiddly. diff --git a/rules/gui/egui-default-font-glyph-coverage-avoid-tofu.md b/rules/gui/egui-default-font-glyph-coverage-avoid-tofu.md new file mode 100644 index 0000000..c5ff255 --- /dev/null +++ b/rules/gui/egui-default-font-glyph-coverage-avoid-tofu.md @@ -0,0 +1,55 @@ +# egui default fonts: which glyphs render, which are tofu + +iris-gui uses egui's built-in fonts (no custom font is loaded). Labels/buttons +render in the **Proportional** family, whose fallback chain (epaint 0.29 +`fonts.rs`) is: + +``` +Ubuntu-Light → NotoEmoji-Regular → emoji-icon-font +``` + +Note **Hack is NOT in the Proportional chain** (it's Monospace-only). So a glyph +that exists only in Hack still renders as tofu (□) in a normal label. + +## Two traps + +**(1) Hack-only glyphs.** Hack covers many symbols (`●` U+25CF, `→` U+2192) but +it's Monospace-only, so those are **tofu in any normal label**. This is the one +that bit us repeatedly — `●` *looks* fine in a monospace context but renders as a +colored □ in the NET light / machine-switch marker. + +**(2) dingbats vs emoji codepoints.** NotoEmoji ships the **emoji-presentation** +codepoints but not the adjacent plain **dingbats**. + +Verified against the actual 0.29 cmaps (Proportional chain only): + +| Want | Tofu — DO NOT USE | Renders — use | +|------|-------------------|---------------| +| filled dot | `●` U+25CF | **`•` U+2022** (size it up), or paint a circle | +| right arrow / breadcrumb | `→` U+2192 | **`»` U+00BB**, or ASCII `->` | +| check | `✓` U+2713 | `✅` U+2705 | +| cross / close | `✗` U+2717, `✕` U+2715 | `×` U+00D7, `❌` U+274C | +| power | `⏻` U+23FB | (none — drop it) | +| folder tabs | `🗂` U+1F5C2 | `📁` U+1F4C1, `📂` U+1F4C2 | + +**Confirmed RENDER** (via Ubuntu-Light/NotoEmoji/emoji-icon): `•` `»` `×` `▶` +`⚠` `↩` `⬇` `■` `ℹ` `🌐` `💾` `📁` `📂` `📷` `−` `≈`. +**Confirmed TOFU:** `●` `→` `✓` `✗` `✕` `🗂` `⏻`. + +For a colored **status dot**, the NET light and granted-folder indicator use +`RichText::new("\u{2022}").size(..).color(..)` (a sized, colored bullet). For an +inline "active" marker in a list, prefer `ui.selectable_label(active, name)` — +no glyph at all. When in doubt, paint the shape (`ui.painter().circle_filled`). + +## How to check a glyph before using it + +Dump the cmaps of the four files under `epaint_default_fonts-0.29.1/fonts/` and +test the codepoint against the **Proportional** chain (Ubuntu-Light + NotoEmoji + +emoji-icon-font) — **NOT** the union with Hack. That single mistake (including +Hack) is what made me wrongly bless `●` and `→`; excluding Hack, a hand-rolled +cmap parser (handle subtable formats 4, 6, 12) matches reality exactly. fontTools +is cleaner but isn't installable in this env (PEP 668 managed environment). + +The real fix for an arbitrary glyph would be to load a font that covers it via +`egui::FontDefinitions`, but that adds binary weight; for a handful of icons, +staying inside the built-in coverage is simpler. diff --git a/rules/gui/keyboard-capture-egui-steals-tab-arrows-esc-and-ctrl-cxv.md b/rules/gui/keyboard-capture-egui-steals-tab-arrows-esc-and-ctrl-cxv.md new file mode 100644 index 0000000..8cc3172 --- /dev/null +++ b/rules/gui/keyboard-capture-egui-steals-tab-arrows-esc-and-ctrl-cxv.md @@ -0,0 +1,91 @@ +# Keyboard capture: egui steals Tab/arrows/Esc, and egui-winit eats Ctrl+C/X/V + +Status: confirmed fix (2026-06-19). When the framebuffer has keyboard capture, +some keys never reach the guest. There are **three independent causes**, all in +the egui/egui-winit layer, none in iris's own keymap. If "key X doesn't reach +the guest while captured" comes up again, check these before touching +`map_key`/`ps2.rs` — the scancode tables are complete (Tab/arrows/F5 are all +mapped end-to-end). + +## 1. Focus-navigation keys (Tab, arrows, Esc) — egui's focus engine + +`pump()` reads `ctx.input(|i| i.events)`, and egui clones **all** raw events into +`InputState.events` (`egui .../input_state/mod.rs`: `events: new.events.clone()`), +so the Tab event *is* delivered to us. The problem is the side effect: +`Memory::begin_pass` (`egui .../memory/mod.rs`) interprets an unfiltered +`Key::Tab` as `FocusDirection::Next`, arrows as directional focus moves, and +`Key::Escape` as "clear focus". On the press, egui moves keyboard focus **off** +the framebuffer `Image` onto the next focusable side-panel widget — and that +widget can then `consume_key` later keystrokes (`events.retain(...)`) before +`pump` sees them. A plain `egui::Image` never claims these keys, so by default it +loses them. + +Fix: while captured, pin focus to the framebuffer and lock the focus filter +(`iris-gui/src/main.rs`, `framebuffer_panel`): + +```rust +if captured { + if !response.has_focus() { response.request_focus(); } + ui.memory_mut(|m| m.set_focus_lock_filter(response.id, egui::EventFilter { + tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true, + })); +} +``` + +`set_focus_lock_filter` only takes effect when the widget `had_focus_last_frame +&& has_focus` — so it must be re-applied every frame (TextEdit does the same), +and there's a harmless one-frame delay when capture engages via the side-panel +button (FB wasn't focused yet). `escape: true` does **not** break release: the +Ctrl+Alt+Esc chord is read globally in `pump` (`i.key_pressed(Escape) && ctrl && +alt`) before any key is forwarded, and plain Esc still flows to the guest. It +also fixes a latent bug where a bare Esc used to clear FB focus. + +## 2. Ctrl+C / Ctrl+X / Ctrl+V — egui-winit converts them to clipboard commands + +`egui-winit::on_keyboard_input` checks `is_copy/cut/paste_command` and, on a +match, pushes `Event::Copy/Cut/Paste` and **`return`s without emitting the Key +event**. Those predicates use `modifiers.command`, and `command == ctrl` on +Linux/Windows — so on those platforms the guest never sees **Ctrl+C (SIGINT!)**, +Ctrl+X, or Ctrl+V. (On macOS `command == Cmd`, so real Ctrl+C still arrives as a +normal `Key::C`; only Cmd+C/X/V are swallowed, which the guest doesn't need.) + +Fix: handle those events in `pump`'s loop, gated on `i.modifiers.ctrl`, and +re-synthesise the bare letter as a tap. The held Ctrl is already sent by the +modifier diff, so the guest forms the full chord: + +```rust +Event::Copy if i.modifiers.ctrl => { keys.push((KeyCode::KeyC, true)); keys.push((KeyCode::KeyC, false)); } +Event::Cut if i.modifiers.ctrl => { keys.push((KeyCode::KeyX, true)); keys.push((KeyCode::KeyX, false)); } +Event::Paste(_) if i.modifiers.ctrl => { keys.push((KeyCode::KeyV, true)); keys.push((KeyCode::KeyV, false)); } +``` + +The `ctrl` gate means macOS Cmd+C is left to the host clipboard. (We forward the +keystroke, not the clipboard text — in a Unix shell Ctrl+V is "literal next", so +faithful passthrough is the correct behavior for a captured emulator.) + +## 3. F5 was dropped on a wrong comment + +`map_key` had `// F-keys (egui has no F5 …)` and skipped it — but `egui::Key::F5` +exists and `ps2.rs` maps `KeyCode::F5` (set 2). Added it. The PS/2 set stops at +F12, so F13+ are still legitimately dropped. + +## 4. F11 is the fullscreen toggle — Ctrl+Alt+F11 is the escape hatch into IRIX + +Plain F11 is the GUI fullscreen toggle (`App::update`), so `map_key` does **not** +forward it — otherwise it would both toggle fullscreen *and* reach the guest. +**Ctrl+Alt+F11** is reserved as the only way to send a real F11 to IRIX: the +fullscreen handler is gated with `!(ctrl && alt)`, and `pump` detects the chord +on the press edge and sends a **bare** F11. Because the modifier diff has already +pressed the chord's Ctrl+Alt in the guest, `pump` lifts whatever modifiers are +held (`state.last_mods`), taps F11, then re-presses them — so IRIX sees an +unmodified F11 — and leaves `last_mods` untouched so the next frame's diff stays +consistent. The hint lives on the capture status block (`capture_controls`) +alongside the Ctrl+Alt+Esc release hint. + +## What is NOT fixable at this layer + +egui's `Key` enum collapses the numpad into `Num0..9` / `Plus`/`Minus`/etc., so +numpad keys reach the guest only as their main-row equivalents, and there are no +egui keys for NumLock/ScrollLock/CapsLock/PrintScreen/ContextMenu — even though +`ps2.rs` has scancodes for some. Distinguishing them would require reading raw +winit `KeyEvent.physical_key`/`location` instead of egui events. diff --git a/rules/macos/chd-fold-needs-folder-grant-not-file-grant.md b/rules/macos/chd-fold-needs-folder-grant-not-file-grant.md new file mode 100644 index 0000000..1902507 --- /dev/null +++ b/rules/macos/chd-fold-needs-folder-grant-not-file-grant.md @@ -0,0 +1,98 @@ +# macOS App Sandbox: the CHD on-exit fold needs a *folder* grant, not a file grant + +Status: confirmed fix (2026-06-19). On the Mac App Store (sandboxed) build, +compressed CHDs were not being "shrunk" on exit — the "Synchronizing disks…" +fold silently did nothing and the `.diff.chd` kept growing. + +## Root cause + +A security-scoped bookmark minted from the file picker (the user choosing a disk +image) grants read-write to **that file**. It does **not** grant the right to +create siblings in, or rename within, the file's **directory**. + +`chd_disk::flatten_diff` (the fold) is built on an atomic replace: + +1. write the rebuilt CHD to `.synctmp.chd` **beside the base**, then +2. `rename(synctmp, base)` over the base, then +3. remove the diff. + +Steps 1 and 2 both require **directory** write access. With only a file-scoped +grant the sandbox denies them, so `flatten_diff` returns `EPERM` early — base and +diff are left untouched (correct/safe), but the disk never compacts. + +Two things hid it: +- The running COW writes go to `IRIS_CHD_DIFF_DIR` (a writable container path set + in `main.rs` for the appstore build), so *during* the session everything works + — only the exit-time fold, which writes beside the base, fails. +- `handle.rs` swallowed the error: `m.sync_chd_disks(...).unwrap_or(0)`, so the + user saw "0 synced" with no message. + +Note you can't fix this by redirecting `synctmp` into the container too: the +final atomic `rename` over the base still needs write access to the base's +directory. And rewriting the base in place (file-level access only) would forfeit +the crash-atomicity the rename gives — a crash mid-rebuild leaves the base +neither old nor new, and the diff no longer applies. So the fold genuinely needs +the folder grant. + +## The fix (all in iris-gui) + +- **Surface it.** `handle.rs` `Cmd::SyncDisks` now reports the fold error via + `Evt::Error` (a toast) instead of `.unwrap_or(0)`. +- **Preflight + gather the permission.** On Start (appstore only), + `chd_dirs_needing_grant()` probes each attached non-scratch HDD `.chd`'s parent + directory with `dir_writable()` (create+remove a `.iris-write-probe-` + file — the sandbox can `stat` a dir yet deny the write, so probe a real + create). Any in a non-writable folder pop the `ChdGrantModal`, which offers a + per-folder "Grant …" button (a folder NSOpenPanel pre-pointed via + `grant_disk_folder_at`, persisting a recursive directory bookmark) or "Start + without compacting" (one-shot `skip_chd_grant_check` bypass). After a grant we + re-probe and drop satisfied disks; when the list empties we start. + +The granted directory bookmark is process-wide once `startAccessingSecurity- +ScopedResource` runs (`macos_sandbox::restore`), so the fold on the handle worker +thread gets the access — security-scoped access is not thread-scoped. + +## Three layers of sandbox file access (why `dir_writable` is the right test) + +A MAS build reaches files three ways, and the fold needs *directory* write from +one of them: +1. **Container** (`~/Library/Containers//Data/…`, where `dirs::data_dir()` + points): always writable, no grant. New disks (`/disks`) and the COW + diff (`IRIS_CHD_DIFF_DIR`) live here — that's why *running* works even when the + fold doesn't. +2. **User-selected + security-scoped bookmarks** (`files.user-selected.read-write` + + `files.bookmarks.app-scope`, both in `installer/iris-gui.entitlements`): a + *file* bookmark (picking a disk) grants RW to that file only; a *directory* + bookmark (picking a folder) grants recursive RW. These are **invisible** in + System Settings → Privacy & Security → Files & Folders. +3. **TCC special-folder consent** (Desktop/Documents/Downloads): the visible + "Documents Folder" toggle. For a sandboxed app this extends access to that + tree — but we have **no** Documents entitlement and must not depend on it. + +`dir_writable()` (a real create-probe) is ground truth across all three: if the +CHD sits in the container, in a granted folder, or under an effective Documents +grant, the probe passes and we don't prompt; otherwise we do. So we never need to +know *which* layer is providing access — only whether a write would succeed. + +We deliberately did **not** force CHDs into the container (multi-GB copy into a +hidden path) or require ~/Documents (broad exposure, TCC-dependent). The +least-privilege path is a user-selected folder grant for the disk's own folder, +with UI text recommending one CHD per dedicated folder (a grant is recursive). + +## When we prompt + +Both at **Start** (preflight) and at **assignment time** — when a disk image is +picked via Browse in the Disks tab (`PathEdit.picked` → `TabOutcome.disk_picked` +→ `check_chd_folder_grants`). Assignment-time is gated on an actual *pick*, never +on typed text, so the dialog can't pop mid-keystroke. The grant button opens a +folder picker pre-pointed at the CHD's directory, so permissions are assigned +only when the user explicitly selects that folder. + +## Detection predicate + +We prompt for any non-scratch, non-CD `.chd` in a non-writable folder. We don't +gate on actual compression (which would need opening the CHD): nearly all +attached CHDs are compressed, a per-disk COW toggle would make even an +uncompressed base overlay, and a redundant folder grant is harmless. CD-ROM CHDs +are read-only (no diff) and scratch disks live in the writable container, so both +are skipped. diff --git a/rules/macos/test-the-app-sandbox-locally-without-the-app-store.md b/rules/macos/test-the-app-sandbox-locally-without-the-app-store.md new file mode 100644 index 0000000..542a9d2 --- /dev/null +++ b/rules/macos/test-the-app-sandbox-locally-without-the-app-store.md @@ -0,0 +1,57 @@ +# Test the macOS App Sandbox locally — no App Store round-trip needed + +The App Sandbox is enforced by the `com.apple.security.app-sandbox` entitlement +**at signing time**, not by App Store distribution. So you can run a genuinely +sandboxed `IRIS.app` locally and exercise the security-scoped bookmark / +folder-grant / CHD-fold flow without waiting on App Review. + +## Recipe + +``` +./scripts/build-macos.sh appstore +open IRIS.app +``` + +The `appstore` variant (added 2026-06-19) does two things the plain build did not: +1. Compiles `--features appstore,iris/lightning`, so the **real** bookmark code + (`#[cfg(feature = "appstore")]` in `macos_sandbox.rs`) and the + `IRIS_CHD_DIFF_DIR` container redirect are active — not the off-sandbox stubs. +2. Signs with `installer/iris-gui-sandbox-local.entitlements` (app-sandbox = + true), so the process actually runs sandboxed. + +The earlier script signed with the MAS entitlements but built **without** the +feature, i.e. a sandboxed app with the bookmark logic compiled out — useless for +this test. That was the trap. + +## Verify it's really sandboxed + +``` +codesign -d --entitlements - IRIS.app 2>/dev/null | grep app-sandbox +codesign --verify --verbose IRIS.app +ls ~/Library/Containers/io.github..iris/ # created on first launch +strings -a IRIS.app/Contents/MacOS/iris-gui | grep IRIS_CHD_DIFF_DIR # feature present +``` + +## Two gotchas that cost time + +- **XML comments can't contain `--`.** codesign's entitlements parser + (`AMFIUnserializeXML`) rejects a double hyphen inside a comment with + "syntax error near line N". Writing `--features`/`--sign` in a comment broke + it. The script now `plutil -lint`s the entitlements before signing. +- **A piped invocation hides the failure.** `build-macos.sh appstore 2>&1 | tail` + reports the pipe's exit code (tail = 0), masking a codesign error. Run it + without a pipe, or check for the "Signing bundle…" success line. + +## Entitlements: local vs MAS + +`iris-gui-sandbox-local.entitlements` omits the MAS-only +`com.apple.application-identifier` / `com.apple.developer.team-identifier` keys — +those need an embedded provisioning profile a real team identity that ad-hoc +(`codesign --sign -`) can't provide. + +**Ad-hoc is enough for the within-session fold test** (grant a folder, boot, +quit → the `.diff.chd` folds away): the open-panel folder pick grants access for +the whole process lifetime, which is what the fold uses — it doesn't depend on a +bookmark resolving. **Cross-launch persistence** (relaunch, disk still +reachable) needs app-scoped bookmarks to resolve, which needs a stable identity: +`CODESIGN_IDENTITY="Developer ID Application: …" ./scripts/build-macos.sh appstore`. diff --git a/scripts/build-macos.sh b/scripts/build-macos.sh index 36b1377..7339817 100755 --- a/scripts/build-macos.sh +++ b/scripts/build-macos.sh @@ -9,6 +9,15 @@ # Usage: # ./scripts/build-macos.sh # standard build # ./scripts/build-macos.sh lightning # enable iris/lightning feature +# ./scripts/build-macos.sh appstore # SANDBOXED build (App Store parity) +# +# The `appstore` variant compiles `--features appstore` (so the real +# security-scoped bookmark code + IRIS_CHD_DIFF_DIR are active, not the +# off-sandbox stubs) and signs with installer/iris-gui-sandbox-local.entitlements +# (app-sandbox = true). The result is a genuinely sandboxed IRIS.app you can run +# locally — no App Store round-trip — to test the folder-grant / CHD-fold flow. +# Ad-hoc signing by default; set CODESIGN_IDENTITY="Developer ID Application: …" +# to get persistent app-scoped bookmarks (see the entitlements file's caveat). # # After it finishes: # open IRIS.app @@ -49,6 +58,11 @@ echo " Bundle ID: $BUNDLE_ID" if [ "$VARIANT" = "lightning" ]; then cargo build --release --target "$TARGET" -p iris-gui --features iris/lightning +elif [ "$VARIANT" = "appstore" ] || [ "$VARIANT" = "sandbox" ]; then + # Sandbox parity: enable the bookmark code + container diff redirect. + # lightning gives a usable interpreter (the appstore feature forces + # IRIS_NO_JIT, so the MIPS/REX JITs are off regardless). + cargo build --release --target "$TARGET" -p iris-gui --features appstore,iris/lightning else cargo build --release --target "$TARGET" -p iris-gui fi @@ -93,11 +107,25 @@ EOF # ── Sign ──────────────────────────────────────────────────────────────────── -echo "Signing bundle..." -if [ -f "installer/iris-gui.entitlements" ]; then - codesign --force --deep --sign - --entitlements installer/iris-gui.entitlements "${BUNDLE}" +# The sandboxed variant signs with the local sandbox entitlements (app-sandbox); +# other variants keep the existing behaviour. CODESIGN_IDENTITY overrides the +# default ad-hoc identity (e.g. a Developer ID for persistent bookmarks). +SIGN_ID="${CODESIGN_IDENTITY:--}" +if [ "$VARIANT" = "appstore" ] || [ "$VARIANT" = "sandbox" ]; then + ENTITLEMENTS="installer/iris-gui-sandbox-local.entitlements" +else + ENTITLEMENTS="installer/iris-gui.entitlements" +fi + +echo "Signing bundle (identity: ${SIGN_ID}, entitlements: ${ENTITLEMENTS})..." +if [ -f "$ENTITLEMENTS" ]; then + # Validate first: codesign's entitlements parser is strict (and an XML + # comment may not contain a double hyphen), and a parse failure would + # otherwise leave the bundle unsigned / un-sandboxed without an obvious error. + plutil -lint "$ENTITLEMENTS" >/dev/null + codesign --force --deep --sign "$SIGN_ID" --entitlements "$ENTITLEMENTS" "${BUNDLE}" else - codesign --force --deep --sign - "${BUNDLE}" + codesign --force --deep --sign "$SIGN_ID" "${BUNDLE}" fi echo "" @@ -107,3 +135,16 @@ echo "Launch without Terminal:" echo " open ${BUNDLE}" echo "" echo "Or double-click IRIS.app in Finder." + +if [ "$VARIANT" = "appstore" ] || [ "$VARIANT" = "sandbox" ]; then + echo "" + echo "This is a SANDBOXED build. Verify the sandbox is actually on:" + echo " codesign -d --entitlements - ${BUNDLE} 2>/dev/null | grep -A1 app-sandbox" + echo " ls ~/Library/Containers/${BUNDLE_ID}/ # created on first launch" + echo "" + echo "Test the CHD folder-grant / fold: attach a compressed .chd from a" + echo "folder you have NOT granted -> the grant modal should appear; grant the" + echo "folder, boot, then quit -> the .diff.chd should fold away and the disk" + echo "shrink. (Ad-hoc bookmarks may not persist across relaunches; the" + echo "within-session fold does not depend on that.)" +fi From edf3bb5b0e4ba70426c5a31035d7bc3d8bf7f41b Mon Sep 17 00:00:00 2001 From: Dani Sarfati Date: Sat, 20 Jun 2026 12:39:21 -0400 Subject: [PATCH 2/2] updated appstore-review-response.md --- docs/appstore-review-response.md | 50 ++++++++++++++++++++++++++++---- 1 file changed, 44 insertions(+), 6 deletions(-) diff --git a/docs/appstore-review-response.md b/docs/appstore-review-response.md index 6cd9444..6d68152 100644 --- a/docs/appstore-review-response.md +++ b/docs/appstore-review-response.md @@ -1,11 +1,46 @@ -# App Store review response — IRIS (Submission 2ed07ab1…) +# App Store review response — IRIS -Covers the two issues raised on the 1.0 (20260610.2118) review (June 16, 2026): +> **Paste-ready version → `docs/appstore-review-notes.txt`** (3,915 chars, under +> the field's 4,000 limit). Copy it verbatim into **App Review Information → +> Notes** in App Store Connect; leave the **App Sandbox Information** section +> blank. That file is the *superset* actually submitted: it adds the +> IRIX-media / IP disclaimer, "the app contacts no external services," the +> inbound port-mapping / FTP use case, and the full entitlement list (all seven +> keys, incl. the honest `allow-jit` note — see below). This markdown file is the +> working/source document (rationale, history, verification commands). + +Originally written for the 1.0 (20260610.2118) review (June 16, 2026), which +raised two issues: 1. **Guideline 2.5.1** — private API `_CGSSetWindowBackgroundBlurRadius`. 2. **Guideline 2.4.5(i)** — entitlements without obvious matching functionality (`com.apple.security.device.camera`, `com.apple.security.network.server`). +## App Sandbox Information screen — N/A + +The App Store Connect **App Sandbox Information** screen is *only* for +temporary-exception entitlements (`com.apple.security.temporary-exception.*`). +IRIS uses none, so that screen stays blank. The entitlement justifications below +go in **App Review Information → Notes**, not there. + +No entitlement has been added since the original submission — the later features +(per-disk CHD copy-on-write + exit-time fold, the in-core pure-Rust NFS server, +the Networking-tab redesign / FTP ALG / in-app file bridge) all run inside the +existing grants. The in-core NFS server in particular opens **zero host sockets** +(it lives entirely in the user-mode NAT), so it does not even rely on +`network.server`. PCAP capture is sandbox-incompatible and ships only in the +non-App-Store release builds, never under the `appstore` feature. + +### `com.apple.security.cs.allow-jit` — kept, described honestly + +The `appstore` feature force-sets `IRIS_NO_JIT=1` (`iris-gui/src/main.rs:111`), so +the App Store build runs the MIPS CPU interpreter-only — JIT is never allocated +(the binary would otherwise `SIGKILL` on the first JITed page under MAS signing, +since `allow-unsigned-executable-memory` is rejected). The entitlement is left in +place for parity with the Developer-ID builds, and the notes describe it +truthfully as present-but-disabled rather than claiming the build uses JIT. +(Decision 2026-06-20: keep + describe honestly, over removing it outright.) + --- ## 1. Guideline 2.5.1 — private API (fixed in binary) @@ -45,9 +80,11 @@ hardware. When the user selects the host camera as the video source, IRIS captures live frames from the Mac's camera (AVFoundation) and feeds them to the emulated VINO device. The matching `NSCameraUsageDescription` is in `Info.plist`. -**How to test (reviewer steps):** -1. Launch IRIS. In the launcher, open the **Video-In** tab. -2. Click **📷 Test Camera**. +**How to test (reviewer steps):** *(no boot or login required)* +1. Launch IRIS. In the left column click **Edit config…**, then click the + **Video-In** button that appears below it. +2. Click **📷 Test Camera**. (The same test is also under **Help ▶ → + Diagnostics → Test Camera…**, which requires a running machine.) 3. macOS shows the camera-permission prompt; allow it. 4. A live preview from the Mac camera appears, with a status line showing the capture resolution and a rising frame count. Closing the window releases the @@ -79,7 +116,8 @@ genuine server features above.) **How to test (reviewer steps):** 1. Launch IRIS and **Start** a machine (the bundled config boots to the PROM). -2. Open **Machine → Serial console…**. +2. Open **Machine ▶ → Serial console…** (also under **Help ▶ → Diagnostics → + Serial console…**). 3. The window shows "● connected to 127.0.0.1:8881" and streams the live guest serial console. Typing a line and pressing Enter sends it to the guest. This confirms the app's loopback serial **server** is live and accepting a