diff --git a/AGENTS.md b/AGENTS.md index ff24b24d..fa472302 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -223,6 +223,10 @@ Release and transactionally installs it to `/Applications` (ad-hoc signed, no team needed). Its `--dry-run` validates and reports the replacement without building, stopping, replacing, or launching anything. +The **Daylight** camera app uses the same iOS build and test schemes. Use +[`./Daylight/install`](Daylight/install) for a physical device. Its dry run resolves +the device without generating, building, installing, or launching. + ## Per-module docs Shared modules live under `Shared/`. Feature modules live under a top-level folder diff --git a/Daylight/AGENTS.md b/Daylight/AGENTS.md new file mode 100644 index 00000000..2d457dad --- /dev/null +++ b/Daylight/AGENTS.md @@ -0,0 +1,11 @@ +# Daylight + +Read [the repository contract](../AGENTS.md). [README.md](README.md) owns setup and operation. + +Keep the app composition root above DaylightUI, DaylightMastodon, and DaylightCore. Core never imports UI or destination modules. Register publishing adapters at the root; never switch over destination names in capture code. + +Keep each RAW/JPEG pair in one Photos asset. Expose both originals to adapters until their dependent work finishes. Do not add photographic edits. + +Freeze capture settings per sequence. Persist image bytes before Photos or network side effects. Preserve ambiguous outcomes for review instead of blindly duplicating assets or posts. Never remove Photos assets. Exact location and credentials never enter social image metadata or logs. + +Run unit suites through ./test and UI snapshots through StuffSnapshotTests. Camera, Photos saving, and unattended thermal behavior require physical-device acceptance. diff --git a/Daylight/Daylight/AGENTS.md b/Daylight/Daylight/AGENTS.md new file mode 100644 index 00000000..a8bedfb1 --- /dev/null +++ b/Daylight/Daylight/AGENTS.md @@ -0,0 +1,11 @@ +# Daylight + +Read [the root contract](../../../AGENTS.md) and [Daylight](../AGENTS.md). See [README.md](README.md) for operation. + +The application composition root and bundled attribution. + +Create the camera, archive, logging system, and destination once. Inject the same instances into every consumer. Keep screen implementations in DaylightUI. + +Use ./test for unit tests. UI snapshots belong to DaylightUISnapshotTests in the shared StuffSnapshotTests scheme. Device camera and Photos checks require the physical iPhone. + +Keep publishing configuration failure isolated from capture startup. Surface adapter issues through its configuration contract. diff --git a/Daylight/Daylight/README.md b/Daylight/Daylight/README.md new file mode 100644 index 00000000..1706b309 --- /dev/null +++ b/Daylight/Daylight/README.md @@ -0,0 +1,9 @@ +# Daylight + +The application composition root and bundled attribution. [Daylight](../README.md) describes setup and operation. The root Package.swift and Project.swift define the targets. + +Create the camera, archive, logging system, and destination once. Inject the same instances into every consumer. Keep screen implementations in DaylightUI. + +Tests use injected services. The application remains disarmed on a fresh installation. Camera access and Photos access require the system permission prompts. + +Mastodon configuration failures remain isolated to publishing. They do not prevent the camera and local capture services from starting. diff --git a/Daylight/Daylight/Resources/attribution.json b/Daylight/Daylight/Resources/attribution.json new file mode 100644 index 00000000..f03971ac --- /dev/null +++ b/Daylight/Daylight/Resources/attribution.json @@ -0,0 +1,124 @@ +{ + "credits": [ + { + "name": "AccessibilitySnapshot", + "kind": "developmentTool", + "version": "0.12.0", + "homepageURL": "https://github.com/cashapp/AccessibilitySnapshot", + "license": { + "name": "Apache License 2.0", + "text": "\n Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\n TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n 1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n 2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n 3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n 4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n 5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n 6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n 7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n 8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n 9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\n END OF TERMS AND CONDITIONS\n\n APPENDIX: How to apply the Apache License to your work.\n\n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"[]\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n\n Copyright [yyyy] [name of copyright owner]\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n" + } + }, + { + "name": "capture-ios", + "kind": "developmentTool", + "version": "0.23.11", + "homepageURL": "https://github.com/bitdriftlabs/capture-ios", + "license": { + "name": "Other", + "text": "# PolyForm Shield License 1.0.0\n\n\n\n## Acceptance\n\nIn order to get any license under these terms, you must agree\nto them as both strict obligations and conditions to all\nyour licenses.\n\n## Copyright License\n\nThe licensor grants you a copyright license for the\nsoftware to do everything you might do with the software\nthat would otherwise infringe the licensor's copyright\nin it for any permitted purpose. However, you may\nonly distribute the software according to [Distribution\nLicense](#distribution-license) and make changes or new works\nbased on the software according to [Changes and New Works\nLicense](#changes-and-new-works-license).\n\n## Distribution License\n\nThe licensor grants you an additional copyright license\nto distribute copies of the software. Your license\nto distribute covers distributing the software with\nchanges and new works permitted by [Changes and New Works\nLicense](#changes-and-new-works-license).\n\n## Notices\n\nYou must ensure that anyone who gets a copy of any part of\nthe software from you also gets a copy of these terms or the\nURL for them above, as well as copies of any plain-text lines\nbeginning with `Required Notice:` that the licensor provided\nwith the software. For example:\n\n> Required Notice: Copyright Yoyodyne, Inc. (http://example.com)\n\n## Changes and New Works License\n\nThe licensor grants you an additional copyright license to\nmake changes and new works based on the software for any\npermitted purpose.\n\n## Patent License\n\nThe licensor grants you a patent license for the software that\ncovers patent claims the licensor can license, or becomes able\nto license, that you would infringe by using the software.\n\n## Noncompete\n\nAny purpose is a permitted purpose, except for providing any\nproduct that competes with the software or any product the\nlicensor or any of its affiliates provides using the software.\n\n## Competition\n\nGoods and services compete even when they provide functionality\nthrough different kinds of interfaces or for different technical\nplatforms. Applications can compete with services, libraries\nwith plugins, frameworks with development tools, and so on,\neven if they're written in different programming languages\nor for different computer architectures. Goods and services\ncompete even when provided free of charge. If you market a\nproduct as a practical substitute for the software or another\nproduct, it definitely competes.\n\n## New Products\n\nIf you are using the software to provide a product that does\nnot compete, but the licensor or any of its affiliates brings\nyour product into competition by providing a new version of\nthe software or another product using the software, you may\ncontinue using versions of the software available under these\nterms beforehand to provide your competing product, but not\nany later versions.\n\n## Discontinued Products\n\nYou may begin using the software to compete with a product\nor service that the licensor or any of its affiliates has\nstopped providing, unless the licensor includes a plain-text\nline beginning with `Licensor Line of Business:` with the\nsoftware that mentions that line of business. For example:\n\n> Licensor Line of Business: YoyodyneCMS Content Management\nSystem (http://example.com/cms)\n\n## Sales of Business\n\nIf the licensor or any of its affiliates sells a line of\nbusiness developing the software or using the software\nto provide a product, the buyer can also enforce\n[Noncompete](#noncompete) for that product.\n\n## Fair Use\n\nYou may have \"fair use\" rights for the software under the\nlaw. These terms do not limit them.\n\n## No Other Rights\n\nThese terms do not allow you to sublicense or transfer any of\nyour licenses to anyone else, or prevent the licensor from\ngranting licenses to anyone else. These terms do not imply\nany other licenses.\n\n## Patent Defense\n\nIf you make any written claim that the software infringes or\ncontributes to infringement of any patent, your patent license\nfor the software granted under these terms ends immediately. If\nyour company makes such a claim, your patent license ends\nimmediately for work on behalf of your company.\n\n## Violations\n\nThe first time you are notified in writing that you have\nviolated any of these terms, or done anything with the software\nnot covered by your licenses, your licenses can nonetheless\ncontinue if you come into full compliance with these terms,\nand take practical steps to correct past violations, within\n32 days of receiving notice. Otherwise, all your licenses\nend immediately.\n\n## No Liability\n\n***As far as the law allows, the software comes as is, without\nany warranty or condition, and the licensor will not be liable\nto you for any damages arising out of these terms or the use\nor nature of the software, under any kind of legal claim.***\n\n## Definitions\n\nThe **licensor** is the individual or entity offering these\nterms, and the **software** is the software the licensor makes\navailable under these terms.\n\nA **product** can be a good or service, or a combination\nof them.\n\n**You** refers to the individual or entity agreeing to these\nterms.\n\n**Your company** is any legal entity, sole proprietorship,\nor other kind of organization that you work for, plus all\nits affiliates.\n\n**Affiliates** means the other organizations than an\norganization has control over, is under the control of, or is\nunder common control with.\n\n**Control** means ownership of substantially all the assets of\nan entity, or the power to direct its management and policies\nby vote, contract, or otherwise. Control can be direct or\nindirect.\n\n**Your licenses** are all the licenses granted to you for the\nsoftware under these terms.\n\n**Use** means anything you do with the software requiring one\nof your licenses.\n" + } + }, + { + "name": "SFSafeSymbols", + "kind": "developmentTool", + "version": "7.0.0", + "homepageURL": "https://github.com/SFSafeSymbols/SFSafeSymbols", + "license": { + "name": "MIT License", + "text": "The MIT License (MIT)\n\nCopyright (c) 2021 SFSafeSymbols\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n" + } + }, + { + "name": "swift-snapshot-testing", + "kind": "developmentTool", + "version": "1.19.3", + "homepageURL": "https://github.com/pointfreeco/swift-snapshot-testing", + "license": { + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) 2019 Point-Free, Inc.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n" + } + }, + { + "name": "ZIPFoundation", + "kind": "developmentTool", + "version": "0.9.20", + "homepageURL": "https://github.com/weichsel/ZIPFoundation", + "license": { + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) 2017-2025 Thomas Zoechling (https://www.peakstep.com)\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n" + } + }, + { + "name": "simple-english", + "kind": "developmentTool", + "version": "59bf6702197a", + "homepageURL": "https://github.com/AminBlg/SimpleEnglish", + "license": { + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) 2026 AminBlg\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n" + } + }, + { + "name": "swift-concurrency-pro", + "kind": "developmentTool", + "version": "e710f8d577cc", + "homepageURL": "https://github.com/twostraws/Swift-Concurrency-Agent-Skill", + "license": { + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) 2026 Paul Hudson.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE." + } + }, + { + "name": "swift-testing-pro", + "kind": "developmentTool", + "version": "2d6bba14a3c8", + "homepageURL": "https://github.com/twostraws/Swift-Testing-Agent-Skill", + "license": { + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) 2026 Paul Hudson.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE." + } + }, + { + "name": "swiftdata-pro", + "kind": "developmentTool", + "version": "922d989473a9", + "homepageURL": "https://github.com/twostraws/SwiftData-Agent-Skill", + "license": { + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) 2026 Paul Hudson.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE." + } + }, + { + "name": "swiftui-pro", + "kind": "developmentTool", + "version": "61b74001b64b", + "homepageURL": "https://github.com/twostraws/swiftui-agent-skill", + "license": { + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) 2026 Paul Hudson.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE." + } + }, + { + "name": "ShellCheck", + "kind": "developmentTool", + "version": "0.11.0", + "homepageURL": "https://github.com/koalaman/shellcheck", + "license": { + "name": "GNU General Public License v3.0", + "text": " GNU GENERAL PUBLIC LICENSE\n Version 3, 29 June 2007\n\n Copyright (C) 2007 Free Software Foundation, Inc. \n Everyone is permitted to copy and distribute verbatim copies\n of this license document, but changing it is not allowed.\n\n Preamble\n\n The GNU General Public License is a free, copyleft license for\nsoftware and other kinds of works.\n\n The licenses for most software and other practical works are designed\nto take away your freedom to share and change the works. By contrast,\nthe GNU General Public License is intended to guarantee your freedom to\nshare and change all versions of a program--to make sure it remains free\nsoftware for all its users. We, the Free Software Foundation, use the\nGNU General Public License for most of our software; it applies also to\nany other work released this way by its authors. You can apply it to\nyour programs, too.\n\n When we speak of free software, we are referring to freedom, not\nprice. Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthem if you wish), that you receive source code or can get it if you\nwant it, that you can change the software or use pieces of it in new\nfree programs, and that you know you can do these things.\n\n To protect your rights, we need to prevent others from denying you\nthese rights or asking you to surrender the rights. Therefore, you have\ncertain responsibilities if you distribute copies of the software, or if\nyou modify it: responsibilities to respect the freedom of others.\n\n For example, if you distribute copies of such a program, whether\ngratis or for a fee, you must pass on to the recipients the same\nfreedoms that you received. You must make sure that they, too, receive\nor can get the source code. And you must show them these terms so they\nknow their rights.\n\n Developers that use the GNU GPL protect your rights with two steps:\n(1) assert copyright on the software, and (2) offer you this License\ngiving you legal permission to copy, distribute and/or modify it.\n\n For the developers' and authors' protection, the GPL clearly explains\nthat there is no warranty for this free software. For both users' and\nauthors' sake, the GPL requires that modified versions be marked as\nchanged, so that their problems will not be attributed erroneously to\nauthors of previous versions.\n\n Some devices are designed to deny users access to install or run\nmodified versions of the software inside them, although the manufacturer\ncan do so. This is fundamentally incompatible with the aim of\nprotecting users' freedom to change the software. The systematic\npattern of such abuse occurs in the area of products for individuals to\nuse, which is precisely where it is most unacceptable. Therefore, we\nhave designed this version of the GPL to prohibit the practice for those\nproducts. If such problems arise substantially in other domains, we\nstand ready to extend this provision to those domains in future versions\nof the GPL, as needed to protect the freedom of users.\n\n Finally, every program is threatened constantly by software patents.\nStates should not allow patents to restrict development and use of\nsoftware on general-purpose computers, but in those that do, we wish to\navoid the special danger that patents applied to a free program could\nmake it effectively proprietary. To prevent this, the GPL assures that\npatents cannot be used to render the program non-free.\n\n The precise terms and conditions for copying, distribution and\nmodification follow.\n\n TERMS AND CONDITIONS\n\n 0. Definitions.\n\n \"This License\" refers to version 3 of the GNU General Public License.\n\n \"Copyright\" also means copyright-like laws that apply to other kinds of\nworks, such as semiconductor masks.\n\n \"The Program\" refers to any copyrightable work licensed under this\nLicense. Each licensee is addressed as \"you\". \"Licensees\" and\n\"recipients\" may be individuals or organizations.\n\n To \"modify\" a work means to copy from or adapt all or part of the work\nin a fashion requiring copyright permission, other than the making of an\nexact copy. The resulting work is called a \"modified version\" of the\nearlier work or a work \"based on\" the earlier work.\n\n A \"covered work\" means either the unmodified Program or a work based\non the Program.\n\n To \"propagate\" a work means to do anything with it that, without\npermission, would make you directly or secondarily liable for\ninfringement under applicable copyright law, except executing it on a\ncomputer or modifying a private copy. Propagation includes copying,\ndistribution (with or without modification), making available to the\npublic, and in some countries other activities as well.\n\n To \"convey\" a work means any kind of propagation that enables other\nparties to make or receive copies. Mere interaction with a user through\na computer network, with no transfer of a copy, is not conveying.\n\n An interactive user interface displays \"Appropriate Legal Notices\"\nto the extent that it includes a convenient and prominently visible\nfeature that (1) displays an appropriate copyright notice, and (2)\ntells the user that there is no warranty for the work (except to the\nextent that warranties are provided), that licensees may convey the\nwork under this License, and how to view a copy of this License. If\nthe interface presents a list of user commands or options, such as a\nmenu, a prominent item in the list meets this criterion.\n\n 1. Source Code.\n\n The \"source code\" for a work means the preferred form of the work\nfor making modifications to it. \"Object code\" means any non-source\nform of a work.\n\n A \"Standard Interface\" means an interface that either is an official\nstandard defined by a recognized standards body, or, in the case of\ninterfaces specified for a particular programming language, one that\nis widely used among developers working in that language.\n\n The \"System Libraries\" of an executable work include anything, other\nthan the work as a whole, that (a) is included in the normal form of\npackaging a Major Component, but which is not part of that Major\nComponent, and (b) serves only to enable use of the work with that\nMajor Component, or to implement a Standard Interface for which an\nimplementation is available to the public in source code form. A\n\"Major Component\", in this context, means a major essential component\n(kernel, window system, and so on) of the specific operating system\n(if any) on which the executable work runs, or a compiler used to\nproduce the work, or an object code interpreter used to run it.\n\n The \"Corresponding Source\" for a work in object code form means all\nthe source code needed to generate, install, and (for an executable\nwork) run the object code and to modify the work, including scripts to\ncontrol those activities. However, it does not include the work's\nSystem Libraries, or general-purpose tools or generally available free\nprograms which are used unmodified in performing those activities but\nwhich are not part of the work. For example, Corresponding Source\nincludes interface definition files associated with source files for\nthe work, and the source code for shared libraries and dynamically\nlinked subprograms that the work is specifically designed to require,\nsuch as by intimate data communication or control flow between those\nsubprograms and other parts of the work.\n\n The Corresponding Source need not include anything that users\ncan regenerate automatically from other parts of the Corresponding\nSource.\n\n The Corresponding Source for a work in source code form is that\nsame work.\n\n 2. Basic Permissions.\n\n All rights granted under this License are granted for the term of\ncopyright on the Program, and are irrevocable provided the stated\nconditions are met. This License explicitly affirms your unlimited\npermission to run the unmodified Program. The output from running a\ncovered work is covered by this License only if the output, given its\ncontent, constitutes a covered work. This License acknowledges your\nrights of fair use or other equivalent, as provided by copyright law.\n\n You may make, run and propagate covered works that you do not\nconvey, without conditions so long as your license otherwise remains\nin force. You may convey covered works to others for the sole purpose\nof having them make modifications exclusively for you, or provide you\nwith facilities for running those works, provided that you comply with\nthe terms of this License in conveying all material for which you do\nnot control copyright. Those thus making or running the covered works\nfor you must do so exclusively on your behalf, under your direction\nand control, on terms that prohibit them from making any copies of\nyour copyrighted material outside their relationship with you.\n\n Conveying under any other circumstances is permitted solely under\nthe conditions stated below. Sublicensing is not allowed; section 10\nmakes it unnecessary.\n\n 3. Protecting Users' Legal Rights From Anti-Circumvention Law.\n\n No covered work shall be deemed part of an effective technological\nmeasure under any applicable law fulfilling obligations under article\n11 of the WIPO copyright treaty adopted on 20 December 1996, or\nsimilar laws prohibiting or restricting circumvention of such\nmeasures.\n\n When you convey a covered work, you waive any legal power to forbid\ncircumvention of technological measures to the extent such circumvention\nis effected by exercising rights under this License with respect to\nthe covered work, and you disclaim any intention to limit operation or\nmodification of the work as a means of enforcing, against the work's\nusers, your or third parties' legal rights to forbid circumvention of\ntechnological measures.\n\n 4. Conveying Verbatim Copies.\n\n You may convey verbatim copies of the Program's source code as you\nreceive it, in any medium, provided that you conspicuously and\nappropriately publish on each copy an appropriate copyright notice;\nkeep intact all notices stating that this License and any\nnon-permissive terms added in accord with section 7 apply to the code;\nkeep intact all notices of the absence of any warranty; and give all\nrecipients a copy of this License along with the Program.\n\n You may charge any price or no price for each copy that you convey,\nand you may offer support or warranty protection for a fee.\n\n 5. Conveying Modified Source Versions.\n\n You may convey a work based on the Program, or the modifications to\nproduce it from the Program, in the form of source code under the\nterms of section 4, provided that you also meet all of these conditions:\n\n a) The work must carry prominent notices stating that you modified\n it, and giving a relevant date.\n\n b) The work must carry prominent notices stating that it is\n released under this License and any conditions added under section\n 7. This requirement modifies the requirement in section 4 to\n \"keep intact all notices\".\n\n c) You must license the entire work, as a whole, under this\n License to anyone who comes into possession of a copy. This\n License will therefore apply, along with any applicable section 7\n additional terms, to the whole of the work, and all its parts,\n regardless of how they are packaged. This License gives no\n permission to license the work in any other way, but it does not\n invalidate such permission if you have separately received it.\n\n d) If the work has interactive user interfaces, each must display\n Appropriate Legal Notices; however, if the Program has interactive\n interfaces that do not display Appropriate Legal Notices, your\n work need not make them do so.\n\n A compilation of a covered work with other separate and independent\nworks, which are not by their nature extensions of the covered work,\nand which are not combined with it such as to form a larger program,\nin or on a volume of a storage or distribution medium, is called an\n\"aggregate\" if the compilation and its resulting copyright are not\nused to limit the access or legal rights of the compilation's users\nbeyond what the individual works permit. Inclusion of a covered work\nin an aggregate does not cause this License to apply to the other\nparts of the aggregate.\n\n 6. Conveying Non-Source Forms.\n\n You may convey a covered work in object code form under the terms\nof sections 4 and 5, provided that you also convey the\nmachine-readable Corresponding Source under the terms of this License,\nin one of these ways:\n\n a) Convey the object code in, or embodied in, a physical product\n (including a physical distribution medium), accompanied by the\n Corresponding Source fixed on a durable physical medium\n customarily used for software interchange.\n\n b) Convey the object code in, or embodied in, a physical product\n (including a physical distribution medium), accompanied by a\n written offer, valid for at least three years and valid for as\n long as you offer spare parts or customer support for that product\n model, to give anyone who possesses the object code either (1) a\n copy of the Corresponding Source for all the software in the\n product that is covered by this License, on a durable physical\n medium customarily used for software interchange, for a price no\n more than your reasonable cost of physically performing this\n conveying of source, or (2) access to copy the\n Corresponding Source from a network server at no charge.\n\n c) Convey individual copies of the object code with a copy of the\n written offer to provide the Corresponding Source. This\n alternative is allowed only occasionally and noncommercially, and\n only if you received the object code with such an offer, in accord\n with subsection 6b.\n\n d) Convey the object code by offering access from a designated\n place (gratis or for a charge), and offer equivalent access to the\n Corresponding Source in the same way through the same place at no\n further charge. You need not require recipients to copy the\n Corresponding Source along with the object code. If the place to\n copy the object code is a network server, the Corresponding Source\n may be on a different server (operated by you or a third party)\n that supports equivalent copying facilities, provided you maintain\n clear directions next to the object code saying where to find the\n Corresponding Source. Regardless of what server hosts the\n Corresponding Source, you remain obligated to ensure that it is\n available for as long as needed to satisfy these requirements.\n\n e) Convey the object code using peer-to-peer transmission, provided\n you inform other peers where the object code and Corresponding\n Source of the work are being offered to the general public at no\n charge under subsection 6d.\n\n A separable portion of the object code, whose source code is excluded\nfrom the Corresponding Source as a System Library, need not be\nincluded in conveying the object code work.\n\n A \"User Product\" is either (1) a \"consumer product\", which means any\ntangible personal property which is normally used for personal, family,\nor household purposes, or (2) anything designed or sold for incorporation\ninto a dwelling. In determining whether a product is a consumer product,\ndoubtful cases shall be resolved in favor of coverage. For a particular\nproduct received by a particular user, \"normally used\" refers to a\ntypical or common use of that class of product, regardless of the status\nof the particular user or of the way in which the particular user\nactually uses, or expects or is expected to use, the product. A product\nis a consumer product regardless of whether the product has substantial\ncommercial, industrial or non-consumer uses, unless such uses represent\nthe only significant mode of use of the product.\n\n \"Installation Information\" for a User Product means any methods,\nprocedures, authorization keys, or other information required to install\nand execute modified versions of a covered work in that User Product from\na modified version of its Corresponding Source. The information must\nsuffice to ensure that the continued functioning of the modified object\ncode is in no case prevented or interfered with solely because\nmodification has been made.\n\n If you convey an object code work under this section in, or with, or\nspecifically for use in, a User Product, and the conveying occurs as\npart of a transaction in which the right of possession and use of the\nUser Product is transferred to the recipient in perpetuity or for a\nfixed term (regardless of how the transaction is characterized), the\nCorresponding Source conveyed under this section must be accompanied\nby the Installation Information. But this requirement does not apply\nif neither you nor any third party retains the ability to install\nmodified object code on the User Product (for example, the work has\nbeen installed in ROM).\n\n The requirement to provide Installation Information does not include a\nrequirement to continue to provide support service, warranty, or updates\nfor a work that has been modified or installed by the recipient, or for\nthe User Product in which it has been modified or installed. Access to a\nnetwork may be denied when the modification itself materially and\nadversely affects the operation of the network or violates the rules and\nprotocols for communication across the network.\n\n Corresponding Source conveyed, and Installation Information provided,\nin accord with this section must be in a format that is publicly\ndocumented (and with an implementation available to the public in\nsource code form), and must require no special password or key for\nunpacking, reading or copying.\n\n 7. Additional Terms.\n\n \"Additional permissions\" are terms that supplement the terms of this\nLicense by making exceptions from one or more of its conditions.\nAdditional permissions that are applicable to the entire Program shall\nbe treated as though they were included in this License, to the extent\nthat they are valid under applicable law. If additional permissions\napply only to part of the Program, that part may be used separately\nunder those permissions, but the entire Program remains governed by\nthis License without regard to the additional permissions.\n\n When you convey a copy of a covered work, you may at your option\nremove any additional permissions from that copy, or from any part of\nit. (Additional permissions may be written to require their own\nremoval in certain cases when you modify the work.) You may place\nadditional permissions on material, added by you to a covered work,\nfor which you have or can give appropriate copyright permission.\n\n Notwithstanding any other provision of this License, for material you\nadd to a covered work, you may (if authorized by the copyright holders of\nthat material) supplement the terms of this License with terms:\n\n a) Disclaiming warranty or limiting liability differently from the\n terms of sections 15 and 16 of this License; or\n\n b) Requiring preservation of specified reasonable legal notices or\n author attributions in that material or in the Appropriate Legal\n Notices displayed by works containing it; or\n\n c) Prohibiting misrepresentation of the origin of that material, or\n requiring that modified versions of such material be marked in\n reasonable ways as different from the original version; or\n\n d) Limiting the use for publicity purposes of names of licensors or\n authors of the material; or\n\n e) Declining to grant rights under trademark law for use of some\n trade names, trademarks, or service marks; or\n\n f) Requiring indemnification of licensors and authors of that\n material by anyone who conveys the material (or modified versions of\n it) with contractual assumptions of liability to the recipient, for\n any liability that these contractual assumptions directly impose on\n those licensors and authors.\n\n All other non-permissive additional terms are considered \"further\nrestrictions\" within the meaning of section 10. If the Program as you\nreceived it, or any part of it, contains a notice stating that it is\ngoverned by this License along with a term that is a further\nrestriction, you may remove that term. If a license document contains\na further restriction but permits relicensing or conveying under this\nLicense, you may add to a covered work material governed by the terms\nof that license document, provided that the further restriction does\nnot survive such relicensing or conveying.\n\n If you add terms to a covered work in accord with this section, you\nmust place, in the relevant source files, a statement of the\nadditional terms that apply to those files, or a notice indicating\nwhere to find the applicable terms.\n\n Additional terms, permissive or non-permissive, may be stated in the\nform of a separately written license, or stated as exceptions;\nthe above requirements apply either way.\n\n 8. Termination.\n\n You may not propagate or modify a covered work except as expressly\nprovided under this License. Any attempt otherwise to propagate or\nmodify it is void, and will automatically terminate your rights under\nthis License (including any patent licenses granted under the third\nparagraph of section 11).\n\n However, if you cease all violation of this License, then your\nlicense from a particular copyright holder is reinstated (a)\nprovisionally, unless and until the copyright holder explicitly and\nfinally terminates your license, and (b) permanently, if the copyright\nholder fails to notify you of the violation by some reasonable means\nprior to 60 days after the cessation.\n\n Moreover, your license from a particular copyright holder is\nreinstated permanently if the copyright holder notifies you of the\nviolation by some reasonable means, this is the first time you have\nreceived notice of violation of this License (for any work) from that\ncopyright holder, and you cure the violation prior to 30 days after\nyour receipt of the notice.\n\n Termination of your rights under this section does not terminate the\nlicenses of parties who have received copies or rights from you under\nthis License. If your rights have been terminated and not permanently\nreinstated, you do not qualify to receive new licenses for the same\nmaterial under section 10.\n\n 9. Acceptance Not Required for Having Copies.\n\n You are not required to accept this License in order to receive or\nrun a copy of the Program. Ancillary propagation of a covered work\noccurring solely as a consequence of using peer-to-peer transmission\nto receive a copy likewise does not require acceptance. However,\nnothing other than this License grants you permission to propagate or\nmodify any covered work. These actions infringe copyright if you do\nnot accept this License. Therefore, by modifying or propagating a\ncovered work, you indicate your acceptance of this License to do so.\n\n 10. Automatic Licensing of Downstream Recipients.\n\n Each time you convey a covered work, the recipient automatically\nreceives a license from the original licensors, to run, modify and\npropagate that work, subject to this License. You are not responsible\nfor enforcing compliance by third parties with this License.\n\n An \"entity transaction\" is a transaction transferring control of an\norganization, or substantially all assets of one, or subdividing an\norganization, or merging organizations. If propagation of a covered\nwork results from an entity transaction, each party to that\ntransaction who receives a copy of the work also receives whatever\nlicenses to the work the party's predecessor in interest had or could\ngive under the previous paragraph, plus a right to possession of the\nCorresponding Source of the work from the predecessor in interest, if\nthe predecessor has it or can get it with reasonable efforts.\n\n You may not impose any further restrictions on the exercise of the\nrights granted or affirmed under this License. For example, you may\nnot impose a license fee, royalty, or other charge for exercise of\nrights granted under this License, and you may not initiate litigation\n(including a cross-claim or counterclaim in a lawsuit) alleging that\nany patent claim is infringed by making, using, selling, offering for\nsale, or importing the Program or any portion of it.\n\n 11. Patents.\n\n A \"contributor\" is a copyright holder who authorizes use under this\nLicense of the Program or a work on which the Program is based. The\nwork thus licensed is called the contributor's \"contributor version\".\n\n A contributor's \"essential patent claims\" are all patent claims\nowned or controlled by the contributor, whether already acquired or\nhereafter acquired, that would be infringed by some manner, permitted\nby this License, of making, using, or selling its contributor version,\nbut do not include claims that would be infringed only as a\nconsequence of further modification of the contributor version. For\npurposes of this definition, \"control\" includes the right to grant\npatent sublicenses in a manner consistent with the requirements of\nthis License.\n\n Each contributor grants you a non-exclusive, worldwide, royalty-free\npatent license under the contributor's essential patent claims, to\nmake, use, sell, offer for sale, import and otherwise run, modify and\npropagate the contents of its contributor version.\n\n In the following three paragraphs, a \"patent license\" is any express\nagreement or commitment, however denominated, not to enforce a patent\n(such as an express permission to practice a patent or covenant not to\nsue for patent infringement). To \"grant\" such a patent license to a\nparty means to make such an agreement or commitment not to enforce a\npatent against the party.\n\n If you convey a covered work, knowingly relying on a patent license,\nand the Corresponding Source of the work is not available for anyone\nto copy, free of charge and under the terms of this License, through a\npublicly available network server or other readily accessible means,\nthen you must either (1) cause the Corresponding Source to be so\navailable, or (2) arrange to deprive yourself of the benefit of the\npatent license for this particular work, or (3) arrange, in a manner\nconsistent with the requirements of this License, to extend the patent\nlicense to downstream recipients. \"Knowingly relying\" means you have\nactual knowledge that, but for the patent license, your conveying the\ncovered work in a country, or your recipient's use of the covered work\nin a country, would infringe one or more identifiable patents in that\ncountry that you have reason to believe are valid.\n\n If, pursuant to or in connection with a single transaction or\narrangement, you convey, or propagate by procuring conveyance of, a\ncovered work, and grant a patent license to some of the parties\nreceiving the covered work authorizing them to use, propagate, modify\nor convey a specific copy of the covered work, then the patent license\nyou grant is automatically extended to all recipients of the covered\nwork and works based on it.\n\n A patent license is \"discriminatory\" if it does not include within\nthe scope of its coverage, prohibits the exercise of, or is\nconditioned on the non-exercise of one or more of the rights that are\nspecifically granted under this License. You may not convey a covered\nwork if you are a party to an arrangement with a third party that is\nin the business of distributing software, under which you make payment\nto the third party based on the extent of your activity of conveying\nthe work, and under which the third party grants, to any of the\nparties who would receive the covered work from you, a discriminatory\npatent license (a) in connection with copies of the covered work\nconveyed by you (or copies made from those copies), or (b) primarily\nfor and in connection with specific products or compilations that\ncontain the covered work, unless you entered into that arrangement,\nor that patent license was granted, prior to 28 March 2007.\n\n Nothing in this License shall be construed as excluding or limiting\nany implied license or other defenses to infringement that may\notherwise be available to you under applicable patent law.\n\n 12. No Surrender of Others' Freedom.\n\n If conditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License. If you cannot convey a\ncovered work so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you may\nnot convey it at all. For example, if you agree to terms that obligate you\nto collect a royalty for further conveying from those to whom you convey\nthe Program, the only way you could satisfy both those terms and this\nLicense would be to refrain entirely from conveying the Program.\n\n 13. Use with the GNU Affero General Public License.\n\n Notwithstanding any other provision of this License, you have\npermission to link or combine any covered work with a work licensed\nunder version 3 of the GNU Affero General Public License into a single\ncombined work, and to convey the resulting work. The terms of this\nLicense will continue to apply to the part which is the covered work,\nbut the special requirements of the GNU Affero General Public License,\nsection 13, concerning interaction through a network will apply to the\ncombination as such.\n\n 14. Revised Versions of this License.\n\n The Free Software Foundation may publish revised and/or new versions of\nthe GNU General Public License from time to time. Such new versions will\nbe similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\n Each version is given a distinguishing version number. If the\nProgram specifies that a certain numbered version of the GNU General\nPublic License \"or any later version\" applies to it, you have the\noption of following the terms and conditions either of that numbered\nversion or of any later version published by the Free Software\nFoundation. If the Program does not specify a version number of the\nGNU General Public License, you may choose any version ever published\nby the Free Software Foundation.\n\n If the Program specifies that a proxy can decide which future\nversions of the GNU General Public License can be used, that proxy's\npublic statement of acceptance of a version permanently authorizes you\nto choose that version for the Program.\n\n Later license versions may give you additional or different\npermissions. However, no additional obligations are imposed on any\nauthor or copyright holder as a result of your choosing to follow a\nlater version.\n\n 15. Disclaimer of Warranty.\n\n THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY\nAPPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT\nHOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY\nOF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,\nTHE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\nPURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM\nIS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF\nALL NECESSARY SERVICING, REPAIR OR CORRECTION.\n\n 16. Limitation of Liability.\n\n IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS\nTHE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY\nGENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE\nUSE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF\nDATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD\nPARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),\nEVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF\nSUCH DAMAGES.\n\n 17. Interpretation of Sections 15 and 16.\n\n If the disclaimer of warranty and limitation of liability provided\nabove cannot be given local legal effect according to their terms,\nreviewing courts shall apply local law that most closely approximates\nan absolute waiver of all civil liability in connection with the\nProgram, unless a warranty or assumption of liability accompanies a\ncopy of the Program in return for a fee.\n\n END OF TERMS AND CONDITIONS\n\n How to Apply These Terms to Your New Programs\n\n If you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\n To do so, attach the following notices to the program. It is safest\nto attach them to the start of each source file to most effectively\nstate the exclusion of warranty; and each file should have at least\nthe \"copyright\" line and a pointer to where the full notice is found.\n\n \n Copyright (C) \n\n This program is free software: you can redistribute it and/or modify\n it under the terms of the GNU General Public License as published by\n the Free Software Foundation, either version 3 of the License, or\n (at your option) any later version.\n\n This program is distributed in the hope that it will be useful,\n but WITHOUT ANY WARRANTY; without even the implied warranty of\n MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n GNU General Public License for more details.\n\n You should have received a copy of the GNU General Public License\n along with this program. If not, see .\n\nAlso add information on how to contact you by electronic and paper mail.\n\n If the program does terminal interaction, make it output a short\nnotice like this when it starts in an interactive mode:\n\n Copyright (C) \n This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.\n This is free software, and you are welcome to redistribute it\n under certain conditions; type `show c' for details.\n\nThe hypothetical commands `show w' and `show c' should show the appropriate\nparts of the General Public License. Of course, your program's commands\nmight be different; for a GUI interface, you would use an \"about box\".\n\n You should also get your employer (if you work as a programmer) or school,\nif any, to sign a \"copyright disclaimer\" for the program, if necessary.\nFor more information on this, and how to apply and follow the GNU GPL, see\n.\n\n The GNU General Public License does not permit incorporating your program\ninto proprietary programs. If your program is a subroutine library, you\nmay consider it more useful to permit linking proprietary applications with\nthe library. If this is what you want to do, use the GNU Lesser General\nPublic License instead of this License. But first, please read\n.\n" + } + }, + { + "name": "TLA+ Tools", + "kind": "developmentTool", + "version": "1.7.4", + "homepageURL": "https://github.com/tlaplus/tlaplus", + "license": { + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) 2017 Microsoft Corporation\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n\n" + } + } + ] +} diff --git a/Daylight/Daylight/Sources/DaylightApp.swift b/Daylight/Daylight/Sources/DaylightApp.swift new file mode 100644 index 00000000..bd05d9fc --- /dev/null +++ b/Daylight/Daylight/Sources/DaylightApp.swift @@ -0,0 +1,63 @@ +import DaylightCore +import DaylightMastodon +import DaylightUI +import PeriscopeCore +import SwiftUI + +@main +struct DaylightApp: App { + private let startup: Result + init() { + do { + let root = try FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true, + ).appendingPathComponent("Daylight", isDirectory: true) + let store = try CaptureStore(root: root) + let camera = CameraService() + let photos = PhotosLibrary() + let mastodon = MastodonDestination( + settingsURL: root.appendingPathComponent("mastodon.json"), + transport: URLSessionTransport(), + credentials: KeychainMastodonCredentials(), + now: { Date() }, + uptime: { ProcessInfo.processInfo.systemUptime }, + ) + let logging = Periscope( + configuration: .init(), + sinks: [OSLogSink(subsystem: "com.stuff.daylight")], + ) + let engine = CaptureEngine( + store: store, + camera: camera, + solar: SolarCalculator(), + photos: photos, + scorer: VisionImageScorer(), + destinations: [mastodon], + log: Log(system: logging), + now: { Date() }, + ) + startup = .success(DaylightModel( + engine: engine, + camera: camera, + photos: photos, + mastodon: mastodon, + readiness: SystemCaptureReadiness(), + )) + } catch { startup = .failure(error) } + } + + var body: some Scene { + WindowGroup { + switch startup { + case let .success(model): DaylightRootView(model: model) + case let .failure(error): + VStack { + Text("Daylight").font(.title); Text(error.localizedDescription).padding() + } + } + } + } +} diff --git a/Daylight/Daylight/Tests/README.md b/Daylight/Daylight/Tests/README.md new file mode 100644 index 00000000..4a20b2c9 --- /dev/null +++ b/Daylight/Daylight/Tests/README.md @@ -0,0 +1,16 @@ +# Device acceptance + +The root composition is built by Stuff-iOS-Tests. Core, adapter, and UI tests exercise the injected services. + +1. Install with `./Daylight/install --device `. +2. Allow Camera and Photos access. +3. Take a test photo. +4. Open the image in Photos. On a supported lens, confirm the asset contains RAW and JPEG resources. +5. Check the saved image orientation and full capture resolution. +6. Take another test shot and check that it creates exactly one new Photos asset. +7. On the eventual camera phone, run one complete sunrise and sunset cycle. +8. Check 13 scheduled photos per event, heat behavior, and screen restoration. +9. Disconnect Wi-Fi during a sequence. +10. Restore Wi-Fi and check that exactly one selected image posts. + +The final cycle requires the mounted camera phone and a configured Mastodon account. Simulator tests cannot prove these hardware behaviors. diff --git a/Daylight/Daylight/attribution-sources.json b/Daylight/Daylight/attribution-sources.json new file mode 100644 index 00000000..518b4559 --- /dev/null +++ b/Daylight/Daylight/attribution-sources.json @@ -0,0 +1,23 @@ +{ + "output": "Daylight/Daylight/Resources/attribution.json", + "sources": [ + { + "type": "swiftPackageManager", + "manifest": "Package.swift", + "resolved": "Package.resolved", + "shippedFrom": [ + "DaylightUI" + ] + }, + { + "type": "agentSkills", + "kind": "developmentTool", + "manifest": ".agents/external-skills.json" + }, + { + "type": "developmentTools", + "kind": "developmentTool", + "manifest": ".agents/development-tools.json" + } + ] +} diff --git a/Daylight/DaylightCore/AGENTS.md b/Daylight/DaylightCore/AGENTS.md new file mode 100644 index 00000000..f6420d46 --- /dev/null +++ b/Daylight/DaylightCore/AGENTS.md @@ -0,0 +1,11 @@ +# DaylightCore + +Read [the root contract](../../../AGENTS.md) and [Daylight](../AGENTS.md). See [README.md](README.md) for API and behavior. + +Keep domain and service code independent of UI, Where, and Ledger. Create collaborators once in the app root and inject them. Persist transitions before external side effects. Keep staged images until all consumers finish. Use 1:1 Swift Testing files and injected protocol implementations. + +Preserve pre-RAW capture records with absent format and delivery checkpoints. Keep saved Photos identifiers intact; see CapturedImageTests and ManualCaptureServiceTests. + +Persist highlight deliveries before marking selection complete. Cleanup must not observe a selected image whose deliveries are still being registered. + +Treat only PhotosSaveFailure as proof that no asset was created. Preserve uncertain-save receipts. Require confirmed absence before replacing uncertain work. Keep delivery IDs and prior checkpoints through recovery. diff --git a/Daylight/DaylightCore/README.md b/Daylight/DaylightCore/README.md new file mode 100644 index 00000000..0379f7c0 --- /dev/null +++ b/Daylight/DaylightCore/README.md @@ -0,0 +1,11 @@ +# DaylightCore + +Daylight's capture domain, solar scheduling, image selection, durable staging, and publishing interfaces. Library targets live in the root Package.swift. See [Daylight](../README.md) for operation and limits. + +`CaptureSettings.standard` specifies San Francisco and 13 slots per event. `SolarCalculator` computes events offline. `CaptureStore` atomically persists versioned records; unknown versions and damaged records throw. `PublishingDestination` consumes typed image events and persists adapter checkpoints through the supplied callback. + +Capture records from before RAW support can omit format and delivery checkpoints. Reading these records preserves their Photos identifiers. Missing format means unknown; a missing delivery checkpoint means no recorded completion. + +Highlight selection remains pending until delivery registration completes. Capture readiness is injected separately from camera access requests so lifecycle tests can exercise permission and thermal failures. + +Photos savers report `PhotosSaveFailure` only when they can prove no asset was created. These failures retry after a persisted delay. Other failures retain receipts for reconciliation. `CaptureControlling.resolvePhotos` checks existing receipts before retrying; uncertain saves require confirmed absence. Publishing adapters own recovery decisions through `recover`. The engine preserves delivery identity and previous checkpoints when requeuing work. diff --git a/Daylight/DaylightCore/Sources/CameraCapture.swift b/Daylight/DaylightCore/Sources/CameraCapture.swift new file mode 100644 index 00000000..827645ac --- /dev/null +++ b/Daylight/DaylightCore/Sources/CameraCapture.swift @@ -0,0 +1,10 @@ +import Foundation + +/// A single shutter event contains a JPEG and, when supported, its unmodified RAW companion. +public struct CameraCapture: Sendable { + public let jpeg: Data + public let raw: Data? + public init(jpeg: Data, raw: Data?) { + self.jpeg = jpeg; self.raw = raw + } +} diff --git a/Daylight/DaylightCore/Sources/CameraCapturing.swift b/Daylight/DaylightCore/Sources/CameraCapturing.swift new file mode 100644 index 00000000..b155b814 --- /dev/null +++ b/Daylight/DaylightCore/Sources/CameraCapturing.swift @@ -0,0 +1,10 @@ +import Foundation + +public protocol CameraCapturing: Sendable { + func requestAccess() async -> Bool + func availableLenses() async -> [CaptureSettings.Camera.Lens] + func capture(settings: CaptureSettings.Camera) async throws -> CameraCapture + func preview(settings: CaptureSettings.Camera) async throws + -> AsyncThrowingStream + func stop() async +} diff --git a/Daylight/DaylightCore/Sources/CameraService.swift b/Daylight/DaylightCore/Sources/CameraService.swift new file mode 100644 index 00000000..27d6ce0e --- /dev/null +++ b/Daylight/DaylightCore/Sources/CameraService.swift @@ -0,0 +1,181 @@ +import AVFoundation +import Foundation + +/// AVFoundation is confined to a serial executor, including blocking session startup/shutdown. +public actor CameraService: CameraCapturing { + private let queue = DispatchSerialQueue(label: "com.stuff.daylight.camera") + public nonisolated var unownedExecutor: UnownedSerialExecutor { + queue.asUnownedSerialExecutor() + } + + private let session = AVCaptureSession() + private let photos = AVCapturePhotoOutput() + private let video = AVCaptureVideoDataOutput() + private var device: AVCaptureDevice? + private var photoDelegate: PhotoCaptureDelegate? + private var previewToken: UUID? + private var previewDelegate: PreviewCaptureDelegate? + public init() {} + + public func requestAccess() async -> Bool { + await AVCaptureDevice.requestAccess(for: .video) + } + + public func availableLenses() -> [CaptureSettings.Camera.Lens] { + CaptureSettings.Camera.Lens.allCases.filter { camera(for: $0) != nil } + } + + private func camera(for lens: CaptureSettings.Camera.Lens) -> AVCaptureDevice? { + let type: AVCaptureDevice.DeviceType + switch lens { + case .main: type = .builtInWideAngleCamera; case .ultraWide: type = + .builtInUltraWideCamera; case .telephoto: type = .builtInTelephotoCamera + } + return AVCaptureDevice.default(type, for: .video, position: .back) + } + + private func configure(_ settings: CaptureSettings.Camera, preview: Bool) throws { + guard AVCaptureDevice.authorizationStatus(for: .video) == .authorized + else { throw DaylightError.cameraPermission } + guard let camera = camera(for: settings.lens) else { throw DaylightError.unavailableCamera } + session.beginConfiguration() + defer { session.commitConfiguration() } + session.sessionPreset = .photo + for input in session.inputs { + session.removeInput(input) + } + let input = try AVCaptureDeviceInput(device: camera) + guard session.canAddInput(input) else { throw DaylightError.unavailableCamera } + session.addInput(input) + if !session.outputs.contains(photos) { + guard session.canAddOutput(photos) else { throw DaylightError.unavailableCamera } + session.addOutput(photos) + } + photos.isAppleProRAWEnabled = photos.isAppleProRAWSupported + if session.outputs.contains(video) { session.removeOutput(video) } + if preview { + video.alwaysDiscardsLateVideoFrames = true + guard session.canAddOutput(video) else { throw DaylightError.unavailableCamera } + session.addOutput(video) + video.setSampleBufferDelegate(previewDelegate, queue: queue) + } + try camera.lockForConfiguration() + defer { camera.unlockForConfiguration() } + camera.videoZoomFactor = min( + camera.maxAvailableVideoZoomFactor, + max(camera.minAvailableVideoZoomFactor, settings.zoom), + ) + camera.setExposureTargetBias(min( + camera.maxExposureTargetBias, + max(camera.minExposureTargetBias, settings.exposureBias), + )) + if camera + .isFocusModeSupported(.continuousAutoFocus) { camera.focusMode = .continuousAutoFocus } + if camera + .isExposureModeSupported(.continuousAutoExposure) + { + camera.exposureMode = .continuousAutoExposure + } + if camera + .isWhiteBalanceModeSupported(.continuousAutoWhiteBalance) + { + camera.whiteBalanceMode = .continuousAutoWhiteBalance + } + // A mounted landscape camera keeps preview and saved frames in the same + // orientation. + for output in session.outputs { + if let connection = output.connection(with: .video), + connection.isVideoRotationAngleSupported(0) { connection.videoRotationAngle = 0 } + } + device = camera + } + + public func capture(settings: CaptureSettings.Camera) async throws -> CameraCapture { + guard photoDelegate == nil else { throw DaylightError.interrupted } + previewToken = nil + previewDelegate?.finish(); previewDelegate = nil + try configure(settings, preview: false) + session.startRunning() + defer { photoDelegate = nil; session.stopRunning() } + let deadline = ContinuousClock.now.advanced(by: .seconds(4)) + repeat { + try Task.checkCancellation() + try await Task.sleep(for: .milliseconds(100)) + } while ContinuousClock + .now < deadline && + (device?.isAdjustingExposure == true || device?.isAdjustingFocus == true || device? + .isAdjustingWhiteBalance == true) + guard session.isRunning, !session.isInterrupted else { + throw DaylightError.interrupted + } + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + let formats = photos.availableRawPhotoPixelFormatTypes + let rawFormat = formats.first(where: AVCapturePhotoOutput.isAppleProRAWPixelFormat) + ?? + (device?.videoZoomFactor == 1 ? formats + .first(where: AVCapturePhotoOutput.isBayerRAWPixelFormat) : nil) + let delegate = PhotoCaptureDelegate( + expectsRAW: rawFormat != nil, + continuation: continuation, + ) + photoDelegate = delegate + let processed = [AVVideoCodecKey: AVVideoCodecType.jpeg] + let options: AVCapturePhotoSettings + if let rawFormat { + options = AVCapturePhotoSettings( + rawPixelFormatType: rawFormat, + processedFormat: processed, + ) + options.photoQualityPrioritization = .speed + } else { options = AVCapturePhotoSettings(format: processed) } + options.flashMode = .off + photos.capturePhoto(with: options, delegate: delegate) + Task { [weak self] in + do { try await Task.sleep(for: .seconds(20)); await self?.timeout(delegate) } + catch { delegate.cancel() } + } + } + } onCancel: { Task { await self.cancelCapture() } } + } + + private func timeout(_ delegate: PhotoCaptureDelegate) { + if photoDelegate === delegate { delegate.cancel() } + } + + private func cancelCapture() { + photoDelegate?.cancel() + } + + public func preview( + settings: CaptureSettings.Camera, + ) throws -> AsyncThrowingStream { + guard photoDelegate == nil else { throw DaylightError.interrupted } + previewToken = nil + previewDelegate?.finish() + let stream = AsyncThrowingStream + .makeStream(bufferingPolicy: .bufferingNewest(1)) + let token = UUID() + previewToken = token + stream.continuation + .onTermination = { [weak self] _ in Task { await self?.endPreview(token) } } + previewDelegate = PreviewCaptureDelegate(continuation: stream.continuation) + try configure(settings, preview: true) + session.startRunning() + return stream.stream + } + + private func endPreview(_ token: UUID) { + guard previewToken == token else { return } + previewToken = nil + previewDelegate = nil + if photoDelegate == nil { session.stopRunning() } + } + + public func stop() { + photoDelegate?.cancel() + previewToken = nil + previewDelegate?.finish(); previewDelegate = nil + session.stopRunning() + } +} diff --git a/Daylight/DaylightCore/Sources/CaptureControlling.swift b/Daylight/DaylightCore/Sources/CaptureControlling.swift new file mode 100644 index 00000000..a0e39519 --- /dev/null +++ b/Daylight/DaylightCore/Sources/CaptureControlling.swift @@ -0,0 +1,21 @@ +import Foundation + +public protocol CaptureControlling: Sendable { + func armedIntent() async throws -> Bool + func setArmedIntent(_ armed: Bool) async throws + func load() async throws -> CaptureSettings + func configure(_ settings: CaptureSettings) async throws + func plan() async throws + func tick(canCapture: Bool) async throws + func publishPending() async throws + func history() async -> [CaptureSequence] + func nextCapture() async -> Date? + func manualHistory() async throws -> [ManualCapture] + func recoverDelivery( + sequenceID: SolarEvent.ID, + deliveryID: PublishingDelivery.ID, + action: PublishingRecoveryAction, + ) async throws + func resolvePhotos(imageID: CaptureSequence.Slot.ID, resolution: PhotosResolution) async throws + func manualCapture() async throws +} diff --git a/Daylight/DaylightCore/Sources/CaptureEngine.swift b/Daylight/DaylightCore/Sources/CaptureEngine.swift new file mode 100644 index 00000000..71e9e262 --- /dev/null +++ b/Daylight/DaylightCore/Sources/CaptureEngine.swift @@ -0,0 +1,500 @@ +import Foundation +import PeriscopeCore + +/// One orchestration owner. Capture and delivery loops may interleave, but mutate current records +/// by identity. +public actor CaptureEngine: CaptureControlling { + private let manual: ManualCaptureService + private let store: CaptureStore + private let camera: any CameraCapturing + private let solar: any SolarCalculating + + private let photos: any PhotosSaving + private let scorer: any ImageScoring + private let destinations: [any PublishingDestination] + private let log: Log + private let now: @Sendable () -> Date + private var sequences: [SolarEvent.ID: CaptureSequence] = [:] + private var settings = CaptureSettings.standard + private var captureBusy = false + private var publishingBusy = false + private var loaded = false + + public init( + store: CaptureStore, + camera: any CameraCapturing, + solar: any SolarCalculating, + photos: any PhotosSaving, + scorer: any ImageScoring, + destinations: [any PublishingDestination], + log: Log, + now: @escaping @Sendable () -> Date, + ) { + precondition(Set(destinations.map(\.id)).count == destinations.count) + manual = ManualCaptureService( + store: store, + camera: camera, + photos: photos, + now: now, + ) + self.store = store; self.camera = camera; self.solar = solar + self.photos = photos; self.scorer = scorer; self.destinations = destinations; self + .log = log; self.now = now + } + + public func armedIntent() async throws -> Bool { + try await store.armedIntent() + } + + public func setArmedIntent(_ armed: Bool) async throws { + try await store.setArmedIntent(armed) + } + + public func load() async throws -> CaptureSettings { + guard !loaded else { return settings } + settings = try await store.settings() + let saved = try await store.sequences() + guard Set(saved.map(\.id)).count == saved.count else { throw DaylightError.invalidStore } + sequences = Dictionary(uniqueKeysWithValues: saved.map { ($0.id, $0) }) + loaded = true + return settings + } + + public func configure(_ value: CaptureSettings) async throws { + try value.validate() + try await store.saveSettings(value) + settings = value + } + + public func history() -> [CaptureSequence] { + sequences.values + .sorted { $0.event.date > $1.event.date } + } + + public func nextCapture() -> Date? { + sequences.values.flatMap(\.slots).filter { + if case .pending = $0.state { return $0.scheduledAt >= now().addingTimeInterval(-30) } + return false + }.map(\.scheduledAt).min() + } + + private func persist(_ sequenceID: SolarEvent.ID) async throws { + guard let sequence = sequences[sequenceID] else { throw DaylightError.invalidStore } + try await store.save(sequence) + } + + public func plan() async throws { + guard loaded else { throw DaylightError.invalidStore } + guard let zone = TimeZone(identifier: settings.site.timeZoneIdentifier) + else { throw DaylightError.invalidSettings } + var calendar = Calendar(identifier: .gregorian); calendar.timeZone = zone + for offset in 0 ... 1 { + guard let date = calendar.date(byAdding: .day, value: offset, to: now()) + else { throw DaylightError.invalidSettings } + for event in try solar.events(on: date, site: settings.site) { + if let existing = sequences[event.id] { + let untouched = existing.slots + .allSatisfy { if case .pending = $0.state { true } else { false } } + if untouched, existing.settings != settings { + sequences[event.id] = CaptureSequence(event: event, settings: settings) + try await persist(event.id) + } + } else { + sequences[event.id] = CaptureSequence(event: event, settings: settings) + try await persist(event.id) + } + } + } + } + + public func tick(canCapture: Bool) async throws { + guard !captureBusy else { return } + captureBusy = true + defer { captureBusy = false } + if canCapture { try await store.requireAvailableSpace() } + try await plan() + for sequenceID in history().reversed().map(\.id) { + guard let initial = sequences[sequenceID] else { continue } + for index in initial.slots.indices { + guard let slot = sequences[sequenceID]?.slots[index] else { continue } + switch slot.state { + case .pending: + let lateness = now().timeIntervalSince(slot.scheduledAt) + if lateness > 30 { + sequences[sequenceID]?.slots[index].state = .missed + try await persist(sequenceID) + } else if lateness >= 0, canCapture { + try await capture(sequenceID: sequenceID, index: index) + } + case .capturing: + // A prior process stopped before recording completion. Never recapture the + // same slot. + let url = await store.imageURL(slot.id, resource: .original) + if FileManager.default.fileExists(atPath: url.path) { + sequences[sequenceID]?.slots[index] + .state = await .captured(CapturedImage( + id: slot.id, + capturedAt: slot.scheduledAt, + format: store.rawURL(slot.id) == nil ? .jpeg : .rawAndJPEG, + )) + } else { + sequences[sequenceID]?.slots[index] + .state = .failed(DaylightError.interrupted.localizedDescription) + } + try await persist(sequenceID) + case .captured, .failed, .missed: break + } + if case .captured = sequences[sequenceID]?.slots[index].state { + try await finishImage(sequenceID: sequenceID, index: index) + } + } + if let sequence = sequences[sequenceID], now() > sequence.end, + case .pending = sequence.selection + { + do { + let selected = try ImageSelector().select(from: sequence) + try await enqueue( + image: selected, + sequenceID: sequenceID, + kind: .sequenceHighlight, + ) + sequences[sequenceID]?.selection = .selected(selected.id) + } catch { + sequences[sequenceID]?.selection = .failed(error.localizedDescription) + log.warning("Image selection failed; sequence preserved.") + } + try await persist(sequenceID) + } + } + try await manual.recover() + } + + private func capture(sequenceID: SolarEvent.ID, index: Int) async throws { + guard let sequence = sequences[sequenceID] else { throw DaylightError.invalidStore } + let slot = sequence.slots[index] + sequences[sequenceID]?.slots[index].state = .capturing + try await persist(sequenceID) + do { + let bytes = try await camera.capture(settings: sequence.settings.camera) + try await store.stageCapture(bytes, imageID: slot.id) + sequences[sequenceID]?.slots[index].state = .captured(CapturedImage( + id: slot.id, + capturedAt: now(), + format: bytes.raw == nil ? .jpeg : .rawAndJPEG, + )) + try await persist(sequenceID) + } catch { + sequences[sequenceID]?.slots[index].state = .failed(error.localizedDescription) + try await persist(sequenceID) + log.warning("Camera capture failed; slot recorded.") + throw error + } + } + + private func setImage( + _ image: CapturedImage, + sequenceID: SolarEvent.ID, + index: Int, + ) async throws { + sequences[sequenceID]?.slots[index].state = .captured(image) + try await persist(sequenceID) + } + + private func finishImage(sequenceID: SolarEvent.ID, index: Int) async throws { + guard case var .captured(image) = sequences[sequenceID]?.slots[index].state else { return } + let original = await store.imageURL(image.id, resource: .original) + if case .saved = image.photos, case .scored = image.score { + try await queueCapturedImage(&image, sequenceID: sequenceID, index: index) + return + } + if case let .retry(date, _) = image.photos { + if date <= now() { image.photos = .pending } + } + switch image.photos { + case .pending: + image.photos = .saving(nil) + try await setImage(image, sequenceID: sequenceID, index: index) + do { + let asset = try await photos.save( + originalURL: original, + rawURL: store.rawURL(image.id), + capturedAt: image.capturedAt, + ) { [weak self] identifier in + try await self?.recordPhotosIdentifier( + identifier, + sequenceID: sequenceID, + index: index, + ) + } + image.photos = .saved(asset) + } catch { + let recorded: String? = if case let .captured(current) = sequences[sequenceID]? + .slots[index].state, + case let .saving(identifier) = current + .photos { identifier } else { nil } + image.photos = try PhotosRecovery.failure( + error, + originalURL: original, + recorded: recorded, + now: now(), + ) + log + .warning( + "Photos save interrupted; staged files preserved for reconciliation.", + ) + } + case .saving, .ambiguous: + let recorded: String? = if case let .saving(identifier) = image + .photos { identifier } else { nil } + if let identifier = try PhotosRecovery.identifier( + originalURL: original, + recorded: recorded, + ) { + do { + image.photos = try await photos + .contains(assetIdentifier: identifier) ? .saved(identifier) : .ambiguous + } catch { + image.photos = .saving(identifier) + log.warning("Photos reconciliation deferred; receipt preserved.") + } + } else { image.photos = .ambiguous } + case .saved, .failed, .retry: break + } + if case .pending = image.score { + do { image.score = try await .scored(scorer.score(Data(contentsOf: original))) } + catch { + image.score = .failed(error.localizedDescription); log + .warning("Local scoring failed; image preserved.") + } + } + try await setImage(image, sequenceID: sequenceID, index: index) + try await queueCapturedImage(&image, sequenceID: sequenceID, index: index) + } + + private func queueCapturedImage( + _ image: inout CapturedImage, + sequenceID: SolarEvent.ID, + index: Int, + ) async throws { + guard image.capturedEventHandled != true else { return } + try await enqueue(image: image, sequenceID: sequenceID, kind: .capturedImage) + image.capturedEventHandled = true + try await setImage(image, sequenceID: sequenceID, index: index) + } + + private func recordPhotosIdentifier( + _ identifier: String, + sequenceID: SolarEvent.ID, + index: Int, + ) async throws { + guard case var .captured(image) = sequences[sequenceID]?.slots[index].state + else { throw DaylightError.invalidStore } + image.photos = .saving(identifier) + try await setImage(image, sequenceID: sequenceID, index: index) + } + + private func enqueue( + image: CapturedImage, + sequenceID: SolarEvent.ID, + kind: PublishingInput.Kind, + ) async throws { + for destination in destinations where destination.inputs.contains(kind) { + guard await destination.isEnabled() else { continue } + let exists = sequences[sequenceID]?.deliveries + .contains { + $0.destination == destination.id && $0.imageID == image.id && $0.kind == kind + } == + true + if !exists { + sequences[sequenceID]?.deliveries.append(PublishingDelivery( + destination: destination.id, + imageID: image.id, + kind: kind, + )) + try await persist(sequenceID) + } + } + } + + public func publishPending() async throws { + guard !publishingBusy else { return } + publishingBusy = true + defer { publishingBusy = false } + for sequenceID in history().reversed().map(\.id) { + guard let sequence = sequences[sequenceID] else { continue } + for delivery in sequence.deliveries { + switch delivery.state { + case .pending: break + case let .retry(date, _): if date > now() { continue } + case .delivered, .needsAttention: continue + } + guard let destination = destinations.first( + where: { $0.id == delivery.destination }, + ), + await destination.isEnabled(), + let image = sequence.images.first(where: { $0.id == delivery.imageID }) + else { continue } + let input = await PublishingInput( + kind: delivery.kind, + image: image, + event: sequence.event, + timeZoneIdentifier: sequence.settings.site.timeZoneIdentifier, + imageURL: store.imageURL(image.id, resource: .original), + rawURL: store.rawURL(image.id), + ) + guard let attemptIndex = sequences[sequenceID]?.deliveries + .firstIndex(where: { $0.id == delivery.id }) + else { throw DaylightError.invalidStore } + sequences[sequenceID]?.deliveries[attemptIndex].attempts += 1 + try await persist(sequenceID) + let backoff = min(3600.0, 30 * pow(2, Double(min(delivery.attempts, 7)))) + let state: PublishingDelivery.State + do { + let receipt = try await destination.deliver( + input, + deliveryID: delivery.id, + checkpoint: delivery.checkpoint, + ) { [weak self] data in + try await self?.checkpoint( + data, + deliveryID: delivery.id, + sequenceID: sequenceID, + ) + } + state = .delivered(receipt) + } catch let PublishingFailure + .retry(after, message) + { state = .retry( + max(after, now().addingTimeInterval(backoff)), + message, + ) } catch let PublishingFailure + .needsAttention(message) { state = .needsAttention(message) } + catch is CancellationError { return } + catch { + state = .retry( + now().addingTimeInterval(backoff), + error.localizedDescription, + ); log + .warning("Publishing failed; delivery remains queued.") + } + guard let index = sequences[sequenceID]?.deliveries + .firstIndex(where: { $0.id == delivery.id }) + else { throw DaylightError.invalidStore } + sequences[sequenceID]?.deliveries[index].state = state + try await persist(sequenceID) + } + try await cleanCompleted(sequenceID) + } + } + + private func checkpoint( + _ data: Data, + deliveryID: PublishingDelivery.ID, + sequenceID: SolarEvent.ID, + ) async throws { + guard let index = sequences[sequenceID]?.deliveries + .firstIndex(where: { $0.id == deliveryID }) else { throw DaylightError.invalidStore } + sequences[sequenceID]?.deliveries[index].checkpoint = data + try await persist(sequenceID) + } + + private func cleanCompleted(_ sequenceID: SolarEvent.ID) async throws { + guard let sequence = sequences[sequenceID], + case .selected = sequence.selection else { return } + for image in sequence.images { + guard case .saved = image.photos, case .scored = image.score else { continue } + let delivered = sequence.deliveries.filter { $0.imageID == image.id } + .allSatisfy { if case .delivered = $0.state { true } else { false } } + if delivered { try await store.removeStagedFiles(imageID: image.id) } + } + } + + public func recoverDelivery( + sequenceID: SolarEvent.ID, + deliveryID: PublishingDelivery.ID, + action: PublishingRecoveryAction, + ) async throws { + guard !publishingBusy else { throw DaylightError.interrupted } + publishingBusy = true; defer { publishingBusy = false } + guard let index = sequences[sequenceID]?.deliveries + .firstIndex(where: { $0.id == deliveryID }), + let delivery = sequences[sequenceID]?.deliveries[index], + let destination = destinations.first(where: { $0.id == delivery.destination }) + else { throw DaylightError.invalidStore } + switch delivery.state { + case .needsAttention, .retry: break + case .pending, + .delivered: throw DaylightError.service("This delivery no longer needs recovery.") + } + let result = try await destination.recover(checkpoint: delivery.checkpoint, action: action) + switch result { + case let .retry(checkpoint): + if let prior = delivery.checkpoint, prior != checkpoint { + var history = delivery.recoveryHistory ?? [] + history.append(prior) + sequences[sequenceID]?.deliveries[index].recoveryHistory = history + } + sequences[sequenceID]?.deliveries[index].checkpoint = checkpoint + sequences[sequenceID]?.deliveries[index].state = .pending + case let .delivered(receipt): + sequences[sequenceID]?.deliveries[index].state = .delivered(receipt) + } + try await persist(sequenceID) + } + + public func resolvePhotos( + imageID: CaptureSequence.Slot.ID, + resolution: PhotosResolution, + ) async throws { + guard !captureBusy else { throw DaylightError.interrupted } + captureBusy = true; defer { captureBusy = false } + for sequence in history() { + guard let index = sequence.slots.firstIndex(where: { $0.id == imageID }), + case var .captured(image) = sequences[sequence.id]?.slots[index].state + else { continue } + image.photos = try await resolvedPhotosState(image, resolution: resolution) + try await setImage(image, sequenceID: sequence.id, index: index) + return + } + guard var manual = try await store.manualCaptures().first(where: { $0.id == imageID }), + case var .captured(image) = manual.state else { throw DaylightError.unavailableAsset } + image.photos = try await resolvedPhotosState(image, resolution: resolution) + manual.state = .captured(image) + try await store.saveManual(manual) + } + + private func resolvedPhotosState( + _ image: CapturedImage, + resolution: PhotosResolution, + ) async throws -> CapturedImage.PhotosState { + if case .saved = image.photos { return image.photos } + let original = await store.imageURL(image.id, resource: .original) + let recorded: String? = if case let .saving(identifier) = image.photos { identifier } + else { nil } + if let identifier = try PhotosRecovery.identifier( + originalURL: original, + recorded: recorded, + ), + try await photos.contains(assetIdentifier: identifier) { return .saved(identifier) } + switch image.photos { + case .retry, .failed, .pending: return .pending + case .saving, .ambiguous: + guard case .confirmedAbsent = resolution + else { throw DaylightError.ambiguousPhotosSave } + return .pending + case .saved: return image.photos + } + } + + public func manualHistory() async throws -> [ManualCapture] { + try await store.manualCaptures() + } + + public func manualCapture() async throws { + guard !captureBusy else { throw DaylightError.interrupted } + captureBusy = true + defer { captureBusy = false } + try await store.requireAvailableSpace() + try await manual.capture(settings: settings, date: now()) + } +} diff --git a/Daylight/DaylightCore/Sources/CaptureReadiness.swift b/Daylight/DaylightCore/Sources/CaptureReadiness.swift new file mode 100644 index 00000000..469664ab --- /dev/null +++ b/Daylight/DaylightCore/Sources/CaptureReadiness.swift @@ -0,0 +1,21 @@ +import AVFoundation +import Foundation +import Photos + +/// Checks system conditions without requesting permissions or opening the camera. +public protocol CaptureReadiness: Sendable { + func check() throws +} + +public struct SystemCaptureReadiness: CaptureReadiness { + public init() {} + public func check() throws { + let thermal = ProcessInfo.processInfo.thermalState + guard thermal != .serious, thermal != .critical else { throw DaylightError.cooling } + guard AVCaptureDevice.authorizationStatus(for: .video) == .authorized + else { throw DaylightError.cameraPermission } + let authorization = PHPhotoLibrary.authorizationStatus(for: .readWrite) + guard authorization == .authorized || authorization == .limited + else { throw DaylightError.photosPermission } + } +} diff --git a/Daylight/DaylightCore/Sources/CaptureSequence.swift b/Daylight/DaylightCore/Sources/CaptureSequence.swift new file mode 100644 index 00000000..6a1ae60b --- /dev/null +++ b/Daylight/DaylightCore/Sources/CaptureSequence.swift @@ -0,0 +1,65 @@ +import Foundation + +/// Durable sequence state. Slot transitions are written before invoking camera or Photos APIs. +public struct CaptureSequence: Codable, Equatable, Sendable, Identifiable { + public var id: SolarEvent.ID { + event.id + } + + public let event: SolarEvent + public var settings: CaptureSettings + public var slots: [Slot] + public var selection: Selection = .pending + public var deliveries: [PublishingDelivery] = [] + + public struct Slot: Codable, Equatable, Sendable, Identifiable { + public struct ID: Codable, Hashable, Sendable { + public let rawValue: UUID + public init(rawValue: UUID) { + self.rawValue = rawValue + } + } + + public let id: ID + public let scheduledAt: Date + public var state: State + public enum State: Codable, Equatable, Sendable { + case pending, missed, capturing + case captured(CapturedImage) + case failed(String) + } + } + + public enum Selection: Codable, Equatable, Sendable { + case pending, selected(Slot.ID), failed(String) + } + + public var end: Date { + event.date.addingTimeInterval(Double(settingsWindow.minutesAfter) * 60) + } + + public var settingsWindow: CaptureSettings.Window { + switch event.id.kind { case .sunrise: settings.sunrise; case .sunset: settings.sunset } + } + + public var images: [CapturedImage] { + slots.compactMap { if case let .captured(image) = $0.state { image } else { nil } } + } + + public init(event: SolarEvent, settings: CaptureSettings) { + self.event = event + self.settings = settings + let window = event.id.kind == .sunrise ? settings.sunrise : settings.sunset + slots = stride( + from: -window.minutesBefore, + through: window.minutesAfter, + by: max(1, window.intervalMinutes), + ).map { + Slot( + id: .init(rawValue: UUID()), + scheduledAt: event.date.addingTimeInterval(Double($0) * 60), + state: .pending, + ) + } + } +} diff --git a/Daylight/DaylightCore/Sources/CaptureSettings.swift b/Daylight/DaylightCore/Sources/CaptureSettings.swift new file mode 100644 index 00000000..630c9164 --- /dev/null +++ b/Daylight/DaylightCore/Sources/CaptureSettings.swift @@ -0,0 +1,58 @@ +import Foundation + +/// A stationary camera's configuration. A sequence freezes a copy before its first slot. +public struct CaptureSettings: Codable, Equatable, Sendable { + public var site: Site + public var sunrise: Window + public var sunset: Window + public var camera: Camera + + public static let standard = Self( + site: .sanFrancisco, + sunrise: .standard, + sunset: .standard, + camera: .standard, + ) + + public struct Site: Codable, Equatable, Sendable { + public var latitude: Double + public var longitude: Double + public var timeZoneIdentifier: String + public static let sanFrancisco = Self( + latitude: 37.789, + longitude: -122.393, + timeZoneIdentifier: "America/Los_Angeles", + ) + } + + public struct Window: Codable, Equatable, Sendable { + public var minutesBefore: Int + public var minutesAfter: Int + public var intervalMinutes: Int + public static let standard = Self(minutesBefore: 30, minutesAfter: 30, intervalMinutes: 5) + } + + public struct Camera: Codable, Equatable, Sendable { + public enum Lens: String, Codable, CaseIterable, + Sendable { case main, ultraWide, telephoto } + public var lens: Lens + public var zoom: Double + public var exposureBias: Float + public static let standard = Self(lens: .main, zoom: 1, exposureBias: 0) + } + + public func validate() throws { + guard site.latitude.isFinite, (-90 ... 90).contains(site.latitude), + site.longitude.isFinite, (-180 ... 180).contains(site.longitude), + TimeZone(identifier: site.timeZoneIdentifier) != nil, + camera.zoom.isFinite, (1 ... 10).contains(camera.zoom), + camera.exposureBias.isFinite, (-3 ... 3).contains(camera.exposureBias) + else { throw DaylightError.invalidSettings } + for window in [sunrise, sunset] { + guard (0 ... 180).contains(window.minutesBefore), + (0 ... 180).contains(window.minutesAfter), + (1 ... 60).contains(window.intervalMinutes) + else { throw DaylightError.invalidSettings } + } + } +} diff --git a/Daylight/DaylightCore/Sources/CaptureStore.swift b/Daylight/DaylightCore/Sources/CaptureStore.swift new file mode 100644 index 00000000..ebb44d6c --- /dev/null +++ b/Daylight/DaylightCore/Sources/CaptureStore.swift @@ -0,0 +1,140 @@ +import Foundation + +/// One injected owner for atomic metadata and durable image staging. Never deletes Photos assets. +public actor CaptureStore { + private let root: URL + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + private struct Envelope: Codable { let version: Int; let value: T } + + public init(root: URL) throws { + self.root = root + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + } + + public func armedIntent() throws -> Bool { + let url = root.appendingPathComponent("armed.json") + return FileManager.default.fileExists(atPath: url.path) ? try read(url) : false + } + + public func setArmedIntent(_ armed: Bool) throws { + try write( + armed, + to: root.appendingPathComponent("armed.json"), + ) + } + + public func saveManual(_ record: ManualCapture) throws { + try write( + record, + to: root.appendingPathComponent("manual-\(record.id.rawValue.uuidString).json"), + ) + } + + public func manualCaptures() throws -> [ManualCapture] { + try FileManager.default.contentsOfDirectory(at: root, includingPropertiesForKeys: nil) + .filter { $0.lastPathComponent.hasPrefix("manual-") && $0.pathExtension == "json" } + .map { try read($0) as ManualCapture }.sorted { $0.date > $1.date } + } + + #if DEBUG + private var capacityOverride: Int64? + @_spi(Testing) public func overrideAvailableCapacity(_ bytes: Int64) { + capacityOverride = bytes + } + #endif + + private func availableCapacity() throws -> Int64? { + #if DEBUG + if let capacityOverride { return capacityOverride } + #endif + return try root.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey]) + .volumeAvailableCapacityForImportantUsage + } + + public func requireAvailableSpace() throws { + if let available = try availableCapacity(), + available < 200_000_000 + { + throw DaylightError.insufficientStorage + } + } + + public func saveSettings(_ settings: CaptureSettings) throws { + try settings.validate() + try write(settings, to: root.appendingPathComponent("settings.json")) + } + + public func settings() throws -> CaptureSettings { + let url = root.appendingPathComponent("settings.json") + guard FileManager.default.fileExists(atPath: url.path) else { return .standard } + let value: CaptureSettings = try read(url) + try value.validate() + return value + } + + public func save(_ sequence: CaptureSequence) throws { + try write( + sequence, + to: root.appendingPathComponent("sequence-\(sequence.id.storageKey).json"), + ) + } + + public func sequences() throws -> [CaptureSequence] { + try FileManager.default.contentsOfDirectory(at: root, includingPropertiesForKeys: nil) + .filter { $0.lastPathComponent.hasPrefix("sequence-") && $0.pathExtension == "json" } + .map { try read($0) as CaptureSequence }.sorted { $0.event.date > $1.event.date } + } + + public enum Resource: String, Sendable { case original, raw } + public func imageURL(_ imageID: CaptureSequence.Slot.ID, resource: Resource) -> URL { + let extensionName = resource == .raw ? "dng" : "jpg" + return root + .appendingPathComponent( + "\(imageID.rawValue.uuidString)-\(resource.rawValue).\(extensionName)", + ) + } + + public func stageCapture(_ capture: CameraCapture, imageID: CaptureSequence.Slot.ID) throws { + // Stage RAW first; a JPEG's presence marks the complete pair for crash recovery. + if let raw = capture.raw { try stage(raw, imageID: imageID, resource: .raw) } + try stage(capture.jpeg, imageID: imageID, resource: .original) + } + + public func rawURL(_ imageID: CaptureSequence.Slot.ID) -> URL? { + let url = imageURL(imageID, resource: .raw) + return FileManager.default.fileExists(atPath: url.path) ? url : nil + } + + public func stage(_ data: Data, imageID: CaptureSequence.Slot.ID, resource: Resource) throws { + if let available = try availableCapacity(), available < max( + 200_000_000, + Int64(data.count) * 4, + ) { + throw DaylightError.insufficientStorage + } + try data.write(to: imageURL(imageID, resource: resource), options: .atomic) + } + + public func removeStagedFiles(imageID: CaptureSequence.Slot.ID) throws { + let receipt = imageURL(imageID, resource: .original).deletingPathExtension() + .appendingPathExtension("photos-receipt") + if FileManager.default + .fileExists(atPath: receipt.path) { try FileManager.default.removeItem(at: receipt) } + for resource in [Resource.original, .raw] { + let url = imageURL(imageID, resource: resource) + if FileManager.default + .fileExists(atPath: url.path) { try FileManager.default.removeItem(at: url) } + } + } + + private func write(_ value: some Codable, to url: URL) throws { + try encoder.encode(Envelope(version: 1, value: value)).write(to: url, options: .atomic) + } + + private func read(_ url: URL) throws -> T { + let envelope = try decoder.decode(Envelope.self, from: Data(contentsOf: url)) + guard envelope.version == 1 else { throw DaylightError.unsupportedVersion } + return envelope.value + } +} diff --git a/Daylight/DaylightCore/Sources/CapturedImage.swift b/Daylight/DaylightCore/Sources/CapturedImage.swift new file mode 100644 index 00000000..ef882cd9 --- /dev/null +++ b/Daylight/DaylightCore/Sources/CapturedImage.swift @@ -0,0 +1,36 @@ +import Foundation + +public struct CapturedImage: Codable, Equatable, Sendable, Identifiable { + public var id: CaptureSequence.Slot.ID + public let capturedAt: Date + /// Older captures did not record a format or delivery checkpoint. + public let format: Format? + public enum Format: String, Codable, Sendable { case jpeg, rawAndJPEG } + + public var photos: PhotosState + public var score: ScoreState + public var capturedEventHandled: Bool? = false + + public enum PhotosState: Codable, Equatable, Sendable { + case pending, saving(String?), saved(String), failed(String), ambiguous + case retry(Date, String) + public var needsRecovery: Bool { + switch self { case .saving, .failed, .ambiguous, .retry: true; case .pending, + .saved: false } + } + + public var requiresAbsenceConfirmation: Bool { + switch self { case .saving, .ambiguous: true; case .pending, .saved, .failed, + .retry: false } + } + } + + public enum ScoreState: Codable, Equatable, Sendable { + case pending, scored(ImageScore), failed(String) + } + + public init(id: CaptureSequence.Slot.ID, capturedAt: Date, format: Format) { + self.id = id; self.capturedAt = capturedAt; self.format = format + photos = .pending; score = .pending + } +} diff --git a/Daylight/DaylightCore/Sources/DaylightError.swift b/Daylight/DaylightCore/Sources/DaylightError.swift new file mode 100644 index 00000000..942be323 --- /dev/null +++ b/Daylight/DaylightCore/Sources/DaylightError.swift @@ -0,0 +1,27 @@ +import Foundation + +public enum DaylightError: Error, LocalizedError, Sendable { + case invalidSettings, unavailableCamera, cameraPermission, photosPermission, insufficientStorage + case invalidImage, unsupportedVersion, invalidStore, noScoredImages + case cooling, interrupted, ambiguousPhotosSave, unavailableAsset + case service(String) + + public var errorDescription: String? { + switch self { + case .invalidSettings: "Check the location, schedule, and camera settings." + case .unavailableCamera: "The selected camera is unavailable." + case .cameraPermission: "Allow camera access in Settings to capture photos." + case .photosPermission: "Allow Photos access in Settings to save captured photographs." + case .insufficientStorage: "Free storage to continue capturing. Pending photos are preserved." + case .invalidImage: "The image could not be decoded or rendered." + case .unsupportedVersion: "This data was saved by an unsupported version of Daylight." + case .invalidStore: "The capture archive is inconsistent. Its files have been preserved." + case .noScoredImages: "No suitable image could be scored. The sequence is preserved." + case .cooling: "Capture paused while the phone cools." + case .interrupted: "Capture was interrupted. Future slots resume when the camera is available." + case .ambiguousPhotosSave: "A Photos save was interrupted. Check Photos before retrying." + case .unavailableAsset: "The saved photo is no longer accessible." + case let .service(message): message + } + } +} diff --git a/Daylight/DaylightCore/Sources/DaylightLogEvent.swift b/Daylight/DaylightCore/Sources/DaylightLogEvent.swift new file mode 100644 index 00000000..619a47ca --- /dev/null +++ b/Daylight/DaylightCore/Sources/DaylightLogEvent.swift @@ -0,0 +1,12 @@ +import PeriscopeCore + +public struct DaylightLogEvent: LogEvent { + public static let eventName = "Daylight" + public enum Operation: String, Codable, + Sendable { case capture, photos, selection, publishing, recovery } + public let operation: Operation + public let message: String + public init(operation: Operation, message: String) { + self.operation = operation; self.message = message + } +} diff --git a/Daylight/DaylightCore/Sources/ImageScore.swift b/Daylight/DaylightCore/Sources/ImageScore.swift new file mode 100644 index 00000000..e2a79c7b --- /dev/null +++ b/Daylight/DaylightCore/Sources/ImageScore.swift @@ -0,0 +1,9 @@ +import Foundation + +public struct ImageScore: Codable, Equatable, Sendable { + public let overall: Float + public let isUtility: Bool + public init(overall: Float, isUtility: Bool) { + self.overall = overall; self.isUtility = isUtility + } +} diff --git a/Daylight/DaylightCore/Sources/ImageScoring.swift b/Daylight/DaylightCore/Sources/ImageScoring.swift new file mode 100644 index 00000000..18eb9718 --- /dev/null +++ b/Daylight/DaylightCore/Sources/ImageScoring.swift @@ -0,0 +1,5 @@ +import Foundation + +public protocol ImageScoring: Sendable { + func score(_ image: Data) async throws -> ImageScore +} diff --git a/Daylight/DaylightCore/Sources/ImageSelector.swift b/Daylight/DaylightCore/Sources/ImageSelector.swift new file mode 100644 index 00000000..47edc414 --- /dev/null +++ b/Daylight/DaylightCore/Sources/ImageSelector.swift @@ -0,0 +1,22 @@ +import Foundation + +public struct ImageSelector: Sendable { + public init() {} + public func select(from sequence: CaptureSequence) throws -> CapturedImage { + let candidates = sequence.images.filter { + if case let .scored(score) = $0 + .score { return !score.isUtility && score.overall.isFinite } + return false + } + guard let best = candidates.sorted(by: { lhs, rhs in + guard case let .scored(left) = lhs.score, + case let .scored(right) = rhs.score else { return false } + if left.overall != right.overall { return left.overall > right.overall } + let ld = abs(lhs.capturedAt.timeIntervalSince(sequence.event.date)) + let rd = abs(rhs.capturedAt.timeIntervalSince(sequence.event.date)) + if ld != rd { return ld < rd } + return lhs.id.rawValue.uuidString < rhs.id.rawValue.uuidString + }).first else { throw DaylightError.noScoredImages } + return best + } +} diff --git a/Daylight/DaylightCore/Sources/JPEGExporter.swift b/Daylight/DaylightCore/Sources/JPEGExporter.swift new file mode 100644 index 00000000..6d96ed75 --- /dev/null +++ b/Daylight/DaylightCore/Sources/JPEGExporter.swift @@ -0,0 +1,27 @@ +import Foundation +import ImageIO +import UniformTypeIdentifiers + +/// Encodes pixels into a fresh JPEG container without copying source metadata. +public struct JPEGExporter: Sendable { + public init() {} + public func stripMetadata(_ data: Data) throws -> Data { + guard let source = CGImageSourceCreateWithData(data as CFData, nil), + let image = CGImageSourceCreateImageAtIndex(source, 0, nil) + else { throw DaylightError.invalidImage } + let result = NSMutableData() + guard let destination = CGImageDestinationCreateWithData( + result, + UTType.jpeg.identifier as CFString, + 1, + nil, + ) else { throw DaylightError.invalidImage } + CGImageDestinationAddImage( + destination, + image, + [kCGImageDestinationLossyCompressionQuality: 0.9] as CFDictionary, + ) + guard CGImageDestinationFinalize(destination) else { throw DaylightError.invalidImage } + return result as Data + } +} diff --git a/Daylight/DaylightCore/Sources/JPEGRenderer.swift b/Daylight/DaylightCore/Sources/JPEGRenderer.swift new file mode 100644 index 00000000..cc463d4b --- /dev/null +++ b/Daylight/DaylightCore/Sources/JPEGRenderer.swift @@ -0,0 +1,34 @@ +import CoreImage +import Foundation +import ImageIO + +/// Applies source orientation and optional downsampling when encoding a JPEG derivative. +public struct JPEGRenderer: Sendable { + private let context = CIContext(options: [.cacheIntermediates: false]) + public init() {} + public func renderJPEG(_ data: Data, maximumDimension: Double?) throws -> Data { + guard let image = CIImage(data: data, options: [.applyOrientationProperty: true]) + else { throw DaylightError.invalidImage } + return try renderJPEG(image, maximumDimension: maximumDimension) + } + + public func renderJPEG(_ image: CIImage, maximumDimension: Double?) throws -> Data { + var output = image + if let maximumDimension { + guard maximumDimension.isFinite, + maximumDimension > 0 else { throw DaylightError.invalidImage } + let scale = min(1, maximumDimension / max(image.extent.width, image.extent.height)) + output = output.transformed(by: CGAffineTransform(scaleX: scale, y: scale)) + } + guard let colorSpace = CGColorSpace(name: CGColorSpace.sRGB), + let data = context.jpegRepresentation( + of: output, + colorSpace: colorSpace, + options: [ + kCGImageDestinationLossyCompressionQuality as CIImageRepresentationOption: 0.92, + ], + ) + else { throw DaylightError.invalidImage } + return data + } +} diff --git a/Daylight/DaylightCore/Sources/ManualCapture.swift b/Daylight/DaylightCore/Sources/ManualCapture.swift new file mode 100644 index 00000000..2060d1a6 --- /dev/null +++ b/Daylight/DaylightCore/Sources/ManualCapture.swift @@ -0,0 +1,16 @@ +import Foundation + +/// A durable test shot follows Photos recovery without ever entering the publishing pipeline. +public struct ManualCapture: Codable, Equatable, Sendable, Identifiable { + public let id: CaptureSequence.Slot.ID + public let date: Date + + public var state: State + public enum State: Codable, Equatable, Sendable { + case capturing, captured(CapturedImage), failed(String) + } + + public init(date: Date) { + id = .init(rawValue: UUID()); self.date = date; state = .capturing + } +} diff --git a/Daylight/DaylightCore/Sources/ManualCaptureService.swift b/Daylight/DaylightCore/Sources/ManualCaptureService.swift new file mode 100644 index 00000000..c5fe0ee1 --- /dev/null +++ b/Daylight/DaylightCore/Sources/ManualCaptureService.swift @@ -0,0 +1,122 @@ +import Foundation + +/// Reuses the capture root's camera and archive; manual shots never create delivery events. +actor ManualCaptureService { + private let store: CaptureStore + private let camera: any CameraCapturing + + private let now: @Sendable () -> Date + private let photos: any PhotosSaving + init( + store: CaptureStore, + camera: any CameraCapturing, + photos: any PhotosSaving, + now: @escaping @Sendable () -> Date, + ) { + self.now = now + self.store = store; self.camera = camera; self.photos = photos + } + + func capture(settings: CaptureSettings, date: Date) async throws { + let record = ManualCapture(date: date) + try await store.saveManual(record) + do { + let original = try await camera.capture(settings: settings.camera) + try await store.stageCapture(original, imageID: record.id) + try await finish(record) + } catch { + var failure = record + let existing = try await store.manualCaptures().first { $0.id == record.id } + if let existing, case .captured = existing.state { throw error } + failure.state = .failed(error.localizedDescription) + try await store.saveManual(failure) + throw error + } + } + + func recover() async throws { + for record in try await store.manualCaptures() { + switch record.state { + case .capturing: try await finish(record) + case let .captured(image): + switch image.photos { + case .pending, .saving, .ambiguous, .retry: try await finish(record) + case .saved: try await store.removeStagedFiles(imageID: record.id) + case .failed: break + } + case .failed: break + } + } + } + + private func finish(_ record: ManualCapture) async throws { + var record = record + let originalURL = await store.imageURL(record.id, resource: .original) + guard FileManager.default.fileExists(atPath: originalURL.path) else { + record.state = .failed(DaylightError.interrupted.localizedDescription) + try await store.saveManual(record) + return + } + var image: CapturedImage = if case let .captured(saved) = record.state { saved } + else { await CapturedImage( + id: record.id, + capturedAt: record.date, + format: store.rawURL(record.id) == nil ? .jpeg : .rawAndJPEG, + ) } + record.state = .captured(image) + try await store.saveManual(record) + if case let .retry(date, _) = image.photos { + guard date <= now() else { return } + image.photos = .pending + } + switch image.photos { + case .pending: + image.photos = .saving(nil); record.state = .captured(image) + try await store.saveManual(record) + do { + let snapshot = record + let asset = try await photos.save( + originalURL: originalURL, + rawURL: store.rawURL(record.id), + capturedAt: record.date, + ) { [store] identifier in + var progress = snapshot + guard case var .captured(image) = progress.state + else { throw DaylightError.invalidStore } + image.photos = .saving(identifier); progress.state = .captured(image) + try await store.saveManual(progress) + } + image.photos = .saved(asset) + } catch { + let saved = try await store.manualCaptures().first { $0.id == record.id } + let identifier: String? = if let saved, + case let .captured(current) = saved.state, + case let .saving(identifier) = current + .photos { identifier } else { nil } + image.photos = try PhotosRecovery.failure( + error, + originalURL: originalURL, + recorded: identifier, + now: now(), + ) + record.state = .captured(image) + try await store.saveManual(record) + throw error + } + case .saving, .ambiguous: + let recorded: String? = if case let .saving(identifier) = image + .photos { identifier } else { nil } + if let identifier = try PhotosRecovery.identifier( + originalURL: originalURL, + recorded: recorded, + ) { + image.photos = try await photos + .contains(assetIdentifier: identifier) ? .saved(identifier) : .ambiguous + } else { image.photos = .ambiguous } + case .saved, .failed, .retry: break + } + record.state = .captured(image) + try await store.saveManual(record) + if case .saved = image.photos { try await store.removeStagedFiles(imageID: image.id) } + } +} diff --git a/Daylight/DaylightCore/Sources/PhotoCaptureDelegate.swift b/Daylight/DaylightCore/Sources/PhotoCaptureDelegate.swift new file mode 100644 index 00000000..96c616eb --- /dev/null +++ b/Daylight/DaylightCore/Sources/PhotoCaptureDelegate.swift @@ -0,0 +1,57 @@ +import AVFoundation +import Foundation +import Synchronization + +/// Waits for the final capture callback so RAW and JPEG remain one shutter event. +final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegate, Sendable { + private struct State { + var continuation: CheckedContinuation? + var jpeg: Data? + var raw: Data? + } + + private let state: Mutex + private let expectsRAW: Bool + init(expectsRAW: Bool, continuation: CheckedContinuation) { + self.expectsRAW = expectsRAW; state = Mutex(State(continuation: continuation)) + } + + func photoOutput( + _: AVCapturePhotoOutput, + didFinishProcessingPhoto photo: AVCapturePhoto, + error: (any Error)?, + ) { + state.withLock { state in + guard let continuation = state.continuation else { return } + if let error { state.continuation = nil; continuation.resume(throwing: error); return } + guard let data = photo.fileDataRepresentation() else { + state.continuation = nil; continuation + .resume(throwing: DaylightError.invalidImage); return + } + if photo.isRawPhoto { state.raw = data } else { state.jpeg = data } + } + } + + func photoOutput( + _: AVCapturePhotoOutput, + didFinishCaptureFor _: AVCaptureResolvedPhotoSettings, + error: (any Error)?, + ) { + state.withLock { state in + guard let continuation = state.continuation else { return } + state.continuation = nil + if let error { continuation.resume(throwing: error); return } + guard let jpeg = state.jpeg, !expectsRAW || state.raw != nil else { + continuation.resume(throwing: DaylightError.invalidImage); return + } + continuation.resume(returning: CameraCapture(jpeg: jpeg, raw: state.raw)) + } + } + + func cancel() { + state + .withLock { state in + state.continuation?.resume(throwing: CancellationError()); state.continuation = nil + } + } +} diff --git a/Daylight/DaylightCore/Sources/PhotosLibrary.swift b/Daylight/DaylightCore/Sources/PhotosLibrary.swift new file mode 100644 index 00000000..a622b254 --- /dev/null +++ b/Daylight/DaylightCore/Sources/PhotosLibrary.swift @@ -0,0 +1,62 @@ +import Foundation +import Photos +import Synchronization + +/// Saves original RAW and JPEG resources in one Photos asset and records a durable asset identifier +/// for recovery. +public struct PhotosLibrary: PhotosSaving { + public init() {} + public func requestAccess() async -> Bool { + let status = await PHPhotoLibrary.requestAuthorization(for: .readWrite) + return status == .authorized || status == .limited + } + + public func contains(assetIdentifier: String) async throws -> Bool { + let status = PHPhotoLibrary.authorizationStatus(for: .readWrite) + guard status == .authorized || status == .limited + else { throw DaylightError.photosPermission } + guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetIdentifier], options: nil) + .firstObject else { return false } + return asset.mediaType == .image + } + + public func save( + originalURL: URL, + rawURL: URL?, + capturedAt: Date, + recordIdentifier: @escaping @Sendable (String) async throws + -> Void, + ) async throws -> String { + let status = PHPhotoLibrary.authorizationStatus(for: .readWrite) + guard status == .authorized || status == .limited + else { + throw PhotosSaveFailure(message: DaylightError.photosPermission.localizedDescription) + } + // New asset placeholders only exist inside a change block. Persist their ID synchronously + // through a local sidecar before the transaction can commit; the owner reconciles it on + // restart. + let receiptURL = originalURL.deletingPathExtension() + .appendingPathExtension("photos-receipt") + let result = Mutex?>(nil) + try await PHPhotoLibrary.shared().performChanges { + do { + let request = PHAssetCreationRequest.forAsset() + if let rawURL { + request.addResource(with: .photo, fileURL: rawURL, options: nil) + request.addResource(with: .alternatePhoto, fileURL: originalURL, options: nil) + } else { request.addResource(with: .photo, fileURL: originalURL, options: nil) } + guard let placeholder = request.placeholderForCreatedAsset + else { throw DaylightError.invalidImage } + request.creationDate = capturedAt + try Data(placeholder.localIdentifier.utf8).write(to: receiptURL, options: .atomic) + result.withLock { $0 = .success(placeholder.localIdentifier) } + } catch { + result.withLock { $0 = .failure(error) } + } + } + guard let outcome = result.withLock({ $0 }) else { throw DaylightError.invalidStore } + let identifier = try outcome.get() + try await recordIdentifier(identifier) + return identifier + } +} diff --git a/Daylight/DaylightCore/Sources/PhotosRecovery.swift b/Daylight/DaylightCore/Sources/PhotosRecovery.swift new file mode 100644 index 00000000..25438fe3 --- /dev/null +++ b/Daylight/DaylightCore/Sources/PhotosRecovery.swift @@ -0,0 +1,26 @@ +import Foundation + +/// Determines safe recovery from durable receipts without creating another Photos asset. +enum PhotosRecovery { + static func identifier(originalURL: URL, recorded: String?) throws -> String? { + if let recorded { return recorded } + let receipt = originalURL.deletingPathExtension().appendingPathExtension("photos-receipt") + guard FileManager.default.fileExists(atPath: receipt.path) else { return nil } + return try String(contentsOf: receipt, encoding: .utf8) + } + + static func failure( + _ error: any Error, + originalURL: URL, + recorded: String?, + now: Date, + ) throws -> CapturedImage.PhotosState { + if let failure = error as? PhotosSaveFailure { + return .retry(now.addingTimeInterval(30), failure.localizedDescription) + } + if let identifier = try identifier(originalURL: originalURL, recorded: recorded) { + return .saving(identifier) + } + return .ambiguous + } +} diff --git a/Daylight/DaylightCore/Sources/PhotosResolution.swift b/Daylight/DaylightCore/Sources/PhotosResolution.swift new file mode 100644 index 00000000..6f914dbf --- /dev/null +++ b/Daylight/DaylightCore/Sources/PhotosResolution.swift @@ -0,0 +1,5 @@ +/// Retrying an uncertain save requires the user to check Photos for the original first. +public enum PhotosResolution: Sendable { + case retry + case confirmedAbsent +} diff --git a/Daylight/DaylightCore/Sources/PhotosSaveFailure.swift b/Daylight/DaylightCore/Sources/PhotosSaveFailure.swift new file mode 100644 index 00000000..9f84bc89 --- /dev/null +++ b/Daylight/DaylightCore/Sources/PhotosSaveFailure.swift @@ -0,0 +1,13 @@ +import Foundation + +/// Use only when the saver can prove no asset was created. Other errors remain uncertain. +public struct PhotosSaveFailure: Error, LocalizedError, Sendable { + public let message: String + public init(message: String) { + self.message = message + } + + public var errorDescription: String? { + message + } +} diff --git a/Daylight/DaylightCore/Sources/PhotosSaving.swift b/Daylight/DaylightCore/Sources/PhotosSaving.swift new file mode 100644 index 00000000..d9268187 --- /dev/null +++ b/Daylight/DaylightCore/Sources/PhotosSaving.swift @@ -0,0 +1,13 @@ +import Foundation + +public protocol PhotosSaving: Sendable { + func requestAccess() async -> Bool + func save( + originalURL: URL, + rawURL: URL?, + capturedAt: Date, + recordIdentifier: @escaping @Sendable (String) async throws -> Void, + ) async throws + -> String + func contains(assetIdentifier: String) async throws -> Bool +} diff --git a/Daylight/DaylightCore/Sources/PreviewCaptureDelegate.swift b/Daylight/DaylightCore/Sources/PreviewCaptureDelegate.swift new file mode 100644 index 00000000..c092797e --- /dev/null +++ b/Daylight/DaylightCore/Sources/PreviewCaptureDelegate.swift @@ -0,0 +1,38 @@ +import AVFoundation +import CoreImage +import Foundation +import Synchronization + +/// Drops stale preview frames and limits display work to four frames per second. +final class PreviewCaptureDelegate: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate, + Sendable +{ + private let renderer = JPEGRenderer() + private let lastFrame = Mutex(nil) + private let continuation: AsyncThrowingStream.Continuation + init(continuation: AsyncThrowingStream.Continuation) { + self.continuation = continuation + } + + func captureOutput( + _: AVCaptureOutput, + didOutput sampleBuffer: CMSampleBuffer, + from _: AVCaptureConnection, + ) { + let eligible = lastFrame.withLock { value in + let now = Date() + if let value, now.timeIntervalSince(value) < 0.25 { return false } + value = now + return true + } + guard eligible, let buffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return } + do { try continuation.yield(renderer.renderJPEG( + CIImage(cvPixelBuffer: buffer), + maximumDimension: 960, + )) } catch { continuation.finish(throwing: error) } + } + + func finish() { + continuation.finish() + } +} diff --git a/Daylight/DaylightCore/Sources/PublishingDelivery.swift b/Daylight/DaylightCore/Sources/PublishingDelivery.swift new file mode 100644 index 00000000..fdc9d6d8 --- /dev/null +++ b/Daylight/DaylightCore/Sources/PublishingDelivery.swift @@ -0,0 +1,35 @@ +import Foundation + +/// Adapter checkpoint data is opaque to capture orchestration and versioned by the adapter. +public struct PublishingDelivery: Codable, Equatable, Sendable, Identifiable { + public struct ID: Codable, Hashable, Sendable { + public let rawValue: UUID + public init(rawValue: UUID) { + self.rawValue = rawValue + } + } + + public let id: ID + public let destination: PublishingDestinationID + public let imageID: CaptureSequence.Slot.ID + public let kind: PublishingInput.Kind + public var attempts = 0 + public var checkpoint: Data? + public var recoveryHistory: [Data]? + public var state: State + public enum State: Codable, Equatable, Sendable { + case pending, retry(Date, String), delivered(PublishingReceipt), needsAttention(String) + public var needsRecovery: Bool { + switch self { case .retry, .needsAttention: true; case .pending, .delivered: false } + } + } + + public init( + destination: PublishingDestinationID, + imageID: CaptureSequence.Slot.ID, + kind: PublishingInput.Kind, + ) { + id = .init(rawValue: UUID()); self.destination = destination; self.imageID = imageID + self.kind = kind; state = .pending + } +} diff --git a/Daylight/DaylightCore/Sources/PublishingDestination.swift b/Daylight/DaylightCore/Sources/PublishingDestination.swift new file mode 100644 index 00000000..00436c00 --- /dev/null +++ b/Daylight/DaylightCore/Sources/PublishingDestination.swift @@ -0,0 +1,16 @@ +import Foundation + +public protocol PublishingDestination: Sendable { + var id: PublishingDestinationID { get } + var inputs: Set { get } + func isEnabled() async -> Bool + func recover(checkpoint: Data?, action: PublishingRecoveryAction) async throws + -> PublishingRecoveryResult + func deliver( + _ input: PublishingInput, + deliveryID: PublishingDelivery.ID, + checkpoint: Data?, + saveCheckpoint: @escaping @Sendable (Data) async throws -> Void, + ) async throws + -> PublishingReceipt +} diff --git a/Daylight/DaylightCore/Sources/PublishingDestinationID.swift b/Daylight/DaylightCore/Sources/PublishingDestinationID.swift new file mode 100644 index 00000000..81cd78d3 --- /dev/null +++ b/Daylight/DaylightCore/Sources/PublishingDestinationID.swift @@ -0,0 +1,8 @@ +import Foundation + +public struct PublishingDestinationID: RawRepresentable, Codable, Hashable, Sendable { + public let rawValue: String + public init(rawValue: String) { + self.rawValue = rawValue + } +} diff --git a/Daylight/DaylightCore/Sources/PublishingFailure.swift b/Daylight/DaylightCore/Sources/PublishingFailure.swift new file mode 100644 index 00000000..b3e91030 --- /dev/null +++ b/Daylight/DaylightCore/Sources/PublishingFailure.swift @@ -0,0 +1,6 @@ +import Foundation + +public enum PublishingFailure: Error, Sendable { + case retry(after: Date, message: String) + case needsAttention(String) +} diff --git a/Daylight/DaylightCore/Sources/PublishingInput.swift b/Daylight/DaylightCore/Sources/PublishingInput.swift new file mode 100644 index 00000000..ee0339cd --- /dev/null +++ b/Daylight/DaylightCore/Sources/PublishingInput.swift @@ -0,0 +1,22 @@ +import Foundation + +public struct PublishingInput: Sendable { + public enum Kind: String, Codable, Hashable, Sendable { case capturedImage, sequenceHighlight } + public let kind: Kind + public let image: CapturedImage + public let event: SolarEvent + public let timeZoneIdentifier: String + public let imageURL: URL + public let rawURL: URL? + public init( + kind: Kind, + image: CapturedImage, + event: SolarEvent, + timeZoneIdentifier: String, + imageURL: URL, + rawURL: URL?, + ) { + self.kind = kind; self.image = image; self.event = event + self.timeZoneIdentifier = timeZoneIdentifier; self.imageURL = imageURL; self.rawURL = rawURL + } +} diff --git a/Daylight/DaylightCore/Sources/PublishingReceipt.swift b/Daylight/DaylightCore/Sources/PublishingReceipt.swift new file mode 100644 index 00000000..15a0e68f --- /dev/null +++ b/Daylight/DaylightCore/Sources/PublishingReceipt.swift @@ -0,0 +1,9 @@ +import Foundation + +public struct PublishingReceipt: Codable, Equatable, Sendable { + public let remoteID: String + public let url: URL + public init(remoteID: String, url: URL) { + self.remoteID = remoteID; self.url = url + } +} diff --git a/Daylight/DaylightCore/Sources/PublishingRecovery.swift b/Daylight/DaylightCore/Sources/PublishingRecovery.swift new file mode 100644 index 00000000..5ff02bde --- /dev/null +++ b/Daylight/DaylightCore/Sources/PublishingRecovery.swift @@ -0,0 +1,14 @@ +import Foundation + +/// User decisions about a retained delivery. Only confirmed absence permits clearing submission +/// uncertainty. +public enum PublishingRecoveryAction: Sendable { + case retry + case confirmedAbsent + case published(URL) +} + +public enum PublishingRecoveryResult: Sendable { + case retry(checkpoint: Data?) + case delivered(PublishingReceipt) +} diff --git a/Daylight/DaylightCore/Sources/SolarCalculating.swift b/Daylight/DaylightCore/Sources/SolarCalculating.swift new file mode 100644 index 00000000..5c59b729 --- /dev/null +++ b/Daylight/DaylightCore/Sources/SolarCalculating.swift @@ -0,0 +1,5 @@ +import Foundation + +public protocol SolarCalculating: Sendable { + func events(on date: Date, site: CaptureSettings.Site) throws -> [SolarEvent] +} diff --git a/Daylight/DaylightCore/Sources/SolarCalculator.swift b/Daylight/DaylightCore/Sources/SolarCalculator.swift new file mode 100644 index 00000000..38c05980 --- /dev/null +++ b/Daylight/DaylightCore/Sources/SolarCalculator.swift @@ -0,0 +1,53 @@ +import Foundation + +/// Offline NOAA fractional-year solar equations, using the conventional 90.833° sunrise zenith. +/// Source: https://www.gml.noaa.gov/grad/solcalc/solareqns.PDF +public struct SolarCalculator: SolarCalculating { + public init() {} + + public func events(on date: Date, site: CaptureSettings.Site) throws -> [SolarEvent] { + var settings = CaptureSettings.standard + settings.site = site + try settings.validate() + guard let zone = TimeZone(identifier: site.timeZoneIdentifier) + else { throw DaylightError.invalidSettings } + var local = Calendar(identifier: .gregorian) + local.timeZone = zone + let components = local.dateComponents([.year, .month, .day], from: date) + guard let year = components.year, let month = components.month, + let day = components.day else { throw DaylightError.invalidSettings } + var utc = Calendar(identifier: .gregorian) + utc.timeZone = .gmt + guard let base = utc.date(from: DateComponents(year: year, month: month, day: day)), + let ordinal = utc.ordinality(of: .day, in: .year, for: base), + let days = utc.range(of: .day, in: .year, for: base) + else { throw DaylightError.invalidSettings } + let gamma = 2 * Double.pi / Double(days.count) * Double(ordinal - 1) + let equation = 229.18 * (0.000075 + 0.001868 * cos(gamma) - 0.032077 * sin(gamma) + - 0.014615 * cos(2 * gamma) - 0.040849 * sin(2 * gamma)) + let declination = 0.006918 - 0.399912 * cos(gamma) + 0.070257 * sin(gamma) + - 0.006758 * cos(2 * gamma) + 0.000907 * sin(2 * gamma) + - 0.002697 * cos(3 * gamma) + 0.00148 * sin(3 * gamma) + let latitude = site.latitude * .pi / 180 + let cosine = cos(90.833 * .pi / 180) / (cos(latitude) * cos(declination)) - tan(latitude) * + tan(declination) + guard (-1 ... 1).contains(cosine) else { return [] } + let angle = acos(cosine) * 180 / .pi + let localDay = local.dateInterval(of: .day, for: date) + guard let localDay else { throw DaylightError.invalidSettings } + return try SolarEvent.Kind.allCases.map { kind in + let sign: Double = kind == .sunrise ? 1 : -1 + let minutes = 720 - 4 * (site.longitude + sign * angle) - equation + let estimate = base.addingTimeInterval(minutes * 60) + // Civil zones can lie on the opposite side of the international date line from + // longitude. + guard let eventDate = [-86400.0, 0, 86400].map({ estimate.addingTimeInterval($0) }) + .first(where: { $0 >= localDay.start && $0 < localDay.end }) + else { throw DaylightError.invalidSettings } + return SolarEvent( + id: .init(year: year, month: month, day: day, kind: kind), + date: eventDate, + ) + } + } +} diff --git a/Daylight/DaylightCore/Sources/SolarEvent.swift b/Daylight/DaylightCore/Sources/SolarEvent.swift new file mode 100644 index 00000000..281cbcf0 --- /dev/null +++ b/Daylight/DaylightCore/Sources/SolarEvent.swift @@ -0,0 +1,24 @@ +import Foundation + +public struct SolarEvent: Codable, Equatable, Sendable { + public enum Kind: String, Codable, CaseIterable, Sendable { case sunrise, sunset } + public struct ID: Codable, Hashable, Sendable { + public let year: Int + public let month: Int + public let day: Int + public let kind: Kind + public init(year: Int, month: Int, day: Int, kind: Kind) { + self.year = year; self.month = month; self.day = day; self.kind = kind + } + + public var storageKey: String { + "\(year)-\(month)-\(day)-\(kind.rawValue)" + } + } + + public let id: ID + public let date: Date + public init(id: ID, date: Date) { + self.id = id; self.date = date + } +} diff --git a/Daylight/DaylightCore/Sources/VisionImageScorer.swift b/Daylight/DaylightCore/Sources/VisionImageScorer.swift new file mode 100644 index 00000000..e97239b0 --- /dev/null +++ b/Daylight/DaylightCore/Sources/VisionImageScorer.swift @@ -0,0 +1,10 @@ +import Foundation +import Vision + +public struct VisionImageScorer: ImageScoring { + public init() {} + public func score(_ image: Data) async throws -> ImageScore { + let observation = try await CalculateImageAestheticsScoresRequest().perform(on: image) + return ImageScore(overall: observation.overallScore, isUtility: observation.isUtility) + } +} diff --git a/Daylight/DaylightCore/Tests/CaptureEngineTestSupport.swift b/Daylight/DaylightCore/Tests/CaptureEngineTestSupport.swift new file mode 100644 index 00000000..8fdedda5 --- /dev/null +++ b/Daylight/DaylightCore/Tests/CaptureEngineTestSupport.swift @@ -0,0 +1,216 @@ +@_spi(Testing) @testable import DaylightCore +import Foundation +import PeriscopeCore +import Synchronization + +final class CaptureTestClock: Sendable { + private let storage: Mutex + init(_ date: Date) { + storage = Mutex(date) + } + + var now: Date { + storage.withLock { $0 } + } + + func advance(_ seconds: TimeInterval) { + storage.withLock { $0 = $0.addingTimeInterval(seconds) } + } +} + +actor ScriptedCamera: CameraCapturing { + var count = 0 + func requestAccess() -> Bool { + true + } + + func availableLenses() -> [CaptureSettings.Camera.Lens] { + [.main] + } + + func capture(settings _: CaptureSettings.Camera) -> CameraCapture { + count += + 1; return CameraCapture(jpeg: Data("original".utf8), raw: Data("raw".utf8)) + } + + func preview( + settings _: CaptureSettings.Camera, + ) -> AsyncThrowingStream { + .init { $0.finish() } + } + + func stop() {} +} + +struct ScriptedSolar: SolarCalculating { + let event: SolarEvent + func events(on _: Date, site _: CaptureSettings.Site) -> [SolarEvent] { + [event] + } +} + +struct ScriptedScorer: ImageScoring { + func score(_: Data) -> ImageScore { + .init(overall: 0.7, isUtility: false) + } +} + +actor ScriptedPhotos: PhotosSaving { + var count = 0 + func requestAccess() -> Bool { + true + } + + func contains(assetIdentifier: String) -> Bool { + assetIdentifier == "saved" + } + + func save( + originalURL _: URL, + rawURL _: URL?, + capturedAt _: Date, + recordIdentifier: @escaping @Sendable (String) async throws -> Void, + ) async throws -> String { + count += 1; try await recordIdentifier("saved"); return "saved" + } +} + +actor ScriptedPublisher: PublishingDestination { + nonisolated let id = PublishingDestinationID(rawValue: "probe") + nonisolated let inputs: Set = [.sequenceHighlight] + var count = 0 + func isEnabled() -> Bool { + true + } + + func recover(checkpoint: Data?, action: PublishingRecoveryAction) -> PublishingRecoveryResult { + switch action { + case .retry: .retry(checkpoint: checkpoint) + case .confirmedAbsent: .retry(checkpoint: nil) + case let .published(url): .delivered(PublishingReceipt( + remoteID: url.lastPathComponent, + url: url, + )) + } + } + + func deliver( + _: PublishingInput, + deliveryID _: PublishingDelivery.ID, + checkpoint _: Data?, + saveCheckpoint: @escaping @Sendable (Data) async throws -> Void, + ) async throws -> PublishingReceipt { + count += 1 + try await saveCheckpoint(Data("checkpoint".utf8)) + return PublishingReceipt(remoteID: "post", url: URL(string: "https://example.com/post")!) + } +} + +struct CaptureHarness { + let root: URL + let store: CaptureStore + let clock: CaptureTestClock + let camera = ScriptedCamera() + let publisher = ScriptedPublisher() + let event: SolarEvent + init() throws { + root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + store = try CaptureStore(root: root) + event = SolarEvent( + id: .init(year: 2026, month: 6, day: 21, kind: .sunrise), + date: Date(timeIntervalSince1970: 1_782_046_800), + ) + clock = CaptureTestClock(event.date.addingTimeInterval(-1800)) + } + + func engine(photos: any PhotosSaving, scorer: any ImageScoring) -> CaptureEngine { + CaptureEngine( + store: store, + camera: camera, + solar: ScriptedSolar(event: event), + photos: photos, + scorer: scorer, + destinations: [publisher], + log: Log(system: Periscope( + configuration: .init(), + sinks: [], + )), + now: { clock.now }, + ) + } + + func clean() throws { + try FileManager.default.removeItem(at: root) + } +} + +struct FailingPhotos: PhotosSaving { + func requestAccess() -> Bool { + false + } + + func contains(assetIdentifier _: String) -> Bool { + false + } + + func save( + originalURL _: URL, + rawURL _: URL?, + capturedAt _: Date, + recordIdentifier _: @escaping @Sendable (String) async throws -> Void, + ) throws + -> String + { + throw PhotosSaveFailure(message: DaylightError.photosPermission.localizedDescription) + } +} + +struct FailingScorer: ImageScoring { + func score(_: Data) throws -> ImageScore { + throw DaylightError.invalidImage + } +} + +actor GatedPublisher: PublishingDestination { + nonisolated let id = PublishingDestinationID(rawValue: "gated") + nonisolated let inputs: Set = [.sequenceHighlight] + private var entered = false + private var enterWaiter: CheckedContinuation? + private var releaseWaiter: CheckedContinuation? + func isEnabled() async -> Bool { + entered = true + enterWaiter?.resume(); enterWaiter = nil + await withCheckedContinuation { releaseWaiter = $0 } + return true + } + + func waitUntilEntered() async { + if entered { return } + await withCheckedContinuation { enterWaiter = $0 } + } + + func release() { + releaseWaiter?.resume(); releaseWaiter = nil + } + + func recover(checkpoint: Data?, action: PublishingRecoveryAction) -> PublishingRecoveryResult { + switch action { + case .retry: .retry(checkpoint: checkpoint) + case .confirmedAbsent: .retry(checkpoint: nil) + case let .published(url): .delivered(PublishingReceipt( + remoteID: url.lastPathComponent, + url: url, + )) + } + } + + func deliver( + _ input: PublishingInput, + deliveryID _: PublishingDelivery.ID, + checkpoint _: Data?, + saveCheckpoint _: @escaping @Sendable (Data) async throws -> Void, + ) throws -> PublishingReceipt { + _ = try Data(contentsOf: input.imageURL) + return PublishingReceipt(remoteID: "gated", url: URL(string: "https://example.com/gated")!) + } +} diff --git a/Daylight/DaylightCore/Tests/CaptureEngineTests.swift b/Daylight/DaylightCore/Tests/CaptureEngineTests.swift new file mode 100644 index 00000000..5257ff99 --- /dev/null +++ b/Daylight/DaylightCore/Tests/CaptureEngineTests.swift @@ -0,0 +1,316 @@ +@_spi(Testing) @testable import DaylightCore +import Foundation +import PeriscopeCore +import Testing + +struct CaptureEngineTests { + @Test func capturesOnceSkipsMissedSlotsAndPublishesOneHighlight() async throws { + let folder = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + defer { do { try FileManager.default.removeItem(at: folder) } catch { Issue.record(error) } + } + let event = SolarEvent( + id: .init(year: 2026, month: 6, day: 21, kind: .sunrise), + date: Date(timeIntervalSince1970: 1_782_046_800), + ) + let clock = CaptureTestClock(event.date.addingTimeInterval(-1800)) + let camera = ScriptedCamera(); let photos = ScriptedPhotos(); let publisher = + ScriptedPublisher() + let store = try CaptureStore(root: folder) + let logging = Periscope(configuration: .init(), sinks: []) + let engine = CaptureEngine( + store: store, + camera: camera, + solar: ScriptedSolar(event: event), + photos: photos, + scorer: ScriptedScorer(), + destinations: [publisher], + log: Log(system: logging), + now: { clock.now }, + ) + _ = try await engine.load() + try await engine.tick(canCapture: true) + try await engine.tick(canCapture: true) + #expect(await camera.count == 1) + #expect(await photos.count == 1) + var settings = CaptureSettings.standard + settings.camera.zoom = 2 + try await engine.configure(settings) + clock.advance(3631) + try await engine.tick(canCapture: true) + let sequence = try #require(await engine.history().first) + #expect(sequence.images.count == 1) + #expect(sequence.settings.camera.zoom == 1) + #expect(sequence.slots + .count(where: { if case .missed = $0.state { true } else { false } }) == 12) + try await engine.publishPending() + try await engine.publishPending() + #expect(await publisher.count == 1) + let persisted = try #require(try await store.sequences().first) + #expect(persisted.deliveries.count == 1) + if case .delivered = persisted.deliveries[0].state {} + else { Issue.record("Delivery did not finish") } + } + + @Test func reconcilesRecordedPhotosIdentifierWithoutAnotherSave() async throws { + let folder = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + defer { do { try FileManager.default.removeItem(at: folder) } catch { Issue.record(error) } + } + let event = SolarEvent( + id: .init(year: 2026, month: 6, day: 21, kind: .sunset), + date: Date(timeIntervalSince1970: 1_782_046_800), + ) + let clock = CaptureTestClock(event.date.addingTimeInterval(-1790)) + var sequence = CaptureSequence(event: event, settings: .standard) + let slot = sequence.slots[0] + var image = CapturedImage(id: slot.id, capturedAt: slot.scheduledAt, format: .jpeg) + image.photos = .saving("saved") + sequence.slots[0].state = .captured(image) + let store = try CaptureStore(root: folder) + try await store.stage(Data("image".utf8), imageID: slot.id, resource: .original) + try await store.save(sequence) + let photos = ScriptedPhotos() + let engine = CaptureEngine( + store: store, + camera: ScriptedCamera(), + solar: ScriptedSolar(event: event), + photos: photos, + scorer: ScriptedScorer(), + destinations: [], + log: Log(system: Periscope(configuration: .init(), sinks: [])), + now: { clock.now }, + ) + _ = try await engine.load() + try await engine.tick(canCapture: true) + #expect(await photos.count == 0) + let recovered = try #require(await engine.history().first?.images.first) + #expect(recovered.photos == .saved("saved")) + } + + @Test func fullSequenceSurvivesClockReversalWithoutDuplicates() async throws { + let fixture = try CaptureHarness() + defer { do { try fixture.clean() } catch { Issue.record(error) } } + let engine = fixture.engine(photos: ScriptedPhotos(), scorer: ScriptedScorer()) + _ = try await engine.load() + for _ in 0 ..< 13 { + try await engine.tick(canCapture: true) + fixture.clock.advance(-60) + try await engine.tick(canCapture: true) + fixture.clock.advance(360) + } + try await engine.tick(canCapture: true) + try await engine.publishPending() + #expect(await fixture.camera.count == 13) + #expect(await fixture.publisher.count == 1) + } + + @Test func diskExhaustionPausesBeforeCameraAndRetainsFutureSlots() async throws { + let fixture = try CaptureHarness() + defer { do { try fixture.clean() } catch { Issue.record(error) } } + let engine = fixture.engine(photos: ScriptedPhotos(), scorer: ScriptedScorer()) + _ = try await engine.load() + await fixture.store.overrideAvailableCapacity(0) + await #expect(throws: DaylightError.self) { try await engine.tick(canCapture: true) } + #expect(await fixture.camera.count == 0) + await fixture.store.overrideAvailableCapacity(1_000_000_000) + fixture.clock.advance(300) + try await engine.tick(canCapture: true) + #expect(await fixture.camera.count == 1) + #expect(await engine.history().first?.slots.first?.state == .missed) + } + + @Test func photosFailureDoesNotBlockSelectionOrPublishing() async throws { + let fixture = try CaptureHarness() + defer { do { try fixture.clean() } catch { Issue.record(error) } } + let engine = fixture.engine(photos: FailingPhotos(), scorer: ScriptedScorer()) + _ = try await engine.load() + try await engine.tick(canCapture: true) + fixture.clock.advance(3631) + try await engine.tick(canCapture: true) + try await engine.publishPending() + let image = try #require(await engine.history().first?.images.first) + if case .retry = image.photos {} else { Issue.record("Expected retryable Photos failure") } + #expect(await fixture.publisher.count == 1) + let original = await fixture.store.imageURL(image.id, resource: .original) + #expect(FileManager.default.fileExists(atPath: original.path)) + } + + @Test func scoringFailureRetainsImagesAndNeverPublishes() async throws { + let fixture = try CaptureHarness() + defer { do { try fixture.clean() } catch { Issue.record(error) } } + let engine = fixture.engine(photos: ScriptedPhotos(), scorer: FailingScorer()) + _ = try await engine.load() + try await engine.tick(canCapture: true) + fixture.clock.advance(3631) + try await engine.tick(canCapture: true) + try await engine.publishPending() + let sequence = try #require(await engine.history().first) + if case .failed = sequence.selection {} else { Issue.record("Expected failed selection") } + #expect(await fixture.publisher.count == 0) + let image = try #require(sequence.images.first) + let original = await fixture.store.imageURL(image.id, resource: .original) + #expect(FileManager.default.fileExists(atPath: original.path)) + } + + @Test func interruptedSlotIsRecoveredWithoutAnotherCapture() async throws { + let fixture = try CaptureHarness() + defer { do { try fixture.clean() } catch { Issue.record(error) } } + var sequence = CaptureSequence(event: fixture.event, settings: .standard) + sequence.slots[0].state = .capturing + try await fixture.store.stage( + Data("original".utf8), + imageID: sequence.slots[0].id, + resource: .original, + ) + try await fixture.store.save(sequence) + let engine = fixture.engine(photos: ScriptedPhotos(), scorer: ScriptedScorer()) + _ = try await engine.load() + try await engine.tick(canCapture: true) + #expect(await fixture.camera.count == 0) + #expect(await engine.history().first?.images.count == 1) + } +} + +extension CaptureEngineTests { + @Test(.timeLimit(.minutes(1))) func cleanupWaitsForHighlightRegistration() async throws { + let fixture = try CaptureHarness() + defer { do { try fixture.clean() } catch { Issue.record(error) } } + var sequence = CaptureSequence(event: fixture.event, settings: .standard) + for index in sequence.slots.indices { + sequence.slots[index].state = .missed + } + let slot = sequence.slots[0] + var image = CapturedImage(id: slot.id, capturedAt: slot.scheduledAt, format: .jpeg) + image.photos = .saved("saved"); image.score = .scored(.init( + overall: 0.8, + isUtility: false, + )); image.capturedEventHandled = true + sequence.slots[0].state = .captured(image) + try await fixture.store.save(sequence) + try await fixture.store.stage(Data("original".utf8), imageID: image.id, resource: .original) + fixture.clock.advance(3631) + let destination = GatedPublisher() + let engine = CaptureEngine( + store: fixture.store, + camera: fixture.camera, + solar: ScriptedSolar(event: fixture.event), + photos: ScriptedPhotos(), + scorer: ScriptedScorer(), + destinations: [destination], + log: Log(system: Periscope(configuration: .init(), sinks: [])), + now: { fixture.clock.now }, + ) + _ = try await engine.load() + let tick = Task { try await engine.tick(canCapture: false) } + await destination.waitUntilEntered() + try await engine.publishPending() + let url = await fixture.store.imageURL(image.id, resource: .original) + #expect(FileManager.default.fileExists(atPath: url.path)) + await destination.release() + try await tick.value + #expect(await engine.history().first?.deliveries.count == 1) + #expect(try await fixture.store.sequences().first?.deliveries.count == 1) + } +} + +extension CaptureEngineTests { + @Test func permissionRestorationRetriesPhotosAfterBackoffAcrossRelaunch() async throws { + let fixture = try CaptureHarness() + defer { do { try fixture.clean() } catch { Issue.record(error) } } + let first = fixture.engine(photos: FailingPhotos(), scorer: ScriptedScorer()) + _ = try await first.load() + try await first.tick(canCapture: true) + let photos = ScriptedPhotos() + let reopened = fixture.engine(photos: photos, scorer: ScriptedScorer()) + _ = try await reopened.load() + try await reopened.tick(canCapture: false) + #expect(await photos.count == 0) + fixture.clock.advance(31) + try await reopened.tick(canCapture: false) + #expect(await photos.count == 1) + #expect(await reopened.history().first?.images.first?.photos == .saved("saved")) + #expect(await fixture.camera.count == 1) + } + + @Test func ambiguousPhotoRequiresConfirmationAndChecksExistingReceipt() async throws { + let fixture = try CaptureHarness() + defer { do { try fixture.clean() } catch { Issue.record(error) } } + var sequence = CaptureSequence(event: fixture.event, settings: .standard) + let slot = sequence.slots[0] + var image = CapturedImage(id: slot.id, capturedAt: slot.scheduledAt, format: .jpeg) + image.photos = .ambiguous + sequence.slots[0].state = .captured(image) + try await fixture.store.save(sequence) + try await fixture.store.stage(Data("image".utf8), imageID: slot.id, resource: .original) + let photos = ScriptedPhotos() + let engine = fixture.engine(photos: photos, scorer: ScriptedScorer()) + _ = try await engine.load() + await #expect(throws: DaylightError.self) { try await engine.resolvePhotos( + imageID: image.id, + resolution: .retry, + ) } + let original = await fixture.store.imageURL(slot.id, resource: .original) + try Data("saved".utf8) + .write(to: original.deletingPathExtension().appendingPathExtension("photos-receipt")) + try await engine.resolvePhotos(imageID: image.id, resolution: .confirmedAbsent) + #expect(await engine.history().first?.images.first?.photos == .saved("saved")) + #expect(await photos.count == 0) + } + + @Test func deliveryRecoveryPreservesIdentityAndPriorCheckpoints() async throws { + let fixture = try CaptureHarness() + defer { do { try fixture.clean() } catch { Issue.record(error) } } + var sequence = CaptureSequence(event: fixture.event, settings: .standard) + var delivery = PublishingDelivery( + destination: fixture.publisher.id, + imageID: sequence.slots[0].id, + kind: .sequenceHighlight, + ) + delivery.checkpoint = Data("earlier submission".utf8) + delivery.state = .needsAttention("Check the previous post") + delivery.attempts = 3 + sequence.deliveries = [delivery] + try await fixture.store.save(sequence) + let engine = fixture.engine(photos: ScriptedPhotos(), scorer: ScriptedScorer()) + _ = try await engine.load() + try await engine.recoverDelivery( + sequenceID: sequence.id, + deliveryID: delivery.id, + action: .confirmedAbsent, + ) + let recovered = try #require(try await fixture.store.sequences().first?.deliveries.first) + #expect(recovered.id == delivery.id) + #expect(recovered.attempts == 3) + #expect(recovered.checkpoint == nil) + #expect(recovered.recoveryHistory == [Data("earlier submission".utf8)]) + if case .pending = recovered.state {} else { Issue.record("Delivery was not requeued") } + } + + @Test func recordedPostReceiptPreventsAnotherDelivery() async throws { + let fixture = try CaptureHarness() + defer { do { try fixture.clean() } catch { Issue.record(error) } } + var sequence = CaptureSequence(event: fixture.event, settings: .standard) + var delivery = PublishingDelivery( + destination: fixture.publisher.id, + imageID: sequence.slots[0].id, + kind: .sequenceHighlight, + ) + delivery.state = .needsAttention("Check the previous post") + sequence.deliveries = [delivery] + try await fixture.store.save(sequence) + let engine = fixture.engine(photos: ScriptedPhotos(), scorer: ScriptedScorer()) + _ = try await engine.load() + let url = try #require(URL(string: "https://example.com/@camera/123")) + try await engine.recoverDelivery( + sequenceID: sequence.id, + deliveryID: delivery.id, + action: .published(url), + ) + try await engine.publishPending() + let recovered = try #require(try await fixture.store.sequences().first?.deliveries.first) + if case let .delivered(receipt) = recovered.state { #expect(receipt.url == url) } + else { Issue.record("Post receipt was not recorded") } + #expect(await fixture.publisher.count == 0) + } +} diff --git a/Daylight/DaylightCore/Tests/CaptureSequenceTests.swift b/Daylight/DaylightCore/Tests/CaptureSequenceTests.swift new file mode 100644 index 00000000..6279dc4a --- /dev/null +++ b/Daylight/DaylightCore/Tests/CaptureSequenceTests.swift @@ -0,0 +1,20 @@ +@testable import DaylightCore +import Foundation +import Testing + +struct CaptureSequenceTests { + @Test func defaultSequenceContainsThirteenSlotsAndFreezesSettings() { + let event = SolarEvent( + id: .init(year: 2026, month: 6, day: 21, kind: .sunrise), + date: Date(timeIntervalSince1970: 10000), + ) + var settings = CaptureSettings.standard + let sequence = CaptureSequence(event: event, settings: settings) + settings.camera.zoom = 2 + #expect(sequence.slots.count == 13) + #expect(sequence.slots.first?.scheduledAt == event.date.addingTimeInterval(-1800)) + #expect(sequence.slots.last?.scheduledAt == event.date.addingTimeInterval(1800)) + #expect(Set(sequence.slots.map(\.id)).count == 13) + #expect(sequence.settings.camera.zoom == 1) + } +} diff --git a/Daylight/DaylightCore/Tests/CaptureStoreTests.swift b/Daylight/DaylightCore/Tests/CaptureStoreTests.swift new file mode 100644 index 00000000..dd025419 --- /dev/null +++ b/Daylight/DaylightCore/Tests/CaptureStoreTests.swift @@ -0,0 +1,43 @@ +@testable import DaylightCore +import Foundation +import Testing + +struct CaptureStoreTests { + @Test func roundTripsStateAcrossOwnersAndRejectsCorruption() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { do { try FileManager.default.removeItem(at: root) } catch { Issue.record(error) } } + let store = try CaptureStore(root: root) + var settings = CaptureSettings.standard + settings.camera.zoom = 2 + try await store.saveSettings(settings) + let event = SolarEvent( + id: .init(year: 2026, month: 6, day: 21, kind: .sunset), + date: Date(timeIntervalSince1970: 10000), + ) + let sequence = CaptureSequence(event: event, settings: settings) + try await store.save(sequence) + let reopened = try CaptureStore(root: root) + #expect(try await reopened.settings() == settings) + #expect(try await reopened.sequences() == [sequence]) + try Data("broken".utf8).write(to: root.appendingPathComponent("settings.json")) + await #expect(throws: (any Error).self) { try await reopened.settings() } + } + + @Test(arguments: [true, false]) + func stagesOriginalResourcesAndRemovesOnlyStagedCopies(hasRAW: Bool) async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { do { try FileManager.default.removeItem(at: root) } catch { Issue.record(error) } } + let store = try CaptureStore(root: root) + let imageID = CaptureSequence.Slot.ID(rawValue: UUID()) + let capture = CameraCapture(jpeg: Data("jpeg".utf8), raw: hasRAW ? Data("raw".utf8) : nil) + try await store.stageCapture(capture, imageID: imageID) + let jpegURL = await store.imageURL(imageID, resource: .original) + #expect(try Data(contentsOf: jpegURL) == capture.jpeg) + let rawURL = await store.rawURL(imageID) + #expect((rawURL != nil) == hasRAW) + if let rawURL { #expect(try Data(contentsOf: rawURL) == capture.raw) } + try await store.removeStagedFiles(imageID: imageID) + #expect(!FileManager.default.fileExists(atPath: jpegURL.path)) + #expect(await store.rawURL(imageID) == nil) + } +} diff --git a/Daylight/DaylightCore/Tests/CapturedImageTests.swift b/Daylight/DaylightCore/Tests/CapturedImageTests.swift new file mode 100644 index 00000000..6d8eb89f --- /dev/null +++ b/Daylight/DaylightCore/Tests/CapturedImageTests.swift @@ -0,0 +1,30 @@ +@testable import DaylightCore +import Foundation +import Testing + +struct CapturedImageTests { + @Test func readsCaptureFromBeforeRAWAndDeliveryCheckpoints() throws { + let data = Data( + #"{"id":{"rawValue":"00000000-0000-0000-0000-000000000001"},"capturedAt":1000,"photos":{"saved":{"_0":"existing-asset"}},"score":{"pending":{}},"recipe":{"preset":"original"}}"# + .utf8, + ) + let image = try JSONDecoder().decode(CapturedImage.self, from: data) + #expect(image.format == nil) + #expect(image.capturedEventHandled == nil) + #expect(image.photos == .saved("existing-asset")) + } + + @Test func retainsRAWFormatAndCompletedDeliveryCheckpoint() throws { + var image = CapturedImage( + id: .init(rawValue: UUID()), + capturedAt: Date(), + format: .rawAndJPEG, + ) + image.capturedEventHandled = true + let decoded = try JSONDecoder().decode( + CapturedImage.self, + from: JSONEncoder().encode(image), + ) + #expect(decoded == image) + } +} diff --git a/Daylight/DaylightCore/Tests/ImageSelectorTests.swift b/Daylight/DaylightCore/Tests/ImageSelectorTests.swift new file mode 100644 index 00000000..22414844 --- /dev/null +++ b/Daylight/DaylightCore/Tests/ImageSelectorTests.swift @@ -0,0 +1,30 @@ +@testable import DaylightCore +import Foundation +import Testing + +struct ImageSelectorTests { + @Test func selectionIgnoresUtilitiesAndBreaksTiesByEventDistance() throws { + let event = SolarEvent( + id: .init(year: 2026, month: 6, day: 21, kind: .sunrise), + date: Date(timeIntervalSince1970: 10000), + ) + var sequence = CaptureSequence(event: event, settings: .standard) + for index in [0, 6, 12] { + var image = CapturedImage( + id: sequence.slots[index].id, + capturedAt: sequence.slots[index].scheduledAt, + format: .jpeg, + ) + image.score = .scored(.init(overall: index == 12 ? 1 : 0.5, isUtility: index == 12)) + sequence.slots[index].state = .captured(image) + } + #expect(try ImageSelector().select(from: sequence).id == sequence.slots[6].id) + } + + @Test func noSuccessfulScoresThrows() { + let event = SolarEvent(id: .init(year: 2026, month: 6, day: 21, kind: .sunset), date: .now) + #expect(throws: DaylightError.self) { + try ImageSelector().select(from: CaptureSequence(event: event, settings: .standard)) + } + } +} diff --git a/Daylight/DaylightCore/Tests/JPEGExporterTests.swift b/Daylight/DaylightCore/Tests/JPEGExporterTests.swift new file mode 100644 index 00000000..dd28a03a --- /dev/null +++ b/Daylight/DaylightCore/Tests/JPEGExporterTests.swift @@ -0,0 +1,41 @@ +import CoreImage +@testable import DaylightCore +import ImageIO +import Testing +import UniformTypeIdentifiers + +struct JPEGExporterTests { + @Test func exportRemovesLocationAndCameraMetadataAfterOrientationIsApplied() throws { + let pixels = CIImage(color: .red).cropped(to: CGRect(x: 0, y: 0, width: 80, height: 40)) + let cgImage = try #require(CIContext().createCGImage(pixels, from: pixels.extent)) + let data = NSMutableData() + let destination = try #require(CGImageDestinationCreateWithData( + data, + UTType.jpeg.identifier as CFString, + 1, + nil, + )) + CGImageDestinationAddImage(destination, cgImage, [ + kCGImagePropertyOrientation: 6, + kCGImagePropertyGPSDictionary: [ + kCGImagePropertyGPSLatitude: 37.78, + kCGImagePropertyGPSLatitudeRef: "N", + ], + kCGImagePropertyTIFFDictionary: [kCGImagePropertyTIFFMake: "Private camera"], + ] as CFDictionary) + #expect(CGImageDestinationFinalize(destination)) + let rendered = try JPEGRenderer().renderJPEG( + data as Data, + maximumDimension: nil, + ) + let clean = try JPEGExporter().stripMetadata(rendered) + let source = try #require(CGImageSourceCreateWithData(clean as CFData, nil)) + let properties = + try #require(CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [String: Any]) + #expect(properties[kCGImagePropertyGPSDictionary as String] == nil) + #expect(properties[kCGImagePropertyTIFFDictionary as String] == nil) + let image = try #require(CGImageSourceCreateImageAtIndex(source, 0, nil)) + #expect(image.width == 40) + #expect(image.height == 80) + } +} diff --git a/Daylight/DaylightCore/Tests/JPEGRendererTests.swift b/Daylight/DaylightCore/Tests/JPEGRendererTests.swift new file mode 100644 index 00000000..6ce0b787 --- /dev/null +++ b/Daylight/DaylightCore/Tests/JPEGRendererTests.swift @@ -0,0 +1,21 @@ +import CoreImage +@testable import DaylightCore +import ImageIO +import Testing + +struct JPEGRendererTests { + @Test func derivativeRespectsDimensionsWithoutUpscaling() throws { + let pixels = CIImage(color: .red).cropped(to: CGRect(x: 0, y: 0, width: 80, height: 40)) + for limit in [40.0, 160.0] { + let data = try JPEGRenderer().renderJPEG(pixels, maximumDimension: limit) + let source = try #require(CGImageSourceCreateWithData(data as CFData, nil)) + let image = try #require(CGImageSourceCreateImageAtIndex(source, 0, nil)) + #expect(image.width == Int(min(limit, 80))) + #expect(image.height == image.width / 2) + } + #expect(throws: DaylightError.self) { try JPEGRenderer().renderJPEG( + pixels, + maximumDimension: .nan, + ) } + } +} diff --git a/Daylight/DaylightCore/Tests/ManualCaptureServiceTests.swift b/Daylight/DaylightCore/Tests/ManualCaptureServiceTests.swift new file mode 100644 index 00000000..99194a4d --- /dev/null +++ b/Daylight/DaylightCore/Tests/ManualCaptureServiceTests.swift @@ -0,0 +1,86 @@ +@testable import DaylightCore +import Foundation +import Testing + +struct ManualCaptureServiceTests { + @Test func shotSavesToPhotosWithoutPublishingAndPersistsArmingIntent() async throws { + let fixture = try CaptureHarness() + defer { do { try fixture.clean() } catch { Issue.record(error) } } + let engine = fixture.engine(photos: ScriptedPhotos(), scorer: ScriptedScorer()) + _ = try await engine.load() + try await engine.manualCapture() + try await engine.publishPending() + #expect(await fixture.publisher.count == 0) + let record = try #require(try await engine.manualHistory().first) + if case let .captured(image) = record.state { #expect(image.photos == .saved("saved")) } + else { Issue.record("Test shot was not saved") } + #expect(try await engine.armedIntent() == false) + try await engine.setArmedIntent(true) + let reopened = try CaptureStore(root: fixture.root) + #expect(try await reopened.armedIntent()) + try await engine.setArmedIntent(false) + #expect(try await reopened.armedIntent() == false) + } + + @Test func legacySavedShotRecoversWithoutRecaptureOrAnotherPhotosSave() async throws { + let fixture = try CaptureHarness() + defer { do { try fixture.clean() } catch { Issue.record(error) } } + let data = Data( + #"{"version":1,"value":{"id":{"rawValue":"00000000-0000-0000-0000-000000000001"},"date":1000,"state":{"captured":{"_0":{"id":{"rawValue":"00000000-0000-0000-0000-000000000001"},"capturedAt":1000,"photos":{"saved":{"_0":"existing-asset"}},"score":{"pending":{}},"recipe":{"preset":"original"}}}}}}"# + .utf8, + ) + let url = fixture.root.appendingPathComponent("manual-legacy.json") + try data.write(to: url) + let engine = fixture.engine(photos: ScriptedPhotos(), scorer: ScriptedScorer()) + _ = try await engine.load() + try await engine.tick(canCapture: false) + let record = try #require(try await engine.manualHistory().first) + guard case let .captured(image) = record.state else { + Issue.record("Saved legacy capture was lost"); return + } + #expect(image.photos == .saved("existing-asset")) + #expect(await fixture.camera.count == 0) + #expect(await fixture.publisher.count == 0) + #expect(try Data(contentsOf: url) == data) + } + + @Test func disarmedRecoveryFinishesStagedTestShotWithoutUsingCamera() async throws { + let fixture = try CaptureHarness() + defer { do { try fixture.clean() } catch { Issue.record(error) } } + let record = ManualCapture(date: fixture.clock.now) + try await fixture.store.saveManual(record) + try await fixture.store.stage( + Data("original".utf8), + imageID: record.id, + resource: .original, + ) + let engine = fixture.engine(photos: ScriptedPhotos(), scorer: ScriptedScorer()) + _ = try await engine.load() + try await engine.tick(canCapture: false) + #expect(await fixture.camera.count == 0) + #expect(await fixture.publisher.count == 0) + let recovered = try #require(try await engine.manualHistory().first) + if case let .captured(image) = recovered.state { #expect(image.photos == .saved("saved")) } + else { Issue.record("Test shot did not recover") } + } +} + +extension ManualCaptureServiceTests { + @Test func permissionFailureRetriesSavedManualBytesWithoutAnotherShutter() async throws { + let fixture = try CaptureHarness() + defer { do { try fixture.clean() } catch { Issue.record(error) } } + let first = fixture.engine(photos: FailingPhotos(), scorer: ScriptedScorer()) + _ = try await first.load() + await #expect(throws: PhotosSaveFailure.self) { try await first.manualCapture() } + fixture.clock.advance(31) + let photos = ScriptedPhotos() + let engine = fixture.engine(photos: photos, scorer: ScriptedScorer()) + _ = try await engine.load() + try await engine.tick(canCapture: false) + let record = try #require(try await engine.manualHistory().first) + if case let .captured(image) = record.state { #expect(image.photos == .saved("saved")) } + else { Issue.record("Manual shot was not recovered") } + #expect(await photos.count == 1) + #expect(await fixture.camera.count == 1) + } +} diff --git a/Daylight/DaylightCore/Tests/PhotosRecoveryTests.swift b/Daylight/DaylightCore/Tests/PhotosRecoveryTests.swift new file mode 100644 index 00000000..f7d5ffbe --- /dev/null +++ b/Daylight/DaylightCore/Tests/PhotosRecoveryTests.swift @@ -0,0 +1,45 @@ +@testable import DaylightCore +import Foundation +import Testing + +struct PhotosRecoveryTests { + @Test func definiteFailureRetriesButUnknownOutcomeDoesNot() throws { + let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + .appendingPathExtension("jpg") + let date = Date(timeIntervalSince1970: 10000) + let failure = PhotosSaveFailure(message: "Permission denied") + #expect(try PhotosRecovery + .failure(failure, originalURL: url, recorded: nil, now: date) == .retry( + date.addingTimeInterval(30), + "Permission denied", + )) + #expect(try PhotosRecovery.failure( + DaylightError.interrupted, + originalURL: url, + recorded: nil, + now: date, + ) == .ambiguous) + #expect(try PhotosRecovery.failure( + DaylightError.interrupted, + originalURL: url, + recorded: "saved", + now: date, + ) == .saving("saved")) + } + + @Test func failureAfterCommitRetainsSidecarForReconciliation() throws { + let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + .appendingPathExtension("jpg") + let receipt = url.deletingPathExtension().appendingPathExtension("photos-receipt") + defer { + do { try FileManager.default.removeItem(at: receipt) } catch { Issue.record(error) } + } + try Data("saved".utf8).write(to: receipt) + #expect(try PhotosRecovery.failure( + DaylightError.interrupted, + originalURL: url, + recorded: nil, + now: Date(), + ) == .saving("saved")) + } +} diff --git a/Daylight/DaylightCore/Tests/SolarCalculatorTests.swift b/Daylight/DaylightCore/Tests/SolarCalculatorTests.swift new file mode 100644 index 00000000..c11f1749 --- /dev/null +++ b/Daylight/DaylightCore/Tests/SolarCalculatorTests.swift @@ -0,0 +1,45 @@ +@testable import DaylightCore +import Foundation +import Testing + +struct SolarCalculatorTests { + @Test(arguments: [ + "2026-03-08T12:00:00Z", + "2026-06-21T12:00:00Z", + "2026-11-01T12:00:00Z", + "2026-12-21T12:00:00Z", + ]) + func producesOrderedEventsOnLocalDay(iso: String) throws { + let date = try #require(ISO8601DateFormatter().date(from: iso)) + let events = try SolarCalculator().events(on: date, site: .sanFrancisco) + #expect(events.count == 2) + #expect(events[0].date < events[1].date) + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: "America/Los_Angeles")) + #expect(events.allSatisfy { calendar.isDate($0.date, inSameDayAs: date) }) + #expect(events[1].date.timeIntervalSince(events[0].date) > 8 * 3600) + } + + @Test func summerReferenceAndPolarNight() throws { + let date = try #require(ISO8601DateFormatter().date(from: "2026-06-21T12:00:00Z")) + let events = try SolarCalculator().events(on: date, site: .sanFrancisco) + let expected = try #require(ISO8601DateFormatter().date(from: "2026-06-21T12:48:00Z")) + #expect(abs(events[0].date.timeIntervalSince(expected)) < 5 * 60) + var polar = CaptureSettings.Site.sanFrancisco + polar.latitude = 89 + #expect(try SolarCalculator().events(on: date, site: polar).isEmpty) + } + + @Test func localDaySurvivesUTCMidnightAndInternationalDateLine() throws { + let formatter = ISO8601DateFormatter() + let beforeMidnight = try #require(formatter.date(from: "2026-06-22T00:05:00Z")) + let sf = try SolarCalculator().events(on: beforeMidnight, site: .sanFrancisco) + #expect(sf.allSatisfy { $0.id.day == 21 }) + var site = CaptureSettings.Site.sanFrancisco + site.latitude = 1.87; site.longitude = -157.4; site.timeZoneIdentifier = "Pacific/Kiritimati" + let events = try SolarCalculator().events(on: beforeMidnight, site: site) + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: site.timeZoneIdentifier)) + #expect(events.allSatisfy { calendar.isDate($0.date, inSameDayAs: beforeMidnight) }) + } +} diff --git a/Daylight/DaylightMastodon/AGENTS.md b/Daylight/DaylightMastodon/AGENTS.md new file mode 100644 index 00000000..48146389 --- /dev/null +++ b/Daylight/DaylightMastodon/AGENTS.md @@ -0,0 +1,13 @@ +# DaylightMastodon + +Read [the root contract](../../../AGENTS.md) and [Daylight](../AGENTS.md). See [README.md](README.md) for operation. + +Mastodon account settings, Keychain credentials, and delivery checkpoints. + +Import DaylightCore and system frameworks. Do not import app or UI code. Persist checkpoints before status submission. Never send credentials across origins. + +Use ./test for unit tests. UI snapshots belong to DaylightUISnapshotTests in the shared StuffSnapshotTests scheme. Device camera and Photos checks require the physical iPhone. + +Check submission age and wall-clock/uptime agreement immediately before each status request. Require reconciliation after clock discontinuity, reboot, or an unverifiable older checkpoint. Preserve unreadable configuration before reconnecting. + +Retain the original account binding when resetting a confirmed-absent delivery. Never clear submission uncertainty for an ordinary retry. diff --git a/Daylight/DaylightMastodon/README.md b/Daylight/DaylightMastodon/README.md new file mode 100644 index 00000000..575f51fd --- /dev/null +++ b/Daylight/DaylightMastodon/README.md @@ -0,0 +1,11 @@ +# DaylightMastodon + +Mastodon account settings, Keychain credentials, and delivery checkpoints. [Daylight](../README.md) describes setup and operation. The root Package.swift and Project.swift define the targets. + +Import DaylightCore and system frameworks. Do not import app or UI code. Persist checkpoints before status submission. Never send credentials across origins. + +Tests use injected services. The application remains disarmed on a fresh installation. Camera access and Photos access require the system permission prompts. + +Status retries check both wall time and uptime before submission. Clock changes, reboot, and checkpoints without an elapsed-time record require reconciliation. Damaged settings disable publishing and expose a configuration issue. Reconnecting preserves the damaged file and starts with automatic publishing disabled. + +History supports retry, confirmed absence, and recording an existing post URL. Uncertain submissions cannot use ordinary retry after expiry. Confirmed absence refreshes upload and caption settings while retaining the original account binding. Existing-post URLs must belong to that server. The engine retains replaced checkpoints as delivery recovery history. diff --git a/Daylight/DaylightMastodon/Sources/HTTPTransport.swift b/Daylight/DaylightMastodon/Sources/HTTPTransport.swift new file mode 100644 index 00000000..aed76987 --- /dev/null +++ b/Daylight/DaylightMastodon/Sources/HTTPTransport.swift @@ -0,0 +1,54 @@ +import Foundation + +public struct HTTPResponse: Sendable { + public let data: Data + public let status: Int + public let retryAfter: String? + public init(data: Data, status: Int, retryAfter: String?) { + self.data = data; self.status = status; self.retryAfter = retryAfter + } +} + +public protocol HTTPTransport: Sendable { + func send(_ request: URLRequest) async throws -> HTTPResponse +} + +public struct URLSessionTransport: HTTPTransport { + private let session: URLSession + public init() { + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = 30 + configuration.timeoutIntervalForResource = 90 + session = URLSession( + configuration: configuration, + delegate: SameOriginRedirects(), + delegateQueue: nil, + ) + } + + public func send(_ request: URLRequest) async throws -> HTTPResponse { + let (data, response) = try await session.data(for: request) + guard let response = response as? HTTPURLResponse + else { throw URLError(.badServerResponse) } + return HTTPResponse( + data: data, + status: response.statusCode, + retryAfter: response.value(forHTTPHeaderField: "Retry-After"), + ) + } +} + +private final class SameOriginRedirects: NSObject, URLSessionTaskDelegate { + func urlSession( + _: URLSession, + task: URLSessionTask, + willPerformHTTPRedirection _: HTTPURLResponse, + newRequest request: URLRequest, + completionHandler: @escaping @Sendable (URLRequest?) -> Void, + ) { + let original = task.originalRequest?.url + let sameOrigin = original?.host == request.url?.host && original?.port == request.url? + .port && original?.scheme == request.url?.scheme + completionHandler(sameOrigin ? request : nil) + } +} diff --git a/Daylight/DaylightMastodon/Sources/MastodonCredential.swift b/Daylight/DaylightMastodon/Sources/MastodonCredential.swift new file mode 100644 index 00000000..590b96a6 --- /dev/null +++ b/Daylight/DaylightMastodon/Sources/MastodonCredential.swift @@ -0,0 +1,10 @@ +import Foundation + +/// Binds a secret to its verified account even if persisting the connection subsequently fails. +public struct MastodonCredential: Codable, Sendable { + public let connection: MastodonSettings.Connection + public let token: String + public init(connection: MastodonSettings.Connection, token: String) { + self.connection = connection; self.token = token + } +} diff --git a/Daylight/DaylightMastodon/Sources/MastodonCredentials.swift b/Daylight/DaylightMastodon/Sources/MastodonCredentials.swift new file mode 100644 index 00000000..7187966b --- /dev/null +++ b/Daylight/DaylightMastodon/Sources/MastodonCredentials.swift @@ -0,0 +1,41 @@ +import Foundation +import Security + +public protocol MastodonCredentials: Sendable { + func read() throws -> MastodonCredential? + func write(_ credential: MastodonCredential) throws +} + +public struct KeychainMastodonCredentials: MastodonCredentials { + public init() {} + private var query: [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "com.stuff.daylight.mastodon", + kSecAttrAccount as String: "token", + ] + } + + public func read() throws -> MastodonCredential? { + var query = query + query[kSecReturnData as String] = true + 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 MastodonError.credentialStore(status) } + return try JSONDecoder().decode(MastodonCredential.self, from: data) + } + + public func write(_ credential: MastodonCredential) throws { + let values: [String: Any] = try [kSecValueData as String: JSONEncoder().encode(credential)] + let status = SecItemUpdate(query as CFDictionary, values as CFDictionary) + if status == errSecItemNotFound { + var addition = query.merging(values) { _, new in new } + addition[kSecAttrAccessible as String] = + kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + let added = SecItemAdd(addition as CFDictionary, nil) + guard added == errSecSuccess else { throw MastodonError.credentialStore(added) } + } else if status != errSecSuccess { throw MastodonError.credentialStore(status) } + } +} diff --git a/Daylight/DaylightMastodon/Sources/MastodonDestination.swift b/Daylight/DaylightMastodon/Sources/MastodonDestination.swift new file mode 100644 index 00000000..a8dd61ae --- /dev/null +++ b/Daylight/DaylightMastodon/Sources/MastodonDestination.swift @@ -0,0 +1,425 @@ +import DaylightCore +import Foundation + +/// One personal Mastodon account. Every remote checkpoint is persisted before the next side effect. +public actor MastodonDestination: PublishingDestination, MastodonManaging { + public nonisolated let id = PublishingDestinationID(rawValue: "mastodon") + public nonisolated let inputs: Set = [.sequenceHighlight] + private let transport: any HTTPTransport + private let credentials: any MastodonCredentials + private let settingsURL: URL + private enum ConfigurationState { + case ready(MastodonSettings) + case failed(String) + } + + private var configurationState: ConfigurationState + private var settings: MastodonSettings { + switch configurationState { + case let .ready(value): value + case .failed: .initial + } + } + + private let uptime: @Sendable () -> TimeInterval + private let now: @Sendable () -> Date + private struct Account: Decodable { let id: String; let acct: String } + private struct Media: Decodable { let id: String; let url: URL? } + private struct Status: Decodable { let id: String; let url: URL } + private struct Instance: Decodable { + let configuration: Configuration + struct Configuration: Decodable { + let mediaAttachments: Limits + let statuses: StatusLimits + } + + struct Limits: Decodable { + let imageSizeLimit: Int; let imageMatrixLimit: Int; let supportedMimeTypes: [String] + } + + struct StatusLimits: Decodable { let maxCharacters: Int } + } + + private struct Checkpoint: Codable { + var version = 1 + let connection: MastodonSettings.Connection + let caption: String + let description: String + let visibility: MastodonSettings.Visibility + var mediaID: String? + var firstSubmission: Date? + var submissionClock: SubmissionClock? + var refreshConfiguration: Bool? + } + + public init( + settingsURL: URL, + transport: any HTTPTransport, + credentials: any MastodonCredentials, + now: @escaping @Sendable () -> Date, + uptime: @escaping @Sendable () -> TimeInterval, + ) { + self.settingsURL = settingsURL; self.transport = transport; self + .credentials = credentials; self.now = now + self.uptime = uptime + do { + let value: MastodonSettings = if FileManager.default + .fileExists(atPath: settingsURL.path) + { + try JSONDecoder().decode(MastodonSettings.self, from: Data(contentsOf: settingsURL)) + } else { .initial } + guard value.version == 1 else { throw MastodonError.invalidResponse } + configurationState = .ready(value) + } catch { + configurationState = .failed(error.localizedDescription) + } + } + + public func configurationIssue() -> String? { + if case let .failed(message) = configurationState { return message } + return nil + } + + public func configuration() -> MastodonSettings { + settings + } + + public func isEnabled() -> Bool { + settings.enabled && settings.connection != nil + } + + public func connect(server value: String, token: String) async throws -> MastodonSettings { + guard let components = URLComponents(string: value + .trimmingCharacters(in: .whitespacesAndNewlines)), + components.scheme == "https", components.host != nil, components.user == nil, + components.password == nil, + components.query == nil, components.fragment == nil, + ["", "/"].contains(components.path), + let url = components.url else { throw MastodonError.invalidServer } + let secret = token.trimmingCharacters(in: .whitespacesAndNewlines) + guard !secret.isEmpty else { throw MastodonError.missingCredentials } + let data = try await request( + server: url, + path: "api/v1/accounts/verify_credentials", + token: secret, + method: "GET", + body: nil, + contentType: nil, + idempotencyKey: nil, + ) + let account = try JSONDecoder().decode(Account.self, from: data) + let connection = MastodonSettings.Connection( + server: url, + accountID: account.id, + username: account.acct, + ) + var updated = settings + updated.connection = connection; updated.enabled = false + try credentials.write(MastodonCredential(connection: connection, token: secret)) + try persist(updated) + return updated + } + + public func update( + enabled: Bool, + visibility: MastodonSettings.Visibility, + caption: String, + ) throws { + guard case .ready = configurationState else { throw MastodonError.invalidResponse } + guard !enabled || settings.connection != nil else { throw MastodonError.missingCredentials } + var updated = settings + updated.enabled = enabled; updated.visibility = visibility; updated.caption = caption + try persist(updated) + } + + private func persist(_ value: MastodonSettings) throws { + if case .failed = configurationState, + FileManager.default.fileExists(atPath: settingsURL.path) + { + let backup = settingsURL.deletingLastPathComponent() + .appendingPathComponent("mastodon-invalid-\(UUID().uuidString).json") + try FileManager.default.copyItem(at: settingsURL, to: backup) + } + try JSONEncoder().encode(value).write(to: settingsURL, options: .atomic) + configurationState = .ready(value) + } + + public func deliver( + _ input: PublishingInput, + deliveryID: PublishingDelivery.ID, + checkpoint: Data?, + saveCheckpoint: @escaping @Sendable (Data) async throws + -> Void, + ) async throws -> PublishingReceipt { + guard settings.enabled, let connection = settings.connection, + let credential = try credentials.read() + else { throw MastodonError.missingCredentials } + guard credential.connection == connection else { + throw PublishingFailure + .needsAttention( + "Reconnect the displayed account before publishing. The saved token belongs to a different account.", + ) + } + let token = credential.token + var progress: Checkpoint + if let checkpoint { + progress = try JSONDecoder().decode(Checkpoint.self, from: checkpoint) + guard progress.version == 1, progress.connection == connection else { + throw PublishingFailure + .needsAttention( + "The account changed. Review this queued post before publishing.", + ) + } + if progress.refreshConfiguration == true { + progress = try makeCheckpoint(input: input, connection: connection) + try await saveCheckpoint(JSONEncoder().encode(progress)) + } + } else { + progress = try makeCheckpoint(input: input, connection: connection) + try await saveCheckpoint(JSONEncoder().encode(progress)) + } + try validateSubmission(progress) + if progress.mediaID == nil { + let instanceData = try await request( + server: connection.server, + path: "api/v2/instance", + token: token, + method: "GET", + body: nil, + contentType: nil, + idempotencyKey: nil, + ) + let decoder = JSONDecoder(); decoder.keyDecodingStrategy = .convertFromSnakeCase + let instance = try decoder.decode(Instance.self, from: instanceData) + guard progress.caption.count <= instance.configuration.statuses.maxCharacters else { + throw PublishingFailure + .needsAttention("Shorten the caption to fit this server's post limit.") + } + let limits = instance.configuration.mediaAttachments + guard limits.supportedMimeTypes.contains("image/jpeg"), limits.imageSizeLimit > 0, + limits.imageMatrixLimit > 0 + else { + throw PublishingFailure + .needsAttention("This server does not accept JPEG photographs.") + } + let source = try Data(contentsOf: input.imageURL) + var dimension = min(2560, sqrt(Double(limits.imageMatrixLimit))) + var jpeg = try JPEGExporter().stripMetadata(JPEGRenderer().renderJPEG( + source, + maximumDimension: dimension, + )) + while jpeg.count > limits.imageSizeLimit, dimension > 320 { + dimension *= 0.75 + jpeg = try JPEGExporter().stripMetadata(JPEGRenderer().renderJPEG( + source, + maximumDimension: dimension, + )) + } + guard jpeg.count <= limits.imageSizeLimit + else { + throw PublishingFailure + .needsAttention("The photograph cannot fit this server's upload limit.") + } + let boundary = UUID().uuidString + let header = [ + "--\(boundary)", + #"Content-Disposition: form-data; name="description""#, + "", + progress.description, + "--\(boundary)", + #"Content-Disposition: form-data; name="file"; filename="daylight.jpg""#, + "Content-Type: image/jpeg", + "", + "", + ].joined(separator: "\r\n") + var body = Data(header.utf8) + body.append(jpeg); body.append(Data("\r\n--\(boundary)--\r\n".utf8)) + let data = try await request( + server: connection.server, + path: "api/v2/media", + token: token, + method: "POST", + body: body, + contentType: "multipart/form-data; boundary=\(boundary)", + idempotencyKey: nil, + ) + progress.mediaID = try JSONDecoder().decode(Media.self, from: data).id + try await saveCheckpoint(JSONEncoder().encode(progress)) + } + guard let mediaID = progress.mediaID else { throw MastodonError.invalidResponse } + let mediaData = try await request( + server: connection.server, + path: "api/v1/media/\(mediaID)", + token: token, + method: "GET", + body: nil, + contentType: nil, + idempotencyKey: nil, + ) + guard try JSONDecoder().decode(Media.self, from: mediaData).url != nil else { + throw PublishingFailure.retry( + after: now().addingTimeInterval(15), + message: "Mastodon is processing the photo.", + ) + } + if progress.firstSubmission == nil { + let date = now() + progress.firstSubmission = date + progress.submissionClock = SubmissionClock(date: date, uptime: uptime()) + try await saveCheckpoint(JSONEncoder().encode(progress)) + } + struct Post: Encodable { + let status: String; let media_ids: [String]; let visibility: String + } + let body = try JSONEncoder().encode(Post( + status: progress.caption, + media_ids: [mediaID], + visibility: progress.visibility.rawValue, + )) + try Task.checkCancellation() + try validateSubmission(progress) + let data = try await request( + server: connection.server, + path: "api/v1/statuses", + token: token, + method: "POST", + body: body, + contentType: "application/json", + idempotencyKey: deliveryID.rawValue.uuidString, + ) + let status = try JSONDecoder().decode(Status.self, from: data) + return PublishingReceipt(remoteID: status.id, url: status.url) + } + + private func makeCheckpoint( + input: PublishingInput, + connection: MastodonSettings.Connection, + ) throws -> Checkpoint { + guard let zone = TimeZone(identifier: input.timeZoneIdentifier) + else { throw MastodonError.invalidResponse } + let date = input.image.capturedAt.formatted(Date.FormatStyle( + date: .abbreviated, + time: .omitted, + timeZone: zone, + )) + let time = input.image.capturedAt.formatted(Date.FormatStyle( + date: .omitted, + time: .shortened, + timeZone: zone, + )) + let event = input.event.id.kind == .sunrise ? "Sunrise" : "Sunset" + return Checkpoint( + connection: connection, + caption: settings.caption.replacingOccurrences( + of: "{event}", + with: event, + ).replacingOccurrences(of: "{date}", with: date), + description: "Camera view near \(event.lowercased()), photographed on \(date) at \(time).", + visibility: settings.visibility, + ) + } + + private func resetForRecovery(_ checkpoint: Checkpoint?) throws -> Data? { + guard var checkpoint else { return nil } + checkpoint.firstSubmission = nil + checkpoint.submissionClock = nil + checkpoint.mediaID = nil + checkpoint.refreshConfiguration = true + return try JSONEncoder().encode(checkpoint) + } + + public func recover( + checkpoint: Data?, + action: PublishingRecoveryAction, + ) throws -> PublishingRecoveryResult { + guard let connection = settings.connection else { throw MastodonError.missingCredentials } + let progress = try checkpoint.map { try JSONDecoder().decode(Checkpoint.self, from: $0) } + if let progress { + guard progress.version == 1, progress.connection == connection else { + throw PublishingFailure + .needsAttention("Reconnect the original account to recover this delivery.") + } + } + switch action { + case .retry: + if let progress, progress.firstSubmission != nil { + try validateSubmission(progress) + return .retry(checkpoint: checkpoint) + } + return try .retry(checkpoint: resetForRecovery(progress)) + case .confirmedAbsent: + return try .retry(checkpoint: resetForRecovery(progress)) + case let .published(url): + guard url.scheme == "https", url.host == connection.server.host, + url.port == connection.server.port, url.user == nil, url.password == nil, + url.query == nil, url.fragment == nil, + !url.lastPathComponent.isEmpty, + url.lastPathComponent.allSatisfy(\.isNumber) + else { + throw PublishingFailure + .needsAttention( + "Enter the published post's URL on the original Mastodon server.", + ) + } + return .delivered(PublishingReceipt(remoteID: url.lastPathComponent, url: url)) + } + } + + private func validateSubmission(_ progress: Checkpoint) throws { + guard progress.firstSubmission != nil else { return } + guard let clock = progress.submissionClock else { + throw PublishingFailure + .needsAttention( + "Check Mastodon before retrying this older submission; its elapsed time cannot be verified.", + ) + } + try clock.validate(date: now(), uptime: uptime()) + } + + private func request( + server: URL, + path: String, + token: String, + method: String, + body: Data?, + contentType: String?, + idempotencyKey: String?, + ) async throws -> Data { + var request = URLRequest(url: server.appendingPathComponent(path)) + request.httpMethod = method; request.httpBody = body + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue(contentType, forHTTPHeaderField: "Content-Type") + request.setValue(idempotencyKey, forHTTPHeaderField: "Idempotency-Key") + let response = try await transport.send(request) + if response.status == 206 { throw PublishingFailure.retry( + after: now().addingTimeInterval(15), + message: "Mastodon is processing the photo.", + ) } + if response.status == 429 || response.status >= 500 { + let retryDate: Date + if let header = response.retryAfter, let seconds = Double(header), seconds.isFinite { + retryDate = now().addingTimeInterval(max(1, seconds)) + } else if let header = response.retryAfter { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = .gmt + formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss z" + retryDate = max( + now().addingTimeInterval(1), + formatter.date(from: header) ?? now().addingTimeInterval(60), + ) + } else { retryDate = now().addingTimeInterval(60) } + throw PublishingFailure.retry( + after: retryDate, + message: "Mastodon is temporarily unavailable (\(response.status)).", + ) + } + guard (200 ... 299).contains(response.status) else { + throw PublishingFailure + .needsAttention( + "Mastodon rejected the request (\(response.status)). Check the account, token permissions, and queued post.", + ) + } + return response.data + } +} diff --git a/Daylight/DaylightMastodon/Sources/MastodonError.swift b/Daylight/DaylightMastodon/Sources/MastodonError.swift new file mode 100644 index 00000000..bbb94088 --- /dev/null +++ b/Daylight/DaylightMastodon/Sources/MastodonError.swift @@ -0,0 +1,13 @@ +import Foundation + +public enum MastodonError: LocalizedError, Sendable { + case invalidServer, missingCredentials, invalidResponse, credentialStore(Int32) + public var errorDescription: String? { + switch self { + case .invalidServer: "Enter an HTTPS Mastodon server URL without a path or login information." + case .missingCredentials: "Connect your Mastodon account before enabling publishing." + case .invalidResponse: "The Mastodon server returned an unexpected response." + case let .credentialStore(status): "Keychain could not store or read the access token (\(status))." + } + } +} diff --git a/Daylight/DaylightMastodon/Sources/MastodonManaging.swift b/Daylight/DaylightMastodon/Sources/MastodonManaging.swift new file mode 100644 index 00000000..7b1f686c --- /dev/null +++ b/Daylight/DaylightMastodon/Sources/MastodonManaging.swift @@ -0,0 +1,12 @@ +import Foundation + +public protocol MastodonManaging: Sendable { + func configurationIssue() async -> String? + func configuration() async -> MastodonSettings + func connect(server: String, token: String) async throws -> MastodonSettings + func update( + enabled: Bool, + visibility: MastodonSettings.Visibility, + caption: String, + ) async throws +} diff --git a/Daylight/DaylightMastodon/Sources/MastodonSettings.swift b/Daylight/DaylightMastodon/Sources/MastodonSettings.swift new file mode 100644 index 00000000..e30bb0f2 --- /dev/null +++ b/Daylight/DaylightMastodon/Sources/MastodonSettings.swift @@ -0,0 +1,18 @@ +import Foundation + +public struct MastodonSettings: Codable, Equatable, Sendable { + public enum Visibility: String, CaseIterable, Codable, + Sendable { case `public`, unlisted, `private` } + public struct Connection: Codable, Equatable, Sendable { + public let server: URL + public let accountID: String + public let username: String + } + + public var version = 1 + public var connection: Connection? + public var enabled = false + public var visibility: Visibility = .public + public var caption = "{event} over San Francisco Bay — {date}." + public static let initial = Self() +} diff --git a/Daylight/DaylightMastodon/Sources/SubmissionClock.swift b/Daylight/DaylightMastodon/Sources/SubmissionClock.swift new file mode 100644 index 00000000..93cbb696 --- /dev/null +++ b/Daylight/DaylightMastodon/Sources/SubmissionClock.swift @@ -0,0 +1,20 @@ +import DaylightCore +import Foundation + +/// A status retry is safe only while wall time and uptime agree within the same boot. +struct SubmissionClock: Codable { + let date: Date + let uptime: TimeInterval + + func validate(date currentDate: Date, uptime currentUptime: TimeInterval) throws { + let wallElapsed = currentDate.timeIntervalSince(date) + let elapsed = currentUptime - uptime + guard wallElapsed >= 0, elapsed >= 0, max(wallElapsed, elapsed) < 3500, + abs(wallElapsed - elapsed) < 5 + else { + throw PublishingFailure.needsAttention( + "The previous post may have succeeded. Check Mastodon before retrying; the clock or duplicate-protection window changed.", + ) + } + } +} diff --git a/Daylight/DaylightMastodon/Tests/MastodonDestinationTests.swift b/Daylight/DaylightMastodon/Tests/MastodonDestinationTests.swift new file mode 100644 index 00000000..bb7d53e9 --- /dev/null +++ b/Daylight/DaylightMastodon/Tests/MastodonDestinationTests.swift @@ -0,0 +1,367 @@ +import CoreImage +import DaylightCore +@testable import DaylightMastodon +import Foundation +import Testing + +struct MastodonDestinationTests { + @Test func connectsDisabledAndRejectsUnsafeURLs() async throws { + let folder = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + defer { do { try FileManager.default.removeItem(at: folder) } catch { Issue.record(error) } + } + let transport = ScriptedHTTPTransport([.init( + data: Data(#"{"id":"123","acct":"camera"}"#.utf8), + status: 200, + retryAfter: nil, + )]) + let destination = MastodonDestination( + settingsURL: folder.appendingPathComponent("settings.json"), + transport: transport, + credentials: MemoryCredentials(), + now: { Date(timeIntervalSince1970: 0) }, + uptime: { 0 }, + ) + let configuration = try await destination.connect( + server: "https://example.com", + token: "secret", + ) + #expect(configuration.connection?.username == "camera") + #expect(await destination.isEnabled() == false) + await #expect(throws: MastodonError.self) { try await destination.connect( + server: "http://example.com", + token: "secret", + ) } + await #expect(throws: MastodonError.self) { try await destination.connect( + server: "https://example.com/path", + token: "secret", + ) } + #expect(await transport.requests.count == 1) + } + + @Test func uploadPersistsCheckpointsAndReturnsReceipt() async throws { + let folder = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + defer { do { try FileManager.default.removeItem(at: folder) } catch { Issue.record(error) } + } + let payloads = [ + #"{"id":"123","acct":"camera"}"#, + #"{"configuration":{"media_attachments":{"image_size_limit":1000000,"image_matrix_limit":1000000,"supported_mime_types":["image/jpeg"]},"statuses":{"max_characters":500}}}"#, + #"{"id":"42","url":null}"#, + #"{"id":"42","url":"https://example.com/photo.jpg"}"#, + #"{"id":"99","url":"https://example.com/@camera/99"}"#, + ] + let transport = ScriptedHTTPTransport(payloads.map { .init( + data: Data($0.utf8), + status: 200, + retryAfter: nil, + ) }) + let destination = MastodonDestination( + settingsURL: folder.appendingPathComponent("settings.json"), + transport: transport, + credentials: MemoryCredentials(), + now: { Date(timeIntervalSince1970: 0) }, + uptime: { 0 }, + ) + _ = try await destination.connect(server: "https://example.com", token: "secret") + try await destination.update( + enabled: true, + visibility: .unlisted, + caption: "{event} — {date}", + ) + let url = folder.appendingPathComponent("image.jpg") + let pixels = CIImage(color: .red).cropped(to: CGRect(x: 0, y: 0, width: 32, height: 32)) + try JPEGRenderer().renderJPEG(pixels, maximumDimension: nil) + .write(to: url) + let event = SolarEvent( + id: .init(year: 2026, month: 6, day: 21, kind: .sunset), + date: Date(timeIntervalSince1970: 0), + ) + let image = CapturedImage( + id: .init(rawValue: UUID()), + capturedAt: event.date, + format: .jpeg, + ) + let input = PublishingInput( + kind: .sequenceHighlight, + image: image, + event: event, + timeZoneIdentifier: "America/Los_Angeles", + imageURL: url, + rawURL: nil, + ) + let checkpoint = CheckpointRecorder() + let deliveryID = PublishingDelivery.ID(rawValue: UUID()) + let receipt = try await destination + .deliver(input, deliveryID: deliveryID, checkpoint: nil) { await checkpoint.save($0) } + #expect(receipt.remoteID == "99") + #expect(await checkpoint.value != nil) + let requests = await transport.requests + #expect(requests.last?.value(forHTTPHeaderField: "Idempotency-Key") == deliveryID.rawValue + .uuidString) + #expect(requests.count == 5) + } + + @Test func processingDelayResumesWithoutUploadingAgain() async throws { + let fixture = try MastodonHarness(responses: [ + MastodonHarness.instance, + MastodonHarness.uploaded, + .init(data: Data(), status: 206, retryAfter: nil), + MastodonHarness.processed, + MastodonHarness.posted, + ]) + defer { do { try fixture.clean() } catch { Issue.record(error) } } + try await fixture.connect() + let input = try fixture.input() + await #expect(throws: PublishingFailure.self) { try await fixture.deliver(input) } + let receipt = try await fixture.deliver(input) + #expect(receipt.remoteID == "99") + let requests = await fixture.transport.requests + #expect(requests.count(where: { $0.url?.path == "/api/v2/media" }) == 1) + #expect(requests.count(where: { $0.url?.path == "/api/v1/statuses" }) == 1) + } + + @Test(arguments: [401, 429, 503]) + func handlesAuthenticationAndRateLimits(status: Int) async throws { + let fixture = try MastodonHarness(responses: [.init( + data: Data(), + status: status, + retryAfter: "120", + )]) + defer { do { try fixture.clean() } catch { Issue.record(error) } } + try await fixture.connect() + do { + _ = try await fixture.deliver(fixture.input()) + Issue.record("Request unexpectedly succeeded") + } catch let failure as PublishingFailure { + switch failure { + case let .retry(after, _): + #expect(status != 401) + #expect(after == fixture.clock.now.addingTimeInterval(120)) + case .needsAttention: #expect(status == 401) + } + } + } + + @Test func expiredAmbiguousSubmissionStopsBeforeNetwork() async throws { + let fixture = try MastodonHarness(responses: [ + MastodonHarness.instance, + MastodonHarness.uploaded, + MastodonHarness.processed, + ]) + defer { do { try fixture.clean() } catch { Issue.record(error) } } + try await fixture.connect() + let input = try fixture.input() + // The transport loses the response to the status submission. + await #expect(throws: URLError.self) { try await fixture.deliver(input) } + let count = await fixture.transport.requests.count + fixture.clock.advance(3601) + do { + _ = try await fixture.deliver(input) + Issue.record("Expired submission was retried") + } catch let failure as PublishingFailure { + if case .needsAttention = failure {} else { Issue.record("Expected reconciliation") } + } + #expect(await fixture.transport.requests.count == count) + } + + @Test func mismatchedCredentialCannotSubmitThroughAnotherAccount() async throws { + let fixture = try MastodonHarness(responses: []) + defer { do { try fixture.clean() } catch { Issue.record(error) } } + try await fixture.connect() + let configuration = await fixture.destination.configuration() + let connection = try #require(configuration.connection) + fixture.credentials.write(MastodonCredential(connection: .init( + server: connection.server, + accountID: "different-account", + username: "different", + ), token: "different-secret")) + await #expect(throws: PublishingFailure.self) { try await fixture.deliver(fixture.input()) } + #expect(await fixture.transport.requests.count == 1) + } +} + +extension MastodonDestinationTests { + @Test func rechecksExpiryAfterMediaRequest() async throws { + let fixture = try MastodonHarness(responses: [ + MastodonHarness.instance, + MastodonHarness.uploaded, + MastodonHarness.processed, + ]) + defer { do { try fixture.clean() } catch { Issue.record(error) } } + try await fixture.connect() + let input = try fixture.input() + await #expect(throws: URLError.self) { try await fixture.deliver(input) } + fixture.clock.advance(3490) + await fixture.transport.replaceResponses([ + MastodonHarness.processed, + MastodonHarness.posted, + ]) { request in + if request.url?.path == "/api/v1/media/42" { fixture.clock.advance(200) } + } + await #expect(throws: PublishingFailure.self) { try await fixture.deliver(input) } + #expect(await fixture.transport.requests + .count(where: { $0.url?.path == "/api/v1/statuses" }) == 1) + } + + @Test func corruptConfigurationRemainsDisabledAndCanReconnectWithoutLosingEvidence( + ) async throws { + let fixture = try MastodonHarness(responses: []) + defer { do { try fixture.clean() } catch { Issue.record(error) } } + let url = fixture.root.appendingPathComponent("broken.json") + let damaged = Data("invalid settings".utf8) + try damaged.write(to: url) + let destination = MastodonDestination( + settingsURL: url, + transport: fixture.transport, + credentials: fixture.credentials, + now: { fixture.clock.now }, + uptime: { fixture.clock.uptime }, + ) + #expect(await destination.configurationIssue() != nil) + #expect(await destination.isEnabled() == false) + await #expect(throws: MastodonError.self) { try await destination.update( + enabled: true, + visibility: .public, + caption: "caption", + ) } + _ = try await destination.connect(server: "https://example.com", token: "secret") + #expect(await destination.configurationIssue() == nil) + #expect(await destination.isEnabled() == false) + let backups = try FileManager.default.contentsOfDirectory( + at: fixture.root, + includingPropertiesForKeys: nil, + ).filter { $0.lastPathComponent.hasPrefix("mastodon-invalid-") } + #expect(backups.count == 1) + #expect(try Data(contentsOf: #require(backups.first)) == damaged) + } +} + +extension MastodonDestinationTests { + @Test func uncertainDeliveryCanBeExplicitlyReconciledWithoutBlindRetry() async throws { + let fixture = try MastodonHarness(responses: [ + MastodonHarness.instance, + MastodonHarness.uploaded, + MastodonHarness.processed, + ]) + defer { do { try fixture.clean() } catch { Issue.record(error) } } + try await fixture.connect() + await #expect(throws: URLError.self) { try await fixture.deliver(fixture.input()) } + fixture.clock.advance(3601) + let checkpoint = try #require(await fixture.checkpoints.value) + await #expect(throws: PublishingFailure.self) { try await fixture.destination.recover( + checkpoint: checkpoint, + action: .retry, + ) } + let result = try await fixture.destination.recover( + checkpoint: checkpoint, + action: .confirmedAbsent, + ) + if case let .retry(checkpoint) = result { + let retained = try #require(checkpoint) + let object = try #require(JSONSerialization + .jsonObject(with: retained) as? [String: Any]) + #expect(object["connection"] != nil) + #expect(object["firstSubmission"] == nil) + } else { Issue.record("Confirmed missing post was not released for retry") } + let url = try #require(URL(string: "https://example.com/@camera/99")) + let recorded = try await fixture.destination.recover( + checkpoint: checkpoint, + action: .published(url), + ) + if case let .delivered(receipt) = recorded { #expect(receipt.remoteID == "99") } + else { Issue.record("Existing post was not recorded") } + let wrongServer = try #require(URL(string: "https://different.example/@camera/99")) + await #expect(throws: PublishingFailure.self) { try await fixture.destination.recover( + checkpoint: checkpoint, + action: .published(wrongServer), + ) } + } +} + +extension MastodonDestinationTests { + @Test func confirmedRetryRefreshesCaptionWithoutChangingDeliveryIdentity() async throws { + let fixture = try MastodonHarness(responses: [ + MastodonHarness.instance, + MastodonHarness.uploaded, + MastodonHarness.processed, + ]) + defer { do { try fixture.clean() } catch { Issue.record(error) } } + try await fixture.connect() + let input = try fixture.input() + await #expect(throws: URLError.self) { try await fixture.deliver(input) } + let recovered = try await fixture.destination.recover( + checkpoint: fixture.checkpoints.value, + action: .confirmedAbsent, + ) + guard case let .retry(checkpoint) = recovered + else { Issue.record("Expected retry"); return } + try await fixture.destination.update( + enabled: true, + visibility: .private, + caption: "Replacement {event}", + ) + await fixture.transport.replaceResponses( + [ + MastodonHarness.instance, + MastodonHarness.uploaded, + MastodonHarness.processed, + MastodonHarness.posted, + ], + onRequest: { _ in }, + ) + _ = try await fixture.destination.deliver( + input, + deliveryID: fixture.deliveryID, + checkpoint: checkpoint, + ) { await fixture.checkpoints.save($0) } + let request = try #require(await fixture.transport.requests.last) + #expect(request.value(forHTTPHeaderField: "Idempotency-Key") == fixture.deliveryID.rawValue + .uuidString) + let body = try #require(request.httpBody) + let object = try #require(JSONSerialization.jsonObject(with: body) as? [String: Any]) + #expect(object["status"] as? String == "Replacement Sunset") + } + + @Test func confirmedRetryRemainsBoundToOriginalAccount() async throws { + let fixture = try MastodonHarness(responses: [ + MastodonHarness.instance, + MastodonHarness.uploaded, + MastodonHarness.processed, + ]) + defer { do { try fixture.clean() } catch { Issue.record(error) } } + try await fixture.connect() + let input = try fixture.input() + await #expect(throws: URLError.self) { try await fixture.deliver(input) } + let recovered = try await fixture.destination.recover( + checkpoint: fixture.checkpoints.value, + action: .confirmedAbsent, + ) + guard case let .retry(checkpoint) = recovered + else { Issue.record("Expected retry"); return } + await fixture.transport.replaceResponses( + [MastodonHarness.response(#"{"id":"other","acct":"other"}"#)], + onRequest: { _ in }, + ) + _ = try await fixture.destination.connect( + server: "https://example.com", + token: "other-token", + ) + try await fixture.destination.update( + enabled: true, + visibility: .private, + caption: "caption", + ) + let count = await fixture.transport.requests.count + await #expect(throws: PublishingFailure.self) { + try await fixture.destination.deliver( + input, + deliveryID: fixture.deliveryID, + checkpoint: checkpoint, + ) { await fixture.checkpoints.save($0) } + } + #expect(await fixture.transport.requests.count == count) + } +} diff --git a/Daylight/DaylightMastodon/Tests/MastodonTestSupport.swift b/Daylight/DaylightMastodon/Tests/MastodonTestSupport.swift new file mode 100644 index 00000000..6b763476 --- /dev/null +++ b/Daylight/DaylightMastodon/Tests/MastodonTestSupport.swift @@ -0,0 +1,140 @@ +import CoreImage +import DaylightCore +@testable import DaylightMastodon +import Foundation +import Synchronization + +actor ScriptedHTTPTransport: HTTPTransport { + var responses: [HTTPResponse] + var requests: [URLRequest] = [] + private var onRequest: (@Sendable (URLRequest) -> Void)? + func replaceResponses( + _ values: [HTTPResponse], + onRequest: @escaping @Sendable (URLRequest) -> Void, + ) { + responses = values; self.onRequest = onRequest + } + + init(_ responses: [HTTPResponse]) { + self.responses = responses + } + + func send(_ request: URLRequest) throws -> HTTPResponse { + onRequest?(request) + requests.append(request) + guard !responses.isEmpty else { throw URLError(.cannotConnectToHost) } + return responses.removeFirst() + } +} + +final class MemoryCredentials: MastodonCredentials { + let token = Mutex(nil) + func read() -> MastodonCredential? { + token.withLock { $0 } + } + + func write(_ value: MastodonCredential) { + token.withLock { $0 = value } + } +} + +actor CheckpointRecorder { + var value: Data? + func save(_ data: Data) { + value = data + } +} + +struct MastodonHarness { + let root: URL + let transport: ScriptedHTTPTransport + let destination: MastodonDestination + let clock = MastodonTestClock() + let checkpoints = CheckpointRecorder() + let credentials = MemoryCredentials() + let deliveryID = PublishingDelivery.ID(rawValue: UUID()) + init(responses: [HTTPResponse]) throws { + root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + transport = + ScriptedHTTPTransport([Self.response(#"{"id":"123","acct":"camera"}"#)] + responses) + destination = MastodonDestination( + settingsURL: root.appendingPathComponent("settings.json"), + transport: transport, + credentials: credentials, + now: { [clock] in clock.now }, + uptime: { [clock] in clock.uptime }, + ) + } + + func connect() async throws { + _ = try await destination.connect(server: "https://example.com", token: "secret") + try await destination.update(enabled: true, visibility: .private, caption: "{event} {date}") + } + + func input() throws -> PublishingInput { + let url = root.appendingPathComponent("image.jpg") + let pixels = CIImage(color: .red).cropped(to: CGRect(x: 0, y: 0, width: 32, height: 32)) + try JPEGRenderer().renderJPEG(pixels, maximumDimension: nil) + .write(to: url) + let event = SolarEvent( + id: .init(year: 2026, month: 6, day: 21, kind: .sunset), + date: clock.now, + ) + return PublishingInput( + kind: .sequenceHighlight, + image: CapturedImage( + id: .init(rawValue: UUID()), + capturedAt: clock.now, + format: .jpeg, + ), + event: event, + timeZoneIdentifier: "America/Los_Angeles", + imageURL: url, + rawURL: nil, + ) + } + + func deliver(_ input: PublishingInput) async throws -> PublishingReceipt { + try await destination + .deliver(input, deliveryID: deliveryID, checkpoint: checkpoints.value) { + await checkpoints.save($0) + } + } + + static func response(_ json: String) -> HTTPResponse { + HTTPResponse(data: Data(json.utf8), status: 200, retryAfter: nil) + } + + static let instance = + response( + #"{"configuration":{"media_attachments":{"image_size_limit":1000000,"image_matrix_limit":1000000,"supported_mime_types":["image/jpeg"]},"statuses":{"max_characters":500}}}"#, + ) + static let uploaded = response(#"{"id":"42","url":null}"#) + static let processed = response(#"{"id":"42","url":"https://example.com/photo.jpg"}"#) + static let posted = response(#"{"id":"99","url":"https://example.com/@camera/99"}"#) + func clean() throws { + try FileManager.default.removeItem(at: root) + } +} + +final class MastodonTestClock: Sendable { + private let date = Mutex(Date(timeIntervalSince1970: 10000)) + private let elapsed = Mutex(10000.0) + var uptime: TimeInterval { + elapsed.withLock { $0 } + } + + func adjustWallClock(_ seconds: TimeInterval) { + date.withLock { $0 = $0.addingTimeInterval(seconds) } + } + + var now: Date { + date.withLock { $0 } + } + + func advance(_ seconds: TimeInterval) { + date.withLock { $0 = $0.addingTimeInterval(seconds) } + elapsed.withLock { $0 += seconds } + } +} diff --git a/Daylight/DaylightMastodon/Tests/SubmissionClockTests.swift b/Daylight/DaylightMastodon/Tests/SubmissionClockTests.swift new file mode 100644 index 00000000..26fd2b88 --- /dev/null +++ b/Daylight/DaylightMastodon/Tests/SubmissionClockTests.swift @@ -0,0 +1,39 @@ +import DaylightCore +@testable import DaylightMastodon +import Foundation +import Testing + +struct SubmissionClockTests { + @Test(arguments: [0.0, 60, 3499]) + func acceptsElapsedTimeWithinSameBoot(seconds: TimeInterval) throws { + let start = Date(timeIntervalSince1970: 10000) + try SubmissionClock(date: start, uptime: 100).validate( + date: start.addingTimeInterval(seconds), + uptime: 100 + seconds, + ) + } + + @Test(arguments: [3500.0, 3601, -1]) + func rejectsExpiryAndReversedTime(seconds: TimeInterval) { + let start = Date(timeIntervalSince1970: 10000) + #expect(throws: PublishingFailure.self) { + try SubmissionClock(date: start, uptime: 100).validate( + date: start.addingTimeInterval(seconds), + uptime: 100 + seconds, + ) + } + } + + @Test func rejectsClockAdjustmentAndReboot() { + let start = Date(timeIntervalSince1970: 10000) + let clock = SubmissionClock(date: start, uptime: 100) + #expect(throws: PublishingFailure.self) { try clock.validate( + date: start.addingTimeInterval(30), + uptime: 160, + ) } + #expect(throws: PublishingFailure.self) { try clock.validate( + date: start.addingTimeInterval(30), + uptime: 10, + ) } + } +} diff --git a/Daylight/DaylightUI/AGENTS.md b/Daylight/DaylightUI/AGENTS.md new file mode 100644 index 00000000..e417d171 --- /dev/null +++ b/Daylight/DaylightUI/AGENTS.md @@ -0,0 +1,15 @@ +# DaylightUI + +Read [the root contract](../../../AGENTS.md) and [Daylight](../AGENTS.md). See [README.md](README.md) for operation. + +SwiftUI presentation, Broadway appearance, and observable models. + +Import Core and Mastodon contracts. Keep capture and persistence in services. Route screen actions through DaylightModel. + +Use ./test for unit tests. UI snapshots belong to DaylightUISnapshotTests in the shared StuffSnapshotTests scheme. Device camera and Photos checks require the physical iPhone. + +Keep live framing independent of archive readiness. Check cancellation after camera permission returns and before starting the preview. + +Scope capture-loop state changes and cleanup to the current foreground run and arming revision. Await any camera stop already in flight before starting preview or another run. + +Route recovery through typed controller actions. Require explicit absence confirmation for uncertain saves or posts; recording a post URL must not submit a replacement. diff --git a/Daylight/DaylightUI/README.md b/Daylight/DaylightUI/README.md new file mode 100644 index 00000000..6b456de2 --- /dev/null +++ b/Daylight/DaylightUI/README.md @@ -0,0 +1,13 @@ +# DaylightUI + +SwiftUI presentation, Broadway appearance, and observable models. [Daylight](../README.md) describes setup and operation. The root Package.swift and Project.swift define the targets. + +Import Core and Mastodon contracts. Keep capture and persistence in services. Route screen actions through DaylightModel. + +Tests use injected services. The application remains disarmed on a fresh installation. Camera access and Photos access require the system permission prompts. + +The setup screen starts live framing and requests camera access independently of archive loading. Archive failures remain visible and keep capture controls disabled. Photos access is requested when saving a test shot or arming capture. + +Foreground runs carry an ownership identity. A completed capture cycle cannot undo a stop action, and cleanup from an older run cannot stop the current session. Camera stop operations finish before a new preview or foreground run starts. + +Sequence history links to Photos and publishing recovery forms. Unknown outcomes require checking Photos or the original Mastodon account before creating replacements. Users can record an existing post URL without publishing again. Recovery forms include standard and accessibility snapshot coverage. diff --git a/Daylight/DaylightUI/SnapshotTests/DaylightContentViewSnapshotTests.swift b/Daylight/DaylightUI/SnapshotTests/DaylightContentViewSnapshotTests.swift new file mode 100644 index 00000000..05dcf695 --- /dev/null +++ b/Daylight/DaylightUI/SnapshotTests/DaylightContentViewSnapshotTests.swift @@ -0,0 +1,10 @@ +@testable import DaylightUI +import SnapshotKitTesting +import Testing + +@MainActor +struct DaylightContentViewSnapshotTests { + @Test func screens() async { + await assertSnapshots(of: DaylightContentView.self) + } +} diff --git a/Daylight/DaylightUI/SnapshotTests/DeliveryRecoveryViewSnapshotTests.swift b/Daylight/DaylightUI/SnapshotTests/DeliveryRecoveryViewSnapshotTests.swift new file mode 100644 index 00000000..90007071 --- /dev/null +++ b/Daylight/DaylightUI/SnapshotTests/DeliveryRecoveryViewSnapshotTests.swift @@ -0,0 +1,10 @@ +@testable import DaylightUI +import SnapshotKitTesting +import Testing + +@MainActor +struct DeliveryRecoveryViewSnapshotTests { + @Test func screen() async { + await assertSnapshots(of: DeliveryRecoveryView.self) + } +} diff --git a/Daylight/DaylightUI/SnapshotTests/MastodonSettingsViewSnapshotTests.swift b/Daylight/DaylightUI/SnapshotTests/MastodonSettingsViewSnapshotTests.swift new file mode 100644 index 00000000..d17ec2d7 --- /dev/null +++ b/Daylight/DaylightUI/SnapshotTests/MastodonSettingsViewSnapshotTests.swift @@ -0,0 +1,10 @@ +@testable import DaylightUI +import SnapshotKitTesting +import Testing + +@MainActor +struct MastodonSettingsViewSnapshotTests { + @Test func screen() async { + await assertSnapshots(of: MastodonSettingsView.self) + } +} diff --git a/Daylight/DaylightUI/SnapshotTests/PhotosRecoveryViewSnapshotTests.swift b/Daylight/DaylightUI/SnapshotTests/PhotosRecoveryViewSnapshotTests.swift new file mode 100644 index 00000000..85fb9e66 --- /dev/null +++ b/Daylight/DaylightUI/SnapshotTests/PhotosRecoveryViewSnapshotTests.swift @@ -0,0 +1,10 @@ +@testable import DaylightUI +import SnapshotKitTesting +import Testing + +@MainActor +struct PhotosRecoveryViewSnapshotTests { + @Test func screen() async { + await assertSnapshots(of: PhotosRecoveryView.self) + } +} diff --git a/Daylight/DaylightUI/SnapshotTests/ScheduleSettingsViewSnapshotTests.swift b/Daylight/DaylightUI/SnapshotTests/ScheduleSettingsViewSnapshotTests.swift new file mode 100644 index 00000000..d414fb0f --- /dev/null +++ b/Daylight/DaylightUI/SnapshotTests/ScheduleSettingsViewSnapshotTests.swift @@ -0,0 +1,10 @@ +@testable import DaylightUI +import SnapshotKitTesting +import Testing + +@MainActor +struct ScheduleSettingsViewSnapshotTests { + @Test func screen() async { + await assertSnapshots(of: ScheduleSettingsView.self) + } +} diff --git a/Daylight/DaylightUI/SnapshotTests/SequenceHistoryViewSnapshotTests.swift b/Daylight/DaylightUI/SnapshotTests/SequenceHistoryViewSnapshotTests.swift new file mode 100644 index 00000000..fc4ada3b --- /dev/null +++ b/Daylight/DaylightUI/SnapshotTests/SequenceHistoryViewSnapshotTests.swift @@ -0,0 +1,10 @@ +@testable import DaylightUI +import SnapshotKitTesting +import Testing + +@MainActor +struct SequenceHistoryViewSnapshotTests { + @Test func screen() async { + await assertSnapshots(of: SequenceHistoryView.self) + } +} diff --git a/Daylight/DaylightUI/SnapshotTests/__Snapshots__/DaylightContentViewSnapshotTests/screens.Accessibility_iPhone.png b/Daylight/DaylightUI/SnapshotTests/__Snapshots__/DaylightContentViewSnapshotTests/screens.Accessibility_iPhone.png new file mode 100644 index 00000000..2a8a4b3f --- /dev/null +++ b/Daylight/DaylightUI/SnapshotTests/__Snapshots__/DaylightContentViewSnapshotTests/screens.Accessibility_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:21048c97d9d9d3e67cda8b156ae12836c7ccef1a58ccea5b02857f8a29c4e375 +size 563366 diff --git a/Daylight/DaylightUI/SnapshotTests/__Snapshots__/DaylightContentViewSnapshotTests/screens.Armed_iPhone.png b/Daylight/DaylightUI/SnapshotTests/__Snapshots__/DaylightContentViewSnapshotTests/screens.Armed_iPhone.png new file mode 100644 index 00000000..a216b346 --- /dev/null +++ b/Daylight/DaylightUI/SnapshotTests/__Snapshots__/DaylightContentViewSnapshotTests/screens.Armed_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ed17ba610a9f84ea6e77964bc94b273c38fe2348a784c6f7e4be570bd83856d8 +size 151669 diff --git a/Daylight/DaylightUI/SnapshotTests/__Snapshots__/DaylightContentViewSnapshotTests/screens.Interrupted_iPhone.png b/Daylight/DaylightUI/SnapshotTests/__Snapshots__/DaylightContentViewSnapshotTests/screens.Interrupted_iPhone.png new file mode 100644 index 00000000..5961b768 --- /dev/null +++ b/Daylight/DaylightUI/SnapshotTests/__Snapshots__/DaylightContentViewSnapshotTests/screens.Interrupted_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ce43dcf744cd6f70ac4f3ccf649c64ff7dcd64115f2f1ee538ce0c5acc35f803 +size 174377 diff --git a/Daylight/DaylightUI/SnapshotTests/__Snapshots__/DaylightContentViewSnapshotTests/screens.Setup_iPhone.png b/Daylight/DaylightUI/SnapshotTests/__Snapshots__/DaylightContentViewSnapshotTests/screens.Setup_iPhone.png new file mode 100644 index 00000000..3eecf177 --- /dev/null +++ b/Daylight/DaylightUI/SnapshotTests/__Snapshots__/DaylightContentViewSnapshotTests/screens.Setup_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0eddc56ec6a4a41177909c12f43831bc548cb2faf40f381a19389753f3251d7e +size 267803 diff --git a/Daylight/DaylightUI/SnapshotTests/__Snapshots__/DaylightContentViewSnapshotTests/screens.Setup_iPhone_dark.png b/Daylight/DaylightUI/SnapshotTests/__Snapshots__/DaylightContentViewSnapshotTests/screens.Setup_iPhone_dark.png new file mode 100644 index 00000000..4b6561ae --- /dev/null +++ b/Daylight/DaylightUI/SnapshotTests/__Snapshots__/DaylightContentViewSnapshotTests/screens.Setup_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:88922270ba5fbd80df96284adf4270a85840f3aa20da491e9b773e734ba79bec +size 251297 diff --git a/Daylight/DaylightUI/SnapshotTests/__Snapshots__/DeliveryRecoveryViewSnapshotTests/screen.Accessibility_iPhone.png b/Daylight/DaylightUI/SnapshotTests/__Snapshots__/DeliveryRecoveryViewSnapshotTests/screen.Accessibility_iPhone.png new file mode 100644 index 00000000..f6d7cebe --- /dev/null +++ b/Daylight/DaylightUI/SnapshotTests/__Snapshots__/DeliveryRecoveryViewSnapshotTests/screen.Accessibility_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e9a4bc5557eaf7252601fe625e53aaea3911598fcdd426087778e54454a6454f +size 292795 diff --git a/Daylight/DaylightUI/SnapshotTests/__Snapshots__/DeliveryRecoveryViewSnapshotTests/screen.Standard_iPhone.png b/Daylight/DaylightUI/SnapshotTests/__Snapshots__/DeliveryRecoveryViewSnapshotTests/screen.Standard_iPhone.png new file mode 100644 index 00000000..8354826e --- /dev/null +++ b/Daylight/DaylightUI/SnapshotTests/__Snapshots__/DeliveryRecoveryViewSnapshotTests/screen.Standard_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c5fc86878ffb1ddde5e29ea046d91327d853ac24128f1a13e65b23b88d071b28 +size 154610 diff --git a/Daylight/DaylightUI/SnapshotTests/__Snapshots__/MastodonSettingsViewSnapshotTests/screen.ConfigurationFailure_iPhone.png b/Daylight/DaylightUI/SnapshotTests/__Snapshots__/MastodonSettingsViewSnapshotTests/screen.ConfigurationFailure_iPhone.png new file mode 100644 index 00000000..ab5ec2ea --- /dev/null +++ b/Daylight/DaylightUI/SnapshotTests/__Snapshots__/MastodonSettingsViewSnapshotTests/screen.ConfigurationFailure_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9128fa7c82634beec53f8ba107c61c233d43198430ca08b425f5d784c8f21c24 +size 145329 diff --git a/Daylight/DaylightUI/SnapshotTests/__Snapshots__/MastodonSettingsViewSnapshotTests/screen.MastodonSettingsView_iPhone.png b/Daylight/DaylightUI/SnapshotTests/__Snapshots__/MastodonSettingsViewSnapshotTests/screen.MastodonSettingsView_iPhone.png new file mode 100644 index 00000000..ae5c8ca2 --- /dev/null +++ b/Daylight/DaylightUI/SnapshotTests/__Snapshots__/MastodonSettingsViewSnapshotTests/screen.MastodonSettingsView_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1bc39ca0563a722bace8c7ac87a044a7830243b3111ffd51ab018034657af927 +size 118458 diff --git a/Daylight/DaylightUI/SnapshotTests/__Snapshots__/PhotosRecoveryViewSnapshotTests/screen.Accessibility_iPhone.png b/Daylight/DaylightUI/SnapshotTests/__Snapshots__/PhotosRecoveryViewSnapshotTests/screen.Accessibility_iPhone.png new file mode 100644 index 00000000..537ff97f --- /dev/null +++ b/Daylight/DaylightUI/SnapshotTests/__Snapshots__/PhotosRecoveryViewSnapshotTests/screen.Accessibility_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:586d83bf379d46dcdfc9b7282b412ad1cb2f020e1c654ed02bf484c949e5ae3b +size 258653 diff --git a/Daylight/DaylightUI/SnapshotTests/__Snapshots__/PhotosRecoveryViewSnapshotTests/screen.Standard_iPhone.png b/Daylight/DaylightUI/SnapshotTests/__Snapshots__/PhotosRecoveryViewSnapshotTests/screen.Standard_iPhone.png new file mode 100644 index 00000000..7f031b72 --- /dev/null +++ b/Daylight/DaylightUI/SnapshotTests/__Snapshots__/PhotosRecoveryViewSnapshotTests/screen.Standard_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fa7e9feb6e776c74e76319e049f0d00bdf03235e8a77430e7c99d1c93dbd0124 +size 142558 diff --git a/Daylight/DaylightUI/SnapshotTests/__Snapshots__/ScheduleSettingsViewSnapshotTests/screen.ScheduleSettingsView_iPhone.png b/Daylight/DaylightUI/SnapshotTests/__Snapshots__/ScheduleSettingsViewSnapshotTests/screen.ScheduleSettingsView_iPhone.png new file mode 100644 index 00000000..25851df7 --- /dev/null +++ b/Daylight/DaylightUI/SnapshotTests/__Snapshots__/ScheduleSettingsViewSnapshotTests/screen.ScheduleSettingsView_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:876dee30ed802873d5f05d67bc7d8f50be1aeca1eda9f1772aeb249ea95ea4e8 +size 242207 diff --git a/Daylight/DaylightUI/SnapshotTests/__Snapshots__/SequenceHistoryViewSnapshotTests/screen.Recovery_iPhone.png b/Daylight/DaylightUI/SnapshotTests/__Snapshots__/SequenceHistoryViewSnapshotTests/screen.Recovery_iPhone.png new file mode 100644 index 00000000..9f336ec6 --- /dev/null +++ b/Daylight/DaylightUI/SnapshotTests/__Snapshots__/SequenceHistoryViewSnapshotTests/screen.Recovery_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:84b8824c76e23d724c0f7b4e0468cacca10290ccdfd39f28a4adfb3dd7376cba +size 320750 diff --git a/Daylight/DaylightUI/SnapshotTests/__Snapshots__/SequenceHistoryViewSnapshotTests/screen.SequenceHistoryView_iPhone.png b/Daylight/DaylightUI/SnapshotTests/__Snapshots__/SequenceHistoryViewSnapshotTests/screen.SequenceHistoryView_iPhone.png new file mode 100644 index 00000000..96d62f9b --- /dev/null +++ b/Daylight/DaylightUI/SnapshotTests/__Snapshots__/SequenceHistoryViewSnapshotTests/screen.SequenceHistoryView_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d1b6258e8792f7582962f91311c24c3e0524c1d543d6c19c54695c314e9e90ee +size 295293 diff --git a/Daylight/DaylightUI/Sources/CameraSetupView.swift b/Daylight/DaylightUI/Sources/CameraSetupView.swift new file mode 100644 index 00000000..8794ad9c --- /dev/null +++ b/Daylight/DaylightUI/Sources/CameraSetupView.swift @@ -0,0 +1,50 @@ +import DaylightCore +import SwiftUI + +struct CameraSetupView: View { + @Bindable var model: DaylightModel + @Environment(\.daylightStylesheet) private var stylesheet + @Environment(\.scenePhase) private var scenePhase + private struct PreviewLifecycle: Equatable { + let key: DaylightModel.PreviewKey; let active: Bool + } + + var body: some View { + VStack { + Group { + if let image = model + .previewImage { Image(uiImage: image).resizable().scaledToFit() } + else { + Rectangle().fill(stylesheet.preview.background) + .overlay { Text(.cameraPreview).foregroundStyle(.white) } + } + } + .aspectRatio(stylesheet.preview.aspectRatio, contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: stylesheet.preview.cornerRadius)) + .accessibilityLabel(Text(.cameraPreview)) + Text(.cameraRawAutomatic).font(.footnote).foregroundStyle(.secondary) + Picker(selection: $model.settings.camera.lens) { + ForEach(model.lenses, id: \.self) { lens in Text(lens.title).tag(lens) } + } label: { Text(.cameraLens) } + LabeledContent { Text( + model.settings.camera.zoom, + format: .number.precision(.fractionLength(1)), + ) } label: { Text(.cameraZoom) } + Slider(value: $model.settings.camera.zoom, in: 1 ... 10) { Text(.cameraZoom) } + LabeledContent { Text( + model.settings.camera.exposureBias, + format: .number.precision(.fractionLength(1)), + ) } label: { Text(.cameraExposure) } + Slider(value: $model.settings.camera.exposureBias, in: -3 ... 3) { + Text(.cameraExposure) + } + } + .task(id: PreviewLifecycle(key: model.previewKey, active: scenePhase == .active)) { + if scenePhase == .active { await model.preview() } + } + } +} + +#if DEBUG + #Preview { CameraSetupView(model: DaylightPreviewSupport.model()).padding() } +#endif diff --git a/Daylight/DaylightUI/Sources/DaylightContentView.swift b/Daylight/DaylightUI/Sources/DaylightContentView.swift new file mode 100644 index 00000000..9a912924 --- /dev/null +++ b/Daylight/DaylightUI/Sources/DaylightContentView.swift @@ -0,0 +1,101 @@ +import DaylightCore +import DaylightMastodon +import SnapshotKit +import SwiftUI + +public struct DaylightContentView: View { + @Bindable var model: DaylightModel + public init(model: DaylightModel) { + self.model = model + } + + public var body: some View { + Group { + if model.isArmed { UnattendedCaptureView(model: model) } + else { NavigationStack { setup.navigationTitle(Text(.appTitle)) } } + } + } + + private var setup: some View { + Form { + Section { + CameraSetupView(model: model) + Button(.captureTest) { Task { await model.testShot() } } + .disabled(!model.ready || model.working) + } + Section { + Button(.captureStart) { Task { await model.toggleArmed() } } + .disabled(!model.ready || model.working) + Text(.captureInstructions).font(.footnote).foregroundStyle(.secondary) + if let next = model.nextCapture { LabeledContent { Text( + next, + format: .dateTime.month().day().hour().minute(), + ) } label: { Text(.captureNext) } } + } + if let notice = model.notice { + Section { + Text(notice).foregroundStyle(.secondary) + .accessibilityLabel(Text(.statusNotice)) + } + } + Section { + NavigationLink { ScheduleSettingsView(model: model) } label: { Text(.scheduleTitle) + } + NavigationLink { MastodonSettingsView(model: model) } label: { Text(.mastodonTitle) + } + NavigationLink { SequenceHistoryView( + model: model, + ) } label: { + Text(.historyTitle) + } + } + } + } +} + +#if DEBUG + extension DaylightContentView: SnapshotProviding { + public static var snapshots: [SnapshotCase] { + SnapshotCase( + name: "Setup", + configurations: SnapshotConfiguration.combinations( + devices: [.iPhoneFullContent], + colorSchemes: [.light, .dark], + ), + settle: .immediate, + ) { + DaylightContentView(model: DaylightModel.preview(mode: .setup, notice: nil)) + .broadwayRoot() + } + SnapshotCase( + name: "Armed", + configurations: SnapshotConfiguration.combinations(devices: [.iPhoneFullContent]), + settle: .immediate, + ) { + DaylightContentView(model: DaylightModel.preview(mode: .armed, notice: nil)) + .broadwayRoot() + } + SnapshotCase( + name: "Interrupted", + configurations: SnapshotConfiguration.combinations(devices: [.iPhoneFullContent]), + settle: .immediate, + ) { + DaylightContentView(model: DaylightModel.preview( + mode: .suspended("Capture paused while the phone cools."), + notice: "A photo needs your attention in Photos.", + )).broadwayRoot() + } + SnapshotCase( + name: "Accessibility", + configurations: SnapshotConfiguration.combinations(devices: [.iPhoneFullContent]), + settle: .immediate, + ) { + DaylightContentView(model: DaylightModel.preview(mode: .setup, notice: nil)) + .broadwayRoot() + .dynamicTypeSize(.accessibility3) + } + } + } + + #Preview { DaylightContentView.snapshotPreviews } +#endif diff --git a/Daylight/DaylightUI/Sources/DaylightModel.swift b/Daylight/DaylightUI/Sources/DaylightModel.swift new file mode 100644 index 00000000..8e01ef94 --- /dev/null +++ b/Daylight/DaylightUI/Sources/DaylightModel.swift @@ -0,0 +1,349 @@ +import DaylightCore +import DaylightMastodon +import Foundation +import Observation +import UIKit + +/// Observable presentation state. Services own capture, persistence, and delivery behavior. +@MainActor @Observable +public final class DaylightModel { + public enum Mode: Equatable { case setup, armed, suspended(String) } + public var settings = CaptureSettings.standard + public var account = MastodonSettings.initial + public var server = "" + public var token = "" + public private(set) var mode: Mode = .setup + public private(set) var manualHistory: [ManualCapture] = [] + public private(set) var history: [CaptureSequence] = [] + public private(set) var nextCapture: Date? + public private(set) var lenses: [CaptureSettings.Camera.Lens] = [.main] + public private(set) var previewImage: UIImage? + public private(set) var publishingIssue: String? + public private(set) var notice: String? + public private(set) var ready = false + public private(set) var working = false + private struct RunID: Equatable { let rawValue = UUID() } + private struct CameraStop { let id: RunID; let task: Task } + private var currentRun: RunID? + private var armRevision = RunID() + private var cameraStop: CameraStop? + private let readiness: any CaptureReadiness + private let engine: any CaptureControlling + private let camera: any CameraCapturing + private let photos: any PhotosSaving + private let mastodon: any MastodonManaging + private var screen: UIScreen? + private var previousBrightness: CGFloat? + private var previousIdleDisabled: Bool? + + public init( + engine: any CaptureControlling, + camera: any CameraCapturing, + photos: any PhotosSaving, + mastodon: any MastodonManaging, + readiness: any CaptureReadiness, + ) { + self.readiness = readiness + self.engine = engine; self.camera = camera; self.photos = photos; self.mastodon = mastodon + } + + public var isArmed: Bool { + switch mode { case .setup: false; case .armed, .suspended: true } + } + + public var previewKey: PreviewKey { + PreviewKey( + settings: settings.camera, + enabled: !isArmed && !working, + ) + } + + public struct PreviewKey: Equatable { let settings: CaptureSettings.Camera; let enabled: Bool } + public var recentHistory: [CaptureSequence] { + Array(history + .filter { + !$0.images.isEmpty || $0.slots + .contains { + switch $0 + .state { case .failed, .missed: true; case .pending, .capturing, + .captured: false } + } + }.prefix(14)) + } + + public func load() async { + guard !ready else { return } + do { + settings = try await engine.load() + if try await engine.armedIntent() { mode = .armed } + account = await mastodon.configuration() + publishingIssue = await mastodon.configurationIssue() + server = account.connection?.server.absoluteString ?? "" + lenses = await camera.availableLenses() + try await engine.plan() + await refresh() + manualHistory = try await engine.manualHistory() + ready = true + } catch { notice = error.localizedDescription } + } + + public func saveSettings() async { + guard !working else { return } + working = true; defer { working = false } + do { + try await engine.configure(settings); try await engine + .plan(); await refresh(); notice = nil + } catch { notice = error.localizedDescription } + } + + public func toggleArmed() async { + guard !working else { return } + working = true; defer { working = false } + armRevision = RunID() + if isArmed { + do { try await engine.setArmedIntent(false) } + catch { notice = error.localizedDescription; return } + mode = .setup; await stopCamera(); restoreScreen(); return + } + do { + try await engine.configure(settings) + guard await camera.requestAccess() else { throw DaylightError.cameraPermission } + guard await photos.requestAccess() else { throw DaylightError.photosPermission } + await stopCamera() + try await engine.setArmedIntent(true) + mode = .armed; notice = nil + dimScreen() + } catch { notice = error.localizedDescription } + } + + public func testShot() async { + guard !working, !isArmed else { return } + working = true; defer { working = false } + do { + try await engine.configure(settings) + guard await camera.requestAccess() else { throw DaylightError.cameraPermission } + guard await photos.requestAccess() else { throw DaylightError.photosPermission } + await stopCamera() + try await engine.manualCapture() + manualHistory = try await engine.manualHistory() + notice = String(localized: .captureTestSaved) + } catch { notice = error.localizedDescription } + } + + public func connect() async { + guard !working else { return } + working = true; defer { working = false } + do { + account = try await mastodon + .connect( + server: server, + token: token, + ); token = ""; notice = nil; publishingIssue = nil + } catch { notice = error.localizedDescription } + } + + public func savePublishing() async { + do { try await mastodon.update( + enabled: account.enabled, + visibility: account.visibility, + caption: account.caption, + ); notice = nil } catch { + notice = error.localizedDescription; account = await mastodon.configuration() + } + } + + public func resolvePhotos( + imageID: CaptureSequence.Slot.ID, + resolution: PhotosResolution, + ) async { + guard !working else { return } + working = true; defer { working = false } + do { + guard await photos.requestAccess() else { throw DaylightError.photosPermission } + try await engine.resolvePhotos(imageID: imageID, resolution: resolution) + await refresh(); notice = nil + } catch { notice = error.localizedDescription } + } + + public func recoverDelivery( + sequenceID: SolarEvent.ID, + deliveryID: PublishingDelivery.ID, + action: PublishingRecoveryAction, + ) async { + guard !working else { return } + working = true; defer { working = false } + do { + try await engine.recoverDelivery( + sequenceID: sequenceID, + deliveryID: deliveryID, + action: action, + ) + await refresh(); notice = nil + } catch { notice = error.localizedDescription } + } + + public func recordPublishedPost( + sequenceID: SolarEvent.ID, + deliveryID: PublishingDelivery.ID, + url: String, + ) async { + guard let url = URL(string: url.trimmingCharacters(in: .whitespacesAndNewlines)) else { + notice = String(localized: .recoveryInvalidURL); return + } + await recoverDelivery( + sequenceID: sequenceID, + deliveryID: deliveryID, + action: .published(url), + ) + } + + public func preview() async { + guard previewKey.enabled else { return } + await cameraStop?.task.value + guard await camera.requestAccess() + else { notice = DaylightError.cameraPermission.localizedDescription; return } + do { + await cameraStop?.task.value + try Task.checkCancellation() + guard previewKey.enabled else { return } + let stream = try await camera.preview( + settings: settings.camera, + ) + for try await data in stream { + guard !Task.isCancelled else { return } + previewImage = UIImage(data: data) + } + } catch is CancellationError { return } + catch { if !Task.isCancelled { notice = error.localizedDescription } } + } + + /// Stop requests are owned tasks: newer sessions wait for a stop already in flight. + private func stopCamera() async { + let previous = cameraStop?.task + let operation = CameraStop(id: RunID(), task: Task { [camera] in + await previous?.value + await camera.stop() + }) + cameraStop = operation + await operation.task.value + if cameraStop?.id == operation.id { cameraStop = nil } + } + + public func run(active: Bool) async { + let run = RunID() + currentRun = run + guard active else { restoreScreen(); await stopCamera(); return } + await cameraStop?.task.value + guard currentRun == run, !Task.isCancelled else { return } + await load() + guard ready, currentRun == run, !Task.isCancelled else { return } + if isArmed { dimScreen() } + await withTaskGroup(of: Void.self) { group in + group.addTask { await self.captureLoop(run: run) } + group.addTask { await self.publishingLoop(run: run) } + await group.waitForAll() + } + guard currentRun == run else { return } + await stopCamera() + guard currentRun == run else { return } + restoreScreen() + } + + private func captureLoop(run: RunID) async { + while currentRun == run, !Task.isCancelled { + let revision = armRevision + do { + if isArmed { + try readiness.check() + try await engine.tick(canCapture: true) + guard currentRun == run, !Task.isCancelled else { return } + if armRevision == revision, isArmed { mode = .armed } + } else { try await engine.tick(canCapture: false) } + guard currentRun == run, !Task.isCancelled else { return } + await refresh() + try await Task.sleep(for: .seconds(1)) + } catch is CancellationError { return } + catch { + guard currentRun == run, !Task.isCancelled else { return } + if armRevision == revision { + if isArmed { mode = .suspended(error.localizedDescription) } + else { notice = error.localizedDescription } + } + do { try await Task.sleep(for: .seconds(5)) } catch { return } + } + } + } + + private func publishingLoop(run: RunID) async { + while currentRun == run, !Task.isCancelled { + do { try await engine.publishPending(); try await Task.sleep(for: .seconds(5)) } + catch is CancellationError { return } + catch { + guard currentRun == run, !Task.isCancelled else { return } + notice = error.localizedDescription + do { try await Task.sleep(for: .seconds(30)) } catch { return } + } + } + } + + private func refresh() async { + history = await engine.history(); nextCapture = await engine.nextCapture() + do { manualHistory = try await engine.manualHistory() } + catch { notice = error.localizedDescription } + } + + func attachScreen(_ screen: UIScreen) { + if self.screen !== screen { restoreScreen(); self.screen = screen } + if isArmed { dimScreen() } + } + + private func dimScreen() { + guard let screen, previousBrightness == nil else { return } + previousBrightness = screen.brightness + previousIdleDisabled = UIApplication.shared.isIdleTimerDisabled + screen.brightness = 0.05 + UIApplication.shared.isIdleTimerDisabled = true + } + + private func restoreScreen() { + if let previousBrightness { + screen?.brightness = previousBrightness; self.previousBrightness = nil + } + if let previousIdleDisabled { + UIApplication.shared.isIdleTimerDisabled = previousIdleDisabled; self + .previousIdleDisabled = nil + } + } +} + +#if DEBUG + extension DaylightModel { + static func recoveryPreview() -> DaylightModel { + let model = DaylightPreviewSupport.model() + model.ready = true; model.history = [DaylightPreviewSupport.recoverySequence()] + return model + } + + static func publishingFailurePreview() -> DaylightModel { + let model = DaylightPreviewSupport.model() + model.ready = true; model.publishingIssue = "The saved Mastodon settings could not be read. Reconnect to resume publishing." + return model + } + + static func historyPreview() -> DaylightModel { + let model = DaylightPreviewSupport.model() + model.ready = true; model.history = [DaylightPreviewSupport.completedSequence()] + return model + } + + static func preview(mode: Mode, notice: String?) -> DaylightModel { + let model = DaylightPreviewSupport.model() + model.ready = true + model.mode = mode + model.notice = notice + model.history = [DaylightPreviewSupport.sequence()] + model.nextCapture = Date(timeIntervalSince1970: 1_788_789_900) + return model + } + } +#endif diff --git a/Daylight/DaylightUI/Sources/DaylightPreviewSupport.swift b/Daylight/DaylightUI/Sources/DaylightPreviewSupport.swift new file mode 100644 index 00000000..2dc8e618 --- /dev/null +++ b/Daylight/DaylightUI/Sources/DaylightPreviewSupport.swift @@ -0,0 +1,170 @@ +#if DEBUG + import DaylightCore + import DaylightMastodon + import Foundation + + @MainActor + enum DaylightPreviewSupport { + static func model() -> DaylightModel { + DaylightModel( + engine: PreviewCaptureController(), + camera: PreviewCamera(), + photos: PreviewPhotos(), + mastodon: PreviewAccount(), + readiness: PreviewReadiness(), + ) + } + + static func recoverySequence() -> CaptureSequence { + var sequence = completedSequence() + if case var .captured(image) = sequence.slots[0].state { + image.photos = .ambiguous + sequence.slots[0].state = .captured(image) + } + sequence.deliveries[0] + .state = .needsAttention("Check Mastodon before retrying this post.") + return sequence + } + + static func completedSequence() -> CaptureSequence { + var result = sequence() + for index in 7 ..< result.slots.count { + result.slots[index].state = .missed + } + let image = result.images[6] + result.selection = .selected(image.id) + var delivery = PublishingDelivery( + destination: PublishingDestinationID(rawValue: "mastodon"), + imageID: image.id, + kind: .sequenceHighlight, + ) + delivery.state = .delivered(PublishingReceipt( + remoteID: "example", + url: URL(string: "https://example.com/@daylight/1")!, + )) + result.deliveries = [delivery] + return result + } + + static func sequence() -> CaptureSequence { + var sequence = CaptureSequence( + event: SolarEvent( + id: .init(year: 2026, month: 9, day: 7, kind: .sunrise), + date: Date(timeIntervalSince1970: 1_788_789_600), + ), + settings: .standard, + ) + for index in 0 ..< 7 { + var image = CapturedImage( + id: sequence.slots[index].id, + capturedAt: sequence.slots[index].scheduledAt, + format: .jpeg, + ) + image.photos = .saved("preview-\(index)") + image.score = .scored(.init(overall: 0.5, isUtility: false)) + sequence.slots[index].state = .captured(image) + } + return sequence + } + } + + struct PreviewReadiness: CaptureReadiness { func check() {} } + + actor PreviewCaptureController: CaptureControlling { + func armedIntent() -> Bool { + false + } + + func setArmedIntent(_: Bool) {} + func load() -> CaptureSettings { + .standard + } + + func configure(_: CaptureSettings) {} + func plan() {} + func tick(canCapture _: Bool) {} + func publishPending() {} + func history() async -> [CaptureSequence] { + await [DaylightPreviewSupport.sequence()] + } + + func nextCapture() -> Date? { + Date(timeIntervalSince1970: 1_788_789_900) + } + + func manualHistory() -> [ManualCapture] { + [] + } + + func recoverDelivery( + sequenceID _: SolarEvent.ID, + deliveryID _: PublishingDelivery.ID, + action _: PublishingRecoveryAction, + ) {} + func resolvePhotos(imageID _: CaptureSequence.Slot.ID, resolution _: PhotosResolution) {} + func manualCapture() {} + } + + private struct PreviewCamera: CameraCapturing { + func requestAccess() -> Bool { + true + } + + func availableLenses() -> [CaptureSettings.Camera.Lens] { + [.main, .ultraWide] + } + + func capture(settings _: CaptureSettings.Camera) throws -> CameraCapture { + throw DaylightError + .unavailableCamera + } + + func preview( + settings _: CaptureSettings.Camera, + ) + -> AsyncThrowingStream + { + AsyncThrowingStream { $0.finish() } + } + + func stop() {} + } + + struct PreviewPhotos: PhotosSaving { + func requestAccess() -> Bool { + true + } + + func contains(assetIdentifier _: String) -> Bool { + true + } + + func save( + originalURL _: URL, + rawURL _: URL?, + capturedAt _: Date, + recordIdentifier _: @escaping @Sendable (String) async throws -> Void, + ) -> String { + "preview" + } + } + + actor PreviewAccount: MastodonManaging { + func configurationIssue() -> String? { + nil + } + + var settings = MastodonSettings.initial + func configuration() -> MastodonSettings { + settings + } + + func connect(server _: String, token _: String) -> MastodonSettings { + settings + } + + func update(enabled: Bool, visibility: MastodonSettings.Visibility, caption: String) { + settings.enabled = enabled; settings.visibility = visibility; settings.caption = caption + } + } +#endif diff --git a/Daylight/DaylightUI/Sources/DaylightRootView.swift b/Daylight/DaylightUI/Sources/DaylightRootView.swift new file mode 100644 index 00000000..234ee164 --- /dev/null +++ b/Daylight/DaylightUI/Sources/DaylightRootView.swift @@ -0,0 +1,21 @@ +import BroadwayUI +import SwiftUI + +public struct DaylightRootView: View { + @State private var model: DaylightModel + @Environment(\.scenePhase) private var scenePhase + public init(model: DaylightModel) { + _model = State(initialValue: model) + } + + public var body: some View { + DaylightContentView(model: model) + .broadwayRoot() + .background { DaylightScreenConnection(connect: model.attachScreen) } + .task(id: scenePhase) { await model.run(active: scenePhase == .active) } + } +} + +#if DEBUG + #Preview { DaylightContentView(model: DaylightPreviewSupport.model()).broadwayRoot() } +#endif diff --git a/Daylight/DaylightUI/Sources/DaylightScreenConnection.swift b/Daylight/DaylightUI/Sources/DaylightScreenConnection.swift new file mode 100644 index 00000000..5185320c --- /dev/null +++ b/Daylight/DaylightUI/Sources/DaylightScreenConnection.swift @@ -0,0 +1,33 @@ +import SwiftUI +import UIKit + +/// Supplies the actual hosting window's screen to the capture model. +struct DaylightScreenConnection: UIViewRepresentable { + let connect: @MainActor (UIScreen) -> Void + func makeUIView(context _: Context) -> ScreenView { + ScreenView(connect: connect) + } + + func updateUIView(_: ScreenView, context _: Context) {} + final class ScreenView: UIView { + let connect: @MainActor (UIScreen) -> Void + init(connect: @escaping @MainActor (UIScreen) -> Void) { + self.connect = connect + super.init(frame: .zero) + isUserInteractionEnabled = false + } + + @available(*, unavailable) required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func didMoveToWindow() { + super.didMoveToWindow() + if let screen = window?.windowScene?.screen { connect(screen) } + } + } +} + +#if DEBUG + #Preview { DaylightScreenConnection(connect: { _ in }) } +#endif diff --git a/Daylight/DaylightUI/Sources/DaylightStylesheet.swift b/Daylight/DaylightUI/Sources/DaylightStylesheet.swift new file mode 100644 index 00000000..20c9b930 --- /dev/null +++ b/Daylight/DaylightUI/Sources/DaylightStylesheet.swift @@ -0,0 +1,35 @@ +import BroadwayCore +import BroadwayUI +import SwiftUI + +struct DaylightStylesheet: BStylesheet { + struct Preview: Equatable { + var aspectRatio: CGFloat = 4 / 3 + var cornerRadius: CGFloat = 18 + var background = Color.black + } + + struct Capture: Equatable { + var spacing: CGFloat = 24 + var padding: CGFloat = 24 + var background = Color.black + var foreground = Color.white + } + + var preview = Preview() + var capture = Capture() + static let `default` = Self() + init() {} + init(context _: SlicingContext) { + self.init() + } +} + +extension EnvironmentValues { + var daylightStylesheet: DaylightStylesheet { + bContext.stylesheet( + DaylightStylesheet.self, + fallback: .default, + ) + } +} diff --git a/Daylight/DaylightUI/Sources/DaylightTitles.swift b/Daylight/DaylightUI/Sources/DaylightTitles.swift new file mode 100644 index 00000000..0fd5f31e --- /dev/null +++ b/Daylight/DaylightUI/Sources/DaylightTitles.swift @@ -0,0 +1,59 @@ +import DaylightCore +import DaylightMastodon +import Foundation + +extension CaptureSettings.Camera.Lens { + var title: LocalizedStringResource { + switch self { + case .main: .lensMain; case .ultraWide: .lensUltraWide; case .telephoto: .lensTelephoto + } + } +} + +extension SolarEvent.Kind { + var title: LocalizedStringResource { + switch self { case .sunrise: .eventSunrise; case .sunset: .eventSunset } + } +} + +extension MastodonSettings.Visibility { + var title: LocalizedStringResource { + switch self { + case .public: .visibilityPublic; case .unlisted: .visibilityUnlisted; case .private: .visibilityPrivate + } + } +} + +extension CaptureSequence.Slot.State { + var title: String { + switch self { + case .pending: String(localized: .slotPending) + case .missed: String(localized: .slotMissed) + case .capturing: String(localized: .slotCapturing) + case let .failed(message): message + case let .captured(image): + switch image.photos { + case .saved: + switch image.format { + case .rawAndJPEG: String(localized: .photosSavedRaw) + case .jpeg: String(localized: .photosSavedJpeg) + case nil: String(localized: .photosSaved) + } + case .pending, .saving: String(localized: .photosSaving) + case let .failed(message), let .retry(_, message): message + case .ambiguous: String(localized: .photosAmbiguous) + } + } + } +} + +extension CapturedImage.PhotosState { + var title: String { + switch self { + case .saved: String(localized: .photosSaved) + case .pending, .saving: String(localized: .photosSaving) + case let .failed(message), let .retry(_, message): message + case .ambiguous: String(localized: .photosAmbiguous) + } + } +} diff --git a/Daylight/DaylightUI/Sources/DeliveryRecoveryView.swift b/Daylight/DaylightUI/Sources/DeliveryRecoveryView.swift new file mode 100644 index 00000000..8d26db4f --- /dev/null +++ b/Daylight/DaylightUI/Sources/DeliveryRecoveryView.swift @@ -0,0 +1,95 @@ +import BroadwayUI +import DaylightCore +import SnapshotKit +import SwiftUI + +struct DeliveryRecoveryView: View { + @Bindable var model: DaylightModel + let sequenceID: SolarEvent.ID + let deliveryID: PublishingDelivery.ID + @State private var postURL = "" + @State private var confirmingAbsence = false + var body: some View { + Form { + Section { + Text(.recoveryPublishingHelp) + Button(.recoveryPublishingRetry) { Task { await model.recoverDelivery( + sequenceID: sequenceID, + deliveryID: deliveryID, + action: .retry, + ) } } + Button(.recoveryPublishingAbsent) { confirmingAbsence = true } + }.disabled(model.working) + Section { + TextField(.recoveryPublishingUrl, text: $postURL) + .textInputAutocapitalization(.never).autocorrectionDisabled().keyboardType(.URL) + Button(.recoveryPublishingRecord) { Task { await model.recordPublishedPost( + sequenceID: sequenceID, + deliveryID: deliveryID, + url: postURL, + ) } } + .disabled(model.working || postURL.isEmpty) + } + if let notice = model.notice { Section { Text(notice) } } + } + .navigationTitle(Text(.recoveryPublishingTitle)) + .confirmationDialog( + Text(.recoveryPublishingConfirm), + isPresented: $confirmingAbsence, + titleVisibility: .visible, + ) { + Button(.recoveryPublishingAbsent) { Task { await model.recoverDelivery( + sequenceID: sequenceID, + deliveryID: deliveryID, + action: .confirmedAbsent, + ) } } + Button(.recoveryCancel, role: .cancel) {} + } + } +} + +#if DEBUG + #Preview { + let sequence = DaylightPreviewSupport.completedSequence() + DeliveryRecoveryView( + model: DaylightPreviewSupport.model(), + sequenceID: sequence.id, + deliveryID: sequence.deliveries[0].id, + ) + } +#endif + +#if DEBUG + extension DeliveryRecoveryView: SnapshotProviding { + static var snapshots: [SnapshotCase] { + SnapshotCase( + name: "Standard", + configurations: SnapshotConfiguration.combinations(devices: [.iPhoneFullContent]), + settle: .immediate, + ) { + NavigationStack { + let sequence = DaylightPreviewSupport.recoverySequence() + DeliveryRecoveryView( + model: DaylightModel.recoveryPreview(), + sequenceID: sequence.id, + deliveryID: sequence.deliveries[0].id, + ) + }.broadwayRoot() + } + SnapshotCase( + name: "Accessibility", + configurations: SnapshotConfiguration.combinations(devices: [.iPhoneFullContent]), + settle: .immediate, + ) { + NavigationStack { + let sequence = DaylightPreviewSupport.recoverySequence() + DeliveryRecoveryView( + model: DaylightModel.recoveryPreview(), + sequenceID: sequence.id, + deliveryID: sequence.deliveries[0].id, + ) + }.broadwayRoot().dynamicTypeSize(.accessibility3) + } + } + } +#endif diff --git a/Daylight/DaylightUI/Sources/MastodonSettingsView.swift b/Daylight/DaylightUI/Sources/MastodonSettingsView.swift new file mode 100644 index 00000000..adae7a20 --- /dev/null +++ b/Daylight/DaylightUI/Sources/MastodonSettingsView.swift @@ -0,0 +1,67 @@ +import BroadwayUI +import DaylightMastodon +import SnapshotKit +import SwiftUI + +struct MastodonSettingsView: View { + @Bindable var model: DaylightModel + var body: some View { + Form { + Section { + TextField(.mastodonServer, text: $model.server).textInputAutocapitalization(.never) + .autocorrectionDisabled().keyboardType(.URL) + SecureField(.mastodonToken, text: $model.token).textInputAutocapitalization(.never) + .autocorrectionDisabled() + Button(.mastodonConnect) { Task { await model.connect() } }.disabled(model.working) + Text(.mastodonTokenHelp).font(.footnote).foregroundStyle(.secondary) + } + if let connection = model.account.connection { + Section { + LabeledContent { Text(connection.username) } label: { Text(.mastodonAccount) } + Picker(selection: $model.account.visibility) { + ForEach(MastodonSettings.Visibility.allCases, id: \.self) { + Text($0.title).tag($0) + } + } label: { Text(.mastodonVisibility) } + TextField(.mastodonCaption, text: $model.account.caption, axis: .vertical) + Text(.mastodonCaptionHelp).font(.footnote).foregroundStyle(.secondary) + Toggle(isOn: $model.account.enabled) { Text(.mastodonAutomatic) } + Button(.settingsSave) { Task { await model.savePublishing() } } + } + } + if let issue = model.publishingIssue { Section { Text(issue) } } + if let notice = model.notice { Section { Text(notice) } } + }.navigationTitle(Text(.mastodonTitle)) + } +} + +#if DEBUG + #Preview { MastodonSettingsView.snapshotPreviews } +#endif + +#if DEBUG + extension MastodonSettingsView: SnapshotProviding { + static var snapshots: [SnapshotCase] { + SnapshotCase( + name: "ConfigurationFailure", + configurations: SnapshotConfiguration.combinations(devices: [.iPhoneFullContent]), + settle: .immediate, + ) { + NavigationStack { + MastodonSettingsView(model: DaylightModel.publishingFailurePreview()) + } + .broadwayRoot() + } + SnapshotCase( + name: "MastodonSettingsView", + configurations: SnapshotConfiguration.combinations(devices: [.iPhoneFullContent]), + settle: .immediate, + ) { + NavigationStack { MastodonSettingsView(model: DaylightModel.preview( + mode: .setup, + notice: nil, + )) }.broadwayRoot() + } + } + } +#endif diff --git a/Daylight/DaylightUI/Sources/PhotosRecoveryView.swift b/Daylight/DaylightUI/Sources/PhotosRecoveryView.swift new file mode 100644 index 00000000..ece7c907 --- /dev/null +++ b/Daylight/DaylightUI/Sources/PhotosRecoveryView.swift @@ -0,0 +1,77 @@ +import BroadwayUI +import DaylightCore +import SnapshotKit +import SwiftUI + +struct PhotosRecoveryView: View { + @Bindable var model: DaylightModel + let image: CapturedImage + @State private var confirmingAbsence = false + var body: some View { + Form { + Text(image.capturedAt, format: .dateTime.month().day().hour().minute()) + Text(.recoveryPhotosHelp) + Button(.recoveryPhotosRetry) { Task { await model.resolvePhotos( + imageID: image.id, + resolution: .retry, + ) } } + .disabled(model.working) + if image.photos.requiresAbsenceConfirmation { + Button(.recoveryPhotosAbsent) { confirmingAbsence = true }.disabled(model.working) + } + if let notice = model.notice { Text(notice) } + } + .navigationTitle(Text(.recoveryPhotosTitle)) + .confirmationDialog( + Text(.recoveryPhotosConfirm), + isPresented: $confirmingAbsence, + titleVisibility: .visible, + ) { + Button(.recoveryPhotosAbsent) { Task { await model.resolvePhotos( + imageID: image.id, + resolution: .confirmedAbsent, + ) } } + Button(.recoveryCancel, role: .cancel) {} + } + } +} + +#if DEBUG + #Preview { PhotosRecoveryView( + model: DaylightPreviewSupport.model(), + image: DaylightPreviewSupport.sequence().images[0], + ) } +#endif + +#if DEBUG + extension PhotosRecoveryView: SnapshotProviding { + static var snapshots: [SnapshotCase] { + SnapshotCase( + name: "Standard", + configurations: SnapshotConfiguration.combinations(devices: [.iPhoneFullContent]), + settle: .immediate, + ) { + NavigationStack { + let sequence = DaylightPreviewSupport.recoverySequence() + PhotosRecoveryView( + model: DaylightModel.recoveryPreview(), + image: sequence.images[0], + ) + }.broadwayRoot() + } + SnapshotCase( + name: "Accessibility", + configurations: SnapshotConfiguration.combinations(devices: [.iPhoneFullContent]), + settle: .immediate, + ) { + NavigationStack { + let sequence = DaylightPreviewSupport.recoverySequence() + PhotosRecoveryView( + model: DaylightModel.recoveryPreview(), + image: sequence.images[0], + ) + }.broadwayRoot().dynamicTypeSize(.accessibility3) + } + } + } +#endif diff --git a/Daylight/DaylightUI/Sources/Resources/Localizable.xcstrings b/Daylight/DaylightUI/Sources/Resources/Localizable.xcstrings new file mode 100644 index 00000000..93363e8b --- /dev/null +++ b/Daylight/DaylightUI/Sources/Resources/Localizable.xcstrings @@ -0,0 +1,820 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "app.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Daylight" + } + } + } + }, + "camera.exposure" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Camera exposure compensation" + } + } + } + }, + "camera.lens" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rear camera" + } + } + } + }, + "camera.preview" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Camera preview" + } + } + } + }, + "camera.raw.automatic" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "RAW is preserved when this camera and zoom support it. JPEG copies are used for viewing and publishing." + } + } + } + }, + "camera.zoom" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zoom" + } + } + } + }, + "capture.armed" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Capturing the light" + } + } + } + }, + "capture.cooling" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Capture paused while the phone cools." + } + } + } + }, + "capture.instructions" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Leave Daylight open with the phone plugged in and unlocked. The display dims while capturing." + } + } + } + }, + "capture.next" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Next photo" + } + } + } + }, + "capture.start" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Start unattended capture" + } + } + } + }, + "capture.stop" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stop capturing" + } + } + } + }, + "capture.test" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Take test photo" + } + } + } + }, + "capture.testSaved" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Test photo saved to Photos." + } + } + } + }, + "event.sunrise" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sunrise" + } + } + } + }, + "event.sunset" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sunset" + } + } + } + }, + "history.count %lld %lld" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld of %lld photos captured" + } + } + } + }, + "history.empty" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Captured sequences will appear here." + } + } + } + }, + "history.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recent sequences" + } + } + } + }, + "lens.main" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Main" + } + } + } + }, + "lens.telephoto" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Telephoto" + } + } + } + }, + "lens.ultraWide" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ultra Wide" + } + } + } + }, + "mastodon.account" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connected account" + } + } + } + }, + "mastodon.automatic" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Publish highlights automatically" + } + } + } + }, + "mastodon.caption" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Caption template" + } + } + } + }, + "mastodon.captionHelp" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Use {event} for Sunrise or Sunset and {date} for the capture date. One selected photo posts after each sequence." + } + } + } + }, + "mastodon.connect" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connect account" + } + } + } + }, + "mastodon.server" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Server URL" + } + } + } + }, + "mastodon.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mastodon" + } + } + } + }, + "mastodon.token" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Access token" + } + } + } + }, + "mastodon.tokenHelp" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Create a personal application token on your Mastodon server with read:accounts, write:media, and write:statuses permissions." + } + } + } + }, + "mastodon.visibility" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Post visibility" + } + } + } + }, + "photos.ambiguous" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Check Photos: the previous save may have completed." + } + } + } + }, + "photos.saved" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Saved to Photos" + } + } + } + }, + "photos.saved.count" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld of %2$lld saved to Photos" + } + } + } + }, + "photos.saved.jpeg" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "JPEG saved — RAW unavailable" + } + } + } + }, + "photos.saved.raw" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "RAW + JPEG saved to Photos" + } + } + } + }, + "photos.saving" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Saving to Photos" + } + } + } + }, + "publishing.none" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No queued posts" + } + } + } + }, + "publishing.pending" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Post queued" + } + } + } + }, + "publishing.view" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "View Mastodon post" + } + } + } + }, + "recovery.cancel" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancel" + } + } + } + }, + "recovery.invalidURL" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enter the full URL of the published post." + } + } + } + }, + "recovery.photos.absent" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "I checked Photos: this photo is missing" + } + } + } + }, + "recovery.photos.confirm" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Save another copy to Photos?" + } + } + } + }, + "recovery.photos.help" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Check Photos at the capture time before saving another copy. Retry first checks whether the photo was already saved." + } + } + } + }, + "recovery.photos.retry" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Retry saving" + } + } + } + }, + "recovery.photos.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Save to Photos" + } + } + } + }, + "recovery.publishing.absent" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "I checked Mastodon: this post is missing" + } + } + } + }, + "recovery.publishing.confirm" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Create a replacement post?" + } + } + } + }, + "recovery.publishing.help" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Check the original Mastodon account before creating a replacement. If the post exists, enter its URL to mark it as published." + } + } + } + }, + "recovery.publishing.record" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Record existing post" + } + } + } + }, + "recovery.publishing.retry" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Retry publishing" + } + } + } + }, + "recovery.publishing.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recover post" + } + } + } + }, + "recovery.publishing.url" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Published post URL" + } + } + } + }, + "schedule.after %lld" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "End %lld minutes after" + } + } + } + }, + "schedule.before %lld" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Start %lld minutes before" + } + } + } + }, + "schedule.frozen" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Changes apply to sequences that have not started. Solar times do not account for terrain or the direction of your window." + } + } + } + }, + "schedule.interval %lld" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Every %lld minutes" + } + } + } + }, + "schedule.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Location and schedule" + } + } + } + }, + "selection.complete" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Best image selected" + } + } + } + }, + "selection.pending" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Waiting for sequence to finish" + } + } + } + }, + "settings.save" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Save settings" + } + } + } + }, + "site.latitude" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Latitude" + } + } + } + }, + "site.longitude" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Longitude" + } + } + } + }, + "site.timeZone" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Time zone" + } + } + } + }, + "site.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Camera location" + } + } + } + }, + "slot.capturing" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Capturing" + } + } + } + }, + "slot.missed" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Missed while unavailable" + } + } + } + }, + "slot.pending" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scheduled" + } + } + } + }, + "status.notice" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Status" + } + } + } + }, + "visibility.private" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Followers only" + } + } + } + }, + "visibility.public" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Public" + } + } + } + }, + "visibility.unlisted" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unlisted" + } + } + } + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/Daylight/DaylightUI/Sources/ScheduleSettingsView.swift b/Daylight/DaylightUI/Sources/ScheduleSettingsView.swift new file mode 100644 index 00000000..38bddd31 --- /dev/null +++ b/Daylight/DaylightUI/Sources/ScheduleSettingsView.swift @@ -0,0 +1,77 @@ +import BroadwayUI +import SnapshotKit +import SwiftUI + +struct ScheduleSettingsView: View { + @Bindable var model: DaylightModel + var body: some View { + Form { + Section { + VStack(alignment: .leading) { + Text(.siteLatitude).font(.caption).foregroundStyle(.secondary) + TextField(.siteLatitude, value: $model.settings.site.latitude, format: .number) + } + VStack(alignment: .leading) { + Text(.siteLongitude).font(.caption).foregroundStyle(.secondary) + TextField( + .siteLongitude, + value: $model.settings.site.longitude, + format: .number, + ) + } + VStack(alignment: .leading) { + Text(.siteTimeZone).font(.caption).foregroundStyle(.secondary) + TextField(.siteTimeZone, text: $model.settings.site.timeZoneIdentifier) + .textInputAutocapitalization(.never).autocorrectionDisabled() + } + } header: { Text(.siteTitle) } + Section { + Stepper(value: $model.settings.sunrise.minutesBefore, in: 0 ... 180) { + Text(.scheduleBefore(model.settings.sunrise.minutesBefore)) + } + Stepper(value: $model.settings.sunrise.minutesAfter, in: 0 ... 180) { + Text(.scheduleAfter(model.settings.sunrise.minutesAfter)) + } + Stepper(value: $model.settings.sunrise.intervalMinutes, in: 1 ... 60) { + Text(.scheduleInterval(model.settings.sunrise.intervalMinutes)) + } + } header: { Text(.eventSunrise) } + Section { + Stepper(value: $model.settings.sunset.minutesBefore, in: 0 ... 180) { + Text(.scheduleBefore(model.settings.sunset.minutesBefore)) + } + Stepper(value: $model.settings.sunset.minutesAfter, in: 0 ... 180) { + Text(.scheduleAfter(model.settings.sunset.minutesAfter)) + } + Stepper(value: $model.settings.sunset.intervalMinutes, in: 1 ... 60) { + Text(.scheduleInterval(model.settings.sunset.intervalMinutes)) + } + } header: { Text(.eventSunset) } + Section { + Button(.settingsSave) { Task { await model.saveSettings() } } + Text(.scheduleFrozen).font(.footnote).foregroundStyle(.secondary) + } + }.navigationTitle(Text(.scheduleTitle)) + } +} + +#if DEBUG + #Preview { ScheduleSettingsView.snapshotPreviews } +#endif + +#if DEBUG + extension ScheduleSettingsView: SnapshotProviding { + static var snapshots: [SnapshotCase] { + SnapshotCase( + name: "ScheduleSettingsView", + configurations: SnapshotConfiguration.combinations(devices: [.iPhoneFullContent]), + settle: .immediate, + ) { + NavigationStack { ScheduleSettingsView(model: DaylightModel.preview( + mode: .setup, + notice: nil, + )) }.broadwayRoot() + } + } + } +#endif diff --git a/Daylight/DaylightUI/Sources/SequenceHistoryView.swift b/Daylight/DaylightUI/Sources/SequenceHistoryView.swift new file mode 100644 index 00000000..780a6486 --- /dev/null +++ b/Daylight/DaylightUI/Sources/SequenceHistoryView.swift @@ -0,0 +1,94 @@ +import BroadwayUI +import DaylightCore +import SnapshotKit +import SwiftUI + +struct SequenceHistoryView: View { + @Bindable var model: DaylightModel + private var sequences: [CaptureSequence] { + model.recentHistory + } + + private var manualCaptures: [ManualCapture] { + model.manualHistory + } + + var body: some View { + List { + if sequences.isEmpty, + manualCaptures.isEmpty { Text(.historyEmpty).foregroundStyle(.secondary) } + if !manualCaptures.isEmpty { + Section(String(localized: .captureTest)) { + ForEach(manualCaptures) { capture in + VStack(alignment: .leading) { + Text(capture.date, format: .dateTime.month().day().hour().minute()) + switch capture.state { + case .capturing: Text(.photosSaving) + case let .captured(image): + Text(CaptureSequence.Slot.State.captured(image).title) + if image.photos.needsRecovery { + NavigationLink { PhotosRecoveryView( + model: model, + image: image, + ) } label: { Text(.recoveryPhotosTitle) } + } + case let .failed(message): Text(message) + } + } + } + } + } + ForEach(sequences) { sequence in + Section { + SequenceSummaryView(sequence: sequence) + ForEach(sequence.deliveries.filter(\.state.needsRecovery)) { delivery in + NavigationLink { DeliveryRecoveryView( + model: model, + sequenceID: sequence.id, + deliveryID: delivery.id, + ) } label: { Text(.recoveryPublishingTitle) } + } + ForEach(sequence.slots) { slot in + VStack(alignment: .leading) { + Text(slot.scheduledAt, format: .dateTime.hour().minute()) + Text(slot.state.title).font(.caption).foregroundStyle(.secondary) + if case let .captured(image) = slot.state, image.photos.needsRecovery { + NavigationLink { PhotosRecoveryView(model: model, image: image) + } label: { Text(.recoveryPhotosTitle) } + } + } + } + } + } + }.navigationTitle(Text(.historyTitle)) + } +} + +#if DEBUG + #Preview { SequenceHistoryView.snapshotPreviews } +#endif + +#if DEBUG + extension SequenceHistoryView: SnapshotProviding { + static var snapshots: [SnapshotCase] { + SnapshotCase( + name: "Recovery", + configurations: SnapshotConfiguration.combinations(devices: [.iPhoneFullContent]), + settle: .immediate, + ) { + NavigationStack { SequenceHistoryView(model: DaylightModel.recoveryPreview()) } + .broadwayRoot() + } + SnapshotCase( + name: "SequenceHistoryView", + configurations: SnapshotConfiguration.combinations(devices: [.iPhoneFullContent]), + settle: .immediate, + ) { + NavigationStack { + SequenceHistoryView(model: DaylightModel.historyPreview()) + } + .broadwayRoot() + } + } + } +#endif diff --git a/Daylight/DaylightUI/Sources/SequenceSummaryView.swift b/Daylight/DaylightUI/Sources/SequenceSummaryView.swift new file mode 100644 index 00000000..df9d5459 --- /dev/null +++ b/Daylight/DaylightUI/Sources/SequenceSummaryView.swift @@ -0,0 +1,38 @@ +import DaylightCore +import SwiftUI + +struct SequenceSummaryView: View { + let sequence: CaptureSequence + var body: some View { + VStack(alignment: .leading) { + Text(sequence.event.id.kind.title).font(.headline) + Text(sequence.event.date, format: .dateTime.month().day().hour().minute()) + .font(.subheadline) + Text(.historyCount(sequence.images.count, sequence.slots.count)) + Text(.photosSavedCount(sequence.images.count(where: { + if case .saved = $0.photos { true } else { false } + }), sequence.images.count)) + if sequence.deliveries.isEmpty { Text(.publishingNone) } + switch sequence.selection { + case .pending: Text(.selectionPending) + case .selected: Text(.selectionComplete) + case let .failed(message): Text(message) + } + ForEach(sequence.deliveries) { delivery in + switch delivery.state { + case .pending: Text(.publishingPending) + case let .retry(date, message): Text(message); Text(date, style: .relative) + case let .needsAttention(message): Text(message) + case let .delivered(receipt): Link( + String(localized: .publishingView), + destination: receipt.url, + ) + } + } + } + } +} + +#if DEBUG + #Preview { SequenceSummaryView(sequence: DaylightPreviewSupport.sequence()) } +#endif diff --git a/Daylight/DaylightUI/Sources/UnattendedCaptureView.swift b/Daylight/DaylightUI/Sources/UnattendedCaptureView.swift new file mode 100644 index 00000000..fbfa30cb --- /dev/null +++ b/Daylight/DaylightUI/Sources/UnattendedCaptureView.swift @@ -0,0 +1,34 @@ +import DaylightCore +import SwiftUI + +struct UnattendedCaptureView: View { + @Bindable var model: DaylightModel + @Environment(\.daylightStylesheet) private var stylesheet + var body: some View { + ScrollView { + VStack(spacing: stylesheet.capture.spacing) { + Text(.captureArmed).font(.largeTitle) + if case let .suspended(message) = model + .mode { Text(message).accessibilityAddTraits(.updatesFrequently) } + if let next = model.nextCapture { + Text(.captureNext).font(.headline) + Text(next, format: .dateTime.month().day().hour().minute()) + .font(.title2.monospacedDigit()) + } + if let sequence = model.recentHistory + .first { SequenceSummaryView(sequence: sequence) } + if let issue = model.publishingIssue { Text(issue) } + if let notice = model.notice { Text(notice) } + Button(.captureStop) { Task { await model.toggleArmed() } }.buttonStyle(.bordered) + }.frame(maxWidth: .infinity).padding(stylesheet.capture.padding) + } + .scrollEdgeEffectHidden() + .foregroundStyle(stylesheet.capture.foreground) + .background(stylesheet.capture.background.ignoresSafeArea()) + .preferredColorScheme(.dark) + } +} + +#if DEBUG + #Preview { UnattendedCaptureView(model: DaylightPreviewSupport.model()) } +#endif diff --git a/Daylight/DaylightUI/Tests/DaylightModelTestSupport.swift b/Daylight/DaylightUI/Tests/DaylightModelTestSupport.swift new file mode 100644 index 00000000..8e008f28 --- /dev/null +++ b/Daylight/DaylightUI/Tests/DaylightModelTestSupport.swift @@ -0,0 +1,122 @@ +import DaylightCore +import Foundation + +actor PreviewTestCamera: CameraCapturing { + let allowed: Bool + let frame: Data + private(set) var accessRequests = 0 + private(set) var previewStarts = 0 + private(set) var stops = 0 + + init(allowed: Bool, frame: Data) { + self.allowed = allowed; self.frame = frame + } + + func requestAccess() -> Bool { + accessRequests += 1 + return allowed + } + + func availableLenses() -> [CaptureSettings.Camera.Lens] { + [.main] + } + + func capture(settings _: CaptureSettings.Camera) throws -> CameraCapture { + throw DaylightError.unavailableCamera + } + + func preview(settings _: CaptureSettings.Camera) -> AsyncThrowingStream { + previewStarts += 1 + return AsyncThrowingStream { continuation in + continuation.yield(frame) + continuation.finish() + } + } + + func stop() { + stops += 1 + } +} + +actor ModelTestGate { + private var opened = false + private var waiters: [CheckedContinuation] = [] + func wait() async { + if opened { return } + await withCheckedContinuation { waiters.append($0) } + } + + func open() { + opened = true + for waiter in waiters { + waiter.resume() + } + waiters.removeAll() + } +} + +actor LifecycleTestController: CaptureControlling { + let tickEntered = ModelTestGate() + let releaseTick = ModelTestGate() + let nextTick = ModelTestGate() + let publishingEntered = ModelTestGate() + let releasePublishing = ModelTestGate() + private var ticks = 0 + private var publishingCalls = 0 + private var armed = true + let blockTick: Bool + let blockPublishing: Bool + init(blockTick: Bool, blockPublishing: Bool) { + self.blockTick = blockTick; self.blockPublishing = blockPublishing + } + + func armedIntent() -> Bool { + armed + } + + func setArmedIntent(_ value: Bool) { + armed = value + } + + func load() -> CaptureSettings { + .standard + } + + func configure(_: CaptureSettings) {} + func plan() {} + func tick(canCapture _: Bool) async { + ticks += 1 + if ticks == 1 { + await tickEntered.open() + if blockTick { await releaseTick.wait() } + } else { await nextTick.open() } + } + + func publishPending() async { + publishingCalls += 1 + if publishingCalls == 1 { + await publishingEntered.open() + if blockPublishing { await releasePublishing.wait() } + } + } + + func history() -> [CaptureSequence] { + [] + } + + func nextCapture() -> Date? { + nil + } + + func manualHistory() -> [ManualCapture] { + [] + } + + func recoverDelivery( + sequenceID _: SolarEvent.ID, + deliveryID _: PublishingDelivery.ID, + action _: PublishingRecoveryAction, + ) {} + func resolvePhotos(imageID _: CaptureSequence.Slot.ID, resolution _: PhotosResolution) {} + func manualCapture() {} +} diff --git a/Daylight/DaylightUI/Tests/DaylightModelTests.swift b/Daylight/DaylightUI/Tests/DaylightModelTests.swift new file mode 100644 index 00000000..380d5da4 --- /dev/null +++ b/Daylight/DaylightUI/Tests/DaylightModelTests.swift @@ -0,0 +1,122 @@ +import DaylightCore +@testable import DaylightUI +import Foundation +import Testing +import UIKit + +@MainActor +struct DaylightModelTests { + @Test func loadsFromInjectedServicesAndPersistsSettings() async { + let model = DaylightPreviewSupport.model() + await model.load() + #expect(model.ready) + #expect(model.history.count == 1) + #expect(!model.isArmed) + model.settings.camera.zoom = 2 + await model.saveSettings() + #expect(model.notice == nil) + } + + @Test func previewRequestsPermissionAndShowsFramesBeforeArchiveLoads() async { + let frame = UIGraphicsImageRenderer(size: CGSize(width: 2, height: 2)).pngData { context in + UIColor.blue.setFill() + context.fill(CGRect(x: 0, y: 0, width: 2, height: 2)) + } + let camera = PreviewTestCamera(allowed: true, frame: frame) + let model = DaylightModel( + engine: PreviewCaptureController(), + camera: camera, + photos: PreviewPhotos(), + mastodon: PreviewAccount(), + readiness: PreviewReadiness(), + ) + #expect(!model.ready) + await model.preview() + #expect(await camera.accessRequests == 1) + #expect(await camera.previewStarts == 1) + #expect(model.previewImage != nil) + #expect(model.notice == nil) + } + + @Test func deniedCameraAccessShowsFailureWithoutStartingPreview() async { + let camera = PreviewTestCamera(allowed: false, frame: Data()) + let model = DaylightModel( + engine: PreviewCaptureController(), + camera: camera, + photos: PreviewPhotos(), + mastodon: PreviewAccount(), + readiness: PreviewReadiness(), + ) + await model.preview() + #expect(await camera.accessRequests == 1) + #expect(await camera.previewStarts == 0) + #expect(model.notice == DaylightError.cameraPermission.localizedDescription) + } + + @Test func cancelledPreviewDoesNotStartAfterPermissionResponse() async { + let camera = PreviewTestCamera(allowed: true, frame: Data()) + let model = DaylightModel( + engine: PreviewCaptureController(), + camera: camera, + photos: PreviewPhotos(), + mastodon: PreviewAccount(), + readiness: PreviewReadiness(), + ) + let task = Task { await model.preview() } + task.cancel() + await task.value + #expect(await camera.previewStarts == 0) + } + + @Test func stoppingReturnsToSetup() async { + let model = DaylightModel.preview(mode: .armed, notice: nil) + await model.toggleArmed() + #expect(!model.isArmed) + } +} + +extension DaylightModelTests { + @Test(.timeLimit(.minutes(1))) func stoppingDuringTickCannotRearm() async { + let engine = LifecycleTestController(blockTick: true, blockPublishing: false) + let model = DaylightModel( + engine: engine, + camera: PreviewTestCamera(allowed: true, frame: Data()), + photos: PreviewPhotos(), + mastodon: PreviewAccount(), + readiness: PreviewReadiness(), + ) + let run = Task { await model.run(active: true) } + await engine.tickEntered.wait() + await model.toggleArmed() + #expect(!model.isArmed) + await engine.releaseTick.open() + await engine.nextTick.wait() + #expect(!model.isArmed) + #expect(await engine.armedIntent() == false) + run.cancel(); await run.value + } + + @Test(.timeLimit(.minutes(1))) func previousForegroundCleanupCannotStopCurrentCamera() async { + let engine = LifecycleTestController(blockTick: false, blockPublishing: true) + let camera = PreviewTestCamera(allowed: true, frame: Data()) + let model = DaylightModel( + engine: engine, + camera: camera, + photos: PreviewPhotos(), + mastodon: PreviewAccount(), + readiness: PreviewReadiness(), + ) + let oldRun = Task { await model.run(active: true) } + await engine.publishingEntered.wait() + oldRun.cancel() + await model.run(active: false) + let stops = await camera.stops + let newRun = Task { await model.run(active: true) } + await engine.nextTick.wait() + await engine.releasePublishing.open() + await oldRun.value + #expect(await camera.stops == stops) + #expect(model.isArmed) + newRun.cancel(); await newRun.value + } +} diff --git a/Daylight/DaylightUI/Tests/DaylightStylesheetTests.swift b/Daylight/DaylightUI/Tests/DaylightStylesheetTests.swift new file mode 100644 index 00000000..2b3d182c --- /dev/null +++ b/Daylight/DaylightUI/Tests/DaylightStylesheetTests.swift @@ -0,0 +1,14 @@ +import BroadwayCore +import BroadwayUI +@testable import DaylightUI +import SwiftUI +import TestHostSupport +import Testing + +@MainActor +struct DaylightStylesheetTests { + @Test func defaultGeometryIsUsable() { + #expect(DaylightStylesheet.default.preview.aspectRatio > 1) + #expect(DaylightStylesheet.default.capture.padding >= 16) + } +} diff --git a/Daylight/README.md b/Daylight/README.md new file mode 100644 index 00000000..26672e23 --- /dev/null +++ b/Daylight/README.md @@ -0,0 +1,85 @@ +# Daylight + +Daylight turns a dedicated iPhone into a camera for sunrise and sunset photography. It saves original RAW and JPEG resources in Apple Photos and posts selected highlights to Mastodon. + +## Setup + +1. Run `./ide --no-open` from the repository root. +2. Install with `./Daylight/install --device `. +3. Mount the phone in landscape with the rear camera facing the view. +4. Open Daylight and allow Camera access. +5. Select the lens, zoom, and camera exposure compensation. +6. Take a test photo and allow Photos access. +7. Check that one original image appears in Photos. +8. Check **Location and schedule**, then save the settings. +9. Connect power and tap **Start unattended capture**. + +The camera preserves Apple ProRAW or Bayer RAW when the selected camera supports it. ProRAW is preferred; Bayer RAW requires 1× zoom. Other configurations save JPEG only. History identifies JPEG-only captures. + +The camera uses a fixed landscape orientation. Auto focus, exposure, and white balance settle before each shot. The preview updates at four frames per second. + +The default schedule captures 13 photos per event, at five-minute intervals. Each window starts 30 minutes before sunrise or sunset and ends 30 minutes after it. Settings are independent for each event. A sequence keeps its settings after its first slot. Later changes apply to future sequences. + +Solar times use the offline NOAA fractional-year equations and the configured time zone. This approximation does not account for terrain, elevation, weather, or window direction. The camera can capture sunset light even when the sun is outside its view. + +## Mastodon + +1. On your Mastodon server, create a personal application access token. +2. Grant `read:accounts`, `write:media`, and `write:statuses` permissions. +3. Enter the HTTPS server URL and token in **Mastodon**. +4. Tap **Connect account**. +5. Check the connected account and post visibility. +6. Set a caption with `{event}` and `{date}` placeholders. +7. Enable automatic publishing and save the settings. + +Each sequence posts one original image after its capture window closes. Vision scores images locally and excludes utility images. Equal scores favor the image closest to the solar event. A failed selection preserves the sequence without a post. Manual test photos never post. + +Mastodon receives a resized JPEG without the original metadata. The access token remains in Keychain. Connecting an account leaves automatic publishing disabled until you enable it. + +## Unattended operation and recovery + +Daylight must stay open and unlocked. The display dims and automatic locking stops while capture is armed. Stopping capture restores the prior display settings. + +Reopen Daylight after a reboot or termination. The saved arming setting restores the previous intent. iOS can interrupt the camera in the background. Missed slots remain missed. A slot has a 30-second scheduling tolerance and never triggers a catch-up burst. + +Camera permission, Photos permission, thermal pressure, and free storage can pause capture. Staged image files remain available after a failure. Daylight never deletes Photos assets. + +Uploads retry while the app is active. The queue preserves media identifiers and submission checkpoints. An uncertain post near the end of Mastodon's one-hour duplicate-protection window requires review. Daylight does not blindly repost it. + +Each capture saves as one Photos asset, with RAW as the main resource and JPEG as its companion when available. Interrupted saves use a durable asset receipt for reconciliation. Ambiguous saves remain visible in history and retain their staged files. + +## Recover a failed save or post + +Open sequence history after stopping unattended capture. Photos failures that cannot have created an asset retry automatically after a short delay. Other failures retain their receipts and require review. + +For a failed Photos save, choose **Save to Photos**. Retry checks whether the saved receipt identifies an existing photo. If the outcome is uncertain, check Photos before confirming that the photo is missing. + +For a failed delivery, choose **Recover post**. After correcting account settings, retry publishing. If the earlier submission is uncertain, check the original Mastodon account. Enter an existing post's URL to record completion, or confirm that it is missing before creating a replacement. Recovery preserves delivery identity and previous checkpoints. + +Damaged Mastodon settings disable publishing while local capture remains available. Reconnect the account to repair the configuration. The damaged settings file is preserved. + +## Architecture and extension points + +`Daylight` creates shared services once. `DaylightUI` renders observable state. `DaylightCore` owns scheduling, capture, processing, Photos integration, and the archive. `DaylightMastodon` owns the first publishing adapter. + +`PublishingDestination` declares the typed events it consumes. Captured-image events support future gallery uploads. Sequence-highlight events support social posts. Each input exposes the original RAW file when available and its JPEG companion. Adapters persist opaque checkpoints through the supplied callback and return a receipt. The coordinator does not switch over adapter names. + +Metadata uses atomic, versioned Codable files under Application Support/Daylight. Persisted enum case names are part of the file format. Renaming a case requires an explicit compatibility design. Unknown versions and damaged records surface errors. + +## Validation + +Run `./test DaylightCoreTests DaylightMastodonTests DaylightUITests` for focused unit coverage. Run `./test --snapshots --only DaylightUISnapshotTests` for image coverage. + +[Device acceptance](Daylight/Tests/README.md) covers the checks that need a physical camera. The complete sunrise/sunset cycle needs the eventual mounted phone and a configured Mastodon account. + +The website and Instagram adapter are tracked in [TODOs.md](TODOs.md). + +## Sources and attribution + +RAW capture follows [Apple’s RAW and ProRAW guide](https://developer.apple.com/documentation/avfoundation/capturing-photos-in-raw-and-apple-proraw-formats). +The solar implementation follows [NOAA solar equations](https://www.gml.noaa.gov/grad/solcalc/solareqns.PDF). +Selection uses Apple's [Vision aesthetics request](https://developer.apple.com/documentation/vision/calculateimageaestheticsscoresrequest). +Publishing follows [Mastodon's status and idempotency contract](https://docs.joinmastodon.org/methods/statuses/). + +The app ships its generated license report in `Daylight/Resources/attribution.json`. +Run `./attribution Daylight/Daylight/attribution-sources.json` from the repository root to refresh it. diff --git a/Daylight/TODOs.md b/Daylight/TODOs.md new file mode 100644 index 00000000..ab38da96 --- /dev/null +++ b/Daylight/TODOs.md @@ -0,0 +1,12 @@ +# Daylight backlog + +Follow the [root item format](../TODOs.md). + +## P2s + +- feat(Daylight) [needs-design]: Publish captures under kve.me — Add a website adapter for captured-image events from `DaylightCore/Sources/PublishingDestination.swift:3`. Design the gallery and hosting together. (human 2026-09-06) +- feat(DaylightMastodon) [needs-design]: Add Instagram publishing — Implement a separate destination beside `DaylightMastodon/Sources/MastodonDestination.swift:5`. Account eligibility and authentication need a separate design. (human 2026-09-06) + +- feat(Daylight) [needs-design]: Add optional photographic editing — Revisit presets and reversible Photos edits after original capture is proven. Keep editing out of the first release. (human 2026-09-06) + +## Completed issues diff --git a/Daylight/install b/Daylight/install new file mode 100755 index 00000000..68e229a4 --- /dev/null +++ b/Daylight/install @@ -0,0 +1,162 @@ +#!/bin/bash +set -euo pipefail + +# install — build the Daylight app and install it on a connected iPhone, no Xcode UI. +# +# Shell owns project/build/process orchestration. Tools/device_selection.rb owns +# exact physical-device decoding so the policy is directly tested without Xcode. + +cd "$(dirname "$0")/.." + +WORKSPACE="Stuff.xcworkspace" +SCHEME="Daylight" +BUNDLE_ID="com.stuff.daylight" + +CONFIGURATION="Debug" +OPTIMIZE=true +DEVICE="" +LAUNCH=true +ASSUME_YES=false +DRY_RUN=false + +usage() { + cat <<'USAGE' +Usage: ./Daylight/install [options] + +Builds the Daylight app and installs it on a connected iPhone via xcodebuild and +xcrun devicectl (no Xcode UI). By default it builds the Debug configuration +with compiler optimizations forced on — keeping the DEBUG-only developer tools +while running at roughly Release speed — and launches the app. + +Options: + --device NAME Target a specific device by name (exact match), + UDID, or identifier (default: the sole paired iPhone) + --configuration NAME Build configuration (default: Debug) + --optimize Force compiler optimizations on (default) + --no-optimize Build without forcing optimizations (use the + configuration's own optimization level) + --no-launch Install only; don't launch the app afterwards + --dry-run Resolve the exact device and report the build, + install, and launch without performing them + -y, --yes Skip the "unlock your device" confirmation prompt + -h, --help Show this help + +Examples: + ./Daylight/install + ./Daylight/install --device "Kai's iPhone" + ./Daylight/install --configuration Release + ./Daylight/install --no-optimize --no-launch + ./Daylight/install --device "Kai's iPhone" --dry-run +USAGE +} + +require_value() { + if [ -z "${2:-}" ]; then + echo "error: $1 requires a value (see ./Daylight/install --help)" >&2 + exit 1 + fi +} + +while [ $# -gt 0 ]; do + case "$1" in + --device) shift; require_value --device "${1:-}"; DEVICE="$1" ;; + --configuration) shift; require_value --configuration "${1:-}"; CONFIGURATION="$1" ;; + --optimize) OPTIMIZE=true ;; + --no-optimize) OPTIMIZE=false ;; + --no-launch) LAUNCH=false ;; + --dry-run) DRY_RUN=true ;; + -y|--yes) ASSUME_YES=true ;; + -h|--help) usage; exit 0 ;; + *) echo "error: unknown option '$1' (see ./Daylight/install --help)" >&2; exit 1 ;; + esac + shift +done + +# A device build must be signed by a real team. Preserve mise failures so a +# malformed configuration remains distinguishable from an unset variable. +# shellcheck disable=SC2016 +TEAM_ID="$(mise exec -- sh -c 'printf "%s" "${TUIST_DEVELOPMENT_TEAM:-}"')" +if [ -z "$TEAM_ID" ]; then + cat >&2 <<'EOF' +error: no Apple Developer team configured — a device build can't be signed. + +Set it once (writes the gitignored .mise.local.toml), then re-run: + ./ide --team-id +EOF + exit 1 +fi + +# Persistent DerivedData avoids package-checkout damage from macOS cleaning +# TMPDIR. A dry run creates no build directory and uses only a disposable JSON +# file for devicectl's read-only device inventory. +WORKDIR="$HOME/Library/Developer/Xcode/DerivedData/daylight-install-$(basename "$PWD")" +DERIVED="$WORKDIR/DerivedData" +if [ "$DRY_RUN" = true ]; then + DEVICES_JSON="$(mktemp "${TMPDIR:-/tmp}/daylight-install-devices.XXXXXX")" + trap 'rm -f "$DEVICES_JSON"' EXIT +else + DEVICES_JSON="$WORKDIR/devices.json" + mkdir -p "$WORKDIR" +fi + +OPTIMIZATION_OVERRIDES=() +if [ "$OPTIMIZE" = true ]; then + OPTIMIZATION_OVERRIDES=(SWIFT_OPTIMIZATION_LEVEL=-O GCC_OPTIMIZATION_LEVEL=s) +fi + +if [ "$DRY_RUN" = true ]; then + echo "==> Would run ./ide --no-open" + echo "==> Would build $SCHEME ($CONFIGURATION$([ "$OPTIMIZE" = true ] && echo ', optimized')) for device" +else + echo "==> ./ide --no-open" + ./ide --no-open >/dev/null + + echo "==> xcodebuild ($CONFIGURATION$([ "$OPTIMIZE" = true ] && echo ', optimized')) for device" + mise exec -- xcodebuild build \ + -workspace "$WORKSPACE" \ + -scheme "$SCHEME" \ + -configuration "$CONFIGURATION" \ + -destination 'generic/platform=iOS' \ + -derivedDataPath "$DERIVED" \ + -allowProvisioningUpdates \ + ${OPTIMIZATION_OVERRIDES[@]+"${OPTIMIZATION_OVERRIDES[@]}"} +fi + +APP="$DERIVED/Build/Products/$CONFIGURATION-iphoneos/Daylight.app" +if [ "$DRY_RUN" != true ] && [ ! -d "$APP" ]; then + echo "error: built app not found at $APP" >&2 + exit 1 +fi + +echo "==> resolving device" +xcrun devicectl list devices --json-output "$DEVICES_JSON" >/dev/null +DEVICE_SELECTION="$(mise exec -- ruby Tools/device_selection.rb --filter "$DEVICE" <"$DEVICES_JSON")" +DEVICE_UDID="${DEVICE_SELECTION%%$'\t'*}" +DEVICE_NAME="${DEVICE_SELECTION#*$'\t'}" + +if [ "$DRY_RUN" = true ]; then + echo "==> Would install $APP to $DEVICE_NAME ($DEVICE_UDID)" + if [ "$LAUNCH" = true ]; then + echo "==> Would launch $BUNDLE_ID" + fi + echo "Dry run — no project, build, app install, or process launch was performed." + exit 0 +fi + +if [ "$ASSUME_YES" = false ] && [ -t 0 ]; then + printf 'Make sure "%s" is unlocked and trusts this Mac.\n' "$DEVICE_NAME" + read -r -p "Press Enter to install, or Ctrl-C to cancel... " _ +fi + +echo "==> installing to device" +xcrun devicectl device install app --device "$DEVICE_UDID" "$APP" + +if [ "$LAUNCH" = true ]; then + echo "==> launching $BUNDLE_ID" + xcrun devicectl device process launch \ + --device "$DEVICE_UDID" \ + --terminate-existing \ + "$BUNDLE_ID" +fi + +echo "Done. (Build products: $DERIVED)" diff --git a/Package.swift b/Package.swift index 933ad2e1..9e9d8d2f 100644 --- a/Package.swift +++ b/Package.swift @@ -9,6 +9,9 @@ let package = Package( .macOS(.v26), ], products: [ + .library(name: "DaylightUI", targets: ["DaylightUI"]), + .library(name: "DaylightMastodon", targets: ["DaylightMastodon"]), + .library(name: "DaylightCore", targets: ["DaylightCore"]), .library(name: "CreditKit", targets: ["CreditKit"]), .library(name: "LedgerCore", targets: ["LedgerCore"]), .library(name: "LifecycleKit", targets: ["LifecycleKit"]), @@ -45,6 +48,28 @@ let package = Package( .package(url: "https://github.com/SFSafeSymbols/SFSafeSymbols", from: "7.0.0"), ], targets: [ + .target( + name: "DaylightUI", + dependencies: [ + .target(name: "DaylightCore"), + .target(name: "DaylightMastodon"), + .target(name: "BroadwayUI"), + .target(name: "SnapshotKit"), + .target(name: "CreditKit"), + ], + path: "Daylight/DaylightUI/Sources", + resources: [.process("Resources")], + ), + .target( + name: "DaylightMastodon", + dependencies: [.target(name: "DaylightCore")], + path: "Daylight/DaylightMastodon/Sources", + ), + .target( + name: "DaylightCore", + dependencies: [.target(name: "PeriscopeCore")], + path: "Daylight/DaylightCore/Sources", + ), .target( name: "CreditKit", path: "Shared/CreditKit/Sources", diff --git a/Project.swift b/Project.swift index 9a055b96..71469004 100644 --- a/Project.swift +++ b/Project.swift @@ -184,6 +184,60 @@ let project = Project( packages: [stuffPackage, sfSafeSymbolsPackage], settings: projectSettings, targets: [ + .target( + name: "Daylight", + destinations: [.iPhone], + product: .app, + bundleId: "com.stuff.daylight", + deploymentTargets: deployment, + infoPlist: .extendingDefault(with: [ + "UILaunchScreen": .dictionary([:]), + "CFBundleShortVersionString": .string("1.0"), + "CFBundleVersion": .string("1"), + "NSCameraUsageDescription": .string( + "Daylight photographs the view during your sunrise and sunset sequences.", + ), + "NSPhotoLibraryUsageDescription": .string( + "Daylight saves your captured photographs and checks interrupted saves.", + ), + "NSPhotoLibraryAddUsageDescription": .string( + "Daylight saves your captured photographs to Photos.", + ), + ]), + sources: ["Daylight/Daylight/Sources/**"], + resources: ["Daylight/Daylight/Resources/**"], + dependencies: [.package(product: "DaylightUI")], + settings: .settings(base: [ + "SWIFT_VERSION": "6.0", + "ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME": "", + ]), + ), + unitTests( + name: "DaylightUITests", + bundleIdSuffix: "daylight.ui", + productDependency: "DaylightUI", + sources: ["Daylight/DaylightUI/Tests/**"], + ), + unitTests( + name: "DaylightUISnapshotTests", + bundleIdSuffix: "daylight.ui.snapshots", + productDependency: "DaylightUI", + sources: ["Daylight/DaylightUI/SnapshotTests/**"], + extraPackageProducts: ["SnapshotKitTesting"], + environmentVariables: snapshotEnvironment, + ), + unitTests( + name: "DaylightMastodonTests", + bundleIdSuffix: "daylight.mastodon", + productDependency: "DaylightMastodon", + sources: ["Daylight/DaylightMastodon/Tests/**"], + ), + unitTests( + name: "DaylightCoreTests", + bundleIdSuffix: "daylight.core", + productDependency: "DaylightCore", + sources: ["Daylight/DaylightCore/Tests/**"], + ), .target( name: "Where", destinations: destinations, @@ -722,6 +776,10 @@ let project = Project( name: "Stuff-iOS-Tests", shared: true, buildAction: .buildAction(targets: [ + "DaylightUITests", + "DaylightMastodonTests", + "DaylightCoreTests", + "Daylight", "Where", "RegionViewer", "StuffTestHost", @@ -749,6 +807,9 @@ let project = Project( ]), testAction: .targets( [ + "DaylightUITests", + "DaylightMastodonTests", + "DaylightCoreTests", "CreditKitTests", "WhereCrashReportingTests", "LifecycleKitTests", @@ -773,6 +834,9 @@ let project = Project( arguments: .arguments(environmentVariables: packageResourceEnvironment), ), ), + testScheme(name: "DaylightMastodonTests"), + testScheme(name: "DaylightUITests"), + testScheme(name: "DaylightCoreTests"), testScheme(name: "LedgerCoreTests"), testScheme(name: "CreditKitTests"), testScheme(name: "WhereCrashReportingTests"), @@ -803,6 +867,7 @@ let project = Project( name: "StuffSnapshotTests", shared: true, buildAction: .buildAction(targets: [ + "DaylightUISnapshotTests", "WhereUISnapshotTests", "FlyoverSnapshotTests", "PeriscopeToolsSnapshotTests", @@ -810,6 +875,7 @@ let project = Project( ]), testAction: .targets( [ + "DaylightUISnapshotTests", "WhereUISnapshotTests", "FlyoverSnapshotTests", "PeriscopeToolsSnapshotTests", diff --git a/attribution b/attribution index 2063a3dd..a9d4bca1 100755 --- a/attribution +++ b/attribution @@ -23,6 +23,7 @@ GENERATOR="Shared/CreditKit/Tools/generate-attribution.rb" # One entry per app that ships a report. CONFIGS=( + "Daylight/Daylight/attribution-sources.json" "Where/Where/attribution-sources.json" )