├── .gitignore ├── Assets.xcassets ├── AppIcon.appiconset │ └── Contents.json ├── Contents.json ├── logo-anim.dataset │ ├── Contents.json │ └── logo-anim.swf ├── ruffle-blue-6.colorset │ └── Contents.json ├── ruffle-blue-9.colorset │ └── Contents.json ├── ruffle-blue.colorset │ └── Contents.json └── ruffle-orange.colorset │ └── Contents.json ├── Base.lproj ├── LaunchScreen.storyboard └── Main.storyboard ├── Cargo.lock ├── Cargo.toml ├── Info.plist ├── LICENSE.md ├── README.md ├── Ruffle.xcdatamodeld └── Ruffle.xcdatamodel │ └── contents ├── build-in-xcode.sh ├── file-icons ├── ATTRIBUTION.md ├── document_ruf.icns ├── document_swf.icns └── file-icons.xcassets │ ├── Contents.json │ ├── document_ruf.imageset │ ├── Contents.json │ └── document_ruf.svg │ └── document_swf.imageset │ ├── 256x256.png │ ├── 512x512.png │ └── Contents.json ├── ruffle-ios.entitlements ├── ruffle-ios.xcodeproj ├── project.pbxproj └── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── rust-toolchain.toml └── src ├── add_controller.rs ├── app_delegate.rs ├── bin └── run_swf.rs ├── edit_controller.rs ├── lib.rs ├── library_controller.rs ├── main.rs ├── player_controller.rs ├── player_view.rs ├── scene_delegate.rs ├── storage.rs └── storyboard_connections.h /.gitignore: -------------------------------------------------------------------------------- 1 | # Rust 2 | /target 3 | /.cargo 4 | 5 | # Xcode user settings 6 | xcuserdata/ 7 | 8 | # Xcode app packaging 9 | *.ipa 10 | *.dSYM.zip 11 | *.dSYM 12 | -------------------------------------------------------------------------------- /Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | } 8 | ], 9 | "info" : { 10 | "author" : "xcode", 11 | "version" : 1 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Assets.xcassets/logo-anim.dataset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "data" : [ 3 | { 4 | "filename" : "logo-anim.swf", 5 | "idiom" : "universal", 6 | "universal-type-identifier" : "swf" 7 | } 8 | ], 9 | "info" : { 10 | "author" : "xcode", 11 | "version" : 1 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Assets.xcassets/logo-anim.dataset/logo-anim.swf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/madsmtm/ruffle-ios/5cc5a5b0eea87b03531b8ac1217160915d635be4/Assets.xcassets/logo-anim.dataset/logo-anim.swf -------------------------------------------------------------------------------- /Assets.xcassets/ruffle-blue-6.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0xB6", 9 | "green" : "0x71", 10 | "red" : "0x52" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Assets.xcassets/ruffle-blue-9.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0x80", 9 | "green" : "0x49", 10 | "red" : "0x2C" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Assets.xcassets/ruffle-blue.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0x8C", 9 | "green" : "0x52", 10 | "red" : "0x37" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Assets.xcassets/ruffle-orange.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0x33", 9 | "green" : "0xAD", 10 | "red" : "0xFF" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /Base.lproj/Main.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 71 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 223 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 319 | 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | 337 | 338 | 339 | 340 | 341 | 342 | 343 | 344 | 345 | 346 | 347 | 348 | 349 | 350 | 351 | 352 | 353 | 354 | 355 | 356 | 357 | 358 | 359 | 360 | 361 | 362 | 363 | 364 | 365 | 366 | 367 | 368 | 369 | 370 | 371 | 372 | 373 | 374 | 375 | 376 | 377 | 378 | 379 | 380 | 381 | 382 | 383 | 384 | 385 | 386 | 387 | 388 | 389 | 390 | 391 | 392 | 393 | 394 | 395 | 396 | 397 | 398 | 399 | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "ruffle-ios" 3 | version = "0.1.0" 4 | authors = ["Mads Marquart "] 5 | edition = "2021" 6 | license = "MIT OR Apache-2.0" 7 | rust-version = "1.81" 8 | publish = false 9 | 10 | [features] 11 | default = ["render_debug_labels"] 12 | render_debug_labels = ["ruffle_render_wgpu/render_debug_labels"] 13 | 14 | [dependencies] 15 | block2 = "0.6.2" 16 | objc2 = "0.6.3" 17 | objc2-core-data = "0.3.2" 18 | objc2-core-foundation = "0.3.2" 19 | objc2-foundation = "0.3.2" 20 | objc2-ui-kit = "0.3.2" 21 | objc2-metal = "0.3.2" 22 | objc2-quartz-core = "0.3.2" 23 | 24 | ruffle_core = { git = "https://github.com/ruffle-rs/ruffle.git", branch = "master", features = [ 25 | "audio", 26 | "symphonia", 27 | "mp3", 28 | "nellymoser", 29 | "lzma", 30 | "default_compatibility_rules", 31 | "default_font", 32 | ] } 33 | ruffle_render = { git = "https://github.com/ruffle-rs/ruffle.git", branch = "master" } 34 | ruffle_render_wgpu = { git = "https://github.com/ruffle-rs/ruffle.git", branch = "master" } 35 | ruffle_video = { git = "https://github.com/ruffle-rs/ruffle.git", branch = "master" } 36 | ruffle_video_software = { git = "https://github.com/ruffle-rs/ruffle.git", branch = "master" } 37 | ruffle_frontend_utils = { git = "https://github.com/ruffle-rs/ruffle.git", branch = "master", features = [ 38 | "cpal", 39 | ] } 40 | flv-rs = { git = "https://github.com/ruffle-rs/ruffle.git", branch = "master" } 41 | 42 | wgpu = "25.0.2" 43 | cpal = "0.15.3" 44 | url = "2.5.2" 45 | tokio = { version = "1.40.0", features = ["rt-multi-thread", "macros"] } 46 | libc = "0.2.158" 47 | 48 | tracing = "0.1.40" 49 | tracing-subscriber = { version = "0.3.20", features = ["env-filter"] } 50 | tracing-oslog = "0.3.0" 51 | 52 | [package.metadata.bundle.bin.run_swf] 53 | name = "Ruffle" 54 | identifier = "rs.ruffle.ios-dev" 55 | 56 | [patch."https://github.com/ruffle-rs/ruffle.git"] 57 | # Override Ruffle for local development. 58 | # ruffle_core = { path = "../ruffle/core" } 59 | # ruffle_render = { path = "../ruffle/render" } 60 | # ruffle_render_wgpu = { path = "../ruffle/render/wgpu" } 61 | # ruffle_video = { path = "../ruffle/video" } 62 | # ruffle_video_software = { path = "../ruffle/video/software" } 63 | # ruffle_frontend_utils = { path = "../ruffle/frontend-utils" } 64 | # flv-rs = { path = "../ruffle/flv" } 65 | 66 | [patch.crates-io] 67 | # Fix broken Mac Catalyst. 68 | cc = { git = "https://github.com/madsmtm/cc-rs.git", branch = "macabi" } 69 | 70 | # visionOS support 71 | # wgpu = { path = "../wgpu/wgpu" } 72 | # naga = { path = "../wgpu/naga" } 73 | # Cannot use git, see https://github.com/briansmith/ring/issues/2144 74 | # ring = { git = "https://github.com/briansmith/ring.git", rev = "2bec9b2e057416e8f58f2ad8d47ccc9599ea6146" } 75 | -------------------------------------------------------------------------------- /Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDocumentTypes 6 | 7 | 8 | CFBundleTypeName 9 | Bundle 10 | LSItemContentTypes 11 | 12 | com.example.rs.ruffle.bundle 13 | 14 | CFBundleTypeRole 15 | Viewer 16 | LSHandlerRank 17 | Owner 18 | LSTypeIsPackage 19 | 20 | 21 | 22 | CFBundleTypeName 23 | Shockwave Flash Movie 24 | LSItemContentTypes 25 | 26 | com.adobe.swf 27 | 28 | CFBundleTypeRole 29 | Viewer 30 | LSHandlerRank 31 | Owner 32 | LSTypeIsPackage 33 | 34 | 35 | 36 | UIApplicationSceneManifest 37 | 38 | UIApplicationSupportsMultipleScenes 39 | 40 | UISceneConfigurations 41 | 42 | UIWindowSceneSessionRoleApplication 43 | 44 | 45 | UISceneConfigurationName 46 | Default Configuration 47 | UISceneDelegateClassName 48 | SceneDelegate 49 | UISceneStoryboardFile 50 | Main 51 | 52 | 53 | 54 | 55 | UTExportedTypeDeclarations 56 | 57 | 58 | UTTypeIdentifier 59 | com.example.rs.ruffle.bundle 60 | UTTypeDescription 61 | Ruffle Bundle 62 | UTTypeIconFiles 63 | 64 | document_ruf.icns 65 | 66 | UTTypeConformsTo 67 | 68 | public.data 69 | public.content 70 | public.directory 71 | public.audiovisual-content 72 | com.apple.package 73 | public.archive 74 | public.zip-archive 75 | 76 | UTTypeTagSpecification 77 | 78 | public.filename-extension 79 | 80 | ruf 81 | 82 | 83 | 84 | 85 | UTImportedTypeDeclarations 86 | 87 | 88 | UTTypeIdentifier 89 | com.adobe.swf 90 | UTTypeDescription 91 | Shockwave Flash Movie 92 | UTTypeIconFiles 93 | 94 | document_swf.icns 95 | 96 | UTTypeConformsTo 97 | 98 | public.data 99 | public.content 100 | public.audiovisual-content 101 | 102 | UTTypeTagSpecification 103 | 104 | public.filename-extension 105 | 106 | swf 107 | 108 | public.mime-type 109 | 110 | application/vnd.adobe.flash.movie 111 | application/x-shockwave-flash 112 | 113 | com.apple.ostype 114 | 115 | SWFL 116 | 117 | 118 | 119 | 120 | 121 | 122 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # ruffle-ios license 2 | 3 | ruffle-ios is licensed under either of 4 | 5 | - Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) 6 | - MIT license (http://opensource.org/licenses/MIT) 7 | 8 | at your option. 9 | 10 | ## MIT License 11 | 12 | Copyright (c) 2018-2022 Ruffle LLC and Ruffle contributors 13 | (https://github.com/ruffle-rs/ruffle/graphs/contributors) 14 | 15 | Permission is hereby granted, free of charge, to any 16 | person obtaining a copy of this software and associated 17 | documentation files (the "Software"), to deal in the 18 | Software without restriction, including without 19 | limitation the rights to use, copy, modify, merge, 20 | publish, distribute, sublicense, and/or sell copies of 21 | the Software, and to permit persons to whom the Software 22 | is furnished to do so, subject to the following 23 | conditions: 24 | 25 | The above copyright notice and this permission notice 26 | shall be included in all copies or substantial portions 27 | of the Software. 28 | 29 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 30 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 31 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 32 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 33 | SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 34 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 35 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 36 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 37 | DEALINGS IN THE SOFTWARE. 38 | 39 | ## Apache License, Version 2.0 40 | 41 | Apache License 42 | Version 2.0, January 2004 43 | http://www.apache.org/licenses/ 44 | 45 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 46 | 47 | 1. Definitions. 48 | 49 | "License" shall mean the terms and conditions for use, reproduction, 50 | and distribution as defined by Sections 1 through 9 of this document. 51 | 52 | "Licensor" shall mean the copyright owner or entity authorized by 53 | the copyright owner that is granting the License. 54 | 55 | "Legal Entity" shall mean the union of the acting entity and all 56 | other entities that control, are controlled by, or are under common 57 | control with that entity. For the purposes of this definition, 58 | "control" means (i) the power, direct or indirect, to cause the 59 | direction or management of such entity, whether by contract or 60 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 61 | outstanding shares, or (iii) beneficial ownership of such entity. 62 | 63 | "You" (or "Your") shall mean an individual or Legal Entity 64 | exercising permissions granted by this License. 65 | 66 | "Source" form shall mean the preferred form for making modifications, 67 | including but not limited to software source code, documentation 68 | source, and configuration files. 69 | 70 | "Object" form shall mean any form resulting from mechanical 71 | transformation or translation of a Source form, including but 72 | not limited to compiled object code, generated documentation, 73 | and conversions to other media types. 74 | 75 | "Work" shall mean the work of authorship, whether in Source or 76 | Object form, made available under the License, as indicated by a 77 | copyright notice that is included in or attached to the work 78 | (an example is provided in the Appendix below). 79 | 80 | "Derivative Works" shall mean any work, whether in Source or Object 81 | form, that is based on (or derived from) the Work and for which the 82 | editorial revisions, annotations, elaborations, or other modifications 83 | represent, as a whole, an original work of authorship. For the purposes 84 | of this License, Derivative Works shall not include works that remain 85 | separable from, or merely link (or bind by name) to the interfaces of, 86 | the Work and Derivative Works thereof. 87 | 88 | "Contribution" shall mean any work of authorship, including 89 | the original version of the Work and any modifications or additions 90 | to that Work or Derivative Works thereof, that is intentionally 91 | submitted to Licensor for inclusion in the Work by the copyright owner 92 | or by an individual or Legal Entity authorized to submit on behalf of 93 | the copyright owner. For the purposes of this definition, "submitted" 94 | means any form of electronic, verbal, or written communication sent 95 | to the Licensor or its representatives, including but not limited to 96 | communication on electronic mailing lists, source code control systems, 97 | and issue tracking systems that are managed by, or on behalf of, the 98 | Licensor for the purpose of discussing and improving the Work, but 99 | excluding communication that is conspicuously marked or otherwise 100 | designated in writing by the copyright owner as "Not a Contribution." 101 | 102 | "Contributor" shall mean Licensor and any individual or Legal Entity 103 | on behalf of whom a Contribution has been received by Licensor and 104 | subsequently incorporated within the Work. 105 | 106 | 2. Grant of Copyright License. Subject to the terms and conditions of 107 | this License, each Contributor hereby grants to You a perpetual, 108 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 109 | copyright license to reproduce, prepare Derivative Works of, 110 | publicly display, publicly perform, sublicense, and distribute the 111 | Work and such Derivative Works in Source or Object form. 112 | 113 | 3. Grant of Patent License. Subject to the terms and conditions of 114 | this License, each Contributor hereby grants to You a perpetual, 115 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 116 | (except as stated in this section) patent license to make, have made, 117 | use, offer to sell, sell, import, and otherwise transfer the Work, 118 | where such license applies only to those patent claims licensable 119 | by such Contributor that are necessarily infringed by their 120 | Contribution(s) alone or by combination of their Contribution(s) 121 | with the Work to which such Contribution(s) was submitted. If You 122 | institute patent litigation against any entity (including a 123 | cross-claim or counterclaim in a lawsuit) alleging that the Work 124 | or a Contribution incorporated within the Work constitutes direct 125 | or contributory patent infringement, then any patent licenses 126 | granted to You under this License for that Work shall terminate 127 | as of the date such litigation is filed. 128 | 129 | 4. Redistribution. You may reproduce and distribute copies of the 130 | Work or Derivative Works thereof in any medium, with or without 131 | modifications, and in Source or Object form, provided that You 132 | meet the following conditions: 133 | 134 | (a) You must give any other recipients of the Work or 135 | Derivative Works a copy of this License; and 136 | 137 | (b) You must cause any modified files to carry prominent notices 138 | stating that You changed the files; and 139 | 140 | (c) You must retain, in the Source form of any Derivative Works 141 | that You distribute, all copyright, patent, trademark, and 142 | attribution notices from the Source form of the Work, 143 | excluding those notices that do not pertain to any part of 144 | the Derivative Works; and 145 | 146 | (d) If the Work includes a "NOTICE" text file as part of its 147 | distribution, then any Derivative Works that You distribute must 148 | include a readable copy of the attribution notices contained 149 | within such NOTICE file, excluding those notices that do not 150 | pertain to any part of the Derivative Works, in at least one 151 | of the following places: within a NOTICE text file distributed 152 | as part of the Derivative Works; within the Source form or 153 | documentation, if provided along with the Derivative Works; or, 154 | within a display generated by the Derivative Works, if and 155 | wherever such third-party notices normally appear. The contents 156 | of the NOTICE file are for informational purposes only and 157 | do not modify the License. You may add Your own attribution 158 | notices within Derivative Works that You distribute, alongside 159 | or as an addendum to the NOTICE text from the Work, provided 160 | that such additional attribution notices cannot be construed 161 | as modifying the License. 162 | 163 | You may add Your own copyright statement to Your modifications and 164 | may provide additional or different license terms and conditions 165 | for use, reproduction, or distribution of Your modifications, or 166 | for any such Derivative Works as a whole, provided Your use, 167 | reproduction, and distribution of the Work otherwise complies with 168 | the conditions stated in this License. 169 | 170 | 5. Submission of Contributions. Unless You explicitly state otherwise, 171 | any Contribution intentionally submitted for inclusion in the Work 172 | by You to the Licensor shall be under the terms and conditions of 173 | this License, without any additional terms or conditions. 174 | Notwithstanding the above, nothing herein shall supersede or modify 175 | the terms of any separate license agreement you may have executed 176 | with Licensor regarding such Contributions. 177 | 178 | 6. Trademarks. This License does not grant permission to use the trade 179 | names, trademarks, service marks, or product names of the Licensor, 180 | except as required for reasonable and customary use in describing the 181 | origin of the Work and reproducing the content of the NOTICE file. 182 | 183 | 7. Disclaimer of Warranty. Unless required by applicable law or 184 | agreed to in writing, Licensor provides the Work (and each 185 | Contributor provides its Contributions) on an "AS IS" BASIS, 186 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 187 | implied, including, without limitation, any warranties or conditions 188 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 189 | PARTICULAR PURPOSE. You are solely responsible for determining the 190 | appropriateness of using or redistributing the Work and assume any 191 | risks associated with Your exercise of permissions under this License. 192 | 193 | 8. Limitation of Liability. In no event and under no legal theory, 194 | whether in tort (including negligence), contract, or otherwise, 195 | unless required by applicable law (such as deliberate and grossly 196 | negligent acts) or agreed to in writing, shall any Contributor be 197 | liable to You for damages, including any direct, indirect, special, 198 | incidental, or consequential damages of any character arising as a 199 | result of this License or out of the use or inability to use the 200 | Work (including but not limited to damages for loss of goodwill, 201 | work stoppage, computer failure or malfunction, or any and all 202 | other commercial damages or losses), even if such Contributor 203 | has been advised of the possibility of such damages. 204 | 205 | 9. Accepting Warranty or Additional Liability. While redistributing 206 | the Work or Derivative Works thereof, You may choose to offer, 207 | and charge a fee for, acceptance of support, warranty, indemnity, 208 | or other liability obligations and/or rights consistent with this 209 | License. However, in accepting such obligations, You may act only 210 | on Your own behalf and on Your sole responsibility, not on behalf 211 | of any other Contributor, and only if You agree to indemnify, 212 | defend, and hold each Contributor harmless for any liability 213 | incurred by, or claims asserted against, such Contributor by reason 214 | of your accepting any such warranty or additional liability. 215 | 216 | END OF TERMS AND CONDITIONS 217 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # The Ruffle Flash Player emulator on iOS 2 | 3 | Work in progress. 4 | 5 | See [ruffle.rs](https://ruffle.rs/) for a general introduction. 6 | 7 | 8 | ## Design choices 9 | 10 | A normal person might have wrapped the Rust in some `extern "C" fn`s, and then used SwiftUI, or at least Objective-C for the UI shell. I would probably recommend that for most use-cases. 11 | 12 | I'm developing [`objc2`](https://github.com/madsmtm/objc2) though, and I want to improve the user-interface of that, so I decided to be a bit unortodox, and do everything in Rust. 13 | 14 | ## Testing 15 | 16 | Run the core player on Mac Catalyst with: 17 | ``` 18 | # Mac Catalyst uses the macOS SDK 19 | export COREAUDIO_SDK_PATH=/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk 20 | cargo bundle --target=aarch64-apple-ios-macabi --bin run_swf && ./target/aarch64-apple-ios-macabi/debug/bundle/ios/Ruffle.app/run_swf 21 | ``` 22 | 23 | The proper application can also be launched from the iOS Simulator by opening [the Xcode project](./ruffle-ios.xcodeproj). 24 | 25 | To open an SWF/Ruffle bundle, simply drag and drop the `.swf`/`.ruf` onto the simulator window, see: 26 | https://developer.apple.com/documentation/xcode/sharing-data-with-simulator 27 | 28 | NOTE: The simulator has to have read permissions for the file, you may have to place it outside the `Desktop`/`Documents`/... folders, which require special permission setup. 29 | 30 | ## UI 31 | 32 | Similar to https://getutm.app/, we should have: 33 | - A library of "installed" SWFs/bundles/saved links, editable. 34 | - When selecting an SWF, the navigation bar at the top shows various options 35 | - Opening keyboard (maybe?) 36 | - Context menu "play, rewind, forward, back, etc."? 37 | - Allow changing between scale 38 | - Back button to go back to library 39 | - "Add" and "edit" are two different flows, and should show two different UIs 40 | - "Add" doesn't have to show all the extra settings; it is only about getting the file. The user can edit it later. 41 | 42 | ## Library item settings 43 | 44 | Settings are stored per Ruffle Bundle. 45 | 46 | - `PlayerOptions` 47 | - https://github.com/ruffle-rs/ruffle/blob/master/frontend-utils/src/bundle/README.md#player 48 | - Inputs: 49 | - Configurable 50 | - Swipe for arrow keys? 51 | - https://openemu.org/ does it pretty well, equivalent for iOS? 52 | - Custom name? 53 | - Custom image? 54 | 55 | 56 | ## Storage 57 | 58 | We do not store Ruffle Bundles / SWFs, the user is responsible for doing that themselves in the Files app. We only store "bookmarks" to these, to allow easily re-opening from within the app, and to store user data. 59 | 60 | This can be synced to iCloud, though the user may have to re-select the referenced Ruffle Bundle (in case it was stored locally, and not in iCloud). 61 | 62 | Goal: Be backwards and forwards compatible with new versions of the Ruffle app. 63 | - Upheld for [Ruffle Bundles](https://discord.com/channels/610531541889581066/1225519553916829736/1232031955751665777). 64 | - Should also be fine for user settings. 65 | 66 | See [src/storage.rs] for implementation. 67 | 68 | 69 | ## Terminology 70 | 71 | What do we call an SWF / a Ruffle Bundle? "Game"? "Movie"? "SWF"? "Flash Animation"? 72 | 73 | Internally: "movie". 74 | 75 | 76 | ## Plan 77 | 78 | 1. Get the Ruffle UI running in a `UIView` 79 | 2. Wire up some way to start it using an SWF on the local device 80 | 81 | 82 | ## TODO 83 | 84 | - Set `idleTimerDisabled` at the appropriate time 85 | - Use white for labels, orange for buttons 86 | - Add settings button in library item 87 | - Add quicklook thumbnail generator app extension 88 | - Figure out what `UIDocument` actually does? 89 | - Ensure that CoreData stores a bookmark of the NSURL, and if not, do that ourselves. 90 | - https://developer.apple.com/documentation/foundation/nsurl/1417795-bookmarkdatawithoptions?language=objc 91 | - https://developer.apple.com/documentation/foundation/nsurl/1408532-writebookmarkdata?language=objc 92 | - Sync 93 | - https://developer.apple.com/documentation/xcode/configuring-icloud-services?language=objc 94 | - https://developer.apple.com/documentation/foundation/optimizing_your_app_s_data_for_icloud_backup?language=objc 95 | - https://developer.apple.com/library/archive/documentation/General/Conceptual/iCloudDesignGuide/Chapters/DesigningForDocumentsIniCloud.html#//apple_ref/doc/uid/TP40012094-CH2 96 | - https://developer.apple.com/documentation/uikit/synchronizing-documents-in-the-icloud-environment?language=objc 97 | 98 | ## Choices 99 | 100 | - Intentionally use `public.app-category.games` to get better performance ("Game Mode" on macOS). 101 | - This is not necessarily the correct choice for Ruffle, but it's the closest. 102 | - It doesn't make sense to have root settings like in the desktop version 103 | - No tab bar, not really desired, since we generally want the SWF's UI to fill most of the screen 104 | - Though if we decide to add an easy way to download from "trusted" sources, we could add a tab bar for that 105 | - A navigation bar is useful though 106 | - To display some settings for the current swf 107 | - To go back to library 108 | - Hide when entering full screen? 109 | -------------------------------------------------------------------------------- /Ruffle.xcdatamodeld/Ruffle.xcdatamodel/contents: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /build-in-xcode.sh: -------------------------------------------------------------------------------- 1 | # Modified from https://gitlab.com/kornelski/cargo-xcode/-/blob/9b1679b950d16f42eb14fb8446ae1a80e2c867d2/src/xcodebuild.sh 2 | 3 | set -euo pipefail; 4 | export PATH="$HOME/.cargo/bin:$PATH:/usr/local/bin:/opt/homebrew/bin"; 5 | # don't use ios/watchos linker for build scripts and proc macros 6 | export CARGO_TARGET_AARCH64_APPLE_DARWIN_LINKER=/usr/bin/ld 7 | export CARGO_TARGET_X86_64_APPLE_DARWIN_LINKER=/usr/bin/ld 8 | export NO_COLOR=1 9 | OTHER_INPUT_FILE_FLAGS="" 10 | 11 | # Make Cargo output cache files in Xcode's directories 12 | export CARGO_TARGET_DIR=$DERIVED_FILE_DIR 13 | 14 | case "$PLATFORM_NAME" in 15 | "macosx") 16 | CARGO_XCODE_TARGET_OS=darwin 17 | if [ "${IS_MACCATALYST-NO}" = YES ]; then 18 | CARGO_XCODE_TARGET_OS=ios-macabi 19 | fi 20 | ;; 21 | "iphoneos") CARGO_XCODE_TARGET_OS=ios ;; 22 | "iphonesimulator") CARGO_XCODE_TARGET_OS=ios-sim ;; 23 | "appletvos" | "appletvsimulator") CARGO_XCODE_TARGET_OS=tvos ;; 24 | "watchos") CARGO_XCODE_TARGET_OS=watchos ;; 25 | "watchsimulator") CARGO_XCODE_TARGET_OS=watchos-sim ;; 26 | "xros") CARGO_XCODE_TARGET_OS=visionos ;; 27 | "xrsimulator") CARGO_XCODE_TARGET_OS=visionos-sim ;; 28 | *) 29 | CARGO_XCODE_TARGET_OS="$PLATFORM_NAME" 30 | echo >&2 "warning: cargo-xcode needs to be updated to handle $PLATFORM_NAME" 31 | ;; 32 | esac 33 | 34 | case "$CONFIGURATION" in 35 | "Debug") 36 | CARGO_XCODE_BUILD_PROFILE=debug 37 | ;; 38 | "Release") 39 | CARGO_XCODE_BUILD_PROFILE=release 40 | OTHER_INPUT_FILE_FLAGS+=" --release" 41 | ;; 42 | *) 43 | echo >&2 "warning: cargo-xcode needs to be updated to handle CONFIGURATION=$CONFIGURATION" 44 | ;; 45 | esac 46 | 47 | CARGO_XCODE_TARGET_TRIPLES="" 48 | CARGO_XCODE_TARGET_FLAGS="" 49 | LIPO_ARGS="" 50 | for arch in $ARCHS; do 51 | if [[ "$arch" == "arm64" ]]; then arch=aarch64; fi 52 | if [[ "$arch" == "i386" && "$CARGO_XCODE_TARGET_OS" != "ios" ]]; then arch=i686; fi 53 | triple="${arch}-apple-$CARGO_XCODE_TARGET_OS" 54 | CARGO_XCODE_TARGET_TRIPLES+=" $triple" 55 | CARGO_XCODE_TARGET_FLAGS+=" --target=$triple" 56 | LIPO_ARGS+="$CARGO_TARGET_DIR/$triple/$CARGO_XCODE_BUILD_PROFILE/$EXECUTABLE_NAME 57 | " 58 | done 59 | 60 | echo >&2 "Cargo $CONFIGURATION $ACTION for $PLATFORM_NAME $ARCHS =$CARGO_XCODE_TARGET_TRIPLES; using ${SDK_NAMES:-}. \$PATH is:" 61 | tr >&2 : '\n' <<<"$PATH" 62 | 63 | if [ "$ACTION" = clean ]; then 64 | cargo clean --verbose $CARGO_XCODE_TARGET_FLAGS $OTHER_INPUT_FILE_FLAGS; 65 | rm -f "$SCRIPT_OUTPUT_FILE_0" 66 | exit 0 67 | fi 68 | 69 | { cargo build --features="${CARGO_XCODE_FEATURES:-}" $CARGO_XCODE_TARGET_FLAGS $OTHER_INPUT_FILE_FLAGS --verbose --message-format=short 2>&1 | sed -E 's/^([^ :]+:[0-9]+:[0-9]+: error)/\1: /' >&2; } || { echo >&2 "error: cargo-xcode project build failed; $CARGO_XCODE_TARGET_TRIPLES"; exit 1; } 70 | 71 | tr '\n' '\0' <<<"$LIPO_ARGS" | xargs -0 lipo -create -output "$SCRIPT_OUTPUT_FILE_0" 72 | 73 | if [ ${LD_DYLIB_INSTALL_NAME:+1} ]; then 74 | install_name_tool -id "$LD_DYLIB_INSTALL_NAME" "$SCRIPT_OUTPUT_FILE_0" 75 | fi 76 | 77 | DEP_FILE_DST="$DERIVED_FILE_DIR/${ARCHS}-${EXECUTABLE_NAME}.d" 78 | echo "" > "$DEP_FILE_DST" 79 | for triple in $CARGO_XCODE_TARGET_TRIPLES; do 80 | BUILT_SRC="$CARGO_TARGET_DIR/$triple/$CARGO_XCODE_BUILD_PROFILE/$EXECUTABLE_NAME" 81 | 82 | # cargo generates a dep file, but for its own path, so append our rename to it 83 | DEP_FILE_SRC="$CARGO_TARGET_DIR/$triple/$CARGO_XCODE_BUILD_PROFILE/$EXECUTABLE_NAME.d" 84 | if [ -f "$DEP_FILE_SRC" ]; then 85 | cat "$DEP_FILE_SRC" >> "$DEP_FILE_DST" 86 | fi 87 | echo >> "$DEP_FILE_DST" "${SCRIPT_OUTPUT_FILE_0/ /\\ /}: ${BUILT_SRC/ /\\ /}" 88 | done 89 | cat "$DEP_FILE_DST" 90 | 91 | echo "success: $ACTION of $SCRIPT_OUTPUT_FILE_0 for $CARGO_XCODE_TARGET_TRIPLES" 92 | -------------------------------------------------------------------------------- /file-icons/ATTRIBUTION.md: -------------------------------------------------------------------------------- 1 | # File Icon Attribution 2 | 3 | The `document_swf` icon is taken directly from Flash Player, produced by Adobe, and of unknown license. 4 | 5 | The `document_ruf` icon is taken from [here](https://discord.com/channels/610531541889581066/1225519553916829736/1225933184630521926). 6 | 7 | Conversion between the different formats is done with the `icnsutil` Python package. 8 | -------------------------------------------------------------------------------- /file-icons/document_ruf.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/madsmtm/ruffle-ios/5cc5a5b0eea87b03531b8ac1217160915d635be4/file-icons/document_ruf.icns -------------------------------------------------------------------------------- /file-icons/document_swf.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/madsmtm/ruffle-ios/5cc5a5b0eea87b03531b8ac1217160915d635be4/file-icons/document_swf.icns -------------------------------------------------------------------------------- /file-icons/file-icons.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /file-icons/file-icons.xcassets/document_ruf.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "document_ruf.svg", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /file-icons/file-icons.xcassets/document_ruf.imageset/document_ruf.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /file-icons/file-icons.xcassets/document_swf.imageset/256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/madsmtm/ruffle-ios/5cc5a5b0eea87b03531b8ac1217160915d635be4/file-icons/file-icons.xcassets/document_swf.imageset/256x256.png -------------------------------------------------------------------------------- /file-icons/file-icons.xcassets/document_swf.imageset/512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/madsmtm/ruffle-ios/5cc5a5b0eea87b03531b8ac1217160915d635be4/file-icons/file-icons.xcassets/document_swf.imageset/512x512.png -------------------------------------------------------------------------------- /file-icons/file-icons.xcassets/document_swf.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "256x256.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "512x512.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "author" : "xcode", 20 | "version" : 1 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /ruffle-ios.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.bookmarks.document-scope 8 | 9 | com.apple.security.files.user-selected.read-write 10 | 11 | com.apple.security.network.client 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /ruffle-ios.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 56; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | EC3BA0B72C93A6CA0072939D /* Base in Resources */ = {isa = PBXBuildFile; fileRef = EC3BA0B62C93A6CA0072939D /* Base */; }; 11 | EC3BA0BC2C93A6CB0072939D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = EC3BA0BB2C93A6CB0072939D /* Assets.xcassets */; }; 12 | EC3BA0BF2C93A6CB0072939D /* Base in Resources */ = {isa = PBXBuildFile; fileRef = EC3BA0BE2C93A6CB0072939D /* Base */; }; 13 | EC42DF8D2C98FADB009998B2 /* document_swf.icns in Resources */ = {isa = PBXBuildFile; fileRef = EC42DF892C98FADB009998B2 /* document_swf.icns */; }; 14 | EC42DF8E2C98FADB009998B2 /* file-icons.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = EC42DF8A2C98FADB009998B2 /* file-icons.xcassets */; }; 15 | EC42DF902C98FE4A009998B2 /* document_ruf.icns in Resources */ = {isa = PBXBuildFile; fileRef = EC42DF8F2C98FE4A009998B2 /* document_ruf.icns */; }; 16 | EC5247022D4B11C30040F6D0 /* Ruffle.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = EC5246FC2D4B03C00040F6D0 /* Ruffle.xcdatamodeld */; }; 17 | /* End PBXBuildFile section */ 18 | 19 | /* Begin PBXFileReference section */ 20 | EC3BA0A92C93A6CA0072939D /* ruffle-ios.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "ruffle-ios.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 21 | EC3BA0B62C93A6CA0072939D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 22 | EC3BA0BB2C93A6CB0072939D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 23 | EC3BA0BE2C93A6CB0072939D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 24 | EC3BA0C02C93A6CB0072939D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 25 | EC3BA0C82C93A7400072939D /* ruffle-ios.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "ruffle-ios.entitlements"; sourceTree = ""; }; 26 | EC3BA0C92C93A87E0072939D /* src */ = {isa = PBXFileReference; lastKnownFileType = folder; path = src; sourceTree = ""; }; 27 | EC3BA0CB2C93A8890072939D /* Cargo.lock */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = Cargo.lock; sourceTree = ""; }; 28 | EC3BA0CC2C93A8890072939D /* Cargo.toml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = Cargo.toml; sourceTree = ""; }; 29 | EC42DF882C98FADB009998B2 /* ATTRIBUTION.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = ATTRIBUTION.md; sourceTree = ""; }; 30 | EC42DF892C98FADB009998B2 /* document_swf.icns */ = {isa = PBXFileReference; lastKnownFileType = image.icns; path = document_swf.icns; sourceTree = ""; }; 31 | EC42DF8A2C98FADB009998B2 /* file-icons.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "file-icons.xcassets"; sourceTree = ""; }; 32 | EC42DF8F2C98FE4A009998B2 /* document_ruf.icns */ = {isa = PBXFileReference; lastKnownFileType = image.icns; path = document_ruf.icns; sourceTree = ""; }; 33 | EC5246FD2D4B03C00040F6D0 /* Ruffle.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Ruffle.xcdatamodel; sourceTree = ""; }; 34 | ECEA01DB2C948F9A00C9D3D6 /* build-in-xcode.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "build-in-xcode.sh"; sourceTree = ""; }; 35 | ECEA01DF2C9495C700C9D3D6 /* .cargo */ = {isa = PBXFileReference; lastKnownFileType = folder; path = .cargo; sourceTree = ""; }; 36 | /* End PBXFileReference section */ 37 | 38 | /* Begin PBXGroup section */ 39 | EC3BA0A02C93A6CA0072939D = { 40 | isa = PBXGroup; 41 | children = ( 42 | EC42DF8B2C98FADB009998B2 /* file-icons */, 43 | EC3BA0C02C93A6CB0072939D /* Info.plist */, 44 | EC3BA0C82C93A7400072939D /* ruffle-ios.entitlements */, 45 | EC3BA0BD2C93A6CB0072939D /* LaunchScreen.storyboard */, 46 | EC3BA0B52C93A6CA0072939D /* Main.storyboard */, 47 | EC3BA0BB2C93A6CB0072939D /* Assets.xcassets */, 48 | EC5246FC2D4B03C00040F6D0 /* Ruffle.xcdatamodeld */, 49 | ECEA01DB2C948F9A00C9D3D6 /* build-in-xcode.sh */, 50 | EC3BA0CC2C93A8890072939D /* Cargo.toml */, 51 | EC3BA0CB2C93A8890072939D /* Cargo.lock */, 52 | ECEA01DF2C9495C700C9D3D6 /* .cargo */, 53 | EC3BA0C92C93A87E0072939D /* src */, 54 | EC3BA0AA2C93A6CA0072939D /* Products */, 55 | ); 56 | sourceTree = ""; 57 | }; 58 | EC3BA0AA2C93A6CA0072939D /* Products */ = { 59 | isa = PBXGroup; 60 | children = ( 61 | EC3BA0A92C93A6CA0072939D /* ruffle-ios.app */, 62 | ); 63 | name = Products; 64 | sourceTree = ""; 65 | }; 66 | EC42DF8B2C98FADB009998B2 /* file-icons */ = { 67 | isa = PBXGroup; 68 | children = ( 69 | EC42DF882C98FADB009998B2 /* ATTRIBUTION.md */, 70 | EC42DF8A2C98FADB009998B2 /* file-icons.xcassets */, 71 | EC42DF8F2C98FE4A009998B2 /* document_ruf.icns */, 72 | EC42DF892C98FADB009998B2 /* document_swf.icns */, 73 | ); 74 | path = "file-icons"; 75 | sourceTree = ""; 76 | }; 77 | /* End PBXGroup section */ 78 | 79 | /* Begin PBXNativeTarget section */ 80 | EC3BA0A82C93A6CA0072939D /* ruffle-ios */ = { 81 | isa = PBXNativeTarget; 82 | buildConfigurationList = EC3BA0C52C93A6CB0072939D /* Build configuration list for PBXNativeTarget "ruffle-ios" */; 83 | buildPhases = ( 84 | EC5247012D4B11BE0040F6D0 /* Sources */, 85 | ECEA01DC2C948FAD00C9D3D6 /* ShellScript */, 86 | ECEA01DE2C94929900C9D3D6 /* ShellScript */, 87 | EC3BA0A72C93A6CA0072939D /* Resources */, 88 | ); 89 | buildRules = ( 90 | ); 91 | dependencies = ( 92 | ); 93 | name = "ruffle-ios"; 94 | productName = "ruffle-ios"; 95 | productReference = EC3BA0A92C93A6CA0072939D /* ruffle-ios.app */; 96 | productType = "com.apple.product-type.application"; 97 | }; 98 | /* End PBXNativeTarget section */ 99 | 100 | /* Begin PBXProject section */ 101 | EC3BA0A12C93A6CA0072939D /* Project object */ = { 102 | isa = PBXProject; 103 | attributes = { 104 | BuildIndependentTargetsInParallel = 1; 105 | LastUpgradeCheck = 1540; 106 | TargetAttributes = { 107 | EC3BA0A82C93A6CA0072939D = { 108 | CreatedOnToolsVersion = 15.4; 109 | }; 110 | }; 111 | }; 112 | buildConfigurationList = EC3BA0A42C93A6CA0072939D /* Build configuration list for PBXProject "ruffle-ios" */; 113 | compatibilityVersion = "Xcode 14.0"; 114 | developmentRegion = en; 115 | hasScannedForEncodings = 0; 116 | knownRegions = ( 117 | en, 118 | Base, 119 | ); 120 | mainGroup = EC3BA0A02C93A6CA0072939D; 121 | productRefGroup = EC3BA0AA2C93A6CA0072939D /* Products */; 122 | projectDirPath = ""; 123 | projectRoot = ""; 124 | targets = ( 125 | EC3BA0A82C93A6CA0072939D /* ruffle-ios */, 126 | ); 127 | }; 128 | /* End PBXProject section */ 129 | 130 | /* Begin PBXResourcesBuildPhase section */ 131 | EC3BA0A72C93A6CA0072939D /* Resources */ = { 132 | isa = PBXResourcesBuildPhase; 133 | buildActionMask = 2147483647; 134 | files = ( 135 | EC3BA0BC2C93A6CB0072939D /* Assets.xcassets in Resources */, 136 | EC3BA0BF2C93A6CB0072939D /* Base in Resources */, 137 | EC3BA0B72C93A6CA0072939D /* Base in Resources */, 138 | EC42DF902C98FE4A009998B2 /* document_ruf.icns in Resources */, 139 | EC42DF8E2C98FADB009998B2 /* file-icons.xcassets in Resources */, 140 | EC42DF8D2C98FADB009998B2 /* document_swf.icns in Resources */, 141 | ); 142 | runOnlyForDeploymentPostprocessing = 0; 143 | }; 144 | /* End PBXResourcesBuildPhase section */ 145 | 146 | /* Begin PBXShellScriptBuildPhase section */ 147 | ECEA01DC2C948FAD00C9D3D6 /* ShellScript */ = { 148 | isa = PBXShellScriptBuildPhase; 149 | buildActionMask = 2147483647; 150 | dependencyFile = "$(DERIVED_FILE_DIR)/$(ARCHS)-$(EXECUTABLE_NAME).d"; 151 | files = ( 152 | ); 153 | inputFileListPaths = ( 154 | ); 155 | inputPaths = ( 156 | "$(SRCROOT)/Cargo.toml", 157 | "$(SRCROOT)/build-in-xcode.sh", 158 | "$(SRCROOT)/.cargo/config.toml", 159 | ); 160 | outputFileListPaths = ( 161 | ); 162 | outputPaths = ( 163 | "$(OBJECT_FILE_DIR)/$(EXECUTABLE_NAME)", 164 | ); 165 | runOnlyForDeploymentPostprocessing = 0; 166 | shellPath = /bin/sh; 167 | shellScript = "./build-in-xcode.sh\n"; 168 | }; 169 | ECEA01DE2C94929900C9D3D6 /* ShellScript */ = { 170 | isa = PBXShellScriptBuildPhase; 171 | buildActionMask = 2147483647; 172 | files = ( 173 | ); 174 | inputFileListPaths = ( 175 | ); 176 | inputPaths = ( 177 | "$(OBJECT_FILE_DIR)/$(EXECUTABLE_NAME)", 178 | ); 179 | outputFileListPaths = ( 180 | ); 181 | outputPaths = ( 182 | "$(TARGET_BUILD_DIR)/$(EXECUTABLE_PATH)", 183 | ); 184 | runOnlyForDeploymentPostprocessing = 0; 185 | shellPath = /bin/sh; 186 | shellScript = "# Type a script or drag a script file from your workspace to insert its path.\ncp $OBJECT_FILE_DIR/$EXECUTABLE_NAME $TARGET_BUILD_DIR/$EXECUTABLE_PATH\n"; 187 | }; 188 | /* End PBXShellScriptBuildPhase section */ 189 | 190 | /* Begin PBXSourcesBuildPhase section */ 191 | EC5247012D4B11BE0040F6D0 /* Sources */ = { 192 | isa = PBXSourcesBuildPhase; 193 | buildActionMask = 2147483647; 194 | files = ( 195 | EC5247022D4B11C30040F6D0 /* Ruffle.xcdatamodeld in Sources */, 196 | ); 197 | runOnlyForDeploymentPostprocessing = 0; 198 | }; 199 | /* End PBXSourcesBuildPhase section */ 200 | 201 | /* Begin PBXVariantGroup section */ 202 | EC3BA0B52C93A6CA0072939D /* Main.storyboard */ = { 203 | isa = PBXVariantGroup; 204 | children = ( 205 | EC3BA0B62C93A6CA0072939D /* Base */, 206 | ); 207 | name = Main.storyboard; 208 | sourceTree = ""; 209 | }; 210 | EC3BA0BD2C93A6CB0072939D /* LaunchScreen.storyboard */ = { 211 | isa = PBXVariantGroup; 212 | children = ( 213 | EC3BA0BE2C93A6CB0072939D /* Base */, 214 | ); 215 | name = LaunchScreen.storyboard; 216 | sourceTree = ""; 217 | }; 218 | /* End PBXVariantGroup section */ 219 | 220 | /* Begin XCBuildConfiguration section */ 221 | EC3BA0C32C93A6CB0072939D /* Debug */ = { 222 | isa = XCBuildConfiguration; 223 | buildSettings = { 224 | ALWAYS_SEARCH_USER_PATHS = NO; 225 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 226 | CLANG_ANALYZER_NONNULL = YES; 227 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 228 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 229 | CLANG_ENABLE_MODULES = YES; 230 | CLANG_ENABLE_OBJC_ARC = YES; 231 | CLANG_ENABLE_OBJC_WEAK = YES; 232 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 233 | CLANG_WARN_BOOL_CONVERSION = YES; 234 | CLANG_WARN_COMMA = YES; 235 | CLANG_WARN_CONSTANT_CONVERSION = YES; 236 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 237 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 238 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 239 | CLANG_WARN_EMPTY_BODY = YES; 240 | CLANG_WARN_ENUM_CONVERSION = YES; 241 | CLANG_WARN_INFINITE_RECURSION = YES; 242 | CLANG_WARN_INT_CONVERSION = YES; 243 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 244 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 245 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 246 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 247 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 248 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 249 | CLANG_WARN_STRICT_PROTOTYPES = YES; 250 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 251 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 252 | CLANG_WARN_UNREACHABLE_CODE = YES; 253 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 254 | COPY_PHASE_STRIP = NO; 255 | DEBUG_INFORMATION_FORMAT = dwarf; 256 | ENABLE_STRICT_OBJC_MSGSEND = YES; 257 | ENABLE_TESTABILITY = YES; 258 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 259 | GCC_C_LANGUAGE_STANDARD = gnu17; 260 | GCC_DYNAMIC_NO_PIC = NO; 261 | GCC_NO_COMMON_BLOCKS = YES; 262 | GCC_OPTIMIZATION_LEVEL = 0; 263 | GCC_PREPROCESSOR_DEFINITIONS = ( 264 | "DEBUG=1", 265 | "$(inherited)", 266 | ); 267 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 268 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 269 | GCC_WARN_UNDECLARED_SELECTOR = YES; 270 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 271 | GCC_WARN_UNUSED_FUNCTION = YES; 272 | GCC_WARN_UNUSED_VARIABLE = YES; 273 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 274 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 275 | MTL_FAST_MATH = YES; 276 | ONLY_ACTIVE_ARCH = YES; 277 | SDKROOT = iphoneos; 278 | }; 279 | name = Debug; 280 | }; 281 | EC3BA0C42C93A6CB0072939D /* Release */ = { 282 | isa = XCBuildConfiguration; 283 | buildSettings = { 284 | ALWAYS_SEARCH_USER_PATHS = NO; 285 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 286 | CLANG_ANALYZER_NONNULL = YES; 287 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 288 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 289 | CLANG_ENABLE_MODULES = YES; 290 | CLANG_ENABLE_OBJC_ARC = YES; 291 | CLANG_ENABLE_OBJC_WEAK = YES; 292 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 293 | CLANG_WARN_BOOL_CONVERSION = YES; 294 | CLANG_WARN_COMMA = YES; 295 | CLANG_WARN_CONSTANT_CONVERSION = YES; 296 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 297 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 298 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 299 | CLANG_WARN_EMPTY_BODY = YES; 300 | CLANG_WARN_ENUM_CONVERSION = YES; 301 | CLANG_WARN_INFINITE_RECURSION = YES; 302 | CLANG_WARN_INT_CONVERSION = YES; 303 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 304 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 305 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 306 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 307 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 308 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 309 | CLANG_WARN_STRICT_PROTOTYPES = YES; 310 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 311 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 312 | CLANG_WARN_UNREACHABLE_CODE = YES; 313 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 314 | COPY_PHASE_STRIP = NO; 315 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 316 | ENABLE_NS_ASSERTIONS = NO; 317 | ENABLE_STRICT_OBJC_MSGSEND = YES; 318 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 319 | GCC_C_LANGUAGE_STANDARD = gnu17; 320 | GCC_NO_COMMON_BLOCKS = YES; 321 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 322 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 323 | GCC_WARN_UNDECLARED_SELECTOR = YES; 324 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 325 | GCC_WARN_UNUSED_FUNCTION = YES; 326 | GCC_WARN_UNUSED_VARIABLE = YES; 327 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 328 | MTL_ENABLE_DEBUG_INFO = NO; 329 | MTL_FAST_MATH = YES; 330 | SDKROOT = iphoneos; 331 | VALIDATE_PRODUCT = YES; 332 | }; 333 | name = Release; 334 | }; 335 | EC3BA0C62C93A6CB0072939D /* Debug */ = { 336 | isa = XCBuildConfiguration; 337 | buildSettings = { 338 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 339 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = "ruffle-orange"; 340 | CODE_SIGN_ENTITLEMENTS = "ruffle-ios.entitlements"; 341 | "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; 342 | CODE_SIGN_STYLE = Automatic; 343 | CURRENT_PROJECT_VERSION = 1; 344 | DEVELOPMENT_TEAM = ""; 345 | ENABLE_USER_SCRIPT_SANDBOXING = NO; 346 | GENERATE_INFOPLIST_FILE = YES; 347 | INFOPLIST_FILE = Info.plist; 348 | INFOPLIST_KEY_CFBundleDisplayName = Ruffle; 349 | INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.games"; 350 | INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES; 351 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 352 | INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; 353 | INFOPLIST_KEY_UIMainStoryboardFile = Main; 354 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 355 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 356 | IPHONEOS_DEPLOYMENT_TARGET = 13.0; 357 | LD_RUNPATH_SEARCH_PATHS = ( 358 | "$(inherited)", 359 | "@executable_path/Frameworks", 360 | ); 361 | MARKETING_VERSION = 0.1; 362 | PRODUCT_BUNDLE_IDENTIFIER = "rs.ruffle.ruffle-ios"; 363 | PRODUCT_NAME = "$(TARGET_NAME)"; 364 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator xros xrsimulator"; 365 | SUPPORTS_MACCATALYST = YES; 366 | SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; 367 | SWIFT_EMIT_LOC_STRINGS = YES; 368 | TARGETED_DEVICE_FAMILY = "1,2,6,7"; 369 | XROS_DEPLOYMENT_TARGET = 1.0; 370 | }; 371 | name = Debug; 372 | }; 373 | EC3BA0C72C93A6CB0072939D /* Release */ = { 374 | isa = XCBuildConfiguration; 375 | buildSettings = { 376 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 377 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = "ruffle-orange"; 378 | CODE_SIGN_ENTITLEMENTS = "ruffle-ios.entitlements"; 379 | "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; 380 | CODE_SIGN_STYLE = Automatic; 381 | CURRENT_PROJECT_VERSION = 1; 382 | DEVELOPMENT_TEAM = ""; 383 | ENABLE_USER_SCRIPT_SANDBOXING = NO; 384 | GENERATE_INFOPLIST_FILE = YES; 385 | INFOPLIST_FILE = Info.plist; 386 | INFOPLIST_KEY_CFBundleDisplayName = Ruffle; 387 | INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.games"; 388 | INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES; 389 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 390 | INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; 391 | INFOPLIST_KEY_UIMainStoryboardFile = Main; 392 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 393 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 394 | IPHONEOS_DEPLOYMENT_TARGET = 13.0; 395 | LD_RUNPATH_SEARCH_PATHS = ( 396 | "$(inherited)", 397 | "@executable_path/Frameworks", 398 | ); 399 | MARKETING_VERSION = 0.1; 400 | PRODUCT_BUNDLE_IDENTIFIER = "rs.ruffle.ruffle-ios"; 401 | PRODUCT_NAME = "$(TARGET_NAME)"; 402 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator xros xrsimulator"; 403 | SUPPORTS_MACCATALYST = YES; 404 | SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; 405 | SWIFT_EMIT_LOC_STRINGS = YES; 406 | TARGETED_DEVICE_FAMILY = "1,2,6,7"; 407 | XROS_DEPLOYMENT_TARGET = 1.0; 408 | }; 409 | name = Release; 410 | }; 411 | /* End XCBuildConfiguration section */ 412 | 413 | /* Begin XCConfigurationList section */ 414 | EC3BA0A42C93A6CA0072939D /* Build configuration list for PBXProject "ruffle-ios" */ = { 415 | isa = XCConfigurationList; 416 | buildConfigurations = ( 417 | EC3BA0C32C93A6CB0072939D /* Debug */, 418 | EC3BA0C42C93A6CB0072939D /* Release */, 419 | ); 420 | defaultConfigurationIsVisible = 0; 421 | defaultConfigurationName = Release; 422 | }; 423 | EC3BA0C52C93A6CB0072939D /* Build configuration list for PBXNativeTarget "ruffle-ios" */ = { 424 | isa = XCConfigurationList; 425 | buildConfigurations = ( 426 | EC3BA0C62C93A6CB0072939D /* Debug */, 427 | EC3BA0C72C93A6CB0072939D /* Release */, 428 | ); 429 | defaultConfigurationIsVisible = 0; 430 | defaultConfigurationName = Release; 431 | }; 432 | /* End XCConfigurationList section */ 433 | 434 | /* Begin XCVersionGroup section */ 435 | EC5246FC2D4B03C00040F6D0 /* Ruffle.xcdatamodeld */ = { 436 | isa = XCVersionGroup; 437 | children = ( 438 | EC5246FD2D4B03C00040F6D0 /* Ruffle.xcdatamodel */, 439 | ); 440 | currentVersion = EC5246FD2D4B03C00040F6D0 /* Ruffle.xcdatamodel */; 441 | path = Ruffle.xcdatamodeld; 442 | sourceTree = ""; 443 | versionGroupType = wrapper.xcdatamodel; 444 | }; 445 | /* End XCVersionGroup section */ 446 | }; 447 | rootObject = EC3BA0A12C93A6CA0072939D /* Project object */; 448 | } 449 | -------------------------------------------------------------------------------- /ruffle-ios.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /ruffle-ios.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | # Until 1.83 comes out 3 | channel = "nightly" 4 | components = ["rust-src"] 5 | targets = [ 6 | "aarch64-apple-ios", 7 | "aarch64-apple-ios-sim", 8 | "aarch64-apple-ios-macabi", 9 | ] 10 | -------------------------------------------------------------------------------- /src/add_controller.rs: -------------------------------------------------------------------------------- 1 | use objc2::rc::{Allocated, Retained}; 2 | use objc2::{define_class, msg_send}; 3 | use objc2_foundation::{NSBundle, NSCoder, NSObjectProtocol, NSString}; 4 | use objc2_ui_kit::UIViewController; 5 | 6 | #[derive(Default, Debug)] 7 | pub struct Ivars {} 8 | 9 | define_class!( 10 | #[unsafe(super(UIViewController))] 11 | #[name = "AddController"] 12 | #[ivars = Ivars] 13 | #[derive(Debug)] 14 | pub struct AddController; 15 | 16 | unsafe impl NSObjectProtocol for AddController {} 17 | 18 | /// UIViewController. 19 | impl AddController { 20 | #[unsafe(method_id(initWithNibName:bundle:))] 21 | fn _init_with_nib_name_bundle( 22 | this: Allocated, 23 | nib_name_or_nil: Option<&NSString>, 24 | nib_bundle_or_nil: Option<&NSBundle>, 25 | ) -> Retained { 26 | tracing::info!("add init"); 27 | let this = this.set_ivars(Ivars::default()); 28 | unsafe { 29 | msg_send![super(this), initWithNibName: nib_name_or_nil, bundle: nib_bundle_or_nil] 30 | } 31 | } 32 | 33 | #[unsafe(method_id(initWithCoder:))] 34 | fn _init_with_coder(this: Allocated, coder: &NSCoder) -> Option> { 35 | tracing::info!("add init"); 36 | let this = this.set_ivars(Ivars::default()); 37 | unsafe { msg_send![super(this), initWithCoder: coder] } 38 | } 39 | 40 | #[unsafe(method(viewDidLoad))] 41 | fn _view_did_load(&self) { 42 | // Xcode template calls super at the beginning 43 | let _: () = unsafe { msg_send![super(self), viewDidLoad] }; 44 | self.view_did_load(); 45 | } 46 | 47 | #[unsafe(method(viewWillAppear:))] 48 | fn _view_will_appear(&self, animated: bool) { 49 | self.view_will_appear(); 50 | // Docs say to call super 51 | let _: () = unsafe { msg_send![super(self), viewWillAppear: animated] }; 52 | } 53 | 54 | #[unsafe(method(viewDidAppear:))] 55 | fn _view_did_appear(&self, animated: bool) { 56 | self.view_did_appear(); 57 | // Docs say to call super 58 | let _: () = unsafe { msg_send![super(self), viewDidAppear: animated] }; 59 | } 60 | } 61 | 62 | /// Storyboard 63 | /// See storyboard_connections.h 64 | impl AddController { 65 | // #[unsafe(method(setTableView:))] 66 | // fn _set_table_view(&self, table_view: &UITableView) { 67 | // tracing::trace!("edit set table view"); 68 | // self.ivars() 69 | // .table_view 70 | // .set(table_view.retain()) 71 | // .expect("only set table view once"); 72 | // } 73 | } 74 | ); 75 | 76 | impl AddController { 77 | fn view_did_load(&self) { 78 | tracing::info!("edit viewDidLoad"); 79 | } 80 | 81 | fn view_will_appear(&self) { 82 | tracing::info!("edit viewWillAppear:"); 83 | } 84 | 85 | fn view_did_appear(&self) { 86 | tracing::info!("edit viewDidAppear:"); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/app_delegate.rs: -------------------------------------------------------------------------------- 1 | use objc2::rc::{Allocated, Retained}; 2 | use objc2::runtime::AnyObject; 3 | use objc2::{define_class, msg_send, DefinedClass, MainThreadOnly, Message}; 4 | use objc2_foundation::{ 5 | ns_string, MainThreadMarker, NSDictionary, NSObject, NSObjectProtocol, NSSet, 6 | }; 7 | use objc2_ui_kit::{ 8 | UIApplication, UIApplicationDelegate, UIApplicationLaunchOptionsKey, UISceneConfiguration, 9 | UISceneConnectionOptions, UISceneSession, UIWindow, 10 | }; 11 | 12 | use crate::storage; 13 | 14 | pub struct Ivars { 15 | window: std::cell::Cell>>, 16 | } 17 | 18 | define_class!( 19 | #[unsafe(super(NSObject))] 20 | #[name = "AppDelegate"] 21 | #[thread_kind = MainThreadOnly] 22 | #[ivars = Ivars] 23 | pub struct AppDelegate; 24 | 25 | unsafe impl NSObjectProtocol for AppDelegate {} 26 | 27 | /// NSObject. 28 | impl AppDelegate { 29 | // Called by UIKitApplicationMain 30 | #[unsafe(method_id(init))] 31 | fn init(this: Allocated) -> Retained { 32 | let this = this.set_ivars(Ivars { 33 | window: std::cell::Cell::new(None), 34 | }); 35 | unsafe { msg_send![super(this), init] } 36 | } 37 | } 38 | 39 | #[allow(non_snake_case)] 40 | unsafe impl UIApplicationDelegate for AppDelegate { 41 | // NOTE: Probably only called by storyboards? 42 | #[unsafe(method_id(window))] 43 | fn window(&self) -> Option> { 44 | let window = self.ivars().window.take(); 45 | self.ivars().window.set(window.clone()); 46 | window 47 | } 48 | 49 | #[unsafe(method(setWindow:))] 50 | fn setWindow(&self, window: Option<&UIWindow>) { 51 | self.ivars().window.set(window.map(|w| w.retain())); 52 | } 53 | 54 | #[unsafe(method(application:didFinishLaunchingWithOptions:))] 55 | fn didFinishLaunching( 56 | &self, 57 | _application: &UIApplication, 58 | _launch_options: Option<&NSDictionary>, 59 | ) -> bool { 60 | tracing::info!("applicationDidFinishLaunching:"); 61 | storage::setup(); 62 | true 63 | } 64 | 65 | #[unsafe(method(applicationWillEnterForeground:))] 66 | fn applicationWillEnterForeground(&self, _application: &UIApplication) { 67 | tracing::info!("applicationWillEnterForeground:"); 68 | } 69 | 70 | #[unsafe(method(applicationDidBecomeActive:))] 71 | fn applicationDidBecomeActive(&self, _application: &UIApplication) { 72 | tracing::info!("applicationDidBecomeActive:"); 73 | } 74 | 75 | #[unsafe(method(applicationWillResignActive:))] 76 | fn applicationWillResignActive(&self, _application: &UIApplication) { 77 | tracing::info!("applicationWillResignActive:"); 78 | } 79 | 80 | #[unsafe(method(applicationDidEnterBackground:))] 81 | fn applicationDidEnterBackground(&self, _application: &UIApplication) { 82 | tracing::info!("applicationDidEnterBackground:"); 83 | } 84 | 85 | #[unsafe(method_id(application:configurationForConnectingSceneSession:options:))] 86 | fn _application_configuration_for_connecting_scene_session_options( 87 | &self, 88 | _application: &UIApplication, 89 | connecting_scene_session: &UISceneSession, 90 | _options: &UISceneConnectionOptions, 91 | ) -> Retained { 92 | tracing::info!("application:configurationForConnectingSceneSession:options:"); 93 | // Called when a new scene session is being created. 94 | // Use this method to select a configuration to create the new scene with. 95 | let mtm = MainThreadMarker::from(self); 96 | UISceneConfiguration::initWithName_sessionRole( 97 | mtm.alloc(), 98 | Some(ns_string!("Default Configuration")), 99 | &connecting_scene_session.role(), 100 | ) 101 | } 102 | 103 | #[unsafe(method(application:didDiscardSceneSessions:))] 104 | fn _application_did_discard_scene_sessions( 105 | &self, 106 | _application: &UIApplication, 107 | _scene_sessions: &NSSet, 108 | ) { 109 | tracing::info!("application:didDiscardSceneSessions:"); 110 | // Called when the user discards a scene session. 111 | // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. 112 | // Use this method to release any resources that were specific to the discarded scenes, as they will not return. 113 | } 114 | } 115 | ); 116 | -------------------------------------------------------------------------------- /src/bin/run_swf.rs: -------------------------------------------------------------------------------- 1 | //! Run an SWF without setting up navigation, a data model and everything. 2 | use std::cell::OnceCell; 3 | 4 | use objc2::rc::{Allocated, Retained}; 5 | use objc2::{define_class, msg_send, ClassType, DefinedClass as _, MainThreadOnly}; 6 | use objc2_foundation::{MainThreadMarker, NSObject, NSObjectProtocol}; 7 | use objc2_ui_kit::{UIApplication, UIApplicationDelegate, UIScreen, UIWindow}; 8 | 9 | use ruffle_frontend_utils::content::PlayingContent; 10 | use ruffle_frontend_utils::player_options::PlayerOptions; 11 | use ruffle_ios::{init_logging, launch, PlayerController}; 12 | use url::Url; 13 | 14 | #[derive(Debug)] 15 | pub struct Ivars { 16 | window: OnceCell>, 17 | } 18 | 19 | define_class!( 20 | #[unsafe(super(NSObject))] 21 | #[name = "AppDelegate"] 22 | #[thread_kind = MainThreadOnly] 23 | #[ivars = Ivars] 24 | #[derive(Debug)] 25 | pub struct AppDelegate; 26 | 27 | unsafe impl NSObjectProtocol for AppDelegate {} 28 | 29 | /// Called by UIKitApplicationMain. 30 | impl AppDelegate { 31 | #[unsafe(method_id(init))] 32 | fn init(this: Allocated) -> Retained { 33 | let this = this.set_ivars(Ivars { 34 | window: OnceCell::new(), 35 | }); 36 | unsafe { msg_send![super(this), init] } 37 | } 38 | } 39 | 40 | unsafe impl UIApplicationDelegate for AppDelegate { 41 | #[unsafe(method(applicationDidFinishLaunching:))] 42 | fn did_finish_launching(&self, _application: &UIApplication) { 43 | tracing::info!("applicationDidFinishLaunching:"); 44 | self.setup(); 45 | } 46 | } 47 | ); 48 | 49 | impl AppDelegate { 50 | fn setup(&self) { 51 | let movie_path = std::env::args_os().skip(1).next(); 52 | let movie_path = movie_path.expect("must provide a path or URL to an SWF to run"); 53 | let mtm = MainThreadMarker::from(self); 54 | 55 | #[allow(deprecated)] // Unsure how else we should do this when setting up? 56 | let frame = UIScreen::mainScreen(mtm).bounds(); 57 | 58 | #[allow(deprecated)] 59 | let window = UIWindow::initWithFrame(mtm.alloc(), frame); 60 | 61 | let movie_path = std::path::absolute(movie_path).unwrap(); 62 | let content = PlayingContent::DirectFile(Url::from_file_path(movie_path).unwrap()); 63 | 64 | let view_controller = PlayerController::new(mtm, content, PlayerOptions::default()); 65 | window.setRootViewController(Some(&view_controller)); 66 | 67 | window.makeKeyAndVisible(); 68 | 69 | self.ivars() 70 | .window 71 | .set(window) 72 | .expect("can only initialize once"); 73 | } 74 | } 75 | 76 | #[tokio::main] 77 | async fn main() { 78 | init_logging(); 79 | launch(None, Some(AppDelegate::class())); 80 | } 81 | -------------------------------------------------------------------------------- /src/edit_controller.rs: -------------------------------------------------------------------------------- 1 | use std::cell::OnceCell; 2 | use std::error::Error; 3 | use std::time::Duration; 4 | 5 | use block2::{Block, RcBlock}; 6 | use objc2::rc::{Allocated, Retained}; 7 | use objc2::{define_class, msg_send, DefinedClass as _, Message}; 8 | use objc2_foundation::{ 9 | ns_string, MainThreadMarker, NSArray, NSBundle, NSCoder, NSIndexPath, NSInteger, 10 | NSObjectProtocol, NSString, 11 | }; 12 | use objc2_ui_kit::{ 13 | NSIndexPathUIKitAdditions, UIAction, UIButton, UILabel, UIMenu, UIMenuElementState, 14 | UIMenuOptions, UIScrollViewDelegate, UISegmentedControl, UITableView, UITableViewCell, 15 | UITableViewDataSource, UITableViewDelegate, UITextField, UIViewController, 16 | }; 17 | use ruffle_core::{LoadBehavior, PlayerRuntime, StageAlign, StageScaleMode}; 18 | use ruffle_frontend_utils::player_options::PlayerOptions; 19 | use ruffle_render::quality::StageQuality; 20 | 21 | use crate::storage::Movie; 22 | 23 | #[derive(Clone, Copy, Debug)] 24 | enum FormElement { 25 | Name, 26 | String { 27 | label: &'static str, 28 | text: fn(&PlayerOptions) -> Option, 29 | write_if_set: fn(&mut PlayerOptions, &str) -> Result<(), Box>, 30 | }, 31 | Select { 32 | label: &'static str, 33 | variants: &'static [&'static str], 34 | enabled_variant: fn(&PlayerOptions) -> Option<&'static str>, 35 | write_if_set: fn(&mut PlayerOptions, &str) -> Result<(), Box>, 36 | }, 37 | Bool { 38 | label: &'static str, 39 | value: fn(&PlayerOptions) -> Option, 40 | write_if_set: fn(&mut PlayerOptions, bool), 41 | }, 42 | } 43 | 44 | // TODO: Localization 45 | // Roughly matches PlayerOptions 46 | const FORM: &[&[FormElement]] = &[ 47 | // Required 48 | &[FormElement::Name], 49 | // General options 50 | &[ 51 | FormElement::String { 52 | label: "Maximum execution duration (s)", 53 | text: |options| { 54 | options 55 | .max_execution_duration 56 | .map(|duration| duration.as_secs().to_string()) 57 | }, 58 | write_if_set: |options, value| { 59 | let value = value 60 | .parse() 61 | .map_err(|err| format!("invalid duration: {err}"))?; 62 | options.max_execution_duration = Some(Duration::from_secs(value)); 63 | Ok(()) 64 | }, 65 | }, 66 | FormElement::Select { 67 | label: "Quality", 68 | variants: &[ 69 | "Low", 70 | "Medium", 71 | "High", 72 | "Best", 73 | "High (8x8)", 74 | "High (8x8) Linear", 75 | "High (16x16)", 76 | "High (16x16) Linear", 77 | ], 78 | enabled_variant: |options| { 79 | options.quality.map(|quality| match quality { 80 | StageQuality::Low => "Low", 81 | StageQuality::Medium => "Medium", 82 | StageQuality::High => "High", 83 | StageQuality::Best => "Best", 84 | StageQuality::High8x8 => "High (8x8)", 85 | StageQuality::High8x8Linear => "High (8x8) Linear", 86 | StageQuality::High16x16 => "High (16x16)", 87 | StageQuality::High16x16Linear => "High (16x16) Linear", 88 | }) 89 | }, 90 | write_if_set: |options, value| { 91 | options.quality = Some(match value { 92 | "Low" => StageQuality::Low, 93 | "Medium" => StageQuality::Medium, 94 | "High" => StageQuality::High, 95 | "Best" => StageQuality::Best, 96 | "High (8x8)" => StageQuality::High8x8, 97 | "High (8x8) Linear" => StageQuality::High8x8Linear, 98 | "High (16x16)" => StageQuality::High16x16, 99 | "High (16x16) Linear" => StageQuality::High16x16Linear, 100 | _ => return Err("invalid stage quality".into()), 101 | }); 102 | Ok(()) 103 | }, 104 | }, 105 | FormElement::String { 106 | label: "Player version", 107 | text: |options| options.player_version.map(|version| version.to_string()), 108 | write_if_set: |options, value| { 109 | let value = value 110 | .parse() 111 | .map_err(|err| format!("invalid player version: {err}"))?; 112 | options.player_version = Some(value); 113 | Ok(()) 114 | }, 115 | }, 116 | FormElement::Select { 117 | label: "Player runtime", 118 | variants: &["Flash Player", "Adobe AIR"], 119 | enabled_variant: |options| { 120 | options.player_runtime.map(|runtime| match runtime { 121 | PlayerRuntime::FlashPlayer => "Flash Player", 122 | PlayerRuntime::AIR => "Adobe AIR", 123 | }) 124 | }, 125 | write_if_set: |options, value| { 126 | options.player_runtime = Some(match value { 127 | "Flash Player" => PlayerRuntime::FlashPlayer, 128 | "Adobe AIR" => PlayerRuntime::AIR, 129 | _ => return Err("invalid player runtime".into()), 130 | }); 131 | Ok(()) 132 | }, 133 | }, 134 | FormElement::String { 135 | label: "Custom framerate (fps)", 136 | text: |options| options.frame_rate.map(|rate: f64| rate.to_string()), 137 | write_if_set: |options, value| { 138 | let value = value 139 | .parse() 140 | .map_err(|err| format!("invalid framerate: {err}"))?; 141 | options.frame_rate = Some(value); 142 | Ok(()) 143 | }, 144 | }, 145 | ], 146 | // Stage Alignment 147 | &[ 148 | FormElement::Select { 149 | label: "Alignment", 150 | variants: &[ 151 | "Center", 152 | "Top", 153 | "Bottom", 154 | "Left", 155 | "Right", 156 | "Top-Left", 157 | "Top-Right", 158 | "Bottom-Left", 159 | "Bottom-Right", 160 | ], 161 | enabled_variant: |options| { 162 | const CENTER: StageAlign = StageAlign::empty(); 163 | const TOP_LEFT: StageAlign = StageAlign::TOP.union(StageAlign::LEFT); 164 | const TOP_RIGHT: StageAlign = StageAlign::TOP.union(StageAlign::RIGHT); 165 | const BOTTOM_LEFT: StageAlign = StageAlign::BOTTOM.union(StageAlign::LEFT); 166 | const BOTTOM_RIGHT: StageAlign = StageAlign::BOTTOM.union(StageAlign::RIGHT); 167 | options.align.map(|align| match align { 168 | CENTER => "Center", 169 | StageAlign::TOP => "Top", 170 | StageAlign::BOTTOM => "Bottom", 171 | StageAlign::LEFT => "Left", 172 | StageAlign::RIGHT => "Right", 173 | TOP_LEFT => "Top-Left", 174 | TOP_RIGHT => "Top-Right", 175 | BOTTOM_LEFT => "Bottom-Left", 176 | BOTTOM_RIGHT => "Bottom-Right", 177 | // Fallback 178 | _ => "Center", 179 | }) 180 | }, 181 | write_if_set: |options, value| { 182 | options.align = Some(match value { 183 | "Center" => StageAlign::empty(), 184 | "Top" => StageAlign::TOP, 185 | "Bottom" => StageAlign::BOTTOM, 186 | "Left" => StageAlign::LEFT, 187 | "Right" => StageAlign::RIGHT, 188 | "Top-Left" => StageAlign::TOP.union(StageAlign::LEFT), 189 | "Top-Right" => StageAlign::TOP.union(StageAlign::RIGHT), 190 | "Bottom-Left" => StageAlign::BOTTOM.union(StageAlign::LEFT), 191 | "Bottom-Right" => StageAlign::BOTTOM.union(StageAlign::RIGHT), 192 | _ => return Err("invalid stage".into()), 193 | }); 194 | Ok(()) 195 | }, 196 | }, 197 | FormElement::Bool { 198 | label: "Force", 199 | value: |options| options.force_align, 200 | write_if_set: |options, value| options.force_align = Some(value), 201 | }, 202 | ], 203 | // Scale mode 204 | &[ 205 | FormElement::Select { 206 | label: "Scale mode", 207 | variants: &[ 208 | "Unscaled (100%)", 209 | "Zoom to Fit", 210 | "Stretch to Fit", 211 | "Crop to Fit", 212 | ], 213 | enabled_variant: |options| { 214 | options.scale.map(|scale| match scale { 215 | StageScaleMode::NoScale => "Unscaled (100%)", 216 | StageScaleMode::ShowAll => "Zoom to Fit", 217 | StageScaleMode::ExactFit => "Stretch to Fit", 218 | StageScaleMode::NoBorder => "Crop to Fit", 219 | }) 220 | }, 221 | write_if_set: |options, value| { 222 | options.scale = Some(match value { 223 | "Unscaled (100%)" => StageScaleMode::NoScale, 224 | "Zoom to Fit" => StageScaleMode::ShowAll, 225 | "Stretch to Fit" => StageScaleMode::ExactFit, 226 | "Crop to Fit" => StageScaleMode::NoBorder, 227 | _ => return Err("invalid scale mode".into()), 228 | }); 229 | Ok(()) 230 | }, 231 | }, 232 | FormElement::Bool { 233 | label: "Force", 234 | value: |options| options.force_scale, 235 | write_if_set: |options, value| options.force_scale = Some(value), 236 | }, 237 | ], 238 | // Network settings 239 | &[ 240 | FormElement::String { 241 | label: "Custom base URL", 242 | text: |options| options.base.as_ref().map(|url| url.to_string()), 243 | write_if_set: |options, value| { 244 | let value = value.parse().map_err(|err| format!("invalid URL: {err}"))?; 245 | options.base = Some(value); 246 | Ok(()) 247 | }, 248 | }, 249 | FormElement::String { 250 | label: "Spoof SWF URL", 251 | text: |options| options.spoof_url.as_ref().map(|url| url.to_string()), 252 | write_if_set: |options, value| { 253 | let value = value.parse().map_err(|err| format!("invalid URL: {err}"))?; 254 | options.spoof_url = Some(value); 255 | Ok(()) 256 | }, 257 | }, 258 | FormElement::String { 259 | label: "Referer URL", 260 | text: |options| options.referer.as_ref().map(|url| url.to_string()), 261 | write_if_set: |options, value| { 262 | let value = value.parse().map_err(|err| format!("invalid URL: {err}"))?; 263 | options.referer = Some(value); 264 | Ok(()) 265 | }, 266 | }, 267 | FormElement::String { 268 | label: "Cookie", 269 | text: |options| options.cookie.clone(), 270 | write_if_set: |options, value| { 271 | options.cookie = Some(value.to_string()); 272 | Ok(()) 273 | }, 274 | }, 275 | FormElement::Bool { 276 | label: "Upgrade HTTP to HTTPS", 277 | value: |options| options.upgrade_to_https, 278 | write_if_set: |options, value| options.upgrade_to_https = Some(value), 279 | }, 280 | FormElement::Select { 281 | label: "Load behaviour", 282 | variants: &["Streaming", "Delayed", "Blocking"], 283 | enabled_variant: |options| { 284 | options.load_behavior.map(|behaviour| match behaviour { 285 | LoadBehavior::Streaming => "Streaming", 286 | LoadBehavior::Delayed => "Delayed", 287 | LoadBehavior::Blocking => "Blocking", 288 | }) 289 | }, 290 | write_if_set: |options, value| { 291 | options.load_behavior = Some(match value { 292 | "Streaming" => LoadBehavior::Streaming, 293 | "Delayed" => LoadBehavior::Delayed, 294 | "Blocking" => LoadBehavior::Blocking, 295 | _ => return Err("invalid load behaviour".into()), 296 | }); 297 | Ok(()) 298 | }, 299 | }, 300 | FormElement::Bool { 301 | label: "Dummy external interface", 302 | value: |options| options.dummy_external_interface, 303 | write_if_set: |options, value| options.dummy_external_interface = Some(value), 304 | }, 305 | ], 306 | // Movie parameters are placed at the end 307 | ]; 308 | 309 | #[derive(Default, Debug)] 310 | pub struct Ivars { 311 | table_view: OnceCell>, 312 | movie: OnceCell>, 313 | } 314 | 315 | define_class!( 316 | #[unsafe(super(UIViewController))] 317 | #[name = "EditController"] 318 | #[ivars = Ivars] 319 | #[derive(Debug)] 320 | pub struct EditController; 321 | 322 | unsafe impl NSObjectProtocol for EditController {} 323 | 324 | /// UIViewController. 325 | impl EditController { 326 | #[unsafe(method_id(initWithNibName:bundle:))] 327 | fn _init_with_nib_name_bundle( 328 | this: Allocated, 329 | nib_name_or_nil: Option<&NSString>, 330 | nib_bundle_or_nil: Option<&NSBundle>, 331 | ) -> Retained { 332 | tracing::info!("edit init"); 333 | let this = this.set_ivars(Ivars::default()); 334 | unsafe { 335 | msg_send![super(this), initWithNibName: nib_name_or_nil, bundle: nib_bundle_or_nil] 336 | } 337 | } 338 | 339 | #[unsafe(method_id(initWithCoder:))] 340 | fn _init_with_coder(this: Allocated, coder: &NSCoder) -> Option> { 341 | tracing::info!("edit init"); 342 | let this = this.set_ivars(Ivars::default()); 343 | unsafe { msg_send![super(this), initWithCoder: coder] } 344 | } 345 | 346 | #[unsafe(method(viewDidLoad))] 347 | fn _view_did_load(&self) { 348 | // Xcode template calls super at the beginning 349 | let _: () = unsafe { msg_send![super(self), viewDidLoad] }; 350 | self.view_did_load(); 351 | } 352 | 353 | #[unsafe(method(viewWillAppear:))] 354 | fn _view_will_appear(&self, animated: bool) { 355 | self.view_will_appear(); 356 | // Docs say to call super 357 | let _: () = unsafe { msg_send![super(self), viewWillAppear: animated] }; 358 | } 359 | 360 | #[unsafe(method(viewDidAppear:))] 361 | fn _view_did_appear(&self, animated: bool) { 362 | self.view_did_appear(); 363 | // Docs say to call super 364 | let _: () = unsafe { msg_send![super(self), viewDidAppear: animated] }; 365 | } 366 | } 367 | 368 | /// Storyboard 369 | /// See storyboard_connections.h 370 | impl EditController { 371 | #[unsafe(method(setTableView:))] 372 | fn _set_table_view(&self, table_view: &UITableView) { 373 | tracing::trace!("edit set table view"); 374 | self.ivars() 375 | .table_view 376 | .set(table_view.retain()) 377 | .expect("only set table view once"); 378 | } 379 | } 380 | 381 | #[allow(non_snake_case)] 382 | unsafe impl UITableViewDataSource for EditController { 383 | #[unsafe(method(tableView:numberOfRowsInSection:))] 384 | fn tableView_numberOfRowsInSection( 385 | &self, 386 | _table_view: &UITableView, 387 | section: NSInteger, 388 | ) -> NSInteger { 389 | if FORM.len() == section as usize { 390 | let movie = self.ivars().movie.get().unwrap(); 391 | movie.user_options().parameters.len() as NSInteger + 1 392 | } else { 393 | FORM[section as usize].len() as NSInteger 394 | } 395 | } 396 | 397 | #[unsafe(method(numberOfSectionsInTableView:))] 398 | fn numberOfSectionsInTableView(&self, _table_view: &UITableView) -> NSInteger { 399 | FORM.len() as NSInteger + 1 400 | } 401 | 402 | #[unsafe(method_id(tableView:cellForRowAtIndexPath:))] 403 | fn tableView_cellForRowAtIndexPath( 404 | &self, 405 | table_view: &UITableView, 406 | index_path: &NSIndexPath, 407 | ) -> Retained { 408 | self.cell_at_index_path(table_view, index_path) 409 | } 410 | } 411 | 412 | unsafe impl UIScrollViewDelegate for EditController {} 413 | 414 | unsafe impl UITableViewDelegate for EditController {} 415 | ); 416 | 417 | impl EditController { 418 | pub fn setup_movie(&self, movie: &Movie) { 419 | self.ivars().movie.set(movie.retain()).unwrap(); 420 | } 421 | 422 | fn view_did_load(&self) { 423 | tracing::info!("edit viewDidLoad"); 424 | } 425 | 426 | fn view_will_appear(&self) { 427 | tracing::info!("edit viewWillAppear:"); 428 | } 429 | 430 | fn view_did_appear(&self) { 431 | tracing::info!("edit viewDidAppear:"); 432 | 433 | // Do the same thing as UITableViewController, flash the scroll bar 434 | let table = self.ivars().table_view.get().expect("table view"); 435 | table.flashScrollIndicators(); 436 | } 437 | 438 | fn cell_at_index_path( 439 | &self, 440 | table_view: &UITableView, 441 | index_path: &NSIndexPath, 442 | ) -> Retained { 443 | let mtm = MainThreadMarker::from(self); 444 | let movie = self.ivars().movie.get().unwrap(); 445 | let options = movie.user_options(); 446 | let section = index_path.section() as usize; 447 | let row = index_path.row() as usize; 448 | if FORM.len() == section { 449 | if options.parameters.len() == row { 450 | return table_view.dequeueReusableCellWithIdentifier_forIndexPath( 451 | ns_string!("movie-parameter-add"), 452 | index_path, 453 | ); 454 | } 455 | 456 | let (param, value) = &options.parameters[row]; 457 | let cell = table_view.dequeueReusableCellWithIdentifier_forIndexPath( 458 | ns_string!("movie-parameter"), 459 | index_path, 460 | ); 461 | let subviews = cell.contentView().subviews(); 462 | let ui_param = subviews.objectAtIndex(1).downcast::().unwrap(); 463 | ui_param.setText(Some(&NSString::from_str(param))); 464 | let ui_value = subviews.objectAtIndex(2).downcast::().unwrap(); 465 | ui_value.setText(Some(&NSString::from_str(value))); 466 | 467 | return cell; 468 | } 469 | 470 | match FORM[section][row] { 471 | FormElement::Name => { 472 | let cell = table_view.dequeueReusableCellWithIdentifier_forIndexPath( 473 | ns_string!("root-name"), 474 | index_path, 475 | ); 476 | let input = cell 477 | .contentView() 478 | .subviews() 479 | .objectAtIndex(0) 480 | .downcast::() 481 | .unwrap(); 482 | input.setText(Some(&movie.cachedName())); 483 | cell 484 | } 485 | FormElement::String { label, text, .. } => { 486 | let cell = table_view.dequeueReusableCellWithIdentifier_forIndexPath( 487 | ns_string!("string"), 488 | index_path, 489 | ); 490 | let subviews = cell.contentView().subviews(); 491 | 492 | let ui_label = subviews.objectAtIndex(0).downcast::().unwrap(); 493 | ui_label.setText(Some(&NSString::from_str(label))); 494 | 495 | let input = subviews.objectAtIndex(1).downcast::().unwrap(); 496 | input.setText(text(&options).map(|s| NSString::from_str(&s)).as_deref()); 497 | cell 498 | } 499 | FormElement::Select { 500 | label, 501 | variants, 502 | enabled_variant, 503 | .. 504 | } => { 505 | let cell = table_view.dequeueReusableCellWithIdentifier_forIndexPath( 506 | ns_string!("select"), 507 | index_path, 508 | ); 509 | let subviews = cell.contentView().subviews(); 510 | 511 | let ui_label = subviews.objectAtIndex(0).downcast::().unwrap(); 512 | ui_label.setText(Some(&NSString::from_str(label))); 513 | 514 | // Set menu 515 | let enabled_variant = enabled_variant(&options); 516 | let button = subviews.objectAtIndex(1).downcast::().unwrap(); 517 | // We have to use UIAction here, UICommand seems to be broken 518 | let block = RcBlock::new(|_| {}); 519 | let block_ptr: *const Block<_> = &*block; 520 | let default_item = 521 | unsafe { UIAction::actionWithHandler(block_ptr.cast_mut(), mtm) }; 522 | default_item.setTitle(ns_string!("Default")); 523 | if enabled_variant.is_none() { 524 | default_item.setState(UIMenuElementState::On); 525 | } 526 | 527 | let children: Retained> = variants 528 | .iter() 529 | .map(|title| { 530 | let cmd = unsafe { UIAction::actionWithHandler(block_ptr.cast_mut(), mtm) }; 531 | cmd.setTitle(&NSString::from_str(title)); 532 | if enabled_variant == Some(title) { 533 | cmd.setState(UIMenuElementState::On); 534 | } 535 | Retained::into_super(cmd) 536 | }) 537 | .collect(); 538 | button.setMenu(Some( 539 | &UIMenu::menuWithTitle_image_identifier_options_children( 540 | ns_string!(""), 541 | None, 542 | None, 543 | UIMenuOptions::SingleSelection, 544 | &NSArray::from_slice(&[ 545 | &**default_item, 546 | &*UIMenu::menuWithTitle_image_identifier_options_children( 547 | ns_string!(""), 548 | None, 549 | None, 550 | UIMenuOptions::DisplayInline | UIMenuOptions::SingleSelection, 551 | &children, 552 | mtm, 553 | ), 554 | ]), 555 | mtm, 556 | ), 557 | )); 558 | cell 559 | } 560 | FormElement::Bool { label, value, .. } => { 561 | let cell = table_view 562 | .dequeueReusableCellWithIdentifier_forIndexPath(ns_string!("bool"), index_path); 563 | let subviews = cell.contentView().subviews(); 564 | 565 | let ui_label = subviews.objectAtIndex(0).downcast::().unwrap(); 566 | ui_label.setText(Some(&NSString::from_str(label))); 567 | 568 | let control = subviews 569 | .objectAtIndex(1) 570 | .downcast::() 571 | .unwrap(); 572 | control.setSelectedSegmentIndex(match value(&options) { 573 | None => 0, 574 | Some(false) => 1, 575 | Some(true) => 2, 576 | }); 577 | cell 578 | } 579 | } 580 | } 581 | 582 | // fn get_data(&self) -> (Retained, PlayerOptions) { 583 | // let table_view = self.ivars().table_view.get().unwrap(); 584 | // let path = NSIndexPath::new(); 585 | // for (i, section) in FORM.iter().enumerate() { 586 | // let cell = table_view.cellForRowAtIndexPath(); 587 | // } 588 | // 589 | // // Movie parameters 590 | // } 591 | } 592 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | use objc2::runtime::AnyClass; 2 | use objc2::ClassType; 3 | use objc2_foundation::{MainThreadMarker, NSString}; 4 | use objc2_ui_kit::UIApplication; 5 | use tracing_subscriber::fmt::Layer; 6 | use tracing_subscriber::layer::SubscriberExt; 7 | use tracing_subscriber::util::SubscriberInitExt; 8 | 9 | mod add_controller; 10 | mod app_delegate; 11 | mod edit_controller; 12 | mod library_controller; 13 | mod player_controller; 14 | mod player_view; 15 | mod scene_delegate; 16 | mod storage; 17 | 18 | pub use self::app_delegate::AppDelegate; 19 | pub use self::player_controller::PlayerController; 20 | pub use self::player_view::PlayerView; 21 | 22 | /// Emit logging to either OSLog or stderr, depending on if using Mac 23 | /// Catalyst or native. 24 | /// 25 | /// TODO: If running Mac Catalyst under Xcode 26 | pub fn init_logging() { 27 | let subscriber = tracing_subscriber::registry(); 28 | 29 | let env_filter = tracing_subscriber::EnvFilter::builder() 30 | .parse_lossy(std::env::var("RUST_LOG").as_deref().unwrap_or("info")); 31 | 32 | let subscriber = subscriber.with(env_filter); 33 | 34 | #[cfg(target_abi = "macabi")] 35 | let subscriber = subscriber.with(Layer::new().with_writer(std::io::stderr)); 36 | 37 | #[cfg(not(target_abi = "macabi"))] 38 | let subscriber = subscriber.with(tracing_oslog::OsLogger::default()); 39 | 40 | subscriber.init(); 41 | } 42 | 43 | pub fn launch(app_class: Option<&AnyClass>, delegate_class: Option<&AnyClass>) { 44 | // Set inside Info.plist 45 | let _ = scene_delegate::SceneDelegate::class(); 46 | 47 | // These classes are loaded from a storyboard, 48 | // and hence need to be initialized first. 49 | // See also [storyboard_connections.h] 50 | let _ = player_view::PlayerView::class(); 51 | let _ = player_controller::PlayerController::class(); 52 | let _ = library_controller::LibraryController::class(); 53 | let _ = edit_controller::EditController::class(); 54 | let _ = add_controller::AddController::class(); 55 | 56 | // This is loaded by CoreData 57 | let _ = storage::Movie::class(); 58 | let _ = storage::MovieData::class(); 59 | 60 | let mtm = MainThreadMarker::new().unwrap(); 61 | UIApplication::main( 62 | app_class.map(|cls| NSString::from_class(cls)).as_deref(), 63 | delegate_class 64 | .map(|cls| NSString::from_class(cls)) 65 | .as_deref(), 66 | mtm, 67 | ); 68 | } 69 | -------------------------------------------------------------------------------- /src/library_controller.rs: -------------------------------------------------------------------------------- 1 | use std::cell::OnceCell; 2 | 3 | use objc2::rc::{Allocated, Retained}; 4 | use objc2::runtime::{AnyObject, ProtocolObject}; 5 | use objc2::{define_class, msg_send, AllocAnyThread, DefinedClass as _, Message}; 6 | use objc2_core_data::{ 7 | NSFetchedResultsChangeType, NSFetchedResultsController, NSFetchedResultsControllerDelegate, 8 | NSFetchedResultsSectionInfo, 9 | }; 10 | use objc2_foundation::{ 11 | ns_string, MainThreadMarker, NSArray, NSBundle, NSCoder, NSIndexPath, NSInteger, NSObject, 12 | NSObjectProtocol, NSString, NSURL, 13 | }; 14 | use objc2_ui_kit::{ 15 | NSDataAsset, UIBarButtonItem, UIDocumentPickerDelegate, UIDocumentPickerViewController, 16 | UILabel, UITableView, UITableViewCell, UITableViewCellEditingStyle, UITableViewController, 17 | UITableViewDataSource, UITableViewRowAnimation, 18 | }; 19 | #[allow(deprecated)] 20 | use objc2_ui_kit::{UIDocumentPickerMode, UIStoryboardSegue}; 21 | use ruffle_core::tag_utils::SwfMovie; 22 | use ruffle_core::PlayerBuilder; 23 | use ruffle_frontend_utils::backends::audio::CpalAudioBackend; 24 | 25 | use crate::edit_controller::EditController; 26 | use crate::storage::Movie; 27 | use crate::{storage, PlayerController, PlayerView}; 28 | 29 | // There is no standardized UTI for SWFs, so this is one we picked. 30 | pub const SWF_UTI: &str = "com.adobe.swf"; 31 | 32 | // Temporary until we publish the package 33 | pub const RUF_UTI: &str = "com.example.rs.ruffle.bundle"; 34 | 35 | #[derive(Debug)] 36 | pub struct Ivars { 37 | logo_view: OnceCell>, 38 | fetched_movies: Retained>, 39 | } 40 | 41 | define_class!( 42 | #[unsafe(super(UITableViewController))] 43 | #[name = "LibraryController"] 44 | #[ivars = Ivars] 45 | #[derive(Debug)] 46 | pub struct LibraryController; 47 | 48 | unsafe impl NSObjectProtocol for LibraryController {} 49 | 50 | /// UIViewController. 51 | impl LibraryController { 52 | #[unsafe(method_id(initWithNibName:bundle:))] 53 | fn _init_with_nib_name_bundle( 54 | this: Allocated, 55 | nib_name_or_nil: Option<&NSString>, 56 | nib_bundle_or_nil: Option<&NSBundle>, 57 | ) -> Retained { 58 | tracing::info!("library init"); 59 | let this = this.set_ivars(Ivars { 60 | logo_view: Default::default(), 61 | fetched_movies: storage::all_movies(), 62 | }); 63 | let this: Retained = unsafe { 64 | msg_send![super(this), initWithNibName: nib_name_or_nil, bundle: nib_bundle_or_nil] 65 | }; 66 | unsafe { 67 | this.ivars() 68 | .fetched_movies 69 | .setDelegate(Some(ProtocolObject::from_ref(&*this))) 70 | }; 71 | this 72 | } 73 | 74 | #[unsafe(method_id(initWithCoder:))] 75 | fn _init_with_coder(this: Allocated, coder: &NSCoder) -> Option> { 76 | tracing::info!("library init"); 77 | let this = this.set_ivars(Ivars { 78 | logo_view: Default::default(), 79 | fetched_movies: storage::all_movies(), 80 | }); 81 | let this: Option> = 82 | unsafe { msg_send![super(this), initWithCoder: coder] }; 83 | if let Some(this) = &this { 84 | unsafe { 85 | this.ivars() 86 | .fetched_movies 87 | .setDelegate(Some(ProtocolObject::from_ref(&**this))); 88 | } 89 | } 90 | this 91 | } 92 | 93 | #[unsafe(method(viewDidLoad))] 94 | fn _view_did_load(&self) { 95 | // Xcode template calls super at the beginning 96 | let _: () = unsafe { msg_send![super(self), viewDidLoad] }; 97 | self.view_did_load(); 98 | } 99 | 100 | #[unsafe(method(viewIsAppearing:))] 101 | fn _view_is_appearing(&self, animated: bool) { 102 | self.view_is_appearing(animated); 103 | // Docs say to call super 104 | let _: () = unsafe { msg_send![super(self), viewIsAppearing: animated] }; 105 | } 106 | 107 | #[unsafe(method(viewWillDisappear:))] 108 | fn _view_will_disappear(&self, animated: bool) { 109 | self.view_will_disappear(animated); 110 | // Docs say to call super 111 | let _: () = unsafe { msg_send![super(self), viewWillDisappear: animated] }; 112 | } 113 | 114 | #[unsafe(method(viewDidDisappear:))] 115 | fn _view_did_disappear(&self, animated: bool) { 116 | self.view_did_disappear(animated); 117 | // Docs say to call super 118 | let _: () = unsafe { msg_send![super(self), viewDidDisappear: animated] }; 119 | } 120 | 121 | #[unsafe(method(prepareForSegue:sender:))] 122 | #[allow(deprecated)] 123 | fn _prepare_for_segue(&self, segue: &UIStoryboardSegue, sender: Option<&NSObject>) { 124 | self.prepare_for_segue(segue, sender.expect("has sender")); 125 | } 126 | } 127 | 128 | /// Storyboard 129 | /// See storyboard_connections.h 130 | impl LibraryController { 131 | #[unsafe(method(setLogoView:))] 132 | fn _set_logo_view(&self, view: Option<&AnyObject>) { 133 | tracing::trace!("library set logo view"); 134 | let view = view 135 | .expect("logo view not null") 136 | .downcast_ref::() 137 | .expect("logo view not a PlayerView"); 138 | self.ivars() 139 | .logo_view 140 | .set(view.retain()) 141 | .expect("only set logo view once"); 142 | } 143 | 144 | #[unsafe(method(toggleEditing:))] 145 | fn _toggle_editing(&self, button: Option<&AnyObject>) { 146 | tracing::trace!("library toggle editing"); 147 | let button = button 148 | .expect("edit button not null") 149 | .downcast_ref::() 150 | .expect("edit button not UIBarButtonItem"); 151 | self.toggle_editing(button); 152 | } 153 | 154 | #[unsafe(method(cancelEditItem:))] 155 | #[allow(deprecated)] 156 | fn _cancel_edit_item(&self, _segue: &UIStoryboardSegue) {} 157 | 158 | #[unsafe(method(saveEditItem:))] 159 | #[allow(deprecated)] 160 | fn _save_edit_item(&self, segue: &UIStoryboardSegue) { 161 | self.save_item(segue); 162 | } 163 | 164 | #[unsafe(method(showDocumentPicker:))] 165 | #[allow(deprecated)] 166 | fn _show_document_picker(&self, _sender: Option<&AnyObject>) { 167 | self.show_document_picker(); 168 | } 169 | } 170 | 171 | #[allow(non_snake_case)] 172 | unsafe impl UITableViewDataSource for LibraryController { 173 | #[unsafe(method(tableView:numberOfRowsInSection:))] 174 | fn tableView_numberOfRowsInSection( 175 | &self, 176 | _table_view: &UITableView, 177 | section: NSInteger, 178 | ) -> NSInteger { 179 | let sections = unsafe { self.ivars().fetched_movies.sections().unwrap() }; 180 | let section_info = sections.objectAtIndex(section as usize); 181 | unsafe { section_info.numberOfObjects() as isize } 182 | } 183 | 184 | #[unsafe(method(numberOfSectionsInTableView:))] 185 | fn numberOfSectionsInTableView(&self, _table_view: &UITableView) -> NSInteger { 186 | unsafe { self.ivars().fetched_movies.sections().unwrap().count() as NSInteger } 187 | } 188 | 189 | #[unsafe(method_id(tableView:cellForRowAtIndexPath:))] 190 | fn tableView_cellForRowAtIndexPath( 191 | &self, 192 | table_view: &UITableView, 193 | index_path: &NSIndexPath, 194 | ) -> Retained { 195 | let cell = table_view.dequeueReusableCellWithIdentifier_forIndexPath( 196 | ns_string!("library-item"), 197 | index_path, 198 | ); 199 | self.configure_cell(&cell, index_path); 200 | cell 201 | } 202 | 203 | #[unsafe(method_id(tableView:titleForHeaderInSection:))] 204 | fn tableView_titleForHeaderInSection( 205 | &self, 206 | _table_view: &UITableView, 207 | _section: NSInteger, 208 | ) -> Option> { 209 | Some(NSString::from_str("Library")) 210 | } 211 | 212 | #[unsafe(method(tableView:commitEditingStyle:forRowAtIndexPath:))] 213 | fn tableView_commitEditingStyle_forRowAtIndexPath( 214 | &self, 215 | _table_view: &UITableView, 216 | editing_style: UITableViewCellEditingStyle, 217 | index_path: &NSIndexPath, 218 | ) { 219 | if editing_style == UITableViewCellEditingStyle::Delete { 220 | let movie = unsafe { self.ivars().fetched_movies.objectAtIndexPath(&index_path) }; 221 | storage::delete_movie(&movie); 222 | } 223 | } 224 | 225 | // TODO: Implement moving (requires keeping the order in CoreData). 226 | } 227 | 228 | // For usage, see: 229 | // https://developer.apple.com/library/archive/samplecode/CoreDataBooks/Listings/Classes_RootViewController_m.html 230 | #[allow(non_snake_case)] 231 | unsafe impl NSFetchedResultsControllerDelegate for LibraryController { 232 | #[unsafe(method(controllerWillChangeContent:))] 233 | fn controllerWillChangeContent(&self, _controller: &NSFetchedResultsController) { 234 | self.tableView().unwrap().beginUpdates(); 235 | } 236 | 237 | #[unsafe(method(controller:didChangeObject:atIndexPath:forChangeType:newIndexPath:))] 238 | fn controller_didChangeObject_atIndexPath_forChangeType_newIndexPath( 239 | &self, 240 | _controller: &NSFetchedResultsController, 241 | _an_object: &AnyObject, 242 | index_path: Option<&NSIndexPath>, 243 | r#type: NSFetchedResultsChangeType, 244 | new_index_path: Option<&NSIndexPath>, 245 | ) { 246 | let table_view = self.tableView().unwrap(); 247 | 248 | match r#type { 249 | NSFetchedResultsChangeType::Insert => table_view 250 | .insertRowsAtIndexPaths_withRowAnimation( 251 | &NSArray::from_slice(&[new_index_path.unwrap()]), 252 | UITableViewRowAnimation::Automatic, 253 | ), 254 | NSFetchedResultsChangeType::Delete => table_view 255 | .deleteRowsAtIndexPaths_withRowAnimation( 256 | &NSArray::from_slice(&[index_path.unwrap()]), 257 | UITableViewRowAnimation::Automatic, 258 | ), 259 | NSFetchedResultsChangeType::Update => self.configure_cell( 260 | &table_view 261 | .cellForRowAtIndexPath(index_path.unwrap()) 262 | .unwrap(), 263 | index_path.unwrap(), 264 | ), 265 | NSFetchedResultsChangeType::Move => { 266 | table_view.deleteRowsAtIndexPaths_withRowAnimation( 267 | &NSArray::from_slice(&[index_path.unwrap()]), 268 | UITableViewRowAnimation::Automatic, 269 | ); 270 | table_view.insertRowsAtIndexPaths_withRowAnimation( 271 | &NSArray::from_slice(&[new_index_path.unwrap()]), 272 | UITableViewRowAnimation::Automatic, 273 | ); 274 | } 275 | _ => {} 276 | } 277 | } 278 | 279 | #[unsafe(method(controllerDidChangeContent:))] 280 | fn controllerDidChangeContent(&self, _controller: &NSFetchedResultsController) { 281 | self.tableView().unwrap().endUpdates(); 282 | } 283 | } 284 | 285 | #[allow(non_snake_case)] 286 | unsafe impl UIDocumentPickerDelegate for LibraryController { 287 | #[unsafe(method(documentPickerWasCancelled:))] 288 | fn documentPickerWasCancelled(&self, _controller: &UIDocumentPickerViewController) { 289 | tracing::info!("cancelled document picker"); 290 | } 291 | 292 | #[unsafe(method(documentPicker:didPickDocumentAtURL:))] 293 | fn documentPicker_didPickDocumentAtURL( 294 | &self, 295 | _controller: &UIDocumentPickerViewController, 296 | url: &NSURL, 297 | ) { 298 | tracing::info!("completed document picker: {url:?}"); 299 | if storage::movie_from_url(&url).is_none() { 300 | storage::add_movie(&url); 301 | } else { 302 | // TODO: Give the user an option here? 303 | tracing::error!("did not add existing movie {url:?}"); 304 | } 305 | } 306 | } 307 | ); 308 | 309 | impl LibraryController { 310 | fn logo_view(&self) -> &PlayerView { 311 | self.ivars().logo_view.get().expect("logo view initialized") 312 | } 313 | 314 | fn view_did_load(&self) { 315 | tracing::info!("library viewDidLoad"); 316 | 317 | self.setup_logo(); 318 | 319 | unsafe { 320 | self.ivars() 321 | .fetched_movies 322 | .performFetch() 323 | .expect("failed fetching movies") 324 | }; 325 | } 326 | 327 | fn setup_logo(&self) { 328 | let view = self.logo_view(); 329 | let asset = NSDataAsset::initWithName(NSDataAsset::alloc(), ns_string!("logo-anim")) 330 | .expect("asset store should contain logo-anim"); 331 | let data = unsafe { asset.data() }; 332 | // SAFETY: SwfMovie::from_data won't modify the NSData. 333 | let bytes = unsafe { data.as_bytes_unchecked() }; 334 | let movie = 335 | SwfMovie::from_data(bytes, "file://logo-anim.swf".into(), None).expect("loading movie"); 336 | 337 | let renderer = view.create_renderer(); 338 | 339 | let mut builder = PlayerBuilder::new() 340 | .with_renderer(renderer) 341 | .with_movie(movie); 342 | 343 | match CpalAudioBackend::new(None) { 344 | Ok(audio) => builder = builder.with_audio(audio), 345 | Err(e) => tracing::error!("Unable to create audio device: {e}"), 346 | } 347 | 348 | view.set_player(builder.build()); 349 | // HACK: Skip first frame to avoid a flicker on startup 350 | // FIXME: This probably indicates a bug in our timing code? 351 | view.player_lock().run_frame(); 352 | } 353 | 354 | fn view_is_appearing(&self, _animated: bool) { 355 | tracing::info!("library viewIsAppearing:"); 356 | 357 | self.logo_view().start(); 358 | } 359 | 360 | fn view_will_disappear(&self, _animated: bool) { 361 | tracing::info!("library viewWillDisappear:"); 362 | 363 | self.logo_view().stop(); 364 | } 365 | 366 | fn view_did_disappear(&self, _animated: bool) { 367 | tracing::info!("library viewDidDisappear:"); 368 | 369 | self.logo_view().player_lock().flush_shared_objects(); 370 | } 371 | 372 | #[allow(deprecated)] 373 | fn prepare_for_segue(&self, segue: &UIStoryboardSegue, sender: &NSObject) { 374 | let destination = segue.destinationViewController(); 375 | tracing::info!(?destination, "prepareForSegue"); 376 | 377 | // Identifiers are set up in the Storyboard 378 | let identifier = segue.identifier().expect("segue to have identifier"); 379 | if &*identifier == ns_string!("add-item") { 380 | // No need to configure AddController 381 | } else if &*identifier == ns_string!("edit-item") { 382 | let edit_controller = destination.downcast_ref::().unwrap(); 383 | let cell = sender.downcast_ref::().unwrap(); 384 | 385 | let index_path = self.tableView().unwrap().indexPathForCell(&cell).unwrap(); 386 | let movie = unsafe { self.ivars().fetched_movies.objectAtIndexPath(&index_path) }; 387 | edit_controller.setup_movie(&movie); 388 | } else if &*identifier == ns_string!("run-item") { 389 | let player_controller = destination.downcast_ref::().unwrap(); 390 | let cell = sender.downcast_ref::().unwrap(); 391 | 392 | let index_path = self.tableView().unwrap().indexPathForCell(&cell).unwrap(); 393 | let movie = unsafe { self.ivars().fetched_movies.objectAtIndexPath(&index_path) }; 394 | player_controller.setup_movie(&movie); 395 | } else { 396 | unreachable!("unknown identifier for segue: {identifier:?}") 397 | } 398 | } 399 | 400 | #[allow(deprecated)] 401 | fn save_item(&self, segue: &UIStoryboardSegue) { 402 | tracing::info!("saveEditItem"); 403 | let edit_controller = segue.sourceViewController(); 404 | let edit_controller = edit_controller.downcast_ref::().unwrap(); 405 | dbg!(edit_controller); // TODO 406 | } 407 | 408 | #[allow(deprecated)] 409 | fn show_document_picker(&self) { 410 | tracing::info!("show document picker"); 411 | let mtm = MainThreadMarker::from(self); 412 | let picker = UIDocumentPickerViewController::initWithDocumentTypes_inMode( 413 | mtm.alloc(), 414 | &NSArray::from_slice(&[ns_string!(RUF_UTI), ns_string!(SWF_UTI)]), 415 | UIDocumentPickerMode::Open, 416 | ); 417 | picker.setDelegate(Some(ProtocolObject::from_ref(self))); 418 | // TODO: Consider setting picker.directoryURL to NSDownloadsDirectory, 419 | // as that's the likely place that people will have their SWFs. 420 | 421 | self.presentViewController_animated_completion(&picker, true, None); 422 | } 423 | 424 | fn toggle_editing(&self, button: &UIBarButtonItem) { 425 | let table_view = self.tableView().expect("has table view"); 426 | let is_editing = !table_view.isEditing(); 427 | table_view.setEditing_animated(is_editing, true); 428 | button.setTitle(Some(if is_editing { 429 | ns_string!("Done") 430 | } else { 431 | ns_string!("Edit") 432 | })); 433 | } 434 | 435 | fn configure_cell(&self, cell: &UITableViewCell, index_path: &NSIndexPath) { 436 | let subviews = cell.contentView().subviews(); 437 | let title = subviews.objectAtIndex(1).downcast::().unwrap(); 438 | let subtitle = subviews.objectAtIndex(2).downcast::().unwrap(); 439 | 440 | let movie = unsafe { self.ivars().fetched_movies.objectAtIndexPath(index_path) }; 441 | let cached_name = movie.cachedName(); 442 | let url = movie.link(); 443 | 444 | title.setText(Some(&cached_name)); 445 | 446 | if url.isFileURL() { 447 | if let Some(_access) = storage::SecurityScopedResource::access(&url) { 448 | dbg!(&url, url.filePathURL()); 449 | subtitle.setText(Some( 450 | &url.filePathURL().unwrap().lastPathComponent().unwrap(), 451 | )); 452 | } else { 453 | subtitle.setText(Some(ns_string!("Unknown"))); 454 | } 455 | } else { 456 | subtitle.setText(Some(&url.absoluteString().unwrap())); 457 | } 458 | } 459 | } 460 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use objc2::ClassType; 2 | use ruffle_ios::{init_logging, launch, AppDelegate}; 3 | 4 | #[tokio::main] 5 | async fn main() { 6 | init_logging(); 7 | launch(None, Some(AppDelegate::class())); 8 | } 9 | -------------------------------------------------------------------------------- /src/player_controller.rs: -------------------------------------------------------------------------------- 1 | use std::cell::{Cell, OnceCell}; 2 | use std::fs::File; 3 | use std::path::Path; 4 | use std::rc::Rc; 5 | use std::sync::{Arc, Mutex, MutexGuard, Weak}; 6 | use std::time::Duration; 7 | use std::{fmt, io}; 8 | 9 | use block2::RcBlock; 10 | use objc2::rc::{Allocated, Retained}; 11 | use objc2::runtime::AnyObject; 12 | use objc2::{define_class, msg_send, DefinedClass as _, Message}; 13 | use objc2_core_foundation::{CGPoint, CGRect, CGSize}; 14 | use objc2_foundation::{ 15 | MainThreadMarker, NSBundle, NSCoder, NSObjectProtocol, NSRunLoop, NSString, 16 | }; 17 | use objc2_ui_kit::UIViewController; 18 | use ruffle_core::backend::storage::StorageBackend; 19 | use ruffle_core::config::Letterbox; 20 | use ruffle_core::{LoadBehavior, Player, PlayerBuilder}; 21 | use ruffle_frontend_utils::backends::audio::CpalAudioBackend; 22 | use ruffle_frontend_utils::backends::executor::{AsyncExecutor, PollRequester}; 23 | use ruffle_frontend_utils::backends::navigator::{ExternalNavigatorBackend, NavigatorInterface}; 24 | use ruffle_frontend_utils::content::PlayingContent; 25 | use ruffle_frontend_utils::player_options::PlayerOptions; 26 | use ruffle_render::quality::StageQuality; 27 | use url::Url; 28 | 29 | use crate::player_view::PlayerView; 30 | use crate::storage::{self, Movie, SecurityScopedResource}; 31 | 32 | #[derive(Clone, Debug)] 33 | pub struct EventSender { 34 | executor: Rc>>>, 35 | main_run_loop: Retained, 36 | } 37 | 38 | impl PollRequester for EventSender { 39 | fn request_poll(&self) { 40 | tracing::info!("request_poll"); 41 | if let Some(executor) = self.executor.get().expect("initialized").upgrade() { 42 | // Schedule poll at a later point to avoid deadlock 43 | unsafe { 44 | self.main_run_loop.performBlock(&RcBlock::new(move || { 45 | tracing::info!("polling"); 46 | executor.poll_all(); 47 | })) 48 | }; 49 | } else { 50 | tracing::error!("tried to poll, but executor was dropped"); 51 | } 52 | } 53 | } 54 | 55 | #[derive(Default)] 56 | pub struct Ivars { 57 | // Populated to be used in `viewDidLoad`. 58 | content: Cell>, 59 | user_options: Cell>, 60 | storage_backend: Cell>>, 61 | 62 | /// Used to keep the bundle resource alive while we're using it. 63 | _scoped_resource: Cell>, 64 | 65 | player: OnceCell>>, 66 | executor: OnceCell>>, 67 | } 68 | 69 | impl fmt::Debug for Ivars { 70 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 71 | f.debug_struct("Ivars").finish_non_exhaustive() 72 | } 73 | } 74 | 75 | #[derive(Clone)] 76 | struct Navigator; 77 | 78 | impl NavigatorInterface for Navigator { 79 | fn navigate_to_website(&self, _url: Url) {} 80 | 81 | async fn open_file(&self, path: &Path) -> io::Result { 82 | tracing::info!("trying to open: {path:?}"); 83 | File::open(path) 84 | } 85 | 86 | async fn confirm_socket(&self, _host: &str, _port: u16) -> bool { 87 | true 88 | } 89 | } 90 | 91 | define_class!( 92 | #[unsafe(super(UIViewController))] 93 | #[name = "PlayerController"] 94 | #[ivars = Ivars] 95 | #[derive(Debug)] 96 | pub struct PlayerController; 97 | 98 | unsafe impl NSObjectProtocol for PlayerController {} 99 | 100 | /// UIViewController. 101 | impl PlayerController { 102 | #[unsafe(method_id(initWithNibName:bundle:))] 103 | fn _init_with_nib_name_bundle( 104 | this: Allocated, 105 | nib_name_or_nil: Option<&NSString>, 106 | nib_bundle_or_nil: Option<&NSBundle>, 107 | ) -> Retained { 108 | tracing::info!("player controller init"); 109 | let this = this.set_ivars(Ivars::default()); 110 | unsafe { 111 | msg_send![super(this), initWithNibName: nib_name_or_nil, bundle: nib_bundle_or_nil] 112 | } 113 | } 114 | 115 | #[unsafe(method_id(initWithCoder:))] 116 | fn _init_with_coder(this: Allocated, coder: &NSCoder) -> Option> { 117 | tracing::info!("player controller init"); 118 | let this = this.set_ivars(Ivars::default()); 119 | unsafe { msg_send![super(this), initWithCoder: coder] } 120 | } 121 | 122 | #[unsafe(method(loadView))] 123 | fn _load_view(&self) { 124 | self.load_view(); 125 | } 126 | 127 | #[unsafe(method(viewDidLoad))] 128 | fn _view_did_load(&self) { 129 | // Xcode template calls super at the beginning 130 | let _: () = unsafe { msg_send![super(self), viewDidLoad] }; 131 | self.view_did_load(); 132 | } 133 | 134 | #[unsafe(method(viewIsAppearing:))] 135 | fn _view_is_appearing(&self, animated: bool) { 136 | self.view_is_appearing(animated); 137 | // Docs say to call super 138 | let _: () = unsafe { msg_send![super(self), viewIsAppearing: animated] }; 139 | } 140 | 141 | #[unsafe(method(viewWillDisappear:))] 142 | fn _view_will_disappear(&self, animated: bool) { 143 | self.view_will_disappear(animated); 144 | // Docs say to call super 145 | let _: () = unsafe { msg_send![super(self), viewWillDisappear: animated] }; 146 | } 147 | 148 | #[unsafe(method(viewDidDisappear:))] 149 | fn _view_did_disappear(&self, animated: bool) { 150 | self.view_did_disappear(animated); 151 | // Docs say to call super 152 | let _: () = unsafe { msg_send![super(self), viewDidDisappear: animated] }; 153 | } 154 | } 155 | 156 | /// UIResponder 157 | #[allow(non_snake_case)] 158 | impl PlayerController { 159 | #[unsafe(method(canBecomeFirstResponder))] 160 | fn canBecomeFirstResponder(&self) -> bool { 161 | true 162 | } 163 | 164 | #[unsafe(method(becomeFirstResponder))] 165 | fn becomeFirstResponder(&self) -> bool { 166 | tracing::info!("player controller becomeFirstResponder"); 167 | self.view().becomeFirstResponder(); 168 | true 169 | } 170 | 171 | #[unsafe(method(canResignFirstResponder))] 172 | fn canResignFirstResponder(&self) -> bool { 173 | true 174 | } 175 | 176 | #[unsafe(method(resignFirstResponder))] 177 | fn resignFirstResponder(&self) -> bool { 178 | tracing::info!("controller resignFirstResponder"); 179 | true 180 | } 181 | } 182 | ); 183 | 184 | impl PlayerController { 185 | /// For use by run_swf.rs 186 | pub fn new( 187 | mtm: MainThreadMarker, 188 | content: PlayingContent, 189 | options: PlayerOptions, 190 | ) -> Retained { 191 | let this = mtm.alloc().set_ivars(Ivars { 192 | content: Cell::new(Some(content)), 193 | user_options: Cell::new(Some(options)), 194 | storage_backend: Cell::new(None), 195 | // run_swf.rs doesn't need security scoping. 196 | _scoped_resource: Cell::new(None), 197 | player: OnceCell::new(), 198 | executor: OnceCell::new(), 199 | }); 200 | let nil = None::<&AnyObject>; 201 | unsafe { msg_send![super(this), initWithNibName: nil, bundle: nil] } 202 | } 203 | 204 | pub fn empty(mtm: MainThreadMarker) -> Retained { 205 | let this = mtm.alloc().set_ivars(Default::default()); 206 | let nil = None::<&AnyObject>; 207 | unsafe { msg_send![super(this), initWithNibName: nil, bundle: nil] } 208 | } 209 | 210 | /// Prepare the controller for playing the given movie. 211 | pub fn setup_movie(&self, movie: &Movie) { 212 | let nsurl = movie.link(); 213 | 214 | self.ivars() 215 | .content 216 | .set(Some(storage::get_playing_content(&nsurl))); 217 | self.ivars().user_options.set(Some(movie.user_options())); 218 | self.ivars() 219 | .storage_backend 220 | .set(Some(Box::new(storage::MovieStorageBackend { 221 | movie: movie.retain(), 222 | }))); 223 | self.ivars()._scoped_resource.set(if nsurl.isFileURL() { 224 | Some(SecurityScopedResource::access(&nsurl).expect("failed accessing NSURL")) 225 | } else { 226 | None 227 | }); 228 | } 229 | 230 | fn load_view(&self) { 231 | tracing::info!("player loadView"); 232 | let mtm = MainThreadMarker::from(self); 233 | let view = PlayerView::initWithFrame( 234 | mtm.alloc(), 235 | CGRect::new(CGPoint::ZERO, CGSize::new(1.0, 1.0)), 236 | ); 237 | self.setView(Some(&view)); 238 | } 239 | 240 | fn view_did_load(&self) { 241 | tracing::info!("player viewDidLoad"); 242 | 243 | // TODO: Specify safe area somehow 244 | let view = self.view(); 245 | let renderer = view.create_renderer(); 246 | 247 | let sender = EventSender { 248 | executor: Rc::new(OnceCell::new()), 249 | main_run_loop: NSRunLoop::mainRunLoop(), 250 | }; 251 | let (executor, future_spawner) = AsyncExecutor::new(sender.clone()); 252 | sender.executor.set(Arc::downgrade(&executor)).unwrap(); 253 | 254 | let content = self.ivars().content.take().unwrap(); 255 | 256 | let player_options = self.ivars().user_options.take().unwrap(); 257 | let player_options = match &content { 258 | PlayingContent::DirectFile(_) => player_options.clone(), 259 | PlayingContent::Bundle(_, bundle) => player_options.or(&bundle.information().player), 260 | }; 261 | 262 | let movie_url = content.initial_swf_url().clone(); 263 | let navigator = ExternalNavigatorBackend::new( 264 | player_options 265 | .base 266 | .to_owned() 267 | .unwrap_or_else(|| movie_url.clone()), 268 | player_options.referer.clone(), 269 | player_options.cookie.clone(), 270 | future_spawner, 271 | None, 272 | player_options.upgrade_to_https.unwrap_or_default(), 273 | Default::default(), 274 | ruffle_core::backend::navigator::SocketMode::Allow, 275 | Rc::new(content), 276 | Navigator, 277 | ); 278 | 279 | let mut builder = PlayerBuilder::new() 280 | .with_renderer(renderer) 281 | .with_navigator(navigator) 282 | .with_letterbox(player_options.letterbox.unwrap_or(Letterbox::On)) 283 | .with_max_execution_duration( 284 | player_options 285 | .max_execution_duration 286 | .unwrap_or(Duration::MAX), 287 | ) 288 | .with_quality(player_options.quality.unwrap_or(StageQuality::High)) 289 | .with_align( 290 | player_options.align.unwrap_or_default(), 291 | player_options.force_align.unwrap_or_default(), 292 | ) 293 | .with_scale_mode( 294 | player_options.scale.unwrap_or_default(), 295 | player_options.force_scale.unwrap_or_default(), 296 | ) 297 | .with_load_behavior( 298 | player_options 299 | .load_behavior 300 | .unwrap_or(LoadBehavior::Streaming), 301 | ) 302 | .with_spoofed_url(player_options.spoof_url.clone().map(|url| url.to_string())) 303 | .with_page_url(player_options.spoof_url.clone().map(|url| url.to_string())) 304 | .with_player_version(player_options.player_version) 305 | .with_player_runtime(player_options.player_runtime.unwrap_or_default()) 306 | .with_frame_rate(player_options.frame_rate); 307 | 308 | if player_options.dummy_external_interface.unwrap_or_default() { 309 | // TODO 310 | } 311 | 312 | match CpalAudioBackend::new(None) { 313 | Ok(audio) => builder = builder.with_audio(audio), 314 | Err(e) => tracing::error!("Unable to create audio device: {e}"), 315 | } 316 | 317 | if let Some(storage) = self.ivars().storage_backend.take() { 318 | builder = builder.with_storage(storage); 319 | } 320 | 321 | let player = builder.build(); 322 | 323 | let mut player_lock = player.lock().unwrap(); 324 | player_lock.fetch_root_movie( 325 | movie_url.to_string(), 326 | player_options.parameters.to_owned(), 327 | Box::new(|metadata| { 328 | eprintln!("got movie: {metadata:?}"); 329 | }), 330 | ); 331 | drop(player_lock); 332 | 333 | view.set_player(player.clone()); 334 | self.ivars() 335 | .player 336 | .set(player) 337 | .unwrap_or_else(|_| panic!("viewDidLoad once")); 338 | self.ivars() 339 | .executor 340 | .set(executor) 341 | .unwrap_or_else(|_| panic!("viewDidLoad once")); 342 | } 343 | 344 | fn view_is_appearing(&self, _animated: bool) { 345 | tracing::info!("player viewIsAppearing:"); 346 | 347 | self.view().start(); 348 | } 349 | 350 | fn view_will_disappear(&self, _animated: bool) { 351 | tracing::info!("player viewWillDisappear:"); 352 | 353 | self.view().stop(); 354 | } 355 | 356 | fn view_did_disappear(&self, _animated: bool) { 357 | tracing::info!("player viewDidDisappear:"); 358 | 359 | self.view().flush(); 360 | } 361 | 362 | pub fn view(&self) -> Retained { 363 | let view = (**self).view().expect("controller loads view"); 364 | view.downcast().expect("must have correct view type") 365 | } 366 | 367 | #[track_caller] 368 | pub fn player_lock(&self) -> MutexGuard<'_, Player> { 369 | self.ivars() 370 | .player 371 | .get() 372 | .expect("player initialized") 373 | .lock() 374 | .expect("player lock") 375 | } 376 | } 377 | -------------------------------------------------------------------------------- /src/player_view.rs: -------------------------------------------------------------------------------- 1 | use std::cell::{Cell, OnceCell}; 2 | use std::fmt; 3 | use std::sync::{Arc, Mutex, MutexGuard}; 4 | use std::time::Instant; 5 | 6 | use objc2::rc::{Allocated, Retained}; 7 | use objc2::runtime::AnyClass; 8 | use objc2::{define_class, msg_send, sel, ClassType, DefinedClass as _}; 9 | use objc2_core_foundation::CGRect; 10 | use objc2_foundation::{ 11 | MainThreadMarker, NSCoder, NSDate, NSObjectProtocol, NSRunLoop, NSRunLoopCommonModes, NSSet, 12 | NSTimer, 13 | }; 14 | use objc2_quartz_core::{CALayer, CALayerDelegate, CAMetalLayer}; 15 | use objc2_ui_kit::{ 16 | UIEvent, UIKey, UIPress, UIPressPhase, UIPressesEvent, UITouch, UITouchPhase, UIView, 17 | UIViewContentMode, 18 | }; 19 | use ruffle_core::events::{KeyDescriptor, KeyLocation, LogicalKey, MouseButton, PhysicalKey}; 20 | use ruffle_core::{Player, PlayerEvent, ViewportDimensions}; 21 | use ruffle_render_wgpu::backend::WgpuRenderBackend; 22 | use ruffle_render_wgpu::target::SwapChainTarget; 23 | 24 | #[derive(Default)] 25 | pub struct Ivars { 26 | player: OnceCell>>, 27 | timer: OnceCell>, 28 | last_frame_time: Cell>, 29 | } 30 | 31 | impl fmt::Debug for Ivars { 32 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 33 | f.debug_struct("Ivars") 34 | .field("timer", &self.timer) 35 | .field("last_frame_time", &self.last_frame_time) 36 | .finish_non_exhaustive() 37 | } 38 | } 39 | 40 | define_class!( 41 | #[unsafe(super(UIView))] 42 | #[name = "PlayerView"] 43 | #[ivars = Ivars] 44 | #[derive(Debug)] 45 | pub struct PlayerView; 46 | 47 | unsafe impl NSObjectProtocol for PlayerView {} 48 | 49 | /// Initialization. 50 | impl PlayerView { 51 | #[unsafe(method_id(initWithFrame:))] 52 | fn _init_with_frame(this: Allocated, frame: CGRect) -> Retained { 53 | let this = this.set_ivars(Ivars::default()); 54 | let this: Retained = unsafe { msg_send![super(this), initWithFrame: frame] }; 55 | this.init(); 56 | this 57 | } 58 | 59 | #[unsafe(method_id(initWithCoder:))] 60 | fn _init_with_coder(this: Allocated, coder: &NSCoder) -> Retained { 61 | let this = this.set_ivars(Ivars::default()); 62 | let this: Retained = unsafe { msg_send![super(this), initWithCoder: coder] }; 63 | this.init(); 64 | this 65 | } 66 | 67 | #[unsafe(method(layerClass))] 68 | fn layer_class() -> &AnyClass { 69 | CAMetalLayer::class() 70 | } 71 | 72 | #[unsafe(method(timerFire:))] 73 | fn _timer_fire(&self, _timer: &NSTimer) { 74 | self.timer_fire(); 75 | } 76 | } 77 | 78 | /// UIResponder 79 | #[allow(non_snake_case)] 80 | impl PlayerView { 81 | #[unsafe(method(canBecomeFirstResponder))] 82 | fn canBecomeFirstResponder(&self) -> bool { 83 | true 84 | } 85 | 86 | #[unsafe(method(becomeFirstResponder))] 87 | fn becomeFirstResponder(&self) -> bool { 88 | tracing::info!("becomeFirstResponder"); 89 | true 90 | } 91 | 92 | #[unsafe(method(canResignFirstResponder))] 93 | fn canResignFirstResponder(&self) -> bool { 94 | true 95 | } 96 | 97 | #[unsafe(method(resignFirstResponder))] 98 | fn resignFirstResponder(&self) -> bool { 99 | tracing::info!("resignFirstResponder"); 100 | true 101 | } 102 | 103 | #[unsafe(method(touchesBegan:withEvent:))] 104 | fn touchesBegan_withEvent(&self, touches: &NSSet, event: Option<&UIEvent>) { 105 | tracing::trace!("touchesBegan:withEvent:"); 106 | if !self.handle_touches(touches) { 107 | // Forward to super 108 | let _: () = 109 | unsafe { msg_send![super(self), touchesBegan: touches, withEvent: event] }; 110 | } 111 | } 112 | 113 | #[unsafe(method(touchesMoved:withEvent:))] 114 | fn touchesMoved_withEvent(&self, touches: &NSSet, event: Option<&UIEvent>) { 115 | tracing::trace!("touchesMoved:withEvent:"); 116 | if !self.handle_touches(touches) { 117 | // Forward to super 118 | let _: () = 119 | unsafe { msg_send![super(self), touchesMoved: touches, withEvent: event] }; 120 | } 121 | } 122 | 123 | #[unsafe(method(touchesEnded:withEvent:))] 124 | fn touchesEnded_withEvent(&self, touches: &NSSet, event: Option<&UIEvent>) { 125 | tracing::trace!("touchesEnded:withEvent:"); 126 | if !self.handle_touches(touches) { 127 | // Forward to super 128 | let _: () = 129 | unsafe { msg_send![super(self), touchesEnded: touches, withEvent: event] }; 130 | } 131 | } 132 | 133 | #[unsafe(method(touchesCancelled:withEvent:))] 134 | fn touchesCancelled_withEvent(&self, touches: &NSSet, event: Option<&UIEvent>) { 135 | tracing::trace!("touchesCancelled:withEvent:"); 136 | if !self.handle_touches(touches) { 137 | // Forward to super 138 | let _: () = 139 | unsafe { msg_send![super(self), touchesCancelled: touches, withEvent: event] }; 140 | } 141 | } 142 | 143 | #[unsafe(method(pressesBegan:withEvent:))] 144 | fn pressesBegan_withEvent(&self, presses: &NSSet, event: Option<&UIPressesEvent>) { 145 | tracing::trace!("pressesBegan:withEvent:"); 146 | if !self.handle_presses(presses) { 147 | // Forward to super 148 | let _: () = 149 | unsafe { msg_send![super(self), pressesBegan: presses, withEvent: event] }; 150 | } 151 | } 152 | 153 | #[unsafe(method(pressesChanged:withEvent:))] 154 | fn pressesChanged_withEvent( 155 | &self, 156 | presses: &NSSet, 157 | event: Option<&UIPressesEvent>, 158 | ) { 159 | tracing::trace!("pressesChanged:withEvent:"); 160 | if !self.handle_presses(presses) { 161 | // Forward to super 162 | let _: () = 163 | unsafe { msg_send![super(self), pressesChanged: presses, withEvent: event] }; 164 | } 165 | } 166 | 167 | #[unsafe(method(pressesEnded:withEvent:))] 168 | fn pressesEnded_withEvent(&self, presses: &NSSet, event: Option<&UIPressesEvent>) { 169 | tracing::trace!("pressesEnded:withEvent:"); 170 | if !self.handle_presses(presses) { 171 | // Forward to super 172 | let _: () = 173 | unsafe { msg_send![super(self), pressesEnded: presses, withEvent: event] }; 174 | } 175 | } 176 | 177 | #[unsafe(method(pressesCancelled:withEvent:))] 178 | fn pressesCancelled_withEvent( 179 | &self, 180 | presses: &NSSet, 181 | event: Option<&UIPressesEvent>, 182 | ) { 183 | tracing::trace!("pressesCancelled:withEvent:"); 184 | if !self.handle_presses(presses) { 185 | // Forward to super 186 | let _: () = 187 | unsafe { msg_send![super(self), pressesCancelled: presses, withEvent: event] }; 188 | } 189 | } 190 | 191 | #[unsafe(method(remoteControlReceivedWithEvent:))] 192 | fn remoteControlReceivedWithEvent(&self, event: Option<&UIEvent>) { 193 | tracing::info!(subtype = ?event.map(|e| e.subtype()), "remoteControlReceivedWithEvent:"); 194 | } 195 | } 196 | 197 | /// UIView overrides. 198 | #[allow(non_snake_case)] 199 | impl PlayerView { 200 | #[unsafe(method(canBecomeFocused))] 201 | fn canBecomeFocused(&self) -> bool { 202 | tracing::info!("canBecomeFocused"); 203 | true 204 | } 205 | } 206 | 207 | // We implement the layer delegate instead of the usual `drawRect:` and 208 | // `layoutSubviews` methods, since we use a custom `layerClass`, and then 209 | // UIView won't call those methods. 210 | // 211 | // The view is automatically set as the layer's delegate. 212 | unsafe impl CALayerDelegate for PlayerView { 213 | #[unsafe(method(displayLayer:))] 214 | fn _display_layer(&self, _layer: &CALayer) { 215 | self.draw_rect(); 216 | } 217 | 218 | // This is the recommended way to listen for changes to the layer's 219 | // frame. Also tracks changes to the backing scale factor. 220 | // 221 | // It may be called at other times though, so we check the configured 222 | // size in `resize` first to avoid unnecessary work. 223 | #[unsafe(method(layoutSublayersOfLayer:))] 224 | fn _layout_sublayers_of_layer(&self, _layer: &CALayer) { 225 | self.resize(); 226 | } 227 | } 228 | ); 229 | 230 | impl PlayerView { 231 | #[allow(non_snake_case)] 232 | pub fn initWithFrame(this: Allocated, frame_rect: CGRect) -> Retained { 233 | unsafe { msg_send![this, initWithFrame: frame_rect] } 234 | } 235 | 236 | fn init(&self) { 237 | // Ensure that the view calls `drawRect:` after being resized 238 | self.setContentMode(UIViewContentMode::Redraw); 239 | 240 | // Create repeating timer that won't fire until we properly start it 241 | // (because of the high interval). 242 | // 243 | // TODO: Consider running two timers, one to maintain the frame rate, 244 | // and one to update Flash timers. 245 | let timer = unsafe { 246 | NSTimer::timerWithTimeInterval_target_selector_userInfo_repeats( 247 | f64::MAX, 248 | self, 249 | sel!(timerFire:), 250 | None, 251 | true, 252 | ) 253 | }; 254 | // Associate the timer with all run loop modes, so that it runs even 255 | // when live-resizing or mouse dragging the window. 256 | unsafe { NSRunLoop::mainRunLoop().addTimer_forMode(&timer, NSRunLoopCommonModes) }; 257 | self.ivars().timer.set(timer).expect("init timer only once"); 258 | } 259 | 260 | pub fn set_player(&self, player: Arc>) { 261 | // TODO: Use `player.start_time` here to ensure that our deltas are 262 | // correct. 263 | self.ivars().last_frame_time.set(Some(Instant::now())); 264 | self.ivars() 265 | .player 266 | .set(player) 267 | .unwrap_or_else(|_| panic!("only init player once")); 268 | } 269 | 270 | #[track_caller] 271 | pub fn player_lock(&self) -> MutexGuard<'_, Player> { 272 | self.ivars() 273 | .player 274 | .get() 275 | .expect("player initialized") 276 | .lock() 277 | .expect("player lock") 278 | } 279 | 280 | fn resize(&self) { 281 | tracing::info!("resizing to {:?}", self.frame().size); 282 | let new_dimensions = self.viewport_dimensions(); 283 | 284 | let mut player_lock = self.player_lock(); 285 | // Avoid unnecessary resizes 286 | // FIXME: Expose `PartialEq` on `ViewportDimensions`. 287 | let old_dimensions = player_lock.viewport_dimensions(); 288 | if new_dimensions.height != old_dimensions.height 289 | || new_dimensions.width != old_dimensions.width 290 | || new_dimensions.scale_factor != old_dimensions.scale_factor 291 | { 292 | player_lock.set_viewport_dimensions(new_dimensions); 293 | } 294 | } 295 | 296 | fn draw_rect(&self) { 297 | tracing::trace!("drawing"); 298 | // Render if the system asks for it because of a resize, 299 | // or if we asked for it with `setNeedsDisplay`. 300 | self.player_lock().render(); 301 | } 302 | 303 | pub fn viewport_dimensions(&self) -> ViewportDimensions { 304 | let size = self.frame().size; 305 | let scale_factor = self.contentScaleFactor(); 306 | ViewportDimensions { 307 | width: (size.width * scale_factor) as u32, 308 | height: (size.height * scale_factor) as u32, 309 | scale_factor: scale_factor as f64, 310 | } 311 | } 312 | 313 | pub fn create_renderer(&self) -> WgpuRenderBackend { 314 | let layer = self.layer(); 315 | let dimensions = self.viewport_dimensions(); 316 | let layer_ptr = Retained::as_ptr(&layer).cast_mut().cast(); 317 | unsafe { 318 | WgpuRenderBackend::for_window_unsafe( 319 | wgpu::SurfaceTargetUnsafe::CoreAnimationLayer(layer_ptr), 320 | (dimensions.width.max(1), dimensions.height.max(1)), 321 | wgpu::Backends::METAL, 322 | wgpu::PowerPreference::HighPerformance, 323 | ) 324 | .expect("creating renderer") 325 | } 326 | } 327 | 328 | #[track_caller] 329 | pub fn timer(&self) -> &NSTimer { 330 | self.ivars().timer.get().expect("timer initialized") 331 | } 332 | 333 | pub fn start(&self) { 334 | self.player_lock().set_is_playing(true); 335 | self.timer().fire(); 336 | } 337 | 338 | pub fn stop(&self) { 339 | self.player_lock().set_is_playing(false); 340 | // Don't update the timer while we're stopped 341 | self.timer().setFireDate(&NSDate::distantFuture()); 342 | } 343 | 344 | pub fn flush(&self) { 345 | self.player_lock().flush_shared_objects(); 346 | } 347 | 348 | fn timer_fire(&self) { 349 | let last_frame_time = self 350 | .ivars() 351 | .last_frame_time 352 | .get() 353 | .expect("initialized last frame time"); 354 | let new_time = Instant::now(); 355 | let dt = new_time.duration_since(last_frame_time).as_nanos(); 356 | self.ivars().last_frame_time.set(Some(new_time)); 357 | tracing::trace!("timer fire: {:?}", dt as f64 / 1_000_000.0); 358 | 359 | let mut player_lock = self.player_lock(); 360 | 361 | player_lock.tick(dt as f64 / 1_000_000.0); 362 | // FIXME: The instant that `time_til_next_frame` is relative to isn't 363 | // defined, so we have to assume that it's roughly relative to "now". 364 | let next_fire = 365 | NSDate::dateWithTimeIntervalSinceNow(player_lock.time_til_next_frame().as_secs_f64()); 366 | self.timer().setFireDate(&next_fire); 367 | 368 | if player_lock.needs_render() { 369 | self.layer().setNeedsDisplay(); 370 | } 371 | } 372 | 373 | fn handle_touches(&self, touches: &NSSet) -> bool { 374 | let mut player_lock = self.player_lock(); 375 | 376 | // Flash only supports one touch at a time, so we intentially don't set 377 | // `multipleTouchEnabled`, and don't have to do check all touches here. 378 | let touch = touches.anyObject().expect("touches must contain a touch"); 379 | 380 | let point = touch.locationInView(Some(self)); 381 | let scale_factor = self.contentScaleFactor(); 382 | let x = point.x as f64 * scale_factor; 383 | let y = point.y as f64 * scale_factor; 384 | // We don't know which button was pressed in UIKit. 385 | let button = MouseButton::Left; 386 | 387 | let event_handled = match touch.phase() { 388 | UITouchPhase::Began => { 389 | player_lock.set_mouse_in_stage(true); 390 | player_lock.handle_event(PlayerEvent::MouseDown { 391 | x, 392 | y, 393 | button, 394 | // We always know whether a click was a double click or not. 395 | index: Some(touch.tapCount()), 396 | }) 397 | } 398 | UITouchPhase::Moved => { 399 | player_lock.set_mouse_in_stage(true); 400 | player_lock.handle_event(PlayerEvent::MouseMove { x, y }) 401 | } 402 | UITouchPhase::Ended => { 403 | player_lock.set_mouse_in_stage(true); 404 | let up_handled = player_lock.handle_event(PlayerEvent::MouseUp { x, y, button }); 405 | player_lock.set_mouse_in_stage(false); 406 | up_handled || player_lock.handle_event(PlayerEvent::MouseLeave) 407 | } 408 | UITouchPhase::Cancelled => { 409 | player_lock.set_mouse_in_stage(true); 410 | player_lock.handle_event(PlayerEvent::MouseLeave) 411 | } 412 | _ => return false, 413 | }; 414 | 415 | if player_lock.needs_render() { 416 | self.layer().setNeedsDisplay(); 417 | } 418 | 419 | event_handled 420 | } 421 | 422 | fn handle_presses(&self, presses: &NSSet) -> bool { 423 | let mtm = MainThreadMarker::from(self); 424 | let mut player_lock = self.player_lock(); 425 | 426 | let mut handled = false; 427 | for press in presses { 428 | // TODO: Consider press.r#type() 429 | let Some(key) = press.key(mtm) else { 430 | continue; 431 | }; 432 | let key = KeyDescriptor { 433 | physical_key: key_to_physical(&key), 434 | logical_key: key_to_logical(&key), 435 | key_location: KeyLocation::Standard, 436 | }; 437 | 438 | let event = match press.phase() { 439 | UIPressPhase::Began => PlayerEvent::KeyDown { key }, 440 | // FIXME: Forward event cancellation 441 | UIPressPhase::Ended | UIPressPhase::Cancelled => PlayerEvent::KeyUp { key }, 442 | _ => continue, 443 | }; 444 | 445 | handled |= player_lock.handle_event(event); 446 | } 447 | handled 448 | } 449 | } 450 | 451 | impl Drop for PlayerView { 452 | fn drop(&mut self) { 453 | // Invalidate the timer if it was registered 454 | if let Some(timer) = self.ivars().timer.get() { 455 | timer.invalidate(); 456 | } 457 | } 458 | } 459 | 460 | fn key_to_physical(key: &UIKey) -> PhysicalKey { 461 | use objc2_ui_kit::UIKeyboardHIDUsage as UI; 462 | match key.keyCode() { 463 | UI::KeyboardA => PhysicalKey::KeyA, 464 | UI::KeyboardB => PhysicalKey::KeyB, 465 | UI::KeyboardC => PhysicalKey::KeyC, 466 | UI::KeyboardD => PhysicalKey::KeyD, 467 | UI::KeyboardE => PhysicalKey::KeyE, 468 | UI::KeyboardF => PhysicalKey::KeyF, 469 | UI::KeyboardG => PhysicalKey::KeyG, 470 | UI::KeyboardH => PhysicalKey::KeyH, 471 | UI::KeyboardI => PhysicalKey::KeyI, 472 | UI::KeyboardJ => PhysicalKey::KeyJ, 473 | UI::KeyboardK => PhysicalKey::KeyK, 474 | UI::KeyboardL => PhysicalKey::KeyL, 475 | UI::KeyboardM => PhysicalKey::KeyM, 476 | UI::KeyboardN => PhysicalKey::KeyN, 477 | UI::KeyboardO => PhysicalKey::KeyO, 478 | UI::KeyboardP => PhysicalKey::KeyP, 479 | UI::KeyboardQ => PhysicalKey::KeyQ, 480 | UI::KeyboardR => PhysicalKey::KeyR, 481 | UI::KeyboardS => PhysicalKey::KeyS, 482 | UI::KeyboardT => PhysicalKey::KeyT, 483 | UI::KeyboardU => PhysicalKey::KeyU, 484 | UI::KeyboardV => PhysicalKey::KeyV, 485 | UI::KeyboardW => PhysicalKey::KeyW, 486 | UI::KeyboardX => PhysicalKey::KeyX, 487 | UI::KeyboardY => PhysicalKey::KeyY, 488 | UI::KeyboardZ => PhysicalKey::KeyZ, 489 | UI::Keyboard1 => PhysicalKey::Digit1, 490 | UI::Keyboard2 => PhysicalKey::Digit2, 491 | UI::Keyboard3 => PhysicalKey::Digit3, 492 | UI::Keyboard4 => PhysicalKey::Digit4, 493 | UI::Keyboard5 => PhysicalKey::Digit5, 494 | UI::Keyboard6 => PhysicalKey::Digit6, 495 | UI::Keyboard7 => PhysicalKey::Digit7, 496 | UI::Keyboard8 => PhysicalKey::Digit8, 497 | UI::Keyboard9 => PhysicalKey::Digit9, 498 | UI::Keyboard0 => PhysicalKey::Digit0, 499 | UI::KeyboardReturnOrEnter => PhysicalKey::Enter, 500 | UI::KeyboardEscape => PhysicalKey::Escape, 501 | UI::KeyboardDeleteOrBackspace => PhysicalKey::Delete, 502 | UI::KeyboardTab => PhysicalKey::Tab, 503 | UI::KeyboardSpacebar => PhysicalKey::Space, 504 | UI::KeyboardHyphen => PhysicalKey::Minus, 505 | UI::KeyboardEqualSign => PhysicalKey::Equal, 506 | UI::KeyboardOpenBracket => PhysicalKey::BracketLeft, 507 | UI::KeyboardCloseBracket => PhysicalKey::BracketRight, 508 | UI::KeyboardBackslash => PhysicalKey::Backslash, 509 | UI::KeyboardSemicolon => PhysicalKey::Semicolon, 510 | UI::KeyboardQuote => PhysicalKey::Quote, 511 | UI::KeyboardGraveAccentAndTilde => PhysicalKey::Backquote, 512 | UI::KeyboardComma => PhysicalKey::Comma, 513 | UI::KeyboardPeriod => PhysicalKey::Period, 514 | UI::KeyboardSlash => PhysicalKey::Slash, 515 | UI::KeyboardCapsLock => PhysicalKey::CapsLock, 516 | UI::KeyboardF1 => PhysicalKey::F1, 517 | UI::KeyboardF2 => PhysicalKey::F2, 518 | UI::KeyboardF3 => PhysicalKey::F3, 519 | UI::KeyboardF4 => PhysicalKey::F4, 520 | UI::KeyboardF5 => PhysicalKey::F5, 521 | UI::KeyboardF6 => PhysicalKey::F6, 522 | UI::KeyboardF7 => PhysicalKey::F7, 523 | UI::KeyboardF8 => PhysicalKey::F8, 524 | UI::KeyboardF9 => PhysicalKey::F9, 525 | UI::KeyboardF10 => PhysicalKey::F10, 526 | UI::KeyboardF11 => PhysicalKey::F11, 527 | UI::KeyboardF12 => PhysicalKey::F12, 528 | UI::KeyboardScrollLock => PhysicalKey::ScrollLock, 529 | UI::KeyboardPause => PhysicalKey::Pause, 530 | UI::KeyboardInsert => PhysicalKey::Insert, 531 | UI::KeyboardHome => PhysicalKey::Home, 532 | UI::KeyboardPageUp => PhysicalKey::PageUp, 533 | UI::KeyboardEnd => PhysicalKey::End, 534 | UI::KeyboardPageDown => PhysicalKey::PageDown, 535 | UI::KeyboardRightArrow => PhysicalKey::ArrowRight, 536 | UI::KeyboardLeftArrow => PhysicalKey::ArrowLeft, 537 | UI::KeyboardDownArrow => PhysicalKey::ArrowDown, 538 | UI::KeyboardUpArrow => PhysicalKey::ArrowUp, 539 | UI::KeypadNumLock => PhysicalKey::NumLock, 540 | UI::KeypadSlash => PhysicalKey::NumpadDivide, 541 | UI::KeypadAsterisk => PhysicalKey::NumpadMultiply, 542 | UI::KeypadHyphen => PhysicalKey::NumpadSubtract, 543 | UI::KeypadPlus => PhysicalKey::NumpadAdd, 544 | UI::KeypadEnter => PhysicalKey::NumpadEnter, 545 | UI::Keypad1 => PhysicalKey::Numpad1, 546 | UI::Keypad2 => PhysicalKey::Numpad2, 547 | UI::Keypad3 => PhysicalKey::Numpad3, 548 | UI::Keypad4 => PhysicalKey::Numpad4, 549 | UI::Keypad5 => PhysicalKey::Numpad5, 550 | UI::Keypad6 => PhysicalKey::Numpad6, 551 | UI::Keypad7 => PhysicalKey::Numpad7, 552 | UI::Keypad8 => PhysicalKey::Numpad8, 553 | UI::Keypad9 => PhysicalKey::Numpad9, 554 | UI::Keypad0 => PhysicalKey::Numpad0, 555 | UI::KeypadPeriod => PhysicalKey::NumpadComma, // Maybe? 556 | UI::KeyboardNonUSBackslash => PhysicalKey::IntlBackslash, 557 | UI::KeypadEqualSign => PhysicalKey::Equal, 558 | UI::KeyboardF13 => PhysicalKey::F13, 559 | UI::KeyboardF14 => PhysicalKey::F14, 560 | UI::KeyboardF15 => PhysicalKey::F15, 561 | UI::KeyboardF16 => PhysicalKey::F16, 562 | UI::KeyboardF17 => PhysicalKey::F17, 563 | UI::KeyboardF18 => PhysicalKey::F18, 564 | UI::KeyboardF19 => PhysicalKey::F19, 565 | UI::KeyboardF20 => PhysicalKey::F20, 566 | UI::KeyboardF21 => PhysicalKey::F21, 567 | UI::KeyboardF22 => PhysicalKey::F22, 568 | UI::KeyboardF23 => PhysicalKey::F23, 569 | UI::KeyboardF24 => PhysicalKey::F24, 570 | UI::KeypadComma => PhysicalKey::Comma, 571 | UI::KeypadEqualSignAS400 => PhysicalKey::Equal, 572 | UI::KeyboardReturn => PhysicalKey::Enter, 573 | UI::KeyboardLeftControl => PhysicalKey::ControlLeft, 574 | UI::KeyboardLeftShift => PhysicalKey::ShiftLeft, 575 | UI::KeyboardLeftAlt => PhysicalKey::AltLeft, 576 | UI::KeyboardLeftGUI => PhysicalKey::SuperLeft, 577 | UI::KeyboardRightControl => PhysicalKey::ControlRight, 578 | UI::KeyboardRightShift => PhysicalKey::ShiftRight, 579 | UI::KeyboardRightAlt => PhysicalKey::AltRight, 580 | UI::KeyboardRightGUI => PhysicalKey::SuperRight, 581 | code => { 582 | tracing::warn!("unhandled physical key {code:?}"); 583 | PhysicalKey::Unknown 584 | } 585 | } 586 | } 587 | 588 | fn key_to_logical(key: &UIKey) -> LogicalKey { 589 | // FIXME: `last()` is functionally equivalent in most cases, but 590 | // we may want to do something else here. 591 | let key_char = key.charactersIgnoringModifiers().to_string().chars().last(); 592 | 593 | if let Some(key_char) = key_char { 594 | LogicalKey::Character(key_char) 595 | } else { 596 | tracing::warn!("unhandled logical key {key:?}"); 597 | LogicalKey::Unknown 598 | } 599 | } 600 | -------------------------------------------------------------------------------- /src/scene_delegate.rs: -------------------------------------------------------------------------------- 1 | use std::cell::Cell; 2 | 3 | use objc2::rc::{Allocated, Retained}; 4 | use objc2::{define_class, msg_send, DefinedClass as _, MainThreadOnly, Message}; 5 | use objc2_foundation::{NSObjectProtocol, NSSet, NSURL}; 6 | use objc2_ui_kit::{ 7 | UINavigationController, UIOpenURLContext, UIResponder, UIScene, UISceneConnectionOptions, 8 | UISceneDelegate, UISceneSession, UIWindow, UIWindowScene, UIWindowSceneDelegate, 9 | }; 10 | 11 | use crate::{storage, PlayerController}; 12 | 13 | pub struct Ivars { 14 | window: Cell>>, 15 | } 16 | 17 | define_class!( 18 | #[unsafe(super(UIResponder))] 19 | #[name = "SceneDelegate"] 20 | #[ivars = Ivars] 21 | pub struct SceneDelegate; 22 | 23 | /// Called by UIStoryboard 24 | impl SceneDelegate { 25 | #[unsafe(method_id(init))] 26 | fn init(this: Allocated) -> Retained { 27 | tracing::info!("init scene"); 28 | let this = this.set_ivars(Ivars { 29 | window: Cell::new(None), 30 | }); 31 | unsafe { msg_send![super(this), init] } 32 | } 33 | } 34 | 35 | unsafe impl NSObjectProtocol for SceneDelegate {} 36 | 37 | #[allow(non_snake_case)] 38 | unsafe impl UISceneDelegate for SceneDelegate { 39 | #[unsafe(method(scene:willConnectToSession:options:))] 40 | fn scene_willConnectToSession_options( 41 | &self, 42 | _scene: &UIScene, 43 | _session: &UISceneSession, 44 | _connection_options: &UISceneConnectionOptions, 45 | ) { 46 | tracing::info!("scene:willConnectToSession:options:"); 47 | // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. 48 | // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. 49 | // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). 50 | } 51 | 52 | #[unsafe(method(sceneDidDisconnect:))] 53 | fn sceneDidDisconnect(&self, _scene: &UIScene) { 54 | tracing::info!("sceneDidDisconnect:"); 55 | // Called as the scene is being released by the system. 56 | // This occurs shortly after the scene enters the background, or when its session is discarded. 57 | // Release any resources associated with this scene that can be re-created the next time the scene connects. 58 | // The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead). 59 | } 60 | 61 | #[unsafe(method(sceneDidBecomeActive:))] 62 | fn sceneDidBecomeActive(&self, scene: &UIScene) { 63 | tracing::info!("sceneDidBecomeActive:"); 64 | 65 | // Restart playing. 66 | let nav = get_navigation_controller(scene); 67 | for controller in nav.viewControllers() { 68 | if let Some(controller) = controller.downcast_ref::() { 69 | controller.view().start(); 70 | } 71 | } 72 | } 73 | 74 | #[unsafe(method(sceneWillResignActive:))] 75 | fn sceneWillResignActive(&self, scene: &UIScene) { 76 | tracing::info!("sceneWillResignActive:"); 77 | 78 | // Stop playing. 79 | // TODO: Is this the best place to do this? 80 | let nav = get_navigation_controller(scene); 81 | for controller in nav.viewControllers() { 82 | if let Some(controller) = controller.downcast_ref::() { 83 | controller.view().stop(); 84 | } 85 | } 86 | } 87 | 88 | #[unsafe(method(sceneWillEnterForeground:))] 89 | fn sceneWillEnterForeground(&self, _scene: &UIScene) { 90 | tracing::info!("sceneWillEnterForegrounds:"); 91 | } 92 | 93 | #[unsafe(method(sceneDidEnterBackground:))] 94 | fn sceneDidEnterBackground(&self, scene: &UIScene) { 95 | tracing::info!("sceneDidEnterBackground:"); 96 | 97 | // Flush when going to the background. 98 | let nav = get_navigation_controller(scene); 99 | for controller in nav.viewControllers() { 100 | if let Some(controller) = controller.downcast_ref::() { 101 | controller.view().flush(); 102 | } 103 | } 104 | } 105 | 106 | #[unsafe(method(scene:openURLContexts:))] 107 | fn scene_openURLContexts(&self, scene: &UIScene, url_contexts: &NSSet) { 108 | tracing::info!(?url_contexts, "scene:openURLContexts:"); 109 | 110 | for context in url_contexts { 111 | let url = context.URL(); 112 | 113 | // TODO: Do something else when this is set? 114 | let _ = context.options().openInPlace(); 115 | 116 | if storage::movie_from_url(&url).is_none() { 117 | storage::add_movie(&url); 118 | } else { 119 | // This is intentional, when the user opens URLs from outside 120 | // the app, we only want to add them to the library if not 121 | // already there. 122 | tracing::debug!("did not add existing movie {url:?}"); 123 | } 124 | } 125 | 126 | if url_contexts.count() == 1 { 127 | let context = url_contexts.anyObject().unwrap(); 128 | let url = context.URL(); 129 | // Start playing this one immediately 130 | play_url(scene, &url); 131 | } 132 | } 133 | } 134 | 135 | #[allow(non_snake_case)] 136 | unsafe impl UIWindowSceneDelegate for SceneDelegate { 137 | #[unsafe(method_id(window))] 138 | fn window(&self) -> Option> { 139 | let window = self.ivars().window.take(); 140 | self.ivars().window.set(window.clone()); 141 | window 142 | } 143 | 144 | #[unsafe(method(setWindow:))] 145 | fn setWindow(&self, window: Option<&UIWindow>) { 146 | self.ivars().window.set(window.map(|w| w.retain())); 147 | } 148 | } 149 | ); 150 | 151 | impl Drop for SceneDelegate { 152 | fn drop(&mut self) { 153 | tracing::info!("drop scene"); 154 | } 155 | } 156 | 157 | fn get_navigation_controller(scene: &UIScene) -> Retained { 158 | let scene = scene.downcast_ref::().unwrap(); 159 | // FIXME: Assumes single-window 160 | let window = scene.windows().firstObject().unwrap(); 161 | let root = window.rootViewController().unwrap(); 162 | root.downcast::().unwrap() 163 | } 164 | 165 | fn play_url(scene: &UIScene, url: &NSURL) -> Option<()> { 166 | let _span = tracing::info_span!("play_url").entered(); 167 | 168 | let nav = get_navigation_controller(scene); 169 | 170 | // TODO: Investigate if we really want to do this? 171 | nav.popToRootViewControllerAnimated(true); 172 | 173 | let movie = storage::movie_from_url(url).expect("we just added the movie"); 174 | let player_controller = PlayerController::empty(scene.mtm()); 175 | player_controller.setup_movie(&movie); 176 | nav.pushViewController_animated(&player_controller, true); 177 | 178 | Some(()) 179 | } 180 | -------------------------------------------------------------------------------- /src/storage.rs: -------------------------------------------------------------------------------- 1 | //! Interface for storing user data with CoreData. 2 | //! 3 | //! Overview: 4 | //! - Movie 5 | //! - link 6 | //! - userOptions 7 | //! - movieData 8 | //! - key/value 9 | //! 10 | //! External methods will be filled in dynamically by CoreData. 11 | //! 12 | //! TODO: 13 | //! - Figure out sync failures. 14 | //! - Better error handling. 15 | //! - Use `define_class!` once we can create ivars with specific names in 16 | //! that (required for NSManagedObject to work). 17 | //! 18 | //! To generate data model interface to compare with, modify .xcdatamodeld and 19 | //! set codegen = Class definition on every entity. Then run: 20 | //! /Applications/Xcode.app/Contents/Developer/usr/bin/momc --action generate ./Ruffle.xcdatamodeld storage 21 | #![allow(non_snake_case)] 22 | 23 | use std::ops::Deref; 24 | use std::path::{Path, PathBuf}; 25 | use std::ptr::NonNull; 26 | use std::sync::OnceLock; 27 | 28 | use block2::RcBlock; 29 | use objc2::encode::{Encoding, RefEncode}; 30 | use objc2::rc::{Allocated, Retained}; 31 | use objc2::runtime::{AnyClass, ClassBuilder}; 32 | use objc2::{extern_conformance, extern_methods, msg_send, AllocAnyThread, ClassType, Message}; 33 | use objc2_core_data::{ 34 | NSFetchRequest, NSFetchRequestResult, NSFetchedResultsController, NSManagedObject, 35 | NSManagedObjectContext, NSPersistentContainer, NSPersistentStoreDescription, 36 | }; 37 | use objc2_foundation::{ 38 | ns_string, NSArray, NSData, NSError, NSObject, NSObjectProtocol, NSSet, NSSortDescriptor, 39 | NSString, NSURL, 40 | }; 41 | use ruffle_core::backend::storage::StorageBackend; 42 | use ruffle_frontend_utils::bundle::source::BundleSourceError; 43 | use ruffle_frontend_utils::bundle::{Bundle, BundleError}; 44 | use ruffle_frontend_utils::content::PlayingContent; 45 | use ruffle_frontend_utils::player_options::PlayerOptions; 46 | use url::Url; 47 | 48 | /// The data relevant for an SWF movie / a Ruffle Bundle. 49 | #[repr(transparent)] 50 | #[derive(Debug)] 51 | pub struct Movie { 52 | superclass: NSManagedObject, 53 | } 54 | 55 | unsafe impl RefEncode for Movie { 56 | const ENCODING_REF: Encoding = NSManagedObject::ENCODING_REF; 57 | } 58 | 59 | unsafe impl Message for Movie {} 60 | 61 | impl Deref for Movie { 62 | type Target = NSManagedObject; 63 | 64 | fn deref(&self) -> &Self::Target { 65 | &self.superclass 66 | } 67 | } 68 | 69 | extern_conformance!( 70 | // HACK 71 | unsafe impl NSObjectProtocol for Movie {} 72 | ); 73 | 74 | extern_conformance!( 75 | // HACK 76 | unsafe impl NSFetchRequestResult for Movie {} 77 | ); 78 | 79 | impl Movie { 80 | pub fn class() -> &'static AnyClass { 81 | static CLS: OnceLock<&'static AnyClass> = OnceLock::new(); 82 | 83 | CLS.get_or_init(|| { 84 | let mut builder = ClassBuilder::new(c"Movie", NSManagedObject::class()).unwrap(); 85 | 86 | // FIXME: Deallocation of these in `dealloc`. 87 | builder.add_ivar::<*mut NSURL>(c"link"); 88 | builder.add_ivar::<*mut NSString>(c"cachedName"); 89 | builder.add_ivar::<*mut NSData>(c"userOptions"); 90 | builder.add_ivar::<*mut NSSet>(c"movieData"); 91 | 92 | builder.register() 93 | }) 94 | } 95 | 96 | // NSManagedObject initializers. 97 | 98 | fn initWithContext(this: Allocated, moc: &NSManagedObjectContext) -> Retained { 99 | unsafe { msg_send![this, initWithContext: moc] } 100 | } 101 | 102 | fn fetchRequest() -> Retained> { 103 | unsafe { msg_send![Self::class(), fetchRequest] } 104 | } 105 | 106 | // Properties 107 | extern_methods!( 108 | /// Reference/bookmark to a Ruffle Bundle or SWF. 109 | /// - Either a bookmarked link to the actual bundle/SWF stored on user's device. 110 | /// - Or http/https link to externally stored bundle/SWF. 111 | /// 112 | /// TODO: Store bookmark data internally. 113 | #[unsafe(method(link))] 114 | pub fn link(&self) -> Retained; 115 | 116 | #[unsafe(method(setLink:))] 117 | pub fn setLink(&self, value: &NSURL); 118 | 119 | /// A cached value of the name of the bundle/SWF. Allows us to avoid 120 | /// reading the link when displaying the list of movies. 121 | #[unsafe(method(cachedName))] 122 | pub fn cachedName(&self) -> Retained; 123 | 124 | #[unsafe(method(setCachedName:))] 125 | pub fn setCachedName(&self, value: &NSString); 126 | 127 | /// Any user-specified settings (overrides the Ruffle Bundle's preconfigured settings). 128 | #[unsafe(method(userOptions))] 129 | fn _userOptions(&self) -> Retained; 130 | 131 | #[unsafe(method(setUserOptions:))] 132 | fn _setUserOptions(&self, value: &NSData); 133 | 134 | /// Data the SWF itself may have stored (the `.sol` key-value store). 135 | #[unsafe(method(movieData))] 136 | pub fn movieData(&self) -> Retained>; 137 | 138 | #[unsafe(method(setMovieData:))] 139 | pub fn setMovieData(&self, values: &NSSet); 140 | ); 141 | 142 | pub fn user_options(&self) -> PlayerOptions { 143 | // TODO: Convert from binary data in _userOptions. 144 | // Maybe using serde? 145 | PlayerOptions::default() 146 | } 147 | 148 | pub fn set_user_options(&self, _options: &PlayerOptions) { 149 | // TODO: Convert to binary data. 150 | self._setUserOptions(&NSData::with_bytes(b"{}")); 151 | } 152 | 153 | // Perhaps: `cachedName`, to allow easily finding relevant settings for an SWF in case the user deleted? 154 | 155 | // Generated accessors 156 | extern_methods!( 157 | #[unsafe(method(addMovieDataObject:))] 158 | pub fn addMovieDataObject(&self, value: &MovieData); 159 | 160 | #[unsafe(method(removeMovieDataObject:))] 161 | pub fn removeMovieDataObject(&self, value: &MovieData); 162 | 163 | #[unsafe(method(addMovieData:))] 164 | pub fn addMovieData(&self, values: &NSSet); 165 | 166 | #[unsafe(method(removeMovieData:))] 167 | pub fn removeMovieData(&self, values: &NSSet); 168 | ); 169 | } 170 | 171 | /// Key/value pairs of data that the movie itself wants to store (.sol). 172 | /// 173 | /// Intended invariant: Keys are unique. 174 | #[repr(transparent)] 175 | #[derive(Debug)] 176 | pub struct MovieData { 177 | superclass: NSManagedObject, 178 | } 179 | 180 | unsafe impl RefEncode for MovieData { 181 | const ENCODING_REF: Encoding = NSManagedObject::ENCODING_REF; 182 | } 183 | 184 | unsafe impl Message for MovieData {} 185 | 186 | impl Deref for MovieData { 187 | type Target = NSManagedObject; 188 | 189 | fn deref(&self) -> &Self::Target { 190 | &self.superclass 191 | } 192 | } 193 | 194 | impl MovieData { 195 | pub fn class() -> &'static AnyClass { 196 | static CLS: OnceLock<&'static AnyClass> = OnceLock::new(); 197 | 198 | CLS.get_or_init(|| { 199 | let mut builder = ClassBuilder::new(c"MovieData", NSManagedObject::class()).unwrap(); 200 | 201 | // FIXME: Deallocation of these in `dealloc`. 202 | builder.add_ivar::<*mut NSString>(c"key"); 203 | builder.add_ivar::<*mut NSData>(c"value"); 204 | 205 | builder.register() 206 | }) 207 | } 208 | 209 | // NSManagedObject initializers. 210 | 211 | fn initWithContext(this: Allocated, moc: &NSManagedObjectContext) -> Retained { 212 | unsafe { msg_send![this, initWithContext: moc] } 213 | } 214 | 215 | // Properties 216 | extern_methods!( 217 | #[unsafe(method(key))] 218 | pub fn key(&self) -> Retained; 219 | 220 | #[unsafe(method(setKey:))] 221 | pub fn setKey(&self, value: &NSString); 222 | 223 | #[unsafe(method(value))] 224 | pub fn value(&self) -> Retained; 225 | 226 | #[unsafe(method(setValue:))] 227 | pub fn setValue(&self, value: &NSData); 228 | 229 | #[unsafe(method(movie))] 230 | pub fn movie(&self) -> Retained; 231 | 232 | #[unsafe(method(setMovie:))] 233 | pub fn setMovie(&self, value: &Movie); 234 | ); 235 | } 236 | 237 | #[derive(Debug, Clone)] 238 | pub struct MovieStorageBackend { 239 | pub movie: Retained, 240 | } 241 | 242 | impl MovieStorageBackend { 243 | fn lookup_data(&self, key: &NSString) -> Option> { 244 | // TODO: Do this lookup on the CoreData model directly? 245 | // Maybe using NSPredicate? 246 | self.movie 247 | .movieData() 248 | .iter() 249 | .find(|data| &*data.key() == key) 250 | } 251 | } 252 | 253 | impl StorageBackend for MovieStorageBackend { 254 | fn get(&self, name: &str) -> Option> { 255 | let key = NSString::from_str(name); 256 | let data = self.lookup_data(&key)?; 257 | Some(data.value().to_vec()) 258 | } 259 | 260 | fn put(&mut self, name: &str, value: &[u8]) -> bool { 261 | let key = NSString::from_str(name); 262 | let value = NSData::with_bytes(value); 263 | if let Some(existing) = self.lookup_data(&key) { 264 | existing.setValue(&value); 265 | } else { 266 | let data = unsafe { msg_send![MovieData::class(), alloc] }; 267 | let data = MovieData::initWithContext(data, unsafe { &container().viewContext() }); 268 | data.setKey(&key); 269 | data.setValue(&value); 270 | self.movie.addMovieDataObject(&data); 271 | } 272 | 273 | // Flush changes to disk. 274 | match unsafe { container().viewContext().save() } { 275 | Ok(()) => true, 276 | Err(err) => { 277 | eprintln!("failed saving key {name:?}: {err}"); 278 | false 279 | } 280 | } 281 | } 282 | 283 | fn remove_key(&mut self, name: &str) { 284 | let key = NSString::from_str(name); 285 | if let Some(existing) = self.lookup_data(&key) { 286 | unsafe { container().viewContext().deleteObject(&existing) }; 287 | } 288 | 289 | // Flush changes to disk. 290 | unsafe { container().viewContext().save() }.unwrap_or_else(|err| { 291 | eprintln!("failed removing key {name:?}: {err}"); 292 | }) 293 | } 294 | } 295 | 296 | static PERSISTENT: OnceLock> = OnceLock::new(); 297 | 298 | pub fn setup() { 299 | let persistent = PERSISTENT.get_or_init(|| unsafe { 300 | NSPersistentContainer::persistentContainerWithName(ns_string!("Ruffle")) 301 | }); 302 | 303 | let block = RcBlock::new( 304 | |descriptor: NonNull, err: *mut NSError| { 305 | if let Some(err) = unsafe { err.as_ref() } { 306 | panic!("failed loading: {err}"); 307 | } 308 | let descriptor = unsafe { descriptor.as_ref() }; 309 | tracing::info!("loading {descriptor:?}"); 310 | }, 311 | ); 312 | unsafe { persistent.loadPersistentStoresWithCompletionHandler(&block) }; 313 | 314 | tracing::info!("finished storage setup"); 315 | } 316 | 317 | fn container() -> &'static NSPersistentContainer { 318 | PERSISTENT 319 | .get() 320 | .expect("NSPersistentContainer must be initialized") 321 | } 322 | 323 | pub struct SecurityScopedResource { 324 | url: Retained, 325 | } 326 | 327 | impl SecurityScopedResource { 328 | pub fn access(url: &NSURL) -> Option { 329 | if unsafe { url.startAccessingSecurityScopedResource() } { 330 | Some(Self { url: url.retain() }) 331 | } else { 332 | None 333 | } 334 | } 335 | } 336 | 337 | impl Drop for SecurityScopedResource { 338 | fn drop(&mut self) { 339 | unsafe { self.url.stopAccessingSecurityScopedResource() }; 340 | } 341 | } 342 | 343 | fn url_to_path(url: &NSURL) -> PathBuf { 344 | // TODO: Use fileSystemRepresentation? 345 | let path = url.filePathURL().unwrap().path().unwrap(); 346 | PathBuf::from(path.to_string()) 347 | } 348 | 349 | pub fn get_playing_content(url: &NSURL) -> PlayingContent { 350 | if !url.isFileURL() { 351 | let s = url.absoluteString().unwrap().to_string(); 352 | let url = Url::parse(&s).unwrap(); 353 | return PlayingContent::DirectFile(url); 354 | } 355 | 356 | // Ensure we are authorized to read the bundle contents. 357 | let _access = SecurityScopedResource::access(url) 358 | .unwrap_or_else(|| panic!("failed accessing NSURL: {url:?}")); 359 | 360 | match Bundle::from_path(url_to_path(&url)) { 361 | Ok(bundle) => { 362 | if bundle.warnings().is_empty() { 363 | tracing::info!("opening bundle at {url:?}"); 364 | } else { 365 | // TODO: Show warnings to user (toast?) 366 | tracing::warn!("opening bundle at {url:?} with warnings"); 367 | for warning in bundle.warnings() { 368 | tracing::warn!("{warning}"); 369 | } 370 | } 371 | 372 | let s = url 373 | .filePathURL() 374 | .unwrap() 375 | .absoluteString() 376 | .unwrap() 377 | .to_string(); 378 | PlayingContent::Bundle(Url::parse(&s).unwrap(), Box::new(bundle)) 379 | } 380 | Err(BundleError::BundleDoesntExist) 381 | | Err(BundleError::InvalidSource(BundleSourceError::UnknownSource)) => { 382 | // Open it as a swf - this likely isn't a bundle at all 383 | let s = url 384 | .filePathURL() 385 | .unwrap() 386 | .absoluteString() 387 | .unwrap() 388 | .to_string(); 389 | PlayingContent::DirectFile(Url::parse(&s).unwrap()) 390 | } 391 | Err(e) => panic!("failed opening bundle {url:?}: {e}"), 392 | } 393 | } 394 | 395 | /// The returned movie should only be relied upon in `scene_delegate::play_url`. 396 | pub fn movie_from_url(url: &NSURL) -> Option> { 397 | // The canonical URL in our DB is a file reference URL. 398 | let file_url = url.fileReferenceURL(); 399 | let url = if url.isFileURL() { 400 | file_url.as_deref().unwrap() 401 | } else { 402 | url 403 | }; 404 | 405 | let request: Retained = unsafe { msg_send![Movie::class(), fetchRequest] }; 406 | let movies = unsafe { 407 | container() 408 | .viewContext() 409 | .executeFetchRequest_error(&request) 410 | } 411 | .unwrap_or_else(|err| panic!("failed loading movies: {err}")); 412 | for movie in movies { 413 | let movie = movie.downcast::().unwrap(); 414 | assert!(movie.isKindOfClass(Movie::class())); 415 | let movie = unsafe { Retained::cast_unchecked::(movie) }; 416 | if &*movie.link() == url { 417 | return Some(movie); 418 | } 419 | } 420 | None 421 | } 422 | 423 | pub fn add_movie(url: &NSURL) { 424 | // The canonical URL in our DB is a file reference URL. 425 | let file_url = url.fileReferenceURL(); 426 | let url = if url.isFileURL() { 427 | file_url.as_deref().unwrap() 428 | } else { 429 | url 430 | }; 431 | 432 | let content = get_playing_content(url); 433 | 434 | let movie = unsafe { msg_send![Movie::class(), alloc] }; 435 | let movie = Movie::initWithContext(movie, unsafe { &container().viewContext() }); 436 | movie.setLink(&url); 437 | let name = match content { 438 | PlayingContent::Bundle(_, bundle) => NSString::from_str(&bundle.information().name), 439 | PlayingContent::DirectFile(url) => { 440 | // Try to figure out a reasonable name for the URL. 441 | if let Some(file_stem) = Path::new(url.path()).file_stem() { 442 | NSString::from_str(&file_stem.to_string_lossy()) 443 | } else { 444 | NSString::from_str(&url.host_str().unwrap_or("unknown")) 445 | } 446 | } 447 | }; 448 | movie.setCachedName(&name); 449 | movie.set_user_options(&PlayerOptions::default()); 450 | 451 | // Flush changes to disk. 452 | unsafe { container().viewContext().save() }.unwrap_or_else(|err| { 453 | eprintln!("failed adding movie {url:?}: {err}"); 454 | }) 455 | } 456 | 457 | pub fn delete_movie(movie: &Movie) { 458 | unsafe { container().viewContext().deleteObject(movie) }; 459 | 460 | // Flush changes to disk. 461 | unsafe { container().viewContext().save() }.unwrap_or_else(|err| { 462 | eprintln!("failed removing movie {:?}: {err}", movie.link()); 463 | }) 464 | } 465 | 466 | pub fn all_movies() -> Retained> { 467 | let fetch_request = Movie::fetchRequest(); 468 | 469 | let cached_name_descriptor = 470 | NSSortDescriptor::sortDescriptorWithKey_ascending(Some(ns_string!("cachedName")), true); 471 | let link_descriptor = NSSortDescriptor::sortDescriptorWithKey_ascending( 472 | Some(ns_string!("link.lastPathComponent")), 473 | true, 474 | ); 475 | let sort_descriptors = NSArray::from_retained_slice(&[cached_name_descriptor, link_descriptor]); 476 | unsafe { fetch_request.setSortDescriptors(Some(&sort_descriptors)) }; 477 | 478 | unsafe { 479 | NSFetchedResultsController::initWithFetchRequest_managedObjectContext_sectionNameKeyPath_cacheName( 480 | NSFetchedResultsController::alloc(), 481 | &fetch_request, 482 | &container().viewContext(), 483 | None, // No sectioning 484 | None, // No cache 485 | ) 486 | } 487 | } 488 | -------------------------------------------------------------------------------- /src/storyboard_connections.h: -------------------------------------------------------------------------------- 1 | // Helper to allow the storyboard to see the connections that we're making 2 | 3 | #import 4 | 5 | @interface PlayerView: UIView 6 | @end 7 | 8 | @interface PlayerController: UIViewController 9 | @end 10 | 11 | @interface LibraryController : UITableViewController 12 | @property IBOutlet PlayerView* logoView; 13 | - (IBAction) toggleEditing: (UIBarButtonItem*) sender; 14 | - (IBAction) cancelEditItem: (UIStoryboardSegue*) segue; 15 | - (IBAction) saveEditItem: (UIStoryboardSegue*) segue; 16 | - (IBAction) showDocumentPicker: (id) sender; 17 | @end 18 | 19 | @interface EditController : UIViewController 20 | @property IBOutlet UITableView* tableView; 21 | @end 22 | --------------------------------------------------------------------------------