Repository: Sovereign-Engineering/obscuravpn-client Branch: main Commit: e6df5885246b Files: 443 Total size: 1.4 MB Directory structure: gitextract_1ne4upm2/ ├── .editorconfig ├── .envrc ├── .git-blame-ignore-revs ├── .github/ │ └── workflows/ │ └── checks.yml ├── .gitignore ├── .shellcheckrc ├── .swiftformat ├── LICENSE.md ├── README.md ├── android/ │ ├── .gitignore │ ├── .idea/ │ │ ├── .gitignore │ │ ├── .name │ │ ├── AndroidProjectSystem.xml │ │ ├── codeStyles/ │ │ │ ├── Project.xml │ │ │ └── codeStyleConfig.xml │ │ ├── compiler.xml │ │ ├── detekt.xml │ │ ├── deviceManager.xml │ │ ├── dictionaries/ │ │ │ └── project.xml │ │ ├── gradle.xml │ │ ├── kotlinc.xml │ │ ├── ktfmt.xml │ │ ├── migrations.xml │ │ ├── misc.xml │ │ ├── runConfigurations.xml │ │ └── vcs.xml │ ├── app/ │ │ ├── .gitignore │ │ ├── build.gradle.kts │ │ ├── google-services.json │ │ ├── proguard-rules.pro │ │ └── src/ │ │ ├── foss/ │ │ │ └── java/ │ │ │ └── net/ │ │ │ └── obscura/ │ │ │ └── vpnclientapp/ │ │ │ └── BillingFacade.kt │ │ ├── main/ │ │ │ ├── AndroidManifest.xml │ │ │ ├── aidl/ │ │ │ │ └── net/ │ │ │ │ └── obscura/ │ │ │ │ └── vpnclientapp/ │ │ │ │ └── services/ │ │ │ │ └── IObscuraVpnService.aidl │ │ │ ├── assets/ │ │ │ │ └── adi-registration.properties │ │ │ ├── java/ │ │ │ │ └── net/ │ │ │ │ └── obscura/ │ │ │ │ └── vpnclientapp/ │ │ │ │ ├── App.kt │ │ │ │ ├── activities/ │ │ │ │ │ └── MainActivity.kt │ │ │ │ ├── client/ │ │ │ │ │ ├── ErrorCodeException.kt │ │ │ │ │ ├── JsonConfig.kt │ │ │ │ │ ├── ManagerCmd.kt │ │ │ │ │ ├── ManagerCmdOk.kt │ │ │ │ │ ├── ObscuraLibrary.java │ │ │ │ │ └── RustFfi.kt │ │ │ │ ├── helpers/ │ │ │ │ │ └── Process.kt │ │ │ │ ├── preferences/ │ │ │ │ │ └── Preferences.kt │ │ │ │ ├── services/ │ │ │ │ │ ├── ContextExtension.kt │ │ │ │ │ ├── IntentExtension.kt │ │ │ │ │ ├── ObscuraVpnService.kt │ │ │ │ │ └── OsNetworkConfig.kt │ │ │ │ ├── sharing/ │ │ │ │ │ └── DebugArchiveFileProvider.java │ │ │ │ └── ui/ │ │ │ │ ├── BillingModule.kt │ │ │ │ ├── JsonFfiBroadcastReceiver.kt │ │ │ │ ├── NetworkStatusObserver.kt │ │ │ │ ├── ObscuraUI.kt │ │ │ │ ├── ObscuraWebView.kt │ │ │ │ ├── OsStatus.kt │ │ │ │ ├── OsStatusManager.kt │ │ │ │ ├── VpnPermissionRequestManager.kt │ │ │ │ ├── VpnStatusObserver.kt │ │ │ │ └── bridge/ │ │ │ │ ├── WebCmd.kt │ │ │ │ ├── WebCmdBridge.kt │ │ │ │ └── WebCmdHelpers.kt │ │ │ └── res/ │ │ │ ├── drawable/ │ │ │ │ ├── ic_launcher.xml │ │ │ │ ├── icon_about.xml │ │ │ │ ├── icon_account.xml │ │ │ │ ├── icon_connection.xml │ │ │ │ ├── icon_location.xml │ │ │ │ └── icon_settings.xml │ │ │ ├── layout/ │ │ │ │ └── activity_main.xml │ │ │ ├── menu/ │ │ │ │ └── nav_menu.xml │ │ │ ├── values/ │ │ │ │ ├── colors.xml │ │ │ │ ├── ic_launcher_background.xml │ │ │ │ ├── ids.xml │ │ │ │ ├── strings.xml │ │ │ │ └── themes.xml │ │ │ ├── values-night/ │ │ │ │ └── themes.xml │ │ │ └── xml/ │ │ │ ├── backup_rules.xml │ │ │ ├── data_extraction_rules.xml │ │ │ └── debug_archive_file_provider_paths.xml │ │ └── play/ │ │ └── java/ │ │ └── net/ │ │ └── obscura/ │ │ └── vpnclientapp/ │ │ └── BillingFacade.kt │ ├── build.gradle.kts │ ├── buildSrc/ │ │ ├── .gitignore │ │ ├── build.gradle.kts │ │ ├── settings.gradle.kts │ │ └── src/ │ │ └── main/ │ │ └── kotlin/ │ │ └── VersionName.kt │ ├── detekt.yml │ ├── gradle/ │ │ ├── libs.versions.toml │ │ ├── mitm-cache/ │ │ │ └── deps.json │ │ └── wrapper/ │ │ ├── gradle-wrapper.jar │ │ └── gradle-wrapper.properties │ ├── gradle.properties │ ├── gradlew │ ├── gradlew.bat │ ├── lib/ │ │ ├── billing/ │ │ │ ├── build.gradle.kts │ │ │ └── src/ │ │ │ └── main/ │ │ │ ├── AndroidManifest.xml │ │ │ └── java/ │ │ │ └── net/ │ │ │ └── obscura/ │ │ │ └── lib/ │ │ │ └── billing/ │ │ │ ├── BillingConnection.kt │ │ │ └── BillingManager.kt │ │ └── util/ │ │ ├── build.gradle.kts │ │ └── src/ │ │ └── main/ │ │ ├── AndroidManifest.xml │ │ └── java/ │ │ └── net/ │ │ └── obscura/ │ │ └── lib/ │ │ └── util/ │ │ ├── ExternallyTaggedEnumSerializer.kt │ │ ├── ExternallyTaggedEnumVariantSerializer.kt │ │ └── Log.kt │ ├── lint.xml │ └── settings.gradle.kts ├── apple/ │ ├── Configurations/ │ │ ├── .gitignore │ │ ├── Base.xcconfig │ │ ├── Debug-app-network-extension.xcconfig │ │ ├── Debug-app.xcconfig │ │ ├── Debug-system-network-extension.xcconfig │ │ ├── Debug.xcconfig │ │ ├── Release-app-network-extension.xcconfig │ │ ├── Release-app.xcconfig │ │ ├── Release-system-network-extension.xcconfig │ │ ├── Release.xcconfig │ │ ├── app-network-extension.xcconfig │ │ ├── app.xcconfig │ │ ├── bundle-ids.xcconfig │ │ └── system-network-extension.xcconfig │ ├── ExportOptions.plist │ ├── Packet Tunnel Provider/ │ │ ├── Keychain.swift │ │ ├── NetworkSettings.swift │ │ ├── PacketTunnelProvider.swift │ │ ├── RustFfi.swift │ │ └── main.swift │ ├── app-network-extension/ │ │ ├── Info.plist │ │ └── entitlements.entitlements │ ├── cbindgen-apple.toml │ ├── client/ │ │ ├── Assets.xcassets/ │ │ │ ├── AccentColor.colorset/ │ │ │ │ └── Contents.json │ │ │ ├── AppIcon.appiconset/ │ │ │ │ └── Contents.json │ │ │ ├── Contents.json │ │ │ ├── DecoPrimer.imageset/ │ │ │ │ └── Contents.json │ │ │ ├── EmotePrimer.imageset/ │ │ │ │ └── Contents.json │ │ │ ├── MenuBarConnected.imageset/ │ │ │ │ └── Contents.json │ │ │ ├── MenuBarConnectedDown.imageset/ │ │ │ │ └── Contents.json │ │ │ ├── MenuBarConnectedUp.imageset/ │ │ │ │ └── Contents.json │ │ │ ├── MenuBarConnectedUpDown.imageset/ │ │ │ │ └── Contents.json │ │ │ ├── MenuBarConnecting-1.imageset/ │ │ │ │ └── Contents.json │ │ │ ├── MenuBarConnecting-2.imageset/ │ │ │ │ └── Contents.json │ │ │ ├── MenuBarConnecting-3.imageset/ │ │ │ │ └── Contents.json │ │ │ ├── MenuBarDisconnected.imageset/ │ │ │ │ └── Contents.json │ │ │ ├── ObscuraOrange.colorset/ │ │ │ │ └── Contents.json │ │ │ ├── UpdateAvailable.imageset/ │ │ │ │ └── Contents.json │ │ │ └── custom.globe.badge.gearshape.fill.symbolset/ │ │ │ └── Contents.json │ │ ├── Constants.swift │ │ ├── ContentView.swift │ │ ├── DebugBundle+XP.swift │ │ ├── DebugBundle.swift │ │ ├── DebugBundleExtensionInfo.swift │ │ ├── Info.plist │ │ ├── LoginItem.swift │ │ ├── LoopingVideoPlayer.swift │ │ ├── Notifications.swift │ │ ├── OSLogEntryEncodable.swift │ │ ├── OsStatus.swift │ │ ├── Preview Content/ │ │ │ └── Preview Assets.xcassets/ │ │ │ └── Contents.json │ │ ├── ScriptMessageHandlers.swift │ │ ├── StartupStatus.swift │ │ ├── StatusItem/ │ │ │ ├── AccountStatusItem.swift │ │ │ ├── BandwidthStatus.swift │ │ │ ├── MenuItemView.swift │ │ │ ├── ObscuraToggle.swift │ │ │ └── StatusMenu.swift │ │ ├── Store/ │ │ │ ├── Obscura VPN Local.storekit │ │ │ ├── Obscura VPN.storekit │ │ │ ├── Product+Convenience.swift │ │ │ ├── StoreKitListener.swift │ │ │ └── StoreKitModel.swift │ │ ├── Style/ │ │ │ ├── Appearance.swift │ │ │ ├── ConditionallyDisabled.swift │ │ │ ├── HyperlinkButtonStyle.swift │ │ │ └── NoFadeButtonStyle.swift │ │ ├── TunnelProvider.swift │ │ ├── UXKit/ │ │ │ ├── UXImage.swift │ │ │ ├── UXViewController.swift │ │ │ └── UXViewRepresentable.swift │ │ ├── UpdaterDriver+XP.swift │ │ ├── Webviews/ │ │ │ ├── ExternalWebView.swift │ │ │ ├── ObscuraUIIOSWrapperAndTabs.swift │ │ │ ├── ObscuraUIMacOSWrapper.swift │ │ │ ├── ObscuraUIWebView.swift │ │ │ ├── ObscuraUIWebViewMacOSWrapper.swift │ │ │ └── WebviewsController.swift │ │ ├── app_state.swift │ │ ├── client-ios.entitlements │ │ ├── client-macos.entitlements │ │ ├── command.swift │ │ ├── extensions/ │ │ │ └── NEVPNStatus.swift │ │ ├── iOS/ │ │ │ ├── MailDelegate.swift │ │ │ └── iOSClientApp.swift │ │ ├── initNetworkExtension.swift │ │ ├── macOS/ │ │ │ ├── CheckForUpdatesView.swift │ │ │ ├── ClientApp.swift │ │ │ ├── InstallSystemExtensionView.swift │ │ │ ├── RegisterLoginItemView.swift │ │ │ ├── SparkleUpdater.swift │ │ │ ├── UpdateSystemExtensionView.swift │ │ │ └── UpdaterDriver.swift │ │ └── startup.swift │ ├── client.xcodeproj/ │ │ ├── project.pbxproj │ │ ├── project.xcworkspace/ │ │ │ ├── contents.xcworkspacedata │ │ │ └── xcshareddata/ │ │ │ ├── IDEWorkspaceChecks.plist │ │ │ ├── WorkspaceSettings.xcsettings │ │ │ └── swiftpm/ │ │ │ └── Package.resolved │ │ └── xcshareddata/ │ │ └── xcschemes/ │ │ ├── App Network Extension.xcscheme │ │ ├── Dev Client.xcscheme │ │ ├── Obscura VPN iOS.xcscheme │ │ ├── Prod Client.xcscheme │ │ └── System Network Extension.xcscheme │ ├── dmg-building/ │ │ └── installer_background.tiff │ ├── libobscuravpn_client/ │ │ └── .gitignore │ ├── obscuravpn-client.xcodeproj/ │ │ └── project.pbxproj │ ├── shared/ │ │ ├── Account+CustomStringConvertible.swift │ │ ├── Account.swift │ │ ├── AccountInfo+Util.swift │ │ ├── Box.swift │ │ ├── Concurrency.swift │ │ ├── ConcurrencyTests.swift │ │ ├── Debug.swift │ │ ├── FfiCb.swift │ │ ├── InfoDict.swift │ │ ├── Json.swift │ │ ├── NetworkExtensionIpc.swift │ │ ├── NotificationIds.swift │ │ ├── ObservableValue.swift │ │ ├── Sleep.swift │ │ ├── String.swift │ │ ├── StringError.swift │ │ ├── Swift.swift │ │ ├── WatchableValue.swift │ │ └── time.swift │ ├── system-network-extension/ │ │ ├── Info.plist │ │ └── entitlements.entitlements │ ├── third-party/ │ │ └── CwlSysctl.swift │ └── xcodescripts/ │ ├── cargo-build-static-lib.bash │ ├── nix-build-web-bundle.bash │ ├── nix-web-dev-server-start.bash │ ├── nix-web-dev-server-stop.bash │ ├── pre-action.bash │ └── set-build-info.bash ├── bin/ │ ├── gradle-deps-update.sh │ ├── log-sleeps.py │ ├── log-summary.py │ └── log-text.py ├── contrib/ │ ├── bin/ │ │ ├── build-obscuravpn-dmg.bash │ │ ├── check-in-obscura-nix-shell.bash │ │ ├── find-nix-files.bash │ │ ├── find-shellcheck-files.bash │ │ ├── linux-packages.bash │ │ ├── linux-test.bash │ │ ├── linux_run_service.sh │ │ ├── ls-non-ignored-files.bash │ │ ├── nixfmt-auto-files.bash │ │ ├── package-arch.bash │ │ ├── package-deb.bash │ │ ├── package-rpm.bash │ │ └── shellcheck-auto-files.bash │ ├── licenses.mjs │ └── shell/ │ ├── source-die.bash │ ├── source-echoerr.bash │ └── source-nix.sh ├── flake.nix ├── justfile ├── linux/ │ ├── arch_Dockerfile │ ├── arch_PKGBUILD │ ├── deb_Dockerfile │ ├── deb_control │ ├── deb_install │ ├── deb_rules │ ├── obscura-preset.conf │ ├── obscura-sysusers.conf │ ├── obscura.service │ ├── rpm_Dockerfile │ ├── rpm_obscura.spec │ ├── rpm_rpmlintrc │ └── vm/ │ ├── archlinux-desktop-cloud-init/ │ │ ├── meta-data │ │ └── user-data │ ├── debian-desktop.preseed.cfg │ ├── fedora43-desktop.ks │ └── ubuntu24.04-desktop-cloud-init/ │ ├── meta-data │ └── user-data ├── obscura-ui/ │ ├── .gitattributes │ ├── .gitignore │ ├── .vscode/ │ │ └── settings.json │ ├── README.md │ ├── index.html │ ├── justfile │ ├── package.json │ ├── postcss.config.js │ ├── src/ │ │ ├── App.module.css │ │ ├── App.tsx │ │ ├── Providers.tsx │ │ ├── bridge/ │ │ │ ├── SystemProvider.tsx │ │ │ ├── android.ts │ │ │ └── commands.ts │ │ ├── common/ │ │ │ ├── KeyedSet.ts │ │ │ ├── accountUtils.ts │ │ │ ├── api.ts │ │ │ ├── appContext.ts │ │ │ ├── common.module.css │ │ │ ├── debuggingArchiveHook.tsx │ │ │ ├── exitUtils.ts │ │ │ ├── fmt.ts │ │ │ ├── links.ts │ │ │ ├── localStorage.ts │ │ │ ├── notifIds.ts │ │ │ ├── useAsync.ts │ │ │ ├── useExitList.ts │ │ │ ├── useLoadable.ts │ │ │ ├── useMailto.ts │ │ │ ├── useSharedWatchable.ts │ │ │ └── utils.ts │ │ ├── components/ │ │ │ ├── AccountNumberSection.tsx │ │ │ ├── AnimatedChevron.tsx │ │ │ ├── BoltBadgeAuto.tsx │ │ │ ├── ButtonLink.tsx │ │ │ ├── CachedColorScheme.tsx │ │ │ ├── ConfirmationDialog.module.css │ │ │ ├── ConfirmationDialog.tsx │ │ │ ├── DebuggingArchive.module.css │ │ │ ├── DebuggingArchive.tsx │ │ │ ├── DevSendCommand.tsx │ │ │ ├── DevSetApiUrl.tsx │ │ │ ├── ExternalLinkIcon.tsx │ │ │ ├── Licenses.tsx │ │ │ ├── Mantine.tsx │ │ │ ├── ObscuraChip.tsx │ │ │ ├── ObscuraWordmark.tsx │ │ │ ├── PaymentManagementSheet.tsx │ │ │ ├── ScrollableView.module.css │ │ │ ├── ScrollableView.tsx │ │ │ ├── SecondaryButton.tsx │ │ │ ├── SelectCreatable.tsx │ │ │ ├── Socials.tsx │ │ │ └── VpnErrorFmt.tsx │ │ ├── index.css │ │ ├── main.tsx │ │ ├── translations/ │ │ │ ├── en.json │ │ │ ├── i18n.ts │ │ │ └── i18next.d.ts │ │ ├── views/ │ │ │ ├── About.module.css │ │ │ ├── About.tsx │ │ │ ├── AccountView.module.css │ │ │ ├── AccountView.tsx │ │ │ ├── ConnectionView.module.css │ │ │ ├── ConnectionView.tsx │ │ │ ├── DeveloperView.tsx │ │ │ ├── HelpView.tsx │ │ │ ├── Location.module.css │ │ │ ├── LocationView.tsx │ │ │ ├── LogInView.tsx │ │ │ ├── LoginView.module.css │ │ │ ├── Settings.module.css │ │ │ ├── Settings.tsx │ │ │ ├── SplashScreen.tsx │ │ │ ├── index.ts │ │ │ └── render-fallbacks/ │ │ │ └── FallbackAppRender.tsx │ │ └── wdyr.ts │ ├── tsconfig.json │ ├── vite-env.d.ts │ └── vite.config.ts ├── rustlib/ │ ├── .cargo/ │ │ └── config.toml │ ├── .gitignore │ ├── Cargo.toml │ ├── about.toml │ ├── build.rs │ ├── examples/ │ │ ├── connect.rs │ │ └── list-relay-rtts.rs │ ├── rust-toolchain.toml │ ├── rustfmt.toml │ └── src/ │ ├── android/ │ │ ├── class_cache.rs │ │ ├── ffi.rs │ │ ├── future.rs │ │ ├── mod.rs │ │ ├── os_impl.rs │ │ ├── tunnel.rs │ │ └── util.rs │ ├── apple/ │ │ ├── ffi.rs │ │ ├── mod.rs │ │ └── os_impl.rs │ ├── backoff.rs │ ├── backoff_test.rs │ ├── bin/ │ │ └── obscura/ │ │ ├── add_operator.rs │ │ ├── client/ │ │ │ ├── client_error.rs │ │ │ ├── ipc.rs │ │ │ └── mod.rs │ │ ├── main.rs │ │ └── service/ │ │ ├── mod.rs │ │ └── os/ │ │ ├── linux/ │ │ │ ├── dns/ │ │ │ │ ├── mod.rs │ │ │ │ └── resolved.rs │ │ │ ├── ipc.rs │ │ │ ├── mod.rs │ │ │ ├── network_manager.rs │ │ │ ├── routes/ │ │ │ │ ├── mod.rs │ │ │ │ └── netlink.rs │ │ │ ├── service_lock.rs │ │ │ ├── start_error.rs │ │ │ └── tun.rs │ │ ├── mod.rs │ │ └── windows/ │ │ ├── adapters.rs │ │ ├── gaa.rs │ │ ├── iphelper.rs │ │ ├── mod.rs │ │ ├── nrpt.rs │ │ ├── start_error.rs │ │ └── tun.rs │ ├── cached_value.rs │ ├── client_state.rs │ ├── config/ │ │ ├── cached.rs │ │ ├── dns_cache.rs │ │ ├── feature_flags.rs │ │ ├── mod.rs │ │ ├── persistence.rs │ │ └── persistence_test.rs │ ├── constants.rs │ ├── debug_archive/ │ │ ├── builder.rs │ │ ├── dns.rs │ │ ├── http.rs │ │ ├── info.rs │ │ ├── mod.rs │ │ ├── task.rs │ │ └── zipper.rs │ ├── dns.rs │ ├── errors.rs │ ├── exit_selection.rs │ ├── ffi_helpers.rs │ ├── lib.rs │ ├── liveness.rs │ ├── logging.rs │ ├── manager.rs │ ├── manager_cmd.rs │ ├── net.rs │ ├── network_config.rs │ ├── os/ │ │ ├── mod.rs │ │ ├── os_trait.rs │ │ └── packet_buffer.rs │ ├── positive_u31.rs │ ├── quicwg.rs │ ├── rate_limited_log.rs │ ├── relay_selection.rs │ ├── serde_safe.rs │ ├── tokio.rs │ └── tunnel_state.rs ├── tag.json └── taplo.toml ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ # https://editorconfig.org/#supported-properties root = true [*] charset = utf-8 indent_size = 4 indent_style = space insert_final_newline = true trim_trailing_whitespace = true [justfile] indent_style = tab indent_size = tab [*.{bash,sh}] indent_style = tab indent_size = tab [*.html] indent_size = 2 [*.nix] indent_size = 2 [*.plist] indent_style = tab indent_size = tab [*.yml] indent_size = 2 [*{.ts,tsx,js,jsx,json}] indent_size = 2 # https://github.com/facebook/ktfmt/blob/main/docs/editorconfig/.editorconfig-kotlinlang [*.{kt,kts}] indent_style = space insert_final_newline = true max_line_length = 120 indent_size = 4 ij_continuation_indent_size = 4 ij_java_names_count_to_use_import_on_demand = 9999 ij_kotlin_align_in_columns_case_branch = false ij_kotlin_align_multiline_binary_operation = false ij_kotlin_align_multiline_extends_list = false ij_kotlin_align_multiline_method_parentheses = false ij_kotlin_align_multiline_parameters = true ij_kotlin_align_multiline_parameters_in_calls = false ij_kotlin_allow_trailing_comma = true ij_kotlin_allow_trailing_comma_on_call_site = true ij_kotlin_assignment_wrap = normal ij_kotlin_blank_lines_after_class_header = 0 ij_kotlin_blank_lines_around_block_when_branches = 0 ij_kotlin_blank_lines_before_declaration_with_comment_or_annotation_on_separate_line = 1 ij_kotlin_block_comment_at_first_column = true ij_kotlin_call_parameters_new_line_after_left_paren = true ij_kotlin_call_parameters_right_paren_on_new_line = false ij_kotlin_call_parameters_wrap = on_every_item ij_kotlin_catch_on_new_line = false ij_kotlin_class_annotation_wrap = split_into_lines ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL ij_kotlin_continuation_indent_for_chained_calls = true ij_kotlin_continuation_indent_for_expression_bodies = true ij_kotlin_continuation_indent_in_argument_lists = true ij_kotlin_continuation_indent_in_elvis = false ij_kotlin_continuation_indent_in_if_conditions = false ij_kotlin_continuation_indent_in_parameter_lists = false ij_kotlin_continuation_indent_in_supertype_lists = false ij_kotlin_else_on_new_line = false ij_kotlin_enum_constants_wrap = off ij_kotlin_extends_list_wrap = normal ij_kotlin_field_annotation_wrap = off ij_kotlin_finally_on_new_line = false ij_kotlin_if_rparen_on_new_line = false ij_kotlin_import_nested_classes = false ij_kotlin_imports_layout = * ij_kotlin_insert_whitespaces_in_simple_one_line_method = true ij_kotlin_keep_blank_lines_before_right_brace = 2 ij_kotlin_keep_blank_lines_in_code = 2 ij_kotlin_keep_blank_lines_in_declarations = 2 ij_kotlin_keep_first_column_comment = true ij_kotlin_keep_indents_on_empty_lines = false ij_kotlin_keep_line_breaks = true ij_kotlin_lbrace_on_next_line = false ij_kotlin_line_comment_add_space = false ij_kotlin_line_comment_at_first_column = true ij_kotlin_method_annotation_wrap = split_into_lines ij_kotlin_method_call_chain_wrap = normal ij_kotlin_method_parameters_new_line_after_left_paren = true ij_kotlin_method_parameters_right_paren_on_new_line = true ij_kotlin_method_parameters_wrap = on_every_item ij_kotlin_name_count_to_use_star_import = 9999 ij_kotlin_name_count_to_use_star_import_for_members = 9999 ij_kotlin_parameter_annotation_wrap = off ij_kotlin_space_after_comma = true ij_kotlin_space_after_extend_colon = true ij_kotlin_space_after_type_colon = true ij_kotlin_space_before_catch_parentheses = true ij_kotlin_space_before_comma = false ij_kotlin_space_before_extend_colon = true ij_kotlin_space_before_for_parentheses = true ij_kotlin_space_before_if_parentheses = true ij_kotlin_space_before_lambda_arrow = true ij_kotlin_space_before_type_colon = false ij_kotlin_space_before_when_parentheses = true ij_kotlin_space_before_while_parentheses = true ij_kotlin_spaces_around_additive_operators = true ij_kotlin_spaces_around_assignment_operators = true ij_kotlin_spaces_around_equality_operators = true ij_kotlin_spaces_around_function_type_arrow = true ij_kotlin_spaces_around_logical_operators = true ij_kotlin_spaces_around_multiplicative_operators = true ij_kotlin_spaces_around_range = false ij_kotlin_spaces_around_relational_operators = true ij_kotlin_spaces_around_unary_operator = false ij_kotlin_spaces_around_when_arrow = true ij_kotlin_variable_annotation_wrap = off ij_kotlin_while_on_new_line = false ij_kotlin_wrap_elvis_expressions = 1 ij_kotlin_wrap_expression_body_functions = 1 ij_kotlin_wrap_first_method_in_call_chain = false ktfmt_trailing_comma_management_strategy = only_add ================================================ FILE: .envrc ================================================ use flake ================================================ FILE: .git-blame-ignore-revs ================================================ 70ff75289a9cbce36f7b70a20b4f9c9f82e3b25e ================================================ FILE: .github/workflows/checks.yml ================================================ name: Checks on: workflow_dispatch: pull_request: branches: - '**' push: branches: - 'main' concurrency: # group is workflow specific and based on the branch (e.g. PRs) or tag group: ${{ github.workflow }}-${{ github.ref }} # we don't want to cancel checks on the main branch cancel-in-progress: ${{ github.ref_name != 'main' }} jobs: flake_check_linux: name: Flake Check Linux runs-on: [nix] steps: - name: git checkout uses: actions/checkout@v3 - name: Nix Checks shell: bash run: | nix flake check \ --keep-going \ --no-update-lock-file \ --print-build-logs \ --show-trace - name: Nix Build shell: bash run: | nix build '.#apks-foss' \ --no-update-lock-file \ --print-build-logs \ --show-trace - name: Upload debug APK uses: actions/upload-artifact@v4 with: name: obscura-debug.apk path: result/app-foss-debug.apk retention-days: 30 flake_check_macos: name: Flake Check macOS runs-on: # https://namespace.so/docs/features/faster-github-actions#using-runner-labels - nscloud-macos-sequoia-arm64-12x28-with-cache - nscloud-cache-tag-obscuravpn-client - nscloud-cache-size-50gb steps: - name: git checkout uses: actions/checkout@v3 - uses: namespacelabs/nscloud-cache-action@v1 with: # The action fails to mount at `/nix` and we want to let the Nix installer handle that anyways. path: /tmp/nix - name: Install Nix uses: DeterminateSystems/nix-installer-action@main with: determinate: false mac-volume-label: obscuravpn-client - name: Nix Checks shell: bash run: | nix flake check \ --keep-going \ --no-update-lock-file \ --print-build-logs \ --show-trace macos_build: name: MacOS build runs-on: # https://namespace.so/docs/reference/github-actions/runner-configuration#runner-labels - nscloud-macos-tahoe-arm64-12x28-with-cache - nscloud-cache-tag-obscuravpn-client - nscloud-cache-size-50gb steps: - name: git checkout uses: actions/checkout@v3 - name: unshallow and fetch git tags shell: bash run: | git fetch --prune --unshallow --tags - uses: namespacelabs/nscloud-cache-action@v1 with: # The action fails to mount at `/nix` and we want to let the Nix installer handle that anyways. path: /tmp/nix - name: Install Nix uses: DeterminateSystems/nix-installer-action@main with: determinate: false mac-volume-label: obscuravpn-client - name: Build macOS client shell: bash run: | xcodebuild build \ -workspace apple/client.xcodeproj/project.xcworkspace \ -scheme 'Prod Client' \ CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED="NO" CODE_SIGN_ENTITLEMENTS="" CODE_SIGNING_ALLOWED="NO" ios_build: name: iOS All runs-on: # https://namespace.so/docs/reference/github-actions/runner-configuration#runner-labels - nscloud-macos-tahoe-arm64-12x28-with-cache - nscloud-cache-tag-obscuravpn-client - nscloud-cache-size-50gb steps: - name: git checkout uses: actions/checkout@v3 - name: unshallow and fetch git tags shell: bash run: | git fetch --prune --unshallow --tags - uses: namespacelabs/nscloud-cache-action@v1 with: # The action fails to mount at `/nix` and we want to let the Nix installer handle that anyways. path: /tmp/nix - name: Install Nix uses: DeterminateSystems/nix-installer-action@main with: determinate: false mac-volume-label: obscuravpn-client - name: Build iOS client shell: bash run: | xcodebuild build \ -workspace apple/client.xcodeproj/project.xcworkspace \ -scheme 'Obscura VPN iOS' \ CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED="NO" CODE_SIGN_ENTITLEMENTS="" CODE_SIGNING_ALLOWED="NO" sync: name: Sync if: | github.repository == 'sovereign-engineering/obscuravpn-client-internal' && github.ref == 'refs/heads/main' needs: - flake_check_linux - flake_check_macos - ios_build - macos_build runs-on: nscloud-ubuntu-20.04-amd64-2x2 environment: sync steps: - name: git checkout uses: actions/checkout@v3 with: fetch-depth: 0 # We need all history to check for internal commits. - name: Check Restricted Commits run: | echo "Ensuring that we have full history." git log --pretty=oneline 5a25968c93bda974d63f9f96e2be38d7277d0993 echo "Ensuring that internal history is not present in HEAD." ! git merge-base --is-ancestor 5a25968c93bda974d63f9f96e2be38d7277d0993 HEAD - name: Push to Public env: SSH_PRIVATE_KEY: ${{ secrets.OBSCURA_CLIENT_SSH_KEY }} run: | echo "$SSH_PRIVATE_KEY" > "$RUNNER_TEMP/ssh-private-key" chmod 600 "$RUNNER_TEMP/ssh-private-key" export GIT_SSH_COMMAND="ssh -i $RUNNER_TEMP/ssh-private-key" git push git@github.com:Sovereign-Engineering/obscuravpn-client.git HEAD:main ================================================ FILE: .gitignore ================================================ /.direnv/ /.idea/ /.devcontainer/ xcuserdata .DS_STORE /.claude/ # Nix result result-* # DMG *.dmg # Android *.apk *.apk.idsig *.aab # Linux /linux/vm/*.qcow2 /linux/vm/*.iso *.rpm *.pkg.tar.zst *.deb # Windows windows/wintun*/* ================================================ FILE: .shellcheckrc ================================================ source-path=. external-sources=true ================================================ FILE: .swiftformat ================================================ --self insert --disable andOperator,unusedArguments,hoistPatternLet --trailing-commas collections-only # Old versions of Swift can't handle failing commas in functions. Switch to always (default) when we don't use those anymore. ================================================ FILE: LICENSE.md ================================================ # PolyForm Noncommercial License 1.0.0 ## Acceptance In order to get any license under these terms, you must agree to them as both strict obligations and conditions to all your licenses. ## Copyright License The licensor grants you a copyright license for the software to do everything you might do with the software that would otherwise infringe the licensor's copyright in it for any permitted purpose. However, you may only distribute the software according to [Distribution License](#distribution-license) and make changes or new works based on the software according to [Changes and New Works License](#changes-and-new-works-license). ## Distribution License The licensor grants you an additional copyright license to distribute copies of the software. Your license to distribute covers distributing the software with changes and new works permitted by [Changes and New Works License](#changes-and-new-works-license). ## Notices You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms or the URL for them above, as well as copies of any plain-text lines beginning with `Required Notice:` that the licensor provided with the software. For example: > Required Notice: Copyright Yoyodyne, Inc. (http://example.com) ## Changes and New Works License The licensor grants you an additional copyright license to make changes and new works based on the software for any permitted purpose. ## Patent License The licensor grants you a patent license for the software that covers patent claims the licensor can license, or becomes able to license, that you would infringe by using the software. ## Noncommercial Purposes Any noncommercial purpose is a permitted purpose. ## Personal Uses Personal use for research, experiment, and testing for the benefit of public knowledge, personal study, private entertainment, hobby projects, amateur pursuits, or religious observance, without any anticipated commercial application, is use for a permitted purpose. ## Noncommercial Organizations Use by any charitable organization, educational institution, public research organization, public safety or health organization, environmental protection organization, or government institution is use for a permitted purpose regardless of the source of funding or obligations resulting from the funding. ## Fair Use You may have "fair use" rights for the software under the law. These terms do not limit them. ## No Other Rights These terms do not allow you to sublicense or transfer any of your licenses to anyone else, or prevent the licensor from granting licenses to anyone else. These terms do not imply any other licenses. ## Patent Defense If you make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company. ## Violations The first time you are notified in writing that you have violated any of these terms, or done anything with the software not covered by your licenses, your licenses can nonetheless continue if you come into full compliance with these terms, and take practical steps to correct past violations, within 32 days of receiving notice. Otherwise, all your licenses end immediately. ## No Liability ***As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim.*** ## Definitions The **licensor** is the individual or entity offering these terms, and the **software** is the software the licensor makes available under these terms. **You** refers to the individual or entity agreeing to these terms. **Your company** is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all organizations that have control over, are under the control of, or are under common control with that organization. **Control** means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect. **Your licenses** are all the licenses granted to you for the software under these terms. **Use** means anything you do with the software requiring one of your licenses. ================================================ FILE: README.md ================================================ # Obscura VPN Client Obscura VPN library, CLI client, and App ## Support No support is provided for this code directly. However, if you are experiencing issues with your Obscura VPN service please contact . ## Contributions At this time we are unable to accept external contributions. This is something that we plan to resolve soon. However until we finish the paperwork we are unable to look at any patches and will close all PRs without looking at them. ## macOS App On macOS the app installs and manages a [network extension](https://developer.apple.com/documentation/networkextension) (system extension). The network extension manages the virtual device and maintains the tunnel using the Rust code as library. ### Setup 1. [Setup Nix](#nix-setup) 1. Install dependencies: `nix-env -iA nixpkgs.{cmake,rustup}` 1. Open the main Xcode project ```bash nix develop --print-build-logs --command just xcode-open ``` 1. In Xcode, login with an account with membership in "Sovereign Engineering Inc." 1. Register development machine in Apple Developer portal (can be done in Xcode) 1. [Enable system extension developer mode](#enabling-system-extension-developer-mode) 1. Setup Developer ID provisioning profile and codesigning for `Prod Client` build scheme 1. Go to https://developer.apple.com/account/resources/profiles/list - Download "Developer ID: System Network Extension" - Download "Developer ID: VPN Client App" 1. Install both provisioning profiles by double-clicking them. 1. Ask Carl to send the Developer ID codesigning certificate and the corresponding password 1. Double click the certificate, enter the password, and install it to your "login" keychain ## Building and Running ### For macOS and iOS 1. Open the main Xcode project: ```bash nix develop --print-build-logs --command just xcode-open ``` 1. Pick a build scheme using Xcode's GUI, one of: ℹ️ **INFO**: Xcode differentiates between "build schemes" and "build configurations", see [Apple's docs on this](https://developer.apple.com/documentation/xcode/build-system) for more details. 1. `Dev Client`: Development Client General purpose for development. Uses the main UI with additional developer and pre-release features exposed. Uses the `Debug*` build configurations. Codesigned with the `Apple Development` xcode-managed identity. ⚠️ **WARNING**: When using this build scheme, make sure you are quitting the app via the top-right status menu bar and **NOT** using Xcode's "Stop" as doing so does not actually stop the dev server. This is because stopping via Xcode doesn't run the build scheme's "Run → Post-actions" 1. `Prod Client`: The App with a static web bundle Useful for reproducing what the final shippable app will look like and be built as. Uses the `Release*` build configurations. Codesigned with the `Developer ID Application: Sovereign Engineering Inc. (5G943LR562)` manually-managed identity. The static web bundle built with the build scheme's "Build → Pre-actions". If you encounter trouble with this build scheme, especially with codesigning or provisioning profiles: 1. Make sure that you've completed the relevant steps in [setup](#setup) 1. See additional instructions in [Confirming "Developer ID" Setup](#confirming-developer-id-setup) 1. `Bare Client`: The App with a minimal HTML UI Useful for fine-grain control and debugging. Uses the `Debug*` build configurations. Codesigned with the `Apple Development` xcode-managed identity. 1. Build or Run the App - `⌘ + B` (Build), or - `⌘ + R` (Run) 💡 **TIP**: It may initially _seem_ like Xcode is doing nothing when you run or build, but it may just be running the build scheme's "Pre-actions", see the "Report navigator" in Xcode's top-left app menu: "View → Navigators → Reports" to track the actual status. 💡 **TIP**: If a build fails with `could not find included file 'buildversion.xcconfig' in search paths`, see the [relevant troubleshooting entry](#error-on-build-in-clean-repo-could-not-find-included-file-buildversionxcconfig-in-search-paths). ----- Xcode places built products in a deeply nested directory structure that it controls, with seperate folders for each build configuration. The easiest way to locate where the app is: 1. "Run" the app 1. Once the app's icon appears on the macOS Dock, `⌘-Click` the app icon to reveal it in the finder. 💡 **TIP**: It is highly recommended to read through various sections in [Development Tips](#development-tips) to better understand the various ways we've configured the Xcode build system to work with our development process. ### For Android #### Nix Builds Nix builds provide an easy way to get a fully built APK. They are hermetic and reliable. However, they provide only coarse grained caching so if you are iterating during development you may prefer to use [Incremental Builds](#incremental-builds). ```sh nix build '.#apks-foss' apksigner sign --ks your-keystore.jks --ks-pass pass:hunter2 --out=obscura-signed.apk result/app-foss-release-unsigned.apk # Sign. adb install obscura-signed.apk # Push to your device. ``` Instead of `app-foss-release-unsigned` you can also use `app-foss-debug` for the debug build. Note that just the Android portion is a debug build, the Rust core and UI are still release builds. #### Incremental Builds The Android app requires a special build of the Rust library and Obscura UI. These are built using Nix, while the Android app itself can be built using [Android Studio](https://developer.android.com/studio) for local development, or the Gradle build system to create an official build. 1. Build the Obscura UI ```bash OBS_WEB_PLATFORM="android" nix develop '.#web' --print-build-logs -c just web-bundle-build ``` 2. Build the Rust library ```bash nix develop '.#android' --command bash -c 'cd rustlib && cargo ndk -t arm64-v8a build --release' ``` 3. Open Android Studio and point it at the `android` directory, or 4. Use Gradle to build everything ```bash nix develop '.#android' --command bash -c 'cd android && gradle --no-daemon $GRADLE_OPTS build' ``` In order to iterate you can just repeat the steps. 1 and 2 are only required if you changed the UI or Rust core respectively but the final APK build must always be re-run. #### Gradle Dependencies To ensure hermetic builds we pin our Gradle dependencies. If you change the dependencies you will need to regenerate the pin file. ``` bin/gradle-deps-update.sh ``` ### For Windows Install [Visual Studio](https://visualstudio.microsoft.com/downloads/) with the following Workloads: - Desktop development with C++ - WinUI application development On Windows, definitely ARM64 machines, you need to add `C:\Program Files\Microsoft Visual Studio\18\Community\VC\Tools\Llvm\ARM64\bin` to path. Download the signed [wintun 0.14.1 DLLs](https://www.wintun.net/). You can use `Get-FileHash -Path .\wintun-0.14.1.zip -Algorithm SHA256` to verify the hash against `SHA2-256: 07c256185d6ee3652e09fa55c0b673e2624b565e02c4b9091c79ca7d2f24ef51`. Extract to `windows/wintun-0.14.1` such that `windows/wintun-0.14.1/bin/arm64/wintun.dll` is a file. To test the service, run `sudo cargo run service`. You need to enable `sudo` under System > Advanced settings. Alternatively, you can run `cargo run service` in an administrative terminal. The default config directory is `%APPDATA%\Obscura`. When testing the service, you may find it beneficial to manually add in an account number. A helpful command to clean DNS query manually is `Remove-DnsClientNrptRule -Name "{fb157da8-6578-4f53-81ea-0a9168e96c1f}"`. ## Swift unit tests "Swift Testing" tests are placed in `*Test.swift` files, which need to be a member of the `Tests` target. Testing (not running) with the `Tests` scheme builds and executes all tests. ## Debugging ### Logs Both app and network extension logs are available via [Apple's unified logging system](https://developer.apple.com/documentation/os/logging). #### Analyzing Logs There are tools for analyzing logs available as `bin/log-*`. They accept log files in JSON lines format. This can be found in the app's Debug Bundle or from the Apple `log` command by specifying `--style=ndjson`. The main tool is `bin/log-text.py` which just turns the logs into a readable text format as well as applying some basic filtering with a few CLI options to apply more filters. Other tools are available, run with `--help` to get information about what they do. For more in-depth analysis you are likely best using the tools as a starting point and modifying them as needed or using other tools like `jq`, `sqlite` or `duckdb`. If your analysis is generally useful consider committing it. #### Stream Logs This will output logs starting at the point in time when you run this command: ```bash log stream --info --debug --predicate 'process CONTAINS[c] "obscura" || subsystem CONTAINS[c] "obscura"' ``` #### View Past Logs > [!WARNING] > Since Apple may or may not persist logs at the `INFO` or `DEBUG` level, logs at these level might be lost. See [Apple's developer docs on this](https://developer.apple.com/documentation/os/logging/generating_log_messages_from_your_code#3665947) for more information. > > You may be able to set a log configuration to ensure that these logs are persisted, though this has not been tested, please update this `README` with instructions if you successfully test this. See [Apple's docs on "Customizing Logging Behavior While Debugging"](https://developer.apple.com/documentation/os/logging/customizing_logging_behavior_while_debugging) for more information. ```bash log show --last 200 --info --debug --color always --predicate 'process CONTAINS[c] "obscura" || subsystem CONTAINS[c] "obscura"' | less +G -R ``` #### UserDefaults ```sh defaults read "net.obscura.vpn-client-app" # delete all defaults including Sparkle related keys (SU*) defaults delete-all "net.obscura.vpn-client-app" # delete keys individually defaults delete "net.obscura.vpn-client-app" ``` ## Running Checks ### Linting ```bash nix develop --print-build-logs --command just lint ``` ### Formatting #### Checking ```bash nix flake check ``` #### Auto-fixing ```bash nix develop --print-build-logs --command just format-fix ``` ## Building a Notarized Disk Image 1. Save authentication credentials for the Apple notary service (only need to do once) ```bash xcrun notarytool store-credentials "notarytool-password" --team-id 5G943LR562 ``` Use [appleid.apple.com](https://appleid.apple.com/account/manage) --> App-Specific Passwords 1. (OPTIONAL) If we're doing a release, tag the version `git tag -s v/1.23 -m v/1.23 && git push --tags`. 1. Unlock the "Login" keychain: `security unlock-keychain` 1. Build the signed and notarized disk image: `just build-dmg` 💡 **TIP**: This command uses AppleScript automation of Finder to change the background of Disk Images, so Finder windows may open. The built disk image will appear in the current working directory as "Obscura VPN.dmg" ## Troubleshooting ### `cargo` not rebuilding when it should A lot of Xcode-set properties don't properly trigger a rebuild from `cargo` even though they're supposed to. The most prominent of which is `MACOSX_DEPLOYMENT_TARGET`. This is easily worked-around by "Product → Clean Build Folder..." in Xcode then rerunning the build. Upstream status on this: - https://github.com/rust-lang/cc-rs/issues/906 - https://github.com/rust-lang/rust/issues/118204 ## Development Tips ### Enabling system extension developer mode This is necessary for: - The `systemextensionsctl` commands to work, and - To allow installing and running system extensions from places other than `/Applications` According to [Apple's docs for system extensions](https://developer.apple.com/documentation/driverkit/debugging_and_testing_system_extensions#3557204), as of 2024-07-04: > You must place all system extensions in the `Contents/Library/SystemExtensions` directory of your app bundle, and the app itself must be installed in one of the system’s `Applications` directories. To allow development of your app outside of these directories, use the `systemextensionsctl` command-line tool to enable developer mode. When in developer mode, the system doesn't check the location of your system extension prior to loading it, so you can load it from anywhere in the file system. To accomplish this: 1. [Disable system integrity protection](https://developer.apple.com/documentation/security/disabling_and_enabling_system_integrity_protection) 1. Then, run ```bash systemextensionsctl developer on ``` ### Removing network extension (system extension) 1. Ensure that [system extension developer mode is enabled](#enabling-system-extension-developer-mode) 1. Then, run ```bash systemextensionsctl uninstall 5G943LR562 net.obscura.vpn-client-app.system-network-extension ``` ### Nix Setup - Install [`nix`](https://nixos.org/download/) (only the package manager is needed) - Enable [`flake`s](https://nixos.wiki/wiki/Flakes) Add the following to `~/.config/nix/nix.conf` or `/etc/nix/nix.conf`: ``` experimental-features = nix-command flakes ``` - Optional, but strongly recommended: Set up [`nix-direnv`](https://github.com/nix-community/nix-direnv) and integrate it with your preferred shell If you do this, you can omit the `nix develop ... --command` parts, as `cd`-ing into the repository directory will set up your environment variables with the correct tools as long as you've `direnv allow`-ed the directory. ### Confirming "Developer ID" Setup To confirm that the Developer ID provisioning profile and codesigning are set up correctly (required for the `Prod Client` build scheme): 1. Pick the `Prod Client` build scheme in Xcode 1. Create an Archive Choose from Xcode's top-left app menu: "Product → Archive" 1. Ensure that the "Archive" action succeeds in the "Report navigator" Choose from Xcode's top-left app menu: "View → Navigators → Reports" ## Linux > [!WARNING] > As of 2024-07-04, the Linux client is not maintained. ```bash cargo build --release && sudo RUST_LOG=info ./target/release/obscuravpn-client ``` ================================================ FILE: android/.gitignore ================================================ *.iml .gradle .kotlin /local.properties /.idea/caches /.idea/libraries /.idea/modules.xml /.idea/workspace.xml /.idea/navEditor.xml /.idea/assetWizardSettings.xml /.idea/appInsightsSettings.xml .DS_Store /build /lib/*/build /captures .externalNativeBuild .cxx local.properties ================================================ FILE: android/.idea/.gitignore ================================================ # Default ignored files /shelf/ /workspace.xml /deploymentTargetSelector.xml ================================================ FILE: android/.idea/.name ================================================ ObscuraVPN ================================================ FILE: android/.idea/AndroidProjectSystem.xml ================================================ ================================================ FILE: android/.idea/codeStyles/Project.xml ================================================ ================================================ FILE: android/.idea/codeStyles/codeStyleConfig.xml ================================================ ================================================ FILE: android/.idea/compiler.xml ================================================ ================================================ FILE: android/.idea/detekt.xml ================================================ ================================================ FILE: android/.idea/deviceManager.xml ================================================ ================================================ FILE: android/.idea/dictionaries/project.xml ================================================ obscura obscuravpn vpnclientapp vpnservice ================================================ FILE: android/.idea/gradle.xml ================================================ ================================================ FILE: android/.idea/kotlinc.xml ================================================ ================================================ FILE: android/.idea/ktfmt.xml ================================================ ================================================ FILE: android/.idea/migrations.xml ================================================ ================================================ FILE: android/.idea/misc.xml ================================================ ================================================ FILE: android/.idea/runConfigurations.xml ================================================ ================================================ FILE: android/.idea/vcs.xml ================================================ ================================================ FILE: android/app/.gitignore ================================================ /build ================================================ FILE: android/app/build.gradle.kts ================================================ import com.android.build.api.dsl.ApplicationExtension plugins { alias(libs.plugins.android.application) alias(libs.plugins.hilt.android) alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlinx.serialization) alias(libs.plugins.ksp) } extensions.configure { buildToolsVersion = "36.0.0" namespace = "net.obscura.vpnclientapp" compileSdk = 36 defaultConfig { applicationId = "net.obscura.vpnclientapp" minSdk = 31 targetSdk = 36 versionCode = 1 versionName = project.getVersionName(project.rootDir) testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } buildFeatures { aidl = true buildConfig = true } buildTypes { getByName("debug") { applicationIdSuffix = ".debug" isMinifyEnabled = false isShrinkResources = false resValue("string", "app_name", "Obscura VPN (Debug)") } getByName("release") { isMinifyEnabled = true isShrinkResources = true proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro", ) } } flavorDimensions += listOf("billing") productFlavors { create("foss") { dimension = "billing" isDefault = true } create("play") { dimension = "billing" } } } kotlin { compilerOptions { freeCompilerArgs.add("-opt-in=kotlinx.serialization.ExperimentalSerializationApi") } jvmToolchain(21) } dependencies { implementation(libs.androidx.appcompat) implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle) implementation(libs.androidx.webkit) implementation(libs.hilt.android) implementation(libs.kotlinx.serialization.json) implementation(libs.material) implementation(project(":lib:util")) "playImplementation"(project(":lib:billing")) ksp(libs.hilt.android.compiler) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) } ================================================ FILE: android/app/google-services.json ================================================ { "project_info": { "project_number": "980686794718", "project_id": "obscura-vpn", "storage_bucket": "obscura-vpn.firebasestorage.app" }, "client": [ { "client_info": { "mobilesdk_app_id": "1:980686794718:android:db99381cf88e8d6712774f", "android_client_info": { "package_name": "net.obscura.vpnclientapp" } }, "oauth_client": [], "api_key": [ { "current_key": "AIzaSyDQpmOigUAURQCeVDA489R8ds78uvAdhCg" } ], "services": { "appinvite_service": { "other_platform_oauth_client": [] } } } ], "configuration_version": "1" } ================================================ FILE: android/app/proguard-rules.pro ================================================ # Add project specific ProGuard rules here. # You can control the set of applied configuration files using the # proguardFiles setting in build.gradle. # # For more details, see # http://developer.android.com/guide/developing/tools/proguard.html # If your project uses WebView with JS, uncomment the following # and specify the fully qualified class name to the JavaScript interface # class: #-keepclassmembers class fqcn.of.javascript.interface.for.webview { # public *; #} # Uncomment this to preserve the line number information for # debugging stack traces. #-keepattributes SourceFile,LineNumberTable # If you keep the line number information, uncomment this to # hide the original source file name. #-renamesourcefileattribute SourceFile ================================================ FILE: android/app/src/foss/java/net/obscura/vpnclientapp/BillingFacade.kt ================================================ package net.obscura.vpnclientapp import android.content.Context import net.obscura.vpnclientapp.activities.MainActivity import net.obscura.vpnclientapp.client.errorCodeUnsupportedOnOS class BillingFacade(@Suppress("UNUSED_PARAMETER") context: Context) { @Suppress("RedundantSuspendModifier") suspend fun launchFlow(@Suppress("UNUSED_PARAMETER") mainActivity: MainActivity): Boolean = throw errorCodeUnsupportedOnOS() fun destroy() = Unit } ================================================ FILE: android/app/src/main/AndroidManifest.xml ================================================ ================================================ FILE: android/app/src/main/aidl/net/obscura/vpnclientapp/services/IObscuraVpnService.aidl ================================================ // IObscuraVpnService.aidl package net.obscura.vpnclientapp.services; interface IObscuraVpnService { void startTunnel(String exitSelector); void stopTunnel(); // Submits the command to the ObscuraLibrary.jsonFfi(String, CompletableFuture) // function, returning back a unique ID. To receive the result of the command, listen on the // CommandBridge.Receiver (BroadcastReceiver) for an Intent with the "id" extra, "result" extra // (indicating success) or "exception" extra (indicating an ErrorCodeException). void jsonFfi(long id, String command); } ================================================ FILE: android/app/src/main/assets/adi-registration.properties ================================================ CPRHUVTQKXB6SAAAAAAAAAAAAA ================================================ FILE: android/app/src/main/java/net/obscura/vpnclientapp/App.kt ================================================ package net.obscura.vpnclientapp import android.app.Application import dagger.hilt.android.HiltAndroidApp import net.obscura.lib.util.Logger private val log = Logger(App::class) @HiltAndroidApp class App : Application() { override fun onCreate() { super.onCreate() log.info("app version: ${BuildConfig.VERSION_NAME}") } } ================================================ FILE: android/app/src/main/java/net/obscura/vpnclientapp/activities/MainActivity.kt ================================================ package net.obscura.vpnclientapp.activities import android.content.ComponentName import android.content.Intent import android.content.ServiceConnection import android.content.SharedPreferences import android.content.res.Configuration import android.os.Bundle import android.os.IBinder import androidx.activity.addCallback import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatDelegate import androidx.core.view.WindowCompat import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import net.obscura.lib.util.Logger import net.obscura.vpnclientapp.BillingFacade import net.obscura.vpnclientapp.R import net.obscura.vpnclientapp.helpers.requireUIProcess import net.obscura.vpnclientapp.preferences.Preferences import net.obscura.vpnclientapp.services.IObscuraVpnService import net.obscura.vpnclientapp.services.bindVpnService import net.obscura.vpnclientapp.services.unbindVpnService import net.obscura.vpnclientapp.ui.ObscuraUI import net.obscura.vpnclientapp.ui.OsStatusManager import net.obscura.vpnclientapp.ui.VpnPermissionRequestManager private val log = Logger(MainActivity::class) @AndroidEntryPoint class MainActivity : AppCompatActivity(), ServiceConnection, SharedPreferences.OnSharedPreferenceChangeListener { @Inject lateinit var billingFacade: BillingFacade @Inject lateinit var osStatusManager: OsStatusManager @Inject lateinit var vpnPermissionRequestManager: VpnPermissionRequestManager private lateinit var preferences: Preferences private lateinit var ui: ObscuraUI private var isFreshLaunch: Boolean = true private var isVpnServiceBound: Boolean = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) requireUIProcess() this.isFreshLaunch = savedInstanceState == null // Edge-to-edge is the future for Android // https://developer.android.com/develop/ui/views/layout/edge-to-edge WindowCompat.enableEdgeToEdge(this.window) setContentView(R.layout.activity_main) ui = findViewById(R.id.ui) onBackPressedDispatcher.addCallback { if (ui.canGoBack) { ui.goBack() } else { isEnabled = false onBackPressedDispatcher.onBackPressed() isEnabled = true } } preferences = Preferences(this).apply { registerListener(this@MainActivity) } applyColorScheme() this.isVpnServiceBound = this.bindVpnService(this) } override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) intent.data?.let { uri -> this.ui.handleObscuraUri(uri) } } override fun onResume() { super.onResume() ui.onResume() } override fun onPause() { super.onPause() ui.onPause() } override fun onDestroy() { super.onDestroy() this.preferences.unregisterListener(this) if (this.isVpnServiceBound) { this.unbindVpnService(this) } } override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) log.debug("configuration changed: $newConfig") this.ui.invalidate() } override fun onServiceConnected(name: ComponentName?, service: IBinder?) { log.debug("onServiceConnected $name $service") this.ui.onCreate( this.isFreshLaunch, IObscuraVpnService.Stub.asInterface(service), this, this.osStatusManager, ) this.isFreshLaunch = false } override fun onServiceDisconnected(name: ComponentName?) { log.debug("onServiceDisconnected $name") this.ui.onDestroy() } override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { if (key == "color-scheme") { applyColorScheme() } } private fun applyColorScheme() { AppCompatDelegate.setDefaultNightMode( when (this.preferences.colorScheme) { Preferences.ColorScheme.Auto -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM Preferences.ColorScheme.Dark -> AppCompatDelegate.MODE_NIGHT_YES Preferences.ColorScheme.Light -> AppCompatDelegate.MODE_NIGHT_NO } ) } } ================================================ FILE: android/app/src/main/java/net/obscura/vpnclientapp/client/ErrorCodeException.kt ================================================ package net.obscura.vpnclientapp.client import androidx.annotation.Keep // This `Keep` annotation is applied defensively to ensure that this class won't be stripped even if // it's only constructed on the Rust side. @Keep data class ErrorCodeException(val errorCode: String) : Exception(errorCode) fun errorCodeOther() = ErrorCodeException("other") fun errorCodePurchaseFailed() = ErrorCodeException("purchaseFailed") fun errorCodePurchaseFailedAlreadyOwned() = ErrorCodeException("purchaseFailedAlreadyOwned") fun errorCodeLegacyAlwaysOn() = ErrorCodeException("errorLegacyAlwaysOn") fun errorCodeOtherAppAlwaysOn() = ErrorCodeException("errorOtherAppAlwaysOn") fun errorCodePermissionNotGranted() = ErrorCodeException("errorPermissionNotGranted") fun errorCodeUnsupportedOnOS() = ErrorCodeException("errorUnsupportedOnOS") ================================================ FILE: android/app/src/main/java/net/obscura/vpnclientapp/client/JsonConfig.kt ================================================ package net.obscura.vpnclientapp.client import kotlinx.serialization.json.ClassDiscriminatorMode import kotlinx.serialization.json.Json val jsonConfig = Json { this.classDiscriminatorMode = ClassDiscriminatorMode.NONE this.encodeDefaults = true this.ignoreUnknownKeys = true } ================================================ FILE: android/app/src/main/java/net/obscura/vpnclientapp/client/ManagerCmd.kt ================================================ package net.obscura.vpnclientapp.client import kotlinx.serialization.KeepGeneratedSerializer import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonObject import net.obscura.lib.util.ExternallyTaggedEnumVariantSerializer sealed interface ManagerCmd { @KeepGeneratedSerializer @Serializable(with = CreateDebugArchive.Serializer::class) data class CreateDebugArchive( val userFeedback: String?, ) : ManagerCmd { internal object Serializer : ExternallyTaggedEnumVariantSerializer("createDebugArchive", generatedSerializer()) } @KeepGeneratedSerializer @Serializable(with = GetStatus.Serializer::class) data class GetStatus(val knownVersion: String?) : ManagerCmd { internal object Serializer : ExternallyTaggedEnumVariantSerializer("getStatus", generatedSerializer()) } @KeepGeneratedSerializer @Serializable(with = SetTunnelArgs.Serializer::class) data class SetTunnelArgs( val args: Map? = null, val active: Boolean? = null, ) : ManagerCmd { internal object Serializer : ExternallyTaggedEnumVariantSerializer("setTunnelArgs", generatedSerializer()) } } ================================================ FILE: android/app/src/main/java/net/obscura/vpnclientapp/client/ManagerCmdOk.kt ================================================ package net.obscura.vpnclientapp.client import kotlinx.serialization.KeepGeneratedSerializer import kotlinx.serialization.Serializable import net.obscura.lib.util.ExternallyTaggedEnumSerializer import net.obscura.lib.util.ExternallyTaggedEnumVariantSerializer sealed interface ManagerCmdOk { @Serializable data class GetStatus( val accountId: String?, val autoConnect: Boolean, val inNewAccountFlow: Boolean, val version: String, val vpnStatus: VpnStatus, ) : ManagerCmdOk { @Serializable(with = VpnStatus.Serializer::class) sealed interface VpnStatus { object Serializer : ExternallyTaggedEnumSerializer( VpnStatus::class, listOf( Connected.Serializer, Connecting.Serializer, Disconnected.Serializer, ), ) @KeepGeneratedSerializer @Serializable(with = Connected.Serializer::class) class Connected : VpnStatus { internal object Serializer : ExternallyTaggedEnumVariantSerializer("connected", generatedSerializer()) } @KeepGeneratedSerializer @Serializable(with = Connecting.Serializer::class) class Connecting : VpnStatus { internal object Serializer : ExternallyTaggedEnumVariantSerializer("connecting", generatedSerializer()) } @KeepGeneratedSerializer @Serializable(with = Disconnected.Serializer::class) class Disconnected : VpnStatus { internal object Serializer : ExternallyTaggedEnumVariantSerializer("disconnected", generatedSerializer()) } } } } ================================================ FILE: android/app/src/main/java/net/obscura/vpnclientapp/client/ObscuraLibrary.java ================================================ package net.obscura.vpnclientapp.client; import android.app.Application; import android.content.Context; import androidx.annotation.Keep; import java.util.concurrent.CompletableFuture; public class ObscuraLibrary { /** Opaque handle returned by Rust initialization, required by all subsequent native FFI calls. */ @Keep public static class FfiHandle { private FfiHandle() {} } static FfiHandle load(Context context, String userAgent) { if (!Application.getProcessName().endsWith(":vpnservice")) { throw new IllegalStateException("Using this class outside of the :vpnservice process is not allowed."); } System.loadLibrary("obscuravpn_client"); return ObscuraLibrary.initialize(context.getFilesDir().getAbsolutePath(), userAgent); } static native FfiHandle initialize(String configDir, String userAgent); static native void jsonFfi(FfiHandle handle, String json, CompletableFuture future); static native void setNetworkInterface(FfiHandle handle, String name, int index); static native void unsetNetworkInterface(FfiHandle handle); static native void forwardLog(int level, String tag, String message, String messageId, String throwableString); } ================================================ FILE: android/app/src/main/java/net/obscura/vpnclientapp/client/RustFfi.kt ================================================ package net.obscura.vpnclientapp.client import android.content.Context import java.util.concurrent.CompletableFuture import net.obscura.lib.util.Logger class RustFfi(context: Context, userAgent: String) { private val handle: ObscuraLibrary.FfiHandle = ObscuraLibrary.load( context, userAgent, ) fun logger(tag: String) = Logger(tag) { params -> ObscuraLibrary.forwardLog( params.level.ordinal, params.tag, params.message, params.messageId ?: "JavaNoID", params.tr?.toString(), ) } fun jsonFfi(json: String, future: CompletableFuture) { ObscuraLibrary.jsonFfi(handle, json, future) } fun setNetworkInterface(name: String, index: Int) { ObscuraLibrary.setNetworkInterface(handle, name, index) } fun unsetNetworkInterface() { ObscuraLibrary.unsetNetworkInterface(handle) } } ================================================ FILE: android/app/src/main/java/net/obscura/vpnclientapp/helpers/Process.kt ================================================ package net.obscura.vpnclientapp.helpers import android.app.Application /** Ensures the calling process is :vpnservice. */ fun requireVpnServiceProcess() { val currentProcess = Application.getProcessName() if (!currentProcess.endsWith(":vpnservice")) { throw RuntimeException("Called outside of the :vpnservice process ($currentProcess)") } } /** Ensures the calling process is the main application process. */ fun requireUIProcess() { val currentProcess = Application.getProcessName() if (currentProcess.contains(":")) { throw RuntimeException("Called outside of the application process ($currentProcess)") } } ================================================ FILE: android/app/src/main/java/net/obscura/vpnclientapp/preferences/Preferences.kt ================================================ package net.obscura.vpnclientapp.preferences import android.content.Context import android.content.SharedPreferences import androidx.core.content.edit import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import net.obscura.vpnclientapp.client.jsonConfig import net.obscura.vpnclientapp.helpers.requireUIProcess class Preferences(context: Context) { init { requireUIProcess() } @Serializable enum class ColorScheme { @SerialName("dark") Dark, @SerialName("light") Light, @SerialName("auto") Auto, } private val sharedPreferences = context.getSharedPreferences("preferences", Context.MODE_PRIVATE) var colorScheme: ColorScheme get() = jsonConfig.decodeFromString(this.sharedPreferences.getString("color-scheme", "\"auto\"")!!) set(value) { this.sharedPreferences.edit(commit = true) { putString("color-scheme", jsonConfig.encodeToString(value)) } } fun registerListener(listener: SharedPreferences.OnSharedPreferenceChangeListener) { this.sharedPreferences.registerOnSharedPreferenceChangeListener(listener) } fun unregisterListener(listener: SharedPreferences.OnSharedPreferenceChangeListener) { this.sharedPreferences.unregisterOnSharedPreferenceChangeListener(listener) } } ================================================ FILE: android/app/src/main/java/net/obscura/vpnclientapp/services/ContextExtension.kt ================================================ package net.obscura.vpnclientapp.services import android.content.Context import android.content.Context.BIND_AUTO_CREATE import android.content.Intent import android.content.ServiceConnection import android.net.VpnService.prepare import net.obscura.lib.util.Logger import net.obscura.vpnclientapp.client.errorCodeOther private val log = Logger("ContextExtension") fun Context.bindVpnService(serviceConnection: ServiceConnection): Boolean { log.info("binding VPN service") val intent = Intent(this, ObscuraVpnService::class.java) return try { val isBinding = this.bindService(intent, serviceConnection, BIND_AUTO_CREATE) if (!isBinding) { log.error("missing permissions or service not found") } isBinding } catch (e: SecurityException) { log.error("missing permissions or service not found", tr = e) this.unbindVpnService(serviceConnection) false } } fun Context.unbindVpnService(serviceConnection: ServiceConnection) { log.info("unbinding VPN service") try { this.unbindService(serviceConnection) } catch (e: IllegalArgumentException) { log.error("VPN service connection not registered", tr = e) } } sealed interface PrepareResult { data class CreateProfile(val intent: Intent) : PrepareResult data object Ready : PrepareResult data object LegacyAlwaysOn : PrepareResult } fun Context.prepareVpnService(): PrepareResult = try { log.info("preparing VPN service") prepare(this)?.let { PrepareResult.CreateProfile(it) } ?: PrepareResult.Ready } catch (e: IllegalStateException) { // This is undocumented, but `prepare` throws when a Legacy VPN is set to Always-On. // Legacy VPN profiles are created manually using the "+" button on "Network & Internet" -> "VPN". // https://cs.android.com/android/platform/superproject/+/android-latest-release:frameworks/base/services/core/java/com/android/server/VpnManagerService.java;l=226;drc=0b5a5f8c78ce8e8800b527216b70db35489b7c41 // https://cs.android.com/android/platform/superproject/+/android-latest-release:frameworks/base/services/core/java/com/android/server/VpnManagerService.java;l=545-557;drc=0b5a5f8c78ce8e8800b527216b70db35489b7c41 log.error("a Legacy VPN profile is set to Always-On", tr = e) PrepareResult.LegacyAlwaysOn } fun Context.startVpnService(): Result = try { log.info("starting VPN service") this.startForegroundService(Intent(this, ObscuraVpnService::class.java)) Result.success(Unit) } catch (e: SecurityException) { log.error("missing permissions or service not found", tr = e) Result.failure(errorCodeOther()) } catch (e: IllegalStateException) { log.error("app not foregrounded", tr = e) Result.failure(errorCodeOther()) } ================================================ FILE: android/app/src/main/java/net/obscura/vpnclientapp/services/IntentExtension.kt ================================================ package net.obscura.vpnclientapp.services import android.content.Intent import net.obscura.lib.util.Logger import net.obscura.vpnclientapp.client.ErrorCodeException import net.obscura.vpnclientapp.client.errorCodeOther private val log = Logger("IntentExtension") private const val EXTRA_ID = "id" private const val EXTRA_VALUE = "value" private const val EXTRA_ERROR_CODE = "errorCode" fun Intent.putJsonFfiExtras(id: Long, value: String?, exception: Throwable?) { this.putExtra(EXTRA_ID, id) this.putExtra(EXTRA_VALUE, value) this.putExtra( EXTRA_ERROR_CODE, when (exception) { is ErrorCodeException -> exception.errorCode is Throwable -> { log.error("job $id threw unexpected exception type: $exception", tr = exception) null } else -> { if (value == null) { log.error("job $id completed with no response") } null } }, ) } data class JsonFfiIntentPayload(val id: Long, val result: Result) fun Intent.getJsonFfiExtras(): JsonFfiIntentPayload { val id = this.getLongExtra(EXTRA_ID, -1) val value = this.getStringExtra(EXTRA_VALUE) val errorCode = this.getStringExtra(EXTRA_ERROR_CODE) return JsonFfiIntentPayload( id, if (value != null) { log.trace("job $id completed with value: $value") Result.success(value) } else { log.trace("job $id completed with error code: $errorCode") Result.failure(errorCode?.let { ErrorCodeException(it) } ?: errorCodeOther()) }, ) } ================================================ FILE: android/app/src/main/java/net/obscura/vpnclientapp/services/ObscuraVpnService.kt ================================================ package net.obscura.vpnclientapp.services import android.Manifest import android.annotation.SuppressLint import android.app.PendingIntent import android.content.Intent import android.content.pm.PackageManager import android.content.pm.ServiceInfo import android.net.ConnectivityManager import android.net.ConnectivityManager.NetworkCallback import android.net.Network import android.net.NetworkCapabilities import android.net.NetworkRequest import android.net.VpnService import android.os.Build import android.os.Handler import android.os.IBinder import android.os.Looper import android.os.ParcelFileDescriptor import android.system.OsConstants import androidx.core.app.NotificationChannelCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat import java.net.NetworkInterface import java.util.concurrent.CompletableFuture import net.obscura.lib.util.Logger import net.obscura.vpnclientapp.BuildConfig import net.obscura.vpnclientapp.R import net.obscura.vpnclientapp.activities.MainActivity import net.obscura.vpnclientapp.client.ManagerCmd import net.obscura.vpnclientapp.client.ManagerCmdOk import net.obscura.vpnclientapp.client.RustFfi import net.obscura.vpnclientapp.client.jsonConfig import net.obscura.vpnclientapp.helpers.requireVpnServiceProcess import net.obscura.vpnclientapp.ui.JsonFfiBroadcastReceiver private val logNoFfi = Logger(ObscuraVpnService::class) @SuppressLint("VpnServicePolicy") class ObscuraVpnService : VpnService() { private class Binder( val service: ObscuraVpnService, ) : IObscuraVpnService.Stub() { override fun startTunnel(exitSelector: String?) { service.log.info("startTunnel $exitSelector", "CddrThRg") service.startTunnel(exitSelector) } override fun stopTunnel() { service.log.info("stopTunnel", "Gf6f2lwW") service.stopTunnel() } override fun jsonFfi( id: Long, command: String, ) { val future = CompletableFuture() service.rustFfi.jsonFfi(command, future) future.handle { value: String?, exception: Throwable? -> try { service.sendBroadcast( Intent(service, JsonFfiBroadcastReceiver::class.java).apply { this.putJsonFfiExtras(id, value, exception) } ) } catch (e: Throwable) { service.log.error("failed to broadcast job $id result: $e", messageId = "L74T4QBq", tr = e) } } } } companion object { private const val NOTIFICATION_CHANNEL_ID = "vpn_channel" private const val NOTIFICATION_ID = 1 private val instance = java.util.concurrent.atomic.AtomicReference(null) @androidx.annotation.Keep @JvmStatic fun ffiSetNetworkConfig(json: String): Int { val service = instance.get() if (service == null) { logNoFfi.error("ffiSetNetworkConfig called with no active service", "wK3xLm9p") return -1 } val config: OsNetworkConfig = try { jsonConfig.decodeFromString(json) } catch (e: Exception) { service.log.error("failed to parse os network config: $e", "yN4zPn0q", e) return -1 } val pfd = try { service.applyNetworkConfig(config) } catch (e: Exception) { service.log.error("failed to apply os network config: $e", "U6hVQEJR", e) return -1 } return pfd?.detachFd() ?: -1 } } private data class NetworkInterfaceProps(val name: String, val index: Int) private lateinit var rustFfi: RustFfi private lateinit var log: Logger private lateinit var handler: Handler private val connectivityManager get() = getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager private var vpnStatus: ManagerCmdOk.GetStatus.VpnStatus? = null private var currentNetwork: Network? = null override fun onCreate() { super.onCreate() logNoFfi.info("ObscuraVpnService onCreate entry") rustFfi = RustFfi(this, "obscura.net/android/${BuildConfig.VERSION_NAME}") log = rustFfi.logger(logNoFfi.tag) if (instance.getAndSet(this) != null) { log.error("instance already initialized", "xR4mNb7c") } requireVpnServiceProcess() log.info("onCreate", "vqiGa01f") handler = Handler(Looper.getMainLooper()) val networkRequest = NetworkRequest.Builder() .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) .build() val service = this connectivityManager.registerBestMatchingNetworkCallback( networkRequest, object : NetworkCallback() { override fun onAvailable(network: Network) { service.currentNetwork = network service.updateInterface(network) } override fun onLost(network: Network) { if (network == service.currentNetwork) { service.currentNetwork = null service.updateInterface(null) } } }, handler, ) createNotificationChannel() loadStatus(null) } private fun start() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { this.startForeground( NOTIFICATION_ID, this.buildNotification(), ServiceInfo.FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED, ) } else { this.startForeground(NOTIFICATION_ID, this.buildNotification()) } } override fun onStartCommand( intent: Intent?, flags: Int, startId: Int, ): Int { log.info("onStartCommand $intent ${intent?.action} $flags $startId", "C9rsG0uh") this.start() if (intent?.action == SERVICE_INTERFACE) { log.info("onStartCommand was system-initiated", "sktWFegO") this.startTunnel(null) } return START_STICKY } override fun onBind(intent: Intent?): IBinder { log.info("onBind $intent ${intent?.action}", "lckBR8hX") if (intent?.action == SERVICE_INTERFACE) { log.info("onBind was system-initiated", "4olaayXf") this.start() } return Binder(this) } override fun onRebind(intent: Intent?) { log.info("onRebind $intent ${intent?.action}", "AcVtL2Ub") super.onRebind(intent) if (intent?.action == SERVICE_INTERFACE) { log.info("onRebind was system-initiated", "YsdxJ7Ni") this.start() } } override fun onUnbind(intent: Intent?): Boolean { log.info("onUnbind $intent ${intent?.action}", "woAdA7g2") if (intent?.action == SERVICE_INTERFACE) { log.info("onUnbind was system-initiated", "oNOWQoPR") this.stopTunnel() this.stopForeground(STOP_FOREGROUND_DETACH) } return true } private fun onStatusUpdated(status: ManagerCmdOk.GetStatus) { log.info("status updated $status", "xXx7PxdD") vpnStatus = status.vpnStatus loadStatus(status.version) updateNotification() } override fun onRevoke() { super.onRevoke() log.info("onRevoke", "V3qS5kil") this.stopTunnel() this.stopForeground(STOP_FOREGROUND_DETACH) } override fun onDestroy() { super.onDestroy() if (instance.getAndSet(null) == null) { log.error("instance already cleared", "bQ5wKr8d") } log.info("onDestroy", "yNLRpqaN") stopTunnel() } private fun updateNotification() { // permission should already have been granted, but checking here to avoid crashes and to fix // the lint errors if ( ContextCompat.checkSelfPermission( this, Manifest.permission.POST_NOTIFICATIONS, ) == PackageManager.PERMISSION_GRANTED ) { NotificationManagerCompat.from(this).notify(NOTIFICATION_ID, buildNotification()) } } private fun buildNotification() = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID) .setContentIntent( PendingIntent.getActivity( this, 0, Intent().apply { this.action = Intent.ACTION_MAIN this.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP this.setClassName( BuildConfig.APPLICATION_ID, MainActivity::class.qualifiedName!!, ) }, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, ) ) .setContentTitle(getString(R.string.app_name)) .setContentText( getString( R.string.notification_vpn_text, when (this.vpnStatus) { is ManagerCmdOk.GetStatus.VpnStatus.Connected -> getString(R.string.notification_vpn_status_connected) is ManagerCmdOk.GetStatus.VpnStatus.Connecting -> getString(R.string.notification_vpn_status_connecting) is ManagerCmdOk.GetStatus.VpnStatus.Disconnected, null -> getString(R.string.notification_vpn_status_disconnected) }, ), ) .setSmallIcon(R.drawable.ic_stat_name) .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) .setOngoing(true) .setLocalOnly(true) .setOnlyAlertOnce(true) .setCategory(NotificationCompat.CATEGORY_SERVICE) .build() private fun createNotificationChannel() { NotificationManagerCompat.from(this) .createNotificationChannel( NotificationChannelCompat.Builder( NOTIFICATION_CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_LOW, ) .setName(getString(R.string.notification_channel_vpn_name)) .build(), ) } private fun loadStatus(knownVersion: String?) { log.info("load status $knownVersion", "8pXipD8h") CompletableFuture().also { rustFfi.jsonFfi( jsonConfig.encodeToString(ManagerCmd.GetStatus(knownVersion)), it, ) it.handle { data, tr -> log.info("getStatus completed $data", "oiAyY4gh", tr) data?.let { data -> onStatusUpdated(jsonConfig.decodeFromString(data)) } } } } private fun setTunnelArgs(exit: String?, active: Boolean?) { CompletableFuture().also { rustFfi.jsonFfi( jsonConfig.encodeToString( ManagerCmd.SetTunnelArgs( args = exit?.let { exit -> jsonConfig.decodeFromString(exit) }, active, ), ), it, ) } } private fun stopTunnel() { setTunnelArgs(null, false) } private fun startTunnel(exitSelector: String?) { setTunnelArgs(exitSelector, true) } private fun applyNetworkConfig(networkConfig: OsNetworkConfig): ParcelFileDescriptor? { log.info("applying network config", "q9cnmRY0") val pfd = Builder() .apply { // always disallow current app so it doesn't get routed through the VPN addDisallowedApplication(applicationInfo.packageName) setMtu(networkConfig.mtu) // Inherit meteredness from the underlying network (set via setUnderlyingNetworks). // Without this, VpnService.Builder defaults to always marking the VPN as metered, // regardless of the underlying network. setMetered(false) if (!networkConfig.useSystemDns) { networkConfig.dns.forEach { addDnsServer(it) } } networkConfig.ipv4.split("/").let { addAddress(it[0], if (it.size == 2) it[1].toInt() else 32) } networkConfig.ipv6.split("/").let { addAddress(it[0], if (it.size == 2) it[1].toInt() else 128) } addRoute("0.0.0.0", 0) addRoute("::", 0) allowFamily(OsConstants.AF_INET) allowFamily(OsConstants.AF_INET6) } .establish() if (pfd == null) { log.error("VpnService.Builder.establish() returned null", "tR7uWe2x") } return pfd } private fun getNetworkInterfaceProps(network: Network?): NetworkInterfaceProps? { val network = network ?: return null val linkProperties = this.connectivityManager.getLinkProperties(network) ?: run { log.error("failed to get link properties for network: $network", "W0JKaOGP") return null } val name = linkProperties.interfaceName ?: run { log.error("network has no interface name: $network", "ukjpaGLl") return null } val ni = NetworkInterface.getByName(name) ?: run { log.error("failed to get interface by name: $name", "JvEt0GtR") return null } log.info("setting network interface: $name ${ni.index}", "pOsKRATd") return NetworkInterfaceProps(name, ni.index) } private fun updateInterface(network: Network?) { log.info("network interface changed: $network", "crWriIOe") this.setUnderlyingNetworks(if (network != null) arrayOf(network) else emptyArray()) val networkInterface = this.getNetworkInterfaceProps(network) if (networkInterface != null) { rustFfi.setNetworkInterface(networkInterface.name, networkInterface.index) } else { rustFfi.unsetNetworkInterface() } } } ================================================ FILE: android/app/src/main/java/net/obscura/vpnclientapp/services/OsNetworkConfig.kt ================================================ package net.obscura.vpnclientapp.services import kotlinx.serialization.Serializable // Keep synchronized with rustlib/src/network_config.rs @Serializable data class OsNetworkConfig( val dns: List, val ipv4: String, val ipv6: String, val mtu: Int, val useSystemDns: Boolean, ) ================================================ FILE: android/app/src/main/java/net/obscura/vpnclientapp/sharing/DebugArchiveFileProvider.java ================================================ package net.obscura.vpnclientapp.sharing; import androidx.core.content.FileProvider; import net.obscura.vpnclientapp.R; // We need to extend `FileProvider` because some OEMs strip `meta-data` tags from the manifest: // https://github.com/androidx/androidx/commit/a4385569db989747caf6b110b345a09ceb86acc7 // ...unfortunately, Kotlin subclasses don't inherit static methods, so we need to use Java. public class DebugArchiveFileProvider extends FileProvider { public DebugArchiveFileProvider() { super(R.xml.debug_archive_file_provider_paths); } } ================================================ FILE: android/app/src/main/java/net/obscura/vpnclientapp/ui/BillingModule.kt ================================================ package net.obscura.vpnclientapp.ui import android.content.Context import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.ActivityRetainedLifecycle import dagger.hilt.android.components.ActivityRetainedComponent import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.scopes.ActivityRetainedScoped import net.obscura.vpnclientapp.BillingFacade // Lifecycle/scope discussion: // https://www.revenuecat.com/blog/engineering/hilt-sdk-lifecycle/ @Module @InstallIn(ActivityRetainedComponent::class) object BillingModule { @Provides @ActivityRetainedScoped fun provideBillingFacade( @ApplicationContext context: Context, lifecycle: ActivityRetainedLifecycle, ): BillingFacade { val billing = BillingFacade(context) lifecycle.addOnClearedListener { billing.destroy() } return billing } } ================================================ FILE: android/app/src/main/java/net/obscura/vpnclientapp/ui/JsonFfiBroadcastReceiver.kt ================================================ package net.obscura.vpnclientapp.ui import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicLong import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.completeWith import net.obscura.lib.util.Logger import net.obscura.vpnclientapp.services.IObscuraVpnService import net.obscura.vpnclientapp.services.getJsonFfiExtras private val log = Logger(JsonFfiBroadcastReceiver::class) class JsonFfiBroadcastReceiver : BroadcastReceiver() { companion object { private val waiting by lazy { ConcurrentHashMap>() } private val currentId = AtomicLong(0) internal fun waitForResponse( binder: IObscuraVpnService, cmd: String, ) = CompletableDeferred().also { job -> val id = this.currentId.incrementAndGet() log.trace("job $id registered: $cmd") try { binder.jsonFfi(id, cmd) this.waiting[id] = job } catch (e: Throwable) { log.error("job $id failed: $e", tr = e) job.completeExceptionally(e) } } } override fun onReceive(context: Context, intent: Intent) { val args = intent.getJsonFfiExtras() waiting.remove(args.id)?.completeWith(args.result) ?: run { log.error("job ${args.id} already completed (or never registered)") } } } ================================================ FILE: android/app/src/main/java/net/obscura/vpnclientapp/ui/NetworkStatusObserver.kt ================================================ package net.obscura.vpnclientapp.ui import android.content.Context import android.net.ConnectivityManager import android.net.Network import android.net.NetworkCapabilities import android.net.NetworkRequest import net.obscura.lib.util.Logger private val log = Logger(NetworkStatusObserver::class) // Network callbacks run on the "connectivity thread" by default: // https://developer.android.com/develop/connectivity/network-ops/reading-network-state#listening-events internal class NetworkStatusObserver(context: Context, private val callback: Callback) : ConnectivityManager.NetworkCallback() { interface Callback { fun onAvailableNetworksChanged(availableNetworks: Int) } private var availableNetworks = 0 private val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager init { val networkRequest = NetworkRequest.Builder() .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) .addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) .build() this.connectivityManager.registerNetworkCallback(networkRequest, this) } override fun onAvailable(network: Network) { this.availableNetworks += 1 log.debug("network available: $network (available networks: ${this.availableNetworks})") this.callback.onAvailableNetworksChanged(this.availableNetworks) } override fun onLost(network: Network) { this.availableNetworks = (this.availableNetworks - 1).coerceAtLeast(0) log.debug("network lost: $network (available networks: ${this.availableNetworks})") this.callback.onAvailableNetworksChanged(this.availableNetworks) } } ================================================ FILE: android/app/src/main/java/net/obscura/vpnclientapp/ui/ObscuraUI.kt ================================================ package net.obscura.vpnclientapp.ui import android.content.Context import android.net.Uri import android.util.AttributeSet import android.widget.FrameLayout import androidx.core.graphics.Insets import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.postDelayed import com.google.android.material.bottomnavigation.BottomNavigationView import com.google.android.material.navigation.NavigationBarView import net.obscura.lib.util.Logger import net.obscura.vpnclientapp.R import net.obscura.vpnclientapp.activities.MainActivity import net.obscura.vpnclientapp.client.ManagerCmdOk import net.obscura.vpnclientapp.services.IObscuraVpnService private val log = Logger(ObscuraUI::class) class ObscuraUI @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : FrameLayout(context, attrs) { private lateinit var vpnStatusObserver: VpnStatusObserver val canGoBack get() = (webView?.canGoBack() ?: false) || (bottomNavigation.selectedItemId != R.id.nav_connection) private lateinit var webViewContainer: FrameLayout private lateinit var bottomNavigation: BottomNavigationView private var loggedIn: Boolean = false private var webView: ObscuraWebView? = null private val itemReselectedListener = NavigationBarView.OnItemReselectedListener { navigateToTab(it.itemId) } private val itemSelectedListener = NavigationBarView.OnItemSelectedListener { navigateToTab(it.itemId) true } private fun setLoggedIn(loggedIn: Boolean) { this.bottomNavigation.visibility = if (loggedIn) VISIBLE else GONE this.loggedIn = loggedIn } override fun onFinishInflate() { super.onFinishInflate() this.webViewContainer = this.findViewById(R.id.web_view_container) this.bottomNavigation = this.findViewById(R.id.nav_view) this.bottomNavigation.visibility = GONE this.bottomNavigation.setOnItemReselectedListener(itemReselectedListener) this.bottomNavigation.setOnItemSelectedListener(itemSelectedListener) // TODO: Synchronize padding with IME animation // https://linear.app/soveng/issue/OBS-3233/android-ime-animation-jank // TODO: Edge-to-edge `WebView` // https://linear.app/soveng/issue/OBS-3237/android-edge-to-edge-webview ViewCompat.setOnApplyWindowInsetsListener(this.webViewContainer) { view, windowInsets -> val insetsMask = WindowInsetsCompat.Type.displayCutout() .or(WindowInsetsCompat.Type.navigationBars()) .or(WindowInsetsCompat.Type.statusBars()) val insets = windowInsets.getInsets(insetsMask) val imeMask = WindowInsetsCompat.Type.ime() val bottom = if (windowInsets.isVisible(imeMask)) { windowInsets.getInsets(imeMask).bottom } else if (!this.loggedIn) { insets.bottom } else { 0 } // Only use non-zero insets when there's overlap // https://developer.android.com/develop/ui/views/layout/webapps/understand-window-insets#bounds-overlap view.setPadding(insets.left, insets.top, insets.right, bottom) // Child `WebView` should ignore any insets we applied here // https://developer.android.com/develop/ui/views/layout/webapps/understand-window-insets#inset-handling WindowInsetsCompat.Builder(windowInsets).setInsets(insetsMask.or(imeMask), Insets.NONE).build() } ViewCompat.setOnApplyWindowInsetsListener(this.bottomNavigation) { view, windowInsets -> // Hide bottom nav when IME is visible // https://github.com/software-mansion/react-native-screens/issues/3647 val showBottomNav = this.loggedIn && !windowInsets.isVisible(WindowInsetsCompat.Type.ime()) view.visibility = if (showBottomNav) VISIBLE else GONE val systemBars = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) view.setPadding(systemBars.left, 0, systemBars.right, systemBars.bottom) WindowInsetsCompat.CONSUMED } } fun onCreate( isFreshLaunch: Boolean, binder: IObscuraVpnService, mainActivity: MainActivity, osStatusManager: OsStatusManager, ) { onDestroy() this.vpnStatusObserver = VpnStatusObserver( binder, object : VpnStatusObserver.Callback { private var isAutoConnectEligible = isFreshLaunch override suspend fun onStatusChanged(status: ManagerCmdOk.GetStatus) { osStatusManager.update { this.vpnStatus = when (status.vpnStatus) { is ManagerCmdOk.GetStatus.VpnStatus.Connected -> OsStatus.OsVpnStatus.Connected is ManagerCmdOk.GetStatus.VpnStatus.Connecting -> OsStatus.OsVpnStatus.Connecting is ManagerCmdOk.GetStatus.VpnStatus.Disconnected -> OsStatus.OsVpnStatus.Disconnected } } this@ObscuraUI.setLoggedIn(status.accountId != null && !status.inNewAccountFlow) val shouldAutoConnect = this.isAutoConnectEligible && status.autoConnect && status.vpnStatus is ManagerCmdOk.GetStatus.VpnStatus.Disconnected this.isAutoConnectEligible = false if (shouldAutoConnect) { mainActivity.vpnPermissionRequestManager .requestVpnStart() .mapCatching { binder.startTunnel(null) } .onSuccess { log.info("auto-connected VPN") } .onFailure { log.error("failed to auto-connect VPN: ${it.message}", tr = it) } } } }, ) mainActivity.lifecycle.addObserver(this.vpnStatusObserver) webView = ObscuraWebView(context, binder, mainActivity, osStatusManager).apply { webViewContainer.addView( this, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT), ) onPageLoadedCallback = { if (bottomNavigation.selectedItemId != R.id.nav_connection) { // TODO: make sure UI picks this up correctly var delay = 0L while (delay < 100L) { postDelayed(delay) { navigateToTab(bottomNavigation.selectedItemId) } delay += 10 } } } } } fun onResume() { webView?.onResume() } fun onPause() { webView?.onPause() } fun onDestroy() { bottomNavigation.visibility = GONE webViewContainer.removeAllViews() this.webView?.bridge?.cancel() this.webView?.destroy() this.webView = null } override fun invalidate() { super.invalidate() this.webView?.invalidate() } fun goBack() { if (webView?.canGoBack() ?: false) { webView?.goBack() } else if (bottomNavigation.selectedItemId != R.id.nav_connection) { bottomNavigation.selectedItemId = R.id.nav_connection } } private fun navigateToTab(id: Int) { val path = when (id) { R.id.nav_connection -> "" R.id.nav_location -> "location" R.id.nav_account -> "account" R.id.nav_settings -> "settings" R.id.nav_about -> "about" else -> { log.error("unrecognized view id: $id") return } } this.webView?.navigate(path) } fun handleObscuraUri(uri: Uri) { log.debug("handling deep link: $uri") val id = when (uri.path) { "/account" -> R.id.nav_account "/location" -> R.id.nav_location else -> { log.error("unrecognized path for deep link: $uri") return } } this.bottomNavigation.selectedItemId = id } } ================================================ FILE: android/app/src/main/java/net/obscura/vpnclientapp/ui/ObscuraWebView.kt ================================================ package net.obscura.vpnclientapp.ui import android.annotation.SuppressLint import android.content.Context import android.content.Intent import android.util.AttributeSet import android.webkit.WebMessage import android.webkit.WebResourceRequest import android.webkit.WebView import android.webkit.WebViewClient import androidx.core.net.toUri import androidx.webkit.WebViewAssetLoader import net.obscura.vpnclientapp.activities.MainActivity import net.obscura.vpnclientapp.services.IObscuraVpnService import net.obscura.vpnclientapp.ui.bridge.WebCmdBridge @SuppressLint("SetJavaScriptEnabled", "ViewConstructor") class ObscuraWebView @JvmOverloads constructor( context: Context, binder: IObscuraVpnService, mainActivity: MainActivity, osStatusManager: OsStatusManager, attrs: AttributeSet? = null, ) : WebView(context, attrs) { companion object { val ORIGIN = "https://appassets.androidplatform.net".toUri() val HOME = "$ORIGIN/assets/web/index.html" } val bridge = WebCmdBridge(context, binder, mainActivity, osStatusManager) { data -> post { postWebMessage(WebMessage("android/$data"), ORIGIN) } } var onPageLoadedCallback: ((String) -> Unit)? = null init { settings.domStorageEnabled = true settings.javaScriptEnabled = true addJavascriptInterface(bridge, "obscuraAndroidCommandBridge") WebViewAssetLoader.Builder() .addPathHandler("/assets/", WebViewAssetLoader.AssetsPathHandler(context)) .addPathHandler("/res/", WebViewAssetLoader.ResourcesPathHandler(context)) .build() .also { assetLoader -> webViewClient = object : WebViewClient() { override fun shouldOverrideUrlLoading( view: WebView, request: WebResourceRequest, ): Boolean { val shouldOverride = request.url.host != ORIGIN.host if (shouldOverride && request.isForMainFrame) { context.startActivity( Intent( Intent.ACTION_VIEW, if (request.url.scheme == "http") { request.url.buildUpon().scheme("https").build() } else { request.url }, ) ) } return shouldOverride } override fun shouldInterceptRequest( view: WebView?, request: WebResourceRequest, ) = assetLoader.shouldInterceptRequest(request.url) override fun onPageFinished(view: WebView?, url: String) { super.onPageFinished(view, url) onPageLoadedCallback?.invoke(url) } } } loadUrl(HOME) } fun navigate(path: String) { postWebMessage(WebMessage("android-navigate/$path"), ORIGIN) } } ================================================ FILE: android/app/src/main/java/net/obscura/vpnclientapp/ui/OsStatus.kt ================================================ package net.obscura.vpnclientapp.ui import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class OsStatus( val version: String, val internetAvailable: Boolean, val osVpnStatus: OsVpnStatus, val srcVersion: String, val updaterStatus: UpdaterStatus, val debugBundleStatus: DebugBundleStatus, val canSendMail: Boolean, val loginItemStatus: LoginItemStatus?, val playBilling: Boolean, ) { // TODO: https://linear.app/soveng/issue/OBS-2640/change-nevpnstatus-to-be-platform-agnostic @Serializable enum class OsVpnStatus { @SerialName("disconnected") Disconnected, @SerialName("connecting") Connecting, @SerialName("connected") Connected, } @Serializable data class LoginItemStatus(val registered: Boolean, val error: String?) @Serializable data class DebugBundleStatus( var inProgress: Boolean?, var latestPath: String?, var inProgressCounter: Long, ) @Serializable data class UpdaterStatus( val type: String, // TODO UpdaterStatusType val appcast: AppcastSummary?, val error: String?, val errorCode: Long?, ) { @Serializable data class AppcastSummary( val date: String, val description: String, val version: String, val minSystemVersionSdk: Boolean, ) } } ================================================ FILE: android/app/src/main/java/net/obscura/vpnclientapp/ui/OsStatusManager.kt ================================================ package net.obscura.vpnclientapp.ui import android.content.Context import dagger.hilt.android.qualifiers.ApplicationContext import java.util.UUID import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Deferred import kotlinx.coroutines.completeWith import net.obscura.lib.util.Logger import net.obscura.vpnclientapp.BuildConfig import net.obscura.vpnclientapp.client.jsonConfig private val log = Logger(OsStatusManager::class) @Singleton // Prevents loss of state on activity destruction class OsStatusManager @Inject constructor(@ApplicationContext context: Context) : NetworkStatusObserver.Callback { data class State( var debugBundleStatus: OsStatus.DebugBundleStatus = OsStatus.DebugBundleStatus( inProgress = false, latestPath = null, inProgressCounter = 0, ), var internetAvailable: Boolean = false, var vpnStatus: OsStatus.OsVpnStatus = OsStatus.OsVpnStatus.Disconnected, ) private data class VersionedState(val version: UUID, val state: State) private var current = VersionedState(UUID.randomUUID(), State()) private val waiting = ArrayList>() init { NetworkStatusObserver(context, this) } override fun onAvailableNetworksChanged(availableNetworks: Int) { this.update { this.internetAvailable = availableNetworks > 0 } } @Synchronized fun update(block: State.() -> Unit = {}) { val version = UUID.randomUUID() val result = runCatching { block(this.current.state) OsStatus( version = version.toString(), internetAvailable = this.current.state.internetAvailable, osVpnStatus = this.current.state.vpnStatus, srcVersion = BuildConfig.VERSION_NAME, updaterStatus = OsStatus.UpdaterStatus( type = "uninitiated", appcast = null, error = null, errorCode = null, ), debugBundleStatus = this.current.state.debugBundleStatus, canSendMail = true, loginItemStatus = null, playBilling = @Suppress("KotlinConstantConditions", "SimplifyBooleanWithConstants") (BuildConfig.FLAVOR == "play"), ) .let { jsonConfig.encodeToString(it) } } this.current = VersionedState(version, this.current.state) log.debug("updated OS status: ${this.current}") this.waiting.forEach { it.completeWith(result) } this.waiting.clear() } @Synchronized fun waitForUpdate(knownVersion: String?): Deferred = CompletableDeferred().also { this.waiting.add(it) if (this.current.version.toString() != knownVersion) { this.update() } } } ================================================ FILE: android/app/src/main/java/net/obscura/vpnclientapp/ui/VpnPermissionRequestManager.kt ================================================ package net.obscura.vpnclientapp.ui import android.Manifest import android.app.Activity.RESULT_CANCELED import android.app.Activity.RESULT_OK import android.content.Intent import android.content.pm.PackageManager import android.os.Build import androidx.activity.result.ActivityResult import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts import androidx.core.content.ContextCompat import androidx.fragment.app.FragmentActivity import dagger.hilt.android.scopes.ActivityScoped import javax.inject.Inject import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.TimeSource import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.onSubscription import net.obscura.lib.util.Logger import net.obscura.vpnclientapp.client.errorCodeLegacyAlwaysOn import net.obscura.vpnclientapp.client.errorCodeOther import net.obscura.vpnclientapp.client.errorCodeOtherAppAlwaysOn import net.obscura.vpnclientapp.client.errorCodePermissionNotGranted import net.obscura.vpnclientapp.services.PrepareResult import net.obscura.vpnclientapp.services.prepareVpnService import net.obscura.vpnclientapp.services.startVpnService private val log = Logger(VpnPermissionRequestManager::class) @ActivityScoped class VpnPermissionRequestManager @Inject constructor(private val activity: FragmentActivity) { private val vpnPermissionRequestCancelThreshold: Duration = 150.milliseconds private val vpnPermissionRequestResultTx = MutableSharedFlow(extraBufferCapacity = 1) private val vpnPermissionRequestResultRx = this.vpnPermissionRequestResultTx.asSharedFlow() private val vpnPermissionRequestLauncher: ActivityResultLauncher = this.activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> log.debug("VPN permission request activity result: $result") val wasEmitted = this.vpnPermissionRequestResultTx.tryEmit(result) if (!wasEmitted) { log.warn("multiple VPN permission requests while collecting") } } private val notificationPermissionRequestLauncher: ActivityResultLauncher = this.activity.registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> // We don't actually care if we're granted permission, since this is // just the user's preference between "classic" foreground service // notifications vs. the modern Task Manager. log.debug("notification permission request activity result: $isGranted") } private fun requestNotificationPermission() { if ( Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && ContextCompat.checkSelfPermission(this.activity, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED ) { this.notificationPermissionRequestLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) } } private fun onSuccess(): Result { this.requestNotificationPermission() return this.activity.startVpnService() } // Android 12+ has no API for checking if another app has Always-On enabled. Instead, the permission request // receives `RESULT_CANCELED` immediately, requiring us to use a heuristic to determine that we're silently // unable to request VPN permissions from the user. private fun onCanceled(vpnPermissionRequestStart: TimeSource.Monotonic.ValueTimeMark): Result { val vpnPermissionRequestEnd = TimeSource.Monotonic.markNow() val elapsed = vpnPermissionRequestEnd - vpnPermissionRequestStart log.debug("$elapsed elapsed between VPN permission request launch and cancellation") return if (elapsed > this.vpnPermissionRequestCancelThreshold) { log.debug("heuristic determined that cancellation was user-initiated") Result.failure(errorCodePermissionNotGranted()) } else { log.debug("heuristic determined that cancellation was automatic") Result.failure(errorCodeOtherAppAlwaysOn()) } } suspend fun requestVpnStart(): Result = when (val prepareResult = this.activity.prepareVpnService()) { is PrepareResult.CreateProfile -> { val vpnPermissionRequestStart = TimeSource.Monotonic.markNow() val vpnPermissionRequestResult = this.vpnPermissionRequestResultRx .onSubscription { this@VpnPermissionRequestManager.vpnPermissionRequestLauncher.launch(prepareResult.intent) } .firstOrNull() ?: run { log.error("VPN permission request result flow was empty") return Result.failure(errorCodeOther()) } when (vpnPermissionRequestResult.resultCode) { RESULT_OK -> this.onSuccess() RESULT_CANCELED -> this.onCanceled(vpnPermissionRequestStart) else -> { log.error("unexpected VPN start activity result: $vpnPermissionRequestResult") Result.failure(errorCodeOther()) } } } is PrepareResult.Ready -> this.onSuccess() is PrepareResult.LegacyAlwaysOn -> Result.failure(errorCodeLegacyAlwaysOn()) } } ================================================ FILE: android/app/src/main/java/net/obscura/vpnclientapp/ui/VpnStatusObserver.kt ================================================ package net.obscura.vpnclientapp.ui import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope import kotlin.time.Duration.Companion.milliseconds import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import net.obscura.lib.util.Logger import net.obscura.vpnclientapp.client.ManagerCmd import net.obscura.vpnclientapp.client.ManagerCmdOk import net.obscura.vpnclientapp.client.jsonConfig import net.obscura.vpnclientapp.services.IObscuraVpnService private val log = Logger(VpnStatusObserver::class) class VpnStatusObserver( private val binder: IObscuraVpnService, private val callback: Callback, ) : DefaultLifecycleObserver { interface Callback { suspend fun onStatusChanged(status: ManagerCmdOk.GetStatus) } private var job: Job? = null override fun onStart(owner: LifecycleOwner) { this.job = owner.lifecycleScope.launch { var knownVersion: String? = null while (this.isActive) { try { val status = JsonFfiBroadcastReceiver.waitForResponse( this@VpnStatusObserver.binder, jsonConfig.encodeToString(ManagerCmd.GetStatus(knownVersion)), ) .await() .let { jsonConfig.decodeFromString(it) } knownVersion = status.version log.debug("updated VPN status: $status") this@VpnStatusObserver.callback.onStatusChanged(status) } catch (e: CancellationException) { log.debug("VPN status job canceled: ${e.message}") throw e } catch (e: Throwable) { log.error("failed to update VPN status: $e", tr = e) } delay(10.milliseconds) } } } override fun onStop(owner: LifecycleOwner) { this.job?.cancel(CancellationException("lifecycle owner stopped")) } override fun onDestroy(owner: LifecycleOwner) { this.job?.cancel(CancellationException("lifecycle owner destroyed")) owner.lifecycle.removeObserver(this) } } ================================================ FILE: android/app/src/main/java/net/obscura/vpnclientapp/ui/bridge/WebCmd.kt ================================================ package net.obscura.vpnclientapp.ui.bridge import android.content.Context import kotlinx.serialization.KeepGeneratedSerializer import kotlinx.serialization.Serializable import net.obscura.lib.util.ExternallyTaggedEnumSerializer import net.obscura.lib.util.ExternallyTaggedEnumVariantSerializer import net.obscura.vpnclientapp.activities.MainActivity import net.obscura.vpnclientapp.client.ManagerCmd import net.obscura.vpnclientapp.client.errorCodeUnsupportedOnOS import net.obscura.vpnclientapp.client.jsonConfig import net.obscura.vpnclientapp.preferences.Preferences import net.obscura.vpnclientapp.services.IObscuraVpnService import net.obscura.vpnclientapp.ui.JsonFfiBroadcastReceiver import net.obscura.vpnclientapp.ui.OsStatusManager private val jsonUnit = jsonConfig.encodeToString(Unit) @Serializable(with = WebCmd.Serializer::class) internal sealed interface WebCmd { object Serializer : ExternallyTaggedEnumSerializer( WebCmd::class, listOf( DebuggingArchive.Serializer, EmailDebugArchive.Serializer, GetOsStatus.Serializer, JsonFfiCmd.Serializer, PurchaseSubscription.Serializer, RevealItemInDir.Serializer, SetColorScheme.Serializer, SetFeatureFlag.Serializer, ShareDebugArchive.Serializer, StartTunnel.Serializer, StopTunnel.Serializer, ), ) data class Args( val context: Context, val binder: IObscuraVpnService, val mainActivity: MainActivity, val osStatusManager: OsStatusManager, ) suspend fun run(args: Args): String @KeepGeneratedSerializer @Serializable(with = DebuggingArchive.Serializer::class) data class DebuggingArchive( val userFeedback: String?, ) : WebCmd { internal object Serializer : ExternallyTaggedEnumVariantSerializer("debuggingArchive", generatedSerializer()) // Eventually, all platforms should just use the JSON FFI command to create debug archives, but for now, // adapting the command here is the least invasive change. // TODO: https://linear.app/soveng/issue/OBS-3095/cross-platform-debug-archive-story override suspend fun run(args: Args) = jsonUnit.also { args.osStatusManager.update { this.debugBundleStatus.inProgress = true } val path = runCatching { JsonFfiCmd(jsonConfig.encodeToString(ManagerCmd.CreateDebugArchive(userFeedback))).run(args).let { jsonConfig.decodeFromString(it) } } args.osStatusManager.update { this.debugBundleStatus.inProgress = false path.onSuccess { this.debugBundleStatus.latestPath = it } } path.getOrThrow() } } @KeepGeneratedSerializer @Serializable(with = EmailDebugArchive.Serializer::class) data class EmailDebugArchive( val path: String, val subject: String, val body: String, ) : WebCmd { internal object Serializer : ExternallyTaggedEnumVariantSerializer("emailDebugArchive", generatedSerializer()) override suspend fun run(args: Args) = jsonUnit.also { shareDebugArchive(args.context, path, true, subject, body) } } @KeepGeneratedSerializer @Serializable(with = GetOsStatus.Serializer::class) data class GetOsStatus(val knownVersion: String?) : WebCmd { internal object Serializer : ExternallyTaggedEnumVariantSerializer("getOsStatus", generatedSerializer()) override suspend fun run(args: Args) = args.osStatusManager.waitForUpdate(knownVersion).await() } @KeepGeneratedSerializer @Serializable(with = JsonFfiCmd.Serializer::class) data class JsonFfiCmd( val cmd: String, ) : WebCmd { internal object Serializer : ExternallyTaggedEnumVariantSerializer("jsonFfiCmd", generatedSerializer()) override suspend fun run(args: Args) = JsonFfiBroadcastReceiver.waitForResponse(args.binder, this.cmd).await() } @KeepGeneratedSerializer @Serializable(with = PurchaseSubscription.Serializer::class) class PurchaseSubscription : WebCmd { internal object Serializer : ExternallyTaggedEnumVariantSerializer( "purchaseSubscription", generatedSerializer(), ) override suspend fun run(args: Args) = args.mainActivity.billingFacade.launchFlow(args.mainActivity).let { jsonConfig.encodeToString(it) } } @KeepGeneratedSerializer @Serializable(with = RevealItemInDir.Serializer::class) data class RevealItemInDir( val path: String, ) : WebCmd { internal object Serializer : ExternallyTaggedEnumVariantSerializer( "revealItemInDir", generatedSerializer(), ) override suspend fun run(args: Args) = throw errorCodeUnsupportedOnOS() } @KeepGeneratedSerializer @Serializable(with = SetColorScheme.Serializer::class) data class SetColorScheme( val value: Preferences.ColorScheme, ) : WebCmd { internal object Serializer : ExternallyTaggedEnumVariantSerializer( "setColorScheme", generatedSerializer(), ) override suspend fun run(args: Args) = jsonUnit.also { Preferences(args.context).colorScheme = this.value } } @KeepGeneratedSerializer @Serializable(with = SetFeatureFlag.Serializer::class) data class SetFeatureFlag( val flag: String, val active: Boolean, ) : WebCmd { internal object Serializer : ExternallyTaggedEnumVariantSerializer( "setFeatureFlag", generatedSerializer(), ) override suspend fun run(args: Args) = throw errorCodeUnsupportedOnOS() } @KeepGeneratedSerializer @Serializable(with = ShareDebugArchive.Serializer::class) data class ShareDebugArchive( val path: String, ) : WebCmd { internal object Serializer : ExternallyTaggedEnumVariantSerializer( "shareDebugArchive", generatedSerializer(), ) override suspend fun run(args: Args) = jsonUnit.also { shareDebugArchive(args.context, path, false) } } @KeepGeneratedSerializer @Serializable(with = StartTunnel.Serializer::class) data class StartTunnel( val tunnelArgs: String? = null, ) : WebCmd { internal object Serializer : ExternallyTaggedEnumVariantSerializer( "startTunnel", generatedSerializer(), ) override suspend fun run(args: Args) = jsonUnit.also { args.mainActivity.vpnPermissionRequestManager.requestVpnStart().getOrThrow() args.binder.startTunnel(this@StartTunnel.tunnelArgs) } } @KeepGeneratedSerializer @Serializable(with = StopTunnel.Serializer::class) class StopTunnel : WebCmd { internal object Serializer : ExternallyTaggedEnumVariantSerializer( "stopTunnel", generatedSerializer(), ) override suspend fun run(args: Args) = jsonUnit.also { args.binder.stopTunnel() } } } ================================================ FILE: android/app/src/main/java/net/obscura/vpnclientapp/ui/bridge/WebCmdBridge.kt ================================================ package net.obscura.vpnclientapp.ui.bridge import android.content.Context import android.webkit.JavascriptInterface import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import net.obscura.lib.util.Logger import net.obscura.vpnclientapp.activities.MainActivity import net.obscura.vpnclientapp.client.ErrorCodeException import net.obscura.vpnclientapp.client.errorCodeOther import net.obscura.vpnclientapp.client.jsonConfig import net.obscura.vpnclientapp.services.IObscuraVpnService import net.obscura.vpnclientapp.ui.OsStatusManager private val log = Logger(WebCmdBridge::class) class WebCmdBridge( private val context: Context, private val binder: IObscuraVpnService, private val mainActivity: MainActivity, private val osStatusManager: OsStatusManager, private val postMessage: (data: String) -> Unit, ) { private val scope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob()) @Serializable private data class Accept( val id: Long, val data: String, ) @Serializable private data class Reject( val id: Long, val error: String, ) private fun accept(id: Long, data: String) { this.postMessage(jsonConfig.encodeToString(Accept(id, data))) } private fun reject(id: Long, exception: ErrorCodeException) { this.postMessage(jsonConfig.encodeToString(Reject(id, exception.errorCode))) } @JavascriptInterface fun invoke(data: String, id: Long) { this.scope.launch { try { this@WebCmdBridge.accept( id, jsonConfig .decodeFromString(data) .run(WebCmd.Args(context, binder, this@WebCmdBridge.mainActivity, osStatusManager)), ) } catch (exception: CancellationException) { log.debug("invoke job canceled: ${exception.message}") throw exception } catch (exception: ErrorCodeException) { this@WebCmdBridge.reject(id, exception) } catch (exception: Throwable) { log.error("unexpected exception type: $exception", tr = exception) this@WebCmdBridge.reject(id, errorCodeOther()) } } } fun cancel() { this.scope.cancel() } } ================================================ FILE: android/app/src/main/java/net/obscura/vpnclientapp/ui/bridge/WebCmdHelpers.kt ================================================ package net.obscura.vpnclientapp.ui.bridge import android.content.Context import android.content.Intent import java.io.File import net.obscura.vpnclientapp.sharing.DebugArchiveFileProvider internal fun shareDebugArchive( context: Context, path: String, email: Boolean, subject: String? = null, body: String? = null, ) { val uri = DebugArchiveFileProvider.getUriForFile( context, "${context.packageName}.debug_archive_file_provider", File(path), ) val intent = Intent(Intent.ACTION_SEND).apply { this.type = "application/zip" this.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) this.putExtra(Intent.EXTRA_STREAM, uri) if (email) { this.putExtra(Intent.EXTRA_EMAIL, arrayOf("support@obscura.net")) this.putExtra(Intent.EXTRA_SUBJECT, subject) this.putExtra(Intent.EXTRA_TEXT, body) } } if (email) { // There unfortunately isn't a way to only show email apps *and* have attachments. By not // using the chooser here, we at least give the user the option to save their previously // selected email app. context.startActivity(intent) } else { context.startActivity(Intent.createChooser(intent, null)) } } ================================================ FILE: android/app/src/main/res/drawable/ic_launcher.xml ================================================ ================================================ FILE: android/app/src/main/res/drawable/icon_about.xml ================================================ ================================================ FILE: android/app/src/main/res/drawable/icon_account.xml ================================================ ================================================ FILE: android/app/src/main/res/drawable/icon_connection.xml ================================================ ================================================ FILE: android/app/src/main/res/drawable/icon_location.xml ================================================ ================================================ FILE: android/app/src/main/res/drawable/icon_settings.xml ================================================ ================================================ FILE: android/app/src/main/res/layout/activity_main.xml ================================================ ================================================ FILE: android/app/src/main/res/menu/nav_menu.xml ================================================ ================================================ FILE: android/app/src/main/res/values/colors.xml ================================================ #ff000000 #ffffff #f56b39 #dc4d26 #9f2d00 #90a1b9 #45556c #303030 #ffffff #303030 ================================================ FILE: android/app/src/main/res/values/ic_launcher_background.xml ================================================ #F55D24 ================================================ FILE: android/app/src/main/res/values/ids.xml ================================================ ================================================ FILE: android/app/src/main/res/values/strings.xml ================================================ Obscura VPN Obscura VPN Status: %1$s Connecting… Connected Disconnected Connection Location Account Settings About ================================================ FILE: android/app/src/main/res/values/themes.xml ================================================ ================================================ FILE: android/app/src/main/res/values-night/themes.xml ================================================ ================================================ FILE: android/app/src/main/res/xml/backup_rules.xml ================================================ ================================================ FILE: android/app/src/main/res/xml/data_extraction_rules.xml ================================================ ================================================ FILE: android/app/src/main/res/xml/debug_archive_file_provider_paths.xml ================================================ ================================================ FILE: android/app/src/play/java/net/obscura/vpnclientapp/BillingFacade.kt ================================================ package net.obscura.vpnclientapp import android.content.Context import net.obscura.lib.billing.BillingManager import net.obscura.vpnclientapp.activities.MainActivity import net.obscura.vpnclientapp.client.errorCodePurchaseFailed import net.obscura.vpnclientapp.client.errorCodePurchaseFailedAlreadyOwned class BillingFacade(context: Context) { private val billingManager = BillingManager(context) suspend fun launchFlow(mainActivity: MainActivity) = when (this@BillingFacade.billingManager.launchFlow(mainActivity)) { BillingManager.PurchaseResult.Completed -> true BillingManager.PurchaseResult.Canceled -> false BillingManager.PurchaseResult.AlreadyOwned -> throw errorCodePurchaseFailedAlreadyOwned() BillingManager.PurchaseResult.Failed -> throw errorCodePurchaseFailed() } fun destroy() = this.billingManager.destroy() } ================================================ FILE: android/build.gradle.kts ================================================ import com.ncorti.ktfmt.gradle.KtfmtExtension import com.ncorti.ktfmt.gradle.TrailingCommaManagementStrategy import io.gitlab.arturbosch.detekt.extensions.DetektExtension plugins { // Only declare a plugin here if it must be loaded once rather than per-subproject // https://discuss.gradle.org/t/why-duplicate-plugins-in-top-level-build-scripts/49087/2 // https://www.reddit.com/r/androiddev/comments/1errttm/comment/li1vm93/ alias(libs.plugins.android.application) apply false alias(libs.plugins.android.library) apply false alias(libs.plugins.hilt.android) apply false alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.ksp) apply false // These are only here for the `subprojects` block to work alias(libs.plugins.detekt) apply false alias(libs.plugins.ktfmt) apply false } subprojects { apply(plugin = rootProject.libs.plugins.detekt.get().pluginId) apply(plugin = rootProject.libs.plugins.ktfmt.get().pluginId) // https://detekt.dev/docs/gettingstarted/gradle/#kotlin-dsl-3 extensions.configure { config.setFrom(rootProject.file("detekt.yml")) parallel = true } extensions.configure { blockIndent.set(4) continuationIndent.set(4) maxWidth.set(120) removeUnusedImports.set(true) trailingCommaManagementStrategy.set(TrailingCommaManagementStrategy.ONLY_ADD) } } ================================================ FILE: android/buildSrc/.gitignore ================================================ /build ================================================ FILE: android/buildSrc/build.gradle.kts ================================================ import com.ncorti.ktfmt.gradle.KtfmtExtension import com.ncorti.ktfmt.gradle.TrailingCommaManagementStrategy import io.gitlab.arturbosch.detekt.extensions.DetektExtension import org.gradle.kotlin.dsl.configure plugins { alias(libs.plugins.detekt) alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlinx.serialization) alias(libs.plugins.ktfmt) } dependencies { implementation(gradleKotlinDsl()) implementation(libs.kotlinx.serialization.json) } extensions.configure { config.setFrom(rootProject.file("../detekt.yml")) parallel = true } extensions.configure { kotlinLangStyle() maxWidth.set(120) removeUnusedImports.set(true) trailingCommaManagementStrategy.set(TrailingCommaManagementStrategy.ONLY_ADD) } ================================================ FILE: android/buildSrc/settings.gradle.kts ================================================ dependencyResolutionManagement { @Suppress("UnstableApiUsage") repositories { google() mavenCentral() } @Suppress("UnstableApiUsage") repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) versionCatalogs { create("libs") { from(files("../gradle/libs.versions.toml")) } } } ================================================ FILE: android/buildSrc/src/main/kotlin/VersionName.kt ================================================ import java.io.File import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import org.gradle.api.Project import org.gradle.api.provider.Property import org.gradle.api.provider.ValueSource import org.gradle.api.provider.ValueSourceParameters import org.slf4j.Logger import org.slf4j.LoggerFactory private val logger: Logger = LoggerFactory.getLogger("version-name") fun Project.getVersionName(projectRootDir: File): String = providers.of(VersionName::class.java) { it.parameters.projectRootDir.set(projectRootDir) }.get() abstract class VersionName : ValueSource { interface Parameters : ValueSourceParameters { val projectRootDir: Property } @Serializable private data class Tag(val version: String) private val json: Json = Json { ignoreUnknownKeys = true } private fun fallback(): String { logger.warn("building outside of nix; not intended for distribution") val tagString = File(parameters.projectRootDir.get().parentFile, "tag.json").readText() val tag = this.json.decodeFromString(tagString) return "v${tag.version}.1-dev" } override fun obtain(): String { val version = System.getenv("OBSCURA_VERSION") logger.info("OBSCURA_VERSION = $version") return version ?: this.fallback() } } ================================================ FILE: android/detekt.yml ================================================ build: maxIssues: 0 excludeCorrectable: false weights: # complexity: 2 # LongParameterList: 1 # style: 1 # comments: 1 config: validation: true warningsAsErrors: false checkExhaustiveness: false # when writing own rules with new properties, exclude the property path e.g.: 'my_rule_set,.*>.*>[my_property]' excludes: "" processors: active: true exclude: - "DetektProgressListener" # - 'KtFileCountProcessor' # - 'PackageCountProcessor' # - 'ClassCountProcessor' # - 'FunctionCountProcessor' # - 'PropertyCountProcessor' # - 'ProjectComplexityProcessor' # - 'ProjectCognitiveComplexityProcessor' # - 'ProjectLLOCProcessor' # - 'ProjectCLOCProcessor' # - 'ProjectLOCProcessor' # - 'ProjectSLOCProcessor' # - 'LicenseHeaderLoaderExtension' console-reports: active: true exclude: - "ProjectStatisticsReport" - "ComplexityReport" - "NotificationReport" - "FindingsReport" - "FileBasedFindingsReport" # - 'LiteFindingsReport' output-reports: active: true exclude: # - 'TxtOutputReport' # - 'XmlOutputReport' # - 'HtmlOutputReport' # - 'MdOutputReport' # - 'SarifOutputReport' comments: active: true AbsentOrWrongFileLicense: active: false licenseTemplateFile: "license.template" licenseTemplateIsRegex: false CommentOverPrivateFunction: active: false CommentOverPrivateProperty: active: false DeprecatedBlockTag: active: false EndOfSentenceFormat: active: false endOfSentenceFormat: '([.?!][ \t\n\r\f<])|([.?!:]$)' KDocReferencesNonPublicProperty: active: false excludes: [ "**/test/**", "**/androidTest/**", "**/commonTest/**", "**/jvmTest/**", "**/androidUnitTest/**", "**/androidInstrumentedTest/**", "**/jsTest/**", "**/iosTest/**", ] OutdatedDocumentation: active: false matchTypeParameters: true matchDeclarationsOrder: true allowParamOnConstructorProperties: false UndocumentedPublicClass: active: false excludes: [ "**/test/**", "**/androidTest/**", "**/commonTest/**", "**/jvmTest/**", "**/androidUnitTest/**", "**/androidInstrumentedTest/**", "**/jsTest/**", "**/iosTest/**", ] searchInNestedClass: true searchInInnerClass: true searchInInnerObject: true searchInInnerInterface: true searchInProtectedClass: false ignoreDefaultCompanionObject: false UndocumentedPublicFunction: active: false excludes: [ "**/test/**", "**/androidTest/**", "**/commonTest/**", "**/jvmTest/**", "**/androidUnitTest/**", "**/androidInstrumentedTest/**", "**/jsTest/**", "**/iosTest/**", ] searchProtectedFunction: false UndocumentedPublicProperty: active: false excludes: [ "**/test/**", "**/androidTest/**", "**/commonTest/**", "**/jvmTest/**", "**/androidUnitTest/**", "**/androidInstrumentedTest/**", "**/jsTest/**", "**/iosTest/**", ] searchProtectedProperty: false complexity: active: true CognitiveComplexMethod: active: false threshold: 15 ComplexCondition: active: true threshold: 4 ComplexInterface: active: false threshold: 10 includeStaticDeclarations: false includePrivateDeclarations: false ignoreOverloaded: false CyclomaticComplexMethod: active: false threshold: 15 ignoreSingleWhenExpression: false ignoreSimpleWhenEntries: false ignoreNestingFunctions: false nestingFunctions: - "also" - "apply" - "forEach" - "isNotNull" - "ifNull" - "let" - "run" - "use" - "with" LabeledExpression: active: false ignoredLabels: [] LargeClass: active: true threshold: 600 LongMethod: active: true threshold: 60 LongParameterList: active: true functionThreshold: 6 constructorThreshold: 7 ignoreDefaultParameters: false ignoreDataClasses: true ignoreAnnotatedParameter: [] MethodOverloading: active: false threshold: 6 NamedArguments: active: false threshold: 3 ignoreArgumentsMatchingNames: false NestedBlockDepth: active: true threshold: 4 NestedScopeFunctions: active: false threshold: 1 functions: - "kotlin.apply" - "kotlin.run" - "kotlin.with" - "kotlin.let" - "kotlin.also" ReplaceSafeCallChainWithRun: active: false StringLiteralDuplication: active: false excludes: [ "**/test/**", "**/androidTest/**", "**/commonTest/**", "**/jvmTest/**", "**/androidUnitTest/**", "**/androidInstrumentedTest/**", "**/jsTest/**", "**/iosTest/**", ] threshold: 3 ignoreAnnotation: true excludeStringsWithLessThan5Characters: true ignoreStringsRegex: "$^" TooManyFunctions: active: false excludes: [ "**/test/**", "**/androidTest/**", "**/commonTest/**", "**/jvmTest/**", "**/androidUnitTest/**", "**/androidInstrumentedTest/**", "**/jsTest/**", "**/iosTest/**", ] thresholdInFiles: 11 thresholdInClasses: 11 thresholdInInterfaces: 11 thresholdInObjects: 11 thresholdInEnums: 11 ignoreDeprecated: false ignorePrivate: false ignoreOverridden: false ignoreAnnotatedFunctions: [] coroutines: active: true GlobalCoroutineUsage: active: false InjectDispatcher: active: true dispatcherNames: - "IO" - "Default" - "Unconfined" RedundantSuspendModifier: active: true SleepInsteadOfDelay: active: true SuspendFunSwallowedCancellation: active: false SuspendFunWithCoroutineScopeReceiver: active: false SuspendFunWithFlowReturnType: active: true empty-blocks: active: true EmptyCatchBlock: active: true allowedExceptionNameRegex: "_|(ignore|expected).*" EmptyClassBlock: active: true EmptyDefaultConstructor: active: true EmptyDoWhileBlock: active: true EmptyElseBlock: active: true EmptyFinallyBlock: active: true EmptyForBlock: active: true EmptyFunctionBlock: active: true ignoreOverridden: false EmptyIfBlock: active: true EmptyInitBlock: active: true EmptyKtFile: active: true EmptySecondaryConstructor: active: true EmptyTryBlock: active: true EmptyWhenBlock: active: true EmptyWhileBlock: active: true exceptions: active: true ExceptionRaisedInUnexpectedLocation: active: true methodNames: - "equals" - "finalize" - "hashCode" - "toString" InstanceOfCheckForException: active: true excludes: [ "**/test/**", "**/androidTest/**", "**/commonTest/**", "**/jvmTest/**", "**/androidUnitTest/**", "**/androidInstrumentedTest/**", "**/jsTest/**", "**/iosTest/**", ] NotImplementedDeclaration: active: false ObjectExtendsThrowable: active: false PrintStackTrace: active: true RethrowCaughtException: active: true ReturnFromFinally: active: true ignoreLabeled: false SwallowedException: active: true ignoredExceptionTypes: - "InterruptedException" - "MalformedURLException" - "NumberFormatException" - "ParseException" allowedExceptionNameRegex: "_|(ignore|expected).*" ThrowingExceptionFromFinally: active: true ThrowingExceptionInMain: active: false ThrowingExceptionsWithoutMessageOrCause: active: true excludes: [ "**/test/**", "**/androidTest/**", "**/commonTest/**", "**/jvmTest/**", "**/androidUnitTest/**", "**/androidInstrumentedTest/**", "**/jsTest/**", "**/iosTest/**", ] exceptions: - "ArrayIndexOutOfBoundsException" - "Exception" - "IllegalArgumentException" - "IllegalMonitorStateException" - "IllegalStateException" - "IndexOutOfBoundsException" - "NullPointerException" - "RuntimeException" - "Throwable" ThrowingNewInstanceOfSameException: active: true TooGenericExceptionCaught: active: false TooGenericExceptionThrown: active: false exceptionNames: - "Error" - "Exception" - "RuntimeException" - "Throwable" naming: active: true BooleanPropertyNaming: active: false allowedPattern: "^(is|has|are)" ClassNaming: active: true classPattern: "[A-Z][a-zA-Z0-9]*" ConstructorParameterNaming: active: true parameterPattern: "[a-z][A-Za-z0-9]*" privateParameterPattern: "[a-z][A-Za-z0-9]*" excludeClassPattern: "$^" EnumNaming: active: true enumEntryPattern: "[A-Z][_a-zA-Z0-9]*" ForbiddenClassName: active: false forbiddenName: [] FunctionMaxLength: active: false maximumFunctionNameLength: 30 FunctionMinLength: active: false minimumFunctionNameLength: 3 FunctionNaming: active: true excludes: [ "**/test/**", "**/androidTest/**", "**/commonTest/**", "**/jvmTest/**", "**/androidUnitTest/**", "**/androidInstrumentedTest/**", "**/jsTest/**", "**/iosTest/**", ] functionPattern: "[a-z][a-zA-Z0-9]*" excludeClassPattern: "$^" FunctionParameterNaming: active: true parameterPattern: "[a-z][A-Za-z0-9]*" excludeClassPattern: "$^" InvalidPackageDeclaration: active: true rootPackage: "" requireRootInDeclaration: false LambdaParameterNaming: active: false parameterPattern: "[a-z][A-Za-z0-9]*|_" MatchingDeclarationName: active: true mustBeFirst: true multiplatformTargets: - "ios" - "android" - "js" - "jvm" - "native" - "iosArm64" - "iosX64" - "macosX64" - "mingwX64" - "linuxX64" MemberNameEqualsClassName: active: true ignoreOverridden: true NoNameShadowing: active: true NonBooleanPropertyPrefixedWithIs: active: false ObjectPropertyNaming: active: true constantPattern: "[A-Za-z][_A-Za-z0-9]*" propertyPattern: "[A-Za-z][_A-Za-z0-9]*" privatePropertyPattern: "(_)?[A-Za-z][_A-Za-z0-9]*" PackageNaming: active: true packagePattern: '[a-z]+(\.[a-z][A-Za-z0-9]*)*' TopLevelPropertyNaming: active: true constantPattern: "[A-Z][_A-Z0-9]*" propertyPattern: "[A-Za-z][_A-Za-z0-9]*" privatePropertyPattern: "_?[A-Za-z][_A-Za-z0-9]*" VariableMaxLength: active: false maximumVariableNameLength: 64 VariableMinLength: active: false minimumVariableNameLength: 1 VariableNaming: active: true variablePattern: "[a-z][A-Za-z0-9]*" privateVariablePattern: "(_)?[a-z][A-Za-z0-9]*" excludeClassPattern: "$^" performance: active: true ArrayPrimitive: active: true CouldBeSequence: active: false threshold: 3 ForEachOnRange: active: true excludes: [ "**/test/**", "**/androidTest/**", "**/commonTest/**", "**/jvmTest/**", "**/androidUnitTest/**", "**/androidInstrumentedTest/**", "**/jsTest/**", "**/iosTest/**", ] SpreadOperator: active: true excludes: [ "**/test/**", "**/androidTest/**", "**/commonTest/**", "**/jvmTest/**", "**/androidUnitTest/**", "**/androidInstrumentedTest/**", "**/jsTest/**", "**/iosTest/**", ] UnnecessaryPartOfBinaryExpression: active: false UnnecessaryTemporaryInstantiation: active: true potential-bugs: active: true AvoidReferentialEquality: active: true forbiddenTypePatterns: - "kotlin.String" CastNullableToNonNullableType: active: false CastToNullableType: active: false Deprecation: active: false DontDowncastCollectionTypes: active: false DoubleMutabilityForCollection: active: true mutableTypes: - "kotlin.collections.MutableList" - "kotlin.collections.MutableMap" - "kotlin.collections.MutableSet" - "java.util.ArrayList" - "java.util.LinkedHashSet" - "java.util.HashSet" - "java.util.LinkedHashMap" - "java.util.HashMap" ElseCaseInsteadOfExhaustiveWhen: active: false ignoredSubjectTypes: [] EqualsAlwaysReturnsTrueOrFalse: active: true EqualsWithHashCodeExist: active: true ExitOutsideMain: active: false ExplicitGarbageCollectionCall: active: true HasPlatformType: active: true IgnoredReturnValue: active: true restrictToConfig: true returnValueAnnotations: - "CheckResult" - "*.CheckResult" - "CheckReturnValue" - "*.CheckReturnValue" ignoreReturnValueAnnotations: - "CanIgnoreReturnValue" - "*.CanIgnoreReturnValue" returnValueTypes: - "kotlin.sequences.Sequence" - "kotlinx.coroutines.flow.*Flow" - "java.util.stream.*Stream" ignoreFunctionCall: [] ImplicitDefaultLocale: active: true ImplicitUnitReturnType: active: false allowExplicitReturnType: true InvalidRange: active: true IteratorHasNextCallsNextMethod: active: true IteratorNotThrowingNoSuchElementException: active: true LateinitUsage: active: false excludes: [ "**/test/**", "**/androidTest/**", "**/commonTest/**", "**/jvmTest/**", "**/androidUnitTest/**", "**/androidInstrumentedTest/**", "**/jsTest/**", "**/iosTest/**", ] ignoreOnClassesPattern: "" MapGetWithNotNullAssertionOperator: active: true MissingPackageDeclaration: active: false excludes: ["**/*.kts"] NullCheckOnMutableProperty: active: false NullableToStringCall: active: false PropertyUsedBeforeDeclaration: active: false UnconditionalJumpStatementInLoop: active: false UnnecessaryNotNullCheck: active: false UnnecessaryNotNullOperator: active: true UnnecessarySafeCall: active: true UnreachableCatchBlock: active: true UnreachableCode: active: true UnsafeCallOnNullableType: active: true excludes: [ "**/test/**", "**/androidTest/**", "**/commonTest/**", "**/jvmTest/**", "**/androidUnitTest/**", "**/androidInstrumentedTest/**", "**/jsTest/**", "**/iosTest/**", ] UnsafeCast: active: true UnusedUnaryOperator: active: true UselessPostfixExpression: active: true WrongEqualsTypeParameter: active: true style: active: true AlsoCouldBeApply: active: false BracesOnIfStatements: active: false singleLine: "never" multiLine: "always" BracesOnWhenStatements: active: false singleLine: "necessary" multiLine: "consistent" CanBeNonNullable: active: false CascadingCallWrapping: active: false includeElvis: true ClassOrdering: active: false CollapsibleIfStatements: active: false DataClassContainsFunctions: active: false conversionFunctionPrefix: - "to" allowOperators: false DataClassShouldBeImmutable: active: false DestructuringDeclarationWithTooManyEntries: active: true maxDestructuringEntries: 3 DoubleNegativeLambda: active: false negativeFunctions: - reason: "Use `takeIf` instead." value: "takeUnless" - reason: "Use `all` instead." value: "none" negativeFunctionNameParts: - "not" - "non" EqualsNullCall: active: true EqualsOnSignatureLine: active: false ExplicitCollectionElementAccessMethod: active: false ExplicitItLambdaParameter: active: true ExpressionBodySyntax: active: false includeLineWrapping: false ForbiddenAnnotation: active: false annotations: - reason: "it is a java annotation. Use `Suppress` instead." value: "java.lang.SuppressWarnings" - reason: "it is a java annotation. Use `kotlin.Deprecated` instead." value: "java.lang.Deprecated" - reason: "it is a java annotation. Use `kotlin.annotation.MustBeDocumented` instead." value: "java.lang.annotation.Documented" - reason: "it is a java annotation. Use `kotlin.annotation.Target` instead." value: "java.lang.annotation.Target" - reason: "it is a java annotation. Use `kotlin.annotation.Retention` instead." value: "java.lang.annotation.Retention" - reason: "it is a java annotation. Use `kotlin.annotation.Repeatable` instead." value: "java.lang.annotation.Repeatable" - reason: "Kotlin does not support @Inherited annotation, see https://youtrack.jetbrains.com/issue/KT-22265" value: "java.lang.annotation.Inherited" ForbiddenComment: active: false comments: - reason: "Forbidden FIXME todo marker in comment, please fix the problem." value: "FIXME:" - reason: "Forbidden STOPSHIP todo marker in comment, please address the problem before shipping the code." value: "STOPSHIP:" - reason: "Forbidden TODO todo marker in comment, please do the changes." value: "TODO:" allowedPatterns: "" ForbiddenImport: active: false imports: [] forbiddenPatterns: "" ForbiddenMethodCall: active: false methods: - reason: "print does not allow you to configure the output stream. Use a logger instead." value: "kotlin.io.print" - reason: "println does not allow you to configure the output stream. Use a logger instead." value: "kotlin.io.println" ForbiddenSuppress: active: false rules: [] ForbiddenVoid: active: true ignoreOverridden: false ignoreUsageInGenerics: false FunctionOnlyReturningConstant: active: true ignoreOverridableFunction: true ignoreActualFunction: true excludedFunctions: [] LoopWithTooManyJumpStatements: active: true maxJumpCount: 1 MagicNumber: active: false excludes: [ "**/test/**", "**/androidTest/**", "**/commonTest/**", "**/jvmTest/**", "**/androidUnitTest/**", "**/androidInstrumentedTest/**", "**/jsTest/**", "**/iosTest/**", "**/*.kts", ] ignoreNumbers: - "-1" - "0" - "1" - "2" ignoreHashCodeFunction: true ignorePropertyDeclaration: false ignoreLocalVariableDeclaration: false ignoreConstantDeclaration: true ignoreCompanionObjectPropertyDeclaration: true ignoreAnnotation: false ignoreNamedArgument: true ignoreEnums: false ignoreRanges: false ignoreExtensionFunctions: true MandatoryBracesLoops: active: false MaxChainedCallsOnSameLine: active: false maxChainedCalls: 5 MaxLineLength: active: true maxLineLength: 120 excludePackageStatements: true excludeImportStatements: true excludeCommentStatements: false excludeRawStrings: true MayBeConst: active: true ModifierOrder: active: true MultilineLambdaItParameter: active: false MultilineRawStringIndentation: active: false indentSize: 4 trimmingMethods: - "trimIndent" - "trimMargin" NestedClassesVisibility: active: true NewLineAtEndOfFile: active: true NoTabs: active: false NullableBooleanCheck: active: false ObjectLiteralToLambda: active: true OptionalAbstractKeyword: active: true OptionalUnit: active: false PreferToOverPairSyntax: active: false ProtectedMemberInFinalClass: active: true RedundantExplicitType: active: false RedundantHigherOrderMapUsage: active: true RedundantVisibilityModifierRule: active: false ReturnCount: active: false max: 2 excludedFunctions: - "equals" excludeLabeled: false excludeReturnFromLambda: true excludeGuardClauses: false SafeCast: active: true SerialVersionUIDInSerializableClass: active: true SpacingBetweenPackageAndImports: active: false StringShouldBeRawString: active: false maxEscapedCharacterCount: 2 ignoredCharacters: [] ThrowsCount: active: true max: 2 excludeGuardClauses: false TrailingWhitespace: active: false TrimMultilineRawString: active: false trimmingMethods: - "trimIndent" - "trimMargin" UnderscoresInNumericLiterals: active: false acceptableLength: 4 allowNonStandardGrouping: false UnnecessaryAbstractClass: active: true UnnecessaryAnnotationUseSiteTarget: active: false UnnecessaryApply: active: true UnnecessaryBackticks: active: false UnnecessaryBracesAroundTrailingLambda: active: false UnnecessaryFilter: active: true UnnecessaryInheritance: active: true UnnecessaryInnerClass: active: false UnnecessaryLet: active: false UnnecessaryParentheses: active: false allowForUnclearPrecedence: false UntilInsteadOfRangeTo: active: false UnusedImports: active: false UnusedParameter: active: true allowedNames: "ignored|expected" UnusedPrivateClass: active: true UnusedPrivateMember: active: true allowedNames: "" UnusedPrivateProperty: active: true allowedNames: "_|ignored|expected|serialVersionUID" UseAnyOrNoneInsteadOfFind: active: true UseArrayLiteralsInAnnotations: active: true UseCheckNotNull: active: true UseCheckOrError: active: true UseDataClass: active: false allowVars: false UseEmptyCounterpart: active: false UseIfEmptyOrIfBlank: active: false UseIfInsteadOfWhen: active: false ignoreWhenContainingVariableDeclaration: false UseIsNullOrEmpty: active: true UseLet: active: false UseOrEmpty: active: true UseRequire: active: true UseRequireNotNull: active: true UseSumOfInsteadOfFlatMapSize: active: false UselessCallOnNotNull: active: true UtilityClassWithPublicConstructor: active: true VarCouldBeVal: active: true ignoreLateinitVar: false WildcardImport: active: true excludeImports: - "java.util.*" ================================================ FILE: android/gradle/libs.versions.toml ================================================ [versions] android-billingclient = "8.3.0" android-gradle-plugin = "8.13.0" androidx-junit = "1.3.0" androidx-lifecycle = "2.10.0" appcompat = "1.7.1" core-ktx = "1.18.0" #noinspection NewerVersionAvailable dagger = "2.58" # Later versions require AGP 9+ detekt = "1.23.8" espresso-core = "3.7.0" junit = "4.13.2" kotlin = "2.3.10" kotlinx-serialization-json = "1.10.0" ksp = "2.3.6" ktfmt = "0.25.0" material = "1.13.0" play-services-location = "21.3.0" webkit = "1.15.0" [libraries] android-billingclient = { module = "com.android.billingclient:billing-ktx", version.ref = "android-billingclient" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso-core" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-junit" } androidx-lifecycle = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "androidx-lifecycle" } androidx-webkit = { group = "androidx.webkit", name = "webkit", version.ref = "webkit" } hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "dagger" } hilt-android-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "dagger" } junit = { group = "junit", name = "junit", version.ref = "junit" } kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", version.ref = "kotlin" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } play-services-location = { module = "com.google.android.gms:play-services-location", version.ref = "play-services-location" } [plugins] android-application = { id = "com.android.application", version.ref = "android-gradle-plugin" } android-library = { id = "com.android.library", version.ref = "android-gradle-plugin" } detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "dagger" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlinx-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } ktfmt = { id = "com.ncorti.ktfmt.gradle", version.ref = "ktfmt" } ================================================ FILE: android/gradle/mitm-cache/deps.json ================================================ { "!comment": "This is a nixpkgs Gradle dependency lockfile. For more details, refer to the Gradle section in the nixpkgs manual.", "!version": 1, "https://dl.google.com/dl/android/maven2": { "androidx/activity#activity/1.2.3": { "module": "sha256-6eLdnZmtrzQzaRh89qPwOA/50lRJRM38RzEO+86xs1E=", "pom": "sha256-vK3ckl1R1VZsRaSmzzvwXHFEpjSHEwjQ5z0+JwOtdOA=" }, "androidx/activity#activity/1.8.0": { "module": "sha256-0UXYtTz9Ef0m5H591FwAcTPvluok9nFctlPHN2RdHfY=", "pom": "sha256-ZRMShfU8sELo/9GJ5wXa/JuK5tYkNeh3ismrO44/dW8=" }, "androidx/activity/activity/1.2.3/activity-1.2.3": { "aar": "sha256-Hc4HBcM0prLvAzgkGNx1hvTlfuI4FyZ7QD6oz8Nsgk4=" }, "androidx/activity/activity/1.8.0/activity-1.8.0": { "aar": "sha256-06Z2cJ3qBPKoUG4q6FBS//dj21Jqx/FrBN5Q/dBbByA=" }, "androidx/annotation#annotation-experimental/1.3.0": { "module": "sha256-Xuvq/wHQQuBtzykqv4lkrTkeSwFZ8AkPFiU9YEXTjaA=", "pom": "sha256-ui/kb3T2iFHiNCRhk6uJbAwB+glM03LBBRtr8E15sLs=" }, "androidx/annotation#annotation-experimental/1.3.1": { "module": "sha256-m2l0p9/ibTwgndY+FvjuJGG1egkXiRYMoetJK7G/P4Q=", "pom": "sha256-Qm+UMQtHvfAE3CA42uqpQ8XOT0sGPob3i8U24jLj3fw=" }, "androidx/annotation#annotation-experimental/1.4.1": { "module": "sha256-KsL3EG4S8mNCW0pN/ICYlEf7iVZ1/pAthnWap0/RK30=", "pom": "sha256-/leyKEF/TXxneQPcYftKfPmT1gNJneJtjYET5HfMTxs=" }, "androidx/annotation#annotation-jvm/1.8.1": { "module": "sha256-yVnjsM3HXBXv4BYF+laqefAz45I44VBji4+r3mqhIaA=", "pom": "sha256-1JIDczqm+uBGw6PeTnlu7TR1lXVUhqZCc5iYRHWXULQ=" }, "androidx/annotation#annotation-jvm/1.9.1": { "jar": "sha256-HjQ5F+vye6lv5NxSscrX/TK3OPvGNVu2zVs7MF1yEtA=", "module": "sha256-A/tlkXfIYY5HQlklwRvJHzhHA+omwmW+myXNeSkrURw=", "pom": "sha256-ibmcIY1gAZMWtQqreYFnB7whaWyJagMOGxrgOJYby44=" }, "androidx/annotation#annotation/1.1.0": { "pom": "sha256-LpNyuneA70SVKtv4a2bh8IaCweUnfJJhhfZWShN5nv4=" }, "androidx/annotation#annotation/1.3.0": { "jar": "sha256-l9xFr+/joeQh2kK4tun5BJFHfEX8YXggPjpeigXuhVM=", "module": "sha256-lRbCrkQoTqC9PQ6t4O5jiHm3CMvjHjr5K6lsMAYE68M=", "pom": "sha256-BJUXxSVqS7yFKfzckMTqrzWBgKwgXJFmB2ffJkZe+7A=" }, "androidx/annotation#annotation/1.8.1": { "module": "sha256-5jhuha/dhlBE4hZXXkk+05pjpjJb2SU3miFCnDlByLU=", "pom": "sha256-txIll07Ah+uWwl72gZ9VscIvUw6FykRrpzX7Zu0E/1w=" }, "androidx/annotation#annotation/1.9.1": { "module": "sha256-8gSwW3KKl1YXGLxxYkLkfGKcAIWoDudPylPU1ji8vj8=", "pom": "sha256-xzOIHC4X1ffIZhzAKpFZyxYLeyCUon1ZORbIfT4lBjY=" }, "androidx/annotation/annotation-experimental/1.4.1/annotation-experimental-1.4.1": { "aar": "sha256-a9THx0dvgmDNO9u4EYNYPpP8n3kMJ96n3DFBgcv4eqA=" }, "androidx/appcompat#appcompat-resources/1.7.1": { "module": "sha256-FjzF4PJJQz3bABb70BGrjeB88j4H5hjHRkRojibNGOg=", "pom": "sha256-L6+SLow9cG8p2GKz+7OqE0dwXCHG6uGFhtK7H3Nm6gE=" }, "androidx/appcompat#appcompat/1.7.1": { "module": "sha256-i78WeSyBzC3Qg2gXg6U4L1cwB+7oFXGCky8bRU4rfwQ=", "pom": "sha256-H3mRDMIN4Zis84Z7HpP57VndtkNYWy6cKO5VUYrxvAA=" }, "androidx/appcompat/appcompat-resources/1.7.1/appcompat-resources-1.7.1": { "aar": "sha256-ji2zEiTKU7EIx4TaKzYZWQYnFtQWshDP7z1aOCgwbfA=" }, "androidx/appcompat/appcompat/1.7.1/appcompat-1.7.1": { "aar": "sha256-KtM0oyOygEbom3OMd9GEyz3MoypVGrBIhRsv2iOjuiY=" }, "androidx/arch/core#core-common/2.1.0": { "jar": "sha256-/hI3vwKdBj5/Kf45rq9z73TIsKNlhIb8KdPFQyZlOIk=", "pom": "sha256-g7uzlg6qvGAKw2bJTLWUFORBUyodaqk4iwuL/6zlzwE=" }, "androidx/arch/core#core-common/2.2.0": { "jar": "sha256-ZTCKBrHADuGGy54ZMhOD8EO5k4E/FSLEf0o+MwO9ukE=", "module": "sha256-7fQgDP3C2UYjIlLJnl3LnGG7kJ61RQsmE9HU/cl0uYE=", "pom": "sha256-HhfUr41kJb4qafivTWVKh+BFYlmp7vFUKGm8sCNUfig=" }, "androidx/arch/core#core-runtime/2.1.0": { "pom": "sha256-wMTtAWDNLKGDkAFd6LOStpfBczJ8aywJR9TmL2lYwF0=" }, "androidx/arch/core#core-runtime/2.2.0": { "module": "sha256-qLF1E5SeXbbJYBwwvhnflTdi3Yd1EvHiz8+ugdJECUQ=", "pom": "sha256-D5U3BlmBQNnYc1zdWusfo1kz9OLzHAvz64GgU6TIwaU=" }, "androidx/arch/core/core-runtime/2.1.0/core-runtime-2.1.0": { "aar": "sha256-3XdhW9PdJ1r7EbYt8luuRrELShF803lDr0W9y/h1WFI=" }, "androidx/arch/core/core-runtime/2.2.0/core-runtime-2.2.0": { "aar": "sha256-ob5eDKorB2I4Yq9q4hs6sHGBIyRRhNDjDeqBtT+ZCkc=" }, "androidx/cardview#cardview/1.0.0": { "pom": "sha256-5k704ItYNY/ie1meb+gKGxU9sBTGRL7uYwqycQYcPmw=" }, "androidx/cardview/cardview/1.0.0/cardview-1.0.0": { "aar": "sha256-EZPATCKj1rWUba6fToxZ1q3eanG2vV2H+5nYLdoa/sc=" }, "androidx/collection#collection-jvm/1.4.2": { "jar": "sha256-mEzpvXgAVer7ihBayj9RRoa/exticux8ZFqYzkD8fbQ=", "module": "sha256-qtazU2wPDlcKpzPVFB1w+mubOt03D3OjEcpMpd7iVEg=", "pom": "sha256-rbs3nisWDwjmPjQa0Eu8xwM/bYaOxRrBHkM0ZCpOOe8=" }, "androidx/collection#collection/1.0.0": { "pom": "sha256-p5E6UnWtaOVV0mEuvowUw2exU+FMpIoYcqZImQIOVO8=" }, "androidx/collection#collection/1.1.0": { "jar": "sha256-YyoOVAdGHed0QJNSlA4pKikQN3JCB6eHggx32vfTO3I=", "pom": "sha256-Z+kGbKSs/cbjzFCCk8MboDmAV/8Rjk9wseGBPJo0VtE=" }, "androidx/collection#collection/1.4.2": { "module": "sha256-AybSz1rb5ZIxKBDKH3HGwMww91PEPwfHQCNht4ineEw=", "pom": "sha256-TyYVoXrqz+EraCmCFKGfKhnGjGbVkAdb27+2WevOK38=" }, "androidx/concurrent#concurrent-futures-ktx/1.2.0": { "jar": "sha256-4fPhe7Q1jM1sd8pF9wY1yauiNyYfGeqk9koCGMAOKj4=", "module": "sha256-gj9Gms2YSt/TCz0KV36093lqA3QqUm73DBWDtZS0O4A=", "pom": "sha256-eGnTmqendUpPMRdxq/2dlcy9f/sn6XD4yxX99bU1W20=" }, "androidx/concurrent#concurrent-futures/1.0.0": { "pom": "sha256-RQW5peMKlBi1mprWcCw+QZOupuaRo9A88iDHZArQg+I=" }, "androidx/concurrent#concurrent-futures/1.1.0": { "jar": "sha256-DOBnxRSg0QSdG+vfcJ40TtMmb+l0QnVoKTfNyxMzTp4=", "module": "sha256-d2OaCwUeIlELrZOv/OoOvXge8SS/m3YhqVdJk3vPzf0=", "pom": "sha256-dIo0610T0Z0Yet7K6oJmf97V8bC5imVeE+0uSos9iuY=" }, "androidx/constraintlayout#constraintlayout-core/1.0.0": { "jar": "sha256-SWyMeWZL2XDGpr56lgog3RV9/Fqi8Y5bgzN2f/vFmRE=", "module": "sha256-Ge5Jb2b8BVlilFTdAdNJ89PJWVmAs3No/NkfFH7WBNs=", "pom": "sha256-k+AWIukLWHm7lQ/ttcQXyAJFVf3HeUgbzuIaO1l3y4w=" }, "androidx/constraintlayout#constraintlayout/2.1.0": { "module": "sha256-FBrMbPAzDdTjYwcolcSyaMC1lfhNck4ukE685fK3Lkg=", "pom": "sha256-JRBK9a6R7+X+bJF47uNfawLjE0lQVzjM603y34OFtYU=" }, "androidx/constraintlayout/constraintlayout/2.1.0/constraintlayout-2.1.0": { "aar": "sha256-pFinViNjvVnCCafeqEcyag4rJrN6iD3D4pBlp5oceIs=" }, "androidx/coordinatorlayout#coordinatorlayout/1.1.0": { "pom": "sha256-pnxSyd36/y/7L9S5fNlPo4LoN+qKWHTQKeCgT6Y+XK8=" }, "androidx/coordinatorlayout/coordinatorlayout/1.1.0/coordinatorlayout-1.1.0": { "aar": "sha256-RKnjCr9WrxAlxSoK9Qb+6cQTGqVe/aUvn9lFEhHF6Ms=" }, "androidx/core#core-ktx/1.18.0": { "module": "sha256-P6V5wR3g9vN4ACwPC7S75xEAY0+A+LKClReJ6n0PuEk=", "pom": "sha256-Ol4ACeBlF2J3VrkgPZqPBV/ah7IVoXt3W65WhpeFQZY=" }, "androidx/core#core-viewtree/1.0.0": { "module": "sha256-EThs+kbLv922pAWfFDVMAGkc9l09Y8NhiBioMybvPH8=", "pom": "sha256-1PLtEXb6jFYSuA90yVKoeZFCqe02AioaI4/eWxQFgNk=" }, "androidx/core#core/1.13.0": { "module": "sha256-Lg5uXBIFt0YqDFw+MrWMLlJUNYEu2JlGx75nN0k7UeM=", "pom": "sha256-RQLk7YtZEiAhrJocExLiMm5LD0P37Lu8m1Dud0KVdNQ=" }, "androidx/core#core/1.18.0": { "module": "sha256-tn/3ofm+lWh9+obWBVFH/B2LvjsdwZBIxDKvyhir/ZU=", "pom": "sha256-V9ZE67N9MJrZWddAFX2OhCTic7nGcoQuT0x1jtxtMOc=" }, "androidx/core#core/1.2.0": { "pom": "sha256-PR9ON7d92SNTh5oECrTOL3BnmLy98GYUdJHDZCs/eaY=" }, "androidx/core/core-ktx/1.18.0/core-ktx-1.18.0": { "aar": "sha256-x8UR49+Dj8vLYazjaUTea7fBDrWXvlRpdPABEZk5yF0=" }, "androidx/core/core-viewtree/1.0.0/core-viewtree-1.0.0": { "aar": "sha256-3BtnjVjrzyv6FYe+aP+CZpTOPSISUbnvMNTUs2KX5t4=" }, "androidx/core/core/1.18.0/core-1.18.0": { "aar": "sha256-MR2DrGfTlAduwh0S7S0QpEtZyykpt9zgDlqQqThC430=" }, "androidx/core/core/1.2.0/core-1.2.0": { "aar": "sha256-UkuLiM62p0p+ROa1Z6E1Zg8hF5mQTLIYv+5b4RZoILI=" }, "androidx/cursoradapter#cursoradapter/1.0.0": { "pom": "sha256-YtlciYUK8hAwsZ8U1ffs1ti8yaMBTFkALsmWJMqsgQA=" }, "androidx/cursoradapter/cursoradapter/1.0.0/cursoradapter-1.0.0": { "aar": "sha256-qByP54gV+kffW3Sd61JyetEfk5faWLFgF/TrLBHihWQ=" }, "androidx/customview#customview/1.0.0": { "pom": "sha256-zp5HuHGE9b1eE56b7NWyZHbULXjDG/L97cN6y0G5rUk=" }, "androidx/customview#customview/1.1.0": { "pom": "sha256-yBTUNfc+nm0WmIbQ65a1xTYf60hEn7uzFckIwDxYjJQ=" }, "androidx/customview/customview/1.0.0/customview-1.0.0": { "aar": "sha256-IOW49lJqNFlaYE9WcY2oEWfAtAp6lKV9qjVWY/JZTfI=" }, "androidx/customview/customview/1.1.0/customview-1.1.0": { "aar": "sha256-AfdqsEN3CpewVARvmBVxe4LOA1XAKWfRbGGYE1ncGJo=" }, "androidx/databinding#databinding-common/8.13.0": { "jar": "sha256-Zsq4JjnawPbCQzRkwJOwdNYIxLuIfsOKm4vErJgSZzI=", "pom": "sha256-C8GPkSrwK3kO+jmsVu2w5b/Inq/gmXaApazWXJinGU4=" }, "androidx/databinding#databinding-compiler-common/8.13.0": { "jar": "sha256-jpAGDUEdIEGfnvXOaYk8/EoyP2ETQDB4nAuy2xo99Hw=", "pom": "sha256-JFCytoqObW96rQCoVlRbPN+sSEprHwr2IWhATDIybTQ=" }, "androidx/drawerlayout#drawerlayout/1.0.0": { "pom": "sha256-2mczQlqD9c6FCHj6cgEII0X+18Zo3VhVD90ZwDlsb6Q=" }, "androidx/drawerlayout#drawerlayout/1.1.1": { "pom": "sha256-wS+pA7pTAFlipyQF83iWYIcY9BZz/EpiMS5FNhMvb0U=" }, "androidx/drawerlayout/drawerlayout/1.1.1/drawerlayout-1.1.1": { "aar": "sha256-LF8NyjeOt4yixEA/mInHfaowWTAiYPJqB/6fY8CJJv4=" }, "androidx/dynamicanimation#dynamicanimation/1.1.0": { "module": "sha256-n19EJUtexrF7SWW4tkGFHSiI29R0xsqBqcr1EnGPAmI=", "pom": "sha256-yuy3s3lYXY5AcOuvrjl0oTfGaJ3oB3+RZZNbNgV4SpM=" }, "androidx/dynamicanimation/dynamicanimation/1.1.0/dynamicanimation-1.1.0": { "aar": "sha256-kGLMZjcTakmtF/hgPHJRkFL/mlaeYX/BtttwhjBNqI8=" }, "androidx/emoji2#emoji2-views-helper/1.2.0": { "module": "sha256-o6nbWBq/F4ewH/FcQPBZUw6OZPOTfKoteI9C6zmJMmg=", "pom": "sha256-21kFLonRO3lGSwS9H4dNeX8dl9g/STf2aaU+H2ioAjc=" }, "androidx/emoji2#emoji2-views-helper/1.3.0": { "module": "sha256-CZdLte+XgN6dVnFdcRcaNcePsuF/2GV3OwyDo6ysA5w=", "pom": "sha256-JnOw9YWdQ2Gk6aMEV4UqZ0XQw8hbXgQSBwSD85Y+9SY=" }, "androidx/emoji2#emoji2/1.3.0": { "module": "sha256-3chR7bpl/RWnobw60YZI4vcy3VrY7zYCIkvOBkf1tNE=", "pom": "sha256-FOu+qCNPATIRll/dCLQu6QfOAHWvMhAYyMKGXC5dsOs=" }, "androidx/emoji2/emoji2-views-helper/1.3.0/emoji2-views-helper-1.3.0": { "aar": "sha256-mhNRKVpPc53w7+g0Stqpr7NIVsOvWE1KmvvsEFpFuQs=" }, "androidx/emoji2/emoji2/1.3.0/emoji2-1.3.0": { "aar": "sha256-K/I4GLI6mW3aobX9W7MhKdr/a7stzhUWbi/M3SAQsaU=" }, "androidx/fragment#fragment/1.0.0": { "pom": "sha256-4ynWczYelNLo9NTRTh8FhjaL1D+xnv1XZs50mLzM0WI=" }, "androidx/fragment#fragment/1.1.0": { "pom": "sha256-73jrJ6wC3fNUXV+KOFfHOig3oBhT+NX893JRAR21JUQ=" }, "androidx/fragment#fragment/1.5.4": { "module": "sha256-rzJggI3OtlMu/C1yFb5Fhywkppna2n13v/c4zjuFp/A=", "pom": "sha256-+MoYd8ZuZCxVSQfhzlpJOol8opJwyxniu21CS6Q7bJg=" }, "androidx/fragment/fragment/1.1.0/fragment-1.1.0": { "aar": "sha256-oUyLjyFT8SjoAPvSZqa+qxwoOYKinsVw0swF0wfYFJY=" }, "androidx/fragment/fragment/1.5.4/fragment-1.5.4": { "aar": "sha256-vDwkMd2kLpS7lRHFh+rokNJ25Kr+OTqNp7ABaRhtr94=" }, "androidx/graphics#graphics-shapes-android/1.0.1": { "module": "sha256-mlUIyvAtePzycpOU6X57J6Q3hic+F8y2W/jhsApZgyQ=", "pom": "sha256-gd57zIVJCE0QTVrzOxIPk+ENRUJKFaRUz6HyQwSqTRE=" }, "androidx/graphics#graphics-shapes/1.0.1": { "module": "sha256-LX9tVQQimfnE+EeKoJS8QJmjRpAnef8wkf7R38K2L1M=", "pom": "sha256-drUM5mT6RKSxZUIIHgiJkc8xaubnI6pM0BpicZ8aiic=" }, "androidx/graphics/graphics-shapes-android/1.0.1/graphics-shapes-android-1.0.1": { "aar": "sha256-cXEmmkEi/quwcXaN9G0Qs2Lkesdf0KqNvK+h9E9htAw=" }, "androidx/interpolator#interpolator/1.0.0": { "pom": "sha256-DdwHzDlpn0js2eyJS1gwwPCeIugpWSlO3zchciTIi3s=" }, "androidx/interpolator/interpolator/1.0.0/interpolator-1.0.0": { "aar": "sha256-MxkxNaZP4h+iw17sZojxp25RJgbA/IPcG2ieN63Xcyo=" }, "androidx/lifecycle#lifecycle-common-jvm/2.10.0": { "jar": "sha256-FZQwgth7zXiDA5j6N38sixJkPeKQ0JBu2OSaLTNd21Q=", "module": "sha256-k9kBazr9A2OaQH9RoRnVyk2umI3jdsOA8OUd2diOaG0=", "pom": "sha256-ygYrmrsCmLDUCuEMLRygbUEp9fYO/cXoxExsKzN10Vs=" }, "androidx/lifecycle#lifecycle-common/2.10.0": { "module": "sha256-XRJHse373J3e3L5SXJ0mKVZ7HFOMt6nGIM0EShJLXHM=", "pom": "sha256-EAT03wURixPCqQmEa63GZkbtJ3lEcGpgTk3YKE1TVIs=" }, "androidx/lifecycle#lifecycle-common/2.3.1": { "jar": "sha256-FYSPtW2zL0x83HKzJAAxg9UqSITWvwm+cIrH9YfRObU=", "module": "sha256-X7fIUU2MVsraXinvidwCiecZQqtMsLLm3KE3udy4/dQ=", "pom": "sha256-jNI9iJoUCVxs4WhA0psaY4j6XhFRRMEwnU1tRpwbw1E=" }, "androidx/lifecycle#lifecycle-common/2.5.1": { "module": "sha256-fUvClhzVvTmeNiHUNPDEU91srfiR+RepRswpGr3ajxo=", "pom": "sha256-gtIZLj3p/df0W5CI4nMV59vsAtCUYy+qajklFpDSjMA=" }, "androidx/lifecycle#lifecycle-common/2.6.1": { "module": "sha256-k3R6kUXLNrxxAF9Zjt4y4rEUmt5aFuYrDklpNFvGLYU=", "pom": "sha256-PmaDDVn65S4QMLS+ckseV3rIFNJzNf34GnEoPcCCnxo=" }, "androidx/lifecycle#lifecycle-common/2.6.2": { "module": "sha256-D6fyj1z/ikBqT3hwskPLDW16fCD6p6K+yv9ZB64S+cw=", "pom": "sha256-VzBM2sTaKJpuzdBzjhax2IWPHvjp+r4tZaljcZ/YHbo=" }, "androidx/lifecycle#lifecycle-livedata-core-ktx/2.10.0": { "module": "sha256-KyigMgHzB3sq4+KFP5g5RK/fUYsCk5rPyf6eX8q4cnU=", "pom": "sha256-2YIILDJ57rnBh9mJdmXKmpDTRD812txokmH0snxy2aw=" }, "androidx/lifecycle#lifecycle-livedata-core/2.10.0": { "module": "sha256-HYO9XzzMEpjtolue0SjowYf4MOfzr40ClL5oirsDw10=", "pom": "sha256-S+1ekc0qfAFVgyC2oXbbuGf8o8TGl9uUjiA75F+RDWQ=" }, "androidx/lifecycle#lifecycle-livedata-core/2.3.1": { "module": "sha256-seCV1VDTmn1sgVdh1tvj/WTrMaOdwoFG54u/LAG6j0E=", "pom": "sha256-+5liIuPeR/G964s2WQuMEYmKT3L5E42hhzZdMQOhQ/s=" }, "androidx/lifecycle#lifecycle-livedata-core/2.5.1": { "module": "sha256-PziOngeJAZcMK/z8Av7K6UjeS0a+UhGRmuB9ASyimA0=", "pom": "sha256-ZC8Oldo/OGux0TbvihFIHczThMKKCT2Utce82qLvpbM=" }, "androidx/lifecycle#lifecycle-livedata-core/2.6.1": { "module": "sha256-6cDcPwrFRBnAz+2P9c7LgpQ6fFj3pUFp8NhJssYKNVI=", "pom": "sha256-4PZ+Ky5hLxO8Dzzqck2u3vpdRJQFHrRfW+5tiKbaiFk=" }, "androidx/lifecycle#lifecycle-livedata/2.0.0": { "pom": "sha256-qEhC/8DxTlGNt1wFzBEmgKikoWT6eDlb4y2IMEpDlCM=" }, "androidx/lifecycle#lifecycle-livedata/2.10.0": { "module": "sha256-Om1HOzEEyYNQHeSIPWunaE3IMzDEYO+Vg5CoJlsHgxA=", "pom": "sha256-trk2hsVaHKjsIOfYSl/jQSZ+XaxuOQ+xzKOYODER6wg=" }, "androidx/lifecycle#lifecycle-process/2.10.0": { "module": "sha256-xorsfMHQ0Z9RMo59KMkdz7SsjhqYQ8/WGB4aqUrhnJs=", "pom": "sha256-osMGIyCHP5rCne0dnKSiQVUpwuRWjVO18rgT7FKtxmQ=" }, "androidx/lifecycle#lifecycle-process/2.4.1": { "module": "sha256-46rj7QS0dE/zFFLpj9KZ4639KNO1cjZh2WeLkvoJzrQ=", "pom": "sha256-2n4uEMrn12JaCuRUAXoSV0nheRoTnC13feka3YlBCvU=" }, "androidx/lifecycle#lifecycle-process/2.6.1": { "module": "sha256-WMnic3HM96IqIz9Ekm00jJ0H54xBpWWIpCZf9q52ZFo=", "pom": "sha256-QR/JuQIWdanSvS1alUhSAR2KMXJjtuM5lGRJpgFdOoY=" }, "androidx/lifecycle#lifecycle-runtime-android/2.10.0": { "module": "sha256-dJtuekQikUWB4HldnUj7T5bao/7pd0dCH/QjSGAYX0c=", "pom": "sha256-6X7gHa9vTcy5AL23Zyuqta08fCXEVJH9zPXroztur/8=" }, "androidx/lifecycle#lifecycle-runtime-ktx-android/2.10.0": { "module": "sha256-mAob4UbK+harOxaAmR3eFLIHeksT1bMUJ8uY3Ikw8ks=", "pom": "sha256-XBomndDwrOh/IoMog7iYvXtCUtaofwTBc0fYPjEoZg4=" }, "androidx/lifecycle#lifecycle-runtime-ktx/2.10.0": { "module": "sha256-VW8VlXN2hYtuOrwI+z31ZupetAZxSSUxlL8qBJYG204=", "pom": "sha256-BgrRdTOB56+deluJgp85Tvb9R/d+MbpMgSRivDe9phg=" }, "androidx/lifecycle#lifecycle-runtime/2.0.0": { "pom": "sha256-qSpG+nrsisMmpdV4c0otW2MgaXaZa54GyuFxs1sKtt4=" }, "androidx/lifecycle#lifecycle-runtime/2.10.0": { "module": "sha256-2oBfoBekrM4T9QFGmnWj8wYkjuvFj1vMKAGbABXfumU=", "pom": "sha256-Sote7FTV7N8JnqU7Lk6fJNspZ+pdOTCXtqbFSe1pMI0=" }, "androidx/lifecycle#lifecycle-runtime/2.3.1": { "module": "sha256-KnuQ5QSbZ0s2vM/WhnezoLMXiz98Lvfd9hjTiVWYxM4=", "pom": "sha256-tEQqhPw5ftsukIof33E8aufTqHZBoJ7hPRDsjuELMx8=" }, "androidx/lifecycle#lifecycle-runtime/2.6.1": { "module": "sha256-pMuwGkLQcEe9jYcAF8lqGwt7RnMyDoa2YxehO+LsEMc=", "pom": "sha256-QecdAD1DfxadToraWcsZgvG0e30lwjAQuPyB1SinQNU=" }, "androidx/lifecycle#lifecycle-runtime/2.6.2": { "module": "sha256-6gExhGq+H+nepZrG3+Hw+52LbWAMnv+aH9StXuXny8c=", "pom": "sha256-gXUlVUbipfUQhl+ErOaAZglUcwJAsZBdkXW0NFvtqXc=" }, "androidx/lifecycle#lifecycle-viewmodel-android/2.10.0": { "module": "sha256-BrB6B64Xx1kqMMUzw9RqEl/LG+8JbX5hNOJCDbJGTQo=", "pom": "sha256-CqFxpQ9QqFyQaHrYLc038xr90b/SRjwhXRMilg5R4ig=" }, "androidx/lifecycle#lifecycle-viewmodel-savedstate-android/2.10.0": { "module": "sha256-lJ0qhitrWEzuka9wqeAXvbEX0iW7RgltqUJKFyjKeek=", "pom": "sha256-X81Cm6V+Hd4bQ4U05Twj1idNI04zx0iWP2+qn4GTPIc=" }, "androidx/lifecycle#lifecycle-viewmodel-savedstate/2.10.0": { "module": "sha256-UIV3gePd+OOZBeTkpXowSWwkX5kB0bfT8XOCOCGINH0=", "pom": "sha256-WWVv/K4eDhRwQ7DW2JlmpflnUvBSkev6AoQG7ZRETdE=" }, "androidx/lifecycle#lifecycle-viewmodel-savedstate/2.3.1": { "module": "sha256-gINxC3WKwJaJHpH1HZHuVqRFsmXXvs8jA1U3cyfAQYs=", "pom": "sha256-IV5A7oT9r7Ke8liXexlrro+lpsejo0EUJ8eHsnHk9Fw=" }, "androidx/lifecycle#lifecycle-viewmodel-savedstate/2.5.1": { "module": "sha256-KazV/mFLP4kSPrg49ojWJeqotCLI0ZBbSK2OdgzXrYs=", "pom": "sha256-PXf6Ow4CbYa3I7vBNHQuIy6Pmc4YoR31t4lWLppsCm8=" }, "androidx/lifecycle#lifecycle-viewmodel-savedstate/2.6.1": { "module": "sha256-2vuGSXY9KcKc2ie8IvzauanvxTwP/5rj3pCILquqiUQ=", "pom": "sha256-qjTLhYY4S44xTP1lsYExl/Mve6cdJSmhHAcerwJ0Hls=" }, "androidx/lifecycle#lifecycle-viewmodel/2.10.0": { "module": "sha256-T9cd9zMkXEbkuI6FtKkLgsmvSjWe8x8r3yUPk6AsdFI=", "pom": "sha256-HYYK/wylmNSOjoPaP05naQeEmFdKGNO4iewrjECQsZg=" }, "androidx/lifecycle#lifecycle-viewmodel/2.3.1": { "module": "sha256-pTGFPf4xbJC3Rm0kvpTb5gpg71SlLJBMhjgZhiAuUfQ=", "pom": "sha256-I/RerQuaA4OC2cOq+ctR9QvrzLY4q4PfnYQqO/CMQRo=" }, "androidx/lifecycle#lifecycle-viewmodel/2.5.1": { "module": "sha256-AeQTtzy+OMtxTcW9shvYYJMRJMjl8jaYA/SqzEkIHJ8=", "pom": "sha256-k+XPzLyf0vihCS7AawdXvYNOY9zSqAAbWUI5qx5KbeE=" }, "androidx/lifecycle#lifecycle-viewmodel/2.6.1": { "module": "sha256-K0BvrqXBLyuN9LemCTH4RmSPLh9NeDYeGY0RhPGaR5c=", "pom": "sha256-3C6OZdtT0hZZon7ZO5Zt7jNsHC6OhyhhZ3OJqZuLkTQ=" }, "androidx/lifecycle/lifecycle-livedata-core-ktx/2.10.0/lifecycle-livedata-core-ktx-2.10.0": { "aar": "sha256-XxhCl0mhg+GReNhmWuQr97YxC1A1iung4MxFKv1uC8M=" }, "androidx/lifecycle/lifecycle-livedata-core/2.10.0/lifecycle-livedata-core-2.10.0": { "aar": "sha256-Et1hqYQ8zrtFR9Pr4vbQMMqPaYjSL4+tGcCvk7SpfpU=" }, "androidx/lifecycle/lifecycle-livedata-core/2.3.1/lifecycle-livedata-core-2.3.1": { "aar": "sha256-5V04w3JGDwoDmX3clQxnInURNA/XT4Y02Z0pZTzYGrE=" }, "androidx/lifecycle/lifecycle-livedata/2.0.0/lifecycle-livedata-2.0.0": { "aar": "sha256-yCYJztjEmPCnAaMPtncbt0gIYNruhNguCoHuhu33ujk=" }, "androidx/lifecycle/lifecycle-livedata/2.10.0/lifecycle-livedata-2.10.0": { "aar": "sha256-PowAn8iNocUuTtBagSadWsm9QFBG8wAwf146F+IBpt8=" }, "androidx/lifecycle/lifecycle-process/2.10.0/lifecycle-process-2.10.0": { "aar": "sha256-ELtbsSdz3KEdg0PAFM/RfftAln1uzjfo8V0E2M6wd1w=" }, "androidx/lifecycle/lifecycle-runtime-android/2.10.0/lifecycle-runtime-android-2.10.0": { "aar": "sha256-IZOhVz1iPzeyDH0n0aj5A6cvZRzG8y5XlPhd2nRP7nU=" }, "androidx/lifecycle/lifecycle-runtime-ktx-android/2.10.0/lifecycle-runtime-ktx-android-2.10.0": { "aar": "sha256-hxiDcDM5+HKE0jLLQ24xfG9K9WEtkrQ/J8axO5IQn6c=" }, "androidx/lifecycle/lifecycle-runtime/2.3.1/lifecycle-runtime-2.3.1": { "aar": "sha256-3SlPSmiccf+Hf9QfO2ejpi93YNRM5CDmEw8fw1adjwA=" }, "androidx/lifecycle/lifecycle-viewmodel-android/2.10.0/lifecycle-viewmodel-android-2.10.0": { "aar": "sha256-kwMocDACfKC4z5inSN3rEh7Bv6ExLABpQo5HZburzng=" }, "androidx/lifecycle/lifecycle-viewmodel-savedstate-android/2.10.0/lifecycle-viewmodel-savedstate-android-2.10.0": { "aar": "sha256-GUBCOfpYQyLC+B6WWeFivAEbdG0p3JUKEdJgYk2DIl4=" }, "androidx/lifecycle/lifecycle-viewmodel-savedstate/2.10.0/lifecycle-viewmodel-savedstate-2.10.0": { "aar": "sha256-H6InIMh7D7PBIk+FgM9pNp88/E2ZIXCLuUSfh4x8s+k=" }, "androidx/lifecycle/lifecycle-viewmodel-savedstate/2.3.1/lifecycle-viewmodel-savedstate-2.3.1": { "aar": "sha256-lxN6ivajF3ahTkhmq4CMfAp5G0hL28eIu9g+ZkB1ZMA=" }, "androidx/lifecycle/lifecycle-viewmodel/2.10.0/lifecycle-viewmodel-2.10.0": { "aar": "sha256-S4Cc+etzI9IAUE6Vkh/rd3/wgCFcrbgjd0LV7Qult5I=" }, "androidx/lifecycle/lifecycle-viewmodel/2.3.1/lifecycle-viewmodel-2.3.1": { "aar": "sha256-tttMJ0oS/4WkdH4eZmnH6Yrvolcazp0fGm+mvkF86Dg=" }, "androidx/loader#loader/1.0.0": { "pom": "sha256-yXjVUICLR0NKpJpjFkEQpQtVsLzGFgqTouN9URDfjF4=" }, "androidx/loader/loader/1.0.0/loader-1.0.0": { "aar": "sha256-Efc1yztVxFjUcL7Z4lJUN1tRi0sbrWkmeDpwJtsPUCU=" }, "androidx/profileinstaller#profileinstaller/1.3.1": { "module": "sha256-zH7tDtS2ad6EuFL3h5elABik8wAC4eOKqmaK8iyltGA=", "pom": "sha256-CCY+twqBSnyybRHTqDgl+Ewp8+S1n5E0ck4OAuK712g=" }, "androidx/profileinstaller#profileinstaller/1.4.0": { "module": "sha256-Ob+ZeijY7tLLMZgZ9vNSobo6eLnJeQBPvgXia499Fgs=", "pom": "sha256-1f45mBo7S3w5WFEw2gv8k4NQVMg0pDjT5j4GXl94vlU=" }, "androidx/profileinstaller/profileinstaller/1.4.0/profileinstaller-1.4.0": { "aar": "sha256-1QIUH8zpAkMPYrZ0wyrs0PdSYufuLNFcdK22F80TEwo=" }, "androidx/recyclerview#recyclerview/1.2.1": { "module": "sha256-I1QsjIXMWPr+CujLogHmyeAbTGeZIjNAotalHXeEgow=", "pom": "sha256-2xYvz8Ayy3N7oH+vLRk2ye42K3Tgw48xzNLGoszXkJA=" }, "androidx/recyclerview/recyclerview/1.2.1/recyclerview-1.2.1": { "aar": "sha256-oeoDKe5tk4MF39D4zlxI3qKqwU5WBtI+f7YK/PtlXW4=" }, "androidx/resourceinspection#resourceinspection-annotation/1.0.1": { "jar": "sha256-jP+HDsb7MdtIpS9KeSM1tL+N4H4DvTeCMYFSZDPM1cs=", "module": "sha256-NSoRqNikwb1s0sL+//nJTKlU17UgKgZWlZ25Upf2orc=", "pom": "sha256-uOtJOO2V1/zaonEuvKNj5GoPkii4vn8jFU3vVoRoFdw=" }, "androidx/savedstate#savedstate-android/1.4.0": { "module": "sha256-AGJRTcuAwup+BBH6i+VFUIU7q3v2sb8UwlF8Lvh0/1A=", "pom": "sha256-tEshW0zOJMpktfqlj/Rj9LkavAKvlbpAWt5IiCj093k=" }, "androidx/savedstate#savedstate/1.1.0": { "module": "sha256-buorwVCCjI/Lp3fpMDcDji7j7EQcQ9as7PLFzZ3cU3Q=", "pom": "sha256-SXhLdctJm7n4E32COocvDG2lym26fJrPLeOmg8t9ttw=" }, "androidx/savedstate#savedstate/1.2.1": { "module": "sha256-W7ZW/HYNnjmWtTUWDLtBBgM8n3NukInm706wxml4UGY=", "pom": "sha256-DTO8KF3x4S8ieA8WJKbws46iphgbCVXsZkHK9iDFDL8=" }, "androidx/savedstate#savedstate/1.4.0": { "module": "sha256-oPKsWgdgY/DIE891pG37eAvVpaLIZ3JAvKhVFVTaj9I=", "pom": "sha256-0raKLQzHbyPOvG1Oqbvy1j+1Nv/ZTgVRStnabF9TBJI=" }, "androidx/savedstate/savedstate-android/1.4.0/savedstate-android-1.4.0": { "aar": "sha256-FlbOYs0jPUiL271C5TO4CyJDW3ppCsrm2+cwI5JRLBQ=" }, "androidx/savedstate/savedstate/1.1.0/savedstate-1.1.0": { "aar": "sha256-1gu+RMLAgIOhfF3GeKbWtNCi1mSFgBarXAScvqkKY7c=" }, "androidx/savedstate/savedstate/1.4.0/savedstate-1.4.0": { "aar": "sha256-2zgXAg7p/X42s2wcVryiMtMBml5MshxK2usgwn9XYF0=" }, "androidx/startup#startup-runtime/1.0.0": { "module": "sha256-QO/8oNbuH94yvClol+VOu8xM9KopsMUxA2y9KoJKPCQ=", "pom": "sha256-GQtlQlHxEEUvZ/ahPXZuj+4IEfB+bwsPd9WJBiJqy6g=" }, "androidx/startup#startup-runtime/1.1.1": { "module": "sha256-z9ls9kUMbitpdZiSRymtmgSVxaT89Ovufi+BsH5BWGU=", "pom": "sha256-9BFLXGhZuxvDyvKBy21vJZmPp/cpLGTOrqdKkyEOdGs=" }, "androidx/startup/startup-runtime/1.1.1/startup-runtime-1.1.1": { "aar": "sha256-4KYymjcSYv5MRQNytw/a8zt2nvaRcJRyN4fPzolrHdM=" }, "androidx/test#core/1.7.0": { "pom": "sha256-uX3REs1oStNob1zPWBuSsQbKdlZc8CzP7OBgrzvFB0M=" }, "androidx/test#monitor/1.8.0": { "pom": "sha256-aBzwjEFwDHOxRpU4Z8Csgm2pwqgQvSpKj4eOoRxk1Aw=" }, "androidx/test#runner/1.7.0": { "pom": "sha256-YigxxtMWZvs0OMsNLuH7u8eMq81JcXaZV42SVQBiIH8=" }, "androidx/test/core/1.7.0/core-1.7.0": { "aar": "sha256-9NrNjtzu7Ejgx27PKDObKPS09rdPjjTp5ZtHLCfZ64E=" }, "androidx/test/espresso#espresso-core/3.7.0": { "pom": "sha256-tuf0PLOWuJpM4ymn5azvedN1IDqHoRp2OdGMDm3lC1Y=" }, "androidx/test/espresso#espresso-idling-resource/3.7.0": { "pom": "sha256-csXz3/ITvRQ836LrQW0b3hztZlbZxUtp78qPdKLMgx0=" }, "androidx/test/espresso/espresso-core/3.7.0/espresso-core-3.7.0": { "aar": "sha256-XdkONmg4vwRMtS6uBkdN69KF3xinp3xARBrI6JUbsA8=" }, "androidx/test/espresso/espresso-idling-resource/3.7.0/espresso-idling-resource-3.7.0": { "aar": "sha256-X/YjJrScMIwdBgRmrjz0qg496vkpXwd6aIYEjdo+mxQ=" }, "androidx/test/ext#junit/1.3.0": { "pom": "sha256-kq9/JT5eq+LK7FxW8wgxJlPRIwQrInVNb/Zk4XUtzz8=" }, "androidx/test/ext/junit/1.3.0/junit-1.3.0": { "aar": "sha256-M2PfhNpFQLqNr/AsP3zWVHEDempTcFkafm3ro3ezbn8=" }, "androidx/test/monitor/1.8.0/monitor-1.8.0": { "aar": "sha256-Vst0lqBtny3KfT/3bFCoowvRjgCiSjsmfVoxQ3snjmc=" }, "androidx/test/runner/1.7.0/runner-1.7.0": { "aar": "sha256-lwMRxHEZkoouQGqIiSo9JwOHzFpJoYGhxEUREFtBuBg=" }, "androidx/test/services#storage/1.6.0": { "pom": "sha256-jl1y4rzu9u+Ae8OINNvHqlMXf9INo0n1Pc5aTyoCi78=" }, "androidx/test/services/storage/1.6.0/storage-1.6.0": { "aar": "sha256-+X489qr04/uX7yGdN6nAoHIBg8H224ezdkJSHib7bTA=" }, "androidx/tracing#tracing/1.0.0": { "module": "sha256-/Ish6+X6OnyW7gmLzc0A8HfrznPyQ/qFjisGcWFfddg=", "pom": "sha256-zQKZqQ1HINePHPtf91BfTbwacNBf4j/Z9NS3fqWcoF4=" }, "androidx/tracing#tracing/1.2.0": { "module": "sha256-0NjUhra9MyBtvz8abRZ+m0PCaOpjwzIciGsVQ60F7OM=", "pom": "sha256-80vsTrWIcdMQApATdxCKqh6/53+m2IK4uGIAsVjibqE=" }, "androidx/tracing/tracing/1.0.0/tracing-1.0.0": { "aar": "sha256-B7i2E5ZluIShYuzPl4kcpQ9/VoMSM78lForgT3tWhhI=" }, "androidx/tracing/tracing/1.2.0/tracing-1.2.0": { "aar": "sha256-b6qQOQ0f2/Ctuamb+Z3me5TGxvNa6pUQWTqdF5c3NqI=" }, "androidx/transition#transition/1.5.0": { "module": "sha256-rUM0Z/Xt6HrSs8Wff+BjmEzwcgzazRBZH12PhOOk4JU=", "pom": "sha256-22x0l/DwlP8u7PsynhcAZY8NbFb7CISC9+m3dN6a1T8=" }, "androidx/transition/transition/1.5.0/transition-1.5.0": { "aar": "sha256-CqZqDqQG0loQkflqO3U7SxLkT9xDuR7FLBeDHpwx9Us=" }, "androidx/vectordrawable#vectordrawable-animated/1.1.0": { "pom": "sha256-J2ogEWtwX7dbkAPulJbFb2/TsyN1+yMkcoEeumCgQL0=" }, "androidx/vectordrawable#vectordrawable/1.1.0": { "pom": "sha256-Ww4tWyF55UgEeFy8Ic5fRzteHd1VpX2kgulNzTlJK7I=" }, "androidx/vectordrawable/vectordrawable-animated/1.1.0/vectordrawable-animated-1.1.0": { "aar": "sha256-dtosUCNx2cOAVN9eKySNANqHgJ7QWPM2Pq6Hzl4kA/g=" }, "androidx/vectordrawable/vectordrawable/1.1.0/vectordrawable-1.1.0": { "aar": "sha256-Rv1jOsAbSbf8q8JjvwmMWouemml3TSNO3MoE+wLfjiY=" }, "androidx/versionedparcelable#versionedparcelable/1.1.0": { "pom": "sha256-xynHvgzAYyO9qCnUYGZuedvUO3maIQiaRL07KT3CU7U=" }, "androidx/versionedparcelable#versionedparcelable/1.1.1": { "pom": "sha256-X1HmWHPKYS3jg4+pDS7pW40EDv0xucOQoZv5TWFc2y8=" }, "androidx/versionedparcelable/versionedparcelable/1.1.0/versionedparcelable-1.1.0": { "aar": "sha256-mh13FArCIreGa1BU7n0Vm8GACYftLUbdav3RRau3EME=" }, "androidx/versionedparcelable/versionedparcelable/1.1.1/versionedparcelable-1.1.1": { "aar": "sha256-V+jZMmDRjVuQB8nu08ZK0VnekMhgnr/HSjR8vVFFNaQ=" }, "androidx/viewpager#viewpager/1.0.0": { "pom": "sha256-H3L4NjOdA8brAT9lB152yocHWld1eOtPlfdKOl0lMSg=" }, "androidx/viewpager/viewpager/1.0.0/viewpager-1.0.0": { "aar": "sha256-FHr04UoZhAENjxVeXhnXgfA8HXDf7QKo4NGEKLj8hoI=" }, "androidx/viewpager2#viewpager2/1.0.0": { "pom": "sha256-QGO8p/6U/mXJj0Fo+XrhDgLaAkhZitOsIcQyx/YIoXo=" }, "androidx/viewpager2/viewpager2/1.0.0/viewpager2-1.0.0": { "aar": "sha256-6VwAMdTMJHzUgZbGKH5Y0s7lTZx5uFr+p8kJIDMCda8=" }, "androidx/webkit#webkit/1.15.0": { "module": "sha256-xdNHJ9+0XW32b68ddJJ6fxGICKSO9E0NnzOrjRyN9P4=", "pom": "sha256-wb8AHuiPdfjpTZRPWW6Yq8ItdYGCsZ6YHgJPpCGX3h8=" }, "androidx/webkit/webkit/1.15.0/webkit-1.15.0": { "aar": "sha256-JByQqsk3tlYlkpdtpcxzEAU4vyVQAPSQ7TcpbaF3OWA=" }, "com/android#signflinger/8.13.0": { "jar": "sha256-wdyixoNjTuGilCmPnHF5V4r2qG4IC9xA+WGRW8XIFC8=", "pom": "sha256-OkrXUCQ7Vqh1+Vc7f8p42UcVlOFAtdpY13lcyGX08rk=" }, "com/android#zipflinger/8.13.0": { "jar": "sha256-BwYAacNeRp18NDq8FfHWNivBNWuBv0YlOduIpT7WU/E=", "pom": "sha256-Y2qWYiGqGrmJ+pOPBuxMzdu47f9BmmclG5NZkX8LolI=" }, "com/android/application#com.android.application.gradle.plugin/8.13.0": { "pom": "sha256-d8GqTwlczexLj/r3rOOuZ2xZrPxO360gzEUtJv4fqUE=" }, "com/android/billingclient#billing-ktx/8.3.0": { "pom": "sha256-Yfdvz4C6UuSgX+utw4xx82TrKfPkyBtekTvIhyc1n3Y=" }, "com/android/billingclient#billing/8.3.0": { "pom": "sha256-cJPjGv6SbvKyCMcsKUHo3GtbS13Jcjh+MEjrTd2R9SQ=" }, "com/android/billingclient/billing-ktx/8.3.0/billing-ktx-8.3.0": { "aar": "sha256-KoscetmEKbQkX/q6HbSPG9x49s0JeuwlbT1uC68bfK4=" }, "com/android/billingclient/billing/8.3.0/billing-8.3.0": { "aar": "sha256-mBC1zUfiC8grNU81g8nTv3xCsz5k4ZLc4EldA2qF9QU=" }, "com/android/databinding#baseLibrary/8.13.0": { "jar": "sha256-eUETcJ2rIbBsJis3lec8twj7rK5hcV80Nh4a9iN6GHA=", "pom": "sha256-PZLfn3MWZqVlF4M4DtMuA1y/KTMKqrzHirWuYzURA/o=" }, "com/android/library#com.android.library.gradle.plugin/8.13.0": { "pom": "sha256-dhcDGWYBg4wNlBgLzcP3iinwHhQbAFZ9rV3Gym56yng=" }, "com/android/tools#annotations/31.13.0": { "jar": "sha256-O0u5YgwX0Z5b2RrBmICAVTVztMO3Of3ZJBb0Ly2vPng=", "pom": "sha256-Iwt9E2YNaI/2m/7LXK/AlXWmHE4G0epZJeij/K2I3ls=" }, "com/android/tools#common/31.13.0": { "jar": "sha256-tLb0upSEPIbhNl8pTZCFtfTxT2P8zQ8OENp/2/pMPQQ=", "pom": "sha256-R4QhNsLtFHaoDxQ/aY45NDUVAvSGOrRzinl9KOSwSQs=" }, "com/android/tools#dvlib/31.13.0": { "jar": "sha256-488/3JR3iN7o1bqnbLcqZlcRdLxHQe3w47q5enypDhs=", "pom": "sha256-9WoffiwZb/8CJvKv0r9Kph0cW7fw8rJ75h8wcZl6iY4=" }, "com/android/tools#play-sdk-proto/31.13.0": { "jar": "sha256-xvwVpcIDBkz9LIoXb96scq4KLXQ+xHouZqAjjY2HC2s=", "pom": "sha256-deEueVxle0h/0V0yguHuTnEz9JG37/+xQ5dci+YY+pA=" }, "com/android/tools#repository/31.13.0": { "jar": "sha256-6VCbMNCI6JmUj4yw1zKTwe/S4fEh/Mu+JdUztki5P6E=", "pom": "sha256-uUBY6ELBtlTAHdJ88i/4gXP686I4juq0QH8oLGLHuy8=" }, "com/android/tools#sdk-common/31.13.0": { "jar": "sha256-jP35nW8XaJ5907zxg01zT23Rxk2MQ5BGMsZdVGlWWTQ=", "pom": "sha256-fqorU3mG9siHaRfu2PwX9ktvZwTk4nuNRzL18pNlq7A=" }, "com/android/tools#sdklib/31.13.0": { "jar": "sha256-3vmw5/ROVK3ThcrBcVSDck+CfxZlEevAwQMZdCqoCGU=", "pom": "sha256-1Ig+YTEazhApxPoOufKcmM38+rQnPNmgcEMDaQAAaPk=" }, "com/android/tools/analytics-library#crash/31.13.0": { "jar": "sha256-zKl6wpoTKb0xCj6DK25X9GIn5QGqUpwApj3yF8XX30E=", "pom": "sha256-ryOUI9PO2T4v88PkO1euo2v8h5czIZJs2MoqIVnrZkY=" }, "com/android/tools/analytics-library#protos/31.13.0": { "jar": "sha256-st7SCol/upZJ7+sYui/AYu455QDU63EgRcsONLQ7Xvs=", "pom": "sha256-YO8OzBOQSe/eID4PFjk7ok6IUN/2pwBbONrpYzVJ0YM=" }, "com/android/tools/analytics-library#shared/31.13.0": { "jar": "sha256-dUNYFvICt6PITZyvMSqJViWiRJkfj8UtBEYjnjrimpw=", "pom": "sha256-tG8yfisNff7yGsrElVOMUuw6U/obFkT6aXvl58iVvHY=" }, "com/android/tools/analytics-library#tracker/31.13.0": { "jar": "sha256-G2ZRS/KRUkIu6KGbmOAgDZLrCj0oBI60hXVk6aHHuFs=", "pom": "sha256-QPVQaB9n0YOudJBN61sBLDjHQWXomdCjMq19pDqc48A=" }, "com/android/tools/build#aapt2-proto/8.13.0-13719691": { "jar": "sha256-as7GPBbFhmf3uyqzG8HoRGWxE6g0ewP7iVW36r7h62k=", "module": "sha256-cL4Y8GBA6Tt5lr4FroRGBiuiaqQR2F5zydzXHl5u3wQ=", "pom": "sha256-29EePBH09HqyCK2U3ipb8J0/u0sKhP3PWc0CFB1Vjxw=" }, "com/android/tools/build#aaptcompiler/8.13.0": { "jar": "sha256-9ek62kopAqXYGBaLQHLQgffPoELqbQBlbaQhc7yn/n8=", "module": "sha256-MsMT7QGm4AbuPqxxfBfB5dIYqd8lZ1KNNLF6WmdC3gk=", "pom": "sha256-o7d5hvIUrN4e/dH+/qk5u1HO9UekRODAPaac9dy0VsI=" }, "com/android/tools/build#apksig/8.13.0": { "jar": "sha256-wHDtE5RinXRkGqCQb2Cy/6Hud+Y2ah+TQ39ZcXsa64k=", "pom": "sha256-s5tsQWn8Qrt9OnTgRd6CA1Cfo3dpOzO351Th8Hx1mGk=" }, "com/android/tools/build#apkzlib/8.13.0": { "jar": "sha256-KQkclFclL5l93+r7M91lo3OtRYQBKPlFgy2Or9kRhWE=", "pom": "sha256-p7hsdtNZ7+4sfrKVqtChxjIhkrpk3Q23Geb02+t+dTA=" }, "com/android/tools/build#builder-model/8.13.0": { "jar": "sha256-GhkEmYAFa3sTIeLB4M/26fMTYr3YM+p7PyXqh6+3f1A=", "module": "sha256-F86TynVjAkBwXrgzTh55729jxsbnrNXQoBpaoWvd/+I=", "pom": "sha256-VV9IuanQmUqAy1yUWQp9o2rLnFUm528JOiB0kKIUklU=" }, "com/android/tools/build#builder-test-api/8.13.0": { "jar": "sha256-q3NB3qJORrIpx4sGazdm2WWqktdcLet9Vc29rOPxnR4=", "module": "sha256-Q+ybge0lm1AGl+zeJCraIG0EiJLRZQ8Eh9cx/OMrzeY=", "pom": "sha256-Z1Q1tnMvTFewncDv4cQEW53jYFVVNgWvya069mi/izI=" }, "com/android/tools/build#builder/8.13.0": { "jar": "sha256-RkrqnMvyAN7WWX0oSCVNlSKel4J3nqZ/lyWINAgSpKk=", "module": "sha256-D9sgwS9lktgI2m0Ade00Ug9hxxk2hotL3jkni7G3tZ0=", "pom": "sha256-nyQ1neOGWJattFOSiFNXL1K/xmTzaS3ypIfnnw1cihM=" }, "com/android/tools/build#bundletool/1.18.1": { "jar": "sha256-pzNBp5RavLDmuJccexsoAb12UAZEfKDSQ3pCYNVyzqw=", "pom": "sha256-XE33suMfF/IOS429YqK3hloJpJof0pMaNZ/TlOy5taU=" }, "com/android/tools/build#gradle-api/8.13.0": { "jar": "sha256-P+x5YqMQnwprC24nyJ/87nXIgJYmPxkQ1HXy47UVdX4=", "module": "sha256-XP/UxYtwOYHY4ySI+FRVK6+MjrceXiv7xzSh83S9yEI=", "pom": "sha256-S6sCnnPyoOigOK+FCIa3LQ+lxoGXgdgCQaZvSlG+TFo=" }, "com/android/tools/build#gradle-common-api/8.13.0": { "jar": "sha256-s6HcvV9e6czXomqfh+BxNOZusiZjTCbu+J4tO6uBwSE=", "module": "sha256-68dcXU2WF7ALKaa03p0LKmwfjN+B8+ZdqgKqwd4KaQk=", "pom": "sha256-726T8+2ihPbbbSIoutfPCdGQeWnWvfUlXHt2NYT9t6Q=" }, "com/android/tools/build#gradle-settings-api/8.13.0": { "jar": "sha256-C7Q8iD0zcZJS6UKyoKcR5ICiVCN8/fMT5TQ6qK0tKl0=", "module": "sha256-P8c1U0Ih8nqq6a5BjhtArteQai2X+7Qnvg8NsD3s/Zc=", "pom": "sha256-wvvYtu8RBTT1Bkn4szDG1Ol5YNjVtyt+aJCMBW0qXnE=" }, "com/android/tools/build#gradle/8.13.0": { "jar": "sha256-0BlYdEl7SsPq6zqFJb95TTpbY6mgEITKmsMw+2MGDQE=", "module": "sha256-H/68JjTTItX/Fkb7VKqc2qoAq16zkAoZP6Vhsj4DGnc=", "pom": "sha256-7kGC9LXLZUTzbSPbXRWWdMqeo9sFqOR0KVr8/Gz1ENo=" }, "com/android/tools/build#manifest-merger/31.13.0": { "jar": "sha256-PpNwiOXPzGeT2fKXGxZMnK3OEK/Ck4zYWOqVRaYhhtU=", "module": "sha256-8SUIO300T17ySA7gx0osjbFoewz7udqc5mQmSv76+Hk=", "pom": "sha256-JZpXvIp3fUF6JYuN3/CE/5IYmQ5vfA5upSZGtVt7Tpg=" }, "com/android/tools/build#transform-api/2.0.0-deprecated-use-gradle-api": { "jar": "sha256-TeSj0F4cU0wtueRYi/NAgrsr0jLYq7lyfEMCkM4iV0A=", "pom": "sha256-fGLzhW6KvKHXkleSXybBJmhpP12VkEBWu6yIYFz9hXU=" }, "com/android/tools/build/jetifier#jetifier-core/1.0.0-beta10": { "jar": "sha256-Jqu0oTkn2QYhacUEyelP6A6a46T3tauIdasAdTapH14=", "module": "sha256-8JF1iaQtJ2Fj8QBAq1hC6RiD3L2x1Iv9Hx/Kpywcp7c=", "pom": "sha256-XJ1C5rfjXU2NAuCjIs8maTs+w2QrEHyPC+WnIdRaDG0=" }, "com/android/tools/build/jetifier#jetifier-processor/1.0.0-beta10": { "jar": "sha256-xQZ6e5KCN6EnGl6ctXEOn4C0lzKTlFvFHjpMhk6kv+0=", "module": "sha256-NsJVdrGZk982AXBSjMYrckbDd3bWFYFUpnzfj8LVjhM=", "pom": "sha256-M7F/OWmJQEpJF0dIVpvI7fTjmmKkKjXOk9ylwOS6CEI=" }, "com/android/tools/ddms#ddmlib/31.13.0": { "jar": "sha256-g5lX+WEQBxPqDu1iioaEzDmqR5Yxw2JJeT5t9+DNY9g=", "pom": "sha256-9o0Z08HeMqDo+iVN4s+eyb9D5oFUE1jVXRKza1sRX40=" }, "com/android/tools/emulator#proto/31.13.0": { "jar": "sha256-t3+BzAdR15OT7EsusEb5ENIavNdgi1sPWh7+obMkO0g=", "pom": "sha256-Dxsx0Qc0+bhXjctVkV+SNHQyoOt9BbzNJOBdQL/6XLk=" }, "com/android/tools/external/com-intellij#intellij-core/31.13.0": { "jar": "sha256-mm+qYGHQ89VKZN7LYZRMGyxpJ/jTJc0pjILCqNhn7mg=", "pom": "sha256-YbAEUWWBcVMjtLu3101F7yW91VGE6HWFb9cuzpE4FKk=" }, "com/android/tools/external/com-intellij#kotlin-compiler/31.13.0": { "jar": "sha256-VS36/+KV0IUEhwgWwn/AkAfhIx+5sUwf+bv4YfmzWZA=", "pom": "sha256-c+HV4hKaLGY+mPoDVFeO1NvE5asp2DLk+EPvEJ6crlM=" }, "com/android/tools/external/org-jetbrains#uast/31.13.0": { "jar": "sha256-ePGKwrJQn7bLGQWOj8lYXDYbl5kN19XbDCqUdE37CpY=", "pom": "sha256-8mr1nLfxVVFRAXoV7aGZ76BfpX4Fzw5Y7EMVq98u5eA=" }, "com/android/tools/layoutlib#layoutlib-api/31.13.0": { "jar": "sha256-0GvGUCR2MqSk5llrhzEgGfRekAJnxUdsR6W/puP9MTI=", "pom": "sha256-iY/RN1KxPWOQywmDx70j8ciixQt1keCrrRRoHQnq/8M=" }, "com/android/tools/lint#lint-api/31.13.0": { "jar": "sha256-j3cGV9ujPzBeWDxilTpPF0x1p7HNLafTETS+Nqlq4qw=", "pom": "sha256-Y1/FLFcMEsSyt2SCb9w0w27/erwm4Al6fIBQVEBKbVs=" }, "com/android/tools/lint#lint-checks/31.13.0": { "jar": "sha256-O2Tzla4X/OoQSIKwCkrNx9xpH12spd/yvd6J+gUrsZk=", "pom": "sha256-DFNHI/Dx6d0+vQXFLJ2vRwFtG5NmIL+CesfH+9iGr54=" }, "com/android/tools/lint#lint-gradle/31.13.0": { "jar": "sha256-pCtqQcQ22QyjGhPWevuhFXsVfvyJKnSW9nQyv4qDHL0=", "pom": "sha256-wldyxmVXyNl/Ov6VrdQ6UPDhqkvv5cbKfiKwXRMlssU=" }, "com/android/tools/lint#lint-model/31.13.0": { "jar": "sha256-nuVdj9ACc27ZXul/sF9N964B9Pl29zj7837Kt5Xlkxk=", "pom": "sha256-S7bme4aLcsJbFhS2HXUJguBBTNZxYXK+MVWtKqfW+4A=" }, "com/android/tools/lint#lint-typedef-remover/31.13.0": { "jar": "sha256-Sjujur/Xnm/Ge872R/tOz+r1m0gbEI98LrpNHFxt6o4=", "pom": "sha256-+X0rIYob+njItTyCeITvq646344Sl5HPff8v9LNG89k=" }, "com/android/tools/lint#lint/31.13.0": { "jar": "sha256-f4damA7iORZDnTaNBzz7wu5OTZn/4bPhPaeU/vNH8po=", "pom": "sha256-f167NcXWSHwddTYFMU/YyJ6DNCsJjjaUbgfVqkun/GE=" }, "com/android/tools/utp#android-device-provider-ddmlib-proto/31.13.0": { "jar": "sha256-BHrs3WbhBhN/d6UsRC8bg9t9boSWiZgAJR8gbH853mU=", "pom": "sha256-qQVnzI9qaK8p3Oe8tkWLYUhuENJG14TCId81qHsTP5M=" }, "com/android/tools/utp#android-device-provider-ddmlib/31.13.0": { "jar": "sha256-5sLUZ0B3YQ4WVSTfbgYXtq8PkTWAdYVNV8EHL1+wwkU=", "module": "sha256-x3m3ICzqcYzkhvgR0SSgxdSujGW8tkP2I41FJCnYmec=", "pom": "sha256-Q3Yb/9k6QLUWjoKLjUilMYetQs4pzIPX0X8bMxO+DHc=" }, "com/android/tools/utp#android-device-provider-profile-proto/31.13.0": { "jar": "sha256-PnsJj24+yuMbb3kJw0O07AmqGNion0G/kgd7pLBW9FM=", "pom": "sha256-vE0jPmc1PQr+1+iMqMQ34Ma490i3sERW21ACEk6t3RY=" }, "com/android/tools/utp#android-device-provider-profile/31.13.0": { "jar": "sha256-Gq5pCd0QzxXRAPJIcaW5MQ//bSVW1nw4CKHhZj70cco=", "module": "sha256-WNmd8ITAq2CeOdyYmA6Q75r8IWum97kG0sTtYS18fig=", "pom": "sha256-lOSDirGHZJ18wbQuI671v4ZsvtWq2ANGtobzikH3vRs=" }, "com/android/tools/utp#android-test-plugin-host-additional-test-output-proto/31.13.0": { "jar": "sha256-a6fmrCII10wbtfHRRkq6/GpF2HELIEVaLcAq34cmvIM=", "pom": "sha256-ibdgXQ7NnRVVmlbQN69jH0CySilrGOLNqRQNGIdxtYg=" }, "com/android/tools/utp#android-test-plugin-host-additional-test-output/31.13.0": { "jar": "sha256-/F8iTbGx0hZCM53/COO8ybs7Z1PE5qfV51B2mny7vrQ=", "module": "sha256-0cRnguU1tnS5VhSsNNHyPkfw8CwAQnPGZRvg2zaoQ58=", "pom": "sha256-VMJZG3W8XWAHY55AIXMQkKcrBFa0WNfWb/wcMWjU5gI=" }, "com/android/tools/utp#android-test-plugin-host-apk-installer-proto/31.13.0": { "jar": "sha256-TythBULpGjWjlrBDaKeEA25CuHhwIUYFULmjSVu4JFs=", "pom": "sha256-fY+DUTrN4W/1r/SMCdF8h6S/7J/Z1ZsNGIhmoPaGmcU=" }, "com/android/tools/utp#android-test-plugin-host-apk-installer/31.13.0": { "jar": "sha256-rUpl9T7YQSU7h1s/QjKCbKg+ktL43yx1eUSCTZ+bseI=", "module": "sha256-K7r8U9YtpU0NKQ0nHfirM16kaK9h44klEmHHdopcK84=", "pom": "sha256-asQz5n/da4RmEBi7ZgPC1BtL2mPhMLZmMA/kKJWWNeI=" }, "com/android/tools/utp#android-test-plugin-host-coverage-proto/31.13.0": { "jar": "sha256-+oZxmj3F3kZffgwCMYRBTCf4/VOjT9VXKJwL9t80AkQ=", "pom": "sha256-/YjLzACtSK0gBzGwZURJFIv3K+zmbpEr42Mb3Z2ynJs=" }, "com/android/tools/utp#android-test-plugin-host-coverage/31.13.0": { "jar": "sha256-bvZB5hKpHc9cEe7sfhg65ha8zxjblo+Fk8MsIVukXms=", "module": "sha256-fHcineMtmZ0rsTTClZACwW3mfExdnKvLdFbR3eTpEzc=", "pom": "sha256-uioklU04iLQA6pnfMn8yJJvTIq49Lf7aoHuY0/AXRnE=" }, "com/android/tools/utp#android-test-plugin-host-device-info-proto/31.13.0": { "jar": "sha256-loOsdkinpBvpoTSfaYFZKUT2JxZImMPIkloL7t6LuLs=", "pom": "sha256-4H9sj1RvKhxyZKnhPVjiju5TcZf5fxGPNklrX4TVnzY=" }, "com/android/tools/utp#android-test-plugin-host-device-info/31.13.0": { "jar": "sha256-u/GWphfV9QHyaWv+q77ktoe06AnY1W2cdGEAlPigB8s=", "module": "sha256-frN3cItvF7vW//PvvYsjqai/0qSybLvQaJxfnn5WSNg=", "pom": "sha256-oYrnne8ilug3u8jVGbfF9HFjQzq5tMBv65WwSkdImVw=" }, "com/android/tools/utp#android-test-plugin-host-emulator-control-proto/31.13.0": { "jar": "sha256-pPNKrg+f+gJtv3FRQ23XrlO+y3JiK0DyxHnKyJQ9kxk=", "pom": "sha256-UUl9OSCqen7ucxJgKFO3R3HVf0TW/Aw/GK8PaGhhjgA=" }, "com/android/tools/utp#android-test-plugin-host-emulator-control/31.13.0": { "jar": "sha256-Q/WnO1WXWMHToRknehCepVKj5PdTaIQvt5r3MIrSJGU=", "module": "sha256-xBsZWgRbA1URISzhX84K07eon0UxDaKwbU61B76nav0=", "pom": "sha256-iqjmoJhmpJvjghgdbL/Es8FdlutICa+ZpVup4YHFPIg=" }, "com/android/tools/utp#android-test-plugin-host-logcat-proto/31.13.0": { "jar": "sha256-wfbrus2tVZtu/k6qKVYVUrMxVjlfBpzZcD/aCcRi3qY=", "pom": "sha256-LiA8at4apdhwhssgjC1rORk9eNgf+AyaHhfFkrn2Ueo=" }, "com/android/tools/utp#android-test-plugin-host-logcat/31.13.0": { "jar": "sha256-vnzK0Ke40GJ4K3aQMOF9Vz6W0wIZay+ZhexRy0K2t2s=", "module": "sha256-vm44ITCPBb4gLaOs1zgsdfdenk5j+1vl1YK9wbLBNlc=", "pom": "sha256-L+YfJXBBs6krqdrJF+KJnncFefeLl0DNYrlKhxsKWDo=" }, "com/android/tools/utp#android-test-plugin-result-listener-gradle-proto/31.13.0": { "jar": "sha256-1Cm5MS3/oFAzgdHuGxipmb2QHnRWYSsvtIxqXVosr4g=", "pom": "sha256-QdmxPdex7v2JYLq0S1/QiN7vFueNVr6LlVdc8UVjrgk=" }, "com/android/tools/utp#android-test-plugin-result-listener-gradle/31.13.0": { "jar": "sha256-oblcvtX9S4xqROfbRkLxrMh/W/+VY1DormRLUFApwSA=", "module": "sha256-GSCmFT95J9Ae1A2orIoIKTmf2Q+Z9jbx3l2tP9C/Xac=", "pom": "sha256-Ubn5suXm6+aJKlkxvCsBQLib8sRts4iTYHa/tjn58lE=" }, "com/android/tools/utp#utp-common/31.13.0": { "jar": "sha256-zeZ4pksTBBzdLMna0WhZkKHQkPsE/12iJh/3WoNZgQY=", "pom": "sha256-UnH7zAPgXRzcGx1cGrggJWSjaPmrJSu1rN36fXSIssU=" }, "com/google/android/datatransport#transport-api/3.0.0": { "pom": "sha256-FTe+vUTaLrfjvnP8QlnhEW8qaKUwX0/iPGzqmm+E95E=" }, "com/google/android/datatransport#transport-backend-cct/3.1.8": { "pom": "sha256-QmmGluvVrx6zP5F+WCuqQW4omiHNg+4ynCVYUiFring=" }, "com/google/android/datatransport#transport-runtime/3.1.8": { "pom": "sha256-1v92IlH7NVlK/l7+hgtYcQZOGMC9G9t3CE41c/kOTo8=" }, "com/google/android/datatransport/transport-api/3.0.0/transport-api-3.0.0": { "aar": "sha256-TmmDwHA7NX328cbOrLG138LFAGp4nHmf7CKYsrUzdGY=" }, "com/google/android/datatransport/transport-backend-cct/3.1.8/transport-backend-cct-3.1.8": { "aar": "sha256-4X7dHvf9R1yQuqTjlCIzLycIfTS8tGy0jOhq+aVKYS4=" }, "com/google/android/datatransport/transport-runtime/3.1.8/transport-runtime-3.1.8": { "aar": "sha256-y5NT7xeRrhcJfYeMpxHiWpwyzskEKtxJsAyt/uGnKQs=" }, "com/google/android/gms#play-services-base/18.5.0": { "pom": "sha256-JXC1FcJxevOGyJDpf2RHguP4bae2d6T/EDYUfn6mIqQ=" }, "com/google/android/gms#play-services-basement/18.4.0": { "pom": "sha256-Bcp8Cs4NYmCTH5ftMsYM5ZgHH/Vg0/pE9J5vBpXStoc=" }, "com/google/android/gms#play-services-basement/18.9.0": { "pom": "sha256-YB7wRxZ8bCgLixkJQtnpSgRj6JGs2cbM9TLrKYpe/ls=" }, "com/google/android/gms#play-services-location/21.3.0": { "pom": "sha256-05aVvPApr7ms5nvwTQNFSgYiyVZkE1P7oWBk0f3Ht5E=" }, "com/google/android/gms#play-services-tasks/18.2.0": { "pom": "sha256-a5nEioldFV5Yq87mbMIhRtuDq9XYTK9sj3oq6psbzSE=" }, "com/google/android/gms/play-services-base/18.5.0/play-services-base-18.5.0": { "aar": "sha256-WaXAwtoSMR112WXOH0GUmFNrGhZ/so/338Lf2c76QVc=" }, "com/google/android/gms/play-services-basement/18.9.0/play-services-basement-18.9.0": { "aar": "sha256-xu1yNrGzzgKWtQilwKCRCP1S2DVZ01ytJjlQXNBKslU=" }, "com/google/android/gms/play-services-location/21.3.0/play-services-location-21.3.0": { "aar": "sha256-1QJDhNW/Zsde3zU9leuOe1v2RIMThHTG4uk+LCOU+EI=" }, "com/google/android/gms/play-services-tasks/18.2.0/play-services-tasks-18.2.0": { "aar": "sha256-fyqqj1AgaOr1Q1bKkq7AQnHW58QWxSxFwNI0QPy9FlQ=" }, "com/google/android/material#material/1.13.0": { "module": "sha256-TBPV6U9KtPp8NazFJBGCvyWYWZRYPNTWK1+rvve8J3I=", "pom": "sha256-nB88RgApXMXX9taIauoW1JfxP5r0Z1RCrScefC/r93I=" }, "com/google/android/material/material/1.13.0/material-1.13.0": { "aar": "sha256-bV4cu2fAW83Lv4QAV4fa/TVKFV9ZpNOAD9Rfp+s2ifU=" }, "com/google/firebase#firebase-encoders-json/18.0.0": { "pom": "sha256-On1ZeVp5loOvpkNZMQZsW7Y0rf69KIOgi0dl84zdPqE=" }, "com/google/firebase#firebase-encoders-proto/16.0.0": { "jar": "sha256-KT25ag0dQ/AzFniBtjjY/ehE5OVJX1EBz1IpV2UpXg4=", "pom": "sha256-X1+3SvP/8OH5Yfy18/mSZ8EDsWojyJtVbIy0pxGo3LI=" }, "com/google/firebase#firebase-encoders/17.0.0": { "jar": "sha256-KCpacD+bfrVlCN3pfqkY6V1zMYsVcFD0V/eobcp1AVA=", "pom": "sha256-QjV141AOmRDjqoP516bXVbX3asWRgjuvZ1cPts5+qBY=" }, "com/google/firebase/firebase-encoders-json/18.0.0/firebase-encoders-json-18.0.0": { "aar": "sha256-gK7Ofh71iVfKL8GVe8kgjskqOpUoIBMx08Y+MYJXD5c=" }, "com/google/testing/platform#android-device-provider-local/0.0.9-alpha03": { "jar": "sha256-ZnpNNbu6h9PIb1GA36Uh/b16TvXGDZSRVLAwHz4jLhs=", "pom": "sha256-kTs67pWnFYGkCQOLHMonPOg10/K48qtaYAu+12nqbf0=" }, "com/google/testing/platform#android-driver-instrumentation/0.0.9-alpha03": { "jar": "sha256-UHxjLsfbd7yymbVRnVmxTMYkOqxUF2fGMv2+3cYiawc=", "pom": "sha256-AtXBStCZWXGhCIGQdacXXRAHa9cVJ3LO00QrsZxMG1M=" }, "com/google/testing/platform#android-test-plugin/0.0.9-alpha03": { "jar": "sha256-1st+Em9DMDcZC808O5BLGbqELUaxew/SfDjMTM7L7JA=", "pom": "sha256-qCucEMqqjl0fPNtjhHqbA6yXOKUlswmrcNTRZh7f6iI=" }, "com/google/testing/platform#core-proto/0.0.9-alpha03": { "jar": "sha256-0AHrDMu/yMueqhk6NY5jcSl0Y5d1ZHvpSasjLCsptAc=", "pom": "sha256-O7RSgN8d0clrmgFySmFFZrfWDTNFP81SwsdB+ZmcOk4=" }, "com/google/testing/platform#core/0.0.9-alpha03": { "jar": "sha256-bhgG0BXEFllvU6RaMQDiV0PDE6bj/E9S8k6LJX8sgs4=", "pom": "sha256-nwPalZsyYcweuciIa/iu1vXWC7lbjYY4vtVUQ65iDCo=" }, "com/google/testing/platform#launcher/0.0.9-alpha03": { "jar": "sha256-ABL5gKBZoMTCFtDx0AFoZ6sx64B54/j4efHwK3vjpuc=", "pom": "sha256-GLgPNKXp+hpe7CJQ/XHlRl8utLTQnhgZQOOPG24GRFI=" } }, "https://plugins.gradle.org/m2": { "com/google/android#annotations/4.1.1.4": { "jar": "sha256-unNOHoTAnWFa9qCdMwNLTwRC+Hct7BIO+zdthqVlrhU=", "pom": "sha256-5LtUdTw2onoOXXAVSlA0/t2P6sQoIpUDS/1IPWx6rng=" }, "com/google/api/grpc#proto-google-common-protos/2.48.0": { "jar": "sha256-Q+x4B0WaqkAS6Dihvk7y1ZDPIzMF2mCvW1TwjsjPIwI=", "pom": "sha256-ReuSeyJoX8Eb+rac5q07Q59y2Bajp95GvSP07Rm/icM=" }, "com/google/auto#auto-parent/6": { "pom": "sha256-BfdAxmSBZdsAz2GN1WwgDEcl41jm1U9YU+C+wVc06go=" }, "com/google/auto/value#auto-value-annotations/1.6.2": { "jar": "sha256-tIsE3bpA6KwzvwNvBvxDmV/FCEvZS9qs6AfOJ9O+o/s=", "pom": "sha256-HHbNRi/JbnqpbccM6C8NVAY9bfFts1ycfZzA0amdP/8=" }, "com/google/auto/value#auto-value-parent/1.6.2": { "pom": "sha256-J7ZAyCF59c/2IAnAtyAz2bxg9g6ZAqZoAidLf+N/yBw=" }, "com/google/code/findbugs#jsr305/3.0.2": { "jar": "sha256-dmrSoHg/JoeWLIrXTO7MOKKLn3Ki0IXuQ4t4E+ko0Mc=", "pom": "sha256-GYidvfGyVLJgGl7mRbgUepdGRIgil2hMeYr+XWPXjf4=" }, "com/google/code/gson#gson-parent/2.11.0": { "pom": "sha256-issfO3Km8CaRasBzW62aqwKT1Sftt7NlMn3vE6k2e3o=" }, "com/google/code/gson#gson/2.11.0": { "jar": "sha256-V5KNblpu3rKr03cKj5W6RNzkXzsjt6ncKzCcWBVSp4s=", "pom": "sha256-wOVHvqmYiI5uJcWIapDnYicryItSdTQ90sBd7Wyi42A=" }, "com/google/crypto/tink#tink/1.7.0": { "jar": "sha256-iJcKRWoIukxmsBsj5YRsoQlcwU5Uy0g2Pl0uFaEwcwg=", "pom": "sha256-Ku41I3FfjyzRCyYDyNGeVhrHWDELfiyYU5RtLF57S/c=" }, "com/google/dagger#dagger/2.28.3": { "jar": "sha256-8d0j+K40qOkTZnI5kerQ1kmdGj6RY85VDCALAtdqhys=", "pom": "sha256-JlupWajhPDoGEz8EtTkWnBAY2v/U0z9TxFOrTLOG9XA=" }, "com/google/dagger#hilt-android-gradle-plugin/2.58": { "jar": "sha256-un8zavJRIOQz+iEgvlhCrYoo9C9GOuD3F/Qmj+BVt7M=", "pom": "sha256-/7LMGjbphgcDQMBdM6R4bkDaK++0y2Uu1AYezXr87GY=" }, "com/google/dagger/hilt/android#com.google.dagger.hilt.android.gradle.plugin/2.58": { "pom": "sha256-1l0XcxUOnNPX4mTeg/3GGu5xWjHFyf03otD1iv2s7M8=" }, "com/google/devtools/ksp#com.google.devtools.ksp.gradle.plugin/2.3.6": { "pom": "sha256-uneNdydnXBs9gWV3KRTfuGMfotCgSGdzBklCLGhZ6Yw=" }, "com/google/devtools/ksp#symbol-processing-api/2.3.6": { "jar": "sha256-Vd+DfVSxv9182+U72E3urjdGd+xKcpqa8VTT/uPAAjA=", "module": "sha256-fTwGu1Wcn5r42q4q+DCS2VaXOBQrLpBlS2ut235974k=", "pom": "sha256-YkfnkAfhEo350LIQnh7H657evU7rjleMDkMSlXeSsF0=" }, "com/google/devtools/ksp#symbol-processing-common-deps/2.3.6": { "jar": "sha256-917ObR1KiXj8nP4siVwyMjR926tcCEsE0M6Hax18lgQ=", "module": "sha256-zC2bYeTXyh+cMHAaun4osQZS6/neLCgEr3rzNN57Tt0=", "pom": "sha256-A29s8I+g0esgo/m+G33D1fOag42dbj/BJRrAxqRe07E=" }, "com/google/devtools/ksp#symbol-processing-gradle-plugin/2.3.6": { "jar": "sha256-FWx8hcLxO4TZ8fwDth/WWSN+vlncEtFK5Mb06z3ba0U=", "module": "sha256-ew4oNjkHCPpx6DknXHfN9wxhPAqkHzWW/RTsFYVmTMg=", "pom": "sha256-O71WDZRv1SD4LMzgZcSy6xTvSVHZ0Yx1LVGLsE4Zq0E=" }, "com/google/errorprone#error_prone_annotations/2.18.0": { "pom": "sha256-kgE1eX3MpZF7WlwBdkKljTQKTNG80S9W+JKlZjvXvdw=" }, "com/google/errorprone#error_prone_annotations/2.27.0": { "jar": "sha256-JMkjNyxY410LnxagKJKbua7cd1IYZ8J08r0HNd9bofU=", "pom": "sha256-TKWjXWEjXhZUmsNG0eNFUc3w/ifoSqV+A8vrJV6k5do=" }, "com/google/errorprone#error_prone_annotations/2.3.1": { "pom": "sha256-PtzmtxG6No7+Frm3qssCFPvWSEFMublllTouftiagZo=" }, "com/google/errorprone#error_prone_annotations/2.30.0": { "jar": "sha256-FE86771uJ9rsVdN1OyxrE8Gv2vDPBIFs21ZFiO2S8b0=", "pom": "sha256-9xOEnCOzSVPoVFZdzoqnlcrgwUFmEbcgwhRhMix5X4Y=" }, "com/google/errorprone#error_prone_parent/2.18.0": { "pom": "sha256-R/Iumce/RmOR3vFvg3eYXl07pvW7z2WFNkSAVRPhX60=" }, "com/google/errorprone#error_prone_parent/2.27.0": { "pom": "sha256-+oGCnQSVWd9pJ/nJpv1rvQn4tQ5tRzaucsgwC2w9dlQ=" }, "com/google/errorprone#error_prone_parent/2.3.1": { "pom": "sha256-dnUl2agRKc0IGWg4KYAzYye+QWKx4iUaGCkR2qczwSM=" }, "com/google/errorprone#error_prone_parent/2.30.0": { "pom": "sha256-Xog0zMDl7Qxy8wbCULUY5q0Q0HWpt7kQz2lcuh7gKi0=" }, "com/google/flatbuffers#flatbuffers-java/1.12.0": { "jar": "sha256-P4wIi03QSphYch8uFiUIyU2w3Yb5YeMG7mPvLtqHG/c=", "pom": "sha256-yyJrr1RiYHcPIegVKmqoi6FSMNc591DfSA8qZo1D4Os=" }, "com/google/guava#failureaccess/1.0.2": { "jar": "sha256-io+Bz5s1nj9t+mkaHndphcBh7y8iPJssgHU+G0WOgGQ=", "pom": "sha256-GevG9L207bs9B7bumU+Ea1TvKVWCqbVjRxn/qfMdA7I=" }, "com/google/guava#guava-parent/26.0-android": { "pom": "sha256-+GmKtGypls6InBr8jKTyXrisawNNyJjUWDdCNgAWzAQ=" }, "com/google/guava#guava-parent/33.3.1-jre": { "pom": "sha256-VUQdsn6Iad/v4FMFm99Hi9x+lVhWQr85HwAjNF/VYoc=" }, "com/google/guava#guava/33.3.1-jre": { "jar": "sha256-S/Dixa+ORSXJbo/eF6T3MH+X+EePEcTI41oOMpiuTpA=", "module": "sha256-QYWMhHU/2WprfFESL8zvOVWMkcwIJk4IUGvPIODmNzM=", "pom": "sha256-MTtn/BPrOwY07acVoSKZcfXem4GIvCgHYoFbg6J18ZM=" }, "com/google/guava#listenablefuture/9999.0-empty-to-avoid-conflict-with-guava": { "jar": "sha256-s3KgN9QjCqV/vv/e8w/WEj+cDC24XQrO0AyRuXTzP5k=", "pom": "sha256-GNSx2yYVPU5VB5zh92ux/gXNuGLvmVSojLzE/zi4Z5s=" }, "com/google/j2objc#j2objc-annotations/2.8": { "pom": "sha256-N/h3mLGDhRE8kYv6nhJ2/lBzXvj6hJtYAMUZ1U2/Efg=" }, "com/google/j2objc#j2objc-annotations/3.0.0": { "jar": "sha256-iCQVc0Z93KRP/U10qgTCu/0Rv3wX4MNCyUyd56cKfGQ=", "pom": "sha256-I7PQOeForYndEUaY5t1744P0osV3uId9gsc6ZRXnShc=" }, "com/google/jimfs#jimfs-parent/1.1": { "pom": "sha256-xxVVdR5X4O+RKHDorJYlrnglAqalucGcz4OyqX2LJr0=" }, "com/google/jimfs#jimfs/1.1": { "jar": "sha256-xIKOKNfAqTCvk4dRCzutp9qlwE18Jadce4sIHxwlfd0=", "pom": "sha256-76huXNki8XtHL9/K5XI02NSsPhSLYlBzffzkVK96ekQ=" }, "com/google/protobuf#protobuf-bom/3.25.5": { "pom": "sha256-CA4phBcyOLUOBkwiav/7sbAjNSApXHkKf9PWrkWT8GM=" }, "com/google/protobuf#protobuf-java-util/3.25.5": { "jar": "sha256-2sxYssPS+o1L3cGsuIHnjWz3wTfdeLwdZ/aspzJDao0=", "pom": "sha256-oJ0ZDqpqeWFrxfS1QE6UsMq1WYA6mMigkMQJmWL0H5I=" }, "com/google/protobuf#protobuf-java/3.25.5": { "jar": "sha256-hUAkf62eBrrvqPtF6zE4AtAZ9IXxQwDg+da1Vu2I51M=", "pom": "sha256-51IDIVeno5vpvjeGaEB1RSpGzVhrKGWr0z5wdWikyK8=" }, "com/google/protobuf#protobuf-parent/3.25.5": { "pom": "sha256-ZMwOOtboX1rsj53Pk0HRN56VJTZP9T4j4W2NWCRnPvc=" }, "com/googlecode/juniversalchardet#juniversalchardet/1.0.3": { "jar": "sha256-dXv+kGGTuLZR553CbNZ9a1XQdwos37A4FZFQT3edSnY=", "pom": "sha256-eEY5mzXHzWQqmzoADD4tYtBOs3pFR7aTPMixi8wvCGs=" }, "com/ncorti/ktfmt/gradle#com.ncorti.ktfmt.gradle.gradle.plugin/0.25.0": { "pom": "sha256-6UMcdrGCa/TUV0mE/Fo7fkZWpd3WrYQOxFz7Z6ACDio=" }, "com/ncorti/ktfmt/gradle#plugin/0.25.0": { "jar": "sha256-d5ySST49X3rhJ5Z5FhkZIoNpWYag5pYnnpVO4OisEkc=", "module": "sha256-OrfvXBctMH3cZnXSk5zSB6eqDCcj2Nm0RfqqtBeFqAM=", "pom": "sha256-hor4T+juXrqdQgGQmDCpnMpnuO0LohugEh/4K5mRjF0=" }, "com/squareup#javapoet/1.10.0": { "pom": "sha256-FpA0CiIiefLLrfNz6Igm+iD388w+wCUvNoGP7TJwGrE=" }, "com/squareup#javapoet/1.13.0": { "jar": "sha256-THUX6EinGzbQadErs79Gpw/UzaMQXYIrDtLhnAC2kpE=", "pom": "sha256-VKNPqFAqRryQ79tJJiYAWR+oC/mjT1pMeYMRrsFsqXc=" }, "com/squareup#javawriter/2.5.0": { "jar": "sha256-/PsJ+w6gqpfTz+fqeSOYCBNI5GjxJrNgPLOAPyQBl/A=", "pom": "sha256-4avX8RFs9eDFmUdpPiGJII7JQpayozlMlZ41EdOZp7A=" }, "com/sun/activation#all/1.2.0": { "pom": "sha256-HYUY46x1MqEE5Pe+d97zfJguUwcjxr2z1ncIzOKwwsQ=" }, "com/sun/activation#all/1.2.1": { "pom": "sha256-NgiDv2RIbs7xYbjygvZQNTbdGmcNU6Coccj7IBcOZ5U=" }, "com/sun/activation#javax.activation/1.2.0": { "jar": "sha256-mTMCsWzXBW8h53nMV30XWoELtJAO9zzY+/K1D5KLqc4=", "pom": "sha256-+Hm26UWFTGkAsNvuHIOE16s95+FX/XrISTdAXEFtKl4=" }, "com/sun/istack#istack-commons-runtime/3.0.8": { "jar": "sha256-T/q7Br5FSgXkOY4gx3+itjCNS4jfvvfKMKdrW31VBe8=", "pom": "sha256-wuAU00y4TtKH0GSYbEXDBaQSQiinM37M9sQh0U1wjxw=" }, "com/sun/istack#istack-commons/3.0.8": { "pom": "sha256-oPBRfoUS8PvMe4KVwS9lZqPQwthtZVY53GYu+MDH6+U=" }, "com/sun/xml/bind#jaxb-bom-ext/2.3.2": { "pom": "sha256-Gn3sKyfn4FV0TNuM8bkN70/Uc6zRuATv8JgTk1iVm9c=" }, "com/sun/xml/bind/mvn#jaxb-parent/2.3.2": { "pom": "sha256-IN1tw0q3VJrEDaHYLpIiLsQ0etDsDLEY72xXA77VOhg=" }, "com/sun/xml/bind/mvn#jaxb-runtime-parent/2.3.2": { "pom": "sha256-sk+NUfGEpovBuG1IwOPP7+shpE7eHF9zA8WK4EiFM+w=" }, "com/sun/xml/bind/mvn#jaxb-txw-parent/2.3.2": { "pom": "sha256-tV0++psVj0g6MOkseMy2APkzFHM9CJ66m3RDbwGzFKQ=" }, "com/sun/xml/fastinfoset#FastInfoset/1.2.16": { "jar": "sha256-BW86HhRECfIe0Wr8JoBfWOmiHz/OFUPELUAHGdJQxRE=", "pom": "sha256-4UfSWKtuZpH3BZmpUkAObmx1WPjJwCjb4b4jF4MI6DA=" }, "com/sun/xml/fastinfoset#fastinfoset-project/1.2.16": { "pom": "sha256-kFgkJa3B9AtBNi2vuVFzkxIlrKpeeWINXmvVL2Rikro=" }, "commons-codec#commons-codec/1.11": { "jar": "sha256-5ZnVMY6Xqkj0ITaikn5t+k6Igd/w5sjjEJ3bv/Ude30=", "pom": "sha256-wecUDR3qj981KLwePFRErAtUEpcxH0X5gGwhPsPumhA=" }, "commons-io#commons-io/2.16.1": { "jar": "sha256-9B97qs1xaJZEes6XWGIfYsHGsKkdiazuSI2ib8R3yE8=", "pom": "sha256-V3fSkiUceJXASkxXAVaD7Ds1OhJIbJs+cXjpsLPDj/8=" }, "commons-logging#commons-logging/1.2": { "jar": "sha256-2t3qHqC+D1aXirMAa4rJKDSv7vvZt+TmMW/KV98PpjY=", "pom": "sha256-yRq1qlcNhvb9B8wVjsa8LFAIBAKXLukXn+JBAHOfuyA=" }, "io/github/java-diff-utils#java-diff-utils-parent/4.16": { "pom": "sha256-pDRNpY5TqOjFe+Gekjut8ksn3UKwfebURtKmWaRWUJo=" }, "io/github/java-diff-utils#java-diff-utils/4.16": { "jar": "sha256-YgQDAw1nakon94CjrOx0ON7hsWUaHIBPprsRuwc5mm8=", "pom": "sha256-Q01Zih4YExEThSNRYWJ6TE69YhNPcFRCjT4gKzLPOd4=" }, "io/gitlab/arturbosch/detekt#detekt-gradle-plugin/1.23.8": { "jar": "sha256-eDFQ5HCJQsXXcF/4t7kPn7WtAXnUatapsB0ZK3A3l1A=", "module": "sha256-u3H5tXAGmJxcA5CbcCQuJeKEWLFVUiHjinxHrZdrLFQ=", "pom": "sha256-DR7QsszZIm6wAtilqpiAsjRzYmLNf1kdg+g2pG2FiWI=" }, "io/gitlab/arturbosch/detekt#io.gitlab.arturbosch.detekt.gradle.plugin/1.23.8": { "pom": "sha256-a3qo6lJGZ5UjlEDJiB+ih1ysttNuzI7uu1kDVKYQoGc=" }, "io/grpc#grpc-api/1.69.1": { "jar": "sha256-qNPW3McfOrYT1miEIoK0iL3ZPT6ZoO9dyn7ub6c0woM=", "pom": "sha256-vq8uR11cRdBjTU0yS/hNsqjWqSkilx5vfcJ+hRxCkH8=" }, "io/grpc#grpc-context/1.69.1": { "jar": "sha256-Re+VuMFYqLW906y2e55oLvJUFLsUj0iOyEdDirZHFdQ=", "pom": "sha256-beKbzqslob0L4R7qhGjQ4/HAxHbQpTMhW0/eHIKEtXA=" }, "io/grpc#grpc-core/1.69.1": { "jar": "sha256-UTUsra7L+aSkqkLZP28fxyjx/QGwUWgDg+0J5WMf+9A=", "pom": "sha256-loCY9KhFG7zT17iMaoq1t6dO+A397npgUvNS//eDssU=" }, "io/grpc#grpc-inprocess/1.69.1": { "jar": "sha256-t8asDjq/S41YJhDWMteUF7w9qBJU4aS89/Aejbe9Ve8=", "pom": "sha256-/rswpc8jgjfQdaOSPok5khg9rxcaHrQ0rPEyEUpff4o=" }, "io/grpc#grpc-netty/1.69.1": { "jar": "sha256-Uqhu1m94kz6D0aP7cWKtFmdIlWTEVWNmt6NXnHAkpEc=", "pom": "sha256-jwZlDMkUKMxRMRG/676r95mUGF1yREsC5d+so4vkNxQ=" }, "io/grpc#grpc-protobuf-lite/1.69.1": { "jar": "sha256-wp+Q+t88diD5NZokPAZ92FtzvXZbKPPZXfkQrC0zFVU=", "pom": "sha256-Jj8fhE11bBXbX6uIaZZeM3IrP0/pB/4ciFqQ4EZGFzE=" }, "io/grpc#grpc-protobuf/1.69.1": { "jar": "sha256-TFLvlI+4mHo7qn1GujYre/MH3TxR8pJBzVxZg5igEN8=", "pom": "sha256-v0ynbSrORNlpRxT7zz/XKcmO8uWGOrkgpIPAUqIvRCM=" }, "io/grpc#grpc-stub/1.69.1": { "jar": "sha256-45xjJz1TBS6+n2ONiumBdnNexWcyjZoXCSzdtvI5uMI=", "pom": "sha256-9a2BV+xA2KI0djKWXWoD0YpIrUYl8y62SzenpHDOU7s=" }, "io/grpc#grpc-util/1.69.1": { "jar": "sha256-3Vl71nXqoELz41eGSNkFDIE8RZXF3mhp753btEkAYDE=", "pom": "sha256-0g00aMt01WvlXtPUb2PKOO5LygkY2arXJ3pEj24HpbQ=" }, "io/netty#netty-buffer/4.1.110.Final": { "jar": "sha256-RtdOeRJarMBVwx8YFS/cXUpWmqjWAJEgPQuqgzlzrDw=", "pom": "sha256-cQrBnMAc2A32vpo/qtPCIrShoy9LVRN74HtgmdXaNWI=" }, "io/netty#netty-codec-http/4.1.110.Final": { "jar": "sha256-3A1q9QVGMKcP8O81TyCqem5Gc4yfxWNu09T+d+OL1I0=", "pom": "sha256-Ua6ZCvFKMh2209aIS5F7fUNj62Dd3A8Uk7GAIaFC560=" }, "io/netty#netty-codec-http2/4.1.110.Final": { "jar": "sha256-tUbHVEWkh7t7zVqUd5yuzOM1gs974xuLOfwOZbHuJvw=", "pom": "sha256-KdL2wmw8yp/oOTZxcH/o75w+MQIKLf4GuCxCZJnCWDc=" }, "io/netty#netty-codec-socks/4.1.110.Final": { "jar": "sha256-l2BSo8m7KAvG2Z86KeZARnfPlYw94FsgUJPTjABriAw=", "pom": "sha256-/+V7MWGR3U+WvuZsVwnBPL207KsIXAEMjbDGqoCav2w=" }, "io/netty#netty-codec/4.1.110.Final": { "jar": "sha256-nszOmo2Ce7jOhPnDGD/sWL0clqUQEM9xEpd0YDSvNwE=", "pom": "sha256-qAa7U2uzI2Zbr/fNEiPysnKi1HF6tPmxI2EIbarl3z4=" }, "io/netty#netty-common/4.1.110.Final": { "jar": "sha256-mFHsZlSLng1BFkzpiUPN1LvjBfaN29JOrlLkUBoNexo=", "pom": "sha256-fUF/UzUwTa4eoIoGWGA4yD/orYTB01uqFe0RkhzveSA=" }, "io/netty#netty-handler-proxy/4.1.110.Final": { "jar": "sha256-rVSrT+nEfvPnI9cSURJttT6NtUOHGtuer8lERlOe/1I=", "pom": "sha256-xhPLTn4G9C76MduNiyoznti/QfAMRtONCQmkwGxlbc8=" }, "io/netty#netty-handler/4.1.110.Final": { "jar": "sha256-1aCNfeNkkS5ChZaN5NTM4/AdpLsEjVxpN+Xyrx+OFIo=", "pom": "sha256-TUPBPRT1Y1oviw1QlNejHFCe4PUsck66DvMM/+PqFVU=" }, "io/netty#netty-parent/4.1.110.Final": { "pom": "sha256-aFra83Nmb8FUJ8gQ+K/zpP4ZSpfH7XS2nQfFSPDULxw=" }, "io/netty#netty-resolver/4.1.110.Final": { "jar": "sha256-oum0rnyqkvxb10fhHR3sINgbGPwAlZVUMCJErFxWznA=", "pom": "sha256-ZV80GS6MdhizxaeeSI5NqjXe9BsNFtRfo2Ujw7TJ9kE=" }, "io/netty#netty-transport-native-unix-common/4.1.110.Final": { "jar": "sha256-UXF7t0cRQZUDkMZxOkSf2xBU0H5gc37n3acIN5bN7kg=", "pom": "sha256-6hjOBMmpesDFH045exhSKf2VmX6QsRM5rc98UZRtU9g=" }, "io/netty#netty-transport/4.1.110.Final": { "jar": "sha256-pC3Wg5DKFLT/LUBiiglsdkhbStt8GWAtUokyGgZp5wQ=", "pom": "sha256-MPXaDnZG8YQNYy+IYVyLnYIFSZ1oVZucRUezsEoGg80=" }, "io/perfmark#perfmark-api/0.27.0": { "jar": "sha256-x7R4UD7FJOVd8ZtCTUbSfIporrgBZk+t1PBptx9S0PY=", "module": "sha256-n2xOamK43v0UFzrNt9spPQhjU7Ikkj7vYpP1gWGJPMo=", "pom": "sha256-IsF1wsGCNmdjDITnMiV2f1lwSS2ObL/7gaZXXbpHLSY=" }, "jakarta/activation#jakarta.activation-api/1.2.1": { "jar": "sha256-iwoPUvqLBcVDGSGgY+2GbvqkHa3y46fuPhlh8rDZZFs=", "pom": "sha256-QlhcsH3afyOqBOteCUAGGUSiRqZ609FpQvvlaf8DzTE=" }, "jakarta/xml/bind#jakarta.xml.bind-api-parent/2.3.2": { "pom": "sha256-FaVbfVN8n5lwrq0o0q+XwFn2X/YQL3a70p8SR92Kbfs=" }, "jakarta/xml/bind#jakarta.xml.bind-api/2.3.2": { "jar": "sha256-aRVjBAeb3u2fwK47OTifGbPMS6REO8gFCJlTlOrXQuo=", "pom": "sha256-tTeziNurTMBpC50vsMdBJNZyUxc0VnrPblMTDqsTGtY=" }, "javax/annotation#javax.annotation-api/1.3.2": { "jar": "sha256-4EulGVvNVV3JVlD3zGFNFR5LzVLSmhC4qiGX86uJq5s=", "pom": "sha256-RqSiUcpAbnjkhT16K66DKChEpJkoUUOe6aHyNxbwa5c=" }, "javax/inject#javax.inject/1": { "jar": "sha256-kcdwRKUMSBY2wy2Rb9ickRinIZU5BFLIEGUID5V95/8=", "pom": "sha256-lD4SsQBieARjj6KFgFoKt4imgCZlMeZQkh6/5GIai/o=" }, "net/java#jvnet-parent/1": { "pom": "sha256-KBRAgRJo5l2eJms8yJgpfiFOBPCXQNA4bO60qJI9Y78=" }, "net/java#jvnet-parent/3": { "pom": "sha256-MPV4nvo53b+WCVqto/wSYMRWH68vcUaGcXyy3FBJR1o=" }, "net/java/dev/jna#jna-platform/5.6.0": { "jar": "sha256-ns6ovysbOZY5OdGLcEZO72DFCP7Ygg+dyroMNVGOq/c=", "pom": "sha256-G+s1y0GE5skGp+Murr2FLdPaCiY5YumRNKuUWDI5Tig=" }, "net/java/dev/jna#jna/5.6.0": { "jar": "sha256-VVfiNaiqL5dm1dxgnWeUjyqIMsLXls6p7x1svgs7fq8=", "pom": "sha256-X+gbAlWXjyRhbTexBgi3lJil8wc+HZsgONhzaoMfJgg=" }, "net/sf/jopt-simple#jopt-simple/4.9": { "jar": "sha256-JsWFbpVLX4ZNt28TuGkZtZxu7Pn9kwuWuqiIRia68vU=", "pom": "sha256-evfi2LJLR5jwTCt9okyfvRt1V7TgF8IFRIFWWRYHkJI=" }, "net/sf/kxml#kxml2/2.3.0": { "jar": "sha256-8mTdn3mh/eEM5ezFMiHv8kvkyTMcgwt9UvLwintjPeI=", "pom": "sha256-Mc5gb06VGJNimbsNJ8l4+mHhhf0d58mHT+lZpT40poU=" }, "org/apache#apache/13": { "pom": "sha256-/1E9sDYf1BI3vvR4SWi8FarkeNTsCpSW+BEHLMrzhB0=" }, "org/apache#apache/18": { "pom": "sha256-eDEwcoX9R1u8NrIK4454gvEcMVOx1ZMPhS1E7ajzPBc=" }, "org/apache#apache/21": { "pom": "sha256-rxDBCNoBTxfK+se1KytLWjocGCZfoq+XoyXZFDU3s4A=" }, "org/apache#apache/23": { "pom": "sha256-vBBiTgYj82V3+sVjnKKTbTJA7RUvttjVM6tNJwVDSRw=" }, "org/apache#apache/31": { "pom": "sha256-VV0MnqppwEKv+SSSe5OB6PgXQTbTVe6tRFIkRS5ikcw=" }, "org/apache/commons#commons-compress/1.21": { "jar": "sha256-auz9VFlyillWAc+gcljRMZcv/Dm0kutIvdWWV3ovJEo=", "pom": "sha256-Z1uwI8m+7d4yMpSZebl0Kl/qlGKApVobRi1Mp4AQiM0=" }, "org/apache/commons#commons-parent/34": { "pom": "sha256-Oi5p0G1kHR87KTEm3J4uTqZWO/jDbIfgq2+kKS0Et5w=" }, "org/apache/commons#commons-parent/42": { "pom": "sha256-zTE0lMZwtIPsJWlyrxaYszDlmPgHACNU63ZUefYEsJw=" }, "org/apache/commons#commons-parent/52": { "pom": "sha256-ddvo806Y5MP/QtquSi+etMvNO18QR9VEYKzpBtu0UC4=" }, "org/apache/commons#commons-parent/69": { "pom": "sha256-1Q2pw5vcqCPWGNG0oDtz8ZZJf8uGFv0NpyfIYjWSqbs=" }, "org/apache/httpcomponents#httpclient/4.5.14": { "jar": "sha256-yLx+HFGm1M5y9A0uu6vxxLaL/nbnMhBLBDgbSTR46dY=", "pom": "sha256-8YNVr0z4CopO8E69dCpH6Qp+rwgMclsgldvE/F2977c=" }, "org/apache/httpcomponents#httpcomponents-client/4.5.14": { "pom": "sha256-W60d5PEBRHZZ+J0ImGjMutZKaMxQPS1lQQtR9pBKoGE=" }, "org/apache/httpcomponents#httpcomponents-client/4.5.6": { "pom": "sha256-sEK0HyOR7bANNff05Qmu0hI2SMHSRs5Y0Pe5Bcn+H3M=" }, "org/apache/httpcomponents#httpcomponents-core/4.4.16": { "pom": "sha256-8tdaLC1COtGFOb8hZW1W+IpAkZRKZi/K8VnVrig9t/c=" }, "org/apache/httpcomponents#httpcomponents-parent/10": { "pom": "sha256-yq+WfZSvshdT82CCxghiBr0fSIJf9ZaTLM66crZdOfo=" }, "org/apache/httpcomponents#httpcomponents-parent/11": { "pom": "sha256-qQH4exFcVQcMfuQ+//Y+IOewLTCvJEOuKSvx9OUy06o=" }, "org/apache/httpcomponents#httpcore/4.4.16": { "jar": "sha256-bJs90UKgncRo4jrTmq1vdaDyuFElEERp8CblKkdORk8=", "pom": "sha256-PLrYSbNdrP5s7DGtraLGI8AmwyYRQbDSbux+OZxs1/o=" }, "org/apache/httpcomponents#httpmime/4.5.6": { "jar": "sha256-CysRAsGNPH4Fp3IUubdQGm9gVhdK5WBODiVndu2nVT4=", "pom": "sha256-37/W/+KnhMqYF8RjZap/ileDILgFveOdb1WgsJ2KqMo=" }, "org/bitbucket/b_c#jose4j/0.9.5": { "jar": "sha256-gI+zFm8+Z9rZgRwzECmrFoEkL9Urc1vD8z8oEWf8xy4=", "pom": "sha256-utAkGAobRpy9lOXy2xKEG8rFRD2VRWB/Zzz95nfB2HI=" }, "org/bouncycastle#bcpkix-jdk18on/1.79": { "jar": "sha256-NjmiTd+bpLfroGWbRHcOkeuoFkIYiOVx8oWq3v5TLNY=", "pom": "sha256-NeSfQTTeKsMmw6UKJXYsu021bzgC+j9zDMhbZTrQmHs=" }, "org/bouncycastle#bcprov-jdk18on/1.79": { "jar": "sha256-DYHswxJFNrU5vOmqP+liG3+Eyc7jcbY1pbMceLeasdo=", "pom": "sha256-2PGgaxSddG6dmN5U4veqmy62E/s1ymfYrjls6qxmHuQ=" }, "org/bouncycastle#bcutil-jdk18on/1.79": { "jar": "sha256-xwuIraWJOMvC8AXUAykFQHi8+hFJ5v/APpJC62qyGDY=", "pom": "sha256-4kwftM8WBUBaaYjp5NbksuH0OT/HOompRSrmJe4xHQI=" }, "org/checkerframework#checker-qual/2.5.8": { "pom": "sha256-M6xqDxNBrpZkfH1EZfSqPST+l9Jpe87izq5vyLXvLDw=" }, "org/checkerframework#checker-qual/3.43.0": { "jar": "sha256-P7wumPBYVMPfFt+auqlVuRsVs+ysM2IyCO1kJGQO8PY=", "module": "sha256-+BYzJyRauGJVMpSMcqkwVIzZfzTWw/6GD6auxaNNebQ=", "pom": "sha256-kxO/U7Pv2KrKJm7qi5bjB5drZcCxZRDMbwIxn7rr7UM=" }, "org/codehaus/mojo#animal-sniffer-annotations/1.24": { "jar": "sha256-xyDm5by+ay9I3tdaR7zNt2Pu3nnRQzAQLg01Lj2J7ZI=", "pom": "sha256-iEhPYKatQjipf+us8rMz6eCMF4uPGAoFo+2/9KOKg24=" }, "org/codehaus/mojo#animal-sniffer-parent/1.24": { "pom": "sha256-Sd2rQ8g2HcLvDB/4fLWQ+nIxcCq59i4m1RLcGKHxzQQ=" }, "org/codehaus/mojo#mojo-parent/84": { "pom": "sha256-L+UQYYsvYPzV8vuCvEssLDRASNdPML5xn8uGgp7orDA=" }, "org/eclipse/ee4j#project/1.0.2": { "pom": "sha256-dJWgenl+iOQ8O8GodCG9ix/FXjIpH6GOTjLYAx3chz8=" }, "org/eclipse/ee4j#project/1.0.5": { "pom": "sha256-kWtHlNjYIgpZo/32pk2+eUrrIzleiIuBrjaptaLFkaY=" }, "org/glassfish/jaxb#jaxb-bom/2.3.2": { "pom": "sha256-oQGLtUZ47Z9ayy96QITjhf9RAgH06dv1913GpnX2a+c=" }, "org/glassfish/jaxb#jaxb-runtime/2.3.2": { "jar": "sha256-5uCh6J+2/3hieeagCC1c71LcLr5nBT0EGABzdlK0/Rs=", "pom": "sha256-lEilrX+mimCD375PQsjIPggrkgKhBUAfxo6UTCZUizQ=" }, "org/glassfish/jaxb#txw2/2.3.2": { "jar": "sha256-SmqfSDOI1GG4GqmijGhbi3TAWXmTvxiEsE7dvKlfSP4=", "pom": "sha256-p53QAvsDgYP/KGomNb4uaMEDuH4OZHF9jUS/0Bf9M+o=" }, "org/gradle/toolchains#foojay-resolver/1.0.0": { "jar": "sha256-eLhqR9/fdpfJvRXaeJg/2A2nJH1uAvwQa98H4DiLYKg=", "module": "sha256-YZDPDkLmZMEeGsCnhWmasCtUnOo0OSxnnzbYosVQ/Lk=", "pom": "sha256-m8SLSeQi2e2rw5asGNiwQd/CIhLX+ujjVmfShdSBApo=" }, "org/gradle/toolchains/foojay-resolver-convention#org.gradle.toolchains.foojay-resolver-convention.gradle.plugin/1.0.0": { "pom": "sha256-8TMkmhh1Suah0nAdANhJsa+6ewaD3bX8GxinAHHOwvo=" }, "org/jdom#jdom2/2.0.6": { "jar": "sha256-E0XxG6YG0VYD1nQFUajCGUfAIVZAdw7GcnH+eL6pfPU=", "pom": "sha256-R7I6ef4za3QbgkNMbgSdaBZSVuQF51wQkh/XL6imXY0=" }, "org/jetbrains#annotations/13.0": { "jar": "sha256-rOKhDcji1f00kl7KwD5JiLLA+FFlDJS4zvSbob0RFHg=", "pom": "sha256-llrrK+3/NpgZvd4b96CzuJuCR91pyIuGN112Fju4w5c=" }, "org/jetbrains/kotlin#abi-tools-api/2.3.10": { "jar": "sha256-E2nLVCrmR6nVVJ5thkkh6g+GApdJWRmXteWqFhyXGIs=", "pom": "sha256-x12MiniT5DijbBZeA1I+uHRDZ6wNaV4sdrZ48LAjnE8=" }, "org/jetbrains/kotlin#fus-statistics-gradle-plugin/2.3.10": { "module": "sha256-S6VmEYmH5t40LreG9cBlq/KOD1GxChn5urHQnQ8BgAg=", "pom": "sha256-Lq0FKRNzQaRlNuKfJLUrjci875dxQRBZvIeXikqlcFI=" }, "org/jetbrains/kotlin#fus-statistics-gradle-plugin/2.3.10/gradle813": { "jar": "sha256-aR4tKmjLngoIjxlX3nz7pWjiSRh2mUhhI9YHX9/znRc=" }, "org/jetbrains/kotlin#kotlin-bom/2.2.0": { "pom": "sha256-RT+GUn7TuIQqnwFECFD5Mlo6iGzXNr73MFp1NSt52h0=" }, "org/jetbrains/kotlin#kotlin-build-statistics/2.3.10": { "jar": "sha256-rATm9NehsNONNMb//SM90SpmzikVjyX68Ax17KzUQ7w=", "pom": "sha256-D412s88lmTbUu/J7bZOrZtxor4wBaf4fXhMuQY9Ano0=" }, "org/jetbrains/kotlin#kotlin-build-tools-api/2.3.10": { "jar": "sha256-EYZyC5EGhN+drZy5YBXM8ZF27FZVzrCbAfvEUjC9H2Q=", "pom": "sha256-3pBa7tBWHZduxSEU8vPk5mls05Mg9DqxFvF/79c9U8I=" }, "org/jetbrains/kotlin#kotlin-compiler-runner/2.3.10": { "jar": "sha256-CpGS+4AlHMrRzb8sq6KEiOjUaGnS5eSVfKF5wnwKTuA=", "pom": "sha256-4CtYvQDZCjExN9L18ZIZ2tt3FXVebd1t74mYpa/RgCc=" }, "org/jetbrains/kotlin#kotlin-daemon-client/2.3.10": { "jar": "sha256-0hpvd9mAOmFebRUIVzd+EKnox80Grm4cHrmZIP2ji5M=", "pom": "sha256-sOpstyMer+j7oN+zf8MZhJkED3TE00EmPtq2yE0Z2sA=" }, "org/jetbrains/kotlin#kotlin-gradle-plugin-annotations/2.3.10": { "jar": "sha256-yz8A2nIhxzuf45W3HFxXnuMmJgxXOjjCGd6t7vUkdks=", "pom": "sha256-W4tTzgpFIWup6yZRWQfmdFi+cp+OqvXBlS9ssuCfcac=" }, "org/jetbrains/kotlin#kotlin-gradle-plugin-api/2.3.10": { "module": "sha256-p7RKyP2FrLVZaQdkrIl8Haz7eUlefoXmY6IMRhECXa4=", "pom": "sha256-qty9XFeR9sVMi0IgpGAvxFBOjrpsjs+kumG0AkRuutI=" }, "org/jetbrains/kotlin#kotlin-gradle-plugin-api/2.3.10/gradle813": { "jar": "sha256-KSEBw7xFdm5gKUIbOJkyJEbM79WrzQJ4hj9SENNJSSI=" }, "org/jetbrains/kotlin#kotlin-gradle-plugin-idea-proto/2.3.10": { "jar": "sha256-aBZWUlkS6+BkS2uQraFIPBOcXpMNT46kiCDb4YHP+9o=", "pom": "sha256-AjxvG3UBfGU0IsaaeUiTaR5B7+jC47nxi1uMHsTQhIo=" }, "org/jetbrains/kotlin#kotlin-gradle-plugin-idea/2.3.10": { "jar": "sha256-lS5zlI4qINOYwmuHprtwzPZkGPuvFSfDUVsYjqnUvWA=", "module": "sha256-nKavltr37lkKDlnJ7+HfkBmrAegPRHZ2udyo/F/q9LU=", "pom": "sha256-nAGu0VT697j/vvhlQZ8XFkBj9reptVgTJppQxrSOlxI=" }, "org/jetbrains/kotlin#kotlin-gradle-plugin/2.3.10": { "module": "sha256-+zIU9bn6DZ/MvLxt8tG71VHloQ/lvFh7dsN03xL0mTI=", "pom": "sha256-5fmNcJdy/v3XWP0KOcT3x1ny8BcdGqDMDMK58Ltv45U=" }, "org/jetbrains/kotlin#kotlin-gradle-plugin/2.3.10/gradle813": { "jar": "sha256-uBYPRY9Qkj3wP9AbIE2V6A9jxK2xQPiUnOULQ9eUveM=" }, "org/jetbrains/kotlin#kotlin-gradle-plugins-bom/2.3.10": { "module": "sha256-0WC3nI5dfs7/uQbYx0RhUO3J8bQ8ZXylBcqvH/UCzuk=", "pom": "sha256-Kk97RKPQrHrsfb9bGqiNuTi/VC9zUVYfsdO1z4G2DQU=" }, "org/jetbrains/kotlin#kotlin-klib-commonizer-api/2.3.10": { "jar": "sha256-K04jpJa9pG8kPL+6pm6xxMkBtTHB/uzGnnnxvET1hpM=", "pom": "sha256-zKwUWegEbV4FD1MJ4qi2Zk5IBrVaXUBFCFWI1+0ZOkk=" }, "org/jetbrains/kotlin#kotlin-native-utils/2.3.10": { "jar": "sha256-kgDRaWeyIar2AU5OsCVzFnmKR7soFnI9PZxkaFPXLhM=", "pom": "sha256-inkaCCnJ8U8F7jxRq2OQWxdofTIfW7p8Vx446+7kdC0=" }, "org/jetbrains/kotlin#kotlin-reflect/2.0.21": { "jar": "sha256-OtL8rQwJ3cCSLeurRETWEhRLe0Zbdai7dYfiDd+v15k=", "pom": "sha256-Aqt66rA8aPQBAwJuXpwnc2DLw2CBilsuNrmjqdjosEk=" }, "org/jetbrains/kotlin#kotlin-serialization/2.3.10": { "module": "sha256-y4SHb+infUQ6gbhjVDIUuMT3e9DXcFnNO4m5Nww8MZ8=", "pom": "sha256-BdRcpwksNSXEYlGEktwffhXwrhzCO8BySexKQhscdNU=" }, "org/jetbrains/kotlin#kotlin-serialization/2.3.10/gradle813": { "jar": "sha256-36XL+qo7OQVI6AXeABvJQBGS4z4CJRLRdsFA6raQOwQ=" }, "org/jetbrains/kotlin#kotlin-stdlib-jdk7/2.2.0": { "jar": "sha256-DRC8DUK4YF8jYpo/MeonwZzbyp3N9PU/bSLNY2aDbRg=", "pom": "sha256-lcIYnDXve/xIlRwyrXCEeyHzgJ0m9dCnbiNXCHmYjDA=" }, "org/jetbrains/kotlin#kotlin-stdlib-jdk8/2.2.0": { "jar": "sha256-rcFmSNu881sNEOfsMBw110bRwv5GDGBqulnxKxF8+bA=", "pom": "sha256-I00G/b3CncvAdEfijEomq6uVmdXD2qPZKjTmrt6iNqY=" }, "org/jetbrains/kotlin#kotlin-stdlib/2.0.21": { "jar": "sha256-8xzFPxBafkjAk2g7vVQ3Vh0SM5IFE3dLRwgFZBvtvAk=", "module": "sha256-gf1tGBASSH7jJG7/TiustktYxG5bWqcpcaTd8b0VQe0=", "pom": "sha256-/LraTNLp85ZYKTVw72E3UjMdtp/R2tHKuqYFSEA+F9o=" }, "org/jetbrains/kotlin#kotlin-tooling-core/2.3.10": { "jar": "sha256-NnFCeBKZvA+RIMHe7A5ik0oa+ep/AaqpxaU1TcXY19k=", "pom": "sha256-5hhz7dWo3QMaa6l1nAXRVpBlnmEuPUjB7RInN9q0SYY=" }, "org/jetbrains/kotlin#kotlin-util-io/2.3.10": { "jar": "sha256-4Bw3Dn83/E0Ck7lXEUH1NwnlEJ4o7J1QgnMtOCDWfy4=", "pom": "sha256-LZfqo2d6QEoFKTIaZ4rrLRzDn5EwwRSamNFm3TL8K3Q=" }, "org/jetbrains/kotlin#kotlin-util-klib-metadata/2.3.10": { "jar": "sha256-1uBU2zAOXqeyCOjjZoPNbE10dNiLaRaVFbew69e1DIs=", "pom": "sha256-eT/ktDjwB3hoJDXOGxvPW6feZSR6JIuZpe6pmSov5aA=" }, "org/jetbrains/kotlin#kotlin-util-klib/2.3.10": { "jar": "sha256-5b3gT8jS8h1wWfBcp01UtYkKC4zCqJD/IgjChB7HZfg=", "pom": "sha256-SzSk2Br5xUmg8BOj6gAQeM5EotBEZweUakkhBbaSkx0=" }, "org/jetbrains/kotlin/android#org.jetbrains.kotlin.android.gradle.plugin/2.3.10": { "pom": "sha256-uzw8gaCzFbyJg60kJ7M2GY8VfuNUmvku/LSm8T+Ry60=" }, "org/jetbrains/kotlin/jvm#org.jetbrains.kotlin.jvm.gradle.plugin/2.3.10": { "pom": "sha256-0OzGk5CQFi+LIGtt4uumJVZzzJ0EOico5yUWmt07NP0=" }, "org/jetbrains/kotlin/plugin/serialization#org.jetbrains.kotlin.plugin.serialization.gradle.plugin/2.3.10": { "pom": "sha256-OXldry8vLwxty3VxvpVp232e1jJnGMKRmM3cc6Bing4=" }, "org/jetbrains/kotlinx#kotlinx-coroutines-bom/1.8.0": { "pom": "sha256-Ejnp2+E5fNWXE0KVayURvDrOe2QYQuQ3KgiNz6i5rVU=" }, "org/jetbrains/kotlinx#kotlinx-coroutines-bom/1.9.0": { "pom": "sha256-vqVRHpAB8sWTq1CA3xMbIZq14ghcxZec5YPqzUlG/Xg=" }, "org/jetbrains/kotlinx#kotlinx-coroutines-core-jvm/1.8.0": { "jar": "sha256-mGCQahk3SQv187BtLw4Q70UeZblbJp8i2vaKPR9QZcU=", "module": "sha256-/2oi2kAECTh1HbCuIRd+dlF9vxJqdnlvVCZye/dsEig=", "pom": "sha256-pWM6vVNGfOuRYi2B8umCCAh3FF4LduG3V4hxVDSIXQs=" }, "org/jetbrains/kotlinx#kotlinx-coroutines-core-jvm/1.9.0": { "jar": "sha256-rYnCiSI15nDyItgZyz2BGIFDyxmgW1nfmImuQmn1xwo=", "module": "sha256-syGomeQNPONFcHqiz9qZg60NzGn+p0qbi/kGoWwc+Kk=", "pom": "sha256-GcSImUGzqgmL1XzGTwL5razGVNVxoSqVbeS1uxSMZJk=" }, "org/jetbrains/kotlinx#kotlinx-coroutines-core/1.9.0": { "module": "sha256-rVNANKlTtOEsvuuHTGat+LHKFN8V/g0uZUeqNOht/so=", "pom": "sha256-dw8nk9BeKwJ7nHmZOOwdLU7xQc5YGceAwyw5lcrbCkc=" }, "org/junit#junit-bom/5.10.2": { "module": "sha256-3iOxFLPkEZqP5usXvtWjhSgWaYus5nBxV51tkn67CAo=", "pom": "sha256-Fp3ZBKSw9lIM/+ZYzGIpK/6fPBSpifqSEgckzeQ6mWg=" }, "org/jvnet/staxex#stax-ex/1.8.1": { "jar": "sha256-IFIlSQVunlCqNe8LRFouR6U9Br4LCpRn1wTiSD/7BJo=", "pom": "sha256-j8hPNs5tps6MiTtlOBmaf2mmmgcG2bF6PuajoJRS7tY=" }, "org/ow2#ow2/1.5.1": { "pom": "sha256-Mh3bt+5v5PU96mtM1tt0FU1r+kI5HB92OzYbn0hazwU=" }, "org/ow2/asm#asm-analysis/9.8": { "jar": "sha256-5kBzL7zTxicZJaUE8SXjg4Roj037v5LIYi387g0J7bk=", "pom": "sha256-xXR+JccuGwfVJjx1x4rWGmJt0kWPr8r8I/gdMlPuQu0=" }, "org/ow2/asm#asm-commons/9.8": { "jar": "sha256-MwGhwctMWfzFKSZI2sHXxa7UwPBn376IhzuM3+d0BPQ=", "pom": "sha256-95PnjwH3A3F9CUcuVs3yEv4piXDIguIRbo5Un7bRQMI=" }, "org/ow2/asm#asm-tree/9.8": { "jar": "sha256-FLeIDLfIXu0QHicQQy/D/7gydVMqaolNxMQJXUmtWfE=", "pom": "sha256-cUnn+qDhkSlvh5ru2SCciULTmPBpjSzKGpxijy4qj3c=" }, "org/ow2/asm#asm-util/9.8": { "jar": "sha256-i6BGDsso/Q4pgOXz7zQzpROkV7wHf4GlO9x1tYegjRU=", "pom": "sha256-JNCXDhceKRe4Oo8PBdUKHNtcguUIVVtS28ydM2HE8Ow=" }, "org/ow2/asm#asm/9.8": { "jar": "sha256-h26raoPa7K1cpn65/KuwY8l7WuuM8fynqYns3hdSIFE=", "pom": "sha256-wTZ8O7OD12Gef3l+ON91E4hfLu8ErntZCPaCImV7W6o=" }, "org/slf4j#slf4j-api/1.7.30": { "jar": "sha256-zboHlk0btAoHYUhcax6ML4/Z6x0ZxTkorA1/lRAQXFc=", "pom": "sha256-fgdHdR6bZ+Gdy1IG8E6iLMA9JQxCJCZALq3QNRPywxQ=" }, "org/slf4j#slf4j-parent/1.7.30": { "pom": "sha256-EWR5VuSKDFv7OsM/bafoPzQQAraFfv0zWlBbaHvjS3U=" }, "org/sonatype/oss#oss-parent/7": { "pom": "sha256-tR+IZ8kranIkmVV/w6H96ne9+e9XRyL+kM5DailVlFQ=" }, "org/sonatype/oss#oss-parent/9": { "pom": "sha256-+0AmX5glSCEv+C42LllzKyGH7G8NgBgohcFO8fmCgno=" }, "org/tensorflow#tensorflow-lite-metadata/0.2.0": { "jar": "sha256-6fGLikHwF+kDPLDthciiuiMHKSzf4l6uNlkj56MdKnA=", "pom": "sha256-D+MTJug7diLLzZx11GeykfAf/jzG4+dmUawFocHHo2A=" } }, "https://repo.maven.apache.org/maven2": { "com/facebook#ktfmt/0.59": { "jar": "sha256-MmXtnOos7DnR4qu5A02SBjcqXkR6cnR5M96+YyG3RB4=", "module": "sha256-tBXyQQXgg21c1jfrbouup0DdHNnoWEVZG3ZYpbncOrU=", "pom": "sha256-i+wlctivol5UhScjNo93boVBXBIBAQbzkAuhxnftVqY=" }, "com/facebook#ktfmt/0.59/with-dependencies": { "jar": "sha256-pJMnHBNwdENqEz1y6eMj+RYEyX4MQFVWKew5WY7+rVQ=" }, "com/google/android#annotations/4.1.1.4": { "jar": "sha256-unNOHoTAnWFa9qCdMwNLTwRC+Hct7BIO+zdthqVlrhU=", "pom": "sha256-5LtUdTw2onoOXXAVSlA0/t2P6sQoIpUDS/1IPWx6rng=" }, "com/google/api/grpc#proto-google-common-protos/2.17.0": { "jar": "sha256-TvH+DDJ/wVIdHXU7CxxKh1pUvRTr3tOv/wyjlTILbqk=", "pom": "sha256-PwKBU6WFxZ9Viz5Dp8mAmmAai7XpEGHWxlj/+iTLjiY=" }, "com/google/api/grpc#proto-google-common-protos/2.48.0": { "jar": "sha256-Q+x4B0WaqkAS6Dihvk7y1ZDPIzMF2mCvW1TwjsjPIwI=", "pom": "sha256-ReuSeyJoX8Eb+rac5q07Q59y2Bajp95GvSP07Rm/icM=" }, "com/google/auto#auto-common/1.2.1": { "jar": "sha256-9D8p/ipuuvBLJZjN7sMqTjRtSalATpkPX8GcGfOijQ4=", "pom": "sha256-E7S1AGKUn4sTQ5J8WBU207sFG4r+pQmqb5AvTeKLwbI=" }, "com/google/auto/service#auto-service-aggregator/1.1.1": { "pom": "sha256-4tw+JjpdsMyBcvsuubzSMqzs/clugXED6rPI2fGQDGo=" }, "com/google/auto/service#auto-service-annotations/1.1.1": { "jar": "sha256-Fqdt0AomUFaER/XW46niyAnZpCNn1WtFIVz7iXMfTSQ=", "pom": "sha256-Fln9CiXOqfqav7GN6517GAzOOs6rFv+eYlRhVn5Eot8=" }, "com/google/auto/service#auto-service/1.1.1": { "jar": "sha256-H0j0UVA+Yj2rp9ntNozKD4Hh44FWU6RWARPhLAEp69U=", "pom": "sha256-DFPC+HlJs76PHIxnu3bAjtT0vrjbqkdcMrpZPUYXYxc=" }, "com/google/code/findbugs#jsr305/3.0.2": { "jar": "sha256-dmrSoHg/JoeWLIrXTO7MOKKLn3Ki0IXuQ4t4E+ko0Mc=", "pom": "sha256-GYidvfGyVLJgGl7mRbgUepdGRIgil2hMeYr+XWPXjf4=" }, "com/google/code/gson#gson-parent/2.10.1": { "pom": "sha256-QkjgiCQmxhUYI4XWCGw+8yYudplXGJ4pMGKAuFSCuDM=" }, "com/google/code/gson#gson-parent/2.11.0": { "pom": "sha256-issfO3Km8CaRasBzW62aqwKT1Sftt7NlMn3vE6k2e3o=" }, "com/google/code/gson#gson-parent/2.8.9": { "pom": "sha256-sW4CbmNCfBlyrQ/GhwPsN5sVduQRuknDL6mjGrC7z/s=" }, "com/google/code/gson#gson/2.10.1": { "jar": "sha256-QkHBSncnw0/uplB+yAExij1KkPBw5FJWgQefuU7kxZM=", "pom": "sha256-0rEVY09cCF20ucn/wmWOieIx/b++IkISGhzZXU2Ujdc=" }, "com/google/code/gson#gson/2.11.0": { "jar": "sha256-V5KNblpu3rKr03cKj5W6RNzkXzsjt6ncKzCcWBVSp4s=", "pom": "sha256-wOVHvqmYiI5uJcWIapDnYicryItSdTQ90sBd7Wyi42A=" }, "com/google/code/gson#gson/2.8.9": { "jar": "sha256-05mSkYVd5JXJTHQ3YbirUXbP6r4oGlqw2OjUUyb9cD4=", "pom": "sha256-r97W5qaQ+/OtSuZa2jl/CpCl9jCzA9G3QbnJeSb91N4=" }, "com/google/crypto/tink#tink/1.7.0": { "jar": "sha256-iJcKRWoIukxmsBsj5YRsoQlcwU5Uy0g2Pl0uFaEwcwg=", "pom": "sha256-Ku41I3FfjyzRCyYDyNGeVhrHWDELfiyYU5RtLF57S/c=" }, "com/google/dagger#dagger-compiler/2.58": { "jar": "sha256-FyqvLs7qchk8D2BpW8I4NXRxmFx/VJoD4kZgu1pjdfk=", "pom": "sha256-jllGOdoMV7fzPaOXRuKZg6mgsRK9vO7XVxdK+Bd18ck=" }, "com/google/dagger#dagger-lint-aar/2.58": { "pom": "sha256-iZNhbn/AwNJuZxCU2+aAQxs0krwUPgbq9FiQQwKAcnk=" }, "com/google/dagger#dagger-spi/2.58": { "jar": "sha256-osA2h9lNAHzsae/mY/gyRcN2zFc+QMespxB/M1VnpKg=", "pom": "sha256-yzcxB37s/3MPdF2nQi4C7tN9jSUq2wq4JZmVRqin0w4=" }, "com/google/dagger#dagger/2.48": { "jar": "sha256-H6Im0rSgLMgJUPpNSaSiNcyOztSZtYH8NYpVRGqD9Xk=", "pom": "sha256-df10hXPlgHWxMG9/rhnPWwLAsIUJCPdlExsJKhFWqUQ=" }, "com/google/dagger#dagger/2.58": { "jar": "sha256-fHAeJFunR+aSXKoZOtf1EMbXVW1JYLMpmeYA0FadxfU=", "pom": "sha256-+liss4wmYmPwSuxAgaDQAn8zx/BWOZFOiorNNwj4xvM=" }, "com/google/dagger#hilt-android-compiler/2.58": { "jar": "sha256-n2kQUQ4hI4/fcMbF6FewTnZ7u0ioLqIbFShXfw0cvE8=", "pom": "sha256-f9FmaKi/IHD3I8EfJg9nN7X+Fz8Rvn19V+X/bVLnfhw=" }, "com/google/dagger#hilt-android/2.58": { "pom": "sha256-GQVrwpg6oiKIOGe/mptjqKxPF4sTAop5QN7wT8D32Ts=" }, "com/google/dagger#hilt-compiler/2.58": { "jar": "sha256-ngp5Lf+vA26j1kuv6cOw+lfn1LSgaZZlSEE0VyFIm4c=", "pom": "sha256-tZsoqk70OP3v1QWo9IwIMZcICXIwQ43wJkuXoP2jLW8=" }, "com/google/dagger#hilt-core/2.58": { "jar": "sha256-nfWU/NyszBlwlqVD4Cic3afoZJ3sk2Llj77yeFIBC+0=", "pom": "sha256-U0YjTGpoHshJcgoUILfPz5+h841DF8vD6+sNQV9nmUo=" }, "com/google/dagger/dagger-lint-aar/2.58/dagger-lint-aar-2.58": { "aar": "sha256-t4e7yOP7iZyxd6upHopKnLeZxGThdZ5yLD2wjcQ3om4=" }, "com/google/dagger/hilt-android/2.58/hilt-android-2.58": { "aar": "sha256-vwk5yKavFE+sIZTV/qMYnDT84VqOlbF1Bm6LnAFgBGg=" }, "com/google/devtools/ksp#symbol-processing-aa-embeddable/2.3.6": { "jar": "sha256-fK6i1CZoCmp3AHvKYNQOockhjz22rp0WxU8P/vxOG1s=", "pom": "sha256-oa7nRVgcA+5ZqJYoiODuB5WifGwbVbczJS5SvfHPI/0=" }, "com/google/devtools/ksp#symbol-processing-api/2.2.20-2.0.3": { "jar": "sha256-ogZEVp7MAUZ9Pv5Pi5eHqHGc4n7RK2o0da4dgr+xag4=", "module": "sha256-HVGBrIyxsW2CXjLznfHy74WtGZVjXqrzU5sy3gVuXY8=", "pom": "sha256-AoGBVPu0W8crSiPLJCqgFlpIMk1rjsQ8srl8itLm3rA=" }, "com/google/devtools/ksp#symbol-processing-api/2.3.6": { "jar": "sha256-Vd+DfVSxv9182+U72E3urjdGd+xKcpqa8VTT/uPAAjA=", "module": "sha256-fTwGu1Wcn5r42q4q+DCS2VaXOBQrLpBlS2ut235974k=", "pom": "sha256-YkfnkAfhEo350LIQnh7H657evU7rjleMDkMSlXeSsF0=" }, "com/google/devtools/ksp#symbol-processing-common-deps/2.3.6": { "jar": "sha256-917ObR1KiXj8nP4siVwyMjR926tcCEsE0M6Hax18lgQ=", "module": "sha256-zC2bYeTXyh+cMHAaun4osQZS6/neLCgEr3rzNN57Tt0=", "pom": "sha256-A29s8I+g0esgo/m+G33D1fOag42dbj/BJRrAxqRe07E=" }, "com/google/devtools/ksp#symbol-processing/2.3.6": { "pom": "sha256-RTWI/ah2Ows/MadEwdl8lbvC5DlCLTfjKA3aZMF63+k=" }, "com/google/errorprone#error_prone_annotations/2.15.0": { "jar": "sha256-BnBHcUNJ53iaW9v62dHAr586HrKMVaDuP2jmgvkFxOs=", "pom": "sha256-e65hfjJoHruyicIDyQX2RsKgOXWYr3htlhpUqqPSseY=" }, "com/google/errorprone#error_prone_annotations/2.21.1": { "jar": "sha256-0fPGaqkaxSVJ4Arjsgi6S5r31y1o8jBkNVO+s45hGKw=", "pom": "sha256-9ZiID+766p1nTcQdsTqzcAS/A3drW7IcBN7ejpIMHxI=" }, "com/google/errorprone#error_prone_annotations/2.23.0": { "jar": "sha256-7G858Gi2/5rDI8aOKLkpn4wKgMpRLcyx1KcPQKw+wFQ=", "pom": "sha256-1auxfyMbY78Ak1j6ZAKBt0SBDLlYflmUl3g0lZwH29g=" }, "com/google/errorprone#error_prone_annotations/2.27.0": { "pom": "sha256-TKWjXWEjXhZUmsNG0eNFUc3w/ifoSqV+A8vrJV6k5do=" }, "com/google/errorprone#error_prone_annotations/2.28.0": { "jar": "sha256-8/yKOgpAIHBqNzsA5/V8JRLdJtH4PSjH04do+GgrIx4=", "pom": "sha256-DOkJ8TpWgUhHbl7iAPOA+Yx1ugiXGq8V2ylet3WY7zo=" }, "com/google/errorprone#error_prone_annotations/2.30.0": { "jar": "sha256-FE86771uJ9rsVdN1OyxrE8Gv2vDPBIFs21ZFiO2S8b0=", "pom": "sha256-9xOEnCOzSVPoVFZdzoqnlcrgwUFmEbcgwhRhMix5X4Y=" }, "com/google/errorprone#error_prone_parent/2.15.0": { "pom": "sha256-Edys0XqqxpqZQFJzut/apflmHWDRebikT1A5WLqlX4g=" }, "com/google/errorprone#error_prone_parent/2.21.1": { "pom": "sha256-MrsLX/JB/Wuh/upEiuu5zt7xaZvnPLbzGTZTh7gr+Sw=" }, "com/google/errorprone#error_prone_parent/2.23.0": { "pom": "sha256-9UcKSzEE/jCfvpSoDRbDxU0g90j0xd5PaKQoaI8wy9Q=" }, "com/google/errorprone#error_prone_parent/2.27.0": { "pom": "sha256-+oGCnQSVWd9pJ/nJpv1rvQn4tQ5tRzaucsgwC2w9dlQ=" }, "com/google/errorprone#error_prone_parent/2.28.0": { "pom": "sha256-rM79u1QWzvX80t3DfbTx/LNKIZPMGlXf5ZcKExs+doM=" }, "com/google/errorprone#error_prone_parent/2.30.0": { "pom": "sha256-Xog0zMDl7Qxy8wbCULUY5q0Q0HWpt7kQz2lcuh7gKi0=" }, "com/google/googlejavaformat#google-java-format-parent/1.23.0": { "pom": "sha256-VgkdKaBZnNAKlXnrzT0dnRhfX3b0Q33zD0C4X+LIUqQ=" }, "com/google/googlejavaformat#google-java-format-parent/1.33.0": { "pom": "sha256-x1dS3W9SbEr79q24GLgFyL/XQp48rYBa/NrMUljc0uk=" }, "com/google/googlejavaformat#google-java-format/1.23.0": { "jar": "sha256-+cXxgfruXHs4D+uWwalL7AxVhZuustFOmkfZLUC+8CE=", "pom": "sha256-3yjFvNmuLslzF3y7WqV7cyJ2yYxe1n+R75OjfmG19qA=" }, "com/google/googlejavaformat#google-java-format/1.33.0": { "jar": "sha256-EmWqdh7CkIU1MksRSZWhxt667cip4uhVMSe6vNtYVn8=", "pom": "sha256-h8Z8okpEf0pab2LX7PuZ1Cyi108mo4Rol8GJ07mZg5A=" }, "com/google/guava#failureaccess/1.0.1": { "jar": "sha256-oXHuTHNN0tqDfksWvp30Zhr6typBra8x64Tf2vk2yiY=", "pom": "sha256-6WBCznj+y6DaK+lkUilHyHtAopG1/TzWcqQ0kkEDxLk=" }, "com/google/guava#failureaccess/1.0.2": { "jar": "sha256-io+Bz5s1nj9t+mkaHndphcBh7y8iPJssgHU+G0WOgGQ=", "pom": "sha256-GevG9L207bs9B7bumU+Ea1TvKVWCqbVjRxn/qfMdA7I=" }, "com/google/guava#guava-parent/26.0-android": { "pom": "sha256-+GmKtGypls6InBr8jKTyXrisawNNyJjUWDdCNgAWzAQ=" }, "com/google/guava#guava-parent/32.0.0-jre": { "pom": "sha256-rPkrzaF3RhUxw2hRncmrxfnVhUrcHCiem/gDcb79NcM=" }, "com/google/guava#guava-parent/32.0.1-jre": { "pom": "sha256-Q+0ONrNT9B5et1zXVmZ8ni35fO8G6xYGaWcVih0DTSo=" }, "com/google/guava#guava-parent/32.1.3-jre": { "pom": "sha256-8oPB8EiXqaiKP6T/RoBOZeghFICaCc0ECUv33gGxhXs=" }, "com/google/guava#guava-parent/33.0.0-jre": { "pom": "sha256-BAzIjGgLQT1wup/INxs2CTAhsQmWqjWYYh3nZ9QYIpo=" }, "com/google/guava#guava-parent/33.3.1-jre": { "pom": "sha256-VUQdsn6Iad/v4FMFm99Hi9x+lVhWQr85HwAjNF/VYoc=" }, "com/google/guava#guava/32.0.0-jre": { "pom": "sha256-nszOUItR1VIeU4g7l00rhy8tJoVDwsUbT4ZuP6B29ec=" }, "com/google/guava#guava/32.0.1-jre": { "jar": "sha256-vX+iJ1kfuFCWd9DREiz5UVjzuKn0VlP1goHYefbcSMU=", "pom": "sha256-QsJX9/c203ezGv7u6XirJtcwzXCvYN3nZi4YI1LiSCo=" }, "com/google/guava#guava/32.1.3-jre": { "jar": "sha256-bU4rWhGKq2Lm5eKdGFoCJO7YLIXECsPTPPBKJww7N0Q=", "module": "sha256-9f/3ZCwS52J7wUKJ/SZ+JgLBf5WQ4jUiw+YxB/YcKUI=", "pom": "sha256-cA5tRudbWTmiKkHCXsK7Ei88vvTv7UXjMS/dy+mT2zM=" }, "com/google/guava#guava/33.0.0-jre": { "jar": "sha256-9NhcPk1BFpQzfLhzq+oJskK2ZLsBMyC+YQUyfEWZFTc=", "module": "sha256-WaLb0FXRuqdi548aW6Orlz7dE/wn3MGHEQXi95f2gtM=", "pom": "sha256-/XCxTEQZhsIubSLO0ldnh3Vr5JGLFFqKvSI+OoC24y0=" }, "com/google/guava#guava/33.3.1-jre": { "jar": "sha256-S/Dixa+ORSXJbo/eF6T3MH+X+EePEcTI41oOMpiuTpA=", "module": "sha256-QYWMhHU/2WprfFESL8zvOVWMkcwIJk4IUGvPIODmNzM=", "pom": "sha256-MTtn/BPrOwY07acVoSKZcfXem4GIvCgHYoFbg6J18ZM=" }, "com/google/guava#listenablefuture/1.0": { "jar": "sha256-5K12B+XAR3xviQ7yaknLjRu03/tlC6tFAq/uZGROMGk=", "pom": "sha256-U4c8rya8HtilZ+psk5qyqqP0el4y1creld31CA0jI4o=" }, "com/google/guava#listenablefuture/9999.0-empty-to-avoid-conflict-with-guava": { "jar": "sha256-s3KgN9QjCqV/vv/e8w/WEj+cDC24XQrO0AyRuXTzP5k=", "pom": "sha256-GNSx2yYVPU5VB5zh92ux/gXNuGLvmVSojLzE/zi4Z5s=" }, "com/google/j2objc#j2objc-annotations/2.8": { "jar": "sha256-8CqV+hpele2z7YWf0Pt99wnRIaNSkO/4t03OKrf01u0=", "pom": "sha256-N/h3mLGDhRE8kYv6nhJ2/lBzXvj6hJtYAMUZ1U2/Efg=" }, "com/google/j2objc#j2objc-annotations/3.0.0": { "jar": "sha256-iCQVc0Z93KRP/U10qgTCu/0Rv3wX4MNCyUyd56cKfGQ=", "pom": "sha256-I7PQOeForYndEUaY5t1744P0osV3uId9gsc6ZRXnShc=" }, "com/google/jimfs#jimfs-parent/1.1": { "pom": "sha256-xxVVdR5X4O+RKHDorJYlrnglAqalucGcz4OyqX2LJr0=" }, "com/google/jimfs#jimfs/1.1": { "jar": "sha256-xIKOKNfAqTCvk4dRCzutp9qlwE18Jadce4sIHxwlfd0=", "pom": "sha256-76huXNki8XtHL9/K5XI02NSsPhSLYlBzffzkVK96ekQ=" }, "com/google/protobuf#protobuf-bom/3.22.3": { "pom": "sha256-E6Mt+53m/Bw8P3r1Pk1cd/130rR2uuOLdLdYHN7i5lU=" }, "com/google/protobuf#protobuf-bom/3.24.4": { "pom": "sha256-BOz9UsUN8Hp1VR+bCeDvMGMO5CN9CRyg7KceW/t4zOU=" }, "com/google/protobuf#protobuf-bom/3.25.5": { "pom": "sha256-CA4phBcyOLUOBkwiav/7sbAjNSApXHkKf9PWrkWT8GM=" }, "com/google/protobuf#protobuf-java-util/3.22.3": { "jar": "sha256-xhX3aHncXDA+TfW5Smr6OVNAWMdUXbLUg/2V2fY8i/4=", "pom": "sha256-tEcBsGoGSGXsm1YUqT6eKPrdfU38S0YPIcgZ71Pb4tY=" }, "com/google/protobuf#protobuf-java-util/3.24.4": { "jar": "sha256-EzySniz+OZChBdGOrMxJEistL7SStCDvAtXZ+Tfq67g=", "pom": "sha256-nwzsJ21NnVpD1uKcwrAk5GgEyThqlvpSfu/Xv3SI5/A=" }, "com/google/protobuf#protobuf-java/3.24.4": { "jar": "sha256-5WVVIr4apcwfLwkqoDawRFFX8pSSju3xMyrJOMe2loY=", "pom": "sha256-OUEiHKZXgZ3evZX+i3QPRwr3q/MEYLE+ocmrefEPq5E=" }, "com/google/protobuf#protobuf-java/3.25.5": { "jar": "sha256-hUAkf62eBrrvqPtF6zE4AtAZ9IXxQwDg+da1Vu2I51M=", "pom": "sha256-51IDIVeno5vpvjeGaEB1RSpGzVhrKGWr0z5wdWikyK8=" }, "com/google/protobuf#protobuf-kotlin/3.24.4": { "jar": "sha256-UIyhPZe1D1QE6qN+tEk8sHiEFi63lxv5JNj4A9TCG7Q=", "pom": "sha256-O7Hm75SmpDa9L/Zq8N1gPCPtOilDsvuJn4PZ+Hktcqk=" }, "com/google/protobuf#protobuf-parent/3.22.3": { "pom": "sha256-OZEz1/b1eTTddsSxjoY0j0JFMhCNr0oByPgguGZfCSk=" }, "com/google/protobuf#protobuf-parent/3.24.4": { "pom": "sha256-+37AUFh2/bnseVEKztLR6wTDuM/GkLWJBJdXypgcrbM=" }, "com/google/protobuf#protobuf-parent/3.25.5": { "pom": "sha256-ZMwOOtboX1rsj53Pk0HRN56VJTZP9T4j4W2NWCRnPvc=" }, "com/squareup#javapoet/1.13.0": { "jar": "sha256-THUX6EinGzbQadErs79Gpw/UzaMQXYIrDtLhnAC2kpE=", "pom": "sha256-VKNPqFAqRryQ79tJJiYAWR+oC/mjT1pMeYMRrsFsqXc=" }, "com/squareup#kotlinpoet/1.11.0": { "jar": "sha256-KIetocoD3YO6onWGQNh+hA0ZB1ZNsO+I0iichoqYBJI=", "module": "sha256-LWrQnnysudpjxv3zM+NDKzmULVWGM28C6wb1tUUPNKA=", "pom": "sha256-ww+ujSieimZrk/uiBfJayVFJOEGuFGwONcNdz2xPiSw=" }, "com/sun/activation#all/1.2.0": { "pom": "sha256-HYUY46x1MqEE5Pe+d97zfJguUwcjxr2z1ncIzOKwwsQ=" }, "com/sun/activation#all/1.2.1": { "pom": "sha256-NgiDv2RIbs7xYbjygvZQNTbdGmcNU6Coccj7IBcOZ5U=" }, "com/sun/activation#javax.activation/1.2.0": { "jar": "sha256-mTMCsWzXBW8h53nMV30XWoELtJAO9zzY+/K1D5KLqc4=", "pom": "sha256-+Hm26UWFTGkAsNvuHIOE16s95+FX/XrISTdAXEFtKl4=" }, "com/sun/istack#istack-commons-runtime/3.0.8": { "jar": "sha256-T/q7Br5FSgXkOY4gx3+itjCNS4jfvvfKMKdrW31VBe8=", "pom": "sha256-wuAU00y4TtKH0GSYbEXDBaQSQiinM37M9sQh0U1wjxw=" }, "com/sun/istack#istack-commons/3.0.8": { "pom": "sha256-oPBRfoUS8PvMe4KVwS9lZqPQwthtZVY53GYu+MDH6+U=" }, "com/sun/xml/bind#jaxb-bom-ext/2.3.2": { "pom": "sha256-Gn3sKyfn4FV0TNuM8bkN70/Uc6zRuATv8JgTk1iVm9c=" }, "com/sun/xml/bind/mvn#jaxb-parent/2.3.2": { "pom": "sha256-IN1tw0q3VJrEDaHYLpIiLsQ0etDsDLEY72xXA77VOhg=" }, "com/sun/xml/bind/mvn#jaxb-runtime-parent/2.3.2": { "pom": "sha256-sk+NUfGEpovBuG1IwOPP7+shpE7eHF9zA8WK4EiFM+w=" }, "com/sun/xml/bind/mvn#jaxb-txw-parent/2.3.2": { "pom": "sha256-tV0++psVj0g6MOkseMy2APkzFHM9CJ66m3RDbwGzFKQ=" }, "com/sun/xml/fastinfoset#FastInfoset/1.2.16": { "jar": "sha256-BW86HhRECfIe0Wr8JoBfWOmiHz/OFUPELUAHGdJQxRE=", "pom": "sha256-4UfSWKtuZpH3BZmpUkAObmx1WPjJwCjb4b4jF4MI6DA=" }, "com/sun/xml/fastinfoset#fastinfoset-project/1.2.16": { "pom": "sha256-kFgkJa3B9AtBNi2vuVFzkxIlrKpeeWINXmvVL2Rikro=" }, "commons-codec#commons-codec/1.10": { "jar": "sha256-QkHfqU5xHUNfKaRgSj4t5cSqPBZeI70Ga+b8H8QwlWk=", "pom": "sha256-vbjbcBLREqbj6o/bfFELMA2Z7/CBnSfd26nEM5fqTPs=" }, "commons-io#commons-io/2.16.1": { "jar": "sha256-9B97qs1xaJZEes6XWGIfYsHGsKkdiazuSI2ib8R3yE8=", "pom": "sha256-V3fSkiUceJXASkxXAVaD7Ds1OhJIbJs+cXjpsLPDj/8=" }, "commons-logging#commons-logging/1.2": { "jar": "sha256-2t3qHqC+D1aXirMAa4rJKDSv7vvZt+TmMW/KV98PpjY=", "pom": "sha256-yRq1qlcNhvb9B8wVjsa8LFAIBAKXLukXn+JBAHOfuyA=" }, "dev/drewhamilton/poko#poko-annotations-jvm/0.17.1": { "jar": "sha256-lA5tUERbxrCuJq1BTse5U6Pk6ALcd1bMFNVpWLyXzDE=", "module": "sha256-7c77BBavhseEjvEMfUQX09svMr31hUhkrKnQXXbrauU=", "pom": "sha256-G7U6/YIw/943O1uLFxHcoMg9OtVh86UOOcSoAp2DI5I=" }, "dev/drewhamilton/poko#poko-annotations/0.17.1": { "module": "sha256-x8I2DoKGnEjLnFFWuDXPKIGJzr3zmqY3sacCc6IxSFI=", "pom": "sha256-eSsiGa7acmaiD/grNy5oOcJznA3KTFPBESXf2SZud3Q=" }, "io/github/davidburstrom/contester#contester-breakpoint/0.2.0": { "jar": "sha256-Zyy+u11Fpys13YH9YSfhh0Ubtvt/ujUxW73y9Xz86DU=", "module": "sha256-JZPF2femBAbmrqzmBWP82SNxdTb90ngGQUKX6T1gn+U=", "pom": "sha256-vi0ixQGThmVimejxY0LGjFJkW3R/+4YZKQjRexb+wK4=" }, "io/github/detekt/sarif4k#sarif4k-jvm/0.6.0": { "jar": "sha256-s6yW3ZesuoMY2+JvakMtbG25HEbHgIBeiSi4ED5XY9w=", "module": "sha256-Lf6fe4j3KOGtSFq5cXrwalWeAl7NXwGb6LUy6TUrmHU=", "pom": "sha256-i5D8N0XwCPUQiGznB1Ofa7cbvdG3yduP0dsK5WahwK8=" }, "io/github/detekt/sarif4k#sarif4k/0.6.0": { "module": "sha256-aK4T0fQBTLjEZUdLo/GgJGAWo0VcJV5u3udUjxUPqMU=", "pom": "sha256-QFP71kaAT/u2ebMSGQGyI+3B+OQbNcp8KR4qjc2pwsE=" }, "io/github/java-diff-utils#java-diff-utils-parent/4.12": { "pom": "sha256-2BHPnxGMwsrRMMlCetVcF01MCm8aAKwa4cm8vsXESxk=" }, "io/github/java-diff-utils#java-diff-utils/4.12": { "jar": "sha256-mZCiA5d49rTMlHkBQcKGiGTqzuBiDGxFlFESGpAc1bU=", "pom": "sha256-wm4JftyOxoBdExmBfSPU5JbMEBXMVdxSAhEtj2qRZfw=" }, "io/gitlab/arturbosch/detekt#detekt-api/1.23.8": { "jar": "sha256-3VuE1CCQTVxWSqsRXTbmKQqdfa9pVZIwmQFWGPK1yD8=", "module": "sha256-8s4t9yehmVmTlnVLWiWVuJmQZGZkFmrq5eMej2CtTAY=", "pom": "sha256-KDAQpI6V8+PzcO+qIcde2Xkwa5nrUdcm2X5i5V62hH4=" }, "io/gitlab/arturbosch/detekt#detekt-cli/1.23.8": { "jar": "sha256-6tfM0yC/MEzs4YlDjWo4R0fHZUWg+IDZyR1cpdLTCmM=", "module": "sha256-fXV30ckqMrrIuFOkfF5BvNZja/Mmh7V24z9IgQUuAoQ=", "pom": "sha256-ReQ3FQ62Dil0mslrkamQ4JJSOyleE7jlNI/n5BvA2iw=" }, "io/gitlab/arturbosch/detekt#detekt-core/1.23.8": { "jar": "sha256-GYHeqOTi6FQa8tg+T401gc5kfP5jF1ueuawqB4SddMk=", "module": "sha256-XJbmQu79sMhURDM3a7hc1jFhmSzYNTyp5ZIQTkI2IXc=", "pom": "sha256-iW0NWvPv2dGf/umtVeIKh0tjP4R4X0y3d8tCJz1WkKA=" }, "io/gitlab/arturbosch/detekt#detekt-metrics/1.23.8": { "jar": "sha256-cY6PcfWHKYbk9c1Ih6QeJqoK7/Qumc9KQlgikbhzjMQ=", "module": "sha256-x6sTPxfP1A/5h6lXTbbrEQ/tI6FGPrFeaau4/N4zt3Y=", "pom": "sha256-eYMhhgRaIYtz+9fYbirEOy07OAaaIqmhEhS9JjF5ioE=" }, "io/gitlab/arturbosch/detekt#detekt-parser/1.23.8": { "jar": "sha256-XN9FoBctk01udAHNQ4OOe5VPetsRfu2DWPyuCxd+kOU=", "module": "sha256-0K3LJljKCW2Jj6+DK+sZmjUzxINqR7I8G+qcqxsOolk=", "pom": "sha256-QfC6f7dnDh5b0zUeylx+KZD3UFmpLKRTcx2RdpNB8rc=" }, "io/gitlab/arturbosch/detekt#detekt-psi-utils/1.23.8": { "jar": "sha256-lQX6nU+adx0lal1BW11R/9eiTlAZ9qYOWmmhJjPc97o=", "module": "sha256-XFZfcZCGBgrc3py1DZ35ZqVuWyr0KrZuSc0Nm7irSKM=", "pom": "sha256-tHScWO7eil587tpAjFNGBYCLvvXXAI1yINlJQ/6rJrg=" }, "io/gitlab/arturbosch/detekt#detekt-report-html/1.23.8": { "jar": "sha256-gGihXgdxjjvb9QHt5fZmgS+1+fsUUNsGBElTTAXmcio=", "module": "sha256-3WDckx67/ybl7EcfBhQSTHWWpyjP5l2KYmI2guCa2Io=", "pom": "sha256-jMdSpvNpMrmLjlESAtafEUmll2aRVBV+x9IMWQAJyX4=" }, "io/gitlab/arturbosch/detekt#detekt-report-md/1.23.8": { "jar": "sha256-zFuQsUds75nhEhYtvNHbMrBAKEp2jzyXWknsC2OYDKM=", "module": "sha256-YTshxfr1SfdKlU+I3yT1hnVO1XEga/C2Hsg6vm7l36Q=", "pom": "sha256-Ja9GMe98lKTNTmcgE+hbCFcZgB8lwkQF8ts29lVpkZY=" }, "io/gitlab/arturbosch/detekt#detekt-report-sarif/1.23.8": { "jar": "sha256-yfkiH8V+0fvRN03lxNpsBpFg+JKnH4O1UdOjLIy60T0=", "module": "sha256-Dn+gYrE0qurBeHUODabhinxqluuDjk2RSMAvMSuapNI=", "pom": "sha256-ba9LdulqHZ4FzWJCcMdY3yB2JnuP7dNoCtFLZsvxJI4=" }, "io/gitlab/arturbosch/detekt#detekt-report-txt/1.23.8": { "jar": "sha256-Qehco1h6vOmgP43LKmfgX89Zt1GPoBbJNQ6nPHn5xUo=", "module": "sha256-prv47yBLVpTbGgMKpeewIqQ9bq8SKXS8GMFNWpuEmto=", "pom": "sha256-mUX7sz4pmT32Q0B9B8omJvqIUen41JUdBcQwd7AEhR4=" }, "io/gitlab/arturbosch/detekt#detekt-report-xml/1.23.8": { "jar": "sha256-1xq6qYiQyuimGIObEwnAE9/znGvX0N4LcE5hk+An7wk=", "module": "sha256-geiqXklP8ZuZLH6oOmt/9sv3NqYgqHf4uWqwUrKUjFo=", "pom": "sha256-2xALuQ97r4rtIRh3jRg0BOqw62sTR2tlgNwpSxxJPfk=" }, "io/gitlab/arturbosch/detekt#detekt-rules-complexity/1.23.8": { "jar": "sha256-OhaXRuOLk+67jrfhDe2wZlfKKUvIHPZ1pzX4W183Hm4=", "module": "sha256-zQXYVo7PY/hUIIhgiS89orKCa7/fzXvVGILWoNi0YQs=", "pom": "sha256-A3v3LOqm5oS7V2S6ugvf2vdH+ndOdQthy4xBsBsos/Q=" }, "io/gitlab/arturbosch/detekt#detekt-rules-coroutines/1.23.8": { "jar": "sha256-r+jJc8F0V/cU8FeVBGEVGIwD9FBpA7sluE2oRuLlaBY=", "module": "sha256-3JMvjexdt3DwkB8AteRVBxwT6tlDMI4gRCPqMybJnsU=", "pom": "sha256-ZlutPHrhKRzQTQRCtPlXRw5naEF0OfICc2mlAWONB7c=" }, "io/gitlab/arturbosch/detekt#detekt-rules-documentation/1.23.8": { "jar": "sha256-0lNnhV2MzRVvKD4c1t4P0VuPPpUwqXAgb0HWGftPvc0=", "module": "sha256-PnZ1eotRfunRkKjJWG80yVGTcAzUQxnws0sMspRs5EE=", "pom": "sha256-YTpMXFcIVS2BC42Edc1tBH0gH97IwfDLUbxxzxpA42A=" }, "io/gitlab/arturbosch/detekt#detekt-rules-empty/1.23.8": { "jar": "sha256-iIZBEUeJrEMpLUSDYiFWXaR6kij3+33+DGs/lOf1ivo=", "module": "sha256-VpBNJ1Is9DP185NLv/x+ASl2w/qlOsIjVHRkGExgoLg=", "pom": "sha256-poQXlxenotOzZ6Ni2IKaGgUnQ05EB8ynF5QQRw/QSD0=" }, "io/gitlab/arturbosch/detekt#detekt-rules-errorprone/1.23.8": { "jar": "sha256-/uHudlFopYlhYuvjmzT6QhwKW3aavI8VD541lpEqScQ=", "module": "sha256-GX8CnrvhxrDAHDIYkSmWeTUf0qE1hmgoa4i4rao5s9A=", "pom": "sha256-lOerkgYUNz+9J30k+C1v1Z174nUy940cRrZvwW7/29Q=" }, "io/gitlab/arturbosch/detekt#detekt-rules-exceptions/1.23.8": { "jar": "sha256-iJ/8cq/wYkEy4LEJMjMscdJPW6uPHBDCC/WsWSyiyvY=", "module": "sha256-hlQWj6pWXed/GYw8XebD89uCXmef7cWrMVvokq2Ua3E=", "pom": "sha256-4PKtnMpBnrN4vGu1P+jzHOQx8Q0pQzd3bE2sxCJPpcc=" }, "io/gitlab/arturbosch/detekt#detekt-rules-naming/1.23.8": { "jar": "sha256-7RmSsb2wSUVngFsKgUXOl49G5a10Pi6Dv4S9XsZhuus=", "module": "sha256-m0Kgrxw9dl05uACWHlGCtINpUA7jOhqEB/B3VKuMW/A=", "pom": "sha256-fy4xrRpHCOg0bh29xZz14tt8WrIxqwlNHacXJaZYS5c=" }, "io/gitlab/arturbosch/detekt#detekt-rules-performance/1.23.8": { "jar": "sha256-2/Xgbi+himz1euJiHkJ0wWO7W7s1Gl23X3JPhvItQ2s=", "module": "sha256-BmoZa4/zQtVm24YK9D9/7lEMlUTNZmpw4dQkOzP4PZ8=", "pom": "sha256-RaraEjayz9pk+QyilMtcugb1lIBD1Sr1VXz2MbLaSG4=" }, "io/gitlab/arturbosch/detekt#detekt-rules-style/1.23.8": { "jar": "sha256-ryZEwibSugZ56oa8pS75DI2fZPRG76nXTL0OYxGwOKg=", "module": "sha256-FTHiCvvECye9YoSALKWTXwNX19lRzWQgpH9y+yhjDno=", "pom": "sha256-7kcVqGMC4EjwRAfAp1tpUaHHksm+UEuRE0eULljhLH4=" }, "io/gitlab/arturbosch/detekt#detekt-rules/1.23.8": { "jar": "sha256-o+5Rbzg3+8AdXDuG9dx759uoE0W/xXxOtYrxL2kjpWA=", "module": "sha256-vzP+AgL02uE4kMF0qYIFGWp7M4KMgOnqceKIUYK/MHk=", "pom": "sha256-si4+rr2LAiDk5bCiVXAF1V0Wn9ja1TA12QW9L73orQM=" }, "io/gitlab/arturbosch/detekt#detekt-tooling/1.23.8": { "jar": "sha256-fpPpojtHj3ASiJOwZ0hnP5EhALfvAwQNfQMx4m0w0JI=", "module": "sha256-o8avD6bmk+YG8b8cQAOZHMKf16sydfOhCZEExxgIli4=", "pom": "sha256-rkS7B0IlXL/Gzj37bvtm077E4dt4emidSQ64u6IU5ZE=" }, "io/gitlab/arturbosch/detekt#detekt-utils/1.23.8": { "jar": "sha256-91/X6SS5Jn2exmGFnKkTEC3kqPWJWwloXOEHl9wm0FY=", "module": "sha256-SjYqCzBBPaN1u0mYMjseG5rg2+M05kngUYs16ecBX4M=", "pom": "sha256-vcv9/v+3y6mTOofXVl59ryHF/rpkUc2f3r7RstXQjcw=" }, "io/grpc#grpc-api/1.57.2": { "jar": "sha256-QrcuZXLAhAVaw84D5u/kM+sF72ILPa9RNqQ1n8csw+E=", "pom": "sha256-x99FUaZPAoKnZugJUU1COEUKdCkFX5x3GIgdFqMQoCY=" }, "io/grpc#grpc-api/1.69.1": { "jar": "sha256-qNPW3McfOrYT1miEIoK0iL3ZPT6ZoO9dyn7ub6c0woM=", "pom": "sha256-vq8uR11cRdBjTU0yS/hNsqjWqSkilx5vfcJ+hRxCkH8=" }, "io/grpc#grpc-context/1.27.2": { "pom": "sha256-DyErFOvYNMvtm9iGml1snBeY7OtRLH/MKNqJ9vik7dg=" }, "io/grpc#grpc-context/1.57.2": { "jar": "sha256-m4rIjZzvKBna/+1729LyJoAjfUgsbGcf4C022j8IzwA=", "pom": "sha256-iSf3fWOB4kSHaCcIGWpspyg2i4/XzrsQT9kyS2sSSRc=" }, "io/grpc#grpc-context/1.69.1": { "jar": "sha256-Re+VuMFYqLW906y2e55oLvJUFLsUj0iOyEdDirZHFdQ=", "pom": "sha256-beKbzqslob0L4R7qhGjQ4/HAxHbQpTMhW0/eHIKEtXA=" }, "io/grpc#grpc-core/1.57.2": { "jar": "sha256-WhAHCr/rSWbsTVgJYdzE5/afqDqyUkL5LBdl77B7hgY=", "pom": "sha256-CpcgGv4Xh08DX4ol/7lwZ45Jqt8pksfZfG/5+x1dohE=" }, "io/grpc#grpc-core/1.69.1": { "jar": "sha256-UTUsra7L+aSkqkLZP28fxyjx/QGwUWgDg+0J5WMf+9A=", "pom": "sha256-loCY9KhFG7zT17iMaoq1t6dO+A397npgUvNS//eDssU=" }, "io/grpc#grpc-inprocess/1.69.1": { "jar": "sha256-t8asDjq/S41YJhDWMteUF7w9qBJU4aS89/Aejbe9Ve8=", "pom": "sha256-/rswpc8jgjfQdaOSPok5khg9rxcaHrQ0rPEyEUpff4o=" }, "io/grpc#grpc-netty/1.57.2": { "jar": "sha256-mAnUwQyU0R57KUbN61sohL4goJUQKJVE83Vp8CyHeiE=", "pom": "sha256-ixIWHPKqz785j7Wvw7DXOiGvIGulDD2Pe/T2xLN16/g=" }, "io/grpc#grpc-netty/1.69.1": { "jar": "sha256-Uqhu1m94kz6D0aP7cWKtFmdIlWTEVWNmt6NXnHAkpEc=", "pom": "sha256-jwZlDMkUKMxRMRG/676r95mUGF1yREsC5d+so4vkNxQ=" }, "io/grpc#grpc-protobuf-lite/1.57.2": { "jar": "sha256-/EkX3F1BmsgQ+z8nUjwU514f5QNyFU+rKTJCFe5qlVo=", "pom": "sha256-YHeMHqQHo7oKfw8J3wmegnInjoq8KYIsnPUOGgUvG3U=" }, "io/grpc#grpc-protobuf-lite/1.69.1": { "jar": "sha256-wp+Q+t88diD5NZokPAZ92FtzvXZbKPPZXfkQrC0zFVU=", "pom": "sha256-Jj8fhE11bBXbX6uIaZZeM3IrP0/pB/4ciFqQ4EZGFzE=" }, "io/grpc#grpc-protobuf/1.57.2": { "jar": "sha256-MWMNip6fCKlZhiAV4wpIY5CL42gMOmhvTB8I0v/q9wY=", "pom": "sha256-xeIpKAIFOXfwRhCxcEhKmh6mrxVBwUSyfRiECsVE+p0=" }, "io/grpc#grpc-protobuf/1.69.1": { "jar": "sha256-TFLvlI+4mHo7qn1GujYre/MH3TxR8pJBzVxZg5igEN8=", "pom": "sha256-v0ynbSrORNlpRxT7zz/XKcmO8uWGOrkgpIPAUqIvRCM=" }, "io/grpc#grpc-services/1.57.2": { "jar": "sha256-BXpDumR4M3VqsvhRusgKO5+NzgJvwawfF9TzFWSPQXI=", "pom": "sha256-dHYBoDpYoYl5gZrFLO/8WGuPnGprQi4XI/Mmr0iBPrk=" }, "io/grpc#grpc-stub/1.57.2": { "jar": "sha256-hNKvEnGRaPdjdfKv39brUTOoZe26kkTUDmuWjjrd4dM=", "pom": "sha256-IVnmFKh5R3XrmOLhyFg0q05ZEb4cSnXHFjqZPpyJK6w=" }, "io/grpc#grpc-stub/1.69.1": { "jar": "sha256-45xjJz1TBS6+n2ONiumBdnNexWcyjZoXCSzdtvI5uMI=", "pom": "sha256-9a2BV+xA2KI0djKWXWoD0YpIrUYl8y62SzenpHDOU7s=" }, "io/grpc#grpc-util/1.69.1": { "jar": "sha256-3Vl71nXqoELz41eGSNkFDIE8RZXF3mhp753btEkAYDE=", "pom": "sha256-0g00aMt01WvlXtPUb2PKOO5LygkY2arXJ3pEj24HpbQ=" }, "io/netty#netty-buffer/4.1.110.Final": { "jar": "sha256-RtdOeRJarMBVwx8YFS/cXUpWmqjWAJEgPQuqgzlzrDw=", "pom": "sha256-cQrBnMAc2A32vpo/qtPCIrShoy9LVRN74HtgmdXaNWI=" }, "io/netty#netty-buffer/4.1.93.Final": { "jar": "sha256-AHx9nDeN8C05BWfQ1931Qv/dsCG3MT2/UCOSET/6uwg=", "pom": "sha256-g/vFTitzuG1Vsgj2GNGioVaRDsFG9+zldWUAe3UK3Xg=" }, "io/netty#netty-codec-http/4.1.110.Final": { "jar": "sha256-3A1q9QVGMKcP8O81TyCqem5Gc4yfxWNu09T+d+OL1I0=", "pom": "sha256-Ua6ZCvFKMh2209aIS5F7fUNj62Dd3A8Uk7GAIaFC560=" }, "io/netty#netty-codec-http/4.1.93.Final": { "jar": "sha256-2s94znirLSlXAyXbTNJFHqWJY5gH3pWIGg+nFVqea1U=", "pom": "sha256-o9r/8HG20oToBj2WhD3iu4PPO4iergzJ4K22SlejG4I=" }, "io/netty#netty-codec-http2/4.1.110.Final": { "jar": "sha256-tUbHVEWkh7t7zVqUd5yuzOM1gs974xuLOfwOZbHuJvw=", "pom": "sha256-KdL2wmw8yp/oOTZxcH/o75w+MQIKLf4GuCxCZJnCWDc=" }, "io/netty#netty-codec-http2/4.1.93.Final": { "jar": "sha256-2WzAkEWhNBxtR0lDUqomO4e3L7HS6p7KFhqnOCC/6Ls=", "pom": "sha256-CEQztC1UH3rEtZKH3SUyhc/aOj1l3nLnNou37D02cnE=" }, "io/netty#netty-codec-socks/4.1.110.Final": { "jar": "sha256-l2BSo8m7KAvG2Z86KeZARnfPlYw94FsgUJPTjABriAw=", "pom": "sha256-/+V7MWGR3U+WvuZsVwnBPL207KsIXAEMjbDGqoCav2w=" }, "io/netty#netty-codec-socks/4.1.93.Final": { "jar": "sha256-DqR7W6I8odqOuRRsj8dVwScUFGM7Hivizh33ZLoP/yo=", "pom": "sha256-jNgW7ZkalGBBurTLJL2cjkHuBpJRJRHy2DzvU462Bdc=" }, "io/netty#netty-codec/4.1.110.Final": { "jar": "sha256-nszOmo2Ce7jOhPnDGD/sWL0clqUQEM9xEpd0YDSvNwE=", "pom": "sha256-qAa7U2uzI2Zbr/fNEiPysnKi1HF6tPmxI2EIbarl3z4=" }, "io/netty#netty-codec/4.1.93.Final": { "jar": "sha256-mQw3gWjcY2TG/1aXAfTy8SL//omYs+GJ66TE2GjtEIQ=", "pom": "sha256-Gc3tJnoHDf8avJ0Cm1UvrSYqzBq6XGxnsiePyhE6Jqs=" }, "io/netty#netty-common/4.1.110.Final": { "jar": "sha256-mFHsZlSLng1BFkzpiUPN1LvjBfaN29JOrlLkUBoNexo=", "pom": "sha256-fUF/UzUwTa4eoIoGWGA4yD/orYTB01uqFe0RkhzveSA=" }, "io/netty#netty-common/4.1.93.Final": { "jar": "sha256-RDuzFlmfsW47rrovtYiBgU1/8LevF2/nbjgHGm6G+MA=", "pom": "sha256-QtiDsT6zjKv1SWFkYsXzMfUzO/DI/JIVdE+DwBgKT2s=" }, "io/netty#netty-handler-proxy/4.1.110.Final": { "jar": "sha256-rVSrT+nEfvPnI9cSURJttT6NtUOHGtuer8lERlOe/1I=", "pom": "sha256-xhPLTn4G9C76MduNiyoznti/QfAMRtONCQmkwGxlbc8=" }, "io/netty#netty-handler-proxy/4.1.93.Final": { "jar": "sha256-KsX3+++gtz73g4iQaTRNVRVQWhSyMDvmk8UALEht8rQ=", "pom": "sha256-bcUNoOZ/WXgSh0+B6qRUBPfQdrgZnqkIiTKoXBthAkU=" }, "io/netty#netty-handler/4.1.110.Final": { "jar": "sha256-1aCNfeNkkS5ChZaN5NTM4/AdpLsEjVxpN+Xyrx+OFIo=", "pom": "sha256-TUPBPRT1Y1oviw1QlNejHFCe4PUsck66DvMM/+PqFVU=" }, "io/netty#netty-handler/4.1.93.Final": { "jar": "sha256-Tl9WOuFO1xM4GBbVgvX8/QYVrvspIDSGzft4LYoAoCs=", "pom": "sha256-hKFSXKwLR1nvrvKZekf+Gbm1ZC+Sc/oP1YoudsegWf4=" }, "io/netty#netty-parent/4.1.110.Final": { "pom": "sha256-aFra83Nmb8FUJ8gQ+K/zpP4ZSpfH7XS2nQfFSPDULxw=" }, "io/netty#netty-parent/4.1.93.Final": { "pom": "sha256-sQnLdvN1/tuKnvdaxYBjFw3rfqLd0CT0Zv723GXN/O4=" }, "io/netty#netty-resolver/4.1.110.Final": { "jar": "sha256-oum0rnyqkvxb10fhHR3sINgbGPwAlZVUMCJErFxWznA=", "pom": "sha256-ZV80GS6MdhizxaeeSI5NqjXe9BsNFtRfo2Ujw7TJ9kE=" }, "io/netty#netty-resolver/4.1.93.Final": { "jar": "sha256-5Zdwtm6Bgi5dERrE5UTX6wxUPgooX1JijlOUGs2O11k=", "pom": "sha256-WzUMPJHp5V0py+aM/k7yEWzB8DKGd+v59hW6twgsefQ=" }, "io/netty#netty-transport-native-unix-common/4.1.110.Final": { "jar": "sha256-UXF7t0cRQZUDkMZxOkSf2xBU0H5gc37n3acIN5bN7kg=", "pom": "sha256-6hjOBMmpesDFH045exhSKf2VmX6QsRM5rc98UZRtU9g=" }, "io/netty#netty-transport-native-unix-common/4.1.93.Final": { "jar": "sha256-d0FlocTbqssX+cGtZms1aaallxWugo58PUdwP0eaU+c=", "pom": "sha256-Fbwltn/wpJJysnDvK4z/1iAFfKFssp3/etVmGtyirhI=" }, "io/netty#netty-transport/4.1.110.Final": { "jar": "sha256-pC3Wg5DKFLT/LUBiiglsdkhbStt8GWAtUokyGgZp5wQ=", "pom": "sha256-MPXaDnZG8YQNYy+IYVyLnYIFSZ1oVZucRUezsEoGg80=" }, "io/netty#netty-transport/4.1.93.Final": { "jar": "sha256-paeAGbwc1D28PHt83TgBkSyibR9Jj7VgUU/uSXhkupY=", "pom": "sha256-DdYqDrPLHqABpNBCbk9cCN8ccNkmVnW/+lxYNhNCLUM=" }, "io/opencensus#opencensus-api/0.31.0": { "jar": "sha256-cCulXXjznVUZXc8EH9+qt6dJCprEUBNUJIftnk06TSM=", "pom": "sha256-m0eVkefD4KtFOB+gQ6kWV4Fb3Yw1k68BDHrDb0yQWRk=" }, "io/opencensus#opencensus-proto/0.2.0": { "jar": "sha256-DBktRR6d106Ychsn0C8OK2vKRLUVY7Xavy4hH3o+vxM=", "pom": "sha256-twh5B5IPyKgVNGhrLxorMxEnr5fwFau9s3hqUfP6HlI=" }, "io/perfmark#perfmark-api/0.26.0": { "jar": "sha256-t9I+k6NFN84zJwgmmg0UBHiKW14ZSegvVTX85Rs+qVs=", "module": "sha256-MdgyMyR0zkgVD1uuADNDMZE28zav0QdqKJApMZ4+qXo=", "pom": "sha256-ft7khhbhe2Epfq46gutIOoXlbSVnkpN4qkbzCpUDIto=" }, "io/perfmark#perfmark-api/0.27.0": { "jar": "sha256-x7R4UD7FJOVd8ZtCTUbSfIporrgBZk+t1PBptx9S0PY=", "module": "sha256-n2xOamK43v0UFzrNt9spPQhjU7Ikkj7vYpP1gWGJPMo=", "pom": "sha256-IsF1wsGCNmdjDITnMiV2f1lwSS2ObL/7gaZXXbpHLSY=" }, "jakarta/activation#jakarta.activation-api/1.2.1": { "jar": "sha256-iwoPUvqLBcVDGSGgY+2GbvqkHa3y46fuPhlh8rDZZFs=", "pom": "sha256-QlhcsH3afyOqBOteCUAGGUSiRqZ609FpQvvlaf8DzTE=" }, "jakarta/inject#jakarta.inject-api/2.0.1": { "jar": "sha256-99yYBi/M8UEmq7dRtk+rEsMSVm6MvchINZi//OqTr3w=", "pom": "sha256-5/1yMuljB6V1sklMk2fWjPQ+yYJEqs48zCPhdz/6b9o=" }, "jakarta/xml/bind#jakarta.xml.bind-api-parent/2.3.2": { "pom": "sha256-FaVbfVN8n5lwrq0o0q+XwFn2X/YQL3a70p8SR92Kbfs=" }, "jakarta/xml/bind#jakarta.xml.bind-api/2.3.2": { "jar": "sha256-aRVjBAeb3u2fwK47OTifGbPMS6REO8gFCJlTlOrXQuo=", "pom": "sha256-tTeziNurTMBpC50vsMdBJNZyUxc0VnrPblMTDqsTGtY=" }, "javax/annotation#javax.annotation-api/1.3.2": { "jar": "sha256-4EulGVvNVV3JVlD3zGFNFR5LzVLSmhC4qiGX86uJq5s=", "pom": "sha256-RqSiUcpAbnjkhT16K66DKChEpJkoUUOe6aHyNxbwa5c=" }, "javax/inject#javax.inject/1": { "jar": "sha256-kcdwRKUMSBY2wy2Rb9ickRinIZU5BFLIEGUID5V95/8=", "pom": "sha256-lD4SsQBieARjj6KFgFoKt4imgCZlMeZQkh6/5GIai/o=" }, "junit#junit/4.13.2": { "jar": "sha256-jklbY0Rp1k+4rPo0laBly6zIoP/1XOHjEAe+TBbcV9M=", "pom": "sha256-Vptpd+5GA8llwcRsMFj6bpaSkbAWDraWTdCSzYnq3ZQ=" }, "net/java#jvnet-parent/1": { "pom": "sha256-KBRAgRJo5l2eJms8yJgpfiFOBPCXQNA4bO60qJI9Y78=" }, "net/java#jvnet-parent/3": { "pom": "sha256-MPV4nvo53b+WCVqto/wSYMRWH68vcUaGcXyy3FBJR1o=" }, "net/java/dev/jna#jna-platform/5.6.0": { "jar": "sha256-ns6ovysbOZY5OdGLcEZO72DFCP7Ygg+dyroMNVGOq/c=", "pom": "sha256-G+s1y0GE5skGp+Murr2FLdPaCiY5YumRNKuUWDI5Tig=" }, "net/java/dev/jna#jna/4.2.2": { "jar": "sha256-HzivVOBsbm9tvzm6LAUrlS3qXd20hxEns0Y53esRvb4=", "pom": "sha256-q9ZtmMmz3dgvnXnK+k1mH0G7xZXSyB2nrz/AGRCa97c=" }, "net/java/dev/jna#jna/5.6.0": { "jar": "sha256-VVfiNaiqL5dm1dxgnWeUjyqIMsLXls6p7x1svgs7fq8=", "pom": "sha256-X+gbAlWXjyRhbTexBgi3lJil8wc+HZsgONhzaoMfJgg=" }, "net/ltgt/gradle/incap#incap/0.2": { "jar": "sha256-tiW5gGsPHkvHouNFcRlIjePNV+og/u3VE9sHClc6T/0=", "pom": "sha256-GkoIoeiNMgUs2C3C90CzTTBI4sDmp8K/4jCe0Adx9zo=" }, "net/sf/kxml#kxml2/2.3.0": { "jar": "sha256-8mTdn3mh/eEM5ezFMiHv8kvkyTMcgwt9UvLwintjPeI=", "pom": "sha256-Mc5gb06VGJNimbsNJ8l4+mHhhf0d58mHT+lZpT40poU=" }, "org/apache#apache/13": { "pom": "sha256-/1E9sDYf1BI3vvR4SWi8FarkeNTsCpSW+BEHLMrzhB0=" }, "org/apache#apache/15": { "pom": "sha256-NsLy+XmsZ7RQwMtIDk6br2tA86aB8iupaSKH0ROa1JQ=" }, "org/apache#apache/18": { "pom": "sha256-eDEwcoX9R1u8NrIK4454gvEcMVOx1ZMPhS1E7ajzPBc=" }, "org/apache#apache/21": { "pom": "sha256-rxDBCNoBTxfK+se1KytLWjocGCZfoq+XoyXZFDU3s4A=" }, "org/apache#apache/23": { "pom": "sha256-vBBiTgYj82V3+sVjnKKTbTJA7RUvttjVM6tNJwVDSRw=" }, "org/apache#apache/31": { "pom": "sha256-VV0MnqppwEKv+SSSe5OB6PgXQTbTVe6tRFIkRS5ikcw=" }, "org/apache/commons#commons-compress/1.21": { "jar": "sha256-auz9VFlyillWAc+gcljRMZcv/Dm0kutIvdWWV3ovJEo=", "pom": "sha256-Z1uwI8m+7d4yMpSZebl0Kl/qlGKApVobRi1Mp4AQiM0=" }, "org/apache/commons#commons-parent/34": { "pom": "sha256-Oi5p0G1kHR87KTEm3J4uTqZWO/jDbIfgq2+kKS0Et5w=" }, "org/apache/commons#commons-parent/35": { "pom": "sha256-cJihq4M27NTJ3CHLvKyGn4LGb2S4rE95iNQbT8tE5Jo=" }, "org/apache/commons#commons-parent/52": { "pom": "sha256-ddvo806Y5MP/QtquSi+etMvNO18QR9VEYKzpBtu0UC4=" }, "org/apache/commons#commons-parent/69": { "pom": "sha256-1Q2pw5vcqCPWGNG0oDtz8ZZJf8uGFv0NpyfIYjWSqbs=" }, "org/apache/httpcomponents#httpclient/4.5.6": { "jar": "sha256-wD+BMZXnqA42CNDd2NqAshaWpMkqaiKYhlvxSQcVUcc=", "pom": "sha256-fvwSQec+f7smi/0zJC0R69PKBwYdfYXyli3DKg8LiFU=" }, "org/apache/httpcomponents#httpcomponents-client/4.5.6": { "pom": "sha256-sEK0HyOR7bANNff05Qmu0hI2SMHSRs5Y0Pe5Bcn+H3M=" }, "org/apache/httpcomponents#httpcomponents-core/4.4.16": { "pom": "sha256-8tdaLC1COtGFOb8hZW1W+IpAkZRKZi/K8VnVrig9t/c=" }, "org/apache/httpcomponents#httpcomponents-parent/10": { "pom": "sha256-yq+WfZSvshdT82CCxghiBr0fSIJf9ZaTLM66crZdOfo=" }, "org/apache/httpcomponents#httpcomponents-parent/11": { "pom": "sha256-qQH4exFcVQcMfuQ+//Y+IOewLTCvJEOuKSvx9OUy06o=" }, "org/apache/httpcomponents#httpcore/4.4.16": { "jar": "sha256-bJs90UKgncRo4jrTmq1vdaDyuFElEERp8CblKkdORk8=", "pom": "sha256-PLrYSbNdrP5s7DGtraLGI8AmwyYRQbDSbux+OZxs1/o=" }, "org/apache/httpcomponents#httpmime/4.5.6": { "jar": "sha256-CysRAsGNPH4Fp3IUubdQGm9gVhdK5WBODiVndu2nVT4=", "pom": "sha256-37/W/+KnhMqYF8RjZap/ileDILgFveOdb1WgsJ2KqMo=" }, "org/bouncycastle#bcpkix-jdk18on/1.79": { "jar": "sha256-NjmiTd+bpLfroGWbRHcOkeuoFkIYiOVx8oWq3v5TLNY=", "pom": "sha256-NeSfQTTeKsMmw6UKJXYsu021bzgC+j9zDMhbZTrQmHs=" }, "org/bouncycastle#bcprov-jdk18on/1.79": { "jar": "sha256-DYHswxJFNrU5vOmqP+liG3+Eyc7jcbY1pbMceLeasdo=", "pom": "sha256-2PGgaxSddG6dmN5U4veqmy62E/s1ymfYrjls6qxmHuQ=" }, "org/bouncycastle#bcutil-jdk18on/1.79": { "jar": "sha256-xwuIraWJOMvC8AXUAykFQHi8+hFJ5v/APpJC62qyGDY=", "pom": "sha256-4kwftM8WBUBaaYjp5NbksuH0OT/HOompRSrmJe4xHQI=" }, "org/checkerframework#checker-compat-qual/2.5.3": { "jar": "sha256-12ua/qYcfAgpCAI/DLwUJ/q5q9LfkVyLij56UJvMvG0=", "pom": "sha256-9/zayZ6zPRafbVKjVUHCL/0U9FirvPVvnEnuFIZZjJw=" }, "org/checkerframework#checker-qual/3.33.0": { "jar": "sha256-4xYlW7/Nn+UNFlMUuFq7KzPLKmapPEkdtkjkmKgsLeE=", "module": "sha256-6FIddWJdQScsdn0mKhU6wWPMUFtmZEou9wX6iUn/tOU=", "pom": "sha256-9VqSICenj92LPqFaDYv+P+xqXOrDDIaqivpKW5sN9gM=" }, "org/checkerframework#checker-qual/3.37.0": { "jar": "sha256-5M4TdswnNeHd4iC2KtCRP1EpdwTarRVaM/OGvF2w2fc=", "module": "sha256-clinadyqJrmBVNIp2FzHLls2ZrC8tjfS2vFuxJiVZjg=", "pom": "sha256-AjkvvUziGQH5RWFUcrHU1NNZGzqr3wExBfXJLsMstPA=" }, "org/checkerframework#checker-qual/3.41.0": { "jar": "sha256-L58kW/aOQlnWEIlPJAbcH2Nj3GOTAr1WboJy5PRUEXI=", "module": "sha256-s4ZywX9FUnayEO00Av+S3OZmdwsajGEMfMNK1UxTLSA=", "pom": "sha256-XHOwdwVAhCzwagHSZLu4muXiSGadydqA6GHoIz3UZ1s=" }, "org/checkerframework#checker-qual/3.43.0": { "jar": "sha256-P7wumPBYVMPfFt+auqlVuRsVs+ysM2IyCO1kJGQO8PY=", "module": "sha256-+BYzJyRauGJVMpSMcqkwVIzZfzTWw/6GD6auxaNNebQ=", "pom": "sha256-kxO/U7Pv2KrKJm7qi5bjB5drZcCxZRDMbwIxn7rr7UM=" }, "org/codehaus/groovy#groovy/3.0.22": { "jar": "sha256-ySySxLmxg/mYG6c5nzZZLl461vTNrHEBtaIswXmY0T8=", "pom": "sha256-Ubcx5c/xIe/W0yD0qAHNYWhpxb7U6cDjU7KQYLHSNV8=" }, "org/codehaus/mojo#animal-sniffer-annotations/1.23": { "jar": "sha256-n/5Sa/Q6Y0jp2LM7nNb1gKf17tDPBVkTAH7aJj3pdNA=", "pom": "sha256-VhDbBrczZBrLx6DEioDEAGnbYnutBD+MfI16+09qPSc=" }, "org/codehaus/mojo#animal-sniffer-annotations/1.24": { "jar": "sha256-xyDm5by+ay9I3tdaR7zNt2Pu3nnRQzAQLg01Lj2J7ZI=", "pom": "sha256-iEhPYKatQjipf+us8rMz6eCMF4uPGAoFo+2/9KOKg24=" }, "org/codehaus/mojo#animal-sniffer-parent/1.23": { "pom": "sha256-a38FSrhqh/jiWZ81gIsJiZIuhrbKsTmIAhzRJkCktAQ=" }, "org/codehaus/mojo#animal-sniffer-parent/1.24": { "pom": "sha256-Sd2rQ8g2HcLvDB/4fLWQ+nIxcCq59i4m1RLcGKHxzQQ=" }, "org/codehaus/mojo#mojo-parent/74": { "pom": "sha256-FHIyWhbwsb2r7SH6SDk3KWSURhApTOJoGyBZ7cZU8rM=" }, "org/codehaus/mojo#mojo-parent/84": { "pom": "sha256-L+UQYYsvYPzV8vuCvEssLDRASNdPML5xn8uGgp7orDA=" }, "org/eclipse/ee4j#project/1.0.2": { "pom": "sha256-dJWgenl+iOQ8O8GodCG9ix/FXjIpH6GOTjLYAx3chz8=" }, "org/eclipse/ee4j#project/1.0.5": { "pom": "sha256-kWtHlNjYIgpZo/32pk2+eUrrIzleiIuBrjaptaLFkaY=" }, "org/eclipse/ee4j#project/1.0.6": { "pom": "sha256-Tn2DKdjafc8wd52CQkG+FF8nEIky9aWiTrkHZ3vI1y0=" }, "org/glassfish/jaxb#jaxb-bom/2.3.2": { "pom": "sha256-oQGLtUZ47Z9ayy96QITjhf9RAgH06dv1913GpnX2a+c=" }, "org/glassfish/jaxb#jaxb-runtime/2.3.2": { "jar": "sha256-5uCh6J+2/3hieeagCC1c71LcLr5nBT0EGABzdlK0/Rs=", "pom": "sha256-lEilrX+mimCD375PQsjIPggrkgKhBUAfxo6UTCZUizQ=" }, "org/glassfish/jaxb#txw2/2.3.2": { "jar": "sha256-SmqfSDOI1GG4GqmijGhbi3TAWXmTvxiEsE7dvKlfSP4=", "pom": "sha256-p53QAvsDgYP/KGomNb4uaMEDuH4OZHF9jUS/0Bf9M+o=" }, "org/hamcrest#hamcrest-core/1.3": { "jar": "sha256-Zv3vkelzk0jfeglqo4SlaF9Oh1WEzOiThqekclHE2Ok=", "pom": "sha256-/eOGp5BRc6GxA95quCBydYS1DQ4yKC4nl3h8IKZP+pM=" }, "org/hamcrest#hamcrest-library/1.3": { "jar": "sha256-cR1kUi+exBCYO9MQk0KW2hNL5CVKElCAoEFuwXjfrRw=", "pom": "sha256-HOtL+w8JiuKbk1BEsjY+ETIzE/4+0gVd+LeXN9UFYnc=" }, "org/hamcrest#hamcrest-parent/1.3": { "pom": "sha256-bVNflO+2Y722gsnyelAzU5RogAlkK6epZ3UEvBvkEps=" }, "org/jcommander#jcommander/1.85": { "jar": "sha256-+nVS0oMaKyB3jYaFHQk+3KaPvAp395K2IjEQ5PrmenA=", "module": "sha256-v6NtKUfZh28yp0B5Y5nne7mMcyiZe0W9JrP32Ss3pUo=", "pom": "sha256-5hQBz9Hk8g4qQEiHLkXu7ncWodb5n2aKR3cSLdKILEg=" }, "org/jetbrains#annotations/13.0": { "jar": "sha256-rOKhDcji1f00kl7KwD5JiLLA+FFlDJS4zvSbob0RFHg=", "pom": "sha256-llrrK+3/NpgZvd4b96CzuJuCR91pyIuGN112Fju4w5c=" }, "org/jetbrains#annotations/23.0.0": { "jar": "sha256-ew8ZckCCy/y8ZuWr6iubySzwih6hHhkZM+1DgB6zzQU=", "pom": "sha256-yUkPZVEyMo3yz7z990P1P8ORbWwdEENxdabKbjpndxw=" }, "org/jetbrains/intellij/deps#trove4j/1.0.20200330": { "jar": "sha256-xf1yW/+rUYRr88d9sTg8YKquv+G3/i8A0j/ht98KQ50=", "pom": "sha256-h3IcuqZaPJfYsbqdIHhA8WTJ/jh1n8nqEP/iZWX40+k=" }, "org/jetbrains/intellij/deps/kotlinx#kotlinx-coroutines-bom/1.8.0-intellij-14": { "pom": "sha256-HUFjTSKbHviGsEg6F+S225NrRkP5QBqzS+UWCc+6YD0=" }, "org/jetbrains/intellij/deps/kotlinx#kotlinx-coroutines-core-jvm/1.8.0-intellij-14": { "jar": "sha256-7wQ4Vu+POHA5FpYPrBacNZ2Y1f69Vx1n/M3+dbo3jeM=", "module": "sha256-Z3M5jeX7L0MyuzdL5AGgNdLxTBM4/rNEYR81hFmZx/c=", "pom": "sha256-zgsI7fz5rY8Sp2+ZcAUQqOX0Md4tqrFvnwOsUkJbK64=" }, "org/jetbrains/kotlin#abi-tools-api/2.3.10": { "jar": "sha256-E2nLVCrmR6nVVJ5thkkh6g+GApdJWRmXteWqFhyXGIs=", "pom": "sha256-x12MiniT5DijbBZeA1I+uHRDZ6wNaV4sdrZ48LAjnE8=" }, "org/jetbrains/kotlin#abi-tools/2.3.10": { "jar": "sha256-lbCSwjPTRrHTScN0ovZpHozCx5dLjCI8blhlVo5r4xw=", "pom": "sha256-dF+mCZPgkwSOup63kIAcUPDvs4NKK9GDTZAbXwCuHdY=" }, "org/jetbrains/kotlin#kotlin-bom/1.8.22": { "pom": "sha256-yNeU63YYiNNDaeZ33o6roLAfnop1bPv/UyFcz6XFjD8=" }, "org/jetbrains/kotlin#kotlin-build-tools-api/2.3.10": { "jar": "sha256-EYZyC5EGhN+drZy5YBXM8ZF27FZVzrCbAfvEUjC9H2Q=", "pom": "sha256-3pBa7tBWHZduxSEU8vPk5mls05Mg9DqxFvF/79c9U8I=" }, "org/jetbrains/kotlin#kotlin-build-tools-compat/2.3.10": { "jar": "sha256-2CMtB+usVuEXJqBmYUPdSmF77b46C/HxQCD4BF2KybM=", "pom": "sha256-D4sDOmFBIvxEBuu+rSSKRYoEkKikvJfYSpb8U3DD88Y=" }, "org/jetbrains/kotlin#kotlin-build-tools-impl/2.3.10": { "jar": "sha256-MvAqavcrjgyC8mNDto4NHaHIRIJ0eP6y+p3EDfX5u1o=", "pom": "sha256-21a2yydSjXKzGHKFbO2rCOx7GBJ0VMBux3iAhQQR21g=" }, "org/jetbrains/kotlin#kotlin-compiler-embeddable/2.0.21": { "jar": "sha256-n6jN0d4NzP/hVMmX1CPsa19TzW2Rd+OnepsN4D+xvIE=", "pom": "sha256-vUZWpG7EGCUuW8Xhwg6yAp+yqODjzJTu3frH6HyM1bY=" }, "org/jetbrains/kotlin#kotlin-compiler-embeddable/2.2.0": { "jar": "sha256-svdD6luhL2ng815djUYGnXTI4oYQh1SKfg4Up4S8TPE=", "pom": "sha256-FqFd0ZfPJBNJT3iMuWFE2aFGJnw9b38cFbejweBSNGo=" }, "org/jetbrains/kotlin#kotlin-compiler-embeddable/2.3.10": { "jar": "sha256-rGoYJ4U0U4C121CF2+6j9fDpJeLhAOVKsFm1eH1PbkA=", "pom": "sha256-/i/NN7clxYZIc8/3jOZNEyBuFOCPS5guEVD2jJwOiW0=" }, "org/jetbrains/kotlin#kotlin-compiler-runner/2.3.10": { "jar": "sha256-CpGS+4AlHMrRzb8sq6KEiOjUaGnS5eSVfKF5wnwKTuA=", "pom": "sha256-4CtYvQDZCjExN9L18ZIZ2tt3FXVebd1t74mYpa/RgCc=" }, "org/jetbrains/kotlin#kotlin-daemon-client/2.3.10": { "jar": "sha256-0hpvd9mAOmFebRUIVzd+EKnox80Grm4cHrmZIP2ji5M=", "pom": "sha256-sOpstyMer+j7oN+zf8MZhJkED3TE00EmPtq2yE0Z2sA=" }, "org/jetbrains/kotlin#kotlin-daemon-embeddable/2.0.21": { "jar": "sha256-saCnPFAi+N0FpjjGt2sr1zYYGKHzhg/yZEEzsd0r2wM=", "pom": "sha256-jbZ7QN1gJaLtBpKU8sm8+2uW2zFZz+927deEHCZq+/A=" }, "org/jetbrains/kotlin#kotlin-daemon-embeddable/2.2.0": { "jar": "sha256-omzI4thhkZfMTRFb6ndm6aqODx54duoETuG58wVlRgE=", "pom": "sha256-vSrk4skWBWVKilUn2nV/KyJ2WA0fXq6+q9M0OvjVxGc=" }, "org/jetbrains/kotlin#kotlin-daemon-embeddable/2.3.10": { "jar": "sha256-NZcReADmCSO8rdAYf6XbyqZvgpAIKdaSXR6kHdfiejA=", "pom": "sha256-2Hj9fQeNtQmxK/bHQK6jHPckUxN6hMd1vU5SHJ77eP0=" }, "org/jetbrains/kotlin#kotlin-klib-abi-reader/2.3.10": { "jar": "sha256-nRYKDGxhKhFEhhRN8Fd7VXlfXaC4Z/4458uvYUzAQM0=", "pom": "sha256-YNkcF6pipglPHMEJUybmqWjCjCtUBtrOmiYNsBtEa5Q=" }, "org/jetbrains/kotlin#kotlin-klib-commonizer-embeddable/2.3.10": { "jar": "sha256-4jjzNyD2Tlk3klr3cvi9hzivM3EJvzYiPryjgqUyS2A=", "pom": "sha256-2RshiOZU+OhfcoQDbE+hzl8xJ/B60GeQn1kRBCQEafg=" }, "org/jetbrains/kotlin#kotlin-metadata-jvm/2.2.20": { "jar": "sha256-hSTqyQ9+jg8TZog/LGyCDJO/ph3z12hXyNPoA89nMV0=", "pom": "sha256-e2qAtqLSZ2oEIvaWg4EyMVQlUfYbMgxochz7nh9ZCdA=" }, "org/jetbrains/kotlin#kotlin-metadata-jvm/2.3.10": { "jar": "sha256-wd9bxlMLTHRLE3iNv4VApGmJj9+83mq8qRGRhPXTcHw=", "pom": "sha256-M4BDrrtxc3PkFFgTeehvS0ztfs+9HcKH3zPRX8FXm+I=" }, "org/jetbrains/kotlin#kotlin-reflect/1.6.10": { "jar": "sha256-MnesECrheq0QpVq+x1/1aWyNEJeQOWQ0tJbnUIeFQgM=", "pom": "sha256-V5BVJCdKAK4CiqzMJyg/a8WSWpNKBGwcxdBsjuTW1ak=" }, "org/jetbrains/kotlin#kotlin-reflect/1.8.21": { "jar": "sha256-imzVo88JKs7idM4sRE3Dbu/bYxV5hZ3U2FezMJpSnJE=", "pom": "sha256-OdeNszIizbsythjHxSI9RFfMGXQoVtEWqFCQ5KlvYjo=" }, "org/jetbrains/kotlin#kotlin-reflect/2.0.21": { "jar": "sha256-OtL8rQwJ3cCSLeurRETWEhRLe0Zbdai7dYfiDd+v15k=", "pom": "sha256-Aqt66rA8aPQBAwJuXpwnc2DLw2CBilsuNrmjqdjosEk=" }, "org/jetbrains/kotlin#kotlin-reflect/2.2.0": { "jar": "sha256-Iw2RwuQQ48/KOk3HPSVUVfYv9SqsCRozOXpuML3pG/c=", "pom": "sha256-3u2DHvy2Y+TPPVEh5a55byAeN7gT0sfWB7Xx+Khv5S4=" }, "org/jetbrains/kotlin#kotlin-script-runtime/2.0.21": { "jar": "sha256-nBEfjQit5FVWYnLVYZIa3CsstrekzO442YKcXjocpqM=", "pom": "sha256-lbLpKa+hBxvZUv0Tey5+gdBP4bu4G3V+vtBrIW5aRSQ=" }, "org/jetbrains/kotlin#kotlin-script-runtime/2.2.0": { "jar": "sha256-Ttl/0eDJux0JSO1eY8yRRWMTpZUYKVuSssFRSu9VYVA=", "pom": "sha256-5QdIv0Z09lRgPnsbuDBjTsmevZwzDeukytzuwHZ8u1Y=" }, "org/jetbrains/kotlin#kotlin-script-runtime/2.3.10": { "jar": "sha256-0LOwjs+JAcZhCqDo77Kds3Mb+9lq/U+YfMgXAWseAD4=", "pom": "sha256-wiijGFXV31s4bhGfFyjPqeQ9Lc23gTnCkqjT7SZ6xYc=" }, "org/jetbrains/kotlin#kotlin-scripting-common/2.3.10": { "jar": "sha256-fpFD0iyAs9AslpgkMbHJa6CR7/e/Q5CrS4lJ7O5D8oU=", "pom": "sha256-dYvXM25rfVJwtwbCgO+qcSKwT+cWzwmZbhUhWQ8zB0E=" }, "org/jetbrains/kotlin#kotlin-scripting-compiler-embeddable/2.3.10": { "jar": "sha256-cjlPfxS2o7mvwuHMncF6HZYQNXFVtMuazGY6O0QuwpA=", "pom": "sha256-A2sKrxS+1njEO47BXQGqXIRan+kjA3q8r0TKaWC3vk4=" }, "org/jetbrains/kotlin#kotlin-scripting-compiler-impl-embeddable/2.3.10": { "jar": "sha256-Ezqc4YWqR9FRphLyzFli0yuMpgX44ieh1/HhsJ4m7m4=", "pom": "sha256-NFeCP8HSlV8GBVk7m+nWUIbwwaEeGzdt9BHwqifkAuU=" }, "org/jetbrains/kotlin#kotlin-scripting-jvm/2.3.10": { "jar": "sha256-vyMqUZrwKVLhAQhvq9SIRDYldlbEXbCBeDysSxSWZLc=", "pom": "sha256-gzabR9onQj0BR+UZh/tY4YbjWt7KQUDxfysOu7uYPTc=" }, "org/jetbrains/kotlin#kotlin-serialization-compiler-plugin-embeddable/2.3.10": { "jar": "sha256-ddZuSpUHKpXnAkU+oH/MFHmsg1wWI9565znQROb182Y=", "pom": "sha256-v0Km4mvetFGIxtf4N7PXltGIeT8uoeuO6IvSNNwYI2M=" }, "org/jetbrains/kotlin#kotlin-stdlib-common/1.8.21": { "jar": "sha256-akTJ7MnXdU2elD+x41iMdNSj8Xhb5RB09J1sVyNoKnM=", "pom": "sha256-4ZpVd8vOqJcolw21MzyCZMjGmuci7recv0HV8LDJrmU=" }, "org/jetbrains/kotlin#kotlin-stdlib-common/1.9.0": { "jar": "sha256-KDJ0IEvXwCB4nsRvj45yr0JE1/VQszkqV+XKAGrXqiw=", "pom": "sha256-NmDTanD+s6vknxG5BjPkHTYnNXbwcbDhCdqbOg3wgqU=" }, "org/jetbrains/kotlin#kotlin-stdlib-common/2.0.21": { "module": "sha256-b134r2M2AKa5z7D8x2SvPVEZ83Zndne5G2rugWsdMKs=", "pom": "sha256-X0As+413MZW5ZwUBJMnom1+EsXJGThiUkpeJv1xMLyk=" }, "org/jetbrains/kotlin#kotlin-stdlib-common/2.2.0": { "module": "sha256-WPwNZk/Dpn5+a+n9vq7b0hLfo+Un90T4YeeSzacsDkc=", "pom": "sha256-U3q0BzqEelm6dtmaZFqGCbU4L/pdJZGjVLL6MR9JlzM=" }, "org/jetbrains/kotlin#kotlin-stdlib-common/2.3.10": { "module": "sha256-GrI+xfbZ3iNeKNRjo/IKJD96VY+aht3VOpSEyHfmeS8=", "pom": "sha256-xTKXlnQm4FsXVVyEzWmqKo3Q3guFkXLoDsdH/5vq/tA=" }, "org/jetbrains/kotlin#kotlin-stdlib-jdk7/1.8.0": { "jar": "sha256-TIidHZgD9fLrbBWSprfmI2msdmDJ7uFauhb+wFkWNmY=", "pom": "sha256-36lkSmrluJjuR1ux9X6DC6H3cK7mycFfgRKqOBGAGEo=" }, "org/jetbrains/kotlin#kotlin-stdlib-jdk7/1.8.20": { "jar": "sha256-rx7EDDuVGv3MDCoBc8e4F2PFKBwtW6+/CoVEokxdzAw=", "pom": "sha256-NiLRBleM3cwKnsIPjOgV9/Sf9UL2QCKNIUH8r4BhawY=" }, "org/jetbrains/kotlin#kotlin-stdlib-jdk7/2.2.0": { "jar": "sha256-DRC8DUK4YF8jYpo/MeonwZzbyp3N9PU/bSLNY2aDbRg=", "pom": "sha256-lcIYnDXve/xIlRwyrXCEeyHzgJ0m9dCnbiNXCHmYjDA=" }, "org/jetbrains/kotlin#kotlin-stdlib-jdk8/1.8.0": { "jar": "sha256-BbYoBEQbDJoZILa31c9zKaTiS2JYR44ysfBGygGQCUY=", "pom": "sha256-K7bHVRuXx7oCn5hmWC56oZ1jq/1M1T2j/AxGLzq1/CY=" }, "org/jetbrains/kotlin#kotlin-stdlib-jdk8/1.8.20": { "jar": "sha256-45i2eXdiJxi/GP+ZtznH2doGDzP7RYouJSAyIcFq8BA=", "pom": "sha256-OkYiFKM26ZVod2lTGx43sMgdjhDJlJzV6nrh14A6AjI=" }, "org/jetbrains/kotlin#kotlin-stdlib-jdk8/2.2.0": { "jar": "sha256-rcFmSNu881sNEOfsMBw110bRwv5GDGBqulnxKxF8+bA=", "pom": "sha256-I00G/b3CncvAdEfijEomq6uVmdXD2qPZKjTmrt6iNqY=" }, "org/jetbrains/kotlin#kotlin-stdlib/1.8.21": { "jar": "sha256-BCoc0ayXbNz+XrY/HY4LC4kskkjhWmnIz7pJXVRupSo=", "pom": "sha256-/gzZ4yGT5FMzP9Kx9XfmYvtavGkHECu5Z4F7wTEoD9c=" }, "org/jetbrains/kotlin#kotlin-stdlib/1.9.0": { "jar": "sha256-Na7/vi21qkRgcs7lD87ki3+p4vxRyjfAzH19C8OdlS4=", "pom": "sha256-N3UiY/Ysw+MlCFbiiO5Kc9QQLXJqd2JwNPlIBsjBCso=" }, "org/jetbrains/kotlin#kotlin-stdlib/2.0.21": { "jar": "sha256-8xzFPxBafkjAk2g7vVQ3Vh0SM5IFE3dLRwgFZBvtvAk=", "module": "sha256-gf1tGBASSH7jJG7/TiustktYxG5bWqcpcaTd8b0VQe0=", "pom": "sha256-/LraTNLp85ZYKTVw72E3UjMdtp/R2tHKuqYFSEA+F9o=" }, "org/jetbrains/kotlin#kotlin-stdlib/2.2.0": { "jar": "sha256-ZdEthaO4ZcFg25FHhRcSpksQ2t1osi7qIqlb+KhnDco=", "module": "sha256-pbmP3NnbAX1ULhlyJdzuGNZYpW3h2yzEHhMZbWsXaaQ=", "pom": "sha256-jDyCEAfBNBFVhzm589U4LrgVUds4lc/7iVYeVsD03BY=" }, "org/jetbrains/kotlin#kotlin-stdlib/2.2.20": { "jar": "sha256-iDbM/9NYX63amQEkSyDUKQHS881YEFjYQ04v+rzzo+c=", "module": "sha256-yRj1IU0CGnLjdn8nVul9EDpSbgTxQj2jZj79+1hH25U=", "pom": "sha256-SosIbmQxvPYjY39Ssv8ZLhrbkTg4dC5cDupwqN7kKcQ=" }, "org/jetbrains/kotlin#kotlin-stdlib/2.3.0": { "jar": "sha256-iHWHyRcTJQrVL+FK2RZtBCwzg1BJiQ6UN/NV/8WhlbE=", "module": "sha256-CRCoo7aWD8eSxFxWqR18Oj8mKG8DKVVUtRnP83h1baI=", "pom": "sha256-TVJW0+SETmVrDKQF9jUNbyF5XCQ3WzRSUmxUZ92ZtaI=" }, "org/jetbrains/kotlin#kotlin-stdlib/2.3.10": { "jar": "sha256-9hZixtOi+O9b00NioC2Hd3LDnzk805T+slnfr39NhDc=", "module": "sha256-6ocfZjGc2ierJSL6jZKRMdXm/LUzROT1TNrgMRHRUKo=", "pom": "sha256-Sj+O2KRMftizGuUQEzSIBYfBCXBlP0s7lE/bI4MLJCA=" }, "org/jetbrains/kotlin#kotlin-test/2.2.0": { "jar": "sha256-jbF1o/Vs8Tnr34k28pPOWmSha1KgQIgE4OwHfohI6zI=", "module": "sha256-VKfhwH1Wew4MfZE5V563Qe0H4N5bWgx/86V6017mWjw=", "pom": "sha256-cBlAy7cIWLKmC6xeNyqmsKciVI0c3QrVtSU6RmH6R2M=" }, "org/jetbrains/kotlin#kotlin-tooling-core/2.3.10": { "jar": "sha256-NnFCeBKZvA+RIMHe7A5ik0oa+ep/AaqpxaU1TcXY19k=", "pom": "sha256-5hhz7dWo3QMaa6l1nAXRVpBlnmEuPUjB7RInN9q0SYY=" }, "org/jetbrains/kotlinx#atomicfu-jvm/0.22.0": { "jar": "sha256-LaBzcn86teVYTnTBLhFRnJCK4t+vausl3tQrZoIpeII=", "module": "sha256-NQcPkjzmn4fG+Q5TBXIOJwRAm2miN0SSrEW+cAde5Jo=", "pom": "sha256-CSM9N5NaKWh4/H6+92ECEmT64oBb+SZCAyi3y5DWelY=" }, "org/jetbrains/kotlinx#atomicfu/0.22.0": { "module": "sha256-XIdu9+HuB90wchxDtG6tjtojzisiJg5w+TPL4AToBgc=", "pom": "sha256-lsP4fQNiioMRHD35NOgD134Q1hNCD3vE0wBZg4bI7zc=" }, "org/jetbrains/kotlinx#kotlinx-coroutines-android/1.7.3": { "jar": "sha256-Wf/7Jr7hLDLa3PpdQgwqfbhdMlNRgSixcO/acmYTJW0=", "module": "sha256-SN/YE57e5UgbzIsl4k11hqymFfDR7SvrJC3HR47UzuA=", "pom": "sha256-AgUVJG0Z4XbVYm6xGPgPl/eHbRrM2M7BWxpBWSV9UFQ=" }, "org/jetbrains/kotlinx#kotlinx-coroutines-android/1.9.0": { "jar": "sha256-vXg6zS+XOIRdWDgPRvRbXN6V0MsDAn1WclM4smv8TXI=", "module": "sha256-ffHgTXfwzEEYkNmZqUSbDjvNTxWaRsMGCxECBMpgfUM=", "pom": "sha256-voYCDNW5O4poykMYWgSbmwuqNF/Rvh/aoBT9rvktbnw=" }, "org/jetbrains/kotlinx#kotlinx-coroutines-bom/1.6.0": { "pom": "sha256-2iMnJQ6r8q3rW6TQFaQ380R1EC0Vuj10SNpx38/Rlt4=" }, "org/jetbrains/kotlinx#kotlinx-coroutines-bom/1.6.4": { "pom": "sha256-qyYUhV+6ZqqKQlFNvj1aiEMV/+HtY/WTLnEKgAYkXOE=" }, "org/jetbrains/kotlinx#kotlinx-coroutines-bom/1.7.3": { "pom": "sha256-Tl0ZAOY3nvP1lw0EqPMFKa3IL4WejMEHwhzoFJ72ZsQ=" }, "org/jetbrains/kotlinx#kotlinx-coroutines-bom/1.8.0": { "pom": "sha256-Ejnp2+E5fNWXE0KVayURvDrOe2QYQuQ3KgiNz6i5rVU=" }, "org/jetbrains/kotlinx#kotlinx-coroutines-bom/1.9.0": { "pom": "sha256-vqVRHpAB8sWTq1CA3xMbIZq14ghcxZec5YPqzUlG/Xg=" }, "org/jetbrains/kotlinx#kotlinx-coroutines-core-jvm/1.6.4": { "jar": "sha256-wkyLsnuzIMSpOHFQGn5eDGFgdjiQexl672dVE9TIIL4=", "module": "sha256-DZTIpBSD58Jwfr1pPhsTV6hBUpmM6FVQ67xUykMho6c=", "pom": "sha256-Cdlg+FkikDwuUuEmsX6fpQILQlxGnsYZRLPAGDVUciQ=" }, "org/jetbrains/kotlinx#kotlinx-coroutines-core-jvm/1.7.3": { "jar": "sha256-GrOsw48+c1XE+dHsYhB6RvpzyJnzBw0FXl1Dc9/mfhI=", "module": "sha256-NNbumbdqwGK1FVW0pwvhg0n+VWbaeaGQYU8XHIC2U44=", "pom": "sha256-dThYdT3su7I5c0PiuHHwYvaXgS6UIuQcnuRqZrk+7jA=" }, "org/jetbrains/kotlinx#kotlinx-coroutines-core-jvm/1.8.0": { "jar": "sha256-mGCQahk3SQv187BtLw4Q70UeZblbJp8i2vaKPR9QZcU=", "module": "sha256-/2oi2kAECTh1HbCuIRd+dlF9vxJqdnlvVCZye/dsEig=", "pom": "sha256-pWM6vVNGfOuRYi2B8umCCAh3FF4LduG3V4hxVDSIXQs=" }, "org/jetbrains/kotlinx#kotlinx-coroutines-core-jvm/1.9.0": { "jar": "sha256-rYnCiSI15nDyItgZyz2BGIFDyxmgW1nfmImuQmn1xwo=", "module": "sha256-syGomeQNPONFcHqiz9qZg60NzGn+p0qbi/kGoWwc+Kk=", "pom": "sha256-GcSImUGzqgmL1XzGTwL5razGVNVxoSqVbeS1uxSMZJk=" }, "org/jetbrains/kotlinx#kotlinx-coroutines-core/1.6.0": { "module": "sha256-USz6to8A1zY0YbXdAmN90ZBIs2Sonhlg+ZeExwWienw=", "pom": "sha256-IlUE64cgcBMpbf1p4hrMYPViQFRubqjUJSeDvc+zJNk=" }, "org/jetbrains/kotlinx#kotlinx-coroutines-core/1.6.4": { "module": "sha256-pu7UoYNViOfIT817BHX86aezREyHDrx5e4i6ZMz0V2s=", "pom": "sha256-E2tO24ekfhUlHwhC8xifoxl560q1Gs+0I5tL+NFtRYg=" }, "org/jetbrains/kotlinx#kotlinx-coroutines-core/1.7.3": { "module": "sha256-f7FiOWWU7CjhtqRBG0V5SadnD14SAZF2d04f1rlHG78=", "pom": "sha256-7W6wOYcXA14p8cHWCk4927iYWPPbnge1etdZ03Ta6Ck=" }, "org/jetbrains/kotlinx#kotlinx-coroutines-core/1.9.0": { "module": "sha256-rVNANKlTtOEsvuuHTGat+LHKFN8V/g0uZUeqNOht/so=", "pom": "sha256-dw8nk9BeKwJ7nHmZOOwdLU7xQc5YGceAwyw5lcrbCkc=" }, "org/jetbrains/kotlinx#kotlinx-html-jvm/0.8.1": { "jar": "sha256-mL2hx4pQKKE0zrJbY/XBMMiTSXMNNf1H73SQtr8LY7M=", "module": "sha256-LmFHvYMmyke+lXIwrS7LOu6OGUoK/bc6Z3YRbu16Sl8=", "pom": "sha256-pKgxcBDDGabHdxdr72MZsyzJbha0hDlOJqLL+Fcf/QQ=" }, "org/jetbrains/kotlinx#kotlinx-serialization-bom/1.10.0": { "pom": "sha256-Lpc+Tfw7xjjdLmg9qVncK5eSMpe/O49gx/8A1h6sOwc=" }, "org/jetbrains/kotlinx#kotlinx-serialization-core-jvm/1.10.0": { "jar": "sha256-FNbyfOKPYevEpRbVYvkRt7wBz75Tl/uITEXqDbBExjU=", "module": "sha256-7tVPsrYUrZV8CP7iDeZeAK1dVs6jkORLpgorhUKBtgw=", "pom": "sha256-XEwMgBAEK39UKrSE3qijaHuWwglE5ywGPqwWonOa2OQ=" }, "org/jetbrains/kotlinx#kotlinx-serialization-core-jvm/1.4.1": { "jar": "sha256-66fxyFQpbkzhQY+wE2D48QxWg+fEWqNHIBhBegZ2NvM=", "module": "sha256-c7yUvdX8hmIVCaZxXD/jRJBO59tYBqDGF5LOI1YInuk=", "pom": "sha256-blIuCoY+rZXX8nMuAPb0W/MC6jLgm+iMZNBgwHZV+DI=" }, "org/jetbrains/kotlinx#kotlinx-serialization-core/1.10.0": { "module": "sha256-pJJxm8QF9QTg2EjzTpQfL5RvgntHhzayW6nGlwjZyao=", "pom": "sha256-ZCi5KEMl0zYxmSHrBY71Fd8BzY6O9s3BNB7PqO9rNXQ=" }, "org/jetbrains/kotlinx#kotlinx-serialization-core/1.4.1": { "module": "sha256-YOWBw5fduUYewfHe5bu0oju37H0JspYCQZYiACKqcJA=", "pom": "sha256-BKWsA/zZmdCmsP5RUG9ln6k/q0Cb0wkFDjMQg5NjdL8=" }, "org/jetbrains/kotlinx#kotlinx-serialization-json-jvm/1.10.0": { "jar": "sha256-rx4+Ho7jF2Ro4exynfhTsgZgcd6UqExBKtn6E1yzfzo=", "module": "sha256-pf5GHIQaWLDKWZaUF8ZaWe9wWHzJw4eD3ox4sKP5UMg=", "pom": "sha256-eGypyBw8fsU9kkuyNqAI8hTCwJbRMX3J97N47NEP1c0=" }, "org/jetbrains/kotlinx#kotlinx-serialization-json-jvm/1.4.1": { "jar": "sha256-r2BMRnNxIdQiX9tg7w4Xdmo8lLfBye92tOOlx3M9VX4=", "module": "sha256-yPv95LXuHkGmkXUWXoOZkdFQFmWnWQ4jFiMmUBrGEiw=", "pom": "sha256-Mtk0fHEBNfvnCnAZKFe8c7BpWZYu3s//MZJgOb4kuGE=" }, "org/jetbrains/kotlinx#kotlinx-serialization-json/1.10.0": { "module": "sha256-yfNeuGIPLTiZoFgoXEF3NciVbu0rGFO+2jrBPHeCuMc=", "pom": "sha256-+uXntE6iGb3qzoSlC/8y06x/bdGb5KjiRTbhgzxUoNY=" }, "org/jetbrains/kotlinx#kotlinx-serialization-json/1.4.1": { "module": "sha256-6ZIjAK/2Y+VezvfT/KMFy2ChR1Wx+YDZQDnjwcq2Rcw=", "pom": "sha256-dkrPeeNju9YeyL+dUcOw2XzryNCLSTjDBiPA+vbRNaA=" }, "org/jspecify#jspecify/1.0.0": { "jar": "sha256-H61ua+dVd4Hk0zcp1Jrhzcj92m/kd7sMxozjUer9+6s=", "module": "sha256-0wfKd6VOGKwe8artTlu+AUvS9J8p4dL4E+R8J4KDGVs=", "pom": "sha256-zauSmjuVIR9D0gkMXi0N/oRllg43i8MrNYQdqzJEM6Y=" }, "org/junit#junit-bom/5.10.2": { "module": "sha256-3iOxFLPkEZqP5usXvtWjhSgWaYus5nBxV51tkn67CAo=", "pom": "sha256-Fp3ZBKSw9lIM/+ZYzGIpK/6fPBSpifqSEgckzeQ6mWg=" }, "org/junit#junit-bom/5.9.2": { "module": "sha256-qxN7pajjLJsGa/kSahx23VYUtyS6XAsCVJdyten0zx8=", "pom": "sha256-LtB9ZYRRMfUzaoZHbJpAVrWdC1i5gVqzZ5uw82819wU=" }, "org/jvnet/staxex#stax-ex/1.8.1": { "jar": "sha256-IFIlSQVunlCqNe8LRFouR6U9Br4LCpRn1wTiSD/7BJo=", "pom": "sha256-j8hPNs5tps6MiTtlOBmaf2mmmgcG2bF6PuajoJRS7tY=" }, "org/ow2#ow2/1.5.1": { "pom": "sha256-Mh3bt+5v5PU96mtM1tt0FU1r+kI5HB92OzYbn0hazwU=" }, "org/ow2/asm#asm-analysis/9.8": { "jar": "sha256-5kBzL7zTxicZJaUE8SXjg4Roj037v5LIYi387g0J7bk=", "pom": "sha256-xXR+JccuGwfVJjx1x4rWGmJt0kWPr8r8I/gdMlPuQu0=" }, "org/ow2/asm#asm-commons/9.8": { "jar": "sha256-MwGhwctMWfzFKSZI2sHXxa7UwPBn376IhzuM3+d0BPQ=", "pom": "sha256-95PnjwH3A3F9CUcuVs3yEv4piXDIguIRbo5Un7bRQMI=" }, "org/ow2/asm#asm-tree/9.8": { "jar": "sha256-FLeIDLfIXu0QHicQQy/D/7gydVMqaolNxMQJXUmtWfE=", "pom": "sha256-cUnn+qDhkSlvh5ru2SCciULTmPBpjSzKGpxijy4qj3c=" }, "org/ow2/asm#asm/9.8": { "jar": "sha256-h26raoPa7K1cpn65/KuwY8l7WuuM8fynqYns3hdSIFE=", "pom": "sha256-wTZ8O7OD12Gef3l+ON91E4hfLu8ErntZCPaCImV7W6o=" }, "org/snakeyaml#snakeyaml-engine/2.7": { "jar": "sha256-QFP4eMFxaSqrh4L1Ojl09D5V4rbtEsNoKzakaWjF3tE=", "pom": "sha256-pSpiQ/hhWFZ5HRKX+XN0B2v4E0gGGvcUrQlj03WuXLc=" }, "org/sonatype/oss#oss-parent/7": { "pom": "sha256-tR+IZ8kranIkmVV/w6H96ne9+e9XRyL+kM5DailVlFQ=" }, "org/sonatype/oss#oss-parent/9": { "pom": "sha256-+0AmX5glSCEv+C42LllzKyGH7G8NgBgohcFO8fmCgno=" } } } ================================================ FILE: android/gradle/wrapper/gradle-wrapper.properties ================================================ #Mon Sep 15 22:54:28 CEST 2025 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists ================================================ FILE: android/gradle.properties ================================================ # Project-wide Gradle settings. # IDE (e.g. Android Studio) users: # Gradle settings configured through the IDE *will override* # any settings specified in this file. # For more details on how to configure your build environment visit # http://www.gradle.org/docs/current/userguide/build_environment.html # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 org.gradle.parallel=true # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. For more details, visit # https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects # org.gradle.parallel=true # AndroidX package structure to make it clearer which packages are bundled with the # Android operating system, and which are packaged with your app's APK # https://developer.android.com/topic/libraries/support-library/androidx-rn android.useAndroidX=true # Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official # Enables namespacing of each library's R class so that its R class includes only the # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library android.nonTransitiveRClass=true ================================================ FILE: android/gradlew ================================================ #!/usr/bin/env sh # # Copyright 2015 the original author or authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # https://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # ############################################################################## ## ## Gradle start up script for UN*X ## ############################################################################## # Attempt to set APP_HOME # Resolve links: $0 may be a link PRG="$0" # Need this for relative symlinks. while [ -h "$PRG" ] ; do ls=`ls -ld "$PRG"` link=`expr "$ls" : '.*-> \(.*\)$'` if expr "$link" : '/.*' > /dev/null; then PRG="$link" else PRG=`dirname "$PRG"`"/$link" fi done SAVED="`pwd`" cd "`dirname \"$PRG\"`/" >/dev/null APP_HOME="`pwd -P`" cd "$SAVED" >/dev/null APP_NAME="Gradle" APP_BASE_NAME=`basename "$0"` # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD="maximum" warn () { echo "$*" } die () { echo echo "$*" echo exit 1 } # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false case "`uname`" in CYGWIN* ) cygwin=true ;; Darwin* ) darwin=true ;; MINGW* ) msys=true ;; NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables JAVACMD="$JAVA_HOME/jre/sh/java" else JAVACMD="$JAVA_HOME/bin/java" fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else JAVACMD="java" which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi # Increase the maximum file descriptors if we can. if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then MAX_FD_LIMIT=`ulimit -H -n` if [ $? -eq 0 ] ; then if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then MAX_FD="$MAX_FD_LIMIT" fi ulimit -n $MAX_FD if [ $? -ne 0 ] ; then warn "Could not set maximum file descriptor limit: $MAX_FD" fi else warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" fi fi # For Darwin, add options to specify how the application appears in the dock if $darwin; then GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" fi # For Cygwin or MSYS, switch paths to Windows format before running java if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then APP_HOME=`cygpath --path --mixed "$APP_HOME"` CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` JAVACMD=`cygpath --unix "$JAVACMD"` # We build the pattern for arguments to be converted via cygpath ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` SEP="" for dir in $ROOTDIRSRAW ; do ROOTDIRS="$ROOTDIRS$SEP$dir" SEP="|" done OURCYGPATTERN="(^($ROOTDIRS))" # Add a user-defined pattern to the cygpath arguments if [ "$GRADLE_CYGPATTERN" != "" ] ; then OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" fi # Now convert the arguments - kludge to limit ourselves to /bin/sh i=0 for arg in "$@" ; do CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` else eval `echo args$i`="\"$arg\"" fi i=`expr $i + 1` done case $i in 0) set -- ;; 1) set -- "$args0" ;; 2) set -- "$args0" "$args1" ;; 3) set -- "$args0" "$args1" "$args2" ;; 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; esac fi # Escape application args save () { for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done echo " " } APP_ARGS=`save "$@"` # Collect all arguments for the java command, following the shell quoting and substitution rules eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" exec "$JAVACMD" "$@" ================================================ FILE: android/gradlew.bat ================================================ @rem @rem Copyright 2015 the original author or authors. @rem @rem Licensed under the Apache License, Version 2.0 (the "License"); @rem you may not use this file except in compliance with the License. @rem You may obtain a copy of the License at @rem @rem https://www.apache.org/licenses/LICENSE-2.0 @rem @rem Unless required by applicable law or agreed to in writing, software @rem distributed under the License is distributed on an "AS IS" BASIS, @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem @if "%DEBUG%" == "" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @rem @rem ########################################################################## @rem Set local scope for the variables with windows NT shell if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 if "%DIRNAME%" == "" set DIRNAME=. set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @rem Resolve any "." and ".." in APP_HOME to make it shorter. for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" @rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if "%ERRORLEVEL%" == "0" goto execute echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. echo. echo Please set the JAVA_HOME variable in your environment to match the echo location of your Java installation. goto fail :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute echo. echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% echo. echo Please set the JAVA_HOME variable in your environment to match the echo location of your Java installation. goto fail :execute @rem Setup the command line set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar @rem Execute Gradle "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* :end @rem End local scope for the variables with windows NT shell if "%ERRORLEVEL%"=="0" goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 exit /b 1 :mainEnd if "%OS%"=="Windows_NT" endlocal :omega ================================================ FILE: android/lib/billing/build.gradle.kts ================================================ import com.android.build.api.dsl.LibraryExtension plugins { alias(libs.plugins.android.library) alias(libs.plugins.kotlin.android) } extensions.configure { buildToolsVersion = "36.0.0" compileSdk = 36 defaultConfig { minSdk = 31 } namespace = "net.obscura.lib.billing" } kotlin { jvmToolchain(21) } dependencies { implementation(libs.android.billingclient) implementation(libs.kotlin.stdlib) // This is a dep of `billingclient`, but we specify it manually to override // an outdated dependency version: // https://github.com/mullvad/mullvadvpn-app/pull/9887 implementation(libs.play.services.location) implementation(project(":lib:util")) } ================================================ FILE: android/lib/billing/src/main/AndroidManifest.xml ================================================ ================================================ FILE: android/lib/billing/src/main/java/net/obscura/lib/billing/BillingConnection.kt ================================================ package net.obscura.lib.billing import android.content.Context import com.android.billingclient.api.BillingClient import com.android.billingclient.api.BillingClientStateListener import com.android.billingclient.api.BillingResult import com.android.billingclient.api.PendingPurchasesParams import com.android.billingclient.api.Purchase import com.android.billingclient.api.PurchasesUpdatedListener import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow import net.obscura.lib.util.Logger private val log = Logger(BillingConnection::class) internal class BillingConnection( context: Context, purchasesUpdatedListenerCallback: (BillingResult, List?) -> T, ) { private val purchaseUpdatesTx = MutableSharedFlow(extraBufferCapacity = 1) val purchaseUpdatesRx = this.purchaseUpdatesTx.asSharedFlow() private val purchasesUpdatedListener = PurchasesUpdatedListener { result, purchases -> log.info("purchases updated: $result $purchases") val wasEmitted = purchaseUpdatesTx.tryEmit(purchasesUpdatedListenerCallback(result, purchases)) if (!wasEmitted) { log.warn("multiple purchase updates while collecting") } } val client = BillingClient.newBuilder(context) .setListener(this.purchasesUpdatedListener) .enableAutoServiceReconnection() .enablePendingPurchases( PendingPurchasesParams.newBuilder() .enableOneTimeProducts() // This is mandatory .build() ) .build() init { log.debug("starting billing connection") // Calling this doesn't appear to be necessary when using `enableAutoServiceReconnection`, but the callbacks can // still be useful for: // 1. Querying purchases/etc. at the earliest possible time // 2. Logging client.startConnection( object : BillingClientStateListener { override fun onBillingSetupFinished(result: BillingResult) { if (result.responseCode == BillingClient.BillingResponseCode.OK) { log.info("billing setup succeeded: $result") } else { log.error("billing setup failed: $result") } } override fun onBillingServiceDisconnected() { log.info("billing client disconnected") } } ) } fun destroy() { log.debug("destroying billing connection") this.client.endConnection() } } ================================================ FILE: android/lib/billing/src/main/java/net/obscura/lib/billing/BillingManager.kt ================================================ package net.obscura.lib.billing import android.app.Activity import android.content.Context import com.android.billingclient.api.BillingClient import com.android.billingclient.api.BillingFlowParams import com.android.billingclient.api.ProductDetails import com.android.billingclient.api.QueryProductDetailsParams import com.android.billingclient.api.queryProductDetails import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.onSubscription import kotlinx.coroutines.withContext import net.obscura.lib.util.Logger private val log = Logger(BillingManager::class) private const val PRODUCT_ID = "vpn_subscription_v1" private const val BASE_PLAN_ID = "monthly-autorenewing" class BillingManager(context: Context) { sealed interface PurchaseResult { object Completed : PurchaseResult object Canceled : PurchaseResult object AlreadyOwned : PurchaseResult object Failed : PurchaseResult } private val connection = BillingConnection(context) { result, _ -> when (result.responseCode) { BillingClient.BillingResponseCode.OK -> PurchaseResult.Completed BillingClient.BillingResponseCode.USER_CANCELED -> PurchaseResult.Canceled BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED -> PurchaseResult.AlreadyOwned else -> { log.error("purchase failed: $result") PurchaseResult.Failed } } } private data class SubscriptionDetails( val productDetails: ProductDetails, val offerDetails: ProductDetails.SubscriptionOfferDetails, ) private suspend fun querySubscriptionDetails(client: BillingClient): SubscriptionDetails? { val result = withContext(Dispatchers.IO) { client.queryProductDetails( QueryProductDetailsParams.newBuilder() .setProductList( listOf( QueryProductDetailsParams.Product.newBuilder() .setProductId(PRODUCT_ID) .setProductType(BillingClient.ProductType.SUBS) .build() ) ) .build() ) } return when (result.billingResult.responseCode) { BillingClient.BillingResponseCode.OK -> { val productDetails = result.productDetailsList?.find { it.productId == PRODUCT_ID } val offerDetails = productDetails?.subscriptionOfferDetails?.find { it.basePlanId == BASE_PLAN_ID } if (offerDetails != null) { log.info("subscription details: $productDetails $offerDetails") SubscriptionDetails(productDetails, offerDetails) } else { log.error( "subscription details for product $PRODUCT_ID and base plan $BASE_PLAN_ID not found: $result" ) null } } else -> { log.error("failed to query subscription details: $result") null } } } suspend fun launchFlow(activity: Activity): PurchaseResult { val productDetailsParams = this.querySubscriptionDetails(this.connection.client)?.let { BillingFlowParams.ProductDetailsParams.newBuilder() .setProductDetails(it.productDetails) .setOfferToken(it.offerDetails.offerToken) .build() } ?: return PurchaseResult.Failed return this.connection.purchaseUpdatesRx .onSubscription { val result = // `launchBillingFlow` can only be called on the UI thread withContext(Dispatchers.Main) { this@BillingManager.connection.client.launchBillingFlow( activity, BillingFlowParams.newBuilder() .setProductDetailsParamsList(listOf(productDetailsParams)) .build(), ) } // This is the result of launching the flow, not of the purchase within the flow! when (result.responseCode) { BillingClient.BillingResponseCode.OK -> { log.info("launched billing flow successfully") } BillingClient.BillingResponseCode.USER_CANCELED -> { log.info("user canceled billing flow") this.emit(PurchaseResult.Canceled) } BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED -> { log.warn("user already owns item") this.emit(PurchaseResult.AlreadyOwned) } else -> { log.error("failed to launch billing flow: $result") this.emit(PurchaseResult.Failed) } } } // Wait for actual purchase result .firstOrNull() ?: run { log.error("purchase updates flow was empty") PurchaseResult.Failed } } fun destroy() { this.connection.destroy() } } ================================================ FILE: android/lib/util/build.gradle.kts ================================================ import com.android.build.api.dsl.LibraryExtension plugins { alias(libs.plugins.android.library) alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlinx.serialization) } extensions.configure { buildToolsVersion = "36.0.0" compileSdk = 36 defaultConfig { minSdk = 31 } namespace = "net.obscura.lib.util" } kotlin { jvmToolchain(21) } dependencies { implementation(libs.kotlin.stdlib) implementation(libs.kotlinx.serialization.json) } ================================================ FILE: android/lib/util/src/main/AndroidManifest.xml ================================================ ================================================ FILE: android/lib/util/src/main/java/net/obscura/lib/util/ExternallyTaggedEnumSerializer.kt ================================================ package net.obscura.lib.util import kotlin.reflect.KClass import kotlinx.serialization.KSerializer import kotlinx.serialization.json.JsonContentPolymorphicSerializer import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.jsonObject open class ExternallyTaggedEnumSerializer( private val baseClass: KClass, private val variants: List>, ) : JsonContentPolymorphicSerializer(baseClass) { override fun selectDeserializer(element: JsonElement): KSerializer = this.variants.find { it.tag in element.jsonObject } ?: error("invalid `${this.baseClass.simpleName}` variant") } ================================================ FILE: android/lib/util/src/main/java/net/obscura/lib/util/ExternallyTaggedEnumVariantSerializer.kt ================================================ package net.obscura.lib.util import kotlinx.serialization.KSerializer import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonTransformingSerializer import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.jsonObject open class ExternallyTaggedEnumVariantSerializer(val tag: String, serializer: KSerializer) : JsonTransformingSerializer(serializer) { override fun transformDeserialize(element: JsonElement): JsonElement = checkNotNull(element.jsonObject[this.tag]) override fun transformSerialize(element: JsonElement): JsonElement = buildJsonObject { this.put(this@ExternallyTaggedEnumVariantSerializer.tag, element) } } ================================================ FILE: android/lib/util/src/main/java/net/obscura/lib/util/Log.kt ================================================ package net.obscura.lib.util import android.util.Log import kotlin.reflect.KClass enum class LogLevel { TRACE, DEBUG, INFO, WARN, ERROR, } data class LogParams( val level: LogLevel, val tag: String, val message: String, val messageId: String?, val tr: Throwable?, ) class Logger(val tag: String, val cb: ((LogParams) -> Unit)? = null) { constructor( classRef: KClass<*>, cb: ((LogParams) -> Unit)? = null, ) : this(classRef.simpleName ?: "AnonymousClass", cb) private fun forward( level: LogLevel, message: String, messageId: String? = null, tr: Throwable? = null, ) { if (this.cb != null) { this.cb(LogParams(level, this.tag, message, messageId, tr)) } } fun trace( message: String, messageId: String? = null, tr: Throwable? = null, ) { Log.v(this.tag, message, tr) this.forward(LogLevel.TRACE, message, messageId, tr) } fun debug( message: String, messageId: String? = null, tr: Throwable? = null, ) { Log.d(this.tag, message, tr) this.forward(LogLevel.DEBUG, message, messageId, tr) } fun info( message: String, messageId: String? = null, tr: Throwable? = null, ) { Log.i(this.tag, message, tr) this.forward(LogLevel.INFO, message, messageId, tr) } fun warn( message: String, messageId: String? = null, tr: Throwable? = null, ) { Log.w(this.tag, message, tr) this.forward(LogLevel.WARN, message, messageId, tr) } fun error( message: String, messageId: String? = null, tr: Throwable? = null, ) { Log.e(this.tag, message, tr) this.forward(LogLevel.ERROR, message, messageId, tr) } } ================================================ FILE: android/lint.xml ================================================ ================================================ FILE: android/settings.gradle.kts ================================================ pluginManagement { repositories { google { content { includeGroupByRegex("com\\.android.*") includeGroupByRegex("com\\.google.*") includeGroupByRegex("androidx.*") } } gradlePluginPortal() mavenCentral() } } plugins { // https://kotlinlang.org/docs/gradle-configure-project.html#gradle-java-toolchains-support // (Unfortunately, we can't use the version catalog for this plugin) id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" } dependencyResolutionManagement { @Suppress("UnstableApiUsage") repositories { google() mavenCentral() } @Suppress("UnstableApiUsage") repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) } rootProject.name = "ObscuraVPN" include( ":app", ":lib:billing", ":lib:util", ) ================================================ FILE: apple/Configurations/.gitignore ================================================ /buildversion.xcconfig ================================================ FILE: apple/Configurations/Base.xcconfig ================================================ #include? "buildversion.xcconfig" #include "bundle-ids.xcconfig" IPHONEOS_DEPLOYMENT_TARGET = 18.0 MACOSX_DEPLOYMENT_TARGET = 13.0 ================================================ FILE: apple/Configurations/Debug-app-network-extension.xcconfig ================================================ // Avoid accidental checkins: // git update-index --skip-worktree apple/Configurations/Debug*.xcconfig // git update-index --no-skip-worktree apple/Configurations/Debug*.xcconfig #include "Debug.xcconfig" #include "app-network-extension.xcconfig" PROVISIONING_PROFILE_SPECIFIER = ================================================ FILE: apple/Configurations/Debug-app.xcconfig ================================================ // Avoid accidental checkins: // git update-index --skip-worktree apple/Configurations/Debug*.xcconfig // git update-index --no-skip-worktree apple/Configurations/Debug*.xcconfig #include "Debug.xcconfig" #include "app.xcconfig" PROVISIONING_PROFILE_SPECIFIER = ================================================ FILE: apple/Configurations/Debug-system-network-extension.xcconfig ================================================ // Avoid accidental checkins: // git update-index --skip-worktree apple/Configurations/Debug*.xcconfig // git update-index --no-skip-worktree apple/Configurations/Debug*.xcconfig #include "Debug.xcconfig" #include "system-network-extension.xcconfig" PROVISIONING_PROFILE_SPECIFIER = ================================================ FILE: apple/Configurations/Debug.xcconfig ================================================ // Avoid accidental checkins: // git update-index --skip-worktree apple/Configurations/Debug*.xcconfig // git update-index --no-skip-worktree apple/Configurations/Debug*.xcconfig #include "Base.xcconfig" CODE_SIGN_IDENTITY = Apple Development CODE_SIGN_STYLE = Automatic ENABLE_HARDENED_RUNTIME = NO OBSCURA_PACKET_TUNNEL_PROVIDER_ENTITLEMENT = packet-tunnel-provider SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) DEBUG ================================================ FILE: apple/Configurations/Release-app-network-extension.xcconfig ================================================ #include "Release.xcconfig" #include "app-network-extension.xcconfig" ================================================ FILE: apple/Configurations/Release-app.xcconfig ================================================ #include "Release.xcconfig" #include "app.xcconfig" PROVISIONING_PROFILE_SPECIFIER = Developer ID: VPN Client App ================================================ FILE: apple/Configurations/Release-system-network-extension.xcconfig ================================================ #include "Release.xcconfig" #include "system-network-extension.xcconfig" PROVISIONING_PROFILE_SPECIFIER = Developer ID: System Network Extension ================================================ FILE: apple/Configurations/Release.xcconfig ================================================ #include "Base.xcconfig" CODE_SIGN_IDENTITY = Developer ID Application: Sovereign Engineering Inc. (5G943LR562) CODE_SIGN_STYLE = Manual ENABLE_HARDENED_RUNTIME = YES OBSCURA_PACKET_TUNNEL_PROVIDER_ENTITLEMENT = packet-tunnel-provider-systemextension OBSCURA_PACKET_TUNNEL_PROVIDER_ENTITLEMENT[sdk=iphoneos*] = packet-tunnel-provider ================================================ FILE: apple/Configurations/app-network-extension.xcconfig ================================================ PRODUCT_BUNDLE_IDENTIFIER = $(OBSCURA_APP_NETWORK_EXTENSION_PRODUCT_BUNDLE_IDENTIFIER) INFOPLIST_KEY_CFBundleDisplayName = Obscura Network Extension INFOPLIST_FILE = app-network-extension/Info.plist GENERATE_INFOPLIST_FILE = YES CODE_SIGN_ENTITLEMENTS = app-network-extension/entitlements.entitlements ================================================ FILE: apple/Configurations/app.xcconfig ================================================ PRODUCT_BUNDLE_IDENTIFIER = $(OBSCURA_APP_PRODUCT_BUNDLE_IDENTIFIER) INFOPLIST_FILE = client/Info.plist GENERATE_INFOPLIST_FILE = YES ================================================ FILE: apple/Configurations/bundle-ids.xcconfig ================================================ OBSCURA_SYSTEM_NETWORK_EXTENSION_PRODUCT_BUNDLE_IDENTIFIER = $(OBSCURA_APP_PRODUCT_BUNDLE_IDENTIFIER).system-network-extension OBSCURA_APP_NETWORK_EXTENSION_PRODUCT_BUNDLE_IDENTIFIER = $(OBSCURA_APP_PRODUCT_BUNDLE_IDENTIFIER).app-network-extension // Points to the identifier used for the network extension on this platform. OBSCURA_NETWORK_EXTENSION_BUNDLE_ID = $(OBSCURA_SYSTEM_NETWORK_EXTENSION_PRODUCT_BUNDLE_IDENTIFIER) OBSCURA_NETWORK_EXTENSION_BUNDLE_ID[sdk=iphoneos*] = $(OBSCURA_APP_NETWORK_EXTENSION_PRODUCT_BUNDLE_IDENTIFIER) DEVELOPMENT_TEAM = 5G943LR562 // Per https://developer.apple.com/documentation/xcode/configuring-app-groups // On macOS app groups must start with team identifier IF the app is sandboxed/app store released. Technically we do not need this. OBSCURA_APP_APP_GROUP_ID = $(TeamIdentifierPrefix)group.$(OBSCURA_APP_PRODUCT_BUNDLE_IDENTIFIER) // On ios app groups must start with group. OBSCURA_APP_APP_GROUP_ID[sdk=iphoneos*] = group.$(OBSCURA_APP_PRODUCT_BUNDLE_IDENTIFIER) OBSCURA_APP_PRODUCT_BUNDLE_IDENTIFIER = net.obscura.vpn-client-app OBSCURA_APP_PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos*] = net.obscura.vpn-client-app-ios ================================================ FILE: apple/Configurations/system-network-extension.xcconfig ================================================ PRODUCT_BUNDLE_IDENTIFIER = $(OBSCURA_SYSTEM_NETWORK_EXTENSION_PRODUCT_BUNDLE_IDENTIFIER) INFOPLIST_KEY_CFBundleDisplayName = Obscura Network Extension INFOPLIST_FILE = system-network-extension/Info.plist GENERATE_INFOPLIST_FILE = YES CODE_SIGN_ENTITLEMENTS = system-network-extension/entitlements.entitlements ================================================ FILE: apple/ExportOptions.plist ================================================ destination export method developer-id provisioningProfiles net.obscura.vpn-client-app Developer ID: VPN Client App net.obscura.vpn-client-app.system-network-extension Developer ID: System Network Extension signingCertificate Developer ID Application signingStyle manual teamID 5G943LR562 ================================================ FILE: apple/Packet Tunnel Provider/Keychain.swift ================================================ import Foundation import Security private let wgSecretKeyquery: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: "obscura", kSecAttrAccount as String: "wireguard-sk", ] func keychainSetWgSecretKey(_ sk: Data) -> Bool { SecItemDelete(wgSecretKeyquery as CFDictionary) var insert = wgSecretKeyquery insert[kSecValueData as String] = sk insert[kSecAttrAccessible as String] = kSecAttrAccessibleAlwaysThisDeviceOnly let insertStatus = SecItemAdd(insert as CFDictionary, nil) switch insertStatus { case errSecSuccess: ffiLog(.Info, "keychain item inserted") return true default: ffiLog(.Error, "error inserting keychain item: \(insertStatus)") return false } } func keychainGetWgSecretKey() -> Data? { var get = wgSecretKeyquery get[kSecMatchLimit as String] = kSecMatchLimitOne get[kSecReturnData as String] = kCFBooleanTrue var item: CFTypeRef? let status = SecItemCopyMatching(get as CFDictionary, &item) switch status { case errSecSuccess: ffiLog(.Info, "keychain item found") case errSecItemNotFound: ffiLog(.Info, "keychain item not found") default: ffiLog(.Error, "error getting keychain item: \(status)") return .none } guard let data = item as? NSData else { ffiLog(.Error, "got unexpected result format from keychain") return .none } return data as Data } ================================================ FILE: apple/Packet Tunnel Provider/NetworkSettings.swift ================================================ import Foundation import NetworkExtension extension NEPacketTunnelNetworkSettings { static func build(_ osNetworkConfig: OsNetworkConfig) -> NEPacketTunnelNetworkSettings { let networkSettings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "127.0.0.1") networkSettings.mtu = osNetworkConfig.mtu as NSNumber let ipv4Settings = NEIPv4Settings( addresses: [osNetworkConfig.ipv4], subnetMasks: ["255.255.255.255"] ) ipv4Settings.includedRoutes = [NEIPv4Route.default()] networkSettings.ipv4Settings = ipv4Settings let selfIpv6Parts = osNetworkConfig.ipv6.split(separator: "/", maxSplits: 1) let selfIpv6Addr = String(selfIpv6Parts[0]) let selfIpv6Prefix = UInt8(selfIpv6Parts[1])! let ipv6Settings = NEIPv6Settings( addresses: [selfIpv6Addr], // If a too-small network is used we won't be granted the default IPv6 route. So cap the prefix length. This shouldn't be in issue for us since the IP is always a private IP that gets NATed. If that ever changes we will likely end up with a bigger prefix anyways, but either way that is a problem for the future. // // Testing has shown that anything smaller than a /125 network won't work on macOS. // // wireguard-apple suggests that a /120 may be required on iOS: https://github.com/WireGuard/wireguard-apple/blob/af58bfcb00e7ebdd0c0f48d2f15df17ab3b2b8d7/WireGuard/WireGuardNetworkExtension/PacketTunnelSettingsGenerator.swift#L165-L170 networkPrefixLengths: [NSNumber(value: min(selfIpv6Prefix, 125))] ) ipv6Settings.includedRoutes = [NEIPv6Route.default()] networkSettings.ipv6Settings = ipv6Settings let dns_settings = NEDNSSettings(servers: osNetworkConfig.dns) if osNetworkConfig.useSystemDns { // Contrary to apple documentation this is not ignored if the VPN tunnel is the default route and allows us to fall back on configured DNS profiles. (https://developer.apple.com/documentation/networkextension/nednssettings/matchdomains (2025-11-15)) dns_settings.matchDomains = ["invalid.obscura.net"] if #available(iOS 26.0, macOS 26.0, *) { dns_settings.allowFailover = true } } else { // This is not necessary to match everything if the VPN tunnel is the default route, but is harmless either way. (https://developer.apple.com/documentation/networkextension/nednssettings/matchdomains (2025-11-15)) dns_settings.matchDomains = [""] } networkSettings.dnsSettings = dns_settings return networkSettings } } ================================================ FILE: apple/Packet Tunnel Provider/PacketTunnelProvider.swift ================================================ import Combine import libobscuravpn_client import NetworkExtension import OSLog import UniformTypeIdentifiers import UserNotifications class PacketTunnelProvider: NEPacketTunnelProvider { weak static var shared: PacketTunnelProvider? private let providerId = genTaskId() private let isActive = AsyncMutex(false) private let isConnected = WatchableValue(false) private let networkConfig: AsyncMutex = AsyncMutex(.none) private let nwPathMonitor: NWPathMonitor = .init() private let rustFfi: RustFfi var selfObservation: NSKeyValueObservation? override init() { let logDir = logDir() if let logDir = logDir { let logger = Logger(subsystem: "net.obscura.sys-ext", category: "pre-log-init") do { try ensureDirWithMinimalProtection(dir: logDir) } catch { logger.error("failed to ensure log dir protection level: \(error)") } } let logFlushGuard = ffiInitializeSystemLogging(logDir) ffiLog(.Info, "init entry \(self.providerId)") if let other = Self.shared { ffiLog(.Warn, "Multiple live PacketTunnelProvider instances. me: \(self.providerId) other: \(other.providerId)") } let configDir = configDir() do { try ensureDirWithMinimalProtection(dir: configDir) } catch { ffiLog(.Error, "failed to ensure config directory protection level: \(error)") } #if os(macOS) let userAgentPlatform = "macos" #else let userAgentPlatform = "ios" #endif let userAgent = "obscura.net/" + userAgentPlatform + "/" + sourceVersion() ffiLog(.Info, "config dir \(configDir)") ffiLog(.Info, "user agent \(userAgent)") let rustFfi = RustFfi(configDir: configDir, userAgent: userAgent, logFlushGuard: logFlushGuard, receiveCallback, setNetworkConfigCallback) self.rustFfi = rustFfi self.nwPathMonitor.pathUpdateHandler = { path in if path.status != .satisfied { ffiLog(.Info, "network path not satisfied") rustFfi.setNetworkInterface(.none) return } switch path.availableInterfaces.first { case .some(let preferredInterface): ffiLog(.Info, "preferred network path interface name: \(preferredInterface.name), index: \(preferredInterface.index)") rustFfi.setNetworkInterface(.some((preferredInterface.index, preferredInterface.name))) case .none: ffiLog(.Info, "no available network path interface") rustFfi.setNetworkInterface(.none) } } self.nwPathMonitor.start(queue: .main) super.init() self.selfObservation = self.observe( \.protocolConfiguration, options: [.old, .new] ) { [weak self] object, change in Task { await self?.handleProtocolConfigurationChange(change: change) } } Self.shared = self self.startSendLoop() self.startStatusLoop() ffiLog(.Info, "init exit \(self.providerId)") } deinit { ffiLog(.Info, "PacketTunnelProvider.deinit \(self.providerId)") /* Hack to avoid macos bugs where handleAppMessage isn't called after deinit. One way to reproduce the issue: - disable network access (e.g. turn off wifi) - start a tunnel - any IPC that should result in handleAppMessage getting called will fail. This is not redundant with the `exit` in stopTunnel, because in the case described above `stopTunnel` is not called. https://linear.app/soveng/issue/OBS-2070 */ exit(0) } override func startTunnel(options: [String: NSObject]?) async throws { ffiLog(.Info, "startTunnel entry \(self.providerId), includeAllNetworks: \(self.protocolConfiguration.includeAllNetworks)") if options?.keys.contains("dontStartTunnel") == .some(true) { ffiLog(.Error, "startTunnel \(self.providerId) throws due to \"dontStartTunnel\" key in options") throw "dummy start with \"dontStartTunnel\" flag" } var tunnelArgs: TunnelArgs? = .none switch options { case .some(let options): ffiLog(.Info, "tunnel options: \(options)") if let args = options["tunnelArgs"] as? String { ffiLog(.Info, "startTunnel called with \"tunnelArgs\"") tunnelArgs = try TunnelArgs(json: args) } case .none: ffiLog(.Info, "startTunnel \(self.providerId) called without options") } try await self.isActive.withLock { isActiveGuard in if isActiveGuard.value { ffiLog(.Error, "startTunnel called on active tunnel \(self.providerId)") throw "tunnel already active" } let _: Empty = try await runManagerCmd(self.rustFfi, .setTunnelArgs(args: tunnelArgs, active: true)) ffiLog(.Info, "set tunnel active flag \(self.providerId)") isActiveGuard.value = true } // macos 14 cancels the tunnel if it stays on connecting for too long if #available(macOS 15, *) { ffiLog(.Info, "waiting for tunnel to start \(self.providerId)") _ = await self.isConnected.waitUntil { $0 == true } } ffiLog(.Info, "startTunnel exit \(self.providerId)") } override func stopTunnel(with reason: NEProviderStopReason) async { ffiLog(.Info, "stopTunnel entry \(self.providerId), reason: \(providerStopReasonToString(reason))") let (disableOndemand, notificationBody): (Bool, String?) = switch reason { case .userInitiated: (true, .none) case .providerDisabled, .superceded, .configurationDisabled: (false, "Tunnel was disabled by another VPN app.") case .none, .noNetworkAvailable, .providerFailed, .unrecoverableNetworkChange, .authenticationCanceled, .configurationFailed, .idleTimeout, .configurationRemoved, .userLogout, .userSwitch, .appUpdate, .connectionFailed, .sleep, .internalError: (false, nil) @unknown default: (false, nil) } if let notificationBody = notificationBody { let content = UNMutableNotificationContent() content.title = "Obscura VPN tunnel stopped" content.body = notificationBody let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false) do { try await UNUserNotificationCenter.current().add(UNNotificationRequest(identifier: NotificationId.onDemandTunnelStopped.rawValue, content: content, trigger: trigger)) } catch { ffiLog(.Error, "notification error: \(error)") } } if disableOndemand { #if os(macOS) ffiLog(.Info, "ignoring disableOndemand on macOS") #else await try_setting_ondemand(false) #endif } await self.isActive.withLock { isActiveGuard in if !isActiveGuard.value { ffiLog(.Warn, "stopTunnel called on inactive tunnel \(self.providerId)") } ffiLog(.Info, "unset tunnel active flag \(self.providerId)") isActiveGuard.value = false ffiLog(.Info, "stopping tunnel \(self.providerId)") do { let _: Empty = try await runManagerCmd(self.rustFfi, .setTunnelArgs(args: .none, active: false)) } catch { ffiLog(.Error, "setting empty tunnel args failed: \(error)") } } ffiLog(.Info, "waiting for tunnel to stop \(self.providerId)") _ = await self.isConnected.waitUntil { $0 == false } ffiLog(.Info, "stopTunnel exit and abort \(self.providerId)") /* Hack to avoid macos bugs where no methods of self are called after stopTunnel including deinit. https://linear.app/soveng/issue/OBS-2069 */ exit(0) } override func handleAppMessage(_ msg: Data, completionHandler: ((Data?) -> Void)?) { guard let completionHandler = completionHandler else { ffiLog(.Error, "received app message without completion handler") return } Task { let json_result = try! await self.rustFfi.jsonManagerCmd(msg).json() completionHandler(json_result.data(using: .utf8)) } } override func sleep() async { ffiLog(.Info, "sleep entry \(self.providerId)") ffiLog(.Info, "sleep exit \(self.providerId)") } override func wake() { ffiLog(.Info, "wake entry \(self.providerId)") self.rustFfi.wake() ffiLog(.Info, "wake exit \(self.providerId)") } func startSendLoop() { /* Note: This code is a bit unusual for a handful of reasons. 1. This must not keep `self` alive. 2. `self.packetFlow.readPackets` just never calls its completion handler when this provider is obsolete. This means that we can't run any cleanup code. It also means we can't use a `Task` as it would never complete. 3. We want to check and log if we are called after a new `PacketTunnelProvider` has been created. In the end we still leak the `handle` callback. But this is basically the minimum we can leak. Neither we or anyone on GitHub appears to have found a way to leak nothing with this API. We aren't the only ones to notice as I found many examples of people using a `weak self` parameter. */ let providerId = self.providerId var handle: (([Data], [NSNumber]) -> Void)? handle = { [weak self] (packets: [Data], _protocols: [NSNumber]) in guard let self = self else { ffiLog(.Error, "Send task for deallocated PacketTunnelProvider \(providerId) called") return } if providerId != Self.shared?.providerId { ffiLog(.Error, "Send task for obsolete PacketTunnelProvider \(providerId) called") return } for packet in packets { self.rustFfi.sendPacket(packet) } self.packetFlow.readPackets(completionHandler: handle!) } self.packetFlow.readPackets(completionHandler: handle!) } func startStatusLoop() { let providerId = self.providerId let rustFfi = self.rustFfi Task { [weak self] in let taskId = genTaskId() ffiLog(.Info, "status loop entry \(taskId)") var knownVersion: UUID? = .none while true { let status = await getRustStatus(rustFfi, knownVersion: knownVersion) knownVersion = status.version guard let self = self else { ffiLog(.Error, "status loop for deallocated PacketTunnelProvider \(providerId) exiting") break } await self.processStatusUpdate(status) } ffiLog(.Info, "status loop exit \(taskId)") } } func processStatusUpdate(_ status: NeStatus) async { ffiLog(.Info, "processing status update \(status.version)") _ = self.isConnected.update { $0 = switch status.vpnStatus { case .connected: true default: false } } await self.isActive.withLock { isActiveGuard in #if !os(macOS) // Move to startTunnel once onDemand is unconditional ( https://linear.app/soveng/issue/OBS-2428 ) if isActiveGuard.value { await try_setting_ondemand(status.featureFlags.killSwitch == .some(true)) } #endif switch status.vpnStatus { case .disconnected: fallthrough case .connecting: if isActiveGuard.value { // macos 14 disconnects the tunnel if it stays on reasserting for 5min. This problem is exacerbated by unreliable sleep. 5min time awake can accumulate in less than an hour with the lid closed. if #available(macOS 15, *) { self.reasserting = true } } case .connected: if isActiveGuard.value { self.reasserting = false } } } ffiLog(.Info, "finished processing status update \(status.version)") } func ensureNetworkConfig(newNetworkConfig: OsNetworkConfig) async -> Bool { return await self.isActive.withLock { isActiveGuard in if !isActiveGuard.value { ffiLog(.Error, "Not active, ignoring new network config.") return false } return await self.networkConfig.withLock { networkConfigGuard in // This check isn't needed for correctness, but skipping unnecessary calls to `setTunnelNetworkSettings` does prevent brief periods with packet loss and lot of OS activity visible in the system log. if networkConfigGuard.value != newNetworkConfig { ffiLog(.Info, "Setting network config: \(newNetworkConfig)") let networkSettings = NEPacketTunnelNetworkSettings.build(newNetworkConfig) do { try await self.setTunnelNetworkSettings(networkSettings) } catch { ffiLog(.Error, "Setting network config failed: \(error)") return false } networkConfigGuard.value = newNetworkConfig } else { ffiLog(.Info, "Unchanged, keeping existing network config: \(newNetworkConfig)") } return true } } } func handleProtocolConfigurationChange(change: NSKeyValueObservedChange) async { ffiLog(.Info, "handleProtocolConfigurationChange entry \(change.oldValue) to \(change.newValue)") defer { ffiLog(.Info, "handleProtocolConfigurationChange exit") } guard let old = change.oldValue else { // First value, no need to react. return } guard let new = change.newValue else { ffiLog(.Warn, "protocolConfiguration changed to (null)!") return } guard !old.includeAllNetworks && new.includeAllNetworks else { ffiLog(.Info, "No interesting changes.") return } ffiLog(.Info, "includeAllNetorks has been enabled.") await self.isActive.withLock { isActiveGuard in if !isActiveGuard.value { ffiLog(.Info, "Not active, ignoring.") return } await self.networkConfig.withLock { networkConfigGuard in guard let networkConfig = networkConfigGuard.value else { ffiLog(.Info, "No existing network config, doing nothing.") return } ffiLog(.Info, "re-setting network config.") let networkSettings = NEPacketTunnelNetworkSettings.build(networkConfig) do { try await self.setTunnelNetworkSettings(networkSettings) ffiLog(.Info, "Network settings reconfigured.") } catch { ffiLog(.Error, "Failed to apply network settings. User is probably offline \(error)") } } } } } // `done` is always non-null, but cbindgen can't emit _Nonnull for function pointers in typedefs. private func setNetworkConfigCallback(networkConfigJson: FfiBytes, context: UnsafeMutableRawPointer?, done: (@convention(c) (UnsafeMutableRawPointer?, Bool) -> Void)!) { guard let inst = PacketTunnelProvider.shared else { ffiLog(.Error, "setNetworkConfigCallback called with no active PacketTunnelProvider") done(context, false) return } let networkConfigData = networkConfigJson.data() Task { do { let networkConfig = try JSONDecoder().decode(OsNetworkConfig.self, from: networkConfigData) let success = await inst.ensureNetworkConfig(newNetworkConfig: networkConfig) done(context, success) } catch { ffiLog(.Error, "failed to decode OsNetworkConfig: \(error)") done(context, false) } } } private func receiveCallback(packet: FfiBytes) { guard let inst = PacketTunnelProvider.shared else { ffiLog(.Error, "Packet callback called with no active PacketTunnelProvider") return } let packet = packet.data() Task { inst.packetFlow.writePackets([packet], withProtocols: [NSNumber(value: AF_INET)]) } } private func genTaskId() -> String { Data((1 ... 5).map { _ in UInt8.random(in: 65 ... 90) }).reduce("") { $0 + String(format: "%c", $1) } } func getRustStatus(_ rustFfi: RustFfi, knownVersion: UUID?) async -> NeStatus { while true { do { return try await runManagerCmd(rustFfi, .getStatus(knownVersion: knownVersion)) } catch { ffiLog(.Error, "error getting rust status \(error)") } try! await Task.sleep(seconds: 1) } } func runManagerCmd(_ rustFfi: RustFfi, _ cmd: NeManagerCmd) async throws -> O { let jsonCmd = try cmd.json() switch await rustFfi.jsonManagerCmd(Data(jsonCmd.utf8)) { case .ok_json(let ok): return try O(json: ok) case .error(let err): throw err } } func providerStopReasonToString(_ reason: NEProviderStopReason) -> String { switch reason { case .none: return "none" case .userInitiated: return "userInitiated" case .providerFailed: return "providerFailed" case .noNetworkAvailable: return "noNetworkAvailable" case .unrecoverableNetworkChange: return "unrecoverableNetworkChange" case .providerDisabled: return "providerDisabled" case .authenticationCanceled: return "authenticationCanceled" case .configurationFailed: return "configurationFailed" case .idleTimeout: return "idleTimeout" case .configurationDisabled: return "configurationDisabled" case .configurationRemoved: return "configurationRemoved" case .superceded: return "superceded" case .userLogout: return "userLogout" case .userSwitch: return "userSwitch" case .appUpdate: return "appUpdate" case .connectionFailed: return "connectionFailed" case .sleep: return "sleep" case .internalError: return "internalError" @unknown default: return "unknown(\(reason))" } } func ensureDirWithMinimalProtection(dir: String) throws { #if os(macOS) // Lower protection levels are not available on macOS: https://support.apple.com/en-gb/guide/security/secb010e978a/web let protectionLevel = FileProtectionType.completeUntilFirstUserAuthentication #else let protectionLevel = FileProtectionType.none #endif if FileManager.default.fileExists(atPath: dir) { ffiLog(.Info, "\(dir) already exists, ensuring correct protection level") try ensureProtectionLevel(dir, protectionLevel) } else { ffiLog(.Info, "creating \(dir)") try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true, attributes: [.protectionKey: protectionLevel]) } } func ensureProtectionLevel(_ path: String, _ protectionLevel: FileProtectionType) throws { ffiLog(.Info, "checking protection level of \(path)") var currentProtectionLevel: FileProtectionType? = Optional.none do { currentProtectionLevel = try getProtectionLevel(path) ffiLog(.Info, "current protection level: \(currentProtectionLevel.debugDescription)") } catch { ffiLog(.Warn, "could not get protection level of \(path)") } if currentProtectionLevel != protectionLevel { ffiLog(.Info, "changing protection level to \(protectionLevel.rawValue)") try FileManager.default.setAttributes([.protectionKey: protectionLevel], ofItemAtPath: path) try ffiLog(.Info, "new protection level: \(getProtectionLevel(path).debugDescription)") } else { ffiLog(.Info, "protection level already correct") } } func getProtectionLevel(_ path: String) throws -> FileProtectionType? { let attributes = try FileManager.default.attributesOfItem(atPath: path) return attributes[.protectionKey] as? FileProtectionType } #if !os(macOS) func try_setting_ondemand(_ enabled: Bool) async { do { let managers = try await NETunnelProviderManager.loadAllFromPreferences() if managers.isEmpty { throw ("no tunnel providers found") } for manager in managers { manager.isOnDemandEnabled = enabled try await manager.saveToPreferences() } } catch { ffiLog(.Error, "setting isOnDemandEnabled to \(enabled) failed: \(error)") } } #endif ================================================ FILE: apple/Packet Tunnel Provider/RustFfi.swift ================================================ import Foundation import libobscuravpn_client import Network func ffiInitializeSystemLogging(_ logDir: String?) -> OpaquePointer? { let logDir: String = logDir ?? "" let logFlushGuard = logDir.withFfiStr { ffiLogDir in libobscuravpn_client.initialize_apple_system_logging(ffiLogDir) } return logFlushGuard } class RustFfi { private let ptr: OpaquePointer init(configDir: String, userAgent: String, logFlushGuard: OpaquePointer?, _ receiveCallback: @convention(c) (FfiBytes) -> Void, _ setNetworkConfigCallback: @convention(c) (FfiBytes, UnsafeMutableRawPointer?, (@convention(c) (UnsafeMutableRawPointer?, Bool) -> Void)?) -> Void) { let wgSecretKey = keychainGetWgSecretKey() ?? Data() let p = configDir.withFfiStr { ffiConfigDir in userAgent.withFfiStr { ffiUserAgent in wgSecretKey.withFfiBytes { ffiWgSecretKey in libobscuravpn_client.initialize(ffiConfigDir, ffiUserAgent, ffiWgSecretKey, receiveCallback, setNetworkConfigCallback, keychainSetWgSecretKeyCallback, logFlushGuard) } } } self.ptr = p! } func jsonManagerCmd(_ jsonCmd: Data) async -> NeManagerCmdResult { return await withCheckedContinuation { continuation in let context = FfiCb.wrap { (ok_json: FfiStr, err: FfiStr) in if let err = err.nonEmptyString() { continuation.resume(returning: .error(err)) return } continuation.resume(returning: .ok_json(ok_json.string())) } jsonCmd.withFfiBytes { libobscuravpn_client.json_ffi_cmd(self.ptr, context, $0) { FfiCb.call($0, ($1, $2)) } } } } func sendPacket(_ packet: Data) { packet.withFfiBytes { libobscuravpn_client.send_packet(self.ptr, $0) } } func setNetworkInterface(_ networkInterface: (Int, String)?) { if let (index, name): (Int, String) = networkInterface { if index <= 0 || Int64(index) > Int64(UInt32.max) { ffiLog(.Error, "network interface index out of range \(index)") "".withFfiStr { ffiEmptyName in libobscuravpn_client.set_network_interface(self.ptr, 0, ffiEmptyName) } } else { name.withFfiStr { ffiName in libobscuravpn_client.set_network_interface(self.ptr, UInt32(index), ffiName) } } } else { "".withFfiStr { ffiEmptyName in libobscuravpn_client.set_network_interface(self.ptr, 0, ffiEmptyName) } } } func wake() { libobscuravpn_client.wake(self.ptr) } } enum LogLevel: UInt8 { case Trace case Debug case Info case Warn case Error } func ffiLog( _ level: LogLevel, _ message: String, fileID: String = #fileID, function: String = #function, line: Int = #line ) { message.withFfiStr { ffiMessage in fileID.withFfiStr { ffiFileID in function.withFfiStr { ffiFunction in libobscuravpn_client.forward_log(level.rawValue, ffiMessage, ffiFileID, ffiFunction, line) } } } } private func keychainSetWgSecretKeyCallback(key: FfiBytes) -> Bool { ffiLog(.Info, "keychainSetWgSecretKeyCallback entry") let ret = keychainSetWgSecretKey(key.data()) if !ret { ffiLog(.Info, "keychainSetWgSecretKey returned false") } ffiLog(.Info, "keychainSetWgSecretKeyCallback exit") return ret } extension String { func withFfiStr(_ body: (libobscuravpn_client.FfiStr) -> R) -> R { self.data(using: .utf8)!.withFfiBytes { let ffiStr = libobscuravpn_client.FfiStr(bytes: $0) return body(ffiStr) } } } extension FfiStr { func string() -> String { String(decoding: self.bytes.data(), as: UTF8.self) } func nonEmptyString() -> String? { let s = self.string() return s.isEmpty ? nil : s } } extension Data { func withFfiBytes(_ body: (libobscuravpn_client.FfiBytes) -> R) -> R { self.withUnsafeBytes { let ffiBytes = libobscuravpn_client.FfiBytes(buffer: $0.baseAddress, len: UInt($0.count)) return body(ffiBytes) } } } extension FfiBytes { func data() -> Data { Data(bytes: self.buffer, count: Int(self.len)) } } ================================================ FILE: apple/Packet Tunnel Provider/main.swift ================================================ import Foundation import NetworkExtension // TODO: Use `std::panic::set_backtrace_style()` in Rust initialization once stabilized. // https://doc.rust-lang.org/std/panic/fn.set_backtrace_style.html setenv("RUST_BACKTRACE", "1", 1) autoreleasepool { NEProvider.startSystemExtensionMode() } dispatchMain() ================================================ FILE: apple/app-network-extension/Info.plist ================================================ NSExtension NSExtensionPointIdentifier com.apple.networkextension.packet-tunnel NSExtensionPrincipalClass $(PRODUCT_MODULE_NAME).PacketTunnelProvider OSLogPreferences net.obscura.vpn-client-app-ios.app-network-extension DEFAULT-OPTIONS Enable-Oversize-Messages Enable-Private-Data Level Enable Debug Persist Debug com.apple.extensionkit DEFAULT-OPTIONS Enable-Oversize-Messages Enable-Private-Data Level Enable Debug Persist Debug com.apple.xpc.transaction DEFAULT-OPTIONS Enable-Oversize-Messages Enable-Private-Data Level Enable Debug Persist Debug net.obscura.rust-apple DEFAULT-OPTIONS Enable-Oversize-Messages Enable-Private-Data Level Enable Debug Persist Debug Obscura ObscuraSourceVersion $(OBSCURA_SOURCE_VERSION) AppGroupIdentifier $(OBSCURA_APP_APP_GROUP_ID) ================================================ FILE: apple/app-network-extension/entitlements.entitlements ================================================ com.apple.developer.networking.networkextension packet-tunnel-provider com.apple.security.app-sandbox com.apple.security.application-groups $(OBSCURA_APP_APP_GROUP_ID) com.apple.security.network.client com.apple.security.network.server ================================================ FILE: apple/cbindgen-apple.toml ================================================ # NOTE: This is a `cbindgen` config that's specific to the apple platforms, as # only those platformw will have the TargetConditionals.h system header # necessary to reliably detect between macOS and iPhone targets. # # See: # find /Applications/Xcode.app/Contents/Developer/Platforms -name 'TargetConditionals\.h' language = "C" sys_includes = ["TargetConditionals.h"] after_includes = """ #if TARGET_OS_OSX #define OBSCURA_DEFINE_TARGET_OS_MACOS #endif #if TARGET_OS_IOS #define OBSCURA_DEFINE_TARGET_OS_IOS #endif """ [defines] "target_os = ios" = "OBSCURA_DEFINE_TARGET_OS_IOS" "target_os = macos" = "OBSCURA_DEFINE_TARGET_OS_MACOS" ================================================ FILE: apple/client/Assets.xcassets/AccentColor.colorset/Contents.json ================================================ { "colors" : [ { "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: apple/client/Assets.xcassets/AppIcon.appiconset/Contents.json ================================================ { "images" : [ { "filename" : "Icon 04 1024w.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" }, { "filename" : "icon_16x16.png", "idiom" : "mac", "scale" : "1x", "size" : "16x16" }, { "filename" : "icon_16x16@2x@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "16x16" }, { "filename" : "icon_32x32.png", "idiom" : "mac", "scale" : "1x", "size" : "32x32" }, { "filename" : "icon_32x32@2x@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "32x32" }, { "filename" : "icon_128x128.png", "idiom" : "mac", "scale" : "1x", "size" : "128x128" }, { "filename" : "icon_128x128@2x@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "128x128" }, { "filename" : "icon_256x256.png", "idiom" : "mac", "scale" : "1x", "size" : "256x256" }, { "filename" : "icon_256x256@2x@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "256x256" }, { "filename" : "icon_512x512.png", "idiom" : "mac", "scale" : "1x", "size" : "512x512" }, { "filename" : "icon_512x512@2x@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "512x512" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: apple/client/Assets.xcassets/Contents.json ================================================ { "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: apple/client/Assets.xcassets/DecoPrimer.imageset/Contents.json ================================================ { "images" : [ { "filename" : "deco-primer.svg", "idiom" : "universal", "scale" : "1x" }, { "idiom" : "universal", "scale" : "2x" }, { "idiom" : "universal", "scale" : "3x" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: apple/client/Assets.xcassets/EmotePrimer.imageset/Contents.json ================================================ { "images" : [ { "filename" : "DecoEmote.svg", "idiom" : "universal", "scale" : "1x" }, { "idiom" : "universal", "scale" : "2x" }, { "idiom" : "universal", "scale" : "3x" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: apple/client/Assets.xcassets/MenuBarConnected.imageset/Contents.json ================================================ { "images" : [ { "filename" : "Connected.svg", "idiom" : "universal", "scale" : "1x" }, { "filename" : "Connected.svg", "idiom" : "universal", "scale" : "2x" }, { "filename" : "Connected.svg", "idiom" : "universal", "scale" : "3x" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: apple/client/Assets.xcassets/MenuBarConnectedDown.imageset/Contents.json ================================================ { "images" : [ { "filename" : "Connected - Down Cutout.svg", "idiom" : "universal", "scale" : "1x" }, { "filename" : "Connected - Down Cutout.svg", "idiom" : "universal", "scale" : "2x" }, { "filename" : "Connected - Down Cutout.svg", "idiom" : "universal", "scale" : "3x" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: apple/client/Assets.xcassets/MenuBarConnectedUp.imageset/Contents.json ================================================ { "images" : [ { "filename" : "Connected - Up Cutout.svg", "idiom" : "universal", "scale" : "1x" }, { "filename" : "Connected - Up Cutout.svg", "idiom" : "universal", "scale" : "2x" }, { "filename" : "Connected - Up Cutout.svg", "idiom" : "universal", "scale" : "3x" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: apple/client/Assets.xcassets/MenuBarConnectedUpDown.imageset/Contents.json ================================================ { "images" : [ { "filename" : "Connected - Up Down Cutout.svg", "idiom" : "universal", "scale" : "1x" }, { "filename" : "Connected - Up Down Cutout.svg", "idiom" : "universal", "scale" : "2x" }, { "filename" : "Connected - Up Down Cutout.svg", "idiom" : "universal", "scale" : "3x" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: apple/client/Assets.xcassets/MenuBarConnecting-1.imageset/Contents.json ================================================ { "images" : [ { "filename" : "Menu Bar Connecting 1.svg", "idiom" : "universal", "scale" : "1x" }, { "filename" : "Menu Bar Connecting 1.svg", "idiom" : "universal", "scale" : "2x" }, { "filename" : "Menu Bar Connecting 1.svg", "idiom" : "universal", "scale" : "3x" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: apple/client/Assets.xcassets/MenuBarConnecting-2.imageset/Contents.json ================================================ { "images" : [ { "filename" : "Menu Bar Connecting 2.svg", "idiom" : "universal", "scale" : "1x" }, { "filename" : "Menu Bar Connecting 2.svg", "idiom" : "universal", "scale" : "2x" }, { "filename" : "Menu Bar Connecting 2.svg", "idiom" : "universal", "scale" : "3x" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: apple/client/Assets.xcassets/MenuBarConnecting-3.imageset/Contents.json ================================================ { "images" : [ { "filename" : "Menu Bar Connecting 3.svg", "idiom" : "universal", "scale" : "1x" }, { "filename" : "Menu Bar Connecting 3.svg", "idiom" : "universal", "scale" : "2x" }, { "filename" : "Menu Bar Connecting 3.svg", "idiom" : "universal", "scale" : "3x" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: apple/client/Assets.xcassets/MenuBarDisconnected.imageset/Contents.json ================================================ { "images" : [ { "filename" : "Disconnected.svg", "idiom" : "universal", "scale" : "1x" }, { "filename" : "Disconnected.svg", "idiom" : "universal", "scale" : "2x" }, { "filename" : "Disconnected.svg", "idiom" : "universal", "scale" : "3x" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: apple/client/Assets.xcassets/ObscuraOrange.colorset/Contents.json ================================================ { "colors" : [ { "color" : { "color-space" : "srgb", "components" : { "alpha" : "1.000", "blue" : "37", "green" : "96", "red" : "255" } }, "idiom" : "universal" }, { "appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ], "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: apple/client/Assets.xcassets/UpdateAvailable.imageset/Contents.json ================================================ { "images" : [ { "filename" : "Update Available.svg", "idiom" : "universal", "scale" : "1x" }, { "filename" : "Update Available.svg", "idiom" : "universal", "scale" : "2x" }, { "filename" : "Update Available.svg", "idiom" : "universal", "scale" : "3x" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: apple/client/Assets.xcassets/custom.globe.badge.gearshape.fill.symbolset/Contents.json ================================================ { "info" : { "author" : "xcode", "version" : 1 }, "symbols" : [ { "filename" : "custom.globe.badge.gearshape.fill.svg", "idiom" : "universal" } ] } ================================================ FILE: apple/client/Constants.swift ================================================ import Foundation enum UserDefaultKeys { static let LoginItemRegistered = "LoginItemRegistered" static let SelectedAppearance = "SelectedAppearance" static let allKeys = [LoginItemRegistered, SelectedAppearance] } enum URLs { static let SystemExtensionHelp = URL(string: "https://support.apple.com/en-ca/120363")! static let PrivacySecurityExtensionSettings = URL(string: "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Security")! static let ExtensionSettings = URL(string: "x-apple.systempreferences:com.apple.LoginItems-Settings.extension?ExtensionItems")! static let NetworkSettings = URL(string: "x-apple.systempreferences:com.apple.NetworkExtensionSettingsUI.NESettingsUIExtension")! // See [Deep Linking](https://soveng.getoutline.com/doc/deep-linking-rhhx0E5oDB) static let AppOpenURL = URL(string: "obscuravpn:///open")! static let AppAccountPage = URL(string: "obscuravpn:///account")! static let AppLocationPage = URL(string: "obscuravpn:///location")! } ================================================ FILE: apple/client/ContentView.swift ================================================ import OrderedCollections import OSLog import SwiftUI #if !os(macOS) import UIKit #endif import UniformTypeIdentifiers import WebKit private let logger = Logger( subsystem: Bundle.main.bundleIdentifier!, category: "ContentView" ) enum AppView: String, Hashable, Identifiable { case account case connection case location case settings case help case about case developer var id: String { self.rawValue } var systemImageName: String { switch self { case .account: "person.circle" case .connection: "network.badge.shield.half.filled" case .location: "mappin.and.ellipse" case .settings: "gear" case .help: "questionmark.circle" case .about: "info.circle" case .developer: "book.and.wrench" } } var ipcValue: String { self.rawValue } var needsScroll: Bool { switch self { case .connection, .help: false case .account, .settings, .location, .about, .developer: true } } } let STABLE_VIEWS: OrderedSet = OrderedSet([ .connection, .location, .account, .settings, .help, .about, ]) let EXPERIMETNAL_VIEWS: OrderedSet = OrderedSet() let DEBUG_VIEWS: OrderedSet = OrderedSet([.developer]) let VIEW_MODES = [ STABLE_VIEWS, STABLE_VIEWS.union(DEBUG_VIEWS), STABLE_VIEWS.union(EXPERIMETNAL_VIEWS).union(DEBUG_VIEWS), ] #if DEBUG let DEFAULT_VIEW_MODE = VIEW_MODES.count - 1 #else let DEFAULT_VIEW_MODE = 0 #endif class ViewModeManager: ObservableObject { @Published private var viewIndex = DEFAULT_VIEW_MODE private var eventMonitor: Any? init() { #if os(macOS) self.eventMonitor = NSEvent.addLocalMonitorForEvents( matching: .keyDown ) { event in if event.charactersIgnoringModifiers == "D", event.modifierFlags.contains(.command) { // Cmd+Shift+d self.viewIndex = (self.viewIndex + 1) % VIEW_MODES.count return nil } return event } #endif } deinit { #if os(macOS) if self.eventMonitor != nil { NSEvent.removeMonitor(self.eventMonitor!) } #endif } func getViews() -> OrderedSet { return VIEW_MODES[self.viewIndex] } func getIOSViews() -> OrderedSet { let iOSViews: Set = [ .connection, .location, .account, .settings, .about, ] return self.getViews().filter { iOSViews.contains($0) } } } extension AccountStatus { var badgeText: String? { guard let days = daysUntilExpiry() else { return nil } if !expiringSoon() { return nil } if days > 3 { return "expires soon" } if days > 1 { return "exp. in \(days)d" } if days == 1 { return "exp. in 1d" } return isActive() ? "exp. today" : "expired" } var badgeColor: Color? { guard let days = daysUntilExpiry() else { return nil } return days <= 3 ? .red : .yellow } } struct ContentView: View { @ObservedObject var appState: AppState @ObservedObject var webviewsController: WebviewsController // when accountBadge and badgeColor are nil, the account status is either unknown OR a badge does not need to be shown // if ever the account is reset to nil, these variables will maintain their last computed values // see https://linear.app/soveng/issue/OBS-1159/ regarding why account could be reset to nil @State private var accountBadge: String? @State private var badgeColor: Color? @State private var indicateUpdateAvailable: Bool = false #if os(macOS) @EnvironmentObject private var appDelegate: AppDelegate #else @State private var tabBarHeight: CGFloat = 0 #endif @ObservedObject private var viewMode = ViewModeManager() // when this variable is set, force hide the toolbar and show "Obscura" for the navigation title // otherwise let macOS manage the state and let the navigation title be driven from the navigation view shown @State private var loginViewShown: Bool // set alongside above, want to hide the sidebar when navigation is not allowed @State private var splitViewVisibility: NavigationSplitViewVisibility let accountBadgeTimer = Timer.publish(every: 5, on: .main, in: .common) .autoconnect() init(appState: AppState) { self.appState = appState self.webviewsController = appState.webviewsController let forceHide = appState.status.accountId == nil || appState.status.inNewAccountFlow self.loginViewShown = forceHide self.splitViewVisibility = forceHide ? .detailOnly : .automatic } var body: some View { self.content .onReceive( self.accountBadgeTimer, perform: { _ in if let account = self.appState.status.account { self.accountBadge = account.badgeText self.badgeColor = account.badgeColor } self.indicateUpdateAvailable = self.appState.osStatus.get().updaterStatus.type == .available } ) .onChange(of: self.webviewsController.tab) { view in // inform webUI to update navigation self.webviewsController.obscuraWebView?.navigateTo(view: view) } .onChange(of: self.appState.status) { status in if let account = self.appState.status.account { self.accountBadge = account.badgeText self.badgeColor = account.badgeColor } if status.accountId == nil || status.inNewAccountFlow { self.loginViewShown = true self.splitViewVisibility = .detailOnly } else if self.loginViewShown { // If previously force closed pop it open. self.loginViewShown = false self.splitViewVisibility = .automatic } } .onChange(of: self.webviewsController.showSubscriptionManageSheet) { newValue in if !newValue { Task { try? await self.appState.getAccountInfo() } } } // once we are targeting macOS 14+, we can use .toolbar(removing: .sidebarToggle) instead .toolbar(self.loginViewShown ? .hidden : .automatic) .onAppear { self.appState.webviewsController.tab = STABLE_VIEWS.first! logger.log("Registering openUrlCallback with AppDelegate") #if os(macOS) self.appDelegate.openUrlCallback = { url in self.webviewsController.handleObscuraURL(url: url) } #endif } } @ViewBuilder func viewLabel(_ view: AppView) -> some View { let label = Label( view.rawValue.capitalized, systemImage: view.systemImageName ) .listItemTint(Color("ObscuraOrange")) if view == .account && self.accountBadge != nil && self.badgeColor != nil { label.badge( Text(self.accountBadge!) .monospacedDigit() .foregroundColor(self.badgeColor) .bold() ) // this has to be here, otherwise the label color is system accent default .listItemTint(Color("ObscuraOrange")) } else if view == .about && self.indicateUpdateAvailable { HStack { label Spacer() Circle() .fill(Color.green) .frame(width: 8, height: 8) } // this has to be here, otherwise the label color is system accent default .listItemTint(Color("ObscuraOrange")) } else { label } } @ViewBuilder var content: some View { if let obscuraWebView = webviewsController.obscuraWebView { #if os(macOS) NavigationSplitView(columnVisibility: self.$splitViewVisibility) { List( self.viewMode.getViews(), id: \.self, selection: self.$webviewsController.tab ) { view in self.viewLabel(view) } .environment(\.sidebarRowSize, .large) .navigationSplitViewColumnWidth(min: 175, ideal: 200) } detail: { ObscuraUIMacOSWrapper( webView: obscuraWebView) .navigationTitle( self.loginViewShown ? "Obscura" : self.webviewsController.tab.rawValue.capitalized ) .frame(minWidth: 390) } #else ObscuraUIIOSViewAndTabsWrapper( webView: obscuraWebView, webviewsController: self.webviewsController, tabs: self.viewMode.getIOSViews(), showTabBar: !self.loginViewShown ) .ignoresSafeArea() .ignoresSafeArea() .tint(Color("ObscuraOrange")) .onChange(of: self.appState.storeKitModel.subscriptionProduct) { if let model = self.appState.storeKitModel.toSubscriptionModel() { _ = self.appState.osStatus.update { value in value.storeKit.subscriptionProduct = model } } } .onChange(of: self.appState.storeKitModel.externalPaymentsAllowed, initial: true) { _, allowed in _ = self.appState.osStatus.update { value in value.storeKit.externalPaymentsAllowed = allowed } } .sheet( isPresented: self.$webviewsController.showModalWebview) { self.webviewsController.externalWebView .ignoresSafeArea() .presentationDetents([.large]) .presentationDragIndicator(.visible) } .manageSubscriptionsSheet( isPresented: self.$webviewsController.showSubscriptionManageSheet) .offerCodeRedemption(isPresented: self.$appState.showOfferCodeRedemption) { result in switch result { case .success: logger.info( "Promo code redemption flow completed successfully. (errors only show up if a valid code fails to redeem. So invalid codes and not entering a code land you here)" ) _ = self.appState.osStatus.update { value in value.offerCodeRedemptionSuccess = true } case .failure(let error): logger.error("Promo code redemption failed: \(error, privacy: .public)") _ = self.appState.osStatus.update { value in value.offerCodeRedemptionSuccess = false } } } .onOpenURL { incomingURL in self.webviewsController.handleObscuraURL(url: incomingURL) } .onReceive(NotificationCenter.default.publisher(for: UIApplication.userDidTakeScreenshotNotification)) { _ in guard self.appState.status.inNewAccountFlow else { return } logger.debug("Screenshot detected during new account flow") self.webviewsController.obscuraWebView?.handleScreenshotDetected() } .fullScreenCover(isPresented: self.$appState.needsIsEnabledFix) { VStack(spacing: 12) { Text("Obscura VPN was disabled by another VPN app. Click the button below if you want to enable it again. This will close any active VPN tunnels from other apps.").font(.body) Button("Continue") { self.appState.runIsEnabledFix() }.buttonStyle(.borderedProminent) } .padding() } #endif } else { EmptyView() } } } struct SidebarButton: View { var body: some View { Button( action: self.toggleSidebar, label: { Image(systemName: "sidebar.leading") } ) } private func toggleSidebar() { #if os(macOS) NSApp.keyWindow?.firstResponder?.tryToPerform( #selector(NSSplitViewController.toggleSidebar(_:)), with: nil ) #endif } } ================================================ FILE: apple/client/DebugBundle+XP.swift ================================================ import Foundation public class DebugBundleStatus: Encodable { var inProgressCounter: Int = 0 var inProgress: Bool { return self.inProgressCounter > 0 } var latestPath: String? func start() { self.inProgressCounter += 1 } func finish() { self.inProgressCounter -= 1 } func setPath(_ path: String) { self.latestPath = path } func markError() { self.latestPath = nil } enum CodingKeys: String, CodingKey { case inProgressCounter case inProgress case latestPath } public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(self.inProgressCounter, forKey: .inProgressCounter) try container.encode(self.inProgress, forKey: .inProgress) try container.encode(self.latestPath, forKey: .latestPath) } } ================================================ FILE: apple/client/DebugBundle.swift ================================================ #if os(macOS) import AppKit #else import StoreKit import UIKit #endif import Foundation import NetworkExtension import OSLog private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "DebugBundle") struct TaskResult: Encodable { var total_s: Float var error: String? } /// A tool to track and manage individual bundle tasks. private class BundleTask { let bundle: DebugBundleBuilder let name: String private var lock = NSLock() private var task: Task? private var start = SuspendingClock.now private var lastPing: SuspendingClock.Instant private var done: SuspendingClock.Instant? private var timeout = Duration.seconds(70) /// Create and start the task. @discardableResult init( _ bundle: DebugBundleBuilder, _ name: String, _ f: @escaping (BundleTask) async throws -> Void ) { self.bundle = bundle self.name = name self.lastPing = self.start bundle.pendingTasks.start() self.lock.withLock { self.task = Task.detached(priority: .userInitiated) { [self] in do { try await f(self) self.writeResult(error: nil) } catch { self.writeResult(error: error.localizedDescription) } } } self.watchdog() } /// Ping the watchdog timer. /// /// Throws if the task was cancelled (for example due to a timeout). func pingWatchdog() throws { try Task.checkCancellation() self.lastPing = SuspendingClock.now } private func watchdog() { self.lock.withLock { let deadline = self.lastPing + self.timeout let now = SuspendingClock.now if now > deadline { self.writeResultWithLock(error: "Timeout") self.task!.cancel() } else { let remaining_s = (deadline - now) / .seconds(1) DispatchQueue.main.asyncAfter(deadline: .now() + remaining_s) { self.watchdog() } } } } private func writeResultWithLock(error: String?) { if self.done != nil { return } let done = SuspendingClock.now self.done = done let duration = done - self.start logger.info("Task \(self.name, privacy: .public) finished in \(duration, privacy: .public) error: \(error ?? "-", privacy: .public)") self.bundle.lock.withLock { self.bundle.tasks[self.name] = TaskResult( total_s: Float(duration / .seconds(1)), error: error ) } self.bundle.pendingTasks.complete() } private func writeResult(error: String?) { self.lock.withLock { self.writeResultWithLock(error: error) } } } private class DebugBundleBuilder { let tmpFolder: URL let archiveFolder: URL let bootTimestamp: Date? let bundleTimestamp = Date() let jsonEncoder = JSONEncoder() let logStartTimestamp: Date let appState: AppState? let userFeedback: String? let dispatchQueue = DispatchQueue.global(qos: .userInitiated) var lock = NSLock() var pendingTasks = PendingTasks() var tasks: [String: TaskResult] = [:] init(appState: AppState?, userFeedback: String?) throws { self.appState = appState self.userFeedback = userFeedback self.tmpFolder = try FileManager.default.url( for: FileManager.SearchPathDirectory.itemReplacementDirectory, in: FileManager.SearchPathDomainMask.userDomainMask, appropriateFor: FileManager.default.temporaryDirectory, create: true ) self.archiveFolder = self.tmpFolder.appending( component: "Obscura Debugging Archive \(utcDateFormat.string(from: self.bundleTimestamp))" ) try FileManager.default.createDirectory( at: self.archiveFolder, withIntermediateDirectories: false ) #if os(iOS) self.bootTimestamp = nil #else do { let tv = try Sysctl.value(ofType: timeval.self, forName: "kern.boottime") self.bootTimestamp = Date(timeIntervalSince1970: Double(tv.tv_sec) + Double(tv.tv_usec) / 1_000_000.0) } catch { logger.error("Failed to read kern.boottime \(error, privacy: .public)") self.bootTimestamp = nil } #endif self.jsonEncoder.outputFormatting = [ .prettyPrinted, .sortedKeys, ] self.logStartTimestamp = if let boot = self.bootTimestamp, ProcessInfo.processInfo.systemUptime < 24 * 3600 { // If awake for less than 24h get all logs since boot. boot - 10 } else { // Otherwise just go back 12h. self.bundleTimestamp - 12 * 3600 } } deinit { do { try FileManager.default.removeItem(at: self.tmpFolder) } catch { logger.error("Error cleaning up debug bundle temp files \(error, privacy: .public)") } } private func createDir(name: String) throws -> URL { let path = self.archiveFolder.appending(component: name) try FileManager.default.createDirectory(at: path, withIntermediateDirectories: false) return path } private func copyFile(source: URL, name: String) throws { let path = self.archiveFolder.appending(component: name) try FileManager.default.copyItem(at: source, to: path) } private func openFile(name: String) throws -> FileHandle { let path = self.archiveFolder.appending(component: name) FileManager.default.createFile(atPath: path.path, contents: nil, attributes: nil) return try FileHandle(forWritingTo: path) } private func writeFile(name: String, data: Data) throws { let path = self.archiveFolder.appending(component: name) try data.write(to: path) } func writeJson(name: String, _ json: T) throws where T: Encodable { let data = try self.jsonEncoder.encode(json) try self.writeFile(name: name, data: data) } private func writeFile(name: String, string: String) throws { // Safe to unwrap because String is unicode. let data = string.data(using: .utf8)! try self.writeFile(name: name, data: data) } func writeError(name: String, error: Error) { logger.error("Error bundling \(name, privacy: .public): \(error, privacy: .public)") do { try self.writeFile(name: "bundle-error-\(name).txt", string: error.localizedDescription) } catch { logger.error("Error bundling error for \(name, privacy: .public): \(error, privacy: .public)") } } #if os(macOS) private func bundleCmd(_ name: String, _ args: [String]) { self.bundleTask(name) { _task in let child = Process() child.executableURL = URL(filePath: args[0]) child.arguments = Array(args.suffix(from: 1)) child.standardInput = FileHandle.nullDevice child.standardOutput = try self.openFile(name: "\(name)-stdout.txt") child.standardError = try self.openFile(name: "\(name)-stderr.txt") try child.run() child.waitUntilExit() if child.terminationStatus != 0 { try self.writeFile(name: "\(name)-status.txt", string: String(child.terminationStatus)) } } } #endif private func bundlePlist(name: String, path: URL) { self.bundleTask(name) { _task in let plist = try Data(contentsOf: path) var value = try PropertyListSerialization.propertyList(from: plist, options: [], format: nil) prepareForJson(&value) let json = try JSONSerialization.data( withJSONObject: value, options: [.fragmentsAllowed, .prettyPrinted] ) try self.writeFile(name: "\(name).json", data: json) } } private func bundlePlist(path: URL) { self.bundlePlist(name: path.lastPathComponent, path: path) } func bundleInfo() throws { struct Info: Encodable { let AppVersion = sourceVersion() let BuildNumber = buildVersion() let BootTimestamp: String? let BundleTimestamp: String let LogStartTimestamp: String let LowPowerMode = ProcessInfo.processInfo.isLowPowerModeEnabled let OSVersion = [ ProcessInfo.processInfo.operatingSystemVersion.majorVersion, ProcessInfo.processInfo.operatingSystemVersion.minorVersion, ProcessInfo.processInfo.operatingSystemVersion.patchVersion, ] let OSVersionString = ProcessInfo.processInfo.operatingSystemVersionString #if os(macOS) let Model = Sysctl.model // Model identifier to model name: https://support.apple.com/en-ca/102869 #else let Model = UIDevice.current.model #endif let PID = ProcessInfo.processInfo.processIdentifier let ProcessName = ProcessInfo.processInfo.processName let ProcessorCountActive = ProcessInfo.processInfo.processorCount let ProcessorCountPhysical = ProcessInfo.processInfo.activeProcessorCount #if os(macOS) let ProcessorName: String = (try? Sysctl.string(for: "machdep.cpu.brand_string")) ?? "Unknown" #endif let RAMPhysicalGiB = Double(ProcessInfo.processInfo.physicalMemory) / 1024.0 / 1024.0 / 1024.0 let SourceID = sourceId() let ThermalState: String let UptimeHours = ProcessInfo.processInfo.systemUptime / 3600 init(_ this: DebugBundleBuilder) { self.BootTimestamp = if let boot = this.bootTimestamp { utcDateFormat.string(from: boot) } else { nil } self.BundleTimestamp = utcDateFormat.string(from: this.bundleTimestamp) self.LogStartTimestamp = utcDateFormat.string(from: this.logStartTimestamp) self.ThermalState = switch ProcessInfo.processInfo.thermalState { case .nominal: "nominal" case .fair: "fair" case .serious: "serious" case .critical: "critical" default: "unknown" } } } try self.writeJson(name: "info.json", Info(self)) } #if os(macOS) func getLogStore() throws -> (OSLogStore, String) { do { let logStore = try OSLogStore.local() return (logStore, "system-log.json") } catch { self.writeError(name: "system-logs", error: error) let logStore = try OSLogStore(scope: .currentProcessIdentifier) return (logStore, "client-log.json") } } #endif #if os(iOS) func getLogStore() throws -> (OSLogStore, String) { let logStore = try OSLogStore(scope: .currentProcessIdentifier) return (logStore, "client-log.json") } #endif func bundleLogs() { self.bundleTask("logs") { task in let (logStore, fileName) = try self.getLogStore() let logEntries = try logStore.getEntries( at: logStore.position(date: self.logStartTimestamp), matching: NSPredicate(format: """ process IN { "Obscura VPN (Debug Dev Server)", "Obscura VPN (Debug)", "Obscura VPN", "kernel", "neagent", "nehelper", "nesessionmanager", "net.obscura.vpn-client-app.system-network-extension", "sysextd" } || subsystem IN { "com.apple.networkextension", "com.apple.powerd" } || eventMessage CONTAINS "bscura" || subsystem CONTAINS "bscura" """) ) let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSSxx" let encoder = JSONEncoder() encoder.dateEncodingStrategy = .formatted(dateFormatter) let file = try self.openFile(name: fileName) let newline = "\n".data(using: .utf8)! for entry in logEntries { if entry.date > self.bundleTimestamp { break } var line = try encoder.encode(entry) line.append(newline) try file.write(contentsOf: line) try task.pingWatchdog() } try file.close() } } #if os(macOS) func bundleExtensions() async throws { let extensions = await getExtensionDebugInfo() struct ExtensionDebugInfo: Encodable { let bundleIdentifier: String let bundleVersion: String let bundleShortVersion: String let url: URL let isAwaitingUserApproval: Bool let isEnabled: Bool let isUninstalling: Bool } try self.writeJson( name: "extensions.json", extensions.map { ExtensionDebugInfo( bundleIdentifier: $0.bundleIdentifier, bundleVersion: $0.bundleVersion, bundleShortVersion: $0.bundleShortVersion, url: $0.url, isAwaitingUserApproval: $0.isAwaitingUserApproval, isEnabled: $0.isEnabled, isUninstalling: $0.isUninstalling ) } ) for (i, ext) in extensions.enumerated() { if !ext.isEnabled { continue } let name = "extension-\(ext.bundleIdentifier)-\(i).provisionprofile" do { try self.copyFile( source: ext.url.appending(path: "Contents/embedded.provisionprofile"), name: name ) } catch { self.writeError(name: name, error: error) } } } #endif func bundleNETunnelProviderManager() { guard let manager = self.appState?.manager else { self.writeError(name: "ne-tunnel-provider-manager", error: "appState or manager is nil") return } struct ConnectionInfo: Encodable { let status: NEVPNStatus init(_ connection: NEVPNConnection) { self.status = connection.status } } struct ProxyServerInfo: Encodable { let address: String let authenticationRequired: Bool let port: Int init(_ proxyServer: NEProxyServer) { self.address = proxyServer.address self.authenticationRequired = proxyServer.authenticationRequired self.port = proxyServer.port } } struct ProxySettingsInfo: Encodable { let autoProxyConfigurationEnabled: Bool let exceptionList: [String]? let excludeSimpleHostnames: Bool let httpEnabled: Bool let httpServer: ProxyServerInfo? let matchDomains: [String]? let proxyAutoConfigurationJavaScript: String? let proxyAutoConfigurationURL: URL? init(_ proxySettings: NEProxySettings) { self.autoProxyConfigurationEnabled = proxySettings.autoProxyConfigurationEnabled self.exceptionList = proxySettings.exceptionList self.excludeSimpleHostnames = proxySettings.excludeSimpleHostnames self.httpEnabled = proxySettings.httpEnabled self.httpServer = proxySettings.httpServer.map { ProxyServerInfo($0) } self.matchDomains = proxySettings.matchDomains self.proxyAutoConfigurationJavaScript = proxySettings.proxyAutoConfigurationJavaScript self.proxyAutoConfigurationURL = proxySettings.proxyAutoConfigurationURL } } struct ProtocolConfigurationInfo: Encodable { let disconnectOnSleep: Bool let enforceRoutes: Bool let excludeLocalNetworks: Bool let includeAllNetworks: Bool let proxySettings: ProxySettingsInfo? let serverAddress: String? init(_ protocolConfiguration: NEVPNProtocol) { self.disconnectOnSleep = protocolConfiguration.disconnectOnSleep self.enforceRoutes = protocolConfiguration.enforceRoutes // TODO: include once our minimal version is macOS 13.3 // self.excludeAPNs = protocolConfiguration.excludeAPNs // self.excludeCellularServices = protocolConfiguration.excludeCellularServices // self.excludeDeviceCommunication = protocolConfiguration.excludeDeviceCommunication self.excludeLocalNetworks = protocolConfiguration.excludeLocalNetworks self.includeAllNetworks = protocolConfiguration.includeAllNetworks self.proxySettings = protocolConfiguration.proxySettings.map { ProxySettingsInfo($0) } self.serverAddress = protocolConfiguration.serverAddress } } struct OnDemandRuleInfo: Encodable { let action: String let dnsSearchDomainMatch: [String]? let dnsServerAddressMatch: [String]? let interfaceTypeMatch: String let probeURL: URL? let ssidMatch: [String]? init(_ onDemandRule: NEOnDemandRule) { self.action = switch onDemandRule.action { case .connect: "connect" case .disconnect: "disconnect" case .evaluateConnection: "evaluateConnection" case .ignore: "ignore" @unknown default: "unknown" } self.dnsSearchDomainMatch = onDemandRule.dnsSearchDomainMatch self.dnsServerAddressMatch = onDemandRule.dnsServerAddressMatch self.interfaceTypeMatch = switch onDemandRule.interfaceTypeMatch { case .any: "any" case .ethernet: "ethernet" case .wiFi: "wiFi" case .cellular: "cellular" @unknown default: "unknown" } self.probeURL = onDemandRule.probeURL self.ssidMatch = onDemandRule.ssidMatch } } struct ManagerInfo: Encodable { let connection: ConnectionInfo let protocolConfiguration: ProtocolConfigurationInfo? let routingMethod: String let isEnabled: Bool let isOnDemandEnabled: Bool let onDemandRules: [OnDemandRuleInfo]? init(_ manager: NETunnelProviderManager) { self.connection = ConnectionInfo(manager.connection) self.protocolConfiguration = manager.protocolConfiguration.map { ProtocolConfigurationInfo($0) } self.routingMethod = switch manager.routingMethod { case .destinationIP: "destinationIP" case .networkRule: "networkRule" case .sourceApplication: "sourceApplication" @unknown default: "unknown" } self.isEnabled = manager.isEnabled self.isOnDemandEnabled = manager.isOnDemandEnabled self.onDemandRules = manager.onDemandRules.map { $0.map { OnDemandRuleInfo($0) }} } } do { try self.writeJson(name: "ne-tunnel-provider-manager.json", ManagerInfo(manager)) } catch { self.writeError(name: "ne-tunnel-provider-manager", error: error) } } func bundleNEDebugInfo() async { guard let manager = self.appState?.manager else { self.writeError(name: "ne-debug-info", error: "appState or manager is nil") return } do { let neDebugInfoJsonString = try await runNeJsonCommand(manager, NeManagerCmd.getDebugInfo.json(), name: "getDebugInfo", attemptTimeout: .seconds(70)) let value = try JSONSerialization.jsonObject(with: Data(neDebugInfoJsonString.utf8)) let json = try JSONSerialization.data( withJSONObject: value, options: [.fragmentsAllowed, .prettyPrinted, .sortedKeys] ) try self.writeFile(name: "ne-debug-info.json", data: json) } catch { self.writeError(name: "ne-debug-info", error: error) } } func bundleRustLog() async { guard let logDir = logDir() else { self.writeError(name: "rust-log", error: "logDir is nil") return } do { try self.copyFile(source: URL(fileURLWithPath: logDir), name: "rust-logs") } catch { self.writeError(name: "rust-log", error: error) } } #if os(iOS) @MainActor func bundleStoreKitInfo() async { guard let storeKitModel = self.appState?.storeKitModel else { self.writeError(name: "storekit-info", error: "appState is nil") return } var products: [Any] = [] var transactionsVerified: [Any] = [] var transactionsUnverified: [[String: Any]] = [] do { var products = try await storeKitModel.collectDebugData() for await verificationResult in Transaction.all { switch verificationResult { case .verified(let transaction): try transactionsVerified.append(JSONSerialization.jsonObject(with: transaction.jsonRepresentation)) case .unverified(let transaction, let error): try transactionsUnverified.append([ "transaction": JSONSerialization.jsonObject(with: transaction.jsonRepresentation), "error": error.localizedDescription, ]) } } let info: [String: Any] = [ "products": products, "transactions": [ "verified": transactionsVerified, "unverified": transactionsUnverified, ], ] let json = try JSONSerialization.data( withJSONObject: info, options: [.fragmentsAllowed, .prettyPrinted, .sortedKeys] ) try self.writeFile(name: "storekit-info.json", data: json) } catch { self.writeError(name: "storekit-info", error: error) } } #endif func bundleTask(_ name: String, _ block: @escaping (BundleTask) async throws -> Void) { BundleTask(self, name, block) } func bundleAll() async { self.bundleLogs() self.bundleTask("user-feedback") { _task in try self.writeFile(name: "user-feedback.txt", string: self.userFeedback ?? "") } self.bundleTask("app-provisionprofile") { _task in #if os(macOS) let path = "Contents/embedded.provisionprofile" #else let path = "embedded.mobileprovision" #endif try self.copyFile( source: Bundle.main.bundleURL.appending(path: path), name: "app.provisionprofile" ) } self.bundleTask("app-extension-provisionprofile") { _task in #if os(macOS) let source = extensionBundle() .bundleURL .appending(path: "Contents/embedded.provisionprofile") #else let source = Bundle.main.bundleURL .appending(path: "PlugIns/App Network Extension.appex/embedded.mobileprovision") #endif try self.copyFile( source: source, name: "app-extension.provisionprofile" ) } self.bundleTask("ne-tunnel-provider-manager") { _task in self.bundleNETunnelProviderManager() } self.bundleTask("ne-debug-info") { _task in await self.bundleNEDebugInfo() } self.bundleTask("info") { _task in try self.bundleInfo() } // TODO: https://linear.app/soveng/issue/OBS-2210/implement-more-diagnostics-on-ios #if os(macOS) self.bundleTask("extensions") { _task in try await self.bundleExtensions() } self.bundleCmd("arp", ["/usr/sbin/arp", "-na"]) self.bundleCmd("csrutil-status", ["/usr/bin/csrutil", "status"]) self.bundleCmd("dig-apple.com", ["/usr/bin/dig", "+time=2", "www.apple.com"]) self.bundleCmd("dig-google.com", ["/usr/bin/dig", "+time=2", "google.com"]) self.bundleCmd("dig-v1.api.prod.obscura.net", ["/usr/bin/dig", "+time=2", "v1.api.prod.obscura.net"]) self.bundleCmd("dns", ["/usr/sbin/scutil", "--dns", "-dv"]) self.bundleCmd("hostinfo", ["/usr/bin/hostinfo"]) self.bundleCmd("http-v1.api.prod.obscura.net", ["/usr/bin/curl", "--verbose", "--insecure", "--location", "--silent", "--show-error", "https://v1.api.prod.obscura.net/api/ping"]) self.bundleCmd("http-v1.api.prod.obscura.net-apple.com", ["/usr/bin/curl", "--verbose", "--insecure", "--silent", "--show-error", "--connect-to", "::v1.api.prod.obscura.net:", "https://apple.com/api/ping", "-Hhost:v1.api.prod.obscura.net"]) self.bundleCmd("http-v1.api.prod.obscura.net-google.com", ["/usr/bin/curl", "--verbose", "--insecure", "--silent", "--show-error", "--connect-to", "::v1.api.prod.obscura.net:", "https://google.com/api/ping", "-Hhost:v1.api.prod.obscura.net"]) self.bundleCmd("ifconfig", ["/sbin/ifconfig", "-aLbmrvv"]) self.bundleCmd("netstat-interface-stats", ["/usr/sbin/netstat", "-ind"]) self.bundleCmd("netstat-listen-queues", ["/usr/sbin/netstat", "-Lanv"]) self.bundleCmd("netstat-routes", ["/usr/sbin/netstat", "-nral"]) self.bundleCmd("netstat-stats", ["/usr/sbin/netstat", "-s"]) self.bundleCmd("network-info", ["/usr/sbin/scutil", "--nwi", "-dv"]) self.bundleCmd("ping-1.1.1.1", ["/sbin/ping", "-oc5", "1.1.1.1"]) self.bundleCmd("ping-2001:4860:4860::8888", ["/sbin/ping6", "-oc5", "2001:4860:4860::8888"]) self.bundleCmd("ping-2606:4700:4700::1111", ["/sbin/ping6", "-oc5", "2606:4700:4700::1111"]) self.bundleCmd("ping-8.8.8.8", ["/sbin/ping", "-oc5", "8.8.8.8"]) self.bundleCmd("ping-v1.api.prod.obscura.net", ["/sbin/ping", "-oc5", "v1.api.prod.obscura.net"]) self.bundleCmd("processes", ["/bin/ps", "axlww"]) self.bundleCmd("proxy", ["/usr/sbin/scutil", "--proxy", "-dv"]) self.bundleCmd("reachability-0.0.0.0", ["/usr/sbin/scutil", "-r", "www.apple.com", "-dv"]) self.bundleCmd("reachability-1.1.1.1", ["/usr/sbin/scutil", "-r", "1.1.1.1", "-dv"]) self.bundleCmd("reachability-169.254.0.0", ["/usr/sbin/scutil", "-r", "169.254.0.0", "-dv"]) self.bundleCmd("reachability-169.254.0.0", ["/usr/sbin/scutil", "-r", "169.254.0.0", "-dv"]) self.bundleCmd("reachability-8.8.8.8", ["/usr/sbin/scutil", "-r", "8.8.8.8", "-dv"]) self.bundleCmd("route-0.0.0.0", ["/sbin/route", "-nv", "get", "0.0.0.0"]) self.bundleCmd("route-1.1.1.1", ["/sbin/route", "-nv", "get", "1.1.1.1"]) self.bundleCmd("route-2001:4860:4860::8888", ["/sbin/route", "-nv", "get", "-inet6", "2001:4860:4860::8888"]) self.bundleCmd("route-2606:4700:4700::1111", ["/sbin/route", "-nv", "get", "-inet6", "2606:4700:4700::1111"]) self.bundleCmd("route-8.8.8.8", ["/sbin/route", "-nv", "get", "8.8.8.8"]) self.bundleCmd("route-::", ["/sbin/route", "-nv", "get", "-inet6", "::"]) self.bundleCmd("route-apple.com", ["/sbin/route", "-nv", "get", "www.apple.com"]) self.bundleCmd("route-google.com", ["/sbin/route", "-nv", "get", "google.com"]) self.bundleCmd("route-v1.api.prod.obscura.net", ["/sbin/route", "-nv", "get", "v1.api.prod.obscura.net"]) self.bundleCmd("scutil-advisory", ["/usr/sbin/scutil", "--advisory", ""]) self.bundleCmd("scutil-rank", ["/usr/sbin/scutil", "--rank", ""]) self.bundleCmd("skywalk-status", ["/usr/sbin/skywalkctl", "status"]) self.bundleCmd("sysctl", ["/usr/sbin/sysctl", "-a"]) self.bundleCmd("system-profiles", ["/usr/sbin/system_profiler", "-json", "-detailLevel", "full", "SPAirPortDataType", "SPConfigurationProfileDataType", "SPDiagnosticsDataType", "SPEthernetDataType", "SPFibreChannelDataType", "SPFirewallDataType", "SPHardwareDataType", "SPInternationalDataType", "SPManagedClientDataType", "SPMemoryDataType", "SPNetworkDataType", "SPNetworkLocationDataType", "SPNVMeDataType", "SPPowerDataType", "SPSoftwareDataType", "SPStorageDataType", "SPUniversalAccessDataType"]) self.bundleCmd("vpn-connections", ["/usr/sbin/scutil", "--nc", "list"]) self.bundlePlist(path: URL(filePath: "/Library/Preferences/SystemConfiguration/NetworkInterfaces.plist")) self.bundlePlist(path: URL(filePath: "/Library/Preferences/com.apple.networkd.plist")) self.bundlePlist(path: URL(filePath: "/Library/Preferences/com.apple.networkextension.cache.plist")) self.bundlePlist(path: URL(filePath: "/Library/Preferences/com.apple.networkextension.control.plist")) self.bundlePlist(path: URL(filePath: "/Library/Preferences/com.apple.networkextension.necp.plist")) self.bundlePlist(path: URL(filePath: "/etc/bootpd.plist")) #endif #if os(iOS) self.bundleTask("rust-log") { _task in await self.bundleRustLog() } self.bundleTask("storekit-info") { _task in await self.bundleStoreKitInfo() } #endif await self.pendingTasks.waitForAll() do { try self.lock.withLock { try self.writeJson(name: "tasks.json", self.tasks) } } catch { self.writeError(name: "tasks-json", error: error) } } func createArchive() throws -> URL { let zipName = "Obscura Debugging Archive \(utcDateFormat.string(from: self.bundleTimestamp)).zip" var zipPath: URL? var coordinatorError: NSError? var blockError: Error? NSFileCoordinator().coordinate( readingItemAt: self.archiveFolder, options: [.forUploading], error: &coordinatorError ) { inUrl in do { let outDir = try FileManager.default.url( for: .itemReplacementDirectory, in: .userDomainMask, appropriateFor: inUrl, create: true ) let outUrl = outDir.appendingPathComponent(zipName) try FileManager.default.moveItem(at: inUrl, to: outUrl) zipPath = outUrl } catch { blockError = error } } if let error = coordinatorError { throw error } if let error = blockError { throw error } guard let zipPath = zipPath else { throw "Archive callback never ran." } return zipPath } } // Abstract DebugBundleStatus manager which ensures that inProgressCounter is appropriately incremented/decremented public class DebugBundleRC { private let appState: AppState init(_ appState: AppState) { self.appState = appState _ = self.appState.osStatus.update { value in value.debugBundleStatus.start() } } deinit { _ = self.appState.osStatus.update { value in value.debugBundleStatus.finish() } } } func _createDebuggingArchive(appState: AppState?, userFeedback: String?) async throws -> String { let _ = ProcessInfo.processInfo.beginActivity( options: [ .automaticTerminationDisabled, .idleSystemSleepDisabled, .suddenTerminationDisabled, .userInitiated, ], reason: "Generating Debug Bundle" ) let start = SuspendingClock.now let builder = try DebugBundleBuilder(appState: appState, userFeedback: userFeedback) await builder.bundleAll() let zipPath = try builder.createArchive() let elapsed = SuspendingClock.now - start logger.info("Debug Bundle completed in \(elapsed, privacy: .public)") #if os(macOS) NSWorkspace.shared.selectFile(zipPath.path, inFileViewerRootedAtPath: "") #endif return zipPath.path } func createDebuggingArchive(appState: AppState?, userFeedback: String?) async throws -> String { // ensure deinit occurs at the end of the method var _debugBundleRc: DebugBundleRC? defer { withExtendedLifetime(_debugBundleRc) {}} if let appState = appState { _debugBundleRc = DebugBundleRC(appState) } do { let path = try await _createDebuggingArchive(appState: appState, userFeedback: userFeedback) _ = appState?.osStatus.update { value in value.debugBundleStatus.setPath(path) } return path } catch { _ = appState?.osStatus.update { value in value.debugBundleStatus.markError() } throw error } } ================================================ FILE: apple/client/DebugBundleExtensionInfo.swift ================================================ import Foundation import NetworkExtension import OSLog import SystemExtensions private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "DebugBundleExtensionInfo") func getExtensionDebugInfo() async -> [OSSystemExtensionProperties] { var delegate: Delegate? // OSSystemExtensionManager doesn't keep our delegate alive, so we need to take a reference. return await withCheckedContinuation { continuation in let request = OSSystemExtensionRequest.propertiesRequest( forExtensionWithIdentifier: networkExtensionBundleID(), queue: .main ) delegate = Delegate(continuation) request.delegate = delegate OSSystemExtensionManager.shared.submitRequest(request) } } private class Delegate: NSObject { let continuation: CheckedContinuation<[OSSystemExtensionProperties], Never> init(_ continuation: CheckedContinuation<[OSSystemExtensionProperties], Never>) { self.continuation = continuation } } extension Delegate: OSSystemExtensionRequestDelegate { func request( _ request: OSSystemExtensionRequest, actionForReplacingExtension existing: OSSystemExtensionProperties, withExtension ext: OSSystemExtensionProperties ) -> OSSystemExtensionRequest.ReplacementAction { return .cancel } func requestNeedsUserApproval(_ request: OSSystemExtensionRequest) {} func request( _ request: OSSystemExtensionRequest, didFinishWithResult result: OSSystemExtensionRequest.Result ) {} func request( _ request: OSSystemExtensionRequest, didFailWithError error: any Error ) {} func request( _ request: OSSystemExtensionRequest, foundProperties extensions: [OSSystemExtensionProperties] ) { self.continuation.resume(returning: extensions) } } ================================================ FILE: apple/client/Info.plist ================================================ CFBundleURLTypes CFBundleTypeRole Editor CFBundleURLName net.obscura.vpn-client-app CFBundleURLSchemes obscuravpn OSLogPreferences com.apple.extensionkit DEFAULT-OPTIONS Enable-Oversize-Messages Enable-Private-Data Level Enable Debug Persist Debug com.apple.xpc.transaction DEFAULT-OPTIONS Enable-Oversize-Messages Enable-Private-Data Level Enable Debug Persist Debug net.obscura.vpn-client-app DEFAULT-OPTIONS Enable-Oversize-Messages Enable-Private-Data Level Enable Debug Persist Debug net.obscura.vpn-client-app-ios DEFAULT-OPTIONS Enable-Oversize-Messages Enable-Private-Data Level Enable Debug Persist Debug Obscura AppGroupIdentifier $(OBSCURA_APP_APP_GROUP_ID) OBSCURA_NETWORK_EXTENSION_BUNDLE_ID $(OBSCURA_NETWORK_EXTENSION_BUNDLE_ID) ObscuraSourceId $(OBSCURA_SOURCE_ID) ObscuraSourceVersion $(OBSCURA_SOURCE_VERSION) SUEnableAutomaticChecks SUEnableInstallerLauncherService SUFeedURL https://pkgs.obscura.net/macos/appcast.xml SUPublicEDKey R4CNa/L1zQGVdNot8RDQOpAxJdwzBGnZnLR/6G/Zyts= SUScheduledCheckInterval 21600 UIDesignRequiresCompatibility ================================================ FILE: apple/client/LoginItem.swift ================================================ import OSLog import ServiceManagement private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "loginitem") func unregisterAsLoginItem(appState: AppState) throws(String) { do { try SMAppService.mainApp.unregister() let loginItemRegistered = isRegisteredAsLoginItem() _ = appState.osStatus.update { value in value.loginItemStatus = OsStatus.LoginItemStatus(registered: loginItemRegistered, error: nil) } } catch { _ = appState.osStatus.update { value in value.loginItemStatus?.error = error.localizedDescription } logger.error("failed to unregister app at login \(error, privacy: .public)") throw errorCodeOther } } func registerAsLoginItem(appState: AppState?) throws(String) { do { try SMAppService.mainApp.register() let loginItemRegistered = isRegisteredAsLoginItem() if let appState = appState { _ = appState.osStatus.update { value in value.loginItemStatus = OsStatus.LoginItemStatus(registered: loginItemRegistered, error: nil) } } } catch { if let appState = appState { _ = appState.osStatus.update { value in value.loginItemStatus?.error = error.localizedDescription } } logger.error("failed to register app at login \(error, privacy: .public)") throw errorCodeOther } } func isRegisteredAsLoginItem() -> Bool { return SMAppService.mainApp.status == .enabled } ================================================ FILE: apple/client/LoopingVideoPlayer.swift ================================================ import AVKit import SwiftUI struct LoopingVideoPlayer: View { @State private var player: AVQueuePlayer @State private var playerLooper: AVPlayerLooper private var width: CGFloat private var height: CGFloat init(url: URL, width: CGFloat, height: CGFloat) { let asset = if #available(macOS 15, *) { AVURLAsset(url: url) } else { AVAsset(url: url) } let item = AVPlayerItem(asset: asset) let player = AVQueuePlayer(playerItem: item) self.player = player self.playerLooper = AVPlayerLooper(player: player, templateItem: item) self.width = width self.height = height self.player.isMuted = true } var body: some View { VideoPlayer(player: self.player) .frame(minWidth: self.width, maxWidth: .infinity, minHeight: self.height, maxHeight: .infinity, alignment: .center) .aspectRatio(self.width / self.height, contentMode: .fit) .onAppear { self.player.play() } .onDisappear { self.player.pause() } .disabled(true) .cornerRadius(8) .padding(.all, 20) .shadow(radius: 5) } } ================================================ FILE: apple/client/Notifications.swift ================================================ import OSLog import UserNotifications private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "Notifications") func displayNotification( _ identifier: NotificationId, _ content: UNMutableNotificationContent ) { Task { do { let granted = await requestNotificationAuthorization() if !granted { return } try await UNUserNotificationCenter.current().add( UNNotificationRequest( identifier: identifier.rawValue, content: content, trigger: nil ) ) } catch { logger.error("Failed to display notification: \(error, privacy: .public)") } } } func requestNotificationAuthorization() async -> Bool { do { if try await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { logger.info("Notifications authorization granted.") return true } else { logger.warning("Notifications blocked.") } } catch { logger.error("Notification authorization request failed: \(error)") } return false } func notifyConnectError(_ error: Error) { let content = UNMutableNotificationContent() if error.localizedDescription == "accountExpired" { content.body = "Your account has expired." } else { content.body = "An error occurred while connecting to the tunnel." } content.title = "Tunnel failed to connect" content.interruptionLevel = .active content.sound = UNNotificationSound.defaultCritical displayNotification(.connectFailed, content) } ================================================ FILE: apple/client/OSLogEntryEncodable.swift ================================================ import OSLog enum OSLogEntryCodingKeys: String, CodingKey { case activityIdentifier case category case components case eventMessage case eventType case formatString case messageType case parentActivityIdentifier case processID case processImagePath case senderImagePath case signpostID case signpostName case signpostType case subsystem case threadID case timestamp } extension OSLogEntry: Encodable { // Format a log matching `log show --style=ndjson` public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: OSLogEntryCodingKeys.self) try container.encode(self.date, forKey: .timestamp) try container.encode(self.composedMessage, forKey: .eventMessage) let type = switch self { case is OSLogEntryActivity: "activityCreateEvent" case is OSLogEntryBoundary: "boundary" // TODO: What is the official name? case is OSLogEntryLog: "logEvent" case is OSLogEntrySignpost: "signpostEvent" default: "unknown" } try container.encode(type, forKey: .eventType) switch self { case let entry as OSLogEntryActivity: try container.encode(entry.parentActivityIdentifier, forKey: .parentActivityIdentifier) case is OSLogEntryBoundary: break // No extra data. case let entry as OSLogEntryLog: let level = switch entry.level { case .undefined: nil as String? case .debug: "Debug" case .info: "Info" case .notice: "Default" case .error: "Error" case .fault: "Fault" default: "unknown" } try container.encode(level, forKey: .messageType) case let entry as OSLogEntrySignpost: try container.encode(entry.signpostIdentifier, forKey: .signpostID) try container.encode(entry.signpostName, forKey: .signpostName) let type = switch entry.signpostType { case .undefined: nil as String? case .intervalBegin: "begin" case .intervalEnd: "end" case .event: "event" default: "unknown" } try container.encode(type, forKey: .signpostType) default: break } if let entry = self as? OSLogEntryFromProcess { try container.encode(entry.activityIdentifier, forKey: .activityIdentifier) try container.encode(entry.process, forKey: .processImagePath) // We only get the filename, whereas the log command gets the full path. try container.encode(entry.processIdentifier, forKey: .processID) try container.encode(entry.sender, forKey: .senderImagePath) // We only get the filename, whereas the log command gets the full path. try container.encode(entry.threadIdentifier, forKey: .threadID) } if let entry = self as? OSLogEntryWithPayload { try container.encode(entry.category, forKey: .category) try container.encode(entry.formatString, forKey: .formatString) try container.encode(entry.subsystem, forKey: .subsystem) // The log command doesn't break these out, but it makes analysis easier. var components = container.nestedUnkeyedContainer(forKey: .components) for component in entry.components { switch component.argument { case .data(let data): try components.encode(data) case .double(let num): try components.encode(num) case .signed(let num): try components.encode(num) case .string(let str): try components.encode(str) case .undefined: try components.encode(nil as String?) case .unsigned(let num): try components.encode(num) @unknown default: try components.encode("unknown-type") } } } } } ================================================ FILE: apple/client/OsStatus.swift ================================================ import Foundation #if os(iOS) import MessageUI #endif import Network import NetworkExtension import OSLog private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "OsStatus") class OsStatus: Encodable { var version: UUID = .init() var internetAvailable: Bool = false var osVpnStatus: NEVPNStatus let srcVersion = sourceVersion() var strictLeakPrevention: Bool var updaterStatus = UpdaterStatus() var debugBundleStatus = DebugBundleStatus() #if os(macOS) let canSendMail: Bool = false #else let canSendMail: Bool = MFMailComposeViewController.canSendMail() var storeKit = StoreKitStatus() var offerCodeRedemptionSuccess: Bool = false struct StoreKitStatus: Codable { var subscriptionProduct: SubscriptionProductModel? var externalPaymentsAllowed: Bool = false } #endif struct LoginItemStatus: Codable { var registered: Bool var error: String? } var loginItemStatus: LoginItemStatus? init(strictLeakPrevention: Bool, osVpnStatus: NEVPNStatus) { self.osVpnStatus = osVpnStatus self.strictLeakPrevention = strictLeakPrevention } static func watchable(manager: NEVPNManager) -> WatchableValue { var lastIncludeAllNetworks = switch manager.protocolConfiguration { case let .some(proto): proto.includeAllNetworks case nil: false // Report safe default. } let w = WatchableValue(OsStatus( strictLeakPrevention: lastIncludeAllNetworks, osVpnStatus: manager.connection.status )) #if os(macOS) let loginItemRegistered = isRegisteredAsLoginItem() w.update { value in value.loginItemStatus = LoginItemStatus(registered: loginItemRegistered, error: nil) } #endif Task { for await path in NWPathMonitor().stream() { logger.info("NWPathMonitor event: \(path.debugDescription, privacy: .public)") _ = w.update { value in value.internetAvailable = path.status == .satisfied value.version = UUID() } } } let vpnConfigNotifications = NotificationCenter.default.notifications(named: .NEVPNConfigurationChange, object: manager) Task { for await _ in vpnConfigNotifications { let includeAllNetworks: Bool if let proto = manager.protocolConfiguration { includeAllNetworks = proto.includeAllNetworks } else { logger.warning("NEVPNManager.protocolConfiguration is nil") includeAllNetworks = false // Safe default } logger.info("NEVPNConfigurationChangeNotification includeAllNetworks \(includeAllNetworks, privacy: .public)") if includeAllNetworks == lastIncludeAllNetworks { continue } lastIncludeAllNetworks = includeAllNetworks _ = w.update { value in value.strictLeakPrevention = includeAllNetworks value.version = UUID() } } } let vpnStatusNotifications = NotificationCenter.default.notifications(named: .NEVPNStatusDidChange, object: manager.connection) Task { for await _ in vpnStatusNotifications { let osVpnStatus = manager.connection.status logger.info("NEVPNStatus event: \(osVpnStatus, privacy: .public)") _ = w.update { value in value.osVpnStatus = osVpnStatus value.version = UUID() } } } return w } func tunnelActivated() -> Bool { switch self.osVpnStatus { case .connected, .connecting, .reasserting: return true case .disconnected, .disconnecting, .invalid: return false @unknown default: return false } } } // Remove this once min OS versions become macOS 14 and iOS 17 extension NWPathMonitor { func stream() -> AsyncStream { AsyncStream { continuation in pathUpdateHandler = { continuation.yield($0) } start(queue: DispatchQueue(label: "NWPathMonitor queue")) } } } ================================================ FILE: apple/client/Preview Content/Preview Assets.xcassets/Contents.json ================================================ { "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: apple/client/ScriptMessageHandlers.swift ================================================ import Foundation import OSLog import WebKit private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "Webview") class CommandHandler: NSObject, WKScriptMessageHandlerWithReply { var appState: AppState init(appState: AppState) { self.appState = appState } func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage, replyHandler: @escaping (Any?, String?) -> Void) { guard let commandJson = message.body as? String else { replyHandler(nil, "command not a string") return } let commandJsonBytes: Data! = commandJson.data(using: .utf8) guard let command = try? JSONDecoder().decode(Command.self, from: commandJsonBytes) else { replyHandler(nil, "decoding command failed") return } Task { do { let response = try await handleWebViewCommand(command: command) replyHandler(response, nil) } catch let error as String { replyHandler(nil, error) } } } } class ErrorHandler: NSObject, WKScriptMessageHandler { static var shared = ErrorHandler() func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { guard let string = message.body as? String else { logger.error("webview error was not a string: \(debugFormat(message.body), privacy: .public)") return } logger.info("error: \(string, privacy: .public)") } } class LogHandler: NSObject, WKScriptMessageHandler { // handles console.log, console.info, console.error (log will include the level) static var shared = LogHandler() func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { guard let string = message.body as? String else { logger.error("webview log was not a string: \(debugFormat(message.body), privacy: .public)") return } logger.info("\(string, privacy: .public)") } } ================================================ FILE: apple/client/StartupStatus.swift ================================================ enum StartupStatus { case initial #if os(macOS) case networkExtensionInit(NetworkExtensionInit, NetworkExtensionInitStatus) #endif case tunnelProviderInit(TunnelProviderInit, TunnelProviderInitStatus) #if os(macOS) case askToRegisterLoginItem(ObservableValue) #endif case ready } ================================================ FILE: apple/client/StatusItem/AccountStatusItem.swift ================================================ import Cocoa import OSLog import SwiftUI import UserNotifications func getExpiredInDaysText(_ days: UInt64) -> String { if days > 1 { return "in \(days) days" } if days == 1 { return "in \(days) day" } return "in < 1 day" } struct StatusItemAccount: View { @Environment(\.openURL) private var openURL var account: AccountStatus var body: some View { VStack { if self.account.expiringSoon() { Label { HStack { VStack(alignment: .leading, spacing: 2) { Text("Fund your account...") .font(.system(size: 13)) HStack { if self.account.isActive() { Text("Account expires soon") .foregroundStyle(.secondary) } else { Text("Account is expired") .foregroundStyle(.red) } Spacer() Text(self.account.isActive() ? getExpiredInDaysText(self.account.daysUntilExpiry()!) : " ") .foregroundStyle(.tertiary) .fixedSize() .frame(minWidth: 50) } .font(.subheadline) }.fixedSize(horizontal: true, vertical: false) Spacer() } } icon: { Image(systemName: "exclamationmark.arrow.circlepath") } // this allows the Spacer to be clickable .contentShape(Rectangle()) .padding(EdgeInsets(top: 2, leading: 14, bottom: 2, trailing: 12)) } } .onTapGesture { self.openURL(URLs.AppAccountPage) } } } ================================================ FILE: apple/client/StatusItem/BandwidthStatus.swift ================================================ import Cocoa import SwiftUI class BandwidthStatusModel: ObservableObject { @Published var uploadBandwidth = BandwidthFmt.fromTransferRate(bytesPerSecond: 0) @Published var downloadBandwidth = BandwidthFmt.fromTransferRate(bytesPerSecond: 0) } struct BandwidthStatusItem: View { var isUpload: Bool var bandwidth: BandwidthFmt @Environment(\.colorScheme) var colorScheme var body: some View { HStack { Image(systemName: self.isUpload ? "arrow.up" : "arrow.down") Text(self.isUpload ? "Upload" : "Download") Spacer() HStack { Text("\(self.bandwidth.TransferPerSecond) \(self.bandwidth.MeasurementUnit)") .monospaced() } } .padding(.horizontal, 8) .padding(.vertical, 8) .background(self.colorScheme == .dark ? Color.white.opacity(0.1) : Color.black.opacity(0.05)) .cornerRadius(5) .shadow(radius: 2) } } struct BandwidthStatus: View { @ObservedObject var bandwidthStatusModel: BandwidthStatusModel @Environment(\.colorScheme) var colorScheme var body: some View { VStack { BandwidthStatusItem(isUpload: true, bandwidth: self.bandwidthStatusModel.uploadBandwidth) BandwidthStatusItem(isUpload: false, bandwidth: self.bandwidthStatusModel.downloadBandwidth) } .padding(EdgeInsets(top: 5, leading: 12, bottom: 5, trailing: 12)) } } let BANDWIDTH_MAX_INTENSITY: Int = 4 // levels struct BandwidthFmt { let TransferPerSecond: String // TB/s, GB/s, MB/s, KB/s let MeasurementUnit: String let Intensity: Int static func fromTransferRate(bytesPerSecond: Double) -> BandwidthFmt { var divisor: Double = 1 var unit = " B/s" var intensityLvl = 0 if bytesPerSecond >= 1_000_000_000_000 { divisor = 1_000_000_000_000 unit = "TB/s" intensityLvl = BANDWIDTH_MAX_INTENSITY } else if bytesPerSecond >= 1_000_000_000 { divisor = 1_000_000_000 unit = "GB/s" intensityLvl = BANDWIDTH_MAX_INTENSITY } else if bytesPerSecond >= 1_000_000 { divisor = 1_000_000 unit = "MB/s" if bytesPerSecond >= 200_000_000 { // 200+ MB/s is basically max bars, arbitrary but loosely backed // e.g. Steam tops out near 250 MB/s // https://www.reddit.com/r/Steam/comments/10nhtsr/testing_the_limits_of_what_download_speeds_steam/ intensityLvl = BANDWIDTH_MAX_INTENSITY } else if bytesPerSecond > 100_000_000 { intensityLvl = BANDWIDTH_MAX_INTENSITY - 1 } else if bytesPerSecond > 20_000_000 { intensityLvl = BANDWIDTH_MAX_INTENSITY - 2 } else { intensityLvl = 1 } } else if bytesPerSecond >= 100 { divisor = 1000 unit = "KB/s" intensityLvl = bytesPerSecond >= 10000 ? 1 : 0 } let transferRate = bytesPerSecond / divisor var transferPerSecond: String if transferRate >= 100 { transferPerSecond = String(Int(transferRate)) } else { // round to one decimal place (e.g. 10.1 KB/s, 1.1 KB/s, 0.1 KB/s) transferPerSecond = String((transferRate * 10).rounded() / 10) } return BandwidthFmt(TransferPerSecond: leftPad(transferPerSecond, toLength: 4, withPad: "\u{2007}"), MeasurementUnit: unit, Intensity: intensityLvl) } } ================================================ FILE: apple/client/StatusItem/MenuItemView.swift ================================================ import Cocoa import SwiftUI // https://github.com/j-f1/MenuBuilder/blob/ba0202c5ff6d63f0fd7ec6b1da11a769eff15000/Sources/MenuBuilder/MenuItemView.swift#L59 (MIT) // https://github.com/attheodo/Pingu/blob/affc3e4ccf88962d4bbb98dbef774c35801102e6/Pingu/Source/Views/HostMenuItemView/HostMenuItemView.swift // https://developer.apple.com/documentation/appkit/nsvisualeffectview // https://developer.apple.com/documentation/appkit/nsview/1514865-enclosingmenuitem class MenuItemView: NSView { private let effectView: NSVisualEffectView let contentView: ContentView let hostView: NSHostingView init(_ view: ContentView) { self.effectView = NSVisualEffectView() self.effectView.state = .active self.effectView.material = .selection self.effectView.isEmphasized = true self.effectView.blendingMode = .behindWindow self.effectView.wantsLayer = true self.effectView.layer?.cornerRadius = 4 self.effectView.layer?.cornerCurve = .continuous // only enable when highlighted self.effectView.isHidden = true self.contentView = view self.hostView = NSHostingView(rootView: AnyView(self.contentView)) let frame = CGRect(origin: .zero, size: hostView.fittingSize) super.init(frame: frame) addSubview(self.effectView) addSubview(self.hostView) self.setUpConstraints() } @available(*, unavailable) required init?(coder decoder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidMoveToWindow() { super.viewDidMoveToWindow() if window != nil { frame = NSRect( origin: frame.origin, size: CGSize(width: enclosingMenuItem!.menu!.size.width, height: frame.height) ) self.effectView.frame = NSRect( origin: CGPoint(x: frame.origin.x + 5, y: frame.origin.y), size: CGSize(width: enclosingMenuItem!.menu!.size.width - 10, height: frame.height) ) self.hostView.frame = frame } } // https://stackoverflow.com/q/6054331/7732434 override func draw(_ dirtyRect: NSRect) { // Without this, it is possible for a Toggle/NSSwitch inside the status // menu dropdown to appear "inactive". That is, without the app tint // and greyed-out, even when the Toggle is in the "ON" position. // // This fix was discovered by observing that the only reliable // difference between instances where the Toggle was and wasn't tinted // was whether the `NSStatusBarWindow` (a private API class) had // `isKeyWindow` true or false. // // References for possibly related problems and references: // - https://developer.apple.com/documentation/swiftui/environmentvalues/controlactivestate // - https://stackoverflow.com/a/59655207 // - https://medium.com/@acwrightdesign/creating-a-macos-menu-bar-application-using-swiftui-54572a5d5f87 if let window = self.window { if window.isVisible { window.becomeKey() } } // NOTE: an action must be defined in the NSMenuItem // Sample usage; let menuItem = NSMenuItem(title: "", action: #selector(menuItemAction), keyEquivalent: "") let highlighted = enclosingMenuItem?.isHighlighted ?? false self.effectView.isHidden = !highlighted // Note: I removed rehosting the view depending on highlighting // I removed it because it would // // NOTE: I removed it because on the first ever draw of the toggle, the vpn state would be visibly delayed by 0.5s // if we ever want our subview to know if it's highlighted, we can use its own .onHover, // or for broader highlighting: `@Binding var menuItemIsHighlighted` // @State var menuItemIsHighlighted = false // which does require providing this class with the view struct and not an instance super.draw(dirtyRect) } private func setUpConstraints() { self.effectView.translatesAutoresizingMaskIntoConstraints = false self.hostView.translatesAutoresizingMaskIntoConstraints = false translatesAutoresizingMaskIntoConstraints = false let margin: CGFloat = 5 self.effectView.topAnchor.constraint(equalTo: topAnchor).isActive = true self.effectView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: margin).isActive = true self.effectView.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true self.effectView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -margin).isActive = true self.hostView.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true self.hostView.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true self.hostView.topAnchor.constraint(equalTo: topAnchor).isActive = true self.hostView.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true } } ================================================ FILE: apple/client/StatusItem/ObscuraToggle.swift ================================================ import Cocoa import OSLog import SwiftUI import UserNotifications private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "ObscuraToggle") enum ToggleLabels: String { case connected case connecting case reconnecting case disconnecting case notConnected } struct ObscuraToggle: View { @Environment(\.openURL) private var openURL @ObservedObject var startupModel = StartupModel.shared @ObservedObject var osStatusModel: OsStatusModel @State private var toggleLabel = ToggleLabels.notConnected @State private var isToggled = false @State private var allowToggleSync = true @State private var vpnStatusId: UUID = .init() @State private var disconnecting = false @State private var isHovering = false @State private var cityNames: [CityExit: String] = [:] let vpnStatusTimer = Timer.publish(every: 0.5, on: .main, in: .common).autoconnect() func getVpnStatus() -> NeStatus? { return self.startupModel.appState?.status } func getCityName() -> String? { switch self.getVpnStatus()?.vpnStatus { case .connected(_, let exit, _, _, _): return exit.city_name default: return nil } } func getConnectHint() -> String { let exitSelector = self.getVpnStatus()?.lastExit switch exitSelector { case .city(let countryCode, let cityCode): let cityExit = CityExit(city_code: cityCode, country_code: countryCode) if let cityName = self.cityNames[cityExit] { return "Connect to \(cityName), \(countryCode.uppercased())" } return cityCode case .country(let countryCode): return "Connect to \(countryCode.uppercased())" case .exit(let exitId): return exitId default: return "Connect via Quick Connect" } } func getToggleText() -> String { switch self.toggleLabel { case .connected: let cityName = self.getCityName() if cityName == nil { return "Connected" } return "Connected to \(cityName!)" case .connecting: if self.isHovering { return "Click to Cancel" } return "Connecting..." case .reconnecting: if self.isHovering { return "Click to Cancel" } return "Reconnecting..." case .disconnecting: return "Disconnecting..." case .notConnected: if self.isHovering { return self.getConnectHint() } // adding tabs prevents text overflow on the first status menu connect return "Not Connected\t\t\t" } } func toggleClick() { self.allowToggleSync = false switch self.getVpnStatus()?.vpnStatus { case .connected, .connecting: self.isToggled = false // this returns faster than the UI could show "Disconnecting" self.toggleLabel = ToggleLabels.disconnecting Task { await self.startupModel.appState?.disableTunnel() } // since disconnect is fairly instant, we only need to delay the toggle sync for a bit DispatchQueue.main.asyncAfter(deadline: .now() + 2) { // if for some reason the vpn is connected right after a disconnect, // and we don't disable the override flag, we wil self.allowToggleSync = true } default: Task { self.toggleLabel = ToggleLabels.connecting do { let exitSelector = self.getVpnStatus()?.lastExit ?? .any try await self.startupModel.appState?.enableTunnel( TunnelArgs(exit: exitSelector)) } catch { logger.error( "Failed to connect from status menu toggle \(error, privacy: .public)") self.toggleLabel = ToggleLabels.notConnected } self.allowToggleSync = true } self.allowToggleSync = true } } var italicizeToggleLabel: Bool { return self.isHovering && (self.toggleLabel == .notConnected || self.toggleLabel == .reconnecting || self.toggleLabel == .connecting) } // we're implicitly creating a (calculated) minimum width here with // - .fixedSize(...) // - Spacer(minLength: 54) var body: some View { let toggleDisabled = self.toggleLabel == ToggleLabels.disconnecting // Separate the presentation from the function to avoid // https://stackoverflow.com/a/59398852/7732434 let toggleBind = Binding( get: { self.isToggled }, set: { _ in self.toggleClick() } ) HStack { VStack(alignment: .leading) { Text("Obscura VPN") .font(.headline.weight(.regular)) .foregroundStyle(toggleDisabled ? .secondary : .primary) Text(self.getToggleText()) .font(.subheadline) .foregroundStyle(toggleDisabled ? .tertiary : .secondary) .italic(self.italicizeToggleLabel) } .lineLimit(1) // so that the text doesn't collapse horizontally and truncate .fixedSize(horizontal: true, vertical: false) Spacer() Toggle(isOn: toggleBind) {} .toggleStyle(.switch) .tint(Color("ObscuraOrange")) .disabled(toggleDisabled) } // this allows the Spacer to be clickable .contentShape(Rectangle()) // leading and trailing matches Tailscale's values as observed via Accessibility Inspector .padding(EdgeInsets(top: 5, leading: 14, bottom: 5, trailing: 12)) .onTapGesture { if !toggleDisabled { self.toggleClick() } } .onHover { hovering in self.isHovering = hovering } .onReceive( self.vpnStatusTimer, perform: { _ in if self.allowToggleSync { if self.osStatusModel.osStatus?.osVpnStatus == .disconnecting { self.isToggled = false self.toggleLabel = ToggleLabels.disconnecting return } guard let vpnStatus = self.getVpnStatus() else { return } // Don't update the toggle's state if the state has already been updated for a particular vpnStatus // This avoids bugs where the toggle is the component driving a vpn status change // E.g. The vpnStatus reports disconnected and the user starts a connection through the toggle // -> Show the connecting state until the new vpnStatus rather than showing a disconnected state if vpnStatus.version == self.vpnStatusId { return } self.vpnStatusId = vpnStatus.version switch vpnStatus.vpnStatus { case .connected: self.isToggled = true self.toggleLabel = ToggleLabels.connected case .connecting(tunnelArgs: _, connectError: _, let reconnecting): self.isToggled = false self.toggleLabel = reconnecting ? ToggleLabels.reconnecting : ToggleLabels.connecting default: self.isToggled = false self.toggleLabel = ToggleLabels.notConnected } } } ) .task { var exitListKnownVersion: String? while true { var takeBreak = true if let appState = self.startupModel.appState { do { let result = try await getCityNames( appState.manager, knownVersion: exitListKnownVersion ) exitListKnownVersion = result.version self.cityNames = result.cityNames takeBreak = false } catch { logger.error( "Failed to get exit list in ObscuraToggle: \(error, privacy: .public)") } } if takeBreak { do { try await Task.sleep(seconds: 1) } catch { logger.error( "exitListWatcher Task cancelled in ObscuraToggle \(error, privacy: .public)" ) return } } } } } } ================================================ FILE: apple/client/StatusItem/StatusMenu.swift ================================================ import AppKit import Combine import OSLog import SwiftUI import UserNotifications private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "StatusMenu") private let creatingDebuggingArchiveStr = "Creating Debugging Archive (takes a few minutes)" private let createDebuggingArchiveStr = "Create Debugging Archive" // https://multi.app/blog/pushing-the-limits-nsstatusitem final class StatusItemManager: ObservableObject { private var hostingView: NSHostingView private var statusItem: NSStatusItem private var debuggingMenuItem: NSMenuItem private var viewLatestDebugItem: NSMenuItem private var accountMenuItemSeparator: NSMenuItem private var accountMenuItem: NSMenuItem private var quickConnectMenuItem: NSMenuItem private var locationSubmenu: NSMenu private var sizePassthrough = PassthroughSubject() private var bandwidthStatusModel = BandwidthStatusModel() private var osStatusModel = OsStatusModel() @Published private var cityNames: [CityExit: String] = [:] // ensures sink() closures are retained in memory // cancel() will be called on each item upon deinit private var cancellables = Set() private var accountUpdateTask: Task? // intentionally empty to ensure that the menu item can be highlighted @objc func emptyAction() {} init() { Self.exitRefreshSubscriber().store(in: &self.cancellables) self.statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) self.hostingView = NSHostingView( rootView: StatusItem( sizePassthrough: self.sizePassthrough, bandwidthStatusModel: self.bandwidthStatusModel, osStatusModel: self.osStatusModel )) self.hostingView.frame = NSRect(x: 0, y: 0, width: 100, height: 24) self.statusItem.button?.frame = self.hostingView.frame self.statusItem.button?.addSubview(self.hostingView) let menu = NSMenu() let toggleMenuItem = NSMenuItem( title: "Toggle VPN", action: #selector(self.emptyAction), keyEquivalent: "" ) let toggleHostingView = MenuItemView(ObscuraToggle(osStatusModel: self.osStatusModel)) // https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/MenuList/Articles/ViewsInMenuItems.html toggleMenuItem.view = toggleHostingView let locationSubmenuMenuItem = NSMenuItem() locationSubmenuMenuItem.title = "Connect via..." locationSubmenuMenuItem.image = NSImage(named: "custom.globe.badge.gearshape.fill") let showWindowMenuItem = NSMenuItem( title: "Open Obscura Manager...", action: #selector(self.showWindow), keyEquivalent: "o" ) let image = NSImage(named: NSImage.applicationIconName)! image.size = NSSize(width: 16.0, height: 16.0) showWindowMenuItem.image = image self.accountMenuItemSeparator = NSMenuItem.separator() self.accountMenuItemSeparator.isHidden = true self.accountMenuItem = NSMenuItem(title: "", action: #selector(self.emptyAction), keyEquivalent: "") self.accountMenuItem.isHidden = true let bandwidthStatusItem = NSMenuItem() bandwidthStatusItem.view = MenuItemView(BandwidthStatus(bandwidthStatusModel: self.bandwidthStatusModel)) self.debuggingMenuItem = NSMenuItem( title: createDebuggingArchiveStr, action: #selector(self.createDebuggingArchiveAction), keyEquivalent: "" ) self.viewLatestDebugItem = NSMenuItem( title: "View Latest Debug Archive", action: #selector(self.viewLatestDebugArchive), keyEquivalent: "" ) self.viewLatestDebugItem.isHidden = true self.locationSubmenu = NSMenu() locationSubmenuMenuItem.submenu = self.locationSubmenu self.quickConnectMenuItem = NSMenuItem( title: "Quick Connect", action: #selector(self.connectAction), keyEquivalent: "" ) self.quickConnectMenuItem.representedObject = ExitSelector.any self.quickConnectMenuItem.indentationLevel = 1 self.locationSubmenu.addItem(self.quickConnectMenuItem) let loadingLocationsItem = NSMenuItem( title: "Loading Locations...", action: nil, keyEquivalent: "" ) loadingLocationsItem.indentationLevel = 1 self.locationSubmenu.addItem(loadingLocationsItem) self.addMoreLocationsItem() toggleMenuItem.target = self showWindowMenuItem.target = self self.quickConnectMenuItem.target = self self.accountMenuItem.target = self self.debuggingMenuItem.target = self self.viewLatestDebugItem.target = self let disconnectAndQuitItem = NSMenuItem( title: "Quit and Disconnect", action: #selector(self.disconnectAndQuit), keyEquivalent: "q" ) disconnectAndQuitItem.target = self menu.items = [ toggleMenuItem, locationSubmenuMenuItem, .separator(), showWindowMenuItem, self.accountMenuItemSeparator, self.accountMenuItem, .separator(), Self.createSectionHeaderMenuItem(title: "Live Usage"), bandwidthStatusItem, .separator(), self.debuggingMenuItem, self.viewLatestDebugItem, .init(title: sourceVersion(), action: nil, keyEquivalent: ""), disconnectAndQuitItem, ] self.statusItem.menu = menu self.sizePassthrough.sink { [weak self] size in let frame = NSRect(origin: .zero, size: .init(width: size.width, height: 24)) self?.hostingView.frame = frame self?.statusItem.button?.frame = frame }.store(in: &self.cancellables) Publishers.CombineLatest(self.$cityNames, StartupModel.shared.$appState .filter { $0 != nil } .flatMap { $0!.$status }).sink { [weak self] _, newStatus in self?.triggerSetLocationMenuItems() }.store(in: &self.cancellables) StartupModel.shared.$appState .compactMap { $0 } .first() .sink { appState in Task { [weak self] in var exitListKnownVersion: String? while true { guard let self = self else { return } do { let result = try await getCityNames(appState.manager, knownVersion: exitListKnownVersion) exitListKnownVersion = result.version self.cityNames = result.cityNames } catch { logger.error("Failed to get exit list: \(error, privacy: .public)") try await Task.sleep(seconds: 1) } } } appState.$status .map { $0.account } .removeDuplicates() .sink { [weak self] _ in self?.accountUpdateTask?.cancel() self?.accountUpdateTask = Task { [weak self] in while true { self?.updateAccountItem() if let account = appState.status.account { if !account.isActive() { try await Task.sleep(for: .seconds(30), tolerance: .seconds(10)) } else if account.expiringSoon() { try await Task.sleep(for: .seconds(60), tolerance: .seconds(30)) } else { // sleep until we expect account item to show up let toppedUpExpirationDate = account.accountInfo.topUp?.creditExpiresAt ?? 0 let stripeEndDate = account.accountInfo.stripeSubscription?.currentPeriodEnd ?? 0 let appleEndDate = account.accountInfo.appleSubscription?.renewalTime ?? 0 let end = max(toppedUpExpirationDate, stripeEndDate, appleEndDate, 0) // 60 seconds after threshold (-10 days) timestamp let sleepUntilTime = end - 10 * 24 * 60 * 60 + 60 let sleepUntilDate = Date(timeIntervalSince1970: TimeInterval(sleepUntilTime)) let sleepInterval = sleepUntilDate.timeIntervalSinceNow if sleepInterval > 0 { try await Task.sleep(for: .seconds(sleepInterval), tolerance: .seconds(30)) } else { logger.error("account is not expiring soon, yet the estimated recheck date is in the past") try await Task.sleep(for: .seconds(60), tolerance: .seconds(30)) } } } else { try await Task.sleep(for: .seconds(30), tolerance: .seconds(30)) } } } }.store(in: &self.cancellables) // MainActor since osStatusModel is used by layout engine Task { @MainActor [weak self] in while true { guard let self = self else { return } self.osStatusModel.osStatus = await appState.getOsStatus(knownVersion: self.osStatusModel.osStatus?.version) } } // MainActor since bandwidth status is used by layout engine Task { @MainActor [weak self] in var trafficStats: TrafficStats? do { while true { try await Task.sleep(seconds: 1) if case .connected = appState.status.vpnStatus { do { let newTrafficStats = try await appState.getTrafficStats() let oldTrafficStats = trafficStats trafficStats = newTrafficStats if let oldTrafficStats = oldTrafficStats, oldTrafficStats.connId == newTrafficStats.connId { let (txBytesDelta, overflowedTx) = newTrafficStats.txBytes.subtractingReportingOverflow(oldTrafficStats.txBytes) let (rxBytesDelta, overflowedRx) = newTrafficStats.rxBytes.subtractingReportingOverflow(oldTrafficStats.rxBytes) let (msElapsed, overflowedT) = newTrafficStats.connectedMs.subtractingReportingOverflow(oldTrafficStats.connectedMs) if overflowedTx || overflowedRx || overflowedT { logger.info("oldTrafficStats: tx \(oldTrafficStats.txBytes, privacy: .public), rx \(oldTrafficStats.rxBytes, privacy: .public), timestamp \(oldTrafficStats.connectedMs, privacy: .public)") logger.info("newTrafficStats: tx \(newTrafficStats.txBytes, privacy: .public), rx \(newTrafficStats.rxBytes, privacy: .public), timestamp \(newTrafficStats.connectedMs, privacy: .public)") #if DEBUG fatalError("unexpected overflowed in bandwidth subtractions. tx overflowed? \(overflowedTx), rx overflowed? \(overflowedRx), timestamp overflowed? \(overflowedT)") #else logger.error("unexpected overflowed in bandwidth subtractions. tx overflowed? \(overflowedTx, privacy: .public), rx overflowed? \(overflowedRx, privacy: .public), timestamp overflowed? \(overflowedT, privacy: .public)") #endif } else { let secondsDelta = Double(msElapsed) / 1000 if secondsDelta > 0 { self?.bandwidthStatusModel.uploadBandwidth = BandwidthFmt.fromTransferRate(bytesPerSecond: Double(txBytesDelta) / secondsDelta) self?.bandwidthStatusModel.downloadBandwidth = BandwidthFmt.fromTransferRate(bytesPerSecond: Double(rxBytesDelta) / secondsDelta) continue } } } } catch { logger.info("StatusItemManager getTrafficStats failed while connected \(error, privacy: .public)") continue } } self?.bandwidthStatusModel.uploadBandwidth = BandwidthFmt.fromTransferRate( bytesPerSecond: 0) self?.bandwidthStatusModel.downloadBandwidth = BandwidthFmt.fromTransferRate( bytesPerSecond: 0) } } } }.store(in: &self.cancellables) self.osStatusModel.$osStatus.sink { [weak self] _ in self?.updateDebugBundleMenuItem() }.store(in: &self.cancellables) } @objc func connectAction(_ sender: NSMenuItem) { // app crashes if this function is async guard let exitSelector = sender.representedObject as? ExitSelector else { logger.error("connectAction called with incorrect sender.representedObject") return } Task { do { guard let appState = StartupModel.shared.appState else { return } try await appState.enableTunnel(TunnelArgs(exit: exitSelector)) } catch { logger.error("Failed to connect from status location submenu: \(error, privacy: .public)") } } } @objc func showWindow() { // Opening the app via the URL increases the probability of NSApp.activate() // actually focusing the app. // With `NSApp.activate(ignoringOtherApps: true)` deprecated, // NSApp.activate() does not guarantee focus NSWorkspace.shared.open(URLs.AppOpenURL) } @objc func openMoreLocations() { NSWorkspace.shared.open(URLs.AppLocationPage) } @objc func disconnectAndQuit() { Task { await StartupModel.shared.appState?.disableTunnel() await NSApp.terminate(nil) } } @objc func viewLatestDebugArchive() { if let mostRecentPath = self.osStatusModel.osStatus?.debugBundleStatus.latestPath { NSWorkspace.shared.selectFile(mostRecentPath, inFileViewerRootedAtPath: "") } } @objc func createDebuggingArchiveAction() { guard let osVpnStatus = self.osStatusModel.osStatus?.osVpnStatus else { return } let alert = NSAlert() alert.messageText = "Disconnect to Create Debugging Archive?" alert.informativeText = "For the best diagnostics, we recommend creating a debugging archive while disconnected. How do you want to create the debugging archive?" alert.alertStyle = .warning alert.addButton(withTitle: "Disconnect") alert.addButton(withTitle: "Stay Connected") alert.addButton(withTitle: "Don't Create Debugging Archive") DispatchQueue.main.async { self.debuggingMenuItem.target = nil self.debuggingMenuItem.title = creatingDebuggingArchiveStr } Task { do { if osVpnStatus == .connected { let response = await alert.runModal() if response == .alertFirstButtonReturn { try await self.waitForDisconnect() } if response == .alertThirdButtonReturn { self.updateDebugBundleMenuItem() return } } let _ = try await createDebuggingArchive(appState: StartupModel.shared.appState, userFeedback: nil) } catch { logger.error("Error creating debug bundle: \(error, privacy: .public)") let content = UNMutableNotificationContent() content.title = "Error Creating Debug Bundle" content.body = error.localizedDescription content.interruptionLevel = .active content.sound = UNNotificationSound.default displayNotification(.debuggingBundleFailed, content) } } } private func waitForDisconnect(maxSeconds: Double = 30) async throws { await StartupModel.shared.appState?.disableTunnel() try await withTimeout(.seconds(maxSeconds)) { while self.osStatusModel.osStatus?.osVpnStatus == .connected || self.osStatusModel.osStatus?.osVpnStatus == .disconnecting { try await Task.sleep(for: .milliseconds(200)) } } } private func getCityDisplayName(countryCode: String, cityCode: String) -> String { return self.cityNames[CityExit(city_code: cityCode, country_code: countryCode)] ?? cityCode } private func updateDebugBundleMenuItem() { DispatchQueue.main.async { if let debugBundleStatus = self.osStatusModel.osStatus?.debugBundleStatus { if debugBundleStatus.inProgress { self.debuggingMenuItem.target = nil self.debuggingMenuItem.title = creatingDebuggingArchiveStr self.viewLatestDebugItem.isHidden = true } else if self.debuggingMenuItem.target == nil { self.debuggingMenuItem.target = self self.debuggingMenuItem.title = createDebuggingArchiveStr let viewLatestAllowed = debugBundleStatus.latestPath != nil self.viewLatestDebugItem.isHidden = !viewLatestAllowed } } } } private func triggerSetLocationMenuItems() { DispatchQueue.main.async { // Remove all items except the Quick Connect item (which is always first) self.locationSubmenu.items.removeLast(max(self.locationSubmenu.numberOfItems - 1, 0)) if let appState = StartupModel.shared.appState { let pinnedLocations = appState.status.pinnedLocations let lastExit = appState.status.lastExit switch lastExit { case .any: self.quickConnectMenuItem.state = .on default: self.quickConnectMenuItem.state = .off } var lastExitIsPinned = false let pinnedLocationsSubHeaderItem = Self.createSectionHeaderMenuItem(title: "Pinned Locations") pinnedLocationsSubHeaderItem.indentationLevel = 1 self.locationSubmenu.addItem(pinnedLocationsSubHeaderItem) if pinnedLocations.isEmpty { let placeholderItem = NSMenuItem(title: "", action: nil, keyEquivalent: "") let italicFont = NSFontManager.shared.convert(NSFont.menuFont(ofSize: 10), toHaveTrait: .italicFontMask) let attributes: [NSAttributedString.Key: Any] = [.font: italicFont] placeholderItem.indentationLevel = 1 placeholderItem.attributedTitle = NSAttributedString(string: "Pinned locations will appear here", attributes: attributes) self.locationSubmenu.addItem(placeholderItem) } else { for pinnedLocation in pinnedLocations { // Do not show location in status menu if the pinned exit is not found in the fetched cityNames let cityExit = CityExit( city_code: pinnedLocation.city_code, country_code: pinnedLocation.country_code ) if !self.cityNames.isEmpty && self.cityNames[cityExit] == nil { continue } let cityName = self.getCityDisplayName( countryCode: pinnedLocation.country_code, cityCode: pinnedLocation.city_code ) let menuItem = NSMenuItem( title: "\(cityName), \(pinnedLocation.country_code.uppercased())", action: #selector(self.connectAction), keyEquivalent: "" ) menuItem.target = self menuItem.representedObject = ExitSelector.city( country_code: pinnedLocation.country_code, city_code: pinnedLocation.city_code ) // Check if this pinned location matches the last chosen exit switch lastExit { case .city(let country_code, let city_code): if country_code == pinnedLocation.country_code && city_code == pinnedLocation.city_code { menuItem.state = .on lastExitIsPinned = true } default: break } menuItem.indentationLevel = 1 self.locationSubmenu.addItem(menuItem) } } // If the last chosen exit is a city that's not in the pinned locations, add a header and menu item if case .city(let country_code, let city_code) = lastExit, !lastExitIsPinned { let nonPinnedLocationHeaderItem = Self.createSectionHeaderMenuItem(title: "Current Selection") nonPinnedLocationHeaderItem.indentationLevel = 1 self.locationSubmenu.addItem(nonPinnedLocationHeaderItem) let cityName = self.getCityDisplayName( countryCode: country_code, cityCode: city_code ) let nonPinnedMenuItem = NSMenuItem( title: "\(cityName), \(country_code.uppercased())", action: #selector(self.connectAction), keyEquivalent: "" ) nonPinnedMenuItem.target = self nonPinnedMenuItem.representedObject = ExitSelector.city( country_code: country_code, city_code: city_code ) nonPinnedMenuItem.state = .on nonPinnedMenuItem.indentationLevel = 1 self.locationSubmenu.addItem(nonPinnedMenuItem) } } self.addMoreLocationsItem() } } private static func createSectionHeaderMenuItem(title: String) -> NSMenuItem { if #available(macOS 14.0, *) { return NSMenuItem.sectionHeader(title: title) } else { return NSMenuItem(title: title, action: nil, keyEquivalent: "") } } private func addMoreLocationsItem() { self.locationSubmenu.addItem(NSMenuItem.separator()) let moreLocationsMenuItem = NSMenuItem( title: "More Locations…", action: #selector(self.openMoreLocations), keyEquivalent: "" ) moreLocationsMenuItem.target = self let image = NSImage(named: NSImage.applicationIconName)! image.size = NSSize(width: 16.0, height: 16.0) moreLocationsMenuItem.image = image self.locationSubmenu.addItem(moreLocationsMenuItem) } private static func refreshExitListIfNeeded() { Task { if let appState = StartupModel.shared.appState { do { _ = try await refreshExitList(appState.manager, freshness: 3600) } catch { logger.error( "Failed to refresh exit list in status menu: \(error, privacy: .public)") } } } } private func updateAccountItem() { guard let appState = StartupModel.shared.appState else { return } if let account = appState.status.account { let secondsStamp = UInt64(Date().timeIntervalSince1970) var pollAccount = false if (account.accountInfo.periodEndDate == nil || account.accountInfo.periodEndDate! < Date()) && secondsStamp - account.lastUpdatedSec > 60 * 5 { pollAccount = true } else if account.expiringSoon() && secondsStamp - account.lastUpdatedSec > 60 * 60 * 12 { pollAccount = true } if pollAccount { Task { // updateAccountItem task will restart upon appState.status.account change try? await appState.getAccountInfo() } return } DispatchQueue.main.async { let accountHostingView = MenuItemView(StatusItemAccount(account: account)) self.accountMenuItem.view = accountHostingView self.accountMenuItem.isHidden = !account.expiringSoon() || appState.status.inNewAccountFlow self.accountMenuItemSeparator.isHidden = self.accountMenuItem.isHidden } } else { DispatchQueue.main.async { self.accountMenuItem.isHidden = true self.accountMenuItemSeparator.isHidden = true } } } private static func exitRefreshSubscriber() -> AnyCancellable { self.refreshExitListIfNeeded() return Timer.publish(every: 3660, tolerance: 60, on: .current, in: .common) .autoconnect() .sink { _ in Self.refreshExitListIfNeeded() } } } private struct SizePreferenceKey: PreferenceKey { static var defaultValue: CGSize = .zero static func reduce(value: inout CGSize, nextValue: () -> CGSize) { value = nextValue() } } struct StatusItem: View { var sizePassthrough: PassthroughSubject @State private var osStatus: OsStatus? @ObservedObject var startupModel = StartupModel.shared @ObservedObject var bandwidthStatusModel: BandwidthStatusModel @ObservedObject var osStatusModel: OsStatusModel let connectingImageNames = ["MenuBarConnecting-1", "MenuBarConnecting-2", "MenuBarConnecting-3"] @State private var menuBarImage = "MenuBarDisconnected" @State private var statusIconIdx = 0 let statusIconTimer = Timer.publish(every: 0.5, on: .main, in: .common).autoconnect() func getVpnStatus() -> NeVpnStatus? { return self.startupModel.appState?.status.vpnStatus } @ViewBuilder var mainContent: some View { HStack(spacing: 10) { HStack(spacing: 3) { ZStack { Image(self.menuBarImage) .renderingMode(.template) .onReceive(self.statusIconTimer, perform: { _ in if self.osStatusModel.osStatus?.osVpnStatus == .disconnecting { self.menuBarImage = self.connectingImageNames[self.statusIconIdx] // add a full count before using modulo to avoid negative indices self.statusIconIdx = (self.statusIconIdx + self.connectingImageNames.count - 1) % self.connectingImageNames.count return } switch self.getVpnStatus() { case .connecting: self.menuBarImage = self.connectingImageNames[self.statusIconIdx] self.statusIconIdx = (self.statusIconIdx + 1) % self.connectingImageNames.count case .connected: self.menuBarImage = "MenuBarConnected" if self.bandwidthStatusModel.uploadBandwidth.Intensity > 0 { self.menuBarImage += "Up" } if self.bandwidthStatusModel.downloadBandwidth.Intensity > 0 { self.menuBarImage += "Down" } self.statusIconIdx = self.connectingImageNames.count - 1 case .disconnected, nil: self.menuBarImage = "MenuBarDisconnected" self.statusIconIdx = 0 } }) if self.menuBarImage.starts(with: "MenuBarConnected") { Rectangle() .frame(width: 4, height: 4) .position(x: 20.5, y: 17) .foregroundStyle(Color(red: 84 / 255, green: 214 / 255, blue: 97 / 255)) } } } } .padding(4) .padding(.bottom, 2) .fixedSize() } var body: some View { self.mainContent .overlay( GeometryReader { geometryProxy in Color.clear .preference(key: SizePreferenceKey.self, value: geometryProxy.size) } ) .onPreferenceChange( SizePreferenceKey.self, perform: { size in self.sizePassthrough.send(size) } ) } } class OsStatusModel: ObservableObject { @Published var osStatus: OsStatus? = nil } ================================================ FILE: apple/client/Store/Obscura VPN Local.storekit ================================================ { "appPolicies" : { "eula" : "", "policies" : [ { "locale" : "en_US", "policyText" : "", "policyURL" : "" } ] }, "identifier" : "77437FF6", "nonRenewingSubscriptions" : [ ], "products" : [ ], "settings" : { "_compatibilityTimeRate" : { "3" : 6 }, "_failTransactionsEnabled" : false, "_locale" : "en_US", "_storefront" : "USA", "_storeKitErrors" : [ { "current" : { "index" : 2, "type" : "generic" }, "enabled" : false, "name" : "Load Products" }, { "current" : { "index" : 1, "type" : "purchase" }, "enabled" : false, "name" : "Purchase" }, { "current" : { "index" : 0, "type" : "verification" }, "enabled" : false, "name" : "Verification" }, { "current" : null, "enabled" : false, "name" : "App Store Sync" }, { "current" : null, "enabled" : false, "name" : "Subscription Status" }, { "current" : null, "enabled" : false, "name" : "App Transaction" }, { "current" : null, "enabled" : false, "name" : "Manage Subscriptions Sheet" }, { "current" : null, "enabled" : false, "name" : "Refund Request Sheet" }, { "current" : null, "enabled" : false, "name" : "Offer Code Redeem Sheet" } ], "_timeRate" : 15 }, "subscriptionGroups" : [ { "id" : "21708276", "localizations" : [ ], "name" : "Obscura VPN", "subscriptions" : [ { "adHocOffers" : [ ], "codeOffers" : [ { "eligibility" : [ "existing", "expired", "new" ], "internalID" : "2D97084D", "isStackable" : false, "paymentMode" : "free", "referenceName" : "One Month", "subscriptionPeriod" : "P1M" } ], "displayPrice" : "6.0", "familyShareable" : false, "groupNumber" : 1, "internalID" : "6747273493", "introductoryOffer" : null, "localizations" : [ { "description" : "Access to Obscura VPN on up to 3 devices.", "displayName" : "Obscura VPN - Monthly", "locale" : "en_US" } ], "productID" : "subscriptions.monthly", "recurringSubscriptionPeriod" : "P1M", "referenceName" : "Obscura VPN - Monthly", "subscriptionGroupID" : "21708276", "type" : "RecurringSubscription", "winbackOffers" : [ ] } ] } ], "version" : { "major" : 4, "minor" : 0 } } ================================================ FILE: apple/client/Store/Obscura VPN.storekit ================================================ { "appPolicies" : { "eula" : "", "policies" : [ { "locale" : "en_US", "policyText" : "", "policyURL" : "" } ] }, "identifier" : "77437FF6", "nonRenewingSubscriptions" : [ ], "products" : [ ], "settings" : { "_applicationInternalID" : "6746820048", "_developerTeamID" : "5G943LR562", "_failTransactionsEnabled" : true, "_lastSynchronizedDate" : 774578988.61326504, "_locale" : "en_US", "_storefront" : "USA", "_storeKitErrors" : [ { "current" : null, "enabled" : true, "name" : "Load Products" }, { "current" : null, "enabled" : true, "name" : "Purchase" }, { "current" : null, "enabled" : true, "name" : "Verification" }, { "current" : null, "enabled" : false, "name" : "App Store Sync" }, { "current" : null, "enabled" : false, "name" : "Subscription Status" }, { "current" : null, "enabled" : false, "name" : "App Transaction" }, { "current" : null, "enabled" : false, "name" : "Manage Subscriptions Sheet" }, { "current" : null, "enabled" : false, "name" : "Refund Request Sheet" }, { "current" : null, "enabled" : false, "name" : "Offer Code Redeem Sheet" } ] }, "subscriptionGroups" : [ { "id" : "21708276", "localizations" : [ ], "name" : "Obscura VPN", "subscriptions" : [ { "adHocOffers" : [ ], "codeOffers" : [ ], "displayPrice" : "6.0", "familyShareable" : false, "groupNumber" : 1, "internalID" : "6747273493", "introductoryOffer" : null, "localizations" : [ { "description" : "Access to Obscura VPN on up to 3 devices.", "displayName" : "Obscura VPN - Monthly", "locale" : "en_US" } ], "productID" : "subscriptions.monthly", "recurringSubscriptionPeriod" : "P1M", "referenceName" : "Obscura VPN - Monthly", "subscriptionGroupID" : "21708276", "type" : "RecurringSubscription", "winbackOffers" : [ ] } ] } ], "version" : { "major" : 4, "minor" : 0 } } ================================================ FILE: apple/client/Store/Product+Convenience.swift ================================================ import StoreKit extension Product { func subscriptionPeriodFormatted() -> String? { guard let subscription else { return nil } return subscription.subscriptionPeriod.formatted(self.subscriptionPeriodFormatStyle) } } ================================================ FILE: apple/client/Store/StoreKitListener.swift ================================================ import os import StoreKit private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "StoreKitListener") /// Apple wants us to start listening "as soon as your app launches": /// https://developer.apple.com/documentation/storekit/transaction/updates class StoreKitListener { private let purchaseIntentsListener: Task private let transactionUpdatesListener: Task private let storefrontUpdatesListener: Task init(appState: AppState) { self.purchaseIntentsListener = Task.detached { for await purchaseIntent in PurchaseIntent.intents { do { _ = try await appState.purchase(product: purchaseIntent.product) } catch { logger.error("failed to honor purchase intent: \(error, privacy: .public)") } } } self.transactionUpdatesListener = Task.detached { // `updates` is for transactions that happen outside the app or on // other devices, and also receives queued unfinished transactions // once at launch. for await result in Transaction.updates { if case .verified(let transaction) = result { // We don't really have a concept of "undelivered" // transactions, so if any transactions are somehow left // unfinished we should just mark them as finished. await transaction.finish() } await appState.storeKitModel.updatePurchases() } } self.storefrontUpdatesListener = Task.detached { // "The storefront value can change at any time." // https://developer.apple.com/documentation/storekit/storefront/updates for await storefront in Storefront.updates { await appState.storeKitModel.updateStorefront(storefront) } } } deinit { self.purchaseIntentsListener.cancel() self.transactionUpdatesListener.cancel() self.storefrontUpdatesListener.cancel() } } ================================================ FILE: apple/client/Store/StoreKitModel.swift ================================================ import os import StoreKit private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "StoreKitModel") @MainActor class StoreKitModel: ObservableObject { @Published private var products: [Product] = [] @Published private var purchasedProducts: [Product] = [] @Published var renewalPrice: String? = nil private let subscriptionProductId = "subscriptions.monthly" var subscriptionProduct: Product? { return self.products.first { $0.id == self.subscriptionProductId } } var subscribed: Bool { return self.purchasedProducts.contains(where: { $0.id == self.subscriptionProductId }) } @Published private var storefront: Storefront? var externalPaymentsAllowed: Bool { // External payments are currently only straightforward in the US. return self.storefront?.countryCode == "USA" } nonisolated init() { Task { @MainActor in await self.updateStorefront(await Storefront.current) } } func updateStorefront(_ storefront: Storefront?) async { self.storefront = storefront do { self.products = try await Product.products(for: [self.subscriptionProductId]) } catch { logger.error("failed to load products: \(error, privacy: .public)") } await self.updatePurchases() } func updatePurchases() async { self.purchasedProducts.removeAll() self.renewalPrice = nil // For auto-renewable subscriptions, `currentEntitlements` only contains // the latest non-expired transaction. for await result in Transaction.currentEntitlements { if case .verified(let transaction) = result { if let product = products.first(where: { $0.id == transaction.productID }) { self.purchasedProducts.append(product) if product.id == self.subscriptionProductId, let subscription = product.subscription { do { for status in try await subscription.status { if case .verified(let renewalInfo) = status.renewalInfo { if let renewalPrice = renewalInfo.renewalPrice, let renewalCurrency = renewalInfo.currency { self.renewalPrice = renewalPrice.formatted(.currency(code: renewalCurrency.identifier)) } } } } catch { logger.error("Failed to fetch subscription renewal info: \(error, privacy: .public)") } break } } } } } func restorePurchases() async throws(String) { do { try await AppStore.sync() await self.updatePurchases() } catch { logger.error("failed to restore purchases: \(error, privacy: .public)") throw "failed to restore purchases: \(error)" } } // This is here just so we can keep `products` completely private. func collectDebugData() async throws -> [Any] { var debugData: [Any] = [] for product in self.products { var subscriptionStatus: [[String: String]] = [] if let subscription = product.subscription { for status in try await subscription.status { subscriptionStatus.append(["state": status.state.localizedDescription]) } } try debugData.append([ "product": JSONSerialization.jsonObject(with: product.jsonRepresentation), "subscriptionStatus": subscriptionStatus, ]) } return debugData } func toSubscriptionModel() -> SubscriptionProductModel? { if let subscriptionProduct = self.subscriptionProduct { return SubscriptionProductModel( displayName: subscriptionProduct.displayName, description: subscriptionProduct.description, displayPrice: subscriptionProduct.displayPrice, renewalPrice: self.renewalPrice, subscriptionPeriodFormatted: subscriptionProduct.subscriptionPeriodFormatted() ) } return nil } } // static representation of useful information derived from a StoreKit Product class SubscriptionProductModel: Codable { var displayName: String var description: String var displayPrice: String var renewalPrice: String? var subscriptionPeriodFormatted: String? init(displayName: String, description: String, displayPrice: String, renewalPrice: String?, subscriptionPeriodFormatted: String? = nil) { self.displayName = displayName self.description = description self.displayPrice = displayPrice self.renewalPrice = renewalPrice self.subscriptionPeriodFormatted = subscriptionPeriodFormatted } } ================================================ FILE: apple/client/Style/Appearance.swift ================================================ import SwiftUI enum AppAppearance: String, Codable { case dark case light case auto var colorScheme: ColorScheme? { switch self { case .dark: return .dark case .light: return .light case .auto: return nil } } } ================================================ FILE: apple/client/Style/ConditionallyDisabled.swift ================================================ import SwiftUI struct ConditionallyDisabledModifier: ViewModifier { let isDisabled: Bool let explanation: String @State private var showAlert = false func body(content: Content) -> some View { content .disabled(self.isDisabled) .opacity(self.isDisabled ? 0.5 : 1.0) .onTapGesture { if self.isDisabled { self.showAlert = true } } .alert("Not Available", isPresented: self.$showAlert) { Button("OK", role: .cancel) {} } message: { Text(self.explanation) } } } extension View { func conditionallyDisabled( when isDisabled: Bool, explanation: String ) -> some View { self.modifier(ConditionallyDisabledModifier( isDisabled: isDisabled, explanation: explanation )) } } ================================================ FILE: apple/client/Style/HyperlinkButtonStyle.swift ================================================ import SwiftUI struct HyperlinkButtonStyle: ButtonStyle { @Environment(\.isEnabled) private var isEnabled func makeBody(configuration: Configuration) -> some View { configuration.label .foregroundColor(self.isEnabled ? .blue : .blue.opacity(0.5)) } } ================================================ FILE: apple/client/Style/NoFadeButtonStyle.swift ================================================ import SwiftUI struct NoFadeButtonStyle: ButtonStyle { var backgroundColor: Color = .init("ObscuraOrange") @Environment(\.isEnabled) private var isEnabled: Bool func makeBody(configuration: Configuration) -> some View { configuration.label .padding() .background(self.isEnabled ? self.backgroundColor : Color.gray) .foregroundColor(.white) .clipShape(RoundedRectangle(cornerRadius: 8)) .scaleEffect(configuration.isPressed ? 0.97 : 1) .animation(.snappy(duration: 0.2), value: configuration.isPressed) } } ================================================ FILE: apple/client/TunnelProvider.swift ================================================ import Foundation import Network import NetworkExtension import OSLog private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "PacketTunnelProvider") enum TunnelProviderInitStatus { case checking case blockingBeforePermissionPopup case waitingForUserPermissionApproval case waitingForUserStopOtherTunnelApproval(manager: NETunnelProviderManager) case configuring case testingCommunication case permissionDenied case unexpectedError } enum TunnelProviderInitEvent { case status(TunnelProviderInitStatus) case done(NETunnelProviderManager, NeStatus) } class TunnelProviderInit { var continuation: AsyncStream.Continuation? func start() -> AsyncStream { return AsyncStream { continuation in self.continuation = continuation self.update(.checking) Task { guard let managers = await Self.loadManagers() else { self.update(.unexpectedError) return } if managers.count > 1 { for manager in managers[1...] { do { logger.log("Removing extra tunnel provider: \(manager.localizedDescription ?? "nil", privacy: .public)") try await manager.removeFromPreferences() } catch { logger.error("error removing extra tunnel provider: \(error)") self.update(.unexpectedError) return } } } if managers.isEmpty { // There are no managers, we will get a permission prompt when we add one. Wait for a call to `continueAfterPermissionPriming()`, so we can prepare the user for the popup. self.update(.blockingBeforePermissionPopup) } else { // There already is a manager we can use, no permission promp will be shown, continue automatically. self.continueAfterPermissionPriming() } } } } func continueAfterPermissionPriming() { Task { guard let managers = await Self.loadManagers() else { self.update(.unexpectedError) return } var askedForUserApproval = false if managers.isEmpty { self.update(.waitingForUserPermissionApproval) askedForUserApproval = true } else { self.update(.configuring) } let manager = switch managers.first { case .some(let manager): manager case .none: NETunnelProviderManager() } manager.onDemandRules = [NEOnDemandRuleConnect()] let proto = NETunnelProviderProtocol() proto.providerBundleIdentifier = networkExtensionBundleID() proto.serverAddress = "obscura.net" proto.includeAllNetworks = manager.protocolConfiguration?.includeAllNetworks ?? false manager.protocolConfiguration = proto do { if askedForUserApproval { manager.isEnabled = true } try await manager.saveToPreferences() } catch { logger.error("error saving tunnel provider to preferences early: \(error)") if (error as NSError).domain == NEVPNErrorDomain { switch NEVPNError.Code(rawValue: (error as NSError).code) { case .configurationReadWriteFailed: self.update(.permissionDenied) default: self.update(.unexpectedError) } } return } if manager.isEnabled { self.continueAfterStopOtherTunnelPriming(manager) } else { logger.info("tunnel provider is not enabled, asking for permission to enable (which kills other tunnels)") self.update(.waitingForUserStopOtherTunnelApproval(manager: manager)) } } } func continueAfterStopOtherTunnelPriming(_ manager: NETunnelProviderManager) { Task { do { if !manager.isEnabled { logger.info("enabling tunnel provider") manager.isEnabled = true try await manager.saveToPreferences() } } catch { logger.error("error saving tunnel provider to preferences after late enablement: \(error)") self.update(.unexpectedError) return } do { try await manager.loadFromPreferences() } catch { logger.error("error loading tunnel provider from preferences: \(error)") self.update(.unexpectedError) return } self.update(.testingCommunication) var pingFailures = 0 while true { do { let status = try await getNeStatus( manager, knownVersion: nil, attemptTimeout: .seconds(10), maxAttempts: 3 ) self.done(manager, status) return } catch { logger.error("Ping error: \(error, privacy: .public)") } pingFailures += 1 if pingFailures > 2 { logger.error("Failed to reach tunnel provider.") self.update(.unexpectedError) return } do { logger.log("Forcing network extension init") try manager.connection.startVPNTunnel(options: ["dontStartTunnel": NSString(string: "")]) } catch { logger.error("Forced network extension init failed: \(error)") } } } } private func update(_ status: TunnelProviderInitStatus) { logger.log("TunnelProviderInit status: \(debugFormat(status), privacy: .public)") if let cont = self.continuation { cont.yield(.status(status)) } } private func done(_ manager: NETunnelProviderManager, _ status: NeStatus) { if let cont = self.continuation { cont.yield(.done(manager, status)) cont.finish() } } private static func loadManagers() async -> [NETunnelProviderManager]? { do { let managers: [NETunnelProviderManager] = try await NETunnelProviderManager.loadAllFromPreferences() return managers } catch { logger.error("loading all tunnel providers from preferences failed with error: \(error)") return .none } } } func neLogin(_ manager: NETunnelProviderManager, accountId: String, attemptTimeout: Duration? = nil, maxAttempts: UInt = 10) async throws { _ = try await runNeJsonCommand(manager, NeManagerCmd.login(accountId: accountId, validate: false).json(), name: "login", attemptTimeout: attemptTimeout, maxAttempts: maxAttempts) } func getNeStatus( _ manager: NETunnelProviderManager, knownVersion: UUID?, attemptTimeout: Duration? = nil, maxAttempts: UInt = 10 ) async throws -> NeStatus { try await runNeCommand(manager, NeManagerCmd.getStatus(knownVersion: knownVersion), attemptTimeout: attemptTimeout, maxAttempts: maxAttempts) } func getAccountInfo( _ manager: NETunnelProviderManager, attemptTimeout: Duration? = nil, maxAttempts: UInt = 10 ) async throws -> AccountInfo { return try await runNeCommand(manager, NeManagerCmd.apiGetAccountInfo, attemptTimeout: attemptTimeout, maxAttempts: maxAttempts) } func getExitList(_ manager: NETunnelProviderManager, knownVersion: String?, attemptTimeout: Duration? = nil, maxAttempts: UInt = 10) async throws -> CachedValue { return try await runNeCommand(manager, NeManagerCmd.getExitList(knownVersion: knownVersion), attemptTimeout: attemptTimeout, maxAttempts: maxAttempts) } func refreshExitList(_ manager: NETunnelProviderManager, freshness: TimeInterval, attemptTimeout: Duration? = nil, maxAttempts: UInt = 10) async throws -> CachedValue { return try await runNeCommand(manager, NeManagerCmd.refreshExitList(freshness: freshness), attemptTimeout: attemptTimeout, maxAttempts: maxAttempts) } struct CachedValue: Codable { var version: String var last_updated: TimeInterval var value: T } struct ExitList: Codable { var exits: [OneExit] } struct CityExit: Hashable { var city_code: String var country_code: String } struct OneExit: Codable { var id: String var city_code: String var country_code: String var city_name: String var provider_id: String var provider_url: String var provider_name: String var provider_homepage_url: String var datacenter_id: UInt32 var tier: UInt8 } func getCityNames(_ manager: NETunnelProviderManager, knownVersion: String?) async throws -> (cityNames: [CityExit: String], version: String) { let cachedValue = try await getExitList(manager, knownVersion: knownVersion) var newCityNames: [CityExit: String] = [:] for exit in cachedValue.value.exits { newCityNames[CityExit(city_code: exit.city_code, country_code: exit.country_code)] = exit.city_name } return (cityNames: newCityNames, version: cachedValue.version) } func runNeCommand( _ manager: NETunnelProviderManager, _ cmd: NeManagerCmd, attemptTimeout: Duration? = .seconds(10), maxAttempts: UInt = 10 ) async throws(String) -> T { return try T(json: await runNeJsonCommand(manager, cmd.json(), name: getEnumCaseName(for: cmd), attemptTimeout: attemptTimeout, maxAttempts: maxAttempts)) } func runNeJsonCommand( _ manager: NETunnelProviderManager, _ jsonCmd: String, name: String?, attemptTimeout: Duration?, maxAttempts: UInt = 10 ) async throws(String) -> String { var result: NeManagerCmdResult do { let resultJson = try await manager.sendAppMessage( jsonCmd.data(using: .utf8)!, maxAttempts: maxAttempts, attemptTimeout: attemptTimeout ) result = try NeManagerCmdResult(json: resultJson) } catch { logger.error("could not run ne command \(name, privacy: .public): \(error, privacy: .public)") result = .error(errorCodeOther) } switch result { case .ok_json(let ok): logger.debug("ne command \(name, privacy: .public) success") return ok case .error(let error): logger.debug("ne command \(name, privacy: .public) error: \(error, privacy: .public)") throw error } } extension NETunnelProviderManager { // TODO: Merge into runNeCommand without retry logic once we are confident that the UI handles errors and necessary retries for all commands nicely. func sendAppMessage( _ msg: Data, maxAttempts: UInt, attemptTimeout: Duration? ) async throws -> Data { guard let connection = self.connection as? NETunnelProviderSession else { throw "NETunnelProviderManager.connection is not a NETunnelProviderSession, got \(debugFormat(self.connection))" } for attempt in 0 ..< maxAttempts { let clock = SuspendingClock.now let response = try? await withTimeout(attemptTimeout) { await withCheckedContinuation { continuation in do { logger.debug("calling sendProviderMessage") try connection.sendProviderMessage(msg) { response in logger.debug("sendProviderMessage returned") continuation.resume(returning: response) } } catch { logger.warning("sendProviderMessage failed: \(error, privacy: .public)") continuation.resume(returning: .none) } } } if let response = response { return response } let latency = SuspendingClock.now - clock logger.log("sendProviderMessage message failed or lost after \(latency, privacy: .public), attempt: \(attempt, privacy: .public)") try await Task.sleep(seconds: 1.0) } throw "sendProviderMessage message lost repeatedly" } } ================================================ FILE: apple/client/UXKit/UXImage.swift ================================================ /* Many UIKit and AppKit classes have fairly similar interfaces To that end you can get away with code like this. There are libraries out there With a more complete set but I did not wnat to add that dependency given we need such a small subset https://github.com/ZeeZide/UXKit */ import SwiftUI #if os(macOS) import AppKit typealias UXImage = NSImage #else import UIKit typealias UXImage = UIImage #endif extension Image { init(uxImage: UXImage) { #if os(macOS) self.init(nsImage: uxImage) #else self.init(uiImage: uxImage) #endif } } ================================================ FILE: apple/client/UXKit/UXViewController.swift ================================================ /* Many UIKit and AppKit classes have fairly similar interfaces To that end you can get away with code like this. There are libraries out there With a more complete set but I did not wnat to add that dependency given we need such a small subset https://github.com/ZeeZide/UXKit */ #if os(macOS) import AppKit typealias UXViewController = NSViewController #else import UIKit typealias UXViewController = UIViewController #endif ================================================ FILE: apple/client/UXKit/UXViewRepresentable.swift ================================================ /* Many UIKit and AppKit classes have fairly similar interfaces To that end you can get away with code like this. There are libraries out there With a more complete set but I did not wnat to add that dependency given we need such a small subset https://github.com/ZeeZide/UXKit */ import SwiftUI #if os(macOS) typealias UXViewRepresentable = NSViewRepresentable #else typealias UXViewRepresentable = UIViewRepresentable #endif ================================================ FILE: apple/client/UpdaterDriver+XP.swift ================================================ import Foundation enum UpdaterStatusType: String, Codable { case uninitiated case initiated case available case notFound case error } struct AppcastSummary: Codable { var date: String var description: String var version: String var minSystemVersionOk: Bool } struct UpdaterStatus: Codable, CustomStringConvertible { var description: String { return "UpdaterStatus(type: \(self.type), appcast: \(self.appcast as Optional), error: \(self.error as Optional)), errorCode: \(self.errorCode as Optional)" } var type: UpdaterStatusType = .uninitiated var appcast: AppcastSummary? var error: String? var errorCode: Int32? } ================================================ FILE: apple/client/Webviews/ExternalWebView.swift ================================================ import WebKit struct ExternalWebView: UXViewRepresentable { let webView: WKWebView init(appState: AppState) { let webConfiguration = WKWebViewConfiguration() #if DEBUG webConfiguration.preferences.setValue(true, forKey: "developerExtrasEnabled") #endif self.webView = WKWebView(frame: .zero, configuration: webConfiguration) self.webView.navigationDelegate = appState.webviewsController } } // MARK: - AppKit extension ExternalWebView { func makeNSView(context: Context) -> WKWebView { return self.webView } // [required] refresh the view func updateNSView(_ webView: WKWebView, context: Context) {} } // MARK: - UIKit #if os(iOS) extension ExternalWebView { func makeUIView(context: Context) -> UIView { return self.webView } func updateUIView(_ uiView: UIView, context: Context) {} } #endif ================================================ FILE: apple/client/Webviews/ObscuraUIIOSWrapperAndTabs.swift ================================================ import Combine import OrderedCollections import SwiftUI import UIKit import WebKit // In SwiftUI in iOS targeting a minimum SDK of 18.0 SwiftUI TabView // requires each tab views view to be different. Sharing the same web view // between them creates significant problems. Workarounds were tried // going to just use UIKit. class ObscuraUIIOSViewAndTabsViewController: UIViewController { private let webView: ObscuraUIWebView private let tabBar: UITabBar private let tabBarItems: [UITabBarItem] private let webviewsController: WebviewsController private let tabs: OrderedSet var showTabBar: Bool { didSet { self.setupLayout() } } private var cancellables = Set() init( webView: ObscuraUIWebView, webviewsController: WebviewsController, tabs: OrderedSet, showTabBar: Bool ) { self.showTabBar = showTabBar self.webView = webView self.tabBar = UITabBar() self.webviewsController = webviewsController self.tabBarItems = tabs.map { view in let item = UITabBarItem( title: view.rawValue.capitalized, image: UIImage(systemName: view.systemImageName), selectedImage: UIImage(systemName: view.systemImageName) ) return item } self.tabs = tabs super.init(nibName: nil, bundle: nil) self.setupTabBar() self.setupLayout() webviewsController.$tab.sink { [weak self] newTab in self?.navigateTo(view: newTab) }.store(in: &self.cancellables) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } private func setupTabBar() { self.tabBar.items = self.tabBarItems self.tabBar.selectedItem = self.tabBarItems.first self.tabBar.delegate = self self.tabBar.tintColor = UIColor(named: "ObscuraOrange") } private func setupLayout() { // Remove constraints and views if they were already subviews self.webView.removeFromSuperview() self.tabBar.removeFromSuperview() view.addSubview(self.webView) self.webView.translatesAutoresizingMaskIntoConstraints = false if self.showTabBar { view.insertSubview(self.tabBar, aboveSubview: self.webView) self.tabBar.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ self.webView.topAnchor.constraint(equalTo: view.topAnchor), self.webView.leadingAnchor.constraint(equalTo: view.leadingAnchor), self.webView.trailingAnchor.constraint(equalTo: view.trailingAnchor), self.webView.bottomAnchor.constraint(equalTo: self.tabBar.topAnchor), self.tabBar.leadingAnchor.constraint(equalTo: view.leadingAnchor), self.tabBar.trailingAnchor.constraint(equalTo: view.trailingAnchor), self.tabBar.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), ]) } else { NSLayoutConstraint.activate([ self.webView.topAnchor.constraint(equalTo: view.topAnchor), self.webView.leadingAnchor.constraint(equalTo: view.leadingAnchor), self.webView.trailingAnchor.constraint(equalTo: view.trailingAnchor), self.webView.bottomAnchor.constraint(equalTo: view.bottomAnchor), ]) } } private func navigateTo(view: AppView) { if let index = tabs.firstIndex(of: view) { self.tabBar.selectedItem = self.tabBarItems[index] } self.webView.navigateTo(view: view) } } // MARK: - UITabBarDelegate extension ObscuraUIIOSViewAndTabsViewController: UITabBarDelegate { func tabBar(_ tabBar: UITabBar, didSelect item: UITabBarItem) { guard let index = tabBarItems.firstIndex(of: item), index < tabs.count else { return } let selectedView = self.tabs[index] self.webviewsController.tab = selectedView } } // MARK: - SwiftUI Wrapper struct ObscuraUIIOSViewAndTabsWrapper: UIViewControllerRepresentable { let webView: ObscuraUIWebView let webviewsController: WebviewsController let tabs: OrderedSet let showTabBar: Bool func makeUIViewController(context: Context) -> ObscuraUIIOSViewAndTabsViewController { return ObscuraUIIOSViewAndTabsViewController( webView: self.webView, webviewsController: self.webviewsController, tabs: self.tabs, showTabBar: self.showTabBar ) } func updateUIViewController(_ uiViewController: ObscuraUIIOSViewAndTabsViewController, context: Context) { uiViewController.showTabBar = self.showTabBar } } ================================================ FILE: apple/client/Webviews/ObscuraUIMacOSWrapper.swift ================================================ import SwiftUI import WebKit struct ObscuraUIMacOSWrapper: UXViewRepresentable { let webView: ObscuraUIWebView init(webView: ObscuraUIWebView) { self.webView = webView } } // MARK: - AppKit // Hack not needed on macOS as NavigationSplitView allows each tab to share the same SwiftUI view extension ObscuraUIMacOSWrapper { func makeNSView(context: Context) -> WKWebView { return self.webView } // [required] refresh the view func updateNSView(_ webView: WKWebView, context: Context) {} } ================================================ FILE: apple/client/Webviews/ObscuraUIWebView.swift ================================================ import SwiftUI import WebKit class ObscuraUIWebView: WKWebView { init(appState: AppState) { let webConfiguration = WKWebViewConfiguration() // webConfiguration.preferences.javaScriptEnabled = true let error_capture_script = WKUserScript(source: js_error_capture, injectionTime: .atDocumentStart, forMainFrameOnly: false) webConfiguration.userContentController.addUserScript(error_capture_script) let log_capture_script = WKUserScript(source: js_log_capture, injectionTime: .atDocumentStart, forMainFrameOnly: false) webConfiguration.userContentController.addUserScript(log_capture_script) // add bridges (command, console.error, console.log) between JS and Swift webConfiguration.userContentController.addScriptMessageHandler(CommandHandler(appState: appState), contentWorld: .page, name: "commandBridge") webConfiguration.userContentController.add(ErrorHandler.shared, name: "errorBridge") webConfiguration.userContentController.add(LogHandler.shared, name: "logBridge") // for React application webConfiguration.setValue(true, forKey: "allowUniversalAccessFromFileURLs") webConfiguration.preferences.setValue(true, forKey: "allowFileAccessFromFileURLs") // note that text selection is disabled using CSS webConfiguration.preferences.isTextInteractionEnabled = true #if DEBUG webConfiguration.preferences.setValue(true, forKey: "developerExtrasEnabled") #endif super.init(frame: .zero, configuration: webConfiguration) self.navigationDelegate = appState.webviewsController #if LOAD_DEV_SERVER let urlRequest = URLRequest(url: URL(string: "http://localhost:1420/")!) self.load(urlRequest) #else // see the Prod Client scheme let url = Bundle.main.url(forResource: "index", withExtension: "html", subdirectory: "build")! self.loadFileURL(url, allowingReadAccessTo: url.deletingLastPathComponent()) #endif #if !os(macOS) // Safe area ignore // https://stackoverflow.com/a/47814446/3833632 self.scrollView.delegate = self self.scrollView.contentInsetAdjustmentBehavior = .never #endif } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func navigateTo(view: AppView) { self.evaluateJavaScript( ObscuraUIWebView.generateNavEventJS(viewName: view.ipcValue) ) #if !os(macOS) self.scrollView.bounces = view.needsScroll #endif } static func generateNavEventJS(viewName: String) -> String { // reuse the variable `__WK_WEBKIT_NAV_EVENT__` let jsDispatchNavUpdateStr = """ __WEBKIT_NAV_EVENT__ = new CustomEvent("navUpdate", { detail: "\(viewName)" }); window.dispatchEvent(__WEBKIT_NAV_EVENT__); """ return jsDispatchNavUpdateStr } func handlePaymentSucceeded() { self.evaluateJavaScript(ObscuraUIWebView.generatePaymentSucceededEventJS()) } static func generatePaymentSucceededEventJS() -> String { return """ window.dispatchEvent(new CustomEvent("paymentSucceeded")) """ } func handleScreenshotDetected() { self.evaluateJavaScript(ObscuraUIWebView.generateScreenshotDetectedEventJS()) } static func generateScreenshotDetectedEventJS() -> String { return """ window.dispatchEvent(new CustomEvent("screenshotDetected")) """ } } #if !os(macOS) extension ObscuraUIWebView: UIScrollViewDelegate { func scrollViewWillBeginZooming(_ scrollView: UIScrollView, with view: UIView?) { scrollView.pinchGestureRecognizer?.isEnabled = false } func scrollViewDidZoom(_ scrollView: UIScrollView) { scrollView.minimumZoomScale = scrollView.zoomScale scrollView.maximumZoomScale = scrollView.zoomScale } } #endif let js_error_capture = #""" window.onerror = (message, source, lineno, colno, error) => { window.webkit.messageHandlers.errorBridge.postMessage(JSON.stringify({ message: message, source: source, lineno: lineno, colno: colno, }, undefined, "\t")); }; window.onunhandledrejection = (event) => { console.error("unhandled promise rejection", event.reason) } """# let js_log_capture = #""" function log(type, msg, ...args) { let formatted = [type, msg, ...args.map(a => JSON.stringify(a, undefined, "\t"))].join(" "); window.webkit.messageHandlers.logBridge.postMessage(formatted); } console.debug = log.bind(null, "debug:"); console.log = log.bind(null, "log:"); console.warn = log.bind(null, "warn:"); console.error = log.bind(null, "error:"); """# ================================================ FILE: apple/client/Webviews/ObscuraUIWebViewMacOSWrapper.swift ================================================ import SwiftUI import WebKit struct ObscuraUIWebViewMacOSWrapper: View { let webView: ObscuraUIWebView init(webView: ObscuraUIWebView) { self.webView = webView } var body: some View { WebViewRepresentable(webView: self.webView) } } private struct WebViewRepresentable: NSViewRepresentable { let webView: ObscuraUIWebView func makeNSView(context: Context) -> WKWebView { return self.webView } func updateNSView(_ webView: WKWebView, context: Context) { // No updates needed } } ================================================ FILE: apple/client/Webviews/WebviewsController.swift ================================================ import OSLog import SwiftUI import WebKit private let logger = Logger( subsystem: Bundle.main.bundleIdentifier!, category: "WebviewsController" ) // This is the navigation for all web views within the app class WebviewsController: NSObject, ObservableObject, WKNavigationDelegate { @Published var showModalWebview: Bool = false @Published var showSubscriptionManageSheet: Bool = false @Published var obscuraWebView: ObscuraUIWebView? = nil @Published var externalWebView: ExternalWebView? = nil @Published var tab: AppView = .connection let useExernalBrowserForPayments = true private enum LinkDestination { case social case checkConnection case managePayment case stripePayment case homepage case termsOfService var openExternally: Bool { switch self { case .social, .checkConnection, .homepage, .stripePayment: return true case .termsOfService, .managePayment: return false } } } func initializeWebviews(appState: AppState) { self.obscuraWebView = ObscuraUIWebView(appState: appState) self.externalWebView = ExternalWebView(appState: appState) } func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { if webView == self.obscuraWebView { // Check if the navigation action is a form submission if navigationAction.navigationType == .linkActivated, let url = navigationAction.request.url { #if os(macOS) NSWorkspace.shared.open(url) #else self.handleWebsiteLinkiOS(url: url) #endif decisionHandler(.cancel) } else { decisionHandler(.allow) } } else { if let url = navigationAction.request.url, url.absoluteString.contains("obscuravpn") { self.handleObscuraURL(url: url) } decisionHandler(.allow) } } #if !os(macOS) func handleWebsiteLinkiOS(url: URL) { if url.absoluteString.contains("obscuravpn:///") { self.handleObscuraURL(url: url) return } if url.scheme == "mailto" { UIApplication.shared.open(url) return } // Check that it is a staging.obscura.com or obscura.com url guard let components = NSURLComponents( url: url, resolvingAgainstBaseURL: true ), let path = components.path, let host = components.host else { logger.error("Failed to parse URL into components") return } let destination: LinkDestination? if host.contains("obscura") { if path.contains("pay") { destination = .stripePayment } else if path.contains("check") { destination = .checkConnection } else if path.contains("legal") { destination = .termsOfService } else if path == "/" { destination = .homepage } else { destination = nil } } else { if host .contains("discord") || host .contains("matrix.to") || host .contains("x.com") { destination = .social } else { destination = nil } } if destination?.openExternally ?? true { UIApplication.shared.open(url) return } else { Task { @MainActor in // Clear webview self.externalWebView?.webView.load(URLRequest(url: URL(string: "about:blank")!)) // Load the requested page self.externalWebView?.webView.load(URLRequest(url: url)) self.showModalWebview = true } } } #endif func handleObscuraURL(url: URL) { logger.info("Handling URL: \(url, privacy: .public)") // From: https://developer.apple.com/documentation/xcode/defining-a-custom-url-scheme-for-your-app#Handle-incoming-URLs guard let components = NSURLComponents( url: url, resolvingAgainstBaseURL: true ) else { logger.error("Failed to parse URL into components") return } #if os(macOS) if let appDelegate = NSApp.delegate as? AppDelegate { appDelegate.showPrimaryWindow() } #else self.showModalWebview = false #endif switch components.path { case .some("/open"): break case .some("/manage-subscription"): self.showSubscriptionManageSheet = true case .some("/payment-succeeded"): self.obscuraWebView?.handlePaymentSucceeded() case .some("/account"): self.tab = .account case .some("/location"): self.tab = .location case let unknownPath: logger.error( "Unknown URL path: \(unknownPath, privacy: .public)" ) } } } ================================================ FILE: apple/client/app_state.swift ================================================ import Foundation #if os(iOS) import MessageUI import StoreKit #endif import NetworkExtension import OSLog import SwiftUI import UserNotifications class AppState: ObservableObject { private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "AppState") var manager: NETunnelProviderManager private let configQueue: DispatchQueue = .init(label: "config queue") let osStatus: WatchableValue @Published var status: NeStatus @Published var needsIsEnabledFix: Bool = false @Published var showOfferCodeRedemption: Bool = false #if !os(macOS) private var didBecomeActiveObserver: NSObjectProtocol? #endif #if os(macOS) let updater: SparkleUpdater #else let mailDelegate = MailDelegate() @Published var storeKitModel: StoreKitModel = .init() private var storeKitListener: StoreKitListener? #endif @Published var webviewsController: WebviewsController init( _ manager: NETunnelProviderManager, initialStatus: NeStatus ) { self.manager = manager self.status = initialStatus self.osStatus = OsStatus.watchable(manager: manager) #if os(macOS) self.updater = SparkleUpdater(osStatus: self.osStatus) #endif self.webviewsController = WebviewsController() self.webviewsController.initializeWebviews(appState: self) #if !os(macOS) self.didBecomeActiveObserver = NotificationCenter.default.addObserver(forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main) { [weak self] _ in self?.updateNeedIsEnabledFix() } self.storeKitListener = StoreKitListener(appState: self) #endif if initialStatus.autoConnect { Task { Self.logger.info("Auto-connect is enabled, waiting for internet availability before connecting") while true { _ = await self.osStatus.waitUntil { $0.internetAvailable } // Wait a little to increase the chance that the OS NE session manager realizes internet is available, otherwise the NE will fail to start connecting and restart, which can cost much more time. try! await Task.sleep(seconds: 0.2) if self.manager.protocolConfiguration?.includeAllNetworks == .some(true) { // Wait even longer if includeAllNetworks is enabled. Otherwise the NE state tends to traverse connected->disconnected->connecting quickly without calling any of the appropriate callbacks and then gets stuck until stopped manually. This is very common on macos 14 and rare on macos 15. try! await Task.sleep(seconds: 2) } if self.osStatus.get().internetAvailable == false { Self.logger.info("Internet became unavailability before auto-connect was triggered. Retrying.") continue } if !self.status.autoConnect { Self.logger.info("Auto-connect was disabled while waiting for internet availability, not connecting") return } if self.osStatus.get().tunnelActivated() { Self.logger.info("Tunnel already activated abandoning auto-connect") return } Self.logger.info("Auto-connecting") do { try await self.enableTunnel(TunnelArgs(exit: self.status.lastExit)) } catch { Self.logger.error("Could not trigger auto connect \(error, privacy: .public)") let content = UNMutableNotificationContent() content.title = "Automatic connect failed" content.body = "Could not connect automatically at launch." content.interruptionLevel = .active content.sound = UNNotificationSound.defaultCritical displayNotification(.autoConnectFailed, content) return } if await self.waitForTunnelActivation(Duration.seconds(1)) { Self.logger.info("Successfully triggered auto-connect") return } Self.logger.info("Auto-connect timed out, trying again") } } } Task { @MainActor in var version: UUID = initialStatus.version while true { if let status = try? await getNeStatus(self.manager, knownVersion: version) { Self.logger.info("Status updated: \(debugFormat(status), privacy: .public)") version = status.version self.status = status switch status.vpnStatus { case .connecting(_, connectError: let err, _): if err == "accountExpired" { Self.logger.info("found connecting error accountExpired") // TODO: iOS app should respond to this error OBS-1542 #if os(macOS) // can't use openURL due to a runtime warning stating that it was called outside of a view NSApp.delegate?.application?(NSApp, open: [URLs.AppAccountPage]) #endif } default: break } } else { // TODO: Mark status as "unknown". // https://linear.app/soveng/issue/OBS-358/status-icon-should-display-unknown-when-status-cant-be-read } } } Task { /* Hacky loop to keep the network extension alive. After 60s of inactivty the network extension is decomissioned which has a number of downsides: 1. It leaks a `utunN` device (macOS bug). 2. It kills all active RPC calls (annoying). In order to resolve this we simply ping the network extension in a loop. */ while true { do { try await self.ping() try! await Task.sleep(seconds: 30) } catch { Self.logger.error("Ping failed \(error.localizedDescription, privacy: .public)") try! await Task.sleep(seconds: 5) } } } } func updateNeedIsEnabledFix() { Self.logger.info("updating need for isEnabled fix") Task { @MainActor in do { try await self.manager.loadFromPreferences() if self.manager.isEnabled { Self.logger.info("manager is enabled, isEnabled fix not needed") return } } catch { Self.logger.error("error loading NE preferences: \(error), assuming isEnabled fix is not needed") return } Self.logger.info("manager is disabled") do { try await self.ping() Self.logger.info("ping succeeded, isEnabled fix not needed") return } catch { Self.logger.error("ping failed: \(error)") } Self.logger.error("manager is disabled and ping failed, isEnabled fix needed") self.needsIsEnabledFix = true } } func runIsEnabledFix() { Task { @MainActor in Self.logger.info("running isEnabledFix") do { self.manager.isEnabled = true try await self.manager.saveToPreferences() self.needsIsEnabledFix = false } catch { Self.logger.error("error loading NE preferences: \(error)") } } } func setIncludeAllNetworks(enable: Bool) async throws { guard let proto = self.manager.protocolConfiguration else { throw "NEVPNManager.protocolConfiguration is nil" } Self.logger.info("setIncludeAllNetworks \(proto.includeAllNetworks, privacy: .public) → \(enable, privacy: .public)") if proto.includeAllNetworks == enable { return } proto.includeAllNetworks = enable do { try await self.manager.saveToPreferences() return } catch { Self.logger.error("Failed to save NEVPNManager: \(error.localizedDescription)") } do { try await self.manager.loadFromPreferences() return } catch { Self.logger.error("Failed to reload NEVPNManager: \(error.localizedDescription)") } proto.includeAllNetworks = false Self.logger.warning("Marking local includeAllNetworks to false as a safe default.") throw "Unable to save VPN configuration." } func enableTunnel(_ tunnelArgs: TunnelArgs?) async throws(String) { let useOnDemand = self.status.featureFlags.killSwitch ?? false // TODO: move this into startup flow or post feature enablement flow (https://linear.app/soveng/issue/OBS-2428) if useOnDemand { _ = await requestNotificationAuthorization() } for _ in 1 ..< 3 { let onDemandEnabled: Bool = await { () -> Bool in do { try await self.manager.loadFromPreferences() return self.manager.isEnabled && self.manager.isOnDemandEnabled } catch { Self.logger.error("Failed to check onDemand status of tunnel \(error, privacy: .public)") return false } }() // Remove once onDemand is unconditional ( https://linear.app/soveng/issue/OBS-2428 ) let tunnelEnabled: Bool = self.manager.connection.status != .disconnected if !self.osStatus.get().internetAvailable { Self.logger.log("Failed to connect due to no network connectivity") throw errorConnectDeviceOffline } // Iff tunnel is already enabled update tunnel args without startVPNTunnel. Doing this unconditionally without returning would be correct as well, but NE round-trips can be a bit slow. if tunnelEnabled || onDemandEnabled { do { Self.logger.log("Tunnel already active, set tunnel args") let _: Empty = try await runNeCommand(self.manager, .setTunnelArgs(args: tunnelArgs, active: .none)) Self.logger.log("Successfully set tunnel args") return } catch { Self.logger.error("Setting tunnel args failed: \(error, privacy: .public)") } } // Call startVPNTunnel unconditionally, because onDemand will not not start the tunnel until there is traffic, which can be confusing. Self.logger.log("Starting tunnel") do { try self.manager.connection.startVPNTunnel(options: ["tunnelArgs": NSString(string: tunnelArgs.json())]) Self.logger.log("startVPNTunnel called without error") } catch { Self.logger.error("startVPNTunnel failed \(error, privacy: .public)") } // Enable tunnel and onDemand do { try await self.manager.loadFromPreferences() self.manager.isOnDemandEnabled = useOnDemand if !self.manager.isEnabled { Self.logger.info("NETunnelProviderManager is disabled, enabling") self.manager.isEnabled = true } try await self.manager.saveToPreferences() try await self.manager.loadFromPreferences() return } catch { Self.logger.error("Could not set onDemand \(error, privacy: .public)") } try! await Task.sleep(seconds: 1) } Self.logger.error("Could not enable tunnel repeatedly, giving up...") throw errorCodeOther } func disableTunnel() async { Self.logger.log("Stopping tunnel") self.manager.isOnDemandEnabled = false do { try await self.manager.saveToPreferences() try await self.manager.loadFromPreferences() } catch { Self.logger.critical("Could not save NETunnelProviderManager preferences before stopping tunnel \(error, privacy: .public)") } self.manager.connection.stopVPNTunnel() } func getOsStatus(knownVersion: UUID?) async -> OsStatus { return await self.osStatus.getIfOrNext { current in current.version != knownVersion } } func ping() async throws(String) { let _: Empty = try await runNeCommand(self.manager, .ping, attemptTimeout: Duration.seconds(5), maxAttempts: 1) } func getAccountInfo() async throws(String) -> AccountInfo { return try await runNeCommand(self.manager, .apiGetAccountInfo) } func getTrafficStats() async throws(String) -> TrafficStats { return try await runNeCommand(self.manager, .getTrafficStats) } func resetUserDefaults() { for k in UserDefaultKeys.allKeys { UserDefaults.standard.removeObject(forKey: k) } } func waitForTunnelActivation(_ timeout: Duration) async -> Bool { let result = await self.osStatus.waitUntilWithTimeout(timeout) { switch $0.osVpnStatus { case .connected, .connecting, .reasserting: return true case .disconnected, .disconnecting, .invalid: return false @unknown default: return false } } return result != nil } // Unfortunately async notification iterators are not sendable, so we often need to resubscribe to state changes. // This function: // - subscribes to state changes // - checks if the initial status is unchanged (because subscribing may race with changes) // - waits for a state change notification or timeout // - returns the changed state if it didn't time out private static func waitForStateChange(connection: NEVPNConnection, initial: NEVPNStatus, maxSeconds: Double) async -> NEVPNStatus? { enum Event { case change case timeout } return await withTaskGroup(of: Event.self) { taskGroup in taskGroup.addTask { let notifications = NotificationCenter.default.notifications(named: .NEVPNStatusDidChange, object: connection) if connection.status != initial { Self.logger.debug("Status already changed.") return Event.change } for await _ in notifications { Self.logger.debug("Status change notification received.") return Event.change } if Task.isCancelled { Self.logger.debug("Status change notification cancelled") } else { Self.logger.error("Status change notification stream stopped unexpectedly.") } return Event.timeout } taskGroup.addTask { if let _ = try? await Task.sleep(seconds: maxSeconds) { Self.logger.debug("Status change timeout.") return Event.timeout } return Event.change } let event = await taskGroup.next()! taskGroup.cancelAll() return event == .timeout ? nil : connection.status } } private static func fetchDisconnectErrorAsErrorCode(connection: NEVPNConnection) async -> String { do { try await connection.fetchLastDisconnectError() self.logger.error("Failed to fetch disconnect error") return "failedWithoutDisconnectError" } catch { if let connectErrorCode = (error as NSError).connectErrorCode() { self.logger.log("Fetched connect error code: \(connectErrorCode)") return connectErrorCode } if (error as NSError).domain == NEVPNConnectionErrorDomain { switch NEVPNConnectionError(rawValue: (error as NSError).code) { case .noNetworkAvailable: return "noNetworkAvailable" default: Self.logger.error("Unexpected NEVPNConnectionError after startTunnel: \(error, privacy: .public)") return errorCodeOther } } Self.logger.error("Unexpected error after startTunnel: \(error, privacy: .public)") return errorCodeOther } } #if os(iOS) func associateAccount() async throws(String) -> AppleAssociateAccountOutput { let appTransaction: String do { appTransaction = try await AppTransaction.shared.jwsRepresentation } catch { throw errorFailedToAssociateAccount } return try await runNeCommand(self.manager, .apiAppleAssociateAccount(appTransactionJws: appTransaction)) } // TODO: Test interrupted purchase // https://developer.apple.com/documentation/storekit/testing-an-interrupted-purchase func purchase(product: Product) async throws -> Bool { _ = try await self.associateAccount() let result = try await product.purchase() if case .success(let verification) = result { if case .verified(let transaction) = verification { await transaction.finish() return true } } return false } func purchaseSubscription() async throws(String) -> Bool { guard let subscriptionProduct = await self.storeKitModel.subscriptionProduct else { Self.logger.error("subscription product missing") return false } do { return try await self.purchase(product: subscriptionProduct) } catch { Self.logger.error("Failed to purchase subscription: \(error, privacy: .public)") throw errorPurchaseFailed } } private func rootViewController() -> UIViewController? { UIApplication.shared.connectedScenes .compactMap { $0 as? UIWindowScene } .filter { $0.activationState == .foregroundActive } .first?.keyWindow?.rootViewController } private func presentFromRoot(viewController: UIViewController) { let rvc = self.rootViewController() // This generates a ton of spurious warnings and errors, which is // apparently normal. Also, the first present will be slow when // connected for debugging. rvc?.present(viewController, animated: true, completion: nil) } func emailDebugArchive(path: String, subject: String, body: String) throws(String) { if !MFMailComposeViewController.canSendMail() { Self.logger.info("Mail services are not available") return } let cvc = MFMailComposeViewController() cvc.mailComposeDelegate = self.mailDelegate cvc.setToRecipients(["support@obscura.net"]) cvc.setSubject(subject) cvc.setMessageBody(body, isHTML: false) let url = URL(fileURLWithPath: path) let data: Data do { data = try Data(contentsOf: url) } catch { throw "Failed to read debugging archive: \(error)" } cvc.addAttachmentData(data, mimeType: "application/zip", fileName: url.lastPathComponent) self.presentFromRoot(viewController: cvc) } func shareFile(path: String) { let url = URL(fileURLWithPath: path) let avc = UIActivityViewController(activityItems: [url], applicationActivities: nil) self.presentFromRoot(viewController: avc) } #endif } struct TrafficStats: Codable { let connectedMs: UInt64 let connId: UUID let txBytes: UInt64 let rxBytes: UInt64 let latestLatencyMs: UInt16 } ================================================ FILE: apple/client/client-ios.entitlements ================================================ com.apple.developer.associated-domains webcredentials:obscura.com webcredentials:obscura.net com.apple.developer.networking.networkextension $(OBSCURA_PACKET_TUNNEL_PROVIDER_ENTITLEMENT) com.apple.security.application-groups $(OBSCURA_APP_APP_GROUP_ID) ================================================ FILE: apple/client/client-macos.entitlements ================================================ com.apple.developer.associated-domains webcredentials:obscura.com webcredentials:obscura.net com.apple.developer.networking.networkextension $(OBSCURA_PACKET_TUNNEL_PROVIDER_ENTITLEMENT) com.apple.developer.system-extension.install com.apple.security.application-groups $(OBSCURA_APP_APP_GROUP_ID) ================================================ FILE: apple/client/command.swift ================================================ #if os(macOS) import AppKit #endif import Foundation import OSLog private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "command") enum Command: Codable { case startTunnel(tunnelArgs: String) case stopTunnel case setStrictLeakPrevention(enable: Bool) case setColorScheme(value: AppAppearance) case debuggingArchive(userFeedback: String?) case revealItemInDir(path: String) case emailDebugArchive(path: String, subject: String, body: String) case shareDebugArchive(path: String) case registerAsLoginItem case unregisterAsLoginItem case resetUserDefaults case getOsStatus(knownVersion: UUID?) case checkForUpdates case installUpdate case associateAccount case purchaseSubscription case restorePurchases case showOfferCodeRedemption case resetOfferCodeRedemptionSuccess case jsonFfiCmd( cmd: String, timeoutMs: Int? ) } extension CommandHandler { func handleWebViewCommand(command: Command) async throws(String) -> String { switch command { case .startTunnel(tunnelArgs: let jsonArgs): let args = try TunnelArgs(json: jsonArgs) try await appState.enableTunnel(args) case .stopTunnel: await appState.disableTunnel() case .resetUserDefaults: // NOTE: only shown in the Developer View appState.resetUserDefaults() case .setStrictLeakPrevention(let enable): do { try await appState.setIncludeAllNetworks(enable: enable) } catch { logger.error("Could not set includeAllNetworks \(error, privacy: .public)") throw errorCodeOther } case .setColorScheme(let colorScheme): DispatchQueue.main.async { StartupModel.shared.selectedAppearance = colorScheme } // When setting color scheme to no preference (nil), // only the header changes appearance immediately // This bug is applicable to iOS 18 & macOS Sequoia: // https://developer.apple.com/forums/thread/677212?answerId=805661022#805661022 // Setting to nil a second time results in the expected visual change DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { StartupModel.shared.selectedAppearance = colorScheme } case .jsonFfiCmd(cmd: let jsonCmd, let timeoutMs): let attemptTimeout: Duration? = switch timeoutMs { case .some(let ms): .milliseconds(ms) case .none: nil } return try await runNeJsonCommand( appState.manager, jsonCmd, name: getEnumCaseName(for: jsonCmd), attemptTimeout: attemptTimeout ) case .getOsStatus(knownVersion: let version): return try await appState.getOsStatus(knownVersion: version).json() case .debuggingArchive(let userFeedback): let path: String do { path = try await createDebuggingArchive(appState: appState, userFeedback: userFeedback) } catch { logger.error("could not create debugging archive \(error, privacy: .public)") throw errorCodeOther } return try path.json() #if os(macOS) case .emailDebugArchive, .shareDebugArchive, .purchaseSubscription, .restorePurchases, .associateAccount, .showOfferCodeRedemption, .resetOfferCodeRedemptionSuccess: throw errorUnsupportedOnOS case .revealItemInDir(let path): NSWorkspace.shared.selectFile(path, inFileViewerRootedAtPath: "") case .registerAsLoginItem: try registerAsLoginItem(appState: self.appState) case .unregisterAsLoginItem: try unregisterAsLoginItem(appState: self.appState) case .checkForUpdates: try? appState.updater.checkForUpdates() case .installUpdate: guard appState.updater.canCheckForUpdates else { throw errorCodeUpdaterInstall } appState.updater.showUpdaterIfNeeded() #else case .associateAccount: try await appState.associateAccount() case .purchaseSubscription: let result = try await appState.purchaseSubscription() return try result.json() case .restorePurchases: try await appState.storeKitModel.restorePurchases() case .showOfferCodeRedemption: DispatchQueue.main.async { self.appState.showOfferCodeRedemption = true } case .resetOfferCodeRedemptionSuccess: _ = appState.osStatus.update { value in value.offerCodeRedemptionSuccess = false } case .emailDebugArchive(let path, let subject, let body): try appState.emailDebugArchive(path: path, subject: subject, body: body) case .shareDebugArchive(let path): appState.shareFile(path: path) case .revealItemInDir, .registerAsLoginItem, .unregisterAsLoginItem, .checkForUpdates, .installUpdate: throw errorUnsupportedOnOS #endif } return "{}" } } ================================================ FILE: apple/client/extensions/NEVPNStatus.swift ================================================ import Foundation import NetworkExtension extension NEVPNStatus: Encodable { public func encode(to encoder: any Encoder) throws { var container = encoder.singleValueContainer() switch self { case .invalid: try container.encode("invalid") case .disconnected: try container.encode("disconnected") case .connecting: try container.encode("connecting") case .connected: try container.encode("connected") case .reasserting: try container.encode("reasserting") case .disconnecting: try container.encode("disconnecting") @unknown default: try container.encode("unknown") } } } ================================================ FILE: apple/client/iOS/MailDelegate.swift ================================================ import MessageUI import OSLog private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "Mail") class MailDelegate: NSObject, MFMailComposeViewControllerDelegate { func mailComposeController(_ controller: MFMailComposeViewController, didFinishWith result: MFMailComposeResult, error: Error?) { switch result { case MFMailComposeResult.cancelled: logger.debug("Cancelled mail") case MFMailComposeResult.saved: logger.debug("Saved mail") case MFMailComposeResult.sent: logger.info("Sent mail successfully") case MFMailComposeResult.failed: logger.error("Failed to send mail: \(error?.localizedDescription, privacy: .public)") default: break } controller.dismiss(animated: true) } } ================================================ FILE: apple/client/iOS/iOSClientApp.swift ================================================ import OSLog import SwiftUI private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "App") @main struct iOSClientApp: App { init() { logger.debug("App init") } @ObservedObject var startupModel = StartupModel.shared var body: some Scene { WindowGroup { if let appState = self.startupModel.appState { ContentView(appState: appState) .preferredColorScheme(self.startupModel.selectedAppearance.colorScheme) } else { StartupView() .preferredColorScheme(self.startupModel.selectedAppearance.colorScheme) } } } } ================================================ FILE: apple/client/initNetworkExtension.swift ================================================ import Foundation import NetworkExtension import OSLog import SystemExtensions private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "NetworkExtensionInit") enum NetworkExtensionInitStatus { case checking case blockingBeforePermissionPopup case blockingBeforeTunnelDisconnect case enabling case waitingForUserApproval // Terminal states. case failed(String) case waitingForReboot } enum NetworkExtensionInitEvent { case status(NetworkExtensionInitStatus) case done } class NetworkExtensionInit: NSObject { var continuation: AsyncStream.Continuation? private var canceling = false private var activationRequested = false private let tunnelConnected: Bool init(tunnelConnected: Bool) { self.tunnelConnected = tunnelConnected } func start() -> AsyncStream { logger.log("Starting NetworkExtensionInit") return AsyncStream { continuation in self.continuation = continuation self.update(.checking) logger.log("Requesting system extension properties...") let request = OSSystemExtensionRequest.propertiesRequest( forExtensionWithIdentifier: networkExtensionBundleID(), queue: .main ) request.delegate = self OSSystemExtensionManager.shared.submitRequest(request) } } func continueAfterPriming() { if self.activationRequested { logger.error("activation requested multiple times") return } self.activationRequested = true logger.log("Requesting system extension activation/replacement...") let request = OSSystemExtensionRequest.activationRequest( forExtensionWithIdentifier: networkExtensionBundleID(), queue: .main ) request.delegate = self OSSystemExtensionManager.shared.submitRequest(request) } private func update(_ status: NetworkExtensionInitStatus) { logger.log("NetworkExtensionInit state: \(debugFormat(status), privacy: .public)") if let cont = self.continuation { cont.yield(.status(status)) } } private func done() { logger.log("NetworkExtensionInit done") if let cont = self.continuation { cont.yield(.done) cont.finish() } } } extension NetworkExtensionInit: OSSystemExtensionRequestDelegate { func request( _ request: OSSystemExtensionRequest, foundProperties sysExts: [OSSystemExtensionProperties] ) { // This method will be called after we submit a `OSSystemExtensionRequest.propertiesRequest`, which happens automatically in `start()` logger.debug("Step 1: OSSystemExtensionRequestDelegate.request(... foundProperties ...) called") let buildVersion = buildVersion() logger.debug("matching system extension bundle version against app build version \(buildVersion, privacy: .public)") var matchingBundleIdAlreadyEnabled = false var matchingBuildVersionAlreadyEnabled = false for sysExt in sysExts { logger.debug("found system extensions \(sysExt.bundleIdentifier) \(sysExt.bundleShortVersion) \(sysExt.bundleVersion), enabled: \(sysExt.isEnabled), awaitingUserApproval: \(sysExt.isAwaitingUserApproval)") if sysExt.bundleIdentifier == networkExtensionBundleID() && sysExt.isEnabled { matchingBundleIdAlreadyEnabled = true if sysExt.bundleVersion == buildVersion { matchingBuildVersionAlreadyEnabled = true } } } if matchingBuildVersionAlreadyEnabled { logger.info("found enabled system extension with matching build version, not expecting a replacement, requesting activation") self.continueAfterPriming() } else if self.tunnelConnected { logger.info("found connected tunnel, but the build version of the enabled system extension doesn't match, expecting tunnel disconnect, waiting for external activation trigger") self.update(.blockingBeforeTunnelDisconnect) } else if matchingBundleIdAlreadyEnabled { logger.info("found enabled system extension, tunnel not connected, not expecting to get blocked, requesting activation") self.continueAfterPriming() } else { logger.info("found no enabled system extension, expecting to get blocked, waiting for external activation trigger") self.update(.blockingBeforePermissionPopup) } } func request( _ request: OSSystemExtensionRequest, actionForReplacingExtension oldExt: OSSystemExtensionProperties, withExtension newExt: OSSystemExtensionProperties ) -> OSSystemExtensionRequest.ReplacementAction { // This method will be called after we submit a `OSSystemExtensionRequest.activationRequest`, which is either: // - automatcially triggered if we don't expect to get blocked by the OS // - triggered by `Self.continueAfterPriming()` being called from the outside, so the caller can prepare the user for the popup and approval steps or tunnel disconnect logger.debug("Step 2: OSSystemExtensionRequestDelegate.request(... actionForReplacingExtension ...) called") var replacementRequired = false let matchingBundleId = oldExt.bundleIdentifier == newExt.bundleIdentifier logger.debug("bundleIdentifier matches? \(matchingBundleId, privacy: .public) (\(newExt.bundleIdentifier))") if !matchingBundleId { logger.error("Unexpected bundleIdentifier old: \(oldExt.bundleIdentifier, privacy: .public)") replacementRequired = true } let matchingShortVersion = oldExt.bundleShortVersion == newExt.bundleShortVersion logger.debug("bundleShortVersion maches? \(matchingShortVersion, privacy: .public) (\(newExt.bundleShortVersion))") if !matchingShortVersion { replacementRequired = true logger.debug("old.bundleShortVersion: \(oldExt.bundleShortVersion, privacy: .public)") } let matchingVersion = oldExt.bundleVersion == newExt.bundleVersion logger.debug("bundleVersion matches? \(matchingVersion, privacy: .public) (\(newExt.bundleVersion))") if !matchingVersion { replacementRequired = true logger.debug("old.bundleVersion: \(oldExt.bundleVersion, privacy: .public)") } logger.log("System extension replacement required? \(replacementRequired)") if replacementRequired { self.update(.enabling) return .replace } else { self.canceling = true return .cancel } } func requestNeedsUserApproval(_ request: OSSystemExtensionRequest) { logger.debug("Step 3: OSSystemExtensionRequestDelegate.requestNeedsUserApproval(...) called") self.update(.waitingForUserApproval) } func request( _ request: OSSystemExtensionRequest, didFinishWithResult result: OSSystemExtensionRequest.Result ) { logger.debug("Step 4: OSSystemExtensionRequestDelegate.request(... didFinishWithResult ...) called") switch result { case .completed: self.done() case .willCompleteAfterReboot: self.update(.waitingForReboot) @unknown default: logger.error("sys ext request unknown result variant: \(debugFormat(result), privacy: .public)") self.update(.failed("Unknown activation result: \(result.rawValue)")) } } func request(_ request: OSSystemExtensionRequest, didFailWithError error: Error) { logger.error("OSSystemExtensionRequestDelegate.request(... didFailWithError ...) called: \(error.localizedDescription, privacy: .public)") switch error { case let error as OSSystemExtensionError: switch OSSystemExtensionError.Code(rawValue: error.errorCode) { case .requestCanceled: if self.canceling { logger.info("System extension installation skipped.") // This should only happen for systems with system extension dev mode enabled. self.done() } else { self.update(.failed("Unexpected system extension install cancellation.")) } case nil: self.update(.failed("Invalid error code: \(error.errorCode)")) default: self.update(.failed(error.localizedDescription)) } default: self.update(.failed(error.localizedDescription)) } } } ================================================ FILE: apple/client/macOS/CheckForUpdatesView.swift ================================================ import Sparkle import SwiftUI /** This is the view for the Check for Updates menu item Note this intermediate view is necessary for the disabled state on the menu item to work properly before Monterey. See https://stackoverflow.com/questions/68553092/menu-not-updating-swiftui-bug for more info. **/ struct CheckForUpdatesView: View { @State var canCheckForUpdates: Bool = false private let updater: SparkleUpdater init(updater: SparkleUpdater) { self.updater = updater } var body: some View { Button("Check for Updates…") { self.updater.showUpdaterIfNeeded() } .onReceive(self.updater.canCheckForUpdatesPublisher) { canCheckForUpdates in self.canCheckForUpdates = canCheckForUpdates } .disabled(!self.canCheckForUpdates) } } ================================================ FILE: apple/client/macOS/ClientApp.swift ================================================ import Combine import Network import NetworkExtension import OSLog import Sparkle import SwiftUI import SystemExtensions import UserNotifications private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "App") @main class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate, UNUserNotificationCenterDelegate, ObservableObject { var primaryWindow: NSWindow! var statusItemManager: StatusItemManager? var updaterMenuItemSubscription: AnyCancellable? var updater: SparkleUpdater? var statusItem: NSStatusItem? static let FrameAutoSaveName = "root-view" static func main() { logger.debug("App init") // Auto-exit if app is already running // Note that this is already rare, but can happen if an installed app is running before running a build from XCode if NSWorkspace.shared.runningApplications.filter({ $0.bundleIdentifier == Bundle.main.bundleIdentifier }).count > 1 { logger.info("App already running.") NSApp.terminate(nil) return } let app = NSApplication.shared let delegate = AppDelegate() app.delegate = delegate app.run() } func applicationWillFinishLaunching(_ notification: Notification) { UNUserNotificationCenter.current().delegate = self } func applicationDidFinishLaunching(_ notification: Notification) { // https://stackoverflow.com/a/19890943/7732434 let event = NSAppleEventManager.shared().currentAppleEvent let launchedAsLoginItem = (event?.eventID == kAEOpenApplication && event?.paramDescriptor(forKeyword: keyAEPropData)?.enumCodeValue == keyAELaunchedAsLogInItem) logger.log("launched as login item: \(launchedAsLoginItem)") if launchedAsLoginItem { // Otherwise, the app icon appears in the dock with a black dot with no window NSApp.setActivationPolicy(.accessory) } self.createPrimaryWindow(launchedAsLoginItem: launchedAsLoginItem) self.setupMainMenu() self.statusItemManager = StatusItemManager() } @objc func quitApp() { NSApp.terminate(nil) } func openPrimaryWindow() { self.primaryWindow.makeKeyAndOrderFront(nil) self.primaryWindow.orderFrontRegardless() } // According to NSWorkspace.shared.menuBarOwningApplication?.localizedName and appWithFocus?.ownsMenuBar // obscura owns the menubar and the app with focus owns the menu bar // implication: owning the menu bar does not guarantee it shows up... // The menubar is blank when Obscura is opened up after switching displays to a monitor (in clamshell) // To reproduce the bug, comment out the menu recreation line and follow these instructions. // Note that LocalSend also has the same bug, but it cannot even be fixed by switching focus. // 1. Close Obscura window (keep it running in status menu) // 2. Close macbook lid // 3. Connect it to a monitor // 4. Open Obscura Manager func showPrimaryWindow() { NSApp.setActivationPolicy(.regular) self.openPrimaryWindow() // Fix for blank menubar when switching displays (clamshell mode): // Recreate the main menu to ensure it displays correctly self.setupMainMenu() focusApp() } // Apple added this method to the template to address a process injection vulnerability related to saving/restoring state // https://sector7.computest.nl/post/2022-08-process-injection-breaking-all-macos-security-layers-with-a-single-vulnerability/ // https://stackoverflow.com/a/77320845/7732434 // Without this method, log warnings will show up, and the app is apparently vulnerable to compromising SIP func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { return true } private func createPrimaryWindow(launchedAsLoginItem: Bool) { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 800, height: 600), styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], backing: .buffered, defer: launchedAsLoginItem ) // must be done before calling `setFrameAutosaveName` window.delegate = self window.toolbarStyle = .unified window.tabbingMode = .disallowed // the following enforces view-specific constraints let contentView = MainWindowContentView().environmentObject(self) let hostingVC = NSHostingController(rootView: contentView) hostingVC.sizingOptions = [.minSize] window.contentViewController = hostingVC // try to restore saved frame if !window.setFrameUsingName(Self.FrameAutoSaveName) { // without this, the window will off centre on first launch window.updateConstraintsIfNeeded() window.center() } window.setFrameAutosaveName(Self.FrameAutoSaveName) // maintain previous swift-ui Window behaviour window.isReleasedWhenClosed = false self.primaryWindow = window if !launchedAsLoginItem { self.showPrimaryWindow() } } func setupMainMenu() { let mainMenu = NSMenu() let appMenuItem = NSMenuItem() let appMenu = NSMenu() appMenuItem.submenu = appMenu let aboutItem = NSMenuItem(title: "About Obscura", action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), keyEquivalent: "") let servicesItem = NSMenuItem(title: "Services", action: nil, keyEquivalent: "") let servicesMenu = NSMenu() servicesItem.submenu = servicesMenu NSApp.servicesMenu = servicesMenu let hideItem = NSMenuItem(title: "Hide Obscura VPN", action: #selector(NSApplication.hide(_:)), keyEquivalent: "h") let hideOthersItem = NSMenuItem(title: "Hide Others", action: #selector(NSApplication.hideOtherApplications(_:)), keyEquivalent: "h") hideOthersItem.keyEquivalentModifierMask = [.command, .option] let showAllItem = NSMenuItem(title: "Show All", action: #selector(NSApplication.unhideAllApplications(_:)), keyEquivalent: "") let closeWindowItem = NSMenuItem(title: "Close Window", action: #selector(NSWindow.performClose(_:)), keyEquivalent: "q") appMenu.items = [ aboutItem, NSMenuItem.separator(), // Check for Updates will be inserted here when updater is available servicesItem, NSMenuItem.separator(), hideItem, hideOthersItem, showAllItem, NSMenuItem.separator(), closeWindowItem, ] // Check for Updates menu item - will be added when updater is available self.updaterMenuItemSubscription = StartupModel.shared.$appState .compactMap { $0?.updater } .first() .receive(on: DispatchQueue.main) .sink { [weak self] updater in self?.updater = updater self?.addCheckForUpdatesMenuItem(to: appMenu) } let editMenuItem = NSMenuItem() let editMenu = NSMenu(title: "Edit") editMenuItem.submenu = editMenu editMenu.items = [ NSMenuItem(title: "Undo", action: Selector(("undo:")), keyEquivalent: "z"), NSMenuItem(title: "Redo", action: Selector(("redo:")), keyEquivalent: "Z"), NSMenuItem.separator(), NSMenuItem(title: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x"), NSMenuItem(title: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c"), NSMenuItem(title: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v"), NSMenuItem(title: "Delete", action: #selector(NSText.delete(_:)), keyEquivalent: ""), NSMenuItem(title: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a"), NSMenuItem.separator(), ] let viewMenuItem = NSMenuItem() let viewMenu = NSMenu(title: "View") viewMenuItem.submenu = viewMenu let fullScreenItem = NSMenuItem(title: "Enter Full Screen", action: #selector(NSWindow.toggleFullScreen(_:)), keyEquivalent: "f") fullScreenItem.keyEquivalentModifierMask = [.function] viewMenu.items = [ fullScreenItem, ] let windowMenuItem = NSMenuItem() let windowMenu = NSMenu(title: "Window") NSApp.windowsMenu = windowMenu windowMenuItem.submenu = windowMenu let closeItem = NSMenuItem(title: "Close", action: #selector(NSWindow.performClose(_:)), keyEquivalent: "w") let closeAllItem = NSMenuItem(title: "Close All", action: Selector(("closeAll:")), keyEquivalent: "w") closeAllItem.keyEquivalentModifierMask = [.command, .option] closeAllItem.isAlternate = true let minimizeItem = NSMenuItem(title: "Minimize", action: #selector(NSWindow.miniaturize(_:)), keyEquivalent: "m") let minimizeAllItem = NSMenuItem(title: "Minimize All", action: #selector(NSWindow.miniaturize(_:)), keyEquivalent: "m") minimizeAllItem.keyEquivalentModifierMask = [.command, .option] minimizeAllItem.isAlternate = true let zoomItem = NSMenuItem(title: "Zoom", action: #selector(NSWindow.zoom(_:)), keyEquivalent: "") let zoomAllItem = NSMenuItem(title: "Zoom All", action: #selector(NSWindow.zoom(_:)), keyEquivalent: "") zoomAllItem.keyEquivalentModifierMask = [.option] zoomAllItem.isAlternate = true // https://github.com/avaidyam/Parrot/tree/6cf7ba419176c386ed8f18e838690a7272fe57ee/Parrot windowMenu.items = [ closeItem, closeAllItem, minimizeItem, minimizeAllItem, zoomItem, zoomAllItem, NSMenuItem.separator(), NSMenuItem( title: "Bring All to Front", action: #selector(NSApplication.arrangeInFront(_:)), keyEquivalent: "" ), ] let helpMenuItem = NSMenuItem() let helpMenu = NSMenu(title: "Help") helpMenuItem.submenu = helpMenu NSApp.helpMenu = helpMenu mainMenu.items = [ appMenuItem, editMenuItem, viewMenuItem, windowMenuItem, helpMenuItem, ] NSApp.mainMenu = mainMenu } private func addCheckForUpdatesMenuItem(to menu: NSMenu) { let checkForUpdatesItem = NSMenuItem( title: "Check for Updates…", action: #selector(self.checkForUpdates), keyEquivalent: "" ) checkForUpdatesItem.target = self menu.insertItem(checkForUpdatesItem, at: 2) menu.insertItem(NSMenuItem.separator(), at: 3) } @objc private func checkForUpdates() { self.updater?.showUpdaterIfNeeded() } // We do not want to depend on applicationShouldTerminateAfterLastWindowClosed, // because it can be triggered for a variety of reasons versus triggering for exactly what we want // Based on Carl's initial debugging, it was determined: // On macOS, when a menu item is highlighted, there is a callback to unhighlight the menu item. // If the app is set to accessory before the callback runs, the callback is unable to unhighlight the menu item (for whatever reason). // This results in a pre-highlight/stuck state. // Recreating the main menu upon opening the window alleviates the need to carefully wait to set the activation policy. func windowWillClose(_ notification: Notification) { NSApp.setActivationPolicy(.accessory) } func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows: Bool) -> Bool { logger.debug("from applicationShouldHandleReopen. hasVisibleWindows = \(hasVisibleWindows)") if NSApp.activationPolicy() == .regular { self.openPrimaryWindow() return true } NSApp.setActivationPolicy(.regular) if #available(macOS 14.0, *) { self.openPrimaryWindow() return true } // On macos ventura or earlier, without this workaround, if the user // reopens the App using Finder while the App is already running, the // App Menu (the left side) becomes completely frozen and unusable // (even the  one) /// more info here: // https://linear.app/soveng/issue/OBS-175/no-obscura-vpn-in-menu-bar-dock-or-app-switcher-when-application-is#comment-2ecf3e57 NSRunningApplication.runningApplications(withBundleIdentifier: "com.apple.systemuiserver") .first!.activate(options: []) self.openPrimaryWindow() NSApp.activate(ignoringOtherApps: true) return true } func userNotificationCenter( _ center: UNUserNotificationCenter, willPresent notification: UNNotification ) async -> UNNotificationPresentationOptions { // Always show notifications, even if we have focus. // Right now we use notifications as the only feedback for some actions. // This is probably not ideal UX but until we can improve that ensure that they appear on screen. return .banner } var openUrlCallback: ((_ url: URL) -> Void)? func application( _ application: NSApplication, open urls: [URL] ) { logger.log("AppDelegate \(#function) called with URLs: \(urls)") guard let openUrlCallback = self.openUrlCallback else { logger.warning("AppDelegate has NO registered openUrlCallback") return } logger.log("AppDelegate: Calling registered openUrlCallback") for url in urls { openUrlCallback(url) } } } struct MainWindowContentView: View { @ObservedObject var startupModel = StartupModel.shared @EnvironmentObject var appDelegate: AppDelegate var body: some View { Group { if let appState = self.startupModel.appState { ContentView(appState: appState) .frame(minWidth: 700, minHeight: 525) } else { StartupView() .frame(minWidth: 800, minHeight: 525) } } .preferredColorScheme(self.startupModel.selectedAppearance.colorScheme) } } func focusApp() { // When opening the app from status menu via // the URLs, NSApp.activate() does not cause troubles // regarding focus. However, just to be safe regarding edge cases and users, we want to continue // using `ignoringOtherApps: true` until it is removed if #available(macOS 26.4, *) { NSApp.activate() } else { NSApp.activate(ignoringOtherApps: true) } } ================================================ FILE: apple/client/macOS/InstallSystemExtensionView.swift ================================================ import SwiftUI let macOS14DemoVideo = Bundle.main.url(forResource: "videos/macOS 14 System Extension Demo", withExtension: "mov")! let macOS15DemoVideo = Bundle.main.url(forResource: "videos/macOS 15 System Extension Demo", withExtension: "mov")! struct InstallSystemExtensionView: View { @ObservedObject var startupModel: StartupModel var subtext: String @Environment(\.openURL) private var openURL var neInit: NetworkExtensionInit? = nil var body: some View { ZStack { VStack { Spacer() Image("DecoPrimer") .resizable() .scaledToFit() .frame(minWidth: 0, minHeight: 50) } VStack { Spacer() .frame(minHeight: 20) HStack { Spacer() VStack(alignment: .leading, spacing: 10) { Image("EmotePrimer") Text("Allow System Extension") .font(.title) Text(self.subtext) .font(.body) .multilineTextAlignment(.leading) .fixedSize(horizontal: false, vertical: true) if let neInit = self.neInit { Button(action: neInit.continueAfterPriming) { Text("Install Now") .font(.headline) .frame(width: 300) } .buttonStyle(NoFadeButtonStyle()) } else { Button(action: { if #available(macOS 15, *) { self.openURL(URLs.ExtensionSettings) } else { self.openURL(URLs.PrivacySecurityExtensionSettings) } }) { if #available(macOS 15, *) { Text("Open Login Items & Extensions Settings") .font(.headline) .frame(width: 300) } else { Text("Open Privacy & Security Settings") .font(.headline) .frame(width: 300) } } .buttonStyle(NoFadeButtonStyle()) } } .frame(width: 350) .padding(.leading, 50) Spacer() if #available(macOS 15, *) { LoopingVideoPlayer(url: macOS15DemoVideo, width: 360, height: 410) } else { LoopingVideoPlayer(url: macOS14DemoVideo, width: 360, height: 410) } Spacer() } Spacer() .frame(minHeight: 50) } VStack(alignment: .trailing) { Spacer() HStack(alignment: .bottom) { Spacer() if #available(macOS 14.0, *) { HelpLink(destination: URLs.SystemExtensionHelp) .padding(.bottom, 2) } else { Button { self.openURL(URLs.SystemExtensionHelp) } label: { Image(systemName: "questionmark.circle.fill") .font(.system(size: 19)) .foregroundStyle(.white, .gray.opacity(0.4)) } .buttonStyle(.plain) .padding(.bottom, 2) .padding(.trailing, 2) } } .padding() } } } } ================================================ FILE: apple/client/macOS/RegisterLoginItemView.swift ================================================ import SwiftUI struct RegisterLoginItemView: View { var value: ObservableValue @Environment(\.openURL) private var openURL @State private var isRegistering = false var body: some View { Image(systemName: "desktopcomputer.and.arrow.down") .font(.system(size: 48)) .symbolRenderingMode(.palette) .foregroundStyle(.white, .blue) .padding() .buttonStyle(.plain) Text("Open at Login") .font(.title) Text("Do you want Obscura VPN to open automatically when you log in?") .font(.body) .multilineTextAlignment(.center) .padding() if self.isRegistering { ProgressView() } else { Button(action: { self.value.publish(true) }) { Text("Yes") .font(.headline) .frame(width: 300) } .buttonStyle(NoFadeButtonStyle()) Button(action: { self.value.publish(false) }) { Text("No") .frame(width: 300) } .buttonStyle(NoFadeButtonStyle(backgroundColor: Color(.darkGray))) } } } ================================================ FILE: apple/client/macOS/SparkleUpdater.swift ================================================ import Combine import Sparkle class SparkleUpdater { /** Sparkle updater. - seealso: [How to integrate the Sparkle framework into a SwiftUI app for MacOS](https://medium.com/@matteospada.m/how-to-integrate-the-sparkle-framework-into-a-swiftui-app-for-macos-98ca029f83f7) - seealso: [Sparkle: Basic Setup](https://sparkle-project.org/documentation/) - seealso: [Sparkle: Create an Updater in SwiftUI](https://sparkle-project.org/documentation/programmatic-setup/#create-an-updater-in-swiftui) */ private let sparkleUpdater: SPUUpdater private let updaterController: SPUStandardUpdaterController init(osStatus: WatchableValue) { self.updaterController = SPUStandardUpdaterController(startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil) self.sparkleUpdater = UpdaterDriver.createUpdater(osStatus: osStatus) } var sessionInProgress: Bool { return self.sparkleUpdater.sessionInProgress } var canCheckForUpdates: Bool { return self.sparkleUpdater.canCheckForUpdates } func checkForUpdates() throws { if self.sessionInProgress { return } guard self.canCheckForUpdates else { throw errorCodeUpdaterCheck } self.sparkleUpdater.checkForUpdates() } func showUpdaterIfNeeded() { self.updaterController.checkForUpdates(nil) } var canCheckForUpdatesPublisher: AnyPublisher { self.sparkleUpdater.publisher(for: \.canCheckForUpdates).eraseToAnyPublisher() } } ================================================ FILE: apple/client/macOS/UpdateSystemExtensionView.swift ================================================ import SwiftUI struct UpdateSystemExtensionView: View { @ObservedObject var startupModel: StartupModel var subtext: String @Environment(\.openURL) private var openURL var neInit: NetworkExtensionInit var body: some View { Spacer() .frame(height: 60) // extensions symbol for macOS <= 15 // coincidentally used for the network extensions symbol on macOS 15 Image(systemName: "puzzlepiece.extension.fill") .font(.system(size: 48)) .padding() Text("System Extension Update Required") .font(.title) Text(self.subtext) .font(.body) .multilineTextAlignment(.center) .fixedSize(horizontal: false, vertical: true) .frame(width: 350) .padding() Button(action: self.neInit.continueAfterPriming) { Text("Disconnect and Update") .font(.headline) .frame(width: 300) } .buttonStyle(NoFadeButtonStyle()) } } ================================================ FILE: apple/client/macOS/UpdaterDriver.swift ================================================ import OSLog import Sparkle private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "UpdaterDriver") class UpdaterDriver: NSObject, SPUUserDriver { private var osStatus: WatchableValue static func createUpdater(osStatus: WatchableValue) -> SPUUpdater { let updater = SPUUpdater(hostBundle: Bundle.main, applicationBundle: Bundle.main, userDriver: UpdaterDriver(osStatus: osStatus), delegate: nil) do { try updater.start() } catch { logger.error("Error starting custom updater: \(error, privacy: .public)") } return updater } init(osStatus: WatchableValue) { self.osStatus = osStatus super.init() } private func updateOsStatus(updaterStatus: UpdaterStatus) { logger.info("New osStatus.updaterStatus \(updaterStatus, privacy: .public))") _ = self.osStatus.update { value in value.updaterStatus = updaterStatus value.version = UUID() } } func showUserInitiatedUpdateCheck(cancellation: @escaping () -> Void) { let status = UpdaterStatus(type: .initiated, appcast: nil, error: nil, errorCode: nil) self.updateOsStatus(updaterStatus: status) } func showUpdateFound(with appcastItem: SUAppcastItem, state: SPUUserUpdateState) async -> SPUUserUpdateChoice { let appcast = AppcastSummary( date: appcastItem.dateString ?? "", description: appcastItem.itemDescription ?? "", version: appcastItem.displayVersionString, minSystemVersionOk: appcastItem.minimumOperatingSystemVersionIsOK ) let status = UpdaterStatus(type: .available, appcast: appcast, error: nil, errorCode: nil) self.updateOsStatus(updaterStatus: status) // don't want to install it return .dismiss } func showUpdateNotFoundWithError(_ error: Error, acknowledgement: @escaping () -> Void) { let appcastItem = (error as NSError).userInfo[SPULatestAppcastItemFoundKey] as? SUAppcastItem let notFoundReason = (error as NSError).userInfo[SPUNoUpdateFoundReasonKey] as? Int32 let appcast = appcastItem.map { item in AppcastSummary( date: item.dateString ?? "", description: item.itemDescription ?? "", version: item.displayVersionString, minSystemVersionOk: item.minimumOperatingSystemVersionIsOK ) } let status = UpdaterStatus(type: .notFound, appcast: appcast, error: error.localizedDescription, errorCode: notFoundReason) self.updateOsStatus(updaterStatus: status) acknowledgement() } func showUpdaterError(_ error: Error, acknowledgement: @escaping () -> Void) { let status = UpdaterStatus(type: .error, appcast: nil, error: error.localizedDescription, errorCode: nil) self.updateOsStatus(updaterStatus: status) acknowledgement() } func show(_ request: SPUUpdatePermissionRequest) async -> SUUpdatePermissionResponse { return SUUpdatePermissionResponse(automaticUpdateChecks: false, sendSystemProfile: false) } func showUpdateReleaseNotes(with downloadData: SPUDownloadData) {} func showUpdateReleaseNotesFailedToDownloadWithError(_ error: Error) {} func showDownloadInitiated(cancellation: @escaping () -> Void) {} func showDownloadDidReceiveExpectedContentLength(_ expectedContentLength: UInt64) {} func showDownloadDidReceiveData(ofLength length: UInt64) {} func showDownloadDidStartExtractingUpdate() {} func showExtractionReceivedProgress(_ progress: Double) {} func showReadyToInstallAndRelaunch() async -> SPUUserUpdateChoice { return .install } func showInstallingUpdate(withApplicationTerminated applicationTerminated: Bool, retryTerminatingApplication: @escaping () -> Void) {} func showUpdateInstalledAndRelaunched(_ relaunched: Bool, acknowledgement: @escaping () -> Void) { acknowledgement() } func showUpdateInFocus() {} func dismissUpdateInstallation() {} } ================================================ FILE: apple/client/startup.swift ================================================ import AVKit import NetworkExtension import OSLog import SwiftUI import UserNotifications private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "startup") struct StartupView: View { @StateObject var model = StartupModel.shared @Environment(\.openURL) private var openURL var body: some View { VStack { switch self.model.status { case .initial: AppIcon() Text("Starting Obscura") #if os(macOS) case .networkExtensionInit(_, .checking): VpnChecksView(subtext: "Checking Network Extension") case .networkExtensionInit(_, .enabling): VpnChecksView(subtext: "Enabling Network Extension") case .networkExtensionInit(_, .waitingForReboot): Text("Reboot Required") case .networkExtensionInit(let neInit, .blockingBeforePermissionPopup): InstallSystemExtensionView(startupModel: self.model, subtext: "Please allow Obscura VPN's network extension to be installed in System Settings to extend the networking features of your Mac.", neInit: neInit) case .networkExtensionInit(let neInit, .blockingBeforeTunnelDisconnect): UpdateSystemExtensionView(startupModel: self.model, subtext: "An updated version of Obscura VPN's network extension is required.", neInit: neInit) case .networkExtensionInit(_, .waitingForUserApproval): InstallSystemExtensionView(startupModel: self.model, subtext: "Please allow Obscura VPN's network extension to be installed in System Settings to extend the networking features of your Mac.") case .networkExtensionInit(_, .failed(let error)): InstallSystemExtensionView(startupModel: self.model, subtext: "Could not start the network extension. \(error). Please restart your Mac or contact support for help.") #endif case .tunnelProviderInit(_, .checking): VpnChecksView(subtext: "Checking Tunnel Provider") case .tunnelProviderInit(let tpInit, .blockingBeforePermissionPopup): VpnConfigurationView(startupModel: self.model, subtext: "This configuration is required for Obscura VPN to anonymize your network traffic.", tpInit: tpInit) case .tunnelProviderInit(_, .waitingForUserPermissionApproval): VpnConfigurationView(startupModel: self.model, subtext: "For Obscura VPN to add itself as a VPN to your system, please click \"Allow\" in the request for permission. If you are currently connected to a VPN, this will disconnect it.") case .tunnelProviderInit(let tpInit, .permissionDenied): VpnConfigurationView(startupModel: self.model, subtext: "Permission was denied. Click below to request permission again.", tpInit: tpInit, isError: true) case .tunnelProviderInit(_, .configuring): VpnChecksView(subtext: "Configuring Tunnel Provider") case .tunnelProviderInit(let tpInit, .waitingForUserStopOtherTunnelApproval(let manager)): VpnEnableView(manager: manager, subtext: "Obscura VPN was disabled by another VPN. Click below to enable it. If you are currently connected to a VPN, this will disconnect it.", tpInit: tpInit) case .tunnelProviderInit(_, .testingCommunication): VpnChecksView(subtext: "Testing Tunnel Provider communication") case .tunnelProviderInit(_, .unexpectedError): VpnFailedView() #if os(macOS) case .askToRegisterLoginItem(let value): RegisterLoginItemView(value: value) #endif case .ready: AppIcon() Text("Ready to launch") } } } } func AppIcon() -> some View { return Image(uxImage: UXImage(named: "AppIcon") ?? UXImage()) .resizable() .frame(width: 64, height: 64) } struct VpnChecksView: View { var manager: NETunnelProviderManager? var subtext: String var body: some View { VStack(spacing: 20) { AppIcon() ProgressView() Text(self.subtext) .font(.headline) } } } struct VpnEnableView: View { var manager: NETunnelProviderManager var subtext: String var tpInit: TunnelProviderInit var body: some View { ZStack(alignment: .topLeading) { Image(systemName: "network.badge.shield.half.filled") .font(.system(size: 48)) .foregroundStyle(.blue) .buttonStyle(.plain) } .padding() Text("Enable Obscura VPN") .font(.title) if !self.subtext.isEmpty { Text(self.subtext) .padding() .italic() .frame(width: 350) .frame(minHeight: 100) .multilineTextAlignment(.center) } Button(action: { self.tpInit.continueAfterStopOtherTunnelPriming(self.manager) }) { Text("Continue") .font(.headline) .frame(width: 300) } .buttonStyle(NoFadeButtonStyle()) } } struct VpnConfigurationView: View { @ObservedObject var startupModel: StartupModel var subtext = "" @Environment(\.openURL) private var openURL var tpInit: TunnelProviderInit? = nil var isError = false var body: some View { let primer = self.tpInit != nil && !self.isError ZStack(alignment: .topLeading) { Image(systemName: "network.badge.shield.half.filled") .font(.system(size: 48)) .foregroundStyle(.blue) .buttonStyle(.plain) .opacity(primer ? 1 : 0) Image(systemName: "network") .font(.system(size: 48)) .foregroundStyle(.blue) .buttonStyle(.plain) .opacity(primer ? 0 : 1) .overlay(alignment: .bottomTrailing) { Image(systemName: self.isError ? "xmark.circle.fill" : "ellipsis.circle.fill") .font(.system(size: 19)) .foregroundStyle(.black, self.isError ? .red : .white) .opacity(primer ? 0 : 1) .alignmentGuide(.bottom, computeValue: { $0.height }) .alignmentGuide(.trailing, computeValue: { $0.width }) } } .padding() Text("Allow VPN Configuration") .font(.title) if !self.subtext.isEmpty { Text(self.subtext) .padding() .italic() .frame(width: 350) .frame(minHeight: 100) .multilineTextAlignment(.center) } Button(action: { self.tpInit?.continueAfterPermissionPriming() }) { Text(self.isError ? "Retry VPN Configuration" : "Allow VPN Configuration") .font(.headline) .frame(width: 300) } .buttonStyle(NoFadeButtonStyle()) .disabled(!primer && !self.isError) } } struct VpnFailedView: View { var body: some View { VStack(spacing: 20) { AppIcon() Image(systemName: "xmark.circle.fill") .font(.system(size: 40)) .foregroundStyle(.black, .red) .padding() Text("Problem initializing Tunnel Provider") .font(.headline) Text("Please try restarting your device or contact support for help.") } } } class StartupModel: ObservableObject { static let shared = StartupModel() @Published var status = StartupStatus.initial @Published var appState: AppState? // This must be in StartupModel, otherwise color scheme applies only to the content view and not to the startup view @MainActor @AppStorage(UserDefaultKeys.SelectedAppearance) var selectedAppearance: AppAppearance = .auto init() { self.start() } private func start() { Task { @MainActor in #if os(macOS) guard let () = await self.stepNetworkExtensionInit() else { return } #endif guard let (tunnelProviderManager, status) = await self.stepTunnelProviderInit() else { return } #if os(macOS) await self.stepRegisterLoginItem() #endif self.update(status: .ready) self.appState = AppState(tunnelProviderManager, initialStatus: status) } } @MainActor private func update(status: StartupStatus) { logger.info("StartupModel.status = \(debugFormat(status), privacy: .public)") self.status = status } #if os(macOS) @MainActor private func stepNetworkExtensionInit() async -> Void? { var tunnelConnected = false do { let managers: [NETunnelProviderManager] = try await NETunnelProviderManager.loadAllFromPreferences() for manager in managers { let status = manager.connection.status if status != .disconnected { logger.info("connection status is \(status, privacy: .public), assume tunnel is connected") tunnelConnected = true } } } catch { logger.error("could not determine connection status, assume tunnel is connected: \(error, privacy: .public)") tunnelConnected = true } let neInit = NetworkExtensionInit(tunnelConnected: tunnelConnected) for await event in neInit.start() { switch event { case .status(let status): self.update(status: .networkExtensionInit(neInit, status)) case .done: return () } } logger.error("Failed to initialize network extension! \(debugFormat(self.status), privacy: .public)") return nil } #endif @MainActor private func stepTunnelProviderInit() async -> (NETunnelProviderManager, NeStatus)? { let tpInit = TunnelProviderInit() for await event in tpInit.start() { switch event { case .status(let status): self.update(status: .tunnelProviderInit(tpInit, status)) case .done(let manager, let status): return (manager, status) } } logger.error("Failed to initialize tunnel provider! \(debugFormat(self.status), privacy: .public)") return nil } #if os(macOS) @MainActor private func stepRegisterLoginItem() async { if !UserDefaults.standard.bool(forKey: UserDefaultKeys.LoginItemRegistered) { let value = ObservableValue() self.update(status: .askToRegisterLoginItem(value)) if await value.get() { do { try registerAsLoginItem(appState: self.appState) } catch {} } UserDefaults.standard.set(true, forKey: UserDefaultKeys.LoginItemRegistered) } } #endif } ================================================ FILE: apple/client.xcodeproj/project.pbxproj ================================================ // !$*UTF8*$! { archiveVersion = 1; classes = { }; objectVersion = 73; objects = { /* Begin PBXBuildFile section */ 1799341F2E94117C0089B6EB /* NotificationIds.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1799341E2E9411770089B6EB /* NotificationIds.swift */; }; 179934202E94117C0089B6EB /* NotificationIds.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1799341E2E9411770089B6EB /* NotificationIds.swift */; }; 179934212E94117C0089B6EB /* NotificationIds.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1799341E2E9411770089B6EB /* NotificationIds.swift */; }; 179934222E94117C0089B6EB /* NotificationIds.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1799341E2E9411770089B6EB /* NotificationIds.swift */; }; 17B910022E0DB3E50073AAD7 /* Keychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17B910012E0DB3E00073AAD7 /* Keychain.swift */; }; 17B910032E0DB3E50073AAD7 /* Keychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17B910012E0DB3E00073AAD7 /* Keychain.swift */; }; 1D9F4FDE2E5BC912001C080B /* DebugBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9BCB05D2DC54AC0006C0133 /* DebugBundle.swift */; }; 1D9F4FE02E5BCDED001C080B /* time.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35A6F2532C6121C2004E1A7C /* time.swift */; }; 1D9F50082E6280FF001C080B /* MailDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D9F50072E6280FF001C080B /* MailDelegate.swift */; }; 1DAD5D622ED7F27800DDB469 /* StoreKitListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DAD5D612ED7F27500DDB469 /* StoreKitListener.swift */; }; 1DAD5D662ED8FAA100DDB469 /* StoreKitModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DAD5D652ED8FAA100DDB469 /* StoreKitModel.swift */; }; 1DB5666B2EA565E7009CEB08 /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96C920682CD91D94002C85EA /* String.swift */; }; 300452782C49BC90000B78F7 /* Json.swift in Sources */ = {isa = PBXBuildFile; fileRef = 300452772C49BC90000B78F7 /* Json.swift */; }; 300452792C49BC90000B78F7 /* Json.swift in Sources */ = {isa = PBXBuildFile; fileRef = 300452772C49BC90000B78F7 /* Json.swift */; }; 30497E662D9EC09E008B22F9 /* ConcurrencyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30497E652D9EC09E008B22F9 /* ConcurrencyTests.swift */; }; 30497E672D9EC0BA008B22F9 /* Concurrency.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35230C152C764B97007ECFEC /* Concurrency.swift */; }; 30497E682D9EC129008B22F9 /* Swift.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35230C182C764F0D007ECFEC /* Swift.swift */; }; 30497E692D9EC16A008B22F9 /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96C920682CD91D94002C85EA /* String.swift */; }; 30497E6A2D9EC19E008B22F9 /* StringError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 309B467C2C4C152900A1B00F /* StringError.swift */; }; 30497E6B2D9EC7AC008B22F9 /* Sleep.swift in Sources */ = {isa = PBXBuildFile; fileRef = 309BA9122C45DFC8000A7428 /* Sleep.swift */; }; 30497E6E2D9ED584008B22F9 /* Box.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30497E6D2D9ED57F008B22F9 /* Box.swift */; }; 30497E6F2D9ED584008B22F9 /* Box.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30497E6D2D9ED57F008B22F9 /* Box.swift */; }; 30497E702D9ED5AD008B22F9 /* Box.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30497E6D2D9ED57F008B22F9 /* Box.swift */; }; 30497E722D9ED7D4008B22F9 /* DequeModule in Frameworks */ = {isa = PBXBuildFile; productRef = 30497E712D9ED7D4008B22F9 /* DequeModule */; }; 30497E742D9ED7DF008B22F9 /* DequeModule in Frameworks */ = {isa = PBXBuildFile; productRef = 30497E732D9ED7DF008B22F9 /* DequeModule */; }; 30497E762D9ED7E9008B22F9 /* DequeModule in Frameworks */ = {isa = PBXBuildFile; productRef = 30497E752D9ED7E9008B22F9 /* DequeModule */; }; 30497E782D9ED7EE008B22F9 /* DequeModule in Frameworks */ = {isa = PBXBuildFile; productRef = 30497E772D9ED7EE008B22F9 /* DequeModule */; }; 30761C222B6EB17100E5F60D /* ClientApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30761C212B6EB17100E5F60D /* ClientApp.swift */; }; 30761C242B6EB17100E5F60D /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30761C232B6EB17100E5F60D /* ContentView.swift */; }; 30761C262B6EB17200E5F60D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 30761C252B6EB17200E5F60D /* Assets.xcassets */; }; 30761C292B6EB17200E5F60D /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 30761C282B6EB17200E5F60D /* Preview Assets.xcassets */; }; 30920D752C1F71BB008690C3 /* startup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30920D742C1F71BB008690C3 /* startup.swift */; }; 30920D792C2057B1008690C3 /* initNetworkExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30920D782C2057B1008690C3 /* initNetworkExtension.swift */; }; 30920D7D2C207379008690C3 /* TunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30920D7C2C207379008690C3 /* TunnelProvider.swift */; }; 30920D942C3D51EC008690C3 /* NetworkExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 30761C362B6EB1F900E5F60D /* NetworkExtension.framework */; }; 30920DA92C3D53F1008690C3 /* libobscuravpn-client.a in Frameworks */ = {isa = PBXBuildFile; fileRef = C90161542BE01159005B14AF /* libobscuravpn-client.a */; platformFilter = ios; }; 30920DAD2C3DC174008690C3 /* InfoDict.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30920DAC2C3DC174008690C3 /* InfoDict.swift */; }; 30920DAE2C3DC174008690C3 /* InfoDict.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30920DAC2C3DC174008690C3 /* InfoDict.swift */; }; 30920DAF2C3DC174008690C3 /* InfoDict.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30920DAC2C3DC174008690C3 /* InfoDict.swift */; }; 3096BFF92CECD50F003D062E /* NEVPNStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3096BFF82CECD50F003D062E /* NEVPNStatus.swift */; }; 3096E0222BDC1FFB0026DE7F /* ScriptMessageHandlers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3096E0212BDC1FFB0026DE7F /* ScriptMessageHandlers.swift */; }; 3096E0402BEFC6770026DE7F /* command.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3096E03F2BEFC6770026DE7F /* command.swift */; }; 3096E0462BF0F5870026DE7F /* app_state.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3096E0452BF0F5860026DE7F /* app_state.swift */; }; 3098C18F2B921489008877AA /* NetworkExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 30761C362B6EB1F900E5F60D /* NetworkExtension.framework */; }; 3098C1922B921489008877AA /* PacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3098C1912B921489008877AA /* PacketTunnelProvider.swift */; }; 3098C1942B921489008877AA /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3098C1932B921489008877AA /* main.swift */; }; 3098C1992B921489008877AA /* net.obscura.vpn-client-app.system-network-extension.systemextension in Embed System Extensions */ = {isa = PBXBuildFile; fileRef = 3098C18E2B921489008877AA /* net.obscura.vpn-client-app.system-network-extension.systemextension */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 309B467D2C4C152900A1B00F /* StringError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 309B467C2C4C152900A1B00F /* StringError.swift */; }; 309B467E2C4C152900A1B00F /* StringError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 309B467C2C4C152900A1B00F /* StringError.swift */; }; 309BA90A2C443978000A7428 /* RustFfi.swift in Sources */ = {isa = PBXBuildFile; fileRef = 309BA9072C44394E000A7428 /* RustFfi.swift */; }; 309BA90C2C446125000A7428 /* NetworkSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 309BA90B2C446125000A7428 /* NetworkSettings.swift */; }; 309BA9132C45DFC8000A7428 /* Sleep.swift in Sources */ = {isa = PBXBuildFile; fileRef = 309BA9122C45DFC8000A7428 /* Sleep.swift */; }; 309BA9142C45DFC8000A7428 /* Sleep.swift in Sources */ = {isa = PBXBuildFile; fileRef = 309BA9122C45DFC8000A7428 /* Sleep.swift */; }; 309BFE3F2D9C169500366431 /* WatchableValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30C532092CC9558000936E1F /* WatchableValue.swift */; }; 30C5320A2CC9558000936E1F /* WatchableValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30C532092CC9558000936E1F /* WatchableValue.swift */; }; 30C5320C2CC959AE00936E1F /* OsStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30C5320B2CC959AE00936E1F /* OsStatus.swift */; }; 30EF74C12BFFE48C0095439F /* FfiCb.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30EF74C02BFFE48C0095439F /* FfiCb.swift */; }; 30EF74C22BFFE86B0095439F /* FfiCb.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30EF74C02BFFE48C0095439F /* FfiCb.swift */; }; 30EF74CD2C02244C0095439F /* NetworkExtensionIpc.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30EF74CC2C02244C0095439F /* NetworkExtensionIpc.swift */; }; 30EF74CE2C02244C0095439F /* NetworkExtensionIpc.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30EF74CC2C02244C0095439F /* NetworkExtensionIpc.swift */; }; 35219C3B2C6BD57F00E63BB8 /* Debug.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35219C3A2C6BD57F00E63BB8 /* Debug.swift */; }; 35219C3C2C6BD57F00E63BB8 /* Debug.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35219C3A2C6BD57F00E63BB8 /* Debug.swift */; }; 35230C162C764B97007ECFEC /* Concurrency.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35230C152C764B97007ECFEC /* Concurrency.swift */; }; 35230C172C764B97007ECFEC /* Concurrency.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35230C152C764B97007ECFEC /* Concurrency.swift */; }; 35230C192C764F0D007ECFEC /* Swift.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35230C182C764F0D007ECFEC /* Swift.swift */; }; 35230C1A2C764F0D007ECFEC /* Swift.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35230C182C764F0D007ECFEC /* Swift.swift */; }; 35230C282C775FF1007ECFEC /* Notifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35230C272C775FF1007ECFEC /* Notifications.swift */; }; 352D58E02C4AE796002F3404 /* ObservableValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 352D58DF2C4AE796002F3404 /* ObservableValue.swift */; }; 352D58E12C4AE796002F3404 /* ObservableValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 352D58DF2C4AE796002F3404 /* ObservableValue.swift */; }; 35A6F2522C611DD1004E1A7C /* DebugBundle+XP.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35A6F2512C611DD1004E1A7C /* DebugBundle+XP.swift */; }; 35A6F2542C6121C2004E1A7C /* time.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35A6F2532C6121C2004E1A7C /* time.swift */; }; 35A6F2552C613366004E1A7C /* time.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35A6F2532C6121C2004E1A7C /* time.swift */; }; 35F8DE7F2C6559D20016CEEB /* OSLogEntryEncodable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35F8DE7E2C6559D20016CEEB /* OSLogEntryEncodable.swift */; }; 35F8DE872C6666C40016CEEB /* DebugBundleExtensionInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35F8DE862C6666C40016CEEB /* DebugBundleExtensionInfo.swift */; }; 962325182C875B3E008A9B76 /* StatusMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 962325172C875B3E008A9B76 /* StatusMenu.swift */; }; 962325212C88D4E8008A9B76 /* ObscuraToggle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 962325202C88D4E8008A9B76 /* ObscuraToggle.swift */; }; 962325232C8A3B58008A9B76 /* MenuItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 962325222C8A3B58008A9B76 /* MenuItemView.swift */; }; 9632E4642D19C5EC00BC8E3F /* AccountStatusItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9632E4632D19C5EA00BC8E3F /* AccountStatusItem.swift */; }; 9649F9592DA4233F009EFF4F /* LoopingVideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9649F9582DA4233F009EFF4F /* LoopingVideoPlayer.swift */; }; 9649F9732DA56142009EFF4F /* AVKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9649F9722DA56142009EFF4F /* AVKit.framework */; }; 96516AC32BF928DD00576562 /* build in Resources */ = {isa = PBXBuildFile; fileRef = 96516AC22BF928DD00576562 /* build */; }; 96615E292C598A9600120DEF /* CwlSysctl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96615E282C598A9600120DEF /* CwlSysctl.swift */; }; 966967C12D440B450019AF9F /* LoginItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 966967C02D440B430019AF9F /* LoginItem.swift */; }; 967D0C862C41A1E500FD0767 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 967D0C852C41A1E500FD0767 /* Constants.swift */; }; 968B02B92CFF5B7B0053D0EF /* Account.swift in Sources */ = {isa = PBXBuildFile; fileRef = 968B02B82CFF5B7B0053D0EF /* Account.swift */; }; 968B02BA2CFF5B7B0053D0EF /* Account.swift in Sources */ = {isa = PBXBuildFile; fileRef = 968B02B82CFF5B7B0053D0EF /* Account.swift */; }; 96C9205B2CD549B1002C85EA /* BandwidthStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96C9205A2CD549B1002C85EA /* BandwidthStatus.swift */; }; 96C920692CD91D96002C85EA /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96C920682CD91D94002C85EA /* String.swift */; }; 96C9206A2CD91D96002C85EA /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96C920682CD91D94002C85EA /* String.swift */; }; 96DB3D542E60F01B005B4D1B /* Appearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96DB3D532E60F01B005B4D1B /* Appearance.swift */; }; 96DB3D552E60F01B005B4D1B /* Appearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96DB3D532E60F01B005B4D1B /* Appearance.swift */; }; A082F8232C46BF5B002AF810 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = A082F8222C46BF5B002AF810 /* Sparkle */; }; A9029C192DEEDCBC00AAD761 /* ObscuraUIMacOSWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9029C182DEEDCBC00AAD761 /* ObscuraUIMacOSWrapper.swift */; }; A9200A862DD1251C00FD035C /* ObscuraUIWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9200A842DD1251C00FD035C /* ObscuraUIWebView.swift */; }; A9200A872DD127A200FD035C /* Notifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35230C272C775FF1007ECFEC /* Notifications.swift */; }; A936D4452DD1492A0031B646 /* UXViewRepresentable.swift in Sources */ = {isa = PBXBuildFile; fileRef = A936D4432DD149290031B646 /* UXViewRepresentable.swift */; }; A936D4462DD1492A0031B646 /* UXViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A936D4422DD149290031B646 /* UXViewController.swift */; }; A936D4472DD1492A0031B646 /* UXViewRepresentable.swift in Sources */ = {isa = PBXBuildFile; fileRef = A936D4432DD149290031B646 /* UXViewRepresentable.swift */; }; A936D4482DD1492A0031B646 /* UXViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A936D4422DD149290031B646 /* UXViewController.swift */; }; A936D4712DD14DC30031B646 /* ObscuraUIWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9200A842DD1251C00FD035C /* ObscuraUIWebView.swift */; }; A936D4722DD14E040031B646 /* ScriptMessageHandlers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3096E0212BDC1FFB0026DE7F /* ScriptMessageHandlers.swift */; }; A936D4732DD14E470031B646 /* command.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3096E03F2BEFC6770026DE7F /* command.swift */; }; A936D48F2DD15AED0031B646 /* startup.swift in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 30920D742C1F71BB008690C3 /* startup.swift */; }; A936D4902DD15B6B0031B646 /* startup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30920D742C1F71BB008690C3 /* startup.swift */; }; A936D4932DD15F6A0031B646 /* StartupStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = A936D4922DD15F6A0031B646 /* StartupStatus.swift */; }; A936D4942DD15F6A0031B646 /* StartupStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = A936D4922DD15F6A0031B646 /* StartupStatus.swift */; }; A936D4962DD160750031B646 /* UpdateSystemExtensionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A936D4952DD160750031B646 /* UpdateSystemExtensionView.swift */; }; A936D4992DD1617F0031B646 /* UXImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A936D4982DD1617F0031B646 /* UXImage.swift */; }; A936D49A2DD161B00031B646 /* UXImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A936D4982DD1617F0031B646 /* UXImage.swift */; }; A936D49C2DD162AB0031B646 /* InstallSystemExtensionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A936D49B2DD162AB0031B646 /* InstallSystemExtensionView.swift */; }; A94743F92DD17143002ACD85 /* iOSClientApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A94743F72DD170F5002ACD85 /* iOSClientApp.swift */; }; A94B1D9C2E286B3900E5F325 /* ConditionallyDisabled.swift in Sources */ = {isa = PBXBuildFile; fileRef = A94B1D9B2E286B3900E5F325 /* ConditionallyDisabled.swift */; }; A94B1DCD2E28B13E00E5F325 /* AccountInfo+Util.swift in Sources */ = {isa = PBXBuildFile; fileRef = A94B1DCC2E28B13E00E5F325 /* AccountInfo+Util.swift */; }; A94B1DCE2E28B13E00E5F325 /* AccountInfo+Util.swift in Sources */ = {isa = PBXBuildFile; fileRef = A94B1DCC2E28B13E00E5F325 /* AccountInfo+Util.swift */; }; A94B1DD42E28B16800E5F325 /* Product+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = A94B1DD02E28B16800E5F325 /* Product+Convenience.swift */; }; A94B1E242E2B03B900E5F325 /* ConditionallyDisabled.swift in Sources */ = {isa = PBXBuildFile; fileRef = A94B1D9B2E286B3900E5F325 /* ConditionallyDisabled.swift */; }; A94B1E3D2E2B236800E5F325 /* Obscura VPN Local.storekit in Resources */ = {isa = PBXBuildFile; fileRef = A94B1E3C2E2B236800E5F325 /* Obscura VPN Local.storekit */; }; A94BF4462E53BBEF007FDD1C /* Account+CustomStringConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = A94BF4452E53BBE9007FDD1C /* Account+CustomStringConvertible.swift */; }; A94BF4472E53BBEF007FDD1C /* Account+CustomStringConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = A94BF4452E53BBE9007FDD1C /* Account+CustomStringConvertible.swift */; }; A94BF4482E53BBEF007FDD1C /* Account+CustomStringConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = A94BF4452E53BBE9007FDD1C /* Account+CustomStringConvertible.swift */; }; A94BF4492E53BBEF007FDD1C /* Account+CustomStringConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = A94BF4452E53BBE9007FDD1C /* Account+CustomStringConvertible.swift */; }; A95A91332DF9910C005CB52A /* ObscuraUIIOSWrapperAndTabs.swift in Sources */ = {isa = PBXBuildFile; fileRef = A95A91322DF9910C005CB52A /* ObscuraUIIOSWrapperAndTabs.swift */; }; A9758FDE2E41910300741928 /* HyperlinkButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9758FDD2E41910300741928 /* HyperlinkButtonStyle.swift */; }; A9758FDF2E41910300741928 /* HyperlinkButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9758FDD2E41910300741928 /* HyperlinkButtonStyle.swift */; }; A9768FAD2DBB01AD00A4595F /* UpdaterDriver.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9768FAC2DBB01AD00A4595F /* UpdaterDriver.swift */; }; A9768FAF2DBB01C400A4595F /* CheckForUpdatesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9768FAE2DBB01C400A4595F /* CheckForUpdatesView.swift */; }; A9768FB42DBB01EE00A4595F /* SparkleUpdater.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9768FB32DBB01EE00A4595F /* SparkleUpdater.swift */; }; A9768FBE2DBB02BC00A4595F /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30761C232B6EB17100E5F60D /* ContentView.swift */; }; A9768FC02DBB02C400A4595F /* OrderedCollections in Frameworks */ = {isa = PBXBuildFile; productRef = A9768FBF2DBB02C400A4595F /* OrderedCollections */; }; A9768FC22DBB02C900A4595F /* OrderedCollections in Frameworks */ = {isa = PBXBuildFile; productRef = A9768FC12DBB02C900A4595F /* OrderedCollections */; }; A983D7E02DF25435007306C5 /* WebviewsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A983D7DF2DF25435007306C5 /* WebviewsController.swift */; }; A983D7E12DF25435007306C5 /* WebviewsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A983D7DF2DF25435007306C5 /* WebviewsController.swift */; }; A983D7E32DF261BB007306C5 /* ExternalWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A983D7E22DF261BB007306C5 /* ExternalWebView.swift */; }; A983D7E42DF261BB007306C5 /* ExternalWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A983D7E22DF261BB007306C5 /* ExternalWebView.swift */; }; A98F1BCE2DADFF17007F04D3 /* DequeModule in Frameworks */ = {isa = PBXBuildFile; productRef = A98F1BA22DADFF17007F04D3 /* DequeModule */; }; A98F1BD02DADFF17007F04D3 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 30761C282B6EB17200E5F60D /* Preview Assets.xcassets */; }; A98F1BD12DADFF17007F04D3 /* build in Resources */ = {isa = PBXBuildFile; fileRef = 96516AC22BF928DD00576562 /* build */; }; A98F1BD22DADFF17007F04D3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 30761C252B6EB17200E5F60D /* Assets.xcassets */; }; A98F1BDA2DAE00A0007F04D3 /* App Network Extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 30920D932C3D51EC008690C3 /* App Network Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; A9963C562DB83FBE00D10893 /* FfiCb.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30EF74C02BFFE48C0095439F /* FfiCb.swift */; }; A9963C572DB83FC200D10893 /* Account.swift in Sources */ = {isa = PBXBuildFile; fileRef = 968B02B82CFF5B7B0053D0EF /* Account.swift */; }; A9963C582DB83FC600D10893 /* Box.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30497E6D2D9ED57F008B22F9 /* Box.swift */; }; A9963C592DB83FCB00D10893 /* Debug.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35219C3A2C6BD57F00E63BB8 /* Debug.swift */; }; A9963C5A2DB83FCF00D10893 /* NetworkSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 309BA90B2C446125000A7428 /* NetworkSettings.swift */; }; A9963C5B2DB83FD200D10893 /* Json.swift in Sources */ = {isa = PBXBuildFile; fileRef = 300452772C49BC90000B78F7 /* Json.swift */; }; A9963C5C2DB83FD600D10893 /* Swift.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35230C182C764F0D007ECFEC /* Swift.swift */; }; A9963C5D2DB83FF300D10893 /* StringError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 309B467C2C4C152900A1B00F /* StringError.swift */; }; A9963C5E2DB83FF700D10893 /* RustFfi.swift in Sources */ = {isa = PBXBuildFile; fileRef = 309BA9072C44394E000A7428 /* RustFfi.swift */; }; A9963C5F2DB83FFD00D10893 /* NetworkExtensionIpc.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30EF74CC2C02244C0095439F /* NetworkExtensionIpc.swift */; }; A9963C602DB8400300D10893 /* WatchableValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30C532092CC9558000936E1F /* WatchableValue.swift */; }; A9963C612DB8400900D10893 /* Concurrency.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35230C152C764B97007ECFEC /* Concurrency.swift */; }; A9963C622DB8400D00D10893 /* PacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3098C1912B921489008877AA /* PacketTunnelProvider.swift */; }; A9963C632DB8401100D10893 /* Sleep.swift in Sources */ = {isa = PBXBuildFile; fileRef = 309BA9122C45DFC8000A7428 /* Sleep.swift */; }; A998710E2DBAF7FE0044D136 /* RegisterLoginItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A998710C2DBAF7FE0044D136 /* RegisterLoginItemView.swift */; }; A99871112DBAF8080044D136 /* NoFadeButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = A998710F2DBAF8080044D136 /* NoFadeButtonStyle.swift */; }; A99871122DBAF8080044D136 /* NoFadeButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = A998710F2DBAF8080044D136 /* NoFadeButtonStyle.swift */; }; A99871152DBAF84D0044D136 /* app_state.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3096E0452BF0F5860026DE7F /* app_state.swift */; }; A99871162DBAF8510044D136 /* OsStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30C5320B2CC959AE00936E1F /* OsStatus.swift */; }; A99871182DBAF8590044D136 /* WatchableValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30C532092CC9558000936E1F /* WatchableValue.swift */; }; A99871192DBAF85C0044D136 /* ObservableValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 352D58DF2C4AE796002F3404 /* ObservableValue.swift */; }; A998711A2DBAF8610044D136 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 967D0C852C41A1E500FD0767 /* Constants.swift */; }; A998711B2DBAF8680044D136 /* FfiCb.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30EF74C02BFFE48C0095439F /* FfiCb.swift */; }; A998711C2DBAF8700044D136 /* Swift.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35230C182C764F0D007ECFEC /* Swift.swift */; }; A998711D2DBAF8740044D136 /* Box.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30497E6D2D9ED57F008B22F9 /* Box.swift */; }; A998711E2DBAF8790044D136 /* Concurrency.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35230C152C764B97007ECFEC /* Concurrency.swift */; }; A998711F2DBAF87D0044D136 /* Sleep.swift in Sources */ = {isa = PBXBuildFile; fileRef = 309BA9122C45DFC8000A7428 /* Sleep.swift */; }; A99871202DBAF8810044D136 /* StringError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 309B467C2C4C152900A1B00F /* StringError.swift */; }; A99871212DBAF8850044D136 /* Json.swift in Sources */ = {isa = PBXBuildFile; fileRef = 300452772C49BC90000B78F7 /* Json.swift */; }; A99871222DBAF8890044D136 /* OSLogEntryEncodable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35F8DE7E2C6559D20016CEEB /* OSLogEntryEncodable.swift */; }; A99871232DBAF88F0044D136 /* NEVPNStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3096BFF82CECD50F003D062E /* NEVPNStatus.swift */; }; A99871242DBAF8940044D136 /* Account.swift in Sources */ = {isa = PBXBuildFile; fileRef = 968B02B82CFF5B7B0053D0EF /* Account.swift */; }; A99871252DBAF8990044D136 /* InfoDict.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30920DAC2C3DC174008690C3 /* InfoDict.swift */; }; A99871262DBAF89E0044D136 /* NetworkExtensionIpc.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30EF74CC2C02244C0095439F /* NetworkExtensionIpc.swift */; }; A99871272DBAF8A60044D136 /* TunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30920D7C2C207379008690C3 /* TunnelProvider.swift */; }; A99871282DBAF8FB0044D136 /* DebugBundle+XP.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35A6F2512C611DD1004E1A7C /* DebugBundle+XP.swift */; }; A99871292DBAF90B0044D136 /* Debug.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35219C3A2C6BD57F00E63BB8 /* Debug.swift */; }; A9BCB05E2DC54AC0006C0133 /* DebugBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9BCB05D2DC54AC0006C0133 /* DebugBundle.swift */; }; A9BCB0612DC54B68006C0133 /* UpdaterDriver+XP.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9BCB0602DC54B68006C0133 /* UpdaterDriver+XP.swift */; }; A9BCB0622DC54B68006C0133 /* UpdaterDriver+XP.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9BCB0602DC54B68006C0133 /* UpdaterDriver+XP.swift */; }; A9E44A782E093DA7006B4616 /* Obscura VPN.storekit in Resources */ = {isa = PBXBuildFile; fileRef = A9E44A762E093DA7006B4616 /* Obscura VPN.storekit */; }; A9E44A7C2E0947EA006B4616 /* Product+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9E44A7B2E0947EA006B4616 /* Product+Convenience.swift */; }; C90161572BE011B2005B14AF /* libobscuravpn-client.a in Frameworks */ = {isa = PBXBuildFile; fileRef = C90161542BE01159005B14AF /* libobscuravpn-client.a */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ 3098C1972B921489008877AA /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 30761C162B6EB17100E5F60D /* Project object */; proxyType = 1; remoteGlobalIDString = 3098C18D2B921489008877AA; remoteInfo = "system-network-extension"; }; A98F1BDB2DAE00A0007F04D3 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 30761C162B6EB17100E5F60D /* Project object */; proxyType = 1; remoteGlobalIDString = 30920D922C3D51EC008690C3; remoteInfo = "App Network Extension"; }; A98F1BDE2DAE00A5007F04D3 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 30761C162B6EB17100E5F60D /* Project object */; proxyType = 1; remoteGlobalIDString = 30920D922C3D51EC008690C3; remoteInfo = "App Network Extension"; }; C90161532BE01159005B14AF /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = C901614E2BE01159005B14AF /* obscuravpn-client.xcodeproj */; proxyType = 2; remoteGlobalIDString = CA0090E2379FFD96F1473BE9; remoteInfo = "obscuravpn-client.a (static library)"; }; C90161552BE01159005B14AF /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = C901614E2BE01159005B14AF /* obscuravpn-client.xcodeproj */; proxyType = 2; remoteGlobalIDString = CA01272F0B60B8156098F4D0; remoteInfo = "obscuravpn-client (standalone executable)"; }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ 3098C1862B9211B1008877AA /* Embed System Extensions */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; dstPath = "$(SYSTEM_EXTENSIONS_FOLDER_PATH)"; dstSubfolderSpec = 16; files = ( 3098C1992B921489008877AA /* net.obscura.vpn-client-app.system-network-extension.systemextension in Embed System Extensions */, ); name = "Embed System Extensions"; runOnlyForDeploymentPostprocessing = 0; }; 9649F96E2DA45808009EFF4F /* Copy Files */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; dstPath = videos; dstSubfolderSpec = 7; files = ( ); name = "Copy Files"; runOnlyForDeploymentPostprocessing = 0; }; A98F1BDD2DAE00A0007F04D3 /* Embed Foundation Extensions */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; dstPath = ""; dstSubfolderSpec = 13; files = ( A936D48F2DD15AED0031B646 /* startup.swift in Embed Foundation Extensions */, A98F1BDA2DAE00A0007F04D3 /* App Network Extension.appex in Embed Foundation Extensions */, ); name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ 1799341E2E9411770089B6EB /* NotificationIds.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationIds.swift; sourceTree = ""; }; 17B910012E0DB3E00073AAD7 /* Keychain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Keychain.swift; sourceTree = ""; }; 1D9F50072E6280FF001C080B /* MailDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MailDelegate.swift; sourceTree = ""; }; 1DAD5D612ED7F27500DDB469 /* StoreKitListener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreKitListener.swift; sourceTree = ""; }; 1DAD5D652ED8FAA100DDB469 /* StoreKitModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreKitModel.swift; sourceTree = ""; }; 300452772C49BC90000B78F7 /* Json.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Json.swift; sourceTree = ""; }; 30497E5E2D9EC038008B22F9 /* Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = Tests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 30497E652D9EC09E008B22F9 /* ConcurrencyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConcurrencyTests.swift; sourceTree = ""; }; 30497E6D2D9ED57F008B22F9 /* Box.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Box.swift; sourceTree = ""; }; 30761C1E2B6EB17100E5F60D /* Obscura VPN (Debug).app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Obscura VPN (Debug).app"; sourceTree = BUILT_PRODUCTS_DIR; }; 30761C212B6EB17100E5F60D /* ClientApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientApp.swift; sourceTree = ""; }; 30761C232B6EB17100E5F60D /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 30761C252B6EB17200E5F60D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 30761C282B6EB17200E5F60D /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 30761C2A2B6EB17200E5F60D /* client.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = client.entitlements; sourceTree = ""; }; 30761C362B6EB1F900E5F60D /* NetworkExtension.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = NetworkExtension.framework; path = System/Library/Frameworks/NetworkExtension.framework; sourceTree = SDKROOT; }; 30920D742C1F71BB008690C3 /* startup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = startup.swift; sourceTree = ""; }; 30920D782C2057B1008690C3 /* initNetworkExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = initNetworkExtension.swift; sourceTree = ""; }; 30920D7C2C207379008690C3 /* TunnelProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelProvider.swift; sourceTree = ""; }; 30920D932C3D51EC008690C3 /* App Network Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "App Network Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; 30920D982C3D51EC008690C3 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 30920D992C3D51EC008690C3 /* entitlements.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = entitlements.entitlements; sourceTree = ""; }; 30920DAC2C3DC174008690C3 /* InfoDict.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoDict.swift; sourceTree = ""; }; 3096BFF82CECD50F003D062E /* NEVPNStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NEVPNStatus.swift; sourceTree = ""; }; 3096E0212BDC1FFB0026DE7F /* ScriptMessageHandlers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScriptMessageHandlers.swift; sourceTree = ""; }; 3096E03F2BEFC6770026DE7F /* command.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = command.swift; sourceTree = ""; }; 3096E0452BF0F5860026DE7F /* app_state.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = app_state.swift; sourceTree = ""; }; 3098C18E2B921489008877AA /* net.obscura.vpn-client-app.system-network-extension.systemextension */ = {isa = PBXFileReference; explicitFileType = "wrapper.system-extension"; includeInIndex = 0; path = "net.obscura.vpn-client-app.system-network-extension.systemextension"; sourceTree = BUILT_PRODUCTS_DIR; }; 3098C1912B921489008877AA /* PacketTunnelProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PacketTunnelProvider.swift; sourceTree = ""; }; 3098C1932B921489008877AA /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; 3098C1952B921489008877AA /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 3098C1962B921489008877AA /* entitlements.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = entitlements.entitlements; sourceTree = ""; }; 309B467C2C4C152900A1B00F /* StringError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringError.swift; sourceTree = ""; }; 309BA9072C44394E000A7428 /* RustFfi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RustFfi.swift; sourceTree = ""; }; 309BA90B2C446125000A7428 /* NetworkSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkSettings.swift; sourceTree = ""; }; 309BA9122C45DFC8000A7428 /* Sleep.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sleep.swift; sourceTree = ""; }; 30C532092CC9558000936E1F /* WatchableValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchableValue.swift; sourceTree = ""; }; 30C5320B2CC959AE00936E1F /* OsStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OsStatus.swift; sourceTree = ""; }; 30EF74C02BFFE48C0095439F /* FfiCb.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FfiCb.swift; sourceTree = ""; }; 30EF74CC2C02244C0095439F /* NetworkExtensionIpc.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkExtensionIpc.swift; sourceTree = ""; }; 35219C3A2C6BD57F00E63BB8 /* Debug.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Debug.swift; sourceTree = ""; }; 35230C152C764B97007ECFEC /* Concurrency.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Concurrency.swift; sourceTree = ""; }; 35230C182C764F0D007ECFEC /* Swift.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Swift.swift; sourceTree = ""; }; 35230C272C775FF1007ECFEC /* Notifications.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Notifications.swift; sourceTree = ""; }; 352D58DF2C4AE796002F3404 /* ObservableValue.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ObservableValue.swift; sourceTree = ""; }; 35A6F2512C611DD1004E1A7C /* DebugBundle+XP.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "DebugBundle+XP.swift"; sourceTree = ""; }; 35A6F2532C6121C2004E1A7C /* time.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = time.swift; sourceTree = ""; }; 35F8DE7E2C6559D20016CEEB /* OSLogEntryEncodable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OSLogEntryEncodable.swift; sourceTree = ""; }; 35F8DE862C6666C40016CEEB /* DebugBundleExtensionInfo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DebugBundleExtensionInfo.swift; sourceTree = ""; }; 962325172C875B3E008A9B76 /* StatusMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusMenu.swift; sourceTree = ""; }; 962325202C88D4E8008A9B76 /* ObscuraToggle.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ObscuraToggle.swift; sourceTree = ""; }; 962325222C8A3B58008A9B76 /* MenuItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuItemView.swift; sourceTree = ""; }; 9632E4632D19C5EA00BC8E3F /* AccountStatusItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountStatusItem.swift; sourceTree = ""; }; 9649F9582DA4233F009EFF4F /* LoopingVideoPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopingVideoPlayer.swift; sourceTree = ""; }; 9649F9722DA56142009EFF4F /* AVKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AVKit.framework; path = System/Library/Frameworks/AVKit.framework; sourceTree = SDKROOT; }; 96516AC22BF928DD00576562 /* build */ = {isa = PBXFileReference; lastKnownFileType = folder; name = build; path = "../obscura-ui/build"; sourceTree = ""; }; 96615E282C598A9600120DEF /* CwlSysctl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CwlSysctl.swift; sourceTree = ""; }; 966967C02D440B430019AF9F /* LoginItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginItem.swift; sourceTree = ""; }; 967D0C852C41A1E500FD0767 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; 968B02B82CFF5B7B0053D0EF /* Account.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Account.swift; sourceTree = ""; }; 96C9205A2CD549B1002C85EA /* BandwidthStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BandwidthStatus.swift; sourceTree = ""; }; 96C920682CD91D94002C85EA /* String.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = String.swift; sourceTree = ""; }; 96DB3D532E60F01B005B4D1B /* Appearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Appearance.swift; sourceTree = ""; }; A06E85A62C2ECA680087C8C8 /* bundle-ids.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "bundle-ids.xcconfig"; sourceTree = ""; }; A06E85A92C2ECA680087C8C8 /* app.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = app.xcconfig; sourceTree = ""; }; A06E85AA2C2ECA680087C8C8 /* buildversion.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = buildversion.xcconfig; sourceTree = ""; }; A9029C182DEEDCBC00AAD761 /* ObscuraUIMacOSWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObscuraUIMacOSWrapper.swift; sourceTree = ""; }; A9200A842DD1251C00FD035C /* ObscuraUIWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObscuraUIWebView.swift; sourceTree = ""; }; A936D4422DD149290031B646 /* UXViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UXViewController.swift; sourceTree = ""; }; A936D4432DD149290031B646 /* UXViewRepresentable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UXViewRepresentable.swift; sourceTree = ""; }; A936D4922DD15F6A0031B646 /* StartupStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupStatus.swift; sourceTree = ""; }; A936D4952DD160750031B646 /* UpdateSystemExtensionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateSystemExtensionView.swift; sourceTree = ""; }; A936D4982DD1617F0031B646 /* UXImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UXImage.swift; sourceTree = ""; }; A936D49B2DD162AB0031B646 /* InstallSystemExtensionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstallSystemExtensionView.swift; sourceTree = ""; }; A94743F72DD170F5002ACD85 /* iOSClientApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSClientApp.swift; sourceTree = ""; }; A94B1D9B2E286B3900E5F325 /* ConditionallyDisabled.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConditionallyDisabled.swift; sourceTree = ""; }; A94B1DCC2E28B13E00E5F325 /* AccountInfo+Util.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AccountInfo+Util.swift"; sourceTree = ""; }; A94B1DD02E28B16800E5F325 /* Product+Convenience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Product+Convenience.swift"; sourceTree = ""; }; A94B1E3C2E2B236800E5F325 /* Obscura VPN Local.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = "Obscura VPN Local.storekit"; sourceTree = ""; }; A94BF4452E53BBE9007FDD1C /* Account+CustomStringConvertible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Account+CustomStringConvertible.swift"; sourceTree = ""; }; A95A91322DF9910C005CB52A /* ObscuraUIIOSWrapperAndTabs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObscuraUIIOSWrapperAndTabs.swift; sourceTree = ""; }; A9758FDD2E41910300741928 /* HyperlinkButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HyperlinkButtonStyle.swift; sourceTree = ""; }; A9768FAC2DBB01AD00A4595F /* UpdaterDriver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdaterDriver.swift; sourceTree = ""; }; A9768FAE2DBB01C400A4595F /* CheckForUpdatesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckForUpdatesView.swift; sourceTree = ""; }; A9768FB32DBB01EE00A4595F /* SparkleUpdater.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SparkleUpdater.swift; sourceTree = ""; }; A983D7DF2DF25435007306C5 /* WebviewsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebviewsController.swift; sourceTree = ""; }; A983D7E22DF261BB007306C5 /* ExternalWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExternalWebView.swift; sourceTree = ""; }; A98F1BD82DADFF17007F04D3 /* Obscura VPN iOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Obscura VPN iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; }; A998710C2DBAF7FE0044D136 /* RegisterLoginItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisterLoginItemView.swift; sourceTree = ""; }; A998710F2DBAF8080044D136 /* NoFadeButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoFadeButtonStyle.swift; sourceTree = ""; }; A9BCB05D2DC54AC0006C0133 /* DebugBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugBundle.swift; sourceTree = ""; }; A9BCB0602DC54B68006C0133 /* UpdaterDriver+XP.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UpdaterDriver+XP.swift"; sourceTree = ""; }; A9E44A762E093DA7006B4616 /* Obscura VPN.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = "Obscura VPN.storekit"; sourceTree = ""; }; A9E44A7B2E0947EA006B4616 /* Product+Convenience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Product+Convenience.swift"; sourceTree = ""; }; C901614E2BE01159005B14AF /* obscuravpn-client.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; path = "obscuravpn-client.xcodeproj"; sourceTree = ""; }; C90161A62BE01A8E005B14AF /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; C90161A72BE01A8E005B14AF /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; C90161A82BE01A8E005B14AF /* Base.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Base.xcconfig; sourceTree = ""; }; C92777D22C248A740058BBFB /* Debug-app.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Debug-app.xcconfig"; sourceTree = ""; }; C92778072C24FCE40058BBFB /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; C96FFC8C2C3DD2FD00D87937 /* system-network-extension.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "system-network-extension.xcconfig"; sourceTree = ""; }; C96FFC8F2C3DD2FD00D87937 /* Debug-system-network-extension.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Debug-system-network-extension.xcconfig"; sourceTree = ""; }; C96FFC902C3DD2FD00D87937 /* Release-system-network-extension.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Release-system-network-extension.xcconfig"; sourceTree = ""; }; C96FFC922C3DD5DC00D87937 /* Release-app-network-extension.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Release-app-network-extension.xcconfig"; sourceTree = ""; }; C96FFC952C3DD5DC00D87937 /* Debug-app-network-extension.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Debug-app-network-extension.xcconfig"; sourceTree = ""; }; C96FFC962C3DD5DC00D87937 /* app-network-extension.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "app-network-extension.xcconfig"; sourceTree = ""; }; C9D486352C123078007D5F2F /* Release-app.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Release-app.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ 9614762B2DAEE5210081B2DE /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( "macOS 14 System Extension Demo.mov", "macOS 15 System Extension Demo.mov", ); target = 30761C1D2B6EB17100E5F60D /* Obscura VPN */; }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet section */ 9614762E2DAEE5270081B2DE /* PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet */ = { isa = PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet; buildPhase = 9649F96E2DA45808009EFF4F /* Copy Files */; membershipExceptions = ( "macOS 14 System Extension Demo.mov", "macOS 15 System Extension Demo.mov", ); }; /* End PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ 9649F96A2DA457B2009EFF4F /* videos */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (9614762B2DAEE5210081B2DE /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 9614762E2DAEE5270081B2DE /* PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = videos; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ 30497E5B2D9EC038008B22F9 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( 30497E722D9ED7D4008B22F9 /* DequeModule in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; 30761C1B2B6EB17100E5F60D /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( 9649F9732DA56142009EFF4F /* AVKit.framework in Frameworks */, A082F8232C46BF5B002AF810 /* Sparkle in Frameworks */, 30497E782D9ED7EE008B22F9 /* DequeModule in Frameworks */, A9768FC22DBB02C900A4595F /* OrderedCollections in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; 30920D902C3D51EC008690C3 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( 30920D942C3D51EC008690C3 /* NetworkExtension.framework in Frameworks */, 30920DA92C3D53F1008690C3 /* libobscuravpn-client.a in Frameworks */, 30497E742D9ED7DF008B22F9 /* DequeModule in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; 3098C18B2B921489008877AA /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( C90161572BE011B2005B14AF /* libobscuravpn-client.a in Frameworks */, 3098C18F2B921489008877AA /* NetworkExtension.framework in Frameworks */, 30497E762D9ED7E9008B22F9 /* DequeModule in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; A98F1BCC2DADFF17007F04D3 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( A9768FC02DBB02C400A4595F /* OrderedCollections in Frameworks */, A98F1BCE2DADFF17007F04D3 /* DequeModule in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ 30761C152B6EB17100E5F60D = { isa = PBXGroup; children = ( A9963C552DB83E4100D10893 /* Packet Tunnel Provider */, 9649F96A2DA457B2009EFF4F /* videos */, 96516AC22BF928DD00576562 /* build */, C90161A52BE01A8E005B14AF /* Configurations */, C901614E2BE01159005B14AF /* obscuravpn-client.xcodeproj */, 30761C202B6EB17100E5F60D /* client */, 3098C1902B921489008877AA /* system-network-extension */, 30EF74BF2BFFC2A90095439F /* shared */, 30920D952C3D51EC008690C3 /* app-network-extension */, 35A6F2502C611341004E1A7C /* third-party */, 30761C352B6EB1F900E5F60D /* Frameworks */, 30761C1F2B6EB17100E5F60D /* Products */, ); sourceTree = ""; }; 30761C1F2B6EB17100E5F60D /* Products */ = { isa = PBXGroup; children = ( 30761C1E2B6EB17100E5F60D /* Obscura VPN (Debug).app */, 3098C18E2B921489008877AA /* net.obscura.vpn-client-app.system-network-extension.systemextension */, 30920D932C3D51EC008690C3 /* App Network Extension.appex */, 30497E5E2D9EC038008B22F9 /* Tests.xctest */, A98F1BD82DADFF17007F04D3 /* Obscura VPN iOS.app */, ); name = Products; sourceTree = ""; }; 30761C202B6EB17100E5F60D /* client */ = { isa = PBXGroup; children = ( A9E44A752E093D3F006B4616 /* Store */, A983D8002DF276DF007306C5 /* Webviews */, A94743F62DD170E8002ACD85 /* iOS */, A936D4972DD161630031B646 /* UXKit */, A99871102DBAF8080044D136 /* Style */, A998710D2DBAF7FE0044D136 /* macOS */, 9649F9582DA4233F009EFF4F /* LoopingVideoPlayer.swift */, A9BCB0602DC54B68006C0133 /* UpdaterDriver+XP.swift */, 966967C02D440B430019AF9F /* LoginItem.swift */, A936D4922DD15F6A0031B646 /* StartupStatus.swift */, 3096BFF72CECD4BA003D062E /* extensions */, 9623252A2C8BACBC008A9B76 /* StatusItem */, 35230C272C775FF1007ECFEC /* Notifications.swift */, 30761C2A2B6EB17200E5F60D /* client.entitlements */, C92778072C24FCE40058BBFB /* Info.plist */, 30761C252B6EB17200E5F60D /* Assets.xcassets */, 30761C272B6EB17200E5F60D /* Preview Content */, 3096E0452BF0F5860026DE7F /* app_state.swift */, 3096E03F2BEFC6770026DE7F /* command.swift */, 967D0C852C41A1E500FD0767 /* Constants.swift */, 30761C232B6EB17100E5F60D /* ContentView.swift */, A9BCB05D2DC54AC0006C0133 /* DebugBundle.swift */, 35A6F2512C611DD1004E1A7C /* DebugBundle+XP.swift */, 35F8DE862C6666C40016CEEB /* DebugBundleExtensionInfo.swift */, 30920D782C2057B1008690C3 /* initNetworkExtension.swift */, 35F8DE7E2C6559D20016CEEB /* OSLogEntryEncodable.swift */, 3096E0212BDC1FFB0026DE7F /* ScriptMessageHandlers.swift */, 30920D742C1F71BB008690C3 /* startup.swift */, 30920D7C2C207379008690C3 /* TunnelProvider.swift */, 30C5320B2CC959AE00936E1F /* OsStatus.swift */, ); path = client; sourceTree = ""; }; 30761C272B6EB17200E5F60D /* Preview Content */ = { isa = PBXGroup; children = ( 30761C282B6EB17200E5F60D /* Preview Assets.xcassets */, ); path = "Preview Content"; sourceTree = ""; }; 30761C352B6EB1F900E5F60D /* Frameworks */ = { isa = PBXGroup; children = ( 9649F9722DA56142009EFF4F /* AVKit.framework */, 30761C362B6EB1F900E5F60D /* NetworkExtension.framework */, ); name = Frameworks; sourceTree = ""; }; 30920D952C3D51EC008690C3 /* app-network-extension */ = { isa = PBXGroup; children = ( 30920D982C3D51EC008690C3 /* Info.plist */, 30920D992C3D51EC008690C3 /* entitlements.entitlements */, ); path = "app-network-extension"; sourceTree = ""; }; 3096BFF72CECD4BA003D062E /* extensions */ = { isa = PBXGroup; children = ( 3096BFF82CECD50F003D062E /* NEVPNStatus.swift */, ); path = extensions; sourceTree = ""; }; 3098C1902B921489008877AA /* system-network-extension */ = { isa = PBXGroup; children = ( 3098C1952B921489008877AA /* Info.plist */, 3098C1962B921489008877AA /* entitlements.entitlements */, ); path = "system-network-extension"; sourceTree = ""; }; 30EF74BF2BFFC2A90095439F /* shared */ = { isa = PBXGroup; children = ( 1799341E2E9411770089B6EB /* NotificationIds.swift */, 30497E6D2D9ED57F008B22F9 /* Box.swift */, 968B02B82CFF5B7B0053D0EF /* Account.swift */, A94BF4452E53BBE9007FDD1C /* Account+CustomStringConvertible.swift */, 96C920682CD91D94002C85EA /* String.swift */, 35230C182C764F0D007ECFEC /* Swift.swift */, 35230C152C764B97007ECFEC /* Concurrency.swift */, 30497E652D9EC09E008B22F9 /* ConcurrencyTests.swift */, 35219C3A2C6BD57F00E63BB8 /* Debug.swift */, 35A6F2532C6121C2004E1A7C /* time.swift */, 352D58DF2C4AE796002F3404 /* ObservableValue.swift */, 30EF74C02BFFE48C0095439F /* FfiCb.swift */, 30EF74CC2C02244C0095439F /* NetworkExtensionIpc.swift */, 30920DAC2C3DC174008690C3 /* InfoDict.swift */, 309BA9122C45DFC8000A7428 /* Sleep.swift */, 300452772C49BC90000B78F7 /* Json.swift */, 309B467C2C4C152900A1B00F /* StringError.swift */, 30C532092CC9558000936E1F /* WatchableValue.swift */, A94B1DCC2E28B13E00E5F325 /* AccountInfo+Util.swift */, ); path = shared; sourceTree = ""; }; 35A6F2502C611341004E1A7C /* third-party */ = { isa = PBXGroup; children = ( 96615E282C598A9600120DEF /* CwlSysctl.swift */, ); path = "third-party"; sourceTree = ""; }; 9623252A2C8BACBC008A9B76 /* StatusItem */ = { isa = PBXGroup; children = ( 9632E4632D19C5EA00BC8E3F /* AccountStatusItem.swift */, 962325172C875B3E008A9B76 /* StatusMenu.swift */, 962325222C8A3B58008A9B76 /* MenuItemView.swift */, 962325202C88D4E8008A9B76 /* ObscuraToggle.swift */, 96C9205A2CD549B1002C85EA /* BandwidthStatus.swift */, ); path = StatusItem; sourceTree = ""; }; A936D4972DD161630031B646 /* UXKit */ = { isa = PBXGroup; children = ( A936D4982DD1617F0031B646 /* UXImage.swift */, A936D4422DD149290031B646 /* UXViewController.swift */, A936D4432DD149290031B646 /* UXViewRepresentable.swift */, ); path = UXKit; sourceTree = ""; }; A94743F62DD170E8002ACD85 /* iOS */ = { isa = PBXGroup; children = ( A94743F72DD170F5002ACD85 /* iOSClientApp.swift */, 1D9F50072E6280FF001C080B /* MailDelegate.swift */, ); path = iOS; sourceTree = ""; }; A983D8002DF276DF007306C5 /* Webviews */ = { isa = PBXGroup; children = ( A983D7DF2DF25435007306C5 /* WebviewsController.swift */, A9029C182DEEDCBC00AAD761 /* ObscuraUIMacOSWrapper.swift */, A95A91322DF9910C005CB52A /* ObscuraUIIOSWrapperAndTabs.swift */, A9200A842DD1251C00FD035C /* ObscuraUIWebView.swift */, A983D7E22DF261BB007306C5 /* ExternalWebView.swift */, ); path = Webviews; sourceTree = ""; }; A9963C552DB83E4100D10893 /* Packet Tunnel Provider */ = { isa = PBXGroup; children = ( 309BA90B2C446125000A7428 /* NetworkSettings.swift */, 17B910012E0DB3E00073AAD7 /* Keychain.swift */, 309BA9072C44394E000A7428 /* RustFfi.swift */, 3098C1912B921489008877AA /* PacketTunnelProvider.swift */, 3098C1932B921489008877AA /* main.swift */, ); path = "Packet Tunnel Provider"; sourceTree = ""; }; A998710D2DBAF7FE0044D136 /* macOS */ = { isa = PBXGroup; children = ( 30761C212B6EB17100E5F60D /* ClientApp.swift */, A936D49B2DD162AB0031B646 /* InstallSystemExtensionView.swift */, A936D4952DD160750031B646 /* UpdateSystemExtensionView.swift */, A9768FAE2DBB01C400A4595F /* CheckForUpdatesView.swift */, A998710C2DBAF7FE0044D136 /* RegisterLoginItemView.swift */, A9768FAC2DBB01AD00A4595F /* UpdaterDriver.swift */, A9768FB32DBB01EE00A4595F /* SparkleUpdater.swift */, ); path = macOS; sourceTree = ""; }; A99871102DBAF8080044D136 /* Style */ = { isa = PBXGroup; children = ( 96DB3D532E60F01B005B4D1B /* Appearance.swift */, A94B1D9B2E286B3900E5F325 /* ConditionallyDisabled.swift */, A998710F2DBAF8080044D136 /* NoFadeButtonStyle.swift */, A9758FDD2E41910300741928 /* HyperlinkButtonStyle.swift */, ); path = Style; sourceTree = ""; }; A9E44A752E093D3F006B4616 /* Store */ = { isa = PBXGroup; children = ( 1DAD5D652ED8FAA100DDB469 /* StoreKitModel.swift */, 1DAD5D612ED7F27500DDB469 /* StoreKitListener.swift */, A94B1DD02E28B16800E5F325 /* Product+Convenience.swift */, A9E44A7B2E0947EA006B4616 /* Product+Convenience.swift */, A94B1E3C2E2B236800E5F325 /* Obscura VPN Local.storekit */, A9E44A762E093DA7006B4616 /* Obscura VPN.storekit */, ); path = Store; sourceTree = ""; }; C901614F2BE01159005B14AF /* Products */ = { isa = PBXGroup; children = ( C90161542BE01159005B14AF /* libobscuravpn-client.a */, C90161562BE01159005B14AF /* obscuravpn-client */, ); name = Products; sourceTree = ""; }; C90161A52BE01A8E005B14AF /* Configurations */ = { isa = PBXGroup; children = ( C96FFC962C3DD5DC00D87937 /* app-network-extension.xcconfig */, C96FFC952C3DD5DC00D87937 /* Debug-app-network-extension.xcconfig */, C96FFC922C3DD5DC00D87937 /* Release-app-network-extension.xcconfig */, A06E85AA2C2ECA680087C8C8 /* buildversion.xcconfig */, C96FFC8F2C3DD2FD00D87937 /* Debug-system-network-extension.xcconfig */, C96FFC902C3DD2FD00D87937 /* Release-system-network-extension.xcconfig */, C96FFC8C2C3DD2FD00D87937 /* system-network-extension.xcconfig */, A06E85A62C2ECA680087C8C8 /* bundle-ids.xcconfig */, C90161A82BE01A8E005B14AF /* Base.xcconfig */, A06E85A92C2ECA680087C8C8 /* app.xcconfig */, C90161A62BE01A8E005B14AF /* Debug.xcconfig */, C90161A72BE01A8E005B14AF /* Release.xcconfig */, C92777D22C248A740058BBFB /* Debug-app.xcconfig */, C9D486352C123078007D5F2F /* Release-app.xcconfig */, ); path = Configurations; sourceTree = ""; }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ 30497E5D2D9EC038008B22F9 /* Tests */ = { isa = PBXNativeTarget; buildConfigurationList = 30497E622D9EC038008B22F9 /* Build configuration list for PBXNativeTarget "Tests" */; buildPhases = ( 30497E5A2D9EC038008B22F9 /* Sources */, 30497E5B2D9EC038008B22F9 /* Frameworks */, 30497E5C2D9EC038008B22F9 /* Resources */, ); buildRules = ( ); dependencies = ( ); name = Tests; packageProductDependencies = ( 30497E712D9ED7D4008B22F9 /* DequeModule */, ); productName = Tests; productReference = 30497E5E2D9EC038008B22F9 /* Tests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; 30761C1D2B6EB17100E5F60D /* Obscura VPN */ = { isa = PBXNativeTarget; buildConfigurationList = 30761C2D2B6EB17200E5F60D /* Build configuration list for PBXNativeTarget "Obscura VPN" */; buildPhases = ( 30761C1A2B6EB17100E5F60D /* Sources */, 30761C1B2B6EB17100E5F60D /* Frameworks */, 30761C1C2B6EB17100E5F60D /* Resources */, 9649F96E2DA45808009EFF4F /* Copy Files */, 3098C1862B9211B1008877AA /* Embed System Extensions */, ); buildRules = ( ); dependencies = ( 3098C1982B921489008877AA /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 9649F96A2DA457B2009EFF4F /* videos */, ); name = "Obscura VPN"; packageProductDependencies = ( A082F8222C46BF5B002AF810 /* Sparkle */, 30497E772D9ED7EE008B22F9 /* DequeModule */, A9768FC12DBB02C900A4595F /* OrderedCollections */, ); productName = client; productReference = 30761C1E2B6EB17100E5F60D /* Obscura VPN (Debug).app */; productType = "com.apple.product-type.application"; }; 30920D922C3D51EC008690C3 /* App Network Extension */ = { isa = PBXNativeTarget; buildConfigurationList = 30920DA22C3D51EC008690C3 /* Build configuration list for PBXNativeTarget "App Network Extension" */; buildPhases = ( 30920D8F2C3D51EC008690C3 /* Sources */, 30920D902C3D51EC008690C3 /* Frameworks */, 30920D912C3D51EC008690C3 /* Resources */, ); buildRules = ( ); dependencies = ( ); name = "App Network Extension"; productName = "app-network-extension"; productReference = 30920D932C3D51EC008690C3 /* App Network Extension.appex */; productType = "com.apple.product-type.app-extension"; }; 3098C18D2B921489008877AA /* System Network Extension */ = { isa = PBXNativeTarget; buildConfigurationList = 3098C19A2B921489008877AA /* Build configuration list for PBXNativeTarget "System Network Extension" */; buildPhases = ( 3098C18A2B921489008877AA /* Sources */, 3098C18B2B921489008877AA /* Frameworks */, 3098C18C2B921489008877AA /* Resources */, ); buildRules = ( ); dependencies = ( ); name = "System Network Extension"; productName = "system-network-extension"; productReference = 3098C18E2B921489008877AA /* net.obscura.vpn-client-app.system-network-extension.systemextension */; productType = "com.apple.product-type.system-extension"; }; A98F1B9D2DADFF17007F04D3 /* Obscura VPN iOS */ = { isa = PBXNativeTarget; buildConfigurationList = A98F1BD52DADFF17007F04D3 /* Build configuration list for PBXNativeTarget "Obscura VPN iOS" */; buildPhases = ( A98F1BA42DADFF17007F04D3 /* Sources */, A98F1BCC2DADFF17007F04D3 /* Frameworks */, A98F1BCF2DADFF17007F04D3 /* Resources */, A98F1BDD2DAE00A0007F04D3 /* Embed Foundation Extensions */, ); buildRules = ( ); dependencies = ( A98F1BDC2DAE00A0007F04D3 /* PBXTargetDependency */, A98F1BDF2DAE00A5007F04D3 /* PBXTargetDependency */, ); name = "Obscura VPN iOS"; packageProductDependencies = ( A98F1BA22DADFF17007F04D3 /* DequeModule */, A9768FBF2DBB02C400A4595F /* OrderedCollections */, ); productName = client; productReference = A98F1BD82DADFF17007F04D3 /* Obscura VPN iOS.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ 30761C162B6EB17100E5F60D /* Project object */ = { isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1620; LastUpgradeCheck = 1520; TargetAttributes = { 30497E5D2D9EC038008B22F9 = { CreatedOnToolsVersion = 16.2; }; 30761C1D2B6EB17100E5F60D = { CreatedOnToolsVersion = 15.2; }; 30920D922C3D51EC008690C3 = { CreatedOnToolsVersion = 15.2; }; 3098C18D2B921489008877AA = { CreatedOnToolsVersion = 15.2; }; }; }; buildConfigurationList = 30761C192B6EB17100E5F60D /* Build configuration list for PBXProject "client" */; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( en, Base, ); mainGroup = 30761C152B6EB17100E5F60D; packageReferences = ( A082F8212C46BF5B002AF810 /* XCRemoteSwiftPackageReference "Sparkle" */, 30497E6C2D9ED476008B22F9 /* XCRemoteSwiftPackageReference "swift-collections" */, ); preferredProjectObjectVersion = 56; productRefGroup = 30761C1F2B6EB17100E5F60D /* Products */; projectDirPath = ""; projectReferences = ( { ProductGroup = C901614F2BE01159005B14AF /* Products */; ProjectRef = C901614E2BE01159005B14AF /* obscuravpn-client.xcodeproj */; }, ); projectRoot = ""; targets = ( 30761C1D2B6EB17100E5F60D /* Obscura VPN */, 3098C18D2B921489008877AA /* System Network Extension */, 30920D922C3D51EC008690C3 /* App Network Extension */, 30497E5D2D9EC038008B22F9 /* Tests */, A98F1B9D2DADFF17007F04D3 /* Obscura VPN iOS */, ); }; /* End PBXProject section */ /* Begin PBXReferenceProxy section */ C90161542BE01159005B14AF /* libobscuravpn-client.a */ = { isa = PBXReferenceProxy; fileType = archive.ar; path = "libobscuravpn-client.a"; remoteRef = C90161532BE01159005B14AF /* PBXContainerItemProxy */; sourceTree = BUILT_PRODUCTS_DIR; }; C90161562BE01159005B14AF /* obscuravpn-client */ = { isa = PBXReferenceProxy; fileType = "compiled.mach-o.executable"; path = "obscuravpn-client"; remoteRef = C90161552BE01159005B14AF /* PBXContainerItemProxy */; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXReferenceProxy section */ /* Begin PBXResourcesBuildPhase section */ 30497E5C2D9EC038008B22F9 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; 30761C1C2B6EB17100E5F60D /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( 30761C292B6EB17200E5F60D /* Preview Assets.xcassets in Resources */, 96516AC32BF928DD00576562 /* build in Resources */, 30761C262B6EB17200E5F60D /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; 30920D912C3D51EC008690C3 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; 3098C18C2B921489008877AA /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; A98F1BCF2DADFF17007F04D3 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( A94B1E3D2E2B236800E5F325 /* Obscura VPN Local.storekit in Resources */, A98F1BD02DADFF17007F04D3 /* Preview Assets.xcassets in Resources */, A98F1BD12DADFF17007F04D3 /* build in Resources */, A9E44A782E093DA7006B4616 /* Obscura VPN.storekit in Resources */, A98F1BD22DADFF17007F04D3 /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ 30497E5A2D9EC038008B22F9 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( 30497E672D9EC0BA008B22F9 /* Concurrency.swift in Sources */, 30497E702D9ED5AD008B22F9 /* Box.swift in Sources */, 30497E692D9EC16A008B22F9 /* String.swift in Sources */, 30497E6A2D9EC19E008B22F9 /* StringError.swift in Sources */, 30497E662D9EC09E008B22F9 /* ConcurrencyTests.swift in Sources */, 30497E682D9EC129008B22F9 /* Swift.swift in Sources */, 30497E6B2D9EC7AC008B22F9 /* Sleep.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; 30761C1A2B6EB17100E5F60D /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( A9758FDF2E41910300741928 /* HyperlinkButtonStyle.swift in Sources */, 3096E0222BDC1FFB0026DE7F /* ScriptMessageHandlers.swift in Sources */, A983D7E02DF25435007306C5 /* WebviewsController.swift in Sources */, A9200A862DD1251C00FD035C /* ObscuraUIWebView.swift in Sources */, 30EF74C12BFFE48C0095439F /* FfiCb.swift in Sources */, 309B467D2C4C152900A1B00F /* StringError.swift in Sources */, A9768FB42DBB01EE00A4595F /* SparkleUpdater.swift in Sources */, 352D58E02C4AE796002F3404 /* ObservableValue.swift in Sources */, 962325232C8A3B58008A9B76 /* MenuItemView.swift in Sources */, 30C5320C2CC959AE00936E1F /* OsStatus.swift in Sources */, A998710E2DBAF7FE0044D136 /* RegisterLoginItemView.swift in Sources */, 30497E6E2D9ED584008B22F9 /* Box.swift in Sources */, A936D4452DD1492A0031B646 /* UXViewRepresentable.swift in Sources */, A936D4462DD1492A0031B646 /* UXViewController.swift in Sources */, A94B1DCE2E28B13E00E5F325 /* AccountInfo+Util.swift in Sources */, A94BF4462E53BBEF007FDD1C /* Account+CustomStringConvertible.swift in Sources */, 9632E4642D19C5EC00BC8E3F /* AccountStatusItem.swift in Sources */, 30920D752C1F71BB008690C3 /* startup.swift in Sources */, 30920D792C2057B1008690C3 /* initNetworkExtension.swift in Sources */, A99871112DBAF8080044D136 /* NoFadeButtonStyle.swift in Sources */, 30920DAD2C3DC174008690C3 /* InfoDict.swift in Sources */, 96C9205B2CD549B1002C85EA /* BandwidthStatus.swift in Sources */, 966967C12D440B450019AF9F /* LoginItem.swift in Sources */, 35230C162C764B97007ECFEC /* Concurrency.swift in Sources */, 35F8DE7F2C6559D20016CEEB /* OSLogEntryEncodable.swift in Sources */, 96C920692CD91D96002C85EA /* String.swift in Sources */, 35219C3B2C6BD57F00E63BB8 /* Debug.swift in Sources */, 962325182C875B3E008A9B76 /* StatusMenu.swift in Sources */, A9768FAF2DBB01C400A4595F /* CheckForUpdatesView.swift in Sources */, 30761C242B6EB17100E5F60D /* ContentView.swift in Sources */, 35F8DE872C6666C40016CEEB /* DebugBundleExtensionInfo.swift in Sources */, A9768FAD2DBB01AD00A4595F /* UpdaterDriver.swift in Sources */, 35A6F2542C6121C2004E1A7C /* time.swift in Sources */, A936D4932DD15F6A0031B646 /* StartupStatus.swift in Sources */, A9BCB0622DC54B68006C0133 /* UpdaterDriver+XP.swift in Sources */, A983D7E32DF261BB007306C5 /* ExternalWebView.swift in Sources */, A94B1E242E2B03B900E5F325 /* ConditionallyDisabled.swift in Sources */, 300452782C49BC90000B78F7 /* Json.swift in Sources */, 968B02B92CFF5B7B0053D0EF /* Account.swift in Sources */, 35A6F2522C611DD1004E1A7C /* DebugBundle+XP.swift in Sources */, 96615E292C598A9600120DEF /* CwlSysctl.swift in Sources */, 9649F9592DA4233F009EFF4F /* LoopingVideoPlayer.swift in Sources */, 30EF74CD2C02244C0095439F /* NetworkExtensionIpc.swift in Sources */, 35230C282C775FF1007ECFEC /* Notifications.swift in Sources */, 30761C222B6EB17100E5F60D /* ClientApp.swift in Sources */, 967D0C862C41A1E500FD0767 /* Constants.swift in Sources */, A9BCB05E2DC54AC0006C0133 /* DebugBundle.swift in Sources */, 30920D7D2C207379008690C3 /* TunnelProvider.swift in Sources */, 3096E0462BF0F5870026DE7F /* app_state.swift in Sources */, 30C5320A2CC9558000936E1F /* WatchableValue.swift in Sources */, A936D4962DD160750031B646 /* UpdateSystemExtensionView.swift in Sources */, 179934222E94117C0089B6EB /* NotificationIds.swift in Sources */, 3096E0402BEFC6770026DE7F /* command.swift in Sources */, 35230C192C764F0D007ECFEC /* Swift.swift in Sources */, 3096BFF92CECD50F003D062E /* NEVPNStatus.swift in Sources */, A936D4992DD1617F0031B646 /* UXImage.swift in Sources */, 96DB3D542E60F01B005B4D1B /* Appearance.swift in Sources */, 309BA9132C45DFC8000A7428 /* Sleep.swift in Sources */, A9029C192DEEDCBC00AAD761 /* ObscuraUIMacOSWrapper.swift in Sources */, A936D49C2DD162AB0031B646 /* InstallSystemExtensionView.swift in Sources */, 962325212C88D4E8008A9B76 /* ObscuraToggle.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; 30920D8F2C3D51EC008690C3 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( A9963C632DB8401100D10893 /* Sleep.swift in Sources */, A9963C622DB8400D00D10893 /* PacketTunnelProvider.swift in Sources */, A94BF4492E53BBEF007FDD1C /* Account+CustomStringConvertible.swift in Sources */, A9963C612DB8400900D10893 /* Concurrency.swift in Sources */, A9963C602DB8400300D10893 /* WatchableValue.swift in Sources */, A9963C5F2DB83FFD00D10893 /* NetworkExtensionIpc.swift in Sources */, 17B910032E0DB3E50073AAD7 /* Keychain.swift in Sources */, A9963C5E2DB83FF700D10893 /* RustFfi.swift in Sources */, A9963C5D2DB83FF300D10893 /* StringError.swift in Sources */, A9963C5C2DB83FD600D10893 /* Swift.swift in Sources */, A9963C5B2DB83FD200D10893 /* Json.swift in Sources */, A9963C5A2DB83FCF00D10893 /* NetworkSettings.swift in Sources */, 179934202E94117C0089B6EB /* NotificationIds.swift in Sources */, A9963C592DB83FCB00D10893 /* Debug.swift in Sources */, A9963C582DB83FC600D10893 /* Box.swift in Sources */, A9963C572DB83FC200D10893 /* Account.swift in Sources */, A9963C562DB83FBE00D10893 /* FfiCb.swift in Sources */, 30920DAF2C3DC174008690C3 /* InfoDict.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; 3098C18A2B921489008877AA /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( 35A6F2552C613366004E1A7C /* time.swift in Sources */, 30EF74CE2C02244C0095439F /* NetworkExtensionIpc.swift in Sources */, 1799341F2E94117C0089B6EB /* NotificationIds.swift in Sources */, 30497E6F2D9ED584008B22F9 /* Box.swift in Sources */, 309BA9142C45DFC8000A7428 /* Sleep.swift in Sources */, 35230C1A2C764F0D007ECFEC /* Swift.swift in Sources */, 352D58E12C4AE796002F3404 /* ObservableValue.swift in Sources */, 30920DAE2C3DC174008690C3 /* InfoDict.swift in Sources */, A94BF4482E53BBEF007FDD1C /* Account+CustomStringConvertible.swift in Sources */, 35219C3C2C6BD57F00E63BB8 /* Debug.swift in Sources */, 3098C1942B921489008877AA /* main.swift in Sources */, 17B910022E0DB3E50073AAD7 /* Keychain.swift in Sources */, 35230C172C764B97007ECFEC /* Concurrency.swift in Sources */, 309BFE3F2D9C169500366431 /* WatchableValue.swift in Sources */, 309B467E2C4C152900A1B00F /* StringError.swift in Sources */, 309BA90C2C446125000A7428 /* NetworkSettings.swift in Sources */, 300452792C49BC90000B78F7 /* Json.swift in Sources */, 968B02BA2CFF5B7B0053D0EF /* Account.swift in Sources */, 309BA90A2C443978000A7428 /* RustFfi.swift in Sources */, 30EF74C22BFFE86B0095439F /* FfiCb.swift in Sources */, 96C9206A2CD91D96002C85EA /* String.swift in Sources */, 3098C1922B921489008877AA /* PacketTunnelProvider.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; A98F1BA42DADFF17007F04D3 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( 1DB5666B2EA565E7009CEB08 /* String.swift in Sources */, 1D9F4FE02E5BCDED001C080B /* time.swift in Sources */, 1D9F4FDE2E5BC912001C080B /* DebugBundle.swift in Sources */, A94743F92DD17143002ACD85 /* iOSClientApp.swift in Sources */, A936D49A2DD161B00031B646 /* UXImage.swift in Sources */, A936D4902DD15B6B0031B646 /* startup.swift in Sources */, A936D4732DD14E470031B646 /* command.swift in Sources */, 1DAD5D662ED8FAA100DDB469 /* StoreKitModel.swift in Sources */, A936D4722DD14E040031B646 /* ScriptMessageHandlers.swift in Sources */, A94BF4472E53BBEF007FDD1C /* Account+CustomStringConvertible.swift in Sources */, A936D4712DD14DC30031B646 /* ObscuraUIWebView.swift in Sources */, A9200A872DD127A200FD035C /* Notifications.swift in Sources */, A9758FDE2E41910300741928 /* HyperlinkButtonStyle.swift in Sources */, A9768FBE2DBB02BC00A4595F /* ContentView.swift in Sources */, A99871292DBAF90B0044D136 /* Debug.swift in Sources */, A99871282DBAF8FB0044D136 /* DebugBundle+XP.swift in Sources */, A99871272DBAF8A60044D136 /* TunnelProvider.swift in Sources */, A99871262DBAF89E0044D136 /* NetworkExtensionIpc.swift in Sources */, A99871252DBAF8990044D136 /* InfoDict.swift in Sources */, A9E44A7C2E0947EA006B4616 /* Product+Convenience.swift in Sources */, A99871242DBAF8940044D136 /* Account.swift in Sources */, A94B1D9C2E286B3900E5F325 /* ConditionallyDisabled.swift in Sources */, A99871232DBAF88F0044D136 /* NEVPNStatus.swift in Sources */, 96DB3D552E60F01B005B4D1B /* Appearance.swift in Sources */, A99871222DBAF8890044D136 /* OSLogEntryEncodable.swift in Sources */, A99871212DBAF8850044D136 /* Json.swift in Sources */, A99871202DBAF8810044D136 /* StringError.swift in Sources */, A983D7E12DF25435007306C5 /* WebviewsController.swift in Sources */, A998711F2DBAF87D0044D136 /* Sleep.swift in Sources */, A983D7E42DF261BB007306C5 /* ExternalWebView.swift in Sources */, A936D4942DD15F6A0031B646 /* StartupStatus.swift in Sources */, A95A91332DF9910C005CB52A /* ObscuraUIIOSWrapperAndTabs.swift in Sources */, A998711E2DBAF8790044D136 /* Concurrency.swift in Sources */, A998711D2DBAF8740044D136 /* Box.swift in Sources */, 179934212E94117C0089B6EB /* NotificationIds.swift in Sources */, A94B1DD42E28B16800E5F325 /* Product+Convenience.swift in Sources */, A9BCB0612DC54B68006C0133 /* UpdaterDriver+XP.swift in Sources */, 1D9F50082E6280FF001C080B /* MailDelegate.swift in Sources */, A998711C2DBAF8700044D136 /* Swift.swift in Sources */, A936D4472DD1492A0031B646 /* UXViewRepresentable.swift in Sources */, A936D4482DD1492A0031B646 /* UXViewController.swift in Sources */, A998711B2DBAF8680044D136 /* FfiCb.swift in Sources */, A998711A2DBAF8610044D136 /* Constants.swift in Sources */, A99871192DBAF85C0044D136 /* ObservableValue.swift in Sources */, A99871182DBAF8590044D136 /* WatchableValue.swift in Sources */, A94B1DCD2E28B13E00E5F325 /* AccountInfo+Util.swift in Sources */, A99871162DBAF8510044D136 /* OsStatus.swift in Sources */, A99871152DBAF84D0044D136 /* app_state.swift in Sources */, A99871122DBAF8080044D136 /* NoFadeButtonStyle.swift in Sources */, 1DAD5D622ED7F27800DDB469 /* StoreKitListener.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ 3098C1982B921489008877AA /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 3098C18D2B921489008877AA /* System Network Extension */; targetProxy = 3098C1972B921489008877AA /* PBXContainerItemProxy */; }; A98F1BDC2DAE00A0007F04D3 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 30920D922C3D51EC008690C3 /* App Network Extension */; targetProxy = A98F1BDB2DAE00A0007F04D3 /* PBXContainerItemProxy */; }; A98F1BDF2DAE00A5007F04D3 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 30920D922C3D51EC008690C3 /* App Network Extension */; targetProxy = A98F1BDE2DAE00A5007F04D3 /* PBXContainerItemProxy */; }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ 30497E632D9EC038008B22F9 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 5G943LR562; GCC_PREPROCESSOR_DEFINITIONS = ( "DEBUG=1", "$(inherited)", ); GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 15.2; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = net.obscura.Tests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; }; name = Debug; }; 30497E642D9EC038008B22F9 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 5G943LR562; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 15.2; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = net.obscura.Tests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; }; name = Release; }; 30761C2C2B6EB17200E5F60D /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_EMPTY_BODY = YES; CLANG_WARN_ENUM_CONVERSION = YES; CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SWIFT_COMPILATION_MODE = wholemodule; }; name = Release; }; 30761C2F2B6EB17200E5F60D /* Release */ = { isa = XCBuildConfiguration; baseConfigurationReference = C9D486352C123078007D5F2F /* Release-app.xcconfig */; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = "client/client-macos.entitlements"; COMBINE_HIDPI_IMAGES = YES; DEVELOPMENT_ASSET_PATHS = "\"client/Preview Content\""; ENABLE_PREVIEWS = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); OBSCURA_MAGIC_NO_NIX = ""; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = macosx; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; }; name = Release; }; 30920D9E2C3D51EC008690C3 /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = C96FFC952C3DD5DC00D87937 /* Debug-app-network-extension.xcconfig */; buildSettings = { INFOPLIST_KEY_NSHumanReadableCopyright = ""; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", "@executable_path/../../../../Frameworks", ); PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; }; 30920D9F2C3D51EC008690C3 /* Release */ = { isa = XCBuildConfiguration; baseConfigurationReference = C96FFC922C3DD5DC00D87937 /* Release-app-network-extension.xcconfig */; buildSettings = { CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = 5G943LR562; INFOPLIST_KEY_NSHumanReadableCopyright = ""; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", "@executable_path/../../../../Frameworks", ); PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = iphoneos; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; }; 3098C19C2B921489008877AA /* Release */ = { isa = XCBuildConfiguration; baseConfigurationReference = C96FFC902C3DD2FD00D87937 /* Release-system-network-extension.xcconfig */; buildSettings = { INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSSystemExtensionUsageDescription = ""; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", "@executable_path/../../../../Frameworks", ); LIBRARY_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)/libobscuravpn_client", ); PRODUCT_NAME = "$(inherited)"; SDKROOT = macosx; SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; }; name = Release; }; 964304952BEEF6D000B3119B /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_EMPTY_BODY = YES; CLANG_WARN_ENUM_CONVERSION = YES; CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; GCC_OPTIMIZATION_LEVEL = 0; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; name = Debug; }; 964304962BEEF6D000B3119B /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = C92777D22C248A740058BBFB /* Debug-app.xcconfig */; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = "client/client-macos.entitlements"; COMBINE_HIDPI_IMAGES = YES; DEVELOPMENT_ASSET_PATHS = "\"client/Preview Content\""; DEVELOPMENT_TEAM = 5G943LR562; ENABLE_PREVIEWS = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); OBSCURA_MAGIC_NO_NIX = ""; PRODUCT_NAME = "$(TARGET_NAME) (Debug)"; SDKROOT = macosx; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG LOAD_DEV_SERVER"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; }; name = Debug; }; 964304972BEEF6D000B3119B /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = C96FFC8F2C3DD2FD00D87937 /* Debug-system-network-extension.xcconfig */; buildSettings = { INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSSystemExtensionUsageDescription = ""; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", "@executable_path/../../../../Frameworks", ); LIBRARY_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)/libobscuravpn_client", ); PRODUCT_NAME = "$(inherited)"; SDKROOT = macosx; SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; }; name = Debug; }; A98F1BD62DADFF17007F04D3 /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = C92777D22C248A740058BBFB /* Debug-app.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = "client/client-ios.entitlements"; COMBINE_HIDPI_IMAGES = YES; DEVELOPMENT_ASSET_PATHS = "\"client/Preview Content\""; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = client/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "Obscura VPN"; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UIRequiresFullScreen = NO; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = UIInterfaceOrientationPortrait; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); OBSCURA_MAGIC_NO_NIX = ""; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; }; name = Debug; }; A98F1BD72DADFF17007F04D3 /* Release */ = { isa = XCBuildConfiguration; baseConfigurationReference = C9D486352C123078007D5F2F /* Release-app.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = "client/client-ios.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; DEVELOPMENT_ASSET_PATHS = "\"client/Preview Content\""; DEVELOPMENT_TEAM = 5G943LR562; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = client/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "Obscura VPN"; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UIRequiresFullScreen = NO; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = UIInterfaceOrientationPortrait; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); OBSCURA_MAGIC_NO_NIX = ""; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; }; name = Release; }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ 30497E622D9EC038008B22F9 /* Build configuration list for PBXNativeTarget "Tests" */ = { isa = XCConfigurationList; buildConfigurations = ( 30497E632D9EC038008B22F9 /* Debug */, 30497E642D9EC038008B22F9 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; 30761C192B6EB17100E5F60D /* Build configuration list for PBXProject "client" */ = { isa = XCConfigurationList; buildConfigurations = ( 964304952BEEF6D000B3119B /* Debug */, 30761C2C2B6EB17200E5F60D /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; 30761C2D2B6EB17200E5F60D /* Build configuration list for PBXNativeTarget "Obscura VPN" */ = { isa = XCConfigurationList; buildConfigurations = ( 964304962BEEF6D000B3119B /* Debug */, 30761C2F2B6EB17200E5F60D /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; 30920DA22C3D51EC008690C3 /* Build configuration list for PBXNativeTarget "App Network Extension" */ = { isa = XCConfigurationList; buildConfigurations = ( 30920D9E2C3D51EC008690C3 /* Debug */, 30920D9F2C3D51EC008690C3 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; 3098C19A2B921489008877AA /* Build configuration list for PBXNativeTarget "System Network Extension" */ = { isa = XCConfigurationList; buildConfigurations = ( 964304972BEEF6D000B3119B /* Debug */, 3098C19C2B921489008877AA /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; A98F1BD52DADFF17007F04D3 /* Build configuration list for PBXNativeTarget "Obscura VPN iOS" */ = { isa = XCConfigurationList; buildConfigurations = ( A98F1BD62DADFF17007F04D3 /* Debug */, A98F1BD72DADFF17007F04D3 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ 30497E6C2D9ED476008B22F9 /* XCRemoteSwiftPackageReference "swift-collections" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/apple/swift-collections.git"; requirement = { kind = upToNextMajorVersion; minimumVersion = 1.1.4; }; }; A082F8212C46BF5B002AF810 /* XCRemoteSwiftPackageReference "Sparkle" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/sparkle-project/Sparkle"; requirement = { kind = upToNextMajorVersion; minimumVersion = 2.6.4; }; }; A98F1BA32DADFF17007F04D3 /* XCRemoteSwiftPackageReference "swift-collections" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/apple/swift-collections.git"; requirement = { kind = upToNextMajorVersion; minimumVersion = 1.1.4; }; }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ 30497E712D9ED7D4008B22F9 /* DequeModule */ = { isa = XCSwiftPackageProductDependency; package = 30497E6C2D9ED476008B22F9 /* XCRemoteSwiftPackageReference "swift-collections" */; productName = DequeModule; }; 30497E732D9ED7DF008B22F9 /* DequeModule */ = { isa = XCSwiftPackageProductDependency; package = 30497E6C2D9ED476008B22F9 /* XCRemoteSwiftPackageReference "swift-collections" */; productName = DequeModule; }; 30497E752D9ED7E9008B22F9 /* DequeModule */ = { isa = XCSwiftPackageProductDependency; package = 30497E6C2D9ED476008B22F9 /* XCRemoteSwiftPackageReference "swift-collections" */; productName = DequeModule; }; 30497E772D9ED7EE008B22F9 /* DequeModule */ = { isa = XCSwiftPackageProductDependency; package = 30497E6C2D9ED476008B22F9 /* XCRemoteSwiftPackageReference "swift-collections" */; productName = DequeModule; }; A082F8222C46BF5B002AF810 /* Sparkle */ = { isa = XCSwiftPackageProductDependency; package = A082F8212C46BF5B002AF810 /* XCRemoteSwiftPackageReference "Sparkle" */; productName = Sparkle; }; A9768FBF2DBB02C400A4595F /* OrderedCollections */ = { isa = XCSwiftPackageProductDependency; package = 30497E6C2D9ED476008B22F9 /* XCRemoteSwiftPackageReference "swift-collections" */; productName = OrderedCollections; }; A9768FC12DBB02C900A4595F /* OrderedCollections */ = { isa = XCSwiftPackageProductDependency; package = 30497E6C2D9ED476008B22F9 /* XCRemoteSwiftPackageReference "swift-collections" */; productName = OrderedCollections; }; A98F1BA22DADFF17007F04D3 /* DequeModule */ = { isa = XCSwiftPackageProductDependency; package = A98F1BA32DADFF17007F04D3 /* XCRemoteSwiftPackageReference "swift-collections" */; productName = DequeModule; }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 30761C162B6EB17100E5F60D /* Project object */; } ================================================ FILE: apple/client.xcodeproj/project.xcworkspace/contents.xcworkspacedata ================================================ ================================================ FILE: apple/client.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist ================================================ IDEDidComputeMac32BitWarning ================================================ FILE: apple/client.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings ================================================ ================================================ FILE: apple/client.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved ================================================ { "originHash" : "840c50f4b7a782fbbd66f4b517616cd6023cbf8b74ecbd0cd20faecf3b23df79", "pins" : [ { "identity" : "sparkle", "kind" : "remoteSourceControl", "location" : "https://github.com/sparkle-project/Sparkle", "state" : { "revision" : "0ef1ee0220239b3776f433314515fd849025673f", "version" : "2.6.4" } }, { "identity" : "swift-collections", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections.git", "state" : { "revision" : "671108c96644956dddcd89dd59c203dcdb36cec7", "version" : "1.1.4" } } ], "version" : 3 } ================================================ FILE: apple/client.xcodeproj/xcshareddata/xcschemes/App Network Extension.xcscheme ================================================ ================================================ FILE: apple/client.xcodeproj/xcshareddata/xcschemes/Dev Client.xcscheme ================================================ ================================================ FILE: apple/client.xcodeproj/xcshareddata/xcschemes/Obscura VPN iOS.xcscheme ================================================ ================================================ FILE: apple/client.xcodeproj/xcshareddata/xcschemes/Prod Client.xcscheme ================================================ ================================================ FILE: apple/client.xcodeproj/xcshareddata/xcschemes/System Network Extension.xcscheme ================================================ ================================================ FILE: apple/libobscuravpn_client/.gitignore ================================================ libobscuravpn_client.a ================================================ FILE: apple/obscuravpn-client.xcodeproj/project.pbxproj ================================================ // !$*UTF8*$! { archiveVersion = 1; classes = { }; objectVersion = 54; objects = { /* Begin PBXBuildFile section */ CA016413314BAAAB3AB4761B /* Cargo.toml in Sources */ = {isa = PBXBuildFile; fileRef = CAF9DEE597793EF4668187A5 /* Cargo.toml */; settings = {COMPILER_FLAGS = "--bin 'obscuravpn-client'"; }; }; /* End PBXBuildFile section */ /* Begin PBXBuildRule section */ CAF4DEE59779AC6C1400ACA8 /* PBXBuildRule */ = { isa = PBXBuildRule; compilerSpec = com.apple.compilers.proxy.script; dependencyFile = "$(DERIVED_FILE_DIR)/$(ARCHS)-$(EXECUTABLE_NAME).d"; filePatterns = "*/Cargo.toml"; fileType = pattern.proxy; inputFiles = ( "$(SRCROOT)/xcodescripts/cargo-build-static-lib.bash", ); isEditable = 0; name = "Cargo project build"; outputFiles = ( "$(TARGET_BUILD_DIR)/$(EXECUTABLE_NAME)", "$(TARGET_BUILD_DIR)/$(PUBLIC_HEADERS_FOLDER_PATH)/$(EXECUTABLE_NAME).h", "$(TARGET_BUILD_DIR)/$(PUBLIC_HEADERS_FOLDER_PATH)/module.modulemap", ); runOncePerArchitecture = 0; script = "exec \"${SCRIPT_INPUT_FILE_0}\"\n"; }; /* End PBXBuildRule section */ /* Begin PBXFileReference section */ C90161AC2BE01A9B005B14AF /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; C90161AD2BE01A9B005B14AF /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; C90161AE2BE01A9B005B14AF /* Base.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Base.xcconfig; sourceTree = ""; }; CA0090E2379FFD96F1473BE9 /* libobscuravpn-client.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libobscuravpn-client.a"; sourceTree = BUILT_PRODUCTS_DIR; }; CA01272F0B60B8156098F4D0 /* obscuravpn-client */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = "obscuravpn-client"; sourceTree = BUILT_PRODUCTS_DIR; }; CAF9DEE597793EF4668187A5 /* Cargo.toml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; name = Cargo.toml; path = ../rustlib/Cargo.toml; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXGroup section */ C90161AB2BE01A9B005B14AF /* Configurations */ = { isa = PBXGroup; children = ( C90161AE2BE01A9B005B14AF /* Base.xcconfig */, C90161AC2BE01A9B005B14AF /* Debug.xcconfig */, C90161AD2BE01A9B005B14AF /* Release.xcconfig */, ); path = Configurations; sourceTree = ""; }; CAF0DEE59779D65BC3C892A8 = { isa = PBXGroup; children = ( C90161AB2BE01A9B005B14AF /* Configurations */, CAF9DEE597793EF4668187A5 /* Cargo.toml */, CAF1DEE5977922869D176AE5 /* Products */, CAF2DEE5977998AF0B5890DB /* Frameworks */, ); sourceTree = ""; }; CAF1DEE5977922869D176AE5 /* Products */ = { isa = PBXGroup; children = ( CA0090E2379FFD96F1473BE9 /* libobscuravpn-client.a */, CA01272F0B60B8156098F4D0 /* obscuravpn-client */, ); name = Products; sourceTree = ""; }; CAF2DEE5977998AF0B5890DB /* Frameworks */ = { isa = PBXGroup; children = ( ); name = Frameworks; sourceTree = ""; }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ CA0090E2379FB3AB1D86918A /* obscuravpn-client.a (static library) */ = { isa = PBXNativeTarget; buildConfigurationList = CA0006912554B3AB1D86918A /* Build configuration list for PBXNativeTarget "obscuravpn-client.a (static library)" */; buildPhases = ( C9CFB0122BE9A36D008B27D6 /* Run Script */, ); buildRules = ( CAF4DEE59779AC6C1400ACA8 /* PBXBuildRule */, ); dependencies = ( ); name = "obscuravpn-client.a (static library)"; productName = "libobscuravpn-client.a"; productReference = CA0090E2379FFD96F1473BE9 /* libobscuravpn-client.a */; productType = "com.apple.product-type.library.static"; }; CA01272F0B60AAAB3AB4761B /* obscuravpn-client (standalone executable) */ = { isa = PBXNativeTarget; buildConfigurationList = CA0106912554AAAB3AB4761B /* Build configuration list for PBXNativeTarget "obscuravpn-client (standalone executable)" */; buildPhases = ( CA0108875302AAAB3AB4761B /* Sources */, ); buildRules = ( CAF4DEE59779AC6C1400ACA8 /* PBXBuildRule */, ); dependencies = ( ); name = "obscuravpn-client (standalone executable)"; productName = "obscuravpn-client"; productReference = CA01272F0B60B8156098F4D0 /* obscuravpn-client */; productType = "com.apple.product-type.tool"; }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ CAF3DEE59779E04653AD465F /* Project object */ = { isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; LastUpgradeCheck = 1510; TargetAttributes = { CA0090E2379FB3AB1D86918A = { CreatedOnToolsVersion = 9.2; ProvisioningStyle = Automatic; }; CA01272F0B60AAAB3AB4761B = { CreatedOnToolsVersion = 9.2; ProvisioningStyle = Automatic; }; }; }; buildConfigurationList = CAF6DEE5977980E02D6C7F57 /* Build configuration list for PBXProject "obscuravpn-client" */; compatibilityVersion = "Xcode 11.4"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( en, Base, ); mainGroup = CAF0DEE59779D65BC3C892A8; productRefGroup = CAF1DEE5977922869D176AE5 /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( CA0090E2379FB3AB1D86918A /* obscuravpn-client.a (static library) */, CA01272F0B60AAAB3AB4761B /* obscuravpn-client (standalone executable) */, ); }; /* End PBXProject section */ /* Begin PBXShellScriptBuildPhase section */ C9CFB0122BE9A36D008B27D6 /* Run Script */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( ); inputPaths = ( "$(SRCROOT)/xcodescripts/cargo-build-static-lib.bash", "$(SRCROOT)/../rustlib/Cargo.toml", "$(SRCROOT)/cbindgen-apple.toml", ); name = "Run Script"; outputFileListPaths = ( ); outputPaths = ( "$(BUILT_PRODUCTS_DIR)/$(EXECUTABLE_NAME)", "$(BUILT_PRODUCTS_DIR)/$(PUBLIC_HEADERS_FOLDER_PATH)/$(EXECUTABLE_NAME).h", "$(BUILT_PRODUCTS_DIR)/$(PUBLIC_HEADERS_FOLDER_PATH)/module.modulemap", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "exec \"${SCRIPT_INPUT_FILE_0}\"\n"; }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ CA0108875302AAAB3AB4761B /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( CA016413314BAAAB3AB4761B /* Cargo.toml in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin XCBuildConfiguration section */ 9643049A2BEEFBAE00B3119B /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = C90161AC2BE01A9B005B14AF /* Debug.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CARGO_TARGET_DIR = "$(PROJECT_TEMP_DIR)/cargo_target"; CARGO_XCODE_BUILD_PROFILE = debug; CARGO_XCODE_FEATURES = ""; ENABLE_USER_SCRIPT_SANDBOXING = NO; ONLY_ACTIVE_ARCH = YES; PRODUCT_NAME = "obscuravpn-client"; RUSTUP_TOOLCHAIN = ""; SDKROOT = auto; SUPPORTS_MACCATALYST = YES; }; name = Debug; }; 9643049B2BEEFBAE00B3119B /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { CARGO_XCODE_CARGO_DEP_FILE_NAME = libobscuravpn_client.d; CARGO_XCODE_CARGO_FILE_NAME = libobscuravpn_client.a; INSTALL_GROUP = ""; INSTALL_MODE_FLAG = ""; INSTALL_OWNER = ""; PRODUCT_NAME = "obscuravpn-client"; PUBLIC_HEADERS_FOLDER_PATH = "include/$(PRODUCT_NAME)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; SUPPORTS_MACCATALYST = NO; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; }; 9643049C2BEEFBAE00B3119B /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { CARGO_XCODE_CARGO_DEP_FILE_NAME = "obscuravpn-client.d"; CARGO_XCODE_CARGO_FILE_NAME = "obscuravpn-client"; PRODUCT_NAME = "obscuravpn-client"; SUPPORTED_PLATFORMS = macosx; }; name = Debug; }; CA0090CB90CFB3AB1D86918A /* Release */ = { isa = XCBuildConfiguration; buildSettings = { CARGO_XCODE_CARGO_DEP_FILE_NAME = libobscuravpn_client.d; CARGO_XCODE_CARGO_FILE_NAME = libobscuravpn_client.a; INSTALL_GROUP = ""; INSTALL_MODE_FLAG = ""; INSTALL_OWNER = ""; PRODUCT_NAME = "obscuravpn-client"; PUBLIC_HEADERS_FOLDER_PATH = "include/$(PRODUCT_NAME)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; SUPPORTS_MACCATALYST = NO; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; }; CA0190CB90CFAAAB3AB4761B /* Release */ = { isa = XCBuildConfiguration; buildSettings = { CARGO_XCODE_CARGO_DEP_FILE_NAME = "obscuravpn-client.d"; CARGO_XCODE_CARGO_FILE_NAME = "obscuravpn-client"; PRODUCT_NAME = "obscuravpn-client"; SUPPORTED_PLATFORMS = macosx; }; name = Release; }; CAF7A11709B13CC16B37690B /* Release */ = { isa = XCBuildConfiguration; baseConfigurationReference = C90161AD2BE01A9B005B14AF /* Release.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CARGO_TARGET_DIR = "$(PROJECT_TEMP_DIR)/cargo_target"; CARGO_XCODE_BUILD_PROFILE = release; CARGO_XCODE_FEATURES = ""; ENABLE_USER_SCRIPT_SANDBOXING = NO; PRODUCT_NAME = "obscuravpn-client"; RUSTUP_TOOLCHAIN = ""; SDKROOT = auto; SUPPORTS_MACCATALYST = YES; }; name = Release; }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ CA0006912554B3AB1D86918A /* Build configuration list for PBXNativeTarget "obscuravpn-client.a (static library)" */ = { isa = XCConfigurationList; buildConfigurations = ( CA0090CB90CFB3AB1D86918A /* Release */, 9643049B2BEEFBAE00B3119B /* Debug */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; CA0106912554AAAB3AB4761B /* Build configuration list for PBXNativeTarget "obscuravpn-client (standalone executable)" */ = { isa = XCConfigurationList; buildConfigurations = ( CA0190CB90CFAAAB3AB4761B /* Release */, 9643049C2BEEFBAE00B3119B /* Debug */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; CAF6DEE5977980E02D6C7F57 /* Build configuration list for PBXProject "obscuravpn-client" */ = { isa = XCConfigurationList; buildConfigurations = ( CAF7A11709B13CC16B37690B /* Release */, 9643049A2BEEFBAE00B3119B /* Debug */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; /* End XCConfigurationList section */ }; rootObject = CAF3DEE59779E04653AD465F /* Project object */; } ================================================ FILE: apple/shared/Account+CustomStringConvertible.swift ================================================ import Foundation private func descriptionOrNilString(_ object: CustomStringConvertible?) -> String { if let object { return "\(object)" } else { return "(nil)" } } private func shortRelativeTimeSubscription(_ date: Date) -> String { let formatter = RelativeDateTimeFormatter() formatter.unitsStyle = RelativeDateTimeFormatter.UnitsStyle.abbreviated return formatter.localizedString(for: date, relativeTo: Date()) } extension AccountInfo: CustomStringConvertible { var description: String { let str = "{AccountInfo -- id \(id), active \(active), topUp: \(descriptionOrNilString(topUp)), stripSubscription: \(descriptionOrNilString(stripeSubscription)), appleSubscription: \(descriptionOrNilString(appleSubscription))}" return str } } extension TopUpInfo: CustomStringConvertible { var description: String { "{TopUpInfo -- creditExpiresAt: \(shortRelativeTimeSubscription(self.creditExpiresAtDate))}" } } extension StripeSubscriptionInfo: CustomStringConvertible { var description: String { "{StripeSubscriptionInfo -- status: \(self.status.rawValue), currentPeriodStart: \(shortRelativeTimeSubscription(self.currentPeriodStartDate)), currentPeriodEnd: \(shortRelativeTimeSubscription(self.currentPeriodEndDate))" } } extension AppleSubscriptionInfo: CustomStringConvertible { var description: String { "{StripeSubscriptionInfo -- status: \(subscriptionStatus.description), autoRenewalStatus: \(autoRenewalStatus), renewalTime: \(shortRelativeTimeSubscription(self.renewalDate))}" } } ================================================ FILE: apple/shared/Account.swift ================================================ import Foundation struct AccountStatus: Codable, Equatable { var accountInfo: AccountInfo var lastUpdatedSec: UInt64 enum CodingKeys: String, CodingKey { case accountInfo = "account_info" case lastUpdatedSec = "last_updated_sec" } // returns nil when: // subscription is active and renewing // returns zero when: // never topped up // returns a date in the past when: // account is past its expiration date (or never funded) var expirationDate: Date? { if self.accountInfo.autoRenews { return nil } return Date(timeIntervalSince1970: TimeInterval(self.accountInfo.currentExpiry ?? 0)) } func daysUntilExpiry() -> UInt64? { if !self.accountInfo.active { return 0 } if let end = self.expirationDate { let now = Date() return UInt64(max(Calendar.current.dateComponents([.day], from: now, to: end).day ?? 0, 0)) } return nil } func isActive() -> Bool { if self.accountInfo.autoRenews { return true } if let timestamp = self.expirationDate { return timestamp > Date() } return self.accountInfo.active } func expiringSoon() -> Bool { if let daysTillExpiry = daysUntilExpiry() { return daysTillExpiry <= 10 } return false } static func == (left: AccountStatus, right: AccountStatus) -> Bool { return left.lastUpdatedSec == right.lastUpdatedSec } } struct AccountInfo: Codable { let id: String let active: Bool let topUp: TopUpInfo? let stripeSubscription: StripeSubscriptionInfo? let appleSubscription: AppleSubscriptionInfo? let _autoRenews: Int64? let currentExpiry: Int64? var hasRenewingStripeSubscription: Bool { guard let stripeSubscription else { return false } return !stripeSubscription.cancelAtPeriodEnd && stripeSubscription.status != .unpaid && stripeSubscription.status != .canceled } enum CodingKeys: String, CodingKey { case topUp = "top_up" case id case active case stripeSubscription = "subscription" case appleSubscription = "apple_subscription" case _autoRenews = "auto_renews" case currentExpiry = "current_expiry" } var autoRenews: Bool { self._autoRenews != nil } /// returns the expected date when /// 1) the account's subscription renews /// 2) or the account will expire /// this is used to determined when the account info should be refreshed var periodEndDate: Date? { if let currentExpiry = self.currentExpiry { return Date(timeIntervalSince1970: TimeInterval(currentExpiry)) } if let autoRenews = self._autoRenews { return Date(timeIntervalSince1970: TimeInterval(autoRenews)) } return nil } } struct TopUpInfo: Codable { let creditExpiresAt: Int64 enum CodingKeys: String, CodingKey { case creditExpiresAt = "credit_expires_at" } var creditExpiresAtDate: Date { return Date(timeIntervalSince1970: TimeInterval(self.creditExpiresAt)) } } extension TopUpInfo { var expiryDate: Date { return Date(timeIntervalSince1970: TimeInterval(self.creditExpiresAt)) } } struct StripeSubscriptionInfo: Codable { let status: StripeSubscriptionStatus let currentPeriodStart: Int64 let currentPeriodEnd: Int64 let cancelAtPeriodEnd: Bool enum CodingKeys: String, CodingKey { case currentPeriodStart = "current_period_start" case currentPeriodEnd = "current_period_end" case cancelAtPeriodEnd = "cancel_at_period_end" case status } var currentPeriodStartDate: Date { return Date(timeIntervalSince1970: TimeInterval(self.currentPeriodStart)) } var currentPeriodEndDate: Date { return Date(timeIntervalSince1970: TimeInterval(self.currentPeriodEnd)) } } enum StripeSubscriptionStatus: String, Codable { case active case canceled case incomplete case incompleteExpired = "incomplete_expired" case pastDue = "past_due" case paused case trialing case unpaid } struct AppleSubscriptionInfo: Codable { // https://developer.apple.com/documentation/appstoreserverapi/status let status: Int32 let autoRenewalStatus: Bool let renewalTime: Int64 enum CodingKeys: String, CodingKey { case status case autoRenewalStatus = "auto_renew_status" case renewalTime = "renewal_date" } enum Status: Int32 { case active = 1 case expired = 2 case billingRetry = 3 case gracePeriod = 4 case revoked = 5 var description: String { switch self { case .active: "Active" case .expired: "Expired" case .billingRetry: "In Billing Retry Period" case .gracePeriod: "In Billing Grace Period" case .revoked: "Revoked" } } } var subscriptionStatus: Status { Status(rawValue: self.status) ?? .expired } var renewalDate: Date { return Date(timeIntervalSince1970: TimeInterval(self.renewalTime)) } } // https://github.com/Sovereign-Engineering/obscuravpn-api/blob/main/src/cmd/apple/associate_account.rs struct AppleAssociateAccountOutput: Codable {} ================================================ FILE: apple/shared/AccountInfo+Util.swift ================================================ import Foundation extension AccountInfo { var hasTopUp: Bool { guard let topUp else { return false } return topUp.creditExpiresAtDate > .now } var hasStripeSubscription: Bool { guard let stripeSubscription else { return false } if self.hasRenewingStripeSubscription { return true } let expirationDate = Date( timeIntervalSince1970: TimeInterval( stripeSubscription.currentPeriodEnd ) ) return expirationDate > .now } var activeNotApple: Bool { return self.active && !self.hasActiveAppleSubscription } var hasActiveAppleSubscription: Bool { guard let appleSubscription else { return false } return appleSubscription.subscriptionStatus == .active || appleSubscription.subscriptionStatus == .billingRetry || appleSubscription.subscriptionStatus == .gracePeriod } } ================================================ FILE: apple/shared/Box.swift ================================================ class Box { var boxed: T init(_ value: T) { self.boxed = value } } ================================================ FILE: apple/shared/Concurrency.swift ================================================ import DequeModule import Foundation import OSLog private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "Concurrency") /// Track a set of callbacks that can be triggered. class Callbacks { typealias CallbackId = ObjectId<(V) -> Void> private var pending: Set = [] /// Add a callback to the queue. /// /// The return value can be used to cancel the callback. @discardableResult func add(_ f: @escaping (V) -> Void) -> CallbackId { let cb = ObjectId(f) self.pending.insert(cb) return cb } /// Cancel a scheduled callback. /// /// Does nothing if the callback has already been executed or removed. func remove(_ cb: CallbackId) { self.pending.remove(cb) } /// Trigger all callbacks. /// /// This triggers all callbacks and clears the queue. func dispatch(_ value: V) { // Swap first to be re-entrant. let pending = self.pending self.pending = [] for cb in pending { cb.value(value) } } } /// A tool for tracking outstanding tasks. /// /// Note: If `TaskGroup` is suitable for your use case you should prefer that. (https://developer.apple.com/documentation/swift/taskgroup) /// /// This type is internally synchronized and all methods are safe to be called concurrently. class PendingTasks { private var lock = NSLock() private var count: UInt64 = 0 private var waiting = Callbacks() init() {} /// Record that a task has been started. func start(tasks: UInt64 = 1) { self.lock.withLock { self.count += tasks } } /// Record that a task has completed. func complete(tasks: UInt64 = 1) { self.lock.withLock { if tasks > self.count { logger.error("More tasks completed (\(tasks, privacy: .public)) than running (\(self.count, privacy: .public))") self.count = 0 } else { self.count -= tasks } if self.count == 0 { self.waiting.dispatch(()) } } } /// Wait until there are no tasks running. /// /// This will return the first time there are no outstanding tasks, or immediately if there are currently none. Tasks that are added while waiting will also be waited for. func waitForAll() async { await withCheckedContinuation { continuation in self.lock.withLock { if self.count == 0 { continuation.resume(returning: ()) } else { self.waiting.add { continuation.resume(returning: ()) } } } } } } struct TimeoutError: Error { var localizedDescription = "Operation Timed Out" } func withTimeout( _ timeout: Duration?, operation: @escaping () async throws -> T ) async throws -> T { guard let timeout = timeout else { return try await operation() } return try await withCheckedThrowingContinuation { continuation in let done = Atomic(false) let task = Task { do { let v = try await operation() let (exchanged, _) = done.compareExchange(expected: false, desired: true) if exchanged { continuation.resume(returning: v) } } catch { let (exchanged, _) = done.compareExchange(expected: false, desired: true) if exchanged { continuation.resume(throwing: error) } } } let timeoutNs = Int(timeout / .nanoseconds(1)) DispatchQueue.main.asyncAfter(deadline: .now().advanced(by: .nanoseconds(timeoutNs))) { let (exchanged, _) = done.compareExchange(expected: false, desired: true) if exchanged { task.cancel() continuation.resume(throwing: "Timeout elapsed") } } } } /// Atomic container until macos 15 becomes the minimum version. class Atomic { private var value: T private let lock = NSLock() init(_ value: T) { self.value = value } func load() -> T { self.lock.withLock { self.value } } func store(_ value: T) { self.lock.withLock { self.value = value } } } extension Atomic where T: Equatable { func compareExchange(expected: T, desired: T) -> (exchanged: Bool, original: T) { self.lock.withLock { let original = self.value let exchanged = self.value == expected if exchanged { self.value = desired } return (exchanged, original) } } } class AsyncMutex { class AsyncMutexGuard { let mutex: AsyncMutex var value: T { get { return self.mutex.value } set(newValue) { self.mutex.value = newValue } } init(mutex: AsyncMutex) { self.mutex = mutex } deinit { self.mutex.unlock() } } private enum State { case unlocked case locked(Box>>) } private var sync = NSLock() private var state: State = .unlocked private var value: T init(_ value: T) { self.value = value } func lock() async -> AsyncMutexGuard { await withCheckedContinuation { continuation in self.sync.withLock { switch self.state { case .unlocked: self.state = .locked(Box([])) continuation.resume(returning: AsyncMutexGuard(mutex: self)) return case .locked(let waiting): waiting.boxed.append(continuation) } } } } private func unlock() { self.sync.withLock { switch self.state { case .unlocked: logger.critical("unlock in unlocked state") case .locked(let waiting): guard let continuation = waiting.boxed.popFirst() else { self.state = .unlocked return } continuation.resume(returning: AsyncMutexGuard(mutex: self)) } } } func withLock(_ body: (AsyncMutexGuard) async throws(E) -> R) async throws(E) -> R { let mutexGuard = await self.lock() defer { withExtendedLifetime(mutexGuard) {}} return try await body(mutexGuard) } } ================================================ FILE: apple/shared/ConcurrencyTests.swift ================================================ import Testing @Test(.timeLimit(.minutes(1))) func asyncMutex() async throws { let mutex = AsyncMutex(false) await withTaskGroup(of: Void.self) { tasks in for _ in 0 ..< 100 { tasks.addTask { await mutex.withLock { mutex_guard in #expect(!mutex_guard.value) mutex_guard.value = true #expect(mutex_guard.value) try! await Task.sleep(seconds: 0.01) #expect(mutex_guard.value) mutex_guard.value = false } } } await tasks.waitForAll() } // Write your test here and use APIs like `#expect(...)` to check expected conditions. #expect(true) } ================================================ FILE: apple/shared/Debug.swift ================================================ func debugFormat(_ v: Any?) -> String { guard let v = v else { return "nil" } var r = "" debugPrint(v, terminator: "", to: &r) return r } ================================================ FILE: apple/shared/FfiCb.swift ================================================ import Foundation /// Unsafe callback wrapper, which allows calling the wrapped callback exactly once using only a pointer sized integer. /// Calling it more than once is unsafe. Never calling it is a memory leak. /// This is used to pass capturing closures across FFI boundaries. class FfiCb { typealias CallbackType = (T) -> Void private let callback: CallbackType private init(_ callback: @escaping CallbackType) { self.callback = callback } /// Get a pointer to the wrapped callback, which will prevent it being released until it is called. static func wrap(_ callback: @escaping CallbackType) -> UInt { let this = FfiCb(callback) return UInt(bitPattern: Unmanaged.passRetained(this).toOpaque()) } /// Call the callback and then release it. The pointer will be unsafe to use after that. static func call(_ ptr: UInt, _ args: T) { let this = Unmanaged>.fromOpaque(UnsafeRawPointer(bitPattern: ptr)!).takeRetainedValue() this.callback(args) } } ================================================ FILE: apple/shared/InfoDict.swift ================================================ import Foundation import OSLog import UniformTypeIdentifiers // The unique build ID. // // This is basically a meaningless number, it shouldn't be shown to users. It can just be used to tell if the exact same binary is being used. It is also used for updates as it is a monotonically increasing value. func buildVersion() -> String { Bundle.main.infoDictionary!["CFBundleVersion"] as! String } private func obscuraInfoDict() -> [String: Any] { Bundle.main.infoDictionary!["Obscura"] as! [String: Any] } // This is the main version number. // // This number is suitable for showing to the user as it contains just the information needed to usefully describe the version. // // In release builds it will be pretty such as v1.23. // // In other builds will will be something like `v1.23-3-abcde123` or `v1.23-6-a1b2c3-dirty`. func sourceVersion() -> String { return obscuraInfoDict()["ObscuraSourceVersion"] as! String } // The source commit ID. // // This will be a full commit ID, suffixed with -dirty if the working directory was not comitted. // // It generally shouldn't be shown to users, use `sourceVersion` instead. func sourceId() -> String { return obscuraInfoDict()["ObscuraSourceId"] as! String } #if os(macOS) func extensionBundle() -> Bundle { let url = Bundle.main.bundleURL .appending(path: "Contents/Library/SystemExtensions/") .appending(component: "\(networkExtensionBundleID()).systemextension") return Bundle(url: url)! } #endif // The correct bundle ID for the client app to connect to based on the build configuration public func networkExtensionBundleID() -> String { return obscuraInfoDict()["OBSCURA_NETWORK_EXTENSION_BUNDLE_ID"] as! String } func appGroupID() -> String { return obscuraInfoDict()["AppGroupIdentifier"] as! String } func configDir() -> String { #if os(macOS) return "/Library/Application Support/obscura-vpn/system-network-extension/" #else return URL.libraryDirectory.appendingPathComponent("obscura", conformingTo: UTType.folder).path(percentEncoded: false) #endif } func groupContainerDir() -> String? { #if os(macOS) return nil #else return FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.net.obscura.vpn-client-app-ios")?.path #endif } func logDir() -> String? { #if os(macOS) return nil #else guard let containerDir = groupContainerDir() else { Logger(subsystem: "net.obscura.sys-ext", category: "pre-log-init").error("no container url for group") return nil } return URL(filePath: containerDir).appending(path: "rust-log").path #endif } ================================================ FILE: apple/shared/Json.swift ================================================ import Foundation import OSLog private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "json") extension Encodable { func json(function: String = #function, file: String = #fileID, line: Int = #line) throws(String) -> String { do { let json = try JSONEncoder().encode(self) return String(data: json, encoding: .utf8)! } catch let err { logger.error("JSON encoding failed (\(file, privacy: .public):\(function, privacy: .public):\(line, privacy: .public)): \(err, privacy: .private)") throw errorCodeOther } } } extension Decodable { init(json: Data, function: String = #function, file: String = #fileID, line: Int = #line) throws(String) { do { self = try JSONDecoder().decode(Self.self, from: json) } catch let err { logger.error("JSON decoding failed (\(file, privacy: .public):\(function, privacy: .public):\(line, privacy: .public)): \(err, privacy: .private)") throw errorCodeOther } } init(json: String, function: String = #function, file: String = #fileID, line: Int = #line) throws(String) { try self.init(json: json.data(using: .utf8)!, function: function, file: file, line: line) } } /// Mutates the values in a dictionary so that they are able to be JSON encoded. /// /// For it just converts binary data to Base 64. func prepareForJson(_ value: inout Any) { switch value { case var array as [Any]: for (i, v) in array.enumerated() { var updated = v prepareForJson(&updated) array[i] = updated } value = array case var dict as [String: Any]: for (k, v) in dict { var updated = v prepareForJson(&updated) dict[k] = updated } value = dict case let data as Data: value = data.base64EncodedString() default: if !JSONSerialization.isValidJSONObject([value]) { value = debugFormat(value) } } } public struct Empty: Codable { public init() {} } ================================================ FILE: apple/shared/NetworkExtensionIpc.swift ================================================ import Foundation import NetworkExtension import OSLog private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "network extension ipc") // See ../../rustlib/src/manager_cmd.rs enum NeManagerCmdResult: Codable { case ok_json(String) case error(String) } // See ../../rustlib/src/manager_cmd.rs enum NeManagerCmd: Codable { case apiAppleAssociateAccount(appTransactionJws: String) case getDebugInfo case apiAppleCreateAppAccountToken case apiApplePollSubscription(originalTransactionId: String) case apiGetAccountInfo case getStatus(knownVersion: UUID?) case getTrafficStats case ping case setTunnelArgs(args: TunnelArgs?, active: Bool?) case login(accountId: String, validate: Bool) case getExitList(knownVersion: String?) case refreshExitList(freshness: TimeInterval) } // See ../../rustlib/src/manager.rs struct TunnelArgs: Codable { var exit: ExitSelector } // See ../../rustlib/src/manager.rs enum ExitSelector: Codable { case any case exit(id: String) case country(country_code: String) case city( country_code: String, city_code: String ) } struct NeStatus: Codable, Equatable { var version: UUID var vpnStatus: NeVpnStatus var accountId: String? var inNewAccountFlow: Bool var pinnedLocations: [PinnedLocation] var lastChosenExit: ExitSelector var lastExit: ExitSelector var apiUrl: String var account: AccountStatus? var autoConnect: Bool var featureFlags: NeStatusFeatureFlags var useSystemDns: Bool static func == (left: NeStatus, right: NeStatus) -> Bool { return left.version == right.version } } struct NeStatusFeatureFlags: Codable, Equatable { var killSwitch: Bool? } struct PinnedLocation: Codable, Equatable { var country_code: String var city_code: String var pinned_at: Int64 } enum NeVpnStatus: Codable { case connecting(tunnelArgs: TunnelArgs, connectError: String?, reconnecting: Bool) case connected(tunnelArgs: TunnelArgs, exit: ExitInfo, exitPublicKey: String, clientPublicKey: String, transport: TransportKind) case disconnected } struct ExitInfo: Codable { var id: String var country_code: String var city_name: String var city_code: String } enum TransportKind: String, Codable, Equatable { case quic case tcpTls } // Keep synchronized with rustlib/src/network_config.rs struct OsNetworkConfig: Codable, CustomStringConvertible, Equatable { var description: String { return "ipv4: \(self.ipv4), use system dns: \(self.useSystemDns), dns: \(self.dns), ipv6: \(self.ipv6)" } var dns: [String] var ipv4: String var ipv6: String var mtu: UInt16 var useSystemDns: Bool } // We must use NSError to communicate errors via startTunnel. // This defines an error domain and related methods for our Rust `ConnectErrorCode`. extension NSError { convenience init(connectErrorCode: String) { self.init(domain: connectErrorDomain, code: 0, userInfo: [variantKey: connectErrorCode]) } func connectErrorCode() -> String? { if self.domain == connectErrorDomain { guard let value = self.userInfo[variantKey] else { logger.error("domain is \(connectErrorDomain) no \(variantKey) key on userInfo") return nil } guard let connectErrorCode = value as? String else { logger.error("domain is \(connectErrorDomain), but userInfo.\(variantKey) is not a String") return nil } return connectErrorCode } return nil } } private let connectErrorDomain = "net.obscura.ConnectErrorCode" private let variantKey = "variant" extension NEVPNStatus: CustomStringConvertible { public var description: String { return switch self { case .invalid: "invalid" case .disconnected: "disconnected" case .connecting: "connecting" case .connected: "connected" case .reasserting: "reasserting" case .disconnecting: "disconnecting" @unknown default: "unknown (rawValue: \(self.rawValue))" } } } ================================================ FILE: apple/shared/NotificationIds.swift ================================================ enum NotificationId: String { case autoConnectFailed = "net.obscura.obscura-auto-connect-failed" case connectFailed = "net.obscura.obscura-connect-failed" case debuggingBundleFailed = "net.obscura.obscura-debugging-bundle-failed" case onDemandTunnelStopped = "net.obscura.ondemand-tunnel-stopped" } ================================================ FILE: apple/shared/ObservableValue.swift ================================================ import Foundation class ObservableValue { var lock: NSLock = .init() var set = false var value: T? var continuations: [CheckedContinuation] = [] func publish(_ value: T) { self.lock.withLock { self.set = true self.value = value for continuation in self.continuations { continuation.resume(returning: value) } self.continuations.removeAll() } } /// Get the value. /// /// This will block if the value hasn't been set yet. func get() async -> T { await withCheckedContinuation { continuation in self.lock.withLock { if self.set { continuation.resume(returning: self.value!) } else { self.continuations.append(continuation) } } } } /// Get the value if it has been set. func tryGet() -> T? { self.lock.withLock { self.value } } } ================================================ FILE: apple/shared/Sleep.swift ================================================ import Foundation extension Task where Success == Never, Failure == Never { static func sleep(seconds: Double) async throws { let duration = UInt64(seconds * 1_000_000_000) try await Task.sleep(nanoseconds: duration) } } ================================================ FILE: apple/shared/String.swift ================================================ // https://stackoverflow.com/a/74896180/7732434 func leftPad(_ str: String, toLength: Int, withPad character: Character) -> String { if str.count < toLength { return String(repeating: character, count: toLength - str.count) + str } else { return str } } // https://forums.swift.org/t/getting-the-name-of-a-swift-enum-value/35654/18 @_silgen_name("swift_EnumCaseName") func _getEnumCaseName(_ value: T) -> UnsafePointer? func getEnumCaseName(for value: T) -> String? { if let stringPtr = _getEnumCaseName(value) { return String(validatingUTF8: stringPtr) } return nil } ================================================ FILE: apple/shared/StringError.swift ================================================ import Foundation // Required to use `String` as `.failure` variant in `Result` extension String: LocalizedError { public var errorDescription: String? { return self } } // Define "ipcError-$" in webUI i18n files let errorCodeOther: String = "other" let errorCodeUpdaterCheck: String = "updaterFailedToCheck" let errorCodeUpdaterInstall: String = "updaterFailedToStartInstall" let errorUnsupportedOnOS: String = "errorUnsupportedOnOS" let errorFailedToAssociateAccount: String = "failedToAssociateAccount" let errorPurchaseFailed: String = "purchaseFailed" let errorConnectDeviceOffline: String = "deviceOffline" ================================================ FILE: apple/shared/Swift.swift ================================================ /// A wrapper for objects that gives them identity based on their address. class ObjectId: Equatable, Hashable { let value: V init(_ v: V) { self.value = v } static func == (l: ObjectId, r: ObjectId) -> Bool { return l === r } func hash(into hasher: inout Hasher) { hasher.combine(ObjectIdentifier(self)) } } ================================================ FILE: apple/shared/WatchableValue.swift ================================================ import Foundation class WatchableValue { private var lock: NSLock = .init() private var value: T private var continuations: [CheckedContinuation] = [] init(_ value: T) { self.value = value } func publish(_ value: T) { _ = self.update { current in current = value } } func update(_ f: (inout T) -> Void) -> T { self.lock.withLock { f(&self.value) for continuation in self.continuations { continuation.resume(returning: self.value) } self.continuations.removeAll() return self.value } } /// Get the current value. func get() -> T { self.lock.withLock { self.value } } /// Get the current value if `predicate` returns true, otherwise return the next published value func getIfOrNext(_ predicate: (T) -> Bool) async -> T { await withCheckedContinuation { continuation in self.lock.withLock { if predicate(self.value) { continuation.resume(returning: self.value) } else { self.continuations.append(continuation) } } } } /// Returns the current value if `predicate` returns true, otherwise returns the next published value that does func waitUntil(_ predicate: (T) -> Bool) async -> T { while true { let value = await self.getIfOrNext(predicate) if predicate(value) { return value } } } func waitUntilWithTimeout(_ timeout: Duration, _ predicate: @escaping (T) -> Bool) async -> T? { do { return try await withTimeout(timeout, operation: { await self.waitUntil(predicate) }) } catch { return nil } } } ================================================ FILE: apple/shared/time.swift ================================================ import Foundation let utcDateFormat: ISO8601DateFormatter = .init() ================================================ FILE: apple/system-network-extension/Info.plist ================================================ NetworkExtension NEProviderClasses com.apple.networkextension.packet-tunnel $(PRODUCT_MODULE_NAME).PacketTunnelProvider OSLogPreferences com.apple.extensionkit DEFAULT-OPTIONS Enable-Oversize-Messages Enable-Private-Data Level Enable Debug Persist Debug com.apple.xpc.transaction DEFAULT-OPTIONS Enable-Oversize-Messages Enable-Private-Data Level Enable Debug Persist Debug net.obscura.rust-apple DEFAULT-OPTIONS Enable-Oversize-Messages Enable-Private-Data Level Enable Debug Persist Debug net.obscura.vpn-client-app.system-network-extension DEFAULT-OPTIONS Enable-Oversize-Messages Enable-Private-Data Level Enable Debug Persist Debug Obscura AppGroupIdentifier $(OBSCURA_APP_APP_GROUP_ID) ObscuraSourceVersion $(OBSCURA_SOURCE_VERSION) ================================================ FILE: apple/system-network-extension/entitlements.entitlements ================================================ com.apple.developer.networking.networkextension $(OBSCURA_PACKET_TUNNEL_PROVIDER_ENTITLEMENT) com.apple.security.temporary-exception.files.absolute-path.read-write /Library/Application Support/obscura-vpn/ com.apple.security.app-sandbox com.apple.security.application-groups $(OBSCURA_APP_APP_GROUP_ID) com.apple.security.network.client com.apple.security.network.server ================================================ FILE: apple/third-party/CwlSysctl.swift ================================================ // // CwlSysctl.swift // CwlUtils // // Created by Matt Gallagher on 2016/02/03. // Copyright © 2016 Matt Gallagher ( https://www.cocoawithlove.com ). All rights reserved. // // Permission to use, copy, modify, and/or distribute this software for any // purpose with or without fee is hereby granted, provided that the above // copyright notice and this permission notice appear in all copies. // // THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES // WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF // MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY // SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES // WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN // ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR // IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. // import Foundation /// A "static"-only namespace around a series of functions that operate on buffers returned from the `Darwin.sysctl` function public enum Sysctl { /// Possible errors. public enum Error: Swift.Error { case unknown case malformedUTF8 case invalidSize case posixError(POSIXErrorCode) } /// Access the raw data for an array of sysctl identifiers. public static func data(for keys: [Int32]) throws -> [Int8] { return try keys.withUnsafeBufferPointer { keysPointer throws -> [Int8] in // Preflight the request to get the required data size var requiredSize = 0 let preFlightResult = Darwin.sysctl(UnsafeMutablePointer(mutating: keysPointer.baseAddress), UInt32(keys.count), nil, &requiredSize, nil, 0) if preFlightResult != 0 { throw POSIXErrorCode(rawValue: errno).map { print($0.rawValue) return Error.posixError($0) } ?? Error.unknown } // Run the actual request with an appropriately sized array buffer let data = [Int8](repeating: 0, count: requiredSize) let result = data.withUnsafeBufferPointer { dataBuffer -> Int32 in Darwin.sysctl(UnsafeMutablePointer(mutating: keysPointer.baseAddress), UInt32(keys.count), UnsafeMutableRawPointer(mutating: dataBuffer.baseAddress), &requiredSize, nil, 0) } if result != 0 { throw POSIXErrorCode(rawValue: errno).map { Error.posixError($0) } ?? Error.unknown } return data } } /// Convert a sysctl name string like "hw.memsize" to the array of `sysctl` identifiers (e.g. [CTL_HW, HW_MEMSIZE]) public static func keys(for name: String) throws -> [Int32] { var keysBufferSize = Int(CTL_MAXNAME) var keysBuffer = [Int32](repeating: 0, count: keysBufferSize) try keysBuffer.withUnsafeMutableBufferPointer { (lbp: inout UnsafeMutableBufferPointer) throws in try name.withCString { (nbp: UnsafePointer) throws in guard sysctlnametomib(nbp, lbp.baseAddress, &keysBufferSize) == 0 else { throw POSIXErrorCode(rawValue: errno).map { Error.posixError($0) } ?? Error.unknown } } } if keysBuffer.count > keysBufferSize { keysBuffer.removeSubrange(keysBufferSize ..< keysBuffer.count) } return keysBuffer } /// Invoke `sysctl` with an array of identifers, interpreting the returned buffer as the specified type. This function will throw `Error.invalidSize` if the size of buffer returned from `sysctl` fails to match the size of `T`. public static func value(ofType: T.Type, forKeys keys: [Int32]) throws -> T { let buffer = try data(for: keys) if buffer.count != MemoryLayout.size { throw Error.invalidSize } return try buffer.withUnsafeBufferPointer { bufferPtr throws -> T in guard let baseAddress = bufferPtr.baseAddress else { throw Error.unknown } return baseAddress.withMemoryRebound(to: T.self, capacity: 1) { $0.pointee } } } /// Invoke `sysctl` with an array of identifers, interpreting the returned buffer as the specified type. This function will throw `Error.invalidSize` if the size of buffer returned from `sysctl` fails to match the size of `T`. public static func value(ofType type: T.Type, forKeys keys: Int32...) throws -> T { return try self.value(ofType: type, forKeys: keys) } /// Invoke `sysctl` with the specified name, interpreting the returned buffer as the specified type. This function will throw `Error.invalidSize` if the size of buffer returned from `sysctl` fails to match the size of `T`. public static func value(ofType type: T.Type, forName name: String) throws -> T { return try self.value(ofType: type, forKeys: self.keys(for: name)) } /// Invoke `sysctl` with an array of identifers, interpreting the returned buffer as a `String`. This function will throw `Error.malformedUTF8` if the buffer returned from `sysctl` cannot be interpreted as a UTF8 buffer. public static func string(for keys: [Int32]) throws -> String { let optionalString = try data(for: keys).withUnsafeBufferPointer { dataPointer -> String? in dataPointer.baseAddress.flatMap { String(validatingUTF8: $0) } } guard let s = optionalString else { throw Error.malformedUTF8 } return s } /// Invoke `sysctl` with an array of identifers, interpreting the returned buffer as a `String`. This function will throw `Error.malformedUTF8` if the buffer returned from `sysctl` cannot be interpreted as a UTF8 buffer. public static func string(for keys: Int32...) throws -> String { return try self.string(for: keys) } /// Invoke `sysctl` with the specified name, interpreting the returned buffer as a `String`. This function will throw `Error.malformedUTF8` if the buffer returned from `sysctl` cannot be interpreted as a UTF8 buffer. public static func string(for name: String) throws -> String { return try self.string(for: self.keys(for: name)) } /// e.g. "MyComputer.local" (from System Preferences -> Sharing -> Computer Name) or /// "My-Name-iPhone" (from Settings -> General -> About -> Name) public static var hostName: String { return try! Sysctl.string(for: [CTL_KERN, KERN_HOSTNAME]) } /// e.g. "x86_64" or "N71mAP" /// NOTE: this is *corrected* on iOS devices to fetch hw.model public static var machine: String { #if os(iOS) && !arch(x86_64) && !arch(i386) return try! Sysctl.string(for: [CTL_HW, HW_MODEL]) #else return try! Sysctl.string(for: [CTL_HW, HW_MACHINE]) #endif } /// e.g. "MacPro4,1" or "iPhone8,1" /// NOTE: this is *corrected* on iOS devices to fetch hw.machine public static var model: String { #if os(iOS) && !arch(x86_64) && !arch(i386) return try! Sysctl.string(for: [CTL_HW, HW_MACHINE]) #else return try! Sysctl.string(for: [CTL_HW, HW_MODEL]) #endif } /// e.g. "8" or "2" public static var activeCPUs: Int32 { return try! Sysctl.value(ofType: Int32.self, forKeys: [CTL_HW, HW_AVAILCPU]) } /// e.g. "15.3.0" or "15.0.0" public static var osRelease: String { return try! Sysctl.string(for: [CTL_KERN, KERN_OSRELEASE]) } /// e.g. "Darwin" or "Darwin" public static var osType: String { return try! Sysctl.string(for: [CTL_KERN, KERN_OSTYPE]) } /// e.g. "15D21" or "13D20" public static var osVersion: String { return try! Sysctl.string(for: [CTL_KERN, KERN_OSVERSION]) } /// e.g. "Darwin Kernel Version 15.3.0: Thu Dec 10 18:40:58 PST 2015; root:xnu-3248.30.4~1/RELEASE_X86_64" or /// "Darwin Kernel Version 15.0.0: Wed Dec 9 22:19:38 PST 2015; root:xnu-3248.31.3~2/RELEASE_ARM64_S8000" public static var version: String { return try! Sysctl.string(for: [CTL_KERN, KERN_VERSION]) } #if os(macOS) /// e.g. 199506 (not available on iOS) public static var osRev: Int32 { return try! Sysctl.value(ofType: Int32.self, forKeys: [CTL_KERN, KERN_OSREV]) } /// e.g. 2659000000 (not available on iOS) public static var cpuFreq: Int64 { return try! Sysctl.value(ofType: Int64.self, forName: "hw.cpufrequency") } /// e.g. 25769803776 (not available on iOS) public static var memSize: UInt64 { return try! Sysctl.value(ofType: UInt64.self, forKeys: [CTL_HW, HW_MEMSIZE]) } #endif } ================================================ FILE: apple/xcodescripts/cargo-build-static-lib.bash ================================================ #!/usr/bin/env bash # Originally generated with cargo-xcode 1.10.0, since modified heavily # See original cargo-xcode build script here https://gitlab.com/kornelski/cargo-xcode/-/blob/v1.10.0/src/xcodebuild.sh?ref_type=tags set -euo pipefail export PATH="$HOME/.nix-profile/bin/:$PATH:/usr/local/bin:$HOME/.cargo/bin:/opt/homebrew/bin" ## don't use ios/watchos linker for build scripts and proc macros ## This If statement is due to an oddity where defining these creates issues on Archive see OBS-1521 if [ "${CONFIGURATION}" != "Release" ] || [ "${PLATFORM_NAME}" != "macosx" ]; then CARGO_TARGET_AARCH64_APPLE_DARWIN_LINKER=$(xcrun --find ld) export CARGO_TARGET_AARCH64_APPLE_DARWIN_LINKER CARGO_TARGET_X86_64_APPLE_DARWIN_LINKER=$(xcrun --find ld) export CARGO_TARGET_X86_64_APPLE_DARWIN_LINKER fi export OBSCURA_CLIENT_RUSTLIB_CBINDGEN_OUTPUT_HEADER_PATH="$SCRIPT_OUTPUT_FILE_1" export OBSCURA_CLIENT_RUSTLIB_CBINDGEN_CONFIG_PATH="$SCRIPT_INPUT_FILE_2" CARGO_XCODE_CARGO_MANIFEST_PATH="${SCRIPT_INPUT_FILE:-"$SCRIPT_INPUT_FILE_1"}" # So that we pick up .cargo/config.toml cd "$(dirname "$CARGO_XCODE_CARGO_MANIFEST_PATH")" # NOTE: We need the '-' paramaeter expansion because we're in bash's "set -u" mode if [ -n "${OTHER_INPUT_FILE_FLAGS-}" ]; then read -r -a CARGO_XCODE_CARGO_EXTRA_FLAGS <<<"$OTHER_INPUT_FILE_FLAGS" else CARGO_XCODE_CARGO_EXTRA_FLAGS=("--lib") fi case "$PLATFORM_NAME" in "macosx") CARGO_XCODE_TARGET_OS=darwin if [ "${IS_MACCATALYST-NO}" = YES ]; then CARGO_XCODE_TARGET_OS=ios-macabi fi ;; "iphoneos") CARGO_XCODE_TARGET_OS=ios ;; "iphonesimulator") CARGO_XCODE_TARGET_OS=ios-sim ;; "appletvos" | "appletvsimulator") CARGO_XCODE_TARGET_OS=tvos ;; "watchos") CARGO_XCODE_TARGET_OS=watchos ;; "watchsimulator") CARGO_XCODE_TARGET_OS=watchos-sim ;; *) CARGO_XCODE_TARGET_OS="$PLATFORM_NAME" echo >&2 "warning: cargo-xcode needs to be updated to handle $PLATFORM_NAME" ;; esac declare -a CARGO_XCODE_TARGET_TRIPLES declare -a CARGO_XCODE_TARGET_FLAGS declare -a LIPO_INPUT_FILES for arch in $ARCHS; do if [[ "$arch" == "arm64" ]]; then arch=aarch64; fi if [[ "$arch" == "i386" && "$CARGO_XCODE_TARGET_OS" != "ios" ]]; then arch=i686; fi triple="${arch}-apple-$CARGO_XCODE_TARGET_OS" CARGO_XCODE_TARGET_TRIPLES+=("$triple") CARGO_XCODE_TARGET_FLAGS+=("--target=${triple}") LIPO_INPUT_FILES+=("$CARGO_TARGET_DIR/$triple/$CARGO_XCODE_BUILD_PROFILE/$CARGO_XCODE_CARGO_FILE_NAME") done echo >&2 "Cargo $CARGO_XCODE_BUILD_PROFILE $ACTION for $PLATFORM_NAME $ARCHS =${CARGO_XCODE_TARGET_TRIPLES[*]}; using ${SDK_NAMES:-}. \$PATH is:" tr >&2 : '\n' <<<"$PATH" if command -v rustup &>/dev/null; then for triple in "${CARGO_XCODE_TARGET_TRIPLES[@]}"; do if ! rustup target list --installed | grep -Eq "^$triple$"; then echo >&2 "warning: this build requires rustup toolchain for $triple, but it isn't installed (will try rustup next)" rustup target add "$triple" || { echo >&2 "warning: can't install $triple, will try nightly -Zbuild-std" CARGO_XCODE_CARGO_EXTRA_FLAGS+=("-Zbuild-std") if [ -z "${RUSTUP_TOOLCHAIN:-}" ]; then export RUSTUP_TOOLCHAIN=nightly fi break } fi done fi if [ "$CARGO_XCODE_BUILD_PROFILE" = release ]; then CARGO_XCODE_CARGO_EXTRA_FLAGS+=("--release") fi if [ "$ACTION" = clean ]; then cargo clean --verbose --manifest-path="$CARGO_XCODE_CARGO_MANIFEST_PATH" "${CARGO_XCODE_TARGET_FLAGS[@]}" "${CARGO_XCODE_CARGO_EXTRA_FLAGS[@]}" rm -f "$SCRIPT_OUTPUT_FILE_0" "$SCRIPT_OUTPUT_FILE_1" "$SCRIPT_OUTPUT_FILE_2" exit 0 fi cargo build --verbose --manifest-path="$CARGO_XCODE_CARGO_MANIFEST_PATH" --features="${CARGO_XCODE_FEATURES:-}" "${CARGO_XCODE_TARGET_FLAGS[@]}" "${CARGO_XCODE_CARGO_EXTRA_FLAGS[@]}" || { echo >&2 "error: cargo build failed" exit 1 } lipo "${LIPO_INPUT_FILES[@]}" -create -output "$SCRIPT_OUTPUT_FILE_0" if [ -n "${LD_DYLIB_INSTALL_NAME-}" ]; then install_name_tool -id "$LD_DYLIB_INSTALL_NAME" "$SCRIPT_OUTPUT_FILE_0" fi echo "success: $ACTION of $SCRIPT_OUTPUT_FILE_0 for ${CARGO_XCODE_TARGET_TRIPLES[*]}" # Generate .modulemap file cat <"$SCRIPT_OUTPUT_FILE_2" module libobscuravpn_client { header "$(basename "$SCRIPT_OUTPUT_FILE_1")" export * } EOF ================================================ FILE: apple/xcodescripts/nix-build-web-bundle.bash ================================================ #!/usr/bin/env bash set -eo pipefail # No -u since we're sourcing external things pushd "${SRCROOT}/../" source contrib/shell/source-nix.sh OBS_WEB_PLATFORM="$PLATFORM_NAME" exec nix develop ".#web" --print-build-logs -c just web-bundle-build ================================================ FILE: apple/xcodescripts/nix-web-dev-server-start.bash ================================================ #!/usr/bin/env bash set -eo pipefail # No -u since we're sourcing external things pushd "${SRCROOT}/../" source contrib/shell/source-nix.sh # TODO: remove magic 1420 port PORT=1420 "$SRCROOT/xcodescripts/nix-web-dev-server-stop.bash" || true OBS_WEB_PLATFORM="$PLATFORM_NAME" WK_WEB_VIEW=1 nix develop ".#web" --print-build-logs -c just web-bundle-start & while jobs %% && ! nc -z localhost $PORT; do sleep 0.05 done disown %% ================================================ FILE: apple/xcodescripts/nix-web-dev-server-stop.bash ================================================ #!/usr/bin/env bash # This just kills whatever is using the port. It is ugly but the best option for a few reasons. # 1. Xcode ignores the result of pre-actions. This means that we have no way to signal a failure. # 2. The clean action will destroy our PID file. # # So we want the best chance of succeeding or the user will unknowingly be using a stale web server. The only way to reliably free up the port is to kill what is listening on it. # TODO: remove magic 1420 port kill "$(lsof -ti 'tcp:1420')" ================================================ FILE: apple/xcodescripts/pre-action.bash ================================================ #!/usr/bin/env bash set -euo pipefail cd "$SRCROOT/.." apple/xcodescripts/set-build-info.bash mkdir -pv obscura-ui/build ================================================ FILE: apple/xcodescripts/set-build-info.bash ================================================ #!/usr/bin/env bash set -euo pipefail cd "$SRCROOT/.." source contrib/shell/source-echoerr.bash git_commit=$(git rev-parse HEAD) if ! git diff --quiet; then git_commit="$git_commit-dirty" fi git_describe=$(git describe --match "v/*" --abbrev=12 --dirty) echoerr "git describe: $git_describe" git_tag="${git_describe%%-*}" build_version=$(date -u '+1.%Y%m%d.%H%M%S') marketing_version="${git_tag#v/}" if [[ "$git_tag" != "$git_describe" ]]; then # For builds that don't exactly match a tag add a `.1` to indicate a "dev" build. marketing_version="${marketing_version}.1" fi source_version="v${git_describe#v/}" tee apple/Configurations/buildversion.xcconfig < max_sleep: max_sleep = delta max_sleep_time = timestamp if delta >= noteworthy: print(f"{fmt_time(timestamp)} sleep for {delta}") last_entry = timestamp print(f"max sleep {max_sleep} at {fmt_time(max_sleep_time)}") ================================================ FILE: bin/log-summary.py ================================================ #!/usr/bin/env python3 import argparse import datetime import json import re import sys import zoneinfo LEVELS = { "Debug": 0, "Info": 1, "Default": 2, "Error": 3, "Fault": 4, } LEVEL_MAX = 5 # Higher than all levels. RUST_PRINT_RE = re.compile("|".join([ "creating tunnel .*", "deriving connect error code for tunnel (creation|connect): .*", "finishing tunnel connection .*", "Ignoring failure to update exit list: .*", "Selected exit .*", "selected relay .*", "tunnel connected", '"preferred network path interface name:.*', '"sleep entry .*', '"startTunnel entry .*', '"stopTunnel entry .*', '"wake entry .*', '.* message_id="(3rOUXFti|Azzlo6j2|KT91bgvI|OfLfwKhf|TJ4nH30h|uQ0xQcPP|UROUZerU)".*', ]), re.DOTALL) SWIFT_PRINT_RE = re.compile("|".join([ "NWPathMonitor event: .*", ])) def format_log_time(log): date = datetime.datetime.fromisoformat(log["timestamp"]) return format_time(date) def format_time(date): if args.zone == "": return "" r = "" for zone in args.zone.split(","): if zone == "local": converted = date.astimezone(None) elif zone == "source": converted = date elif zone == "utc": converted = date.astimezone(datetime.timezone.utc) else: converted = date.astimezone(zoneinfo.ZoneInfo(zone)) if args.date: r += converted.strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] + " " else: r += converted.strftime("%H:%M:%S.%f")[:-3] + " " return r if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument("path") parser.add_argument( "-d", "--date", action="store_true", help="Show the date along with the time." ) parser.add_argument( "-l", "--level", choices=list(LEVELS), default="Fault", help="Print all logs at or above this level", ) parser.add_argument( "-z", "--zone", default="source", help="A comma separated list of timezones in which to display times. Each item is either `source` (for the users timezone), `local` for your timezone or an IANNA timezone name (like `America/Toronto`)." ) args = parser.parse_args() min_level = LEVELS[args.level] with open(args.path) as f: for line in f: entry = json.loads(line) if entry.get("eventType") != "logEvent": continue subsystem = entry["subsystem"] if subsystem == "net.obscura.rust-apple": msg = entry["eventMessage"] if RUST_PRINT_RE.match(msg): print(format_log_time(entry), msg) elif ' message_id="eech6Ier"' in msg: print(format_log_time(entry), "Racing relays... CONNECTION ATTEMPT START") elif 'Ignoring failure to update exit list' in msg: print("WTF", msg) print("MATCH", RUST_PRINT_RE.match(msg)) elif LEVELS.get(entry["messageType"], LEVEL_MAX) >= min_level: print(format_log_time(entry), msg) elif subsystem == "net.obscura.vpn-client-app": msg = entry["eventMessage"] if SWIFT_PRINT_RE.match(msg): print(format_log_time(entry), msg) elif subsystem == "" and entry["processID"] == 0: msg = entry["eventMessage"] if msg == "PMRD: trace point 0x18": print(format_log_time(entry), "########## KERNEL SLEEP ##########") ================================================ FILE: bin/log-text.py ================================================ #!/usr/bin/env python3 import argparse import datetime import json import sys import zoneinfo LEVELS = { "Debug": 0, "Info": 1, "Default": 2, "Error": 3, "Fault": 4, } LEVEL_MAX = 5 # Higher than all levels. LEVEL_FMT = { "Debug": "D", "Info": "I", "Default": "L", "Error": "E", "Fault": "F", None: "N", "unknown": "U", } IGNORED_TYPES = { "activityCreateEvent", "signpostEvent", "stateEvent", "unknown", "userActionEvent", } OUR_PROCESSES = { "Obscura VPN", "net.obscura.vpn-client-app.system-network-extension", } UI_SUBSYSTEMS = { "com.apple.AppKit", "com.apple.CFBundle", "com.apple.defaults", } def format_time(date): if args.zone == "": return "" r = "" for zone in args.zone.split(","): if zone == "local": converted = date.astimezone(None) elif zone == "source": converted = date elif zone == "utc": converted = date.astimezone(datetime.timezone.utc) else: converted = date.astimezone(zoneinfo.ZoneInfo(zone)) if args.date: r += converted.strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] + " " else: r += converted.strftime("%H:%M:%S.%f")[:-3] + " " return r def format_log(log): if log.get("finished") == 1: return "Finished" if log["eventType"] in IGNORED_TYPES: return None if log["eventType"] == "timesyncEvent": date = datetime.datetime.fromisoformat(log["timestamp"]) datestr = format_time(date) return f"{datestr}timesyncEvent" if args.obscura and log["processImagePath"] not in OUR_PROCESSES: return None level_int = LEVELS.get(log["messageType"], LEVEL_MAX) if level_int < min_level: return None if not args.ui and log["subsystem"] in UI_SUBSYSTEMS: return None date = datetime.datetime.fromisoformat(log["timestamp"]) datestr = format_time(date) level_s = LEVEL_FMT.get(log["messageType"], "?") return f"{datestr}{level_s} {log["processImagePath"]}:{log["subsystem"]}:{log["category"]} | {log["eventMessage"]}" if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument("path") parser.add_argument( "-d", "--date", action="store_true", help="Show the date along with the time." ) parser.add_argument( "-l", "--level", choices=list(LEVELS), default="Debug", help="Minimum log level to print.", ) parser.add_argument( "--obscura", action="store_true", help="Show only logs from our processes." ) parser.add_argument( "--ui", action="store_true", help="Show UI-related logs." ) parser.add_argument( "-z", "--zone", default="source", help="A comma separated list of timezones in which to display times. Each item is either `source` (for the users timezone), `local` for your timezone or an IANNA timezone name (like `America/Toronto`)." ) args = parser.parse_args() min_level = LEVELS[args.level] with open(args.path) as f: for line in f: entry = json.loads(line) formatted = format_log(entry) if formatted == None: continue print(formatted) ================================================ FILE: contrib/bin/build-obscuravpn-dmg.bash ================================================ #!/usr/bin/env bash set -euo pipefail source contrib/shell/source-echoerr.bash source contrib/shell/source-die.bash if [ "$#" -ne 0 ]; then die "No parameters accepted." fi KEYCHAIN_PROFILE="notarytool-password" CERT='Developer ID Application: Sovereign Engineering Inc. (5G943LR562)' APP_NAME="Obscura VPN" APP_BASENAME="$APP_NAME.app" DMG_FILE_NAME="$APP_NAME.dmg" VOLUME_NAME="$APP_NAME" BACKGROUND="apple/dmg-building/installer_background.tiff" # Temp directory setup TMP_DIR=$(mktemp -d) cleanup() { echo "Cleaning up temporary directory: $TMP_DIR" rm -rf "$TMP_DIR" } trap cleanup EXIT ARCHIVE_DIR="$TMP_DIR/client-prod.xcarchive" EXPORT_DIR="$TMP_DIR/client-prod-export" APP_PATH="$EXPORT_DIR/$APP_BASENAME" SOURCE_DIR="$TMP_DIR/dmg-contents" mkdir "$SOURCE_DIR" # Size of the Finder window toolbar WINDOW_TOP_PADDING=28 # NOTE: Keep in sync with "$BACKGROUND" ICON_SIZE=120 # We need to specify the center location of the icons ICON_CENTERING_DELTA=$((ICON_SIZE / 2)) ICONS_Y_FROM_TOP=277 OBSCURA_APP_ICON_X_FROM_LEFT=287 APPLICATIONS_DROP_ICON_X_FROM_LEFT=553 BACKGROUND_IMAGE_HEIGHT=601 BACKGROUND_IMAGE_WIDTH=960 WINDOW_POS_X=200 WINDOW_POS_Y=120 set -x xcodebuild archive \ -workspace apple/client.xcodeproj/project.xcworkspace \ -scheme 'Prod Client' \ -archivePath "$ARCHIVE_DIR" xcodebuild -exportArchive \ -archivePath "$ARCHIVE_DIR" \ -exportOptionsPlist apple/ExportOptions.plist \ -exportPath "$EXPORT_DIR" NOTARIZE_ZIP="$TMP_DIR/obscura-notarize.zip" ditto -c -k --keepParent "$APP_PATH" "$NOTARIZE_ZIP" xcrun notarytool submit \ --keychain-profile "$KEYCHAIN_PROFILE" \ --verbose \ --wait \ "$NOTARIZE_ZIP" xcrun stapler staple -v "$APP_PATH" xcrun stapler validate -v "$APP_PATH" # Ref: https://developer.apple.com/forums/thread/130560 spctl -a -t exec -vvv "$APP_PATH" mv "$APP_PATH" "$SOURCE_DIR/$APP_BASENAME" # Create the DMG rm -vf "$DMG_FILE_NAME" create-dmg \ --volname "${VOLUME_NAME}" \ --background "$BACKGROUND" \ --window-pos "$WINDOW_POS_X" "$WINDOW_POS_Y" \ --window-size "$BACKGROUND_IMAGE_WIDTH" $(( BACKGROUND_IMAGE_HEIGHT + WINDOW_TOP_PADDING )) \ --icon-size "$ICON_SIZE" \ --icon "$APP_BASENAME" $(( OBSCURA_APP_ICON_X_FROM_LEFT + ICON_CENTERING_DELTA )) $(( ICONS_Y_FROM_TOP + ICON_CENTERING_DELTA )) \ --hide-extension "$APP_BASENAME" \ --app-drop-link $(( APPLICATIONS_DROP_ICON_X_FROM_LEFT + ICON_CENTERING_DELTA )) $(( ICONS_Y_FROM_TOP + ICON_CENTERING_DELTA )) \ --no-internet-enable \ "$DMG_FILE_NAME" \ "$SOURCE_DIR" # Codesign the DMG # Ref: https://developer.apple.com/library/archive/documentation/Security/Conceptual/CodeSigningGuide/Procedures/Procedures.html codesign --sign "$CERT" "$DMG_FILE_NAME" xcrun notarytool submit \ --keychain-profile "$KEYCHAIN_PROFILE" \ --verbose \ --wait \ "$DMG_FILE_NAME" xcrun stapler staple -v "$DMG_FILE_NAME" xcrun stapler validate -v "$DMG_FILE_NAME" # Ref: https://developer.apple.com/library/archive/technotes/tn2206/_index.html spctl -a -t open --context context:primary-signature -v "$DMG_FILE_NAME" # Ref: https://wiki.freepascal.org/Notarization_for_macOS_10.14.5%2B#Step_7_-_Verify_notarization_of_the_disk_image spctl -a -vv -t install "$DMG_FILE_NAME" ================================================ FILE: contrib/bin/check-in-obscura-nix-shell.bash ================================================ #!/usr/bin/env bash set -eo pipefail source contrib/shell/source-die.bash if [ -z "$OBSCURA_MAGIC_IN_NIX_SHELL" ]; then die "ERROR: Not running in Obscura Nix Shell, see README.md for setup" fi ================================================ FILE: contrib/bin/find-nix-files.bash ================================================ #!/usr/bin/env bash set -eo pipefail source contrib/shell/source-die.bash # Parse command line options while getopts ":z" opt; do case $opt in z) NULL_OUTPUT=true ;; \?) die "Invalid option: -${OPTARG}" ;; esac done nix_file_git_patterns=( '*.nix' ) ./contrib/bin/ls-non-ignored-files.bash ${NULL_OUTPUT:+-z} -- "${nix_file_git_patterns[@]}" ================================================ FILE: contrib/bin/find-shellcheck-files.bash ================================================ #!/usr/bin/env bash set -eo pipefail source contrib/shell/source-die.bash # Parse command line options while getopts ":z" opt; do case $opt in z) NULL_OUTPUT=true ;; \?) die "Invalid option: -${OPTARG}" ;; esac done shell_script_git_patterns=( '*.sh' '*.bash' ) ./contrib/bin/ls-non-ignored-files.bash ${NULL_OUTPUT:+-z} -- "${shell_script_git_patterns[@]}" ================================================ FILE: contrib/bin/linux-packages.bash ================================================ #!/usr/bin/env bash set -eu ./contrib/bin/package-deb.bash ./contrib/bin/package-rpm.bash ./contrib/bin/package-arch.bash ================================================ FILE: contrib/bin/linux-test.bash ================================================ #!/usr/bin/env bash set -eu trap 'pkill -P $$' EXIT function error() { echo "$@" >&2 kill $$ } function check_args() { if [ "$1" -ne "$2" ]; then error "L${BASH_LINENO[0]}: wrong number of function arguments, got $1, expected $2" fi } function reset() { check_args $# 2 local DISTRO=$1 local FLAVOR=$2 echo "Creating disk image" virsh --connect qemu:///session destroy "obs-${DISTRO}-${FLAVOR}" &> /dev/null || true qemu-img create -f qcow2 "$(disk_image_path "${DISTRO}" "${FLAVOR}").tmp" 20G echo "Downloading ${DISTRO}-${FLAVOR} installation media if necessary" download "${DISTRO}" "${FLAVOR}" prepare "${DISTRO}" "${FLAVOR}" echo "Installing ${DISTRO}-${FLAVOR}" mapfile -t AUTOINSTALL_ARGS < <(autoinstall "${DISTRO}" "${FLAVOR}") virt-install \ --connect qemu:///session \ --transient \ --name "obs-${DISTRO}-${FLAVOR}" \ --ram 4096 \ --vcpus $(($(nproc)-1)) \ --cpu host-model \ --disk path="$(disk_image_path "${DISTRO}" "${FLAVOR}").tmp,format=qcow2,bus=virtio" \ --network user \ --graphics none \ --video virtio \ "${AUTOINSTALL_ARGS[@]}" mv "$(disk_image_path "${DISTRO}" "${FLAVOR}").tmp" "$(disk_image_path "${DISTRO}" "${FLAVOR}")" } function disk_image_path() { check_args $# 2 local DISTRO=$1 local FLAVOR=$2 echo "./linux/vm/${DISTRO}-${FLAVOR}.qcow2" } function download() { check_args $# 2 local DISTRO=$1 local FLAVOR=$2 # Ubuntu doesn't have small desktop or netinstall images, so we need to download the iso declare -A map=( ["ubuntu24.04-desktop"]="https://releases.ubuntu.com/noble/ubuntu-24.04.3-desktop-amd64.iso" ) if [[ -v map[${DISTRO}-${FLAVOR}] ]]; then local ISO="./linux/vm/${DISTRO}-${FLAVOR}.iso" if [ ! -e "${ISO}" ]; then wget "${map[${DISTRO}-${FLAVOR}]}" -O "${ISO}" fi fi } function prepare() { check_args $# 2 local DISTRO=$1 local FLAVOR=$2 # Ubuntu on desktop doesn't support auto install via initrd injected files declare -A map=( ["ubuntu24.04-desktop"]="x" ["archlinux-desktop"]="x" ) if [[ -v map[${DISTRO}-${FLAVOR}] ]]; then cloud-localds "./linux/vm/${DISTRO}-${FLAVOR}.seed.iso" "./linux/vm/${DISTRO}-${FLAVOR}-cloud-init/user-data" "./linux/vm/${DISTRO}-${FLAVOR}-cloud-init/meta-data" fi } function autoinstall() { check_args $# 2 local DISTRO=$1 local FLAVOR=$2 echo "--os-variant" declare -A map=( ["debian12-desktop"]="debian12" ["debian13-desktop"]="debian13" ["ubuntu24.04-desktop"]="ubuntu24.04" ["fedora43-desktop"]="fedora41" ["archlinux-desktop"]="archlinux" ) if [[ ! -v map[${DISTRO}-${FLAVOR}] ]]; then error "unknown autoinstall os-variant for ${DISTRO}-${FLAVOR}" fi echo "${map[${DISTRO}-${FLAVOR}]}" echo "--location" declare -A map=( ["debian12-desktop"]="https://deb.debian.org/debian/dists/bookworm/main/installer-amd64/" ["debian13-desktop"]="https://deb.debian.org/debian/dists/trixie/main/installer-amd64/" ["ubuntu24.04-desktop"]="./linux/vm/ubuntu24.04-desktop.iso,kernel=casper/vmlinuz,initrd=casper/initrd" ["fedora43-desktop"]="https://download.fedoraproject.org/pub/fedora/linux/releases/43/Everything/x86_64/os/" ["archlinux-desktop"]="https://mirrors.edge.kernel.org/archlinux/iso/latest/,kernel=arch/boot/x86_64/vmlinuz-linux,initrd=arch/boot/x86_64/initramfs-linux.img" ) if [[ ! -v map[${DISTRO}-${FLAVOR}] ]]; then error "unknown autoinstall location for ${DISTRO}-${FLAVOR}" fi echo "${map[${DISTRO}-${FLAVOR}]}" declare -A map=( ["ubuntu24.04-desktop"]="x" ["archlinux-desktop"]="x" ) if [[ -v map[${DISTRO}-${FLAVOR}] ]]; then echo "--disk" echo "./linux/vm/${DISTRO}-${FLAVOR}.seed.iso" fi echo "--extra-args" declare -A map=( ["debian12-desktop"]="auto=true priority=critical file=/debian-desktop.preseed.cfg console=ttyS0" ["debian13-desktop"]="auto=true priority=critical file=/debian-desktop.preseed.cfg console=ttyS0" ["ubuntu24.04-desktop"]="autoinstall console=ttyS0" ["fedora43-desktop"]="inst.ks=file:/fedora43-desktop.ks console=tty0 console=ttyS0" ["archlinux-desktop"]="ip=dhcp archisobasedir=arch archiso_http_srv=https://mirrors.edge.kernel.org/archlinux/iso/latest/ console=ttyS0" ) if [[ ! -v map[${DISTRO}-${FLAVOR}] ]]; then error "unknown autoinstall extra-args for ${DISTRO}-${FLAVOR}" fi echo "${map[${DISTRO}-${FLAVOR}]}" declare -A map=( ["debian12-desktop"]="./linux/vm/debian12-desktop.preseed.cfg" ["debian13-desktop"]="./linux/vm/debian13-desktop.preseed.cfg" ["fedora43-desktop"]="./linux/vm/fedora43-desktop.ks" ) if [[ -v map[${DISTRO}-${FLAVOR}] ]]; then echo "--initrd-inject" echo "${map[${DISTRO}-${FLAVOR}]}" fi } function ssh_run() { sxx_run ssh -p 2222 user@localhost "$@" } function scp_run() { check_args $# 2 local SRC=$1 local DEST=$2 sxx_run scp -P 2222 "${SRC}" "user@localhost:${DEST}" } function sxx_run() { local CMD=$1 shift sshpass -p pw "${CMD}" -o ConnectTimeout=1 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR "$@" } function start_vm() { check_args $# 2 local DISTRO=$1 local FLAVOR=$2 qemu-system-x86_64 \ -enable-kvm \ -m 4G \ -smp $(($(nproc) - 1)) \ -drive file="$(disk_image_path "${DISTRO}" "${FLAVOR}"),format=qcow2,if=virtio,snapshot=on" \ -netdev user,id=n1,hostfwd=tcp::2222-:22 \ -device virtio-net,netdev=n1 & echo "### Started ${DISTRO}-${FLAVOR}, waiting for SSH login" until ssh_run exit; do sleep 1 done echo "### SSH login on ${DISTRO}-${FLAVOR} successful" } function install_package() { check_args $# 2 local DISTRO=$1 local FLAVOR=$2 if [[ ${DISTRO} == debian* ]] || [[ ${DISTRO} == ubuntu* ]]; then scp_run ./obscura_0.0.1_amd64.deb /home/user/obscura.deb ssh_run sudo dpkg -i /home/user/obscura.deb elif [[ ${DISTRO} == fedora* ]] || [[ ${DISTRO} == alma* ]]; then scp_run ./obscura-0.0.1-1.x86_64.rpm /home/user/obscura.rpm ssh_run sudo dnf install -y /home/user/obscura.rpm elif [[ ${DISTRO} == archlinux* ]]; then scp_run ./obscura-0.0.1-1-x86_64.pkg.tar.zst /home/user/obscura.zst ssh_run sudo pacman --noconfirm -U /home/user/obscura.zst ssh_run sudo systemctl enable --now obscura else error "no package install instructions for this ${DISTRO}" fi sleep 1 } function setup_and_connect() { check_args $# 1 local ACCOUNT_ID=$1 ssh_run obscura add-operator '&&' RUST_LOG=debug obscura ipc-test ssh_run obscura login "${ACCOUNT_ID}" ssh_run obscura start } # shellcheck disable=SC2120 function check_if_mullvad() { check_args $# 0 local MULLVAD_CHECK_OUTPUT for IP_VERSION in 4 6; do MULLVAD_CHECK_OUTPUT="$(ssh_run curl -sS https://ipv${IP_VERSION}.am.i.mullvad.net/json)" if [[ "${MULLVAD_CHECK_OUTPUT}" == *'"mullvad_exit_ip":true'* ]]; then echo "Mullvad IPv${IP_VERSION} check passed" else error "Mullvad IPv${IP_VERSION} check failed: ${MULLVAD_CHECK_OUTPUT}" fi done } # MAIN if [ $# -ne 2 ]; then error "usage: $0 " fi ACCOUNT_ID=$1 DISTRO=$2 FLAVOR="desktop" if [ ! -f "$(disk_image_path "${DISTRO}" "${FLAVOR}")" ]; then reset "${DISTRO}" "${FLAVOR}" fi start_vm "${DISTRO}" "${FLAVOR}" install_package "${DISTRO}" "${FLAVOR}" setup_and_connect "${ACCOUNT_ID}" check_if_mullvad sleep 100000000 ================================================ FILE: contrib/bin/linux_run_service.sh ================================================ #!/usr/bin/env bash set -eux (cd rustlib && cargo build) sudo --preserve-env=RUST_LOG sg obscura "umask 002 && ./rustlib/target/debug/obscura service" ================================================ FILE: contrib/bin/ls-non-ignored-files.bash ================================================ #!/usr/bin/env bash set -eo pipefail exec -- \ git ls-files \ --exclude-standard \ --others \ --cached \ "$@" ================================================ FILE: contrib/bin/nixfmt-auto-files.bash ================================================ #!/usr/bin/env bash set -eo pipefail # NOTE: we can't use `nix fmt` because it doesn't have `--check` mode ./contrib/bin/find-nix-files.bash -z \ | exec xargs --null -- \ nixfmt --width=120 "$@" -- ================================================ FILE: contrib/bin/package-arch.bash ================================================ #!/usr/bin/env bash set -eu nix build .#rust-static docker build -f linux/arch_Dockerfile -t obscura-arch . docker run --rm --security-opt label=disable -v "$PWD:/wd" -v "$(realpath result/bin/obscura):/obscura" obscura-arch sh -c ' set -eu mkdir -p ~/build/src && cd ~/build cp /wd/linux/arch_PKGBUILD PKGBUILD cp /obscura src/obscura cp /wd/linux/obscura.service src/ cp /wd/linux/obscura-sysusers.conf src/ cp /wd/LICENSE.md src/ makepkg -f namcap -e elfnoshstk *.pkg.tar.zst sudo cp *.pkg.tar.zst /wd/ ' ================================================ FILE: contrib/bin/package-deb.bash ================================================ #!/usr/bin/env bash set -eu nix build .#rust-static docker build -f linux/deb_Dockerfile -t obscura-deb . docker run --rm --security-opt label=disable -v "$PWD:/wd" -v "$(realpath result/bin/obscura):/obscura" obscura-deb sh -c ' set -eu mkdir -p /build/obscura/debian cd /build/obscura cp /wd/linux/deb_control debian/control cp /wd/linux/deb_rules debian/rules cp /wd/linux/deb_install debian/obscura.install echo "obscura (0.0.1) unstable; urgency=low" > debian/changelog echo "" >> debian/changelog echo " * Release" >> debian/changelog echo "" >> debian/changelog echo " -- obscura authors Thu, 01 Jan 1970 00:00:00 +0000" >> debian/changelog cp /wd/linux/obscura.service debian/obscura.service cp /wd/linux/obscura-sysusers.conf debian/obscura.sysusers install -m755 /obscura obscura chmod +x debian/rules dpkg-buildpackage -us -uc -b lintian --allow-root --suppress-tags no-copyright-file,no-manual-page,shared-library-lacks-prerequisites,description-starts-with-package-name /build/*.deb cp /build/*.deb /wd/ ' ================================================ FILE: contrib/bin/package-rpm.bash ================================================ #!/usr/bin/env bash set -eu nix build .#rust-static docker build -f linux/rpm_Dockerfile -t obscura-rpm . docker run --rm --security-opt label=disable -v "$PWD:/wd" -v "$(realpath result/bin/obscura):/obscura" obscura-rpm sh -c ' set -eu mkdir -p ~/rpmbuild/{SOURCES,SPECS,RPMS} cp /wd/linux/rpm_obscura.spec ~/rpmbuild/SPECS/obscura.spec cp /obscura ~/rpmbuild/SOURCES/ cp /wd/linux/obscura.service ~/rpmbuild/SOURCES/ cp /wd/linux/obscura-sysusers.conf ~/rpmbuild/SOURCES/ cp /wd/linux/obscura-preset.conf ~/rpmbuild/SOURCES/ rpmbuild -bb ~/rpmbuild/SPECS/obscura.spec rpmlint -r /wd/linux/rpm_rpmlintrc ~/rpmbuild/RPMS/*/*.rpm cp ~/rpmbuild/RPMS/*/*.rpm /wd/ ' ================================================ FILE: contrib/bin/shellcheck-auto-files.bash ================================================ #!/usr/bin/env bash set -eo pipefail ./contrib/bin/find-shellcheck-files.bash -z | exec xargs --null -- shellcheck -- ================================================ FILE: contrib/licenses.mjs ================================================ import {readFileSync} from "node:fs"; function rpartition(s, p) { let i = s.lastIndexOf(p); if (i < 0) { throw new Error(`No ${JSON.stringify(p)} in ${JSON.stringify(s)}`) } return [ s.slice(0, i), s.slice(i + 1), ]; } const LICENSES_NODE = JSON.parse(readFileSync(process.env.LICENSES_NODE)); const LICENSES_RUST = JSON.parse(readFileSync(process.env.LICENSES_RUST)); let overview = new Map; let licenses = new Map; let out = { overview: [], licenses: [], }; function addLicense(info) { let o = overview.get(info.id); if (!o) { o = { id: info.id, name: info.name, count: 0, }; overview.set(info.id, o); out.overview.push(o); } o.count += 1; let l = licenses.get(info.text); if (!l) { l = { id: info.id, name: o.name, text: info.text, used_by: [], }; licenses.set(info.text, l); out.licenses.push(l); } l.used_by.push(info.package); } // https://github.com/sparkle-project/Sparkle/blob/2c95fa406a92b683ed649cde2975034f2f774289/LICENSE addLicense({ id: "MIT", name: "MIT License", text: `Copyright (c) 2006-2013 Andy Matuschak. Copyright (c) 2009-2013 Elgato Systems GmbH. Copyright (c) 2011-2014 Kornel Lesiński. Copyright (c) 2015-2017 Mayur Pawashe. Copyright (c) 2014 C.W. Betts. Copyright (c) 2014 Petroules Corporation. Copyright (c) 2014 Big Nerd Ranch. All rights reserved. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================= EXTERNAL LICENSES ================= bspatch.c and bsdiff.c, from bsdiff 4.3 : Copyright 2003-2005 Colin Percival All rights reserved Redistribution and use in source and binary forms, with or without modification, are permitted providing that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY THE AUTHOR ${"``"}AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -- sais.c and sais.h, from sais-lite (2010/08/07) : The sais-lite copyright is as follows: Copyright (c) 2008-2010 Yuta Mori All Rights Reserved. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -- Portable C implementation of Ed25519, from https://github.com/orlp/ed25519 Copyright (c) 2015 Orson Peters This software is provided 'as-is', without any express or implied warranty. In no event will the authors be held liable for any damages arising from the use of this software. Permission is granted to anyone to use this software for any purpose, including commercial applications, and to alter it and redistribute it freely, subject to the following restrictions: 1. The origin of this software must not be misrepresented; you must not claim that you wrote the original software. If you use this software in a product, an acknowledgment in the product documentation would be appreciated but is not required. 2. Altered source versions must be plainly marked as such, and must not be misrepresented as being the original software. 3. This notice may not be removed or altered from any source distribution. -- SUSignatureVerifier.m: Copyright (c) 2011 Mark Hamlin. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted providing that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY THE AUTHOR ${"``"}AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.`, package: { name: "Sparkle", url: "https://sparkle-project.org/", version: "2.6.4", }, }); // ../apple/third-party/CwlSysctl.swift addLicense({ id: "ISC", name: "ISC License", text: `Created by Matt Gallagher on 2016/02/03. Copyright © 2016 Matt Gallagher ( https://www.cocoawithlove.com ). All rights reserved. Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.`, package: { name: "CwlUtils", url: "https://github.com/mattgallagher/CwlUtils", version: "d58bb51c9370b0b73adaead17f29f21a863cc126", }, }); // Note: Do Rust licenses first as they have nice names. for (let license of LICENSES_RUST.licenses) { for (let {crate} of license.used_by) { addLicense({ id: license.id, name: license.name, text: license.text, package: { name: crate.name, url: crate.repository ?? `https://crates.io/crates/${crate.name}`, version: crate.version, }, }); } } for (let [pkg, info] of Object.entries(LICENSES_NODE)) { let id = /[a-zA-Z0-9-]+/.exec(info.licenses)[0]; let [name, version] = rpartition(pkg, "@"); addLicense({ id, name: id, text: info.licenseFile ? readFileSync(info.licenseFile).toString() : info.id, package: { name, url: info.repository ?? `https://www.npmjs.com/package/${name}`, version, }, }); } function cmp(l, r) { if (l < r) return -1; if (l > r) return 1; return 0; } out.overview.sort((l, r) => { return cmp(l.id, r.id) || cmp(l.name, r.name) || cmp(l.count, r.count); }) out.licenses.sort((l, r) => { return cmp(l.id, r.id) || cmp(l.name, r.name) || cmp(l.text, r.text); }) console.log(JSON.stringify(out, null, "\t")); ================================================ FILE: contrib/shell/source-die.bash ================================================ # shellcheck shell=bash source contrib/shell/source-echoerr.bash die() { echoerr "$@" exit 1 } ================================================ FILE: contrib/shell/source-echoerr.bash ================================================ # shellcheck shell=bash echoerr() { echo "$@" 1>&2; } ================================================ FILE: contrib/shell/source-nix.sh ================================================ # Use POSIX shell dialect because we can # shellcheck shell=sh # shellcheck source=/dev/null . /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh # shellcheck source=/dev/null . /nix/var/nix/profiles/default/etc/profile.d/nix.sh ================================================ FILE: flake.nix ================================================ { inputs = { crane.url = "github:ipetkov/crane"; flake-utils.url = "github:numtide/flake-utils"; nixpkgs.url = "nixpkgs/nixos-unstable"; rust-overlay.url = "github:oxalica/rust-overlay"; }; outputs = { crane, flake-utils, nixpkgs, rust-overlay, self }: flake-utils.lib.eachDefaultSystem (system: let overlays = [ (import rust-overlay) ]; pkgs = import nixpkgs { inherit overlays system; config = { allowUnfree = true; # sadly, for Android android_sdk.accept_license = true; }; }; lib = pkgs.lib; evaluatedSource = lib.fileset.toSource { root = ./.; fileset = lib.fileset.difference ./. ./tag.json; }; # Extract the hash from the store path. sourceHash = builtins.substring 0 32 (baseNameOf evaluatedSource); tag = builtins.fromJSON (builtins.readFile ./tag.json); commitShort = self.shortRev or self.dirtyShortRev; # Note: We need to check `self.rev` to ensure that a modification of `tag.json` doesn't get marked as clean. Otherwise only the hash matters. isCleanBuild = self ? rev && tag.sourceHash == sourceHash; version = if isCleanBuild then "v${tag.version}" else "v${tag.version}.1-${commitShort}"; hash = pkgs.writeText "obscura-source-hash.txt" sourceHash; androidBuildToolsVersion = "36.0.0"; androidCmakeVersion = "3.31.6"; android = pkgs.androidenv.composeAndroidPackages { toolsVersion = "26.1.1"; # frozen legacy version platformToolsVersion = "36.0.0"; platformVersions = [ "36" ]; buildToolsVersions = [ androidBuildToolsVersion ]; includeEmulator = false; includeSources = false; cmakeVersions = [ androidCmakeVersion ]; includeNDK = true; ndkVersion = "26.3.11579264"; useGoogleAPIs = true; useGoogleTVAddOns = false; includeExtras = [ "extras;google;google_play_services" ]; }; androidBuildTools = "${android.androidsdk}/libexec/android-sdk/build-tools/${androidBuildToolsVersion}"; androidGradleEnv = { ANDROID_HOME = "${android.androidsdk}/libexec/android-sdk"; OBSCURA_VERSION = version; }; androidRustEnv = { ANDROID_NDK_ROOT = "${android.ndk-bundle}/libexec/android-sdk/ndk-bundle"; }; gradleOpts = [ "-Dorg.gradle.project.android.aapt2FromMavenOverride=${androidBuildTools}/aapt2" ]; gradleFlags = gradleOpts ++ [ # Prevents dependency on group-index and SNAPSHOT files: https://github.com/NixOS/nixpkgs/issues/501643 "-xlint" ]; rustToolchain = pkgs.rust-bin.fromRustupToolchainFile ./rustlib/rust-toolchain.toml; craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain; rustDepsArgs = { src = ./rustlib; strictDeps = true; nativeBuildInputs = [ pkgs.cmake ]; }; rustDepsArgs-android = rustDepsArgs // androidRustEnv // { buildInputs = [ android.androidsdk ]; nativeBuildInputs = rustDepsArgs.nativeBuildInputs ++ [ pkgs.cargo-ndk ]; CARGO_BUILD_TARGET = "aarch64-linux-android"; doCheck = false; # TODO: Long-term it is probably better to just configure the environment ourselves using nixpkgs's standard cross-compilation framework. Right now this is a weird state where we are "secretly" cross-compiling. cargoBuildCommand = "cargo ndk -t arm64-v8a build --release"; cargoCheckCommand = "cargo ndk -t arm64-v8a check --release"; }; rustDepsArgs-musl = rustDepsArgs // { nativeBuildInputs = rustDepsArgs.nativeBuildInputs ++ [ pkgs.pkgsCross.musl64.stdenv.cc ]; CARGO_BUILD_TARGET = "x86_64-unknown-linux-musl"; CC_x86_64_unknown_linux_musl = "${pkgs.pkgsCross.musl64.stdenv.cc}/bin/${pkgs.pkgsCross.musl64.stdenv.cc.targetPrefix}cc"; }; rustArgs = rustDepsArgs // { cargoArtifacts = craneLib.buildDepsOnly rustDepsArgs; }; rustArgs-android = rustDepsArgs-android // { cargoArtifacts = craneLib.buildDepsOnly rustDepsArgs-android; }; rustArgs-musl = rustDepsArgs-musl // { cargoArtifacts = craneLib.buildDepsOnly rustDepsArgs-musl; }; rustLibArgs = { # Environment variables for cbindgen, see rustlib/build.rs outputs = [ "out" "dev" ]; # Assumes that crane's derivation only has "out" OBSCURA_CLIENT_RUSTLIB_CBINDGEN_CONFIG_PATH = ./apple/cbindgen-apple.toml; OBSCURA_CLIENT_RUSTLIB_CBINDGEN_OUTPUT_HEADER_PATH = "${placeholder "dev"}/include/libobscuravpn_client.h"; OBSCURA_VERSION = version; }; rust = craneLib.buildPackage (rustArgs // rustLibArgs); rust-android = craneLib.buildPackage (rustArgs-android // rustLibArgs); rust-static = craneLib.buildPackage rustArgs-musl; nodeModules = pkgs.importNpmLock.buildNodeModules { npmRoot = ./obscura-ui; nodejs = pkgs.nodejs; }; nodeDerivation = { name, nativeBuildInputs ? [ ], preBuildPhases ? [ ], ... }@args: pkgs.stdenv.mkDerivation (args // { name = "obscuravpn-client-${name}"; nativeBuildInputs = nativeBuildInputs ++ [ pkgs.nodejs ]; preBuildPhases = [ "preBuildNodeDerivation" ] ++ preBuildPhases; preBuildNodeDerivation = '' ln -s ${nodeModules}/node_modules . export PATH="${nodeModules}/node_modules/.bin/:$PATH" ''; }); licenses = pkgs.runCommand "licenses.json" { nativeBuildInputs = [ pkgs.nodejs ]; LICENSES_NODE = licenses-node; LICENSES_RUST = licenses-rust; } '' node ${contrib/licenses.mjs} >"$out" ''; licenses-node = nodeDerivation { name = "licenses-node.json"; nativeBuildInputs = [ pkgs.pnpm ]; src = lib.fileset.toSource { root = ./obscura-ui; fileset = lib.fileset.unions [ ./obscura-ui/package.json ./obscura-ui/package-lock.json ]; }; buildPhase = '' license-checker \ --start ${nodeModules} \ --onlyAllow '0BSD;Apache-2.0;BSD-2-Clause;BSD-3-Clause;CC0-1.0;CC-BY-3.0;CC-BY-4.0;ISC;MIT;OFL-1.1;Python-2.0' \ --excludePrivatePackages \ --unknown \ --json \ >"$out" ''; }; licenses-rust = craneLib.mkCargoDerivation (rustArgs // { name = "licenses-rust.json"; nativeBuildInputs = [ pkgs.cargo-about ]; src = lib.fileset.toSource { root = ./rustlib; fileset = lib.fileset.unions [ rustlib/about.toml rustlib/Cargo.lock rustlib/Cargo.toml ]; }; buildPhaseCargoCommand = '' mkdir -p src/bin/obscura touch src/bin/obscura/main.rs src/lib.rs cargo-about generate --format=json --fail >"$out" ''; installPhase = " "; }); mkWeb = platform: nodeDerivation { name = "web-${platform}"; src = lib.fileset.toSource { root = ./.; fileset = lib.fileset.unions [ ./apple/client/Assets.xcassets ./obscura-ui ]; }; LICENSE_JSON = licenses; OBS_WEB_PLATFORM = platform; buildPhase = '' pushd obscura-ui npm run build popd ''; installPhase = '' mv obscura-ui/build $out ''; }; web-android = mkWeb "android"; web-ios = mkWeb "iphoneos"; web-macos = mkWeb "macosx"; # https://nixos.org/manual/nixpkgs/stable/#gradle gradleDerivation = { name, task, appOutputs }@args: pkgs.stdenv.mkDerivation (finalAttrs: androidGradleEnv // { name = "obscura-${name}"; src = (lib.fileset.toSource { root = ./android; fileset = lib.fileset.unions [ android/app/build.gradle.kts android/app/google-services.json android/app/proguard-rules.pro android/app/src android/build.gradle.kts android/buildSrc/build.gradle.kts android/buildSrc/settings.gradle.kts android/buildSrc/src android/detekt.yml android/gradle.properties android/gradle/libs.versions.toml android/lib/billing/build.gradle.kts android/lib/billing/src android/lib/util/build.gradle.kts android/lib/util/src android/settings.gradle.kts ]; }); nativeBuildInputs = [ pkgs.gradle ]; mitmCache = pkgs.gradle.fetchDeps { pkg = finalAttrs.finalPackage; data = android/gradle/mitm-cache/deps.json; }; # Accounts for check-only dependencies + tools needed for building an APK/AAB gradleUpdateTask = "check extractReleaseAnnotations"; # This is more robust than `nixDownloadDeps`, and will become the default once a Gradle bug is fixed that's only known to impact one project. # https://github.com/NixOS/nixpkgs/issues/365086 # https://github.com/NixOS/nixpkgs/pull/383115 gradleUpdateScript = '' runHook preBuild gradle ${finalAttrs.gradleUpdateTask} --write-verification-metadata sha256 ''; ANDROID_USER_HOME = "/tmp/"; gradleBuildTask = task; gradleFlags = gradleFlags; patchPhase = '' # TODO: Find a cleaner way to pass these inputs that works during dev as well. ln -sfv ${rust-android}/lib/libobscuravpn_client.so app/src/main/jniLibs/arm64-v8a/ ln -sfv ${web-android} app/src/main/assets/web ''; APP_OUTPUTS = toString (map lib.strings.escapeShellArg appOutputs); installPhase = '' mkdir $out for output in $APP_OUTPUTS; do cp -v app/build/outputs/$output $out/ done ''; doCheck = true; # Checking a specific flavor is impossible: # https://issuetracker.google.com/issues/63810920 gradleCheckTask = "check"; }); apks-foss = gradleDerivation { name = "apks-foss"; task = "assembleFoss"; appOutputs = [ "apk/foss/debug/app-foss-debug.apk" "apk/foss/release/app-foss-release-unsigned.apk" ]; }; apks-play = gradleDerivation { name = "apks-play"; task = "assemblePlay"; appOutputs = [ "apk/play/debug/app-play-debug.apk" "apk/play/release/app-play-release-unsigned.apk" ]; }; aab-play-debug = gradleDerivation { name = "aab-play-debug"; task = "bundlePlayDebug"; appOutputs = [ "bundle/playDebug/app-play-debug.aab" ]; }; aab-play-release = gradleDerivation { name = "aab-play-release"; task = "bundlePlayRelease"; appOutputs = [ "bundle/playRelease/app-play-release.aab" ]; }; nixFiles = lib.sources.sourceFilesBySuffices evaluatedSource [ ".nix" ]; shellFiles = lib.sources.sourceFilesBySuffices evaluatedSource [ ".bash" ".sh" ".shellcheckrc" ]; swiftFiles = lib.sources.sourceFilesBySuffices (lib.fileset.toSource { root = ./.; fileset = lib.fileset.unions [ ./.swiftformat apple/client ]; }) [ ".swift" ".swiftformat" ]; in { apps = { gradle-deps-update = { type = "app"; program = toString apks-foss.mitmCache.updateScript; }; }; checks = { inherit apks-foss aab-play-release hash licenses rust rust-android web-android web-ios web-macos; taplo = pkgs.runCommand "taplo-check" { nativeBuildInputs = [ pkgs.taplo ]; src = lib.sources.cleanSourceWith { src = self; filter = path: type: type == "directory" || lib.hasSuffix ".toml" path; }; } '' cd $src taplo format --check touch $out ''; } // lib.optionalAttrs pkgs.stdenv.isLinux { inherit rust-static; } // { clippy = craneLib.cargoClippy (rustArgs // { cargoClippyExtraArgs = "--all-features --all-targets -- -Dwarnings"; }); shellcheck = pkgs.runCommand "shellcheck" { nativeBuildInputs = [ pkgs.shellcheck ]; } '' shopt -s globstar shellcheck -P ${shellFiles} -- ${shellFiles}/**/*.{bash,sh} touch "$out" ''; rustfmt = craneLib.cargoFmt rustArgs; swiftformat = pkgs.runCommand "swiftformat" { nativeBuildInputs = [ pkgs.swiftformat ]; } '' swiftformat --lint ${swiftFiles} touch "$out" ''; typescript = nodeDerivation { name = "typescript"; src = ./obscura-ui; buildPhase = '' tsc --noEmit touch "$out" ''; }; nixfmt = pkgs.runCommand "nixfmt" { nativeBuildInputs = [ pkgs.nixfmt-classic ]; } '' nixfmt --width=120 --check ${nixFiles} touch "$out" ''; }; devShells = { default = pkgs.mkShellNoCC { packages = [ pkgs.corepack_20 pkgs.gnused pkgs.gradle pkgs.just pkgs.nixfmt-classic pkgs.nodejs_20 pkgs.shellcheck pkgs.swiftformat pkgs.taplo rustToolchain.passthru.availableComponents.rustfmt # Just rustfmt, nothing else ] ++ rustArgs.nativeBuildInputs ++ lib.optionals pkgs.stdenv.isDarwin [ pkgs.create-dmg ]; shellHook = '' export OBSCURA_MAGIC_IN_NIX_SHELL=1 ''; }; web = pkgs.mkShellNoCC { packages = [ pkgs.just pkgs.nodejs_20 pkgs.pnpm ]; # This only changes when our dependencies or license config changes and is relatively slow. # So build it once and cache it. LICENSE_JSON = licenses; }; android = pkgs.mkShellNoCC (androidGradleEnv // androidRustEnv // { buildInputs = [ pkgs.libiconv pkgs.taplo ] ++ rustArgs-android.buildInputs; nativeBuildInputs = [ android.cmake android.emulator android.platform-tools rustToolchain pkgs.firebase-tools pkgs.gradle pkgs.jdk21 pkgs.just pkgs.ninja pkgs.nodejs_20 pkgs.pkg-config pkgs.pnpm ] ++ rustArgs-android.nativeBuildInputs; GRADLE_OPTS = lib.concatStringsSep " " gradleOpts; # Doesn't support spaces. JAVA_HOME = pkgs.jdk21.home; shellHook = '' export PATH="$ANDROID_SDK_ROOT/cmdline-tools/latest/bin:${androidBuildTools}:$PATH" ''; }); }; packages = { inherit apks-foss apks-play aab-play-debug aab-play-release hash licenses licenses-node licenses-rust rust web-android web-ios web-macos; } // lib.optionalAttrs pkgs.stdenv.isLinux { inherit rust-static; }; }); } ================================================ FILE: justfile ================================================ # NOTE: Must be first recipe to be default @_default: just --list @_check-in-obscura-nix-shell: ./contrib/bin/check-in-obscura-nix-shell.bash # fix formatting format-fix: _check-in-obscura-nix-shell cd android && gradle ktfmtFormat swiftformat . cd rustlib && cargo --offline fmt ./contrib/bin/nixfmt-auto-files.bash taplo format # lint checks lint: _check-in-obscura-nix-shell ./contrib/bin/shellcheck-auto-files.bash web-bundle-dir := "./obscura-ui/" web-bundle-build: just "{{web-bundle-dir}}"/build web-bundle-start: just "{{web-bundle-dir}}"/start xcode-open: open -a /Applications/Xcode.app apple/client.xcodeproj # build notarized .dmg in current directory from APP build-dmg: _check-in-obscura-nix-shell ./contrib/bin/build-obscuravpn-dmg.bash ================================================ FILE: linux/arch_Dockerfile ================================================ FROM archlinux:latest RUN pacman -Sy --noconfirm base-devel namcap sudo && \ useradd -m builder && \ echo 'builder ALL=(ALL) NOPASSWD: /usr/bin/cp' >> /etc/sudoers USER builder ================================================ FILE: linux/arch_PKGBUILD ================================================ pkgname=obscura pkgver=0.0.1 pkgrel=1 pkgdesc='Privacy that'\''s more than a promise' arch=('x86_64') license=('LicenseRef-PolyForm-Noncommercial-1.0.0') options=('!debug') package() { install -Dm755 "$srcdir/obscura" "$pkgdir/usr/bin/obscura" install -Dm644 "$srcdir/obscura.service" "$pkgdir/usr/lib/systemd/system/obscura.service" install -Dm644 "$srcdir/obscura-sysusers.conf" "$pkgdir/usr/lib/sysusers.d/obscura.conf" install -Dm644 "$srcdir/LICENSE.md" "$pkgdir/usr/share/licenses/obscura/LICENSE.md" } ================================================ FILE: linux/deb_Dockerfile ================================================ FROM debian:trixie-slim RUN apt-get update && apt-get install -y --no-install-recommends debhelper build-essential lintian ================================================ FILE: linux/deb_control ================================================ Source: obscura Maintainer: obscura authors Build-Depends: debhelper-compat (= 13) Section: net Package: obscura Architecture: amd64 Depends: systemd Description: Obscura VPN client Privacy that's more than a promise. ================================================ FILE: linux/deb_install ================================================ obscura usr/bin ================================================ FILE: linux/deb_rules ================================================ #!/usr/bin/make -f %: dh $@ --with installsysusers ================================================ FILE: linux/obscura-preset.conf ================================================ enable obscura.service ================================================ FILE: linux/obscura-sysusers.conf ================================================ g obscura - - ================================================ FILE: linux/obscura.service ================================================ [Unit] Description=Obscura VPN After=network.target [Service] ExecStart=/usr/bin/obscura service Group=obscura UMask=0007 Restart=on-failure [Install] WantedBy=multi-user.target ================================================ FILE: linux/rpm_Dockerfile ================================================ FROM fedora:43 RUN dnf install -y rpm-build systemd-rpm-macros rpmlint ================================================ FILE: linux/rpm_obscura.spec ================================================ Name: obscura Version: 0.0.1 Release: 1 Summary: Obscura VPN client License: PolyForm-Noncommercial-1.0.0 URL: https://obscura.net %description Privacy that's more than a promise. %install install -Dm755 %{_sourcedir}/obscura %{buildroot}%{_bindir}/obscura install -Dm644 %{_sourcedir}/obscura.service %{buildroot}%{_unitdir}/obscura.service install -Dm644 %{_sourcedir}/obscura-sysusers.conf %{buildroot}%{_sysusersdir}/obscura.conf install -Dm644 %{_sourcedir}/obscura-preset.conf %{buildroot}%{_presetdir}/80-obscura.preset %files %{_bindir}/obscura %{_unitdir}/obscura.service %{_sysusersdir}/obscura.conf %{_presetdir}/80-obscura.preset %pre %sysusers_create_package obscura %{_sourcedir}/obscura-sysusers.conf %post %systemd_post obscura.service if [ $1 -eq 1 ]; then systemctl start obscura.service fi %preun %systemd_preun obscura.service %postun %systemd_postun_with_restart obscura.service %changelog * Thu Jan 01 1970 obscura authors - 0.0.1-1 - Release ================================================ FILE: linux/rpm_rpmlintrc ================================================ addFilter("statically-linked-binary") addFilter("no-manual-page-for-binary") addFilter("no-documentation") addFilter("invalid-license") addFilter("unstripped-binary-or-object") addFilter("name-repeated-in-summary") addFilter("no-changelogname-tag") ================================================ FILE: linux/vm/archlinux-desktop-cloud-init/meta-data ================================================ ================================================ FILE: linux/vm/archlinux-desktop-cloud-init/user-data ================================================ #cloud-config write_files: - path: /config.json owner: root:root permissions: '0644' content: | { "app_config": {}, "archinstall-language": "English", "auth_config": {}, "bootloader_config": { "bootloader": "Grub", "removable": false, "uki": false }, "custom_commands": [], "disk_config": { "btrfs_options": { "snapshot_config": null }, "config_type": "default_layout", "device_modifications": [ { "device": "/dev/vda", "partitions": [ { "btrfs": [], "dev_path": null, "flags": [ "boot" ], "fs_type": "fat32", "mount_options": [], "mountpoint": "/boot", "obj_id": "8bef294b-98c6-40e1-a5c1-1db9e2bafe0a", "size": { "sector_size": { "unit": "B", "value": 512 }, "unit": "GiB", "value": 1 }, "start": { "sector_size": { "unit": "B", "value": 512 }, "unit": "MiB", "value": 1 }, "status": "create", "type": "primary" }, { "btrfs": [], "dev_path": null, "flags": [], "fs_type": "btrfs", "mount_options": [ "compress=zstd" ], "mountpoint": "/", "obj_id": "dc3f91a8-c069-47c8-9ea6-542ec8a09eab", "size": { "sector_size": { "unit": "B", "value": 512 }, "unit": "B", "value": 20400046080 }, "start": { "sector_size": { "unit": "B", "value": 512 }, "unit": "B", "value": 1074790400 }, "status": "create", "type": "primary" } ], "wipe": true } ] }, "hostname": "unassigned-hostname", "kernels": [ "linux" ], "locale_config": { "kb_layout": "us", "sys_enc": "UTF-8", "sys_lang": "en_US.UTF-8" }, "network_config": { "type": "nm" }, "ntp": true, "packages": [ "curl", "net-tools", "firefox" ], "parallel_downloads": 0, "profile_config": { "gfx_driver": "All open-source", "greeter": "gdm", "profile": { "custom_settings": { "GNOME": {} }, "details": [ "GNOME" ], "main": "Desktop" } }, "script": null, "services": [], "swap": false, "timezone": "UTC", "version": "3.0.14" } - path: /creds.json owner: root:root permissions: '0644' content: | { "users": [ { "enc_password": "$y$j9T$LBDvFqutcqkzsap08YCvv/$e4rn0cAHlrz/Bl1IL3ED5t4fmebsi6L68C.9pHo2rQC", "groups": [], "sudo": true, "username": "user" } ] } - path: /etc/systemd/system/auto-install.service owner: root:root permissions: '0644' content: | [Unit] After=network-online.target Wants=network-online.target Conflicts=getty@tty1.service Before=getty@tty1.service [Service] Type=oneshot TTYPath=/dev/tty1 StandardOutput=tty Environment=TERM=dumb Environment=LANG=C ExecStart=/usr/bin/archinstall --config /config.json --creds /creds.json --silent ExecStartPost=/usr/bin/arch-chroot /mnt /bin/bash -c "systemctl enable sshd" ExecStartPost=/usr/bin/arch-chroot /mnt /bin/bash -c "systemctl enable NetworkManager" ExecStartPost=/usr/bin/arch-chroot /mnt /bin/bash -c "echo 'user ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/nopasswd && chmod 0440 /etc/sudoers.d/nopasswd" ExecStartPost=/usr/bin/poweroff [Install] WantedBy=multi-user.target runcmd: - systemctl daemon-reload - systemctl enable --now --no-block auto-install.service ================================================ FILE: linux/vm/debian-desktop.preseed.cfg ================================================ d-i debian-installer/locale string en_US.UTF-8 d-i keyboard-configuration/xkb-keymap select us d-i netcfg/choose_interface select auto d-i netcfg/get_hostname string unassigned-hostname d-i netcfg/get_domain string unassigned-domain d-i mirror/country string manual d-i mirror/http/hostname string deb.debian.org d-i mirror/http/directory string /debian d-i mirror/http/proxy string d-i passwd/root-login boolean false d-i passwd/user-fullname string User d-i passwd/username string user d-i passwd/user-password password pw d-i passwd/user-password-again password pw d-i user-setup/allow-password-weak boolean true d-i clock-setup/utc boolean true d-i time/zone string UTC d-i clock-setup/ntp boolean true d-i partman-auto/method string regular d-i partman-auto/choose_recipe select atomic d-i partman-partitioning/confirm_write_new_label boolean true d-i partman/choose_partition select finish d-i partman/confirm boolean true d-i partman/confirm_nooverwrite boolean true tasksel tasksel/first multiselect standard, gnome-desktop d-i pkgsel/include string openssh-server curl net-tools d-i grub-installer/only_debian boolean true d-i grub-installer/bootdev string default d-i preseed/late_command string \ echo 'user ALL=(ALL:ALL) NOPASSWD: ALL' > /target/etc/sudoers.d/user; \ chmod 440 /target/etc/sudoers.d/user; \ printf "[logging]\ndomains=ALL:DEBUG\n" > /target/etc/NetworkManager/conf.d/95-nm-debug.conf d-i finish-install/reboot_in_progress note d-i debian-installer/exit/poweroff boolean true ================================================ FILE: linux/vm/fedora43-desktop.ks ================================================ url --mirrorlist=http://mirrors.fedoraproject.org/mirrorlist?repo=fedora-43&arch=x86_64 text lang en_US.UTF-8 keyboard us timezone UTC network --bootproto=dhcp --activate rootpw pw --plaintext user --name=user --password=pw --plaintext --groups=wheel services --enabled=sshd clearpart --all --initlabel autopart reboot %packages @^workstation-product-environment curl net-tools # Contains ifconfig and route openssh-server %end %post echo 'user ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/user chmod 0440 /etc/sudoers.d/user %end ================================================ FILE: linux/vm/ubuntu24.04-desktop-cloud-init/meta-data ================================================ ================================================ FILE: linux/vm/ubuntu24.04-desktop-cloud-init/user-data ================================================ #cloud-config autoinstall: version: 1 shutdown: poweroff identity: hostname: unassigned-hostname password: '$6$.3M3jqAKFfWngAem$XK/l4Mepy7Poe1Re8gmDzN3gSgdu/mGIs5slQKc909CEEZHaXpBhNf9kF5QXmdfnf50CM0MXSiaahx8VUnFHW1' realname: user username: user locale: en_US.UTF-8 keyboard: {layout: us} ssh: install-server: true allow-pw: true storage: layout: name: direct packages: - net-tools - curl late-commands: - "echo 'user ALL=(ALL) NOPASSWD:ALL' > /target/etc/sudoers.d/user-nopasswd" - "chmod 440 /target/etc/sudoers.d/user-nopasswd" ================================================ FILE: obscura-ui/.gitattributes ================================================ * text=auto eol=lf ================================================ FILE: obscura-ui/.gitignore ================================================ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies /node_modules /.pnp .pnp.js # testing /coverage # production /build /build-pyi /dist vite.config.js.timestamp-* vite.config.ts.timestamp-* # misc .DS_Store .env.local .env.development.local .env.test.local .env.production.local /__pycache__ npm-debug.log* yarn-debug.log* yarn-error.log* stats.html # icos /src-tauri/icons/SystemTray*/ ================================================ FILE: obscura-ui/.vscode/settings.json ================================================ { "cSpell.words": [ "mantine" ] } ================================================ FILE: obscura-ui/README.md ================================================ # App UI for Obscura VPN ## Libraries - [React Icons](https://react-icons.github.io/react-icons) - [Mantine Docs](https://mantine.dev/pages/basics/) - [Mantine Default Theme](https://github.com/mantinedev/mantine/blob/master/src/mantine-styles/src/theme/default-theme.ts) - [react-18next Trans Component](https://react.i18next.com/latest/trans-component) ## Tips and Trouble Shooting - Broken npm sub-dependency? Use `resolutions: {subDependency: version}` - Use `pnpm upgrade --interactive` to upgrade package interactively - use `npm install --package-lock-only` to update `package-lock.json` which is used to generate the license.json used by the UI ### Media Queries For adding new mobile styles, you can do the following ```css @media screen and (max-width: $mantine-breakpoint-xs) { padding-top: env(safe-area-inset-top) !important; } ``` ================================================ FILE: obscura-ui/index.html ================================================ Obscura VPN
================================================ FILE: obscura-ui/justfile ================================================ setup: pnpm install --frozen-lockfile build: setup pnpm run build start: setup pnpm start ================================================ FILE: obscura-ui/package.json ================================================ { "name": "obscura-vpn", "version": "0.2.3", "type": "module", "private": true, "dependencies": { "@fontsource/open-sans": "^5.2.7", "@mantine/core": "^7.17.8", "@mantine/hooks": "^7.17.8", "@mantine/modals": "^7.17.8", "@mantine/notifications": "^7.17.8", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", "countries-list": "^3.2.0", "framer-motion": "^12.23.24", "i18next": "^25.6.0", "i18next-browser-languagedetector": "^8.2.0", "js-cookie": "^3.0.5", "localforage": "^1.10.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-error-boundary": "^6.0.0", "react-i18next": "^16.2.4", "react-icons": "^5.5.0", "react-router-dom": "^7.9.5" }, "scripts": { "start": "vite --strictPort", "build": "vite build", "preview": "vite preview", "serve": "vite preview", "typecheck": "tsc --noEmit" }, "eslintConfig": { "extends": [ "react-app", "react-app/jest" ] }, "devDependencies": { "@types/js-cookie": "^3.0.6", "@types/node": "^20.19.24", "@vitejs/plugin-react": "^5.1.0", "@welldone-software/why-did-you-render": "^10.0.1", "license-checker": "^25.0.1", "postcss": "^8.5.6", "postcss-preset-mantine": "^1.18.0", "postcss-simple-vars": "^7.0.1", "rollup-plugin-visualizer": "^6.0.5", "terser": "^5.44.1", "toml": "^3.0.0", "typescript": "^5.9.3", "vite": "^7.2.0", "vite-plugin-svgr": "^4.5.0" }, "resolutions": {}, "packageManager": "pnpm@9.0.6+sha1.648f6014eb363abb36618f2ba59282a9eeb3e879" } ================================================ FILE: obscura-ui/postcss.config.js ================================================ export default { plugins: { 'postcss-preset-mantine': {}, 'postcss-simple-vars': { variables: { 'mantine-breakpoint-xs': '36em', 'mantine-breakpoint-sm': '48em', 'mantine-breakpoint-md': '62em', 'mantine-breakpoint-lg': '75em', 'mantine-breakpoint-xl': '88em', }, }, }, }; ================================================ FILE: obscura-ui/src/App.module.css ================================================ .navLink { display: block; width: 100%; padding: var(--mantine-spacing-xs); border-radius: var(--mantine-radius-md); color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0)); text-decoration: none; will-change: transform; } .navLink:hover:active { transform: translateY(2px); } .navLinkActive { background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5)); } .navLinkInactive:hover { background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6)); } .header { display: flex; align-items: center; height: 100%; } .headerRightItems { margin-left: auto; } body { background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8)); } .mediaQuery { display: none; } .footer { display: flex; justify-content: space-between; align-items: center; } ================================================ FILE: obscura-ui/src/App.tsx ================================================ import { AppShell, AppShellMain } from '@mantine/core'; import { useHotkeys, useThrottledValue } from '@mantine/hooks'; import { notifications } from '@mantine/notifications'; import { ReactNode, useContext, useEffect, useRef, useState } from 'react'; import { ErrorBoundary } from 'react-error-boundary'; import { useTranslation } from 'react-i18next'; import { Navigate, Route, Routes, useNavigate } from 'react-router-dom'; import classes from './App.module.css'; import * as commands from './bridge/commands'; import { IS_HANDHELD_DEVICE, logReactError, PLATFORM, Platform, useSystemChecks } from './bridge/SystemProvider'; import { AppContext, AppStatus, ConnectionInProgress, connectionIsIdle, NEVPNStatus, OsStatus } from './common/appContext'; import { fmt } from './common/fmt'; import { NotificationId } from './common/notifIds'; import { useAsync } from './common/useAsync'; import { useLoadable } from './common/useLoadable'; import { MIN_LOAD_MS, normalizeError } from './common/utils'; import { CColorSchemeContext } from './components/CachedColorScheme'; import { ScrollableView } from './components/ScrollableView'; import { VpnError } from './components/VpnErrorFmt'; import { About, Account, Connection, DeveloperView, FallbackAppRender, Help, Location, LogIn, Settings, SplashScreen } from './views'; // imported views need to be added to the `views` list variable interface View { component: () => ReactNode, path: string, exact?: boolean, needsScroll: boolean, } export default function () { const { t } = useTranslation(); const navigate = useNavigate(); const colorScheme = useContext(CColorSchemeContext); const toggleColorScheme = async () => { const newColorScheme = colorScheme === 'dark' ? 'light' : 'dark'; try { await commands.setColorScheme(newColorScheme); } catch (e) { console.error('Failed to set theme:', e); } }; useSystemChecks(); useHotkeys([[PLATFORM === Platform.macOS ? 'mod+J' : 'ctrl+J', toggleColorScheme]]); // App State const [vpnConnected, setVpnConnected] = useState(false); // keep track of how the connection was initiated to show correct transitioning UI const [initiatingExitSelector, setInitiatingExitSelector] = useState(); const [connectionInProgress, setConnectionInProgress] = useState(ConnectionInProgress.UNSET); const [appStatus, setStatus] = useState(null); const [osStatus, setOsStatus] = useState(null); const [isProcessingPayment, setPaymentProcessing] = useState(false); const ignoreConnectingErrors = useRef(false); const views: View[] = [ { component: Connection, path: '/connection', needsScroll: false }, { component: DeveloperView, path: '/developer', needsScroll: true }, { component: Location, path: '/location', needsScroll: true }, { component: Account, path: '/account', needsScroll: false }, { component: Help, path: '/help', needsScroll: false }, { component: About, path: '/about', needsScroll: false }, { component: Settings, path: '/settings', needsScroll: true }, ]; const isLoggedIn = !!appStatus?.accountId; const showAccountCreation = appStatus?.inNewAccountFlow; const loading = appStatus === null || osStatus === null; async function tryConnect(exit: commands.ExitSelector) { setInitiatingExitSelector(exit); if (vpnConnected) { setConnectionInProgress(ConnectionInProgress.ChangingLocations); } else { setConnectionInProgress(ConnectionInProgress.Connecting); } ignoreConnectingErrors.current = false; try { await commands.connect(exit); } catch (e) { const error = normalizeError(e); if (error.message === 'accountExpired') { void pollAccount(); } if (!ignoreConnectingErrors.current && error.message !== 'tunnelNotDisconnected') { notifications.hide(NotificationId.VPN_ERROR); notifications.show({ title: t('Error Connecting'), message: , color: 'red', id: NotificationId.VPN_ERROR, autoClose: false }); // see https://linear.app/soveng/issue/OBS-775/not-starting-tunnel-because-it-isnt-disconnected-connecting#comment-e98a7150 setConnectionInProgress(ConnectionInProgress.UNSET); } } } async function disconnectFromVpn() { ignoreConnectingErrors.current = true; setConnectionInProgress(ConnectionInProgress.Disconnecting); setVpnConnected(false); await commands.disconnect(); } function notifyVpnError(errorEnum: string) { // see enum JsVpnError in commands.swift if (errorEnum !== null) { notifications.hide(NotificationId.VPN_ERROR); notifications.show({ id: NotificationId.VPN_ERROR, withCloseButton: true, color: 'red', title: t('Error'), message: , autoClose: 15_000 }); } } function handleNewStatus(newStatus: AppStatus) { const vpnStatus = newStatus.vpnStatus; if (vpnStatus === undefined) return; if (vpnStatus.connected !== undefined) { setVpnConnected(true); setConnectionInProgress(ConnectionInProgress.UNSET); notifications.hide(NotificationId.VPN_ERROR); notifications.update({ id: NotificationId.VPN_DISCONNECT_CONNECT, message: undefined, color: 'green', autoClose: 1000 }); } else if (vpnStatus.connecting !== undefined) { setVpnConnected(false); const reconnecting = vpnStatus.connecting.reconnecting; setConnectionInProgress(value => { if (reconnecting) return ConnectionInProgress.Reconnecting; if (value === ConnectionInProgress.ChangingLocations) return value; return ConnectionInProgress.Connecting; }); const connectError = vpnStatus.connecting?.connectError; if (connectError !== undefined) { if (reconnecting) { console.error(`got error while reconnecting: ${connectError}`); } else { console.error(`got error while connecting: ${connectError}`); } console.log(fmt`vpnStatus = ${vpnStatus}`); notifyVpnError(connectError); } } } // this code fetches the status of the VPN continuously // getting the status is blocking and takes an ID such that if non-null, only new statuses will be returned useEffect(() => { let knownStatusId = null; let keepAlive = true; (async () => { while (keepAlive) { try { let newStatus = await commands.status(knownStatusId); knownStatusId = newStatus.version; setStatus(newStatus); } catch (error) { const e = normalizeError(error); console.error('command status failed', e.message); notifications.show({ title: t('errorFetchingStatus'), message: e.message, color: 'red' }); } } })(); return () => { keepAlive = false; }; }, []); useEffect(() => { let knownOsStatusId = null; let keepAlive = true; (async () => { while (keepAlive) { try { let newOsStatus = await commands.osStatus(knownOsStatusId); knownOsStatusId = newOsStatus.version; setOsStatus(newOsStatus); } catch (error) { const e = normalizeError(error); console.error('command osStatus failed', e.message); notifications.show({ title: t('errorFetchingOsStatus'), message: e.message, color: 'red' }); } } })(); return () => { keepAlive = false; }; }, []); useEffect(() => { if (appStatus !== null) handleNewStatus(appStatus); }, [appStatus]); useEffect(() => { if (osStatus !== null) { const { osVpnStatus } = osStatus; switch (osVpnStatus) { case NEVPNStatus.Disconnecting: setConnectionInProgress(ConnectionInProgress.Disconnecting); break; case NEVPNStatus.Disconnected: setConnectionInProgress(ConnectionInProgress.UNSET); setVpnConnected(false); setInitiatingExitSelector(undefined); break; case NEVPNStatus.Connected: setInitiatingExitSelector(undefined); break; } } }, [osStatus]); function resetState() { if (window.location.pathname === '/connection') { window.location.pathname = '/help'; } else { window.location.pathname = '/'; } } // native driven navigation useEffect(() => { const onNavUpdate = (e: Event) => { if (e instanceof CustomEvent) { navigate(`/${e.detail}`); } else { console.error('expected custom event for navigation purposes, got generic Event'); } }; window.addEventListener('navUpdate', onNavUpdate); return () => window.removeEventListener('navUpdate', onNavUpdate); }, []); const onPaymentSucceeded = () => { console.log("handling paymentSucceeded event"); void pollAccount(); commands.setInNewAccountFlow(false); } // deep link payment succeeded useEffect(() => { window.addEventListener('paymentSucceeded', onPaymentSucceeded); return () => window.removeEventListener('paymentSucceeded', onPaymentSucceeded); }, []); const { lastSuccessfulValue: accountInfo, error: accountInfoError, refresh: pollAccount, loading: accountLoading } = useLoadable({ skip: !osStatus?.internetAvailable || !isLoggedIn, load: commands.getAccount, periodMs: isProcessingPayment ? 3000 : (showAccountCreation ? 60 * 1000 : 12 * 3600 * 1000), returnError: true, }); const accountLoadingDelayed = useThrottledValue(accountLoading, accountLoading ? MIN_LOAD_MS : 0); useEffect(() => { if (isProcessingPayment && accountInfo?.active) { setPaymentProcessing(false); commands.setInNewAccountFlow(false); commands.resetOfferCodeRedemptionSuccess(); } }, [accountInfo, isProcessingPayment]); useEffect(() => { if (accountInfoError) { console.error("Failed to fetch account info", accountInfoError); // We just ignore errors, they will be shown if the user goes to the account page. } }, [accountInfoError]); const _ = useAsync({ skip: osStatus === null || (!osStatus.internetAvailable || IS_HANDHELD_DEVICE), load: commands.checkForUpdates, returnError: true, }); if (loading) return ; const appContext = { accountInfo: accountInfo ?? null, appStatus, connectionInProgress, osStatus, pollAccount, showOfflineUI: !osStatus.internetAvailable && connectionIsIdle(connectionInProgress, appStatus.vpnStatus, osStatus.osVpnStatus), accountLoading: accountLoadingDelayed, vpnConnect: tryConnect, vpnConnected, vpnDisconnect: disconnectFromVpn, initiatingExitSelector, isProcessingPayment, setPaymentProcessing } if (!isLoggedIn || showAccountCreation) { return ( ); } return <> resetState()} onError={logReactError}> {views[0] !== undefined && } />} {views.map((view, index) => } />)} ; } function RenderView({ view }: { view: View }) { return ( view.needsScroll ? : ); } ================================================ FILE: obscura-ui/src/Providers.tsx ================================================ import '@fontsource/open-sans'; import { PropsWithChildren } from 'react'; import { MemoryRouter } from 'react-router-dom'; import Mantine from './components/Mantine'; export default function ({ children }: PropsWithChildren) { return <> {/* Cannot use Browser router for loading from file */} {children} ; } ================================================ FILE: obscura-ui/src/bridge/SystemProvider.tsx ================================================ import { ErrorInfo, useEffect } from 'react'; export const PLATFORM = import.meta.env.OBS_WEB_PLATFORM as Platform; // Update translation files whenever Platform is updated export enum Platform { macOS = 'macosx', iOS = 'iphoneos', Android = 'android', } export function systemName(): string { switch (PLATFORM) { case Platform.macOS: return "macOS"; case Platform.iOS: return "iOS"; case Platform.Android: return "Android"; } } export const IS_HANDHELD_DEVICE = PLATFORM === Platform.iOS || PLATFORM === Platform.Android; const platformDefined = Object.values(Platform).includes(PLATFORM); // TODO: Can we remove iOS by preventing it from failing early? // https://linear.app/soveng/issue/OBS-3164/improve-feedback-during-connecting-state export const CONNECT_REQUIRES_ONLINE = PLATFORM === Platform.iOS || PLATFORM === Platform.macOS; export function useSystemChecks() { useEffect(() => { if (!platformDefined) { const errMsg = `OBS_WEB_PLATFORM was unexpected, got "${PLATFORM}"`; throw new Error(errMsg); } }, [platformDefined]); } export async function logReactError(error: Error, info: ErrorInfo) { console.error(`Render error "${error.message}"; ComponentStack = ${info.componentStack}`); } ================================================ FILE: obscura-ui/src/bridge/android.ts ================================================ import { PLATFORM, Platform } from "./SystemProvider"; if (PLATFORM === Platform.Android) { const MESSAGE_PREFIX = "android/"; const NAVIGATE_PREFIX = "android-navigate/"; let counter = 0; const acceptFns = new Map void>(); const rejectFns = new Map void>(); window.addEventListener("message", (event) => { if (typeof event.data !== "string") { return; } if ( event.data.startsWith(MESSAGE_PREFIX) ) { const message: { id: number; error?: string; data?: string } = JSON.parse( event.data.substring(MESSAGE_PREFIX.length), ); if (typeof message.error === "string") { const reject = rejectFns.get(message.id); if (reject) { reject(message.error); } } else if (typeof message.data === "string") { const accept = acceptFns.get(message.id); if (accept) { accept(message.data); } } } else if (event.data.startsWith(NAVIGATE_PREFIX)) { window.dispatchEvent(new CustomEvent('navUpdate', { detail: event.data.substring(NAVIGATE_PREFIX.length), })); } }); Object.defineProperty(window, "webkit", { writable: false, enumerable: false, configurable: false, value: Object.freeze({ messageHandlers: Object.freeze({ commandBridge: Object.freeze({ postMessage: (data: string) => new Promise((accept, reject) => { const id = (counter += 1); const cleanup = () => { acceptFns.delete(id); rejectFns.delete(id); }; acceptFns.set(id, (value) => { cleanup(); accept(value); }); rejectFns.set(id, (error) => { cleanup(); reject(new Error(error)); }); // obscuraAndroidCommandBridge is defined by the Android WebView (window as any).obscuraAndroidCommandBridge.invoke(data, id); }), }), }), }), }); } ================================================ FILE: obscura-ui/src/bridge/commands.ts ================================================ import { useThrottledValue } from '@mantine/hooks'; import { notifications } from '@mantine/notifications'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { AccountId } from '../common/accountUtils'; import { AccountInfo, Exit } from '../common/api'; import { AppStatus, DNSContentBlock, FeatureFlagKey, OsStatus, PinnedLocation, SubscriptionProductModel } from '../common/appContext'; import { normalizeError } from '../common/utils'; import { fmtErrorI18n } from '../translations/i18n'; import { Platform, PLATFORM } from './SystemProvider'; import './android'; async function WKWebViewInvoke(command: string, args: Object) { const commandJson = JSON.stringify({ [command]: args }); if (command !== 'jsonFfiCmd') { console.log("invoked non-FFI command", command); } let resultJson; try { resultJson = await window.webkit.messageHandlers.commandBridge.postMessage(commandJson); } catch (e) { throw new CommandError(normalizeError(e).message); } return JSON.parse(resultJson); } async function invoke(command: string, args: Object = {}): Promise { // all commands are logged for wkwebview according to ContentView.swift try { return await WKWebViewInvoke(command, args); } catch (e) { console.error("Command failed", command, args, e); throw e; } } export class CommandError extends Error { code: string constructor(code: string) { // HACK: We should put some "human readable" message into the message field but lots of code currently just hopes to find specific error codes in the message field. So until we hunt down all of those just put the code in the message as well. Don't write new code that treats `message` as machine readable. super(code); this.code = code; } i18nKey() { return `ipcError-${this.code}`; } } // VPN Client Specific Commands export async function jsonFfiCmd(cmd: string, arg = {}, timeoutMs: number | null = 10_000): Promise { let jsonCmd = JSON.stringify(({ [cmd]: arg })); console.log("invoked FFI command", cmd); return await invoke('jsonFfiCmd', { cmd: jsonCmd, timeoutMs, }) } export async function status(lastStatusId: string | null = null): Promise { return await jsonFfiCmd( 'getStatus', { knownVersion: lastStatusId }, null, ) as AppStatus; } export async function osStatus(lastOsStatusId: string | null = null): Promise { return await invoke('getOsStatus', { knownVersion: lastOsStatusId }) as OsStatus; } export function login(accountId: AccountId, validate = false) { return jsonFfiCmd('login', { accountId, validate }); } export function logout() { return jsonFfiCmd('logout'); } export async function setApiUrl(url: string | null): Promise { await jsonFfiCmd("setApiUrl", { url }); } export async function setApiHostAlternate(host: string | null): Promise { await jsonFfiCmd('setApiHostAlternate', { host }); } export async function setSniRelay(host: string | null): Promise { await jsonFfiCmd('setSniRelay', { host }); } export async function setStrictLeakPrevention(enable: boolean): Promise { await invoke('setStrictLeakPrevention', { enable }); } export async function setColorScheme(value: 'dark' | 'light' | 'auto'): Promise { await invoke('setColorScheme', { value }); } // See ../../../rustlib/src/manager.rs export interface TunnelArgs { exit: ExitSelector, } export interface ExitSelectorId { id: string; } export interface ExitSelectorCity { country_code: string, city_code: string, } export interface ExitSelectorCountry { country_code: string, } // See ../../../rustlib/src/manager.rs export type ExitSelector = | { any: {} } | { exit: ExitSelectorId } | { city: ExitSelectorCity } | { country: ExitSelectorCountry } ; export async function connect(exit: ExitSelector): Promise { let args: TunnelArgs = { exit, }; await invoke('startTunnel', { tunnelArgs: JSON.stringify(args), }); } export async function disconnect(): Promise { await invoke('stopTunnel'); } export async function debuggingArchive(userFeedback: string): Promise { return (await invoke('debuggingArchive', { userFeedback })) as String; } export function revealItemInDir(path: String) { return invoke('revealItemInDir', { path }); } export async function emailDebugArchive(path: String, subject: String, body: String): Promise { await invoke('emailDebugArchive', { path, subject, body }); } // trigger native share dialog export async function shareDebugArchive(path: String): Promise { await invoke('shareDebugArchive', { path }); } export interface Notice { type: 'Error' | 'Warn' | 'Important', content: string } export async function registerAsLoginItem(): Promise { await invoke('registerAsLoginItem'); } export async function unregisterAsLoginItem(): Promise { await invoke('unregisterAsLoginItem'); } export async function developerResetUserDefaults(): Promise { await invoke('resetUserDefaults'); } export async function checkForUpdates(): Promise { await invoke('checkForUpdates'); } export async function installUpdate(): Promise { await invoke('installUpdate'); } export interface TrafficStats { connectedMs: number, connId: string, txBytes: number, rxBytes: number, latestLatencyMs: number, } export async function getTrafficStats(): Promise { return await jsonFfiCmd('getTrafficStats') as TrafficStats; } export interface CachedValue { version: string, last_updated: number, value: T, } export interface ExitList { exits: Exit[] } export async function getExitList(version?: string): Promise> { return await jsonFfiCmd( 'getExitList', { knownVersion: version }, null ) as CachedValue; } export async function refreshExitList(freshnessS: number): Promise { await jsonFfiCmd('refreshExitList', { freshness: freshnessS * 1000, }); } export async function deleteAccount(): Promise { await jsonFfiCmd('apiDeleteAccount'); } export async function getAccount(): Promise { /* see obscuravpn-api/src/types.rs:AccountInfo */ return await jsonFfiCmd('apiGetAccountInfo') as AccountInfo; } export function setInNewAccountFlow(value: boolean) { return jsonFfiCmd('setInNewAccountFlow', { value }); } export function setPinnedExits(newPinnedExits: PinnedLocation[]) { return jsonFfiCmd('setPinnedExits', { exits: newPinnedExits }); } export function rotateWgKey() { return jsonFfiCmd('rotateWgKey'); } export function setAutoConnect(enable: boolean) { return jsonFfiCmd('setAutoConnect', { enable }); } export function setUseSystemDns(enable: boolean) { return jsonFfiCmd('setUseSystemDns', { enable }); } export async function setFeatureFlag(flag: FeatureFlagKey, active: boolean) { await jsonFfiCmd('setFeatureFlag', { flag, active }); } export async function setDnsContentBlock(value: DNSContentBlock): Promise { await jsonFfiCmd('setDnsContentBlock', { value }); } export async function getSubscriptionProductDisplay(): Promise { return await invoke('getSubscriptionProduct') as SubscriptionProductModel; } export async function storeKitAssociateAccount(): Promise { await invoke('associateAccount'); } export async function storeKitPurchaseSubscription(): Promise { return await invoke('purchaseSubscription', {}) as boolean; } export async function storeKitRestorePurchases(): Promise { await invoke('restorePurchases', {}); } export async function showOfferCodeRedemption(): Promise { await invoke('showOfferCodeRedemption'); } export async function resetOfferCodeRedemptionSuccess(): Promise { if (PLATFORM === Platform.iOS) { await invoke('resetOfferCodeRedemptionSuccess'); } } export async function playPurchaseSubscription(): Promise { return await invoke('purchaseSubscription', {}) as boolean; } export interface UseCommandOptions { command: (...args: CommandArgs) => Promise; /** Whether to show a notification on error. Default: false */ showNotification?: boolean; /** Whether to re-throw the error after handling. Default: false */ rethrow?: boolean; } /** * Hook for calling non-return value bridge commands with loading and error state management. * * @returns Object containing: * - loading: boolean indicating if command is in progress * - showLoadingUI: boolean indicating whether caller should show a throttled loading UI * - error: string with error message if command failed */ export function useCommand({ command, showNotification = false, rethrow = false }: UseCommandOptions) { const [loading, setLoading] = useState(false); const [error, setError] = useState(); const { t } = useTranslation(); const showLoadingUI = useThrottledValue(loading, loading ? 200 : 0); const execute = async (...args: CommandArgs) => { if (loading) return; setLoading(true); setError(undefined); try { await command(...args); } catch (err) { const error = normalizeError(err); const message = error instanceof CommandError ? fmtErrorI18n(t, error) : error.message; setError(message); if (showNotification) { notifications.show({ color: 'red', title: t('Error'), message }); } if (rethrow) { throw error; } } finally { setLoading(false); } }; return { loading, showLoadingUI, error, execute }; } ================================================ FILE: obscura-ui/src/common/KeyedSet.ts ================================================ export class KeyedSet { #key: (v: L) => K; #map = new Map; constructor( key: (v: L) => K, entries?: Iterable, ) { this.#key = key; if (entries) { this.extend(entries); } } [Symbol.iterator](): Iterator { return this.#map.values(); } /// Add an item to the set. /// /// Always updates the stored item to the new value. add(v: V): V | undefined { let k = this.#key(v); let existing = this.#map.get(k); // Note: Skip second lookup in common case where value is not undefined. if (existing || this.#map.has(k)) { return existing; } this.#map.set(k, v); } extend(values: Iterable) { for (let v of values) { this.add(v); } } get(v: L): V | undefined { return this.getKey(this.#key(v)); } getKey(k: K): V | undefined { return this.#map.get(k); } has(v: L): boolean { return this.hasKey(this.#key(v)); } hasKey(k: K): boolean { return this.#map.has(k); } get size(): number { return this.#map.size } } ================================================ FILE: obscura-ui/src/common/accountUtils.ts ================================================ import { err } from "./fmt"; const D = [ [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], [1, 2, 3, 4, 0, 6, 7, 8, 9, 5], [2, 3, 4, 0, 1, 7, 8, 9, 5, 6], [3, 4, 0, 1, 2, 8, 9, 5, 6, 7], [4, 0, 1, 2, 3, 9, 5, 6, 7, 8], [5, 9, 8, 7, 6, 0, 4, 3, 2, 1], [6, 5, 9, 8, 7, 1, 0, 4, 3, 2], [7, 6, 5, 9, 8, 2, 1, 0, 4, 3], [8, 7, 6, 5, 9, 3, 2, 1, 0, 4], [9, 8, 7, 6, 5, 4, 3, 2, 1, 0] ]; const P = [ [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], [1, 5, 7, 6, 2, 8, 3, 0, 9, 4], [5, 8, 0, 3, 7, 9, 6, 1, 4, 2], [8, 9, 1, 6, 0, 4, 3, 5, 2, 7], [9, 4, 5, 3, 1, 2, 6, 8, 7, 0], [4, 2, 8, 6, 5, 7, 3, 9, 0, 1], [2, 7, 9, 3, 8, 0, 6, 4, 1, 5], [7, 0, 4, 6, 9, 1, 3, 2, 5, 8] ]; const INVERSE = [0, 4, 3, 2, 1, 5, 6, 7, 8, 9]; function rawChecksum(digits: string): number { return digits.split("").reduceRight((acc, char, i) => { let index = P[(digits.length - 1 - i) % 8]![+char]; if (index === undefined) { throw err`Invalid digit ${char}`; } return D[acc]![index]!; }, 0); } export function checkDigit(digits: string): number { return INVERSE[rawChecksum(`${digits}0`)]!; } export function validChecksum(digits: string): boolean { return rawChecksum(digits) === 0; } const ACCOUNT_ID_LENGTH = 19; const MAX_ID = 10n ** BigInt(ACCOUNT_ID_LENGTH); const USER_ACCOUNT_NUMBER_LEN = ACCOUNT_ID_LENGTH + 1; const ACCOUNT_ID_DISPLAY_CHUNK_SIZE = 4; const ACCOUNT_ID_CHUNK_RE = new RegExp(`.{${ACCOUNT_ID_DISPLAY_CHUNK_SIZE}}(?=.)`, "g"); function generateAccountId(): BigInt { let rand = new BigUint64Array(1); while (1) { window.crypto.getRandomValues(rand); let n = rand[0]!; if (n < MAX_ID) { return n; } } throw err`unreachable`; } export function generateAccountNumber(): AccountId { const accountID = generateAccountId().toString().padStart(ACCOUNT_ID_LENGTH, '0'); return accountID + String(checkDigit(accountID)) as any as AccountId; } export interface AccountId { readonly Type: unique symbol }; /// The raw formatting of an account ID. export function accountIdToString(id: AccountId): string { return id as any as string; } const enum ObscuraAccountErrorCode { TOO_SHORT = "tooShort", TOO_LONG = "tooLong", INVALID_CHECKSUM = "invalidChecksum", }; export class ObscuraAccountIdError extends Error { public readonly code: string; constructor(code: ObscuraAccountErrorCode, message: string) { super(message); this.name = 'ObscuraAccountError'; this.code = code; } i18nKey() { return `accountIdError-${this.code}`; } } /// Parse a strictly integer account ID. export function parseAccountIdInt(id: string): AccountId { if (id.length < USER_ACCOUNT_NUMBER_LEN) { throw new ObscuraAccountIdError(ObscuraAccountErrorCode.TOO_SHORT, "Account ID is too short."); } if (id.length > USER_ACCOUNT_NUMBER_LEN) { throw new ObscuraAccountIdError(ObscuraAccountErrorCode.TOO_LONG, "Account ID is too long."); } if (!validChecksum(id)) { throw new ObscuraAccountIdError(ObscuraAccountErrorCode.INVALID_CHECKSUM, "Mistyped Account ID."); } return id as any as AccountId; } export function parseAccountIdInput(input: string): AccountId { return parseAccountIdInt(normalizeAccountIdInput(input)); } function normalizeAccountIdInput(id: string): string { return id.replace(/[^\d]/g, ""); } export function formatPartialAccountId(accountId: string): string { accountId = normalizeAccountIdInput(accountId); if (accountId.length >= USER_ACCOUNT_NUMBER_LEN) { return `${accountId.slice(0, 4)} - ${accountId.slice(4, 8)} - ${accountId.slice(8, 12)} - ${accountId.slice(12, 16)} - ${accountId.slice(16)}`; } return accountId.replace(ACCOUNT_ID_CHUNK_RE, "$& - "); } export const OBSCURA_WEBPAGE = 'https://obscura.com'; export const CHECK_STATUS_WEBPAGE = `${OBSCURA_WEBPAGE}/check`; export const LEGAL_WEBPAGE = `${OBSCURA_WEBPAGE}/legal`; export const APP_ACCOUNT_TAB = 'obscuravpn:///account'; export const APP_MANAGE_SUBSCRIPTION = `obscuravpn:///manage-subscription`; export function payUrl(accountId: AccountId): string { return `${OBSCURA_WEBPAGE}/pay#account_id=${encodeURIComponent(accountIdToString(accountId))}`; } export function subscriptionUrl(accountId: AccountId): string { return `${OBSCURA_WEBPAGE}/subscription/stripe/checkout#account_id=${encodeURIComponent(accountIdToString(accountId))}`; } export function tunnelsUrl(accountId: AccountId): string { return `${OBSCURA_WEBPAGE}/account/tunnels#account_id=${encodeURIComponent(accountIdToString(accountId))}`; } ================================================ FILE: obscura-ui/src/common/api.ts ================================================ import { getCountryData, ICountryData, TContinentCode, TCountryCode } from "countries-list"; import { useEffect, useReducer } from 'react'; import { AccountId } from "./accountUtils"; import { AccountStatus } from './appContext'; export interface Exit { id: string, country_code: string, // lowercase TCountryCode city_code: string, city_name: string, provider_id: string, provider_url: string, provider_name: string, provider_homepage_url: string, } export function getContinent(countryData: ICountryData): TContinentCode { if (countryData.iso2 === 'MX') return 'SA'; return countryData.continent; } export function getCountry(country_code: string): ICountryData { return getCountryData(country_code.toUpperCase() as TCountryCode); } export function getExitCountry(exit: Exit): ICountryData { if (exit.country_code.length !== 2) { console.warn(`Exit ${exit.id} (${exit.city_name}) does not have a country code of length 2 (got ${exit.country_code})`); } return getCountry(exit.country_code); } export interface AccountInfo { id: AccountId, active: boolean, top_up: TopUpInfo | null, subscription: SubscriptionInfo | null, apple_subscription: AppleSubscriptionInfo | null, auto_renews: number | null, current_expiry: number | null, } export interface TopUpInfo { credit_expires_at: number, } export function hasCredit(accountInfo: AccountInfo | undefined): boolean { const expires = accountInfo?.top_up?.credit_expires_at || 0; return new Date(expires * 1000).getTime() > new Date().getTime(); } export interface SubscriptionInfo { status: SubscriptionStatus, current_period_start: number, current_period_end: number, cancel_at_period_end: boolean, } // returns if a subscription is active, regardless about renewal status export function hasActiveSubscription(account: AccountInfo): boolean { if (account.subscription?.status === SubscriptionStatus.ACTIVE || account.subscription?.status === SubscriptionStatus.TRIALING) { return true; } if (account.apple_subscription?.status === AppleSubscriptionStatus.ACTIVE && account.apple_subscription.renewal_date > new Date().getTime()) { return true; } return false; } export function isRenewing(account: AccountInfo): boolean { return account.auto_renews !== null; } /// Returns the end of the current payment period. /// /// Note that if the account has a renewing subscription it can stay active for longer. export function paidUntil(account: AccountInfo): Date | null { const autoRenewDate = account.auto_renews || 0; const currentExpiry = account.current_expiry || 0; const maxExpiry = Math.max(autoRenewDate, currentExpiry); return maxExpiry > 0 ? new Date(maxExpiry * 1000) : null; } export function activeAppleSubscription(account: AccountInfo): boolean { return ( account.active && account.apple_subscription !== null && ( account.apple_subscription.status === AppleSubscriptionStatus.ACTIVE || account.apple_subscription.status === AppleSubscriptionStatus.GRACE_PERIOD ) ); } export function accountIsExpired(accountInfo: AccountInfo): boolean { if (accountInfo.auto_renews) return false; return (accountInfo.active && accountInfo.current_expiry) ? new Date(accountInfo.current_expiry * 1000).getTime() < new Date().getTime() : true; } // TimeRemaining is represented in parts of a whole export interface TimeRemaining { days: number; hours: number; minutes: number; } /// Returns a human representation of the time left on an account. /// /// Note that there is funny rounding on this number, it MUST NOT be used for computation. export function accountTimeRemaining(account: AccountInfo): TimeRemaining { const expiry = paidUntil(account); const remainingMs = expiry !== null ? expiry.getTime() - Date.now() : 0; let remainingSeconds = Math.floor(remainingMs / 1000); const days = Math.floor(remainingMs / 1000 / 3600 / 24); remainingSeconds -= days * 86400; const hours = Math.floor(remainingSeconds / 3600); remainingSeconds -= hours * 3600; const minutes = Math.floor(remainingSeconds / 60); return { days, hours, minutes }; } /// https://docs.stripe.com/api/subscriptions/object#subscription_object-status export const enum SubscriptionStatus { ACTIVE = "active", CANCELED = "canceled", INCOMPLETE = "incomplete", INCOMPLETE_EXPIRED = "incomplete_expired", PAST_DUE = "past_due", PAUSED = "paused", TRIALING = "trialing", UNPAID = "unpaid", } // https://developer.apple.com/documentation/appstoreserverapi/status export const enum AppleSubscriptionStatus { ACTIVE = 1, EXPIRED = 2, BILLING_RETRY = 3, GRACE_PERIOD = 4, REVOKED = 5, } export interface AppleSubscriptionInfo { status: AppleSubscriptionStatus, auto_renew_status: boolean, renewal_date: number, } export function hasAppleSubscription(accountInfo: AccountInfo | undefined): boolean { const status = accountInfo?.apple_subscription?.status; return status === AppleSubscriptionStatus.ACTIVE || status === AppleSubscriptionStatus.GRACE_PERIOD; } /** * Force the component to re-render when an account is expected to expire */ export function useReRenderWhenExpired(account: AccountStatus | null) { const [, forceUpdate] = useReducer(x => x + 1, 0); useEffect(() => { if (account !== null) { const expiryDate = paidUntil(account.account_info); if (expiryDate !== null && !accountIsExpired(account.account_info)) { const timeoutId = setTimeout(forceUpdate, expiryDate.getTime() - (new Date()).getTime()); return () => clearTimeout(timeoutId); } } }, [account?.last_updated_sec]); } ================================================ FILE: obscura-ui/src/common/appContext.ts ================================================ import { createContext, useContext } from 'react'; import { ExitSelector, ExitSelectorCity, TunnelArgs } from 'src/bridge/commands'; import { AccountId } from './accountUtils'; import { AccountInfo, Exit } from './api'; export enum NEVPNStatus { Invalid = 'invalid', Disconnected = 'disconnected', Connecting = 'connecting', Connected = 'connected', Reasserting = 'reasserting', Disconnecting = 'disconnecting' } export enum UpdaterStatusType { Uninitiated = 'uninitiated', Initiated = 'initiated', Available = 'available', NotFound = 'notFound', Error = 'error' } export interface AppcastSummary { date: string; description: string; version: string; minSystemVersionOk: boolean; } export interface UpdaterStatus { type: UpdaterStatusType; appcast?: AppcastSummary; error?: string; errorCode?: number; } export interface OsStatus { version: string, internetAvailable: boolean, osVpnStatus: NEVPNStatus, srcVersion: string strictLeakPrevention: boolean, updaterStatus: UpdaterStatus, debugBundleStatus: { inProgress: boolean, latestPath: string | null, inProgressCounter: number, }, canSendMail: boolean, loginItemStatus?: { registered: boolean, error?: string }, // iOS-specific storeKit?: { subscriptionProduct?: SubscriptionProductModel, externalPaymentsAllowed: boolean, }, offerCodeRedemptionSuccess?: boolean, // Android-specific playBilling?: boolean, } export interface SubscriptionProductModel { displayName: string, description: string, displayPrice: string, renewalPrice?: string, subscriptionPeriodFormatted: string, } export enum TransportKind { Quic = 'quic', TcpTls = 'tcpTls', } export interface VpnStatus { connected?: { exit: Exit, clientPublicKey: string, exitPublicKey: string, transport: TransportKind, tunnelArgs: TunnelArgs, }, connecting?: { connectError: string, reconnecting: boolean tunnelArgs: TunnelArgs, }, disconnected?: {} } export function getCityFromStatus(status: VpnStatus): ExitSelectorCity | undefined { const tunnelArgs = getTunnelArgs(status); return getCityFromArgs(tunnelArgs?.exit); } export function getCityFromArgs(exitSelector: ExitSelector | undefined): ExitSelectorCity | undefined { return exitSelector !== undefined && "city" in exitSelector ? exitSelector.city : undefined; } export function getTunnelArgs(status: VpnStatus): TunnelArgs | undefined { return status.connected?.tunnelArgs ?? status.connecting?.tunnelArgs; } export interface PinnedLocation { country_code: string, city_code: string, // Seconds since UNIX epoch. pinned_at: number, } export interface AccountStatus { account_info: AccountInfo, last_updated_sec: number } // See rustlib/src/config/feature_flags.rs export enum KnownFeatureFlagKey { QuicFramePadding = "quicFramePadding", KillSwitch = "killSwitch", ForceSmallMtu = "forceSmallMtu", TcpTlsTunnel = "tcpTlsTunnel", } export type FeatureFlagKey = KnownFeatureFlagKey | string; export type FeatureFlagValue = boolean | null; export function featureFlagEnabled(value: FeatureFlagValue | undefined): boolean { return value === true; } export interface DNSContentBlock { ad: boolean, tracker: boolean, malware: boolean, adult: boolean, gambling: boolean, socialMedia: boolean, } export interface AppStatus { version: string, dnsContentBlock: DNSContentBlock, vpnStatus: VpnStatus, accountId: AccountId, pinnedLocations: Array, lastChosenExit: ExitSelector, inNewAccountFlow: boolean, apiUrl: string, account: AccountStatus | null, autoConnect: boolean, featureFlags: Record, featureFlagKeys: FeatureFlagKey[], useSystemDns: boolean, } interface IAppContext { vpnConnected: boolean, // the exitSelector used to initiate the connection initiatingExitSelector?: ExitSelector, vpnConnect: (exit: ExitSelector) => Promise, vpnDisconnect: () => Promise, pollAccount: () => Promise, accountLoading: boolean, appStatus: AppStatus, osStatus: OsStatus, showOfflineUI: boolean, accountInfo: AccountInfo | null, connectionInProgress: ConnectionInProgress, isProcessingPayment: boolean, setPaymentProcessing: (value: boolean) => void } export const AppContext = createContext(null as any as IAppContext); export enum ConnectionInProgress { Connecting = 'Connecting', Reconnecting = 'Reconnecting', Disconnecting = 'Disconnecting', // UI exclusives: ChangingLocations = 'Changing Locations', UNSET = 'UNSET' } /** * State derived isConnecting hook */ export function useIsConnecting() { const { connectionInProgress, osStatus, appStatus } = useContext(AppContext); return osStatus.osVpnStatus === NEVPNStatus.Connecting || osStatus.osVpnStatus === NEVPNStatus.Reasserting || connectionInProgress === ConnectionInProgress.ChangingLocations || appStatus.vpnStatus.connecting !== undefined; } export function useIsTransitioning() { const { connectionInProgress, osStatus, appStatus } = useContext(AppContext); return osStatus.osVpnStatus === NEVPNStatus.Connecting || osStatus.osVpnStatus === NEVPNStatus.Reasserting || osStatus.osVpnStatus === NEVPNStatus.Disconnecting || connectionInProgress === ConnectionInProgress.ChangingLocations || appStatus.vpnStatus.connecting !== undefined; } export function isConnecting(connectionInProgress: ConnectionInProgress) { switch (connectionInProgress) { case ConnectionInProgress.Connecting: case ConnectionInProgress.Reconnecting: case ConnectionInProgress.ChangingLocations: return true; } return false; } export function connectionIsIdle(connectionInProgress: ConnectionInProgress, vpnStatus: VpnStatus, osVpnStatus: NEVPNStatus) { return connectionInProgress === ConnectionInProgress.UNSET && vpnStatus.disconnected !== undefined && ( osVpnStatus === NEVPNStatus.Disconnected || osVpnStatus === NEVPNStatus.Invalid ); } ================================================ FILE: obscura-ui/src/common/common.module.css ================================================ .chip { border-radius: 5px; border-width: 1px; border-style: solid; border-color: var(-mantine-color-teal-1); } .button { &:disabled, &[data-disabled] { border-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)); background-color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-3)); color: var(--mantine-color-white) } } @media screen and (max-width: $mantine-breakpoint-xs) { .desktopOnly { display: none; } } @media screen and (min-width: $mantine-breakpoint-xs) { .mobileOnly { display: none !important; } } .elevatedSurface { background-color: light-dark(var(--mantine-color-body), var(--mantine-color-dark-6)) !important; } .svgThemed { fill: light-dark(black, white); } .wordmark { fill: light-dark(black, var(--mantine-color-gray-4)); } .secondaryColor { color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-text)) !important; } ================================================ FILE: obscura-ui/src/common/debuggingArchiveHook.tsx ================================================ import { Anchor, Text } from '@mantine/core'; import { notifications } from '@mantine/notifications'; import { useState } from 'react'; import { Trans, useTranslation } from 'react-i18next'; import { CommandError, debuggingArchive, revealItemInDir } from '../bridge/commands'; import { IS_HANDHELD_DEVICE } from '../bridge/SystemProvider'; import { fmtErrorI18n } from '../translations/i18n'; import { normalizeError } from './utils'; type ArchiveState = { inProgress: boolean, error?: Error }; export function useDebuggingArchive(): (userFeedback: string) => Promise { const { t } = useTranslation(); const [_, setArchiveState] = useState({ inProgress: false }); const startCreatingArchive = async (userFeedback: string) => { setArchiveState({ inProgress: true }); try { const path = await debuggingArchive(userFeedback); if (!IS_HANDHELD_DEVICE) { notifications.show({ title: t('Debugging Archive Created'), message: revealItemInDir(path)} />]} /> }); } } catch (e) { const error = normalizeError(e); const message = error instanceof CommandError ? fmtErrorI18n(t, error) : error.message; notifications.show({ title: t('Debugging Archive Failed'), message, color: 'red' }); } } return startCreatingArchive; } ================================================ FILE: obscura-ui/src/common/exitUtils.ts ================================================ import { ExitSelectorCity } from '../bridge/commands'; import { PinnedLocation } from '../common/appContext'; import { Exit, getContinent, getExitCountry } from './api'; /** returns a string containing the country flag emoji. */ export function getCountryFlag(countryCode: string): string { return countryCode .replace(/[A-Za-z]/g, char => { let codePoint = char.toUpperCase().codePointAt(0)! - "A".codePointAt(0)! + "🇦".codePointAt(0)!; return String.fromCodePoint(codePoint) }); } export function getExitCountryFlag(exit: Exit): string { return getCountryFlag(getExitCountry(exit).iso2); } /** returns a sort comparator for Exit[] given some parameters */ export function exitsSortComparator(left: Exit, right: Exit): number { const leftCountry = getExitCountry(left); const rightCountry = getExitCountry(right); const leftContinent = getContinent(leftCountry); const rightContinent = getContinent(rightCountry); const leftCountryName = leftCountry.name; const rightCountryName = rightCountry.name; return continentCmp(leftContinent, rightContinent) || leftCountryName.localeCompare(rightCountryName) || left.city_name.localeCompare(right.city_name) || left.id.localeCompare(right.id); } const continentRankings = [ 'NA', 'EU', 'SA', 'AS', 'AF', 'OC', 'AN', ]; export function continentCmp(left: string, right: string): number { return continentRankings.indexOf(left) - continentRankings.indexOf(right); } export function exitLocation(exit: Exit): PinnedLocation { let {city_code, country_code} = exit; return { city_code, country_code, pinned_at: 0, }; } export function exitCityEquals(left?: ExitSelectorCity, right?: ExitSelectorCity): boolean { if (left === undefined || right === undefined) return false; return left.country_code === right.country_code && left.city_code === right.city_code; } ================================================ FILE: obscura-ui/src/common/fmt.ts ================================================ export function fmt(lits: TemplateStringsArray, ...values: unknown[]): string { let out: unknown[] = []; for (let i = 0; ; i++) { out.push(lits[i]); if (i >= values.length) break; let v = values[i]; let type = typeof v; switch (type) { case "bigint": case "boolean": case "number": case "symbol": case "undefined": out.push(v); break; case "object": case "string": if (v instanceof Error) { out.push(`${v}`); } else { out.push(JSON.stringify(v)); } break; case "function": out.push(`[function ${(v as Function).name}]`); break; default: let _: never = type; void _; out.push(JSON.stringify(v)); } } return out.join(""); } class InterpolatedError extends Error { constructor( message: string, readonly values: unknown[], ) { super(message); } } export function err(lits: TemplateStringsArray, ...values: unknown[]): Error { return new InterpolatedError(fmt(lits, ...values), values); } ================================================ FILE: obscura-ui/src/common/links.ts ================================================ export const EMAIL = 'support@obscura.net'; export const DISCORD_SERVER = 'https://discord.gg/xsP2Fp7s6r'; export const MATRIX_SERVER = 'https://matrix.to/#/!CznDYbvmUUGxsJaWuW:matrix.social.obscuravpn.io?via=matrix.social.obscuravpn.io&via=matrix.org' export const TWITTER = 'https://x.com/obscuravpn'; ================================================ FILE: obscura-ui/src/common/localStorage.ts ================================================ export enum LocalStorageKey { CustomApiUrls = "customApiUrls" } export function getCustomApiUrls(): string[] { const customApiUrlsExist = localStorageGet(LocalStorageKey.CustomApiUrls); return JSON.parse(customApiUrlsExist ?? '[]'); } export function setCustomApiUrls(customApiUrls: string[]): string | null { return localStorageSet(LocalStorageKey.CustomApiUrls, JSON.stringify(customApiUrls)); } export function localStorageGet(key: LocalStorageKey): string | null { return window.localStorage.getItem(key) } export function localStorageSet(key: LocalStorageKey, value: string): string | null { let prev = localStorageGet(key); window.localStorage.setItem(key, value); return prev } export function localStorageRemove(key: LocalStorageKey): string | null { let prev = localStorageGet(key); window.localStorage.removeItem(key) return prev } ================================================ FILE: obscura-ui/src/common/notifIds.ts ================================================ export enum NotificationId { VPN_DISCONNECT_CONNECT = 'vpnDisconnectConnect', VPN_ERROR = 'vpnError', OPEN_AT_LOGIN = 'openAtLogin' } ================================================ FILE: obscura-ui/src/common/useAsync.ts ================================================ import React, { useMemo, useState } from "react"; import { isPromise, normalizeError } from "./utils"; const NEVER_LOADED = 0; export interface UseAsyncArgs { load: () => Promise | T; deps?: React.DependencyList; returnError?: boolean; skip?: boolean; } type RefreshCallback = (value?: T, error?: unknown) => void; export class UseAsyncState { value: T | undefined = undefined; lastSuccessfulValue: T | undefined = undefined; error: Error | undefined = undefined; loadVersion: number = NEVER_LOADED; valueVersion: number = NEVER_LOADED; refreshToken: unknown; refreshCallbacks: RefreshCallback[] | undefined; refresh?: () => Promise; setValue(version: number, value: T, callbacks?: RefreshCallback[]): boolean { for (let callback of callbacks ?? []) { try { callback(value); } catch (cbError) { console.error("Refresh callback failed", cbError); } } if (version < this.valueVersion) { return false; } this.value = value; this.lastSuccessfulValue = value; this.error = undefined; this.valueVersion = version; return true; } setError(version: number, error: unknown, callbacks?: RefreshCallback[]): boolean { for (let callback of callbacks ?? []) { try { callback(undefined, error); } catch (cbError) { console.error("Refresh callback failed", cbError); } } if (version < this.loadVersion) { return false; } this.value = undefined; this.error = normalizeError(error); this.valueVersion = version; return true; } } export interface UseAsyncResult { /// The loaded value. /// /// If `error` or `!everLoaded` this will be undefined. /// /// Note that this is the **most recently received response** not necessarily the **most recent requested data**. This can occur if multiple versions are requesting data. If you only want to show data from the latest request only show `value` if `!loading`. If you want to always show the most recent available data just show `value` (and maybe show a refreshing indicator if `loading`). value: T | undefined, /// The value for the current `deps` array. /// /// This value always corresponds to the most recently requested data and is never stale. If `deps` change this will immediately switch back to `undefined`. Exactly the same as `value` if `!loading`. currentValue: T | undefined, /// The last successful value if there ever was one. lastSuccessfulValue: T | undefined, /// An error if it occurred. error: Error | undefined, /// If this component has ever loaded, successfully or otherwise. /// /// If true either `value` or `error` will be set (but not necessarily up to date) and the other will be undefined. Note that both will be `undefined` iff `f` successfully returned `undefined`. everLoaded: boolean, /// The latest version dispatched. loadVersion: number, /// The version that the current `value` and `error` correspond to. valueVersion: number, /// If the current values of `value` and `error` do not yet reflect the current `deps`. /// /// For example the render after `deps` change `value` and `error` will remain the same and `loading` will become `true`. This allows you to either hide the state value, or continue to use it at your discretion. loading: boolean, refresh: () => Promise, } export function useAsync({ deps = [], load, returnError = false, skip = false, }: UseAsyncArgs): UseAsyncResult { const [state, setState] = useState({ inner: new UseAsyncState(), }); state.inner.refresh ??= () => { return new Promise((resolve, reject) => { if (!state.inner.refreshCallbacks) { state.inner.refreshToken = {}; state.inner.refreshCallbacks = []; } state.inner.refreshCallbacks.push((_, error) => { if (error) reject(error); else resolve(); }); setState({ inner: state.inner }); }) }; useMemo(() => { const callbacks = state.inner.refreshCallbacks; state.inner.refreshCallbacks = undefined; if (skip && !callbacks) { return; } const version = ++state.inner.loadVersion; try { let r = load(); if (isPromise(r)) { r.then( (value) => { if (state.inner.setValue(version, value, callbacks)) { setState({ inner: state.inner, }); } }, (error) => { if (state.inner.setError(version, error, callbacks)) { setState({ inner: state.inner, }); } }, ); } else { state.inner.setValue(version, r, callbacks); } } catch (error) { state.inner.setError(version, error, callbacks); } }, [skip, state.inner.refreshToken, ...deps]); if (state.inner.error && !returnError) { throw state.inner.error; } let loading = state.inner.valueVersion < state.inner.loadVersion; return { ...state.inner, currentValue: loading ? undefined : state.inner.value, everLoaded: state.inner.valueVersion > NEVER_LOADED, loading, refresh: state.inner.refresh!, }; } ================================================ FILE: obscura-ui/src/common/useExitList.ts ================================================ import { makeWatchable, useSharedWatchable } from "./useSharedWatchable"; import { getExitList, refreshExitList } from "../bridge/commands"; import { Exit } from "./api"; export interface UseExitListArgs { periodS: number, } export interface UseExitListResult { exitList?: Exit[], error?: Error, } const EXIT_WATCHABLE = makeWatchable(refreshExitList, getExitList); export function useExitList({ periodS, }: UseExitListArgs): UseExitListResult { let r = useSharedWatchable(EXIT_WATCHABLE, periodS); return { exitList: r.value?.value.exits, error: r.error, }; } ================================================ FILE: obscura-ui/src/common/useLoadable.ts ================================================ import { useEffect, useRef } from "react"; import { useAsync, UseAsyncArgs, UseAsyncResult } from "./useAsync"; export interface UseLoadableArgs extends UseAsyncArgs { periodMs: number, } export function useLoadable({ periodMs, ...args }: UseLoadableArgs): UseAsyncResult { let failures = useRef(0); let r = useAsync(args); useEffect(() => { // If the dependencies change reset the backoff. failures.current = 0; }, [args.skip, ...args.deps ?? []]); useEffect(() => { if (r.loading) { // Should only happen for the initial load. return; } let delayMs: number; if (r.error) { failures.current += 1; delayMs = Math.min( Math.random() * (500 * 2 ** failures.current), 60 * 1000, ); } else { failures.current = 0; delayMs = periodMs; } let timer = setTimeout(r.refresh, delayMs); return () => clearTimeout(timer); }, [r.valueVersion]) return { ...r, refresh: () => { failures.current = 0; return r.refresh(); }, }; } ================================================ FILE: obscura-ui/src/common/useMailto.ts ================================================ import { useTranslation } from 'react-i18next'; import { OsStatus } from './appContext'; import { EMAIL } from './links'; import { percentEncodeQuery } from './utils'; import { systemName } from '../bridge/SystemProvider'; // this component may be used before appContext is created, and thus requires explicitly passing osStatus export default function useMailto(osStatus: OsStatus) { const { t } = useTranslation(); // \r is important to ensure email clients do not trim newlines const params = { subject: t('emailSubject', { platform: systemName(), version: osStatus.srcVersion }), body: t('emailBodyIntro') + ':\n\n\r' }; const queryString = percentEncodeQuery(params); const mailto = `mailto:${EMAIL}?${queryString}`; return mailto } ================================================ FILE: obscura-ui/src/common/useSharedWatchable.ts ================================================ import { useEffect, useState } from "react"; import { normalizeError, sleep } from "./utils"; interface Versioned { version: unknown } interface SharedWatchable { error: Error | undefined; value: T | undefined; load: (freshnessS: number) => Promise; watch: (version?: T["version"]) => Promise; isWatcherRunning: boolean; subscribers: Set<(v: UseWatchableResult) => void>; } export function makeWatchable( load: (freshnessS: number) => Promise, watch: (version?: T["version"]) => Promise, ): SharedWatchable { return { error: undefined, value: undefined, load, watch, isWatcherRunning: false, subscribers: new Set, }; } export interface UseWatchableResult { error?: Error, value?: T, } export function useSharedWatchable( shared: SharedWatchable, periodS: number, ): UseWatchableResult { let [state, setState] = useState>({ value: shared.value, error: shared.error, }); useEffect(() => { let timeout: ReturnType | undefined; void (async function doLoad() { timeout = undefined; try { await shared.load(periodS); } catch (error) { shared.error = normalizeError(error); let r = { value: shared.value, error: shared.error, }; for (let watcher of shared.subscribers) { watcher(r); } } finally { timeout = setTimeout(doLoad, periodS*1000); } })(); shared.subscribers.add(setState); if (!shared.isWatcherRunning) { shared.isWatcherRunning = true; void (async () => { while (shared.subscribers.size > 0) { try { let newValue = await shared.watch(shared.value?.version); shared.value = newValue; let r = { value: newValue, error: undefined, }; for (let watcher of shared.subscribers) { watcher(r); } } catch (error) { console.error("Failure watching value:", error); // TODO: Should we report this in some way? await sleep(1000); } } shared.isWatcherRunning = false; })(); } return () => { shared.subscribers.delete(setState); if (timeout) { clearTimeout(timeout); } } }, []); return state; } ================================================ FILE: obscura-ui/src/common/utils.ts ================================================ import { useMantineTheme } from '@mantine/core'; import Cookies from 'js-cookie'; import localforage from 'localforage'; import { Dispatch, ForwardedRef, RefCallback, SetStateAction, useEffect, useLayoutEffect, useState } from 'react'; import { fmt } from './fmt'; export { localforage }; export const HEADER_TITLE = 'Obscura VPN'; export const IS_DEVELOPMENT = import.meta.env.MODE === 'development'; export const MIN_LOAD_MS = 400; export function useCookie(key: string, defaultValue: string, options: Cookies.CookieAttributes = { expires: 365000, sameSite: 'lax', path: '/' }): [string, Dispatch>] { // cookie expires in a millenia // sameSite != 'strict' because the cookie is not read for sensitive actions // synchronous const cookieValue = Cookies.get(key); const [state, setState] = useState(cookieValue || defaultValue); useEffect(() => { Cookies.set(key, state, options); }, [state]); return [state, setState]; } // show browser / native notification export function notify(title: string, body?: string) { new Notification(title, { body: body || "", }); } export function sleep(ms: number) { return new Promise(resolve => setTimeout(resolve, ms)); } export function downloadFile(filename: string, content: BlobPart, contentType = 'text/plain') { const element = document.createElement('a'); const file = new Blob([content], { type: contentType }); element.href = URL.createObjectURL(file); element.download = filename; document.body.appendChild(element); // Required for this to work in FireFox element.click(); } export function isPromise(v: unknown): v is PromiseLike { return !!v && (typeof v == "object" || typeof v == "function") && "then" in v; } export function arraysEqual(a: T[], b: T[]) { if (a === b) return true; if (a == null || b == null) return false; if (a.length !== b.length) return false; // If you don't care about the order of the elements inside // the array, you should sort both arrays here. // Please note that calling sort on an array will modify that array. // you might want to clone your array first. for (var i = 0; i < a.length; ++i) { if (a[i] !== b[i]) return false; } return true; } // https://reactjs.org/docs/hooks-custom.html export function useLocalForage(key: string, defaultValue: T) { // only supports primitives, arrays, and {} objects const [state, setState] = useState(defaultValue); const [loading, setLoading] = useState(true); // useLayoutEffect will be called before DOM paintings and before useEffect useLayoutEffect(() => { let allow = true; localforage.getItem(key) .then(value => { if (value === null) throw ''; if (allow) setState(value as T); }).catch(() => localforage.setItem(key, defaultValue)) .then(() => { if (allow) setLoading(false); }); return () => { allow = false; } }, []); // useLayoutEffect does not like Promise return values. useEffect(() => { // do not allow setState to be called before data has even been loaded! // this prevents overwriting if (!loading) localforage.setItem(key, state); }, [state]); return [state, setState, loading]; } /** * A hack to get the latest state value to be used in long running tasks * This function should not be made use of liberally * @param {A} setter the setState method of the state you want the latest value of * @returns the state which was passed to the setter's action */ export function getLatestState(setter: Dispatch>) { let v; setter(value => { v = value; return value; }); return v; } export function percentEncodeQuery(params: Record) { return Object.entries(params) .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) .join('&'); } const DEFAULT_ERROR_MSG = "An unexpected error has occurred."; export function errMsg(error: unknown): string { if (error instanceof Error) { return error.message; } console.warn(fmt`errMsg: error = ${error} is not an instance of Error`); return DEFAULT_ERROR_MSG; } export function normalizeError(error: unknown): Error { if (error instanceof Error) { return error; } console.warn(fmt`normalizeError: error = ${error} is not an instance of Error`); return new Error(DEFAULT_ERROR_MSG, { cause: error, }); } export function multiRef(...refs: ForwardedRef[]): RefCallback { return value => { return refs.forEach((ref) => { if (ref !== null) { if (typeof ref === 'function') { ref(value); } else { ref.current = value } } }); }; } export function randomChoice(arr: T[]): T { if (arr.length === 0) throw new Error('array length cannot be zero'); const randIdx = Math.floor(Math.random() * arr.length); return arr[randIdx]!; } /** * Returns hh:mm:ss from ms * @param ms milliseconds */ export function fmtTime(ms: number) { const totalSeconds = Math.floor(ms / 1000); const seconds = totalSeconds % 60; const minutes = Math.floor((totalSeconds % 3600) / 60); const hours = Math.floor(totalSeconds / 3600); return `${zeroPad(hours, 2)}:${zeroPad(minutes, 2)}:${zeroPad(seconds, 2)}`; } function zeroPad(num: number, width: number) { return num.toString().padStart(width, '0'); } export function usePrimaryColorResolved() { const theme = useMantineTheme(); return theme.variantColorResolver({color: theme.primaryColor, theme, variant: 'subtle'}).color; } // https://claritydev.net/blog/diacritic-insensitive-string-comparison-javascript export function normalizeString(str: string): string { return str // canonical decomposition .normalize('NFD') // remove diacritic marks from string .replace(/[\u0300-\u036f]/g, '') .toLowerCase(); }; // case-insensitive and diacritic-insensitive search export function normalizedIncludes(needle: string, haystack: string) { return normalizeString(haystack).includes(normalizeString(needle)); } ================================================ FILE: obscura-ui/src/components/AccountNumberSection.tsx ================================================ import { ActionIcon, Button, CopyButton, Group, Stack, Text, useMantineTheme } from '@mantine/core'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { FaCopy } from 'react-icons/fa6'; import { IoCopy, IoLogOutOutline } from 'react-icons/io5'; import * as ObscuraAccount from '../common/accountUtils'; import commonClasses from '../common/common.module.css'; import { usePrimaryColorResolved } from '../common/utils'; import Eye from '../res/eye.fill.svg?react'; import EyeSlash from '../res/eye.slash.fill.svg?react'; import PersonBadgeKey from '../res/person.badge.key.svg?react'; export function AccountNumberSection({ accountId, logOut }: { accountId: ObscuraAccount.AccountId, logOut: () => void }) { const { t } = useTranslation(); const theme = useMantineTheme(); const primaryColorResolved = usePrimaryColorResolved(); const [showAccountNumber, setShowAccountNumber] = useState(false); const EyeIcon = showAccountNumber ? EyeSlash : Eye; return ( Account Number
setShowAccountNumber(!showAccountNumber)}> {} {({ copied, copy }) => ( )}
{showAccountNumber ? ObscuraAccount.formatPartialAccountId(ObscuraAccount.accountIdToString(accountId)) : 'XXXX - XXXX - XXXX - XXXX - XXXX'} {({ copied, copy }) => ( )}
); } ================================================ FILE: obscura-ui/src/components/AnimatedChevron.tsx ================================================ import { BsChevronDown } from 'react-icons/bs'; export default function AnimatedChevron({ rotated }: { rotated: Boolean }) { return ( ); } ================================================ FILE: obscura-ui/src/components/BoltBadgeAuto.tsx ================================================ import SvgFile from '../res/bolt.badge.automatic.fill.svg?react'; export default function BoltBadgeAuto({ height = '1.25em', fill = 'white' }) { return } ================================================ FILE: obscura-ui/src/components/ButtonLink.tsx ================================================ import { Button, ButtonProps } from '@mantine/core'; import { PropsWithChildren } from 'react'; import ExternalLinkIcon from './ExternalLinkIcon'; interface ButtonLinkProps extends PropsWithChildren { href: string, inline?: boolean, size?: ButtonProps['size'], onClick?: React.MouseEventHandler, variant?: ButtonProps['variant'], } export function ButtonLink({ children, href, onClick, variant, inline = false, size }: ButtonLinkProps) { return ( ); } ================================================ FILE: obscura-ui/src/components/CachedColorScheme.tsx ================================================ import { useComputedColorScheme } from '@mantine/core'; import { createContext, PropsWithChildren } from 'react'; // useComputerColorScheme does not return instantly, it first returns the default value // and then later returns the true value. // This causes flashes since the chance from light (default) to dark is noticeable // To avoid a flash, we can cache the value at a location of the tree where we know // useComputerColorScheme would be too slow export const CColorSchemeContext = createContext('light' as 'light' | 'dark'); export default function CachedColorScheme({ children }: PropsWithChildren) { const colorScheme = useComputedColorScheme(); return ( {children} ); } ================================================ FILE: obscura-ui/src/components/ConfirmationDialog.module.css ================================================ .drawerContent { display: flex; flex-direction: column; height: min-content !important; border-radius: var(--mantine-radius-lg) var(--mantine-radius-lg) 0 0 !important; padding-bottom: env(safe-area-inset-bottom); } .drawerBody { flex-grow: 1; } ================================================ FILE: obscura-ui/src/components/ConfirmationDialog.tsx ================================================ import { Drawer, DrawerProps, MantineSize, Modal } from '@mantine/core'; import { PropsWithChildren } from 'react'; import { useTranslation } from 'react-i18next'; import { IS_HANDHELD_DEVICE } from '../bridge/SystemProvider'; import classes from './ConfirmationDialog.module.css'; interface ConfirmationDialogProps extends PropsWithChildren { opened: boolean; onClose: () => void; drawerSize?: MantineSize | (string & {}) | number; title?: string; drawerCloseButton?: boolean; closeOnClickOutside?: boolean; closeOnEscape?: boolean; withCloseButton?: boolean; } export function ConfirmationDialog({ opened, onClose, drawerSize = 'xs', title, children, drawerCloseButton, closeOnClickOutside, closeOnEscape, withCloseButton }: ConfirmationDialogProps) { const { t } = useTranslation(); return ( IS_HANDHELD_DEVICE ? {children} : {children} ); } type MobileDrawerProps = Omit; export function MobileDrawer({ size, title, opened, onClose, children, withCloseButton, ...others }: MobileDrawerProps) { return ( {children} ); } ================================================ FILE: obscura-ui/src/components/DebuggingArchive.module.css ================================================ .card { background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6)); } .havingTroubleTitle { color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-3)); } .debugLabel { background-color: light-dark(white, var(--mantine-color-dark-8)); position: absolute; bottom: 16px; right: 16px; max-width: 155px; } .debugLabelHandheld { @media (orientation: portrait) { position: relative; right: auto; top: 50px; bottom: auto; max-width: 170px; text-align: center; } @media (orientation: landscape) { top: 16px; right: 16px; bottom: auto; } } ================================================ FILE: obscura-ui/src/components/DebuggingArchive.tsx ================================================ import { Anchor, Button, Card, Group, Loader, Stack, Text, Textarea, Title } from '@mantine/core'; import { useDisclosure } from '@mantine/hooks'; import { useState } from 'react'; import { Trans, useTranslation } from 'react-i18next'; import { IoIosMail, IoIosShare } from 'react-icons/io'; import * as commands from '../bridge/commands'; import { emailDebugArchive, revealItemInDir, shareDebugArchive } from '../bridge/commands'; import { IS_HANDHELD_DEVICE, systemName } from '../bridge/SystemProvider'; import { NEVPNStatus, OsStatus } from '../common/appContext'; import { useDebuggingArchive } from '../common/debuggingArchiveHook'; import { EMAIL } from '../common/links'; import useMailto from '../common/useMailto'; import { ConfirmationDialog } from './ConfirmationDialog'; import classes from './DebuggingArchive.module.css'; import { fmtErrorI18n } from '../translations/i18n'; import { normalizeError } from '../common/utils'; import { notifications } from '@mantine/notifications'; const ICON_SIZE = 20; export enum DebuggingArchiveVariant { Card = 'card', LoginLabel = 'label' } // this component may be used before appContext is created, and thus requires explicitly passing osStatus export default function DebuggingArchive({ osStatus, variant = DebuggingArchiveVariant.Card }: { osStatus: OsStatus, variant?: DebuggingArchiveVariant }) { const { t } = useTranslation(); const createDebuggingArchive = useDebuggingArchive(); const [opened, { open, close }] = useDisclosure(false); const { execute: disconnect } = commands.useCommand({ command: commands.disconnect, showNotification: false, rethrow: true }); const [disconnectInProgress, setDisableButtons] = useState(false); const [userFeedback, setUserFeedback] = useState(''); const onContinue = () => { setDisableButtons(false); void createDebuggingArchive(userFeedback); // For Label variant, keep modal open to show status if (variant !== DebuggingArchiveVariant.LoginLabel) { close(); setUserFeedback(''); } } const loadingSpinner = !!osStatus.debugBundleStatus.inProgress && {t('createDebugArchiveInProgress')}; const archiveAvailable = !osStatus.debugBundleStatus.inProgress && osStatus.debugBundleStatus.latestPath !== null; const showStatus = osStatus.debugBundleStatus.inProgress || osStatus.debugBundleStatus.latestPath !== null; const modal = ( { osStatus.osVpnStatus !== NEVPNStatus.Disconnected && <> {t('debugArchiveDisconnectPrompt')} }