diff --git a/Package.resolved b/Package.resolved index d901f23c1..f41168771 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "50f5c006bb5b3e164cef24425f9464618baa354aeac574f74d2034161d47c135", + "originHash" : "77aa5282d3a15ec303262d111fe90890fead6728b9aa8f631294962cbbd6c946", "pins" : [ { "identity" : "accessibilitysnapshot", @@ -73,6 +73,15 @@ "version" : "1.15.0" } }, + { + "identity" : "swift-protobuf", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-protobuf.git", + "state" : { + "revision" : "55d7a1cc5666b85c13464aea1c4b4a90feccb4c8", + "version" : "1.38.1" + } + }, { "identity" : "swift-snapshot-testing", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index a1d086888..773587ac3 100644 --- a/Package.swift +++ b/Package.swift @@ -45,6 +45,7 @@ let package = Package( .package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.18.0"), .package(url: "https://github.com/cashapp/AccessibilitySnapshot", from: "0.12.0"), .package(url: "https://github.com/SFSafeSymbols/SFSafeSymbols", from: "7.0.0"), + .package(url: "https://github.com/apple/swift-protobuf.git", from: "1.38.1"), ], targets: [ .target( @@ -224,6 +225,8 @@ let package = Package( name: "ThrowCore", dependencies: [ .target(name: "PeriscopeCore"), + .product(name: "SwiftProtobuf", package: "swift-protobuf"), + .product(name: "ZIPFoundation", package: "ZIPFoundation"), ], path: "Throw/ThrowCore/Sources", resources: [ diff --git a/Project.swift b/Project.swift index 8d704cbbf..5ad983a6f 100644 --- a/Project.swift +++ b/Project.swift @@ -2,6 +2,7 @@ import ProjectDescription let destinations: Destinations = [.iPhone, .iPad] let deployment: DeploymentTargets = .iOS("26.0") +let throwDeployment: DeploymentTargets = .iOS("27.0") /// The Ledger menu bar app is the only native-macOS target; everything else /// stays on the shared iOS destinations above. @@ -189,7 +190,7 @@ let project = Project( destinations: destinations, product: .app, bundleId: "com.stuff.throw", - deploymentTargets: deployment, + deploymentTargets: throwDeployment, infoPlist: .extendingDefault(with: [ "CFBundleDisplayName": .string("Throw"), "CFBundleShortVersionString": .string("0.1"), @@ -211,14 +212,6 @@ let project = Project( "UISceneConfigurationName": .string("Throw Controller"), ]), ]), - "UIWindowSceneSessionRoleExternalDisplayNonInteractive": .array([ - .dictionary([ - "UISceneConfigurationName": .string("Throw External Display"), - "UISceneDelegateClassName": .string( - "$(PRODUCT_MODULE_NAME).ExternalDisplaySceneDelegate", - ), - ]), - ]), ]), ]), "UIApplicationSupportsIndirectInputEvents": .boolean(true), @@ -451,7 +444,7 @@ let project = Project( destinations: destinations, product: .unitTests, bundleId: "com.stuff.throw.tests", - deploymentTargets: deployment, + deploymentTargets: throwDeployment, sources: ["Throw/Throw/Tests/**"], dependencies: [ .target(name: "Throw"), @@ -640,6 +633,7 @@ let project = Project( bundleIdSuffix: "throwcore", productDependency: "ThrowCore", sources: ["Throw/ThrowCore/Tests/**"], + extraPackageProducts: ["SwiftProtobuf"], ), unitTests( name: "ThrowUITests", diff --git a/Throw/AGENTS.md b/Throw/AGENTS.md index 86487b653..389c23255 100644 --- a/Throw/AGENTS.md +++ b/Throw/AGENTS.md @@ -25,6 +25,10 @@ WhereCore, RegionKit, or LifecycleKit into this feature. `RunnableProjectionExperienceID` to playlist and runtime commands. - Keep aircraft provider implementations in ThrowCore. ThrowUI uses the provider-neutral operation service and domain results. +- Keep transit provider adapters in ThrowCore. Keep each city behind the typed + schedule and observation protocols. Never add agency checks to the renderer. +- Keep exactly one Transit runtime. It can poll only while Transit is active or + prewarming. Treat each realtime partition as an independent failure domain. - Keep one experience coordinator for all scenes. Only the active and prewarming experience runtimes may run at the same time. - Exchange complete experience frames only while the projection is black. @@ -48,8 +52,8 @@ WhereCore, RegionKit, or LifecycleKit into this feature. bypass quiet output without starting a feed. - Keep Geography offline and Map-only. Never add online map tiles or transmit the observer location to a map provider. -- Revalidate the availability-gated iOS 27 scene-accessory adapter against the - GM SDK before release. +- Attach the iOS 27 external-display scene accessory to the controller root. + Use the shared session and production projection surface in its content. ## Installing to a device diff --git a/Throw/README.md b/Throw/README.md index 4333021de..bf30110ac 100644 --- a/Throw/README.md +++ b/Throw/README.md @@ -21,7 +21,7 @@ does not import WhereCore or RegionKit and does not use LifecycleKit. ## Build and run -Generate the workspace, then use the shared `Throw` scheme on an iOS 26 or +Generate the workspace, then use the shared `Throw` scheme on an iOS 27 or newer iPhone or iPad: ```bash @@ -46,10 +46,9 @@ Preview runs the same projection renderer on the device. ## Projection Views -“View” is the user-facing name for a `ProjectionExperience`. Air & Space is the -first enabled View. It contains Geography, Flights, and the planned Stars and -Satellites layers. Transit is planned for nearby moving buses, trains, and -ferries. This change does not include a live transit provider. +“View” is the user-facing name for a `ProjectionExperience`. Air & Space contains +Geography, Flights, and the planned Stars and Satellites layers. Transit first +supports New York City subway trains. One playlist controls every projector, full-screen output, and Preview. Each View has a dwell duration. Automatic rotation starts only when two Views are @@ -58,11 +57,31 @@ View visible until the new View has fresh data and a complete prepared frame. Both values must belong to the same activation. The surface fades to black, exchanges atomically, and fades back in. Two Views never share one frame. -Air & Space is the only configurable View in this release. Automatic rotation -therefore remains dormant. Transit stays in the display and preference formats -as a planned View. It has no runnable identity, so release code cannot add it to -the playlist or send it to the coordinator. A future runtime must add a new -runnable identity and update the exhaustive activation switch. +You can configure Air & Space and NYC Subway together. Then Throw can rotate +between the two Views. Each View keeps its own Map center, viewport, labels, +mark size, and context intensity. + +## NYC Subway + +The NYC Subway View uses the official supplemented GTFS schedule from the MTA. +It polls the eight official GTFS Realtime feed partitions every 30 seconds. +The schedule defines routes, stops, trip patterns, and route shapes. The +realtime feeds provide assigned train runs and predicted stop times. + +The MTA feeds do not provide a continuous position for each train. Throw +estimates each train position along its scheduled shape. A train becomes more +certain after consecutive feed updates show its movement through stops. + +Throw downloads the schedule during setup and stores it in the local cache. +It refreshes the schedule each hour while the View runs. A valid empty realtime +response completes activation. If one partition fails, the other partitions +continue to update. The failed partition keeps its last train positions for +90 seconds. Then it fades them for 30 seconds. + +Transit data uses a provider-neutral schedule and observation boundary. An +additional city can supply its own GTFS adapters without changing the Transit +projection layers. The first release does not include San Francisco data, +buses, ferries, service alerts, or accessibility status. ## Aircraft sources @@ -135,24 +154,25 @@ when it is inside the visible Map. True Sky always uses the observer location. Cloud aircraft sources receive a coarse version of the Map center and the query radius. They do not receive the exact observer location when the centers differ. -Throw bundles Natural Earth Vector 1:10m data and selected 2025 U.S. Census -Bureau data. Map rendering does not request tiles or send a location to a map -provider. The generated archive contains no place names or road names. +Throw bundles Natural Earth Vector 1:10m data, selected 2025 U.S. Census Bureau +data, and NYC's source-resolution planimetric shoreline. Map rendering does not +request tiles or send a location to a map provider. The generated archive +contains no place names or road names. The data is generalized and is not authoritative. Natural Earth boundaries use the default de facto view. Census boundaries support statistical work and are not legal land descriptions. See the [Natural Earth terms](https://www.naturalearthdata.com/about/terms-of-use/) and the [2025 TIGER/Line documentation](https://www2.census.gov/geo/pdfs/maps-data/data/tiger/tgrshp2025/TGRSHP2025_TechDoc_Ch1.pdf). +NYC shoreline geometry is from the [NYC Planimetric Database](https://data.cityofnewyork.us/Recreation/NYC-Planimetric-Database-Shoreline/59xk-wagz). ## External scenes -iOS 26 discovers noninteractive external displays through the declared scene -role. On iOS 27, the controller also registers a retained -`UISceneAccessory.externalNonInteractive` adapter. The iOS 27 integration was -compiled against the installed beta SDK and must be revalidated against the -iOS 27 GM SDK before release. Focus, keystone, and optical registration remain -projector responsibilities. +Throw requires iOS 27. The controller root attaches an +`ExternalNonInteractiveAccessory`. The system creates its external scene when +a compatible display connects. The accessory shows the same production +`ProjectionSurface` that Preview and the full-screen fallback use. Focus, +keystone, and optical registration remain projector responsibilities. See [`AGENTS.md`](AGENTS.md) for the feature's editing rules and each module's README for its public API and limitations. diff --git a/Throw/TODOs.md b/Throw/TODOs.md index 7707de877..d8c879050 100644 --- a/Throw/TODOs.md +++ b/Throw/TODOs.md @@ -22,14 +22,12 @@ owned by the root [`TODOs.md`](../TODOs.md). 240 NM on each projector aspect ratio. Record the devices, OS builds, projectors, provider states, and results in the release checklist. (human 2026-08-24) -- test(Throw) [needs-design]: Revalidate and physically exercise the iOS 27 - external-scene accessory against the GM SDK — the availability-gated adapter - constructs, retains, unregisters, and migrates `UISceneAccessory` registration - between controller scenes (`Throw/Sources/ThrowApp.swift:36-119`), while the - feature contract explicitly treats final-SDK validation as a release gate - (`AGENTS.md:28-29`). Confirm API compatibility, controller-window - closure/recreation, and that the iOS 26 manifest and iOS 27 accessory paths do - not create duplicate output scenes or polling demand. (human 2026-08-24) +- test(Throw) [needs-design]: Physically exercise the native iOS 27 external + scene accessory — the controller root attaches one + `ExternalNonInteractiveAccessory` and gives it the shared session + (`Throw/Sources/ThrowRuntime.swift`). Confirm USB-C and AirPlay connection, + controller-window recreation, display reconnection, resolution changes, and + single polling demand across all output scenes. (human 2026-08-24) - test(ThrowCore) [needs-design]: Revalidate the externally controlled aircraft provider contracts immediately before each beta release — implementation was checked on 2026-08-24 against the current ADS-B Exchange Personal/RapidAPI @@ -51,14 +49,9 @@ owned by the root [`TODOs.md`](../TODOs.md). ## P1s (Should do) -- feat(Throw) [needs-design]: Implement the planned Transit View — the catalog - reserves Network and Vehicles layers - (`ThrowCore/Sources/ProjectionExperience.swift:67-74`), and Views presents - Transit as unavailable - (`ThrowUI/Sources/Settings/ProjectionViewsSettingsView.swift:38-42,95-102`). - Select a live provider, define GTFS or equivalent route geometry, and add - setup and credentials if required. Give its Map runtime independent polling. - Keep vehicles brighter than dim network and Geography context. (human - 2026-08-26) - # Completed issues + +- feat(Throw): Implement the first Transit View with the official NYC Subway + static and realtime MTA feeds. Keep the source seams independent of the city, + and keep route lines and train marks in the generic projection pipeline. + (completed 2026-08-27) diff --git a/Throw/Throw/AGENTS.md b/Throw/Throw/AGENTS.md index 15d8a5754..f39909f5f 100644 --- a/Throw/Throw/AGENTS.md +++ b/Throw/Throw/AGENTS.md @@ -12,7 +12,7 @@ The Throw app is the iOS composition and scene shell; see - Expose the shared session through the runtime protocol. Compose concrete controller and projection roots at each scene without `AnyView` erasure. - Construct `ThrowRuntime` only in `ThrowRuntime.swift`. `AppDelegate` obtains - that one live runtime. Scene delegates use the platform handoff and never create a fallback. + that one live runtime. Never create a fallback runtime. - Start cold launch from the process runtime. Never attach launch ownership to a scene or SwiftUI task. - Compose controller and projection surfaces through the exhaustive session @@ -23,17 +23,15 @@ The Throw app is the iOS composition and scene shell; see - Retain the final-background preference flush under one injected UIKit execution lease. End the lease on completion or expiration. Cancel the retained flush task when the lease expires or a controller returns foreground. -- Host every projected output with ThrowUI's `ThrowProjectionRootView`; keep its - UIKit window and hosting view opaque black. -- Derive size and aspect changes from the connected `UIWindowScene`, never - `UIScreen.main`. -- Retain the iOS 27 `UISceneAccessory` and its registration for as long as the - controller scene is eligible. Revalidate this adapter against the GM SDK. +- Host every projected output with ThrowUI's `ThrowProjectionRootView`. Keep its + surface opaque black. +- Attach the iOS 27 `ExternalNonInteractiveAccessory` to the controller root. + Never construct another runtime or session in the accessory content. - Keep required-reason API declarations in `PrivacyInfo.xcprivacy`. Preserve the built-app manifest guard when changing app resources or preferences. - Restore the process's prior idle-timer state when the final output leaves. ## Testing -Run `./test ThrowTests`. App tests prove the delegate and every scene handoff -use the same runtime and that duplicate output IDs do not duplicate demand. +Run `./test ThrowTests`. App tests prove the shared runtime, controller-scene +lifecycle, output demand, background flush, and idle-timer contracts. diff --git a/Throw/Throw/README.md b/Throw/Throw/README.md index d6ae47c07..26eb2a855 100644 --- a/Throw/Throw/README.md +++ b/Throw/Throw/README.md @@ -2,8 +2,8 @@ This target is the thin iOS shell for Throw. `ThrowRuntime.swift` is the only runtime construction owner. `AppDelegate` obtains exactly one live runtime. -SwiftUI controller windows and UIKit-created external-display windows all -receive that runtime's shared ThrowUI session. +SwiftUI controller windows and the external-display accessory receive that +runtime's shared ThrowUI session. Each scene composes its concrete ThrowUI root from that session. Runtime handoff does not erase roots to `AnyView` or construct feature services. @@ -16,15 +16,13 @@ black until the state contains loaded setup and credential status. ## Scene paths -- The iOS 26 scene manifest declares the controller and noninteractive - external-display roles. +- The app requires iOS 27. - Each controller root binds to its exact `UIWindowScene` and forwards that scene's foreground, background, and disconnect notifications to the shared runtime under a typed persistent identity. -- iOS 27 controller hosting registers a retained external scene accessory, - availability-gated at runtime. -- `ExternalDisplaySceneDelegate` hosts `ThrowProjectionRootView` in a black - `UIHostingController`. The root creates `ProjectionSurface` only after launch. +- The controller root attaches an `ExternalNonInteractiveAccessory`. +- The accessory hosts `ThrowProjectionRootView` on an opaque-black surface. + The root creates `ProjectionSurface` only after launch. - Preview and explicit full-screen mirroring fallback remain ThrowUI flows and use the same surface. @@ -55,5 +53,4 @@ duplicate static copy across the ThrowUI boundary. ## Build and test Run the shared `Throw` scheme after `./ide --no-open`. App-shell tests are in -`ThrowTests`; domain and UI tests live with their modules. Revalidate the iOS -27 scene-accessory calls against the GM SDK before a release build. +`ThrowTests`. Domain and UI tests live with their modules. diff --git a/Throw/Throw/Resources/attribution.json b/Throw/Throw/Resources/attribution.json index 0c1920c1e..a6b569b3d 100644 --- a/Throw/Throw/Resources/attribution.json +++ b/Throw/Throw/Resources/attribution.json @@ -10,6 +10,26 @@ "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-protobuf", + "kind": "library", + "version": "1.38.1", + "homepageURL": "https://github.com/apple/swift-protobuf", + "license": { + "name": "Apache License 2.0", + "text": " 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\n \n \n## Runtime Library Exception to the Apache 2.0 License: ##\n\n\n As an exception, if you use this Software to compile your source code and\n portions of this Software are embedded into the binary product as a result,\n you may redistribute such product without providing attribution as would\n otherwise be required by Sections 4(a), 4(b) and 4(d) of the License.\n" + } + }, + { + "name": "ZIPFoundation", + "kind": "library", + "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": "AccessibilitySnapshot", "kind": "developmentTool", @@ -40,16 +60,6 @@ "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", diff --git a/Throw/Throw/Sources/ExternalDisplaySceneDelegate.swift b/Throw/Throw/Sources/ExternalDisplaySceneDelegate.swift deleted file mode 100644 index abb697e79..000000000 --- a/Throw/Throw/Sources/ExternalDisplaySceneDelegate.swift +++ /dev/null @@ -1,93 +0,0 @@ -import SwiftUI -import ThrowUI -import UIKit - -/// Hosts the shared projection surface on one noninteractive external scene. -@MainActor -final class ExternalDisplaySceneDelegate: UIResponder, UIWindowSceneDelegate { - var window: UIWindow? - - private var output: ProjectionOutput? - private weak var runtime: (any ThrowApplicationRuntime)? - - func scene( - _ scene: UIScene, - willConnectTo session: UISceneSession, - options _: UIScene.ConnectionOptions, - ) { - guard let windowScene = scene as? UIWindowScene else { return } - guard let runtime = Self.runtime(from: UIApplication.shared.delegate) else { - assertionFailure("Throw external scene connected without the process runtime") - connectBlackFallback(to: windowScene) - return - } - - let id = ProjectionOutputID(rawValue: "external:\(session.persistentIdentifier)") - let output = ProjectionOutput.externalDisplay(id) - let host = UIHostingController( - rootView: ThrowProjectionRootView( - session: runtime.session, - presentation: .externalDisplay, - ) - .throwBroadwayRoot(), - ) - host.view.backgroundColor = .black - host.view.isOpaque = true - - let window = UIWindow(windowScene: windowScene) - window.backgroundColor = .black - window.rootViewController = host - window.makeKeyAndVisible() - - self.window = window - connectProjectionOutput(output, runtime: runtime) { [weak host] style in - host?.overrideUserInterfaceStyle = style - } - } - - func sceneDidDisconnect(_: UIScene) { - disconnectProjectionOutput() - window = nil - } - - func windowScene( - _: UIWindowScene, - didUpdateEffectiveGeometry _: UIWindowScene.Geometry, - ) { - window?.setNeedsLayout() - window?.rootViewController?.view.setNeedsLayout() - } - - static func runtime( - from applicationDelegate: (any UIApplicationDelegate)?, - ) -> (any ThrowApplicationRuntime)? { - (applicationDelegate as? any ThrowRuntimeProviding)?.runtime - } - - func connectProjectionOutput( - _ output: ProjectionOutput, - runtime: any ThrowApplicationRuntime, - appearanceSink: @escaping @MainActor (UIUserInterfaceStyle) -> Void, - ) { - self.runtime = runtime - self.output = output - runtime.projectionOutputConnected(output, appearanceSink: appearanceSink) - } - - func disconnectProjectionOutput() { - guard let output, let runtime else { return } - runtime.projectionOutputDisconnected(output) - self.output = nil - self.runtime = nil - } - - private func connectBlackFallback(to windowScene: UIWindowScene) { - let controller = UIViewController() - controller.view.backgroundColor = .black - let window = UIWindow(windowScene: windowScene) - window.backgroundColor = .black - window.rootViewController = controller - window.makeKeyAndVisible() - self.window = window - } -} diff --git a/Throw/Throw/Sources/ThrowApp.swift b/Throw/Throw/Sources/ThrowApp.swift index af71b7f87..900fc8d43 100644 --- a/Throw/Throw/Sources/ThrowApp.swift +++ b/Throw/Throw/Sources/ThrowApp.swift @@ -11,16 +11,14 @@ struct ThrowApp: App { RuntimeControllerView( session: appDelegate.runtime.session, outputDemandDidChange: appDelegate.runtime.sessionOutputDemandDidChange, + externalOutputConnected: appDelegate.runtime.projectionOutputConnected, + externalOutputDisconnected: appDelegate.runtime.projectionOutputDisconnected, ) .throwBroadwayRoot() .background { ControllerSceneBridge( - appearanceDidChange: appDelegate.runtime - .controllerAppearanceDidChange, lifecycleDidChange: appDelegate.runtime .controllerScene(_:didReceive:), - registerAccessory: appDelegate.registerExternalDisplayAccessory, - unregisterAccessory: appDelegate.unregisterExternalDisplayAccessory, ) .frame(width: 0, height: 0) .accessibilityHidden(true) @@ -29,24 +27,10 @@ struct ThrowApp: App { } } -/// Platform handoff from every UIKit-created scene to the process runtime. @MainActor -protocol ThrowRuntimeProviding: UIApplicationDelegate { - var runtime: any ThrowApplicationRuntime { get } -} - -@MainActor -final class AppDelegate: NSObject, UIApplicationDelegate, ThrowRuntimeProviding { +final class AppDelegate: NSObject, UIApplicationDelegate { let runtime: any ThrowApplicationRuntime - // Type-erased because Swift does not permit iOS 27-only stored-property - // types in an app that still deploys to iOS 26. Both UIKit values are - // NSObject subclasses; retaining them keeps the accessory registered. - private var externalDisplayAccessory: AnyObject? - private var externalDisplayRegistration: AnyObject? - private weak var externalDisplayAccessoryOwner: UIViewController? - private var accessoryControllers: [ObjectIdentifier: WeakControllerReference] = [:] - override init() { runtime = ThrowRuntime.live() super.init() @@ -56,81 +40,14 @@ final class AppDelegate: NSObject, UIApplicationDelegate, ThrowRuntimeProviding self.runtime = runtime super.init() } - - func registerExternalDisplayAccessory(from controller: UIViewController) { - guard #available(iOS 27.0, *) else { return } - pruneAccessoryControllers() - accessoryControllers[ObjectIdentifier(controller)] = WeakControllerReference(controller) - - if externalDisplayAccessoryOwner == nil { - externalDisplayAccessory = nil - externalDisplayRegistration = nil - } - guard externalDisplayRegistration == nil else { return } - installExternalDisplayAccessory(on: controller) - } - - func unregisterExternalDisplayAccessory(from controller: UIViewController) { - guard #available(iOS 27.0, *) else { return } - accessoryControllers[ObjectIdentifier(controller)] = nil - guard externalDisplayAccessoryOwner === controller else { return } - - if let registration = externalDisplayRegistration as? UISceneAccessoryRegistration { - controller.unregisterSceneAccessory(registration) - } - externalDisplayAccessory = nil - externalDisplayRegistration = nil - externalDisplayAccessoryOwner = nil - - pruneAccessoryControllers() - if let replacement = accessoryControllers.values.lazy.compactMap(\.controller).first { - installExternalDisplayAccessory(on: replacement) - } - } - - @available(iOS 27.0, *) - private func installExternalDisplayAccessory(on controller: UIViewController) { - guard externalDisplayRegistration == nil else { return } - - let configuration = Self.externalDisplayConfiguration() - let accessory = UISceneAccessory.externalNonInteractive( - sceneConfiguration: configuration, - ) - let registration = controller.registerSceneAccessory(accessory) - registration.isEnabled = true - externalDisplayAccessory = accessory - externalDisplayRegistration = registration - externalDisplayAccessoryOwner = controller - } - - private func pruneAccessoryControllers() { - accessoryControllers = accessoryControllers.filter { $0.value.controller != nil } - } - - static func externalDisplayConfiguration() -> UISceneConfiguration { - let configuration = UISceneConfiguration( - name: "Throw External Display", - sessionRole: .windowExternalDisplayNonInteractive, - ) - configuration.delegateClass = ExternalDisplaySceneDelegate.self - return configuration - } } private struct ControllerSceneBridge: UIViewControllerRepresentable { - let appearanceDidChange: @MainActor (UIUserInterfaceStyle) -> Void let lifecycleDidChange: @MainActor (ControllerSceneID, ControllerSceneLifecycleEvent) -> Void - let registerAccessory: @MainActor (UIViewController) -> Void - let unregisterAccessory: @MainActor (UIViewController) -> Void func makeUIViewController(context _: Context) -> ControllerSceneBridgeController { - ControllerSceneBridgeController( - appearanceDidChange: appearanceDidChange, - lifecycleDidChange: lifecycleDidChange, - registerAccessory: registerAccessory, - unregisterAccessory: unregisterAccessory, - ) + ControllerSceneBridgeController(lifecycleDidChange: lifecycleDidChange) } func updateUIViewController( @@ -149,27 +66,18 @@ private struct ControllerSceneBridge: UIViewControllerRepresentable { } private final class ControllerSceneBridgeController: UIViewController { - private let appearanceDidChange: @MainActor (UIUserInterfaceStyle) -> Void private let lifecycleDidChange: @MainActor (ControllerSceneID, ControllerSceneLifecycleEvent) -> Void - private let registerAccessory: @MainActor (UIViewController) -> Void - private let unregisterAccessory: @MainActor (UIViewController) -> Void private weak var observedControllerScene: UIWindowScene? private var observedControllerSceneID: ControllerSceneID? init( - appearanceDidChange: @escaping @MainActor (UIUserInterfaceStyle) -> Void, lifecycleDidChange: @escaping @MainActor ( ControllerSceneID, ControllerSceneLifecycleEvent, ) -> Void, - registerAccessory: @escaping @MainActor (UIViewController) -> Void, - unregisterAccessory: @escaping @MainActor (UIViewController) -> Void, ) { - self.appearanceDidChange = appearanceDidChange self.lifecycleDidChange = lifecycleDidChange - self.registerAccessory = registerAccessory - self.unregisterAccessory = unregisterAccessory super.init(nibName: nil, bundle: nil) } @@ -182,27 +90,18 @@ private final class ControllerSceneBridgeController: UIViewController { super.viewDidLoad() view.backgroundColor = .clear view.isUserInteractionEnabled = false - registerForTraitChanges([UITraitUserInterfaceStyle.self]) { ( - controller: ControllerSceneBridgeController, - _: UITraitCollection, - ) in - controller.reportCurrentState() - } } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) reportCurrentState() - registerAccessory(self) } func reportCurrentState() { observeControllerSceneIfNeeded() - appearanceDidChange(traitCollection.userInterfaceStyle) } func disconnect() { - unregisterAccessory(self) stopObservingControllerScene() } @@ -280,11 +179,3 @@ private final class ControllerSceneBridgeController: UIViewController { } } } - -private final class WeakControllerReference { - weak var controller: UIViewController? - - init(_ controller: UIViewController) { - self.controller = controller - } -} diff --git a/Throw/Throw/Sources/ThrowRuntime.swift b/Throw/Throw/Sources/ThrowRuntime.swift index f58ee0352..0b7026abd 100644 --- a/Throw/Throw/Sources/ThrowRuntime.swift +++ b/Throw/Throw/Sources/ThrowRuntime.swift @@ -89,16 +89,12 @@ enum ControllerSceneLifecycleEvent: Equatable { protocol ThrowApplicationRuntime: AnyObject { var session: ThrowSession { get } - func projectionOutputConnected( - _ output: ProjectionOutput, - appearanceSink: @escaping @MainActor (UIUserInterfaceStyle) -> Void, - ) + func projectionOutputConnected(_ output: ProjectionOutput) func projectionOutputDisconnected(_ output: ProjectionOutput) func controllerScene( _ id: ControllerSceneID, didReceive event: ControllerSceneLifecycleEvent, ) - func controllerAppearanceDidChange(_ style: UIUserInterfaceStyle) func sessionOutputDemandDidChange() } @@ -123,10 +119,7 @@ final class ThrowRuntime: ThrowApplicationRuntime { private let idleTimerController: any IdleTimerControlling private let backgroundExecutionLeaser: any BackgroundExecutionLeasing private var activeOutputs: [ProjectionOutputID: ProjectionOutput] = [:] - private var appearanceSinks: [ProjectionOutputID: @MainActor (UIUserInterfaceStyle) -> Void] = - [:] private var previousIdleTimerState: Bool? - private var controllerAppearance: UIUserInterfaceStyle = .unspecified private var foregroundControllerScenes: Set = [] private var backgroundPreferenceFlush = BackgroundPreferenceFlushState.idle( nextID: BackgroundPreferenceFlushID(rawValue: 0), @@ -165,14 +158,8 @@ final class ThrowRuntime: ThrowApplicationRuntime { ) } - func projectionOutputConnected( - _ output: ProjectionOutput, - appearanceSink: @escaping @MainActor (UIUserInterfaceStyle) -> Void, - ) { + func projectionOutputConnected(_ output: ProjectionOutput) { let id = Self.id(for: output) - appearanceSinks[id] = appearanceSink - appearanceSink(controllerAppearance) - guard activeOutputs[id] == nil else { return } activeOutputs[id] = output session.projectionOutputConnected(output) @@ -181,7 +168,6 @@ final class ThrowRuntime: ThrowApplicationRuntime { func projectionOutputDisconnected(_ output: ProjectionOutput) { let id = Self.id(for: output) - appearanceSinks[id] = nil guard let connectedOutput = activeOutputs.removeValue(forKey: id) else { return } session.projectionOutputDisconnected(connectedOutput) sessionOutputDemandDidChange() @@ -208,14 +194,6 @@ final class ThrowRuntime: ThrowApplicationRuntime { } } - func controllerAppearanceDidChange(_ style: UIUserInterfaceStyle) { - guard controllerAppearance != style else { return } - controllerAppearance = style - for sink in appearanceSinks.values { - sink(style) - } - } - func sessionOutputDemandDidChange() { reconcileIdleTimer(hasOutputDemand: session.hasProjectionOutputDemand) } @@ -287,6 +265,11 @@ final class ThrowRuntime: ThrowApplicationRuntime { struct RuntimeControllerView: View { let session: ThrowSession let outputDemandDidChange: @MainActor () -> Void + let externalOutputConnected: @MainActor (ProjectionOutput) -> Void + let externalOutputDisconnected: @MainActor (ProjectionOutput) -> Void + @State private var externalDisplayOutputID = ProjectionOutputID( + rawValue: "external-accessory:\(UUID().uuidString)", + ) var body: some View { ThrowRootView(session: session) @@ -294,5 +277,21 @@ struct RuntimeControllerView: View { _, _ in outputDemandDidChange() } + .sceneAccessory { + ExternalNonInteractiveAccessory { + ThrowProjectionRootView(session: session, presentation: .externalDisplay) + .throwBroadwayRoot() + .onAppear { + externalOutputConnected( + .externalDisplay(externalDisplayOutputID), + ) + } + .onDisappear { + externalOutputDisconnected( + .externalDisplay(externalDisplayOutputID), + ) + } + } + } } } diff --git a/Throw/Throw/Tests/ExternalDisplaySceneDelegateTests.swift b/Throw/Throw/Tests/ExternalDisplaySceneDelegateTests.swift deleted file mode 100644 index 582a2f972..000000000 --- a/Throw/Throw/Tests/ExternalDisplaySceneDelegateTests.swift +++ /dev/null @@ -1,38 +0,0 @@ -import Testing -@testable import Throw -import ThrowUI -import UIKit - -@MainActor -struct ExternalDisplaySceneDelegateTests { - @Test func platformHandoffUsesTheDelegateOwnedRuntime() throws { - let runtime = ThrowApplicationRuntimeSpy() - let delegate = AppDelegate(runtime: runtime) - - let resolved = try #require(ExternalDisplaySceneDelegate.runtime(from: delegate)) - - #expect(resolved === runtime) - } - - @Test func unrelatedApplicationDelegateCannotCreateARuntime() { - #expect(ExternalDisplaySceneDelegate.runtime(from: UnrelatedDelegate()) == nil) - } - - @Test func projectionOutputLifecycleUsesTheInjectedRuntime() { - let runtime = ThrowApplicationRuntimeSpy() - let delegate = ExternalDisplaySceneDelegate() - let output = ProjectionOutput.externalDisplay( - ProjectionOutputID(rawValue: "external-lifecycle-test"), - ) - - delegate.connectProjectionOutput(output, runtime: runtime) { _ in } - #expect(runtime.connectedOutputs == [output]) - - delegate.disconnectProjectionOutput() - delegate.disconnectProjectionOutput() - #expect(runtime.disconnectedOutputs == [output]) - } -} - -@MainActor -private final class UnrelatedDelegate: NSObject, UIApplicationDelegate {} diff --git a/Throw/Throw/Tests/ThrowAppTestSupport.swift b/Throw/Throw/Tests/ThrowAppTestSupport.swift index 96fc268f9..0a58e28a5 100644 --- a/Throw/Throw/Tests/ThrowAppTestSupport.swift +++ b/Throw/Throw/Tests/ThrowAppTestSupport.swift @@ -9,7 +9,6 @@ final class ThrowApplicationRuntimeSpy: ThrowApplicationRuntime { private(set) var controllerSceneEvents: [RecordedControllerSceneEvent] = [] private(set) var connectedOutputs: [ProjectionOutput] = [] private(set) var disconnectedOutputs: [ProjectionOutput] = [] - private(set) var appearances: [UIUserInterfaceStyle] = [] private(set) var outputDemandChangeCount = 0 init(session: ThrowSession) { @@ -20,10 +19,7 @@ final class ThrowApplicationRuntimeSpy: ThrowApplicationRuntime { self.init(session: .fixture()) } - func projectionOutputConnected( - _ output: ProjectionOutput, - appearanceSink _: @escaping @MainActor (UIUserInterfaceStyle) -> Void, - ) { + func projectionOutputConnected(_ output: ProjectionOutput) { connectedOutputs.append(output) } @@ -38,10 +34,6 @@ final class ThrowApplicationRuntimeSpy: ThrowApplicationRuntime { controllerSceneEvents.append(RecordedControllerSceneEvent(id: id, event: event)) } - func controllerAppearanceDidChange(_ style: UIUserInterfaceStyle) { - appearances.append(style) - } - func sessionOutputDemandDidChange() { outputDemandChangeCount += 1 } diff --git a/Throw/Throw/Tests/ThrowAppTests.swift b/Throw/Throw/Tests/ThrowAppTests.swift index f472e8b39..962598bbb 100644 --- a/Throw/Throw/Tests/ThrowAppTests.swift +++ b/Throw/Throw/Tests/ThrowAppTests.swift @@ -24,14 +24,4 @@ struct ThrowAppTests { RecordedControllerSceneEvent(id: id, event: .didEnterBackground), ]) } - - @Test func iOS27ExternalAccessoryConfigurationHasStableSceneIdentity() throws { - let configuration = AppDelegate.externalDisplayConfiguration() - let delegateClass = try #require(configuration.delegateClass) - - #expect(configuration.name == "Throw External Display") - #expect(configuration.role == .windowExternalDisplayNonInteractive) - #expect(ObjectIdentifier(delegateClass) == - ObjectIdentifier(ExternalDisplaySceneDelegate.self)) - } } diff --git a/Throw/Throw/Tests/ThrowRuntimeTests.swift b/Throw/Throw/Tests/ThrowRuntimeTests.swift index 295d6b991..331b8dc49 100644 --- a/Throw/Throw/Tests/ThrowRuntimeTests.swift +++ b/Throw/Throw/Tests/ThrowRuntimeTests.swift @@ -55,8 +55,8 @@ struct ThrowRuntimeTests { ProjectionOutputID(rawValue: "preview-test"), ) - runtime.projectionOutputConnected(external) { _ in } - runtime.projectionOutputConnected(preview) { _ in } + runtime.projectionOutputConnected(external) + runtime.projectionOutputConnected(preview) #expect(idleTimer.isIdleTimerDisabled) runtime.projectionOutputDisconnected(external) @@ -77,32 +77,13 @@ struct ThrowRuntimeTests { ProjectionOutputID(rawValue: "external-test"), ) - runtime.projectionOutputConnected(output) { _ in } - runtime.projectionOutputConnected(output) { _ in } + runtime.projectionOutputConnected(output) + runtime.projectionOutputConnected(output) runtime.projectionOutputDisconnected(output) #expect(idleTimer.isIdleTimerDisabled == false) } - @Test func externalAppearanceTracksTheController() { - let runtime = ThrowRuntime( - session: .fixture(), - idleTimerController: IdleTimerControllerSpy(isIdleTimerDisabled: false), - backgroundExecutionLeaser: BackgroundExecutionLeaserSpy(), - ) - let output = ProjectionOutput.externalDisplay( - ProjectionOutputID(rawValue: "external-test"), - ) - var received: [UIUserInterfaceStyle] = [] - - runtime.projectionOutputConnected(output) { received.append($0) } - runtime.controllerAppearanceDidChange(.dark) - runtime.controllerAppearanceDidChange(.dark) - runtime.controllerAppearanceDidChange(.light) - - #expect(received == [.unspecified, .dark, .light]) - } - @Test func sessionOutputDemandDrivesTheIdleTimerBridge() { let session = ThrowSession.fixture() let idleTimer = IdleTimerControllerSpy(isIdleTimerDisabled: false) @@ -161,7 +142,7 @@ struct ThrowRuntimeTests { ProjectionOutputID(rawValue: "external-lifecycle-test"), ) - runtime.projectionOutputConnected(externalOutput) { _ in } + runtime.projectionOutputConnected(externalOutput) #expect(session.hasProjectionOutputDemand) #expect(session.hasForegroundControllerSceneForTesting == false) diff --git a/Throw/ThrowCore/AGENTS.md b/Throw/ThrowCore/AGENTS.md index 516da164b..18c4bd658 100644 --- a/Throw/ThrowCore/AGENTS.md +++ b/Throw/ThrowCore/AGENTS.md @@ -10,6 +10,8 @@ location, and scheduling; see [`README.md`](README.md). Read the root import SwiftUI, UIKit, WhereCore, RegionKit, or LifecycleKit. - Keep provider DTOs internal. Public source, layer, preference, and credential boundaries stay provider-neutral and typed. +- Keep GTFS schedule and realtime adapters behind `TransitScheduleSource` and + `TransitObservationSource`. Do not expose generated protobuf types. - Keep provider-specific setup and capability dispatch in `AircraftSourceService`. Presentation code uses its protocol only. - Keep source factories at composition boundaries. A source never creates a @@ -59,13 +61,14 @@ location, and scheduling; see [`README.md`](README.md). Read the root - Keep projection functions deterministic and independent of SwiftUI layout. - Represent geodetic altitude as `GeodeticAltitude`. An available altitude carries its value and available quality together. +- Construct projection Map radii only from validated experience-specific viewports. - Keep experience and layer catalogs compile-time and free of UI values. Add no runtime plugin or `AnyView` boundary. - Keep shipped experience identities closed. Derive standard descriptors and presentation through exhaustive switches so a new case forces every owner to update. - Keep the standard experience catalog authoritative. Pass - `RunnableProjectionExperienceID` through playlist mutations, and keep planned - `ProjectionExperienceID` values at display and persistence boundaries. + `RunnableProjectionExperienceID` through playlist mutations. Keep + `ProjectionExperienceID` at display and persistence boundaries. - Construct semantic frames through typed layer and experience cases. Never pass parallel experience IDs, raw layer arrays, and modes across production boundaries. - Keep layer IDs, mark element families, line styles, and payload shapes closed @@ -89,12 +92,19 @@ location, and scheduling; see [`README.md`](README.md). Read the root erased layer array. - Erase projected frames only in ThrowUI's `ProjectionFrame.swift`. Cache static lines by layer identity and semantic revision. -- Keep version-two preferences grouped by global, playlist, and experience - ownership. Preserve exact version-one migration and existing Keychain IDs. +- Keep version-four preferences grouped by global, playlist, and experience + ownership. Preserve exact version-one, version-two, and version-three + migrations and existing Keychain IDs. - Keep global and experience preferences as validated aggregate values. Use their replacement methods instead of mutable scalar mirrors. - Represent temporary quiet wake durations as `TemporaryQuietWake`. Never pass a raw minute count across the session boundary. +- Treat MTA realtime partitions as independent failure domains. Do not remove a + successful partition because another partition fails. +- Keep transit run identity scoped by agency, feed partition, service date, and + stable run value. Do not use a route or trip ID as a vehicle identity. +- Keep scheduled and realtime transit data separate. Persist only the static + schedule cache. Never persist a run, prediction, or estimated position. - Project Geography with the selected regional Map center and saved calibration. Never use Mercator placement or draw it in True Sky. - Keep observer and Map-center semantics separate. True Sky and local activity diff --git a/Throw/ThrowCore/README.md b/Throw/ThrowCore/README.md index c31ee6610..6374edf2b 100644 --- a/Throw/ThrowCore/README.md +++ b/Throw/ThrowCore/README.md @@ -22,6 +22,8 @@ important boundaries are: - `ProjectionExperienceCatalog.standard` and `LayerCatalog.standard`, which define fixed View and layer membership at compile time; - `AircraftObservationSource`, the provider-neutral one-shot feed contract; +- `TransitScheduleSource` and `TransitObservationSource`, the provider-neutral + static and realtime transit contracts; - `AircraftSourceOperationServing`, the provider-neutral setup and usage boundary used by presentation code; - HTTP, preference, credential, location, and clock protocols, with live and @@ -64,12 +66,13 @@ outward so provider coordinates on the local filter boundary are not omitted. ## Composition -`ThrowSession+Composition.swift` creates the live stores, source graph, poller, -and session once. ThrowUI's shared session drives the poller according to -foreground, quiet, and output demand. Version-two preferences separate global, -playlist, and Air & Space state. The codec migrates version-one data under the -existing storage key. Validated preference aggregates stay intact through the -session boundary. Raw values exist only at editing and codec boundaries. +`ThrowSession+Composition.swift` creates the live stores, source graphs, +pollers, runtimes, and session once. ThrowUI's shared session drives the +pollers according to foreground, quiet, output demand, and View state. +Version-four preferences separate global, playlist, Air & Space, and Transit +state. The codec migrates version-one, version-two, and version-three data under +the existing storage key. Validated preference aggregates stay intact through +the session boundary. Raw values exist only at editing and codec boundaries. Keychain credential IDs do not change. Setup is one typed lifecycle value. Its configured case requires a validated @@ -78,8 +81,8 @@ state from the stable version-one and version-two fields. The process starts one Periscope store and attaches it to Throw's typed log. The durable-logging starter is also the session failure logger. It sends failures -to OSLog while this store opens. It also -retains each exact typed record and its error attachment. The handoff writes +to OSLog while this store opens. It retains each exact typed record and its +error attachment. The handoff writes these records to the store once before new session records use the attached sink. The store keeps at most 100 days and 50,000 events. A store error leaves OSLog active and produces a typed error event. A history-prune error does not make @@ -106,13 +109,14 @@ construction exists only in DEBUG Testing SPI. `ProjectionExperienceInput` also pairs each experience with its supported projection modes. Transit can accept only a Map viewport. Geography visibility belongs to Map inputs, so it cannot be requested in True Sky. -`ProjectionExperienceID` is the closed display and persistence identity. It -includes planned Transit. `RunnableProjectionExperienceID` is the smaller -release runtime identity. It includes only Air & Space. The standard catalog -owns the relationship between these types. Its public descriptors can be read, -but production code cannot construct another catalog. Test catalogs are -available only through the DEBUG Testing SPI. Playlist entries and mutations -require runnable identities, so planned Transit cannot enter a release playlist. +`ProjectionExperienceID` is the closed display and persistence identity. +`RunnableProjectionExperienceID` is the closed release runtime identity for +Air & Space and Transit. The standard catalog owns the relationship between +these types. Its public descriptors can be read, but production code cannot +construct another catalog. Test catalogs are available only through the DEBUG +Testing SPI. Playlist entries and mutations require runnable identities. +The Transit radius accepts whole nautical-mile values from 2 through 8 NM. +The default radius is 5 NM. `ProjectionMarkLayerKind` and `ProjectionLineLayerKind` bind each semantic element or style family to its projected payload. `ProjectedLayerFrame` keeps @@ -128,10 +132,10 @@ The worker converts `ProjectionExperienceInput` into one closed accepts that value and returns the matching `ProjectedExperienceFrame`. Each present Transit network produces one required projected frame. That frame stores the semantic source revision, so the prepared value cannot omit it. -ThrowUI erases the closed output once in -`ProjectionFrame.swift`, at the renderer boundary. +ThrowUI erases the closed output once in `ProjectionFrame.swift`, at the +renderer boundary. -Line styles are typed, so Geography and future transit routes use one +Line styles are typed, so Geography and Transit routes use one projection path. `ProjectionEngine.lineFrame` records the semantic source revision and full projection context in each static-line frame. The engine rejects a prepared frame from another source revision or projection context. @@ -152,6 +156,23 @@ delayed state read cannot replace a newer stream publication. The polling clock can finish a wait or report cancellation. It cannot leave a dead poll task in a retrying state through another error. +The NYC transit adapter reads the official MTA supplemented GTFS archive and +eight GTFS Realtime partitions. SwiftProtobuf decodes the standard feed and the +NYCT extension. ZIPFoundation reads the static archive. The static parser +rejects duplicate identities, malformed CSV rows, invalid shapes, and broken +references. + +The Transit estimator matches an exact trip ID first. If that ID is absent, it +matches the route, direction, and remaining stop sequence. It places each train +on the matching GTFS shape between its previous and next stops. Consecutive +updates increase position confidence. The semantic frame keeps schedule-inferred +and feed-tracked positions distinct. + +The Transit runtime polls all partitions concurrently every 30 seconds. A +partition failure does not remove data from successful partitions. Failed +partition marks remain steady for 90 seconds and fade for the next 30 seconds. +The runtime refreshes the cached schedule each hour. + The Flights runtime compares consecutive positions for each aircraft. Valid provider track and speed remain authoritative. Observed positions supply motion when provider values are missing or clearly inconsistent. @@ -165,12 +186,14 @@ Throw uses that turn rate for the first 12 seconds of a prediction. The runtime removes this history when the source changes or the app runtime ends. `Tools/generate-geography.rb` creates the bundled archive from pinned Natural -Earth Vector 1:10m and U.S. Census Bureau inputs. A source manifest records each -official URL, release, file member, and SHA-256 digest. +Earth Vector 1:10m, U.S. Census Bureau, and NYC Open Data inputs. A source +manifest records each official URL, release, file member, and SHA-256 digest. The generator filters features, removes names, splits antimeridian paths, and simplifies linework. It then quantizes coordinates and assigns wide, standard, -or local visibility. The committed archive lets every app build stay offline. +local, or neighborhood visibility. Source-resolution Census coastline and NYC +planimetric shoreline replace broader coastline tiers at neighborhood scale. +The committed archive lets every app build stay offline. Raw source archives stay outside the tracked tree. `Tools/generate-aircraft-types.rb` creates the bundled ICAO type lookup from a @@ -220,6 +243,9 @@ and aggregate retention counts. Route enrichment sends broadcast callsigns to ADSBDB. It never sends aircraft or observer positions, persists route history, or logs route request or response values. +The MTA requests contain no observer or Map-center coordinate. Throw stores the +downloaded static schedule in the cache. It does not persist realtime runs, +train identities, stop predictions, or position history. ## Testing diff --git a/Throw/ThrowCore/Sources/DomainValues.swift b/Throw/ThrowCore/Sources/DomainValues.swift index 1b7784f5a..c5603c4dd 100644 --- a/Throw/ThrowCore/Sources/DomainValues.swift +++ b/Throw/ThrowCore/Sources/DomainValues.swift @@ -161,6 +161,15 @@ public struct MapViewport: Hashable, Sendable { } } +/// A Map radius accepted by projection after an experience validates its own viewport rules. +public struct ProjectionMapViewport: Hashable, Sendable { + public let radius: NauticalMiles + + public init(_ viewport: MapViewport) { + radius = viewport.radius + } +} + public struct SkyViewport: Hashable, Sendable { public static let allowedMinimumElevation = 0.0 ... 45.0 public static let defaultValue = try! SkyViewport( @@ -183,9 +192,13 @@ public struct SkyViewport: Hashable, Sendable { } public enum ProjectionViewport: Hashable, Sendable { - case map(MapViewport) + case map(ProjectionMapViewport) case trueSky(SkyViewport) + public static func map(_ viewport: MapViewport) -> Self { + .map(ProjectionMapViewport(viewport)) + } + public var mode: ProjectionMode { switch self { case .map: .map diff --git a/Throw/ThrowCore/Sources/FlightPredictor.swift b/Throw/ThrowCore/Sources/FlightPredictor.swift index 9c63593ac..edab897b3 100644 --- a/Throw/ThrowCore/Sources/FlightPredictor.swift +++ b/Throw/ThrowCore/Sources/FlightPredictor.swift @@ -71,8 +71,13 @@ public enum FlightPredictor { return mark } - let coordinate: GeoCoordinate = if let track = mark.velocity?.groundTrack, - let speed = mark.velocity?.groundSpeedKnots + let coordinate: GeoCoordinate = if let transitMotion = mark.transitMotion { + try transitCoordinate( + for: transitMotion, + at: mark.freshness.positionObservedAt.addingTimeInterval(observationAge), + ) + } else if let track = mark.velocity?.groundTrack, + let speed = mark.velocity?.groundSpeedKnots { try predictedCoordinate( from: anchor.coordinate, @@ -111,10 +116,46 @@ public enum FlightPredictor { label: mark.label, prominence: mark.prominence, velocity: mark.velocity, + transitMotion: mark.transitMotion, freshness: mark.freshness, ) } + private static func transitCoordinate( + for motion: TransitProjectionMotion, + at date: Date, + ) throws -> GeoCoordinate { + let duration = motion.endsAt.timeIntervalSince(motion.startsAt) + let elapsed = date.timeIntervalSince(motion.startsAt) + let progress = min(max(elapsed / duration, 0), 1) + guard let first = motion.points.first, let last = motion.points.last else { + throw TransitDataError.invalidSchedule + } + let totalDistance = last.distance - first.distance + guard totalDistance > 0 else { return last.coordinate } + let target = first.distance + totalDistance * progress + guard let upperIndex = motion.points.firstIndex(where: { $0.distance >= target }) else { + return last.coordinate + } + guard upperIndex > 0 else { return first.coordinate } + let lower = motion.points[upperIndex - 1] + let upper = motion.points[upperIndex] + let span = upper.distance - lower.distance + guard span > 0 else { return upper.coordinate } + let segmentProgress = (target - lower.distance) / span + var longitudeDelta = upper.coordinate.longitude - lower.coordinate.longitude + if longitudeDelta > 180 { longitudeDelta -= 360 } + if longitudeDelta < -180 { longitudeDelta += 360 } + var longitude = lower.coordinate.longitude + longitudeDelta * segmentProgress + if longitude > 180 { longitude -= 360 } + if longitude < -180 { longitude += 360 } + return try GeoCoordinate( + latitude: lower.coordinate.latitude + + (upper.coordinate.latitude - lower.coordinate.latitude) * segmentProgress, + longitude: longitude, + ) + } + static func observationAge(positionObservedAt: Date, at date: Date) -> TimeInterval? { let age = date.timeIntervalSince(positionObservedAt) guard age.isFinite, age >= 0 else { return nil } @@ -127,18 +168,36 @@ public enum FlightPredictor { ) -> Double? { switch availability { case .current: - return 1 + 1 case let .retrying(since): - guard let age = observationAge(positionObservedAt: since, at: date) else { - return nil - } - let expiration = failureGracePeriod + failureFadeDuration - guard age < expiration else { return nil } - guard age > failureGracePeriod else { return 1 } - return 1 - (age - failureGracePeriod) / failureFadeDuration + failureOpacity( + since: since, + at: date, + gracePeriod: failureGracePeriod, + fadeDuration: failureFadeDuration, + ) + case let .transitRetrying(since): + failureOpacity( + since: since, + at: date, + gracePeriod: 90, + fadeDuration: 30, + ) } } + private static func failureOpacity( + since: Date, + at date: Date, + gracePeriod: TimeInterval, + fadeDuration: TimeInterval, + ) -> Double? { + guard let age = observationAge(positionObservedAt: since, at: date) else { return nil } + guard age < gracePeriod + fadeDuration else { return nil } + guard age > gracePeriod else { return 1 } + return 1 - (age - gracePeriod) / fadeDuration + } + private static func predictedAltitude( current: Altitude, verticalRateFeetPerMinute: Double, diff --git a/Throw/ThrowCore/Sources/GeographyModels.swift b/Throw/ThrowCore/Sources/GeographyModels.swift index 6a80d6f5f..8dccb6ffc 100644 --- a/Throw/ThrowCore/Sources/GeographyModels.swift +++ b/Throw/ThrowCore/Sources/GeographyModels.swift @@ -17,15 +17,24 @@ public protocol ProjectionLineStyle: Hashable, Sendable {} extension GeographyLineKind: ProjectionLineStyle {} /// The closed style family for the Transit network layer. -public enum TransitNetworkLineStyle: Hashable, Sendable, ProjectionLineStyle { - case route +public struct TransitNetworkLineStyle: Hashable, Sendable, ProjectionLineStyle { + public let routeID: TransitRouteID + public let color: TransitColor + + public init(routeID: TransitRouteID, color: TransitColor) { + self.routeID = routeID + self.color = color + } } +public typealias TransitRouteLineStyle = TransitNetworkLineStyle + /// Controls the largest Map radius at which a geographic line can appear. public enum GeographyDetailLevel: String, CaseIterable, Codable, Hashable, Sendable { case wide case standard case local + case neighborhood public func includes(mapRadius: NauticalMiles) -> Bool { switch self { @@ -35,6 +44,15 @@ public enum GeographyDetailLevel: String, CaseIterable, Codable, Hashable, Senda mapRadius.value <= 80 case .local: mapRadius.value <= 20 + case .neighborhood: + mapRadius.value <= 8 + } + } + + public var replacesBroaderDetail: Bool { + switch self { + case .wide, .standard, .local: false + case .neighborhood: true } } } diff --git a/Throw/ThrowCore/Sources/LayerCatalog.swift b/Throw/ThrowCore/Sources/LayerCatalog.swift index c281b3976..1482ab2f4 100644 --- a/Throw/ThrowCore/Sources/LayerCatalog.swift +++ b/Throw/ThrowCore/Sources/LayerCatalog.swift @@ -85,11 +85,19 @@ public struct LayerCatalog: Sendable { geographyFactory: LayerRuntimeFactory { GeographyLayerRuntime(dataSource: BundledGeographyDataSource()) }, + transitNetworkFactory: LayerRuntimeFactory { + TransitNetworkLayerRuntime(builder: TransitLayerFrameBuilder()) + }, + transitVehiclesFactory: LayerRuntimeFactory { + TransitVehiclesLayerRuntime(builder: TransitLayerFrameBuilder()) + }, ) /// Typed descriptors used to construct the enabled production layers. public let flights: LayerDescriptor public let geography: LayerDescriptor + public let transitNetwork: LayerDescriptor + public let transitVehicles: LayerDescriptor /// The heterogeneous catalog used for discovery and presentation only. public let descriptors: [AnyLayerDescriptor] @@ -97,6 +105,8 @@ public struct LayerCatalog: Sendable { public init( flightsFactory: LayerRuntimeFactory, geographyFactory: LayerRuntimeFactory, + transitNetworkFactory: LayerRuntimeFactory, + transitVehiclesFactory: LayerRuntimeFactory, ) { let flights = LayerDescriptor( availability: LayerAvailability.enabled, @@ -119,20 +129,18 @@ public struct LayerCatalog: Sendable { }, ) let transitNetwork = LayerDescriptor( - availability: LayerAvailability.planned, - runtimeFactory: LayerRuntimeFactory { - EmptyLayerRuntime() - }, + availability: LayerAvailability.enabled, + runtimeFactory: transitNetworkFactory, ) let transitVehicles = LayerDescriptor( - availability: LayerAvailability.planned, - runtimeFactory: LayerRuntimeFactory { - EmptyLayerRuntime() - }, + availability: LayerAvailability.enabled, + runtimeFactory: transitVehiclesFactory, ) self.flights = flights self.geography = geography + self.transitNetwork = transitNetwork + self.transitVehicles = transitVehicles descriptors = [ AnyLayerDescriptor(flights), AnyLayerDescriptor(geography), diff --git a/Throw/ThrowCore/Sources/ProjectionEngine.swift b/Throw/ThrowCore/Sources/ProjectionEngine.swift index 5fe402cae..84280799e 100644 --- a/Throw/ThrowCore/Sources/ProjectionEngine.swift +++ b/Throw/ThrowCore/Sources/ProjectionEngine.swift @@ -305,7 +305,7 @@ public struct ProjectionEngine: Sendable { calibration: calibration, geometry: geometry, ) - let orientation = switch mark.glyph { + let orientation: Double? = switch mark.glyph { case let .airport(descriptor): try projectedOrientation( bearing: descriptor.runwayBearing, anchor: prediction.mark.anchor, @@ -324,6 +324,8 @@ public struct ProjectionEngine: Sendable { geometry: geometry, currentPoint: point, ) + case .transitStop: + nil } let altitudeIsApproximate: Bool = switch prediction.mark.anchor { case let .geodetic(anchor): @@ -382,6 +384,11 @@ public struct ProjectionEngine: Sendable { ) throws -> [ProjectedLineSegment