diff --git a/.github/workflows/native-macos.yml b/.github/workflows/native-macos.yml new file mode 100644 index 0000000000000..06341d0d04c19 --- /dev/null +++ b/.github/workflows/native-macos.yml @@ -0,0 +1,64 @@ +name: Native macOS prototype + +on: + pull_request: + paths: + - apps/macos/** + - apps/desktop/src/preview/extensions/** + - apps/desktop/src/preview/BrowserSession.ts + - apps/desktop/src/preview-extension-preload.ts + - .github/workflows/native-macos.yml + push: + branches: [main] + paths: + - apps/macos/** + - apps/desktop/src/preview/extensions/** + - apps/desktop/src/preview/BrowserSession.ts + - apps/desktop/src/preview-extension-preload.ts + - .github/workflows/native-macos.yml + +permissions: + contents: read + +jobs: + native: + runs-on: macos-15 + timeout-minutes: 20 + env: + DEVELOPER_DIR: /Applications/Xcode_26.3.app/Contents/Developer + steps: + - uses: actions/checkout@v6 + - run: test "$(uname -m)" = arm64 + - uses: voidzero-dev/setup-vp@v1.21.2 + with: + node-version-file: package.json + cache: true + run-install: | + args: + - --filter=t3... + - --filter=@t3tools/desktop... + - --filter=@t3tools/scripts... + - run: cp .env.example .env + - run: node --test apps/macos/chromium-helper/*.test.mjs + - run: swift test --package-path apps/macos + - run: apps/macos/build.sh + - name: Verify packaged browser + env: + T3_CHROMIUM_TEST_APP: ${{ github.workspace }}/apps/macos/.build/T3 Native.app/Contents/Resources/chromium-helper/T3 Browser.app + run: node --test apps/macos/chromium-helper/runtime.test.mjs + - name: Verify packaged backend + env: + T3_NATIVE_SMOKE_RESOURCES: ${{ github.workspace }}/apps/macos/.build/T3 Native.app/Contents/Resources + run: swift test --package-path apps/macos --filter BackendRuntimeSmokeTests + - name: Preserve failed browser fixture evidence + if: failure() + uses: actions/upload-artifact@v4 + with: + name: native-browser-fixture-evidence + if-no-files-found: ignore + path: | + /tmp/t3-native-evidence/chromium-runtime-*/*.stderr.log + /tmp/t3-native-evidence/chromium-runtime-*/cursor.png + /tmp/t3-native-evidence/chromium-extension-*/result.json + /tmp/t3-native-evidence/chromium-extension-*/first/extension-ui.json + /tmp/t3-native-evidence/chromium-extension-*/restart/extension-ui.json diff --git a/apps/desktop/src/preview/extensions/ExtensionHost.ts b/apps/desktop/src/preview/extensions/ExtensionHost.ts index f9f9241204e65..e49e23f026bce 100644 --- a/apps/desktop/src/preview/extensions/ExtensionHost.ts +++ b/apps/desktop/src/preview/extensions/ExtensionHost.ts @@ -423,6 +423,14 @@ export class ExtensionHost { // --------------------------------------------------------------------------- // Toolbar + /** A service worker has explicitly chosen its popup, including no popup. */ + hasActionPopupOverride(tabWebContentsId: number, extensionId: string): boolean { + const tab = AllWebContents.fromId(tabWebContentsId); + if (!tab || tab.isDestroyed()) return false; + const state = this.#sessions.get(tab.session); + return typeof state?.actions.get(extensionId)?.global.popup === "string"; + } + /** Installed extensions, with toolbar state as one preview tab sees it. */ async list(tabWebContentsId?: number): Promise> { const installed = await this.#installedList(); diff --git a/apps/macos/.gitignore b/apps/macos/.gitignore new file mode 100644 index 0000000000000..fb002c5d552fe --- /dev/null +++ b/apps/macos/.gitignore @@ -0,0 +1,3 @@ +.build/ +.swiftpm/ +__pycache__/ diff --git a/apps/macos/Licenses/Ghostty-LICENSE.txt b/apps/macos/Licenses/Ghostty-LICENSE.txt new file mode 100644 index 0000000000000..0a07a66cd1ad6 --- /dev/null +++ b/apps/macos/Licenses/Ghostty-LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Mitchell Hashimoto, Ghostty contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/apps/macos/Licenses/Ghostty-THIRD-PARTY-NOTICES.txt b/apps/macos/Licenses/Ghostty-THIRD-PARTY-NOTICES.txt new file mode 100644 index 0000000000000..b0a60385ba88d --- /dev/null +++ b/apps/macos/Licenses/Ghostty-THIRD-PARTY-NOTICES.txt @@ -0,0 +1,8795 @@ +GhosttyKit third-party notices + +The bundled arm64 library is built from the revision in Vendor/GHOSTTY_VERSION. +Its source and the build scripts are available in https://github.com/Yash-Singh1/ghostty +and apps/macos/scripts/build-ghostty.sh. The source URLs below provide the exact +dependency versions, including the libintl and z2d sources. Build-time dependencies +and notices for optional portions of those source packages are included here too. +To modify or replace a library, edit that source dependency and rebuild GhosttyKit +with GHOSTTY_ALLOW_LOCAL_CHANGES=1, then rebuild T3 Native with apps/macos/build.sh. + +Source: https://deps.files.ghostty.org/zlib-1220fed0c74e1019b3ee29edae2051788b080cd96e90d56836eea857b0b966742efb.tar.gz +Zig package hash: N-V-__8AAB0eQwD-0MdOEBmz7intriBReIsIDNlukNVoNu6o + +--- LICENSE --- +Copyright notice: + + (C) 1995-2022 Jean-loup Gailly and Mark Adler + + This software is provided 'as-is', without any express or implied + warranty. In no event will the authors be held liable for any damages + arising from the use of this software. + + Permission is granted to anyone to use this software for any purpose, + including commercial applications, and to alter it and redistribute it + freely, subject to the following restrictions: + + 1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would be + appreciated but is not required. + 2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. + 3. This notice may not be removed or altered from any source distribution. + + Jean-loup Gailly Mark Adler + jloup@gzip.org madler@alumni.caltech.edu + +--- contrib/dotzlib/LICENSE_1_0.txt --- +Boost Software License - Version 1.0 - August 17th, 2003 + +Permission is hereby granted, free of charge, to any person or organization +obtaining a copy of the software and accompanying documentation covered by +this license (the "Software") to use, reproduce, display, distribute, +execute, and transmit the Software, and to prepare derivative works of the +Software, and to permit third-parties to whom the Software is furnished to +do so, all subject to the following: + +The copyright notices in the Software and this entire statement, including +the above license grant, this restriction and the following disclaimer, +must be included in all copies of the Software, in whole or in part, and +all derivative works of the Software, unless such copies or derivative +works are solely in the form of machine-executable object code generated by +a source language processor. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT. IN NO EVENT +SHALL THE COPYRIGHT HOLDERS OR ANYONE DISTRIBUTING THE SOFTWARE BE LIABLE +FOR ANY DAMAGES OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +======================================== + +Source: https://deps.files.ghostty.org/glslang-12201278a1a05c0ce0b6eb6026c65cd3e9247aa041b1c260324bf29cee559dd23ba1.tar.gz +Zig package hash: N-V-__8AABzkUgISeKGgXAzgtutgJsZc0-kkeqBBscJgMkvy + +--- LICENSE.txt --- +Here, glslang proper means core GLSL parsing, HLSL parsing, and SPIR-V code +generation. Glslang proper requires use of a number of licenses, one that covers +preprocessing and others that covers non-preprocessing. + +Bison was removed long ago. You can build glslang from the source grammar, +using tools of your choice, without using bison or any bison files. + +Other parts, outside of glslang proper, include: + +- gl_types.h, only needed for OpenGL-like reflection, and can be left out of + a parse and codegen project. See it for its license. + +- update_glslang_sources.py, which is not part of the project proper and does + not need to be used. + +- the SPIR-V "remapper", which is optional, but has the same license as + glslang proper + +- Google tests and SPIR-V tools, and anything in the external subdirectory + are external and optional; see them for their respective licenses. + +-------------------------------------------------------------------------------- + +The core of glslang-proper, minus the preprocessor is licenced as follows: + +-------------------------------------------------------------------------------- +3-Clause BSD License +-------------------------------------------------------------------------------- + +// +// Copyright (C) 2015-2018 Google, Inc. +// Copyright (C) +// +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions +// are met: +// +// Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// +// Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following +// disclaimer in the documentation and/or other materials provided +// with the distribution. +// +// Neither the name of 3Dlabs Inc. Ltd. nor the names of its +// contributors may be used to endorse or promote products derived +// from this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +// FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +// COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +// INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +// BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +// LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +// ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +// POSSIBILITY OF SUCH DAMAGE. +// + + +-------------------------------------------------------------------------------- +2-Clause BSD License +-------------------------------------------------------------------------------- + +Copyright 2020 The Khronos Group Inc + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +-------------------------------------------------------------------------------- +The MIT License +-------------------------------------------------------------------------------- + +Copyright 2020 The Khronos Group Inc + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +-------------------------------------------------------------------------------- +APACHE LICENSE, VERSION 2.0 +-------------------------------------------------------------------------------- + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + +-------------------------------------------------------------------------------- +GPL 3 with special bison exception +-------------------------------------------------------------------------------- + + 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. + +Bison Exception + +As a special exception, you may create a larger work that contains part or all +of the Bison parser skeleton and distribute that work under terms of your +choice, so long as that work isn't itself a parser generator using the skeleton +or a modified version thereof as a parser skeleton. Alternatively, if you +modify or redistribute the parser skeleton itself, you may (at your option) +remove this special exception, which will cause the skeleton and the resulting +Bison output files to be licensed under the GNU General Public License without +this special exception. + +This special exception was added by the Free Software Foundation in version +2.2 of Bison. + + END OF TERMS AND CONDITIONS + +-------------------------------------------------------------------------------- +================================================================================ +-------------------------------------------------------------------------------- + +The preprocessor has the core licenses stated above, plus additional licences: + +/****************************************************************************\ +Copyright (c) 2002, NVIDIA Corporation. + +NVIDIA Corporation("NVIDIA") supplies this software to you in +consideration of your agreement to the following terms, and your use, +installation, modification or redistribution of this NVIDIA software +constitutes acceptance of these terms. If you do not agree with these +terms, please do not use, install, modify or redistribute this NVIDIA +software. + +In consideration of your agreement to abide by the following terms, and +subject to these terms, NVIDIA grants you a personal, non-exclusive +license, under NVIDIA's copyrights in this original NVIDIA software (the +"NVIDIA Software"), to use, reproduce, modify and redistribute the +NVIDIA Software, with or without modifications, in source and/or binary +forms; provided that if you redistribute the NVIDIA Software, you must +retain the copyright notice of NVIDIA, this notice and the following +text and disclaimers in all such redistributions of the NVIDIA Software. +Neither the name, trademarks, service marks nor logos of NVIDIA +Corporation may be used to endorse or promote products derived from the +NVIDIA Software without specific prior written permission from NVIDIA. +Except as expressly stated in this notice, no other rights or licenses +express or implied, are granted by NVIDIA herein, including but not +limited to any patent rights that may be infringed by your derivative +works or by other works in which the NVIDIA Software may be +incorporated. No hardware is licensed hereunder. + +THE NVIDIA SOFTWARE IS BEING PROVIDED ON AN "AS IS" BASIS, WITHOUT +WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR IMPLIED, +INCLUDING WITHOUT LIMITATION, WARRANTIES OR CONDITIONS OF TITLE, +NON-INFRINGEMENT, MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, OR +ITS USE AND OPERATION EITHER ALONE OR IN COMBINATION WITH OTHER +PRODUCTS. + +IN NO EVENT SHALL NVIDIA BE LIABLE FOR ANY SPECIAL, INDIRECT, +INCIDENTAL, EXEMPLARY, CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED +TO, LOST PROFITS; PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF +USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) OR ARISING IN ANY WAY +OUT OF THE USE, REPRODUCTION, MODIFICATION AND/OR DISTRIBUTION OF THE +NVIDIA SOFTWARE, HOWEVER CAUSED AND WHETHER UNDER THEORY OF CONTRACT, +TORT (INCLUDING NEGLIGENCE), STRICT LIABILITY OR OTHERWISE, EVEN IF +NVIDIA HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +\****************************************************************************/ + +/* +** Copyright (c) 2014-2016 The Khronos Group Inc. +** +** Permission is hereby granted, free of charge, to any person obtaining a copy +** of this software and/or associated documentation files (the "Materials"), +** to deal in the Materials without restriction, including without limitation +** the rights to use, copy, modify, merge, publish, distribute, sublicense, +** and/or sell copies of the Materials, and to permit persons to whom the +** Materials are furnished to do so, subject to the following conditions: +** +** The above copyright notice and this permission notice shall be included in +** all copies or substantial portions of the Materials. +** +** MODIFICATIONS TO THIS FILE MAY MEAN IT NO LONGER ACCURATELY REFLECTS KHRONOS +** STANDARDS. THE UNMODIFIED, NORMATIVE VERSIONS OF KHRONOS SPECIFICATIONS AND +** HEADER INFORMATION ARE LOCATED AT https://www.khronos.org/registry/ +** +** THE MATERIALS ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +** OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +** THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +** FROM,OUT OF OR IN CONNECTION WITH THE MATERIALS OR THE USE OR OTHER DEALINGS +** IN THE MATERIALS. +*/ + + +======================================== + +Source: https://deps.files.ghostty.org/pixels-12207ff340169c7d40c570b4b6a97db614fe47e0d83b5801a932dcd44917424c8806.tar.gz +Zig package hash: N-V-__8AADYiAAB_80AWnH1AxXC0tql9thT-R-DYO1gBqTLc + +--- LICENSE --- +CC0 1.0 Universal + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator and +subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for the +purpose of contributing to a commons of creative, cultural and scientific +works ("Commons") that the public can reliably and without fear of later +claims of infringement build upon, modify, incorporate in other works, reuse +and redistribute as freely as possible in any form whatsoever and for any +purposes, including without limitation commercial purposes. These owners may +contribute to the Commons to promote the ideal of a free culture and the +further production of creative, cultural and scientific works, or to gain +reputation or greater distribution for their Work in part through the use and +efforts of others. + +For these and/or other purposes and motivations, and without any expectation +of additional consideration or compensation, the person associating CC0 with a +Work (the "Affirmer"), to the extent that he or she is an owner of Copyright +and Related Rights in the Work, voluntarily elects to apply CC0 to the Work +and publicly distribute the Work under its terms, with knowledge of his or her +Copyright and Related Rights in the Work and the meaning and intended legal +effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not limited +to, the following: + + i. the right to reproduce, adapt, distribute, perform, display, communicate, + and translate a Work; + + ii. moral rights retained by the original author(s) and/or performer(s); + + iii. publicity and privacy rights pertaining to a person's image or likeness + depicted in a Work; + + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + + v. rights protecting the extraction, dissemination, use and reuse of data in + a Work; + + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation thereof, + including any amended or successor version of such directive); and + + vii. other similar, equivalent or corresponding rights throughout the world + based on applicable law or treaty, and any national implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention of, +applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and +unconditionally waives, abandons, and surrenders all of Affirmer's Copyright +and Related Rights and associated claims and causes of action, whether now +known or unknown (including existing as well as future claims and causes of +action), in the Work (i) in all territories worldwide, (ii) for the maximum +duration provided by applicable law or treaty (including future time +extensions), (iii) in any current or future medium and for any number of +copies, and (iv) for any purpose whatsoever, including without limitation +commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes +the Waiver for the benefit of each member of the public at large and to the +detriment of Affirmer's heirs and successors, fully intending that such Waiver +shall not be subject to revocation, rescission, cancellation, termination, or +any other legal or equitable action to disrupt the quiet enjoyment of the Work +by the public as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason be +judged legally invalid or ineffective under applicable law, then the Waiver +shall be preserved to the maximum extent permitted taking into account +Affirmer's express Statement of Purpose. In addition, to the extent the Waiver +is so judged Affirmer hereby grants to each affected person a royalty-free, +non transferable, non sublicensable, non exclusive, irrevocable and +unconditional license to exercise Affirmer's Copyright and Related Rights in +the Work (i) in all territories worldwide, (ii) for the maximum duration +provided by applicable law or treaty (including future time extensions), (iii) +in any current or future medium and for any number of copies, and (iv) for any +purpose whatsoever, including without limitation commercial, advertising or +promotional purposes (the "License"). The License shall be deemed effective as +of the date CC0 was applied by Affirmer to the Work. Should any part of the +License for any reason be judged legally invalid or ineffective under +applicable law, such partial invalidity or ineffectiveness shall not +invalidate the remainder of the License, and in such case Affirmer hereby +affirms that he or she will not (i) exercise any of his or her remaining +Copyright and Related Rights in the Work or (ii) assert any associated claims +and causes of action with respect to the Work, in either case contrary to +Affirmer's express Statement of Purpose. + +4. Limitations and Disclaimers. + + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + + b. Affirmer offers the Work as-is and makes no representations or warranties + of any kind concerning the Work, express, implied, statutory or otherwise, + including without limitation warranties of title, merchantability, fitness + for a particular purpose, non infringement, or the absence of latent or + other defects, accuracy, or the present or absence of errors, whether or not + discoverable, all to the greatest extent permissible under applicable law. + + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without limitation + any person's Copyright and Related Rights in the Work. Further, Affirmer + disclaims responsibility for obtaining any necessary consents, permissions + or other rights required for any use of the Work. + + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to this + CC0 or use of the Work. + +For more information, please see + + + + +======================================== + +Source: https://deps.files.ghostty.org/gettext-0.24.tar.gz +Zig package hash: N-V-__8AADcZkgn4cMhTUpIz6mShCKyqqB-NBtf_S2bHaTC- + +--- COPYING --- + 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 +. + +--- gettext-runtime/COPYING --- +The gettext-runtime package is partially under the LGPL and partially under +the GPL. + +The following parts are under the LGPL, see file intl/COPYING.LIB: + - the libintl and libasprintf libraries and their header files, + - the libintl.jar Java library, + - the GNU.Gettext.dll C# library, + - the gettext.sh shells script function library. + +The following parts are under the GPL, see file COPYING in the toplevel +directory: + - the _programs_ gettext, ngettext, envsubst, + - the documentation. + +--- gettext-runtime/intl/COPYING.LIB --- + GNU LESSER GENERAL PUBLIC LICENSE + Version 2.1, February 1999 + + Copyright (C) 1991, 1999 Free Software Foundation, Inc. + + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + +[This is the first released version of the Lesser GPL. It also counts + as the successor of the GNU Library Public License, version 2, hence + the version number 2.1.] + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +Licenses are intended to guarantee your freedom to share and change +free software--to make sure the software is free for all its users. + + This license, the Lesser General Public License, applies to some +specially designated software packages--typically libraries--of the +Free Software Foundation and other authors who decide to use it. You +can use it too, but we suggest you first think carefully about whether +this license or the ordinary General Public License is the better +strategy to use in any particular case, based on the explanations below. + + When we speak of free software, we are referring to freedom of use, +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 this service if you wish); that you receive source code or can get +it if you want it; that you can change the software and use pieces of +it in new free programs; and that you are informed that you can do +these things. + + To protect your rights, we need to make restrictions that forbid +distributors to deny you these rights or to ask you to surrender these +rights. These restrictions translate to certain responsibilities for +you if you distribute copies of the library or if you modify it. + + For example, if you distribute copies of the library, whether gratis +or for a fee, you must give the recipients all the rights that we gave +you. You must make sure that they, too, receive or can get the source +code. If you link other code with the library, you must provide +complete object files to the recipients, so that they can relink them +with the library after making changes to the library and recompiling +it. And you must show them these terms so they know their rights. + + We protect your rights with a two-step method: (1) we copyright the +library, and (2) we offer you this license, which gives you legal +permission to copy, distribute and/or modify the library. + + To protect each distributor, we want to make it very clear that +there is no warranty for the free library. Also, if the library is +modified by someone else and passed on, the recipients should know +that what they have is not the original version, so that the original +author's reputation will not be affected by problems that might be +introduced by others. + + Finally, software patents pose a constant threat to the existence of +any free program. We wish to make sure that a company cannot +effectively restrict the users of a free program by obtaining a +restrictive license from a patent holder. Therefore, we insist that +any patent license obtained for a version of the library must be +consistent with the full freedom of use specified in this license. + + Most GNU software, including some libraries, is covered by the +ordinary GNU General Public License. This license, the GNU Lesser +General Public License, applies to certain designated libraries, and +is quite different from the ordinary General Public License. We use +this license for certain libraries in order to permit linking those +libraries into non-free programs. + + When a program is linked with a library, whether statically or using +a shared library, the combination of the two is legally speaking a +combined work, a derivative of the original library. The ordinary +General Public License therefore permits such linking only if the +entire combination fits its criteria of freedom. The Lesser General +Public License permits more lax criteria for linking other code with +the library. + + We call this license the "Lesser" General Public License because it +does Less to protect the user's freedom than the ordinary General +Public License. It also provides other free software developers Less +of an advantage over competing non-free programs. These disadvantages +are the reason we use the ordinary General Public License for many +libraries. However, the Lesser license provides advantages in certain +special circumstances. + + For example, on rare occasions, there may be a special need to +encourage the widest possible use of a certain library, so that it becomes +a de-facto standard. To achieve this, non-free programs must be +allowed to use the library. A more frequent case is that a free +library does the same job as widely used non-free libraries. In this +case, there is little to gain by limiting the free library to free +software only, so we use the Lesser General Public License. + + In other cases, permission to use a particular library in non-free +programs enables a greater number of people to use a large body of +free software. For example, permission to use the GNU C Library in +non-free programs enables many more people to use the whole GNU +operating system, as well as its variant, the GNU/Linux operating +system. + + Although the Lesser General Public License is Less protective of the +users' freedom, it does ensure that the user of a program that is +linked with the Library has the freedom and the wherewithal to run +that program using a modified version of the Library. + + The precise terms and conditions for copying, distribution and +modification follow. Pay close attention to the difference between a +"work based on the library" and a "work that uses the library". The +former contains code derived from the library, whereas the latter must +be combined with the library in order to run. + + GNU LESSER GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License Agreement applies to any software library or other +program which contains a notice placed by the copyright holder or +other authorized party saying it may be distributed under the terms of +this Lesser General Public License (also called "this License"). +Each licensee is addressed as "you". + + A "library" means a collection of software functions and/or data +prepared so as to be conveniently linked with application programs +(which use some of those functions and data) to form executables. + + The "Library", below, refers to any such software library or work +which has been distributed under these terms. A "work based on the +Library" means either the Library or any derivative work under +copyright law: that is to say, a work containing the Library or a +portion of it, either verbatim or with modifications and/or translated +straightforwardly into another language. (Hereinafter, translation is +included without limitation in the term "modification".) + + "Source code" for a work means the preferred form of the work for +making modifications to it. For a library, complete source code means +all the source code for all modules it contains, plus any associated +interface definition files, plus the scripts used to control compilation +and installation of the library. + + Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running a program using the Library is not restricted, and output from +such a program is covered only if its contents constitute a work based +on the Library (independent of the use of the Library in a tool for +writing it). Whether that is true depends on what the Library does +and what the program that uses the Library does. + + 1. You may copy and distribute verbatim copies of the Library's +complete source code as you receive it, in any medium, provided that +you conspicuously and appropriately publish on each copy an +appropriate copyright notice and disclaimer of warranty; keep intact +all the notices that refer to this License and to the absence of any +warranty; and distribute a copy of this License along with the +Library. + + You may charge a fee for the physical act of transferring a copy, +and you may at your option offer warranty protection in exchange for a +fee. + + 2. You may modify your copy or copies of the Library or any portion +of it, thus forming a work based on the Library, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) The modified work must itself be a software library. + + b) You must cause the files modified to carry prominent notices + stating that you changed the files and the date of any change. + + c) You must cause the whole of the work to be licensed at no + charge to all third parties under the terms of this License. + + d) If a facility in the modified Library refers to a function or a + table of data to be supplied by an application program that uses + the facility, other than as an argument passed when the facility + is invoked, then you must make a good faith effort to ensure that, + in the event an application does not supply such function or + table, the facility still operates, and performs whatever part of + its purpose remains meaningful. + + (For example, a function in a library to compute square roots has + a purpose that is entirely well-defined independent of the + application. Therefore, Subsection 2d requires that any + application-supplied function or table used by this function must + be optional: if the application does not supply it, the square + root function must still compute square roots.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Library, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Library, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote +it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Library. + +In addition, mere aggregation of another work not based on the Library +with the Library (or with a work based on the Library) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may opt to apply the terms of the ordinary GNU General Public +License instead of this License to a given copy of the Library. To do +this, you must alter all the notices that refer to this License, so +that they refer to the ordinary GNU General Public License, version 2, +instead of to this License. (If a newer version than version 2 of the +ordinary GNU General Public License has appeared, then you can specify +that version instead if you wish.) Do not make any other change in +these notices. + + Once this change is made in a given copy, it is irreversible for +that copy, so the ordinary GNU General Public License applies to all +subsequent copies and derivative works made from that copy. + + This option is useful when you wish to copy part of the code of +the Library into a program that is not a library. + + 4. You may copy and distribute the Library (or a portion or +derivative of it, under Section 2) in object code or executable form +under the terms of Sections 1 and 2 above provided that you accompany +it with the complete corresponding machine-readable source code, which +must be distributed under the terms of Sections 1 and 2 above on a +medium customarily used for software interchange. + + If distribution of object code is made by offering access to copy +from a designated place, then offering equivalent access to copy the +source code from the same place satisfies the requirement to +distribute the source code, even though third parties are not +compelled to copy the source along with the object code. + + 5. A program that contains no derivative of any portion of the +Library, but is designed to work with the Library by being compiled or +linked with it, is called a "work that uses the Library". Such a +work, in isolation, is not a derivative work of the Library, and +therefore falls outside the scope of this License. + + However, linking a "work that uses the Library" with the Library +creates an executable that is a derivative of the Library (because it +contains portions of the Library), rather than a "work that uses the +library". The executable is therefore covered by this License. +Section 6 states terms for distribution of such executables. + + When a "work that uses the Library" uses material from a header file +that is part of the Library, the object code for the work may be a +derivative work of the Library even though the source code is not. +Whether this is true is especially significant if the work can be +linked without the Library, or if the work is itself a library. The +threshold for this to be true is not precisely defined by law. + + If such an object file uses only numerical parameters, data +structure layouts and accessors, and small macros and small inline +functions (ten lines or less in length), then the use of the object +file is unrestricted, regardless of whether it is legally a derivative +work. (Executables containing this object code plus portions of the +Library will still fall under Section 6.) + + Otherwise, if the work is a derivative of the Library, you may +distribute the object code for the work under the terms of Section 6. +Any executables containing that work also fall under Section 6, +whether or not they are linked directly with the Library itself. + + 6. As an exception to the Sections above, you may also combine or +link a "work that uses the Library" with the Library to produce a +work containing portions of the Library, and distribute that work +under terms of your choice, provided that the terms permit +modification of the work for the customer's own use and reverse +engineering for debugging such modifications. + + You must give prominent notice with each copy of the work that the +Library is used in it and that the Library and its use are covered by +this License. You must supply a copy of this License. If the work +during execution displays copyright notices, you must include the +copyright notice for the Library among them, as well as a reference +directing the user to the copy of this License. Also, you must do one +of these things: + + a) Accompany the work with the complete corresponding + machine-readable source code for the Library including whatever + changes were used in the work (which must be distributed under + Sections 1 and 2 above); and, if the work is an executable linked + with the Library, with the complete machine-readable "work that + uses the Library", as object code and/or source code, so that the + user can modify the Library and then relink to produce a modified + executable containing the modified Library. (It is understood + that the user who changes the contents of definitions files in the + Library will not necessarily be able to recompile the application + to use the modified definitions.) + + b) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (1) uses at run time a + copy of the library already present on the user's computer system, + rather than copying library functions into the executable, and (2) + will operate properly with a modified version of the library, if + the user installs one, as long as the modified version is + interface-compatible with the version that the work was made with. + + c) Accompany the work with a written offer, valid for at + least three years, to give the same user the materials + specified in Subsection 6a, above, for a charge no more + than the cost of performing this distribution. + + d) If distribution of the work is made by offering access to copy + from a designated place, offer equivalent access to copy the above + specified materials from the same place. + + e) Verify that the user has already received a copy of these + materials or that you have already sent this user a copy. + + For an executable, the required form of the "work that uses the +Library" must include any data and utility programs needed for +reproducing the executable from it. However, as a special exception, +the materials to be distributed need not include anything that is +normally distributed (in either source or binary form) with the major +components (compiler, kernel, and so on) of the operating system on +which the executable runs, unless that component itself accompanies +the executable. + + It may happen that this requirement contradicts the license +restrictions of other proprietary libraries that do not normally +accompany the operating system. Such a contradiction means you cannot +use both them and the Library together in an executable that you +distribute. + + 7. You may place library facilities that are a work based on the +Library side-by-side in a single library together with other library +facilities not covered by this License, and distribute such a combined +library, provided that the separate distribution of the work based on +the Library and of the other library facilities is otherwise +permitted, and provided that you do these two things: + + a) Accompany the combined library with a copy of the same work + based on the Library, uncombined with any other library + facilities. This must be distributed under the terms of the + Sections above. + + b) Give prominent notice with the combined library of the fact + that part of it is a work based on the Library, and explaining + where to find the accompanying uncombined form of the same work. + + 8. You may not copy, modify, sublicense, link with, or distribute +the Library except as expressly provided under this License. Any +attempt otherwise to copy, modify, sublicense, link with, or +distribute the Library is void, and will automatically terminate your +rights under this License. However, parties who have received copies, +or rights, from you under this License will not have their licenses +terminated so long as such parties remain in full compliance. + + 9. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Library or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Library (or any work based on the +Library), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Library or works based on it. + + 10. Each time you redistribute the Library (or any work based on the +Library), the recipient automatically receives a license from the +original licensor to copy, distribute, link with or modify the Library +subject to these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties with +this License. + + 11. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +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 +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Library at all. For example, if a patent +license would not permit royalty-free redistribution of the Library by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Library. + +If any portion of this section is held invalid or unenforceable under any +particular circumstance, the balance of the section is intended to apply, +and the section as a whole is intended to apply in other circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 12. If the distribution and/or use of the Library is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Library under this License may add +an explicit geographical distribution limitation excluding those countries, +so that distribution is permitted only in or among countries not thus +excluded. In such case, this License incorporates the limitation as if +written in the body of this License. + + 13. The Free Software Foundation may publish revised and/or new +versions of the Lesser 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 Library +specifies a version number of this License which applies to it and +"any later version", you have the option of following the terms and +conditions either of that version or of any later version published by +the Free Software Foundation. If the Library does not specify a +license version number, you may choose any version ever published by +the Free Software Foundation. + + 14. If you wish to incorporate parts of the Library into other free +programs whose distribution conditions are incompatible with these, +write to the author to ask for permission. For software which is +copyrighted by the Free Software Foundation, write to the Free +Software Foundation; we sometimes make exceptions for this. Our +decision will be guided by the two goals of preserving the free status +of all derivatives of our free software and of promoting the sharing +and reuse of software generally. + + NO WARRANTY + + 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO +WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. +EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE LIBRARY "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 +LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME +THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN +WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY +AND/OR REDISTRIBUTE THE LIBRARY 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 +LIBRARY (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 LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF +SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Libraries + + If you develop a new library, and you want it to be of the greatest +possible use to the public, we recommend making it free software that +everyone can redistribute and change. You can do so by permitting +redistribution under these terms (or, alternatively, under the terms of the +ordinary General Public License). + + To apply these terms, attach the following notices to the library. It is +safest to attach them to the start of each source file to most effectively +convey 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 library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library 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 + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, see . + +Also add information on how to contact you by electronic and paper mail. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the library, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the + library `Frob' (a library for tweaking knobs) written by James Random Hacker. + + , 1 April 1990 + Moe Ghoul, President of Vice + +That's all there is to it! + +--- gettext-runtime/libasprintf/COPYING --- +The libasprintf package is under the LGPL, see file COPYING.LIB. + +--- gettext-runtime/libasprintf/COPYING.LIB --- + GNU LESSER GENERAL PUBLIC LICENSE + Version 2.1, February 1999 + + Copyright (C) 1991, 1999 Free Software Foundation, Inc. + + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + +[This is the first released version of the Lesser GPL. It also counts + as the successor of the GNU Library Public License, version 2, hence + the version number 2.1.] + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +Licenses are intended to guarantee your freedom to share and change +free software--to make sure the software is free for all its users. + + This license, the Lesser General Public License, applies to some +specially designated software packages--typically libraries--of the +Free Software Foundation and other authors who decide to use it. You +can use it too, but we suggest you first think carefully about whether +this license or the ordinary General Public License is the better +strategy to use in any particular case, based on the explanations below. + + When we speak of free software, we are referring to freedom of use, +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 this service if you wish); that you receive source code or can get +it if you want it; that you can change the software and use pieces of +it in new free programs; and that you are informed that you can do +these things. + + To protect your rights, we need to make restrictions that forbid +distributors to deny you these rights or to ask you to surrender these +rights. These restrictions translate to certain responsibilities for +you if you distribute copies of the library or if you modify it. + + For example, if you distribute copies of the library, whether gratis +or for a fee, you must give the recipients all the rights that we gave +you. You must make sure that they, too, receive or can get the source +code. If you link other code with the library, you must provide +complete object files to the recipients, so that they can relink them +with the library after making changes to the library and recompiling +it. And you must show them these terms so they know their rights. + + We protect your rights with a two-step method: (1) we copyright the +library, and (2) we offer you this license, which gives you legal +permission to copy, distribute and/or modify the library. + + To protect each distributor, we want to make it very clear that +there is no warranty for the free library. Also, if the library is +modified by someone else and passed on, the recipients should know +that what they have is not the original version, so that the original +author's reputation will not be affected by problems that might be +introduced by others. + + Finally, software patents pose a constant threat to the existence of +any free program. We wish to make sure that a company cannot +effectively restrict the users of a free program by obtaining a +restrictive license from a patent holder. Therefore, we insist that +any patent license obtained for a version of the library must be +consistent with the full freedom of use specified in this license. + + Most GNU software, including some libraries, is covered by the +ordinary GNU General Public License. This license, the GNU Lesser +General Public License, applies to certain designated libraries, and +is quite different from the ordinary General Public License. We use +this license for certain libraries in order to permit linking those +libraries into non-free programs. + + When a program is linked with a library, whether statically or using +a shared library, the combination of the two is legally speaking a +combined work, a derivative of the original library. The ordinary +General Public License therefore permits such linking only if the +entire combination fits its criteria of freedom. The Lesser General +Public License permits more lax criteria for linking other code with +the library. + + We call this license the "Lesser" General Public License because it +does Less to protect the user's freedom than the ordinary General +Public License. It also provides other free software developers Less +of an advantage over competing non-free programs. These disadvantages +are the reason we use the ordinary General Public License for many +libraries. However, the Lesser license provides advantages in certain +special circumstances. + + For example, on rare occasions, there may be a special need to +encourage the widest possible use of a certain library, so that it becomes +a de-facto standard. To achieve this, non-free programs must be +allowed to use the library. A more frequent case is that a free +library does the same job as widely used non-free libraries. In this +case, there is little to gain by limiting the free library to free +software only, so we use the Lesser General Public License. + + In other cases, permission to use a particular library in non-free +programs enables a greater number of people to use a large body of +free software. For example, permission to use the GNU C Library in +non-free programs enables many more people to use the whole GNU +operating system, as well as its variant, the GNU/Linux operating +system. + + Although the Lesser General Public License is Less protective of the +users' freedom, it does ensure that the user of a program that is +linked with the Library has the freedom and the wherewithal to run +that program using a modified version of the Library. + + The precise terms and conditions for copying, distribution and +modification follow. Pay close attention to the difference between a +"work based on the library" and a "work that uses the library". The +former contains code derived from the library, whereas the latter must +be combined with the library in order to run. + + GNU LESSER GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License Agreement applies to any software library or other +program which contains a notice placed by the copyright holder or +other authorized party saying it may be distributed under the terms of +this Lesser General Public License (also called "this License"). +Each licensee is addressed as "you". + + A "library" means a collection of software functions and/or data +prepared so as to be conveniently linked with application programs +(which use some of those functions and data) to form executables. + + The "Library", below, refers to any such software library or work +which has been distributed under these terms. A "work based on the +Library" means either the Library or any derivative work under +copyright law: that is to say, a work containing the Library or a +portion of it, either verbatim or with modifications and/or translated +straightforwardly into another language. (Hereinafter, translation is +included without limitation in the term "modification".) + + "Source code" for a work means the preferred form of the work for +making modifications to it. For a library, complete source code means +all the source code for all modules it contains, plus any associated +interface definition files, plus the scripts used to control compilation +and installation of the library. + + Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running a program using the Library is not restricted, and output from +such a program is covered only if its contents constitute a work based +on the Library (independent of the use of the Library in a tool for +writing it). Whether that is true depends on what the Library does +and what the program that uses the Library does. + + 1. You may copy and distribute verbatim copies of the Library's +complete source code as you receive it, in any medium, provided that +you conspicuously and appropriately publish on each copy an +appropriate copyright notice and disclaimer of warranty; keep intact +all the notices that refer to this License and to the absence of any +warranty; and distribute a copy of this License along with the +Library. + + You may charge a fee for the physical act of transferring a copy, +and you may at your option offer warranty protection in exchange for a +fee. + + 2. You may modify your copy or copies of the Library or any portion +of it, thus forming a work based on the Library, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) The modified work must itself be a software library. + + b) You must cause the files modified to carry prominent notices + stating that you changed the files and the date of any change. + + c) You must cause the whole of the work to be licensed at no + charge to all third parties under the terms of this License. + + d) If a facility in the modified Library refers to a function or a + table of data to be supplied by an application program that uses + the facility, other than as an argument passed when the facility + is invoked, then you must make a good faith effort to ensure that, + in the event an application does not supply such function or + table, the facility still operates, and performs whatever part of + its purpose remains meaningful. + + (For example, a function in a library to compute square roots has + a purpose that is entirely well-defined independent of the + application. Therefore, Subsection 2d requires that any + application-supplied function or table used by this function must + be optional: if the application does not supply it, the square + root function must still compute square roots.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Library, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Library, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote +it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Library. + +In addition, mere aggregation of another work not based on the Library +with the Library (or with a work based on the Library) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may opt to apply the terms of the ordinary GNU General Public +License instead of this License to a given copy of the Library. To do +this, you must alter all the notices that refer to this License, so +that they refer to the ordinary GNU General Public License, version 2, +instead of to this License. (If a newer version than version 2 of the +ordinary GNU General Public License has appeared, then you can specify +that version instead if you wish.) Do not make any other change in +these notices. + + Once this change is made in a given copy, it is irreversible for +that copy, so the ordinary GNU General Public License applies to all +subsequent copies and derivative works made from that copy. + + This option is useful when you wish to copy part of the code of +the Library into a program that is not a library. + + 4. You may copy and distribute the Library (or a portion or +derivative of it, under Section 2) in object code or executable form +under the terms of Sections 1 and 2 above provided that you accompany +it with the complete corresponding machine-readable source code, which +must be distributed under the terms of Sections 1 and 2 above on a +medium customarily used for software interchange. + + If distribution of object code is made by offering access to copy +from a designated place, then offering equivalent access to copy the +source code from the same place satisfies the requirement to +distribute the source code, even though third parties are not +compelled to copy the source along with the object code. + + 5. A program that contains no derivative of any portion of the +Library, but is designed to work with the Library by being compiled or +linked with it, is called a "work that uses the Library". Such a +work, in isolation, is not a derivative work of the Library, and +therefore falls outside the scope of this License. + + However, linking a "work that uses the Library" with the Library +creates an executable that is a derivative of the Library (because it +contains portions of the Library), rather than a "work that uses the +library". The executable is therefore covered by this License. +Section 6 states terms for distribution of such executables. + + When a "work that uses the Library" uses material from a header file +that is part of the Library, the object code for the work may be a +derivative work of the Library even though the source code is not. +Whether this is true is especially significant if the work can be +linked without the Library, or if the work is itself a library. The +threshold for this to be true is not precisely defined by law. + + If such an object file uses only numerical parameters, data +structure layouts and accessors, and small macros and small inline +functions (ten lines or less in length), then the use of the object +file is unrestricted, regardless of whether it is legally a derivative +work. (Executables containing this object code plus portions of the +Library will still fall under Section 6.) + + Otherwise, if the work is a derivative of the Library, you may +distribute the object code for the work under the terms of Section 6. +Any executables containing that work also fall under Section 6, +whether or not they are linked directly with the Library itself. + + 6. As an exception to the Sections above, you may also combine or +link a "work that uses the Library" with the Library to produce a +work containing portions of the Library, and distribute that work +under terms of your choice, provided that the terms permit +modification of the work for the customer's own use and reverse +engineering for debugging such modifications. + + You must give prominent notice with each copy of the work that the +Library is used in it and that the Library and its use are covered by +this License. You must supply a copy of this License. If the work +during execution displays copyright notices, you must include the +copyright notice for the Library among them, as well as a reference +directing the user to the copy of this License. Also, you must do one +of these things: + + a) Accompany the work with the complete corresponding + machine-readable source code for the Library including whatever + changes were used in the work (which must be distributed under + Sections 1 and 2 above); and, if the work is an executable linked + with the Library, with the complete machine-readable "work that + uses the Library", as object code and/or source code, so that the + user can modify the Library and then relink to produce a modified + executable containing the modified Library. (It is understood + that the user who changes the contents of definitions files in the + Library will not necessarily be able to recompile the application + to use the modified definitions.) + + b) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (1) uses at run time a + copy of the library already present on the user's computer system, + rather than copying library functions into the executable, and (2) + will operate properly with a modified version of the library, if + the user installs one, as long as the modified version is + interface-compatible with the version that the work was made with. + + c) Accompany the work with a written offer, valid for at + least three years, to give the same user the materials + specified in Subsection 6a, above, for a charge no more + than the cost of performing this distribution. + + d) If distribution of the work is made by offering access to copy + from a designated place, offer equivalent access to copy the above + specified materials from the same place. + + e) Verify that the user has already received a copy of these + materials or that you have already sent this user a copy. + + For an executable, the required form of the "work that uses the +Library" must include any data and utility programs needed for +reproducing the executable from it. However, as a special exception, +the materials to be distributed need not include anything that is +normally distributed (in either source or binary form) with the major +components (compiler, kernel, and so on) of the operating system on +which the executable runs, unless that component itself accompanies +the executable. + + It may happen that this requirement contradicts the license +restrictions of other proprietary libraries that do not normally +accompany the operating system. Such a contradiction means you cannot +use both them and the Library together in an executable that you +distribute. + + 7. You may place library facilities that are a work based on the +Library side-by-side in a single library together with other library +facilities not covered by this License, and distribute such a combined +library, provided that the separate distribution of the work based on +the Library and of the other library facilities is otherwise +permitted, and provided that you do these two things: + + a) Accompany the combined library with a copy of the same work + based on the Library, uncombined with any other library + facilities. This must be distributed under the terms of the + Sections above. + + b) Give prominent notice with the combined library of the fact + that part of it is a work based on the Library, and explaining + where to find the accompanying uncombined form of the same work. + + 8. You may not copy, modify, sublicense, link with, or distribute +the Library except as expressly provided under this License. Any +attempt otherwise to copy, modify, sublicense, link with, or +distribute the Library is void, and will automatically terminate your +rights under this License. However, parties who have received copies, +or rights, from you under this License will not have their licenses +terminated so long as such parties remain in full compliance. + + 9. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Library or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Library (or any work based on the +Library), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Library or works based on it. + + 10. Each time you redistribute the Library (or any work based on the +Library), the recipient automatically receives a license from the +original licensor to copy, distribute, link with or modify the Library +subject to these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties with +this License. + + 11. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +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 +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Library at all. For example, if a patent +license would not permit royalty-free redistribution of the Library by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Library. + +If any portion of this section is held invalid or unenforceable under any +particular circumstance, the balance of the section is intended to apply, +and the section as a whole is intended to apply in other circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 12. If the distribution and/or use of the Library is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Library under this License may add +an explicit geographical distribution limitation excluding those countries, +so that distribution is permitted only in or among countries not thus +excluded. In such case, this License incorporates the limitation as if +written in the body of this License. + + 13. The Free Software Foundation may publish revised and/or new +versions of the Lesser 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 Library +specifies a version number of this License which applies to it and +"any later version", you have the option of following the terms and +conditions either of that version or of any later version published by +the Free Software Foundation. If the Library does not specify a +license version number, you may choose any version ever published by +the Free Software Foundation. + + 14. If you wish to incorporate parts of the Library into other free +programs whose distribution conditions are incompatible with these, +write to the author to ask for permission. For software which is +copyrighted by the Free Software Foundation, write to the Free +Software Foundation; we sometimes make exceptions for this. Our +decision will be guided by the two goals of preserving the free status +of all derivatives of our free software and of promoting the sharing +and reuse of software generally. + + NO WARRANTY + + 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO +WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. +EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE LIBRARY "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 +LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME +THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN +WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY +AND/OR REDISTRIBUTE THE LIBRARY 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 +LIBRARY (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 LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF +SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Libraries + + If you develop a new library, and you want it to be of the greatest +possible use to the public, we recommend making it free software that +everyone can redistribute and change. You can do so by permitting +redistribution under these terms (or, alternatively, under the terms of the +ordinary General Public License). + + To apply these terms, attach the following notices to the library. It is +safest to attach them to the start of each source file to most effectively +convey 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 library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library 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 + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, see . + +Also add information on how to contact you by electronic and paper mail. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the library, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the + library `Frob' (a library for tweaking knobs) written by James Random Hacker. + + , 1 April 1990 + Moe Ghoul, President of Vice + +That's all there is to it! + +--- gettext-tools/COPYING --- +This subpackage is under the GPL, see file COPYING in the toplevel directory. + +--- gettext-tools/gnulib-lib/libxml/COPYING --- +Except where otherwise noted in the source code (e.g. the files hash.c, +list.c and the trio files, which are covered by a similar licence but +with different Copyright notices) all the files are: + + Copyright (C) 1998-2012 Daniel Veillard. All Rights Reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is fur- +nished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FIT- +NESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +--- gettext-tools/tree-sitter-0.23.2/LICENSE --- +The MIT License (MIT) + +Copyright (c) 2018-2024 Max Brunsfeld + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- gettext-tools/tree-sitter-0.23.2/lib/src/unicode/LICENSE --- +COPYRIGHT AND PERMISSION NOTICE (ICU 58 and later) + +Copyright © 1991-2019 Unicode, Inc. All rights reserved. +Distributed under the Terms of Use in https://www.unicode.org/copyright.html. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Unicode data files and any associated documentation +(the "Data Files") or Unicode software and any associated documentation +(the "Software") to deal in the Data Files or Software +without restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, and/or sell copies of +the Data Files or Software, and to permit persons to whom the Data Files +or Software are furnished to do so, provided that either +(a) this copyright and permission notice appear with all copies +of the Data Files or Software, or +(b) this copyright and permission notice appear in associated +Documentation. + +THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT OF THIRD PARTY RIGHTS. +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS +NOTICE BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL +DAMAGES, OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, +DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THE DATA FILES OR SOFTWARE. + +Except as contained in this notice, the name of a copyright holder +shall not be used in advertising or otherwise to promote the sale, +use or other dealings in these Data Files or Software without prior +written authorization of the copyright holder. + +--------------------- + +Third-Party Software Licenses + +This section contains third-party software notices and/or additional +terms for licensed third-party software components included within ICU +libraries. + +1. ICU License - ICU 1.8.1 to ICU 57.1 + +COPYRIGHT AND PERMISSION NOTICE + +Copyright (c) 1995-2016 International Business Machines Corporation and others +All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, and/or sell copies of the Software, and to permit persons +to whom the Software is furnished to do so, provided that the above +copyright notice(s) and this permission notice appear in all copies of +the Software and that both the above copyright notice(s) and this +permission notice appear in supporting documentation. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF THIRD PARTY RIGHTS. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR +HOLDERS INCLUDED IN THIS NOTICE BE LIABLE FOR ANY CLAIM, OR ANY +SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, OR ANY DAMAGES WHATSOEVER +RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF +CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +Except as contained in this notice, the name of a copyright holder +shall not be used in advertising or otherwise to promote the sale, use +or other dealings in this Software without prior written authorization +of the copyright holder. + +All trademarks and registered trademarks mentioned herein are the +property of their respective owners. + +2. Chinese/Japanese Word Break Dictionary Data (cjdict.txt) + + # The Google Chrome software developed by Google is licensed under + # the BSD license. Other software included in this distribution is + # provided under other licenses, as set forth below. + # + # The BSD License + # http://opensource.org/licenses/bsd-license.php + # Copyright (C) 2006-2008, Google Inc. + # + # All rights reserved. + # + # Redistribution and use in source and binary forms, with or without + # modification, are permitted provided that the following conditions are met: + # + # Redistributions of source code must retain the above copyright notice, + # this list of conditions and the following disclaimer. + # Redistributions in binary form must reproduce the above + # copyright notice, this list of conditions and the following + # disclaimer in the documentation and/or other materials provided with + # the distribution. + # Neither the name of Google Inc. nor the names of its + # contributors may be used to endorse or promote products derived from + # this software without specific prior written permission. + # + # + # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND + # CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, + # INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF + # MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE + # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR + # BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + # LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + # + # + # The word list in cjdict.txt are generated by combining three word lists + # listed below with further processing for compound word breaking. The + # frequency is generated with an iterative training against Google web + # corpora. + # + # * Libtabe (Chinese) + # - https://sourceforge.net/project/?group_id=1519 + # - Its license terms and conditions are shown below. + # + # * IPADIC (Japanese) + # - http://chasen.aist-nara.ac.jp/chasen/distribution.html + # - Its license terms and conditions are shown below. + # + # ---------COPYING.libtabe ---- BEGIN-------------------- + # + # /* + # * Copyright (c) 1999 TaBE Project. + # * Copyright (c) 1999 Pai-Hsiang Hsiao. + # * All rights reserved. + # * + # * Redistribution and use in source and binary forms, with or without + # * modification, are permitted provided that the following conditions + # * are met: + # * + # * . Redistributions of source code must retain the above copyright + # * notice, this list of conditions and the following disclaimer. + # * . Redistributions in binary form must reproduce the above copyright + # * notice, this list of conditions and the following disclaimer in + # * the documentation and/or other materials provided with the + # * distribution. + # * . Neither the name of the TaBE Project nor the names of its + # * contributors may be used to endorse or promote products derived + # * from this software without specific prior written permission. + # * + # * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + # * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + # * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + # * FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + # * REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + # * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + # * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + # * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + # * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + # * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + # * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + # * OF THE POSSIBILITY OF SUCH DAMAGE. + # */ + # + # /* + # * Copyright (c) 1999 Computer Systems and Communication Lab, + # * Institute of Information Science, Academia + # * Sinica. All rights reserved. + # * + # * Redistribution and use in source and binary forms, with or without + # * modification, are permitted provided that the following conditions + # * are met: + # * + # * . Redistributions of source code must retain the above copyright + # * notice, this list of conditions and the following disclaimer. + # * . Redistributions in binary form must reproduce the above copyright + # * notice, this list of conditions and the following disclaimer in + # * the documentation and/or other materials provided with the + # * distribution. + # * . Neither the name of the Computer Systems and Communication Lab + # * nor the names of its contributors may be used to endorse or + # * promote products derived from this software without specific + # * prior written permission. + # * + # * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + # * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + # * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + # * FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + # * REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + # * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + # * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + # * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + # * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + # * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + # * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + # * OF THE POSSIBILITY OF SUCH DAMAGE. + # */ + # + # Copyright 1996 Chih-Hao Tsai @ Beckman Institute, + # University of Illinois + # c-tsai4@uiuc.edu http://casper.beckman.uiuc.edu/~c-tsai4 + # + # ---------------COPYING.libtabe-----END-------------------------------- + # + # + # ---------------COPYING.ipadic-----BEGIN------------------------------- + # + # Copyright 2000, 2001, 2002, 2003 Nara Institute of Science + # and Technology. All Rights Reserved. + # + # Use, reproduction, and distribution of this software is permitted. + # Any copy of this software, whether in its original form or modified, + # must include both the above copyright notice and the following + # paragraphs. + # + # Nara Institute of Science and Technology (NAIST), + # the copyright holders, disclaims all warranties with regard to this + # software, including all implied warranties of merchantability and + # fitness, in no event shall NAIST be liable for + # any special, indirect or consequential damages or any damages + # whatsoever resulting from loss of use, data or profits, whether in an + # action of contract, negligence or other tortuous action, arising out + # of or in connection with the use or performance of this software. + # + # A large portion of the dictionary entries + # originate from ICOT Free Software. The following conditions for ICOT + # Free Software applies to the current dictionary as well. + # + # Each User may also freely distribute the Program, whether in its + # original form or modified, to any third party or parties, PROVIDED + # that the provisions of Section 3 ("NO WARRANTY") will ALWAYS appear + # on, or be attached to, the Program, which is distributed substantially + # in the same form as set out herein and that such intended + # distribution, if actually made, will neither violate or otherwise + # contravene any of the laws and regulations of the countries having + # jurisdiction over the User or the intended distribution itself. + # + # NO WARRANTY + # + # The program was produced on an experimental basis in the course of the + # research and development conducted during the project and is provided + # to users as so produced on an experimental basis. Accordingly, the + # program is provided without any warranty whatsoever, whether express, + # implied, statutory or otherwise. The term "warranty" used herein + # includes, but is not limited to, any warranty of the quality, + # performance, merchantability and fitness for a particular purpose of + # the program and the nonexistence of any infringement or violation of + # any right of any third party. + # + # Each user of the program will agree and understand, and be deemed to + # have agreed and understood, that there is no warranty whatsoever for + # the program and, accordingly, the entire risk arising from or + # otherwise connected with the program is assumed by the user. + # + # Therefore, neither ICOT, the copyright holder, or any other + # organization that participated in or was otherwise related to the + # development of the program and their respective officials, directors, + # officers and other employees shall be held liable for any and all + # damages, including, without limitation, general, special, incidental + # and consequential damages, arising out of or otherwise in connection + # with the use or inability to use the program or any product, material + # or result produced or otherwise obtained by using the program, + # regardless of whether they have been advised of, or otherwise had + # knowledge of, the possibility of such damages at any time during the + # project or thereafter. Each user will be deemed to have agreed to the + # foregoing by his or her commencement of use of the program. The term + # "use" as used herein includes, but is not limited to, the use, + # modification, copying and distribution of the program and the + # production of secondary products from the program. + # + # In the case where the program, whether in its original form or + # modified, was distributed or delivered to or received by a user from + # any person, organization or entity other than ICOT, unless it makes or + # grants independently of ICOT any specific warranty to the user in + # writing, such person, organization or entity, will also be exempted + # from and not be held liable to the user for any such damages as noted + # above as far as the program is concerned. + # + # ---------------COPYING.ipadic-----END---------------------------------- + +3. Lao Word Break Dictionary Data (laodict.txt) + + # Copyright (c) 2013 International Business Machines Corporation + # and others. All Rights Reserved. + # + # Project: http://code.google.com/p/lao-dictionary/ + # Dictionary: http://lao-dictionary.googlecode.com/git/Lao-Dictionary.txt + # License: http://lao-dictionary.googlecode.com/git/Lao-Dictionary-LICENSE.txt + # (copied below) + # + # This file is derived from the above dictionary, with slight + # modifications. + # ---------------------------------------------------------------------- + # Copyright (C) 2013 Brian Eugene Wilson, Robert Martin Campbell. + # All rights reserved. + # + # Redistribution and use in source and binary forms, with or without + # modification, + # are permitted provided that the following conditions are met: + # + # + # Redistributions of source code must retain the above copyright notice, this + # list of conditions and the following disclaimer. Redistributions in + # binary form must reproduce the above copyright notice, this list of + # conditions and the following disclaimer in the documentation and/or + # other materials provided with the distribution. + # + # + # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + # INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + # SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + # HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + # STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + # OF THE POSSIBILITY OF SUCH DAMAGE. + # -------------------------------------------------------------------------- + +4. Burmese Word Break Dictionary Data (burmesedict.txt) + + # Copyright (c) 2014 International Business Machines Corporation + # and others. All Rights Reserved. + # + # This list is part of a project hosted at: + # github.com/kanyawtech/myanmar-karen-word-lists + # + # -------------------------------------------------------------------------- + # Copyright (c) 2013, LeRoy Benjamin Sharon + # All rights reserved. + # + # Redistribution and use in source and binary forms, with or without + # modification, are permitted provided that the following conditions + # are met: Redistributions of source code must retain the above + # copyright notice, this list of conditions and the following + # disclaimer. Redistributions in binary form must reproduce the + # above copyright notice, this list of conditions and the following + # disclaimer in the documentation and/or other materials provided + # with the distribution. + # + # Neither the name Myanmar Karen Word Lists, nor the names of its + # contributors may be used to endorse or promote products derived + # from this software without specific prior written permission. + # + # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND + # CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, + # INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF + # MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS + # BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED + # TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + # ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR + # TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF + # THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF + # SUCH DAMAGE. + # -------------------------------------------------------------------------- + +5. Time Zone Database + + ICU uses the public domain data and code derived from Time Zone +Database for its time zone support. The ownership of the TZ database +is explained in BCP 175: Procedure for Maintaining the Time Zone +Database section 7. + + # 7. Database Ownership + # + # The TZ database itself is not an IETF Contribution or an IETF + # document. Rather it is a pre-existing and regularly updated work + # that is in the public domain, and is intended to remain in the + # public domain. Therefore, BCPs 78 [RFC5378] and 79 [RFC3979] do + # not apply to the TZ Database or contributions that individuals make + # to it. Should any claims be made and substantiated against the TZ + # Database, the organization that is providing the IANA + # Considerations defined in this RFC, under the memorandum of + # understanding with the IETF, currently ICANN, may act in accordance + # with all competent court orders. No ownership claims will be made + # by ICANN or the IETF Trust on the database or the code. Any person + # making a contribution to the database or code waives all rights to + # future claims in that contribution or in the TZ Database. + +6. Google double-conversion + +Copyright 2006-2011, the V8 project authors. All rights reserved. +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +--- gettext-tools/tree-sitter-rust-0.23.2/LICENSE --- +The MIT License (MIT) + +Copyright (c) 2017 Maxim Sokolov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- gnulib-local/lib/libxml/COPYING --- +Except where otherwise noted in the source code (e.g. the files hash.c, +list.c and the trio files, which are covered by a similar licence but +with different Copyright notices) all the files are: + + Copyright (C) 1998-2012 Daniel Veillard. All Rights Reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is fur- +nished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FIT- +NESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +--- libtextstyle/COPYING --- + 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 +. + +--- libtextstyle/lib/libxml/COPYING --- +Except where otherwise noted in the source code (e.g. the files hash.c, +list.c and the trio files, which are covered by a similar licence but +with different Copyright notices) all the files are: + + Copyright (C) 1998-2012 Daniel Veillard. All Rights Reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is fur- +nished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FIT- +NESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + +======================================== + +Source: https://deps.files.ghostty.org/N-V-__8AAEbOfQBnvcFcCX2W5z7tDaN8vaNZGamEQtNOe0UI.tar.gz +Zig package hash: N-V-__8AAEbOfQBnvcFcCX2W5z7tDaN8vaNZGamEQtNOe0UI + +--- LICENSE.txt --- +The MIT License (MIT) + +Copyright (c) 2014-2025 Omar Cornut + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- examples/libs/glfw/COPYING.txt --- +Copyright (c) 2002-2006 Marcus Geelnard +Copyright (c) 2006-2010 Camilla Berglund + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would + be appreciated but is not required. + +2. Altered source versions must be plainly marked as such, and must not + be misrepresented as being the original software. + +3. This notice may not be removed or altered from any source + distribution. + + + +======================================== + +Source: https://deps.files.ghostty.org/highway-66486a10623fa0d72fe91260f96c892e41aceb06.tar.gz +Zig package hash: N-V-__8AAGmZhABbsPJLfbqrh6JTHsXhY6qCaLAQyx25e0XE + +--- LICENSE --- + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--- LICENSE-BSD3 --- +Copyright (c) The Highway Project Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +--- debian/copyright --- +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Upstream-Name: highway + +Files: * +Copyright: 2020 Google LLC +License: Apache-2.0 + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + . + http://www.apache.org/licenses/LICENSE-2.0 + . + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + . + On Debian systems, the complete text of the Apache License, Version 2 + can be found in "/usr/share/common-licenses/Apache-2.0". + + +======================================== + +Source: https://deps.files.ghostty.org/oniguruma-1220c15e72eadd0d9085a8af134904d9a0f5dfcbed5f606ad60edc60ebeccd9706bb.tar.gz +Zig package hash: N-V-__8AAHjwMQDBXnLq3Q2QhaivE0kE2aD138vtX2Bq1g7c + +--- COPYING --- +Oniguruma LICENSE +----------------- + +Copyright (c) 2002-2021 K.Kosako +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. + + +======================================== + +Source: https://deps.files.ghostty.org/JetBrainsMono-2.304.tar.gz +Zig package hash: N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x + +--- OFL.txt --- +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. + + +======================================== + +Source: https://deps.files.ghostty.org/libpng-1220aa013f0c83da3fb64ea6d327f9173fa008d10e28bc9349eac3463457723b1c66.tar.gz +Zig package hash: N-V-__8AAJrvXQCqAT8Mg9o_tk6m0yf5Fz-gCNEOKLyTSerD + +--- LICENSE --- +COPYRIGHT NOTICE, DISCLAIMER, and LICENSE +========================================= + +PNG Reference Library License version 2 +--------------------------------------- + + * Copyright (c) 1995-2024 The PNG Reference Library Authors. + * Copyright (c) 2018-2024 Cosmin Truta. + * Copyright (c) 2000-2002, 2004, 2006-2018 Glenn Randers-Pehrson. + * Copyright (c) 1996-1997 Andreas Dilger. + * Copyright (c) 1995-1996 Guy Eric Schalnat, Group 42, Inc. + +The software is supplied "as is", without warranty of any kind, +express or implied, including, without limitation, the warranties +of merchantability, fitness for a particular purpose, title, and +non-infringement. In no event shall the Copyright owners, or +anyone distributing the software, be liable for any damages or +other liability, whether in contract, tort or otherwise, arising +from, out of, or in connection with the software, or the use or +other dealings in the software, even if advised of the possibility +of such damage. + +Permission is hereby granted to use, copy, modify, and distribute +this software, or portions hereof, for any purpose, without fee, +subject to the following restrictions: + + 1. The origin of this software must not be misrepresented; you + must not claim that you wrote the original software. If you + use this software in a product, an acknowledgment in the product + documentation would be appreciated, but is not required. + + 2. Altered source versions must be plainly marked as such, and must + not be misrepresented as being the original software. + + 3. This Copyright notice may not be removed or altered from any + source or altered source distribution. + + +PNG Reference Library License version 1 (for libpng 0.5 through 1.6.35) +----------------------------------------------------------------------- + +libpng versions 1.0.7, July 1, 2000, through 1.6.35, July 15, 2018 are +Copyright (c) 2000-2002, 2004, 2006-2018 Glenn Randers-Pehrson, are +derived from libpng-1.0.6, and are distributed according to the same +disclaimer and license as libpng-1.0.6 with the following individuals +added to the list of Contributing Authors: + + Simon-Pierre Cadieux + Eric S. Raymond + Mans Rullgard + Cosmin Truta + Gilles Vollant + James Yu + Mandar Sahastrabuddhe + Google Inc. + Vadim Barkov + +and with the following additions to the disclaimer: + + There is no warranty against interference with your enjoyment of + the library or against infringement. There is no warranty that our + efforts or the library will fulfill any of your particular purposes + or needs. This library is provided with all faults, and the entire + risk of satisfactory quality, performance, accuracy, and effort is + with the user. + +Some files in the "contrib" directory and some configure-generated +files that are distributed with libpng have other copyright owners, and +are released under other open source licenses. + +libpng versions 0.97, January 1998, through 1.0.6, March 20, 2000, are +Copyright (c) 1998-2000 Glenn Randers-Pehrson, are derived from +libpng-0.96, and are distributed according to the same disclaimer and +license as libpng-0.96, with the following individuals added to the +list of Contributing Authors: + + Tom Lane + Glenn Randers-Pehrson + Willem van Schaik + +libpng versions 0.89, June 1996, through 0.96, May 1997, are +Copyright (c) 1996-1997 Andreas Dilger, are derived from libpng-0.88, +and are distributed according to the same disclaimer and license as +libpng-0.88, with the following individuals added to the list of +Contributing Authors: + + John Bowler + Kevin Bracey + Sam Bushell + Magnus Holmgren + Greg Roelofs + Tom Tanner + +Some files in the "scripts" directory have other copyright owners, +but are released under this license. + +libpng versions 0.5, May 1995, through 0.88, January 1996, are +Copyright (c) 1995-1996 Guy Eric Schalnat, Group 42, Inc. + +For the purposes of this copyright and license, "Contributing Authors" +is defined as the following set of individuals: + + Andreas Dilger + Dave Martindale + Guy Eric Schalnat + Paul Schmidt + Tim Wegner + +The PNG Reference Library is supplied "AS IS". The Contributing +Authors and Group 42, Inc. disclaim all warranties, expressed or +implied, including, without limitation, the warranties of +merchantability and of fitness for any purpose. The Contributing +Authors and Group 42, Inc. assume no liability for direct, indirect, +incidental, special, exemplary, or consequential damages, which may +result from the use of the PNG Reference Library, even if advised of +the possibility of such damage. + +Permission is hereby granted to use, copy, modify, and distribute this +source code, or portions hereof, for any purpose, without fee, subject +to the following restrictions: + + 1. The origin of this source code must not be misrepresented. + + 2. Altered versions must be plainly marked as such and must not + be misrepresented as being the original source. + + 3. This Copyright notice may not be removed or altered from any + source or altered source distribution. + +The Contributing Authors and Group 42, Inc. specifically permit, +without fee, and encourage the use of this source code as a component +to supporting the PNG file format in commercial products. If you use +this source code in a product, acknowledgment is not required but would +be appreciated. + +--- ci/LICENSE_MIT.txt --- +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- contrib/gregbook/COPYING --- + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc. + 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Library General Public License instead.) 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 +this service 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 make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. 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. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute 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 and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +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 +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the 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 a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, 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. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE 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. + + 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 +convey 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 2 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, write to the Free Software + Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision 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, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Ty Coon, President of Vice + +This 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 Library General +Public License instead of this License. + +--- contrib/gregbook/LICENSE --- + --------------------------------------------------------------------------- + + Copyright (c) 1998-2008 Greg Roelofs. All rights reserved. + + This software is provided "as is," without warranty of any kind, + express or implied. In no event shall the author or contributors + be held liable for any damages arising in any way from the use of + this software. + + The contents of this file are DUAL-LICENSED. You may modify and/or + redistribute this software according to the terms of one of the + following two licenses (at your option): + + + LICENSE 1 ("BSD-like with advertising clause"): + + Permission is granted to anyone to use this software for any purpose, + including commercial applications, and to alter it and redistribute + it freely, subject to the following restrictions: + + 1. Redistributions of source code must retain the above copyright + notice, disclaimer, and this list of conditions. + 2. Redistributions in binary form must reproduce the above copyright + notice, disclaimer, and this list of conditions in the documenta- + tion and/or other materials provided with the distribution. + 3. All advertising materials mentioning features or use of this + software must display the following acknowledgment: + + This product includes software developed by Greg Roelofs + and contributors for the book, "PNG: The Definitive Guide," + published by O'Reilly and Associates. + + + LICENSE 2 (GNU GPL v2 or later): + + 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 2 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, write to the Free Software Foundation, + Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + + --------------------------------------------------------------------------- + +--- contrib/pngexif/LICENSE_MIT.txt --- +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- contrib/pngminus/LICENSE.txt --- + +pnm2png / png2pnm --- conversion from PBM/PGM/PPM-file to PNG-file + +copyright (C) 1999-2019 by Willem van Schaik + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +The software is provided "as is", without warranty of any kind, express or +implied, including but not limited to the warranties of merchantability, +fitness for a particular purpose and noninfringement. In no event shall the +authors or copyright holders be liable for any claim, damages or other +liability, whether in an action of contract, tort or otherwise, arising from, +out of or in connection with the software or the use or other dealings in the +software. + + +======================================== + +Source: https://deps.files.ghostty.org/freetype-1220b81f6ecfb3fd222f76cf9106fecfa6554ab07ec7fdc4124b9bb063ae2adf969d.tar.gz +Zig package hash: N-V-__8AAKLKpwC4H27Ps_0iL3bPkQb-z6ZVSrB-x_3EEkub + +--- LICENSE.TXT --- +FREETYPE LICENSES +----------------- + +The FreeType 2 font engine is copyrighted work and cannot be used +legally without a software license. In order to make this project +usable to a vast majority of developers, we distribute it under two +mutually exclusive open-source licenses. + +This means that *you* must choose *one* of the two licenses described +below, then obey all its terms and conditions when using FreeType 2 in +any of your projects or products. + + - The FreeType License, found in the file `docs/FTL.TXT`, which is + similar to the original BSD license *with* an advertising clause + that forces you to explicitly cite the FreeType project in your + product's documentation. All details are in the license file. + This license is suited to products which don't use the GNU General + Public License. + + Note that this license is compatible to the GNU General Public + License version 3, but not version 2. + + - The GNU General Public License version 2, found in + `docs/GPLv2.TXT` (any later version can be used also), for + programs which already use the GPL. Note that the FTL is + incompatible with GPLv2 due to its advertisement clause. + +The contributed BDF and PCF drivers come with a license similar to +that of the X Window System. It is compatible to the above two +licenses (see files `src/bdf/README` and `src/pcf/README`). The same +holds for the source code files `src/base/fthash.c` and +`include/freetype/internal/fthash.h`; they were part of the BDF driver +in earlier FreeType versions. + +The gzip module uses the zlib license (see `src/gzip/zlib.h`) which +too is compatible to the above two licenses. + +The files `src/autofit/ft-hb.c` and `src/autofit/ft-hb.h` contain code +taken almost verbatim from the HarfBuzz file `hb-ft.cc`, which uses +the 'Old MIT' license, compatible to the above two licenses. + +The MD5 checksum support (only used for debugging in development +builds) is in the public domain. + + +--- end of LICENSE.TXT --- + +--- docs/FTL.TXT --- + The FreeType Project LICENSE + ---------------------------- + + 2006-Jan-27 + + Copyright 1996-2002, 2006 by + David Turner, Robert Wilhelm, and Werner Lemberg + + + +Introduction +============ + + The FreeType Project is distributed in several archive packages; + some of them may contain, in addition to the FreeType font engine, + various tools and contributions which rely on, or relate to, the + FreeType Project. + + This license applies to all files found in such packages, and + which do not fall under their own explicit license. The license + affects thus the FreeType font engine, the test programs, + documentation and makefiles, at the very least. + + This license was inspired by the BSD, Artistic, and IJG + (Independent JPEG Group) licenses, which all encourage inclusion + and use of free software in commercial and freeware products + alike. As a consequence, its main points are that: + + o We don't promise that this software works. However, we will be + interested in any kind of bug reports. (`as is' distribution) + + o You can use this software for whatever you want, in parts or + full form, without having to pay us. (`royalty-free' usage) + + o You may not pretend that you wrote this software. If you use + it, or only parts of it, in a program, you must acknowledge + somewhere in your documentation that you have used the + FreeType code. (`credits') + + We specifically permit and encourage the inclusion of this + software, with or without modifications, in commercial products. + We disclaim all warranties covering The FreeType Project and + assume no liability related to The FreeType Project. + + + Finally, many people asked us for a preferred form for a + credit/disclaimer to use in compliance with this license. We thus + encourage you to use the following text: + + """ + Portions of this software are copyright © The FreeType + Project (www.freetype.org). All rights reserved. + """ + + Please replace with the value from the FreeType version you + actually use. + + +Legal Terms +=========== + +0. Definitions +-------------- + + Throughout this license, the terms `package', `FreeType Project', + and `FreeType archive' refer to the set of files originally + distributed by the authors (David Turner, Robert Wilhelm, and + Werner Lemberg) as the `FreeType Project', be they named as alpha, + beta or final release. + + `You' refers to the licensee, or person using the project, where + `using' is a generic term including compiling the project's source + code as well as linking it to form a `program' or `executable'. + This program is referred to as `a program using the FreeType + engine'. + + This license applies to all files distributed in the original + FreeType Project, including all source code, binaries and + documentation, unless otherwise stated in the file in its + original, unmodified form as distributed in the original archive. + If you are unsure whether or not a particular file is covered by + this license, you must contact us to verify this. + + The FreeType Project is copyright (C) 1996-2000 by David Turner, + Robert Wilhelm, and Werner Lemberg. All rights reserved except as + specified below. + +1. No Warranty +-------------- + + THE FREETYPE PROJECT IS PROVIDED `AS IS' WITHOUT WARRANTY OF ANY + KIND, EITHER EXPRESS OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, + WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + PURPOSE. IN NO EVENT WILL ANY OF THE AUTHORS OR COPYRIGHT HOLDERS + BE LIABLE FOR ANY DAMAGES CAUSED BY THE USE OR THE INABILITY TO + USE, OF THE FREETYPE PROJECT. + +2. Redistribution +----------------- + + This license grants a worldwide, royalty-free, perpetual and + irrevocable right and license to use, execute, perform, compile, + display, copy, create derivative works of, distribute and + sublicense the FreeType Project (in both source and object code + forms) and derivative works thereof for any purpose; and to + authorize others to exercise some or all of the rights granted + herein, subject to the following conditions: + + o Redistribution of source code must retain this license file + (`FTL.TXT') unaltered; any additions, deletions or changes to + the original files must be clearly indicated in accompanying + documentation. The copyright notices of the unaltered, + original files must be preserved in all copies of source + files. + + o Redistribution in binary form must provide a disclaimer that + states that the software is based in part of the work of the + FreeType Team, in the distribution documentation. We also + encourage you to put an URL to the FreeType web page in your + documentation, though this isn't mandatory. + + These conditions apply to any software derived from or based on + the FreeType Project, not just the unmodified files. If you use + our work, you must acknowledge us. However, no fee need be paid + to us. + +3. Advertising +-------------- + + Neither the FreeType authors and contributors nor you shall use + the name of the other for commercial, advertising, or promotional + purposes without specific prior written permission. + + We suggest, but do not require, that you use one or more of the + following phrases to refer to this software in your documentation + or advertising materials: `FreeType Project', `FreeType Engine', + `FreeType library', or `FreeType Distribution'. + + As you have not signed this license, you are not required to + accept it. However, as the FreeType Project is copyrighted + material, only this license, or another one contracted with the + authors, grants you the right to use, distribute, and modify it. + Therefore, by using, distributing, or modifying the FreeType + Project, you indicate that you understand and accept all the terms + of this license. + +4. Contacts +----------- + + There are two mailing lists related to FreeType: + + o freetype@nongnu.org + + Discusses general use and applications of FreeType, as well as + future and wanted additions to the library and distribution. + If you are looking for support, start in this list if you + haven't found anything to help you in the documentation. + + o freetype-devel@nongnu.org + + Discusses bugs, as well as engine internals, design issues, + specific licenses, porting, etc. + + Our home page can be found at + + https://www.freetype.org + + +--- end of FTL.TXT --- + + +======================================== + +Source: https://deps.files.ghostty.org/breakpad-b99f444ba5f6b98cac261cbb391d8766b34a5918.tar.gz +Zig package hash: N-V-__8AALw2uwF_03u4JRkZwRLc3Y9hakkYV7NKRR9-RIZJ + +--- LICENSE --- +Copyright 2006 Google LLC + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google LLC nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +-------------------------------------------------------------------- +License for src/common/convert_UTF.cc, src/common/convert_UTF.h + +Copyright © 1991-2015 Unicode, Inc. All rights reserved. +Distributed under the Terms of Use in +http://www.unicode.org/copyright.html. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Unicode data files and any associated documentation +(the "Data Files") or Unicode software and any associated documentation +(the "Software") to deal in the Data Files or Software +without restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, and/or sell copies of +the Data Files or Software, and to permit persons to whom the Data Files +or Software are furnished to do so, provided that +(a) this copyright and permission notice appear with all copies +of the Data Files or Software, +(b) this copyright and permission notice appear in associated +documentation, and +(c) there is clear notice in each modified Data File or in the Software +as well as in the documentation associated with the Data File(s) or +Software that the data or software has been modified. + +THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT OF THIRD PARTY RIGHTS. +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS +NOTICE BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL +DAMAGES, OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, +DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THE DATA FILES OR SOFTWARE. + +Except as contained in this notice, the name of a copyright holder +shall not be used in advertising or otherwise to promote the sale, +use or other dealings in these Data Files or Software without prior +written authorization of the copyright holder. + +-------------------------------------------------------------------- +License for src/common/linux/breakpad_getcontext.S + +libunwind - a platform-independent unwind library + Copyright (C) 2008 Google, Inc + Contributed by Paul Pluzhnikov + Copyright (C) 2010 Konstantin Belousov + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ + +-------------------------------------------------------------------- +License for +src/client/mac/handler/breakpad_nlist_64.cc +src/third_party/mac_headers/architecture/byte_order.h +src/third_party/mac_headers/mach-o/arch.h +src/third_party/mac_headers/mach-o/fat.h: +src/third_party/mac_headers/mach-o/loader.h +src/third_party/mac_headers/mach-o/nlist.h + +APPLE PUBLIC SOURCE LICENSE + +Version 2.0 - August 6, 2003 + +Please read this License carefully before downloading this software. By +downloading or using this software, you are agreeing to be bound by the terms +of this License. If you do not or cannot agree to the terms of this License, +please do not download or use the software. + +Apple Note: In January 2007, Apple changed its corporate name from "Apple +Computer, Inc." to "Apple Inc." This change has been reflected below and +copyright years updated, but no other changes have been made to the APSL 2.0. + +1. General; Definitions. This License applies to any program or other work which Apple Inc. ("Apple") makes publicly available and which contains a notice placed by Apple identifying such program or work as "Original Code" and stating that it is subject to the terms of this Apple Public Source License version 2.0 ("License"). As used in this License: + +1.1 "Applicable Patent Rights" mean: (a) in the case where Apple is the +grantor of rights, (i) claims of patents that are now or hereafter acquired, +owned by or assigned to Apple and (ii) that cover subject matter contained in +the Original Code, but only to the extent necessary to use, reproduce and/or +distribute the Original Code without infringement; and (b) in the case where +You are the grantor of rights, (i) claims of patents that are now or hereafter +acquired, owned by or assigned to You and (ii) that cover subject matter in +Your Modifications, taken alone or in combination with Original Code. + +1.2 "Contributor" means any person or entity that creates or contributes to +the creation of Modifications. + +1.3 "Covered Code" means the Original Code, Modifications, the combination of +Original Code and any Modifications, and/or any respective portions thereof. + +1.4 "Externally Deploy" means: (a) to sublicense, distribute or otherwise make +Covered Code available, directly or indirectly, to anyone other than You; +and/or (b) to use Covered Code, alone or as part of a Larger Work, in any way +to provide a service, including but not limited to delivery of content, +through electronic communication with a client other than You. + +1.5 "Larger Work" means a work which combines Covered Code or portions thereof +with code not governed by the terms of this License. + +1.6 "Modifications" mean any addition to, deletion from, and/or change to, the +substance and/or structure of the Original Code, any previous Modifications, +the combination of Original Code and any previous Modifications, and/or any +respective portions thereof. When code is released as a series of files, a +Modification is: (a) any addition to or deletion from the contents of a file +containing Covered Code; and/or (b) any new file or other representation of +computer program statements that contains any part of Covered Code. + +1.7 "Original Code" means (a) the Source Code of a program or other work as +originally made available by Apple under this License, including the Source +Code of any updates or upgrades to such programs or works made available by +Apple under this License, and that has been expressly identified by Apple as +such in the header file(s) of such work; and (b) the object code compiled from +such Source Code and originally made available by Apple under this License + +1.8 "Source Code" means the human readable form of a program or other work +that is suitable for making modifications to it, including all modules it +contains, plus any associated interface definition files, scripts used to +control compilation and installation of an executable (object code). + +1.9 "You" or "Your" means an individual or a legal entity exercising rights +under this License. For legal entities, "You" or "Your" includes any entity +which controls, is controlled by, or is under common control with, You, where +"control" means (a) the power, direct or indirect, to cause the direction or +management of such entity, whether by contract or otherwise, or (b) ownership +of fifty percent (50%) or more of the outstanding shares or beneficial +ownership of such entity. + +2. Permitted Uses; Conditions & Restrictions. Subject to the terms and conditions of this License, Apple hereby grants You, effective on the date You accept this License and download the Original Code, a world-wide, royalty-free, non-exclusive license, to the extent of Apple's Applicable Patent Rights and copyrights covering the Original Code, to do the following: + +2.1 Unmodified Code. You may use, reproduce, display, perform, internally +distribute within Your organization, and Externally Deploy verbatim, +unmodified copies of the Original Code, for commercial or non-commercial +purposes, provided that in each instance: + +(a) You must retain and reproduce in all copies of Original Code the copyright +and other proprietary notices and disclaimers of Apple as they appear in the +Original Code, and keep intact all notices in the Original Code that refer to +this License; and + +(b) You must include a copy of this License with every copy of Source Code of +Covered Code and documentation You distribute or Externally Deploy, and You +may not offer or impose any terms on such Source Code that alter or restrict +this License or the recipients' rights hereunder, except as permitted +under Section 6. + +2.2 Modified Code. You may modify Covered Code and use, reproduce, display, +perform, internally distribute within Your organization, and Externally Deploy +Your Modifications and Covered Code, for commercial or non-commercial +purposes, provided that in each instance You also meet all of these +conditions: + +(a) You must satisfy all the conditions of Section 2.1 with respect to the +Source Code of the Covered Code; + +(b) You must duplicate, to the extent it does not already exist, the notice in +Exhibit A in each file of the Source Code of all Your Modifications, and cause +the modified files to carry prominent notices stating that You changed the +files and the date of any change; and + +(c) If You Externally Deploy Your Modifications, You must make Source Code of +all Your Externally Deployed Modifications either available to those to whom +You have Externally Deployed Your Modifications, or publicly available. Source +Code of Your Externally Deployed Modifications must be released under the +terms set forth in this License, including the license grants set forth in +Section 3 below, for as long as you Externally Deploy the Covered Code or +twelve (12) months from the date of initial External Deployment, whichever is +longer. You should preferably distribute the Source Code of Your Externally +Deployed Modifications electronically (e.g. download from a web site). + +2.3 Distribution of Executable Versions. In addition, if You Externally Deploy +Covered Code (Original Code and/or Modifications) in object code, executable +form only, You must include a prominent notice, in the code itself as well as +in related documentation, stating that Source Code of the Covered Code is +available under the terms of this License with information on how and where to +obtain such Source Code. + +2.4 Third Party Rights. You expressly acknowledge and agree that although +Apple and each Contributor grants the licenses to their respective portions of +the Covered Code set forth herein, no assurances are provided by Apple or any +Contributor that the Covered Code does not infringe the patent or other +intellectual property rights of any other entity. Apple and each Contributor +disclaim any liability to You for claims brought by any other entity based on +infringement of intellectual property rights or otherwise. As a condition to +exercising the rights and licenses granted hereunder, You hereby assume sole +responsibility to secure any other intellectual property rights needed, if +any. For example, if a third party patent license is required to allow You to +distribute the Covered Code, it is Your responsibility to acquire that license +before distributing the Covered Code. + +3. Your Grants. In consideration of, and as a condition to, the licenses granted to You under this License, You hereby grant to any person or entity receiving or distributing Covered Code under this License a non-exclusive, royalty-free, perpetual, irrevocable license, under Your Applicable Patent Rights and other intellectual property rights (other than patent) owned or controlled by You, to use, reproduce, display, perform, modify, sublicense, distribute and Externally Deploy Your Modifications of the same scope and extent as Apple's licenses under Sections 2.1 and 2.2 above. + +4. Larger Works. You may create a Larger Work by combining Covered Code with other code not governed by the terms of this License and distribute the Larger Work as a single product. In each such instance, You must make sure the requirements of this License are fulfilled for the Covered Code or any portion thereof. + +5. Limitations on Patent License. Except as expressly stated in Section 2, no other patent rights, express or implied, are granted by Apple herein. Modifications and/or Larger Works may require additional patent licenses from Apple which Apple may grant in its sole discretion. + +6. Additional Terms. You may choose to offer, and to charge a fee for, warranty, support, indemnity or liability obligations and/or other rights consistent with the scope of the license granted herein ("Additional Terms") to one or more recipients of Covered Code. However, You may do so only on Your own behalf and as Your sole responsibility, and not on behalf of Apple or any Contributor. You must obtain the recipient's agreement that any such Additional Terms are offered by You alone, and You hereby agree to indemnify, defend and hold Apple and every Contributor harmless for any liability incurred by or claims asserted against Apple or such Contributor by reason of any such Additional Terms. + +7. Versions of the License. Apple may publish revised and/or new versions of this License from time to time. Each version will be given a distinguishing version number. Once Original Code has been published under a particular version of this License, You may continue to use it under the terms of that version. You may also choose to use such Original Code under the terms of any subsequent version of this License published by Apple. No one other than Apple has the right to modify the terms applicable to Covered Code created under this License. + +8. NO WARRANTY OR SUPPORT. The Covered Code may contain in whole or in part pre-release, untested, or not fully tested works. The Covered Code may contain errors that could cause failures or loss of data, and may be incomplete or contain inaccuracies. You expressly acknowledge and agree that use of the Covered Code, or any portion thereof, is at Your sole and entire risk. THE COVERED CODE IS PROVIDED "AS IS" AND WITHOUT WARRANTY, UPGRADES OR SUPPORT OF ANY KIND AND APPLE AND APPLE'S LICENSOR(S) (COLLECTIVELY REFERRED TO AS "APPLE" FOR THE PURPOSES OF SECTIONS 8 AND 9) AND ALL CONTRIBUTORS EXPRESSLY DISCLAIM ALL WARRANTIES AND/OR CONDITIONS, EXPRESS OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES AND/OR CONDITIONS OF MERCHANTABILITY, OF SATISFACTORY QUALITY, OF FITNESS FOR A PARTICULAR PURPOSE, OF ACCURACY, OF QUIET ENJOYMENT, AND NONINFRINGEMENT OF THIRD PARTY RIGHTS. APPLE AND EACH CONTRIBUTOR DOES NOT WARRANT AGAINST INTERFERENCE WITH YOUR ENJOYMENT OF THE COVERED CODE, THAT THE FUNCTIONS CONTAINED IN THE COVERED CODE WILL MEET YOUR REQUIREMENTS, THAT THE OPERATION OF THE COVERED CODE WILL BE UNINTERRUPTED OR ERROR-FREE, OR THAT DEFECTS IN THE COVERED CODE WILL BE CORRECTED. NO ORAL OR WRITTEN INFORMATION OR ADVICE GIVEN BY APPLE, AN APPLE AUTHORIZED REPRESENTATIVE OR ANY CONTRIBUTOR SHALL CREATE A WARRANTY. You acknowledge that the Covered Code is not intended for use in the operation of nuclear facilities, aircraft navigation, communication systems, or air traffic control machines in which case the failure of the Covered Code could lead to death, personal injury, or severe physical or environmental damage. + +9. LIMITATION OF LIABILITY. TO THE EXTENT NOT PROHIBITED BY LAW, IN NO EVENT SHALL APPLE OR ANY CONTRIBUTOR BE LIABLE FOR ANY INCIDENTAL, SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES ARISING OUT OF OR RELATING TO THIS LICENSE OR YOUR USE OR INABILITY TO USE THE COVERED CODE, OR ANY PORTION THEREOF, WHETHER UNDER A THEORY OF CONTRACT, WARRANTY, TORT (INCLUDING NEGLIGENCE), PRODUCTS LIABILITY OR OTHERWISE, EVEN IF APPLE OR SUCH CONTRIBUTOR HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES AND NOTWITHSTANDING THE FAILURE OF ESSENTIAL PURPOSE OF ANY REMEDY. SOME JURISDICTIONS DO NOT ALLOW THE LIMITATION OF LIABILITY OF INCIDENTAL OR CONSEQUENTIAL DAMAGES, SO THIS LIMITATION MAY NOT APPLY TO YOU. In no event shall Apple's total liability to You for all damages (other than as may be required by applicable law) under this License exceed the amount of fifty dollars ($50.00). + +10. Trademarks. This License does not grant any rights to use the trademarks or trade names "Apple", "Mac", "Mac OS", "QuickTime", "QuickTime Streaming Server" or any other trademarks, service marks, logos or trade names belonging to Apple (collectively "Apple Marks") or to any trademark, service mark, logo or trade name belonging to any Contributor. You agree not to use any Apple Marks in or as part of the name of products derived from the Original Code or to endorse or promote products derived from the Original Code other than as expressly permitted by and in strict compliance at all times with Apple's third party trademark usage guidelines which are posted at http://www.apple.com/legal/guidelinesfor3rdparties.html. + +11. Ownership. Subject to the licenses granted under this License, each Contributor retains all rights, title and interest in and to any Modifications made by such Contributor. Apple retains all rights, title and interest in and to the Original Code and any Modifications made by or on behalf of Apple ("Apple Modifications"), and such Apple Modifications will not be automatically subject to this License. Apple may, at its sole discretion, choose to license such Apple Modifications under this License, or on different terms from those contained in this License or may choose not to license them at all. + +12. Termination. + +12.1 Termination. This License and the rights granted hereunder will +terminate: + +(a) automatically without notice from Apple if You fail to comply with any +term(s) of this License and fail to cure such breach within 30 days of +becoming aware of such breach; + +(b) immediately in the event of the circumstances described in Section +13.5(b); or + +(c) automatically without notice from Apple if You, at any time during the +term of this License, commence an action for patent infringement against +Apple; provided that Apple did not first commence an action for patent +infringement against You in that instance. + +12.2 Effect of Termination. Upon termination, You agree to immediately stop +any further use, reproduction, modification, sublicensing and distribution of +the Covered Code. All sublicenses to the Covered Code which have been properly +granted prior to termination shall survive any termination of this License. +Provisions which, by their nature, should remain in effect beyond the +termination of this License shall survive, including but not limited to +Sections 3, 5, 8, 9, 10, 11, 12.2 and 13. No party will be liable to any other +for compensation, indemnity or damages of any sort solely as a result of +terminating this License in accordance with its terms, and termination of this +License will be without prejudice to any other right or remedy of any party. + +13. Miscellaneous. + +13.1 Government End Users. The Covered Code is a "commercial item" as defined +in FAR 2.101. Government software and technical data rights in the Covered +Code include only those rights customarily provided to the public as defined +in this License. This customary commercial license in technical data and +software is provided in accordance with FAR 12.211 (Technical Data) and 12.212 +(Computer Software) and, for Department of Defense purchases, DFAR +252.227-7015 (Technical Data -- Commercial Items) and 227.7202-3 (Rights in +Commercial Computer Software or Computer Software Documentation). Accordingly, +all U.S. Government End Users acquire Covered Code with only those rights set +forth herein. + +13.2 Relationship of Parties. This License will not be construed as creating +an agency, partnership, joint venture or any other form of legal association +between or among You, Apple or any Contributor, and You will not represent to +the contrary, whether expressly, by implication, appearance or otherwise. + +13.3 Independent Development. Nothing in this License will impair Apple's +right to acquire, license, develop, have others develop for it, market and/or +distribute technology or products that perform the same or similar functions +as, or otherwise compete with, Modifications, Larger Works, technology or +products that You may develop, produce, market or distribute. + +13.4 Waiver; Construction. Failure by Apple or any Contributor to enforce any +provision of this License will not be deemed a waiver of future enforcement of +that or any other provision. Any law or regulation which provides that the +language of a contract shall be construed against the drafter will not apply +to this License. + +13.5 Severability. (a) If for any reason a court of competent jurisdiction +finds any provision of this License, or portion thereof, to be unenforceable, +that provision of the License will be enforced to the maximum extent +permissible so as to effect the economic benefits and intent of the parties, +and the remainder of this License will continue in full force and effect. (b) +Notwithstanding the foregoing, if applicable law prohibits or restricts You +from fully and/or specifically complying with Sections 2 and/or 3 or prevents +the enforceability of either of those Sections, this License will immediately +terminate and You must immediately discontinue any use of the Covered Code and +destroy all copies of it that are in your possession or control. + +13.6 Dispute Resolution. Any litigation or other dispute resolution between +You and Apple relating to this License shall take place in the Northern +District of California, and You and Apple hereby consent to the personal +jurisdiction of, and venue in, the state and federal courts within that +District with respect to this License. The application of the United Nations +Convention on Contracts for the International Sale of Goods is expressly +excluded. + +13.7 Entire Agreement; Governing Law. This License constitutes the entire +agreement between the parties with respect to the subject matter hereof. This +License shall be governed by the laws of the United States and the State of +California, except that body of California law concerning conflicts of law. + +Where You are located in the province of Quebec, Canada, the following clause +applies: The parties hereby confirm that they have requested that this License +and all related documents be drafted in English. Les parties ont exigé que le +présent contrat et tous les documents connexes soient rédigés en anglais. + +EXHIBIT A. + +"Portions Copyright (c) 1999-2007 Apple Inc. All Rights Reserved. + +This file contains Original Code and/or Modifications of Original Code as +defined in and that are subject to the Apple Public Source License Version 2.0 +(the 'License'). You may not use this file except in compliance with +the License. Please obtain a copy of the License at +http://www.opensource.apple.com/apsl/ and read it before using this file. + +The Original Code and all software distributed under the License are +distributed on an 'AS IS' basis, WITHOUT WARRANTY OF ANY KIND, +EITHER EXPRESS OR IMPLIED, AND APPLE HEREBY DISCLAIMS ALL SUCH WARRANTIES, +INCLUDING WITHOUT LIMITATION, ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE, QUIET ENJOYMENT OR NON-INFRINGEMENT. Please see the +License for the specific language governing rights and limitations under the +License." + +-------------------------------------------------------------------- +License for +src/client/mac/handler/breakpad_nlist_64.cc +src/third_party/mac_headers/mach-o/nlist.h + +Copyright (c) 1989, 1993 +The Regents of the University of California. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +3. All advertising materials mentioning features or use of this software + must display the following acknowledgement: + This product includes software developed by the University of + California, Berkeley and its contributors. +4. Neither the name of the University nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. + +-------------------------------------------------------------------- +License for +src/third_party/curl/curl.h +src/third_party/curl/curlbuild.h +src/third_party/curl/curlrules.h +src/third_party/curl/curlver.h +src/third_party/curl/easy.h +src/third_party/curl/mprintf.h +src/third_party/curl/multi.h +src/third_party/curl/stdcheaders.h +src/third_party/curl/typecheck-gcc.h + +COPYRIGHT AND PERMISSION NOTICE + +Copyright (c) 1996 - 2011, Daniel Stenberg, . + +All rights reserved. + +Permission to use, copy, modify, and distribute this software for any purpose +with or without fee is hereby granted, provided that the above copyright +notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF THIRD PARTY RIGHTS. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE +OR OTHER DEALINGS IN THE SOFTWARE. + +Except as contained in this notice, the name of a copyright holder shall not +be used in advertising or otherwise to promote the sale, use or other dealings +in this Software without prior written authorization of the copyright holder. + +-------------------------------------------------------------------- +License for +src/common/mac/GTMDefines.h +src/common/mac/GTMLogger.h +src/common/mac/GTMLogger.m +src/common/mac/testing/GTMSenTestCase.h +src/common/mac/testing/GTMSenTestCase.m + +Apache License + +Version 2.0, January 2004 + +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and +distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the +copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other +entities that control, are controlled by, or are under common control with +that entity. For the purposes of this definition, "control" means (i) the +power, direct or indirect, to cause the direction or management of such +entity, whether by contract or otherwise, or (ii) ownership of fifty percent +(50%) or more of the outstanding shares, or (iii) beneficial ownership of such +entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising +permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, +including but not limited to software source code, documentation source, and +configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or +translation of a Source form, including but not limited to compiled object +code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, +made available under the License, as indicated by a copyright notice that is +included in or attached to the work (an example is provided in the Appendix +below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that +is based on (or derived from) the Work and for which the editorial revisions, +annotations, elaborations, or other modifications represent, as a whole, an +original work of authorship. For the purposes of this License, Derivative +Works shall not include works that remain separable from, or merely link (or +bind by name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original +version of the Work and any modifications or additions to that Work or +Derivative Works thereof, that is intentionally submitted to Licensor for +inclusion in the Work by the copyright owner or by an individual or Legal +Entity authorized to submit on behalf of the copyright owner. For the purposes +of this definition, "submitted" means any form of electronic, verbal, or +written communication sent to the Licensor or its representatives, including +but not limited to communication on electronic mailing lists, source code +control systems, and issue tracking systems that are managed by, or on behalf +of, the Licensor for the purpose of discussing and improving the Work, but +excluding communication that is conspicuously marked or otherwise designated +in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf +of whom a Contribution has been received by Licensor and subsequently +incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + +(a) You must give any other recipients of the Work or Derivative Works a copy +of this License; and + +(b) You must cause any modified files to carry prominent notices stating that +You changed the files; and + +(c) You must retain, in the Source form of any Derivative Works that You +distribute, all copyright, patent, trademark, and attribution notices from the +Source form of the Work, excluding those notices that do not pertain to any +part of the Derivative Works; and + +(d) If the Work includes a "NOTICE" text file as part of its distribution, +then any Derivative Works that You distribute must include a readable copy of +the attribution notices contained within such NOTICE file, excluding those +notices that do not pertain to any part of the Derivative Works, in at least +one of the following places: within a NOTICE text file distributed as part of +the Derivative Works; within the Source form or documentation, if provided +along with the Derivative Works; or, within a display generated by the +Derivative Works, if and wherever such third-party notices normally appear. +The contents of the NOTICE file are for informational purposes only and do not +modify the License. You may add Your own attribution notices within Derivative +Works that You distribute, alongside or as an addendum to the NOTICE text from +the Work, provided that such additional attribution notices cannot be +construed as modifying the License. + +You may add Your own copyright statement to Your modifications and may provide +additional or different license terms and conditions for use, reproduction, or +distribution of Your modifications, or for any such Derivative Works as a +whole, provided Your use, reproduction, and distribution of the Work otherwise +complies with the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +-------------------------------------------------------------------- +License for INSTALL + +Copying and distribution of this file, with or without modification, +are permitted in any medium without royalty provided the copyright +notice and this notice are preserved. + +-------------------------------------------------------------------- +License for src/common/mac/testing/GTMSenTestCase.h + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +-------------------------------------------------------------------- +License for src/third_party//libdisasm + + The "Clarified Artistic License" + + Preamble + +The intent of this document is to state the conditions under which a +Package may be copied, such that the Copyright Holder maintains some +semblance of artistic control over the development of the package, +while giving the users of the package the right to use and distribute +the Package in a more-or-less customary fashion, plus the right to make +reasonable modifications. + +Definitions: + + "Package" refers to the collection of files distributed by the + Copyright Holder, and derivatives of that collection of files + created through textual modification. + + "Standard Version" refers to such a Package if it has not been + modified, or has been modified in accordance with the wishes + of the Copyright Holder as specified below. + + "Copyright Holder" is whoever is named in the copyright or + copyrights for the package. + + "You" is you, if you're thinking about copying or distributing + this Package. + + "Distribution fee" is a fee you charge for providing a copy of this + Package to another party. + + "Freely Available" means that no fee is charged for the right to use + the item, though there may be fees involved in handling the item. + +1. You may make and give away verbatim copies of the source form of the +Standard Version of this Package without restriction, provided that you +duplicate all of the original copyright notices and associated disclaimers. + +2. You may apply bug fixes, portability fixes and other modifications +derived from the Public Domain, or those made Freely Available, or from +the Copyright Holder. A Package modified in such a way shall still be +considered the Standard Version. + +3. You may otherwise modify your copy of this Package in any way, provided +that you insert a prominent notice in each changed file stating how and +when you changed that file, and provided that you do at least ONE of the +following: + + a) place your modifications in the Public Domain or otherwise make them + Freely Available, such as by posting said modifications to Usenet or + an equivalent medium, or placing the modifications on a major archive + site allowing unrestricted access to them, or by allowing the Copyright + Holder to include your modifications in the Standard Version of the + Package. + + b) use the modified Package only within your corporation or organization. + + c) rename any non-standard executables so the names do not conflict + with standard executables, which must also be provided, and provide + a separate manual page for each non-standard executable that clearly + documents how it differs from the Standard Version. + + d) make other distribution arrangements with the Copyright Holder. + + e) permit and encourge anyone who receives a copy of the modified Package + permission to make your modifications Freely Available in some specific + way. + +4. You may distribute the programs of this Package in object code or +executable form, provided that you do at least ONE of the following: + + a) distribute a Standard Version of the executables and library files, + together with instructions (in the manual page or equivalent) on where + to get the Standard Version. + + b) accompany the distribution with the machine-readable source of + the Package with your modifications. + + c) give non-standard executables non-standard names, and clearly + document the differences in manual pages (or equivalent), together + with instructions on where to get the Standard Version. + + d) make other distribution arrangements with the Copyright Holder. + + e) offer the machine-readable source of the Package, with your + modifications, by mail order. + +5. You may charge a distribution fee for any distribution of this Package. +If you offer support for this Package, you may charge any fee you choose +for that support. You may not charge a license fee for the right to use +this Package itself. You may distribute this Package in aggregate with +other (possibly commercial and possibly nonfree) programs as part of a +larger (possibly commercial and possibly nonfree) software distribution, +and charge license fees for other parts of that software distribution, +provided that you do not advertise this Package as a product of your own. +If the Package includes an interpreter, You may embed this Package's +interpreter within an executable of yours (by linking); this shall be +construed as a mere form of aggregation, provided that the complete +Standard Version of the interpreter is so embedded. + +6. The scripts and library files supplied as input to or produced as +output from the programs of this Package do not automatically fall +under the copyright of this Package, but belong to whoever generated +them, and may be sold commercially, and may be aggregated with this +Package. If such scripts or library files are aggregated with this +Package via the so-called "undump" or "unexec" methods of producing a +binary executable image, then distribution of such an image shall +neither be construed as a distribution of this Package nor shall it +fall under the restrictions of Paragraphs 3 and 4, provided that you do +not represent such an executable image as a Standard Version of this +Package. + +7. C subroutines (or comparably compiled subroutines in other +languages) supplied by you and linked into this Package in order to +emulate subroutines and variables of the language defined by this +Package shall not be considered part of this Package, but are the +equivalent of input as in Paragraph 6, provided these subroutines do +not change the language in any way that would cause it to fail the +regression tests for the language. + +8. Aggregation of the Standard Version of the Package with a commercial +distribution is always permitted provided that the use of this Package is +embedded; that is, when no overt attempt is made to make this Package's +interfaces visible to the end user of the commercial distribution. +Such use shall not be construed as a distribution of this Package. + +9. The name of the Copyright Holder may not be used to endorse or promote +products derived from this software without specific prior written permission. + +10. THIS PACKAGE IS PROVIDED "AS IS" AND WITHOUT ANY EXPRESS OR +IMPLIED WARRANTIES, INCLUDING, WITHOUT LIMITATION, THE IMPLIED +WARRANTIES OF MERCHANTIBILITY AND FITNESS FOR A PARTICULAR PURPOSE. + + The End + +-------------------------------------------------------------------- +License for Autotools + +AUTOCONF CONFIGURE SCRIPT EXCEPTION + +Version 3.0, 18 August 2009 + +Copyright © 2009 Free Software Foundation, Inc. + +Everyone is permitted to copy and distribute verbatim copies of this license +document, but changing it is not allowed. + +This Exception is an additional permission under section 7 of the GNU General +Public License, version 3 ("GPLv3"). It applies to a given file that bears a +notice placed by the copyright holder of the file stating that the file is +governed by GPLv3 along with this Exception. + +The purpose of this Exception is to allow distribution of Autoconf's +typical output under terms of the recipient's choice (including +proprietary). + +0. Definitions. +"Covered Code" is the source or object code of a version of Autoconf that is a +covered work under this License. + +"Normally Copied Code" for a version of Autoconf means all parts of its +Covered Code which that version can copy from its code (i.e., not from its +input file) into its minimally verbose, non-debugging and non-tracing output. + +"Ineligible Code" is Covered Code that is not Normally Copied Code. + +1. Grant of Additional Permission. +You have permission to propagate output of Autoconf, even if such propagation +would otherwise violate the terms of GPLv3. However, if by modifying Autoconf +you cause any Ineligible Code of the version you received to become Normally +Copied Code of your modified version, then you void this Exception for the +resulting covered work. If you convey that resulting covered work, you must +remove this Exception in accordance with the second paragraph of Section 7 of +GPLv3. + +2. No Weakening of Autoconf Copyleft. +The availability of this Exception does not imply any general presumption that +third-party software is unaffected by the copyleft requirements of the license +of Autoconf. + +-------------------------------------------------------------------- +License for Autotools + + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) 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 +this service 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 make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. 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. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute 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 and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +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 +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the 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 a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, 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. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE 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. + +Autoconf Exception + +As a special exception, the Free Software Foundation gives unlimited +permission to copy, distribute and modify the configure scripts that are the +output of Autoconf. You need not follow the terms of the GNU General Public +License when using or distributing such scripts, even though portions of the +text of Autoconf appear in them. The GNU General Public License (GPL) does +govern all other use of the material that constitutes the Autoconf program. + +Certain portions of the Autoconf source text are designed to be copied (in +certain cases, depending on the input) into the output of Autoconf. We call +these the "data" portions. The rest of the Autoconf source text consists of +comments plus executable code that decides which of the data portions to +output in any given case. We call these comments and executable code the "non- +data" portions. Autoconf never copies any of the non-data portions into its +output. + +This special exception to the GPL applies to versions of Autoconf released by +the Free Software Foundation. When you make and distribute a modified version +of Autoconf, you may extend this special exception to the GPL to apply to your +modified version as well, *unless* your modified version has the potential to +copy into its output some of the text that was the non-data portion of the +version that you started with. (In other words, unless your change moves or +copies text from the non-data portions to the data portions.) If your +modification has such potential, you must delete any notice of this special +exception to the GPL from your modified version. + + + +--- src/third_party/curl/COPYING --- +COPYRIGHT AND PERMISSION NOTICE + +Copyright (c) 1996 - 2011, Daniel Stenberg, . + +All rights reserved. + +Permission to use, copy, modify, and distribute this software for any purpose +with or without fee is hereby granted, provided that the above copyright +notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF THIRD PARTY RIGHTS. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE +OR OTHER DEALINGS IN THE SOFTWARE. + +Except as contained in this notice, the name of a copyright holder shall not +be used in advertising or otherwise to promote the sale, use or other dealings +in this Software without prior written authorization of the copyright holder. + + +--- src/third_party/libdisasm/LICENSE --- + + + + + The "Clarified Artistic License" + + Preamble + +The intent of this document is to state the conditions under which a +Package may be copied, such that the Copyright Holder maintains some +semblance of artistic control over the development of the package, +while giving the users of the package the right to use and distribute +the Package in a more-or-less customary fashion, plus the right to make +reasonable modifications. + +Definitions: + + "Package" refers to the collection of files distributed by the + Copyright Holder, and derivatives of that collection of files + created through textual modification. + + "Standard Version" refers to such a Package if it has not been + modified, or has been modified in accordance with the wishes + of the Copyright Holder as specified below. + + "Copyright Holder" is whoever is named in the copyright or + copyrights for the package. + + "You" is you, if you're thinking about copying or distributing + this Package. + + "Distribution fee" is a fee you charge for providing a copy of this + Package to another party. + + "Freely Available" means that no fee is charged for the right to use + the item, though there may be fees involved in handling the item. + +1. You may make and give away verbatim copies of the source form of the +Standard Version of this Package without restriction, provided that you +duplicate all of the original copyright notices and associated disclaimers. + +2. You may apply bug fixes, portability fixes and other modifications +derived from the Public Domain, or those made Freely Available, or from +the Copyright Holder. A Package modified in such a way shall still be +considered the Standard Version. + +3. You may otherwise modify your copy of this Package in any way, provided +that you insert a prominent notice in each changed file stating how and +when you changed that file, and provided that you do at least ONE of the +following: + + a) place your modifications in the Public Domain or otherwise make them + Freely Available, such as by posting said modifications to Usenet or + an equivalent medium, or placing the modifications on a major archive + site allowing unrestricted access to them, or by allowing the Copyright + Holder to include your modifications in the Standard Version of the + Package. + + b) use the modified Package only within your corporation or organization. + + c) rename any non-standard executables so the names do not conflict + with standard executables, which must also be provided, and provide + a separate manual page for each non-standard executable that clearly + documents how it differs from the Standard Version. + + d) make other distribution arrangements with the Copyright Holder. + + e) permit and encourge anyone who receives a copy of the modified Package + permission to make your modifications Freely Available in some specific + way. + +4. You may distribute the programs of this Package in object code or +executable form, provided that you do at least ONE of the following: + + a) distribute a Standard Version of the executables and library files, + together with instructions (in the manual page or equivalent) on where + to get the Standard Version. + + b) accompany the distribution with the machine-readable source of + the Package with your modifications. + + c) give non-standard executables non-standard names, and clearly + document the differences in manual pages (or equivalent), together + with instructions on where to get the Standard Version. + + d) make other distribution arrangements with the Copyright Holder. + + e) offer the machine-readable source of the Package, with your + modifications, by mail order. + +5. You may charge a distribution fee for any distribution of this Package. +If you offer support for this Package, you may charge any fee you choose +for that support. You may not charge a license fee for the right to use +this Package itself. You may distribute this Package in aggregate with +other (possibly commercial and possibly nonfree) programs as part of a +larger (possibly commercial and possibly nonfree) software distribution, +and charge license fees for other parts of that software distribution, +provided that you do not advertise this Package as a product of your own. +If the Package includes an interpreter, You may embed this Package's +interpreter within an executable of yours (by linking); this shall be +construed as a mere form of aggregation, provided that the complete +Standard Version of the interpreter is so embedded. + +6. The scripts and library files supplied as input to or produced as +output from the programs of this Package do not automatically fall +under the copyright of this Package, but belong to whoever generated +them, and may be sold commercially, and may be aggregated with this +Package. If such scripts or library files are aggregated with this +Package via the so-called "undump" or "unexec" methods of producing a +binary executable image, then distribution of such an image shall +neither be construed as a distribution of this Package nor shall it +fall under the restrictions of Paragraphs 3 and 4, provided that you do +not represent such an executable image as a Standard Version of this +Package. + +7. C subroutines (or comparably compiled subroutines in other +languages) supplied by you and linked into this Package in order to +emulate subroutines and variables of the language defined by this +Package shall not be considered part of this Package, but are the +equivalent of input as in Paragraph 6, provided these subroutines do +not change the language in any way that would cause it to fail the +regression tests for the language. + +8. Aggregation of the Standard Version of the Package with a commercial +distribution is always permitted provided that the use of this Package is +embedded; that is, when no overt attempt is made to make this Package's +interfaces visible to the end user of the commercial distribution. +Such use shall not be construed as a distribution of this Package. + +9. The name of the Copyright Holder may not be used to endorse or promote +products derived from this software without specific prior written permission. + +10. THIS PACKAGE IS PROVIDED "AS IS" AND WITHOUT ANY EXPRESS OR +IMPLIED WARRANTIES, INCLUDING, WITHOUT LIMITATION, THE IMPLIED +WARRANTIES OF MERCHANTIBILITY AND FITNESS FOR A PARTICULAR PURPOSE. + + The End + +--- src/third_party/musl/COPYRIGHT --- +musl as a whole is licensed under the following standard MIT license: + +---------------------------------------------------------------------- +Copyright © 2005-2014 Rich Felker, et al. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +---------------------------------------------------------------------- + +Authors/contributors include: + +Alex Dowad +Alexander Monakov +Anthony G. Basile +Arvid Picciani +Bobby Bingham +Boris Brezillon +Brent Cook +Chris Spiegel +Clément Vasseur +Daniel Micay +Denys Vlasenko +Emil Renner Berthing +Felix Fietkau +Felix Janda +Gianluca Anzolin +Hauke Mehrtens +Hiltjo Posthuma +Isaac Dunham +Jaydeep Patil +Jens Gustedt +Jeremy Huntwork +Jo-Philipp Wich +Joakim Sindholt +John Spencer +Josiah Worcester +Justin Cormack +Khem Raj +Kylie McClain +Luca Barbato +Luka Perkov +M Farkas-Dyck (Strake) +Mahesh Bodapati +Michael Forney +Natanael Copa +Nicholas J. Kain +orc +Pascal Cuoq +Petr Hosek +Pierre Carrier +Rich Felker +Richard Pennington +Shiz +sin +Solar Designer +Stefan Kristiansson +Szabolcs Nagy +Timo Teräs +Trutz Behn +Valentin Ochs +William Haddon + +Portions of this software are derived from third-party works licensed +under terms compatible with the above MIT license: + +The TRE regular expression implementation (src/regex/reg* and +src/regex/tre*) is Copyright © 2001-2008 Ville Laurikari and licensed +under a 2-clause BSD license (license text in the source files). The +included version has been heavily modified by Rich Felker in 2012, in +the interests of size, simplicity, and namespace cleanliness. + +Much of the math library code (src/math/* and src/complex/*) is +Copyright © 1993,2004 Sun Microsystems or +Copyright © 2003-2011 David Schultz or +Copyright © 2003-2009 Steven G. Kargl or +Copyright © 2003-2009 Bruce D. Evans or +Copyright © 2008 Stephen L. Moshier +and labelled as such in comments in the individual source files. All +have been licensed under extremely permissive terms. + +The ARM memcpy code (src/string/arm/memcpy_el.S) is Copyright © 2008 +The Android Open Source Project and is licensed under a two-clause BSD +license. It was taken from Bionic libc, used on Android. + +The implementation of DES for crypt (src/crypt/crypt_des.c) is +Copyright © 1994 David Burren. It is licensed under a BSD license. + +The implementation of blowfish crypt (src/crypt/crypt_blowfish.c) was +originally written by Solar Designer and placed into the public +domain. The code also comes with a fallback permissive license for use +in jurisdictions that may not recognize the public domain. + +The smoothsort implementation (src/stdlib/qsort.c) is Copyright © 2011 +Valentin Ochs and is licensed under an MIT-style license. + +The BSD PRNG implementation (src/prng/random.c) and XSI search API +(src/search/*.c) functions are Copyright © 2011 Szabolcs Nagy and +licensed under following terms: "Permission to use, copy, modify, +and/or distribute this code for any purpose with or without fee is +hereby granted. There is no warranty." + +The x86_64 port was written by Nicholas J. Kain and is licensed under +the standard MIT terms. + +The mips and microblaze ports were originally written by Richard +Pennington for use in the ellcc project. The original code was adapted +by Rich Felker for build system and code conventions during upstream +integration. It is licensed under the standard MIT terms. + +The mips64 port was contributed by Imagination Technologies and is +licensed under the standard MIT terms. + +The powerpc port was also originally written by Richard Pennington, +and later supplemented and integrated by John Spencer. It is licensed +under the standard MIT terms. + +All other files which have no copyright comments are original works +produced specifically for use as part of this library, written either +by Rich Felker, the main author of the library, or by one or more +contibutors listed above. Details on authorship of individual files +can be found in the git version control history of the project. The +omission of copyright and license comments in each file is in the +interest of source tree size. + +In addition, permission is hereby granted for all public header files +(include/* and arch/*/bits/*) and crt files intended to be linked into +applications (crt/*, ldso/dlstart.c, and arch/*/crt_arch.h) to omit +the copyright notice and permission notice otherwise required by the +license, and to use these files without any requirement of +attribution. These files include substantial contributions from: + +Bobby Bingham +John Spencer +Nicholas J. Kain +Rich Felker +Richard Pennington +Stefan Kristiansson +Szabolcs Nagy + +all of whom have explicitly granted such permission. + +This file previously contained text expressing a belief that most of +the files covered by the above exception were sufficiently trivial not +to be subject to copyright, resulting in confusion over whether it +negated the permissions granted in the license. In the spirit of +permissive licensing, and of not having licensing issues being an +obstacle to adoption, that text has been removed. + + +======================================== + +Source: https://deps.files.ghostty.org/NerdFontsSymbolsOnly-3.4.0.tar.gz +Zig package hash: N-V-__8AAMVLTABmYkLqhZPLXnMl-KyN38R8UVYqGrxqO26s + +--- LICENSE --- +The MIT License (MIT) + +Copyright (c) 2014 Ryan L McIntyre + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +======================================== + +Source: https://deps.files.ghostty.org/DearBindings_v0.17_ImGui_v1.92.5-docking.tar.gz +Zig package hash: N-V-__8AANT61wB--nJ95Gj_ctmzAtcjloZ__hRqNw5lC1Kr + +// THIS FILE HAS BEEN AUTO-GENERATED BY THE 'DEAR BINDINGS' GENERATOR. +// **DO NOT EDIT DIRECTLY** +// https://github.com/dearimgui/dear_bindings + +// dear imgui, v1.92.5 +// (headers) + +// Help: +// - Call and read ImGui::ShowDemoWindow() in imgui_demo.cpp. All applications in examples/ are doing that. +// - Read top of imgui.cpp for more details, links and comments. +// - Add '#define IMGUI_DEFINE_MATH_OPERATORS' before including imgui.h (or in imconfig.h) to access courtesy maths operators for ImVec2 and ImVec4. + +// Resources: +// - FAQ ........................ https://dearimgui.com/faq (in repository as docs/FAQ.md) +// - Homepage ................... https://github.com/ocornut/imgui +// - Releases & Changelog ....... https://github.com/ocornut/imgui/releases +// - Gallery .................... https://github.com/ocornut/imgui/issues?q=label%3Agallery (please post your screenshots/video there!) +// - Wiki ....................... https://github.com/ocornut/imgui/wiki (lots of good stuff there) +// - Getting Started https://github.com/ocornut/imgui/wiki/Getting-Started (how to integrate in an existing app by adding ~25 lines of code) +// - Third-party Extensions https://github.com/ocornut/imgui/wiki/Useful-Extensions (ImPlot & many more) +// - Bindings/Backends https://github.com/ocornut/imgui/wiki/Bindings (language bindings + backends for various tech/engines) +// - Debug Tools https://github.com/ocornut/imgui/wiki/Debug-Tools +// - Glossary https://github.com/ocornut/imgui/wiki/Glossary +// - Software using Dear ImGui https://github.com/ocornut/imgui/wiki/Software-using-dear-imgui +// - Issues & support ........... https://github.com/ocornut/imgui/issues +// - Test Engine & Automation ... https://github.com/ocornut/imgui_test_engine (test suite, test engine to automate your apps) +// - Web version of the Demo .... https://pthom.github.io/imgui_manual_online/manual/imgui_manual.html (w/ source code browser) + +// For FIRST-TIME users having issues compiling/linking/running: +// please post in https://github.com/ocornut/imgui/discussions if you cannot find a solution in resources above. +// EVERYTHING ELSE should be asked in 'Issues'! We are building a database of cross-linked knowledge there. +// Since 1.92, we encourage font loading questions to also be posted in 'Issues'. + + + +======================================== + +Source: https://deps.files.ghostty.org/spirv_cross-1220fb3b5586e8be67bc3feb34cbe749cf42a60d628d2953632c2f8141302748c8da.tar.gz +Zig package hash: N-V-__8AANb6pwD7O1WG6L5nvD_rNMvnSc9Cpg1ijSlTYywv + +--- LICENSE --- + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + +======================================== + +Source: https://deps.files.ghostty.org/wuffs-7411f488fe2e2c205c3d3b3d28638b7356522930.tar.gz +Zig package hash: N-V-__8AAP5JWgCGP_AD0teWpa4krRvE9VPZzvviGdbmN4jI + +--- LICENSE --- +This software is distributed under the terms of both the MIT license and the +Apache License (Version 2.0). + + +MIT license + +Copyright 2023 The Wuffs Authors + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + + +Apache 2 license + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + +--- LICENSE-APACHE --- + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + +--- LICENSE-MIT --- +Copyright 2023 The Wuffs Authors + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + + +======================================== + +Source: https://deps.files.ghostty.org/sentry-1220446be831adcca918167647c06c7b825849fa3fba5f22da394667974537a9c77e.tar.gz +Zig package hash: N-V-__8AAPlZGwBEa-gxrcypGBZ2R8Bse4JYSfo_ul8i2jlG + +--- LICENSE --- +Copyright (c) 2019 Sentry (https://sentry.io) and individual contributors. +All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +======================================== + +Source: https://deps.files.ghostty.org/libxev-9ce8e8e6ff89e583258a7f8e7adeeeaeae8611bf.tar.gz +Zig package hash: libxev-0.0.0-86vtcwIRFADbH4hk-EjROXxlrKIRPQdA41XiTSytYO-F + +--- LICENSE --- +MIT License + +Copyright (c) 2023 Mitchell Hashimoto + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +======================================== + +Source: https://deps.files.ghostty.org/translate_c-80f8b6e4f45a303268717d8e5f4f91d7837138bb.tar.gz +Zig package hash: translate_c-0.0.0-Q_BUWmU6BwB_9JKG2l2W7i_mhmYWeRseTGBEHi_YlV5f + +--- LICENSE --- +The MIT License (Expat) + +Copyright (c) Zig contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + +======================================== + +Source: https://github.com/vancluever/arocc/archive/ecbc5c799574e0da2758a961b12efa586007f03c.tar.gz +Zig package hash: aro-0.0.0-JSD1Qk8rOQDnuVcD4jAwMpHitA6pADRKzQ7M7hKRwxvD + +--- LICENSE --- +MIT License + +Copyright (c) 2021 Veikka Tuominen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- LICENSE-UNICODE --- +UNICODE LICENSE V3 + +COPYRIGHT AND PERMISSION NOTICE + +Copyright © 1991-2023 Unicode, Inc. + +NOTICE TO USER: Carefully read the following legal agreement. BY +DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR +SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE +TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT +DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of data files and any associated documentation (the "Data Files") or +software and any associated documentation (the "Software") to deal in the +Data Files or Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, and/or sell +copies of the Data Files or Software, and to permit persons to whom the +Data Files or Software are furnished to do so, provided that either (a) +this copyright and permission notice appear with all copies of the Data +Files or Software, or (b) this copyright and permission notice appear in +associated Documentation. + +THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF +THIRD PARTY RIGHTS. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE +BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, +OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, +ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA +FILES OR SOFTWARE. + +Except as contained in this notice, the name of a copyright holder shall +not be used in advertising or otherwise to promote the sale, use or other +dealings in these Data Files or Software without prior written +authorization of the copyright holder. + +======================================== + +Source: https://deps.files.ghostty.org/uucode-2826a37a4562284fdacd8fa029d49509cc9bffcd.tar.gz +Zig package hash: uucode-0.2.0-ZZjBPlK5VADj7fdoq7G8LIHzD5o6FSkcBXXrRWr4jnrA + +--- LICENSE.md --- +# uucode license + +MIT License + +Copyright (c) 2026 Jacob Sandlund + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +## Other licenses + +See [./licenses](./licenses) for licenses of code being used in the repo: + +* [LICENSE_Bjoern_Hoehrmann](./licenses/LICENSE_Bjoern_Hoehrmann) +* [LICENSE_unicode](./licenses/LICENSE_unicode) + + +======================================== + +Source: https://deps.files.ghostty.org/vaxis-1dbbe575dff4586fe51e3217aa5c3fecdcbb6089.tar.gz +Zig package hash: vaxis-0.6.0-BWNV_CrbCQCscGpzsAlR402rYQ_tV3aAl081c2iRRkka + +--- LICENSE --- +MIT License + +Copyright (c) 2023 Tim Culverhouse + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +======================================== + +Source: https://deps.files.ghostty.org/z2d-7dbae85c81784dba9988320bf9543ed9a81350c8.tar.gz +Zig package hash: z2d-0.12.1-j5P_Hsw8EQAKyZTQICCQnAH2xYkLDW8k9uefbsYdfPZ- + +// SPDX-License-Identifier: MPL-2.0 +// Copyright © 2024-2026 Chris Marchesi +--- https://raw.githubusercontent.com/vancluever/z2d/7dbae85c81784dba9988320bf9543ed9a81350c8/LICENSE --- +Copyright © 2024-2026 Chris Marchesi + +This Source Code is subject to the terms of the Mozilla Public License, v.2.0. +You should have received a copy of the MPL along with this library in the file +COPYING. If a copy of the MPL was not distributed with this file, You can +obtain one at https://mozilla.org/MPL/2.0/. + +--- + +Portions of this code, where noted, have been derived and adapted from the +Cairo project (https://www.cairographics.org/), used under the terms of Mozilla +Public License (MPL) 1.1, and re-distributed under the terms of the MPL 2.0. +The following statement applies to Cairo-covered code. + +Copyright © 2024-2026 Chris Marchesi +Copyright © 2002 University of Southern California + +This Source Code is subject to the terms of the Mozilla Public License, v.2.0. +You should have received a copy of the MPL along with this library in the file +COPYING. If a copy of the MPL was not distributed with this file, You can +obtain one at https://mozilla.org/MPL/2.0/. + +--- + +Portions of this code, where noted, have been derived and adapted from the +Pixman project (https://gitlab.freedesktop.org/pixman/pixman), used under the +terms of the MIT license. The following statement applies to Pixman-covered +code. + +Copyright 1987, 1988, 1989, 1998 The Open Group +Copyright 1987, 1988, 1989 Digital Equipment Corporation +Copyright 1999, 2004, 2008 Keith Packard +Copyright 2000 SuSE, Inc. +Copyright 2000 Keith Packard, member of The XFree86 Project, Inc. +Copyright 2004, 2005, 2007, 2008, 2009, 2010 Red Hat, Inc. +Copyright 2004 Nicholas Miell +Copyright 2005 Lars Knoll & Zack Rusin, Trolltech +Copyright 2005 Trolltech AS +Copyright 2007 Luca Barbato +Copyright 2008 Aaron Plattner, NVIDIA Corporation +Copyright 2008 Rodrigo Kumpera +Copyright 2008 André Tupinambá +Copyright 2008 Mozilla Corporation +Copyright 2008 Frederic Plourde +Copyright 2009, Oracle and/or its affiliates. All rights reserved. +Copyright 2009, 2010 Nokia Corporation + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice (including the next +paragraph) shall be included in all copies or substantial portions of the +Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +--- + +Portions of this code, where noted, have been derived and adapted from the +tiny-skia project (https://github.com/linebender/tiny-skia), used under the +terms of the 3-Clause BSD license. The following statement applies to +tiny-skia-covered code. + +Copyright (c) 2011 Google Inc. All rights reserved. +Copyright (c) 2020 Yevhenii Reizner All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + + * Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +--- + +Portions of this code, where noted, have been derived and adapted from +reference materials from the World Wide Web Consortium (W3C), used under the +terms of the W3C Software and Document license. Below is a copy of the license +notice. + +By obtaining and/or copying this work, you (the licensee) agree that you have +read, understood, and will comply with the following terms and conditions. + +Permission to copy, modify, and distribute this work, with or without +modification, for any purpose and without fee or royalty is hereby granted, +provided that you include the following on ALL copies of the work or portions +thereof, including modifications: + +- The full text of this NOTICE in a location viewable to users of the + redistributed or derivative work. +- Any pre-existing intellectual property disclaimers, notices, or terms and + conditions. If none exist, the W3C software and document short notice should + be included. +- Notice of any changes or modifications, through a copyright statement on the + new code or document such as "This software or document includes material + copied from or derived from [title and URI of the W3C document]. Copyright © + [$year-of-document] World Wide Web Consortium. + https://www.w3.org/copyright/software-license-2023/" + +The short notice follows (year is noted in cited works): + +Copyright © World Wide Web Consortium. All Rights Reserved. This work is +distributed under the W3C® Software and Document License [1] 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. + + [1] https://www.w3.org/Consortium/Legal/copyright-software + +--- + +Certain fonts included in various parts of this software for testing purposes +are under the terms of the SIL Open Font License (OFL) v1.1. These fonts are: + +Annotation Mono (https://github.com/qwerasd205/AnnotationMono): + Copyright (c) 2025, Qwerasd (Qwerasd.org), + with Reserved Font Name Annotation Mono. + +Inter: + Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) + +Montserrat: + Copyright 2024 The Montserrat.Git Project Authors + (https://github.com/JulietaUla/Montserrat.git) + +Below is the text of the SIL Open Font License (OFL) v1.1. It is also available +with a FAQ at: http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION AND CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. + +--- + +The DejaVu Sans font, included in various parts of this software for testing +purposes, is used under the terms of the following license: + +Fonts are (c) Bitstream (see below). DejaVu changes are in public domain. +Glyphs imported from Arev fonts are (c) Tavmjong Bah (see below) + + +Bitstream Vera Fonts Copyright +------------------------------ + +Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is +a trademark of Bitstream, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of the fonts accompanying this license ("Fonts") and associated +documentation files (the "Font Software"), to reproduce and distribute the +Font Software, including without limitation the rights to use, copy, merge, +publish, distribute, and/or sell copies of the Font Software, and to permit +persons to whom the Font Software is furnished to do so, subject to the +following conditions: + +The above copyright and trademark notices and this permission notice shall +be included in all copies of one or more of the Font Software typefaces. + +The Font Software may be modified, altered, or added to, and in particular +the designs of glyphs or characters in the Fonts may be modified and +additional glyphs or characters may be added to the Fonts, only if the fonts +are renamed to names not containing either the words "Bitstream" or the word +"Vera". + +This License becomes null and void to the extent applicable to Fonts or Font +Software that has been modified and is distributed under the "Bitstream +Vera" names. + +The Font Software may be sold as part of a larger software package but no +copy of one or more of the Font Software typefaces may be sold by itself. + +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, +TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME +FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING +ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF +THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE +FONT SOFTWARE. + +Except as contained in this notice, the names of Gnome, the Gnome +Foundation, and Bitstream Inc., shall not be used in advertising or +otherwise to promote the sale, use or other dealings in this Font Software +without prior written authorization from the Gnome Foundation or Bitstream +Inc., respectively. For further information, contact: fonts at gnome dot +org. + +Arev Fonts Copyright +------------------------------ + +Copyright (c) 2006 by Tavmjong Bah. All Rights Reserved. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of the fonts accompanying this license ("Fonts") and +associated documentation files (the "Font Software"), to reproduce +and distribute the modifications to the Bitstream Vera Font Software, +including without limitation the rights to use, copy, merge, publish, +distribute, and/or sell copies of the Font Software, and to permit +persons to whom the Font Software is furnished to do so, subject to +the following conditions: + +The above copyright and trademark notices and this permission notice +shall be included in all copies of one or more of the Font Software +typefaces. + +The Font Software may be modified, altered, or added to, and in +particular the designs of glyphs or characters in the Fonts may be +modified and additional glyphs or characters may be added to the +Fonts, only if the fonts are renamed to names not containing either +the words "Tavmjong Bah" or the word "Arev". + +This License becomes null and void to the extent applicable to Fonts +or Font Software that has been modified and is distributed under the +"Tavmjong Bah Arev" names. + +The Font Software may be sold as part of a larger software package but +no copy of one or more of the Font Software typefaces may be sold by +itself. + +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL +TAVMJONG BAH BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. + +Except as contained in this notice, the name of Tavmjong Bah shall not +be used in advertising or otherwise to promote the sale, use or other +dealings in this Font Software without prior written authorization +from Tavmjong Bah. For further information, contact: tavmjong @ free +. fr. + +TeX Gyre DJV Math +----------------- +Fonts are (c) Bitstream (see below). DejaVu changes are in public domain. + +Math extensions done by B. Jackowski, P. Strzelczyk and P. Pianowski +(on behalf of TeX users groups) are in public domain. + +Letters imported from Euler Fraktur from AMSfonts are (c) American +Mathematical Society (see below). +Bitstream Vera Fonts Copyright +Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera +is a trademark of Bitstream, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of the fonts accompanying this license (“Fonts”) and associated +documentation +files (the “Font Software”), to reproduce and distribute the Font Software, +including without limitation the rights to use, copy, merge, publish, +distribute, +and/or sell copies of the Font Software, and to permit persons to whom +the Font Software is furnished to do so, subject to the following +conditions: + +The above copyright and trademark notices and this permission notice +shall be +included in all copies of one or more of the Font Software typefaces. + +The Font Software may be modified, altered, or added to, and in particular +the designs of glyphs or characters in the Fonts may be modified and +additional +glyphs or characters may be added to the Fonts, only if the fonts are +renamed +to names not containing either the words “Bitstream” or the word “Vera”. + +This License becomes null and void to the extent applicable to Fonts or +Font Software +that has been modified and is distributed under the “Bitstream Vera” +names. + +The Font Software may be sold as part of a larger software package but +no copy +of one or more of the Font Software typefaces may be sold by itself. + +THE FONT SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, +TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME +FOUNDATION +BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, +SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN +ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR +INABILITY TO USE +THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE. +Except as contained in this notice, the names of GNOME, the GNOME +Foundation, +and Bitstream Inc., shall not be used in advertising or otherwise to promote +the sale, use or other dealings in this Font Software without prior written +authorization from the GNOME Foundation or Bitstream Inc., respectively. +For further information, contact: fonts at gnome dot org. + +AMSFonts (v. 2.2) copyright + +The PostScript Type 1 implementation of the AMSFonts produced by and +previously distributed by Blue Sky Research and Y&Y, Inc. are now freely +available for general use. This has been accomplished through the +cooperation +of a consortium of scientific publishers with Blue Sky Research and Y&Y. +Members of this consortium include: + +Elsevier Science IBM Corporation Society for Industrial and Applied +Mathematics (SIAM) Springer-Verlag American Mathematical Society (AMS) + +In order to assure the authenticity of these fonts, copyright will be +held by +the American Mathematical Society. This is not meant to restrict in any way +the legitimate use of the fonts, such as (but not limited to) electronic +distribution of documents containing these fonts, inclusion of these fonts +into other public domain or commercial font collections or computer +applications, use of the outline data to create derivative fonts and/or +faces, etc. However, the AMS does require that the AMS copyright notice be +removed from any derivative versions of the fonts which have been altered in +any way. In addition, to ensure the fidelity of TeX documents using Computer +Modern fonts, Professor Donald Knuth, creator of the Computer Modern faces, +has requested that any alterations which yield different font metrics be +given a different name. + +--- + +The acceptance tests in the "spec" directory (unless otherwise specified) are +licensed 0BSD to allow the unambiguous free use of this code in assisting the +integration of z2d. + +Below is the license text of the 0BSD license. + +BSD Zero Clause License + +Copyright © 2024 by Chris Marchesi + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. + +--- + +--- https://raw.githubusercontent.com/vancluever/z2d/7dbae85c81784dba9988320bf9543ed9a81350c8/COPYING --- +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at https://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. + + + +======================================== + +Source: https://deps.files.ghostty.org/zf-c35c421f84895193246db06c40683c1a30e616ef.tar.gz +Zig package hash: zf-0.11.0-OIRy8X-RAAAwaRXHMYpj2uvBnuGTZWEE_3V7acqHQNtW + +--- LICENSE --- +MIT License + +Copyright (c) Nathan Craddock + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +======================================== + +Source: https://deps.files.ghostty.org/zig_objc-c8de82ff80281215ad92900866dab7103a8efa8b.tar.gz +Zig package hash: zig_objc-0.0.0-Ir_Sp9gsAQCPAJc0oF5xoWePHWP6Y6tCphDeyNUThJoi + +--- LICENSE --- +MIT License + +Copyright (c) 2023 Mitchell Hashimoto + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +======================================== + +Source: git+https://github.com/zigimg/zigimg#d695acd97c02e57bb151e8f659d1280f5cd6ca70 +Zig package hash: zigimg-0.1.0-8_eo2oyaFwBZwJpmqPkCfVXWBrHcqbYwmrp1I6bTD3lI + +--- LICENSE --- +MIT License + +Copyright (c) 2019-2021 zigimg developers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +======================================== + +# Nerd Fonts Licensing + +There are various sources used under various licenses: + +* Nerd Fonts source fonts, patched fonts, and folders with explict OFL SIL files are licensed under SIL OPEN FONT LICENSE Version 1.1 (see below). +* Nerd Fonts original source code files (such as `.sh`, `.py`, `font-patcher` and others) are licensed under the MIT License (MIT) (see below). +* Many other licenses are present in this project for even more detailed breakdown see: [License Audit](https://github.com/ryanoasis/nerd-fonts/blob/-/license-audit.md). + +## Source files not in folders containing an explicit license are using the MIT License (MIT) + +The MIT License (MIT) + +Copyright (c) 2014 Ryan L McIntyre + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +## Various Fonts, Patched Fonts, SVGs, Glyph Fonts, and any files in a folder with explicit SIL OFL 1.1 License + +Copyright (c) 2014, Ryan L McIntyre (https://ryanlmcintyre.com). + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/apps/macos/Licenses/playwright-LICENSE.txt b/apps/macos/Licenses/playwright-LICENSE.txt new file mode 100644 index 0000000000000..4ace03ddb607e --- /dev/null +++ b/apps/macos/Licenses/playwright-LICENSE.txt @@ -0,0 +1,202 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Portions Copyright (c) Microsoft Corporation. + Portions Copyright 2017 Google Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/apps/macos/Licenses/playwright-NOTICE.txt b/apps/macos/Licenses/playwright-NOTICE.txt new file mode 100644 index 0000000000000..814ec1696f7ad --- /dev/null +++ b/apps/macos/Licenses/playwright-NOTICE.txt @@ -0,0 +1,5 @@ +Playwright +Copyright (c) Microsoft Corporation + +This software contains code derived from the Puppeteer project (https://github.com/puppeteer/puppeteer), +available under the Apache 2.0 license (https://github.com/puppeteer/puppeteer/blob/master/LICENSE). diff --git a/apps/macos/Licenses/swift-cmark-COPYING.txt b/apps/macos/Licenses/swift-cmark-COPYING.txt new file mode 100644 index 0000000000000..db88a81b3ec35 --- /dev/null +++ b/apps/macos/Licenses/swift-cmark-COPYING.txt @@ -0,0 +1,170 @@ +Copyright (c) 2014, John MacFarlane + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +----- + +houdini.h, houdini_href_e.c, houdini_html_e.c, houdini_html_u.c + +derive from https://github.com/vmg/houdini (with some modifications) + +Copyright (C) 2012 Vicent Martí + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +----- + +buffer.h, buffer.c, chunk.h + +are derived from code (C) 2012 Github, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +----- + +utf8.c and utf8.c + +are derived from utf8proc +(), +(C) 2009 Public Software Group e. V., Berlin, Germany. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +----- + +The normalization code in normalize.py was derived from the +markdowntest project, Copyright 2013 Karl Dubost: + +The MIT License (MIT) + +Copyright (c) 2013 Karl Dubost + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +----- + +The CommonMark spec (test/spec.txt) is + +Copyright (C) 2014-15 John MacFarlane + +Released under the Creative Commons CC-BY-SA 4.0 license: +. + +----- + +The test software in test/ is + +Copyright (c) 2014, John MacFarlane + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/apps/macos/Licenses/swift-markdown-LICENSE.txt b/apps/macos/Licenses/swift-markdown-LICENSE.txt new file mode 100644 index 0000000000000..78129ccbb84cf --- /dev/null +++ b/apps/macos/Licenses/swift-markdown-LICENSE.txt @@ -0,0 +1,211 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + + +### Runtime Library Exception to the Apache 2.0 License: ### + + + As an exception, if you use this Software to compile your source code and + portions of this Software are embedded into the binary product as a result, + you may redistribute such product without providing attribution as would + otherwise be required by Sections 4(a), 4(b) and 4(d) of the License. diff --git a/apps/macos/Licenses/swift-markdown-NOTICE.txt b/apps/macos/Licenses/swift-markdown-NOTICE.txt new file mode 100644 index 0000000000000..555f51d8485d2 --- /dev/null +++ b/apps/macos/Licenses/swift-markdown-NOTICE.txt @@ -0,0 +1,41 @@ + + The Swift Markdown Project + ========================== + +Please visit the Swift Markdown web site for more information: + + * https://github.com/apple/swift-markdown + +Copyright (c) 2021 Apple Inc. and the Swift project authors + +The Swift Project licenses this file to you under the Apache License, +version 2.0 (the "License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at: + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +License for the specific language governing permissions and limitations +under the License. + +------------------------------------------------------------------------------- + +This product contains Swift Argument Parser. + + * LICENSE (Apache License 2.0): + * https://www.apache.org/licenses/LICENSE-2.0 + * HOMEPAGE: + * https://github.com/apple/swift-argument-parser + +--- + +This product contains a derivation of the cmark-gfm project, available at +https://github.com/apple/swift-cmark. + + * LICENSE (BSD-2): + * https://opensource.org/licenses/BSD-2-Clause + * HOMEPAGE: + * https://github.com/github/cmark-gfm + diff --git a/apps/macos/Package.resolved b/apps/macos/Package.resolved new file mode 100644 index 0000000000000..f3f23c6bd062e --- /dev/null +++ b/apps/macos/Package.resolved @@ -0,0 +1,24 @@ +{ + "originHash" : "14758fba8541d7036e4ef0b93f840bc2dcee8ec647e0ca7baab5f5101e36a7ff", + "pins" : [ + { + "identity" : "swift-cmark", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-cmark.git", + "state" : { + "revision" : "08ddb528923cc1a6527e02b7a1aee9e516ca749a", + "version" : "0.9.0" + } + }, + { + "identity" : "swift-markdown", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-markdown.git", + "state" : { + "revision" : "25cb61d3482054b09ae76ca4f281b1bfe7fe5a43", + "version" : "0.9.0" + } + } + ], + "version" : 3 +} diff --git a/apps/macos/Package.swift b/apps/macos/Package.swift new file mode 100644 index 0000000000000..2cb7c464e70c1 --- /dev/null +++ b/apps/macos/Package.swift @@ -0,0 +1,22 @@ +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "T3Native", + platforms: [.macOS(.v14)], + products: [.executable(name: "T3Native", targets: ["T3Native"])], + dependencies: [.package(url: "https://github.com/swiftlang/swift-markdown.git", exact: "0.9.0")], + targets: [ + .binaryTarget(name: "GhosttyKit", path: "Vendor/GhosttyKit.xcframework"), + .target(name: "T3Core"), + .target( + name: "T3Markdown", dependencies: [.product(name: "Markdown", package: "swift-markdown")]), + .executableTarget( + name: "T3Native", dependencies: ["T3Core", "T3Markdown", "GhosttyKit"], + resources: [.copy("Resources")], + linkerSettings: [.linkedLibrary("c++"), .linkedFramework("Carbon")]), + .testTarget(name: "T3CoreTests", dependencies: ["T3Core"]), + .testTarget(name: "T3MarkdownTests", dependencies: ["T3Markdown"]), + .testTarget(name: "T3NativeTests", dependencies: ["T3Native", "T3Core"]), + ] +) diff --git a/apps/macos/README.md b/apps/macos/README.md new file mode 100644 index 0000000000000..6bdb301d068c1 --- /dev/null +++ b/apps/macos/README.md @@ -0,0 +1,438 @@ +# T3 Native prototype + +A macOS 14+ AppKit client for a T3 protocol-v2 server. The interface uses +native tables and text controls, with isolated WebKit views for document previews. Agents, +SQLite, Git, terminals, and remote access still belong to the existing server. +It reconnects to saved environments or starts its bundled server on first launch. +Closing this app leaves external servers running and stops only its own server child. +About T3 Native shows the build's source revision and whether it includes local changes. + +The client supports grouped active/pinned/settled/snoozed threads, project filtering, +search, archived history and restore, rename, pin, settle, snooze, and shared unread +state. Snoozes wake on the same attention and time rules as Electron. It also supports bounded conversation +history with older pages, streamed text and activity, per-thread drafts, new threads, +message submission, and stopping a run. New threads use the project filter when set, +then the current thread's project. Configured project and environment model defaults +take precedence over the current model, and permissions use the configured defaults. +New conversations remain saved local drafts until their first message. Choose the +project, model options, permissions, and current checkout or new worktree before +sending. Workspace defaults follow project overrides, environment settings, then +the project's JSONC `t3.json`. Worktrees use a selected base branch and the existing server launch contract. +An uncertain first launch retries the original command; newer edits remain saved. + +Open an existing project folder with ⌘O or the folder-plus button. The picker browses +the connected server, including remote environments. Chat → New Project (⌘⇧N) +creates a folder in that server's configured projects directory. A new environment +can start its first thread without an existing thread; if the server does not support +Scratch, choose or create a project first. + +Search matches thread titles, projects and branches. Use ↑/↓ to browse matches, +Return to open one, or Escape to clear the search. The folder button limits the +list to a project. Hover a thread for Settle and Snooze; archived and parked +threads use compact rows. Open Settings with the sidebar gear or ⌘,; Browser +Settings also remain available in the View menu. + +The composer discovers provider instances, models, and model options from the +server. The searchable model picker supports account tabs, device-local favorites +scoped to the server, legacy models, and keyboard selection. Search matches model +and account names together, across all account tabs. Open it with ⌘⇧M; use ↑/↓ and +Return to choose a model, or Escape to close. ⌘B toggles the sidebar and ⌘N creates +a thread. Runtime modes and enabled plan mode are editable there. Pending approvals +and agent questions can be answered directly; resolved requests disappear when +another client answers them. Questions support choices, multiple selections, +custom answers, and dismissal when the server advertises it. Command and tool +results omitted from the live feed can be loaded on demand; stale fetches cannot +replace a newer streaming revision. Commands, searches, file changes, and reasoning +collapse into expandable work groups. Expanding preserves the full activity text; +errors and pending questions or approvals stay outside collapsed groups. + +The composer offers workspace files/folders with @, provider skills with $, and +provider commands with /. Suggestions follow the selected account and workspace; +↑/↓ and Return/Tab select, and Escape dismisses. Context usage comes from provider +reports and live turns. Compatible idle conversations can compact older context. + +Settings includes global thread defaults, restart and usage-limit recovery, +inactive-thread settling, notifications, appearance, providers, scheduled prompts, +connections, and storage diagnostics. Appearance applies to this Mac; server +settings apply to the connected environment. Existing project overrides continue +to apply. Account sign-in uses the provider's existing authentication flow. +Model visibility, ordering, and favorites are scoped to the environment on this Mac. +Scheduled prompts run once or at a recurring interval through the shared server scheduler. + +Questions appear one at a time. Write a custom answer or attach files in the main +composer, then use Next or ⌘ Return to advance and submit the final answers. +Previous returns to an earlier answer; the question header collapses its options. +Single-choice options advance automatically, and number keys select visible +options when focus is outside the text editor. Each answer and its files survive +restart, separately from the normal message draft, which returns when the request +closes. Answered files remain available in the conversation. + +Enable the optional Plan control from Chat → Enable Plan Mode. This is a saved +client preference. After a plan finishes, type feedback and choose Refine to stay +in planning mode, or leave the composer empty and choose Implement. The adjacent +menu can start implementation in a new thread with the same model and workspace. +An empty composer shows Resume for an interrupted or usage-limited thread, including +one with a proposed plan, and Stop while a run is active. Typing restores the send +or refinement action. Questions, queued edits, and saved plan-send retries keep +their own actions; ⌘ Return follows the same action as the primary button. +Both implementation paths retain the link to the source plan. If a send loses its connection, +Retry resumes the saved send; its menu can dismiss the retry without stopping +work already accepted by the server. + +Long plan cards start with a compact preview; Expand plan reveals the full text. +Their actions menu copies the complete Markdown, downloads it, or saves it to a +path in the thread’s workspace. Workspace saves use the connected environment, +including a remote server or the thread’s worktree. A failed save keeps the dialog +and entered path available for retry. + +The menu beside Send chooses Auto, Queue next, Steer now, or Interrupt and restart +when the provider supports them. Queued messages can be edited, removed, reordered, +or sent as steering. Stop holds the queue; Resume continues interrupted work and +releases queued messages. Queued edits have separate saved drafts; ⌥↑ at the start +of the composer opens the last queued message and ⌘⇧Return steers the next one. + +Attach images or files with the paperclip or ⌘⇧A, or paste/drop into the message +field. The attachment tray shows uploads, failures, retry, and removal. Drafts keep +private local copies across restarts, and expired uploads are restored from those +copies. HEIC/TIFF and oversized images are converted to JPEG; file and aggregate +image limits follow the server contract. Attachment-only messages are supported. +Received files can be downloaded from the conversation with a native save dialog. +Image attachments have thumbnails, and files open in a Quick Look gallery with +navigation and Save controls. + +Open the thread’s Files panel from its header or ⌘⇧E. Folders load as you expand +them, including ignored files; Search files also finds paths in unopened folders. +Text files open in a native editor with ⌘F find. Edits autosave after a brief pause; +⌘S saves immediately or retries a failed save. Switching files, closing the panel, +and quitting flush pending edits. If the server cannot finish a save during shutdown, +the local draft remains recoverable. Unsaved changes survive file, thread, and server +switches and app restarts; use Unsaved files to recover them. A delayed save keeps +the original workspace even after navigation. Save checks for intervening server +changes; conflicts retain your edits to copy or retry. Reload can discard them and +load the server version. Completed agent commands, file changes, and checkpoints +refresh visited folders and the open file without replacing unsaved text. Truncated +previews and absolute host paths are read-only. + +Markdown files offer Source and Preview; editable checklists save when clicked. +Relative file links open against the file’s directory on the connected server. +CSV and TSV files open as tables, with quoted delimiters and multiline cells preserved. +Table display is limited to 100 rows, 30 columns, and 2,000 characters per cell; +Source retains the complete loaded text. Markdown and table display preferences +are saved separately. ⌘F opens source for searching without changing that preference. +File links in messages, plans and previews open against the connected workspace. +Folder links reveal their ancestors and contents in the file tree. +`:line:column` and `#LlineCcolumn` destinations reveal source without changing +the preview preference; positions beyond the file are clamped to its contents. +Right-click a file or file link, or use Actions in the file toolbar, to copy its +path, open it, reveal it in the host's file manager, or choose an available editor. +Open and reveal use the connected environment's apps and capabilities. +Images and PDFs open in the file panel. Audio and video stream through native +playback controls; Retry refreshes the asset URL and preserves playback position. +HTML files offer Source and Preview, with previews refreshing after autosave. +Save… downloads the original file. Formats macOS cannot decode can be opened +through Actions. Syntax coloring, heading links and workspace content search +remain incomplete. + +Open the terminal drawer with ⌘J or the thread header. Its tabs reuse shells on +the connected server, including shells opened from Electron. With terminal focus, +⌘N opens another shell and ⌘W closes the selected shell. Hiding the drawer or +switching threads leaves shells running; reopening restores their history. Clear +removes saved output, and Restart explicitly starts a new shell after exit. +Closing or restarting warns when a command is running. Copy, paste, selection, +scrolling, Unicode and input methods use the native Ghostty renderer. Split panes +and terminal search remain incomplete. + +Open Changes from the thread header or ⌘⇧G to review uncommitted work or compare +against a branch or revision. Choose a unified or side-by-side diff, filter the +changed-file list, ignore whitespace, wrap long lines, copy a patch, or use ⌘F to +search the selected diff. Split columns stay aligned when wrapping or resizing, +scroll together vertically, and search the focused side. Clicking a +new line number opens that location in the current workspace file. Completed +agent tools refresh the comparison; Refresh also picks up external edits. +Choose Latest turn, a numbered turn, or Full thread to inspect saved checkpoint +changes. Full thread compares the baseline through the latest saved turn. +Changed-file cards below assistant replies open that turn or a particular file; +file selection reuses the fetched comparison. Missing, stale and unfinished +checkpoints show their availability rather than falling back to a different turn. +Links open the current workspace file, which may have changed since the checkpoint. +Workspace comparisons can expand unchanged context inline or for the whole file, +then collapse it again. Context is loaded on demand and checked against the patch; +refresh if the file changed during loading. Copy patch retains the original diff. +Changes and Files share the right side of the window. Large patches are bounded +and labeled when incomplete; complete file totals remain available when supplied +by the server. Syntax coloring and line +annotations remain incomplete. Older pairing sessions need a new pairing token +for review access. + +Open Usage from the sidebar or ⌘⇧U. Limits groups subscription windows by provider, +with individual account bars, reset times, pace and the quota restored by the next +reset. Duplicate accounts from provider instances and usage sources count once; +the freshest read supplies their limits. Click a bar for account details and any +available reset credits; redemption requires confirmation. Failed probes remain +visible as notices. Cost and Tokens show provider charts and model/time breakdowns +for the past 24 hours or 7, 30 and 90 days. Model details separate input, cache and +output tokens; reasoning is included in output. Costs are API equivalents, with +missing prices and incomplete source coverage identified. The metric and period +are saved locally. Refresh updates the selected view; Escape returns to chat. +Usage currently covers the connected environment and its configured accounts. + +Open Browser from the conversation header or ⌘⇧B. The native WebKit panel has +per-conversation tabs, back/forward, an address/search field (⌘L), new tabs (⌘T), +zoom, reload, private tabs, uploads, and screenshot attachments. HTTP links in chat +open in the panel; its menu can open the current page in the default browser. +Tabs synchronize through the existing preview RPCs. Loaded pages keep their form +state when switching tabs or conversations. Browser cookies are separate from +Electron; normal profile cookies persist, while private tabs use a memory-only +store. Empty tabs list servers discovered by the connected environment. +Loopback links resolve to a directly connected LAN/Tailscale host; public relays +need a browser gateway before local previews can work. + +Use the profile button to open a tab with separate cookies and logins. Manage +Profiles, or View → Browser Settings, lets you add and rename profiles, choose a +default, and clear a profile's website data. Removing a profile clears its data; +existing tabs stay open. Settings also saves the default viewport, zoom and +appearance for new pages, where conversation links open, and whether agent +actions show a floating preview. Agents use the same default profile and viewport; +an explicit show/hide request overrides the preview preference. The managed +Chromium option can import the 1Password extension package from a selected browser +profile; it does not copy that browser's cookies, logins, or passwords. + +Browser Settings offers an optional Chromium companion for 1Password. Its live +page opens in a managed floating window, and browser tools operate on that page. +The genuine 1Password extension can be installed from the official Chrome Web Store +or imported from a selected installed profile. Desktop unlock uses 1Password's +[additional-browser setup](https://support.1password.com/additional-browsers/), +which requires an Apple-signed browser in Applications and explicit user trust. +The default ad-hoc development build does not satisfy that signing requirement. +The anonymous welcome screen is checked on the first action and after helper restart. +The browser waits for 1Password to choose onboarding or its account popup before opening it. +Actual account popup, vault unlock and autofill require user verification; the WebKit panel cannot +run Chromium extensions. The two engines have separate cookie stores. + +The device toolbar offers the same viewport presets as Electron, rotation, and +custom CSS dimensions (press Return to apply). Previews fit the panel without +changing responsive breakpoints or reloading the page. These are viewport sizes; +the browser still uses macOS WebKit, without mobile user-agent or touch emulation. +Select an element with the pointer button to add its URL, selector and HTML to the +message draft. Escape cancels; selected links do not navigate. Selection currently +covers the main document and open shadow roots. Pen, rectangle, and arrow annotations +can be saved or attached with their page context. React source attribution and +frame selection remain incomplete. + +Use the download button, or View → Browser Downloads, to see progress, cancel a +download or show its file in Finder. Downloads you start use the native Save +dialog. Agent-triggered downloads save automatically; the downloads window links +to their folder. Downloads continue when a tab closes. Quitting asks before +cancelling active downloads. Existing files are replaced only after a download +finishes, and a failed save keeps the completed temporary file available in Finder. + +Agents can open, navigate, inspect, click, type, press keys, scroll and resize the +native browser through T3's existing browser tools. Background actions preserve +the selected conversation and its browser state; an agent can show a separate +preview window. Stop agent in the browser, or View → Pause Agent Browser, pauses +browser control and cancels queued actions. Resume Agent Browser enables it again. +Typing, clicking or scrolling inside a page also interrupts pending agent actions +for that conversation and returns download handling to the native Save dialog. +Switching tabs or conversations keeps ongoing agent actions attached to their page. +This does not stop the agent's other work or page scripts already running. +Snapshots include a screenshot, accessible page tree, interactive elements and +action history. The cursor animates between agent actions and appears in page +captures. Recordings use H.264 MP4 with a 30/60 fps target, constrained by WebKit +capture throughput, and stop at the time/size limits. Artifacts can be attached to +the composer or uploaded for broker downloads. Console/network diagnostics, +cross-frame locators, and remote JavaScript object handles remain incomplete. + +Connections supports saved Mac environments, local server start/stop, T3 Connect +sign-in, environment discovery, and host linking. It preserves saved external +environments and existing phone/iPad connections. The native host uses separate +userdata at ~/Library/Application Support/T3Native/server and refuses live ~/.t3. +Host linking and authentication remain explicit user actions. + +Markdown supports native headings, +lists, task lists, quotes, code blocks, tables, and selectable links. Copy-message +actions preserve the original Markdown; code blocks have a Copy code context action. +Code blocks have syntax colors, Copy/Wrap controls, and horizontal overflow. +Interface/code fonts, sizes, wrapping, and chat width follow Appearance settings. +Inline images, file/citation chips, math, and diagrams remain incomplete. + +## Build and run + +Requires Apple Silicon and Xcode with Swift 6.2 or later. Swift Package Manager fetches the pinned +Swift Markdown and cmark parser dependencies. Packaging also requires the checkout's +Node and pnpm toolchain and builds the shared server/web client. The app bundles +arm64 Node and a physical runtime dependency closure; it does not need a sibling +checkout or a separately installed Node to launch. +GhosttyKit is vendored for arm64 only; Intel Macs are not supported. Ordinary app +builds need neither Zig nor a Metal compiler. To rebuild GhosttyKit, install the +Xcode Metal toolchain (`xcodebuild -downloadComponent MetalToolchain`) and run +`apps/macos/scripts/build-ghostty.sh`. It pins and verifies Zig 0.16.0 and uses the +Ghostty revision in `Vendor/GHOSTTY_VERSION`, with crash reporting disabled. +Dependency notices ship in the app's Resources/Licenses directory. To rebuild +with modified dependency sources, pass `GHOSTTY_ALLOW_LOCAL_CHANGES=1` to that script. +The browser selector engine is vendored from the desktop's pinned Playwright +version and verified when packaging. After installing workspace dependencies, +`node apps/macos/update-playwright.mjs` regenerates it with its notices and checksum. + +```sh +pnpm install --frozen-lockfile +swift test --package-path apps/macos +apps/macos/build.sh +open 'apps/macos/.build/T3 Native.app' +``` + +The script creates an ad-hoc-signed local app, separate from the installed T3 app. +It is not a notarized distributable. Quit the prototype before rebuilding its bundle. +An optional first argument chooses the output bundle, for example +`apps/macos/build.sh "$HOME/Applications/T3 Native.app"`. +To sign the app and Chromium helper with an installed Apple signing identity, set +`T3_NATIVE_SIGN_IDENTITY` to that identity's name or fingerprint when running the script. +Packaging authenticates the pinned official Electron arm64 archive and compares +every runtime file and symlink before copying it. The original runtime is never re-signed. + +Choose **Connect**, enter the server's base URL and a pairing token from its +Connections settings. Pairing requests `orchestration:read`, +`orchestration:operate`, `terminal:operate`, and `review:write`; the exchanged bearer token is saved in Keychain. Uncheck +the pairing option to use an existing bearer token. HTTPS direct endpoints and +HTTP loopback endpoints are supported. When the session expires, pair again. + +Connecting to a live environment makes all thread actions operate on that environment. +The optional native host owns a separate data directory and never adopts an existing listener. +Use a separately running server with a pruned database copy for development; see +the root AGENTS.md test-data procedure. + +## Integration check + +A private JSON connection file can be used for local tests without saving a token: + +```json +{ "endpoint": "http://127.0.0.1:4773", "token": "YOUR_BEARER_TOKEN" } +``` + +Keep this file outside Git and restrict its permissions (`chmod 600`). +For a normal app launch, add `--remember-connection` alongside `--connection-file` +to save the imported token in Keychain. Subsequent launches reconnect automatically +and restore the last selected thread for that server. Test launches leave saved +credentials unchanged unless this flag is explicitly supplied. + +```sh +apps/macos/.build/release/T3Native --smoke --connection-file /private/path/connection.json +``` + +This reads real shell/thread subscriptions and an older history page. To verify a +real provider reply and idempotent submission, create a disposable project rooted +in an isolated workspace, then add `--send-smoke --sandbox-project PROJECT_ID`. +This submits a short prompt to the server's authenticated Codex provider. It must +not target a project containing ongoing work. + +The parity integration check creates a new thread in the isolated project, checks +model/options and runtime-mode mutations against fresh server reads, and exercises +the app controller's configuration subscription and reconnect behavior: + +```sh +apps/macos/.build/release/T3Native --parity-smoke \ + --connection-file /private/path/connection.json --sandbox-project PROJECT_ID +``` + +Add `--question-smoke` to ask the real Codex provider for a question, answer through +the app controller, and verify the resolved request and completed reply. This +requires an authenticated Codex instance and consumes one short provider turn. +Use `--question-paging-smoke` for two provider turns covering multiple questions, +per-question attachments, paging and restart recovery, attachment-only answers, +exact downloaded bytes, and resolution from another client. `--queue-smoke` +checks queue edits, reorder, cancellation, stop/resume, and draft recovery. +`--plan-smoke` checks real plan generation and refinement, implementation in the +same and a new thread, source-plan links, and restart after a partially committed +send. It uses an isolated plan-mode preference and five short provider turns. +`--plan-card-thread THREAD_ID` reuses one of those isolated plan threads to check +collapsed/expanded text layout and exact local/workspace exports, including +reconnect and invalid-path handling. It writes a uniquely named Markdown file +under `plans/` in the isolated workspace and sends no inference request. +Add `--manual-question` instead to leave the real question pending for a UI check. +Add `--tool-output-smoke` to run a diagnostic `printf`, approve only that exact +command once if requested, and fetch its omitted output through the controller. +An interrupted tool check can resume with `--resume-tool-thread ID` on its isolated +test thread; it does not send another message. +UI runs can use `--thread-id ID` and `--filter TEXT` to select an isolated thread. + +`--terminal-thread THREAD_ID` checks the native terminal transport on an existing +thread in the isolated project. It creates its own temporary directory and shell, +checks input/output, Unicode, resizing, history replay, clearing and restarting, +then closes that shell and deletes its history. It sends no inference request and +requires a bearer token with `terminal:operate`; older native pairing tokens need +to be replaced for this check. It also sends input from the native terminal engine +through the real server PTY. The native test suite checks the renderer's history, +selection, Unicode input and bracketed paste without opening a window. + +`--review-thread THREAD_ID` creates a disposable Git repository and thread in the +isolated project, then checks actual working-tree and branch patches, complete +statistics, renames, deletions, binary/Unicode/CRLF/untracked files, whitespace +filtering, large patches, invalid-ref recovery, navigation and refresh. It sends +no inference request and requires `review:write`. Fixtures remain for UI checking. +If the isolated project's directory lies outside the server's configured workspace, +pass `--review-fixture-root /path/to/isolated-server-base/worktrees` to create the +fixture inside that server's permitted managed-worktree directory. + +`--checkpoint-review-thread THREAD_ID` uses an existing completed sandbox thread +with saved checkpoints. It checks turn and full-thread RPCs, exact per-file patch +copies, current-file access, the timeline entry point, unavailable checkpoints, +local rollback snapshot handling, reconnect and navigation. It sends no inference +and verifies the server's run/checkpoint history remains unchanged. + +For browser verification, run `python3 apps/macos/Tests/Fixtures/browser_server.py` +and use its printed URL with `--parity-smoke --browser-smoke --browser-fixture URL`, +the private connection file, and an isolated sandbox project. This creates +verification threads, loads real HTTP pages in WebKit, and checks server status +echoes, SPA form state, history, popups and PNG capture. It also checks discovered +server buttons, panel sizing, conversation/hide state retention and private-cookie +isolation, trusted agent input, selectors, redirects, hidden screenshots and +conversation isolation, then closes its test tabs. For broker verification, also +run `node apps/macos/Tests/Fixtures/browser_broker.mjs /private/path/broker.json` +and pass `--browser-broker-fixture /private/path/broker.json` to the same smoke. +That fixture uses the real broker with a local RPC adapter to check routing, +ordered actions, cancellation, pause/resume and reconnects. These checks send no +inference. Stop both fixture servers with their captured PIDs after verification. + +## Measure + +```sh +python3 apps/macos/measure.py --pid NATIVE_PID --pid ELECTRON_PID --seconds 15 +``` + +The script samples entire descendant trees and reports summed RSS and CPU time as +a percentage of one core. RSS includes shared pages and is not physical footprint. +Report the backend separately: the native client uses an external server, whereas +the Electron desktop normally owns its server. Use the same thread/history and +idle workloads before comparing. A full-featured desktop and this limited client +are not equivalent workloads. This sampler does not measure cold launch, input +latency, scrolling frame times, or energy. + +Use `--exclude-pid BACKEND_PID` to exclude a verified backend subtree from the +desktop client sample. Record that exclusion and measure the backend separately. + +## Protocol constraints + +The Swift adapter implements the existing Effect JSON RPC framing, WebSocket +tickets, stream acknowledgements, cancellation, and protocol-v2 snapshots/events. +It deliberately preserves the existing desktop `creationSource: "web"` value. +No new server contract is introduced. Changes to those contracts need a native +smoke run as well as the reducer tests. + +One reconnect task owns the session. Reconnect reloads authoritative bounded +snapshots; cached text remains visible while offline. Mutations are never replayed +automatically. An unchanged draft retry within this process reuses the command and +message IDs, including after an app restart. Draft text, question answers, and +pending submission IDs are stored atomically under Application Support/T3 Native, +separated by server address. The files are private to the current macOS user. +Switching servers or quitting does not discard a draft that failed to save without +an explicit choice to quit without saving. + +Use `--parity-smoke --workspace-thread THREAD_ID` with an existing isolated +“Native plan verification” thread to check workspace files without inference. +It creates uniquely named fixture files and a second thread with an isolated +workspace, then checks lazy listing, ignored entries, path search, autosave, exact +text bytes, recovery after restart, typing/undo during a write, navigation with a +pending save, conflicts, reconnect retry, mutation refresh, and truncated/binary +previews. Fixtures remain available for UI verification. Add +`--workspace-provider-smoke` to also have the real provider change an open file +and verify automatic refresh from its tool/checkpoint events. That option consumes +one short provider turn in the newly created verification thread. diff --git a/apps/macos/Sources/T3Core/AttachmentPreviews.swift b/apps/macos/Sources/T3Core/AttachmentPreviews.swift new file mode 100644 index 0000000000000..196c287dc822a --- /dev/null +++ b/apps/macos/Sources/T3Core/AttachmentPreviews.swift @@ -0,0 +1,173 @@ +import Foundation +import ImageIO + +public struct AttachmentPreviewRequest: Sendable { + public enum Key: Hashable, Sendable { + case local(URL) + case remote(environment: String, attachmentID: String) + } + public let key: Key + public let name: String + public let load: @Sendable (URL) async throws -> Void + + public init(key: Key, name: String, load: @escaping @Sendable (URL) async throws -> Void) { + self.key = key + self.name = name + self.load = load + } + + public static func local(_ url: URL, name: String) -> Self { + Self(key: .local(url), name: name) { destination in + try FileManager.default.copyItem(at: url, to: destination) + } + } + + public static func remote( + _ attachment: JSON, connection: RPCConnection, settings: ConnectionSettings + ) throws -> Self { + Self( + key: .remote( + environment: settings.endpoint.absoluteString, + attachmentID: try attachment.requiredString("id")), + name: attachment["name"].string ?? "Attachment" + ) { destination in + let issued = try await connection.call( + "assets.createUrl", + payload: .object([ + "resource": .object([ + "_tag": .string("attachment"), "attachmentId": attachment["id"], + "fileName": attachment["name"], "mimeType": attachment["mimeType"], + ]) + ])) + let url = try AttachmentHTTP.signedURL( + issued.requiredString("relativeUrl"), endpoint: settings.endpoint, prefix: "/api/assets/") + try await AttachmentHTTP.download(url, to: destination) + } + } +} + +private final class PreviewDirectory: Sendable { + let url: URL + init(_ url: URL) { self.url = url } + deinit { try? FileManager.default.removeItem(at: url) } +} + +/// A visible preview keeps its private file alive even after cache eviction. +public final class AttachmentPreviewFile: Sendable { + public let url: URL + public let sizeBytes: Int + private let directory: PreviewDirectory + private let root: PreviewDirectory + fileprivate init(url: URL, sizeBytes: Int, directory: PreviewDirectory, root: PreviewDirectory) { + self.url = url + self.sizeBytes = sizeBytes + self.directory = directory + self.root = root + } + + public func save(to destination: URL) async throws { + try Data(contentsOf: url, options: .mappedIfSafe).write(to: destination, options: .atomic) + } +} + +public actor AttachmentPreviewCache { + private struct Entry { + var file: AttachmentPreviewFile + var thumbnail: CGImage? + var used: UInt64 + } + private let root: PreviewDirectory + private let byteLimit: Int + private let countLimit: Int + private var entries: [AttachmentPreviewRequest.Key: Entry] = [:] + private var jobs: [AttachmentPreviewRequest.Key: Task] = [:] + private var clock: UInt64 = 0 + private var active = 0 + private var waiting: [CheckedContinuation] = [] + + public init(directory: URL? = nil, byteLimit: Int = 128 * 1024 * 1024, countLimit: Int = 64) { + root = PreviewDirectory( + (directory ?? FileManager.default.temporaryDirectory) + .appendingPathComponent("t3-previews-" + UUID().uuidString)) + self.byteLimit = max(AttachmentPolicy.fileLimit, byteLimit) + self.countLimit = max(1, countLimit) + } + + public func file(for request: AttachmentPreviewRequest) async throws -> AttachmentPreviewFile { + try Task.checkCancellation() + clock &+= 1 + if var entry = entries[request.key] { + entry.used = clock + entries[request.key] = entry + return entry.file + } + if let job = jobs[request.key] { return try await job.value } + guard jobs.count < 32 else { throw T3Error("Other previews are loading. Try again shortly.") } + let job = Task { try await self.load(request) } + jobs[request.key] = job + return try await job.value + } + + public func thumbnail(for request: AttachmentPreviewRequest) async throws -> CGImage { + let file = try await file(for: request) + try Task.checkCancellation() + if let thumbnail = entries[request.key]?.thumbnail { return thumbnail } + guard + let source = CGImageSourceCreateWithURL( + file.url as CFURL, [kCGImageSourceShouldCache: false] as CFDictionary), + let image = CGImageSourceCreateThumbnailAtIndex( + source, 0, + [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceShouldCacheImmediately: true, + kCGImageSourceThumbnailMaxPixelSize: 512, + ] as CFDictionary) + else { throw T3Error("This image could not be previewed. You can still download the file.") } + entries[request.key]?.thumbnail = image + return image + } + + private func load(_ request: AttachmentPreviewRequest) async throws -> AttachmentPreviewFile { + if active >= 2 { + await withCheckedContinuation { waiting.append($0) } + } else { + active += 1 + } + defer { + jobs.removeValue(forKey: request.key) + if waiting.isEmpty { active -= 1 } else { waiting.removeFirst().resume() } + } + try Task.checkCancellation() + let directory = PreviewDirectory(root.url.appendingPathComponent(UUID().uuidString)) + try FileManager.default.createDirectory( + at: root.url, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700]) + try FileManager.default.createDirectory( + at: directory.url, withIntermediateDirectories: false, attributes: [.posixPermissions: 0o700]) + let component = (request.name as NSString).lastPathComponent + let cleaned = String( + component.unicodeScalars.filter { !CharacterSet.controlCharacters.contains($0) }) + let name = + cleaned.isEmpty || cleaned == "." || cleaned == ".." + ? "Attachment" : String(cleaned.prefix(240)) + let url = directory.url.appendingPathComponent(name) + try await request.load(url) + let values = try url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]) + guard values.isRegularFile == true, let size = values.fileSize, + size > 0, size <= AttachmentPolicy.fileLimit + else { + throw T3Error("This file cannot be previewed. It must contain between 1 byte and 50 MiB.") + } + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) + let file = AttachmentPreviewFile(url: url, sizeBytes: size, directory: directory, root: root) + clock &+= 1 + entries[request.key] = Entry(file: file, used: clock) + var bytes = entries.values.reduce(0) { $0 + $1.file.sizeBytes } + while entries.count > countLimit || bytes > byteLimit { + guard let oldest = entries.min(by: { $0.value.used < $1.value.used }) else { break } + bytes -= oldest.value.file.sizeBytes + entries.removeValue(forKey: oldest.key) + } + return file + } +} diff --git a/apps/macos/Sources/T3Core/Attachments.swift b/apps/macos/Sources/T3Core/Attachments.swift new file mode 100644 index 0000000000000..a6277dd34af97 --- /dev/null +++ b/apps/macos/Sources/T3Core/Attachments.swift @@ -0,0 +1,243 @@ +import Foundation +import ImageIO +import UniformTypeIdentifiers + +public struct DraftAttachment: Codable, Sendable, Equatable, Identifiable { + public let id: String + public let name: String + public let mimeType: String + public let sizeBytes: Int + public let type: String + public var uploadedID: String? + + public var uploadInput: JSON { + .object([ + "type": .string(type), "name": .string(name), "mimeType": .string(mimeType), + "sizeBytes": .number(Double(sizeBytes)), + ]) + } + public var wire: JSON? { + guard let uploadedID, case .object(var fields) = uploadInput else { return nil } + fields["id"] = .string(uploadedID) + return .object(fields) + } +} + +public struct AttachmentPolicy: Sendable, Equatable { + public static let imageLimit = 10 * 1024 * 1024 + public static let fileLimit = 50 * 1024 * 1024 + public var uploads = false + public var questions = false + public var fileBytes: Int? + public init(_ capabilities: JSON = .null) { + uploads = capabilities["attachmentUploads"].bool + questions = capabilities["questionAttachments"].bool + fileBytes = capabilities["fileAttachments"]["maxUploadBytes"].int.map { + min($0, Self.fileLimit) + } + } + public func validate( + _ attachments: [DraftAttachment], requireServer: Bool = true, retained: [JSON] = [] + ) throws { + let newCount = attachments.count + let attachments = + attachments + + retained.map { + DraftAttachment( + id: $0["id"].string ?? "", name: $0["name"].string ?? "Attachment", + mimeType: $0["mimeType"].string ?? "", sizeBytes: $0["sizeBytes"].int ?? 0, + type: $0["type"].string ?? "file") + } + guard attachments.count <= 100 else { + throw T3Error("You can attach up to 100 files per message.") + } + guard + attachments.filter({ $0.type == "image" }).reduce(0, { $0 + $1.sizeBytes }) <= 80 * 1024 + * 1024 + else { + throw T3Error("Images can total up to 80 MiB per message. Send fewer or smaller images.") + } + if requireServer, newCount > 0, !uploads { + throw T3Error("This server does not accept attachment uploads right now.") + } + for (index, attachment) in attachments.enumerated() { + let requiresUpload = requireServer && index < newCount + if requiresUpload, attachment.type == "file", fileBytes == nil { + throw T3Error( + "This server does not accept file attachments right now. Remove the files to send.") + } + let limit = + attachment.type == "image" + ? Self.imageLimit : requiresUpload ? fileBytes ?? 0 : Self.fileLimit + guard attachment.sizeBytes > 0, attachment.sizeBytes <= limit else { + throw T3Error( + "\(attachment.name) exceeds the attachment limit (\(ByteCountFormatter.string(fromByteCount: Int64(limit), countStyle: .file)))." + ) + } + } + } +} + +/// Immutable private copies keep drafts independent of moved or edited source files. +public struct AttachmentRepository: Sendable { + public let directory: URL + public init(directory: URL) { self.directory = directory } + + public func file(_ attachment: DraftAttachment) throws -> URL { + guard UUID(uuidString: attachment.id) != nil else { + throw T3Error("Invalid local attachment ID.") + } + return directory.appendingPathComponent(attachment.id) + } + + public func stage(_ source: URL) throws -> DraftAttachment { + let values = try source.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]) + guard source.isFileURL, values.isRegularFile == true else { + throw T3Error("Choose files, not folders.") + } + guard let size = values.fileSize, size > 0, size <= AttachmentPolicy.fileLimit else { + throw T3Error("\(source.lastPathComponent) must contain between 1 byte and 50 MiB.") + } + var name = String(source.lastPathComponent.prefix(255)) + let contentType = UTType(filenameExtension: source.pathExtension) + var mime = contentType?.preferredMIMEType ?? "application/octet-stream" + let isImage = contentType?.conforms(to: .image) == true + let supported = ["image/png", "image/jpeg", "image/gif", "image/webp"].contains(mime) + let convertible = ["heic", "heif", "tif", "tiff"].contains(source.pathExtension.lowercased()) + if isImage && !supported && !convertible { + throw T3Error("\(name) is not a supported image. Use PNG, JPEG, GIF, WebP, HEIC, or TIFF.") + } + try FileManager.default.createDirectory( + at: directory, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700]) + let id = UUID().uuidString + let destination = directory.appendingPathComponent(id) + do { + if isImage && (convertible || size > AttachmentPolicy.imageLimit) { + guard let imageSource = CGImageSourceCreateWithURL(source as CFURL, nil), + let image = CGImageSourceCreateThumbnailAtIndex( + imageSource, 0, + [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: 2048, + ] as CFDictionary), + let output = CGImageDestinationCreateWithURL( + destination as CFURL, UTType.jpeg.identifier as CFString, 1, nil) + else { throw T3Error("Could not read \(name) as an image.") } + CGImageDestinationAddImage( + output, image, [kCGImageDestinationLossyCompressionQuality: 0.85] as CFDictionary) + guard CGImageDestinationFinalize(output) else { + throw T3Error("Could not convert \(name).") + } + mime = "image/jpeg" + name = String(source.deletingPathExtension().lastPathComponent.prefix(250)) + ".jpg" + } else { + try FileManager.default.copyItem(at: source, to: destination) + } + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], ofItemAtPath: destination.path) + let copiedSize = try destination.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? 0 + let result = DraftAttachment( + id: id, name: name, mimeType: mime, sizeBytes: copiedSize, + type: isImage ? "image" : "file") + try AttachmentPolicy().validate([result], requireServer: false) + return result + } catch { + try? FileManager.default.removeItem(at: destination) + throw error + } + } + + public func remove(_ attachment: DraftAttachment) { + if let url = try? file(attachment) { try? FileManager.default.removeItem(at: url) } + } +} + +private final class AttachmentTransferDelegate: NSObject, URLSessionTaskDelegate, + @unchecked Sendable +{ + let progress: @Sendable (Double) -> Void + private let lock = NSLock() + private var lastProgress = -1 + init(progress: @escaping @Sendable (Double) -> Void) { self.progress = progress } + func urlSession( + _ session: URLSession, task: URLSessionTask, didSendBodyData bytesSent: Int64, + totalBytesSent: Int64, totalBytesExpectedToSend: Int64 + ) { + guard totalBytesExpectedToSend > 0 else { return } + let step = Int(Double(totalBytesSent) / Double(totalBytesExpectedToSend) * 20) + let changed = lock.withLock { + guard step != lastProgress else { return false } + lastProgress = step + return true + } + if changed { progress(Double(step) / 20) } + } + func urlSession( + _ session: URLSession, task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, + completionHandler: @escaping @Sendable (URLRequest?) -> Void + ) { + completionHandler(nil) + } +} + +public enum AttachmentHTTP { + public static func signedURL(_ relative: String, endpoint: URL, prefix: String) throws -> URL { + guard let parts = URLComponents(string: relative), parts.scheme == nil, parts.host == nil, + parts.user == nil, parts.password == nil, parts.fragment == nil, + parts.path.hasPrefix(prefix), !parts.path.contains(".."), + let url = URL(string: relative, relativeTo: endpoint)?.absoluteURL, + url.scheme == endpoint.scheme, url.host == endpoint.host, url.port == endpoint.port + else { throw T3Error("The server returned an invalid attachment address.") } + return url + } + public static func upload( + _ attachment: DraftAttachment, from file: URL, to url: URL, + progress: @escaping @Sendable (Double) -> Void + ) async throws { + guard try file.resourceValues(forKeys: [.fileSizeKey]).fileSize == attachment.sizeBytes else { + throw T3Error("The saved attachment is missing or changed. Remove it and attach it again.") + } + let session = URLSession(configuration: .ephemeral) + defer { session.invalidateAndCancel() } + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.timeoutInterval = 120 + request.setValue(attachment.mimeType, forHTTPHeaderField: "Content-Type") + let (_, response) = try await session.upload( + for: request, fromFile: file, + delegate: AttachmentTransferDelegate(progress: progress)) + guard let response = response as? HTTPURLResponse, (200..<300).contains(response.statusCode) + else { + throw T3Error("Attachment upload failed. Retry the upload or remove the file.") + } + } + + public static func download( + _ url: URL, to destination: URL, maximumBytes: Int? = AttachmentPolicy.fileLimit + ) async throws { + let session = URLSession(configuration: .ephemeral) + defer { session.invalidateAndCancel() } + let (temporary, response) = try await session.download( + for: URLRequest(url: url), + delegate: AttachmentTransferDelegate(progress: { _ in })) + guard let response = response as? HTTPURLResponse, (200..<300).contains(response.statusCode) + else { + throw T3Error("The attachment could not be downloaded. Try again when connected.") + } + defer { try? FileManager.default.removeItem(at: temporary) } + guard let size = try temporary.resourceValues(forKeys: [.fileSizeKey]).fileSize, + maximumBytes.map({ size <= $0 }) ?? true + else { throw T3Error("The attachment exceeds the download limit.") } + let staging = destination.deletingLastPathComponent().appendingPathComponent( + ".t3-download-" + UUID().uuidString) + defer { try? FileManager.default.removeItem(at: staging) } + try FileManager.default.copyItem(at: temporary, to: staging) + if FileManager.default.fileExists(atPath: destination.path) { + _ = try FileManager.default.replaceItemAt(destination, withItemAt: staging) + } else { + try FileManager.default.moveItem(at: staging, to: destination) + } + } +} diff --git a/apps/macos/Sources/T3Core/BackendConfiguration.swift b/apps/macos/Sources/T3Core/BackendConfiguration.swift new file mode 100644 index 0000000000000..9470dae71df73 --- /dev/null +++ b/apps/macos/Sources/T3Core/BackendConfiguration.swift @@ -0,0 +1,96 @@ +import Foundation + +public struct BackendConfiguration: Codable, Equatable, Sendable { + public var home: URL + public var port: Int + public init(home: URL, port: Int = 3774) { + self.home = home + self.port = port + } + public static var defaultHome: URL { + FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("T3Native/server", isDirectory: true) + } + public var endpoint: URL { URL(string: "http://127.0.0.1:\(port)")! } + public func validate(userHome: URL = FileManager.default.homeDirectoryForCurrentUser) throws { + guard (1...65535).contains(port), home.isFileURL, home.path.hasPrefix("/") else { + throw T3Error("Use an absolute data directory and a port between 1 and 65535.") + } + let actual = home.standardizedFileURL.resolvingSymlinksInPath().path + let live = userHome.appendingPathComponent(".t3").standardizedFileURL.resolvingSymlinksInPath() + .path + // A fresh native host must never contend with the running Electron install's database. + guard actual != live, !actual.hasPrefix(live + "/"), actual != userHome.path, actual != "/" + else { + throw T3Error( + "Choose a separate native server directory. The live ~/.t3 directory is in use.") + } + } + public func bootstrap(token: String) throws -> Data { + try validate() + return try JSON.object([ + "mode": .string("desktop"), "noBrowser": .bool(true), + "port": .number(Double(port)), "t3Home": .string(home.path), "host": .string("127.0.0.1"), + "desktopBootstrapToken": .string(token), "tailscaleServeEnabled": .bool(false), + "tailscaleServePort": .number(443), + ]).data() + Data([10]) + } + public static func childEnvironment( + _ inherited: [String: String], home: URL, + bundledNode: URL? = nil, userHome: URL = FileManager.default.homeDirectoryForCurrentUser + ) -> [String: String] { + var env = inherited + for key in [ + "T3CODE_PORT", "T3CODE_MODE", "T3CODE_NO_BROWSER", "T3CODE_HOST", + "T3CODE_HOME", "T3CODE_BOOTSTRAP_FD", "T3CODE_DESKTOP_WS_URL", "T3CODE_DESKTOP_LAN_ACCESS", + "T3CODE_DESKTOP_LAN_HOST", "T3CODE_DESKTOP_HTTPS_ENDPOINTS", "T3CODE_TAILSCALE_SERVE", + "T3CODE_TAILSCALE_SERVE_PORT", "T3CODE_DEV_AUTH_TOKEN", "VITE_DEV_SERVER_URL", + "VITE_HTTP_URL", "VITE_WS_URL", "NODE_OPTIONS", "ELECTRON_RUN_AS_NODE", + ] { env.removeValue(forKey: key) } + env["T3CODE_HOME"] = home.path + // Finder does not load shell profiles. Provider scripts using /usr/bin/env node + // must also resolve the packaged Node rather than require a separate install. + var candidates = bundledNode.map { [$0.deletingLastPathComponent().path] } ?? [] + candidates += [".local/bin", ".bun/bin", ".local/share/mise/shims", ".npm-global/bin"] + .map { userHome.appendingPathComponent($0).path } + candidates += ["/opt/homebrew/bin", "/usr/local/bin"] + candidates += (env["PATH"] ?? "").split(separator: ":").map(String.init) + candidates += ["/usr/bin", "/bin", "/usr/sbin", "/sbin"] + var seen = Set() + let entries = candidates.filter { seen.insert($0).inserted } + env["PATH"] = entries.joined(separator: ":") + return env + } +} + +public struct BackendRuntime: Sendable { + public let node: URL + public let serverEntry: URL + public init(node: URL, serverEntry: URL) { + self.node = node + self.serverEntry = serverEntry + } + public static func bundled(resources: URL) -> Self { + Self( + node: resources.appendingPathComponent("backend/node/bin/node"), + serverEntry: resources.appendingPathComponent("backend/server/apps/server/dist/bin.mjs")) + } + public func validate() throws { + #if !arch(arm64) + throw T3Error("T3 Native requires Apple Silicon.") + #else + guard FileManager.default.isExecutableFile(atPath: node.path), + FileManager.default.fileExists(atPath: serverEntry.path) + else { + throw T3Error( + "The bundled Node/server runtime is missing. Rebuild the app with its backend assets.") + } + let handle = try FileHandle(forReadingFrom: node) + defer { try? handle.close() } + let header = try handle.read(upToCount: 8) ?? Data() + guard header == Data([0xcf, 0xfa, 0xed, 0xfe, 0x0c, 0x00, 0x00, 0x01]) else { + throw T3Error("Package the Apple Silicon arm64 Node runtime for this native app.") + } + #endif + } +} diff --git a/apps/macos/Sources/T3Core/BackendRuntimeSmoke.swift b/apps/macos/Sources/T3Core/BackendRuntimeSmoke.swift new file mode 100644 index 0000000000000..50ccd009d0f76 --- /dev/null +++ b/apps/macos/Sources/T3Core/BackendRuntimeSmoke.swift @@ -0,0 +1,166 @@ +import Darwin +import Foundation + +/// Explicit packaging validation. This never uses preferences or Keychain. +public enum BackendRuntimeSmoke { + public struct Report: Codable, Sendable { + public let home: String + public let port: Int + public let processID: Int32 + public let environmentID: String + public let authenticatedSession: Bool + public let webIndexMatchesBundle: Bool + public let healthStatus: Int + public let healthIsHTML: Bool + public let childStopped: Bool + public let exitStatus: Int32? + } + + @MainActor public static func run(resources: URL, newHome: URL) async throws -> Report { + let runtime = BackendRuntime.bundled(resources: resources) + try runtime.validate() + let root = newHome.standardizedFileURL.resolvingSymlinksInPath() + let temporaryRoots = [NSTemporaryDirectory(), "/tmp"].map { + URL(fileURLWithPath: $0).resolvingSymlinksInPath().path + } + guard root.isFileURL, temporaryRoots.contains(where: { root.path.hasPrefix($0 + "/") }), + !FileManager.default.fileExists(atPath: root.path) + else { + throw T3Error( + "Runtime smoke validation requires a new directory under the system temporary directory.") + } + let configuration = BackendConfiguration(home: root, port: try freePort()) + try configuration.validate() + try FileManager.default.createDirectory( + at: root, withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700]) + let userHome = root.appendingPathComponent("probe-user", isDirectory: true) + try FileManager.default.createDirectory( + at: userHome, withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700]) + var environment = BackendConfiguration.childEnvironment( + ProcessInfo.processInfo.environment, + home: root, bundledNode: runtime.node) + environment["HOME"] = userHome.path + environment["XDG_CONFIG_HOME"] = userHome.appendingPathComponent(".config").path + environment["XDG_DATA_HOME"] = userHome.appendingPathComponent(".local/share").path + environment["XDG_CACHE_HOME"] = userHome.appendingPathComponent(".cache").path + let child = OwnedBackendProcess() + var connection: RPCConnection? + do { + let secret = ConnectionProof.base64url(ConnectionProof().rawRepresentation) + try child.start( + executable: runtime.node, + arguments: [runtime.serverEntry.path, "--bootstrap-fd", "0"], directory: root, + environment: environment, input: configuration.bootstrap(token: secret)) + let settings = try await waitForSession( + child: child, endpoint: configuration.endpoint, secret: secret) + let auth = try await ConnectionHTTP.shared.read(settings.request("/api/auth/session")) + guard auth["authenticated"].bool else { + throw T3Error("The runtime session was not authenticated.") + } + let rpc = RPCConnection(settings: settings) + connection = rpc + try await rpc.open() + let config = try await rpc.call("server.getConfig", payload: .object([:])) + let environmentID = try config["environment"].requiredString("environmentId") + let keybindings = try config.requiredString("keybindingsConfigPath") + guard keybindings.hasPrefix(root.path + "/") else { + throw T3Error("The packaged server did not use the isolated data directory.") + } + let (index, response) = try await get( + configuration.endpoint.appendingPathComponent("index.html")) + let bundledIndex = try Data( + contentsOf: runtime.serverEntry.deletingLastPathComponent() + .appendingPathComponent("client/index.html")) + guard response.statusCode == 200, index == bundledIndex else { + throw T3Error("The packaged web index did not match the served index.") + } + let (health, healthResponse) = try await get( + configuration.endpoint.appendingPathComponent("health")) + await rpc.close() + connection = nil + let pid = child.processIdentifier! + try await stop(child) + return Report( + home: root.path, port: configuration.port, processID: pid, + environmentID: environmentID, authenticatedSession: true, webIndexMatchesBundle: true, + healthStatus: healthResponse.statusCode, + healthIsHTML: String(decoding: health.prefix(512), as: UTF8.self).lowercased().contains( + " ConnectionSettings { + let deadline = Date().addingTimeInterval(60) + while Date() < deadline { + try Task.checkCancellation() + guard child.isRunning else { + throw T3Error( + "The packaged backend exited before authentication (exit \(child.exitStatus ?? -1)).") + } + do { + let session = try await ConnectionHTTP.exchange(endpoint: endpoint, credential: secret) + try Task.checkCancellation() + guard child.isRunning else { + throw T3Error("The packaged backend exited after authentication.") + } + return session + } catch is CancellationError { throw CancellationError() } catch let error as T3Error + where error.authentication + { throw error } catch {} + try await Task.sleep(for: .milliseconds(200)) + } + throw T3Error("The packaged backend did not authenticate within 60 seconds.") + } + + private static func get(_ url: URL) async throws -> (Data, HTTPURLResponse) { + var request = URLRequest(url: url) + request.timeoutInterval = 10 + let (data, response) = try await ConnectionHTTP.shared.session.data(for: request) + guard let response = response as? HTTPURLResponse else { + throw T3Error("Invalid runtime HTTP response.") + } + return (data, response) + } + + @MainActor private static func stop(_ child: OwnedBackendProcess) async throws { + child.terminate() + let deadline = Date().addingTimeInterval(10) + while child.isRunning, Date() < deadline { try? await Task.sleep(for: .milliseconds(100)) } + // Only the still-running captured Process may be escalated during a smoke probe. + if child.isRunning, let pid = child.processIdentifier { _ = Darwin.kill(pid, SIGKILL) } + let forcedDeadline = Date().addingTimeInterval(5) + while child.isRunning, Date() < forcedDeadline { + try? await Task.sleep(for: .milliseconds(100)) + } + guard !child.isRunning else { throw T3Error("The owned runtime smoke child did not stop.") } + } + + private static func freePort() throws -> Int { + let fd = socket(AF_INET, SOCK_STREAM, 0) + guard fd >= 0 else { throw T3Error("Could not reserve a smoke probe port.") } + defer { close(fd) } + var address = sockaddr_in() + address.sin_len = UInt8(MemoryLayout.size) + address.sin_family = sa_family_t(AF_INET) + address.sin_addr.s_addr = inet_addr("127.0.0.1") + var length = socklen_t(MemoryLayout.size) + let result = withUnsafeMutablePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { socketAddress in + guard bind(fd, socketAddress, length) == 0 else { return Int32(-1) } + return getsockname(fd, socketAddress, &length) + } + } + guard result == 0 else { throw T3Error("Could not allocate a free loopback smoke probe port.") } + return Int(UInt16(bigEndian: address.sin_port)) + } +} diff --git a/apps/macos/Sources/T3Core/Browser.swift b/apps/macos/Sources/T3Core/Browser.swift new file mode 100644 index 0000000000000..86d71f3bc1c76 --- /dev/null +++ b/apps/macos/Sources/T3Core/Browser.swift @@ -0,0 +1,174 @@ +import Foundation + +public enum BrowserURL { + public static func normalize(_ text: String) throws -> URL { + let text = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty, text.utf8.count <= 2_048 else { + throw T3Error("Enter a URL of at most 2,048 characters.") + } + let local = URLComponents(string: "http://" + text)?.host.map(loopback) ?? false + let candidate = text.contains("://") ? text : (local ? "http://" : "https://") + text + guard var parts = URLComponents(string: candidate), let scheme = parts.scheme?.lowercased(), + ["https", "http"].contains(scheme), let host = parts.host, !host.isEmpty, + !host.contains(where: { $0.isWhitespace }), parts.user == nil, parts.password == nil, + parts.port.map({ (1...65_535).contains($0) }) ?? true + else { throw T3Error("Enter an HTTP or HTTPS URL without embedded credentials.") } + if parts.path.isEmpty { parts.path = "/" } + guard let url = parts.url else { throw T3Error("This URL could not be opened.") } + return url + } + + public static func loopback(_ host: String) -> Bool { + let host = host.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: "[].")) + return ["localhost", "::1", "::", "0.0.0.0"].contains(host) || ipv4(host)?.first == 127 + } + + private static func ipv4(_ host: String) -> [Int]? { + let tokens = host.split(separator: ".", omittingEmptySubsequences: false) + guard tokens.count == 4, tokens.allSatisfy({ !$0.isEmpty && $0.allSatisfy(\.isNumber) }) else { + return nil + } + let parts = tokens.compactMap { Int($0) } + return parts.count == 4 && parts.allSatisfy({ (0...255).contains($0) }) ? parts : nil + } + + /// A loopback link names a server on the connected environment, not necessarily this Mac. + public static func resolve(_ url: URL, environment: URL) throws -> URL { + guard loopback(url.host ?? "") else { return url } + guard let host = environment.host, privateHost(host), + var parts = URLComponents(url: url, resolvingAgainstBaseURL: false) + else { + throw T3Error( + "This local server needs a direct LAN or Tailscale connection to its environment.") + } + parts.host = + loopback(host) ? "localhost" : host.contains(":") && !host.hasPrefix("[") ? "[\(host)]" : host + guard let resolved = parts.url else { + throw T3Error("Could not resolve the environment’s local server.") + } + return resolved + } + + private static func privateHost(_ host: String) -> Bool { + let host = host.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: "[].")) + if loopback(host) + || [".local", ".localhost", ".home.arpa", ".ts.net"].contains(where: host.hasSuffix) + || host == "home.arpa" || !host.contains(".") && !host.contains(":") + { + return true + } + if let parts = ipv4(host) { + return [0, 10, 127].contains(parts[0]) || parts[0] == 192 && parts[1] == 168 + || parts[0] == 172 && (16...31).contains(parts[1]) + || parts[0] == 100 && (64...127).contains(parts[1]) + || parts[0] == 169 && parts[1] == 254 || parts[0] == 198 && (18...19).contains(parts[1]) + } + if host.contains(":"), + let first = host.split(separator: ":").first.flatMap({ Int($0, radix: 16) }) + { + return first & 0xfe00 == 0xfc00 || first & 0xffc0 == 0xfe80 + } + return false + } +} + +public struct BrowserTab: Sendable, Equatable, Identifiable { + public let id: String + public let threadID: String + public let status: JSON + public let viewport: JSON + public let profileID: String? + public var url: URL? { status["url"].string.flatMap { try? BrowserURL.normalize($0) } } + public var title: String { + let title = status["title"].string ?? "" + return title.isEmpty ? url?.host ?? "Browser" : title + } + public init(_ value: JSON) throws { + id = try value.requiredString("tabId") + threadID = try value.requiredString("threadId") + status = value["navStatus"] + viewport = value["viewport"] + profileID = value["profileId"].string + } +} + +public struct BrowserDirectory: Sendable { + public let threadID: String + public private(set) var tabs: [BrowserTab] = [] + public private(set) var epoch: String? + public private(set) var revision = 0 + private var pending: [JSON] = [] + public init(threadID: String) { self.threadID = threadID } + + public mutating func snapshot(_ value: JSON) throws { + let epoch = try value.requiredString("serverEpoch") + let revision = try value.requiredInt("revision") + guard self.epoch != epoch || revision >= self.revision else { return } + tabs = try value["sessions"].array.filter { $0["threadId"].string == threadID }.map( + BrowserTab.init) + self.epoch = epoch + self.revision = revision + let buffered = pending + pending = [] + for event in buffered { try apply(event) } + } + + @discardableResult public mutating func apply(_ event: JSON) throws -> Bool { + guard event["threadId"].string == threadID else { return false } + guard let epoch else { + guard pending.count < 256 else { + throw T3Error("Browser updates overflowed. Reopen Browser to reconnect.") + } + pending.append(event) + return false + } + guard event["serverEpoch"].string == epoch else { + throw T3Error("The browser server restarted. Reopen Browser to reconnect.") + } + let revision = try event.requiredInt("revision") + guard revision > self.revision else { return false } + self.revision = revision + let id = try event.requiredString("tabId") + switch event["type"].string { + case "opened", "navigated", "resized": + let tab = try BrowserTab(event["snapshot"]) + guard tab.threadID == threadID else { return false } + if let index = tabs.firstIndex(where: { $0.id == tab.id }) { + tabs[index] = tab + } else { + tabs.append(tab) + } + case "closed": tabs.removeAll { $0.id == id } + case "failed": + if let index = tabs.firstIndex(where: { $0.id == id }) { + let old = tabs[index] + tabs[index] = try BrowserTab( + .object([ + "tabId": .string(id), "threadId": .string(threadID), "viewport": old.viewport, + "profileId": old.profileID.map(JSON.string) ?? .null, + "navStatus": .object([ + "_tag": .string("LoadFailed"), "url": event["url"], "title": event["title"], + "code": event["code"], "description": event["description"], + ]), + ])) + } + default: return false + } + return true + } +} + +public struct BrowserLocalServer: Sendable, Equatable, Identifiable { + public let url: URL + public let process: String + public let threadID: String? + public var id: String { url.absoluteString } + public init?(_ value: JSON) { + guard let text = value["url"].string, let url = try? BrowserURL.normalize(text) else { + return nil + } + self.url = url + process = value["processName"].string ?? "Local server" + threadID = value["terminal"]["threadId"].string + } +} diff --git a/apps/macos/Sources/T3Core/BrowserAnnotations.swift b/apps/macos/Sources/T3Core/BrowserAnnotations.swift new file mode 100644 index 0000000000000..52a6aa31b231d --- /dev/null +++ b/apps/macos/Sources/T3Core/BrowserAnnotations.swift @@ -0,0 +1,64 @@ +import Foundation + +public struct BrowserAnnotationPoint: Codable, Equatable, Sendable { + public var x: Double + public var y: Double + public init(x: Double, y: Double) { + self.x = x + self.y = y + } +} +public struct BrowserAnnotation: Codable, Equatable, Sendable { + public enum Tool: String, Codable, Sendable { case pen, rectangle, arrow } + public var tool: Tool + public var points: [BrowserAnnotationPoint] + public var width: Double + public init(tool: Tool, points: [BrowserAnnotationPoint], width: Double = 3) { + self.tool = tool + self.points = points + self.width = width + } +} +public struct BrowserAnnotationDocument: Codable, Equatable, Sendable { + public let version: Int + public let pageURL: String + public let capturedAt: Date + public var annotations: [BrowserAnnotation] + public init(pageURL: String, capturedAt: Date = Date(), annotations: [BrowserAnnotation] = []) { + version = 1 + self.pageURL = pageURL + self.capturedAt = capturedAt + self.annotations = annotations + } + public func validate() throws { + guard version == 1, pageURL.utf8.count <= 8192, annotations.count <= 1000, + capturedAt.timeIntervalSince1970.isFinite + else { throw T3Error("Invalid annotation document.") } + var count = 0 + for annotation in annotations { + count += annotation.points.count + guard (0.5...16).contains(annotation.width), annotation.width.isFinite, + annotation.points.count >= 2, count <= 100_000, + annotation.tool == .pen || annotation.points.count == 2, + annotation.points.allSatisfy({ + $0.x.isFinite && $0.y.isFinite && (0...1).contains($0.x) && (0...1).contains($0.y) + }) + else { throw T3Error("Invalid annotation geometry.") } + } + } + public func serialized() throws -> Data { + try validate() + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.sortedKeys] + return try encoder.encode(self) + } + public static func deserialize(_ data: Data) throws -> Self { + guard data.count <= 8 * 1024 * 1024 else { throw T3Error("Annotation document is too large.") } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let document = try decoder.decode(Self.self, from: data) + try document.validate() + return document + } +} diff --git a/apps/macos/Sources/T3Core/BrowserViewport.swift b/apps/macos/Sources/T3Core/BrowserViewport.swift new file mode 100644 index 0000000000000..1cd3a1006f28c --- /dev/null +++ b/apps/macos/Sources/T3Core/BrowserViewport.swift @@ -0,0 +1,76 @@ +import Foundation + +public struct BrowserViewport: Equatable, Sendable { + public let width: Int? + public let height: Int? + public let presetID: String? + public static let fill = BrowserViewport() + private init() { + width = nil + height = nil + presetID = nil + } + public init(width: Int, height: Int, presetID: String? = nil) throws { + guard (240...3840).contains(width), (240...3840).contains(height), width * height <= 3840 * 2160 + else { + throw T3Error( + "Use dimensions from 240 to 3840 pixels, with an area no larger than 3840 × 2160.") + } + self.width = width + self.height = height + self.presetID = presetID + } + public init(_ value: JSON) throws { + guard value["_tag"].string == "preset" || value["_tag"].string == "freeform" else { + self = .fill + return + } + try self.init( + width: value.requiredInt("width"), height: value.requiredInt("height"), + presetID: value["_tag"].string == "preset" ? value["presetId"].string : nil) + } + public var json: JSON { + guard let width, let height else { return .object(["_tag": .string("fill")]) } + var fields: [String: JSON] = [ + "_tag": .string(presetID == nil ? "freeform" : "preset"), + "width": .number(Double(width)), "height": .number(Double(height)), + ] + if let presetID { fields["presetId"] = .string(presetID) } + return .object(fields) + } + public var rotated: Self { + guard let width, let height else { return self } + return try! Self(width: height, height: width, presetID: presetID) + } +} + +public struct BrowserDevicePreset: Sendable { + public let id: String + public let name: String + public let viewport: BrowserViewport + private init(_ id: String, _ name: String, _ width: Int, _ height: Int) { + self.id = id + self.name = name + viewport = try! BrowserViewport(width: width, height: height, presetID: id) + } + // Same IDs and CSS sizes as packages/shared/src/previewViewport.ts. + public static let all: [Self] = [ + .init("iphone-se", "iPhone SE", 375, 667), + .init("iphone-xr", "iPhone XR", 414, 896), + .init("iphone-12-pro", "iPhone 12 Pro", 390, 844), + .init("iphone-14-pro-max", "iPhone 14 Pro Max", 430, 932), + .init("pixel-7", "Pixel 7", 412, 915), + .init("samsung-galaxy-s8-plus", "Samsung Galaxy S8+", 360, 740), + .init("samsung-galaxy-s20-ultra", "Samsung Galaxy S20 Ultra", 412, 915), + .init("ipad-mini", "iPad Mini", 768, 1024), + .init("ipad-air", "iPad Air", 820, 1180), + .init("ipad-pro", "iPad Pro", 1024, 1366), + .init("surface-pro-7", "Surface Pro 7", 912, 1368), + .init("surface-duo", "Surface Duo", 540, 720), + .init("galaxy-z-fold-5", "Galaxy Z Fold 5", 344, 882), + .init("asus-zenbook-fold", "Asus Zenbook Fold", 853, 1280), + .init("samsung-galaxy-a51-71", "Samsung Galaxy A51/71", 412, 914), + .init("nest-hub", "Nest Hub", 1024, 600), + .init("nest-hub-max", "Nest Hub Max", 1280, 800), + ] +} diff --git a/apps/macos/Sources/T3Core/ComposerMention.swift b/apps/macos/Sources/T3Core/ComposerMention.swift new file mode 100644 index 0000000000000..7ecf645508add --- /dev/null +++ b/apps/macos/Sources/T3Core/ComposerMention.swift @@ -0,0 +1,277 @@ +import Foundation + +public struct ComposerMention: Equatable, Sendable { + public enum Kind: String, Sendable { case file, skill, command } + public let kind: Kind + public let query: String + public let range: NSRange + + public static func detect(text: String, cursor: Int) -> Self? { + let source = text as NSString + guard cursor >= 0, cursor <= source.length else { return nil } + let prefix = source.substring(to: cursor) as NSString + var start = cursor + while start > 0, !whitespace(prefix.character(at: start - 1)) { start -= 1 } + let token = prefix.substring(from: start) + guard let sigil = token.first else { return nil } + let kind: Kind + switch sigil { + case "@": kind = .file + case "$": kind = .skill + case "/": kind = .command + default: return nil + } + let query = String(token.dropFirst()) + guard query.utf16.count <= 256 else { return nil } + if kind == .skill, !query.isEmpty, + query.range(of: #"^[0-9][0-9_]*(?:[kKmMbBtT]|[eE][0-9]+)?$"#, options: .regularExpression) + != nil + { + return nil + } + var end = cursor + while end < source.length, !whitespace(source.character(at: end)) { end += 1 } + return Self(kind: kind, query: query, range: NSRange(location: start, length: end - start)) + } + + private static func whitespace(_ unit: UInt16) -> Bool { + UnicodeScalar(unit).map { CharacterSet.whitespacesAndNewlines.contains($0) } ?? false + } + + public func insertion(in text: String, replacement: String) -> ( + range: NSRange, replacement: String + ) { + let source = text as NSString + var range = range + if NSMaxRange(range) < source.length, source.character(at: NSMaxRange(range)) == 32 { + range.length += 1 + } + return (range, replacement + " ") + } + + public enum FileFormat: Sendable { case markdownLink, absoluteLiteral } + public static func fileReference(path: String, root: String, format: FileFormat = .markdownLink) + -> String + { + if format == .absoluteLiteral { + if path.hasPrefix("/") + || path.range(of: #"^[A-Za-z]:[\\/]"#, options: .regularExpression) != nil + { + return path + } + let separator = root.contains("\\") ? "\\" : "/" + return root + (root.hasSuffix(separator) ? "" : separator) + path + } + let basename = + path.replacingOccurrences(of: "\\", with: "/").split(separator: "/").last.map(String.init) + ?? path + let label = basename.replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "[", with: "\\[").replacingOccurrences(of: "]", with: "\\]") + // Matches serializeComposerFileLink in packages/shared/src/composerTrigger.ts. + let allowed = CharacterSet( + charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789;/,:@&=+$-_.!~*'" + ) + return "[\(label)](\(path.addingPercentEncoding(withAllowedCharacters: allowed) ?? path))" + } +} + +public struct ComposerSuggestion: Equatable, Sendable, Identifiable { + public let id: String + public let kind: ComposerMention.Kind + public let name: String + public let label: String + public let detail: String + public let path: String? + public let isDirectory: Bool + public var localCommand: String? = nil + + /// Local actions work anywhere; provider commands only expand at the start of a prompt. + public static func slashItems( + providerItems: [Self], mention: ComposerMention, localCommandsEnabled: Bool, + planModeEnabled: Bool, interactionModeSupported: Bool, limit: Int = 40 + ) -> [Self] { + guard mention.kind == .command else { return [] } + var items: [Self] = [] + if localCommandsEnabled { + let names = + ["model"] + (planModeEnabled && interactionModeSupported ? ["plan", "default"] : []) + items = names.map { name in + Self( + id: "local:" + name, kind: .command, name: name, label: "/" + name, + detail: name == "model" + ? "Switch response model for this thread" + : name == "plan" + ? "Switch this thread into plan mode" + : "Switch this thread back to normal build mode", + path: nil, isDirectory: false, localCommand: name) + } + } + if mention.range.location == 0 { + items += providerItems.filter { $0.kind == .command && $0.localCommand == nil } + } + return ranked(items, query: mention.query, limit: limit) + } + + public static func files(_ result: JSON, limit: Int = 40) -> [Self] { + var seen: Set = [] + return result["entries"].array.compactMap { entry in + guard let path = entry["path"].string, !path.isEmpty, seen.insert(path).inserted, + ["file", "directory"].contains(entry["kind"].string ?? "") + else { return nil } + return Self( + id: "file:" + path, kind: .file, name: path, + label: (path as NSString).lastPathComponent, detail: path, path: path, + isDirectory: entry["kind"].string == "directory") + }.prefix(max(0, limit)).map { $0 } + } + + public static func providerItems( + _ provider: JSON, cwd: String, kind: ComposerMention.Kind, + query: String, allowCompact: Bool = false, limit: Int = 40 + ) -> [Self] { + guard kind != .file, provider["enabled"].bool, provider["installed"].bool, + provider["status"].string != "disabled", provider["availability"].string != "unavailable" + else { return [] } + let snapshot = + provider["workspaceSnapshots"].array.first { $0["cwd"].string == cwd } ?? provider + let values = snapshot[kind == .skill ? "skills" : "slashCommands"].array + var seen: Set = [] + let items: [Self] = values.compactMap { item in + guard let name = item["name"].string, !name.isEmpty, + !name.contains(where: { $0.isWhitespace }) + else { return nil } + if kind == .skill { + guard item["enabled"].bool, item["userInvocable"] != .bool(false), + name.range(of: #"^[a-zA-Z0-9][a-zA-Z0-9:_-]*$"#, options: .regularExpression) != nil, + ComposerMention.detect(text: "$" + name, cursor: name.utf16.count + 1) != nil + else { return nil } + } else if name == "compact", !allowCompact { + return nil + } + guard seen.insert(name.lowercased()).inserted else { return nil } + return Self( + id: kind.rawValue + ":" + name, kind: kind, name: name, + label: kind == .command ? "/" + name : item["displayName"].string ?? "$" + name, + detail: item["shortDescription"].string ?? item["description"].string ?? item["input"][ + "hint" + ].string ?? item["scope"].string ?? "", + path: item["path"].string, isDirectory: false) + } + return ranked(items, query: query, limit: limit) + } + + private static func ranked(_ items: [Self], query: String, limit: Int) -> [Self] { + let query = query.lowercased() + if query.isEmpty { return Array(items.prefix(max(0, limit))) } + return items.compactMap { item -> (Self, Int)? in + if item.name.lowercased() == query { return (item, 0) } + if item.name.lowercased().hasPrefix(query) { return (item, 1) } + if item.name.lowercased().contains(query) || item.label.lowercased().contains(query) { + return (item, 2) + } + if item.detail.lowercased().contains(query) { return (item, 3) } + return nil + }.sorted { + if $0.1 != $1.1 { return $0.1 < $1.1 } + if ($0.0.localCommand != nil) != ($1.0.localCommand != nil) { + return $0.0.localCommand != nil + } + return $0.0.name < $1.0.name + } + .prefix(max(0, limit)).map(\.0) + } + + public func selectionEdit( + mention: ComposerMention, text: String, root: String, + fileFormat: ComposerMention.FileFormat = .markdownLink, skillUsesLiteralPath: Bool = false + ) -> (range: NSRange, replacement: String) { + if localCommand != nil { return (mention.range, "") } + return mention.insertion( + in: text, + replacement: replacement( + root: root, fileFormat: fileFormat, skillUsesLiteralPath: skillUsesLiteralPath)) + } + + public func replacement( + root: String, fileFormat: ComposerMention.FileFormat = .markdownLink, + skillUsesLiteralPath: Bool = false + ) -> String { + if localCommand != nil { return "" } + return switch kind { + case .file: ComposerMention.fileReference(path: name, root: root, format: fileFormat) + case .skill: + if skillUsesLiteralPath, let path { + ComposerMention.fileReference(path: path, root: root, format: .absoluteLiteral) + } else { + "$" + name + } + case .command: "/" + name + } + } +} + +/// Query identity includes the connection session, draft, thread, provider instance, and worktree. +public struct ComposerCompletionScope: Equatable, Sendable { + public let environment: String + public let session: UUID + public let thread: String + public let draft: String + public let provider: String + public let root: String + public init( + environment: String, session: UUID, thread: String, draft: String, provider: String, + root: String + ) { + self.environment = environment + self.session = session + self.thread = thread + self.draft = draft + self.provider = provider + self.root = root + } +} + +public struct ComposerCompletionState: Sendable { + public struct Ticket: Equatable, Sendable { + fileprivate let revision: UInt64 + public let scope: ComposerCompletionScope + public let mention: ComposerMention + } + public private(set) var mention: ComposerMention? + private var scope: ComposerCompletionScope? + private var revision: UInt64 = 0 + private var text = "" + private var cursor = -1 + private var dismissed: (ComposerMention.Kind, Int, ComposerCompletionScope)? + public init() {} + + public mutating func refresh(text: String, cursor: Int, scope: ComposerCompletionScope?) { + if self.text != text || self.cursor != cursor || self.scope != scope { revision &+= 1 } + self.text = text + self.cursor = cursor + self.scope = scope + guard let scope, let candidate = ComposerMention.detect(text: text, cursor: cursor) else { + mention = nil + dismissed = nil + return + } + if let dismissed, dismissed.0 == candidate.kind, dismissed.1 == candidate.range.location, + dismissed.2 == scope + { + mention = nil + return + } + dismissed = nil + mention = candidate + } + public var ticket: Ticket? { + guard let scope, let mention else { return nil } + return Ticket(revision: revision, scope: scope, mention: mention) + } + public func accepts(_ ticket: Ticket) -> Bool { self.ticket == ticket } + public mutating func dismiss() { + if let scope, let mention { dismissed = (mention.kind, mention.range.location, scope) } + revision &+= 1 + mention = nil + } +} diff --git a/apps/macos/Sources/T3Core/ConnectAccount.swift b/apps/macos/Sources/T3Core/ConnectAccount.swift new file mode 100644 index 0000000000000..d3b03172edc67 --- /dev/null +++ b/apps/macos/Sources/T3Core/ConnectAccount.swift @@ -0,0 +1,223 @@ +import CryptoKit +import Foundation + +public struct ConnectConfiguration: Sendable { + public let relay: URL + public let hostedApp: URL + public let tokenEndpoint: URL + public let clientID: String + public let callbackPort = 34338 + public var redirectURI: URL { URL(string: "http://127.0.0.1:\(callbackPort)/callback")! } + public var credentialAccount: String { + relay.absoluteString + "|" + tokenEndpoint.absoluteString + "|" + clientID + } + public init( + relay: String, publishableKey: String, clientID: String, + hostedApp: String = "https://app.t3.codes" + ) throws { + self.relay = try ConnectionEndpoint.normalize(relay) + self.hostedApp = try ConnectionEndpoint.normalize(hostedApp) + guard self.relay.scheme == "https", self.hostedApp.scheme == "https", !clientID.isEmpty, + publishableKey.hasPrefix("pk_live_") || publishableKey.hasPrefix("pk_test_") + else { + throw T3Error( + "T3 Connect requires its relay URL, Clerk publishable key, and CLI OAuth client ID.") + } + let encoded = publishableKey.split(separator: "_").dropFirst(2).joined(separator: "_") + let padded = encoded + String(repeating: "=", count: (4 - encoded.count % 4) % 4) + guard let data = Data(base64Encoded: padded), var host = String(data: data, encoding: .utf8) + else { + throw T3Error("The T3 Connect publishable key is invalid.") + } + if host.hasSuffix("$") { host.removeLast() } + guard !host.isEmpty, !host.contains("/"), !host.contains("@"), !host.contains(":"), + !host.contains("?") + else { + throw T3Error("The T3 Connect account host is invalid.") + } + let origin = try ConnectionEndpoint.normalize("https://" + host) + self.tokenEndpoint = origin.appendingPathComponent("oauth/token") + self.clientID = clientID + } + public static func bundled( + info: [String: Any], environment: [String: String] = ProcessInfo.processInfo.environment + ) throws -> Self { + func value(_ name: String, _ key: String) -> String { + environment[name]?.trimmingCharacters(in: .whitespacesAndNewlines) + ?? (info[key] as? String ?? "") + } + return try Self( + relay: value("T3CODE_RELAY_URL", "T3ConnectRelayURL"), + publishableKey: value("T3CODE_CLERK_PUBLISHABLE_KEY", "T3ConnectClerkPublishableKey"), + clientID: value("T3CODE_CLERK_CLI_OAUTH_CLIENT_ID", "T3ConnectOAuthClientID"), + hostedApp: environment["T3CODE_HOSTED_APP_URL"] ?? info["T3ConnectHostedAppURL"] as? String + ?? "https://app.t3.codes") + } +} + +public struct ConnectAuthorization: Sendable { + public let verifier: String + public let state: String + public let configuration: ConnectConfiguration + public init(configuration: ConnectConfiguration) { + self.configuration = configuration + verifier = ConnectionProof.base64url(ConnectionProof().rawRepresentation) + state = ConnectionProof.base64url(ConnectionProof().rawRepresentation) + } + public var url: URL { + var parts = URLComponents(url: configuration.hostedApp, resolvingAgainstBaseURL: false)! + parts.path = "/connect" + var fragment = URLComponents() + fragment.queryItems = [ + URLQueryItem(name: "state", value: state), + URLQueryItem( + name: "challenge", + value: ConnectionProof.base64url(Data(SHA256.hash(data: Data(verifier.utf8))))), + URLQueryItem(name: "port", value: String(configuration.callbackPort)), + ] + parts.percentEncodedFragment = fragment.percentEncodedQuery + return parts.url! + } + public func code(from callback: URL) throws -> String { + guard let parts = URLComponents(url: callback, resolvingAgainstBaseURL: false), + parts.scheme == "http", parts.host == "127.0.0.1", parts.port == configuration.callbackPort, + parts.path == "/callback", parts.user == nil, parts.password == nil, parts.fragment == nil, + parts.queryItems?.filter({ $0.name == "state" }).count == 1, + parts.queryItems?.first(where: { $0.name == "state" })?.value == state, + parts.queryItems?.filter({ $0.name == "code" }).count == 1, + let code = parts.queryItems?.first(where: { $0.name == "code" })?.value, !code.isEmpty + else { + throw T3Error("Invalid T3 Connect callback. Restart sign-in from Connections.") + } + return code + } + public func exchange(_ callback: URL) async throws -> ConnectAccountToken { + try await ConnectAccountToken.exchange( + configuration: configuration, + fields: [ + "grant_type": "authorization_code", "code": code(from: callback), + "redirect_uri": configuration.redirectURI.absoluteString, + "client_id": configuration.clientID, "code_verifier": verifier, + ]) + } +} + +public struct ConnectAccountToken: Codable, Sendable { + public let accessToken: String + public let refreshToken: String + public let expiresAt: Date + public func refreshed(configuration: ConnectConfiguration) async throws -> Self { + if expiresAt.timeIntervalSinceNow > 60 { return self } + guard !refreshToken.isEmpty else { + throw T3Error("T3 Connect sign-in expired. Sign in again.", authentication: true) + } + return try await Self.exchange( + configuration: configuration, + fields: [ + "grant_type": "refresh_token", + "refresh_token": refreshToken, "client_id": configuration.clientID, + ]) + } + static func exchange(configuration: ConnectConfiguration, fields: [String: String]) async throws + -> Self + { + var request = URLRequest(url: configuration.tokenEndpoint) + request.httpMethod = "POST" + request.timeoutInterval = 25 + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.httpBody = ConnectionHTTP.form(fields) + let result = try await ConnectionHTTP.shared.read(request) + guard result["token_type"].string?.lowercased() == "bearer", + let seconds = result["expires_in"].int, seconds > 0 + else { throw T3Error("Invalid T3 Connect account session.") } + return Self( + accessToken: try result.requiredString("access_token"), + refreshToken: result["refresh_token"].string ?? fields["refresh_token"] ?? "", + expiresAt: Date().addingTimeInterval(Double(seconds))) + } +} + +public struct ConnectEnvironment: Identifiable, Equatable, Sendable { + public let id: String + public let label: String + public let endpoint: URL + public init(_ json: JSON) throws { + id = try json.requiredString("environmentId") + label = try json.requiredString("label") + endpoint = try ConnectionEndpoint.normalize(json["endpoint"].requiredString("httpBaseUrl")) + guard endpoint.scheme == "https" else { + throw T3Error("T3 Connect returned an insecure environment address.") + } + } +} + +public struct ConnectClient: Sendable { + public let configuration: ConnectConfiguration + public let account: ConnectAccountToken + public init(configuration: ConnectConfiguration, account: ConnectAccountToken) { + self.configuration = configuration + self.account = account + } + public func request(_ path: String, method: String = "GET", payload: JSON? = nil) async throws + -> JSON + { + try await ConnectionHTTP.shared.read( + ConnectionHTTP.request( + endpoint: configuration.relay, + path: path, method: method, token: account.accessToken, payload: payload)) + } + public func environments() async throws -> [ConnectEnvironment] { + let result = try await request("/v1/environments") + guard case .array(let rows) = result["environments"] else { + throw T3Error("Invalid T3 Connect environment catalog.") + } + return try rows.map(ConnectEnvironment.init) + } + public func pair(_ environment: ConnectEnvironment) async throws -> ConnectionSettings { + let proof = ConnectionProof() + var request = try ConnectionHTTP.request( + endpoint: configuration.relay, path: "/v1/client/dpop-token", + method: "POST", proof: proof) + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.httpBody = ConnectionHTTP.form([ + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "subject_token": account.accessToken, + "subject_token_type": "urn:ietf:params:oauth:token-type:jwt", + "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", + "resource": configuration.relay.absoluteString, "scope": "environment:connect", + "client_id": "t3-web", + ]) + let authorization = try await ConnectionHTTP.shared.read(request) + guard authorization["token_type"].string == "DPoP", + authorization["scope"].string == "environment:connect" + else { + throw T3Error("T3 Connect granted an unsupported authentication session.") + } + let id = environment.id.addingPercentEncoding(withAllowedCharacters: .alphanumerics)! + let connected = try await ConnectionHTTP.shared.read( + ConnectionHTTP.request( + endpoint: configuration.relay, + path: "/v1/environments/\(id)/connect", method: "POST", + token: authorization.requiredString("access_token"), + payload: .object(["clientProofKeyThumbprint": .string(proof.thumbprint)]), proof: proof)) + let target = try Self.pairingEndpoint(connected, expected: environment) + return try await ConnectionHTTP.exchange( + endpoint: target, credential: connected.requiredString("credential"), proof: proof) + } + public static func pairingEndpoint(_ response: JSON, expected: ConnectEnvironment) throws -> URL { + let endpoint = try ConnectionEndpoint.normalize( + response["endpoint"].requiredString("httpBaseUrl")) + guard response["environmentId"].string == expected.id, endpoint == expected.endpoint else { + throw T3Error( + "T3 Connect returned a different environment. Refresh the catalog and try again.") + } + return endpoint + } + public func unlink(_ id: String) async throws { + let id = id.addingPercentEncoding(withAllowedCharacters: .alphanumerics)! + let result = try await request("/v1/client/environment-links/\(id)", method: "DELETE") + guard result["ok"].bool else { + throw T3Error("T3 Connect did not confirm removal of the environment.") + } + } +} diff --git a/apps/macos/Sources/T3Core/Connection.swift b/apps/macos/Sources/T3Core/Connection.swift new file mode 100644 index 0000000000000..9ef22b5847d8a --- /dev/null +++ b/apps/macos/Sources/T3Core/Connection.swift @@ -0,0 +1,313 @@ +import Foundation + +public struct ConnectionSettings: Codable, Sendable { + public let endpoint: URL + public let token: String + public let proofKey: Data? + + public init(endpoint: URL, token: String, proofKey: Data? = nil) throws { + guard let parts = URLComponents(url: endpoint, resolvingAgainstBaseURL: false), + let host = parts.host, parts.user == nil, parts.password == nil, + parts.query == nil, parts.fragment == nil, + parts.path.isEmpty || parts.path == "/", + parts.scheme == "https" + || (parts.scheme == "http" && ["localhost", "127.0.0.1", "::1", "[::1]"].contains(host)) + else { throw T3Error("Use an HTTPS server address, or HTTP on localhost.") } + guard !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw T3Error("Enter a pairing token or saved access token.", authentication: true) + } + self.endpoint = endpoint + self.token = token + if let proofKey { _ = try ConnectionProof(rawRepresentation: proofKey) } + self.proofKey = proofKey + } + + public static func load(_ file: URL) throws -> Self { + let decoded = try JSONDecoder().decode(Self.self, from: Data(contentsOf: file)) + return try Self(endpoint: decoded.endpoint, token: decoded.token, proofKey: decoded.proofKey) + } + + public func request(_ path: String, method: String = "GET", query: [URLQueryItem] = []) + -> URLRequest + { + var parts = URLComponents(url: endpoint, resolvingAgainstBaseURL: false)! + parts.path = path + parts.queryItems = query.isEmpty ? nil : query + var request = URLRequest(url: parts.url!) + request.httpMethod = method + request.timeoutInterval = 25 + if let proofKey { + // Invalid keys fail closed: never downgrade a proof-bound token to bearer auth. + if let proof = try? ConnectionProof(rawRepresentation: proofKey), + let jwt = try? proof.jwt(method: method, url: request.url!, token: token) + { + request.setValue("DPoP \(token)", forHTTPHeaderField: "Authorization") + request.setValue(jwt, forHTTPHeaderField: "DPoP") + } + } else { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + request.setValue("2", forHTTPHeaderField: "x-t3-orchestration-protocol") + return request + } +} + +public enum EnvironmentHTTP { + public static func read(_ request: URLRequest, session: URLSession? = nil) async throws -> JSON { + let (data, response) = try await (session ?? ConnectionHTTP.shared.session!).data(for: request) + guard let response = response as? HTTPURLResponse else { + throw T3Error("Invalid server response.") + } + guard (200..<300).contains(response.statusCode) else { + let reason = (try? JSON.decode(data))?["reason"].string ?? "request_failed" + throw T3Error( + "Server returned \(response.statusCode): \(reason)", + authentication: response.statusCode == 401 || response.statusCode == 403) + } + return try JSON.decode(data) + } + + public static func pair(endpoint: URL, credential: String) async throws -> ConnectionSettings { + let settings = try ConnectionSettings(endpoint: endpoint, token: credential) + var request = settings.request("/oauth/token", method: "POST") + request.setValue(nil, forHTTPHeaderField: "Authorization") + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + let fields = [ + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "subject_token": credential, + "subject_token_type": "urn:t3:params:oauth:token-type:environment-bootstrap", + "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", + "scope": "orchestration:read orchestration:operate terminal:operate review:write", + "client_label": "T3 Native", "client_device_type": "desktop", "client_os": "macOS", + ] + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-._~")) + request.httpBody = fields.sorted(by: { $0.key < $1.key }).map { + "\($0.key)=\($0.value.addingPercentEncoding(withAllowedCharacters: allowed)!)" + }.joined(separator: "&").data(using: .utf8) + let result = try await read(request) + guard result["token_type"].string == "Bearer" else { + throw T3Error("This prototype requires a bearer session.") + } + return try ConnectionSettings(endpoint: endpoint, token: result.requiredString("access_token")) + } +} + +public struct RPCSubscription: Sendable { + public let id: String + public let values: AsyncThrowingStream +} + +public actor RPCConnection { + public let settings: ConnectionSettings + private let session: URLSession + private var socket: URLSessionWebSocketTask? + private var receiver: Task? + private var heartbeat: Task? + private var streams: [String: AsyncThrowingStream.Continuation] = [:] + private var calls: [String: CheckedContinuation] = [:] + private var timeouts: [String: Task] = [:] + private var lastResponse = Date() + + public init(settings: ConnectionSettings) { + self.settings = settings + let config = URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest = 25 + self.session = URLSession( + configuration: config, delegate: ConnectionHTTP.shared, delegateQueue: nil) + } + + public func open() async throws { + let ticket = try await EnvironmentHTTP.read( + settings.request("/api/auth/websocket-ticket", method: "POST"), session: session) + try Task.checkCancellation() + var url = URLComponents(url: settings.endpoint, resolvingAgainstBaseURL: false)! + url.scheme = url.scheme == "https" ? "wss" : "ws" + url.path = "/ws" + url.queryItems = [ + URLQueryItem(name: "wsTicket", value: try ticket.requiredString("ticket")), + URLQueryItem(name: "orchestrationProtocol", value: "2"), + ] + let task = session.webSocketTask(with: url.url!) + task.maximumMessageSize = 32 * 1024 * 1024 + socket = task + task.resume() + receiver = Task { [weak self] in + do { + while !Task.isCancelled { + let message = try await task.receive() + try await self?.receive(message) + } + } catch { await self?.close(error: T3Error("Connection lost. Reconnecting…")) } + } + heartbeat = Task { [weak self] in + do { + while !Task.isCancelled { + try await Task.sleep(for: .seconds(15)) + try await self?.ping() + } + } catch { await self?.close(error: T3Error("Server stopped responding. Reconnecting…")) } + } + } + + private func ping() async throws { + guard Date().timeIntervalSince(lastResponse) < 45 else { throw T3Error("Heartbeat expired.") } + try await send(.object(["_tag": .string("Ping")])) + } + + private func send(_ value: JSON) async throws { + guard let socket else { throw T3Error("Not connected.") } + try await socket.send(.string(String(decoding: try value.data(), as: UTF8.self))) + } + + public static func request(id: String, method: String, payload: JSON) -> JSON { + .object([ + "_tag": .string("Request"), "id": .string(id), "tag": .string(method), + "payload": payload, "headers": .array([]), + ]) + } + + public func subscribe(_ method: String, payload: JSON = .object([:])) async throws + -> RPCSubscription + { + let id = UUID().uuidString + let (values, continuation) = AsyncThrowingStream.makeStream( + bufferingPolicy: .bufferingOldest(256)) + streams[id] = continuation + do { try await send(Self.request(id: id, method: method, payload: payload)) } catch { + streams.removeValue(forKey: id)?.finish(throwing: error) + throw error + } + return RPCSubscription(id: id, values: values) + } + + public func cancel(_ id: String) async { + streams.removeValue(forKey: id)?.finish() + try? await send(.object(["_tag": .string("Interrupt"), "requestId": .string(id)])) + } + + public func call(_ method: String, payload: JSON) async throws -> JSON { + try Task.checkCancellation() + let id = UUID().uuidString + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + calls[id] = continuation + timeouts[id] = Task { [weak self] in + do { try await Task.sleep(for: .seconds(25)) } catch { return } + await self?.failCall( + id, + error: T3Error( + "Request timed out; its result is unknown. Check the conversation before retrying.")) + } + Task { + guard calls[id] != nil else { return } + do { try await send(Self.request(id: id, method: method, payload: payload)) } catch { + failCall(id, error: error) + } + } + } + } onCancel: { + Task { await self.failCall(id, error: CancellationError()) } + } + } + + private func failCall(_ id: String, error: any Error) { + timeouts.removeValue(forKey: id)?.cancel() + calls.removeValue(forKey: id)?.resume(throwing: error) + } + + private func receive(_ message: URLSessionWebSocketTask.Message) async throws { + let data: Data + switch message { + case .data(let bytes): data = bytes + case .string(let text): data = Data(text.utf8) + @unknown default: throw T3Error("Unsupported WebSocket frame.") + } + let json = try JSON.decode(data) + let frames: [JSON] = if case .array(let batch) = json { batch } else { [json] } + lastResponse = Date() + for frame in frames { + let id = frame["requestId"].string ?? "" + switch frame["_tag"].string { + case "Chunk": + if let stream = streams[id] { + for value in frame["values"].array { + if case .dropped = stream.yield(value) { + throw T3Error("Live updates exceeded the client buffer. Reloading the snapshot.") + } + } + } + try await send(.object(["_tag": .string("Ack"), "requestId": .string(id)])) + case "Exit": + timeouts.removeValue(forKey: id)?.cancel() + let exit = frame["exit"] + if exit["_tag"].string == "Success" { + calls.removeValue(forKey: id)?.resume(returning: exit["value"]) + streams.removeValue(forKey: id)?.finish() + } else { + let error = Self.requestError(exit) + calls.removeValue(forKey: id)?.resume(throwing: error) + streams.removeValue(forKey: id)?.finish(throwing: error) + } + case "Pong": break + case "Defect", "ClientProtocolError": + throw T3Error("The server rejected this protocol session.") + default: break + } + } + } + + public static func failureMessage(_ value: JSON) -> String { + if let message = value["message"].string { return message } + if let detail = value["detail"].string { return detail } + if case .object(let fields) = value { + for key in ["cause", "error", "failure", "defect", "reasons"] { + if let child = fields[key] { + let result = failureMessage(child) + if result != "Server rejected the request." { return result } + } + } + } + for child in value.array { + let result = failureMessage(child) + if result != "Server rejected the request." { return result } + } + return "Server rejected the request." + } + + public static func failureCode(_ value: JSON) -> String? { + failureDetails(value)?["_tag"].string + } + + public static func requestError(_ value: JSON) -> T3Error { + let details = failureDetails(value) + return T3Error( + details?["message"].string ?? details?["detail"].string ?? failureMessage(value), + remoteCode: details?["_tag"].string, remoteFailure: details?["failure"].string) + } + + private static func failureDetails(_ value: JSON) -> JSON? { + if let tag = value["_tag"].string, tag.hasSuffix("Error") { return value } + if case .object(let fields) = value { + for key in ["cause", "error", "failure", "defect", "reasons"] { + if let child = fields[key], let details = failureDetails(child) { return details } + } + } + return value.array.lazy.compactMap(failureDetails).first + } + + public func close(error: any Error = CancellationError()) { + receiver?.cancel() + receiver = nil + heartbeat?.cancel() + heartbeat = nil + socket?.cancel(with: .goingAway, reason: nil) + socket = nil + for stream in streams.values { stream.finish(throwing: error) } + streams.removeAll() + for call in calls.values { call.resume(throwing: error) } + calls.removeAll() + for timeout in timeouts.values { timeout.cancel() } + timeouts.removeAll() + session.invalidateAndCancel() + } +} diff --git a/apps/macos/Sources/T3Core/ConnectionOperation.swift b/apps/macos/Sources/T3Core/ConnectionOperation.swift new file mode 100644 index 0000000000000..4596eae65d0b2 --- /dev/null +++ b/apps/macos/Sources/T3Core/ConnectionOperation.swift @@ -0,0 +1,18 @@ +import Foundation + +public struct ConnectionOperation: Sendable { + public let environment: String + public let connection: RPCConnection? + public init(environment: String, connection: RPCConnection?) { + self.environment = environment + self.connection = connection + } + public func requireCurrent(environment: String, connection: RPCConnection?) throws { + try Task.checkCancellation() + guard self.environment == environment, self.connection === connection else { + throw T3Error( + "The connection changed while this operation was running. Refresh Connections before continuing." + ) + } + } +} diff --git a/apps/macos/Sources/T3Core/ConnectionProof.swift b/apps/macos/Sources/T3Core/ConnectionProof.swift new file mode 100644 index 0000000000000..001a18dc41b3c --- /dev/null +++ b/apps/macos/Sources/T3Core/ConnectionProof.swift @@ -0,0 +1,129 @@ +import CryptoKit +import Foundation + +public struct ConnectionProof: Sendable { + private let key: P256.Signing.PrivateKey + public init() { key = P256.Signing.PrivateKey() } + public init(rawRepresentation: Data) throws { + key = try P256.Signing.PrivateKey(rawRepresentation: rawRepresentation) + } + public var rawRepresentation: Data { key.rawRepresentation } + public var publicJWK: [String: JSON] { + let bytes = key.publicKey.x963Representation + return [ + "kty": .string("EC"), "crv": .string("P-256"), + "x": .string(Self.base64url(bytes.subdata(in: 1..<33))), + "y": .string(Self.base64url(bytes.subdata(in: 33..<65))), + ] + } + public var thumbprint: String { + Self.base64url(Data(SHA256.hash(data: try! Self.canonical(.object(publicJWK))))) + } + public func jwt(method: String, url: URL, token: String? = nil, now: Date = Date()) throws + -> String + { + var parts = URLComponents(url: url, resolvingAgainstBaseURL: false)! + parts.query = nil + parts.fragment = nil + var payload: [String: JSON] = [ + "jti": .string(UUID().uuidString), + "htm": .string(method.uppercased()), "htu": .string(parts.url!.absoluteString), + "iat": .number(floor(now.timeIntervalSince1970)), + ] + if let token { + payload["ath"] = .string(Self.base64url(Data(SHA256.hash(data: Data(token.utf8))))) + } + let header = JSON.object([ + "typ": .string("dpop+jwt"), "alg": .string("ES256"), "jwk": .object(publicJWK), + ]) + let input = + Self.base64url(try Self.canonical(header)) + "." + + Self.base64url(try Self.canonical(.object(payload))) + return input + "." + Self.base64url(try key.signature(for: Data(input.utf8)).rawRepresentation) + } + public static func base64url(_ data: Data) -> String { + data.base64EncodedString().replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "") + } + public static func canonical(_ json: JSON) throws -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + return try encoder.encode(json) + } +} + +/// All account and connection-management requests reject redirects rather than forwarding credentials. +public final class ConnectionHTTP: NSObject, URLSessionTaskDelegate, @unchecked Sendable { + public static let shared = ConnectionHTTP() + public private(set) var session: URLSession! + public override init() { + super.init() + let config = URLSessionConfiguration.ephemeral + config.httpCookieAcceptPolicy = .never + config.httpShouldSetCookies = false + session = URLSession(configuration: config, delegate: self, delegateQueue: nil) + } + public func urlSession( + _ session: URLSession, task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, + completionHandler: @escaping @Sendable (URLRequest?) -> Void + ) { completionHandler(nil) } + public func read(_ request: URLRequest) async throws -> JSON { + try await EnvironmentHTTP.read(request, session: session) + } + public static func form(_ fields: [String: String]) -> Data { + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-._~")) + return Data( + fields.sorted { $0.key < $1.key }.map { + $0.key + "=" + $0.value.addingPercentEncoding(withAllowedCharacters: allowed)! + }.joined(separator: "&").utf8) + } + public static func request( + endpoint: URL, path: String, method: String = "GET", token: String? = nil, + payload: JSON? = nil, proof: ConnectionProof? = nil + ) throws -> URLRequest { + let origin = try ConnectionEndpoint.normalize(endpoint.absoluteString) + var parts = URLComponents(url: origin, resolvingAgainstBaseURL: false)! + parts.path = path + var request = URLRequest(url: parts.url!) + request.httpMethod = method + request.timeoutInterval = 25 + if let token { + request.setValue( + (proof == nil ? "Bearer " : "DPoP ") + token, forHTTPHeaderField: "Authorization") + } + if let proof { + request.setValue( + try proof.jwt(method: method, url: request.url!, token: token), forHTTPHeaderField: "DPoP") + } + if let payload { + request.httpBody = try payload.data() + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + } + return request + } + public static func exchange( + endpoint: URL, credential: String, proof: ConnectionProof? = nil, + scopes: String? = nil + ) async throws -> ConnectionSettings { + var request = try Self.request( + endpoint: endpoint, path: "/oauth/token", method: "POST", proof: proof) + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + var fields = [ + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "subject_token": credential, + "subject_token_type": "urn:t3:params:oauth:token-type:environment-bootstrap", + "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", + "client_label": "T3 Native", "client_device_type": "desktop", "client_os": "macOS", + ] + if let scopes { fields["scope"] = scopes } + request.httpBody = form(fields) + let result = try await shared.read(request) + guard result["token_type"].string == (proof == nil ? "Bearer" : "DPoP") else { + throw T3Error("The server returned an unsupported authentication session.") + } + return try ConnectionSettings( + endpoint: endpoint, token: result.requiredString("access_token"), + proofKey: proof?.rawRepresentation) + } +} diff --git a/apps/macos/Sources/T3Core/ContextUsage.swift b/apps/macos/Sources/T3Core/ContextUsage.swift new file mode 100644 index 0000000000000..b7323c5edec37 --- /dev/null +++ b/apps/macos/Sources/T3Core/ContextUsage.swift @@ -0,0 +1,158 @@ +import Foundation + +/// A provider report, normalized once at the event boundary. +public struct ContextUsage: Equatable, Sendable { + public let usedTokens: Double + public let updatedAt: String + public let fields: JSON + public let maxTokens: Double? + public var usedPercentage: Double? { + maxTokens.flatMap { $0 > 0 ? min(100, usedTokens / $0 * 100) : nil } + } + public var remainingTokens: Double? { maxTokens.map { max(0, ($0 - usedTokens).rounded()) } } + public var isOverloaded: Bool { (usedPercentage ?? 0) > 90 } + public var formattedPercentage: String? { + usedPercentage.map { + $0 < 10 ? Self.decimal($0) + "%" : String(format: "%.0f", $0.rounded()) + "%" + } + } + public var compactsAutomatically: Bool? { + fields["compactsAutomatically"] == .null ? nil : fields["compactsAutomatically"].bool + } + public func number(_ key: String) -> Double? { Self.finite(fields[key]) } + + public init?(report: JSON, updatedAt: String? = nil, live: Bool = false) { + guard let used = Self.finite(report["usedTokens"]), used >= 0, + let time = updatedAt ?? report["updatedAt"].string + else { return nil } + usedTokens = used + self.updatedAt = time + maxTokens = Self.finite(report["maxTokens"]) + if live { + var values: [String: JSON] = ["compactsAutomatically": .bool(true)] + for key in ["inputTokens", "cachedInputTokens", "outputTokens", "reasoningOutputTokens"] { + values[key] = report[key] + } + fields = .object(values) + } else { + fields = report + } + } + + public static func compaction(_ item: JSON) -> ContextUsage? { + guard item["type"].string == "compaction" else { return nil } + return ContextUsage( + report: .object([ + "usedTokens": item["afterTokenCount"], "totalProcessedTokens": item["beforeTokenCount"], + "compactsAutomatically": .bool(true), + ]), updatedAt: item["startedAt"].string ?? item["updatedAt"].string) + } + private static func finite(_ value: JSON) -> Double? { + guard case .number(let number) = value, number.isFinite else { return nil } + return number + } + public static func formatTokens(_ value: Double) -> String { + guard value.isFinite else { return "0" } + if value < 1_000 { return String(format: "%.0f", value.rounded()) } + if value < 10_000 { return decimal(value / 1_000) + "k" } + if value < 1_000_000 { return String(format: "%.0f", (value / 1_000).rounded()) + "k" } + return decimal(value / 1_000_000) + "m" + } + private static func decimal(_ value: Double) -> String { + let value = String(format: "%.1f", (value * 10).rounded() / 10) + return value.hasSuffix(".0") ? String(value.dropLast(2)) : value + } + public func shouldOfferResume(provider: String?, now: Date = Date()) -> Bool { + guard provider == "claudeAgent", usedTokens >= 100_000, + let date = ThreadDates.parse(updatedAt) + else { return false } + return now.timeIntervalSince(date) >= 70 * 60 + } + public static func hasPermanentDismissal(_ request: JSON) -> Bool { + guard request["kind"].string == "user_input", request["status"].string == "resolved", + case .object(let answers) = request["answers"] + else { return false } + let pattern = + #"^This session is (?:\d+h \d+m|\d+m) old and uses \d{1,3}(?:,\d{3})* tokens\. Compact it before continuing\?$"# + return answers.contains { question, answer in + answer.string == "Don't ask again" + && question.range(of: pattern, options: .regularExpression) != nil + } + } +} + +/// Keep-full-history hides this exact report; another environment or newer report is independent. +public struct ContextResumeDismissals: Sendable { + private struct Report: Hashable { + let environment: String + let thread: String + let updatedAt: String + } + private var reports: Set = [] + public init() {} + public mutating func dismiss(environment: String, thread: String, usage: ContextUsage) { + reports.insert(Report(environment: environment, thread: thread, updatedAt: usage.updatedAt)) + } + public func contains(environment: String, thread: String, usage: ContextUsage) -> Bool { + reports.contains(Report(environment: environment, thread: thread, updatedAt: usage.updatedAt)) + } +} + +/// Uses server capabilities and the same continuation-group selection as the web composer. +public struct ContextCompactionProviders: Sendable { + private var providers: [JSON] = [] + private var settings: JSON = .null + public init() {} + public mutating func apply(_ event: JSON) { + switch event["type"].string { + case "snapshot": + providers = event["config"]["providers"].array + settings = event["config"]["settings"] + case "providerStatuses": providers = event["payload"]["providers"].array + case "settingsUpdated": settings = event["payload"]["settings"] + default: break + } + } + public func reportsContextWindow(instance: String?) -> Bool? { + guard let provider = providers.first(where: { $0["instanceId"].string == instance }), + case .bool(let reports) = provider["reportsContextWindow"] + else { return nil } + return reports + } + public func supportsCompact(driver: String, instance: String?, lockedInstance: String?) -> Bool { + let driverProviders = providers.filter { $0["driver"].string == driver } + let group = driverProviders.first { $0["instanceId"].string == lockedInstance }?[ + "continuation"]["groupKey"].string + let compatible = driverProviders.filter { + group == nil || $0["continuation"]["groupKey"].string == group + } + func selectable(_ value: JSON) -> Bool { + let id = value["instanceId"].string ?? "" + let explicit = settings["providerInstances"][id] + let legacy = settings["providers"][driver] + let enabled: Bool + if explicit != .null { + if explicit["enabled"] == .bool(false) || explicit["config"]["enabled"] == .bool(false) { + enabled = false + } else if explicit["enabled"] != .null { + enabled = explicit["enabled"].bool + } else if explicit["config"]["enabled"] != .null { + enabled = explicit["config"]["enabled"].bool + } else { + enabled = !["cursor", "grok", "antigravity", "pi", "opencode"].contains(driver) + } + } else if id == driver && legacy != .null { + enabled = legacy["enabled"] == .null ? value["enabled"].bool : legacy["enabled"].bool + } else { + enabled = settings == .null ? value["enabled"].bool : false + } + return enabled && value["availability"].string != "unavailable" + } + let requested = compatible.first { $0["instanceId"].string == instance && selectable($0) } + let selected = + requested + ?? compatible.first { selectable($0) && $0["status"].string == "ready" } + ?? compatible.first { selectable($0) && $0["status"].string != "error" } + return selected?["slashCommands"].array.contains { $0["name"].string == "compact" } == true + } +} diff --git a/apps/macos/Sources/T3Core/ConversationTimeline.swift b/apps/macos/Sources/T3Core/ConversationTimeline.swift new file mode 100644 index 0000000000000..5c3d20ee7fd55 --- /dev/null +++ b/apps/macos/Sources/T3Core/ConversationTimeline.swift @@ -0,0 +1,161 @@ +import Foundation + +public enum ConversationEntry: Sendable, Equatable, Identifiable { + case item(TimelineRow) + case work(TimelineWorkGroup) + case changes(TurnCheckpoint) + + public var id: String { + switch self { + case .item(let row): row.id + case .work(let group): group.id + case .changes(let checkpoint): "changes:" + checkpoint.id + } + } + + public var item: TimelineRow? { + if case .item(let row) = self { row } else { nil } + } +} + +public struct TimelineWorkGroup: Sendable, Equatable, Identifiable { + public let id: String + public let entries: [TimelineRow] + public let expanded: Bool + public let active: Bool + public let summary: String + + fileprivate init(entries: [TimelineRow], expanded: Bool, active: Bool) { + self.entries = entries + self.expanded = expanded + self.active = active + id = "work:" + entries[0].id + let latest = entries.last(where: { $0.streaming }) ?? entries[entries.count - 1] + if active || entries.count == 1 { + let inProgress = active && latest.streaming + let completed = latest.item["status"].string == "completed" + let label: String + switch latest.kind { + case "reasoning": label = inProgress ? "Thinking" : "Thought" + case "command_execution": + let command = + latest.item["input"].string?.split(separator: "\n").first.map(String.init) ?? "command" + let verb = inProgress ? "Running" : completed ? "Ran" : "Command" + label = "\(verb) \(command)" + case "file_change": + label = + (inProgress ? "Editing " : completed ? "Edited " : "File change: ") + + (latest.item["fileName"].string ?? "file") + case "file_search": + label = inProgress ? "Searching files" : completed ? "Searched files" : "File search" + case "web_search": + label = inProgress ? "Searching the web" : completed ? "Searched the web" : "Web search" + default: label = (inProgress ? "Using " : completed ? "Used " : "Tool: ") + latest.title + } + summary = + String(label.prefix(180)) + (entries.count > 1 ? " · \(entries.count) activities" : "") + } else { + let categories: [(String, String)] = [ + ("command_execution", "command"), ("file_change", "file change"), + ("file_search", "file search"), ("web_search", "web search"), + ("dynamic_tool", "tool call"), ("reasoning", "thought"), + ] + var counts: [String: Int] = [:] + for row in entries { counts[row.kind, default: 0] += 1 } + summary = categories.compactMap { kind, name in + guard let count = counts[kind] else { return nil } + return "\(count) \(name)" + + (count == 1 ? "" : kind == "file_search" || kind == "web_search" ? "es" : "s") + }.joined(separator: " · ") + } + } +} + +public enum ConversationTimeline { + public static func entries( + from rows: [TimelineRow], activeRunID: String?, expandedItemIDs: Set = [], + checkpoints: [TurnCheckpoint] = [] + ) -> [ConversationEntry] { + var result: [ConversationEntry] = [] + var work: [TimelineRow] = [] + let nonempty = checkpoints.filter { !$0.files.isEmpty } + let byID = Dictionary(nonempty.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + let byMessage = Dictionary( + nonempty.compactMap { checkpoint in + checkpoint.assistantMessageID.map { ($0, checkpoint) } + }, uniquingKeysWith: { first, _ in first }) + let visibleAssistants = Set( + rows.compactMap { row -> String? in + guard row.kind == "assistant_message", !row.inherited, + let message = row.item["messageId"].string + else { return nil } + return row.sourceID + "\u{0}" + message + }) + var shown: Set = [] + func flush(trailing: Bool) { + guard let first = work.first else { return } + let isCurrentRun = + !first.inherited && activeRunID != nil && first.item["runId"].string == activeRunID + let group = TimelineWorkGroup( + entries: work, expanded: work.contains { expandedItemIDs.contains($0.id) }, + active: isCurrentRun && (trailing || work.contains(where: \.streaming))) + result.append(.work(group)) + if group.expanded { result.append(contentsOf: work.map(ConversationEntry.item)) } + work.removeAll(keepingCapacity: true) + } + for row in rows { + if row.kind == "checkpoint", !row.inherited, + let checkpoint = byID[row.item["checkpointId"].string ?? ""], + checkpoint.threadID == row.sourceID + { + if let message = checkpoint.assistantMessageID, + visibleAssistants.contains(checkpoint.threadID + "\u{0}" + message) + { + continue + } + flush(trailing: false) + if shown.insert(checkpoint.id).inserted { result.append(.changes(checkpoint)) } + continue + } + // Empty checkpoints do not interrupt otherwise contiguous provider work. + if row.kind == "checkpoint" && row.item["files"].array.isEmpty { continue } + if row.canCollapseWork { + if let first = work.first, !first.sharesWorkContext(with: row) { flush(trailing: false) } + work.append(row) + } else { + flush(trailing: false) + result.append(.item(row)) + if row.kind == "assistant_message", !row.inherited, + let checkpoint = byMessage[row.item["messageId"].string ?? ""], + checkpoint.threadID == row.sourceID, shown.insert(checkpoint.id).inserted + { + result.append(.changes(checkpoint)) + } + } + } + flush(trailing: true) + return result + } +} + +extension TimelineRow { + public var failed: Bool { + item["status"].string == "failed" || item["outputIndicatesFailure"].bool + || (kind == "command_execution" && item["exitCode"].int.map { $0 != 0 } == true) + || (kind == "dynamic_tool" && item["output"]["isError"].bool) + } + + fileprivate var canCollapseWork: Bool { + ["reasoning", "command_execution", "file_change", "file_search", "web_search", "dynamic_tool"] + .contains(kind) + && !failed && !["cancelled", "declined", "interrupted"].contains(item["status"].string ?? "") + && item["toolNonExecutionKind"].string == nil + } + + fileprivate func sharesWorkContext(with other: TimelineRow) -> Bool { + sourceID == other.sourceID && inherited == other.inherited + && ["runId", "nodeId", "providerThreadId", "providerTurnId", "parentItemId"].allSatisfy { + item[$0] == other.item[$0] + } + } +} diff --git a/apps/macos/Sources/T3Core/CoreWorkspaceDefaults.swift b/apps/macos/Sources/T3Core/CoreWorkspaceDefaults.swift new file mode 100644 index 0000000000000..99716516b1292 --- /dev/null +++ b/apps/macos/Sources/T3Core/CoreWorkspaceDefaults.swift @@ -0,0 +1,215 @@ +import Foundation + +public enum WorkspaceDefaults { + /// Nil means inherit from t3.json; a present null override masks the environment and legacy value. + public static func configuredMode(settings: JSON, project: JSON) -> String? { + if let projectID = project["id"].string, + case .object(let overrides) = settings["projectSettingsOverrides"][projectID], + let value = overrides["defaultThreadEnvMode"] + { + return mode(value) + } + if !settings["projectSettingsFolded"].bool, project["defaultThreadEnvMode"] != .null { + return mode(project["defaultThreadEnvMode"]) + } + return mode(settings["defaultThreadEnvMode"]) + } + + /// Invalid files, files over 1 MiB or depth 64, and absent modes defer to the local default. + public static func fileMode(contents: String) -> String? { + guard contents.utf8.count <= 1_048_576, let bytes = jsonc(contents) else { return nil } + var reader = JSONCReader(bytes: bytes) + guard case .object(let fields) = reader.parse(), validProjectFile(fields) else { return nil } + return mode(fields["defaultThreadEnvMode"] ?? .null) + } + + private static func mode(_ value: JSON) -> String? { + guard let string = value.string, string == "local" || string == "worktree" else { return nil } + return string + } + + // Matches packages/contracts/src/t3ProjectFile.ts. Unknown fields are ignored by that decoder. + private static func validProjectFile(_ fields: [String: JSON]) -> Bool { + if let schema = fields["$schema"], schema.string == nil { return false } + if let icon = fields["iconPath"], !nonempty(icon, maxLength: 512) { return false } + if let value = fields["defaultThreadEnvMode"], mode(value) == nil { return false } + if let value = fields["worktreeSubmodules"], + !["recursive", "top-level", "none"].contains(value.string ?? "") + { + return false + } + if let scripts = fields["scripts"] { + guard case .array(let values) = scripts, values.count <= 50 else { return false } + for script in values { + guard case .object(let fields) = script, + nonempty(script["name"]), nonempty(script["command"]) + else { return false } + if let icon = fields["icon"], + !["play", "test", "lint", "configure", "build", "debug"].contains(icon.string ?? "") + { + return false + } + if let url = fields["previewUrl"], !nonempty(url) { return false } + for key in ["runOnWorktreeCreate", "async", "autoOpenPreview"] { + if let value = fields[key] { + guard case .bool = value else { return false } + } + } + } + } + return true + } + + private static func nonempty(_ value: JSON, maxLength: Int? = nil) -> Bool { + guard let string = value.string, + !string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { return false } + return maxLength.map { string.utf16.count <= $0 } ?? true + } + + private static func jsonc(_ contents: String) -> [UInt8]? { + let input = Array(contents.utf8) + var bytes: [UInt8] = [] + bytes.reserveCapacity(input.count) + var index = 0 + var quoted = false + var escaped = false + while index < input.count { + let byte = input[index] + if quoted { + bytes.append(byte) + if escaped { + escaped = false + } else if byte == 92 { + escaped = true + } else if byte == 34 { + quoted = false + } + index += 1 + } else if byte == 34 { + quoted = true + bytes.append(byte) + index += 1 + } else if byte == 47 && index + 1 < input.count && input[index + 1] == 47 { + index += 2 + while index < input.count && input[index] != 10 && input[index] != 13 { index += 1 } + bytes.append(32) + } else if byte == 47 && index + 1 < input.count && input[index + 1] == 42 { + index += 2 + while index + 1 < input.count && !(input[index] == 42 && input[index + 1] == 47) { + index += 1 + } + guard index + 1 < input.count else { return nil } + index += 2 + bytes.append(32) + } else { + bytes.append(byte) + index += 1 + } + } + guard !quoted else { return nil } + + var previous: UInt8? + for index in bytes.indices { + let byte = bytes[index] + if quoted { + if escaped { + escaped = false + } else if byte == 92 { + escaped = true + } else if byte == 34 { + quoted = false + } + } else if byte == 34 { + quoted = true + } else if byte == 44 { + var next = index + 1 + while next < bytes.count && whitespace(bytes[next]) { next += 1 } + if next < bytes.count && (bytes[next] == 93 || bytes[next] == 125) { + guard let previous, ![44, 91, 123, 58].contains(previous) else { return nil } + bytes[index] = 32 + } + } + if !whitespace(byte) { previous = byte } + } + return bytes + } + + private static func whitespace(_ byte: UInt8) -> Bool { + byte == 32 || byte == 9 || byte == 10 || byte == 13 + } + + // Foundation object decoders keep the first duplicate key. Build objects in source order + // to match JSON.parse, and let Foundation validate scalar escapes, literals and numbers. + private struct JSONCReader { + let bytes: [UInt8] + var index = 0 + + mutating func parse() -> JSON? { + guard let value = value(depth: 0) else { return nil } + skipWhitespace() + return index == bytes.count ? value : nil + } + + private mutating func skipWhitespace() { + while index < bytes.count && WorkspaceDefaults.whitespace(bytes[index]) { index += 1 } + } + + private mutating func consume(_ byte: UInt8) -> Bool { + skipWhitespace() + guard index < bytes.count, bytes[index] == byte else { return false } + index += 1 + return true + } + + private mutating func value(depth: Int) -> JSON? { + skipWhitespace() + guard depth <= 64, index < bytes.count else { return nil } + if consume(123) { + var fields: [String: JSON] = [:] + if consume(125) { return .object(fields) } + while true { + guard let key = string(), consume(58), let child = value(depth: depth + 1) else { + return nil + } + fields[key] = child + if consume(125) { return .object(fields) } + guard consume(44) else { return nil } + } + } + if consume(91) { + var values: [JSON] = [] + if consume(93) { return .array(values) } + while true { + guard let child = value(depth: depth + 1) else { return nil } + values.append(child) + if consume(93) { return .array(values) } + guard consume(44) else { return nil } + } + } + if bytes[index] == 34 { return string().map(JSON.string) } + let start = index + while index < bytes.count && !WorkspaceDefaults.whitespace(bytes[index]) + && ![123, 125, 91, 93, 44, 58, 34].contains(bytes[index]) + { index += 1 } + guard index > start else { return nil } + return try? JSON.decode(Data(bytes[start.. String? { + skipWhitespace() + let start = index + guard consume(34) else { return nil } + while index < bytes.count { + let byte = bytes[index] + index += 1 + if byte == 92 { + index += 1 + } else if byte == 34 { + return (try? JSON.decode(Data(bytes[start.., _ b: ArraySlice, whitespace: Bool) -> Bool { + if a.elementsEqual(b) { return true } + guard whitespace else { return false } + func significant(_ byte: UInt8) -> Bool { byte != 32 && !(9...13).contains(byte) } + return a.lazy.filter(significant).elementsEqual(b.lazy.filter(significant)) + } + func finishHunk() throws { + if let expected, oldCursor != expected.old || newCursor != expected.new { throw Self.changed } + } + func appendGap(toOld: Int, toNew: Int, after: Bool) throws { + let count = toOld - oldCursor + guard count >= 0, count == toNew - newCursor, + toOld <= old.count, toNew <= new.count + else { throw Self.changed } + for offset in 0.. 0 { + let gap = Gap( + id: hunkCount, count: count, oldStart: oldCursor, newStart: newCursor, + before: hunkCount == 0, after: after) + parts.append(.gap(gap)) + gaps.append(gap) + } + oldCursor = toOld + newCursor = toNew + } + for (index, line) in file.lines.enumerated() { + if line.kind == .hunk { + try finishHunk() + guard let hunk = ContextHunk(line.text) else { throw Self.changed } + try appendGap(toOld: hunk.oldOffset, toNew: hunk.newOffset, after: false) + expected = (hunk.oldOffset + hunk.oldCount, hunk.newOffset + hunk.newCount) + hunkCount += 1 + } + var displayed = line + let patchBytes = Array(line.text.utf8)[...] + let hasMarker = + index + 1 < file.lines.count + && file.lines[index + 1].kind == .metadata + && file.lines[index + 1].text.hasPrefix("\\ No newline at end of file") + if let number = line.oldLine { + guard number == oldCursor + 1, oldCursor < old.count, + same( + old.line(oldCursor), patchBytes, whitespace: line.kind == .context && ignoreWhitespace), + old.terminated(oldCursor) != hasMarker + else { throw Self.changed } + if line.kind == .context { displayed.oldText = old.text(oldCursor) } + oldCursor += 1 + } + if let number = line.newLine { + guard number == newCursor + 1, newCursor < new.count, + same( + new.line(newCursor), patchBytes, whitespace: line.kind == .context && ignoreWhitespace), + new.terminated(newCursor) != hasMarker + else { throw Self.changed } + if line.kind == .context { + displayed = DiffLine( + kind: .context, text: new.text(newCursor), oldLine: line.oldLine, + newLine: line.newLine, oldText: displayed.oldText) + } + newCursor += 1 + } + parts.append(.line(displayed)) + } + try finishHunk() + try appendGap(toOld: old.count, toNew: new.count, after: true) + original = file + self.old = old + self.new = new + self.parts = parts + self.gaps = gaps + } + + public func render(expanded: [Int: Int]) -> (file: DiffFile, limited: Bool) { + var file = original + file.lines = [] + var characters = 0 + var limited = false + func append(_ line: DiffLine) -> Bool { + let cost = line.text.utf16.count + (line.oldText?.utf16.count ?? line.text.utf16.count) + 64 + guard file.lines.count < 10_000, cost <= 1_000_000 - characters else { + limited = true + return false + } + characters += cost + file.lines.append(line) + return true + } + func context(_ gap: Gap, _ range: Range) -> Bool { + for offset in range { + let oldIndex = gap.oldStart + offset + let newIndex = gap.newStart + offset + let line = DiffLine( + kind: .context, text: new.text(newIndex), oldLine: oldIndex + 1, + newLine: newIndex + 1, oldText: old.text(oldIndex)) + if !append(line) { return false } + } + return true + } + for part in parts { + switch part { + case .line(let line): if !append(line) { return (file, limited) } + case .gap(let gap): + let count = max(0, min(gap.count, expanded[gap.id] ?? 0)) + let leading = gap.after ? count : gap.before ? 0 : (count + 1) / 2 + let trailing = count - leading + if !context(gap, 0.. DiffFile { + var result = file + result.lines = [] + var oldEnd = 0 + var newEnd = 0 + var id = 0 + for line in file.lines { + if line.kind == .hunk, let hunk = ContextHunk(line.text) { + let count = hunk.oldOffset - oldEnd + if count > 0, count == hunk.newOffset - newEnd { + result.lines.append(gapLine(id, "Show more context · \(count) unchanged lines")) + } + oldEnd = hunk.oldOffset + hunk.oldCount + newEnd = hunk.newOffset + hunk.newCount + id += 1 + } + result.lines.append(line) + } + result.lines.append(gapLine(id, "Show remaining context")) + return result + } + + private static func gapLine(_ id: Int, _ title: String) -> DiffLine { + DiffLine(kind: .gap, text: title, oldLine: nil, newLine: nil, contextID: id) + } + private static var changed: T3Error { + T3Error( + "The file changed since this patch was loaded. Refresh Changes before expanding context.") + } +} + +private struct ContextLines: Sendable { + let bytes: [UInt8] + let starts: [Int] + var count: Int { starts.count } + init(_ text: String) throws { + guard text.utf8.count <= 1_000_000 else { + throw T3Error("This file exceeds the 1 MB context limit.") + } + bytes = Array(text.utf8) + var starts: [Int] = bytes.isEmpty ? [] : [0] + for index in bytes.indices where bytes[index] == 10 && index + 1 < bytes.count { + starts.append(index + 1) + } + self.starts = starts + } + private func end(_ index: Int) -> Int { index + 1 < count ? starts[index + 1] : bytes.count } + func terminated(_ index: Int) -> Bool { bytes[end(index) - 1] == 10 } + func line(_ index: Int) -> ArraySlice { + bytes[starts[index]..<(end(index) - (terminated(index) ? 1 : 0))] + } + func text(_ index: Int) -> String { String(decoding: line(index), as: UTF8.self) } +} + +private struct ContextHunk { + let oldOffset: Int + let oldCount: Int + let newOffset: Int + let newCount: Int + private static let pattern = try! NSRegularExpression( + pattern: "^@@ -([0-9]+)(?:,([0-9]+))? \\+([0-9]+)(?:,([0-9]+))? @@") + init?(_ line: String) { + let text = line as NSString + guard + let match = Self.pattern.firstMatch( + in: line, range: NSRange(location: 0, length: text.length)) + else { return nil } + func number(_ index: Int) -> Int? { + let range = match.range(at: index) + return range.location == NSNotFound ? 1 : Int(text.substring(with: range)) + } + guard let old = number(1), let oldCount = number(2), let new = number(3), + let newCount = number(4), + old < Int.max - oldCount, new < Int.max - newCount, + oldCount == 0 || old > 0, newCount == 0 || new > 0 + else { return nil } + oldOffset = old - (oldCount == 0 ? 0 : 1) + self.oldCount = oldCount + newOffset = new - (newCount == 0 ? 0 : 1) + self.newCount = newCount + } +} diff --git a/apps/macos/Sources/T3Core/DraftRepository.swift b/apps/macos/Sources/T3Core/DraftRepository.swift new file mode 100644 index 0000000000000..2c2faeb5d6519 --- /dev/null +++ b/apps/macos/Sources/T3Core/DraftRepository.swift @@ -0,0 +1,134 @@ +import CryptoKit +import Foundation + +public struct PendingSubmission: Codable, Sendable, Equatable { + public let text: String + public let command: String + public let message: String + public let attachments: [JSON]? + public let localAttachmentIDs: [String]? + public let delivery: MessageDelivery? + public let queuedRunID: String? + public let retainedAttachmentIDs: [String]? + public let context: JSON? + public let continuationOf: String? + public let launch: JSON? + public init( + text: String, command: String = UUID().uuidString, message: String = UUID().uuidString, + attachments: [JSON]? = nil, localAttachmentIDs: [String]? = nil, + delivery: MessageDelivery? = nil, queuedRunID: String? = nil, + retainedAttachmentIDs: [String]? = nil, context: JSON? = nil, continuationOf: String? = nil, + launch: JSON? = nil + ) { + self.text = text + self.command = command + self.message = message + self.attachments = attachments + self.localAttachmentIDs = localAttachmentIDs + self.delivery = delivery + self.queuedRunID = queuedRunID + self.retainedAttachmentIDs = retainedAttachmentIDs + self.context = context + self.continuationOf = continuationOf + self.launch = launch + } + public func matches( + text: String, attachments: [DraftAttachment], delivery: MessageDelivery = .auto, + edit: QueuedEdit? = nil + ) -> Bool { + self.text == text && (localAttachmentIDs ?? []) == attachments.map(\.id) + && (self.delivery ?? .auto) == delivery && queuedRunID == edit?.runID + && (retainedAttachmentIDs ?? []) == (edit?.attachments.compactMap { $0["id"].string } ?? []) + } + public func payload(threadID: String) -> JSON { + guard let queuedRunID else { + return Commands.message( + threadID: threadID, text: text, commandID: command, messageID: message, + attachments: attachments ?? [], delivery: delivery ?? .auto, continuationOf: continuationOf) + } + var fields: [String: JSON] = [ + "type": .string("queued-run.edit"), "commandId": .string(command), + "threadId": .string(threadID), "runId": .string(queuedRunID), "text": .string(text), + "attachments": .array(attachments ?? []), + ] + if let context, context != .null { fields["context"] = context } + return .object(fields) + } +} + +public struct SavedDrafts: Codable, Sendable, Equatable { + public var version = 5 + public var messages: [String: String] = [:] + public var submissions: [String: PendingSubmission] = [:] + public var questions: [String: [String: QuestionDraft]] = [:] + public var attachments: [String: [DraftAttachment]]? + public var queuedEdits: [String: QueuedEdit]? + public var deliveries: [String: MessageDelivery]? + public var questionPages: [String: Int]? + public var questionResponses: [String: PendingQuestionResponse]? + public var planSubmissions: [String: PendingPlanSubmission]? + public var conversations: [String: NewConversationDraft]? + public init( + messages: [String: String] = [:], submissions: [String: PendingSubmission] = [:], + questions: [String: [String: QuestionDraft]] = [:], + attachments: [String: [DraftAttachment]]? = nil, + queuedEdits: [String: QueuedEdit]? = nil, deliveries: [String: MessageDelivery]? = nil, + questionPages: [String: Int]? = nil, + questionResponses: [String: PendingQuestionResponse]? = nil, + planSubmissions: [String: PendingPlanSubmission]? = nil, + conversations: [String: NewConversationDraft]? = nil + ) { + self.messages = messages + self.submissions = submissions + self.questions = questions + self.attachments = attachments + self.queuedEdits = queuedEdits + self.deliveries = deliveries + self.questionPages = questionPages + self.questionResponses = questionResponses + self.planSubmissions = planSubmissions + self.conversations = conversations + } +} + +public struct DraftRepository: Sendable { + public let directory: URL + public init(directory: URL) { self.directory = directory } + + public func attachmentRepository(for endpoint: URL) -> AttachmentRepository { + AttachmentRepository( + directory: file(for: endpoint).deletingPathExtension().appendingPathComponent("Attachments")) + } + + public func file(for endpoint: URL) -> URL { + var parts = URLComponents(url: endpoint, resolvingAgainstBaseURL: false)! + parts.path = "" + parts.host = parts.host?.lowercased() + if (parts.scheme == "https" && parts.port == 443) + || (parts.scheme == "http" && parts.port == 80) + { + parts.port = nil + } + let key = SHA256.hash(data: Data(parts.string!.utf8)).map { String(format: "%02x", $0) } + .joined() + return directory.appendingPathComponent(key + ".json") + } + + public func load(for endpoint: URL) throws -> SavedDrafts { + let file = file(for: endpoint) + guard FileManager.default.fileExists(atPath: file.path) else { return SavedDrafts() } + let drafts = try JSONDecoder().decode(SavedDrafts.self, from: Data(contentsOf: file)) + guard (1...5).contains(drafts.version) else { + throw T3Error("The saved drafts use a newer format.") + } + return drafts + } + + public func save(_ drafts: SavedDrafts, for endpoint: URL) throws { + try FileManager.default.createDirectory( + at: directory, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700]) + let file = file(for: endpoint) + try JSONEncoder().encode(drafts).write(to: file, options: .atomic) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: file.path) + } +} diff --git a/apps/macos/Sources/T3Core/FileNavigation.swift b/apps/macos/Sources/T3Core/FileNavigation.swift new file mode 100644 index 0000000000000..0089d729a03d5 --- /dev/null +++ b/apps/macos/Sources/T3Core/FileNavigation.swift @@ -0,0 +1,73 @@ +import Foundation + +public struct FileSourcePosition: Equatable, Sendable { + public let line: Int + public let column: Int? + + public init(line: Int, column: Int? = nil) { + self.line = max(1, line) + self.column = column.map { max(1, $0) } + } + + public func range(in source: String) -> NSRange { + let text = source as NSString + var start = 0 + var currentLine = 1 + var index = 0 + while index < text.length && currentLine < line { + let unit = text.character(at: index) + index += 1 + if unit == 13 || unit == 10 { + if unit == 13 && index < text.length && text.character(at: index) == 10 { index += 1 } + start = index + currentLine += 1 + } + } + var end = start + while end < text.length && ![10, 13].contains(text.character(at: end)) { end += 1 } + guard let column else { return NSRange(location: start, length: end - start) } + var offset = start + min(column - 1, end - start) + if offset < end { offset = text.rangeOfComposedCharacterSequence(at: offset).location } + return NSRange(location: offset, length: 0) + } +} + +public struct WorkspaceFileLink: Equatable, Sendable { + public let path: String + public let position: FileSourcePosition? + + public init?(url: URL, root: String) { + guard let path = WorkspaceMarkdown.workspacePath(url, root: root) else { return nil } + let parsed = Self.split( + path, fragment: URLComponents(url: url, resolvingAgainstBaseURL: false)?.fragment) + self.path = parsed.path + position = parsed.position + } + + static func split(_ path: String, fragment: String? = nil) + -> (path: String, position: FileSourcePosition?) + { + let text = path as NSString + let suffix = positionSuffix.firstMatch( + in: path, range: NSRange(location: 0, length: text.length)) + let fragmentText = (fragment ?? "") as NSString + let anchor = positionAnchor.firstMatch( + in: fragmentText as String, range: NSRange(location: 0, length: fragmentText.length)) + guard let match = suffix ?? anchor else { return (path, nil) } + let matchedText = suffix == nil ? fragmentText : text + let line = Int(matchedText.substring(with: match.range(at: 1))) + let column = + match.range(at: 2).location == NSNotFound + ? nil : Int(matchedText.substring(with: match.range(at: 2))) + return ( + suffix.map { text.substring(to: $0.range.location) } ?? path, + line.flatMap { + $0 > 0 ? FileSourcePosition(line: $0, column: column.flatMap { $0 > 0 ? $0 : nil }) : nil + } + ) + } + + private static let positionSuffix = try! NSRegularExpression(pattern: ":([0-9]+)(?::([0-9]+))?$") + private static let positionAnchor = try! NSRegularExpression( + pattern: "^L([0-9]+)(?:C([0-9]+))?$", options: .caseInsensitive) +} diff --git a/apps/macos/Sources/T3Core/FileOpen.swift b/apps/macos/Sources/T3Core/FileOpen.swift new file mode 100644 index 0000000000000..93dda4fb2ac61 --- /dev/null +++ b/apps/macos/Sources/T3Core/FileOpen.swift @@ -0,0 +1,58 @@ +import Foundation + +public struct FileOpenCapabilities: Equatable, Sendable { + public let editors: [String] + public let revealTitle: String? + public var canOpenDefault: Bool { editors.contains("file-manager") } + + public init(_ config: JSON = .null) { + var seen: Set = [] + editors = config["availableEditors"].array.compactMap(\.string).filter { + Self.labels[$0] != nil && seen.insert($0).inserted + } + if editors.contains("file-manager") && config["shellRevealInFileManager"].bool { + let kind = config["shellRevealInFileManagerKind"].string + let os = config["environment"]["platform"]["os"].string + switch kind ?? (os == "darwin" ? "finder" : os == "win32" ? "file-explorer" : "files") { + case "finder": revealTitle = "Reveal in Finder" + case "file-explorer": revealTitle = "Reveal in File Explorer" + default: revealTitle = "Reveal in Files" + } + } else { + revealTitle = nil + } + } + + public func launchInput( + path: String, root: String, editor: String, reveal: Bool = false, + position: FileSourcePosition? = nil + ) throws -> JSON { + guard editors.contains(editor), !reveal || (editor == "file-manager" && revealTitle != nil) + else { + throw T3Error("This environment does not support that file action.") + } + guard !path.isEmpty, root.hasPrefix("/") else { throw T3Error("The file path is unavailable.") } + var absolute = Self.absolutePath(path, root: root) + if editor != "file-manager", let position { + absolute += ":\(position.line)" + (position.column.map { ":\($0)" } ?? "") + } + var fields: [String: JSON] = ["cwd": .string(absolute), "editor": .string(editor)] + if reveal { fields["reveal"] = .bool(true) } + return .object(fields) + } + + public static func absolutePath(_ path: String, root: String) -> String { + ((path.hasPrefix("/") ? path : (root as NSString).appendingPathComponent(path)) as NSString) + .standardizingPath + } + + public static func label(_ editor: String) -> String { labels[editor] ?? editor } + private static let labels = [ + "cursor": "Cursor", "trae": "Trae", "kiro": "Kiro", "vscode": "VS Code", + "vscode-insiders": "VS Code Insiders", "vscodium": "VSCodium", "zed": "Zed", + "antigravity": "Antigravity", "idea": "IntelliJ IDEA", "aqua": "Aqua", "clion": "CLion", + "datagrip": "DataGrip", "dataspell": "DataSpell", "goland": "GoLand", "phpstorm": "PhpStorm", + "pycharm": "PyCharm", "rider": "Rider", "rubymine": "RubyMine", "rustrover": "RustRover", + "webstorm": "WebStorm", "file-manager": "File Manager", + ] +} diff --git a/apps/macos/Sources/T3Core/FilePreview.swift b/apps/macos/Sources/T3Core/FilePreview.swift new file mode 100644 index 0000000000000..a3f66790cfff6 --- /dev/null +++ b/apps/macos/Sources/T3Core/FilePreview.swift @@ -0,0 +1,171 @@ +import Foundation + +public enum FilePreviewKind: String, Sendable { + case markdown, csv, tsv, html, source + public init(path: String) { + switch (path as NSString).pathExtension.lowercased() { + case "md", "mdx": self = .markdown + case "csv": self = .csv + case "tsv": self = .tsv + case "htm", "html": self = .html + default: self = .source + } + } + public var delimiter: UInt16? { + switch self { + case .csv: 44 + case .tsv: 9 + default: nil + } + } +} + +public struct DelimitedFilePreview: Equatable, Sendable { + public let rows: [[String]] + public let truncated: Bool + public init(_ source: String, delimiter: UInt16) { + let text = Array(source.utf16) + var rows: [[String]] = [] + var row: [String] = [] + var cell: [UInt16] = [] + var quoted = false + var truncated = false + var rowStart = text.first == 0xfeff ? 1 : 0 + var index = rowStart + func endCell() { + if row.count < 30 { + row.append(String(decoding: cell, as: UTF16.self)) + } else { + truncated = true + } + cell.removeAll(keepingCapacity: true) + } + while index < text.count { + let char = text[index] + if char == 34 { + if quoted && index + 1 < text.count && text[index + 1] == 34 { + if cell.count < 2000 { cell.append(34) } else { truncated = true } + index += 2 + continue + } + if quoted || cell.isEmpty { + quoted.toggle() + index += 1 + continue + } + } + if !quoted && (char == delimiter || char == 10 || char == 13) { + endCell() + if char != delimiter { + rows.append(row) + row = [] + if char == 13 && index + 1 < text.count && text[index + 1] == 10 { index += 1 } + rowStart = index + 1 + if rows.count == 100 { + self.rows = rows + self.truncated = truncated || index < text.count - 1 + return + } + } + } else if cell.count < 2000 { + cell.append(char) + } else { + truncated = true + } + index += 1 + } + if rowStart < text.count { + endCell() + rows.append(row) + } + self.rows = rows + self.truncated = truncated || quoted + } +} + +public enum WorkspaceMarkdown { + public static func settingTask(_ source: String, offset: Int, checked: Bool) -> String { + let text = source as NSString + guard offset >= 0, offset <= text.length - 3, + text.character(at: offset) == 91, text.character(at: offset + 2) == 93, + [32, 88, 120].contains(text.character(at: offset + 1)) + else { return source } + return text.replacingCharacters( + in: NSRange(location: offset + 1, length: 1), with: checked ? "x" : " ") + } + + public static func link(_ destination: String, filePath: String, root: String?) -> URL? { + var destination = destination.trimmingCharacters(in: .whitespacesAndNewlines) + if destination.hasPrefix("<") && destination.hasSuffix(">") { + destination = String(destination.dropFirst().dropLast()) + } + guard !destination.isEmpty else { return nil } + if let url = URL(string: destination), let scheme = url.scheme, + ["http", "https", "mailto", "file"].contains(scheme.lowercased()) + { + return url + } + guard let root else { return nil } + // Bare filenames with :line look like URL schemes to Foundation. + if let url = URL(string: destination), url.scheme != nil { + let parsed = WorkspaceFileLink.split(destination) + guard parsed.path != destination, + !(parsed.path as NSString).pathExtension.isEmpty + || ["Makefile", "Dockerfile", "README", "LICENSE", "AGENTS"].contains(parsed.path) + else { return nil } + destination = "./" + destination + } + guard let parts = URLComponents(string: destination), parts.host == nil else { return nil } + if parts.path.isEmpty { + guard !filePath.isEmpty, + WorkspaceFileLink.split("", fragment: parts.fragment).position != nil + else { return nil } + } + let base = + filePath.hasPrefix("/") ? filePath : (root as NSString).appendingPathComponent(filePath) + let directory = filePath.isEmpty ? root : (base as NSString).deletingLastPathComponent + let path = + parts.path.isEmpty + ? base + : parts.path.hasPrefix("/") + ? parts.path + : (directory as NSString).appendingPathComponent(parts.path) + var result = URLComponents( + url: URL(fileURLWithPath: (path as NSString).standardizingPath), + resolvingAgainstBaseURL: false) + result?.fragment = parts.fragment + return result?.url + } + + public static func workspacePath(_ url: URL, root: String) -> String? { + guard url.isFileURL, url.host == nil || url.host == "" || url.host == "localhost" else { + return nil + } + let path = (url.path as NSString).standardizingPath + let root = (root as NSString).standardizingPath + if path == root { return "." } + let prefix = root == "/" ? "/" : root + "/" + return path.hasPrefix(prefix) ? String(path.dropFirst(prefix.count)) : path + } +} + +public struct FilePreviewPreferences { + private let defaults: UserDefaults + public init(defaults: UserDefaults = .standard) { self.defaults = defaults } + public func rendered(_ kind: FilePreviewKind) -> Bool { + switch kind { + case .markdown: defaults.bool(forKey: "filePreview.renderMarkdown") + case .csv, .tsv: defaults.object(forKey: "filePreview.renderTable") as? Bool ?? true + case .html: defaults.object(forKey: "filePreview.renderHTML") as? Bool ?? true + case .source: false + } + } + public func setRendered(_ value: Bool, for kind: FilePreviewKind) { + switch kind { + case .markdown: defaults.set(value, forKey: "filePreview.renderMarkdown") + case .csv, .tsv: defaults.set(value, forKey: "filePreview.renderTable") + case .html: defaults.set(value, forKey: "filePreview.renderHTML") + case .source: break + } + } +} diff --git a/apps/macos/Sources/T3Core/FileSaveSchedule.swift b/apps/macos/Sources/T3Core/FileSaveSchedule.swift new file mode 100644 index 0000000000000..9753d7351737f --- /dev/null +++ b/apps/macos/Sources/T3Core/FileSaveSchedule.swift @@ -0,0 +1,78 @@ +import Foundation + +/// One file's debounce and single-write state. Times are monotonic seconds. +public struct FileSaveSchedule: Sendable { + public private(set) var revision = 0 + public private(set) var confirmedRevision = 0 + public private(set) var inFlight: Int? + public private(set) var deadline: TimeInterval? + private var lastChange: TimeInterval = 0 + private var urgent = false + public var pending: Bool { revision > confirmedRevision } + public init() {} + + public mutating func change(at now: TimeInterval) { + revision += 1 + lastChange = now + deadline = urgent ? now : now + 0.5 + } + + public mutating func flush(at now: TimeInterval) { + guard pending else { return } + urgent = true + deadline = now + } + + public mutating func begin(at now: TimeInterval) -> Int? { + guard inFlight == nil, pending, let deadline, deadline <= now else { return nil } + inFlight = revision + self.deadline = nil + return revision + } + + public mutating func complete(_ savedRevision: Int, succeeded: Bool, at now: TimeInterval) { + guard inFlight == savedRevision else { return } + inFlight = nil + if succeeded { confirmedRevision = savedRevision } + if revision > savedRevision { + deadline = urgent ? now : max(now, lastChange + 0.5) + } else { + // A failed latest write waits for another edit or an explicit retry. + deadline = nil + urgent = false + } + } +} + +/// Captures the connection and root before navigation. A delayed save cannot target the next workspace. +public struct WorkspaceFileAccess: Sendable { + private let connection: RPCConnection + public let location: WorkspaceLocation + public init(connection: RPCConnection, location: WorkspaceLocation) { + self.connection = connection + self.location = location + } + public func read(_ path: String) async throws -> WorkspaceFileDocument { + try await WorkspaceFileDocument( + connection.call( + "projects.readFile", + payload: .object([ + "cwd": .string(location.root), "relativePath": .string(path), + ]))) + } + public func save( + _ document: WorkspaceFileDocument, + beforeWrite: @Sendable (String) async throws -> Void = { _ in } + ) async throws { + let latest = try await read(document.path) + try document.validateSave(against: latest) + if latest.contents == document.contents { return } + try await beforeWrite(latest.contents) + _ = try await connection.call( + "projects.writeFile", + payload: .object([ + "cwd": .string(location.root), "relativePath": .string(document.path), + "contents": .string(document.contents), + ])) + } +} diff --git a/apps/macos/Sources/T3Core/JSON.swift b/apps/macos/Sources/T3Core/JSON.swift new file mode 100644 index 0000000000000..1b8a86ec0df87 --- /dev/null +++ b/apps/macos/Sources/T3Core/JSON.swift @@ -0,0 +1,86 @@ +import Foundation + +public enum JSON: Codable, Sendable, Equatable { + case object([String: JSON]) + case array([JSON]) + case string(String) + case number(Double) + case bool(Bool) + case null + + public init(from decoder: any Decoder) throws { + let value = try decoder.singleValueContainer() + if value.decodeNil() { + self = .null + } else if let x = try? value.decode(Bool.self) { + self = .bool(x) + } else if let x = try? value.decode(String.self) { + self = .string(x) + } else if let x = try? value.decode(Double.self) { + self = .number(x) + } else if let x = try? value.decode([JSON].self) { + self = .array(x) + } else { + self = .object(try value.decode([String: JSON].self)) + } + } + + public func encode(to encoder: any Encoder) throws { + var value = encoder.singleValueContainer() + switch self { + case .object(let x): try value.encode(x) + case .array(let x): try value.encode(x) + case .string(let x): try value.encode(x) + case .number(let x): try value.encode(x) + case .bool(let x): try value.encode(x) + case .null: try value.encodeNil() + } + } + + public subscript(_ key: String) -> JSON { + guard case .object(let x) = self else { return .null } + return x[key] ?? .null + } + public var string: String? { if case .string(let x) = self { x } else { nil } } + public var array: [JSON] { if case .array(let x) = self { x } else { [] } } + public var int: Int? { + if case .number(let x) = self, x.isFinite, x >= 0, x < Double(Int.max) { Int(x) } else { nil } + } + public var bool: Bool { self == .bool(true) } + public func requiredString(_ key: String) throws -> String { + guard let x = self[key].string else { throw T3Error("Missing field: \(key)") } + return x + } + public func requiredInt(_ key: String) throws -> Int { + guard let x = self[key].int else { throw T3Error("Missing sequence: \(key)") } + return x + } + public func data() throws -> Data { try JSONEncoder().encode(self) } + public var displayText: String { + if let string { return string } + if self == .null { return "" } + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + return (try? encoder.encode(self)).flatMap { String(data: $0, encoding: .utf8) } ?? "" + } + public static func decode(_ data: Data) throws -> JSON { + try JSONDecoder().decode(JSON.self, from: data) + } +} + +public struct T3Error: LocalizedError, Sendable { + public let message: String + public let authentication: Bool + public let remoteCode: String? + public let remoteFailure: String? + public init( + _ message: String, authentication: Bool = false, + remoteCode: String? = nil, remoteFailure: String? = nil + ) { + self.message = message + self.authentication = authentication + self.remoteCode = remoteCode + self.remoteFailure = remoteFailure + } + public var errorDescription: String? { message } +} diff --git a/apps/macos/Sources/T3Core/ModelSearch.swift b/apps/macos/Sources/T3Core/ModelSearch.swift new file mode 100644 index 0000000000000..f67a4d2caf54d --- /dev/null +++ b/apps/macos/Sources/T3Core/ModelSearch.swift @@ -0,0 +1,118 @@ +import Foundation + +public struct ModelIdentity: Codable, Hashable, Sendable { + public let instanceID: String + public let modelID: String + public init(instanceID: String, modelID: String) { + self.instanceID = instanceID + self.modelID = modelID + } + public init(_ selection: JSON) { + self.init( + instanceID: selection["instanceId"].string ?? "", modelID: selection["model"].string ?? "") + } +} + +public struct ModelChoice: Sendable, Equatable, Identifiable { + public let provider: ProviderEntry + public let model: ProviderModel + public var id: ModelIdentity { ModelIdentity(instanceID: provider.id, modelID: model.id) } + fileprivate let fields: [String] +} + +public struct ModelSearch: Sendable { + private let choices: [ModelChoice] + + public init(catalog: ProviderCatalog) { + choices = catalog.providers.flatMap { provider in + provider.models.map { model in + let names = [ + model.name, model.shortName, model.subProvider, provider.driver, provider.name, + ] + .compactMap { $0 } + return ModelChoice( + provider: provider, model: model, + fields: (names + [names.joined(separator: " ")]).map { + $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + }) + } + } + } + + public func results( + query: String, instanceID: String?, favoritesOnly: Bool = false, + favorites: Set = [] + ) -> [ModelChoice] { + let tokens = query.lowercased().split(whereSeparator: \.isWhitespace).map(String.init) + if tokens.isEmpty { + return choices.enumerated().filter { _, choice in + (instanceID == nil || choice.provider.id == instanceID) + && (!favoritesOnly || favorites.contains(choice.id)) + }.sorted { a, b in + let aFavorite = favorites.contains(a.element.id) + let bFavorite = favorites.contains(b.element.id) + if aFavorite != bFavorite { return aFavorite } + return a.offset < b.offset + }.map(\.element) + } + // Searching spans accounts even while a provider or Favorites tab is selected. + let normalizedQuery = tokens.joined(separator: " ") + func isExactModel(_ choice: ModelChoice) -> Bool { + choice.model.name.lowercased().split(whereSeparator: \.isWhitespace).joined(separator: " ") + == normalizedQuery + } + return choices.enumerated().compactMap { offset, choice -> (ModelChoice, Int, Int, Bool)? in + var total = 0 + for token in tokens { + let matches = choice.fields.enumerated().compactMap { fieldIndex, field in + Self.score(field: field, token: token, base: fieldIndex * 10) + } + guard let best = matches.min() else { return nil } + total += best + } + if favorites.contains(choice.id) { total -= 24 } + return (choice, total, offset, isExactModel(choice)) + }.sorted { a, b in + if a.3 != b.3 { return a.3 } + if a.1 != b.1 { return a.1 < b.1 } + let aFavorite = favorites.contains(a.0.id) + let bFavorite = favorites.contains(b.0.id) + if aFavorite != bFavorite { return aFavorite } + // Keep precise matches and favorites ahead; prefer current models only on a relevance tie. + if a.0.model.legacy != b.0.model.legacy { return !a.0.model.legacy } + let order = (a.0.fields.last ?? "").localizedCompare(b.0.fields.last ?? "") + return order == .orderedSame ? a.2 < b.2 : order == .orderedAscending + }.map { $0.0 } + } + + private static func score(field: String, token: String, base: Int) -> Int? { + guard !field.isEmpty else { return nil } + if field == token { return base } + let lengthPenalty = min(64, max(0, field.count - token.count)) + if field.hasPrefix(token) { return base + 2 + lengthPenalty } + let boundary = [" ", "-", "_", "/"].compactMap { marker -> Int? in + guard let range = field.range(of: marker + token) else { return nil } + return field.distance(from: field.startIndex, to: range.lowerBound) + 1 + }.min() + if let boundary { return base + 4 + boundary * 2 + lengthPenalty } + if let range = field.range(of: token) { + return base + 6 + field.distance(from: field.startIndex, to: range.lowerBound) * 2 + + lengthPenalty + } + guard token.count >= 3 else { return nil } + let query = Array(token) + var queryIndex = 0 + var first = 0 + var previous = 0 + var gaps = 0 + for (index, character) in field.enumerated() where character == query[queryIndex] { + if queryIndex == 0 { first = index } else { gaps += index - previous - 1 } + previous = index + queryIndex += 1 + if queryIndex == query.count { + return base + 100 + first * 2 + gaps * 3 + index - first + 1 - query.count + lengthPenalty + } + } + return nil + } +} diff --git a/apps/macos/Sources/T3Core/Models.swift b/apps/macos/Sources/T3Core/Models.swift new file mode 100644 index 0000000000000..a58b5258623d9 --- /dev/null +++ b/apps/macos/Sources/T3Core/Models.swift @@ -0,0 +1,529 @@ +import Foundation + +public struct ThreadSummary: Sendable, Equatable, Identifiable { + public let id: String + public let projectID: String + public let title: String + public let updatedAt: String + public let status: String + public let activeRunID: String? + public let model: JSON + public let runtimeMode: String + public let interactionMode: String + public let archived: Bool + public let lifecycle: ThreadLifecycle + public let branch: String? + public let worktreePath: String? + public let providerHistory: [String] + public let pullRequests: [JSON] + public let deleted: Bool + + public init(_ value: JSON) throws { + id = try value.requiredString("id") + projectID = try value.requiredString("projectId") + title = try value.requiredString("title") + updatedAt = value["updatedAt"].string ?? "" + status = value["activityRunStatus"].string ?? value["status"].string ?? "idle" + activeRunID = value["activeRunId"].string + model = value["modelSelection"] + runtimeMode = value["runtimeMode"].string ?? "approval-required" + interactionMode = value["interactionMode"].string ?? "default" + archived = value["archivedAt"].string != nil + lifecycle = ThreadLifecycle(value) + branch = value["branch"].string + worktreePath = value["worktreePath"].string + providerHistory = value["providerInstanceHistory"].array.compactMap(\.string) + let links = value["pullRequests"].array + pullRequests = + links.isEmpty + ? [value["branchPullRequest"], value["linkedPullRequest"]].filter { $0["url"].string != nil } + : links + deleted = value["deletedAt"].string != nil + } +} + +public struct ShellState: Sendable { + public private(set) var threads: [String: ThreadSummary] = [:] + public private(set) var projects: [String: String] = [:] + public private(set) var projectDetails: [String: JSON] = [:] + public private(set) var sequence = -1 + public private(set) var synchronized = false + public let archived: Bool + public init(archived: Bool = false) { self.archived = archived } + + public mutating func apply(_ value: JSON) throws { + switch value["kind"].string { + case "snapshot": + // Repository identity enrichment contains no authoritative thread list. + if case .array = value["resolvedRepositoryIdentityRoots"] { return } + let snapshot = value["snapshot"] + let nextSequence = try snapshot.requiredInt("snapshotSequence") + var next: [String: ThreadSummary] = [:] + for item in snapshot["threads"].array { + let thread = try ThreadSummary(item) + if !thread.deleted && thread.archived == archived { next[thread.id] = thread } + } + threads = next + projects = Dictionary( + snapshot["projects"].array.compactMap { project in + guard let id = project["id"].string else { return nil } + return (id, project["title"].string ?? project["name"].string ?? "Project") + }, uniquingKeysWith: { _, new in new }) + projectDetails = Dictionary( + snapshot["projects"].array.compactMap { project in + project["id"].string.map { ($0, project) } + }, uniquingKeysWith: { _, new in new }) + sequence = nextSequence + synchronized = archived + case "synchronized": synchronized = true + default: + let nextSequence = try value.requiredInt("sequence") + guard nextSequence > sequence else { return } + switch value["kind"].string { + case "thread.updated": + let thread = try ThreadSummary(value["thread"]) + let isArchived = value["location"].string.map { $0 == "archive" } ?? thread.archived + if thread.deleted || isArchived != archived { + threads.removeValue(forKey: thread.id) + } else { + threads[thread.id] = thread + } + case "thread.removed": threads.removeValue(forKey: try value.requiredString("threadId")) + case "project.updated": + let project = value["project"] + let id = try project.requiredString("id") + projectDetails[id] = project + projects[id] = + project["title"].string ?? project["name"].string ?? "Project" + case "project.removed": + let id = try value.requiredString("projectId") + projects.removeValue(forKey: id) + projectDetails.removeValue(forKey: id) + default: break + } + sequence = nextSequence + } + } +} + +public struct TimelineRow: Sendable, Equatable, Identifiable { + public let item: JSON + public let id: String + public let sourceID: String + public let kind: String + public let text: String + public let title: String + public let streaming: Bool + public let position: Int + public let inherited: Bool + public let attachments: [JSON] + + public init( + item: JSON, position: Int, sourceID: String, inherited: Bool = false, + request: JSON = .null + ) throws { + self.item = item + self.sourceID = sourceID + id = sourceID + ":" + (try item.requiredString("id")) + kind = try item.requiredString("type") + self.position = position + self.inherited = inherited + if kind == "user_input_request", + case .object(let files) = item["questionAnswer"]["attachmentsByQuestionId"] + { + let ordered = item["questions"].array.compactMap { $0["id"].string } + attachments = (ordered + files.keys.filter { !ordered.contains($0) }.sorted()).flatMap { + files[$0]?.array ?? [] + } + } else { + attachments = item["attachments"].array + } + streaming = item["streaming"].bool || item["status"].string == "running" + switch kind { + case "user_message": + switch item["inputIntent"].string { + case "queued_turn": title = "You · queued" + case "steer": title = "You · steer" + case "promoted_queued_to_steer": title = "You · sent from queue as steer" + default: title = "You" + } + text = try item.requiredString("text") + case "assistant_message": + title = "Assistant" + text = try item.requiredString("text") + case "reasoning": + title = "Thinking" + text = try item.requiredString("text") + case "proposed_plan": + title = "Plan" + text = item["markdown"].string ?? "" + case "command_execution": + title = "Terminal" + text = [item["input"].string, item["output"].string].compactMap { $0 }.joined(separator: "\n") + case "file_change": + title = "File change" + text = [item["fileName"].string, item["diffStr"].string].compactMap { $0 }.joined( + separator: "\n") + case "dynamic_tool": + title = item["toolName"].string ?? item["title"].string ?? "Tool" + text = [item["input"].displayText, item["output"].displayText].filter { !$0.isEmpty }.joined( + separator: "\n\n") + case "file_search": + title = "File search" + text = + ([item["pattern"].string].compactMap { $0 } + + item["results"].array.map { result in + (result["fileName"].string ?? "") + (result["line"].int.map { ":\($0)" } ?? "") + + (result["preview"].string.map { "\n" + $0 } ?? "") + }).joined(separator: "\n") + case "web_search": + title = "Web search" + text = + (item["patterns"].array.compactMap(\.string) + + item["results"].array.map { result in + [result["title"].string, result["url"].string, result["snippet"].string].compactMap { $0 } + .joined(separator: "\n") + }).joined(separator: "\n\n") + case "approval_request": + title = "Approval request" + text = item["prompt"].string ?? item["appName"].string ?? "Agent requested approval" + case "user_input_request": + let resolved = + !inherited && request["id"] == item["requestId"] + && request["kind"].string == "user_input" && request["status"].string == "resolved" + let answers = + item["questionAnswer"] != .null + ? item["questionAnswer"]["answers"] : resolved ? request["answers"] : .null + title = + item["status"].string == "completed" || answers != .null + ? "Answered questions" : "Question" + text = item["questions"].array.map { question in + let prompt = question["question"].string ?? "" + let reply = Self.answerText(answers[question["id"].string ?? ""]) + return reply.isEmpty ? prompt : prompt + "\n" + reply + }.joined(separator: "\n\n") + case "todo_list": + title = "Plan" + text = item["steps"].array.map { + ($0["status"].string == "completed" ? "✓ " : "○ ") + + ($0["step"].string ?? $0["title"].string ?? "") + }.joined(separator: "\n") + case "system_notice", "run_interrupt_request", "run_interrupt_result": + title = "Notice" + text = item["message"].string ?? "" + default: + title = kind.replacingOccurrences(of: "_", with: " ").capitalized + text = item["title"].string ?? item["toolName"].string ?? item["progress"].string ?? title + } + } + + private static func answerText(_ value: JSON) -> String { + switch value { + case .string(let text): text + case .array(let values): values.map(answerText).filter { !$0.isEmpty }.joined(separator: ", ") + case .object: answerText(value["answers"]) + default: "" + } + } +} + +public struct ThreadState: Sendable { + public let id: String + public private(set) var sequence = -1 + public private(set) var rows: [TimelineRow] = [] + public private(set) var cursor: String? + public private(set) var hasMore = false + public private(set) var synchronized = false + public private(set) var pendingRequests: [PendingRequest] = [] + public private(set) var execution = ThreadExecution() + public private(set) var checkpoints = ThreadCheckpoints() + public private(set) var proposedPlans: [ProposedPlan] = [] + private var planTimes: [String: String] = [:] + private var projectionUpdatedAt = "" + public private(set) var latestProposedPlan: ProposedPlan? + public private(set) var workspaceMutationToken: String? + private var workspaceMutationItem: (id: String, position: Int)? + private var latestCheckpointTime: String? + private var requests: [String: JSON] = [:] + private var requestItems: [String: JSON] = [:] + private var indices: [String: Int] = [:] + + public init(id: String) { self.id = id } + + public mutating func markUnsynchronized() { synchronized = false } + + /// Returns true when lineage or rollback invalidates the visible timeline. + public mutating func apply(_ value: JSON) throws -> Bool { + switch value["kind"].string { + case "snapshot": + let projection = value["projection"] + guard projection["thread"]["id"].string == id else { + throw T3Error("Snapshot belongs to another thread.") + } + let nextSequence = try value.requiredInt("snapshotSequence") + var nextRequests: [String: JSON] = [:] + for request in projection["runtimeRequests"].array { + nextRequests[try request.requiredString("id")] = request + } + var nextRequestItems: [String: JSON] = [:] + for item in projection["turnItems"].array { + if let id = item["requestId"].string { nextRequestItems[id] = item } + } + var next: [TimelineRow] = [] + for visible in projection["visibleTurnItems"].array { + next.append(try projectedRow(visible, requests: nextRequests)) + } + if next.isEmpty { + for item in projection["turnItems"].array { + next.append( + try TimelineRow( + item: item, position: item.requiredInt("ordinal"), sourceID: id, + request: nextRequests[item["requestId"].string ?? ""] ?? .null)) + } + } + rows = next.sorted { $0.position < $1.position } + workspaceMutationItem = rows.last(where: Self.mayChangeWorkspace).map { ($0.id, $0.position) } + latestCheckpointTime = + projection["checkpoints"].array.last(where: { + $0["appRunOrdinal"].int != nil && $0["runId"].string != nil + })?["capturedAt"].string + updateWorkspaceMutationToken() + execution = ThreadExecution(projection) + checkpoints = ThreadCheckpoints(projection) + projectionUpdatedAt = projection["updatedAt"].string ?? "" + planTimes = Dictionary( + projection["turnItems"].array.compactMap { item in + guard item["type"].string == "proposed_plan", let id = item["planId"].string else { + return nil + } + return (id, item["updatedAt"].string ?? projectionUpdatedAt) + }, uniquingKeysWith: max) + proposedPlans = projection["plans"].array.compactMap { + ProposedPlan($0, updatedAt: planTimes[$0["id"].string ?? ""] ?? projectionUpdatedAt) + } + latestProposedPlan = ProposedPlan.latest(proposedPlans, runID: execution.latestRunID) + requests = nextRequests + requestItems = nextRequestItems + for row in rows where !row.inherited { + if let id = row.item["requestId"].string { requestItems[id] = row.item } + } + try rebuildRequests() + reindex() + sequence = nextSequence + cursor = value["historyCursor"].string + hasMore = value["hasMoreHistory"].bool + synchronized = false + case "synchronized": synchronized = true + case "event": + let nextSequence = try value.requiredInt("sequence") + guard nextSequence > sequence else { return false } + let event = value["event"] + if let owner = event["threadId"].string, owner != id { + throw T3Error("Update belongs to another thread.") + } + let type = try event.requiredString("type") + let previousRun = execution.latestRunID + execution.apply(event) + checkpoints.apply(event) + if type.hasPrefix("context-transfer.") || type == "checkpoint.rollback-requested" + || type == "thread.provider-switched" + { + return true + } + if type == "turn-item.updated" { + let item = event["payload"] + guard item["threadId"].string == id else { + throw T3Error("Update belongs to another thread.") + } + let key = id + ":" + (try item.requiredString("id")) + if item["type"].string == "proposed_plan", let planID = item["planId"].string { + let time = item["updatedAt"].string ?? projectionUpdatedAt + planTimes[planID] = time + if let index = proposedPlans.firstIndex(where: { $0.id == planID }) { + proposedPlans[index].updateTime(time) + } + } + let request = requests[item["requestId"].string ?? ""] ?? .null + if let index = indices[key] { + rows[index] = try TimelineRow( + item: item, position: rows[index].position, sourceID: id, request: request) + } else { + let position = (rows.last?.position ?? -1) + 1 + let row = try TimelineRow( + item: item, position: position, sourceID: id, request: request) + indices[row.id] = rows.count + rows.append(row) + } + if item["type"].string == "command_execution" || item["type"].string == "file_change", + let index = indices[key] + { + let row = rows[index] + if Self.mayChangeWorkspace(row), row.position >= (workspaceMutationItem?.position ?? -1) { + workspaceMutationItem = (row.id, row.position) + } else if row.id == workspaceMutationItem?.id && !Self.mayChangeWorkspace(row) { + workspaceMutationItem = rows.last(where: Self.mayChangeWorkspace).map { + ($0.id, $0.position) + } + } + updateWorkspaceMutationToken() + } + if let id = item["requestId"].string { + requestItems[id] = item + try rebuildRequests() + } + } else if type == "checkpoint.captured" { + let checkpoint = event["payload"] + if checkpoint["appRunOrdinal"].int != nil, checkpoint["runId"].string != nil { + latestCheckpointTime = checkpoint["capturedAt"].string + updateWorkspaceMutationToken() + } + } else if type == "plan.updated" { + let value = event["payload"] + if let plan = ProposedPlan( + value, updatedAt: planTimes[value["id"].string ?? ""] ?? projectionUpdatedAt) + { + proposedPlans.removeAll { $0.id == plan.id } + proposedPlans.append(plan) + } + } else if type == "runtime-request.updated" { + let request = event["payload"] + let requestID = try request.requiredString("id") + _ = try request.requiredString("kind") + requests[requestID] = request + for index in rows.indices + where rows[index].sourceID == id && !rows[index].inherited + && rows[index].item["requestId"].string == requestID + { + let row = rows[index] + rows[index] = try TimelineRow( + item: row.item, position: row.position, sourceID: id, request: request) + } + try rebuildRequests() + } + if previousRun != execution.latestRunID || type == "plan.updated" + || (type == "turn-item.updated" && event["payload"]["type"].string == "proposed_plan") + { + latestProposedPlan = ProposedPlan.latest(proposedPlans, runID: execution.latestRunID) + } + sequence = nextSequence + default: break + } + return false + } + + private static func mayChangeWorkspace(_ row: TimelineRow) -> Bool { + (row.kind == "command_execution" || row.kind == "file_change") + && !["pending", "running", "waiting"].contains(row.item["status"].string ?? "") + } + private mutating func updateWorkspaceMutationToken() { + workspaceMutationToken = + workspaceMutationItem == nil && latestCheckpointTime == nil + ? nil : (workspaceMutationItem?.id ?? "") + "\u{0}" + (latestCheckpointTime ?? "") + } + + public mutating func prependHistory(_ page: JSON) throws { + var older: [TimelineRow] = [] + for visible in page["items"].array { + let row = try projectedRow(visible, requests: requests) + // A history response may have been generated before the latest live update. + if indices[row.id] == nil { older.append(row) } + } + rows = (older + rows).sorted { $0.position < $1.position } + if workspaceMutationItem == nil { + workspaceMutationItem = rows.last(where: Self.mayChangeWorkspace).map { ($0.id, $0.position) } + updateWorkspaceMutationToken() + } + reindex() + cursor = page["nextCursor"].string + hasMore = page["hasMoreHistory"].bool + } + + @discardableResult public mutating func hydrateOutput( + _ item: JSON, replacing original: TimelineRow + ) throws -> Bool { + guard let index = indices[original.id], rows[index] == original, + original.item["outputOmitted"].bool, + item["id"] == original.item["id"], item["threadId"].string == original.sourceID, + item["updatedAt"] == original.item["updatedAt"], + !item["outputOmitted"].bool, + case .object(var full) = original.item + else { return false } + full["output"] = item["output"] + full.removeValue(forKey: "outputOmitted") + rows[index] = try TimelineRow( + item: .object(full), position: original.position, sourceID: original.sourceID, + inherited: original.inherited) + return true + } + + private func projectedRow(_ visible: JSON, requests: [String: JSON]) throws -> TimelineRow { + let sourceID = try visible.requiredString("sourceThreadId") + return try TimelineRow( + item: visible["item"], position: visible.requiredInt("position"), + sourceID: sourceID, + inherited: visible["visibility"].string == "inherited", + request: sourceID == id + ? requests[visible["item"]["requestId"].string ?? ""] ?? .null : .null) + } + + private mutating func reindex() { + indices = Dictionary( + rows.enumerated().map { ($0.element.id, $0.offset) }, uniquingKeysWith: { _, new in new }) + } + + private mutating func rebuildRequests() throws { + pendingRequests = try requests.values.compactMap { request in + guard request["status"].string == "pending", + !["auth_refresh", "dynamic_tool_call"].contains(request["kind"].string ?? "") + else { return nil } + let item = requestItems[try request.requiredString("id")] ?? .null + if request["kind"].string == "user_input", item == .null { return nil } + return try PendingRequest(request: request, item: item) + }.sorted { ($0.createdAt, $0.id) < ($1.createdAt, $1.id) } + } +} + +public enum Commands { + public static func message( + threadID: String, text: String, commandID: String, messageID: String, attachments: [JSON] = [], + delivery: MessageDelivery = .auto, continuationOf: String? = nil, + sourcePlanRef: JSON? = nil, modelSelection: JSON? = nil + ) + -> JSON + { + var fields: [String: JSON] = [ + "type": .string("message.dispatch"), "commandId": .string(commandID), + "threadId": .string(threadID), "messageId": .string(messageID), "text": .string(text), + "createdBy": .string("user"), "creationSource": .string("web"), + "attachments": .array(attachments), + "dispatchMode": .object([ + "type": .string(delivery == .queue ? "queue_after_active" : "start_immediately") + ]), + ] + if let continuationOf { + fields["manualContinuationOfRunId"] = .string(continuationOf) + } else if delivery != .queue { + fields["deliveryIntent"] = .string(delivery.rawValue) + } + if let sourcePlanRef { fields["sourcePlanRef"] = sourcePlanRef } + if let modelSelection { fields["modelSelection"] = modelSelection } + return .object(fields) + } + + public static func newThread( + template: ThreadSummary, id: String, title: String = "New thread", + runtimeMode: String? = nil, interactionMode: String? = nil, inheritWorkspace: Bool = false + ) + -> JSON + { + .object([ + "type": .string("thread.create"), "commandId": .string(UUID().uuidString), + "threadId": .string(id), "projectId": .string(template.projectID), "title": .string(title), + "modelSelection": template.model, "runtimeMode": .string(runtimeMode ?? template.runtimeMode), + "interactionMode": .string(interactionMode ?? template.interactionMode), + "branch": inheritWorkspace ? template.branch.map(JSON.string) ?? .null : .null, + "worktreePath": inheritWorkspace ? template.worktreePath.map(JSON.string) ?? .null : .null, + "createdBy": .string("user"), "creationSource": .string("web"), + ]) + } +} diff --git a/apps/macos/Sources/T3Core/NewConversationDraft.swift b/apps/macos/Sources/T3Core/NewConversationDraft.swift new file mode 100644 index 0000000000000..d5888879a0030 --- /dev/null +++ b/apps/macos/Sources/T3Core/NewConversationDraft.swift @@ -0,0 +1,61 @@ +import Foundation + +public struct NewConversationDraft: Codable, Sendable, Equatable { + public let id: String + public var project: JSON + public var model: JSON + public var runtimeMode: String + public var interactionMode: String + public var workspaceMode: String + public var branch: String? + public let createdAt: String + + public init(project: JSON, configuration: NewThreadConfiguration, workspaceMode: String = "local") + { + id = UUID().uuidString + self.project = project + model = configuration.model + runtimeMode = configuration.runtimeMode + interactionMode = configuration.interactionMode + self.workspaceMode = workspaceMode + branch = nil + createdAt = ISO8601DateFormatter().string(from: Date()) + } + + public var summary: ThreadSummary? { + try? ThreadSummary( + .object([ + "id": .string(id), "projectId": project["id"], "title": .string("New conversation"), + "createdAt": .string(createdAt), + "updatedAt": .string(createdAt), "modelSelection": model, + "runtimeMode": .string(runtimeMode), "interactionMode": .string(interactionMode), + "branch": branch.map(JSON.string) ?? .null, + ])) + } + + public func launch(_ submission: PendingSubmission) throws -> JSON { + let strategy: JSON + if workspaceMode == "worktree" { + guard let branch, !branch.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw T3Error("Select a base branch before starting in a new worktree.") + } + strategy = .object(["type": .string("worktree"), "baseRef": .string(branch)]) + } else { + var fields: [String: JSON] = ["type": .string("root")] + if let branch { fields["branch"] = .string(branch) } + strategy = .object(fields) + } + let title = String(submission.text.trimmingCharacters(in: .whitespacesAndNewlines).prefix(120)) + return .object([ + "commandId": .string(submission.command), "threadId": .string(id), + "creationSource": .string("web"), "projectId": project["id"], + "title": .string(title.isEmpty ? "New conversation" : title), "generateTitle": .bool(true), + "modelSelection": model, "runtimeMode": .string(runtimeMode), + "interactionMode": .string(interactionMode), "workspaceStrategy": strategy, + "initialMessage": .object([ + "messageId": .string(submission.message), "text": .string(submission.text), + "attachments": .array(submission.attachments ?? []), + ]), + ]) + } +} diff --git a/apps/macos/Sources/T3Core/NewThreadConfiguration.swift b/apps/macos/Sources/T3Core/NewThreadConfiguration.swift new file mode 100644 index 0000000000000..83d538aa72bbd --- /dev/null +++ b/apps/macos/Sources/T3Core/NewThreadConfiguration.swift @@ -0,0 +1,18 @@ +import Foundation + +public struct NewThreadConfiguration: Sendable, Equatable { + public let projectID: String + public let model: JSON + public let runtimeMode: String + public let interactionMode: String + + public func command(id: String) -> JSON { + .object([ + "type": .string("thread.create"), "commandId": .string(UUID().uuidString), + "threadId": .string(id), "projectId": .string(projectID), "title": .string("New thread"), + "modelSelection": model, "runtimeMode": .string(runtimeMode), + "interactionMode": .string(interactionMode), "branch": .null, "worktreePath": .null, + "createdBy": .string("user"), "creationSource": .string("web"), + ]) + } +} diff --git a/apps/macos/Sources/T3Core/OwnedBackendProcess.swift b/apps/macos/Sources/T3Core/OwnedBackendProcess.swift new file mode 100644 index 0000000000000..36a5e52843985 --- /dev/null +++ b/apps/macos/Sources/T3Core/OwnedBackendProcess.swift @@ -0,0 +1,55 @@ +import Foundation + +/// A handle to exactly one child. Never adopt a PID discovered in the environment. +@MainActor public final class OwnedBackendProcess { + private let process = Process() + private var stdin: Pipe? + private var waiters: [CheckedContinuation] = [] + public private(set) var started = false + public var isRunning: Bool { started && process.isRunning } + public var processIdentifier: Int32? { started ? process.processIdentifier : nil } + public var exitStatus: Int32? { started && !process.isRunning ? process.terminationStatus : nil } + + public init() {} + public func start( + executable: URL, arguments: [String], directory: URL, + environment: [String: String], input: Data? = nil + ) throws { + guard !started else { throw T3Error("This backend process was already started.") } + process.executableURL = executable + process.arguments = arguments + process.currentDirectoryURL = directory + process.environment = environment + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + let pipe = Pipe() + stdin = pipe + process.standardInput = pipe + process.terminationHandler = { [weak self] _ in + Task { @MainActor in + guard let self else { return } + let waiters = self.waiters + self.waiters.removeAll() + for waiter in waiters { waiter.resume() } + } + } + try process.run() + started = true + do { + if let input { try pipe.fileHandleForWriting.write(contentsOf: input) } + try pipe.fileHandleForWriting.close() + } catch { + terminate() + throw T3Error("Could not deliver the server bootstrap configuration.") + } + } + public func terminate() { + if isRunning { process.terminate() } + } + public func waitForExit() async { + if !started || !process.isRunning { return } + await withCheckedContinuation { continuation in + waiters.append(continuation) + } + } +} diff --git a/apps/macos/Sources/T3Core/PlanDocument.swift b/apps/macos/Sources/T3Core/PlanDocument.swift new file mode 100644 index 0000000000000..fdc8a2f7b3b25 --- /dev/null +++ b/apps/macos/Sources/T3Core/PlanDocument.swift @@ -0,0 +1,100 @@ +import Foundation + +public struct PlanDocument: Equatable, Sendable { + public let heading: String? + public let body: String + public let preview: String + public let canCollapse: Bool + public let filename: String + public let contents: String + public var title: String { heading ?? "Proposed plan" } + + public init(_ markdown: String) { + let trimmed = Self.trimEnd(markdown) + contents = trimmed + "\n" + var lines = ArraySlice( + trimmed.replacingOccurrences(of: "\r\n", with: "\n") + .components(separatedBy: "\n")) + heading = lines.lazy.compactMap(Self.heading).first + var name = (heading ?? "plan").lowercased().replacingOccurrences( + of: #"[`'".,!?()\[\]{}]+"#, with: "", options: .regularExpression + ) + .replacingOccurrences(of: "[^a-z0-9]+", with: "-", options: .regularExpression) + .trimmingCharacters(in: CharacterSet(charactersIn: "-")) + if name.isEmpty { name = "plan" } + filename = name + ".md" + canCollapse = markdown.utf16.count > 900 || markdown.components(separatedBy: "\n").count > 20 + if let first = lines.first, Self.heading(first) != nil { lines = lines.dropFirst() } + lines = lines.drop { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + if let first = lines.first, Self.heading(first)?.lowercased() == "summary" { + lines = lines.dropFirst().drop { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + } + body = lines.joined(separator: "\n") + var previewLines: [String] = [] + var count = 0 + var overflow = false + for line in lines { + let line = Self.trimEnd(line) + let visible = !line.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + if visible && count == 10 { + overflow = true + break + } + previewLines.append(line) + if visible { count += 1 } + } + while previewLines.last?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == true { + previewLines.removeLast() + } + if previewLines.isEmpty { + preview = heading ?? "Plan preview unavailable." + } else { + if overflow { previewLines += ["", "..."] } + preview = previewLines.joined(separator: "\n") + } + } + + public static func heading(_ line: String) -> String? { + guard line.range(of: #"^\s{0,3}#{1,6}\s+(.+)$"#, options: .regularExpression) != nil else { + return nil + } + let result = line.replacingOccurrences( + of: #"^\s{0,3}#{1,6}\s+"#, with: "", options: .regularExpression + ) + .trimmingCharacters(in: .whitespacesAndNewlines) + return result.isEmpty ? nil : result + } + + private static func trimEnd(_ value: String) -> String { + String(value.dropLast(value.reversed().prefix { $0.isWhitespace }.count)) + } + + public func save(to url: URL) async throws { + let contents = contents + try await Task.detached { + try contents.write(to: url, atomically: true, encoding: .utf8) + }.value + } +} + +public struct WorkspaceLocation: Equatable, Sendable { + public let environment: String + public let threadID: String + public let root: String + public init?(environment: String, thread: ThreadSummary, project: JSON) { + guard let root = thread.worktreePath ?? project["workspaceRoot"].string, !root.isEmpty else { + return nil + } + self.environment = environment + threadID = thread.id + self.root = root + } + + public func writeInput(relativePath: String, contents: String) throws -> JSON { + let path = relativePath.trimmingCharacters(in: .whitespacesAndNewlines) + guard !path.isEmpty else { throw T3Error("Enter a workspace path.") } + return .object([ + "cwd": .string(root), "relativePath": .string(path), "contents": .string(contents), + ]) + } +} diff --git a/apps/macos/Sources/T3Core/ProposedPlans.swift b/apps/macos/Sources/T3Core/ProposedPlans.swift new file mode 100644 index 0000000000000..d815e8195f4cc --- /dev/null +++ b/apps/macos/Sources/T3Core/ProposedPlans.swift @@ -0,0 +1,79 @@ +import Foundation + +public struct ProposedPlan: Codable, Equatable, Sendable, Identifiable { + public let id: String + public let runID: String? + public let status: String + public let markdown: String + public let heading: String? + public let title: String + public private(set) var updatedAt: String + + public init?(_ value: JSON, updatedAt: String) { + guard value["kind"].string == "proposed_plan", let id = value["id"].string else { return nil } + self.id = id + runID = value["runId"].string + status = value["status"].string ?? "draft" + markdown = value["markdown"].string ?? "" + heading = + markdown.components(separatedBy: .newlines).lazy.compactMap(PlanDocument.heading).first + title = heading ?? "Plan" + self.updatedAt = updatedAt + } + + public var implementationPrompt: String { + "PLEASE IMPLEMENT THIS PLAN:\n" + markdown.trimmingCharacters(in: .whitespacesAndNewlines) + } + + mutating func updateTime(_ time: String) { updatedAt = time } + + public static func latest(_ plans: [ProposedPlan], runID: String?) -> ProposedPlan? { + let matching = runID.map { id in plans.filter { $0.runID == id } } ?? [] + return (matching.isEmpty ? plans : matching).max { + ($0.updatedAt, $0.id) < ($1.updatedAt, $1.id) + } + } +} + +/// Freeze both steps before dispatch: retrying after a lost acknowledgement must not create +/// another thread or implementation message, even after the first step changes plan mode. +public struct PendingPlanSubmission: Codable, Equatable, Sendable { + public let sourceThreadID: String + public let targetThreadID: String + public let plan: ProposedPlan + public let draft: String + public let setup: JSON + public let message: JSON + public var isNewThread: Bool { targetThreadID != sourceThreadID } + public var isImplementation: Bool { message["sourcePlanRef"] != .null } + + public init( + plan: ProposedPlan, template: ThreadSummary, draft: String, newThread: Bool, + defaultRuntimeMode: String + ) { + self.plan = plan + self.draft = draft + sourceThreadID = template.id + targetThreadID = newThread ? UUID().uuidString : template.id + let trimmed = draft.trimmingCharacters(in: .whitespacesAndNewlines) + let implementing = newThread || trimmed.isEmpty + let mode = implementing ? "default" : "plan" + if newThread { + setup = Commands.newThread( + template: template, id: targetThreadID, + title: String((plan.heading.map { "Implement " + $0 } ?? "Implement plan").prefix(120)), + runtimeMode: defaultRuntimeMode, interactionMode: mode, inheritWorkspace: true) + } else { + setup = .object([ + "type": .string("thread.interaction-mode.set"), "commandId": .string(UUID().uuidString), + "threadId": .string(template.id), "interactionMode": .string(mode), + ]) + } + message = Commands.message( + threadID: targetThreadID, text: implementing ? plan.implementationPrompt : trimmed, + commandID: UUID().uuidString, messageID: UUID().uuidString, + sourcePlanRef: implementing + ? .object(["threadId": .string(template.id), "planId": .string(plan.id)]) : nil, + modelSelection: template.model) + } +} diff --git a/apps/macos/Sources/T3Core/ProviderCatalog.swift b/apps/macos/Sources/T3Core/ProviderCatalog.swift new file mode 100644 index 0000000000000..41ea9c3fc616f --- /dev/null +++ b/apps/macos/Sources/T3Core/ProviderCatalog.swift @@ -0,0 +1,184 @@ +import Foundation + +public struct ProviderModel: Sendable, Equatable, Identifiable { + public let id: String + public let name: String + public let descriptors: [JSON] + public let legacy: Bool + public let shortName: String? + public let subProvider: String? + public let custom: Bool + public let badge: String? + + public init(_ value: JSON) throws { + id = try value.requiredString("slug") + name = value["name"].string ?? id + descriptors = value["capabilities"]["optionDescriptors"].array + legacy = value["isLegacy"].bool + shortName = value["shortName"].string + subProvider = value["subProvider"].string + custom = value["isCustom"].bool + badge = value["badge"].string + } + + public func selection( + instanceID: String, preserving previous: JSON? = nil, planModeEnabled: Bool = true + ) -> JSON { + let previousOptions = previous?["options"].array ?? [] + let options = descriptors.compactMap { descriptor -> JSON? in + guard let id = descriptor["id"].string else { return nil } + let old = previousOptions.first { $0["id"].string == id }?["value"] ?? .null + let value: JSON + if descriptor["type"].string == "boolean" { + if case .bool = old { value = old } else { value = .bool(descriptor["currentValue"].bool) } + } else { + let choices = descriptor["options"].array.filter { + planModeEnabled || id != "agent" || $0["id"].string != "plan" + } + value = + choices.contains { $0["id"] == old } + ? old + : choices.first { $0["id"] == descriptor["currentValue"] }?["id"] + ?? choices.first { $0["isDefault"].bool }?["id"] + ?? (id == "agent" ? choices.first?["id"] : nil) ?? .null + } + return value == .null ? nil : .object(["id": .string(id), "value": value]) + } + return .object([ + "instanceId": .string(instanceID), "model": .string(id), "options": .array(options), + ]) + } +} + +public struct ProviderEntry: Sendable, Equatable, Identifiable { + public let id: String + public let name: String + public let driver: String + public let accentColor: String? + public let models: [ProviderModel] + public let available: Bool + public let unavailableReason: String? + public let runtimeModes: [String] + public let showInteractionMode: Bool + public let requiresNewThreadForModelChange: Bool + + public init(_ value: JSON) throws { + id = try value.requiredString("instanceId") + name = value["displayName"].string ?? id + driver = value["driver"].string ?? id + accentColor = value["accentColor"].string + models = try value["models"].array.map(ProviderModel.init) + available = + value["enabled"].bool && value["installed"].bool + && value["status"].string != "disabled" && value["availability"].string != "unavailable" + unavailableReason = value["unavailableReason"].string ?? value["message"].string + runtimeModes = value["supportedRuntimeModes"].array.compactMap(\.string) + showInteractionMode = value["showInteractionModeToggle"].bool + requiresNewThreadForModelChange = value["requiresNewThreadForModelChange"].bool + } +} + +public struct ProviderCatalog: Sendable, Equatable { + public private(set) var providers: [ProviderEntry] = [] + public private(set) var planModeEnabled = false + public private(set) var attachmentPolicy = AttachmentPolicy() + public private(set) var fileOpen = FileOpenCapabilities() + public private(set) var newProjectsRoot: String? + public private(set) var scratchWorkspaceRoot: String? + private var defaultMode = "approval-required" + private var projectModes: [String: String] = [:] + private var settings: JSON = .null + public init(planModeEnabled: Bool = false) { self.planModeEnabled = planModeEnabled } + public mutating func setPlanModeEnabled(_ enabled: Bool) { planModeEnabled = enabled } + + public mutating func apply(_ event: JSON) throws { + switch event["type"].string { + case "snapshot": + providers = try event["config"]["providers"].array.map(ProviderEntry.init) + applySettings(event["config"]["settings"]) + attachmentPolicy = AttachmentPolicy(event["config"]["environment"]["capabilities"]) + fileOpen = FileOpenCapabilities(event["config"]) + newProjectsRoot = event["config"]["newProjectsRoot"].string + scratchWorkspaceRoot = event["config"]["scratchWorkspaceRoot"].string + case "providerStatuses": + providers = try event["payload"]["providers"].array.map(ProviderEntry.init) + case "settingsUpdated": + applySettings(event["payload"]["settings"]) + default: break + } + } + + public func provider(for selection: JSON) -> ProviderEntry? { + providers.first { $0.id == selection["instanceId"].string } + } + + public func configuredWorkspaceMode(project: JSON) -> String? { + WorkspaceDefaults.configuredMode(settings: settings, project: project) + } + + public func defaultRuntimeMode(projectID: String) -> String { + projectModes[projectID] ?? defaultMode + } + private mutating func applySettings(_ settings: JSON) { + self.settings = settings + defaultMode = settings["defaultRuntimeMode"].string ?? "approval-required" + if case .object(let overrides) = settings["projectSettingsOverrides"] { + projectModes = overrides.compactMapValues { $0["defaultRuntimeMode"].string } + } else { + projectModes = [:] + } + } + + public func newThreadConfiguration(project: JSON, carrying thread: ThreadSummary?) throws + -> NewThreadConfiguration + { + let projectID = try project.requiredString("id") + let overrides = settings["projectSettingsOverrides"][projectID] + var configured = settings["defaultModelSelection"] + if !settings["projectSettingsFolded"].bool, project["defaultModelSelection"] != .null { + configured = project["defaultModelSelection"] + } + if case .object(let fields) = overrides, let value = fields["defaultModelSelection"] { + configured = value + } + if configured != .null, provider(for: configured)?.available != true { + configured = settings["defaultModelSelection"] + } + let candidates = [configured, thread?.model ?? .null] + for candidate in candidates { + if let provider = provider(for: candidate), provider.available, + let model = model(for: candidate) + { + return NewThreadConfiguration( + projectID: projectID, + model: model.selection( + instanceID: provider.id, preserving: candidate, planModeEnabled: planModeEnabled), + runtimeMode: defaultRuntimeMode(projectID: projectID), + interactionMode: planModeEnabled ? thread?.interactionMode ?? "default" : "default") + } + } + guard let provider = providers.first(where: { $0.available && !$0.models.isEmpty }), + let model = provider.models.first(where: { !$0.legacy }) ?? provider.models.first + else { throw T3Error("Connect an available provider before starting a new thread.") } + return NewThreadConfiguration( + projectID: projectID, + model: model.selection(instanceID: provider.id, planModeEnabled: planModeEnabled), + runtimeMode: defaultRuntimeMode(projectID: projectID), interactionMode: "default") + } + + public func model(for selection: JSON) -> ProviderModel? { + provider(for: selection)?.models.first { $0.id == selection["model"].string } + } + + public func descriptors(for selection: JSON) -> [JSON] { + (model(for: selection)?.descriptors ?? []).compactMap { descriptor in + guard !planModeEnabled, descriptor["id"].string == "agent", + descriptor["type"].string == "select", case .object(var fields) = descriptor + else { return descriptor } + let options = descriptor["options"].array.filter { $0["id"].string != "plan" } + guard !options.isEmpty else { return nil } + fields["options"] = .array(options) + return .object(fields) + } + } +} diff --git a/apps/macos/Sources/T3Core/ProviderManagement.swift b/apps/macos/Sources/T3Core/ProviderManagement.swift new file mode 100644 index 0000000000000..36d1dff7e444b --- /dev/null +++ b/apps/macos/Sources/T3Core/ProviderManagement.swift @@ -0,0 +1,208 @@ +import Foundation + +public struct ManagedProvider: Identifiable, Sendable, Equatable { + public let id: String + public let driver: String + public let name: String + public let status: String + public let account: String + public let signedIn: Bool + public let canLogout: Bool + public let canAuthenticate: Bool + public let envelope: JSON + public let models: [ProviderModel] + public let explicit: Bool + + public init(snapshot: JSON, settings: JSON) throws { + id = try snapshot.requiredString("instanceId") + driver = try snapshot.requiredString("driver") + name = snapshot["displayName"].string ?? id + status = snapshot["status"].string ?? "unknown" + account = snapshot["auth"]["email"].string ?? snapshot["auth"]["label"].string ?? "" + signedIn = snapshot["auth"]["status"].string == "authenticated" + canLogout = snapshot["auth"]["canLogout"] != .bool(false) + canAuthenticate = snapshot["setup"]["canAuthenticate"] != .bool(false) + models = try snapshot["models"].array.map(ProviderModel.init) + explicit = settings["providerInstances"][id] != .null + envelope = + explicit + ? settings["providerInstances"][id] + : .object([ + "driver": .string(driver), "displayName": .string(name), + "enabled": settings["providers"][driver]["enabled"] == .null + ? .bool(true) : settings["providers"][driver]["enabled"], + "config": settings["providers"][driver], + ]) + } +} + +public struct ProviderInstanceDraft: Codable, Sendable { + public var id: String + public var driver: String + public var displayName: String + public var enabled: Bool + public var homePath: String + public var shadowHomePath: String + public var binaryPath: String + public var launchArgs: String + public var customModels: [JSON] + private let original: JSON + + public init(provider: ManagedProvider? = nil) { + original = provider?.envelope ?? .object([:]) + id = provider?.id ?? "codex_" + UUID().uuidString.lowercased().prefix(8) + driver = provider?.driver ?? "codex" + displayName = provider?.name ?? "" + enabled = original["enabled"] != .bool(false) + homePath = original["config"]["homePath"].string ?? "" + shadowHomePath = original["config"]["shadowHomePath"].string ?? "" + binaryPath = original["config"]["binaryPath"].string ?? "" + launchArgs = original["config"]["launchArgs"].string ?? "" + customModels = original["config"]["customModels"].array + } + + public func mutation(create: Bool, mergingLatest latest: JSON? = nil) throws -> JSON { + let slug = id.trimmingCharacters(in: .whitespacesAndNewlines) + guard slug.count <= 64, + slug.range(of: "^[a-zA-Z][a-zA-Z0-9_-]*$", options: .regularExpression) != nil + else { + throw T3Error( + "Account ID must start with a letter and use up to 64 letters, numbers, dashes or underscores." + ) + } + guard !displayName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw T3Error("Enter an account name.") + } + guard driver == "codex" || driver == "claudeAgent" else { + throw T3Error("Configuration editing is available for Claude and Codex.") + } + var seen: Set = [] + for model in customModels { + let slug = (model.string ?? model["slug"].string ?? "").trimmingCharacters( + in: .whitespacesAndNewlines) + guard !slug.isEmpty, seen.insert(slug).inserted else { + throw T3Error("Custom model IDs must be nonempty and unique.") + } + } + let base = latest ?? original + var config: [String: JSON] = [:] + if case .object(let fields) = base["config"] { config = fields } + config["homePath"] = .string(homePath.trimmingCharacters(in: .whitespacesAndNewlines)) + config["binaryPath"] = .string(binaryPath.trimmingCharacters(in: .whitespacesAndNewlines)) + config["launchArgs"] = .string(launchArgs) + config["customModels"] = .array(customModels) + if driver == "codex" { + config["shadowHomePath"] = .string( + shadowHomePath.trimmingCharacters(in: .whitespacesAndNewlines)) + } + var envelope: [String: JSON] = [:] + if case .object(let fields) = base { envelope = fields } + envelope["driver"] = .string(driver) + envelope["displayName"] = .string(displayName.trimmingCharacters(in: .whitespacesAndNewlines)) + envelope["enabled"] = .bool(enabled) + envelope["config"] = .object(config) + return .object([ + "patch": .object([:]), + "providerInstanceMutation": .object([ + "operation": .string(create ? "create" : "upsert"), + "instanceId": .string(slug), "instance": .object(envelope), + ]), + ]) + } +} + +public struct ProviderLoginState: Sendable, Equatable { + public let instanceID: String + public let phase: String + public let flowID: String? + public let interaction: JSON + public let methods: [JSON] + public let url: URL? + public var active: Bool { ["starting", "waiting", "verifying"].contains(phase) } + public init(_ value: JSON) throws { + instanceID = try value.requiredString("instanceId") + phase = try value.requiredString("phase") + flowID = value["flowId"].string + interaction = value["interaction"] + methods = value["methods"].array + let candidate = interaction["url"].string ?? value["authorizationUrl"].string + url = candidate.flatMap(URL.init(string:)).flatMap { + ["https", "http"].contains($0.scheme?.lowercased() ?? "") ? $0 : nil + } + } + public func response(_ response: JSON) throws -> JSON { + guard active, let flowID, let interactionID = interaction["id"].string else { + throw T3Error("This sign-in interaction is no longer active.") + } + switch response["type"].string { + case "browser": + guard interaction["type"].string == "browser", + ["accept", "decline"].contains(response["action"].string ?? "") + else { + throw T3Error("This sign-in does not accept browser consent.") + } + case "terminal": + guard interaction["type"].string == "terminal", let text = response["data"].string, + text.utf16.count <= 4_096 + else { + throw T3Error("Terminal input must be at most 4096 characters.") + } + case "credentials": + guard interaction["type"].string == "credentials", + case .object(let values) = response["values"], + values.count <= 16, + values.values.allSatisfy({ $0.string != nil && ($0.string?.utf16.count ?? 0) <= 16_384 }) + else { + throw T3Error("Invalid credential response.") + } + default: throw T3Error("Unsupported sign-in interaction.") + } + return .object([ + "instanceId": .string(instanceID), "flowId": .string(flowID), + "interactionId": .string(interactionID), "response": response, + ]) + } +} + +/// Local client preferences, scoped by server endpoint and compatible with the native picker's favorites. +public struct ModelPickerPreferences: Sendable, Equatable { + public var favorites: Set = [] + public var hidden: Set = [] + public var order: [String: [String]] = [:] + public init() {} + public init(environment: String, defaults: UserDefaults = .standard) { + func identities(_ key: String) -> Set { + guard let data = defaults.data(forKey: key + environment) else { return [] } + return (try? JSONDecoder().decode(Set.self, from: data)) ?? [] + } + favorites = identities("modelFavorites:") + hidden = identities("modelHidden:") + if let data = defaults.data(forKey: "modelOrder:" + environment) { + order = (try? JSONDecoder().decode([String: [String]].self, from: data)) ?? [:] + } + } + public func save(environment: String, defaults: UserDefaults = .standard) throws { + defaults.set(try JSONEncoder().encode(favorites), forKey: "modelFavorites:" + environment) + defaults.set(try JSONEncoder().encode(hidden), forKey: "modelHidden:" + environment) + defaults.set(try JSONEncoder().encode(order), forKey: "modelOrder:" + environment) + } + public func models(_ models: [ProviderModel], instanceID: String, includingHidden: Bool = false) + -> [ProviderModel] + { + var positions: [String: Int] = [:] + for (index, id) in (order[instanceID] ?? []).enumerated() where positions[id] == nil { + positions[id] = index + } + return models.enumerated().filter { _, model in + includingHidden || model.custom + || !hidden.contains(ModelIdentity(instanceID: instanceID, modelID: model.id)) + }.sorted { lhs, rhs in + let left = ModelIdentity(instanceID: instanceID, modelID: lhs.element.id) + let right = ModelIdentity(instanceID: instanceID, modelID: rhs.element.id) + if favorites.contains(left) != favorites.contains(right) { return favorites.contains(left) } + let a = positions[lhs.element.id] ?? Int.max + let b = positions[rhs.element.id] ?? Int.max + return a == b ? lhs.offset < rhs.offset : a < b + }.map(\.element) + } +} diff --git a/apps/macos/Sources/T3Core/ReviewDiff.swift b/apps/macos/Sources/T3Core/ReviewDiff.swift new file mode 100644 index 0000000000000..fea91f799c2c3 --- /dev/null +++ b/apps/macos/Sources/T3Core/ReviewDiff.swift @@ -0,0 +1,280 @@ +import Foundation + +public struct DiffLine: Equatable, Sendable { + public enum Kind: Sendable { case context, addition, deletion, hunk, metadata, gap } + public let kind: Kind + public let text: String + public let oldLine: Int? + public let newLine: Int? + public var oldText: String? = nil + public var contextID: Int? = nil +} + +public struct DiffFile: Equatable, Sendable { + public var path: String + public var previousPath: String? + public var lines: [DiffLine] = [] + public var rawPatch = "" + public var isDeleted = false + public var isNew = false + public var isBinary = false + public var incomplete = false + public var additions: Int { lines.lazy.filter { $0.kind == .addition }.count } + public var deletions: Int { lines.lazy.filter { $0.kind == .deletion }.count } +} + +public enum UnifiedDiff { + public static func parse(_ text: String) -> [DiffFile] { + var files: [DiffFile] = [] + var file: DiffFile? + var old = 0 + var new = 0 + var oldRemaining = 0 + var newRemaining = 0 + var start = text.utf8.startIndex + func finish(at end: String.UTF8View.Index) { + if var value = file { + value.rawPatch = String(decoding: text.utf8[start.. 0 || newRemaining > 0 + files.append(value) + } + file = nil + oldRemaining = 0 + newRemaining = 0 + } + // Split on LF only: a CR in a changed line belongs to the file's contents. + for raw in text.utf8.split(separator: 10, omittingEmptySubsequences: false) { + let line = String(decoding: raw, as: UTF8.self) + if line.hasPrefix("diff --git ") { + finish(at: raw.startIndex) + start = raw.startIndex + let paths = headerPaths(String(line.dropFirst(11))) + file = DiffFile(path: paths.1, previousPath: paths.0 == paths.1 ? nil : paths.0) + continue + } + guard file != nil else { continue } + if line.hasPrefix("@@ ") { + if oldRemaining > 0 || newRemaining > 0 { file?.incomplete = true } + let text = line as NSString + if let match = hunk.firstMatch(in: line, range: NSRange(location: 0, length: text.length)) { + func number(_ index: Int, default fallback: Int = 1) -> Int { + let range = match.range(at: index) + return range.location == NSNotFound ? fallback : Int(text.substring(with: range)) ?? 0 + } + old = number(1) + new = number(3) + oldRemaining = number(2) + newRemaining = number(4) + file?.lines.append(DiffLine(kind: .hunk, text: line, oldLine: nil, newLine: nil)) + continue + } + file?.incomplete = true + } + if line.hasPrefix("\\ No newline at end of file") { + file?.lines.append(DiffLine(kind: .metadata, text: line, oldLine: nil, newLine: nil)) + continue + } + if oldRemaining > 0 || newRemaining > 0 { + let kind: DiffLine.Kind? + switch raw.first { + case 32 where oldRemaining > 0 && newRemaining > 0: kind = .context + case 43 where newRemaining > 0: kind = .addition + case 45 where oldRemaining > 0: kind = .deletion + default: kind = nil + } + if let kind { + file?.lines.append( + DiffLine( + kind: kind, text: String(decoding: raw.dropFirst(), as: UTF8.self), + oldLine: kind == .addition ? nil : old, newLine: kind == .deletion ? nil : new)) + if kind != .addition { + old += 1 + oldRemaining -= 1 + } + if kind != .deletion { + new += 1 + newRemaining -= 1 + } + continue + } + file?.incomplete = true + oldRemaining = 0 + newRemaining = 0 + } + if line.hasPrefix("--- ") { + let path = patchPath(String(line.dropFirst(4)), prefix: "a/") + if path == "/dev/null" { file?.isNew = true } + if path != "/dev/null", path != file?.path { file?.previousPath = path } + } else if line.hasPrefix("+++ ") { + let path = patchPath(String(line.dropFirst(4)), prefix: "b/") + if path == "/dev/null" { file?.isDeleted = true } else { file?.path = path } + } else if line.hasPrefix("rename from ") { + file?.previousPath = unquote(String(line.dropFirst(12))) + } else if line.hasPrefix("rename to ") { + file?.path = unquote(String(line.dropFirst(10))) + } else if line.hasPrefix("Binary files ") || line == "GIT binary patch" { + file?.isBinary = true + } else if line.hasPrefix("deleted file mode ") { + file?.isDeleted = true + } else if line.hasPrefix("new file mode ") { + file?.isNew = true + } + if !line.isEmpty { + file?.lines.append(DiffLine(kind: .metadata, text: line, oldLine: nil, newLine: nil)) + } + } + finish(at: text.utf8.endIndex) + return files + } + + private static let hunk = try! NSRegularExpression( + pattern: "^@@ -([0-9]+)(?:,([0-9]+))? \\+([0-9]+)(?:,([0-9]+))? @@") + + private static func patchPath(_ source: String, prefix: String) -> String { + let value = unquote( + source.split(separator: "\t", maxSplits: 1).first.map(String.init) ?? source) + return value.hasPrefix(prefix) ? String(value.dropFirst(prefix.count)) : value + } + + private static func headerPaths(_ source: String) -> (String, String) { + if source.hasPrefix("\"") { + var escaped = false + for index in source.indices.dropFirst() { + let character = source[index] + if character == "\"" && !escaped { + let end = source.index(after: index) + return ( + patchPath(String(source[.. String { + guard value.hasPrefix("\""), value.hasSuffix("\"") else { return value } + let bytes = Array(value.utf8.dropFirst().dropLast()) + var decoded: [UInt8] = [] + var index = 0 + while index < bytes.count { + let byte = bytes[index] + index += 1 + guard byte == 92, index < bytes.count else { + decoded.append(byte) + continue + } + let next = bytes[index] + index += 1 + if (48...55).contains(next) { + var octal = Int(next - 48) + for _ in 0..<2 where index < bytes.count && (48...55).contains(bytes[index]) { + octal = octal * 8 + Int(bytes[index] - 48) + index += 1 + } + decoded.append(UInt8(truncatingIfNeeded: octal)) + } else { + decoded.append([97: 7, 98: 8, 116: 9, 110: 10, 118: 11, 102: 12, 114: 13][next] ?? next) + } + } + return String(decoding: decoded, as: UTF8.self) + } +} + +public struct ReviewFile: Equatable, Sendable { + public let path: String + public let previousPath: String? + public let additions: Int + public let deletions: Int + public init(path: String, previousPath: String?, additions: Int, deletions: Int) { + self.path = path + self.previousPath = previousPath + self.additions = additions + self.deletions = deletions + } + public init(_ patch: DiffFile) { + self.init( + path: patch.path, previousPath: patch.previousPath, + additions: patch.additions, deletions: patch.deletions) + } + public var input: [String: JSON] { + ["path": .string(path), "previousPath": previousPath.map(JSON.string) ?? .null] + } +} + +public struct ReviewSource: Equatable, Sendable { + public let kind: String + public let title: String + public let baseRef: String? + public let hash: String + public let truncated: Bool + public let completeFileList: Bool + public let files: [ReviewFile] + + public init( + kind: String, title: String, hash: String, patches: [DiffFile], outputLimited: Bool = false + ) { + self.kind = kind + self.title = title + self.hash = hash + baseRef = nil + truncated = outputLimited || patches.contains(where: \.incomplete) + completeFileList = !truncated + files = patches.map(ReviewFile.init).sorted { + $0.path.localizedStandardCompare($1.path) == .orderedAscending + } + } + + public init(_ value: JSON) throws { + kind = try value.requiredString("kind") + title = try value.requiredString("title") + baseRef = value["baseRef"].string + hash = try value.requiredString("diffHash") + truncated = value["truncated"].bool + completeFileList = value["files"] != .null + let list: [ReviewFile] + if completeFileList { + list = try value["files"].array.map { + ReviewFile( + path: try $0.requiredString("path"), previousPath: $0["previousPath"].string, + additions: $0["additions"].int ?? 0, deletions: $0["deletions"].int ?? 0) + } + } else { + list = UnifiedDiff.parse(value["diff"].string ?? "").map { + ReviewFile( + path: $0.path, previousPath: $0.previousPath, + additions: $0.additions, deletions: $0.deletions) + } + } + var seen: Set = [] + files = list.filter { seen.insert($0.path).inserted } + .sorted { $0.path.localizedStandardCompare($1.path) == .orderedAscending } + } +} + +public struct ReviewSnapshot: Sendable { + public let root: String + public let sources: [ReviewSource] + public init(root: String, sources: [ReviewSource]) { + self.root = root + self.sources = sources + } + public init(_ value: JSON) throws { + root = try value.requiredString("cwd") + sources = try value["sources"].array.map(ReviewSource.init) + } +} diff --git a/apps/macos/Sources/T3Core/RuntimeRequests.swift b/apps/macos/Sources/T3Core/RuntimeRequests.swift new file mode 100644 index 0000000000000..bea577845e11c --- /dev/null +++ b/apps/macos/Sources/T3Core/RuntimeRequests.swift @@ -0,0 +1,147 @@ +import Foundation + +public struct QuestionDraft: Codable, Sendable, Equatable { + public var selected: [String] = [] + public var custom = "" + public init() {} + + public func answer(for question: JSON, attachmentCount: Int = 0, blocked: Bool = false) -> JSON? { + guard !blocked else { return nil } + let custom = custom.trimmingCharacters(in: .whitespacesAndNewlines) + if question["allowCustomAnswer"] != .bool(false), !custom.isEmpty { return .string(custom) } + let allowed = question["options"].array.compactMap { $0["value"].string ?? $0["label"].string } + var seen = Set() + let selected = selected.filter { allowed.contains($0) && seen.insert($0).inserted } + guard let first = selected.first else { + return question["allowCustomAnswer"] != .bool(false) && attachmentCount > 0 + ? .string("") : nil + } + return question["multiSelect"].bool ? .array(selected.map(JSON.string)) : .string(first) + } + + public static func attachmentKey(threadID: String, requestID: String, questionID: String) + -> String + { + "question:" + + (try! JSONEncoder().encode([threadID, requestID, questionID])).base64EncodedString() + } + public static func attachmentScope(_ key: String) -> (threadID: String, requestID: String)? { + guard key.hasPrefix("question:"), let data = Data(base64Encoded: String(key.dropFirst(9))), + let fields = try? JSONDecoder().decode([String].self, from: data), fields.count == 3 + else { return nil } + return (fields[0], fields[1]) + } +} + +public struct QuestionProgress: Equatable, Sendable { + public let index: Int + public let question: JSON + public let answer: JSON? + public let answers: JSON? + public let answeredCount: Int + public let isLast: Bool + public init( + questions: [JSON], drafts: [String: QuestionDraft], index: Int, + attachmentCounts: [String: Int] = [:], blocked: Set = [] + ) { + self.index = min(max(0, index), max(0, questions.count - 1)) + question = questions.isEmpty ? .null : questions[self.index] + var resolved: [String: JSON] = [:] + for question in questions { + guard let id = question["id"].string else { continue } + resolved[id] = (drafts[id] ?? QuestionDraft()).answer( + for: question, attachmentCount: attachmentCounts[id] ?? 0, blocked: blocked.contains(id)) + } + answer = resolved[question["id"].string ?? ""] + answeredCount = resolved.count + answers = !questions.isEmpty && resolved.count == questions.count ? .object(resolved) : nil + isLast = self.index == max(0, questions.count - 1) + } +} + +public struct PendingQuestionResponse: Codable, Sendable, Equatable { + public let commandID: String + public let threadID: String + public let requestID: String + public let answers: JSON + public let attachments: [String: [DraftAttachment]] + public init( + threadID: String, requestID: String, answers: JSON, + attachments: [String: [DraftAttachment]], commandID: String = UUID().uuidString + ) { + self.threadID = threadID + self.requestID = requestID + self.answers = answers + self.attachments = attachments.filter { !$0.value.isEmpty } + self.commandID = commandID + } + public var localAttachmentIDs: Set { Set(attachments.values.flatMap { $0.map(\.id) }) } + public func matches(answers: JSON, attachments: [String: [DraftAttachment]]) -> Bool { + self.answers == answers + && self.attachments.mapValues { $0.map(\.id) } + == attachments.filter { !$0.value.isEmpty }.mapValues { $0.map(\.id) } + } + public var payload: JSON { + var fields: [String: JSON] = [ + "type": .string("runtime-request.respond"), "commandId": .string(commandID), + "threadId": .string(threadID), "requestId": .string(requestID), "answers": answers, + ] + if !attachments.isEmpty { + fields["attachmentsByQuestionId"] = .object( + attachments.mapValues { .array($0.compactMap(\.wire)) }) + } + return .object(fields) + } +} + +public struct PendingRequest: Sendable, Equatable, Identifiable { + public let id: String + public let kind: String + public let capability: String + public let reason: String? + public let createdAt: String + public let item: JSON + public var isQuestion: Bool { kind == "user_input" } + public var canRespond: Bool { + isQuestion + ? ["live", "message"].contains(capability) + : capability == "live" + && ["command", "file-read", "file-change", "apply-patch", "permission", "mcp-elicitation"] + .contains(kind) + } + public var dismissible: Bool { + isQuestion && (capability == "message" || item["responseMode"].string == "message") + } + public var questions: [JSON] { item["questions"].array } + public var title: String { + switch kind { + case "user_input": "Question from the agent" + case "command": "Command approval" + case "file-read": "File read approval" + case "permission": "Permission approval" + case "mcp-elicitation": "App access approval" + default: "File change approval" + } + } + public var detail: String { item["prompt"].string ?? item["appName"].string ?? title } + public var options: [JSON] { + if case .array(let options) = item["options"] { return options } + return [ + ("decline", "Decline"), ("accept", "Approve"), + ("acceptForSession", "Always allow this session"), ("cancel", "Cancel"), + ].map { .object(["decision": .string($0.0), "label": .string($0.1)]) } + } + + public init(request: JSON, item: JSON) throws { + id = try request.requiredString("id") + kind = try request.requiredString("kind") + capability = request["responseCapability"]["type"].string ?? "not_resumable" + reason = request["responseCapability"]["reason"].string + createdAt = request["createdAt"].string ?? "" + self.item = item + } + + public func answers(from drafts: [String: QuestionDraft]) -> JSON? { + QuestionProgress(questions: questions, drafts: drafts, index: 0).answers + } +} diff --git a/apps/macos/Sources/T3Core/SavedConnections.swift b/apps/macos/Sources/T3Core/SavedConnections.swift new file mode 100644 index 0000000000000..eabfd1399712f --- /dev/null +++ b/apps/macos/Sources/T3Core/SavedConnections.swift @@ -0,0 +1,125 @@ +import Foundation + +public enum ConnectionEndpoint { + public static func normalize(_ text: String) throws -> URL { + let text = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { throw T3Error("Enter a server address.") } + var parts = URLComponents(string: text.contains("://") ? text : "https://" + text) + if parts?.scheme == "wss" { parts?.scheme = "https" } + if parts?.scheme == "ws" { parts?.scheme = "http" } + guard var value = parts, let host = value.host, !host.isEmpty, + value.user == nil, value.password == nil, value.query == nil, value.fragment == nil, + value.path.isEmpty || value.path == "/", value.port.map({ (1...65535).contains($0) }) ?? true + else { throw T3Error("Enter a server origin without a path, password, query, or fragment.") } + value.scheme = value.scheme?.lowercased() + value.host = host.lowercased() + if (value.scheme == "https" && value.port == 443) + || (value.scheme == "http" && value.port == 80) + { + value.port = nil + } + value.path = "" + guard let url = value.url else { throw T3Error("Invalid server address.") } + _ = try ConnectionSettings(endpoint: url, token: "validation") + return url + } + + public static func credentialAccount(_ endpoint: URL) throws -> String { + try normalize(endpoint.absoluteString).absoluteString + } +} + +public struct SavedEnvironment: Codable, Identifiable, Equatable, Sendable { + public let id: String + public var label: String + public let endpoint: URL + public var local: Bool + public var cloudEnvironmentID: String? + + public init(label: String, endpoint: URL, local: Bool = false, cloudEnvironmentID: String? = nil) + throws + { + self.endpoint = try ConnectionEndpoint.normalize(endpoint.absoluteString) + self.id = self.endpoint.absoluteString + self.label = + label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? self.endpoint.host ?? "Environment" : label.trimmingCharacters(in: .whitespacesAndNewlines) + self.local = local + self.cloudEnvironmentID = cloudEnvironmentID + } +} + +public struct SavedConnections: Codable, Equatable, Sendable { + public var environments: [SavedEnvironment] = [] + public var selectedID: String? + public init() {} + + public mutating func upsert(_ environment: SavedEnvironment, selecting: Bool = false) { + if let index = environments.firstIndex(where: { $0.id == environment.id }) { + environments[index] = environment + } else { + environments.append(environment) + } + if selecting { selectedID = environment.id } + } + public mutating func remove(_ id: String) { + environments.removeAll { $0.id == id } + if selectedID == id { selectedID = nil } + } + public mutating func migrateLegacy(_ settings: ConnectionSettings) throws { + guard environments.isEmpty else { return } + upsert( + try SavedEnvironment( + label: settings.endpoint.host ?? "Saved server", endpoint: settings.endpoint), + selecting: true) + } + public static func decode(_ data: Data) throws -> Self { + let decoded = try JSONDecoder().decode(Self.self, from: data) + var result = Self() + for value in decoded.environments { + result.upsert( + try SavedEnvironment( + label: value.label, endpoint: value.endpoint, + local: value.local, cloudEnvironmentID: value.cloudEnvironmentID)) + } + if let selected = decoded.selectedID, + let url = try? ConnectionEndpoint.normalize(selected), + result.environments.contains(where: { $0.id == url.absoluteString }) + { + result.selectedID = url.absoluteString + } + return result + } +} + +public struct RemotePairingTarget: Sendable { + public let endpoint: URL + public let credential: String + public init(address: String, credential: String) throws { + guard var parts = URLComponents(string: address.trimmingCharacters(in: .whitespacesAndNewlines)) + else { + throw T3Error("Invalid pairing address.") + } + let fragment = + URLComponents(string: "https://pair.invalid/?" + (parts.percentEncodedFragment ?? ""))? + .queryItems ?? [] + let query = parts.queryItems ?? [] + let parameters = fragment + query + let embedded = parameters.first(where: { $0.name == "token" })?.value + let hosted = parameters.first(where: { $0.name == "host" })?.value + if let hosted { + guard parts.scheme == "https", + parts.path == "/pair" || parts.path == "/" || parts.path.isEmpty + else { throw T3Error("Invalid hosted pairing link.") } + self.endpoint = try ConnectionEndpoint.normalize(hosted) + } else { + parts.query = nil + parts.fragment = nil + // Direct pairing URLs use the server origin, just as the web/mobile parser does. + if embedded != nil { parts.path = "" } + self.endpoint = try ConnectionEndpoint.normalize(parts.string ?? address) + } + self.credential = (embedded ?? credential).trimmingCharacters(in: .whitespacesAndNewlines) + guard !self.credential.isEmpty else { throw T3Error("Enter a pairing code.") } + } +} diff --git a/apps/macos/Sources/T3Core/ScheduledTasks.swift b/apps/macos/Sources/T3Core/ScheduledTasks.swift new file mode 100644 index 0000000000000..fb5ef2fd6c052 --- /dev/null +++ b/apps/macos/Sources/T3Core/ScheduledTasks.swift @@ -0,0 +1,182 @@ +import Foundation + +public struct ScheduledPrompt: Identifiable, Sendable, Equatable { + public let id: String + public let title: String + public let prompt: String + public let enabled: Bool + public let schedule: JSON + public let projectID: String + public let threadID: String? + public let model: JSON + public let workspace: JSON + public let runtimeMode: String + public let interactionMode: String + public let nextRun: Date? + public let lastRun: Date? + public let lastStatus: String + public let runCount: Int + public let source: JSON + public var editable: Bool { ["interval", "fixed_time"].contains(schedule["type"].string ?? "") } + public var lifecycle: String { + if !enabled { return "Paused" } + return nextRun == nil && (source["endsAt"] != .null || source["maxRuns"] != .null) + ? "Ended" : "Active" + } + public init(_ value: JSON) throws { + id = try value.requiredString("id") + title = try value.requiredString("title") + prompt = try value.requiredString("prompt") + projectID = try value.requiredString("projectId") + enabled = value["enabled"].bool + schedule = value["schedule"] + threadID = value["threadId"].string + model = value["modelSelection"] + workspace = value["workspaceStrategy"] + runtimeMode = value["runtimeMode"].string ?? "approval-required" + interactionMode = value["interactionMode"].string ?? "default" + nextRun = try Self.date(value["nextRunAt"]) + lastRun = try Self.date(value["lastRunAt"]) + lastStatus = value["lastRunStatus"].string ?? "never" + runCount = try value.requiredInt("runCount") + source = value + } + public static func date(_ value: JSON) throws -> Date? { + if value == .null { return nil } + guard let text = value.string else { throw T3Error("Invalid schedule date.") } + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: text) { return date } + formatter.formatOptions = [.withInternetDateTime] + guard let date = formatter.date(from: text) else { throw T3Error("Invalid schedule date.") } + return date + } + public var cadence: String { + if schedule["type"].string == "interval" { + return "Every \(Double(schedule["everyMs"].int ?? 0) / 60_000) minutes" + } + guard schedule["type"].string == "fixed_time" else { return "Unsupported trigger" } + let days = Set(schedule["weekdays"].array.compactMap(\.int)) + let names = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] + let label = + days.isEmpty || days.count == 7 + ? "Daily" + : days.sorted().compactMap { names.indices.contains($0) ? names[$0] : nil }.joined( + separator: ", ") + return label + " at " + (schedule["timeOfDay"].string ?? "") + " · server time" + } +} + +public struct ScheduledPromptDraft: Codable, Sendable { + public let id: String + public let commandID: String + public let editing: Bool + public var title = "" + public var prompt = "" + public var enabled = true + public var mode = "fixed_time" + public var timeOfDay = "09:00" + public var weekdays: Set = Set(0..<7) + public var intervalMinutes = "15" + public var projectID = "" + public var threadID = "" + public var model: JSON = .null + public var workspaceMode = "root" + public var baseRef = "main" + public var worktreePath = "" + public var branch = "" + public var startFromOrigin = true + public var runtimeMode = "approval-required" + public var interactionMode = "default" + + public init(task: ScheduledPrompt? = nil) { + commandID = UUID().uuidString.lowercased() + id = task?.id ?? "scheduled-task:" + commandID + editing = task != nil + guard let task else { return } + title = task.title + prompt = task.prompt + enabled = task.enabled + mode = task.schedule["type"].string ?? "fixed_time" + timeOfDay = task.schedule["timeOfDay"].string ?? "09:00" + let savedDays = task.schedule["weekdays"].array.compactMap(\.int) + weekdays = savedDays.isEmpty ? Set(0..<7) : Set(savedDays) + intervalMinutes = String(Double(task.schedule["everyMs"].int ?? 900_000) / 60_000) + projectID = task.projectID + threadID = task.threadID ?? "" + model = task.model + workspaceMode = task.workspace["type"].string ?? "root" + baseRef = task.workspace["baseRef"].string ?? "main" + worktreePath = task.workspace["worktreePath"].string ?? "" + branch = task.workspace["branch"].string ?? "" + startFromOrigin = task.workspace["startFromOrigin"] != .bool(false) + runtimeMode = task.runtimeMode + interactionMode = task.interactionMode + } + public func payload() throws -> JSON { + func trimmed(_ value: String) -> String { + value.trimmingCharacters(in: .whitespacesAndNewlines) + } + guard !trimmed(title).isEmpty, !trimmed(prompt).isEmpty, !projectID.isEmpty, + !(model["instanceId"].string ?? "").isEmpty, !(model["model"].string ?? "").isEmpty + else { + throw T3Error("Choose a project and model, and enter a title and prompt.") + } + guard ["approval-required", "auto-accept-edits", "auto", "full-access"].contains(runtimeMode), + ["default", "plan"].contains(interactionMode) + else { + throw T3Error("Choose supported permissions and an interaction mode.") + } + let schedule: JSON + switch mode { + case "fixed_time": + guard + timeOfDay.range(of: "^([01]?[0-9]|2[0-3]):[0-5][0-9]$", options: .regularExpression) != nil, + weekdays.allSatisfy({ (0...6).contains($0) }) + else { throw T3Error("Enter a server time such as 09:30.") } + var fields: [String: JSON] = ["type": .string(mode), "timeOfDay": .string(timeOfDay)] + if !weekdays.isEmpty && weekdays.count < 7 { + fields["weekdays"] = .array(weekdays.sorted().map { .number(Double($0)) }) + } + schedule = .object(fields) + case "interval": + guard let minutes = Double(intervalMinutes), minutes.isFinite, minutes >= 1, + minutes * 60_000 < 9_007_199_254_740_991 + else { throw T3Error("Intervals must be at least one minute.") } + schedule = .object(["type": .string(mode), "everyMs": .number((minutes * 60_000).rounded())]) + default: throw T3Error("Only fixed times and recurring intervals can be edited here.") + } + var workspace: [String: JSON] = ["type": .string(workspaceMode)] + switch workspaceMode { + case "root": break + case "worktree": + guard !trimmed(baseRef).isEmpty else { + throw T3Error("Enter a base branch for the worktree.") + } + workspace["baseRef"] = .string(trimmed(baseRef)) + workspace["startFromOrigin"] = .bool(startFromOrigin) + case "existing_worktree": + guard !trimmed(worktreePath).isEmpty else { + throw T3Error("Enter a worktree path on the server.") + } + workspace["worktreePath"] = .string(trimmed(worktreePath)) + default: throw T3Error("Choose a supported workspace strategy.") + } + if !trimmed(branch).isEmpty { workspace["branch"] = .string(trimmed(branch)) } + var payload: [String: JSON] = [ + "id": .string(id), "commandId": .string(commandID), "requireExisting": .bool(editing), + "title": .string(trimmed(title)), "prompt": .string(trimmed(prompt)), + "enabled": .bool(enabled), + "schedule": schedule, "projectId": .string(projectID), + "threadId": threadID.isEmpty ? .null : .string(threadID), + "workspaceStrategy": .object(workspace), "modelSelection": model, + "runtimeMode": .string(runtimeMode), "interactionMode": .string(interactionMode), + ] + // Omission preserves end conditions on servers with this contract, including past end dates. + if !editing { + payload["createdBy"] = .string("user") + payload["creationSource"] = .string("web") + } + return .object(payload) + } +} diff --git a/apps/macos/Sources/T3Core/SplitDiff.swift b/apps/macos/Sources/T3Core/SplitDiff.swift new file mode 100644 index 0000000000000..06d88d83b66b3 --- /dev/null +++ b/apps/macos/Sources/T3Core/SplitDiff.swift @@ -0,0 +1,60 @@ +import Foundation + +public struct SplitDiffRow: Equatable, Sendable { + public let old: DiffLine? + public let new: DiffLine? +} + +public struct SplitDiff: Sendable { + public let rows: [SplitDiffRow] + public let limited: Bool + + public init(_ file: DiffFile?, lineLimit: Int = 10_000, characterLimit: Int = 1_000_000) { + var rows: [SplitDiffRow] = [] + var deletions: [DiffLine] = [] + var additions: [DiffLine] = [] + var characters = 0 + var limited = false + func flush() { + for index in 0.. JSON { + var fields = fields + fields["threadId"] = .string(threadID) + fields["terminalId"] = .string(terminalID) + return .object(fields) + } + public func matches(_ event: JSON) -> Bool { + event["threadId"].string == threadID && event["terminalId"].string == terminalID + } +} + +public struct TerminalGrid: Equatable, Sendable { + public let columns: Int + public let rows: Int + public init(columns: Int, rows: Int) { + self.columns = min(1000, max(1, columns)) + self.rows = min(500, max(1, rows)) + } + public var fields: [String: JSON] { + ["cols": .number(Double(columns)), "rows": .number(Double(rows))] + } +} + +/// A cursor reads only new bytes. A reconnect, clear, or lagging consumer gets a bounded reset. +public struct TerminalOutput: Sendable { + public struct Cursor: Equatable, Sendable { + fileprivate let generation: UUID + fileprivate let offset: Int + } + public struct Update: Sendable { + public let reset: Bool + public let bytes: Data + public let cursor: Cursor + } + private var generation = UUID() + private var chunks: [Data] = [] + private var end = 0 + public private(set) var retainedBytes = 0 + public let limit: Int + public init(limit: Int = 512 * 1024) { self.limit = max(0, limit) } + + public mutating func reset(_ text: String = "") { + generation = UUID() + chunks = [] + retainedBytes = 0 + end = 0 + append(text) + } + + public mutating func append(_ text: String) { + let bytes = Data(text.utf8) + guard !bytes.isEmpty else { return } + end += bytes.count + if limit == 0 { return } + for part in Self.split(bytes, size: 16 * 1024) { + if let last = chunks.last, last.count + part.count <= 16 * 1024 { + chunks[chunks.count - 1].append(part) + } else { + chunks.append(part) + } + retainedBytes += part.count + } + while retainedBytes > limit, let first = chunks.first { + let excess = retainedBytes - limit + if first.count <= excess { + retainedBytes -= first.count + chunks.removeFirst() + } else { + var start = first.startIndex + excess + while start < first.endIndex && first[start] & 0xC0 == 0x80 { start += 1 } + retainedBytes -= start - first.startIndex + chunks[0] = Data(first[start...]) + } + } + } + + public func read(after cursor: Cursor?) -> Update { + let start = end - retainedBytes + let reset = + cursor == nil || cursor?.generation != generation + || cursor!.offset < start || cursor!.offset > end + var skip = reset ? 0 : cursor!.offset - start + var result = Data() + for chunk in chunks { + if skip >= chunk.count { + skip -= chunk.count + continue + } + result.append(contentsOf: chunk.dropFirst(skip)) + skip = 0 + } + return Update(reset: reset, bytes: result, cursor: Cursor(generation: generation, offset: end)) + } + + public static func split(_ bytes: Data, size: Int) -> [Data] { + var result: [Data] = [] + var start = bytes.startIndex + while start < bytes.endIndex { + var end = min(bytes.endIndex, start + max(4, size)) + while end < bytes.endIndex && bytes[end] & 0xC0 == 0x80 { end -= 1 } + result.append(Data(bytes[start.. Bool { + let type = try event.requiredString("type") + if type == "snapshot" || type == "restarted" { + let snapshot = event["snapshot"] + guard target.matches(snapshot) else { return false } + output.reset(try snapshot.requiredString("history")) + status = try snapshot.requiredString("status") + label = snapshot["label"].string ?? target.terminalID + pid = snapshot["pid"].int + sequence = snapshot["sequence"].int + receivedSnapshot = true + error = nil + return true + } + guard target.matches(event), receivedSnapshot else { return false } + // Terminal events can be published out of sequence while history is persisted. + // The attach stream owns snapshot deduplication; consume its live events in arrival order. + if let next = event["sequence"].int { sequence = next } + switch type { + case "output": + output.append(try event.requiredString("data")) + error = nil + case "cleared": + output.reset() + error = nil + case "exited", "closed": + status = type + pid = nil + hasRunningSubprocess = false + error = nil + case "error": + status = "error" + error = try event.requiredString("message") + case "activity": + label = event["label"].string ?? label + hasRunningSubprocess = event["hasRunningSubprocess"].bool + default: return false + } + return true + } +} + +/// Coalesces keystrokes while a write is in flight, without splitting a pasted code point. +public struct TerminalInputQueue: Sendable { + private var chunks: [Data] = [] + public private(set) var byteCount = 0 + public init() {} + public mutating func append(_ text: String) -> Bool { + let bytes = Data(text.utf8) + guard byteCount + bytes.count <= 1024 * 1024 else { return false } + for part in TerminalOutput.split(bytes, size: 16 * 1024) { + if let last = chunks.last, last.count + part.count <= 16 * 1024 { + chunks[chunks.count - 1].append(part) + } else { + chunks.append(part) + } + } + byteCount += bytes.count + return true + } + public mutating func next() -> String? { + guard !chunks.isEmpty else { return nil } + var bytes = chunks.removeFirst() + while let first = chunks.first, bytes.count + first.count <= 16 * 1024 { + bytes.append(chunks.removeFirst()) + } + byteCount -= bytes.count + return String(decoding: bytes, as: UTF8.self) + } +} + +/// Owns one attach subscription; detaching never closes the server's shell. +@MainActor public final class RemoteTerminal { + public private(set) var state: TerminalState + public private(set) var attached = false + public private(set) var error: String? + public var onChange: (() -> Void)? + public let connection: RPCConnection + private let context: [String: JSON] + private var generation = UUID() + private var subscription: Task? + private var writer: Task? + private var resizer: Task? + private var pendingInput = TerminalInputQueue() + private var requestedGrid = TerminalGrid(columns: 80, rows: 24) + private var sentGrid: TerminalGrid? + + public init(target: TerminalTarget, connection: RPCConnection, context: [String: JSON]) { + self.connection = connection + self.context = context + state = TerminalState(target: target) + } + deinit { + subscription?.cancel() + writer?.cancel() + resizer?.cancel() + } + public func attach() { + detach() + error = nil + let generation = generation + subscription = Task { [weak self, connection, context, target = state.target] in + var id: String? + do { + try Task.checkCancellation() + var fields = context + fields.merge(self?.requestedGrid.fields ?? [:]) { _, new in new } + fields["restartIfNotRunning"] = .bool(false) + let stream = try await connection.subscribe( + "terminal.attach", payload: target.input(fields)) + id = stream.id + for try await event in stream.values { + guard !Task.isCancelled, let self, self.generation == generation else { break } + if try self.state.apply(event) { + self.attached = true + self.error = nil + if event["type"].string == "snapshot" || event["type"].string == "restarted" { + self.sentGrid = nil + self.scheduleResize() + } + self.onChange?() + } + } + if !Task.isCancelled, let self, self.generation == generation { + throw T3Error("Terminal connection ended. Reconnect to restore its history.") + } + } catch { + if let self, self.generation == generation, !Task.isCancelled { + self.error = error.localizedDescription + self.attached = false + self.onChange?() + } + } + if let id { await connection.cancel(id) } + } + } + public func detach() { + generation = UUID() + subscription?.cancel() + subscription = nil + writer?.cancel() + writer = nil + resizer?.cancel() + resizer = nil + pendingInput = TerminalInputQueue() + sentGrid = nil + attached = false + } + public func write(_ text: String) { + guard attached, state.status == "running", !text.isEmpty else { return } + guard pendingInput.append(text) else { + error = "Terminal input is still sending. Wait before pasting more text." + onChange?() + return + } + guard writer == nil else { return } + let generation = generation + writer = Task { [weak self] in + guard let self else { return } + defer { if self.generation == generation { self.writer = nil } } + do { + while self.generation == generation, !Task.isCancelled, let text = pendingInput.next() { + _ = try await connection.call( + "terminal.write", payload: state.target.input(["data": .string(text)])) + } + } catch { + guard self.generation == generation, !Task.isCancelled else { return } + pendingInput = TerminalInputQueue() + self.error = + "Terminal input could not be confirmed. It will not be sent again automatically." + onChange?() + } + } + } + public func resize(_ grid: TerminalGrid) { + requestedGrid = grid + scheduleResize() + } + private func scheduleResize() { + guard attached, state.status == "running", resizer == nil, requestedGrid != sentGrid else { + return + } + let generation = generation + resizer = Task { [weak self] in + guard let self else { return } + defer { if self.generation == generation { self.resizer = nil } } + do { + while self.generation == generation, !Task.isCancelled, requestedGrid != sentGrid { + let grid = requestedGrid + _ = try await connection.call("terminal.resize", payload: state.target.input(grid.fields)) + guard self.generation == generation else { return } + sentGrid = grid + } + } catch { + guard self.generation == generation, !Task.isCancelled else { return } + self.error = error.localizedDescription + onChange?() + } + } + } + public func clear() async throws { + _ = try await connection.call("terminal.clear", payload: state.target.input()) + } + public func restart() async throws { + var fields = context + fields.merge(requestedGrid.fields) { _, new in new } + _ = try await connection.call("terminal.restart", payload: state.target.input(fields)) + } + public func close() async throws { + _ = try await connection.call( + "terminal.close", payload: state.target.input(["deleteHistory": .bool(true)])) + detach() + } +} diff --git a/apps/macos/Sources/T3Core/TerminalDirectory.swift b/apps/macos/Sources/T3Core/TerminalDirectory.swift new file mode 100644 index 0000000000000..236a7f72acf70 --- /dev/null +++ b/apps/macos/Sources/T3Core/TerminalDirectory.swift @@ -0,0 +1,51 @@ +import Foundation + +public struct TerminalSummary: Equatable, Sendable { + public let id: String + public let label: String + public let status: String + public let hasRunningSubprocess: Bool + public init(_ value: JSON) throws { + id = try value.requiredString("terminalId") + label = value["label"].string ?? id + status = try value.requiredString("status") + hasRunningSubprocess = value["hasRunningSubprocess"].bool + } +} + +/// Metadata is environment-wide. Keep only this thread, without moving tabs on activity updates. +public struct TerminalDirectory: Sendable { + public let threadID: String + public private(set) var terminals: [TerminalSummary] = [] + public private(set) var receivedSnapshot = false + public init(threadID: String) { self.threadID = threadID } + + @discardableResult public mutating func apply(_ event: JSON) throws -> Bool { + switch try event.requiredString("type") { + case "snapshot": + terminals = try event["terminals"].array.filter { $0["threadId"].string == threadID } + .map(TerminalSummary.init) + .sorted { $0.id.localizedStandardCompare($1.id) == .orderedAscending } + receivedSnapshot = true + return true + case "upsert": + let value = event["terminal"] + guard value["threadId"].string == threadID else { return false } + let terminal = try TerminalSummary(value) + if let index = terminals.firstIndex(where: { $0.id == terminal.id }) { + guard terminals[index] != terminal else { return false } + terminals[index] = terminal + } else { + terminals.append(terminal) + } + return true + case "remove": + guard event["threadId"].string == threadID else { return false } + let id = try event.requiredString("terminalId") + let count = terminals.count + terminals.removeAll { $0.id == id } + return terminals.count != count + default: return false + } + } +} diff --git a/apps/macos/Sources/T3Core/ThreadCheckpoints.swift b/apps/macos/Sources/T3Core/ThreadCheckpoints.swift new file mode 100644 index 0000000000000..0efb08d953aa6 --- /dev/null +++ b/apps/macos/Sources/T3Core/ThreadCheckpoints.swift @@ -0,0 +1,160 @@ +import Foundation + +public struct TurnCheckpoint: Equatable, Sendable, Identifiable { + public let id: String + public let threadID: String + public let runID: String + public let turnCount: Int + public let ref: String + public let status: String + public let capturedAt: String + public let files: [ReviewFile] + public var assistantMessageID: String? + public var runCompleted: Bool + public var unavailableReason: String? { + if status != "ready" { + return "Turn \(turnCount)’s checkpoint is \(status). Its saved diff is unavailable." + } + if !runCompleted { + return "Turn \(turnCount) has not completed. Its saved diff is not ready yet." + } + return nil + } + + init?(_ value: JSON, threadID: String) { + guard value["threadId"].string == threadID, let id = value["id"].string, + let runID = value["runId"].string, let ordinal = value["appRunOrdinal"].int, ordinal > 0, + let ref = value["ref"].string, let status = value["status"].string + else { return nil } + self.id = id + self.threadID = threadID + self.runID = runID + turnCount = ordinal + self.ref = ref + self.status = status + capturedAt = value["capturedAt"].string ?? "" + files = value["files"].array.compactMap { item in + guard let path = item["path"].string else { return nil } + return ReviewFile( + path: path, previousPath: nil, + additions: item["additions"].int ?? 0, deletions: item["deletions"].int ?? 0) + }.sorted { $0.path.localizedStandardCompare($1.path) == .orderedAscending } + runCompleted = false + } +} + +/// Retains checkpoint metadata, not assistant text. Token updates leave this index unchanged. +public struct ThreadCheckpoints: Sendable { + public private(set) var items: [TurnCheckpoint] = [] + public private(set) var revision = UUID() + private var threadID = "" + private var byID: [String: TurnCheckpoint] = [:] + private var completed: Set = [] + private var assistants: [String: (id: String, createdAt: String)] = [:] + private var seenAssistantIDs: Set = [] + + public init(_ projection: JSON = .null) { + threadID = projection["thread"]["id"].string ?? "" + for run in projection["runs"].array where run["status"].string == "completed" { + if let id = run["id"].string { completed.insert(id) } + } + for message in projection["messages"].array { _ = receiveMessage(message) } + for value in projection["checkpoints"].array { + if let checkpoint = TurnCheckpoint(value, threadID: threadID) { + byID[checkpoint.id] = checkpoint + } + } + rebuild() + } + + public mutating func apply(_ event: JSON) { + let value = event["payload"] + guard value["threadId"].string == threadID else { return } + switch event["type"].string { + case "checkpoint.captured": + guard let checkpoint = TurnCheckpoint(value, threadID: threadID) else { return } + byID[checkpoint.id] = checkpoint + case "message.updated": + guard receiveMessage(value), + byID.values.contains(where: { $0.runID == value["runId"].string }) + else { return } + case "run.created", "run.updated": + guard let id = value["id"].string else { return } + let changed = + value["status"].string == "completed" + ? completed.insert(id).inserted : completed.remove(id) != nil + guard changed else { return } + default: return + } + rebuild() + } + + private mutating func receiveMessage(_ value: JSON) -> Bool { + guard value["threadId"].string == threadID, value["role"].string == "assistant", + let run = value["runId"].string, let id = value["id"].string + else { return false } + guard seenAssistantIDs.insert(id).inserted else { return false } + let time = value["createdAt"].string ?? "" + if let previous = assistants[run], previous.id == id || previous.createdAt > time { + return false + } + assistants[run] = (id, time) + return true + } + + private mutating func rebuild() { + let next = byID.values.map { item in + var item = item + item.assistantMessageID = assistants[item.runID]?.id + item.runCompleted = completed.contains(item.runID) + return item + }.sorted { + if $0.turnCount != $1.turnCount { return $0.turnCount > $1.turnCount } + if $0.capturedAt != $1.capturedAt { return $0.capturedAt > $1.capturedAt } + return $0.id < $1.id + } + if next != items { + items = next + revision = UUID() + } + } +} + +public struct CheckpointDiffRequest: Sendable, Equatable { + public let threadID: String + public let from: Int + public let to: Int + public let fullThread: Bool + public let ignoreWhitespace: Bool + public let checkpointID: String + public let checkpointRef: String + public let title: String + public var method: String { + fullThread ? "orchestration.getFullThreadDiff" : "orchestration.getTurnDiff" + } + public var input: JSON { + var fields: [String: JSON] = [ + "threadId": .string(threadID), "toTurnCount": .number(Double(to)), + "ignoreWhitespace": .bool(ignoreWhitespace), + ] + if !fullThread { fields["fromTurnCount"] = .number(Double(from)) } + return .object(fields) + } + public init(_ checkpoint: TurnCheckpoint, fullThread: Bool, ignoreWhitespace: Bool) throws { + if let error = checkpoint.unavailableReason { throw T3Error(error) } + threadID = checkpoint.threadID + from = fullThread ? 0 : max(0, checkpoint.turnCount - 1) + to = checkpoint.turnCount + self.fullThread = fullThread + self.ignoreWhitespace = ignoreWhitespace + checkpointID = checkpoint.id + checkpointRef = checkpoint.ref + title = fullThread ? "Full thread · through turn \(to)" : "Turn \(to)" + } + public func validate(_ result: JSON) throws -> String { + guard result["threadId"].string == threadID, result["fromTurnCount"].int == from, + result["toTurnCount"].int == to + else { throw T3Error("The server returned a different checkpoint comparison.") } + return try result.requiredString("diff") + } +} diff --git a/apps/macos/Sources/T3Core/ThreadExecution.swift b/apps/macos/Sources/T3Core/ThreadExecution.swift new file mode 100644 index 0000000000000..a4201cef8f627 --- /dev/null +++ b/apps/macos/Sources/T3Core/ThreadExecution.swift @@ -0,0 +1,307 @@ +import Foundation + +public enum MessageDelivery: String, Codable, CaseIterable, Sendable { + case auto, queue, steer, restart + public var title: String { + switch self { + case .auto: "Auto" + case .queue: "Queue next" + case .steer: "Steer now" + case .restart: "Interrupt and restart" + } + } +} + +public struct QueuedMessage: Equatable, Sendable, Identifiable { + public let id: String + public let messageID: String + public let text: String + public let attachments: [JSON] + public let context: JSON +} + +public struct QueuedEdit: Codable, Equatable, Sendable { + public var active = true + public let threadID: String + public let runID: String + public let messageID: String + public var attachments: [JSON] + public let context: JSON + public var draftID: String { "queued-edit:" + runID } + public init(_ message: QueuedMessage, threadID: String) { + self.threadID = threadID + runID = message.id + messageID = message.messageID + attachments = message.attachments + context = message.context + } + public var retainedContext: JSON { + guard case .object(var fields) = context else { return .null } + let ids = Set(attachments.compactMap { $0["id"].string }) + fields["records"] = .array( + context["records"].array.filter { + $0["attachmentId"].string.map { ids.contains($0) } ?? true + }) + return .object(fields) + } +} + +/// Only execution events rebuild the queue; streaming text does not scan run history. +public struct ThreadExecution: Sendable { + public private(set) var queued: [QueuedMessage] = [] + public private(set) var activeRunID: String? + public private(set) var isHeld = false + public private(set) var canQueue = false + public private(set) var canSteer = false + public private(set) var canRestart = false + public private(set) var steeringInterruptsTools = false + public private(set) var resumableRunID: String? + public private(set) var latestRunID: String? + public private(set) var latestRunSettled = false + public private(set) var hiddenUserMessageIDs: Set = [] + private var thread: JSON = .null + private var runs: [String: JSON] = [:] + private var messages: [String: JSON] = [:] + private var sessions: [String: JSON] = [:] + private var providerThreads: [String: JSON] = [:] + private var turns: [String: JSON] = [:] + private var failures: [String: JSON] = [:] + private struct UsageReport: Sendable { + let ordinal: Int + let usage: ContextUsage + } + private var liveReports: [String: UsageReport] = [:] + private var providerReports: [String: ContextUsage] = [:] + private var compactedReports: [String: ContextUsage] = [:] + private var compactableItems: Set = [] + public private(set) var hasDismissedResumeCompaction = false + public var selectedProviderThread: JSON { + thread["activeProviderThreadId"].string.flatMap { providerThreads[$0] } ?? .null + } + public var liveProviderTurnUsage: ContextUsage? { + thread["activeProviderThreadId"].string.flatMap { liveReports[$0]?.usage } + } + public var contextUsage: ContextUsage? { + guard let id = thread["activeProviderThreadId"].string else { return nil } + return liveReports[id]?.usage ?? providerReports[id] ?? compactedReports[id] + } + public var hasCompactableConversation: Bool { !compactableItems.isEmpty } + + public init(_ projection: JSON = .null) { + func indexed(_ values: JSON) -> [String: JSON] { + Dictionary( + values.array.compactMap { value in value["id"].string.map { ($0, value) } }, + uniquingKeysWith: { _, new in new }) + } + thread = projection["thread"] + runs = indexed(projection["runs"]) + messages = indexed( + .array( + projection["messages"].array.filter { $0["role"].string == "user" || $0["role"] == .null })) + sessions = indexed(projection["providerSessions"]) + providerThreads = indexed(projection["providerThreads"]) + turns = indexed(projection["providerTurns"]) + failures = indexed( + .array(projection["turnItems"].array.filter { $0["type"].string == "error" })) + for provider in providerThreads.values { recordProviderUsage(provider) } + for turn in turns.values { recordLiveUsage(turn) } + let visible = projection["visibleTurnItems"].array + let items = visible.isEmpty ? projection["turnItems"].array : visible.map { $0["item"] } + for item in items { recordContextItem(item) } + hasDismissedResumeCompaction = projection["runtimeRequests"].array.contains( + where: ContextUsage.hasPermanentDismissal) + rebuild() + } + + public mutating func apply(_ event: JSON) { + let payload = event["payload"] + let type = event["type"].string ?? "" + let id = payload["id"].string + switch type { + case "run.created", "run.updated": if let id { runs[id] = payload } + case "message.updated": + guard payload["role"].string == "user" || payload["role"] == .null else { return } + if let id { messages[id] = payload } + case "provider-session.attached", "provider-session.updated": + if let id { sessions[id] = payload } + case "provider-session.detached": + if let id = payload["providerSessionId"].string { sessions.removeValue(forKey: id) } + case "provider-thread.updated": + if let id { + providerThreads[id] = payload + recordProviderUsage(payload) + } + case "provider-turn.updated": + guard let id else { return } + let previous = turns[id] + recordLiveUsage(payload) + turns[id] = payload + guard + previous?["runAttemptId"] != payload["runAttemptId"] + || previous?["status"] != payload["status"] + else { return } + case "runtime-request.updated": + hasDismissedResumeCompaction = + hasDismissedResumeCompaction || ContextUsage.hasPermanentDismissal(payload) + return + case "turn-item.updated": + recordContextItem(payload) + guard payload["type"].string == "error", let id else { return } + failures[id] = payload + default: + guard type.hasPrefix("thread."), payload["id"] == thread["id"] else { return } + thread = payload + } + rebuild() + } + + public func hidesQueuedMessage(_ item: JSON) -> Bool { + item["type"].string == "user_message" && item["inputIntent"].string == "queued_turn" + && hiddenUserMessageIDs.contains(item["messageId"].string ?? "") + } + + private mutating func recordProviderUsage(_ provider: JSON) { + guard let id = provider["id"].string, + let usage = ContextUsage( + report: provider["contextUsage"], updatedAt: provider["updatedAt"].string) + else { return } + if let previous = providerReports[id], + (ThreadDates.parse(usage.updatedAt) ?? .distantPast) + < (ThreadDates.parse(previous.updatedAt) ?? .distantPast) + { + return + } + providerReports[id] = usage + } + + private mutating func recordLiveUsage(_ turn: JSON) { + guard let providerID = turn["providerThreadId"].string, turn["id"].string != nil, + let usage = ContextUsage(report: turn["tokenUsage"], live: true) + else { return } + let ordinal = turn["ordinal"].int ?? 0 + if let previous = liveReports[providerID] { + guard ordinal >= previous.ordinal else { return } + if ordinal == previous.ordinal, + (ThreadDates.parse(usage.updatedAt) ?? .distantPast) + < (ThreadDates.parse(previous.usage.updatedAt) ?? .distantPast) + { + return + } + } + liveReports[providerID] = UsageReport(ordinal: ordinal, usage: usage) + } + + private mutating func recordContextItem(_ item: JSON) { + if item["type"].string == "user_message", let id = item["id"].string { + let text = + item["text"].string?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" + if text != "/compact" || !item["attachments"].array.isEmpty { + compactableItems.insert(id) + } else { + compactableItems.remove(id) + } + } + guard let usage = ContextUsage.compaction(item), + let id = item["providerThreadId"].string ?? thread["activeProviderThreadId"].string + else { return } + if let previous = compactedReports[id], + (ThreadDates.parse(usage.updatedAt) ?? .distantPast) + < (ThreadDates.parse(previous.updatedAt) ?? .distantPast) + { + return + } + compactedReports[id] = usage + } + + private mutating func rebuild() { + let ordered = runs.values.sorted { ($0["ordinal"].int ?? 0) < ($1["ordinal"].int ?? 0) } + let active = ordered.last { + ["preparing", "starting", "running", "waiting"].contains($0["status"].string ?? "") + } + activeRunID = active?["id"].string + let providerThreadID = + active?["providerThreadId"].string ?? thread["activeProviderThreadId"].string + let attached = + providerThreadID.flatMap { providerThreads[$0] } + ?? providerThreads.values.sorted { + ($0["createdAt"].string ?? "") < ($1["createdAt"].string ?? "") + } + .first { $0["appThreadId"] == thread["id"] && $0["providerSessionId"].string != nil } + let session: JSON? + if let sessionID = attached?["providerSessionId"].string { + session = sessions[sessionID] + } else { + session = sessions.values.filter { + !["stopped", "error"].contains($0["status"].string ?? "") + } + .max { ($0["createdAt"].string ?? "") < ($1["createdAt"].string ?? "") } + } + let capabilities = session?["capabilities"]["turns"] ?? .null + let hasTurn = + active?["status"].string == "running" + && active?["activeAttemptId"].string != nil + && turns.values.contains { + $0["runAttemptId"] == active?["activeAttemptId"] && $0["status"].string == "running" + } + canQueue = capabilities["supportsQueuedMessages"].bool + canRestart = hasTurn && capabilities["supportsSteeringByInterruptRestart"].bool + canSteer = hasTurn && (capabilities["supportsActiveSteering"].bool || canRestart) + steeringInterruptsTools = + !capabilities["supportsActiveSteering"].bool + || capabilities["activeSteeringInterruptsTools"].bool + isHeld = ordered.contains { $0["status"].string == "queued" && $0["queueHeld"].bool } + hiddenUserMessageIDs = Set( + ordered.filter { + $0["status"].string == "queued" + || ($0["status"].string == "cancelled" && $0["startedAt"] == .null) + }.compactMap { $0["userMessageId"].string }) + queued = ordered.filter { run in + let message = messages[run["userMessageId"].string ?? ""] ?? .null + return run["status"].string == "queued" && message["delegatedCompletion"] == .null + && message["notification"] == .null + }.sorted { + let left = ($0["queuePosition"].int ?? $0["ordinal"].int ?? 0, $0["ordinal"].int ?? 0) + let right = ($1["queuePosition"].int ?? $1["ordinal"].int ?? 0, $1["ordinal"].int ?? 0) + return left < right + }.compactMap { run in + guard let id = run["id"].string, let messageID = run["userMessageId"].string else { + return nil + } + let message = messages[messageID] ?? .null + return QueuedMessage( + id: id, messageID: messageID, text: message["text"].string ?? "Queued message", + attachments: message["attachments"].array, context: message["context"]) + } + let latest = ordered.filter { + $0["status"].string != "queued" + && !($0["status"].string == "cancelled" && $0["startedAt"] == .null) + }.max { + let left = ThreadDates.parse($0["completedAt"].string)?.timeIntervalSince1970 ?? .infinity + let right = ThreadDates.parse($1["completedAt"].string)?.timeIntervalSince1970 ?? .infinity + return (left, $0["ordinal"].int ?? 0) < (right, $1["ordinal"].int ?? 0) + } + let failure = failures.values.filter { + $0["runId"] == latest?["id"] && $0["nodeId"] == latest?["rootNodeId"] + && $0["status"].string == "failed" + }.max { + ( + ThreadDates.parse($0["updatedAt"].string) ?? .distantPast, $0["ordinal"].int ?? 0, + $0["id"].string ?? "" + ) + < ( + ThreadDates.parse($1["updatedAt"].string) ?? .distantPast, $1["ordinal"].int ?? 0, + $1["id"].string ?? "" + ) + }?["failure"] + let limited = + latest?["status"].string == "failed" && failure?["class"].string == "usage_limit" + && (session?["lastError"].string == nil || session?["lastError"] == failure?["message"]) + latestRunID = latest?["id"].string + latestRunSettled = + latest != nil && activeRunID == nil + && thread["activeRunId"].string != latestRunID + resumableRunID = + activeRunID == nil && (latest?["status"].string == "interrupted" || limited) + ? latest?["id"].string : nil + } +} diff --git a/apps/macos/Sources/T3Core/ThreadLifecycle.swift b/apps/macos/Sources/T3Core/ThreadLifecycle.swift new file mode 100644 index 0000000000000..611e79bc1a4f8 --- /dev/null +++ b/apps/macos/Sources/T3Core/ThreadLifecycle.swift @@ -0,0 +1,243 @@ +import Foundation + +public enum ThreadDates { + public static func parse(_ value: String?) -> Date? { + guard let value else { return nil } + return + (try? Date( + value, + strategy: .iso8601.year().month().day().time(includingFractionalSeconds: true).timeZone( + separator: .colon))) + ?? (try? Date(value, strategy: .iso8601)) + } + public static func string(_ value: Date) -> String { + value.ISO8601Format( + .iso8601.year().month().day().time(includingFractionalSeconds: true).timeZone( + separator: .colon)) + } +} + +public struct ThreadLifecycle: Sendable, Equatable { + public let createdAt: Date? + public let updatedAt: Date? + public let settled: Bool + public let settledAt: Date? + public let unsettledAt: Date? + public let snoozedUntil: Date? + public let snoozedAt: Date? + public let pinned: Bool + public let pinOrderKey: String? + public let activeOrderKey: String? + public let lastVisitedAt: Date? + public let latestUserMessageAt: Date? + public let latestRunRequestedAt: Date? + public let latestRunStartedAt: Date? + public let latestRunCompletedAt: Date? + public let pendingRequestKind: String? + public let status: String + public let runStatus: String + public let hasLatestRun: Bool + public let backgroundWaiting: Bool + public let planReady: Bool + public let autoSettleDisabled: Bool + + public init(_ value: JSON) { + createdAt = ThreadDates.parse(value["createdAt"].string) + updatedAt = ThreadDates.parse(value["updatedAt"].string) + settled = value["settledOverride"].string == "settled" + settledAt = ThreadDates.parse(value["settledAt"].string) + unsettledAt = ThreadDates.parse(value["unsettledAt"].string) + snoozedUntil = ThreadDates.parse(value["snoozedUntil"].string) + snoozedAt = ThreadDates.parse(value["snoozedAt"].string) + pinned = value["pinnedAt"].string != nil + pinOrderKey = value["pinOrderKey"].string + activeOrderKey = value["activeOrderKey"].string + lastVisitedAt = ThreadDates.parse(value["lastVisitedAt"].string) + latestUserMessageAt = ThreadDates.parse(value["latestUserMessageAt"].string) + latestRunRequestedAt = ThreadDates.parse(value["latestRunRequestedAt"].string) + latestRunStartedAt = ThreadDates.parse(value["latestRunStartedAt"].string) + hasLatestRun = value["latestRunId"].string != nil + runStatus = value["status"].string ?? "idle" + if case .object(let object) = value, object["latestRunCompletedAt"] == nil, + hasLatestRun, + ["idle", "completed", "cancelled", "failed", "interrupted", "rolled_back"].contains(runStatus) + { + latestRunCompletedAt = updatedAt + } else { + latestRunCompletedAt = ThreadDates.parse(value["latestRunCompletedAt"].string) + } + pendingRequestKind = value["pendingRuntimeRequest"]["kind"].string + backgroundWaiting = value["pendingBackgroundTasks"].array.contains { + $0["kind"].string != "command" + } + status = + backgroundWaiting && runStatus != "failed" + ? "idle" : value["activityRunStatus"].string ?? runStatus + planReady = + value["interactionMode"].string == "plan" && value["hasActionableProposedPlan"].bool + && ["idle", "completed", "interrupted", "cancelled", "failed"].contains(status) + autoSettleDisabled = value["autoSettleDisabledAt"].string != nil + } + + public var needsAttention: Bool { + pendingRequestKind != nil && pendingRequestKind != "auth_refresh" + } + public var activeAnchor: Date { max(createdAt ?? .distantPast, unsettledAt ?? .distantPast) } + public var settledAnchor: Date { + settledAt ?? [ + latestUserMessageAt, latestRunRequestedAt, latestRunStartedAt, latestRunCompletedAt, + ] + .compactMap { $0 }.max() ?? updatedAt ?? .distantPast + } + public var unread: Bool { + guard let lastVisitedAt, let latestRunCompletedAt else { return false } + return latestRunCompletedAt > lastVisitedAt + } + + public func hasQueuedStart(at now: Date) -> Bool { + if ["preparing", "starting", "queued"].contains(status) { return true } + guard status != "error", let message = latestUserMessageAt, + abs(now.timeIntervalSince(message)) <= 120 + else { return false } + return !hasLatestRun + || [latestRunRequestedAt, latestRunStartedAt, latestRunCompletedAt] + .allSatisfy { $0 == nil || $0! < message } + } + + public func canSnooze(at now: Date) -> Bool { !needsAttention && !hasQueuedStart(at: now) } + + public var raisedHand: Bool { + if needsAttention { return true } + if ["failed", "error"].contains(status), + snoozedAt == nil || (updatedAt != nil && updatedAt! > snoozedAt!) + { + return true + } + return completedAfterSnooze + } + + private var completedAfterSnooze: Bool { + guard ["idle", "completed"].contains(runStatus), let snoozedAt, + let latestRunCompletedAt + else { return false } + return latestRunCompletedAt > snoozedAt + } + + public func snoozed(at now: Date) -> Bool { + guard let snoozedUntil, snoozedUntil > now else { return false } + return !raisedHand + } + + public func wokeAt(_ now: Date) -> Date? { + guard let snoozedUntil else { return nil } + if raisedHand { return completedAfterSnooze ? latestRunCompletedAt : updatedAt ?? snoozedAt } + return snoozedUntil <= now ? snoozedUntil : nil + } + + public func statusLabel(at now: Date) -> String? { + if needsAttention { + return pendingRequestKind == "user_input" ? "Awaiting Input" : "Pending Approval" + } + if ["running", "waiting"].contains(status) { return "Working" } + if ["preparing", "starting", "queued"].contains(status) { return "Connecting" } + if backgroundWaiting { return "Waiting" } + if planReady { return "Plan Ready" } + if unread { return "Completed" } + if let wake = wokeAt(now), lastVisitedAt == nil || wake > lastVisitedAt! { return "Woke" } + return nil + } +} + +public enum ThreadSection: String, CaseIterable, Sendable { + case pinned = "Pinned" + case active = "Active" + case snoozed = "Snoozed" + case settled = "Settled" + case archived = "Archived" +} + +public struct ThreadGroup: Sendable, Equatable { + public let section: ThreadSection + public let threads: [ThreadSummary] + + public static func resolve(_ threads: [ThreadSummary], at now: Date) -> [ThreadGroup] { + var groups: [ThreadSection: [ThreadSummary]] = [:] + for thread in threads where !thread.deleted { + let state = thread.lifecycle + let section: ThreadSection = + thread.archived + ? .archived + : state.settled + ? .settled + : state.snoozed(at: now) ? .snoozed : state.pinned ? .pinned : .active + groups[section, default: []].append(thread) + } + return ThreadSection.allCases.compactMap { section in + guard let items = groups[section] else { return nil } + return ThreadGroup(section: section, threads: sorted(items, in: section)) + } + } + + public static func sorted(_ threads: [ThreadSummary], in section: ThreadSection) + -> [ThreadSummary] + { + threads.sorted { a, b in + let left = a.lifecycle + let right = b.lifecycle + if section == .active || section == .pinned { + let lKey = section == .pinned ? left.pinOrderKey : left.activeOrderKey + let rKey = section == .pinned ? right.pinOrderKey : right.activeOrderKey + if let lKey, let rKey, lKey != rKey { return lKey < rKey } + if (lKey == nil) != (rKey == nil) { return section == .pinned ? lKey != nil : lKey == nil } + if lKey != nil { return a.id < b.id } + } + let lDate: Date + let rDate: Date + switch section { + case .pinned: + lDate = left.createdAt ?? .distantPast + rDate = right.createdAt ?? .distantPast + case .settled: + lDate = left.settledAnchor + rDate = right.settledAnchor + case .archived: + lDate = left.updatedAt ?? .distantPast + rDate = right.updatedAt ?? .distantPast + default: + lDate = left.activeAnchor + rDate = right.activeAnchor + } + return lDate == rDate ? a.id < b.id : lDate > rDate + } + } +} + +public struct SnoozePreset: Sendable { + public let title: String + public let date: Date + + public static func choices(at now: Date, calendar: Calendar = .current) -> [SnoozePreset] { + var result = [ + SnoozePreset(title: "In 1 hour", date: now.addingTimeInterval(3600)), + SnoozePreset(title: "In 3 hours", date: now.addingTimeInterval(10800)), + ] + if let evening = calendar.date(bySettingHour: 18, minute: 0, second: 0, of: now), + evening.timeIntervalSince(now) > 3600 + { + result.append(.init(title: "This evening", date: evening)) + } + if let day = calendar.date(byAdding: .day, value: 1, to: now), + let morning = calendar.date(bySettingHour: 9, minute: 0, second: 0, of: day) + { + result.append(.init(title: "Tomorrow", date: morning)) + } + let distance = (2 - calendar.component(.weekday, from: now) + 7) % 7 + if let day = calendar.date(byAdding: .day, value: distance == 0 ? 7 : distance, to: now), + let morning = calendar.date(bySettingHour: 9, minute: 0, second: 0, of: day), + !result.contains(where: { $0.date == morning }) + { + result.append(.init(title: "Next week", date: morning)) + } + return result + } +} diff --git a/apps/macos/Sources/T3Core/UsageHistory.swift b/apps/macos/Sources/T3Core/UsageHistory.swift new file mode 100644 index 0000000000000..c8a9de531b95d --- /dev/null +++ b/apps/macos/Sources/T3Core/UsageHistory.swift @@ -0,0 +1,213 @@ +import Foundation + +public enum UsageMetric: String, CaseIterable, Sendable { + case cost, tokens, limits + public var title: String { rawValue.capitalized } +} + +public enum UsagePeriod: Int, CaseIterable, Sendable { + case day = 1 + case week = 7 + case month = 30 + case quarter = 90 + public var title: String { self == .day ? "Past 24h" : "\(rawValue) days" } + + public func window(at now: Date = Date(), timeZone: TimeZone = .current) -> UsageHistoryWindow { + UsageHistoryWindow(period: self, now: now, timeZone: timeZone) + } +} + +public struct UsageHistoryWindow: Sendable, Equatable { + public let input: JSON + public let periods: [String] + public let hourly: Bool + public let timeZone: TimeZone + + init(period: UsagePeriod, now: Date, timeZone: TimeZone) { + self.timeZone = timeZone + hourly = period == .day + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = timeZone + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.calendar = calendar + formatter.timeZone = timeZone + formatter.dateFormat = "yyyy-MM-dd" + let until = + hourly ? Date(timeIntervalSince1970: floor(now.timeIntervalSince1970 / 60) * 60) : now + let since = + hourly + ? until.addingTimeInterval(-86_400) + : calendar.date( + byAdding: .day, value: -(period.rawValue - 1), to: calendar.startOfDay(for: until))! + var fields: [String: JSON] = [ + "sinceDay": .string(formatter.string(from: since)), + "untilDay": .string(formatter.string(from: until)), + "timeZone": .string(timeZone.identifier), "resolution": .string(hourly ? "hour" : "day"), + ] + if hourly { + fields["sinceTime"] = .string(ThreadDates.string(since)) + fields["untilTime"] = .string(ThreadDates.string(until)) + periods = (0..<24).map { ThreadDates.string(since.addingTimeInterval(Double($0) * 3_600)) } + } else { + periods = (0.. String { + let formatter = DateFormatter() + formatter.locale = .current + if hourly, let date = ThreadDates.parse(period) { + formatter.timeZone = timeZone + formatter.dateFormat = "MMM d, h:mm a z" + return formatter.string(from: date) + } + let parts = period.split(separator: "-").compactMap { Int($0) } + guard parts.count == 3 else { return period } + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = timeZone + guard + let date = calendar.date(from: DateComponents(year: parts[0], month: parts[1], day: parts[2])) + else { return period } + formatter.timeZone = timeZone + formatter.dateFormat = "MMM d" + return formatter.string(from: date) + } +} + +public struct UsageTotals: Sendable, Equatable { + public var cost = 0.0 + public var input = 0.0 + public var cached = 0.0 + public var cacheWrite = 0.0 + public var output = 0.0 + public var reasoning = 0.0 + public var records = 0.0 + public var unpriced = 0.0 + public var cacheSavings = 0.0 + public var inputCost = 0.0 + public var cacheReadCost = 0.0 + public var cacheWriteCost = 0.0 + public var outputCost = 0.0 + public var speedPremium = 0.0 + public var totalTokens: Double { input + cached + cacheWrite + output } + public var costUnknown: Bool { records > 0 && unpriced >= records } + public init() {} + public func value(_ metric: UsageMetric) -> Double { metric == .tokens ? totalTokens : cost } + public func display(_ metric: UsageMetric) -> String { + metric == .tokens + ? UsageFormat.tokens(totalTokens) : costUnknown ? "Unpriced" : UsageFormat.cost(cost) + } + mutating func add(_ bucket: JSON) { + cost += bucket["costUsd"].usageNumber ?? 0 + input += bucket["totals"]["uncachedInputTokens"].usageNumber ?? 0 + cached += bucket["totals"]["cachedInputTokens"].usageNumber ?? 0 + cacheWrite += bucket["totals"]["cacheCreationTokens"].usageNumber ?? 0 + output += bucket["totals"]["outputTokens"].usageNumber ?? 0 + reasoning += bucket["totals"]["reasoningTokens"].usageNumber ?? 0 + records += bucket["records"].usageNumber ?? 0 + unpriced += bucket["unpricedRecords"].usageNumber ?? 0 + cacheSavings += bucket["cacheSavingsUsd"].usageNumber ?? 0 + inputCost += bucket["categoryCostUsd"]["input"].usageNumber ?? 0 + cacheReadCost += bucket["categoryCostUsd"]["cacheRead"].usageNumber ?? 0 + cacheWriteCost += bucket["categoryCostUsd"]["cacheWrite"].usageNumber ?? 0 + outputCost += bucket["categoryCostUsd"]["output"].usageNumber ?? 0 + speedPremium += bucket["speedPremiumUsd"].usageNumber ?? 0 + } +} + +public struct UsageBreakdown: Sendable, Equatable, Identifiable { + public let id: String + public let provider: String + public let name: String + public var totals = UsageTotals() +} + +public struct UsageHistory: Sendable, Equatable { + public let readAt: Date? + public let window: UsageHistoryWindow + public let totals: UsageTotals + public let providers: [UsageBreakdown] + public let models: [UsageBreakdown] + public let periods: [UsageBreakdown] + public let series: [UsageBreakdown] + public let notices: [String] + public let pricing: String + public let sessions: Int + + public init(_ value: JSON, window: UsageHistoryWindow) throws { + guard let version = value["contractVersion"].int, (4...6).contains(version) else { + throw T3Error( + "This server’s usage format is incompatible. Update the server and native app to view totals." + ) + } + self.window = window + readAt = ThreadDates.parse(value["readAt"].string) + pricing = value["pricing"]["status"].string ?? "unavailable" + var notices: [String] = [] + var seenSources: Set = [] + var sessions = 0 + for source in value["sources"].array { + let fingerprint = source["fingerprint"] + let key = ["hostId", "provider", "resolvedHomePath", "volumeId"].map { + fingerprint[$0].string ?? "" + }.joined(separator: "\0") + if seenSources.insert(key).inserted { sessions += source["distinctSessions"].int ?? 0 } + if source["status"].string != "ok" { + let provider = UsageFormat.provider(fingerprint["provider"].string ?? "Source") + notices.append( + provider + ": " + + (source["message"].string ?? "\(source["status"].string ?? "Unknown") coverage")) + } + } + self.sessions = sessions + self.notices = Array(Set(notices)).sorted() + var totals = UsageTotals() + var providers: [String: UsageBreakdown] = [:] + var models: [String: UsageBreakdown] = [:] + var periods = Dictionary( + uniqueKeysWithValues: window.periods.map { + ($0, UsageBreakdown(id: $0, provider: "", name: $0)) + }) + var series: [String: UsageBreakdown] = [:] + for bucket in value["buckets"].array { + guard let provider = bucket["provider"].string, let model = bucket["model"].string else { + continue + } + let period = + window.hourly + ? ThreadDates.parse(bucket["hourStart"].string).map(ThreadDates.string) + : bucket["day"].string + guard let period, periods[period] != nil else { continue } + totals.add(bucket) + let modelKey = provider + ":" + model + let seriesKey = provider + ":" + period + providers[ + provider, + default: UsageBreakdown( + id: provider, provider: provider, name: UsageFormat.provider(provider)) + ].totals.add(bucket) + models[modelKey, default: UsageBreakdown(id: modelKey, provider: provider, name: model)] + .totals.add(bucket) + periods[period]?.totals.add(bucket) + series[seriesKey, default: UsageBreakdown(id: seriesKey, provider: provider, name: period)] + .totals.add(bucket) + } + self.totals = totals + let sortedProviders = providers.values.sorted { + UsageFormat.providerOrder($0.provider) < UsageFormat.providerOrder($1.provider) + } + self.providers = sortedProviders + self.models = models.values.sorted { $0.totals.cost > $1.totals.cost } + self.periods = window.periods.compactMap { periods[$0] } + self.series = window.periods.flatMap { period in + sortedProviders.map { provider in + series[provider.id + ":" + period] + ?? UsageBreakdown(id: provider.id + ":" + period, provider: provider.id, name: period) + } + } + } +} diff --git a/apps/macos/Sources/T3Core/UsageLimits.swift b/apps/macos/Sources/T3Core/UsageLimits.swift new file mode 100644 index 0000000000000..652d7b3c9f952 --- /dev/null +++ b/apps/macos/Sources/T3Core/UsageLimits.swift @@ -0,0 +1,310 @@ +import Foundation + +public struct UsageWindow: Sendable, Equatable, Identifiable { + public let id: String + public let kind: String + public let label: String + public let used: Double + public let resetsAt: Date? + public let durationMinutes: Double? + public var key: String { kind + ":" + id } + public var remaining: Int { Int((100 - used).rounded()) } + public var order: Int { ["session", "weekly", "monthly", "other"].firstIndex(of: kind) ?? 3 } + + init?(_ value: JSON) { + guard let id = value["id"].string, let label = value["label"].string, + let kind = value["kind"].string, let used = value["usedPercent"].usageNumber, + (0...100).contains(used) + else { return nil } + self.id = id + self.kind = kind + self.label = label + self.used = used + resetsAt = ThreadDates.parse(value["resetsAt"].string) + durationMinutes = value["windowDurationMins"].usageNumber + } + + public func elapsed(at now: Date) -> Double? { + guard let resetsAt, let durationMinutes, durationMinutes > 0 else { return nil } + return max(0, min(1, 1 - resetsAt.timeIntervalSince(now) / (durationMinutes * 60))) + } + + public func resetDescription(at now: Date) -> String? { + guard let resetsAt else { return nil } + return resetsAt <= now ? "Resets now" : UsageFormat.duration(resetsAt.timeIntervalSince(now)) + } +} + +public struct UsageAccount: Sendable, Equatable, Identifiable { + public let id: String + public let driver: String + public var name: String + public let email: String? + public var plan: String? + public var source: String? + public var windows: [UsageWindow] + public var checkedAt: Date? + public var credits: Int? + public var creditExpiresAt: Date? + public var redeemInput: JSON? + + init( + _ value: JSON, driver: String, fallback: String, name: String?, source: String?, redeem: JSON? + ) { + let limits = value["usageLimits"] + email = value["email"].string ?? value["auth"]["email"].string + let normalizedEmail = email?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if let normalizedEmail, !normalizedEmail.isEmpty { + id = driver + ":" + normalizedEmail + } else if let fingerprint = limits["credentialFingerprint"].string { + id = driver + ":credential:" + fingerprint + } else { + id = fallback + } + self.driver = driver + self.name = + name ?? email.map { + $0.split(separator: "@").compactMap { $0.first.map(String.init) }.joined().uppercased() + } ?? UsageFormat.provider(driver) + plan = value["plan"].string ?? value["auth"]["plan"].string + self.source = source + windows = limits["windows"].array.compactMap(UsageWindow.init) + checkedAt = ThreadDates.parse(limits["checkedAt"].string) + credits = limits["resetCredits"]["availableCount"].int + creditExpiresAt = ThreadDates.parse(limits["resetCredits"]["nextExpiresAt"].string) + redeemInput = redeem + } +} + +public struct UsagePool: Sendable, Equatable, Identifiable { + public let driver: String + public let accounts: [UsageAccount] + public let windows: [UsagePoolWindow] + public var id: String { driver } +} + +public struct UsagePoolWindow: Sendable, Equatable, Identifiable { + public struct Member: Sendable, Equatable, Identifiable { + public let account: UsageAccount + public let window: UsageWindow? + public var id: String { account.id } + } + public let id: String + public let label: String + public let columns: [Member] + public let remaining: Int + public let pace: String? + public let nextRecovery: Int? + public let nextReset: Date? + + init(window: UsageWindow, accounts: [UsageAccount], now: Date) { + id = window.key + label = window.label + columns = accounts.map { + Member(account: $0, window: $0.windows.first { $0.key == window.key }) + } + let reported = columns.compactMap(\.window) + remaining = Int((100 - reported.reduce(0) { $0 + $1.used } / Double(reported.count)).rounded()) + let timed = reported.compactMap { w in w.elapsed(at: now).map { (used: w.used, elapsed: $0) } } + if timed.isEmpty { + pace = nil + } else { + let gap = timed.reduce(0) { $0 + $1.used - $1.elapsed * 100 } / Double(timed.count) + pace = + gap > 5 + ? "Spending faster than the window elapses" + : gap < -5 ? "Headroom for the rest of the window" : "On pace" + } + let first = reported.filter { $0.resetsAt != nil }.min { $0.resetsAt! < $1.resetsAt! } + nextRecovery = first.map { Int(($0.used / Double(reported.count)).rounded()) } + nextReset = first?.resetsAt + } +} + +public struct UsageLimitsSnapshot: Sendable, Equatable { + public struct ExternalLink: Sendable, Equatable, Identifiable { + public let label: String + public let url: URL + public var id: URL { url } + } + public private(set) var accounts: [UsageAccount] = [] + public private(set) var notices: [String] = [] + public private(set) var links: [ExternalLink] = [] + public private(set) var environmentLabel = "Connected environment" + private var providers: [JSON] = [] + private var sources: [JSON] = [] + public init() {} + public static func == (left: Self, right: Self) -> Bool { + left.accounts == right.accounts && left.notices == right.notices && left.links == right.links + && left.environmentLabel == right.environmentLabel + } + + public mutating func apply(_ event: JSON) { + switch event["type"].string { + case "snapshot": + providers = event["config"]["providers"].array + sources = event["config"]["usageLimitSources"].array + environmentLabel = event["config"]["environment"]["label"].string ?? "Connected environment" + case "providerStatuses": providers = event["payload"]["providers"].array + case "usageLimitSourcesUpdated": sources = event["payload"]["sources"].array + default: return + } + rebuild() + } + + private mutating func rebuild() { + var merged: [String: UsageAccount] = [:] + notices = [] + links = [] + func merge(_ next: UsageAccount) { + guard var previous = merged[next.id] else { + merged[next.id] = next + return + } + let fresher = (next.checkedAt ?? .distantPast) > (previous.checkedAt ?? .distantPast) + if fresher { + previous.windows = next.windows + previous.checkedAt = next.checkedAt + } + if next.credits != nil && (previous.credits == nil || fresher) { + previous.credits = next.credits + previous.creditExpiresAt = next.creditExpiresAt + } + // Hub redemption also clears that hub's routing cooldown for this subscription. + if next.redeemInput?["sourceId"].string != nil || previous.redeemInput == nil { + previous.redeemInput = next.redeemInput + } + previous.plan = previous.plan ?? next.plan + merged[next.id] = previous + } + for provider in providers + where provider["enabled"].bool && provider["installed"].bool + && provider["availability"].string != "unavailable" && provider["usageLimits"] != .null + { + guard let driver = provider["driver"].string, let instance = provider["instanceId"].string + else { continue } + let name = provider["displayName"].string ?? UsageFormat.provider(driver) + let limits = provider["usageLimits"] + if let urlString = limits["externalUsage"]["url"].string, let url = URL(string: urlString), + ["https", "http"].contains(url.scheme ?? ""), + provider["auth"]["status"].string == "authenticated", + !links.contains(where: { $0.url == url }) + { + links.append(ExternalLink(label: limits["externalUsage"]["label"].string ?? name, url: url)) + } + if let notice = Self.notice(limits) { + if limits["unavailable"]["reason"].string != "unsupported" { + notices.append(name + ": " + notice) + } + continue + } + merge( + UsageAccount( + provider, driver: driver, fallback: instance, name: name, source: nil, + redeem: .object(["instanceId": .string(instance)]))) + } + for source in sources { + let label = source["label"].string ?? "Usage source" + if let error = source["error"].string { + notices.append(label + ": " + error) + } else if source["accounts"].array.isEmpty { + notices.append(label + ": No accounts reported.") + } + for account in source["accounts"].array { + guard let driver = account["driver"].string, let id = account["id"].string, + let sourceID = source["id"].string + else { continue } + if let notice = Self.notice(account["usageLimits"]) { + if account["usageLimits"]["unavailable"]["reason"].string != "unsupported" { + notices.append(label + ": " + notice) + } + continue + } + let credit = account["usageLimits"]["resetCredits"]["nextCreditId"].string + merge( + UsageAccount( + account, driver: driver, fallback: sourceID + ":" + id, + name: account["email"].string == nil + ? id.replacingOccurrences(of: ".json", with: "") : nil, + source: label, + redeem: credit.map { + .object([ + "sourceId": .string(sourceID), "accountId": .string(id), "creditId": .string($0), + ]) + })) + } + } + accounts = merged.values.sorted { $0.id < $1.id } + } + + private static func notice(_ limits: JSON) -> String? { + if limits["unavailable"] != .null { + return limits["unavailable"]["message"].string ?? "Could not read limits." + } + return limits["windows"].array.isEmpty ? "No limits reported." : nil + } + + public func pools(at now: Date) -> [UsagePool] { + Dictionary(grouping: accounts, by: \.driver).map { driver, members in + let windows = members.flatMap(\.windows).sorted { $0.order < $1.order } + let orderKey = windows.first?.key + let sorted = members.sorted { left, right in + let l = left.windows.first { $0.key == orderKey }?.resetsAt ?? .distantFuture + let r = right.windows.first { $0.key == orderKey }?.resetsAt ?? .distantFuture + if l != r { return l < r } + if left.name != right.name { + return left.name.localizedStandardCompare(right.name) == .orderedAscending + } + return left.id < right.id + } + var keys: Set = [] + let hasCursorPools = + windows.contains { $0.id == "autoPercentUsed" } + && windows.contains { $0.id == "apiPercentUsed" } + let rows = windows.filter { + keys.insert($0.key).inserted + && !(driver == "cursor" && hasCursorPools && $0.id == "totalPercentUsed") + }.map { UsagePoolWindow(window: $0, accounts: sorted, now: now) } + return UsagePool(driver: driver, accounts: sorted, windows: rows) + }.sorted { UsageFormat.providerOrder($0.driver) < UsageFormat.providerOrder($1.driver) } + } +} + +extension JSON { + var usageNumber: Double? { + if case .number(let value) = self, value.isFinite { value } else { nil } + } +} + +public enum UsageFormat { + public static func provider(_ driver: String) -> String { + switch driver { + case "codex": "Codex" + case "claude", "claudeAgent": "Claude" + case "cursor": "Cursor" + case "grok": "Grok" + case "opencode": "OpenCode" + case "antigravity": "Antigravity" + default: driver + } + } + static func providerOrder(_ driver: String) -> String { + let index = + ["codex", "claudeAgent", "claude", "cursor", "grok", "opencode", "antigravity"].firstIndex( + of: driver) ?? 7 + return "\(index):\(driver)" + } + public static func duration(_ seconds: TimeInterval) -> String { + let minutes = Int(max(0, seconds) / 60) + if minutes >= 1_440 { return "\(minutes / 1_440)d \((minutes % 1_440) / 60)h" } + if minutes >= 60 { return "\(minutes / 60)h \(minutes % 60)m" } + return "\(minutes)m" + } + public static func tokens(_ value: Double) -> String { + for (scale, suffix) in [(1e12, "T"), (1e9, "B"), (1e6, "M"), (1e3, "K")] where value >= scale { + return (value / scale).formatted(.number.precision(.significantDigits(1...3))) + suffix + } + return value.formatted(.number.precision(.fractionLength(0))) + } + public static func cost(_ value: Double) -> String { value.formatted(.currency(code: "USD")) } +} diff --git a/apps/macos/Sources/T3Core/WorkspaceFiles.swift b/apps/macos/Sources/T3Core/WorkspaceFiles.swift new file mode 100644 index 0000000000000..76b63589cf1c5 --- /dev/null +++ b/apps/macos/Sources/T3Core/WorkspaceFiles.swift @@ -0,0 +1,104 @@ +import Foundation + +public struct WorkspaceEntry: Equatable, Sendable { + public let path: String + public let isDirectory: Bool + public let ignored: Bool + public var name: String { (path as NSString).lastPathComponent } + public var parent: String { path.split(separator: "/").dropLast().joined(separator: "/") } + + public init(_ value: JSON) throws { + path = try value.requiredString("path") + isDirectory = value["kind"].string == "directory" + ignored = value["ignored"].bool + } + + public static func entries(_ result: JSON, directory: String? = nil) throws -> [Self] { + var seen: Set = [] + return try result["entries"].array.map(Self.init) + .filter { (directory == nil || $0.parent == directory) && seen.insert($0.path).inserted } + .sorted { + if $0.isDirectory != $1.isDirectory { return $0.isDirectory } + return $0.path.localizedStandardCompare($1.path) == .orderedAscending + } + } +} + +public struct WorkspaceFileID: Codable, Hashable, Sendable { + public let environment: String + public let root: String + public let path: String + public init(location: WorkspaceLocation, path: String) { + environment = location.environment + root = location.root + self.path = path + } +} + +public enum WorkspaceTreeNavigation { + public static func directories(for path: String, isDirectory: Bool) -> [String] { + guard !path.hasPrefix("/") else { return [] } + let parts = path.split(separator: "/").filter { $0 != "." } + guard !parts.contains("..") else { return [] } + let count = isDirectory ? parts.count : max(0, parts.count - 1) + return [""] + (0.. [WorkspaceFileID: WorkspaceFileDocument] { + guard FileManager.default.fileExists(atPath: file.path) else { return [:] } + return try JSONDecoder().decode( + [WorkspaceFileID: WorkspaceFileDocument].self, from: Data(contentsOf: file)) + } + public func save(_ documents: [WorkspaceFileID: WorkspaceFileDocument]) throws { + let directory = file.deletingLastPathComponent() + try FileManager.default.createDirectory( + at: directory, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700]) + let dirty = documents.filter { $0.value.requiresRecovery && $0.value.canEdit } + try JSONEncoder().encode(dirty).write(to: file, options: [.atomic]) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: file.path) + } +} diff --git a/apps/macos/Sources/T3Core/WorkspaceMedia.swift b/apps/macos/Sources/T3Core/WorkspaceMedia.swift new file mode 100644 index 0000000000000..0a07402ddc1cc --- /dev/null +++ b/apps/macos/Sources/T3Core/WorkspaceMedia.swift @@ -0,0 +1,123 @@ +import Foundation + +public enum WorkspaceMediaKind: String, Sendable { + case image, audio, video, pdf, html + public init?(path: String) { + switch (path as NSString).pathExtension.lowercased() { + case "avif", "gif", "ico", "jpeg", "jpg", "png", "svg", "webp": self = .image + case "mp3", "wav", "ogg", "oga", "flac", "aac", "m4a", "opus", "aiff": self = .audio + case "avi", "m4v", "mkv", "mov", "mp4", "ogv", "webm": self = .video + case "pdf": self = .pdf + case "htm", "html": self = .html + default: return nil + } + } + public var playable: Bool { self == .audio || self == .video } +} + +public struct WorkspaceMediaRequest: Sendable { + public struct Key: Equatable, Sendable { + public let file: WorkspaceFileID + public let threadID: String + public let connection: UUID + public let revision: UInt64 + public init(file: WorkspaceFileID, threadID: String, connection: UUID, revision: UInt64) { + self.file = file + self.threadID = threadID + self.connection = connection + self.revision = revision + } + } + public let key: Key + public let kind: WorkspaceMediaKind + public let load: @Sendable () async throws -> URL + public var name: String { (key.file.path as NSString).lastPathComponent } + + public init(key: Key, kind: WorkspaceMediaKind, load: @escaping @Sendable () async throws -> URL) + { + self.key = key + self.kind = kind + self.load = load + } + public func save(to destination: URL) async throws { + try await AttachmentHTTP.download(load(), to: destination, maximumBytes: nil) + } + + public static func resource(path: String, at location: WorkspaceLocation) -> JSON { + let absolute = FileOpenCapabilities.absolutePath(path, root: location.root) + let root = (location.root as NSString).standardizingPath + let inside = absolute == root || absolute.hasPrefix(root == "/" ? "/" : root + "/") + let singleFile = !inside || WorkspaceMediaKind(path: absolute)?.playable == true + return .object([ + "_tag": .string(singleFile ? "media-file" : "workspace-file"), + "threadId": .string(location.threadID), "path": .string(absolute), + ]) + } + + public static func freshURL(_ issued: JSON, endpoint: URL, revision: UInt64) throws -> URL { + let signed = try AttachmentHTTP.signedURL( + issued.requiredString("relativeUrl"), endpoint: endpoint, prefix: "/api/assets/") + guard var parts = URLComponents(url: signed, resolvingAgainstBaseURL: false) else { + throw T3Error("The preview address is invalid.") + } + var query = parts.queryItems ?? [] + query.append(URLQueryItem(name: "workspace-revision", value: String(revision))) + parts.queryItems = query + guard let url = parts.url else { throw T3Error("The preview address is invalid.") } + return url + } +} + +public enum WorkspaceMediaDocument { + public static func html(kind: WorkspaceMediaKind, url: URL, title: String, nonce: String) + -> String + { + let source = escape(url.absoluteString) + let title = escape(title) + let body: String + if kind == .image { + body = "\"\(title)\"" + } else { + body = + "" + } + return """ + + + + + \(body) + """ + } + + private static func escape(_ value: String) -> String { + value.replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "\"", with: """) + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + } +} + +public struct MediaPlaybackRecovery: Sendable { + public private(set) var position: Double = 0 + public private(set) var rate: Float = 0 + public private(set) var attemptedRefresh = false + public init() {} + public mutating func capture(position: Double, rate: Float) { + if position.isFinite && position > self.position + 1 { attemptedRefresh = false } + if position.isFinite { self.position = max(0, position) } + self.rate = rate.isFinite ? max(0, rate) : 0 + } + public mutating func refreshAfterFailure() -> Bool { + guard !attemptedRefresh else { return false } + attemptedRefresh = true + return true + } + public mutating func retry() { attemptedRefresh = false } +} diff --git a/apps/macos/Sources/T3Markdown/MarkdownCodeLayout.swift b/apps/macos/Sources/T3Markdown/MarkdownCodeLayout.swift new file mode 100644 index 0000000000000..6c3a208151d7d --- /dev/null +++ b/apps/macos/Sources/T3Markdown/MarkdownCodeLayout.swift @@ -0,0 +1,49 @@ +import AppKit + +extension MarkdownRenderer { + /// Changes only TextKit styles/geometry. Content, highlights, links, and source offsets stay intact. + public static func settingCodeWrapping( + _ wraps: Bool, at index: Int, in rendered: RenderedMarkdown, width: CGFloat + ) -> RenderedMarkdown { + guard rendered.codeBlocks.indices.contains(index), + let range = rendered.codeBlocks[index].surfaceRange + else { return rendered } + let original = rendered.codeBlocks[index] + let text = NSMutableAttributedString(attributedString: rendered.text) + let font = + text.attribute(.font, at: original.range.location, effectiveRange: nil) as? NSFont + ?? rendered.configuration.codeFont() + let naturalWidth = + original.code.split(separator: "\n", omittingEmptySubsequences: false) + .reduce(CGFloat(0)) { + max($0, (String($1) as NSString).size(withAttributes: [.font: font]).width) + } + 28 + let surfaceWidth = wraps ? max(120, width) : max(width, naturalWidth) + let table = NSTextTable() + table.numberOfColumns = 1 + table.layoutAlgorithm = .fixedLayoutAlgorithm + table.setValue(surfaceWidth, type: .absoluteValueType, for: .width) + let cell = NSTextTableBlock( + table: table, startingRow: 0, rowSpan: 1, startingColumn: 0, columnSpan: 1) + cell.setWidth(14, type: .absoluteValueType, for: .padding) + rendered.text.enumerateAttribute(.paragraphStyle, in: range) { value, subrange, _ in + guard let style = (value as? NSParagraphStyle)?.mutableCopy() as? NSMutableParagraphStyle + else { return } + style.textBlocks = [cell] + style.lineBreakMode = wraps ? .byCharWrapping : .byClipping + text.addAttribute(.paragraphStyle, value: style, range: subrange) + } + text.addAttribute(.t3BlockLayoutWidth, value: surfaceWidth, range: range) + var blocks = rendered.codeBlocks + blocks[index] = RenderedCodeBlock( + range: original.range, code: original.code, language: original.language, + surfaceRange: range, headerRange: original.headerRange, contentWidth: surfaceWidth, + wraps: wraps) + let contentWidth = max( + width, blocks.map(\.contentWidth).max() ?? width, + rendered.tables.map(\.contentWidth).max() ?? width) + return RenderedMarkdown( + text: text, codeBlocks: blocks, tasks: rendered.tasks, tables: rendered.tables, + contentWidth: contentWidth, configuration: rendered.configuration) + } +} diff --git a/apps/macos/Sources/T3Markdown/MarkdownRenderer.swift b/apps/macos/Sources/T3Markdown/MarkdownRenderer.swift new file mode 100644 index 0000000000000..4a417df5e33b1 --- /dev/null +++ b/apps/macos/Sources/T3Markdown/MarkdownRenderer.swift @@ -0,0 +1,508 @@ +import AppKit +import Markdown + +public struct RenderedCodeBlock { + public let range: NSRange + public let code: String + public let language: String? + public let surfaceRange: NSRange? + public let headerRange: NSRange? + public let contentWidth: CGFloat + public let wraps: Bool + public init( + range: NSRange, code: String, language: String?, surfaceRange: NSRange? = nil, + headerRange: NSRange? = nil, contentWidth: CGFloat = 0, wraps: Bool = true + ) { + self.range = range + self.code = code + self.language = language + self.surfaceRange = surfaceRange + self.headerRange = headerRange + self.contentWidth = contentWidth + self.wraps = wraps + } +} + +public struct RenderedMarkdownTable { + public let range: NSRange + public let columns: Int + public let rows: Int + public let contentWidth: CGFloat +} + +public struct RenderedMarkdownTask { + public let range: NSRange + public let sourceOffset: Int + public let checked: Bool +} + +public struct RenderedMarkdown { + public let text: NSAttributedString + public let codeBlocks: [RenderedCodeBlock] + public let tasks: [RenderedMarkdownTask] + public let tables: [RenderedMarkdownTable] + public let contentWidth: CGFloat + public let configuration: MarkdownConfiguration + public init( + text: NSAttributedString, codeBlocks: [RenderedCodeBlock], tasks: [RenderedMarkdownTask] = [], + tables: [RenderedMarkdownTable] = [], contentWidth: CGFloat = 0, + configuration: MarkdownConfiguration = MarkdownConfiguration() + ) { + self.text = text + self.codeBlocks = codeBlocks + self.tasks = tasks + self.tables = tables + self.contentWidth = contentWidth + self.configuration = configuration + } +} + +@MainActor public enum MarkdownRenderer { + public static func render( + _ source: String, width: CGFloat, headingLevelOffset: Int = 0, + taskLinks: Bool = false, configuration: MarkdownConfiguration = MarkdownConfiguration(), + resolveLink: ((String) -> URL?)? = nil + ) -> RenderedMarkdown { + let renderer = Renderer( + source: source, width: width, headingLevelOffset: headingLevelOffset, + taskLinks: taskLinks, configuration: configuration, resolveLink: resolveLink) + for block in Document(parsing: source).children { renderer.block(block) } + return RenderedMarkdown( + text: renderer.output, codeBlocks: renderer.codeBlocks, tasks: renderer.tasks, + tables: renderer.tables, contentWidth: renderer.contentWidth, configuration: configuration) + } + + public static func height(of text: NSAttributedString, width: CGFloat) -> CGFloat { + let storage = NSTextStorage(attributedString: text) + let layout = MarkdownLayoutManager() + let container = MarkdownTextContainer(text: text, width: width) + container.lineFragmentPadding = 0 + storage.addLayoutManager(layout) + layout.addTextContainer(container) + layout.ensureLayout(for: container) + return ceil(layout.usedRect(for: container).height) + } +} + +@MainActor private final class Renderer { + struct Style { + var font = NSFont.systemFont(ofSize: 14) + var color = NSColor.labelColor + var indent: CGFloat = 0 + var quote = false + var tableBlock: NSTextTableBlock? + var alignment = NSTextAlignment.left + var code = false + var header = false + } + let width: CGFloat + let headingLevelOffset: Int + let output = NSMutableAttributedString(string: "") + var codeBlocks: [RenderedCodeBlock] = [] + var tasks: [RenderedMarkdownTask] = [] + var tables: [RenderedMarkdownTable] = [] + var contentWidth: CGFloat + private let configuration: MarkdownConfiguration + private let source: NSString + private var lineStarts: [Int] = [] + private let taskLinks: Bool + private let resolveLink: ((String) -> URL?)? + init( + source: String, width: CGFloat, headingLevelOffset: Int, taskLinks: Bool, + configuration: MarkdownConfiguration, resolveLink: ((String) -> URL?)? + ) { + self.source = source as NSString + self.taskLinks = taskLinks + self.configuration = configuration + self.contentWidth = max(120, width) + self.resolveLink = resolveLink + lineStarts = [0] + var previous: UInt16 = 0 + for (index, unit) in source.utf16.enumerated() { + if unit == 10 && previous == 13 { + lineStarts[lineStarts.count - 1] = index + 1 + } else if unit == 10 || unit == 13 { + lineStarts.append(index + 1) + } + previous = unit + } + self.width = max(120, width) + self.headingLevelOffset = min(5, max(0, headingLevelOffset)) + } + + func block(_ node: any Markup, style suppliedStyle: Style? = nil, prefix: String = "") { + var style = suppliedStyle ?? Style() + if suppliedStyle == nil { style.font = configuration.bodyFont() } + switch node { + case let heading as Heading: + var headingStyle = style + headingStyle.font = configuration.bodyFont( + size: [22.0, 19, 17, 15, 14, 14][min(5, max(0, heading.level + headingLevelOffset - 1))] + * configuration.fontSize / 14, + weight: .semibold) + paragraph( + inline(heading, style: headingStyle), style: headingStyle, prefix: prefix, + before: configuration.polished ? 18 : 10) + case let paragraph as Paragraph: + self.paragraph(inline(paragraph, style: style), style: style, prefix: prefix) + case let code as CodeBlock: + renderCode(code, style: style, prefix: prefix) + case let quote as BlockQuote: + var quoted = style + quoted.indent = min(width - 60, quoted.indent + 18) + quoted.quote = true + quoted.color = .secondaryLabelColor + for child in quote.children { block(child, style: quoted) } + case let ordered as OrderedList: + list(ordered, style: style, start: ordered.startIndex) + case let unordered as UnorderedList: + list(unordered, style: style, start: nil) + case let table as Table: + renderTable(table, style: style) + case is ThematicBreak: + var ruleStyle = style + ruleStyle.color = .separatorColor + paragraph( + NSAttributedString( + string: String(repeating: "─", count: max(4, Int((width - style.indent) / 9))), + attributes: attributes(ruleStyle)), style: ruleStyle) + case let html as HTMLBlock: + paragraph( + NSAttributedString(string: html.rawHTML, attributes: attributes(style)), style: style, + prefix: prefix) + default: + for child in node.children { block(child, style: style, prefix: prefix) } + } + } + + private func list(_ node: any Markup, style: Style, start: UInt?) { + for (index, child) in node.children.enumerated() { + guard let item = child as? ListItem else { continue } + let sourceTask = item.checkbox != nil || start != nil ? taskMarker(item) : nil + let checkbox = item.checkbox ?? (start != nil ? sourceTask?.checkbox : nil) + let marker: String + switch checkbox { + case .checked: marker = "☑" + case .unchecked: marker = "☐" + case nil: marker = start.map { "\($0 + UInt(index))." } ?? "•" + } + var itemStyle = style + itemStyle.indent = min(width - 60, itemStyle.indent + 22) + let start = output.length + for (blockIndex, child) in item.children.enumerated() { + if blockIndex == 0, item.checkbox == nil, checkbox != nil, + let paragraph = child as? Paragraph + { + // swift-markdown recognizes unordered tasks; GFM also permits ordered tasks. + let text = NSMutableAttributedString( + attributedString: inline(paragraph, style: itemStyle)) + let prefix = (text.string as NSString).range( + of: #"^\[[ xX]\](?:[ \t]+|$)"#, options: .regularExpression) + if prefix.location == 0 { text.deleteCharacters(in: prefix) } + self.paragraph(text, style: itemStyle, prefix: marker + "\t") + } else { + block(child, style: itemStyle, prefix: blockIndex == 0 ? marker + "\t" : "") + } + } + if taskLinks, let checkbox, let offset = sourceTask?.offset, + output.length > start + { + let range = NSRange(location: start, length: 1) + tasks.append( + RenderedMarkdownTask(range: range, sourceOffset: offset, checked: checkbox == .checked)) + output.addAttribute( + .link, value: URL(string: "t3-markdown-task://toggle/\(offset)")!, range: range) + } + } + } + + private static let taskPattern = try! NSRegularExpression( + pattern: #"^(?:[-+*]|[0-9]{1,9}[.)])[ \t]+(\[[ xX]\])(?:[ \t]+|$)"#) + + private func taskMarker(_ item: ListItem) -> (offset: Int, checkbox: Checkbox)? { + guard let location = item.range?.lowerBound, location.line > 0, + location.line <= lineStarts.count + else { return nil } + let start = lineStarts[location.line - 1] + let end = location.line < lineStarts.count ? lineStarts[location.line] : source.length + let line = source.substring(with: NSRange(location: start, length: end - start)) + let bytes = Array(line.utf8) + let column = max(0, location.column - 1) + guard column < bytes.count else { return nil } + let prefix = String(decoding: bytes.prefix(column), as: UTF8.self).utf16.count + let remainder = String(decoding: bytes.dropFirst(column), as: UTF8.self) as NSString + guard + let match = Self.taskPattern.firstMatch( + in: remainder as String, range: NSRange(location: 0, length: remainder.length)) + else { return nil } + let offset = start + prefix + match.range(at: 1).location + return (offset, source.character(at: offset + 1) == 32 ? .unchecked : .checked) + } + + private func paragraph( + _ text: NSAttributedString, style: Style, prefix: String = "", before: CGFloat = 0, + after: CGFloat? = nil + ) { + let start = output.length + if !prefix.isEmpty { + output.append(NSAttributedString(string: prefix, attributes: attributes(style))) + } + output.append(text) + if !output.string.hasSuffix("\n") || output.length == start { + output.append(NSAttributedString(string: "\n", attributes: attributes(style))) + } + let paragraph = NSMutableParagraphStyle() + paragraph.lineSpacing = style.code ? 3 : configuration.lineSpacing + paragraph.paragraphSpacing = after ?? configuration.paragraphSpacing + paragraph.paragraphSpacingBefore = before + paragraph.headIndent = style.indent + paragraph.firstLineHeadIndent = prefix.isEmpty ? style.indent : max(0, style.indent - 22) + paragraph.tabStops = [NSTextTab(textAlignment: .left, location: style.indent)] + paragraph.defaultTabInterval = 24 + paragraph.alignment = style.alignment + if style.code && configuration.polished { + paragraph.lineBreakMode = configuration.wrapCode ? .byCharWrapping : .byClipping + paragraph.defaultTabInterval = configuration.codeFontSize * 2.4 + } + if style.header { + paragraph.minimumLineHeight = 27 + paragraph.maximumLineHeight = 27 + } + if let tableBlock = style.tableBlock { paragraph.textBlocks = [tableBlock] } + let range = NSRange(location: start, length: output.length - start) + output.addAttribute(.paragraphStyle, value: paragraph, range: range) + if style.quote { + output.addAttribute( + .backgroundColor, value: NSColor.quaternaryLabelColor.withAlphaComponent(0.04), range: range + ) + } + } + + private func attributes(_ style: Style) -> [NSAttributedString.Key: Any] { + [.font: style.font, .foregroundColor: style.color] + } + + private func inline(_ node: any Markup, style: Style) -> NSAttributedString { + let result = NSMutableAttributedString(string: "") + for child in node.children { + switch child { + case let text as Text: + result.append(NSAttributedString(string: text.string, attributes: attributes(style))) + case is SoftBreak: + result.append( + NSAttributedString( + string: configuration.polished ? " " : "\n", attributes: attributes(style))) + case is LineBreak: + result.append(NSAttributedString(string: "\n", attributes: attributes(style))) + case let code as InlineCode: + var attrs = attributes(style) + attrs[.font] = configuration.codeFont(size: max(11, style.font.pointSize - 1)) + if configuration.polished { + attrs[.t3InlineCode] = true + } else { + attrs[.backgroundColor] = NSColor.quaternaryLabelColor.withAlphaComponent(0.12) + } + result.append(NSAttributedString(string: code.code, attributes: attrs)) + case is Strong: + var strong = style + strong.font = NSFontManager.shared.convert(style.font, toHaveTrait: .boldFontMask) + result.append(inline(child, style: strong)) + case is Emphasis: + var emphasis = style + emphasis.font = NSFontManager.shared.convert(style.font, toHaveTrait: .italicFontMask) + result.append(inline(child, style: emphasis)) + case is Strikethrough: + let text = NSMutableAttributedString(attributedString: inline(child, style: style)) + text.addAttribute( + .strikethroughStyle, value: NSUnderlineStyle.single.rawValue, + range: NSRange(location: 0, length: text.length)) + result.append(text) + case let link as Link: + let text = NSMutableAttributedString(attributedString: inline(link, style: style)) + if let destination = link.destination, let url = safeURL(destination) { + text.addAttributes( + [.link: url, .underlineStyle: NSUnderlineStyle.single.rawValue], + range: NSRange(location: 0, length: text.length)) + } + result.append(text) + case let image as Markdown.Image: + let label = image.plainText.isEmpty ? "Image" : image.plainText + var attrs = attributes(style) + if let source = image.source, let url = safeURL(source) { + attrs[.link] = url + attrs[.underlineStyle] = NSUnderlineStyle.single.rawValue + } + result.append(NSAttributedString(string: "[" + label + "]", attributes: attrs)) + case let html as InlineHTML: + result.append(NSAttributedString(string: html.rawHTML, attributes: attributes(style))) + default: result.append(inline(child, style: style)) + } + } + return result + } + + private func safeURL(_ destination: String) -> URL? { + if let resolveLink { return resolveLink(destination) } + guard let url = URL(string: destination), let scheme = url.scheme?.lowercased(), + ["http", "https", "mailto", "file"].contains(scheme) + else { return nil } + return url + } + + private func renderCode(_ code: CodeBlock, style: Style, prefix: String) { + var codeStyle = style + codeStyle.font = configuration.codeFont() + codeStyle.indent = min(width - 60, style.indent + 10) + codeStyle.code = true + let surfaceStart = output.length + var headerRange: NSRange? + var surfaceWidth = width - style.indent + if configuration.polished { + codeStyle.color = MarkdownPalette.codeText + codeStyle.indent = 0 + if !configuration.wrapCode { + surfaceWidth = max(surfaceWidth, longestLine(code.code, font: codeStyle.font) + 28) + } + let table = NSTextTable() + table.numberOfColumns = 1 + table.layoutAlgorithm = .fixedLayoutAlgorithm + table.setValue(surfaceWidth, type: .absoluteValueType, for: .width) + let cell = NSTextTableBlock( + table: table, startingRow: 0, rowSpan: 1, startingColumn: 0, columnSpan: 1) + cell.setWidth(14, type: .absoluteValueType, for: .padding) + codeStyle.tableBlock = cell + var headerStyle = codeStyle + headerStyle.font = .systemFont(ofSize: 11, weight: .medium) + headerStyle.header = true + // Reserve header space without adding button titles to selected/copied code. + paragraph(NSAttributedString(string: ""), style: headerStyle, after: 0) + headerRange = NSRange(location: surfaceStart, length: output.length - surfaceStart) + } else if let language = code.language, !language.isEmpty { + var labelStyle = style + labelStyle.font = .systemFont(ofSize: 10, weight: .medium) + labelStyle.color = .secondaryLabelColor + paragraph( + NSAttributedString(string: language, attributes: attributes(labelStyle)), + style: labelStyle, prefix: prefix, after: 2) + } + let start = output.length + let text = NSMutableAttributedString(string: code.code, attributes: attributes(codeStyle)) + if configuration.polished { + MarkdownSyntaxHighlighter.apply(to: text, language: code.language) + } else { + text.addAttribute( + .backgroundColor, + value: NSColor.quaternaryLabelColor.withAlphaComponent(0.08), + range: NSRange(location: 0, length: text.length)) + } + paragraph( + text, style: codeStyle, + prefix: configuration.polished || code.language != nil ? "" : prefix, + after: configuration.polished ? 0 : nil) + if configuration.polished { + output.addAttribute( + .t3BlockLayoutWidth, value: surfaceWidth, + range: NSRange(location: surfaceStart, length: output.length - surfaceStart)) + } + codeBlocks.append( + RenderedCodeBlock( + range: NSRange(location: start, length: output.length - start), code: code.code, + language: code.language, + surfaceRange: configuration.polished + ? NSRange(location: surfaceStart, length: output.length - surfaceStart) : nil, + headerRange: headerRange, contentWidth: surfaceWidth, wraps: configuration.wrapCode)) + contentWidth = max(contentWidth, surfaceWidth + style.indent) + if configuration.polished { + paragraph(NSAttributedString(string: ""), style: style, after: 4) + } + } + + private func longestLine(_ text: String, font: NSFont) -> CGFloat { + text.split(separator: "\n", omittingEmptySubsequences: false) + .reduce(CGFloat(0)) { + max($0, (String($1) as NSString).size(withAttributes: [.font: font]).width) + } + } + + private func renderTable(_ source: Table, style: Style) { + let start = output.length + let columns = max(1, source.maxColumnCount) + let rows = [Array(source.head.children)] + source.body.children.map { Array($0.children) } + // Build each cell once; measure the same attributed content that TextKit will display. + let cells = rows.enumerated().map { rowIndex, row in + row.map { cell -> NSAttributedString in + var cellStyle = style + if rowIndex == 0 { + cellStyle.font = configuration.bodyFont( + size: configuration.polished ? configuration.fontSize : configuration.fontSize - 1, + weight: .semibold) + } + return inline(cell, style: cellStyle) + } + } + let table = NSTextTable() + table.numberOfColumns = columns + table.layoutAlgorithm = .fixedLayoutAlgorithm + table.collapsesBorders = true + let availableWidth = max(60, width - style.indent) + var columnWidths = Array(repeating: availableWidth / CGFloat(columns), count: columns) + if !configuration.wrapTables { + for row in cells { + for (column, cell) in row.enumerated() { + columnWidths[column] = max(columnWidths[column], cell.size().width + 24) + } + } + } + let tableWidth = columnWidths.reduce(0, +) + table.setValue(tableWidth, type: .absoluteValueType, for: .width) + if !configuration.wrapTables { + table.setValue(tableWidth, type: .absoluteValueType, for: .minimumWidth) + } + contentWidth = max(contentWidth, tableWidth + style.indent) + for (rowIndex, row) in cells.enumerated() { + for (column, cell) in row.enumerated() { + let block = NSTextTableBlock( + table: table, startingRow: rowIndex, rowSpan: 1, startingColumn: column, columnSpan: 1) + block.setValue(columnWidths[column], type: .absoluteValueType, for: .width) + if !configuration.wrapTables { + block.setValue(columnWidths[column] - 20, type: .absoluteValueType, for: .minimumWidth) + } + block.setWidth(configuration.polished ? 10 : 7, type: .absoluteValueType, for: .padding) + if configuration.polished { + block.setWidth(0, type: .absoluteValueType, for: .border) + block.setWidth( + rowIndex == 0 ? 1 : 0.5, type: .absoluteValueType, for: .border, edge: .maxY) + } else { + block.setWidth(0.5, type: .absoluteValueType, for: .border) + } + block.setBorderColor(.separatorColor) + block.backgroundColor = + rowIndex == 0 + ? NSColor.quaternaryLabelColor.withAlphaComponent(configuration.polished ? 0.05 : 0.08) + : .clear + var cellStyle = style + cellStyle.indent = 0 + cellStyle.tableBlock = block + if rowIndex == 0 { + cellStyle.font = configuration.bodyFont( + size: configuration.polished ? configuration.fontSize : configuration.fontSize - 1, + weight: .semibold) + } + switch source.columnAlignments[column] { + case .center: cellStyle.alignment = .center + case .right: cellStyle.alignment = .right + default: cellStyle.alignment = .left + } + paragraph(cell, style: cellStyle, after: 0) + } + } + output.addAttribute( + .t3BlockLayoutWidth, value: tableWidth, + range: NSRange(location: start, length: output.length - start)) + tables.append( + RenderedMarkdownTable( + range: NSRange(location: start, length: output.length - start), columns: columns, + rows: rows.count, contentWidth: tableWidth)) + paragraph(NSAttributedString(string: ""), style: style, after: 2) + } +} diff --git a/apps/macos/Sources/T3Markdown/MarkdownStyle.swift b/apps/macos/Sources/T3Markdown/MarkdownStyle.swift new file mode 100644 index 0000000000000..351977e6fd825 --- /dev/null +++ b/apps/macos/Sources/T3Markdown/MarkdownStyle.swift @@ -0,0 +1,128 @@ +import AppKit + +/// Render once per content/width/preference change, then reuse the attributed result. +public struct MarkdownConfiguration: Equatable, Sendable { + public var fontName: String + public var codeFontName: String + public var fontSize: CGFloat + public var codeFontSize: CGFloat + public var lineSpacing: CGFloat + public var paragraphSpacing: CGFloat + public var wrapCode: Bool + public var wrapTables: Bool + public var polished: Bool + + public init( + fontSize: CGFloat = 14, codeFontSize: CGFloat = 12.5, lineSpacing: CGFloat = 3, + paragraphSpacing: CGFloat = 8, wrapCode: Bool = true, wrapTables: Bool = true, + polished: Bool = false, fontName: String = "", codeFontName: String = "" + ) { + self.fontName = fontName + self.codeFontName = codeFontName + self.fontSize = fontSize + self.codeFontSize = codeFontSize + self.lineSpacing = lineSpacing + self.paragraphSpacing = paragraphSpacing + self.wrapCode = wrapCode + self.wrapTables = wrapTables + self.polished = polished + } + + @MainActor func bodyFont(size: CGFloat? = nil, weight: NSFont.Weight = .regular) -> NSFont { + let size = size ?? fontSize + guard !fontName.isEmpty, let font = NSFont(name: fontName, size: size) else { + return .systemFont(ofSize: size, weight: weight) + } + return weight == .regular + ? font : NSFontManager.shared.convert(font, toHaveTrait: .boldFontMask) + } + + @MainActor func codeFont(size: CGFloat? = nil) -> NSFont { + let size = size ?? codeFontSize + return codeFontName.isEmpty + ? .monospacedSystemFont(ofSize: size, weight: .regular) + : NSFont(name: codeFontName, size: size) + ?? .monospacedSystemFont(ofSize: size, weight: .regular) + } + + public static let message = MarkdownConfiguration( + fontSize: 15, codeFontSize: 13, lineSpacing: 5, paragraphSpacing: 14, polished: true) +} + +extension NSAttributedString.Key { + public static let t3InlineCode = NSAttributedString.Key("T3InlineCode") + public static let t3BlockLayoutWidth = NSAttributedString.Key("T3BlockLayoutWidth") + public static let t3SyntaxToken = NSAttributedString.Key("T3SyntaxToken") +} + +@MainActor public enum MarkdownPalette { + public static let codeBackground = NSColor(srgbRed: 0.075, green: 0.085, blue: 0.105, alpha: 1) + public static let codeBorder = NSColor(srgbRed: 0.22, green: 0.24, blue: 0.28, alpha: 1) + public static let codeText = NSColor(srgbRed: 0.86, green: 0.88, blue: 0.92, alpha: 1) + public static let codeSecondary = NSColor(srgbRed: 0.57, green: 0.62, blue: 0.70, alpha: 1) +} + +/// Inline code stays real text, including inside native table cells. +@MainActor public final class MarkdownLayoutManager: NSLayoutManager { + public override func drawBackground(forGlyphRange glyphsToShow: NSRange, at origin: NSPoint) { + guard let storage = textStorage, let container = textContainers.first else { return } + let capsuleColor = NSColor.quaternaryLabelColor.withAlphaComponent(0.13) + let visible = characterRange(forGlyphRange: glyphsToShow, actualGlyphRange: nil) + storage.enumerateAttribute(.t3InlineCode, in: visible) { value, range, _ in + guard value != nil else { return } + let glyphs = self.glyphRange(forCharacterRange: range, actualCharacterRange: nil) + self.enumerateEnclosingRects( + forGlyphRange: glyphs, withinSelectedGlyphRange: NSRange(location: NSNotFound, length: 0), + in: container + ) { rect, _ in + let capsule = rect.offsetBy(dx: origin.x, dy: origin.y).insetBy(dx: -3, dy: 0) + capsuleColor.setFill() + NSBezierPath(roundedRect: capsule, xRadius: 4, yRadius: 4).fill() + } + } + super.drawBackground(forGlyphRange: glyphsToShow, at: origin) + } +} + +/// TextKit tables need an expanded container to overflow; prose still gets the viewport width. +public final class MarkdownTextContainer: NSTextContainer { + private let viewportWidth: CGFloat + + public init(text: NSAttributedString, width: CGFloat) { + viewportWidth = width + super.init(size: NSSize(width: width, height: CGFloat.greatestFiniteMagnitude)) + lineFragmentPadding = 0 + widthTracksTextView = false + updateLayoutWidth(for: text) + } + public required init(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + public func updateLayoutWidth(for text: NSAttributedString) { + var widest = viewportWidth + text.enumerateAttribute(.t3BlockLayoutWidth, in: NSRange(location: 0, length: text.length)) { + value, _, _ in + if let width = value as? CGFloat { widest = max(widest, width) } + } + size = NSSize(width: widest, height: CGFloat.greatestFiniteMagnitude) + } + + public override var isSimpleRectangularTextContainer: Bool { false } + + public override func lineFragmentRect( + forProposedRect proposedRect: NSRect, at characterIndex: Int, + writingDirection baseWritingDirection: NSWritingDirection, + remaining remainingRect: UnsafeMutablePointer? + ) -> NSRect { + var rect = super.lineFragmentRect( + forProposedRect: proposedRect, at: characterIndex, + writingDirection: baseWritingDirection, remaining: remainingRect) + guard let storage = layoutManager?.textStorage, characterIndex < storage.length else { + return rect + } + let allowed = + storage.attribute(.t3BlockLayoutWidth, at: characterIndex, effectiveRange: nil) as? CGFloat + ?? viewportWidth + rect.size.width = max(0, min(rect.width, allowed - rect.minX)) + return rect + } +} diff --git a/apps/macos/Sources/T3Markdown/MarkdownSyntaxHighlighter.swift b/apps/macos/Sources/T3Markdown/MarkdownSyntaxHighlighter.swift new file mode 100644 index 0000000000000..00f0a7a6b9364 --- /dev/null +++ b/apps/macos/Sources/T3Markdown/MarkdownSyntaxHighlighter.swift @@ -0,0 +1,60 @@ +import AppKit + +@MainActor enum MarkdownSyntaxHighlighter { + // One lexical pass prevents keywords inside strings/comments being recolored. + private static let tokens = try! NSRegularExpression( + pattern: + #"(?/\*[\s\S]*?(?:\*/|$)|//[^\n]*|#[^\n]*)|(?"(?:\\.|[^"\\])*"?|'(?:\\.|[^'\\])*'?|`(?:\\.|[^`\\])*`?)|(?\b(?:0[xX][\da-fA-F]+|\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)\b)|(?[A-Za-z_$][\w$]*)"# + ) + private static let keywords = Set( + ("actor as async await break case catch class const continue def defer do else enum export extends " + + "false final for from func function guard if import in init interface is let match mut new nil " + + "null of override package private protocol public raise return self static struct super switch " + + "throw throws true try type typeof undefined var void when where while yield fn impl pub use " + + "select insert update delete into values join on create table and or not set with").split( + separator: " " + ).map(String.init)) + private static let supported = Set([ + "swift", "js", "javascript", "jsx", "ts", "typescript", "tsx", "json", "jsonc", "python", "py", + "bash", "sh", "shell", "zsh", "ruby", "rb", "rust", "rs", "go", "golang", "c", "cpp", "c++", + "java", "kotlin", "kt", "sql", "yaml", "yml", "css", "scss", "html", "xml", + ]) + + static func apply(to text: NSMutableAttributedString, language: String?) { + guard let language = language?.lowercased().split(separator: " ").first.map(String.init), + supported.contains(language), text.length > 0, text.length <= 150_000 + else { return } + let hashComments = ["python", "py", "bash", "sh", "shell", "zsh", "ruby", "rb", "yaml", "yml"] + .contains(language) + let source = text.string as NSString + tokens.enumerateMatches(in: text.string, range: NSRange(location: 0, length: text.length)) { + match, _, _ in + guard let match else { return } + let token: String + let color: NSColor + if match.range(withName: "comment").location != NSNotFound { + if source.substring(with: match.range).hasPrefix("#"), !hashComments { return } + token = "comment" + color = MarkdownPalette.codeSecondary + } else if match.range(withName: "string").location != NSNotFound { + token = "string" + color = NSColor(srgbRed: 0.63, green: 0.82, blue: 0.60, alpha: 1) + } else if match.range(withName: "number").location != NSNotFound { + token = "number" + color = NSColor(srgbRed: 0.91, green: 0.71, blue: 0.48, alpha: 1) + } else { + let word = source.substring(with: match.range) + if keywords.contains(word) || (language == "sql" && keywords.contains(word.lowercased())) { + token = "keyword" + color = NSColor(srgbRed: 0.77, green: 0.65, blue: 0.93, alpha: 1) + } else if word.first?.isUppercase == true { + token = "type" + color = NSColor(srgbRed: 0.47, green: 0.79, blue: 0.85, alpha: 1) + } else { + return + } + } + text.addAttributes([.foregroundColor: color, .t3SyntaxToken: token], range: match.range) + } + } +} diff --git a/apps/macos/Sources/T3Native/AttachmentPreviews.swift b/apps/macos/Sources/T3Native/AttachmentPreviews.swift new file mode 100644 index 0000000000000..0fd0a782d2da8 --- /dev/null +++ b/apps/macos/Sources/T3Native/AttachmentPreviews.swift @@ -0,0 +1,272 @@ +import AppKit +import QuickLookUI +import T3Core + +@MainActor struct AttachmentPreviewItem { + let name: String + let request: () throws -> AttachmentPreviewRequest +} + +@MainActor final class AttachmentPreviews { + let cache = AttachmentPreviewCache() + private var controller: AttachmentPreviewWindow? + private var environment = "" + + func updateEnvironment(_ next: String) { + guard next != environment else { return } + controller?.close() + controller = nil + environment = next + } + + func show(_ items: [AttachmentPreviewItem], index: Int, from parent: NSWindow?) { + guard items.indices.contains(index) else { return } + controller?.close() + let next = AttachmentPreviewWindow(items: items, index: index, cache: cache) + controller = next + next.showWindow(nil) + if let parent, let window = next.window { + window.setFrameOrigin( + NSPoint( + x: parent.frame.midX - window.frame.width / 2, + y: parent.frame.midY - window.frame.height / 2)) + } + next.window?.makeKeyAndOrderFront(nil) + } +} + +@MainActor final class AttachmentThumbnail: NSView { + private let button: ActionButton + private let item: AttachmentPreviewItem + private let cache: AttachmentPreviewCache + private var task: Task? + private var loaded = false + + init(item: AttachmentPreviewItem, cache: AttachmentPreviewCache, open: @escaping () -> Void) { + self.item = item + self.cache = cache + button = ActionButton("", action: open) + super.init(frame: .zero) + button.isBordered = false + button.imagePosition = .imageOnly + button.imageScaling = .scaleProportionallyUpOrDown + button.image = NSImage(systemSymbolName: "photo", accessibilityDescription: nil) + button.setAccessibilityLabel("Preview " + item.name) + button.toolTip = item.name + wantsLayer = true + layer?.cornerRadius = 6 + layer?.masksToBounds = true + addSubview(button) + button.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + button.leadingAnchor.constraint(equalTo: leadingAnchor), + button.trailingAnchor.constraint(equalTo: trailingAnchor), + button.topAnchor.constraint(equalTo: topAnchor), + button.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + if window == nil { + task?.cancel() + task = nil + } else if !loaded && task == nil { + do { + let request = try item.request() + task = Task { [weak self, cache] in + do { + let image = try await cache.thumbnail(for: request) + guard !Task.isCancelled, let self else { return } + self.button.image = NSImage(cgImage: image, size: .zero) + self.loaded = true + } catch { + guard !Task.isCancelled else { return } + self?.button.toolTip = "\(request.name) — \(error.localizedDescription)" + } + self?.task = nil + } + } catch { button.toolTip = error.localizedDescription } + } + } +} + +@MainActor private final class PreviewPanel: NSPanel { + var navigate: ((Int) -> Bool)? + override func performKeyEquivalent(with event: NSEvent) -> Bool { + if !event.modifierFlags.contains(.command) && !event.modifierFlags.contains(.option) { + if event.keyCode == 123, navigate?(-1) == true { + return true + } + if event.keyCode == 124, navigate?(1) == true { + return true + } + } + return super.performKeyEquivalent(with: event) + } + override func keyDown(with event: NSEvent) { + if event.keyCode == 53 { + close() + } else if event.keyCode == 123, navigate?(-1) == true { + return + } else if event.keyCode == 124, navigate?(1) == true { + return + } else { + super.keyDown(with: event) + } + } + override func cancelOperation(_ sender: Any?) { close() } +} + +private final class PreviewItem: NSObject, QLPreviewItem { + let file: AttachmentPreviewFile + let name: String + init(file: AttachmentPreviewFile, name: String) { + self.file = file + self.name = name + } + var previewItemURL: URL? { file.url } + var previewItemTitle: String? { name } +} + +@MainActor private final class AttachmentPreviewWindow: NSWindowController, NSWindowDelegate { + private let items: [AttachmentPreviewItem] + private var index: Int + private let cache: AttachmentPreviewCache + private let preview = QLPreviewView(frame: .zero, style: .normal)! + private let status = NSTextField(wrappingLabelWithString: "Loading preview…") + private let caption = NSTextField(labelWithString: "") + private let previous = NSButton(title: "Previous", target: nil, action: nil) + private let next = NSButton(title: "Next", target: nil, action: nil) + private let save = NSButton(title: "Save…", target: nil, action: nil) + private let retry = NSButton(title: "Retry", target: nil, action: nil) + private var task: Task? + private var generation = UUID() + private var current: PreviewItem? + + init(items: [AttachmentPreviewItem], index: Int, cache: AttachmentPreviewCache) { + self.items = items + self.index = index + self.cache = cache + let panel = PreviewPanel( + contentRect: NSRect(x: 0, y: 0, width: 820, height: 650), + styleMask: [.titled, .closable, .resizable], backing: .buffered, defer: false) + panel.minSize = NSSize(width: 500, height: 380) + panel.isReleasedWhenClosed = false + super.init(window: panel) + panel.delegate = self + panel.navigate = { [weak self] direction in + guard let self, self.items.count > 1 else { return false } + self.navigate(direction) + return true + } + let root = SurfaceView() + panel.contentView = root + preview.autostarts = false + preview.shouldCloseWithWindow = true + caption.lineBreakMode = .byTruncatingMiddle + caption.font = .systemFont(ofSize: 12) + status.alignment = .center + previous.target = self + previous.action = #selector(goPrevious) + next.target = self + next.action = #selector(goNext) + save.target = self + save.action = #selector(saveFile) + retry.target = self + retry.action = #selector(load) + let controls = NSStackView(views: [previous, caption, next, save]) + controls.spacing = 10 + caption.setContentHuggingPriority(.defaultLow, for: .horizontal) + let feedback = NSStackView(views: [status, retry]) + feedback.orientation = .vertical + feedback.spacing = 12 + for view in [preview, controls, feedback] { + root.addSubview(view) + view.translatesAutoresizingMaskIntoConstraints = false + } + NSLayoutConstraint.activate([ + controls.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 16), + controls.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -16), + controls.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -12), + preview.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 12), + preview.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -12), + preview.topAnchor.constraint(equalTo: root.topAnchor, constant: 12), + preview.bottomAnchor.constraint(equalTo: controls.topAnchor, constant: -12), + feedback.centerXAnchor.constraint(equalTo: preview.centerXAnchor), + feedback.centerYAnchor.constraint(equalTo: preview.centerYAnchor), + feedback.widthAnchor.constraint(lessThanOrEqualTo: root.widthAnchor, constant: -60), + ]) + load() + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + @objc private func goPrevious() { navigate(-1) } + @objc private func goNext() { navigate(1) } + private func navigate(_ direction: Int) { + index = (index + direction + items.count) % items.count + load() + } + @objc private func load() { + task?.cancel() + generation = UUID() + let generation = generation + let item = items[index] + window?.title = item.name + caption.stringValue = item.name + (items.count > 1 ? " (\(index + 1)/\(items.count))" : "") + caption.toolTip = item.name + previous.isEnabled = items.count > 1 + next.isEnabled = items.count > 1 + preview.previewItem = nil + preview.isHidden = true + current = nil + save.isEnabled = false + status.stringValue = "Loading preview…" + status.isHidden = false + retry.isHidden = true + do { + let request = try item.request() + task = Task { [weak self, cache] in + do { + let file = try await cache.file(for: request) + guard !Task.isCancelled, let self, self.generation == generation else { return } + let current = PreviewItem(file: file, name: item.name) + self.current = current + self.preview.previewItem = current + self.preview.isHidden = false + self.status.isHidden = true + self.save.isEnabled = true + } catch { + guard !Task.isCancelled, let self, self.generation == generation else { return } + self.failed(error) + } + } + } catch { failed(error) } + } + private func failed(_ error: Error) { + status.stringValue = error.localizedDescription + status.isHidden = false + retry.isHidden = false + } + @objc private func saveFile() { + guard let window, let current else { return } + let file = current.file + let panel = NSSavePanel() + panel.nameFieldStringValue = current.name + panel.beginSheetModal(for: window) { [weak self] response in + guard response == .OK, let destination = panel.url else { return } + Task { + do { + try await file.save(to: destination) + } catch { self?.failed(error) } + } + } + } + func windowWillClose(_ notification: Notification) { + task?.cancel() + generation = UUID() + preview.previewItem = nil + current = nil + } +} diff --git a/apps/macos/Sources/T3Native/AttachmentTray.swift b/apps/macos/Sources/T3Native/AttachmentTray.swift new file mode 100644 index 0000000000000..f14e42d114ed4 --- /dev/null +++ b/apps/macos/Sources/T3Native/AttachmentTray.swift @@ -0,0 +1,160 @@ +import AppKit +import T3Core + +@MainActor private final class AttachmentRows: NSStackView { + override var isFlipped: Bool { true } +} + +@MainActor final class AttachmentTray: NSScrollView { + private let store: ClientStore + private let previews: AttachmentPreviews + private let rows = AttachmentRows() + private var fingerprint = "" + private var trayHeight: NSLayoutConstraint! + init(store: ClientStore, previews: AttachmentPreviews) { + self.store = store + self.previews = previews + super.init(frame: .zero) + drawsBackground = false + hasVerticalScroller = true + autohidesScrollers = true + rows.orientation = .vertical + rows.alignment = .leading + rows.spacing = 4 + documentView = rows + rows.translatesAutoresizingMaskIntoConstraints = false + rows.widthAnchor.constraint(equalTo: widthAnchor).isActive = true + trayHeight = heightAnchor.constraint(equalToConstant: 0) + trayHeight.isActive = true + setAccessibilityLabel("Message attachments") + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func refresh() { + let attachments = store.selectedAttachments + let retained = store.queuedEdit?.attachments ?? [] + let importing = store.importingAttachments.contains(store.composerDraftID ?? "") + let sending = store.composerBusy + let key = + store.environmentKey + (store.composerDraftID ?? "") + "\(importing):\(sending):" + + retained.compactMap { $0["id"].string }.joined(separator: "|") + + attachments.map { + "\($0.id):\(String(describing: store.attachmentUploads[$0.id]))" + }.joined(separator: "|") + guard key != fingerprint else { return } + fingerprint = key + for child in rows.arrangedSubviews { + rows.removeArrangedSubview(child) + child.removeFromSuperview() + } + func add(_ view: NSView) { + rows.addArrangedSubview(view) + view.widthAnchor.constraint(equalTo: rows.widthAnchor, constant: -14).isActive = true + } + let environment = store.environmentKey + let retainedItems = retained.map { attachment in + AttachmentPreviewItem(name: attachment["name"].string ?? "Attachment") { [weak store] in + guard let store, store.environmentKey == environment else { + throw T3Error("Reconnect to preview this file.") + } + return try store.attachmentPreviewRequest(attachment) + } + } + for (index, attachment) in retained.enumerated() { + let item = retainedItems[index] + let open = ActionButton(item.name) { [weak self] in + self?.previews.show(retainedItems, index: index, from: self?.window) + } + open.bezelStyle = .inline + open.cell?.lineBreakMode = .byTruncatingMiddle + open.setAccessibilityLabel("Preview queued attachment " + item.name) + open.toolTip = "Already attached to the queued message" + let remove = ActionButton("") { [weak store] in store?.removeQueuedAttachment(attachment) } + remove.image = NSImage(systemSymbolName: "xmark.circle", accessibilityDescription: nil) + remove.isBordered = false + remove.isEnabled = !sending + remove.setAccessibilityLabel("Remove queued attachment " + item.name) + let row = NSStackView(views: [open, NSView(), remove]) + row.spacing = 8 + row.heightAnchor.constraint(equalToConstant: 38).isActive = true + if attachment["type"].string == "image" { + let thumbnail = AttachmentThumbnail(item: item, cache: previews.cache) { [weak self] in + self?.previews.show(retainedItems, index: index, from: self?.window) + } + thumbnail.widthAnchor.constraint(equalToConstant: 36).isActive = true + thumbnail.heightAnchor.constraint(equalToConstant: 36).isActive = true + row.insertArrangedSubview(thumbnail, at: 0) + } + add(row) + } + let repository = store.attachmentRepository + let items = attachments.map { attachment in + AttachmentPreviewItem(name: attachment.name) { + guard let repository else { throw T3Error("The saved attachment is unavailable.") } + return .local(try repository.file(attachment), name: attachment.name) + } + } + for (index, attachment) in attachments.enumerated() { + let label = NSTextField(labelWithString: attachment.name) + label.font = .systemFont(ofSize: 12, weight: .medium) + label.lineBreakMode = .byTruncatingMiddle + label.toolTip = attachment.name + var state = ByteCountFormatter.string( + fromByteCount: Int64(attachment.sizeBytes), countStyle: .file) + var failed = false + switch store.attachmentUploads[attachment.id] { + case .uploading(let percent): state += " · Uploading \(percent)%" + case .ready: state += " · Ready" + case .failed(let reason): + state = reason + failed = true + case nil: state += " · Waiting to upload" + } + let detail = NSTextField(labelWithString: state) + detail.font = .systemFont(ofSize: 10) + detail.textColor = failed ? .systemOrange : .secondaryLabelColor + detail.lineBreakMode = .byTruncatingTail + detail.toolTip = state + let labels = NSStackView(views: [label, detail]) + labels.orientation = .vertical + labels.alignment = .leading + labels.spacing = 2 + label.widthAnchor.constraint(equalTo: labels.widthAnchor).isActive = true + detail.widthAnchor.constraint(equalTo: labels.widthAnchor).isActive = true + let open = { [weak self] in self?.previews.show(items, index: index, from: self?.window) } + let icon: NSView + if attachment.type == "image" { + icon = AttachmentThumbnail(item: items[index], cache: previews.cache) { open() } + } else { + let button = ActionButton("") { open() } + button.isBordered = false + button.image = NSImage(systemSymbolName: "doc", accessibilityDescription: nil) + button.setAccessibilityLabel("Preview " + attachment.name) + button.toolTip = "Preview " + attachment.name + icon = button + } + icon.widthAnchor.constraint(equalToConstant: 36).isActive = true + icon.heightAnchor.constraint(equalToConstant: 36).isActive = true + let row = NSStackView(views: [icon, labels]) + row.spacing = 8 + row.heightAnchor.constraint(equalToConstant: 38).isActive = true + if failed { + let retry = ActionButton("Retry") { [weak store] in store?.retryAttachment(attachment) } + retry.setAccessibilityLabel("Retry upload of " + attachment.name) + retry.isEnabled = !sending + row.addArrangedSubview(retry) + } + let remove = ActionButton("") { [weak store] in store?.removeAttachment(attachment) } + remove.image = NSImage(systemSymbolName: "xmark.circle", accessibilityDescription: nil) + remove.isBordered = false + remove.isEnabled = !sending + remove.setAccessibilityLabel("Remove " + attachment.name) + row.addArrangedSubview(remove) + add(row) + } + if importing { add(NSTextField(labelWithString: "Adding attachments…")) } + isHidden = attachments.isEmpty && retained.isEmpty && !importing + trayHeight.constant = min( + 126, CGFloat((attachments.count + retained.count) * 42 + (importing ? 24 : 0))) + } +} diff --git a/apps/macos/Sources/T3Native/BackendHost.swift b/apps/macos/Sources/T3Native/BackendHost.swift new file mode 100644 index 0000000000000..6b4ce77fa30a6 --- /dev/null +++ b/apps/macos/Sources/T3Native/BackendHost.swift @@ -0,0 +1,149 @@ +import Combine +import Darwin +import Foundation +import T3Core + +@MainActor final class BackendHost: ObservableObject { + static let shared = BackendHost() + @Published private(set) var state = "Stopped" + @Published private(set) var settings: ConnectionSettings? + @Published private(set) var error: String? + @Published private(set) var configuration: BackendConfiguration + private var child: OwnedBackendProcess? + private var generation = UUID() + private var starting = false + private let preferences: UserDefaults + private let runtime: BackendRuntime? + var isRunning: Bool { child?.isRunning ?? false } + + init(runtime: BackendRuntime? = nil, preferences: UserDefaults = .standard) { + self.preferences = preferences + self.runtime = runtime ?? Bundle.main.resourceURL.map(BackendRuntime.bundled(resources:)) + if let data = preferences.data(forKey: "native.backendConfiguration.v1"), + let saved = try? JSONDecoder().decode(BackendConfiguration.self, from: data) + { + configuration = saved + } else { + configuration = BackendConfiguration(home: BackendConfiguration.defaultHome) + } + } + func configure(_ value: BackendConfiguration) throws { + guard !isRunning, !starting else { + throw T3Error("Stop the native server before changing its data directory or port.") + } + try value.validate() + preferences.set(try JSONEncoder().encode(value), forKey: "native.backendConfiguration.v1") + configuration = value + } + /// Parent calls at startup only when the selected environment is local (or on first launch). + func start() async throws -> ConnectionSettings { + if let settings, isRunning { return settings } + guard !starting, !isRunning else { + throw T3Error("The local server is already starting. Wait for it to finish.") + } + guard let runtime else { throw T3Error("The bundled backend runtime is unavailable.") } + try runtime.validate() + try configuration.validate() + try Self.checkPort(configuration.port) + let current = UUID() + generation = current + starting = true + state = "Starting…" + error = nil + defer { starting = false } + let token = ConnectionProof.base64url(ConnectionProof().rawRepresentation) + let child = OwnedBackendProcess() + self.child = child + do { + try FileManager.default.createDirectory( + at: configuration.home, withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700]) + try child.start( + executable: runtime.node, + arguments: [runtime.serverEntry.path, "--bootstrap-fd", "0"], + directory: configuration.home, + environment: BackendConfiguration.childEnvironment( + ProcessInfo.processInfo.environment, + home: configuration.home, bundledNode: runtime.node), + input: configuration.bootstrap(token: token)) + let deadline = Date().addingTimeInterval(60) + while Date() < deadline { + try Task.checkCancellation() + guard generation == current, child.isRunning else { + throw T3Error( + "The local server exited during startup\(child.exitStatus.map { " (exit \($0))" } ?? "")." + ) + } + do { + // Successful exchange of this child's random secret proves ownership, unlike /health. + let session = try await ConnectionHTTP.exchange( + endpoint: configuration.endpoint, credential: token) + guard generation == current, child.isRunning else { throw CancellationError() } + settings = session + state = "Running" + Task { [weak self] in + await child.waitForExit() + guard let self, self.generation == current else { return } + self.settings = nil + self.child = nil + self.state = "Stopped" + self.error = + "The local server exited (\(child.exitStatus ?? -1)). Start it again to reconnect." + } + return session + } catch let failure as T3Error where failure.authentication { + throw failure + } catch is CancellationError { throw CancellationError() } catch {} + try await Task.sleep(for: .milliseconds(250)) + } + throw T3Error( + "The local server did not become ready within 60 seconds. Check its runtime assets and data directory." + ) + } catch { + child.terminate() + self.settings = nil + state = "Stopped" + self.error = error.localizedDescription + throw error + } + } + /// Stop only our child; the server's normal SIGTERM cleanup closes its managed tunnel. + func stop() async throws { + generation = UUID() + settings = nil + guard let child else { + state = "Stopped" + return + } + state = "Stopping…" + child.terminate() + let deadline = Date().addingTimeInterval(15) + while child.isRunning, Date() < deadline { try await Task.sleep(for: .milliseconds(100)) } + guard !child.isRunning else { + state = "Still running" + throw T3Error("The local server has not stopped. Keep the app open and try Stop again.") + } + self.child = nil + state = "Stopped" + } + private static func checkPort(_ port: Int) throws { + let fd = socket(AF_INET, SOCK_STREAM, 0) + guard fd >= 0 else { throw T3Error("Could not check the local server port.") } + defer { close(fd) } + var address = sockaddr_in() + address.sin_len = UInt8(MemoryLayout.size) + address.sin_family = sa_family_t(AF_INET) + address.sin_port = UInt16(port).bigEndian + address.sin_addr.s_addr = inet_addr("127.0.0.1") + let result = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + bind(fd, $0, socklen_t(MemoryLayout.size)) + } + } + guard result == 0 else { + throw T3Error( + "Port \(port) is already in use. Choose another port; the existing server will not be stopped." + ) + } + } +} diff --git a/apps/macos/Sources/T3Native/BrowserAgentCursor.swift b/apps/macos/Sources/T3Native/BrowserAgentCursor.swift new file mode 100644 index 0000000000000..a624797b3b618 --- /dev/null +++ b/apps/macos/Sources/T3Native/BrowserAgentCursor.swift @@ -0,0 +1,253 @@ +import AppKit +import QuartzCore +import T3Core +import WebKit + +// A view-backed layer remains visible to screen sharing; no display-link or repaint loop. +@MainActor final class BrowserAgentCursor: NSView { + private let scene = CALayer() + private let pointer = CAShapeLayer() + private let ring = CAShapeLayer() + private let badge = CATextLayer() + private let keyBadge = CATextLayer() + private var feedbackTask: Task? + private(set) var target: CGPoint? + private(set) var feedback = "" + private var humanPoint: CGPoint? + var reduceMotionOverride: Bool? + var reducedMotion: Bool { + reduceMotionOverride ?? NSWorkspace.shared.accessibilityDisplayShouldReduceMotion + } + override var isFlipped: Bool { true } + override func hitTest(_ point: NSPoint) -> NSView? { nil } + override init(frame: NSRect) { + super.init(frame: frame) + wantsLayer = true + layer?.masksToBounds = true + // AppKit resets backing-layer geometry. Keep the top-left coordinate system on + // our own child layer so page/cursor positions stay aligned after layout. + scene.isGeometryFlipped = true + scene.frame = bounds + layer?.addSublayer(scene) + pointer.path = Self.path + pointer.fillColor = NSColor.systemPurple.cgColor + pointer.strokeColor = NSColor.white.cgColor + pointer.lineWidth = 1.5 + pointer.shadowColor = NSColor.black.cgColor + pointer.shadowOpacity = 0.35 + pointer.shadowRadius = 2 + pointer.shadowOffset = CGSize(width: 0, height: 1) + pointer.bounds = CGRect(x: 0, y: 0, width: 24, height: 29) + pointer.anchorPoint = .zero + ring.path = CGPath(ellipseIn: CGRect(x: -13, y: -13, width: 26, height: 26), transform: nil) + ring.fillColor = NSColor.clear.cgColor + ring.strokeColor = NSColor.systemPurple.cgColor + ring.lineWidth = 3 + ring.opacity = 0 + for text in [badge, keyBadge] { + text.fontSize = 11 + text.foregroundColor = NSColor.white.cgColor + text.backgroundColor = NSColor.systemPurple.cgColor + text.cornerRadius = 4 + text.alignmentMode = .center + text.contentsScale = NSScreen.main?.backingScaleFactor ?? 2 + } + badge.string = "Agent" + badge.bounds = CGRect(x: 0, y: 0, width: 45, height: 19) + badge.anchorPoint = .zero + scene.addSublayer(ring) + scene.addSublayer(pointer) + scene.addSublayer(badge) + scene.addSublayer(keyBadge) + isHidden = true + setAccessibilityLabel("Agent browser cursor") + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + override func layout() { + super.layout() + CATransaction.begin() + CATransaction.setDisableActions(true) + scene.frame = bounds + CATransaction.commit() + } + var contentGeometryFlipped: Bool { scene.isGeometryFlipped } + static var path: CGPath { + let path = CGMutablePath() + path.move(to: .zero) + for point in [ + CGPoint(x: 1, y: 23), CGPoint(x: 7, y: 17), CGPoint(x: 12, y: 27), + CGPoint(x: 17, y: 24), CGPoint(x: 12, y: 15), CGPoint(x: 21, y: 14), + ] { + path.addLine(to: point) + } + path.closeSubpath() + return path + } + @discardableResult func move(css: CGPoint, zoom: Double, animate: Bool = true) throws -> Double { + guard css.x.isFinite, css.y.isFinite, zoom.isFinite, zoom > 0, + css.x >= 0, css.y >= 0, css.x * zoom < bounds.width, css.y * zoom < bounds.height + else { + throw BrowserAutomationError( + "PreviewAutomationCoordinatesOutsideViewportError", + "The target is outside the browser viewport.") + } + if let parent = superview { parent.addSubview(self, positioned: .above, relativeTo: nil) } + let destination = CGPoint(x: css.x * zoom, y: css.y * zoom) + let source = pointer.presentation()?.position ?? target ?? destination + let duration = animate && !reducedMotion && target != nil ? 0.24 : 0 + target = destination + humanPoint = nil + pointer.opacity = 1 + badge.opacity = 1 + isHidden = false + CATransaction.begin() + CATransaction.setDisableActions(true) + pointer.position = destination + badge.position = badgePosition(destination) + ring.position = destination + CATransaction.commit() + for (item, from, to) in [ + (pointer as CALayer, source, destination), + (badge as CALayer, badgePosition(source), badgePosition(destination)), + (ring as CALayer, source, destination), + ] { + item.removeAnimation(forKey: "agentMove") + if duration > 0 { + let animation = CABasicAnimation(keyPath: "position") + animation.fromValue = NSValue(point: from) + animation.toValue = NSValue(point: to) + animation.duration = duration + animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + item.add(animation, forKey: "agentMove") + } + } + return duration + } + private func badgePosition(_ point: CGPoint) -> CGPoint { + CGPoint( + x: max(0, min(bounds.width - 45, point.x + 20)), + y: max(0, min(bounds.height - 19, point.y + 24))) + } + func click() { + guard target != nil else { return } + CATransaction.begin() + CATransaction.setDisableActions(true) + ring.opacity = reducedMotion ? 1 : 0 + CATransaction.commit() + if !reducedMotion { + let opacity = CABasicAnimation(keyPath: "opacity") + opacity.fromValue = 1 + opacity.toValue = 0 + opacity.duration = 0.65 + ring.add(opacity, forKey: "click") + let scale = CABasicAnimation(keyPath: "transform.scale") + scale.fromValue = 0.5 + scale.toValue = 1.8 + scale.duration = 0.65 + ring.add(scale, forKey: "clickScale") + } + showFeedback("Agent click") + } + func showFeedback(_ value: String) { + feedbackTask?.cancel() + feedback = value + isHidden = false + if target == nil { + pointer.opacity = 0 + badge.opacity = 0 + } + CATransaction.begin() + CATransaction.setDisableActions(true) + keyBadge.string = value + keyBadge.backgroundColor = + value.hasPrefix("Human") ? NSColor.systemBlue.cgColor : NSColor.systemPurple.cgColor + keyBadge.frame = CGRect(x: 12, y: max(0, bounds.height - 36), width: 180, height: 24) + keyBadge.opacity = 1 + CATransaction.commit() + feedbackTask = Task { [weak self] in + do { try await Task.sleep(for: .seconds(1.2)) } catch { return } + self?.feedback = "" + self?.keyBadge.opacity = 0 + self?.ring.opacity = 0 + self?.humanPoint = nil + if self?.target == nil { self?.isHidden = true } + } + } + func humanInput(_ event: NSEvent, web: WKWebView) { + if event.type != .keyDown { + let local = web.convert(event.locationInWindow, from: nil) + humanPoint = CGPoint(x: local.x, y: web.isFlipped ? local.y : web.bounds.height - local.y) + } + let namedKeys: [UInt16: String] = [ + 36: "Enter", 48: "Tab", 53: "Escape", 51: "Backspace", + 123: "ArrowLeft", 124: "ArrowRight", 125: "ArrowDown", 126: "ArrowUp", + ] + showFeedback( + event.type == .keyDown ? "Human " + (namedKeys[event.keyCode] ?? "key") : "Human input") + } + func hide() { + feedbackTask?.cancel() + feedbackTask = nil + target = nil + humanPoint = nil + feedback = "" + isHidden = true + CATransaction.begin() + CATransaction.setDisableActions(true) + ring.opacity = 0 + keyBadge.opacity = 0 + for item in [pointer, ring, badge, keyBadge] { item.removeAllAnimations() } + CATransaction.commit() + } + isolated deinit { feedbackTask?.cancel() } + var movementAnimation: CAAnimation? { pointer.animation(forKey: "agentMove") } + + // WKSnapshotConfiguration captures the page, not native sibling layers. Compose the + // same vector cursor explicitly; deterministic screenshots use its final position. + func drawCapture(size: CGSize, mouse: Bool, keys: Bool, deterministic: Bool) { + guard bounds.width > 0, bounds.height > 0, let context = NSGraphicsContext.current?.cgContext + else { return } + context.saveGState() + context.scaleBy(x: size.width / bounds.width, y: size.height / bounds.height) + if mouse, !isHidden, let target { + let point = deterministic ? target : pointer.presentation()?.position ?? target + context.saveGState() + context.translateBy(x: point.x, y: point.y) + context.addPath(Self.path) + context.setFillColor(NSColor.systemPurple.cgColor) + context.setStrokeColor(NSColor.white.cgColor) + context.setLineWidth(1.5) + context.drawPath(using: .fillStroke) + if feedback == "Agent click" { + context.setStrokeColor(NSColor.systemPurple.cgColor) + context.strokeEllipse(in: CGRect(x: -13, y: -13, width: 26, height: 26)) + } + context.restoreGState() + drawLabel("Agent", at: badgePosition(point)) + } + if mouse, let humanPoint { + context.setStrokeColor(NSColor.systemBlue.cgColor) + context.setLineWidth(3) + context.strokeEllipse( + in: CGRect(x: humanPoint.x - 10, y: humanPoint.y - 10, width: 20, height: 20)) + } + if keys, !feedback.isEmpty { + drawLabel(feedback, at: CGPoint(x: 12, y: max(0, bounds.height - 36))) + } + context.restoreGState() + } + private func drawLabel(_ string: String, at point: CGPoint) { + let attributes: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: 11, weight: .medium), .foregroundColor: NSColor.white, + ] + let size = (string as NSString).size(withAttributes: attributes) + (string.hasPrefix("Human") ? NSColor.systemBlue : NSColor.systemPurple).setFill() + NSBezierPath( + roundedRect: CGRect( + origin: point, + size: CGSize(width: size.width + 10, height: 20)), xRadius: 4, yRadius: 4 + ).fill() + (string as NSString).draw( + at: CGPoint(x: point.x + 5, y: point.y + 2), withAttributes: attributes) + } +} diff --git a/apps/macos/Sources/T3Native/BrowserAnnotationRenderer.swift b/apps/macos/Sources/T3Native/BrowserAnnotationRenderer.swift new file mode 100644 index 0000000000000..170c503f01314 --- /dev/null +++ b/apps/macos/Sources/T3Native/BrowserAnnotationRenderer.swift @@ -0,0 +1,77 @@ +import AppKit +import T3Core + +@MainActor enum BrowserAnnotationRenderer { + static func png(_ image: NSImage) throws -> Data { + guard let tiff = image.tiffRepresentation, let bitmap = NSBitmapImageRep(data: tiff), + let data = bitmap.representation(using: .png, properties: [:]) + else { throw T3Error("Could not encode browser screenshot.") } + return data + } + static func draw(_ annotations: [BrowserAnnotation], size: CGSize) { + NSColor.systemRed.setStroke() + for annotation in annotations { + let points = annotation.points.map { CGPoint(x: $0.x * size.width, y: $0.y * size.height) } + guard let first = points.first, let last = points.last, points.count > 1 else { continue } + let path = NSBezierPath() + path.lineWidth = annotation.width + path.lineCapStyle = .round + path.lineJoinStyle = .round + switch annotation.tool { + case .pen: + path.move(to: first) + for point in points.dropFirst() { path.line(to: point) } + case .rectangle: + path.appendRect( + CGRect( + x: min(first.x, last.x), y: min(first.y, last.y), + width: abs(first.x - last.x), height: abs(first.y - last.y))) + case .arrow: + path.move(to: first) + path.line(to: last) + let angle = atan2(last.y - first.y, last.x - first.x) + for direction in [-1.0, 1.0] { + path.move(to: last) + path.line( + to: CGPoint( + x: last.x - 14 * cos(angle + direction * .pi / 6), + y: last.y - 14 * sin(angle + direction * .pi / 6))) + } + } + path.stroke() + } + } + static func render(_ image: NSImage, document: BrowserAnnotationDocument) throws -> NSImage { + try document.validate() + let composed = NSImage(size: image.size, flipped: true) { rect in + image.draw( + in: rect, from: .zero, operation: .copy, fraction: 1, respectFlipped: true, hints: nil) + draw(document.annotations, size: rect.size) + return true + } + guard let image = NSImage(data: try png(composed)) else { + throw T3Error("Could not render annotations.") + } + return image + } + static func save(_ image: NSImage, document: BrowserAnnotationDocument, directory: URL) throws + -> URL + { + let image = try render(image, document: document) + try FileManager.default.createDirectory( + at: directory, withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700]) + let stem = "Annotated browser " + UUID().uuidString + let pngURL = directory.appendingPathComponent(stem + ".png") + let metadata = directory.appendingPathComponent(stem + ".json") + do { + try png(image).write(to: pngURL, options: .atomic) + try document.serialized().write(to: metadata, options: .atomic) + } catch { + try? FileManager.default.removeItem(at: pngURL) + try? FileManager.default.removeItem(at: metadata) + throw error + } + return pngURL + } +} diff --git a/apps/macos/Sources/T3Native/BrowserAnnotations.swift b/apps/macos/Sources/T3Native/BrowserAnnotations.swift new file mode 100644 index 0000000000000..1dd199919118b --- /dev/null +++ b/apps/macos/Sources/T3Native/BrowserAnnotations.swift @@ -0,0 +1,152 @@ +import AppKit +import T3Core + +@MainActor final class BrowserAnnotationCanvas: NSView { + let image: NSImage + var document: BrowserAnnotationDocument + var tool: BrowserAnnotation.Tool = .pen + var onChange: (() -> Void)? + private var stroke: BrowserAnnotation? + override var isFlipped: Bool { true } + override var acceptsFirstResponder: Bool { true } + init(image: NSImage, pageURL: String) { + self.image = image + document = BrowserAnnotationDocument(pageURL: pageURL) + super.init(frame: .zero) + setAccessibilityLabel("Browser screenshot annotation canvas") + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + var imageRect: CGRect { + let scale = min(bounds.width / image.size.width, bounds.height / image.size.height) + let size = CGSize(width: image.size.width * scale, height: image.size.height * scale) + return CGRect( + x: (bounds.width - size.width) / 2, y: (bounds.height - size.height) / 2, + width: size.width, height: size.height) + } + override func draw(_ dirtyRect: NSRect) { + NSColor.windowBackgroundColor.setFill() + bounds.fill() + let rect = imageRect + image.draw( + in: rect, from: .zero, operation: .sourceOver, fraction: 1, respectFlipped: true, hints: nil) + guard let context = NSGraphicsContext.current?.cgContext else { return } + context.saveGState() + context.clip(to: rect) + context.translateBy(x: rect.minX, y: rect.minY) + BrowserAnnotationRenderer.draw( + document.annotations + (stroke.map { [$0] } ?? []), size: rect.size) + context.restoreGState() + } + private func point(_ event: NSEvent) -> BrowserAnnotationPoint? { + let local = convert(event.locationInWindow, from: nil) + let rect = imageRect + guard rect.width > 0, rect.height > 0 else { return nil } + return .init( + x: max(0, min(1, (local.x - rect.minX) / rect.width)), + y: max(0, min(1, (local.y - rect.minY) / rect.height))) + } + override func mouseDown(with event: NSEvent) { + guard imageRect.contains(convert(event.locationInWindow, from: nil)), + let point = point(event), document.annotations.count < 1000 + else { return } + stroke = BrowserAnnotation(tool: tool, points: [point, point]) + needsDisplay = true + } + override func mouseDragged(with event: NSEvent) { + guard let point = point(event), stroke != nil else { return } + if tool == .pen { + if stroke!.points.count < 2000 { stroke?.points.append(point) } + } else { + stroke?.points[1] = point + } + needsDisplay = true + } + override func mouseUp(with event: NSEvent) { + mouseDragged(with: event) + if let stroke { document.annotations.append(stroke) } + stroke = nil + needsDisplay = true + onChange?() + } + override func keyDown(with event: NSEvent) { + if event.keyCode == 53 { + stroke = nil + needsDisplay = true + return + } + if event.modifierFlags.contains(.command), event.charactersIgnoringModifiers == "z" { + undoStroke() + return + } + super.keyDown(with: event) + } + func undoStroke() { + if !document.annotations.isEmpty { document.annotations.removeLast() } + needsDisplay = true + onChange?() + } + func clear() { + document.annotations = [] + stroke = nil + needsDisplay = true + onChange?() + } +} + +@MainActor final class BrowserAnnotationWindow: NSWindowController, NSWindowDelegate { + let canvas: BrowserAnnotationCanvas + var onSave: ((URL, Bool) -> Void)? + var onClose: (() -> Void)? + private let message = NSTextField( + labelWithString: "Draw on the screenshot, then save or attach it.") + init(image: NSImage, pageURL: String) { + canvas = BrowserAnnotationCanvas(image: image, pageURL: pageURL) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 900, height: 680), + styleMask: [.titled, .closable, .resizable], backing: .buffered, defer: false) + super.init(window: window) + window.title = "Annotate browser screenshot" + window.isReleasedWhenClosed = false + window.delegate = self + window.minSize = NSSize(width: 480, height: 360) + let tools = NSPopUpButton() + tools.addItems(withTitles: ["Pen", "Rectangle", "Arrow"]) + tools.target = self + tools.action = #selector(selectTool(_:)) + let toolbar = NSStackView(views: [ + tools, + ActionButton("Undo") { [weak self] in self?.canvas.undoStroke() }, + ActionButton("Clear") { [weak self] in self?.canvas.clear() }, + ActionButton("Save") { [weak self] in self?.save(attach: false) }, + ActionButton("Save and Attach") { [weak self] in self?.save(attach: true) }, + ]) + let stack = NSStackView(views: [toolbar, canvas, message]) + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 10 + window.contentView?.addSubview(stack) + stack.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: window.contentView!.leadingAnchor, constant: 12), + stack.trailingAnchor.constraint(equalTo: window.contentView!.trailingAnchor, constant: -12), + stack.topAnchor.constraint(equalTo: window.contentView!.topAnchor, constant: 12), + stack.bottomAnchor.constraint(equalTo: window.contentView!.bottomAnchor, constant: -12), + canvas.widthAnchor.constraint(equalTo: stack.widthAnchor), + canvas.heightAnchor.constraint(greaterThanOrEqualToConstant: 200), + ]) + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + @objc private func selectTool(_ sender: NSPopUpButton) { + canvas.tool = [.pen, .rectangle, .arrow][sender.indexOfSelectedItem] + } + private func save(attach: Bool) { + do { + let file = try BrowserAnnotationRenderer.save( + canvas.image, document: canvas.document, + directory: BrowserArtifacts.directory) + message.stringValue = "Saved " + file.lastPathComponent + onSave?(file, attach) + } catch { message.stringValue = error.localizedDescription } + } + func windowWillClose(_ notification: Notification) { onClose?() } +} diff --git a/apps/macos/Sources/T3Native/BrowserAutomationHost.swift b/apps/macos/Sources/T3Native/BrowserAutomationHost.swift new file mode 100644 index 0000000000000..c4699e98fd0a6 --- /dev/null +++ b/apps/macos/Sources/T3Native/BrowserAutomationHost.swift @@ -0,0 +1,264 @@ +import AppKit +import T3Core + +struct BrowserAutomationError: LocalizedError { + let tag: String + let message: String + var errorDescription: String? { message } + init(_ tag: String, _ message: String) { + self.tag = tag + self.message = message + } + static let interrupted = Self( + "PreviewAutomationControlInterruptedError", "Browser control was interrupted.") +} + +struct BrowserAutomationDeadline { + let end: ContinuousClock.Instant + init(milliseconds: Int) { + end = .now.advanced(by: .milliseconds(min(120_000, max(1, milliseconds)))) + } + var remaining: Duration { ContinuousClock.now.duration(to: end) } + func check() throws { + try Task.checkCancellation() + guard ContinuousClock.now < end else { + throw BrowserAutomationError( + "PreviewAutomationTimeoutError", "The browser operation timed out.") + } + } +} + +@MainActor final class BrowserAutomationHost { + typealias Handler = (JSON, BrowserAutomationDeadline) async throws -> JSON + var handle: Handler? + var liveTabs: (() -> [JSON])? + var onChange: (() -> Void)? + private(set) var error: String? + private(set) var running = 0 + private(set) var paused = false + private let clientID = "native-" + UUID().uuidString + private var connection: RPCConnection? + private var environmentID: String? + private var connectionID: String? + private var streamTask: Task? + private var work: [String: Task] = [:] + private var owners: [String: String] = [:] + private var queues: [String: (id: String, task: Task)] = [:] + private var seen: [String] = [] + private var generation = UUID() + private var focusTask: Task? + private var pendingFocus: JSON? + private var lastFocus: JSON? + private var activityTasks: [Task] = [] + static let operations = [ + "status", "open", "navigate", "snapshot", "click", "type", "press", "scroll", "evaluate", + "waitFor", "resize", "setColorScheme", "recordingStart", "recordingStop", + ] + + init() { + for name in [ + NSApplication.didBecomeActiveNotification, NSApplication.didResignActiveNotification, + ] { + activityTasks.append( + Task { [weak self] in + for await _ in NotificationCenter.default.notifications(named: name) { + guard !Task.isCancelled else { return } + self?.refreshFocus() + } + }) + } + } + isolated deinit { + for task in activityTasks { task.cancel() } + stop() + } + func retry() { + let connection = connection + let environmentID = environmentID + stop() + synchronize(connection: connection, environmentID: environmentID) + } + func synchronize(connection: RPCConnection?, environmentID: String?) { + guard self.connection !== connection || self.environmentID != environmentID else { + refreshFocus() + return + } + stop() + self.connection = connection + self.environmentID = environmentID + guard let connection, let environmentID else { return } + let generation = generation + streamTask = Task { [weak self] in + guard let self else { return } + do { + let stream = try await connection.subscribe( + "previewAutomation.connect", + payload: .object([ + "clientId": .string(clientID), "environmentId": .string(environmentID), + "supportedOperations": .array(Self.operations.map(JSON.string)), + ])) + defer { Task { await connection.cancel(stream.id) } } + for try await event in stream.values { + try Task.checkCancellation() + guard self.generation == generation else { return } + if event["type"].string == "connected" { + connectionID = try event.requiredString("connectionId") + error = nil + lastFocus = nil + refreshFocus() + onChange?() + } else if event["connectionId"].string == connectionID { + enqueue(event["request"], connection: connection, generation: generation) + } + } + throw T3Error("Agent browser connection ended. Reconnect to restore it.") + } catch { + guard !Task.isCancelled, self.generation == generation else { return } + self.error = error.localizedDescription + connectionID = nil + interrupt() + onChange?() + } + } + } + func setPaused(_ paused: Bool) { + self.paused = paused + if paused { interrupt() } + onChange?() + } + func interrupt(threadID: String) { + for (id, owner) in owners where owner == threadID { work[id]?.cancel() } + } + private func interrupt() { for task in work.values { task.cancel() } } + private func stop() { + generation = UUID() + streamTask?.cancel() + streamTask = nil + interrupt() + work = [:] + owners = [:] + queues = [:] + seen = [] + focusTask?.cancel() + focusTask = nil + pendingFocus = nil + lastFocus = nil + connectionID = nil + connection = nil + environmentID = nil + error = nil + running = 0 + } + private func enqueue(_ request: JSON, connection: RPCConnection, generation: UUID) { + guard let id = request["requestId"].string, let threadID = request["threadId"].string, + let connectionID, !seen.contains(id) + else { return } + seen.append(id) + if seen.count > 256 { seen.removeFirst() } + let maximum = request["operation"].string == "recordingStop" ? 120_000 : 60_000 + let timeoutMs = min(maximum, max(1, request["timeoutMs"].int ?? 15_000)) + // Match Electron's response margin so a page timeout does not evict a healthy host. + let deadline = BrowserAutomationDeadline( + milliseconds: timeoutMs - min(1_500, (timeoutMs + 4) / 5)) + let predecessor = queues[threadID]?.id + let overCapacity = work.count >= 16 + let task = Task { [weak self] in + guard let self else { return } + var expired = false + let timeout = Task { + do { try await Task.sleep(for: deadline.remaining) } catch { return } + expired = true + work[id]?.cancel() + } + var response: [String: JSON] = [ + "clientId": .string(clientID), "connectionId": .string(connectionID), + "requestId": .string(id), + ] + defer { + timeout.cancel() + work.removeValue(forKey: id) + owners.removeValue(forKey: id) + if queues[threadID]?.id == id { queues.removeValue(forKey: threadID) } + running = work.count + onChange?() + refreshFocus() + } + do { + guard !paused else { throw BrowserAutomationError.interrupted } + guard !overCapacity else { + throw T3Error("The native browser is busy. Retry after the current actions finish.") + } + while let predecessor, work[predecessor] != nil { + try deadline.check() + try await Task.sleep(for: .milliseconds(25)) + } + try deadline.check() + guard self.generation == generation, let handle, !paused else { + throw BrowserAutomationError.interrupted + } + response["result"] = try await handle(request, deadline) + try deadline.check() + response["ok"] = .bool(true) + } catch { + let failure = error as? BrowserAutomationError + response["ok"] = .bool(false) + response["error"] = .object([ + "_tag": .string( + expired + ? "PreviewAutomationTimeoutError" + : error is CancellationError + ? BrowserAutomationError.interrupted.tag + : failure?.tag ?? "PreviewAutomationExecutionError"), + "message": .string( + expired + ? "The browser operation timed out." : String(error.localizedDescription.prefix(2000)) + ), + ]) + } + guard self.generation == generation, self.connectionID == connectionID else { return } + do { + let payload = JSON.object(response) + _ = try await Task { + try await connection.call("previewAutomation.respond", payload: payload) + }.value + } catch { + if !Task.isCancelled { + self.error = "Agent browser response failed: " + error.localizedDescription + } + } + } + work[id] = task + owners[id] = threadID + if !overCapacity { queues[threadID] = (id, task) } + running = work.count + onChange?() + } + func refreshFocus() { + guard let connection, let environmentID, let connectionID else { return } + let payload: JSON = .object([ + "clientId": .string(clientID), "environmentId": .string(environmentID), + "connectionId": .string(connectionID), + "focused": .bool(NSApp?.isActive == true), "liveTabs": .array(liveTabs?() ?? []), + ]) + guard payload != lastFocus else { return } + lastFocus = payload + pendingFocus = payload + guard focusTask == nil else { return } + let generation = generation + focusTask = Task { [weak self] in + guard let self else { return } + defer { if self.generation == generation { focusTask = nil } } + while let input = pendingFocus, !Task.isCancelled, self.generation == generation { + pendingFocus = nil + do { _ = try await connection.call("previewAutomation.focusHost", payload: input) } catch { + if self.generation == generation { + lastFocus = nil + self.error = error.localizedDescription + onChange?() + } + return + } + } + } + } +} diff --git a/apps/macos/Sources/T3Native/BrowserAutomationPage.swift b/apps/macos/Sources/T3Native/BrowserAutomationPage.swift new file mode 100644 index 0000000000000..0b0ed023b14a7 --- /dev/null +++ b/apps/macos/Sources/T3Native/BrowserAutomationPage.swift @@ -0,0 +1,319 @@ +import AppKit +import T3Core +import WebKit + +@MainActor final class BrowserAutomationPage { + let cursor: BrowserAgentCursor + private let web: WKWebView + private let world = WKContentWorld.world(name: "T3BrowserAutomation") + private var installed = false + private var document = UUID() + private var calls: [UUID: JavaScriptCall] = [:] + private(set) var controlRevision = UUID() + private(set) var actions: [JSON] = [] + private static let injected: String = { + let file = Bundle.module.url( + forResource: "PlaywrightInjected", withExtension: "js", subdirectory: "Resources")! + return try! String(contentsOf: file, encoding: .utf8) + }() + private static let operations: String = { + let file = Bundle.module.url( + forResource: "BrowserAutomation", withExtension: "js", subdirectory: "Resources")! + return try! String(contentsOf: file, encoding: .utf8) + }() + init(web: WKWebView) { + self.web = web + cursor = BrowserAgentCursor(frame: web.bounds) + cursor.autoresizingMask = [.width, .height] + web.addSubview(cursor, positioned: .above, relativeTo: nil) + } + func interrupt() { + controlRevision = UUID() + cursor.hide() + for call in calls.values { call.finish(.failure(BrowserAutomationError.interrupted)) } + } + func invalidate() { + installed = false + document = UUID() + } + + func record( + _ operation: String, status: String, id: String, started: String, error: String? = nil + ) { + var value: [String: JSON] = [ + "id": .string(id), "action": .string(operation), "status": .string(status), + "startedAt": .string(started), + ] + if status != "running" { value["completedAt"] = .string(Date().ISO8601Format()) } + if let error { value["error"] = .string(error) } + if let index = actions.firstIndex(where: { $0["id"].string == id }) { + actions[index] = .object(value) + } else { + actions.append(.object(value)) + if actions.count > 50 { actions.removeFirst() } + } + } + + private func install(_ deadline: BrowserAutomationDeadline) async throws { + guard !installed else { return } + let document = document + _ = try await script( + """ + if (!globalThis.__t3PlaywrightInjected) { + const module = {exports:{}}; + \(Self.injected) + globalThis.__t3PlaywrightInjected = new (module.exports.InjectedScript())(globalThis, { + isUnderTest:false, sdkLanguage:'javascript', testIdAttributeName:'data-testid', stableRafCount:1, + browserName:'webkit', shouldPrependErrorPrefix:false, isUtilityWorld:true, customEngines:[] + }); + } + return JSON.stringify(true); + """, arguments: [:], world: world, deadline: deadline) + guard self.document == document else { throw BrowserAutomationError.interrupted } + installed = true + } + + func query(_ operation: String, input: JSON = .object([:]), deadline: BrowserAutomationDeadline) + async throws -> JSON + { + try await install(deadline) + return try await script( + "const input = JSON.parse(payload); const value = await (async () => {\n" + Self.operations + + "\n})(); return JSON.stringify(value ?? null);", + arguments: [ + "operation": operation, "payload": String(decoding: try input.data(), as: UTF8.self), + ], world: world, deadline: deadline) + } + + func run(_ operation: String, input: JSON, deadline: BrowserAutomationDeadline) async throws + -> JSON + { + let revision = controlRevision + func check() throws { + try deadline.check() + guard revision == controlRevision else { throw BrowserAutomationError.interrupted } + } + try check() + if operation == "evaluate" { + guard input["returnByValue"] != .bool(false) else { + throw BrowserAutomationError( + "PreviewAutomationUnsupportedClientError", + "Native WebKit returns values; remote JavaScript object handles are unavailable.") + } + let result = try await script( + "let value = (0,eval)(expression); if (wait) value = await value; return JSON.stringify(value ?? null);", + arguments: [ + "expression": try input.requiredString("expression"), + "wait": input["awaitPromise"] != .bool(false), + ], world: .page, deadline: deadline) + try check() + return result + } + if ["type", "scroll", "press"].contains(operation) { + let target = try await query("pointer", input: input, deadline: deadline) + try check() + if case .number(let x) = target["x"], case .number(let y) = target["y"] { + let duration = try cursor.move(css: CGPoint(x: x, y: y), zoom: web.pageZoom) + if duration > 0 { try await Task.sleep(for: .seconds(duration)) } + try check() + } + // Do not display typed text: it may be a password or another secret. + let key = input["key"].string ?? "key" + let modifiers = input["modifiers"].array.compactMap(\.string) + let label = + key.count > 1 || modifiers.contains("Meta") || modifiers.contains("Control") + ? (modifiers + [key]).joined(separator: "+") : "key" + cursor.showFeedback( + operation == "type" + ? "Agent typing" : operation == "scroll" ? "Agent scroll" : "Agent " + label) + } + if operation == "press" { + try await press(input, deadline: deadline) + try check() + return .null + } + while true { + try check() + let result = try await query(operation, input: input, deadline: deadline) + try check() + if let tag = result["error"]["_tag"].string { + throw BrowserAutomationError( + tag, result["error"]["message"].string ?? "Browser action failed.") + } + if result["pending"].bool { + try await Task.sleep(for: .milliseconds(80)) + continue + } + if operation == "click" { + guard case .number(let x) = result["x"], case .number(let y) = result["y"] else { + throw T3Error("Browser target did not provide coordinates.") + } + // Seed the first movement so the agent is visibly approaching its first target. + if cursor.target == nil { + _ = try cursor.move( + css: CGPoint( + x: min(16, web.bounds.width / web.pageZoom / 2), + y: min(16, web.bounds.height / web.pageZoom / 2)), zoom: web.pageZoom, animate: false) + } + let duration = try cursor.move(css: CGPoint(x: x, y: y), zoom: web.pageZoom) + if duration > 0 { try await Task.sleep(for: .seconds(duration)) } + try check() + // Layout or scrolling can change during the transition; resolve the target again. + let resolved = try await query(operation, input: input, deadline: deadline) + try check() + if resolved["pending"].bool { continue } + guard case .number(let rx) = resolved["x"], case .number(let ry) = resolved["y"] else { + throw T3Error("Browser target no longer provides coordinates.") + } + _ = try cursor.move(css: CGPoint(x: rx, y: ry), zoom: web.pageZoom, animate: false) + cursor.click() + try click(resolved) + return .null + } + if ["type", "scroll", "waitFor"].contains(operation) { return .null } + return result + } + } + + private func click(_ point: JSON) throws { + guard case .number(let x) = point["x"], case .number(let y) = point["y"], + x.isFinite, y.isFinite, x >= 0, y >= 0, + x < web.bounds.width / web.pageZoom, y < web.bounds.height / web.pageZoom, + let window = web.window + else { + throw BrowserAutomationError( + "PreviewAutomationCoordinatesOutsideViewportError", + "The target is outside the browser viewport.") + } + let local = NSPoint( + x: x * web.pageZoom, + y: (web.isFlipped ? y * web.pageZoom : web.bounds.height - y * web.pageZoom)) + let location = web.convert(local, to: nil) + guard let content = window.contentView, + let hit = content.hitTest(content.superview?.convert(location, from: nil) ?? location), + hit === web || hit.isDescendant(of: web) + else { + throw BrowserAutomationError( + "PreviewAutomationTargetNotFoundError", "The browser target is covered by another view.") + } + // Dispatch only to this WK view; never send global input or make another window key. + for type in [NSEvent.EventType.leftMouseDown, .leftMouseUp] { + guard + let event = NSEvent.mouseEvent( + with: type, location: location, modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, context: nil, eventNumber: 0, clickCount: 1, + pressure: type == .leftMouseDown ? 1 : 0) + else { throw T3Error("Could not create browser input.") } + if type == .leftMouseDown { hit.mouseDown(with: event) } else { hit.mouseUp(with: event) } + } + } + + private func press(_ input: JSON, deadline: BrowserAutomationDeadline) async throws { + guard let window = web.window else { throw BrowserAutomationError.interrupted } + let key = try input.requiredString("key") + let keys: [String: (String, UInt16)] = [ + "Enter": ("\r", 36), "Tab": ("\t", 48), "Escape": ("\u{1b}", 53), "Backspace": ("\u{7f}", 51), + "Delete": ("\u{f728}", 117), "ArrowLeft": ("\u{f702}", 123), "ArrowRight": ("\u{f703}", 124), + "ArrowUp": ("\u{f700}", 126), "ArrowDown": ("\u{f701}", 125), + "Home": ("\u{f729}", 115), "End": ("\u{f72b}", 119), "PageUp": ("\u{f72c}", 116), + "PageDown": ("\u{f72d}", 121), "Space": (" ", 49), + ] + let characterCodes: [String: UInt16] = [ + "a": 0, "s": 1, "d": 2, "f": 3, "h": 4, "g": 5, "z": 6, "x": 7, "c": 8, "v": 9, + "b": 11, "q": 12, "w": 13, "e": 14, "r": 15, "y": 16, "t": 17, "1": 18, "2": 19, "3": 20, + "4": 21, "6": 22, "5": 23, + "=": 24, "9": 25, "7": 26, "-": 27, "8": 28, "0": 29, "]": 30, "o": 31, "u": 32, "[": 33, + "i": 34, "p": 35, + "l": 37, "j": 38, "'": 39, "k": 40, ";": 41, "\\": 42, ",": 43, "/": 44, "n": 45, "m": 46, + ".": 47, " ": 49, "`": 50, + ] + guard let pair = keys[key] ?? characterCodes[key.lowercased()].map({ (key, $0) }) else { + throw T3Error("Unsupported browser key: " + key + ". Use type for text input.") + } + var flags: NSEvent.ModifierFlags = [] + for modifier in input["modifiers"].array.compactMap(\.string) { + switch modifier { + case "Alt": flags.insert(.option) + case "Control": flags.insert(.control) + case "Meta": flags.insert(.command) + case "Shift": flags.insert(.shift) + default: break + } + } + let responder = + (window.firstResponder as? NSView).flatMap { + $0 === web || $0.isDescendant(of: web) ? $0 : nil + } ?? web + let selectAll = #selector(NSText.selectAll(_:)) + if flags == [.command], key.lowercased() == "a", responder.responds(to: selectAll) { + responder.perform(selectAll, with: nil) + _ = try await query("ready", deadline: deadline) + return + } + for type in [NSEvent.EventType.keyDown, .keyUp] { + guard + let event = NSEvent.keyEvent( + with: type, location: .zero, modifierFlags: flags, + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, context: nil, characters: pair.0, + charactersIgnoringModifiers: pair.0, isARepeat: false, keyCode: pair.1) + else { throw T3Error("Could not create browser key input.") } + if type == .keyDown { + if !flags.contains(.command) || !web.performKeyEquivalent(with: event) { + responder.keyDown(with: event) + } + } else { + responder.keyUp(with: event) + } + } + _ = try await query("ready", deadline: deadline) + } + + func script( + _ source: String, arguments: [String: Any], world: WKContentWorld, + deadline: BrowserAutomationDeadline + ) async throws -> JSON { + try deadline.check() + let call = JavaScriptCall() + let id = UUID() + calls[id] = call + defer { calls.removeValue(forKey: id) } + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + call.continuation = continuation + call.timer = Task { + do { try await Task.sleep(for: deadline.remaining) } catch { return } + call.finish( + .failure( + BrowserAutomationError( + "PreviewAutomationTimeoutError", "The page did not respond in time."))) + } + web.callAsyncJavaScript(source, arguments: arguments, in: nil, in: world) { result in + switch result { + case .success(let value): + guard let string = value as? String, string.utf8.count <= 4_000_000 else { + call.finish( + .failure(T3Error("The page result was not serializable or was too large."))) + return + } + call.finish(Result { try JSON.decode(Data(string.utf8)) }) + case .failure(let error): call.finish(.failure(error)) + } + } + } + } onCancel: { + Task { @MainActor in call.finish(.failure(CancellationError())) } + } + } + @MainActor private final class JavaScriptCall { + var continuation: CheckedContinuation? + var timer: Task? + func finish(_ result: Result) { + timer?.cancel() + timer = nil + continuation?.resume(with: result) + continuation = nil + } + } +} diff --git a/apps/macos/Sources/T3Native/BrowserAutomationSmoke.swift b/apps/macos/Sources/T3Native/BrowserAutomationSmoke.swift new file mode 100644 index 0000000000000..2668d96d260df --- /dev/null +++ b/apps/macos/Sources/T3Native/BrowserAutomationSmoke.swift @@ -0,0 +1,344 @@ +import AppKit +import T3Core + +extension ParitySmoke { + @MainActor static func browserAutomation( + panel: BrowserPanel, store: ClientStore, connection: RPCConnection, + threadID: String, alternate: String, fixture: URL + ) async throws { + var tabID: String? + func action( + _ operation: String, _ input: [String: JSON] = [:], thread: String? = nil, + timeout: Int = 15_000 + ) async throws -> JSON { + var request: [String: JSON] = [ + "requestId": .string(UUID().uuidString), "threadId": .string(thread ?? threadID), + "operation": .string(operation), "input": .object(input), + ] + if let tabID { request["tabId"] = .string(tabID) } + return try await panel.automate( + .object(request), deadline: BrowserAutomationDeadline(milliseconds: timeout)) + } + func evaluate(_ expression: String) async throws -> JSON { + try await action("evaluate", ["expression": .string(expression)]) + } + let opened = try await action( + "open", + [ + "url": .string(fixture.absoluteString), "open": .bool(false), + "reuseExistingTab": .bool(false), + ]) + tabID = try opened.requiredString("tabId") + guard opened["title"].string == "Browser fixture One", opened["visible"] == .bool(false) else { + throw T3Error("Background browser open was not ready or changed visibility.") + } + print("Browser automation: hidden open and loaded status") + _ = try await action( + "type", + ["locator": .string("css=#draft"), "text": .string("hello native"), "clear": .bool(true)]) + guard try await evaluate("document.querySelector('#draft').value") == .string("hello native") + else { throw T3Error("Browser typing failed.") } + _ = try await action("press", ["key": .string("ArrowLeft")]) + _ = try await action("type", ["text": .string("!")]) + guard try await evaluate("document.querySelector('#draft').value") == .string("hello nativ!e") + else { throw T3Error("Browser key input missed the active field.") } + _ = try await action("press", ["key": .string("a"), "modifiers": .array([.string("Meta")])]) + _ = try await action("type", ["text": .string("selected all")]) + guard try await evaluate("document.querySelector('#draft').value") == .string("selected all") + else { + throw T3Error("Browser Command-A did not select the field's text.") + } + _ = try await action( + "click", ["locator": .string("internal:role=button[name=\"Trusted click\"s]")]) + guard try await evaluate("window.trustedClick") == .bool(true) else { + throw T3Error("Browser click was not trusted or hit the wrong element.") + } + print("Browser automation: Playwright role locator, trusted click, typing and native arrow key") + _ = try await action( + "type", + [ + "selector": .string("input[name=search]"), "text": .string("native enter"), + "clear": .bool(true), + ]) + _ = try await action("press", ["key": .string("Enter")]) + _ = try await action( + "waitFor", + ["urlIncludes": .string("search=native+enter"), "text": .string("Browser fixture Two")]) + _ = try await action( + "navigate", + [ + "url": .string( + fixture.deletingLastPathComponent().appendingPathComponent("redirect").absoluteString) + ]) + guard try await evaluate("location.pathname") == .string("/two") else { + throw T3Error("Redirect returned before its document loaded.") + } + _ = try await action( + "navigate", + ["url": .string(fixture.absoluteString), "readiness": .string("domContentLoaded")]) + guard try await evaluate("document.title") == .string("Browser fixture One") else { + throw T3Error("DOMContentLoaded returned the previous document.") + } + print("Browser automation: form Enter, combined wait, redirects and document readiness") + let resized = try await action( + "resize", ["mode": .string("freeform"), "width": .number(667), "height": .number(375)]) + guard resized["viewport"]["width"].int == 667, resized["viewport"]["height"].int == 375 else { + throw T3Error("Automation viewport did not apply.") + } + _ = try await action("setColorScheme", ["colorScheme": .string("light")]) + _ = try await action("waitFor", ["selector": .string("#draft")]) + _ = try await action("scroll", ["deltaY": .number(200)]) + guard (try await evaluate("scrollY")).int ?? 0 > 0 else { + throw T3Error("Browser scroll did not move the page.") + } + let snapshot = try await action("snapshot") + guard snapshot["interactiveElements"].array.count >= 5, + snapshot["accessibilityTree"].string?.contains("Browser fixture One") == true, + let png = snapshot["screenshot"]["data"].string.flatMap({ Data(base64Encoded: $0) }), + let bitmap = NSBitmapImageRep(data: png), bitmap.pixelsWide > 0, + bitmap.colorAt(x: 10, y: 10)?.usingColorSpace(.deviceRGB)?.blueComponent ?? 1 < 0.5 + else { throw T3Error("Browser snapshot omitted content or captured a blank hidden page.") } + print("Browser automation: hidden screenshot, accessible tree, viewport, appearance and scroll") + do { + _ = try await action("click", ["selector": .string("button")]) + throw T3Error("Ambiguous selector was accepted.") + } catch let error as BrowserAutomationError { + guard error.tag == "PreviewAutomationInvalidSelectorError" else { throw error } + } + do { + _ = try await action("type", ["selector": .string("h1"), "text": .string("wrong")]) + throw T3Error("Noneditable target was accepted.") + } catch let error as BrowserAutomationError { + guard error.tag == "PreviewAutomationTargetNotEditableError" else { throw error } + } + do { + _ = try await action("waitFor", ["text": .string("does not exist")], timeout: 250) + throw T3Error("Missing text did not time out.") + } catch let error as BrowserAutomationError { + guard error.tag == "PreviewAutomationTimeoutError" else { throw error } + } + do { + _ = try await action("status", thread: alternate) + throw T3Error("Cross-conversation tab access was accepted.") + } catch let error as BrowserAutomationError { + guard error.tag == "PreviewAutomationTabNotFoundError" else { throw error } + } + let pending = Task { + try await action("evaluate", ["expression": .string("new Promise(() => {})")]) + } + try await Task.sleep(for: .milliseconds(100)) + pending.cancel() + do { + _ = try await pending.value + throw T3Error("Browser script cancellation failed.") + } catch is CancellationError {} + _ = try await evaluate("window.retainedAutomationState='kept'; true") + store.select(alternate) + try await waitForStore(store) { store.canEditThread && store.selectedID == alternate } + store.onChange = { panel.refresh(active: true) } + panel.refresh(active: true) + guard try await evaluate("window.retainedAutomationState") == .string("kept"), + store.selectedID == alternate + else { throw T3Error("Background action discarded state or selected its conversation.") } + let beforePopup = try await connection.call( + "preview.list", payload: .object(["threadId": .string(threadID)])) + let originalTabs = Set(beforePopup["sessions"].array.compactMap { $0["tabId"].string }) + _ = try await action("click", ["selector": .string("#popup")]) + var popupID: String? + for _ in 0..<100 { + let afterPopup = try await connection.call( + "preview.list", payload: .object(["threadId": .string(threadID)])) + popupID = afterPopup["sessions"].array.compactMap { $0["tabId"].string }.first { + !originalTabs.contains($0) + } + if popupID != nil { break } + try await Task.sleep(for: .milliseconds(20)) + } + guard let popupID, store.selectedID == alternate else { + throw T3Error("Background popup was lost or changed the selected conversation.") + } + _ = try await connection.call( + "preview.close", payload: .object(["threadId": .string(threadID), "tabId": .string(popupID)])) + print( + "Browser automation: strict selectors, editable targets, timeout, cancellation and conversation isolation" + ) + if let file = Arguments.value("--browser-broker-fixture") { + try await browserAutomationBroker( + panel: panel, settings: ConnectionSettings.load(URL(fileURLWithPath: file)), + threadID: threadID, tabID: tabID!) + } + let focusedWindow = NSApp.keyWindow + let revealed = try await action("open", ["open": .bool(true)]) + guard revealed["visible"].bool, NSApp.keyWindow === focusedWindow, store.selectedID == alternate + else { + throw T3Error("Floating preview was hidden or took focus from the selected conversation.") + } + print("Browser automation: floating preview reveals without taking keyboard focus") + var downloadPrompted = false + panel.downloads.chooseDestination = { _, _ in + downloadPrompted = true + return nil + } + _ = try await action("click", ["selector": .string("#download")]) + let downloadDeadline = Date().addingTimeInterval(10) + while panel.downloads.items.isEmpty || panel.downloads.activeCount > 0 { + guard Date() < downloadDeadline else { + throw T3Error("Agent download did not finish in the browser panel.") + } + try await Task.sleep(for: .milliseconds(25)) + } + guard !downloadPrompted, let item = panel.downloads.items.first, item.state == .finished, + let file = item.file, FileManager.default.fileExists(atPath: file.path) + else { + throw T3Error("Agent action opened Save or failed to save its browser download.") + } + print("Browser automation: page controller routes downloads to an automatic destination") + _ = try await connection.call( + "preview.close", payload: .object(["threadId": .string(threadID), "tabId": .string(tabID!)])) + store.select(threadID) + try await waitForStore(store) { store.canEditThread && store.selectedID == threadID } + store.onChange = { panel.refresh(active: true) } + panel.refresh(active: true) + } + + @MainActor private static func browserAutomationBroker( + panel: BrowserPanel, settings: ConnectionSettings, + threadID: String, tabID: String + ) async throws { + guard BrowserURL.loopback(settings.endpoint.host ?? "") else { + throw T3Error("Broker fixture must run on loopback.") + } + let connection = RPCConnection(settings: settings) + let host = BrowserAutomationHost() + host.handle = { try await panel.automate($0, deadline: $1) } + host.liveTabs = { + [.object(["threadId": .string(threadID), "tabId": .string(tabID), "visible": .bool(false)])] + } + defer { + host.synchronize(connection: nil, environmentID: nil) + Task { await connection.close() } + } + func wait(_ condition: () -> Bool) async throws { + let end = Date().addingTimeInterval(5) + while !condition() { + if let error = host.error { throw T3Error(error) } + guard Date() < end else { + throw T3Error("Broker fixture host did not reach the expected state.") + } + try await Task.sleep(for: .milliseconds(20)) + } + } + func invoke(_ operation: String, _ input: [String: JSON] = [:], timeout: Int = 5000) + async throws -> JSON + { + var request = settings.request("/invoke", method: "POST") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSON.object([ + "threadId": .string(threadID), "tabId": .string(tabID), + "operation": .string(operation), "input": .object(input), + "timeoutMs": .number(Double(timeout)), + ]).data() + let result = try await EnvironmentHTTP.read(request) + guard result["ok"].bool else { + throw BrowserAutomationError( + result["error"]["_tag"].string ?? "BrokerError", + result["error"]["message"].string ?? "Broker request failed.") + } + return result["result"] + } + try await connection.open() + host.synchronize(connection: connection, environmentID: "native-browser-fixture") + let end = Date().addingTimeInterval(5) + var initial = JSON.null + repeat { + initial = try await EnvironmentHTTP.read(settings.request("/status")) + if initial["focusCount"].int ?? 0 > 0 { break } + guard Date() < end else { throw T3Error("The agent host did not register with the broker.") } + try await Task.sleep(for: .milliseconds(20)) + } while true + let status = try await invoke("status") + guard status["tabId"].string == tabID else { + throw T3Error("Broker routed the wrong browser tab.") + } + _ = try await invoke( + "type", + [ + "selector": .string("#draft"), "text": .string("routed through broker"), + "clear": .bool(true), + ]) + guard + try await invoke( + "evaluate", ["expression": .string("document.querySelector('#draft').value")]) + == .string("routed through broker") + else { throw T3Error("Broker response did not contain the real page result.") } + let first = Task { + try await invoke( + "evaluate", + [ + "expression": .string( + "new Promise(resolve => setTimeout(() => { window.nativeOrder=[1]; resolve(true); }, 200))" + ) + ]) + } + try await wait { host.running == 1 } + let second = Task { + try await invoke( + "evaluate", ["expression": .string("window.nativeOrder.push(2); window.nativeOrder")]) + } + _ = try await first.value + guard try await second.value == .array([.number(1), .number(2)]) else { + throw T3Error("Broker actions ran out of order.") + } + let active = Task { try await invoke("waitFor", ["text": .string("missing active target")]) } + try await wait { host.running == 1 } + let queued = Task { + try await invoke("evaluate", ["expression": .string("window.shouldNeverExecute=true")]) + } + try await wait { host.running == 2 } + host.interrupt(threadID: threadID) + for task in [active, queued] { + do { + _ = try await task.value + throw T3Error("Interrupted browser action completed successfully.") + } catch let error as BrowserAutomationError { + guard error.tag == "PreviewAutomationControlInterruptedError" else { throw error } + } + } + host.setPaused(true) + do { + _ = try await invoke("status") + throw T3Error("Paused browser accepted an agent action.") + } catch let error as BrowserAutomationError { + guard error.tag == "PreviewAutomationControlInterruptedError" else { throw error } + } + host.setPaused(false) + do { + _ = try await invoke( + "evaluate", ["expression": .string("new Promise(() => {})")], timeout: 1000) + throw T3Error("Unresolved page promise did not time out through the broker.") + } catch let error as BrowserAutomationError { + guard error.tag == "PreviewAutomationTimeoutError" else { throw error } + } + guard + try await invoke("evaluate", ["expression": .string("window.shouldNeverExecute ?? false")]) + == .bool(false) + else { throw T3Error("A cancelled queued action reached the page.") } + host.retry() + var ready = false + for _ in 0..<100 { + let state = try await EnvironmentHTTP.read(settings.request("/status")) + if state["focusCount"].int ?? 0 > initial["focusCount"].int ?? 0, state["connected"].int == 1 + { + ready = true + break + } + try await Task.sleep(for: .milliseconds(20)) + } + guard ready, try await invoke("status")["tabId"].string == tabID else { + throw T3Error("Agent browser reconnect failed.") + } + print( + "Browser automation: real broker registration, routing, responses, ordered actions, active/queued cancellation, pause/resume and reconnect" + ) + } +} diff --git a/apps/macos/Sources/T3Native/BrowserAutomationSurface.swift b/apps/macos/Sources/T3Native/BrowserAutomationSurface.swift new file mode 100644 index 0000000000000..51568ee4b10db --- /dev/null +++ b/apps/macos/Sources/T3Native/BrowserAutomationSurface.swift @@ -0,0 +1,55 @@ +import AppKit +import T3Core +import WebKit + +@MainActor final class BrowserAutomationSurface: NSObject, NSWindowDelegate { + var onStop: (() -> Void)? + let window: NSPanel + let viewport = BrowserViewportView() + private weak var page: BrowserPage? + override init() { + window = NSPanel( + contentRect: NSRect(x: 80, y: 80, width: 1000, height: 720), + styleMask: [.titled, .closable, .resizable, .nonactivatingPanel], backing: .buffered, + defer: false) + super.init() + window.delegate = self + window.isReleasedWhenClosed = false + window.hidesOnDeactivate = false + window.level = .floating + window.becomesKeyOnlyIfNeeded = true + window.contentView = viewport + window.minSize = NSSize(width: 360, height: 300) + window.collectionBehavior = [.fullScreenAuxiliary] + window.isMovableByWindowBackground = true + let accessory = NSTitlebarAccessoryViewController() + accessory.layoutAttribute = .right + let stop = NSButton(title: "Stop agent", target: self, action: #selector(stopAgent)) + stop.bezelStyle = .inline + stop.frame = NSRect(x: 0, y: 2, width: 90, height: 24) + accessory.view = NSView(frame: NSRect(x: 0, y: 0, width: 98, height: 28)) + accessory.view.addSubview(stop) + window.addTitlebarAccessoryViewController(accessory) + } + func attach(_ page: BrowserPage, show: Bool, title: String) { + self.page = page + viewport.setting = (try? BrowserViewport(page.snapshot.viewport)) ?? .fill + viewport.show(page.web) + window.title = title + viewport.layoutSubtreeIfNeeded() + if show { window.orderFront(nil) } + } + func windowShouldClose(_ sender: NSWindow) -> Bool { + page?.recording.cancel() + page?.automation.interrupt() + onStop?() + return true + } + @objc private func stopAgent() { window.performClose(nil) } + func close() { + page?.recording.cancel() + page = nil + viewport.show(nil) + window.close() + } +} diff --git a/apps/macos/Sources/T3Native/BrowserCapture.swift b/apps/macos/Sources/T3Native/BrowserCapture.swift new file mode 100644 index 0000000000000..9e90e8e244b87 --- /dev/null +++ b/apps/macos/Sources/T3Native/BrowserCapture.swift @@ -0,0 +1,28 @@ +import AppKit +import T3Core +import WebKit + +@MainActor enum BrowserCapture { + static func image( + web: WKWebView, cursor: BrowserAgentCursor, mouse: Bool = true, + keys: Bool = true, deterministic: Bool = true + ) async throws -> NSImage { + guard web.bounds.width > 0, web.bounds.height > 0 else { + throw T3Error("Browser viewport is empty.") + } + let configuration = WKSnapshotConfiguration() + configuration.snapshotWidth = min(1_600, web.bounds.width) as NSNumber + let image = try await web.takeSnapshot(configuration: configuration) + let composed = NSImage(size: image.size, flipped: true) { rect in + image.draw( + in: rect, from: .zero, operation: .copy, fraction: 1, respectFlipped: true, hints: nil) + cursor.drawCapture(size: rect.size, mouse: mouse, keys: keys, deterministic: deterministic) + return true + } + // Materialize now; later rendering must not pick up another cursor position. + guard let data = composed.tiffRepresentation, let frozen = NSImage(data: data) else { + throw T3Error("Could not compose the browser screenshot.") + } + return frozen + } +} diff --git a/apps/macos/Sources/T3Native/BrowserChromiumController.swift b/apps/macos/Sources/T3Native/BrowserChromiumController.swift new file mode 100644 index 0000000000000..088a4336513f6 --- /dev/null +++ b/apps/macos/Sources/T3Native/BrowserChromiumController.swift @@ -0,0 +1,350 @@ +import AppKit +import Combine +import Security +import T3Core + +@MainActor final class BrowserChromiumController: ObservableObject { + @Published var enabled: Bool { + didSet { + UserDefaults.standard.set(enabled, forKey: "T3NativeChromiumBrowserEnabled") + if !enabled { stop() } + onChange?() + } + } + @Published private(set) var state = "Stopped" + @Published private(set) var error: String? + @Published private(set) var extensions: [JSON] = [] + @Published var nativeRecordingActive = false + var environmentID: String? + @Published private(set) var downloads: [String: JSON] = [:] + var activeDownloadCount: Int { + downloads.values.filter { $0["state"].string == "downloading" }.count + } + private(set) var pages: [String: JSON] = [:] + var onEvent: ((String, JSON) -> Void)? + var onChange: (() -> Void)? + private var process: Process? + private var input: FileHandle? + private var output: FileHandle? + private var diagnostics: FileHandle? + private var token = "" + private var generation = UUID() + private var buffer = Data() + private var pending: [String: CheckedContinuation] = [:] + private var timeouts: [String: Task] = [:] + var available: Bool { FileManager.default.isExecutableFile(atPath: executable.path) } + var running: Bool { process?.isRunning == true } + var passwordStatus: String { + guard + let installed = extensions.first(where: { + $0["id"].string == "aeblfdkhhhdcdjpifhhbdiojplfjncoa" + }) + else { + return "1Password extension is not installed" + } + if let failure = installed["loadError"].string { return "1Password: " + failure } + return installed["enabled"].bool + ? "1Password extension installed" : "1Password extension disabled" + } + private var executable: URL { + Bundle.main.bundleURL.appendingPathComponent( + "Contents/Resources/chromium-helper/T3 Browser.app/Contents/MacOS/Electron") + } + init() { enabled = UserDefaults.standard.bool(forKey: "T3NativeChromiumBrowserEnabled") } + isolated deinit { + output?.readabilityHandler = nil + diagnostics?.readabilityHandler = nil + try? input?.close() + if process?.isRunning == true { process?.terminate() } + for task in timeouts.values { task.cancel() } + } + static func pageKey(environment: String, thread: String, tab: String) -> String { + [environment, thread, tab].joined(separator: "\n") + } + func owns(environment: String, thread: String, tab: String) -> Bool { + pages[Self.pageKey(environment: environment, thread: thread, tab: tab)] != nil + } + func context(environment: String, thread: String, tab: String) -> [String: JSON] { + ["environmentID": .string(environment), "threadId": .string(thread), "tabId": .string(tab)] + } + private func launch() throws { + guard !running else { return } + guard available else { + throw T3Error( + "The packaged Chromium helper is unavailable. Build T3 Browser with the native app first.") + } + #if !arch(arm64) + throw T3Error("T3 Browser requires Apple Silicon.") + #endif + var bytes = [UInt8](repeating: 0, count: 32) + guard SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) == errSecSuccess else { + throw T3Error("Could not authenticate the browser helper.") + } + token = bytes.map { String(format: "%02x", $0) }.joined() + let owner = UUID() + generation = owner + buffer = Data() + let process = Process() + let stdin = Pipe() + let stdout = Pipe() + let stderr = Pipe() + process.executableURL = executable + var environment = ProcessInfo.processInfo.environment + environment.removeValue(forKey: "ELECTRON_RUN_AS_NODE") + environment.removeValue(forKey: "NODE_OPTIONS") + environment["T3_CHROMIUM_TOKEN"] = token + environment["T3_CHROMIUM_STATE"] = + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/T3Native/Chromium").path + process.environment = environment + process.standardInput = stdin + process.standardOutput = stdout + process.standardError = stderr + stdout.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + Task { @MainActor [weak self] in self?.receive(data, generation: owner) } + } + // Electron and extension diagnostics never enter the credential-bearing wire. + stderr.fileHandleForReading.readabilityHandler = { handle in _ = handle.availableData } + process.terminationHandler = { [weak self] _ in + Task { @MainActor [weak self] in + guard let self, self.generation == owner else { return } + self.stop() + } + } + self.process = process + input = stdin.fileHandleForWriting + output = stdout.fileHandleForReading + diagnostics = stderr.fileHandleForReading + do { + try process.run() + state = "Starting Chromium" + error = nil + onChange?() + } catch { + stop() + throw error + } + } + func request(_ method: String, input payload: JSON = .object([:]), milliseconds: Int = 60_000) + async throws -> JSON + { + try Task.checkCancellation() + try launch() + let id = UUID().uuidString + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + pending[id] = continuation + timeouts[id] = Task { [weak self] in + do { try await Task.sleep(for: .milliseconds(max(1, min(120_000, milliseconds)))) } catch + { return } + guard let self, let pending = self.pending.removeValue(forKey: id) else { return } + self.timeouts.removeValue(forKey: id) + self.interrupt(payload) + pending.resume( + throwing: BrowserAutomationError( + "PreviewAutomationTimeoutError", "Chromium operation timed out.")) + } + do { try write(id: id, method: method, payload: payload) } catch { + pending.removeValue(forKey: id)?.resume(throwing: error) + timeouts.removeValue(forKey: id)?.cancel() + } + } + } onCancel: { + Task { @MainActor [weak self] in + guard let self else { return } + self.pending.removeValue(forKey: id)?.resume(throwing: CancellationError()) + self.timeouts.removeValue(forKey: id)?.cancel() + self.interrupt(payload) + } + } + } + private func write(id: String, method: String, payload: JSON) throws { + let message = JSON.object([ + "token": .string(token), "id": .string(id), + "method": .string(method), "input": payload, + ]) + var data = try message.data() + guard data.count <= 1024 * 1024 else { throw T3Error("The Chromium command is too large.") } + data.append(10) + guard let input else { throw BrowserAutomationError.interrupted } + try input.write(contentsOf: data) + } + private func interrupt(_ payload: JSON) { + guard payload["tabId"].string != nil else { return } + try? write(id: UUID().uuidString, method: "stop", payload: payload) + } + private func receive(_ data: Data, generation owner: UUID) { + guard generation == owner, !data.isEmpty else { return } + buffer.append(data) + guard buffer.count <= 24 * 1024 * 1024 else { + error = "Chromium exceeded its message limit." + stop() + return + } + while let end = buffer.firstIndex(of: 10) { + let line = buffer[.. Bool { + guard running else { return true } + do { + let active = try await request("downloads", milliseconds: 10_000).array + guard !active.isEmpty else { return true } + let alert = NSAlert() + alert.messageText = "Chromium downloads are still running" + alert.informativeText = "Quitting cancels \(active.count) Chromium browser download(s)." + alert.addButton(withTitle: "Keep downloading") + alert.addButton(withTitle: "Quit and cancel downloads") + let response = + if let window, window.isVisible { await alert.beginSheetModal(for: window) } else { + alert.runModal() + } + guard response == .alertSecondButtonReturn else { return false } + _ = try await request("cancelDownloads", milliseconds: 10_000) + return true + } catch { + self.error = error.localizedDescription + return !running + } + } + func removeExtension() async { + do { + extensions = try await request("removeExtension").array + error = nil + } catch { self.error = error.localizedDescription } + onChange?() + } + func clearProfile(_ profile: String, kinds: BrowserProfiles.DataKinds) async throws { + guard let environmentID else { + throw T3Error("Connect to the profile's environment before clearing Chromium data.") + } + let kind: String = + switch kinds { + case .all: "all" + case .cookies: "cookies" + case .cache: "cache" + } + _ = try await request( + "clearProfile", + input: .object([ + "environmentID": .string(environmentID), + "profileId": .string(profile), "kinds": .string(kind), + ])) + } + func install() async { + do { + extensions = try await request("install", milliseconds: 120_000).array + error = nil + } catch { self.error = error.localizedDescription } + onChange?() + } + func importSelectedProfile(window: NSWindow?) async { + let picker = NSOpenPanel() + picker.title = "Import 1Password Extension" + picker.message = + "Select the existing Chrome, Edge, Brave, Vivaldi, Opera or Arc profile containing 1Password. Only its extension package is copied; cookies and passwords are not imported." + picker.canChooseDirectories = true + picker.canChooseFiles = false + picker.allowsMultipleSelection = false + picker.directoryURL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent( + "Library/Application Support/Google/Chrome") + let result: NSApplication.ModalResponse + if let window { + result = await picker.beginSheetModal(for: window) + } else { + result = await picker.begin() + } + guard result == .OK, let file = picker.url else { return } + do { + extensions = try await request( + "import", input: .object(["profilePath": .string(file.path)]), milliseconds: 120_000 + ).array + error = nil + } catch { self.error = error.localizedDescription } + onChange?() + } + func refreshExtensions() async { + guard running else { return } + do { extensions = try await request("extensions").array } catch { + self.error = error.localizedDescription + } + } +} diff --git a/apps/macos/Sources/T3Native/BrowserDeviceToolbar.swift b/apps/macos/Sources/T3Native/BrowserDeviceToolbar.swift new file mode 100644 index 0000000000000..631340e33b7d9 --- /dev/null +++ b/apps/macos/Sources/T3Native/BrowserDeviceToolbar.swift @@ -0,0 +1,91 @@ +import AppKit +import T3Core + +@MainActor final class BrowserDeviceToolbar: NSStackView { + var onChange: ((BrowserViewport) -> Void)? + var onError: ((String) -> Void)? + private let preset = NSPopUpButton() + private let width = NSTextField() + private let height = NSTextField() + private let rotate = NSButton() + private let close = NSButton() + private var setting: BrowserViewport = .fill + + override init(frame: NSRect) { + super.init(frame: frame) + spacing = 5 + alignment = .centerY + setAccessibilityLabel("Browser device toolbar") + preset.addItem(withTitle: "Responsive") + for device in BrowserDevicePreset.all { preset.addItem(withTitle: device.name) } + preset.target = self + preset.action = #selector(selectPreset) + preset.setAccessibilityLabel("Browser device preset") + preset.widthAnchor.constraint(equalToConstant: 140).isActive = true + for (field, label) in [(width, "Viewport width"), (height, "Viewport height")] { + field.font = .monospacedDigitSystemFont(ofSize: 11, weight: .regular) + field.alignment = .center + field.target = self + field.action = #selector(applyCustom) + field.setAccessibilityLabel(label) + field.toolTip = label + " in CSS pixels. Press Return to apply." + field.widthAnchor.constraint(equalToConstant: 48).isActive = true + } + for (button, symbol, label, action) in [ + (rotate, "rotate.right", "Rotate viewport", #selector(rotateViewport)), + (close, "xmark", "Close device toolbar", #selector(fillPanel)), + ] { + button.image = NSImage(systemSymbolName: symbol, accessibilityDescription: label) + button.bezelStyle = .inline + button.isBordered = false + button.target = self + button.action = action + button.toolTip = label + button.setAccessibilityLabel(label) + button.widthAnchor.constraint(equalToConstant: 24).isActive = true + } + for child in [preset, width, NSTextField(labelWithString: "×"), height, rotate, close] { + addArrangedSubview(child) + } + heightAnchor.constraint(equalToConstant: 28).isActive = true + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func render(_ next: BrowserViewport, enabled: Bool) { + let changed = setting != next + setting = next + isHidden = next.width == nil + if changed || width.currentEditor() == nil { + width.stringValue = next.width.map(String.init) ?? "" + } + if changed || height.currentEditor() == nil { + height.stringValue = next.height.map(String.init) ?? "" + } + preset.selectItem( + at: next.presetID.flatMap { id in + BrowserDevicePreset.all.firstIndex { $0.id == id }.map { $0 + 1 } + } ?? 0) + for control in [preset, width, height, rotate, close] { control.isEnabled = enabled } + } + + @objc private func selectPreset() { + let index = preset.indexOfSelectedItem - 1 + if BrowserDevicePreset.all.indices.contains(index) { + onChange?(BrowserDevicePreset.all[index].viewport) + } else if let width = setting.width, let height = setting.height, + let next = try? BrowserViewport(width: width, height: height) + { + onChange?(next) + } + } + @objc private func applyCustom() { + do { + guard let width = Int(width.stringValue), let height = Int(height.stringValue) else { + throw T3Error("Enter whole numbers for the viewport width and height.") + } + onChange?(try BrowserViewport(width: width, height: height)) + } catch { onError?(error.localizedDescription) } + } + @objc private func rotateViewport() { onChange?(setting.rotated) } + @objc private func fillPanel() { onChange?(.fill) } +} diff --git a/apps/macos/Sources/T3Native/BrowserDownloadSmoke.swift b/apps/macos/Sources/T3Native/BrowserDownloadSmoke.swift new file mode 100644 index 0000000000000..72ea7c752a4c8 --- /dev/null +++ b/apps/macos/Sources/T3Native/BrowserDownloadSmoke.swift @@ -0,0 +1,155 @@ +import AppKit +import T3Core +import WebKit + +extension ParitySmoke { + @MainActor static func browserDownloads(page: BrowserPage) async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + "t3-download-smoke-" + UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let downloads = BrowserDownloads(agentDirectory: directory.appendingPathComponent("agent")) + page.onDownload = { downloads.receive($0, agent: $1, window: $2) } + let target = directory.appendingPathComponent("chosen.txt") + var savePrompts = 0 + var selected: URL? = target + downloads.chooseDestination = { _, _ in + savePrompts += 1 + return selected + } + defer { + page.onDownload = nil + try? FileManager.default.removeItem(at: directory) + } + func wait(_ condition: () -> Bool) async throws { + let end = Date().addingTimeInterval(10) + while !condition() { + guard Date() < end else { + throw T3Error( + "Browser download timed out: " + + downloads.items.map { "\($0.state): \($0.error ?? "")" }.joined(separator: "; ")) + } + try await Task.sleep(for: .milliseconds(25)) + } + } + func click(_ selector: String) async throws { + _ = try await page.automation.run( + "click", input: .object(["selector": .string(selector)]), + deadline: BrowserAutomationDeadline(milliseconds: 5000)) + } + func completed(_ count: Int) async throws -> BrowserDownloadItem { + try await wait { downloads.items.count == count && downloads.activeCount == 0 } + guard let item = downloads.items.first, item.state == .finished, item.file != nil else { + throw T3Error( + "Browser download failed: " + (downloads.items.first?.error ?? "No completed file")) + } + return item + } + let cookie = HTTPCookie(properties: [ + .domain: "localhost", .path: "/", .name: "downloadSmoke", .value: "signed-in", + ])! + await page.web.configuration.websiteDataStore.httpCookieStore.setCookie(cookie) + page.agentDriven = true + try await click("#download") + let first = try await completed(1) + let receivedCookie = + try first.file.map { + try String(contentsOf: $0, encoding: .utf8).contains("downloadSmoke=signed-in") + } ?? false + guard savePrompts == 0, let firstFile = first.file, + receivedCookie, page.failure == nil + else { + throw T3Error( + "Agent download mismatch: cookie=\(receivedCookie), prompts=\(savePrompts), navigation=\(page.failure ?? "none")." + ) + } + try await click("#blob-download") + let blob = try await completed(2) + guard let blobFile = blob.file, blobFile != firstFile, + try String(contentsOf: blobFile, encoding: .utf8) == "native blob download" + else { + throw T3Error("Blob download lost its bytes or replaced another download.") + } + try await click("#redirect-download") + _ = try await completed(3) + print( + "Browser downloads: cookies, HTTP redirects, blob exports and unique automatic agent files") + + let monitor = BrowserInputMonitor() + monitor.register(page) + let pending = Task { + try await page.automation.run( + "evaluate", + input: .object(["expression": .string("window.takeoverReady=true; new Promise(() => {})")]), + deadline: BrowserAutomationDeadline(milliseconds: 10_000)) + } + let end = Date().addingTimeInterval(5) + while try await page.web.evaluateJavaScript("window.takeoverReady === true") as? Bool != true { + guard Date() < end else { throw T3Error("Takeover action did not reach the page.") } + try await Task.sleep(for: .milliseconds(25)) + } + guard let window = page.web.window else { + throw T3Error("No browser window for input routing.") + } + window.makeFirstResponder(page.web) + let event = NSEvent.keyEvent( + with: .keyDown, location: .zero, modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: window.windowNumber, + context: nil, characters: "a", charactersIgnoringModifiers: "a", isARepeat: false, keyCode: 0)! + monitor.receive(event) + do { + _ = try await pending.value + throw T3Error("Human input did not interrupt the pending page action.") + } catch let error as BrowserAutomationError { + guard error.tag == "PreviewAutomationControlInterruptedError", !page.agentDriven else { + throw error + } + } + try Data("original file".utf8).write(to: target) + try await click("#download") + let manual = try await completed(4) + guard savePrompts == 1, manual.file == target, manual.name == target.lastPathComponent, + try String(contentsOf: target, encoding: .utf8).hasPrefix("native browser download") + else { + throw T3Error("Human takeover did not restore the chosen download destination.") + } + selected = nil + try await click("#download") + try await wait { downloads.items.count == 5 && downloads.activeCount == 0 } + guard downloads.items.first?.state == .cancelled, savePrompts == 2 else { + throw T3Error("Cancelling Save did not cancel the download.") + } + print("Browser downloads: human input interrupts agent work and restores manual Save or Cancel") + + selected = target + try Data("keep this file".utf8).write(to: target) + try await click("#slow-download") + try await wait { downloads.items.count == 6 && downloads.items[0].received > 0 } + guard try String(contentsOf: target, encoding: .utf8) == "keep this file" else { + throw T3Error("An unfinished download replaced the existing file.") + } + downloads.cancel(downloads.items[0].id) + try await wait { downloads.activeCount == 0 } + guard downloads.items[0].state == .cancelled, + try String(contentsOf: target, encoding: .utf8) == "keep this file" + else { throw T3Error("Cancellation lost the existing file.") } + try await click("#failed-download") + try await wait { downloads.items.count == 7 && downloads.activeCount == 0 } + guard downloads.items[0].state == .failed, + try String(contentsOf: target, encoding: .utf8) == "keep this file", + try !FileManager.default.contentsOfDirectory(atPath: directory.path).contains(where: { + $0.hasPrefix(".t3-download-") + }) + else { + throw T3Error("Failed download lost an existing file or left a partial file behind.") + } + page.agentDriven = true + try await click("#slow-download") + try await wait { downloads.items.count == 8 && downloads.items[0].received > 0 } + page.dispose() + page.web.removeFromSuperview() + _ = try await completed(8) + print( + "Browser downloads: progress, cancellation, failure cleanup, safe replacement and tab-close survival" + ) + } +} diff --git a/apps/macos/Sources/T3Native/BrowserDownloads.swift b/apps/macos/Sources/T3Native/BrowserDownloads.swift new file mode 100644 index 0000000000000..b8381b08277cb --- /dev/null +++ b/apps/macos/Sources/T3Native/BrowserDownloads.swift @@ -0,0 +1,296 @@ +import AppKit +import Combine +import T3Core +import WebKit + +struct BrowserDownloadFile { + let destination: URL + let directory: URL + let partial: URL + let replaceExisting: Bool + init(destination: URL) throws { + self.destination = destination + replaceExisting = FileManager.default.fileExists(atPath: destination.path) + directory = destination.deletingLastPathComponent().appendingPathComponent( + ".t3-download-" + UUID().uuidString) + partial = directory.appendingPathComponent(destination.lastPathComponent) + try FileManager.default.createDirectory( + at: directory, withIntermediateDirectories: false, attributes: [.posixPermissions: 0o700]) + } + func finish() throws { + if replaceExisting && FileManager.default.fileExists(atPath: destination.path) { + _ = try FileManager.default.replaceItemAt(destination, withItemAt: partial) + } else { + try FileManager.default.moveItem(at: partial, to: destination) + } + discard() + } + func discard() { try? FileManager.default.removeItem(at: directory) } + static func name(_ suggestion: String) -> String { + let component = + suggestion.replacingOccurrences(of: "\\", with: "/").split(separator: "/").last.map( + String.init) ?? "download" + let clean = component.unicodeScalars.filter { !CharacterSet.controlCharacters.contains($0) } + let name = String(String.UnicodeScalarView(clean)).trimmingCharacters( + in: .whitespacesAndNewlines) + if name.isEmpty || name == "." || name == ".." { return "download" } + // Leave room for the unique prefix and multibyte filenames on APFS. + var result = "" + for character in name { + if result.utf8.count + String(character).utf8.count > 180 { break } + result.append(character) + } + return result.isEmpty ? "download" : result + } +} + +struct BrowserDownloadItem: Identifiable { + enum State { case choosing, downloading, finished, cancelled, failed } + let id: UUID + var name = "Download" + var state: State = .choosing + var received: Int64 = 0 + var total: Int64 = 0 + var file: URL? + var error: String? +} + +@MainActor final class BrowserDownloads: NSObject, ObservableObject, WKDownloadDelegate { + @Published private(set) var items: [BrowserDownloadItem] = [] + var onChange: (() -> Void)? + let agentDirectory: URL + private final class Transfer { + let id = UUID() + let download: WKDownload + let agent: Bool + weak var window: NSWindow? + var file: BrowserDownloadFile? + var cancelled = false + init(_ download: WKDownload, agent: Bool, window: NSWindow?) { + self.download = download + self.agent = agent + self.window = window + } + } + private var transfers: [ObjectIdentifier: Transfer] = [:] + private var progressTask: Task? + private var prompt: (id: UUID, panel: NSSavePanel)? + // Injected by the local WebKit smoke; normal use always shows the native save panel. + var chooseDestination: ((String, NSWindow?) async -> URL?)? + var activeCount: Int { transfers.count } + init(agentDirectory: URL? = nil) { + self.agentDirectory = + agentDirectory + ?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("T3Native/BrowserDownloads") + super.init() + } + isolated deinit { + progressTask?.cancel() + for transfer in transfers.values { + transfer.download.cancel(nil) + transfer.file?.discard() + } + } + func receive(_ download: WKDownload, agent: Bool, window: NSWindow?) { + guard transfers.count < 8 else { + download.cancel(nil) + items.insert( + BrowserDownloadItem( + id: UUID(), state: .failed, + error: "Eight downloads are already running. Try this download again after one finishes."), + at: 0) + trimHistory() + onChange?() + return + } + let transfer = Transfer(download, agent: agent, window: window) + transfers[ObjectIdentifier(download)] = transfer + items.insert(BrowserDownloadItem(id: transfer.id), at: 0) + trimHistory() + download.delegate = self + onChange?() + startProgress() + } + private func trimHistory() { + let active = Set(transfers.values.map(\.id)) + while items.count > 100, let index = items.lastIndex(where: { !active.contains($0.id) }) { + items.remove(at: index) + } + } + private func update(_ id: UUID, _ change: (inout BrowserDownloadItem) -> Void) { + guard let index = items.firstIndex(where: { $0.id == id }) else { return } + change(&items[index]) + } + private func startProgress() { + guard progressTask == nil else { return } + progressTask = Task { [weak self] in + while !Task.isCancelled { + do { try await Task.sleep(for: .milliseconds(250)) } catch { break } + guard let self else { break } + var progressByID: [UUID: (received: Int64, total: Int64)] = [:] + for transfer in transfers.values { + let progress = transfer.download.progress + progressByID[transfer.id] = (progress.completedUnitCount, progress.totalUnitCount) + } + var next = items + var changed = false + for index in next.indices { + guard next[index].state == .downloading, let progress = progressByID[next[index].id], + next[index].received != progress.received || next[index].total != progress.total + else { continue } + next[index].received = progress.received + next[index].total = progress.total + changed = true + } + if changed { items = next } + if transfers.isEmpty { + progressTask = nil + break + } + } + } + } + func download( + _ download: WKDownload, decideDestinationUsing response: URLResponse, suggestedFilename: String + ) async -> URL? { + guard let transfer = transfers[ObjectIdentifier(download)] else { return nil } + let name = BrowserDownloadFile.name(suggestedFilename) + update(transfer.id) { $0.name = name } + do { + let destination: URL? + if transfer.agent { + try FileManager.default.createDirectory( + at: agentDirectory, withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700]) + destination = agentDirectory.appendingPathComponent(transfer.id.uuidString + "-" + name) + } else if let chooseDestination { + destination = await chooseDestination(name, transfer.window) + } else { + while prompt != nil && !transfer.cancelled { try await Task.sleep(for: .milliseconds(50)) } + guard !transfer.cancelled else { return nil } + let panel = NSSavePanel() + panel.nameFieldStringValue = name + panel.canCreateDirectories = true + panel.message = "Save browser download" + prompt = (transfer.id, panel) + let result: NSApplication.ModalResponse + if let window = transfer.window, window.isVisible { + result = await panel.beginSheetModal(for: window) + } else { + result = await panel.begin() + } + prompt = nil + destination = result == .OK ? panel.url : nil + } + guard !transfer.cancelled, transfers[ObjectIdentifier(download)] === transfer else { + return nil + } + guard let destination else { + end(transfer, state: .cancelled) + return nil + } + let file = try BrowserDownloadFile(destination: destination) + transfer.file = file + update(transfer.id) { + if !transfer.agent { $0.name = destination.lastPathComponent } + $0.state = .downloading + $0.total = response.expectedContentLength + } + return file.partial + } catch { + end(transfer, state: .failed, error: error.localizedDescription) + return nil + } + } + func downloadDidFinish(_ download: WKDownload) { + guard let transfer = transfers[ObjectIdentifier(download)], let file = transfer.file else { + return + } + if transfer.cancelled { + end(transfer, state: .cancelled) + return + } + do { + try file.finish() + end(transfer, state: .finished, file: file.destination) + } catch { + // Keep a completed download if its chosen folder becomes unavailable or a new + // file appears there. Never discard its bytes or overwrite an unapproved file. + transfer.file = nil + end( + transfer, state: .failed, + error: "Could not save to the chosen location: " + error.localizedDescription, + file: file.partial) + } + } + func download(_ download: WKDownload, respondTo challenge: URLAuthenticationChallenge) async -> ( + URLSession.AuthChallengeDisposition, URLCredential? + ) { + (.performDefaultHandling, nil) + } + func download(_ download: WKDownload, didFailWithError error: any Error, resumeData: Data?) { + guard let transfer = transfers[ObjectIdentifier(download)] else { return } + end( + transfer, state: transfer.cancelled ? .cancelled : .failed, + error: transfer.cancelled ? nil : error.localizedDescription) + } + private func end( + _ transfer: Transfer, state: BrowserDownloadItem.State, error: String? = nil, file: URL? = nil + ) { + guard transfers.removeValue(forKey: ObjectIdentifier(transfer.download)) != nil else { return } + transfer.file?.discard() + update(transfer.id) { + $0.state = state + $0.error = error + $0.file = file + } + onChange?() + } + func cancel(_ id: UUID) { + guard let transfer = transfers.values.first(where: { $0.id == id }), !transfer.cancelled else { + return + } + transfer.cancelled = true + if prompt?.id == id { prompt?.panel.cancel(nil) } + transfer.download.cancel { [weak self] _ in self?.end(transfer, state: .cancelled) } + } + func clearFinished() { + let active = Set(transfers.values.map(\.id)) + items.removeAll { !active.contains($0.id) } + onChange?() + } + func reveal(_ item: BrowserDownloadItem) { + guard let file = item.file else { return } + guard FileManager.default.fileExists(atPath: file.path) else { + update(item.id) { $0.error = "This file has been moved or deleted." } + return + } + NSWorkspace.shared.activateFileViewerSelecting([file]) + } + func revealAgentFolder() { + do { + try FileManager.default.createDirectory( + at: agentDirectory, withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700]) + NSWorkspace.shared.open(agentDirectory) + } catch { NSAlert(error: error).runModal() } + } + func finishBeforeQuit(window: NSWindow?) async -> Bool { + guard activeCount > 0 else { return true } + let alert = NSAlert() + alert.messageText = "Downloads are still running" + alert.informativeText = "Quitting cancels \(activeCount) browser download(s)." + alert.addButton(withTitle: "Keep downloading") + alert.addButton(withTitle: "Quit and cancel downloads") + let result = + if let window, window.isVisible { await alert.beginSheetModal(for: window) } else { + alert.runModal() + } + guard result == .alertSecondButtonReturn else { return false } + for id in transfers.values.map(\.id) { cancel(id) } + let end = Date().addingTimeInterval(5) + while !transfers.isEmpty && Date() < end { try? await Task.sleep(for: .milliseconds(25)) } + return true + } +} diff --git a/apps/macos/Sources/T3Native/BrowserDownloadsWindow.swift b/apps/macos/Sources/T3Native/BrowserDownloadsWindow.swift new file mode 100644 index 0000000000000..2e40715272439 --- /dev/null +++ b/apps/macos/Sources/T3Native/BrowserDownloadsWindow.swift @@ -0,0 +1,74 @@ +import AppKit +import SwiftUI + +@MainActor final class BrowserDownloadsWindow: NSWindowController { + init(downloads: BrowserDownloads) { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 540, height: 420), + styleMask: [.titled, .closable, .resizable], backing: .buffered, defer: false) + super.init(window: window) + window.title = "Browser Downloads" + window.isReleasedWhenClosed = false + window.minSize = NSSize(width: 440, height: 300) + window.contentView = NSHostingView(rootView: BrowserDownloadsView(downloads: downloads)) + window.center() + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } +} + +private struct BrowserDownloadsView: View { + @ObservedObject var downloads: BrowserDownloads + var body: some View { + VStack(spacing: 0) { + HStack { + Button("Agent downloads folder", action: downloads.revealAgentFolder) + Spacer() + Button("Clear finished", action: downloads.clearFinished).disabled(downloads.items.isEmpty) + }.padding(16) + Divider() + if downloads.items.isEmpty { + ContentUnavailableView( + "No downloads yet", systemImage: "arrow.down.circle", + description: Text( + "Downloads from this session appear here. Agent downloads are saved automatically.")) + } else { + List(downloads.items) { item in + HStack { + VStack(alignment: .leading, spacing: 6) { + Text(item.name).lineLimit(1).truncationMode(.middle) + Text(status(item)).font(.caption).foregroundStyle(.secondary) + if item.state == .downloading, item.total > 0 { + ProgressView(value: min(1, Double(item.received) / Double(item.total))) + } + if let error = item.error { + Text(error).font(.caption).foregroundStyle(.orange).textSelection(.enabled) + } + } + Spacer() + if item.state == .choosing || item.state == .downloading { + Button("Cancel") { downloads.cancel(item.id) } + } else if item.file != nil { + Button("Show in Finder") { downloads.reveal(item) } + } + }.padding(.vertical, 6) + } + } + } + } + private func status(_ item: BrowserDownloadItem) -> String { + switch item.state { + case .choosing: "Waiting for a save location…" + case .downloading: + ByteCountFormatter.string(fromByteCount: item.received, countStyle: .file) + + (item.total > 0 + ? " of " + ByteCountFormatter.string(fromByteCount: item.total, countStyle: .file) + : " downloaded") + case .finished: "Downloaded" + case .cancelled: "Cancelled" + case .failed: + item.file == nil + ? "Download failed. Try the download again from the page." + : "Downloaded to a temporary file" + } + } +} diff --git a/apps/macos/Sources/T3Native/BrowserElementPicker.swift b/apps/macos/Sources/T3Native/BrowserElementPicker.swift new file mode 100644 index 0000000000000..13d7d7fd97b63 --- /dev/null +++ b/apps/macos/Sources/T3Native/BrowserElementPicker.swift @@ -0,0 +1,133 @@ +import AppKit +import T3Core +import WebKit + +@MainActor final class BrowserElementPicker { + var onChange: (() -> Void)? + var onSelection: ((JSON) -> Void)? + var onError: ((String) -> Void)? + var active: Bool { token != nil } + private var token: String? + private let web: WKWebView + private let world = WKContentWorld.world(name: "T3BrowserPicker") + private var handler: Handler? + + init(web: WKWebView) { + self.web = web + let handler = Handler() + handler.owner = self + self.handler = handler + web.configuration.userContentController.add(handler, contentWorld: world, name: "t3Element") + } + + func start() { + guard !active else { return } + let token = UUID().uuidString + self.token = token + onChange?() + Task { [weak self] in + guard let self else { return } + do { + _ = try await web.callAsyncJavaScript( + Self.script, arguments: ["token": token], in: nil, contentWorld: world) + if self.token == token { web.window?.makeFirstResponder(web) } else { await cleanup(token) } + } catch { + guard self.token == token else { return } + self.token = nil + onError?("Could not select an element: " + error.localizedDescription) + onChange?() + } + } + } + func cancel() { + guard let token else { return } + self.token = nil + onChange?() + Task { await cleanup(token) } + } + private func cleanup(_ token: String) async { + _ = try? await web.callAsyncJavaScript( + "if (globalThis.t3Picker?.token === token) globalThis.t3Picker.cancel(); return true;", + arguments: ["token": token], in: nil, contentWorld: world) + } + func dispose() { + cancel() + web.configuration.userContentController.removeScriptMessageHandler( + forName: "t3Element", contentWorld: world) + handler = nil + } + private func receive(_ message: WKScriptMessage) { + guard message.frameInfo.isMainFrame, let body = message.body as? [String: Any], + let token, body["token"] as? String == token + else { return } + self.token = nil + onChange?() + guard let element = body["element"], JSONSerialization.isValidJSONObject(element), + let data = try? JSONSerialization.data(withJSONObject: element), data.count < 64_000, + let context = try? JSON.decode(data) + else { return } + onSelection?(context) + } + @MainActor private final class Handler: NSObject, WKScriptMessageHandler { + weak var owner: BrowserElementPicker? + func userContentController( + _ userContentController: WKUserContentController, didReceive message: WKScriptMessage + ) { + owner?.receive(message) + } + } + + // A separate content world keeps the page from invoking our message handler or replacing picker helpers. + private static let script = #""" + globalThis.t3Picker?.cancel(); + const overlay = document.createElement('div'); + overlay.style.cssText = 'position:fixed;pointer-events:none;z-index:2147483647;box-sizing:border-box;border:2px solid #6ca7ff;background:#6ca7ff22;display:none;'; + document.documentElement.append(overlay); + const controller = new AbortController(); + const options = {capture:true, signal:controller.signal}; + const cleanup = () => { controller.abort(); overlay.remove(); delete globalThis.t3Picker; }; + const finish = element => { cleanup(); webkit.messageHandlers.t3Element.postMessage({token,element}); }; + globalThis.t3Picker = {token, cancel:cleanup}; + const target = event => event.composedPath().find(node => node instanceof Element && node !== overlay); + const path = element => { + const root = element.getRootNode(); + const parts = []; + for (let node = element; node instanceof Element; node = node.parentElement) { + if (node.id && root.querySelectorAll('#' + CSS.escape(node.id)).length === 1) { + parts.unshift('#' + CSS.escape(node.id)); break; + } + const siblings = node.parentElement ? [...node.parentElement.children].filter(s => s.localName === node.localName) : []; + parts.unshift(CSS.escape(node.localName) + (siblings.length > 1 ? ':nth-of-type(' + (siblings.indexOf(node) + 1) + ')' : '')); + } + return (root instanceof ShadowRoot ? path(root.host) + ' >>> ' : '') + parts.join(' > '); + }; + window.addEventListener('mousemove', event => { + const element = target(event); + if (!element) return; + const rect = element.getBoundingClientRect(); + Object.assign(overlay.style, {display:'block', left:rect.x+'px', top:rect.y+'px', width:rect.width+'px', height:rect.height+'px'}); + }, options); + for (const name of ['pointerdown','mousedown','mouseup','pointerup']) { + window.addEventListener(name, event => { event.preventDefault(); event.stopImmediatePropagation(); }, options); + } + window.addEventListener('click', event => { + event.preventDefault(); event.stopImmediatePropagation(); + if (!event.isTrusted) return; + const element = target(event); + if (!element) return; + const clone = element.cloneNode(true); + clone.querySelectorAll('script,style,input[type=hidden]').forEach(node => node.remove()); + for (const node of [clone, ...clone.querySelectorAll('input,textarea')]) { + node.removeAttribute('value'); + if (node.localName === 'textarea') node.textContent = ''; + } + finish({pageUrl:location.href.slice(0,2048), pageTitle:document.title.slice(0,512), + tagName:element.localName, selector:path(element).slice(0,2048), + htmlPreview:clone.outerHTML.slice(0,4000), pickedAt:new Date().toISOString()}); + }, options); + window.addEventListener('keydown', event => { + if (event.key === 'Escape') { event.preventDefault(); event.stopImmediatePropagation(); finish(null); } + }, options); + return true; + """# +} diff --git a/apps/macos/Sources/T3Native/BrowserInputMonitor.swift b/apps/macos/Sources/T3Native/BrowserInputMonitor.swift new file mode 100644 index 0000000000000..d5daf74dd89a6 --- /dev/null +++ b/apps/macos/Sources/T3Native/BrowserInputMonitor.swift @@ -0,0 +1,40 @@ +import AppKit +import WebKit + +// Local AppKit events are human input. Agent events are dispatched directly to the +// page's responder, so they never pass through this monitor or take system focus. +@MainActor final class BrowserInputMonitor { + private let pages = NSMapTable.weakToWeakObjects() + private var monitor: Any? + init() { + monitor = NSEvent.addLocalMonitorForEvents(matching: [ + .leftMouseDown, .rightMouseDown, .otherMouseDown, .keyDown, .scrollWheel, .magnify, .swipe, + .rotate, + ]) { [weak self] event in + self?.receive(event) + return event + } + } + isolated deinit { if let monitor { NSEvent.removeMonitor(monitor) } } + func register(_ page: BrowserPage) { pages.setObject(page, forKey: page.web) } + func receive(_ event: NSEvent) { + guard let window = event.window else { return } + var target: NSView? + if event.type == .keyDown { + target = window.firstResponder as? NSView + } else if let content = window.contentView { + target = content.hitTest( + content.superview?.convert(event.locationInWindow, from: nil) ?? event.locationInWindow) + } + while let view = target { + if let web = view as? WKWebView, let page = pages.object(forKey: web) { + page.takeControl() + if page.recording.active { + page.automation.cursor.humanInput(event, web: web) + } + return + } + target = view.superview + } + } +} diff --git a/apps/macos/Sources/T3Native/BrowserPage.swift b/apps/macos/Sources/T3Native/BrowserPage.swift new file mode 100644 index 0000000000000..313c39c3e486e --- /dev/null +++ b/apps/macos/Sources/T3Native/BrowserPage.swift @@ -0,0 +1,449 @@ +import AppKit +import CryptoKit +import T3Core +import WebKit + +@MainActor final class BrowserPage: NSObject, WKNavigationDelegate, WKUIDelegate { + let web: WKWebView + let picker: BrowserElementPicker + let automation: BrowserAutomationPage + let recording = BrowserRecording() + let tabID: String + let threadID: String + let environment: URL + var suppressAgentPresentation = false + var agentDriven = false + var activeAutomation = 0 + var onHumanInput: (() -> Void)? + var onDownload: ((WKDownload, Bool, NSWindow?) -> Void)? + var onChange: (() -> Void)? + var onNewTab: ((URL) -> Void)? + private(set) var failure: String? + private(set) var synchronizationError: String? + private(set) var snapshot: BrowserTab + private let connection: RPCConnection + private var disposed = false + private var observations: [NSKeyValueObservation] = [] + private var expectedReports: [JSON] = [] + private var pendingReport: JSON? + private var lastReport: JSON? + private var reportTask: Task? + private var serverURL: URL? + private var retryURL: URL? + private var requestedURL: URL? + private var started = false + private var expectingDownload = false + private(set) var navigationPending = false + private(set) var navigationCommitted = false + var displayedURL: URL? { failure == nil ? web.url ?? snapshot.url : retryURL ?? snapshot.url } + + init(tab: BrowserTab, environment: URL, connection: RPCConnection, dataStore: WKWebsiteDataStore) + { + snapshot = tab + tabID = tab.id + threadID = tab.threadID + self.environment = environment + self.connection = connection + let configuration = WKWebViewConfiguration() + configuration.websiteDataStore = dataStore + configuration.preferences.javaScriptCanOpenWindowsAutomatically = false + web = WKWebView(frame: .zero, configuration: configuration) + picker = BrowserElementPicker(web: web) + automation = BrowserAutomationPage(web: web) + super.init() + web.navigationDelegate = self + web.uiDelegate = self + web.allowsBackForwardNavigationGestures = true + web.isInspectable = true + for keyPath in [\WKWebView.isLoading, \WKWebView.canGoBack, \WKWebView.canGoForward] { + observations.append( + web.observe(keyPath) { [weak self] _, _ in + Task { @MainActor in self?.onChange?() } + }) + } + observations.append( + web.observe(\.title) { [weak self] _, _ in + Task { @MainActor in + guard let self else { return } + if !self.web.isLoading && self.failure == nil { self.report(kind: "Success") } + self.onChange?() + } + }) + observations.append( + web.observe(\.url) { [weak self] _, _ in + Task { @MainActor in + guard let self else { return } + if !self.web.isLoading && self.failure == nil { self.report(kind: "Success") } + self.onChange?() + } + }) + } + + func receive(_ tab: BrowserTab, navigate: Bool = false) { + snapshot = tab + if !navigate, let index = expectedReports.firstIndex(of: tab.status) { + expectedReports.removeFirst(index + 1) + serverURL = tab.url + return + } + guard navigate || !started || serverURL != tab.url else { return } + if navigate { expectedReports = [] } + started = true + serverURL = tab.url + guard let url = tab.url else { return } + do { + let resolved = try BrowserURL.resolve(url, environment: environment) + if web.url != resolved || failure != nil, !(web.isLoading && requestedURL == resolved) { + load(resolved) + } + } catch { + failure = error.localizedDescription + onChange?() + } + } + + func dispose() { + disposed = true + onChange = nil + recording.cancel() + automation.interrupt() + picker.dispose() + automation.invalidate() + web.stopLoading() + web.navigationDelegate = nil + web.uiDelegate = nil + observations = [] + reportTask?.cancel() + reportTask = nil + } + + func takeControl() { + agentDriven = false + automation.interrupt() + onHumanInput?() + } + + func reload(hard: Bool = false) { + failure = nil + if let url = retryURL ?? (web.url == nil ? snapshot.url : nil) { + do { load(try BrowserURL.resolve(url, environment: environment)) } catch { + failure = error.localizedDescription + } + } else { + requestedURL = web.url + navigationPending = true + navigationCommitted = false + if hard { web.reloadFromOrigin() } else { web.reload() } + } + onChange?() + } + + private func load(_ url: URL) { + requestedURL = url + navigationPending = true + navigationCommitted = false + failure = nil + if web.load(URLRequest(url: url)) == nil { rejectedNavigation(url) } + } + private func rejectedNavigation(_ url: URL) { + failed( + NSError( + domain: NSURLErrorDomain, code: NSURLErrorCannotConnectToHost, + userInfo: [ + NSURLErrorFailingURLErrorKey: url, + NSLocalizedDescriptionKey: + "WebKit could not open this address. Check the URL and port, then try again.", + ])) + } + + func captureImage(mouse: Bool = true, keys: Bool = true, deterministic: Bool = true) async throws + -> NSImage + { + try await BrowserCapture.image( + web: web, cursor: automation.cursor, mouse: mouse, + keys: keys, deterministic: deterministic) + } + func capture() async throws -> URL { + let image = try await captureImage() + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + "t3-browser-" + UUID().uuidString) + try FileManager.default.createDirectory( + at: directory, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700]) + let url = directory.appendingPathComponent("Browser screenshot.png") + do { + try BrowserAnnotationRenderer.png(image).write(to: url, options: .atomic) + return url + } catch { + try? FileManager.default.removeItem(at: directory) + throw error + } + } + + func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { + navigationPending = true + navigationCommitted = false + picker.cancel() + automation.invalidate() + failure = nil + retryURL = nil + onChange?() + } + func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) { + automation.invalidate() + navigationCommitted = true + } + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + navigationPending = false + if webView.url?.absoluteString == "about:blank", let requestedURL, + ["http", "https"].contains(requestedURL.scheme ?? "") + { + rejectedNavigation(requestedURL) + return + } + requestedURL = nil + failure = nil + report(kind: "Success") + onChange?() + } + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: any Error) + { failed(error) } + func webView( + _ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, + withError error: any Error + ) { failed(error) } + func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { + navigationPending = false + automation.invalidate() + failure = "This page’s web process stopped. Reload to restore it." + onChange?() + } + private func failed(_ error: any Error) { + let error = error as NSError + guard error.code != NSURLErrorCancelled else { return } + // Converting navigation to WKDownload interrupts the old frame with legacy + // WebKit policy code 102, even when the file has downloaded successfully. + if expectingDownload, error.domain == "WebKitErrorDomain", error.code == 102 { + expectingDownload = false + navigationPending = false + return + } + navigationPending = false + requestedURL = nil + failure = error.localizedDescription + retryURL = (error as NSError).userInfo[NSURLErrorFailingURLErrorKey] as? URL ?? serverURL + report(kind: "LoadFailed", error: error as NSError) + onChange?() + } + + private func report(kind: String, error: NSError? = nil) { + guard !disposed else { return } + guard let url = error?.userInfo[NSURLErrorFailingURLErrorKey] as? URL ?? web.url ?? serverURL, + ["http", "https"].contains(url.scheme ?? ""), url.absoluteString.utf8.count <= 2_048 + else { return } + var status: [String: JSON] = [ + "_tag": .string(kind), "url": .string(url.absoluteString), + "title": .string(String((web.title ?? "").prefix(512))), + ] + if let error { + status["code"] = .number(Double(error.code)) + status["description"] = .string(error.localizedDescription) + } + let input: JSON = .object([ + "threadId": .string(threadID), "tabId": .string(tabID), "navStatus": .object(status), + "canGoBack": .bool(web.canGoBack), "canGoForward": .bool(web.canGoForward), + ]) + guard lastReport != input else { return } + lastReport = input + pendingReport = input + guard reportTask == nil else { return } + reportTask = Task { [weak self] in + guard let self else { return } + defer { reportTask = nil } + while let input = pendingReport, !Task.isCancelled { + pendingReport = nil + expectedReports.append(input["navStatus"]) + if expectedReports.count > 16 { expectedReports.removeFirst() } + do { + _ = try await connection.call("preview.reportStatus", payload: input) + guard !disposed, !Task.isCancelled else { return } + synchronizationError = nil + } catch { + guard !disposed, !Task.isCancelled else { return } + expectedReports.removeAll { $0 == input["navStatus"] } + if lastReport == input { lastReport = nil } + synchronizationError = "Browser tab could not sync: " + error.localizedDescription + } + onChange?() + } + } + } + + func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction) async + -> WKNavigationActionPolicy + { + guard let url = action.request.url else { return .cancel } + if action.targetFrame?.isMainFrame == true { expectingDownload = false } + if action.shouldPerformDownload, + ["http", "https", "blob", "data"].contains(url.scheme?.lowercased() ?? "") + { + expectingDownload = true + return .download + } + if url.absoluteString == "about:blank" { return .allow } + guard ["http", "https"].contains(url.scheme?.lowercased() ?? "") else { return .cancel } + if action.targetFrame == nil { + onNewTab?(url) + return .cancel + } + if action.targetFrame?.isMainFrame == true { + do { + let resolved = try BrowserURL.resolve( + try BrowserURL.normalize(url.absoluteString), environment: environment) + requestedURL = resolved + if resolved != url { + load(resolved) + return .cancel + } + } catch { + failure = error.localizedDescription + onChange?() + return .cancel + } + } + return .allow + } + + func webView(_ webView: WKWebView, decidePolicyFor response: WKNavigationResponse) async + -> WKNavigationResponsePolicy + { + let disposition = + (response.response as? HTTPURLResponse)?.value(forHTTPHeaderField: "Content-Disposition") + ?? "" + if !response.canShowMIMEType || disposition.lowercased().hasPrefix("attachment") { + expectingDownload = true + return .download + } + return .allow + } + func webView( + _ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload + ) { + becameDownload(download) + } + func webView( + _ webView: WKWebView, navigationResponse: WKNavigationResponse, didBecome download: WKDownload + ) { + becameDownload(download) + } + private func becameDownload(_ download: WKDownload) { + navigationPending = false + requestedURL = nil + failure = nil + if let onDownload { + onDownload(download, agentDriven, web.window) + } else { + download.cancel(nil) + } + onChange?() + } + + func webView( + _ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, + for action: WKNavigationAction, windowFeatures: WKWindowFeatures + ) -> WKWebView? { + if let url = action.request.url, ["http", "https"].contains(url.scheme ?? "") { + onNewTab?(url) + } + return nil + } + func webView( + _ webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String, + initiatedByFrame frame: WKFrameInfo + ) async { + _ = await dialog(message: message, confirm: false) + } + func webView( + _ webView: WKWebView, runJavaScriptConfirmPanelWithMessage message: String, + initiatedByFrame frame: WKFrameInfo + ) async -> Bool { + await dialog(message: message, confirm: true) + } + func webView( + _ webView: WKWebView, runJavaScriptTextInputPanelWithPrompt prompt: String, + defaultText: String?, initiatedByFrame frame: WKFrameInfo + ) async -> String? { + guard let window = web.window, !web.isHiddenOrHasHiddenAncestor else { return nil } + let alert = NSAlert() + alert.messageText = frame.securityOrigin.host + alert.informativeText = String(prompt.prefix(4_000)) + let field = NSTextField(string: defaultText ?? "") + field.frame = NSRect(x: 0, y: 0, width: 320, height: 24) + alert.accessoryView = field + alert.addButton(withTitle: "OK") + alert.addButton(withTitle: "Cancel") + alert.window.initialFirstResponder = field + return await alert.beginSheetModal(for: window) == .alertFirstButtonReturn + ? field.stringValue : nil + } + func webView( + _ webView: WKWebView, runOpenPanelWith parameters: WKOpenPanelParameters, + initiatedByFrame frame: WKFrameInfo + ) async -> [URL]? { + guard let window = web.window, !web.isHiddenOrHasHiddenAncestor else { return nil } + let panel = NSOpenPanel() + panel.message = "Choose files for \(frame.securityOrigin.host)" + panel.allowsMultipleSelection = parameters.allowsMultipleSelection + panel.canChooseDirectories = parameters.allowsDirectories + panel.canChooseFiles = true + return await panel.beginSheetModal(for: window) == .OK ? panel.urls : nil + } + private func dialog(message: String, confirm: Bool) async -> Bool { + guard let window = web.window, !web.isHiddenOrHasHiddenAncestor else { return false } + let alert = NSAlert() + alert.messageText = web.url?.host ?? "Website" + alert.informativeText = String(message.prefix(4_000)) + alert.addButton(withTitle: "OK") + if confirm { alert.addButton(withTitle: "Cancel") } + return await alert.beginSheetModal(for: window) == .alertFirstButtonReturn + } +} + +@MainActor final class BrowserProfiles { + enum DataKinds { case all, cookies, cache } + private var stores: [String: WKWebsiteDataStore] = [:] + func clear(profile: String?, kinds: DataKinds) async { + let types: Set + switch kinds { + case .all: types = WKWebsiteDataStore.allWebsiteDataTypes() + case .cookies: + types = [ + WKWebsiteDataTypeCookies, WKWebsiteDataTypeLocalStorage, + WKWebsiteDataTypeIndexedDBDatabases, WKWebsiteDataTypeServiceWorkerRegistrations, + ] + case .cache: + types = [ + WKWebsiteDataTypeDiskCache, WKWebsiteDataTypeMemoryCache, WKWebsiteDataTypeFetchCache, + ] + } + await dataStore(profile: profile).removeData(ofTypes: types, modifiedSince: .distantPast) + } + func dataStore(profile: String?) -> WKWebsiteDataStore { + let profile = profile ?? "default" + let key = "T3NativeBrowser\n" + profile + if let store = stores[key] { return store } + let store: WKWebsiteDataStore + if profile == "incognito" { + store = .nonPersistent() + } else { + let hash = Array(SHA256.hash(data: Data(key.utf8)).prefix(16)) + let identifier = UUID( + uuid: ( + hash[0], hash[1], hash[2], hash[3], hash[4], hash[5], hash[6], hash[7], hash[8], hash[9], + hash[10], hash[11], hash[12], hash[13], hash[14], hash[15] + )) + store = WKWebsiteDataStore(forIdentifier: identifier) + } + stores[key] = store + return store + } +} diff --git a/apps/macos/Sources/T3Native/BrowserPanel.swift b/apps/macos/Sources/T3Native/BrowserPanel.swift new file mode 100644 index 0000000000000..c2c2e24ae91a0 --- /dev/null +++ b/apps/macos/Sources/T3Native/BrowserPanel.swift @@ -0,0 +1,1950 @@ +import AppKit +import T3Core +import WebKit + +@MainActor final class BrowserPanel: NSViewController { + var onClose: (() -> Void)? + var onAgentReveal: (() -> Void)? + var onContextAdded: (() -> Void)? + var onExpand: (() -> Void)? + // Parent may use this for an artifact gallery/reveal UI. Attachment import stays in ClientStore. + var onArtifactSaved: ((URL) -> Void)? + private var annotationWindows: [BrowserAnnotationWindow] = [] + private var recordingPage: BrowserPage? + private var lastArtifact: (file: URL, thread: String, connection: RPCConnection)? + private var recordingOptions = BrowserRecordingOptions() + var recordingActive: Bool { pages.values.contains { $0.recording.active } } + var recordingSettings: BrowserRecordingOptions { recordingOptions } + func configureRecording(_ options: BrowserRecordingOptions) throws { + guard !recordingActive else { + throw T3Error("Stop recording before changing recording options.") + } + try options.validate() + recordingOptions = options + render() + } + private let automationHost = BrowserAutomationHost() + var agentPaused: Bool { automationHost.paused } + func toggleAgentControl() { + automationHost.setPaused(!automationHost.paused) + if automationHost.paused { chromiumCommand("stop") } + } + let chromium = BrowserChromiumController() + private var chromiumOpening = Set() + private var chromiumURLs: [String: String] = [:] + func stopChromiumHelper() { chromium.stop() } + func finishBeforeQuit(window: NSWindow?) async -> Bool { + let wasPaused = automationHost.paused + automationHost.setPaused(true) + var allowed = false + defer { if !allowed { automationHost.setPaused(wasPaused) } } + let recordings = pages.values.filter { $0.recording.active } + if !recordings.isEmpty { + let alert = NSAlert() + alert.messageText = "Browser recording is still running" + alert.informativeText = + "Save \(recordings.count) recording(s) before quitting, or discard them." + alert.addButton(withTitle: "Save and quit") + alert.addButton(withTitle: "Keep recording") + alert.addButton(withTitle: "Discard recordings and quit") + let result = + if let window, window.isVisible { await alert.beginSheetModal(for: window) } else { + alert.runModal() + } + if result == .alertSecondButtonReturn { return false } + if result == .alertFirstButtonReturn { + do { for page in recordings { _ = try await page.recording.finishForQuit() } } catch { + NSAlert(error: error).runModal() + return false + } + } else { + for page in recordings { page.recording.cancel() } + } + recordingPage = nil + } + guard await downloads.finishBeforeQuit(window: window) else { return false } + allowed = await chromium.finishBeforeQuit(window: window) + return allowed + } + + private let stopAgent = NSButton(title: "Stop agent", target: nil, action: nil) + private var automationSurfaces: [String: BrowserAutomationSurface] = [:] + private var automationMetadata: Task? + private let store: ClientStore + private let profiles = BrowserProfiles() + private let inputMonitor = BrowserInputMonitor() + let downloads: BrowserDownloads + private let downloadButton = NSButton() + private var downloadsWindow: BrowserDownloadsWindow? + let preferences: BrowserPreferences + var opensLinksExternally: Bool { preferences.values.externalLinks } + private var settingsWindow: BrowserSettingsWindow? + private let profilePicker = NSPopUpButton(frame: .zero, pullsDown: true) + private var profileMenuSignature = "" + private let tabs = NSStackView() + private let host = BrowserViewportView() + private let devices = BrowserDeviceToolbar() + private let deviceToggle = NSButton() + private var lastViewports: [String: BrowserViewport] = [:] + private let address = NSTextField() + private let empty = NSStackView() + private let footer = NSStackView() + private let status = NSTextField(wrappingLabelWithString: "") + private let back = NSButton() + private let forward = NSButton() + private let reload = NSButton() + private let capture = NSButton() + private let pick = NSButton() + private let more = NSPopUpButton(frame: .zero, pullsDown: true) + private let add = NSButton() + private let retry = NSButton(title: "Retry", target: nil, action: nil) + private var directory = BrowserDirectory(threadID: "") + private var connection: RPCConnection? + private var endpoint: URL? + private var threadID: String? + private var pages: [String: BrowserPage] = [:] + private var selections: [String: String] = [:] + private var selectedID: String? + private var shownPage: BrowserPage? + private var metadata: Task? + private var discovery: Task? + private var operation: Task? + private var generation = UUID() + private var active = false + private var loading = false + private var busy = false + private var error: String? + private var discoveryError: String? + private var servers: [BrowserLocalServer] = [] + private var renderedTabs: [String] = [] + private var renderedEmpty = "" + private var addressSelection: String? + private var pendingOpen: (text: String?, newTab: Bool, profile: String?)? + private var focusWhenReady = false + + init( + store: ClientStore, preferences: BrowserPreferences? = nil, downloads: BrowserDownloads? = nil + ) { + self.store = store + self.preferences = preferences ?? BrowserPreferences() + self.downloads = downloads ?? BrowserDownloads() + super.init(nibName: nil, bundle: nil) + self.preferences.onChange = { [weak self] in self?.render() } + self.downloads.onChange = { [weak self] in self?.render() } + chromium.onChange = { [weak self] in self?.render() } + chromium.onEvent = { [weak self] event, value in self?.chromiumEvent(event, value: value) } + + automationHost.handle = { [weak self] request, deadline in + guard let self else { throw BrowserAutomationError.interrupted } + return try await self.automate(request, deadline: deadline) + } + automationHost.liveTabs = { [weak self] in + guard let self else { return [] } + let chromiumTabs = self.chromium.pages.values.map { value in + JSON.object([ + "threadId": value["threadId"], "tabId": value["tabId"], "visible": value["visible"], + ]) + } + return chromiumTabs + + self.pages.values.map { page in + .object([ + "threadId": .string(page.threadID), "tabId": .string(page.tabID), + "visible": .bool(page.web.window?.isVisible == true && !page.web.isHidden), + ]) + } + } + automationHost.onChange = { [weak self] in self?.render() } + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + isolated deinit { + detach() + chromium.stop() + automationMetadata?.cancel() + automationHost.synchronize(connection: nil, environmentID: nil) + for surface in automationSurfaces.values { surface.close() } + for page in pages.values { page.dispose() } + } + + override func loadView() { + view = SurfaceView() + tabs.orientation = .horizontal + tabs.spacing = 4 + let tabScroll = NSScrollView() + tabScroll.drawsBackground = false + tabScroll.hasHorizontalScroller = true + tabScroll.autohidesScrollers = true + tabScroll.documentView = tabs + tabs.translatesAutoresizingMaskIntoConstraints = false + tabs.leadingAnchor.constraint(equalTo: tabScroll.contentView.leadingAnchor).isActive = true + tabs.topAnchor.constraint(equalTo: tabScroll.contentView.topAnchor).isActive = true + tabs.heightAnchor.constraint(equalToConstant: 30).isActive = true + configure(add, symbol: "plus", label: "New browser tab", action: #selector(newTab)) + let expand = NSButton() + configure( + expand, symbol: "arrow.up.left.and.arrow.down.right", label: "Expand browser", + action: #selector(expandPanel)) + let close = NSButton() + configure(close, symbol: "xmark", label: "Hide browser", action: #selector(hidePanel)) + profilePicker.bezelStyle = .inline + profilePicker.setAccessibilityLabel("Browser profiles") + configure( + downloadButton, symbol: "arrow.down.circle", label: "Browser downloads", + action: #selector(showDownloads)) + let top = NSStackView(views: [tabScroll, add, profilePicker, downloadButton, expand, close]) + top.spacing = 6 + configure(back, symbol: "arrow.left", label: "Back", action: #selector(goBack)) + configure(forward, symbol: "arrow.right", label: "Forward", action: #selector(goForward)) + configure(reload, symbol: "arrow.clockwise", label: "Reload", action: #selector(reloadPage)) + configure( + capture, symbol: "camera", label: "Attach browser screenshot", action: #selector(capturePage)) + configure( + pick, symbol: "cursorarrow.click.2", label: "Select element for message", + action: #selector(togglePicker)) + address.placeholderString = "Search or enter URL" + address.font = .systemFont(ofSize: 13) + address.isBezeled = false + address.drawsBackground = false + address.target = self + address.action = #selector(submitAddress) + address.setAccessibilityLabel("Browser address") + address.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + more.bezelStyle = .inline + more.setAccessibilityLabel("Browser actions") + configure( + deviceToggle, symbol: "rectangle.on.rectangle", label: "Toggle device toolbar", + action: #selector(toggleDevices)) + devices.onChange = { [weak self] in self?.resize($0) } + devices.onError = { [weak self] message in + self?.error = message + self?.render() + } + let navigation = NSStackView(views: [ + back, forward, reload, address, pick, capture, deviceToggle, more, + ]) + navigation.spacing = 7 + let divider = NSBox() + divider.boxType = .separator + status.font = .systemFont(ofSize: 11) + status.textColor = .secondaryLabelColor + status.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + retry.target = self + retry.action = #selector(retryBrowser) + retry.bezelStyle = .inline + footer.addArrangedSubview(status) + footer.addArrangedSubview(retry) + stopAgent.target = self + stopAgent.action = #selector(toggleAgent) + stopAgent.bezelStyle = .inline + footer.addArrangedSubview(stopAgent) + footer.setContentHuggingPriority(.required, for: .vertical) + status.setContentHuggingPriority(.defaultLow, for: .horizontal) + let stack = NSStackView(views: [top, navigation, divider, devices, host, footer]) + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 10 + view.addSubview(stack) + stack.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 12), + stack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -12), + stack.topAnchor.constraint(equalTo: view.topAnchor, constant: 42), + stack.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -12), + tabScroll.heightAnchor.constraint(equalToConstant: 30), + host.heightAnchor.constraint(greaterThanOrEqualToConstant: 140), + address.widthAnchor.constraint(greaterThanOrEqualToConstant: 100), + more.widthAnchor.constraint(equalToConstant: 28), + profilePicker.widthAnchor.constraint(equalToConstant: 28), + ]) + for child in [top, navigation, divider, devices, host, footer] { + child.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true + } + empty.orientation = .vertical + empty.alignment = .centerX + empty.spacing = 15 + host.addSubview(empty) + empty.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + empty.centerXAnchor.constraint(equalTo: host.centerXAnchor), + empty.topAnchor.constraint(equalTo: host.topAnchor, constant: 55), + empty.widthAnchor.constraint(lessThanOrEqualTo: host.widthAnchor, constant: -30), + empty.widthAnchor.constraint(lessThanOrEqualToConstant: 500), + ]) + render() + buildMenu() + } + + private func configure(_ button: NSButton, symbol: String, label: String, action: Selector) { + button.image = NSImage(systemSymbolName: symbol, accessibilityDescription: label) + button.imagePosition = .imageOnly + button.bezelStyle = .inline + button.isBordered = false + button.target = self + button.action = action + button.toolTip = label + button.setAccessibilityLabel(label) + button.widthAnchor.constraint(equalToConstant: 24).isActive = true + } + + func refresh(active: Bool) { + _ = view + let nextConnection = store.browserConnection + let nextThread = store.selectedID + automationHost.synchronize( + connection: nextConnection, environmentID: store.browserEnvironmentID) + let contextChanged = + connection !== nextConnection || threadID != nextThread || self.active != active + guard contextChanged else { + render() + return + } + if connection !== nextConnection { + chromium.stop() + chromiumOpening = [] + chromiumURLs = [:] + recordingPage?.recording.cancel() + recordingPage = nil + } + if connection !== nextConnection { + automationMetadata?.cancel() + automationMetadata = nil + for surface in automationSurfaces.values { surface.close() } + automationSurfaces = [:] + for page in pages.values { + page.dispose() + page.web.removeFromSuperview() + } + pages = [:] + shownPage = nil + } + detach() + self.active = active + connection = nextConnection + chromium.environmentID = store.browserEnvironmentID + endpoint = store.browserEndpoint + if automationMetadata == nil, let nextConnection { watchAutomationTabs(nextConnection) } + threadID = nextThread + shownPage?.picker.cancel() + let detachedPage = shownPage + host.show(nil) + shownPage = nil + retainDetachedPage(detachedPage) + empty.isHidden = false + directory = BrowserDirectory(threadID: nextThread ?? "") + selectedID = nextThread.flatMap { selections[$0] } + error = nil + discoveryError = nil + servers = [] + render() + guard active, let connection, let threadID else { return } + subscribe(connection: connection, threadID: threadID) + } + + private func detach() { + generation = UUID() + metadata?.cancel() + metadata = nil + discovery?.cancel() + discovery = nil + operation?.cancel() + operation = nil + loading = false + busy = false + pendingOpen = nil + } + private func key(_ tabID: String, thread: String) -> String { thread + "\n" + tabID } + private var page: BrowserPage? { + guard let threadID, let selectedID else { return nil } + return pages[key(selectedID, thread: threadID)] + } + + private func subscribe(connection: RPCConnection, threadID: String) { + let generation = generation + loading = true + metadata = Task { [weak self] in + guard let self else { return } + do { + let stream = try await connection.subscribe("subscribePreviewEvents") + defer { Task { await connection.cancel(stream.id) } } + // The stream buffers events while the list is in flight; revisions reject list-era events. + let result = try await connection.call( + "preview.list", payload: .object(["threadId": .string(threadID)])) + try Task.checkCancellation() + guard self.generation == generation else { return } + try directory.snapshot(result) + loading = false + reconcile() + if focusWhenReady { focusAddress() } + if let request = pendingOpen { + pendingOpen = nil + open(request.text, newTab: request.newTab, profile: request.profile) + } + for try await event in stream.values { + try Task.checkCancellation() + guard self.generation == generation else { return } + if try directory.apply(event) { reconcile() } + } + throw T3Error("Browser updates ended. Retry to reconnect.") + } catch { + guard !Task.isCancelled, self.generation == generation else { return } + loading = false + self.error = error.localizedDescription + render() + } + } + discovery = Task { [weak self] in + guard let self else { return } + do { + let stream = try await connection.subscribe("subscribeDiscoveredLocalServers") + defer { Task { await connection.cancel(stream.id) } } + for try await value in stream.values { + try Task.checkCancellation() + guard self.generation == generation else { return } + let servers = value["servers"].array.compactMap(BrowserLocalServer.init).sorted { + if ($0.threadID == threadID) != ($1.threadID == threadID) { + return $0.threadID == threadID + } + return ($0.url.port ?? 0) < ($1.url.port ?? 0) + } + if self.servers != servers { + self.servers = servers + renderEmpty() + } + } + } catch { + guard !Task.isCancelled, self.generation == generation else { return } + discoveryError = "Local-server discovery: " + error.localizedDescription + render() + } + } + } + + private func reconcile() { + guard let threadID else { return } + let live = Set(directory.tabs.map(\.id)) + for (key, page) in pages where page.threadID == threadID && !live.contains(page.tabID) { + page.dispose() + page.web.removeFromSuperview() + pages.removeValue(forKey: key) + } + if !chromium.enabled { + for tab in directory.tabs { pages[key(tab.id, thread: threadID)]?.receive(tab) } + } + if selectedID == nil || !live.contains(selectedID!) { selectedID = directory.tabs.first?.id } + selections[threadID] = selectedID + showSelected() + render() + } + + private func showSelected() { + guard active, let threadID, let connection, let endpoint, + let tab = directory.tabs.first(where: { $0.id == selectedID }) + else { + shownPage?.picker.cancel() + let previous = shownPage + host.show(nil) + shownPage = nil + retainDetachedPage(previous) + empty.isHidden = false + return + } + if chromium.enabled { + shownPage?.picker.cancel() + host.show(nil) + shownPage = nil + empty.isHidden = false + handoffChromium(tab, connection: connection, endpoint: endpoint) + return + } + let key = key(tab.id, thread: threadID) + let page: BrowserPage + if let existing = pages[key] { + page = existing + } else { + page = makePage(tab, connection: connection, endpoint: endpoint) + } + if shownPage !== page { + shownPage?.picker.cancel() + let previous = shownPage + host.show(nil) + retainDetachedPage(previous) + shownPage = page + automationSurfaces[key]?.viewport.show(nil) + automationSurfaces[key]?.window.orderOut(nil) + host.show(page.web) + } + page.receive(tab) + page.web.isHidden = tab.url == nil && page.web.url == nil + empty.isHidden = !page.web.isHidden + } + + private func retainDetachedPage(_ page: BrowserPage?) { + guard let page else { return } + let key = key(page.tabID, thread: page.threadID) + guard page.activeAutomation > 0 || automationSurfaces[key] != nil else { return } + let surface = automationSurfaces[key] ?? BrowserAutomationSurface() + automationSurfaces[key] = surface + surface.onStop = { [weak self] in self?.automationHost.setPaused(true) } + surface.attach(page, show: false, title: "Browser") + } + + private func makePage(_ tab: BrowserTab, connection: RPCConnection, endpoint: URL) -> BrowserPage + { + let page = BrowserPage( + tab: tab, environment: endpoint, connection: connection, + dataStore: profiles.dataStore(profile: tab.profileID)) + page.web.pageZoom = preferences.values.zoom + page.web.appearance = + preferences.values.appearance == "system" + ? nil + : NSAppearance(named: preferences.values.appearance == "dark" ? .darkAqua : .aqua) + pages[key(tab.id, thread: tab.threadID)] = page + inputMonitor.register(page) + page.onHumanInput = { [weak self, weak page] in + guard let self, let page else { return } + automationHost.interrupt(threadID: page.threadID) + } + page.onDownload = { [weak downloads] download, agent, window in + downloads?.receive(download, agent: agent, window: window) + } + page.picker.onChange = { [weak self] in self?.render() } + page.picker.onError = { [weak self] message in + self?.error = message + self?.render() + } + page.picker.onSelection = { [weak self, weak page] context in + guard let self, let page, self.shownPage === page, self.active, + self.store.selectedID == page.threadID, self.connection === self.store.browserConnection + else { return } + self.store.addBrowserContext(context) + self.onContextAdded?() + } + page.onChange = { [weak self] in + guard let self, store.browserConnection === connection else { return } + render() + } + page.recording.onChange = { [weak self, weak page] in + guard let self, let page else { return } + if case .saved(let file) = page.recording.state, + self.connection === connection, store.browserConnection === connection, + lastArtifact?.file != file + { + lastArtifact = (file, page.threadID, connection) + onArtifactSaved?(file) + } + render() + } + page.onNewTab = { [weak self, weak page] url in + guard let self, let page else { return } + if !page.agentDriven, self.threadID == page.threadID, self.active, self.shownPage === page { + self.open(url.absoluteString, newTab: true, profile: tab.profileID) + } else { + self.openBackgroundPopup(url, from: page) + } + } + return page + } + + private func render() { + guard isViewLoaded else { return } + renderProfiles() + if chromium.nativeRecordingActive != recordingActive { + chromium.nativeRecordingActive = recordingActive + } + let downloadError = downloads.items.first?.error + downloadButton.toolTip = + downloads.activeCount > 0 + ? "\(downloads.activeCount) downloads in progress" : downloadError ?? "Browser downloads" + downloadButton.image = NSImage( + systemSymbolName: downloads.activeCount > 0 + ? "arrow.down.circle.fill" + : downloadError == nil ? "arrow.down.circle" : "exclamationmark.circle", + accessibilityDescription: "Browser downloads") + automationHost.refreshFocus() + let tabLabels = directory.tabs.map { tab in + return tab.id + "\n" + tabTitle(tab) + + "\n" + (tab.url?.absoluteString ?? "") + + (tab.id == selectedID ? "\nselected" : "") + } + if renderedTabs != tabLabels || tabs.arrangedSubviews.isEmpty { + renderedTabs = tabLabels + for child in tabs.arrangedSubviews { + tabs.removeArrangedSubview(child) + child.removeFromSuperview() + } + if directory.tabs.isEmpty { + let label = NSTextField(labelWithString: "◎ Browser") + label.font = .systemFont(ofSize: 13, weight: .medium) + tabs.addArrangedSubview(label) + } + for tab in directory.tabs { + let title = tabTitle(tab) + let select = ActionButton(title) { [weak self] in self?.select(tab.id) } + select.bezelStyle = .inline + select.state = tab.id == selectedID ? .on : .off + select.toolTip = tab.url?.absoluteString ?? "Empty browser tab" + select.lineBreakMode = .byTruncatingTail + select.widthAnchor.constraint(lessThanOrEqualToConstant: 165).isActive = true + let close = ActionButton("×") { [weak self] in self?.close(tab.id) } + close.bezelStyle = .inline + close.setAccessibilityLabel("Close " + title) + tabs.addArrangedSubview(NSStackView(views: [select, close])) + } + } + let page = page + let viewport = (try? BrowserViewport(page?.snapshot.viewport ?? .null)) ?? .fill + host.setting = viewport + devices.render(viewport, enabled: !loading && !busy && page != nil) + deviceToggle.isEnabled = page != nil && !loading && !busy + deviceToggle.state = viewport.width == nil ? .off : .on + let selectedChanged = addressSelection != selectedID + addressSelection = selectedID + if selectedChanged || address.currentEditor() == nil { + address.stringValue = + page?.displayedURL?.absoluteString ?? directory.tabs.first { $0.id == selectedID }?.url? + .absoluteString ?? "" + } + address.isEnabled = connection != nil && threadID != nil && !loading && !busy + add.isEnabled = address.isEnabled + profilePicker.isEnabled = address.isEnabled + back.isEnabled = page?.web.canGoBack == true + forward.isEnabled = page?.web.canGoForward == true + reload.isEnabled = page?.web.url != nil || page?.snapshot.url != nil + reload.image = NSImage( + systemSymbolName: page?.web.isLoading == true ? "xmark" : "arrow.clockwise", + accessibilityDescription: page?.web.isLoading == true ? "Stop loading" : "Reload") + pick.isEnabled = + page?.web.url != nil && page?.failure == nil && page?.web.isLoading == false + && store.canAddBrowserContext + pick.state = page?.picker.active == true ? .on : .off + pick.toolTip = + page?.picker.active == true + ? "Cancel element selection (Escape)" : "Select element for message" + capture.isEnabled = + page?.web.url != nil && page?.failure == nil && page?.web.isLoading == false + && store.canAttach + if chromium.enabled { + pick.isEnabled = false + deviceToggle.isEnabled = false + capture.isEnabled = selectedID != nil && chromium.running && store.canAttach + back.isEnabled = selectedID != nil && chromium.running + forward.isEnabled = back.isEnabled + reload.isEnabled = back.isEnabled + } + let message = + error ?? chromium.error ?? page?.failure ?? page?.synchronizationError ?? discoveryError + ?? automationHost.error + stopAgent.isHidden = automationHost.running == 0 && !automationHost.paused + stopAgent.title = automationHost.paused ? "Resume agent browser" : "Stop agent" + status.stringValue = + message + ?? (chromium.enabled ? chromium.state + " · " + chromium.passwordStatus : nil) + ?? (automationHost.paused + ? "Agent browser control paused" + : automationHost.running > 0 ? "Agent is using Browser…" : nil) + ?? (page?.picker.active == true + ? "Click an element to add it to your message. Escape cancels." : nil) + ?? (recordingPage?.recording.active == true + ? "Recording Browser · \(recordingOptions.framesPerSecond) fps target · stop to save" : nil) + ?? (loading + ? "Loading browser tabs…" + : connection == nil + ? "Connect to use Browser" + : threadID == nil + ? "Select a conversation to use Browser" + : page?.web.isLoading == true ? "Loading…" : "") + status.isHidden = status.stringValue.isEmpty + retry.isHidden = message == nil + footer.isHidden = status.isHidden && retry.isHidden && stopAgent.isHidden + renderEmpty() + } + + private func tabTitle(_ tab: BrowserTab) -> String { + let loaded = pages[key(tab.id, thread: tab.threadID)]?.web.title ?? "" + return (tab.profileID == "incognito" ? "Private · " : "") + + (loaded.isEmpty ? tab.title : loaded) + } + + private func renderEmpty() { + let signature = + servers.map(\.id).joined(separator: "\n") + + "\n\(connection != nil):\(loading):\(chromium.enabled):\(chromium.state)" + guard signature != renderedEmpty || empty.arrangedSubviews.isEmpty else { return } + renderedEmpty = signature + for child in empty.arrangedSubviews { + empty.removeArrangedSubview(child) + child.removeFromSuperview() + } + let icon = NSImageView( + image: NSImage(systemSymbolName: "globe", accessibilityDescription: nil)!) + icon.contentTintColor = .secondaryLabelColor + icon.heightAnchor.constraint(equalToConstant: 40).isActive = true + let title = NSTextField( + labelWithString: chromium.enabled ? "1Password Browser" : "No preview yet") + title.font = .systemFont(ofSize: 22, weight: .semibold) + let detail = NSTextField( + wrappingLabelWithString: + chromium.enabled + ? "The live Chromium page and 1Password popup run in a managed floating window. Cookies stay in its selected profile." + : "Type a URL above, or run a dev script. Browser-ready localhost servers will show up here automatically." + ) + detail.font = .systemFont(ofSize: 13) + detail.textColor = .secondaryLabelColor + detail.alignment = .center + for child in [icon, title, detail] { empty.addArrangedSubview(child) } + detail.widthAnchor.constraint(lessThanOrEqualTo: host.widthAnchor, constant: -48).isActive = + true + if chromium.enabled { + for (label, action) in [ + ("Reveal page", #selector(revealChromiumBrowser)), + ("1Password", #selector(openChromiumPassword)), + ("Close page", #selector(closeChromiumPage)), + ("Stop helper", #selector(stopChromium)), + ] { + let button = NSButton(title: label, target: self, action: action) + button.bezelStyle = .inline + empty.addArrangedSubview(button) + } + } + for server in servers.prefix(8) { + let button = ActionButton(server.process + " · " + server.url.absoluteString) { [weak self] in + self?.open(server.url.absoluteString) + } + button.bezelStyle = .inline + button.lineBreakMode = .byTruncatingMiddle + empty.addArrangedSubview(button) + button.widthAnchor.constraint(lessThanOrEqualTo: host.widthAnchor, constant: -48).isActive = + true + } + } + + func focusAddress() { + focusWhenReady = loading + guard !loading else { return } + view.window?.makeFirstResponder(address) + address.selectText(nil) + } + private func select(_ id: String) { + selectedID = id + if let threadID { selections[threadID] = id } + showSelected() + render() + } + @objc func newTab() { open(nil, newTab: true) } + @objc private func submitAddress() { open(address.stringValue) } + func open(_ text: String?, newTab: Bool = false, profile: String? = nil) { + guard let connection, let threadID, active, !busy else { return } + if loading { + pendingOpen = (text, newTab, profile) + return + } + let url: URL? + do { + if let text { + let text = text.trimmingCharacters(in: .whitespacesAndNewlines) + // Search terms use the same explicit address submission as a URL; no keystroke suggestions leave the app. + if text.contains(where: { $0.isWhitespace }), !text.contains("://") { + var search = URLComponents(string: "https://www.google.com/search")! + search.queryItems = [URLQueryItem(name: "q", value: text)] + url = search.url + } else { + url = try BrowserURL.normalize(text) + } + if let url, let endpoint { _ = try BrowserURL.resolve(url, environment: endpoint) } + } else { + url = nil + } + } catch { + self.error = error.localizedDescription + render() + return + } + let tab = newTab ? nil : selectedID + if tab != nil { page?.takeControl() } + var input: [String: JSON] = ["threadId": .string(threadID)] + if let tab { input["tabId"] = .string(tab) } + if let url { input["url"] = .string(url.absoluteString) } + if tab == nil { + input["profileId"] = .string(profile ?? preferences.values.defaultProfileID) + input["viewport"] = preferences.values.viewport + } + mutate(tab == nil ? "preview.open" : "preview.navigate", input: input, connection: connection) { + [weak self] result in + guard let self, let id = result["tabId"].string else { return } + self.selectedID = id + self.selections[threadID] = id + } + } + + private func close(_ id: String) { + guard let connection, let threadID else { return } + pages[key(id, thread: threadID)]?.takeControl() + mutate( + "preview.close", input: ["threadId": .string(threadID), "tabId": .string(id)], + connection: connection) + } + private func mutate( + _ method: String, input: [String: JSON], connection: RPCConnection, + completed: ((JSON) -> Void)? = nil + ) { + guard !busy, let threadID else { return } + let generation = generation + busy = true + error = nil + render() + operation = Task { [weak self] in + guard let self else { return } + defer { + if self.generation == generation { + busy = false + operation = nil + render() + if method == "preview.open", input["url"] == nil { + focusAddress() + } else if self.error == nil, method == "preview.open" || method == "preview.navigate", + let page + { + view.window?.makeFirstResponder(page.web) + } + } + } + do { + let result = try await connection.call(method, payload: .object(input)) + try Task.checkCancellation() + guard self.generation == generation else { return } + if method == "preview.navigate", let tab = try? BrowserTab(result), tab.id == selectedID { + // Explicit navigation must not be mistaken for an old status echo after a failed load. + page?.receive(tab, navigate: true) + } + completed?(result) + let snapshot = try await connection.call( + "preview.list", payload: .object(["threadId": .string(threadID)])) + try Task.checkCancellation() + guard self.generation == generation else { return } + try directory.snapshot(snapshot) + reconcile() + } catch { + guard !Task.isCancelled, self.generation == generation else { return } + self.error = error.localizedDescription + } + } + } + + private func buildMenu() { + more.removeAllItems() + more.addItem(withTitle: "•••") + for (title, action) in [ + ("Use 1Password Browser", #selector(useChromiumBrowser)), + ("Use Native Browser", #selector(useNativeBrowser)), + ("Reveal 1Password Browser", #selector(revealChromiumBrowser)), + ("Open 1Password Popup", #selector(openChromiumPassword)), + ("Install Official 1Password Extension", #selector(installChromiumPassword)), + ("Import 1Password From Browser…", #selector(importChromiumPassword)), + ("Close Chromium Page", #selector(closeChromiumPage)), + ("Stop Chromium Helper", #selector(stopChromium)), + ("Start Recording", #selector(startRecording)), + ("Stop Recording and Save", #selector(stopRecording)), + ("Cancel Recording", #selector(cancelRecording)), + ("Recording: 30 fps", #selector(recordAt30)), + ("Recording: 60 fps", #selector(recordAt60)), + ("Toggle Mouse Overlay", #selector(toggleRecordingMouse)), + ("Toggle Key Overlay", #selector(toggleRecordingKeys)), + ("Annotate Screenshot…", #selector(annotatePage)), + ("Attach Last Saved Artifact", #selector(attachLastArtifact)), + ("Show Saved Browser Artifacts", #selector(showArtifacts)), + ("Open in Default Browser", #selector(openExternal)), + ("Reload Without Cache", #selector(hardReload)), ("Zoom In", #selector(zoomIn)), + ("Zoom Out", #selector(zoomOut)), ("Actual Size", #selector(resetZoom)), + ("New Private Tab", #selector(newPrivateTab)), + ("Clear Profile Cookies…", #selector(clearProfileCookies)), + ("Clear Profile Cache…", #selector(clearProfileCache)), + ("Browser Settings…", #selector(showBrowserSettings)), + ("Downloads…", #selector(showDownloads)), + ] { + more.addItem(withTitle: title) + more.lastItem?.target = self + more.lastItem?.action = action + } + } + private func renderProfiles() { + let current = page?.snapshot.profileID ?? preferences.values.defaultProfileID + let signature = + String(describing: preferences.values.availableProfiles) + current + + preferences.values.defaultProfileID + guard signature != profileMenuSignature else { return } + profileMenuSignature = signature + profilePicker.removeAllItems() + profilePicker.addItem(withTitle: "") + profilePicker.item(at: 0)?.image = NSImage( + systemSymbolName: "person.crop.circle", accessibilityDescription: nil) + profilePicker.toolTip = "Profile: " + preferences.name(current) + let heading = NSMenuItem(title: "New tab with profile", action: nil, keyEquivalent: "") + heading.isEnabled = false + profilePicker.menu?.addItem(heading) + for profile in preferences.values.availableProfiles { + let item = NSMenuItem( + title: profile.name, action: #selector(openProfile(_:)), keyEquivalent: "") + item.target = self + item.representedObject = profile.id + item.state = profile.id == current ? .on : .off + profilePicker.menu?.addItem(item) + } + profilePicker.menu?.addItem(.separator()) + let manage = NSMenuItem( + title: "Manage Profiles…", action: #selector(showBrowserSettings), keyEquivalent: "") + manage.target = self + profilePicker.menu?.addItem(manage) + } + @objc private func openProfile(_ sender: NSMenuItem) { + guard let id = sender.representedObject as? String else { return } + open(nil, newTab: true, profile: id) + } + @objc func showBrowserSettings() { + if settingsWindow == nil { + settingsWindow = BrowserSettingsWindow( + preferences: preferences, profiles: profiles, chromium: chromium, + onModeChange: { [weak self] in self?.chromiumModeChanged() }) + } + settingsWindow?.showWindow(nil) + settingsWindow?.window?.makeKeyAndOrderFront(nil) + } + @objc func showDownloads() { + if downloadsWindow == nil { downloadsWindow = BrowserDownloadsWindow(downloads: downloads) } + downloadsWindow?.showWindow(nil) + downloadsWindow?.window?.makeKeyAndOrderFront(nil) + } + @objc private func clearProfileCookies() { clearProfile(.cookies) } + @objc private func clearProfileCache() { clearProfile(.cache) } + private func clearProfile(_ kinds: BrowserProfiles.DataKinds) { + guard let window = view.window, + let tab = directory.tabs.first(where: { $0.id == selectedID }) + else { return } + let profile = tab.profileID + let alert = NSAlert() + alert.messageText = + kinds == .cookies + ? "Clear cookies for \(preferences.name(profile))?" + : "Clear cache for \(preferences.name(profile))?" + alert.informativeText = + kinds == .cookies + ? "This signs you out of websites in this profile." + : "Websites in this profile will download fresh copies of cached content." + alert.addButton(withTitle: "Clear") + alert.addButton(withTitle: "Cancel") + Task { [weak self] in + guard let self, await alert.beginSheetModal(for: window) == .alertFirstButtonReturn else { + return + } + do { + if chromium.enabled { + try await chromium.clearProfile(profile ?? "default", kinds: kinds) + } else { + await profiles.clear(profile: profile, kinds: kinds) + } + } catch { + self.error = error.localizedDescription + render() + } + } + } + @objc private func toggleAgent() { toggleAgentControl() } + @objc private func togglePicker() { + guard let page, store.canAddBrowserContext else { return } + page.takeControl() + if page.picker.active { page.picker.cancel() } else { page.picker.start() } + } + func resize(_ viewport: BrowserViewport) { + guard let connection, let threadID, let selectedID else { return } + page?.takeControl() + let key = key(selectedID, thread: threadID) + mutate( + "preview.resize", + input: [ + "threadId": .string(threadID), "tabId": .string(selectedID), + "viewport": viewport.json, + ], connection: connection + ) { [weak self] _ in + if viewport.width != nil { self?.lastViewports[key] = viewport } + } + } + @objc private func toggleDevices() { + guard let page else { return } + if host.setting.width != nil { + resize(.fill) + return + } + let previous = lastViewports[key(page.tabID, thread: page.threadID)] + let width = min(3840, max(240, Int(host.bounds.width / page.web.pageZoom))) + let height = min(2160, max(240, Int((host.bounds.height - 42) / page.web.pageZoom))) + resize( + previous ?? (try? BrowserViewport(width: width, height: height)) + ?? BrowserDevicePreset.all[0].viewport) + } + @objc private func goBack() { + if chromium.enabled { + chromiumCommand("back") + return + } + page?.takeControl() + page?.web.goBack() + } + @objc private func goForward() { + if chromium.enabled { + chromiumCommand("forward") + return + } + page?.takeControl() + page?.web.goForward() + } + @objc private func reloadPage() { + if chromium.enabled { + chromiumCommand("reload") + return + } + page?.takeControl() + if page?.web.isLoading == true { page?.web.stopLoading() } else { page?.reload() } + } + @objc private func hardReload() { + if chromium.enabled { + chromiumCommand("reload") + return + } + page?.takeControl() + page?.reload(hard: true) + } + @objc private func zoomIn() { + if chromium.enabled { + chromiumCommand("zoom", extra: ["delta": .number(0.1)]) + return + } + page?.takeControl() + if let web = page?.web { + web.pageZoom = min(5, web.pageZoom + 0.1) + host.needsLayout = true + } + } + @objc private func zoomOut() { + if chromium.enabled { + chromiumCommand("zoom", extra: ["delta": .number(-0.1)]) + return + } + page?.takeControl() + if let web = page?.web { + web.pageZoom = max(0.25, web.pageZoom - 0.1) + host.needsLayout = true + } + } + @objc private func resetZoom() { + if chromium.enabled { + chromiumCommand("zoom", extra: ["factor": .number(1)]) + return + } + page?.takeControl() + page?.web.pageZoom = 1 + host.needsLayout = true + } + @objc private func newPrivateTab() { open(nil, newTab: true, profile: "incognito") } + @objc private func openExternal() { + if let url = page?.web.url, ["http", "https"].contains(url.scheme ?? "") { + NSWorkspace.shared.open(url) + } + } + @objc private func hidePanel() { onClose?() } + @objc private func expandPanel() { onExpand?() } + @objc private func retryBrowser() { + if automationHost.error != nil { automationHost.retry() } + if error != nil || discoveryError != nil { + self.active = false + refresh(active: true) + } else { + page?.reload() + } + } + @objc func startRecording() { + guard let page, let connection, store.browserConnection === connection, !recordingActive else { + return + } + recordingPage = page + let options = recordingOptions + Task { [weak self, weak page] in + guard let self, let page else { return } + do { + try await page.recording.start(options: options, directory: BrowserArtifacts.directory) { + [weak page] options in + guard let page else { throw CancellationError() } + return try await page.captureImage( + mouse: options.showMouse, keys: options.showKeys, deterministic: false) + } + guard self.connection === connection, store.browserConnection === connection, + recordingPage === page + else { + page.recording.cancel() + return + } + } catch { + guard self.connection === connection, store.browserConnection === connection, + recordingPage === page + else { return } + self.error = error.localizedDescription + render() + } + } + } + @objc func stopRecording() { + guard let page = recordingPage, let connection else { return } + Task { [weak self] in + guard let self else { return } + do { + let file = try await page.recording.stop() + guard self.connection === connection, store.browserConnection === connection, + recordingPage === page + else { return } + recordingPage = nil + lastArtifact = (file, page.threadID, connection) + render() + } catch { + guard self.connection === connection, store.browserConnection === connection, + recordingPage === page + else { return } + self.error = error.localizedDescription + render() + } + } + } + @objc func cancelRecording() { + recordingPage?.recording.cancel() + recordingPage = nil + render() + } + @objc private func recordAt30() { recordingOptions.framesPerSecond = 30 } + @objc private func recordAt60() { recordingOptions.framesPerSecond = 60 } + @objc private func toggleRecordingMouse() { recordingOptions.showMouse.toggle() } + @objc private func toggleRecordingKeys() { recordingOptions.showKeys.toggle() } + @objc func showArtifacts() { + do { + try FileManager.default.createDirectory( + at: BrowserArtifacts.directory, + withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700]) + if !NSWorkspace.shared.open(BrowserArtifacts.directory) { + throw T3Error("Could not open saved browser artifacts.") + } + } catch { + self.error = error.localizedDescription + render() + } + } + @objc func attachLastArtifact() { + guard let artifact = lastArtifact, store.selectedID == artifact.thread, + store.browserConnection === artifact.connection, store.canAttach + else { return } + store.addAttachments([artifact.file]) + onContextAdded?() + } + @objc func annotatePage() { + if chromium.enabled { + captureChromium(annotate: true) + return + } + guard let page, let connection, store.browserConnection === connection else { return } + let thread = page.threadID + Task { [weak self] in + guard let self else { return } + do { + let image = try await page.captureImage() + guard store.selectedID == thread, store.browserConnection === connection else { return } + let window = BrowserAnnotationWindow( + image: image, pageURL: page.displayedURL?.absoluteString ?? "") + window.onSave = { [weak self] file, attach in + guard let self else { return } + onArtifactSaved?(file) + guard store.selectedID == thread, store.browserConnection === connection else { return } + lastArtifact = (file, thread, connection) + if attach, store.canAttach { + store.addAttachments([file]) + onContextAdded?() + } + } + window.onClose = { [weak self, weak window] in + self?.annotationWindows.removeAll { $0 === window } + } + annotationWindows.append(window) + window.showWindow(nil) + window.window?.center() + window.window?.makeKeyAndOrderFront(nil) + } catch { + guard store.browserConnection === connection else { return } + self.error = error.localizedDescription + render() + } + } + } + @objc private func capturePage() { + if chromium.enabled { + captureChromium(annotate: false) + return + } + guard let page, store.canAttach else { return } + let threadID = page.threadID + let endpoint = store.environmentKey + let connection = store.browserConnection + Task { + do { + let file = try await page.capture() + guard store.selectedID == threadID, store.environmentKey == endpoint, + store.browserConnection === connection, store.canAttach + else { + try? FileManager.default.removeItem(at: file.deletingLastPathComponent()) + return + } + store.addAttachments([file], deleteSources: true) + } catch { + self.error = error.localizedDescription + render() + } + } + } +} + +extension BrowserPanel { + private func openBackgroundPopup(_ url: URL, from source: BrowserPage) { + guard let connection, let endpoint else { return } + Task { [weak self, weak source] in + guard let self, let source, + self.connection === connection && self.store.browserConnection === connection + else { return } + do { + let tab = try BrowserTab( + await connection.call( + "preview.open", + payload: .object([ + "threadId": .string(source.threadID), "url": .string(url.absoluteString), + "profileId": .string(source.snapshot.profileID ?? "default"), + "viewport": source.snapshot.viewport, + ]))) + guard self.connection === connection && self.store.browserConnection === connection, + self.pages[key(source.tabID, thread: source.threadID)] === source + else { + _ = try? await connection.call( + "preview.close", + payload: .object(["threadId": .string(tab.threadID), "tabId": .string(tab.id)])) + return + } + let page = makePage(tab, connection: connection, endpoint: endpoint) + page.agentDriven = source.agentDriven + let surface = BrowserAutomationSurface() + surface.onStop = { [weak self] in self?.automationHost.setPaused(true) } + automationSurfaces[key(tab.id, thread: tab.threadID)] = surface + surface.attach( + page, show: source.web.window?.isVisible == true, + title: "Browser · " + + (store.shell.threads[tab.threadID]?.title ?? "Background conversation")) + page.receive(tab) + selections[tab.threadID] = tab.id + automationHost.refreshFocus() + } catch { + guard self.connection === connection && self.store.browserConnection === connection else { + return + } + self.error = error.localizedDescription + render() + } + } + } + private func watchAutomationTabs(_ connection: RPCConnection) { + automationMetadata = Task { [weak self] in + guard let self else { return } + do { + let events = try await connection.subscribe("subscribePreviewEvents") + defer { Task { await connection.cancel(events.id) } } + for try await event in events.values { + guard !Task.isCancelled, + self.connection === connection && self.store.browserConnection === connection, + let thread = event["threadId"].string, let tabID = event["tabId"].string + else { return } + let key = key(tabID, thread: thread) + if event["type"].string == "closed" { + automationHost.interrupt(threadID: thread) + automationSurfaces.removeValue(forKey: key)?.close() + pages.removeValue(forKey: key)?.dispose() + } else if !(active && self.threadID == thread), + let tab = try? BrowserTab(event["snapshot"]), let page = pages[key] + { + page.receive(tab) + automationSurfaces[key]?.viewport.setting = + (try? BrowserViewport(tab.viewport)) ?? .fill + } + automationHost.refreshFocus() + } + } catch { + guard !Task.isCancelled, + self.connection === connection && self.store.browserConnection === connection + else { return } + self.error = "Browser synchronization ended: " + error.localizedDescription + render() + } + } + } + + func automate(_ request: JSON, deadline: BrowserAutomationDeadline) async throws -> JSON { + _ = view + try deadline.check() + guard let connection, let endpoint else { throw BrowserAutomationError.interrupted } + let thread = try request.requiredString("threadId") + let operation = try request.requiredString("operation") + let input = request["input"] + var listing = try await connection.call( + "preview.list", payload: .object(["threadId": .string(thread)])) + try deadline.check() + guard self.connection === connection && self.store.browserConnection === connection else { + throw BrowserAutomationError.interrupted + } + let tabs = try listing["sessions"].array.map(BrowserTab.init).filter { $0.threadID == thread } + let requestedID = request["tabId"].string ?? input["tabId"].string + var tab = requestedID.flatMap { id in tabs.first { $0.id == id } } + if operation == "recordingStop", request["tabIdExplicit"] != .bool(true) { + let recordingTabs = pages.values.filter { $0.threadID == thread && $0.recording.active }.map( + \.tabID) + if recordingTabs.count == 1 { tab = tabs.first { $0.id == recordingTabs[0] } } + } + if requestedID == nil, tab == nil { + tab = tabs.first { $0.id == selections[thread] } ?? tabs.first + } else if tab == nil { + throw BrowserAutomationError( + "PreviewAutomationTabNotFoundError", + "That browser tab no longer belongs to this conversation.") + } + if operation == "status", tab == nil { + return .object([ + "available": .bool(false), "visible": .bool(false), "tabId": .null, "url": .null, + "title": .null, "loading": .bool(false), + ]) + } + if operation == "open", tab == nil || input["reuseExistingTab"] == .bool(false) { + var payload: [String: JSON] = [ + "threadId": .string(thread), + "profileId": .string(preferences.values.defaultProfileID), + "viewport": preferences.values.defaultViewport.width == nil + ? try BrowserViewport(width: 1280, height: 800).json : preferences.values.viewport, + ] + if let url = input["url"].string { + payload["url"] = .string( + try BrowserURL.resolve(BrowserURL.normalize(url), environment: endpoint).absoluteString) + } + tab = try BrowserTab(await connection.call("preview.open", payload: .object(payload))) + } + try deadline.check() + guard self.connection === connection, store.browserConnection === connection else { + throw BrowserAutomationError.interrupted + } + guard var tab else { + throw BrowserAutomationError( + "PreviewAutomationTabNotFoundError", "Open a browser tab before using it.") + } + if chromium.enabled { + return try await automateChromium( + tab, operation: operation, input: input, + connection: connection, endpoint: endpoint, deadline: deadline) + } + let key = key(tab.id, thread: thread) + let page = pages[key] ?? makePage(tab, connection: connection, endpoint: endpoint) + guard !page.picker.active else { throw BrowserAutomationError.interrupted } + let controlRevision = page.automation.controlRevision + page.activeAutomation += 1 + defer { page.activeAutomation -= 1 } + if !["status", "snapshot", "waitFor"].contains(operation) { page.agentDriven = true } + let id = request["requestId"].string ?? UUID().uuidString + let started = Date().ISO8601Format() + page.automation.record(operation, status: "running", id: id, started: started) + do { + let explicitPresentation = input["open"] == .null ? input["show"] : input["open"] + if operation == "open" { + page.suppressAgentPresentation = explicitPresentation == .bool(false) + } + let reveal = + operation == "open" + ? (explicitPresentation == .null + ? preferences.values.showAgentPreview : explicitPresentation.bool) + : operation != "status" && preferences.values.showAgentPreview + && !page.suppressAgentPresentation + if reveal, self.threadID == thread { + selections[thread] = tab.id + selectedID = tab.id + onAgentReveal?() + } + if self.active, self.threadID == thread, selectedID == tab.id { + listing = try await connection.call( + "preview.list", payload: .object(["threadId": .string(thread)])) + try directory.snapshot(listing) + reconcile() + } + if page.web.superview == nil || page.web.window == nil { + let surface = automationSurfaces[key] ?? BrowserAutomationSurface() + automationSurfaces[key] = surface + surface.onStop = { [weak self] in self?.automationHost.setPaused(true) } + surface.attach( + page, show: reveal, + title: "Browser · " + (store.shell.threads[thread]?.title ?? "Background conversation")) + } else if reveal, let surface = automationSurfaces[key], shownPage !== page { + surface.window.orderFront(nil) + } + page.receive(tab) + let expectedPage = page + func check() throws { + try deadline.check() + guard self.connection === connection && self.store.browserConnection === connection, + self.pages[key] === expectedPage, + page.automation.controlRevision == controlRevision + else { + throw BrowserAutomationError.interrupted + } + } + if operation == "navigate" || operation == "open" && input["url"].string != nil { + let url = try navigationURL(input, environment: endpoint) + tab = try BrowserTab( + await connection.call( + "preview.navigate", + payload: .object([ + "threadId": .string(thread), "tabId": .string(tab.id), + "url": .string(url.absoluteString), + ]))) + try check() + page.receive(tab, navigate: true) + let readiness = input["readiness"].string ?? "load" + while readiness != "none" { + try check() + if let error = page.failure { throw T3Error(error) } + if readiness == "domContentLoaded", page.navigationCommitted { + let state = try await page.automation.query("ready", deadline: deadline).string + if state == "interactive" || state == "complete" { break } + } else if !page.navigationPending && !page.web.isLoading && page.web.url != nil + && page.web.url?.absoluteString != "about:blank" + { + break + } + try await Task.sleep(for: .milliseconds(40)) + } + } + let result: JSON + switch operation { + case "open", "navigate", "status": + result = try await automationStatus(page, deadline: deadline) + case "resize": + let viewport = try automationViewport(input) + tab = try BrowserTab( + await connection.call( + "preview.resize", + payload: .object([ + "threadId": .string(thread), "tabId": .string(tab.id), "viewport": viewport.json, + ]))) + try check() + page.receive(tab) + let surface = shownPage === page ? host : automationSurfaces[key]?.viewport + surface?.setting = viewport + surface?.layoutSubtreeIfNeeded() + var measured = JSON.null + repeat { + try check() + measured = try await page.automation.query("viewport", deadline: deadline) + if viewport.width == nil + || measured["width"].int == viewport.width && measured["height"].int == viewport.height + { + break + } + try await Task.sleep(for: .milliseconds(40)) + } while true + result = .object(["tabId": .string(tab.id), "setting": viewport.json, "viewport": measured]) + case "setColorScheme": + let scheme = input["colorScheme"].string ?? "system" + guard ["system", "light", "dark"].contains(scheme) else { + throw T3Error("Unknown browser appearance.") + } + page.web.appearance = + scheme == "system" ? nil : NSAppearance(named: scheme == "dark" ? .darkAqua : .aqua) + result = .object(["tabId": .string(tab.id), "colorScheme": .string(scheme)]) + case "recordingStart": + guard !page.recording.active else { + throw BrowserAutomationError( + "PreviewAutomationRecordingAlreadyActiveError", "This browser tab is already recording." + ) + } + let options = recordingOptions + try await page.recording.start(options: options, directory: BrowserArtifacts.directory) { + [weak page] options in + guard let page else { throw CancellationError() } + return try await page.captureImage( + mouse: options.showMouse, keys: options.showKeys, deterministic: false) + } + try check() + recordingPage = page + result = .object([ + "tabId": .string(tab.id), "recording": .bool(true), + "startedAt": page.recording.startedAt.map { .string($0.ISO8601Format()) } ?? .null, + ]) + case "recordingStop": + guard page.recording.readyToStop else { + throw BrowserAutomationError( + "PreviewAutomationRecordingNotActiveError", "This browser tab is not recording.") + } + let file = try await page.recording.stop() + try check() + if recordingPage === page { recordingPage = nil } + let size = try file.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? 0 + var artifact: [String: JSON] = [ + "id": .string(file.deletingPathExtension().lastPathComponent), "tabId": .string(tab.id), + "path": .string(file.path), "mimeType": .string("video/mp4"), + "sizeBytes": .number(Double(size)), "createdAt": .string(Date().ISO8601Format()), + ] + if input["transferToEnvironment"].bool { + artifact["uploadedAttachmentId"] = .string( + try await BrowserRecordingUpload.upload( + file, + connection: connection, endpoint: endpoint, deadline: deadline, check: check)) + } + result = .object(artifact) + case "snapshot": + let content = try await page.automation.run(operation, input: input, deadline: deadline) + try check() + let file = try await page.capture() + defer { try? FileManager.default.removeItem(at: file.deletingLastPathComponent()) } + try check() + let data = try Data(contentsOf: file) + guard data.count <= 16 * 1024 * 1024 else { + throw BrowserAutomationError( + "PreviewAutomationResultTooLargeError", + "The browser screenshot is too large. Reduce the viewport and retry.") + } + guard case .object(var fields) = content, let image = NSBitmapImageRep(data: data) else { + throw T3Error("Could not capture the browser.") + } + fields["screenshot"] = .object([ + "mimeType": .string("image/png"), "data": .string(data.base64EncodedString()), + "width": .number(Double(image.pixelsWide)), "height": .number(Double(image.pixelsHigh)), + ]) + fields["actionTimeline"] = .array(page.automation.actions) + result = .object(fields) + default: result = try await page.automation.run(operation, input: input, deadline: deadline) + } + try check() + page.automation.record(operation, status: "succeeded", id: id, started: started) + render() + return result + } catch { + let interrupted = + error is CancellationError + || (error as? BrowserAutomationError)?.tag == "PreviewAutomationControlInterruptedError" + if operation == "recordingStart", interrupted { page.recording.cancel() } + page.automation.record( + operation, status: interrupted ? "interrupted" : "failed", id: id, + started: started, error: error.localizedDescription) + throw error + } + } + + private var chromiumEnvironment: String? { store.browserEnvironmentID } + private func chromiumModeChanged() { + if chromium.enabled { + guard !recordingActive else { + chromium.enabled = false + error = "Save or cancel your recording before switching browsers." + render() + return + } + for page in pages.values { + page.dispose() + page.web.removeFromSuperview() + } + pages = [:] + shownPage = nil + for surface in automationSurfaces.values { surface.close() } + automationSurfaces = [:] + recordingPage?.recording.cancel() + recordingPage = nil + } + chromiumOpening = [] + chromiumURLs = [:] + showSelected() + render() + } + @objc private func useChromiumBrowser() { + guard !recordingActive else { + error = "Save or cancel your recording before switching browsers." + render() + return + } + chromium.enabled = true + chromiumModeChanged() + } + @objc private func useNativeBrowser() { + chromium.enabled = false + chromiumModeChanged() + } + @objc private func revealChromiumBrowser() { + guard chromium.enabled, let connection, let endpoint, + let tab = directory.tabs.first(where: { $0.id == selectedID }), + let environment = chromiumEnvironment + else { return } + if !chromium.owns(environment: environment, thread: tab.threadID, tab: tab.id) { + chromiumURLs.removeValue( + forKey: BrowserChromiumController.pageKey( + environment: environment, thread: tab.threadID, tab: tab.id)) + handoffChromium(tab, connection: connection, endpoint: endpoint) + } else { + chromiumCommand("reveal") + } + } + @objc private func openChromiumPassword() { chromiumCommand("popup") } + @objc private func closeChromiumPage() { chromiumCommand("close") } + @objc private func stopChromium() { + Task { [weak self] in + guard let self, await chromium.finishBeforeQuit(window: view.window) else { return } + chromium.stop() + chromiumURLs = [:] + } + } + @objc private func installChromiumPassword() { + Task { [weak self] in await self?.chromium.install() } + } + @objc private func importChromiumPassword() { + Task { [weak self] in + guard let self else { return } + await chromium.importSelectedProfile(window: view.window) + } + } + private func chromiumCommand(_ method: String, extra: [String: JSON] = [:]) { + guard let connection, store.browserConnection === connection, + let environment = chromiumEnvironment, let threadID, let selectedID + else { return } + var input = chromium.context(environment: environment, thread: threadID, tab: selectedID) + input.merge(extra) { _, new in new } + Task { [weak self] in + guard let self else { return } + do { _ = try await chromium.request(method, input: .object(input)) } catch { + guard store.browserConnection === connection else { return } + self.error = error.localizedDescription + render() + } + } + } + private func handoffChromium(_ tab: BrowserTab, connection: RPCConnection, endpoint: URL) { + guard let environment = chromiumEnvironment else { return } + let key = BrowserChromiumController.pageKey( + environment: environment, thread: tab.threadID, tab: tab.id) + let url = + (try? tab.url.map { try BrowserURL.resolve($0, environment: endpoint).absoluteString }) + ?? "about:blank" + if chromiumURLs[key] == url || chromiumOpening.contains(key) { return } + chromiumOpening.insert(key) + Task { [weak self] in + guard let self else { return } + defer { chromiumOpening.remove(key) } + do { + try await ensureChromium(tab, connection: connection, endpoint: endpoint, show: true) + guard self.connection === connection, store.browserConnection === connection, + chromium.enabled + else { return } + if chromiumURLs[key] != url { + let input = chromium.context(environment: environment, thread: tab.threadID, tab: tab.id) + .merging(["url": .string(url)]) { _, new in new } + _ = try await chromium.request("navigate", input: .object(input)) + } + guard self.connection === connection, store.browserConnection === connection, + chromium.enabled + else { return } + chromiumURLs[key] = url + } catch { + guard self.connection === connection, store.browserConnection === connection, + chromium.enabled + else { return } + self.error = error.localizedDescription + render() + } + } + } + private func ensureChromium( + _ tab: BrowserTab, connection: RPCConnection, endpoint: URL, show: Bool + ) + async throws + { + guard let environment = chromiumEnvironment, chromium.enabled, + self.connection === connection, store.browserConnection === connection + else { throw BrowserAutomationError.interrupted } + let key = BrowserChromiumController.pageKey( + environment: environment, thread: tab.threadID, tab: tab.id) + guard !chromium.owns(environment: environment, thread: tab.threadID, tab: tab.id) else { + return + } + let url = + try tab.url.map { try BrowserURL.resolve($0, environment: endpoint).absoluteString } + ?? "about:blank" + var payload = chromium.context(environment: environment, thread: tab.threadID, tab: tab.id) + payload["url"] = .string(url) + payload["profileId"] = .string(tab.profileID ?? preferences.values.defaultProfileID) + payload["viewport"] = tab.viewport + payload["show"] = .bool(show) + _ = try await chromium.request("open", input: .object(payload)) + guard self.connection === connection, store.browserConnection === connection, chromium.enabled, + chromiumEnvironment == environment + else { throw BrowserAutomationError.interrupted } + chromiumURLs[key] = url + } + private func chromiumEvent(_ event: String, value: JSON) { + guard let connection, let endpoint, store.browserConnection === connection, + value["environmentID"].string == chromiumEnvironment, + let thread = value["threadId"].string, let tab = value["tabId"].string + else { return } + if event == "interrupted" { + automationHost.setPaused(true) + return + } + if event == "newPage", thread == threadID, let url = value["url"].string { + open(url, newTab: true) + return + } + guard event == "closed" || event == "page" && !value["loading"].bool else { return } + let environment = chromiumEnvironment + Task { [weak self] in + guard let self else { return } + do { + let listing = try await connection.call( + "preview.list", payload: .object(["threadId": .string(thread)])) + guard self.connection === connection, store.browserConnection === connection, + chromiumEnvironment == environment, chromium.enabled, + listing["sessions"].array.contains(where: { + $0["tabId"].string == tab && $0["threadId"].string == thread + }) + else { return } + if event == "closed" { + _ = try await connection.call( + "preview.close", payload: .object(["threadId": .string(thread), "tabId": .string(tab)])) + return + } + guard let raw = value["url"].string, let url = URL(string: raw), + ["http", "https"].contains(url.scheme ?? ""), raw.utf8.count <= 2048 + else { return } + _ = try BrowserURL.resolve(url, environment: endpoint) + let key = BrowserChromiumController.pageKey( + environment: environment ?? "", thread: thread, tab: tab) + guard chromium.pages[key]?["url"].string == raw else { return } + chromiumURLs[key] = raw + _ = try await connection.call( + "preview.reportStatus", + payload: .object([ + "threadId": .string(thread), "tabId": .string(tab), + "navStatus": .object([ + "_tag": .string("Success"), "url": .string(raw), + "title": .string(String((value["title"].string ?? "").prefix(512))), + ]), + "canGoBack": value["canGoBack"], "canGoForward": value["canGoForward"], + ])) + } catch { + guard self.connection === connection, store.browserConnection === connection else { return } + self.error = error.localizedDescription + render() + } + } + } + private func automateChromium( + _ initial: BrowserTab, operation: String, input: JSON, + connection: RPCConnection, endpoint: URL, deadline: BrowserAutomationDeadline + ) async throws -> JSON { + guard let environment = chromiumEnvironment else { throw BrowserAutomationError.interrupted } + var tab = initial + if operation == "navigate" || operation == "open" && input["url"].string != nil { + let url = try navigationURL(input, environment: endpoint) + tab = try BrowserTab( + await connection.call( + "preview.navigate", + payload: .object([ + "threadId": .string(tab.threadID), "tabId": .string(tab.id), + "url": .string(url.absoluteString), + ]))) + } + if operation == "resize" { + let viewport = try automationViewport(input) + tab = try BrowserTab( + await connection.call( + "preview.resize", + payload: .object([ + "threadId": .string(tab.threadID), "tabId": .string(tab.id), "viewport": viewport.json, + ]))) + } + try deadline.check() + guard self.connection === connection, store.browserConnection === connection, + chromiumEnvironment == environment + else { throw BrowserAutomationError.interrupted } + let presentation = input["open"] == .null ? input["show"] : input["open"] + let show = presentation == .null ? preferences.values.showAgentPreview : presentation.bool + try await ensureChromium(tab, connection: connection, endpoint: endpoint, show: show) + var action = input + if operation == "navigate" || operation == "open" { + action = .object([ + "url": .string( + try tab.url.map { try BrowserURL.resolve($0, environment: endpoint).absoluteString } + ?? "about:blank"), + "readiness": input["readiness"] == .null ? .string("load") : input["readiness"], + ]) + } + if operation == "resize" { action = .object(["viewport": tab.viewport]) } + var payload = chromium.context(environment: environment, thread: tab.threadID, tab: tab.id) + payload["operation"] = .string(operation == "open" ? "navigate" : operation) + payload["input"] = action + let remaining = deadline.remaining.components + let milliseconds = max( + 1, Int(remaining.seconds * 1000 + remaining.attoseconds / 1_000_000_000_000_000)) + payload["timeoutMs"] = .number(Double(milliseconds)) + let result = try await chromium.request( + "automation", input: .object(payload), milliseconds: milliseconds) + try deadline.check() + guard self.connection === connection, store.browserConnection === connection, + chromiumEnvironment == environment, chromium.enabled + else { throw BrowserAutomationError.interrupted } + return result + } + private func captureChromium(annotate: Bool) { + guard let connection, let endpoint, let threadID, + let tab = directory.tabs.first(where: { $0.id == selectedID }) + else { return } + Task { [weak self] in + guard let self else { return } + do { + let result = try await automateChromium( + tab, operation: "snapshot", input: .object([:]), + connection: connection, endpoint: endpoint, + deadline: BrowserAutomationDeadline(milliseconds: 30_000)) + guard store.selectedID == threadID, store.browserConnection === connection, + let encoded = result["screenshot"]["data"].string, + let data = Data(base64Encoded: encoded), let image = NSImage(data: data) + else { return } + if annotate { + let window = BrowserAnnotationWindow(image: image, pageURL: result["url"].string ?? "") + window.onSave = { [weak self] file, attach in + guard let self else { return } + onArtifactSaved?(file) + guard store.selectedID == threadID, store.browserConnection === connection else { + return + } + lastArtifact = (file, threadID, connection) + if attach, store.canAttach { + store.addAttachments([file]) + onContextAdded?() + } + } + window.onClose = { [weak self, weak window] in + self?.annotationWindows.removeAll { $0 === window } + } + annotationWindows.append(window) + window.showWindow(nil) + window.window?.makeKeyAndOrderFront(nil) + } else { + try FileManager.default.createDirectory( + at: BrowserArtifacts.directory, withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700]) + let file = BrowserArtifacts.directory.appendingPathComponent( + "chromium-" + UUID().uuidString + ".png") + try data.write(to: file, options: .atomic) + lastArtifact = (file, threadID, connection) + onArtifactSaved?(file) + if store.canAttach { + store.addAttachments([file]) + onContextAdded?() + } + } + } catch { + guard store.browserConnection === connection else { return } + self.error = error.localizedDescription + render() + } + } + } + + private func automationStatus(_ page: BrowserPage, deadline: BrowserAutomationDeadline) + async throws -> JSON + { + var status: [String: JSON] = [ + "available": .bool(true), + "visible": .bool(page.web.window?.isVisible == true && !page.web.isHidden), + "tabId": .string(page.tabID), + "url": page.displayedURL.map { .string($0.absoluteString) } ?? .null, + "title": page.web.title.map(JSON.string) ?? .null, "loading": .bool(page.web.isLoading), + "viewportSetting": page.snapshot.viewport, + ] + if page.web.url != nil { + status["viewport"] = try await page.automation.query("viewport", deadline: deadline) + } + return .object(status) + } + private func navigationURL(_ input: JSON, environment: URL) throws -> URL { + let target = input["target"] + let raw: String + if target["kind"].string == "environment-port" { + let port = try target.requiredInt("port") + guard (1...65535).contains(port) else { throw T3Error("Invalid local-server port.") } + let path = target["path"].string ?? "/" + raw = + (target["protocol"].string ?? "http") + "://localhost:\(port)" + + (path.hasPrefix("/") ? path : "/" + path) + } else { + raw = try (target["kind"].string == "url" ? target : input).requiredString("url") + } + return try BrowserURL.resolve(BrowserURL.normalize(raw), environment: environment) + } + private func automationViewport(_ input: JSON) throws -> BrowserViewport { + switch input["mode"].string { + case "fill": return .fill + case "freeform": + return try BrowserViewport( + width: input.requiredInt("width"), height: input.requiredInt("height")) + case "preset": + guard + var viewport = BrowserDevicePreset.all.first(where: { $0.id == input["preset"].string })? + .viewport + else { throw T3Error("Unknown device preset.") } + if let orientation = input["orientation"].string, + (orientation == "landscape") != (viewport.width! > viewport.height!) + { + viewport = viewport.rotated + } + return viewport + default: throw T3Error("Unknown viewport mode.") + } + } +} + +extension BrowserPanel: NSMenuItemValidation { + func validateMenuItem(_ item: NSMenuItem) -> Bool { + switch item.action { + case #selector(startRecording): + return !chromium.enabled && page?.web.url != nil && !recordingActive + case #selector(stopRecording): return recordingPage?.recording.readyToStop == true + case #selector(cancelRecording): return recordingActive + case #selector(recordAt30): + item.state = recordingOptions.framesPerSecond == 30 ? .on : .off + return !recordingActive + case #selector(recordAt60): + item.state = recordingOptions.framesPerSecond == 60 ? .on : .off + return !recordingActive + case #selector(toggleRecordingMouse): + item.state = recordingOptions.showMouse ? .on : .off + return !recordingActive + case #selector(toggleRecordingKeys): + item.state = recordingOptions.showKeys ? .on : .off + return !recordingActive + case #selector(annotatePage): + return chromium.enabled + ? selectedID != nil && chromium.running + : page?.web.url != nil && page?.web.isLoading == false + case #selector(useChromiumBrowser): + item.state = chromium.enabled ? .on : .off + return !recordingActive + case #selector(useNativeBrowser): + item.state = chromium.enabled ? .off : .on + return true + case #selector(stopChromium): return chromium.running + case #selector(openChromiumPassword), #selector(closeChromiumPage): + return chromium.enabled && chromium.running && selectedID != nil + case #selector(attachLastArtifact): + return lastArtifact?.thread == store.selectedID + && lastArtifact?.connection === store.browserConnection && store.canAttach + default: return true + } + } +} diff --git a/apps/macos/Sources/T3Native/BrowserPreferences.swift b/apps/macos/Sources/T3Native/BrowserPreferences.swift new file mode 100644 index 0000000000000..764687bfeefe3 --- /dev/null +++ b/apps/macos/Sources/T3Native/BrowserPreferences.swift @@ -0,0 +1,120 @@ +import Combine +import Foundation +import T3Core + +struct BrowserProfile: Codable, Equatable, Identifiable { + let id: String + var name: String + var builtIn: Bool { id == "default" || id == "incognito" } +} + +struct BrowserPreferenceValues: Codable, Equatable { + var profiles: [BrowserProfile] = [] + var defaultProfileID = "default" + var viewport = BrowserViewport.fill.json + var zoom = 1.0 + var appearance = "system" + var externalLinks = false + var showAgentPreview = true + + var availableProfiles: [BrowserProfile] { + [ + BrowserProfile(id: "default", name: "Default"), + BrowserProfile(id: "incognito", name: "Incognito"), + ] + profiles + } + var defaultViewport: BrowserViewport { (try? BrowserViewport(viewport)) ?? .fill } + func validated() throws -> Self { + var result = self + guard zoom.isFinite, (0.25...5).contains(zoom), + ["system", "light", "dark"].contains(appearance) + else { + throw T3Error("Choose a zoom from 25% to 500% and a supported appearance.") + } + guard ["fill", "freeform", "preset"].contains(viewport["_tag"].string ?? "") else { + throw T3Error("Choose a supported browser viewport.") + } + result.viewport = try BrowserViewport(viewport).json + var seen: Set = ["default", "incognito"] + result.profiles = try profiles.filter { seen.insert($0.id).inserted }.map { + guard !$0.id.isEmpty, $0.id.utf16.count <= 64, + $0.id.unicodeScalars.allSatisfy({ !CharacterSet.controlCharacters.contains($0) }) + else { + throw T3Error("Invalid browser profile identity.") + } + return BrowserProfile(id: $0.id, name: try Self.name($0.name)) + } + guard result.profiles.count <= 24 else { + throw T3Error("You can create up to 24 browser profiles.") + } + if defaultProfileID != "default" + && !result.profiles.contains(where: { $0.id == defaultProfileID }) + { + result.defaultProfileID = "default" + } + return result + } + static func name(_ value: String) throws -> String { + let value = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty, value.utf16.count <= 48 else { + throw T3Error("Use a profile name from 1 to 48 characters.") + } + return value + } +} + +@MainActor final class BrowserPreferences: ObservableObject { + @Published private(set) var values = BrowserPreferenceValues() + @Published var error: String? + var onChange: (() -> Void)? + let file: URL + init(file: URL? = nil) { + self.file = + file + ?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("T3Native/browser-preferences.json") + if FileManager.default.fileExists(atPath: self.file.path) { + do { + values = try JSONDecoder().decode( + BrowserPreferenceValues.self, from: Data(contentsOf: self.file) + ).validated() + } catch { + self.error = "Browser preferences could not be read: " + error.localizedDescription + } + } + } + func update(_ change: (inout BrowserPreferenceValues) throws -> Void) throws { + var next = values + try change(&next) + next = try next.validated() + try FileManager.default.createDirectory( + at: file.deletingLastPathComponent(), withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700]) + try JSONEncoder().encode(next).write(to: file, options: .atomic) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: file.path) + values = next + error = nil + onChange?() + } + @discardableResult func create(_ name: String) throws -> BrowserProfile { + let profile = BrowserProfile( + id: "profile-" + UUID().uuidString, name: try BrowserPreferenceValues.name(name)) + try update { $0.profiles.append(profile) } + return profile + } + func rename(_ id: String, to name: String) throws { + let name = try BrowserPreferenceValues.name(name) + try update { values in + guard let index = values.profiles.firstIndex(where: { $0.id == id }) else { + throw T3Error("That profile cannot be renamed.") + } + values.profiles[index].name = name + } + } + func remove(_ id: String) throws { + try update { $0.profiles.removeAll { $0.id == id } } + } + func name(_ id: String?) -> String { + values.availableProfiles.first { $0.id == (id ?? "default") }?.name ?? "Removed profile" + } +} diff --git a/apps/macos/Sources/T3Native/BrowserRecording.swift b/apps/macos/Sources/T3Native/BrowserRecording.swift new file mode 100644 index 0000000000000..bae5f0a4cfc10 --- /dev/null +++ b/apps/macos/Sources/T3Native/BrowserRecording.swift @@ -0,0 +1,247 @@ +import AVFoundation +import AppKit +import T3Core + +struct BrowserRecordingOptions: Equatable { + var framesPerSecond = 30 + var showMouse = true + var showKeys = true + func validate() throws { + guard [30, 60].contains(framesPerSecond) else { throw T3Error("Choose 30 or 60 fps.") } + } +} + +// Browser frames stay on this machine. Only the finished artifact uses attachment upload. +@MainActor final class BrowserRecording { + enum State: Equatable { + case idle, recording, finishing + case saved(URL) + case failed(String) + } + private(set) var state: State = .idle + private(set) var frameCount = 0 + private(set) var startedAt: Date? + var readyToStop: Bool { state == .recording && writer != nil } + private(set) var options = BrowserRecordingOptions() + var onChange: (() -> Void)? + private var changeWaiters: [UUID: CheckedContinuation] = [:] + private var writer: AVAssetWriter? + private var input: AVAssetWriterInput? + private var adaptor: AVAssetWriterInputPixelBufferAdaptor? + private var task: Task? + private var file: URL? + private var startTime = 0.0 + private var dimensions = CGSize.zero + private var revision = UUID() + var active: Bool { state == .recording || state == .finishing } + + func start( + options: BrowserRecordingOptions, directory: URL, + capture: @escaping @MainActor (BrowserRecordingOptions) async throws -> NSImage + ) async throws { + guard !active else { throw T3Error("A browser recording is already in progress.") } + try options.validate() + let revision = UUID() + self.revision = revision + self.options = options + startedAt = Date() + frameCount = 0 + state = .recording + changed() + do { + let image = try await capture(options) + try Task.checkCancellation() + guard self.revision == revision, state == .recording else { throw CancellationError() } + try FileManager.default.createDirectory( + at: directory, withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700]) + let file = directory.appendingPathComponent("Browser recording " + UUID().uuidString + ".mp4") + self.file = file + let writer = try AVAssetWriter(outputURL: file, fileType: .mp4) + self.writer = writer + dimensions = CGSize( + width: max(2, floor(image.size.width / 2) * 2), + height: max(2, floor(image.size.height / 2) * 2)) + let input = AVAssetWriterInput( + mediaType: .video, + outputSettings: [ + AVVideoCodecKey: AVVideoCodecType.h264, + AVVideoWidthKey: dimensions.width, AVVideoHeightKey: dimensions.height, + AVVideoCompressionPropertiesKey: [ + AVVideoExpectedSourceFrameRateKey: options.framesPerSecond, + AVVideoAverageBitRateKey: 4_000_000, + AVVideoMaxKeyFrameIntervalKey: options.framesPerSecond, + ], + ]) + input.expectsMediaDataInRealTime = true + guard writer.canAdd(input) else { throw T3Error("Video encoding is unavailable.") } + writer.add(input) + self.input = input + adaptor = AVAssetWriterInputPixelBufferAdaptor( + assetWriterInput: input, + sourcePixelBufferAttributes: [ + kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32ARGB, + kCVPixelBufferWidthKey as String: Int(dimensions.width), + kCVPixelBufferHeightKey as String: Int(dimensions.height), + kCVPixelBufferCGImageCompatibilityKey as String: true, + kCVPixelBufferCGBitmapContextCompatibilityKey as String: true, + ]) + guard writer.startWriting() else { + throw writer.error ?? T3Error("Could not start video encoding.") + } + writer.startSession(atSourceTime: .zero) + startTime = ProcessInfo.processInfo.systemUptime + try append(image, seconds: 0) + changed() + task = Task { [weak self] in + guard let self else { return } + let interval = 1.0 / Double(options.framesPerSecond) + var next = startTime + interval + do { + while state == .recording, self.revision == revision, !Task.isCancelled { + let wait = next - ProcessInfo.processInfo.systemUptime + if wait > 0 { try await Task.sleep(for: .seconds(wait)) } + // One capture at a time, with writer backpressure. Never accumulate image frames. + guard writer.status == .writing else { + throw writer.error ?? T3Error("Browser encoder stopped.") + } + if input.isReadyForMoreMediaData { + let image = try await capture(options) + guard self.revision == revision, state == .recording, !Task.isCancelled else { + return + } + try append(image, seconds: ProcessInfo.processInfo.systemUptime - startTime) + } + next = max(next + interval, ProcessInfo.processInfo.systemUptime) + let nearSizeLimit = + frameCount % options.framesPerSecond == 0 + && ((try? file.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0) >= 48 * 1024 + * 1024 + if ProcessInfo.processInfo.systemUptime - startTime >= 600 || nearSizeLimit { + _ = try await stop() + return + } + } + } catch { + if self.revision == revision, !Task.isCancelled { + cancel() + state = .failed(error.localizedDescription) + changed() + } + } + } + } catch { + if self.revision == revision { + cancel() + state = .failed(error.localizedDescription) + changed() + } + throw error + } + } + + private func append(_ image: NSImage, seconds: Double) throws { + guard let writer, let input, let adaptor, writer.status == .writing else { + throw self.writer?.error ?? T3Error("Browser encoder stopped.") + } + guard input.isReadyForMoreMediaData else { return } + var buffer: CVPixelBuffer? + if let pool = adaptor.pixelBufferPool { + guard CVPixelBufferPoolCreatePixelBuffer(nil, pool, &buffer) == kCVReturnSuccess else { + throw T3Error("Could not allocate a recording frame.") + } + } + guard let buffer else { throw T3Error("Recording frame pool is unavailable.") } + CVPixelBufferLockBaseAddress(buffer, []) + defer { CVPixelBufferUnlockBaseAddress(buffer, []) } + guard + let context = CGContext( + data: CVPixelBufferGetBaseAddress(buffer), + width: Int(dimensions.width), height: Int(dimensions.height), bitsPerComponent: 8, + bytesPerRow: CVPixelBufferGetBytesPerRow(buffer), space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.noneSkipFirst.rawValue), + let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) + else { throw T3Error("Could not render a recording frame.") } + context.setFillColor(NSColor.black.cgColor) + context.fill(CGRect(origin: .zero, size: dimensions)) + let scale = min(dimensions.width / image.size.width, dimensions.height / image.size.height) + let size = CGSize(width: image.size.width * scale, height: image.size.height * scale) + context.draw( + cgImage, + in: CGRect( + x: (dimensions.width - size.width) / 2, + y: (dimensions.height - size.height) / 2, width: size.width, height: size.height)) + guard + adaptor.append( + buffer, withPresentationTime: CMTime(seconds: seconds, preferredTimescale: 60_000)) + else { throw writer.error ?? T3Error("Could not append browser video frame.") } + frameCount += 1 + } + + @discardableResult func stop() async throws -> URL { + guard state == .recording, let writer, let input, let file else { + throw T3Error("There is no browser recording to finish.") + } + state = .finishing + let revision = revision + task?.cancel() + task = nil + changed() + input.markAsFinished() + await writer.finishWriting() + guard self.revision == revision else { throw CancellationError() } + guard writer.status == .completed else { + let error = writer.error ?? T3Error("Could not save the browser recording.") + cancel() + state = .failed(error.localizedDescription) + changed() + throw error + } + self.writer = nil + self.input = nil + adaptor = nil + self.file = nil + state = .saved(file) + changed() + return file + } + func cancel() { + revision = UUID() + task?.cancel() + task = nil + writer?.cancelWriting() + writer = nil + input = nil + adaptor = nil + if let file { try? FileManager.default.removeItem(at: file) } + file = nil + state = .idle + startedAt = nil + changed() + } + private func changed() { + let waiters = Array(changeWaiters.values) + changeWaiters = [:] + for waiter in waiters { waiter.resume() } + onChange?() + } + func finishForQuit() async throws -> URL { + while active { + if readyToStop { return try await stop() } + await withCheckedContinuation { changeWaiters[UUID()] = $0 } + } + switch state { + case .saved(let file): return file + case .failed(let message): throw T3Error(message) + default: throw T3Error("The recording was cancelled before it could be saved.") + } + } + isolated deinit { cancel() } +} + +@MainActor enum BrowserArtifacts { + static var directory: URL { + FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("T3Native/BrowserArtifacts", isDirectory: true) + } +} diff --git a/apps/macos/Sources/T3Native/BrowserRecordingUpload.swift b/apps/macos/Sources/T3Native/BrowserRecordingUpload.swift new file mode 100644 index 0000000000000..d5e447822e3d8 --- /dev/null +++ b/apps/macos/Sources/T3Native/BrowserRecordingUpload.swift @@ -0,0 +1,64 @@ +import Foundation +import T3Core + +@MainActor enum BrowserRecordingUpload { + static func upload( + _ file: URL, connection: RPCConnection, endpoint: URL, + deadline: BrowserAutomationDeadline, check: @MainActor () throws -> Void + ) async throws -> String { + try check() + let size = try file.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? 0 + guard size > 0, size <= AttachmentPolicy.fileLimit else { + throw BrowserAutomationError( + "PreviewAutomationRecordingTooLargeError", + "The recording exceeds 50 MiB. The saved copy remains on this Mac.") + } + var minted: String? + do { + let issued = try await connection.call( + "attachments.createUploadUrl", + payload: .object([ + "type": .string("file"), "name": .string(file.lastPathComponent), + "mimeType": .string("video/mp4"), "sizeBytes": .number(Double(size)), + ])) + minted = try issued.requiredString("attachmentId") + try check() + let url = try AttachmentHTTP.signedURL( + issued.requiredString("relativeUrl"), endpoint: endpoint, + prefix: "/api/attachments/upload/") + // A signed upload requires no copied bearer credential. Share the broker request deadline. + let session = URLSession(configuration: .ephemeral) + defer { session.invalidateAndCancel() } + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("video/mp4", forHTTPHeaderField: "Content-Type") + let remaining = deadline.remaining.components + request.timeoutInterval = max( + 0.001, Double(remaining.seconds) + Double(remaining.attoseconds) / 1e18) + let (_, response) = try await session.upload(for: request, fromFile: file) + try check() + guard let response = response as? HTTPURLResponse, (200..<300).contains(response.statusCode), + let minted + else { throw T3Error("Browser recording upload failed.") } + return minted + } catch { + if let minted { + // Cancellation must still delete the unclaimed pending attachment on the original server. + let cleanup = Task { + _ = try? await connection.call( + "attachments.delete", payload: .object(["attachmentId": .string(minted)])) + } + await cleanup.value + } + if error is CancellationError + || (error as? BrowserAutomationError)?.tag == BrowserAutomationError.interrupted.tag + { + throw error + } + throw BrowserAutomationError( + "PreviewAutomationRecordingTransferError", + "The recording could not be transferred. The saved copy remains on this Mac. " + + error.localizedDescription) + } + } +} diff --git a/apps/macos/Sources/T3Native/BrowserSettings.swift b/apps/macos/Sources/T3Native/BrowserSettings.swift new file mode 100644 index 0000000000000..78d9767b74bea --- /dev/null +++ b/apps/macos/Sources/T3Native/BrowserSettings.swift @@ -0,0 +1,291 @@ +import AppKit +import SwiftUI +import T3Core + +@MainActor final class BrowserSettingsWindow: NSWindowController { + init( + preferences: BrowserPreferences, profiles: BrowserProfiles, chromium: BrowserChromiumController, + onModeChange: @escaping () -> Void + ) { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 620, height: 660), + styleMask: [.titled, .closable, .resizable], backing: .buffered, defer: false) + super.init(window: window) + window.title = "Browser Settings" + window.isReleasedWhenClosed = false + window.minSize = NSSize(width: 520, height: 500) + window.contentView = NSHostingView( + rootView: BrowserSettings( + preferences: preferences, profiles: profiles, chromium: chromium, onModeChange: onModeChange + )) + window.center() + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } +} + +private struct BrowserSettings: View { + @ObservedObject var preferences: BrowserPreferences + let profiles: BrowserProfiles + @ObservedObject var chromium: BrowserChromiumController + let onModeChange: () -> Void + @State private var removeExtension = false + @State private var editing: BrowserProfile? + @State private var adding = false + @State private var name = "" + @State private var confirmation: BrowserProfile? + @State private var removing = false + @State private var busy = false + @State private var width = "" + @State private var height = "" + @State private var zoom = "" + + var body: some View { + Form { + if let error = preferences.error { + Text(error).foregroundStyle(.orange).textSelection(.enabled) + } + Section("1Password Browser") { + Toggle("Use managed Chromium browser", isOn: $chromium.enabled).disabled( + chromium.nativeRecordingActive + ) + .onChange(of: chromium.enabled) { _, _ in onModeChange() } + Text( + "The live page opens in a managed floating window. 1Password runs its genuine browser extension there." + ) + .font(.caption).foregroundStyle(.secondary) + Text(chromium.state + " · " + chromium.passwordStatus).font(.caption) + if !chromium.available { + Text("This app needs its packaged Apple Silicon Chromium helper.").foregroundStyle( + .orange) + } + if let error = chromium.error { + Text(error).foregroundStyle(.orange).textSelection(.enabled) + } + HStack { + Button("Install official 1Password extension") { Task { await chromium.install() } } + Button("Import from browser…") { + Task { await chromium.importSelectedProfile(window: nil) } + } + }.disabled(!chromium.available) + Button("Remove 1Password extension", role: .destructive) { removeExtension = true } + .disabled(chromium.extensions.isEmpty) + .confirmationDialog( + "Remove the managed 1Password extension?", isPresented: $removeExtension + ) { + Button("Remove extension", role: .destructive) { + Task { await chromium.removeExtension() } + } + } + Button("Stop Chromium helper") { + Task { if await chromium.finishBeforeQuit(window: nil) { chromium.stop() } } + }.disabled(!chromium.running) + Text( + "For desktop unlock, choose an Apple code signed T3 Browser in Applications using 1Password Settings → Browser → Add Browser. Trust grants the browser access to unlocked 1Password information for saving and filling. This setup is your choice; T3 does not unlock your vault." + ) + .font(.caption).foregroundStyle(.secondary) + Button("Show T3 Browser in Finder") { + NSWorkspace.shared.activateFileViewerSelecting([ + Bundle.main.bundleURL.appendingPathComponent( + "Contents/Resources/chromium-helper/T3 Browser.app") + ]) + }.disabled(!chromium.available) + Link( + "1Password browser setup", + destination: URL(string: "https://support.1password.com/additional-browsers/")!) + } + Section("New tabs") { + Picker("Default profile", selection: binding(\.defaultProfileID)) { + ForEach(preferences.values.availableProfiles.filter { $0.id != "incognito" }) { + Text($0.name).tag($0.id) + } + } + Picker( + "Viewport", + selection: Binding( + get: { + preferences.values.defaultViewport.width == nil + ? "fill" : preferences.values.defaultViewport.presetID ?? "custom" + }, set: { id in setViewport(id) }) + ) { + Text("Fit panel").tag("fill") + Text("Custom").tag("custom") + ForEach(BrowserDevicePreset.all, id: \.id) { Text($0.name).tag($0.id) } + } + if preferences.values.defaultViewport.width != nil { + HStack { + TextField("Width", text: $width).accessibilityLabel("Default viewport width").onSubmit( + applySize) + Text("×") + TextField("Height", text: $height).accessibilityLabel("Default viewport height") + .onSubmit(applySize) + Button("Apply", action: applySize) + } + } + HStack { + TextField("Zoom (%)", text: $zoom).onSubmit(applyZoom) + Button("Apply", action: applyZoom).accessibilityLabel("Apply default zoom") + } + Picker("Appearance", selection: binding(\.appearance)) { + Text("System").tag("system") + Text("Light").tag("light") + Text("Dark").tag("dark") + } + Text("These defaults apply when a page opens. Existing pages keep their current view.") + .font(.caption).foregroundStyle(.secondary) + } + Section("Links and agents") { + Toggle("Open conversation links in the default browser", isOn: binding(\.externalLinks)) + Toggle( + "Show floating previews when an agent uses Browser", isOn: binding(\.showAgentPreview)) + } + Section { + ForEach(preferences.values.availableProfiles.filter { $0.id != "incognito" }) { profile in + HStack { + Text(profile.name).lineLimit(1) + if preferences.values.defaultProfileID == profile.id { + Text("Default").font(.caption).foregroundStyle(.secondary) + } + Spacer() + Menu { + Button("Set as default") { save { $0.defaultProfileID = profile.id } } + .disabled(preferences.values.defaultProfileID == profile.id) + if !profile.builtIn { + Button("Rename…") { + name = profile.name + editing = profile + } + } + Button("Clear cookies and cache…") { + removing = false + confirmation = profile + } + if !profile.builtIn { + Button("Remove profile and data…", role: .destructive) { + removing = true + confirmation = profile + } + } + } label: { + Image(systemName: "ellipsis") + } + .menuStyle(.borderlessButton).fixedSize().accessibilityLabel(profile.name + " options") + } + } + Button("Add profile…") { + name = "" + adding = true + }.disabled(preferences.values.profiles.count >= 24) + } header: { + Text("Profiles") + } footer: { + Text("Profiles separate cookies and logins. Incognito data is cleared when the app closes.") + } + } + .formStyle(.grouped).disabled(busy).onAppear(perform: updateFields) + .onChange(of: preferences.values.viewport) { _, _ in updateFields() } + .onChange(of: preferences.values.zoom) { _, _ in updateFields() } + .sheet( + isPresented: Binding( + get: { adding || editing != nil }, + set: { + if !$0 { + adding = false + editing = nil + } + }) + ) { + VStack(alignment: .leading, spacing: 16) { + Text(adding ? "Add profile" : "Rename profile").font(.headline) + TextField("Profile name", text: $name).onSubmit(saveName) + if let error = preferences.error { Text(error).foregroundStyle(.orange).font(.caption) } + HStack { + Spacer() + Button("Cancel") { + adding = false + editing = nil + }.keyboardShortcut(.cancelAction) + Button("Save", action: saveName).keyboardShortcut(.defaultAction) + } + }.padding(24).frame(width: 360) + } + .confirmationDialog( + removing ? "Remove profile and data?" : "Clear cookies and cache?", + isPresented: Binding(get: { confirmation != nil }, set: { if !$0 { confirmation = nil } }), + titleVisibility: .visible, presenting: confirmation + ) { profile in + Button(removing ? "Remove profile" : "Clear data", role: .destructive) { + let remove = removing + busy = true + Task { @MainActor in + do { + if chromium.enabled { + try await chromium.clearProfile(profile.id, kinds: .all) + } else { + await profiles.clear(profile: profile.id, kinds: .all) + } + if remove { try preferences.remove(profile.id) } + } catch { preferences.error = error.localizedDescription } + busy = false + confirmation = nil + } + } + } message: { profile in + Text( + "This signs you out of \(profile.name) and removes its website data. Existing tabs stay open." + ) + } + } + private func binding(_ path: WritableKeyPath) -> Binding< + Value + > { + Binding( + get: { preferences.values[keyPath: path] }, + set: { value in save { $0[keyPath: path] = value } }) + } + private func save(_ change: (inout BrowserPreferenceValues) throws -> Void) { + do { try preferences.update(change) } catch { preferences.error = error.localizedDescription } + } + private func saveName() { + do { + if adding { + try preferences.create(name) + } else if let editing { + try preferences.rename(editing.id, to: name) + } + adding = false + editing = nil + } catch { preferences.error = error.localizedDescription } + } + private func updateFields() { + width = preferences.values.defaultViewport.width.map(String.init) ?? "1280" + height = preferences.values.defaultViewport.height.map(String.init) ?? "800" + zoom = String(Int((preferences.values.zoom * 100).rounded())) + } + private func setViewport(_ id: String) { + save { + if id == "fill" { + $0.viewport = BrowserViewport.fill.json + } else if id == "custom" { + $0.viewport = try BrowserViewport(width: 1280, height: 800).json + } else if let preset = BrowserDevicePreset.all.first(where: { $0.id == id }) { + $0.viewport = preset.viewport.json + } + } + } + private func applySize() { + save { + guard let width = Int(width), let height = Int(height) else { + throw T3Error("Enter whole numbers for the viewport dimensions.") + } + $0.viewport = try BrowserViewport(width: width, height: height).json + } + } + private func applyZoom() { + save { + guard let value = Double(zoom) else { + throw T3Error("Enter a zoom percentage from 25 to 500.") + } + $0.zoom = value / 100 + } + } +} diff --git a/apps/macos/Sources/T3Native/BrowserSmoke.swift b/apps/macos/Sources/T3Native/BrowserSmoke.swift new file mode 100644 index 0000000000000..1c293ef9b763d --- /dev/null +++ b/apps/macos/Sources/T3Native/BrowserSmoke.swift @@ -0,0 +1,413 @@ +import AppKit +import T3Core +import WebKit + +extension ParitySmoke { + @MainActor static func browser( + settings: ConnectionSettings, connection: RPCConnection, threadID: String + ) async throws { + _ = NSApplication.shared + guard let text = Arguments.value("--browser-fixture"), let fixture = URL(string: text), + BrowserURL.loopback(fixture.host ?? ""), BrowserURL.loopback(settings.endpoint.host ?? "") + else { + throw T3Error("Browser smoke requires a local --browser-fixture URL and sandbox server.") + } + let input: JSON = .object([ + "threadId": .string(threadID), "url": .string(fixture.absoluteString), + "profileId": .string("incognito"), + ]) + let tab = try BrowserTab(await connection.call("preview.open", payload: input)) + let page = BrowserPage( + tab: tab, environment: settings.endpoint, connection: connection, dataStore: .nonPersistent()) + let window = NSWindow( + contentRect: NSRect(x: 100, y: 100, width: 800, height: 600), + styleMask: [.titled, .closable], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.title = "Native browser verification" + window.contentView = page.web + window.orderFront(nil) + defer { + page.dispose() + window.close() + } + func close() async throws { + _ = try await connection.call( + "preview.close", + payload: .object(["threadId": .string(threadID), "tabId": .string(tab.id)])) + } + func wait(_ condition: () -> Bool) async throws { + let deadline = Date().addingTimeInterval(25) + while !condition() { + if let failure = page.failure { throw T3Error(failure) } + guard Date() < deadline else { + throw T3Error("Browser navigation timed out at \(page.web.url?.path ?? "blank").") + } + try await Task.sleep(for: .milliseconds(30)) + } + } + func serverTab(_ title: String) async throws -> BrowserTab { + let deadline = Date().addingTimeInterval(15) + while Date() < deadline { + if let error = page.synchronizationError { throw T3Error(error) } + let snapshot = try await connection.call( + "preview.list", payload: .object(["threadId": .string(threadID)])) + if let value = snapshot["sessions"].array.first(where: { $0["tabId"].string == tab.id }), + value["navStatus"]["title"].string == title, + value["navStatus"]["_tag"].string == "Success" + { + return try BrowserTab(value) + } + try await Task.sleep(for: .milliseconds(50)) + } + throw T3Error("Browser navigation was not reported to the server.") + } + do { + page.receive(tab) + try await wait { !page.web.isLoading && page.web.title == "Browser fixture One" } + let loaded = try await serverTab("Browser fixture One") + _ = try await page.web.evaluateJavaScript( + "document.getElementById('draft').value='keep this draft'; window.fixtureMarker='original'; 'ok'" + ) + page.receive(loaded) + guard !page.web.isLoading, + try await page.web.evaluateJavaScript("window.fixtureMarker") as? String == "original" + else { throw T3Error("A server echo reloaded the browser page.") } + print("Browser smoke: HTTP load, status reporting and echo without reload") + _ = try await page.web.evaluateJavaScript("document.getElementById('spa').click(); 'ok'") + try await wait { page.web.url?.query == "step=2" } + // URL changes are reported after page scripts have updated the title. + let spa = try await serverTab("Browser fixture SPA") + page.receive(spa) + guard + try await page.web.evaluateJavaScript("document.getElementById('draft').value") as? String + == "keep this draft" + else { throw T3Error("A route change discarded the form.") } + print("Browser smoke: SPA navigation preserves form state") + _ = try await page.web.evaluateJavaScript("document.getElementById('next').click(); 'ok'") + try await wait { !page.web.isLoading && page.web.title == "Browser fixture Two" } + _ = try await serverTab("Browser fixture Two") + guard page.web.canGoBack else { throw T3Error("Browser history was not retained.") } + page.web.goBack() + try await wait { !page.web.isLoading && page.web.url?.query == "step=2" } + guard page.web.canGoForward else { throw T3Error("Forward history was lost.") } + page.web.goForward() + try await wait { !page.web.isLoading && page.web.title == "Browser fixture Two" } + var popups: [URL] = [] + page.onNewTab = { popups.append($0) } + _ = try await page.web.evaluateJavaScript("document.getElementById('popup').click(); 'ok'") + try await wait { !popups.isEmpty } + guard popups.count == 1, popups[0].path == "/two" else { + throw T3Error("Popup navigation opened duplicate tabs.") + } + let screenshot = try await page.capture() + defer { try? FileManager.default.removeItem(at: screenshot.deletingLastPathComponent()) } + guard let image = NSImage(contentsOf: screenshot), image.size.width > 0 else { + throw T3Error("Browser screenshot was empty.") + } + print("Browser smoke: links, back/forward, popup and screenshot") + page.reload(hard: true) + try await wait { !page.web.isLoading && page.web.title == "Browser fixture Two" } + let blocked = try BrowserTab( + await connection.call( + "preview.navigate", + payload: .object([ + "threadId": .string(threadID), "tabId": .string(tab.id), + "url": .string("http://localhost:1/unavailable"), + ]))) + page.receive(blocked) + let deadline = Date().addingTimeInterval(10) + while page.failure == nil && Date() < deadline { + try await Task.sleep(for: .milliseconds(50)) + } + guard page.failure != nil else { + throw T3Error("WebKit rejected an address without showing an error.") + } + let restored = try BrowserTab( + await connection.call( + "preview.navigate", + payload: .object([ + "threadId": .string(threadID), "tabId": .string(tab.id), + "url": .string(fixture.absoluteString), + ]))) + page.receive(restored, navigate: true) + try await wait { !page.web.isLoading && page.web.title == "Browser fixture One" } + guard page.failure == nil else { + throw T3Error("Browser navigation did not recover after an error.") + } + print("Browser smoke: blocked address reports failure and recovers on valid navigation") + try await browserDownloads(page: page) + try await close() + let remaining = try await connection.call( + "preview.list", payload: .object(["threadId": .string(threadID)])) + guard !remaining["sessions"].array.contains(where: { $0["tabId"].string == tab.id }) else { + throw T3Error("Closed browser tab survived.") + } + page.dispose() + window.orderOut(nil) + try await browserPanel( + settings: settings, connection: connection, threadID: threadID, fixture: fixture) + print("Browser smoke passed. No inference sent.") + } catch { + try? await close() + throw error + } + } + + @MainActor private static func browserPanel( + settings: ConnectionSettings, connection: RPCConnection, threadID: String, fixture: URL + ) async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + "t3-browser-smoke-" + UUID().uuidString) + let store = ClientStore(draftDirectory: directory) + let preferences = BrowserPreferences(file: directory.appendingPathComponent("browser.json")) + let panel = BrowserPanel( + store: store, preferences: preferences, + downloads: BrowserDownloads(agentDirectory: directory.appendingPathComponent("downloads"))) + let window = NSWindow( + contentRect: NSRect(x: 100, y: 100, width: 850, height: 700), + styleMask: [.titled, .closable, .resizable], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentViewController = panel + window.orderFront(nil) + defer { + store.onChange = nil + panel.refresh(active: false) + store.disconnect() + window.close() + try? FileManager.default.removeItem(at: directory) + } + func views(_ root: NSView) -> [NSView] { [root] + root.subviews.flatMap(views) } + func web() -> WKWebView? { views(panel.view).compactMap { $0 as? WKWebView }.first } + func wait(_ condition: () -> Bool) async throws { + let deadline = Date().addingTimeInterval(45) + while !condition() { + guard Date() < deadline else { throw T3Error("Browser panel timed out.") } + try await Task.sleep(for: .milliseconds(50)) + } + } + func cleanTabs() async throws { + _ = try await connection.call( + "preview.close", payload: .object(["threadId": .string(threadID)])) + } + do { + store.connect(settings, selecting: threadID) + try await waitForStore(store) { store.canEditThread } + store.onChange = { panel.refresh(active: true) } + panel.refresh(active: true) + try await wait { + views(panel.view).contains { ($0 as? NSButton)?.title.contains(" · http") == true } + } + print("Browser smoke: discovered servers render in the real panel") + panel.open(fixture.absoluteString, newTab: true) + try await wait { web()?.title == "Browser fixture One" && web()?.isLoading == false } + guard let regular = web() else { throw T3Error("No attached browser view.") } + panel.view.layoutSubtreeIfNeeded() + guard regular.bounds.height > panel.view.bounds.height - 180 else { + throw T3Error("The browser page did not fill the panel height.") + } + _ = try await regular.evaluateJavaScript( + "document.getElementById('draft').value='preserved across conversations'; 'ok'") + func expectViewport(_ setting: BrowserViewport) async throws { + panel.resize(setting) + let deadline = Date().addingTimeInterval(15) + while Date() < deadline { + panel.view.layoutSubtreeIfNeeded() + if let actual = try await regular.evaluateJavaScript("[innerWidth,innerHeight]") + as? [Int], + actual == [setting.width!, setting.height!] + { + return + } + try await Task.sleep(for: .milliseconds(50)) + } + let actual = try await regular.evaluateJavaScript("[innerWidth,innerHeight]") + throw T3Error( + "Requested viewport \(setting.width!) × \(setting.height!), got \(String(describing: actual))." + ) + } + let phone = BrowserDevicePreset.all[0].viewport + try await expectViewport(phone) + let stored = try await connection.call( + "preview.list", payload: .object(["threadId": .string(threadID)])) + guard stored["sessions"].array.contains(where: { $0["viewport"] == phone.json }) else { + throw T3Error("Device viewport did not persist on the server.") + } + try await expectViewport(phone.rotated) + regular.pageZoom = 1.5 + try await expectViewport(try BrowserViewport(width: 1024, height: 768)) + guard + try await regular.evaluateJavaScript("document.getElementById('draft').value") as? String + == "preserved across conversations" + else { throw T3Error("Resizing discarded the page form.") } + regular.pageZoom = 1 + panel.resize(.fill) + try await wait { + (views(panel.view).first { $0 is BrowserViewportView } as? BrowserViewportView)?.setting + == .fill + } + print( + "Browser smoke: preset, rotated and custom CSS viewports, page zoom, server persistence and form retention" + ) + let alternate = UUID().uuidString + guard let template = store.selectedThread else { throw T3Error("No browser fixture thread.") } + _ = try await connection.call( + "orchestration.dispatchCommand", + payload: Commands.newThread( + template: template, id: alternate, title: "Native browser switching verification")) + try await waitForStore(store) { store.shell.threads[alternate] != nil } + let regularID = try stored["sessions"].array.first?.requiredString("tabId") ?? "" + func regularAction(_ operation: String, _ input: [String: JSON]) async throws -> JSON { + try await panel.automate( + .object([ + "requestId": .string(UUID().uuidString), "threadId": .string(threadID), + "tabId": .string(regularID), "operation": .string(operation), "input": .object(input), + ]), deadline: BrowserAutomationDeadline(milliseconds: 10_000)) + } + let pendingClick = Task { + try await regularAction("click", ["selector": .string("#after-switch")]) + } + defer { pendingClick.cancel() } + let pendingDeadline = Date().addingTimeInterval(5) + while true { + let snapshot = try await regularAction("snapshot", [:]) + if snapshot["actionTimeline"].array.contains(where: { + $0["action"].string == "click" && $0["status"].string == "running" + }) { + break + } + guard Date() < pendingDeadline else { + throw T3Error("Pending click did not start before switching conversations.") + } + } + store.select(alternate) + try await waitForStore(store) { store.canEditThread && store.selectedID == alternate } + panel.refresh(active: true) + try await wait { web() == nil } + _ = try await regularAction( + "evaluate", + [ + "expression": .string( + "const control=document.createElement('button'); control.id='after-switch'; control.textContent='Deferred control'; control.onclick=()=>window.clickedAfterSwitch=true; document.body.append(control); true" + ) + ]) + _ = try await pendingClick.value + guard + try await regularAction("evaluate", ["expression": .string("window.clickedAfterSwitch")]) + == .bool(true) + else { + throw T3Error("Switching conversations detached the page during an agent action.") + } + print( + "Browser smoke: in-flight agent click survives switching away from a manually opened page") + store.select(threadID) + try await waitForStore(store) { store.canEditThread && store.selectedID == threadID } + store.onChange = { panel.refresh(active: true) } + panel.refresh(active: true) + try await wait { web() === regular } + guard + try await regular.evaluateJavaScript("document.getElementById('draft').value") as? String + == "preserved across conversations" + else { throw T3Error("Switching conversations discarded the browser form.") } + panel.refresh(active: false) + panel.refresh(active: true) + try await wait { web() === regular } + let cookie = "nativeSmoke" + UUID().uuidString.replacingOccurrences(of: "-", with: "") + _ = try await regular.evaluateJavaScript("document.cookie='\(cookie)=regular; path=/'; 'ok'") + panel.open(fixture.absoluteString, newTab: true, profile: "incognito") + try await wait { + web() !== regular && web()?.title == "Browser fixture One" && web()?.isLoading == false + } + guard let privateWeb = web(), !privateWeb.configuration.websiteDataStore.isPersistent, + let cookies = try await privateWeb.evaluateJavaScript("document.cookie") as? String, + !cookies.contains(cookie) + else { throw T3Error("Private tabs shared persistent cookies.") } + _ = try await regular.evaluateJavaScript( + "document.cookie='\(cookie)=; path=/; Max-Age=0'; 'ok'") + print( + "Browser smoke: full-height layout, conversation/hide state retention, private cookie isolation" + ) + let profile = try preferences.create("Smoke profile") + let otherProfile = try preferences.create("Other smoke profile") + let profiles = BrowserProfiles() + do { + try preferences.update { + $0.defaultProfileID = profile.id + $0.viewport = BrowserDevicePreset.all[0].viewport.json + $0.zoom = 1.25 + $0.appearance = "dark" + $0.showAgentPreview = false + } + panel.open(fixture.absoluteString, newTab: true) + try await wait { + web() !== privateWeb && web()?.title == "Browser fixture One" && web()?.isLoading == false + } + guard let custom = web() else { throw T3Error("New profile tab has no page.") } + panel.view.layoutSubtreeIfNeeded() + let dimensions = try await custom.evaluateJavaScript("[innerWidth,innerHeight]") as? [Int] + guard custom.pageZoom == 1.25, custom.appearance?.name == .darkAqua, + dimensions == [375, 667] + else { + throw T3Error( + "New tab ignored defaults: zoom \(custom.pageZoom), appearance \(String(describing: custom.appearance?.name)), viewport \(String(describing: dimensions))." + ) + } + let profileCookie = HTTPCookie(properties: [ + .domain: "localhost", .path: "/", .name: cookie, .value: "custom", + .expires: Date().addingTimeInterval(3600), + ])! + await custom.configuration.websiteDataStore.httpCookieStore.setCookie(profileCookie) + let cookies = await profiles.dataStore(profile: profile.id).httpCookieStore.allCookies() + guard cookies.contains(where: { $0.name == cookie }), + await profiles.dataStore(profile: otherProfile.id).httpCookieStore.allCookies().isEmpty + else { throw T3Error("Browser profiles did not isolate or restore their cookie stores.") } + await profiles.dataStore(profile: otherProfile.id).httpCookieStore.setCookie(profileCookie) + await profiles.clear(profile: profile.id, kinds: .cookies) + guard await custom.configuration.websiteDataStore.httpCookieStore.allCookies().isEmpty, + await profiles.dataStore(profile: otherProfile.id).httpCookieStore.allCookies().contains( + where: { $0.name == cookie }) + else { throw T3Error("Clearing one profile affected another profile.") } + let opened = try await panel.automate( + .object([ + "requestId": .string(UUID().uuidString), "threadId": .string(alternate), + "operation": .string("open"), + "input": .object([ + "url": .string(fixture.absoluteString), "reuseExistingTab": .bool(false), + ]), + ]), deadline: BrowserAutomationDeadline(milliseconds: 15_000)) + let agentTabs = try await connection.call( + "preview.list", payload: .object(["threadId": .string(alternate)])) + guard opened["visible"] == .bool(false), + agentTabs["sessions"].array.contains(where: { + $0["tabId"] == opened["tabId"] && $0["profileId"].string == profile.id + && $0["viewport"] == preferences.values.viewport + }) + else { throw T3Error("Agent tab ignored profile, viewport or presentation preferences.") } + _ = try await connection.call( + "preview.close", + payload: .object(["threadId": .string(alternate), "tabId": opened["tabId"]])) + try preferences.rename(profile.id, to: "Renamed smoke profile") + try preferences.remove(profile.id) + guard preferences.values.defaultProfileID == "default" else { + throw T3Error("Removed default profile did not fall back to Default.") + } + try preferences.update { $0 = BrowserPreferenceValues() } + await profiles.clear(profile: profile.id, kinds: .all) + await profiles.clear(profile: otherProfile.id, kinds: .all) + } catch { + await profiles.clear(profile: profile.id, kinds: .all) + await profiles.clear(profile: otherProfile.id, kinds: .all) + throw error + } + print( + "Browser smoke: saved manual and agent defaults, profile cookie isolation and scoped clearing" + ) + try await browserAutomation( + panel: panel, store: store, connection: connection, threadID: threadID, + alternate: alternate, fixture: fixture) + try await cleanTabs() + } catch { + try? await cleanTabs() + throw error + } + } +} diff --git a/apps/macos/Sources/T3Native/BrowserViewportView.swift b/apps/macos/Sources/T3Native/BrowserViewportView.swift new file mode 100644 index 0000000000000..8bc0e30e7e748 --- /dev/null +++ b/apps/macos/Sources/T3Native/BrowserViewportView.swift @@ -0,0 +1,54 @@ +import AppKit +import T3Core +import WebKit + +@MainActor final class BrowserViewportView: NSView { + private let canvas = NSView() + private(set) var web: WKWebView? + var setting: BrowserViewport = .fill { didSet { if oldValue != setting { needsLayout = true } } } + override var isFlipped: Bool { true } + + override init(frame: NSRect) { + super.init(frame: frame) + addSubview(canvas) + canvas.wantsLayer = true + canvas.layer?.masksToBounds = true + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func show(_ web: WKWebView?) { + guard self.web !== web else { return } + self.web?.removeFromSuperview() + self.web = web + if let web { + web.translatesAutoresizingMaskIntoConstraints = true + canvas.addSubview(web) + } + needsLayout = true + } + + override func layout() { + super.layout() + guard let web else { return } + if let width = setting.width, let height = setting.height { + // WebKit truncates view points before dividing by page zoom. Round the outer size up so + // fractional zoom cannot shrink the requested responsive breakpoint by a CSS pixel. + // Scale presentation, not page zoom: responsive breakpoints keep the requested CSS size. + let size = NSSize( + width: ceil(Double(width) * web.pageZoom) + 0.01, + height: ceil(Double(height) * web.pageZoom) + 0.01 + ) + let scale = min( + 1, max(1, bounds.width - 24) / size.width, max(1, bounds.height - 24) / size.height) + let displayed = NSSize(width: size.width * scale, height: size.height * scale) + canvas.frame = NSRect( + x: (bounds.width - displayed.width) / 2, y: 12, + width: displayed.width, height: displayed.height) + canvas.bounds = NSRect(origin: .zero, size: size) + } else { + canvas.frame = bounds + canvas.bounds = NSRect(origin: .zero, size: bounds.size) + } + web.frame = canvas.bounds + } +} diff --git a/apps/macos/Sources/T3Native/CheckpointCard.swift b/apps/macos/Sources/T3Native/CheckpointCard.swift new file mode 100644 index 0000000000000..fdfd93b7b4270 --- /dev/null +++ b/apps/macos/Sources/T3Native/CheckpointCard.swift @@ -0,0 +1,71 @@ +import AppKit +import T3Core + +@MainActor final class CheckpointCard: NSView { + static func height(_ checkpoint: TurnCheckpoint) -> CGFloat { + 46 + CGFloat(min(checkpoint.files.count, 5)) * 26 + + (checkpoint.files.count > 5 ? 26 : 0) + + (checkpoint.unavailableReason == nil ? 0 : 36) + } + + init(_ checkpoint: TurnCheckpoint, open: @escaping (String?) -> Void) { + super.init(frame: .zero) + let totals = checkpoint.files.reduce((0, 0)) { ($0.0 + $1.additions, $0.1 + $1.deletions) } + let title = NSTextField( + labelWithString: "Turn \(checkpoint.turnCount) · \(checkpoint.files.count) changed files") + title.font = .systemFont(ofSize: 12, weight: .semibold) + let counts = NSTextField(labelWithString: "+\(totals.0) −\(totals.1)") + counts.font = .monospacedDigitSystemFont(ofSize: 11, weight: .regular) + let review = ActionButton("Review") { open(nil) } + review.controlSize = .small + review.isEnabled = checkpoint.unavailableReason == nil + let header = NSStackView(views: [title, NSView(), counts, review]) + let stack = NSStackView(views: [header]) + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 4 + if let error = checkpoint.unavailableReason { + let label = NSTextField(wrappingLabelWithString: error) + label.font = .systemFont(ofSize: 11) + label.textColor = .secondaryLabelColor + label.maximumNumberOfLines = 2 + stack.addArrangedSubview(label) + } + for file in checkpoint.files.prefix(5) { + let link = ActionButton(file.path) { open(file.path) } + link.bezelStyle = .inline + link.alignment = .left + link.font = .monospacedSystemFont(ofSize: 11, weight: .regular) + link.lineBreakMode = .byTruncatingMiddle + link.toolTip = "Review \(file.path) · +\(file.additions) −\(file.deletions)" + link.isEnabled = checkpoint.unavailableReason == nil + let stats = NSTextField(labelWithString: "+\(file.additions) −\(file.deletions)") + stats.font = .monospacedDigitSystemFont(ofSize: 11, weight: .regular) + stats.textColor = .secondaryLabelColor + stats.setContentCompressionResistancePriority(.required, for: .horizontal) + let row = NSStackView(views: [link, NSView(), stats]) + row.heightAnchor.constraint(equalToConstant: 22).isActive = true + stack.addArrangedSubview(row) + } + if checkpoint.files.count > 5 { + let all = ActionButton("View all \(checkpoint.files.count) files…") { open(nil) } + all.bezelStyle = .inline + all.alignment = .left + all.isEnabled = checkpoint.unavailableReason == nil + all.heightAnchor.constraint(equalToConstant: 22).isActive = true + stack.addArrangedSubview(all) + } + for row in stack.arrangedSubviews { + row.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true + } + addSubview(stack) + stack.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12), + stack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12), + stack.topAnchor.constraint(equalTo: topAnchor, constant: 8), + ]) + setAccessibilityLabel("Changes from turn \(checkpoint.turnCount)") + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } +} diff --git a/apps/macos/Sources/T3Native/CheckpointSmoke.swift b/apps/macos/Sources/T3Native/CheckpointSmoke.swift new file mode 100644 index 0000000000000..9144f81d3430a --- /dev/null +++ b/apps/macos/Sources/T3Native/CheckpointSmoke.swift @@ -0,0 +1,154 @@ +import AppKit +import T3Core + +extension ParitySmoke { + @MainActor static func checkpointReview( + connection: RPCConnection, settings: ConnectionSettings, threadID: String, projectID: String + ) async throws { + let projection = try await connection.call( + "orchestration.getThreadProjection", payload: .object(["threadId": .string(threadID)])) + guard projection["thread"]["projectId"].string == projectID else { + throw T3Error("Checkpoint review smoke requires a thread in the isolated project.") + } + let index = ThreadCheckpoints(projection) + guard let latest = index.items.first, latest.unavailableReason == nil else { + throw T3Error( + "Checkpoint smoke needs an existing completed turn with a ready saved checkpoint.") + } + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let store = ClientStore(draftDirectory: directory) + store.connect(settings, selecting: threadID) + defer { store.disconnect() } + try await waitForStore(store) { store.canEditThread && store.selectedID == threadID } + let changes = ReviewChanges(store: store) + store.onChange = { changes.refreshContext(active: true) } + defer { + store.onChange = nil + changes.refreshContext(active: false) + } + changes.refreshContext(active: true) + changes.selectSource("latest-turn") + try await waitForCheckpointReview(changes) { !changes.loading && !changes.loadingFile } + for full in [false, true] { + changes.selectSource(full ? "full-thread" : "latest-turn") + try await waitForCheckpointReview(changes) { !changes.loading && !changes.loadingFile } + let request = try CheckpointDiffRequest(latest, fullThread: full, ignoreWhitespace: false) + let result = try await connection.call(request.method, payload: request.input) + let exact = try request.validate(result) + let files = UnifiedDiff.parse(exact) + guard + changes.source?.files + == files.map(ReviewFile.init).sorted(by: { + $0.path.localizedStandardCompare($1.path) == .orderedAscending + }) + else { + throw T3Error("Saved diff file list disagrees with the checkpoint RPC.") + } + for file in files { + changes.selectFile(file.path) + guard changes.rawPatch == file.rawPatch, changes.patch == file, !changes.loadingFile else { + throw T3Error("Switching saved-diff files did not reuse the fetched comparison.") + } + guard ReviewText.render(changes.patch).text.string.contains(file.lines.last?.text ?? "") + else { + throw T3Error("Checkpoint text did not render.") + } + let split = SplitReviewText(file) + let removed = file.lines.first(where: { $0.kind == .deletion })?.text ?? "" + let added = file.lines.first(where: { $0.kind == .addition })?.text ?? "" + guard + split.limited || split.old.string.contains(removed) && split.new.string.contains(added) + else { throw T3Error("The split checkpoint diff lost changed lines.") } + if !file.isDeleted && !file.isBinary, let absolutePath = changes.absolutePath, + let location = store.workspaceLocation + { + _ = try await store.readWorkspaceFile(absolutePath, at: location) + } + } + } + let entries = ConversationTimeline.entries( + from: store.thread?.rows ?? [], activeRunID: nil, + checkpoints: store.thread?.checkpoints.items ?? []) + if !latest.files.isEmpty { + guard entries.contains(.changes(latest)) else { + throw T3Error("The checkpoint has no timeline review entry point.") + } + } + changes.openCheckpoint(latest.id, filePath: latest.files.first?.path) + try await waitForCheckpointReview(changes) { !changes.loading && !changes.loadingFile } + let previous = changes.patchRevision + changes.openCheckpoint(latest.id, filePath: latest.files.first?.path) + guard changes.patchRevision == previous else { + throw T3Error("Reopening the same checkpoint refetched its patch.") + } + changes.compare(baseRef: "", ignoreWhitespace: true) + try await waitForCheckpointReview(changes) { !changes.loading && !changes.loadingFile } + changes.openCheckpoint("missing-checkpoint-native-smoke") + guard changes.error != nil, changes.patch == nil, changes.snapshot == nil else { + throw T3Error("A removed checkpoint left an obsolete diff visible.") + } + changes.openCheckpoint(latest.id) + try await waitForCheckpointReview(changes) { !changes.loading && !changes.loadingFile } + // Model a rollback snapshot locally; the actual server history stays unchanged. + guard case .object(var rolledBack) = projection else { throw T3Error("Invalid projection.") } + rolledBack["checkpoints"] = .array( + projection["checkpoints"].array.filter { $0["id"].string != latest.id }) + var state = ThreadState(id: threadID) + _ = try state.apply( + .object([ + "kind": .string("snapshot"), "snapshotSequence": .number(1), + "projection": .object(rolledBack), + ])) + store.thread = state + changes.refreshContext(active: true) + guard changes.error != nil, changes.patch == nil, + !changes.checkpoints.contains(where: { $0.id == latest.id }) + else { + throw T3Error("Rollback did not invalidate the selected checkpoint.") + } + store.connect(settings, selecting: threadID) + try await waitForStore(store) { store.canEditThread && store.selectedID == threadID } + store.onChange = { changes.refreshContext(active: true) } + changes.refreshContext(active: true) + changes.selectSource("latest-turn") + try await waitForCheckpointReview(changes) { !changes.loading && !changes.loadingFile } + changes.refresh() + store.selectedID = nil + changes.refreshContext(active: true) + guard changes.checkpoints.isEmpty, changes.patch == nil, changes.snapshot == nil else { + throw T3Error("Checkpoint review leaked across navigation.") + } + let final = try await connection.call( + "orchestration.getThreadProjection", payload: .object(["threadId": .string(threadID)])) + guard final["runs"] == projection["runs"], final["checkpoints"] == projection["checkpoints"] + else { + throw T3Error("Read-only review changed thread history.") + } + print( + "Checkpoint smoke passed: real latest-turn/full-thread RPCs, exact per-file patches reused on selection, timeline card, whitespace filter, missing checkpoint, local rollback snapshot invalidation, reconnect recovery, navigation isolation, unchanged server history; thread \(threadID), latest turn \(latest.turnCount), changed files \(latest.files.count)." + ) + } + + @MainActor private static func waitForCheckpointReview( + _ changes: ReviewChanges, until predicate: @escaping @MainActor () -> Bool + ) async throws { + let (stream, continuation) = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) + changes.onChange = { continuation.yield(()) } + let timeout = Task { + try await Task.sleep(for: .seconds(20)) + continuation.finish() + } + defer { + changes.onChange = nil + timeout.cancel() + continuation.finish() + } + continuation.yield(()) + for await _ in stream { + if let error = changes.error ?? changes.fileError { throw T3Error(error) } + if predicate() { return } + } + throw T3Error("Timed out waiting for checkpoint review.") + } +} diff --git a/apps/macos/Sources/T3Native/ClientStore.swift b/apps/macos/Sources/T3Native/ClientStore.swift new file mode 100644 index 0000000000000..7f3250fc1e374 --- /dev/null +++ b/apps/macos/Sources/T3Native/ClientStore.swift @@ -0,0 +1,1978 @@ +import Foundation +import T3Core + +@MainActor final class ClientStore { + var shell = ShellState() + private(set) var archives = ShellState(archived: true) + private(set) var showingArchives = false + private(set) var archiveError: String? + private(set) var sortedThreads: [ThreadSummary] = [] + var thread: ThreadState? { didSet { questionProgressCache = nil } } + var selectedID: String? { didSet { questionProgressCache = nil } } + var status = "Connect to your T3 server" + var error: String? + var ready = false + var loadingHistory = false + var sending: Set = [] + var drafts: [String: String] = [:] { didSet { scheduleDraftSave() } } + private(set) var queuedEdits: [String: QueuedEdit] = [:] { didSet { scheduleDraftSave() } } + private var deliveries: [String: MessageDelivery] = [:] { didSet { scheduleDraftSave() } } + private(set) var attachmentDrafts: [String: [DraftAttachment]] = [:] { + didSet { + questionProgressCache = nil + scheduleDraftSave() + } + } + enum UploadState: Equatable { + case uploading(Int) + case ready + case failed(String) + } + private(set) var attachmentUploads: [String: UploadState] = [:] { + didSet { questionProgressCache = nil } + } + private(set) var importingAttachments: Set = [] { didSet { questionProgressCache = nil } } + private var uploadJobs: [String: (token: UUID, task: Task)] = [:] + var catalog = ProviderCatalog() { didSet { questionProgressCache = nil } } + private(set) var catalogReady = false + private(set) var usageLimits = UsageLimitsSnapshot() + private(set) var usageSessionID = UUID() + var questionDrafts: [String: [String: QuestionDraft]] = [:] { + didSet { + questionProgressCache = nil + scheduleDraftSave() + } + } + private var questionPages: [String: Int] = [:] { + didSet { + questionProgressCache = nil + scheduleDraftSave() + } + } + private var questionResponses: [String: PendingQuestionResponse] = [:] { + didSet { scheduleDraftSave() } + } + private var selectedRequestID: String? + private var questionProgressCache: + (request: PendingRequest, progress: QuestionProgress, attachments: [String: [DraftAttachment]])? + private(set) var storageError: String? + private(set) var commandsInFlight: Set = [] + private(set) var creatingThread = false + private(set) var conversationDrafts: [String: NewConversationDraft] = [:] { + didSet { scheduleDraftSave() } + } + var selectedDraft: NewConversationDraft? { selectedID.flatMap { conversationDrafts[$0] } } + var isNewConversation: Bool { selectedDraft != nil } + var onChange: (() -> Void)? + var onShellEvent: ((JSON, RPCConnection, URL) -> Void)? + private var connection: RPCConnection? + var terminalConnection: RPCConnection? { ready ? connection : nil } + private(set) var browserEnvironmentID: String? + var browserConnection: RPCConnection? { ready ? connection : nil } + var browserEndpoint: URL? { ready ? settings?.endpoint : nil } + var hasConnection: Bool { settings != nil } + private var settings: ConnectionSettings? + private var cachedWorkspaceMedia: WorkspaceMediaRequest? + private var lifetime: Task? + private var detail: Task? + private var configuration: Task? + private var archiveSubscription: Task? + private var visibleVisit: (id: String, stamp: String)? + private var suppressVisit: String? + private var notification: Task? + private var generation = UUID() + private var selectionGeneration = UUID() + private var pending: [String: PendingSubmission] = [:] { didSet { scheduleDraftSave() } } + private var planSubmissions: [String: PendingPlanSubmission] = [:] { + didSet { scheduleDraftSave() } + } + private let draftRepository: DraftRepository + private let preferences: UserDefaults + private var draftEndpoint: URL? + private var draftSave: Task? + + init(draftDirectory: URL? = nil, preferences: UserDefaults = .standard) { + self.preferences = preferences + catalog = ProviderCatalog(planModeEnabled: preferences.bool(forKey: "planModeEnabled")) + let directory = + draftDirectory + ?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("T3 Native/Drafts", isDirectory: true) + draftRepository = DraftRepository(directory: directory) + } + + func connect(_ settings: ConnectionSettings, selecting threadID: String? = nil) { + if let current = self.settings, current.endpoint != settings.endpoint, + !drafts.isEmpty || !questionDrafts.isEmpty || !pending.isEmpty || !attachmentDrafts.isEmpty + || !queuedEdits.isEmpty || !questionResponses.isEmpty || !deliveries.isEmpty + || !planSubmissions.isEmpty || !conversationDrafts.isEmpty, + !flushDrafts() + { + error = "Save the current drafts before switching servers." + notify() + return + } + disconnect() + if self.settings?.endpoint != settings.endpoint || draftEndpoint == nil { + draftEndpoint = nil + shell = ShellState() + archives = ShellState(archived: true) + sortedThreads = [] + thread = nil + selectedID = nil + drafts = [:] + attachmentDrafts = [:] + questionDrafts = [:] + pending = [:] + queuedEdits = [:] + deliveries = [:] + questionPages = [:] + questionResponses = [:] + planSubmissions = [:] + conversationDrafts = [:] + selectedRequestID = nil + catalog = ProviderCatalog(planModeEnabled: preferences.bool(forKey: "planModeEnabled")) + do { + let saved = try draftRepository.load(for: settings.endpoint) + drafts = saved.messages + pending = saved.submissions + questionDrafts = saved.questions + attachmentDrafts = saved.attachments ?? [:] + queuedEdits = saved.queuedEdits ?? [:] + deliveries = saved.deliveries ?? [:] + questionPages = saved.questionPages ?? [:] + questionResponses = saved.questionResponses ?? [:] + planSubmissions = saved.planSubmissions ?? [:] + conversationDrafts = saved.conversations ?? [:] + draftEndpoint = settings.endpoint + storageError = nil + } catch { + storageError = "Saved drafts could not be loaded: " + error.localizedDescription + } + } + self.settings = settings + selectedID = + threadID ?? selectedID + ?? (preferences.dictionary(forKey: "selectedThreadByEndpoint")?[ + settings.endpoint.absoluteString] as? String) + status = "Connecting…" + error = nil + let generation = self.generation + lifetime = Task { + var attempt = 0 + while !Task.isCancelled && self.generation == generation { + status = attempt == 0 ? "Connecting…" : "Reconnecting…" + ready = false + catalogReady = false + notify() + let connection = RPCConnection(settings: settings) + usageSessionID = UUID() + creatingThread = false + usageLimits = UsageLimitsSnapshot() + browserEnvironmentID = nil + self.connection = connection + let started = Date() + do { + try await connection.open() + try Task.checkCancellation() + subscribeConfiguration(connection, generation: generation) + if showingArchives { subscribeArchives(connection, generation: generation) } + let subscription = try await connection.subscribe( + "orchestration.subscribeShell", + payload: .object(["requestCompletionMarker": .bool(true)])) + for try await value in subscription.values { + guard self.generation == generation else { break } + try shell.apply(value) + sortedThreads = ThreadGroup.sorted(Array(shell.threads.values), in: .active) + if shell.synchronized { + if !ready { + ready = true + status = "Connected · \(settings.endpoint.host ?? "T3")" + error = nil + startAttachmentUploads() + if let selectedID, + shell.threads[selectedID] != nil || archives.threads[selectedID] != nil + || conversationDrafts[selectedID] != nil + { + select(selectedID, force: true) + } else if let requested = Arguments.value("--thread-id"), + shell.threads[requested] != nil + { + select(requested) + } else if let first = sortedThreads.first { + select(first.id) + } + } + } + onShellEvent?(value, connection, settings.endpoint) + notify() + } + throw T3Error("Live connection ended.") + } catch { + guard self.generation == generation && !Task.isCancelled else { + await connection.close() + return + } + ready = false + detail?.cancel() + configuration?.cancel() + archiveSubscription?.cancel() + self.error = error.localizedDescription + await connection.close() + if (error as? T3Error)?.authentication == true { + status = "Pair again to reconnect" + notify() + return + } + if Date().timeIntervalSince(started) > 30 { attempt = 0 } + attempt += 1 + status = "Connection lost · retrying" + notify() + do { + try await Task.sleep( + for: .seconds( + min(300, pow(2, Double(min(attempt, 9)))) * Double.random(in: 0.8...1.2))) + } catch { return } + } + } + } + } + + func disconnect() { + _ = flushDrafts() + generation = UUID() + usageSessionID = UUID() + usageLimits = UsageLimitsSnapshot() + selectionGeneration = UUID() + lifetime?.cancel() + lifetime = nil + detail?.cancel() + detail = nil + configuration?.cancel() + configuration = nil + archiveSubscription?.cancel() + archiveSubscription = nil + visibleVisit = nil + if let connection { Task { await connection.close() } } + connection = nil + cachedWorkspaceMedia = nil + ready = false + catalogReady = false + commandsInFlight = [] + creatingThread = false + sending = [] + for job in uploadJobs.values { job.task.cancel() } + uploadJobs = [:] + attachmentUploads = [:] + importingAttachments = [] + } + + func retry() { if let settings { connect(settings) } } + + func showArchives(_ value: Bool) { + showingArchives = value + if value, archiveSubscription == nil, let connection, ready { + subscribeArchives(connection, generation: generation) + } + notify() + } + + private func subscribeArchives(_ connection: RPCConnection, generation: UUID) { + archiveSubscription?.cancel() + archiveError = nil + archives = ShellState(archived: true) + archiveSubscription = Task { + do { + let stream = try await connection.subscribe("orchestration.subscribeArchivedShell") + defer { Task { await connection.cancel(stream.id) } } + for try await event in stream.values { + try Task.checkCancellation() + guard self.generation == generation else { return } + try archives.apply(event) + notify() + } + throw T3Error("Archive updates ended. Reconnect to refresh.") + } catch { + guard !Task.isCancelled, self.generation == generation else { return } + archiveError = error.localizedDescription + archiveSubscription = nil + notify() + } + } + } + + func lifecycleBusy(_ id: String) -> Bool { commandsInFlight.contains(id + ":lifecycle") } + + func rename(_ id: String, to title: String) { + let title = title.trimmingCharacters(in: .whitespacesAndNewlines) + guard !title.isEmpty else { return } + performThreadCommand( + "thread.metadata.update", fields: ["title": .string(title)], key: "lifecycle", threadID: id) + } + + func setPinned(_ id: String, _ pinned: Bool) { + performThreadCommand( + pinned ? "thread.pin" : "thread.unpin", fields: [:], key: "lifecycle", threadID: id) + } + + func setSettled(_ id: String, _ settled: Bool) { + performThreadCommand( + settled ? "thread.settle" : "thread.unsettle", + fields: settled ? [:] : ["reason": .string("user")], key: "lifecycle", threadID: id) + } + + func setAutoSettle(_ id: String, enabled: Bool) { + performThreadCommand( + "thread.auto-settle.set", fields: ["enabled": .bool(enabled)], key: "lifecycle", threadID: id) + } + + func snooze(_ id: String, until date: Date?) { + if let date { + guard date > Date(), shell.threads[id]?.lifecycle.canSnooze(at: Date()) == true else { + return + } + performThreadCommand( + "thread.snooze", fields: ["snoozedUntil": .string(ThreadDates.string(date))], + key: "lifecycle", threadID: id) + } else { + performThreadCommand( + "thread.unsnooze", fields: ["reason": .string("user")], key: "lifecycle", threadID: id) + } + } + + func setArchived(_ id: String, _ archived: Bool) { + performThreadCommand( + archived ? "thread.archive" : "thread.unarchive", fields: [:], key: "lifecycle", threadID: id + ) { [weak self] in + guard let self else { return } + if archived, selectedID == id { + if let next = sortedThreads.first(where: { + $0.id != id && !$0.lifecycle.settled && !$0.lifecycle.snoozed(at: Date()) + }) { + select(next.id) + } else { + detail?.cancel() + selectionGeneration = UUID() + selectedID = nil + thread = nil + } + } + } + } + + func markUnread(_ id: String) { + if selectedID == id { suppressVisit = id } + performThreadCommand("thread.mark-unread", fields: [:], key: "lifecycle", threadID: id) + } + + func markVisibleThreadRead() { + guard ready, thread?.synchronized == true, let current = selectedThread, + suppressVisit != current.id, ThreadDates.parse(current.updatedAt) != nil, + !commandsInFlight.contains(current.id + ":visit"), + visibleVisit?.id != current.id || visibleVisit?.stamp != current.updatedAt + else { return } + visibleVisit = (current.id, current.updatedAt) + if let visited = current.lifecycle.lastVisitedAt, let updated = current.lifecycle.updatedAt, + visited >= updated + { + return + } + performThreadCommand( + "thread.visit", fields: ["visitedAt": .string(current.updatedAt)], key: "visit", + threadID: current.id) + } + + func saveDraftsBeforeExit() -> Bool { + guard importingAttachments.isEmpty else { + storageError = "Wait for the selected files to finish copying before quitting." + return false + } + return flushDrafts() + } + + private func scheduleDraftSave() { + guard draftEndpoint != nil else { return } + draftSave?.cancel() + draftSave = Task { + do { try await Task.sleep(for: .milliseconds(250)) } catch { return } + _ = flushDrafts() + } + } + + @discardableResult private func flushDrafts() -> Bool { + draftSave?.cancel() + draftSave = nil + guard let draftEndpoint else { return storageError == nil } + do { + try draftRepository.save( + SavedDrafts( + messages: drafts.filter { !$0.value.isEmpty }, submissions: pending, + questions: questionDrafts, attachments: attachmentDrafts.filter { !$0.value.isEmpty }, + queuedEdits: queuedEdits, deliveries: deliveries, + questionPages: questionPages, questionResponses: questionResponses, + planSubmissions: planSubmissions, conversations: conversationDrafts), + for: draftEndpoint) + storageError = nil + return true + } catch { + storageError = "Drafts could not be saved: " + error.localizedDescription + notify() + return false + } + } + + private func subscribeConfiguration(_ connection: RPCConnection, generation: UUID) { + configuration?.cancel() + configuration = Task { + do { + let stream = try await connection.subscribe( + "subscribeServerConfig", payload: .object(["usageLimitSources": .bool(true)])) + defer { Task { await connection.cancel(stream.id) } } + for try await event in stream.values { + try Task.checkCancellation() + guard self.generation == generation else { return } + try catalog.apply(event) + usageLimits.apply(event) + if event["type"].string == "snapshot" { + browserEnvironmentID = event["config"]["environment"]["environmentId"].string + catalogReady = true + } + startAttachmentUploads() + notify() + } + throw T3Error("Provider updates ended. Reconnect to refresh.") + } catch { + guard !Task.isCancelled, self.generation == generation else { return } + catalogReady = false + self.error = "Provider configuration: " + error.localizedDescription + notify() + } + } + } + + var selectedThread: ThreadSummary? { + selectedID.flatMap { + shell.threads[$0] ?? archives.threads[$0] ?? conversationDrafts[$0]?.summary + } + } + var activeSidebarThreads: [String: ThreadSummary] { + var result = shell.threads + for draft in conversationDrafts.values where result[draft.id] == nil { + if let summary = draft.summary { result[draft.id] = summary } + } + return result + } + var environmentKey: String { settings?.endpoint.absoluteString ?? "disconnected" } + func usageCall(_ method: String, payload: JSON = .object([:]), session: UUID) async throws -> JSON + { + guard ready, catalogReady, let connection, session == usageSessionID else { + throw T3Error("Connect to load usage.") + } + let result = try await connection.call(method, payload: payload) + try Task.checkCancellation() + guard ready, session == usageSessionID else { throw T3Error("The usage connection changed.") } + return result + } + var fileOpenCapabilities: FileOpenCapabilities { + ready && catalogReady ? catalog.fileOpen : FileOpenCapabilities() + } + func openFileExternally( + _ path: String, position: FileSourcePosition? = nil, + editor: String, reveal: Bool, at location: WorkspaceLocation + ) { + guard ready, catalogReady, let connection, workspaceLocation == location else { return } + let payload: JSON + do { + payload = try fileOpenCapabilities.launchInput( + path: path, root: location.root, editor: editor, reveal: reveal, position: position) + } catch { + self.error = error.localizedDescription + notify() + return + } + let generation = generation + Task { + do { _ = try await connection.call("shell.openInEditor", payload: payload) } catch { + guard self.generation == generation, workspaceLocation == location else { return } + self.error = error.localizedDescription + notify() + } + } + } + var workspaceLocation: WorkspaceLocation? { + guard let selectedThread else { return nil } + return WorkspaceLocation( + environment: environmentKey, thread: selectedThread, + project: shell.projectDetails[selectedThread.projectID] ?? archives.projectDetails[ + selectedThread.projectID] ?? .null) + } + func savePlan(_ document: PlanDocument, at location: WorkspaceLocation, relativePath: String) + async throws -> String + { + guard ready, let connection else { throw T3Error("Connect before saving to the workspace.") } + guard workspaceLocation == location else { + throw T3Error("The workspace changed. Reopen Save to workspace for the current thread.") + } + let result = try await connection.call( + "projects.writeFile", + payload: location.writeInput(relativePath: relativePath, contents: document.contents)) + return try result.requiredString("relativePath") + } + func workspaceCall(_ method: String, at location: WorkspaceLocation, fields: [String: JSON]) + async throws -> JSON + { + guard ready, let connection else { throw T3Error("Connect to load workspace files.") } + guard workspaceLocation == location else { throw T3Error("The workspace changed.") } + let generation = generation + var payload = fields + payload["cwd"] = .string(location.root) + let result = try await connection.call(method, payload: .object(payload)) + guard self.generation == generation, workspaceLocation == location else { + throw T3Error("The workspace changed while the file operation was running.") + } + return result + } + + var reviewRepositoryRoot: String? { + guard let selectedThread else { return nil } + let project = + shell.projectDetails[selectedThread.projectID] + ?? archives.projectDetails[selectedThread.projectID] ?? .null + return selectedThread.worktreePath ?? project["repositoryIdentity"]["rootPath"].string + ?? workspaceLocation?.root + } + + func checkpointDiff(_ request: CheckpointDiffRequest, at location: WorkspaceLocation) + async throws -> String + { + guard ready, let connection else { throw T3Error("Connect to review saved changes.") } + guard workspaceLocation == location, request.threadID == location.threadID else { + throw T3Error("The selected thread changed.") + } + let generation = generation + let result = try await connection.call(request.method, payload: request.input) + guard self.generation == generation, workspaceLocation == location else { + throw T3Error("The selected thread changed while loading its saved diff.") + } + return try request.validate(result) + } + + func readWorkspaceFile(_ path: String, at location: WorkspaceLocation) async throws + -> WorkspaceFileDocument + { + try await WorkspaceFileDocument( + workspaceCall( + "projects.readFile", at: location, fields: ["relativePath": .string(path)])) + } + + func workspaceMediaRequest(_ path: String, at location: WorkspaceLocation, revision: UInt64) + -> WorkspaceMediaRequest? + { + guard let connection, let settings, workspaceLocation == location, + let kind = WorkspaceMediaKind(path: path) + else { return nil } + let key = WorkspaceMediaRequest.Key( + file: WorkspaceFileID(location: location, path: path), threadID: location.threadID, + connection: generation, revision: revision) + if let cachedWorkspaceMedia, cachedWorkspaceMedia.key == key { return cachedWorkspaceMedia } + let resource = WorkspaceMediaRequest.resource(path: path, at: location) + let request = WorkspaceMediaRequest(key: key, kind: kind) { + let issued = try await connection.call( + "assets.createUrl", payload: .object(["resource": resource])) + return try WorkspaceMediaRequest.freshURL( + issued, endpoint: settings.endpoint, revision: revision) + } + cachedWorkspaceMedia = request + return request + } + + func workspaceFileAccess(at location: WorkspaceLocation) throws -> WorkspaceFileAccess { + guard ready, let connection else { throw T3Error("Connect to save workspace files.") } + guard workspaceLocation == location else { throw T3Error("The workspace changed.") } + return WorkspaceFileAccess(connection: connection, location: location) + } + + func saveWorkspaceFile(_ document: WorkspaceFileDocument, at location: WorkspaceLocation) + async throws + { + try await workspaceFileAccess(at: location).save(document) + } + + var changingSettings: Bool { commandsInFlight.contains((selectedID ?? "") + ":settings") } + var canEditThread: Bool { + ready && (isNewConversation || thread?.synchronized == true) + && selectedThread?.archived == false && !changingSettings + } + + func setModel(_ selection: JSON) { + guard catalogReady, let selectedThread, let provider = catalog.provider(for: selection), + provider.available, + catalog.model(for: selection) != nil + else { return } + if isNewConversation { + updateConversationDraft { $0.model = selection } + return + } + let changingModel = + selection["model"] != selectedThread.model["model"] + || selection["instanceId"] != selectedThread.model["instanceId"] + if changingModel, selection["instanceId"] == selectedThread.model["instanceId"], + !(thread?.rows.isEmpty ?? true), + provider.requiresNewThreadForModelChange + || catalog.provider(for: selectedThread.model)?.requiresNewThreadForModelChange == true + { + error = "This provider requires a new thread to change models." + notify() + return + } + performThreadCommand( + "thread.model-selection.set", fields: ["modelSelection": selection], key: "settings") + } + + func modelDisabledReason(_ choice: ModelChoice) -> String? { + guard catalogReady, canEditThread else { + return "Wait for the thread to connect and finish updating." + } + if isNewConversation, pending[selectedID ?? ""]?.launch != nil { + return "Retry the pending first message before changing its model." + } + guard choice.provider.available else { + return choice.provider.unavailableReason ?? "This provider is unavailable." + } + guard let selectedThread else { return "Select a thread first." } + if choice.provider.id == selectedThread.model["instanceId"].string, + choice.model.id != selectedThread.model["model"].string, + !(thread?.rows.isEmpty ?? true), choice.provider.requiresNewThreadForModelChange + { + return "Start a new thread to change this provider’s model." + } + return nil + } + + func setModelOption(_ optionID: String, value: JSON) { + guard let selectedThread, case .object(var selection) = selectedThread.model, + let descriptor = catalog.descriptors(for: selectedThread.model).first(where: { + $0["id"].string == optionID + }) + else { return } + if descriptor["type"].string == "boolean" { + guard case .bool = value else { return } + } else { + guard descriptor["options"].array.contains(where: { $0["id"] == value }) else { return } + } + var options = selectedThread.model["options"].array.filter { $0["id"].string != optionID } + options.append(.object(["id": .string(optionID), "value": value])) + selection["options"] = .array(options) + setModel(.object(selection)) + } + + func setRuntimeMode(_ mode: String) { + guard catalogReady, let selectedThread else { return } + let supported = catalog.provider(for: selectedThread.model)?.runtimeModes ?? [] + guard + supported.contains(mode) + || (supported.isEmpty && ["approval-required", "full-access"].contains(mode)) + else { return } + if isNewConversation { + updateConversationDraft { $0.runtimeMode = mode } + return + } + performThreadCommand( + "thread.runtime-mode.set", fields: ["runtimeMode": .string(mode)], key: "settings") + } + + func setInteractionMode(_ mode: String) { + guard catalogReady, catalog.planModeEnabled, let selectedThread, + catalog.provider(for: selectedThread.model)?.showInteractionMode == true, + ["default", "plan"].contains(mode) + else { return } + if isNewConversation { + updateConversationDraft { $0.interactionMode = mode } + return + } + performThreadCommand( + "thread.interaction-mode.set", fields: ["interactionMode": .string(mode)], key: "settings") + } + + func setPlanModeEnabled(_ enabled: Bool) { + preferences.set(enabled, forKey: "planModeEnabled") + catalog.setPlanModeEnabled(enabled) + notify() + } + + func respond(_ request: PendingRequest, decision: String? = nil) { + guard request.canRespond, thread?.pendingRequests.contains(request) == true else { return } + var fields: [String: JSON] = ["requestId": .string(request.id)] + if request.isQuestion { + respondToQuestions(request) + return + } else { + guard let decision, request.options.contains(where: { $0["decision"].string == decision }) + else { return } + fields["decision"] = .string(decision) + } + performThreadCommand("runtime-request.respond", fields: fields, key: "request:" + request.id) + } + + func dismiss(_ request: PendingRequest) { + guard request.dismissible, thread?.pendingRequests.contains(request) == true, + let id = selectedID, !questionBusy(request), + !request.questions.contains(where: { + guard let questionID = $0["id"].string else { return false } + return importingAttachments.contains( + QuestionDraft.attachmentKey(threadID: id, requestID: request.id, questionID: questionID)) + }) + else { return } + performThreadCommand( + "thread.user-input.dismiss", fields: ["requestId": .string(request.id)], + key: "request:" + request.id, + completion: { [weak self] in self?.clearQuestionDrafts(request, threadID: id) }) + } + + var selectedRequest: PendingRequest? { + let requests = thread?.pendingRequests ?? [] + return requests.first { $0.id == selectedRequestID } ?? requests.first + } + var questionRequest: PendingRequest? { + guard queuedEdit == nil, let request = selectedRequest, request.isQuestion else { return nil } + return request + } + func selectRequest(_ id: String) { + guard !importingAttachments.contains(composerDraftID ?? "") else { return } + selectedRequestID = id + notify() + } + func questionBusy(_ request: PendingRequest) -> Bool { + commandsInFlight.contains((selectedID ?? "") + ":request:" + request.id) + } + func questionIndex(_ request: PendingRequest) -> Int { + min(max(0, questionPages[request.id] ?? 0), max(0, request.questions.count - 1)) + } + var activeQuestion: JSON? { + guard let request = questionRequest, !request.questions.isEmpty else { return nil } + return request.questions[questionIndex(request)] + } + private func questionAttachments(_ request: PendingRequest) -> [String: [DraftAttachment]] { + guard let threadID = selectedID else { return [:] } + return Dictionary( + request.questions.compactMap { question in + guard let id = question["id"].string else { return nil } + return ( + id, + attachmentDrafts[ + QuestionDraft.attachmentKey( + threadID: threadID, requestID: request.id, questionID: id)] ?? [] + ) + }, uniquingKeysWith: { _, new in new }) + } + func questionProgress(_ request: PendingRequest) -> QuestionProgress { + if let cached = questionProgressCache, cached.request == request { return cached.progress } + let attachments = questionAttachments(request) + var blocked = Set() + for question in request.questions { + guard let id = question["id"].string, let threadID = selectedID else { continue } + let key = QuestionDraft.attachmentKey( + threadID: threadID, requestID: request.id, questionID: id) + let files = attachments[id] ?? [] + if importingAttachments.contains(key) + || (!files.isEmpty && !catalog.attachmentPolicy.questions) + || files.contains(where: { attachmentUploads[$0.id] != .ready }) + { + blocked.insert(id) + } + } + let progress = QuestionProgress( + questions: request.questions, drafts: questionDrafts[request.id] ?? [:], + index: questionPages[request.id] ?? 0, attachmentCounts: attachments.mapValues(\.count), + blocked: blocked) + questionProgressCache = (request, progress, attachments) + return progress + } + var activeQuestionProgress: QuestionProgress? { questionRequest.map(questionProgress) } + var composerBusy: Bool { + sending.contains(selectedID ?? "") || questionRequest.map(questionBusy) == true + } + func setQuestionPage(_ index: Int, request: PendingRequest) { + guard !questionBusy(request), !importingAttachments.contains(composerDraftID ?? ""), + thread?.pendingRequests.contains(request) == true + else { return } + questionPages[request.id] = min(max(0, index), max(0, request.questions.count - 1)) + notify() + } + func chooseQuestionOption(_ value: String, request: PendingRequest, question: JSON) { + guard canEditThread, request.canRespond, !questionBusy(request), queuedEdit == nil, + thread?.pendingRequests.contains(request) == true, + let id = question["id"].string, request.questions.contains(question), + question["options"].array.contains(where: { + ($0["value"].string ?? $0["label"].string) == value + }) + else { return } + var draft = questionDrafts[request.id]?[id] ?? QuestionDraft() + let custom = draft.custom.trimmingCharacters(in: .whitespacesAndNewlines) + if !custom.isEmpty, let threadID = selectedID { + let old = (drafts[threadID] ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + drafts[threadID] = old.isEmpty ? custom : old + "\n\n" + custom + } + draft.custom = "" + if question["multiSelect"].bool { + if draft.selected.contains(value) { + draft.selected.removeAll { $0 == value } + } else { + draft.selected.append(value) + } + } else { + draft.selected = [value] + } + questionDrafts[request.id, default: [:]][id] = draft + notify() + } + func advanceQuestion(_ request: PendingRequest) { + guard canEditThread, request.canRespond, !questionBusy(request), queuedEdit == nil, + thread?.pendingRequests.contains(request) == true + else { return } + let progress = questionProgress(request) + guard progress.answer != nil else { return } + if progress.isLast { + respondToQuestions(request) + } else { + setQuestionPage(progress.index + 1, request: request) + } + } + func submitComposer() { + guard sendBlockReason == nil else { return } + switch composerAction { + case .question: if let request = questionRequest { advanceQuestion(request) } + case .plan: submitPlan() + case .resume: resume() + case .stop: stop() + case .message: send() + } + } + + enum ComposerAction { case question, plan, resume, stop, message } + var composerAction: ComposerAction { + if questionRequest != nil { return .question } + let plan = planFollowUp + let hasContent = + !composerText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || !selectedAttachments.isEmpty || importingAttachments.contains(composerDraftID ?? "") + let canResume = execution.isHeld || execution.resumableRunID != nil + if plan != nil && (pendingPlanSubmission != nil || hasContent || !canResume) { return .plan } + if queuedEdit == nil && !hasContent { + if execution.activeRunID != nil { return .stop } + if canResume { return .resume } + } + return .message + } + + var pendingPlanSubmission: PendingPlanSubmission? { + selectedID.flatMap { planSubmissions[$0] } + } + var planFollowUp: ProposedPlan? { + guard queuedEdit == nil, selectedAttachments.isEmpty, + !importingAttachments.contains(selectedID ?? ""), + thread?.pendingRequests.contains(where: \.isQuestion) != true + else { return nil } + if let pendingPlanSubmission { return pendingPlanSubmission.plan } + guard catalog.planModeEnabled, selectedThread?.interactionMode == "plan", + execution.latestRunSettled, let plan = thread?.latestProposedPlan, + plan.status == "active", + !plan.markdown.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { return nil } + return plan + } + var planActionTitle: String { + if let pendingPlanSubmission { + return pendingPlanSubmission.isImplementation ? "Retry implementation" : "Retry refinement" + } + return composerText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? "Implement" : "Refine" + } + func discardPlanRetry() { + guard let id = selectedID, let command = planSubmissions[id], !sending.contains(id) else { + return + } + planSubmissions.removeValue(forKey: id) + if !flushDrafts() { planSubmissions[id] = command } + notify() + } + private var planSendBlockReason: String? { + guard canEditThread, catalogReady else { return "Wait for the thread to connect." } + if sending.contains(selectedID ?? "") { return "Sending…" } + if queueBusy { return "Wait for the queue update." } + if pendingPlanSubmission == nil, + catalog.provider(for: selectedThread?.model ?? .null)?.available != true + { + return "The selected provider is unavailable." + } + return nil + } + func submitPlan(newThread: Bool = false) { + guard let plan = planFollowUp, let template = selectedThread, let connection, + planSendBlockReason == nil, !newThread || pendingPlanSubmission == nil + else { return } + let command = + pendingPlanSubmission + ?? PendingPlanSubmission( + plan: plan, template: template, draft: drafts[template.id] ?? "", newThread: newThread, + defaultRuntimeMode: catalog.defaultRuntimeMode(projectID: template.projectID)) + planSubmissions[template.id] = command + guard flushDrafts() else { return } + sending.insert(template.id) + error = nil + notify() + let generation = generation + Task { + defer { + if self.generation == generation { + sending.remove(template.id) + notify() + } + } + do { + for payload in [command.setup, command.message] { + _ = try await connection.call("orchestration.dispatchCommand", payload: payload) + guard self.generation == generation else { return } + } + if !command.isNewThread, drafts[template.id] == command.draft { drafts[template.id] = "" } + planSubmissions.removeValue(forKey: template.id) + _ = flushDrafts() + if command.isNewThread, selectedID == template.id { select(command.targetThreadID) } + } catch { + guard self.generation == generation else { return } + self.error = + error.localizedDescription + + " Your plan send is saved. Retry reuses the same thread and message." + } + } + } + private func respondToQuestions(_ request: PendingRequest) { + guard canEditThread, request.canRespond, !questionBusy(request), let id = selectedID, + let connection, let answers = questionProgress(request).answers + else { return } + let attachments = questionProgressCache?.attachments ?? [:] + do { try catalog.attachmentPolicy.validate(attachments.values.flatMap { $0 }) } catch { + self.error = error.localizedDescription + notify() + return + } + let command = + questionResponses[request.id].flatMap { + $0.matches(answers: answers, attachments: attachments) ? $0 : nil + } + ?? PendingQuestionResponse( + threadID: id, requestID: request.id, answers: answers, attachments: attachments) + questionResponses[request.id] = command + guard flushDrafts() else { return } + let operation = id + ":request:" + request.id + commandsInFlight.insert(operation) + error = nil + notify() + let generation = generation + Task { + defer { + if self.generation == generation { + commandsInFlight.remove(operation) + notify() + } + } + do { + _ = try await connection.call("orchestration.dispatchCommand", payload: command.payload) + guard self.generation == generation else { return } + clearQuestionDrafts(request, threadID: id) + } catch { + guard self.generation == generation else { return } + self.error = error.localizedDescription + } + } + } + private func clearQuestionDrafts(_ request: PendingRequest, threadID: String) { + clearQuestionDrafts([request.id], threadID: threadID) + } + private func clearQuestionDrafts(_ requestIDs: Set, threadID: String) { + guard !requestIDs.isEmpty else { return } + var files: [DraftAttachment] = [] + var changed = false + for key in Set(attachmentDrafts.keys).union(importingAttachments) { + guard let scope = QuestionDraft.attachmentScope(key), scope.threadID == threadID, + requestIDs.contains(scope.requestID) + else { continue } + files += attachmentDrafts.removeValue(forKey: key) ?? [] + importingAttachments.remove(key) + changed = true + } + for id in requestIDs { + if let response = questionResponses.removeValue(forKey: id) { + files += response.attachments.values.flatMap { $0 } + changed = true + } + if questionDrafts.removeValue(forKey: id) != nil { changed = true } + if questionPages.removeValue(forKey: id) != nil { changed = true } + } + guard changed else { return } + files = Array(Dictionary(files.map { ($0.id, $0) }, uniquingKeysWith: { _, new in new }).values) + for file in files { + uploadJobs.removeValue(forKey: file.id)?.task.cancel() + attachmentUploads.removeValue(forKey: file.id) + } + if flushDrafts(), let repository = attachmentRepository { + for file in files { repository.remove(file) } + } + startAttachmentUploads() + } + private func cleanResolvedQuestions(_ value: JSON, threadID: String) { + let records: [JSON] + if value["kind"].string == "snapshot" { + records = value["projection"]["runtimeRequests"].array + } else if value["event"]["type"].string == "runtime-request.updated" { + records = [value["event"]["payload"]] + } else { + return + } + let ids = Set( + records.compactMap { record -> String? in + guard record["kind"].string == "user_input", + ["resolved", "expired", "cancelled"].contains(record["status"].string ?? "") + else { return nil } + return record["id"].string + }) + clearQuestionDrafts(ids, threadID: threadID) + } + + private func performThreadCommand( + _ type: String, fields: [String: JSON], key: String, + threadID: String? = nil, completion: (() -> Void)? = nil + ) { + guard ready, threadID != nil || canEditThread, + let id = threadID ?? selectedID, let connection + else { return } + let operation = id + ":" + key + guard !commandsInFlight.contains(operation) else { return } + let generation = generation + var payload = fields + payload["type"] = .string(type) + payload["threadId"] = .string(id) + payload["commandId"] = .string(UUID().uuidString) + commandsInFlight.insert(operation) + error = nil + notify() + Task { + defer { + if self.generation == generation { + commandsInFlight.remove(operation) + notify() + } + } + do { + _ = try await connection.call("orchestration.dispatchCommand", payload: .object(payload)) + guard self.generation == generation else { return } + completion?() + } catch { + guard self.generation == generation else { return } + self.error = error.localizedDescription + } + } + } + + func select(_ id: String, force: Bool = false) { + guard id != selectedID || force else { return } + if id != selectedID { + suppressVisit = nil + visibleVisit = nil + } + detail?.cancel() + selectionGeneration = UUID() + let selection = selectionGeneration + let generation = generation + selectedID = id + if let settings { + var selections = preferences.dictionary(forKey: "selectedThreadByEndpoint") ?? [:] + selections[settings.endpoint.absoluteString] = id + preferences.set(selections, forKey: "selectedThreadByEndpoint") + } + if thread?.id != id { thread = ThreadState(id: id) } + thread?.markUnsynchronized() + loadingHistory = false + error = nil + notify() + guard !isNewConversation, let connection, ready else { return } + detail = Task { + do { + let subscription = try await connection.subscribe( + "orchestration.subscribeThread", + payload: .object([ + "threadId": .string(id), "requestCompletionMarker": .bool(true), + "acceptBoundedSnapshot": .bool(true), + ])) + defer { Task { await connection.cancel(subscription.id) } } + for try await value in subscription.values { + try Task.checkCancellation() + guard self.generation == generation && selectionGeneration == selection else { return } + let previousSequence = thread?.sequence + if try thread?.apply(value) == true { + select(id, force: true) + return + } + if value["kind"].string == "snapshot" || thread?.sequence != previousSequence { + cleanResolvedQuestions(value, threadID: id) + } + notify() + } + guard !Task.isCancelled && selectionGeneration == selection && self.generation == generation + else { return } + thread?.markUnsynchronized() + error = "Conversation updates ended. Reconnect to refresh." + notify() + } catch { + guard !Task.isCancelled && selectionGeneration == selection && self.generation == generation + else { return } + thread?.markUnsynchronized() + self.error = error.localizedDescription + notify() + } + } + } + + func loadOlder() { + guard let settings, let thread, let cursor = thread.cursor, thread.hasMore, !loadingHistory + else { return } + loadingHistory = true + let selection = selectionGeneration + notify() + Task { + do { + let page = try await EnvironmentHTTP.read( + settings.request( + "/api/orchestration/threads/\(thread.id)/history", + query: [URLQueryItem(name: "cursor", value: cursor)])) + guard selectionGeneration == selection else { return } + try self.thread?.prependHistory(page) + } catch { + guard selectionGeneration == selection else { return } + self.error = error.localizedDescription + } + guard selectionGeneration == selection else { return } + loadingHistory = false + notify() + } + } + + func loadOutput(_ row: TimelineRow) { + let key = row.id + ":output" + guard ready, let connection, row.item["outputOmitted"].bool, + !commandsInFlight.contains(key) + else { return } + commandsInFlight.insert(key) + let generation = generation + let selection = selectionGeneration + notify() + Task { + defer { + if self.generation == generation { + commandsInFlight.remove(key) + notify() + } + } + do { + let response = try await connection.call( + "orchestration.getTurnItem", + payload: .object([ + "threadId": .string(row.sourceID), "itemId": row.item["id"], + "revision": row.item["updatedAt"], + ])) + guard self.generation == generation, selectionGeneration == selection else { return } + guard response["item"] != .null else { + throw T3Error("This tool output is no longer available.") + } + try thread?.hydrateOutput(response["item"], replacing: row) + } catch { + guard self.generation == generation, selectionGeneration == selection else { return } + self.error = "Tool output: " + error.localizedDescription + } + } + } + + var queuedEdit: QueuedEdit? { + queuedEdits.values.first { $0.threadID == selectedID && $0.active } + } + var savedQueuedEdits: [QueuedEdit] { + queuedEdits.values.filter { $0.threadID == selectedID && !$0.active }.sorted { + $0.runID < $1.runID + } + } + var messageDraftID: String? { queuedEdit?.draftID ?? selectedID } + var composerDraftID: String? { + if let request = questionRequest, let id = selectedID, + let questionID = activeQuestion?["id"].string + { + return QuestionDraft.attachmentKey( + threadID: id, requestID: request.id, questionID: questionID) + } + return messageDraftID + } + var composerText: String { + get { + if let request = questionRequest, let id = activeQuestion?["id"].string { + return questionDrafts[request.id]?[id]?.custom ?? "" + } + return messageDraftID.flatMap { drafts[$0] } ?? "" + } + set { + if let request = questionRequest, let question = activeQuestion, + let id = question["id"].string + { + guard question["allowCustomAnswer"] != .bool(false), !questionBusy(request) else { return } + var draft = questionDrafts[request.id]?[id] ?? QuestionDraft() + draft.custom = newValue + if !newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { draft.selected = [] } + questionDrafts[request.id, default: [:]][id] = draft + } else if let id = messageDraftID { + drafts[id] = newValue + } + } + } + var execution: ThreadExecution { thread?.execution ?? ThreadExecution() } + var delivery: MessageDelivery { + guard queuedEdit == nil else { return .auto } + return selectedID.flatMap { deliveries[$0] } ?? .auto + } + func setDelivery(_ value: MessageDelivery) { + guard let id = selectedID, !sending.contains(id), queuedEdit == nil else { return } + deliveries[id] = value + notify() + } + var queueBusy: Bool { + !canEditThread || commandsInFlight.contains((selectedID ?? "") + ":queue") + || sending.contains(selectedID ?? "") + } + func editQueuedMessage(_ message: QueuedMessage) { + guard let id = selectedID, !queueBusy, queuedEdit == nil, + !importingAttachments.contains(composerDraftID ?? ""), execution.queued.contains(message) + else { return } + if queuedEdits[message.id] != nil { + queuedEdits[message.id]?.active = true + } else { + let edit = QueuedEdit(message, threadID: id) + queuedEdits[message.id] = edit + drafts[edit.draftID] = message.text + } + _ = flushDrafts() + notify() + } + func cancelQueuedEdit() { + guard let id = selectedID, let edit = queuedEdit, !sending.contains(id), + !importingAttachments.contains(composerDraftID ?? "") + else { return } + queuedEdits[edit.runID]?.active = false + _ = flushDrafts() + notify() + } + func removeQueuedAttachment(_ attachment: JSON) { + guard let id = selectedID, let edit = queuedEdit, !sending.contains(id) else { return } + queuedEdits[edit.runID]?.attachments.removeAll { $0["id"] == attachment["id"] } + notify() + } + func reopenQueuedEdit(_ runID: String) { + guard let id = selectedID, !queueBusy, queuedEdit == nil, queuedEdits[runID]?.threadID == id, + !importingAttachments.contains(composerDraftID ?? "") + else { return } + queuedEdits[runID]?.active = true + _ = flushDrafts() + notify() + } + func reorderQueuedMessage(_ id: String, before beforeID: String?) { + guard !queueBusy, execution.canQueue, execution.queued.contains(where: { $0.id == id }), + beforeID == nil || execution.queued.contains(where: { $0.id == beforeID }) + else { return } + performThreadCommand( + "queued-run.reorder", + fields: ["runId": .string(id), "beforeRunId": beforeID.map(JSON.string) ?? .null], + key: "queue") + } + func cancelQueuedMessage(_ id: String) { + guard !queueBusy, execution.queued.contains(where: { $0.id == id }) else { return } + performThreadCommand("queued-run.cancel", fields: ["runId": .string(id)], key: "queue") + } + func steerQueuedMessage(_ id: String) { + guard !queueBusy, execution.canSteer, let target = execution.activeRunID, + execution.queued.contains(where: { $0.id == id }) + else { return } + performThreadCommand( + "queued-message.promote-to-steer", + fields: ["queuedRunId": .string(id), "targetRunId": .string(target)], key: "queue") + } + + var canAddBrowserContext: Bool { + selectedThread?.archived == false && questionRequest == nil && queuedEdit == nil + && pendingPlanSubmission == nil && messageDraftID != nil + } + func addBrowserContext(_ context: JSON) { + guard canAddBrowserContext else { return } + let draft = composerText + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + guard let data = try? encoder.encode(context), let text = String(data: data, encoding: .utf8) + else { return } + // A fence longer than any run in the page keeps selected HTML inside the context block. + let fence = String( + repeating: "`", + count: max(3, (text.split(whereSeparator: { $0 != "`" }).map(\.count).max() ?? 0) + 1)) + composerText = + draft + (draft.isEmpty ? "" : "\n\n") + "Selected browser element:\n" + fence + "json\n" + + text + "\n" + fence + notify() + } + + var selectedAttachments: [DraftAttachment] { + composerDraftID.flatMap { attachmentDrafts[$0] } ?? [] + } + var canAttach: Bool { + guard selectedThread?.archived == false, !importingAttachments.contains(composerDraftID ?? ""), + !(queuedEdit != nil && sending.contains(selectedID ?? "")), pendingPlanSubmission == nil + else { return false } + if let request = questionRequest { + return request.canRespond && !questionBusy(request) && catalog.attachmentPolicy.questions + && activeQuestion?["allowCustomAnswer"] != .bool(false) + } + return true + } + var attachmentRepository: AttachmentRepository? { + settings.map { draftRepository.attachmentRepository(for: $0.endpoint) } + } + func attachmentPreviewRequest(_ attachment: JSON) throws -> AttachmentPreviewRequest { + guard let connection, let settings, ready else { + throw T3Error("Connect before downloading attachments.") + } + return try .remote(attachment, connection: connection, settings: settings) + } + func downloadAttachment(_ attachment: JSON, to destination: URL) async throws { + try await attachmentPreviewRequest(attachment).load(destination) + } + var sendBlockReason: String? { + if let request = questionRequest { + guard canEditThread, request.canRespond else { + return request.reason ?? "Wait for the question to reconnect." + } + if questionBusy(request) { return "Sending answers…" } + let progress = questionProgress(request) + return progress.answer == nil || (progress.isLast && progress.answers == nil) + ? "Answer the question and finish its uploads before continuing." : nil + } + let action = composerAction + if action == .plan { return planSendBlockReason } + if action == .resume || action == .stop { + guard canEditThread else { return "Wait for the thread to connect." } + if sending.contains(selectedID ?? "") { return "Sending…" } + if queueBusy { return "Wait for the queue update." } + if commandsInFlight.contains((selectedID ?? "") + ":stop") { return "Stopping…" } + return nil + } + return messageSendBlockReason + } + private var messageSendBlockReason: String? { + guard let threadID = selectedID, let id = messageDraftID, canEditThread else { + return "Wait for the thread to connect." + } + if sending.contains(threadID) { return "Sending…" } + // An uncertain first launch must replay its original request, even if the user edits the draft. + if pending[id]?.launch != nil { return nil } + if let draft = selectedDraft, draft.workspaceMode == "worktree", draft.branch == nil { + return "Select a base branch for the new worktree." + } + if let model = selectedDraft?.model, catalog.provider(for: model)?.available != true { + return "Choose an available provider account." + } + if commandsInFlight.contains(threadID + ":queue") { return "Wait for the queue update." } + if importingAttachments.contains(id) { return "Adding attachments…" } + let attachments = attachmentDrafts[id] ?? [] + let text = drafts[id] ?? "" + let edit = queuedEdit + if text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && attachments.isEmpty + && (edit?.attachments.isEmpty ?? true) + { + return "Write a message or attach a file." + } + if pending[id]?.matches(text: text, attachments: attachments, delivery: delivery, edit: edit) + == true + { + return nil + } + if let edit, + !execution.queued.contains(where: { $0.id == edit.runID && $0.messageID == edit.messageID }) + { + return + "This queued message has started or was removed. Your edit is saved; return to your draft to continue." + } + if delivery == .queue && !execution.canQueue { + return "This provider does not support queued messages. Choose Auto." + } + if delivery == .steer && !execution.canSteer { + return "There is no provider turn available to steer. Choose Auto or Queue next." + } + if delivery == .restart && !execution.canRestart { + return "This provider turn cannot be restarted. Choose Auto or Queue next." + } + do { + try catalog.attachmentPolicy.validate(attachments, retained: edit?.attachments ?? []) + } catch { + return error.localizedDescription + } + for attachment in attachments where attachmentUploads[attachment.id] != .ready { + if case .failed(let reason) = attachmentUploads[attachment.id] { return reason } + return "Wait for attachments to finish uploading." + } + return nil + } + + func addAttachments(_ urls: [URL], deleteSources: Bool = false) { + guard let id = composerDraftID, canAttach, let repository = attachmentRepository else { return } + let generation = generation + let retained = queuedEdit?.attachments ?? [] + let questionFiles = questionRequest.map(questionAttachments) ?? [:] + let otherFiles = questionFiles.filter { + $0.key != activeQuestion?["id"].string + }.values.flatMap { $0 } + let remaining = max( + 0, 100 - (attachmentDrafts[id]?.count ?? 0) - retained.count - otherFiles.count) + importingAttachments.insert(id) + notify() + Task { + let result = await Task.detached { + var files: [DraftAttachment] = [] + var errors: [String] = + urls.count > remaining ? ["You can attach up to 100 files per message."] : [] + for url in urls.prefix(remaining) { + do { files.append(try repository.stage(url)) } catch { + errors.append(error.localizedDescription) + } + } + if deleteSources { + for url in urls { + try? FileManager.default.removeItem(at: url) + if url.deletingLastPathComponent().lastPathComponent.hasPrefix("t3-paste-") { + try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) + } + } + } + return (files, errors) + }.value + guard self.generation == generation, importingAttachments.contains(id) else { + result.0.forEach(repository.remove) + return + } + importingAttachments.remove(id) + var errors = result.1 + for attachment in result.0 { + do { + try catalog.attachmentPolicy.validate( + otherFiles + (attachmentDrafts[id] ?? []) + [attachment], requireServer: catalogReady, + retained: retained) + attachmentDrafts[id, default: []].append(attachment) + } catch { + repository.remove(attachment) + errors.append(error.localizedDescription) + } + } + if !errors.isEmpty { error = errors.joined(separator: "\n") } + if flushDrafts() { startAttachmentUploads() } + notify() + } + } + + func removeAttachment(_ attachment: DraftAttachment) { + guard let id = composerDraftID, !composerBusy, + let repository = attachmentRepository + else { + return + } + uploadJobs.removeValue(forKey: attachment.id)?.task.cancel() + attachmentUploads.removeValue(forKey: attachment.id) + attachmentDrafts[id]?.removeAll { $0.id == attachment.id } + // An uncertain send still owns its exact bytes and remote IDs until resolved. + if !(pending[id]?.localAttachmentIDs ?? []).contains(attachment.id), + !questionResponses.values.contains(where: { $0.localAttachmentIDs.contains(attachment.id) }), + flushDrafts() + { + repository.remove(attachment) + if let uploadedID = attachment.uploadedID, let connection { + Task { + _ = try? await connection.call( + "attachments.delete", payload: .object(["attachmentId": .string(uploadedID)])) + } + } + } + startAttachmentUploads() + notify() + } + + func retryAttachment(_ attachment: DraftAttachment) { + guard uploadJobs[attachment.id] == nil else { return } + attachmentUploads.removeValue(forKey: attachment.id) + startAttachmentUploads() + notify() + } + + private func startAttachmentUploads() { + guard ready, catalogReady, catalog.attachmentPolicy.uploads, + let connection, let settings, let repository = attachmentRepository + else { return } + let generation = generation + let questionAttachmentIDs = questionResponses.values.reduce(into: Set()) { + $0.formUnion($1.localAttachmentIDs) + } + for (threadID, attachments) in attachmentDrafts.sorted(by: { $0.key < $1.key }) { + for attachment in attachments { + if (pending[threadID]?.localAttachmentIDs ?? []).contains(attachment.id) + || questionAttachmentIDs.contains(attachment.id) + { + attachmentUploads[attachment.id] = .ready + continue + } + guard uploadJobs.count < 2 else { return } + guard attachmentUploads[attachment.id] == nil, uploadJobs[attachment.id] == nil else { + continue + } + do { try catalog.attachmentPolicy.validate([attachment]) } catch { + attachmentUploads[attachment.id] = .failed(error.localizedDescription) + continue + } + let token = UUID() + attachmentUploads[attachment.id] = .uploading(0) + let task = Task { [self] in + var mintedID: String? + defer { + if let mintedID { + Task { + _ = try? await connection.call( + "attachments.delete", payload: .object(["attachmentId": .string(mintedID)])) + } + } + if self.generation == generation, uploadJobs[attachment.id]?.token == token { + uploadJobs.removeValue(forKey: attachment.id) + startAttachmentUploads() + notify() + } + } + do { + var uploadedID = attachment.uploadedID + if let previous = uploadedID { + do { + _ = try await connection.call( + "assets.createUrl", + payload: .object([ + "resource": .object([ + "_tag": .string("attachment"), "attachmentId": .string(previous), + ]) + ])) + } catch { + guard (error as? T3Error)?.remoteCode == "AssetAttachmentNotFoundError" else { + throw error + } + uploadedID = nil + } + } + if uploadedID == nil { + try Task.checkCancellation() + let issued = try await connection.call( + "attachments.createUploadUrl", payload: attachment.uploadInput) + mintedID = try issued.requiredString("attachmentId") + try Task.checkCancellation() + let url = try AttachmentHTTP.signedURL( + issued.requiredString("relativeUrl"), endpoint: settings.endpoint, + prefix: "/api/attachments/upload/") + try await AttachmentHTTP.upload( + attachment, from: repository.file(attachment), to: url + ) { [weak self] progress in + Task { @MainActor in + guard let self, self.generation == generation, + self.uploadJobs[attachment.id]?.token == token + else { return } + let value = Int(progress * 20) * 5 + if self.attachmentUploads[attachment.id] != .uploading(value) { + self.attachmentUploads[attachment.id] = .uploading(value) + self.notify() + } + } + } + uploadedID = mintedID + } + try Task.checkCancellation() + guard self.generation == generation, uploadJobs[attachment.id]?.token == token, + let index = attachmentDrafts[threadID]?.firstIndex(where: { $0.id == attachment.id }) + else { return } + attachmentDrafts[threadID]?[index].uploadedID = uploadedID + mintedID = nil + attachmentUploads[attachment.id] = .ready + _ = flushDrafts() + } catch { + guard self.generation == generation, uploadJobs[attachment.id]?.token == token else { + return + } + attachmentUploads[attachment.id] = .failed( + "\(attachment.name): " + error.localizedDescription) + } + } + uploadJobs[attachment.id] = (token, task) + } + } + } + + func send() { + guard let id = selectedID, let draftID = messageDraftID, let connection, + messageSendBlockReason == nil + else { return } + let text = drafts[draftID] ?? "" + let attachments = attachmentDrafts[draftID] ?? [] + let edit = queuedEdit + var command = + pending[draftID].flatMap { + if $0.launch != nil { return $0 } + return $0.matches(text: text, attachments: attachments, delivery: delivery, edit: edit) + ? $0 : nil + } + ?? PendingSubmission( + text: text, attachments: (edit?.attachments ?? []) + attachments.compactMap(\.wire), + localAttachmentIDs: attachments.map(\.id), delivery: delivery, queuedRunID: edit?.runID, + retainedAttachmentIDs: edit?.attachments.compactMap { $0["id"].string }, + context: edit?.retainedContext) + if let draft = selectedDraft, command.launch == nil { + do { + let launch = try draft.launch(command) + command = PendingSubmission( + text: command.text, command: command.command, message: command.message, + attachments: command.attachments, localAttachmentIDs: command.localAttachmentIDs, + delivery: command.delivery, launch: launch) + } catch { + self.error = error.localizedDescription + notify() + return + } + } + pending[draftID] = command + let submitted = command + guard flushDrafts() else { return } + let generation = generation + let repository = attachmentRepository + sending.insert(id) + error = nil + notify() + Task { + defer { + if self.generation == generation { + sending.remove(id) + notify() + } + } + do { + _ = try await connection.call( + submitted.launch == nil ? "orchestration.dispatchCommand" : "orchestration.launchThread", + payload: submitted.launch ?? submitted.payload(threadID: id)) + guard self.generation == generation else { return } + if submitted.launch != nil { + conversationDrafts.removeValue(forKey: id) + if selectedID == id { select(id, force: true) } + } + if drafts[draftID] == submitted.text { drafts[draftID] = "" } + let sentIDs = Set(submitted.localAttachmentIDs ?? []) + attachmentDrafts[draftID]?.removeAll { sentIDs.contains($0.id) } + for id in sentIDs { attachmentUploads.removeValue(forKey: id) } + pending.removeValue(forKey: draftID) + if let edit { queuedEdits.removeValue(forKey: edit.runID) } + if flushDrafts() { + for attachment in attachments where sentIDs.contains(attachment.id) { + repository?.remove(attachment) + } + } + } catch { + guard self.generation == generation, selectedID == id else { return } + self.error = + error.localizedDescription + + (submitted.launch == nil + ? " Your draft is preserved. Retrying the unchanged message reuses the same command." + : " Retry sends the original first message. Any newer edits remain saved for the next message.") + } + } + } + + var canCreateThread: Bool { ready && catalogReady && !creatingThread } + var projectSessionID: UUID { usageSessionID } + + func browseProjectFolders(_ path: String, session: UUID) async throws -> JSON { + let path = path.trimmingCharacters(in: .whitespacesAndNewlines) + guard !path.isEmpty else { throw T3Error("Enter a folder path.") } + return try await projectCall( + "filesystem.browse", + payload: .object(["partialPath": .string(path.hasSuffix("/") ? path : path + "/")]), + session: session) + } + + func openProject(_ path: String, session: UUID) async throws -> JSON { + let result = try await browseProjectFolders(path, session: session) + let root = try result.requiredString("parentPath") + if let existing = shell.projectDetails.values.first(where: { + $0["workspaceRoot"].string == root + }) { + return existing + } + let id = UUID().uuidString + do { + return try await projectCall( + "projects.mutate", + payload: .object([ + "type": .string("project.create"), "commandId": .string(UUID().uuidString), + "projectId": .string(id), "workspaceRoot": .string(root), + "title": .string(root == "/" ? "/" : (root as NSString).lastPathComponent), + ]), session: session) + } catch { + // An accepted create may lose its response, or another client may open the same folder. + if let existing = try? await serverProjects(session: session).first(where: { + $0["workspaceRoot"].string == root + }) { + return existing + } + throw error + } + } + + func createProject(_ name: String, session: UUID) async throws -> ( + project: JSON, warning: String? + ) { + let result = try await projectCall( + "projects.createNew", payload: .object(["name": .string(name)]), session: session) + let id = try result.requiredString("projectId") + let project = try await project(id: id, session: session) + return ( + project, + result["commitError"].string.map { "Project created. Its initial commit failed: " + $0 } + ) + } + + private func projectCall(_ method: String, payload: JSON, session: UUID) async throws -> JSON { + guard ready, let connection, session == projectSessionID else { + throw T3Error("The connection changed. Reopen the project picker to use the current server.") + } + let result = try await connection.call(method, payload: payload) + try Task.checkCancellation() + guard ready, session == projectSessionID else { + throw T3Error("The connection changed while the project operation was running.") + } + return result + } + + private func serverProjects(session: UUID) async throws -> [JSON] { + guard ready, let connection, session == projectSessionID else { + throw T3Error("The project connection changed.") + } + let stream = try await connection.subscribe( + "orchestration.subscribeShell", payload: .object(["requestCompletionMarker": .bool(true)])) + defer { Task { await connection.cancel(stream.id) } } + for try await value in stream.values { + try Task.checkCancellation() + guard ready, session == projectSessionID else { + throw T3Error("The project connection changed.") + } + if value["kind"].string == "snapshot" { + return value["snapshot"]["projects"].array + } + } + throw T3Error("The server did not return its projects.") + } + + private func project(id: String, session: UUID) async throws -> JSON { + if let project = shell.projectDetails[id], session == projectSessionID { return project } + guard + let project = try await serverProjects(session: session).first(where: { + $0["id"].string == id + }) + else { throw T3Error("The project is no longer available.") } + return project + } + + func updateConversationDraft(_ edit: (inout NewConversationDraft) -> Void) { + guard let id = selectedID, var draft = conversationDrafts[id], + !sending.contains(id), pending[id]?.launch == nil + else { return } + edit(&draft) + conversationDrafts[id] = draft + error = nil + notify() + } + + func setDraftProject(_ project: JSON) { + guard isNewConversation else { return } + updateConversationDraft { draft in + draft.project = project + draft.branch = nil + } + } + + func startWithoutProject() { + guard canCreateThread, catalog.scratchWorkspaceRoot != nil else { return } + let session = projectSessionID + let selection = selectionGeneration + Task { + do { + let scratch = try await projectCall( + "projects.ensureScratch", payload: .object([:]), session: session) + let project = try await self.project( + id: scratch.requiredString("projectId"), session: session) + guard selectionGeneration == selection else { return } + if isNewConversation { setDraftProject(project) } else { newThread(project: project) } + } catch { + guard session == projectSessionID, selectionGeneration == selection else { return } + self.error = error.localizedDescription + notify() + } + } + } + + func compactContext() { + guard canEditThread, !isNewConversation, !composerBusy, execution.activeRunID == nil, + let id = selectedID, let connection + else { return } + let command = Commands.message( + threadID: id, text: "/compact", commandID: UUID().uuidString, messageID: UUID().uuidString) + let generation = generation + sending.insert(id) + notify() + Task { + defer { + if self.generation == generation { + sending.remove(id) + notify() + } + } + do { _ = try await connection.call("orchestration.dispatchCommand", payload: command) } catch + { if self.generation == generation { self.error = error.localizedDescription } } + } + } + + func resolveNewDraftWorkspaceMode(project: JSON, session: UUID) async throws -> String { + guard ready, session == projectSessionID else { + throw T3Error("The project connection changed.") + } + if let configured = catalog.configuredWorkspaceMode(project: project) { return configured } + let file = try? await projectCall( + "projects.readFile", + payload: .object([ + "cwd": project["workspaceRoot"], "relativePath": .string("t3.json"), + ]), session: session) + try Task.checkCancellation() + guard ready, session == projectSessionID else { + throw T3Error("The project connection changed.") + } + if let configured = catalog.configuredWorkspaceMode(project: project) { return configured } + guard let file, !file["truncated"].bool, let contents = file["contents"].string, + contents.utf8.count <= 1_048_576 + else { return "local" } + return WorkspaceDefaults.fileMode(contents: contents) ?? "local" + } + + func newThread( + projectID: String? = nil, project openedProject: JSON? = nil, warning: String? = nil + ) { + guard canCreateThread else { return } + let session = projectSessionID + let selection = selectionGeneration + let template = selectedThread + let projectID = projectID ?? openedProject?["id"].string ?? template?.projectID + creatingThread = true + error = nil + notify() + Task { + defer { + if session == projectSessionID { + creatingThread = false + notify() + } + } + do { + let project: JSON + if let openedProject { + project = openedProject + } else if let projectID { + project = try await self.project(id: projectID, session: session) + } else { + let scratch = try await projectCall( + "projects.ensureScratch", payload: .object([:]), session: session) + project = try await self.project( + id: scratch.requiredString("projectId"), session: session) + } + let workspaceMode = try await resolveNewDraftWorkspaceMode( + project: project, session: session) + let configuration = try catalog.newThreadConfiguration(project: project, carrying: template) + let draft = NewConversationDraft( + project: project, configuration: configuration, workspaceMode: workspaceMode) + conversationDrafts[draft.id] = draft + guard flushDrafts() else { return } + if selectionGeneration == selection { + showingArchives = false + select(draft.id) + } + error = warning + } catch { + guard session == projectSessionID else { return } + self.error = error.localizedDescription + notify() + } + } + } + + func stop() { + guard let run = execution.activeRunID ?? selectedThread?.activeRunID else { return } + performThreadCommand( + "run.interrupt", fields: ["runId": .string(run), "holdQueue": .bool(true)], key: "stop") + } + + func resume() { + guard let id = selectedID, let connection, !queueBusy, + execution.isHeld || execution.resumableRunID != nil + else { return } + let key = "resume:" + id + let command = + pending[key] + ?? PendingSubmission( + text: "Continue where you left off.", continuationOf: execution.resumableRunID) + pending[key] = command + guard flushDrafts() else { return } + commandsInFlight.insert(id + ":queue") + error = nil + notify() + let generation = generation + Task { + defer { + if self.generation == generation { + commandsInFlight.remove(id + ":queue") + notify() + } + } + do { + if command.continuationOf != nil { + _ = try await connection.call( + "orchestration.dispatchCommand", payload: command.payload(threadID: id)) + } + _ = try await connection.call( + "orchestration.dispatchCommand", + payload: .object([ + "type": .string("queue.resume"), "commandId": .string(command.command + ":queue"), + "threadId": .string(id), + ])) + guard self.generation == generation else { return } + pending.removeValue(forKey: key) + _ = flushDrafts() + } catch { + guard self.generation == generation else { return } + self.error = error.localizedDescription + } + } + } + + private func notify() { + guard notification == nil else { return } + notification = Task { + try? await Task.sleep(for: .milliseconds(33)) + notification = nil + onChange?() + } + } +} diff --git a/apps/macos/Sources/T3Native/ComposerAppearance.swift b/apps/macos/Sources/T3Native/ComposerAppearance.swift new file mode 100644 index 0000000000000..d0cf4e9d9b2f2 --- /dev/null +++ b/apps/macos/Sources/T3Native/ComposerAppearance.swift @@ -0,0 +1,302 @@ +import AppKit +import T3Core + +@MainActor enum ProviderArtwork { + private static var images: [String: NSImage] = [:] + static func image(_ driver: String) -> NSImage? { + if let image = images[driver] { return image } + let ext = driver == "antigravity" ? "png" : "pdf" + guard + let url = Bundle.module.url( + forResource: driver, withExtension: ext, subdirectory: "Resources/Providers"), + let image = NSImage(contentsOf: url) + else { return nil } + image.isTemplate = ext == "pdf" + image.size = NSSize(width: 18, height: 18) + images[driver] = image + return image + } + static func color(_ driver: String) -> NSColor { + driver == "claudeAgent" + ? NSColor(srgbRed: 0.85, green: 0.47, blue: 0.34, alpha: 1) : .secondaryLabelColor + } + static func accountImage(_ provider: ProviderEntry) -> NSImage? { + let key = "account:" + provider.id + ":" + provider.name + ":" + provider.driver + if let image = images[key] { return image } + guard let artwork = image(provider.driver) else { return nil } + let badge = provider.driver == "codex" ? "" : SidebarProviderMark.initials(provider.name) + let combined = NSImage(size: NSSize(width: 22, height: 24), flipped: false) { _ in + let iconRect = NSRect(x: 1, y: 6, width: 18, height: 18) + artwork.draw(in: iconRect) + if artwork.isTemplate { + color(provider.driver).setFill() + iconRect.fill(using: .sourceAtop) + } + if !badge.isEmpty { + let rect = NSRect(x: 4, y: 0, width: 18, height: 10) + NSColor.controlBackgroundColor.setFill() + NSBezierPath(roundedRect: rect, xRadius: 4, yRadius: 4).fill() + let attributes: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: 7, weight: .semibold), + .foregroundColor: NSColor.secondaryLabelColor, + ] + let size = (badge as NSString).size(withAttributes: attributes) + (badge as NSString).draw( + at: NSPoint(x: rect.midX - size.width / 2, y: 1), withAttributes: attributes) + } + return true + } + images[key] = combined + return combined + } +} + +@MainActor final class ComposerPill: NSButton { + var invoke: () -> Void + private var hover = false + private var tracking: NSTrackingArea? + init(_ label: String, symbol: String? = nil, height: CGFloat = 32, action: @escaping () -> Void) { + invoke = action + super.init(frame: .zero) + title = label + " ⌄" + font = .systemFont(ofSize: 13) + isBordered = false + bezelStyle = .inline + alignment = .left + imagePosition = .imageLeading + contentTintColor = .secondaryLabelColor + if let symbol { image = NSImage(systemSymbolName: symbol, accessibilityDescription: nil) } + symbolConfiguration = .init(pointSize: 14, weight: .regular) + wantsLayer = true + layer?.cornerRadius = 8 + target = self + self.action = #selector(performAction) + heightAnchor.constraint(equalToConstant: height).isActive = true + widthAnchor.constraint(greaterThanOrEqualToConstant: 36).isActive = true + setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + cell?.lineBreakMode = .byTruncatingTail + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + func displayLabel(_ label: String) { title = label.isEmpty ? "" : label + " ⌄" } + @objc private func performAction() { + guard isEnabled else { return } + invoke() + } + override func updateTrackingAreas() { + super.updateTrackingAreas() + if let tracking { removeTrackingArea(tracking) } + tracking = NSTrackingArea( + rect: .zero, options: [.mouseEnteredAndExited, .activeInKeyWindow, .inVisibleRect], + owner: self) + addTrackingArea(tracking!) + } + override func mouseEntered(with event: NSEvent) { + hover = true + needsDisplay = true + } + override func mouseExited(with event: NSEvent) { + hover = false + needsDisplay = true + } + override func draw(_ dirtyRect: NSRect) { + layer?.backgroundColor = + hover && isEnabled + ? NSColor.labelColor.withAlphaComponent(0.06).cgColor : NSColor.clear.cgColor + super.draw(dirtyRect) + } +} + +struct ComposerOption { + let title: String + var description: String? = nil + var symbol: String? = nil + var selected = false + var badge: String? = nil + let action: () -> Void +} + +@MainActor private final class ComposerOptionBackground: NSView { + let selected: Bool + private var hover = false + private var tracking: NSTrackingArea? + init(selected: Bool) { + self.selected = selected + super.init(frame: .zero) + wantsLayer = true + layer?.cornerRadius = 8 + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + override var wantsUpdateLayer: Bool { true } + override func updateLayer() { + layer?.backgroundColor = + NSColor.labelColor.withAlphaComponent(selected ? 0.085 : hover ? 0.045 : 0).cgColor + } + override func updateTrackingAreas() { + super.updateTrackingAreas() + if let tracking { removeTrackingArea(tracking) } + tracking = NSTrackingArea( + rect: .zero, options: [.mouseEnteredAndExited, .activeInKeyWindow, .inVisibleRect], + owner: self) + addTrackingArea(tracking!) + } + override func mouseEntered(with event: NSEvent) { + hover = true + needsDisplay = true + } + override func mouseExited(with event: NSEvent) { + hover = false + needsDisplay = true + } + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + needsDisplay = true + } +} + +@MainActor private final class ComposerOptionsDocument: NSView { + override var isFlipped: Bool { true } +} + +@MainActor final class ComposerOptionsPopover: NSViewController { + private let heading: String? + private let options: [ComposerOption] + private let close: () -> Void + private let width: CGFloat + private var measuredContentSize = NSSize.zero + var contentSize: NSSize { + _ = view + return measuredContentSize + } + init(_ heading: String?, options: [ComposerOption], width: CGFloat, close: @escaping () -> Void) { + self.heading = heading + self.options = options + self.width = width + self.close = close + super.init(nibName: nil, bundle: nil) + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + override func loadView() { + let stack = NSStackView() + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 2 + stack.edgeInsets = NSEdgeInsets(top: 8, left: 8, bottom: 8, right: 8) + if let heading { + let label = NSTextField(labelWithString: heading) + label.font = .systemFont(ofSize: 12) + label.textColor = .secondaryLabelColor + let container = NSView() + container.addSubview(label) + label.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + label.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 8), + label.centerYAnchor.constraint(equalTo: container.centerYAnchor), + container.heightAnchor.constraint(equalToConstant: 28), + ]) + stack.addArrangedSubview(container) + } + for option in options { + let row = ComposerOptionBackground(selected: option.selected) + let title = NSTextField(labelWithString: option.title) + title.font = .systemFont(ofSize: 14) + title.lineBreakMode = .byTruncatingTail + title.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + let titleRow = NSStackView(views: [title]) + titleRow.spacing = 6 + if let symbol = option.symbol, + let image = NSImage(systemSymbolName: symbol, accessibilityDescription: nil) + { + let icon = NSImageView(image: image) + icon.contentTintColor = .secondaryLabelColor + icon.widthAnchor.constraint(equalToConstant: 18).isActive = true + icon.heightAnchor.constraint(equalToConstant: 18).isActive = true + titleRow.insertArrangedSubview(icon, at: 0) + } + if let badge = option.badge { + let tag = NSTextField(labelWithString: badge) + tag.font = .systemFont(ofSize: 10) + tag.textColor = .secondaryLabelColor + titleRow.addArrangedSubview(tag) + } + titleRow.addArrangedSubview(NSView()) + if option.selected { + let check = NSImageView( + image: NSImage(systemSymbolName: "checkmark", accessibilityDescription: "Selected")!) + check.contentTintColor = .secondaryLabelColor + titleRow.addArrangedSubview(check) + } + let contents = NSStackView(views: [titleRow]) + contents.orientation = .vertical + contents.alignment = .leading + contents.spacing = 5 + if let description = option.description { + let detail = NSTextField(wrappingLabelWithString: description) + detail.font = .systemFont(ofSize: 12) + detail.textColor = .secondaryLabelColor + detail.preferredMaxLayoutWidth = width - 34 + detail.setContentCompressionResistancePriority(.required, for: .vertical) + contents.addArrangedSubview(detail) + detail.widthAnchor.constraint(equalTo: contents.widthAnchor).isActive = true + } + row.addSubview(contents) + contents.translatesAutoresizingMaskIntoConstraints = false + let button = ActionButton("") { [close] in + close() + option.action() + } + button.isBordered = false + button.setAccessibilityLabel(option.title) + button.toolTip = option.description + row.addSubview(button) + button.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + contents.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 9), + contents.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -9), + contents.topAnchor.constraint(equalTo: row.topAnchor, constant: 8), + contents.bottomAnchor.constraint(equalTo: row.bottomAnchor, constant: -8), + titleRow.widthAnchor.constraint(equalTo: contents.widthAnchor), + row.heightAnchor.constraint(greaterThanOrEqualToConstant: 34), + button.leadingAnchor.constraint(equalTo: row.leadingAnchor), + button.trailingAnchor.constraint(equalTo: row.trailingAnchor), + button.topAnchor.constraint(equalTo: row.topAnchor), + button.bottomAnchor.constraint(equalTo: row.bottomAnchor), + ]) + stack.addArrangedSubview(row) + row.widthAnchor.constraint(equalTo: stack.widthAnchor, constant: -16).isActive = true + } + // Measure once at the menu's fixed width; callers reuse the bounded result. + let measurement = stack.widthAnchor.constraint(equalToConstant: width) + measurement.isActive = true + let height = ceil(stack.fittingSize.height) + measurement.isActive = false + measuredContentSize = NSSize(width: width, height: min(420, max(34, height))) + view = SurfaceView(raised: true) + let scroll = NSScrollView() + scroll.drawsBackground = false + scroll.hasVerticalScroller = true + scroll.autohidesScrollers = true + scroll.scrollerStyle = .overlay + let document = ComposerOptionsDocument(frame: NSRect(x: 0, y: 0, width: width, height: height)) + document.translatesAutoresizingMaskIntoConstraints = false + scroll.documentView = document + document.addSubview(stack) + stack.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(scroll) + scroll.translatesAutoresizingMaskIntoConstraints = false + let preferredHeight = view.heightAnchor.constraint(equalToConstant: measuredContentSize.height) + preferredHeight.priority = .defaultHigh + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: document.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: document.trailingAnchor), + stack.topAnchor.constraint(equalTo: document.topAnchor), + stack.bottomAnchor.constraint(equalTo: document.bottomAnchor), + document.widthAnchor.constraint(equalTo: scroll.contentView.widthAnchor), + document.heightAnchor.constraint(equalToConstant: height), + scroll.leadingAnchor.constraint(equalTo: view.leadingAnchor), + scroll.trailingAnchor.constraint(equalTo: view.trailingAnchor), + scroll.topAnchor.constraint(equalTo: view.topAnchor), + scroll.bottomAnchor.constraint(equalTo: view.bottomAnchor), + view.widthAnchor.constraint(equalToConstant: width), preferredHeight, + ]) + } +} diff --git a/apps/macos/Sources/T3Native/ComposerCompletion.swift b/apps/macos/Sources/T3Native/ComposerCompletion.swift new file mode 100644 index 0000000000000..43f58643afab9 --- /dev/null +++ b/apps/macos/Sources/T3Native/ComposerCompletion.swift @@ -0,0 +1,435 @@ +import AppKit +import T3Core + +@MainActor +final class ComposerCompletion: NSObject, NSTableViewDataSource, NSTableViewDelegate, + NSPopoverDelegate +{ + var onInsert: ((NSRange, String) -> Void)? + /// Invoked after the command token was removed. The parent opens the picker or changes mode. + var onLocalCommand: ((String) -> Void)? + weak var anchorView: NSView? + /// Optional caret rectangle, in anchorView coordinates. NSTextView anchors compute it automatically. + var anchorRect: NSRect? + /// Set only for an older server known to lack workspace-scoped refreshProviders. + var useGlobalProviderCatalog = false { didSet { cache = nil } } + var fileReferenceFormat: ComposerMention.FileFormat = .markdownLink + var skillUsesLiteralPath = false + /// Older servers may expose bounded folder listings but not indexed path search. + var useDirectoryListing = false + + private let store: ClientStore + private var state = ComposerCompletionState() + private var text = "" + private var cursor = 0 + private var displayedTicket: ComposerCompletionState.Ticket? + private var displayedEligibility: LocalEligibility? + private var items: [ComposerSuggestion] = [] + private var debounce: Task? + private var request: Task? + private var discoveryRetry: Task? + private var discoveryScope: ComposerCompletionScope? + private var discoveryAttempts = 0 + private var cache: (scope: ComposerCompletionScope, provider: JSON, loaded: Date)? + private let popover = NSPopover() + private let table = CompletionTable() + private let scroll = NSScrollView() + private let status = NSTextField(labelWithString: "") + private var programmaticClose = false + + private struct LocalEligibility: Equatable { + let enabled: Bool + let planModeEnabled: Bool + let interactionModeSupported: Bool + } + private var localEligibility: LocalEligibility { + LocalEligibility( + enabled: onLocalCommand != nil && onInsert != nil, + planModeEnabled: store.catalog.planModeEnabled, + interactionModeSupported: store.catalog.provider(for: store.selectedThread?.model ?? .null)? + .showInteractionMode == true) + } + private func slashItems( + _ providerItems: [ComposerSuggestion], for ticket: ComposerCompletionState.Ticket + ) -> [ComposerSuggestion] { + let eligibility = localEligibility + return ComposerSuggestion.slashItems( + providerItems: providerItems, mention: ticket.mention, + localCommandsEnabled: eligibility.enabled, planModeEnabled: eligibility.planModeEnabled, + interactionModeSupported: eligibility.interactionModeSupported) + } + + init(store: ClientStore) { + self.store = store + super.init() + let controller = NSViewController() + controller.view = NSView(frame: NSRect(x: 0, y: 0, width: 420, height: 250)) + let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("completion")) + table.addTableColumn(column) + table.headerView = nil + table.rowHeight = 46 + table.intercellSpacing = .zero + table.style = .plain + table.columnAutoresizingStyle = .lastColumnOnlyAutoresizingStyle + table.autoresizingMask = [.width] + table.dataSource = self + table.delegate = self + table.target = self + table.action = #selector(selectClickedItem) + table.setAccessibilityLabel("Composer suggestions") + scroll.documentView = table + scroll.hasVerticalScroller = true + scroll.autohidesScrollers = true + scroll.drawsBackground = false + status.font = .systemFont(ofSize: 12) + status.textColor = .secondaryLabelColor + status.maximumNumberOfLines = 2 + status.lineBreakMode = .byWordWrapping + controller.view.addSubview(scroll) + controller.view.addSubview(status) + popover.contentViewController = controller + popover.behavior = .semitransient + popover.animates = false + popover.delegate = self + } + + /// Call on edits, selection changes, and store changes. Selection ranges/cursors use UTF-16. + func refresh(text: String, cursor: Int) { + self.text = text + self.cursor = cursor + let old = state.ticket + let editor = anchorView as? NSTextView + let composing = editor?.hasMarkedText() == true || (editor?.selectedRange().length ?? 0) > 0 + state.refresh(text: text, cursor: cursor, scope: composing ? nil : currentScope()) + guard let ticket = state.ticket else { + debounce?.cancel() + discoveryRetry?.cancel() + displayedTicket = nil + items = [] + hide() + return + } + if old == ticket, displayedTicket == ticket, displayedEligibility == localEligibility { + present() + return + } + items = slashItems([], for: ticket) + displayedTicket = items.isEmpty ? nil : ticket + displayedEligibility = localEligibility + table.reloadData() + if !items.isEmpty { table.selectRowIndexes(IndexSet(integer: 0), byExtendingSelection: false) } + status.stringValue = + ticket.mention.kind == .file + ? "Searching files and folders…" : "Loading provider commands and skills…" + present() + schedule(ticket) + } + + func dismiss() { + state.dismiss() + debounce?.cancel() + discoveryRetry?.cancel() + items = [] + displayedTicket = nil + hide() + } + + /// Return true before the composer's own Return/Tab/send handling when this consumes the key. + func handleKeyDown(_ event: NSEvent) -> Bool { + guard popover.isShown, (anchorView as? NSTextView)?.hasMarkedText() != true, + event.modifierFlags.intersection([.command, .control, .option]).isEmpty + else { return false } + if event.keyCode == 53 { + dismiss() + return true + } + guard !items.isEmpty, let displayedTicket, state.accepts(displayedTicket), + currentScope() == displayedTicket.scope + else { return false } + switch event.keyCode { + case 125, 126: + let delta = event.keyCode == 125 ? 1 : -1 + let next = (max(0, table.selectedRow) + delta + items.count) % items.count + table.selectRowIndexes(IndexSet(integer: next), byExtendingSelection: false) + table.scrollRowToVisible(next) + return true + case 36, 76, 48: + guard onInsert != nil else { return false } + insertSelected() + return true + default: return false + } + } + + private func currentScope() -> ComposerCompletionScope? { + guard store.ready, store.catalogReady, store.canEditThread, + let location = store.workspaceLocation, let draft = store.composerDraftID, + let provider = store.catalog.provider(for: store.selectedThread?.model ?? .null), + provider.available + else { return nil } + return ComposerCompletionScope( + environment: store.environmentKey, session: store.usageSessionID, + thread: location.threadID, draft: draft, provider: provider.id, root: location.root) + } + + private func schedule(_ ticket: ComposerCompletionState.Ticket) { + debounce?.cancel() + debounce = Task { [weak self] in + do { try await Task.sleep(for: .milliseconds(160)) } catch { return } + guard let self, state.accepts(ticket), currentScope() == ticket.scope, request == nil else { + return + } + startRequest(ticket) + } + } + + private func startRequest(_ ticket: ComposerCompletionState.Ticket) { + // One RPC sequence at a time: rapidly typing cannot accumulate server-side searches. + request = Task { [weak self] in + guard let self else { return } + defer { + request = nil + if let next = state.ticket, next != ticket { schedule(next) } + } + guard state.accepts(ticket), currentScope() == ticket.scope else { return } + do { + let results: [ComposerSuggestion] + var discoveryPending = false + if ticket.mention.kind == .file { + guard let location = store.workspaceLocation else { return } + let result: JSON + if useDirectoryListing { + let query = ticket.mention.query + let directory = query.lastIndex(of: "/").map { String(query[..<$0]) } ?? "" + result = try await store.workspaceCall( + "projects.listEntries", at: location, + fields: ["directoryPath": .string(directory)]) + results = ComposerSuggestion.files(result, limit: 200).filter { + query.isEmpty || $0.name.localizedStandardContains(query) + }.prefix(40).map { $0 } + } else { + result = try await store.workspaceCall( + "projects.searchEntries", at: location, + fields: ["query": .string(ticket.mention.query), "limit": .number(40)]) + results = ComposerSuggestion.files(result) + } + } else if ticket.mention.kind == .command, ticket.mention.range.location > 0 { + results = slashItems([], for: ticket) + } else { + let provider = try await providerSnapshot(for: ticket.scope) + guard state.accepts(ticket), currentScope() == ticket.scope else { return } + discoveryPending = + provider["workspaceSnapshots"].array.first { + $0["cwd"].string == ticket.scope.root + }?["slashCommandsPending"].bool == true + let onlyCommand = (text as NSString).replacingCharacters( + in: ticket.mention.range, with: "" + ) + .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + let allowCompact = + onlyCommand && !store.isNewConversation && !store.composerBusy + && store.execution.activeRunID == nil && store.selectedAttachments.isEmpty + && store.queuedEdit == nil && store.questionRequest == nil + let providerItems = ComposerSuggestion.providerItems( + provider, cwd: ticket.scope.root, + kind: ticket.mention.kind, query: ticket.mention.query, allowCompact: allowCompact) + results = + ticket.mention.kind == .command ? slashItems(providerItems, for: ticket) : providerItems + } + guard state.accepts(ticket), currentScope() == ticket.scope else { return } + items = results + displayedTicket = ticket + displayedEligibility = localEligibility + status.stringValue = + results.isEmpty + ? (ticket.mention.kind == .file + ? "No matching files or folders." : "No matching commands or skills for this provider.") + : "" + if ticket.mention.kind == .command, discoveryPending { + if results.isEmpty { status.stringValue = "Discovering provider commands…" } + retryDiscovery(ticket) + } + table.reloadData() + if !items.isEmpty { + table.selectRowIndexes(IndexSet(integer: 0), byExtendingSelection: false) + } + present() + } catch { + guard state.accepts(ticket), currentScope() == ticket.scope else { return } + status.stringValue = error.localizedDescription + items = slashItems([], for: ticket) + displayedTicket = ticket + displayedEligibility = localEligibility + table.reloadData() + if !items.isEmpty { + table.selectRowIndexes(IndexSet(integer: 0), byExtendingSelection: false) + } + present() + } + } + } + + private func providerSnapshot(for scope: ComposerCompletionScope) async throws -> JSON { + if discoveryScope != scope { + discoveryScope = scope + discoveryAttempts = 0 + } + if let cache, cache.scope == scope { + let pending = + cache.provider["workspaceSnapshots"].array.first { + $0["cwd"].string == scope.root + }?["slashCommandsPending"].bool == true + if Date().timeIntervalSince(cache.loaded) < (pending ? 1 : 20) { return cache.provider } + } + let config = try await store.usageCall("server.getConfig", session: scope.session) + guard currentScope() == scope else { throw CancellationError() } + guard + var provider = config["providers"].array.first(where: { + $0["instanceId"].string == scope.provider + }) + else { + return .null + } + let workspace = provider["workspaceSnapshots"].array.first { $0["cwd"].string == scope.root } + if !useGlobalProviderCatalog, + workspace == nil || workspace?["slashCommandsPending"].bool == true + { + let refreshed = try await store.usageCall( + "server.refreshProviders", + payload: .object(["instanceId": .string(scope.provider), "cwd": .string(scope.root)]), + session: scope.session) + guard currentScope() == scope else { throw CancellationError() } + provider = + refreshed["providers"].array.first { $0["instanceId"].string == scope.provider } ?? .null + } + guard currentScope() == scope else { throw CancellationError() } + cache = (scope, provider, Date()) + return provider + } + + private func retryDiscovery(_ ticket: ComposerCompletionState.Ticket) { + guard discoveryAttempts < 2 else { return } + discoveryAttempts += 1 + discoveryRetry?.cancel() + discoveryRetry = Task { [weak self] in + do { try await Task.sleep(for: .milliseconds(1250)) } catch { return } + guard let self, state.accepts(ticket), currentScope() == ticket.scope, request == nil else { + return + } + startRequest(ticket) + } + } + + private func present() { + guard let anchorView, anchorView.window != nil, state.ticket != nil else { return } + let height = items.isEmpty ? 52 : CGFloat(min(6, items.count)) * table.rowHeight + 12 + let width = min(500, max(300, anchorView.bounds.width)) + popover.contentSize = NSSize(width: width, height: height) + scroll.frame = NSRect(x: 6, y: 6, width: width - 12, height: height - 12) + table.setFrameSize( + NSSize(width: width - 12, height: max(height - 12, CGFloat(items.count) * table.rowHeight))) + scroll.isHidden = items.isEmpty + status.frame = NSRect(x: 14, y: 12, width: width - 28, height: 28) + status.isHidden = !items.isEmpty + let rect: NSRect + if let anchorRect { + rect = anchorRect + } else if let editor = anchorView as? NSTextView, let window = editor.window { + let screenRect = editor.firstRect( + forCharacterRange: NSRange(location: cursor, length: 0), actualRange: nil) + rect = editor.convert(window.convertFromScreen(screenRect), from: nil) + } else { + rect = anchorView.bounds + } + if popover.isShown { + popover.positioningRect = rect + } else { + let responder = anchorView.window?.firstResponder + popover.show(relativeTo: rect, of: anchorView, preferredEdge: .maxY) + if let responder { anchorView.window?.makeFirstResponder(responder) } + } + } + + private func hide() { + programmaticClose = true + popover.performClose(nil) + programmaticClose = false + } + func popoverDidClose(_ notification: Notification) { + if !programmaticClose { + state.dismiss() + debounce?.cancel() + discoveryRetry?.cancel() + } + } + + @objc private func selectClickedItem() { insertSelected() } + private func insertSelected() { + guard let ticket = displayedTicket, state.accepts(ticket), currentScope() == ticket.scope, + items.indices.contains(table.selectedRow), let onInsert + else { return } + if let editor = anchorView as? NSTextView, + editor.string != text || editor.selectedRange() != NSRange(location: cursor, length: 0) + { + dismiss() + return + } + let item = items[table.selectedRow] + let localAction = onLocalCommand + if item.localCommand != nil, + !slashItems([], for: ticket).contains(where: { $0.id == item.id }) + { + return + } + let insertion = item.selectionEdit( + mention: ticket.mention, text: text, + root: ticket.scope.root, fileFormat: fileReferenceFormat, + skillUsesLiteralPath: skillUsesLiteralPath) + let expectedText = (text as NSString).replacingCharacters( + in: insertion.range, with: insertion.replacement) + dismiss() + onInsert(insertion.range, insertion.replacement) + if let command = item.localCommand { + // Parent validation may reject the edit; do not perform an action on an unremoved token. + if let editor = anchorView as? NSTextView, editor.string != expectedText { return } + guard currentScope() == ticket.scope else { return } + localAction?(command) + } + } + + func numberOfRows(in tableView: NSTableView) -> Int { items.count } + func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? + { + guard items.indices.contains(row) else { return nil } + let item = items[row] + let cell = NSStackView() + cell.spacing = 10 + cell.edgeInsets = NSEdgeInsets(top: 5, left: 10, bottom: 5, right: 10) + let image = NSImageView( + image: NSImage( + systemSymbolName: item.kind == .file + ? (item.isDirectory ? "folder" : "doc") : item.kind == .skill ? "sparkles" : "terminal", + accessibilityDescription: nil) ?? NSImage()) + image.contentTintColor = .secondaryLabelColor + image.widthAnchor.constraint(equalToConstant: 18).isActive = true + let title = NSTextField(labelWithString: item.label) + title.font = .systemFont(ofSize: 13, weight: .medium) + title.lineBreakMode = .byTruncatingTail + let detail = NSTextField(labelWithString: item.detail) + detail.font = .systemFont(ofSize: 11) + detail.textColor = .secondaryLabelColor + detail.lineBreakMode = .byTruncatingMiddle + let labels = NSStackView(views: [title, detail]) + labels.orientation = .vertical + labels.alignment = .leading + labels.spacing = 2 + cell.addArrangedSubview(image) + cell.addArrangedSubview(labels) + cell.setAccessibilityLabel(item.label + ", " + item.detail) + return cell + } +} + +private final class CompletionTable: NSTableView { + override var acceptsFirstResponder: Bool { false } +} diff --git a/apps/macos/Sources/T3Native/ComposerControls.swift b/apps/macos/Sources/T3Native/ComposerControls.swift new file mode 100644 index 0000000000000..40751eb6b2d96 --- /dev/null +++ b/apps/macos/Sources/T3Native/ComposerControls.swift @@ -0,0 +1,426 @@ +import AppKit +import T3Core + +@MainActor final class ActionButton: NSButton { + var invoke: () -> Void + init(_ title: String, action: @escaping () -> Void) { + invoke = action + super.init(frame: .zero) + self.title = title + bezelStyle = .rounded + target = self + self.action = #selector(performAction) + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + @objc private func performAction() { + guard isEnabled else { return } + invoke() + } +} + +@MainActor final class ActionMenuItem: NSMenuItem { + let invoke: () -> Void + init(_ title: String, action: @escaping () -> Void) { + invoke = action + super.init(title: title, action: #selector(performAction), keyEquivalent: "") + target = self + } + required init(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + @objc private func performAction() { + guard isEnabled else { return } + invoke() + } +} + +@MainActor final class ActionTextField: NSTextField, NSTextFieldDelegate { + var changed: ((String) -> Void)? + override init(frame: NSRect) { + super.init(frame: frame) + delegate = self + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + func controlTextDidChange(_ obj: Notification) { changed?(stringValue) } +} + +@MainActor final class ComposerControls: NSStackView, NSPopoverDelegate { + private let store: ClientStore + private var fingerprint = "" + private var previousCatalog = ProviderCatalog() + private var popover: NSPopover? + private weak var previousResponder: NSResponder? + private struct Presentation { + let pill: ComposerPill + let full: String + let compact: String + let primary: Bool + } + private var presentations: [Presentation] = [] + private var separators: [NSView] = [] + private var overflow: ComposerPill? + private var fullWidth: CGFloat = 0 + private var compactWidth: CGFloat = 0 + private var layoutMode = -1 + var minimumCompactWidth: CGFloat { + let count = presentations.filter(\.primary).count + (overflow == nil ? 0 : 1) + return CGFloat(count) * 36 + CGFloat(max(0, count - 1)) * 6 + } + init(store: ClientStore) { + self.store = store + super.init(frame: .zero) + orientation = .horizontal + alignment = .centerY + spacing = 9 + setHuggingPriority(.defaultLow, for: .horizontal) + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func refresh() { + let selected = store.selectedThread + let enabled = + store.canEditThread && store.catalogReady && (!store.isNewConversation || !store.composerBusy) + let fingerprint = + "\(store.environmentKey)|\(store.projectSessionID)|\(selected?.id ?? "")|\(selected?.model ?? .null)|\(selected?.runtimeMode ?? "")|\(selected?.interactionMode ?? "")|\(enabled)" + guard fingerprint != self.fingerprint || store.catalog != previousCatalog else { + (popover?.contentViewController as? ModelPicker)?.refresh() + return + } + popover?.performClose(nil) + self.fingerprint = fingerprint + previousCatalog = store.catalog + for view in arrangedSubviews { + removeArrangedSubview(view) + view.removeFromSuperview() + } + presentations = [] + separators = [] + overflow = nil + layoutMode = -1 + guard let selected else { return } + let provider = store.catalog.provider(for: selected.model) + let model = ComposerPill( + store.catalog.model(for: selected.model)?.name ?? selected.model["model"].string + ?? "Choose model" + ) { [weak self] in self?.showModels() } + model.image = provider.flatMap { ProviderArtwork.accountImage($0) } + model.contentTintColor = .secondaryLabelColor + model.setAccessibilityLabel("Model") + model.toolTip = [ + provider?.name, + store.catalog.model(for: selected.model)?.name ?? selected.model["model"].string, + ].compactMap { $0 }.joined(separator: " — ") + model.isEnabled = enabled + model.widthAnchor.constraint(lessThanOrEqualToConstant: 240).isActive = true + addArrangedSubview(model) + presentations.append( + Presentation( + pill: model, full: model.title.replacingOccurrences(of: " ⌄", with: ""), + compact: store.catalog.model(for: selected.model)?.shortName ?? "Model", primary: true)) + var hasReasoning = false + for descriptor in store.catalog.descriptors(for: selected.model) { + guard let id = descriptor["id"].string else { continue } + let stored = selected.model["options"].array.first { $0["id"].string == id }?["value"] + let value = stored ?? descriptor["currentValue"] + let label = descriptor["label"].string ?? id + let control: ComposerPill + if descriptor["type"].string == "boolean" { + control = ComposerPill(label, symbol: value.bool ? "checkmark.circle" : "circle") { + [weak store] in + store?.setModelOption(id, value: .bool(!value.bool)) + } + } else { + let options = descriptor["options"].array + let current = options.first { $0["id"] == value } ?? options.first { $0["isDefault"].bool } + control = ComposerPill( + current?["label"].string ?? label, + symbol: id.lowercased().contains("reason") || id == "effort" + ? "brain" : "slider.horizontal.3", action: {}) + control.invoke = { [weak self, weak control, weak store] in + guard let self, let control else { return } + showOptions( + label, + options: options.map { option in + ComposerOption( + title: option["label"].string ?? option["id"].string ?? "", + description: option["description"].string, + selected: option["id"] == current?["id"], + badge: option["isDefault"].bool ? "Default" : nil, + action: { [weak store] in store?.setModelOption(id, value: option["id"]) }) + }, width: 210, anchor: control) + } + } + addSeparator() + control.isEnabled = enabled + control.setAccessibilityLabel(label) + control.toolTip = + label + ": " + control.title.replacingOccurrences(of: " ⌄", with: "") + + (descriptor["description"].string.map { " — " + $0 } ?? "") + addArrangedSubview(control) + let reasoning = + !hasReasoning + && (id.lowercased().contains("reason") || id == "effort" + || id.lowercased().contains("thinking")) + hasReasoning = hasReasoning || reasoning + presentations.append( + Presentation( + pill: control, full: control.title.replacingOccurrences(of: " ⌄", with: ""), + compact: control.title.replacingOccurrences(of: " ⌄", with: ""), primary: reasoning)) + } + addSeparator() + let runtime = ComposerPill( + Self.modeLabel(selected.runtimeMode), + symbol: selected.runtimeMode == "full-access" ? "lock.open" : "lock", action: {}) + runtime.setAccessibilityLabel("Permissions") + runtime.toolTip = Self.modeLabel(selected.runtimeMode) + runtime.isEnabled = enabled + runtime.invoke = { [weak self, weak runtime, weak store] in + guard let self, let runtime else { return } + let modes = provider?.runtimeModes ?? [] + showOptions( + nil, + options: (modes.isEmpty ? ["approval-required", "full-access"] : modes).map { mode in + ComposerOption( + title: Self.modeLabel(mode), description: Self.modeDescription(mode), + symbol: Self.modeSymbol(mode), selected: mode == selected.runtimeMode, + action: { [weak store] in store?.setRuntimeMode(mode) }) + }, width: 420, anchor: runtime) + } + addArrangedSubview(runtime) + let compactRuntime = + selected.runtimeMode == "full-access" + ? "Access" + : selected.runtimeMode == "approval-required" + ? "Ask" : selected.runtimeMode == "auto-accept-edits" ? "Edits" : "Auto" + presentations.append( + Presentation( + pill: runtime, full: Self.modeLabel(selected.runtimeMode), compact: compactRuntime, + primary: true)) + if store.catalog.planModeEnabled, provider?.showInteractionMode == true { + let plan = ComposerPill( + selected.interactionMode == "plan" ? "Plan" : "Build", symbol: "list.bullet.clipboard" + ) { [weak store] in + store?.setInteractionMode(selected.interactionMode == "plan" ? "default" : "plan") + } + plan.isEnabled = enabled + plan.setAccessibilityLabel("Interaction mode") + plan.toolTip = selected.interactionMode == "plan" ? "Plan" : "Build" + addArrangedSubview(plan) + presentations.append( + Presentation( + pill: plan, full: selected.interactionMode == "plan" ? "Plan" : "Build", compact: "", + primary: false)) + } + if presentations.contains(where: { !$0.primary }) { + let more = ComposerPill("", symbol: "ellipsis") { [weak self] in self?.showOverflow() } + more.setAccessibilityLabel("More conversation options") + more.toolTip = "More conversation options" + more.isEnabled = enabled + addArrangedSubview(more) + overflow = more + } + fullWidth = + presentations.reduce(0) { + $0 + + min( + $1.pill === model ? 240 : CGFloat.greatestFiniteMagnitude, + max(36, $1.pill.intrinsicContentSize.width)) + } + + CGFloat(separators.count) + CGFloat(max(0, presentations.count + separators.count - 1)) * 9 + compactWidth = + presentations.filter(\.primary).reduce(0) { result, item in + item.pill.displayLabel(item.compact) + return result + max(36, item.pill.intrinsicContentSize.width) + } + (overflow == nil ? 0 : 36) + + CGFloat(max(0, presentations.filter(\.primary).count + (overflow == nil ? 0 : 1) - 1)) * 6 + adaptLayout() + needsLayout = true + } + + override func viewDidMoveToSuperview() { + super.viewDidMoveToSuperview() + for name in [NSView.frameDidChangeNotification, NSView.boundsDidChangeNotification] { + NotificationCenter.default.removeObserver(self, name: name, object: nil) + } + guard let row = superview as? NSStackView else { return } + row.postsFrameChangedNotifications = true + row.postsBoundsChangedNotifications = true + for name in [NSView.frameDidChangeNotification, NSView.boundsDidChangeNotification] { + NotificationCenter.default.addObserver( + self, selector: #selector(rowGeometryChanged), name: name, object: row) + } + needsLayout = true + } + + @objc private func rowGeometryChanged(_ notification: Notification) { + // The spacer can absorb a resize without changing our frame. + needsLayout = true + } + + override func layout() { + adaptLayout() + super.layout() + } + + private func adaptLayout() { + guard !presentations.isEmpty else { return } + var available = bounds.width + if let row = superview as? NSStackView, row.orientation == .horizontal, + row.arrangedSubviews.contains(self) + { + // Use the row's budget: our intrinsic width shrinks when labels hide, while its spacer grows. + let siblings = row.arrangedSubviews.filter { $0 !== self && !$0.isHidden } + available = + row.bounds.width - row.edgeInsets.left - row.edgeInsets.right + - siblings.reduce(0) { $0 + max(0, $1.fittingSize.width) } + - CGFloat(siblings.count) * row.spacing + } + let mode = available <= 0 || available >= fullWidth ? 0 : available >= compactWidth ? 1 : 2 + guard mode != layoutMode else { return } + layoutMode = mode + spacing = mode == 0 ? 9 : 6 + for item in presentations { + item.pill.isHidden = mode != 0 && !item.primary + item.pill.displayLabel(mode == 0 ? item.full : mode == 1 ? item.compact : "") + // Some providers have no artwork. An icon-only model control still needs a visible affordance. + if item.pill === presentations.first?.pill, item.pill.image == nil { + item.pill.image = NSImage(systemSymbolName: "cpu", accessibilityDescription: nil) + } + } + for separator in separators { separator.isHidden = mode != 0 } + overflow?.isHidden = mode == 0 + } + + func overflowMenu() -> NSMenu { + let menu = NSMenu() + menu.autoenablesItems = false + guard let selected = store.selectedThread else { return menu } + let enabled = + store.canEditThread && store.catalogReady && (!store.isNewConversation || !store.composerBusy) + for descriptor in store.catalog.descriptors(for: selected.model) { + guard let id = descriptor["id"].string else { continue } + let label = descriptor["label"].string ?? id + let value = + selected.model["options"].array.first { $0["id"].string == id }?["value"] + ?? descriptor["currentValue"] + if descriptor["type"].string == "boolean" { + let item = ActionMenuItem(label) { [weak store] in + store?.setModelOption(id, value: .bool(!value.bool)) + } + item.state = value.bool ? .on : .off + item.isEnabled = enabled + menu.addItem(item) + } else { + let group = NSMenuItem(title: label, action: nil, keyEquivalent: "") + let options = NSMenu() + options.autoenablesItems = false + for option in descriptor["options"].array { + let item = ActionMenuItem(option["label"].string ?? option["id"].string ?? "") { + [weak store] in store?.setModelOption(id, value: option["id"]) + } + item.state = option["id"] == value ? .on : .off + item.toolTip = option["description"].string + item.isEnabled = enabled + options.addItem(item) + } + group.submenu = options + group.isEnabled = enabled + menu.addItem(group) + } + } + if store.catalog.planModeEnabled, + store.catalog.provider(for: selected.model)?.showInteractionMode == true + { + let item = ActionMenuItem("Plan mode") { [weak store] in + store?.setInteractionMode(selected.interactionMode == "plan" ? "default" : "plan") + } + item.state = selected.interactionMode == "plan" ? .on : .off + item.isEnabled = enabled + menu.addItem(item) + } + return menu + } + + private func showOverflow() { + guard store.canEditThread, store.catalogReady, let overflow else { return } + overflowMenu().popUp(positioning: nil, at: NSPoint(x: 0, y: overflow.bounds.maxY), in: overflow) + } + + private func addSeparator() { + let line = NSBox() + line.boxType = .separator + line.widthAnchor.constraint(equalToConstant: 1).isActive = true + line.heightAnchor.constraint(equalToConstant: 16).isActive = true + addArrangedSubview(line) + separators.append(line) + } + + private func showOptions( + _ heading: String?, options: [ComposerOption], width: CGFloat, anchor: NSView + ) { + popover?.performClose(nil) + previousResponder = window?.firstResponder + let next = NSPopover() + next.behavior = .transient + next.delegate = self + let controller = ComposerOptionsPopover(heading, options: options, width: width) { + [weak self] in + self?.popover?.performClose(nil) + } + next.contentViewController = controller + next.contentSize = controller.contentSize + popover = next + next.show(relativeTo: anchor.bounds, of: anchor, preferredEdge: .minY) + } + + func showModels() { + if popover?.isShown == true { + popover?.performClose(nil) + return + } + guard store.canEditThread, store.catalogReady, let anchor = arrangedSubviews.first else { + return + } + previousResponder = window?.firstResponder + let next = NSPopover() + next.behavior = .transient + next.delegate = self + next.contentViewController = ModelPicker(store: store) { [weak self] in + self?.popover?.performClose(nil) + } + next.contentSize = NSSize(width: 440, height: 340) + popover = next + next.show(relativeTo: anchor.bounds, of: anchor, preferredEdge: .minY) + } + + func popoverDidClose(_ notification: Notification) { + popover = nil + fingerprint = "" + refresh() + if let previousResponder { window?.makeFirstResponder(previousResponder) } + } + static func modeLabel(_ mode: String) -> String { + switch mode { + case "approval-required": "Supervised" + case "auto-accept-edits": "Auto-accept edits" + case "auto": "Auto" + case "full-access": "Full access" + default: mode + } + } + static func modeDescription(_ mode: String) -> String? { + switch mode { + case "approval-required": "Ask before commands and file changes." + case "auto-accept-edits": "Auto-approve edits, ask before other actions." + case "auto": "Supported providers approve routine actions; others still ask." + case "full-access": "Allow commands and edits without prompts." + default: nil + } + } + static func modeSymbol(_ mode: String) -> String { + switch mode { + case "auto-accept-edits": "pencil" + case "auto": "sparkles" + case "full-access": "lock.open" + default: "lock" + } + } +} diff --git a/apps/macos/Sources/T3Native/ConnectCallback.swift b/apps/macos/Sources/T3Native/ConnectCallback.swift new file mode 100644 index 0000000000000..4c547f8246b16 --- /dev/null +++ b/apps/macos/Sources/T3Native/ConnectCallback.swift @@ -0,0 +1,124 @@ +import Combine +import Foundation +import Network +import T3Core + +/// The existing CLI protocol uses a loopback callback, not a custom URL containing access tokens. +@MainActor final class ConnectCallback { + private var listener: NWListener? + private var generation = UUID() + private var pending: CheckedContinuation? + private var timeout: Task? + + func authorize(_ authorization: ConnectAuthorization, open: @escaping @MainActor (URL) -> Bool) + async throws -> URL + { + cancel() + let current = UUID() + generation = current + let parameters = NWParameters.tcp + parameters.requiredLocalEndpoint = .hostPort( + host: .ipv4(.loopback), + port: NWEndpoint.Port(rawValue: UInt16(authorization.configuration.callbackPort))!) + parameters.allowLocalEndpointReuse = false + let listener = try NWListener(using: parameters) + self.listener = listener + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + pending = continuation + listener.stateUpdateHandler = { [weak self] state in + Task { @MainActor in + guard let self, self.generation == current else { return } + switch state { + case .ready: + if !open(authorization.url) { + self.finish(.failure(T3Error("Could not open the T3 Connect sign-in page."))) + } + case .failed: + self.finish( + .failure( + T3Error( + "Cannot listen on T3 Connect's loopback callback port 34338. Close another pending T3 sign-in and try again." + ))) + default: break + } + } + } + listener.newConnectionHandler = { [weak self] connection in + Task { @MainActor in + guard let self, self.generation == current else { + connection.cancel() + return + } + self.receive(connection, authorization: authorization, generation: current) + } + } + timeout = Task { [weak self] in + do { try await Task.sleep(for: .seconds(300)) } catch { return } + guard let self, self.generation == current else { return } + self.finish(.failure(T3Error("T3 Connect sign-in timed out. Try again."))) + } + listener.start(queue: .main) + } + } onCancel: { + Task { @MainActor [weak self] in if self?.generation == current { self?.cancel() } } + } + } + private func receive( + _ connection: NWConnection, authorization: ConnectAuthorization, generation current: UUID + ) { + connection.start(queue: .main) + read(connection, authorization: authorization, generation: current, bytes: Data()) + } + private func read( + _ connection: NWConnection, authorization: ConnectAuthorization, generation current: UUID, + bytes: Data + ) { + connection.receive(minimumIncompleteLength: 1, maximumLength: 4096) { + [weak self] data, _, complete, error in + Task { @MainActor in + guard let self, self.generation == current else { + connection.cancel() + return + } + let bytes = bytes + (data ?? Data()) + guard bytes.count <= 8192, error == nil else { + connection.cancel() + return + } + if bytes.range(of: Data("\r\n\r\n".utf8)) == nil, !complete { + self.read(connection, authorization: authorization, generation: current, bytes: bytes) + return + } + let line = + String(decoding: bytes, as: UTF8.self).components(separatedBy: "\r\n").first ?? "" + let fields = line.split(separator: " ") + let callback = + fields.count == 3 && fields[0] == "GET" && fields[1].hasPrefix("/callback?") + ? URL(string: String(fields[1]), relativeTo: authorization.configuration.redirectURI)? + .absoluteURL : nil + let valid = callback.flatMap { try? authorization.code(from: $0) } != nil + let body = + valid + ? "T3 Connect authorization received. Return to T3 Native." + : "Invalid T3 Connect callback." + let response = + "HTTP/1.1 \(valid ? "200 OK" : "400 Bad Request")\r\nContent-Type: text/plain; charset=utf-8\r\nCache-Control: no-store\r\nConnection: close\r\nContent-Length: \(body.utf8.count)\r\n\r\n\(body)" + connection.send( + content: Data(response.utf8), completion: .contentProcessed { _ in connection.cancel() }) + if valid, let callback { self.finish(.success(callback)) } + } + } + } + private func finish(_ result: Result) { + let continuation = pending + pending = nil + listener?.cancel() + listener = nil + timeout?.cancel() + timeout = nil + generation = UUID() + continuation?.resume(with: result) + } + func cancel() { finish(.failure(CancellationError())) } +} diff --git a/apps/macos/Sources/T3Native/ConnectionRepository.swift b/apps/macos/Sources/T3Native/ConnectionRepository.swift new file mode 100644 index 0000000000000..494e4bf5b3d40 --- /dev/null +++ b/apps/macos/Sources/T3Native/ConnectionRepository.swift @@ -0,0 +1,260 @@ +import Foundation +import T3Core + +@MainActor final class ConnectionRepository { + private let preferences: UserDefaults + private let credentials: Credentials.Access + private let key = "native.savedConnections.v1" + private(set) var values: SavedConnections + + init( + preferences: UserDefaults = .standard, credentials: Credentials.Access = .keychain, + validating: @escaping @MainActor () throws -> Void = {} + ) async throws { + self.preferences = preferences + self.credentials = credentials + try Task.checkCancellation() + try validating() + if let data = preferences.data(forKey: key) { + values = try SavedConnections.decode(data) + } else { + values = SavedConnections() + } + // Keep a legacy selection even when Keychain access is missing. Never replace a + // saved remote server with an implicitly-created local environment on launch. + if values.environments.isEmpty, let address = preferences.string(forKey: "endpoint") { + let endpoint = try ConnectionEndpoint.normalize(address) + let initial = values + let old = try await Credentials.loadLegacy(address: address, access: credentials) + try validating() + if try adoptLegacyMigration(endpoint) { return } + try requireSelection(initial, legacyAddress: address) + if let old { + try await Credentials.saveSession(old, access: credentials) + try validating() + if try adoptLegacyMigration(endpoint) { return } + try requireSelection(initial, legacyAddress: address) + } + // A different repository may have created the catalog while Security was waiting. + if values.environments.isEmpty { + values.upsert( + try SavedEnvironment(label: endpoint.host ?? "Saved server", endpoint: endpoint), + selecting: true) + } + let canonical = endpoint.absoluteString + if var selections = preferences.dictionary(forKey: "selectedThreadByEndpoint"), + selections[canonical] == nil, let previous = selections[address] + { + selections[canonical] = previous + preferences.set(selections, forKey: "selectedThreadByEndpoint") + } + try persist() + } + } + // Startup and Connections can migrate together. Adopt a completed migration only + // while the legacy endpoint and current selection still describe the same intent. + private func adoptLegacyMigration(_ endpoint: URL) throws -> Bool { + try Task.checkCancellation() + let latest = try readCatalog() + guard latest.selectedID == endpoint.absoluteString, + latest.environments.contains(where: { $0.id == latest.selectedID && $0.endpoint == endpoint } + ), + let address = preferences.string(forKey: "endpoint"), + (try? ConnectionEndpoint.normalize(address)) == endpoint + else { return false } + values = latest + return true + } + func settings(for environment: SavedEnvironment) async throws -> ConnectionSettings { + let initial = try readCatalog() + let address = preferences.string(forKey: "endpoint") + let settings = try await Credentials.loadSession( + endpoint: environment.endpoint, access: credentials) + try requireSelection(initial, legacyAddress: address) + guard values.environments.contains(environment) else { throw CancellationError() } + guard let settings else { + throw T3Error( + "This environment has no saved access token. Pair it again.", authentication: true) + } + return settings + } + /// Main calls this asynchronously unless --connection-file explicitly overrides the saved selection. + /// First launch creates an isolated native host; later launches honor the saved environment. + func connectionForLaunch( + host: BackendHost = .shared, validating: @escaping @MainActor () throws -> Void = {} + ) async throws -> ConnectionSettings? { + try Task.checkCancellation() + try validating() + values = try readCatalog() + if let environment = values.environments.first(where: { $0.id == values.selectedID }) { + if environment.local { + guard environment.endpoint == host.configuration.endpoint else { + throw T3Error("The saved local endpoint differs from the configured native server port.") + } + let session = try await host.start() + try validating() + try requireLaunchSelection(environment) + _ = try await remember(session, label: environment.label, local: true) { + try validating() + try self.requireLaunchSelection(environment) + } + return session + } + if let id = environment.cloudEnvironmentID, + let configuration = try? ConnectConfiguration.bundled( + info: Bundle.main.infoDictionary ?? [:]) + { + let loaded = try await Credentials.loadAccount( + configuration: configuration, access: credentials) + try validating() + try requireLaunchSelection(environment) + if let account = loaded { + let refreshed = try await account.refreshed(configuration: configuration) + try validating() + try requireLaunchSelection(environment) + try await Credentials.saveAccount( + refreshed, configuration: configuration, access: credentials) + try validating() + try requireLaunchSelection(environment) + let client = ConnectClient(configuration: configuration, account: refreshed) + let catalog = try await client.environments() + try validating() + try requireLaunchSelection(environment) + guard let target = catalog.first(where: { $0.id == id }) else { + throw T3Error( + "The saved environment is no longer registered with your T3 Connect account.") + } + let session = try await client.pair(target) + try validating() + try requireLaunchSelection(environment) + let saved = try await remember(session, label: environment.label, cloudEnvironmentID: id) + { + try validating() + try self.requireLaunchSelection(environment) + } + try Task.checkCancellation() + try validating() + try select(saved) + return session + } + } + let session = try await settings(for: environment) + try validating() + try requireLaunchSelection(environment) + return session + } + guard values.environments.isEmpty else { return nil } + let session = try await host.start() + try validating() + try requireLaunchSelection(nil) + let environment = try await remember(session, label: "This Mac", local: true) { + try validating() + try self.requireLaunchSelection(nil) + } + try Task.checkCancellation() + try validating() + try select(environment) + return session + } + /// An async launch may finish after another repository or process selected an environment. + /// Merge unrelated catalog edits, but never persist a result for a stale selection. + func requireLaunchSelection(_ expected: SavedEnvironment?) throws { + try Task.checkCancellation() + let latest = try readCatalog() + if let expected { + guard latest.selectedID == expected.id, + latest.environments.first(where: { $0.id == expected.id }) == expected + else { + throw CancellationError() + } + if let legacy = preferences.string(forKey: "endpoint"), + (try? ConnectionEndpoint.normalize(legacy)) != expected.endpoint + { + throw CancellationError() + } + } else { + guard latest.environments.isEmpty, latest.selectedID == nil, + preferences.string(forKey: "endpoint") == nil + else { throw CancellationError() } + } + values = latest + } + private func readCatalog() throws -> SavedConnections { + if let data = preferences.data(forKey: key) { return try SavedConnections.decode(data) } + return SavedConnections() + } + func selectedSettings() async throws -> ConnectionSettings? { + values = try readCatalog() + guard let selected = values.environments.first(where: { $0.id == values.selectedID }) else { + return nil + } + let settings = try await settings(for: selected) + try requireLaunchSelection(selected) + return settings + } + func remember( + _ settings: ConnectionSettings, label: String, local: Bool = false, + cloudEnvironmentID: String? = nil, + validating: @escaping @MainActor () throws -> Void = {} + ) async throws -> SavedEnvironment { + let environment = try SavedEnvironment( + label: label, endpoint: settings.endpoint, local: local, + cloudEnvironmentID: cloudEnvironmentID) + try Task.checkCancellation() + try validating() + let initial = try readCatalog() + let address = preferences.string(forKey: "endpoint") + let previous = initial.environments.first { $0.id == environment.id } + try await Credentials.saveSession(settings, access: credentials) + try validating() + try requireSelection(initial, legacyAddress: address) + guard values.environments.first(where: { $0.id == environment.id }) == previous else { + throw CancellationError() + } + values.upsert(environment) + try persist() + return environment + } + func select(_ environment: SavedEnvironment) throws { + try Task.checkCancellation() + values = try readCatalog() + guard values.environments.contains(where: { $0.id == environment.id }) else { + throw T3Error("The environment was removed.") + } + values.selectedID = environment.id + try persist() + preferences.set(environment.endpoint.absoluteString, forKey: "endpoint") + } + func remove( + _ environment: SavedEnvironment, validating: @escaping @MainActor () throws -> Void = {} + ) async throws { + try Task.checkCancellation() + try validating() + let initial = try readCatalog() + let address = preferences.string(forKey: "endpoint") + try await Credentials.removeSession(endpoint: environment.endpoint, access: credentials) + try validating() + try requireSelection(initial, legacyAddress: address) + guard values.environments.first(where: { $0.id == environment.id }) == environment else { + throw CancellationError() + } + values.remove(environment.id) + try persist() + if let old = preferences.string(forKey: "endpoint"), + (try? ConnectionEndpoint.normalize(old)) == environment.endpoint + { + preferences.removeObject(forKey: "endpoint") + } + } + private func requireSelection(_ initial: SavedConnections, legacyAddress: String?) throws { + try Task.checkCancellation() + let latest = try readCatalog() + guard latest.selectedID == initial.selectedID, + latest.environments.first(where: { $0.id == latest.selectedID }) + == initial.environments.first(where: { $0.id == initial.selectedID }), + preferences.string(forKey: "endpoint") == legacyAddress + else { throw CancellationError() } + values = latest + } + private func persist() throws { preferences.set(try JSONEncoder().encode(values), forKey: key) } +} diff --git a/apps/macos/Sources/T3Native/ConnectionWelcomeView.swift b/apps/macos/Sources/T3Native/ConnectionWelcomeView.swift new file mode 100644 index 0000000000000..6d1d401d46166 --- /dev/null +++ b/apps/macos/Sources/T3Native/ConnectionWelcomeView.swift @@ -0,0 +1,69 @@ +import AppKit + +@MainActor final class ConnectionWelcomeView: NSStackView { + var onPrimary: (() -> Void)? + var onSecondary: (() -> Void)? + private let title = NSTextField(labelWithString: "") + private let detail = NSTextField(wrappingLabelWithString: "") + private lazy var primary = ActionButton("") { [weak self] in self?.onPrimary?() } + private lazy var secondary = ActionButton("") { [weak self] in self?.onSecondary?() } + + init() { + super.init(frame: .zero) + orientation = .vertical + alignment = .centerX + spacing = 16 + setAccessibilityIdentifier("connection-welcome") + let icon = NSImageView( + image: NSImage( + systemSymbolName: "bubble.left.and.bubble.right", accessibilityDescription: nil)!) + icon.symbolConfiguration = .init(pointSize: 30, weight: .light) + icon.contentTintColor = .tertiaryLabelColor + title.font = .systemFont(ofSize: 22, weight: .semibold) + title.alignment = .center + detail.font = .systemFont(ofSize: 13) + detail.textColor = .secondaryLabelColor + detail.alignment = .center + primary.controlSize = .large + primary.keyEquivalent = "" + secondary.bezelStyle = .inline + secondary.isBordered = false + secondary.font = .systemFont(ofSize: 12) + secondary.contentTintColor = .secondaryLabelColor + let actions = NSStackView(views: [primary, secondary]) + actions.orientation = .vertical + actions.spacing = 10 + for view in [icon, title, detail, actions] { addArrangedSubview(view) } + detail.widthAnchor.constraint(equalTo: widthAnchor).isActive = true + setCustomSpacing(22, after: icon) + setCustomSpacing(24, after: detail) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func refresh(_ store: ClientStore) { + if store.ready { + title.stringValue = "Start a conversation" + detail.stringValue = "Choose a project or start a new thread to get to work." + primary.title = "New thread" + primary.isEnabled = store.canCreateThread + secondary.title = "Open project…" + secondary.isHidden = false + secondary.isEnabled = store.catalogReady + } else { + title.stringValue = store.hasConnection ? "Connecting to T3" : "Connect to T3" + detail.stringValue = + store.hasConnection + ? "Your conversations will appear when the server is connected." + : "Connect to your T3 server to pick up your conversations, projects, and models." + if let error = store.error { + title.stringValue = "Connect to T3" + detail.stringValue = error + } + primary.title = store.hasConnection ? "Try again" : "Connect to server…" + primary.isEnabled = true + secondary.title = "Choose another server…" + secondary.isHidden = !store.hasConnection + } + } +} diff --git a/apps/macos/Sources/T3Native/Credentials.swift b/apps/macos/Sources/T3Native/Credentials.swift new file mode 100644 index 0000000000000..46bfee24e3ab9 --- /dev/null +++ b/apps/macos/Sources/T3Native/Credentials.swift @@ -0,0 +1,202 @@ +import Foundation +import Security +import T3Core + +enum Credentials { + private static let legacyService = "app.t3.native-prototype" + private static let sessionService = "app.t3.native-prototype.environment.v1" + private static let accountService = "app.t3.native-prototype.connect.v1" + + private static let queue = DispatchQueue(label: "app.t3.native.credentials") + + enum Operation: Sendable { + case read(service: String, account: String) + case write(Data, service: String, account: String) + case remove(service: String, account: String) + case removeSession(account: String) + } + + // One boundary for tests to hold a Security operation without touching a real vault. + struct Access: Sendable { + let execute: @Sendable (Operation) throws -> Data? + static let keychain = Access(execute: Credentials.securityOperation) + + func perform(_ operation: Operation) async throws -> Data? { + try Task.checkCancellation() + let cancellation = Cancellation() + let result: Data? = try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + queue.async { + do { + try cancellation.check() + continuation.resume(returning: try execute(operation)) + } catch { continuation.resume(throwing: error) } + } + } + } onCancel: { + cancellation.cancel() + } + // Security cannot cancel an operation that has already entered SecurityAgent. + // Discard its result and prevent subsequent writes/catalog commits instead. + try Task.checkCancellation() + return result + } + } + + private final class Cancellation: @unchecked Sendable { + private let lock = NSLock() + private var cancelled = false + func cancel() { lock.withLock { cancelled = true } } + func check() throws { + if lock.withLock({ cancelled }) { throw CancellationError() } + } + } + + @MainActor static func save( + _ settings: ConnectionSettings, validating: @escaping @MainActor () throws -> Void = {} + ) async throws { + let repository = try await ConnectionRepository(validating: validating) + try validating() + let id = try ConnectionEndpoint.credentialAccount(settings.endpoint) + let existing = repository.values.environments.first { $0.id == id } + let environment = try await repository.remember( + settings, label: existing?.label ?? settings.endpoint.host ?? "Server", + local: existing?.local ?? false, cloudEnvironmentID: existing?.cloudEnvironmentID, + validating: validating) + try Task.checkCancellation() + try validating() + try repository.select(environment) + } + @MainActor static func load() async throws -> ConnectionSettings? { + try await ConnectionRepository().selectedSettings() + } + static func saveSession(_ settings: ConnectionSettings, access: Access = .keychain) async throws { + let endpoint = try ConnectionEndpoint.normalize(settings.endpoint.absoluteString) + let normalized = try ConnectionSettings( + endpoint: endpoint, token: settings.token, proofKey: settings.proofKey) + _ = try await access.perform( + .write( + try JSONEncoder().encode(normalized), service: sessionService, + account: endpoint.absoluteString)) + } + static func loadSession(endpoint: URL, access: Access = .keychain) async throws + -> ConnectionSettings? + { + let account = try ConnectionEndpoint.credentialAccount(endpoint) + guard let data = try await access.perform(.read(service: sessionService, account: account)) + else { return nil } + let value = try JSONDecoder().decode(ConnectionSettings.self, from: data) + guard try ConnectionEndpoint.credentialAccount(value.endpoint) == account else { + throw T3Error("The saved credential belongs to another server.") + } + return try ConnectionSettings( + endpoint: URL(string: account)!, token: value.token, proofKey: value.proofKey) + } + static func removeSession(endpoint: URL, access: Access = .keychain) async throws { + let account = try ConnectionEndpoint.credentialAccount(endpoint) + _ = try await access.perform(.removeSession(account: account)) + } + static func loadLegacy(address: String?, access: Access = .keychain) async throws + -> ConnectionSettings? + { + guard let account = address, let endpoint = URL(string: account), + let data = try await access.perform(.read(service: legacyService, account: account)) + else { return nil } + guard let token = String(data: data, encoding: .utf8) else { + throw T3Error("The legacy token is unreadable.") + } + return try ConnectionSettings(endpoint: endpoint, token: token) + } + static func saveAccount( + _ account: ConnectAccountToken, configuration: ConnectConfiguration, access: Access = .keychain + ) + async throws + { + _ = try await access.perform( + .write( + try JSONEncoder().encode(account), service: accountService, + account: configuration.credentialAccount)) + } + static func loadAccount(configuration: ConnectConfiguration, access: Access = .keychain) + async throws -> ConnectAccountToken? + { + guard + let data = try await access.perform( + .read(service: accountService, account: configuration.credentialAccount)) + else { return nil } + return try JSONDecoder().decode(ConnectAccountToken.self, from: data) + } + static func removeAccount(configuration: ConnectConfiguration, access: Access = .keychain) + async throws + { + _ = try await access.perform( + .remove(service: accountService, account: configuration.credentialAccount)) + } + + private static func securityOperation(_ operation: Operation) throws -> Data? { + switch operation { + case .read(let service, let account): return try read(service: service, account: account) + case .write(let data, let service, let account): + try write(data, service: service, account: account) + case .remove(let service, let account): try remove(service: service, account: account) + case .removeSession(let account): + try remove(service: sessionService, account: account) + // Legacy accounts included a trailing slash/default port. Remove all equivalent legacy entries. + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: legacyService, kSecReturnAttributes as String: true, + kSecMatchLimit as String: kSecMatchLimitAll, + ] + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw failure("inspect", status) + } + for item in result as? [[String: Any]] ?? [] { + if let name = item[kSecAttrAccount as String] as? String, + (try? ConnectionEndpoint.normalize(name).absoluteString) == account + { + try remove(service: legacyService, account: name) + } + } + } + return nil + } + private static func query(service: String, account: String) -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + } + private static func write(_ data: Data, service: String, account: String) throws { + let query = query(service: service, account: account) + var status = SecItemUpdate( + query as CFDictionary, [kSecValueData as String: data] as CFDictionary) + if status == errSecItemNotFound { + var item = query + item[kSecValueData as String] = data + item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + status = SecItemAdd(item as CFDictionary, nil) + } + guard status == errSecSuccess else { throw failure("save", status) } + } + private static func read(service: String, account: String) throws -> Data? { + var query = query(service: service, account: account) + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { return nil } + guard status == errSecSuccess, let data = result as? Data else { throw failure("read", status) } + return data + } + private static func remove(service: String, account: String) throws { + let status = SecItemDelete(query(service: service, account: account) as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw failure("remove", status) + } + } + private static func failure(_ action: String, _ status: OSStatus) -> T3Error { + T3Error("Could not \(action) the credential in Keychain (\(status)).") + } +} diff --git a/apps/macos/Sources/T3Native/DraftWorkspaceControls.swift b/apps/macos/Sources/T3Native/DraftWorkspaceControls.swift new file mode 100644 index 0000000000000..d7c413b9bf019 --- /dev/null +++ b/apps/macos/Sources/T3Native/DraftWorkspaceControls.swift @@ -0,0 +1,196 @@ +import AppKit +import T3Core + +struct DraftBranchFetchState { + struct Key: Equatable { + let environment: String + let session: UUID + let draftID: String + let root: String + let ready: Bool + let retry: Int + } + private(set) var key: Key? + private(set) var loading = false + private(set) var failure: String? + mutating func select(_ next: Key?) -> Bool { + guard key != next else { return false } + key = next + failure = nil + loading = next?.ready == true && next?.root.isEmpty == false + return true + } + func accepts(_ request: Key, current: Key?) -> Bool { + loading && key == request && current == request && request.ready + } + @discardableResult mutating func finish(_ request: Key, current: Key?, error: String? = nil) + -> Bool + { + guard accepts(request, current: current) else { return false } + loading = false + failure = error + return true + } +} + +@MainActor final class DraftWorkspaceControls: NSStackView { + private let store: ClientStore + private let mode = ComposerPill("Current checkout", symbol: "folder", action: {}) + private let branch = ComposerPill("Select branch", symbol: "arrow.triangle.branch", action: {}) + private var popover: NSPopover? + private var fetch = DraftBranchFetchState() + private var retryGeneration = 0 + private let retry = ComposerPill("", symbol: "arrow.clockwise", action: {}) + private var refs: [JSON] = [] + private var load: Task? + init(store: ClientStore) { + self.store = store + super.init(frame: .zero) + orientation = .horizontal + spacing = 8 + edgeInsets = NSEdgeInsets(top: 3, left: 14, bottom: 3, right: 14) + addArrangedSubview(mode) + addArrangedSubview(NSView()) + addArrangedSubview(branch) + addArrangedSubview(retry) + retry.setAccessibilityLabel("Retry loading branches") + retry.isHidden = true + retry.invoke = { [weak self] in + guard let self, store.ready, fetch.failure != nil else { return } + retryGeneration += 1 + refresh() + } + mode.invoke = { [weak self] in self?.chooseWorkspace() } + branch.invoke = { [weak self] in self?.chooseBranch() } + branch.widthAnchor.constraint(lessThanOrEqualToConstant: 280).isActive = true + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + private var currentKey: DraftBranchFetchState.Key? { + guard let draft = store.selectedDraft else { return nil } + return .init( + environment: store.environmentKey, session: store.projectSessionID, + draftID: draft.id, root: draft.project["workspaceRoot"].string ?? "", + ready: store.ready && store.catalogReady, retry: retryGeneration) + } + func refresh() { + let key = currentKey + if fetch.select(key) { + load?.cancel() + load = nil + refs = [] + popover?.performClose(nil) + if let key, fetch.loading { loadBranches(key) } + } + isHidden = store.selectedDraft == nil + guard let draft = store.selectedDraft else { return } + mode.displayLabel(draft.workspaceMode == "worktree" ? "New worktree" : "Current checkout") + mode.isEnabled = store.canEditThread && !store.composerBusy + branch.isEnabled = mode.isEnabled && !refs.isEmpty && key?.ready == true + let branchLabel = + fetch.loading + ? "Loading branches…" + : fetch.failure != nil + ? "Branches unavailable" : key?.ready != true ? "Connect to load branches" : "Select branch" + branch.displayLabel(draft.branch ?? branchLabel) + branch.toolTip = fetch.failure ?? draft.branch + retry.isHidden = fetch.failure == nil + retry.isEnabled = mode.isEnabled && key?.ready == true + retry.toolTip = fetch.failure.map { "Retry loading branches: " + $0 } + } + private func loadBranches(_ key: DraftBranchFetchState.Key) { + load = Task { [weak self] in + guard let self else { return } + do { + var fetched: [JSON] = [] + var cursor: JSON = .null + repeat { + var payload: [String: JSON] = ["cwd": .string(key.root), "limit": .number(100)] + if cursor != .null { payload["cursor"] = cursor } + if key.retry > 0 { payload["refresh"] = .bool(true) } + let result = try await store.usageCall( + "vcs.listRefs", payload: .object(payload), session: key.session) + try Task.checkCancellation() + guard fetch.accepts(key, current: currentKey) else { return } + fetched += result["refs"].array + cursor = result["nextCursor"] + } while cursor != .null && fetched.count < 500 + guard fetch.finish(key, current: currentKey) else { return } + refs = fetched + load = nil + if store.selectedDraft?.id == key.draftID, store.selectedDraft?.branch == nil, + let current = refs.first(where: { $0["current"].bool })?["name"].string + { + store.updateConversationDraft { $0.branch = current } + } + refresh() + } catch { + guard !Task.isCancelled, + fetch.finish(key, current: currentKey, error: error.localizedDescription) + else { return } + load = nil + refresh() + } + } + } + private func chooseWorkspace() { + guard mode.isEnabled, let draft = store.selectedDraft else { return } + show( + "Workspace", + options: [ + ComposerOption( + title: "Current checkout", symbol: "folder", selected: draft.workspaceMode != "worktree" + ) { [weak store] in store?.updateConversationDraft { $0.workspaceMode = "local" } }, + ComposerOption( + title: "New worktree", symbol: "arrow.triangle.branch", + selected: draft.workspaceMode == "worktree" + ) { [weak store] in store?.updateConversationDraft { $0.workspaceMode = "worktree" } }, + ], width: 240, anchor: mode) + } + private func chooseBranch() { + guard branch.isEnabled, let draft = store.selectedDraft else { return } + show( + "Base branch", + options: refs.compactMap { ref in + guard let name = ref["name"].string else { return nil } + return ComposerOption( + title: name, selected: draft.branch == name, badge: ref["current"].bool ? "Current" : nil + ) { [weak store] in store?.updateConversationDraft { $0.branch = name } } + }, width: 320, anchor: branch) + } + private func show(_ heading: String, options: [ComposerOption], width: CGFloat, anchor: NSView) { + guard !options.isEmpty else { return } + popover?.performClose(nil) + let next = NSPopover() + next.behavior = .transient + let controller = ComposerOptionsPopover(heading, options: options, width: width) { + [weak self] in self?.popover?.performClose(nil) + } + next.contentViewController = controller + next.contentSize = controller.contentSize + popover = next + next.show(relativeTo: anchor.bounds, of: anchor, preferredEdge: .maxY) + } +} + +@MainActor final class RoundSendButton: NSButton { + override func draw(_ dirtyRect: NSRect) { + guard imagePosition != .noImage else { + super.draw(dirtyRect) + return + } + let rect = NSRect( + x: (bounds.width - 32) / 2, y: (bounds.height - 32) / 2, width: 32, height: 32) + (isEnabled + ? NSColor(srgbRed: 0.2, green: 0.36, blue: 0.78, alpha: 1) + : NSColor.labelColor.withAlphaComponent(0.06)).setFill() + NSBezierPath(ovalIn: rect).fill() + guard let image else { return } + let tinted = + image.withSymbolConfiguration( + NSImage.SymbolConfiguration(pointSize: 17, weight: .semibold).applying( + .init(paletteColors: [.white]))) ?? image + tinted.draw( + in: rect.insetBy(dx: 7, dy: 7), from: .zero, operation: .sourceOver, + fraction: isEnabled ? 1 : 0.45, respectFlipped: true, hints: nil) + } +} diff --git a/apps/macos/Sources/T3Native/ExistingProjectPicker.swift b/apps/macos/Sources/T3Native/ExistingProjectPicker.swift new file mode 100644 index 0000000000000..28844eae2bfc3 --- /dev/null +++ b/apps/macos/Sources/T3Native/ExistingProjectPicker.swift @@ -0,0 +1,204 @@ +import AppKit +import T3Core + +@MainActor +final class ExistingProjectPicker: NSViewController, NSTableViewDataSource, + NSTableViewDelegate, NSSearchFieldDelegate +{ + private let search = NSSearchField() + private let table = NSTableView() + private let empty = NSTextField(labelWithString: "No matching projects") + private var projects: [JSON] = [] + private var matches: [JSON] = [] + private var selectedID: String? + private let choose: (JSON) -> Void + private let openFolder: () -> Void + private let close: () -> Void + + init( + projects: [JSON], selectedID: String?, choose: @escaping (JSON) -> Void, + openFolder: @escaping () -> Void, close: @escaping () -> Void + ) { + self.choose = choose + self.openFolder = openFolder + self.close = close + super.init(nibName: nil, bundle: nil) + update(projects: projects, selectedID: selectedID) + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override func loadView() { + view = SurfaceView(raised: true) + search.placeholderString = "Search projects…" + search.font = .systemFont(ofSize: 14) + search.isBezeled = true + search.drawsBackground = false + search.focusRingType = .none + search.sendsSearchStringImmediately = true + search.sendsWholeSearchString = true + search.delegate = self + search.setAccessibilityLabel("Search existing projects") + table.headerView = nil + table.addTableColumn(NSTableColumn(identifier: .init("project"))) + table.columnAutoresizingStyle = .uniformColumnAutoresizingStyle + table.rowHeight = 52 + table.intercellSpacing = .zero + table.backgroundColor = .clear + table.dataSource = self + table.delegate = self + table.target = self + table.action = #selector(chooseClicked) + table.setAccessibilityLabel("Existing projects") + let scroll = NSScrollView() + scroll.documentView = table + scroll.hasVerticalScroller = true + scroll.autohidesScrollers = true + scroll.drawsBackground = false + empty.font = .systemFont(ofSize: 13) + empty.textColor = .secondaryLabelColor + empty.alignment = .center + let results = NSView() + for child in [scroll, empty] { results.addSubview(child) } + scroll.translatesAutoresizingMaskIntoConstraints = false + empty.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + scroll.leadingAnchor.constraint(equalTo: results.leadingAnchor), + scroll.trailingAnchor.constraint(equalTo: results.trailingAnchor), + scroll.topAnchor.constraint(equalTo: results.topAnchor), + scroll.bottomAnchor.constraint(equalTo: results.bottomAnchor), + empty.centerXAnchor.constraint(equalTo: results.centerXAnchor), + empty.centerYAnchor.constraint(equalTo: results.centerYAnchor), + ]) + let open = ComposerPill("", symbol: "folder.badge.plus") { [weak self] in + guard let self else { return } + close() + openFolder() + } + open.title = "Open another folder…" + open.setAccessibilityLabel("Open another folder") + let stack = NSStackView(views: [search, results, open]) + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 8 + view.addSubview(stack) + stack.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 10), + stack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -10), + stack.topAnchor.constraint(equalTo: view.topAnchor, constant: 10), + stack.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -10), + search.heightAnchor.constraint(equalToConstant: 32), + ]) + for child in [search, results, open] { + child.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true + } + preferredContentSize = NSSize(width: 400, height: 340) + reload() + } + + override func viewDidAppear() { + super.viewDidAppear() + view.window?.makeFirstResponder(search) + } + + func update(projects: [JSON], selectedID: String?) { + let sorted = projects.filter { + $0["id"].string != nil && $0["workspaceRoot"].string?.isEmpty == false + }.sorted { + let left = $0["title"].string ?? "Project" + let right = $1["title"].string ?? "Project" + if left != right { return left.localizedStandardCompare(right) == .orderedAscending } + return ($0["workspaceRoot"].string ?? "") < ($1["workspaceRoot"].string ?? "") + } + guard sorted != self.projects || selectedID != self.selectedID else { return } + self.projects = sorted + self.selectedID = selectedID + if isViewLoaded { reload() } + } + + private func reload() { + let tokens = search.stringValue.split(whereSeparator: \.isWhitespace).map(String.init) + matches = projects.filter { project in + let text = (project["title"].string ?? "") + " " + (project["workspaceRoot"].string ?? "") + return tokens.allSatisfy { text.localizedStandardContains($0) } + } + table.reloadData() + empty.isHidden = !matches.isEmpty + if !matches.isEmpty { + let index = matches.firstIndex { $0["id"].string == selectedID } ?? 0 + table.selectRowIndexes(IndexSet(integer: index), byExtendingSelection: false) + table.scrollRowToVisible(index) + } + } + + func controlTextDidChange(_ obj: Notification) { reload() } + func control(_ control: NSControl, textView: NSTextView, doCommandBy selector: Selector) -> Bool { + switch selector { + case #selector(NSResponder.moveDown(_:)), #selector(NSResponder.moveUp(_:)): + guard !matches.isEmpty else { return true } + let step = selector == #selector(NSResponder.moveDown(_:)) ? 1 : -1 + let index = min(matches.count - 1, max(0, table.selectedRow + step)) + table.selectRowIndexes(IndexSet(integer: index), byExtendingSelection: false) + table.scrollRowToVisible(index) + return true + case #selector(NSResponder.insertNewline(_:)): + select(table.selectedRow) + return true + case #selector(NSResponder.cancelOperation(_:)): + close() + return true + default: return false + } + } + + @objc private func chooseClicked() { select(table.clickedRow) } + private func select(_ row: Int) { + guard matches.indices.contains(row) else { return } + let project = matches[row] + close() + choose(project) + } + func numberOfRows(in tableView: NSTableView) -> Int { matches.count } + func tableView(_ tableView: NSTableView, rowViewForRow row: Int) -> NSTableRowView? { + SidebarTableRow() + } + func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? + { + let project = matches[row] + let title = project["title"].string ?? "Project" + let root = project["workspaceRoot"].string ?? "" + let name = NSTextField(labelWithString: title) + name.font = .systemFont(ofSize: 14) + name.lineBreakMode = .byTruncatingTail + let path = NSTextField(labelWithString: root) + path.font = .systemFont(ofSize: 11) + path.textColor = .secondaryLabelColor + path.lineBreakMode = .byTruncatingMiddle + let labels = NSStackView(views: [name, path]) + labels.orientation = .vertical + labels.alignment = .leading + labels.spacing = 3 + let icon = NSImageView( + image: NSImage(systemSymbolName: "folder", accessibilityDescription: nil)!) + icon.contentTintColor = .secondaryLabelColor + let check = NSImageView( + image: NSImage(systemSymbolName: "checkmark", accessibilityDescription: nil)!) + check.isHidden = project["id"].string != selectedID + let content = NSStackView(views: [icon, labels, check]) + content.spacing = 10 + let cell = NSTableCellView() + cell.addSubview(content) + cell.setAccessibilityLabel(title + ", " + root) + content.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + content.leadingAnchor.constraint(equalTo: cell.leadingAnchor, constant: 9), + content.trailingAnchor.constraint(equalTo: cell.trailingAnchor, constant: -9), + content.centerYAnchor.constraint(equalTo: cell.centerYAnchor), + name.widthAnchor.constraint(equalTo: labels.widthAnchor), + path.widthAnchor.constraint(equalTo: labels.widthAnchor), + icon.widthAnchor.constraint(equalToConstant: 18), + check.widthAnchor.constraint(equalToConstant: 14), + ]) + return cell + } +} diff --git a/apps/macos/Sources/T3Native/FileContextMenu.swift b/apps/macos/Sources/T3Native/FileContextMenu.swift new file mode 100644 index 0000000000000..4260ec820bbdb --- /dev/null +++ b/apps/macos/Sources/T3Native/FileContextMenu.swift @@ -0,0 +1,68 @@ +import AppKit +import T3Core + +@MainActor enum FileContextMenu { + static func make( + path: String, position: FileSourcePosition? = nil, + at location: WorkspaceLocation, store: ClientStore + ) -> NSMenu { + let menu = NSMenu() + menu.autoenablesItems = false + let capabilities = store.fileOpenCapabilities + func item(_ title: String, editor: String, reveal: Bool = false) -> NSMenuItem { + ActionMenuItem(title) { [weak store] in + store?.openFileExternally( + path, position: position, editor: editor, reveal: reveal, at: location) + } + } + if capabilities.canOpenDefault { menu.addItem(item("Open", editor: "file-manager")) } + if let title = capabilities.revealTitle { + menu.addItem(item(title, editor: "file-manager", reveal: true)) + } + let editors = capabilities.editors.filter { $0 != "file-manager" } + if !editors.isEmpty { + let parent = NSMenuItem(title: "Open with", action: nil, keyEquivalent: "") + let submenu = NSMenu() + submenu.autoenablesItems = false + for editor in editors { + submenu.addItem(item(FileOpenCapabilities.label(editor), editor: editor)) + } + parent.submenu = submenu + menu.addItem(parent) + } + if !menu.items.isEmpty { menu.addItem(.separator()) } + for (title, text) in [ + ("Copy path", path), + ("Copy absolute path", FileOpenCapabilities.absolutePath(path, root: location.root)), + ] { + menu.addItem( + ActionMenuItem(title) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + }) + } + return menu + } + + static func forLink(_ url: URL, at location: WorkspaceLocation?, store: ClientStore) -> NSMenu? { + guard let location, store.workspaceLocation == location, + let link = WorkspaceFileLink(url: url, root: location.root) + else { return nil } + return make(path: link.path, position: link.position, at: location, store: store) + } + + static func append(_ source: NSMenu, to menu: NSMenu) { + for item in source.items { + source.removeItem(item) + menu.addItem(item) + } + } +} + +@MainActor final class WorkspaceOutlineView: NSOutlineView { + var menuAtRow: ((Int) -> NSMenu?)? + override func menu(for event: NSEvent) -> NSMenu? { + let row = row(at: convert(event.locationInWindow, from: nil)) + return row >= 0 ? menuAtRow?(row) : nil + } +} diff --git a/apps/macos/Sources/T3Native/Main.swift b/apps/macos/Sources/T3Native/Main.swift new file mode 100644 index 0000000000000..a520dd2d2e852 --- /dev/null +++ b/apps/macos/Sources/T3Native/Main.swift @@ -0,0 +1,313 @@ +import AppKit +import T3Core + +@MainActor final class AppDelegate: NSObject, NSApplicationDelegate { + private let store = ClientStore() + private var controller: ChatWindow? + private var finishingTermination = false + + func applicationDidFinishLaunching(_ notification: Notification) { + let controller = ChatWindow(store: store) + self.controller = controller + controller.showWindow(nil) + NSApp.activate(ignoringOtherApps: true) + let launchSession = store.usageSessionID + Task { + let validate: @MainActor () throws -> Void = { + try Task.checkCancellation() + guard self.store.usageSessionID == launchSession else { throw CancellationError() } + } + do { + if let path = Arguments.value("--connection-file") { + let settings = try ConnectionSettings.load(URL(fileURLWithPath: path)) + if CommandLine.arguments.contains("--remember-connection") { + try await Credentials.save(settings, validating: validate) + } + try validate() + store.connect(settings, selecting: Arguments.value("--thread-id")) + } else { + let repository = try await ConnectionRepository(validating: validate) + if let settings = try await repository.connectionForLaunch(validating: validate) { + try validate() + store.connect(settings, selecting: Arguments.value("--thread-id")) + } + } + } catch { + guard store.usageSessionID == launchSession, !(error is CancellationError) else { return } + store.error = error.localizedDescription + controller.refresh() + } + } + } + + func applicationWillTerminate(_ notification: Notification) { store.disconnect() } + + @objc func showAbout() { + let bundle = Bundle.main + let revision = bundle.object(forInfoDictionaryKey: "T3SourceRevision") as? String ?? "unknown" + let modified = bundle.object(forInfoDictionaryKey: "T3SourceModified") as? Bool ?? false + let built = bundle.object(forInfoDictionaryKey: "T3BuildDate") as? String ?? "unknown" + NSApp.orderFrontStandardAboutPanel(options: [ + .version: String(revision.prefix(12)) + (modified ? " + local changes" : ""), + .credits: NSAttributedString(string: "Source: \(revision)\nBuilt: \(built)"), + ]) + } + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + guard !finishingTermination else { return .terminateLater } + finishingTermination = true + Task { + let filesSaved = await controller?.finishWorkspaceSaving() ?? true + let messagesSaved = store.saveDraftsBeforeExit() + var shouldQuit = messagesSaved && filesSaved + if !shouldQuit { + let alert = NSAlert() + alert.messageText = "Drafts could not be saved" + alert.informativeText = + controller?.workspaceStorageError ?? store.storageError + ?? "Keep the app open to preserve your unsent drafts." + alert.addButton(withTitle: "Keep open") + alert.addButton(withTitle: "Quit without saving") + shouldQuit = alert.runModal() == .alertSecondButtonReturn + } + if shouldQuit { shouldQuit = await controller?.finishBrowserDownloads() ?? true } + if shouldQuit { controller?.stopBrowserHelper() } + if shouldQuit, BackendHost.shared.isRunning { + do { try await BackendHost.shared.stop() } catch { + let alert = NSAlert() + alert.messageText = "The local server is still stopping" + alert.informativeText = error.localizedDescription + alert.addButton(withTitle: "Keep open") + alert.runModal() + shouldQuit = false + } + } + finishingTermination = false + if !shouldQuit { controller?.showWindow(nil) } + sender.reply(toApplicationShouldTerminate: shouldQuit) + } + return .terminateLater + } + + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true } +} + +enum Arguments { + static func value(_ flag: String) -> String? { + guard let index = CommandLine.arguments.firstIndex(of: flag), + CommandLine.arguments.indices.contains(index + 1) + else { return nil } + return CommandLine.arguments[index + 1] + } +} + +@main enum Main { + @MainActor static func main() { + if CommandLine.arguments.contains("--smoke") || CommandLine.arguments.contains("--parity-smoke") + { + Task { + do { + if CommandLine.arguments.contains("--parity-smoke") { + try await ParitySmoke.run() + } else { + try await Smoke.run() + } + exit(0) + } catch { + fputs("Native smoke failed: \(error.localizedDescription)\n", stderr) + exit(1) + } + } + RunLoop.main.run() + return + } + let app = NSApplication.shared + app.setActivationPolicy(.regular) + let mainMenu = NSMenu() + let appItem = NSMenuItem() + let appMenu = NSMenu() + let aboutItem = appMenu.addItem( + withTitle: "About T3 Native", + action: #selector(AppDelegate.showAbout), keyEquivalent: "") + appMenu.addItem(.separator()) + appMenu.addItem( + withTitle: "Quit T3 Native", action: #selector(NSApplication.terminate(_:)), + keyEquivalent: "q") + appItem.submenu = appMenu + mainMenu.addItem(appItem) + let edit = NSMenuItem() + edit.title = "Edit" + edit.submenu = NSMenu(title: "Edit") + for (title, action, key) in [ + ("Undo", "undo:", "z"), ("Cut", "cut:", "x"), ("Copy", "copy:", "c"), + ("Paste", "paste:", "v"), ("Select All", "selectAll:", "a"), + ] { + edit.submenu?.addItem(withTitle: title, action: Selector(action), keyEquivalent: key) + } + mainMenu.addItem(edit) + let windows = NSMenu(title: "Window") + windows.addItem( + withTitle: "Close Window", action: #selector(NSWindow.performClose(_:)), keyEquivalent: "w") + windows.addItem( + withTitle: "Minimize", action: #selector(NSWindow.performMiniaturize(_:)), keyEquivalent: "m") + windows.addItem( + withTitle: "Zoom", action: #selector(NSWindow.performZoom(_:)), keyEquivalent: "") + windows.addItem(.separator()) + windows.addItem( + withTitle: "Bring All to Front", action: #selector(NSApplication.arrangeInFront(_:)), + keyEquivalent: "") + let windowItem = NSMenuItem(title: "Window", action: nil, keyEquivalent: "") + windowItem.submenu = windows + mainMenu.addItem(windowItem) + app.windowsMenu = windows + app.mainMenu = mainMenu + let delegate = AppDelegate() + aboutItem.target = delegate + app.delegate = delegate + withExtendedLifetime(delegate) { app.run() } + } +} + +enum Smoke { + static func run() async throws { + guard let path = Arguments.value("--connection-file") else { + throw T3Error("--connection-file is required.") + } + let settings = try ConnectionSettings.load(URL(fileURLWithPath: path)) + let connection = RPCConnection(settings: settings) + let watchdog = Task { + try await Task.sleep(for: .seconds(120)) + await connection.close(error: T3Error("Smoke test timed out.")) + } + defer { + watchdog.cancel() + Task { await connection.close() } + } + let start = Date() + try await connection.open() + let subscription = try await connection.subscribe( + "orchestration.subscribeShell", payload: .object(["requestCompletionMarker": .bool(true)])) + var shell = ShellState() + for try await value in subscription.values { + try shell.apply(value) + if shell.synchronized { break } + } + await connection.cancel(subscription.id) + guard let template = shell.threads.values.sorted(by: { $0.updatedAt > $1.updatedAt }).first + else { throw T3Error("No threads in this environment.") } + let id = Arguments.value("--thread-id") ?? template.id + let detail = try await connection.subscribe( + "orchestration.subscribeThread", + payload: .object([ + "threadId": .string(id), "requestCompletionMarker": .bool(true), + "acceptBoundedSnapshot": .bool(true), + ])) + var thread = ThreadState(id: id) + for try await value in detail.values { + _ = try thread.apply(value) + if thread.synchronized { break } + } + await connection.cancel(detail.id) + guard thread.synchronized else { throw T3Error("Thread never synchronized.") } + let initialRows = thread.rows.count + if let cursor = thread.cursor, thread.hasMore { + let page = try await EnvironmentHTTP.read( + settings.request( + "/api/orchestration/threads/\(id)/history", + query: [URLQueryItem(name: "cursor", value: cursor)])) + try thread.prependHistory(page) + } + print( + "Connected: \(shell.projects.count) projects, \(shell.threads.count) threads; \(initialRows) recent rows, \(thread.rows.count) after history page; \(Int(Date().timeIntervalSince(start) * 1000)) ms to read." + ) + if CommandLine.arguments.contains("--attachment-preview-smoke") { + let cache = AttachmentPreviewCache() + let attachments = thread.rows.flatMap { $0.item["attachments"].array } + guard !attachments.isEmpty else { throw T3Error("The selected thread has no attachments.") } + var images = 0 + for attachment in attachments { + let request = try AttachmentPreviewRequest.remote( + attachment, connection: connection, settings: settings) + let file = try await cache.file(for: request) + let reused = try await cache.file(for: request) + guard file === reused else { throw T3Error("The preview cache did not reuse the file.") } + if attachment["type"].string == "image" { + let thumbnail = try await cache.thumbnail(for: request) + guard thumbnail.width <= 512, thumbnail.height <= 512 else { + throw T3Error("The thumbnail exceeded its decode limit.") + } + images += 1 + } + } + print( + "PASS: \(attachments.count) signed attachment previews, \(images) decoded thumbnails, cached file reuse." + ) + } + if CommandLine.arguments.contains("--send-smoke") { + guard let project = Arguments.value("--sandbox-project") else { + throw T3Error("--send-smoke requires --sandbox-project (an isolated project ID).") + } + let configs = try await connection.subscribe("subscribeServerConfig") + var model = JSON.null + for try await value in configs.values { + if let provider = value["config"]["providers"].array.first(where: { + $0["instanceId"].string == "codex" && $0["status"].string == "ready" + }), + let choice = provider["models"].array.first(where: { $0["slug"].string == "gpt-6.1-sol" }) + ?? provider["models"].array.first + { + model = .object(["instanceId": provider["instanceId"], "model": choice["slug"]]) + } + break + } + await connection.cancel(configs.id) + guard model != .null else { + throw T3Error("Smoke test needs an authenticated Codex provider.") + } + let templateJSON: JSON = .object([ + "id": .string(template.id), "projectId": .string(project), "title": .string("Native smoke"), + "modelSelection": model, "runtimeMode": .string("approval-required"), + "interactionMode": .string("default"), + ]) + let testID = UUID().uuidString + _ = try await connection.call( + "orchestration.dispatchCommand", + payload: Commands.newThread( + template: ThreadSummary(templateJSON), id: testID, title: "Native client smoke")) + let stream = try await connection.subscribe( + "orchestration.subscribeThread", + payload: .object(["threadId": .string(testID), "acceptBoundedSnapshot": .bool(true)])) + let command = Commands.message( + threadID: testID, + text: + "Reply with exactly NATIVE_T3_OK. Do not use tools, inspect files, or execute commands.", + commandID: UUID().uuidString, messageID: UUID().uuidString) + let receipt = try await connection.call("orchestration.dispatchCommand", payload: command) + // A repeated command must resolve to the same receipt, not send another turn. + let retryReceipt = try await connection.call( + "orchestration.dispatchCommand", payload: command) + guard receipt == retryReceipt else { + throw T3Error("Repeated command returned a different receipt.") + } + var events = 0 + for try await value in stream.values { + if value["kind"].string == "event" { events += 1 } + let event = value["event"] + if event["type"].string == "turn-item.updated", + event["payload"]["type"].string == "assistant_message", + event["payload"]["text"].string?.contains("NATIVE_T3_OK") == true, + !event["payload"]["streaming"].bool + { + print( + "PASS: real provider reply streamed through Swift (\(events) events); duplicate dispatch accepted idempotently. Test thread: \(testID)" + ) + await connection.cancel(stream.id) + return + } + if event["type"].string == "run.updated", event["payload"]["status"].string == "failed" { + throw T3Error("Provider run failed; inspect the isolated server log.") + } + } + throw T3Error("No completed provider reply received.") + } + } +} diff --git a/apps/macos/Sources/T3Native/MessageAttachmentsView.swift b/apps/macos/Sources/T3Native/MessageAttachmentsView.swift new file mode 100644 index 0000000000000..593e5709ee9b5 --- /dev/null +++ b/apps/macos/Sources/T3Native/MessageAttachmentsView.swift @@ -0,0 +1,85 @@ +import AppKit +import T3Core + +@MainActor final class MessageAttachmentsView: NSStackView { + static func height(_ attachments: [JSON]) -> CGFloat { + let images = attachments.filter { $0["type"].string == "image" }.count + let files = attachments.count - images + let imageRows = (images + 1) / 2 + return CGFloat(imageRows * 108 + files * 30 - (attachments.isEmpty ? 0 : 6)) + } + + init( + attachments: [JSON], store: ClientStore, previews: AttachmentPreviews, + download: @escaping (JSON) -> Void + ) { + super.init(frame: .zero) + orientation = .vertical + alignment = .leading + spacing = 6 + let environment = store.environmentKey + let items = attachments.map { attachment in + AttachmentPreviewItem(name: attachment["name"].string ?? "Attachment") { [weak store] in + guard let store, store.environmentKey == environment else { + throw T3Error("Reconnect to this attachment’s environment to preview it.") + } + return try store.attachmentPreviewRequest(attachment) + } + } + let images = attachments.indices.filter { attachments[$0]["type"].string == "image" } + for offset in stride(from: 0, to: images.count, by: 2) { + let row = NSStackView() + row.spacing = 8 + for index in images[offset.. Void)? + var fileLinkMenu: ((URL) -> NSMenu?)? + var onLayoutChange: ((CGFloat, CGFloat) -> Void)? + var onCodeWrapChange: ((Int, Bool) -> Void)? + private var rendered: RenderedMarkdown + private let layoutWidth: CGFloat + private var surfaces: [NSRect] = [] + private var controls: [(label: NSTextField, copy: NSButton, wrap: NSButton)] = [] + private var geometryDirty = true + + var contentWidth: CGFloat { max(layoutWidth, rendered.contentWidth) } + + static func horizontalScrollerAllowance(for rendered: RenderedMarkdown, width: CGFloat) -> CGFloat + { + rendered.contentWidth > width + 1 ? 16 : 0 + } + + init(rendered: RenderedMarkdown, width: CGFloat, height: CGFloat) { + self.rendered = rendered + self.layoutWidth = width + let storage = NSTextStorage(attributedString: rendered.text) + let layout = MarkdownLayoutManager() + let container = MarkdownTextContainer(text: rendered.text, width: width) + container.lineFragmentPadding = 0 + container.widthTracksTextView = false + storage.addLayoutManager(layout) + layout.addTextContainer(container) + super.init(frame: NSRect(x: 0, y: 0, width: width, height: height), textContainer: container) + delegate = self + isEditable = false + isSelectable = true + isRichText = true + drawsBackground = false + textContainerInset = .zero + isHorizontallyResizable = false + isVerticallyResizable = false + linkTextAttributes = [ + .foregroundColor: NSColor.linkColor + ] + setAccessibilityLabel("Message content") + for (index, code) in rendered.codeBlocks.enumerated() { + guard code.surfaceRange != nil else { continue } + let label = NSTextField( + labelWithString: code.language?.split(separator: " ").first.map(String.init) ?? "code") + label.font = .systemFont(ofSize: 11, weight: .medium) + label.textColor = MarkdownPalette.codeSecondary + label.lineBreakMode = .byTruncatingTail + let copy = ActionButton("Copy") { [weak self] in self?.copyCode(at: index) } + copy.image = NSImage(systemSymbolName: "doc.on.doc", accessibilityDescription: nil) + copy.toolTip = "Copy code" + copy.setAccessibilityLabel("Copy \(label.stringValue) code") + let wrap = ActionButton("Wrap") { [weak self] in self?.toggleCodeWrapping(at: index) } + wrap.image = NSImage(systemSymbolName: "arrow.turn.down.left", accessibilityDescription: nil) + wrap.setButtonType(.toggle) + wrap.state = code.wraps ? .on : .off + wrap.toolTip = "Wrap long lines" + wrap.setAccessibilityLabel("Wrap \(label.stringValue) code lines") + for button in [copy, wrap] { + button.bezelStyle = .inline + button.controlSize = .small + button.font = .systemFont(ofSize: 11, weight: .medium) + button.contentTintColor = MarkdownPalette.codeText + button.imagePosition = .imageLeading + addSubview(button) + } + addSubview(label) + controls.append((label, copy, wrap)) + } + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + /// The document keeps prose at layoutWidth while code/tables can extend horizontally. + /// Parent rows should reserve 16pt for the horizontal scroller when using this wrapper. + func horizontalScrollContainer() -> NSScrollView { + let scroll = NSScrollView(frame: frame) + scroll.drawsBackground = false + scroll.borderType = .noBorder + scroll.hasVerticalScroller = false + scroll.hasHorizontalScroller = true + scroll.autohidesScrollers = true + scroll.scrollerStyle = .overlay + setFrameSize(NSSize(width: contentWidth, height: frame.height)) + scroll.documentView = self + scroll.contentView.postsBoundsChangedNotifications = true + NotificationCenter.default.addObserver( + self, selector: #selector(viewportChanged), name: NSView.boundsDidChangeNotification, + object: scroll.contentView) + return scroll + } + + @objc private func viewportChanged() { + positionControls() + } + + override func layout() { + super.layout() + updateGeometry() + positionControls() + } + + private func updateGeometry() { + guard geometryDirty, let layoutManager, let textContainer else { return } + layoutManager.ensureLayout(for: textContainer) + surfaces = rendered.codeBlocks.compactMap { code in + guard let range = code.surfaceRange else { return nil } + let glyphs = layoutManager.glyphRange(forCharacterRange: range, actualCharacterRange: nil) + let bounds = layoutManager.boundingRect(forGlyphRange: glyphs, in: textContainer) + return NSRect( + x: max(0, bounds.minX - 14) + textContainerOrigin.x, + y: bounds.minY - 14 + textContainerOrigin.y, + width: code.contentWidth, height: bounds.height + 28) + } + geometryDirty = false + } + + private func positionControls() { + for (surface, control) in zip(surfaces, controls) { + let right = min(surface.maxX, visibleRect.maxX) - 12 + let y = surface.minY + 10 + control.copy.frame = NSRect(x: right - 57, y: y, width: 57, height: 22) + control.wrap.frame = NSRect(x: right - 123, y: y, width: 61, height: 22) + control.label.frame = NSRect( + x: max(surface.minX, visibleRect.minX) + 14, y: y + 3, + width: max(0, right - 145 - max(surface.minX, visibleRect.minX)), height: 16) + } + } + + override func draw(_ dirtyRect: NSRect) { + updateGeometry() + for surface in surfaces where surface.intersects(dirtyRect) { + let path = NSBezierPath( + roundedRect: surface.insetBy(dx: 0.5, dy: 0.5), xRadius: 8, yRadius: 8) + MarkdownPalette.codeBackground.setFill() + path.fill() + MarkdownPalette.codeBorder.setStroke() + path.lineWidth = 0.5 + path.stroke() + let rule = NSBezierPath() + rule.move(to: NSPoint(x: surface.minX + 1, y: surface.minY + 39)) + rule.line(to: NSPoint(x: surface.maxX - 1, y: surface.minY + 39)) + rule.lineWidth = 0.5 + rule.stroke() + } + super.draw(dirtyRect) + } + + private func copyCode(at index: Int) { + guard rendered.codeBlocks.indices.contains(index) else { return } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(rendered.codeBlocks[index].code, forType: .string) + let controlIndex = rendered.codeBlocks.prefix(index).filter { $0.surfaceRange != nil }.count + if controls.indices.contains(controlIndex) { + controls[controlIndex].copy.title = "Copied" + controls[controlIndex].copy.setAccessibilityLabel("Code copied") + } + } + + private func toggleCodeWrapping(at index: Int) { + guard rendered.codeBlocks.indices.contains(index) else { return } + let wraps = !rendered.codeBlocks[index].wraps + let selection = selectedRange() + rendered = MarkdownRenderer.settingCodeWrapping( + wraps, at: index, in: rendered, width: layoutWidth) + (textContainer as? MarkdownTextContainer)?.updateLayoutWidth(for: rendered.text) + textStorage?.setAttributedString(rendered.text) + setSelectedRange(selection) + geometryDirty = true + needsLayout = true + needsDisplay = true + let controlIndex = rendered.codeBlocks.prefix(index).filter { $0.surfaceRange != nil }.count + if controls.indices.contains(controlIndex) { + controls[controlIndex].wrap.state = wraps ? .on : .off + } + let height = MarkdownRenderer.height(of: rendered.text, width: layoutWidth) + if enclosingScrollView?.documentView === self { + setFrameSize(NSSize(width: contentWidth, height: height)) + } + onLayoutChange?(height, contentWidth) + onCodeWrapChange?(index, wraps) + } + + private func open(_ url: URL) { + if let onOpenLink { onOpenLink(url) } else { NSWorkspace.shared.open(url) } + } + func textView(_ textView: NSTextView, clickedOnLink link: Any, at charIndex: Int) -> Bool { + guard let url = link as? URL else { return false } + open(url) + return true + } + + override func menu(for event: NSEvent) -> NSMenu? { + let menu = NSMenu() + menu.autoenablesItems = false + guard let layoutManager, let textContainer else { return menu } + let point = convert(event.locationInWindow, from: nil) + let index = layoutManager.characterIndex( + for: NSPoint(x: point.x - textContainerOrigin.x, y: point.y - textContainerOrigin.y), + in: textContainer, fractionOfDistanceBetweenInsertionPoints: nil) + if let codeIndex = rendered.codeBlocks.firstIndex(where: { NSLocationInRange(index, $0.range) }) + { + let code = rendered.codeBlocks[codeIndex] + menu.addItem(ActionMenuItem("Copy code") { [weak self] in self?.copyCode(at: codeIndex) }) + if code.surfaceRange != nil { + menu.addItem( + ActionMenuItem(code.wraps ? "Unwrap code" : "Wrap code") { [weak self] in + self?.toggleCodeWrapping(at: codeIndex) + }) + } + } + if index < rendered.text.length, + let link = rendered.text.attribute(.link, at: index, effectiveRange: nil) as? URL + { + if link.scheme == "t3-markdown-task" { + menu.addItem(ActionMenuItem("Toggle task") { [weak self] in self?.open(link) }) + } else { + menu.addItem(ActionMenuItem("Open link") { [weak self] in self?.open(link) }) + menu.addItem( + ActionMenuItem("Copy link") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(link.absoluteString, forType: .string) + }) + if let actions = fileLinkMenu?(link) { + menu.addItem(.separator()) + FileContextMenu.append(actions, to: menu) + } + } + } + if !menu.items.isEmpty { menu.addItem(.separator()) } + let copy = NSMenuItem(title: "Copy", action: #selector(copy(_:)), keyEquivalent: "") + copy.target = self + copy.isEnabled = selectedRange().length > 0 + menu.addItem(copy) + let select = NSMenuItem( + title: "Select all", action: #selector(selectAll(_:)), keyEquivalent: "") + select.target = self + menu.addItem(select) + return menu + } +} diff --git a/apps/macos/Sources/T3Native/ModelPicker.swift b/apps/macos/Sources/T3Native/ModelPicker.swift new file mode 100644 index 0000000000000..a875fe5185c1b --- /dev/null +++ b/apps/macos/Sources/T3Native/ModelPicker.swift @@ -0,0 +1,449 @@ +import AppKit +import T3Core + +@MainActor private final class ModelTable: NSTableView { + var choose: (() -> Void)? + var dismiss: (() -> Void)? + override func keyDown(with event: NSEvent) { + if event.keyCode == 36 { + choose?() + } else if event.keyCode == 53 { + dismiss?() + } else { + super.keyDown(with: event) + } + } +} + +@MainActor private final class ModelRail: NSStackView { + override var isFlipped: Bool { true } +} + +@MainActor +final class ModelPicker: NSViewController, NSTableViewDataSource, + NSTableViewDelegate, NSSearchFieldDelegate +{ + private enum Row { + case model(ModelChoice) + case legacy(Int) + } + private let store: ClientStore + private let threadID: String? + private let environment: String + private let close: () -> Void + private let search = NSSearchField() + private let table = ModelTable() + private let rail = ModelRail() + private let railScroll = NSScrollView() + private let footer = NSTextField(wrappingLabelWithString: "") + private var catalog = ProviderCatalog() + private var index = ModelSearch(catalog: ProviderCatalog()) + private var rows: [Row] = [] + private var instanceID: String? + private var favoritesOnly = false + private var preferences = ModelPickerPreferences() + private var favorites: Set = [] + private var expandedLegacy: Set = [] + private var previousSelection: JSON? + private var previousEnabled: Bool? + private var previousStarted: Bool? + private var favoritesKey: String { "modelFavorites:" + environment } + + init(store: ClientStore, close: @escaping () -> Void) { + self.store = store + threadID = store.selectedID + environment = store.environmentKey + self.close = close + super.init(nibName: nil, bundle: nil) + if let data = UserDefaults.standard.data(forKey: favoritesKey), + let saved = try? JSONDecoder().decode(Set.self, from: data) + { + favorites = saved + } + preferences = ModelPickerPreferences(environment: environment) + favorites = preferences.favorites + instanceID = store.selectedThread?.model["instanceId"].string + favoritesOnly = + !favorites.isEmpty + && store.catalog.provider(for: store.selectedThread?.model ?? .null)?.available == true + if store.catalog.model(for: store.selectedThread?.model ?? .null)?.legacy == true, + let instanceID + { + expandedLegacy.insert(instanceID) + } + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override func loadView() { + view = SurfaceView(raised: true) + view.frame = NSRect(x: 0, y: 0, width: 440, height: 340) + rail.orientation = .vertical + rail.alignment = .centerX + rail.spacing = 3 + rail.edgeInsets = NSEdgeInsets(top: 10, left: 6, bottom: 10, right: 6) + railScroll.documentView = rail + railScroll.hasVerticalScroller = true + railScroll.drawsBackground = false + rail.translatesAutoresizingMaskIntoConstraints = false + rail.widthAnchor.constraint(equalTo: railScroll.widthAnchor).isActive = true + search.placeholderString = "Search models…" + search.font = .systemFont(ofSize: 14) + search.isBezeled = true + search.drawsBackground = false + search.focusRingType = .none + search.heightAnchor.constraint(equalToConstant: 34).isActive = true + search.setAccessibilityLabel("Search models") + search.sendsSearchStringImmediately = true + search.sendsWholeSearchString = true + search.delegate = self + table.headerView = nil + table.addTableColumn(NSTableColumn(identifier: NSUserInterfaceItemIdentifier("model"))) + table.columnAutoresizingStyle = .uniformColumnAutoresizingStyle + table.dataSource = self + table.delegate = self + table.target = self + table.action = #selector(clicked) + table.rowHeight = 58 + table.selectionHighlightStyle = .regular + table.intercellSpacing = .zero + table.backgroundColor = .clear + table.setAccessibilityLabel("Models") + table.choose = { [weak self] in self?.chooseSelected() } + table.dismiss = close + let scroll = NSScrollView() + scroll.documentView = table + scroll.hasVerticalScroller = true + scroll.autohidesScrollers = true + scroll.drawsBackground = false + footer.font = .systemFont(ofSize: 11) + footer.textColor = .secondaryLabelColor + let right = NSStackView(views: [search, scroll, footer]) + right.orientation = .vertical + right.alignment = .leading + right.spacing = 8 + right.edgeInsets = NSEdgeInsets(top: 10, left: 10, bottom: 10, right: 10) + for child in [search, scroll, footer] { + child.widthAnchor.constraint(equalTo: right.widthAnchor, constant: -20).isActive = true + } + view.addSubview(railScroll) + view.addSubview(right) + railScroll.translatesAutoresizingMaskIntoConstraints = false + right.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + railScroll.leadingAnchor.constraint(equalTo: view.leadingAnchor), + railScroll.topAnchor.constraint(equalTo: view.topAnchor), + railScroll.bottomAnchor.constraint(equalTo: view.bottomAnchor), + railScroll.widthAnchor.constraint(equalToConstant: 56), + right.leadingAnchor.constraint(equalTo: railScroll.trailingAnchor), + right.trailingAnchor.constraint(equalTo: view.trailingAnchor), + right.topAnchor.constraint(equalTo: view.topAnchor), + right.bottomAnchor.constraint(equalTo: view.bottomAnchor), + scroll.heightAnchor.constraint(greaterThanOrEqualToConstant: 160), + ]) + refresh() + } + + override func viewDidAppear() { + super.viewDidAppear() + view.window?.makeFirstResponder(search) + } + + func refresh() { + guard store.selectedID == threadID, store.environmentKey == environment else { + close() + return + } + let changedCatalog = catalog != store.catalog + let selection = store.selectedThread?.model + let enabled = store.canEditThread && store.catalogReady + let started = !(store.thread?.rows.isEmpty ?? true) + guard + changedCatalog || previousSelection != selection || previousEnabled != enabled + || previousStarted != started + else { return } + previousSelection = selection + previousEnabled = enabled + previousStarted = started + if changedCatalog { + catalog = store.catalog + index = ModelSearch(catalog: catalog) + } + guard isViewLoaded else { return } + rebuildRail() + reloadResults() + } + + private func rebuildRail() { + for child in rail.arrangedSubviews { + rail.removeArrangedSubview(child) + child.removeFromSuperview() + } + func add( + _ title: String, image: NSImage?, badge: String = "", selected: Bool, + action: @escaping () -> Void + ) { + let button = ModelAccountButton(badge, selected: selected, action: action) + button.image = image + button.toolTip = title + button.setAccessibilityLabel(title) + rail.addArrangedSubview(button) + button.widthAnchor.constraint(equalToConstant: 40).isActive = true + button.heightAnchor.constraint(equalToConstant: 44).isActive = true + } + add( + "Favorites", image: NSImage(systemSymbolName: "star.fill", accessibilityDescription: nil), + selected: favoritesOnly + ) { [weak self] in self?.filter(nil, favorites: true) } + for provider in catalog.providers { + add( + provider.name, image: ProviderArtwork.image(provider.driver), + badge: provider.driver == "codex" ? "" : SidebarProviderMark.initials(provider.name), + selected: !favoritesOnly && instanceID == provider.id + ) { [weak self] in self?.filter(provider.id, favorites: false) } + } + } + + private func filter(_ id: String?, favorites: Bool) { + instanceID = id + favoritesOnly = favorites + search.stringValue = "" + rebuildRail() + reloadResults(resetSelection: true) + view.window?.makeFirstResponder(search) + } + + private func reloadResults(resetSelection: Bool = false) { + let oldID: ModelIdentity? = { + guard rows.indices.contains(table.selectedRow), + case .model(let choice) = rows[table.selectedRow] + else { return nil } + return choice.id + }() + preferences = ModelPickerPreferences(environment: environment) + favorites = preferences.favorites + var matches = index.results( + query: search.stringValue, instanceID: favoritesOnly ? nil : instanceID, + favoritesOnly: favoritesOnly, favorites: favorites + ) + .filter { !preferences.hidden.contains($0.id) || $0.model.custom } + if search.stringValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + let positions = Dictionary( + uniqueKeysWithValues: catalog.providers.flatMap { provider in + preferences.models(provider.models, instanceID: provider.id).enumerated().map { + (ModelIdentity(instanceID: provider.id, modelID: $0.element.id), $0.offset) + } + }) + let providers = Dictionary( + uniqueKeysWithValues: catalog.providers.enumerated().map { ($0.element.id, $0.offset) }) + matches = matches.enumerated().sorted { a, b in + let left = ( + providers[a.element.provider.id] ?? Int.max, positions[a.element.id] ?? Int.max, a.offset + ) + let right = ( + providers[b.element.provider.id] ?? Int.max, positions[b.element.id] ?? Int.max, b.offset + ) + return left < right + }.map(\.element) + } + let searching = !search.stringValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + if !searching && !favoritesOnly, let instanceID { + rows = matches.filter { !$0.model.legacy }.map(Row.model) + let legacy = matches.filter { $0.model.legacy } + if !legacy.isEmpty { + rows.append(.legacy(legacy.count)) + if expandedLegacy.contains(instanceID) { rows += legacy.map(Row.model) } + } + } else { + rows = matches.map(Row.model) + } + table.reloadData() + let selectedID = + resetSelection ? nil : oldID ?? store.selectedThread.map { ModelIdentity($0.model) } + let selected = + selectedID.flatMap { identity in + rows.firstIndex { + if case .model(let choice) = $0 { return choice.id == identity } + return false + } + } ?? (rows.isEmpty ? -1 : 0) + table.selectRowIndexes( + selected < 0 ? [] : IndexSet(integer: selected), byExtendingSelection: false) + if selected >= 0 { table.scrollRowToVisible(selected) } + updateFooter() + } + + private func updateFooter() { + if rows.isEmpty { + footer.stringValue = + favoritesOnly && search.stringValue.isEmpty + ? "Star a model to add it to Favorites." : "No matching models." + } else if rows.indices.contains(table.selectedRow), + case .model(let choice) = rows[table.selectedRow] + { + footer.stringValue = + store.modelDisabledReason(choice) ?? "↑ ↓ to browse · Return to select · Esc to close" + } else { + footer.stringValue = "Return to show or hide legacy models" + } + } + + func controlTextDidChange(_ obj: Notification) { reloadResults(resetSelection: true) } + func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) + -> Bool + { + switch commandSelector { + case #selector(NSResponder.moveDown(_:)), #selector(NSResponder.moveUp(_:)): + guard !rows.isEmpty else { return true } + let delta = commandSelector == #selector(NSResponder.moveDown(_:)) ? 1 : -1 + let next = max(0, min(rows.count - 1, table.selectedRow + delta)) + table.selectRowIndexes(IndexSet(integer: next), byExtendingSelection: false) + table.scrollRowToVisible(next) + return true + case #selector(NSResponder.insertNewline(_:)): + chooseSelected() + return true + case #selector(NSResponder.cancelOperation(_:)): + close() + return true + default: return false + } + } + func tableViewSelectionDidChange(_ notification: Notification) { updateFooter() } + func numberOfRows(in tableView: NSTableView) -> Int { rows.count } + @objc private func clicked() { if table.clickedRow >= 0 { chooseSelected() } } + + private func chooseSelected() { + guard rows.indices.contains(table.selectedRow) else { return } + switch rows[table.selectedRow] { + case .legacy: + guard let instanceID else { return } + let selectedRow = table.selectedRow + if !expandedLegacy.insert(instanceID).inserted { expandedLegacy.remove(instanceID) } + reloadResults() + table.selectRowIndexes(IndexSet(integer: selectedRow), byExtendingSelection: false) + table.scrollRowToVisible(selectedRow) + case .model(let choice): + guard store.selectedID == threadID, store.environmentKey == environment, + store.modelDisabledReason(choice) == nil, + let provider = store.catalog.providers.first(where: { $0.id == choice.provider.id }), + let model = provider.models.first(where: { $0.id == choice.model.id }) + else { + refresh() + return + } + let previous = store.selectedThread?.model + close() + store.setModel( + model.selection( + instanceID: provider.id, + preserving: previous?["instanceId"].string == provider.id ? previous : nil, + planModeEnabled: store.catalog.planModeEnabled)) + } + } + + func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? + { + let cell = NSView() + switch rows[row] { + case .legacy(let count): + let expanded = instanceID.map { expandedLegacy.contains($0) } ?? false + let label = NSTextField(labelWithString: "\(expanded ? "⌄" : "›") Legacy models · \(count)") + label.font = .systemFont(ofSize: 12) + cell.addSubview(label) + label.frame = NSRect(x: 10, y: 15, width: table.bounds.width - 20, height: 20) + label.autoresizingMask = [.width] + case .model(let choice): + let title = NSTextField( + labelWithString: choice.model.name + (choice.model.badge == "new" ? " NEW" : "")) + title.font = .systemFont(ofSize: 14, weight: .regular) + title.textColor = + store.modelDisabledReason(choice) == nil ? .labelColor : .secondaryLabelColor + title.lineBreakMode = .byTruncatingTail + let detail = NSTextField( + labelWithString: choice.provider.name + + (choice.model.subProvider.map { " · " + $0 } ?? "") + + (choice.model.legacy ? " · Legacy" : choice.model.custom ? " · Custom" : "")) + detail.font = .systemFont(ofSize: 12) + detail.textColor = .secondaryLabelColor + detail.lineBreakMode = .byTruncatingTail + let favorite = favorites.contains(choice.id) + let star = ActionButton("") { [weak self] in + guard let self else { return } + if !self.favorites.insert(choice.id).inserted { self.favorites.remove(choice.id) } + self.preferences.favorites = self.favorites + try? self.preferences.save(environment: self.environment) + self.reloadResults() + } + star.isBordered = false + star.image = NSImage( + systemSymbolName: favorite ? "star.fill" : "star", accessibilityDescription: nil) + star.setAccessibilityLabel( + (favorite ? "Unfavorite " : "Favorite ") + choice.model.name + " · " + choice.provider.name) + cell.toolTip = store.modelDisabledReason(choice) ?? choice.model.id + let labels = NSStackView(views: [title, detail]) + labels.orientation = .vertical + labels.alignment = .leading + labels.spacing = 5 + cell.addSubview(labels) + cell.addSubview(star) + labels.translatesAutoresizingMaskIntoConstraints = false + star.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + labels.leadingAnchor.constraint(equalTo: cell.leadingAnchor, constant: 10), + labels.centerYAnchor.constraint(equalTo: cell.centerYAnchor), + labels.trailingAnchor.constraint(equalTo: star.leadingAnchor, constant: -6), + title.widthAnchor.constraint(equalTo: labels.widthAnchor), + detail.widthAnchor.constraint(equalTo: labels.widthAnchor), + star.trailingAnchor.constraint(equalTo: cell.trailingAnchor, constant: -8), + star.centerYAnchor.constraint(equalTo: cell.centerYAnchor), + star.widthAnchor.constraint(equalToConstant: 22), + ]) + } + return cell + } + + func tableView(_ tableView: NSTableView, rowViewForRow row: Int) -> NSTableRowView? { + ModelSelectionRow() + } +} + +@MainActor private final class ModelSelectionRow: NSTableRowView { + override func drawSelection(in dirtyRect: NSRect) { + NSColor.labelColor.withAlphaComponent(0.08).setFill() + NSBezierPath(roundedRect: bounds.insetBy(dx: 2, dy: 2), xRadius: 9, yRadius: 9).fill() + } + override var isEmphasized: Bool { + get { false } + set {} + } +} + +@MainActor private final class ModelAccountButton: NSButton { + let selectedAccount: Bool + let invoke: () -> Void + init(_ badge: String, selected: Bool, action: @escaping () -> Void) { + selectedAccount = selected + invoke = action + super.init(frame: .zero) + title = badge + font = .systemFont(ofSize: 8, weight: .semibold) + imagePosition = badge.isEmpty ? .imageOnly : .imageAbove + isBordered = false + contentTintColor = selected ? .labelColor : .secondaryLabelColor + symbolConfiguration = .init(pointSize: 23, weight: .regular) + target = self + self.action = #selector(choose) + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + @objc private func choose() { invoke() } + override func draw(_ dirtyRect: NSRect) { + if selectedAccount { + NSColor.controlAccentColor.setFill() + NSBezierPath( + roundedRect: NSRect(x: bounds.maxX - 3, y: 7, width: 3, height: bounds.height - 14), + xRadius: 1.5, yRadius: 1.5 + ).fill() + } + super.draw(dirtyRect) + } +} diff --git a/apps/macos/Sources/T3Native/NativeConnections.swift b/apps/macos/Sources/T3Native/NativeConnections.swift new file mode 100644 index 0000000000000..dc06802c08567 --- /dev/null +++ b/apps/macos/Sources/T3Native/NativeConnections.swift @@ -0,0 +1,824 @@ +import AppKit +import Combine +import SwiftUI +import T3Core + +@MainActor final class NativeConnections: ObservableObject { + let store: ClientStore + let host: BackendHost + @Published private(set) var environments: [SavedEnvironment] = [] + @Published private(set) var discovered: [ConnectEnvironment] = [] + @Published private(set) var clients: [JSON] = [] + @Published private(set) var pairingLinks: [JSON] = [] + @Published private(set) var cloudState: JSON = .null + @Published private(set) var error: String? + @Published private(set) var notice: String? + @Published private(set) var busy = false + @Published private(set) var signedIn = false + @Published private(set) var signInPending = false + @Published private(set) var relayStatus: JSON = .null + @Published private(set) var pairingURL: URL? + @Published private(set) var accountAvailable = false + private let preferences: UserDefaults + private let credentials: Credentials.Access + private var loading = false + private var accountLoaded = false + private var repository: ConnectionRepository? + private var accountConfiguration: ConnectConfiguration? + private var account: ConnectAccountToken? + private var accessConnection: RPCConnection? + private var accessEndpoint: URL? + private let callback = ConnectCallback() + private var operation: Task? + private var generation = UUID() + private var accountGeneration = UUID() + + init( + store: ClientStore, host: BackendHost = .shared, preferences: UserDefaults = .standard, + credentials: Credentials.Access = .keychain + ) { + self.store = store + self.host = host + self.credentials = credentials + self.preferences = preferences + do { + accountConfiguration = try ConnectConfiguration.bundled( + info: Bundle.main.infoDictionary ?? [:]) + accountAvailable = true + } catch { accountAvailable = false } + run { _ in } + } + private func load(_ current: UUID) async throws { + if repository == nil { + let loaded = try await ConnectionRepository( + preferences: preferences, credentials: credentials + ) { + try self.requireGeneration(current) + } + try requireGeneration(current) + repository = loaded + environments = loaded.values.environments + } + if !accountLoaded { + let accountID = accountGeneration + if let configuration = accountConfiguration { + do { + let loaded = try await Credentials.loadAccount( + configuration: configuration, access: credentials) + try requireGeneration(current) + guard accountGeneration == accountID else { throw CancellationError() } + account = loaded + signedIn = loaded != nil + } catch is CancellationError { throw CancellationError() } catch { + try requireGeneration(current) + accountLoaded = true + throw error + } + } + try requireGeneration(current) + accountLoaded = true + } + } + private func requireGeneration(_ current: UUID) throws { + try Task.checkCancellation() + guard generation == current else { throw CancellationError() } + } + private func run(_ body: @escaping @MainActor (UUID) async throws -> Void) { + if busy { + guard loading else { return } + // An action replaces initialization; retry loading inside its tracked task so + // cancelling or superseding a Keychain prompt cannot lose the first action. + cancel() + } + let current = UUID() + generation = current + busy = true + loading = repository == nil || !accountLoaded + error = nil + notice = nil + pairingURL = nil + operation = Task { [weak self] in + guard let self else { return } + defer { + if self.generation == current { + self.busy = false + self.loading = false + self.operation = nil + } + } + do { + try await self.load(current) + try self.requireGeneration(current) + self.loading = false + try await body(current) + } catch is CancellationError { + if self.generation == current, self.repository == nil, !Task.isCancelled { + self.error = + "Saved connections changed while loading. Reopen Connections or retry your action to reload." + } + } catch { + if self.generation == current { self.error = error.localizedDescription } + } + } + } + func cancel() { + generation = UUID() + operation?.cancel() + operation = nil + callback.cancel() + busy = false + loading = false + signInPending = false + } + private func check(_ current: UUID, environment: String, connection: RPCConnection?) throws { + try requireGeneration(current) + try ConnectionOperation(environment: environment, connection: connection) + .requireCurrent(environment: store.environmentKey, connection: store.browserConnection) + } + private func connected() throws -> (RPCConnection, URL) { + guard let connection = store.browserConnection, let endpoint = store.browserEndpoint else { + throw T3Error("Connect to an environment first.") + } + return (connection, endpoint) + } + private func verify(_ connection: RPCConnection, _ endpoint: URL, _ current: UUID) throws { + try check(current, environment: endpoint.absoluteString, connection: connection) + } + private func http( + _ path: String, method: String = "GET", payload: JSON? = nil, + connection: RPCConnection, endpoint: URL, generation: UUID + ) async throws -> JSON { + try verify(connection, endpoint, generation) + let settings = await connection.settings + try verify(connection, endpoint, generation) + var request = settings.request(path, method: method) + if let payload { + request.httpBody = try payload.data() + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + } + let result = try await ConnectionHTTP.shared.read(request) + try verify(connection, endpoint, generation) + return result + } + private func rpc( + _ method: String, payload: JSON = .object([:]), connection: RPCConnection, + endpoint: URL, generation: UUID + ) async throws -> JSON { + try verify(connection, endpoint, generation) + let result = try await connection.call(method, payload: payload) + try verify(connection, endpoint, generation) + return result + } + private func use( + _ settings: ConnectionSettings, label: String, local: Bool = false, cloudID: String? = nil, + generation current: UUID, origin: String, connection: RPCConnection? + ) async throws { + try check(current, environment: origin, connection: connection) + guard store.saveDraftsBeforeExit() else { + throw T3Error(store.storageError ?? "Save your drafts before switching environments.") + } + guard let repository else { + throw T3Error("Saved connections are unavailable. Resolve the catalog error first.") + } + let environment = try await repository.remember( + settings, label: label, local: local, cloudEnvironmentID: cloudID + ) { + try self.check(current, environment: origin, connection: connection) + } + try check(current, environment: origin, connection: connection) + store.connect(settings) + guard store.environmentKey == settings.endpoint.absoluteString else { + throw T3Error( + store.error ?? "The environment could not be selected. Your drafts are preserved.") + } + try repository.select(environment) + environments = repository.values.environments + clearAccess() + } + private func clearAccess() { + accessConnection = nil + accessEndpoint = nil + clients = [] + pairingLinks = [] + cloudState = .null + relayStatus = .null + pairingURL = nil + } + func select(_ environment: SavedEnvironment) { + let origin = store.environmentKey + let connection = store.browserConnection + run { [self] current in + let settings: ConnectionSettings + if environment.local { + guard environment.endpoint == host.configuration.endpoint else { + throw T3Error("This saved local endpoint differs from the native host's configured port.") + } + settings = try await host.start() + } else if let id = environment.cloudEnvironmentID, signedIn { + let client = try await cloudClient() + let catalog = try await client.environments() + guard let discovered = catalog.first(where: { $0.id == id }) else { + throw T3Error("This environment is no longer registered with your T3 Connect account.") + } + settings = try await client.pair(discovered) + } else { + guard let repository else { throw T3Error("The connection catalog is unavailable.") } + settings = try await repository.settings(for: environment) + } + try check(current, environment: origin, connection: connection) + try await use( + settings, label: environment.label, local: environment.local, + cloudID: environment.cloudEnvironmentID, generation: current, origin: origin, + connection: connection) + } + } + func pair(address: String, credential: String, label: String, savedToken: Bool) { + let origin = store.environmentKey + let connection = store.browserConnection + run { [self] current in + let target = try RemotePairingTarget(address: address, credential: credential) + let settings = + savedToken + ? try ConnectionSettings(endpoint: target.endpoint, token: target.credential) + : try await ConnectionHTTP.exchange( + endpoint: target.endpoint, credential: target.credential) + // Validate a pasted access token before overwriting a known good saved credential. + let session = try await ConnectionHTTP.shared.read(settings.request("/api/auth/session")) + guard session["authenticated"].bool else { + throw T3Error("The server did not authenticate this session.") + } + try check(current, environment: origin, connection: connection) + try await use( + settings, label: label, generation: current, origin: origin, connection: connection) + } + } + func startLocal(home: String, port: String) { + let origin = store.environmentKey + let connection = store.browserConnection + run { [self] current in + guard let value = Int(port), home.hasPrefix("/") else { + throw T3Error("Enter an absolute data directory and a valid port.") + } + let config = BackendConfiguration(home: URL(fileURLWithPath: home), port: value) + if config != host.configuration { try host.configure(config) } + let settings = try await host.start() + try check(current, environment: origin, connection: connection) + try await use( + settings, label: "This Mac", local: true, generation: current, + origin: origin, connection: connection) + } + } + func stopLocal() { + run { [self] _ in + if store.browserEndpoint == host.configuration.endpoint { + guard store.saveDraftsBeforeExit() else { + throw T3Error("Save your drafts before stopping the native server.") + } + store.disconnect() + clearAccess() + } + try await host.stop() + } + } + func forget(_ environment: SavedEnvironment) { + run { [self] current in + guard let repository else { throw T3Error("Saved connections are unavailable.") } + if store.environmentKey == environment.id { + guard store.saveDraftsBeforeExit() else { + throw T3Error("Save your drafts before removing this environment.") + } + store.disconnect() + clearAccess() + } + let origin = store.environmentKey + let connection = store.browserConnection + try await repository.remove(environment) { + try self.check(current, environment: origin, connection: connection) + } + try check(current, environment: origin, connection: connection) + environments = repository.values.environments + notice = + "Removed from this Mac. Server sessions and T3 Connect registration are managed separately." + } + } + func refreshAccess() { + clearAccess() + run { [self] current in + let (connection, endpoint) = try connected() + let session = try await http( + "/api/auth/session", connection: connection, endpoint: endpoint, generation: current) + accessConnection = connection + accessEndpoint = endpoint + let scopes = session["scopes"].array.compactMap(\.string) + if scopes.contains("access:read") { + clients = try await http( + "/api/auth/clients", connection: connection, endpoint: endpoint, generation: current + ).array + pairingLinks = try await http( + "/api/auth/pairing-links", connection: connection, endpoint: endpoint, generation: current + ).array + } else { + notice = + "This session cannot manage other clients. Pair with an administrator link to manage access." + } + if scopes.contains("relay:read") { + cloudState = try await http( + "/api/connect/link-state", connection: connection, endpoint: endpoint, generation: current + ) + relayStatus = try await rpc( + "cloud.getRelayClientStatus", connection: connection, endpoint: endpoint, + generation: current) + } + } + } + func createPairingLink() { + run { [self] current in + let (connection, endpoint) = try connected() + let result = try await http( + "/api/auth/pairing-token", method: "POST", + payload: .object(["label": .string("Phone or iPad")]), connection: connection, + endpoint: endpoint, generation: current) + let configuration = try await rpc( + "server.getConfig", connection: connection, endpoint: endpoint, generation: current) + let remote = configuration["directEndpoints"].array.first?["httpBaseUrl"].string + let environmentID = configuration["environment"]["environmentId"].string + let cloud = discovered.first(where: { $0.id == environmentID })?.endpoint.absoluteString + let address = cloud ?? remote ?? endpoint.absoluteString + let base = try ConnectionEndpoint.normalize(address) + var url = URLComponents(url: base, resolvingAgainstBaseURL: false)! + var fragment = URLComponents() + fragment.queryItems = [ + URLQueryItem(name: "token", value: try result.requiredString("credential")) + ] + url.percentEncodedFragment = fragment.percentEncodedQuery + pairingURL = url.url + notice = + ["127.0.0.1", "localhost"].contains(base.host ?? "") + ? "This address only works on this Mac. Link this host with T3 Connect for phone/iPad access." + : "Open this one-time link on the device you want to pair. It grants standard client access." + } + } + func revoke(id: String, pairing: Bool) { + run { [self] current in + let (connection, endpoint) = try connected() + guard accessConnection === connection, accessEndpoint == endpoint else { + throw T3Error( + "The access list belongs to a previous connection. Refresh access before revoking anything." + ) + } + let result = try await http( + pairing ? "/api/auth/pairing-links/revoke" : "/api/auth/clients/revoke", method: "POST", + payload: .object([pairing ? "id" : "sessionId": .string(id)]), connection: connection, + endpoint: endpoint, generation: current) + guard result["revoked"].bool else { + throw T3Error("The server did not confirm revocation. Refresh access before retrying.") + } + if pairing { + pairingLinks.removeAll { $0["id"].string == id } + } else { + clients.removeAll { $0["sessionId"].string == id } + } + notice = pairing ? "Pairing link revoked." : "Client session revoked." + } + } + private func cloudClient() async throws -> ConnectClient { + let current = accountGeneration + let operationID = generation + guard let configuration = accountConfiguration, let account else { + throw T3Error("Sign in to T3 Connect first.") + } + let refreshed = try await account.refreshed(configuration: configuration) + try requireGeneration(operationID) + guard current == accountGeneration else { throw CancellationError() } + try await Credentials.saveAccount(refreshed, configuration: configuration, access: credentials) + try requireGeneration(operationID) + guard current == accountGeneration else { throw CancellationError() } + self.account = refreshed + return ConnectClient(configuration: configuration, account: refreshed) + } + func signIn() { + run { [self] current in + guard let configuration = accountConfiguration else { + throw T3Error("T3 Connect public configuration is missing from this app bundle.") + } + let accountID = accountGeneration + signInPending = true + defer { if generation == current { signInPending = false } } + let authorization = ConnectAuthorization(configuration: configuration) + let callbackURL = try await callback.authorize(authorization) { NSWorkspace.shared.open($0) } + try requireGeneration(current) + guard accountGeneration == accountID else { throw CancellationError() } + let token = try await authorization.exchange(callbackURL) + try requireGeneration(current) + guard accountGeneration == accountID else { throw CancellationError() } + try await Credentials.saveAccount(token, configuration: configuration, access: credentials) + try requireGeneration(current) + guard accountGeneration == accountID else { throw CancellationError() } + accountGeneration = UUID() + account = token + signedIn = true + let signedInID = accountGeneration + let catalog = try await cloudClient().environments() + try Task.checkCancellation() + guard generation == current, accountGeneration == signedInID else { + throw CancellationError() + } + discovered = catalog + } + } + func signOut() { + cancel() + accountGeneration = UUID() + let accountID = accountGeneration + accountLoaded = true + account = nil + signedIn = false + discovered = [] + run { [self] current in + if let configuration = accountConfiguration { + try await Credentials.removeAccount(configuration: configuration, access: credentials) + } + try requireGeneration(current) + guard accountGeneration == accountID else { throw CancellationError() } + notice = "Signed out on this Mac. Existing server sessions and host links remain active." + } + } + func discover() { + run { [self] current in + let accountID = accountGeneration + let result = try await cloudClient().environments() + try Task.checkCancellation() + guard generation == current, accountGeneration == accountID else { throw CancellationError() } + discovered = result + } + } + func pairCloud(_ environment: ConnectEnvironment) { + let origin = store.environmentKey + let connection = store.browserConnection + run { [self] current in + let settings = try await cloudClient().pair(environment) + try check(current, environment: origin, connection: connection) + try await use( + settings, label: environment.label, cloudID: environment.id, + generation: current, origin: origin, connection: connection) + } + } + func linkHost() { + run { [self] current in + let (connection, endpoint) = try connected() + guard host.isRunning, endpoint == host.configuration.endpoint else { + throw T3Error("Start and select this Mac's native server before linking its host.") + } + let status = try await rpc( + "cloud.getRelayClientStatus", connection: connection, endpoint: endpoint, + generation: current) + guard status["status"].string == "available" else { + throw T3Error("Install the relay connector using Install connector, then link this Mac.") + } + let client = try await cloudClient() + try verify(connection, endpoint, current) + let configuration = try await rpc( + "server.getConfig", connection: connection, endpoint: endpoint, generation: current) + let id = try configuration["environment"].requiredString("environmentId") + let preferences: JSON = .object([ + "notificationsEnabled": .bool(true), "liveActivitiesEnabled": .bool(true), + "managedTunnelsEnabled": .bool(true), + ]) + let challenge = try await client.request( + "/v1/client/environment-link-challenges", method: "POST", payload: preferences) + try verify(connection, endpoint, current) + var ws = URLComponents(url: endpoint, resolvingAgainstBaseURL: false)! + ws.scheme = "ws" + let proof = try await http( + "/api/connect/link-proof", method: "POST", + payload: .object([ + "challenge": challenge["challenge"], + "relayIssuer": .string(client.configuration.relay.absoluteString), + "endpoint": .object([ + "httpBaseUrl": .string(endpoint.absoluteString), + "wsBaseUrl": .string(ws.url!.absoluteString), + "providerKind": .string("cloudflare_tunnel"), + ]), + "origin": .object([ + "localHttpHost": .string("127.0.0.1"), + "localHttpPort": .number(Double(host.configuration.port)), + ]), + ]), + connection: connection, endpoint: endpoint, generation: current) + let link = try await client.request( + "/v1/client/environment-links", method: "POST", + payload: .object([ + "proof": proof, "notificationsEnabled": .bool(true), "liveActivitiesEnabled": .bool(true), + "managedTunnelsEnabled": .bool(true), + ])) + try verify(connection, endpoint, current) + guard link["environmentId"].string == id, + link["endpoint"]["providerKind"].string == "cloudflare_tunnel" + else { + throw T3Error( + "T3 Connect returned a different host. The host was not configured; refresh the account catalog to remove the link." + ) + } + let configured = try await http( + "/api/connect/relay-config", method: "POST", + payload: .object([ + "relayUrl": .string(client.configuration.relay.absoluteString), + "relayIssuer": link["relayIssuer"], + "cloudUserId": link["cloudUserId"], + "environmentCredential": link["environmentCredential"], + "cloudMintPublicKey": link["cloudMintPublicKey"], + "endpointRuntime": link["endpointRuntime"], + ]), + connection: connection, endpoint: endpoint, generation: current) + guard configured["ok"].bool, configured["endpointRuntimeStatus"]["status"].string == "running" + else { + throw T3Error( + "The host link was saved but the tunnel did not report running. Refresh its status before retrying." + ) + } + cloudState = try await http( + "/api/connect/link-state", connection: connection, endpoint: endpoint, generation: current) + let catalog = try await client.environments() + try verify(connection, endpoint, current) + discovered = catalog + notice = + "This Mac is linked. Keep its native server running and select it from T3 Account on your phone or iPad." + } + } + func installConnector() { + run { [self] current in + let (connection, endpoint) = try connected() + guard endpoint == host.configuration.endpoint, host.isRunning else { + throw T3Error("Select this Mac to install its connector.") + } + let stream = try await connection.subscribe("cloud.installRelayClient") + defer { Task { await connection.cancel(stream.id) } } + var complete = false + for try await event in stream.values { + try verify(connection, endpoint, current) + if event["type"].string == "complete" { + relayStatus = event["status"] + complete = event["status"]["status"].string == "available" + } + } + guard complete else { + throw T3Error("The relay connector did not report a completed installation.") + } + notice = "Connector installed. Link this Mac to enable T3 Connect." + } + } + func unlinkHost() { + run { [self] current in + let (connection, endpoint) = try connected() + let configuration = try await rpc( + "server.getConfig", connection: connection, endpoint: endpoint, generation: current) + _ = try await http( + "/api/connect/unlink", method: "POST", connection: connection, endpoint: endpoint, + generation: current) + cloudState = .null + do { + try await cloudClient().unlink(configuration["environment"].requiredString("environmentId")) + try verify(connection, endpoint, current) + let catalog = try await cloudClient().environments() + try verify(connection, endpoint, current) + discovered = catalog + notice = "T3 Connect host access disabled and its account registration removed." + } catch { + throw T3Error( + "The local host link was disabled, but account removal failed: " + + error.localizedDescription + + " Refresh the account catalog and remove its registration.") + } + } + } + func removeCloud(_ environment: ConnectEnvironment) { + run { [self] current in + let accountID = accountGeneration + try await cloudClient().unlink(environment.id) + try Task.checkCancellation() + guard generation == current, accountGeneration == accountID else { throw CancellationError() } + discovered.removeAll { $0.id == environment.id } + notice = + "Environment removed from this T3 Connect account. Saved direct connections remain on this Mac." + } + } +} + +@MainActor struct ConnectionsSettingsView: View { + @StateObject private var model: NativeConnections + @ObservedObject private var host: BackendHost + @State private var address = "" + @State private var credential = "" + @State private var label = "" + @State private var savedToken = false + @State private var home: String + @State private var port: String + @State private var confirmation: ConnectionConfirmation? + + init(store: ClientStore) { + let host = BackendHost.shared + _model = StateObject(wrappedValue: NativeConnections(store: store, host: host)) + _host = ObservedObject(wrappedValue: host) + _home = State(initialValue: host.configuration.home.path) + _port = State(initialValue: String(host.configuration.port)) + } + var body: some View { + Form { + if let error = model.error ?? host.error { + Text(error).foregroundStyle(.red).textSelection(.enabled) + } + if let notice = model.notice { + Text(notice).foregroundStyle(.secondary).textSelection(.enabled) + } + Section("Saved environments") { + if model.environments.isEmpty { Text("No saved environments").foregroundStyle(.secondary) } + ForEach(model.environments) { environment in + HStack { + VStack(alignment: .leading) { + Text(environment.label) + Text(environment.endpoint.absoluteString).font(.caption).foregroundStyle(.secondary) + } + Spacer() + Button("Connect") { model.select(environment) }.disabled(model.busy) + Button("Remove…") { confirmation = .forget(environment) }.disabled(model.busy) + } + } + } + Section("This Mac") { + LabeledContent("Native server", value: host.state) + TextField("Data directory", text: $home).disabled(host.isRunning || model.busy) + TextField("Port", text: $port).disabled(host.isRunning || model.busy) + HStack { + Button(host.isRunning ? "Connect to this Mac" : "Start native server") { + model.startLocal(home: home, port: port) + } + .disabled(model.busy) + if host.isRunning { Button("Stop server…") { confirmation = .stop }.disabled(model.busy) } + } + Text( + "The native server owns its data in this directory. Keep it running for phone and iPad access through T3 Connect." + ) + .font(.caption).foregroundStyle(.secondary) + } + Section("Add a remote environment") { + TextField("Server address or pairing link", text: $address) + TextField("Name", text: $label) + SecureField(savedToken ? "Access token" : "Pairing code", text: $credential) + Toggle("Use an existing access token", isOn: $savedToken) + Button("Pair and connect") { + model.pair(address: address, credential: credential, label: label, savedToken: savedToken) + credential = "" + } + .disabled(model.busy || address.isEmpty) + } + Section("T3 Connect account") { + if !model.accountAvailable { + Text( + "T3 Connect is not configured in this build. Package the relay URL, Clerk publishable key, and CLI OAuth client ID to enable sign-in." + ) + .foregroundStyle(.secondary) + } else if model.signedIn { + HStack { + Button("Refresh environments") { model.discover() }.disabled(model.busy) + Button("Sign out") { model.signOut() }.disabled(model.busy && !model.signInPending) + } + ForEach(model.discovered) { environment in + HStack { + VStack(alignment: .leading) { + Text(environment.label) + Text(environment.endpoint.absoluteString).font(.caption).foregroundStyle(.secondary) + } + Spacer() + Button("Connect") { model.pairCloud(environment) }.disabled(model.busy) + Button("Remove from account…") { confirmation = .cloud(environment) }.disabled( + model.busy) + } + } + } else { + Button("Sign in with browser…") { model.signIn() }.disabled(model.busy) + if model.signInPending { Button("Cancel sign-in") { model.cancel() } } + } + HStack { + Button("Link this Mac…") { confirmation = .link }.disabled( + model.busy || !model.signedIn || !host.isRunning) + Button("Unlink selected host…") { confirmation = .unlink }.disabled(model.busy) + Button("Install connector…") { confirmation = .install }.disabled( + model.busy || !host.isRunning) + } + if model.cloudState != .null { + LabeledContent( + "Host link", value: model.cloudState["linked"].bool ? "Linked" : "Not linked") + LabeledContent( + "Managed tunnel", + value: model.cloudState["managedTunnelActive"].bool ? "Active" : "Inactive") + } + } + Section("Clients and pairing links") { + HStack { + Button("Refresh access") { model.refreshAccess() }.disabled(model.busy) + Button("Create phone/iPad pairing link") { model.createPairingLink() }.disabled( + model.busy) + } + if let url = model.pairingURL { + Text("One-time pairing link ready").font(.caption) + Button("Copy pairing link") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(url.absoluteString, forType: .string) + } + } + ForEach(model.clients, id: \.displayText) { client in + HStack { + Text(client["client"]["label"].string ?? "Client") + Text(client["connected"].bool ? "Connected" : "Offline").font(.caption).foregroundStyle( + .secondary) + Spacer() + if client["current"].bool { + Text("This session").font(.caption) + } else { + Button("Revoke…") { confirmation = .revoke(client["sessionId"].string ?? "", false) } + .disabled(model.busy) + } + } + } + ForEach(model.pairingLinks, id: \.displayText) { link in + HStack { + Text(link["label"].string ?? "Pairing link") + Spacer() + Button("Revoke…") { confirmation = .revoke(link["id"].string ?? "", true) }.disabled( + model.busy) + } + } + } + if model.busy { + HStack { + ProgressView().controlSize(.small) + Text("Working…") + Button("Cancel") { model.cancel() } + } + } + } + .formStyle(.grouped) + .alert(item: $confirmation) { confirmation in + Alert( + title: Text(confirmation.title), message: Text(confirmation.message), + primaryButton: .default(Text(confirmation.action)) { perform(confirmation) }, + secondaryButton: .cancel()) + } + .onDisappear { model.cancel() } + } + private func perform(_ action: ConnectionConfirmation) { + switch action { + case .forget(let environment): model.forget(environment) + case .cloud(let environment): model.removeCloud(environment) + case .stop: model.stopLocal() + case .link: model.linkHost() + case .unlink: model.unlinkHost() + case .install: model.installConnector() + case .revoke(let id, let pairing): model.revoke(id: id, pairing: pairing) + } + } +} + +private enum ConnectionConfirmation: Identifiable { + case forget(SavedEnvironment) + case cloud(ConnectEnvironment) + case stop, link, unlink, install + case revoke(String, Bool) + var id: String { title } + var title: String { + switch self { + case .forget: "Remove saved environment?" + case .cloud: "Remove environment from account?" + case .stop: "Stop this Mac's server?" + case .link: "Enable T3 Connect on this Mac?" + case .unlink: "Disable T3 Connect on the selected host?" + case .install: "Install the relay connector?" + case .revoke: "Revoke access?" + } + } + var message: String { + switch self { + case .forget: + "This removes its saved credential from this Mac. Drafts are preserved. Server sessions remain active." + case .cloud: + "T3 Connect access and its managed tunnel will be revoked for all devices. Direct saved connections remain." + case .stop: "Phone and iPad access to this native host will stop until you start it again." + case .link: + "This links the native server to your signed-in T3 account and starts its managed tunnel for remote devices." + case .unlink: "The selected host's managed tunnel and T3 Connect registration will be removed." + case .install: + "The server will download and install its supported relay connector. This does not enable the tunnel until you link this Mac." + case .revoke: "The selected client session or pairing link will lose access immediately." + } + } + var action: String { + switch self { + case .forget, .cloud: "Remove" + case .stop: "Stop" + case .link: "Link this Mac" + case .unlink: "Unlink" + case .install: "Install" + case .revoke: "Revoke" + } + } +} diff --git a/apps/macos/Sources/T3Native/NativeContextBanner.swift b/apps/macos/Sources/T3Native/NativeContextBanner.swift new file mode 100644 index 0000000000000..485a6205b7d88 --- /dev/null +++ b/apps/macos/Sources/T3Native/NativeContextBanner.swift @@ -0,0 +1,311 @@ +import AppKit +import T3Core + +/// Mount above the composer and refresh after accepted store updates. +@MainActor final class NativeContextBanner: NSStackView { + var onCompact: (() -> Void)? + private let store: ClientStore + private var connection: RPCConnection? + private var endpoint: URL? + private var revision = UUID() + private var subscription: Task? + private var providers = ContextCompactionProviders() + private var dismissals = ContextResumeDismissals() + private var displayedUsage: ContextUsage? + private var canCompact = false + private var ageTimer: Timer? + private var timerReport: String? + private let resume = NSStackView() + private let meter = NSStackView() + private let subtitle = NSTextField(labelWithString: "") + private let gauge = ContextUsageGauge() + private lazy var compactButton = ComposerPill("", height: 28) { [weak self] in self?.compact() } + private lazy var keepButton = ComposerPill("", height: 28) { [weak self] in self?.dismiss() } + private lazy var meterButton = ActionButton("Context") { [weak self] in self?.showDetails() } + + init(store: ClientStore) { + self.store = store + super.init(frame: .zero) + orientation = .vertical + alignment = .leading + spacing = 8 + translatesAutoresizingMaskIntoConstraints = false + setHuggingPriority(.required, for: .vertical) + resume.orientation = .horizontal + resume.distribution = .fill + resume.alignment = .centerY + resume.spacing = 10 + resume.edgeInsets = NSEdgeInsets(top: 6, left: 12, bottom: 6, right: 8) + resume.wantsLayer = true + resume.layer?.cornerRadius = 12 + let icon = NSImageView( + image: NSImage( + systemSymbolName: "arrow.down.right.and.arrow.up.left", accessibilityDescription: nil)!) + icon.contentTintColor = .secondaryLabelColor + icon.widthAnchor.constraint(equalToConstant: 16).isActive = true + let title = NSTextField(labelWithString: "Resume with less context") + title.font = .systemFont(ofSize: 13, weight: .medium) + title.setContentCompressionResistancePriority(.required, for: .horizontal) + subtitle.font = .systemFont(ofSize: 12) + subtitle.textColor = .secondaryLabelColor + subtitle.lineBreakMode = .byTruncatingTail + subtitle.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + subtitle.setContentHuggingPriority(.defaultLow, for: .horizontal) + compactButton.title = "Compact" + compactButton.contentTintColor = .labelColor + compactButton.alignment = .center + compactButton.setContentCompressionResistancePriority(.required, for: .horizontal) + keepButton.title = "" + keepButton.image = NSImage(systemSymbolName: "xmark", accessibilityDescription: nil) + keepButton.imagePosition = .imageOnly + keepButton.setAccessibilityLabel("Keep full history") + keepButton.toolTip = "Dismiss this reminder and keep full history" + keepButton.widthAnchor.constraint(equalToConstant: 36).isActive = true + resume.addArrangedSubview(icon) + resume.addArrangedSubview(title) + resume.addArrangedSubview(subtitle) + resume.addArrangedSubview(compactButton) + resume.addArrangedSubview(keepButton) + addArrangedSubview(resume) + resume.widthAnchor.constraint(equalTo: widthAnchor).isActive = true + meter.orientation = .horizontal + meter.alignment = .centerY + meter.spacing = 6 + let spacer = NSView() + spacer.setContentHuggingPriority(.defaultLow, for: .horizontal) + meter.addArrangedSubview(spacer) + meter.addArrangedSubview(gauge) + meterButton.bezelStyle = .inline + meterButton.font = .systemFont(ofSize: 11) + meter.addArrangedSubview(meterButton) + addArrangedSubview(meter) + meter.widthAnchor.constraint(equalTo: widthAnchor).isActive = true + gauge.widthAnchor.constraint(equalToConstant: 18).isActive = true + gauge.heightAnchor.constraint(equalToConstant: 18).isActive = true + refresh() + updateAppearance() + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + deinit { subscription?.cancel() } + + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + updateAppearance() + } + + private func updateAppearance() { + let dark = effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua + resume.layer?.backgroundColor = NSColor(calibratedWhite: dark ? 0.065 : 0.98, alpha: 1).cgColor + resume.layer?.borderColor = NSColor(calibratedWhite: dark ? 0.13 : 0.88, alpha: 1).cgColor + resume.layer?.borderWidth = 1 + } + + func refresh() { + refreshConnection() + let execution = store.execution + displayedUsage = store.isNewConversation ? nil : execution.contextUsage + guard let usage = displayedUsage, let threadID = store.selectedID else { + let reserveMeter = + !store.isNewConversation && store.selectedThread != nil + && store.thread?.synchronized != true + && providers.reportsContextWindow( + instance: store.selectedThread?.model["instanceId"].string) != false + isHidden = !reserveMeter + resume.isHidden = true + meterButton.title = "Loading context…" + meterButton.isEnabled = false + gauge.usage = nil + ageTimer?.invalidate() + timerReport = nil + return + } + isHidden = false + meterButton.isEnabled = true + let selectedProvider = execution.selectedProviderThread + let instance = + selectedProvider["providerInstanceId"].string + ?? store.selectedThread?.model["instanceId"].string + let driver = + selectedProvider["driver"].string + ?? store.catalog.provider(for: store.selectedThread?.model ?? .null)?.driver + let dismissalKey = + "t3native:resume-compaction-dismissed:\(store.environmentKey):\(instance ?? "claudeAgent")" + if execution.hasDismissedResumeCompaction && !UserDefaults.standard.bool(forKey: dismissalKey) { + UserDefaults.standard.set(true, forKey: dismissalKey) + } + canCompact = + store.canEditThread && !store.composerBusy && execution.activeRunID == nil + && execution.hasCompactableConversation && store.thread?.pendingRequests.isEmpty == true + && store.planFollowUp == nil && store.queuedEdit == nil + && !store.importingAttachments.contains(store.composerDraftID ?? "") + && store.catalogReady && connection === store.browserConnection + && endpoint == store.browserEndpoint + && driver.map { + providers.supportsCompact(driver: $0, instance: instance, lockedInstance: instance) + } == true + compactButton.isEnabled = canCompact + compactButton.toolTip = + canCompact + ? "Summarize earlier context before continuing" + : "Compaction requires an idle connected thread and a compatible provider with /compact support." + resume.isHidden = + !usage.shouldOfferResume(provider: driver) + || execution.activeRunID != nil + || store.thread?.pendingRequests.contains(where: \.isQuestion) == true + || UserDefaults.standard.bool(forKey: dismissalKey) + || dismissals.contains(environment: store.environmentKey, thread: threadID, usage: usage) + subtitle.stringValue = "\(ContextUsage.formatTokens(usage.usedTokens)) tokens from earlier" + gauge.usage = usage + let percent = usage.formattedPercentage.map { " · \($0)" } ?? "" + meterButton.title = "\(ContextUsage.formatTokens(usage.usedTokens)) tokens\(percent)" + meterButton.contentTintColor = usage.isOverloaded ? .systemRed : .secondaryLabelColor + meterButton.setAccessibilityLabel("Context usage: \(meterButton.title)") + meterButton.toolTip = "Show context usage and compaction options" + scheduleAgeRefresh(usage, driver: driver) + } + + private func refreshConnection() { + guard connection !== store.browserConnection || endpoint != store.browserEndpoint else { + return + } + subscription?.cancel() + subscription = nil + revision = UUID() + connection = store.browserConnection + endpoint = store.browserEndpoint + providers = ContextCompactionProviders() + guard let connection, let endpoint else { return } + let gate = NativeSettingsResponseGate( + connection: connection, endpoint: endpoint, revision: revision) + subscription = Task { [weak self] in + do { + guard !Task.isCancelled, let currentStore = self?.store, + let currentRevision = self?.revision, + gate.accepts( + connection: currentStore.browserConnection, endpoint: currentStore.browserEndpoint, + revision: currentRevision) + else { return } + let stream = try await connection.subscribe("subscribeServerConfig") + defer { Task { await connection.cancel(stream.id) } } + for try await event in stream.values { + guard !Task.isCancelled, let self, + gate.accepts( + connection: store.browserConnection, endpoint: store.browserEndpoint, + revision: revision) + else { return } + providers.apply(event) + refresh() + } + throw T3Error("Context capability updates ended. Reconnect to compact context.") + } catch { + guard !Task.isCancelled, let self, + gate.accepts( + connection: store.browserConnection, endpoint: store.browserEndpoint, revision: revision + ) + else { return } + providers = ContextCompactionProviders() + refresh() + meterButton.toolTip = "Context capabilities unavailable: \(error.localizedDescription)" + } + } + } + + private func scheduleAgeRefresh(_ usage: ContextUsage, driver: String?) { + let key = "\(store.environmentKey):\(store.selectedID ?? ""):\(usage.updatedAt):\(driver ?? "")" + guard key != timerReport else { return } + timerReport = key + ageTimer?.invalidate() + guard driver == "claudeAgent", usage.usedTokens >= 100_000, + let reported = ThreadDates.parse(usage.updatedAt) + else { return } + let delay = reported.addingTimeInterval(70 * 60).timeIntervalSinceNow + guard delay > 0 else { return } + ageTimer = Timer.scheduledTimer(withTimeInterval: delay, repeats: false) { [weak self] _ in + Task { @MainActor [weak self] in self?.refresh() } + } + } + private func dismiss() { + guard let usage = displayedUsage, let thread = store.selectedID else { return } + dismissals.dismiss(environment: store.environmentKey, thread: thread, usage: usage) + refresh() + } + private func compact() { + refresh() + guard canCompact else { return } + onCompact?() + } + private func showDetails() { + refresh() + guard let usage = displayedUsage else { return } + let menu = NSMenu() + func detail(_ text: String) { + let item = NSMenuItem(title: text, action: nil, keyEquivalent: "") + item.isEnabled = false + menu.addItem(item) + } + let model = + store.catalog.model(for: store.selectedThread?.model ?? .null)?.name + ?? store.selectedThread?.model["model"].string + detail(model ?? "Context window") + detail("Used: \(ContextUsage.formatTokens(usage.usedTokens)) tokens") + if let max = usage.maxTokens { detail("Capacity: \(ContextUsage.formatTokens(max)) tokens") } + if let remaining = usage.remainingTokens { + detail("Remaining: \(ContextUsage.formatTokens(remaining)) tokens") + } + for (key, label) in [ + ("totalProcessedTokens", "Total processed"), ("inputTokens", "Input"), + ("cachedInputTokens", "Cached input"), ("outputTokens", "Output"), + ("reasoningOutputTokens", "Reasoning output"), ("lastUsedTokens", "Last request"), + ("lastInputTokens", "Last input"), ("lastCachedInputTokens", "Last cached input"), + ("lastOutputTokens", "Last output"), ("lastReasoningOutputTokens", "Last reasoning output"), + ("toolUses", "Tool uses"), + ] { + if let value = usage.number(key) { detail("\(label): \(ContextUsage.formatTokens(value))") } + } + if let duration = usage.number("durationMs") { + detail("Duration: \(String(format: "%.1f", duration / 1000)) seconds") + } + if let percent = usage.formattedPercentage { detail("Context used: \(percent)") } + if case .number(let amount) = usage.fields["cost"]["amount"], amount.isFinite, + let currency = usage.fields["cost"]["currency"].string + { + detail( + "Cost: \(currency) \(String(format: abs(amount) > 0 && abs(amount) < 0.01 ? "%.4f" : "%.2f", amount))" + ) + } + if usage.compactsAutomatically == true { + if let threshold = usage.number("autoCompactThreshold"), threshold > 0 { + detail("Compacts automatically at \(String(format: "%.0f", threshold)) tokens.") + } else { + detail( + model.map { "Context for \($0) compacts automatically when needed." } + ?? "Context compacts automatically when needed.") + } + } + menu.addItem(.separator()) + let compact = ActionMenuItem("Compact context") { [weak self] in self?.compact() } + compact.isEnabled = canCompact + menu.addItem(compact) + menu.popUp(positioning: nil, at: NSPoint(x: 0, y: meterButton.bounds.maxY + 3), in: meterButton) + } +} + +@MainActor private final class ContextUsageGauge: NSView { + var usage: ContextUsage? { didSet { if oldValue != usage { needsDisplay = true } } } + override func draw(_ dirtyRect: NSRect) { + let rect = bounds.insetBy(dx: 2, dy: 2) + let base = NSBezierPath(ovalIn: rect) + base.lineWidth = 2 + NSColor.separatorColor.setStroke() + base.stroke() + guard let usage, let percent = usage.usedPercentage, percent > 0 else { return } + let arc = NSBezierPath() + arc.lineWidth = 2 + arc.lineCapStyle = .round + arc.appendArc( + withCenter: NSPoint(x: bounds.midX, y: bounds.midY), radius: rect.width / 2, + startAngle: 90, endAngle: 90 - CGFloat(percent) * 3.6, clockwise: true) + (usage.isOverloaded ? NSColor.systemRed : NSColor.secondaryLabelColor).setStroke() + arc.stroke() + } +} diff --git a/apps/macos/Sources/T3Native/NativeDraftSmoke.swift b/apps/macos/Sources/T3Native/NativeDraftSmoke.swift new file mode 100644 index 0000000000000..208a4c402b77e --- /dev/null +++ b/apps/macos/Sources/T3Native/NativeDraftSmoke.swift @@ -0,0 +1,336 @@ +import Foundation +import T3Core + +@MainActor enum NativeDraftSmoke { + @discardableResult + static func run(settings: ConnectionSettings, root: String, projectID: String? = nil) async throws + -> URL + { + #if !arch(arm64) + throw T3Error("New draft smoke requires Apple Silicon.") + #endif + let evidenceRoot = URL(fileURLWithPath: root).standardizedFileURL.resolvingSymlinksInPath() + guard ["localhost", "127.0.0.1"].contains(settings.endpoint.host), + settings.endpoint.port == 4773, + evidenceRoot.path == "/private/tmp/t3-native-evidence" + || evidenceRoot.path.hasPrefix("/private/tmp/t3-native-evidence/") + || evidenceRoot.path == "/tmp/t3-native-evidence" + || evidenceRoot.path.hasPrefix("/tmp/t3-native-evidence/") + else { + throw T3Error("New draft smoke requires isolated localhost:4773 and /tmp/t3-native-evidence.") + } + let directory = evidenceRoot.appendingPathComponent("new-draft-" + UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let suite = "T3Native.NewDraftSmoke." + UUID().uuidString + guard let preferences = UserDefaults(suiteName: suite) else { + throw T3Error("Could not isolate new draft smoke preferences.") + } + preferences.set(true, forKey: "planModeEnabled") + defer { preferences.removePersistentDomain(forName: suite) } + + let observer = RPCConnection(settings: settings) + let timeout = Task { + try await Task.sleep(for: .seconds(90)) + await observer.close(error: T3Error("New draft smoke timed out.")) + } + defer { + timeout.cancel() + Task { await observer.close() } + } + try await observer.open() + let before = try await serverSnapshot(observer) + let projects = before["active"]["projects"].array.sorted { + ($0["id"].string ?? "") < ($1["id"].string ?? "") + } + guard + let project = projects.first(where: { + $0["workspaceRoot"].string != nil && (projectID == nil || $0["id"].string == projectID) + }) + else { throw T3Error("New draft smoke needs an existing project with a workspace root.") } + var catalog = ProviderCatalog(planModeEnabled: true) + let configurationSnapshot = try await configuration(observer) + try catalog.apply(configurationSnapshot) + let workspaceSettings = configurationSnapshot["config"]["settings"] + let initial = try catalog.newThreadConfiguration(project: project, carrying: nil) + let seed = NewConversationDraft(project: project, configuration: initial) + let draftsDirectory = directory.appendingPathComponent("drafts") + let repository = DraftRepository(directory: draftsDirectory) + try repository.save( + SavedDrafts(messages: [seed.id: "Seed stays unsent"], conversations: [seed.id: seed]), + for: settings.endpoint) + preferences.set( + [settings.endpoint.absoluteString: seed.id], forKey: "selectedThreadByEndpoint") + + // Keep startup on a local selection: selecting a real server thread can mark it visited. + let store = ClientStore(draftDirectory: draftsDirectory, preferences: preferences) + defer { store.disconnect() } + store.connect(settings) + try await wait(store) { store.ready && store.catalogReady && store.selectedID == seed.id } + try check( + store.isNewConversation && store.canEditThread, "Startup did not restore the local seed.") + let expectedWorkspaceMode = try await store.resolveNewDraftWorkspaceMode( + project: project, session: store.projectSessionID) + let expected = try store.catalog.newThreadConfiguration( + project: project, carrying: seed.summary) + store.newThread(projectID: initial.projectID) + store.newThread(projectID: initial.projectID) + store.select(seed.id, force: true) + try await wait(store) { !store.creatingThread && store.conversationDrafts.count == 2 } + try check(store.selectedID == seed.id, "An awaited draft creation stole the current selection.") + guard let created = store.conversationDrafts.values.first(where: { $0.id != seed.id }) else { + throw T3Error("Missing new local conversation draft.") + } + try check( + created.model == expected.model && created.runtimeMode == expected.runtimeMode + && created.interactionMode == expected.interactionMode + && created.workspaceMode == expectedWorkspaceMode, + "New conversation did not use actual configured project/model defaults.") + let stableID = created.id + store.select(stableID) + guard + let provider = store.catalog.providers.first(where: { + $0.available && $0.models.contains(where: { !$0.descriptors.isEmpty }) + }), let model = provider.models.first(where: { !$0.descriptors.isEmpty }) + else { + throw T3Error("New draft smoke needs an available model with configurable parameters.") + } + store.setModel(model.selection(instanceID: provider.id)) + guard + let descriptor = store.catalog.descriptors(for: store.selectedThread?.model ?? .null) + .first(where: { $0["type"].string == "boolean" || !$0["options"].array.isEmpty }), + let optionID = descriptor["id"].string + else { throw T3Error("No usable model parameter in the actual provider catalog.") } + let old = + store.selectedDraft?.model["options"].array.first(where: { + $0["id"].string == optionID + }).map { $0["value"] } ?? .null + let option: JSON + if descriptor["type"].string == "boolean" { + option = .bool(!old.bool) + } else { + option = + descriptor["options"].array.first(where: { $0["id"] != old })?["id"] + ?? descriptor["options"].array[0]["id"] + } + store.setModelOption(optionID, value: option) + let runtime = provider.runtimeModes.last ?? "full-access" + store.setRuntimeMode(runtime) + store.updateConversationDraft { + $0.workspaceMode = "worktree" + $0.branch = "main" + } + let targetProject = projects.last(where: { $0["workspaceRoot"].string != nil }) ?? project + store.setDraftProject(targetProject) + try check( + store.selectedDraft?.branch == nil, "Changing the project retained the old base branch.") + store.updateConversationDraft { $0.branch = "release/native-smoke" } + if provider.showInteractionMode { store.setInteractionMode("plan") } + store.composerText = "Unsent body preserved by the actual ClientStore" + guard let configured = store.selectedDraft else { + throw T3Error("Lost the selected local draft.") + } + try check( + store.selectedID == stableID && configured.id == stableID + && configured.project == targetProject && configured.runtimeMode == runtime + && configured.model["instanceId"].string == provider.id + && configured.model["model"].string == model.id + && configured.model["options"].array.contains(where: { + $0["id"].string == optionID && $0["value"] == option + }), + "Project, model parameter or runtime edits changed the draft identity or were not applied.") + try check( + !provider.showInteractionMode || configured.interactionMode == "plan", + "Local plan mode was not applied.") + try await verifySidebarDrafts(store, seed: seed, created: configured, body: store.composerText) + try check(store.saveDraftsBeforeExit(), "ClientStore could not save the local draft.") + let saved = try repository.load(for: settings.endpoint) + try check( + saved.conversations?[stableID] == configured && saved.submissions.isEmpty + && saved.messages[stableID] == store.composerText + && saved.messages[seed.id] == "Seed stays unsent", + "Saving the new draft lost configuration/body or created a submission.") + let body = store.composerText + store.disconnect() + + guard let reopenedPreferences = UserDefaults(suiteName: suite) else { + throw T3Error("Could not reopen the isolated preferences.") + } + let reopened = ClientStore(draftDirectory: draftsDirectory, preferences: reopenedPreferences) + defer { reopened.disconnect() } + reopened.connect(settings) + try await wait(reopened) { + reopened.ready && reopened.catalogReady && reopened.selectedID == stableID + } + try check( + reopened.selectedDraft == configured && reopened.composerText == body + && reopened.canEditThread && reopened.catalog.planModeEnabled, + "Reopening ClientStore did not restore selection, preferences, body and model parameters.") + try await verifySidebarDrafts(reopened, seed: seed, created: configured, body: body) + let previousSession = reopened.projectSessionID + reopened.disconnect() + reopened.connect(settings) + try await wait(reopened) { + reopened.ready && reopened.catalogReady && reopened.selectedID == stableID + } + try check( + reopened.projectSessionID != previousSession && reopened.selectedDraft == configured + && reopened.composerText == body, + "Reconnect lost the local draft or reused the disconnected session.") + try await verifySidebarDrafts(reopened, seed: seed, created: configured, body: body) + do { + _ = try await reopened.browseProjectFolders( + targetProject.requiredString("workspaceRoot"), session: previousSession) + throw T3Error("Disconnected project session was accepted.") + } catch let error as T3Error where error.message.contains("connection changed") {} + do { + _ = try await reopened.resolveNewDraftWorkspaceMode( + project: targetProject, session: previousSession) + throw T3Error("Disconnected workspace-default session was accepted.") + } catch let error as T3Error where error.message.contains("connection changed") {} + reopened.disconnect() + + try legacyClientStores(settings: settings, directory: directory, preferences: preferences) + let after = try await serverSnapshot(observer) + try check( + threadRecords(before["active"]) == threadRecords(after["active"]) + && threadRecords(before["archived"]) == threadRecords(after["archived"]) + && before["active"]["snapshotSequence"] == after["active"]["snapshotSequence"] + && before["archived"]["snapshotSequence"] == after["archived"]["snapshotSequence"], + "Server threads or orchestration sequence changed during the local-only smoke.") + let report: JSON = .object([ + "passed": .bool(true), "serverPort": .number(4773), "selectedDraftID": .string(stableID), + "serverThreadsBefore": .number(Double(threadRecords(before["active"]).count)), + "serverThreadsAfter": .number(Double(threadRecords(after["active"]).count)), + "archivedThreadsBefore": .number(Double(threadRecords(before["archived"]).count)), + "archivedThreadsAfter": .number(Double(threadRecords(after["archived"]).count)), + "sequenceBefore": before["active"]["snapshotSequence"], + "sequenceAfter": after["active"]["snapshotSequence"], + "modelParameter": .string(optionID), "projectChanged": .bool(project != targetProject), + "resolvedInitialWorkspaceMode": .string(expectedWorkspaceMode), + "initialWorkspaceModeNeedsRepositoryRead": .bool( + WorkspaceDefaults.configuredMode(settings: workspaceSettings, project: project) == nil), + "checks": .array( + [ + "configured defaults", "duplicate-click guard", "selection during awaited creation", + "project and model parameter editing", "stable draft identity", "save and reopen", + "preference restoration", "connection restoration", "stale project session rejection", + "stale workspace-default session rejection", + "merged sidebar retains both local drafts during server thread selection", + "sidebar draft reselection before save, after reopen and after reconnect", + "legacy draft versions 1 through 5", "unchanged server threads and sequence", + ].map(JSON.string)), + ]) + let result = directory.appendingPathComponent("result.json") + try report.data().write(to: result, options: .atomic) + await observer.close() + print("PASS: actual ClientStore local draft smoke. Evidence: \(result.path)") + return result + } + + private static func check(_ condition: Bool, _ message: String) throws { + guard condition else { throw T3Error("New draft smoke: " + message) } + } + + private static func verifySidebarDrafts( + _ store: ClientStore, seed: NewConversationDraft, created: NewConversationDraft, body: String + ) async throws { + guard let serverThread = store.shell.threads.values.sorted(by: { $0.id < $1.id }).first else { + throw T3Error("Sidebar draft smoke needs an existing server thread to navigate away to.") + } + store.select(serverThread.id) + try await wait(store) { + store.selectedID == serverThread.id && store.thread?.synchronized == true + } + // Headless selection reads the thread; no Window calls markVisibleThreadRead here. + let sidebar = store.activeSidebarThreads + try check( + Set(sidebar.keys) == Set(store.shell.threads.keys).union([seed.id, created.id]) + && sidebar[seed.id] == seed.summary && sidebar[created.id] == created.summary + && store.shell.threads.allSatisfy { sidebar[$0.key] == $0.value }, + "Navigating to a server thread hid a local sidebar draft or replaced its metadata.") + guard let seedRow = sidebar[seed.id], let createdRow = sidebar[created.id] else { + throw T3Error("The merged sidebar did not expose both local draft rows.") + } + store.select(seedRow.id) + try check( + store.isNewConversation && store.canEditThread && store.selectedID == seed.id + && store.selectedDraft == seed && store.composerText == "Seed stays unsent", + "Reselecting the seed sidebar row lost its saved body, configuration or identity.") + store.select(createdRow.id) + try check( + store.isNewConversation && store.canEditThread && store.selectedID == created.id + && store.selectedDraft == created && store.composerText == body + && store.activeSidebarThreads[seed.id] == seed.summary + && store.activeSidebarThreads[created.id] == created.summary, + "Reselecting the created sidebar row lost either draft or its saved model/project metadata.") + } + + private static func wait(_ store: ClientStore, until predicate: () -> Bool) async throws { + let deadline = Date().addingTimeInterval(25) + while !predicate() { + try Task.checkCancellation() + if let error = store.storageError ?? store.error { throw T3Error(error) } + guard Date() < deadline else { throw T3Error("Timed out waiting for new draft ClientStore.") } + try await Task.sleep(for: .milliseconds(25)) + } + } + + private static func serverSnapshot(_ connection: RPCConnection) async throws -> JSON { + let subscription = try await connection.subscribe( + "orchestration.subscribeShell", payload: .object(["requestCompletionMarker": .bool(true)])) + defer { Task { await connection.cancel(subscription.id) } } + for try await event in subscription.values where event["kind"].string == "snapshot" { + let archived = try await connection.call( + "orchestration.getArchivedShellSnapshot", payload: .object([:])) + return .object(["active": event["snapshot"], "archived": archived]) + } + throw T3Error("Server did not return the actual shell snapshot.") + } + + private static func configuration(_ connection: RPCConnection) async throws -> JSON { + let subscription = try await connection.subscribe("subscribeServerConfig") + defer { Task { await connection.cancel(subscription.id) } } + for try await event in subscription.values where event["type"].string == "snapshot" { + return event + } + throw T3Error("Server did not return its actual provider configuration.") + } + + private static func threadRecords(_ snapshot: JSON) -> [JSON] { + (snapshot["threads"].array + snapshot["archivedThreads"].array).sorted { + ($0["id"].string ?? "") < ($1["id"].string ?? "") + } + } + + private static func legacyClientStores( + settings: ConnectionSettings, directory: URL, preferences: UserDefaults + ) throws { + for version in 1...5 { + let legacyDirectory = directory.appendingPathComponent("legacy-\(version)") + try FileManager.default.createDirectory( + at: legacyDirectory, withIntermediateDirectories: true) + let repository = DraftRepository(directory: legacyDirectory) + let legacy = Data( + """ + {"version":\(version),"messages":{"older-thread":"Older unsent body"},"questions":{}, + "submissions":{"older-thread":{"text":"Older unsent body", + "command":"older-command","message":"older-message"}}} + """.utf8) + try legacy.write(to: repository.file(for: settings.endpoint), options: .atomic) + let store = ClientStore(draftDirectory: legacyDirectory, preferences: preferences) + // Loading is synchronous. Disconnect before the connection task can select a server thread. + store.connect(settings, selecting: "older-thread") + defer { store.disconnect() } + try check( + store.storageError == nil && store.drafts["older-thread"] == "Older unsent body" + && store.conversationDrafts.isEmpty && store.saveDraftsBeforeExit(), + "ClientStore could not load and save legacy drafts version \(version).") + let saved = try repository.load(for: settings.endpoint) + try check( + saved.submissions["older-thread"]?.command == "older-command" + && saved.submissions["older-thread"]?.message == "older-message" + && saved.submissions["older-thread"]?.launch == nil, + "ClientStore changed an older draft's pending message identity.") + } + } +} diff --git a/apps/macos/Sources/T3Native/NativePreferences.swift b/apps/macos/Sources/T3Native/NativePreferences.swift new file mode 100644 index 0000000000000..ae60b7c1730ee --- /dev/null +++ b/apps/macos/Sources/T3Native/NativePreferences.swift @@ -0,0 +1,431 @@ +import AppKit +import Combine +import Foundation +import T3Core +import UserNotifications + +enum NativeAppearance: String, Codable, CaseIterable { + case system, light, dark + var name: String { rawValue.capitalized } + var appKitAppearance: NSAppearance? { + switch self { + case .system: nil + case .light: NSAppearance(named: .aqua) + case .dark: NSAppearance(named: .darkAqua) + } + } +} + +enum NativeChatWidth: String, Codable, CaseIterable { + case comfortable, wide, full + var name: String { rawValue.capitalized } + var maximumWidth: CGFloat? { + switch self { + case .comfortable: 880 + case .wide: 1200 + case .full: nil + } + } +} + +enum NativeNotificationMode: String, Codable, CaseIterable { + case off, notifications, sound + case notificationsAndSound = "notifications-and-sound" + var name: String { + switch self { + case .off: "Off" + case .notifications: "Notifications only" + case .sound: "Sound only" + case .notificationsAndSound: "Notifications with sound" + } + } + var showsBanner: Bool { self == .notifications || self == .notificationsAndSound } + var playsSound: Bool { self == .sound || self == .notificationsAndSound } +} + +struct NativeAppearanceValues: Codable, Equatable { + var appearance: NativeAppearance = .system + var interfaceFont = "" + var interfaceFontSize = 16 + var codeFont = "" + var codeFontSize = 13 + var chatWidth: NativeChatWidth = .comfortable + var wordWrap = true + + private enum CodingKeys: String, CodingKey { + case appearance, interfaceFont, interfaceFontSize, codeFont, codeFontSize, chatWidth, wordWrap + } + init() {} + init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + appearance = try c.decodeIfPresent(NativeAppearance.self, forKey: .appearance) ?? .system + interfaceFont = try c.decodeIfPresent(String.self, forKey: .interfaceFont) ?? "" + interfaceFontSize = try c.decodeIfPresent(Int.self, forKey: .interfaceFontSize) ?? 16 + codeFont = try c.decodeIfPresent(String.self, forKey: .codeFont) ?? "" + codeFontSize = try c.decodeIfPresent(Int.self, forKey: .codeFontSize) ?? 13 + chatWidth = try c.decodeIfPresent(NativeChatWidth.self, forKey: .chatWidth) ?? .comfortable + wordWrap = try c.decodeIfPresent(Bool.self, forKey: .wordWrap) ?? true + } + func validated() throws -> Self { + guard (12...20).contains(interfaceFontSize), (10...18).contains(codeFontSize), + interfaceFont.utf16.count <= 200, codeFont.utf16.count <= 200, + !(interfaceFont + codeFont).unicodeScalars.contains( + where: CharacterSet.controlCharacters.contains) + else { throw T3Error("Choose an interface size from 12 to 20 and a code size from 10 to 18.") } + var result = self + result.interfaceFont = interfaceFont.trimmingCharacters(in: .whitespacesAndNewlines) + result.codeFont = codeFont.trimmingCharacters(in: .whitespacesAndNewlines) + return result + } + var resolvedInterfaceFont: NSFont { + NSFont(name: interfaceFont, size: CGFloat(interfaceFontSize)) + ?? NSFont.systemFont(ofSize: CGFloat(interfaceFontSize)) + } + var resolvedCodeFont: NSFont { + NSFont(name: codeFont, size: CGFloat(codeFontSize)) + ?? NSFont.monospacedSystemFont(ofSize: CGFloat(codeFontSize), weight: .regular) + } +} + +struct NativeNotificationValues: Codable, Equatable { + var mode: NativeNotificationMode = .off + var completion = true + var attention = true + var failure = true + var whileAppActive = false + private enum CodingKeys: String, CodingKey { + case mode, completion, attention, failure, whileAppActive + } + init() {} + init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + mode = try c.decodeIfPresent(NativeNotificationMode.self, forKey: .mode) ?? .off + completion = try c.decodeIfPresent(Bool.self, forKey: .completion) ?? true + attention = try c.decodeIfPresent(Bool.self, forKey: .attention) ?? true + failure = try c.decodeIfPresent(Bool.self, forKey: .failure) ?? true + whileAppActive = try c.decodeIfPresent(Bool.self, forKey: .whileAppActive) ?? false + } + func allows(_ kind: NativeThreadNotification.Kind) -> Bool { + guard mode != .off else { return false } + switch kind { + case .completion: return completion + case .attention: return attention + case .failure: return failure + } + } +} + +struct NativePreferenceValues: Codable, Equatable { + var appearance = NativeAppearanceValues() + var notifications = NativeNotificationValues() + private enum CodingKeys: String, CodingKey { case appearance, notifications } + init() {} + init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + appearance = try c.decodeIfPresent(NativeAppearanceValues.self, forKey: .appearance) ?? .init() + notifications = + try c.decodeIfPresent(NativeNotificationValues.self, forKey: .notifications) ?? .init() + } +} + +@MainActor final class NativePreferences: ObservableObject { + static let shared = NativePreferences() + @Published private(set) var values = NativePreferenceValues() + @Published private(set) var error: String? + let file: URL + private let changes = PassthroughSubject() + var appearance: NativeAppearanceValues { values.appearance } + var notifications: NativeNotificationValues { values.notifications } + + init(file: URL? = nil) { + self.file = + file + ?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("T3Native/native-preferences.json") + guard FileManager.default.fileExists(atPath: self.file.path) else { return } + do { + var restored = try JSONDecoder().decode( + NativePreferenceValues.self, from: Data(contentsOf: self.file)) + restored.appearance = try restored.appearance.validated() + values = restored + } catch { self.error = "Native preferences could not be read: " + error.localizedDescription } + } + + func update(_ change: (inout NativePreferenceValues) throws -> Void) throws { + do { + var next = values + try change(&next) + next.appearance = try next.appearance.validated() + guard next != values else { return } + try FileManager.default.createDirectory( + at: file.deletingLastPathComponent(), withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700]) + try JSONEncoder().encode(next).write(to: file, options: .atomic) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: file.path) + values = next + error = nil + changes.send(next) + } catch { + self.error = "Native preferences could not be saved: " + error.localizedDescription + throw error + } + } + + /// Emits after a successful save. Retain the returned token for the lifetime of the observer. + func observeChanges(_ change: @escaping (NativePreferenceValues) -> Void) -> AnyCancellable { + changes.sink(receiveValue: change) + } +} + +struct NativeThreadNotification: Equatable { + enum Kind { case completion, attention, failure } + let id: String + let threadID: String + let title: String + let body: String + let kind: Kind +} + +/// Consumes the actual shell contract. Snapshots seed silently, and sequence replay never alerts. +struct NativeNotificationTransitionTracker { + private struct State { + var raw: JSON + var delivered: Set = [] + init(raw: JSON, delivered: Set = []) { + self.raw = raw + self.delivered = delivered + if let request = raw["pendingRuntimeRequest"]["id"].string { + self.delivered.insert("request:" + request) + } + let run = raw["latestRunId"].string ?? "" + if raw["status"].string == "failed" { + self.delivered.insert( + (raw["lastErrorClass"].string == "usage_limit" ? "limited:" : "failed:") + run) + } + if let completed = raw["latestRunCompletedAt"].string { + self.delivered.insert("completed:" + run + ":" + completed) + } + } + } + private var previous: [String: State] = [:] + private var sequence = -1 + mutating func reset() { + previous = [:] + sequence = -1 + } + + mutating func receive(_ event: JSON) -> [NativeThreadNotification] { + if event["kind"].string == "snapshot" { + guard case .array = event["snapshot"]["threads"], + let stamp = event["snapshot"]["snapshotSequence"].int + else { return [] } + previous = Dictionary( + event["snapshot"]["threads"].array.compactMap { raw in + raw["id"].string.map { ($0, State(raw: raw, delivered: previous[$0]?.delivered ?? [])) } + }, uniquingKeysWith: { _, last in last }) + sequence = stamp + return [] + } + guard let stamp = event["sequence"].int, stamp > sequence else { return [] } + sequence = stamp + if event["kind"].string == "thread.removed", let id = event["threadId"].string { + previous.removeValue(forKey: id) + return [] + } + guard event["kind"].string == "thread.updated", let id = event["thread"]["id"].string else { + return [] + } + let raw = event["thread"] + guard var prior = previous[id] else { + previous[id] = State(raw: raw) + return [] + } + let old = prior.raw + prior.raw = raw + defer { previous[id] = prior } + guard raw["archivedAt"] == .null, raw["deletedAt"] == .null, + event["location"].string != "archive", + raw["lineage"]["relationshipToParent"].string != "subagent" + else { return [] } + let run = raw["latestRunId"].string ?? "" + let request = raw["pendingRuntimeRequest"] + let kind: NativeThreadNotification.Kind + let signature: String + let title: String + if let requestID = request["id"].string, request["kind"].string != "auth_refresh", + request != old["pendingRuntimeRequest"] + { + kind = .attention + signature = "request:" + requestID + title = request["kind"].string == "user_input" ? "Input needed" : "Approval needed" + } else if raw["status"].string == "failed", raw["lastErrorClass"].string == "usage_limit", + old["status"] != raw["status"] || old["latestRunId"] != raw["latestRunId"] + || old["lastErrorClass"] != raw["lastErrorClass"] + { + kind = .attention + signature = "limited:" + run + title = "Usage limit reached" + } else if raw["status"].string == "failed", raw["lastErrorClass"].string != "usage_limit", + old["status"] != raw["status"] || old["latestRunId"] != raw["latestRunId"] + { + kind = .failure + signature = "failed:" + run + title = "Thread failed" + } else if ["idle", "completed"].contains(raw["status"].string ?? ""), + raw["activityRunStatus"] == .null, + let completed = raw["latestRunCompletedAt"].string, + ThreadDates.parse(completed) != nil, + raw["latestRunCompletedAt"] != old["latestRunCompletedAt"], + !run.isEmpty, + !raw["pendingBackgroundTasks"].array.contains(where: { $0["kind"].string != "command" }) + { + kind = .completion + signature = "completed:" + run + ":" + completed + title = "Thread completed" + } else { + return [] + } + guard prior.delivered.insert(signature).inserted else { return [] } + // One run can contain several input requests; bound dedup state to the current run. + if old["latestRunId"] != raw["latestRunId"] { prior.delivered = [signature] } + return [ + .init( + id: id + ":" + signature, threadID: id, title: title, + body: raw["title"].string ?? "Thread", kind: kind) + ] + } +} + +@MainActor +final class NativeNotificationPresenter: NSObject, ObservableObject, + UNUserNotificationCenterDelegate +{ + private let store: ClientStore + private let preferences: NativePreferences + private var tracker = NativeNotificationTransitionTracker() + private var connection: RPCConnection? + private var endpoint: URL? + @Published private(set) var error: String? + var onOpenThread: ((URL, String) -> Void)? + + init(store: ClientStore, preferences: NativePreferences = .shared) { + self.store = store + self.preferences = preferences + super.init() + } + + /// Register once at app startup to handle clicks on notifications delivered in an earlier session. + func activate() { + if Bundle.main.bundleURL.pathExtension == "app" { + UNUserNotificationCenter.current().delegate = self + } + } + + /// Parent calls on connection changes and before forwarding shell events. + func refreshConnection() { + guard connection !== store.browserConnection || endpoint != store.browserEndpoint else { + return + } + connection = store.browserConnection + endpoint = store.browserEndpoint + tracker.reset() + } + + /// Forward accepted shell events, including the initial snapshot, using their issuing connection. + func receive(_ event: JSON, connection issued: RPCConnection, endpoint issuedEndpoint: URL) { + refreshConnection() + guard connection === issued, endpoint == issuedEndpoint else { return } + for notice in tracker.receive(event) { + guard preferences.notifications.allows(notice.kind), + preferences.notifications.whileAppActive || NSApp?.isActive != true + else { continue } + Task { await present(notice, connection: issued, endpoint: issuedEndpoint) } + } + } + + static func requestAuthorization() async throws -> Bool { + guard Bundle.main.bundleURL.pathExtension == "app" else { + throw T3Error("Notification permission is available in the installed native app.") + } + let center = UNUserNotificationCenter.current() + return try await center.requestAuthorization(options: [.alert, .sound]) + } + + private func present( + _ notice: NativeThreadNotification, connection issued: RPCConnection, + endpoint issuedEndpoint: URL + ) async { + guard store.browserConnection === issued, store.browserEndpoint == issuedEndpoint, + preferences.notifications.allows(notice.kind), + preferences.notifications.whileAppActive || NSApp?.isActive != true + else { return } + let mode = preferences.notifications.mode + if !mode.showsBanner { + if mode.playsSound { NSSound(named: .init("Glass"))?.play() } + return + } + guard Bundle.main.bundleURL.pathExtension == "app" else { return } + let center = UNUserNotificationCenter.current() + center.delegate = self + let settings = await center.notificationSettings() + guard store.browserConnection === issued, store.browserEndpoint == issuedEndpoint, + preferences.notifications.allows(notice.kind), preferences.notifications.mode.showsBanner, + preferences.notifications.whileAppActive || NSApp?.isActive != true + else { return } + guard + settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional + else { + error = "Enable notifications for T3 Native in System Settings." + return + } + let content = UNMutableNotificationContent() + content.title = notice.title + content.body = notice.body + if preferences.notifications.mode.playsSound { content.sound = .default } + content.userInfo = ["endpoint": issuedEndpoint.absoluteString, "threadID": notice.threadID] + do { + try await center.add( + UNNotificationRequest( + identifier: issuedEndpoint.absoluteString + ":" + notice.id, + content: content, trigger: nil)) + guard store.browserConnection === issued, store.browserEndpoint == issuedEndpoint else { + return + } + error = nil + } catch { + guard store.browserConnection === issued, store.browserEndpoint == issuedEndpoint else { + return + } + self.error = "Notification could not be delivered: " + error.localizedDescription + } + } + + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + let endpoint = response.notification.request.content.userInfo["endpoint"] as? String + let thread = response.notification.request.content.userInfo["threadID"] as? String + Task { @MainActor [weak self] in + if let endpoint, let url = URL(string: endpoint), let thread { + self?.onOpenThread?(url, thread) + } + } + completionHandler() + } + + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification + ) async -> UNNotificationPresentationOptions { + let notificationEndpoint = notification.request.content.userInfo["endpoint"] as? String + return await MainActor.run { + let choices = preferences.notifications + guard choices.whileAppActive, choices.mode.showsBanner, + notificationEndpoint == store.browserEndpoint?.absoluteString + else { return [] } + return choices.mode.playsSound ? [.banner, .sound] : [.banner] + } + } +} diff --git a/apps/macos/Sources/T3Native/NativeSettings.swift b/apps/macos/Sources/T3Native/NativeSettings.swift new file mode 100644 index 0000000000000..79caffefc6ef9 --- /dev/null +++ b/apps/macos/Sources/T3Native/NativeSettings.swift @@ -0,0 +1,830 @@ +import AppKit +import Combine +import SwiftUI +import T3Core + +/// Identity, rather than URL alone, rejects responses from a replaced connection to the same server. +struct NativeSettingsResponseGate { + let connectionID: ObjectIdentifier + let endpoint: URL + let revision: UUID + init(connection: RPCConnection, endpoint: URL, revision: UUID) { + connectionID = ObjectIdentifier(connection) + self.endpoint = endpoint + self.revision = revision + } + func accepts(connection: RPCConnection?, endpoint: URL?, revision: UUID) -> Bool { + guard let connection else { return false } + return ObjectIdentifier(connection) == connectionID && endpoint == self.endpoint + && revision == self.revision + } +} + +enum NativeServerSettingsPatch { + case model(JSON) + case modelOption(selection: JSON, id: String, value: JSON) + case permission(String) + case workspace(String?) + case restartContinuation(Bool) + case usageLimitContinuation(Bool) + case settleOnMerge(Bool) + case settleAfterDays(Int?) + case worktreesDirectory(String) + + func payload() throws -> JSON { + let key: String + let value: JSON + switch self { + case .model(let selection): + key = "defaultModelSelection" + value = selection + case .modelOption(let selection, let id, let option): + guard case .object(var fields) = selection, selection["instanceId"].string != nil, + selection["model"].string != nil, !id.isEmpty + else { throw T3Error("Choose a model first.") } + var options = selection["options"].array.filter { $0["id"].string != id } + if option != .null { options.append(.object(["id": .string(id), "value": option])) } + fields["options"] = .array(options) + key = "defaultModelSelection" + value = .object(fields) + case .permission(let mode): + guard ["approval-required", "auto-accept-edits", "auto", "full-access"].contains(mode) else { + throw T3Error("Choose a supported permission mode.") + } + key = "defaultRuntimeMode" + value = .string(mode) + case .workspace(let mode): + guard mode == nil || ["local", "worktree"].contains(mode!) else { + throw T3Error("Choose a supported workspace mode.") + } + key = "defaultThreadEnvMode" + value = mode.map(JSON.string) ?? .null + case .restartContinuation(let enabled): + key = "continueThreadsAfterServerUpdate" + value = .bool(enabled) + case .usageLimitContinuation(let enabled): + key = "autoResumeLimitedThreads" + value = .bool(enabled) + case .settleOnMerge(let enabled): + key = "sidebarAutoSettleOnMerge" + value = .bool(enabled) + case .settleAfterDays(let days): + guard days == nil || (1...90).contains(days!) else { + throw T3Error("Choose 1 to 90 days, or Never.") + } + key = "sidebarAutoSettleAfterDays" + value = days.map { .number(Double($0)) } ?? .null + case .worktreesDirectory(let directory): + key = "worktreesDirectory" + value = .string(directory.trimmingCharacters(in: .whitespacesAndNewlines)) + } + return .object(["patch": .object([key: value])]) + } +} + +@MainActor final class NativeSettingsModel: ObservableObject { + let store: ClientStore + @Published private(set) var settings: JSON = .null + @Published private(set) var config: JSON = .null + @Published private(set) var catalog = ProviderCatalog() + @Published private(set) var endpoint: URL? + @Published private(set) var busy = false + @Published private(set) var error: String? + @Published private(set) var connectionError: String? + @Published private(set) var threadCount = 0 + @Published private(set) var projectCount = 0 + private var connection: RPCConnection? + private var revision = UUID() + private var subscription: Task? + private var mutation: Task? + var canEdit: Bool { + settings != .null && !busy && connection != nil + && connection === store.browserConnection && endpoint == store.browserEndpoint + } + + init(store: ClientStore) { self.store = store } + + func refresh() { + connectionError = store.error ?? store.storageError + threadCount = store.shell.threads.count + projectCount = store.shell.projects.count + if store.catalogReady { catalog = store.catalog } + guard connection !== store.browserConnection || endpoint != store.browserEndpoint else { + return + } + stop() + connection = store.browserConnection + endpoint = store.browserEndpoint + settings = .null + config = .null + error = nil + guard let connection, let endpoint else { return } + let gate = NativeSettingsResponseGate( + connection: connection, endpoint: endpoint, revision: revision) + busy = true + subscription = Task { [weak self] in + guard let self, !Task.isCancelled, accepts(gate) else { return } + do { + let result = try await connection.call("server.getSettings", payload: .object([:])) + guard accepts(gate) else { return } + try applySettings(result) + busy = false + let stream = try await connection.subscribe("subscribeServerConfig") + defer { Task { await connection.cancel(stream.id) } } + for try await event in stream.values { + try Task.checkCancellation() + guard accepts(gate) else { return } + if event["type"].string == "snapshot" { + config = event["config"] + try applySettings(config["settings"]) + } else if event["type"].string == "settingsUpdated" { + try applySettings(event["payload"]["settings"]) + } + try catalog.apply(event) + } + throw T3Error("Settings updates ended. Reconnect or refresh Settings.") + } catch { + guard !Task.isCancelled, accepts(gate) else { return } + busy = false + self.error = error.localizedDescription + } + } + } + + func reload() { + stop() + connection = nil + endpoint = nil + refresh() + } + + func stop() { + revision = UUID() + subscription?.cancel() + subscription = nil + mutation?.cancel() + mutation = nil + busy = false + } + + func save(_ patch: NativeServerSettingsPatch) { + guard canEdit, let connection, let endpoint else { + error = "Connect to the current server and wait for Settings to load before saving." + return + } + let payload: JSON + do { payload = try patch.payload() } catch { + self.error = error.localizedDescription + return + } + let gate = NativeSettingsResponseGate( + connection: connection, endpoint: endpoint, revision: revision) + busy = true + error = nil + mutation = Task { [weak self] in + guard let self, !Task.isCancelled, accepts(gate) else { return } + do { + let result = try await connection.call("server.updateSettings", payload: payload) + guard accepts(gate) else { return } + try applySettings(result) + busy = false + } catch { + guard !Task.isCancelled, accepts(gate) else { return } + busy = false + self.error = error.localizedDescription + } + } + } + + private func accepts(_ gate: NativeSettingsResponseGate) -> Bool { + gate.accepts( + connection: store.browserConnection, endpoint: store.browserEndpoint, revision: revision) + } + private func applySettings(_ value: JSON) throws { + guard case .object = value else { + throw T3Error("The server returned an invalid settings response.") + } + settings = value + } +} + +@MainActor final class NativeSettingsWindow: NSWindowController { + private let model: NativeSettingsModel + private let preferences: NativePreferences + private var observation: AnyCancellable? + private var closeObservation: AnyCancellable? + var onAppearanceChange: (() -> Void)? + + init(store: ClientStore, preferences: NativePreferences = .shared) { + self.preferences = preferences + model = NativeSettingsModel(store: store) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 900, height: 730), + styleMask: [.titled, .closable, .resizable, .miniaturizable], backing: .buffered, defer: false + ) + super.init(window: window) + window.title = "Settings" + window.isReleasedWhenClosed = false + window.minSize = NSSize(width: 760, height: 560) + window.appearance = preferences.appearance.appearance.appKitAppearance + window.contentView = NSHostingView( + rootView: NativeSettingsPages(model: model, preferences: preferences)) + window.center() + var previous = preferences.appearance + observation = preferences.observeChanges { [weak self] values in + guard previous != values.appearance else { return } + previous = values.appearance + self?.window?.appearance = values.appearance.appearance.appKitAppearance + self?.onAppearanceChange?() + } + closeObservation = NotificationCenter.default.publisher( + for: NSWindow.willCloseNotification, object: window + ) + .sink { [weak self] _ in Task { @MainActor in self?.model.stop() } } + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + override func showWindow(_ sender: Any?) { + let wasVisible = window?.isVisible == true + super.showWindow(sender) + if wasVisible { model.refresh() } else { model.reload() } + } + /// Parent calls from its existing store change callback to follow disconnects and server switches. + func refresh() { if window?.isVisible == true { model.refresh() } } +} + +private enum NativeSettingsPage: String, CaseIterable, Identifiable { + case general = "General" + case appearance = "Appearance" + case providers = "Providers" + case scheduledTasks = "Scheduled Tasks" + case connections = "Connections" + case storage = "Storage" + var id: String { rawValue } + var symbol: String { + switch self { + case .general: "gearshape" + case .appearance: "paintpalette" + case .providers: "cpu" + case .scheduledTasks: "calendar.badge.clock" + case .connections: "network" + case .storage: "internaldrive" + } + } +} + +private struct NativeSettingsPages: View { + @ObservedObject var model: NativeSettingsModel + @ObservedObject var preferences: NativePreferences + @Environment(\.colorScheme) private var colorScheme + @Environment(\.colorSchemeContrast) private var contrast + @State private var selected: NativeSettingsPage = .general + @State private var permissionResult: String? + @State private var worktreeDirectory = "" + @State private var worktreeDirty = false + @State private var interfaceFont = "" + @State private var codeFont = "" + @State private var requestingPermission = false + private let fontFamilies = NSFontManager.shared.availableFontFamilies.sorted() + + var body: some View { + HStack(spacing: 0) { + VStack(alignment: .leading, spacing: 5) { + Text("Settings").font(.system(size: 15, weight: .semibold)) + .padding(.horizontal, 12).padding(.top, 8).padding(.bottom, 20) + ForEach(NativeSettingsPage.allCases) { page in + Button { + selected = page + } label: { + HStack(spacing: 10) { + Image(systemName: page.symbol) + .frame(width: 18).foregroundStyle(.secondary) + .accessibilityHidden(true) + Text(page.rawValue) + Spacer(minLength: 0) + } + .font(.system(size: 13, weight: selected == page ? .medium : .regular)) + .foregroundStyle(selected == page ? .primary : .secondary) + .padding(.horizontal, 12).padding(.vertical, 10) + .contentShape(RoundedRectangle(cornerRadius: 7)) + .background( + selected == page ? selectionColor : .clear, + in: RoundedRectangle(cornerRadius: 7)) + }.buttonStyle(.plain).accessibilityAddTraits(selected == page ? .isSelected : []) + } + Spacer() + Label( + model.endpoint?.host ?? "Not connected", + systemImage: model.endpoint == nil ? "wifi.slash" : "server.rack" + ) + .font(.system(size: 11)).foregroundStyle(.secondary).lineLimit(2).padding(12) + }.padding(12).frame(width: 200).background(sidebarColor) + Divider() + VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: 14) { + HStack { + VStack(alignment: .leading, spacing: 5) { + Text(selected.rawValue).font(.system(size: 22, weight: .semibold)) + .accessibilityAddTraits(.isHeader) + Text(subtitle).font(.system(size: 12)).foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer() + if model.busy { + ProgressView().controlSize(.small).accessibilityLabel("Saving or loading settings") + } + if selected != .appearance { + Button { + model.reload() + } label: { + Image(systemName: "arrow.clockwise") + } + .buttonStyle(.borderless).help("Reload settings from the current server") + .accessibilityLabel("Reload settings").disabled(model.busy) + } + } + if let error = preferences.error { errorRow(error) } + if let error = model.error { errorRow(error) } + if let error = model.connectionError { errorRow(error) } + if model.endpoint == nil && selected != .appearance { + Label("Connect to a T3 server to edit its settings.", systemImage: "wifi.slash") + .font(.callout).foregroundStyle(.secondary) + } + }.padding(.horizontal, 28).padding(.top, 26).padding(.bottom, 24) + switch selected { + case .providers: + ProviderSettingsView(store: model.store).scrollContentBackground(.hidden) + case .scheduledTasks: + ScheduledTasksView(store: model.store).scrollContentBackground(.hidden) + case .connections: + ConnectionsSettingsView(store: model.store).scrollContentBackground(.hidden) + case .general, .appearance, .storage: + ScrollView { + VStack(alignment: .leading, spacing: 22) { + switch selected { + case .general: general + case .appearance: appearance + case .storage: storage + default: EmptyView() + } + }.padding(.horizontal, 28).padding(.bottom, 32) + .frame(maxWidth: 850, alignment: .leading).frame(maxWidth: .infinity) + } + } + }.frame(maxWidth: .infinity, maxHeight: .infinity) + .background(pageColor) + } + .onAppear { + syncLocalFields() + worktreeDirectory = model.settings["worktreesDirectory"].string ?? "" + } + .onChange(of: preferences.appearance) { _, _ in syncLocalFields() } + .onChange(of: model.settings["worktreesDirectory"]) { _, value in + let saved = value.string ?? "" + if worktreeDirectory.trimmingCharacters(in: .whitespacesAndNewlines) == saved { + worktreeDirty = false + } + if !worktreeDirty { worktreeDirectory = saved } + } + .onChange(of: model.endpoint) { _, _ in + worktreeDirty = false + worktreeDirectory = model.settings["worktreesDirectory"].string ?? "" + } + } + + // Match SurfaceView while following the window's effective appearance, including System. + private var pageColor: Color { Color(white: colorScheme == .dark ? 0.045 : 0.99) } + private var sidebarColor: Color { Color(white: colorScheme == .dark ? 0.035 : 0.95) } + private var cardColor: Color { Color(white: colorScheme == .dark ? 0.075 : 1) } + private var selectionColor: Color { + Color.primary.opacity(contrast == .increased ? 0.18 : 0.08) + } + private var separatorColor: Color { + Color.primary.opacity(contrast == .increased ? 0.28 : 0.08) + } + + private var subtitle: String { + switch selected { + case .general: "Thread defaults and automation for the connected server." + case .appearance: "Make this Mac feel like your workspace." + case .providers: "Models, accounts and provider configuration." + case .scheduledTasks: "Work that runs on a schedule." + case .connections: "Manage your server connections." + case .storage: "Workspace locations and diagnostics." + } + } + + private var general: some View { + VStack(alignment: .leading, spacing: 22) { + group( + "New threads", + footer: + "One global default for this server. Existing threads keep their settings; existing project overrides still apply." + ) { + row("Model", detail: "Choose from this server’s available providers") { + Menu { + Button("Use server fallback") { model.save(.model(.null)) } + ForEach(model.catalog.providers) { provider in + Section(provider.name) { + ForEach(provider.models) { candidate in + Button(candidate.name) { + model.save( + .model( + candidate.selection( + instanceID: provider.id, + preserving: model.settings["defaultModelSelection"], + planModeEnabled: model.catalog.planModeEnabled))) + }.disabled(!provider.available) + } + } + } + } label: { + Text(modelLabel).lineLimit(1).frame(maxWidth: 250, alignment: .trailing) + } + } + ForEach(reasoningDescriptors, id: \.displayText) { descriptor in + descriptorRow(descriptor) + } + row("Permissions", detail: "Access allowed for new threads") { + Picker( + "Permissions", + selection: Binding( + get: { model.settings["defaultRuntimeMode"].string ?? "full-access" }, + set: { model.save(.permission($0)) }) + ) { + ForEach(permissionModes, id: \.self) { Text(permissionName($0)).tag($0) } + }.labelsHidden().frame(width: 205) + } + row("Workspace", detail: "Where new threads start") { + Picker( + "Workspace", + selection: Binding( + get: { model.settings["defaultThreadEnvMode"].string ?? "inherit" }, + set: { model.save(.workspace($0 == "inherit" ? nil : $0)) }) + ) { + Text("Use repository default").tag("inherit") + Text("Local checkout").tag("local") + Text("New worktree").tag("worktree") + }.labelsHidden().frame(width: 205) + } + }.disabled(!model.canEdit) + group( + "Recovery", + footer: + "The server resumes eligible work. These choices apply even when this Mac is offline." + ) { + toggleRow( + "Resume interrupted threads", detail: "Continue eligible threads after a server restart", + key: "continueThreadsAfterServerUpdate", + patch: NativeServerSettingsPatch.restartContinuation) + toggleRow( + "Continue after usage limits reset", + detail: "Automatically resume threads paused by a usage limit", + key: "autoResumeLimitedThreads", patch: NativeServerSettingsPatch.usageLimitContinuation) + }.disabled(!model.canEdit) + group( + "Automatic settling", + footer: + "Settled threads stay available in the sidebar. The server manages eligibility and timing." + ) { + toggleRow( + "Settle after merging", detail: "Settle eligible threads when their pull request merges", + key: "sidebarAutoSettleOnMerge", patch: NativeServerSettingsPatch.settleOnMerge) + row("Settle inactive threads", detail: "Days since the last activity") { + HStack(spacing: 8) { + Toggle( + "Enable automatic settling", + isOn: Binding( + get: { model.settings["sidebarAutoSettleAfterDays"] != .null }, + set: { model.save(.settleAfterDays($0 ? 3 : nil)) }) + ).labelsHidden().toggleStyle(.switch).controlSize(.small) + if let days = model.settings["sidebarAutoSettleAfterDays"].int { + Stepper( + "\(days) days", + value: Binding(get: { days }, set: { model.save(.settleAfterDays($0)) }), in: 1...90 + ) + .frame(width: 120) + } else { + Text("Never").foregroundStyle(.secondary) + } + } + } + }.disabled(!model.canEdit) + notifications + } + } + + private var modelLabel: String { + let selection = model.settings["defaultModelSelection"] + if selection == .null { return "Use server fallback" } + guard let candidate = model.catalog.model(for: selection) else { + return (selection["model"].string ?? "Saved model") + " (unavailable)" + } + return candidate.name + } + private var reasoningDescriptors: [JSON] { + model.catalog.descriptors(for: model.settings["defaultModelSelection"]).filter { + let id = $0["id"].string?.lowercased() ?? "" + return id.contains("reason") || id.contains("effort") || id == "thinking" + } + } + private var permissionModes: [String] { + let supported = + model.catalog.provider(for: model.settings["defaultModelSelection"])?.runtimeModes ?? [] + return ["approval-required", "auto-accept-edits", "auto", "full-access"].filter { + supported.isEmpty || supported.contains($0) + || model.settings["defaultRuntimeMode"].string == $0 + } + } + private func permissionName(_ mode: String) -> String { + switch mode { + case "approval-required": "Ask for approval" + case "auto-accept-edits": "Accept edits" + case "auto": "Auto" + case "full-access": "Full access" + default: mode + } + } + @ViewBuilder private func descriptorRow(_ descriptor: JSON) -> some View { + if let id = descriptor["id"].string { + row(descriptor["label"].string ?? "Reasoning", detail: descriptor["description"].string) { + let selection = model.settings["defaultModelSelection"] + let saved = + selection["options"].array.first { $0["id"].string == id }?["value"] + ?? descriptor["currentValue"] + if descriptor["type"].string == "boolean" { + Toggle( + "Reasoning", + isOn: Binding( + get: { saved.bool }, + set: { + model.save(.modelOption(selection: selection, id: id, value: .bool($0))) + }) + ).labelsHidden().toggleStyle(.switch).controlSize(.small) + } else { + Picker( + "Reasoning", + selection: Binding( + get: { saved.string ?? "" }, + set: { + model.save( + .modelOption( + selection: selection, id: id, value: $0.isEmpty ? .null : .string($0))) + }) + ) { + Text("Provider default").tag("") + ForEach(descriptor["options"].array, id: \.displayText) { option in + Text(option["label"].string ?? option["id"].string ?? "Option").tag( + option["id"].string ?? "") + } + }.labelsHidden().frame(width: 205) + } + } + } + } + + private var notifications: some View { + group( + "Notifications", + footer: "Notification choices apply to this Mac. Permission is managed by macOS." + ) { + row("Notify me", detail: "When a thread finishes or needs attention") { + Picker("Notification mode", selection: localBinding(\.notifications.mode)) { + ForEach(NativeNotificationMode.allCases, id: \.self) { Text($0.name).tag($0) } + }.labelsHidden().frame(width: 205) + } + localToggle("Thread completed", key: \.notifications.completion) + localToggle("Input, approvals and usage limits", key: \.notifications.attention) + localToggle("Thread failed", key: \.notifications.failure) + localToggle("Notify while this app is active", key: \.notifications.whileAppActive) + row("macOS permission", detail: permissionResult ?? "Allow banners and sounds for T3 Native") + { + Button(requestingPermission ? "Requesting…" : "Enable notifications…") { + requestingPermission = true + Task { @MainActor in + defer { requestingPermission = false } + do { + let granted = try await NativeNotificationPresenter.requestAuthorization() + permissionResult = + granted + ? "Notifications are allowed." : "Notifications are disabled in System Settings." + } catch { permissionResult = error.localizedDescription } + } + }.disabled(requestingPermission) + } + } + } + + private var appearance: some View { + VStack(alignment: .leading, spacing: 22) { + group("Theme") { + row("Appearance", detail: "Follow macOS or choose a light or dark workspace") { + Picker("Appearance", selection: localBinding(\.appearance.appearance)) { + ForEach(NativeAppearance.allCases, id: \.self) { Text($0.name).tag($0) } + }.labelsHidden().pickerStyle(.segmented).frame(width: 220) + } + } + group( + "Typography", + footer: + "Empty font names use the system interface font and the system monospaced code font." + ) { + fontRow("Interface font", text: $interfaceFont, key: \.appearance.interfaceFont) + row("Interface size") { + Stepper( + "\(preferences.appearance.interfaceFontSize) pt", + value: localBinding(\.appearance.interfaceFontSize), in: 12...20 + ) + .frame(width: 120) + } + fontRow("Code font", text: $codeFont, key: \.appearance.codeFont) + row("Code size") { + Stepper( + "\(preferences.appearance.codeFontSize) pt", + value: localBinding(\.appearance.codeFontSize), in: 10...18 + ) + .frame(width: 120) + } + VStack(alignment: .leading, spacing: 10) { + Text("The quick brown fox jumps over the lazy dog.").font( + Font(preferences.appearance.resolvedInterfaceFont)) + Text("let workspace = thread.project").font(Font(preferences.appearance.resolvedCodeFont)) + .foregroundStyle(.secondary) + }.padding(16).frame(maxWidth: .infinity, alignment: .leading) + } + group("Conversation") { + row("Chat width", detail: "Maximum width on larger windows") { + Picker("Chat width", selection: localBinding(\.appearance.chatWidth)) { + ForEach(NativeChatWidth.allCases, id: \.self) { Text($0.name).tag($0) } + }.labelsHidden().frame(width: 205) + } + localToggle("Wrap code and diffs", key: \.appearance.wordWrap) + } + } + } + + private var storage: some View { + VStack(alignment: .leading, spacing: 22) { + group("This Mac") { + row("Preferences", detail: preferences.file.path) { + Text(preferenceSize).font(.caption).foregroundStyle(.secondary) + copyButton(preferences.file.path) + } + } + group( + "Connected server", + footer: + "Paths belong to the connected server. Changing the worktree location affects new worktrees." + ) { + row("Environment", detail: model.endpoint?.absoluteString ?? "Not connected") { + Text(model.config["environment"]["platform"]["os"].string ?? "").foregroundStyle( + .secondary) + } + row("Workspace records") { + Text("\(model.projectCount) projects · \(model.threadCount) threads in sidebar") + .foregroundStyle(.secondary) + } + VStack(alignment: .leading, spacing: 10) { + Text("Worktree location").font(.system(size: 13, weight: .medium)) + HStack { + TextField( + "Use the server’s default worktrees directory", + text: Binding( + get: { worktreeDirectory }, + set: { + worktreeDirectory = $0 + worktreeDirty = true + }) + ) + .textFieldStyle(.roundedBorder) + .onSubmit(saveWorktreeDirectory) + Button("Save", action: saveWorktreeDirectory).disabled(!worktreeDirty) + } + Text("Leave empty to use /worktrees.").font(.caption).foregroundStyle(.secondary) + }.padding(16).disabled(!model.canEdit) + if let path = model.config["observability"]["logsDirectoryPath"].string { + row("Server logs", detail: path) { copyButton(path) } + } + } + let issues = model.config["issues"].array.compactMap { $0["message"].string } + if !issues.isEmpty { + group("Server configuration issues") { + ForEach(issues, id: \.self) { issue in errorRow(issue).padding(12) } + } + } + } + } + + private var preferenceSize: String { + guard + let bytes = (try? FileManager.default.attributesOfItem(atPath: preferences.file.path)[.size]) + as? NSNumber + else { + return "Not saved yet" + } + return ByteCountFormatter.string(fromByteCount: bytes.int64Value, countStyle: .file) + } + private func saveWorktreeDirectory() { + model.save(.worktreesDirectory(worktreeDirectory)) + } + private func syncLocalFields() { + interfaceFont = preferences.appearance.interfaceFont + codeFont = preferences.appearance.codeFont + } + private func localBinding(_ key: WritableKeyPath) + -> Binding + { + Binding( + get: { preferences.values[keyPath: key] }, + set: { value in + try? preferences.update { $0[keyPath: key] = value } + }) + } + private func localToggle(_ title: String, key: WritableKeyPath) + -> some View + { + row(title) { + Toggle(title, isOn: localBinding(key)).labelsHidden().toggleStyle(.switch).controlSize(.small) + } + } + private func toggleRow( + _ title: String, detail: String, key: String, + patch: @escaping (Bool) -> NativeServerSettingsPatch + ) -> some View { + row(title, detail: detail) { + Toggle( + title, isOn: Binding(get: { model.settings[key].bool }, set: { model.save(patch($0)) }) + ) + .labelsHidden().toggleStyle(.switch).controlSize(.small) + } + } + private func fontRow( + _ title: String, text: Binding, key: WritableKeyPath + ) -> some View { + row(title) { + HStack(spacing: 6) { + TextField("System default", text: text).textFieldStyle(.roundedBorder).frame(width: 170) + .onSubmit { try? preferences.update { $0[keyPath: key] = text.wrappedValue } } + Menu { + Button("System default") { try? preferences.update { $0[keyPath: key] = "" } } + ForEach(fontFamilies, id: \.self) { family in + Button(family) { try? preferences.update { $0[keyPath: key] = family } } + } + } label: { + Image(systemName: "chevron.down") + }.menuStyle(.borderlessButton).fixedSize() + .accessibilityLabel("Choose " + title.lowercased()) + Button("Apply") { try? preferences.update { $0[keyPath: key] = text.wrappedValue } } + } + } + } + private func copyButton(_ value: String) -> some View { + Button { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(value, forType: .string) + } label: { + Image(systemName: "doc.on.doc") + }.buttonStyle(.borderless).help("Copy path") + .accessibilityLabel("Copy path") + } + private func errorRow(_ message: String) -> some View { + Label(message, systemImage: "exclamationmark.triangle").font(.callout).foregroundStyle(.orange) + .textSelection(.enabled).frame(maxWidth: .infinity, alignment: .leading) + } + private func group( + _ title: String, footer: String? = nil, + @ViewBuilder content: () -> Content + ) -> some View { + VStack(alignment: .leading, spacing: 9) { + Text(title).font(.system(size: 12, weight: .medium)).foregroundStyle(.secondary) + .padding(.leading, 16).accessibilityAddTraits(.isHeader) + VStack(spacing: 0) { content() } + .background(cardColor, in: RoundedRectangle(cornerRadius: 10)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .overlay( + RoundedRectangle(cornerRadius: 10).strokeBorder(separatorColor, lineWidth: 1)) + if let footer { + Text(footer).font(.system(size: 11)).foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true).padding(.horizontal, 16) + } + } + } + private func row( + _ title: String, detail: String? = nil, + @ViewBuilder control: () -> Control + ) -> some View { + VStack(spacing: 0) { + HStack(spacing: 18) { + VStack(alignment: .leading, spacing: 4) { + Text(title).font(.system(size: 13, weight: .medium)) + if let detail { + Text(detail).font(.system(size: 12)).foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true).textSelection(.enabled) + } + } + Spacer(minLength: 8) + control().controlSize(.regular) + }.padding(.horizontal, 16).padding(.vertical, 14) + separatorColor.frame(height: 1).padding(.horizontal, 16).accessibilityHidden(true) + } + } +} diff --git a/apps/macos/Sources/T3Native/ParitySmoke.swift b/apps/macos/Sources/T3Native/ParitySmoke.swift new file mode 100644 index 0000000000000..05f43a6f49d58 --- /dev/null +++ b/apps/macos/Sources/T3Native/ParitySmoke.swift @@ -0,0 +1,1901 @@ +import AVFoundation +import CoreGraphics +import Foundation +import T3Core +import T3Markdown + +enum ParitySmoke { + @MainActor static func run() async throws { + guard let file = Arguments.value("--connection-file"), + let project = Arguments.value("--sandbox-project") + else { + throw T3Error( + "--parity-smoke requires a private --connection-file and an isolated --sandbox-project.") + } + let settings = try ConnectionSettings.load(URL(fileURLWithPath: file)) + let connection = RPCConnection(settings: settings) + let timeout = Task { + try await Task.sleep( + for: .seconds(CommandLine.arguments.contains("--plan-smoke") ? 300 : 120)) + await connection.close(error: T3Error("Parity smoke timed out.")) + } + defer { + timeout.cancel() + Task { await connection.close() } + } + try await connection.open() + if let root = Arguments.value("--project-smoke-root") { + try await ProjectSmoke.run(settings: settings, root: root) + return + } + if CommandLine.arguments.contains("--usage-smoke") { + try await usage(settings: settings, connection: connection) + return + } + if let threadID = Arguments.value("--checkpoint-review-thread") { + try await checkpointReview( + connection: connection, settings: settings, threadID: threadID, projectID: project) + return + } + if let threadID = Arguments.value("--review-thread") { + try await reviewChanges( + connection: connection, settings: settings, threadID: threadID, projectID: project) + return + } + if let threadID = Arguments.value("--terminal-thread") { + try await terminal( + connection: connection, settings: settings, threadID: threadID, projectID: project) + return + } + if let threadID = Arguments.value("--workspace-resume-thread") { + try await workspaceProvider( + settings: settings, connection: connection, threadID: threadID, + projectID: project, sendPrompt: false) + return + } + if let threadID = Arguments.value("--workspace-thread") { + try await workspaceFiles( + settings: settings, connection: connection, threadID: threadID, projectID: project) + return + } + if let threadID = Arguments.value("--plan-card-thread") { + try await planCard( + settings: settings, connection: connection, threadID: threadID, projectID: project) + return + } + if let threadID = Arguments.value("--resume-queue-thread") { + let projection = try await connection.call( + "orchestration.getThreadProjection", payload: .object(["threadId": .string(threadID)])) + guard projection["thread"]["projectId"].string == project, + projection["thread"]["title"].string == "Native queue verification" + else { throw T3Error("Resume requires the isolated queue test thread.") } + try await queueSteering(settings: settings, connection: connection, threadID: threadID) + return + } + if let threadID = Arguments.value("--resume-tool-thread") { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + "t3-tool-resume-" + UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let store = ClientStore(draftDirectory: directory) + store.connect(settings, selecting: threadID) + defer { store.disconnect() } + try await waitForStore(store) { store.canEditThread } + guard store.selectedThread?.projectID == project, + store.selectedThread?.title == "Native tool output verification" + else { + throw T3Error("Resume requires the isolated tool-output test thread.") + } + try await toolOutput(store, connection: connection, threadID: threadID, resume: true) + return + } + let configs = try await connection.subscribe("subscribeServerConfig") + var catalog = ProviderCatalog() + for try await event in configs.values { + try catalog.apply(event) + if event["type"].string == "snapshot" { break } + } + await connection.cancel(configs.id) + guard let provider = catalog.providers.first(where: { $0.id == "codex" && $0.available }), + let model = provider.models.first(where: { $0.id == "gpt-6.1-sol" }) ?? provider.models.first + else { + throw T3Error("Parity smoke requires a configured Codex instance.") + } + let id = UUID().uuidString + let selection = model.selection(instanceID: provider.id) + let template = try ThreadSummary( + .object([ + "id": .string(id), "projectId": .string(project), + "title": .string("Native parity verification"), + "modelSelection": selection, "runtimeMode": .string("approval-required"), + ])) + _ = try await connection.call( + "orchestration.dispatchCommand", + payload: Commands.newThread(template: template, id: id, title: "Native parity verification")) + print("Parity thread: \(id)") + if CommandLine.arguments.contains("--browser-smoke") { + try await browser(settings: settings, connection: connection, threadID: id) + return + } + if CommandLine.arguments.contains("--plan-smoke") { + try await plans(settings: settings, connection: connection, threadID: id) + return + } + if CommandLine.arguments.contains("--attachment-smoke") { + try await attachments(settings: settings, connection: connection, threadID: id) + return + } + if CommandLine.arguments.contains("--queue-smoke") { + try await queue(settings: settings, connection: connection, threadID: id) + return + } + if CommandLine.arguments.contains("--question-paging-smoke") { + try await questions(settings: settings, connection: connection, threadID: id) + return + } + func dispatch(_ type: String, fields: [String: JSON]) async throws { + var fields = fields + fields["type"] = .string(type) + fields["threadId"] = .string(id) + fields["commandId"] = .string(UUID().uuidString) + _ = try await connection.call("orchestration.dispatchCommand", payload: .object(fields)) + } + func verify(_ key: String, _ expected: JSON) async throws { + let projection = try await connection.call( + "orchestration.getThreadProjection", payload: .object(["threadId": .string(id)])) + guard projection["thread"][key] == expected else { + throw T3Error("Read-back mismatch for \(key).") + } + } + for mode in provider.runtimeModes.isEmpty + ? ["approval-required", "full-access"] : provider.runtimeModes + { + try await dispatch("thread.runtime-mode.set", fields: ["runtimeMode": .string(mode)]) + try await verify("runtimeMode", .string(mode)) + } + try await dispatch( + "thread.runtime-mode.set", fields: ["runtimeMode": .string("approval-required")]) + try await verify("runtimeMode", .string("approval-required")) + if let other = provider.models.first(where: { $0.id != model.id }) { + let changed = other.selection(instanceID: provider.id, preserving: selection) + try await dispatch("thread.model-selection.set", fields: ["modelSelection": changed]) + try await verify("modelSelection", changed) + } + try await dispatch("thread.model-selection.set", fields: ["modelSelection": selection]) + try await verify("modelSelection", selection) + try await dispatch("thread.interaction-mode.set", fields: ["interactionMode": .string("plan")]) + try await verify("interactionMode", .string("plan")) + print("Provider catalog, model/options, runtime modes, and plan mode round trips passed.") + let draftDirectory = FileManager.default.temporaryDirectory.appendingPathComponent( + "t3-native-smoke-drafts-" + UUID().uuidString) + defer { try? FileManager.default.removeItem(at: draftDirectory) } + let store = ClientStore(draftDirectory: draftDirectory) + store.connect(settings, selecting: id) + defer { store.disconnect() } + try await waitForStore(store) { store.canEditThread && store.catalogReady } + guard store.selectedID == id else { throw T3Error("Controller selected the wrong thread.") } + if let descriptor = catalog.model(for: selection)?.descriptors.first(where: { + $0["type"].string == "select" && $0["options"].array.count > 1 + }), let optionID = descriptor["id"].string, let choice = descriptor["options"].array.last { + store.setModelOption(optionID, value: choice["id"]) + try await waitForStore(store) { + store.canEditThread + && store.selectedThread?.model["options"].array.contains { + $0["id"].string == optionID && $0["value"] == choice["id"] + } == true + } + store.setModel(selection) + try await waitForStore(store) { + store.canEditThread && store.selectedThread?.model == selection + } + } + store.drafts[id] = "Draft survives reconnect" + store.retry() + try await waitForStore(store) { store.canEditThread && store.catalogReady } + guard store.drafts[id] == "Draft survives reconnect" else { + throw T3Error("Controller lost the draft on reconnect.") + } + print("ClientStore model options, shell read-back, and reconnect/draft checks passed.") + store.disconnect() + let reopened = ClientStore(draftDirectory: draftDirectory) + reopened.connect(settings, selecting: id) + try await waitForStore(reopened) { reopened.canEditThread && reopened.catalogReady } + guard reopened.drafts[id] == "Draft survives reconnect" else { + throw T3Error("Draft did not survive a new controller instance.") + } + reopened.disconnect() + store.connect(settings, selecting: id) + try await waitForStore(store) { store.canEditThread && store.catalogReady } + print("Draft restored in a fresh app controller.") + store.rename(id, to: "Native thread lifecycle verification") + try await waitForStore(store) { + !store.lifecycleBusy(id) + && store.selectedThread?.title == "Native thread lifecycle verification" + } + store.setPinned(id, true) + try await waitForStore(store) { + !store.lifecycleBusy(id) && store.selectedThread?.lifecycle.pinned == true + } + store.setSettled(id, true) + try await waitForStore(store) { + !store.lifecycleBusy(id) && store.selectedThread?.lifecycle.settled == true + && store.selectedThread?.lifecycle.pinned == false + } + store.setSettled(id, false) + try await waitForStore(store) { + !store.lifecycleBusy(id) && store.selectedThread?.lifecycle.settled == false + } + let wake = Date().addingTimeInterval(3600) + store.snooze(id, until: wake) + try await waitForStore(store) { + !store.lifecycleBusy(id) && store.selectedThread?.lifecycle.snoozed(at: Date()) == true + } + store.setPinned(id, true) + try await waitForStore(store) { + !store.lifecycleBusy(id) && store.selectedThread?.lifecycle.pinned == true + && store.selectedThread?.lifecycle.snoozedUntil == nil + } + store.setPinned(id, false) + try await waitForStore(store) { + !store.lifecycleBusy(id) && store.selectedThread?.lifecycle.pinned == false + } + store.setAutoSettle(id, enabled: false) + try await waitForStore(store) { + !store.lifecycleBusy(id) && store.selectedThread?.lifecycle.autoSettleDisabled == true + } + store.setAutoSettle(id, enabled: true) + try await waitForStore(store) { + !store.lifecycleBusy(id) && store.selectedThread?.lifecycle.autoSettleDisabled == false + } + store.markVisibleThreadRead() + try await waitForStore(store) { store.selectedThread?.lifecycle.lastVisitedAt != nil } + store.showArchives(true) + try await waitForStore(store) { store.archives.synchronized } + store.setArchived(id, true) + try await waitForStore(store) { + !store.lifecycleBusy(id) && store.shell.threads[id] == nil + && store.archives.threads[id] != nil + } + store.select(id) + try await waitForStore(store) { store.thread?.synchronized == true } + guard !store.canEditThread else { throw T3Error("Archived composer remained editable.") } + store.setArchived(id, false) + try await waitForStore(store) { + !store.lifecycleBusy(id) && store.shell.threads[id] != nil + && store.archives.threads[id] == nil + } + store.showArchives(false) + store.select(id, force: true) + try await waitForStore(store) { store.canEditThread } + guard store.drafts[id] == "Draft survives reconnect" else { + throw T3Error("Thread lifecycle lost the draft.") + } + print( + "Thread rename, pin/settle/snooze, auto-settle, visit, archive/restore, and draft retention round trips passed." + ) + if CommandLine.arguments.contains("--tool-output-smoke") { + try await dispatch( + "thread.interaction-mode.set", fields: ["interactionMode": .string("default")]) + store.rename(id, to: "Native tool output verification") + try await waitForStore(store) { + !store.lifecycleBusy(id) && store.selectedThread?.title == "Native tool output verification" + } + try await toolOutput(store, connection: connection, threadID: id) + return + } + let manualQuestion = CommandLine.arguments.contains("--manual-question") + guard CommandLine.arguments.contains("--question-smoke") || manualQuestion else { + try await dispatch( + "thread.interaction-mode.set", fields: ["interactionMode": .string("default")]) + return + } + if manualQuestion { + store.rename(id, to: "Native question UI verification") + try await waitForStore(store) { + !store.lifecycleBusy(id) && store.selectedThread?.title == "Native question UI verification" + } + } + let stream = try await connection.subscribe( + "orchestration.subscribeThread", + payload: .object([ + "threadId": .string(id), "requestCompletionMarker": .bool(true), + "acceptBoundedSnapshot": .bool(true), + ])) + defer { Task { await connection.cancel(stream.id) } } + _ = try await connection.call( + "orchestration.dispatchCommand", + payload: Commands.message( + threadID: id, + text: + "This is an interaction test. Use the request_user_input tool to ask one question, Which color?, with choices Blue and Green. Wait for the answer, then reply with exactly NATIVE_QUESTION_OK. Do not execute commands, inspect files, or use any other tool.", + commandID: UUID().uuidString, messageID: UUID().uuidString)) + var state = ThreadState(id: id) + var answered = Set() + for try await event in stream.values { + _ = try state.apply(event) + for request in state.pendingRequests + where request.isQuestion && request.canRespond && !answered.contains(request.id) { + if manualQuestion { + print( + "Real provider question is pending in Native question UI verification. Complete it in the app." + ) + return + } + var drafts: [String: QuestionDraft] = [:] + for question in request.questions { + guard let questionID = question["id"].string else { continue } + var draft = QuestionDraft() + if let option = question["options"].array.first { + draft.selected = [option["value"].string ?? option["label"].string ?? ""] + } else { + draft.custom = "Blue" + } + drafts[questionID] = draft + } + guard let answers = request.answers(from: drafts) else { + throw T3Error("Provider question could not be answered.") + } + try await waitForStore(store) { + store.thread?.pendingRequests.contains(where: { $0.id == request.id }) == true + } + guard let actual = store.thread?.pendingRequests.first(where: { $0.id == request.id }) + else { throw T3Error("Controller missed the request.") } + store.questionDrafts[request.id] = drafts + guard actual.answers(from: drafts) == answers else { + throw T3Error("Controller question disagrees with wire projection.") + } + store.respond(actual) + try await waitForStore(store) { + store.thread?.pendingRequests.contains(where: { $0.id == request.id }) == false + } + answered.insert(request.id) + print("Answered one real provider question.") + } + if !answered.isEmpty, state.pendingRequests.isEmpty, + state.rows.contains(where: { + $0.kind == "assistant_message" && !$0.streaming && $0.text.contains("NATIVE_QUESTION_OK") + }) + { + print("Question response, resolved state, and completed provider reply passed.") + return + } + } + throw T3Error("Question stream ended before verification completed.") + } + + @MainActor private static func toolOutput( + _ store: ClientStore, connection: RPCConnection, threadID: String, resume: Bool = false + ) async throws { + let stream = try await connection.subscribe( + "orchestration.subscribeThread", + payload: .object([ + "threadId": .string(threadID), "requestCompletionMarker": .bool(true), + "acceptBoundedSnapshot": .bool(true), + ])) + defer { Task { await connection.cancel(stream.id) } } + if !resume { + _ = try await connection.call( + "orchestration.dispatchCommand", + payload: Commands.message( + threadID: threadID, + text: + "This is a tool-output test. Run exactly `printf NATIVE_TOOL_OUTPUT_OK` once with your shell tool, then reply exactly NATIVE_TOOL_DONE. Do not read, write, or modify files, inspect the workspace, use the network, or run any other command.", + commandID: UUID().uuidString, messageID: UUID().uuidString)) + } + var state = ThreadState(id: threadID) + var fetched = false + var approved = Set() + for try await event in stream.values { + _ = try state.apply(event) + for request in state.pendingRequests where !approved.contains(request.id) { + guard request.kind == "command", request.canRespond, + ["printf NATIVE_TOOL_OUTPUT_OK", "/bin/zsh -lc 'printf NATIVE_TOOL_OUTPUT_OK'"].contains( + request.detail), + request.options.contains(where: { $0["decision"].string == "accept" }) + else { + throw T3Error("Tool-output test stopped at an unexpected approval request.") + } + try await waitForStore(store) { + store.thread?.pendingRequests.contains(where: { $0.id == request.id }) == true + } + guard let actual = store.thread?.pendingRequests.first(where: { $0.id == request.id }) + else { throw T3Error("Controller missed command approval.") } + store.respond(actual, decision: "accept") + try await waitForStore(store) { + store.thread?.pendingRequests.contains(where: { $0.id == request.id }) == false + } + approved.insert(request.id) + print( + "ClientStore approved the exact diagnostic printf command once and observed resolution.") + } + if !fetched, + let row = state.rows.first(where: { + $0.item["outputOmitted"].bool && $0.item["status"].string == "completed" + }) + { + try await waitForStore(store) { + store.thread?.rows.contains(where: { + $0.id == row.id && $0.item["status"].string == "completed" + }) == true + } + guard let current = store.thread?.rows.first(where: { $0.id == row.id }) else { + throw T3Error("Controller missed tool output.") + } + store.loadOutput(current) + try await waitForStore(store) { + store.thread?.rows.contains(where: { + $0.id == row.id && !$0.item["outputOmitted"].bool + && $0.item["output"].displayText.contains("NATIVE_TOOL_OUTPUT_OK") + }) == true + } + fetched = true + print("Real tool output was omitted from the live feed and fetched by ClientStore.") + } + if state.rows.contains(where: { + $0.kind == "assistant_message" && !$0.streaming && $0.text == "NATIVE_TOOL_DONE" + }) { + guard fetched else { + throw T3Error("Provider completed without exercising omitted tool output.") + } + print("Tool-output hydration and completed provider reply passed.") + return + } + } + throw T3Error("Tool-output verification ended before completion.") + } + + @MainActor private static func attachments( + settings: ConnectionSettings, connection: RPCConnection, threadID: String + ) async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + "t3-attachment-smoke-" + UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let source = directory.appendingPathComponent("native-attachment.txt") + let content = Data("NATIVE_ATTACHMENT_BYTES_OK\n".utf8) + try content.write(to: source) + let image = directory.appendingPathComponent("native-image.png") + let imageBytes = Data( + base64Encoded: + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aJ1sAAAAASUVORK5CYII=" + )! + try imageBytes.write(to: image) + let drafts = directory.appendingPathComponent("drafts") + let store = ClientStore(draftDirectory: drafts) + store.connect(settings, selecting: threadID) + try await waitForStore(store) { store.canEditThread && store.catalogReady } + store.rename(threadID, to: "Native attachment verification") + store.addAttachments([source, image]) + try await waitForStore(store) { + store.selectedAttachments.count == 2 + && store.selectedAttachments.allSatisfy { store.attachmentUploads[$0.id] == .ready } + } + let original = store.selectedAttachments + try await store.downloadAttachment( + original[0].wire!, to: directory.appendingPathComponent("download-before.txt")) + guard try Data(contentsOf: directory.appendingPathComponent("download-before.txt")) == content + else { throw T3Error("Uploaded bytes changed.") } + try FileManager.default.removeItem(at: source) + try FileManager.default.removeItem(at: image) + guard store.saveDraftsBeforeExit() else { + throw T3Error("Could not persist the attachment draft.") + } + store.disconnect() + _ = try await connection.call( + "attachments.delete", payload: .object(["attachmentId": .string(original[0].uploadedID!)])) + let restored = ClientStore(draftDirectory: drafts) + restored.connect(settings, selecting: threadID) + defer { restored.disconnect() } + try await waitForStore(restored) { + restored.canEditThread && restored.selectedAttachments.count == 2 + && restored.selectedAttachments.allSatisfy { restored.attachmentUploads[$0.id] == .ready } + } + guard restored.selectedAttachments[0].uploadedID != original[0].uploadedID, + restored.selectedAttachments[1].uploadedID == original[1].uploadedID + else { + throw T3Error("Attachment recovery did not replace only the missing upload.") + } + restored.drafts[threadID] = + "This is an attachment delivery test. Reply exactly NATIVE_ATTACHMENT_DONE without opening the attached files or using any tools." + restored.send() + try await waitForStore(restored) { + !restored.sending.contains(threadID) && restored.selectedAttachments.isEmpty + } + try await waitForStore(restored) { + restored.thread?.rows.contains { + $0.kind == "assistant_message" && !$0.streaming + && $0.text.contains("NATIVE_ATTACHMENT_DONE") + } == true + } + guard let message = restored.thread?.rows.last(where: { $0.kind == "user_message" }), + message.item["attachments"].array.count == 2 + else { + throw T3Error("The accepted message did not contain both attachments.") + } + for attachment in message.item["attachments"].array { + let destination = directory.appendingPathComponent("download-after") + try await restored.downloadAttachment(attachment, to: destination) + let expected = attachment["type"].string == "image" ? imageBytes : content + guard try Data(contentsOf: destination) == expected else { + throw T3Error("Claimed attachment bytes changed.") + } + } + print( + "Attachment upload/download, restart after source removal, expired-upload recovery, real send/reply and claimed bytes passed." + ) + } + + @MainActor private static func questions( + settings: ConnectionSettings, connection: RPCConnection, threadID: String + ) async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + "t3-questions-smoke-" + UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let draftDirectory = directory.appendingPathComponent("drafts") + let source = directory.appendingPathComponent("question-evidence.txt") + let bytes = Data("Native question attachment evidence.\n".utf8) + try bytes.write(to: source) + var store = ClientStore(draftDirectory: draftDirectory) + defer { store.disconnect() } + do { + _ = try await connection.call( + "orchestration.dispatchCommand", + payload: .object([ + "type": .string("thread.interaction-mode.set"), "commandId": .string(UUID().uuidString), + "threadId": .string(threadID), "interactionMode": .string("plan"), + ])) + store.connect(settings, selecting: threadID) + try await waitForStore(store) { store.canEditThread && store.catalogReady } + store.rename(threadID, to: "Native question paging verification") + store.composerText = + "For a UI test, call request_user_input with exactly two questions in one call. First ask Color? with options Blue (recommended) and Green. Second ask Evidence? with options File and Text; I may supply a custom answer or attached file instead. Wait for both answers, then reply exactly NATIVE_QUESTION_PAGING_DONE. Do not inspect files or run any other tools." + store.submitComposer() + try await waitForStore(store) { + !store.sending.contains(threadID) && store.questionRequest?.questions.count == 2 + } + guard let request = store.questionRequest, request.canRespond, + store.catalog.attachmentPolicy.questions, + let firstID = request.questions[0]["id"].string, + let secondID = request.questions[1]["id"].string, + let option = request.questions[0]["options"].array.first + else { + throw T3Error( + "The provider did not issue two answerable questions with attachment support.") + } + store.drafts[threadID] = "Separate draft behind the questions." + let value = option["value"].string ?? option["label"].string ?? "Blue" + store.chooseQuestionOption(value, request: request, question: request.questions[0]) + store.submitComposer() + guard store.activeQuestionProgress?.index == 1, store.composerText.isEmpty else { + throw T3Error("The composer did not advance to the second question.") + } + store.composerText = "Saved custom answer for the second question." + store.addAttachments([source]) + try await waitForStore(store) { + store.selectedAttachments.count == 1 + && store.selectedAttachments.allSatisfy { + store.attachmentUploads[$0.id] == .ready + } + } + let attachment = store.selectedAttachments[0] + store.setQuestionPage(0, request: request) + guard store.selectedAttachments.isEmpty, store.composerText.isEmpty else { + throw T3Error("The second question's files leaked into the first question.") + } + store.setQuestionPage(1, request: request) + guard store.composerText == "Saved custom answer for the second question.", + store.selectedAttachments.map(\.id) == [attachment.id], store.saveDraftsBeforeExit() + else { throw T3Error("Paging lost the question draft or attachment.") } + try FileManager.default.removeItem(at: source) + store.disconnect() + store = ClientStore(draftDirectory: draftDirectory) + store.connect(settings, selecting: threadID) + try await waitForStore(store) { + store.canEditThread && store.catalogReady && store.activeQuestionProgress?.index == 1 + && store.selectedAttachments.count == 1 + && store.selectedAttachments.allSatisfy { store.attachmentUploads[$0.id] == .ready } + } + guard store.composerText == "Saved custom answer for the second question.", + store.questionDrafts[request.id]?[firstID]?.selected == [value], + store.drafts[threadID] == "Separate draft behind the questions." + else { throw T3Error("A question answer or the normal draft did not survive restart.") } + store.composerText = "" + guard store.sendBlockReason == nil else { + throw T3Error("An attachment-only answer was blocked.") + } + store.submitComposer() + try await waitForStore(store) { + store.questionRequest == nil && !store.questionBusy(request) + && store.execution.activeRunID == nil + && store.thread?.rows.contains { + $0.kind == "assistant_message" && !$0.streaming + && $0.text.contains("NATIVE_QUESTION_PAGING_DONE") + } == true + } + let projection = try await connection.call( + "orchestration.getThreadProjection", + payload: .object(["threadId": .string(threadID)])) + guard + let recorded = projection["turnItems"].array.first(where: { + $0["requestId"].string == request.id + })?["questionAnswer"], + recorded["answers"][firstID].string == value, + recorded["answers"][secondID].string?.contains(attachment.name) == true, + recorded["attachmentsByQuestionId"][secondID].array.first?["name"].string + == attachment.name, + store.composerText == "Separate draft behind the questions.", + store.selectedAttachments.isEmpty, store.questionDrafts[request.id] == nil, + store.thread?.rows.first(where: { $0.item["requestId"].string == request.id })?.attachments + .count == 1 + else { + throw T3Error( + "The recorded answer, attachment, or restored main draft differs from the submission.") + } + let downloaded = directory.appendingPathComponent("downloaded.txt") + try await store.downloadAttachment( + recorded["attachmentsByQuestionId"][secondID].array[0], to: downloaded) + guard try Data(contentsOf: downloaded) == bytes else { + throw T3Error("The claimed answer attachment changed bytes.") + } + store.composerText = + "For one more UI test, call request_user_input with one question: Ready? Offer Continue and Stop. After I answer, reply exactly NATIVE_QUESTION_EXTERNAL_DONE. Do not use any other tools." + store.submitComposer() + try await waitForStore(store) { + !store.sending.contains(threadID) && store.questionRequest != nil + } + guard let externalRequest = store.questionRequest else { + throw T3Error("Missing cross-client question.") + } + store.drafts[threadID] = "Separate draft behind the questions." + store.composerText = "Unsent answer superseded by another client." + let unusedSource = directory.appendingPathComponent("unused-answer.txt") + try bytes.write(to: unusedSource) + store.addAttachments([unusedSource]) + try await waitForStore(store) { + store.selectedAttachments.count == 1 + && store.selectedAttachments.allSatisfy { + store.attachmentUploads[$0.id] == .ready + } + } + guard let repository = store.attachmentRepository else { + throw T3Error("Missing answer storage.") + } + let unusedFile = try repository.file(store.selectedAttachments[0]) + var externalAnswers: [String: JSON] = [:] + for question in externalRequest.questions { + guard let id = question["id"].string, let option = question["options"].array.first else { + continue + } + externalAnswers[id] = .string( + option["value"].string ?? option["label"].string ?? "Continue") + } + _ = try await connection.call( + "orchestration.dispatchCommand", + payload: .object([ + "type": .string("runtime-request.respond"), "commandId": .string(UUID().uuidString), + "threadId": .string(threadID), "requestId": .string(externalRequest.id), + "answers": .object(externalAnswers), + ])) + try await waitForStore(store) { + store.questionRequest == nil && store.execution.activeRunID == nil + && store.thread?.rows.contains { + $0.kind == "assistant_message" && !$0.streaming + && $0.text.contains("NATIVE_QUESTION_EXTERNAL_DONE") + } == true + } + guard store.questionDrafts[externalRequest.id] == nil, + !FileManager.default.fileExists(atPath: unusedFile.path), + store.composerText == "Separate draft behind the questions.", + let answeredRow = store.thread?.rows.first(where: { + $0.item["requestId"].string == externalRequest.id + }), externalAnswers.values.allSatisfy({ answeredRow.text.contains($0.string ?? "") }) + else { + throw T3Error( + "Cross-client resolution lost an answer, retained obsolete files, or changed the draft.") + } + print( + "PASS: two real questions, separate answer files, backward/forward paging, restart after source deletion, attachment-only response, claimed bytes, provider completion and cross-client cleanup; normal draft preserved." + ) + } catch { + let projection = try? await connection.call( + "orchestration.getThreadProjection", + payload: .object(["threadId": .string(threadID)])) + for run in projection?["runs"].array ?? [] + where ["preparing", "starting", "running", "waiting"].contains(run["status"].string ?? "") { + _ = try? await connection.call( + "orchestration.dispatchCommand", + payload: .object([ + "type": .string("run.interrupt"), "commandId": .string(UUID().uuidString), + "threadId": .string(threadID), "runId": run["id"], "holdQueue": .bool(true), + ])) + } + throw error + } + } + + @MainActor private static func queue( + settings: ConnectionSettings, connection: RPCConnection, threadID: String + ) async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + "t3-queue-smoke-" + UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let draftDirectory = directory.appendingPathComponent("drafts") + let source = directory.appendingPathComponent("queue-first.txt") + let extra = directory.appendingPathComponent("queue-extra.txt") + try Data("First queued attachment\n".utf8).write(to: source) + try Data("Added while editing\n".utf8).write(to: extra) + var store = ClientStore(draftDirectory: draftDirectory) + defer { store.disconnect() } + do { + _ = try await connection.call( + "orchestration.dispatchCommand", + payload: .object([ + "type": .string("thread.interaction-mode.set"), "commandId": .string(UUID().uuidString), + "threadId": .string(threadID), "interactionMode": .string("plan"), + ])) + store.connect(settings, selecting: threadID) + try await waitForStore(store) { store.canEditThread && store.catalogReady } + store.rename(threadID, to: "Native queue verification") + store.addAttachments([source]) + try await waitForStore(store) { + store.selectedAttachments.count == 1 + && store.selectedAttachments.allSatisfy { store.attachmentUploads[$0.id] == .ready } + } + _ = try await connection.call( + "orchestration.dispatchCommand", + payload: Commands.message( + threadID: threadID, + text: + "For a UI queue test, call request_user_input now with one question: Ready? Offer Continue and Stop. Wait for my answer. After Continue, or if I ask to continue after an interruption, reply exactly NATIVE_QUEUE_READY and finish without asking again. Do not inspect files or run commands.", + commandID: UUID().uuidString, messageID: UUID().uuidString)) + try await waitForStore(store) { + store.thread?.pendingRequests.contains { $0.isQuestion && $0.canRespond } == true + } + guard store.execution.canQueue else { + throw T3Error("The test provider did not advertise queued messages.") + } + store.setDelivery(.queue) + store.drafts[threadID] = + "Reply exactly NATIVE_QUEUE_FIRST. Do not open attachments or use tools." + store.send() + try await waitForStore(store) { + !store.sending.contains(threadID) && store.execution.queued.count == 1 + } + let first = store.execution.queued[0] + store.drafts[threadID] = "Reply exactly NATIVE_QUEUE_REMOVE. Do not use tools." + store.send() + try await waitForStore(store) { + !store.sending.contains(threadID) && store.execution.queued.count == 2 + } + let second = store.execution.queued[1] + store.drafts[threadID] = "Separate normal draft must survive editing." + store.editQueuedMessage(first) + guard store.queuedEdit?.attachments.count == 1 else { + throw T3Error("Queued edit dropped the original attachment.") + } + let edited = "Reply exactly NATIVE_QUEUE_EDITED. Do not open attachments or use tools." + store.composerText = edited + store.addAttachments([extra]) + try await waitForStore(store) { + store.selectedAttachments.count == 1 + && store.selectedAttachments.allSatisfy { store.attachmentUploads[$0.id] == .ready } + } + store.cancelQueuedEdit() + store.editQueuedMessage(second) + let secondEdit = "Unsent edit of the second queued message." + store.composerText = secondEdit + store.cancelQueuedEdit() + guard store.savedQueuedEdits.count == 2, + store.drafts[threadID] == "Separate normal draft must survive editing." + else { throw T3Error("Editing a second queued message lost an earlier draft.") } + store.reopenQueuedEdit(first.id) + guard store.saveDraftsBeforeExit() else { throw T3Error("Queued edit was not saved.") } + store.disconnect() + store = ClientStore(draftDirectory: draftDirectory) + store.connect(settings, selecting: threadID) + try await waitForStore(store) { + store.canEditThread && store.catalogReady && store.queuedEdit != nil + && store.selectedAttachments.allSatisfy { store.attachmentUploads[$0.id] == .ready } + } + guard store.composerText == edited, + store.drafts[threadID] == "Separate normal draft must survive editing.", + store.queuedEdit?.attachments.count == 1, store.selectedAttachments.count == 1, + store.savedQueuedEdits.first?.runID == second.id, + store.drafts["queued-edit:" + second.id] == secondEdit + else { throw T3Error("Queued edit and normal draft did not restore separately.") } + store.send() + try await waitForStore(store) { + !store.sending.contains(threadID) && store.queuedEdit == nil + && store.execution.queued.first?.text == edited + } + guard store.execution.queued[0].attachments.count == 2, + store.drafts[threadID] == "Separate normal draft must survive editing." + else { throw T3Error("Saving the queued edit lost attachments or the normal draft.") } + store.reorderQueuedMessage(second.id, before: first.id) + try await waitForStore(store) { + !store.queueBusy && store.execution.queued.first?.id == second.id + } + store.reorderQueuedMessage(first.id, before: second.id) + try await waitForStore(store) { + !store.queueBusy && store.execution.queued.first?.id == first.id + } + store.cancelQueuedMessage(second.id) + try await waitForStore(store) { !store.queueBusy && store.execution.queued.count == 1 } + store.reopenQueuedEdit(second.id) + guard store.composerText == secondEdit, + store.sendBlockReason?.contains("started or was removed") == true + else { throw T3Error("The cancelled message's saved edit was lost or could be sent.") } + store.cancelQueuedEdit() + store.stop() + try await waitForStore(store) { + store.execution.activeRunID == nil && store.execution.isHeld + && store.execution.resumableRunID != nil + } + store.resume() + try await waitForStore(store) { + store.thread?.pendingRequests.contains { $0.isQuestion && $0.canRespond } == true + || store.thread?.rows.contains { + $0.kind == "assistant_message" && !$0.streaming + && $0.text.contains("NATIVE_QUEUE_EDITED") + } == true + } + if let request = store.thread?.pendingRequests.first(where: { $0.isQuestion && $0.canRespond } + ) { + var drafts: [String: QuestionDraft] = [:] + for question in request.questions { + guard let id = question["id"].string, let option = question["options"].array.first else { + continue + } + var draft = QuestionDraft() + draft.selected = [option["value"].string ?? option["label"].string ?? "Continue"] + drafts[id] = draft + } + store.questionDrafts[request.id] = drafts + store.respond(request) + } + try await waitForStore(store) { + store.execution.queued.isEmpty && store.execution.activeRunID == nil + && store.thread?.rows.contains { + $0.kind == "assistant_message" && !$0.streaming + && $0.text.contains("NATIVE_QUEUE_EDITED") + } == true + } + guard store.drafts[threadID] == "Separate normal draft must survive editing." else { + throw T3Error("Resume overwrote the normal draft.") + } + print( + "PASS: real queued messages, multiple saved edits and attachments across restart, reorder, cancel, stop/hold, resume and provider reply; separate draft preserved." + ) + } catch { + let projection = try? await connection.call( + "orchestration.getThreadProjection", payload: .object(["threadId": .string(threadID)])) + for run in projection?["runs"].array ?? [] { + let status = run["status"].string ?? "" + if status == "queued" || ["preparing", "starting", "running", "waiting"].contains(status) { + _ = try? await connection.call( + "orchestration.dispatchCommand", + payload: .object([ + "type": .string(status == "queued" ? "queued-run.cancel" : "run.interrupt"), + "commandId": .string(UUID().uuidString), "threadId": .string(threadID), + "runId": run["id"], + ])) + } + } + throw error + } + } + + @MainActor private static func queueSteering( + settings: ConnectionSettings, connection: RPCConnection, threadID: String + ) async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + "t3-steer-smoke-" + UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let store = ClientStore(draftDirectory: directory) + store.connect(settings, selecting: threadID) + defer { store.disconnect() } + try await waitForStore(store) { store.canEditThread && store.catalogReady } + do { + for (mode, marker) in [ + (MessageDelivery.queue, "NATIVE_PROMOTED"), (.steer, "NATIVE_DIRECT_STEER"), + (.restart, "NATIVE_RESTARTED"), + ] { + store.setDelivery(.auto) + store.composerText = + "This is a controlled steering test. Ask one request_user_input question: Ready? with Continue and Stop options, then wait. If a new user message arrives, follow it immediately and do not ask another question. Do not use other tools or inspect any files." + store.send() + try await waitForStore(store) { + store.thread?.pendingRequests.contains { $0.isQuestion && $0.canRespond } == true + } + if mode == .restart && !store.execution.canRestart { + store.stop() + try await waitForStore(store) { store.execution.activeRunID == nil } + print( + "Restart control correctly disabled: the real provider does not advertise interrupt/restart steering." + ) + break + } + guard store.execution.canSteer else { + throw T3Error("The test provider did not expose a steerable turn.") + } + store.setDelivery(mode) + store.drafts[threadID] = + "Stop waiting for the previous question. Reply exactly \(marker) and finish without using tools or asking questions." + store.send() + if mode == .queue { + try await waitForStore(store) { + !store.sending.contains(threadID) && store.execution.queued.count == 1 + } + store.steerQueuedMessage(store.execution.queued[0].id) + } + try await waitForStore(store) { + !store.queueBusy && !store.sending.contains(threadID) && store.execution.queued.isEmpty + } + if mode != .restart, + let request = store.thread?.pendingRequests.first(where: { + $0.isQuestion && $0.canRespond + }) + { + var drafts: [String: QuestionDraft] = [:] + for question in request.questions { + guard let id = question["id"].string, let option = question["options"].array.first + else { continue } + var draft = QuestionDraft() + draft.selected = [option["value"].string ?? option["label"].string ?? "Continue"] + drafts[id] = draft + } + store.questionDrafts[request.id] = drafts + store.respond(request) + } + try await waitForStore(store) { + !store.queueBusy && store.execution.queued.isEmpty && store.execution.activeRunID == nil + && store.thread?.rows.contains { + $0.kind == "assistant_message" && !$0.streaming && $0.text.contains(marker) + } == true + } + print("PASS: \(marker) through real provider.") + } + } catch { + if let run = store.execution.activeRunID { + _ = try? await connection.call( + "orchestration.dispatchCommand", + payload: .object([ + "type": .string("run.interrupt"), "commandId": .string(UUID().uuidString), + "threadId": .string(threadID), "runId": .string(run), "holdQueue": .bool(true), + ])) + } + for queued in store.execution.queued { + _ = try? await connection.call( + "orchestration.dispatchCommand", + payload: .object([ + "type": .string("queued-run.cancel"), "commandId": .string(UUID().uuidString), + "threadId": .string(threadID), "runId": .string(queued.id), + ])) + } + throw error + } + } + + @MainActor private static func plans( + settings: ConnectionSettings, connection: RPCConnection, threadID: String + ) async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + "t3-plans-" + UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let preferenceDomain = "t3-native-plan-smoke-" + UUID().uuidString + let preferences = UserDefaults(suiteName: preferenceDomain)! + defer { preferences.removePersistentDomain(forName: preferenceDomain) } + var store = ClientStore(draftDirectory: directory, preferences: preferences) + store.setPlanModeEnabled(true) + defer { store.disconnect() } + var targets = Set([threadID]) + func projection(_ id: String) async throws -> JSON { + try await connection.call( + "orchestration.getThreadProjection", payload: .object(["threadId": .string(id)])) + } + func propose(_ title: String) async throws { + store.setInteractionMode("plan") + try await waitForStore(store) { + store.canEditThread && store.selectedThread?.interactionMode == "plan" + } + store.composerText = + "This is a native plan UI test. Do not use tools, inspect files, or run commands. Return a proposed plan using the native format, titled \(title). The plan must have exactly one implementation step: reply exactly NATIVE_PLAN_IMPLEMENTED without doing anything else. Do not implement yet." + store.send() + try await waitForStore(store, seconds: 60) { + !store.sending.contains(threadID) && store.planFollowUp?.title == title + } + } + do { + store.connect(settings, selecting: threadID) + try await waitForStore(store) { store.canEditThread && store.catalogReady } + store.rename(threadID, to: "Native plan verification") + try await waitForStore(store) { !store.lifecycleBusy(threadID) } + try await propose("Native plan handoff") + let initialRun = store.execution.latestRunID + store.composerText = + "Refine the plan: change its title to Native refined plan. Keep its sole implementation step of replying exactly NATIVE_PLAN_IMPLEMENTED, with no tools, commands, or file changes. Return only the revised proposed plan." + store.submitComposer() + try await waitForStore(store, seconds: 60) { + !store.sending.contains(threadID) && store.planFollowUp?.title == "Native refined plan" + && store.execution.latestRunID != initialRun + } + guard store.selectedThread?.interactionMode == "plan", store.composerText.isEmpty else { + throw T3Error("Refining a plan left planning mode or retained the sent draft.") + } + print("PASS: real proposed plan and composer refinement in planning mode.") + for newThread in [true, false] { + if !newThread { + store.select(threadID) + try await waitForStore(store) { store.canEditThread && store.selectedID == threadID } + try await propose("Native same thread plan") + } + guard let plan = store.planFollowUp, let template = store.selectedThread else { + throw T3Error("Missing actionable plan.") + } + let runtime = store.catalog.defaultRuntimeMode(projectID: template.projectID) + let command = PendingPlanSubmission( + plan: plan, template: template, draft: "", newThread: newThread, + defaultRuntimeMode: runtime) + targets.insert(command.targetThreadID) + store.disconnect() + try DraftRepository(directory: directory).save( + SavedDrafts(messages: [threadID: ""], planSubmissions: [threadID: command]), + for: settings.endpoint) + _ = try await connection.call("orchestration.dispatchCommand", payload: command.setup) + store = ClientStore(draftDirectory: directory, preferences: preferences) + store.connect(settings, selecting: threadID) + try await waitForStore(store) { + store.canEditThread && store.catalogReady && store.pendingPlanSubmission != nil + } + guard store.pendingPlanSubmission == command, store.planFollowUp != nil, + store.sendBlockReason == nil + else { + throw T3Error("A partially committed plan send could not be resumed after restart.") + } + store.submitComposer() + try await waitForStore(store, seconds: 60) { + store.selectedID == command.targetThreadID && store.thread?.synchronized == true + && !store.sending.contains(threadID) && store.execution.activeRunID == nil + && store.thread?.rows.contains(where: { + $0.kind == "assistant_message" && !$0.streaming + && $0.text.contains("NATIVE_PLAN_IMPLEMENTED") + }) == true + } + // Replaying a send whose reply was lost must succeed even though it completed the source plan. + for payload in [command.setup, command.message] { + _ = try await connection.call("orchestration.dispatchCommand", payload: payload) + } + let result = try await projection(command.targetThreadID) + let source = newThread ? try await projection(threadID) : result + guard result["thread"]["interactionMode"].string == "default", + result["messages"].array.filter({ $0["id"] == command.message["messageId"] }).count == 1, + result["runs"].array.first(where: { $0["userMessageId"] == command.message["messageId"] } + )?["sourcePlanRef"] + == command.message["sourcePlanRef"], + source["plans"].array.first(where: { $0["id"].string == plan.id })?["status"].string + == "completed", + result["thread"]["modelSelection"] == template.model, + result["thread"]["branch"].string == template.branch, + result["thread"]["worktreePath"].string == template.worktreePath + else { + throw T3Error( + "Plan implementation lost its source link, workspace, model, or retry identity.") + } + if newThread { + guard result["thread"]["runtimeMode"].string == runtime, + source["thread"]["interactionMode"].string == "plan" + else { + throw T3Error( + "New-thread implementation changed the source mode or ignored project defaults.") + } + } + print( + "PASS: \(newThread ? "new-thread" : "same-thread") implementation \(command.targetThreadID), partial-send restart, stable message, completed source plan, preserved model/workspace and provider reply." + ) + } + } catch { + for id in targets { + let value = try? await projection(id) + for run in value?["runs"].array ?? [] + where ["preparing", "starting", "running", "waiting"].contains(run["status"].string ?? "") { + _ = try? await connection.call( + "orchestration.dispatchCommand", + payload: .object([ + "type": .string("run.interrupt"), "commandId": .string(UUID().uuidString), + "threadId": .string(id), "runId": run["id"], "holdQueue": .bool(true), + ])) + } + } + throw error + } + } + + @MainActor private static func workspaceFiles( + settings: ConnectionSettings, connection: RPCConnection, threadID: String, projectID: String + ) async throws { + let original = try await connection.call( + "orchestration.getThreadProjection", + payload: .object(["threadId": .string(threadID)])) + guard original["thread"]["projectId"].string == projectID, + original["thread"]["title"].string == "Native plan verification" + else { + throw T3Error("Workspace checks require an isolated plan verification thread.") + } + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + "t3-files-" + UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let store = ClientStore(draftDirectory: directory.appendingPathComponent("chat")) + store.connect(settings, selecting: threadID) + defer { store.disconnect() } + try await waitForStore(store) { store.canEditThread } + guard let location = store.workspaceLocation else { throw T3Error("No workspace.") } + let folder = "native-files-" + UUID().uuidString.lowercased() + let path = folder + "/nested/日本語 file.txt" + let originalContents = "First line\r\n🦉 exact UTF-8\r\n" + func write(_ path: String, _ contents: String) async throws { + _ = try await store.workspaceCall( + "projects.writeFile", at: location, + fields: [ + "relativePath": .string(path), "contents": .string(contents), + ]) + } + try await write(path, originalContents) + try await write(folder + "/.gitignore", "ignored/\n") + try await write(folder + "/ignored/hidden.txt", "ignored but browsable\n") + let files = WorkspaceFiles(store: store, directory: directory.appendingPathComponent("files")) + files.refreshContext() + try await waitForFiles(files) { files.directories[""] != nil && !files.loadingTree } + guard files.directories.count == 1, + files.directories[""]?.contains(where: { $0.path == folder && $0.isDirectory }) == true + else { + throw T3Error("Root listing was missing its fixture or recursively loaded folders.") + } + files.loadDirectory(folder) + files.loadDirectory(folder) + try await waitForFiles(files) { files.directories[folder] != nil && !files.loadingTree } + guard files.directories.count == 2, + files.directories[folder]?.contains(where: { $0.path == folder + "/ignored" && $0.ignored }) + == true + else { + throw T3Error("Lazy listing lost ignored directories or loaded descendants.") + } + files.loadDirectory(folder + "/nested") + try await waitForFiles(files) { + files.directories[folder + "/nested"]?.contains(where: { $0.path == path }) == true + } + files.search("日本語 file") + files.search(folder + "/nested") + try await waitForFiles(files) { files.searchResults.contains(where: { $0.path == path }) } + files.search("") + files.open(path) + try await waitForFiles(files) { !files.loadingFile && files.document != nil } + guard files.document?.contents == originalContents else { + throw T3Error("Read changed Unicode or CRLF bytes.") + } + let firstEdit = originalContents + "First edit\n" + files.edit(originalContents + "temporary typing") + files.edit(firstEdit) + try await waitForFiles(files) { !files.pendingSave && files.document?.original == firstEdit } + guard try await store.readWorkspaceFile(path, at: location).contents == firstEdit else { + throw T3Error("Debounced autosave did not persist the latest bytes.") + } + let switchedEdit = firstEdit + "Flush when changing files\n" + files.edit(switchedEdit) + files.open(folder + "/.gitignore") + try await waitForFiles(files) { + !files.loadingFile && files.saving.isEmpty && files.drafts.isEmpty + } + guard try await store.readWorkspaceFile(path, at: location).contents == switchedEdit else { + throw T3Error("Switching files did not flush the debounce window.") + } + files.open(path) + try await waitForFiles(files) { !files.loadingFile && files.document?.path == path } + let recoveryEdit = switchedEdit + "Recover after a disconnected write\n" + files.edit(recoveryEdit) + store.disconnect() + files.refreshContext() + guard await files.finishSaving() else { throw T3Error("Could not persist disconnected edits.") } + store.connect(settings, selecting: threadID) + try await waitForStore(store) { store.canEditThread } + let restored = WorkspaceFiles( + store: store, directory: directory.appendingPathComponent("files")) + restored.refreshContext() + restored.open(path) + guard restored.document?.contents == recoveryEdit, restored.document?.requiresRecovery == true + else { + throw T3Error("Restart did not recover disconnected file edits.") + } + let secondEdit = recoveryEdit + "Typed while saving\n" + var editedDuringSave = false + var sawIntermediateConfirmation = false + restored.onChange = { + if !restored.saving.isEmpty && !editedDuringSave { + editedDuringSave = true + restored.edit(secondEdit) + } + if restored.document?.original == recoveryEdit && restored.document?.contents == secondEdit { + sawIntermediateConfirmation = true + } + } + try await restored.save() + restored.onChange = nil + guard editedDuringSave, sawIntermediateConfirmation, restored.document?.contents == secondEdit, + !restored.pendingSave, + try await store.readWorkspaceFile(path, at: location).contents == secondEdit + else { + throw T3Error("Flushing lost or failed to serialize edits made during a write.") + } + let remoteEdit = secondEdit + "agent edit\n" + try await write(path, remoteEdit) + restored.edit(secondEdit + "local conflicting edit\n") + restored.receiveWorkspaceMutation("conflicting-fixture-mutation") + var rejected = false + do { try await restored.save() } catch { + rejected = error.localizedDescription.contains("changed on the server") + } + let preserved = try await store.readWorkspaceFile(path, at: location) + guard rejected, restored.document?.isDirty == true, preserved.contents == remoteEdit else { + throw T3Error("A conflict or pending mutation replaced unsaved edits.") + } + restored.open(path, reload: true) + try await waitForFiles(restored) { + !restored.loadingFile && restored.document?.contents == remoteEdit + } + let refreshed = remoteEdit + "refresh after a workspace mutation\n" + try await write(path, refreshed) + restored.receiveWorkspaceMutation("clean-fixture-mutation") + try await waitForFiles(restored) { + !restored.loadingFile && restored.document?.contents == refreshed + } + let retriedContents = refreshed + "accepted before reply lost\n" + restored.edit(retriedContents) + try await write(path, retriedContents) + store.disconnect() + restored.refreshContext() + guard !restored.canSave else { throw T3Error("Disconnected editor could save.") } + guard await restored.finishSaving() else { + throw T3Error("Could not preserve an uncertain save.") + } + store.connect(settings, selecting: threadID) + try await waitForStore(store) { store.canEditThread } + restored.refreshContext() + restored.open(path) + try await restored.save() + guard !restored.pendingSave else { throw T3Error("Retry did not recognize accepted content.") } + let closingEdit = retriedContents + "Close before debounce expires\n" + restored.edit(closingEdit) + restored.closeEditor() + guard await restored.finishSaving(), + try await store.readWorkspaceFile(path, at: location).contents == closingEdit + else { + throw T3Error("Closing did not finish the latest write.") + } + restored.refreshContext() + let large = folder + "/large.txt" + try await write(large, String(repeating: "x", count: 1_048_577)) + restored.open(large) + try await waitForFiles(restored) { !restored.loadingFile && restored.document?.path == large } + guard restored.document?.truncated == true, restored.document?.canEdit == false else { + throw T3Error("Truncated file was editable.") + } + restored.edit("must not replace a partial file") + guard restored.document?.isDirty == false else { + throw T3Error("Truncated preview accepted an edit.") + } + let binary = folder + "/binary.bin" + try await write(binary, "binary\u{0}data") + restored.open(binary) + try await waitForFiles(restored) { !restored.loadingFile } + guard restored.document == nil, !restored.message.isEmpty else { + throw T3Error("Binary content was offered as editable text.") + } + restored.open(path) + try await waitForFiles(restored) { !restored.loadingFile && restored.document?.path == path } + guard restored.document?.contents == closingEdit else { + throw T3Error("Closing lost saved file bytes.") + } + var undoneDuringWrite = false + restored.onChange = { + if !restored.saving.isEmpty && !undoneDuringWrite { + undoneDuringWrite = true + restored.edit(closingEdit) + } + } + restored.edit(closingEdit + "temporary edit to undo") + try await restored.save() + restored.onChange = nil + guard undoneDuringWrite, !restored.pendingSave, + try await store.readWorkspaceFile(path, at: location).contents == closingEdit + else { + throw T3Error("Undo during an in-flight write did not restore the original server content.") + } + try await workspacePreviews(store: store, files: restored, location: location, folder: folder) + restored.open(path) + try await waitForFiles(restored) { !restored.loadingFile && restored.document?.path == path } + let alternateRoot = location.root + "/" + folder + "/alternate" + try await write(folder + "/alternate/.keep", "isolated workspace fixture\n") + let alternateID = UUID().uuidString + let alternate = try ThreadSummary( + .object([ + "id": .string(alternateID), "projectId": .string(projectID), + "title": .string("Native workspace switch verification"), + "modelSelection": store.selectedThread?.model ?? .null, + "worktreePath": .string(alternateRoot), "runtimeMode": .string("approval-required"), + ])) + _ = try await connection.call( + "orchestration.dispatchCommand", + payload: + Commands.newThread( + template: alternate, id: alternateID, + title: "Native workspace switch verification", inheritWorkspace: true)) + try await waitForStore(store) { store.shell.threads[alternateID] != nil } + let originalAccess = try store.workspaceFileAccess(at: location) + let scopedEdit = closingEdit + "Saved while navigating to another workspace\n" + restored.edit(scopedEdit) + store.select(alternateID) + restored.refreshContext() + try await waitForStore(store) { store.canEditThread && store.selectedID == alternateID } + guard await restored.finishSaving(), + try await originalAccess.read(path).contents == scopedEdit, + let alternateLocation = store.workspaceLocation, alternateLocation.root == alternateRoot + else { + throw T3Error("Navigating workspaces lost the pending save or its original root.") + } + var missingInOtherWorkspace = false + do { _ = try await store.readWorkspaceFile(path, at: alternateLocation) } catch { + missingInOtherWorkspace = (error as? T3Error)?.remoteCode == "ProjectReadFileError" + } + guard missingInOtherWorkspace else { + throw T3Error("A delayed save reached the other workspace.") + } + if CommandLine.arguments.contains("--workspace-provider-smoke") { + _ = try await store.workspaceCall( + "projects.writeFile", at: alternateLocation, + fields: [ + "relativePath": .string("observed.txt"), "contents": .string("Before provider change\n"), + ]) + try await workspaceProvider( + settings: settings, connection: connection, threadID: alternateID, + projectID: projectID, sendPrompt: true) + } + store.select(threadID) + try await waitForStore(store) { store.canEditThread && store.selectedID == threadID } + restored.refreshContext() + let final = try await connection.call( + "orchestration.getThreadProjection", + payload: .object(["threadId": .string(threadID)])) + guard final["runs"] == original["runs"] else { + throw T3Error("Workspace edits changed inference state.") + } + print( + "PASS: lazy/ignored folder listing, path search, 500ms autosave, exact UTF-8/CRLF, switch/close flush, disconnected draft recovery, serialized typing and undo during save, captured workspace across navigation, conflict and pending-refresh preservation, mutation-triggered refresh with injected tokens, lost-reply retry, truncated/binary handling, no inference in source thread; fixture \(folder), alternate thread \(alternateID)." + ) + } + + @MainActor private static func workspacePreviews( + store: ClientStore, files: WorkspaceFiles, location: WorkspaceLocation, folder: String + ) async throws { + let markdownPath = folder + "/previews/readme.md" + let markdown = [ + "# Native file previews", "", "🦉 Exact Unicode and line endings.", "", + "- [ ] Verify autosave", " - [x] Preserve source bytes", "", "1. [ ] Ordered task", "", + "| Feature | State |", "| --- | --- |", "| Markdown | Preview |", "| CSV and TSV | Table |", + "", + "[Open the data](data.csv)", "", "```swift", "let answer = 42", "```", "", + "[Jump to the ordered task](readme.md:8:4)", "", + ].joined(separator: "\r\n") + let csv = "Name,Note\r\n\"🦉, café\",\"First\r\nSecond \"\"quoted\"\"\"\r\n,\r\n" + let tsv = "Name\tNote\nT3\t\"Multiple\nlines\"\n" + for (path, contents) in [ + (markdownPath, markdown), (folder + "/previews/data.csv", csv), + (folder + "/previews/data.tsv", tsv), + ] { + _ = try await store.workspaceCall( + "projects.writeFile", at: location, + fields: [ + "relativePath": .string(path), "contents": .string(contents), + ]) + } + files.open(markdownPath) + try await waitForFiles(files) { !files.loadingFile && files.document?.path == markdownPath } + guard let document = files.document, document.contents == markdown, files.canEditDocument else { + throw T3Error("Markdown preview source was not preserved or editable.") + } + let rendered = MarkdownRenderer.render(document.contents, width: 400, taskLinks: true) { + WorkspaceMarkdown.link($0, filePath: markdownPath, root: location.root) + } + let offset = (markdown as NSString).range(of: "[ ] Verify autosave").location + guard rendered.tasks.count == 3, + rendered.tasks.contains(where: { $0.sourceOffset == offset && !$0.checked }) + else { + throw T3Error("Rendered Markdown lost a task or its source position.") + } + let changed = WorkspaceMarkdown.settingTask(markdown, offset: offset, checked: true) + files.edit(changed) + try await waitForFiles(files) { !files.pendingSave && files.document?.original == changed } + guard try await store.readWorkspaceFile(markdownPath, at: location).contents == changed, + let link = WorkspaceMarkdown.link("data.csv", filePath: markdownPath, root: location.root), + let csvPath = WorkspaceMarkdown.workspacePath(link, root: location.root), + csvPath == folder + "/previews/data.csv" + else { throw T3Error("Markdown task autosave or contextual file link failed.") } + files.open(csvPath) + try await waitForFiles(files) { !files.loadingFile && files.document?.path == csvPath } + let table = DelimitedFilePreview(files.document?.contents ?? "", delimiter: 44) + guard table.rows == [["Name", "Note"], ["🦉, café", "First\r\nSecond \"quoted\""], ["", ""]], + !table.truncated, try await store.readWorkspaceFile(csvPath, at: location).contents == csv, + DelimitedFilePreview(tsv, delimiter: 9).rows == [ + ["Name", "Note"], ["T3", "Multiple\nlines"], + ] + else { throw T3Error("Table preview changed quoted cells or its original source.") } + guard + let sourceURL = WorkspaceMarkdown.link( + "readme.md:8:4", filePath: csvPath, root: location.root), + let sourceLink = WorkspaceFileLink(url: sourceURL, root: location.root) + else { throw T3Error("Source link could not be resolved.") } + files.open(sourceLink.path, position: sourceLink.position) + guard files.revealRequest == nil else { + throw T3Error("Source reveal ran before loading the file.") + } + try await waitForFiles(files) { !files.loadingFile && files.document?.path == markdownPath } + guard let reveal = files.revealRequest, let contents = files.document?.contents, + reveal.position == FileSourcePosition(line: 8, column: 4), + reveal.position.range(in: contents).location + == (contents as NSString).range(of: "[ ] Ordered task").location + else { throw T3Error("File navigation lost its source location while loading.") } + let previewFolder = folder + "/previews" + files.search("readme") + files.open(previewFolder) + try await waitForFiles(files) { !files.loadingFile && !files.loadingTree } + guard files.selectedDirectory == previewFolder, files.document == nil, + !files.isSearching, + files.directories[previewFolder]?.contains(where: { $0.path == markdownPath }) == true, + files.directories[folder]?.contains(where: { $0.path == previewFolder && $0.isDirectory }) + == true + else { + throw T3Error( + "A folder link did not reveal its ancestors and children: directory=\(files.selectedDirectory ?? "none"), message=\(files.message), search=\(files.isSearching), loaded=\(files.directories.keys.sorted())." + ) + } + let changingPath = previewFolder + "/changing.txt" + let changingURL = URL(fileURLWithPath: location.root).appendingPathComponent(changingPath) + try FileManager.default.createDirectory(at: changingURL, withIntermediateDirectories: false) + files.open(changingPath) + try await waitForFiles(files) { !files.loadingFile && !files.loadingTree } + guard files.selectedDirectory == changingPath else { + throw T3Error("A folder named like a file was not recognized.") + } + try FileManager.default.removeItem(at: changingURL) + _ = try await store.workspaceCall( + "projects.writeFile", at: location, + fields: ["relativePath": .string(changingPath), "contents": .string("Now a text file\n")]) + files.receiveWorkspaceMutation("folder-became-file") + try await waitForFiles(files) { files.document?.contents == "Now a text file\n" } + guard files.selectedDirectory == nil, files.canEditDocument else { + throw T3Error("A workspace mutation did not replace the folder view with the new file.") + } + try await workspaceMedia(store: store, files: files, location: location, folder: previewFolder) + print( + "PASS: Markdown task source offsets and exact CRLF/Unicode autosave, relative file and source-position navigation after loading, folder reveal and folder-to-file mutation, CSV/TSV quoted table previews with unchanged source; fixtures \(folder)/previews." + ) + } + + @MainActor private static func workspaceMedia( + store: ClientStore, files: WorkspaceFiles, location: WorkspaceLocation, folder: String + ) async throws { + let root = URL(fileURLWithPath: location.root) + let image = Data( + base64Encoded: + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aJ1sAAAAASUVORK5CYII=" + )! + let html = + "

Native HTML preview

Script pending

" + let htmlPath = folder + "/page.html" + let pdfPath = folder + "/document.pdf" + let videoPath = folder + "/video.mp4" + try await previewVideo(at: root.appendingPathComponent(videoPath)) + var mediaBox = CGRect(x: 0, y: 0, width: 320, height: 240) + guard + let context = CGContext( + root.appendingPathComponent(pdfPath) as CFURL, mediaBox: &mediaBox, nil) + else { + throw T3Error("Could not create the isolated PDF fixture.") + } + context.beginPDFPage(nil) + context.setFillColor(CGColor(red: 0.2, green: 0.5, blue: 0.9, alpha: 1)) + context.fill(CGRect(x: 20, y: 20, width: 280, height: 200)) + context.endPDFPage() + context.closePDF() + var wave = Data("RIFF".utf8) + func word(_ value: UInt32, bytes: Int) { + for offset in 0..> (offset * 8))) } + } + word(36 + 16000, bytes: 4) + wave.append(Data("WAVEfmt ".utf8)) + word(16, bytes: 4) + word(1, bytes: 2) + word(1, bytes: 2) + word(8000, bytes: 4) + word(16000, bytes: 4) + word(2, bytes: 2) + word(16, bytes: 2) + wave.append(Data("data".utf8)) + word(16000, bytes: 4) + wave.append(Data(repeating: 0, count: 16000)) + for (name, bytes) in [ + ("pixel.png", image), ("page.html", Data(html.utf8)), + ("theme.css", Data("body { color: #257; font: 20px system-ui; }".utf8)), + ("sound.wav", wave), + ] { try bytes.write(to: root.appendingPathComponent(folder + "/" + name)) } + let session = URLSession(configuration: .ephemeral) + defer { session.invalidateAndCancel() } + let downloads = FileManager.default.temporaryDirectory.appendingPathComponent( + "t3-media-download-" + UUID().uuidString) + try FileManager.default.createDirectory( + at: downloads, withIntermediateDirectories: false, attributes: [.posixPermissions: 0o700]) + defer { try? FileManager.default.removeItem(at: downloads) } + for (path, kind, expected) in [ + (folder + "/pixel.png", WorkspaceMediaKind.image, image), + (folder + "/sound.wav", .audio, wave), + (videoPath, .video, try Data(contentsOf: root.appendingPathComponent(videoPath))), + (pdfPath, .pdf, try Data(contentsOf: root.appendingPathComponent(pdfPath))), + (htmlPath, .html, Data(html.utf8)), + ] { + files.open(path) + try await waitForFiles(files) { !files.loadingFile } + guard let request = files.mediaRequest, request.kind == kind, + files.canEditDocument == (kind == .html) + else { throw T3Error("Media source was not recognized: " + path + " · " + files.message) } + let url: URL + do { url = try await request.load() } catch { + throw T3Error( + "Signing the \(kind.rawValue) preview failed: \((error as? T3Error)?.remoteCode ?? "unknown") · \(error.localizedDescription)" + ) + } + let (data, response) = try await session.data(from: url) + guard (response as? HTTPURLResponse)?.statusCode == 200, data == expected else { + throw T3Error("The signed media asset did not preserve its source bytes: " + path) + } + if kind == .image { + let destination = downloads.appendingPathComponent("saved.png") + try await request.save(to: destination) + guard try Data(contentsOf: destination) == image else { + throw T3Error("Media download changed its bytes.") + } + let original = Data("Existing destination".utf8) + try original.write(to: destination) + var limited = false + do { + try await AttachmentHTTP.download(url, to: destination, maximumBytes: 1) + } catch is T3Error { limited = true } + guard limited, try Data(contentsOf: destination) == original else { + throw T3Error("A rejected download replaced the existing destination.") + } + try await request.save(to: destination) + guard try Data(contentsOf: destination) == image, + try FileManager.default.contentsOfDirectory(atPath: downloads.path) == ["saved.png"] + else { throw T3Error("Media replacement failed or left a staging file.") } + } + if kind.playable { + var range = URLRequest(url: url) + range.setValue("bytes=0-43", forHTTPHeaderField: "Range") + let (prefix, response) = try await session.data(for: range) + guard (response as? HTTPURLResponse)?.statusCode == 206, prefix == expected.prefix(44) + else { + throw T3Error("Media streaming did not honor the byte range.") + } + let asset = AVURLAsset(url: url) + let (playable, duration) = try await asset.load(.isPlayable, .duration) + guard playable, duration.seconds > 0 else { + throw T3Error("The platform could not decode the streamed media fixture.") + } + } + if kind == .html { + let style = url.deletingLastPathComponent().appendingPathComponent("theme.css") + let (css, response) = try await session.data(from: style) + guard (response as? HTTPURLResponse)?.statusCode == 200, + String(data: css, encoding: .utf8)?.contains("system-ui") == true + else { throw T3Error("The workspace HTML preview could not load a sibling stylesheet.") } + let changed = html.replacingOccurrences( + of: "Native HTML preview", with: "Saved HTML preview") + files.edit(changed) + try await waitForFiles(files) { !files.pendingSave && files.document?.original == changed } + guard let refreshed = files.mediaRequest, refreshed.key != request.key else { + throw T3Error("Saving HTML did not refresh the preview revision.") + } + let (saved, _) = try await session.data(from: refreshed.load()) + guard saved == Data(changed.utf8) else { + throw T3Error("The HTML preview still served pre-save content.") + } + } + } + let outside = FileManager.default.temporaryDirectory.appendingPathComponent( + "t3-host-preview-" + UUID().uuidString + ".html") + defer { try? FileManager.default.removeItem(at: outside) } + try Data(html.utf8).write(to: outside) + files.open(outside.path) + try await waitForFiles(files) { !files.loadingFile } + guard let host = files.mediaRequest, !files.canEditDocument else { + throw T3Error("An external HTML file was editable or lacked its host preview.") + } + let hostURL: URL + do { hostURL = try await host.load() } catch { + throw T3Error( + "Signing the external HTML preview failed: \((error as? T3Error)?.remoteCode ?? "unknown") · \(error.localizedDescription)" + ) + } + let (hostData, hostResponse) = try await session.data(from: hostURL) + guard (hostResponse as? HTTPURLResponse)?.statusCode == 200, hostData == Data(html.utf8) else { + throw T3Error("The host HTML file could not be previewed through its single-file grant.") + } + let directory = folder + "/folder.png" + try FileManager.default.createDirectory( + at: root.appendingPathComponent(directory), withIntermediateDirectories: false) + files.open(directory) + try await waitForFiles(files) { !files.loadingFile } + guard files.selectedDirectory == directory, files.mediaRequest == nil else { + throw T3Error("A folder extension was incorrectly treated as a media file.") + } + print( + "PASS: real PNG/PDF/HTML/audio/video asset bytes, audio/video range streaming and native decoder metadata, HTML sibling assets and autosave revision, read-only host HTML and media-named folder; fixtures \(folder)." + ) + } + + @MainActor private static func previewVideo(at url: URL) async throws { + let writer = try AVAssetWriter(outputURL: url, fileType: .mp4) + let input = AVAssetWriterInput( + mediaType: .video, + outputSettings: [ + AVVideoCodecKey: AVVideoCodecType.h264, AVVideoWidthKey: 64, AVVideoHeightKey: 64, + ]) + let adaptor = AVAssetWriterInputPixelBufferAdaptor( + assetWriterInput: input, + sourcePixelBufferAttributes: [ + kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32ARGB, + kCVPixelBufferWidthKey as String: 64, kCVPixelBufferHeightKey as String: 64, + ]) + writer.add(input) + guard writer.startWriting() else { throw T3Error("The video fixture encoder did not start.") } + writer.startSession(atSourceTime: .zero) + var buffer: CVPixelBuffer? + guard + CVPixelBufferCreate(kCFAllocatorDefault, 64, 64, kCVPixelFormatType_32ARGB, nil, &buffer) + == kCVReturnSuccess, + let buffer + else { throw T3Error("The video fixture buffer could not be allocated.") } + CVPixelBufferLockBaseAddress(buffer, []) + if let address = CVPixelBufferGetBaseAddress(buffer) { + memset(address, 80, CVPixelBufferGetDataSize(buffer)) + } + CVPixelBufferUnlockBaseAddress(buffer, []) + guard input.isReadyForMoreMediaData, adaptor.append(buffer, withPresentationTime: .zero) else { + writer.cancelWriting() + throw T3Error("The video fixture frame could not be encoded.") + } + writer.endSession(atSourceTime: CMTime(seconds: 1, preferredTimescale: 600)) + input.markAsFinished() + await writer.finishWriting() + guard writer.status == .completed else { + throw T3Error("The video fixture could not be finalized.") + } + } + + @MainActor private static func workspaceProvider( + settings: ConnectionSettings, connection: RPCConnection, threadID: String, + projectID: String, sendPrompt: Bool + ) async throws { + FileHandle.standardOutput.write(Data("Workspace provider thread: \(threadID)\n".utf8)) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + "t3-file-provider-" + UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let store = ClientStore(draftDirectory: directory.appendingPathComponent("chat")) + store.connect(settings, selecting: threadID) + defer { store.disconnect() } + try await waitForStore(store) { store.canEditThread } + guard store.selectedThread?.projectID == projectID, + store.selectedThread?.title == "Native workspace switch verification", + let root = store.workspaceLocation?.root, root.contains("/native-files-"), + root.hasSuffix("/alternate") + else { + throw T3Error("Provider workspace verification requires its isolated switch-test thread.") + } + let files = WorkspaceFiles(store: store, directory: directory.appendingPathComponent("files")) + files.refreshContext() + files.open("observed.txt") + try await waitForFiles(files) { !files.loadingFile && files.document != nil } + let initialMutation = store.thread?.workspaceMutationToken + var approved: Set = [] + var unexpectedApproval = false + let script = #"printf 'NATIVE_AUTOSAVE_REFRESH\n' > observed.txt"# + let wrappedScript = #"printf 'NATIVE_AUTOSAVE_REFRESH\\n' > observed.txt"# + let allowed = Set([ + script, #"/bin/zsh -lc ""# + wrappedScript + #"""#, + #"/bin/bash -lc ""# + wrappedScript + #"""#, + ]) + func update() { + files.refreshContext() + for request in store.thread?.pendingRequests ?? [] where !approved.contains(request.id) { + guard request.kind == "command", request.canRespond, allowed.contains(request.detail), + request.options.contains(where: { $0["decision"].string == "accept" }) + else { + unexpectedApproval = true + continue + } + approved.insert(request.id) + store.respond(request, decision: "accept") + } + files.onChange?() + } + store.onChange = { update() } + defer { store.onChange = nil } + update() + if sendPrompt { + _ = try await connection.call( + "orchestration.dispatchCommand", + payload: Commands.message( + threadID: threadID, + text: + "Sandbox verification: use a shell command to replace observed.txt in the current workspace with exactly NATIVE_AUTOSAVE_REFRESH followed by one newline. Do not modify any other file. Then reply NATIVE_AUTOSAVE_REFRESH_DONE.", + commandID: UUID().uuidString, messageID: UUID().uuidString)) + } + try await waitForFiles(files, seconds: 90) { + unexpectedApproval + || (files.document?.contents == "NATIVE_AUTOSAVE_REFRESH\n" + && store.execution.activeRunID == nil + && store.thread?.rows.contains(where: { + $0.kind == "assistant_message" && !$0.streaming + && $0.text.contains("NATIVE_AUTOSAVE_REFRESH_DONE") + }) == true) + } + guard !unexpectedApproval else { + throw T3Error("Workspace test stopped at an unexpected approval request.") + } + guard store.thread?.workspaceMutationToken != initialMutation, + store.thread?.pendingRequests.isEmpty == true + else { + throw T3Error("Provider refresh had no settled mutation or left a pending request.") + } + print( + "PASS: a real provider command changed the open file; its settled tool/checkpoint events automatically refreshed the native document. Approved \(approved.count) exact fixture command(s); no active or pending test run remains." + ) + } + + @MainActor private static func waitForFiles( + _ files: WorkspaceFiles, seconds: Int = 15, until predicate: @escaping @MainActor () -> Bool + ) async throws { + let (stream, continuation) = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) + files.onChange = { continuation.yield(()) } + let timeout = Task { + try await Task.sleep(for: .seconds(seconds)) + continuation.finish() + } + defer { + files.onChange = nil + timeout.cancel() + continuation.finish() + } + continuation.yield(()) + for await _ in stream { + if predicate() { return } + if let error = files.storageError { throw T3Error(error) } + } + throw T3Error("Timed out waiting for workspace files: \(files.message)") + } + + @MainActor private static func planCard( + settings: ConnectionSettings, connection: RPCConnection, threadID: String, projectID: String + ) async throws { + let original = try await connection.call( + "orchestration.getThreadProjection", payload: .object(["threadId": .string(threadID)])) + guard original["thread"]["projectId"].string == projectID, + original["thread"]["title"].string == "Native plan verification" + else { + throw T3Error("Plan card checks require an isolated plan verification thread.") + } + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + "t3-plan-card-" + UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let store = ClientStore(draftDirectory: directory) + store.connect(settings, selecting: threadID) + defer { store.disconnect() } + try await waitForStore(store) { store.canEditThread && store.catalogReady } + guard let row = store.thread?.rows.first(where: { $0.kind == "proposed_plan" }), + let location = store.workspaceLocation, let template = store.selectedThread + else { + throw T3Error("The stored plan or its workspace is unavailable.") + } + let document = PlanDocument(row.text) + let downloaded = directory.appendingPathComponent(document.filename) + try await document.save(to: downloaded) + guard try Data(contentsOf: downloaded) == Data(document.contents.utf8) else { + throw T3Error("The local Markdown export changed bytes.") + } + let path = "plans/native-plan-card-" + UUID().uuidString.lowercased() + ".md" + let saved = try await store.savePlan(document, at: location, relativePath: " " + path + " ") + let read = try await connection.call( + "projects.readFile", + payload: .object([ + "cwd": .string(location.root), "relativePath": .string(saved), + ])) + guard saved == path, !read["truncated"].bool, + read["contents"].string == document.contents, + read["byteLength"].int == document.contents.utf8.count + else { + throw T3Error("The workspace export changed path or bytes.") + } + var rejected = false + do { + _ = try await store.savePlan( + document, at: location, relativePath: "../outside-" + UUID().uuidString + ".md") + } catch { + rejected = (error as? T3Error)?.remoteCode == "ProjectWriteFileError" + } + guard rejected else { throw T3Error("The server did not reject an outside-workspace save.") } + let other = WorkspaceLocation( + environment: "different-environment", thread: template, + project: .object(["workspaceRoot": .string(location.root)]))! + rejected = false + do { _ = try await store.savePlan(document, at: other, relativePath: path) } catch { + rejected = error.localizedDescription.contains("workspace changed") + } + guard rejected else { + throw T3Error("A save dialog could write after its environment changed.") + } + store.retry() + try await waitForStore(store) { store.canEditThread && store.catalogReady } + _ = try await store.savePlan(document, at: location, relativePath: saved) + let repeated = try await connection.call( + "projects.readFile", + payload: .object([ + "cwd": .string(location.root), "relativePath": .string(saved), + ])) + guard repeated["contents"] == read["contents"] else { + throw T3Error("Reconnect/retry changed the exported plan.") + } + let longPlan = + "# Card layout\n\n## Summary\n\n" + + (1...24).map { + "- Step \($0): " + String(repeating: "Preserve the full plan. ", count: 8) + }.joined(separator: "\n\n") + "\n\n**Last step marker**\n" + let collapsed = PlanCardLayout(markdown: longPlan, width: 340, expanded: false) + let expanded = PlanCardLayout(markdown: longPlan, width: 340, expanded: true) + guard collapsed.bodyHeight <= 416, expanded.height > collapsed.height, + !collapsed.rendered.text.string.contains("Last step marker"), + expanded.rendered.text.string.contains("Last step marker"), + collapsed.document.contents == expanded.document.contents + else { + throw T3Error("Collapsing the card lost export content or failed to bound its layout.") + } + let final = try await connection.call( + "orchestration.getThreadProjection", payload: .object(["threadId": .string(threadID)])) + guard final["runs"] == original["runs"] else { + throw T3Error("Export unexpectedly changed inference state.") + } + print( + "PASS: plan card bounded/collapsed and expanded Markdown layout, exact local and workspace exports, rejected path/environment changes, reconnect/retry, no new inference; saved \(saved)." + ) + } + + @MainActor static func waitForStore( + _ store: ClientStore, seconds: Int = 25, until predicate: @escaping @MainActor () -> Bool + ) async throws { + let (stream, continuation) = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) + store.onChange = { continuation.yield(()) } + let timeout = Task { + try await Task.sleep(for: .seconds(seconds)) + continuation.finish() + } + defer { + store.onChange = nil + timeout.cancel() + continuation.finish() + } + continuation.yield(()) + for await _ in stream { + if predicate() { return } + if let error = store.error { throw T3Error(error) } + } + throw T3Error("Timed out waiting for ClientStore state.") + } +} diff --git a/apps/macos/Sources/T3Native/PlanCardView.swift b/apps/macos/Sources/T3Native/PlanCardView.swift new file mode 100644 index 0000000000000..0e0add1144c51 --- /dev/null +++ b/apps/macos/Sources/T3Native/PlanCardView.swift @@ -0,0 +1,142 @@ +import AppKit +import T3Core +import T3Markdown + +@MainActor struct PlanCardLayout { + let document: PlanDocument + let rendered: RenderedMarkdown + let width: CGFloat + let textHeight: CGFloat + let bodyHeight: CGFloat + let expanded: Bool + var height: CGFloat { 94 + bodyHeight + (document.canCollapse ? 44 : 0) } + + init(markdown: String, width: CGFloat, expanded: Bool, resolveLink: ((String) -> URL?)? = nil) { + document = PlanDocument(markdown) + self.width = max(120, width - 64) + self.expanded = expanded + let text = document.canCollapse && !expanded ? document.preview : document.body + rendered = MarkdownRenderer.render( + text, width: self.width, headingLevelOffset: 3, resolveLink: resolveLink) + textHeight = max(20, MarkdownRenderer.height(of: rendered.text, width: self.width)) + bodyHeight = document.canCollapse && !expanded ? min(416, textHeight) : textHeight + } +} + +@MainActor final class PlanCardView: NSView { + init( + layout: PlanCardLayout, inherited: Bool, canSave: Bool, + toggle: @escaping () -> Void, copy: @escaping () -> Void, + download: @escaping () -> Void, save: @escaping () -> Void, + openLink: @escaping (URL) -> Void, fileLinkMenu: ((URL) -> NSMenu?)? = nil + ) { + super.init(frame: .zero) + let surface = SurfaceView(raised: true) + surface.layer?.cornerRadius = 24 + addSubview(surface) + surface.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + surface.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12), + surface.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12), + surface.topAnchor.constraint(equalTo: topAnchor, constant: 8), + surface.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8), + ]) + let badge = NSTextField(labelWithString: "Plan") + badge.font = .systemFont(ofSize: 11, weight: .medium) + badge.textColor = .secondaryLabelColor + let title = NSTextField( + labelWithString: layout.document.title + (inherited ? " · inherited" : "")) + title.font = .systemFont(ofSize: 14, weight: .medium) + title.lineBreakMode = .byTruncatingTail + title.toolTip = title.stringValue + title.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + let actions = NSPopUpButton(frame: .zero, pullsDown: true) + actions.bezelStyle = .inline + actions.addItem(withTitle: "") + actions.item(at: 0)?.image = NSImage( + systemSymbolName: "ellipsis", accessibilityDescription: nil) + actions.setAccessibilityLabel("Plan actions") + actions.toolTip = "Plan actions" + actions.menu?.autoenablesItems = false + actions.menu?.addItem(ActionMenuItem("Copy to clipboard", action: copy)) + actions.menu?.addItem(ActionMenuItem("Download as markdown", action: download)) + let saveItem = ActionMenuItem("Save to workspace", action: save) + saveItem.isEnabled = canSave + actions.menu?.addItem(saveItem) + let header = NSStackView(views: [badge, title, NSView(), actions]) + header.spacing = 8 + let body = PlanBodyView(layout: layout, openLink: openLink, fileLinkMenu: fileLinkMenu) + let stack = NSStackView(views: [header, body]) + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 16 + if layout.document.canCollapse { + let footer = NSView() + let button = ActionButton(layout.expanded ? "Collapse plan" : "Expand plan", action: toggle) + button.bezelStyle = .rounded + button.controlSize = .small + button.setAccessibilityLabel(button.title) + footer.addSubview(button) + button.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + button.centerXAnchor.constraint(equalTo: footer.centerXAnchor), + button.centerYAnchor.constraint(equalTo: footer.centerYAnchor), + button.heightAnchor.constraint(equalToConstant: 28), + footer.heightAnchor.constraint(equalToConstant: 28), + ]) + stack.addArrangedSubview(footer) + footer.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true + } + surface.addSubview(stack) + stack.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: surface.leadingAnchor, constant: 20), + stack.trailingAnchor.constraint(equalTo: surface.trailingAnchor, constant: -20), + stack.topAnchor.constraint(equalTo: surface.topAnchor, constant: 20), + header.widthAnchor.constraint(equalTo: stack.widthAnchor), + header.heightAnchor.constraint(equalToConstant: 22), + body.widthAnchor.constraint(equalTo: stack.widthAnchor), + body.heightAnchor.constraint(equalToConstant: layout.bodyHeight), + ]) + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } +} + +@MainActor private final class PlanBodyView: NSView { + private let fade: CAGradientLayer? + init(layout: PlanCardLayout, openLink: @escaping (URL) -> Void, fileLinkMenu: ((URL) -> NSMenu?)?) + { + fade = layout.document.canCollapse && !layout.expanded ? CAGradientLayer() : nil + super.init(frame: .zero) + wantsLayer = true + layer?.masksToBounds = true + if let fade { + fade.colors = [NSColor.black.cgColor, NSColor.black.cgColor, NSColor.clear.cgColor] + fade.startPoint = CGPoint(x: 0.5, y: 1) + fade.endPoint = CGPoint(x: 0.5, y: 0) + layer?.mask = fade + } + let text = MessageTextView( + rendered: layout.rendered, width: layout.width, height: layout.textHeight) + text.onOpenLink = openLink + text.fileLinkMenu = fileLinkMenu + text.setAccessibilityLabel("Plan content") + addSubview(text) + text.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + text.leadingAnchor.constraint(equalTo: leadingAnchor), + text.trailingAnchor.constraint(equalTo: trailingAnchor), + text.topAnchor.constraint(equalTo: topAnchor), + text.heightAnchor.constraint(equalToConstant: layout.textHeight), + ]) + } + override func layout() { + super.layout() + CATransaction.begin() + CATransaction.setDisableActions(true) + fade?.frame = bounds + fade?.locations = [0, NSNumber(value: max(0, 1 - 96 / max(1, Double(bounds.height)))), 1] + CATransaction.commit() + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } +} diff --git a/apps/macos/Sources/T3Native/ProjectPicker.swift b/apps/macos/Sources/T3Native/ProjectPicker.swift new file mode 100644 index 0000000000000..d7f205a2f86f4 --- /dev/null +++ b/apps/macos/Sources/T3Native/ProjectPicker.swift @@ -0,0 +1,265 @@ +import AppKit +import T3Core + +@MainActor final class ProjectPicker: NSWindowController, NSTableViewDataSource, NSTableViewDelegate +{ + private let store: ClientStore + private let session: UUID + private let opened: (JSON, String?) -> Void + private let closed: () -> Void + private let mode = NSSegmentedControl( + labels: ["Open folder", "New project"], trackingMode: .selectOne, target: nil, action: nil) + private let path = ActionTextField() + private let name = ActionTextField() + private let table = NSTableView() + private let scroll = NSScrollView() + private let detail = NSTextField(wrappingLabelWithString: "") + private let errorLabel = NSTextField(wrappingLabelWithString: "") + private let openButton = NSButton(title: "Open", target: nil, action: nil) + private let cancelButton = NSButton(title: "Cancel", target: nil, action: nil) + private let browseButton = NSButton(title: "Go", target: nil, action: nil) + private let upButton = NSButton(title: "Up", target: nil, action: nil) + private let homeButton = NSButton(title: "Home", target: nil, action: nil) + private var entries: [JSON] = [] + private var directory: String? + private var browsing: Task? + private var browseID = UUID() + private var saving = false + private var creationFailed = false + private var pathRow: NSStackView! + + init( + store: ClientStore, create: Bool, opened: @escaping (JSON, String?) -> Void, + closed: @escaping () -> Void + ) { + self.store = store + session = store.projectSessionID + self.opened = opened + self.closed = closed + let panel = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 600, height: 470), styleMask: [.titled], + backing: .buffered, defer: false) + panel.title = "Open project" + super.init(window: panel) + mode.selectedSegment = create ? 1 : 0 + mode.target = self + mode.action = #selector(changeProjectAction) + mode.setAccessibilityLabel("Project action") + path.stringValue = "~" + path.placeholderString = "Folder on the connected server" + path.setAccessibilityLabel("Project folder path") + path.target = self + path.action = #selector(go) + path.changed = { [weak self] _ in + self?.table.deselectAll(nil) + self?.updateControls() + } + name.placeholderString = "Project name" + name.setAccessibilityLabel("New project name") + name.changed = { [weak self] _ in self?.updateControls() } + name.target = self + name.action = #selector(save) + for (button, action) in [ + (browseButton, #selector(go)), (upButton, #selector(up)), (homeButton, #selector(home)), + (openButton, #selector(save)), (cancelButton, #selector(cancel)), + ] { + button.target = self + button.action = action + button.bezelStyle = .rounded + } + openButton.keyEquivalent = "\r" + cancelButton.keyEquivalent = "\u{1b}" + table.addTableColumn(NSTableColumn(identifier: .init("folder"))) + table.headerView = nil + table.dataSource = self + table.delegate = self + table.target = self + table.doubleAction = #selector(enterFolder) + table.rowHeight = 28 + table.setAccessibilityLabel("Server folders") + scroll.documentView = table + scroll.hasVerticalScroller = true + scroll.autohidesScrollers = true + scroll.borderType = .bezelBorder + detail.font = .systemFont(ofSize: 12) + detail.textColor = .secondaryLabelColor + errorLabel.font = .systemFont(ofSize: 12) + errorLabel.textColor = .systemRed + errorLabel.isSelectable = true + pathRow = NSStackView(views: [upButton, homeButton, path, browseButton]) + pathRow.spacing = 6 + let actions = NSStackView(views: [NSView(), cancelButton, openButton]) + actions.spacing = 8 + let stack = NSStackView(views: [ + mode, detail, pathRow, name, scroll, errorLabel, NSView(), actions, + ]) + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 12 + let content = NSView() + panel.contentView = content + content.addSubview(stack) + stack.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20), + stack.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -20), + stack.topAnchor.constraint(equalTo: content.topAnchor, constant: 20), + stack.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -20), + scroll.heightAnchor.constraint(equalToConstant: 240), + ]) + for view in [detail, pathRow!, name, scroll, errorLabel, actions] { + view.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true + } + updateControls() + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func present(on parent: NSWindow) { + guard let window else { return } + parent.beginSheet(window) { [weak self] _ in + self?.browsing?.cancel() + self?.closed() + } + window.makeFirstResponder(mode.selectedSegment == 1 ? name : path) + if mode.selectedSegment == 0 { browse("~") } + } + + func numberOfRows(in tableView: NSTableView) -> Int { entries.count } + func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? + { + let label = NSTextField(labelWithString: entries[row]["name"].string ?? "Folder") + label.lineBreakMode = .byTruncatingMiddle + let icon = NSImageView( + image: NSImage(systemSymbolName: "folder", accessibilityDescription: nil)!) + let cell = NSStackView(views: [icon, label]) + cell.spacing = 8 + return cell + } + func tableViewSelectionDidChange(_ notification: Notification) { updateControls() } + + private func updateControls() { + let creating = mode.selectedSegment == 1 + let height: CGFloat = creating ? 260 : 470 + if window?.contentView?.frame.height != height { + window?.setContentSize(NSSize(width: 600, height: height)) + } + let connected = session == store.projectSessionID && store.ready + mode.isEnabled = !saving + pathRow.isHidden = creating + scroll.isHidden = creating + name.isHidden = !creating + detail.stringValue = + creating + ? "Create a project on the connected server in \(store.catalog.newProjectsRoot ?? "its projects folder")." + : "Choose a folder on \(store.browserEndpoint?.host ?? "the connected server"). Double-click a folder to browse inside." + errorLabel.isHidden = errorLabel.stringValue.isEmpty + openButton.title = + saving ? (creating ? "Creating…" : "Opening…") : (creating ? "Create" : "Open") + openButton.isEnabled = + connected && !saving && browsing == nil + && (creating + ? !name.stringValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && !creationFailed : !path.stringValue.isEmpty) + cancelButton.isEnabled = !saving + browseButton.isEnabled = connected && !saving && browsing == nil && !path.stringValue.isEmpty + upButton.isEnabled = + connected && !saving && browsing == nil && directory != nil && directory != "/" + homeButton.isEnabled = connected && !saving && browsing == nil + path.isEditable = !saving + name.isEditable = !saving && !creationFailed + table.isEnabled = !saving && browsing == nil + } + + private func browse(_ value: String) { + browsing?.cancel() + browseID = UUID() + let request = browseID + errorLabel.stringValue = "" + directory = nil + entries = [] + table.deselectAll(nil) + table.reloadData() + browsing = Task { [weak self] in + guard let self else { return } + defer { + if request == browseID { + browsing = nil + updateControls() + } + } + do { + let result = try await store.browseProjectFolders(value, session: session) + guard request == browseID, !Task.isCancelled else { return } + directory = try result.requiredString("parentPath") + path.stringValue = directory ?? value + entries = result["entries"].array + table.deselectAll(nil) + table.reloadData() + } catch { + guard request == browseID, !Task.isCancelled else { return } + errorLabel.stringValue = error.localizedDescription + } + } + updateControls() + } + + @objc private func changeProjectAction() { + browsing?.cancel() + browsing = nil + browseID = UUID() + errorLabel.stringValue = "" + updateControls() + window?.makeFirstResponder(mode.selectedSegment == 1 ? name : path) + if mode.selectedSegment == 0, directory == nil { browse(path.stringValue) } + } + @objc private func go() { browse(path.stringValue) } + @objc private func home() { browse("~") } + @objc private func up() { + if let directory { browse((directory as NSString).deletingLastPathComponent) } + } + @objc private func enterFolder() { + guard entries.indices.contains(table.clickedRow), !saving else { return } + browse(entries[table.clickedRow]["fullPath"].string ?? path.stringValue) + } + @objc private func save() { + guard openButton.isEnabled else { return } + let creating = mode.selectedSegment == 1 + let value = + creating + ? name.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + : entries.indices.contains(table.selectedRow) + ? entries[table.selectedRow]["fullPath"].string ?? path.stringValue : path.stringValue + saving = true + errorLabel.stringValue = "" + updateControls() + Task { [self] in + defer { + saving = false + updateControls() + } + do { + if creating { + let result = try await store.createProject(value, session: session) + opened(result.project, result.warning) + } else { + let project = try await store.openProject(value, session: session) + opened(project, nil) + } + if let window { window.sheetParent?.endSheet(window, returnCode: .OK) } + } catch { + creationFailed = creating + errorLabel.stringValue = + error.localizedDescription + + (creating + ? " Check the project list before creating again; the request may already have completed." + : "") + } + } + } + @objc private func cancel() { + guard !saving, let window else { return } + browsing?.cancel() + window.sheetParent?.endSheet(window, returnCode: .cancel) + } +} diff --git a/apps/macos/Sources/T3Native/ProjectSmoke.swift b/apps/macos/Sources/T3Native/ProjectSmoke.swift new file mode 100644 index 0000000000000..a83d023955909 --- /dev/null +++ b/apps/macos/Sources/T3Native/ProjectSmoke.swift @@ -0,0 +1,93 @@ +import Foundation +import T3Core + +@MainActor enum ProjectSmoke { + static func run(settings: ConnectionSettings, root: String) async throws { + guard ["localhost", "127.0.0.1"].contains(settings.endpoint.host), + root.hasPrefix("/tmp/t3-native-evidence/") + || root.hasPrefix("/private/tmp/t3-native-evidence/") + else { throw T3Error("Project smoke requires a local server and an isolated evidence folder.") } + let directory = URL(fileURLWithPath: root).appendingPathComponent( + "project-" + UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let preferences = UserDefaults(suiteName: "t3-project-smoke-" + UUID().uuidString)! + let store = ClientStore( + draftDirectory: directory.appendingPathComponent("drafts"), preferences: preferences) + store.connect(settings) + defer { store.disconnect() } + try await ParitySmoke.waitForStore(store) { store.ready && store.catalogReady } + let session = store.projectSessionID + let browsed = try await store.browseProjectFolders(directory.path, session: session) + let canonicalRoot = try browsed.requiredString("parentPath") + let project = try await store.openProject(canonicalRoot, session: session) + let duplicate = try await store.openProject(canonicalRoot, session: session) + guard project["id"] == duplicate["id"] else { + throw T3Error("Opening a folder duplicated its project.") + } + store.selectedID = nil + store.showArchives(true) + store.newThread(project: project) + store.newThread(project: project) + try await ParitySmoke.waitForStore(store) { !store.creatingThread && store.canEditThread } + guard let first = store.selectedThread, !store.showingArchives, + first.projectID == project["id"].string, + first.worktreePath == nil, + first.runtimeMode == store.catalog.defaultRuntimeMode(projectID: first.projectID), + store.shell.threads.values.filter({ $0.projectID == first.projectID }).count == 1 + else { throw T3Error("New thread without a template or duplicate-click protection failed.") } + store.drafts[first.id] = "Preserve this draft" + store.newThread(projectID: first.projectID) + try await ParitySmoke.waitForStore(store) { + !store.creatingThread && store.selectedID != first.id && store.canEditThread + } + guard store.selectedThread?.model == first.model, + store.drafts[first.id] == "Preserve this draft" + else { + throw T3Error("New thread did not preserve the previous model and draft.") + } + let second = store.selectedID + store.newThread(projectID: first.projectID) + store.select(first.id) + try await ParitySmoke.waitForStore(store) { + !store.creatingThread + && store.shell.threads.values.filter({ $0.projectID == first.projectID }).count == 3 + } + guard store.selectedID == first.id, + store.shell.threads.values.filter({ $0.projectID == first.projectID }).count == 3 + else { + throw T3Error( + "A delayed create changed the user's selection or created the wrong number of threads.") + } + guard let second else { throw T3Error("Missing second thread.") } + store.select(second) + guard let managedRoot = store.catalog.newProjectsRoot, + managedRoot.contains("/t3code-native-macos/.t3/") + else { throw T3Error("Managed project smoke requires the isolated server's .t3 folder.") } + let created = try await store.createProject( + "Native project check " + UUID().uuidString, session: session) + guard let createdRoot = created.project["workspaceRoot"].string, + createdRoot.hasPrefix(managedRoot + "/"), + FileManager.default.fileExists(atPath: createdRoot + "/README.md") + else { throw T3Error("New project was not created in the server's configured folder.") } + if store.catalog.scratchWorkspaceRoot != nil { + store.selectedID = nil + store.newThread() + try await ParitySmoke.waitForStore(store) { !store.creatingThread && store.canEditThread } + guard store.workspaceLocation?.root == store.catalog.scratchWorkspaceRoot else { + throw T3Error("New thread without a project did not use Scratch.") + } + print("PASS: new thread without a project uses Scratch.") + } else { + print("Scratch is unavailable on this server; the UI opens the project picker instead.") + } + store.disconnect() + do { + _ = try await store.browseProjectFolders(canonicalRoot, session: session) + throw T3Error("An old project picker used a disconnected session.") + } catch let error as T3Error where error.message.contains("connection changed") {} + print( + "PASS: server folder browsing, duplicate project reuse, first thread without a template, duplicate-click guard, model and draft preservation, navigation during create, managed project creation, and disconnected picker rejection." + ) + print("Project fixture: \(project["id"].string ?? "missing") at \(canonicalRoot)") + } +} diff --git a/apps/macos/Sources/T3Native/ProviderSettings.swift b/apps/macos/Sources/T3Native/ProviderSettings.swift new file mode 100644 index 0000000000000..5cbdc791927a2 --- /dev/null +++ b/apps/macos/Sources/T3Native/ProviderSettings.swift @@ -0,0 +1,749 @@ +import AppKit +import SwiftUI +import T3Core + +@MainActor final class ManagementSettingsSession: ObservableObject { + let store: ClientStore + @Published var config: JSON = .null + @Published var tasks: [ScheduledPrompt] = [] + @Published var login: ProviderLoginState? + @Published var preferences = ModelPickerPreferences() + @Published var error: String? + @Published var busy = false + @Published var loaded = false + @Published var schedulesLoaded = false + @Published var pendingProviderCreate: ProviderInstanceDraft? + @Published var pendingScheduleCreate: ScheduledPromptDraft? + private(set) var loginRevision = 0 + private(set) var loginInstanceID: String? + private var connection: RPCConnection? + private var endpoint: URL? + private var generation = UUID() + private var operation: Task? + + init(store: ClientStore) { self.store = store } + var connected: Bool { + connection != nil && connection === store.browserConnection && endpoint == store.browserEndpoint + } + var environment: String { endpoint?.absoluteString ?? "" } + var providers: [ManagedProvider] { + config["providers"].array.compactMap { + try? ManagedProvider(snapshot: $0, settings: config["settings"]) + } + } + + func observe(schedules: Bool = false) async { + operation?.cancel() + let changedEndpoint = endpoint != store.browserEndpoint + generation = UUID() + let token = generation + connection = store.browserConnection + endpoint = store.browserEndpoint + if changedEndpoint { + pendingProviderCreate = UserDefaults.standard.data( + forKey: "pendingProviderCreate:" + environment + ) + .flatMap { try? JSONDecoder().decode(ProviderInstanceDraft.self, from: $0) } + pendingScheduleCreate = UserDefaults.standard.data( + forKey: "pendingScheduleCreate:" + environment + ) + .flatMap { try? JSONDecoder().decode(ScheduledPromptDraft.self, from: $0) } + } + loaded = false + schedulesLoaded = false + busy = false + config = .null + tasks = [] + login = nil + loginInstanceID = nil + error = nil + preferences = ModelPickerPreferences(environment: environment) + guard let connection else { return } + defer { + if token == generation { + operation?.cancel() + self.connection = nil + busy = false + } + } + do { + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { try await self.watchConfiguration(connection, token: token) } + if schedules { group.addTask { try await self.watchSchedules(connection, token: token) } } + try await group.waitForAll() + } + } catch { + guard token == generation, connected, !Task.isCancelled else { return } + self.error = "Settings could not be loaded. Refresh to reconnect." + } + } + + private func valid(_ connection: RPCConnection, token: UUID) throws { + try Task.checkCancellation() + guard token == generation, connection === self.connection, + connection === store.browserConnection, endpoint == store.browserEndpoint + else { + throw CancellationError() + } + } + private func watchConfiguration(_ connection: RPCConnection, token: UUID) async throws { + let subscription = try await connection.subscribe("subscribeServerConfig") + defer { Task { await connection.cancel(subscription.id) } } + for try await value in subscription.values { + try valid(connection, token: token) + switch value["type"].string { + case "snapshot": + config = value["config"] + loaded = true + case "providerStatuses": + if case .object(var fields) = config { + fields["providers"] = value["payload"]["providers"] + config = .object(fields) + } + case "settingsUpdated": + if case .object(var fields) = config { + fields["settings"] = value["payload"]["settings"] + config = .object(fields) + } + default: break + } + } + } + private func watchSchedules(_ connection: RPCConnection, token: UUID) async throws { + let subscription = try await connection.subscribe("scheduledTasks.subscribe") + defer { Task { await connection.cancel(subscription.id) } } + for try await value in subscription.values { + try valid(connection, token: token) + tasks = value["tasks"].array.compactMap { try? ScheduledPrompt($0) } + schedulesLoaded = true + } + } + func observeLogin(_ id: String) async { + guard let connection, connected else { return } + let token = generation + login = nil + loginInstanceID = id + loginRevision += 1 + do { + let subscription = try await connection.subscribe( + "provider.auth.subscribe", payload: .object(["instanceId": .string(id)])) + defer { Task { await connection.cancel(subscription.id) } } + for try await value in subscription.values { + try valid(connection, token: token) + let next = try ProviderLoginState(value) + guard next.instanceID == id, loginInstanceID == id else { continue } + login = next + loginRevision += 1 + } + } catch { + guard token == generation, connected, !Task.isCancelled else { return } + self.error = "Account status could not be loaded." + } + } + func request(_ method: String, _ payload: JSON) async throws -> JSON { + guard let connection, connected else { throw T3Error("Connect to a server first.") } + let token = generation + let result = try await connection.call(method, payload: payload) + try valid(connection, token: token) + return result + } + func perform(_ action: @escaping @MainActor () async throws -> Void) { + guard !busy, connected else { return } + busy = true + error = nil + let token = generation + operation = Task { + defer { + if token == generation { + busy = false + operation = nil + } + } + do { try await action() } catch { + guard token == generation, connected else { return } + self.error = + "The action did not finish. Its server result may be unknown. Refresh to check before retrying; the editor keeps the same account or task ID." + } + } + } + func cancelOperation() { operation?.cancel() } + func persistPendingCreates() throws { + guard connected else { throw CancellationError() } + let defaults = UserDefaults.standard + if let pendingProviderCreate { + defaults.set( + try JSONEncoder().encode(pendingProviderCreate), + forKey: "pendingProviderCreate:" + environment) + } else { + defaults.removeObject(forKey: "pendingProviderCreate:" + environment) + } + if let pendingScheduleCreate { + defaults.set( + try JSONEncoder().encode(pendingScheduleCreate), + forKey: "pendingScheduleCreate:" + environment) + } else { + defaults.removeObject(forKey: "pendingScheduleCreate:" + environment) + } + } + func persistPreferences() { + guard connected else { return } + do { try preferences.save(environment: environment) } catch { + self.error = "Model preferences could not be saved." + } + } +} + +struct ProviderSettingsView: View { + let store: ClientStore + @StateObject private var session: ManagementSettingsSession + @State private var draft: ProviderInstanceDraft? + @State private var editingID: String? + @State private var editor = false + @State private var accountID: String? + @State private var removing: ManagedProvider? + @State private var draftEnvironment: String? + @State private var refreshID = UUID() + + init(store: ClientStore) { + self.store = store + _session = StateObject(wrappedValue: ManagementSettingsSession(store: store)) + } + var body: some View { + Form { + Section { + HStack { + Label("Providers & accounts", systemImage: "person.crop.circle.badge.checkmark").font( + .headline) + Spacer() + Button("Refresh") { refreshID = UUID() }.disabled( + session.busy || store.browserConnection == nil) + } + Text( + "Accounts and CLI paths belong to \(store.browserEndpoint?.host ?? "the connected server")." + ) + .font(.caption).foregroundStyle(.secondary) + if !session.connected { + Text("Connect to a server to manage accounts.").foregroundStyle(.secondary) + } else if !session.loaded { + ProgressView("Loading accounts…") + } + if let error = session.error { Text(error).foregroundStyle(.orange).font(.caption) } + if session.busy { + HStack { + ProgressView().controlSize(.small) + Button("Cancel action", action: session.cancelOperation) + } + } + } + ForEach(session.providers) { provider in + Section { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(provider.name).font(.headline) + Text( + provider.signedIn + ? (provider.account.isEmpty ? "Signed in" : provider.account) : "Not signed in" + ) + .font(.caption).foregroundStyle(.secondary) + } + Spacer() + Text(provider.status.capitalized).font(.caption).foregroundStyle( + provider.status == "error" ? .orange : .secondary) + } + LabeledContent( + "Runtime", + value: provider.driver == "claudeAgent" ? "Claude Code" : provider.driver.capitalized) + LabeledContent( + "Home", + value: provider.envelope["config"]["homePath"].string.flatMap { $0.isEmpty ? nil : $0 } + ?? "Provider default") + LabeledContent( + "Binary", + value: provider.envelope["config"]["binaryPath"].string.flatMap { + $0.isEmpty ? nil : $0 + } ?? "Provider default") + HStack { + Button("Account…") { accountID = provider.id } + if ["codex", "claudeAgent"].contains(provider.driver) { + Button("Configure…") { + draft = ProviderInstanceDraft(provider: provider) + editingID = provider.id + draftEnvironment = session.environment + editor = true + } + } + Spacer() + if provider.explicit { Button("Remove…", role: .destructive) { removing = provider } } + }.disabled(session.busy || !session.connected) + ProviderModelPreferencesRows(session: session, provider: provider) + } header: { + Text(provider.name) + } + } + Section { + Button( + session.pendingProviderCreate != nil || draft != nil && editingID == nil + ? "Continue adding account…" : "Add account…" + ) { + if let pending = session.pendingProviderCreate { + draft = pending + editingID = nil + } else if draft == nil || editingID != nil { + draft = ProviderInstanceDraft() + editingID = nil + } + draftEnvironment = session.environment + editor = true + }.disabled(!session.connected || !session.loaded || session.busy) + } footer: { + Text( + "Claude and Codex accounts can use separate homes. Model visibility and favorites are saved on this Mac for this server." + ) + } + } + .formStyle(.grouped) + .task( + id: (store.browserConnection.map { String(describing: ObjectIdentifier($0)) } ?? "") + + refreshID.uuidString + ) { await session.observe() } + .onChange(of: session.environment) { _, next in + if let draftEnvironment, draftEnvironment != next { + draft = nil + editor = false + accountID = nil + editingID = nil + } + } + .sheet(isPresented: $editor) { + if let draft { + ProviderInstanceEditor(session: session, initial: draft, creating: editingID == nil) { + value in + self.draft = value + } saved: { + self.draft = nil + editingID = nil + editor = false + } cancel: { + editor = false + } + } + } + .sheet(isPresented: Binding(get: { accountID != nil }, set: { if !$0 { accountID = nil } })) { + if let id = accountID { ProviderAccountSheet(session: session, id: id) { accountID = nil } } + } + .confirmationDialog( + "Remove this account?", + isPresented: Binding(get: { removing != nil }, set: { if !$0 { removing = nil } }), + titleVisibility: .visible, presenting: removing + ) { provider in + Button("Remove \(provider.name)", role: .destructive) { + session.perform { + _ = try await session.request( + "server.updateSettings", + .object([ + "patch": .object([:]), + "providerInstanceMutation": .object([ + "operation": .string("remove"), "instanceId": .string(provider.id), + ]), + ])) + removing = nil + } + } + } message: { _ in + Text( + "Threads using this account will need another provider. Credentials stay in the provider's home." + ) + } + } +} + +private struct ProviderModelPreferencesRows: View { + @ObservedObject var session: ManagementSettingsSession + let provider: ManagedProvider + @State private var expanded = false + var body: some View { + DisclosureGroup("Models", isExpanded: $expanded) { + let models = session.preferences.models( + provider.models, instanceID: provider.id, includingHidden: true) + ForEach(Array(models.enumerated()), id: \.element.id) { index, model in + let identity = ModelIdentity(instanceID: provider.id, modelID: model.id) + HStack { + VStack(alignment: .leading) { + Text(model.name) + if model.custom { Text("Custom model").font(.caption).foregroundStyle(.secondary) } + } + Spacer() + Button { + if !session.preferences.favorites.insert(identity).inserted { + session.preferences.favorites.remove(identity) + } + session.persistPreferences() + } label: { + Image( + systemName: session.preferences.favorites.contains(identity) ? "star.fill" : "star") + } + .help("Favorite \(model.name)").accessibilityLabel("Favorite \(model.name)") + Toggle( + "Visible", + isOn: Binding( + get: { model.custom || !session.preferences.hidden.contains(identity) }, + set: { visible in + if visible { + session.preferences.hidden.remove(identity) + } else { + session.preferences.hidden.insert(identity) + } + session.persistPreferences() + }) + ).disabled(model.custom).toggleStyle(.checkbox).fixedSize() + Button { + move(models, index, -1) + } label: { + Image(systemName: "chevron.up") + } + .disabled(index == 0).accessibilityLabel("Move \(model.name) up") + Button { + move(models, index, 1) + } label: { + Image(systemName: "chevron.down") + } + .disabled(index == models.count - 1).accessibilityLabel("Move \(model.name) down") + }.buttonStyle(.borderless) + } + }.disabled(!session.connected) + } + private func move(_ models: [ProviderModel], _ index: Int, _ offset: Int) { + var order = models.map(\.id) + order.swapAt(index, index + offset) + session.preferences.order[provider.id] = order + session.persistPreferences() + } +} + +private struct ProviderInstanceEditor: View { + @ObservedObject var session: ManagementSettingsSession + @State var draft: ProviderInstanceDraft + let creating: Bool + let changed: (ProviderInstanceDraft) -> Void + let saved: () -> Void + let cancel: () -> Void + @State private var modelID = "" + @State private var modelName = "" + @State private var validation: String? + @State private var reconciled = false + init( + session: ManagementSettingsSession, initial: ProviderInstanceDraft, creating: Bool, + changed: @escaping (ProviderInstanceDraft) -> Void, saved: @escaping () -> Void, + cancel: @escaping () -> Void + ) { + self.session = session + _draft = State(initialValue: initial) + self.creating = creating + self.changed = changed + self.saved = saved + self.cancel = cancel + } + var body: some View { + VStack(spacing: 0) { + Form { + Section(creating && !reconciled ? "Add account" : "Account configuration") { + if creating && !reconciled, session.pendingProviderCreate != nil, + let existing = session.providers.first(where: { $0.id == draft.id }) + { + Text( + "This account is present on the server. Open its saved configuration to finish this edit." + ) + .font(.caption).foregroundStyle(.secondary) + Button("Open saved configuration") { + draft = ProviderInstanceDraft(provider: existing) + reconciled = true + } + } + TextField("Account name", text: $draft.displayName) + TextField("Account ID", text: $draft.id).disabled( + !creating || session.pendingProviderCreate != nil) + Picker("Provider", selection: $draft.driver) { + Text("Codex").tag("codex") + Text("Claude Code").tag("claudeAgent") + }.disabled(!creating) + Toggle("Enabled", isOn: $draft.enabled) + } + Section("CLI on the server") { + TextField( + draft.driver == "codex" ? "CODEX_HOME" : "CLAUDE_CONFIG_DIR", text: $draft.homePath) + if draft.driver == "codex" { + TextField("Account shadow home", text: $draft.shadowHomePath) + } + TextField("Binary path", text: $draft.binaryPath) + TextField("Launch arguments", text: $draft.launchArgs) + Text("Leave paths empty to use provider defaults. Paths are resolved on the server.") + .font(.caption).foregroundStyle(.secondary) + } + Section("Custom models") { + ForEach(Array(draft.customModels.enumerated()), id: \.offset) { index, _ in + HStack { + VStack { + TextField("Model ID", text: customField(index, "slug")) + TextField("Display name", text: customField(index, "name")) + } + Button("Remove", role: .destructive) { draft.customModels.remove(at: index) } + } + } + TextField("Model ID", text: $modelID) + TextField("Display name (optional)", text: $modelName) + Button("Add custom model") { + let slug = modelID.trimmingCharacters(in: .whitespacesAndNewlines) + guard !slug.isEmpty else { + validation = "Enter a custom model ID." + return + } + var value: [String: JSON] = ["slug": .string(slug)] + if !modelName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + value["name"] = .string(modelName.trimmingCharacters(in: .whitespacesAndNewlines)) + } + draft.customModels.append(.object(value)) + modelID = "" + modelName = "" + } + } + if let error = validation ?? session.error { + Text(error).font(.caption).foregroundStyle(.orange) + } + }.formStyle(.grouped).disabled(session.busy || !session.connected) + HStack { + if session.busy { + ProgressView().controlSize(.small) + Button("Cancel action", action: session.cancelOperation) + } + Spacer() + Button("Cancel") { + changed(draft) + cancel() + }.keyboardShortcut(.cancelAction).disabled(session.busy) + Button("Save") { + do { + _ = try draft.mutation(create: creating && !reconciled) + changed(draft) + validation = nil + let value = draft + let isCreate = creating && !reconciled + if isCreate { session.pendingProviderCreate = value } + try session.persistPendingCreates() + session.perform { + let settings = try await session.request("server.getSettings", .object([:])) + let explicit = settings["providerInstances"][value.id] + var latest = explicit + if !isCreate, explicit == .null { + guard value.id == value.driver, settings["providers"][value.driver] != .null else { + throw T3Error("This account no longer exists.") + } + latest = .object([ + "driver": .string(value.driver), "config": settings["providers"][value.driver], + ]) + } + guard isCreate || latest["driver"].string == value.driver else { + throw T3Error("This account changed on the server. Reopen its configuration.") + } + let payload = try value.mutation( + create: isCreate, mergingLatest: isCreate ? nil : latest) + _ = try await session.request("server.updateSettings", payload) + if session.pendingProviderCreate?.id == value.id { + session.pendingProviderCreate = nil + } + try session.persistPendingCreates() + saved() + } + } catch { validation = error.localizedDescription } + }.keyboardShortcut(.defaultAction).disabled(session.busy || !session.connected) + }.padding(16) + }.frame(width: 560, height: 670).interactiveDismissDisabled(true) + } + private func customField(_ index: Int, _ key: String) -> Binding { + Binding( + get: { + guard draft.customModels.indices.contains(index) else { return "" } + let value = draft.customModels[index] + return value[key].string ?? (key == "slug" ? value.string ?? "" : "") + }, + set: { text in + guard draft.customModels.indices.contains(index) else { return } + let value = draft.customModels[index] + var fields: [String: JSON] = ["slug": .string(value.string ?? "")] + if case .object(let saved) = value { fields = saved } + if key == "name", text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + fields.removeValue(forKey: key) + } else { + fields[key] = .string(text.trimmingCharacters(in: .whitespacesAndNewlines)) + } + draft.customModels[index] = .object(fields) + }) + } +} + +private struct ProviderAccountSheet: View { + @ObservedObject var session: ManagementSettingsSession + let id: String + let close: () -> Void + @State private var method = "" + @State private var input = "" + @State private var credentials: [String: String] = [:] + @State private var callback = "" + @State private var signOut = false + var body: some View { + VStack(spacing: 0) { + Form { + Section("Account") { + Text(session.providers.first { $0.id == id }?.name ?? id).font(.headline) + Text(session.login?.phase.capitalized ?? "Loading account status…").foregroundStyle( + .secondary) + if let auth = session.login { + if !auth.active { + Picker("Sign-in method", selection: $method) { + Text("Provider default").tag("") + ForEach(Array(auth.methods.enumerated()), id: \.offset) { _, value in + Text(value["name"].string ?? "Sign in").tag(value["id"].string ?? "") + } + } + Button("Sign in") { + var fields: [String: JSON] = [ + "instanceId": .string(id), "callbackMode": .string("server"), + ] + if !method.isEmpty { fields["methodId"] = .string(method) } + authenticate("provider.auth.start", .object(fields)) + }.disabled(session.providers.first { $0.id == id }?.canAuthenticate == false) + if session.providers.first(where: { $0.id == id })?.signedIn == true { + Button("Sign out…", role: .destructive) { signOut = true } + .disabled(session.providers.first { $0.id == id }?.canLogout == false) + } + } else { + interaction(auth) + Button("Cancel sign-in") { + if let flow = auth.flowID { + authenticate( + "provider.auth.cancel", + .object(["instanceId": .string(id), "flowId": .string(flow)])) + } + } + } + } + if let error = session.error { Text(error).font(.caption).foregroundStyle(.orange) } + } + }.formStyle(.grouped).disabled(session.busy || !session.connected) + HStack { + if session.busy { + ProgressView().controlSize(.small) + Button("Cancel action", action: session.cancelOperation) + } + Spacer() + Button("Done", action: close).keyboardShortcut(.cancelAction) + }.padding(16) + }.frame(width: 560, height: 540) + .task(id: id) { await session.observeLogin(id) } + .onChange(of: session.login?.interaction["id"].string) { _, _ in + credentials = [:] + input = "" + callback = "" + } + .onDisappear { + credentials = [:] + input = "" + callback = "" + } + .confirmationDialog( + "Sign out of this provider account?", isPresented: $signOut, titleVisibility: .visible + ) { + Button("Sign out", role: .destructive) { + authenticate("provider.auth.logout", .object(["instanceId": .string(id)])) + } + } + } + @ViewBuilder private func interaction(_ auth: ProviderLoginState) -> some View { + switch auth.interaction["type"].string { + case "browser", "deviceCode": + if let url = auth.url { + if let code = auth.interaction["userCode"].string { + SecureField("Device code", text: .constant(code)) + Button("Copy device code") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(code, forType: .string) + } + } + Button("Open sign-in page") { + session.perform { + if auth.interaction["requiresConsent"].bool { + _ = try await session.request( + "provider.auth.respond", + auth.response(.object(["type": .string("browser"), "action": .string("accept")]))) + } + guard session.connected, session.login?.flowID == auth.flowID else { + throw CancellationError() + } + NSWorkspace.shared.open(url) + } + } + if auth.interaction["acceptsCallback"].bool { + Text( + "For a remote server, paste the final redirect URL from your browser if sign-in does not finish automatically." + ).font(.caption).foregroundStyle(.secondary) + SecureField("Callback URL", text: $callback) + Button("Complete sign-in") { + guard let flow = auth.flowID, !callback.isEmpty, callback.utf16.count <= 16_384 else { + return + } + let value = callback + callback = "" + authenticate( + "provider.auth.complete", + .object([ + "instanceId": .string(id), "flowId": .string(flow), "callbackUrl": .string(value), + ])) + } + } + } + case "terminal": + ScrollView { + Text(auth.interaction["output"].string ?? "").font(.system(.caption, design: .monospaced)) + .textSelection(.enabled).frame(maxWidth: .infinity, alignment: .leading) + }.frame(height: 150) + SecureField("Terminal input", text: $input).onSubmit { sendTerminal(auth) } + HStack { + Button("Send", action: { sendTerminal(auth) }) + Button("Interrupt") { + respond(auth, .object(["type": .string("terminal"), "data": .string("\u{03}")])) + } + } + case "credentials": + ForEach(Array(auth.interaction["fields"].array.enumerated()), id: \.offset) { _, field in + let name = field["name"].string ?? "" + SecureField( + field["label"].string ?? name, + text: Binding(get: { credentials[name] ?? "" }, set: { credentials[name] = $0 })) + } + Button("Continue") { + let values = credentials.mapValues(JSON.string) + credentials = [:] + respond(auth, .object(["type": .string("credentials"), "values": .object(values)])) + } + default: Text("Waiting for the provider’s sign-in workflow…").foregroundStyle(.secondary) + } + } + private func sendTerminal(_ auth: ProviderLoginState) { + let value = input + "\r" + input = "" + respond(auth, .object(["type": .string("terminal"), "data": .string(value)])) + } + private func respond(_ auth: ProviderLoginState, _ value: JSON) { + do { authenticate("provider.auth.respond", try auth.response(value)) } catch { + session.error = "This sign-in interaction is no longer active." + } + } + private func authenticate(_ method: String, _ payload: JSON) { + let revision = session.loginRevision + session.perform { + let next = try ProviderLoginState(await session.request(method, payload)) + guard next.instanceID == id, session.loginInstanceID == id, session.loginRevision == revision + else { return } + session.login = next + } + } +} diff --git a/apps/macos/Sources/T3Native/QueuePanel.swift b/apps/macos/Sources/T3Native/QueuePanel.swift new file mode 100644 index 0000000000000..0fbea29f98c0b --- /dev/null +++ b/apps/macos/Sources/T3Native/QueuePanel.swift @@ -0,0 +1,256 @@ +import AppKit +import T3Core + +@MainActor final class QueuePanel: NSStackView, NSTableViewDataSource, NSTableViewDelegate { + private let store: ClientStore + private let previews: AttachmentPreviews + private let table = NSTableView() + private let scroll = NSScrollView() + private let header = NSStackView() + private let editBanner = NSStackView() + private var listHeight: NSLayoutConstraint! + private var expanded = true + private var state: State? + private let dragType = NSPasteboard.PasteboardType("app.t3.native.queued-run") + private struct State: Equatable { + let environment: String + let thread: String? + let queued: [QueuedMessage] + let held: Bool + let canQueue: Bool + let canSteer: Bool + let interrupts: Bool + let resumable: String? + let editing: String? + let savedEdits: [QueuedEdit] + let returningToQuestion: Bool + let busy: Bool + let importing: Bool + let sending: Bool + } + + init(store: ClientStore, previews: AttachmentPreviews) { + self.store = store + self.previews = previews + super.init(frame: .zero) + orientation = .vertical + alignment = .leading + spacing = 6 + table.addTableColumn(NSTableColumn(identifier: NSUserInterfaceItemIdentifier("queue"))) + table.headerView = nil + table.backgroundColor = .clear + table.rowHeight = 44 + table.intercellSpacing = NSSize(width: 0, height: 4) + table.columnAutoresizingStyle = .lastColumnOnlyAutoresizingStyle + table.selectionHighlightStyle = .none + table.delegate = self + table.dataSource = self + table.registerForDraggedTypes([dragType]) + table.setDraggingSourceOperationMask(.move, forLocal: true) + table.setAccessibilityLabel("Queued messages") + scroll.documentView = table + scroll.drawsBackground = false + scroll.hasVerticalScroller = true + scroll.autohidesScrollers = true + listHeight = scroll.heightAnchor.constraint(equalToConstant: 0) + listHeight.isActive = true + for view in [header, scroll, editBanner] { + addArrangedSubview(view) + view.widthAnchor.constraint(equalTo: widthAnchor).isActive = true + } + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func refresh(force: Bool = false) { + let execution = store.execution + let next = State( + environment: store.environmentKey, thread: store.selectedID, + queued: execution.queued, held: execution.isHeld, + canQueue: execution.canQueue, canSteer: execution.canSteer, + interrupts: execution.steeringInterruptsTools, + resumable: execution.resumableRunID, editing: store.queuedEdit?.runID, + savedEdits: store.savedQueuedEdits, + returningToQuestion: store.selectedRequest?.isQuestion == true, + busy: store.queueBusy, + importing: store.importingAttachments.contains(store.composerDraftID ?? ""), + sending: store.sending.contains(store.selectedID ?? "")) + guard force || next != state else { return } + state = next + for stack in [header, editBanner] { + for view in stack.arrangedSubviews { + stack.removeArrangedSubview(view) + view.removeFromSuperview() + } + } + let toggle = ActionButton("\(next.queued.count) queued" + (next.held ? " · Paused" : "")) { + [weak self] in + guard let self else { return } + self.expanded.toggle() + self.refresh(force: true) + } + toggle.isBordered = false + toggle.font = .systemFont(ofSize: 12, weight: .medium) + toggle.image = NSImage( + systemSymbolName: expanded ? "chevron.down" : "chevron.right", accessibilityDescription: nil) + toggle.imagePosition = .imageLeading + toggle.isHidden = next.queued.isEmpty + header.addArrangedSubview(toggle) + header.addArrangedSubview(NSView()) + if next.held || next.resumable != nil { + let resume = ActionButton(next.resumable == nil ? "Resume queue" : "Resume") { [weak store] in + store?.resume() + } + resume.isEnabled = !next.busy + header.addArrangedSubview(resume) + } + header.isHidden = next.queued.isEmpty && !next.held && next.resumable == nil + scroll.isHidden = !expanded || next.queued.isEmpty + listHeight.constant = scroll.isHidden ? 0 : min(144, CGFloat(next.queued.count * 48)) + editBanner.isHidden = next.editing == nil && next.savedEdits.isEmpty + if let editing = next.editing { + let waiting = next.queued.contains { $0.id == editing } + let label = NSTextField( + wrappingLabelWithString: waiting + ? "Editing queued message" : "This message has left the queue. Your edit is saved.") + label.font = .systemFont(ofSize: 12) + let cancel = ActionButton(next.returningToQuestion ? "Return to question" : "Return to draft") + { [weak store] in store?.cancelQueuedEdit() } + cancel.toolTip = "Keep this edit and return to the composer" + cancel.isEnabled = !next.sending && !next.importing + editBanner.addArrangedSubview(label) + editBanner.addArrangedSubview(NSView()) + editBanner.addArrangedSubview(cancel) + } else if !next.savedEdits.isEmpty { + let label = NSTextField(labelWithString: "Saved queued edits") + label.font = .systemFont(ofSize: 12) + let reopen = NSPopUpButton(frame: .zero, pullsDown: true) + reopen.addItem(withTitle: "Reopen…") + for edit in next.savedEdits { + let text = store.drafts[edit.draftID] ?? "Queued attachment edit" + reopen.menu?.addItem( + ActionMenuItem(String(text.prefix(70))) { [weak store] in + store?.reopenQueuedEdit(edit.runID) + }) + } + reopen.setAccessibilityLabel("Reopen saved queued edit") + reopen.isEnabled = !next.busy && !next.importing + editBanner.addArrangedSubview(label) + editBanner.addArrangedSubview(NSView()) + editBanner.addArrangedSubview(reopen) + } + isHidden = header.isHidden && editBanner.isHidden + table.reloadData() + } + + func numberOfRows(in tableView: NSTableView) -> Int { state?.queued.count ?? 0 } + func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row index: Int) + -> NSView? + { + guard let state, state.queued.indices.contains(index) else { return nil } + let message = state.queued[index] + let row = NSStackView() + row.spacing = 6 + let text = NSTextField( + wrappingLabelWithString: message.text.isEmpty ? "Attachments" : message.text) + text.maximumNumberOfLines = 2 + text.lineBreakMode = .byTruncatingTail + text.font = .systemFont(ofSize: 12) + text.toolTip = message.text + text.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + if state.editing == message.id { text.textColor = .controlAccentColor } + row.addArrangedSubview(text) + func action(_ title: String, symbol: String, enabled: Bool, invoke: @escaping () -> Void) { + let button = ActionButton("", action: invoke) + button.bezelStyle = .inline + button.image = NSImage(systemSymbolName: symbol, accessibilityDescription: nil) + button.toolTip = title + button.setAccessibilityLabel(title) + button.isEnabled = enabled && !state.busy + row.addArrangedSubview(button) + } + if !message.attachments.isEmpty { + let items = message.attachments.map { attachment in + AttachmentPreviewItem(name: attachment["name"].string ?? "Attachment") { [weak store] in + guard let store, store.environmentKey == state.environment else { + throw T3Error("Reconnect to preview this file.") + } + return try store.attachmentPreviewRequest(attachment) + } + } + let attachment = ActionButton("\(items.count) 📎") { [weak self] in + self?.previews.show(items, index: 0, from: self?.window) + } + attachment.bezelStyle = .inline + attachment.setAccessibilityLabel("Preview \(items.count) queued attachments") + row.addArrangedSubview(attachment) + } + action("Move queued message up", symbol: "arrow.up", enabled: state.canQueue && index > 0) { + [weak store] in + store?.reorderQueuedMessage(message.id, before: state.queued[index - 1].id) + } + action( + "Move queued message down", symbol: "arrow.down", + enabled: state.canQueue && index + 1 < state.queued.count + ) { [weak store] in + store?.reorderQueuedMessage( + message.id, before: index + 2 < state.queued.count ? state.queued[index + 2].id : nil) + } + action("Edit queued message", symbol: "pencil", enabled: state.editing == nil) { [weak store] in + store?.editQueuedMessage(message) + } + action( + state.interrupts ? "Steer now (interrupts current tools)" : "Steer now", + symbol: "arrow.turn.up.right", enabled: state.canSteer + ) { [weak store] in store?.steerQueuedMessage(message.id) } + action("Remove queued message", symbol: "xmark", enabled: true) { [weak store] in + store?.cancelQueuedMessage(message.id) + } + let cell = NSView() + cell.addSubview(row) + row.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + row.leadingAnchor.constraint(equalTo: cell.leadingAnchor, constant: 8), + row.trailingAnchor.constraint(equalTo: cell.trailingAnchor, constant: -8), + row.centerYAnchor.constraint(equalTo: cell.centerYAnchor), + ]) + return cell + } + + func tableView(_ tableView: NSTableView, pasteboardWriterForRow row: Int) -> ( + any NSPasteboardWriting + )? { + guard let state, state.canQueue, !state.busy, state.queued.indices.contains(row) else { + return nil + } + let item = NSPasteboardItem() + item.setString( + state.environment + "\n" + (state.thread ?? "") + "\n" + state.queued[row].id, + forType: dragType) + return item + } + private func draggedRun(_ info: any NSDraggingInfo) -> String? { + guard let state, !state.busy, state.canQueue, + let parts = info.draggingPasteboard.string(forType: dragType)?.components(separatedBy: "\n"), + parts.count == 3, parts[0] == store.environmentKey, parts[1] == store.selectedID, + state.queued.contains(where: { $0.id == parts[2] }) + else { return nil } + return parts[2] + } + func tableView( + _ tableView: NSTableView, validateDrop info: any NSDraggingInfo, + proposedRow row: Int, proposedDropOperation dropOperation: NSTableView.DropOperation + ) -> NSDragOperation { + guard draggedRun(info) != nil else { return [] } + tableView.setDropRow(row, dropOperation: .above) + return .move + } + func tableView( + _ tableView: NSTableView, acceptDrop info: any NSDraggingInfo, + row: Int, dropOperation: NSTableView.DropOperation + ) -> Bool { + guard let id = draggedRun(info), let state else { return false } + let before = state.queued.indices.contains(row) ? state.queued[row].id : nil + if before != id { store.reorderQueuedMessage(id, before: before) } + return true + } +} diff --git a/apps/macos/Sources/T3Native/RequestsPanel.swift b/apps/macos/Sources/T3Native/RequestsPanel.swift new file mode 100644 index 0000000000000..da206552130a5 --- /dev/null +++ b/apps/macos/Sources/T3Native/RequestsPanel.swift @@ -0,0 +1,316 @@ +import AppKit +import T3Core + +@MainActor final class RequestsPanel: NSStackView { + private let store: ClientStore + private var previous: [PendingRequest] = [] + private var previousReady = false + private var previousBusy: Set = [] + private var previousPage = -1 + private var previousRequestID: String? + private var previousImporting = false + private var previousEditingQueue = false + private var collapsedQuestion: String? + private var autoAdvance: Task? + private var progressLabel: NSTextField? + private var answerButtons: [String: [ActionButton]] = [:] + private var submitButton: ActionButton? + + init(store: ClientStore) { + self.store = store + super.init(frame: .zero) + orientation = .vertical + alignment = .leading + spacing = 8 + wantsLayer = true + layer?.cornerRadius = 10 + edgeInsets = NSEdgeInsets(top: 10, left: 12, bottom: 10, right: 12) + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func refresh(force: Bool = false) { + let requests = store.thread?.pendingRequests ?? [] + let busy = Set( + store.commandsInFlight.filter { $0.hasPrefix((store.selectedID ?? "") + ":request:") }) + let request = store.selectedRequest + let page = request.map(store.questionIndex) ?? 0 + let importing = store.importingAttachments.contains(store.composerDraftID ?? "") + guard + force || previous != requests || previousReady != store.canEditThread + || previousBusy != busy || previousPage != page + || previousEditingQueue != (store.queuedEdit != nil) + || previousRequestID != request?.id || previousImporting != importing + else { + if let request, request.isQuestion { updateAnswers(request) } + return + } + if previousPage != page || previous != requests || previousRequestID != request?.id { + autoAdvance?.cancel() + } + previousPage = page + previousRequestID = request?.id + previousImporting = importing + previousEditingQueue = store.queuedEdit != nil + previous = requests + previousReady = store.canEditThread + previousBusy = busy + isHidden = requests.isEmpty + for view in arrangedSubviews { + removeArrangedSubview(view) + view.removeFromSuperview() + } + answerButtons = [:] + progressLabel = nil + submitButton = nil + guard let request else { return } + layer?.backgroundColor = NSColor.controlBackgroundColor.cgColor + let title = NSTextField(labelWithString: request.title) + title.font = .systemFont(ofSize: 12, weight: .semibold) + title.textColor = .systemOrange + let top = NSStackView(views: [title, NSView()]) + top.spacing = 8 + if requests.count > 1 { + let switcher = NSPopUpButton() + switcher.menu?.autoenablesItems = false + for (index, candidate) in requests.enumerated() { + let item = ActionMenuItem("\(index + 1)/\(requests.count) · \(candidate.title)") { + [weak self] in + self?.store.selectRequest(candidate.id) + self?.refresh(force: true) + } + item.representedObject = candidate.id + switcher.menu?.addItem(item) + } + switcher.selectItem(at: requests.firstIndex(of: request) ?? 0) + top.addArrangedSubview(switcher) + } + addArrangedSubview(top) + stretch(top) + let enabled = request.canRespond && store.canEditThread && !responding(request) + if !request.canRespond { + let unavailable = label( + request.reason ?? "Provider process is gone. Interrupt or restart the run to respond.") + addArrangedSubview(unavailable) + stretch(unavailable) + } + if request.isQuestion { + buildQuestions(request, enabled: enabled) + } else { + let text = NSTextView() + text.isEditable = false + text.isSelectable = true + text.drawsBackground = false + text.font = + request.kind == "mcp-elicitation" + ? .systemFont(ofSize: 12) : .monospacedSystemFont(ofSize: 12, weight: .regular) + text.string = request.detail + text.isVerticallyResizable = true + text.autoresizingMask = [.width] + text.textContainer?.widthTracksTextView = true + text.setAccessibilityLabel("Approval details") + let scroll = NSScrollView() + scroll.documentView = text + scroll.hasVerticalScroller = true + scroll.drawsBackground = false + scroll.heightAnchor.constraint(equalToConstant: 58).isActive = true + addArrangedSubview(scroll) + stretch(scroll) + let buttons = NSStackView() + buttons.spacing = 6 + for option in request.options { + guard let decision = option["decision"].string else { continue } + let warning = option["warning"].string + let button = ActionButton( + (warning == nil ? "" : "⚠ ") + (option["label"].string ?? decision) + ) { [weak store] in store?.respond(request, decision: decision) } + button.toolTip = warning + button.isEnabled = enabled + button.controlSize = .small + buttons.addArrangedSubview(button) + if let warning { + let text = label(warning) + text.textColor = .systemOrange + addArrangedSubview(text) + stretch(text) + } + } + addArrangedSubview(buttons) + } + } + + private func buildQuestions(_ request: PendingRequest, enabled: Bool) { + let progress = store.questionProgress(request) + let question = progress.question + guard let id = question["id"].string else { return } + let key = request.id + ":" + id + let collapsed = collapsedQuestion == key + let toggle = ActionButton(question["header"].string ?? "Question") { [weak self] in + guard let self else { return } + self.collapsedQuestion = collapsed ? nil : key + self.refresh(force: true) + self.store.onChange?() + } + toggle.isBordered = false + toggle.font = .systemFont(ofSize: 12, weight: .semibold) + toggle.image = NSImage( + systemSymbolName: collapsed ? "chevron.right" : "chevron.down", accessibilityDescription: nil) + toggle.imagePosition = .imageLeading + toggle.toolTip = + collapsed ? "Show the question and its options" : "Hide the question and its options" + let count = NSTextField(labelWithString: "") + count.font = .systemFont(ofSize: 11) + count.textColor = .secondaryLabelColor + progressLabel = count + let heading = NSStackView(views: [toggle, NSView(), count]) + addArrangedSubview(heading) + stretch(heading) + if !collapsed { + let content = NSStackView() + content.orientation = .vertical + content.alignment = .leading + content.spacing = 8 + let prompt = label(question["question"].string ?? "") + prompt.font = .systemFont(ofSize: 12, weight: .medium) + content.addArrangedSubview(prompt) + prompt.widthAnchor.constraint(equalTo: content.widthAnchor, constant: -6).isActive = true + if question["multiSelect"].bool { + content.addArrangedSubview(label("Select one or more options.")) + } + for (index, option) in question["options"].array.enumerated() { + let value = option["value"].string ?? option["label"].string ?? "" + let description = option["description"].string ?? "" + let button = ActionButton( + (index < 9 ? "\(index + 1). " : "") + (option["label"].string ?? value) + ) { [weak self] in + self?.choose(value, question: question, request: request) + } + button.setButtonType(question["multiSelect"].bool ? .switch : .radio) + button.toolTip = description + button.isEnabled = enabled && store.queuedEdit == nil + content.addArrangedSubview(button) + answerButtons[id, default: []].append(button) + if !description.isEmpty { + let detail = label(description) + detail.textColor = .secondaryLabelColor + content.addArrangedSubview(detail) + detail.widthAnchor.constraint(equalTo: content.widthAnchor, constant: -6).isActive = true + } + } + let hint = label( + question["allowCustomAnswer"] == .bool(false) + ? "Choose an option to continue." : "Or write your answer in the composer below.") + hint.textColor = .secondaryLabelColor + content.addArrangedSubview(hint) + let scroll = NSScrollView() + scroll.documentView = content + scroll.hasVerticalScroller = true + scroll.drawsBackground = false + content.translatesAutoresizingMaskIntoConstraints = false + content.widthAnchor.constraint(equalTo: scroll.contentView.widthAnchor).isActive = true + content.topAnchor.constraint(equalTo: scroll.contentView.topAnchor).isActive = true + content.leadingAnchor.constraint(equalTo: scroll.contentView.leadingAnchor).isActive = true + scroll.heightAnchor.constraint( + equalToConstant: CGFloat(min(240, max(85, (3 + question["options"].array.count * 2) * 21))) + ).isActive = true + addArrangedSubview(scroll) + stretch(scroll) + } else { + let summary = NSTextField(labelWithString: question["question"].string ?? "") + summary.lineBreakMode = .byTruncatingTail + summary.font = .systemFont(ofSize: 11) + addArrangedSubview(summary) + stretch(summary) + } + let submit = ActionButton("") { [weak store] in store?.advanceQuestion(request) } + submitButton = submit + let buttons = NSStackView() + if progress.index > 0 { + let back = ActionButton("Previous") { [weak store] in + store?.setQuestionPage(progress.index - 1, request: request) + } + back.isEnabled = + !responding(request) && !store.importingAttachments.contains(store.composerDraftID ?? "") + buttons.addArrangedSubview(back) + } + buttons.addArrangedSubview(submit) + if request.dismissible { + let dismiss = ActionButton("Dismiss") { [weak store] in store?.dismiss(request) } + dismiss.isEnabled = store.canEditThread && !responding(request) + buttons.addArrangedSubview(dismiss) + } + addArrangedSubview(buttons) + updateAnswers(request) + } + + private func choose(_ value: String, question: JSON, request: PendingRequest) { + store.chooseQuestionOption(value, request: request, question: question) + updateAnswers(request) + autoAdvance?.cancel() + guard !question["multiSelect"].bool, let questionID = question["id"].string else { return } + let environment = store.environmentKey + let thread = store.selectedID + autoAdvance = Task { [weak self] in + do { try await Task.sleep(for: .milliseconds(200)) } catch { return } + guard let self, self.store.environmentKey == environment, self.store.selectedID == thread, + self.store.questionRequest?.id == request.id, + self.store.activeQuestion?["id"].string == questionID, + self.store.questionDrafts[request.id]?[questionID]?.selected == [value], + self.store.questionDrafts[request.id]?[questionID]?.custom.isEmpty == true + else { return } + self.store.advanceQuestion(request) + } + } + + func handleNumberKey(_ event: NSEvent) -> Bool { + guard event.modifierFlags.intersection([.command, .control, .option, .shift]).isEmpty, + !(window?.firstResponder is NSTextView), let digit = Int(event.characters ?? ""), + (1...9).contains(digit), + let request = store.questionRequest, request.canRespond, store.canEditThread, + !responding(request) + else { return false } + let question = store.questionProgress(request).question + guard let id = question["id"].string, collapsedQuestion != request.id + ":" + id, + question["options"].array.indices.contains(digit - 1) + else { return false } + let option = question["options"].array[digit - 1] + choose( + option["value"].string ?? option["label"].string ?? "", question: question, request: request) + return true + } + + private func updateAnswers(_ request: PendingRequest) { + let progress = store.questionProgress(request) + let question = progress.question + if let id = question["id"].string { + let draft = store.questionDrafts[request.id]?[id] ?? QuestionDraft() + for (index, button) in (answerButtons[id] ?? []).enumerated() { + let option = question["options"].array[index] + let value = option["value"].string ?? option["label"].string ?? "" + button.state = + draft.custom.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && draft.selected.contains(value) ? .on : .off + } + } + progressLabel?.stringValue = + "\(progress.index + 1)/\(request.questions.count) · \(progress.answeredCount) answered" + submitButton?.title = + responding(request) ? "Sending…" : progress.isLast ? "Submit answers" : "Next" + submitButton?.isEnabled = + store.canEditThread && request.canRespond && !responding(request) + && store.queuedEdit == nil && progress.answer != nil + && (!progress.isLast || progress.answers != nil) + } + + private func responding(_ request: PendingRequest) -> Bool { + store.commandsInFlight.contains((store.selectedID ?? "") + ":request:" + request.id) + } + private func label(_ text: String) -> NSTextField { + let label = NSTextField(wrappingLabelWithString: text) + label.font = .systemFont(ofSize: 11) + label.isSelectable = true + return label + } + private func stretch(_ view: NSView) { + view.widthAnchor.constraint(equalTo: widthAnchor, constant: -24).isActive = true + } +} diff --git a/apps/macos/Sources/T3Native/Resources/BrowserAutomation.js b/apps/macos/Sources/T3Native/Resources/BrowserAutomation.js new file mode 100644 index 0000000000000..d84a12eafbf06 --- /dev/null +++ b/apps/macos/Sources/T3Native/Resources/BrowserAutomation.js @@ -0,0 +1,186 @@ +// Runs in the browser's isolated automation content world. +return (() => { + const injected = globalThis.__t3PlaywrightInjected; + const fail = (tag, message) => ({ error: { _tag: tag, message } }); + const locator = input.locator ?? (input.selector ? "css=" + input.selector : null); + let element; + try { + if (locator) element = injected.querySelector(injected.parseSelector(locator), document, true); + } catch (error) { + return fail("PreviewAutomationInvalidSelectorError", String(error)); + } + const deepActive = () => { + let node = document.activeElement; + while (node?.shadowRoot?.activeElement) node = node.shadowRoot.activeElement; + return node; + }; + const visible = (node) => node && injected.elementState(node, "visible").matches; + const clippedPoint = (node) => { + if (!node || node === document.body || node === document.documentElement) + return { x: innerWidth / 2, y: innerHeight / 2 }; + const rect = node.getBoundingClientRect(); + let left = Math.max(0, rect.left), + top = Math.max(0, rect.top); + let right = Math.min(innerWidth, rect.right), + bottom = Math.min(innerHeight, rect.bottom); + for (let parent = node.parentElement; parent; parent = parent.parentElement) { + const style = getComputedStyle(parent), + box = parent.getBoundingClientRect(); + if (/hidden|clip|auto|scroll/.test(style.overflowX)) { + left = Math.max(left, box.left); + right = Math.min(right, box.right); + } + if (/hidden|clip|auto|scroll/.test(style.overflowY)) { + top = Math.max(top, box.top); + bottom = Math.min(bottom, box.bottom); + } + } + return right > left && bottom > top ? { x: (left + right) / 2, y: (top + bottom) / 2 } : null; + }; + switch (operation) { + case "pointer": { + const node = element ?? deepActive(); + if (locator && node) + node.scrollIntoView({ block: "center", inline: "center", behavior: "instant" }); + return clippedPoint(node); + } + case "viewport": + return { width: innerWidth, height: innerHeight }; + case "ready": + return document.readyState; + case "click": { + if (!locator) return { x: input.x, y: input.y }; + if (!visible(element) || !injected.elementState(element, "enabled").matches) + return { pending: true }; + element.scrollIntoView({ block: "center", inline: "center", behavior: "instant" }); + const point = clippedPoint(element); + if (!point) return { pending: true }; + const { x, y } = point; + const hit = injected.expectHitTarget({ x, y }, element); + if (hit !== "done") return { pending: true }; + return { x, y }; + } + case "type": { + if (locator && !element) return { pending: true }; + element ??= deepActive(); + const textControl = + element instanceof HTMLTextAreaElement || + (element instanceof HTMLInputElement && + ![ + "button", + "checkbox", + "color", + "file", + "hidden", + "image", + "radio", + "range", + "reset", + "submit", + ].includes(element.type)); + if ( + !element || + !visible(element) || + !(textControl || element.isContentEditable) || + element.disabled || + element.readOnly + ) + return fail( + "PreviewAutomationTargetNotEditableError", + "The target is not an editable field.", + ); + element.focus(); + if (deepActive() !== element) + return fail("PreviewAutomationTargetNotEditableError", "The target could not be focused."); + if (input.clear) { + if (textControl) element.select(); + else { + const range = document.createRange(); + range.selectNodeContents(element); + const selection = document.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + } + } + if (input.text.length) { + if (!document.execCommand("insertText", false, input.text)) + return fail("PreviewAutomationTargetNotEditableError", "The page did not accept text."); + } else if (input.clear) document.execCommand("delete", false); + element.dispatchEvent(new Event("change", { bubbles: true })); + return { ok: true }; + } + case "scroll": { + if (locator && !element) + return fail("PreviewAutomationTargetNotFoundError", "The scroll target was not found."); + (element ?? window).scrollBy({ + left: input.deltaX ?? 0, + top: input.deltaY ?? 0, + behavior: "instant", + }); + return { ok: true }; + } + case "waitFor": + return { + pending: Boolean( + (locator && !visible(element)) || + (input.text && !(document.body?.innerText ?? "").includes(input.text)) || + (input.urlIncludes && !location.href.includes(input.urlIncludes)), + ), + }; + case "snapshot": { + const roots = [document]; + const elements = []; + let visited = 0; + for ( + let index = 0; + index < roots.length && visited < 8000 && elements.length < 200; + index++ + ) { + const walker = document.createTreeWalker(roots[index], NodeFilter.SHOW_ELEMENT); + let node; + while ((node = walker.nextNode()) && visited++ < 8000 && elements.length < 200) { + if (node.shadowRoot) roots.push(node.shadowRoot); + if ( + elements.length >= 200 || + !node.matches("a[href],button,input,textarea,select,[role],[tabindex]") || + !visible(node) + ) + continue; + const rect = node.getBoundingClientRect(); + elements.push({ + tag: node.localName, + role: node.getAttribute("role"), + name: ( + node.getAttribute("aria-label") || + node.innerText || + node.getAttribute("name") || + "" + ).slice(0, 160), + selector: injected.generateSelectorSimple(node), + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + }); + } + } + return { + url: location.href, + title: document.title, + loading: document.readyState !== "complete", + visibleText: (document.body?.innerText ?? "").slice(0, 32000), + interactiveElements: elements, + accessibilityTree: injected + .ariaSnapshot(document.body ?? document.documentElement, { mode: "ai" }) + .slice(0, 64000), + consoleEntries: [], + networkEntries: [], + }; + } + default: + return fail( + "PreviewAutomationUnsupportedClientError", + "Unsupported browser operation: " + operation, + ); + } +})(); diff --git a/apps/macos/Sources/T3Native/Resources/PlaywrightInjected.js b/apps/macos/Sources/T3Native/Resources/PlaywrightInjected.js new file mode 100644 index 0000000000000..0ed10558b02dc --- /dev/null +++ b/apps/macos/Sources/T3Native/Resources/PlaywrightInjected.js @@ -0,0 +1,8076 @@ + +var __commonJS = obj => { + let required = false; + let result; + return function __require() { + if (!required) { + required = true; + let fn; + for (const name in obj) { fn = obj[name]; break; } + const module = { exports: {} }; + fn(module.exports, module); + result = module.exports; + } + return result; + } +}; +var __export = (target, all) => {for (var name in all) target[name] = all[name];}; +var __toESM = mod => ({ ...mod, 'default': mod }); +var __toCommonJS = mod => ({ ...mod, __esModule: true }); + + +// packages/injected/src/injectedScript.ts +var injectedScript_exports = {}; +__export(injectedScript_exports, { + InjectedScript: () => InjectedScript +}); +module.exports = __toCommonJS(injectedScript_exports); + +// packages/isomorphic/ariaSnapshot.ts +function ariaNodesEqual(a, b) { + if (a.role !== b.role || a.name !== b.name) + return false; + if (!ariaPropsEqual(a, b) || hasPointerCursor(a) !== hasPointerCursor(b)) + return false; + const aKeys = Object.keys(a.props); + const bKeys = Object.keys(b.props); + return aKeys.length === bKeys.length && aKeys.every((k) => a.props[k] === b.props[k]); +} +function hasPointerCursor(ariaNode) { + return ariaNode.box.cursor === "pointer"; +} +function ariaPropsEqual(a, b) { + return a.active === b.active && a.checked === b.checked && a.disabled === b.disabled && a.expanded === b.expanded && a.selected === b.selected && a.level === b.level && a.pressed === b.pressed; +} +function parseAriaSnapshot(yaml, text, options = {}) { + var _a; + const lineCounter = new yaml.LineCounter(); + const parseOptions = { + keepSourceTokens: true, + lineCounter, + ...options + }; + const yamlDoc = yaml.parseDocument(text, parseOptions); + const errors = []; + const convertRange = (range) => { + return [lineCounter.linePos(range[0]), lineCounter.linePos(range[1])]; + }; + const addError = (error) => { + errors.push({ + message: error.message, + range: [lineCounter.linePos(error.pos[0]), lineCounter.linePos(error.pos[1])] + }); + }; + const convertSeq = (container, seq) => { + for (const item of seq.items) { + const itemIsString = item instanceof yaml.Scalar && typeof item.value === "string"; + if (itemIsString) { + const childNode = KeyParser.parse(item, parseOptions, errors); + if (childNode) { + container.children = container.children || []; + container.children.push(childNode); + } + continue; + } + const itemIsMap = item instanceof yaml.YAMLMap; + if (itemIsMap) { + convertMap(container, item); + continue; + } + errors.push({ + message: "Sequence items should be strings or maps", + range: convertRange(item.range || seq.range) + }); + } + }; + const convertMap = (container, map) => { + var _a2; + for (const entry of map.items) { + container.children = container.children || []; + const keyIsString = entry.key instanceof yaml.Scalar && typeof entry.key.value === "string"; + if (!keyIsString) { + errors.push({ + message: "Only string keys are supported", + range: convertRange(entry.key.range || map.range) + }); + continue; + } + const key = entry.key; + const value = entry.value; + if (key.value === "text") { + const valueIsString = value instanceof yaml.Scalar && typeof value.value === "string"; + if (!valueIsString) { + errors.push({ + message: "Text value should be a string", + range: convertRange(entry.value.range || map.range) + }); + continue; + } + container.children.push({ + kind: "text", + text: textValue(value.value) + }); + continue; + } + if (key.value === "/children") { + const valueIsString = value instanceof yaml.Scalar && typeof value.value === "string"; + if (!valueIsString || value.value !== "contain" && value.value !== "equal" && value.value !== "deep-equal") { + errors.push({ + message: 'Strict value should be "contain", "equal" or "deep-equal"', + range: convertRange(entry.value.range || map.range) + }); + continue; + } + container.containerMode = value.value; + continue; + } + if (key.value.startsWith("/")) { + const valueIsString = value instanceof yaml.Scalar && typeof value.value === "string"; + if (!valueIsString) { + errors.push({ + message: "Property value should be a string", + range: convertRange(entry.value.range || map.range) + }); + continue; + } + container.props = (_a2 = container.props) != null ? _a2 : {}; + container.props[key.value.slice(1)] = textValue(value.value); + continue; + } + const childNode = KeyParser.parse(key, parseOptions, errors); + if (!childNode) + continue; + const valueIsScalar = value instanceof yaml.Scalar; + if (valueIsScalar) { + const type = typeof value.value; + if (type !== "string" && type !== "number" && type !== "boolean") { + errors.push({ + message: "Node value should be a string or a sequence", + range: convertRange(entry.value.range || map.range) + }); + continue; + } + container.children.push({ + ...childNode, + children: [{ + kind: "text", + text: textValue(String(value.value)) + }] + }); + continue; + } + const valueIsSequence = value instanceof yaml.YAMLSeq; + if (valueIsSequence) { + container.children.push(childNode); + convertSeq(childNode, value); + continue; + } + errors.push({ + message: "Map values should be strings or sequences", + range: convertRange(entry.value.range || map.range) + }); + } + }; + const fragment = { kind: "role", role: "fragment" }; + yamlDoc.errors.forEach(addError); + if (errors.length) + return { errors, fragment }; + if (!(yamlDoc.contents instanceof yaml.YAMLSeq)) { + errors.push({ + message: 'Aria snapshot must be a YAML sequence, elements starting with " -"', + range: yamlDoc.contents ? convertRange(yamlDoc.contents.range) : [{ line: 0, col: 0 }, { line: 0, col: 0 }] + }); + } + if (errors.length) + return { errors, fragment }; + convertSeq(fragment, yamlDoc.contents); + if (errors.length) + return { errors, fragment: emptyFragment }; + if (((_a = fragment.children) == null ? void 0 : _a.length) === 1 && (!fragment.containerMode || fragment.containerMode === "contain")) + return { fragment: fragment.children[0], errors: [] }; + return { fragment, errors: [] }; +} +var emptyFragment = { kind: "role", role: "fragment" }; +function normalizeWhitespace(text) { + return text.replace(/[\u200b\u00ad]/g, "").replace(/[\r\n\s\t]+/g, " ").trim(); +} +function textValue(value) { + return { + raw: value, + normalized: normalizeWhitespace(value) + }; +} +var KeyParser = class _KeyParser { + static parse(text, options, errors) { + try { + return new _KeyParser(text.value)._parse(); + } catch (e) { + if (e instanceof ParserError) { + const message = options.prettyErrors === false ? e.message : e.message + ":\n\n" + text.value + "\n" + " ".repeat(e.pos) + "^\n"; + errors.push({ + message, + range: [options.lineCounter.linePos(text.range[0]), options.lineCounter.linePos(text.range[0] + e.pos)] + }); + return null; + } + throw e; + } + } + constructor(input) { + this._input = input; + this._pos = 0; + this._length = input.length; + } + _peek() { + return this._input[this._pos] || ""; + } + _next() { + if (this._pos < this._length) + return this._input[this._pos++]; + return null; + } + _eof() { + return this._pos >= this._length; + } + _isWhitespace() { + return !this._eof() && /\s/.test(this._peek()); + } + _skipWhitespace() { + while (this._isWhitespace()) + this._pos++; + } + _readIdentifier(type) { + if (this._eof()) + this._throwError(`Unexpected end of input when expecting ${type}`); + const start = this._pos; + while (!this._eof() && /[a-zA-Z]/.test(this._peek())) + this._pos++; + return this._input.slice(start, this._pos); + } + _readString() { + let result = ""; + let escaped = false; + while (!this._eof()) { + const ch = this._next(); + if (escaped) { + result += ch; + escaped = false; + } else if (ch === "\\") { + escaped = true; + } else if (ch === '"') { + return result; + } else { + result += ch; + } + } + this._throwError("Unterminated string"); + } + _throwError(message, offset = 0) { + throw new ParserError(message, offset || this._pos); + } + _readRegex() { + let result = ""; + let escaped = false; + let insideClass = false; + while (!this._eof()) { + const ch = this._next(); + if (escaped) { + result += ch; + escaped = false; + } else if (ch === "\\") { + escaped = true; + result += ch; + } else if (ch === "/" && !insideClass) { + return { pattern: result }; + } else if (ch === "[") { + insideClass = true; + result += ch; + } else if (ch === "]" && insideClass) { + result += ch; + insideClass = false; + } else { + result += ch; + } + } + this._throwError("Unterminated regex"); + } + _readStringOrRegex() { + const ch = this._peek(); + if (ch === '"') { + this._next(); + return normalizeWhitespace(this._readString()); + } + if (ch === "/") { + this._next(); + return this._readRegex(); + } + return null; + } + _readAttributes(result) { + let errorPos = this._pos; + while (true) { + this._skipWhitespace(); + if (this._peek() === "[") { + this._next(); + this._skipWhitespace(); + errorPos = this._pos; + const flagName = this._readIdentifier("attribute"); + this._skipWhitespace(); + let flagValue = ""; + if (this._peek() === "=") { + this._next(); + this._skipWhitespace(); + errorPos = this._pos; + while (this._peek() !== "]" && !this._isWhitespace() && !this._eof()) + flagValue += this._next(); + } + this._skipWhitespace(); + if (this._peek() !== "]") + this._throwError("Expected ]"); + this._next(); + this._applyAttribute(result, flagName, flagValue || "true", errorPos); + } else { + break; + } + } + } + _parse() { + this._skipWhitespace(); + const role = this._readIdentifier("role"); + this._skipWhitespace(); + const name = this._readStringOrRegex() || ""; + const result = { kind: "role", role, name }; + this._readAttributes(result); + this._skipWhitespace(); + if (!this._eof()) + this._throwError("Unexpected input"); + return result; + } + _applyAttribute(node, key, value, errorPos) { + if (key === "checked") { + this._assert(value === "true" || value === "false" || value === "mixed", 'Value of "checked" attribute must be a boolean or "mixed"', errorPos); + node.checked = value === "true" ? true : value === "false" ? false : "mixed"; + return; + } + if (key === "disabled") { + this._assert(value === "true" || value === "false", 'Value of "disabled" attribute must be a boolean', errorPos); + node.disabled = value === "true"; + return; + } + if (key === "expanded") { + this._assert(value === "true" || value === "false", 'Value of "expanded" attribute must be a boolean', errorPos); + node.expanded = value === "true"; + return; + } + if (key === "active") { + this._assert(value === "true" || value === "false", 'Value of "active" attribute must be a boolean', errorPos); + node.active = value === "true"; + return; + } + if (key === "level") { + this._assert(!isNaN(Number(value)), 'Value of "level" attribute must be a number', errorPos); + node.level = Number(value); + return; + } + if (key === "pressed") { + this._assert(value === "true" || value === "false" || value === "mixed", 'Value of "pressed" attribute must be a boolean or "mixed"', errorPos); + node.pressed = value === "true" ? true : value === "false" ? false : "mixed"; + return; + } + if (key === "selected") { + this._assert(value === "true" || value === "false", 'Value of "selected" attribute must be a boolean', errorPos); + node.selected = value === "true"; + return; + } + this._assert(false, `Unsupported attribute [${key}]`, errorPos); + } + _assert(value, message, valuePos) { + if (!value) + this._throwError(message || "Assertion error", valuePos); + } +}; +var ParserError = class extends Error { + constructor(message, pos) { + super(message); + this.pos = pos; + } +}; +function findNewNode(from, to) { + var _a, _b; + function fillMap(root, map, position) { + let size = 1; + let childPosition = position + size; + for (const child of root.children || []) { + if (typeof child === "string") { + size++; + childPosition++; + } else { + size += fillMap(child, map, childPosition); + childPosition += size; + } + } + if (!["none", "presentation", "fragment", "iframe", "generic"].includes(root.role) && root.name) { + let byRole = map.get(root.role); + if (!byRole) { + byRole = /* @__PURE__ */ new Map(); + map.set(root.role, byRole); + } + const existing = byRole.get(root.name); + const sizeAndPosition = size * 100 - position; + if (!existing || existing.sizeAndPosition < sizeAndPosition) + byRole.set(root.name, { node: root, sizeAndPosition }); + } + return size; + } + const fromMap = /* @__PURE__ */ new Map(); + if (from) + fillMap(from, fromMap, 0); + const toMap = /* @__PURE__ */ new Map(); + fillMap(to, toMap, 0); + const result = []; + for (const [role, byRole] of toMap) { + for (const [name, byName] of byRole) { + const inFrom = (_a = fromMap.get(role)) == null ? void 0 : _a.get(name); + if (!inFrom) + result.push(byName); + } + } + result.sort((a, b) => b.sizeAndPosition - a.sizeAndPosition); + return (_b = result[0]) == null ? void 0 : _b.node; +} + +// packages/isomorphic/cssTokenizer.ts +var between = function(num, first, last) { + return num >= first && num <= last; +}; +function digit(code) { + return between(code, 48, 57); +} +function hexdigit(code) { + return digit(code) || between(code, 65, 70) || between(code, 97, 102); +} +function uppercaseletter(code) { + return between(code, 65, 90); +} +function lowercaseletter(code) { + return between(code, 97, 122); +} +function letter(code) { + return uppercaseletter(code) || lowercaseletter(code); +} +function nonascii(code) { + return code >= 128; +} +function namestartchar(code) { + return letter(code) || nonascii(code) || code === 95; +} +function namechar(code) { + return namestartchar(code) || digit(code) || code === 45; +} +function nonprintable(code) { + return between(code, 0, 8) || code === 11 || between(code, 14, 31) || code === 127; +} +function newline(code) { + return code === 10; +} +function whitespace(code) { + return newline(code) || code === 9 || code === 32; +} +var maximumallowedcodepoint = 1114111; +var InvalidCharacterError = class extends Error { + constructor(message) { + super(message); + this.name = "InvalidCharacterError"; + } +}; +function preprocess(str) { + const codepoints = []; + for (let i = 0; i < str.length; i++) { + let code = str.charCodeAt(i); + if (code === 13 && str.charCodeAt(i + 1) === 10) { + code = 10; + i++; + } + if (code === 13 || code === 12) + code = 10; + if (code === 0) + code = 65533; + if (between(code, 55296, 56319) && between(str.charCodeAt(i + 1), 56320, 57343)) { + const lead = code - 55296; + const trail = str.charCodeAt(i + 1) - 56320; + code = Math.pow(2, 16) + lead * Math.pow(2, 10) + trail; + i++; + } + codepoints.push(code); + } + return codepoints; +} +function stringFromCode(code) { + if (code <= 65535) + return String.fromCharCode(code); + code -= Math.pow(2, 16); + const lead = Math.floor(code / Math.pow(2, 10)) + 55296; + const trail = code % Math.pow(2, 10) + 56320; + return String.fromCharCode(lead) + String.fromCharCode(trail); +} +function tokenize(str1) { + const str = preprocess(str1); + let i = -1; + const tokens = []; + let code; + let line = 0; + let column = 0; + let lastLineLength = 0; + const incrLineno = function() { + line += 1; + lastLineLength = column; + column = 0; + }; + const locStart = { line, column }; + const codepoint = function(i2) { + if (i2 >= str.length) + return -1; + return str[i2]; + }; + const next = function(num) { + if (num === void 0) + num = 1; + if (num > 3) + throw "Spec Error: no more than three codepoints of lookahead."; + return codepoint(i + num); + }; + const consume = function(num) { + if (num === void 0) + num = 1; + i += num; + code = codepoint(i); + if (newline(code)) + incrLineno(); + else + column += num; + return true; + }; + const reconsume = function() { + i -= 1; + if (newline(code)) { + line -= 1; + column = lastLineLength; + } else { + column -= 1; + } + locStart.line = line; + locStart.column = column; + return true; + }; + const eof = function(codepoint2) { + if (codepoint2 === void 0) + codepoint2 = code; + return codepoint2 === -1; + }; + const donothing = function() { + }; + const parseerror = function() { + }; + const consumeAToken = function() { + consumeComments(); + consume(); + if (whitespace(code)) { + while (whitespace(next())) + consume(); + return new WhitespaceToken(); + } else if (code === 34) { + return consumeAStringToken(); + } else if (code === 35) { + if (namechar(next()) || areAValidEscape(next(1), next(2))) { + const token = new HashToken(""); + if (wouldStartAnIdentifier(next(1), next(2), next(3))) + token.type = "id"; + token.value = consumeAName(); + return token; + } else { + return new DelimToken(code); + } + } else if (code === 36) { + if (next() === 61) { + consume(); + return new SuffixMatchToken(); + } else { + return new DelimToken(code); + } + } else if (code === 39) { + return consumeAStringToken(); + } else if (code === 40) { + return new OpenParenToken(); + } else if (code === 41) { + return new CloseParenToken(); + } else if (code === 42) { + if (next() === 61) { + consume(); + return new SubstringMatchToken(); + } else { + return new DelimToken(code); + } + } else if (code === 43) { + if (startsWithANumber()) { + reconsume(); + return consumeANumericToken(); + } else { + return new DelimToken(code); + } + } else if (code === 44) { + return new CommaToken(); + } else if (code === 45) { + if (startsWithANumber()) { + reconsume(); + return consumeANumericToken(); + } else if (next(1) === 45 && next(2) === 62) { + consume(2); + return new CDCToken(); + } else if (startsWithAnIdentifier()) { + reconsume(); + return consumeAnIdentlikeToken(); + } else { + return new DelimToken(code); + } + } else if (code === 46) { + if (startsWithANumber()) { + reconsume(); + return consumeANumericToken(); + } else { + return new DelimToken(code); + } + } else if (code === 58) { + return new ColonToken(); + } else if (code === 59) { + return new SemicolonToken(); + } else if (code === 60) { + if (next(1) === 33 && next(2) === 45 && next(3) === 45) { + consume(3); + return new CDOToken(); + } else { + return new DelimToken(code); + } + } else if (code === 64) { + if (wouldStartAnIdentifier(next(1), next(2), next(3))) + return new AtKeywordToken(consumeAName()); + else + return new DelimToken(code); + } else if (code === 91) { + return new OpenSquareToken(); + } else if (code === 92) { + if (startsWithAValidEscape()) { + reconsume(); + return consumeAnIdentlikeToken(); + } else { + parseerror(); + return new DelimToken(code); + } + } else if (code === 93) { + return new CloseSquareToken(); + } else if (code === 94) { + if (next() === 61) { + consume(); + return new PrefixMatchToken(); + } else { + return new DelimToken(code); + } + } else if (code === 123) { + return new OpenCurlyToken(); + } else if (code === 124) { + if (next() === 61) { + consume(); + return new DashMatchToken(); + } else if (next() === 124) { + consume(); + return new ColumnToken(); + } else { + return new DelimToken(code); + } + } else if (code === 125) { + return new CloseCurlyToken(); + } else if (code === 126) { + if (next() === 61) { + consume(); + return new IncludeMatchToken(); + } else { + return new DelimToken(code); + } + } else if (digit(code)) { + reconsume(); + return consumeANumericToken(); + } else if (namestartchar(code)) { + reconsume(); + return consumeAnIdentlikeToken(); + } else if (eof()) { + return new EOFToken(); + } else { + return new DelimToken(code); + } + }; + const consumeComments = function() { + while (next(1) === 47 && next(2) === 42) { + consume(2); + while (true) { + consume(); + if (code === 42 && next() === 47) { + consume(); + break; + } else if (eof()) { + parseerror(); + return; + } + } + } + }; + const consumeANumericToken = function() { + const num = consumeANumber(); + if (wouldStartAnIdentifier(next(1), next(2), next(3))) { + const token = new DimensionToken(); + token.value = num.value; + token.repr = num.repr; + token.type = num.type; + token.unit = consumeAName(); + return token; + } else if (next() === 37) { + consume(); + const token = new PercentageToken(); + token.value = num.value; + token.repr = num.repr; + return token; + } else { + const token = new NumberToken(); + token.value = num.value; + token.repr = num.repr; + token.type = num.type; + return token; + } + }; + const consumeAnIdentlikeToken = function() { + const str2 = consumeAName(); + if (str2.toLowerCase() === "url" && next() === 40) { + consume(); + while (whitespace(next(1)) && whitespace(next(2))) + consume(); + if (next() === 34 || next() === 39) + return new FunctionToken(str2); + else if (whitespace(next()) && (next(2) === 34 || next(2) === 39)) + return new FunctionToken(str2); + else + return consumeAURLToken(); + } else if (next() === 40) { + consume(); + return new FunctionToken(str2); + } else { + return new IdentToken(str2); + } + }; + const consumeAStringToken = function(endingCodePoint) { + if (endingCodePoint === void 0) + endingCodePoint = code; + let string = ""; + while (consume()) { + if (code === endingCodePoint || eof()) { + return new StringToken(string); + } else if (newline(code)) { + parseerror(); + reconsume(); + return new BadStringToken(); + } else if (code === 92) { + if (eof(next())) + donothing(); + else if (newline(next())) + consume(); + else + string += stringFromCode(consumeEscape()); + } else { + string += stringFromCode(code); + } + } + throw new Error("Internal error"); + }; + const consumeAURLToken = function() { + const token = new URLToken(""); + while (whitespace(next())) + consume(); + if (eof(next())) + return token; + while (consume()) { + if (code === 41 || eof()) { + return token; + } else if (whitespace(code)) { + while (whitespace(next())) + consume(); + if (next() === 41 || eof(next())) { + consume(); + return token; + } else { + consumeTheRemnantsOfABadURL(); + return new BadURLToken(); + } + } else if (code === 34 || code === 39 || code === 40 || nonprintable(code)) { + parseerror(); + consumeTheRemnantsOfABadURL(); + return new BadURLToken(); + } else if (code === 92) { + if (startsWithAValidEscape()) { + token.value += stringFromCode(consumeEscape()); + } else { + parseerror(); + consumeTheRemnantsOfABadURL(); + return new BadURLToken(); + } + } else { + token.value += stringFromCode(code); + } + } + throw new Error("Internal error"); + }; + const consumeEscape = function() { + consume(); + if (hexdigit(code)) { + const digits = [code]; + for (let total = 0; total < 5; total++) { + if (hexdigit(next())) { + consume(); + digits.push(code); + } else { + break; + } + } + if (whitespace(next())) + consume(); + let value = parseInt(digits.map(function(x) { + return String.fromCharCode(x); + }).join(""), 16); + if (value > maximumallowedcodepoint) + value = 65533; + return value; + } else if (eof()) { + return 65533; + } else { + return code; + } + }; + const areAValidEscape = function(c1, c2) { + if (c1 !== 92) + return false; + if (newline(c2)) + return false; + return true; + }; + const startsWithAValidEscape = function() { + return areAValidEscape(code, next()); + }; + const wouldStartAnIdentifier = function(c1, c2, c3) { + if (c1 === 45) + return namestartchar(c2) || c2 === 45 || areAValidEscape(c2, c3); + else if (namestartchar(c1)) + return true; + else if (c1 === 92) + return areAValidEscape(c1, c2); + else + return false; + }; + const startsWithAnIdentifier = function() { + return wouldStartAnIdentifier(code, next(1), next(2)); + }; + const wouldStartANumber = function(c1, c2, c3) { + if (c1 === 43 || c1 === 45) { + if (digit(c2)) + return true; + if (c2 === 46 && digit(c3)) + return true; + return false; + } else if (c1 === 46) { + if (digit(c2)) + return true; + return false; + } else if (digit(c1)) { + return true; + } else { + return false; + } + }; + const startsWithANumber = function() { + return wouldStartANumber(code, next(1), next(2)); + }; + const consumeAName = function() { + let result = ""; + while (consume()) { + if (namechar(code)) { + result += stringFromCode(code); + } else if (startsWithAValidEscape()) { + result += stringFromCode(consumeEscape()); + } else { + reconsume(); + return result; + } + } + throw new Error("Internal parse error"); + }; + const consumeANumber = function() { + let repr = ""; + let type = "integer"; + if (next() === 43 || next() === 45) { + consume(); + repr += stringFromCode(code); + } + while (digit(next())) { + consume(); + repr += stringFromCode(code); + } + if (next(1) === 46 && digit(next(2))) { + consume(); + repr += stringFromCode(code); + consume(); + repr += stringFromCode(code); + type = "number"; + while (digit(next())) { + consume(); + repr += stringFromCode(code); + } + } + const c1 = next(1); + const c2 = next(2); + const c3 = next(3); + if ((c1 === 69 || c1 === 101) && digit(c2)) { + consume(); + repr += stringFromCode(code); + consume(); + repr += stringFromCode(code); + type = "number"; + while (digit(next())) { + consume(); + repr += stringFromCode(code); + } + } else if ((c1 === 69 || c1 === 101) && (c2 === 43 || c2 === 45) && digit(c3)) { + consume(); + repr += stringFromCode(code); + consume(); + repr += stringFromCode(code); + consume(); + repr += stringFromCode(code); + type = "number"; + while (digit(next())) { + consume(); + repr += stringFromCode(code); + } + } + const value = convertAStringToANumber(repr); + return { type, value, repr }; + }; + const convertAStringToANumber = function(string) { + return +string; + }; + const consumeTheRemnantsOfABadURL = function() { + while (consume()) { + if (code === 41 || eof()) { + return; + } else if (startsWithAValidEscape()) { + consumeEscape(); + donothing(); + } else { + donothing(); + } + } + }; + let iterationCount = 0; + while (!eof(next())) { + tokens.push(consumeAToken()); + iterationCount++; + if (iterationCount > str.length * 2) + throw new Error("I'm infinite-looping!"); + } + return tokens; +} +var CSSParserToken = class { + constructor() { + this.tokenType = ""; + } + toJSON() { + return { token: this.tokenType }; + } + toString() { + return this.tokenType; + } + toSource() { + return "" + this; + } +}; +var BadStringToken = class extends CSSParserToken { + constructor() { + super(...arguments); + this.tokenType = "BADSTRING"; + } +}; +var BadURLToken = class extends CSSParserToken { + constructor() { + super(...arguments); + this.tokenType = "BADURL"; + } +}; +var WhitespaceToken = class extends CSSParserToken { + constructor() { + super(...arguments); + this.tokenType = "WHITESPACE"; + } + toString() { + return "WS"; + } + toSource() { + return " "; + } +}; +var CDOToken = class extends CSSParserToken { + constructor() { + super(...arguments); + this.tokenType = "CDO"; + } + toSource() { + return ""; + } +}; +var ColonToken = class extends CSSParserToken { + constructor() { + super(...arguments); + this.tokenType = ":"; + } +}; +var SemicolonToken = class extends CSSParserToken { + constructor() { + super(...arguments); + this.tokenType = ";"; + } +}; +var CommaToken = class extends CSSParserToken { + constructor() { + super(...arguments); + this.tokenType = ","; + } +}; +var GroupingToken = class extends CSSParserToken { + constructor() { + super(...arguments); + this.value = ""; + this.mirror = ""; + } +}; +var OpenCurlyToken = class extends GroupingToken { + constructor() { + super(); + this.tokenType = "{"; + this.value = "{"; + this.mirror = "}"; + } +}; +var CloseCurlyToken = class extends GroupingToken { + constructor() { + super(); + this.tokenType = "}"; + this.value = "}"; + this.mirror = "{"; + } +}; +var OpenSquareToken = class extends GroupingToken { + constructor() { + super(); + this.tokenType = "["; + this.value = "["; + this.mirror = "]"; + } +}; +var CloseSquareToken = class extends GroupingToken { + constructor() { + super(); + this.tokenType = "]"; + this.value = "]"; + this.mirror = "["; + } +}; +var OpenParenToken = class extends GroupingToken { + constructor() { + super(); + this.tokenType = "("; + this.value = "("; + this.mirror = ")"; + } +}; +var CloseParenToken = class extends GroupingToken { + constructor() { + super(); + this.tokenType = ")"; + this.value = ")"; + this.mirror = "("; + } +}; +var IncludeMatchToken = class extends CSSParserToken { + constructor() { + super(...arguments); + this.tokenType = "~="; + } +}; +var DashMatchToken = class extends CSSParserToken { + constructor() { + super(...arguments); + this.tokenType = "|="; + } +}; +var PrefixMatchToken = class extends CSSParserToken { + constructor() { + super(...arguments); + this.tokenType = "^="; + } +}; +var SuffixMatchToken = class extends CSSParserToken { + constructor() { + super(...arguments); + this.tokenType = "$="; + } +}; +var SubstringMatchToken = class extends CSSParserToken { + constructor() { + super(...arguments); + this.tokenType = "*="; + } +}; +var ColumnToken = class extends CSSParserToken { + constructor() { + super(...arguments); + this.tokenType = "||"; + } +}; +var EOFToken = class extends CSSParserToken { + constructor() { + super(...arguments); + this.tokenType = "EOF"; + } + toSource() { + return ""; + } +}; +var DelimToken = class extends CSSParserToken { + constructor(code) { + super(); + this.tokenType = "DELIM"; + this.value = ""; + this.value = stringFromCode(code); + } + toString() { + return "DELIM(" + this.value + ")"; + } + toJSON() { + const json = this.constructor.prototype.constructor.prototype.toJSON.call(this); + json.value = this.value; + return json; + } + toSource() { + if (this.value === "\\") + return "\\\n"; + else + return this.value; + } +}; +var StringValuedToken = class extends CSSParserToken { + constructor() { + super(...arguments); + this.value = ""; + } + ASCIIMatch(str) { + return this.value.toLowerCase() === str.toLowerCase(); + } + toJSON() { + const json = this.constructor.prototype.constructor.prototype.toJSON.call(this); + json.value = this.value; + return json; + } +}; +var IdentToken = class extends StringValuedToken { + constructor(val) { + super(); + this.tokenType = "IDENT"; + this.value = val; + } + toString() { + return "IDENT(" + this.value + ")"; + } + toSource() { + return escapeIdent(this.value); + } +}; +var FunctionToken = class extends StringValuedToken { + constructor(val) { + super(); + this.tokenType = "FUNCTION"; + this.value = val; + this.mirror = ")"; + } + toString() { + return "FUNCTION(" + this.value + ")"; + } + toSource() { + return escapeIdent(this.value) + "("; + } +}; +var AtKeywordToken = class extends StringValuedToken { + constructor(val) { + super(); + this.tokenType = "AT-KEYWORD"; + this.value = val; + } + toString() { + return "AT(" + this.value + ")"; + } + toSource() { + return "@" + escapeIdent(this.value); + } +}; +var HashToken = class extends StringValuedToken { + constructor(val) { + super(); + this.tokenType = "HASH"; + this.value = val; + this.type = "unrestricted"; + } + toString() { + return "HASH(" + this.value + ")"; + } + toJSON() { + const json = this.constructor.prototype.constructor.prototype.toJSON.call(this); + json.value = this.value; + json.type = this.type; + return json; + } + toSource() { + if (this.type === "id") + return "#" + escapeIdent(this.value); + else + return "#" + escapeHash(this.value); + } +}; +var StringToken = class extends StringValuedToken { + constructor(val) { + super(); + this.tokenType = "STRING"; + this.value = val; + } + toString() { + return '"' + escapeString(this.value) + '"'; + } +}; +var URLToken = class extends StringValuedToken { + constructor(val) { + super(); + this.tokenType = "URL"; + this.value = val; + } + toString() { + return "URL(" + this.value + ")"; + } + toSource() { + return 'url("' + escapeString(this.value) + '")'; + } +}; +var NumberToken = class extends CSSParserToken { + constructor() { + super(); + this.tokenType = "NUMBER"; + this.type = "integer"; + this.repr = ""; + } + toString() { + if (this.type === "integer") + return "INT(" + this.value + ")"; + return "NUMBER(" + this.value + ")"; + } + toJSON() { + const json = super.toJSON(); + json.value = this.value; + json.type = this.type; + json.repr = this.repr; + return json; + } + toSource() { + return this.repr; + } +}; +var PercentageToken = class extends CSSParserToken { + constructor() { + super(); + this.tokenType = "PERCENTAGE"; + this.repr = ""; + } + toString() { + return "PERCENTAGE(" + this.value + ")"; + } + toJSON() { + const json = this.constructor.prototype.constructor.prototype.toJSON.call(this); + json.value = this.value; + json.repr = this.repr; + return json; + } + toSource() { + return this.repr + "%"; + } +}; +var DimensionToken = class extends CSSParserToken { + constructor() { + super(); + this.tokenType = "DIMENSION"; + this.type = "integer"; + this.repr = ""; + this.unit = ""; + } + toString() { + return "DIM(" + this.value + "," + this.unit + ")"; + } + toJSON() { + const json = this.constructor.prototype.constructor.prototype.toJSON.call(this); + json.value = this.value; + json.type = this.type; + json.repr = this.repr; + json.unit = this.unit; + return json; + } + toSource() { + const source = this.repr; + let unit = escapeIdent(this.unit); + if (unit[0].toLowerCase() === "e" && (unit[1] === "-" || between(unit.charCodeAt(1), 48, 57))) { + unit = "\\65 " + unit.slice(1, unit.length); + } + return source + unit; + } +}; +function escapeIdent(string) { + string = "" + string; + let result = ""; + const firstcode = string.charCodeAt(0); + for (let i = 0; i < string.length; i++) { + const code = string.charCodeAt(i); + if (code === 0) + throw new InvalidCharacterError("Invalid character: the input contains U+0000."); + if (between(code, 1, 31) || code === 127 || i === 0 && between(code, 48, 57) || i === 1 && between(code, 48, 57) && firstcode === 45) + result += "\\" + code.toString(16) + " "; + else if (code >= 128 || code === 45 || code === 95 || between(code, 48, 57) || between(code, 65, 90) || between(code, 97, 122)) + result += string[i]; + else + result += "\\" + string[i]; + } + return result; +} +function escapeHash(string) { + string = "" + string; + let result = ""; + for (let i = 0; i < string.length; i++) { + const code = string.charCodeAt(i); + if (code === 0) + throw new InvalidCharacterError("Invalid character: the input contains U+0000."); + if (code >= 128 || code === 45 || code === 95 || between(code, 48, 57) || between(code, 65, 90) || between(code, 97, 122)) + result += string[i]; + else + result += "\\" + code.toString(16) + " "; + } + return result; +} +function escapeString(string) { + string = "" + string; + let result = ""; + for (let i = 0; i < string.length; i++) { + const code = string.charCodeAt(i); + if (code === 0) + throw new InvalidCharacterError("Invalid character: the input contains U+0000."); + if (between(code, 1, 31) || code === 127) + result += "\\" + code.toString(16) + " "; + else if (code === 34 || code === 92) + result += "\\" + string[i]; + else + result += string[i]; + } + return result; +} + +// packages/isomorphic/cssParser.ts +var InvalidSelectorError = class extends Error { +}; +function parseCSS(selector, customNames) { + let tokens; + try { + tokens = tokenize(selector); + if (!(tokens[tokens.length - 1] instanceof EOFToken)) + tokens.push(new EOFToken()); + } catch (e) { + const newMessage = e.message + ` while parsing css selector "${selector}". Did you mean to CSS.escape it?`; + const index = (e.stack || "").indexOf(e.message); + if (index !== -1) + e.stack = e.stack.substring(0, index) + newMessage + e.stack.substring(index + e.message.length); + e.message = newMessage; + throw e; + } + const unsupportedToken = tokens.find((token) => { + return token instanceof AtKeywordToken || token instanceof BadStringToken || token instanceof BadURLToken || token instanceof ColumnToken || token instanceof CDOToken || token instanceof CDCToken || token instanceof SemicolonToken || // TODO: Consider using these for something, e.g. to escape complex strings. + // For example :xpath{ (//div/bar[@attr="foo"])[2]/baz } + // Or this way :xpath( {complex-xpath-goes-here("hello")} ) + token instanceof OpenCurlyToken || token instanceof CloseCurlyToken || // TODO: Consider treating these as strings? + token instanceof URLToken || token instanceof PercentageToken; + }); + if (unsupportedToken) + throw new InvalidSelectorError(`Unsupported token "${unsupportedToken.toSource()}" while parsing css selector "${selector}". Did you mean to CSS.escape it?`); + let pos = 0; + const names = /* @__PURE__ */ new Set(); + function unexpected() { + return new InvalidSelectorError(`Unexpected token "${tokens[pos].toSource()}" while parsing css selector "${selector}". Did you mean to CSS.escape it?`); + } + function skipWhitespace() { + while (tokens[pos] instanceof WhitespaceToken) + pos++; + } + function isIdent(p = pos) { + return tokens[p] instanceof IdentToken; + } + function isString(p = pos) { + return tokens[p] instanceof StringToken; + } + function isNumber(p = pos) { + return tokens[p] instanceof NumberToken; + } + function isComma(p = pos) { + return tokens[p] instanceof CommaToken; + } + function isOpenParen(p = pos) { + return tokens[p] instanceof OpenParenToken; + } + function isCloseParen(p = pos) { + return tokens[p] instanceof CloseParenToken; + } + function isFunction(p = pos) { + return tokens[p] instanceof FunctionToken; + } + function isStar(p = pos) { + return tokens[p] instanceof DelimToken && tokens[p].value === "*"; + } + function isEOF(p = pos) { + return tokens[p] instanceof EOFToken; + } + function isClauseCombinator(p = pos) { + return tokens[p] instanceof DelimToken && [">", "+", "~"].includes(tokens[p].value); + } + function isSelectorClauseEnd(p = pos) { + return isComma(p) || isCloseParen(p) || isEOF(p) || isClauseCombinator(p) || tokens[p] instanceof WhitespaceToken; + } + function consumeFunctionArguments() { + const result2 = [consumeArgument()]; + while (true) { + skipWhitespace(); + if (!isComma()) + break; + pos++; + result2.push(consumeArgument()); + } + return result2; + } + function consumeArgument() { + skipWhitespace(); + if (isNumber()) + return tokens[pos++].value; + if (isString()) + return tokens[pos++].value; + return consumeComplexSelector(); + } + function consumeComplexSelector() { + const result2 = { simples: [] }; + skipWhitespace(); + if (isClauseCombinator()) { + result2.simples.push({ selector: { functions: [{ name: "scope", args: [] }] }, combinator: "" }); + } else { + result2.simples.push({ selector: consumeSimpleSelector(), combinator: "" }); + } + while (true) { + skipWhitespace(); + if (isClauseCombinator()) { + result2.simples[result2.simples.length - 1].combinator = tokens[pos++].value; + skipWhitespace(); + } else if (isSelectorClauseEnd()) { + break; + } + result2.simples.push({ combinator: "", selector: consumeSimpleSelector() }); + } + return result2; + } + function consumeSimpleSelector() { + let rawCSSString = ""; + const functions = []; + while (!isSelectorClauseEnd()) { + if (isIdent() || isStar()) { + rawCSSString += tokens[pos++].toSource(); + } else if (tokens[pos] instanceof HashToken) { + rawCSSString += tokens[pos++].toSource(); + } else if (tokens[pos] instanceof DelimToken && tokens[pos].value === ".") { + pos++; + if (isIdent()) + rawCSSString += "." + tokens[pos++].toSource(); + else + throw unexpected(); + } else if (tokens[pos] instanceof ColonToken) { + pos++; + if (isIdent()) { + if (!customNames.has(tokens[pos].value.toLowerCase())) { + rawCSSString += ":" + tokens[pos++].toSource(); + } else { + const name = tokens[pos++].value.toLowerCase(); + functions.push({ name, args: [] }); + names.add(name); + } + } else if (isFunction()) { + const name = tokens[pos++].value.toLowerCase(); + if (!customNames.has(name)) { + rawCSSString += `:${name}(${consumeBuiltinFunctionArguments()})`; + } else { + functions.push({ name, args: consumeFunctionArguments() }); + names.add(name); + } + skipWhitespace(); + if (!isCloseParen()) + throw unexpected(); + pos++; + } else { + throw unexpected(); + } + } else if (tokens[pos] instanceof OpenSquareToken) { + rawCSSString += "["; + pos++; + while (!(tokens[pos] instanceof CloseSquareToken) && !isEOF()) + rawCSSString += tokens[pos++].toSource(); + if (!(tokens[pos] instanceof CloseSquareToken)) + throw unexpected(); + rawCSSString += "]"; + pos++; + } else { + throw unexpected(); + } + } + if (!rawCSSString && !functions.length) + throw unexpected(); + return { css: rawCSSString || void 0, functions }; + } + function consumeBuiltinFunctionArguments() { + let s = ""; + let balance = 1; + while (!isEOF()) { + if (isOpenParen() || isFunction()) + balance++; + if (isCloseParen()) + balance--; + if (!balance) + break; + s += tokens[pos++].toSource(); + } + return s; + } + const result = consumeFunctionArguments(); + if (!isEOF()) + throw unexpected(); + if (result.some((arg) => typeof arg !== "object" || !("simples" in arg))) + throw new InvalidSelectorError(`Error while parsing css selector "${selector}". Did you mean to CSS.escape it?`); + return { selector: result, names: Array.from(names) }; +} + +// packages/isomorphic/selectorParser.ts +var kNestedSelectorNames = /* @__PURE__ */ new Set(["internal:has", "internal:has-not", "internal:and", "internal:or", "internal:chain", "left-of", "right-of", "above", "below", "near"]); +var kNestedSelectorNamesWithDistance = /* @__PURE__ */ new Set(["left-of", "right-of", "above", "below", "near"]); +var customCSSNames = /* @__PURE__ */ new Set(["not", "is", "where", "has", "scope", "light", "visible", "text", "text-matches", "text-is", "has-text", "above", "below", "right-of", "left-of", "near", "nth-match"]); +function parseSelector(selector) { + const parsedStrings = parseSelectorString(selector); + const parts = []; + for (const part of parsedStrings.parts) { + if (part.name === "css" || part.name === "css:light") { + if (part.name === "css:light") + part.body = ":light(" + part.body + ")"; + const parsedCSS = parseCSS(part.body, customCSSNames); + parts.push({ + name: "css", + body: parsedCSS.selector, + source: part.body + }); + continue; + } + if (kNestedSelectorNames.has(part.name)) { + let innerSelector; + let distance; + try { + const unescaped = JSON.parse("[" + part.body + "]"); + if (!Array.isArray(unescaped) || unescaped.length < 1 || unescaped.length > 2 || typeof unescaped[0] !== "string") + throw new InvalidSelectorError(`Malformed selector: ${part.name}=` + part.body); + innerSelector = unescaped[0]; + if (unescaped.length === 2) { + if (typeof unescaped[1] !== "number" || !kNestedSelectorNamesWithDistance.has(part.name)) + throw new InvalidSelectorError(`Malformed selector: ${part.name}=` + part.body); + distance = unescaped[1]; + } + } catch (e) { + throw new InvalidSelectorError(`Malformed selector: ${part.name}=` + part.body); + } + const nested = { name: part.name, source: part.body, body: { parsed: parseSelector(innerSelector), distance } }; + const lastFrame = [...nested.body.parsed.parts].reverse().find((part2) => part2.name === "internal:control" && part2.body === "enter-frame"); + const lastFrameIndex = lastFrame ? nested.body.parsed.parts.indexOf(lastFrame) : -1; + if (lastFrameIndex !== -1 && selectorPartsEqual(nested.body.parsed.parts.slice(0, lastFrameIndex + 1), parts.slice(0, lastFrameIndex + 1))) + nested.body.parsed.parts.splice(0, lastFrameIndex + 1); + parts.push(nested); + continue; + } + parts.push({ ...part, source: part.body }); + } + if (kNestedSelectorNames.has(parts[0].name)) + throw new InvalidSelectorError(`"${parts[0].name}" selector cannot be first`); + return { + capture: parsedStrings.capture, + parts + }; +} +function selectorPartsEqual(list1, list2) { + return stringifySelector({ parts: list1 }) === stringifySelector({ parts: list2 }); +} +function stringifySelector(selector, forceEngineName) { + if (typeof selector === "string") + return selector; + return selector.parts.map((p, i) => { + let includeEngine = true; + if (!forceEngineName && i !== selector.capture) { + if (p.name === "css") + includeEngine = false; + else if (p.name === "xpath" && p.source.startsWith("//") || p.source.startsWith("..")) + includeEngine = false; + } + const prefix = includeEngine ? p.name + "=" : ""; + return `${i === selector.capture ? "*" : ""}${prefix}${p.source}`; + }).join(" >> "); +} +function visitAllSelectorParts(selector, visitor) { + const visit = (selector2, nested) => { + for (const part of selector2.parts) { + visitor(part, nested); + if (kNestedSelectorNames.has(part.name)) + visit(part.body.parsed, true); + } + }; + visit(selector, false); +} +function parseSelectorString(selector) { + let index = 0; + let quote; + let start = 0; + const result = { parts: [] }; + const append = () => { + const part = selector.substring(start, index).trim(); + const eqIndex = part.indexOf("="); + let name; + let body; + if (eqIndex !== -1 && part.substring(0, eqIndex).trim().match(/^[a-zA-Z_0-9-+:*]+$/)) { + name = part.substring(0, eqIndex).trim(); + body = part.substring(eqIndex + 1); + } else if (part.length > 1 && part[0] === '"' && part[part.length - 1] === '"') { + name = "text"; + body = part; + } else if (part.length > 1 && part[0] === "'" && part[part.length - 1] === "'") { + name = "text"; + body = part; + } else if (/^\(*\/\//.test(part) || part.startsWith("..")) { + name = "xpath"; + body = part; + } else { + name = "css"; + body = part; + } + let capture = false; + if (name[0] === "*") { + capture = true; + name = name.substring(1); + } + result.parts.push({ name, body }); + if (capture) { + if (result.capture !== void 0) + throw new InvalidSelectorError(`Only one of the selectors can capture using * modifier`); + result.capture = result.parts.length - 1; + } + }; + if (!selector.includes(">>")) { + index = selector.length; + append(); + return result; + } + const shouldIgnoreTextSelectorQuote = () => { + const prefix = selector.substring(start, index); + const match = prefix.match(/^\s*text\s*=(.*)$/); + return !!match && !!match[1]; + }; + while (index < selector.length) { + const c = selector[index]; + if (c === "\\" && index + 1 < selector.length) { + index += 2; + } else if (c === quote) { + quote = void 0; + index++; + } else if (!quote && (c === '"' || c === "'" || c === "`") && !shouldIgnoreTextSelectorQuote()) { + quote = c; + index++; + } else if (!quote && c === ">" && selector[index + 1] === ">") { + append(); + index += 2; + start = index; + } else { + index++; + } + } + append(); + return result; +} +function parseAttributeSelector(selector, allowUnquotedStrings) { + let wp = 0; + let EOL = selector.length === 0; + const next = () => selector[wp] || ""; + const eat1 = () => { + const result2 = next(); + ++wp; + EOL = wp >= selector.length; + return result2; + }; + const syntaxError = (stage) => { + if (EOL) + throw new InvalidSelectorError(`Unexpected end of selector while parsing selector \`${selector}\``); + throw new InvalidSelectorError(`Error while parsing selector \`${selector}\` - unexpected symbol "${next()}" at position ${wp}` + (stage ? " during " + stage : "")); + }; + function skipSpaces() { + while (!EOL && /\s/.test(next())) + eat1(); + } + function isCSSNameChar(char) { + return char >= "\x80" || char >= "0" && char <= "9" || char >= "A" && char <= "Z" || char >= "a" && char <= "z" || char >= "0" && char <= "9" || char === "_" || char === "-"; + } + function readIdentifier() { + let result2 = ""; + skipSpaces(); + while (!EOL && isCSSNameChar(next())) + result2 += eat1(); + return result2; + } + function readQuotedString(quote) { + let result2 = eat1(); + if (result2 !== quote) + syntaxError("parsing quoted string"); + while (!EOL && next() !== quote) { + if (next() === "\\") + eat1(); + result2 += eat1(); + } + if (next() !== quote) + syntaxError("parsing quoted string"); + result2 += eat1(); + return result2; + } + function readRegularExpression() { + if (eat1() !== "/") + syntaxError("parsing regular expression"); + let source = ""; + let inClass = false; + while (!EOL) { + if (next() === "\\") { + source += eat1(); + if (EOL) + syntaxError("parsing regular expression"); + } else if (inClass && next() === "]") { + inClass = false; + } else if (!inClass && next() === "[") { + inClass = true; + } else if (!inClass && next() === "/") { + break; + } + source += eat1(); + } + if (eat1() !== "/") + syntaxError("parsing regular expression"); + let flags = ""; + while (!EOL && next().match(/[dgimsuy]/)) + flags += eat1(); + try { + return new RegExp(source, flags); + } catch (e) { + throw new InvalidSelectorError(`Error while parsing selector \`${selector}\`: ${e.message}`); + } + } + function readAttributeToken() { + let token = ""; + skipSpaces(); + if (next() === `'` || next() === `"`) + token = readQuotedString(next()).slice(1, -1); + else + token = readIdentifier(); + if (!token) + syntaxError("parsing property path"); + return token; + } + function readOperator() { + skipSpaces(); + let op = ""; + if (!EOL) + op += eat1(); + if (!EOL && op !== "=") + op += eat1(); + if (!["=", "*=", "^=", "$=", "|=", "~="].includes(op)) + syntaxError("parsing operator"); + return op; + } + function readAttribute() { + eat1(); + const jsonPath = []; + jsonPath.push(readAttributeToken()); + skipSpaces(); + while (next() === ".") { + eat1(); + jsonPath.push(readAttributeToken()); + skipSpaces(); + } + if (next() === "]") { + eat1(); + return { name: jsonPath.join("."), jsonPath, op: "", value: null, caseSensitive: false }; + } + const operator = readOperator(); + let value = void 0; + let caseSensitive = true; + skipSpaces(); + if (next() === "/") { + if (operator !== "=") + throw new InvalidSelectorError(`Error while parsing selector \`${selector}\` - cannot use ${operator} in attribute with regular expression`); + value = readRegularExpression(); + } else if (next() === `'` || next() === `"`) { + value = readQuotedString(next()).slice(1, -1); + skipSpaces(); + if (next() === "i" || next() === "I") { + caseSensitive = false; + eat1(); + } else if (next() === "s" || next() === "S") { + caseSensitive = true; + eat1(); + } + } else { + value = ""; + while (!EOL && (isCSSNameChar(next()) || next() === "+" || next() === ".")) + value += eat1(); + if (value === "true") { + value = true; + } else if (value === "false") { + value = false; + } else { + if (!allowUnquotedStrings) { + value = +value; + if (Number.isNaN(value)) + syntaxError("parsing attribute value"); + } + } + } + skipSpaces(); + if (next() !== "]") + syntaxError("parsing attribute value"); + eat1(); + if (operator !== "=" && typeof value !== "string") + throw new InvalidSelectorError(`Error while parsing selector \`${selector}\` - cannot use ${operator} in attribute with non-string matching value - ${value}`); + return { name: jsonPath.join("."), jsonPath, op: operator, value, caseSensitive }; + } + const result = { + name: "", + attributes: [] + }; + result.name = readIdentifier(); + skipSpaces(); + while (next() === "[") { + result.attributes.push(readAttribute()); + skipSpaces(); + } + if (!EOL) + syntaxError(void 0); + if (!result.name && !result.attributes.length) + throw new InvalidSelectorError(`Error while parsing selector \`${selector}\` - selector cannot be empty`); + return result; +} + +// packages/isomorphic/stringUtils.ts +function escapeWithQuotes(text, char = "'") { + const stringified = JSON.stringify(text); + const escapedText = stringified.substring(1, stringified.length - 1).replace(/\\"/g, '"'); + if (char === "'") + return char + escapedText.replace(/[']/g, "\\'") + char; + if (char === '"') + return char + escapedText.replace(/["]/g, '\\"') + char; + if (char === "`") + return char + escapedText.replace(/[`]/g, "\\`") + char; + throw new Error("Invalid escape char"); +} +function toTitleCase(name) { + return name.charAt(0).toUpperCase() + name.substring(1); +} +function toSnakeCase(name) { + return name.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/([A-Z])([A-Z][a-z])/g, "$1_$2").toLowerCase(); +} +function quoteCSSAttributeValue(text) { + return `"${text.replace(/["\\]/g, (char) => "\\" + char)}"`; +} +var normalizedWhitespaceCache; +function cacheNormalizedWhitespaces() { + normalizedWhitespaceCache = /* @__PURE__ */ new Map(); +} +function normalizeWhiteSpace(text) { + let result = normalizedWhitespaceCache == null ? void 0 : normalizedWhitespaceCache.get(text); + if (result === void 0) { + result = text.replace(/[\u200b\u00ad]/g, "").trim().replace(/\s+/g, " "); + normalizedWhitespaceCache == null ? void 0 : normalizedWhitespaceCache.set(text, result); + } + return result; +} +function normalizeEscapedRegexQuotes(source) { + return source.replace(/(^|[^\\])(\\\\)*\\(['"`])/g, "$1$2$3"); +} +function escapeRegexForSelector(re) { + if (re.unicode || re.unicodeSets) + return String(re); + return String(re).replace(/(^|[^\\])(\\\\)*(["'`])/g, "$1$2\\$3").replace(/>>/g, "\\>\\>"); +} +function escapeForTextSelector(text, exact) { + if (typeof text !== "string") + return escapeRegexForSelector(text); + return `${JSON.stringify(text)}${exact ? "s" : "i"}`; +} +function escapeForAttributeSelector(value, exact) { + if (typeof value !== "string") + return escapeRegexForSelector(value); + return `"${value.replace(/\\/g, "\\\\").replace(/["]/g, '\\"')}"${exact ? "s" : "i"}`; +} +function trimString(input, cap, suffix = "") { + if (input.length <= cap) + return input; + const chars = [...input]; + if (chars.length > cap) + return chars.slice(0, cap - suffix.length).join("") + suffix; + return chars.join(""); +} +function trimStringWithEllipsis(input, cap) { + return trimString(input, cap, "\u2026"); +} +function escapeRegExp(s) { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} +function longestCommonSubstring(s1, s2) { + const n = s1.length; + const m = s2.length; + let maxLen = 0; + let endingIndex = 0; + const dp = Array(n + 1).fill(null).map(() => Array(m + 1).fill(0)); + for (let i = 1; i <= n; i++) { + for (let j = 1; j <= m; j++) { + if (s1[i - 1] === s2[j - 1]) { + dp[i][j] = dp[i - 1][j - 1] + 1; + if (dp[i][j] > maxLen) { + maxLen = dp[i][j]; + endingIndex = i; + } + } + } + } + return s1.slice(endingIndex - maxLen, endingIndex); +} +var ansiRegex = new RegExp("([\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~])))", "g"); + +// packages/isomorphic/locatorGenerators.ts +function asLocator(lang, selector, isFrameLocator = false) { + return asLocators(lang, selector, isFrameLocator, 1)[0]; +} +function asLocators(lang, selector, isFrameLocator = false, maxOutputSize = 20, preferredQuote) { + try { + return innerAsLocators(new generators[lang](preferredQuote), parseSelector(selector), isFrameLocator, maxOutputSize); + } catch (e) { + return [selector]; + } +} +function innerAsLocators(factory, parsed, isFrameLocator = false, maxOutputSize = 20) { + const parts = [...parsed.parts]; + const tokens = []; + let nextBase = isFrameLocator ? "frame-locator" : "page"; + for (let index = 0; index < parts.length; index++) { + const part = parts[index]; + const base = nextBase; + nextBase = "locator"; + if (part.name === "internal:describe") + continue; + if (part.name === "nth") { + if (part.body === "0") + tokens.push([factory.generateLocator(base, "first", ""), factory.generateLocator(base, "nth", "0")]); + else if (part.body === "-1") + tokens.push([factory.generateLocator(base, "last", ""), factory.generateLocator(base, "nth", "-1")]); + else + tokens.push([factory.generateLocator(base, "nth", part.body)]); + continue; + } + if (part.name === "visible") { + tokens.push([factory.generateLocator(base, "visible", part.body), factory.generateLocator(base, "default", `visible=${part.body}`)]); + continue; + } + if (part.name === "internal:text") { + const { exact, text } = detectExact(part.body); + tokens.push([factory.generateLocator(base, "text", text, { exact })]); + continue; + } + if (part.name === "internal:has-text") { + const { exact, text } = detectExact(part.body); + if (!exact) { + tokens.push([factory.generateLocator(base, "has-text", text, { exact })]); + continue; + } + } + if (part.name === "internal:has-not-text") { + const { exact, text } = detectExact(part.body); + if (!exact) { + tokens.push([factory.generateLocator(base, "has-not-text", text, { exact })]); + continue; + } + } + if (part.name === "internal:has") { + const inners = innerAsLocators(factory, part.body.parsed, false, maxOutputSize); + tokens.push(inners.map((inner) => factory.generateLocator(base, "has", inner))); + continue; + } + if (part.name === "internal:has-not") { + const inners = innerAsLocators(factory, part.body.parsed, false, maxOutputSize); + tokens.push(inners.map((inner) => factory.generateLocator(base, "hasNot", inner))); + continue; + } + if (part.name === "internal:and") { + const inners = innerAsLocators(factory, part.body.parsed, false, maxOutputSize); + tokens.push(inners.map((inner) => factory.generateLocator(base, "and", inner))); + continue; + } + if (part.name === "internal:or") { + const inners = innerAsLocators(factory, part.body.parsed, false, maxOutputSize); + tokens.push(inners.map((inner) => factory.generateLocator(base, "or", inner))); + continue; + } + if (part.name === "internal:chain") { + const inners = innerAsLocators(factory, part.body.parsed, false, maxOutputSize); + tokens.push(inners.map((inner) => factory.generateLocator(base, "chain", inner))); + continue; + } + if (part.name === "internal:label") { + const { exact, text } = detectExact(part.body); + tokens.push([factory.generateLocator(base, "label", text, { exact })]); + continue; + } + if (part.name === "internal:role") { + const attrSelector = parseAttributeSelector(part.body, true); + const options = { attrs: [] }; + for (const attr of attrSelector.attributes) { + if (attr.name === "name") { + options.exact = attr.caseSensitive; + options.name = attr.value; + } else if (attr.name === "description") { + options.exact = attr.caseSensitive; + options.description = attr.value; + } else { + if (attr.name === "level" && typeof attr.value === "string") + attr.value = +attr.value; + options.attrs.push({ name: attr.name === "include-hidden" ? "includeHidden" : attr.name, value: attr.value }); + } + } + tokens.push([factory.generateLocator(base, "role", attrSelector.name, options)]); + continue; + } + if (part.name === "internal:testid") { + const attrSelector = parseAttributeSelector(part.body, true); + const { value } = attrSelector.attributes[0]; + tokens.push([factory.generateLocator(base, "test-id", value)]); + continue; + } + if (part.name === "internal:attr") { + const attrSelector = parseAttributeSelector(part.body, true); + const { name, value, caseSensitive } = attrSelector.attributes[0]; + const text = value; + const exact = !!caseSensitive; + if (name === "placeholder") { + tokens.push([factory.generateLocator(base, "placeholder", text, { exact })]); + continue; + } + if (name === "alt") { + tokens.push([factory.generateLocator(base, "alt", text, { exact })]); + continue; + } + if (name === "title") { + tokens.push([factory.generateLocator(base, "title", text, { exact })]); + continue; + } + } + if (part.name === "internal:control" && part.body === "enter-frame") { + const lastTokens = tokens[tokens.length - 1]; + const lastPart = parts[index - 1]; + const transformed = lastTokens.map((token) => factory.chainLocators([token, factory.generateLocator(base, "frame", "")])); + if (["xpath", "css"].includes(lastPart.name)) { + transformed.push( + factory.generateLocator(base, "frame-locator", stringifySelector({ parts: [lastPart] })), + factory.generateLocator(base, "frame-locator", stringifySelector({ parts: [lastPart] }, true)) + ); + } + lastTokens.splice(0, lastTokens.length, ...transformed); + nextBase = "frame-locator"; + continue; + } + const nextPart = parts[index + 1]; + const selectorPart = stringifySelector({ parts: [part] }); + const locatorPart = factory.generateLocator(base, "default", selectorPart); + if (nextPart && ["internal:has-text", "internal:has-not-text"].includes(nextPart.name)) { + const { exact, text } = detectExact(nextPart.body); + if (!exact) { + const nextLocatorPart = factory.generateLocator("locator", nextPart.name === "internal:has-text" ? "has-text" : "has-not-text", text, { exact }); + const options = {}; + if (nextPart.name === "internal:has-text") + options.hasText = text; + else + options.hasNotText = text; + const combinedPart = factory.generateLocator(base, "default", selectorPart, options); + tokens.push([factory.chainLocators([locatorPart, nextLocatorPart]), combinedPart]); + index++; + continue; + } + } + let locatorPartWithEngine; + if (["xpath", "css"].includes(part.name)) { + const selectorPart2 = stringifySelector( + { parts: [part] }, + /* forceEngineName */ + true + ); + locatorPartWithEngine = factory.generateLocator(base, "default", selectorPart2); + } + tokens.push([locatorPart, locatorPartWithEngine].filter(Boolean)); + } + return combineTokens(factory, tokens, maxOutputSize); +} +function combineTokens(factory, tokens, maxOutputSize) { + const currentTokens = tokens.map(() => ""); + const result = []; + const visit = (index) => { + if (index === tokens.length) { + result.push(factory.chainLocators(currentTokens)); + return result.length < maxOutputSize; + } + for (const taken of tokens[index]) { + currentTokens[index] = taken; + if (!visit(index + 1)) + return false; + } + return true; + }; + visit(0); + return result; +} +function detectExact(text) { + let exact = false; + const match = text.match(/^\/(.*)\/([igm]*)$/); + if (match) + return { text: new RegExp(match[1], match[2]) }; + if (text.endsWith('"')) { + text = JSON.parse(text); + exact = true; + } else if (text.endsWith('"s')) { + text = JSON.parse(text.substring(0, text.length - 1)); + exact = true; + } else if (text.endsWith('"i')) { + text = JSON.parse(text.substring(0, text.length - 1)); + exact = false; + } + return { exact, text }; +} +var JavaScriptLocatorFactory = class { + constructor(preferredQuote) { + this.preferredQuote = preferredQuote; + } + generateLocator(base, kind, body, options = {}) { + switch (kind) { + case "default": + if (options.hasText !== void 0) + return `locator(${this.quote(body)}, { hasText: ${this.toHasText(options.hasText)} })`; + if (options.hasNotText !== void 0) + return `locator(${this.quote(body)}, { hasNotText: ${this.toHasText(options.hasNotText)} })`; + return `locator(${this.quote(body)})`; + case "frame-locator": + return `frameLocator(${this.quote(body)})`; + case "frame": + return `contentFrame()`; + case "nth": + return `nth(${body})`; + case "first": + return `first()`; + case "last": + return `last()`; + case "visible": + return `filter({ visible: ${body === "true" ? "true" : "false"} })`; + case "role": + const attrs = []; + if (isRegExp(options.name)) + attrs.push(`name: ${this.regexToSourceString(options.name)}`); + else if (typeof options.name === "string") + attrs.push(`name: ${this.quote(options.name)}`); + if (isRegExp(options.description)) + attrs.push(`description: ${this.regexToSourceString(options.description)}`); + else if (typeof options.description === "string") + attrs.push(`description: ${this.quote(options.description)}`); + if (options.exact && (typeof options.name === "string" || typeof options.description === "string")) + attrs.push(`exact: true`); + for (const { name, value } of options.attrs) + attrs.push(`${name}: ${typeof value === "string" ? this.quote(value) : value}`); + const attrString = attrs.length ? `, { ${attrs.join(", ")} }` : ""; + return `getByRole(${this.quote(body)}${attrString})`; + case "has-text": + return `filter({ hasText: ${this.toHasText(body)} })`; + case "has-not-text": + return `filter({ hasNotText: ${this.toHasText(body)} })`; + case "has": + return `filter({ has: ${body} })`; + case "hasNot": + return `filter({ hasNot: ${body} })`; + case "and": + return `and(${body})`; + case "or": + return `or(${body})`; + case "chain": + return `locator(${body})`; + case "test-id": + return `getByTestId(${this.toTestIdValue(body)})`; + case "text": + return this.toCallWithExact("getByText", body, !!options.exact); + case "alt": + return this.toCallWithExact("getByAltText", body, !!options.exact); + case "placeholder": + return this.toCallWithExact("getByPlaceholder", body, !!options.exact); + case "label": + return this.toCallWithExact("getByLabel", body, !!options.exact); + case "title": + return this.toCallWithExact("getByTitle", body, !!options.exact); + default: + throw new Error("Unknown selector kind " + kind); + } + } + chainLocators(locators) { + return locators.join("."); + } + regexToSourceString(re) { + return normalizeEscapedRegexQuotes(String(re)); + } + toCallWithExact(method, body, exact) { + if (isRegExp(body)) + return `${method}(${this.regexToSourceString(body)})`; + return exact ? `${method}(${this.quote(body)}, { exact: true })` : `${method}(${this.quote(body)})`; + } + toHasText(body) { + if (isRegExp(body)) + return this.regexToSourceString(body); + return this.quote(body); + } + toTestIdValue(value) { + if (isRegExp(value)) + return this.regexToSourceString(value); + return this.quote(value); + } + quote(text) { + var _a; + return escapeWithQuotes(text, (_a = this.preferredQuote) != null ? _a : "'"); + } +}; +var PythonLocatorFactory = class { + generateLocator(base, kind, body, options = {}) { + switch (kind) { + case "default": + if (options.hasText !== void 0) + return `locator(${this.quote(body)}, has_text=${this.toHasText(options.hasText)})`; + if (options.hasNotText !== void 0) + return `locator(${this.quote(body)}, has_not_text=${this.toHasText(options.hasNotText)})`; + return `locator(${this.quote(body)})`; + case "frame-locator": + return `frame_locator(${this.quote(body)})`; + case "frame": + return `content_frame`; + case "nth": + return `nth(${body})`; + case "first": + return `first`; + case "last": + return `last`; + case "visible": + return `filter(visible=${body === "true" ? "True" : "False"})`; + case "role": + const attrs = []; + if (isRegExp(options.name)) + attrs.push(`name=${this.regexToString(options.name)}`); + else if (typeof options.name === "string") + attrs.push(`name=${this.quote(options.name)}`); + if (isRegExp(options.description)) + attrs.push(`description=${this.regexToString(options.description)}`); + else if (typeof options.description === "string") + attrs.push(`description=${this.quote(options.description)}`); + if (options.exact && (typeof options.name === "string" || typeof options.description === "string")) + attrs.push(`exact=True`); + for (const { name, value } of options.attrs) { + let valueString = typeof value === "string" ? this.quote(value) : value; + if (typeof value === "boolean") + valueString = value ? "True" : "False"; + attrs.push(`${toSnakeCase(name)}=${valueString}`); + } + const attrString = attrs.length ? `, ${attrs.join(", ")}` : ""; + return `get_by_role(${this.quote(body)}${attrString})`; + case "has-text": + return `filter(has_text=${this.toHasText(body)})`; + case "has-not-text": + return `filter(has_not_text=${this.toHasText(body)})`; + case "has": + return `filter(has=${body})`; + case "hasNot": + return `filter(has_not=${body})`; + case "and": + return `and_(${body})`; + case "or": + return `or_(${body})`; + case "chain": + return `locator(${body})`; + case "test-id": + return `get_by_test_id(${this.toTestIdValue(body)})`; + case "text": + return this.toCallWithExact("get_by_text", body, !!options.exact); + case "alt": + return this.toCallWithExact("get_by_alt_text", body, !!options.exact); + case "placeholder": + return this.toCallWithExact("get_by_placeholder", body, !!options.exact); + case "label": + return this.toCallWithExact("get_by_label", body, !!options.exact); + case "title": + return this.toCallWithExact("get_by_title", body, !!options.exact); + default: + throw new Error("Unknown selector kind " + kind); + } + } + chainLocators(locators) { + return locators.join("."); + } + regexToString(body) { + const suffix = body.flags.includes("i") ? ", re.IGNORECASE" : ""; + return `re.compile(r"${normalizeEscapedRegexQuotes(body.source).replace(/\\\//, "/").replace(/"/g, '\\"')}"${suffix})`; + } + toCallWithExact(method, body, exact) { + if (isRegExp(body)) + return `${method}(${this.regexToString(body)})`; + if (exact) + return `${method}(${this.quote(body)}, exact=True)`; + return `${method}(${this.quote(body)})`; + } + toHasText(body) { + if (isRegExp(body)) + return this.regexToString(body); + return `${this.quote(body)}`; + } + toTestIdValue(value) { + if (isRegExp(value)) + return this.regexToString(value); + return this.quote(value); + } + quote(text) { + return escapeWithQuotes(text, '"'); + } +}; +var JavaLocatorFactory = class { + generateLocator(base, kind, body, options = {}) { + let clazz; + switch (base) { + case "page": + clazz = "Page"; + break; + case "frame-locator": + clazz = "FrameLocator"; + break; + case "locator": + clazz = "Locator"; + break; + } + switch (kind) { + case "default": + if (options.hasText !== void 0) + return `locator(${this.quote(body)}, new ${clazz}.LocatorOptions().setHasText(${this.toHasText(options.hasText)}))`; + if (options.hasNotText !== void 0) + return `locator(${this.quote(body)}, new ${clazz}.LocatorOptions().setHasNotText(${this.toHasText(options.hasNotText)}))`; + return `locator(${this.quote(body)})`; + case "frame-locator": + return `frameLocator(${this.quote(body)})`; + case "frame": + return `contentFrame()`; + case "nth": + return `nth(${body})`; + case "first": + return `first()`; + case "last": + return `last()`; + case "visible": + return `filter(new ${clazz}.FilterOptions().setVisible(${body === "true" ? "true" : "false"}))`; + case "role": + const attrs = []; + if (isRegExp(options.name)) + attrs.push(`.setName(${this.regexToString(options.name)})`); + else if (typeof options.name === "string") + attrs.push(`.setName(${this.quote(options.name)})`); + if (isRegExp(options.description)) + attrs.push(`.setDescription(${this.regexToString(options.description)})`); + else if (typeof options.description === "string") + attrs.push(`.setDescription(${this.quote(options.description)})`); + if (options.exact && (typeof options.name === "string" || typeof options.description === "string")) + attrs.push(`.setExact(true)`); + for (const { name, value } of options.attrs) + attrs.push(`.set${toTitleCase(name)}(${typeof value === "string" ? this.quote(value) : value})`); + const attrString = attrs.length ? `, new ${clazz}.GetByRoleOptions()${attrs.join("")}` : ""; + return `getByRole(AriaRole.${toSnakeCase(body).toUpperCase()}${attrString})`; + case "has-text": + return `filter(new ${clazz}.FilterOptions().setHasText(${this.toHasText(body)}))`; + case "has-not-text": + return `filter(new ${clazz}.FilterOptions().setHasNotText(${this.toHasText(body)}))`; + case "has": + return `filter(new ${clazz}.FilterOptions().setHas(${body}))`; + case "hasNot": + return `filter(new ${clazz}.FilterOptions().setHasNot(${body}))`; + case "and": + return `and(${body})`; + case "or": + return `or(${body})`; + case "chain": + return `locator(${body})`; + case "test-id": + return `getByTestId(${this.toTestIdValue(body)})`; + case "text": + return this.toCallWithExact(clazz, "getByText", body, !!options.exact); + case "alt": + return this.toCallWithExact(clazz, "getByAltText", body, !!options.exact); + case "placeholder": + return this.toCallWithExact(clazz, "getByPlaceholder", body, !!options.exact); + case "label": + return this.toCallWithExact(clazz, "getByLabel", body, !!options.exact); + case "title": + return this.toCallWithExact(clazz, "getByTitle", body, !!options.exact); + default: + throw new Error("Unknown selector kind " + kind); + } + } + chainLocators(locators) { + return locators.join("."); + } + regexToString(body) { + const suffix = body.flags.includes("i") ? ", Pattern.CASE_INSENSITIVE" : ""; + return `Pattern.compile(${this.quote(normalizeEscapedRegexQuotes(body.source))}${suffix})`; + } + toCallWithExact(clazz, method, body, exact) { + if (isRegExp(body)) + return `${method}(${this.regexToString(body)})`; + if (exact) + return `${method}(${this.quote(body)}, new ${clazz}.${toTitleCase(method)}Options().setExact(true))`; + return `${method}(${this.quote(body)})`; + } + toHasText(body) { + if (isRegExp(body)) + return this.regexToString(body); + return this.quote(body); + } + toTestIdValue(value) { + if (isRegExp(value)) + return this.regexToString(value); + return this.quote(value); + } + quote(text) { + return escapeWithQuotes(text, '"'); + } +}; +var CSharpLocatorFactory = class { + generateLocator(base, kind, body, options = {}) { + switch (kind) { + case "default": + if (options.hasText !== void 0) + return `Locator(${this.quote(body)}, new() { ${this.toHasText(options.hasText)} })`; + if (options.hasNotText !== void 0) + return `Locator(${this.quote(body)}, new() { ${this.toHasNotText(options.hasNotText)} })`; + return `Locator(${this.quote(body)})`; + case "frame-locator": + return `FrameLocator(${this.quote(body)})`; + case "frame": + return `ContentFrame`; + case "nth": + return `Nth(${body})`; + case "first": + return `First`; + case "last": + return `Last`; + case "visible": + return `Filter(new() { Visible = ${body === "true" ? "true" : "false"} })`; + case "role": + const attrs = []; + if (isRegExp(options.name)) + attrs.push(`NameRegex = ${this.regexToString(options.name)}`); + else if (typeof options.name === "string") + attrs.push(`Name = ${this.quote(options.name)}`); + if (isRegExp(options.description)) + attrs.push(`DescriptionRegex = ${this.regexToString(options.description)}`); + else if (typeof options.description === "string") + attrs.push(`Description = ${this.quote(options.description)}`); + if (options.exact && (typeof options.name === "string" || typeof options.description === "string")) + attrs.push(`Exact = true`); + for (const { name, value } of options.attrs) + attrs.push(`${toTitleCase(name)} = ${typeof value === "string" ? this.quote(value) : value}`); + const attrString = attrs.length ? `, new() { ${attrs.join(", ")} }` : ""; + return `GetByRole(AriaRole.${toTitleCase(body)}${attrString})`; + case "has-text": + return `Filter(new() { ${this.toHasText(body)} })`; + case "has-not-text": + return `Filter(new() { ${this.toHasNotText(body)} })`; + case "has": + return `Filter(new() { Has = ${body} })`; + case "hasNot": + return `Filter(new() { HasNot = ${body} })`; + case "and": + return `And(${body})`; + case "or": + return `Or(${body})`; + case "chain": + return `Locator(${body})`; + case "test-id": + return `GetByTestId(${this.toTestIdValue(body)})`; + case "text": + return this.toCallWithExact("GetByText", body, !!options.exact); + case "alt": + return this.toCallWithExact("GetByAltText", body, !!options.exact); + case "placeholder": + return this.toCallWithExact("GetByPlaceholder", body, !!options.exact); + case "label": + return this.toCallWithExact("GetByLabel", body, !!options.exact); + case "title": + return this.toCallWithExact("GetByTitle", body, !!options.exact); + default: + throw new Error("Unknown selector kind " + kind); + } + } + chainLocators(locators) { + return locators.join("."); + } + regexToString(body) { + const suffix = body.flags.includes("i") ? ", RegexOptions.IgnoreCase" : ""; + return `new Regex(${this.quote(normalizeEscapedRegexQuotes(body.source))}${suffix})`; + } + toCallWithExact(method, body, exact) { + if (isRegExp(body)) + return `${method}(${this.regexToString(body)})`; + if (exact) + return `${method}(${this.quote(body)}, new() { Exact = true })`; + return `${method}(${this.quote(body)})`; + } + toHasText(body) { + if (isRegExp(body)) + return `HasTextRegex = ${this.regexToString(body)}`; + return `HasText = ${this.quote(body)}`; + } + toTestIdValue(value) { + if (isRegExp(value)) + return this.regexToString(value); + return this.quote(value); + } + toHasNotText(body) { + if (isRegExp(body)) + return `HasNotTextRegex = ${this.regexToString(body)}`; + return `HasNotText = ${this.quote(body)}`; + } + quote(text) { + return escapeWithQuotes(text, '"'); + } +}; +var JsonlLocatorFactory = class { + generateLocator(base, kind, body, options = {}) { + return JSON.stringify({ + kind, + body, + options + }); + } + chainLocators(locators) { + const objects = locators.map((l) => JSON.parse(l)); + for (let i = 0; i < objects.length - 1; ++i) + objects[i].next = objects[i + 1]; + return JSON.stringify(objects[0]); + } +}; +var generators = { + javascript: JavaScriptLocatorFactory, + python: PythonLocatorFactory, + java: JavaLocatorFactory, + csharp: CSharpLocatorFactory, + jsonl: JsonlLocatorFactory +}; +function isRegExp(obj) { + return obj instanceof RegExp; +} + +// packages/isomorphic/yaml.ts +function yamlEscapeKeyIfNeeded(str) { + if (!yamlStringNeedsQuotes(str)) + return str; + return `'` + str.replace(/'/g, `''`) + `'`; +} +function yamlEscapeValueIfNeeded(str) { + if (!yamlStringNeedsQuotes(str)) + return str; + return '"' + str.replace(/[\\"\x00-\x1f\x7f-\x9f]/g, (c) => { + switch (c) { + case "\\": + return "\\\\"; + case '"': + return '\\"'; + case "\b": + return "\\b"; + case "\f": + return "\\f"; + case "\n": + return "\\n"; + case "\r": + return "\\r"; + case " ": + return "\\t"; + default: + const code = c.charCodeAt(0); + return "\\x" + code.toString(16).padStart(2, "0"); + } + }) + '"'; +} +function yamlStringNeedsQuotes(str) { + if (str.length === 0) + return true; + if (/^\s|\s$/.test(str)) + return true; + if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/.test(str)) + return true; + if (/^-/.test(str)) + return true; + if (/[\n:](\s|$)/.test(str)) + return true; + if (/\s#/.test(str)) + return true; + if (/[\n\r]/.test(str)) + return true; + if (/^[&*\],?!>|@"'#%]/.test(str)) + return true; + if (/[{}`]/.test(str)) + return true; + if (/^\[/.test(str)) + return true; + if (!isNaN(Number(str)) || ["y", "n", "yes", "no", "true", "false", "on", "off", "null"].includes(str.toLowerCase())) + return true; + return false; +} + +// packages/injected/src/domUtils.ts +var globalOptions = {}; +function setGlobalOptions(options) { + globalOptions = options; +} +function isInsideScope(scope, element) { + while (element) { + if (scope.contains(element)) + return true; + element = enclosingShadowHost(element); + } + return false; +} +function parentElementOrShadowHost(element) { + if (element.parentElement) + return element.parentElement; + if (!element.parentNode) + return; + if (element.parentNode.nodeType === 11 && element.parentNode.host) + return element.parentNode.host; +} +function enclosingShadowRootOrDocument(element) { + let node = element; + while (node.parentNode) + node = node.parentNode; + if (node.nodeType === 11 || node.nodeType === 9) + return node; +} +function enclosingShadowHost(element) { + while (element.parentElement) + element = element.parentElement; + return parentElementOrShadowHost(element); +} +function closestCrossShadow(element, css, scope) { + while (element) { + const closest = element.closest(css); + if (scope && closest !== scope && (closest == null ? void 0 : closest.contains(scope))) + return; + if (closest) + return closest; + element = enclosingShadowHost(element); + } +} +function getElementComputedStyle(element, pseudo) { + const cache = pseudo === "::before" ? cacheStyleBefore : pseudo === "::after" ? cacheStyleAfter : cacheStyle; + if (cache && cache.has(element)) + return cache.get(element); + const style = element.ownerDocument && element.ownerDocument.defaultView ? element.ownerDocument.defaultView.getComputedStyle(element, pseudo) : void 0; + cache == null ? void 0 : cache.set(element, style); + return style; +} +function isElementStyleVisibilityVisible(element, style) { + style = style != null ? style : getElementComputedStyle(element); + if (!style) + return true; + if (Element.prototype.checkVisibility && globalOptions.browserNameForWorkarounds !== "webkit") { + if (!element.checkVisibility()) + return false; + } else { + const detailsOrSummary = element.closest("details,summary"); + if (detailsOrSummary !== element && (detailsOrSummary == null ? void 0 : detailsOrSummary.nodeName) === "DETAILS" && !detailsOrSummary.open) + return false; + } + if (style.visibility !== "visible") + return false; + return true; +} +function computeBox(element) { + const style = getElementComputedStyle(element); + if (!style) + return { visible: true, inline: false }; + const cursor = style.cursor; + if (style.display === "contents") { + for (let child = element.firstChild; child; child = child.nextSibling) { + if (child.nodeType === 1 && isElementVisible(child)) + return { visible: true, inline: false, cursor }; + if (child.nodeType === 3 && isVisibleTextNode(child)) + return { visible: true, inline: true, cursor }; + } + return { visible: false, inline: false, cursor }; + } + if (!isElementStyleVisibilityVisible(element, style)) + return { cursor, visible: false, inline: false }; + const rect = element.getBoundingClientRect(); + return { cursor, visible: rect.width > 0 && rect.height > 0, inline: style.display === "inline" }; +} +function isElementVisible(element) { + return computeBox(element).visible; +} +function isVisibleTextNode(node) { + const range = node.ownerDocument.createRange(); + range.selectNode(node); + const rect = range.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; +} +function elementSafeTagName(element) { + const tagName = element.tagName; + if (typeof tagName === "string") + return tagName.toUpperCase(); + if (element instanceof HTMLFormElement) + return "FORM"; + return element.tagName.toUpperCase(); +} +var cacheStyle; +var cacheStyleBefore; +var cacheStyleAfter; +var cachesCounter = 0; +function beginDOMCaches() { + ++cachesCounter; + cacheStyle != null ? cacheStyle : cacheStyle = /* @__PURE__ */ new Map(); + cacheStyleBefore != null ? cacheStyleBefore : cacheStyleBefore = /* @__PURE__ */ new Map(); + cacheStyleAfter != null ? cacheStyleAfter : cacheStyleAfter = /* @__PURE__ */ new Map(); +} +function endDOMCaches() { + if (!--cachesCounter) { + cacheStyle = void 0; + cacheStyleBefore = void 0; + cacheStyleAfter = void 0; + } +} + +// packages/injected/src/roleUtils.ts +function hasExplicitAccessibleName(e) { + return e.hasAttribute("aria-label") || e.hasAttribute("aria-labelledby"); +} +var kAncestorPreventingLandmark = "article:not([role]), aside:not([role]), main:not([role]), nav:not([role]), section:not([role]), [role=article], [role=complementary], [role=main], [role=navigation], [role=region]"; +var kGlobalAriaAttributes = [ + ["aria-atomic", void 0], + ["aria-busy", void 0], + ["aria-controls", void 0], + ["aria-current", void 0], + ["aria-describedby", void 0], + ["aria-details", void 0], + // Global use deprecated in ARIA 1.2 + // ['aria-disabled', undefined], + ["aria-dropeffect", void 0], + // Global use deprecated in ARIA 1.2 + // ['aria-errormessage', undefined], + ["aria-flowto", void 0], + ["aria-grabbed", void 0], + // Global use deprecated in ARIA 1.2 + // ['aria-haspopup', undefined], + ["aria-hidden", void 0], + // Global use deprecated in ARIA 1.2 + // ['aria-invalid', undefined], + ["aria-keyshortcuts", void 0], + ["aria-label", ["caption", "code", "deletion", "emphasis", "generic", "insertion", "paragraph", "presentation", "strong", "subscript", "superscript"]], + ["aria-labelledby", ["caption", "code", "deletion", "emphasis", "generic", "insertion", "paragraph", "presentation", "strong", "subscript", "superscript"]], + ["aria-live", void 0], + ["aria-owns", void 0], + ["aria-relevant", void 0], + ["aria-roledescription", ["generic"]] +]; +function hasGlobalAriaAttribute(element, forRole) { + return kGlobalAriaAttributes.some(([attr, prohibited]) => { + return !(prohibited == null ? void 0 : prohibited.includes(forRole || "")) && element.hasAttribute(attr); + }); +} +function hasTabIndex(element) { + return !Number.isNaN(Number(String(element.getAttribute("tabindex")))); +} +function isFocusable(element) { + return !isNativelyDisabled(element) && (isNativelyFocusable(element) || hasTabIndex(element)); +} +function isNativelyFocusable(element) { + const tagName = elementSafeTagName(element); + if (["BUTTON", "DETAILS", "SELECT", "TEXTAREA"].includes(tagName)) + return true; + if (tagName === "A" || tagName === "AREA") + return element.hasAttribute("href"); + if (tagName === "INPUT") + return !element.hidden; + return false; +} +var kImplicitRoleByTagName = { + "A": (e) => { + return e.hasAttribute("href") ? "link" : null; + }, + "AREA": (e) => { + return e.hasAttribute("href") ? "link" : null; + }, + "ARTICLE": () => "article", + "ASIDE": () => "complementary", + "BLOCKQUOTE": () => "blockquote", + "BUTTON": () => "button", + "CAPTION": () => "caption", + "CODE": () => "code", + "DATALIST": () => "listbox", + "DD": () => "definition", + "DEL": () => "deletion", + "DETAILS": () => "group", + "DFN": () => "term", + "DIALOG": () => "dialog", + "DT": () => "term", + "EM": () => "emphasis", + "FIELDSET": () => "group", + "FIGURE": () => "figure", + "FOOTER": (e) => closestCrossShadow(e, kAncestorPreventingLandmark) ? null : "contentinfo", + "FORM": (e) => hasExplicitAccessibleName(e) ? "form" : null, + "H1": () => "heading", + "H2": () => "heading", + "H3": () => "heading", + "H4": () => "heading", + "H5": () => "heading", + "H6": () => "heading", + "HEADER": (e) => closestCrossShadow(e, kAncestorPreventingLandmark) ? null : "banner", + "HR": () => "separator", + "HTML": () => "document", + "IMG": (e) => e.getAttribute("alt") === "" && !e.getAttribute("title") && !hasGlobalAriaAttribute(e) && !hasTabIndex(e) ? "presentation" : "img", + "INPUT": (e) => { + const type = e.type.toLowerCase(); + if (type === "search") + return e.hasAttribute("list") ? "combobox" : "searchbox"; + if (["email", "tel", "text", "url", ""].includes(type)) { + const list = getIdRefs(e, e.getAttribute("list"))[0]; + return list && elementSafeTagName(list) === "DATALIST" ? "combobox" : "textbox"; + } + if (type === "hidden") + return null; + if (type === "file") + return "button"; + return inputTypeToRole[type] || "textbox"; + }, + "INS": () => "insertion", + "LI": () => "listitem", + "MAIN": () => "main", + "MARK": () => "mark", + "MATH": () => "math", + "MENU": () => "list", + "METER": () => "meter", + "NAV": () => "navigation", + "OL": () => "list", + "OPTGROUP": () => "group", + "OPTION": () => "option", + "OUTPUT": () => "status", + "P": () => "paragraph", + "PROGRESS": () => "progressbar", + "SEARCH": () => "search", + "SECTION": (e) => hasExplicitAccessibleName(e) ? "region" : null, + "SELECT": (e) => e.hasAttribute("multiple") || e.size > 1 ? "listbox" : "combobox", + "STRONG": () => "strong", + "SUB": () => "subscript", + "SUP": () => "superscript", + // For we default to Chrome behavior: + // - Chrome reports 'img'. + // - Firefox reports 'diagram' that is not in official ARIA spec yet. + // - Safari reports 'no role', but still computes accessible name. + "SVG": () => "img", + "TABLE": () => "table", + "TBODY": () => "rowgroup", + "TD": (e) => { + const table = closestCrossShadow(e, "table"); + const role = table ? getExplicitAriaRole(table) : ""; + return role === "grid" || role === "treegrid" ? "gridcell" : "cell"; + }, + "TEXTAREA": () => "textbox", + "TFOOT": () => "rowgroup", + "TH": (e) => { + const scope = e.getAttribute("scope"); + if (scope === "col" || scope === "colgroup") + return "columnheader"; + if (scope === "row" || scope === "rowgroup") + return "rowheader"; + const nextSibling = e.nextElementSibling; + const prevSibling = e.previousElementSibling; + const row = !!e.parentElement && elementSafeTagName(e.parentElement) === "TR" ? e.parentElement : void 0; + if (!nextSibling && !prevSibling) { + if (row) { + const table = closestCrossShadow(row, "table"); + if (table && table.rows.length <= 1) + return null; + } + return "columnheader"; + } + if (isHeaderCell(nextSibling) && isHeaderCell(prevSibling)) + return "columnheader"; + if (isNonEmptyDataCell(nextSibling) || isNonEmptyDataCell(prevSibling)) + return "rowheader"; + return "columnheader"; + }, + "THEAD": () => "rowgroup", + "TIME": () => "time", + "TR": () => "row", + "UL": () => "list" +}; +function isHeaderCell(element) { + return !!element && elementSafeTagName(element) === "TH"; +} +function isNonEmptyDataCell(element) { + var _a; + if (!element || elementSafeTagName(element) !== "TD") + return false; + return !!(((_a = element.textContent) == null ? void 0 : _a.trim()) || element.children.length > 0); +} +var kPresentationInheritanceParents = { + "DD": ["DL", "DIV"], + "DIV": ["DL"], + "DT": ["DL", "DIV"], + "LI": ["OL", "UL"], + "TBODY": ["TABLE"], + "TD": ["TR"], + "TFOOT": ["TABLE"], + "TH": ["TR"], + "THEAD": ["TABLE"], + "TR": ["THEAD", "TBODY", "TFOOT", "TABLE"] +}; +function getImplicitAriaRole(element) { + var _a; + const implicitRole = ((_a = kImplicitRoleByTagName[elementSafeTagName(element)]) == null ? void 0 : _a.call(kImplicitRoleByTagName, element)) || ""; + if (!implicitRole) + return null; + let ancestor = element; + while (ancestor) { + const parent = parentElementOrShadowHost(ancestor); + const parents = kPresentationInheritanceParents[elementSafeTagName(ancestor)]; + if (!parents || !parent || !parents.includes(elementSafeTagName(parent))) + break; + const parentExplicitRole = getExplicitAriaRole(parent); + if ((parentExplicitRole === "none" || parentExplicitRole === "presentation") && !hasPresentationConflictResolution(parent, parentExplicitRole)) + return parentExplicitRole; + ancestor = parent; + } + return implicitRole; +} +var validRoles = [ + "alert", + "alertdialog", + "application", + "article", + "banner", + "blockquote", + "button", + "caption", + "cell", + "checkbox", + "code", + "columnheader", + "combobox", + "complementary", + "contentinfo", + "definition", + "deletion", + "dialog", + "directory", + "document", + "emphasis", + "feed", + "figure", + "form", + "generic", + "grid", + "gridcell", + "group", + "heading", + "img", + "insertion", + "link", + "list", + "listbox", + "listitem", + "log", + "main", + "mark", + "marquee", + "math", + "meter", + "menu", + "menubar", + "menuitem", + "menuitemcheckbox", + "menuitemradio", + "navigation", + "none", + "note", + "option", + "paragraph", + "presentation", + "progressbar", + "radio", + "radiogroup", + "region", + "row", + "rowgroup", + "rowheader", + "scrollbar", + "search", + "searchbox", + "separator", + "slider", + "spinbutton", + "status", + "strong", + "subscript", + "superscript", + "switch", + "tab", + "table", + "tablist", + "tabpanel", + "term", + "textbox", + "time", + "timer", + "toolbar", + "tooltip", + "tree", + "treegrid", + "treeitem" +]; +function getExplicitAriaRole(element) { + const roles = (element.getAttribute("role") || "").split(" ").map((role) => role.trim()); + return roles.find((role) => validRoles.includes(role)) || null; +} +function hasPresentationConflictResolution(element, role) { + return hasGlobalAriaAttribute(element, role) || isFocusable(element); +} +function getAriaRole(element) { + const explicitRole = getExplicitAriaRole(element); + if (!explicitRole) + return getImplicitAriaRole(element); + if (explicitRole === "none" || explicitRole === "presentation") { + const implicitRole = getImplicitAriaRole(element); + if (hasPresentationConflictResolution(element, implicitRole)) + return implicitRole; + } + return explicitRole; +} +function getAriaBoolean(attr) { + return attr === null ? void 0 : attr.toLowerCase() === "true"; +} +function isElementIgnoredForAria(element) { + return ["STYLE", "SCRIPT", "NOSCRIPT", "TEMPLATE"].includes(elementSafeTagName(element)); +} +function isElementHiddenForAria(element) { + if (isElementIgnoredForAria(element)) + return true; + const style = getElementComputedStyle(element); + const isSlot = element.nodeName === "SLOT"; + if ((style == null ? void 0 : style.display) === "contents" && !isSlot) { + for (let child = element.firstChild; child; child = child.nextSibling) { + if (child.nodeType === 1 && !isElementHiddenForAria(child)) + return false; + if (child.nodeType === 3 && isVisibleTextNode(child)) + return false; + } + return true; + } + const isOptionInsideSelect = element.nodeName === "OPTION" && !!element.closest("select"); + if (!isOptionInsideSelect && !isSlot && !isElementStyleVisibilityVisible(element, style)) + return true; + return belongsToDisplayNoneOrAriaHiddenOrNonSlotted(element); +} +function belongsToDisplayNoneOrAriaHiddenOrNonSlotted(element) { + let hidden = cacheIsHidden == null ? void 0 : cacheIsHidden.get(element); + if (hidden === void 0) { + hidden = false; + if (element.parentElement && element.parentElement.shadowRoot && !element.assignedSlot) + hidden = true; + if (!hidden) { + const style = getElementComputedStyle(element); + hidden = !style || style.display === "none" || getAriaBoolean(element.getAttribute("aria-hidden")) === true; + } + if (!hidden) { + const parent = parentElementOrShadowHost(element); + if (parent) + hidden = belongsToDisplayNoneOrAriaHiddenOrNonSlotted(parent); + } + cacheIsHidden == null ? void 0 : cacheIsHidden.set(element, hidden); + } + return hidden; +} +function getIdRefs(element, ref) { + if (!ref) + return []; + const root = enclosingShadowRootOrDocument(element); + if (!root) + return []; + try { + const ids = ref.split(" ").filter((id) => !!id); + const result = []; + for (const id of ids) { + const firstElement = root.querySelector("#" + CSS.escape(id)); + if (firstElement && !result.includes(firstElement)) + result.push(firstElement); + } + return result; + } catch (e) { + return []; + } +} +function trimFlatString(s) { + return s.trim(); +} +function asFlatString(s) { + return s.split("\xA0").map((chunk) => chunk.replace(/\r\n/g, "\n").replace(/[\u200b\u00ad]/g, "").replace(/\s\s*/g, " ")).join("\xA0").trim(); +} +function queryInAriaOwned(element, selector) { + const result = [...element.querySelectorAll(selector)]; + for (const owned of getIdRefs(element, element.getAttribute("aria-owns"))) { + if (owned.matches(selector)) + result.push(owned); + result.push(...owned.querySelectorAll(selector)); + } + return result; +} +function getCSSContent(element, pseudo) { + const cache = pseudo === "::before" ? cachePseudoContentBefore : pseudo === "::after" ? cachePseudoContentAfter : cachePseudoContent; + if (cache == null ? void 0 : cache.has(element)) + return cache == null ? void 0 : cache.get(element); + const style = getElementComputedStyle(element, pseudo); + let content; + if (style) { + const contentValue = style.content; + if (contentValue && contentValue !== "none" && contentValue !== "normal") { + if (style.display !== "none" && style.visibility !== "hidden") { + content = parseCSSContentPropertyAsString(element, contentValue, !!pseudo); + } + } + } + if (pseudo && content !== void 0) { + const display = (style == null ? void 0 : style.display) || "inline"; + if (display !== "inline") + content = " " + content + " "; + } + if (cache) + cache.set(element, content); + return content; +} +function parseCSSContentPropertyAsString(element, content, isPseudo) { + if (!content || content === "none" || content === "normal") { + return; + } + try { + let tokens = tokenize(content).filter((token) => !(token instanceof WhitespaceToken)); + const delimIndex = tokens.findIndex((token) => token instanceof DelimToken && token.value === "/"); + if (delimIndex !== -1) { + tokens = tokens.slice(delimIndex + 1); + } else if (!isPseudo) { + return; + } + const accumulated = []; + let index = 0; + while (index < tokens.length) { + if (tokens[index] instanceof StringToken) { + accumulated.push(tokens[index].value); + index++; + } else if (index + 2 < tokens.length && tokens[index] instanceof FunctionToken && tokens[index].value === "attr" && tokens[index + 1] instanceof IdentToken && tokens[index + 2] instanceof CloseParenToken) { + const attrName = tokens[index + 1].value; + accumulated.push(element.getAttribute(attrName) || ""); + index += 3; + } else { + return; + } + } + return accumulated.join(""); + } catch { + } +} +function getAriaLabelledByElements(element) { + const ref = element.getAttribute("aria-labelledby"); + if (ref === null) + return null; + const refs = getIdRefs(element, ref); + return refs.length ? refs : null; +} +function allowsNameFromContent(role, targetDescendant) { + const alwaysAllowsNameFromContent = ["button", "cell", "checkbox", "columnheader", "gridcell", "heading", "link", "menuitem", "menuitemcheckbox", "menuitemradio", "option", "radio", "row", "rowheader", "switch", "tab", "tooltip", "treeitem"].includes(role); + const descendantAllowsNameFromContent = targetDescendant && ["", "caption", "code", "contentinfo", "definition", "deletion", "emphasis", "insertion", "list", "listitem", "mark", "none", "paragraph", "presentation", "region", "row", "rowgroup", "section", "strong", "subscript", "superscript", "table", "term", "time"].includes(role); + return alwaysAllowsNameFromContent || descendantAllowsNameFromContent; +} +function getElementAccessibleName(element, includeHidden) { + const cache = includeHidden ? cacheAccessibleNameHidden : cacheAccessibleName; + let accessibleName = cache == null ? void 0 : cache.get(element); + if (accessibleName === void 0) { + accessibleName = ""; + const elementProhibitsNaming = ["caption", "code", "definition", "deletion", "emphasis", "generic", "insertion", "mark", "paragraph", "presentation", "strong", "subscript", "suggestion", "superscript", "term", "time"].includes(getAriaRole(element) || ""); + if (!elementProhibitsNaming) { + accessibleName = asFlatString(getTextAlternativeInternal(element, { + includeHidden, + visitedElements: /* @__PURE__ */ new Set(), + embeddedInTargetElement: "self" + })); + } + cache == null ? void 0 : cache.set(element, accessibleName); + } + return accessibleName; +} +function getElementAccessibleDescription(element, includeHidden) { + const cache = includeHidden ? cacheAccessibleDescriptionHidden : cacheAccessibleDescription; + let accessibleDescription = cache == null ? void 0 : cache.get(element); + if (accessibleDescription === void 0) { + accessibleDescription = ""; + if (element.hasAttribute("aria-describedby")) { + const describedBy = getIdRefs(element, element.getAttribute("aria-describedby")); + accessibleDescription = asFlatString(describedBy.map((ref) => getTextAlternativeInternal(ref, { + includeHidden, + visitedElements: /* @__PURE__ */ new Set(), + embeddedInDescribedBy: { element: ref, hidden: isElementHiddenForAria(ref) } + })).join(" ")); + } else if (element.hasAttribute("aria-description")) { + accessibleDescription = asFlatString(element.getAttribute("aria-description") || ""); + } else { + accessibleDescription = asFlatString(element.getAttribute("title") || ""); + } + cache == null ? void 0 : cache.set(element, accessibleDescription); + } + return accessibleDescription; +} +function getAriaInvalid(element) { + const ariaInvalid = element.getAttribute("aria-invalid"); + if (!ariaInvalid || ariaInvalid.trim() === "" || ariaInvalid.toLocaleLowerCase() === "false") + return "false"; + if (ariaInvalid === "true" || ariaInvalid === "grammar" || ariaInvalid === "spelling") + return ariaInvalid; + return "true"; +} +function getValidityInvalid(element) { + if ("validity" in element) { + const validity = element.validity; + return (validity == null ? void 0 : validity.valid) === false; + } + return false; +} +function getElementAccessibleErrorMessage(element) { + const cache = cacheAccessibleErrorMessage; + let accessibleErrorMessage = cacheAccessibleErrorMessage == null ? void 0 : cacheAccessibleErrorMessage.get(element); + if (accessibleErrorMessage === void 0) { + accessibleErrorMessage = ""; + const isAriaInvalid = getAriaInvalid(element) !== "false"; + const isValidityInvalid = getValidityInvalid(element); + if (isAriaInvalid || isValidityInvalid) { + const errorMessageId = element.getAttribute("aria-errormessage"); + const errorMessages = getIdRefs(element, errorMessageId); + const parts = errorMessages.map((errorMessage) => asFlatString( + getTextAlternativeInternal(errorMessage, { + visitedElements: /* @__PURE__ */ new Set(), + embeddedInDescribedBy: { element: errorMessage, hidden: isElementHiddenForAria(errorMessage) } + }) + )); + accessibleErrorMessage = parts.join(" ").trim(); + } + cache == null ? void 0 : cache.set(element, accessibleErrorMessage); + } + return accessibleErrorMessage; +} +function getTextAlternativeInternal(element, options) { + var _a, _b, _c, _d; + if (options.visitedElements.has(element)) + return ""; + const childOptions = { + ...options, + embeddedInTargetElement: options.embeddedInTargetElement === "self" ? "descendant" : options.embeddedInTargetElement + }; + if (!options.includeHidden) { + const isEmbeddedInHiddenReferenceTraversal = !!((_a = options.embeddedInLabelledBy) == null ? void 0 : _a.hidden) || !!((_b = options.embeddedInDescribedBy) == null ? void 0 : _b.hidden) || !!((_c = options.embeddedInNativeTextAlternative) == null ? void 0 : _c.hidden) || !!((_d = options.embeddedInLabel) == null ? void 0 : _d.hidden); + if (isElementIgnoredForAria(element) || !isEmbeddedInHiddenReferenceTraversal && isElementHiddenForAria(element)) { + options.visitedElements.add(element); + return ""; + } + } + const labelledBy = getAriaLabelledByElements(element); + if (!options.embeddedInLabelledBy) { + const accessibleName = (labelledBy || []).map((ref) => getTextAlternativeInternal(ref, { + ...options, + embeddedInLabelledBy: { element: ref, hidden: isElementHiddenForAria(ref) }, + embeddedInDescribedBy: void 0, + embeddedInTargetElement: void 0, + embeddedInLabel: void 0, + embeddedInNativeTextAlternative: void 0 + })).join(" "); + if (accessibleName) + return accessibleName; + } + const role = getAriaRole(element) || ""; + const tagName = elementSafeTagName(element); + if (!!options.embeddedInLabel || !!options.embeddedInLabelledBy || options.embeddedInTargetElement === "descendant") { + const isOwnLabel = [...element.labels || []].includes(element); + const isOwnLabelledBy = (labelledBy || []).includes(element); + if (!isOwnLabel && !isOwnLabelledBy) { + if (role === "textbox") { + options.visitedElements.add(element); + if (tagName === "INPUT" || tagName === "TEXTAREA") + return element.value; + return element.textContent || ""; + } + if (["combobox", "listbox"].includes(role)) { + options.visitedElements.add(element); + let selectedOptions; + if (tagName === "SELECT") { + selectedOptions = [...element.selectedOptions]; + if (!selectedOptions.length && element.options.length) + selectedOptions.push(element.options[0]); + } else { + const listbox = role === "combobox" ? queryInAriaOwned(element, "*").find((e) => getAriaRole(e) === "listbox") : element; + selectedOptions = listbox ? queryInAriaOwned(listbox, '[aria-selected="true"]').filter((e) => getAriaRole(e) === "option") : []; + } + if (!selectedOptions.length && tagName === "INPUT") { + return element.value; + } + return selectedOptions.map((option) => getTextAlternativeInternal(option, childOptions)).join(" "); + } + if (["progressbar", "scrollbar", "slider", "spinbutton", "meter"].includes(role)) { + options.visitedElements.add(element); + if (element.hasAttribute("aria-valuetext")) + return element.getAttribute("aria-valuetext") || ""; + if (element.hasAttribute("aria-valuenow")) + return element.getAttribute("aria-valuenow") || ""; + return element.getAttribute("value") || ""; + } + if (["menu"].includes(role)) { + options.visitedElements.add(element); + return ""; + } + } + } + const ariaLabel = element.getAttribute("aria-label") || ""; + if (trimFlatString(ariaLabel)) { + options.visitedElements.add(element); + return ariaLabel; + } + if (!["presentation", "none"].includes(role)) { + if (tagName === "INPUT" && ["button", "submit", "reset"].includes(element.type)) { + options.visitedElements.add(element); + const value = element.value || ""; + if (trimFlatString(value)) + return value; + if (element.type === "submit") + return "Submit"; + if (element.type === "reset") + return "Reset"; + const title = element.getAttribute("title") || ""; + return title; + } + if (tagName === "INPUT" && element.type === "file") { + options.visitedElements.add(element); + const labels = element.labels || []; + if (labels.length && !options.embeddedInLabelledBy) + return getAccessibleNameFromAssociatedLabels(labels, options); + return "Choose File"; + } + if (tagName === "INPUT" && element.type === "image") { + options.visitedElements.add(element); + const labels = element.labels || []; + if (labels.length && !options.embeddedInLabelledBy) + return getAccessibleNameFromAssociatedLabels(labels, options); + const alt = element.getAttribute("alt") || ""; + if (trimFlatString(alt)) + return alt; + const title = element.getAttribute("title") || ""; + if (trimFlatString(title)) + return title; + return "Submit"; + } + if (!labelledBy && tagName === "BUTTON") { + options.visitedElements.add(element); + const labels = element.labels || []; + if (labels.length) + return getAccessibleNameFromAssociatedLabels(labels, options); + } + if (!labelledBy && tagName === "OUTPUT") { + options.visitedElements.add(element); + const labels = element.labels || []; + if (labels.length) + return getAccessibleNameFromAssociatedLabels(labels, options); + return element.getAttribute("title") || ""; + } + if (!labelledBy && (tagName === "TEXTAREA" || tagName === "SELECT" || tagName === "INPUT")) { + options.visitedElements.add(element); + const labels = element.labels || []; + if (labels.length) + return getAccessibleNameFromAssociatedLabels(labels, options); + const usePlaceholder = tagName === "INPUT" && ["text", "password", "search", "tel", "email", "url"].includes(element.type) || tagName === "TEXTAREA"; + const placeholder = element.getAttribute("placeholder") || ""; + const title = element.getAttribute("title") || ""; + if (!usePlaceholder || title) + return title; + return placeholder; + } + if (!labelledBy && tagName === "FIELDSET") { + options.visitedElements.add(element); + for (let child = element.firstElementChild; child; child = child.nextElementSibling) { + if (elementSafeTagName(child) === "LEGEND") { + return getTextAlternativeInternal(child, { + ...childOptions, + embeddedInNativeTextAlternative: { element: child, hidden: isElementHiddenForAria(child) } + }); + } + } + const title = element.getAttribute("title") || ""; + return title; + } + if (!labelledBy && tagName === "FIGURE") { + options.visitedElements.add(element); + for (let child = element.firstElementChild; child; child = child.nextElementSibling) { + if (elementSafeTagName(child) === "FIGCAPTION") { + return getTextAlternativeInternal(child, { + ...childOptions, + embeddedInNativeTextAlternative: { element: child, hidden: isElementHiddenForAria(child) } + }); + } + } + const title = element.getAttribute("title") || ""; + return title; + } + if (tagName === "IMG") { + options.visitedElements.add(element); + const alt = element.getAttribute("alt") || ""; + if (trimFlatString(alt)) + return alt; + const title = element.getAttribute("title") || ""; + return title; + } + if (tagName === "TABLE") { + options.visitedElements.add(element); + for (let child = element.firstElementChild; child; child = child.nextElementSibling) { + if (elementSafeTagName(child) === "CAPTION") { + return getTextAlternativeInternal(child, { + ...childOptions, + embeddedInNativeTextAlternative: { element: child, hidden: isElementHiddenForAria(child) } + }); + } + } + const summary = element.getAttribute("summary") || ""; + if (summary) + return summary; + } + if (tagName === "AREA") { + options.visitedElements.add(element); + const alt = element.getAttribute("alt") || ""; + if (trimFlatString(alt)) + return alt; + const title = element.getAttribute("title") || ""; + return title; + } + if (tagName === "SVG" || element.ownerSVGElement) { + options.visitedElements.add(element); + for (let child = element.firstElementChild; child; child = child.nextElementSibling) { + if (elementSafeTagName(child) === "TITLE" && child.ownerSVGElement) { + return getTextAlternativeInternal(child, { + ...childOptions, + embeddedInLabelledBy: { element: child, hidden: isElementHiddenForAria(child) } + }); + } + } + } + if (element.ownerSVGElement && tagName === "A") { + const title = element.getAttribute("xlink:title") || ""; + if (trimFlatString(title)) { + options.visitedElements.add(element); + return title; + } + } + } + const shouldNameFromContentForSummary = tagName === "SUMMARY" && !["presentation", "none"].includes(role); + if (allowsNameFromContent(role, options.embeddedInTargetElement === "descendant") || shouldNameFromContentForSummary || !!options.embeddedInLabelledBy || !!options.embeddedInDescribedBy || !!options.embeddedInLabel || !!options.embeddedInNativeTextAlternative) { + options.visitedElements.add(element); + const accessibleName = innerAccumulatedElementText(element, childOptions); + const maybeTrimmedAccessibleName = options.embeddedInTargetElement === "self" ? trimFlatString(accessibleName) : accessibleName; + if (maybeTrimmedAccessibleName) + return accessibleName; + } + if (!["presentation", "none"].includes(role) || tagName === "IFRAME") { + options.visitedElements.add(element); + const title = element.getAttribute("title") || ""; + if (trimFlatString(title)) + return title; + } + options.visitedElements.add(element); + return ""; +} +function innerAccumulatedElementText(element, options) { + const tokens = []; + const visit = (node, skipSlotted) => { + var _a; + if (skipSlotted && node.assignedSlot) + return; + if (node.nodeType === 1) { + const display = ((_a = getElementComputedStyle(node)) == null ? void 0 : _a.display) || "inline"; + let token = getTextAlternativeInternal(node, options); + if (display !== "inline" || node.nodeName === "BR") + token = " " + token + " "; + tokens.push(token); + } else if (node.nodeType === 3) { + tokens.push(node.textContent || ""); + } + }; + tokens.push(getCSSContent(element, "::before") || ""); + const content = getCSSContent(element); + if (content !== void 0) { + tokens.push(content); + } else { + const assignedNodes = element.nodeName === "SLOT" ? element.assignedNodes() : []; + if (assignedNodes.length) { + for (const child of assignedNodes) + visit(child, false); + } else { + for (let child = element.firstChild; child; child = child.nextSibling) + visit(child, true); + if (element.shadowRoot) { + for (let child = element.shadowRoot.firstChild; child; child = child.nextSibling) + visit(child, true); + } + for (const owned of getIdRefs(element, element.getAttribute("aria-owns"))) + visit(owned, true); + } + } + tokens.push(getCSSContent(element, "::after") || ""); + return tokens.join(""); +} +var kAriaSelectedRoles = ["gridcell", "option", "row", "tab", "rowheader", "columnheader", "treeitem"]; +function getAriaSelected(element) { + if (elementSafeTagName(element) === "OPTION") + return element.selected; + if (kAriaSelectedRoles.includes(getAriaRole(element) || "")) + return getAriaBoolean(element.getAttribute("aria-selected")) === true; + return false; +} +var kAriaCheckedRoles = ["checkbox", "menuitemcheckbox", "option", "radio", "switch", "menuitemradio", "treeitem"]; +function getAriaChecked(element) { + const result = getChecked(element, true); + return result === "error" ? false : result; +} +function getCheckedAllowMixed(element) { + return getChecked(element, true); +} +function getCheckedWithoutMixed(element) { + const result = getChecked(element, false); + return result; +} +function getChecked(element, allowMixed) { + const tagName = elementSafeTagName(element); + if (allowMixed && tagName === "INPUT" && element.indeterminate) + return "mixed"; + if (tagName === "INPUT" && ["checkbox", "radio"].includes(element.type)) + return element.checked; + if (kAriaCheckedRoles.includes(getAriaRole(element) || "")) { + const checked = element.getAttribute("aria-checked"); + if (checked === "true") + return true; + if (allowMixed && checked === "mixed") + return "mixed"; + return false; + } + return "error"; +} +var kAriaReadonlyRoles = ["checkbox", "combobox", "grid", "gridcell", "listbox", "radiogroup", "slider", "spinbutton", "textbox", "columnheader", "rowheader", "searchbox", "switch", "treegrid"]; +function getReadonly(element) { + const tagName = elementSafeTagName(element); + if (["INPUT", "TEXTAREA", "SELECT"].includes(tagName)) + return element.hasAttribute("readonly"); + if (kAriaReadonlyRoles.includes(getAriaRole(element) || "")) + return element.getAttribute("aria-readonly") === "true"; + if (element.isContentEditable) + return false; + return "error"; +} +var kAriaPressedRoles = ["button"]; +function getAriaPressed(element) { + if (kAriaPressedRoles.includes(getAriaRole(element) || "")) { + const pressed = element.getAttribute("aria-pressed"); + if (pressed === "true") + return true; + if (pressed === "mixed") + return "mixed"; + } + return false; +} +var kAriaExpandedRoles = ["application", "button", "checkbox", "combobox", "gridcell", "link", "listbox", "menuitem", "row", "rowheader", "tab", "treeitem", "columnheader", "menuitemcheckbox", "menuitemradio", "rowheader", "switch"]; +function getAriaExpanded(element) { + if (elementSafeTagName(element) === "DETAILS") + return element.open; + if (kAriaExpandedRoles.includes(getAriaRole(element) || "")) { + const expanded = element.getAttribute("aria-expanded"); + if (expanded === null) + return void 0; + if (expanded === "true") + return true; + return false; + } + return void 0; +} +var kAriaLevelRoles = ["heading", "listitem", "row", "treeitem"]; +function getAriaLevel(element) { + const native = { "H1": 1, "H2": 2, "H3": 3, "H4": 4, "H5": 5, "H6": 6 }[elementSafeTagName(element)]; + if (native) + return native; + if (kAriaLevelRoles.includes(getAriaRole(element) || "")) { + const attr = element.getAttribute("aria-level"); + const value = attr === null ? Number.NaN : Number(attr); + if (Number.isInteger(value) && value >= 1) + return value; + } + return 0; +} +var kAriaDisabledRoles = ["application", "button", "composite", "gridcell", "group", "input", "link", "menuitem", "scrollbar", "separator", "tab", "checkbox", "columnheader", "combobox", "grid", "listbox", "menu", "menubar", "menuitemcheckbox", "menuitemradio", "option", "radio", "radiogroup", "row", "rowheader", "searchbox", "select", "slider", "spinbutton", "switch", "tablist", "textbox", "toolbar", "tree", "treegrid", "treeitem"]; +function getAriaDisabled(element) { + return isNativelyDisabled(element) || hasExplicitAriaDisabled(element); +} +function isNativelyDisabled(element) { + const isNativeFormControl = ["BUTTON", "INPUT", "SELECT", "TEXTAREA", "OPTION", "OPTGROUP"].includes(elementSafeTagName(element)); + return isNativeFormControl && (element.hasAttribute("disabled") || belongsToDisabledOptGroup(element) || belongsToDisabledFieldSet(element)); +} +function belongsToDisabledOptGroup(element) { + return elementSafeTagName(element) === "OPTION" && !!element.closest("OPTGROUP[DISABLED]"); +} +function belongsToDisabledFieldSet(element) { + const fieldSetElement = element == null ? void 0 : element.closest("FIELDSET[DISABLED]"); + if (!fieldSetElement) + return false; + const legendElement = fieldSetElement.querySelector(":scope > LEGEND"); + return !legendElement || !legendElement.contains(element); +} +function hasExplicitAriaDisabled(element, isAncestor = false) { + if (!element) + return false; + if (isAncestor || kAriaDisabledRoles.includes(getAriaRole(element) || "")) { + const attribute = (element.getAttribute("aria-disabled") || "").toLowerCase(); + if (attribute === "true") + return true; + if (attribute === "false") + return false; + return hasExplicitAriaDisabled(parentElementOrShadowHost(element), true); + } + return false; +} +function getAccessibleNameFromAssociatedLabels(labels, options) { + return [...labels].map((label) => getTextAlternativeInternal(label, { + ...options, + embeddedInLabel: { element: label, hidden: isElementHiddenForAria(label) }, + embeddedInNativeTextAlternative: void 0, + embeddedInLabelledBy: void 0, + embeddedInDescribedBy: void 0, + embeddedInTargetElement: void 0 + })).filter((accessibleName) => !!accessibleName).join(" "); +} +function receivesPointerEvents(element) { + const cache = cachePointerEvents; + let e = element; + let result; + const parents = []; + for (; e; e = parentElementOrShadowHost(e)) { + const cached = cache.get(e); + if (cached !== void 0) { + result = cached; + break; + } + parents.push(e); + const style = getElementComputedStyle(e); + if (!style) { + result = true; + break; + } + const value = style.pointerEvents; + if (value) { + result = value !== "none"; + break; + } + } + if (result === void 0) + result = true; + for (const parent of parents) + cache.set(parent, result); + return result; +} +var cacheAccessibleName; +var cacheAccessibleNameHidden; +var cacheAccessibleDescription; +var cacheAccessibleDescriptionHidden; +var cacheAccessibleErrorMessage; +var cacheIsHidden; +var cachePseudoContent; +var cachePseudoContentBefore; +var cachePseudoContentAfter; +var cachePointerEvents; +var cachesCounter2 = 0; +function beginAriaCaches() { + beginDOMCaches(); + ++cachesCounter2; + cacheAccessibleName != null ? cacheAccessibleName : cacheAccessibleName = /* @__PURE__ */ new Map(); + cacheAccessibleNameHidden != null ? cacheAccessibleNameHidden : cacheAccessibleNameHidden = /* @__PURE__ */ new Map(); + cacheAccessibleDescription != null ? cacheAccessibleDescription : cacheAccessibleDescription = /* @__PURE__ */ new Map(); + cacheAccessibleDescriptionHidden != null ? cacheAccessibleDescriptionHidden : cacheAccessibleDescriptionHidden = /* @__PURE__ */ new Map(); + cacheAccessibleErrorMessage != null ? cacheAccessibleErrorMessage : cacheAccessibleErrorMessage = /* @__PURE__ */ new Map(); + cacheIsHidden != null ? cacheIsHidden : cacheIsHidden = /* @__PURE__ */ new Map(); + cachePseudoContent != null ? cachePseudoContent : cachePseudoContent = /* @__PURE__ */ new Map(); + cachePseudoContentBefore != null ? cachePseudoContentBefore : cachePseudoContentBefore = /* @__PURE__ */ new Map(); + cachePseudoContentAfter != null ? cachePseudoContentAfter : cachePseudoContentAfter = /* @__PURE__ */ new Map(); + cachePointerEvents != null ? cachePointerEvents : cachePointerEvents = /* @__PURE__ */ new Map(); +} +function endAriaCaches() { + if (!--cachesCounter2) { + cacheAccessibleName = void 0; + cacheAccessibleNameHidden = void 0; + cacheAccessibleDescription = void 0; + cacheAccessibleDescriptionHidden = void 0; + cacheAccessibleErrorMessage = void 0; + cacheIsHidden = void 0; + cachePseudoContent = void 0; + cachePseudoContentBefore = void 0; + cachePseudoContentAfter = void 0; + cachePointerEvents = void 0; + } + endDOMCaches(); +} +var inputTypeToRole = { + "button": "button", + "checkbox": "checkbox", + "image": "button", + "number": "spinbutton", + "radio": "radio", + "range": "slider", + "reset": "button", + "submit": "button" +}; + +// packages/injected/src/ariaSnapshot.ts +var lastRef = 0; +function toInternalOptions(options) { + const renderBoxes = options.boxes; + if (options.mode === "ai") { + return { + visibility: "ariaOrVisible", + refs: "interactable", + refPrefix: options.refPrefix, + includeGenericRole: true, + renderActive: !options.doNotRenderActive, + renderCursorPointer: true, + renderBoxes + }; + } + if (options.mode === "autoexpect") { + return { visibility: "ariaAndVisible", refs: "none", renderBoxes }; + } + if (options.mode === "codegen") { + return { visibility: "aria", refs: "none", renderStringsAsRegex: true, renderBoxes }; + } + return { visibility: "aria", refs: "none", renderBoxes }; +} +function generateAriaTree(rootElement, publicOptions) { + const options = toInternalOptions(publicOptions); + const visited = /* @__PURE__ */ new Set(); + const snapshot = { + root: { role: "fragment", name: "", children: [], props: {}, box: computeBox(rootElement), receivesPointerEvents: true }, + elements: /* @__PURE__ */ new Map(), + refs: /* @__PURE__ */ new Map(), + iframeRefs: [] + }; + setAriaNodeElement(snapshot.root, rootElement); + const visit = (ariaNode, node, parentElementVisible) => { + if (visited.has(node)) + return; + visited.add(node); + if (node.nodeType === Node.TEXT_NODE && node.nodeValue) { + if (!parentElementVisible) + return; + const text = node.nodeValue; + if (ariaNode.role !== "textbox" && text) + ariaNode.children.push(node.nodeValue || ""); + return; + } + if (node.nodeType !== Node.ELEMENT_NODE) + return; + const element = node; + const isElementVisibleForAria = !isElementHiddenForAria(element); + let visible = isElementVisibleForAria; + if (options.visibility === "ariaOrVisible") + visible = isElementVisibleForAria || isElementVisible(element); + if (options.visibility === "ariaAndVisible") + visible = isElementVisibleForAria && isElementVisible(element); + if (options.visibility === "aria" && !visible) + return; + const ariaChildren = []; + if (element.hasAttribute("aria-owns")) { + const ids = element.getAttribute("aria-owns").split(/\s+/); + for (const id of ids) { + const ownedElement = rootElement.ownerDocument.getElementById(id); + if (ownedElement) + ariaChildren.push(ownedElement); + } + } + const childAriaNode = visible ? toAriaNode(element, options) : null; + if (childAriaNode) { + if (childAriaNode.ref) { + snapshot.elements.set(childAriaNode.ref, element); + snapshot.refs.set(element, childAriaNode.ref); + if (childAriaNode.role === "iframe") + snapshot.iframeRefs.push(childAriaNode.ref); + } + ariaNode.children.push(childAriaNode); + } + processElement(childAriaNode || ariaNode, element, ariaChildren, visible); + }; + function processElement(ariaNode, element, ariaChildren, parentElementVisible) { + var _a; + const display = ((_a = getElementComputedStyle(element)) == null ? void 0 : _a.display) || "inline"; + const treatAsBlock = display !== "inline" || element.nodeName === "BR" ? " " : ""; + if (treatAsBlock) + ariaNode.children.push(treatAsBlock); + ariaNode.children.push(getCSSContent(element, "::before") || ""); + const assignedNodes = element.nodeName === "SLOT" ? element.assignedNodes() : []; + if (assignedNodes.length) { + for (const child of assignedNodes) + visit(ariaNode, child, parentElementVisible); + } else { + for (let child = element.firstChild; child; child = child.nextSibling) { + if (!child.assignedSlot) + visit(ariaNode, child, parentElementVisible); + } + if (element.shadowRoot) { + for (let child = element.shadowRoot.firstChild; child; child = child.nextSibling) + visit(ariaNode, child, parentElementVisible); + } + } + for (const child of ariaChildren) + visit(ariaNode, child, parentElementVisible); + ariaNode.children.push(getCSSContent(element, "::after") || ""); + if (treatAsBlock) + ariaNode.children.push(treatAsBlock); + if (ariaNode.children.length === 1 && ariaNode.name === ariaNode.children[0]) + ariaNode.children = []; + if (ariaNode.role === "link" && element.hasAttribute("href")) { + const href = element.getAttribute("href"); + ariaNode.props["url"] = href; + } + if (ariaNode.role === "textbox" && element.hasAttribute("placeholder") && element.getAttribute("placeholder") !== ariaNode.name) { + const placeholder = element.getAttribute("placeholder"); + ariaNode.props["placeholder"] = placeholder; + } + } + beginAriaCaches(); + try { + visit(snapshot.root, rootElement, true); + } finally { + endAriaCaches(); + } + normalizeStringChildren(snapshot.root); + normalizeGenericRoles(snapshot.root); + return snapshot; +} +function computeAriaRef(ariaNode, options) { + var _a; + if (options.refs === "none") + return; + if (options.refs === "interactable" && (!ariaNode.box.visible || !ariaNode.receivesPointerEvents)) + return; + const element = ariaNodeElement(ariaNode); + let ariaRef = element._ariaRef; + if (!ariaRef || ariaRef.role !== ariaNode.role || ariaRef.name !== ariaNode.name) { + ariaRef = { role: ariaNode.role, name: ariaNode.name, ref: ((_a = options.refPrefix) != null ? _a : "") + "e" + ++lastRef }; + element._ariaRef = ariaRef; + } + ariaNode.ref = ariaRef.ref; +} +function toAriaNode(element, options) { + var _a; + const active = element.ownerDocument.activeElement === element; + if (element.nodeName === "IFRAME") { + const ariaNode = { + role: "iframe", + name: "", + children: [], + props: {}, + box: computeBox(element), + receivesPointerEvents: true, + active + }; + setAriaNodeElement(ariaNode, element); + computeAriaRef(ariaNode, options); + return ariaNode; + } + const defaultRole = options.includeGenericRole ? "generic" : null; + const role = (_a = getAriaRole(element)) != null ? _a : defaultRole; + if (!role || role === "presentation" || role === "none") + return null; + const name = normalizeWhiteSpace(getElementAccessibleName(element, false) || ""); + const receivesPointerEvents2 = receivesPointerEvents(element); + const box = computeBox(element); + if (role === "generic" && box.inline && element.childNodes.length === 1 && element.childNodes[0].nodeType === Node.TEXT_NODE) + return null; + const result = { + role, + name, + children: [], + props: {}, + box, + receivesPointerEvents: receivesPointerEvents2, + active + }; + setAriaNodeElement(result, element); + computeAriaRef(result, options); + if (kAriaCheckedRoles.includes(role)) + result.checked = getAriaChecked(element); + if (kAriaDisabledRoles.includes(role)) + result.disabled = getAriaDisabled(element); + if (kAriaExpandedRoles.includes(role)) + result.expanded = getAriaExpanded(element); + if (kAriaLevelRoles.includes(role)) + result.level = getAriaLevel(element); + if (kAriaPressedRoles.includes(role)) + result.pressed = getAriaPressed(element); + if (kAriaSelectedRoles.includes(role)) + result.selected = getAriaSelected(element); + if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) { + if (element.type !== "checkbox" && element.type !== "radio" && element.type !== "file") + result.children = [element.value]; + } + return result; +} +function normalizeGenericRoles(node) { + const normalizeChildren = (node2) => { + const result = []; + for (const child of node2.children || []) { + if (typeof child === "string") { + result.push(child); + continue; + } + const normalized = normalizeChildren(child); + result.push(...normalized); + } + const removeSelf = node2.role === "generic" && !node2.name && result.length <= 1 && result.every((c) => typeof c !== "string" && !!c.ref); + if (removeSelf) + return result; + node2.children = result; + return [node2]; + }; + normalizeChildren(node); +} +function normalizeStringChildren(rootA11yNode) { + const flushChildren = (buffer, normalizedChildren) => { + if (!buffer.length) + return; + const text = normalizeWhiteSpace(buffer.join("")); + if (text) + normalizedChildren.push(text); + buffer.length = 0; + }; + const visit = (ariaNode) => { + const normalizedChildren = []; + const buffer = []; + for (const child of ariaNode.children || []) { + if (typeof child === "string") { + buffer.push(child); + } else { + flushChildren(buffer, normalizedChildren); + visit(child); + normalizedChildren.push(child); + } + } + flushChildren(buffer, normalizedChildren); + ariaNode.children = normalizedChildren.length ? normalizedChildren : []; + if (ariaNode.children.length === 1 && ariaNode.children[0] === ariaNode.name) + ariaNode.children = []; + }; + visit(rootA11yNode); +} +function matchesStringOrRegex(text, template) { + if (!template) + return true; + if (!text) + return false; + if (typeof template === "string") + return text === template; + return !!text.match(new RegExp(template.pattern)); +} +function matchesTextValue(text, template) { + if (!(template == null ? void 0 : template.normalized)) + return true; + if (!text) + return false; + if (text === template.normalized) + return true; + if (text === template.raw) + return true; + const regex = cachedRegex(template); + if (regex) + return !!text.match(regex); + return false; +} +var cachedRegexSymbol = Symbol("cachedRegex"); +function cachedRegex(template) { + if (template[cachedRegexSymbol] !== void 0) + return template[cachedRegexSymbol]; + const { raw } = template; + const canBeRegex = raw.startsWith("/") && raw.endsWith("/") && raw.length > 1; + let regex; + try { + regex = canBeRegex ? new RegExp(raw.slice(1, -1)) : null; + } catch (e) { + regex = null; + } + template[cachedRegexSymbol] = regex; + return regex; +} +function matchesExpectAriaTemplate(rootElement, template) { + const snapshot = generateAriaTree(rootElement, { mode: "default" }); + const matches = matchesNodeDeep(snapshot.root, template, false, false); + return { + matches, + received: { + raw: renderAriaTree(snapshot, { mode: "default" }).text, + regex: renderAriaTree(snapshot, { mode: "codegen" }).text + } + }; +} +function getAllElementsMatchingExpectAriaTemplate(rootElement, template) { + const root = generateAriaTree(rootElement, { mode: "default" }).root; + const matches = matchesNodeDeep(root, template, true, false); + return matches.map((n) => ariaNodeElement(n)); +} +function matchesNode(node, template, isDeepEqual) { + var _a; + if (typeof node === "string" && template.kind === "text") + return matchesTextValue(node, template.text); + if (node === null || typeof node !== "object" || template.kind !== "role") + return false; + if (template.role !== "fragment" && template.role !== node.role) + return false; + if (template.checked !== void 0 && template.checked !== node.checked) + return false; + if (template.disabled !== void 0 && template.disabled !== node.disabled) + return false; + if (template.expanded !== void 0 && template.expanded !== node.expanded) + return false; + if (template.level !== void 0 && template.level !== node.level) + return false; + if (template.pressed !== void 0 && template.pressed !== node.pressed) + return false; + if (template.selected !== void 0 && template.selected !== node.selected) + return false; + if (!matchesStringOrRegex(node.name, template.name)) + return false; + if (!matchesTextValue(node.props.url, (_a = template.props) == null ? void 0 : _a.url)) + return false; + if (template.containerMode === "contain") + return containsList(node.children || [], template.children || []); + if (template.containerMode === "equal") + return listEqual(node.children || [], template.children || [], false); + if (template.containerMode === "deep-equal" || isDeepEqual) + return listEqual(node.children || [], template.children || [], true); + return containsList(node.children || [], template.children || []); +} +function listEqual(children, template, isDeepEqual) { + if (template.length !== children.length) + return false; + for (let i = 0; i < template.length; ++i) { + if (!matchesNode(children[i], template[i], isDeepEqual)) + return false; + } + return true; +} +function containsList(children, template) { + if (template.length > children.length) + return false; + const cc = children.slice(); + const tt = template.slice(); + for (const t of tt) { + let c = cc.shift(); + while (c) { + if (matchesNode(c, t, false)) + break; + c = cc.shift(); + } + if (!c) + return false; + } + return true; +} +function matchesNodeDeep(root, template, collectAll, isDeepEqual) { + const results = []; + const visit = (node, parent) => { + if (matchesNode(node, template, isDeepEqual)) { + const result = typeof node === "string" ? parent : node; + if (result) + results.push(result); + return !collectAll; + } + if (typeof node === "string") + return false; + for (const child of node.children || []) { + if (visit(child, node)) + return true; + } + return false; + }; + visit(root, null); + return results; +} +function buildByRefMap(root, map = /* @__PURE__ */ new Map()) { + if (root == null ? void 0 : root.ref) + map.set(root.ref, root); + for (const child of (root == null ? void 0 : root.children) || []) { + if (typeof child !== "string") + buildByRefMap(child, map); + } + return map; +} +function compareSnapshots(ariaSnapshot, previousSnapshot) { + var _a; + const previousByRef = buildByRefMap(previousSnapshot == null ? void 0 : previousSnapshot.root); + const result = /* @__PURE__ */ new Map(); + const visit = (ariaNode, previousNode) => { + let same = ariaNode.children.length === (previousNode == null ? void 0 : previousNode.children.length) && ariaNodesEqual(ariaNode, previousNode); + let canBeSkipped = same; + for (let childIndex = 0; childIndex < ariaNode.children.length; childIndex++) { + const child = ariaNode.children[childIndex]; + const previousChild = previousNode == null ? void 0 : previousNode.children[childIndex]; + if (typeof child === "string") { + same && (same = child === previousChild); + canBeSkipped && (canBeSkipped = child === previousChild); + } else { + let previous = typeof previousChild !== "string" ? previousChild : void 0; + if (child.ref) + previous = previousByRef.get(child.ref); + const sameChild = visit(child, previous); + if (!previous || !sameChild && !child.ref || previous !== previousChild) + canBeSkipped = false; + same && (same = sameChild && previous === previousChild); + } + } + result.set(ariaNode, same ? "same" : canBeSkipped ? "skip" : "changed"); + return same; + }; + visit(ariaSnapshot.root, previousByRef.get((_a = previousSnapshot == null ? void 0 : previousSnapshot.root) == null ? void 0 : _a.ref)); + return result; +} +function filterSnapshotDiff(nodes, statusMap) { + const result = []; + const visit = (ariaNode) => { + const status = statusMap.get(ariaNode); + if (status === "same") { + } else if (status === "skip") { + for (const child of ariaNode.children) { + if (typeof child !== "string") + visit(child); + } + } else { + result.push(ariaNode); + } + }; + for (const node of nodes) { + if (typeof node === "string") + result.push(node); + else + visit(node); + } + return result; +} +function indent(depth) { + return " ".repeat(depth); +} +function renderAriaTree(ariaSnapshot, publicOptions, previousSnapshot) { + const options = toInternalOptions(publicOptions); + const lines = []; + const iframeDepths = {}; + const includeText = options.renderStringsAsRegex ? textContributesInfo : () => true; + const renderString = options.renderStringsAsRegex ? convertToBestGuessRegex : (str) => str; + let nodesToRender = ariaSnapshot.root.role === "fragment" ? ariaSnapshot.root.children : [ariaSnapshot.root]; + const statusMap = compareSnapshots(ariaSnapshot, previousSnapshot); + if (previousSnapshot) + nodesToRender = filterSnapshotDiff(nodesToRender, statusMap); + const visitText = (text, depth) => { + if (publicOptions.depth && depth > publicOptions.depth) + return; + const escaped = yamlEscapeValueIfNeeded(renderString(text)); + if (escaped) + lines.push(indent(depth) + "- text: " + escaped); + }; + const createKey = (ariaNode, renderCursorPointer) => { + let key = ariaNode.role; + if (ariaNode.name && ariaNode.name.length <= 900) { + const name = renderString(ariaNode.name); + if (name) { + const stringifiedName = name.startsWith("/") && name.endsWith("/") ? name : JSON.stringify(name); + key += " " + stringifiedName; + } + } + if (ariaNode.checked === "mixed") + key += ` [checked=mixed]`; + if (ariaNode.checked === true) + key += ` [checked]`; + if (ariaNode.disabled) + key += ` [disabled]`; + if (ariaNode.expanded) + key += ` [expanded]`; + if (ariaNode.active && options.renderActive) + key += ` [active]`; + if (ariaNode.level) + key += ` [level=${ariaNode.level}]`; + if (ariaNode.pressed === "mixed") + key += ` [pressed=mixed]`; + if (ariaNode.pressed === true) + key += ` [pressed]`; + if (ariaNode.selected === true) + key += ` [selected]`; + if (ariaNode.ref) { + key += ` [ref=${ariaNode.ref}]`; + if (renderCursorPointer && hasPointerCursor(ariaNode)) + key += " [cursor=pointer]"; + } + if (options.renderBoxes) { + const element = ariaNodeElement(ariaNode); + if (element) { + const r = element.getBoundingClientRect(); + key += ` [box=${Math.round(r.x)},${Math.round(r.y)},${Math.round(r.width)},${Math.round(r.height)}]`; + } + } + return key; + }; + const getSingleInlinedTextChild = (ariaNode) => { + return (ariaNode == null ? void 0 : ariaNode.children.length) === 1 && typeof ariaNode.children[0] === "string" && !Object.keys(ariaNode.props).length ? ariaNode.children[0] : void 0; + }; + const visit = (ariaNode, depth, renderCursorPointer) => { + if (publicOptions.depth && depth > publicOptions.depth) + return; + if (ariaNode.role === "iframe" && ariaNode.ref) + iframeDepths[ariaNode.ref] = depth; + if (statusMap.get(ariaNode) === "same" && ariaNode.ref) { + lines.push(indent(depth) + `- ref=${ariaNode.ref} [unchanged]`); + return; + } + const isDiffRoot = !!previousSnapshot && !depth; + const escapedKey = indent(depth) + "- " + (isDiffRoot ? " " : "") + yamlEscapeKeyIfNeeded(createKey(ariaNode, renderCursorPointer)); + const singleInlinedTextChild = getSingleInlinedTextChild(ariaNode); + const isAtDepthLimit = !!publicOptions.depth && depth === publicOptions.depth; + const hasNoChildren = !singleInlinedTextChild && (!ariaNode.children.length || isAtDepthLimit); + if (hasNoChildren && !Object.keys(ariaNode.props).length) { + lines.push(escapedKey); + } else if (singleInlinedTextChild !== void 0) { + const shouldInclude = includeText(ariaNode, singleInlinedTextChild); + if (shouldInclude) + lines.push(escapedKey + ": " + yamlEscapeValueIfNeeded(renderString(singleInlinedTextChild))); + else + lines.push(escapedKey); + } else { + lines.push(escapedKey + ":"); + for (const [name, value] of Object.entries(ariaNode.props)) + lines.push(indent(depth + 1) + "- /" + name + ": " + yamlEscapeValueIfNeeded(value)); + const inCursorPointer = !!ariaNode.ref && renderCursorPointer && hasPointerCursor(ariaNode); + for (const child of ariaNode.children) { + if (typeof child === "string") + visitText(includeText(ariaNode, child) ? child : "", depth + 1); + else + visit(child, depth + 1, renderCursorPointer && !inCursorPointer); + } + } + }; + for (const nodeToRender of nodesToRender) { + if (typeof nodeToRender === "string") + visitText(nodeToRender, 0); + else + visit(nodeToRender, 0, !!options.renderCursorPointer); + } + return { text: lines.join("\n"), iframeDepths }; +} +function convertToBestGuessRegex(text) { + const dynamicContent = [ + // 550e8400-e29b-41d4-a716-446655440000 + { regex: /\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b/, replacement: "[0-9a-fA-F-]+" }, + // 2mb + { regex: /\b[\d,.]+[bkmBKM]+\b/, replacement: "[\\d,.]+[bkmBKM]+" }, + // 2ms, 20s + { regex: /\b\d+[hmsp]+\b/, replacement: "\\d+[hmsp]+" }, + { regex: /\b[\d,.]+[hmsp]+\b/, replacement: "[\\d,.]+[hmsp]+" }, + // Do not replace single digits with regex by default. + // 2+ digits: [Issue 22, 22.3, 2.33, 2,333] + { regex: /\b\d+,\d+\b/, replacement: "\\d+,\\d+" }, + { regex: /\b\d+\.\d{2,}\b/, replacement: "\\d+\\.\\d+" }, + { regex: /\b\d{2,}\.\d+\b/, replacement: "\\d+\\.\\d+" }, + { regex: /\b\d{2,}\b/, replacement: "\\d+" } + ]; + let pattern = ""; + let lastIndex = 0; + const combinedRegex = new RegExp(dynamicContent.map((r) => "(" + r.regex.source + ")").join("|"), "g"); + text.replace(combinedRegex, (match, ...args) => { + const offset = args[args.length - 2]; + const groups = args.slice(0, -2); + pattern += escapeRegExp(text.slice(lastIndex, offset)); + for (let i = 0; i < groups.length; i++) { + if (groups[i]) { + const { replacement } = dynamicContent[i]; + pattern += replacement; + break; + } + } + lastIndex = offset + match.length; + return match; + }); + if (!pattern) + return text; + pattern += escapeRegExp(text.slice(lastIndex)); + return String(new RegExp(pattern)); +} +function textContributesInfo(node, text) { + if (!text.length) + return false; + if (!node.name) + return true; + const substr = text.length <= 200 && node.name.length <= 200 ? longestCommonSubstring(text, node.name) : ""; + let filtered = text; + while (substr && filtered.includes(substr)) + filtered = filtered.replace(substr, ""); + return filtered.trim().length / text.length > 0.1; +} +var elementSymbol = Symbol("element"); +function ariaNodeElement(ariaNode) { + return ariaNode[elementSymbol]; +} +function setAriaNodeElement(ariaNode, element) { + ariaNode[elementSymbol] = element; +} +function findNewElement(from, to) { + const node = findNewNode(from, to); + return node ? ariaNodeElement(node) : void 0; +} + +// packages/injected/src/highlight.css?inline +var highlight_default = ":host{font-size:13px;font-family:system-ui,Ubuntu,Droid Sans,sans-serif;color:#333}svg{position:absolute;height:0}x-pw-tooltip{backdrop-filter:blur(5px);background-color:#fff;border-radius:6px;box-shadow:0 .5rem 1.2rem #0000004d;display:none;font-size:12.8px;font-weight:400;left:0;line-height:1.5;max-width:600px;position:absolute;top:0;padding:0;flex-direction:column;overflow:hidden}x-pw-tooltip-line{display:flex;max-width:600px;padding:6px;user-select:none;cursor:pointer}x-pw-tooltip-footer{display:flex;max-width:600px;padding:6px;user-select:none;color:#777}x-pw-dialog{background-color:#fff;pointer-events:auto;border-radius:6px;box-shadow:0 .5rem 1.2rem #0000004d;display:flex;flex-direction:column;position:absolute;z-index:10;font-size:13px}x-pw-dialog:not(.autosize){width:400px;height:150px}x-pw-dialog-body{display:flex;flex-direction:column;flex:auto}x-pw-dialog-body label{margin:5px 8px;display:flex;flex-direction:row;align-items:center}x-pw-highlight{position:absolute;top:0;left:0;width:0;height:0}x-pw-action-point{position:absolute;width:20px;height:20px;background:red;border-radius:10px;margin:-10px 0 0 -10px;z-index:2}x-pw-title{position:absolute;backdrop-filter:blur(5px);background-color:#00000080;color:#fff;border-radius:6px;padding:6px;font-size:24px;line-height:1.4;white-space:nowrap;user-select:none;z-index:3}x-pw-user-overlays,x-pw-user-overlay{position:absolute;inset:0}@keyframes pw-fade-out{0%{opacity:1}to{opacity:0}}x-pw-separator{height:1px;margin:6px 9px;background:#949494e5}x-pw-tool-gripper{height:28px;width:24px;margin:2px 0;cursor:grab}x-pw-tool-gripper:active{cursor:grabbing}x-pw-tool-gripper>x-div{width:16px;height:16px;margin:6px 4px;clip-path:url(#icon-gripper);background-color:#555}x-pw-tools-list>label{display:flex;align-items:center;margin:0 10px;user-select:none}x-pw-tools-list{display:flex;width:100%;border-bottom:1px solid #dddddd}x-pw-tool-item{pointer-events:auto;height:28px;width:28px;border-radius:3px}x-pw-tool-item:not(.disabled){cursor:pointer}x-pw-tool-item:not(.disabled):hover{background-color:#dbdbdb}x-pw-tool-item.toggled{background-color:#8acae480}x-pw-tool-item.toggled:not(.disabled):hover{background-color:#8acae4c4}x-pw-tool-item>x-div{width:16px;height:16px;margin:6px;background-color:#3a3a3a}x-pw-tool-item.disabled>x-div{background-color:#61616180;cursor:default}x-pw-tool-item.record.toggled{background-color:transparent}x-pw-tool-item.record.toggled:not(.disabled):hover{background-color:#dbdbdb}x-pw-tool-item.record.toggled>x-div{background-color:#a1260d}x-pw-tool-item.record.disabled.toggled>x-div{opacity:.8}x-pw-tool-item.accept>x-div{background-color:#388a34}x-pw-tool-item.record>x-div{clip-path:url(#icon-circle-large-filled)}x-pw-tool-item.record.toggled>x-div{clip-path:url(#icon-stop-circle)}x-pw-tool-item.pick-locator>x-div{clip-path:url(#icon-inspect)}x-pw-tool-item.text>x-div{clip-path:url(#icon-whole-word)}x-pw-tool-item.visibility>x-div{clip-path:url(#icon-eye)}x-pw-tool-item.value>x-div{clip-path:url(#icon-symbol-constant)}x-pw-tool-item.snapshot>x-div{clip-path:url(#icon-gist)}x-pw-tool-item.accept>x-div{clip-path:url(#icon-check)}x-pw-tool-item.cancel>x-div{clip-path:url(#icon-close)}x-pw-tool-item.succeeded>x-div{clip-path:url(#icon-pass);background-color:#388a34!important}x-pw-overlay{position:absolute;top:0;max-width:min-content;z-index:2147483647;background:transparent;pointer-events:auto}x-pw-overlay x-pw-tools-list{background-color:#fffd;box-shadow:#0000001a 0 5px 5px;border-radius:3px;border-bottom:none}x-pw-overlay x-pw-tool-item{margin:2px}textarea.text-editor{font-family:system-ui,Ubuntu,Droid Sans,sans-serif;flex:auto;border:none;margin:6px 10px;color:#333;outline:1px solid transparent!important;resize:none;padding:0;font-size:13px}textarea.text-editor.does-not-match{outline:1px solid red!important}x-div{display:block}x-spacer{flex:auto}*{box-sizing:border-box}*[hidden]{display:none!important}x-locator-editor{flex:none;width:100%;height:60px;padding:4px;border-bottom:1px solid #dddddd;outline:1px solid transparent}x-locator-editor.does-not-match{outline:1px solid red}.CodeMirror{width:100%!important;height:100%!important}x-pw-action-list{flex:auto;display:flex;flex-direction:column;user-select:none}x-pw-action-item{padding:6px 10px;cursor:pointer;overflow:hidden}x-pw-action-item:hover{background-color:#f2f2f2}x-pw-action-item:last-child{border-bottom-left-radius:6px;border-bottom-right-radius:6px}\n"; + +// packages/injected/src/highlight.ts +var Highlight = class { + constructor(injectedScript) { + this._renderedEntries = []; + this._userOverlays = /* @__PURE__ */ new Map(); + this._userOverlayHidden = false; + this._language = "javascript"; + this._elementHighlightSelectors = /* @__PURE__ */ new Map(); + this._injectedScript = injectedScript; + const document = injectedScript.document; + this._isUnderTest = injectedScript.isUnderTest; + this._glassPaneElement = document.createElement("x-pw-glass"); + this._glassPaneElement.setAttribute("popover", "manual"); + this._glassPaneElement.style.inset = "0"; + this._glassPaneElement.style.width = "100%"; + this._glassPaneElement.style.height = "100%"; + this._glassPaneElement.style.maxWidth = "none"; + this._glassPaneElement.style.maxHeight = "none"; + this._glassPaneElement.style.padding = "0"; + this._glassPaneElement.style.margin = "0"; + this._glassPaneElement.style.border = "none"; + this._glassPaneElement.style.overflow = "visible"; + this._glassPaneElement.style.pointerEvents = "none"; + this._glassPaneElement.style.display = "flex"; + this._glassPaneElement.style.backgroundColor = "transparent"; + this._actionPointElement = document.createElement("x-pw-action-point"); + this._actionPointElement.setAttribute("hidden", "true"); + this._titleElement = document.createElement("x-pw-title"); + this._titleElement.setAttribute("hidden", "true"); + this._userOverlayContainer = document.createElement("x-pw-user-overlays"); + this._userOverlayContainer.setAttribute("hidden", "true"); + this._glassPaneShadow = this._glassPaneElement.attachShadow({ mode: this._isUnderTest ? "open" : "closed" }); + if (typeof this._glassPaneShadow.adoptedStyleSheets.push === "function") { + const sheet = new this._injectedScript.window.CSSStyleSheet(); + sheet.replaceSync(highlight_default); + this._glassPaneShadow.adoptedStyleSheets.push(sheet); + } else { + const styleElement = this._injectedScript.document.createElement("style"); + styleElement.textContent = highlight_default; + this._glassPaneShadow.appendChild(styleElement); + } + this._glassPaneShadow.appendChild(this._actionPointElement); + this._glassPaneShadow.appendChild(this._titleElement); + this._glassPaneShadow.appendChild(this._userOverlayContainer); + } + install() { + if (!this._injectedScript.document.documentElement) + return; + if (!this._injectedScript.document.documentElement.contains(this._glassPaneElement) || this._glassPaneElement.nextElementSibling) + this._injectedScript.document.documentElement.appendChild(this._glassPaneElement); + this._bringToFront(); + } + _bringToFront() { + this._glassPaneElement.hidePopover(); + this._glassPaneElement.showPopover(); + } + setLanguage(language) { + this._language = language; + } + addElementHighlight(selector, cssStyle) { + const key = stringifySelector(selector); + this._elementHighlightSelectors.set(key, { selector, cssStyle }); + this._ensureElementHighlightRaf(); + } + removeElementHighlight(selector) { + const key = stringifySelector(selector); + if (!this._elementHighlightSelectors.delete(key)) + return; + if (this._elementHighlightSelectors.size === 0) { + if (this._rafRequest) { + this._injectedScript.utils.builtins.cancelAnimationFrame(this._rafRequest); + this._rafRequest = void 0; + } + this.clearHighlight(); + } + } + _ensureElementHighlightRaf() { + if (this._rafRequest) + return; + const tick = () => { + const entries = []; + for (const { selector, cssStyle } of this._elementHighlightSelectors.values()) { + const elements = this._injectedScript.querySelectorAll(selector, this._injectedScript.document.documentElement); + const locator = asLocator(this._language, stringifySelector(selector)); + const color = elements.length > 1 ? "#f6b26b7f" : "#6fa8dc7f"; + for (let i = 0; i < elements.length; ++i) { + const suffix = elements.length > 1 ? ` [${i + 1} of ${elements.length}]` : ""; + entries.push({ element: elements[i], color, tooltipText: locator + suffix, cssStyle }); + } + } + this.updateHighlight(entries); + this._rafRequest = this._injectedScript.utils.builtins.requestAnimationFrame(tick); + }; + this._rafRequest = this._injectedScript.utils.builtins.requestAnimationFrame(tick); + } + uninstall() { + if (this._rafRequest) { + this._injectedScript.utils.builtins.cancelAnimationFrame(this._rafRequest); + this._rafRequest = void 0; + } + this._elementHighlightSelectors.clear(); + this._glassPaneElement.remove(); + } + showActionPoint(x, y, fadeDuration) { + this._actionPointElement.style.top = y + "px"; + this._actionPointElement.style.left = x + "px"; + this._actionPointElement.hidden = false; + if (fadeDuration) + this._actionPointElement.style.animation = `pw-fade-out ${fadeDuration}ms ease-out forwards`; + else + this._actionPointElement.style.animation = ""; + } + hideActionPoint() { + this._actionPointElement.hidden = true; + } + showActionTitle(text, fadeDuration, position, fontSize) { + this._titleElement.textContent = text; + this._titleElement.hidden = false; + if (fadeDuration) { + const fadeTime = fadeDuration / 4; + this._titleElement.style.animation = `pw-fade-out ${fadeTime}ms ease-out ${fadeDuration - fadeTime}ms forwards`; + } else { + this._titleElement.style.animation = ""; + } + this._titleElement.style.top = ""; + this._titleElement.style.bottom = ""; + this._titleElement.style.left = ""; + this._titleElement.style.right = ""; + this._titleElement.style.transform = ""; + switch (position) { + case "top-left": + this._titleElement.style.top = "6px"; + this._titleElement.style.left = "6px"; + break; + case "top": + this._titleElement.style.top = "6px"; + this._titleElement.style.left = "50%"; + this._titleElement.style.transform = "translateX(-50%)"; + break; + case "bottom-left": + this._titleElement.style.bottom = "6px"; + this._titleElement.style.left = "6px"; + break; + case "bottom": + this._titleElement.style.bottom = "6px"; + this._titleElement.style.left = "50%"; + this._titleElement.style.transform = "translateX(-50%)"; + break; + case "bottom-right": + this._titleElement.style.bottom = "6px"; + this._titleElement.style.right = "6px"; + break; + case "top-right": + default: + this._titleElement.style.top = "6px"; + this._titleElement.style.right = "6px"; + break; + } + if (fontSize) + this._titleElement.style.fontSize = fontSize + "px"; + } + hideActionTitle() { + this._titleElement.hidden = true; + } + addUserOverlay(id, html) { + const element = this._injectedScript.document.createElement("div"); + element.className = "x-pw-user-overlay"; + element.innerHTML = html; + for (const script of element.querySelectorAll("script")) + script.remove(); + for (const el of element.querySelectorAll("*")) { + for (const attr of [...el.attributes]) { + if (attr.name.startsWith("on")) + el.removeAttribute(attr.name); + } + } + this._userOverlays.set(id, element); + this._userOverlayContainer.appendChild(element); + this._userOverlayContainer.hidden = this._userOverlayHidden; + return id; + } + getUserOverlay(id) { + return this._userOverlays.get(id); + } + removeUserOverlay(id) { + const element = this._userOverlays.get(id); + if (element) { + element.remove(); + this._userOverlays.delete(id); + } + if (this._userOverlays.size === 0) + this._userOverlayContainer.hidden = true; + } + setUserOverlaysVisible(visible) { + this._userOverlayHidden = !visible; + this._userOverlayContainer.hidden = !visible || this._userOverlays.size === 0; + } + clearHighlight() { + var _a, _b; + for (const entry of this._renderedEntries) { + (_a = entry.highlightElement) == null ? void 0 : _a.remove(); + (_b = entry.tooltipElement) == null ? void 0 : _b.remove(); + } + this._renderedEntries = []; + } + maskElements(elements, color) { + this.updateHighlight(elements.map((element) => ({ element, color }))); + } + updateHighlight(entries) { + if (this._highlightIsUpToDate(entries)) + return; + this.clearHighlight(); + for (const entry of entries) { + const highlightElement = this._createHighlightElement(); + this._glassPaneShadow.appendChild(highlightElement); + let tooltipElement; + if (entry.tooltipText) { + tooltipElement = this._injectedScript.document.createElement("x-pw-tooltip"); + this._glassPaneShadow.appendChild(tooltipElement); + tooltipElement.style.top = "0"; + tooltipElement.style.left = "0"; + tooltipElement.style.display = "flex"; + const lineElement = this._injectedScript.document.createElement("x-pw-tooltip-line"); + lineElement.textContent = entry.tooltipText; + tooltipElement.appendChild(lineElement); + } + this._renderedEntries.push({ targetElement: entry.element, box: toDOMRect(entry.box), color: entry.color, borderColor: entry.borderColor, fadeDuration: entry.fadeDuration, cssStyle: entry.cssStyle, tooltipElement, highlightElement }); + } + for (const entry of this._renderedEntries) { + if (!entry.box && !entry.targetElement) + continue; + entry.box = entry.box || entry.targetElement.getBoundingClientRect(); + if (!entry.tooltipElement) + continue; + const { anchorLeft, anchorTop } = this.tooltipPosition(entry.box, entry.tooltipElement); + entry.tooltipTop = anchorTop; + entry.tooltipLeft = anchorLeft; + } + for (const entry of this._renderedEntries) { + if (entry.tooltipElement) { + entry.tooltipElement.style.top = entry.tooltipTop + "px"; + entry.tooltipElement.style.left = entry.tooltipLeft + "px"; + } + const box = entry.box; + entry.highlightElement.style.backgroundColor = entry.color; + entry.highlightElement.style.left = box.x + "px"; + entry.highlightElement.style.top = box.y + "px"; + entry.highlightElement.style.width = box.width + "px"; + entry.highlightElement.style.height = box.height + "px"; + entry.highlightElement.style.display = "block"; + if (entry.borderColor) + entry.highlightElement.style.border = "2px solid " + entry.borderColor; + if (entry.fadeDuration) + entry.highlightElement.style.animation = `pw-fade-out ${entry.fadeDuration}ms ease-out forwards`; + if (entry.cssStyle) + entry.highlightElement.style.cssText += ";" + entry.cssStyle; + if (this._isUnderTest) + console.error("Highlight box for test: " + JSON.stringify({ x: box.x, y: box.y, width: box.width, height: box.height })); + } + } + firstBox() { + var _a; + return (_a = this._renderedEntries[0]) == null ? void 0 : _a.box; + } + firstTooltipBox() { + const entry = this._renderedEntries[0]; + if (!entry || !entry.tooltipElement || entry.tooltipLeft === void 0 || entry.tooltipTop === void 0) + return; + return { + x: entry.tooltipLeft, + y: entry.tooltipTop, + left: entry.tooltipLeft, + top: entry.tooltipTop, + width: entry.tooltipElement.offsetWidth, + height: entry.tooltipElement.offsetHeight, + bottom: entry.tooltipTop + entry.tooltipElement.offsetHeight, + right: entry.tooltipLeft + entry.tooltipElement.offsetWidth, + toJSON: () => { + } + }; + } + // Note: there is a copy of this method in dialog.tsx. Please fix bugs in both places. + tooltipPosition(box, tooltipElement) { + const tooltipWidth = tooltipElement.offsetWidth; + const tooltipHeight = tooltipElement.offsetHeight; + const totalWidth = this._glassPaneElement.offsetWidth; + const totalHeight = this._glassPaneElement.offsetHeight; + let anchorLeft = Math.max(5, box.left); + if (anchorLeft + tooltipWidth > totalWidth - 5) + anchorLeft = totalWidth - tooltipWidth - 5; + let anchorTop = Math.max(0, box.bottom) + 5; + if (anchorTop + tooltipHeight > totalHeight - 5) { + if (Math.max(0, box.top) > tooltipHeight + 5) { + anchorTop = Math.max(0, box.top) - tooltipHeight - 5; + } else { + anchorTop = totalHeight - 5 - tooltipHeight; + } + } + return { anchorLeft, anchorTop }; + } + _highlightIsUpToDate(entries) { + if (entries.length !== this._renderedEntries.length) + return false; + for (let i = 0; i < this._renderedEntries.length; ++i) { + if (entries[i].element !== this._renderedEntries[i].targetElement) + return false; + if (entries[i].color !== this._renderedEntries[i].color) + return false; + if (entries[i].cssStyle !== this._renderedEntries[i].cssStyle) + return false; + const oldBox = this._renderedEntries[i].box; + if (!oldBox) + return false; + const box = entries[i].box ? toDOMRect(entries[i].box) : entries[i].element.getBoundingClientRect(); + if (box.top !== oldBox.top || box.right !== oldBox.right || box.bottom !== oldBox.bottom || box.left !== oldBox.left) + return false; + } + return true; + } + _createHighlightElement() { + return this._injectedScript.document.createElement("x-pw-highlight"); + } + appendChild(element) { + this._glassPaneShadow.appendChild(element); + } + onGlassPaneClick(handler) { + this._glassPaneElement.style.pointerEvents = "auto"; + this._glassPaneElement.style.backgroundColor = "rgba(0, 0, 0, 0.3)"; + this._glassPaneElement.addEventListener("click", handler); + } + offGlassPaneClick(handler) { + this._glassPaneElement.style.pointerEvents = "none"; + this._glassPaneElement.style.backgroundColor = "transparent"; + this._glassPaneElement.removeEventListener("click", handler); + } +}; +function toDOMRect(box) { + if (!box) + return void 0; + return new DOMRect(box.x, box.y, box.width, box.height); +} + +// packages/injected/src/layoutSelectorUtils.ts +function boxRightOf(box1, box2, maxDistance) { + const distance = box1.left - box2.right; + if (distance < 0 || maxDistance !== void 0 && distance > maxDistance) + return; + return distance + Math.max(box2.bottom - box1.bottom, 0) + Math.max(box1.top - box2.top, 0); +} +function boxLeftOf(box1, box2, maxDistance) { + const distance = box2.left - box1.right; + if (distance < 0 || maxDistance !== void 0 && distance > maxDistance) + return; + return distance + Math.max(box2.bottom - box1.bottom, 0) + Math.max(box1.top - box2.top, 0); +} +function boxAbove(box1, box2, maxDistance) { + const distance = box2.top - box1.bottom; + if (distance < 0 || maxDistance !== void 0 && distance > maxDistance) + return; + return distance + Math.max(box1.left - box2.left, 0) + Math.max(box2.right - box1.right, 0); +} +function boxBelow(box1, box2, maxDistance) { + const distance = box1.top - box2.bottom; + if (distance < 0 || maxDistance !== void 0 && distance > maxDistance) + return; + return distance + Math.max(box1.left - box2.left, 0) + Math.max(box2.right - box1.right, 0); +} +function boxNear(box1, box2, maxDistance) { + const kThreshold = maxDistance === void 0 ? 50 : maxDistance; + let score = 0; + if (box1.left - box2.right >= 0) + score += box1.left - box2.right; + if (box2.left - box1.right >= 0) + score += box2.left - box1.right; + if (box2.top - box1.bottom >= 0) + score += box2.top - box1.bottom; + if (box1.top - box2.bottom >= 0) + score += box1.top - box2.bottom; + return score > kThreshold ? void 0 : score; +} +var kLayoutSelectorNames = ["left-of", "right-of", "above", "below", "near"]; +function layoutSelectorScore(name, element, inner, maxDistance) { + const box = element.getBoundingClientRect(); + const scorer = { "left-of": boxLeftOf, "right-of": boxRightOf, "above": boxAbove, "below": boxBelow, "near": boxNear }[name]; + let bestScore; + for (const e of inner) { + if (e === element) + continue; + const score = scorer(box, e.getBoundingClientRect(), maxDistance); + if (score === void 0) + continue; + if (bestScore === void 0 || score < bestScore) + bestScore = score; + } + return bestScore; +} + +// packages/injected/src/selectorUtils.ts +function matchesAttributePart(value, attr) { + const objValue = typeof value === "string" && !attr.caseSensitive ? value.toUpperCase() : value; + const attrValue = typeof attr.value === "string" && !attr.caseSensitive ? attr.value.toUpperCase() : attr.value; + if (attr.op === "") + return !!objValue; + if (attr.op === "=") { + if (attrValue instanceof RegExp) + return typeof objValue === "string" && !!objValue.match(attrValue); + return objValue === attrValue; + } + if (typeof objValue !== "string" || typeof attrValue !== "string") + return false; + if (attr.op === "*=") + return objValue.includes(attrValue); + if (attr.op === "^=") + return objValue.startsWith(attrValue); + if (attr.op === "$=") + return objValue.endsWith(attrValue); + if (attr.op === "|=") + return objValue === attrValue || objValue.startsWith(attrValue + "-"); + if (attr.op === "~=") + return objValue.split(" ").includes(attrValue); + return false; +} +function shouldSkipForTextMatching(element) { + const document = element.ownerDocument; + return element.nodeName === "SCRIPT" || element.nodeName === "NOSCRIPT" || element.nodeName === "STYLE" || document.head && document.head.contains(element); +} +function elementText(cache, root) { + let value = cache.get(root); + if (value === void 0) { + value = { full: "", normalized: "", immediate: [] }; + if (!shouldSkipForTextMatching(root)) { + let currentImmediate = ""; + if (root instanceof HTMLInputElement && (root.type === "submit" || root.type === "button")) { + value = { full: root.value, normalized: normalizeWhiteSpace(root.value), immediate: [root.value] }; + } else { + for (let child = root.firstChild; child; child = child.nextSibling) { + if (child.nodeType === Node.TEXT_NODE) { + value.full += child.nodeValue || ""; + currentImmediate += child.nodeValue || ""; + } else if (child.nodeType === Node.COMMENT_NODE) { + continue; + } else { + if (currentImmediate) + value.immediate.push(currentImmediate); + currentImmediate = ""; + if (child.nodeType === Node.ELEMENT_NODE) + value.full += elementText(cache, child).full; + } + } + if (currentImmediate) + value.immediate.push(currentImmediate); + if (root.shadowRoot) + value.full += elementText(cache, root.shadowRoot).full; + if (value.full) + value.normalized = normalizeWhiteSpace(value.full); + } + } + cache.set(root, value); + } + return value; +} +function elementMatchesText(cache, element, matcher) { + if (shouldSkipForTextMatching(element)) + return "none"; + if (!matcher(elementText(cache, element))) + return "none"; + for (let child = element.firstChild; child; child = child.nextSibling) { + if (child.nodeType === Node.ELEMENT_NODE && matcher(elementText(cache, child))) + return "selfAndChildren"; + } + if (element.shadowRoot && matcher(elementText(cache, element.shadowRoot))) + return "selfAndChildren"; + return "self"; +} +function getElementLabels(textCache, element) { + const labels = getAriaLabelledByElements(element); + if (labels) + return labels.map((label) => elementText(textCache, label)); + const ariaLabel = element.getAttribute("aria-label"); + if (ariaLabel !== null && !!ariaLabel.trim()) + return [{ full: ariaLabel, normalized: normalizeWhiteSpace(ariaLabel), immediate: [ariaLabel] }]; + const isNonHiddenInput = element.nodeName === "INPUT" && element.type !== "hidden"; + if (["BUTTON", "METER", "OUTPUT", "PROGRESS", "SELECT", "TEXTAREA"].includes(element.nodeName) || isNonHiddenInput) { + const labels2 = element.labels; + if (labels2) + return [...labels2].map((label) => elementText(textCache, label)); + } + return []; +} + +// packages/injected/src/roleSelectorEngine.ts +var kSupportedAttributes = ["selected", "checked", "pressed", "expanded", "level", "disabled", "name", "description", "include-hidden"]; +kSupportedAttributes.sort(); +function validateSupportedRole(attr, roles, role) { + if (!roles.includes(role)) + throw new Error(`"${attr}" attribute is only supported for roles: ${roles.slice().sort().map((role2) => `"${role2}"`).join(", ")}`); +} +function validateSupportedValues(attr, values) { + if (attr.op !== "" && !values.includes(attr.value)) + throw new Error(`"${attr.name}" must be one of ${values.map((v) => JSON.stringify(v)).join(", ")}`); +} +function validateSupportedOp(attr, ops) { + if (!ops.includes(attr.op)) + throw new Error(`"${attr.name}" does not support "${attr.op}" matcher`); +} +function validateAttributes(attrs, role) { + const options = { role }; + for (const attr of attrs) { + switch (attr.name) { + case "checked": { + validateSupportedRole(attr.name, kAriaCheckedRoles, role); + validateSupportedValues(attr, [true, false, "mixed"]); + validateSupportedOp(attr, ["", "="]); + options.checked = attr.op === "" ? true : attr.value; + break; + } + case "pressed": { + validateSupportedRole(attr.name, kAriaPressedRoles, role); + validateSupportedValues(attr, [true, false, "mixed"]); + validateSupportedOp(attr, ["", "="]); + options.pressed = attr.op === "" ? true : attr.value; + break; + } + case "selected": { + validateSupportedRole(attr.name, kAriaSelectedRoles, role); + validateSupportedValues(attr, [true, false]); + validateSupportedOp(attr, ["", "="]); + options.selected = attr.op === "" ? true : attr.value; + break; + } + case "expanded": { + validateSupportedRole(attr.name, kAriaExpandedRoles, role); + validateSupportedValues(attr, [true, false]); + validateSupportedOp(attr, ["", "="]); + options.expanded = attr.op === "" ? true : attr.value; + break; + } + case "level": { + validateSupportedRole(attr.name, kAriaLevelRoles, role); + if (typeof attr.value === "string") + attr.value = +attr.value; + if (attr.op !== "=" || typeof attr.value !== "number" || Number.isNaN(attr.value)) + throw new Error(`"level" attribute must be compared to a number`); + options.level = attr.value; + break; + } + case "disabled": { + validateSupportedValues(attr, [true, false]); + validateSupportedOp(attr, ["", "="]); + options.disabled = attr.op === "" ? true : attr.value; + break; + } + case "name": { + if (attr.op === "") + throw new Error(`"name" attribute must have a value`); + if (typeof attr.value !== "string" && !(attr.value instanceof RegExp)) + throw new Error(`"name" attribute must be a string or a regular expression`); + options.name = attr.value; + options.nameOp = attr.op; + options.nameExact = attr.caseSensitive; + break; + } + case "description": { + if (attr.op === "") + throw new Error(`"description" attribute must have a value`); + if (typeof attr.value !== "string" && !(attr.value instanceof RegExp)) + throw new Error(`"description" attribute must be a string or a regular expression`); + options.description = attr.value; + options.descriptionOp = attr.op; + options.descriptionExact = attr.caseSensitive; + break; + } + case "include-hidden": { + validateSupportedValues(attr, [true, false]); + validateSupportedOp(attr, ["", "="]); + options.includeHidden = attr.op === "" ? true : attr.value; + break; + } + default: { + throw new Error(`Unknown attribute "${attr.name}", must be one of ${kSupportedAttributes.map((a) => `"${a}"`).join(", ")}.`); + } + } + } + return options; +} +function queryRole(scope, options, internal) { + const result = []; + const match = (element) => { + if (getAriaRole(element) !== options.role) + return; + if (options.selected !== void 0 && getAriaSelected(element) !== options.selected) + return; + if (options.checked !== void 0 && getAriaChecked(element) !== options.checked) + return; + if (options.pressed !== void 0 && getAriaPressed(element) !== options.pressed) + return; + if (options.expanded !== void 0 && getAriaExpanded(element) !== options.expanded) + return; + if (options.level !== void 0 && getAriaLevel(element) !== options.level) + return; + if (options.disabled !== void 0 && getAriaDisabled(element) !== options.disabled) + return; + if (!options.includeHidden) { + const isHidden = isElementHiddenForAria(element); + if (isHidden) + return; + } + if (options.name !== void 0) { + const accessibleName = normalizeWhiteSpace(getElementAccessibleName(element, !!options.includeHidden)); + if (typeof options.name === "string") + options.name = normalizeWhiteSpace(options.name); + if (internal && !options.nameExact && options.nameOp === "=") + options.nameOp = "*="; + if (!matchesAttributePart(accessibleName, { name: "", jsonPath: [], op: options.nameOp || "=", value: options.name, caseSensitive: !!options.nameExact })) + return; + } + if (options.description !== void 0) { + const accessibleDescription = normalizeWhiteSpace(getElementAccessibleDescription(element, !!options.includeHidden)); + if (typeof options.description === "string") + options.description = normalizeWhiteSpace(options.description); + if (internal && !options.descriptionExact && options.descriptionOp === "=") + options.descriptionOp = "*="; + if (!matchesAttributePart(accessibleDescription, { name: "", jsonPath: [], op: options.descriptionOp || "=", value: options.description, caseSensitive: !!options.descriptionExact })) + return; + } + result.push(element); + }; + const query = (root) => { + const shadows = []; + if (root.shadowRoot) + shadows.push(root.shadowRoot); + for (const element of root.querySelectorAll("*")) { + match(element); + if (element.shadowRoot) + shadows.push(element.shadowRoot); + } + shadows.forEach(query); + }; + query(scope); + return result; +} +function createRoleEngine(internal) { + return { + queryAll: (scope, selector) => { + const parsed = parseAttributeSelector(selector, true); + const role = parsed.name.toLowerCase(); + if (!role) + throw new Error(`Role must not be empty`); + const options = validateAttributes(parsed.attributes, role); + beginAriaCaches(); + try { + return queryRole(scope, options, internal); + } finally { + endAriaCaches(); + } + } + }; +} + +// packages/injected/src/selectorEvaluator.ts +var SelectorEvaluatorImpl = class { + constructor() { + this._retainCacheCounter = 0; + this._cacheText = /* @__PURE__ */ new Map(); + this._cacheQueryCSS = /* @__PURE__ */ new Map(); + this._cacheMatches = /* @__PURE__ */ new Map(); + this._cacheQuery = /* @__PURE__ */ new Map(); + this._cacheMatchesSimple = /* @__PURE__ */ new Map(); + this._cacheMatchesParents = /* @__PURE__ */ new Map(); + this._cacheCallMatches = /* @__PURE__ */ new Map(); + this._cacheCallQuery = /* @__PURE__ */ new Map(); + this._cacheQuerySimple = /* @__PURE__ */ new Map(); + this._engines = /* @__PURE__ */ new Map(); + this._engines.set("not", notEngine); + this._engines.set("is", isEngine); + this._engines.set("where", isEngine); + this._engines.set("has", hasEngine); + this._engines.set("scope", scopeEngine); + this._engines.set("light", lightEngine); + this._engines.set("visible", visibleEngine); + this._engines.set("text", textEngine); + this._engines.set("text-is", textIsEngine); + this._engines.set("text-matches", textMatchesEngine); + this._engines.set("has-text", hasTextEngine); + this._engines.set("right-of", createLayoutEngine("right-of")); + this._engines.set("left-of", createLayoutEngine("left-of")); + this._engines.set("above", createLayoutEngine("above")); + this._engines.set("below", createLayoutEngine("below")); + this._engines.set("near", createLayoutEngine("near")); + this._engines.set("nth-match", nthMatchEngine); + const allNames = [...this._engines.keys()]; + allNames.sort(); + const parserNames = [...customCSSNames]; + parserNames.sort(); + if (allNames.join("|") !== parserNames.join("|")) + throw new Error(`Please keep customCSSNames in sync with evaluator engines: ${allNames.join("|")} vs ${parserNames.join("|")}`); + } + begin() { + ++this._retainCacheCounter; + } + end() { + --this._retainCacheCounter; + if (!this._retainCacheCounter) { + this._cacheQueryCSS.clear(); + this._cacheMatches.clear(); + this._cacheQuery.clear(); + this._cacheMatchesSimple.clear(); + this._cacheMatchesParents.clear(); + this._cacheCallMatches.clear(); + this._cacheCallQuery.clear(); + this._cacheQuerySimple.clear(); + this._cacheText.clear(); + } + } + _cached(cache, main, rest, cb) { + if (!cache.has(main)) + cache.set(main, []); + const entries = cache.get(main); + const entry = entries.find((e) => rest.every((value, index) => e.rest[index] === value)); + if (entry) + return entry.result; + const result = cb(); + entries.push({ rest, result }); + return result; + } + _checkSelector(s) { + const wellFormed = typeof s === "object" && s && (Array.isArray(s) || "simples" in s && s.simples.length); + if (!wellFormed) + throw new Error(`Malformed selector "${s}"`); + return s; + } + matches(element, s, context) { + const selector = this._checkSelector(s); + this.begin(); + try { + return this._cached(this._cacheMatches, element, [selector, context.scope, context.pierceShadow, context.originalScope], () => { + if (Array.isArray(selector)) + return this._matchesEngine(isEngine, element, selector, context); + if (this._hasScopeClause(selector)) + context = this._expandContextForScopeMatching(context); + if (!this._matchesSimple(element, selector.simples[selector.simples.length - 1].selector, context)) + return false; + return this._matchesParents(element, selector, selector.simples.length - 2, context); + }); + } finally { + this.end(); + } + } + query(context, s) { + const selector = this._checkSelector(s); + this.begin(); + try { + return this._cached(this._cacheQuery, selector, [context.scope, context.pierceShadow, context.originalScope], () => { + if (Array.isArray(selector)) + return this._queryEngine(isEngine, context, selector); + if (this._hasScopeClause(selector)) + context = this._expandContextForScopeMatching(context); + const previousScoreMap = this._scoreMap; + this._scoreMap = /* @__PURE__ */ new Map(); + let elements = this._querySimple(context, selector.simples[selector.simples.length - 1].selector); + elements = elements.filter((element) => this._matchesParents(element, selector, selector.simples.length - 2, context)); + if (this._scoreMap.size) { + elements.sort((a, b) => { + const aScore = this._scoreMap.get(a); + const bScore = this._scoreMap.get(b); + if (aScore === bScore) + return 0; + if (aScore === void 0) + return 1; + if (bScore === void 0) + return -1; + return aScore - bScore; + }); + } + this._scoreMap = previousScoreMap; + return elements; + }); + } finally { + this.end(); + } + } + _markScore(element, score) { + if (this._scoreMap) + this._scoreMap.set(element, score); + } + _hasScopeClause(selector) { + return selector.simples.some((simple) => simple.selector.functions.some((f) => f.name === "scope")); + } + _expandContextForScopeMatching(context) { + if (context.scope.nodeType !== 1) + return context; + const scope = parentElementOrShadowHost(context.scope); + if (!scope) + return context; + return { ...context, scope, originalScope: context.originalScope || context.scope }; + } + _matchesSimple(element, simple, context) { + return this._cached(this._cacheMatchesSimple, element, [simple, context.scope, context.pierceShadow, context.originalScope], () => { + if (element === context.scope) + return false; + if (simple.css && !this._matchesCSS(element, simple.css)) + return false; + for (const func of simple.functions) { + if (!this._matchesEngine(this._getEngine(func.name), element, func.args, context)) + return false; + } + return true; + }); + } + _querySimple(context, simple) { + if (!simple.functions.length) + return this._queryCSS(context, simple.css || "*"); + return this._cached(this._cacheQuerySimple, simple, [context.scope, context.pierceShadow, context.originalScope], () => { + let css = simple.css; + const funcs = simple.functions; + if (css === "*" && funcs.length) + css = void 0; + let elements; + let firstIndex = -1; + if (css !== void 0) { + elements = this._queryCSS(context, css); + } else { + firstIndex = funcs.findIndex((func) => this._getEngine(func.name).query !== void 0); + if (firstIndex === -1) + firstIndex = 0; + elements = this._queryEngine(this._getEngine(funcs[firstIndex].name), context, funcs[firstIndex].args); + } + for (let i = 0; i < funcs.length; i++) { + if (i === firstIndex) + continue; + const engine = this._getEngine(funcs[i].name); + if (engine.matches !== void 0) + elements = elements.filter((e) => this._matchesEngine(engine, e, funcs[i].args, context)); + } + for (let i = 0; i < funcs.length; i++) { + if (i === firstIndex) + continue; + const engine = this._getEngine(funcs[i].name); + if (engine.matches === void 0) + elements = elements.filter((e) => this._matchesEngine(engine, e, funcs[i].args, context)); + } + return elements; + }); + } + _matchesParents(element, complex, index, context) { + if (index < 0) + return true; + return this._cached(this._cacheMatchesParents, element, [complex, index, context.scope, context.pierceShadow, context.originalScope], () => { + const { selector: simple, combinator } = complex.simples[index]; + if (combinator === ">") { + const parent = parentElementOrShadowHostInContext(element, context); + if (!parent || !this._matchesSimple(parent, simple, context)) + return false; + return this._matchesParents(parent, complex, index - 1, context); + } + if (combinator === "+") { + const previousSibling = previousSiblingInContext(element, context); + if (!previousSibling || !this._matchesSimple(previousSibling, simple, context)) + return false; + return this._matchesParents(previousSibling, complex, index - 1, context); + } + if (combinator === "") { + let parent = parentElementOrShadowHostInContext(element, context); + while (parent) { + if (this._matchesSimple(parent, simple, context)) { + if (this._matchesParents(parent, complex, index - 1, context)) + return true; + if (complex.simples[index - 1].combinator === "") + break; + } + parent = parentElementOrShadowHostInContext(parent, context); + } + return false; + } + if (combinator === "~") { + let previousSibling = previousSiblingInContext(element, context); + while (previousSibling) { + if (this._matchesSimple(previousSibling, simple, context)) { + if (this._matchesParents(previousSibling, complex, index - 1, context)) + return true; + if (complex.simples[index - 1].combinator === "~") + break; + } + previousSibling = previousSiblingInContext(previousSibling, context); + } + return false; + } + if (combinator === ">=") { + let parent = element; + while (parent) { + if (this._matchesSimple(parent, simple, context)) { + if (this._matchesParents(parent, complex, index - 1, context)) + return true; + if (complex.simples[index - 1].combinator === "") + break; + } + parent = parentElementOrShadowHostInContext(parent, context); + } + return false; + } + throw new Error(`Unsupported combinator "${combinator}"`); + }); + } + _matchesEngine(engine, element, args, context) { + if (engine.matches) + return this._callMatches(engine, element, args, context); + if (engine.query) + return this._callQuery(engine, args, context).includes(element); + throw new Error(`Selector engine should implement "matches" or "query"`); + } + _queryEngine(engine, context, args) { + if (engine.query) + return this._callQuery(engine, args, context); + if (engine.matches) + return this._queryCSS(context, "*").filter((element) => this._callMatches(engine, element, args, context)); + throw new Error(`Selector engine should implement "matches" or "query"`); + } + _callMatches(engine, element, args, context) { + return this._cached(this._cacheCallMatches, element, [engine, context.scope, context.pierceShadow, context.originalScope, ...args], () => { + return engine.matches(element, args, context, this); + }); + } + _callQuery(engine, args, context) { + return this._cached(this._cacheCallQuery, engine, [context.scope, context.pierceShadow, context.originalScope, ...args], () => { + return engine.query(context, args, this); + }); + } + _matchesCSS(element, css) { + return element.matches(css); + } + _queryCSS(context, css) { + return this._cached(this._cacheQueryCSS, css, [context.scope, context.pierceShadow, context.originalScope], () => { + let result = []; + function query(root) { + result = result.concat([...root.querySelectorAll(css)]); + if (!context.pierceShadow) + return; + if (root.shadowRoot) + query(root.shadowRoot); + for (const element of root.querySelectorAll("*")) { + if (element.shadowRoot) + query(element.shadowRoot); + } + } + query(context.scope); + return result; + }); + } + _getEngine(name) { + const engine = this._engines.get(name); + if (!engine) + throw new Error(`Unknown selector engine "${name}"`); + return engine; + } +}; +var isEngine = { + matches(element, args, context, evaluator) { + if (args.length === 0) + throw new Error(`"is" engine expects non-empty selector list`); + return args.some((selector) => evaluator.matches(element, selector, context)); + }, + query(context, args, evaluator) { + if (args.length === 0) + throw new Error(`"is" engine expects non-empty selector list`); + let elements = []; + for (const arg of args) + elements = elements.concat(evaluator.query(context, arg)); + return args.length === 1 ? elements : sortInDOMOrder(elements); + } +}; +var hasEngine = { + matches(element, args, context, evaluator) { + if (args.length === 0) + throw new Error(`"has" engine expects non-empty selector list`); + return evaluator.query({ ...context, scope: element }, args).length > 0; + } + // TODO: we can implement efficient "query" by matching "args" and returning + // all parents/descendants, just have to be careful with the ":scope" matching. +}; +var scopeEngine = { + matches(element, args, context, evaluator) { + if (args.length !== 0) + throw new Error(`"scope" engine expects no arguments`); + const actualScope = context.originalScope || context.scope; + if (actualScope.nodeType === 9) + return element === actualScope.documentElement; + return element === actualScope; + }, + query(context, args, evaluator) { + if (args.length !== 0) + throw new Error(`"scope" engine expects no arguments`); + const actualScope = context.originalScope || context.scope; + if (actualScope.nodeType === 9) { + const root = actualScope.documentElement; + return root ? [root] : []; + } + if (actualScope.nodeType === 1) + return [actualScope]; + return []; + } +}; +var notEngine = { + matches(element, args, context, evaluator) { + if (args.length === 0) + throw new Error(`"not" engine expects non-empty selector list`); + return !evaluator.matches(element, args, context); + } +}; +var lightEngine = { + query(context, args, evaluator) { + return evaluator.query({ ...context, pierceShadow: false }, args); + }, + matches(element, args, context, evaluator) { + return evaluator.matches(element, args, { ...context, pierceShadow: false }); + } +}; +var visibleEngine = { + matches(element, args, context, evaluator) { + if (args.length) + throw new Error(`"visible" engine expects no arguments`); + return isElementVisible(element); + } +}; +var textEngine = { + matches(element, args, context, evaluator) { + if (args.length !== 1 || typeof args[0] !== "string") + throw new Error(`"text" engine expects a single string`); + const text = normalizeWhiteSpace(args[0]).toLowerCase(); + const matcher = (elementText2) => elementText2.normalized.toLowerCase().includes(text); + return elementMatchesText(evaluator._cacheText, element, matcher) === "self"; + } +}; +var textIsEngine = { + matches(element, args, context, evaluator) { + if (args.length !== 1 || typeof args[0] !== "string") + throw new Error(`"text-is" engine expects a single string`); + const text = normalizeWhiteSpace(args[0]); + const matcher = (elementText2) => { + if (!text && !elementText2.immediate.length) + return true; + return elementText2.immediate.some((s) => normalizeWhiteSpace(s) === text); + }; + return elementMatchesText(evaluator._cacheText, element, matcher) !== "none"; + } +}; +var textMatchesEngine = { + matches(element, args, context, evaluator) { + if (args.length === 0 || typeof args[0] !== "string" || args.length > 2 || args.length === 2 && typeof args[1] !== "string") + throw new Error(`"text-matches" engine expects a regexp body and optional regexp flags`); + const re = new RegExp(args[0], args.length === 2 ? args[1] : void 0); + const matcher = (elementText2) => re.test(elementText2.full); + return elementMatchesText(evaluator._cacheText, element, matcher) === "self"; + } +}; +var hasTextEngine = { + matches(element, args, context, evaluator) { + if (args.length !== 1 || typeof args[0] !== "string") + throw new Error(`"has-text" engine expects a single string`); + if (shouldSkipForTextMatching(element)) + return false; + const text = normalizeWhiteSpace(args[0]).toLowerCase(); + const matcher = (elementText2) => elementText2.normalized.toLowerCase().includes(text); + return matcher(elementText(evaluator._cacheText, element)); + } +}; +function createLayoutEngine(name) { + return { + matches(element, args, context, evaluator) { + const maxDistance = args.length && typeof args[args.length - 1] === "number" ? args[args.length - 1] : void 0; + const queryArgs = maxDistance === void 0 ? args : args.slice(0, args.length - 1); + if (args.length < 1 + (maxDistance === void 0 ? 0 : 1)) + throw new Error(`"${name}" engine expects a selector list and optional maximum distance in pixels`); + const inner = evaluator.query(context, queryArgs); + const score = layoutSelectorScore(name, element, inner, maxDistance); + if (score === void 0) + return false; + evaluator._markScore(element, score); + return true; + } + }; +} +var nthMatchEngine = { + query(context, args, evaluator) { + let index = args[args.length - 1]; + if (args.length < 2) + throw new Error(`"nth-match" engine expects non-empty selector list and an index argument`); + if (typeof index !== "number" || index < 1) + throw new Error(`"nth-match" engine expects a one-based index as the last argument`); + const elements = isEngine.query(context, args.slice(0, args.length - 1), evaluator); + index--; + return index < elements.length ? [elements[index]] : []; + } +}; +function parentElementOrShadowHostInContext(element, context) { + if (element === context.scope) + return; + if (!context.pierceShadow) + return element.parentElement || void 0; + return parentElementOrShadowHost(element); +} +function previousSiblingInContext(element, context) { + if (element === context.scope) + return; + return element.previousElementSibling || void 0; +} +function sortInDOMOrder(elements) { + const elementToEntry = /* @__PURE__ */ new Map(); + const roots = []; + const result = []; + function append(element) { + let entry = elementToEntry.get(element); + if (entry) + return entry; + const parent = parentElementOrShadowHost(element); + if (parent) { + const parentEntry = append(parent); + parentEntry.children.push(element); + } else { + roots.push(element); + } + entry = { children: [], taken: false }; + elementToEntry.set(element, entry); + return entry; + } + for (const e of elements) + append(e).taken = true; + function visit(element) { + const entry = elementToEntry.get(element); + if (entry.taken) + result.push(element); + if (entry.children.length > 1) { + const set = new Set(entry.children); + entry.children = []; + let child = element.firstElementChild; + while (child && entry.children.length < set.size) { + if (set.has(child)) + entry.children.push(child); + child = child.nextElementSibling; + } + child = element.shadowRoot ? element.shadowRoot.firstElementChild : null; + while (child && entry.children.length < set.size) { + if (set.has(child)) + entry.children.push(child); + child = child.nextElementSibling; + } + } + entry.children.forEach(visit); + } + roots.forEach(visit); + return result; +} + +// packages/injected/src/selectorGenerator.ts +var kTextScoreRange = 10; +var kExactPenalty = kTextScoreRange / 2; +var kTestIdScore = 1; +var kOtherTestIdScore = 2; +var kIframeByAttributeScore = 10; +var kBeginPenalizedScore = 50; +var kRoleWithNameScore = 100; +var kPlaceholderScore = 120; +var kLabelScore = 140; +var kAltTextScore = 160; +var kTextScore = 180; +var kTitleScore = 200; +var kTextScoreRegex = 250; +var kPlaceholderScoreExact = kPlaceholderScore + kExactPenalty; +var kLabelScoreExact = kLabelScore + kExactPenalty; +var kRoleWithNameScoreExact = kRoleWithNameScore + kExactPenalty; +var kAltTextScoreExact = kAltTextScore + kExactPenalty; +var kTextScoreExact = kTextScore + kExactPenalty; +var kTitleScoreExact = kTitleScore + kExactPenalty; +var kEndPenalizedScore = 300; +var kCSSIdScore = 500; +var kRoleWithoutNameScore = 510; +var kCSSInputTypeNameScore = 520; +var kCSSTagNameScore = 530; +var kNthScore = 1e4; +var kCSSFallbackScore = 1e7; +var kScoreThresholdForTextExpect = 1e3; +function generateSelector(injectedScript, targetElement, options) { + var _a; + injectedScript._evaluator.begin(); + const cache = { allowText: /* @__PURE__ */ new Map(), disallowText: /* @__PURE__ */ new Map() }; + beginAriaCaches(); + beginDOMCaches(); + try { + let selectors = []; + if (options.forTextExpect) { + let targetTokens = cssFallback(injectedScript, targetElement.ownerDocument.documentElement, options); + for (let element = targetElement; element; element = parentElementOrShadowHost(element)) { + const tokens = generateSelectorFor(cache, injectedScript, element, { ...options, noText: true }); + if (!tokens) + continue; + const score = combineScores(tokens); + if (score <= kScoreThresholdForTextExpect) { + targetTokens = tokens; + break; + } + } + selectors = [joinTokens(targetTokens)]; + } else { + if (!targetElement.matches("input,textarea,select") && !targetElement.isContentEditable) { + const interactiveParent = closestCrossShadow(targetElement, "button,select,input,[role=button],[role=checkbox],[role=radio],a,[role=link]", options.root); + if (interactiveParent && isElementVisible(interactiveParent)) + targetElement = interactiveParent; + } + if (options.multiple) { + const withText = generateSelectorFor(cache, injectedScript, targetElement, options); + const withoutText = generateSelectorFor(cache, injectedScript, targetElement, { ...options, noText: true }); + let tokens = [withText, withoutText]; + cache.allowText.clear(); + cache.disallowText.clear(); + if (withText && hasCSSIdToken(withText)) + tokens.push(generateSelectorFor(cache, injectedScript, targetElement, { ...options, noCSSId: true })); + if (withoutText && hasCSSIdToken(withoutText)) + tokens.push(generateSelectorFor(cache, injectedScript, targetElement, { ...options, noText: true, noCSSId: true })); + tokens = tokens.filter(Boolean); + if (!tokens.length) { + const css = cssFallback(injectedScript, targetElement, options); + tokens.push(css); + if (hasCSSIdToken(css)) + tokens.push(cssFallback(injectedScript, targetElement, { ...options, noCSSId: true })); + } + selectors = [...new Set(tokens.map((t) => joinTokens(t)))]; + } else { + const targetTokens = generateSelectorFor(cache, injectedScript, targetElement, options) || cssFallback(injectedScript, targetElement, options); + selectors = [joinTokens(targetTokens)]; + } + } + const selector = selectors[0]; + const parsedSelector = injectedScript.parseSelector(selector); + return { + selector, + selectors, + elements: injectedScript.querySelectorAll(parsedSelector, (_a = options.root) != null ? _a : targetElement.ownerDocument) + }; + } finally { + endDOMCaches(); + endAriaCaches(); + injectedScript._evaluator.end(); + } +} +function generateSelectorFor(cache, injectedScript, targetElement, options) { + var _a; + if (options.root && !isInsideScope(options.root, targetElement)) + throw new Error(`Target element must belong to the root's subtree`); + if (targetElement === options.root) + return [{ engine: "css", selector: ":scope", score: 1 }]; + if (targetElement.ownerDocument.documentElement === targetElement) + return [{ engine: "css", selector: "html", score: 1 }]; + let result = null; + const updateResult = (candidate) => { + if (!result || combineScores(candidate) < combineScores(result)) + result = candidate; + }; + const candidates = []; + if (!options.noText) { + for (const candidate of buildTextCandidates(injectedScript, targetElement, !options.isRecursive)) + candidates.push({ candidate, isTextCandidate: true }); + } + for (const token of buildNoTextCandidates(injectedScript, targetElement, options)) { + if (options.omitInternalEngines && token.engine.startsWith("internal:")) + continue; + candidates.push({ candidate: [token], isTextCandidate: false }); + } + candidates.sort((a, b) => combineScores(a.candidate) - combineScores(b.candidate)); + for (const { candidate, isTextCandidate } of candidates) { + const elements = injectedScript.querySelectorAll(injectedScript.parseSelector(joinTokens(candidate)), (_a = options.root) != null ? _a : targetElement.ownerDocument); + if (!elements.includes(targetElement)) { + continue; + } + if (elements.length === 1) { + updateResult(candidate); + break; + } + const index = elements.indexOf(targetElement); + if (index > 5) { + continue; + } + updateResult([...candidate, { engine: "nth", selector: String(index), score: kNthScore }]); + if (options.isRecursive) { + continue; + } + for (let parent = parentElementOrShadowHost(targetElement); parent && parent !== options.root; parent = parentElementOrShadowHost(parent)) { + const filtered = elements.filter((e) => isInsideScope(parent, e) && e !== parent); + const newIndex = filtered.indexOf(targetElement); + if (filtered.length > 5 || newIndex === -1 || newIndex === index && filtered.length > 1) { + continue; + } + const inParent = filtered.length === 1 ? candidate : [...candidate, { engine: "nth", selector: String(newIndex), score: kNthScore }]; + const idealSelectorForParent = { engine: "", selector: "", score: 1 }; + if (result && combineScores([idealSelectorForParent, ...inParent]) >= combineScores(result)) { + continue; + } + const noText = !!options.noText || isTextCandidate; + const cacheMap = noText ? cache.disallowText : cache.allowText; + let parentTokens = cacheMap.get(parent); + if (parentTokens === void 0) { + parentTokens = generateSelectorFor(cache, injectedScript, parent, { ...options, isRecursive: true, noText }) || cssFallback(injectedScript, parent, options); + cacheMap.set(parent, parentTokens); + } + if (!parentTokens) + continue; + updateResult([...parentTokens, ...inParent]); + } + } + return result; +} +function buildNoTextCandidates(injectedScript, element, options) { + const candidates = []; + { + for (const attr of ["data-testid", "data-test-id", "data-test"]) { + if (attr !== options.testIdAttributeName && element.getAttribute(attr)) + candidates.push({ engine: "css", selector: `[${attr}=${quoteCSSAttributeValue(element.getAttribute(attr))}]`, score: kOtherTestIdScore }); + } + if (!options.noCSSId) { + const idAttr = element.getAttribute("id"); + if (idAttr && !isGuidLike(idAttr)) + candidates.push({ engine: "css", selector: makeSelectorForId(idAttr), score: kCSSIdScore }); + } + candidates.push({ engine: "css", selector: escapeNodeName(element), score: kCSSTagNameScore }); + } + if (element.nodeName === "IFRAME") { + for (const attribute of ["name", "title"]) { + if (element.getAttribute(attribute)) + candidates.push({ engine: "css", selector: `${escapeNodeName(element)}[${attribute}=${quoteCSSAttributeValue(element.getAttribute(attribute))}]`, score: kIframeByAttributeScore }); + } + if (element.getAttribute(options.testIdAttributeName)) + candidates.push({ engine: "css", selector: `[${options.testIdAttributeName}=${quoteCSSAttributeValue(element.getAttribute(options.testIdAttributeName))}]`, score: kTestIdScore }); + penalizeScoreForLength([candidates]); + return candidates; + } + if (element.getAttribute(options.testIdAttributeName)) + candidates.push({ engine: "internal:testid", selector: `[${options.testIdAttributeName}=${escapeForAttributeSelector(element.getAttribute(options.testIdAttributeName), true)}]`, score: kTestIdScore }); + if (element.nodeName === "INPUT" || element.nodeName === "TEXTAREA") { + const input = element; + if (input.placeholder) { + candidates.push({ engine: "internal:attr", selector: `[placeholder=${escapeForAttributeSelector(input.placeholder, true)}]`, score: kPlaceholderScoreExact }); + for (const alternative of suitableTextAlternatives(input.placeholder)) + candidates.push({ engine: "internal:attr", selector: `[placeholder=${escapeForAttributeSelector(alternative.text, false)}]`, score: kPlaceholderScore - alternative.scoreBonus }); + } + } + const labels = getElementLabels(injectedScript._evaluator._cacheText, element); + for (const label of labels) { + const labelText = label.normalized; + candidates.push({ engine: "internal:label", selector: escapeForTextSelector(labelText, true), score: kLabelScoreExact }); + for (const alternative of suitableTextAlternatives(labelText)) + candidates.push({ engine: "internal:label", selector: escapeForTextSelector(alternative.text, false), score: kLabelScore - alternative.scoreBonus }); + } + const ariaRole = getAriaRole(element); + if (ariaRole && !["none", "presentation"].includes(ariaRole)) + candidates.push({ engine: "internal:role", selector: ariaRole, score: kRoleWithoutNameScore }); + if (element.getAttribute("name") && ["BUTTON", "FORM", "FIELDSET", "FRAME", "IFRAME", "INPUT", "KEYGEN", "OBJECT", "OUTPUT", "SELECT", "TEXTAREA", "MAP", "META", "PARAM"].includes(element.nodeName)) + candidates.push({ engine: "css", selector: `${escapeNodeName(element)}[name=${quoteCSSAttributeValue(element.getAttribute("name"))}]`, score: kCSSInputTypeNameScore }); + if (["INPUT", "TEXTAREA"].includes(element.nodeName) && element.getAttribute("type") !== "hidden") { + if (element.getAttribute("type")) + candidates.push({ engine: "css", selector: `${escapeNodeName(element)}[type=${quoteCSSAttributeValue(element.getAttribute("type"))}]`, score: kCSSInputTypeNameScore }); + } + if (["INPUT", "TEXTAREA", "SELECT"].includes(element.nodeName) && element.getAttribute("type") !== "hidden") + candidates.push({ engine: "css", selector: escapeNodeName(element), score: kCSSInputTypeNameScore + 1 }); + penalizeScoreForLength([candidates]); + return candidates; +} +function buildTextCandidates(injectedScript, element, isTargetNode) { + if (element.nodeName === "SELECT") + return []; + const candidates = []; + const title = element.getAttribute("title"); + if (title) { + candidates.push([{ engine: "internal:attr", selector: `[title=${escapeForAttributeSelector(title, true)}]`, score: kTitleScoreExact }]); + for (const alternative of suitableTextAlternatives(title)) + candidates.push([{ engine: "internal:attr", selector: `[title=${escapeForAttributeSelector(alternative.text, false)}]`, score: kTitleScore - alternative.scoreBonus }]); + } + const alt = element.getAttribute("alt"); + if (alt && ["APPLET", "AREA", "IMG", "INPUT"].includes(element.nodeName)) { + candidates.push([{ engine: "internal:attr", selector: `[alt=${escapeForAttributeSelector(alt, true)}]`, score: kAltTextScoreExact }]); + for (const alternative of suitableTextAlternatives(alt)) + candidates.push([{ engine: "internal:attr", selector: `[alt=${escapeForAttributeSelector(alternative.text, false)}]`, score: kAltTextScore - alternative.scoreBonus }]); + } + const text = elementText(injectedScript._evaluator._cacheText, element).normalized; + const textAlternatives = text ? suitableTextAlternatives(text) : []; + if (text) { + if (isTargetNode) { + if (text.length <= 80) + candidates.push([{ engine: "internal:text", selector: escapeForTextSelector(text, true), score: kTextScoreExact }]); + for (const alternative of textAlternatives) + candidates.push([{ engine: "internal:text", selector: escapeForTextSelector(alternative.text, false), score: kTextScore - alternative.scoreBonus }]); + } + const cssToken = { engine: "css", selector: escapeNodeName(element), score: kCSSTagNameScore }; + for (const alternative of textAlternatives) + candidates.push([cssToken, { engine: "internal:has-text", selector: escapeForTextSelector(alternative.text, false), score: kTextScore - alternative.scoreBonus }]); + if (isTargetNode && text.length <= 80) { + const re = new RegExp("^" + escapeRegExp(text) + "$"); + candidates.push([cssToken, { engine: "internal:has-text", selector: escapeForTextSelector(re, false), score: kTextScoreRegex }]); + } + } + const ariaRole = getAriaRole(element); + if (ariaRole && !["none", "presentation"].includes(ariaRole)) { + const ariaName = getElementAccessibleName(element, false); + if (ariaName && !ariaName.match(/^\p{Co}+$/u)) { + const roleToken = { engine: "internal:role", selector: `${ariaRole}[name=${escapeForAttributeSelector(ariaName, true)}]`, score: kRoleWithNameScoreExact }; + candidates.push([roleToken]); + for (const alternative of suitableTextAlternatives(ariaName)) + candidates.push([{ engine: "internal:role", selector: `${ariaRole}[name=${escapeForAttributeSelector(alternative.text, false)}]`, score: kRoleWithNameScore - alternative.scoreBonus }]); + const ariaDescription = getElementAccessibleDescription(element, false); + if (ariaDescription) { + candidates.push([{ engine: "internal:role", selector: `${ariaRole}[name=${escapeForAttributeSelector(ariaName, true)}][description=${escapeForAttributeSelector(ariaDescription, true)}]`, score: kRoleWithNameScoreExact + 1 }]); + for (const alternative of suitableTextAlternatives(ariaName)) + candidates.push([{ engine: "internal:role", selector: `${ariaRole}[name=${escapeForAttributeSelector(alternative.text, false)}][description=${escapeForAttributeSelector(ariaDescription, true)}]`, score: kRoleWithNameScore - alternative.scoreBonus + 1 }]); + } + } else { + const roleToken = { engine: "internal:role", selector: `${ariaRole}`, score: kRoleWithoutNameScore }; + const ariaDescription = getElementAccessibleDescription(element, false); + if (ariaDescription) + candidates.push([{ engine: "internal:role", selector: `${ariaRole}[description=${escapeForAttributeSelector(ariaDescription, true)}]`, score: kRoleWithoutNameScore + 1 }]); + for (const alternative of textAlternatives) + candidates.push([roleToken, { engine: "internal:has-text", selector: escapeForTextSelector(alternative.text, false), score: kTextScore - alternative.scoreBonus }]); + if (isTargetNode && text.length <= 80) { + const re = new RegExp("^" + escapeRegExp(text) + "$"); + candidates.push([roleToken, { engine: "internal:has-text", selector: escapeForTextSelector(re, false), score: kTextScoreRegex }]); + } + } + } + penalizeScoreForLength(candidates); + return candidates; +} +function makeSelectorForId(id) { + return /^[a-zA-Z][a-zA-Z0-9\-\_]+$/.test(id) ? "#" + id : `[id=${quoteCSSAttributeValue(id)}]`; +} +function hasCSSIdToken(tokens) { + return tokens.some((token) => token.engine === "css" && (token.selector.startsWith("#") || token.selector.startsWith('[id="'))); +} +function cssFallback(injectedScript, targetElement, options) { + var _a; + const root = (_a = options.root) != null ? _a : targetElement.ownerDocument; + const tokens = []; + function uniqueCSSSelector(prefix) { + const path = tokens.slice(); + if (prefix) + path.unshift(prefix); + const selector = path.join(" > "); + const parsedSelector = injectedScript.parseSelector(selector); + const node = injectedScript.querySelector(parsedSelector, root, false); + return node === targetElement ? selector : void 0; + } + function makeStrict(selector) { + const token = { engine: "css", selector, score: kCSSFallbackScore }; + const parsedSelector = injectedScript.parseSelector(selector); + const elements = injectedScript.querySelectorAll(parsedSelector, root); + if (elements.length === 1) + return [token]; + const nth = { engine: "nth", selector: String(elements.indexOf(targetElement)), score: kNthScore }; + return [token, nth]; + } + for (let element = targetElement; element && element !== root; element = parentElementOrShadowHost(element)) { + let bestTokenForLevel = ""; + if (element.id && !options.noCSSId) { + const token = makeSelectorForId(element.id); + const selector = uniqueCSSSelector(token); + if (selector) + return makeStrict(selector); + bestTokenForLevel = token; + } + const parent = element.parentNode; + const classes = [...element.classList].map(escapeClassName); + for (let i = 0; i < classes.length; ++i) { + const token = "." + classes.slice(0, i + 1).join("."); + const selector = uniqueCSSSelector(token); + if (selector) + return makeStrict(selector); + if (!bestTokenForLevel && parent) { + const sameClassSiblings = parent.querySelectorAll(token); + if (sameClassSiblings.length === 1) + bestTokenForLevel = token; + } + } + if (parent) { + const siblings = [...parent.children]; + const nodeName = element.nodeName; + const sameTagSiblings = siblings.filter((sibling) => sibling.nodeName === nodeName); + const token = sameTagSiblings.indexOf(element) === 0 ? escapeNodeName(element) : `${escapeNodeName(element)}:nth-child(${1 + siblings.indexOf(element)})`; + const selector = uniqueCSSSelector(token); + if (selector) + return makeStrict(selector); + if (!bestTokenForLevel) + bestTokenForLevel = token; + } else if (!bestTokenForLevel) { + bestTokenForLevel = escapeNodeName(element); + } + tokens.unshift(bestTokenForLevel); + } + return makeStrict(uniqueCSSSelector()); +} +function penalizeScoreForLength(groups) { + for (const group of groups) { + for (const token of group) { + if (token.score > kBeginPenalizedScore && token.score < kEndPenalizedScore) + token.score += Math.min(kTextScoreRange, token.selector.length / 10 | 0); + } + } +} +function joinTokens(tokens) { + const parts = []; + let lastEngine = ""; + for (const { engine, selector } of tokens) { + if (parts.length && (lastEngine !== "css" || engine !== "css" || selector.startsWith(":nth-match("))) + parts.push(">>"); + lastEngine = engine; + if (engine === "css") + parts.push(selector); + else + parts.push(`${engine}=${selector}`); + } + return parts.join(" "); +} +function combineScores(tokens) { + let score = 0; + for (let i = 0; i < tokens.length; i++) + score += tokens[i].score * (tokens.length - i); + return score; +} +function isGuidLike(id) { + let lastCharacterType; + let transitionCount = 0; + for (let i = 0; i < id.length; ++i) { + const c = id[i]; + let characterType; + if (c === "-" || c === "_") + continue; + if (c >= "a" && c <= "z") + characterType = "lower"; + else if (c >= "A" && c <= "Z") + characterType = "upper"; + else if (c >= "0" && c <= "9") + characterType = "digit"; + else + characterType = "other"; + if (characterType === "lower" && lastCharacterType === "upper") { + lastCharacterType = characterType; + continue; + } + if (lastCharacterType && lastCharacterType !== characterType) + ++transitionCount; + lastCharacterType = characterType; + } + return transitionCount >= id.length / 4; +} +function trimWordBoundary(text, maxLength) { + if (text.length <= maxLength) + return text; + text = text.substring(0, maxLength); + const match = text.match(/^(.*)\b(.+?)$/); + if (!match) + return ""; + return match[1].trimEnd(); +} +function suitableTextAlternatives(text) { + let result = []; + { + const match = text.match(/^([\d.,]+)[^.,\w]/); + const leadingNumberLength = match ? match[1].length : 0; + if (leadingNumberLength) { + const alt = trimWordBoundary(text.substring(leadingNumberLength).trimStart(), 80); + result.push({ text: alt, scoreBonus: alt.length <= 30 ? 2 : 1 }); + } + } + { + const match = text.match(/[^.,\w]([\d.,]+)$/); + const trailingNumberLength = match ? match[1].length : 0; + if (trailingNumberLength) { + const alt = trimWordBoundary(text.substring(0, text.length - trailingNumberLength).trimEnd(), 80); + result.push({ text: alt, scoreBonus: alt.length <= 30 ? 2 : 1 }); + } + } + if (text.length <= 30) { + result.push({ text, scoreBonus: 0 }); + } else { + result.push({ text: trimWordBoundary(text, 80), scoreBonus: 0 }); + result.push({ text: trimWordBoundary(text, 30), scoreBonus: 1 }); + } + result = result.filter((r) => r.text); + if (!result.length) + result.push({ text: text.substring(0, 80), scoreBonus: 0 }); + return result; +} +function escapeNodeName(node) { + return node.nodeName.toLocaleLowerCase().replace(/[:\.]/g, (char) => "\\" + char); +} +function escapeClassName(className) { + let result = ""; + for (let i = 0; i < className.length; i++) + result += cssEscapeCharacter(className, i); + return result; +} +function cssEscapeCharacter(s, i) { + const c = s.charCodeAt(i); + if (c === 0) + return "\uFFFD"; + if (c >= 1 && c <= 31 || c >= 48 && c <= 57 && (i === 0 || i === 1 && s.charCodeAt(0) === 45)) + return "\\" + c.toString(16) + " "; + if (i === 0 && c === 45 && s.length === 1) + return "\\" + s.charAt(i); + if (c >= 128 || c === 45 || c === 95 || c >= 48 && c <= 57 || c >= 65 && c <= 90 || c >= 97 && c <= 122) + return s.charAt(i); + return "\\" + s.charAt(i); +} + +// packages/injected/src/xpathSelectorEngine.ts +var XPathEngine = { + queryAll(root, selector) { + if (selector.startsWith("/") && root.nodeType !== Node.DOCUMENT_NODE) + selector = "." + selector; + const result = []; + const document = root.ownerDocument || root; + if (!document) + return result; + const it = document.evaluate(selector, root, null, XPathResult.ORDERED_NODE_ITERATOR_TYPE); + for (let node = it.iterateNext(); node; node = it.iterateNext()) { + if (node.nodeType === Node.ELEMENT_NODE) + result.push(node); + } + return result; + } +}; + +// packages/isomorphic/locatorUtils.ts +function getByAttributeTextSelector(attrName, text, options) { + return `internal:attr=[${attrName}=${escapeForAttributeSelector(text, (options == null ? void 0 : options.exact) || false)}]`; +} +function getByTestIdSelector(testIdAttributeName, testId) { + return `internal:testid=[${testIdAttributeName}=${escapeForAttributeSelector(testId, true)}]`; +} +function getByLabelSelector(text, options) { + return "internal:label=" + escapeForTextSelector(text, !!(options == null ? void 0 : options.exact)); +} +function getByAltTextSelector(text, options) { + return getByAttributeTextSelector("alt", text, options); +} +function getByTitleSelector(text, options) { + return getByAttributeTextSelector("title", text, options); +} +function getByPlaceholderSelector(text, options) { + return getByAttributeTextSelector("placeholder", text, options); +} +function getByTextSelector(text, options) { + return "internal:text=" + escapeForTextSelector(text, !!(options == null ? void 0 : options.exact)); +} +function getByRoleSelector(role, options = {}) { + const props = []; + if (options.checked !== void 0) + props.push(["checked", String(options.checked)]); + if (options.disabled !== void 0) + props.push(["disabled", String(options.disabled)]); + if (options.selected !== void 0) + props.push(["selected", String(options.selected)]); + if (options.expanded !== void 0) + props.push(["expanded", String(options.expanded)]); + if (options.includeHidden !== void 0) + props.push(["include-hidden", String(options.includeHidden)]); + if (options.level !== void 0) + props.push(["level", String(options.level)]); + if (options.name !== void 0) + props.push(["name", escapeForAttributeSelector(options.name, !!options.exact)]); + if (options.description !== void 0) + props.push(["description", escapeForAttributeSelector(options.description, !!options.exact)]); + if (options.pressed !== void 0) + props.push(["pressed", String(options.pressed)]); + return `internal:role=${role}${props.map(([n, v]) => `[${n}=${v}]`).join("")}`; +} + +// packages/injected/src/consoleApi.ts +var selectorSymbol = Symbol("selector"); +selectorSymbol; +var _Locator = class _Locator { + constructor(injectedScript, selector, options) { + if (options == null ? void 0 : options.hasText) + selector += ` >> internal:has-text=${escapeForTextSelector(options.hasText, false)}`; + if (options == null ? void 0 : options.hasNotText) + selector += ` >> internal:has-not-text=${escapeForTextSelector(options.hasNotText, false)}`; + if (options == null ? void 0 : options.has) + selector += ` >> internal:has=` + JSON.stringify(options.has[selectorSymbol]); + if (options == null ? void 0 : options.hasNot) + selector += ` >> internal:has-not=` + JSON.stringify(options.hasNot[selectorSymbol]); + if ((options == null ? void 0 : options.visible) !== void 0) + selector += ` >> visible=${options.visible ? "true" : "false"}`; + this[selectorSymbol] = selector; + if (selector) { + const parsed = injectedScript.parseSelector(selector); + this.element = injectedScript.querySelector(parsed, injectedScript.document, false); + this.elements = injectedScript.querySelectorAll(parsed, injectedScript.document); + } + const selectorBase = selector; + const self = this; + self.locator = (selector2, options2) => { + return new _Locator(injectedScript, selectorBase ? selectorBase + " >> " + selector2 : selector2, options2); + }; + self.getByTestId = (testId) => self.locator(getByTestIdSelector(injectedScript.testIdAttributeNameForStrictErrorAndConsoleCodegen(), testId)); + self.getByAltText = (text, options2) => self.locator(getByAltTextSelector(text, options2)); + self.getByLabel = (text, options2) => self.locator(getByLabelSelector(text, options2)); + self.getByPlaceholder = (text, options2) => self.locator(getByPlaceholderSelector(text, options2)); + self.getByText = (text, options2) => self.locator(getByTextSelector(text, options2)); + self.getByTitle = (text, options2) => self.locator(getByTitleSelector(text, options2)); + self.getByRole = (role, options2 = {}) => self.locator(getByRoleSelector(role, options2)); + self.filter = (options2) => new _Locator(injectedScript, selector, options2); + self.first = () => self.locator("nth=0"); + self.last = () => self.locator("nth=-1"); + self.nth = (index) => self.locator(`nth=${index}`); + self.and = (locator) => new _Locator(injectedScript, selectorBase + ` >> internal:and=` + JSON.stringify(locator[selectorSymbol])); + self.or = (locator) => new _Locator(injectedScript, selectorBase + ` >> internal:or=` + JSON.stringify(locator[selectorSymbol])); + } +}; +var Locator = _Locator; +var ConsoleAPI = class { + constructor(injectedScript) { + this._injectedScript = injectedScript; + } + install() { + if (this._injectedScript.window.playwright) + return; + this._injectedScript.window.playwright = { + $: (selector, strict) => this._querySelector(selector, !!strict), + $$: (selector) => this._querySelectorAll(selector), + inspect: (selector) => this._inspect(selector), + selector: (element) => this._selector(element), + generateLocator: (element, language) => this._generateLocator(element, language), + ariaSnapshot: (element, options) => { + return this._injectedScript.ariaSnapshot(element || this._injectedScript.document.body, options || { mode: "default" }); + }, + resume: () => this._resume(), + ...new Locator(this._injectedScript, "") + }; + delete this._injectedScript.window.playwright.filter; + delete this._injectedScript.window.playwright.first; + delete this._injectedScript.window.playwright.last; + delete this._injectedScript.window.playwright.nth; + delete this._injectedScript.window.playwright.and; + delete this._injectedScript.window.playwright.or; + } + _querySelector(selector, strict) { + if (typeof selector !== "string") + throw new Error(`Usage: playwright.query('Playwright >> selector').`); + const parsed = this._injectedScript.parseSelector(selector); + return this._injectedScript.querySelector(parsed, this._injectedScript.document, strict); + } + _querySelectorAll(selector) { + if (typeof selector !== "string") + throw new Error(`Usage: playwright.$$('Playwright >> selector').`); + const parsed = this._injectedScript.parseSelector(selector); + return this._injectedScript.querySelectorAll(parsed, this._injectedScript.document); + } + _inspect(selector) { + if (typeof selector !== "string") + throw new Error(`Usage: playwright.inspect('Playwright >> selector').`); + this._injectedScript.window.inspect(this._querySelector(selector, false)); + } + _selector(element) { + if (!(element instanceof Element)) + throw new Error(`Usage: playwright.selector(element).`); + return this._injectedScript.generateSelectorSimple(element); + } + _generateLocator(element, language) { + if (!(element instanceof Element)) + throw new Error(`Usage: playwright.locator(element).`); + const selector = this._injectedScript.generateSelectorSimple(element); + return asLocator(language || "javascript", selector); + } + _resume() { + if (!this._injectedScript.window.__pw_resume) + return false; + this._injectedScript.window.__pw_resume().catch(() => { + }); + } +}; + +// packages/isomorphic/utilityScriptSerializers.ts +function isRegExp2(obj) { + try { + return obj instanceof RegExp || Object.prototype.toString.call(obj) === "[object RegExp]"; + } catch (error) { + return false; + } +} +function isDate(obj) { + try { + return obj instanceof Date || Object.prototype.toString.call(obj) === "[object Date]"; + } catch (error) { + return false; + } +} +function isURL(obj) { + try { + return obj instanceof URL || Object.prototype.toString.call(obj) === "[object URL]"; + } catch (error) { + return false; + } +} +function isError(obj) { + var _a; + try { + return obj instanceof Error || obj && ((_a = Object.getPrototypeOf(obj)) == null ? void 0 : _a.name) === "Error"; + } catch (error) { + return false; + } +} +function isTypedArray(obj, constructor) { + try { + return obj instanceof constructor || Object.prototype.toString.call(obj) === `[object ${constructor.name}]`; + } catch (error) { + return false; + } +} +function isArrayBuffer(obj) { + try { + return obj instanceof ArrayBuffer || Object.prototype.toString.call(obj) === "[object ArrayBuffer]"; + } catch (error) { + return false; + } +} +var typedArrayConstructors = { + i8: Int8Array, + ui8: Uint8Array, + ui8c: Uint8ClampedArray, + i16: Int16Array, + ui16: Uint16Array, + i32: Int32Array, + ui32: Uint32Array, + // TODO: add Float16Array once it's in baseline + f32: Float32Array, + f64: Float64Array, + bi64: BigInt64Array, + bui64: BigUint64Array +}; +function typedArrayToBase64(array) { + if ("toBase64" in array) + return array.toBase64(); + const binary = Array.from(new Uint8Array(array.buffer, array.byteOffset, array.byteLength)).map((b) => String.fromCharCode(b)).join(""); + return btoa(binary); +} +function base64ToTypedArray(base64, TypedArrayConstructor) { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) + bytes[i] = binary.charCodeAt(i); + return new TypedArrayConstructor(bytes.buffer); +} +function parseEvaluationResultValue(value, handles = [], refs = /* @__PURE__ */ new Map()) { + if (Object.is(value, void 0)) + return void 0; + if (typeof value === "object" && value) { + if ("ref" in value) + return refs.get(value.ref); + if ("v" in value) { + if (value.v === "undefined") + return void 0; + if (value.v === "null") + return null; + if (value.v === "NaN") + return NaN; + if (value.v === "Infinity") + return Infinity; + if (value.v === "-Infinity") + return -Infinity; + if (value.v === "-0") + return -0; + return void 0; + } + if ("d" in value) { + return new Date(value.d); + } + if ("u" in value) + return new URL(value.u); + if ("bi" in value) + return BigInt(value.bi); + if ("e" in value) { + const error = new Error(value.e.m); + error.name = value.e.n; + error.stack = value.e.s; + return error; + } + if ("r" in value) + return new RegExp(value.r.p, value.r.f); + if ("a" in value) { + const result = []; + refs.set(value.id, result); + for (const a of value.a) + result.push(parseEvaluationResultValue(a, handles, refs)); + return result; + } + if ("o" in value) { + const result = {}; + refs.set(value.id, result); + for (const { k, v } of value.o) { + if (k === "__proto__") + continue; + result[k] = parseEvaluationResultValue(v, handles, refs); + } + return result; + } + if ("h" in value) + return handles[value.h]; + if ("ta" in value) + return base64ToTypedArray(value.ta.b, typedArrayConstructors[value.ta.k]); + if ("ab" in value) + return base64ToTypedArray(value.ab.b, Uint8Array).buffer; + } + return value; +} +function serializeAsCallArgument(value, handleSerializer) { + return serialize(value, handleSerializer, { visited: /* @__PURE__ */ new Map(), lastId: 0 }); +} +function serialize(value, handleSerializer, visitorInfo) { + if (value && typeof value === "object") { + if (typeof globalThis.Window === "function" && value instanceof globalThis.Window) + return "ref: "; + if (typeof globalThis.Document === "function" && value instanceof globalThis.Document) + return "ref: "; + if (typeof globalThis.Node === "function" && value instanceof globalThis.Node) + return "ref: "; + } + return innerSerialize(value, handleSerializer, visitorInfo); +} +function innerSerialize(value, handleSerializer, visitorInfo) { + var _a; + const result = handleSerializer(value); + if ("fallThrough" in result) + value = result.fallThrough; + else + return result; + if (typeof value === "symbol") + return { v: "undefined" }; + if (Object.is(value, void 0)) + return { v: "undefined" }; + if (Object.is(value, null)) + return { v: "null" }; + if (Object.is(value, NaN)) + return { v: "NaN" }; + if (Object.is(value, Infinity)) + return { v: "Infinity" }; + if (Object.is(value, -Infinity)) + return { v: "-Infinity" }; + if (Object.is(value, -0)) + return { v: "-0" }; + if (typeof value === "boolean") + return value; + if (typeof value === "number") + return value; + if (typeof value === "string") + return value; + if (typeof value === "bigint") + return { bi: value.toString() }; + if (isError(value)) { + let stack; + if ((_a = value.stack) == null ? void 0 : _a.startsWith(value.name + ": " + value.message)) { + stack = value.stack; + } else { + stack = `${value.name}: ${value.message} +${value.stack}`; + } + return { e: { n: value.name, m: value.message, s: stack } }; + } + if (isDate(value)) + return { d: value.toJSON() }; + if (isURL(value)) + return { u: value.toJSON() }; + if (isRegExp2(value)) + return { r: { p: value.source, f: value.flags } }; + for (const [k, ctor] of Object.entries(typedArrayConstructors)) { + if (isTypedArray(value, ctor)) + return { ta: { b: typedArrayToBase64(value), k } }; + } + if (isArrayBuffer(value)) + return { ab: { b: typedArrayToBase64(new Uint8Array(value)) } }; + const id = visitorInfo.visited.get(value); + if (id) + return { ref: id }; + if (Array.isArray(value)) { + const a = []; + const id2 = ++visitorInfo.lastId; + visitorInfo.visited.set(value, id2); + for (let i = 0; i < value.length; ++i) + a.push(serialize(value[i], handleSerializer, visitorInfo)); + return { a, id: id2 }; + } + if (typeof value === "object") { + const o = []; + const id2 = ++visitorInfo.lastId; + visitorInfo.visited.set(value, id2); + for (const name of Object.keys(value)) { + let item; + try { + item = value[name]; + } catch (e) { + continue; + } + if (name === "toJSON" && typeof item === "function") + o.push({ k: name, v: { o: [], id: 0 } }); + else + o.push({ k: name, v: serialize(item, handleSerializer, visitorInfo) }); + } + let jsonWrapper; + try { + if (o.length === 0 && value.toJSON && typeof value.toJSON === "function") + jsonWrapper = { value: value.toJSON() }; + } catch (e) { + } + if (jsonWrapper) + return innerSerialize(jsonWrapper.value, handleSerializer, visitorInfo); + return { o, id: id2 }; + } +} + +// packages/injected/src/utilityScript.ts +var UtilityScript = class { + constructor(global, isUnderTest) { + var _a, _b, _c, _d, _e, _f, _g, _h; + this.global = global; + this.isUnderTest = isUnderTest; + if (global.__pwClock) { + this.builtins = global.__pwClock.builtins; + } else { + this.builtins = { + setTimeout: (_a = global.setTimeout) == null ? void 0 : _a.bind(global), + clearTimeout: (_b = global.clearTimeout) == null ? void 0 : _b.bind(global), + setInterval: (_c = global.setInterval) == null ? void 0 : _c.bind(global), + clearInterval: (_d = global.clearInterval) == null ? void 0 : _d.bind(global), + requestAnimationFrame: (_e = global.requestAnimationFrame) == null ? void 0 : _e.bind(global), + cancelAnimationFrame: (_f = global.cancelAnimationFrame) == null ? void 0 : _f.bind(global), + requestIdleCallback: (_g = global.requestIdleCallback) == null ? void 0 : _g.bind(global), + cancelIdleCallback: (_h = global.cancelIdleCallback) == null ? void 0 : _h.bind(global), + performance: global.performance, + Intl: global.Intl, + Date: global.Date, + AbortSignal: global.AbortSignal + }; + } + if (this.isUnderTest) + global.builtins = this.builtins; + } + evaluate(isFunction, returnByValue, expression, argCount, ...argsAndHandles) { + const args = argsAndHandles.slice(0, argCount); + const handles = argsAndHandles.slice(argCount); + const parameters = []; + for (let i = 0; i < args.length; i++) + parameters[i] = parseEvaluationResultValue(args[i], handles); + let result = this.global.eval(expression); + if (isFunction === true) { + result = result(...parameters); + } else if (isFunction === false) { + result = result; + } else { + if (typeof result === "function") + result = result(...parameters); + } + return returnByValue ? this._promiseAwareJsonValueNoThrow(result) : result; + } + jsonValue(returnByValue, value) { + if (value === void 0) + return void 0; + return serializeAsCallArgument(value, (value2) => ({ fallThrough: value2 })); + } + _promiseAwareJsonValueNoThrow(value) { + const safeJson = (value2) => { + try { + return this.jsonValue(true, value2); + } catch (e) { + return void 0; + } + }; + if (value && typeof value === "object" && typeof value.then === "function") { + return (async () => { + const promiseValue = await value; + return safeJson(promiseValue); + })(); + } + return safeJson(value); + } +}; + +// packages/injected/src/injectedScript.ts +var InjectedScript = class { + constructor(window, options) { + this._testIdAttributeNameForStrictErrorAndConsoleCodegen = "data-testid"; + this._lastAriaSnapshotForTrack = /* @__PURE__ */ new Map(); + // Recorder must use any external dependencies through InjectedScript. + // Otherwise it will end up with a copy of all modules it uses, and any + // module-level globals will be duplicated, which leads to subtle bugs. + this.utils = { + asLocator, + cacheNormalizedWhitespaces, + elementText, + getAriaRole, + getElementAccessibleDescription, + getElementAccessibleName, + isElementVisible, + isInsideScope, + normalizeWhiteSpace, + parseAriaSnapshot, + generateAriaTree, + findNewElement, + // Builtins protect injected code from clock emulation. + builtins: null + }; + this.window = window; + this.document = window.document; + this.isUnderTest = options.isUnderTest; + this.utils.builtins = new UtilityScript(window, options.isUnderTest).builtins; + this._sdkLanguage = options.sdkLanguage; + this._testIdAttributeNameForStrictErrorAndConsoleCodegen = options.testIdAttributeName; + this._evaluator = new SelectorEvaluatorImpl(); + this.consoleApi = new ConsoleAPI(this); + this.onGlobalListenersRemoved = /* @__PURE__ */ new Set(); + this._autoClosingTags = /* @__PURE__ */ new Set(["AREA", "BASE", "BR", "COL", "COMMAND", "EMBED", "HR", "IMG", "INPUT", "KEYGEN", "LINK", "MENUITEM", "META", "PARAM", "SOURCE", "TRACK", "WBR"]); + this._booleanAttributes = /* @__PURE__ */ new Set(["checked", "selected", "disabled", "readonly", "multiple"]); + this._eventTypes = /* @__PURE__ */ new Map([ + ["auxclick", "mouse"], + ["click", "mouse"], + ["dblclick", "mouse"], + ["mousedown", "mouse"], + ["mouseeenter", "mouse"], + ["mouseleave", "mouse"], + ["mousemove", "mouse"], + ["mouseout", "mouse"], + ["mouseover", "mouse"], + ["mouseup", "mouse"], + ["mouseleave", "mouse"], + ["mousewheel", "mouse"], + ["keydown", "keyboard"], + ["keyup", "keyboard"], + ["keypress", "keyboard"], + ["textInput", "keyboard"], + ["touchstart", "touch"], + ["touchmove", "touch"], + ["touchend", "touch"], + ["touchcancel", "touch"], + ["pointerover", "pointer"], + ["pointerout", "pointer"], + ["pointerenter", "pointer"], + ["pointerleave", "pointer"], + ["pointerdown", "pointer"], + ["pointerup", "pointer"], + ["pointermove", "pointer"], + ["pointercancel", "pointer"], + ["gotpointercapture", "pointer"], + ["lostpointercapture", "pointer"], + ["focus", "focus"], + ["blur", "focus"], + ["drag", "drag"], + ["dragstart", "drag"], + ["dragend", "drag"], + ["dragover", "drag"], + ["dragenter", "drag"], + ["dragleave", "drag"], + ["dragexit", "drag"], + ["drop", "drag"], + ["wheel", "wheel"], + ["deviceorientation", "deviceorientation"], + ["deviceorientationabsolute", "deviceorientation"], + ["devicemotion", "devicemotion"] + ]); + this._hoverHitTargetInterceptorEvents = /* @__PURE__ */ new Set(["mousemove"]); + this._tapHitTargetInterceptorEvents = /* @__PURE__ */ new Set(["pointerdown", "pointerup", "touchstart", "touchend", "touchcancel"]); + this._mouseHitTargetInterceptorEvents = /* @__PURE__ */ new Set(["mousedown", "mouseup", "pointerdown", "pointerup", "click", "auxclick", "dblclick", "contextmenu"]); + this._allHitTargetInterceptorEvents = /* @__PURE__ */ new Set([...this._hoverHitTargetInterceptorEvents, ...this._tapHitTargetInterceptorEvents, ...this._mouseHitTargetInterceptorEvents]); + this._engines = /* @__PURE__ */ new Map(); + this._engines.set("xpath", XPathEngine); + this._engines.set("xpath:light", XPathEngine); + this._engines.set("role", createRoleEngine(false)); + this._engines.set("text", this._createTextEngine(true, false)); + this._engines.set("text:light", this._createTextEngine(false, false)); + this._engines.set("id", this._createAttributeEngine("id", true)); + this._engines.set("id:light", this._createAttributeEngine("id", false)); + this._engines.set("data-testid", this._createAttributeEngine("data-testid", true)); + this._engines.set("data-testid:light", this._createAttributeEngine("data-testid", false)); + this._engines.set("data-test-id", this._createAttributeEngine("data-test-id", true)); + this._engines.set("data-test-id:light", this._createAttributeEngine("data-test-id", false)); + this._engines.set("data-test", this._createAttributeEngine("data-test", true)); + this._engines.set("data-test:light", this._createAttributeEngine("data-test", false)); + this._engines.set("css", this._createCSSEngine()); + this._engines.set("nth", { queryAll: () => [] }); + this._engines.set("visible", this._createVisibleEngine()); + this._engines.set("internal:control", this._createControlEngine()); + this._engines.set("internal:has", this._createHasEngine()); + this._engines.set("internal:has-not", this._createHasNotEngine()); + this._engines.set("internal:and", { queryAll: () => [] }); + this._engines.set("internal:or", { queryAll: () => [] }); + this._engines.set("internal:chain", this._createInternalChainEngine()); + this._engines.set("internal:label", this._createInternalLabelEngine()); + this._engines.set("internal:text", this._createTextEngine(true, true)); + this._engines.set("internal:has-text", this._createInternalHasTextEngine()); + this._engines.set("internal:has-not-text", this._createInternalHasNotTextEngine()); + this._engines.set("internal:attr", this._createNamedAttributeEngine()); + this._engines.set("internal:testid", this._createNamedAttributeEngine()); + this._engines.set("internal:role", createRoleEngine(true)); + this._engines.set("internal:describe", this._createDescribeEngine()); + this._engines.set("aria-ref", this._createAriaRefEngine()); + for (const { name, source } of options.customEngines) + this._engines.set(name, this.eval(source)); + this._stableRafCount = options.stableRafCount; + this._browserName = options.browserName; + this._shouldPrependErrorPrefix = !!options.shouldPrependErrorPrefix; + this._isUtilityWorld = !!options.isUtilityWorld; + setGlobalOptions({ browserNameForWorkarounds: options.browserName }); + this._setupGlobalListenersRemovalDetection(); + this._setupHitTargetInterceptors(); + if (this.isUnderTest) + this.window.__injectedScript = this; + } + eval(expression) { + return this.window.eval(expression); + } + testIdAttributeNameForStrictErrorAndConsoleCodegen() { + return this._testIdAttributeNameForStrictErrorAndConsoleCodegen; + } + parseSelector(selector) { + const result = parseSelector(selector); + visitAllSelectorParts(result, (part) => { + if (!this._engines.has(part.name)) + throw this.createStacklessError(`Unknown engine "${part.name}" while parsing selector ${selector}`); + }); + return result; + } + generateSelector(targetElement, options) { + return generateSelector(this, targetElement, options); + } + generateSelectorSimple(targetElement, options) { + return generateSelector(this, targetElement, { ...options, testIdAttributeName: this._testIdAttributeNameForStrictErrorAndConsoleCodegen }).selector; + } + querySelector(selector, root, strict) { + const result = this.querySelectorAll(selector, root); + if (strict && result.length > 1) + throw this.strictModeViolationError(selector, result); + this.checkDeprecatedSelectorUsage(selector, result); + return result[0]; + } + _queryNth(elements, part) { + const list = [...elements]; + let nth = +part.body; + if (nth === -1) + nth = list.length - 1; + return new Set(list.slice(nth, nth + 1)); + } + _queryLayoutSelector(elements, part, originalRoot) { + const name = part.name; + const body = part.body; + const result = []; + const inner = this.querySelectorAll(body.parsed, originalRoot); + for (const element of elements) { + const score = layoutSelectorScore(name, element, inner, body.distance); + if (score !== void 0) + result.push({ element, score }); + } + result.sort((a, b) => a.score - b.score); + return new Set(result.map((r) => r.element)); + } + ariaSnapshot(node, options) { + return this.incrementalAriaSnapshot(node, options).full; + } + incrementalAriaSnapshot(node, options) { + if (node.nodeType !== Node.ELEMENT_NODE) + throw this.createStacklessError("Can only capture aria snapshot of Element nodes."); + const ariaSnapshot = generateAriaTree(node, options); + const rendered = renderAriaTree(ariaSnapshot, options); + let incremental; + if (options.track) { + const previousSnapshot = this._lastAriaSnapshotForTrack.get(options.track); + if (previousSnapshot) + incremental = renderAriaTree(ariaSnapshot, options, previousSnapshot).text; + this._lastAriaSnapshotForTrack.set(options.track, ariaSnapshot); + } + this._lastAriaSnapshotForQuery = ariaSnapshot; + return { full: rendered.text, incremental, iframeRefs: ariaSnapshot.iframeRefs, iframeDepths: rendered.iframeDepths }; + } + ariaSnapshotForRecorder() { + const tree = generateAriaTree(this.document.body, { mode: "ai" }); + const { text: ariaSnapshot } = renderAriaTree(tree, { mode: "ai" }); + return { ariaSnapshot, refs: tree.refs }; + } + getAllElementsMatchingExpectAriaTemplate(document, template) { + return getAllElementsMatchingExpectAriaTemplate(document.documentElement, template); + } + querySelectorAll(selector, root) { + if (selector.capture !== void 0) { + if (selector.parts.some((part) => part.name === "nth")) + throw this.createStacklessError(`Can't query n-th element in a request with the capture.`); + const withHas = { parts: selector.parts.slice(0, selector.capture + 1) }; + if (selector.capture < selector.parts.length - 1) { + const parsed = { parts: selector.parts.slice(selector.capture + 1) }; + const has = { name: "internal:has", body: { parsed }, source: stringifySelector(parsed) }; + withHas.parts.push(has); + } + return this.querySelectorAll(withHas, root); + } + if (!root["querySelectorAll"]) + throw this.createStacklessError("Node is not queryable."); + if (selector.capture !== void 0) { + throw this.createStacklessError("Internal error: there should not be a capture in the selector."); + } + if (root.nodeType === 11 && selector.parts.length === 1 && selector.parts[0].name === "css" && selector.parts[0].source === ":scope") + return [root]; + this._evaluator.begin(); + try { + let roots = /* @__PURE__ */ new Set([root]); + for (const part of selector.parts) { + if (part.name === "nth") { + roots = this._queryNth(roots, part); + } else if (part.name === "internal:and") { + const andElements = this.querySelectorAll(part.body.parsed, root); + roots = new Set(andElements.filter((e) => roots.has(e))); + } else if (part.name === "internal:or") { + const orElements = this.querySelectorAll(part.body.parsed, root); + roots = new Set(sortInDOMOrder(/* @__PURE__ */ new Set([...roots, ...orElements]))); + } else if (kLayoutSelectorNames.includes(part.name)) { + roots = this._queryLayoutSelector(roots, part, root); + } else { + const next = /* @__PURE__ */ new Set(); + for (const root2 of roots) { + const all = this._queryEngineAll(part, root2); + for (const one of all) + next.add(one); + } + roots = next; + } + } + return [...roots]; + } finally { + this._evaluator.end(); + } + } + _queryEngineAll(part, root) { + const result = this._engines.get(part.name).queryAll(root, part.body); + for (const element of result) { + if (!("nodeName" in element)) + throw this.createStacklessError(`Expected a Node but got ${Object.prototype.toString.call(element)}`); + } + return result; + } + _createAttributeEngine(attribute, shadow) { + const toCSS = (selector) => { + const css = `[${attribute}=${JSON.stringify(selector)}]`; + return [{ simples: [{ selector: { css, functions: [] }, combinator: "" }] }]; + }; + return { + queryAll: (root, selector) => { + return this._evaluator.query({ scope: root, pierceShadow: shadow }, toCSS(selector)); + } + }; + } + _createCSSEngine() { + return { + queryAll: (root, body) => { + return this._evaluator.query({ scope: root, pierceShadow: true }, body); + } + }; + } + _createTextEngine(shadow, internal) { + const queryAll = (root, selector) => { + const { matcher, kind } = createTextMatcher(selector, internal); + const result = []; + let lastDidNotMatchSelf = null; + const appendElement = (element) => { + if (kind === "lax" && lastDidNotMatchSelf && lastDidNotMatchSelf.contains(element)) + return false; + const matches = elementMatchesText(this._evaluator._cacheText, element, matcher); + if (matches === "none") + lastDidNotMatchSelf = element; + if (matches === "self" || matches === "selfAndChildren" && kind === "strict" && !internal) + result.push(element); + }; + if (root.nodeType === Node.ELEMENT_NODE) + appendElement(root); + const elements = this._evaluator._queryCSS({ scope: root, pierceShadow: shadow }, "*"); + for (const element of elements) + appendElement(element); + return result; + }; + return { queryAll }; + } + _createInternalHasTextEngine() { + return { + queryAll: (root, selector) => { + if (root.nodeType !== 1) + return []; + const element = root; + const text = elementText(this._evaluator._cacheText, element); + const { matcher } = createTextMatcher(selector, true); + return matcher(text) ? [element] : []; + } + }; + } + _createInternalHasNotTextEngine() { + return { + queryAll: (root, selector) => { + if (root.nodeType !== 1) + return []; + const element = root; + const text = elementText(this._evaluator._cacheText, element); + const { matcher } = createTextMatcher(selector, true); + return matcher(text) ? [] : [element]; + } + }; + } + _createInternalLabelEngine() { + return { + queryAll: (root, selector) => { + const { matcher } = createTextMatcher(selector, true); + const allElements = this._evaluator._queryCSS({ scope: root, pierceShadow: true }, "*"); + return allElements.filter((element) => { + return getElementLabels(this._evaluator._cacheText, element).some((label) => matcher(label)); + }); + } + }; + } + _createNamedAttributeEngine() { + const queryAll = (root, selector) => { + const parsed = parseAttributeSelector(selector, true); + if (parsed.name || parsed.attributes.length !== 1) + throw new Error("Malformed attribute selector: " + selector); + const { name, value, caseSensitive } = parsed.attributes[0]; + const lowerCaseValue = caseSensitive ? null : value.toLowerCase(); + let matcher; + if (value instanceof RegExp) + matcher = (s) => !!s.match(value); + else if (caseSensitive) + matcher = (s) => s === value; + else + matcher = (s) => s.toLowerCase().includes(lowerCaseValue); + const elements = this._evaluator._queryCSS({ scope: root, pierceShadow: true }, `[${name}]`); + return elements.filter((e) => matcher(e.getAttribute(name))); + }; + return { queryAll }; + } + _createDescribeEngine() { + const queryAll = (root) => { + if (root.nodeType !== 1) + return []; + return [root]; + }; + return { queryAll }; + } + _createControlEngine() { + return { + queryAll(root, body) { + if (body === "enter-frame") + return []; + if (body === "return-empty") + return []; + if (body === "component") { + if (root.nodeType !== 1) + return []; + return [root.childElementCount === 1 ? root.firstElementChild : root]; + } + throw new Error(`Internal error, unknown internal:control selector ${body}`); + } + }; + } + _createHasEngine() { + const queryAll = (root, body) => { + if (root.nodeType !== 1) + return []; + const has = !!this.querySelector(body.parsed, root, false); + return has ? [root] : []; + }; + return { queryAll }; + } + _createHasNotEngine() { + const queryAll = (root, body) => { + if (root.nodeType !== 1) + return []; + const has = !!this.querySelector(body.parsed, root, false); + return has ? [] : [root]; + }; + return { queryAll }; + } + _createVisibleEngine() { + const queryAll = (root, body) => { + if (root.nodeType !== 1) + return []; + const visible = body === "true"; + return isElementVisible(root) === visible ? [root] : []; + }; + return { queryAll }; + } + _createInternalChainEngine() { + const queryAll = (root, body) => { + return this.querySelectorAll(body.parsed, root); + }; + return { queryAll }; + } + extend(source, params) { + const constrFunction = this.window.eval(` + (() => { + const module = {}; + ${source} + return module.exports.default(); + })()`); + return new constrFunction(this, params); + } + async viewportRatio(element) { + return await new Promise((resolve) => { + const observer = new IntersectionObserver((entries) => { + resolve(entries[0].intersectionRatio); + observer.disconnect(); + }); + observer.observe(element); + this.utils.builtins.requestAnimationFrame(() => { + }); + }); + } + getElementBorderWidth(node) { + if (node.nodeType !== Node.ELEMENT_NODE || !node.ownerDocument || !node.ownerDocument.defaultView) + return { left: 0, top: 0 }; + const style = node.ownerDocument.defaultView.getComputedStyle(node); + return { left: parseInt(style.borderLeftWidth || "", 10), top: parseInt(style.borderTopWidth || "", 10) }; + } + describeIFrameStyle(iframe) { + if (!iframe.ownerDocument || !iframe.ownerDocument.defaultView) + return "error:notconnected"; + const defaultView = iframe.ownerDocument.defaultView; + for (let e = iframe; e; e = parentElementOrShadowHost(e)) { + if (defaultView.getComputedStyle(e).transform !== "none") + return "transformed"; + } + const iframeStyle = defaultView.getComputedStyle(iframe); + return { + left: parseInt(iframeStyle.borderLeftWidth || "", 10) + parseInt(iframeStyle.paddingLeft || "", 10), + top: parseInt(iframeStyle.borderTopWidth || "", 10) + parseInt(iframeStyle.paddingTop || "", 10) + }; + } + retarget(node, behavior) { + let element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement; + if (!element) + return null; + if (behavior === "none") + return element; + if (!element.matches("input, textarea, select") && !element.isContentEditable) { + if (behavior === "button-link") + element = element.closest("button, [role=button], a, [role=link]") || element; + else + element = element.closest("button, [role=button], [role=checkbox], [role=radio]") || element; + } + if (behavior === "follow-label") { + if (!element.matches("a, input, textarea, button, select, [role=link], [role=button], [role=checkbox], [role=radio]") && !element.isContentEditable) { + const enclosingLabel = element.closest("label"); + if (enclosingLabel && enclosingLabel.control) + element = enclosingLabel.control; + } + } + return element; + } + async checkElementStates(node, states) { + if (states.includes("stable")) { + const stableResult = await this._checkElementIsStable(node); + if (stableResult === false) + return { missingState: "stable" }; + if (stableResult === "error:notconnected") + return "error:notconnected"; + } + for (const state of states) { + if (state !== "stable") { + const result = this.elementState(node, state); + if (result.received === "error:notconnected") + return "error:notconnected"; + if (!result.matches) + return { missingState: state }; + } + } + } + async _checkElementIsStable(node) { + const continuePolling = Symbol("continuePolling"); + let lastRect; + let stableRafCounter = 0; + let lastTime = 0; + const check = () => { + const element = this.retarget(node, "no-follow-label"); + if (!element) + return "error:notconnected"; + const time = this.utils.builtins.performance.now(); + if (this._stableRafCount > 1 && time - lastTime < 15) + return continuePolling; + lastTime = time; + const clientRect = element.getBoundingClientRect(); + const rect = { x: clientRect.top, y: clientRect.left, width: clientRect.width, height: clientRect.height }; + if (lastRect) { + const samePosition = rect.x === lastRect.x && rect.y === lastRect.y && rect.width === lastRect.width && rect.height === lastRect.height; + if (!samePosition) + return false; + if (++stableRafCounter >= this._stableRafCount) + return true; + } + lastRect = rect; + return continuePolling; + }; + let fulfill; + let reject; + const result = new Promise((f, r) => { + fulfill = f; + reject = r; + }); + const raf = () => { + try { + const success = check(); + if (success !== continuePolling) + fulfill(success); + else + this.utils.builtins.requestAnimationFrame(raf); + } catch (e) { + reject(e); + } + }; + this.utils.builtins.requestAnimationFrame(raf); + return result; + } + _createAriaRefEngine() { + const queryAll = (root, selector) => { + var _a, _b; + const result = (_b = (_a = this._lastAriaSnapshotForQuery) == null ? void 0 : _a.elements) == null ? void 0 : _b.get(selector); + return result && result.isConnected ? [result] : []; + }; + return { queryAll }; + } + elementState(node, state) { + const element = this.retarget(node, ["visible", "hidden"].includes(state) ? "none" : "follow-label"); + if (!element || !element.isConnected) { + if (state === "hidden") + return { matches: true, received: "hidden" }; + return { matches: false, received: "error:notconnected" }; + } + if (state === "visible" || state === "hidden") { + const visible = isElementVisible(element); + return { + matches: state === "visible" ? visible : !visible, + received: visible ? "visible" : "hidden" + }; + } + if (state === "disabled" || state === "enabled") { + const disabled = getAriaDisabled(element); + return { + matches: state === "disabled" ? disabled : !disabled, + received: disabled ? "disabled" : "enabled" + }; + } + if (state === "editable") { + const disabled = getAriaDisabled(element); + const readonly = getReadonly(element); + if (readonly === "error") + throw this.createStacklessError("Element is not an Browser fixture {name} + +

Browser fixture {name}

A local page for navigation and state checks.

+
+

+Open page two in a new tab

+
+Download file
+Download slowly
+Download slowly for quit check
+Fail download
+Download through redirect
+
+
+ + +
""" + + +class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + if self.path == "/download-redirect": + self.send_response(302) + self.send_header("Location", "/download") + self.end_headers() + return + if self.path.startswith("/download"): + content = ("native browser download\n" + self.headers.get("Cookie", "")).encode() + slow = self.path in ("/download-slow", "/download-long") + failed = self.path == "/download-fail" + self.send_response(200) + self.send_header("Content-Type", "application/octet-stream") + self.send_header("Content-Disposition", 'attachment; filename="fixture.txt"') + self.send_header("Content-Length", str(64 * 65536 if slow or failed else len(content))) + self.end_headers() + try: + if slow: + for _ in range(64): + self.wfile.write(b"x" * 65536) + self.wfile.flush() + time.sleep(0.2 if self.path == "/download-long" else 0.03) + else: + self.wfile.write(content) + if failed: + self.close_connection = True + except (BrokenPipeError, ConnectionResetError): + pass + return + if self.path == "/redirect": + self.send_response(302) + self.send_header("Location", "/two") + self.end_headers() + return + content = PAGE.format(name="Two" if self.path.startswith("/two") else "One").encode() + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(content))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(content) + + +if __name__ == "__main__": + server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler) + print(json.dumps({"url": f"http://localhost:{server.server_port}/one"}), flush=True) + server.serve_forever() diff --git a/apps/macos/Tests/T3CoreTests/AttachmentPreviewTests.swift b/apps/macos/Tests/T3CoreTests/AttachmentPreviewTests.swift new file mode 100644 index 0000000000000..260e8b4bad521 --- /dev/null +++ b/apps/macos/Tests/T3CoreTests/AttachmentPreviewTests.swift @@ -0,0 +1,162 @@ +import Foundation +import ImageIO +import Testing +import UniformTypeIdentifiers + +@testable import T3Core + +private actor PreviewLoads { + private var starts = 0 + private var active = 0 + private var maximum = 0 + private var released = false + private var waiting: [CheckedContinuation] = [] + private var observer: CheckedContinuation? + func load(_ url: URL, bytes: Data) async throws { + starts += 1 + active += 1 + maximum = max(maximum, active) + if starts >= 2 { + observer?.resume() + observer = nil + } + if !released { await withCheckedContinuation { waiting.append($0) } } + try bytes.write(to: url) + active -= 1 + } + func waitForTwo() async { + if starts < 2 { await withCheckedContinuation { observer = $0 } } + } + func release() { + released = true + for continuation in waiting { continuation.resume() } + waiting.removeAll() + } + var counts: (starts: Int, maximum: Int) { (starts, maximum) } +} + +@Test func previewsDeduplicateLoadsAndSeparateEnvironments() async throws { + let cache = AttachmentPreviewCache() + let loads = PreviewLoads() + func request(_ environment: String) -> AttachmentPreviewRequest { + AttachmentPreviewRequest( + key: .remote(environment: environment, attachmentID: "same-id"), + name: "context.txt" + ) { url in + try await loads.load(url, bytes: Data(environment.utf8)) + } + } + async let first = cache.file(for: request("first")) + async let duplicate = cache.file(for: request("first")) + async let other = cache.file(for: request("other")) + await loads.waitForTwo() + await loads.release() + let (a, b, c) = try await (first, duplicate, other) + #expect(a === b) + #expect(a.url != c.url) + #expect(try Data(contentsOf: a.url) == Data("first".utf8)) + #expect(try Data(contentsOf: c.url) == Data("other".utf8)) + #expect(await loads.counts.starts == 2) + #expect( + try FileManager.default.attributesOfItem(atPath: a.url.path)[.posixPermissions] as? Int == 0o600 + ) + #expect( + try FileManager.default.attributesOfItem(atPath: a.url.deletingLastPathComponent().path)[ + .posixPermissions] as? Int == 0o700) +} + +@Test func previewsBoundConcurrentDownloads() async throws { + let cache = AttachmentPreviewCache() + let loads = PreviewLoads() + let work = Task { + try await withThrowingTaskGroup(of: AttachmentPreviewFile.self) { group in + for index in 0..<12 { + group.addTask { + try await cache.file( + for: AttachmentPreviewRequest( + key: .remote(environment: "server", attachmentID: String(index)), name: "file.txt" + ) { url in try await loads.load(url, bytes: Data("File \(index)".utf8)) }) + } + } + var count = 0 + for try await file in group { + #expect(try String(contentsOf: file.url, encoding: .utf8).hasPrefix("File ")) + count += 1 + } + return count + } + } + await loads.waitForTwo() + await loads.release() + #expect(try await work.value == 12) + #expect(await loads.counts.starts == 12) + #expect(await loads.counts.maximum == 2) +} + +@Test func cacheEvictionKeepsVisibleFilesAliveAndFailedLoadsCanRetry() async throws { + let cache = AttachmentPreviewCache(countLimit: 1) + let key = AttachmentPreviewRequest.Key.remote(environment: "server", attachmentID: "a") + let good = AttachmentPreviewRequest(key: key, name: "../../context.txt") { url in + try Data("Preserved preview".utf8).write(to: url) + } + do { + _ = try await cache.file( + for: AttachmentPreviewRequest(key: key, name: "partial.txt") { url in + try Data("partial".utf8).write(to: url) + throw T3Error("Interrupted") + }) + Issue.record("Failed transfer should throw") + } catch {} + let original = try await cache.file(for: good) + #expect(original.url.lastPathComponent == "context.txt") + let other = try await cache.file( + for: AttachmentPreviewRequest( + key: .remote(environment: "server", attachmentID: "b"), name: "other.txt" + ) { url in try Data("Other preview".utf8).write(to: url) }) + #expect(original.url.deletingLastPathComponent() != other.url.deletingLastPathComponent()) + #expect(try String(contentsOf: original.url, encoding: .utf8) == "Preserved preview") + let reloaded = try await cache.file(for: good) + #expect(original.url != reloaded.url) + #expect(try Data(contentsOf: reloaded.url) == Data(contentsOf: original.url)) +} + +@Test func thumbnailsDecodeOnceAtBoundedResolutionWithoutChangingTheOriginal() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let source = root.appendingPathComponent("image.png") + let context = CGContext( + data: nil, width: 1600, height: 800, bitsPerComponent: 8, + bytesPerRow: 6400, space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)! + context.setFillColor(CGColor(red: 0.3, green: 0.5, blue: 0.7, alpha: 1)) + context.fill(CGRect(x: 0, y: 0, width: 1600, height: 800)) + let output = CGImageDestinationCreateWithURL( + source as CFURL, UTType.png.identifier as CFString, 1, nil)! + CGImageDestinationAddImage(output, context.makeImage()!, nil) + #expect(CGImageDestinationFinalize(output)) + let bytes = try Data(contentsOf: source) + let cache = AttachmentPreviewCache(directory: root) + let request = AttachmentPreviewRequest.local(source, name: "image.png") + let image = try await cache.thumbnail(for: request) + let again = try await cache.thumbnail(for: request) + #expect(image === again) + #expect(image.width == 512) + #expect(image.height == 256) + let file = try await cache.file(for: request) + try FileManager.default.removeItem(at: source) + #expect(try Data(contentsOf: file.url) == bytes) + let destination = root.appendingPathComponent("saved-image.png") + try Data("Existing destination".utf8).write(to: destination) + try await file.save(to: destination) + #expect(try Data(contentsOf: destination) == bytes) + #expect(try await cache.thumbnail(for: request) === image) + let bad = AttachmentPreviewRequest( + key: .remote(environment: "server", attachmentID: "broken"), + name: "invalid.png" + ) { url in + try Data("Not an image".utf8).write(to: url) + } + await #expect(throws: T3Error.self) { try await cache.thumbnail(for: bad) } + #expect(try await cache.file(for: bad).sizeBytes == 12) +} diff --git a/apps/macos/Tests/T3CoreTests/AttachmentTests.swift b/apps/macos/Tests/T3CoreTests/AttachmentTests.swift new file mode 100644 index 0000000000000..e0696eace35e1 --- /dev/null +++ b/apps/macos/Tests/T3CoreTests/AttachmentTests.swift @@ -0,0 +1,175 @@ +import Foundation +import ImageIO +import Testing +import UniformTypeIdentifiers + +@testable import T3Core + +@Test func oldTextDraftsUpgradeWithoutDroppingMessagesOrRetryIDs() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let repository = DraftRepository(directory: root) + let endpoint = URL(string: "https://example.test")! + try repository.save(SavedDrafts(), for: endpoint) + let old = Data( + #"{"version":1,"messages":{"t":"keep me"},"questions":{},"submissions":{"t":{"text":"keep me","command":"stable-command","message":"stable-message"}}}"# + .utf8) + try old.write(to: repository.file(for: endpoint)) + let restored = try repository.load(for: endpoint) + #expect(restored.messages["t"] == "keep me") + #expect(restored.submissions["t"]?.command == "stable-command") + #expect(restored.submissions["t"]?.matches(text: "keep me", attachments: []) == true) + let saved = SavedDrafts( + messages: restored.messages, submissions: restored.submissions, questions: restored.questions, + attachments: [:]) + try repository.save(saved, for: endpoint) + #expect(try repository.load(for: endpoint).version == 5) + let future = Data(#"{"version":6,"messages":{},"questions":{},"submissions":{}}"#.utf8) + try future.write(to: repository.file(for: endpoint)) + #expect(throws: T3Error.self) { try repository.load(for: endpoint) } + #expect(try Data(contentsOf: repository.file(for: endpoint)) == future) +} + +@Test func nativeImageConversionProducesProviderSupportedBytes() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let context = CGContext( + data: nil, width: 16, height: 16, bitsPerComponent: 8, bytesPerRow: 64, + space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)! + context.setFillColor(CGColor(red: 0.2, green: 0.4, blue: 0.6, alpha: 1)) + context.fill(CGRect(x: 0, y: 0, width: 16, height: 16)) + let image = context.makeImage()! + let source = root.appendingPathComponent("fixture.tiff") + let output = CGImageDestinationCreateWithURL( + source as CFURL, UTType.tiff.identifier as CFString, 1, nil)! + CGImageDestinationAddImage(output, image, nil) + #expect(CGImageDestinationFinalize(output)) + let repository = AttachmentRepository(directory: root.appendingPathComponent("copies")) + let staged = try repository.stage(source) + #expect(staged.name == "fixture.jpg") + #expect(staged.type == "image") + #expect(staged.mimeType == "image/jpeg") + let converted = CGImageSourceCreateWithURL(try repository.file(staged) as CFURL, nil)! + #expect(CGImageSourceGetType(converted) as String? == UTType.jpeg.identifier) + let decoded = CGImageSourceCreateImageAtIndex(converted, 0, nil) + #expect(decoded?.width == 16) + #expect(decoded?.height == 16) +} + +@Test func attachmentCopiesAndSendIdentitySurviveSourceRemovalAndRestart() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let source = root.appendingPathComponent("context.txt") + let content = Data("Original attachment content\n".utf8) + try content.write(to: source) + let endpoint = URL(string: "https://example.test")! + let drafts = DraftRepository(directory: root.appendingPathComponent("drafts")) + let files = drafts.attachmentRepository(for: endpoint) + var staged = try files.stage(source) + try Data("Changed original".utf8).write(to: source) + try FileManager.default.removeItem(at: source) + #expect(try Data(contentsOf: files.file(staged)) == content) + #expect( + try FileManager.default.attributesOfItem(atPath: files.file(staged).path)[.posixPermissions] + as? Int == 0o600) + staged.uploadedID = "pending_file_123" + let submission = PendingSubmission( + text: "", attachments: [staged.wire!], localAttachmentIDs: [staged.id]) + try drafts.save( + SavedDrafts(submissions: ["thread": submission], attachments: ["thread": [staged]]), + for: endpoint) + let restored = try drafts.load(for: endpoint) + #expect(restored.attachments?["thread"] == [staged]) + #expect(restored.submissions["thread"] == submission) + #expect(submission.matches(text: "", attachments: [staged])) + #expect(!submission.matches(text: "changed", attachments: [staged])) + #expect(!submission.matches(text: "", attachments: [])) + var refreshed = staged + refreshed.uploadedID = "different_upload" + #expect(submission.attachments == [staged.wire!]) + #expect(submission.attachments != [refreshed.wire!]) + #expect( + Commands.message( + threadID: "thread", text: "", commandID: submission.command, + messageID: submission.message, attachments: submission.attachments!)["attachments"] + == .array([staged.wire!])) + #expect(try drafts.load(for: URL(string: "https://other.test")!).attachments == nil) + files.remove(staged) + #expect(!FileManager.default.fileExists(atPath: try files.file(staged).path)) +} + +@Test func attachmentLimitsRespectServerCapabilitiesAndAggregateImageBytes() throws { + func attachment(_ type: String, _ size: Int) -> DraftAttachment { + DraftAttachment( + id: UUID().uuidString, name: "example", + mimeType: type == "image" ? "image/png" : "text/plain", sizeBytes: size, type: type) + } + let policy = AttachmentPolicy( + .object([ + "attachmentUploads": .bool(true), + "fileAttachments": .object(["maxUploadBytes": .number(1024)]), + ])) + try policy.validate([attachment("file", 1024)]) + #expect(throws: T3Error.self) { try policy.validate([attachment("file", 1025)]) } + #expect(throws: T3Error.self) { try policy.validate([attachment("file", 0)]) } + #expect(throws: T3Error.self) { try AttachmentPolicy().validate([attachment("image", 1)]) } + try policy.validate(Array(repeating: attachment("image", AttachmentPolicy.imageLimit), count: 8)) + #expect(throws: T3Error.self) { + try policy.validate( + Array(repeating: attachment("image", AttachmentPolicy.imageLimit), count: 9)) + } + #expect(throws: T3Error.self) { + try policy.validate(Array(repeating: attachment("file", 1), count: 101)) + } + #expect(throws: T3Error.self) { + try policy.validate([attachment("image", AttachmentPolicy.imageLimit + 1)]) + } + #expect( + AttachmentPolicy( + .object(["fileAttachments": .object(["maxUploadBytes": .number(999_999_999)])]) + ).fileBytes == AttachmentPolicy.fileLimit) +} + +@Test func attachmentStagingRejectsDirectoriesEmptyFilesAndUnsafeLocalIDs() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let files = AttachmentRepository(directory: root.appendingPathComponent("copies")) + #expect(throws: T3Error.self) { try files.stage(root) } + let empty = root.appendingPathComponent("empty.txt") + try Data().write(to: empty) + #expect(throws: T3Error.self) { try files.stage(empty) } + let unsafe = DraftAttachment( + id: "../source", name: "source", mimeType: "text/plain", sizeBytes: 1, type: "file") + #expect(throws: T3Error.self) { try files.file(unsafe) } +} + +@Test func signedAttachmentURLsCannotSendBytesToAnotherOrigin() throws { + let endpoint = URL(string: "https://example.test")! + let prefix = "/api/attachments/upload/" + #expect( + try AttachmentHTTP.signedURL(prefix + "signed.token", endpoint: endpoint, prefix: prefix) + .absoluteString == "https://example.test/api/attachments/upload/signed.token") + for value in [ + "https://other.test" + prefix + "token", "//other.test" + prefix + "token", "/oauth/token", + prefix + "../secret", prefix + "%2E%2E/secret", "file:///tmp/upload", + ] { + #expect(throws: T3Error.self) { + try AttachmentHTTP.signedURL(value, endpoint: endpoint, prefix: prefix) + } + } + let failure: JSON = .object([ + "cause": .object([ + "reasons": .array([ + .object([ + "_tag": .string("Fail"), + "error": .object(["_tag": .string("AssetAttachmentNotFoundError")]), + ]) + ]) + ]) + ]) + #expect(RPCConnection.failureCode(failure) == "AssetAttachmentNotFoundError") + #expect(RPCConnection.failureCode(.object(["message": .string("offline")])) == nil) +} diff --git a/apps/macos/Tests/T3CoreTests/BackendRuntimeSmokeTests.swift b/apps/macos/Tests/T3CoreTests/BackendRuntimeSmokeTests.swift new file mode 100644 index 0000000000000..c9aac2f4183b5 --- /dev/null +++ b/apps/macos/Tests/T3CoreTests/BackendRuntimeSmokeTests.swift @@ -0,0 +1,20 @@ +import Foundation +import Testing + +@testable import T3Core + +@Suite @MainActor struct BackendRuntimeSmokeTests { + @Test(.enabled(if: ProcessInfo.processInfo.environment["T3_NATIVE_SMOKE_RESOURCES"] != nil)) + func packagedRuntimeAuthenticatesServesWebAndStopsOwnedChild() async throws { + let resources = URL( + fileURLWithPath: ProcessInfo.processInfo.environment["T3_NATIVE_SMOKE_RESOURCES"]!) + let home = URL(fileURLWithPath: "/tmp/t3-native-evidence/backend-test-" + UUID().uuidString) + let report = try await BackendRuntimeSmoke.run(resources: resources, newHome: home) + #expect(report.authenticatedSession) + #expect(report.webIndexMatchesBundle) + #expect(!report.environmentID.isEmpty) + #expect(report.childStopped) + #expect(report.exitStatus != nil) + #expect(FileManager.default.fileExists(atPath: home.appendingPathComponent("userdata").path)) + } +} diff --git a/apps/macos/Tests/T3CoreTests/BrowserAnnotationsTests.swift b/apps/macos/Tests/T3CoreTests/BrowserAnnotationsTests.swift new file mode 100644 index 0000000000000..1a9b4312024e8 --- /dev/null +++ b/apps/macos/Tests/T3CoreTests/BrowserAnnotationsTests.swift @@ -0,0 +1,35 @@ +import Foundation +import Testing + +@testable import T3Core + +@Test func browserAnnotationSerializationRoundTripsNormalizedGeometry() throws { + let document = BrowserAnnotationDocument( + pageURL: "https://example.test/scrolled", + capturedAt: Date(timeIntervalSince1970: 1_800_000_000), + annotations: [ + .init(tool: .pen, points: [.init(x: 0, y: 0), .init(x: 0.4, y: 0.8), .init(x: 1, y: 1)]), + .init(tool: .rectangle, points: [.init(x: 0.2, y: 0.3), .init(x: 0.8, y: 0.9)]), + .init(tool: .arrow, points: [.init(x: 0.5, y: 0.1), .init(x: 0.3, y: 0.7)], width: 6), + ]) + let data = try document.serialized() + #expect(try BrowserAnnotationDocument.deserialize(data) == document) +} + +@Test func browserAnnotationDeserializationRejectsCorruptAndUnboundedGeometry() throws { + let data = Data( + """ + {"version":1,"pageURL":"https://example.test","capturedAt":"2026-10-12T00:00:00Z", + "annotations":[{"tool":"rectangle","width":3,"points":[{"x":1.1,"y":0},{"x":0,"y":1}]}]} + """.utf8) + #expect(throws: (any Error).self) { try BrowserAnnotationDocument.deserialize(data) } + #expect(throws: (any Error).self) { + try BrowserAnnotationDocument.deserialize(Data("broken".utf8)) + } + let document = BrowserAnnotationDocument( + pageURL: "", + annotations: [ + .init(tool: .pen, points: Array(repeating: .init(x: 0, y: 0), count: 100_001)) + ]) + #expect(throws: (any Error).self) { try document.serialized() } +} diff --git a/apps/macos/Tests/T3CoreTests/BrowserTests.swift b/apps/macos/Tests/T3CoreTests/BrowserTests.swift new file mode 100644 index 0000000000000..91e968b58bc24 --- /dev/null +++ b/apps/macos/Tests/T3CoreTests/BrowserTests.swift @@ -0,0 +1,100 @@ +import Foundation +import Testing + +@testable import T3Core + +@Test func browserAddressesUseHTTPForLoopbackAndRejectCredentialsAndOtherSchemes() throws { + #expect( + try BrowserURL.normalize(" localhost:3000/page?q=a#section ").absoluteString + == "http://localhost:3000/page?q=a#section") + #expect(try BrowserURL.normalize("127.0.1.2:8080").scheme == "http") + #expect(try BrowserURL.normalize("[::1]:8080").scheme == "http") + #expect(try BrowserURL.normalize("example.com").absoluteString == "https://example.com/") + for invalid in [ + "", "file:///tmp/test", "javascript:alert(1)", "https://user:secret@example.com", + "https://example.com:0", "https://example.com:65536", + ] { + #expect(throws: T3Error.self) { try BrowserURL.normalize(invalid) } + } +} + +@Test func browserLocalLinksFollowTheConnectedMachineAndNeverPublicRelays() throws { + let local = try BrowserURL.normalize("localhost:3000/page?q=a#section") + for host in ["192.168.1.2", "100.101.1.2", "dev.local", "mac.tail123.ts.net", "[fd00::2]"] { + let environment = URL(string: "https://\(host):4773")! + let resolved = try BrowserURL.resolve(local, environment: environment) + #expect(resolved.host == environment.host) + #expect(resolved.port == 3000) + #expect(resolved.path == "/page") + #expect(resolved.query == "q=a") + #expect(resolved.fragment == "section") + } + for host in ["relay.example.com", "127.example.com", "192.168.bad.1.2", "8.8.8.8"] { + #expect(throws: T3Error.self) { + try BrowserURL.resolve(local, environment: URL(string: "https://\(host)")!) + } + } + let publicURL = try BrowserURL.normalize("https://127.example.com/page") + #expect( + try BrowserURL.resolve(publicURL, environment: URL(string: "https://192.168.1.2")!) == publicURL + ) + #expect( + try BrowserURL.resolve(local, environment: URL(string: "http://127.0.0.1:4773")!).host + == "localhost") +} + +private func browserTab(_ id: String, thread: String = "one", url: String = "https://example.com/") + -> JSON +{ + .object([ + "threadId": .string(thread), "tabId": .string(id), "profileId": .string("work"), + "viewport": .object(["_tag": .string("fill")]), + "navStatus": .object(["_tag": .string("Success"), "url": .string(url), "title": .string(id)]), + ]) +} +private func browserList(_ tabs: [JSON], revision: Int, epoch: String = "boot") -> JSON { + .object([ + "sessions": .array(tabs), "revision": .number(Double(revision)), "serverEpoch": .string(epoch), + ]) +} +private func browserEvent( + _ type: String, id: String, revision: Int, thread: String = "one", epoch: String = "boot" +) -> JSON { + .object([ + "type": .string(type), "threadId": .string(thread), "tabId": .string(id), + "serverEpoch": .string(epoch), "revision": .number(Double(revision)), + "snapshot": browserTab(id, thread: thread), "url": .string("https://example.com/failed"), + "title": .string("Failed"), "code": .number(-1004), + "description": .string("Connection refused"), + ]) +} + +@Test func browserSnapshotRacePreservesNewTabsAndRejectsStaleListsAndOtherThreads() throws { + var directory = BrowserDirectory(threadID: "one") + try directory.apply(browserEvent("opened", id: "second", revision: 4)) + try directory.snapshot( + browserList([browserTab("first"), browserTab("foreign", thread: "two")], revision: 3)) + #expect(directory.tabs.map(\.id) == ["first", "second"]) + try directory.snapshot(browserList([], revision: 2)) + try directory.apply(browserEvent("closed", id: "second", revision: 99, thread: "two")) + #expect(directory.tabs.map(\.id) == ["first", "second"]) + try directory.apply(browserEvent("closed", id: "first", revision: 5)) + try directory.apply(browserEvent("opened", id: "first", revision: 4)) + #expect(directory.tabs.map(\.id) == ["second"]) + #expect(throws: T3Error.self) { + try directory.apply(browserEvent("opened", id: "restart", revision: 1, epoch: "reboot")) + } + try directory.snapshot(browserList([], revision: 0, epoch: "reboot")) + #expect(directory.tabs.isEmpty) +} + +@Test func browserFailedEventsKeepTabIdentityProfileAndViewport() throws { + var directory = BrowserDirectory(threadID: "one") + try directory.snapshot(browserList([browserTab("first")], revision: 1)) + try directory.apply(browserEvent("failed", id: "first", revision: 2)) + let tab = try #require(directory.tabs.first) + #expect(tab.status["_tag"].string == "LoadFailed") + #expect(tab.url?.path == "/failed") + #expect(tab.profileID == "work") + #expect(tab.viewport["_tag"].string == "fill") +} diff --git a/apps/macos/Tests/T3CoreTests/ComposerMentionTests.swift b/apps/macos/Tests/T3CoreTests/ComposerMentionTests.swift new file mode 100644 index 0000000000000..59a583465f275 --- /dev/null +++ b/apps/macos/Tests/T3CoreTests/ComposerMentionTests.swift @@ -0,0 +1,278 @@ +import Foundation +import Testing + +@testable import T3Core + +@Test func composerMentionsReplaceWholeTokensAtUTF16CaretOffsets() throws { + let text = "🌊 café @Sources/Part.swift and $review" + let source = text as NSString + let cursor = source.range(of: "Part").location + 2 + let mention = try #require(ComposerMention.detect(text: text, cursor: cursor)) + #expect(mention.kind == .file) + #expect(mention.query == "Sources/Pa") + #expect(source.substring(with: mention.range) == "@Sources/Part.swift") + let insertion = mention.insertion(in: text, replacement: "[Other.swift](Sources/Other.swift)") + #expect( + source.replacingCharacters(in: insertion.range, with: insertion.replacement) + == "🌊 café [Other.swift](Sources/Other.swift) and $review") + #expect(ComposerMention.detect(text: text, cursor: source.length)?.query == "review") + #expect(ComposerMention.detect(text: "user@example.test", cursor: 17) == nil) + #expect(ComposerMention.detect(text: "cost $20k", cursor: 9) == nil) + #expect(ComposerMention.detect(text: "explain\n/compact", cursor: 16)?.kind == .command) + #expect(ComposerMention.detect(text: "/compact", cursor: 8)?.kind == .command) + #expect( + ComposerMention.detect(text: "@" + String(repeating: "a", count: 257), cursor: 258) == nil) +} + +@Test func composerFileLinksMatchWebWireFormatAndLiteralFallbackPreservesRoots() { + #expect( + ComposerMention.fileReference(path: "docs/My File (draft).md", root: "/repo") + == "[My File (draft).md](docs/My%20File%20%28draft%29.md)") + #expect( + ComposerMention.fileReference(path: "docs/[café]?#.md", root: "/repo") + == "[\\[café\\]?#.md](docs/%5Bcaf%C3%A9%5D%3F%23.md)") + #expect( + ComposerMention.fileReference(path: "C:\\repo\\src\\index.ts", root: "/repo") + == "[index.ts](C:%5Crepo%5Csrc%5Cindex.ts)") + #expect( + ComposerMention.fileReference(path: "src", root: "/worktrees/one/", format: .absoluteLiteral) + == "/worktrees/one/src") + #expect( + ComposerMention.fileReference(path: "src/a.ts", root: "C:\\repo", format: .absoluteLiteral) + == "C:\\repo\\src/a.ts") +} + +private func completionProvider() -> JSON { + .object([ + "enabled": .bool(true), "installed": .bool(true), "status": .string("ready"), + "slashCommands": .array([.object(["name": .string("global-command")])]), + "skills": .array([.object(["name": .string("global-skill"), "enabled": .bool(true)])]), + "workspaceSnapshots": .array([ + .object([ + "cwd": .string("/repo"), + "slashCommands": .array([ + .object(["name": .string("review"), "description": .string("Review changes")]), + .object(["name": .string("compact")]), + ]), + "skills": .array([ + .object([ + "name": .string("review-code"), "enabled": .bool(true), + "displayName": .string("Code review"), + ]), + .object(["name": .string("disabled"), "enabled": .bool(false)]), + .object([ + "name": .string("agent-only"), "enabled": .bool(true), "userInvocable": .bool(false), + ]), + .object([ + "name": .string("user-only"), "enabled": .bool(true), "userInvocationOnly": .bool(true), + ]), + ]), + ]) + ]), + ]) +} + +@Test func composerUsesTheSelectedWorktreeCatalogAndFiltersUnusableSkillsAndCommands() { + let provider = completionProvider() + let commands = ComposerSuggestion.providerItems(provider, cwd: "/repo", kind: .command, query: "") + #expect(commands.map(\.name) == ["review"]) + #expect(commands.first?.replacement(root: "/repo") == "/review") + let skills = ComposerSuggestion.providerItems(provider, cwd: "/repo", kind: .skill, query: "") + #expect(Set(skills.map(\.name)) == ["review-code", "user-only"]) + #expect( + skills.first(where: { $0.name == "review-code" })?.replacement(root: "/repo") == "$review-code") + #expect( + ComposerSuggestion.providerItems(provider, cwd: "/other", kind: .command, query: "").map(\.name) + == ["global-command"]) + #expect( + ComposerSuggestion.providerItems( + provider, cwd: "/repo", kind: .command, query: "compact", allowCompact: true + ).map(\.name) == ["compact"]) + #expect( + ComposerSuggestion.providerItems(provider, cwd: "/repo", kind: .skill, query: "code review") + .map(\.name) == ["review-code"]) + guard case .object(var fields) = provider else { return } + fields["availability"] = .string("unavailable") + #expect( + ComposerSuggestion.providerItems(.object(fields), cwd: "/repo", kind: .command, query: "") + .isEmpty) +} + +@Test func composerSkillLiteralFallbackNamesTheRealCatalogFile() throws { + let provider: JSON = .object([ + "enabled": .bool(true), "installed": .bool(true), + "skills": .array([ + .object([ + "name": .string("review"), "path": .string("/repo/.agents/skills/review/SKILL.md"), + "enabled": .bool(true), + ]) + ]), + ]) + let skill = try #require( + ComposerSuggestion.providerItems(provider, cwd: "/repo", kind: .skill, query: "review").first) + #expect(skill.replacement(root: "/repo") == "$review") + #expect( + skill.replacement(root: "/repo", skillUsesLiteralPath: true) + == "/repo/.agents/skills/review/SKILL.md") +} + +@Test func composerDisabledDuplicatesCannotHideAnEnabledSkillAndMoneyNamesStayLiteral() { + let provider: JSON = .object([ + "enabled": .bool(true), "installed": .bool(true), + "skills": .array([ + .object(["name": .string("review"), "enabled": .bool(false)]), + .object(["name": .string("review"), "enabled": .bool(true)]), + .object(["name": .string("20k"), "enabled": .bool(true)]), + ]), + ]) + #expect( + ComposerSuggestion.providerItems(provider, cwd: "/repo", kind: .skill, query: "").map(\.name) + == ["review"]) +} + +@Test func composerFileResultsKeepServerRankingAndBoundDuplicateMatches() { + let result: JSON = .object([ + "entries": .array([ + .object(["path": .string("src/z.swift"), "kind": .string("file")]), + .object(["path": .string("src"), "kind": .string("directory")]), + .object(["path": .string("src/z.swift"), "kind": .string("file")]), + .object(["path": .string("src/a.swift"), "kind": .string("file")]), + ]) + ]) + let items = ComposerSuggestion.files(result, limit: 2) + #expect(items.map(\.name) == ["src/z.swift", "src"]) + #expect(items[1].isDirectory) + #expect(items[1].replacement(root: "/repo") == "[src](src)") +} + +private func completionScope( + thread: String = "one", provider: String = "codex", root: String = "/repo", session: UUID +) -> ComposerCompletionScope { + ComposerCompletionScope( + environment: "https://server.example", session: session, + thread: thread, draft: thread, provider: provider, root: root) +} + +@Test func composerRejectsStaleSearchesAfterTypingReconnectOrThreadAndProviderChanges() throws { + let session = UUID() + let scope = completionScope(session: session) + var state = ComposerCompletionState() + state.refresh(text: "@a", cursor: 2, scope: scope) + let first = try #require(state.ticket) + state.refresh(text: "@ab", cursor: 3, scope: scope) + #expect(!state.accepts(first)) + let newer = try #require(state.ticket) + #expect(state.accepts(newer)) + for next in [ + completionScope(thread: "two", session: session), + completionScope(provider: "claude", session: session), + completionScope(root: "/worktree", session: session), completionScope(session: UUID()), + ] { + state.refresh(text: "@ab", cursor: 3, scope: next) + #expect(!state.accepts(newer)) + } + state.refresh(text: "@ab", cursor: 3, scope: nil) + #expect(state.ticket == nil) +} + +@Test func composerEscapeStaysDismissedWhileTypingUntilCaretLeavesToken() throws { + let scope = completionScope(session: UUID()) + var state = ComposerCompletionState() + state.refresh(text: "$rev", cursor: 4, scope: scope) + let ticket = try #require(state.ticket) + state.dismiss() + state.refresh(text: "$review", cursor: 7, scope: scope) + #expect(state.mention == nil) + #expect(!state.accepts(ticket)) + state.refresh(text: "$review ", cursor: 8, scope: scope) + state.refresh(text: "$review $", cursor: 9, scope: scope) + #expect(state.mention?.kind == .skill) +} + +@Test func composerLocalActionsKeepProviderCommandsAtPromptStartAndRespectPlanAvailability() throws +{ + let providerItems = ComposerSuggestion.providerItems( + completionProvider(), cwd: "/repo", kind: .command, query: "") + let start = try #require(ComposerMention.detect(text: "/", cursor: 1)) + let middle = try #require(ComposerMention.detect(text: "Explain 🌊 /", cursor: 12)) + func items( + _ mention: ComposerMention, enabled: Bool = true, plan: Bool = true, support: Bool = true + ) -> [ComposerSuggestion] { + ComposerSuggestion.slashItems( + providerItems: providerItems, mention: mention, + localCommandsEnabled: enabled, planModeEnabled: plan, interactionModeSupported: support) + } + #expect(items(start).map(\.name) == ["model", "plan", "default", "review"]) + #expect(items(middle).map(\.name) == ["model", "plan", "default"]) + #expect(items(middle, plan: false).map(\.name) == ["model"]) + #expect(items(middle, support: false).map(\.name) == ["model"]) + #expect(items(middle, enabled: false).isEmpty) + #expect(items(start, enabled: false).map(\.name) == ["review"]) + let search = try #require(ComposerMention.detect(text: "Explain /pl", cursor: 11)) + #expect(items(search).map(\.localCommand) == ["plan"]) + #expect(items(search, plan: false).isEmpty) + let review = try #require(ComposerMention.detect(text: "Explain /review", cursor: 15)) + #expect(items(review).isEmpty) +} + +@Test func composerLocalSelectionRemovesOnlyTheCommandAndPreservesPromptContext() throws { + for command in ["model", "plan", "default"] { + let text = "🌊 discuss /\(command)more [file.swift](src/file.swift) $review" + let source = text as NSString + let cursor = source.range(of: "/" + command).location + command.utf16.count + 1 + let mention = try #require(ComposerMention.detect(text: text, cursor: cursor)) + let item = try #require( + ComposerSuggestion.slashItems( + providerItems: [], mention: mention, + localCommandsEnabled: true, planModeEnabled: true, interactionModeSupported: true + ).first) + let edit = item.selectionEdit(mention: mention, text: text, root: "/repo") + #expect(item.localCommand == command) + #expect(edit.range == source.range(of: "/" + command + "more")) + #expect(edit.replacement.isEmpty) + #expect( + source.replacingCharacters(in: edit.range, with: edit.replacement) + == "🌊 discuss [file.swift](src/file.swift) $review") + #expect(item.replacement(root: "/repo").isEmpty) + } + let text = "/review later" + let mention = try #require(ComposerMention.detect(text: text, cursor: 7)) + let item = try #require( + ComposerSuggestion.providerItems( + completionProvider(), cwd: "/repo", kind: .command, query: "review" + ).first) + let edit = item.selectionEdit(mention: mention, text: text, root: "/repo") + #expect(item.localCommand == nil) + #expect((text as NSString).replacingCharacters(in: edit.range, with: edit.replacement) == text) +} + +@Test func composerLocalActionsCannotUseTicketsFromAnotherDraftEnvironmentOrConnection() throws { + let session = UUID() + let scope = completionScope(session: session) + for changed in [ + ComposerCompletionScope( + environment: "other-server", session: session, thread: scope.thread, + draft: scope.draft, provider: scope.provider, root: scope.root), + ComposerCompletionScope( + environment: scope.environment, session: session, thread: scope.thread, + draft: "edited-queued-message", provider: scope.provider, root: scope.root), + completionScope(thread: "two", session: session), + completionScope(provider: "claude", session: session), + completionScope(root: "/another-worktree", session: session), + completionScope(session: UUID()), + ] { + var state = ComposerCompletionState() + state.refresh(text: "Explain /model", cursor: 14, scope: scope) + let original = try #require(state.ticket) + #expect( + !ComposerSuggestion.slashItems( + providerItems: [], mention: original.mention, + localCommandsEnabled: true, planModeEnabled: false, interactionModeSupported: false + ).isEmpty) + state.refresh(text: "Explain /model", cursor: 14, scope: changed) + #expect(!state.accepts(original)) + #expect(state.ticket?.scope == changed) + state.refresh(text: "Explain /model", cursor: 14, scope: nil) + #expect(state.ticket == nil) + } +} diff --git a/apps/macos/Tests/T3CoreTests/ContextUsageNativeTests.swift b/apps/macos/Tests/T3CoreTests/ContextUsageNativeTests.swift new file mode 100644 index 0000000000000..1c312f5e03516 --- /dev/null +++ b/apps/macos/Tests/T3CoreTests/ContextUsageNativeTests.swift @@ -0,0 +1,209 @@ +import Foundation +import Testing + +@testable import T3Core + +private func contextJSON(_ text: String) throws -> JSON { try JSON.decode(Data(text.utf8)) } +private func contextEvent(_ type: String, _ payload: JSON) -> JSON { + .object(["type": .string(type), "threadId": .string("thread"), "payload": payload]) +} +private func contextFields(_ value: JSON, _ fields: [String: JSON]) -> JSON { + guard case .object(var result) = value else { return .object(fields) } + result.merge(fields) { _, new in new } + return .object(result) +} +private func contextFixture() throws -> JSON { + try contextJSON( + #""" + {"thread":{"id":"thread","activeProviderThreadId":"claude-thread"}, + "providerThreads":[ + {"id":"claude-thread","appThreadId":"thread","driver":"claudeAgent","providerInstanceId":"claudeAgent","updatedAt":"2026-10-12T12:00:00Z","contextUsage":{"usedTokens":110000,"maxTokens":200000,"cost":{"amount":0.004,"currency":"USD"},"autoCompactThreshold":160000}}, + {"id":"codex-thread","appThreadId":"thread","driver":"codex","providerInstanceId":"codex","updatedAt":"2026-10-12T13:00:00Z","contextUsage":{"usedTokens":3000}}], + "providerTurns":[{"id":"turn","providerThreadId":"claude-thread","ordinal":1,"status":"running","runAttemptId":"attempt","tokenUsage":{"usedTokens":120000,"maxTokens":200000,"inputTokens":100000,"updatedAt":"2026-10-12T12:10:00Z"}}], + "turnItems":[{"id":"message","type":"user_message","text":"Review this code","attachments":[]}]} + """#) +} + +@Test func nativeContextRetainsUsageOnlyUpdatesAndRejectsOlderReports() throws { + let fixture = try contextFixture() + var execution = ThreadExecution(fixture) + #expect(execution.contextUsage?.usedTokens == 120000) + #expect(execution.contextUsage?.number("autoCompactThreshold") == nil) + let turn = fixture["providerTurns"].array[0] + let update = contextFields( + turn, + [ + "tokenUsage": try contextJSON( + #"{"usedTokens":140000,"maxTokens":200000,"updatedAt":"2026-10-12T12:20:00Z"}"#) + ]) + execution.apply(contextEvent("provider-turn.updated", update)) + #expect(execution.liveProviderTurnUsage?.usedTokens == 140000) + #expect(execution.contextUsage?.remainingTokens == 60000) + execution.apply(contextEvent("provider-turn.updated", turn)) + #expect(execution.contextUsage?.usedTokens == 140000) + execution.apply( + contextEvent( + "provider-turn.updated", + contextFields( + update, ["id": .string("new-turn"), "ordinal": .number(2), "tokenUsage": .null]))) + #expect(execution.contextUsage?.usedTokens == 140000) + #expect(execution.hasCompactableConversation) +} + +@Test func nativeContextDoesNotLeakUsageAcrossProviderSwitches() throws { + let fixture = try contextFixture() + var execution = ThreadExecution(fixture) + execution.apply( + contextEvent( + "thread.provider-switched", + contextFields(fixture["thread"], ["activeProviderThreadId": .string("codex-thread")]))) + #expect(execution.selectedProviderThread["driver"].string == "codex") + #expect(execution.contextUsage?.usedTokens == 3000) + #expect(execution.liveProviderTurnUsage == nil) + execution.apply(contextEvent("provider-turn.updated", fixture["providerTurns"].array[0])) + #expect(execution.contextUsage?.usedTokens == 3000) + execution.apply( + contextEvent( + "thread.provider-switched", + contextFields(fixture["thread"], ["activeProviderThreadId": .string("unknown-thread")]))) + #expect(execution.contextUsage == nil) +} + +@Test func nativeContextProviderAndCompactionFallbacksRetainActualFields() throws { + let fixture = try contextFixture() + var execution = ThreadExecution(contextFields(fixture, ["providerTurns": .array([])])) + #expect(execution.contextUsage?.number("autoCompactThreshold") == 160000) + #expect(execution.contextUsage?.fields["cost"]["amount"] == .number(0.004)) + execution.apply( + contextEvent( + "provider-thread.updated", + contextFields( + fixture["providerThreads"].array[0], + [ + "updatedAt": .string("2026-10-12T11:00:00Z"), + "contextUsage": .object(["usedTokens": .number(1)]), + ]))) + #expect(execution.contextUsage?.usedTokens == 110000) + execution = ThreadExecution( + contextFields( + fixture, + [ + "providerTurns": .array([]), "providerThreads": .array([]), + "turnItems": try contextJSON( + #"[{"id":"compact","type":"compaction","providerThreadId":"claude-thread","beforeTokenCount":180000,"afterTokenCount":35000,"startedAt":"2026-10-12T12:00:00Z","updatedAt":"2026-10-12T12:01:00Z"}]"# + ), + ])) + #expect(execution.contextUsage?.usedTokens == 35000) + #expect(execution.contextUsage?.number("totalProcessedTokens") == 180000) + #expect(execution.contextUsage?.maxTokens == nil) + #expect(execution.contextUsage?.compactsAutomatically == true) + #expect(execution.contextUsage?.updatedAt == "2026-10-12T12:00:00Z") +} + +@Test func nativeContextResumeBoundaryAndDismissalAreScopedToReport() throws { + let usage = try #require( + ContextUsage( + report: contextJSON( + #"{"usedTokens":100000,"maxTokens":100000,"updatedAt":"2026-10-12T12:00:00Z"}"#))) + let now = try #require(ThreadDates.parse("2026-10-12T13:10:00Z")) + #expect(usage.shouldOfferResume(provider: "claudeAgent", now: now)) + #expect(!usage.shouldOfferResume(provider: "codex", now: now)) + #expect(!usage.shouldOfferResume(provider: "claudeAgent", now: now.addingTimeInterval(-1))) + let small = try #require( + ContextUsage( + report: .object(["usedTokens": .number(99999), "updatedAt": .string(usage.updatedAt)]))) + #expect(!small.shouldOfferResume(provider: "claudeAgent", now: now)) + let invalid = try #require( + ContextUsage(report: .object(["usedTokens": .number(100000), "updatedAt": .string("invalid")]))) + #expect(!invalid.shouldOfferResume(provider: "claudeAgent", now: now)) + var dismissed = ContextResumeDismissals() + dismissed.dismiss(environment: "one", thread: "thread", usage: usage) + #expect(dismissed.contains(environment: "one", thread: "thread", usage: usage)) + #expect(!dismissed.contains(environment: "two", thread: "thread", usage: usage)) + #expect(!dismissed.contains(environment: "one", thread: "other", usage: usage)) + let newer = try #require(ContextUsage(report: usage.fields, updatedAt: "2026-10-12T12:01:00Z")) + #expect(!dismissed.contains(environment: "one", thread: "thread", usage: newer)) +} + +@Test func nativeContextPermanentDismissalRecognizesOnlyTheServerQuestion() throws { + let request = try contextJSON( + #"{"kind":"user_input","status":"resolved","answers":{"This session is 1h 10m old and uses 100,000 tokens. Compact it before continuing?":"Don't ask again"}}"# + ) + #expect(ContextUsage.hasPermanentDismissal(request)) + #expect( + !ContextUsage.hasPermanentDismissal(contextFields(request, ["status": .string("pending")]))) + #expect( + !ContextUsage.hasPermanentDismissal( + contextFields( + request, ["answers": .object(["Compact before continuing?": .string("Don't ask again")])]))) + var execution = ThreadExecution() + execution.apply(contextEvent("runtime-request.updated", request)) + #expect(execution.hasDismissedResumeCompaction) +} + +@Test func nativeContextCompactionRequiresCompatibleAdvertisedCapability() throws { + let providers = try contextJSON( + #""" + [{"instanceId":"claudeAgent","driver":"claudeAgent","enabled":true,"status":"error","continuation":{"groupKey":"shared"},"slashCommands":[{"name":"compact"}]}, + {"instanceId":"alternate","driver":"claudeAgent","enabled":true,"status":"ready","continuation":{"groupKey":"other"},"slashCommands":[{"name":"compact"}]}, + {"instanceId":"codex","driver":"codex","enabled":true,"status":"ready","slashCommands":[{"name":"compact"}]}] + """#) + var catalog = ContextCompactionProviders() + catalog.apply( + .object(["type": .string("snapshot"), "config": .object(["providers": providers])])) + #expect( + catalog.supportsCompact( + driver: "claudeAgent", instance: "claudeAgent", lockedInstance: "claudeAgent")) + catalog.apply( + .object([ + "type": .string("settingsUpdated"), + "payload": .object([ + "settings": try contextJSON( + #"{"providerInstances":{"claudeAgent":{"driver":"claudeAgent","enabled":false},"alternate":{"driver":"claudeAgent","enabled":true}},"providers":{"codex":{"enabled":true}}}"# + ) + ]), + ])) + #expect( + !catalog.supportsCompact( + driver: "claudeAgent", instance: "claudeAgent", lockedInstance: "claudeAgent")) + #expect( + catalog.supportsCompact(driver: "claudeAgent", instance: "claudeAgent", lockedInstance: nil)) + #expect(catalog.supportsCompact(driver: "codex", instance: "codex", lockedInstance: "codex")) + catalog.apply( + .object([ + "type": .string("providerStatuses"), + "payload": .object([ + "providers": .array( + providers.array.map { contextFields($0, ["availability": .string("unavailable")]) }) + ]), + ])) + #expect(!catalog.supportsCompact(driver: "codex", instance: "codex", lockedInstance: nil)) +} + +@Test func nativeContextCompactOnlyMessageDoesNotStartAConversation() throws { + var execution = ThreadExecution( + try contextJSON( + #"{"thread":{"id":"thread"},"turnItems":[{"id":"message","type":"user_message","text":" /compact\n","attachments":[]}]}"# + )) + #expect(!execution.hasCompactableConversation) + execution.apply( + contextEvent( + "turn-item.updated", + try contextJSON( + #"{"id":"message","type":"user_message","text":"/compact","attachments":[{"id":"file"}]}"#)) + ) + #expect(execution.hasCompactableConversation) +} + +@Test func nativeContextTokenFormattingAndWarningMatchWebBoundaries() throws { + #expect(ContextUsage.formatTokens(999) == "999") + #expect(ContextUsage.formatTokens(1500) == "1.5k") + #expect(ContextUsage.formatTokens(10000) == "10k") + #expect(ContextUsage.formatTokens(1_250_000) == "1.3m") + let ninety = try #require( + ContextUsage(report: contextJSON(#"{"usedTokens":900,"maxTokens":1000,"updatedAt":"today"}"#))) + #expect(!ninety.isOverloaded) + let full = try #require( + ContextUsage(report: contextJSON(#"{"usedTokens":1100,"maxTokens":1000,"updatedAt":"today"}"#))) + #expect(full.isOverloaded && full.usedPercentage == 100 && full.remainingTokens == 0) +} diff --git a/apps/macos/Tests/T3CoreTests/ConversationTimelineTests.swift b/apps/macos/Tests/T3CoreTests/ConversationTimelineTests.swift new file mode 100644 index 0000000000000..24fafc3864cba --- /dev/null +++ b/apps/macos/Tests/T3CoreTests/ConversationTimelineTests.swift @@ -0,0 +1,112 @@ +import Testing + +@testable import T3Core + +private func activity( + _ id: String, kind: String = "command_execution", source: String = "thread", + inherited: Bool = false, fields: [String: JSON] = [:] +) throws -> TimelineRow { + try TimelineRow( + item: .object( + [ + "id": .string(id), "type": .string(kind), "runId": .string("run"), + "providerTurnId": .string("turn"), "status": .string("completed"), + "input": .string("printf \(id)"), "text": .string(id), + ].merging(fields) { _, new in new }), position: 0, sourceID: source, inherited: inherited) +} + +private func groups(_ entries: [ConversationEntry]) -> [TimelineWorkGroup] { + entries.compactMap { if case .work(let group) = $0 { group } else { nil } } +} + +@Test func workCollapsesWithoutHidingMessagesRequestsOrErrors() throws { + let rows = try [ + activity("prompt", kind: "user_message"), activity("search", kind: "file_search"), + activity("command"), activity("approval", kind: "approval_request"), + activity("failed", fields: ["exitCode": .number(1)]), + activity("reasoning", kind: "reasoning"), activity("notice", kind: "system_notice"), + activity("question", kind: "user_input_request"), activity("reply", kind: "assistant_message"), + ] + let entries = ConversationTimeline.entries(from: rows, activeRunID: nil) + #expect( + groups(entries).map { $0.entries.map(\.id) } == [ + ["thread:search", "thread:command"], ["thread:reasoning"], + ]) + #expect( + entries.compactMap(\.item).map(\.id) == [ + "thread:prompt", "thread:approval", "thread:failed", "thread:notice", "thread:question", + "thread:reply", + ]) + #expect(groups(entries).first?.summary == "1 command · 1 file search") + for fields: [String: JSON] in [ + ["status": .string("failed")], ["status": .string("cancelled")], + ["status": .string("interrupted")], ["outputIndicatesFailure": .bool(true)], + ["toolNonExecutionKind": .string("declined")], ["output": .object(["isError": .bool(true)])], + ] { + let row = try activity("outcome", kind: "dynamic_tool", fields: fields) + #expect(ConversationTimeline.entries(from: [row], activeRunID: "run") == [.item(row)]) + } +} + +@Test func expansionSurvivesStreamUpdatesAndOlderHistoryWithoutMixingSources() throws { + let command = try activity("cmd", fields: ["status": .string("running")]) + let first = ConversationTimeline.entries(from: [command], activeRunID: "run") + let firstGroup = try #require(groups(first).first) + #expect(firstGroup.active) + #expect(firstGroup.summary == "Running printf cmd") + let complete = try activity("cmd", fields: ["output": .string("done")]) + let next = try activity("next", kind: "file_search") + let entries = ConversationTimeline.entries( + from: [complete, next], activeRunID: "run", expandedItemIDs: [command.id]) + let updated = try #require(groups(entries).first) + #expect(updated.id == firstGroup.id) + #expect(updated.active) + #expect(updated.summary == "Searched files · 2 activities") + #expect(updated.expanded) + #expect(entries.compactMap(\.item) == [complete, next]) + let older = try activity("older") + let paged = ConversationTimeline.entries( + from: [older, complete, next], activeRunID: nil, expandedItemIDs: [command.id]) + #expect(groups(paged).first?.expanded == true) + #expect(groups(paged).first?.active == false) + #expect(paged.compactMap(\.item) == [older, complete, next]) + #expect( + groups(ConversationTimeline.entries(from: [complete, next], activeRunID: nil)).first?.expanded + == false) +} + +@Test func workGroupsRespectRunAttemptSubagentAndInheritedBoundaries() throws { + let first = try activity("a") + for field in ["runId", "providerTurnId", "nodeId", "providerThreadId", "parentItemId"] { + let other = try activity("b", fields: [field: .string("other")]) + #expect( + groups(ConversationTimeline.entries(from: [first, other], activeRunID: "run")).count == 2) + } + let parent = try activity( + "a", source: "parent", inherited: true, fields: ["status": .string("running")]) + let inherited = ConversationTimeline.entries( + from: [parent, first], activeRunID: "run", expandedItemIDs: [first.id]) + #expect(groups(inherited).count == 2) + #expect(groups(inherited)[0].active == false) + #expect(groups(inherited)[0].expanded == false) + #expect(groups(inherited)[1].expanded) + #expect(inherited.compactMap(\.item) == [first]) +} + +@Test func completedActivityStaysVisibleUntilTheNextResponseAndStopsWithItsRun() throws { + let row = try activity("command") + let tail = ConversationTimeline.entries(from: [row], activeRunID: "run") + #expect(groups(tail).first?.active == true) + #expect(groups(tail).first?.summary == "Ran printf command") + let reply = try activity("reply", kind: "assistant_message") + #expect( + groups(ConversationTimeline.entries(from: [row, reply], activeRunID: "run")).first?.active + == false) + let staleRunning = try activity("command", fields: ["status": .string("running")]) + #expect( + groups(ConversationTimeline.entries(from: [staleRunning], activeRunID: nil)).first?.active + == false) + let checkpoint = try activity("checkpoint", kind: "checkpoint") + #expect( + groups(ConversationTimeline.entries(from: [row, checkpoint, row], activeRunID: nil)).count == 1) +} diff --git a/apps/macos/Tests/T3CoreTests/DiffContextTests.swift b/apps/macos/Tests/T3CoreTests/DiffContextTests.swift new file mode 100644 index 0000000000000..0b908889dcf2c --- /dev/null +++ b/apps/macos/Tests/T3CoreTests/DiffContextTests.swift @@ -0,0 +1,109 @@ +import Foundation +import Testing + +@testable import T3Core + +private func contextFixture() throws -> (DiffFile, String, String) { + let old = (1...100).map { "line \($0) 日本語\r" } + var new = old + new[39] = "changed 40 👋\r" + new.replaceSubrange(54...54, with: ["changed 55\r", "inserted\r"]) + let chunks: [String] = [ + "diff --git a/file b/file\n@@ -38,5 +38,5 @@\n", + old[37...38].map { " " + $0 + "\n" }.joined(), + "-" + old[39] + "\n+" + new[39] + "\n", + old[40...41].map { " " + $0 + "\n" }.joined(), + "@@ -53,5 +53,6 @@\n", + old[52...53].map { " " + $0 + "\n" }.joined(), + "-" + old[54] + "\n+" + new[54] + "\n+" + new[55] + "\n", + old[55...56].map { " " + $0 + "\n" }.joined(), + ] + let raw = chunks.joined() + return ( + try #require(UnifiedDiff.parse(raw).first), old.joined(separator: "\n") + "\n", + new.joined(separator: "\n") + "\n" + ) +} + +@Test func contextExpansionKeepsBothLineSequencesAndRawPatchWhileFillingEachGap() throws { + let (file, old, new) = try contextFixture() + let context = try DiffContext(file, oldContents: old, newContents: new, ignoreWhitespace: false) + #expect(context.gaps.map(\.id) == [0, 1, 2]) + #expect(context.gaps.map(\.count) == [37, 10, 43]) + #expect(DiffContext.preview(file).lines.compactMap(\.contextID) == [0, 1, 2]) + let full = context.render( + expanded: Dictionary(uniqueKeysWithValues: context.gaps.map { ($0.id, $0.count) })) + #expect(!full.limited) + #expect(full.file.rawPatch == file.rawPatch) + #expect(full.file.additions == file.additions && full.file.deletions == file.deletions) + #expect(full.file.lines.compactMap(\.oldLine) == Array(1...100)) + #expect(full.file.lines.compactMap(\.newLine) == Array(1...101)) + #expect(full.file.lines.first?.text == "line 1 日本語\r") + let partial = context.render(expanded: [0: 20, 1: 4, 2: 20]).file + #expect(partial.lines.first?.contextID == 0) + #expect(partial.lines.compactMap(\.oldLine).first == 18) + let between = partial.lines.compactMap(\.oldLine).filter { (43...52).contains($0) } + #expect(between == [43, 44, 51, 52]) + #expect(partial.lines.compactMap(\.oldLine).last == 77) + #expect(context.render(expanded: [:]).file.lines.compactMap(\.contextID) == [0, 1, 2]) +} + +@Test func contextExpansionRejectsEditsInsideOutsideAndAtTheEndOfThePatch() throws { + let (file, old, new) = try contextFixture() + for modified in [ + new.replacingOccurrences(of: "line 1 日本語", with: "new unseen edit"), + new.replacingOccurrences(of: "changed 40 👋", with: "different change"), + String(new.dropLast()), new + "extra\n", + ] { + #expect(throws: T3Error.self) { + try DiffContext(file, oldContents: old, newContents: modified, ignoreWhitespace: false) + } + } +} + +@Test func contextExpansionHonorsWhitespaceFilteringAndKeepsEachSidesActualText() throws { + let file = try #require( + UnifiedDiff.parse( + "diff --git a/a b/a\n@@ -2,2 +2,2 @@\n-old\n+new\n tail\n" + ).first) + let old = "let a = 1\nold\n\ttail\n" + let new = "let a=1\nnew\ntail\n" + #expect(throws: T3Error.self) { + try DiffContext(file, oldContents: old, newContents: new, ignoreWhitespace: false) + } + let context = try DiffContext(file, oldContents: old, newContents: new, ignoreWhitespace: true) + let full = context.render(expanded: [0: 1]).file + #expect(full.lines.first?.text == "let a=1" && full.lines.first?.oldText == "let a = 1") + #expect(full.lines.last?.text == "tail" && full.lines.last?.oldText == "\ttail") + #expect(throws: T3Error.self) { + try DiffContext( + file, oldContents: old, + newContents: new.replacingOccurrences(of: "let a", with: "let\u{00A0}a"), + ignoreWhitespace: true) + } +} + +@Test func contextExpansionUnderstandsNoFinalNewlineAndBoundsFullFilePresentation() throws { + let file = try #require( + UnifiedDiff.parse( + "diff --git a/a b/a\n@@ -1 +1 @@\n-before\n+after\n\\ No newline at end of file\n" + ).first) + let context = try DiffContext( + file, oldContents: "before\n", newContents: "after", ignoreWhitespace: false) + #expect(context.gaps.isEmpty) + #expect(throws: T3Error.self) { + try DiffContext(file, oldContents: "before\n", newContents: "after\n", ignoreWhitespace: false) + } + let mode = try #require( + UnifiedDiff.parse("diff --git a/a b/a\nold mode 100644\nnew mode 100755\n").first) + let contents = String(repeating: "same\n", count: 12_000) + let huge = try DiffContext( + mode, oldContents: contents, newContents: contents, ignoreWhitespace: false) + let rendered = huge.render(expanded: [0: 12_000]) + #expect(rendered.limited && rendered.file.lines.count <= 10_000) + #expect(throws: T3Error.self) { + try DiffContext( + mode, oldContents: String(repeating: "a", count: 1_000_001), newContents: "", + ignoreWhitespace: false) + } +} diff --git a/apps/macos/Tests/T3CoreTests/DraftRepositoryTests.swift b/apps/macos/Tests/T3CoreTests/DraftRepositoryTests.swift new file mode 100644 index 0000000000000..e5a02b6174989 --- /dev/null +++ b/apps/macos/Tests/T3CoreTests/DraftRepositoryTests.swift @@ -0,0 +1,47 @@ +import Foundation +import Testing + +@testable import T3Core + +@Test func draftsAndRetryIdentitySurviveRestartWithoutCrossingEnvironments() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let repository = DraftRepository(directory: directory) + let local = URL(string: "http://127.0.0.1:4773")! + let other = URL(string: "http://127.0.0.1:3773")! + var answer = QuestionDraft() + answer.selected = ["first", " second "] + let pending = PendingSubmission(text: "A possibly accepted message") + let saved = SavedDrafts( + messages: ["same-thread-id": pending.text], submissions: ["same-thread-id": pending], + questions: ["request": ["question": answer]]) + try repository.save(saved, for: local) + let reopened = DraftRepository(directory: directory) + #expect(try reopened.load(for: local) == saved) + #expect(try reopened.load(for: other) == SavedDrafts()) + let permission = + try FileManager.default.attributesOfItem(atPath: repository.file(for: local).path)[ + .posixPermissions] as? Int + #expect(permission == 0o600) + let directoryPermission = + try FileManager.default.attributesOfItem(atPath: directory.path)[.posixPermissions] as? Int + #expect(directoryPermission == 0o700) + try repository.save(SavedDrafts(), for: local) + #expect(try reopened.load(for: local).messages.isEmpty) + #expect(try reopened.load(for: local).submissions.isEmpty) +} + +@Test func malformedDraftFileIsPreservedForRecovery() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let repository = DraftRepository(directory: directory) + let endpoint = URL(string: "https://example.test")! + try repository.save(SavedDrafts(), for: endpoint) + let data = Data("{unfinished".utf8) + try data.write(to: repository.file(for: endpoint)) + #expect(throws: (any Error).self) { try repository.load(for: endpoint) } + #expect(try Data(contentsOf: repository.file(for: endpoint)) == data) + #expect( + repository.file(for: endpoint) + == repository.file(for: URL(string: "https://EXAMPLE.test:443/")!)) +} diff --git a/apps/macos/Tests/T3CoreTests/FileNavigationTests.swift b/apps/macos/Tests/T3CoreTests/FileNavigationTests.swift new file mode 100644 index 0000000000000..a8cd9a34f94fc --- /dev/null +++ b/apps/macos/Tests/T3CoreTests/FileNavigationTests.swift @@ -0,0 +1,103 @@ +import Foundation +import Testing + +@testable import T3Core + +@Test func fileNavigationPreservesLocationsWithoutSendingThemAsFilenames() throws { + for (destination, expectedPath, line, column) in [ + ("source.swift:42:7", "docs/source.swift", 42, 7), + ("../src/source.swift#L42C7", "src/source.swift", 42, 7), + ("file://localhost/repo/src/source.swift#l42c7", "src/source.swift", 42, 7), + ("../src/source.swift:42:7#L99C3", "src/source.swift", 42, 7), + ("<../src/My%20File.swift:42:7>", "src/My File.swift", 42, 7), + ("/tmp/external.swift#L42C7", "/tmp/external.swift", 42, 7), + ("#L42C7", "docs/readme.md", 42, 7), + ] { + let url = try #require( + WorkspaceMarkdown.link(destination, filePath: "docs/readme.md", root: "/repo")) + let link = try #require(WorkspaceFileLink(url: url, root: "/repo")) + #expect(link.path == expectedPath) + #expect(link.position == FileSourcePosition(line: line, column: column)) + } + let conversation = try #require( + WorkspaceMarkdown.link("src/main.swift:8", filePath: "", root: "/repo")) + let link = try #require(WorkspaceFileLink(url: conversation, root: "/repo")) + #expect(link.path == "src/main.swift") + #expect(link.position == FileSourcePosition(line: 8)) + let encoded = try #require( + WorkspaceMarkdown.link("file:///repo/100%2520%23.md#L2", filePath: "", root: "/repo")) + #expect(WorkspaceFileLink(url: encoded, root: "/repo")?.path == "100%20#.md") + let zero = try #require( + WorkspaceMarkdown.link("source.swift:0:0", filePath: "", root: "/repo")) + #expect(WorkspaceFileLink(url: zero, root: "/repo")?.path == "source.swift") + #expect(WorkspaceFileLink(url: zero, root: "/repo")?.position == nil) + for unsafe in ["javascript:1", "data:text/html,bad", "t3-markdown-task://toggle/1"] { + #expect(WorkspaceMarkdown.link(unsafe, filePath: "", root: "/repo") == nil) + } + #expect(WorkspaceMarkdown.link("src/main.swift:8", filePath: "", root: nil) == nil) + #expect( + WorkspaceMarkdown.link("https://example.test", filePath: "", root: nil)?.scheme == "https") +} + +@Test func sourceLocationsClampLinesAndColumnsWithoutSplittingUnicodeOrCRLF() { + for ending in ["\n", "\r\n", "\r"] { + let source = "first" + ending + "🦉 cafe\u{301}" + ending + "last" + let text = source as NSString + let start = text.range(of: "🦉").location + let last = text.range(of: "last") + #expect(FileSourcePosition(line: 2).range(in: source) == NSRange(location: start, length: 8)) + #expect(FileSourcePosition(line: 2, column: 2).range(in: source).location == start) + #expect(FileSourcePosition(line: 2, column: 3).range(in: source).location == start + 2) + #expect(FileSourcePosition(line: 2, column: 8).range(in: source).location == start + 6) + #expect(FileSourcePosition(line: 2, column: Int.max).range(in: source).location == start + 8) + #expect(FileSourcePosition(line: Int.max).range(in: source) == last) + #expect( + FileSourcePosition(line: Int.max).range(in: source + ending).location == text.length + + (ending as NSString).length) + } + #expect( + FileSourcePosition(line: Int.max, column: Int.max).range(in: "") + == NSRange(location: 0, length: 0)) + #expect(FileSourcePosition(line: 1).range(in: "\r\n\n") == NSRange(location: 0, length: 0)) + #expect(FileSourcePosition(line: 2).range(in: "\r\n\n") == NSRange(location: 2, length: 0)) +} + +@Test func folderFailuresRemainStructuredThroughTheRPCEnvelope() throws { + for failure in ["path_not_file", "binary_file", "operation_failed"] { + let value: JSON = .object([ + "_tag": .string("Failure"), + "cause": .object([ + "reasons": .array([ + .object([ + "_tag": .string("Fail"), + "error": .object([ + "_tag": .string("ProjectReadFileError"), "failure": .string(failure), + "message": .string("Cannot read the requested path"), + ]), + ]) + ]) + ]), + ]) + let error = RPCConnection.requestError(value) + #expect(error.remoteCode == "ProjectReadFileError") + #expect(error.remoteFailure == failure) + #expect(error.localizedDescription == "Cannot read the requested path") + } + let unknown = RPCConnection.requestError(.object(["message": .string("Connection lost")])) + #expect(unknown.remoteCode == nil) + #expect(unknown.remoteFailure == nil) + #expect(unknown.localizedDescription == "Connection lost") + #expect( + WorkspaceTreeNavigation.directories(for: "docs/nested/readme.md", isDirectory: false) == [ + "", "docs", "docs/nested", + ]) + #expect( + WorkspaceTreeNavigation.directories(for: "docs/nested/", isDirectory: true) == [ + "", "docs", "docs/nested", + ]) + #expect(WorkspaceTreeNavigation.directories(for: "../outside", isDirectory: true).isEmpty) + #expect(WorkspaceTreeNavigation.directories(for: "/tmp/report", isDirectory: true).isEmpty) + let root = try #require(WorkspaceMarkdown.link("..", filePath: "docs/readme.md", root: "/repo")) + #expect(WorkspaceMarkdown.workspacePath(root, root: "/repo") == ".") + #expect(WorkspaceTreeNavigation.directories(for: ".", isDirectory: true) == [""]) +} diff --git a/apps/macos/Tests/T3CoreTests/FileOpenTests.swift b/apps/macos/Tests/T3CoreTests/FileOpenTests.swift new file mode 100644 index 0000000000000..98fb88a94ab75 --- /dev/null +++ b/apps/macos/Tests/T3CoreTests/FileOpenTests.swift @@ -0,0 +1,76 @@ +import Foundation +import Testing + +@testable import T3Core + +@Test func fileActionsFollowServerCapabilitiesAndPreserveTheHostsFilePosition() throws { + let capabilities = FileOpenCapabilities( + .object([ + "availableEditors": .array( + ["vscode", "future-editor", "file-manager", "vscode"].map(JSON.string)), + "shellRevealInFileManager": .bool(true), + "shellRevealInFileManagerKind": .string("finder"), + ])) + #expect(capabilities.editors == ["vscode", "file-manager"]) + #expect(capabilities.revealTitle == "Reveal in Finder") + let position = FileSourcePosition(line: 42, column: 7) + let editor = try capabilities.launchInput( + path: "src/My File.swift", root: "/Volumes/Remote Workspace", editor: "vscode", + position: position) + #expect(editor["cwd"].string == "/Volumes/Remote Workspace/src/My File.swift:42:7") + #expect(editor["editor"].string == "vscode") + #expect(editor["reveal"] == .null) + let reveal = try capabilities.launchInput( + path: "src/My File.swift", root: "/Volumes/Remote Workspace", editor: "file-manager", + reveal: true, position: position) + #expect(reveal["cwd"].string == "/Volumes/Remote Workspace/src/My File.swift") + #expect(reveal["reveal"] == .bool(true)) + let hostFile = try capabilities.launchInput( + path: "/tmp/report.md", root: "/repo", editor: "file-manager") + #expect(hostFile["cwd"].string == "/tmp/report.md") + #expect(throws: T3Error.self) { + try capabilities.launchInput(path: "a.swift", root: "/repo", editor: "future-editor") + } + #expect(throws: T3Error.self) { + try capabilities.launchInput(path: "a.swift", root: "/repo", editor: "vscode", reveal: true) + } +} + +@Test func configurationRefreshRemovesUnavailableFileActionsWithoutAssumingFinder() throws { + var catalog = ProviderCatalog() + let config: JSON = .object([ + "availableEditors": .array([.string("file-manager"), .string("zed")]), + "environment": .object(["platform": .object(["os": .string("darwin")])]), + ]) + try catalog.apply(.object(["type": .string("snapshot"), "config": config])) + #expect(catalog.fileOpen.canOpenDefault) + #expect(catalog.fileOpen.revealTitle == nil) + #expect(throws: T3Error.self) { + try catalog.fileOpen.launchInput( + path: "a.swift", root: "/repo", editor: "file-manager", reveal: true) + } + try catalog.apply(.object(["type": .string("providerStatuses"), "payload": .object([:])])) + #expect(catalog.fileOpen.editors == ["file-manager", "zed"]) + try catalog.apply( + .object([ + "type": .string("snapshot"), + "config": .object([ + "availableEditors": .array([.string("zed")]), "shellRevealInFileManager": .bool(true), + ]), + ])) + #expect(!catalog.fileOpen.canOpenDefault) + #expect(catalog.fileOpen.revealTitle == nil) + #expect(throws: T3Error.self) { + try catalog.fileOpen.launchInput(path: "a.swift", root: "/repo", editor: "file-manager") + } + try catalog.apply(.object(["type": .string("snapshot"), "config": .object([:])])) + #expect(catalog.fileOpen.editors.isEmpty) + let remote = FileOpenCapabilities( + .object([ + "availableEditors": .array([.string("file-manager")]), + "shellRevealInFileManager": .bool(true), + "shellRevealInFileManagerKind": .string("file-explorer"), + "environment": .object(["platform": .object(["os": .string("linux")])]), + ])) + #expect(remote.revealTitle == "Reveal in File Explorer") +} diff --git a/apps/macos/Tests/T3CoreTests/FilePreviewTests.swift b/apps/macos/Tests/T3CoreTests/FilePreviewTests.swift new file mode 100644 index 0000000000000..db546a68f2fe2 --- /dev/null +++ b/apps/macos/Tests/T3CoreTests/FilePreviewTests.swift @@ -0,0 +1,101 @@ +import Foundation +import Testing + +@testable import T3Core + +@Test func delimitedPreviewPreservesQuotedCellsAndEmptyRows() { + let source = "\u{feff}Name,Note\r\n\"🦉, café\",\"First\r\nSecond \"\"quoted\"\"\"\r\n,\r\n\"\"" + let preview = DelimitedFilePreview(source, delimiter: 44) + #expect( + preview.rows == [ + ["Name", "Note"], ["🦉, café", "First\r\nSecond \"quoted\""], ["", ""], [""], + ]) + #expect(!preview.truncated) + #expect(DelimitedFilePreview("", delimiter: 44).rows.isEmpty) + #expect(DelimitedFilePreview("\u{feff}", delimiter: 44).rows.isEmpty) + #expect( + DelimitedFilePreview("A\tB\n1\t\"two\tthree\"\n", delimiter: 9).rows == [ + ["A", "B"], ["1", "two\tthree"], + ]) + let incomplete = DelimitedFilePreview("A,B\n1,\"unfinished", delimiter: 44) + #expect(incomplete.rows == [["A", "B"], ["1", "unfinished"]]) + #expect(incomplete.truncated) +} + +@Test func tablePreviewBoundsDoNotLoseQuotedDelimitersOrLaterCells() { + let exactRows = String(repeating: "a,b\r\n", count: 100) + #expect(!DelimitedFilePreview(exactRows, delimiter: 44).truncated) + let overflow = DelimitedFilePreview(exactRows + "hidden,row", delimiter: 44) + #expect(overflow.rows.count == 100) + #expect(overflow.truncated) + let columns = DelimitedFilePreview( + (0...30).map(String.init).joined(separator: ","), delimiter: 44) + #expect(columns.rows[0].count == 30) + #expect(columns.rows[0].last == "29") + #expect(columns.truncated) + let long = String(repeating: "🦉", count: 1000) + #expect(!DelimitedFilePreview(long, delimiter: 44).truncated) + let clipped = DelimitedFilePreview("\"" + long + ",inside\",after\nnext,row", delimiter: 44) + #expect(clipped.rows == [[long, "after"], ["next", "row"]]) + #expect(clipped.truncated) +} + +@Test func checkingMarkdownChangesOnlyTheRequestedUTF16Marker() { + let source = "# 🦉 café\r\n\r\n- [ ] first\r\n - [X] second\r\n" + let first = (source as NSString).range(of: "[ ]").location + let second = (source as NSString).range(of: "[X]").location + let checked = WorkspaceMarkdown.settingTask(source, offset: first, checked: true) + #expect(checked == source.replacingOccurrences(of: "[ ]", with: "[x]")) + #expect(WorkspaceMarkdown.settingTask(checked, offset: first, checked: false) == source) + #expect( + WorkspaceMarkdown.settingTask(source, offset: second, checked: false) + == source.replacingOccurrences(of: "[X]", with: "[ ]")) + for offset in [-1, 0, first + 1, Int.max] { + #expect(WorkspaceMarkdown.settingTask(source, offset: offset, checked: true) == source) + } +} + +@Test func workspaceLinksUseTheDocumentsHostDirectoryAndKeepWorkspaceBoundaries() throws { + let relative = try #require( + WorkspaceMarkdown.link("../src/My%20File.swift#L12", filePath: "docs/readme.md", root: "/repo")) + #expect(relative.path == "/repo/src/My File.swift") + #expect(relative.fragment == "L12") + #expect(WorkspaceMarkdown.workspacePath(relative, root: "/repo") == "src/My File.swift") + let outside = try #require( + WorkspaceMarkdown.link("appendix.md", filePath: "/tmp/report/readme.md", root: "/repo")) + #expect(WorkspaceMarkdown.workspacePath(outside, root: "/repo") == "/tmp/report/appendix.md") + let sibling = try #require( + WorkspaceMarkdown.link("/repo-other/notes.md", filePath: "readme.md", root: "/repo")) + #expect(WorkspaceMarkdown.workspacePath(sibling, root: "/repo") == "/repo-other/notes.md") + #expect(WorkspaceMarkdown.workspacePath(sibling, root: "/") == "repo-other/notes.md") + let encoded = try #require( + WorkspaceMarkdown.link("100%25%23%20done.md", filePath: "docs/readme.md", root: "/repo")) + #expect(encoded.path == "/repo/docs/100%# done.md") + #expect( + WorkspaceMarkdown.link("https://example.test/docs", filePath: "readme.md", root: "/repo")? + .scheme == "https") + for unsafe in [ + "javascript:alert(1)", "data:text/html,bad", "t3-markdown-task://toggle/1", + "//another-host/file", "#section", + ] { + #expect(WorkspaceMarkdown.link(unsafe, filePath: "readme.md", root: "/repo") == nil) + } + #expect( + WorkspaceMarkdown.workspacePath(URL(string: "file://another-host/private")!, root: "/repo") + == nil) +} + +@Test func filePreviewPreferencesPersistSeparatelyForMarkdownAndTables() throws { + let name = "T3FilePreviewTests-" + UUID().uuidString + let defaults = try #require(UserDefaults(suiteName: name)) + defer { defaults.removePersistentDomain(forName: name) } + let preferences = FilePreviewPreferences(defaults: defaults) + #expect(!preferences.rendered(.markdown)) + #expect(preferences.rendered(.csv)) + preferences.setRendered(true, for: .markdown) + preferences.setRendered(false, for: .tsv) + let restored = FilePreviewPreferences(defaults: try #require(UserDefaults(suiteName: name))) + #expect(restored.rendered(.markdown)) + #expect(!restored.rendered(.csv)) + #expect(!restored.rendered(.tsv)) +} diff --git a/apps/macos/Tests/T3CoreTests/FileSaveScheduleTests.swift b/apps/macos/Tests/T3CoreTests/FileSaveScheduleTests.swift new file mode 100644 index 0000000000000..9fbfbfc243114 --- /dev/null +++ b/apps/macos/Tests/T3CoreTests/FileSaveScheduleTests.swift @@ -0,0 +1,67 @@ +import Foundation +import Testing + +@testable import T3Core + +@Test func autosaveDebouncesAndSerializesEditsIncludingARevert() throws { + var schedule = FileSaveSchedule() + schedule.change(at: 10) + schedule.change(at: 10.3) + let blocked1 = schedule.begin(at: 10.799) + #expect(blocked1 == nil) + let firstAttempt = schedule.begin(at: 10.8) + let first = try #require(firstAttempt) + schedule.change(at: 10.9) + let blocked2 = schedule.begin(at: 12) + #expect(blocked2 == nil) + schedule.complete(first, succeeded: true, at: 12) + #expect(schedule.pending) + let revertedAttempt = schedule.begin(at: 12) + let reverted = try #require(revertedAttempt) + schedule.complete(reverted, succeeded: true, at: 12.1) + #expect(!schedule.pending) + schedule.flush(at: 12.2) + let blocked3 = schedule.begin(at: 13) + #expect(blocked3 == nil) +} + +@Test func closingFlushesNewEditsWithoutConcurrentWritesOrRetryLoops() throws { + var schedule = FileSaveSchedule() + schedule.change(at: 1) + schedule.flush(at: 1.1) + let firstAttempt = schedule.begin(at: 1.1) + let first = try #require(firstAttempt) + schedule.change(at: 1.2) + schedule.flush(at: 1.3) + let blocked4 = schedule.begin(at: 1.3) + #expect(blocked4 == nil) + schedule.complete(first, succeeded: true, at: 1.4) + let latestAttempt = schedule.begin(at: 1.4) + let latest = try #require(latestAttempt) + schedule.complete(latest, succeeded: false, at: 1.5) + #expect(schedule.pending) + let blocked5 = schedule.begin(at: 100) + #expect(blocked5 == nil) + schedule.flush(at: 100) + let retriedAttempt = schedule.begin(at: 100) + let retried = try #require(retriedAttempt) + schedule.complete(retried, succeeded: true, at: 100.1) + #expect(!schedule.pending) +} + +@Test func aNewEditAfterFailureGetsItsOwnDebounce() throws { + var schedule = FileSaveSchedule() + schedule.change(at: 1) + let firstAttempt = schedule.begin(at: 1.5) + let first = try #require(firstAttempt) + schedule.change(at: 2) + schedule.complete(first, succeeded: false, at: 2.1) + let blocked6 = schedule.begin(at: 2.49) + #expect(blocked6 == nil) + let latestAttempt = schedule.begin(at: 2.5) + let latest = try #require(latestAttempt) + schedule.complete(first, succeeded: true, at: 2.6) + #expect(schedule.inFlight == latest) + schedule.complete(latest, succeeded: true, at: 2.7) + #expect(!schedule.pending) +} diff --git a/apps/macos/Tests/T3CoreTests/InteractionTests.swift b/apps/macos/Tests/T3CoreTests/InteractionTests.swift new file mode 100644 index 0000000000000..b5d366da99fd7 --- /dev/null +++ b/apps/macos/Tests/T3CoreTests/InteractionTests.swift @@ -0,0 +1,292 @@ +import Foundation +import Testing + +@testable import T3Core + +private func decode(_ value: String) throws -> JSON { try JSON.decode(Data(value.utf8)) } + +private func request( + _ status: String = "pending", capability: String = "live", kind: String = "command" +) -> JSON { + .object([ + "id": .string("r"), "kind": .string(kind), "status": .string(status), + "responseCapability": .object(["type": .string(capability)]), + "createdAt": .string("2026-10-11T00:00:00Z"), + ]) +} + +private func requestItem(_ kind: String = "approval_request") -> JSON { + .object([ + "id": .string("item"), "type": .string(kind), "threadId": .string("t"), + "requestId": .string("r"), "ordinal": .number(0), "prompt": .string("echo check"), + "questions": .array([ + .object(["id": .string("q"), "question": .string("Choose a color"), "options": .array([])]) + ]), + ]) +} + +private func snapshot(requests: [JSON], items: [JSON] = []) -> JSON { + .object([ + "kind": .string("snapshot"), "snapshotSequence": .number(5), + "projection": .object([ + "thread": .object(["id": .string("t")]), "runtimeRequests": .array(requests), + "turnItems": .array(items), + ]), + ]) +} + +private func event(_ type: String, _ payload: JSON, _ sequence: Int) -> JSON { + .object([ + "kind": .string("event"), "sequence": .number(Double(sequence)), + "event": .object(["type": .string(type), "payload": payload]), + ]) +} + +@Test func approvalFollowsAuthoritativeRuntimeStateAcrossClients() throws { + var thread = ThreadState(id: "t") + _ = try thread.apply(snapshot(requests: [request()], items: [requestItem()])) + #expect(thread.pendingRequests.count == 1) + #expect(thread.pendingRequests[0].detail == "echo check") + #expect(thread.pendingRequests[0].canRespond) + _ = try thread.apply(event("runtime-request.updated", request("resolved"), 6)) + _ = try thread.apply(event("runtime-request.updated", request(), 5)) + #expect(thread.pendingRequests.isEmpty) + #expect(thread.rows.count == 1) +} + +@Test func requestAndDisplayEventsMayArriveInEitherOrder() throws { + for itemFirst in [true, false] { + var thread = ThreadState(id: "t") + _ = try thread.apply(snapshot(requests: [])) + let values = [ + ("turn-item.updated", requestItem("user_input_request")), + ("runtime-request.updated", request(kind: "user_input")), + ] + for (index, value) in (itemFirst ? values : values.reversed()).enumerated() { + _ = try thread.apply(event(value.0, value.1, index + 6)) + if index == 0 { #expect(thread.pendingRequests.isEmpty) } + } + #expect(thread.pendingRequests.count == 1) + #expect(thread.pendingRequests[0].questions.count == 1) + } +} + +@Test func answeredQuestionHistorySurvivesEventOrderAndReconnect() throws { + let answered = try decode( + """ + {"id":"r","kind":"user_input","status":"resolved", + "answers":{"q":{"answers":["Blue","",{"answers":["Green"]}]}}} + """) + let item = requestItem("user_input_request") + for itemFirst in [true, false] { + var thread = ThreadState(id: "t") + _ = try thread.apply(snapshot(requests: [])) + let values = [("turn-item.updated", item), ("runtime-request.updated", answered)] + for (index, value) in (itemFirst ? values : values.reversed()).enumerated() { + _ = try thread.apply(event(value.0, value.1, index + 6)) + } + #expect(thread.rows[0].text == "Choose a color\nBlue, Green") + #expect(thread.rows[0].title == "Answered questions") + #expect(thread.rows[0].item == item) + _ = try thread.apply(event("runtime-request.updated", request(kind: "user_input"), 6)) + #expect(thread.rows[0].text == "Choose a color\nBlue, Green") + _ = try thread.apply(event("turn-item.updated", item, 8)) + #expect(thread.rows[0].text == "Choose a color\nBlue, Green") + _ = try thread.apply(snapshot(requests: [answered], items: [item])) + #expect(thread.rows[0].text == "Choose a color\nBlue, Green") + #expect(thread.pendingRequests.isEmpty) + } +} + +@Test func pagedQuestionAnswersStayWithinTheirSourceThread() throws { + let answered = try decode( + """ + {"id":"r","kind":"user_input","status":"resolved","answers":{"q":"Blue"}} + """) + let item = requestItem("user_input_request") + let rows: [JSON] = ["parent", "t"].enumerated().map { index, source in + .object([ + "item": item, "position": .number(Double(index)), "sourceThreadId": .string(source), + "visibility": .string(source == "t" ? "own" : "inherited"), + ]) + } + var thread = ThreadState(id: "t") + _ = try thread.apply(snapshot(requests: [answered])) + try thread.prependHistory(.object(["items": .array(rows)])) + #expect(thread.rows.map(\.text) == ["Choose a color", "Choose a color\nBlue"]) + _ = try thread.apply(event("runtime-request.updated", answered, 6)) + #expect(thread.rows.map(\.text) == ["Choose a color", "Choose a color\nBlue"]) + _ = try thread.apply( + .object([ + "kind": .string("snapshot"), "snapshotSequence": .number(7), + "projection": .object([ + "thread": .object(["id": .string("t")]), "runtimeRequests": .array([answered]), + "visibleTurnItems": .array(rows), + ]), + ])) + #expect(thread.rows.map(\.text) == ["Choose a color", "Choose a color\nBlue"]) + for status in ["pending", "expired", "cancelled"] { + let unresolved = try decode( + """ + {"id":"r","kind":"user_input","status":"\(status)","answers":{"q":"Blue"}} + """) + let row = try TimelineRow(item: item, position: 0, sourceID: "t", request: unresolved) + #expect(row.text == "Choose a color") + #expect(row.title == "Question") + } +} + +@Test func recordedQuestionAnswerAndFilesTakePrecedenceOverLegacyRequest() throws { + let item = try decode( + """ + {"id":"item","type":"user_input_request","requestId":"r", + "questions":[{"id":"q","question":"Which file?"}], + "questionAnswer":{"answers":{"q":"Use this file"}, + "attachmentsByQuestionId":{"q":[{"id":"file","name":"notes.txt"}]}}} + """) + let answered = try decode( + """ + {"id":"r","kind":"user_input","status":"resolved","answers":{"q":"Legacy answer"}} + """) + let row = try TimelineRow(item: item, position: 0, sourceID: "t", request: answered) + #expect(row.text == "Which file?\nUse this file") + #expect(row.attachments.first?["name"].string == "notes.txt") +} + +@Test func stoppedSessionCannotApproveButMessageQuestionsRemainAnswerable() throws { + let expired = try PendingRequest( + request: request(capability: "not_resumable"), item: requestItem()) + #expect(!expired.canRespond) + let message = try PendingRequest( + request: request(capability: "message", kind: "user_input"), + item: requestItem("user_input_request")) + #expect(message.canRespond) + #expect(message.dismissible) + let approval = try PendingRequest(request: request(capability: "message"), item: requestItem()) + #expect(!approval.canRespond) +} + +@Test func providerApprovalChoicesAreNotExpandedByClientDefaults() throws { + let item = try decode( + """ + {"options":[{"decision":"decline","label":"Deny","warning":"Untrusted content"}]} + """) + let pending = try PendingRequest(request: request(), item: item) + #expect(pending.options.count == 1) + #expect(pending.options[0]["warning"].string == "Untrusted content") + #expect(!pending.options.contains { $0["decision"].string == "accept" }) + let noChoices = try PendingRequest(request: request(), item: decode("{\"options\":[]}")) + #expect(noChoices.options.isEmpty) +} + +@Test func inheritedApprovalDoesNotSupplyDetailsForLocalRequestWithSameID() throws { + var thread = ThreadState(id: "t") + let value: JSON = .object([ + "kind": .string("snapshot"), "snapshotSequence": .number(5), + "projection": .object([ + "thread": .object(["id": .string("t")]), "runtimeRequests": .array([request()]), + "visibleTurnItems": .array([ + .object([ + "item": requestItem(), "position": .number(0), "sourceThreadId": .string("parent"), + "visibility": .string("inherited"), + ]) + ]), + ]), + ]) + _ = try thread.apply(value) + #expect(thread.pendingRequests[0].detail == "Command approval") +} + +@Test func reconnectReplacesPendingRequestsAndBoundedRowsDoNotHideThem() throws { + var thread = ThreadState(id: "t") + _ = try thread.apply(snapshot(requests: [request()], items: [requestItem()])) + _ = try thread.apply(snapshot(requests: [])) + #expect(thread.pendingRequests.isEmpty) + _ = try thread.apply(snapshot(requests: [request("expired")], items: [requestItem()])) + #expect(thread.pendingRequests.isEmpty) +} + +@Test func answerValuesRemainExactAndInvalidSelectionsCannotBeSubmitted() throws { + let question = try decode( + """ + {"id":"q","multiSelect":true,"options":[{"value":" A ","label":"First"},{"value":"","label":"Empty"}]} + """) + var draft = QuestionDraft() + draft.selected = [" A ", " A ", "missing", ""] + #expect(draft.answer(for: question) == .array([.string(" A "), .string("")])) + draft.custom = " custom answer \n" + #expect(draft.answer(for: question) == .string("custom answer")) + let restricted = try decode("{\"id\":\"q\",\"allowCustomAnswer\":false,\"options\":[]}") + #expect(draft.answer(for: restricted) == nil) + let pending = try PendingRequest( + request: request(kind: "user_input"), item: requestItem("user_input_request")) + #expect(pending.answers(from: [:]) == nil) + #expect(pending.answers(from: ["q": draft]) == .object(["q": .string("custom answer")])) +} + +@Test func modelChangeOnlyPreservesOptionsSupportedByTheNewModel() throws { + let model = try ProviderModel( + decode( + """ + {"slug":"new","name":"New","capabilities":{"optionDescriptors":[ + {"id":"effort","type":"select","options":[{"id":"high","label":"High","isDefault":true}]}, + {"id":"fast","type":"boolean"} + ]}} + """)) + let old = try decode( + """ + {"options":[{"id":"effort","value":"ultra"},{"id":"fast","value":true},{"id":"unsupported","value":"yes"}]} + """) + let selection = model.selection(instanceID: "custom-instance", preserving: old) + #expect(selection["instanceId"].string == "custom-instance") + #expect( + selection["options"].array == [ + .object(["id": .string("effort"), "value": .string("high")]), + .object(["id": .string("fast"), "value": .bool(true)]), + ]) +} + +@Test func catalogTracksProviderInstancesAndPlanSettingIndependently() throws { + let provider = try decode( + """ + {"instanceId":"custom","displayName":"Work account","enabled":true,"installed":true,"models":[ + {"slug":"model","capabilities":{"optionDescriptors":[ + {"id":"agent","type":"select","options":[{"id":"build"},{"id":"plan"}]} + ]}} + ]} + """) + var catalog = ProviderCatalog() + try catalog.apply( + .object(["type": .string("snapshot"), "config": .object(["providers": .array([provider])])])) + let selection: JSON = .object(["instanceId": .string("custom"), "model": .string("model")]) + #expect(catalog.provider(for: selection)?.available == true) + #expect(catalog.descriptors(for: selection)[0]["options"].array.count == 1) + let normalized = catalog.model(for: selection)?.selection( + instanceID: "custom", + preserving: .object([ + "options": .array([.object(["id": .string("agent"), "value": .string("plan")])]) + ]), planModeEnabled: false) + #expect( + normalized?["options"].array == [.object(["id": .string("agent"), "value": .string("build")])]) + catalog.setPlanModeEnabled(true) + try catalog.apply( + .object([ + "type": .string("settingsUpdated"), + "payload": .object([ + "settings": .object([ + "defaultRuntimeMode": .string("full-access"), + "projectSettingsOverrides": .object([ + "restricted": .object(["defaultRuntimeMode": .string("approval-required")]) + ]), + ]) + ]), + ])) + #expect(catalog.planModeEnabled) + #expect(catalog.defaultRuntimeMode(projectID: "restricted") == "approval-required") + #expect(catalog.defaultRuntimeMode(projectID: "other") == "full-access") + #expect(catalog.descriptors(for: selection)[0]["options"].array.count == 2) + #expect(catalog.providers.count == 1) + try catalog.apply( + .object(["type": .string("providerStatuses"), "payload": .object(["providers": .array([])])])) + #expect(catalog.providers.isEmpty) +} diff --git a/apps/macos/Tests/T3CoreTests/ModelSearchTests.swift b/apps/macos/Tests/T3CoreTests/ModelSearchTests.swift new file mode 100644 index 0000000000000..1f34fe4858ba7 --- /dev/null +++ b/apps/macos/Tests/T3CoreTests/ModelSearchTests.swift @@ -0,0 +1,127 @@ +import Foundation +import Testing + +@testable import T3Core + +private func searchCatalog() throws -> ProviderCatalog { + var catalog = ProviderCatalog() + try catalog.apply( + JSON.decode( + Data( + """ + {"type":"snapshot","config":{"providers":[ + {"instanceId":"personal","driver":"codex","displayName":"Codex Personal","enabled":true,"installed":true,"models":[ + {"slug":"gpt-new","name":"GPT New","shortName":"New","isCustom":false}, + {"slug":"gpt-old","name":"GPT Old","isLegacy":true,"isCustom":false}]}, + {"instanceId":"work","driver":"codex","displayName":"Codex Work","enabled":true,"installed":true,"models":[ + {"slug":"gpt-new","name":"GPT New","isCustom":false}]}, + {"instanceId":"proxy","driver":"opencode","displayName":"OpenCode","enabled":true,"installed":true,"models":[ + {"slug":"claude-opus","name":"Claude Opus 4.7","subProvider":"GitHub Copilot","isCustom":true}]}, + {"instanceId":"unavailable","driver":"cursor","displayName":"Cursor Offline","enabled":false,"installed":false,"models":[ + {"slug":"opus","name":"Opus 4.7","isCustom":false}]}]}} + """.utf8))) + return catalog +} + +@Test func modelSearchMatchesTokensAcrossAccountDriverAndModelFields() throws { + let search = ModelSearch(catalog: try searchCatalog()) + let matches = search.results(query: " PERSONAL new ", instanceID: "work") + #expect(matches.map(\.id) == [ModelIdentity(instanceID: "personal", modelID: "gpt-new")]) + #expect( + search.results(query: "coplt op", instanceID: "personal").map(\.model.id) == ["claude-opus"]) + #expect(search.results(query: "personal opus", instanceID: nil).isEmpty) + #expect(search.results(query: "missing", instanceID: nil).isEmpty) + #expect( + search.results(query: "gpt", instanceID: nil).map(\.id) == [ + ModelIdentity(instanceID: "work", modelID: "gpt-new"), + ModelIdentity(instanceID: "personal", modelID: "gpt-new"), + ModelIdentity(instanceID: "personal", modelID: "gpt-old"), + ModelIdentity(instanceID: "proxy", modelID: "claude-opus"), + ]) +} + +@Test func modelFavoritesStayAccountScopedAndSearchCanLeaveFavorites() throws { + let search = ModelSearch(catalog: try searchCatalog()) + let favorite = ModelIdentity(instanceID: "work", modelID: "gpt-new") + let favorites: Set = [favorite] + #expect( + search.results(query: "", instanceID: nil, favoritesOnly: true, favorites: favorites).map(\.id) + == [favorite]) + let other = search.results( + query: "personal", instanceID: nil, favoritesOnly: true, favorites: favorites) + #expect(other.count == 2) + #expect(other.allSatisfy { $0.provider.id == "personal" }) + #expect(search.results(query: "new", instanceID: nil, favorites: favorites).first?.id == favorite) +} + +@Test func favoritesBoostDoesNotOutrankAnExactModelName() throws { + let search = ModelSearch(catalog: try searchCatalog()) + let favorite = ModelIdentity(instanceID: "proxy", modelID: "claude-opus") + let exact = search.results(query: "opus 4.7", instanceID: nil, favorites: [favorite]) + #expect(exact.first?.provider.id == "unavailable") + #expect(exact.first?.provider.available == false) + #expect( + search.results(query: "opu", instanceID: nil, favorites: [favorite]).first?.id == favorite) +} + +@Test func browsingRetainsServerOrderAndKeepsLegacyAndCustomModelsDiscoverable() throws { + let catalog = try searchCatalog() + let search = ModelSearch(catalog: catalog) + #expect( + search.results(query: "", instanceID: "personal").map(\.model.id) == ["gpt-new", "gpt-old"]) + #expect(search.results(query: "old", instanceID: "work").first?.model.legacy == true) + #expect(search.results(query: "copilot", instanceID: nil).first?.model.custom == true) + #expect(search.results(query: "", instanceID: "gone").isEmpty) + var removed = ProviderCatalog() + try removed.apply( + .object(["type": .string("providerStatuses"), "payload": .object(["providers": .array([])])])) + #expect(ModelSearch(catalog: removed).results(query: "new", instanceID: nil).isEmpty) +} + +private func generationSearchCatalog() throws -> ProviderCatalog { + var catalog = ProviderCatalog() + try catalog.apply( + JSON.decode( + Data( + """ + {"type":"snapshot","config":{"providers":[ + {"instanceId":"personal","driver":"codex","displayName":"Personal","enabled":true,"installed":true,"models":[ + {"slug":"old","name":"GPT-5.6-Sol","shortName":"Sol","isLegacy":true}, + {"slug":"current","name":"GPT-6.1-Sol","shortName":"Sol"}, + {"slug":"old-custom","name":"Atlas A","isLegacy":true}, + {"slug":"current-custom","name":"Atlas B"}]}, + {"instanceId":"work","driver":"codex","displayName":"Work","enabled":true,"installed":true,"models":[ + {"slug":"current","name":"GPT-6.1-Sol","shortName":"Sol"}]}]}} + """.utf8))) + return catalog +} + +@Test func equallyRelevantSearchPrefersCurrentModelsAcrossAccounts() throws { + let search = ModelSearch(catalog: try generationSearchCatalog()) + let matches = search.results(query: "sol", instanceID: "personal", favoritesOnly: true) + #expect( + matches.prefix(3).map(\.id) == [ + ModelIdentity(instanceID: "personal", modelID: "current"), + ModelIdentity(instanceID: "work", modelID: "current"), + ModelIdentity(instanceID: "personal", modelID: "old"), + ]) + // The preference follows catalog metadata, including models without short names or versions. + #expect( + search.results(query: "atlas", instanceID: nil).map(\.model.id) + == ["current-custom", "old-custom"]) +} + +@Test func legacySearchKeepsPreciseMatchesFavoritesAndBrowsingOrder() throws { + let search = ModelSearch(catalog: try generationSearchCatalog()) + let legacy = ModelIdentity(instanceID: "personal", modelID: "old") + #expect(search.results(query: "GPT-5.6-Sol", instanceID: nil).first?.id == legacy) + #expect(search.results(query: "5.6", instanceID: "work").map(\.id) == [legacy]) + #expect(search.results(query: "atlas a", instanceID: nil).first?.model.id == "old-custom") + #expect(search.results(query: "sol", instanceID: nil, favorites: [legacy]).first?.id == legacy) + #expect( + search.results(query: " ", instanceID: "personal").map(\.model.id) + == ["old", "current", "old-custom", "current-custom"]) + #expect( + search.results(query: "", instanceID: "work").map(\.id) + == [ModelIdentity(instanceID: "work", modelID: "current")]) +} diff --git a/apps/macos/Tests/T3CoreTests/NativeConnectionTests.swift b/apps/macos/Tests/T3CoreTests/NativeConnectionTests.swift new file mode 100644 index 0000000000000..0a009b68b6d8a --- /dev/null +++ b/apps/macos/Tests/T3CoreTests/NativeConnectionTests.swift @@ -0,0 +1,281 @@ +import CryptoKit +import Foundation +import Testing + +@testable import T3Core + +@Suite struct NativeConnectionTests { + @Test func canonicalEndpointsShareCredentialAndDraftIdentity() throws { + let equivalents = [ + "https://MAC.Example/", "https://mac.example:443", "wss://mac.example", "mac.example", + ] + for value in equivalents { + #expect(try ConnectionEndpoint.normalize(value).absoluteString == "https://mac.example") + #expect( + try ConnectionEndpoint.credentialAccount( + URL(string: value.contains("://") ? value : "https://" + value)!) == "https://mac.example" + ) + } + #expect( + try ConnectionEndpoint.normalize("http://[::1]:3774/").absoluteString == "http://[::1]:3774") + for value in [ + "http://remote.example", "https://user:secret@host", "https://host/ws", + "https://host?token=secret", + "https://host#secret", "file:///tmp/file", "https://host:0", "https://host:65536", + "http://127.1", + ] { + #expect(throws: (any Error).self) { try ConnectionEndpoint.normalize(value) } + } + } + @Test func pairingLinksExtractSecretsWithoutSavingThemInEndpoint() throws { + let direct = try RemotePairingTarget( + address: "https://mac.example/#token=a%2Bb%26c", credential: "unused") + #expect(direct.endpoint.absoluteString == "https://mac.example") + #expect(direct.credential == "a+b&c") + let hosted = try RemotePairingTarget( + address: "https://app.t3.codes/?host=https%3A%2F%2Fmac.example&label=Mac#token=secret", + credential: "") + #expect(hosted.endpoint == direct.endpoint) + #expect(hosted.credential == "secret") + #expect(throws: (any Error).self) { + try RemotePairingTarget( + address: "https://app.t3.codes/?host=http%3A%2F%2Fremote.example#token=secret", + credential: "") + } + } + @Test func legacyMigrationRetainsSelectionAndNeverSerializesCredentials() throws { + let legacy = try ConnectionSettings( + endpoint: URL(string: "https://MAC.example:443/")!, token: "sensitive-access-token") + var catalog = SavedConnections() + try catalog.migrateLegacy(legacy) + #expect(catalog.selectedID == "https://mac.example") + #expect(catalog.environments.count == 1) + let encoded = try JSONEncoder().encode(catalog) + #expect(!String(decoding: encoded, as: UTF8.self).contains(legacy.token)) + let decoded = try SavedConnections.decode(encoded) + #expect(decoded == catalog) + try catalog.migrateLegacy( + ConnectionSettings(endpoint: URL(string: "https://other.example")!, token: "other-token")) + #expect(catalog == decoded) + catalog.remove("https://mac.example") + #expect(catalog.selectedID == nil) + } + @Test func legacyConnectionFilesRemainReadable() throws { + let value = try JSONDecoder().decode( + ConnectionSettings.self, + from: Data(#"{"endpoint":"https://mac.example","token":"secret"}"#.utf8)) + #expect(value.proofKey == nil) + #expect( + value.request("/api/auth/session").value(forHTTPHeaderField: "Authorization") + == "Bearer secret") + } + @Test func proofSignatureAndTokenBindingAreReal() throws { + let key = ConnectionProof() + let token = "example-token" + let url = URL(string: "https://mac.example/oauth/token?ignored=yes#fragment")! + let jwt = try key.jwt( + method: "post", url: url, token: token, now: Date(timeIntervalSince1970: 1000)) + let fields = jwt.split(separator: ".").map(String.init) + func decode(_ text: String) throws -> Data { + let value = text.replacingOccurrences(of: "-", with: "+").replacingOccurrences( + of: "_", with: "/") + return try #require( + Data(base64Encoded: value + String(repeating: "=", count: (4 - value.count % 4) % 4))) + } + let signature = try P256.Signing.ECDSASignature(rawRepresentation: decode(fields[2])) + let publicKey = try P256.Signing.PrivateKey(rawRepresentation: key.rawRepresentation).publicKey + #expect(publicKey.isValidSignature(signature, for: Data((fields[0] + "." + fields[1]).utf8))) + let payload = try JSON.decode(decode(fields[1])) + #expect(payload["htu"].string == "https://mac.example/oauth/token") + #expect(payload["htm"].string == "POST") + #expect(payload["iat"].int == 1000) + #expect( + payload["ath"].string == ConnectionProof.base64url(Data(SHA256.hash(data: Data(token.utf8))))) + let restored = try ConnectionProof(rawRepresentation: key.rawRepresentation) + #expect(restored.thumbprint == key.thumbprint) + let settings = try ConnectionSettings( + endpoint: URL(string: "https://mac.example")!, token: token, proofKey: key.rawRepresentation) + #expect( + settings.request("/api/auth/websocket-ticket", method: "POST").value( + forHTTPHeaderField: "Authorization") == "DPoP " + token) + #expect( + settings.request("/api/auth/websocket-ticket", method: "POST").value( + forHTTPHeaderField: "DPoP") != nil) + } + @Test func oauthUsesHostedFragmentAndRejectsForeignCallbacks() throws { + let config = try ConnectConfiguration( + relay: "https://relay.example", + publishableKey: "pk_test_" + Data("clerk.example$".utf8).base64EncodedString(), + clientID: "cli-client") + let auth = ConnectAuthorization(configuration: config) + #expect(auth.url.path == "/connect") + #expect(auth.url.query == nil) + #expect(auth.url.fragment?.contains("port=34338") == true) + #expect(config.tokenEndpoint.absoluteString == "https://clerk.example/oauth/token") + let url = URL(string: "http://127.0.0.1:34338/callback?state=\(auth.state)&code=code")! + #expect(try auth.code(from: url) == "code") + for input in [ + "http://127.0.0.1:34338/callback?state=wrong&code=code", + "https://remote.example/callback?state=\(auth.state)&code=code", + "http://127.0.0.1:34338/callback?state=\(auth.state)&code=code&code=other", + ] { + #expect(throws: (any Error).self) { try auth.code(from: URL(string: input)!) } + } + } + @Test func relayPairingRejectsChangedEnvironmentOrEndpoint() throws { + let environment: JSON = .object([ + "environmentId": .string("env-1"), "label": .string("Mac"), + "endpoint": .object(["httpBaseUrl": .string("https://mac.example")]), + ]) + let target = try ConnectEnvironment(environment) + #expect(try ConnectClient.pairingEndpoint(environment, expected: target) == target.endpoint) + #expect(throws: (any Error).self) { + try ConnectClient.pairingEndpoint( + .object(["environmentId": .string("env-2"), "endpoint": environment["endpoint"]]), + expected: target) + } + #expect(throws: (any Error).self) { + try ConnectClient.pairingEndpoint( + .object([ + "environmentId": .string("env-1"), + "endpoint": .object(["httpBaseUrl": .string("https://foreign.example")]), + ]), expected: target) + } + } + @Test func startupCannotUseLiveHomeIncludingSymlinkAliases() throws { + let root = URL(fileURLWithPath: "/tmp/t3-native-evidence/connections-home-" + UUID().uuidString) + try FileManager.default.createDirectory( + at: root.appendingPathComponent(".t3/userdata"), withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let alias = root.appendingPathComponent("alias") + try FileManager.default.createSymbolicLink( + at: alias, withDestinationURL: root.appendingPathComponent(".t3")) + for home in [ + root.appendingPathComponent(".t3"), root.appendingPathComponent(".t3/userdata"), alias, + alias.appendingPathComponent("userdata"), + ] { + #expect(throws: (any Error).self) { + try BackendConfiguration(home: home).validate(userHome: root) + } + } + try BackendConfiguration(home: root.appendingPathComponent("native")).validate(userHome: root) + } + @Test func bootstrapUsesExistingDesktopContractAndExplicitIsolation() throws { + let home = URL(fileURLWithPath: "/tmp/t3-native-evidence/connection-config") + let configuration = BackendConfiguration(home: home, port: 3779) + let data = try configuration.bootstrap(token: "bootstrap-secret") + let json = try JSON.decode(data) + #expect(json["mode"].string == "desktop") + #expect(json["host"].string == "127.0.0.1") + #expect(json["t3Home"].string == home.path) + #expect(json["noBrowser"].bool) + #expect(!json["tailscaleServeEnabled"].bool) + let env = BackendConfiguration.childEnvironment( + [ + "T3CODE_HOME": "/live", "T3CODE_HOST": "0.0.0.0", + "T3CODE_DEV_AUTH_TOKEN": "secret", "NODE_OPTIONS": "--inspect", + "T3CODE_DEV_ALLOWED_ORIGINS": "https://app.t3.codes", + "HTTPS_PROXY": "https://proxy.example", + "PATH": "/usr/bin:/opt/homebrew/bin:/custom/cli:/usr/bin", + ], + home: home, + bundledNode: URL( + fileURLWithPath: "/Applications/T3 Native.app/Contents/Resources/backend/node/bin/node"), + userHome: URL(fileURLWithPath: "/Users/provider")) + #expect(env["T3CODE_HOME"] == home.path) + #expect(env["T3CODE_HOST"] == nil) + #expect(env["T3CODE_DEV_AUTH_TOKEN"] == nil) + #expect(env["NODE_OPTIONS"] == nil) + #expect(env["HTTPS_PROXY"] == "https://proxy.example") + #expect(env["T3CODE_DEV_ALLOWED_ORIGINS"] == "https://app.t3.codes") + let path = (env["PATH"] ?? "").split(separator: ":").map(String.init) + #expect(path.first == "/Applications/T3 Native.app/Contents/Resources/backend/node/bin") + for directory in [".local/bin", ".bun/bin", ".local/share/mise/shims", ".npm-global/bin"] { + #expect(path.contains("/Users/provider/" + directory)) + } + #expect(path.contains("/custom/cli")) + #expect(path.contains("/bin")) + #expect(path.filter { $0 == "/usr/bin" }.count == 1) + #expect(path.filter { $0 == "/opt/homebrew/bin" }.count == 1) + #expect(!path.contains(home.appendingPathComponent(".local/bin").path)) + } + @Test func bundledRuntimeRequiresArm64AndPreservesServerTreeLayout() throws { + let root = URL( + fileURLWithPath: "/tmp/t3-native-evidence/connections-assets-" + UUID().uuidString) + let runtime = BackendRuntime.bundled(resources: root) + #expect(runtime.node.path.hasSuffix("/backend/node/bin/node")) + #expect(runtime.serverEntry.path.hasSuffix("/backend/server/apps/server/dist/bin.mjs")) + try FileManager.default.createDirectory( + at: runtime.node.deletingLastPathComponent(), withIntermediateDirectories: true) + try FileManager.default.createDirectory( + at: runtime.serverEntry.deletingLastPathComponent(), withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + try Data().write(to: runtime.serverEntry) + try Data([0xcf, 0xfa, 0xed, 0xfe, 0x07, 0x00, 0x00, 0x01]).write(to: runtime.node) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], ofItemAtPath: runtime.node.path) + #expect(throws: (any Error).self) { try runtime.validate() } + try Data([0xcf, 0xfa, 0xed, 0xfe, 0x0c, 0x00, 0x00, 0x01]).write(to: runtime.node) + try runtime.validate() + } + @Test func reconnectAtSameEndpointInvalidatesOldOperations() throws { + let settings = try ConnectionSettings( + endpoint: URL(string: "https://mac.example")!, token: "token") + let original = RPCConnection(settings: settings) + let replacement = RPCConnection(settings: settings) + let operation = ConnectionOperation( + environment: settings.endpoint.absoluteString, connection: original) + try operation.requireCurrent( + environment: settings.endpoint.absoluteString, connection: original) + #expect(throws: (any Error).self) { + try operation.requireCurrent( + environment: settings.endpoint.absoluteString, connection: replacement) + } + #expect(throws: (any Error).self) { + try operation.requireCurrent(environment: settings.endpoint.absoluteString, connection: nil) + } + #expect(throws: (any Error).self) { + try operation.requireCurrent(environment: "https://other.example", connection: original) + } + } + @Test func migrationKeepsRealDraftsAndPendingSubmissions() throws { + let directory = URL( + fileURLWithPath: "/tmp/t3-native-evidence/connections-drafts-" + UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let repository = DraftRepository(directory: directory) + let legacy = try ConnectionSettings( + endpoint: URL(string: "https://MAC.example:443/")!, token: "token") + let drafts = SavedDrafts( + messages: ["thread": "unsent message"], + submissions: ["thread": PendingSubmission(text: "pending message")]) + try repository.save(drafts, for: legacy.endpoint) + var catalog = SavedConnections() + try catalog.migrateLegacy(legacy) + #expect(try repository.load(for: catalog.environments[0].endpoint) == drafts) + } + @Test @MainActor func processOwnershipOnlyStopsTheCapturedChild() async throws { + let owned = OwnedBackendProcess() + let other = OwnedBackendProcess() + let executable = URL(fileURLWithPath: "/bin/sleep") + let directory = URL(fileURLWithPath: "/tmp") + try owned.start( + executable: executable, arguments: ["30"], directory: directory, environment: [:]) + try other.start( + executable: executable, arguments: ["30"], directory: directory, environment: [:]) + defer { + owned.terminate() + other.terminate() + } + #expect(owned.processIdentifier != other.processIdentifier) + #expect(other.isRunning) + owned.terminate() + await owned.waitForExit() + #expect(!owned.isRunning) + #expect(other.isRunning) + #expect(throws: (any Error).self) { + try owned.start(executable: executable, arguments: [], directory: directory, environment: [:]) + } + other.terminate() + await other.waitForExit() + } +} diff --git a/apps/macos/Tests/T3CoreTests/NewConversationDraftTests.swift b/apps/macos/Tests/T3CoreTests/NewConversationDraftTests.swift new file mode 100644 index 0000000000000..1e5e18e677b0f --- /dev/null +++ b/apps/macos/Tests/T3CoreTests/NewConversationDraftTests.swift @@ -0,0 +1,229 @@ +import Foundation +import Testing + +@testable import T3Core + +@Suite struct NewConversationDraftTests { + private func json(_ value: String) throws -> JSON { + try JSON.decode(Data(value.utf8)) + } + + private func conversation(workspace: String = "local") throws -> NewConversationDraft { + let project = try json( + """ + {"id":"project-native-draft","title":"Draft project","workspaceRoot":"/server/projects/draft"} + """) + let model = try json( + """ + {"instanceId":"codex_work","model":"gpt-current","options":[{"id":"effort","value":"high"}]} + """) + return NewConversationDraft( + project: project, + configuration: NewThreadConfiguration( + projectID: "project-native-draft", model: model, + runtimeMode: "full-access", interactionMode: "plan"), + workspaceMode: workspace) + } + + private func directory() -> URL { + URL(fileURLWithPath: "/tmp/t3-native-evidence", isDirectory: true) + .appendingPathComponent("new-conversation-drafts-" + UUID().uuidString, isDirectory: true) + } + + @Test func unsentConversationSurvivesRepositoryRestartAndKeepsItsSelectedConfiguration() throws { + let directory = directory() + defer { try? FileManager.default.removeItem(at: directory) } + let endpoint = try #require(URL(string: "https://draft-server.example.invalid")) + let other = try #require(URL(string: "https://other-server.example.invalid")) + var draft = try conversation(workspace: "worktree") + draft.branch = "release/native" + let repository = DraftRepository(directory: directory) + try repository.save( + SavedDrafts( + messages: [draft.id: "A message that has never been sent"], conversations: [draft.id: draft] + ), + for: endpoint) + + let reloaded = try DraftRepository(directory: directory).load(for: endpoint) + let restored = try #require(reloaded.conversations?[draft.id]) + #expect(restored == draft) + #expect(reloaded.messages[restored.id] == "A message that has never been sent") + #expect(reloaded.submissions.isEmpty) + #expect(restored.summary?.id == draft.id) + #expect(restored.summary?.projectID == "project-native-draft") + #expect(restored.summary?.model == draft.model) + #expect(restored.summary?.runtimeMode == "full-access") + #expect(restored.summary?.interactionMode == "plan") + #expect(restored.summary?.branch == "release/native") + #expect(restored.summary?.updatedAt == draft.createdAt) + #expect(try repository.load(for: other).conversations == nil) + } + + @Test func localLaunchUsesTheLaunchThreadEnvelopeAndPreservesFirstMessageBytes() throws { + var draft = try conversation() + draft.branch = "feature/existing" + let attachment = try json( + """ + {"type":"file","id":"uploaded_draft_file","name":"notes.txt","mimeType":"text/plain","sizeBytes":28} + """) + let submission = PendingSubmission( + text: " Review these notes\nKeep the indentation ", + command: "launch-command-original", message: "initial-message-original", + attachments: [attachment], localAttachmentIDs: ["local-file-original"]) + let wire = try JSON.decode(draft.launch(submission).data()) + + #expect(wire["commandId"] == .string("launch-command-original")) + #expect(wire["threadId"] == .string(draft.id)) + #expect(wire["projectId"] == .string("project-native-draft")) + #expect(wire["creationSource"] == .string("web")) + #expect(wire["generateTitle"] == .bool(true)) + #expect(wire["title"] == .string("Review these notes\nKeep the indentation")) + #expect(wire["modelSelection"] == draft.model) + #expect(wire["runtimeMode"] == .string("full-access")) + #expect(wire["interactionMode"] == .string("plan")) + #expect( + wire["workspaceStrategy"] + == .object([ + "type": .string("root"), "branch": .string("feature/existing"), + ])) + #expect( + wire["initialMessage"] + == .object([ + "messageId": .string("initial-message-original"), "text": .string(submission.text), + "attachments": .array([attachment]), + ])) + #expect(wire["type"] == .null) + #expect(wire["initialMessage"]["localAttachmentIDs"] == .null) + #expect(wire["initialMessage"]["delivery"] == .null) + } + + @Test func restoredUnsentConversationAppearsBeforeOlderActiveThreads() throws { + let draft = try JSONDecoder().decode( + NewConversationDraft.self, from: JSONEncoder().encode(conversation())) + let summary = try #require(draft.summary) + let older = try ThreadSummary( + json( + """ + {"id":"older-thread","projectId":"project-native-draft","title":"Earlier conversation","createdAt":"2026-01-01T00:00:00Z","updatedAt":"2026-01-01T00:00:00Z"} + """)) + let groups = ThreadGroup.resolve([older, summary], at: Date()) + let active = try #require(groups.first { $0.section == .active }) + #expect(active.threads.map(\.id) == [draft.id, older.id]) + } + + @Test func worktreeLaunchUsesTheSelectedBranchAsBaseRefRatherThanTheNewBranchName() throws { + var draft = try conversation(workspace: "worktree") + draft.branch = "origin/release/native" + let launch = try draft.launch(PendingSubmission(text: "Inspect release changes")) + #expect( + launch["workspaceStrategy"] + == .object([ + "type": .string("worktree"), "baseRef": .string("origin/release/native"), + ])) + #expect(launch["workspaceStrategy"]["branch"] == .null) + #expect(launch["initialMessage"]["attachments"] == .array([])) + } + + @Test(arguments: [nil, "", " \n\t "] as [String?]) + func worktreeRejectsAMissingOrBlankBaseBranch(baseRef: String?) throws { + var draft = try conversation(workspace: "worktree") + draft.branch = baseRef + #expect(throws: T3Error.self) { + try draft.launch(PendingSubmission(text: "Do not send a launch without a base branch")) + } + } + + @Test func attachmentOnlyLaunchKeepsAValidTitleAndAnEmptyInitialMessage() throws { + let draft = try conversation() + let file = try json( + """ + {"type":"file","id":"uploaded_empty_prompt","name":"spec.md","mimeType":"text/markdown","sizeBytes":100} + """) + let launch = try draft.launch(PendingSubmission(text: "", attachments: [file])) + #expect(launch["title"] == .string("New conversation")) + #expect(launch["initialMessage"]["text"] == .string("")) + #expect(launch["initialMessage"]["attachments"] == .array([file])) + #expect(launch["workspaceStrategy"] == .object(["type": .string("root")])) + } + + @Test func anUncertainLaunchRetainsTheExactPayloadAndIDsAcrossSaveReloadAndRetries() throws { + let directory = directory() + defer { try? FileManager.default.removeItem(at: directory) } + let endpoint = try #require(URL(string: "http://127.0.0.1:4773")) + var draft = try conversation(workspace: "worktree") + draft.branch = "main" + let file = try json( + """ + {"type":"file","id":"uploaded_retry_file","name":"README.md","mimeType":"text/markdown","sizeBytes":400} + """) + let initial = PendingSubmission( + text: "A launch whose response might be lost", command: "stable-launch-command", + message: "stable-initial-message", attachments: [file], + localAttachmentIDs: ["local-retry-file"], delivery: .auto) + let launched = try draft.launch(initial) + let pending = PendingSubmission( + text: initial.text, command: initial.command, message: initial.message, + attachments: initial.attachments, localAttachmentIDs: initial.localAttachmentIDs, + delivery: initial.delivery, launch: launched) + let repository = DraftRepository(directory: directory) + var saved = SavedDrafts( + messages: [draft.id: initial.text], submissions: [draft.id: pending], + conversations: [draft.id: draft]) + + for _ in 0..<3 { + try repository.save(saved, for: endpoint) + saved = try DraftRepository(directory: directory).load(for: endpoint) + let recoveredDraft = try #require(saved.conversations?[draft.id]) + let recoveredPending = try #require(saved.submissions[draft.id]) + #expect(recoveredPending.command == "stable-launch-command") + #expect(recoveredPending.message == "stable-initial-message") + #expect(recoveredPending.launch == launched) + #expect(try recoveredDraft.launch(recoveredPending) == launched) + #expect(recoveredPending.localAttachmentIDs == ["local-retry-file"]) + #expect(recoveredPending.launch?["initialMessage"]["attachments"] == .array([file])) + } + + // An already prepared launch stays frozen even if the surrounding composer draft is edited. + var edited = try #require(saved.conversations?[draft.id]) + edited.branch = "different-base" + edited.model = .object(["instanceId": .string("claude_work"), "model": .string("opus")]) + saved.conversations?[draft.id] = edited + try repository.save(saved, for: endpoint) + let recovered = try repository.load(for: endpoint) + #expect(recovered.submissions[draft.id]?.launch == launched) + #expect(recovered.conversations?[draft.id]?.branch == "different-base") + } + + @Test(arguments: [1, 2, 3, 4, 5]) + func preConversationDraftFilesStillLoadAndTheirPendingMessagesRemainRetryable(version: Int) throws + { + let directory = directory() + defer { try? FileManager.default.removeItem(at: directory) } + let endpoint = try #require(URL(string: "https://legacy-drafts.example.invalid")) + let repository = DraftRepository(directory: directory) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let legacy = Data( + """ + {"version":\(version),"messages":{"existing-thread":"The old unsent message"}, + "questions":{},"submissions":{"existing-thread":{"text":"The old unsent message", + "command":"old-command","message":"old-message"}}} + """.utf8) + try legacy.write(to: repository.file(for: endpoint)) + + let saved = try repository.load(for: endpoint) + #expect(saved.version == version) + #expect(saved.conversations == nil) + #expect(saved.messages["existing-thread"] == "The old unsent message") + let pending = try #require(saved.submissions["existing-thread"]) + #expect(pending.launch == nil) + #expect(pending.matches(text: "The old unsent message", attachments: [])) + let payload = pending.payload(threadID: "existing-thread") + #expect(payload["type"] == .string("message.dispatch")) + #expect(payload["commandId"] == .string("old-command")) + #expect(payload["messageId"] == .string("old-message")) + #expect(payload["threadId"] == .string("existing-thread")) + #expect(payload["attachments"] == .array([])) + try repository.save(saved, for: endpoint) + #expect(try repository.load(for: endpoint) == saved) + } +} diff --git a/apps/macos/Tests/T3CoreTests/NewThreadTests.swift b/apps/macos/Tests/T3CoreTests/NewThreadTests.swift new file mode 100644 index 0000000000000..1901bc5d8ef55 --- /dev/null +++ b/apps/macos/Tests/T3CoreTests/NewThreadTests.swift @@ -0,0 +1,101 @@ +import Foundation +import Testing + +@testable import T3Core + +private let project: JSON = .object(["id": .string("project")]) +private func selection(_ instance: String, model: String = "current") -> JSON { + .object(["instanceId": .string(instance), "model": .string(model)]) +} +private func catalog(_ settings: [String: JSON] = [:]) throws -> ProviderCatalog { + var value = ProviderCatalog() + try value.apply( + .object([ + "type": .string("snapshot"), + "config": .object([ + "settings": .object(settings), + "providers": .array( + ["one", "two", "disabled"].map { id in + .object([ + "instanceId": .string(id), "enabled": .bool(id != "disabled"), + "installed": .bool(true), + "models": .array([ + .object(["slug": .string("old"), "isLegacy": .bool(true)]), + .object(["slug": .string("current")]), + ]), + ]) + }), + ]), + ])) + return value +} +private func template() throws -> ThreadSummary { + try ThreadSummary( + .object([ + "id": .string("original"), "projectId": .string("other-project"), + "title": .string("Existing"), + "modelSelection": selection("two"), "runtimeMode": .string("full-access"), + "branch": .string("worktree-branch"), "worktreePath": .string("/old-worktree"), + ])) +} + +@Test func newThreadCanStartWithoutATemplateAndResetsWorkspaceAndPermissions() throws { + let catalog = try catalog() + let config = try catalog.newThreadConfiguration(project: project, carrying: nil) + #expect(config.model["instanceId"].string == "one") + #expect(config.model["model"].string == "current") + let carried = try catalog.newThreadConfiguration(project: project, carrying: template()) + #expect(carried.model["instanceId"].string == "two") + #expect(carried.runtimeMode == "approval-required") + let command = carried.command(id: "new-thread") + #expect(command["projectId"].string == "project") + #expect(command["branch"] == .null) + #expect(command["worktreePath"] == .null) +} + +@Test func projectOverridesWinAndExplicitNullClearsTheEnvironmentModel() throws { + let settings: [String: JSON] = [ + "defaultModelSelection": selection("one"), + "projectSettingsOverrides": .object([ + "project": .object([ + "defaultModelSelection": selection("two"), "defaultRuntimeMode": .string("full-access"), + ]) + ]), + ] + let configured = try catalog(settings).newThreadConfiguration(project: project, carrying: nil) + #expect(configured.model["instanceId"].string == "two") + #expect(configured.runtimeMode == "full-access") + var cleared = settings + cleared["projectSettingsOverrides"] = .object([ + "project": .object(["defaultModelSelection": .null]) + ]) + #expect( + try catalog(cleared).newThreadConfiguration(project: project, carrying: template()).model[ + "instanceId" + ].string == "two") +} + +@Test func foldedSettingsIgnoreOldProjectDefaultsAndDisabledProvidersFallBack() throws { + let legacy: JSON = .object(["id": .string("project"), "defaultModelSelection": selection("two")]) + var settings: [String: JSON] = ["defaultModelSelection": selection("one")] + #expect( + try catalog(settings).newThreadConfiguration(project: legacy, carrying: nil).model["instanceId"] + .string == "two") + settings["projectSettingsFolded"] = .bool(true) + #expect( + try catalog(settings).newThreadConfiguration(project: legacy, carrying: nil).model["instanceId"] + .string == "one") + settings["projectSettingsOverrides"] = .object([ + "project": .object(["defaultModelSelection": selection("disabled")]) + ]) + #expect( + try catalog(settings).newThreadConfiguration(project: project, carrying: template()).model[ + "instanceId" + ].string == "one") +} + +@Test func newThreadWithoutAnyAvailableProviderReportsWhyItCannotStart() throws { + #expect(throws: T3Error.self) { + try ProviderCatalog().newThreadConfiguration(project: project, carrying: template()) + } +} diff --git a/apps/macos/Tests/T3CoreTests/PlanDocumentTests.swift b/apps/macos/Tests/T3CoreTests/PlanDocumentTests.swift new file mode 100644 index 0000000000000..615b53f232b64 --- /dev/null +++ b/apps/macos/Tests/T3CoreTests/PlanDocumentTests.swift @@ -0,0 +1,70 @@ +import Foundation +import Testing + +@testable import T3Core + +@Test func collapsedPlanKeepsFullExportAndOnlyDropsDisplayHeadings() { + let steps = (1...14).map { "- Step \($0)\n" }.joined(separator: "\n") + let source = "# Integrate `Effect` RPC: Server!\n\n## Summary\n\n" + steps + " \n\t" + let document = PlanDocument(source) + #expect(document.title == "Integrate `Effect` RPC: Server!") + #expect(document.filename == "integrate-effect-rpc-server.md") + #expect(document.canCollapse) + #expect(document.body.hasPrefix("- Step 1\n\n- Step 2")) + #expect(document.body.hasSuffix("- Step 14")) + #expect(document.preview.contains("- Step 10")) + #expect(!document.preview.contains("- Step 11")) + #expect(document.preview.hasSuffix("\n\n...")) + #expect(document.contents == "# Integrate `Effect` RPC: Server!\n\n## Summary\n\n" + steps) + let scope = PlanDocument("## Build\r\n\r\n## Scope\r\n\r\n```swift\r\nlet x = 1\r\n```\r\n") + #expect(scope.body == "## Scope\n\n```swift\nlet x = 1\n```") + #expect(scope.contents.hasSuffix("```\n")) + #expect(scope.contents.contains("let x = 1\r\n")) +} + +@Test func planPreviewHandlesMissingHeadingsAndUsesElectronCollapseThresholds() { + let plain = PlanDocument("- Step one\n\n- Step two\n") + #expect(plain.title == "Proposed plan") + #expect(plain.filename == "plan.md") + #expect(plain.body == "- Step one\n\n- Step two") + #expect(!plain.canCollapse) + #expect(PlanDocument("# Only title").preview == "Only title") + #expect(PlanDocument("# 日本語").filename == "plan.md") + #expect(!PlanDocument(String(repeating: "x", count: 900)).canCollapse) + #expect(PlanDocument(String(repeating: "🌊", count: 451)).canCollapse) + #expect(!PlanDocument(Array(repeating: "line", count: 20).joined(separator: "\n")).canCollapse) + #expect(PlanDocument(Array(repeating: "line", count: 21).joined(separator: "\n")).canCollapse) + #expect( + PlanDocument("Intro\n\n# Later heading\n\nText").body.hasPrefix("Intro\n\n# Later heading")) + #expect(PlanDocument(" # Code, not a title").heading == nil) +} + +@Test func planWorkspaceUsesTheThreadsWorktreeAndPreservesExportBytes() throws { + let project: JSON = .object(["workspaceRoot": .string("/project-root")]) + func thread(worktree: JSON = .null) throws -> ThreadSummary { + try ThreadSummary( + .object([ + "id": .string("thread"), "projectId": .string("project"), "title": .string("Plan"), + "worktreePath": worktree, + ])) + } + let root = try #require( + WorkspaceLocation(environment: "server", thread: thread(), project: project)) + let worktree = try #require( + WorkspaceLocation( + environment: "server", thread: thread(worktree: .string("/separate-worktree")), + project: project)) + #expect(root.root == "/project-root") + #expect(worktree.root == "/separate-worktree") + #expect(root != worktree) + let document = PlanDocument("# Plan\n\nFull **content**.\n\n") + let input = try worktree.writeInput( + relativePath: " plans/full.md \n", contents: document.contents) + #expect(input["cwd"].string == "/separate-worktree") + #expect(input["relativePath"].string == "plans/full.md") + #expect(input["contents"].string == "# Plan\n\nFull **content**.\n") + #expect(throws: T3Error.self) { + try worktree.writeInput(relativePath: " \n", contents: document.contents) + } + #expect(try WorkspaceLocation(environment: "server", thread: thread(), project: .null) == nil) +} diff --git a/apps/macos/Tests/T3CoreTests/ProposedPlanTests.swift b/apps/macos/Tests/T3CoreTests/ProposedPlanTests.swift new file mode 100644 index 0000000000000..23441905aa5c5 --- /dev/null +++ b/apps/macos/Tests/T3CoreTests/ProposedPlanTests.swift @@ -0,0 +1,139 @@ +import Foundation +import Testing + +@testable import T3Core + +private func planFixture(_ id: String, run: String = "run", status: String = "active") -> JSON { + .object([ + "kind": .string("proposed_plan"), "id": .string(id), "runId": .string(run), + "status": .string(status), + "markdown": .string("# Make the button accessible\n\nAdd a label.\n"), + ]) +} + +@Test func planSelectionTracksRunStatusItemTimingAndCrossClientCompletion() throws { + var state = ThreadState(id: "t") + let run: JSON = .object([ + "id": .string("run"), "ordinal": .number(1), "status": .string("completed"), + "completedAt": .string("2026-10-11T12:00:00Z"), + ]) + _ = try state.apply( + .object([ + "kind": .string("snapshot"), "snapshotSequence": .number(1), + "projection": .object([ + "thread": .object(["id": .string("t")]), "runs": .array([run]), + "plans": .array([planFixture("old", run: "older"), planFixture("a"), planFixture("b")]), + "updatedAt": .string("2026-10-11T12:00:00Z"), + ]), + ])) + #expect(state.execution.latestRunSettled) + #expect(state.latestProposedPlan?.id == "b") + func update(_ type: String, _ payload: JSON, sequence: Int) throws { + _ = try state.apply( + .object([ + "kind": .string("event"), "sequence": .number(Double(sequence)), + "event": .object(["type": .string(type), "threadId": .string("t"), "payload": payload]), + ])) + } + try update( + "turn-item.updated", + .object([ + "id": .string("item"), "threadId": .string("t"), "type": .string("proposed_plan"), + "planId": .string("a"), "markdown": .string("# Latest plan"), + "updatedAt": .string("2026-10-11T12:01:00Z"), + ]), sequence: 2) + #expect(state.latestProposedPlan?.id == "a") + try update("plan.updated", planFixture("a", status: "completed"), sequence: 3) + #expect(state.latestProposedPlan?.status == "completed") + try update("plan.updated", planFixture("a"), sequence: 2) + #expect(state.latestProposedPlan?.status == "completed") + try update( + "run.created", + .object([ + "id": .string("next"), "ordinal": .number(2), "status": .string("starting"), + ]), sequence: 4) + #expect(!state.execution.latestRunSettled) + #expect(state.execution.latestRunID == "next") + #expect(state.latestProposedPlan?.id == "a") +} + +@Test func planRefinementAndImplementationKeepDifferentModesAndSourceLinks() throws { + let plan = ProposedPlan(planFixture("plan"), updatedAt: "")! + let template = try ThreadSummary( + .object([ + "id": .string("source"), "projectId": .string("project"), "title": .string("Planning"), + "modelSelection": .object(["instanceId": .string("account"), "model": .string("chosen")]), + "runtimeMode": .string("approval-required"), "interactionMode": .string("plan"), + "branch": .string("feature"), "worktreePath": .string("/tmp/isolated-worktree"), + ])) + let refine = PendingPlanSubmission( + plan: plan, template: template, draft: " Make the label shorter.\n", newThread: false, + defaultRuntimeMode: "full-access") + #expect(refine.setup["interactionMode"].string == "plan") + #expect(refine.message["text"].string == "Make the label shorter.") + #expect(refine.message["sourcePlanRef"] == .null) + for newThread in [false, true] { + let implement = PendingPlanSubmission( + plan: plan, template: template, draft: " \n", newThread: newThread, + defaultRuntimeMode: "full-access") + #expect(implement.setup["interactionMode"].string == "default") + #expect( + implement.message["text"].string + == "PLEASE IMPLEMENT THIS PLAN:\n# Make the button accessible\n\nAdd a label.") + #expect( + implement.message["sourcePlanRef"] + == .object(["threadId": .string("source"), "planId": .string("plan")])) + #expect(implement.message["modelSelection"] == template.model) + if newThread { + #expect(implement.targetThreadID != template.id) + #expect(implement.setup["title"].string == "Implement Make the button accessible") + #expect(implement.setup["branch"].string == "feature") + #expect(implement.setup["worktreePath"].string == template.worktreePath) + #expect(implement.setup["runtimeMode"].string == "full-access") + } else { + #expect(implement.targetThreadID == template.id) + } + } + #expect(Commands.newThread(template: template, id: "ordinary")["worktreePath"] == .null) + let simplyNamed = ProposedPlan( + .object([ + "id": .string("plan"), "kind": .string("proposed_plan"), + "markdown": .string("# Plan\n\nDo the work."), + ]), updatedAt: "")! + let named = PendingPlanSubmission( + plan: simplyNamed, template: template, draft: "", newThread: true, + defaultRuntimeMode: "approval-required") + #expect(named.setup["title"].string == "Implement Plan") +} + +@Test func partialPlanSendPreservesBothCommandIdentitiesAcrossRestart() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let repository = DraftRepository(directory: root) + let endpoint = URL(string: "https://example.test")! + let template = try ThreadSummary( + .object([ + "id": .string("source"), "projectId": .string("project"), "title": .string("Planning"), + ])) + for newThread in [false, true] { + let command = PendingPlanSubmission( + plan: ProposedPlan(planFixture("plan"), updatedAt: "")!, template: template, + draft: "", newThread: newThread, defaultRuntimeMode: "approval-required") + let saved = SavedDrafts( + messages: ["source": "Unsent draft"], planSubmissions: ["source": command]) + try repository.save(saved, for: endpoint) + let restored = try DraftRepository(directory: root).load(for: endpoint) + #expect(restored == saved) + #expect(restored.planSubmissions?["source"]?.message == command.message) + #expect(restored.planSubmissions?["source"]?.setup == command.setup) + #expect(try repository.load(for: URL(string: "https://other.test")!).planSubmissions == nil) + } + let old = Data( + #"{"version":4,"messages":{"source":"keep"},"questions":{},"submissions":{},"questionPages":{"r":1}}"# + .utf8) + try old.write(to: repository.file(for: endpoint)) + let restored = try repository.load(for: endpoint) + #expect(restored.messages["source"] == "keep") + #expect(restored.questionPages?["r"] == 1) + #expect(restored.planSubmissions == nil) +} diff --git a/apps/macos/Tests/T3CoreTests/ProviderManagementContractTests.swift b/apps/macos/Tests/T3CoreTests/ProviderManagementContractTests.swift new file mode 100644 index 0000000000000..6831b5c494f74 --- /dev/null +++ b/apps/macos/Tests/T3CoreTests/ProviderManagementContractTests.swift @@ -0,0 +1,147 @@ +import Foundation +import Testing + +@testable import T3Core + +@Suite struct ProviderManagementContractTests { + private func json(_ text: String) throws -> JSON { try JSON.decode(Data(text.utf8)) } + + @Test func atomicAccountEditPreservesOpaqueConfigurationAndEnvironment() throws { + let settings = try json( + """ + {"providerInstances":{"claude_work":{"driver":"claudeAgent","displayName":"Work","enabled":true, + "accentColor":"#fa853d","environment":[{"name":"ORG_SETTING","value":"configured","sensitive":false}], + "config":{"homePath":"/server/work","binaryPath":"claude","autoCompactWindow":"300000", + "customModels":[{"slug":"claude-custom","name":"Custom","capabilities":{"optionDescriptors":[{"id":"effort","type":"select","options":[{"id":"high","name":"High"}]}]}}]}}}} + """) + let snapshot = try json( + """ + {"instanceId":"claude_work","driver":"claudeAgent","displayName":"Work","status":"ready", + "auth":{"status":"authenticated","email":"work@example.invalid"},"models":[]} + """) + let provider = try ManagedProvider(snapshot: snapshot, settings: settings) + var draft = ProviderInstanceDraft(provider: provider) + draft.homePath = "/server/renamed" + draft.launchArgs = "--chrome --verbose" + let wire = try JSON.decode(draft.mutation(create: false).data()) + let mutation = wire["providerInstanceMutation"] + #expect(wire["patch"] == .object([:])) + #expect(mutation["operation"] == .string("upsert")) + #expect(mutation["instanceId"] == .string("claude_work")) + #expect(mutation["instance"]["config"]["homePath"] == .string("/server/renamed")) + #expect(mutation["instance"]["config"]["autoCompactWindow"] == .string("300000")) + #expect( + mutation["instance"]["config"]["customModels"] + == settings["providerInstances"]["claude_work"]["config"]["customModels"]) + #expect( + mutation["instance"]["environment"] + == settings["providerInstances"]["claude_work"]["environment"]) + #expect(mutation["instance"]["accentColor"] == .string("#fa853d")) + #expect(provider.signedIn) + } + + @Test func legacyAccountUsesDriverConfigWhileNewInstancesUseAtomicCreate() throws { + let settings = try json( + """ + {"providers":{"codex":{"binaryPath":"/server/bin/codex","homePath":"/server/codex","shadowHomePath":"/server/auth","launchArgs":"--config x=y","customModels":[]}}} + """) + let provider = try ManagedProvider( + snapshot: json( + """ + {"instanceId":"codex","driver":"codex","displayName":"Codex","auth":{"status":"unauthenticated"},"models":[]} + """), settings: settings) + #expect(!provider.explicit) + let old = try ProviderInstanceDraft(provider: provider).mutation(create: false) + #expect( + old["providerInstanceMutation"]["instance"]["config"]["shadowHomePath"] + == .string("/server/auth")) + var new = ProviderInstanceDraft() + new.id = "codex_personal" + new.displayName = "Personal" + #expect( + try new.mutation(create: true)["providerInstanceMutation"]["operation"] == .string("create")) + new.id = "1invalid" + #expect(throws: T3Error.self) { try new.mutation(create: true) } + new.id = "personal" + new.customModels = [.string("duplicate"), .object(["slug": .string("duplicate")])] + #expect(throws: T3Error.self) { try new.mutation(create: true) } + } + + @Test func loginUsesServerFlowAndInteractionIDsAndRejectsStaleStates() throws { + let state = try ProviderLoginState( + json( + """ + {"instanceId":"codex_work","phase":"waiting","flowId":"flow-17","authorizationUrl":null, + "interaction":{"type":"browser","id":"browser-3","url":"https://accounts.example.invalid/login","requiresConsent":true},"methods":[]} + """)) + let payload = try state.response( + .object(["type": .string("browser"), "action": .string("accept")])) + #expect(payload["flowId"] == .string("flow-17")) + #expect(payload["interactionId"] == .string("browser-3")) + #expect(payload["instanceId"] == .string("codex_work")) + #expect(state.url?.scheme == "https") + let finished = try ProviderLoginState( + json( + """ + {"instanceId":"codex_work","phase":"succeeded","flowId":null,"interaction":null,"authorizationUrl":"javascript:alert(1)"} + """)) + #expect(finished.url == nil) + #expect(throws: T3Error.self) { try finished.response(.object([:])) } + } + + @Test func preferenceStorageIsEndpointAndAccountScopedAndMaintainsCustomModels() throws { + let name = "t3-provider-contract-" + UUID().uuidString + let defaults = try #require(UserDefaults(suiteName: name)) + defer { defaults.removePersistentDomain(forName: name) } + var preferences = ModelPickerPreferences() + preferences.favorites = [ModelIdentity(instanceID: "work", modelID: "b")] + preferences.hidden = [ + ModelIdentity(instanceID: "work", modelID: "a"), + ModelIdentity(instanceID: "work", modelID: "custom"), + ] + preferences.order = ["work": ["custom", "b", "a"]] + try preferences.save(environment: "https://one.invalid", defaults: defaults) + #expect( + ModelPickerPreferences(environment: "https://one.invalid", defaults: defaults) == preferences) + #expect( + ModelPickerPreferences(environment: "https://two.invalid", defaults: defaults).favorites + .isEmpty) + let models = try json( + """ + [{"slug":"a","name":"A"},{"slug":"b","name":"B"},{"slug":"custom","name":"Custom","isCustom":true}] + """ + ).array.map(ProviderModel.init) + #expect(preferences.models(models, instanceID: "work").map(\.id) == ["b", "custom"]) + #expect(preferences.models(models, instanceID: "personal").map(\.id) == ["a", "b", "custom"]) + } + + @Test func interruptedAccountDraftRecoversItsIDAndRebasesOntoLatestConfig() throws { + let provider = try ManagedProvider( + snapshot: json( + """ + {"instanceId":"codex_work","driver":"codex","displayName":"Work","auth":{"status":"authenticated"},"models":[]} + """), + settings: json( + """ + {"providerInstances":{"codex_work":{"driver":"codex","displayName":"Work","config":{"homePath":"/work","otherOption":"old"}}}} + """)) + var value = ProviderInstanceDraft(provider: provider) + value.homePath = "/new-home" + let recovered = try JSONDecoder().decode( + ProviderInstanceDraft.self, from: JSONEncoder().encode(value)) + #expect(recovered.id == "codex_work") + let latest = try json( + """ + {"driver":"codex","displayName":"Work","accentColor":"#123456", + "environment":[{"name":"API_TOKEN","value":"","sensitive":true,"valueRedacted":true}], + "config":{"homePath":"/work","otherOption":"changed-on-other-client","futureOption":true}} + """) + let next = try recovered.mutation(create: false, mergingLatest: latest)[ + "providerInstanceMutation"]["instance"] + #expect(next["config"]["homePath"] == .string("/new-home")) + #expect(next["config"]["otherOption"] == .string("changed-on-other-client")) + #expect(next["config"]["futureOption"] == .bool(true)) + #expect(next["environment"] == latest["environment"]) + #expect(next["accentColor"] == .string("#123456")) + } +} diff --git a/apps/macos/Tests/T3CoreTests/QuestionProgressTests.swift b/apps/macos/Tests/T3CoreTests/QuestionProgressTests.swift new file mode 100644 index 0000000000000..0da03fc98facc --- /dev/null +++ b/apps/macos/Tests/T3CoreTests/QuestionProgressTests.swift @@ -0,0 +1,82 @@ +import Foundation +import Testing + +@testable import T3Core + +private func questionsFixture() throws -> [JSON] { + try JSONDecoder().decode( + JSON.self, + from: Data( + #""" + [ + {"id":"color","options":[{"label":"Blue","value":" blue "}],"allowCustomAnswer":false}, + {"id":"evidence","options":[],"multiSelect":true} + ] + """#.utf8) + ).array +} + +@Test func pagedQuestionsCountAttachmentOnlyAnswersAndBlockIncompleteUploads() throws { + let questions = try questionsFixture() + var color = QuestionDraft() + color.selected = [" blue "] + let drafts = ["color": color] + let first = QuestionProgress(questions: questions, drafts: drafts, index: -3) + #expect(first.index == 0 && first.answer == .string(" blue ")) + #expect(first.answeredCount == 1 && first.answers == nil && !first.isLast) + let uploading = QuestionProgress( + questions: questions, drafts: drafts, index: 1, attachmentCounts: ["evidence": 1], + blocked: ["evidence"]) + #expect(uploading.answer == nil && uploading.answers == nil) + let ready = QuestionProgress( + questions: questions, drafts: drafts, index: 99, attachmentCounts: ["evidence": 1]) + #expect(ready.index == 1 && ready.isLast && ready.answeredCount == 2) + #expect(ready.answers == .object(["color": .string(" blue "), "evidence": .string("")])) + #expect(QuestionDraft().answer(for: questions[0], attachmentCount: 1) == nil) + #expect(QuestionProgress(questions: [], drafts: [:], index: 3).answers == nil) +} + +@Test func questionFilesAndResponseIdentitySurviveRestartWithoutUsingTheMessageDraft() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let source = root.appendingPathComponent("answer.txt") + let bytes = Data("Question attachment content\n".utf8) + try bytes.write(to: source) + let endpoint = URL(string: "https://example.test")! + let repository = DraftRepository(directory: root.appendingPathComponent("drafts")) + let files = repository.attachmentRepository(for: endpoint) + var attachment = try files.stage(source) + attachment.uploadedID = "original-upload" + try FileManager.default.removeItem(at: source) + let key = QuestionDraft.attachmentKey( + threadID: "thread", requestID: "request", questionID: "evidence") + var draft = QuestionDraft() + draft.custom = "Keep this answer" + let response = PendingQuestionResponse( + threadID: "thread", requestID: "request", answers: .object(["evidence": .string(draft.custom)]), + attachments: ["evidence": [attachment]]) + try repository.save( + SavedDrafts( + messages: ["thread": "Separate unsent message"], questions: ["request": ["evidence": draft]], + attachments: [key: [attachment]], questionPages: ["request": 1], + questionResponses: ["request": response]), for: endpoint) + let restored = try repository.load(for: endpoint) + #expect(restored.messages["thread"] == "Separate unsent message") + #expect(restored.questions["request"]?["evidence"] == draft) + #expect(restored.questionPages?["request"] == 1) + let retried = try #require(restored.questionResponses?["request"]) + #expect(retried.payload == response.payload) + #expect(try Data(contentsOf: files.file(attachment)) == bytes) + attachment.uploadedID = "replacement-upload" + #expect(retried.matches(answers: response.answers, attachments: ["evidence": [attachment]])) + #expect( + retried.payload["attachmentsByQuestionId"]["evidence"].array.first?["id"].string + == "original-upload") + #expect(!retried.matches(answers: response.answers, attachments: [:])) + #expect( + key + != QuestionDraft.attachmentKey( + threadID: "other", requestID: "request", questionID: "evidence")) + #expect(try repository.load(for: URL(string: "https://other.test")!).questionResponses == nil) +} diff --git a/apps/macos/Tests/T3CoreTests/ReviewDiffTests.swift b/apps/macos/Tests/T3CoreTests/ReviewDiffTests.swift new file mode 100644 index 0000000000000..20e258772cc06 --- /dev/null +++ b/apps/macos/Tests/T3CoreTests/ReviewDiffTests.swift @@ -0,0 +1,169 @@ +import Foundation +import Testing + +@testable import T3Core + +@Test func reviewRepositoryErrorsDisplayServerDetails() { + let error = RPCConnection.requestError( + .object([ + "_tag": .string("Failure"), + "cause": .array([ + .object([ + "_tag": .string("Fail"), + "error": .object([ + "_tag": .string("VcsRepositoryDetectionError"), + "detail": .string( + "Review diff preview cwd must stay within the configured workspace root."), + ]), + ]) + ]), + ])) + #expect(error.remoteCode == "VcsRepositoryDetectionError") + #expect( + error.message == "Review diff preview cwd must stay within the configured workspace root.") +} + +@Test func diffLinesPreserveUnicodeCRLFAndHeaderLikeContents() throws { + let files = UnifiedDiff.parse( + """ + diff --git a/a b/a + index abc..def 100644 + --- a/a + +++ b/a + @@ -8,3 +8,4 @@ function + same\r + --- actual text\r + +++ actual text\r + +日本語 👋\r + end\r + @@ -30 +31 @@ + -old + +new + \\ No newline at end of file + """) + let file = try #require(files.first) + let lines = file.lines.filter { $0.oldLine != nil || $0.newLine != nil } + try #require(lines.count == 7) + #expect(file.path == "a") + #expect(file.previousPath == nil) + #expect(!file.incomplete) + #expect(file.additions == 3 && file.deletions == 2) + #expect(lines.map(\.oldLine) == [8, 9, nil, nil, 10, 30, nil]) + #expect(lines.map(\.newLine) == [8, nil, 9, 10, 11, nil, 31]) + #expect(lines[1].text == "-- actual text\r") + #expect(lines[2].text == "++ actual text\r") + #expect(lines[3].text == "日本語 👋\r") + #expect(file.lines.last?.text == "\\ No newline at end of file") +} + +@Test func diffParsesNewDeletedRenamedBinaryAndModeOnlyFiles() throws { + let files = UnifiedDiff.parse( + """ + diff --git a/new file b/new file + new file mode 100644 + --- /dev/null + +++ b/new file + @@ -0,0 +1,2 @@ + +one + +two + diff --git a/deleted b/deleted + deleted file mode 100644 + --- a/deleted + +++ /dev/null + @@ -1,2 +0,0 @@ + -one + -two + diff --git a/old name b/new name + similarity index 100% + rename from old name + rename to new name + diff --git a/image.png b/image.png + Binary files a/image.png and b/image.png differ + diff --git a/script b/script + old mode 100644 + new mode 100755 + """) + #expect(files.count == 5) + #expect(files.map(\.path) == ["new file", "deleted", "new name", "image.png", "script"]) + #expect(files[0].lines.last?.newLine == 2) + #expect(files[1].isDeleted && files[1].lines.last?.oldLine == 2) + #expect(files[2].previousPath == "old name") + #expect(files[3].isBinary) + #expect(files.allSatisfy { !$0.incomplete }) +} + +@Test func diffMarkersAreBytesEvenWhenTheFileLineStartsWithACombiningMark() throws { + let file = try #require( + UnifiedDiff.parse( + "diff --git a/a b/a\n@@ -1 +1 @@\n-\u{0301}old\n+\u{0301}new\n" + ).first) + #expect(!file.incomplete) + #expect(file.deletions == 1 && file.additions == 1) + #expect(file.lines.last?.text == "\u{0301}new") +} + +@Test func gitQuotedPathsDecodeOctalUTF8AndEscapedCharacters() throws { + let files = UnifiedDiff.parse( + #""" + diff --git "a/\346\227\245\346\234\254\350\252\236\t\".txt" "b/\346\227\245\346\234\254\350\252\236\t\".txt" + --- "a/\346\227\245\346\234\254\350\252\236\t\".txt" + +++ "b/\346\227\245\346\234\254\350\252\236\t\".txt" + @@ -1 +1 @@ + -before + +after + """#) + let file = try #require(files.first) + #expect(file.path == "日本語\t\".txt") + #expect(file.previousPath == nil) + #expect(!file.incomplete) + let spaced = UnifiedDiff.parse( + "diff --git a/a b/inside b/a b/inside\nold mode 100644\nnew mode 100755\n") + #expect(spaced.first?.path == "a b/inside") +} + +@Test func truncatedHunksDoNotInventLineNumbersOrConsumeTheNextFile() { + let files = UnifiedDiff.parse( + """ + diff --git a/a b/a + --- a/a + +++ b/a + @@ -1,3 +1,3 @@ + -first + +second + [output truncated] + diff --git a/b b/b + --- a/b + +++ b/b + @@ -1 +1 @@ + -a + +b + """) + #expect(files[0].incomplete) + #expect(!files[1].incomplete) + #expect(files[0].lines.last?.newLine == nil) + #expect(files[1].lines.last?.newLine == 1) + #expect(UnifiedDiff.parse("diff --git a/a b/a\n@@ -1 +1 @@\n-old").first?.incomplete == true) +} + +@Test func completeServerStatsSurviveTruncatedPatchAndSortNaturally() throws { + let value: JSON = .object([ + "kind": .string("working-tree"), "title": .string("Uncommitted"), + "diffHash": .string("hash"), "truncated": .bool(true), "diff": .string(""), + "files": .array([ + .object(["path": .string("file10"), "additions": .number(300), "deletions": .number(2)]), + .object(["path": .string("file2"), "additions": .number(3), "deletions": .number(4)]), + ]), + ]) + let source = try ReviewSource(value) + #expect(source.files.map(\.path) == ["file2", "file10"]) + #expect(source.files.last?.additions == 300) + #expect(source.completeFileList) + #expect(source.truncated) + let older = try ReviewSource( + .object([ + "kind": .string("working-tree"), "title": .string("Uncommitted"), "diffHash": .string("old"), + "diff": .string("diff --git a/a b/a\n@@ -1 +1 @@\n-old\n+new\n"), + ])) + #expect(!older.completeFileList) + #expect(older.files.first?.additions == 1) +} diff --git a/apps/macos/Tests/T3CoreTests/ScheduledPromptContractTests.swift b/apps/macos/Tests/T3CoreTests/ScheduledPromptContractTests.swift new file mode 100644 index 0000000000000..a7559f412d6f5 --- /dev/null +++ b/apps/macos/Tests/T3CoreTests/ScheduledPromptContractTests.swift @@ -0,0 +1,127 @@ +import Foundation +import Testing + +@testable import T3Core + +@Suite struct ScheduledPromptContractTests { + private func json(_ text: String) throws -> JSON { try JSON.decode(Data(text.utf8)) } + private func draft() -> ScheduledPromptDraft { + var draft = ScheduledPromptDraft() + draft.title = " Daily review " + draft.prompt = " Inspect the project " + draft.projectID = "project-1" + draft.model = .object([ + "instanceId": .string("codex_work"), "model": .string("gpt-current"), + "options": .array([.object(["id": .string("effort"), "value": .string("high")])]), + ]) + return draft + } + @Test func serverWallClockIsSentVerbatimWithSundayBasedWeekdays() throws { + var value = draft() + value.timeOfDay = "09:30" + value.weekdays = [5, 1, 3] + let wire = try JSON.decode(value.payload().data()) + #expect( + try wire["schedule"] + == json( + """ + {"type":"fixed_time","timeOfDay":"09:30","weekdays":[1,3,5]} + """)) + #expect(wire["title"] == .string("Daily review")) + #expect(wire["modelSelection"] == value.model) + #expect(wire["workspaceStrategy"] == .object(["type": .string("root")])) + #expect(wire["creationSource"] == .string("web")) + #expect(wire["threadId"] == .null) + value.weekdays = Set(0...6) + #expect(try value.payload()["schedule"]["weekdays"] == .null) + value.weekdays = [] + #expect(try value.payload()["schedule"]["weekdays"] == .null) + value.timeOfDay = "24:00" + #expect(throws: T3Error.self) { try value.payload() } + value.timeOfDay = "09:30" + value.weekdays = [7] + #expect(throws: T3Error.self) { try value.payload() } + } + @Test func retriesReuseTaskAndCommandIDsAndWritesEnforceMinuteMinimum() throws { + var value = draft() + value.mode = "interval" + value.intervalMinutes = "1.25" + let first = try value.payload() + #expect(try first == value.payload()) + #expect(first["schedule"]["everyMs"] == .number(75_000)) + #expect(first["id"] == .string(value.id)) + #expect(first["requireExisting"] == .bool(false)) + for invalid in ["0.999", "nan", "inf", "-10", "1e100"] { + value.intervalMinutes = invalid + #expect(throws: T3Error.self) { try value.payload() } + } + value.mode = "webhook" + #expect(throws: T3Error.self) { try value.payload() } + } + @Test func readingRealWireDatesAndEditingPreservesHistoryAndEndConditionsByOmission() throws { + let task = try ScheduledPrompt( + json( + """ + {"id":"scheduled-task:existing","title":"Review","prompt":"Inspect changes","enabled":true, + "schedule":{"type":"interval","everyMs":30000},"projectId":"project-1","threadId":"thread-8", + "workspaceStrategy":{"type":"worktree","baseRef":"release","branch":"scheduled-review","startFromOrigin":false}, + "modelSelection":{"instanceId":"claude_work","model":"opus","options":[{"id":"effort","value":"high"}]}, + "runtimeMode":"full-access","interactionMode":"plan","runCount":3,"lastRunStatus":"failed", + "nextRunAt":null,"lastRunAt":"2026-10-12T14:33:45.123Z","endsAt":"2026-10-11T09:00:00-04:00","maxRuns":3} + """)) + #expect(task.lifecycle == "Ended") + #expect(task.lastRun == Date(timeIntervalSince1970: 1791815625.123)) + var value = ScheduledPromptDraft(task: task) + #expect(throws: T3Error.self) { try value.payload() } + value.intervalMinutes = "15" + value.title = "Renamed" + let wire = try value.payload() + #expect(wire["requireExisting"] == .bool(true)) + #expect(wire["id"] == .string(task.id)) + #expect(wire["workspaceStrategy"] == task.workspace) + #expect(wire["modelSelection"] == task.model) + #expect(wire["endsAt"] == .null) + #expect(wire["maxRuns"] == .null) + if case .object(let fields) = wire { + #expect(fields["endsAt"] == nil) + #expect(fields["maxRuns"] == nil) + #expect(fields["runCount"] == nil) + #expect(fields["nextRunAt"] == nil) + } + #expect( + try ScheduledPrompt.date(.string("2026-10-12T10:33:45-04:00")) + == ScheduledPrompt.date(.string("2026-10-12T14:33:45Z"))) + #expect(throws: T3Error.self) { try ScheduledPrompt.date(.string("2026-13-99")) } + } + @Test func unknownTriggerIsReadableButCannotBeEditedAndWorktreePathsAreServerSide() throws { + let task = try ScheduledPrompt( + json( + """ + {"id":"future","title":"Future trigger","prompt":"Inspect","enabled":false,"schedule":{"type":"future-trigger"}, + "projectId":"project-1","modelSelection":{"instanceId":"codex","model":"gpt"},"runCount":0,"lastRunStatus":"never"} + """)) + #expect(!task.editable) + #expect(task.lifecycle == "Paused") + #expect(throws: T3Error.self) { try ScheduledPromptDraft(task: task).payload() } + var value = draft() + value.workspaceMode = "existing_worktree" + #expect(throws: T3Error.self) { try value.payload() } + value.worktreePath = "/remote/worktrees/review" + #expect( + try value.payload()["workspaceStrategy"]["worktreePath"] + == .string("/remote/worktrees/review")) + } + + @Test func interruptedCreateRecoversExactPayloadAcrossSettingsLifetimes() throws { + var value = draft() + value.workspaceMode = "worktree" + value.baseRef = "release" + value.weekdays = [1, 5] + let submitted = try value.payload() + let persisted = try JSONEncoder().encode(value) + let recovered = try JSONDecoder().decode(ScheduledPromptDraft.self, from: persisted) + #expect(try recovered.payload() == submitted) + #expect(recovered.id == value.id) + #expect(recovered.commandID == value.commandID) + } +} diff --git a/apps/macos/Tests/T3CoreTests/SplitDiffTests.swift b/apps/macos/Tests/T3CoreTests/SplitDiffTests.swift new file mode 100644 index 0000000000000..32958ea6b3bd7 --- /dev/null +++ b/apps/macos/Tests/T3CoreTests/SplitDiffTests.swift @@ -0,0 +1,67 @@ +import Testing + +@testable import T3Core + +@Test func splitDiffPairsReplacementsAndPadsUnequalSidesWithoutCrossingHunks() throws { + let file = try #require( + UnifiedDiff.parse( + """ + diff --git a/file b/file + @@ -2,3 +2,4 @@ + context + -old first + -old second + +new first + +new second + +new third + @@ -100 +101 @@ + -last old + +last new + """ + ).first) + let split = SplitDiff(file) + #expect(!split.limited) + #expect( + split.rows.map { $0.old?.text } == [ + "@@ -2,3 +2,4 @@", "context", "old first", "old second", nil, + "@@ -100 +101 @@", "last old", + ]) + #expect(split.rows.map { $0.new?.newLine } == [nil, 2, 3, 4, 5, nil, 101]) + #expect(split.rows.map { $0.old?.oldLine } == [nil, 2, 3, 4, nil, nil, 100]) +} + +@Test func splitDiffKeepsNoNewlineMarkersOnTheirOwnSide() throws { + let file = try #require( + UnifiedDiff.parse( + """ + diff --git a/file b/file + @@ -1 +1 @@ + -before + \\ No newline at end of file + +after + \\ No newline at end of file + """ + ).first) + let rows = SplitDiff(file).rows + #expect(rows.count == 3) + #expect(rows[1].old?.text == "before" && rows[1].new?.text == "after") + #expect(rows[2].old?.text == "\\ No newline at end of file") + #expect(rows[2].new?.text == "\\ No newline at end of file") + let added = UnifiedDiff.parse("diff --git a/a b/a\n@@ -0,0 +1 @@\n+added\n").first + #expect(SplitDiff(added).rows.last?.old == nil) + let removed = UnifiedDiff.parse("diff --git a/a b/a\n@@ -1 +0,0 @@\n-removed\n").first + #expect(SplitDiff(removed).rows.last?.new == nil) +} + +@Test func splitDiffBoundsSourceWorkAndAccountsForBothCopiesOfContext() throws { + let file = try #require( + UnifiedDiff.parse( + "diff --git a/a b/a\n@@ -1,12000 +0,0 @@\n" + String(repeating: "-old\n", count: 12_000) + ).first) + let split = SplitDiff(file) + #expect(split.limited && split.rows.count == 10_000) + let context = UnifiedDiff.parse("diff --git a/a b/a\n@@ -1 +1 @@\n same\n").first + let short = SplitDiff(context, characterLimit: 10) + #expect(short.limited && short.rows.isEmpty) + #expect(SplitDiff(nil).rows.isEmpty && !SplitDiff(nil).limited) +} diff --git a/apps/macos/Tests/T3CoreTests/StateTests.swift b/apps/macos/Tests/T3CoreTests/StateTests.swift new file mode 100644 index 0000000000000..df6946850df0d --- /dev/null +++ b/apps/macos/Tests/T3CoreTests/StateTests.swift @@ -0,0 +1,288 @@ +import Foundation +import Testing + +@testable import T3Core + +private func item( + _ id: String, text: String, ordinal: Int = 0, thread: String = "thread", + type: String = "assistant_message" +) -> JSON { + .object([ + "id": .string(id), "threadId": .string(thread), "type": .string(type), + "text": .string(text), "ordinal": .number(Double(ordinal)), "streaming": .bool(false), + ]) +} + +private func visible( + _ item: JSON, position: Int, source: String = "thread", inherited: Bool = false +) -> JSON { + .object([ + "item": item, "position": .number(Double(position)), "sourceThreadId": .string(source), + "visibility": .string(inherited ? "inherited" : "local"), + ]) +} + +private func snapshot(_ items: [JSON], sequence: Int = 10) -> JSON { + .object([ + "kind": .string("snapshot"), "snapshotSequence": .number(Double(sequence)), + "projection": .object([ + "thread": .object(["id": .string("thread")]), "visibleTurnItems": .array(items), + ]), + "historyCursor": .string("opaque-cursor"), "hasMoreHistory": .bool(true), + ]) +} + +private func update(_ item: JSON, sequence: Int, type: String = "turn-item.updated") -> JSON { + .object([ + "kind": .string("event"), "sequence": .number(Double(sequence)), + "event": .object(["type": .string(type), "payload": item]), + ]) +} + +@Test func liveUpdatesReplaceOneRowAndRejectReplay() throws { + var state = ThreadState(id: "thread") + _ = try state.apply(snapshot([visible(item("a", text: "old"), position: 0)])) + _ = try state.apply(update(item("a", text: "new"), sequence: 12)) + _ = try state.apply(update(item("a", text: "stale"), sequence: 11)) + _ = try state.apply(update(item("b", text: "next", ordinal: 1), sequence: 13)) + #expect(state.rows.map(\.text) == ["new", "next"]) + #expect(state.sequence == 13) +} + +@Test func historyCannotOverwriteNewerLiveContent() throws { + var state = ThreadState(id: "thread") + _ = try state.apply(snapshot([visible(item("b", text: "initial"), position: 1)])) + _ = try state.apply(update(item("b", text: "live"), sequence: 11)) + try state.prependHistory( + .object([ + "items": .array([ + visible(item("a", text: "older"), position: 0), + visible(item("b", text: "outdated"), position: 1), + ]), + "hasMoreHistory": .bool(false), "nextCursor": .null, "snapshotSequence": .number(10), + ])) + #expect(state.rows.map(\.text) == ["older", "live"]) + #expect(state.sequence == 11) + #expect(!state.hasMore) + #expect(state.cursor == nil) +} + +@Test func inheritedRowsWithSameItemIDStayDistinct() throws { + var state = ThreadState(id: "thread") + _ = try state.apply( + snapshot([ + visible( + item("a", text: "parent", thread: "parent"), position: 0, source: "parent", inherited: true), + visible(item("a", text: "local"), position: 1), + ])) + _ = try state.apply(update(item("a", text: "changed"), sequence: 11)) + #expect(state.rows.map(\.text) == ["parent", "changed"]) + #expect(state.rows[0].inherited) +} + +@Test func malformedKnownEventDoesNotAdvanceCursor() throws { + var state = ThreadState(id: "thread") + _ = try state.apply(snapshot([])) + #expect(throws: T3Error.self) { + try state.apply( + update( + .object([ + "id": .string("a"), "threadId": .string("thread"), "type": .string("assistant_message"), + ]), sequence: 11)) + } + #expect(state.sequence == 10) + #expect(state.rows.isEmpty) +} + +@Test func unrelatedThreadCannotReplaceCurrentSnapshot() throws { + var state = ThreadState(id: "other") + #expect(throws: T3Error.self) { try state.apply(snapshot([])) } + #expect(state.sequence == -1) +} + +@Test func futureEventsAdvanceCursorAndRollbackRequiresSnapshot() throws { + var state = ThreadState(id: "thread") + _ = try state.apply(snapshot([])) + #expect(try !state.apply(update(.null, sequence: 11, type: "future.event"))) + #expect(state.sequence == 11) + #expect(try state.apply(update(.null, sequence: 12, type: "checkpoint.rollback-requested"))) + #expect(state.sequence == 11) +} + +@Test func shellRemovesArchivedThreadsAndIgnoresOlderEvents() throws { + let thread: JSON = .object([ + "id": .string("t"), "projectId": .string("p"), "title": .string("Example"), + ]) + var state = ShellState() + try state.apply( + .object([ + "kind": .string("snapshot"), + "snapshot": .object([ + "snapshotSequence": .number(5), "threads": .array([thread]), + "projects": .array([.object(["id": .string("p"), "title": .string("Workspace")])]), + ]), + ])) + try state.apply( + .object([ + "kind": .string("thread.updated"), "sequence": .number(6), "location": .string("archive"), + "thread": thread, + ])) + try state.apply( + .object([ + "kind": .string("thread.updated"), "sequence": .number(5), "location": .string("active"), + "thread": thread, + ])) + #expect(state.threads.isEmpty) + #expect(state.projects["p"] == "Workspace") +} + +@Test func credentialsCannotBeSentOverRemoteHTTPOrAmbiguousURLs() throws { + for endpoint in [ + "http://example.com", "https://user:password@example.com", "https://example.com/?token=x", + "https://example.com/path", + ] { + #expect(throws: T3Error.self) { + try ConnectionSettings(endpoint: URL(string: endpoint)!, token: "secret") + } + } + let settings = try ConnectionSettings( + endpoint: URL(string: "http://127.0.0.1:4773")!, token: "secret") + let request = settings.request("/api/orchestration/shell") + #expect(request.url?.query == nil) + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer secret") + #expect(request.value(forHTTPHeaderField: "x-t3-orchestration-protocol") == "2") +} + +@Test func metadataEnrichmentDoesNotEraseThreadsOrReadiness() throws { + var state = ShellState() + let thread: JSON = .object([ + "id": .string("t"), "projectId": .string("p"), "title": .string("Keep me"), + ]) + try state.apply( + .object([ + "kind": .string("snapshot"), + "snapshot": .object([ + "snapshotSequence": .number(10), "threads": .array([thread]), "projects": .array([]), + ]), + ])) + try state.apply(.object(["kind": .string("synchronized")])) + try state.apply( + .object([ + "kind": .string("snapshot"), "resolvedRepositoryIdentityRoots": .array([]), + "snapshot": .object([ + "snapshotSequence": .number(0), "threads": .array([]), "projects": .array([]), + ]), + ])) + #expect(state.threads["t"]?.title == "Keep me") + #expect(state.sequence == 10) + #expect(state.synchronized) +} + +@Test func authoritativeReconnectSnapshotReplacesCacheAndItsCursor() throws { + var state = ThreadState(id: "thread") + _ = try state.apply(snapshot([visible(item("old", text: "cached"), position: 0)], sequence: 20)) + _ = try state.apply(.object(["kind": .string("synchronized")])) + _ = try state.apply(snapshot([visible(item("new", text: "restored"), position: 0)], sequence: 3)) + #expect(!state.synchronized) + _ = try state.apply(update(item("new", text: "live after restore"), sequence: 4)) + #expect(state.rows.map(\.text) == ["live after restore"]) + #expect(state.sequence == 4) +} + +@Test func fullOutputIsScopedToItsSourceAndCannotOverwriteStreamingUpdates() throws { + func command(_ fields: [String: JSON] = [:]) -> JSON { + .object( + [ + "id": .string("cmd"), "threadId": .string("parent"), "type": .string("command_execution"), + "input": .string("printf hello"), "outputOmitted": .bool(true), + "updatedAt": .string("rev1"), + ] + .merging(fields) { _, new in new }) + } + var state = ThreadState(id: "thread") + _ = try state.apply( + snapshot([visible(command(), position: 0, source: "parent", inherited: true)])) + let original = try #require(state.rows.first) + #expect( + try !state.hydrateOutput( + command([ + "threadId": .string("thread"), "outputOmitted": .bool(false), "output": .string("wrong"), + ]), replacing: original)) + #expect( + try !state.hydrateOutput( + command([ + "updatedAt": .string("rev2"), "outputOmitted": .bool(false), "output": .string("raced"), + ]), replacing: original)) + #expect( + try state.hydrateOutput( + command(["outputOmitted": .bool(false), "output": .string("hello")]), replacing: original)) + #expect(state.rows[0].text == "printf hello\nhello") + #expect(state.rows[0].inherited) + #expect(state.sequence == 10) + + var local = ThreadState(id: "thread") + let localItem = command(["threadId": .string("thread")]) + _ = try local.apply(snapshot([visible(localItem, position: 0)])) + let before = try #require(local.rows.first) + _ = try local.apply( + update( + command([ + "threadId": .string("thread"), "updatedAt": .string("rev2"), + "input": .string("new command"), + ]), sequence: 11)) + #expect( + try !local.hydrateOutput( + command([ + "threadId": .string("thread"), "outputOmitted": .bool(false), + "output": .string("old response"), + ]), replacing: before)) + #expect(local.rows[0].text == "new command") + #expect(local.sequence == 11) +} + +@Test func workspaceRefreshFollowsSettledFileToolsAndCheckpoints() throws { + func fileItem(_ id: String, status: String, type: String = "file_change") -> JSON { + .object([ + "id": .string(id), "threadId": .string("thread"), "type": .string(type), + "status": .string(status), + ]) + } + var state = ThreadState(id: "thread") + _ = try state.apply( + snapshot([ + visible(fileItem("earlier", status: "completed"), position: 0), + visible(fileItem("later", status: "running", type: "command_execution"), position: 1), + ])) + let first = try #require(state.workspaceMutationToken) + _ = try state.apply(update(item("stream", text: "Streaming text"), sequence: 11)) + #expect(state.workspaceMutationToken == first) + _ = try state.apply( + update(fileItem("later", status: "waiting", type: "command_execution"), sequence: 12)) + #expect(state.workspaceMutationToken == first) + _ = try state.apply( + update(fileItem("later", status: "failed", type: "command_execution"), sequence: 13)) + let second = try #require(state.workspaceMutationToken) + #expect(second != first) + _ = try state.apply(update(fileItem("earlier", status: "completed"), sequence: 14)) + #expect(state.workspaceMutationToken == second) + let checkpoint: JSON = .object([ + "id": .string("checkpoint"), "appRunOrdinal": .number(1), + "runId": .string("run"), "capturedAt": .string("2026-10-11T20:00:00Z"), + ]) + _ = try state.apply(update(checkpoint, sequence: 15, type: "checkpoint.captured")) + let captured = state.workspaceMutationToken + #expect(captured != second) + _ = try state.apply(update(checkpoint, sequence: 16, type: "checkpoint.captured")) + #expect(state.workspaceMutationToken == captured) + _ = try state.apply(snapshot([], sequence: 20)) + #expect(state.workspaceMutationToken == nil) + try state.prependHistory( + .object([ + "items": .array([ + visible( + fileItem("inherited", status: "completed"), position: 0, source: "parent", inherited: true + ) + ]) + ])) + #expect(state.workspaceMutationToken?.hasPrefix("parent:inherited") == true) +} diff --git a/apps/macos/Tests/T3CoreTests/TerminalDirectoryTests.swift b/apps/macos/Tests/T3CoreTests/TerminalDirectoryTests.swift new file mode 100644 index 0000000000000..49e65d83a2ffc --- /dev/null +++ b/apps/macos/Tests/T3CoreTests/TerminalDirectoryTests.swift @@ -0,0 +1,39 @@ +import T3Core +import Testing + +@Test func terminalTabsFollowOnlyTheirThreadAndPreserveActivityOrder() throws { + func summary(_ id: String, thread: String = "active", label: String = "zsh") -> JSON { + .object([ + "threadId": .string(thread), "terminalId": .string(id), "label": .string(label), + "status": .string("running"), "hasRunningSubprocess": .bool(label != "zsh"), + ]) + } + var directory = TerminalDirectory(threadID: "active") + try directory.apply( + .object([ + "type": .string("snapshot"), + "terminals": .array([ + summary("term-10"), summary("term-1", thread: "other"), summary("term-2"), + ]), + ])) + #expect(directory.terminals.map(\.id) == ["term-2", "term-10"]) + #expect(directory.receivedSnapshot) + try directory.apply( + .object(["type": .string("upsert"), "terminal": summary("term-2", label: "vim")])) + #expect(directory.terminals.map(\.id) == ["term-2", "term-10"]) + #expect(directory.terminals[0].hasRunningSubprocess) + try directory.apply( + .object([ + "type": .string("remove"), "threadId": .string("other"), "terminalId": .string("term-2"), + ])) + #expect(directory.terminals.count == 2) + try directory.apply( + .object([ + "type": .string("remove"), "threadId": .string("active"), "terminalId": .string("term-2"), + ])) + #expect(directory.terminals.map(\.id) == ["term-10"]) + // A fresh snapshot replaces stale sessions after disconnect. + try directory.apply( + .object(["type": .string("snapshot"), "terminals": .array([summary("term-1")])])) + #expect(directory.terminals.map(\.id) == ["term-1"]) +} diff --git a/apps/macos/Tests/T3CoreTests/TerminalTests.swift b/apps/macos/Tests/T3CoreTests/TerminalTests.swift new file mode 100644 index 0000000000000..a3f97b41934a2 --- /dev/null +++ b/apps/macos/Tests/T3CoreTests/TerminalTests.swift @@ -0,0 +1,138 @@ +import Foundation +import Testing + +@testable import T3Core + +@Test func terminalOutputReplaysOnlyMissingBytesAndResetsOnReconnectOrClear() { + var output = TerminalOutput() + output.append("first\r\n\u{1B}[31mred") + let initial = output.read(after: nil) + #expect(initial.reset) + #expect(String(decoding: initial.bytes, as: UTF8.self) == "first\r\n\u{1B}[31mred") + output.append(" 日本語 👋\r\n") + let incremental = output.read(after: initial.cursor) + #expect(!incremental.reset) + #expect(String(decoding: incremental.bytes, as: UTF8.self) == " 日本語 👋\r\n") + #expect(output.read(after: incremental.cursor).bytes.isEmpty) + output.reset("reconnected history") + let replay = output.read(after: incremental.cursor) + #expect(replay.reset) + #expect(String(decoding: replay.bytes, as: UTF8.self) == "reconnected history") + output.reset() + let cleared = output.read(after: replay.cursor) + #expect(cleared.reset) + #expect(cleared.bytes.isEmpty) +} + +@Test func terminalOutputBoundsHistoryWithoutBreakingUTF8OrLiveOffsets() { + var output = TerminalOutput(limit: 17) + output.append("old history") + let old = output.read(after: nil) + output.append("日本語👋 abcdefgh") + let lagging = output.read(after: nil) + #expect(output.retainedBytes <= 17) + #expect(String(data: lagging.bytes, encoding: .utf8) != nil) + let live = output.read(after: old.cursor) + #expect(live.reset) // This consumer missed more bytes than can be retained. + output.append("xyz") + let tail = output.read(after: lagging.cursor) + #expect(!tail.reset) + #expect(String(decoding: tail.bytes, as: UTF8.self) == "xyz") + var discarded = TerminalOutput(limit: 0) + discarded.append("hello") + #expect(discarded.retainedBytes == 0) + #expect(discarded.read(after: nil).bytes.isEmpty) +} + +@Test func terminalHighVolumeOutputAndInputChunksPreserveUnicodeAndControlSequences() { + let text = String(repeating: "\u{1B}[32m日本語👋\r\n\u{FEFF}", count: 80_000) + let parts = TerminalOutput.split(Data(text.utf8), size: 16 * 1024) + #expect(parts.allSatisfy { $0.count <= 16 * 1024 && String(data: $0, encoding: .utf8) != nil }) + #expect(parts.reduce(into: Data()) { $0.append($1) } == Data(text.utf8)) + var output = TerminalOutput() + output.append(text) + let update = output.read(after: nil) + #expect(output.retainedBytes <= 512 * 1024) + let retained = String(data: update.bytes, encoding: .utf8) + #expect(retained != nil) + #expect(text.hasSuffix(retained!)) +} + +@Test func terminalEventsKeepOtherSessionsOutAndReplaceSnapshotHistory() throws { + let target = TerminalTarget( + environment: "http://localhost:4773", threadID: "thread", terminalID: "term-2") + var state = TerminalState(target: target) + let snapshot: JSON = .object([ + "threadId": .string("thread"), "terminalId": .string("term-2"), + "history": .string("saved"), "status": .string("running"), "pid": .number(42), + "label": .string("zsh"), "sequence": .number(5), + ]) + try state.apply(.object(["type": .string("snapshot"), "snapshot": snapshot])) + let other = TerminalTarget( + environment: target.environment, threadID: "other", terminalID: "term-2") + try state.apply( + other.input(["type": .string("output"), "data": .string("wrong"), "sequence": .number(6)])) + try state.apply( + target.input(["type": .string("output"), "data": .string(" live"), "sequence": .number(6)])) + #expect(String(decoding: state.output.read(after: nil).bytes, as: UTF8.self) == "saved live") + try state.apply( + target.input([ + "type": .string("activity"), "label": .string("build"), "hasRunningSubprocess": .bool(true), + "sequence": .number(7), + ])) + #expect(state.hasRunningSubprocess && state.label == "build") + try state.apply(target.input(["type": .string("exited"), "sequence": .number(8)])) + #expect(state.status == "exited" && state.pid == nil && !state.hasRunningSubprocess) + try state.apply(.object(["type": .string("snapshot"), "snapshot": snapshot])) + #expect(state.pid == 42 && state.status == "running") + #expect(String(decoding: state.output.read(after: nil).bytes, as: UTF8.self) == "saved") +} + +@Test func queuedKeystrokesCoalesceAndPastesRemainBoundedAndInOrder() { + var queue = TerminalInputQueue() + for key in ["h", "e", "l", "l", "o", " 👋", "\r"] { + let accepted = queue.append(key) + #expect(accepted) + } + let keys = queue.next() + #expect(keys == "hello 👋\r") + #expect(queue.byteCount == 0) + let text = String(repeating: "日本語👋", count: 4000) + let pasted = queue.append(text) + let refused = queue.append(String(repeating: "x", count: 1024 * 1024)) + #expect(pasted && !refused) + var result = "" + while let next = queue.next() { + #expect(next.utf8.count <= 16 * 1024) + result += next + } + #expect(result == text) + #expect(queue.byteCount == 0) +} + +@Test func terminalClearCannotBeDiscardedAfterNewerOutputArrivesDuringHistoryPersistence() throws { + let target = TerminalTarget(environment: "env", threadID: "t", terminalID: "term-1") + var state = TerminalState(target: target) + try state.apply( + .object([ + "type": .string("snapshot"), + "snapshot": target.input([ + "history": .string("old history"), "status": .string("running"), "sequence": .number(236), + ]), + ])) + let before = state.output.read(after: nil).cursor + try state.apply( + target.input([ + "type": .string("output"), "data": .string("fresh prompt"), "sequence": .number(242), + ])) + try state.apply(target.input(["type": .string("cleared"), "sequence": .number(237)])) + let cleared = state.output.read(after: before) + #expect(cleared.reset && cleared.bytes.isEmpty) + try state.apply( + target.input([ + "type": .string("output"), "data": .string("next command"), "sequence": .number(243), + ])) + #expect( + String(decoding: state.output.read(after: cleared.cursor).bytes, as: UTF8.self) + == "next command") +} diff --git a/apps/macos/Tests/T3CoreTests/ThreadCheckpointTests.swift b/apps/macos/Tests/T3CoreTests/ThreadCheckpointTests.swift new file mode 100644 index 0000000000000..a7b2bc5464825 --- /dev/null +++ b/apps/macos/Tests/T3CoreTests/ThreadCheckpointTests.swift @@ -0,0 +1,135 @@ +import Testing + +@testable import T3Core + +private func checkpoint( + _ id: String, turn: Int?, run: String?, status: String = "ready", files: [JSON] = [] +) -> JSON { + .object([ + "id": .string(id), "threadId": .string("t"), "runId": run.map(JSON.string) ?? .null, + "appRunOrdinal": turn.map { .number(Double($0)) } ?? .null, + "ref": .string("refs/checkpoints/" + id), "status": .string(status), + "capturedAt": .string("2026-10-11T12:00:00Z"), "files": .array(files), + ]) +} +private func message( + _ id: String, run: String, time: String = "2026-10-11T12:00:00Z", text: String = "reply" +) -> JSON { + .object([ + "id": .string(id), "threadId": .string("t"), "runId": .string(run), + "role": .string("assistant"), "createdAt": .string(time), "text": .string(text), + ]) +} +private func projection(checkpoints: [JSON], messages: [JSON] = []) -> JSON { + .object([ + "thread": .object(["id": .string("t")]), "checkpoints": .array(checkpoints), + "messages": .array(messages), + "runs": .array([ + .object(["id": .string("r1"), "status": .string("completed")]), + .object(["id": .string("r2"), "status": .string("completed")]), + ]), + ]) +} +private func event(_ type: String, _ value: JSON) -> JSON { + .object(["type": .string(type), "payload": value]) +} + +@Test func checkpointOrdinalsExcludeBaselinesAndKeepUnavailableTurnsExplicit() throws { + let index = ThreadCheckpoints( + projection(checkpoints: [ + checkpoint("baseline", turn: nil, run: nil), + checkpoint("c2", turn: 7, run: "r2", status: "missing"), + checkpoint("c1", turn: 2, run: "r1"), checkpoint("subagent", turn: nil, run: "r1"), + ])) + #expect(index.items.map(\.turnCount) == [7, 2]) + #expect(index.items[0].unavailableReason?.contains("missing") == true) + #expect(throws: T3Error.self) { + try CheckpointDiffRequest(index.items[0], fullThread: false, ignoreWhitespace: true) + } + let turn = try CheckpointDiffRequest(index.items[1], fullThread: false, ignoreWhitespace: false) + #expect(turn.from == 1 && turn.to == 2) + #expect(turn.input["ignoreWhitespace"] == .bool(false)) + let full = try CheckpointDiffRequest(index.items[1], fullThread: true, ignoreWhitespace: true) + #expect(full.from == 0 && full.to == 2 && full.input["fromTurnCount"] == .null) + #expect(full.method == "orchestration.getFullThreadDiff") + #expect(throws: T3Error.self) { + try full.validate( + .object([ + "threadId": .string("other"), "fromTurnCount": .number(0), "toTurnCount": .number(2), + "diff": .string(""), + ])) + } +} + +@Test func checkpointUpdatesDoNotRebuildOnTokensOrMoveCardsToOldMessages() throws { + var index = ThreadCheckpoints( + projection( + checkpoints: [checkpoint("c1", turn: 1, run: "r1")], + messages: [ + message("a", run: "r1"), message("b", run: "r1"), + ])) + #expect(index.items.first?.assistantMessageID == "b") + let revision = index.revision + index.apply(event("message.updated", message("a", run: "r1", text: "late old message update"))) + index.apply(event("message.updated", message("b", run: "r1", text: "more text"))) + #expect(index.revision == revision) + index.apply(event("message.updated", message("c", run: "r1", time: "2026-10-11T12:01:00Z"))) + #expect(index.items.first?.assistantMessageID == "c") + #expect(index.revision != revision) + index.apply(event("checkpoint.captured", checkpoint("c2", turn: 2, run: "r2"))) + #expect(index.items.map(\.turnCount) == [2, 1]) + let updated = index.revision + index.apply(event("checkpoint.captured", checkpoint("c2", turn: 2, run: "r2"))) + #expect(index.revision == updated) + index.apply( + event( + "run.updated", + .object(["id": .string("r2"), "threadId": .string("t"), "status": .string("failed")]))) + #expect(index.items.first?.unavailableReason?.contains("not completed") == true) + let rolledBack = ThreadCheckpoints( + projection(checkpoints: [checkpoint("c1", turn: 1, run: "r1")])) + #expect(rolledBack.items.count == 1 && rolledBack.revision != index.revision) +} + +@Test func checkpointCardsFollowTheirAssistantAndNeverDuplicateOrCrossInheritedHistory() throws { + let file: JSON = .object([ + "path": .string("file.swift"), "additions": .number(3), "deletions": .number(1), + ]) + let checkpoints = ThreadCheckpoints( + projection( + checkpoints: [checkpoint("c1", turn: 1, run: "r1", files: [file])], + messages: [message("reply", run: "r1")]) + ).items + func row(_ id: String, kind: String, source: String = "t", inherited: Bool = false) throws + -> TimelineRow + { + try TimelineRow( + item: .object([ + "id": .string(id), "type": .string(kind), "messageId": .string("reply"), + "checkpointId": .string("c1"), "text": .string("reply"), "files": .array([file]), + ]), position: 1, sourceID: source, inherited: inherited) + } + let assistant = try row("assistant", kind: "assistant_message") + let checkpointRow = try row("checkpoint", kind: "checkpoint") + let entries = ConversationTimeline.entries( + from: [assistant, checkpointRow], activeRunID: nil, checkpoints: checkpoints) + #expect(entries.count == 2 && entries.first?.item == assistant) + #expect(entries.last == .changes(checkpoints[0])) + #expect( + ConversationTimeline.entries( + from: [checkpointRow, assistant], activeRunID: nil, checkpoints: checkpoints) == entries) + let inherited = try row("assistant", kind: "assistant_message", source: "parent", inherited: true) + #expect( + ConversationTimeline.entries(from: [inherited], activeRunID: nil, checkpoints: checkpoints) == [ + .item(inherited) + ]) +} + +@Test func checkpointPatchesCanBeCopiedPerFileWithoutReserializingTheirContents() throws { + let first = "diff --git a/a b/a\n@@ -1 +1 @@\n-before\r\n+after\r\n" + let second = "diff --git a/b b/b\n@@ -0,0 +1 @@\n+new\n\\ No newline at end of file\n" + let files = UnifiedDiff.parse(first + second) + try #require(files.count == 2) + #expect(files[0].rawPatch == first && files[1].rawPatch == second) + #expect(files[0].lines.last?.text == "after\r") +} diff --git a/apps/macos/Tests/T3CoreTests/ThreadExecutionTests.swift b/apps/macos/Tests/T3CoreTests/ThreadExecutionTests.swift new file mode 100644 index 0000000000000..b81dcbbad503f --- /dev/null +++ b/apps/macos/Tests/T3CoreTests/ThreadExecutionTests.swift @@ -0,0 +1,220 @@ +import Foundation +import Testing + +@testable import T3Core + +private func executionFixture() throws -> JSON { + try JSONDecoder().decode( + JSON.self, + from: Data( + #""" + { + "thread":{"id":"thread","activeProviderThreadId":"provider-thread"}, + "runs":[ + {"id":"active","ordinal":1,"status":"running","providerThreadId":"provider-thread","activeAttemptId":"attempt","userMessageId":"original","startedAt":"2026-10-11T12:00:00Z","completedAt":null}, + {"id":"first","ordinal":2,"queuePosition":2,"status":"queued","userMessageId":"one"}, + {"id":"second","ordinal":3,"queuePosition":1,"status":"queued","userMessageId":"two"}, + {"id":"wake","ordinal":4,"queuePosition":3,"status":"queued","userMessageId":"automatic","queueHeld":true} + ], + "messages":[ + {"id":"one","role":"user","text":"First draft","attachments":[{"id":"document","type":"file","name":"context.txt","sizeBytes":16,"mimeType":"text/plain"}],"context":{"version":1,"records":[{"contextId":"file","attachmentId":"document"},{"contextId":"terminal","text":"retain this"}]}}, + {"id":"two","role":"user","text":"Second draft","attachments":[]}, + {"id":"automatic","role":"user","text":"Agent wake","notification":{"kind":"background"}} + ], + "providerThreads":[{"id":"provider-thread","appThreadId":"thread","providerSessionId":"session"}], + "providerSessions":[{"id":"session","status":"ready","createdAt":"2026-10-11T12:00:00Z","capabilities":{"turns":{"supportsQueuedMessages":true,"supportsActiveSteering":true,"supportsSteeringByInterruptRestart":true}}}], + "providerTurns":[{"id":"turn","runAttemptId":"attempt","status":"running"}] + } + """#.utf8)) +} + +private func changed(_ value: JSON, _ fields: [String: JSON]) -> JSON { + guard case .object(var existing) = value else { return .object(fields) } + existing.merge(fields) { _, new in new } + return .object(existing) +} +private func executionEvent(_ type: String, _ payload: JSON) -> JSON { + .object(["type": .string(type), "threadId": .string("thread"), "payload": payload]) +} + +@Test func queueTracksServerOrderEditsAndAutomaticWorkSeparately() throws { + let fixture = try executionFixture() + var execution = ThreadExecution(fixture) + #expect(execution.queued.map(\.id) == ["second", "first"]) + #expect(execution.queued.last?.attachments.first?["id"].string == "document") + #expect(execution.isHeld) + #expect(execution.canQueue && execution.canSteer && execution.canRestart) + execution.apply( + executionEvent("run.updated", changed(fixture["runs"].array[1], ["queuePosition": .number(1)]))) + #expect(execution.queued.map(\.id) == ["first", "second"]) + execution.apply( + executionEvent( + "message.updated", + changed(fixture["messages"].array[0], ["text": .string("Edited from another client")]))) + #expect(execution.queued.first?.text == "Edited from another client") + execution.apply( + executionEvent( + "run.updated", changed(fixture["runs"].array[1], ["status": .string("cancelled")]))) + #expect(execution.queued.map(\.id) == ["second"]) + #expect(execution.hiddenUserMessageIDs.contains("one")) + let queuedItem: JSON = .object([ + "type": .string("user_message"), "messageId": .string("one"), + "inputIntent": .string("queued_turn"), + ]) + #expect(execution.hidesQueuedMessage(queuedItem)) + #expect( + !execution.hidesQueuedMessage( + changed(queuedItem, ["inputIntent": .string("promoted_queued_to_steer")]))) + execution.apply( + executionEvent( + "run.updated", changed(fixture["runs"].array[2], ["status": .string("starting")]))) + #expect(execution.queued.isEmpty) + #expect(!execution.hiddenUserMessageIDs.contains("two")) +} + +@Test func steeringRequiresTheCurrentLiveProviderTurnAndAttachedSession() throws { + let fixture = try executionFixture() + var execution = ThreadExecution(fixture) + execution.apply( + executionEvent( + "provider-turn.updated", + changed(fixture["providerTurns"].array[0], ["status": .string("completed")]))) + #expect(!execution.canSteer && !execution.canRestart) + #expect(execution.canQueue) + execution.apply( + executionEvent( + "provider-turn.updated", + changed(fixture["providerTurns"].array[0], ["runAttemptId": .string("old-attempt")]))) + #expect(!execution.canSteer) + execution.apply(executionEvent("provider-turn.updated", fixture["providerTurns"].array[0])) + #expect(execution.canSteer) + execution.apply( + executionEvent( + "provider-session.updated", + changed( + fixture["providerSessions"].array[0], + [ + "capabilities": .object([ + "turns": .object(["supportsSteeringByInterruptRestart": .bool(true)]) + ]) + ]))) + #expect(execution.canSteer && execution.steeringInterruptsTools && !execution.canQueue) + execution.apply( + executionEvent("provider-session.detached", .object(["providerSessionId": .string("session")]))) + #expect(!execution.canSteer && !execution.canRestart && !execution.canQueue) +} + +@Test func resumeChoosesLastExecutedWorkRatherThanTheHighestQueueOrdinal() throws { + let fixture = try executionFixture() + let interrupted = changed( + fixture["runs"].array[0], + ["status": .string("interrupted"), "completedAt": .string("2026-10-11T12:05:00Z")]) + var execution = ThreadExecution( + changed(fixture, ["runs": .array([interrupted] + fixture["runs"].array.dropFirst())])) + #expect(execution.resumableRunID == "active") + let newerOrdinal = changed( + interrupted, + [ + "id": .string("older-completion"), "ordinal": .number(10), + "completedAt": .string("2026-10-11T12:02:00Z"), "status": .string("completed"), + ]) + execution.apply(executionEvent("run.created", newerOrdinal)) + #expect(execution.resumableRunID == "active") + execution.apply( + executionEvent( + "run.updated", changed(newerOrdinal, ["completedAt": .string("2026-10-11T12:06:00Z")]))) + #expect(execution.resumableRunID == nil) + #expect(execution.isHeld) +} + +@Test func onlyLatestRootUsageFailureEnablesFailedRunResume() throws { + let fixture = try executionFixture() + let run = changed( + fixture["runs"].array[0], + [ + "status": .string("failed"), "rootNodeId": .string("root"), + "completedAt": .string("2026-10-11T12:05:00Z"), + ]) + var execution = ThreadExecution(changed(fixture, ["runs": .array([run])])) + let failure: JSON = .object([ + "id": .string("failure"), "type": .string("error"), "runId": .string("active"), + "nodeId": .string("subagent"), "status": .string("failed"), "ordinal": .number(2), + "updatedAt": .string("2026-10-11T12:05:00Z"), + "failure": .object(["class": .string("usage_limit"), "message": .string("Limit")]), + ]) + execution.apply(executionEvent("turn-item.updated", failure)) + #expect(execution.resumableRunID == nil) + execution.apply( + executionEvent("turn-item.updated", changed(failure, ["nodeId": .string("root")]))) + #expect(execution.resumableRunID == "active") + execution.apply( + executionEvent( + "provider-session.updated", + changed( + fixture["providerSessions"].array[0], + ["lastError": .string("Session authentication failed")]))) + #expect(execution.resumableRunID == nil) +} + +@Test func queueEventsRespectThreadSequenceAndOwnership() throws { + let fixture = try executionFixture() + var state = ThreadState(id: "thread") + _ = try state.apply( + .object(["kind": .string("snapshot"), "snapshotSequence": .number(10), "projection": fixture])) + let payload = changed(fixture["runs"].array[1], ["status": .string("cancelled")]) + let event = executionEvent("run.updated", payload) + _ = try state.apply(.object(["kind": .string("event"), "sequence": .number(9), "event": event])) + #expect(state.execution.queued.count == 2) + let foreign = changed(event, ["threadId": .string("another-thread")]) + #expect(throws: T3Error.self) { + try state.apply(.object(["kind": .string("event"), "sequence": .number(11), "event": foreign])) + } + #expect(state.sequence == 10) + _ = try state.apply(.object(["kind": .string("event"), "sequence": .number(11), "event": event])) + #expect(state.execution.queued.map(\.id) == ["second"]) +} + +@Test func queuedEditsAndDeliveryRetryIdentitySurviveRestartBesideANormalDraft() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let repository = DraftRepository(directory: root) + let endpoint = URL(string: "https://example.test")! + let message = try #require(ThreadExecution(executionFixture()).queued.last) + var edit = QueuedEdit(message, threadID: "thread") + let otherMessage = try #require(ThreadExecution(executionFixture()).queued.first) + var otherEdit = QueuedEdit(otherMessage, threadID: "thread") + otherEdit.active = false + let pending = PendingSubmission( + text: "Saved edit", attachments: edit.attachments, localAttachmentIDs: [], + queuedRunID: edit.runID, retainedAttachmentIDs: ["document"], context: edit.retainedContext) + try repository.save( + SavedDrafts( + messages: [ + "thread": "Unrelated unsent message", edit.draftID: "Saved edit", + otherEdit.draftID: "Another saved edit", + ], + submissions: [edit.draftID: pending], + queuedEdits: [edit.runID: edit, otherEdit.runID: otherEdit], + deliveries: ["thread": .queue]), for: endpoint) + let restored = try repository.load(for: endpoint) + #expect(restored.messages["thread"] == "Unrelated unsent message") + #expect(restored.queuedEdits?[edit.runID] == edit) + #expect(restored.queuedEdits?[otherEdit.runID] == otherEdit) + #expect(restored.messages[otherEdit.draftID] == "Another saved edit") + #expect( + restored.submissions[edit.draftID]?.payload(threadID: "thread") + == pending.payload(threadID: "thread")) + #expect(pending.matches(text: "Saved edit", attachments: [], edit: edit)) + edit.attachments = [] + #expect(!pending.matches(text: "Saved edit", attachments: [], edit: edit)) + #expect(edit.retainedContext["records"].array.map { $0["contextId"].string } == ["terminal"]) + let queued = PendingSubmission(text: "Next", delivery: .queue) + #expect(queued.payload(threadID: "thread")["dispatchMode"]["type"].string == "queue_after_active") + #expect(queued.payload(threadID: "thread")["deliveryIntent"] == .null) + #expect(queued.matches(text: "Next", attachments: [], delivery: .queue)) + #expect(!queued.matches(text: "Next", attachments: [], delivery: .steer)) + let resumed = PendingSubmission(text: "Continue", continuationOf: "interrupted") + #expect(resumed.payload(threadID: "thread")["manualContinuationOfRunId"].string == "interrupted") + #expect(resumed.payload(threadID: "thread")["deliveryIntent"] == .null) + try AttachmentPolicy().validate([], retained: message.attachments) +} diff --git a/apps/macos/Tests/T3CoreTests/ThreadLifecycleTests.swift b/apps/macos/Tests/T3CoreTests/ThreadLifecycleTests.swift new file mode 100644 index 0000000000000..dff785db07c72 --- /dev/null +++ b/apps/macos/Tests/T3CoreTests/ThreadLifecycleTests.swift @@ -0,0 +1,155 @@ +import Foundation +import Testing + +@testable import T3Core + +private let now = ThreadDates.parse("2026-10-11T16:00:00.000Z")! +private func shellThread(_ id: String = "t", _ fields: [String: JSON] = [:]) throws -> ThreadSummary +{ + try ThreadSummary( + .object( + [ + "id": .string(id), "projectId": .string("p"), "title": .string(id), + "createdAt": .string("2026-10-01T00:00:00Z"), "updatedAt": .string("2026-10-11T15:00:00Z"), + ].merging(fields) { _, value in value })) +} + +@Test func activityDoesNotReorderThreadsButReopeningAndManualKeysDo() throws { + let oldBusy = try shellThread("old", ["updatedAt": .string("2026-10-11T16:00:00Z")]) + let newer = try shellThread("new", ["createdAt": .string("2026-10-10T00:00:00Z")]) + let reopened = try shellThread("reopened", ["unsettledAt": .string("2026-10-11T00:00:00Z")]) + let firstManual = try shellThread("first", ["activeOrderKey": .string("a0")]) + let secondManual = try shellThread("second", ["activeOrderKey": .string("a1")]) + #expect( + ThreadGroup.sorted([oldBusy, secondManual, newer, firstManual, reopened], in: .active).map(\.id) + == ["reopened", "new", "old", "first", "second"]) + let pinned = try shellThread("pinned", ["pinOrderKey": .string("a0")]) + #expect( + ThreadGroup.sorted([newer, pinned, oldBusy], in: .pinned).map(\.id) == ["pinned", "new", "old"]) +} + +@Test func settledOrderUsesSettlementNotUnrelatedMetadataChanges() throws { + let older = try shellThread( + "old", + ["settledAt": .string("2026-10-01T00:00:00Z"), "updatedAt": .string("2026-10-12T00:00:00Z")]) + let newer = try shellThread("new", ["settledAt": .string("2026-10-10T00:00:00Z")]) + let legacy = try shellThread("legacy", ["latestRunCompletedAt": .string("2026-10-09T00:00:00Z")]) + #expect( + ThreadGroup.sorted([older, newer, legacy], in: .settled).map(\.id) == ["new", "legacy", "old"]) +} + +@Test func snoozeWakesOnAttentionFreshFailureCompletionOrExpiry() throws { + let snooze: [String: JSON] = [ + "snoozedAt": .string("2026-10-11T15:00:00Z"), + "snoozedUntil": .string("2026-10-11T18:00:00Z"), + ] + func state(_ extra: [String: JSON] = [:]) throws -> ThreadLifecycle { + try shellThread("t", snooze.merging(extra) { _, new in new }).lifecycle + } + #expect(try state().snoozed(at: now)) + #expect(try !state().snoozed(at: now.addingTimeInterval(7200))) + #expect( + try !state(["pendingRuntimeRequest": .object(["kind": .string("user_input")])]).snoozed(at: now) + ) + #expect(try state(["status": .string("failed")]).snoozed(at: now)) + let failed = try state([ + "status": .string("failed"), "updatedAt": .string("2026-10-11T15:30:00Z"), + ]) + #expect(!failed.snoozed(at: now)) + let finished = try state([ + "latestRunId": .string("run"), "status": .string("idle"), + "latestRunCompletedAt": .string("2026-10-11T15:30:00Z"), + ]) + #expect(!finished.snoozed(at: now)) + #expect(finished.wokeAt(now) == finished.wokeAt(now.addingTimeInterval(10800))) + #expect(try !state(["snoozedUntil": .string("invalid")]).snoozed(at: now)) +} + +@Test func queuedStartsAreBoundedAndRunningThreadsRemainSnoozable() throws { + let recent = try shellThread("t", ["latestUserMessageAt": .string("2026-10-11T15:59:30Z")]) + .lifecycle + #expect(!recent.canSnooze(at: now)) + #expect(recent.canSnooze(at: now.addingTimeInterval(121))) + let skewed = try shellThread("t", ["latestUserMessageAt": .string("2026-10-11T16:00:30Z")]) + .lifecycle + #expect(!skewed.canSnooze(at: now)) + let adopted = try shellThread( + "t", + [ + "latestUserMessageAt": .string("2026-10-11T15:59:30Z"), + "latestRunId": .string("run"), "latestRunRequestedAt": .string("2026-10-11T15:59:30Z"), + "status": .string("running"), + ] + ).lifecycle + #expect(adopted.canSnooze(at: now)) +} + +@Test func serverVisitWatermarkAndBackgroundWorkDriveStatus() throws { + let complete: [String: JSON] = [ + "latestRunId": .string("run"), "status": .string("idle"), + "latestRunCompletedAt": .string("2026-10-11T15:00:00Z"), + "lastVisitedAt": .string("2026-10-11T14:00:00Z"), + ] + let unread = try shellThread("t", complete) + #expect(unread.lifecycle.statusLabel(at: now) == "Completed") + let read = try shellThread( + "t", complete.merging(["lastVisitedAt": .string("2026-10-11T16:00:00Z")]) { _, new in new }) + #expect(!read.lifecycle.unread) + #expect(read.lifecycle.statusLabel(at: now) == nil) + let command = try shellThread( + "t", + complete.merging(["pendingBackgroundTasks": .array([.object(["kind": .string("command")])])]) { + _, new in new + }) + #expect(command.lifecycle.statusLabel(at: now) == "Completed") + let agent = try shellThread( + "t", + complete.merging(["pendingBackgroundTasks": .array([.object(["kind": .string("subagent")])])]) { + _, new in new + }) + #expect(agent.lifecycle.statusLabel(at: now) == "Waiting") +} + +@Test func archivesHaveIndependentSequenceAndRestoringRemovesArchiveRow() throws { + let item: JSON = .object([ + "id": .string("t"), "projectId": .string("p"), "title": .string("Archived"), + "archivedAt": .string("2026-10-11T00:00:00Z"), + ]) + var archived = ShellState(archived: true) + try archived.apply( + .object([ + "kind": .string("snapshot"), + "snapshot": .object([ + "snapshotSequence": .number(99), "threads": .array([item]), "projects": .array([]), + ]), + ])) + #expect(archived.synchronized) + #expect(archived.threads["t"]?.archived == true) + try archived.apply( + .object(["kind": .string("thread.removed"), "sequence": .number(100), "threadId": .string("t")]) + ) + try archived.apply( + .object(["kind": .string("thread.updated"), "sequence": .number(99), "thread": item])) + #expect(archived.threads.isEmpty) + try archived.apply( + .object([ + "kind": .string("snapshot"), + "snapshot": .object([ + "snapshotSequence": .number(2), "threads": .array([item]), "projects": .array([]), + ]), + ])) + #expect(archived.threads.count == 1) + #expect(archived.sequence == 2) +} + +@Test func snoozeCalendarPresetsRespectDSTAndDoNotDuplicateMonday() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "America/New_York")! + let beforeSpring = ThreadDates.parse("2026-03-07T23:30:00-05:00")! + let tomorrow = try #require( + SnoozePreset.choices(at: beforeSpring, calendar: calendar).first { $0.title == "Tomorrow" }) + #expect(ThreadDates.string(tomorrow.date) == "2026-03-08T13:00:00.000Z") + let sunday = ThreadDates.parse("2026-10-11T12:00:00-04:00")! + #expect( + !SnoozePreset.choices(at: sunday, calendar: calendar).contains { $0.title == "Next week" }) +} diff --git a/apps/macos/Tests/T3CoreTests/UsageTests.swift b/apps/macos/Tests/T3CoreTests/UsageTests.swift new file mode 100644 index 0000000000000..8aaf088223320 --- /dev/null +++ b/apps/macos/Tests/T3CoreTests/UsageTests.swift @@ -0,0 +1,191 @@ +import Foundation +import Testing + +@testable import T3Core + +private let usageNow = ThreadDates.parse("2026-10-11T20:00:00Z")! + +private func quota(_ id: String, used: Double, kind: String = "weekly", reset: String? = nil) + -> JSON +{ + .object([ + "id": .string(id), "label": .string(id), "kind": .string(kind), "usedPercent": .number(used), + "resetsAt": reset.map(JSON.string) ?? .null, + "windowDurationMins": .number(kind == "session" ? 300 : 10_080), + ]) +} +private func usageProvider( + _ id: String, email: String? = nil, windows: [JSON], checked: String = "2026-10-11T19:00:00Z", + fingerprint: String? = nil +) -> JSON { + .object([ + "instanceId": .string(id), "driver": .string("claudeAgent"), "displayName": .string(id), + "enabled": .bool(true), "installed": .bool(true), + "auth": .object(["email": email.map(JSON.string) ?? .null]), + "usageLimits": .object([ + "checkedAt": .string(checked), "windows": .array(windows), + "credentialFingerprint": fingerprint.map(JSON.string) ?? .null, + ]), + ]) +} +private func limitsSnapshot(_ providers: [JSON], sources: [JSON] = []) -> UsageLimitsSnapshot { + var snapshot = UsageLimitsSnapshot() + snapshot.apply( + .object([ + "type": .string("snapshot"), + "config": .object([ + "providers": .array(providers), "usageLimitSources": .array(sources), + ]), + ])) + return snapshot +} + +@Test func usageDeduplicatesAccountsAcrossInstancesAndHubsAndKeepsNativeNames() throws { + let a = usageProvider("Work", email: " SAME@example.test ", windows: [quota("week", used: 20)]) + let b = usageProvider( + "Duplicate", email: "same@example.test", windows: [quota("week", used: 30)], + checked: "2026-10-11T19:30:00Z") + let limits: JSON = .object([ + "checkedAt": .string("2026-10-11T20:00:00Z"), "windows": .array([quota("week", used: 90)]), + "resetCredits": .object(["availableCount": .number(2), "nextCreditId": .string("credit-a")]), + ]) + let source: JSON = .object([ + "id": .string("hub"), "label": .string("Hub"), + "accounts": .array([ + .object([ + "id": .string("account"), "driver": .string("claudeAgent"), + "email": .string("same@example.test"), "usageLimits": limits, + ]) + ]), + ]) + var snapshot = limitsSnapshot([a, b], sources: [source]) + let account = try #require(snapshot.accounts.first) + #expect(snapshot.accounts.count == 1) + #expect(account.name == "Work") + #expect(account.windows.first?.remaining == 10) + #expect(account.credits == 2) + #expect(account.redeemInput?["sourceId"].string == "hub") + snapshot.apply( + .object([ + "type": .string("usageLimitSourcesUpdated"), "payload": .object(["sources": .array([])]), + ])) + #expect(snapshot.accounts.first?.windows.first?.remaining == 70) + #expect(snapshot.accounts.first?.credits == nil) + let sameCredential = limitsSnapshot([ + usageProvider("One", windows: [quota("week", used: 10)], fingerprint: "hash"), + usageProvider("Two", windows: [quota("week", used: 10)], fingerprint: "hash"), + ]) + #expect(sameCredential.accounts.count == 1) +} + +@Test func usagePoolsOnlyReportedWindowsAndKeepsFixedAccountColumnsAndKinds() throws { + let snapshot = limitsSnapshot([ + usageProvider( + "A", + windows: [quota("week", used: 96, reset: "2026-10-12T20:00:00Z"), quota("fable", used: 100)]), + usageProvider( + "B", + windows: [quota("week", used: 56, reset: "2026-10-13T20:00:00Z"), quota("fable", used: 81)]), + usageProvider( + "C", + windows: [ + quota("week", used: 37, reset: "2026-10-14T20:00:00Z"), quota("fable", used: 47), + quota("week", used: 4, kind: "monthly"), + ]), + ]) + let pool = try #require(snapshot.pools(at: usageNow).first) + let weekly = try #require(pool.windows.first { $0.id == "weekly:week" }) + #expect(weekly.remaining == 37) + #expect(weekly.nextRecovery == 32) + #expect(pool.windows.first { $0.id == "weekly:fable" }?.remaining == 24) + let monthly = try #require(pool.windows.first { $0.id == "monthly:week" }) + #expect(monthly.remaining == 96) + #expect(monthly.columns.map(\.account.name) == weekly.columns.map(\.account.name)) + #expect(monthly.columns.compactMap(\.window).count == 1) +} + +@Test func usageDoesNotInventQuotaWhenAProbeFailsOrHasNoWindow() throws { + var provider = try #require(usageProvider("Unavailable", windows: []).objectValue) + provider["usageLimits"] = .object([ + "windows": .array([quota("week", used: 0)]), + "unavailable": .object([ + "reason": .string("probeFailed"), "message": .string("Try again"), + ]), + ]) + let snapshot = limitsSnapshot([.object(provider), usageProvider("Empty", windows: [])]) + #expect(snapshot.accounts.isEmpty) + #expect(snapshot.notices == ["Unavailable: Try again", "Empty: No limits reported."]) + let window = try #require(UsageWindow(quota("week", used: 10))) + #expect(window.resetDescription(at: usageNow) == nil) + #expect(window.elapsed(at: usageNow) == nil) + let expired = try #require(UsageWindow(quota("week", used: 100, reset: "2026-10-10T20:00:00Z"))) + #expect(expired.resetDescription(at: usageNow) == "Resets now") + #expect(expired.remaining == 0) +} + +@Test func usageWindowsRespectLocalCalendarDaysAndExact24HoursAcrossDST() throws { + let zone = try #require(TimeZone(identifier: "America/New_York")) + for timestamp in ["2026-03-08T23:30:00Z", "2026-11-01T23:30:00Z"] { + let now = try #require(ThreadDates.parse(timestamp)) + let day = UsagePeriod.day.window(at: now, timeZone: zone) + let start = try #require(ThreadDates.parse(day.input["sinceTime"].string)) + let end = try #require(ThreadDates.parse(day.input["untilTime"].string)) + #expect(end.timeIntervalSince(start) == 86_400) + #expect(day.periods.count == 24) + #expect(Set(day.periods).count == 24) + let week = UsagePeriod.week.window(at: now, timeZone: zone) + #expect(week.periods.count == 7) + #expect( + week.input["sinceDay"].string == (timestamp.contains("03-08") ? "2026-03-02" : "2026-10-26")) + } +} + +@Test func usageHistoryDoesNotDoubleCountReasoningOrSessionsAndExposesMissingPrices() throws { + let window = UsagePeriod.week.window(at: usageNow, timeZone: TimeZone(secondsFromGMT: 0)!) + func bucket(_ day: String, model: String, unpriced: Bool = false) -> JSON { + .object([ + "day": .string(day), "provider": .string("claude"), "model": .string(model), + "costUsd": .number(unpriced ? 0 : 2.5), "records": .number(2), + "unpricedRecords": .number(unpriced ? 2 : 0), + "sessions": .number(50), + "totals": .object([ + "uncachedInputTokens": .number(100), "cachedInputTokens": .number(200), + "cacheCreationTokens": .number(30), + "outputTokens": .number(40), "reasoningTokens": .number(15), + ]), + ]) + } + var payload: [String: JSON] = [ + "contractVersion": .number(6), "readAt": .string(ThreadDates.string(usageNow)), + "pricing": .object(["status": .string("cached")]), + "sources": .array([ + .object([ + "distinctSessions": .number(3), "status": .string("partial"), + "message": .string("One file skipped"), + "fingerprint": .object(["provider": .string("claude")]), + ]) + ]), + "buckets": .array([ + bucket("2026-10-10", model: "fable"), bucket("2026-10-11", model: "fable"), + bucket("2026-10-11", model: "new-model", unpriced: true), + bucket("2026-10-01", model: "outside-window"), + ]), + ] + let history = try UsageHistory(.object(payload), window: window) + #expect(history.totals.totalTokens == 1_110) + #expect(history.totals.reasoning == 45) + #expect(history.totals.cost == 5) + #expect(history.sessions == 3) + #expect(history.periods.count == 7) + #expect(history.periods.first?.totals.totalTokens == 0) + #expect(history.models.first { $0.name == "new-model" }?.totals.costUnknown == true) + #expect(history.notices == ["Claude: One file skipped"]) + payload["contractVersion"] = .number(7) + #expect(throws: T3Error.self) { try UsageHistory(.object(payload), window: window) } +} + +extension JSON { + fileprivate var objectValue: [String: JSON]? { + if case .object(let fields) = self { fields } else { nil } + } +} diff --git a/apps/macos/Tests/T3CoreTests/WorkspaceDefaultsContractTests.swift b/apps/macos/Tests/T3CoreTests/WorkspaceDefaultsContractTests.swift new file mode 100644 index 0000000000000..74b8165da090c --- /dev/null +++ b/apps/macos/Tests/T3CoreTests/WorkspaceDefaultsContractTests.swift @@ -0,0 +1,175 @@ +import Foundation +import Testing + +@testable import T3Core + +@Suite struct WorkspaceDefaultsContractTests { + private func json(_ contents: String) throws -> JSON { + try JSON.decode(Data(contents.utf8)) + } + + @Test func projectOverridesBeatLegacyAndEnvironmentAndDoNotLeakBetweenProjects() throws { + let settings = try json( + """ + {"defaultThreadEnvMode":"worktree","projectSettingsFolded":false, + "projectSettingsOverrides":{"project-a":{"defaultThreadEnvMode":"local"}}} + """) + #expect( + WorkspaceDefaults.configuredMode( + settings: settings, + project: try json(#"{"id":"project-a","defaultThreadEnvMode":"worktree"}"#)) == "local") + #expect( + WorkspaceDefaults.configuredMode( + settings: settings, project: try json(#"{"id":"project-b"}"#)) == "worktree") + } + + @Test func explicitNullOverrideMasksEnvironmentAndLegacyAndDefersToRepository() throws { + let settings = try json( + """ + {"defaultThreadEnvMode":"local","projectSettingsFolded":false, + "projectSettingsOverrides":{"project-a":{"defaultThreadEnvMode":null}}} + """) + let project = try json(#"{"id":"project-a","defaultThreadEnvMode":"local"}"#) + let configured = WorkspaceDefaults.configuredMode(settings: settings, project: project) + #expect(configured == nil) + #expect( + configured + ?? WorkspaceDefaults.fileMode(contents: #"{"defaultThreadEnvMode":"worktree"}"#) + ?? "local" == "worktree") + #expect(configured ?? WorkspaceDefaults.fileMode(contents: "{invalid") ?? "local" == "local") + } + + @Test func foldingDisablesStaleAggregateValuesButKeepsExplicitOverrides() throws { + let project = try json(#"{"id":"project-a","defaultThreadEnvMode":"worktree"}"#) + var settings = try json(#"{"defaultThreadEnvMode":"local","projectSettingsFolded":false}"#) + #expect(WorkspaceDefaults.configuredMode(settings: settings, project: project) == "worktree") + settings = try json(#"{"defaultThreadEnvMode":"local","projectSettingsFolded":true}"#) + #expect(WorkspaceDefaults.configuredMode(settings: settings, project: project) == "local") + settings = try json( + """ + {"defaultThreadEnvMode":null,"projectSettingsFolded":true, + "projectSettingsOverrides":{"project-a":{"defaultThreadEnvMode":"local"}}} + """) + #expect(WorkspaceDefaults.configuredMode(settings: settings, project: project) == "local") + #expect( + WorkspaceDefaults.configuredMode( + settings: try json(#"{"defaultThreadEnvMode":null,"projectSettingsFolded":true}"#), + project: project) == nil) + } + + @Test func absentOverrideKeyAndNullLegacyStillUseTheEnvironmentWithoutReadingAFile() throws { + let settings = try json( + """ + {"defaultThreadEnvMode":"local","projectSettingsFolded":false, + "projectSettingsOverrides":{"project-a":{"defaultRuntimeMode":"full-access"}}} + """) + #expect( + WorkspaceDefaults.configuredMode( + settings: settings, + project: try json(#"{"id":"project-a","defaultThreadEnvMode":null}"#)) == "local") + #expect( + WorkspaceDefaults.configuredMode( + settings: .object([:]), project: try json(#"{"id":"project-a"}"#)) == nil) + } + + @Test func repositoryJSONCPreservesStringsAndAcceptsNestedTrailingCommas() { + let contents = #""" + { + /* Repository choice; // inside the block is ordinary comment text. */ + "defaultThreadEnvMode": "worktree", // trailing comment + "iconPath": "assets/logo.svg", + "worktreeSubmodules": "top-level", + "scripts": [ + { + "name": "Run // local /* preview */ ,] ,}", + "command": "printf \"// /* literal */ ,] ,}\"; echo C:\\tools\\", + "previewUrl": "http://localhost:3000/a,]//path", + "async": false, + }, + ], + "unknownFutureField": {"nested": [1, 2, /* before close */],}, + } // end of file + """# + #expect(WorkspaceDefaults.fileMode(contents: contents) == "worktree") + #expect( + WorkspaceDefaults.fileMode( + contents: #"{"note":"a,] // worktree","defaultThreadEnvMode":"local",}"#) + == "local") + } + + @Test(arguments: [ + "", "null", "[]", "{ not json", #"{"defaultThreadEnvMode":"worktree""#, + #"{"defaultThreadEnvMode":"worktree"} /* unterminated"#, + #"{"defaultThreadEnvMode":"worktree",,}"#, + #"{"defaultThreadEnvMode":"worktree","extra":[1,,]}"#, + #"{"defaultThreadEnvMode":"worktree","extra":[,]}"#, + #"{"defaultThreadEnvMode":"worktree","extra":tru/* split token */e}"#, + #"{"defaultThreadEnvMode":" worktree "}"#, + #"{"defaultThreadEnvMode":"spaceship"}"#, + #"{"defaultThreadEnvMode":null}"#, + #"{"defaultThreadEnvMode":true}"#, + ]) + func malformedOrIncompleteRepositoryFilesDeferToLocal(contents: String) { + #expect(WorkspaceDefaults.fileMode(contents: contents) == nil) + #expect(WorkspaceDefaults.fileMode(contents: contents) ?? "local" == "local") + } + + @Test(arguments: [ + #""scripts":[{"name":"Dev"}]"#, + #""scripts":[{"name":" ","command":"pnpm dev"}]"#, + #""scripts":[{"name":"Dev","command":"pnpm dev","icon":"unknown"}]"#, + #""scripts":[{"name":"Dev","command":"pnpm dev","async":null}]"#, + #""scripts":[{"name":"Dev","command":"pnpm dev","previewUrl":" "}]"#, + #""scripts":null"#, + #""iconPath":" ""#, + #""$schema":3"#, + #""worktreeSubmodules":"invalid""#, + ]) + func invalidKnownProjectFileFieldsRejectTheWholeFile(field: String) { + #expect( + WorkspaceDefaults.fileMode(contents: "{\"defaultThreadEnvMode\":\"worktree\",\(field)}") + == nil) + } + + @Test func projectFileLimitsMatchTheSharedDecoder() { + let script = #"{"name":"Dev","command":"pnpm dev"}"# + let atLimit = Array(repeating: script, count: 50).joined(separator: ",") + let overLimit = atLimit + "," + script + #expect( + WorkspaceDefaults.fileMode( + contents: "{\"defaultThreadEnvMode\":\"worktree\",\"scripts\":[\(atLimit)]}") == "worktree") + #expect( + WorkspaceDefaults.fileMode( + contents: "{\"defaultThreadEnvMode\":\"worktree\",\"scripts\":[\(overLimit)]}") == nil) + let icon = String(repeating: "a", count: 513) + #expect( + WorkspaceDefaults.fileMode( + contents: "{\"defaultThreadEnvMode\":\"worktree\",\"iconPath\":\"\(icon)\"}") == nil) + #expect(WorkspaceDefaults.fileMode(contents: #"{"scripts":[]}"#) == nil) + } + + @Test func repeatedRepositoryKeysUseTheLastValueAsJSONParseDoes() { + #expect( + WorkspaceDefaults.fileMode( + contents: #"{"defaultThreadEnvMode":"local","defaultThreadEnvMode":"worktree"}"#) + == "worktree") + #expect( + WorkspaceDefaults.fileMode( + contents: #"{"defaultThreadEnvMode":"worktree","defaultThreadEnvMode":null}"#) == nil) + } + + @Test func repositoryFilesAreBoundedBeforeParsing() { + let document = #"{"defaultThreadEnvMode":"worktree","note":"é"}"# + let atLimit = document + String(repeating: " ", count: 1_048_576 - document.utf8.count) + #expect(WorkspaceDefaults.fileMode(contents: atLimit) == "worktree") + #expect(WorkspaceDefaults.fileMode(contents: atLimit + " ") == nil) + let nested = + #"{"defaultThreadEnvMode":"worktree","extra":"# + + String(repeating: "[", count: 32) + "0" + String(repeating: "]", count: 32) + "}" + #expect(WorkspaceDefaults.fileMode(contents: nested) == "worktree") + let tooDeep = + #"{"defaultThreadEnvMode":"worktree","extra":"# + + String(repeating: "[", count: 65) + "0" + String(repeating: "]", count: 65) + "}" + #expect(WorkspaceDefaults.fileMode(contents: tooDeep) == nil) + } +} diff --git a/apps/macos/Tests/T3CoreTests/WorkspaceFilesTests.swift b/apps/macos/Tests/T3CoreTests/WorkspaceFilesTests.swift new file mode 100644 index 0000000000000..f3fd1a9aa88fa --- /dev/null +++ b/apps/macos/Tests/T3CoreTests/WorkspaceFilesTests.swift @@ -0,0 +1,137 @@ +import Foundation +import Testing + +@testable import T3Core + +struct WorkspaceFilesTests { + private func file(_ contents: String, path: String = "src/file.swift", truncated: Bool = false) + throws + -> WorkspaceFileDocument + { + try WorkspaceFileDocument( + .object([ + "relativePath": .string(path), "contents": .string(contents), + "byteLength": .number(Double(contents.utf8.count)), "truncated": .bool(truncated), + ])) + } + + @Test func saveConflictsAndLostRepliesPreserveEdits() throws { + var document = try file("original\r\n🦉\n") + document.contents = "my edit\r\n🦉\n" + try document.validateSave(against: file("original\r\n🦉\n")) + #expect(throws: T3Error.self) { try document.validateSave(against: file("agent edit")) } + #expect(document.isDirty) + try document.validateSave(against: file(document.contents)) + let submitted = document.contents + document.contents += "more edits while saving" + document.acknowledgeSave(submitted) + #expect(document.isDirty) + #expect(document.original == submitted) + try document.validateSave(against: file(submitted)) + #expect(throws: T3Error.self) { + try document.validateSave(against: file(submitted, truncated: true)) + } + #expect(throws: T3Error.self) { + try document.validateSave(against: file(submitted, path: "another.swift")) + } + for path in ["/etc/hosts", "../outside.txt", "src/../../outside.txt"] { + let host = try file("text", path: path) + #expect(throws: T3Error.self) { try host.validateSave(against: host) } + } + } + + @Test func folderEntriesStayImmediateAndNaturallyOrdered() throws { + func entry(_ path: String, _ kind: String = "file", ignored: Bool = false) -> JSON { + .object(["path": .string(path), "kind": .string(kind), "ignored": .bool(ignored)]) + } + let result: JSON = .object([ + "entries": .array([ + entry("src/z.swift"), entry("src/folder", "directory"), entry("src/file10.swift"), + entry("src/file2.swift", ignored: true), entry("src/folder/hidden.swift"), + entry("src/file2.swift"), entry("other/file.swift"), + ]) + ]) + let children = try WorkspaceEntry.entries(result, directory: "src") + #expect( + children.map(\.path) == ["src/folder", "src/file2.swift", "src/file10.swift", "src/z.swift"]) + #expect(children[1].ignored) + let search = try WorkspaceEntry.entries(result) + #expect(search.count == 6) + #expect(search.contains { $0.path == "src/folder/hidden.swift" }) + } + + @Test func recoveryIsPrivateAndScopedToEnvironmentAndRoot() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let repository = WorkspaceDraftRepository(directory: directory) + #expect(try repository.load().isEmpty) + func id(_ environment: String, root: String) throws -> WorkspaceFileID { + let thread = try ThreadSummary( + .object([ + "id": .string("thread"), "projectId": .string("project"), "title": .string("Test"), + ])) + let location = WorkspaceLocation( + environment: environment, thread: thread, + project: .object(["workspaceRoot": .string(root)]))! + return WorkspaceFileID(location: location, path: "src/file.swift") + } + let first = try id("one", root: "/workspace") + let otherServer = try id("two", root: "/workspace") + let worktree = try id("one", root: "/worktree") + var edited = try file("before") + edited.contents = "saved locally\n日本語" + try repository.save([first: edited, otherServer: edited, worktree: try file("clean")]) + #expect(try repository.load() == [first: edited, otherServer: edited]) + let mode = + try FileManager.default.attributesOfItem(atPath: repository.file.path)[.posixPermissions] + as? NSNumber + #expect(mode?.intValue == 0o600) + try repository.save([:]) + #expect(try repository.load().isEmpty) + try Data("invalid recovery".utf8).write(to: repository.file) + #expect(throws: (any Error).self) { try repository.load() } + #expect(try String(contentsOf: repository.file, encoding: .utf8) == "invalid recovery") + } +} + +@Test func undoDuringAnUncertainSaveSurvivesRestart() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let repository = WorkspaceDraftRepository(directory: directory) + let thread = try ThreadSummary( + .object([ + "id": .string("thread"), "projectId": .string("project"), "title": .string("Test"), + ])) + let location = WorkspaceLocation( + environment: "server", thread: thread, + project: .object(["workspaceRoot": .string("/workspace")]))! + let id = WorkspaceFileID(location: location, path: "file.txt") + func document(_ text: String) throws -> WorkspaceFileDocument { + try WorkspaceFileDocument( + .object([ + "relativePath": .string("file.txt"), "contents": .string(text), + "byteLength": .number(Double(text.utf8.count)), "truncated": .bool(false), + ])) + } + var local = try document("original") + local.contents = "first edit" + local.recordSaveAttempt("first edit") + local.contents = "original" + #expect(!local.isDirty && local.requiresRecovery) + try repository.save([id: local]) + var recovered = try #require(repository.load()[id]) + try recovered.validateSave(against: document("first edit")) + #expect(recovered.contents == "original") + #expect(throws: T3Error.self) { + try recovered.validateSave(against: document("another client's edit")) + } + recovered.acknowledgeSave("first edit") + recovered.recordSaveAttempt("original") + try repository.save([id: recovered]) + recovered = try #require(repository.load()[id]) + try recovered.validateSave(against: document("original")) + recovered.acknowledgeSave("original") + #expect(!recovered.requiresRecovery) + try repository.save([id: recovered]) + #expect(try repository.load().isEmpty) +} diff --git a/apps/macos/Tests/T3CoreTests/WorkspaceMediaTests.swift b/apps/macos/Tests/T3CoreTests/WorkspaceMediaTests.swift new file mode 100644 index 0000000000000..dd1e23ba8b90e --- /dev/null +++ b/apps/macos/Tests/T3CoreTests/WorkspaceMediaTests.swift @@ -0,0 +1,85 @@ +import Foundation +import Testing + +@testable import T3Core + +@Test func mediaAssetsUseTheCorrectWorkspaceOrSingleHostFileGrant() throws { + let thread = try ThreadSummary( + .object([ + "id": .string("thread"), "projectId": .string("project"), "title": .string("Preview"), + "worktreePath": .string("/repo/work"), + ])) + let location = try #require( + WorkspaceLocation(environment: "remote", thread: thread, project: .null)) + for (path, absolute, tag) in [ + ("docs/page.html", "/repo/work/docs/page.html", "workspace-file"), + ("sound.wav", "/repo/work/sound.wav", "media-file"), + ("video.mp4", "/repo/work/video.mp4", "media-file"), + ("/repo/work/docs/page.html", "/repo/work/docs/page.html", "workspace-file"), + ("../report.pdf", "/repo/report.pdf", "media-file"), + ("/repo/work-other/page.html", "/repo/work-other/page.html", "media-file"), + ("/repo/Work/page.html", "/repo/Work/page.html", "media-file"), + ] { + let resource = WorkspaceMediaRequest.resource(path: path, at: location) + #expect(resource["_tag"].string == tag) + #expect(resource["path"].string == absolute) + #expect(resource["threadId"].string == "thread") + } + let endpoint = URL(string: "https://host.test")! + let url = try WorkspaceMediaRequest.freshURL( + .object(["relativeUrl": .string("/api/assets/signed/My%20File.pdf?token=a%2Bb")]), + endpoint: endpoint, revision: 7) + let parts = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)) + #expect(parts.host == "host.test") + #expect(parts.path == "/api/assets/signed/My File.pdf") + #expect(parts.queryItems?.first(where: { $0.name == "token" })?.value == "a+b") + #expect(parts.queryItems?.first(where: { $0.name == "workspace-revision" })?.value == "7") + for invalid in [ + "https://different.test/api/assets/file", "//different.test/api/assets/file", + "/api/auth/secret", + ] { + #expect(throws: T3Error.self) { + try WorkspaceMediaRequest.freshURL( + .object(["relativeUrl": .string(invalid)]), endpoint: endpoint, revision: 1) + } + } +} + +@Test func playbackRetriesPreservePauseAndPositionWithoutRetryingBadMediaForever() { + var playback = MediaPlaybackRecovery() + playback.capture(position: 42.5, rate: 1.5) + let refresh1 = playback.refreshAfterFailure() + #expect(refresh1) + let refresh2 = playback.refreshAfterFailure() + #expect(!refresh2) + #expect(playback.position == 42.5) + #expect(playback.rate == 1.5) + playback.capture(position: .nan, rate: 0) + #expect(playback.position == 42.5) + #expect(playback.rate == 0) + let refresh3 = playback.refreshAfterFailure() + #expect(!refresh3) + playback.retry() + let refresh4 = playback.refreshAfterFailure() + #expect(refresh4) + playback.capture(position: 45, rate: 1) + let refresh5 = playback.refreshAfterFailure() + #expect(refresh5) + let refresh6 = playback.refreshAfterFailure() + #expect(!refresh6) + playback.capture(position: .infinity, rate: .infinity) + #expect(playback.position == 45) + #expect(playback.rate == 0) +} + +@Test func htmlSourcePreferenceIsIndependentOfMarkdownAndTables() throws { + let name = "T3MediaTests-" + UUID().uuidString + let defaults = try #require(UserDefaults(suiteName: name)) + defer { defaults.removePersistentDomain(forName: name) } + let preferences = FilePreviewPreferences(defaults: defaults) + #expect(preferences.rendered(.html)) + preferences.setRendered(false, for: .html) + #expect(!FilePreviewPreferences(defaults: defaults).rendered(.html)) + #expect(preferences.rendered(.csv)) + #expect(!preferences.rendered(.markdown)) +} diff --git a/apps/macos/Tests/T3MarkdownTests/MarkdownTests.swift b/apps/macos/Tests/T3MarkdownTests/MarkdownTests.swift new file mode 100644 index 0000000000000..37a5a446071ef --- /dev/null +++ b/apps/macos/Tests/T3MarkdownTests/MarkdownTests.swift @@ -0,0 +1,369 @@ +import AppKit +import Testing + +@testable import T3Markdown + +@MainActor @Test func editableTasksLocateExactSourceMarkersAcrossUnicodeAndNestedLists() throws { + for newline in ["\n", "\r\n", "\r"] { + let source = [ + "# 🦉 café", "", "- [ ] first", " - [X] second", "", "> 1. [x] quoted", "", + "```md", "- [ ] code, never editable", "```", "", "- [ ] fourth with [ ] literal", + ].joined(separator: newline) + let rendered = MarkdownRenderer.render(source, width: 360, taskLinks: true) + #expect( + rendered.tasks.count == 4, + "newline=\(newline.debugDescription), tasks=\(rendered.tasks.map(\.sourceOffset)), text=\(rendered.text.string.debugDescription)" + ) + for (marker, checked) in [ + ("[ ] first", false), ("[X] second", true), ("[x] quoted", true), ("[ ] fourth", false), + ] { + let expected = (source as NSString).range(of: marker).location + let task = try #require(rendered.tasks.first(where: { $0.sourceOffset == expected })) + #expect(task.checked == checked) + #expect(rendered.text.attributedSubstring(from: task.range).string == (checked ? "☑" : "☐")) + #expect( + (rendered.text.attribute(.link, at: task.range.location, effectiveRange: nil) as? URL)? + .lastPathComponent == String(expected)) + } + let readonly = MarkdownRenderer.render(source, width: 360) + #expect(readonly.tasks.isEmpty) + #expect(readonly.text.string == rendered.text.string) + } +} + +@MainActor @Test func workspaceLinkResolutionControlsTheRenderedDestination() { + let rendered = MarkdownRenderer.render("[sibling](../notes.md) [blocked](custom:run)", width: 400) + { destination in + destination == "../notes.md" ? URL(fileURLWithPath: "/repo/notes.md") : nil + } + let text = rendered.text.string as NSString + #expect( + (rendered.text.attribute(.link, at: text.range(of: "sibling").location, effectiveRange: nil) + as? URL)?.path == "/repo/notes.md") + #expect( + rendered.text.attribute(.link, at: text.range(of: "blocked").location, effectiveRange: nil) + == nil) +} + +@MainActor @Test func blocksKeepTheirContentAndStructure() throws { + let source = """ + # Heading + + A **bold** paragraph with `inline_code` and [link](https://example.test). + + 3. Three + 4. Four + - Nested + + - [x] Done + - [ ] Remaining + + > Quoted text + + ```swift + let greeting = "hello" + print(greeting) + ``` + """ + let rendered = MarkdownRenderer.render(source, width: 600) + #expect(rendered.text.string.contains("Heading\n")) + #expect(rendered.text.string.contains("3.\tThree")) + #expect(rendered.text.string.contains("4.\tFour")) + #expect(rendered.text.string.contains("•\tNested")) + #expect(rendered.text.string.contains("☑\tDone")) + #expect(rendered.text.string.contains("☐\tRemaining")) + #expect(rendered.codeBlocks.count == 1) + #expect(rendered.codeBlocks[0].code == "let greeting = \"hello\"\nprint(greeting)\n") + let string = rendered.text.string as NSString + let headingFont = try #require( + rendered.text.attribute(.font, at: 0, effectiveRange: nil) as? NSFont) + let paragraphFont = try #require( + rendered.text.attribute(.font, at: string.range(of: "paragraph").location, effectiveRange: nil) + as? NSFont) + #expect(headingFont.pointSize > paragraphFont.pointSize) + let link = + rendered.text.attribute(.link, at: string.range(of: "link").location, effectiveRange: nil) + as? URL + #expect(link?.absoluteString == "https://example.test") + #expect(MarkdownRenderer.height(of: rendered.text, width: 600) > 150) +} + +@MainActor @Test func gfmTablesUseNativeTextTablesAndReflow() throws { + let source = """ + | Name | Description | + | :--- | ---: | + | First | A longer description that should wrap as the available space gets smaller. | + | Second | **Formatted** content | + """ + let wide = MarkdownRenderer.render(source, width: 700) + let narrow = MarkdownRenderer.render(source, width: 240) + let style = try #require( + wide.text.attribute(.paragraphStyle, at: 0, effectiveRange: nil) as? NSParagraphStyle) + let block = try #require(style.textBlocks.first as? NSTextTableBlock) + #expect(block.table.numberOfColumns == 2) + #expect(wide.text.string.contains("First\nA longer description")) + #expect( + MarkdownRenderer.height(of: narrow.text, width: 240) + > MarkdownRenderer.height(of: wide.text, width: 700)) + let description = (wide.text.string as NSString).range(of: "Description") + let aligned = try #require( + wide.text.attribute(.paragraphStyle, at: description.location, effectiveRange: nil) + as? NSParagraphStyle) + #expect(aligned.alignment == .right) +} + +@MainActor @Test func incompleteStreamingCodeAndUnicodeRemainCopyable() throws { + let code = "// 🌊 café\nlet value = \"**literal**\"" + let rendered = MarkdownRenderer.render("```swift\n" + code, width: 340) + let block = try #require(rendered.codeBlocks.first) + #expect(block.code.trimmingCharacters(in: .newlines) == code) + #expect(NSMaxRange(block.range) <= rendered.text.length) + #expect(rendered.text.attributedSubstring(from: block.range).string.contains("**literal**")) + #expect(MarkdownRenderer.height(of: rendered.text, width: 340).isFinite) +} + +@MainActor @Test func markupDoesNotActivateExecutableSchemesOrHTML() throws { + let rendered = MarkdownRenderer.render( + "[bad](javascript:alert%281%29) ![Image description](https://example.test/picture.png)", + width: 400) + #expect(rendered.text.string.contains("bad")) + #expect(rendered.text.string.contains("[Image description]")) + #expect(rendered.text.attribute(.link, at: 0, effectiveRange: nil) == nil) + #expect(rendered.text.string.contains(" + """, baseURL: URL(string: "https://native-fixture.test")) + } + } + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + continuation?.resume() + continuation = nil + } + func webView( + _ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, + withError error: any Error + ) { + continuation?.resume(throwing: error) + continuation = nil + } + func query(_ operation: String, _ input: JSON = .object([:])) async throws -> JSON { + try await automation.query( + operation, input: input, deadline: BrowserAutomationDeadline(milliseconds: 5000)) + } + func image(_ options: BrowserRecordingOptions = .init()) async throws -> NSImage { + try await BrowserCapture.image( + web: web, cursor: automation.cursor, mouse: options.showMouse, + keys: options.showKeys) + } + func close() { + automation.interrupt() + web.stopLoading() + web.navigationDelegate = nil + window.close() + } +} + +@MainActor @Test func browserCursorUsesScrolledClippedZoomedFixtureCoordinates() async throws { + let fixture = BrowserArtifactFixture() + defer { fixture.close() } + try await fixture.load() + fixture.web.pageZoom = 1.5 + fixture.viewport.setting = try BrowserViewport(width: 400, height: 500) + fixture.viewport.layoutSubtreeIfNeeded() + let target = try await fixture.query("pointer", .object(["selector": .string("#target")])) + guard case .number(let x) = target["x"], case .number(let y) = target["y"] else { + throw T3Error("The fixture target has no clipped visible point.") + } + #expect(x >= 80 && x < 260) + #expect(y >= 0 && Double(y) < fixture.web.bounds.height / fixture.web.pageZoom) + let cursor = fixture.automation.cursor + cursor.reduceMotionOverride = false + _ = try cursor.move(css: CGPoint(x: 10, y: 10), zoom: fixture.web.pageZoom, animate: false) + let duration = try cursor.move(css: CGPoint(x: x, y: y), zoom: fixture.web.pageZoom) + #expect(duration == 0.24) + #expect(cursor.movementAnimation != nil) + #expect(cursor.target == CGPoint(x: Double(x) * 1.5, y: Double(y) * 1.5)) + let shown = cursor.convert(cursor.target!, to: fixture.viewport) + #expect(fixture.viewport.bounds.contains(shown)) + #expect(cursor.hitTest(.zero) == nil) + #expect(cursor.contentGeometryFlipped) + let first = try await fixture.image() + let second = try await fixture.image(.init(showMouse: false, showKeys: false)) + let firstData = try BrowserAnnotationRenderer.png(first) + let secondData = try BrowserAnnotationRenderer.png(second) + #expect(firstData != secondData) + try browserArtifactEvidence(firstData, name: "cursor-fixture.png") + // Capture never hides the cursor or removes its animation. + #expect(!cursor.isHidden) + #expect(cursor.target != nil) + cursor.reduceMotionOverride = true + #expect(try cursor.move(css: CGPoint(x: 20, y: 20), zoom: 1.5) == 0) + #expect(cursor.movementAnimation == nil) + #expect(throws: (any Error).self) { try cursor.move(css: CGPoint(x: -1, y: 20), zoom: 1.5) } + fixture.automation.interrupt() + #expect(cursor.isHidden) +} + +@MainActor @Test func browserAnnotationsSaveActualFixturePixelsAndMetadata() async throws { + let fixture = BrowserArtifactFixture() + defer { fixture.close() } + try await fixture.load() + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + "t3-annotation-test-" + UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let image = try await fixture.image() + let document = BrowserAnnotationDocument( + pageURL: fixture.web.url!.absoluteString, + annotations: [ + .init(tool: .rectangle, points: [.init(x: 0.2, y: 0.3), .init(x: 0.8, y: 0.7)]), + .init(tool: .arrow, points: [.init(x: 0.1, y: 0.1), .init(x: 0.5, y: 0.5)]), + ]) + let file = try BrowserAnnotationRenderer.save(image, document: document, directory: directory) + let png = try Data(contentsOf: file) + #expect(NSImage(data: png) != nil) + #expect(png != (try BrowserAnnotationRenderer.png(image))) + try browserArtifactEvidence(png, name: "annotation-fixture.png") + let sidecar = file.deletingPathExtension().appendingPathExtension("json") + #expect( + try BrowserAnnotationDocument.deserialize(Data(contentsOf: sidecar)).annotations + == document.annotations) +} + +@MainActor @Test(arguments: [30, 60]) func browserRecordingEncodesRealFixtureAndCleansCancellation( + fps: Int +) async throws { + let fixture = BrowserArtifactFixture() + defer { fixture.close() } + try await fixture.load() + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + "t3-recording-test-" + UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let recording = BrowserRecording() + let options = BrowserRecordingOptions(framesPerSecond: fps) + var captured = 0 + // Observe real capture completion rather than waiting for an arbitrary sleep. + let thirdFrame = AsyncStream.makeStream() + try await recording.start(options: options, directory: directory) { options in + let image = try await fixture.image(options) + captured += 1 + if captured >= 3 { + thirdFrame.continuation.yield(()) + thirdFrame.continuation.finish() + } + return image + } + for await _ in thirdFrame.stream { break } + #expect(recording.frameCount >= 2) + let file = try await recording.stop() + #expect(recording.state == .saved(file)) + let asset = AVURLAsset(url: file) + let tracks = try await asset.loadTracks(withMediaType: .video) + #expect(tracks.count == 1) + let size = try await tracks[0].load(.naturalSize) + #expect(size.width > 0 && size.height > 0) + let duration = try await asset.load(.duration) + #expect(duration.seconds > 0) + let decoded = try await AVAssetImageGenerator(asset: asset).image(at: .zero).image + #expect(decoded.width == Int(size.width)) + let encoded = try Data(contentsOf: file) + #expect(encoded.count > 100) + try browserArtifactEvidence(encoded, name: "recording-\(fps).mp4") + let savedFiles = try FileManager.default.contentsOfDirectory(atPath: directory.path) + try await recording.start(options: options, directory: directory) { try await fixture.image($0) } + recording.cancel() + #expect(recording.state == .idle) + #expect(try FileManager.default.contentsOfDirectory(atPath: directory.path) == savedFiles) + #expect(throws: (any Error).self) { try BrowserRecordingOptions(framesPerSecond: 15).validate() } +} + +@MainActor @Test func browserRecordingCancelledDuringRealSnapshotCannotLeavePartialArtifact() + async throws +{ + let fixture = BrowserArtifactFixture() + defer { fixture.close() } + try await fixture.load() + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + "t3-recording-cancel-" + UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let recording = BrowserRecording() + do { + try await recording.start(options: .init(), directory: directory) { options in + let image = try await fixture.image(options) + recording.cancel() + return image + } + Issue.record("Cancelled startup unexpectedly started encoding.") + } catch is CancellationError {} + #expect(!recording.active) + #expect(!FileManager.default.fileExists(atPath: directory.path)) + try await recording.start(options: .init(), directory: directory) { try await fixture.image($0) } + recording.cancel() + #expect(try FileManager.default.contentsOfDirectory(atPath: directory.path).isEmpty) +} + +@MainActor @Test func browserClickResolverTargetsActualClippedFixtureElement() async throws { + let fixture = BrowserArtifactFixture() + defer { fixture.close() } + try await fixture.load() + let point = try await fixture.query("click", .object(["selector": .string("#target")])) + guard case .number(let x) = point["x"], case .number(let y) = point["y"] else { + throw T3Error("The clipped button did not resolve to a visible click point.") + } + let hit = try await fixture.automation.run( + "evaluate", + input: .object([ + "expression": .string("document.elementFromPoint(\(x),\(y)).id") + ]), + deadline: BrowserAutomationDeadline(milliseconds: 5000)) + #expect(hit == .string("target")) +} + +private func browserArtifactEvidence(_ data: Data, name: String) throws { + guard let path = ProcessInfo.processInfo.environment["T3_BROWSER_TEST_EVIDENCE_DIR"] else { + return + } + let directory = URL(fileURLWithPath: path, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try data.write(to: directory.appendingPathComponent(name), options: .atomic) +} + +@MainActor @Test func browserChromiumCursorMovesOnRealScrolledClippedZoomedPageAndSurvivesCapture() + async throws +{ + let fixture = BrowserArtifactFixture() + defer { fixture.close() } + try await fixture.load() + fixture.web.pageZoom = 1.5 + let target = try await fixture.query("pointer", .object(["selector": .string("#target")])) + guard case .number(let x) = target["x"], case .number(let y) = target["y"] else { + throw T3Error("Missing target") + } + let module = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent().appendingPathComponent("chromium-helper/cursor.mjs") + let source = try String(contentsOf: module, encoding: .utf8) + let context = JSContext()! + guard + let script = context.evaluateScript( + source.replacingOccurrences( + of: "export const cursorSource =", with: "globalThis.cursorSource ="))?.toString() + else { + throw T3Error("Could not evaluate the helper cursor module.") + } + let before = try await fixture.image() + _ = try await fixture.web.evaluateJavaScript(script) + _ = try await fixture.automation.script( + "return JSON.stringify(await globalThis.__t3AgentCursor.move(x,y));", + arguments: ["x": x, "y": y], world: .page, + deadline: BrowserAutomationDeadline(milliseconds: 5000)) + let value = + try await fixture.web.evaluateJavaScript("globalThis.__t3AgentCursor.state()") as? [String: Any] + let point = value?["position"] as? [String: Double] + #expect(point?["x"] == x && point?["y"] == y) + #expect(value?["visible"] as? Bool == true) + #expect(value?["animations"] as? Int == 0) + let after = try await fixture.image() + let png = try BrowserAnnotationRenderer.png(after) + #expect(png != (try BrowserAnnotationRenderer.png(before))) + try browserArtifactEvidence(png, name: "chromium-cursor-fixture.png") + _ = try await fixture.web.evaluateJavaScript("globalThis.__t3AgentCursor.click()") + _ = try await fixture.image() + let visible = + try await fixture.web.evaluateJavaScript("globalThis.__t3AgentCursor.state().visible") as? Bool + #expect(visible == true) + _ = try await fixture.web.evaluateJavaScript("globalThis.__t3AgentCursor.hide()") + let hidden = + try await fixture.web.evaluateJavaScript("globalThis.__t3AgentCursor.state().visible") as? Bool + #expect(hidden == false) +} + +@MainActor @Test func browserRecordingQuitSaveWaitsForRealCaptureAndEncoderCompletion() async throws +{ + let fixture = BrowserArtifactFixture() + defer { fixture.close() } + try await fixture.load() + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + "t3-recording-quit-" + UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let recording = BrowserRecording() + var finishing: Task? + try await recording.start(options: .init(), directory: directory) { options in + if finishing == nil { finishing = Task { try await recording.finishForQuit() } } + return try await fixture.image(options) + } + let file = try await finishing!.value + #expect(recording.state == .saved(file)) + #expect(!recording.active) + let decoded = try await AVAssetImageGenerator(asset: AVURLAsset(url: file)).image(at: .zero).image + #expect(decoded.width > 0 && decoded.height > 0) +} diff --git a/apps/macos/Tests/T3NativeTests/BrowserDownloadTests.swift b/apps/macos/Tests/T3NativeTests/BrowserDownloadTests.swift new file mode 100644 index 0000000000000..cc9decb7adfbb --- /dev/null +++ b/apps/macos/Tests/T3NativeTests/BrowserDownloadTests.swift @@ -0,0 +1,51 @@ +import Foundation +import Testing + +@testable import T3Native + +@Test func browserDownloadsPreserveExistingFilesUntilCompleteAndOnCancellation() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let target = directory.appendingPathComponent("report.txt") + try Data("original".utf8).write(to: target) + let cancelled = try BrowserDownloadFile(destination: target) + try Data("incomplete".utf8).write(to: cancelled.partial) + #expect(try String(contentsOf: target, encoding: .utf8) == "original") + cancelled.discard() + #expect(try String(contentsOf: target, encoding: .utf8) == "original") + #expect(!FileManager.default.fileExists(atPath: cancelled.directory.path)) + let finished = try BrowserDownloadFile(destination: target) + try Data("complete".utf8).write(to: finished.partial) + try finished.finish() + #expect(try String(contentsOf: target, encoding: .utf8) == "complete") + #expect(!FileManager.default.fileExists(atPath: finished.directory.path)) +} + +@Test func browserDownloadsRetainBytesIfAnUnapprovedFileAppearsAtTheDestination() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let target = directory.appendingPathComponent("new.txt") + let download = try BrowserDownloadFile(destination: target) + try Data("downloaded".utf8).write(to: download.partial) + try Data("created while downloading".utf8).write(to: target) + #expect(throws: (any Error).self) { try download.finish() } + #expect(try String(contentsOf: target, encoding: .utf8) == "created while downloading") + #expect(try String(contentsOf: download.partial, encoding: .utf8) == "downloaded") +} + +@Test func browserDownloadNamesCannotEscapeTheirDirectory() throws { + let directory = URL(fileURLWithPath: "/private/tmp/downloads", isDirectory: true) + for suggestion in [ + "../../report.txt", "C:\\secret\\report.txt", ".", "..", "\n\0", + String(repeating: "🖼️", count: 200), + ] { + let name = BrowserDownloadFile.name(suggestion) + #expect(!name.isEmpty) + #expect(name.utf8.count <= 180) + #expect( + directory.appendingPathComponent(name).standardizedFileURL.deletingLastPathComponent() + == directory) + } +} diff --git a/apps/macos/Tests/T3NativeTests/BrowserPreferencesTests.swift b/apps/macos/Tests/T3NativeTests/BrowserPreferencesTests.swift new file mode 100644 index 0000000000000..ff6d9873f7fb5 --- /dev/null +++ b/apps/macos/Tests/T3NativeTests/BrowserPreferencesTests.swift @@ -0,0 +1,76 @@ +import Foundation +import T3Core +import Testing + +@testable import T3Native + +@MainActor @Test func browserPreferencesRestoreProfilesAndDefaults() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let file = directory.appendingPathComponent("preferences.json") + let preferences = BrowserPreferences(file: file) + let work = try preferences.create(" Work ") + let personal = try preferences.create("Personal") + try preferences.update { + $0.defaultProfileID = work.id + $0.viewport = BrowserDevicePreset.all[0].viewport.json + $0.zoom = 1.5 + $0.appearance = "dark" + $0.externalLinks = true + $0.showAgentPreview = false + } + let restored = BrowserPreferences(file: file) + #expect(restored.error == nil) + #expect(restored.values == preferences.values) + try restored.rename(work.id, to: "Company") + #expect(restored.values.defaultProfileID == work.id) + #expect(restored.name(work.id) == "Company") + try restored.remove(work.id) + let removed = BrowserPreferences(file: file) + #expect(removed.values.defaultProfileID == "default") + #expect(removed.values.profiles == [personal]) + #expect(removed.values.zoom == 1.5) + #expect(removed.values.defaultViewport == BrowserDevicePreset.all[0].viewport) +} + +@MainActor @Test func browserPreferencesRejectInvalidOrUnwritableUpdates() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let file = directory.appendingPathComponent("preferences.json") + let preferences = BrowserPreferences(file: file) + try preferences.create("Keep") + let saved = try Data(contentsOf: file) + let previous = preferences.values + #expect(throws: (any Error).self) { try preferences.update { $0.zoom = .nan } } + #expect(throws: (any Error).self) { try preferences.update { $0.viewport = .null } } + #expect(throws: (any Error).self) { try preferences.rename("default", to: "Wrong") } + #expect(try Data(contentsOf: file) == saved) + #expect(preferences.values == previous) + try FileManager.default.removeItem(at: file) + try FileManager.default.createDirectory(at: file, withIntermediateDirectories: false) + #expect(throws: (any Error).self) { try preferences.create("Must not appear") } + #expect(preferences.values == previous) +} + +@MainActor @Test func browserPreferencesNormalizeStaleProfilesWithoutShadowingBuiltIns() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let file = directory.appendingPathComponent("preferences.json") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + var saved = BrowserPreferenceValues() + saved.profiles = [ + .init(id: "default", name: "Wrong"), .init(id: "work", name: "Work"), + .init(id: "work", name: "Duplicate"), + ] + saved.defaultProfileID = "removed" + try JSONEncoder().encode(saved).write(to: file) + let preferences = BrowserPreferences(file: file) + #expect(preferences.values.defaultProfileID == "default") + #expect(preferences.name("default") == "Default") + #expect(preferences.values.profiles == [.init(id: "work", name: "Work")]) + try Data("broken".utf8).write(to: file) + let broken = BrowserPreferences(file: file) + #expect(broken.error != nil) + #expect(broken.values == BrowserPreferenceValues()) + #expect(try String(contentsOf: file, encoding: .utf8) == "broken") +} diff --git a/apps/macos/Tests/T3NativeTests/ComposerActionTests.swift b/apps/macos/Tests/T3NativeTests/ComposerActionTests.swift new file mode 100644 index 0000000000000..e246283bfa710 --- /dev/null +++ b/apps/macos/Tests/T3NativeTests/ComposerActionTests.swift @@ -0,0 +1,204 @@ +import Foundation +import T3Core +import Testing + +@testable import T3Native + +@MainActor private func composerStore( + status: String = "interrupted", question: Bool = false, + queued: Bool = false +) throws -> ClientStore { + let store = ClientStore( + draftDirectory: FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + ) + store.catalog = ProviderCatalog(planModeEnabled: true) + let thread: JSON = .object([ + "id": .string("t"), "projectId": .string("p"), "title": .string("Plan"), + "interactionMode": .string("plan"), + ]) + try store.shell.apply( + .object([ + "kind": .string("snapshot"), + "snapshot": .object([ + "snapshotSequence": .number(1), "threads": .array([thread]), + ]), + ])) + store.selectedID = "t" + var runs: [JSON] = [ + .object([ + "id": .string("run"), "ordinal": .number(1), "status": .string(status), + "rootNodeId": .string("root"), + "startedAt": .string("2026-10-11T12:00:00Z"), + "completedAt": status == "running" ? .null : .string("2026-10-11T12:01:00Z"), + ]) + ] + if queued { + runs.append( + .object([ + "id": .string("queued"), "ordinal": .number(2), "status": .string("queued"), + "queueHeld": .bool(true), "userMessageId": .string("message"), + ])) + } + let request: JSON = .object([ + "id": .string("request"), "kind": .string("user_input"), "status": .string("pending"), + "responseCapability": .object(["type": .string("message")]), + ]) + let item: JSON = .object([ + "id": .string("item"), "threadId": .string("t"), "ordinal": .number(0), + "type": .string("user_input_request"), "requestId": .string("request"), + "questions": .array([.object(["id": .string("q"), "question": .string("Which option?")])]), + ]) + var state = ThreadState(id: "t") + _ = try state.apply( + .object([ + "kind": .string("snapshot"), "snapshotSequence": .number(1), + "projection": .object([ + "thread": thread, "runs": .array(runs), + "runtimeRequests": .array(question ? [request] : []), + "turnItems": .array(question ? [item] : []), + "messages": .array([ + .object(["id": .string("message"), "text": .string("Queued follow-up")]) + ]), + "plans": .array([ + .object([ + "id": .string("plan"), "runId": .string("run"), "kind": .string("proposed_plan"), + "status": .string("active"), "markdown": .string("# Proposed plan\n\nDo the work."), + ]) + ]), + ]), + ])) + _ = try state.apply(.object(["kind": .string("synchronized")])) + store.thread = state + store.ready = true + return store +} + +@MainActor @Test func interruptedPlanResumesUntilTheUserWritesRefinement() throws { + let store = try composerStore() + #expect(store.planFollowUp != nil) + #expect(store.composerAction == .resume) + #expect(store.sendBlockReason == nil) + store.composerText = " \n" + #expect(store.composerAction == .resume) + store.composerText = "Make the first step smaller." + #expect(store.composerAction == .plan) + #expect(store.planActionTitle == "Refine") + store.composerText = "" + #expect(store.composerAction == .resume) + _ = try store.thread?.apply( + .object([ + "kind": .string("event"), "sequence": .number(2), + "event": .object([ + "type": .string("run.updated"), + "payload": .object([ + "id": .string("run"), "ordinal": .number(1), "status": .string("completed"), + "completedAt": .string("2026-10-11T12:02:00Z"), + ]), + ]), + ])) + #expect(store.composerAction == .plan) + #expect(store.planActionTitle == "Implement") +} + +@MainActor @Test func emptyRunningComposerStopsButDraftsRemainSendable() throws { + let store = try composerStore(status: "running", queued: true) + #expect(store.composerAction == .stop) + #expect(store.sendBlockReason == nil) + store.composerText = "A follow-up" + #expect(store.composerAction == .message) + #expect(store.sendBlockReason == nil) + store.composerText = "" + store.ready = false + #expect(store.composerAction == .stop) + #expect(store.sendBlockReason != nil) + let stopped = try composerStore() + stopped.ready = false + #expect(stopped.composerAction == .resume) + #expect(stopped.sendBlockReason != nil) +} + +@MainActor @Test func questionsAndQueuedEditsDoNotTurnIntoResumeOrImplementation() throws { + let store = try composerStore(question: true) + #expect(store.composerAction == .question) + #expect(store.sendBlockReason != nil) + store.composerText = "Custom answer" + #expect(store.composerAction == .question) + #expect(store.sendBlockReason == nil) + let queued = try composerStore(queued: true) + let message = try #require(queued.execution.queued.first) + queued.editQueuedMessage(message) + #expect(queued.queuedEdit != nil) + #expect(queued.composerAction == .message) + queued.composerText = "" + #expect(queued.composerAction == .message) + #expect(queued.sendBlockReason != nil) + queued.cancelQueuedEdit() + #expect(queued.composerAction == .resume) + let held = try composerStore(status: "completed", queued: true) + #expect(held.execution.resumableRunID == nil) + #expect(held.composerAction == .resume) +} + +@MainActor @Test func usageFailureEnablesResumeWhenTheAuthoritativeErrorArrives() throws { + let store = try composerStore(status: "failed") + #expect(store.composerAction == .plan) + _ = try store.thread?.apply( + .object([ + "kind": .string("event"), "sequence": .number(2), + "event": .object([ + "type": .string("turn-item.updated"), + "payload": .object([ + "id": .string("failure"), "threadId": .string("t"), "runId": .string("run"), + "nodeId": .string("root"), "ordinal": .number(0), "type": .string("error"), + "status": .string("failed"), + "failure": .object([ + "class": .string("usage_limit"), "message": .string("Usage limit reached"), + ]), + ]), + ]), + ])) + #expect(store.composerAction == .resume) + #expect(store.sendBlockReason == nil) +} + +@MainActor @Test func recoveredPlanRetryAndFileDraftKeepTheirOriginalIntent() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let fixture = try composerStore() + let settings = try ConnectionSettings( + endpoint: URL(string: "http://127.0.0.1:9")!, token: "fixture") + let repository = DraftRepository(directory: directory) + let pending = PendingPlanSubmission( + plan: try #require(fixture.planFollowUp), template: try #require(fixture.selectedThread), + draft: "", newThread: false, defaultRuntimeMode: "approval-required") + try repository.save(SavedDrafts(planSubmissions: ["t": pending]), for: settings.endpoint) + func restored() -> ClientStore { + let store = ClientStore(draftDirectory: directory) + store.connect(settings, selecting: "t") + // Read persisted drafts, then cancel before yielding to the network task. + store.disconnect() + store.shell = fixture.shell + store.thread = fixture.thread + store.selectedID = "t" + store.ready = true + store.catalog = fixture.catalog + return store + } + let retry = restored() + #expect(retry.execution.resumableRunID != nil) + #expect(retry.composerAction == .plan) + #expect(retry.planActionTitle == "Retry implementation") + #expect(retry.pendingPlanSubmission?.setup == pending.setup) + #expect(retry.pendingPlanSubmission?.message == pending.message) + retry.disconnect() + + let source = directory.appendingPathComponent("attached.txt") + try Data("An attachment-only follow-up".utf8).write(to: source) + let attachment = try repository.attachmentRepository(for: settings.endpoint).stage(source) + try repository.save(SavedDrafts(attachments: ["t": [attachment]]), for: settings.endpoint) + let withFile = restored() + #expect(withFile.selectedAttachments.count == 1) + #expect(withFile.composerAction == .message) + #expect(withFile.planFollowUp == nil) + withFile.disconnect() +} diff --git a/apps/macos/Tests/T3NativeTests/ConnectionLaunchSelectionTests.swift b/apps/macos/Tests/T3NativeTests/ConnectionLaunchSelectionTests.swift new file mode 100644 index 0000000000000..93180d1770c31 --- /dev/null +++ b/apps/macos/Tests/T3NativeTests/ConnectionLaunchSelectionTests.swift @@ -0,0 +1,149 @@ +import Darwin +import Foundation +import T3Core +import Testing + +@testable import T3Native + +@Suite @MainActor struct ConnectionLaunchSelectionTests { + private func preferences() throws -> UserDefaults { + try #require(UserDefaults(suiteName: "app.t3.launch-selection-tests." + UUID().uuidString)) + } + private func environment(_ name: String) throws -> SavedEnvironment { + try SavedEnvironment(label: name, endpoint: URL(string: "https://\(name).invalid:4773")!) + } + private func write(_ catalog: SavedConnections, to preferences: UserDefaults) throws { + preferences.set(try JSONEncoder().encode(catalog), forKey: "native.savedConnections.v1") + } + + @Test func externallyChangedSelectionCancelsWithoutRollingPreferencesBack() async throws { + let prefs = try preferences() + defer { + prefs.removeObject(forKey: "native.savedConnections.v1") + prefs.removeObject(forKey: "endpoint") + } + let first = try environment("first") + let second = try environment("second") + var catalog = SavedConnections() + catalog.upsert(first, selecting: true) + catalog.upsert(second) + try write(catalog, to: prefs) + let pending = try await ConnectionRepository(preferences: prefs) + let external = try await ConnectionRepository(preferences: prefs) + try external.select(second) + #expect(throws: CancellationError.self) { try pending.requireLaunchSelection(first) } + #expect( + try SavedConnections.decode(prefs.data(forKey: "native.savedConnections.v1")!).selectedID + == second.id) + #expect(prefs.string(forKey: "endpoint") == second.endpoint.absoluteString) + } + + @Test func unrelatedEnvironmentEditsArePreservedBeforeLaunchPersistence() async throws { + let prefs = try preferences() + defer { prefs.removeObject(forKey: "native.savedConnections.v1") } + let first = try environment("first") + let added = try environment("added") + var catalog = SavedConnections() + catalog.upsert(first, selecting: true) + try write(catalog, to: prefs) + let pending = try await ConnectionRepository(preferences: prefs) + catalog.upsert(added) + try write(catalog, to: prefs) + try pending.requireLaunchSelection(first) + #expect(pending.values.environments == catalog.environments) + try pending.select(first) + #expect( + try SavedConnections.decode(prefs.data(forKey: "native.savedConnections.v1")!).environments + == catalog.environments) + prefs.removeObject(forKey: "endpoint") + } + + @Test func firstLaunchCannotReplaceASelectionSavedDuringStartup() async throws { + let prefs = try preferences() + defer { prefs.removeObject(forKey: "native.savedConnections.v1") } + let pending = try await ConnectionRepository(preferences: prefs) + var latest = SavedConnections() + latest.upsert(try environment("remote"), selecting: true) + try write(latest, to: prefs) + #expect(throws: CancellationError.self) { try pending.requireLaunchSelection(nil) } + #expect( + try SavedConnections.decode(prefs.data(forKey: "native.savedConnections.v1")!) == latest) + } + + @Test func legacyEndpointChangeAlsoCancelsPendingLaunch() async throws { + let prefs = try preferences() + defer { + prefs.removeObject(forKey: "native.savedConnections.v1") + prefs.removeObject(forKey: "endpoint") + } + let first = try environment("first") + var catalog = SavedConnections() + catalog.upsert(first, selecting: true) + try write(catalog, to: prefs) + let pending = try await ConnectionRepository(preferences: prefs) + let address = try environment("second").endpoint.absoluteString + prefs.set(address, forKey: "endpoint") + #expect(throws: CancellationError.self) { try pending.requireLaunchSelection(first) } + #expect(prefs.string(forKey: "endpoint") == address) + } + + @Test(.enabled(if: ProcessInfo.processInfo.environment["T3_NATIVE_SMOKE_RESOURCES"] != nil)) + func selectionChangedDuringRealLocalStartupNeverRestoresOldSelection() async throws { + let prefs = try preferences() + defer { + for key in ["native.savedConnections.v1", "native.backendConfiguration.v1", "endpoint"] { + prefs.removeObject(forKey: key) + } + } + let resources = URL( + fileURLWithPath: ProcessInfo.processInfo.environment["T3_NATIVE_SMOKE_RESOURCES"]!) + let home = URL( + fileURLWithPath: "/tmp/t3-native-evidence/backend-selection-test-" + UUID().uuidString) + let configuration = BackendConfiguration(home: home, port: try freePort()) + let local = try SavedEnvironment( + label: "This Mac", endpoint: configuration.endpoint, local: true) + let remote = try environment("selected-during-startup") + var catalog = SavedConnections() + catalog.upsert(local, selecting: true) + catalog.upsert(remote) + try write(catalog, to: prefs) + let pending = try await ConnectionRepository(preferences: prefs) + let external = try await ConnectionRepository(preferences: prefs) + let host = BackendHost( + runtime: BackendRuntime.bundled(resources: resources), preferences: prefs) + try host.configure(configuration) + let launch = Task { try await pending.connectionForLaunch(host: host) } + let startingDeadline = Date().addingTimeInterval(5) + while host.state == "Stopped", Date() < startingDeadline { await Task.yield() } + try external.select(remote) + do { + _ = try await launch.value + Issue.record("A stale local startup must cancel before saving its credential or selection.") + } catch is CancellationError {} catch { Issue.record(error) } + let latest = try SavedConnections.decode(prefs.data(forKey: "native.savedConnections.v1")!) + #expect(latest.selectedID == remote.id) + #expect(prefs.string(forKey: "endpoint") == remote.endpoint.absoluteString) + #expect(latest.environments == catalog.environments) + try await host.stop() + #expect(!host.isRunning) + } + + private func freePort() throws -> Int { + let fd = socket(AF_INET, SOCK_STREAM, 0) + guard fd >= 0 else { throw T3Error("Cannot allocate a test port.") } + defer { close(fd) } + var address = sockaddr_in() + address.sin_len = UInt8(MemoryLayout.size) + address.sin_family = sa_family_t(AF_INET) + address.sin_addr.s_addr = inet_addr("127.0.0.1") + var length = socklen_t(MemoryLayout.size) + let result = withUnsafeMutablePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { socketAddress in + guard bind(fd, socketAddress, length) == 0 else { return Int32(-1) } + return getsockname(fd, socketAddress, &length) + } + } + guard result == 0 else { throw T3Error("Cannot find a free test port.") } + return Int(UInt16(bigEndian: address.sin_port)) + } +} diff --git a/apps/macos/Tests/T3NativeTests/ConnectionRepositoryTests.swift b/apps/macos/Tests/T3NativeTests/ConnectionRepositoryTests.swift new file mode 100644 index 0000000000000..70ae20c6c8376 --- /dev/null +++ b/apps/macos/Tests/T3NativeTests/ConnectionRepositoryTests.swift @@ -0,0 +1,40 @@ +import Foundation +import T3Core +import Testing + +@testable import T3Native + +@Suite @MainActor struct ConnectionRepositoryTests { + @Test func missingLegacyCredentialPreservesRemote4773AndNeverStartsLocalHost() async throws { + let suite = "app.t3.connection-tests." + UUID().uuidString + let preferences = try #require(UserDefaults(suiteName: suite)) + defer { preferences.removePersistentDomain(forName: suite) } + // A unique endpoint guarantees this test cannot read an actual user's credential. + let address = "https://missing-" + UUID().uuidString.lowercased() + ".invalid:4773/" + preferences.set(address, forKey: "endpoint") + preferences.set([address: "existing-thread"], forKey: "selectedThreadByEndpoint") + let repository = try await ConnectionRepository(preferences: preferences) + let expected = try ConnectionEndpoint.normalize(address) + #expect(repository.values.selectedID == expected.absoluteString) + #expect(repository.values.environments.count == 1) + #expect(repository.values.environments[0].endpoint.port == 4773) + #expect(!repository.values.environments[0].local) + #expect( + preferences.dictionary(forKey: "selectedThreadByEndpoint")?[expected.absoluteString] + as? String == "existing-thread") + let host = BackendHost( + runtime: BackendRuntime( + node: URL(fileURLWithPath: "/missing-node"), + serverEntry: URL(fileURLWithPath: "/missing-server")), preferences: preferences) + do { + _ = try await repository.connectionForLaunch(host: host) + Issue.record( + "Missing remote auth must require pairing rather than switching to a local server.") + } catch let error as T3Error { #expect(error.authentication) } + #expect(!host.isRunning) + #expect(host.state == "Stopped") + #expect(repository.values.selectedID == expected.absoluteString) + let reopened = try await ConnectionRepository(preferences: preferences) + #expect(reopened.values == repository.values) + } +} diff --git a/apps/macos/Tests/T3NativeTests/ContextReviewTests.swift b/apps/macos/Tests/T3NativeTests/ContextReviewTests.swift new file mode 100644 index 0000000000000..f50ae49407648 --- /dev/null +++ b/apps/macos/Tests/T3NativeTests/ContextReviewTests.swift @@ -0,0 +1,62 @@ +import AppKit +import T3Core +import Testing + +@testable import T3Native + +@MainActor @Test func contextReviewExpansionKeepsTheVisibleLineAndLinksInBothLayouts() throws { + let old = (1...240).map { "line \($0) 日本語" } + var new = old + var raw = "diff --git a/a b/a\n" + for number in [30, 120, 200] { + new[number - 1] = "changed \(number)" + raw += + "@@ -\(number - 1),3 +\(number - 1),3 @@\n \(old[number - 2])\n-\(old[number - 1])\n+\(new[number - 1])\n \(old[number])\n" + } + let file = try #require(UnifiedDiff.parse(raw).first) + let context = try DiffContext( + file, oldContents: old.joined(separator: "\n") + "\n", + newContents: new.joined(separator: "\n") + "\n", ignoreWhitespace: false) + let collapsed = context.render(expanded: [:]).file + let full = context.render( + expanded: Dictionary(uniqueKeysWithValues: context.gaps.map { ($0.id, $0.count) }) + ).file + let view = SplitReviewView(frame: NSRect(x: 0, y: 0, width: 650, height: 100)) + view.setWrapped(true) + view.render(collapsed) + // Put a numbered context row at the top, beyond the first replacement block. + let rendered = SplitReviewText(collapsed) + let rows = SplitDiff(collapsed).rows + let index = try #require(rows.firstIndex { $0.old?.oldLine == 119 }) + let frames = SplitReviewView.rowFrames(in: view.oldText, ranges: rendered.oldRanges) + view.oldScroll.contentView.scroll(to: NSPoint(x: 0, y: frames[index].minY + 8)) + let before = ReviewText.capturePosition(view.oldText, in: view.oldScroll) + try #require(before.first?.id == "line:119") + view.render(full, preservePosition: true) + let after = ReviewText.capturePosition(view.oldText, in: view.oldScroll) + #expect(after.first?.id == before.first?.id) + #expect(abs((after.first?.offset ?? 0) - (before.first?.offset ?? 0)) < 0.5) + #expect(view.oldScroll.contentView.bounds.minY == view.newScroll.contentView.bounds.minY) + var links: [String] = [] + let unified = ReviewText.render(collapsed).text + unified.enumerateAttribute(.link, in: NSRange(location: 0, length: unified.length)) { + value, _, _ in + if let value = value as? String { links.append(value) } + } + #expect(links.filter { $0.hasPrefix("context:") } == context.gaps.map { "context:\($0.id)" }) + let split = SplitReviewText(collapsed) + var oldLinks: [String] = [] + split.old.enumerateAttribute(.link, in: NSRange(location: 0, length: split.old.length)) { + value, _, _ in + if let value = value as? String { oldLinks.append(value) } + } + #expect(oldLinks == context.gaps.map { "context:\($0.id)" }) +} + +@MainActor @Test func contextReviewShowsActualWhitespaceOnBothSides() throws { + let file = try #require(UnifiedDiff.parse("diff --git a/a b/a\n@@ -2 +2 @@\n-old\n+new\n").first) + let context = try DiffContext( + file, oldContents: "let a = 1\nold\n", newContents: "let a=1\nnew\n", ignoreWhitespace: true) + let split = SplitReviewText(context.render(expanded: [0: 1]).file) + #expect(split.old.string.contains("let a = 1") && split.new.string.contains("let a=1")) +} diff --git a/apps/macos/Tests/T3NativeTests/ConversationResizeTests.swift b/apps/macos/Tests/T3NativeTests/ConversationResizeTests.swift new file mode 100644 index 0000000000000..f1f64df46e5cd --- /dev/null +++ b/apps/macos/Tests/T3NativeTests/ConversationResizeTests.swift @@ -0,0 +1,138 @@ +import AppKit +import QuartzCore +import T3Core +import Testing + +@testable import T3Native + +@MainActor @Suite(.serialized) struct ConversationResizeTests { + private func fixture() throws -> (ChatWindow, NSTableView) { + _ = NSApplication.shared + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let store = ClientStore(draftDirectory: directory) + store.selectedID = "resize-test" + var thread = ThreadState(id: "resize-test") + _ = try thread.apply( + .object([ + "kind": .string("snapshot"), "snapshotSequence": .number(1), + "projection": .object([ + "thread": .object(["id": .string("resize-test")]), + "turnItems": .array( + (0..<240).map { index in + .object([ + "id": .string("message-\(index)"), "ordinal": .number(Double(index)), + "type": .string("assistant_message"), + "text": .string( + String( + repeating: "A **message** with enough text to wrap across the conversation. ", + count: 12)), + ]) + }), + ]), + ])) + store.thread = thread + let frameKey = "NSWindow Frame T3NativeMain" + let savedFrame = UserDefaults.standard.object(forKey: frameKey) + let controller = ChatWindow(store: store) + let window = try #require(controller.window) + window.setFrameAutosaveName("") + if let savedFrame { + UserDefaults.standard.set(savedFrame, forKey: frameKey) + } else { + UserDefaults.standard.removeObject(forKey: frameKey) + } + window.setContentSize(NSSize(width: 1200, height: 800)) + window.contentView?.layoutSubtreeIfNeeded() + func tables(_ view: NSView) -> [NSTableView] { + (view as? NSTableView).map { [$0] } ?? view.subviews.flatMap(tables) + } + let content = try #require(window.contentView) + let table = try #require( + tables(content).first { + $0.accessibilityLabel() == "Conversation" + }) + return (controller, table) + } + + @Test(arguments: [false, true]) + func liveResizeRetainsRowsThenReflowsAndKeepsTheScrollAnchor(atBottom: Bool) throws { + let (controller, table) = try fixture() + let window = try #require(controller.window) + defer { window.close() } + let clip = try #require(table.enclosingScrollView?.contentView) + let anchorRow = atBottom ? 239 : 120 + let y = + atBottom ? table.bounds.height - clip.bounds.height : table.rect(ofRow: anchorRow).minY + 5 + clip.scroll(to: NSPoint(x: 0, y: y)) + let original = try #require(table.view(atColumn: 0, row: anchorRow, makeIfNecessary: true)) + let originalHeight = table.rect(ofRow: anchorRow).height + NotificationCenter.default.post(name: NSWindow.willStartLiveResizeNotification, object: window) + var durations: [Double] = [] + for width in stride(from: 1180, through: 920, by: -20) { + let start = CACurrentMediaTime() + window.setContentSize(NSSize(width: width, height: 800)) + window.contentView?.layoutSubtreeIfNeeded() + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.001)) + durations.append((CACurrentMediaTime() - start) * 1000) + } + let during = try #require(table.view(atColumn: 0, row: anchorRow, makeIfNecessary: true)) + #expect(during === original) + #expect(table.rect(ofRow: anchorRow).height == originalHeight) + NotificationCenter.default.post(name: NSWindow.didEndLiveResizeNotification, object: window) + window.contentView?.layoutSubtreeIfNeeded() + #expect(table.rect(ofRow: anchorRow).height > originalHeight) + if atBottom { + #expect(abs(clip.bounds.maxY - table.bounds.maxY) < 1) + } else { + #expect(table.row(at: NSPoint(x: 1, y: clip.bounds.minY + 1)) == anchorRow) + #expect(abs(clip.bounds.minY - table.rect(ofRow: anchorRow).minY - 5) < 1) + } + print( + "Resize benchmark, 240 messages: median \(durations.sorted()[durations.count / 2]) ms, max \(durations.max()!) ms" + ) + } + + @Test func onlyTheActivePageParticipatesInWindowLayout() throws { + let (controller, _) = try fixture() + let window = try #require(controller.window) + defer { window.close() } + let split = try #require(window.contentViewController as? NSSplitViewController) + let pages = split.splitViewItems[1].viewController + let usage = try #require(pages.children.last as? UsagePageController) + #expect(!usage.isViewLoaded) + for index in 0..<3 { + #expect(NSApp.sendAction(NSSelectorFromString("showUsage"), to: controller, from: nil)) + #expect(usage.view.superview === pages.view) + #expect(pages.children[0].view.superview == nil) + controller.store.selectedID = "next-\(index)" + controller.refresh() + #expect(usage.view.superview == nil) + #expect(pages.children[0].view.superview === pages.view) + #expect(pages.view.subviews.count == 1) + } + } + + @Test func messageTextStaysInsideItsRowAfterWindowResizing() throws { + let (controller, table) = try fixture() + let window = try #require(controller.window) + defer { window.close() } + func textViews(_ view: NSView) -> [MessageTextView] { + (view as? MessageTextView).map { [$0] } ?? view.subviews.flatMap(textViews) + } + for width: CGFloat in [1200, 920, 1200] { + controller.windowWillStartLiveResize( + Notification(name: NSWindow.willStartLiveResizeNotification)) + window.setContentSize(NSSize(width: width, height: 800)) + window.layoutIfNeeded() + controller.windowDidEndLiveResize(Notification(name: NSWindow.didEndLiveResizeNotification)) + let cell = try #require(table.view(atColumn: 0, row: 120, makeIfNecessary: true)) + cell.layoutSubtreeIfNeeded() + let body = try #require(textViews(cell).first) + let viewport = try #require(body.enclosingScrollView) + let rect = viewport.convert(viewport.bounds, to: cell) + #expect(rect.minX >= 0, "The first character must not be clipped by AppKit's row inset.") + #expect(rect.maxX <= cell.bounds.maxX) + #expect(body.visibleRect.minX == 0) + } + } +} diff --git a/apps/macos/Tests/T3NativeTests/CredentialResponsivenessTests.swift b/apps/macos/Tests/T3NativeTests/CredentialResponsivenessTests.swift new file mode 100644 index 0000000000000..543280b37f8db --- /dev/null +++ b/apps/macos/Tests/T3NativeTests/CredentialResponsivenessTests.swift @@ -0,0 +1,400 @@ +import Foundation +import T3Core +import Testing + +@testable import T3Native + +// Only the blocking credential boundary is substituted; catalog operations use the real repository. +private final class BlockingCredentialBoundary: @unchecked Sendable { + enum Kind { case read, write, remove } + let started: AsyncStream + private let continuation: AsyncStream.Continuation + private let release = DispatchSemaphore(value: 0) + private let lock = NSLock() + private let kind: Kind + private let response: Data? + private var blocked = false + private var writes = 0 + private var offMain = true + + init(_ kind: Kind, response: Data? = nil) { + self.kind = kind + self.response = response + (started, continuation) = AsyncStream.makeStream() + } + var access: Credentials.Access { Credentials.Access { [self] in try execute($0) } } + var writeCount: Int { lock.withLock { writes } } + var ranOffMain: Bool { lock.withLock { offMain } } + func unblock() { release.signal() } + func waitUntilBlocked() async { + for await _ in started { return } + } + private func execute(_ operation: Credentials.Operation) throws -> Data? { + let operationKind: Kind + switch operation { + case .read: operationKind = .read + case .write: operationKind = .write + case .remove, .removeSession: operationKind = .remove + } + let shouldBlock = lock.withLock { + offMain = offMain && !Thread.isMainThread + if operationKind == .write { writes += 1 } + guard operationKind == kind, !blocked else { return false } + blocked = true + return true + } + if shouldBlock { + continuation.yield(()) + release.wait() + } + if operationKind == .read { return response } + return nil + } +} + +@Suite(.serialized) @MainActor struct CredentialResponsivenessTests { + private let key = "native.savedConnections.v1" + private func environment(_ name: String) throws -> SavedEnvironment { + try SavedEnvironment(label: name, endpoint: URL(string: "https://\(name).invalid:4773")!) + } + private func write(_ catalog: SavedConnections, to preferences: UserDefaults) throws { + preferences.set(try JSONEncoder().encode(catalog), forKey: key) + } + private func catalog(_ preferences: UserDefaults) throws -> SavedConnections { + try SavedConnections.decode(try #require(preferences.data(forKey: key))) + } + private func isolatedPreferences() throws -> (String, UserDefaults) { + let name = "app.t3.credential-responsiveness." + UUID().uuidString + return (name, try #require(UserDefaults(suiteName: name))) + } + private func expectCancelled(_ task: Task) async { + do { + _ = try await task.value + Issue.record( + "Stale credential work must be discarded before committing or returning a session.") + } catch is CancellationError {} catch { Issue.record(error) } + } + + @Test func mainActorRespondsWhileLegacyReadIsBlockedAndMigrationKeepsCanonicalThreadKey() + async throws + { + let (suite, prefs) = try isolatedPreferences() + defer { prefs.removePersistentDomain(forName: suite) } + let address = "https://Legacy-" + UUID().uuidString + ".invalid:443/" + prefs.set(address, forKey: "endpoint") + prefs.set([address: "selected-thread"], forKey: "selectedThreadByEndpoint") + let boundary = BlockingCredentialBoundary(.read, response: Data("synthetic-token".utf8)) + defer { boundary.unblock() } + let loading = Task { + try await ConnectionRepository(preferences: prefs, credentials: boundary.access) + } + await boundary.waitUntilBlocked() + // This task must run before the blocked Security operation is released. + let response = await Task { @MainActor in "main actor responded" }.value + #expect(response == "main actor responded") + #expect(prefs.data(forKey: key) == nil) + #expect(boundary.ranOffMain) + boundary.unblock() + let repository = try await loading.value + let endpoint = try ConnectionEndpoint.normalize(address) + #expect(repository.values.selectedID == endpoint.absoluteString) + #expect(repository.values.environments.map(\.endpoint) == [endpoint]) + #expect(boundary.writeCount == 1) + #expect( + prefs.dictionary(forKey: "selectedThreadByEndpoint")?[endpoint.absoluteString] as? String + == "selected-thread") + } + + @Test func connectionsInitializationReturnsImmediatelyAndPublishesAfterCredentialLoad() + async throws + { + let (suite, prefs) = try isolatedPreferences() + defer { prefs.removePersistentDomain(forName: suite) } + let endpoint = try environment("settings").endpoint + prefs.set(endpoint.absoluteString, forKey: "endpoint") + let boundary = BlockingCredentialBoundary(.read) + defer { boundary.unblock() } + let drafts = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: drafts) } + let store = ClientStore(draftDirectory: drafts, preferences: prefs) + let host = BackendHost(preferences: prefs) + let model = NativeConnections( + store: store, host: host, preferences: prefs, credentials: boundary.access) + #expect(model.busy) + #expect(model.environments.isEmpty) + await boundary.waitUntilBlocked() + #expect(model.busy) + #expect(boundary.ranOffMain) + let completed = Task { @MainActor in + for await busy in model.$busy.values { + if !busy { return } + } + } + boundary.unblock() + await completed.value + #expect(model.error == nil) + #expect(model.environments.map(\.endpoint) == [endpoint]) + #expect(!model.signedIn) + #expect(!host.isRunning) + #expect(!store.hasConnection) + } + + @Test(arguments: [false, true]) + func firstActionSurvivesPendingOrCancelledInitialization(cancelFirst: Bool) async throws { + let (suite, prefs) = try isolatedPreferences() + defer { prefs.removePersistentDomain(forName: suite) } + let endpoint = try environment("pending-settings").endpoint + let environment = try SavedEnvironment(label: endpoint.host!, endpoint: endpoint) + prefs.set(environment.endpoint.absoluteString, forKey: "endpoint") + let boundary = BlockingCredentialBoundary(.read) + defer { boundary.unblock() } + let drafts = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: drafts) } + let store = ClientStore(draftDirectory: drafts, preferences: prefs) + let host = BackendHost(preferences: prefs) + let model = NativeConnections( + store: store, host: host, preferences: prefs, credentials: boundary.access) + await boundary.waitUntilBlocked() + if cancelFirst { + model.cancel() + #expect(!model.busy) + } + model.forget(environment) + #expect(model.busy) + let completed = Task { @MainActor in + for await busy in model.$busy.values { + if !busy { return } + } + } + boundary.unblock() + await completed.value + #expect(model.error == nil) + #expect(model.notice?.hasPrefix("Removed from this Mac.") == true) + #expect(model.environments.isEmpty) + #expect(try catalog(prefs).environments.isEmpty) + #expect(prefs.string(forKey: "endpoint") == nil) + #expect(!host.isRunning) + } + + @Test func concurrentLegacyInitializersAdoptTheSameCompletedMigration() async throws { + let (suite, prefs) = try isolatedPreferences() + defer { prefs.removePersistentDomain(forName: suite) } + let address = "https://Concurrent.invalid:443/" + prefs.set(address, forKey: "endpoint") + prefs.set([address: "thread"], forKey: "selectedThreadByEndpoint") + let boundary = BlockingCredentialBoundary(.write, response: Data("synthetic-token".utf8)) + defer { boundary.unblock() } + let first = Task { + try await ConnectionRepository(preferences: prefs, credentials: boundary.access) + } + await boundary.waitUntilBlocked() + let secondStarted = AsyncStream.makeStream() + let second = Task { + try await ConnectionRepository(preferences: prefs, credentials: boundary.access) { + secondStarted.continuation.yield(()) + } + } + // The second initializer has captured the empty catalog before the first write resumes. + for await _ in secondStarted.stream { break } + boundary.unblock() + let firstRepository = try await first.value + let secondRepository = try await second.value + let latest = try catalog(prefs) + let endpoint = try ConnectionEndpoint.normalize(address) + #expect(firstRepository.values == latest) + #expect(secondRepository.values == latest) + #expect(latest.selectedID == endpoint.absoluteString) + #expect(latest.environments.map(\.endpoint) == [endpoint]) + #expect( + prefs.dictionary(forKey: "selectedThreadByEndpoint")?[endpoint.absoluteString] as? String + == "thread") + } + + @Test func legacyReadCannotOverwriteSelectionMadeWhileSecurityWaits() async throws { + let (suite, prefs) = try isolatedPreferences() + defer { prefs.removePersistentDomain(forName: suite) } + let original = try environment("legacy") + let selected = try environment("latest") + prefs.set(original.endpoint.absoluteString, forKey: "endpoint") + let boundary = BlockingCredentialBoundary(.read, response: Data("synthetic-token".utf8)) + defer { boundary.unblock() } + let loading = Task { + try await ConnectionRepository(preferences: prefs, credentials: boundary.access) + } + await boundary.waitUntilBlocked() + var latest = SavedConnections() + latest.upsert(selected, selecting: true) + try write(latest, to: prefs) + let external = try await ConnectionRepository(preferences: prefs, credentials: boundary.access) + try external.select(selected) + boundary.unblock() + await expectCancelled(loading) + #expect(try catalog(prefs) == latest) + #expect(prefs.string(forKey: "endpoint") == selected.endpoint.absoluteString) + #expect(boundary.writeCount == 0) + } + + @Test func legacyEndpointChangeWithoutCatalogDiscardsOldCredential() async throws { + let (suite, prefs) = try isolatedPreferences() + defer { prefs.removePersistentDomain(forName: suite) } + prefs.set(try environment("old").endpoint.absoluteString, forKey: "endpoint") + let boundary = BlockingCredentialBoundary(.read, response: Data("synthetic-token".utf8)) + defer { boundary.unblock() } + let loading = Task { + try await ConnectionRepository(preferences: prefs, credentials: boundary.access) + } + await boundary.waitUntilBlocked() + let address = try environment("new").endpoint.absoluteString + prefs.set(address, forKey: "endpoint") + boundary.unblock() + await expectCancelled(loading) + #expect(prefs.string(forKey: "endpoint") == address) + #expect(prefs.data(forKey: key) == nil) + #expect(boundary.writeCount == 0) + } + + @Test func cancelledMigrationWriteCannotCommitCatalogOrThreadMigration() async throws { + let (suite, prefs) = try isolatedPreferences() + defer { prefs.removePersistentDomain(forName: suite) } + let address = "https://cancelled.invalid:443/" + prefs.set(address, forKey: "endpoint") + prefs.set([address: "thread"], forKey: "selectedThreadByEndpoint") + let boundary = BlockingCredentialBoundary(.write, response: Data("synthetic-token".utf8)) + defer { boundary.unblock() } + let loading = Task { + try await ConnectionRepository(preferences: prefs, credentials: boundary.access) + } + await boundary.waitUntilBlocked() + loading.cancel() + boundary.unblock() + await expectCancelled(loading) + #expect(prefs.data(forKey: key) == nil) + #expect( + (prefs.dictionary(forKey: "selectedThreadByEndpoint") as? [String: String]) == [ + address: "thread" + ]) + #expect(prefs.string(forKey: "endpoint") == address) + } + + @Test func plainRemoteLaunchRejectsSelectionChangedDuringCredentialRead() async throws { + let (suite, prefs) = try isolatedPreferences() + defer { prefs.removePersistentDomain(forName: suite) } + let first = try environment("first") + let second = try environment("second") + var initial = SavedConnections() + initial.upsert(first, selecting: true) + initial.upsert(second) + try write(initial, to: prefs) + let settings = try ConnectionSettings(endpoint: first.endpoint, token: "synthetic-token") + let boundary = BlockingCredentialBoundary(.read, response: try JSONEncoder().encode(settings)) + defer { boundary.unblock() } + let repository = try await ConnectionRepository( + preferences: prefs, credentials: boundary.access) + let external = try await ConnectionRepository(preferences: prefs, credentials: boundary.access) + let host = BackendHost(preferences: prefs) + let launch = Task { try await repository.connectionForLaunch(host: host) } + await boundary.waitUntilBlocked() + try external.select(second) + boundary.unblock() + await expectCancelled(launch) + #expect(try catalog(prefs).selectedID == second.id) + #expect(!host.isRunning) + #expect(boundary.writeCount == 0) + } + + @Test(arguments: [false, true]) + func rememberCannotCommitAfterSelectionChangeOrCancellation(cancel: Bool) async throws { + let (suite, prefs) = try isolatedPreferences() + defer { prefs.removePersistentDomain(forName: suite) } + let first = try environment("first") + let second = try environment("second") + var initial = SavedConnections() + initial.upsert(first, selecting: true) + initial.upsert(second) + try write(initial, to: prefs) + let boundary = BlockingCredentialBoundary(.write) + defer { boundary.unblock() } + let repository = try await ConnectionRepository( + preferences: prefs, credentials: boundary.access) + let external = try await ConnectionRepository(preferences: prefs, credentials: boundary.access) + let settings = try ConnectionSettings(endpoint: first.endpoint, token: "synthetic-token") + let remembering = Task { try await repository.remember(settings, label: "stale label") } + await boundary.waitUntilBlocked() + if cancel { remembering.cancel() } else { try external.select(second) } + let expected = try catalog(prefs) + boundary.unblock() + await expectCancelled(remembering) + #expect(try catalog(prefs) == expected) + #expect(try catalog(prefs).environments.first(where: { $0.id == first.id })?.label == "first") + } + + @Test func invalidatedCallerCannotCommitAfterCredentialWrite() async throws { + let (suite, prefs) = try isolatedPreferences() + defer { prefs.removePersistentDomain(forName: suite) } + let first = try environment("first") + var initial = SavedConnections() + initial.upsert(first, selecting: true) + try write(initial, to: prefs) + let boundary = BlockingCredentialBoundary(.write) + defer { boundary.unblock() } + let repository = try await ConnectionRepository( + preferences: prefs, credentials: boundary.access) + let settings = try ConnectionSettings(endpoint: first.endpoint, token: "synthetic-token") + var current = true + let remembering = Task { + try await repository.remember(settings, label: "stale") { + guard current else { throw CancellationError() } + } + } + await boundary.waitUntilBlocked() + current = false + boundary.unblock() + await expectCancelled(remembering) + #expect(try catalog(prefs) == initial) + } + + @Test func rememberMergesUnrelatedCatalogEditsAfterCredentialWrite() async throws { + let (suite, prefs) = try isolatedPreferences() + defer { prefs.removePersistentDomain(forName: suite) } + let first = try environment("first") + let added = try environment("added") + var initial = SavedConnections() + initial.upsert(first, selecting: true) + try write(initial, to: prefs) + let boundary = BlockingCredentialBoundary(.write) + defer { boundary.unblock() } + let repository = try await ConnectionRepository( + preferences: prefs, credentials: boundary.access) + let settings = try ConnectionSettings(endpoint: first.endpoint, token: "synthetic-token") + let remembering = Task { try await repository.remember(settings, label: "updated") } + await boundary.waitUntilBlocked() + initial.upsert(added) + try write(initial, to: prefs) + boundary.unblock() + let remembered = try await remembering.value + let latest = try catalog(prefs) + #expect(latest.selectedID == first.id) + #expect(latest.environments == [remembered, added]) + } + + @Test func cancelledRemovalCannotCommitCatalog() async throws { + let (suite, prefs) = try isolatedPreferences() + defer { prefs.removePersistentDomain(forName: suite) } + let first = try environment("first") + var initial = SavedConnections() + initial.upsert(first, selecting: true) + try write(initial, to: prefs) + prefs.set(first.endpoint.absoluteString, forKey: "endpoint") + let boundary = BlockingCredentialBoundary(.remove) + defer { boundary.unblock() } + let repository = try await ConnectionRepository( + preferences: prefs, credentials: boundary.access) + let removal = Task { try await repository.remove(first) } + await boundary.waitUntilBlocked() + removal.cancel() + boundary.unblock() + await expectCancelled(removal) + #expect(try catalog(prefs) == initial) + #expect(prefs.string(forKey: "endpoint") == first.endpoint.absoluteString) + } +} diff --git a/apps/macos/Tests/T3NativeTests/ExistingProjectPickerTests.swift b/apps/macos/Tests/T3NativeTests/ExistingProjectPickerTests.swift new file mode 100644 index 0000000000000..36c7cd21c192e --- /dev/null +++ b/apps/macos/Tests/T3NativeTests/ExistingProjectPickerTests.swift @@ -0,0 +1,67 @@ +import AppKit +import T3Core +import Testing + +@testable import T3Native + +@MainActor private func projectPickerViews(_ view: NSView) -> [NSView] { + [view] + view.subviews.flatMap(projectPickerViews) +} + +private func pickerProject(_ id: String, _ title: String, _ root: String) -> JSON { + .object(["id": .string(id), "title": .string(title), "workspaceRoot": .string(root)]) +} + +@MainActor @Test func existingProjectPickerSearchesPathsAndChoosesWithKeyboard() throws { + var chosen: JSON? + var closed = false + let projects = [ + pickerProject("one", "App", "/work/client/App"), + pickerProject("two", "App", "/work/server/App"), + pickerProject("three", "Website", "/work/site"), + ] + let picker = ExistingProjectPicker( + projects: projects, selectedID: "one", choose: { chosen = $0 }, openFolder: {}, + close: { closed = true }) + let views = projectPickerViews(picker.view) + let search = try #require(views.compactMap { $0 as? NSSearchField }.first) + let table = try #require(views.compactMap { $0 as? NSTableView }.first) + #expect(table.numberOfRows == 3) + search.stringValue = "app server" + picker.controlTextDidChange( + Notification(name: NSControl.textDidChangeNotification, object: search)) + #expect(table.numberOfRows == 1) + #expect(chosen == nil && !closed, "Searching must not switch the draft's project.") + #expect( + picker.control( + search, textView: NSTextView(), doCommandBy: #selector(NSResponder.insertNewline(_:)))) + #expect(chosen == projects[1] && closed) +} + +@MainActor @Test func existingProjectPickerKeepsEmptySearchSafeAndRefreshesTheCatalog() throws { + var chosen: JSON? + var closed = false + let project = pickerProject("one", "Native", "/work/native") + let picker = ExistingProjectPicker( + projects: [project], selectedID: "one", choose: { chosen = $0 }, openFolder: {}, + close: { closed = true }) + let views = projectPickerViews(picker.view) + let search = try #require(views.compactMap { $0 as? NSSearchField }.first) + let table = try #require(views.compactMap { $0 as? NSTableView }.first) + search.stringValue = "does not exist" + picker.controlTextDidChange( + Notification(name: NSControl.textDidChangeNotification, object: search)) + #expect(table.numberOfRows == 0) + _ = picker.control( + search, textView: NSTextView(), doCommandBy: #selector(NSResponder.insertNewline(_:))) + #expect(chosen == nil && !closed) + search.stringValue = "" + picker.controlTextDidChange( + Notification(name: NSControl.textDidChangeNotification, object: search)) + picker.update(projects: [], selectedID: "one") + #expect( + table.numberOfRows == 0, "A removed project must not remain selectable in an open picker.") + _ = picker.control( + search, textView: NSTextView(), doCommandBy: #selector(NSResponder.cancelOperation(_:))) + #expect(chosen == nil && closed) +} diff --git a/apps/macos/Tests/T3NativeTests/FileNavigationTests.swift b/apps/macos/Tests/T3NativeTests/FileNavigationTests.swift new file mode 100644 index 0000000000000..176c022eb98fb --- /dev/null +++ b/apps/macos/Tests/T3NativeTests/FileNavigationTests.swift @@ -0,0 +1,53 @@ +import Foundation +import T3Core +import Testing + +@testable import T3Native + +@MainActor @Test func fileRevealsUseRecoveredTextAndDoNotFollowTheUserToAnotherFile() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let store = ClientStore(draftDirectory: directory.appendingPathComponent("chat")) + let thread: JSON = .object([ + "id": .string("t"), "projectId": .string("p"), "title": .string("Navigation"), + "worktreePath": .string("/repo"), + ]) + try store.shell.apply( + .object([ + "kind": .string("snapshot"), + "snapshot": .object([ + "snapshotSequence": .number(1), "threads": .array([thread]), + ]), + ])) + store.selectedID = "t" + let location = try #require(store.workspaceLocation) + var documents: [WorkspaceFileID: WorkspaceFileDocument] = [:] + for path in ["first.md", "second.md"] { + var document = try WorkspaceFileDocument( + .object([ + "relativePath": .string(path), "contents": .string("Original\n"), "byteLength": .number(9), + ])) + document.contents = "First line\r\nUnsaved edit\r\n" + documents[WorkspaceFileID(location: location, path: path)] = document + } + let drafts = directory.appendingPathComponent("files") + try WorkspaceDraftRepository(directory: drafts).save(documents) + let files = WorkspaceFiles(store: store, directory: drafts) + files.refreshContext() + files.open("first.md", position: FileSourcePosition(line: 2)) + let first = try #require(files.revealRequest) + let text = try #require(files.document?.contents) + #expect((text as NSString).substring(with: first.position.range(in: text)) == "Unsaved edit") + files.open("first.md", position: FileSourcePosition(line: 1)) + #expect(files.revealRequest?.id != first.id) + #expect(files.revealRequest?.position.line == 1) + files.open("second.md") + #expect(files.revealRequest == nil) + #expect(files.document?.contents == text) + files.open("second.md", position: FileSourcePosition(line: 2, column: 3)) + #expect(files.revealRequest?.file.path == "second.md") + store.selectedID = nil + files.refreshContext() + #expect(files.revealRequest == nil) + #expect(files.document == nil) +} diff --git a/apps/macos/Tests/T3NativeTests/NativeConversationLayoutTests.swift b/apps/macos/Tests/T3NativeTests/NativeConversationLayoutTests.swift new file mode 100644 index 0000000000000..af639c19327b8 --- /dev/null +++ b/apps/macos/Tests/T3NativeTests/NativeConversationLayoutTests.swift @@ -0,0 +1,396 @@ +import AppKit +import Foundation +import T3Core +import Testing + +@testable import T3Native + +@MainActor private func conversationDescendants(_ view: NSView) -> [NSView] { + [view] + view.subviews.flatMap(conversationDescendants) +} + +/// AppKit lays out the real component under a fixed viewport, without an NSWindow or popover. +@MainActor private func conversationViewport(_ view: NSView, width: CGFloat, height: CGFloat) + -> NSView +{ + let host = NSView(frame: NSRect(x: 0, y: 0, width: width, height: height)) + host.addSubview(view) + view.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + view.leadingAnchor.constraint(equalTo: host.leadingAnchor), + view.trailingAnchor.constraint(equalTo: host.trailingAnchor), + view.topAnchor.constraint(equalTo: host.topAnchor), + view.bottomAnchor.constraint(equalTo: host.bottomAnchor), + ]) + host.layoutSubtreeIfNeeded() + #expect(view.window == nil) + return host +} + +@MainActor @Test func nativeConversationCompactOptionRowsKeepTheirLabelsInsideHitTargets() throws { + let controller = ComposerOptionsPopover( + "Permissions", + options: [ + ComposerOption( + title: "Supervised", description: ComposerControls.modeDescription("approval-required"), + symbol: "lock", selected: true, action: {}), + ComposerOption( + title: "Full access", description: ComposerControls.modeDescription("full-access"), + symbol: "lock.open", action: {}), + ], width: 420, close: {}) + let view = controller.view + let host = conversationViewport(view, width: 420, height: controller.contentSize.height) + let buttons = conversationDescendants(view).compactMap { $0 as? ActionButton } + #expect(buttons.count == 2) + var prior: NSRect? + for button in buttons { + let row = try #require(button.superview) + #expect(row.bounds.width > 380 && row.bounds.height >= 34) + #expect(button.frame == row.bounds) + let rowRect = row.convert(row.bounds, to: view) + if let prior { #expect(!prior.intersects(rowRect)) } + prior = rowRect + for label in conversationDescendants(row).compactMap({ $0 as? NSTextField }) { + let labelRect = label.convert(label.bounds, to: row) + #expect(row.bounds.insetBy(dx: -0.5, dy: -0.5).contains(labelRect)) + } + } + withExtendedLifetime(host) {} +} + +@MainActor @Test func nativeConversationReasoningDescriptionsDoNotOverlapTheNextOption() throws { + let description = + "Use extra reasoning for complex changes across several files, checking assumptions and evaluating alternative approaches before choosing an implementation." + let controller = ComposerOptionsPopover( + "Reasoning", + options: [ + ComposerOption(title: "High", description: description, selected: true, action: {}), + ComposerOption(title: "Low", description: "Respond quickly for simple tasks.", action: {}), + ], width: 210, close: {}) + let view = controller.view + let host = conversationViewport(view, width: 210, height: controller.contentSize.height) + let detail = try #require( + conversationDescendants(view).compactMap { $0 as? NSTextField }.first { + $0.stringValue == description + }) + let button = try #require( + conversationDescendants(view).compactMap { $0 as? ActionButton }.first { + $0.accessibilityLabel() == "High" + }) + let row = try #require(button.superview) + let detailRect = detail.convert(detail.bounds, to: row) + #expect( + row.bounds.insetBy(dx: -0.5, dy: -0.5).contains(detailRect), + "The wrapping provider description must fit inside its clickable option, rather than overlap the next row." + ) + withExtendedLifetime(host) {} +} + +@MainActor @Test func nativeConversationLongBranchMenusKeepTheLastChoiceScrollable() throws { + let options = (1...24).map { index in + ComposerOption(title: "release/native-\(index)", selected: index == 24, action: {}) + } + let controller = ComposerOptionsPopover("Base branch", options: options, width: 320, close: {}) + let view = controller.view + #expect(controller.contentSize.height == 420) + let host = conversationViewport(view, width: 320, height: controller.contentSize.height) + let scroll = try #require(conversationDescendants(view).compactMap { $0 as? NSScrollView }.first) + let document = try #require(scroll.documentView) + #expect(document.bounds.height > scroll.contentSize.height) + #expect( + document.bounds.width <= scroll.contentSize.width + 1, + "A vertically scrolling branch menu must keep its document as narrow as the viewport.") + let buttons = conversationDescendants(document).compactMap { $0 as? ActionButton } + #expect(buttons.count == 24) + let last = try #require(buttons.first { $0.accessibilityLabel() == "release/native-24" }) + let lastRect = last.convert(last.bounds, to: document) + #expect(document.bounds.insetBy(dx: -0.5, dy: -0.5).contains(lastRect)) + document.scrollToVisible(lastRect) + #expect(document.visibleRect.insetBy(dx: -0.5, dy: -0.5).contains(lastRect)) + #expect(scroll.contentSize.width > 280) + withExtendedLifetime(host) {} +} + +@MainActor private func conversationLayoutStore(directory: URL) throws -> ClientStore { + let store = ClientStore(draftDirectory: directory) + let thread: JSON = .object([ + "id": .string("layout-thread"), "projectId": .string("layout-project"), + "title": .string("Layout review"), + "modelSelection": .object([ + "instanceId": .string("codex"), "model": .string("layout-model"), + "options": .array([.object(["id": .string("effort"), "value": .string("high")])]), + ]), + "runtimeMode": .string("full-access"), "interactionMode": .string("plan"), + ]) + try store.shell.apply( + .object([ + "kind": .string("snapshot"), + "snapshot": .object([ + "snapshotSequence": .number(1), "threads": .array([thread]), "projects": .array([]), + ]), + ])) + store.selectedID = "layout-thread" + try store.catalog.apply( + try JSON.decode( + Data( + #""" + {"type":"snapshot","config":{"providers":[{"instanceId":"codex","driver":"codex","enabled":true,"installed":true,"status":"ready","showInteractionModeToggle":true,"supportedRuntimeModes":["approval-required","full-access"],"models":[{"slug":"layout-model","name":"A provider model with a long display name","capabilities":{"optionDescriptors":[{"id":"effort","type":"select","label":"Reasoning","currentValue":"high","options":[{"id":"high","label":"High","isDefault":true}]}]}}]}],"settings":{}}} + """#.utf8))) + store.catalog.setPlanModeEnabled(true) + return store +} + +@MainActor @Test +func nativeConversationDisconnectedModelRowsKeepEveryControlReachableAtCompactWidth() throws { + let directory = URL( + fileURLWithPath: "/tmp/t3-native-evidence/conversation-layout-" + UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let store = try conversationLayoutStore(directory: directory) + let controls = ComposerControls(store: store) + controls.refresh() + let host = conversationViewport(controls, width: 280, height: 32) + let originalViews = conversationDescendants(controls).compactMap { $0 as? ComposerPill } + #expect(originalViews.count == 5) + let minimum = controls.minimumCompactWidth + #expect(minimum <= 200) + for width: CGFloat in [280, 200, 900, 280] { + host.setFrameSize(NSSize(width: width, height: 32)) + host.layoutSubtreeIfNeeded() + let buttons = conversationDescendants(controls).compactMap { $0 as? ComposerPill }.filter { + !$0.isHidden + } + let labels = Set(buttons.compactMap { $0.accessibilityLabel() }) + #expect(labels.isSuperset(of: ["Model", "Reasoning", "Permissions"])) + #expect(buttons.count == 4) + #expect( + buttons.allSatisfy { !$0.isEnabled }, + "A disconnected conversation must not leave model or permission controls interactive.") + for button in buttons { + #expect( + button.frame.width >= 36, + "Each visible pill retains a usable minimum hit target after compacting.") + #expect( + button.frame.minX >= -0.5 && button.frame.maxX <= controls.bounds.maxX + 0.5, + "The control must remain horizontally inside the composer row.") + } + if width < 900 { + #expect(labels.contains("More conversation options")) + let menu = controls.overflowMenu() + let plan = try #require(menu.items.first { $0.title == "Plan mode" }) + #expect(plan.state == .on && !plan.isEnabled) + let reasoning = try #require( + menu.items.first { $0.title == "Reasoning" }?.submenu?.items.first) + #expect(reasoning.title == "High" && reasoning.state == .on && !reasoning.isEnabled) + } else { + #expect(labels.contains("Interaction mode")) + #expect(!labels.contains("More conversation options")) + } + controls.refresh() + let reused = conversationDescendants(controls).compactMap { $0 as? ComposerPill } + #expect( + Set(originalViews.map(ObjectIdentifier.init)) == Set(reused.map(ObjectIdentifier.init)), + "Resizing and unchanged refreshes reuse the controls instead of rebuilding conversation content." + ) + } + withExtendedLifetime(host) {} +} + +@MainActor @Test +func nativeConversationWindowResizeRestoresComposerLabelsWithoutChildInvalidation() throws { + let directory = URL( + fileURLWithPath: "/tmp/t3-native-evidence/conversation-window-layout-" + UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let store = try conversationLayoutStore(directory: directory) + let nestedControls = ComposerControls(store: store) + nestedControls.refresh() + let endButtons = ["paperclip", "stop.fill", "arrow.up.circle.fill", "chevron.down"].enumerated() + .map { index, symbol in + let button = NSButton( + image: NSImage(systemSymbolName: symbol, accessibilityDescription: nil)!, target: nil, + action: nil) + button.isBordered = false + button.widthAnchor.constraint(equalToConstant: index == 2 ? 36 : 24).isActive = true + return button + } + let bottom = NSStackView(views: [nestedControls, NSView()] + endButtons) + bottom.spacing = 8 + let editor = NSTextView() + editor.isVerticallyResizable = true + editor.isHorizontallyResizable = false + editor.autoresizingMask = [.width] + editor.textContainer?.widthTracksTextView = true + let scroll = NSScrollView() + scroll.documentView = editor + scroll.heightAnchor.constraint(equalToConstant: 86).isActive = true + let composer = NSStackView(views: [scroll, bottom]) + composer.orientation = .vertical + composer.alignment = .leading + composer.spacing = 8 + let surface = NSView() + surface.addSubview(composer) + composer.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + composer.leadingAnchor.constraint(equalTo: surface.leadingAnchor, constant: 16), + composer.trailingAnchor.constraint(equalTo: surface.trailingAnchor, constant: -16), + composer.topAnchor.constraint(equalTo: surface.topAnchor, constant: 6), + composer.bottomAnchor.constraint(equalTo: surface.bottomAnchor, constant: -6), + scroll.widthAnchor.constraint(equalTo: composer.widthAnchor), + bottom.widthAnchor.constraint(equalTo: composer.widthAnchor), + ]) + _ = NSApplication.shared + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 1187, height: 840), + styleMask: [.titled, .resizable], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + defer { window.close() } + let content = try #require(window.contentView) + content.addSubview(surface) + surface.translatesAutoresizingMaskIntoConstraints = false + let preferredWidth = surface.widthAnchor.constraint(equalTo: content.widthAnchor, constant: -304) + preferredWidth.priority = .defaultHigh + NSLayoutConstraint.activate([ + surface.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 280), + surface.trailingAnchor.constraint(lessThanOrEqualTo: content.trailingAnchor, constant: -24), + surface.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -16), + surface.heightAnchor.constraint(equalToConstant: 142), + surface.widthAnchor.constraint(lessThanOrEqualToConstant: 880), + preferredWidth, + ]) + let nestedViews = conversationDescendants(nestedControls).compactMap { $0 as? ComposerPill } + for width: CGFloat in [1187, 900, 1187, 900, 1187] { + window.setContentSize(NSSize(width: width, height: width == 900 ? 720 : 839)) + // Flush only AppKit's pending layout; do not invalidate the controls or their descendants. + window.layoutIfNeeded() + let visible = conversationDescendants(nestedControls).compactMap { $0 as? ComposerPill }.filter + { !$0.isHidden } + #expect( + Set(visible.compactMap { $0.accessibilityLabel() }).isSuperset(of: [ + "Model", "Reasoning", "Permissions", + ])) + #expect(bottom.bounds.width == min(880, width - 304) - 32) + #expect(scroll.bounds.width == bottom.bounds.width) + var previousRect: NSRect? + for button in visible + endButtons { + let rect = button.convert(button.bounds, to: bottom) + if let previousRect { #expect(!previousRect.intersects(rect)) } + previousRect = rect + #expect(rect.width >= (endButtons.contains(button) ? 24 : 36)) + #expect(rect.minX >= -0.5 && rect.maxX <= bottom.bounds.maxX + 0.5) + } + if width == 1187 { + let model = try #require(visible.first { $0.accessibilityLabel() == "Model" }) + let reasoning = try #require(visible.first { $0.accessibilityLabel() == "Reasoning" }) + let permissions = try #require(visible.first { $0.accessibilityLabel() == "Permissions" }) + #expect(model.title == "A provider model with a long display name ⌄") + #expect(reasoning.title == "High ⌄") + #expect(permissions.title == "Full access ⌄") + #expect(model.frame.width > 100 && reasoning.frame.width > 36 && permissions.frame.width > 80) + #expect(visible.contains { $0.accessibilityLabel() == "Interaction mode" }) + #expect(!visible.contains { $0.accessibilityLabel() == "More conversation options" }) + #expect(reasoning.frame.width >= reasoning.intrinsicContentSize.width - 0.5) + #expect(permissions.frame.width >= permissions.intrinsicContentSize.width - 0.5) + } else { + #expect(visible.first { $0.accessibilityLabel() == "Model" }?.title == "Model ⌄") + #expect(visible.first { $0.accessibilityLabel() == "Permissions" }?.title == "Access ⌄") + #expect(visible.contains { $0.accessibilityLabel() == "More conversation options" }) + } + let currentViews = conversationDescendants(nestedControls).compactMap { $0 as? ComposerPill } + #expect( + Set(nestedViews.map(ObjectIdentifier.init)) == Set(currentViews.map(ObjectIdentifier.init))) + } +} + +@Test func nativeConversationBranchFetchRetriesReadinessAndRejectsStaleSessions() { + let initialSession = UUID() + func key(ready: Bool = true, session: UUID? = nil, retry: Int = 0, draftID: String = "draft") + -> DraftBranchFetchState.Key + { + .init( + environment: "ws://server", session: session ?? initialSession, draftID: draftID, + root: "/repo", ready: ready, retry: retry) + } + var fetch = DraftBranchFetchState() + #expect({ fetch.select(key(ready: false)) }()) + #expect(!fetch.loading) + let request = key() + #expect({ fetch.select(request) }()) + #expect(fetch.loading) + #expect( + { !fetch.select(request) }(), + "An ordinary refresh does not refetch or cancel an in-flight request.") + #expect({ fetch.finish(request, current: request, error: "Connection closed") }()) + #expect(fetch.failure == "Connection closed" && !fetch.loading) + #expect({ !fetch.select(request) }(), "A failure stays visible without creating a request loop.") + let retry = key(retry: 1) + #expect({ fetch.select(retry) }()) + #expect(fetch.loading && fetch.failure == nil) + #expect( + { !fetch.finish(request, current: retry) }(), + "A late result from before retry cannot replace newer branches.") + #expect({ fetch.finish(retry, current: retry) }()) + let reconnected = key(session: UUID(), retry: 1) + #expect({ fetch.select(reconnected) }()) + #expect(fetch.loading) + #expect( + { !fetch.finish(retry, current: reconnected) }(), + "The same endpoint and project after reconnect is a new scope.") + #expect( + { + !fetch.finish(reconnected, current: key(ready: false, session: reconnected.session, retry: 1)) + }()) + #expect( + { + !fetch.finish( + reconnected, current: key(session: reconnected.session, retry: 1, draftID: "other")) + }()) + #expect({ fetch.finish(reconnected, current: reconnected) }()) + #expect(!fetch.loading && fetch.failure == nil) +} + +@MainActor @Test func nativeConversationContextReminderFitsAndDismissesWithoutCompacting() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let store = ClientStore(draftDirectory: directory) + store.selectedID = "context-layout" + var thread = ThreadState(id: "context-layout") + _ = try thread.apply( + try JSON.decode( + Data( + #""" + {"kind":"snapshot","snapshotSequence":1,"projection":{ + "thread":{"id":"context-layout","activeProviderThreadId":"context-provider"}, + "providerThreads":[{"id":"context-provider","appThreadId":"context-layout", + "driver":"claudeAgent","providerInstanceId":"context-layout-test", + "updatedAt":"2020-01-01T12:00:00Z","contextUsage":{"usedTokens":206000,"maxTokens":200000}}] + }} + """#.utf8))) + store.thread = thread + let banner = NativeContextBanner(store: store) + var compacted = false + banner.onCompact = { compacted = true } + let host = conversationViewport(banner, width: 880, height: 70) + let dismiss = try #require( + conversationDescendants(banner).compactMap { $0 as? NSButton }.first { + $0.accessibilityLabel() == "Keep full history" + }) + let reminder = try #require(dismiss.superview) + #expect(!reminder.isHidden) + for width: CGFloat in [880, 420, 880] { + host.setFrameSize(NSSize(width: width, height: 70)) + host.layoutSubtreeIfNeeded() + let controls = reminder.subviews.filter { $0 is NSTextField || $0 is NSButton } + for control in controls { + #expect(control.frame.minX >= 0 && control.frame.maxX <= reminder.bounds.maxX) + } + #expect(dismiss.frame.maxX >= reminder.bounds.maxX - 12) + } + dismiss.performClick(nil) + #expect(reminder.isHidden) + #expect(!compacted) + let meter = try #require( + conversationDescendants(banner).compactMap { $0 as? NSButton }.first { + $0.accessibilityLabel()?.hasPrefix("Context usage:") == true + }) + #expect(meter.isEnabled && !banner.isHidden) + withExtendedLifetime(host) {} +} diff --git a/apps/macos/Tests/T3NativeTests/NativeDraftRuntimeTests.swift b/apps/macos/Tests/T3NativeTests/NativeDraftRuntimeTests.swift new file mode 100644 index 0000000000000..1fb4bc06ddc61 --- /dev/null +++ b/apps/macos/Tests/T3NativeTests/NativeDraftRuntimeTests.swift @@ -0,0 +1,20 @@ +import Foundation +import T3Core +import Testing + +@testable import T3Native + +@MainActor +@Test( + .enabled( + if: ProcessInfo.processInfo.environment["T3_NATIVE_NEW_DRAFT_SMOKE_CONNECTION_FILE"] != nil) +) +func localConversationDraftWithTheActualIsolatedClientStore() async throws { + let path = try #require( + ProcessInfo.processInfo.environment["T3_NATIVE_NEW_DRAFT_SMOKE_CONNECTION_FILE"]) + let root = + ProcessInfo.processInfo.environment["T3_NATIVE_NEW_DRAFT_SMOKE_ROOT"] + ?? "/tmp/t3-native-evidence" + try await NativeDraftSmoke.run( + settings: ConnectionSettings.load(URL(fileURLWithPath: path)), root: root) +} diff --git a/apps/macos/Tests/T3NativeTests/NativeNotificationTransitionsTests.swift b/apps/macos/Tests/T3NativeTests/NativeNotificationTransitionsTests.swift new file mode 100644 index 0000000000000..452781c8e3eae --- /dev/null +++ b/apps/macos/Tests/T3NativeTests/NativeNotificationTransitionsTests.swift @@ -0,0 +1,150 @@ +import Foundation +import T3Core +import Testing + +@testable import T3Native + +private func nativeNoticeThread( + status: String, run: String = "run-1", completed: String? = nil, + request: String? = nil, archived: Bool = false, subagent: Bool = false +) -> JSON { + .object([ + "id": .string("thread-1"), "projectId": .string("project-1"), "title": .string("My thread"), + "status": .string(status), "latestRunId": .string(run), + "latestRunCompletedAt": completed.map(JSON.string) ?? .null, + "pendingRuntimeRequest": request.map { + .object(["id": .string($0), "kind": .string("user_input")]) + } ?? .null, + "archivedAt": archived ? .string("2026-10-12T12:00:00Z") : .null, + "lineage": .object(["relationshipToParent": .string(subagent ? "subagent" : "root")]), + ]) +} +private func nativeNoticeSnapshot(_ thread: JSON) -> JSON { + .object([ + "kind": .string("snapshot"), + "snapshot": .object(["snapshotSequence": .number(1), "threads": .array([thread])]), + ]) +} +private func nativeNoticeUpdate(_ thread: JSON, sequence: Int) -> JSON { + .object([ + "kind": .string("thread.updated"), "sequence": .number(Double(sequence)), "thread": thread, + ]) +} + +@Test func nativeNotificationsAlertOnceForCompletionAndIgnoreShellReplayOrTitleEdits() { + var tracker = NativeNotificationTransitionTracker() + #expect(tracker.receive(nativeNoticeSnapshot(nativeNoticeThread(status: "running"))).isEmpty) + let completed = nativeNoticeThread(status: "completed", completed: "2026-10-12T12:00:00Z") + let notices = tracker.receive(nativeNoticeUpdate(completed, sequence: 2)) + #expect(notices.count == 1) + #expect(notices.first?.kind == .completion) + #expect(tracker.receive(nativeNoticeUpdate(completed, sequence: 2)).isEmpty) + #expect(tracker.receive(nativeNoticeUpdate(completed, sequence: 3)).isEmpty) + #expect(tracker.receive(nativeNoticeSnapshot(completed)).isEmpty) + #expect(tracker.receive(nativeNoticeUpdate(completed, sequence: 4)).isEmpty) +} + +@Test func nativeNotificationsSeedSnapshotsSilentlyAndDeduplicateRepeatedRequests() { + var tracker = NativeNotificationTransitionTracker() + #expect( + tracker.receive(nativeNoticeSnapshot(nativeNoticeThread(status: "waiting", request: "old"))) + .isEmpty) + #expect( + tracker.receive( + nativeNoticeUpdate(nativeNoticeThread(status: "waiting", request: "old"), sequence: 2) + ).isEmpty) + let first = tracker.receive( + nativeNoticeUpdate(nativeNoticeThread(status: "waiting", request: "new"), sequence: 3)) + #expect(first.first?.kind == .attention) + #expect( + tracker.receive(nativeNoticeUpdate(nativeNoticeThread(status: "running"), sequence: 4)).isEmpty) + #expect( + tracker.receive( + nativeNoticeUpdate(nativeNoticeThread(status: "waiting", request: "new"), sequence: 5) + ).isEmpty) + #expect( + tracker.receive( + nativeNoticeUpdate(nativeNoticeThread(status: "waiting", request: "next"), sequence: 6) + ).count == 1) +} + +@Test func nativeNotificationsIgnoreArchivedSubagentsInterruptedAndCancelledWork() { + for thread in [ + nativeNoticeThread(status: "completed", completed: "2026-10-12T12:00:00Z", archived: true), + nativeNoticeThread(status: "completed", completed: "2026-10-12T12:00:00Z", subagent: true), + nativeNoticeThread(status: "interrupted", completed: "2026-10-12T12:00:00Z"), + nativeNoticeThread(status: "cancelled", completed: "2026-10-12T12:00:00Z"), + ] { + var tracker = NativeNotificationTransitionTracker() + _ = tracker.receive(nativeNoticeSnapshot(nativeNoticeThread(status: "running"))) + #expect(tracker.receive(nativeNoticeUpdate(thread, sequence: 2)).isEmpty) + } +} + +@Test func nativeNotificationsNewRunsCanFailAgainAndPreferencesFilterEachKind() { + var tracker = NativeNotificationTransitionTracker() + _ = tracker.receive(nativeNoticeSnapshot(nativeNoticeThread(status: "running"))) + #expect( + tracker.receive(nativeNoticeUpdate(nativeNoticeThread(status: "failed"), sequence: 2)).first? + .kind == .failure) + #expect( + tracker.receive(nativeNoticeUpdate(nativeNoticeThread(status: "failed"), sequence: 3)).isEmpty) + #expect( + tracker.receive( + nativeNoticeUpdate(nativeNoticeThread(status: "failed", run: "run-2"), sequence: 4) + ).count == 1) + var choices = NativeNotificationValues() + #expect(!choices.allows(.completion)) + choices.mode = .notificationsAndSound + #expect(choices.allows(.completion)) + choices.attention = false + #expect(!choices.allows(.attention)) + #expect(choices.allows(.failure)) + tracker.reset() + #expect( + tracker.receive(nativeNoticeUpdate(nativeNoticeThread(status: "failed"), sequence: 5)).isEmpty) +} + +@Test func nativeNotificationsUsageLimitFailuresAreAttentionAndResetTimeChangesDoNotRepeat() { + var tracker = NativeNotificationTransitionTracker() + _ = tracker.receive(nativeNoticeSnapshot(nativeNoticeThread(status: "running"))) + guard case .object(var fields) = nativeNoticeThread(status: "failed") else { return } + fields["lastErrorClass"] = .string("usage_limit") + fields["usageLimitResetAt"] = .string("2026-10-12T14:00:00Z") + let limited = tracker.receive(nativeNoticeUpdate(.object(fields), sequence: 2)) + #expect(limited.first?.kind == .attention) + #expect(limited.first?.title == "Usage limit reached") + fields["usageLimitResetAt"] = .string("2026-10-12T15:00:00Z") + #expect(tracker.receive(nativeNoticeUpdate(.object(fields), sequence: 3)).isEmpty) +} + +@Test func nativeNotificationsDoNotReplayAttentionSeededBySnapshots() { + var tracker = NativeNotificationTransitionTracker() + _ = tracker.receive( + nativeNoticeSnapshot(nativeNoticeThread(status: "waiting", request: "existing"))) + _ = tracker.receive(nativeNoticeUpdate(nativeNoticeThread(status: "running"), sequence: 2)) + #expect( + tracker.receive( + nativeNoticeUpdate(nativeNoticeThread(status: "waiting", request: "existing"), sequence: 3) + ).isEmpty) + #expect( + tracker.receive( + nativeNoticeUpdate(nativeNoticeThread(status: "waiting", request: "new"), sequence: 4) + ).count == 1) + _ = tracker.receive(nativeNoticeSnapshot(nativeNoticeThread(status: "running"))) + #expect( + tracker.receive( + nativeNoticeUpdate(nativeNoticeThread(status: "waiting", request: "new"), sequence: 5) + ).isEmpty) +} + +@Test func nativeNotificationsBackgroundWaitsDoNotProduceCompletionAlerts() { + var tracker = NativeNotificationTransitionTracker() + _ = tracker.receive(nativeNoticeSnapshot(nativeNoticeThread(status: "running"))) + guard + case .object(var fields) = nativeNoticeThread( + status: "completed", completed: "2026-10-12T12:00:00Z") + else { return } + fields["pendingBackgroundTasks"] = .array([.object(["kind": .string("monitor")])]) + #expect(tracker.receive(nativeNoticeUpdate(.object(fields), sequence: 2)).isEmpty) +} diff --git a/apps/macos/Tests/T3NativeTests/NativePreferencesTests.swift b/apps/macos/Tests/T3NativeTests/NativePreferencesTests.swift new file mode 100644 index 0000000000000..56345e0961945 --- /dev/null +++ b/apps/macos/Tests/T3NativeTests/NativePreferencesTests.swift @@ -0,0 +1,102 @@ +import Combine +import Foundation +import Testing + +@testable import T3Native + +@MainActor @Test func nativePreferencesRoundTripAndObserveAfterDurableSave() throws { + let directory = URL(fileURLWithPath: "/tmp/t3-native-evidence/preferences-" + UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let file = directory.appendingPathComponent("preferences.json") + let preferences = NativePreferences(file: file) + var observed: [NativePreferenceValues] = [] + var persistedDuringObservation = false + let token = preferences.observeChanges { values in + observed.append(values) + persistedDuringObservation = + (try? Data(contentsOf: file)) != nil && preferences.values == values + } + try preferences.update { + $0.appearance.appearance = .dark + $0.appearance.interfaceFont = " Helvetica " + $0.appearance.interfaceFontSize = 18 + $0.appearance.codeFont = "Menlo" + $0.appearance.codeFontSize = 15 + $0.appearance.chatWidth = .wide + $0.appearance.wordWrap = false + $0.notifications.mode = .notificationsAndSound + $0.notifications.failure = false + $0.notifications.whileAppActive = true + } + let restored = NativePreferences(file: file) + #expect(restored.values == preferences.values) + #expect(restored.appearance.interfaceFont == "Helvetica") + #expect(restored.appearance.resolvedInterfaceFont.pointSize == 18) + #expect(restored.appearance.resolvedCodeFont.pointSize == 15) + #expect(restored.appearance.chatWidth.maximumWidth == 1200) + #expect(persistedDuringObservation) + #expect(observed == [preferences.values]) + try preferences.update { $0.appearance.interfaceFontSize = 18 } + #expect(observed.count == 1) + withExtendedLifetime(token) {} +} + +@Test func nativePreferencesDecodeOlderPartialFilesAndDefaults() throws { + let defaults = try JSONDecoder().decode(NativePreferenceValues.self, from: Data("{}".utf8)) + #expect(defaults == NativePreferenceValues()) + #expect(defaults.appearance.appearance == .system) + #expect(defaults.appearance.interfaceFontSize == 16) + #expect(defaults.appearance.codeFontSize == 13) + #expect(defaults.appearance.wordWrap) + #expect(defaults.notifications.mode == .off) + let partial = try JSONDecoder().decode( + NativePreferenceValues.self, + from: Data(#"{"appearance":{"appearance":"dark"},"notifications":{"mode":"sound"}}"#.utf8)) + #expect(partial.appearance.appearance == .dark) + #expect(partial.appearance.chatWidth == .comfortable) + #expect(partial.notifications.completion) + #expect(partial.notifications.mode.playsSound) + #expect(!partial.notifications.mode.showsBanner) +} + +@MainActor @Test func nativePreferencesFailedWritesAndInvalidValuesKeepSavedState() throws { + let directory = URL( + fileURLWithPath: "/tmp/t3-native-evidence/preferences-failure-" + UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let file = directory.appendingPathComponent("preferences.json") + let preferences = NativePreferences(file: file) + try preferences.update { $0.appearance.appearance = .light } + let saved = try Data(contentsOf: file) + let original = preferences.values + var emissions = 0 + let token = preferences.observeChanges { _ in emissions += 1 } + #expect(throws: (any Error).self) { try preferences.update { $0.appearance.codeFontSize = 99 } } + #expect(try Data(contentsOf: file) == saved) + #expect(preferences.values == original) + try FileManager.default.removeItem(at: file) + try FileManager.default.createDirectory(at: file, withIntermediateDirectories: false) + #expect(throws: (any Error).self) { try preferences.update { $0.notifications.mode = .sound } } + #expect(preferences.values == original) + #expect(preferences.error != nil) + #expect(emissions == 0) + withExtendedLifetime(token) {} +} + +@MainActor @Test func nativePreferencesCorruptFileIsPreservedAndFontFallbackIsSafe() throws { + let directory = URL( + fileURLWithPath: "/tmp/t3-native-evidence/preferences-corrupt-" + UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let file = directory.appendingPathComponent("preferences.json") + try Data("broken".utf8).write(to: file) + let preferences = NativePreferences(file: file) + #expect(preferences.values == NativePreferenceValues()) + #expect(preferences.error != nil) + #expect(try String(contentsOf: file, encoding: .utf8) == "broken") + var appearance = NativeAppearanceValues() + appearance.interfaceFont = "UnavailableFont-" + UUID().uuidString + appearance.codeFont = appearance.interfaceFont + #expect(appearance.resolvedInterfaceFont.pointSize == CGFloat(appearance.interfaceFontSize)) + #expect(appearance.resolvedCodeFont.isFixedPitch) + #expect(NativeChatWidth.full.maximumWidth == nil) +} diff --git a/apps/macos/Tests/T3NativeTests/NativeSettingsContractTests.swift b/apps/macos/Tests/T3NativeTests/NativeSettingsContractTests.swift new file mode 100644 index 0000000000000..6c2d4f4b2f4fd --- /dev/null +++ b/apps/macos/Tests/T3NativeTests/NativeSettingsContractTests.swift @@ -0,0 +1,82 @@ +import Foundation +import T3Core +import Testing + +@testable import T3Native + +@Test func nativeSettingsPatchesUseGlobalServerContractWithoutProjectOverrides() throws { + let cases: [(NativeServerSettingsPatch, String, JSON)] = [ + (.workspace("worktree"), "defaultThreadEnvMode", .string("worktree")), + (.workspace(nil), "defaultThreadEnvMode", .null), + (.permission("full-access"), "defaultRuntimeMode", .string("full-access")), + (.restartContinuation(true), "continueThreadsAfterServerUpdate", .bool(true)), + (.usageLimitContinuation(true), "autoResumeLimitedThreads", .bool(true)), + (.settleOnMerge(false), "sidebarAutoSettleOnMerge", .bool(false)), + (.settleAfterDays(90), "sidebarAutoSettleAfterDays", .number(90)), + (.settleAfterDays(nil), "sidebarAutoSettleAfterDays", .null), + (.worktreesDirectory(" /tmp/worktrees "), "worktreesDirectory", .string("/tmp/worktrees")), + (.model(.null), "defaultModelSelection", .null), + ] + for (patch, key, value) in cases { + let request = RPCConnection.request( + id: "settings", method: "server.updateSettings", payload: try patch.payload()) + let decoded = try JSON.decode(request.data()) + #expect(decoded["tag"] == .string("server.updateSettings")) + #expect(decoded["payload"] == .object(["patch": .object([key: value])])) + } +} + +@Test func nativeSettingsReasoningPatchRetainsProviderModelAndOtherOptions() throws { + let selection: JSON = .object([ + "instanceId": .string("custom-codex"), "model": .string("a-real-catalog-choice"), + "options": .array([ + .object(["id": .string("reasoningEffort"), "value": .string("medium")]), + .object(["id": .string("serviceTier"), "value": .string("fast")]), + ]), + ]) + let payload = try NativeServerSettingsPatch.modelOption( + selection: selection, id: "reasoningEffort", value: .string("high") + ).payload() + let changed = payload["patch"]["defaultModelSelection"] + #expect(changed["instanceId"] == selection["instanceId"]) + #expect(changed["model"] == selection["model"]) + #expect( + changed["options"].array.contains( + .object(["id": .string("serviceTier"), "value": .string("fast")]))) + #expect( + changed["options"].array.filter { $0["id"].string == "reasoningEffort" } == [ + .object(["id": .string("reasoningEffort"), "value": .string("high")]) + ]) + let reset = try NativeServerSettingsPatch.modelOption( + selection: changed, id: "reasoningEffort", value: .null + ).payload() + #expect( + reset["patch"]["defaultModelSelection"]["options"].array == [selection["options"].array[1]]) +} + +@Test func nativeSettingsRejectOutOfRangeSettlingAndUnsupportedWorkspaceBeforeRPC() { + #expect(throws: (any Error).self) { try NativeServerSettingsPatch.settleAfterDays(0).payload() } + #expect(throws: (any Error).self) { try NativeServerSettingsPatch.settleAfterDays(91).payload() } + #expect(throws: (any Error).self) { + try NativeServerSettingsPatch.workspace("container").payload() + } + #expect(throws: (any Error).self) { + try NativeServerSettingsPatch.permission("invalid").payload() + } +} + +@Test func nativeSettingsResponseGateRejectsSameEndpointReconnectAndOlderWindowRequest() throws { + let endpoint = URL(string: "http://127.0.0.1:9100")! + let settings = try ConnectionSettings(endpoint: endpoint, token: "test") + let issued = RPCConnection(settings: settings) + let replacement = RPCConnection(settings: settings) + let revision = UUID() + let gate = NativeSettingsResponseGate(connection: issued, endpoint: endpoint, revision: revision) + #expect(gate.accepts(connection: issued, endpoint: endpoint, revision: revision)) + #expect(!gate.accepts(connection: replacement, endpoint: endpoint, revision: revision)) + #expect(!gate.accepts(connection: nil, endpoint: endpoint, revision: revision)) + #expect( + !gate.accepts( + connection: issued, endpoint: URL(string: "http://127.0.0.1:9200")!, revision: revision)) + #expect(!gate.accepts(connection: issued, endpoint: endpoint, revision: UUID())) +} diff --git a/apps/macos/Tests/T3NativeTests/ReviewTextTests.swift b/apps/macos/Tests/T3NativeTests/ReviewTextTests.swift new file mode 100644 index 0000000000000..1f2190f064633 --- /dev/null +++ b/apps/macos/Tests/T3NativeTests/ReviewTextTests.swift @@ -0,0 +1,33 @@ +import AppKit +import T3Core +import Testing + +@testable import T3Native + +@MainActor @Test func diffPresentationLinksOnlyCurrentLinesAndBoundsLargePatches() throws { + let file = try #require( + UnifiedDiff.parse( + """ + diff --git a/test b/test + @@ -7,2 +7,2 @@ + same + -old + +new 日本語 + """ + ).first) + let rendered = ReviewText.render(file) + #expect(!rendered.limited) + #expect(rendered.text.string.contains("new 日本語")) + var links: [String] = [] + rendered.text.enumerateAttribute(.link, in: NSRange(location: 0, length: rendered.text.length)) { + value, _, _ in + if let value = value as? String { links.append(value) } + } + #expect(links == ["7", "8"]) + let huge = UnifiedDiff.parse( + "diff --git a/huge b/huge\n@@ -0,0 +1,12000 @@\n" + String(repeating: "+line\n", count: 12_000) + ).first + let limited = ReviewText.render(huge) + #expect(limited.limited) + #expect(limited.text.string.components(separatedBy: "\n").count <= 10_001) +} diff --git a/apps/macos/Tests/T3NativeTests/SidebarPresentationTests.swift b/apps/macos/Tests/T3NativeTests/SidebarPresentationTests.swift new file mode 100644 index 0000000000000..44848e26ff9a6 --- /dev/null +++ b/apps/macos/Tests/T3NativeTests/SidebarPresentationTests.swift @@ -0,0 +1,91 @@ +import AppKit +import T3Core +import Testing + +@testable import T3Native + +@Test func sidebarIdentitiesMatchTheElectronUnicodeAndTimeRules() { + #expect(SidebarProjectIdentity(name: "owner_claude_cathedral").text == "OC") + #expect(SidebarProjectIdentity(name: " T3 Code ").text == "T3") + #expect(SidebarProjectIdentity(name: "").text == "PR") + #expect(SidebarProjectIdentity(name: "owner_claude_cathedral").color == "rose") + #expect(SidebarProviderMark.initials("Claude Cathedral") == "CC") + #expect(SidebarProviderMark.initials("😀account") == "😀A") + let now = Date(timeIntervalSince1970: 10_000_000) + #expect(SidebarThreadPresentation.time(now.addingTimeInterval(60), now: now) == "now") + #expect(SidebarThreadPresentation.time(now.addingTimeInterval(-59), now: now) == "now") + #expect(SidebarThreadPresentation.time(now.addingTimeInterval(-3600), now: now) == "1h") + #expect(SidebarThreadPresentation.time(now.addingTimeInterval(-86_400), now: now) == "1d") +} + +private func sidebarFixture(settled: Bool = false) throws -> SidebarThreadPresentation { + var catalog = ProviderCatalog() + try catalog.apply( + .object([ + "type": .string("snapshot"), + "config": .object([ + "providers": .array( + ["one", "two"].map { id in + .object([ + "instanceId": .string(id), "displayName": .string("Claude " + id), + "driver": .string("claudeAgent"), + "models": .array([ + .object([ + "slug": .string("fable"), "name": .string("Fable"), + ]) + ]), + ]) + }) + ]), + ])) + let thread = try ThreadSummary( + .object([ + "id": .string("t"), "projectId": .string("p"), + "title": .string("A long task title that must still leave room for the activity status"), + "branch": .string("max/a-long-branch-with-important-information-at-the-end"), + "latestUserMessageAt": .string("2026-10-11T12:00:00Z"), + "updatedAt": .string("2026-10-11T14:00:00Z"), + "providerInstanceHistory": .array([.string("two"), .string("one"), .string("two")]), + "modelSelection": .object(["instanceId": .string("two"), "model": .string("fable")]), + "settledOverride": settled ? .string("settled") : .null, + "settledAt": settled ? .string("2026-10-11T14:00:00Z") : .null, + ])) + return SidebarThreadPresentation( + thread: thread, project: "owner_claude_cathedral", + identity: SidebarProjectIdentity(name: "owner_claude_cathedral"), + directory: SidebarProviderDirectory(catalog), draft: true, enabled: true, + now: ThreadDates.parse("2026-10-11T15:00:00Z")!) +} + +@Test func sidebarHistoryKeepsCurrentAccountLastAndUsesTheCorrectTimeAnchor() throws { + let active = try sidebarFixture() + #expect(active.providers.map(\.id) == ["one", "two"]) + #expect(active.providers.first?.initials == nil) + #expect(active.providers.last?.initials == "CT") + #expect(active.time == "3h") + #expect(active.detail.contains("Claude two\nFable")) + #expect(active.detail.contains("Unsent draft")) + let settled = try sidebarFixture(settled: true) + #expect(settled.compact) + #expect(settled.time == "1h") +} + +@MainActor @Test func reusedSidebarCellsKeepCardAndHistoryTextInsideNarrowRows() throws { + let cell = SidebarThreadCell(frame: NSRect(x: 0, y: 0, width: 220, height: 78)) + let card = try sidebarFixture() + let history = try sidebarFixture(settled: true) + func labels(_ view: NSView) -> [NSTextField] { + view.subviews.flatMap { child in (child as? NSTextField).map { [$0] } ?? labels(child) } + } + for value in [card, history, card, history, card] { + cell.setFrameSize(NSSize(width: value.compact ? 220 : 285, height: value.compact ? 36 : 78)) + cell.configure(value, enabled: true, quickAction: {}, snoozeAction: {}) + cell.layoutSubtreeIfNeeded() + let title = try #require(labels(cell).first { $0.stringValue == value.thread.title }) + let frame = cell.convert(title.bounds, from: title) + #expect(frame.minX >= 0 && frame.maxX <= cell.bounds.maxX + 0.5) + #expect(frame.minY >= 0 && frame.maxY <= cell.bounds.maxY + 0.5) + #expect(frame.width > 70) + #expect(labels(cell).filter { $0.stringValue == value.thread.title }.count == 1) + } +} diff --git a/apps/macos/Tests/T3NativeTests/SplitReviewTests.swift b/apps/macos/Tests/T3NativeTests/SplitReviewTests.swift new file mode 100644 index 0000000000000..0d2fa263dac1d --- /dev/null +++ b/apps/macos/Tests/T3NativeTests/SplitReviewTests.swift @@ -0,0 +1,60 @@ +import AppKit +import T3Core +import Testing + +@testable import T3Native + +@MainActor @Test func splitReviewWrapResizeAndScrollKeepCorrespondingLinesTogether() throws { + let long = String(repeating: "Unicode 日本語 👋 and tabs\t ", count: 12) + let raw = + "diff --git a/a b/a\n@@ -1,5 +1,6 @@\n header\n-short\r\n+\(long)\r\n \(long)\n-\(long)\n+short\n+extra\n end\n" + let file = try #require(UnifiedDiff.parse(raw).first) + let rendered = SplitReviewText(file) + let view = SplitReviewView(frame: NSRect(x: 0, y: 0, width: 600, height: 240)) + view.setWrapped(true) + #expect(!view.render(file)) + func verifyAlignment() throws { + view.layoutSubtreeIfNeeded() + let before = SplitReviewView.rowFrames(in: view.oldText, ranges: rendered.oldRanges) + let after = SplitReviewView.rowFrames(in: view.newText, ranges: rendered.newRanges) + try #require(before.count == after.count && !before.isEmpty) + for index in before.indices { + #expect(!before[index].isNull && !after[index].isNull) + #expect(abs(before[index].minY - after[index].minY) < 0.5) + } + #expect(view.oldText.frame.height == view.newText.frame.height) + } + try verifyAlignment() + view.newText.setSelectedRange(NSRange(location: 4, length: 10)) + view.setFrameSize(NSSize(width: 430, height: 240)) + view.needsLayout = true + try verifyAlignment() + #expect(view.newText.selectedRange() == NSRange(location: 4, length: 10)) + let origin = NSPoint(x: 0, y: 120) + view.oldScroll.contentView.scroll(to: origin) + #expect(abs(view.newScroll.contentView.bounds.minY - origin.y) < 0.5) + view.newScroll.contentView.scroll(to: NSPoint(x: 0, y: 200)) + #expect(abs(view.oldScroll.contentView.bounds.minY - 200) < 0.5) + view.setWrapped(false) + try verifyAlignment() + #expect(view.newText.frame.width > view.newScroll.contentSize.width) + view.render(nil) + #expect(view.oldText.string.isEmpty && view.newText.string.isEmpty) +} + +@MainActor @Test func splitReviewLinksOnlyNewLinesAndDoesNotLosePatchCharacters() throws { + let raw = "diff --git a/a b/a\n@@ -4,2 +4,2 @@\n-before\r\n+after 日本語\r\n same\n" + let file = try #require(UnifiedDiff.parse(raw).first) + let rendered = SplitReviewText(file) + func links(_ text: NSAttributedString) -> [String] { + var result: [String] = [] + text.enumerateAttribute(.link, in: NSRange(location: 0, length: text.length)) { value, _, _ in + if let value = value as? String { result.append(value) } + } + return result + } + #expect(links(rendered.old).isEmpty && links(rendered.new) == ["4", "5"]) + #expect(rendered.old.string.contains("before\r\n")) + #expect(rendered.new.string.contains("after 日本語\r\n")) + #expect(file.rawPatch == raw) +} diff --git a/apps/macos/Tests/T3NativeTests/StartupTests.swift b/apps/macos/Tests/T3NativeTests/StartupTests.swift new file mode 100644 index 0000000000000..442ee9e9ed410 --- /dev/null +++ b/apps/macos/Tests/T3NativeTests/StartupTests.swift @@ -0,0 +1,89 @@ +import AppKit +import T3Core +import Testing + +@testable import T3Native + +@MainActor @Suite(.serialized) struct StartupTests { + private func descendants(_ view: NSView) -> [NSView] { + [view] + view.subviews.flatMap(descendants) + } + + private func visible(_ view: NSView) -> Bool { + !view.isHidden && (view.superview.map(visible) ?? true) + } + + @Test func firstLaunchHasAnActionableConnectionScreen() throws { + _ = NSApplication.shared + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let store = ClientStore(draftDirectory: directory) + let frameKey = "NSWindow Frame T3NativeMain" + let savedFrame = UserDefaults.standard.object(forKey: frameKey) + let controller = ChatWindow(store: store) + let window = try #require(controller.window) + window.setFrameAutosaveName("") + if let savedFrame { + UserDefaults.standard.set(savedFrame, forKey: frameKey) + } else { + UserDefaults.standard.removeObject(forKey: frameKey) + } + defer { window.close() } + let views = descendants(try #require(window.contentView)) + let labels = views.compactMap { $0 as? NSTextField }.filter(visible).map(\.stringValue) + let buttons = views.compactMap { $0 as? NSButton }.filter(visible) + let icon = try #require(views.first { $0.identifier?.rawValue == "thread-search-icon" }) + let search = try #require( + views.compactMap { $0 as? NSTextField }.first { + $0.accessibilityLabel() == "Search threads" + }) + for query in ["", "a long thread search"] { + search.stringValue = query + controller.refresh() + window.contentView?.layoutSubtreeIfNeeded() + let iconBounds = icon.convert(icon.bounds, to: window.contentView) + let searchBounds = search.convert(search.bounds, to: window.contentView) + #expect(searchBounds.minX >= iconBounds.maxX + 4) + #expect(searchBounds.width > 70) + } + #expect(!labels.contains("Connecting…")) + #expect(buttons.contains { $0.title == "Connect to server…" && $0.isEnabled }) + #expect(!buttons.contains { ["Files", "Browser", "Changes", "Terminal"].contains($0.title) }) + #expect(!views.contains { $0 is Composer && visible($0) }) + + store.error = "The saved connection could not be read." + controller.refresh() + let errorLabels = views.compactMap { $0 as? NSTextField }.filter(visible).map(\.stringValue) + #expect(errorLabels.contains(store.error!)) + #expect(buttons.contains { $0.title == "Connect to server…" && $0.isEnabled }) + } + + @Test func selectionIsRestoredPerServerAndExplicitSelectionWins() throws { + let suite = "T3Native.StartupTests.\(UUID().uuidString)" + let preferences = try #require(UserDefaults(suiteName: suite)) + defer { preferences.removePersistentDomain(forName: suite) } + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let first = try ConnectionSettings( + endpoint: URL(string: "http://127.0.0.1:4773")!, token: "test") + let second = try ConnectionSettings( + endpoint: URL(string: "http://127.0.0.1:4774")!, token: "test") + let store = ClientStore(draftDirectory: directory, preferences: preferences) + defer { + store.disconnect() + try? FileManager.default.removeItem(at: directory) + } + store.connect(first) + store.select("first-thread") + store.connect(second) + #expect(store.selectedID == nil) + store.select("second-thread") + store.connect(first) + #expect(store.selectedID == "first-thread") + store.connect(first, selecting: "explicit-thread") + #expect(store.selectedID == "explicit-thread") + + let reopened = ClientStore(draftDirectory: directory, preferences: preferences) + defer { reopened.disconnect() } + reopened.connect(second) + #expect(reopened.selectedID == "second-thread") + } +} diff --git a/apps/macos/Tests/T3NativeTests/TerminalSurfaceTests.swift b/apps/macos/Tests/T3NativeTests/TerminalSurfaceTests.swift new file mode 100644 index 0000000000000..e10e6f588fae4 --- /dev/null +++ b/apps/macos/Tests/T3NativeTests/TerminalSurfaceTests.swift @@ -0,0 +1,113 @@ +import AppKit +import T3Core +import Testing + +@testable import T3Native + +@MainActor @Test func terminalEnginePreservesHistoryAndEncodesInput() async throws { + _ = NSApplication.shared + let surface = TerminalSurface(frame: NSRect(x: 0, y: 0, width: 800, height: 320)) + defer { surface.stop() } + var failure: String? + var grid: TerminalGrid? + surface.onError = { failure = $0 } + surface.onResize = { grid = $0 } + surface.start() + #expect(failure == nil) + #expect(try #require(grid).columns > 40) + surface.inputEnabled = true + var output = TerminalOutput() + output.append("\u{1B}[32m日本語 👋\u{1B}[0m\r\nsecond line") + try await terminalReply(surface) { + output.append("\u{1B}[6n") + surface.synchronize(output) + } + surface.selectAll(nil) + let selected = try #require(surface.selectedText) + #expect(selected.contains("日本語 👋")) + #expect(selected.contains("second line")) + #expect(!selected.contains("[32m")) + #expect((surface.accessibilityValue() as? String)?.contains("日本語 👋") == true) + + // Synchronizing the same snapshot must not duplicate the text. + try await terminalReply(surface) { + surface.synchronize(output) + output.append("\u{1B}[6n") + surface.synchronize(output) + } + surface.selectAll(nil) + #expect(try #require(surface.selectedText).components(separatedBy: "second line").count == 2) + + let input = try await terminalReply( + surface, until: { $0.contains("a\r") }, + perform: { + for (code, text) in [(UInt16(0), "a"), (UInt16(36), "\r")] { + let event = NSEvent.keyEvent( + with: .keyDown, location: .zero, modifierFlags: [], timestamp: 0, windowNumber: 0, + context: nil, characters: text, charactersIgnoringModifiers: text, isARepeat: false, + keyCode: code)! + surface.keyDown(with: event) + } + }) + #expect(input == "a\r") + let committed = try await terminalReply( + surface, until: { $0.contains("日本語") }, + perform: { + surface.setMarkedText( + "に", selectedRange: NSRange(location: 1, length: 0), + replacementRange: NSRange(location: NSNotFound, length: 0)) + surface.insertText("日本語", replacementRange: NSRange(location: NSNotFound, length: 0)) + }) + #expect(committed == "日本語") + #expect(!surface.hasMarkedText()) + + try await terminalReply(surface) { + output.append("\u{1B}[?2004h\u{1B}[6n") + surface.synchronize(output) + } + let pasted = String(repeating: "日本語 👋\n", count: 2000) + let bracketed = try await terminalReply( + surface, until: { $0.hasSuffix("\u{1B}[201~") }, + perform: { + surface.pasteText(pasted) + }) + #expect(bracketed == "\u{1B}[200~" + pasted + "\u{1B}[201~") + + try await terminalReply(surface) { + output.reset("fresh history\u{1B}[6n") + surface.synchronize(output) + } + surface.selectAll(nil) + #expect(try #require(surface.selectedText).contains("fresh history")) + #expect(!(surface.selectedText ?? "").contains("second line")) + surface.stop() + surface.start() + surface.inputEnabled = true + try await terminalReply(surface) { surface.synchronize(output) } + surface.selectAll(nil) + #expect(try #require(surface.selectedText).contains("fresh history")) +} + +@discardableResult @MainActor private func terminalReply( + _ surface: TerminalSurface, until predicate: @escaping (String) -> Bool = { $0.hasSuffix("R") }, + perform: () -> Void +) async throws -> String { + let (stream, continuation) = AsyncStream.makeStream() + surface.onInput = { continuation.yield($0) } + let timeout = Task { + try await Task.sleep(for: .seconds(8)) + continuation.finish() + } + defer { + timeout.cancel() + continuation.finish() + surface.onInput = nil + } + perform() + var received = "" + for await bytes in stream { + received += bytes + if predicate(received) { return received } + } + throw T3Error("Terminal engine did not acknowledge the input/output check.") +} diff --git a/apps/macos/Vendor/GHOSTTY_SHA256 b/apps/macos/Vendor/GHOSTTY_SHA256 new file mode 100644 index 0000000000000..c8af9668078b5 --- /dev/null +++ b/apps/macos/Vendor/GHOSTTY_SHA256 @@ -0,0 +1 @@ +5a297497737834a7b7619b4f2d5b44e9e0f982d393bef3d4b0b6a8f998d9334d GhosttyKit.xcframework/macos-arm64/libghostty-internal.a diff --git a/apps/macos/Vendor/GHOSTTY_VERSION b/apps/macos/Vendor/GHOSTTY_VERSION new file mode 100644 index 0000000000000..92e5c8af3fc81 --- /dev/null +++ b/apps/macos/Vendor/GHOSTTY_VERSION @@ -0,0 +1 @@ +cf8edc23f3a6a87a96e41a90013e89e987d34980 diff --git a/apps/macos/Vendor/GhosttyKit.xcframework/Info.plist b/apps/macos/Vendor/GhosttyKit.xcframework/Info.plist new file mode 100644 index 0000000000000..60c998a4ad2c6 --- /dev/null +++ b/apps/macos/Vendor/GhosttyKit.xcframework/Info.plist @@ -0,0 +1,29 @@ + + + + + AvailableLibraries + + + BinaryPath + libghostty-internal.a + HeadersPath + Headers + LibraryIdentifier + macos-arm64 + LibraryPath + libghostty-internal.a + SupportedArchitectures + + arm64 + + SupportedPlatform + macos + + + CFBundlePackageType + XFWK + XCFrameworkFormatVersion + 1.0 + + diff --git a/apps/macos/Vendor/GhosttyKit.xcframework/macos-arm64/Headers/ghostty.h b/apps/macos/Vendor/GhosttyKit.xcframework/macos-arm64/Headers/ghostty.h new file mode 100644 index 0000000000000..2931441805cdc --- /dev/null +++ b/apps/macos/Vendor/GhosttyKit.xcframework/macos-arm64/Headers/ghostty.h @@ -0,0 +1,1290 @@ +// Ghostty's internal embedder API, a.k.a. "libghostty-internal". +// +// The only consumer of this API is the macOS app, and while it is fairly +// comprehensive, it is tailored to the needs of the macOS app and not designed +// for external use, hence why most functions are undocumented and some are +// macOS-specific (e.g. ones dealing with the Metal graphics API). +// +// External embedders should instead use `libghostty-vt` or other related +// packages, which are extensively documented and designed from the ground up +// to be used in other software. Header files for which can be found in +// `include/ghostty/`. +#ifndef GHOSTTY_H +#define GHOSTTY_H + +#ifdef __cplusplus +extern "C" { +#endif + +#include +#include +#include + +#ifdef _MSC_VER +#include +typedef SSIZE_T ssize_t; +#else +#include +#endif + +//------------------------------------------------------------------- +// Macros + +#define GHOSTTY_SUCCESS 0 + +// Symbol visibility for shared library builds. On Windows, functions +// are exported from the DLL when building and imported when consuming. +// On other platforms with GCC/Clang, functions are marked with default +// visibility so they remain accessible when the library is built with +// -fvisibility=hidden. For static library builds, define GHOSTTY_STATIC +// before including this header to make this a no-op. +#ifndef GHOSTTY_API +#if defined(GHOSTTY_STATIC) + #define GHOSTTY_API +#elif defined(_WIN32) || defined(_WIN64) + #ifdef GHOSTTY_BUILD_SHARED + #define GHOSTTY_API __declspec(dllexport) + #else + #define GHOSTTY_API __declspec(dllimport) + #endif +#elif defined(__GNUC__) && __GNUC__ >= 4 + #define GHOSTTY_API __attribute__((visibility("default"))) +#else + #define GHOSTTY_API +#endif +#endif + +//------------------------------------------------------------------- +// Types + +// Opaque types +typedef void* ghostty_app_t; +typedef void* ghostty_config_t; +typedef void* ghostty_surface_t; +typedef void* ghostty_inspector_t; + +// All the types below are fully defined and must be kept in sync with +// their Zig counterparts. Any changes to these types MUST have an associated +// Zig change. +typedef enum { + GHOSTTY_PLATFORM_INVALID, + GHOSTTY_PLATFORM_MACOS, + GHOSTTY_PLATFORM_IOS, +} ghostty_platform_e; + +// Callback for custom surface I/O writes. +typedef void (*ghostty_surface_write_fn)(void* userdata, + const uint8_t* data, + size_t len); + +typedef enum { + GHOSTTY_CLIPBOARD_STANDARD, + GHOSTTY_CLIPBOARD_SELECTION, + GHOSTTY_CLIPBOARD_PRIMARY, +} ghostty_clipboard_e; + +// One representation of clipboard contents. The data is binary-safe with +// an explicit length; it is not necessarily null-terminated. +typedef struct { + const char *mime; + const char *data; + size_t len; +} ghostty_clipboard_content_s; + +// The payload for completing a clipboard read request. See +// ghostty_surface_complete_clipboard_request. +typedef struct { + const ghostty_clipboard_content_s *contents; + size_t contents_len; + const char *const *available; + size_t available_len; + bool confirmed; + bool remember; +} ghostty_clipboard_complete_s; + +// The payload of a clipboard read confirmation request: the would-be +// completion contents plus the information shown in the permission +// prompt. See ghostty_runtime_confirm_read_clipboard_cb. +typedef struct { + const ghostty_clipboard_content_s *contents; + size_t contents_len; + const char *const *available; + size_t available_len; + const char *name; + bool can_remember; +} ghostty_clipboard_confirm_s; + +typedef enum { + GHOSTTY_CLIPBOARD_REQUEST_PASTE, + GHOSTTY_CLIPBOARD_REQUEST_OSC_52_READ, + GHOSTTY_CLIPBOARD_REQUEST_OSC_52_WRITE, + GHOSTTY_CLIPBOARD_REQUEST_KITTY_READ, + GHOSTTY_CLIPBOARD_REQUEST_KITTY_WRITE, + GHOSTTY_CLIPBOARD_REQUEST_LIST, +} ghostty_clipboard_request_e; + +// apprt.ClipboardReadResult +typedef enum { + GHOSTTY_CLIPBOARD_READ_STARTED, + GHOSTTY_CLIPBOARD_READ_UNAVAILABLE, + GHOSTTY_CLIPBOARD_READ_UNSUPPORTED, +} ghostty_clipboard_read_result_e; + +typedef enum { + GHOSTTY_MOUSE_RELEASE, + GHOSTTY_MOUSE_PRESS, +} ghostty_input_mouse_state_e; + +typedef enum { + GHOSTTY_MOUSE_UNKNOWN, + GHOSTTY_MOUSE_LEFT, + GHOSTTY_MOUSE_RIGHT, + GHOSTTY_MOUSE_MIDDLE, + GHOSTTY_MOUSE_FOUR, + GHOSTTY_MOUSE_FIVE, + GHOSTTY_MOUSE_SIX, + GHOSTTY_MOUSE_SEVEN, + GHOSTTY_MOUSE_EIGHT, + GHOSTTY_MOUSE_NINE, + GHOSTTY_MOUSE_TEN, + GHOSTTY_MOUSE_ELEVEN, +} ghostty_input_mouse_button_e; + +typedef enum { + GHOSTTY_MOUSE_MOMENTUM_NONE, + GHOSTTY_MOUSE_MOMENTUM_BEGAN, + GHOSTTY_MOUSE_MOMENTUM_STATIONARY, + GHOSTTY_MOUSE_MOMENTUM_CHANGED, + GHOSTTY_MOUSE_MOMENTUM_ENDED, + GHOSTTY_MOUSE_MOMENTUM_CANCELLED, + GHOSTTY_MOUSE_MOMENTUM_MAY_BEGIN, +} ghostty_input_mouse_momentum_e; + +typedef enum { + GHOSTTY_COLOR_SCHEME_LIGHT = 0, + GHOSTTY_COLOR_SCHEME_DARK = 1, +} ghostty_color_scheme_e; + +// This is a packed struct (see src/input/mouse.zig) but the C standard +// afaik doesn't let us reliably define packed structs so we build it up +// from scratch. +typedef int ghostty_input_scroll_mods_t; + +typedef enum { + GHOSTTY_MODS_NONE = 0, + GHOSTTY_MODS_SHIFT = 1 << 0, + GHOSTTY_MODS_CTRL = 1 << 1, + GHOSTTY_MODS_ALT = 1 << 2, + GHOSTTY_MODS_SUPER = 1 << 3, + GHOSTTY_MODS_CAPS = 1 << 4, + GHOSTTY_MODS_NUM = 1 << 5, + GHOSTTY_MODS_SHIFT_RIGHT = 1 << 6, + GHOSTTY_MODS_CTRL_RIGHT = 1 << 7, + GHOSTTY_MODS_ALT_RIGHT = 1 << 8, + GHOSTTY_MODS_SUPER_RIGHT = 1 << 9, +} ghostty_input_mods_e; + +typedef enum { + GHOSTTY_BINDING_FLAGS_CONSUMED = 1 << 0, + GHOSTTY_BINDING_FLAGS_ALL = 1 << 1, + GHOSTTY_BINDING_FLAGS_GLOBAL = 1 << 2, + GHOSTTY_BINDING_FLAGS_PERFORMABLE = 1 << 3, +} ghostty_binding_flags_e; + +typedef enum { + GHOSTTY_ACTION_RELEASE, + GHOSTTY_ACTION_PRESS, + GHOSTTY_ACTION_REPEAT, +} ghostty_input_action_e; + +// Based on: https://www.w3.org/TR/uievents-code/ +typedef enum { + GHOSTTY_KEY_UNIDENTIFIED, + + // "Writing System Keys" § 3.1.1 + GHOSTTY_KEY_BACKQUOTE, + GHOSTTY_KEY_BACKSLASH, + GHOSTTY_KEY_BRACKET_LEFT, + GHOSTTY_KEY_BRACKET_RIGHT, + GHOSTTY_KEY_COMMA, + GHOSTTY_KEY_DIGIT_0, + GHOSTTY_KEY_DIGIT_1, + GHOSTTY_KEY_DIGIT_2, + GHOSTTY_KEY_DIGIT_3, + GHOSTTY_KEY_DIGIT_4, + GHOSTTY_KEY_DIGIT_5, + GHOSTTY_KEY_DIGIT_6, + GHOSTTY_KEY_DIGIT_7, + GHOSTTY_KEY_DIGIT_8, + GHOSTTY_KEY_DIGIT_9, + GHOSTTY_KEY_EQUAL, + GHOSTTY_KEY_INTL_BACKSLASH, + GHOSTTY_KEY_INTL_RO, + GHOSTTY_KEY_INTL_YEN, + GHOSTTY_KEY_A, + GHOSTTY_KEY_B, + GHOSTTY_KEY_C, + GHOSTTY_KEY_D, + GHOSTTY_KEY_E, + GHOSTTY_KEY_F, + GHOSTTY_KEY_G, + GHOSTTY_KEY_H, + GHOSTTY_KEY_I, + GHOSTTY_KEY_J, + GHOSTTY_KEY_K, + GHOSTTY_KEY_L, + GHOSTTY_KEY_M, + GHOSTTY_KEY_N, + GHOSTTY_KEY_O, + GHOSTTY_KEY_P, + GHOSTTY_KEY_Q, + GHOSTTY_KEY_R, + GHOSTTY_KEY_S, + GHOSTTY_KEY_T, + GHOSTTY_KEY_U, + GHOSTTY_KEY_V, + GHOSTTY_KEY_W, + GHOSTTY_KEY_X, + GHOSTTY_KEY_Y, + GHOSTTY_KEY_Z, + GHOSTTY_KEY_MINUS, + GHOSTTY_KEY_PERIOD, + GHOSTTY_KEY_QUOTE, + GHOSTTY_KEY_SEMICOLON, + GHOSTTY_KEY_SLASH, + + // "Functional Keys" § 3.1.2 + GHOSTTY_KEY_ALT_LEFT, + GHOSTTY_KEY_ALT_RIGHT, + GHOSTTY_KEY_BACKSPACE, + GHOSTTY_KEY_CAPS_LOCK, + GHOSTTY_KEY_CONTEXT_MENU, + GHOSTTY_KEY_CONTROL_LEFT, + GHOSTTY_KEY_CONTROL_RIGHT, + GHOSTTY_KEY_ENTER, + GHOSTTY_KEY_META_LEFT, + GHOSTTY_KEY_META_RIGHT, + GHOSTTY_KEY_SHIFT_LEFT, + GHOSTTY_KEY_SHIFT_RIGHT, + GHOSTTY_KEY_SPACE, + GHOSTTY_KEY_TAB, + GHOSTTY_KEY_CONVERT, + GHOSTTY_KEY_KANA_MODE, + GHOSTTY_KEY_NON_CONVERT, + + // "Control Pad Section" § 3.2 + GHOSTTY_KEY_DELETE, + GHOSTTY_KEY_END, + GHOSTTY_KEY_HELP, + GHOSTTY_KEY_HOME, + GHOSTTY_KEY_INSERT, + GHOSTTY_KEY_PAGE_DOWN, + GHOSTTY_KEY_PAGE_UP, + + // "Arrow Pad Section" § 3.3 + GHOSTTY_KEY_ARROW_DOWN, + GHOSTTY_KEY_ARROW_LEFT, + GHOSTTY_KEY_ARROW_RIGHT, + GHOSTTY_KEY_ARROW_UP, + + // "Numpad Section" § 3.4 + GHOSTTY_KEY_NUM_LOCK, + GHOSTTY_KEY_NUMPAD_0, + GHOSTTY_KEY_NUMPAD_1, + GHOSTTY_KEY_NUMPAD_2, + GHOSTTY_KEY_NUMPAD_3, + GHOSTTY_KEY_NUMPAD_4, + GHOSTTY_KEY_NUMPAD_5, + GHOSTTY_KEY_NUMPAD_6, + GHOSTTY_KEY_NUMPAD_7, + GHOSTTY_KEY_NUMPAD_8, + GHOSTTY_KEY_NUMPAD_9, + GHOSTTY_KEY_NUMPAD_ADD, + GHOSTTY_KEY_NUMPAD_BACKSPACE, + GHOSTTY_KEY_NUMPAD_CLEAR, + GHOSTTY_KEY_NUMPAD_CLEAR_ENTRY, + GHOSTTY_KEY_NUMPAD_COMMA, + GHOSTTY_KEY_NUMPAD_DECIMAL, + GHOSTTY_KEY_NUMPAD_DIVIDE, + GHOSTTY_KEY_NUMPAD_ENTER, + GHOSTTY_KEY_NUMPAD_EQUAL, + GHOSTTY_KEY_NUMPAD_MEMORY_ADD, + GHOSTTY_KEY_NUMPAD_MEMORY_CLEAR, + GHOSTTY_KEY_NUMPAD_MEMORY_RECALL, + GHOSTTY_KEY_NUMPAD_MEMORY_STORE, + GHOSTTY_KEY_NUMPAD_MEMORY_SUBTRACT, + GHOSTTY_KEY_NUMPAD_MULTIPLY, + GHOSTTY_KEY_NUMPAD_PAREN_LEFT, + GHOSTTY_KEY_NUMPAD_PAREN_RIGHT, + GHOSTTY_KEY_NUMPAD_SUBTRACT, + GHOSTTY_KEY_NUMPAD_SEPARATOR, + GHOSTTY_KEY_NUMPAD_UP, + GHOSTTY_KEY_NUMPAD_DOWN, + GHOSTTY_KEY_NUMPAD_RIGHT, + GHOSTTY_KEY_NUMPAD_LEFT, + GHOSTTY_KEY_NUMPAD_BEGIN, + GHOSTTY_KEY_NUMPAD_HOME, + GHOSTTY_KEY_NUMPAD_END, + GHOSTTY_KEY_NUMPAD_INSERT, + GHOSTTY_KEY_NUMPAD_DELETE, + GHOSTTY_KEY_NUMPAD_PAGE_UP, + GHOSTTY_KEY_NUMPAD_PAGE_DOWN, + + // "Function Section" § 3.5 + GHOSTTY_KEY_ESCAPE, + GHOSTTY_KEY_F1, + GHOSTTY_KEY_F2, + GHOSTTY_KEY_F3, + GHOSTTY_KEY_F4, + GHOSTTY_KEY_F5, + GHOSTTY_KEY_F6, + GHOSTTY_KEY_F7, + GHOSTTY_KEY_F8, + GHOSTTY_KEY_F9, + GHOSTTY_KEY_F10, + GHOSTTY_KEY_F11, + GHOSTTY_KEY_F12, + GHOSTTY_KEY_F13, + GHOSTTY_KEY_F14, + GHOSTTY_KEY_F15, + GHOSTTY_KEY_F16, + GHOSTTY_KEY_F17, + GHOSTTY_KEY_F18, + GHOSTTY_KEY_F19, + GHOSTTY_KEY_F20, + GHOSTTY_KEY_F21, + GHOSTTY_KEY_F22, + GHOSTTY_KEY_F23, + GHOSTTY_KEY_F24, + GHOSTTY_KEY_F25, + GHOSTTY_KEY_FN, + GHOSTTY_KEY_FN_LOCK, + GHOSTTY_KEY_PRINT_SCREEN, + GHOSTTY_KEY_SCROLL_LOCK, + GHOSTTY_KEY_PAUSE, + + // "Media Keys" § 3.6 + GHOSTTY_KEY_BROWSER_BACK, + GHOSTTY_KEY_BROWSER_FAVORITES, + GHOSTTY_KEY_BROWSER_FORWARD, + GHOSTTY_KEY_BROWSER_HOME, + GHOSTTY_KEY_BROWSER_REFRESH, + GHOSTTY_KEY_BROWSER_SEARCH, + GHOSTTY_KEY_BROWSER_STOP, + GHOSTTY_KEY_EJECT, + GHOSTTY_KEY_LAUNCH_APP_1, + GHOSTTY_KEY_LAUNCH_APP_2, + GHOSTTY_KEY_LAUNCH_MAIL, + GHOSTTY_KEY_MEDIA_PLAY_PAUSE, + GHOSTTY_KEY_MEDIA_SELECT, + GHOSTTY_KEY_MEDIA_STOP, + GHOSTTY_KEY_MEDIA_TRACK_NEXT, + GHOSTTY_KEY_MEDIA_TRACK_PREVIOUS, + GHOSTTY_KEY_POWER, + GHOSTTY_KEY_SLEEP, + GHOSTTY_KEY_AUDIO_VOLUME_DOWN, + GHOSTTY_KEY_AUDIO_VOLUME_MUTE, + GHOSTTY_KEY_AUDIO_VOLUME_UP, + GHOSTTY_KEY_WAKE_UP, + + // "Legacy, Non-standard, and Special Keys" § 3.7 + GHOSTTY_KEY_COPY, + GHOSTTY_KEY_CUT, + GHOSTTY_KEY_PASTE, +} ghostty_input_key_e; + +typedef struct { + ghostty_input_action_e action; + ghostty_input_mods_e mods; + ghostty_input_mods_e consumed_mods; + uint32_t keycode; + const char* text; + uint32_t unshifted_codepoint; + bool composing; +} ghostty_input_key_s; + +typedef enum { + GHOSTTY_TRIGGER_PHYSICAL, + GHOSTTY_TRIGGER_UNICODE, + GHOSTTY_TRIGGER_CATCH_ALL, +} ghostty_input_trigger_tag_e; + +typedef union { + ghostty_input_key_e physical; + uint32_t unicode; + // catch_all has no payload +} ghostty_input_trigger_key_u; + +typedef struct { + ghostty_input_trigger_tag_e tag; + ghostty_input_trigger_key_u key; + ghostty_input_mods_e mods; +} ghostty_input_trigger_s; + +typedef struct { + const char* action_key; + const char* action; + const char* title; + const char* description; +} ghostty_command_s; + +typedef enum { + GHOSTTY_BUILD_MODE_DEBUG, + GHOSTTY_BUILD_MODE_RELEASE_SAFE, + GHOSTTY_BUILD_MODE_RELEASE_FAST, + GHOSTTY_BUILD_MODE_RELEASE_SMALL, +} ghostty_build_mode_e; + +typedef struct { + ghostty_build_mode_e build_mode; + const char* version; + uintptr_t version_len; +} ghostty_info_s; + +typedef struct { + const char* message; +} ghostty_diagnostic_s; + +typedef struct { + const char* ptr; + uintptr_t len; + bool sentinel; +} ghostty_string_s; + +typedef struct { + double tl_px_x; + double tl_px_y; + uint32_t offset_start; + uint32_t offset_len; + const char* text; + uintptr_t text_len; +} ghostty_text_s; + +typedef enum { + GHOSTTY_POINT_ACTIVE, + GHOSTTY_POINT_VIEWPORT, + GHOSTTY_POINT_SCREEN, + GHOSTTY_POINT_SURFACE, +} ghostty_point_tag_e; + +typedef enum { + GHOSTTY_POINT_COORD_EXACT, + GHOSTTY_POINT_COORD_TOP_LEFT, + GHOSTTY_POINT_COORD_BOTTOM_RIGHT, +} ghostty_point_coord_e; + +typedef struct { + ghostty_point_tag_e tag; + ghostty_point_coord_e coord; + uint32_t x; + uint32_t y; +} ghostty_point_s; + +typedef struct { + ghostty_point_s top_left; + ghostty_point_s bottom_right; + bool rectangle; +} ghostty_selection_s; + +typedef struct { + const char* key; + const char* value; +} ghostty_env_var_s; + +typedef struct { + void* nsview; +} ghostty_platform_macos_s; + +typedef struct { + void* uiview; +} ghostty_platform_ios_s; + +typedef union { + ghostty_platform_macos_s macos; + ghostty_platform_ios_s ios; +} ghostty_platform_u; + +typedef enum { + GHOSTTY_SURFACE_CONTEXT_WINDOW = 0, + GHOSTTY_SURFACE_CONTEXT_TAB = 1, + GHOSTTY_SURFACE_CONTEXT_SPLIT = 2, +} ghostty_surface_context_e; + +typedef struct { + ghostty_platform_e platform_tag; + ghostty_platform_u platform; + void* userdata; + double scale_factor; + float font_size; + const char* working_directory; + const char* command; + ghostty_env_var_s* env_vars; + size_t env_var_count; + const char* initial_input; + bool wait_after_command; + ghostty_surface_context_e context; + bool use_custom_io; +} ghostty_surface_config_s; + +typedef struct { + uint16_t columns; + uint16_t rows; + uint32_t width_px; + uint32_t height_px; + uint32_t cell_width_px; + uint32_t cell_height_px; +} ghostty_surface_size_s; + +// Config types + +// config.Path +typedef struct { + const char* path; + bool optional; +} ghostty_config_path_s; + +// config.Color +typedef struct { + uint8_t r; + uint8_t g; + uint8_t b; +} ghostty_config_color_s; + +// config.ColorList +typedef struct { + const ghostty_config_color_s* colors; + size_t len; +} ghostty_config_color_list_s; + +// config.RepeatableCommand +typedef struct { + const ghostty_command_s* commands; + size_t len; +} ghostty_config_command_list_s; + +// config.Palette +typedef struct { + ghostty_config_color_s colors[256]; +} ghostty_config_palette_s; + +// config.QuickTerminalSize +typedef enum { + GHOSTTY_QUICK_TERMINAL_SIZE_NONE, + GHOSTTY_QUICK_TERMINAL_SIZE_PERCENTAGE, + GHOSTTY_QUICK_TERMINAL_SIZE_PIXELS, +} ghostty_quick_terminal_size_tag_e; + +typedef union { + float percentage; + uint32_t pixels; +} ghostty_quick_terminal_size_value_u; + +typedef struct { + ghostty_quick_terminal_size_tag_e tag; + ghostty_quick_terminal_size_value_u value; +} ghostty_quick_terminal_size_s; + +typedef struct { + ghostty_quick_terminal_size_s primary; + ghostty_quick_terminal_size_s secondary; +} ghostty_config_quick_terminal_size_s; + +// config.Fullscreen +typedef enum { + GHOSTTY_CONFIG_FULLSCREEN_FALSE, + GHOSTTY_CONFIG_FULLSCREEN_TRUE, + GHOSTTY_CONFIG_FULLSCREEN_NON_NATIVE, + GHOSTTY_CONFIG_FULLSCREEN_NON_NATIVE_VISIBLE_MENU, + GHOSTTY_CONFIG_FULLSCREEN_NON_NATIVE_PADDED_NOTCH, +} ghostty_config_fullscreen_e; + +// apprt.Target.Key +typedef enum { + GHOSTTY_TARGET_APP, + GHOSTTY_TARGET_SURFACE, +} ghostty_target_tag_e; + +typedef union { + ghostty_surface_t surface; +} ghostty_target_u; + +typedef struct { + ghostty_target_tag_e tag; + ghostty_target_u target; +} ghostty_target_s; + +// apprt.action.SplitDirection +typedef enum { + GHOSTTY_SPLIT_DIRECTION_RIGHT, + GHOSTTY_SPLIT_DIRECTION_DOWN, + GHOSTTY_SPLIT_DIRECTION_LEFT, + GHOSTTY_SPLIT_DIRECTION_UP, +} ghostty_action_split_direction_e; + +// apprt.action.GotoSplit +typedef enum { + GHOSTTY_GOTO_SPLIT_PREVIOUS, + GHOSTTY_GOTO_SPLIT_NEXT, + GHOSTTY_GOTO_SPLIT_UP, + GHOSTTY_GOTO_SPLIT_LEFT, + GHOSTTY_GOTO_SPLIT_DOWN, + GHOSTTY_GOTO_SPLIT_RIGHT, +} ghostty_action_goto_split_e; + +// apprt.action.GotoWindow +typedef enum { + GHOSTTY_GOTO_WINDOW_PREVIOUS, + GHOSTTY_GOTO_WINDOW_NEXT, +} ghostty_action_goto_window_e; + +// apprt.action.ResizeSplit.Direction +typedef enum { + GHOSTTY_RESIZE_SPLIT_UP, + GHOSTTY_RESIZE_SPLIT_DOWN, + GHOSTTY_RESIZE_SPLIT_LEFT, + GHOSTTY_RESIZE_SPLIT_RIGHT, +} ghostty_action_resize_split_direction_e; + +// apprt.action.ResizeSplit +typedef struct { + uint16_t amount; + ghostty_action_resize_split_direction_e direction; +} ghostty_action_resize_split_s; + +// apprt.action.MoveTab +typedef struct { + ssize_t amount; +} ghostty_action_move_tab_s; + +// apprt.action.GotoTab +typedef enum { + GHOSTTY_GOTO_TAB_PREVIOUS = -1, + GHOSTTY_GOTO_TAB_NEXT = -2, + GHOSTTY_GOTO_TAB_LAST = -3, +} ghostty_action_goto_tab_e; + +// apprt.action.Fullscreen +typedef enum { + GHOSTTY_FULLSCREEN_NATIVE, + GHOSTTY_FULLSCREEN_MACOS_NON_NATIVE, + GHOSTTY_FULLSCREEN_MACOS_NON_NATIVE_VISIBLE_MENU, + GHOSTTY_FULLSCREEN_MACOS_NON_NATIVE_PADDED_NOTCH, +} ghostty_action_fullscreen_e; + +// apprt.action.FloatWindow +typedef enum { + GHOSTTY_FLOAT_WINDOW_ON, + GHOSTTY_FLOAT_WINDOW_OFF, + GHOSTTY_FLOAT_WINDOW_TOGGLE, +} ghostty_action_float_window_e; + +// apprt.action.SecureInput +typedef enum { + GHOSTTY_SECURE_INPUT_ON, + GHOSTTY_SECURE_INPUT_OFF, + GHOSTTY_SECURE_INPUT_TOGGLE, +} ghostty_action_secure_input_e; + +// apprt.action.Inspector +typedef enum { + GHOSTTY_INSPECTOR_TOGGLE, + GHOSTTY_INSPECTOR_SHOW, + GHOSTTY_INSPECTOR_HIDE, +} ghostty_action_inspector_e; + +// apprt.action.ExportTerminalIO.C +typedef struct { + const char* contents; + size_t len; +} ghostty_action_export_terminal_io_s; + +// apprt.action.QuitTimer +typedef enum { + GHOSTTY_QUIT_TIMER_START, + GHOSTTY_QUIT_TIMER_STOP, +} ghostty_action_quit_timer_e; + +// apprt.action.Readonly +typedef enum { + GHOSTTY_READONLY_OFF, + GHOSTTY_READONLY_ON, +} ghostty_action_readonly_e; + +// apprt.action.DesktopNotification.C +typedef struct { + const char* title; + const char* body; +} ghostty_action_desktop_notification_s; + +// apprt.action.SetTitle.C +typedef struct { + const char* title; +} ghostty_action_set_title_s; + +// apprt.action.PromptTitle +typedef enum { + GHOSTTY_PROMPT_TITLE_SURFACE, + GHOSTTY_PROMPT_TITLE_TAB, + GHOSTTY_PROMPT_TITLE_WINDOW, +} ghostty_action_prompt_title_e; + +// apprt.action.Pwd.C +typedef struct { + const char* pwd; +} ghostty_action_pwd_s; + +// apprt.action.OpenConfig +typedef enum { + // Open the config in the OS default editor. + GHOSTTY_ACTION_OPEN_CONFIG_OS_OPEN, + // Open the config in a new window using $EDITOR or $VISUAL + GHOSTTY_ACTION_OPEN_CONFIG_NEW_WINDOW, +} ghostty_action_open_config_e; + +// terminal.MouseShape +typedef enum { + GHOSTTY_MOUSE_SHAPE_DEFAULT, + GHOSTTY_MOUSE_SHAPE_CONTEXT_MENU, + GHOSTTY_MOUSE_SHAPE_HELP, + GHOSTTY_MOUSE_SHAPE_POINTER, + GHOSTTY_MOUSE_SHAPE_PROGRESS, + GHOSTTY_MOUSE_SHAPE_WAIT, + GHOSTTY_MOUSE_SHAPE_CELL, + GHOSTTY_MOUSE_SHAPE_CROSSHAIR, + GHOSTTY_MOUSE_SHAPE_TEXT, + GHOSTTY_MOUSE_SHAPE_VERTICAL_TEXT, + GHOSTTY_MOUSE_SHAPE_ALIAS, + GHOSTTY_MOUSE_SHAPE_COPY, + GHOSTTY_MOUSE_SHAPE_MOVE, + GHOSTTY_MOUSE_SHAPE_NO_DROP, + GHOSTTY_MOUSE_SHAPE_NOT_ALLOWED, + GHOSTTY_MOUSE_SHAPE_GRAB, + GHOSTTY_MOUSE_SHAPE_GRABBING, + GHOSTTY_MOUSE_SHAPE_ALL_SCROLL, + GHOSTTY_MOUSE_SHAPE_COL_RESIZE, + GHOSTTY_MOUSE_SHAPE_ROW_RESIZE, + GHOSTTY_MOUSE_SHAPE_N_RESIZE, + GHOSTTY_MOUSE_SHAPE_E_RESIZE, + GHOSTTY_MOUSE_SHAPE_S_RESIZE, + GHOSTTY_MOUSE_SHAPE_W_RESIZE, + GHOSTTY_MOUSE_SHAPE_NE_RESIZE, + GHOSTTY_MOUSE_SHAPE_NW_RESIZE, + GHOSTTY_MOUSE_SHAPE_SE_RESIZE, + GHOSTTY_MOUSE_SHAPE_SW_RESIZE, + GHOSTTY_MOUSE_SHAPE_EW_RESIZE, + GHOSTTY_MOUSE_SHAPE_NS_RESIZE, + GHOSTTY_MOUSE_SHAPE_NESW_RESIZE, + GHOSTTY_MOUSE_SHAPE_NWSE_RESIZE, + GHOSTTY_MOUSE_SHAPE_ZOOM_IN, + GHOSTTY_MOUSE_SHAPE_ZOOM_OUT, +} ghostty_action_mouse_shape_e; + +// apprt.action.MouseVisibility +typedef enum { + GHOSTTY_MOUSE_VISIBLE, + GHOSTTY_MOUSE_HIDDEN, +} ghostty_action_mouse_visibility_e; + +// apprt.action.MouseOverLink +typedef struct { + const char* url; + size_t len; +} ghostty_action_mouse_over_link_s; + +// apprt.action.SizeLimit +typedef struct { + uint32_t min_width; + uint32_t min_height; + uint32_t max_width; + uint32_t max_height; +} ghostty_action_size_limit_s; + +// apprt.action.InitialSize +typedef struct { + uint32_t width; + uint32_t height; +} ghostty_action_initial_size_s; + +// apprt.action.CellSize +typedef struct { + uint32_t width; + uint32_t height; +} ghostty_action_cell_size_s; + +// renderer.Health +typedef enum { + GHOSTTY_RENDERER_HEALTH_HEALTHY, + GHOSTTY_RENDERER_HEALTH_UNHEALTHY, +} ghostty_action_renderer_health_e; + +// apprt.action.KeySequence +typedef struct { + bool active; + ghostty_input_trigger_s trigger; +} ghostty_action_key_sequence_s; + +// apprt.action.KeyTable.Tag +typedef enum { + GHOSTTY_KEY_TABLE_ACTIVATE, + GHOSTTY_KEY_TABLE_DEACTIVATE, + GHOSTTY_KEY_TABLE_DEACTIVATE_ALL, +} ghostty_action_key_table_tag_e; + +// apprt.action.KeyTable.CValue +typedef union { + struct { + const char *name; + size_t len; + } activate; +} ghostty_action_key_table_u; + +// apprt.action.KeyTable.C +typedef struct { + ghostty_action_key_table_tag_e tag; + ghostty_action_key_table_u value; +} ghostty_action_key_table_s; + +// apprt.action.ColorKind +typedef enum { + GHOSTTY_ACTION_COLOR_KIND_FOREGROUND = -1, + GHOSTTY_ACTION_COLOR_KIND_BACKGROUND = -2, + GHOSTTY_ACTION_COLOR_KIND_CURSOR = -3, +} ghostty_action_color_kind_e; + +// apprt.action.ColorChange +typedef struct { + ghostty_action_color_kind_e kind; + uint8_t r; + uint8_t g; + uint8_t b; +} ghostty_action_color_change_s; + +// apprt.action.ConfigChange +typedef struct { + ghostty_config_t config; +} ghostty_action_config_change_s; + +// apprt.action.ReloadConfig +typedef struct { + bool soft; +} ghostty_action_reload_config_s; + +// apprt.action.OpenUrlKind +typedef enum { + GHOSTTY_ACTION_OPEN_URL_KIND_UNKNOWN, + GHOSTTY_ACTION_OPEN_URL_KIND_TEXT, + GHOSTTY_ACTION_OPEN_URL_KIND_HTML, + GHOSTTY_ACTION_OPEN_URL_KIND_OSC8, +} ghostty_action_open_url_kind_e; + +// apprt.action.OpenUrl.C +typedef struct { + ghostty_action_open_url_kind_e kind; + const char* url; + uintptr_t len; +} ghostty_action_open_url_s; + +// apprt.action.CloseTabMode +typedef enum { + GHOSTTY_ACTION_CLOSE_TAB_MODE_THIS, + GHOSTTY_ACTION_CLOSE_TAB_MODE_OTHER, + GHOSTTY_ACTION_CLOSE_TAB_MODE_RIGHT, +} ghostty_action_close_tab_mode_e; + +// apprt.surface.Message.ChildExited +typedef struct { + uint32_t exit_code; + uint64_t timetime_ms; +} ghostty_surface_message_childexited_s; + +// terminal.osc.Command.ProgressReport.State +typedef enum { + GHOSTTY_PROGRESS_STATE_REMOVE, + GHOSTTY_PROGRESS_STATE_SET, + GHOSTTY_PROGRESS_STATE_ERROR, + GHOSTTY_PROGRESS_STATE_INDETERMINATE, + GHOSTTY_PROGRESS_STATE_PAUSE, +} ghostty_action_progress_report_state_e; + +// terminal.osc.Command.ProgressReport.C +typedef struct { + ghostty_action_progress_report_state_e state; + // -1 if no progress was reported, otherwise 0-100 indicating percent + // completeness. + int8_t progress; +} ghostty_action_progress_report_s; + +// apprt.action.CommandFinished.C +typedef struct { + // -1 if no exit code was reported, otherwise 0-255 + int16_t exit_code; + // number of nanoseconds that command was running for + uint64_t duration; +} ghostty_action_command_finished_s; + +// apprt.action.StartSearch.C +typedef struct { + const char* needle; +} ghostty_action_start_search_s; + +// apprt.action.SearchTotal +typedef struct { + ssize_t total; +} ghostty_action_search_total_s; + +// apprt.action.SearchSelected +typedef struct { + ssize_t selected; +} ghostty_action_search_selected_s; + +// terminal.Scrollbar +typedef struct { + uint64_t total; + uint64_t offset; + uint64_t len; +} ghostty_action_scrollbar_s; + +// apprt.Action.Key +typedef enum { + GHOSTTY_ACTION_QUIT, + GHOSTTY_ACTION_NEW_WINDOW, + GHOSTTY_ACTION_NEW_TAB, + GHOSTTY_ACTION_CLOSE_TAB, + GHOSTTY_ACTION_NEW_SPLIT, + GHOSTTY_ACTION_CLOSE_ALL_WINDOWS, + GHOSTTY_ACTION_TOGGLE_MAXIMIZE, + GHOSTTY_ACTION_TOGGLE_FULLSCREEN, + GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW, + GHOSTTY_ACTION_TOGGLE_WINDOW_DECORATIONS, + GHOSTTY_ACTION_TOGGLE_QUICK_TERMINAL, + GHOSTTY_ACTION_TOGGLE_COMMAND_PALETTE, + GHOSTTY_ACTION_TOGGLE_VISIBILITY, + GHOSTTY_ACTION_TOGGLE_BACKGROUND_OPACITY, + GHOSTTY_ACTION_MOVE_TAB, + GHOSTTY_ACTION_GOTO_TAB, + GHOSTTY_ACTION_GOTO_SPLIT, + GHOSTTY_ACTION_GOTO_WINDOW, + GHOSTTY_ACTION_RESIZE_SPLIT, + GHOSTTY_ACTION_EQUALIZE_SPLITS, + GHOSTTY_ACTION_TOGGLE_SPLIT_ZOOM, + GHOSTTY_ACTION_PRESENT_TERMINAL, + GHOSTTY_ACTION_SIZE_LIMIT, + GHOSTTY_ACTION_RESET_WINDOW_SIZE, + GHOSTTY_ACTION_INITIAL_SIZE, + GHOSTTY_ACTION_CELL_SIZE, + GHOSTTY_ACTION_SCROLLBAR, + GHOSTTY_ACTION_RENDER, + GHOSTTY_ACTION_INSPECTOR, + GHOSTTY_ACTION_SHOW_GTK_INSPECTOR, + GHOSTTY_ACTION_RENDER_INSPECTOR, + GHOSTTY_ACTION_EXPORT_TERMINAL_IO, + GHOSTTY_ACTION_DESKTOP_NOTIFICATION, + GHOSTTY_ACTION_SET_TITLE, + GHOSTTY_ACTION_SET_TAB_TITLE, + GHOSTTY_ACTION_SET_WINDOW_TITLE, + GHOSTTY_ACTION_PROMPT_TITLE, + GHOSTTY_ACTION_PWD, + GHOSTTY_ACTION_MOUSE_SHAPE, + GHOSTTY_ACTION_MOUSE_VISIBILITY, + GHOSTTY_ACTION_MOUSE_OVER_LINK, + GHOSTTY_ACTION_RENDERER_HEALTH, + GHOSTTY_ACTION_OPEN_CONFIG, + GHOSTTY_ACTION_QUIT_TIMER, + GHOSTTY_ACTION_FLOAT_WINDOW, + GHOSTTY_ACTION_SECURE_INPUT, + GHOSTTY_ACTION_KEY_SEQUENCE, + GHOSTTY_ACTION_KEY_TABLE, + GHOSTTY_ACTION_COLOR_CHANGE, + GHOSTTY_ACTION_RELOAD_CONFIG, + GHOSTTY_ACTION_CONFIG_CHANGE, + GHOSTTY_ACTION_CLOSE_WINDOW, + GHOSTTY_ACTION_RING_BELL, + GHOSTTY_ACTION_SELECTION_CHANGED, + GHOSTTY_ACTION_UNDO, + GHOSTTY_ACTION_REDO, + GHOSTTY_ACTION_CHECK_FOR_UPDATES, + GHOSTTY_ACTION_OPEN_URL, + GHOSTTY_ACTION_SHOW_CHILD_EXITED, + GHOSTTY_ACTION_PROGRESS_REPORT, + GHOSTTY_ACTION_SHOW_ON_SCREEN_KEYBOARD, + GHOSTTY_ACTION_COMMAND_FINISHED, + GHOSTTY_ACTION_START_SEARCH, + GHOSTTY_ACTION_END_SEARCH, + GHOSTTY_ACTION_SEARCH_TOTAL, + GHOSTTY_ACTION_SEARCH_SELECTED, + GHOSTTY_ACTION_READONLY, + GHOSTTY_ACTION_COPY_TITLE_TO_CLIPBOARD, + GHOSTTY_ACTION_MOVE_TAB_TO_NEW_WINDOW, +} ghostty_action_tag_e; + +typedef union { + ghostty_action_split_direction_e new_split; + ghostty_action_fullscreen_e toggle_fullscreen; + ghostty_action_move_tab_s move_tab; + ghostty_action_goto_tab_e goto_tab; + ghostty_action_goto_split_e goto_split; + ghostty_action_goto_window_e goto_window; + ghostty_action_resize_split_s resize_split; + ghostty_action_size_limit_s size_limit; + ghostty_action_initial_size_s initial_size; + ghostty_action_cell_size_s cell_size; + ghostty_action_scrollbar_s scrollbar; + ghostty_action_inspector_e inspector; + ghostty_action_export_terminal_io_s export_terminal_io; + ghostty_action_desktop_notification_s desktop_notification; + ghostty_action_set_title_s set_title; + ghostty_action_set_title_s set_tab_title; + ghostty_action_prompt_title_e prompt_title; + ghostty_action_pwd_s pwd; + ghostty_action_mouse_shape_e mouse_shape; + ghostty_action_mouse_visibility_e mouse_visibility; + ghostty_action_mouse_over_link_s mouse_over_link; + ghostty_action_renderer_health_e renderer_health; + ghostty_action_quit_timer_e quit_timer; + ghostty_action_float_window_e float_window; + ghostty_action_secure_input_e secure_input; + ghostty_action_key_sequence_s key_sequence; + ghostty_action_key_table_s key_table; + ghostty_action_color_change_s color_change; + ghostty_action_reload_config_s reload_config; + ghostty_action_config_change_s config_change; + ghostty_action_open_url_s open_url; + ghostty_action_close_tab_mode_e close_tab_mode; + ghostty_surface_message_childexited_s child_exited; + ghostty_action_progress_report_s progress_report; + ghostty_action_command_finished_s command_finished; + ghostty_action_start_search_s start_search; + ghostty_action_search_total_s search_total; + ghostty_action_search_selected_s search_selected; + ghostty_action_readonly_e readonly; + ghostty_action_open_config_e open_config; +} ghostty_action_u; + +typedef struct { + ghostty_action_tag_e tag; + ghostty_action_u action; +} ghostty_action_s; + +typedef void (*ghostty_runtime_wakeup_cb)(void*); +typedef ghostty_clipboard_read_result_e (*ghostty_runtime_read_clipboard_cb)( + void*, + ghostty_clipboard_e, + void*, + const char* const*, + size_t, + bool); +typedef void (*ghostty_runtime_confirm_read_clipboard_cb)( + void*, + const ghostty_clipboard_confirm_s*, + void*, + ghostty_clipboard_request_e); +typedef void (*ghostty_runtime_write_clipboard_cb)(void*, + ghostty_clipboard_e, + const ghostty_clipboard_content_s*, + size_t, + bool); +typedef void (*ghostty_runtime_close_surface_cb)(void*, bool); +typedef bool (*ghostty_runtime_action_cb)(ghostty_app_t, + ghostty_target_s, + ghostty_action_s); + +typedef struct { + void* userdata; + bool supports_selection_clipboard; + ghostty_runtime_wakeup_cb wakeup_cb; + ghostty_runtime_action_cb action_cb; + ghostty_runtime_read_clipboard_cb read_clipboard_cb; + ghostty_runtime_confirm_read_clipboard_cb confirm_read_clipboard_cb; + ghostty_runtime_write_clipboard_cb write_clipboard_cb; + ghostty_runtime_close_surface_cb close_surface_cb; +} ghostty_runtime_config_s; + +// apprt.ipc.Target.Key +typedef enum { + GHOSTTY_IPC_TARGET_CLASS, + GHOSTTY_IPC_TARGET_DETECT, +} ghostty_ipc_target_tag_e; + +typedef union { + char *klass; +} ghostty_ipc_target_u; + +typedef struct { + ghostty_ipc_target_tag_e tag; + ghostty_ipc_target_u target; +} chostty_ipc_target_s; + +// apprt.ipc.Action.NewWindow +typedef struct { + // This should be a null terminated list of strings. + const char **arguments; +} ghostty_ipc_action_new_window_s; + +typedef union { + ghostty_ipc_action_new_window_s new_window; +} ghostty_ipc_action_u; + +// apprt.ipc.Action.Key +typedef enum { + GHOSTTY_IPC_ACTION_NEW_WINDOW, + GHOSTTY_IPC_ACTION_NEW_TAB, + GHOSTTY_IPC_ACTION_TOGGLE_QUICK_TERMINAL, +} ghostty_ipc_action_tag_e; + +//------------------------------------------------------------------- +// Published API + +GHOSTTY_API int ghostty_init(uintptr_t, char**); +GHOSTTY_API void ghostty_cli_try_action(void); +GHOSTTY_API ghostty_info_s ghostty_info(void); +GHOSTTY_API const char* ghostty_translate(const char*); +GHOSTTY_API void ghostty_string_free(ghostty_string_s); + +GHOSTTY_API ghostty_config_t ghostty_config_new(); +GHOSTTY_API void ghostty_config_free(ghostty_config_t); +GHOSTTY_API ghostty_config_t ghostty_config_clone(ghostty_config_t); +GHOSTTY_API void ghostty_config_load_cli_args(ghostty_config_t); +GHOSTTY_API void ghostty_config_load_file(ghostty_config_t, const char*); +GHOSTTY_API void ghostty_config_load_default_files(ghostty_config_t); +GHOSTTY_API void ghostty_config_load_recursive_files(ghostty_config_t); +GHOSTTY_API void ghostty_config_finalize(ghostty_config_t); +GHOSTTY_API bool ghostty_config_get(ghostty_config_t, void*, const char*, uintptr_t); +GHOSTTY_API ghostty_input_trigger_s ghostty_config_trigger(ghostty_config_t, + const char*, + uintptr_t); +GHOSTTY_API bool ghostty_config_key_is_binding(ghostty_config_t, ghostty_input_key_s); +GHOSTTY_API uint32_t ghostty_config_diagnostics_count(ghostty_config_t); +GHOSTTY_API ghostty_diagnostic_s ghostty_config_get_diagnostic(ghostty_config_t, uint32_t); +GHOSTTY_API ghostty_string_s ghostty_config_open_path(void); + +GHOSTTY_API ghostty_app_t ghostty_app_new(const ghostty_runtime_config_s*, + ghostty_config_t); +GHOSTTY_API void ghostty_app_free(ghostty_app_t); +GHOSTTY_API void ghostty_app_tick(ghostty_app_t); +GHOSTTY_API void* ghostty_app_userdata(ghostty_app_t); +GHOSTTY_API void ghostty_app_set_focus(ghostty_app_t, bool); +GHOSTTY_API bool ghostty_app_key(ghostty_app_t, ghostty_input_key_s); +GHOSTTY_API void ghostty_app_keyboard_changed(ghostty_app_t); +GHOSTTY_API void ghostty_app_open_config(ghostty_app_t); +GHOSTTY_API void ghostty_app_update_config(ghostty_app_t, ghostty_config_t); +GHOSTTY_API bool ghostty_app_needs_confirm_quit(ghostty_app_t); +GHOSTTY_API bool ghostty_app_has_global_keybinds(ghostty_app_t); +GHOSTTY_API void ghostty_app_set_color_scheme(ghostty_app_t, ghostty_color_scheme_e); + +GHOSTTY_API ghostty_surface_config_s ghostty_surface_config_new(); + +GHOSTTY_API ghostty_surface_t ghostty_surface_new(ghostty_app_t, + const ghostty_surface_config_s*); +GHOSTTY_API void ghostty_surface_free(ghostty_surface_t); +GHOSTTY_API void* ghostty_surface_userdata(ghostty_surface_t); +GHOSTTY_API ghostty_app_t ghostty_surface_app(ghostty_surface_t); +GHOSTTY_API ghostty_surface_config_s ghostty_surface_inherited_config(ghostty_surface_t, ghostty_surface_context_e); +GHOSTTY_API void ghostty_surface_update_config(ghostty_surface_t, ghostty_config_t); +GHOSTTY_API bool ghostty_surface_needs_confirm_quit(ghostty_surface_t); +GHOSTTY_API bool ghostty_surface_process_exited(ghostty_surface_t); +GHOSTTY_API void ghostty_surface_refresh(ghostty_surface_t); +GHOSTTY_API void ghostty_surface_draw(ghostty_surface_t); +GHOSTTY_API void ghostty_surface_feed_data(ghostty_surface_t, + const uint8_t*, + size_t); +GHOSTTY_API void ghostty_surface_set_write_callback(ghostty_surface_t, + ghostty_surface_write_fn, + void*); +GHOSTTY_API void ghostty_surface_set_content_scale(ghostty_surface_t, double, double); +GHOSTTY_API void ghostty_surface_set_focus(ghostty_surface_t, bool); +GHOSTTY_API void ghostty_surface_set_occlusion(ghostty_surface_t, bool); +GHOSTTY_API void ghostty_surface_set_size(ghostty_surface_t, uint32_t, uint32_t); +GHOSTTY_API ghostty_surface_size_s ghostty_surface_size(ghostty_surface_t); +GHOSTTY_API uint64_t ghostty_surface_foreground_pid(ghostty_surface_t); +GHOSTTY_API ghostty_string_s ghostty_surface_tty_name(ghostty_surface_t); +GHOSTTY_API void ghostty_surface_set_color_scheme(ghostty_surface_t, + ghostty_color_scheme_e); +GHOSTTY_API ghostty_input_mods_e ghostty_surface_key_translation_mods(ghostty_surface_t, + ghostty_input_mods_e); +GHOSTTY_API bool ghostty_surface_key(ghostty_surface_t, ghostty_input_key_s); +GHOSTTY_API bool ghostty_surface_key_is_binding(ghostty_surface_t, + ghostty_input_key_s, + ghostty_binding_flags_e*); +GHOSTTY_API void ghostty_surface_text(ghostty_surface_t, const char*, uintptr_t); +GHOSTTY_API void ghostty_surface_preedit(ghostty_surface_t, const char*, uintptr_t); +GHOSTTY_API bool ghostty_surface_mouse_captured(ghostty_surface_t); +GHOSTTY_API bool ghostty_surface_mouse_button(ghostty_surface_t, + ghostty_input_mouse_state_e, + ghostty_input_mouse_button_e, + ghostty_input_mods_e); +GHOSTTY_API void ghostty_surface_mouse_pos(ghostty_surface_t, + double, + double, + ghostty_input_mods_e); +GHOSTTY_API void ghostty_surface_mouse_scroll(ghostty_surface_t, + double, + double, + ghostty_input_scroll_mods_t); +GHOSTTY_API void ghostty_surface_mouse_pressure(ghostty_surface_t, uint32_t, double); +GHOSTTY_API void ghostty_surface_ime_point(ghostty_surface_t, double*, double*, double*, double*); +GHOSTTY_API void ghostty_surface_request_close(ghostty_surface_t); +GHOSTTY_API void ghostty_surface_split(ghostty_surface_t, ghostty_action_split_direction_e); +GHOSTTY_API void ghostty_surface_split_focus(ghostty_surface_t, + ghostty_action_goto_split_e); +GHOSTTY_API void ghostty_surface_split_resize(ghostty_surface_t, + ghostty_action_resize_split_direction_e, + uint16_t); +GHOSTTY_API void ghostty_surface_split_equalize(ghostty_surface_t); +GHOSTTY_API bool ghostty_surface_binding_action(ghostty_surface_t, const char*, uintptr_t); +GHOSTTY_API void ghostty_surface_complete_clipboard_request( + ghostty_surface_t, + const ghostty_clipboard_complete_s*, + void*); +GHOSTTY_API void ghostty_surface_deny_clipboard_request(ghostty_surface_t, + void*); +GHOSTTY_API bool ghostty_surface_has_selection(ghostty_surface_t); +GHOSTTY_API bool ghostty_surface_read_selection(ghostty_surface_t, ghostty_text_s*); +GHOSTTY_API bool ghostty_surface_read_text(ghostty_surface_t, + ghostty_selection_s, + ghostty_text_s*); +GHOSTTY_API void ghostty_surface_free_text(ghostty_surface_t, ghostty_text_s*); + +#ifdef __APPLE__ +GHOSTTY_API void ghostty_surface_set_display_id(ghostty_surface_t, uint32_t); +GHOSTTY_API void* ghostty_surface_quicklook_font(ghostty_surface_t); +GHOSTTY_API bool ghostty_surface_quicklook_word(ghostty_surface_t, ghostty_text_s*); +#endif + +GHOSTTY_API ghostty_inspector_t ghostty_surface_inspector(ghostty_surface_t); +GHOSTTY_API void ghostty_inspector_free(ghostty_surface_t); +GHOSTTY_API void ghostty_inspector_set_focus(ghostty_inspector_t, bool); +GHOSTTY_API void ghostty_inspector_set_content_scale(ghostty_inspector_t, double, double); +GHOSTTY_API void ghostty_inspector_set_size(ghostty_inspector_t, uint32_t, uint32_t); +GHOSTTY_API void ghostty_inspector_mouse_button(ghostty_inspector_t, + ghostty_input_mouse_state_e, + ghostty_input_mouse_button_e, + ghostty_input_mods_e); +GHOSTTY_API void ghostty_inspector_mouse_pos(ghostty_inspector_t, double, double); +GHOSTTY_API void ghostty_inspector_mouse_scroll(ghostty_inspector_t, + double, + double, + ghostty_input_scroll_mods_t); +GHOSTTY_API void ghostty_inspector_key(ghostty_inspector_t, + ghostty_input_action_e, + ghostty_input_key_e, + ghostty_input_mods_e); +GHOSTTY_API void ghostty_inspector_text(ghostty_inspector_t, const char*); + +#ifdef __APPLE__ +GHOSTTY_API bool ghostty_inspector_metal_init(ghostty_inspector_t, void*); +GHOSTTY_API void ghostty_inspector_metal_render(ghostty_inspector_t, void*, void*); +GHOSTTY_API bool ghostty_inspector_metal_shutdown(ghostty_inspector_t); +#endif + +// APIs I'd like to get rid of eventually but are still needed for now. +// Don't use these unless you know what you're doing. +GHOSTTY_API void ghostty_set_window_background_blur(ghostty_app_t, void*); + +// Benchmark API, if available. +GHOSTTY_API bool ghostty_benchmark_cli(const char*, const char*); + +#ifdef __cplusplus +} +#endif + +#endif /* GHOSTTY_H */ diff --git a/apps/macos/Vendor/GhosttyKit.xcframework/macos-arm64/Headers/module.modulemap b/apps/macos/Vendor/GhosttyKit.xcframework/macos-arm64/Headers/module.modulemap new file mode 100644 index 0000000000000..8961f5c04ba55 --- /dev/null +++ b/apps/macos/Vendor/GhosttyKit.xcframework/macos-arm64/Headers/module.modulemap @@ -0,0 +1,7 @@ +// This makes Ghostty available to the XCode build for the macOS app. +// We append "Kit" to it not to be cute, but because targets have to have +// unique names and we use Ghostty for other things. +module GhosttyKit { + umbrella header "ghostty.h" + export * +} diff --git a/apps/macos/Vendor/GhosttyKit.xcframework/macos-arm64/libghostty-internal.a b/apps/macos/Vendor/GhosttyKit.xcframework/macos-arm64/libghostty-internal.a new file mode 100644 index 0000000000000..73bd5e42d3a3f Binary files /dev/null and b/apps/macos/Vendor/GhosttyKit.xcframework/macos-arm64/libghostty-internal.a differ diff --git a/apps/macos/Vendor/PLAYWRIGHT_SHA256 b/apps/macos/Vendor/PLAYWRIGHT_SHA256 new file mode 100644 index 0000000000000..d41abb8c9535a --- /dev/null +++ b/apps/macos/Vendor/PLAYWRIGHT_SHA256 @@ -0,0 +1 @@ +7000b1119dc91aabdb4a19b0ebf9d3e1f02cdb59153cff8c3cdc690e503e944a Sources/T3Native/Resources/PlaywrightInjected.js diff --git a/apps/macos/Vendor/PLAYWRIGHT_VERSION b/apps/macos/Vendor/PLAYWRIGHT_VERSION new file mode 100644 index 0000000000000..4d5fde5bd1654 --- /dev/null +++ b/apps/macos/Vendor/PLAYWRIGHT_VERSION @@ -0,0 +1 @@ +1.60.0 diff --git a/apps/macos/build-chromium-helper.mjs b/apps/macos/build-chromium-helper.mjs new file mode 100644 index 0000000000000..59c982f9a7a71 --- /dev/null +++ b/apps/macos/build-chromium-helper.mjs @@ -0,0 +1,275 @@ +import * as NodeFSP from "node:fs/promises"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodeModule from "node:module"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; +import * as NodeChildProcess from "node:child_process"; +import { verifyRuntime } from "./chromium-helper/runtime-verification.mjs"; +import { smokeRuntime } from "./chromium-helper/runtime-smoke.mjs"; + +// Authenticate the pinned official arm64 archive and compare runtime bytes. +// Only the output copy is signed; the caller chooses the Apple signing identity. +export async function buildChromiumHelper({ + output, + runtime, + archive, + signIdentity = "-", + bundleOnly = false, +}) { + // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone packager has no Effect runtime. + const hostPlatform = NodeOS.platform(); + // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone packager has no Effect runtime. + const hostArchitecture = NodeOS.arch(); + if (hostPlatform !== "darwin" || hostArchitecture !== "arm64") + throw new Error("Apple Silicon macOS is required."); + if (!output) throw new Error("Pass --output with a dedicated helper packaging directory."); + const repo = NodePath.resolve(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), "../.."); + const source = NodePath.join(repo, "apps/macos/chromium-helper"); + const destination = NodePath.resolve(output); + if (destination === repo || destination === source || !destination.endsWith("chromium-helper")) { + throw new Error("The output directory must be dedicated to chromium-helper."); + } + await NodeFSP.mkdir(destination, { recursive: true }); + let appRoot, runtimeEvidence; + if (!bundleOnly) { + if (!runtime || !NodePath.resolve(runtime).endsWith(".app")) + throw new Error("Supply --runtime /absolute/path/Electron.app; no runtime is downloaded."); + const executable = NodePath.join(NodePath.resolve(runtime), "Contents/MacOS/Electron"); + const machine = NodeChildProcess.execFileSync("/usr/bin/file", ["-b", executable], { + encoding: "utf8", + }); + if (!machine.includes("Mach-O") || !machine.includes("arm64")) + throw new Error("The runtime must contain a real arm64 Electron executable."); + const desktopPackage = JSON.parse( + await NodeFSP.readFile(NodePath.join(repo, "apps/desktop/package.json"), "utf8"), + ); + runtimeEvidence = await verifyRuntime({ + runtime: NodePath.resolve(runtime), + version: desktopPackage.dependencies.electron, + archive, + }); + appRoot = NodePath.join(destination, "T3 Browser.app"); + if ( + NodePath.resolve(runtime).startsWith(destination + "/") || + destination.startsWith(NodePath.resolve(runtime) + "/") + ) + throw new Error("The runtime and output must be disjoint."); + await NodeFSP.rm(appRoot, { recursive: true, force: true }); + await NodeFSP.cp(NodePath.resolve(runtime), appRoot, { + recursive: true, + dereference: false, + verbatimSymlinks: true, + }); + for (const name of ["app.asar", "default_app.asar", "app"]) { + await NodeFSP.rm(NodePath.join(appRoot, "Contents/Resources", name), { + recursive: true, + force: true, + }); + } + const plist = NodePath.join(appRoot, "Contents/Info.plist"); + for (const [key, value] of Object.entries({ + CFBundleIdentifier: "com.t3tools.native.browser", + CFBundleName: "T3 Browser", + CFBundleDisplayName: "T3 Browser", + })) { + NodeChildProcess.execFileSync("/usr/libexec/PlistBuddy", [ + "-c", + `Set :${key} ${value}`, + plist, + ]); + } + } + const assets = bundleOnly + ? NodePath.join(destination, "app") + : NodePath.join(appRoot, "Contents/Resources/app"); + await NodeFSP.mkdir(assets, { recursive: true }); + const require = NodeModule.createRequire(NodePath.join(repo, "apps/desktop/package.json")); + const { build } = await import( + NodePath.join(NodePath.dirname(require.resolve("vite-plus")), "pack.js") + ); + const bundled = { + alwaysBundle: (id) => !id.startsWith("node:") && id !== "electron", + neverBundle: (id) => id === "electron", + onlyBundle: false, + }; + await build({ + config: false, + entry: [NodePath.join(source, "main.mjs")], + outDir: assets, + format: "esm", + dts: false, + clean: false, + platform: "node", + outExtensions: () => ({ js: ".mjs" }), + outputOptions: { codeSplitting: false }, + deps: bundled, + alias: { + "@effect/platform-node/NodeCrypto": require.resolve("@effect/platform-node/NodeCrypto"), + "effect/Effect": require.resolve("effect/Effect"), + }, + }); + for (const entry of [ + NodePath.join(source, "shell-preload.mjs"), + NodePath.join(source, "guest-input.mjs"), + NodePath.join(repo, "apps/desktop/src/preview-extension-preload.ts"), + ]) { + await build({ + config: false, + entry: [entry], + outDir: assets, + format: "cjs", + dts: false, + clean: false, + platform: "node", + outExtensions: () => ({ js: ".cjs" }), + outputOptions: { codeSplitting: false }, + deps: bundled, + }); + } + for (const name of ["shell.html", "shell.css", "shell.js"]) + await NodeFSP.cp(NodePath.join(source, name), NodePath.join(assets, name)); + for (const name of ["BrowserAutomation.js", "PlaywrightInjected.js"]) { + await NodeFSP.cp( + NodePath.join(repo, "apps/macos/Sources/T3Native/Resources", name), + NodePath.join(assets, name), + ); + } + await NodeFSP.writeFile( + NodePath.join(assets, "package.json"), + JSON.stringify( + { name: "t3-native-browser", version: "1.0.0", type: "module", main: "main.mjs" }, + null, + 2, + ), + ); + const licenses = NodePath.join(assets, "Licenses"); + await NodeFSP.mkdir(licenses, { recursive: true }); + await NodeFSP.cp(NodePath.join(repo, "LICENSE"), NodePath.join(licenses, "T3-Code-MIT.txt")); + // Retain notices for bundled workspace dependencies and the injected runtime. + for (const name of ["effect", "@effect/platform-node", "fflate", "playwright-core"]) { + const metadata = (require.resolve.paths(name) ?? []) + .map((root) => NodePath.join(root, name, "package.json")) + .find(NodeFS.existsSync); + if (!metadata) throw new Error(`Missing license metadata for ${name}`); + for (const file of await NodeFSP.readdir(NodePath.dirname(metadata))) { + if (/^(license|notice|copyright)/i.test(file)) + await NodeFSP.cp( + NodePath.join(NodePath.dirname(metadata), file), + NodePath.join(licenses, `${name.replaceAll("/", "-")}-${file}`), + ); + } + } + if (runtime) { + const resources = NodePath.join(NodePath.resolve(runtime), "Contents/Resources"); + for (const file of ["LICENSE", "LICENSES.chromium.html"]) { + if (NodeFS.existsSync(NodePath.join(resources, file))) + await NodeFSP.cp(NodePath.join(resources, file), NodePath.join(licenses, file)); + else if (NodeFS.existsSync(NodePath.join(NodePath.dirname(NodePath.resolve(runtime)), file))) + await NodeFSP.cp( + NodePath.join(NodePath.dirname(NodePath.resolve(runtime)), file), + NodePath.join(licenses, file), + ); + else + throw new Error( + `The official runtime is missing ${file}; retain the complete distribution notices.`, + ); + } + } + if (appRoot) { + let entitlements = NodePath.join(source, "entitlements.plist"); + if (signIdentity === "-") { + // Ad hoc signatures have no Team ID for hardened library validation. + // Apple identities retain library validation and sign every nested binary. + entitlements = NodePath.join(destination, "development-entitlements.plist"); + const base = await NodeFSP.readFile(NodePath.join(source, "entitlements.plist"), "utf8"); + await NodeFSP.writeFile( + entitlements, + base.replace( + "", + "com.apple.security.cs.disable-library-validation", + ), + ); + } + await signOutput(appRoot, signIdentity, entitlements); + } + const runtimeSmoke = appRoot ? await smokeRuntime(appRoot) : null; + await NodeFSP.writeFile( + NodePath.join(destination, "manifest.json"), + JSON.stringify( + { + protocol: 1, + arch: "arm64", + executable: "T3 Browser.app/Contents/MacOS/Electron", + app: bundleOnly ? "app" : "T3 Browser.app", + extensionId: "aeblfdkhhhdcdjpifhhbdiojplfjncoa", + requiresSigning: false, + signingIdentity: bundleOnly ? null : signIdentity, + runtime: runtimeEvidence, + runtimeSmoke, + }, + null, + 2, + ), + ); + return { assets, app: appRoot ?? null }; +} +async function signOutput(root, identity, entitlements) { + async function visit(directory) { + for (const entry of await NodeFSP.readdir(directory, { withFileTypes: true })) { + const path = NodePath.join(directory, entry.name); + if (entry.isDirectory()) await visit(path); + else if (entry.isFile()) { + const file = NodeChildProcess.execFileSync("/usr/bin/file", ["-b", path], { + encoding: "utf8", + }); + if (file.includes("Mach-O")) + NodeChildProcess.execFileSync( + "/usr/bin/codesign", + ["--force", "--sign", identity, path], + { + stdio: "pipe", + }, + ); + } + } + if (directory.endsWith(".app") || directory.endsWith(".framework")) { + NodeChildProcess.execFileSync( + "/usr/bin/codesign", + [ + "--force", + "--sign", + identity, + "--options", + "runtime", + "--entitlements", + entitlements, + directory, + ], + { stdio: "pipe" }, + ); + } + } + await visit(root); + NodeChildProcess.execFileSync("/usr/bin/codesign", ["--verify", "--deep", "--strict", root], { + stdio: "pipe", + }); +} +if ( + process.argv[1] && + NodePath.resolve(process.argv[1]) === NodeURL.fileURLToPath(import.meta.url) +) { + const args = process.argv.slice(2), + value = (name) => { + const index = args.indexOf(name); + return index < 0 ? undefined : args[index + 1]; + }; + const result = await buildChromiumHelper({ + output: value("--output"), + runtime: value("--runtime"), + archive: value("--official-archive"), + signIdentity: value("--sign-identity") ?? value("--sign") ?? "-", + bundleOnly: args.includes("--bundle-only"), + }); + console.log(`Packaged Chromium helper assets: ${result.assets}`); +} diff --git a/apps/macos/build.sh b/apps/macos/build.sh new file mode 100755 index 0000000000000..7d6bdf0732ab4 --- /dev/null +++ b/apps/macos/build.sh @@ -0,0 +1,46 @@ +#!/bin/bash +set -euo pipefail +[[ "$(uname -m)" == arm64 ]] || { echo 'T3 Native requires Apple Silicon.' >&2; exit 1; } +macos_root="$(cd "$(dirname "$0")" && pwd)" +(cd "$macos_root/Vendor" && shasum -a 256 --check GHOSTTY_SHA256) +(cd "$macos_root" && shasum -a 256 --check Vendor/PLAYWRIGHT_SHA256) +swift build --package-path "$macos_root" -c release +binary_dir="$(swift build --package-path "$macos_root" -c release --show-bin-path)" +bundle="${1:-$macos_root/.build/T3 Native.app}" +sign_identity="${T3_NATIVE_SIGN_IDENTITY:--}" +mkdir -p "$bundle/Contents/MacOS" "$bundle/Contents/Resources/Licenses" +cp -f "$macos_root"/Licenses/* "$bundle/Contents/Resources/Licenses/" +cp -R "$binary_dir/T3Native_T3Native.bundle" "$bundle/Contents/Resources/" +cp "$binary_dir/T3Native" "$bundle/Contents/MacOS/T3Native" +cat > "$bundle/Contents/Info.plist" <<'PLIST' + + + +CFBundleIdentifierapp.t3.native-prototype +CFBundleNameT3 Native +CFBundleExecutableT3Native +CFBundlePackageTypeAPPL +CFBundleShortVersionString0.1.0 +CFBundleVersion1 +LSMinimumSystemVersion14.0 +NSHighResolutionCapable +NSAppTransportSecurityNSAllowsLocalNetworkingNSAllowsArbitraryLoadsInWebContent +NSPrincipalClassNSApplication + +PLIST +revision="$(git -C "$macos_root" -c core.fsmonitor=false rev-parse HEAD 2>/dev/null || printf unknown)" +modified=false +if [[ -n "$(git -C "$macos_root" -c core.fsmonitor=false status --porcelain -- . 2>/dev/null)" ]]; then + modified=true +fi +/usr/libexec/PlistBuddy -c "Add :T3SourceRevision string $revision" "$bundle/Contents/Info.plist" +/usr/libexec/PlistBuddy -c "Add :T3SourceModified bool $modified" "$bundle/Contents/Info.plist" +/usr/libexec/PlistBuddy -c "Add :T3BuildDate string $(date -u +%Y-%m-%dT%H:%M:%SZ)" "$bundle/Contents/Info.plist" +node "$macos_root/package-backend.mjs" "$bundle" +node "$macos_root/../desktop/scripts/ensure-electron-runtime.mjs" +node "$macos_root/build-chromium-helper.mjs" \ + --output "$bundle/Contents/Resources/chromium-helper" \ + --runtime "$macos_root/../desktop/node_modules/electron/dist/Electron.app" \ + --sign "$sign_identity" +codesign --force --sign "$sign_identity" "$bundle" +printf '%s\n' "$bundle" diff --git a/apps/macos/chromium-helper/cursor.mjs b/apps/macos/chromium-helper/cursor.mjs new file mode 100644 index 0000000000000..84c68d58b1122 --- /dev/null +++ b/apps/macos/chromium-helper/cursor.mjs @@ -0,0 +1,42 @@ +// Finite compositor transitions; the isolated world owns this closed shadow root. +export const cursorSource = `(() => { + if (globalThis.__t3AgentCursor) return; + const host = document.createElement('div'); + host.style.cssText = 'position:fixed;inset:0;pointer-events:none;z-index:2147483647;overflow:hidden'; + const shadow = host.attachShadow({mode:'closed'}); + shadow.innerHTML = '
Agent
'; + const pointer = shadow.querySelector('.pointer'), ring = shadow.querySelector('.ring'); + let position = null, movement = null; + const attach = () => { if (!host.isConnected) document.documentElement.append(host); }; + globalThis.__t3AgentCursor = { + move: async (x,y,label='Agent') => { + attach(); x=Math.max(0,Math.min(innerWidth-1,x)); y=Math.max(0,Math.min(innerHeight-1,y)); + const reduced = matchMedia('(prefers-reduced-motion:reduce)').matches; + pointer.style.display='block'; shadow.querySelector('span').textContent=label; + const from=position?getComputedStyle(pointer).transform:'translate(16px,16px)'; + movement?.cancel(); + const destination='translate('+x+'px,'+y+'px)'; + pointer.style.transform=destination;position={x,y}; + if (!reduced) { + const animation=pointer.animate([{transform:from},{transform:destination}], + {duration:240,easing:'cubic-bezier(.2,.75,.3,1)'}); + movement=animation; + let timer; + await Promise.race([animation.finished.catch(()=>{}), + new Promise(resolve=>{timer=setTimeout(resolve,280);})]); + clearTimeout(timer); + // A hidden guest may suspend its compositor. Its base transform already + // names the destination, so cancellation settles capture deterministically. + animation.cancel(); + if(movement===animation)movement=null; + } + return position; + }, + ensure:()=>{attach();}, + settle:()=>{attach();movement?.cancel();movement=null;}, + click:()=>{ring.getAnimations().forEach(a=>a.cancel());ring.animate([{opacity:1,transform:'scale(.6)'},{opacity:0,transform:'scale(1.5)'}],{duration:380});}, + hide:()=>{movement?.cancel();movement=null;pointer.style.display='none';}, + position:()=>position, + state:()=>({position,visible:pointer.style.display==='block',inFlight:movement!==null,animations:pointer.getAnimations().filter(animation=>animation.playState==='running'||animation.pending).length,transform:getComputedStyle(pointer).transform}) + }; +})();`; diff --git a/apps/macos/chromium-helper/entitlements.plist b/apps/macos/chromium-helper/entitlements.plist new file mode 100644 index 0000000000000..bda591d141965 --- /dev/null +++ b/apps/macos/chromium-helper/entitlements.plist @@ -0,0 +1,5 @@ + + + +com.apple.security.cs.allow-jit + diff --git a/apps/macos/chromium-helper/guest-input.mjs b/apps/macos/chromium-helper/guest-input.mjs new file mode 100644 index 0000000000000..7e49398434b5a --- /dev/null +++ b/apps/macos/chromium-helper/guest-input.mjs @@ -0,0 +1,34 @@ +import { ipcRenderer } from "electron"; +// Isolated, sandboxed preload: page JavaScript never receives this IPC API. +window.addEventListener( + "pointerdown", + (event) => { + if (event.isTrusted) + ipcRenderer.send("t3-native-browser-human-input", { + kind: "pointer", + x: event.clientX, + y: event.clientY, + button: event.button, + }); + }, + true, +); +window.addEventListener( + "keydown", + (event) => { + if (event.isTrusted) + ipcRenderer.send("t3-native-browser-human-input", { + kind: "key", + key: event.key, + code: event.code, + }); + }, + true, +); +window.addEventListener( + "wheel", + (event) => { + if (event.isTrusted) ipcRenderer.send("t3-native-browser-human-input", { kind: "wheel" }); + }, + { capture: true, passive: true }, +); diff --git a/apps/macos/chromium-helper/main.mjs b/apps/macos/chromium-helper/main.mjs new file mode 100644 index 0000000000000..426052597d533 --- /dev/null +++ b/apps/macos/chromium-helper/main.mjs @@ -0,0 +1,992 @@ +import { app, BrowserWindow, ipcMain, dialog } from "electron"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeCrypto from "node:crypto"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; +import * as NodeStringDecoder from "node:string_decoder"; +import * as Effect from "effect/Effect"; +import * as NodeEffectCrypto from "@effect/platform-node/NodeCrypto"; +import { make as makeBrowserSessions } from "../../desktop/src/preview/BrowserSession.ts"; +import { ExtensionHost } from "../../desktop/src/preview/extensions/ExtensionHost.ts"; +import { ExtensionStore } from "../../desktop/src/preview/extensions/ExtensionStore.ts"; +import { BROWSER_IMPORT_SOURCES } from "../../desktop/src/preview/BrowserImport/Sources.ts"; +import { makePreviewAutomationKeySequence } from "../../desktop/src/preview/PreviewKeyboard.ts"; +import { + CommandDecoder, + ONE_PASSWORD_ID, + WIRE_PREFIX, + pageURL, + identity, + stateDirectory, + selectedProfile, +} from "./protocol.mjs"; +import { cursorSource } from "./cursor.mjs"; + +const token = process.env.T3_CHROMIUM_TOKEN; +// oxlint-disable-next-line t3code/no-global-process-runtime -- Electron startup guard runs outside the Effect services. +const hostPlatform = NodeOS.platform(); +// oxlint-disable-next-line t3code/no-global-process-runtime -- Electron startup guard runs outside the Effect services. +const hostArchitecture = NodeOS.arch(); +if ( + hostPlatform !== "darwin" || + hostArchitecture !== "arm64" || + !/^[a-f0-9]{64}$/.test(token ?? "") +) { + throw new Error("Launch the Apple Silicon helper from T3 Native."); +} +delete process.env.T3_CHROMIUM_TOKEN; +const decoder = new CommandDecoder(token); +const send = (value) => + process.stdout.write(WIRE_PREFIX + JSON.stringify({ token, ...value }) + "\n"); +console.log = console.info = (...values) => console.error(...values); +const root = await stateDirectory(process.env.T3_CHROMIUM_STATE); +const downloadRoot = NodePath.join(root, "downloads"); +await NodeFSP.mkdir(downloadRoot, { recursive: true, mode: 0o700 }); +app.setName("T3 Browser"); +app.setPath("userData", root); +app.setPath("sessionData", root); +const assets = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); +const injectedSource = await NodeFSP.readFile( + NodePath.join(assets, "PlaywrightInjected.js"), + "utf8", +); +const operationsSource = await NodeFSP.readFile( + NodePath.join(assets, "BrowserAutomation.js"), + "utf8", +); +const pages = new Map(); +const creatingPages = new Map(); +const downloads = new Map(); +const downloadSessions = new Set(); +let extensions, + sessions, + closing = false; +let commandCount = 0; +const key = (input) => + JSON.stringify([identity(input.environmentID), identity(input.threadId), identity(input.tabId)]); +const fail = (tag, message) => Object.assign(new Error(message), { _tag: tag }); +function check(page, revision) { + if ( + page.window.isDestroyed() || + page.guest?.isDestroyed() || + page.revision !== revision || + pages.get(page.key) !== page + ) { + throw fail("PreviewAutomationControlInterruptedError", "Browser control was interrupted."); + } +} +async function isolated(page, code, guard) { + const result = page.guest.executeJavaScriptInIsolatedWorld(997, [{ code }]); + return guard ? guard.wait(result) : result; +} +async function state(page) { + if (!page.guest || page.guest.isDestroyed()) return {}; + return { + available: true, + visible: page.window.isVisible(), + windowId: page.window.id, + tabId: page.tabId, + threadId: page.threadId, + environmentID: page.environmentID, + url: page.guest.getURL(), + title: page.guest.getTitle(), + loading: page.guest.isLoading(), + canGoBack: page.guest.navigationHistory.canGoBack(), + canGoForward: page.guest.navigationHistory.canGoForward(), + viewportSetting: page.viewport, + viewport: await isolated(page, "({width:innerWidth,height:innerHeight})"), + agentCursor: await isolated(page, "globalThis.__t3AgentCursor?.state()??null"), + }; +} +async function publish(page) { + if (page.window.isDestroyed() || !page.guest || page.guest.isDestroyed()) return; + const value = await state(page); + if (page.window.isDestroyed()) return; + page.window.webContents.send("t3-native-browser-state", value); + send({ event: "page", value }); +} +function interrupt(page, reason = "Browser control was interrupted.") { + page.revision++; + page.expectedInputs = []; + for (const control of page.controls ?? []) control.cancel(reason); + releaseInputs(page); + page.agentDriven = false; + if (page.guest && !page.guest.isDestroyed()) + void isolated(page, "globalThis.__t3AgentCursor?.hide()").catch(() => {}); + send({ + event: "interrupted", + value: { environmentID: page.environmentID, threadId: page.threadId, tabId: page.tabId }, + }); +} +function releaseInputs(page) { + if (!page.guest || page.guest.isDestroyed() || !page.guest.debugger.isAttached()) return; + const pressed = page.pressedMouse; + page.pressedMouse = null; + if (pressed) + void page.guest.debugger + .sendCommand("Input.dispatchMouseEvent", { ...pressed, type: "mouseReleased" }) + .catch(() => {}); + const keyUp = page.pressedKey; + page.pressedKey = null; + if (keyUp) void page.guest.debugger.sendCommand("Input.dispatchKeyEvent", keyUp).catch(() => {}); +} +async function createPage(input) { + const id = key(input); + if (creatingPages.has(id)) return creatingPages.get(id); + const existing = pages.get(id); + if (existing) return existing; + const initialURL = pageURL(input.url ?? "about:blank"); + const creation = createPageOwned(input, id, initialURL); + creatingPages.set(id, creation); + try { + return await creation; + } catch (error) { + const page = pages.get(id); + if (page && !page.window.isDestroyed()) page.window.destroy(); + throw error; + } finally { + if (creatingPages.get(id) === creation) creatingPages.delete(id); + } +} +async function createPageOwned(input, id, initialURL) { + const profileId = identity(input.profileId ?? "default"); + const scope = identity(input.environmentID) + "\u0000" + profileId; + const partition = await Effect.runPromise( + sessions.getPartition(scope, profileId !== "incognito", "profile"), + ); + const browserSession = await Effect.runPromise( + sessions.getSession(scope, profileId !== "incognito", "profile"), + ); + await extensions.attachSession(browserSession); + installDownloads(browserSession); + const window = new BrowserWindow({ + width: 1100, + height: 800, + minWidth: 620, + minHeight: 400, + show: false, + title: "T3 Browser · 1Password", + alwaysOnTop: true, + webPreferences: { + preload: NodePath.join(assets, "shell-preload.cjs"), + nodeIntegration: false, + contextIsolation: true, + sandbox: true, + webviewTag: true, + }, + }); + const page = { + key: id, + window, + partition, + threadId: input.threadId, + tabId: input.tabId, + environmentID: input.environmentID, + profileId, + viewport: input.viewport ?? { width: null, height: null }, + revision: 0, + documentRevision: 0, + controls: new Set(), + expectedInputs: [], + synthetic: 0, + agentDriven: false, + actions: [], + }; + pages.set(id, page); + window.webContents.setWindowOpenHandler(() => ({ action: "deny" })); + window.webContents.on("will-navigate", (event) => event.preventDefault()); + window.webContents.on("will-attach-webview", (event, preferences, params) => { + if (params.partition !== partition || !safeURL(params.src)) { + event.preventDefault(); + return; + } + delete preferences.preload; + preferences.nodeIntegration = false; + preferences.nodeIntegrationInSubFrames = false; + preferences.contextIsolation = true; + preferences.sandbox = true; + preferences.webSecurity = true; + }); + page.attached = new Promise((resolveAttached, reject) => { + const timer = setTimeout(() => reject(new Error("The Chromium page did not attach.")), 20000); + window.once("closed", () => { + clearTimeout(timer); + reject(new Error("The Chromium page was closed.")); + }); + window.webContents.once("did-attach-webview", (_event, guest) => { + clearTimeout(timer); + page.guest = guest; + guest.setWindowOpenHandler(({ url }) => { + if (safeURL(url)) + send({ + event: "newPage", + value: { + environmentID: page.environmentID, + threadId: page.threadId, + tabId: page.tabId, + url, + }, + }); + return { action: "deny" }; + }); + guest.on("will-navigate", (details) => { + if (!safeURL(details.url)) details.preventDefault(); + }); + guest.on("will-redirect", (details) => { + if (!safeURL(details.url)) details.preventDefault(); + }); + guest.ipc.on("t3-native-browser-human-input", (event, signal) => { + if ( + event.senderFrame !== guest.mainFrame || + !signal || + !["pointer", "key", "wheel"].includes(signal.kind) + ) + return; + const now = Date.now(); + page.expectedInputs = page.expectedInputs.filter((input) => now - input.at < 750); + const expected = page.expectedInputs.findIndex( + (input) => + input.kind === signal.kind && + (signal.kind === "pointer" + ? signal.button === 0 && + Math.abs(input.x - signal.x) < 1 && + Math.abs(input.y - signal.y) < 1 + : signal.kind === "key" + ? input.key === signal.key && input.code === signal.code + : false), + ); + if (expected >= 0) { + page.expectedInputs.splice(expected, 1); + return; + } + interrupt(page, "Browser control was interrupted by manual " + signal.kind + " input."); + }); + guest.on("did-start-navigation", (details) => { + if (details.isMainFrame) { + page.documentRevision++; + for (const control of page.controls) + if (!control.allowNavigation) control.cancel("The browser document changed."); + releaseInputs(page); + void isolated(page, "globalThis.__t3AgentCursor?.hide()").catch(() => {}); + } + }); + for (const name of [ + "did-navigate", + "did-navigate-in-page", + "page-title-updated", + "did-stop-loading", + ]) { + guest.on(name, () => void publish(page).catch(() => {})); + } + resolveAttached(guest); + }); + }); + // The renderer gets a partition only from this private, authenticated owner. + page.initialURL = initialURL; + window.on("close", (event) => { + if ( + !closing && + !page.allowClose && + [...downloads.values()].some((download) => download.page === page) + ) { + event.preventDefault(); + void dialog + .showMessageBox(window, { + type: "question", + message: "Downloads are still running", + detail: "Closing this page cancels its downloads.", + buttons: ["Keep downloading", "Close and cancel downloads"], + defaultId: 0, + cancelId: 0, + }) + .then(({ response }) => { + if (response === 1) { + for (const download of downloads.values()) + if (download.page === page) download.item.cancel(); + page.allowClose = true; + window.destroy(); + } + }); + } + }); + window.once("closed", () => { + interrupt(page); + if (pages.get(id) === page) pages.delete(id); + send({ + event: "closed", + value: { environmentID: page.environmentID, threadId: page.threadId, tabId: page.tabId }, + }); + }); + await window.loadFile(NodePath.join(assets, "shell.html")); + await page.attached; + if (input.show !== false) window.show(); + return page; +} +function safeURL(url) { + try { + pageURL(url); + return true; + } catch { + return false; + } +} +function installDownloads(session) { + if (downloadSessions.has(session)) return; + downloadSessions.add(session); + session.registerPreloadScript({ + id: "t3-native-browser-human-input", + type: "frame", + filePath: NodePath.join(assets, "guest-input.cjs"), + }); + session.on("will-download", (_event, item, source) => { + const page = [...pages.values()].find((page) => page.guest === source); + if (!page) { + item.cancel(); + return; + } + const id = NodeCrypto.randomUUID(), + status = { + id, + environmentID: page.environmentID, + threadId: page.threadId, + tabId: page.tabId, + name: + NodePath.basename(item.getFilename()) + .split("") + .filter((character) => character.charCodeAt(0) > 31) + .join("") + .slice(0, 180) || "download", + state: "downloading", + }; + const managed = page.agentDriven ? NodePath.join(downloadRoot, id + "-" + status.name) : null; + if (managed) item.setSavePath(managed); + let finish; + const done = new Promise((resolve) => { + finish = resolve; + }); + const download = { item, page, status, done }; + downloads.set(id, download); + send({ event: "download", value: status }); + item.on("updated", () => { + status.receivedBytes = item.getReceivedBytes(); + status.totalBytes = item.getTotalBytes(); + }); + item.once("done", async (_event, state) => { + downloads.delete(id); + status.state = state; + if (state === "completed") status.path = item.getSavePath(); + else if (managed) await NodeFSP.rm(managed, { force: true }).catch(() => {}); + send({ event: "download", value: status }); + finish(); + }); + // Human downloads keep the native Save dialog; agent downloads use a unique + // managed file and remove incomplete bytes on cancellation. + }); +} +async function query(page, operation, input, documentIdentity, guard) { + const injected = injectedSource, + operations = operationsSource; + return isolated( + page, + `(async()=>{if(${JSON.stringify(documentIdentity ?? null)}!==null && globalThis.__t3DocumentIdentity!==${JSON.stringify(documentIdentity ?? null)})throw new Error('Browser document changed.');if(!globalThis.__t3PlaywrightInjected){const module={exports:{}};${injected}\nglobalThis.__t3PlaywrightInjected=new(module.exports.InjectedScript())(globalThis,{isUnderTest:false,sdkLanguage:'javascript',testIdAttributeName:'data-testid',stableRafCount:1,browserName:'chromium',shouldPrependErrorPrefix:false,isUtilityWorld:true,customEngines:[]});}const operation=${JSON.stringify(operation)},input=${JSON.stringify(input)};return(await(async()=>{${operations}\n})());})()`, + guard, + ); +} +async function move(page, point, label, guard) { + guard(); + if (!point || !Number.isFinite(point.x) || !Number.isFinite(point.y)) return; + try { + await isolated(page, cursorSource, guard); + guard(); + await isolated( + page, + `globalThis.__t3AgentCursor.move(${point.x},${point.y},${JSON.stringify(label)})`, + guard, + ); + guard(); + } catch (error) { + if (page.guest && !page.guest.isDestroyed()) + void isolated(page, "globalThis.__t3AgentCursor?.hide()").catch(() => {}); + throw error; + } +} +async function automate(page, operation, input, timeout = 60000) { + await page.attached; + const revision = page.revision, + documentRevision = page.documentRevision; + const end = Date.now() + Math.min(60000, Math.max(1, timeout)); + const guard = () => { + check(page, revision); + if (operation !== "navigate" && page.documentRevision !== documentRevision) + throw fail("PreviewAutomationControlInterruptedError", "The browser document changed."); + if (Date.now() > end) + throw fail("PreviewAutomationTimeoutError", "Browser operation timed out."); + }; + guard(); + let rejectControl; + const interrupted = new Promise((_resolve, reject) => { + rejectControl = reject; + }); + void interrupted.catch(() => {}); + const control = { + allowNavigation: operation === "navigate", + cancel: (reason) => + rejectControl( + fail( + "PreviewAutomationControlInterruptedError", + reason ?? "Browser control was interrupted.", + ), + ), + }; + const timer = setTimeout( + () => rejectControl(fail("PreviewAutomationTimeoutError", "Browser operation timed out.")), + Math.max(1, end - Date.now()), + ); + page.controls.add(control); + guard.wait = (promise) => Promise.race([promise, interrupted]); + try { + return await automateGuarded(page, operation, input, guard); + } finally { + clearTimeout(timer); + page.controls.delete(control); + } +} +async function automateGuarded(page, operation, input, guard) { + const documentIdentity = + operation === "navigate" + ? null + : await isolated( + page, + `globalThis.__t3DocumentIdentity??=${JSON.stringify(NodeCrypto.randomUUID())}`, + guard, + ); + guard(); + if (!["status", "snapshot", "waitFor"].includes(operation)) page.agentDriven = true; + if (operation === "status" || operation === "open") return guard.wait(state(page)); + if (operation === "navigate") { + const readiness = input.readiness ?? "load"; + if (!["load", "domContentLoaded", "none"].includes(readiness)) + throw new Error("Invalid navigation readiness."); + let ready, listener; + if (readiness === "domContentLoaded") + ready = new Promise((resolve) => { + listener = resolve; + page.guest.once("dom-ready", listener); + }); + const navigation = page.guest.loadURL(pageURL(input.url)); + try { + if (readiness === "none") void navigation.catch(() => {}); + else if (ready) await guard.wait(Promise.race([ready, navigation])); + else await guard.wait(navigation); + } finally { + if (listener) page.guest.removeListener("dom-ready", listener); + } + // Intentional navigation adopts its destination; other actions remain bound + // to the document/control revision captured before any selector lookup. + guard(); + return guard.wait(state(page)); + } + if (operation === "resize") { + const width = input.viewport?.width ?? input.width, + height = input.viewport?.height ?? input.height; + if (width == null && height == null) { + page.viewport = { width: null, height: null }; + return { + tabId: page.tabId, + setting: page.viewport, + viewport: await isolated(page, "({width:innerWidth,height:innerHeight})"), + }; + } + if ( + !Number.isInteger(width) || + !Number.isInteger(height) || + width < 240 || + height < 240 || + width > 3840 || + height > 3840 + ) + throw new Error("Invalid Chromium viewport."); + page.viewport = { width, height }; + const toolbar = await page.window.webContents.executeJavaScript( + "document.querySelector('header').getBoundingClientRect().height+document.querySelector('#notice').getBoundingClientRect().height", + ); + const zoom = page.guest.getZoomFactor(); + page.window.setContentSize(Math.round(width * zoom), Math.round(height * zoom + toolbar)); + let measured; + do { + guard(); + measured = await isolated(page, "({width:innerWidth,height:innerHeight})", guard); + if (measured.width === width && measured.height === height) break; + await new Promise((resolve) => setTimeout(resolve, 40)); + } while (measured.width !== width || measured.height !== height); + return { tabId: page.tabId, setting: page.viewport, viewport: measured }; + } + if (operation === "setColorScheme") { + if (!["system", "dark", "light"].includes(input.colorScheme)) + throw new Error("Invalid color scheme."); + if (!page.guest.debugger.isAttached()) page.guest.debugger.attach("1.3"); + await page.guest.debugger.sendCommand("Emulation.setEmulatedMedia", { + features: + input.colorScheme === "system" + ? [] + : [{ name: "prefers-color-scheme", value: input.colorScheme }], + }); + return { tabId: page.tabId, colorScheme: input.colorScheme }; + } + if (operation === "evaluate") { + if (input.returnByValue === false) + throw fail( + "PreviewAutomationUnsupportedClientError", + "Remote object handles are not available.", + ); + if (typeof input.expression !== "string") throw new Error("Missing JavaScript expression."); + const result = await guard.wait( + page.guest.executeJavaScript( + `(async()=>{let value=(0,eval)(${JSON.stringify(input.expression)});${input.awaitPromise === false ? "" : "value=await value;"}return value??null;})()`, + ), + ); + guard(); + return result; + } + if (operation === "press") { + await move( + page, + await query(page, "pointer", input, documentIdentity, guard), + "Agent key", + guard, + ); + if (!page.guest.debugger.isAttached()) page.guest.debugger.attach("1.3"); + const sequence = makePreviewAutomationKeySequence( + { ...input, threadId: page.threadId }, + { isMac: true }, + ); + page.synthetic++; + try { + guard(); + page.expectedInputs.push({ ...sequence.signal, at: Date.now() }); + page.pressedKey = sequence.keyUp; + await guard.wait(page.guest.debugger.sendCommand("Input.dispatchKeyEvent", sequence.keyDown)); + guard(); + await guard.wait(page.guest.debugger.sendCommand("Input.dispatchKeyEvent", sequence.keyUp)); + page.pressedKey = null; + } finally { + releaseInputs(page); + page.synthetic--; + } + guard(); + return null; + } + if (operation === "snapshot") { + await isolated(page, "globalThis.__t3AgentCursor?.settle()", guard); + const result = await query(page, "snapshot", input, documentIdentity, guard); + guard(); + const image = await guard.wait(page.guest.capturePage()); + guard(); + const size = image.getSize(), + bytes = image.toPNG(); + if (bytes.length > 16 * 1024 * 1024) + throw fail( + "PreviewAutomationResultTooLargeError", + "Reduce the viewport to capture this page.", + ); + return { + ...result, + tabId: page.tabId, + actionTimeline: page.actions, + screenshot: { mimeType: "image/png", data: bytes.toString("base64"), ...size }, + }; + } + if (["recordingStart", "recordingStop"].includes(operation)) + throw fail( + "PreviewAutomationUnsupportedClientError", + "Chromium recording is not available yet; use the native browser recorder.", + ); + if (!["click", "type", "scroll", "waitFor"].includes(operation)) + throw fail("PreviewAutomationUnsupportedClientError", "Unsupported browser operation."); + if (operation !== "waitFor") + await move( + page, + await query(page, "pointer", input, documentIdentity, guard), + operation === "type" ? "Agent typing" : "Agent", + guard, + ); + while (true) { + guard(); + const result = await query(page, operation, input, documentIdentity, guard); + guard(); + if (result?.error) throw fail(result.error._tag, result.error.message); + if (result?.pending) { + await new Promise((resolve) => setTimeout(resolve, 50)); + continue; + } + if (operation === "click") { + await move(page, result, "Agent", guard); + const final = await query(page, "click", input, documentIdentity, guard); + guard(); + if (final?.pending) continue; + if (final?.error) throw fail(final.error._tag, final.error.message); + await move(page, final, "Agent", guard); + const viewport = await isolated(page, "({width:innerWidth,height:innerHeight})", guard); + guard(); + if ( + !Number.isFinite(final.x) || + !Number.isFinite(final.y) || + final.x < 0 || + final.y < 0 || + final.x >= viewport.width || + final.y >= viewport.height + ) + throw new Error("Click is outside the page viewport."); + if (!page.guest.debugger.isAttached()) page.guest.debugger.attach("1.3"); + page.synthetic++; + try { + guard(); + await guard.wait( + page.guest.debugger.sendCommand("Input.dispatchMouseEvent", { + type: "mouseMoved", + x: final.x, + y: final.y, + }), + ); + guard(); + page.expectedInputs.push({ kind: "pointer", x: final.x, y: final.y, at: Date.now() }); + page.pressedMouse = { x: final.x, y: final.y, button: "left", clickCount: 1 }; + await guard.wait( + page.guest.debugger.sendCommand("Input.dispatchMouseEvent", { + ...page.pressedMouse, + type: "mousePressed", + }), + ); + guard(); + await guard.wait( + page.guest.debugger.sendCommand("Input.dispatchMouseEvent", { + ...page.pressedMouse, + type: "mouseReleased", + }), + ); + page.pressedMouse = null; + } finally { + releaseInputs(page); + page.synthetic--; + } + guard(); + await isolated(page, "globalThis.__t3AgentCursor?.click()", guard); + guard(); + return null; + } + return operation === "waitFor" ? null : result; + } +} +async function flushSessions() { + await Promise.all( + [...downloadSessions] + .filter((session) => session.isPersistent()) + .map(async (session) => { + await session.cookies.flushStore(); + session.flushStorageData(); + }), + ); +} +async function cancelDownloads() { + const active = [...downloads.values()]; + for (const download of active) download.item.cancel(); + await Promise.all(active.map((download) => download.done)); +} +async function command(method, input) { + if (method === "hello") return { version: 1, engine: "chromium", extensionId: ONE_PASSWORD_ID }; + if (method === "stopHelper") { + closing = true; + extensions.dispose(); + await cancelDownloads(); + await flushSessions(); + for (const page of pages.values()) page.window.destroy(); + setImmediate(() => app.quit()); + return null; + } + if (method === "install") { + await extensions.installFromWebStore(ONE_PASSWORD_ID); + return extensions.list(); + } + if (method === "import") { + const requested = NodePath.resolve(input.profilePath ?? ""); + // Imports are only reachable through an explicit native file chooser; only + // the selected extension package is copied, never cookies or vault state. + for (const source of BROWSER_IMPORT_SOURCES.filter((source) => source.engine === "chromium")) { + const root = source.userDataDirectory({ + platform: "darwin", + home: NodeOS.homedir(), + path: { join: NodePath.join }, + }); + if (!root || NodePath.dirname(requested) !== root) continue; + const profilePath = await selectedProfile(root, NodePath.basename(requested)); + await extensions.importFromProfile({ + profilePath, + source: { kind: "browser", sourceId: source.id }, + extensionIds: [ONE_PASSWORD_ID], + }); + return extensions.list(); + } + throw new Error( + "Select a Chrome, Edge, Brave, Vivaldi, Opera or Arc profile containing 1Password.", + ); + } + if (method === "removeExtension") { + await extensions.remove(ONE_PASSWORD_ID); + return extensions.list(); + } + if (method === "clearProfile") { + const profileId = identity(input.profileId), + environment = identity(input.environmentID), + scope = environment + "\u0000" + profileId; + if (!["all", "cookies", "cache"].includes(input.kinds)) + throw new Error("Invalid profile clear."); + const browserSession = await Effect.runPromise( + sessions.getSession(scope, profileId !== "incognito", "profile"), + ); + for (const page of pages.values()) + if (page.environmentID === environment && page.profileId === profileId) interrupt(page); + if (input.kinds === "cache") await browserSession.clearCache(); + else if (input.kinds === "all") await browserSession.clearStorageData(); + else { + const partition = await Effect.runPromise( + sessions.getPartition(scope, profileId !== "incognito", "profile"), + ); + await Effect.runPromise(sessions.clearCookies([partition])); + } + return null; + } + if (method === "extensions") return extensions.list(); + if (method === "downloads") return [...downloads.values()].map((download) => download.status); + if (method === "cancelDownloads") { + await cancelDownloads(); + return null; + } + if (method === "open") { + const page = await createPage(input); + return state(page); + } + const page = pages.get(key(input)); + if (!page) + throw fail("PreviewAutomationTabNotFoundError", "The Chromium page is no longer open."); + if (method === "close") { + page.window.close(); + return null; + } + if (method === "stop") { + interrupt(page); + return null; + } + if (method === "reveal") { + page.window.show(); + page.window.focus(); + return state(page); + } + if (method === "popup") { + interrupt(page); + if (page.profileId === "incognito") + throw new Error("1Password is unavailable in private profiles."); + const revision = page.revision; + const password = (await extensions.list(page.guest.id)).find( + (extension) => extension.id === ONE_PASSWORD_ID, + ); + check(page, revision); + if (!password?.enabled) + throw new Error("Install the 1Password extension in Browser Settings first."); + if (password.loadError) throw new Error(password.loadError); + const startupDeadline = Date.now() + 15000; + // 1Password chooses onboarding or its account popup after worker startup. + // Opening the manifest default earlier can leave a popup waiting forever. + while (!extensions.hasActionPopupOverride(page.guest.id, ONE_PASSWORD_ID)) { + check(page, revision); + if (Date.now() >= startupDeadline) + throw new Error("1Password is still initializing. Try opening it again."); + await new Promise((resolve) => setTimeout(resolve, 50)); + } + check(page, revision); + const anchor = input.anchor ?? { + x: page.window.getContentBounds().width - 300, + y: 8, + width: 90, + height: 32, + }; + if ( + !["x", "y", "width", "height"].every( + (key) => Number.isFinite(anchor[key]) && Math.abs(anchor[key]) < 10000, + ) + ) + throw new Error("Invalid popup anchor."); + await extensions.openPopup({ + extensionId: ONE_PASSWORD_ID, + tabWebContentsId: page.guest.id, + ownerWebContentsId: page.window.webContents.id, + anchor, + }); + return null; + } + if (method === "zoom") { + interrupt(page); + const zoom = input.factor ?? page.guest.getZoomFactor() + (input.delta ?? 0); + if (!Number.isFinite(zoom) || zoom < 0.25 || zoom > 5) + throw new Error("Browser zoom must be between 25% and 500%."); + page.guest.setZoomFactor(zoom); + return state(page); + } + if (method === "back") { + interrupt(page); + page.guest.navigationHistory.goBack(); + return null; + } + if (method === "forward") { + interrupt(page); + page.guest.navigationHistory.goForward(); + return null; + } + if (method === "reload") { + interrupt(page); + page.guest.reload(); + return null; + } + if (method === "navigate") return automate(page, "navigate", input); + if (method === "automation") { + const action = { + id: NodeCrypto.randomUUID(), + action: input.operation, + status: "running", + startedAt: new Date().toISOString(), + }; + page.actions.push(action); + if (page.actions.length > 50) page.actions.shift(); + try { + const result = await automate(page, input.operation, input.input ?? {}, input.timeoutMs); + action.status = "succeeded"; + return result; + } catch (error) { + action.status = + error._tag === "PreviewAutomationControlInterruptedError" ? "interrupted" : "failed"; + action.error = error.message; + throw error; + } finally { + action.completedAt = new Date().toISOString(); + } + } + throw new Error("Unknown helper command."); +} +async function start() { + sessions = await Effect.runPromise( + makeBrowserSessions.pipe(Effect.provide(NodeEffectCrypto.layer)), + ); + extensions = new ExtensionHost({ + store: new ExtensionStore(NodePath.join(root, "browser-extensions")), + preloadPath: NodePath.join(assets, "preview-extension-preload.cjs"), + platform: "darwin", + homeDirectory: NodeOS.homedir(), + chromeVersion: process.versions.chrome, + onChange: () => + void extensions + .list() + .then((value) => send({ event: "extensions", value })) + .catch(() => {}), + log: (message) => console.error(message), + }); + ipcMain.handle("t3-native-browser-toolbar", async (event, { method, input }) => { + const page = [...pages.values()].find((page) => page.window.webContents === event.sender); + if ( + !page || + event.senderFrame !== page.window.webContents.mainFrame || + event.senderFrame.url !== NodeURL.pathToFileURL(NodePath.join(assets, "shell.html")).href + ) + throw new Error("Invalid browser toolbar owner."); + if (method === "ready") { + page.window.webContents.send("t3-native-browser-state", { + partition: page.partition, + url: page.initialURL, + }); + return; + } + if (!["back", "forward", "reload", "stop", "close", "popup", "navigate"].includes(method)) + throw new Error("Invalid toolbar action."); + if (method === "navigate") interrupt(page); + return command(method, { + ...input, + environmentID: page.environmentID, + threadId: page.threadId, + tabId: page.tabId, + }); + }); + const utf8 = new NodeStringDecoder.StringDecoder("utf8"); + process.stdin.on("data", (chunk) => { + try { + const commands = decoder.push(utf8.write(chunk)); + for (const request of commands) { + if (commandCount >= 32) throw new Error("Too many pending browser commands."); + commandCount++; + void command(request.method, request.input ?? {}) + .then( + (result) => send({ id: request.id, result: result ?? null }), + (error) => + send({ + id: request.id, + error: { + _tag: error._tag ?? "PreviewAutomationActionFailedError", + message: error.message, + }, + }), + ) + .finally(() => commandCount--); + } + } catch (error) { + console.error(error.message); + app.quit(); + } + }); + const shutdown = () => { + if (closing) return; + closing = true; + extensions.dispose(); + void cancelDownloads() + .then(flushSessions) + .finally(() => app.quit()); + }; + process.stdin.on("end", shutdown); + process.once("SIGTERM", shutdown); + process.once("SIGINT", shutdown); + app.on("window-all-closed", () => {}); + app.on("before-quit", (event) => { + if (closing) return; + event.preventDefault(); + const finish = async () => { + closing = true; + extensions.dispose(); + await cancelDownloads(); + await flushSessions(); + app.quit(); + }; + if (!downloads.size) { + void finish(); + return; + } + const owner = [...pages.values()].find((page) => !page.window.isDestroyed())?.window; + const options = { + type: "question", + message: "Downloads are still running", + detail: "Quitting T3 Browser cancels its downloads.", + buttons: ["Keep downloading", "Quit and cancel downloads"], + defaultId: 0, + cancelId: 0, + }; + void (owner ? dialog.showMessageBox(owner, options) : dialog.showMessageBox(options)).then( + ({ response }) => { + if (response === 1) void finish(); + }, + ); + }); + send({ event: "ready", value: { version: 1, engine: "chromium" } }); +} +void app + .whenReady() + .then(start) + .catch((error) => { + console.error(error); + app.exit(1); + }); diff --git a/apps/macos/chromium-helper/protocol.mjs b/apps/macos/chromium-helper/protocol.mjs new file mode 100644 index 0000000000000..314c89e2d7479 --- /dev/null +++ b/apps/macos/chromium-helper/protocol.mjs @@ -0,0 +1,84 @@ +import * as NodeCrypto from "node:crypto"; +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; +import * as NodeOS from "node:os"; + +export const ONE_PASSWORD_ID = "aeblfdkhhhdcdjpifhhbdiojplfjncoa"; +export const WIRE_PREFIX = "T3CHROMIUM "; +export function pageURL(value) { + if (value === "about:blank") return value; + if (typeof value !== "string" || value.length > 16384) throw new Error("Invalid page URL."); + const url = new URL(value); + if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) { + throw new Error("Only HTTP(S) pages without URL credentials are allowed."); + } + return url.href; +} +export function identity(value) { + if ( + typeof value !== "string" || + !value.length || + value.length > 512 || + value.split("").some((character) => character.charCodeAt(0) < 32) + ) { + throw new Error("Invalid browser identity."); + } + return value; +} +export function authenticated(value, token) { + if (typeof value !== "string" || !/^[a-f0-9]{64}$/.test(value) || !/^[a-f0-9]{64}$/.test(token)) + return false; + return NodeCrypto.timingSafeEqual(Buffer.from(value, "hex"), Buffer.from(token, "hex")); +} +export class CommandDecoder { + #buffer = ""; + constructor(token) { + this.token = token; + } + push(chunk) { + this.#buffer += chunk; + if (Buffer.byteLength(this.#buffer) > 1024 * 1024) + throw new Error("Helper command exceeds the limit."); + const result = []; + let end; + while ((end = this.#buffer.indexOf("\n")) >= 0) { + const line = this.#buffer.slice(0, end); + this.#buffer = this.#buffer.slice(end + 1); + const value = JSON.parse(line); + if (!authenticated(value.token, this.token)) + throw new Error("Unauthenticated helper command."); + identity(value.id); + identity(value.method); + result.push(value); + } + return result; + } +} +export async function stateDirectory(value) { + const allowed = NodePath.join(NodeOS.homedir(), "Library/Application Support/T3Native/Chromium"); + const root = NodePath.resolve(value ?? allowed); + if (root !== NodePath.resolve(allowed)) + throw new Error("The helper requires its managed Chromium state directory."); + await NodeFSP.mkdir(root, { recursive: true, mode: 0o700 }); + if ((await NodeFSP.realpath(root)) !== root) + throw new Error("The Chromium state directory cannot be a symlink."); + return root; +} +export async function selectedProfile(root, selection) { + if ( + typeof selection !== "string" || + !selection || + selection.includes("/") || + selection.includes("\\") || + selection === "." || + selection === ".." + ) { + throw new Error("Select an existing browser profile."); + } + const canonicalRoot = await NodeFSP.realpath(root); + const candidate = await NodeFSP.realpath(NodePath.join(canonicalRoot, selection)); + const part = NodePath.relative(canonicalRoot, candidate); + if (part.startsWith("..") || NodePath.isAbsolute(part) || !part) + throw new Error("The selected profile is outside its browser."); + return candidate; +} diff --git a/apps/macos/chromium-helper/protocol.test.mjs b/apps/macos/chromium-helper/protocol.test.mjs new file mode 100644 index 0000000000000..ab0ad751b7cc5 --- /dev/null +++ b/apps/macos/chromium-helper/protocol.test.mjs @@ -0,0 +1,132 @@ +import * as NodeTest from "node:test"; +import * as NodeAssert from "node:assert/strict"; +import * as NodeCrypto from "node:crypto"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { CommandDecoder, authenticated, pageURL, selectedProfile, identity } from "./protocol.mjs"; + +NodeTest.test( + "private pipe accepts split authenticated commands and refuses another launch token", + () => { + const token = NodeCrypto.randomBytes(32).toString("hex"), + decoder = new CommandDecoder(token); + const line = + JSON.stringify({ + token, + id: "request-1", + method: "open", + input: { url: "https://example.com/" }, + }) + "\n"; + NodeAssert.deepEqual(decoder.push(line.slice(0, 11)), []); + NodeAssert.deepEqual(decoder.push(line.slice(11)), [ + { token, id: "request-1", method: "open", input: { url: "https://example.com/" } }, + ]); + const bad = + JSON.stringify({ + token: NodeCrypto.randomBytes(32).toString("hex"), + id: "request-2", + method: "install", + }) + "\n"; + NodeAssert.throws(() => decoder.push(bad), /Unauthenticated/); + NodeAssert.equal(authenticated("x", token), false); + }, +); +NodeTest.test( + "pipe bounds unfinished lines before parsing and refuses invalid request identities", + () => { + const token = NodeCrypto.randomBytes(32).toString("hex"); + NodeAssert.throws(() => new CommandDecoder(token).push("x".repeat(1024 * 1024 + 1)), /exceeds/); + NodeAssert.throws( + () => + new CommandDecoder(token).push( + JSON.stringify({ token, id: "request\nforged", method: "open" }) + "\n", + ), + /identity/, + ); + NodeAssert.throws(() => identity(""), /identity/); + }, +); +NodeTest.test( + "page navigation permits HTTP(S) and refuses credentials or privileged schemes", + () => { + NodeAssert.equal(pageURL("https://example.com/a?q=b"), "https://example.com/a?q=b"); + NodeAssert.equal(pageURL("http://localhost:3000"), "http://localhost:3000/"); + NodeAssert.equal(pageURL("about:blank"), "about:blank"); + for (const url of [ + "file:///tmp/a", + "javascript:alert(1)", + "data:text/html,a", + "chrome-extension://a/a", + "https://user:secret@example.com", + ]) { + NodeAssert.throws(() => pageURL(url)); + } + }, +); +NodeTest.test( + "explicit profile selection uses canonical files and rejects traversal and symlink escapes", + async () => { + const directory = await NodeFSP.mkdtemp( + NodePath.join(NodeOS.tmpdir(), "t3-chromium-profiles-"), + ); + try { + const root = NodePath.join(directory, "Chrome"), + outside = NodePath.join(directory, "outside"); + await NodeFSP.mkdir(NodePath.join(root, "Default"), { recursive: true }); + await NodeFSP.mkdir(outside); + await NodeFSP.symlink(outside, NodePath.join(root, "Profile 2")); + NodeAssert.equal( + await selectedProfile(root, "Default"), + await NodeFSP.realpath(NodePath.join(root, "Default")), + ); + await NodeAssert.rejects(selectedProfile(root, "../outside"), /existing browser profile/); + await NodeAssert.rejects(selectedProfile(root, "Profile 2"), /outside its browser/); + } finally { + await NodeFSP.rm(directory, { recursive: true, force: true }); + } + }, +); + +import { compareRuntimeTrees, releaseChecksum } from "./runtime-verification.mjs"; + +NodeTest.test( + "verified runtime comparison detects changed bytes and rewritten framework symlinks", + async () => { + const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-electron-tree-")); + try { + const a = NodePath.join(directory, "a"), + b = NodePath.join(directory, "b"); + for (const root of [a, b]) { + await NodeFSP.mkdir(NodePath.join(root, "Versions/A"), { recursive: true }); + await NodeFSP.writeFile( + NodePath.join(root, "Versions/A/Electron"), + "original executable bytes", + ); + await NodeFSP.symlink("A", NodePath.join(root, "Versions/Current")); + await NodeFSP.symlink("Versions/Current/Electron", NodePath.join(root, "Electron")); + } + await compareRuntimeTrees(a, b); + await NodeFSP.writeFile(NodePath.join(a, "Versions/A/Electron"), "modified executable bytes"); + await NodeAssert.rejects(compareRuntimeTrees(a, b), /differs/); + await NodeFSP.writeFile(NodePath.join(a, "Versions/A/Electron"), "original executable bytes"); + await NodeFSP.rm(NodePath.join(a, "Electron")); + await NodeFSP.symlink(NodePath.join(b, "Versions/A/Electron"), NodePath.join(a, "Electron")); + await NodeAssert.rejects(compareRuntimeTrees(a, b), /differs/); + } finally { + await NodeFSP.rm(directory, { recursive: true, force: true }); + } + }, +); +NodeTest.test( + "release checksum selection rejects absent architecture and accepts exact release filename", + () => { + const checksum = "a".repeat(64), + file = "electron-v44.4.2-darwin-arm64.zip"; + NodeAssert.equal(releaseChecksum(`${checksum} ${file}\n`, file), checksum); + NodeAssert.throws( + () => releaseChecksum(`${checksum} electron-v44.4.2-darwin-x64.zip\n`, file), + /does not include/, + ); + }, +); diff --git a/apps/macos/chromium-helper/runtime-observer.mjs b/apps/macos/chromium-helper/runtime-observer.mjs new file mode 100644 index 0000000000000..db1833f383ea0 --- /dev/null +++ b/apps/macos/chromium-helper/runtime-observer.mjs @@ -0,0 +1,95 @@ +// Test entry only: the packager never includes this file. The product main and +// extension services run unchanged; real window events keep fixture UI hidden. +import { app } from "electron"; +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; +const evidence = process.env.T3_CHROMIUM_FIXTURE_EVIDENCE; +if (!evidence?.startsWith("/private/tmp/t3-native-evidence/")) + throw new Error("Private fixture evidence required."); +await NodeFSP.mkdir(evidence, { recursive: true }); +app.on("browser-window-created", (_event, window) => { + window.setOpacity(0); + window.setFocusable(false); + window.webContents.setBackgroundThrottling(false); + window.webContents.on("did-finish-load", () => { + const contents = window.webContents; + if (!contents.getURL().startsWith("chrome-extension://")) return; + void (async () => { + let text = ""; + const end = Date.now() + 15000; + const ready = (value) => value.trim().length > 0 && !/^Loading[.…]*$/i.test(value.trim()); + while (!ready(text) && Date.now() < end && !contents.isDestroyed()) { + text = await contents.executeJavaScript('document.body?.innerText??""'); + if (!ready(text)) await new Promise((resolve) => setTimeout(resolve, 25)); + } + const report = { + url: contents.getURL(), + title: contents.getTitle(), + text: text.slice(0, 12000), + uiReady: ready(text), + visible: window.isVisible(), + opacity: window.getOpacity(), + }; + await NodeFSP.writeFile( + NodePath.join(evidence, "extension-ui.json"), + JSON.stringify(report, null, 2), + ); + try { + const image = await contents.capturePage(); + await NodeFSP.writeFile(NodePath.join(evidence, "extension-ui.png"), image.toPNG()); + } catch (error) { + await NodeFSP.writeFile(NodePath.join(evidence, "capture-limitation.txt"), String(error)); + } + })().catch((error) => console.error("Fixture popup observation:", error)); + }); +}); +const observedSessions = new Set(); +app.on("web-contents-created", (_event, contents) => { + if (contents.getType() === "webview" && process.env.T3_CHROMIUM_FIXTURE_MOTION) { + const motion = process.env.T3_CHROMIUM_FIXTURE_MOTION; + if (!["no-preference", "reduce"].includes(motion)) + throw new Error("Invalid fixture motion preference."); + contents.on("dom-ready", () => { + if (!contents.debugger.isAttached()) contents.debugger.attach("1.3"); + void contents.debugger + .sendCommand("Emulation.setEmulatedMedia", { + features: [{ name: "prefers-reduced-motion", value: motion }], + }) + .catch((error) => console.error("Fixture media emulation:", error)); + }); + } + if (!observedSessions.has(contents.session)) { + observedSessions.add(contents.session); + contents.session.serviceWorkers.on("console-message", (_event, details) => { + console.error("Fixture worker console:", JSON.stringify(details)); + }); + } + contents.on("did-finish-load", () => { + if (!contents.getURL().startsWith("chrome-extension://")) return; + try { + contents.debugger.attach("1.3"); + contents.debugger.on("message", (_event, method, details) => { + if (method === "Runtime.exceptionThrown") + console.error("Fixture popup exception:", JSON.stringify(details)); + }); + void contents.debugger + .sendCommand("Runtime.enable") + .catch((error) => console.error("Fixture exception listener:", error)); + } catch (error) { + console.error("Fixture exception listener:", error); + } + }); + contents.on("console-message", (details) => { + if (contents.getURL().startsWith("chrome-extension://")) + console.error( + "Fixture extension console:", + JSON.stringify({ + level: details.level, + message: details.message, + sourceId: details.sourceId, + lineNumber: details.lineNumber, + }), + ); + }); +}); +await import("./main.mjs"); diff --git a/apps/macos/chromium-helper/runtime-smoke.mjs b/apps/macos/chromium-helper/runtime-smoke.mjs new file mode 100644 index 0000000000000..61f53d5004ebf --- /dev/null +++ b/apps/macos/chromium-helper/runtime-smoke.mjs @@ -0,0 +1,114 @@ +import * as NodeChildProcess from "node:child_process"; +import * as NodeCrypto from "node:crypto"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { WIRE_PREFIX } from "./protocol.mjs"; + +// Every packaged executable must initialize and authenticate without exposing +// windows, loading extensions or opening the user's managed browser state. +export async function smokeRuntime(app) { + const home = await NodeFSP.realpath( + await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-chromium-smoke-")), + ); + const token = NodeCrypto.randomBytes(32).toString("hex"); + const environment = { + ...process.env, + HOME: home, + T3_CHROMIUM_STATE: NodePath.join(home, "Library/Application Support/T3Native/Chromium"), + T3_CHROMIUM_TOKEN: token, + }; + delete environment.ELECTRON_RUN_AS_NODE; + delete environment.NODE_OPTIONS; + const child = NodeChildProcess.spawn(NodePath.join(app, "Contents/MacOS/Electron"), [], { + env: environment, + stdio: ["pipe", "pipe", "pipe"], + }); + let diagnostic = "", + buffer = "", + hello = false, + ready = false, + stopped = false, + exited = false; + let timer; + try { + await new Promise((resolve, reject) => { + const fail = (message) => + reject( + Error( + "The signed Chromium helper failed its isolated startup check: " + + message + + "\n" + + diagnostic, + ), + ); + timer = setTimeout(() => { + child.kill("SIGTERM"); + fail("timeout"); + }, 15000); + child.stderr.on("data", (data) => { + diagnostic = (diagnostic + data).slice(-8192); + }); + child.once("error", (error) => fail(error.message)); + child.once("exit", (code, signal) => { + exited = true; + if (code === 0 && ready && hello && stopped) resolve(); + else fail("exit " + code + " " + signal); + }); + const send = (id, method) => + child.stdin.write(JSON.stringify({ token, id, method, input: {} }) + "\n"); + child.stdout.on("data", (data) => { + try { + buffer += data; + if (buffer.length > 1024 * 1024) throw Error("Oversized startup reply"); + let end; + while ((end = buffer.indexOf("\n")) >= 0) { + const line = buffer.slice(0, end); + buffer = buffer.slice(end + 1); + if (!line.startsWith(WIRE_PREFIX)) continue; + const message = JSON.parse(line.slice(WIRE_PREFIX.length)); + if (message.token !== token) throw Error("Unauthenticated startup reply"); + if (message.event === "ready" && !ready) { + ready = true; + send("hello", "hello"); + } + if (message.id === "hello") { + if ( + message.error || + message.result?.version !== 1 || + message.result?.engine !== "chromium" + ) + throw Error("Invalid engine handshake"); + hello = true; + send("stop", "stopHelper"); + } + if (message.id === "stop") { + if (message.error) throw Error(message.error.message); + stopped = true; + } + } + } catch (error) { + fail(error.message); + } + }); + }); + return { protocol: 1, engine: "chromium", initialized: true, ownedShutdown: true }; + } finally { + clearTimeout(timer); + if (!exited) { + child.stdin.end(); + child.kill("SIGTERM"); + await new Promise((resolve) => { + const timeout = setTimeout(() => { + if (!exited) child.kill("SIGKILL"); + resolve(); + }, 2000); + child.once("exit", () => { + clearTimeout(timeout); + resolve(); + }); + }); + } + await NodeFSP.rm(home, { recursive: true, force: true }); + } +} diff --git a/apps/macos/chromium-helper/runtime-verification.mjs b/apps/macos/chromium-helper/runtime-verification.mjs new file mode 100644 index 0000000000000..7d5442465d963 --- /dev/null +++ b/apps/macos/chromium-helper/runtime-verification.mjs @@ -0,0 +1,94 @@ +import * as NodeCrypto from "node:crypto"; +import * as NodeFS from "node:fs"; +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeOS from "node:os"; + +export function releaseChecksum(text, filename) { + for (const line of text.split("\n")) { + const match = /^([a-f0-9]{64})\s+\*?(.+)$/.exec(line.trim()); + if (match?.[2] === filename) return match[1]; + } + throw new Error(`The official release checksum does not include ${filename}.`); +} +async function digest(path) { + const hash = NodeCrypto.createHash("sha256"); + for await (const chunk of NodeFS.createReadStream(path)) hash.update(chunk); + return hash.digest("hex"); +} +async function tree(root) { + const entries = new Map(); + async function visit(directory, relative = "") { + for (const name of await NodeFSP.readdir(directory)) { + const path = NodePath.join(directory, name), + key = relative ? `${relative}/${name}` : name; + const entry = await NodeFSP.lstat(path); + if (entry.isSymbolicLink()) entries.set(key, "link:" + (await NodeFSP.readlink(path))); + else if (entry.isDirectory()) { + entries.set(key, "directory"); + await visit(path, key); + } else if (entry.isFile()) entries.set(key, "file:" + (await digest(path))); + else throw new Error("The runtime contains an unsupported filesystem entry."); + } + } + await visit(root); + return entries; +} +export async function compareRuntimeTrees(runtime, verified) { + const [actual, expected] = await Promise.all([tree(runtime), tree(verified)]); + if (actual.size !== expected.size) + throw new Error("The supplied runtime differs from the verified official archive."); + for (const [path, digest] of expected) + if (actual.get(path) !== digest) { + throw new Error( + `The supplied runtime differs from the verified official archive at ${path}.`, + ); + } +} +export async function verifyRuntime({ runtime, version, archive }) { + if (!/^\d+\.\d+\.\d+$/.test(version)) throw new Error("The Electron version must be pinned."); + const sourceVersion = ( + await NodeFSP.readFile(NodePath.join(NodePath.dirname(runtime), "version"), "utf8") + ).trim(); + if (sourceVersion !== version) + throw new Error(`Expected Electron ${version}; supplied runtime is ${sourceVersion}.`); + // A valid ad hoc seal is not vendor provenance. Always authenticate the + // pinned official archive and compare every runtime file and symlink. + const temporary = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-chromium-verify-")); + try { + const filename = `electron-v${version}-darwin-arm64.zip`; + const base = `https://github.com/electron/electron/releases/download/v${version}`; + const checksums = NodePath.join(temporary, "SHASUMS256.txt"); + NodeChildProcess.execFileSync( + "/usr/bin/curl", + [ + "-fsSL", + "--proto", + "=https", + "--proto-redir", + "=https", + `${base}/SHASUMS256.txt`, + "-o", + checksums, + ], + { stdio: "pipe" }, + ); + const expected = releaseChecksum(await NodeFSP.readFile(checksums, "utf8"), filename); + const zip = archive ?? NodePath.join(temporary, filename); + if (!archive) + NodeChildProcess.execFileSync( + "/usr/bin/curl", + ["-fsSL", "--proto", "=https", "--proto-redir", "=https", `${base}/${filename}`, "-o", zip], + { stdio: "pipe" }, + ); + if ((await digest(zip)) !== expected) + throw new Error("The official Electron arm64 archive checksum does not match."); + const unpacked = NodePath.join(temporary, "verified"); + NodeChildProcess.execFileSync("/usr/bin/ditto", ["-x", "-k", zip, unpacked], { stdio: "pipe" }); + await compareRuntimeTrees(runtime, NodePath.join(unpacked, "Electron.app")); + return { version, verification: "official-release-sha256", sha256: expected }; + } finally { + await NodeFSP.rm(temporary, { recursive: true, force: true }); + } +} diff --git a/apps/macos/chromium-helper/runtime.test.mjs b/apps/macos/chromium-helper/runtime.test.mjs new file mode 100644 index 0000000000000..3dc353359245f --- /dev/null +++ b/apps/macos/chromium-helper/runtime.test.mjs @@ -0,0 +1,503 @@ +import * as NodeTest from "node:test"; +import * as NodeAssert from "node:assert/strict"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeHttp from "node:http"; +import * as NodeCrypto from "node:crypto"; +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; +import * as NodeOS from "node:os"; +import * as NodeEvents from "node:events"; +const supplied = process.env.T3_CHROMIUM_TEST_APP; +const extensionTest = process.env.T3_CHROMIUM_TEST_EXTENSION === "1"; +const context = { + environmentID: "fixture-environment", + threadId: "fixture-thread", + tabId: "fixture-tab", + profileId: "fixture-profile", + show: false, +}; +const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); +async function until(read, accept, timeout = 10000) { + const end = Date.now() + timeout; + while (Date.now() < end) { + const value = await read(); + if (accept(value)) return value; + await delay(25); + } + throw Error("Fixture milestone timed out"); +} +class Helper { + constructor(app, home, evidence, motion) { + this.token = NodeCrypto.randomBytes(32).toString("hex"); + this.events = []; + this.pending = new Map(); + this.next = 0; + this.buffer = ""; + this.errors = ""; + const environment = { + ...process.env, + HOME: home, + T3_CHROMIUM_STATE: NodePath.join(home, "Library/Application Support/T3Native/Chromium"), + T3_CHROMIUM_TOKEN: this.token, + T3_CHROMIUM_FIXTURE_EVIDENCE: evidence, + T3_CHROMIUM_FIXTURE_MOTION: motion ?? "", + }; + delete environment.ELECTRON_RUN_AS_NODE; + delete environment.NODE_OPTIONS; + this.child = NodeChildProcess.spawn(NodePath.join(app, "Contents/MacOS/Electron"), [], { + env: environment, + stdio: ["pipe", "pipe", "pipe"], + }); + this.exited = NodeEvents.once(this.child, "exit"); + this.child.stderr.on("data", (data) => { + this.errors += data; + }); + this.child.stdout.on("data", (data) => { + this.buffer += data; + let end; + while ((end = this.buffer.indexOf("\n")) >= 0) { + const line = this.buffer.slice(0, end); + this.buffer = this.buffer.slice(end + 1); + if (!line.startsWith("T3CHROMIUM ")) continue; + const message = JSON.parse(line.slice(11)); + NodeAssert.equal(message.token, this.token); + delete message.token; + if (message.event) this.events.push(message); + const pending = this.pending.get(message.id); + if (pending) { + this.pending.delete(message.id); + clearTimeout(pending.timer); + if (message.error) + pending.reject( + Object.assign(Error(message.error.message), { tag: message.error._tag }), + ); + else pending.resolve(message.result); + } + } + }); + this.child.once("exit", (code, signal) => { + this.exit = { code, signal }; + for (const pending of this.pending.values()) { + clearTimeout(pending.timer); + pending.reject(Error("Helper exited: " + code + " " + signal)); + } + this.pending.clear(); + }); + } + rpc(method, input = {}, timeout = 20000) { + return new Promise((resolve, reject) => { + const id = "r" + ++this.next; + const timer = setTimeout(() => { + this.pending.delete(id); + reject(Error("Timed out: " + method)); + }, timeout); + this.pending.set(id, { resolve, reject, timer }); + this.child.stdin.write(JSON.stringify({ token: this.token, id, method, input }) + "\n"); + }); + } + action(operation, input = {}, timeout = 10000) { + return this.rpc( + "automation", + { ...context, operation, input, timeoutMs: timeout }, + timeout + 3000, + ); + } + async ready() { + await until(() => this.events.find((event) => event.event === "ready"), Boolean); + NodeAssert.equal((await this.rpc("hello")).engine, "chromium"); + } + async stop() { + if (this.exit) return; + await this.rpc("stopHelper"); + const [code] = await Promise.race([ + this.exited, + delay(10000).then(() => { + throw Error("Owned helper failed to quit"); + }), + ]); + NodeAssert.equal(code, 0); + } + async cleanup(log) { + if (!this.exit) { + this.child.stdin.end(); + await Promise.race([this.exited, delay(3000)]); + if (!this.exit) { + this.child.kill("SIGTERM"); + await this.exited; + } + } + await NodeFSP.writeFile(log, this.errors); + } +} +async function observedApp(root) { + const app = NodePath.join(root, "T3 Browser.app"); + await NodeFSP.cp(supplied, app, { + recursive: true, + dereference: false, + verbatimSymlinks: true, + }); + const assets = NodePath.join(app, "Contents/Resources/app"); + // A copy retains the exact signed product main/preloads/frameworks. Its test + // entry observes genuine Electron windows and hides UI; no extension API stub. + NodeAssert.deepEqual( + await NodeFSP.readFile(NodePath.join(assets, "main.mjs")), + await NodeFSP.readFile(NodePath.join(supplied, "Contents/Resources/app/main.mjs")), + ); + await NodeFSP.cp( + NodePath.join(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), "runtime-observer.mjs"), + NodePath.join(assets, "runtime-observer.mjs"), + ); + const metadata = JSON.parse( + await NodeFSP.readFile(NodePath.join(assets, "package.json"), "utf8"), + ); + metadata.main = "runtime-observer.mjs"; + await NodeFSP.writeFile(NodePath.join(assets, "package.json"), JSON.stringify(metadata)); + const entitlements = NodePath.join(root, "fixture-entitlements.plist"); + await NodeFSP.writeFile( + entitlements, + NodeChildProcess.execFileSync("/usr/bin/codesign", ["-d", "--entitlements", ":-", supplied], { + stdio: "pipe", + }), + ); + NodeChildProcess.execFileSync( + "/usr/bin/codesign", + [ + "--force", + "--sign", + process.env.T3_CHROMIUM_TEST_SIGN_IDENTITY ?? "-", + "--options", + "runtime", + "--entitlements", + entitlements, + app, + ], + { stdio: "pipe" }, + ); + NodeChildProcess.execFileSync("/usr/bin/codesign", ["--verify", "--deep", "--strict", app]); + return app; +} +NodeTest.test( + "signed isolated Chromium: actual pages, cancellation, navigation identity, cursor, downloads and persistence", + { skip: !supplied, timeout: 180000 }, + async (t) => { + // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone executable fixture uses the actual host architecture. + NodeAssert.equal(NodeOS.arch(), "arm64"); + NodeChildProcess.execFileSync("/usr/bin/codesign", [ + "--verify", + "--deep", + "--strict", + supplied, + ]); + await NodeFSP.mkdir("/tmp/t3-native-evidence", { recursive: true }); + const root = await NodeFSP.realpath( + await NodeFSP.mkdtemp("/tmp/t3-native-evidence/chromium-runtime-"), + ); + const home = NodePath.join(root, "home"); + await NodeFSP.mkdir(home); + t.diagnostic("evidence: " + root); + let streaming; + const html = + 'Actual Chromium fixture
Download fixture'; + const server = NodeHttp.createServer((request, response) => { + if (request.url === "/download") { + streaming = response; + response.writeHead(200, { + "Content-Type": "application/octet-stream", + "Content-Disposition": 'attachment; filename="fixture.bin"', + "Content-Length": "50000000", + }); + response.write(Buffer.alloc(1024, 7)); + return; + } + response.writeHead(200, { "Content-Type": "text/html" }); + response.end(request.url === "/new" ? html.replace('id="target"', 'id="late"') : html); + }); + server.listen(0, "127.0.0.1"); + await NodeEvents.once(server, "listening"); + const origin = "http://127.0.0.1:" + server.address().port; + const app = await observedApp(root); + let helper = new Helper(app, home, root, "no-preference"); + try { + await helper.ready(); + await t.test("concurrent opens share one real native window", async () => { + const results = await Promise.all( + Array.from({ length: 8 }, () => helper.rpc("open", { ...context, url: origin })), + ); + NodeAssert.equal(new Set(results.map((result) => result.windowId)).size, 1); + NodeAssert.equal(results[0].visible, false); + await helper.action("navigate", { url: origin }); + await until( + () => + helper.action("evaluate", { + expression: 'matchMedia("(prefers-reduced-motion: no-preference)").matches', + }), + Boolean, + ); + t.diagnostic("finite animation fixture: actual Chromium media preference is no-preference"); + }); + await t.test( + "real scrolled clipped zoomed target receives trusted input and cursor survives PNG capture", + async () => { + t.diagnostic( + JSON.stringify( + await helper.action("evaluate", { + expression: + '({reducedMotion:matchMedia("(prefers-reduced-motion:reduce)").matches,deviceScale:devicePixelRatio,ready:document.readyState})', + }), + ), + ); + await helper.rpc("zoom", { ...context, factor: 1.5 }); + await helper.action("click", { selector: "#target" }); + const actual = await helper.action("evaluate", { + expression: + '({clicked, trusted, point:(()=>{const r=document.querySelector("#target").getBoundingClientRect(),p=document.querySelector("#target").parentElement.getBoundingClientRect();return{x:(Math.max(0,r.left,p.left)+Math.min(innerWidth,r.right,p.right))/2,y:(Math.max(0,r.top,p.top)+Math.min(innerHeight,r.bottom,p.bottom))/2}})()})', + }); + NodeAssert.equal(actual.clicked, 1); + NodeAssert.equal(actual.trusted, true); + const status = await helper.action("status"); + NodeAssert.equal(status.agentCursor.visible, true); + NodeAssert.equal(status.agentCursor.inFlight, false); + NodeAssert.ok(Math.abs(status.agentCursor.position.x - actual.point.x) < 1); + NodeAssert.ok(Math.abs(status.agentCursor.position.y - actual.point.y) < 1); + const capture = await helper.action("snapshot", {}); + NodeAssert.equal(capture.screenshot.mimeType, "image/png"); + await NodeFSP.writeFile( + NodePath.join(root, "cursor.png"), + Buffer.from(capture.screenshot.data, "base64"), + ); + NodeAssert.equal((await helper.action("status")).agentCursor.visible, true); + NodeAssert.ok( + capture.actionTimeline.some( + (action) => action.action === "click" && action.status === "succeeded", + ), + ); + }, + ); + await t.test( + "Stop during an actual finite cursor move prevents input and hides stale overlay", + async () => { + const clickedBefore = await helper.action("evaluate", { expression: "clicked" }); + const pending = helper.action("click", { selector: "#target" }).then( + () => null, + (error) => error, + ); + await until( + () => helper.action("status"), + (status) => status.agentCursor?.inFlight === true && status.agentCursor.animations > 0, + ); + await helper.rpc("stop", context); + const error = await pending; + NodeAssert.equal(error?.tag, "PreviewAutomationControlInterruptedError"); + NodeAssert.equal( + await helper.action("evaluate", { expression: "clicked" }), + clickedBefore, + ); + NodeAssert.equal((await helper.action("status")).agentCursor.visible, false); + }, + ); + await t.test("pending selector action refuses the next document", async () => { + const pending = helper.action("click", { selector: "#late" }).then( + () => null, + (error) => error, + ); + await until( + () => helper.action("status"), + (status) => status.agentCursor?.visible === true, + ); + await helper.action("navigate", { url: origin + "/new" }); + const error = await pending; + NodeAssert.equal(error?.tag, "PreviewAutomationControlInterruptedError"); + NodeAssert.equal(await helper.action("evaluate", { expression: "clicked" }), 0); + }); + await t.test( + "actual type and persistent page session survive owned-process restart", + async () => { + await helper.action("type", { selector: "#username", text: "fixture-user", clear: true }); + NodeAssert.equal( + await helper.action("evaluate", { + expression: 'document.querySelector("#username").value', + }), + "fixture-user", + ); + await helper.action("evaluate", { + expression: + 'localStorage.fixture="restart-preserved";document.cookie="fixtureSession=private; Max-Age=3600; SameSite=Lax"', + }); + await helper.stop(); + await helper.cleanup(NodePath.join(root, "first.stderr.log")); + helper = new Helper(app, home, root, "no-preference"); + await helper.ready(); + await helper.rpc("open", { ...context, url: origin }); + await helper.action("navigate", { url: origin }); + NodeAssert.equal( + await helper.action("evaluate", { expression: "localStorage.fixture" }), + "restart-preserved", + ); + NodeAssert.match( + await helper.action("evaluate", { expression: "document.cookie" }), + /fixtureSession=private/, + ); + }, + ); + await t.test( + "active real download cancellation removes partial bytes and quit stops the owned helper", + async () => { + await helper.action("click", { selector: "#download" }).catch((error) => { + if (error.tag !== "PreviewAutomationControlInterruptedError") throw error; + }); + await until( + () => helper.rpc("downloads"), + (downloads) => downloads.length === 1, + ); + await helper.rpc("cancelDownloads"); + NodeAssert.deepEqual(await helper.rpc("downloads"), []); + NodeAssert.deepEqual( + await NodeFSP.readdir( + NodePath.join(home, "Library/Application Support/T3Native/Chromium/downloads"), + ), + [], + ); + await helper.action("click", { selector: "#download" }).catch((error) => { + if (error.tag !== "PreviewAutomationControlInterruptedError") throw error; + }); + await until( + () => helper.rpc("downloads"), + (downloads) => downloads.length === 1, + ); + await helper.stop(); + NodeAssert.deepEqual( + await NodeFSP.readdir( + NodePath.join(home, "Library/Application Support/T3Native/Chromium/downloads"), + ), + [], + ); + }, + ); + await NodeFSP.writeFile( + NodePath.join(root, "result.json"), + JSON.stringify( + { + app: supplied, + protocol: true, + cursor: true, + stop: true, + documentIdentity: true, + concurrentOpen: true, + restart: true, + downloads: true, + vaultAccess: false, + }, + null, + 2, + ), + ); + } finally { + streaming?.destroy(); + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + await helper.cleanup(NodePath.join(root, "final.stderr.log")); + } + }, +); +NodeTest.test( + "official 1Password opens genuine anonymous onboarding on the first action and after restart", + { skip: !supplied || !extensionTest, timeout: 180000 }, + async (t) => { + // Never use the real user's extension/profile/host manifests. System 1Password + // manifests would allow a desktop-vault connection, so refuse this fixture. + for (const directory of [ + "/Library/Google/Chrome/NativeMessagingHosts", + "/Library/Application Support/Chromium/NativeMessagingHosts", + "/Library/Microsoft/Edge/NativeMessagingHosts", + ]) { + const files = await NodeFSP.readdir(directory).catch(() => []); + NodeAssert.ok( + !files.some((name) => /1password|agilebits|2bua8c4s2c/i.test(name)), + "Vault-capable system native host present; do not run anonymous fixture.", + ); + } + await NodeFSP.mkdir("/tmp/t3-native-evidence", { recursive: true }); + const root = await NodeFSP.realpath( + await NodeFSP.mkdtemp("/tmp/t3-native-evidence/chromium-extension-"), + ); + t.diagnostic("evidence: " + root); + const app = await observedApp(root), + home = NodePath.join(root, "home"); + await NodeFSP.mkdir(home); + let helper = new Helper(app, home, NodePath.join(root, "first")); + try { + await helper.ready(); + await helper.rpc("open", { ...context, url: "about:blank" }); + const installed = await helper.rpc("install", {}, 90000); + const password = installed.find( + (extension) => extension.id === "aeblfdkhhhdcdjpifhhbdiojplfjncoa", + ); + NodeAssert.ok(password?.enabled); + NodeAssert.equal(password.loadError, null); + await helper.rpc("popup", context); + const report = await until( + () => + NodeFSP.readFile(NodePath.join(root, "first/extension-ui.json"), "utf8") + .then(JSON.parse) + .catch(() => null), + Boolean, + 20000, + ); + NodeAssert.match(report.url, /^chrome-extension:\/\/aeblfdkhhhdcdjpifhhbdiojplfjncoa\//); + NodeAssert.equal(report.title, "1Password"); + NodeAssert.equal(report.uiReady, true); + NodeAssert.match(report.url, /app\/app.html/); + NodeAssert.match(report.text, /Welcome to 1Password/); + t.diagnostic( + "anonymous onboarding readiness: " + + Boolean(report.uiReady) + + "; isolated native host unavailable: " + + helper.errors.includes("Specified native messaging host not found"), + ); + await helper.stop(); + await helper.cleanup(NodePath.join(root, "first.stderr.log")); + helper = new Helper(app, home, NodePath.join(root, "restart")); + await helper.ready(); + await helper.rpc("open", { ...context, url: "about:blank" }); + const restored = (await helper.rpc("extensions")).find( + (extension) => extension.id === password.id, + ); + NodeAssert.equal(restored?.version, password.version); + NodeAssert.equal(restored?.loadError, null); + await helper.rpc("popup", context); + const reopened = await until( + () => + NodeFSP.readFile(NodePath.join(root, "restart/extension-ui.json"), "utf8") + .then(JSON.parse) + .catch(() => null), + Boolean, + 20000, + ); + NodeAssert.equal(reopened.title, "1Password"); + NodeAssert.equal(reopened.uiReady, true); + NodeAssert.match(reopened.url, /app\/app.html/); + NodeAssert.match(reopened.text, /Welcome to 1Password/); + await helper.stop(); + await NodeFSP.writeFile( + NodePath.join(root, "result.json"), + JSON.stringify( + { + officialExtension: password.id, + version: password.version, + onboardingDocument: true, + onboardingReady: Boolean(report.uiReady && reopened.uiReady), + restart: true, + actualVaultAutofill: + "unverified: requires user-controlled sign-in/unlock or Add Browser trust", + vaultAccess: false, + }, + null, + 2, + ), + ); + } finally { + await helper.cleanup(NodePath.join(root, "final.stderr.log")); + } + }, +); diff --git a/apps/macos/chromium-helper/shell-preload.mjs b/apps/macos/chromium-helper/shell-preload.mjs new file mode 100644 index 0000000000000..5de17e63038b6 --- /dev/null +++ b/apps/macos/chromium-helper/shell-preload.mjs @@ -0,0 +1,7 @@ +import { contextBridge, ipcRenderer } from "electron"; +contextBridge.exposeInMainWorld("t3Browser", { + command: (method, input = {}) => + ipcRenderer.invoke("t3-native-browser-toolbar", { method, input }), + onState: (callback) => + ipcRenderer.on("t3-native-browser-state", (_event, state) => callback(state)), +}); diff --git a/apps/macos/chromium-helper/shell.css b/apps/macos/chromium-helper/shell.css new file mode 100644 index 0000000000000..f7b0712a9f581 --- /dev/null +++ b/apps/macos/chromium-helper/shell.css @@ -0,0 +1,57 @@ +:root { + color-scheme: light dark; + font: + 13px -apple-system, + BlinkMacSystemFont, + sans-serif; +} +body { + margin: 0; + display: flex; + flex-direction: column; + height: 100vh; + background: Canvas; + color: CanvasText; +} +header { + display: flex; + gap: 6px; + padding: 8px; + border-bottom: 1px solid GrayText; + align-items: center; +} +button, +input { + font: inherit; + padding: 6px; + border-radius: 6px; + border: 1px solid GrayText; + background: Canvas; + color: CanvasText; +} +button { + cursor: pointer; +} +form { + display: flex; + flex: 1; + gap: 6px; +} +input { + min-width: 40px; + flex: 1; +} +#notice { + padding: 6px 10px; + font-size: 11px; + color: GrayText; +} +main { + flex: 1; + min-height: 0; +} +webview { + height: 100%; + width: 100%; + display: flex; +} diff --git a/apps/macos/chromium-helper/shell.html b/apps/macos/chromium-helper/shell.html new file mode 100644 index 0000000000000..b4dc27d6e46e9 --- /dev/null +++ b/apps/macos/chromium-helper/shell.html @@ -0,0 +1,26 @@ + + + + + + + T3 Browser · 1Password + + +
+ + + +
+
Managed Chromium companion · the live page runs here
+
+ + + diff --git a/apps/macos/chromium-helper/shell.js b/apps/macos/chromium-helper/shell.js new file mode 100644 index 0000000000000..09eaec63157eb --- /dev/null +++ b/apps/macos/chromium-helper/shell.js @@ -0,0 +1,33 @@ +const api = window.t3Browser; +const notice = document.getElementById("notice"); +const address = document.getElementById("address"); +const run = async (method, input) => { + try { + await api.command(method, input); + } catch (error) { + notice.textContent = error.message; + } +}; +for (const method of ["back", "forward", "reload", "stop", "close"]) { + document.getElementById(method).addEventListener("click", () => run(method)); +} +document.getElementById("password").addEventListener("click", () => { + const box = document.getElementById("password").getBoundingClientRect(); + run("popup", { anchor: { x: box.x, y: box.y, width: box.width, height: box.height } }); +}); +document.getElementById("navigation").addEventListener("submit", (event) => { + event.preventDefault(); + run("navigate", { url: address.value }); +}); +api.onState((state) => { + if (state.partition && !document.querySelector("webview")) { + const page = document.createElement("webview"); + page.setAttribute("partition", state.partition); + page.setAttribute("webpreferences", "contextIsolation=true,sandbox=true,nodeIntegration=false"); + page.src = state.url || "about:blank"; + document.getElementById("page").append(page); + } + if (state.url && document.activeElement !== address) address.value = state.url; + notice.textContent = state.message || "Managed Chromium companion · the live page runs here"; +}); +run("ready"); diff --git a/apps/macos/measure.py b/apps/macos/measure.py new file mode 100644 index 0000000000000..20779087caa9b --- /dev/null +++ b/apps/macos/measure.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Sample RSS and CPU time for explicit process trees without changing app state.""" +import argparse +import json +import statistics +import subprocess +import time + + +def processes(): + result = {} + output = subprocess.check_output( + ["ps", "-axo", "pid=,ppid=,rss=,time="], text=True + ) + for line in output.splitlines(): + pid, parent, rss, cpu = line.split() + seconds = 0.0 + for part in cpu.split(":"): + seconds = seconds * 60 + float(part) + result[int(pid)] = (int(parent), int(rss), seconds) + return result + + +def tree(snapshot, root): + members = {root} if root in snapshot else set() + while True: + children = {pid for pid, row in snapshot.items() if row[0] in members} + if children <= members: + return members + members |= children + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--pid", type=int, action="append", required=True) + parser.add_argument("--exclude-pid", type=int, action="append", default=[]) + parser.add_argument("--seconds", type=int, default=15) + args = parser.parse_args() + if not 1 <= args.seconds <= 60: + parser.error("--seconds must be between 1 and 60") + first = processes() + if any(pid not in first for pid in args.pid): + parser.error("Every root PID must be running") + samples = {pid: [] for pid in args.pid} + previous = first + cpu = dict.fromkeys(args.pid, 0.0) + stable = dict.fromkeys(args.pid, True) + started = time.monotonic() + for _ in range(args.seconds): + time.sleep(1) + current = processes() + if any(pid not in current for pid in args.pid): + parser.error("A root process exited during measurement") + excluded = set().union(*(tree(current, pid) for pid in args.exclude_pid)) + for root in args.pid: + members = tree(current, root) - excluded + if tree(current, root) != tree(first, root): + stable[root] = False + samples[root].append(sum(current[pid][1] for pid in members) / 1024) + # Exited children cannot contribute their final CPU tick: keep workloads idle. + cpu[root] += sum(max(0, current[pid][2] - previous.get(pid, (0, 0, 0))[2]) for pid in members) + previous = current + elapsed = time.monotonic() - started + print(json.dumps({ + "seconds": round(elapsed, 2), + "metric": "sum of resident sets; includes shared pages, not physical footprint", + "processes": [{ + "pid": pid, + "rss_mib_mean": round(statistics.mean(samples[pid]), 1), + "cpu_percent_one_core": round(cpu[pid] / elapsed * 100, 2), + "process_count": len(tree(previous, pid) - excluded), + "stable_process_tree": stable[pid], + } for pid in args.pid], + }, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/apps/macos/package-backend.mjs b/apps/macos/package-backend.mjs new file mode 100644 index 0000000000000..7fd1016a0469d --- /dev/null +++ b/apps/macos/package-backend.mjs @@ -0,0 +1,168 @@ +import * as NodeFSP from "node:fs/promises"; +import * as NodeFS from "node:fs"; +import * as NodeModule from "node:module"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; +import * as NodeCrypto from "node:crypto"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeOS from "node:os"; +import { selectCliRuntimeExternalDependencies } from "../../scripts/lib/cli-external-packages.ts"; +import { loadRepoEnv } from "../../scripts/lib/public-config.ts"; + +// oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone native packager has no Effect runtime. +const hostPlatform = NodeOS.platform(); +// oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone native packager has no Effect runtime. +const hostArch = NodeOS.arch(); + +const repo = NodePath.resolve(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), "../.."); +const bundle = NodePath.resolve(process.argv[2]); +if (hostPlatform !== "darwin" || hostArch !== "arm64" || !bundle.endsWith(".app")) { + throw new Error("Package an Apple Silicon .app bundle on macOS."); +} +if (!NodeFS.existsSync(NodePath.join(repo, "node_modules/.bin/vp"))) { + throw new Error( + "Run pnpm install --frozen-lockfile in the checkout before building the native app.", + ); +} +NodeChildProcess.execFileSync("pnpm", ["exec", "vp", "run", "--filter", "t3", "build"], { + cwd: repo, + stdio: "inherit", +}); +const resources = NodePath.join(bundle, "Contents/Resources"); +const backend = NodePath.join(resources, "backend"); +const server = NodePath.join(backend, "server"); +await NodeFSP.mkdir(NodePath.join(server, "apps/server"), { recursive: true }); +await NodeFSP.cp( + NodePath.join(repo, "apps/server/dist"), + NodePath.join(server, "apps/server/dist"), + { recursive: true }, +); +await NodeFSP.mkdir(NodePath.join(backend, "node/bin"), { recursive: true }); +await NodeFSP.cp(process.execPath, NodePath.join(backend, "node/bin/node")); +await NodeFSP.cp( + NodePath.join(NodePath.dirname(NodePath.dirname(process.execPath)), "LICENSE"), + NodePath.join(resources, "Licenses/Node-LICENSE.txt"), +); +await NodeFSP.cp( + NodePath.join(NodePath.dirname(NodePath.dirname(process.execPath)), "LICENSE"), + NodePath.join(backend, "node/LICENSE"), +); + +const packages = new Map(); +const modules = NodePath.join(server, "node_modules"); +await NodeFSP.mkdir(modules, { recursive: true }); +const resolvePackage = async (name, from) => { + const require = NodeModule.createRequire(from); + for (const search of require.resolve.paths(name) ?? []) { + const file = NodePath.join(search, name, "package.json"); + if (NodeFS.existsSync(file)) return NodeFSP.realpath(file); + } + throw new Error(`Missing runtime dependency ${name}. Run pnpm install first.`); +}; +const linkPackage = async (name, from, targetModules, optional = false) => { + let source; + try { + source = await resolvePackage(name, from); + } catch (error) { + if (optional) return; + throw error; + } + let target = packages.get(source); + if (!target) { + const metadata = JSON.parse(await NodeFSP.readFile(source, "utf8")); + const hash = NodeCrypto.createHash("sha256").update(source).digest("hex").slice(0, 12); + target = NodePath.join( + modules, + ".native", + `${name.replaceAll("/", "+")}@${metadata.version}-${hash}`, + ); + packages.set(source, target); + await NodeFSP.cp(NodePath.dirname(source), target, { + recursive: true, + dereference: true, + filter: (file) => + NodePath.relative(NodePath.dirname(source), file).split("/")[0] !== "node_modules", + }); + const children = { + ...metadata.peerDependencies, + ...metadata.dependencies, + ...metadata.optionalDependencies, + }; + for (const child of Object.keys(children)) { + await linkPackage( + child, + source, + NodePath.join(target, "node_modules"), + child in (metadata.optionalDependencies ?? {}) || + metadata.peerDependenciesMeta?.[child]?.optional === true, + ); + } + } + const link = NodePath.join(targetModules, name); + await NodeFSP.mkdir(NodePath.dirname(link), { recursive: true }); + if (!NodeFS.existsSync(link)) + await NodeFSP.symlink(NodePath.relative(NodePath.dirname(link), target), link); +}; +const manifest = NodePath.join(repo, "apps/server/package.json"); +const serverPackage = JSON.parse(await NodeFSP.readFile(manifest, "utf8")); +for (const name of Object.keys(selectCliRuntimeExternalDependencies(serverPackage.dependencies))) { + await linkPackage(name, manifest, modules); +} +const env = loadRepoEnv(); +const plist = NodePath.join(bundle, "Contents/Info.plist"); +for (const [key, name] of Object.entries({ + T3ConnectRelayURL: "T3CODE_RELAY_URL", + T3ConnectClerkPublishableKey: "T3CODE_CLERK_PUBLISHABLE_KEY", + T3ConnectOAuthClientID: "T3CODE_CLERK_CLI_OAUTH_CLIENT_ID", + T3ConnectHostedAppURL: "T3CODE_HOSTED_APP_URL", +})) { + if (env[name]) { + let exists = true; + try { + NodeChildProcess.execFileSync("/usr/libexec/PlistBuddy", ["-c", `Print :${key}`, plist], { + stdio: "pipe", + }); + } catch { + exists = false; + } + NodeChildProcess.execFileSync("/usr/libexec/PlistBuddy", [ + "-c", + exists ? `Set :${key} ${env[name]}` : `Add :${key} string ${env[name]}`, + plist, + ]); + } +} +const revision = NodeChildProcess.execFileSync( + "git", + ["-C", repo, "-c", "core.fsmonitor=false", "rev-parse", "HEAD"], + { encoding: "utf8" }, +).trim(); +await NodeFSP.writeFile( + NodePath.join(backend, "manifest.json"), + JSON.stringify( + { revision, node: process.version, arch: hostArch, externalPackages: packages.size }, + null, + 2, + ), +); +// The app signature seals nested native code after each executable gets its own signature. +const signNative = async (directory) => { + for (const entry of await NodeFSP.readdir(directory, { withFileTypes: true })) { + const file = NodePath.join(directory, entry.name); + if (entry.isDirectory()) await signNative(file); + else if (entry.isFile()) { + if ( + file.endsWith(".node") || + file.endsWith(".dylib") || + entry.name === "node" || + entry.name === "spawn-helper" + ) { + NodeChildProcess.execFileSync("codesign", ["--force", "--sign", "-", file], { + stdio: "pipe", + }); + } + } + } +}; +await signNative(backend); +console.log(`Packaged arm64 Node and server with ${packages.size} runtime dependencies`); diff --git a/apps/macos/scripts/build-ghostty.sh b/apps/macos/scripts/build-ghostty.sh new file mode 100755 index 0000000000000..d0e46944acc4b --- /dev/null +++ b/apps/macos/scripts/build-ghostty.sh @@ -0,0 +1,49 @@ +#!/bin/bash +set -euo pipefail +[[ "$(uname -m)" == arm64 ]] || { echo 'GhosttyKit requires Apple Silicon.' >&2; exit 1; } +macos_root="$(cd "$(dirname "$0")/.." && pwd)" +deps="$macos_root/.build/terminal-deps" +revision="$(cat "$macos_root/Vendor/GHOSTTY_VERSION")" +mkdir -p "$deps" +export GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.fsmonitor GIT_CONFIG_VALUE_0=false +if [[ ! -d "$deps/ghostty/.git" ]]; then + git clone --no-checkout https://github.com/Yash-Singh1/ghostty.git "$deps/ghostty" + git -C "$deps/ghostty" checkout --detach "$revision" +fi +[[ "$(git -C "$deps/ghostty" rev-parse HEAD)" == "$revision" ]] || { + echo "Check out Ghostty revision $revision in $deps/ghostty before rebuilding." >&2; exit 1; +} +[[ "${GHOSTTY_ALLOW_LOCAL_CHANGES:-0}" == 1 ]] || git -C "$deps/ghostty" diff --exit-code HEAD --quiet || { + echo 'The Ghostty source contains local changes. Preserve them before rebuilding the vendored library.' >&2; exit 1; +} +if [[ ! -x "$deps/zig/zig" ]]; then + archive="$deps/zig-aarch64-macos-0.16.0.tar.xz" + curl --fail --location https://ziglang.org/download/0.16.0/zig-aarch64-macos-0.16.0.tar.xz --output "$archive" + echo "b23d70deaa879b5c2d486ed3316f7eaa53e84acf6fc9cc747de152450d401489 $archive" | shasum -a 256 --check + mkdir -p "$deps/zig" + tar -xJf "$archive" --strip-components=1 -C "$deps/zig" +fi +[[ "$("$deps/zig/zig" version)" == 0.16.0 ]] || { echo 'Zig 0.16.0 is required.' >&2; exit 1; } +xcrun metal --version >/dev/null || { + echo 'Install the Metal compiler with: xcodebuild -downloadComponent MetalToolchain' >&2; exit 1; +} +cd "$deps/ghostty" +"$deps/zig/zig" build -Dapp-runtime=none -Demit-xcframework=true \ + -Demit-macos-app=false -Demit-exe=false -Demit-docs=false \ + -Demit-webdata=false -Demit-helpgen=false -Demit-terminfo=false \ + -Demit-termcap=false -Demit-themes=false -Doptimize=ReleaseFast \ + -Dstrip -Dsentry=false -Dxcframework-target=native -j4 +built="$deps/ghostty/macos/GhosttyKit.xcframework" +library='macos-arm64/libghostty-internal.a' +[[ "$(lipo -archs "$built/$library")" == arm64 ]] || { echo 'Expected an arm64 library.' >&2; exit 1; } +xcrun strip -S -x "$built/$library" +stage="$(mktemp -d "$macos_root/.build/ghostty-vendor.XXXXXX")" +trap 'rm -rf "$stage"' EXIT +ditto "$built" "$stage/GhosttyKit.xcframework" +python3 "$macos_root/scripts/ghostty-notices.py" "$deps/ghostty" "$stage/Ghostty-THIRD-PARTY-NOTICES.txt" +rm -rf "$macos_root/Vendor/GhosttyKit.xcframework" +mv "$stage/GhosttyKit.xcframework" "$macos_root/Vendor/" +cp "$deps/ghostty/LICENSE" "$macos_root/Licenses/Ghostty-LICENSE.txt" +cp "$stage/Ghostty-THIRD-PARTY-NOTICES.txt" "$macos_root/Licenses/" +cd "$macos_root/Vendor" +shasum -a 256 "GhosttyKit.xcframework/$library" > GHOSTTY_SHA256 diff --git a/apps/macos/scripts/ghostty-notices.py b/apps/macos/scripts/ghostty-notices.py new file mode 100644 index 0000000000000..9cb4cb19c115b --- /dev/null +++ b/apps/macos/scripts/ghostty-notices.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Collect notices from the pinned Ghostty source and its fetched Zig dependencies.""" +import os +from pathlib import Path, PurePosixPath +import re +import subprocess +import sys +import tarfile + +source, output = map(Path, sys.argv[1:]) +cache = Path(os.environ.get("ZIG_GLOBAL_CACHE_DIR", Path.home() / ".cache/zig")) / "p" +dependency = re.compile(r'\.url\s*=\s*"([^"]+)"\s*,\s*\.hash\s*=\s*"([^"]+)"') +license_name = re.compile(r"^(?:licen[cs]e|copying|copyright|notice|ofl|ftl)(?:[._-].*)?$", re.I) +pending = {} +for path in [source / "build.zig.zon", *sorted((source / "pkg").rglob("build.zig.zon"))]: + pending.update({checksum: url for url, checksum in dependency.findall(path.read_text())}) +sections = [] +visited = set() +while pending: + checksum = sorted(pending)[0] + url = pending.pop(checksum) + if checksum in visited: + continue + visited.add(checksum) + archive = cache / (checksum + ".tar.gz") + if not archive.exists(): + continue # Lazy platform dependencies were not fetched by this build. + notices = [] + with tarfile.open(archive) as package: + for member in sorted(package.getmembers(), key=lambda item: item.name): + if not member.isfile(): + continue + path = PurePosixPath(member.name) + if path.name == "build.zig.zon": + text = package.extractfile(member).read().decode() + pending.update({key: value for value, key in dependency.findall(text)}) + if license_name.fullmatch(path.name) and not path.name.endswith(".cfg"): + text = package.extractfile(member).read().decode("utf-8", errors="replace") + notices.append(f"--- {path.relative_to(path.parts[0])} ---\n{text}") + elif checksum.startswith("z2d-") and str(path).endswith("/src/z2d.zig"): + header = package.extractfile(member).read().decode().split("\n\n", 1)[0] + notices.append(header) + revision = re.search(r"z2d-([a-f0-9]{40})\.tar\.gz$", url).group(1) + # z2d's Zig package omits these files; retrieve them from that exact source revision. + for filename in ["LICENSE", "COPYING"]: + license_url = f"https://raw.githubusercontent.com/vancluever/z2d/{revision}/{filename}" + text = subprocess.run(["curl", "--fail", "--silent", "--show-error", "--location", license_url], check=True, capture_output=True).stdout.decode() + notices.append(f"--- {license_url} ---\n{text}") + elif "/dcimgui.h" in member.name and "ANT61w" in checksum: + header = package.extractfile(member).read().decode().split("// Library Version", 1)[0] + notices.append(header) + if not notices: + raise SystemExit(f"No license notice found for {url}") + sections.append(f"Source: {url}\nZig package hash: {checksum}\n\n" + "\n".join(notices)) + +sections.append((source / "vendor/nerd-fonts/LICENSE").read_text()) +header = """GhosttyKit third-party notices + +The bundled arm64 library is built from the revision in Vendor/GHOSTTY_VERSION. +Its source and the build scripts are available in https://github.com/Yash-Singh1/ghostty +and apps/macos/scripts/build-ghostty.sh. The source URLs below provide the exact +dependency versions, including the libintl and z2d sources. Build-time dependencies +and notices for optional portions of those source packages are included here too. +To modify or replace a library, edit that source dependency and rebuild GhosttyKit +with GHOSTTY_ALLOW_LOCAL_CHANGES=1, then rebuild T3 Native with apps/macos/build.sh. + +""" +output.write_text(header + "\n\n========================================\n\n".join(sections)) diff --git a/apps/macos/update-playwright.mjs b/apps/macos/update-playwright.mjs new file mode 100644 index 0000000000000..b40835ec25b66 --- /dev/null +++ b/apps/macos/update-playwright.mjs @@ -0,0 +1,37 @@ +// Extract the same pinned injected selector engine used by the Electron client. +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; +import * as NodeVM from "node:vm"; +import * as NodeCrypto from "node:crypto"; +const root = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); +const source = NodePath.resolve(process.argv[2] ?? "apps/desktop/node_modules/playwright-core"); +const pkg = JSON.parse(NodeFS.readFileSync(NodePath.resolve(source, "package.json"), "utf8")); +if (pkg.name !== "playwright-core" || pkg.version !== "1.60.0") + throw Error("Expected playwright-core 1.60.0"); +const bundle = NodeFS.readFileSync(NodePath.resolve(source, "lib/coreBundle.js"), "utf8"); +const marker = "source3 = "; +const start = bundle.indexOf(marker); +const end = bundle.indexOf(";\n }\n});", start); +if (start < 0 || end < 0) throw Error("Injected runtime was not found"); +const script = NodeVM.runInNewContext( + bundle.slice(start + marker.length, end), + Object.create(null), + { timeout: 1000 }, +); +if (typeof script !== "string" || script.length < 100000) throw Error("Invalid injected runtime"); +NodeFS.writeFileSync( + NodePath.resolve(root, "Sources/T3Native/Resources/PlaywrightInjected.js"), + script, +); +NodeFS.writeFileSync(NodePath.resolve(root, "Vendor/PLAYWRIGHT_VERSION"), pkg.version + "\n"); +NodeFS.writeFileSync( + NodePath.resolve(root, "Vendor/PLAYWRIGHT_SHA256"), + NodeCrypto.createHash("sha256").update(script).digest("hex") + + " Sources/T3Native/Resources/PlaywrightInjected.js\n", +); +for (const file of ["LICENSE", "NOTICE"]) + NodeFS.copyFileSync( + NodePath.resolve(source, file), + NodePath.resolve(root, "Licenses/playwright-" + file + ".txt"), + ); diff --git a/apps/macos/update-provider-icons.py b/apps/macos/update-provider-icons.py new file mode 100644 index 0000000000000..5d5bed8ecc25c --- /dev/null +++ b/apps/macos/update-provider-icons.py @@ -0,0 +1,43 @@ +"""Generate native vector marks from the Electron icons. Requires reportlab.""" + +import base64 +import re +from pathlib import Path + +from reportlab import rl_config +from reportlab.graphics import renderPDF +from reportlab.graphics.shapes import Drawing +from reportlab.graphics.svgpath import SvgPath +from reportlab.lib.colors import black + +rl_config.invariant = True +root = Path(__file__).resolve().parent +source = (root.parent / "web/src/components/Icons.tsx").read_text() +destination = root / "Sources/T3Native/Resources/Providers" +destination.mkdir(parents=True, exist_ok=True) + +for name, driver in [ + ("OpenAI", "codex"), ("ClaudeAI", "claudeAgent"), ("CursorIcon", "cursor"), + ("GrokIcon", "grok"), ("PiAgentIcon", "pi"), ("OpenCodeIcon", "opencode"), +]: + component = source.split("export const " + name + ":")[1] + component = component.split("\nexport const ")[0].split("\nconst ")[0] + x, y, width, height = map(float, re.search(r'viewBox="([^"]+)"', component)[1].split()) + drawing = Drawing(64, 64) + drawing.translate(-x * 64 / width, 64 + y * 64 / height) + drawing.scale(64 / width, -64 / height) + for attributes in re.findall(r"]+?)/>", component, re.S): + # OpenCode's interior is supplied by the row background when templated. + if driver == "opencode" and ("data-opencode-hole" in attributes or "dark:block" in attributes): + continue + path = re.search(r'\bd="([^"]+)"', attributes) + if path: + shape = SvgPath(path[1], fillColor=black, strokeColor=None) + shape.fillMode = 0 + drawing.add(shape) + renderPDF.drawToFile(drawing, str(destination / (driver + ".pdf"))) + +data = re.search( + r'const ANTIGRAVITY_ICON_DATA_URL\s*=\s*"data:image/png;base64,([^";]+)"', source +)[1] +(destination / "antigravity.png").write_bytes(base64.b64decode(data)) diff --git a/vite.config.ts b/vite.config.ts index 8e198d08b0f07..297143797404d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -94,6 +94,8 @@ export default defineConfig({ "pnpm-lock.yaml", "*.tsbuildinfo", "**/routeTree.gen.ts", + // Keep the pinned third-party selector engine byte-for-byte with its checksum. + "apps/macos/Sources/T3Native/Resources/PlaywrightInjected.js", "apps/mobile/android/**", "apps/mobile/ios/**", "apps/mobile/uniwind-types.d.ts", @@ -119,6 +121,7 @@ export default defineConfig({ "pnpm-lock.yaml", "*.tsbuildinfo", "**/routeTree.gen.ts", + "apps/macos/Sources/T3Native/Resources/PlaywrightInjected.js", "apps/mobile/android/**", "apps/mobile/ios/**", "apps/mobile/uniwind-types.d.ts",