├── .github └── workflows │ └── update-as-nightly.yml ├── .gitignore ├── .taskcluster.yml ├── CODE_OF_CONDUCT.md ├── FocusRustComponentsWrapper ├── dummy.m └── include │ └── dummy.h ├── LICENSE ├── MozillaRustComponentsWrapper ├── dummy.m └── include │ └── dummy.h ├── Package.swift ├── README.md ├── appservices_local_xcframework.sh ├── automation ├── requirements.txt └── update-from-application-services.py ├── swift-source ├── all │ ├── FxAClient │ │ ├── FxAccountConfig.swift │ │ ├── FxAccountDeviceConstellation.swift │ │ ├── FxAccountLogging.swift │ │ ├── FxAccountManager.swift │ │ ├── FxAccountOAuth.swift │ │ ├── FxAccountState.swift │ │ ├── FxAccountStorage.swift │ │ ├── KeychainWrapper+.swift │ │ ├── MZKeychain │ │ │ ├── KeychainItemAccessibility.swift │ │ │ ├── KeychainWrapper.swift │ │ │ └── KeychainWrapperSubscript.swift │ │ └── PersistedFirefoxAccount.swift │ ├── Generated │ │ ├── Metrics │ │ │ └── Metrics.swift │ │ ├── autofill.swift │ │ ├── autofillFFI.h │ │ ├── contextIDFFI.h │ │ ├── context_id.swift │ │ ├── crashtest.swift │ │ ├── crashtestFFI.h │ │ ├── errorFFI.h │ │ ├── errorsupport.swift │ │ ├── fxa_client.swift │ │ ├── fxa_clientFFI.h │ │ ├── init_rust_components.swift │ │ ├── init_rust_componentsFFI.h │ │ ├── logins.swift │ │ ├── loginsFFI.h │ │ ├── megazord_ios.modulemap │ │ ├── merino.swift │ │ ├── merinoFFI.h │ │ ├── nimbus.swift │ │ ├── nimbusFFI.h │ │ ├── places.swift │ │ ├── placesFFI.h │ │ ├── push.swift │ │ ├── pushFFI.h │ │ ├── relay.swift │ │ ├── relayFFI.h │ │ ├── remote_settings.swift │ │ ├── remote_settingsFFI.h │ │ ├── rust_log_forwarder.swift │ │ ├── rustlogforwarderFFI.h │ │ ├── search.swift │ │ ├── searchFFI.h │ │ ├── suggest.swift │ │ ├── suggestFFI.h │ │ ├── sync15.swift │ │ ├── sync15FFI.h │ │ ├── syncmanager.swift │ │ ├── syncmanagerFFI.h │ │ ├── tabs.swift │ │ ├── tabsFFI.h │ │ ├── tracing.swift │ │ └── tracingFFI.h │ ├── Logins │ │ └── LoginsStorage.swift │ ├── Nimbus │ │ ├── ArgumentProcessor.swift │ │ ├── Bundle+.swift │ │ ├── Collections+.swift │ │ ├── Dictionary+.swift │ │ ├── FeatureHolder.swift │ │ ├── FeatureInterface.swift │ │ ├── FeatureManifestInterface.swift │ │ ├── FeatureVariables.swift │ │ ├── HardcodedNimbusFeatures.swift │ │ ├── Nimbus.swift │ │ ├── NimbusApi.swift │ │ ├── NimbusBuilder.swift │ │ ├── NimbusCreate.swift │ │ ├── NimbusMessagingHelpers.swift │ │ ├── Operation+.swift │ │ └── Utils │ │ │ ├── Logger.swift │ │ │ ├── Sysctl.swift │ │ │ ├── Unreachable.swift │ │ │ └── Utils.swift │ ├── Places │ │ ├── Bookmark.swift │ │ ├── HistoryMetadata.swift │ │ └── Places.swift │ ├── Sync15 │ │ ├── ResultError.swift │ │ ├── RustSyncTelemetryPing.swift │ │ └── SyncUnlockInfo.swift │ ├── SyncManager │ │ ├── SyncManagerComponent.swift │ │ └── SyncManagerTelemetry.swift │ └── Viaduct │ │ ├── RustViaductFFI.h │ │ └── Viaduct.swift └── focus │ ├── Generated │ ├── Metrics │ │ └── Metrics.swift │ ├── errorFFI.h │ ├── errorsupport.swift │ ├── megazord_focus.modulemap │ ├── nimbus.swift │ ├── nimbusFFI.h │ ├── remote_settings.swift │ ├── remote_settingsFFI.h │ ├── rust_log_forwarder.swift │ ├── rustlogforwarderFFI.h │ ├── tracing.swift │ └── tracingFFI.h │ ├── Nimbus │ ├── ArgumentProcessor.swift │ ├── Bundle+.swift │ ├── Collections+.swift │ ├── Dictionary+.swift │ ├── FeatureHolder.swift │ ├── FeatureInterface.swift │ ├── FeatureManifestInterface.swift │ ├── FeatureVariables.swift │ ├── HardcodedNimbusFeatures.swift │ ├── Nimbus.swift │ ├── NimbusApi.swift │ ├── NimbusBuilder.swift │ ├── NimbusCreate.swift │ ├── NimbusMessagingHelpers.swift │ ├── Operation+.swift │ └── Utils │ │ ├── Logger.swift │ │ ├── Sysctl.swift │ │ ├── Unreachable.swift │ │ └── Utils.swift │ └── Viaduct │ ├── RustViaductFFI.h │ └── Viaduct.swift └── taskcluster ├── config.yml ├── docker └── linux │ └── Dockerfile ├── kinds ├── docker-image │ └── kind.yml └── hello │ └── kind.yml └── rust_components_swift_taskgraph └── transforms └── hello.py /.github/workflows/update-as-nightly.yml: -------------------------------------------------------------------------------- 1 | name: Create a PR for release with the newest A-S version available 2 | 3 | # Controls when the workflow will run 4 | on: 5 | schedule: 6 | # Runs 8:00am UTC each day 7 | - cron: '0 8 * * *' 8 | workflow_dispatch: 9 | 10 | jobs: 11 | release-pr: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | max-parallel: 1 15 | matrix: 16 | python-version: [3.10.x] 17 | steps: 18 | # Checks out the rust-components-swift repository 19 | # This uses the `PAT` secret, which is a personal access token generated by Ben with 20 | # the `repo:public_repo` scope. 21 | - uses: actions/checkout@v2 22 | with: 23 | token: ${{ secrets.PAT }} 24 | submodules: 'recursive' 25 | ref: main 26 | - name: Setup Python ${{ matrix.python-version }} 27 | uses: actions/setup-python@v1 28 | with: 29 | python-version: ${{ matrix.python-version }} 30 | - name: Setup git information 31 | run: | 32 | git config user.email "sync-team@mozilla.com" 33 | git config user.name "Firefox Sync Engineering" 34 | - name: Run nightly update 35 | run: python automation/update-from-application-services.py nightly --push --remote=origin 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .build 3 | .swiftpm 4 | Package.resolved 5 | 6 | # From the Glean sdk generator 7 | .venv 8 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Community Participation Guidelines 2 | 3 | This repository is governed by Mozilla's code of conduct and etiquette guidelines. 4 | For more details, please read the 5 | [Mozilla Community Participation Guidelines](https://www.mozilla.org/about/governance/policies/participation/). 6 | 7 | ## How to Report 8 | For more information on how to report violations of the Community Participation Guidelines, please read our '[How to Report](https://www.mozilla.org/about/governance/policies/participation/reporting/)' page. 9 | -------------------------------------------------------------------------------- /FocusRustComponentsWrapper/dummy.m: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | */ 5 | 6 | // Swift Package Manager needs at least one source file. 7 | -------------------------------------------------------------------------------- /FocusRustComponentsWrapper/include/dummy.h: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | */ 5 | 6 | // Swift Package Manager needs at least one header to prevent a warning. See 7 | // https://github.com/mozilla/application-services/issues/4422. 8 | -------------------------------------------------------------------------------- /MozillaRustComponentsWrapper/dummy.m: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | */ 5 | 6 | // Swift Package Manager needs at least one source file. 7 | -------------------------------------------------------------------------------- /MozillaRustComponentsWrapper/include/dummy.h: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 | */ 5 | 6 | // Swift Package Manager needs at least one header to prevent a warning. See 7 | // https://github.com/mozilla/application-services/issues/4422. 8 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.4 2 | import PackageDescription 3 | 4 | let checksum = "f56ca6a9ad0c9b8c6feaf99a548af785024a51d24db34a4c44d24278faf7af29" 5 | let version = "142.0.20250702050314" 6 | let url = "https://firefox-ci-tc.services.mozilla.com/api/index/v1/task/project.application-services.v2.swift.142.20250702050314/artifacts/public/build/MozillaRustComponents.xcframework.zip" 7 | 8 | // Focus xcframework 9 | let focusChecksum = "e1b02339b8b825e2a2c12f5cbd25d38c36528ff0da7d2a5a091d4758190c0f4c" 10 | let focusUrl = "https://firefox-ci-tc.services.mozilla.com/api/index/v1/task/project.application-services.v2.swift.142.20250702050314/artifacts/public/build/FocusRustComponents.xcframework.zip" 11 | let package = Package( 12 | name: "MozillaRustComponentsSwift", 13 | platforms: [.iOS(.v14)], 14 | products: [ 15 | .library(name: "MozillaAppServices", targets: ["MozillaAppServices"]), 16 | .library(name: "FocusAppServices", targets: ["FocusAppServices"]), 17 | ], 18 | dependencies: [ 19 | ], 20 | targets: [ 21 | /* 22 | * A placeholder wrapper for our binaryTarget so that Xcode will ensure this is 23 | * downloaded/built before trying to use it in the build process 24 | * A bit hacky but necessary for now https://github.com/mozilla/application-services/issues/4422 25 | */ 26 | .target( 27 | name: "MozillaRustComponentsWrapper", 28 | dependencies: [ 29 | .target(name: "MozillaRustComponents", condition: .when(platforms: [.iOS])) 30 | ], 31 | path: "MozillaRustComponentsWrapper" 32 | ), 33 | .target( 34 | name: "FocusRustComponentsWrapper", 35 | dependencies: [ 36 | .target(name: "FocusRustComponents", condition: .when(platforms: [.iOS])) 37 | ], 38 | path: "FocusRustComponentsWrapper" 39 | ), 40 | .binaryTarget( 41 | name: "MozillaRustComponents", 42 | // 43 | // For release artifacts, reference the MozillaRustComponents as a URL with checksum. 44 | // IMPORTANT: The checksum has to be on the line directly after the `url` 45 | // this is important for our release script so that all values are updated correctly 46 | url: url, 47 | checksum: checksum 48 | 49 | // For local testing, you can point at an (unzipped) XCFramework that's part of the repo. 50 | // Note that you have to actually check it in and make a tag for it to work correctly. 51 | // 52 | //path: "./MozillaRustComponents.xcframework" 53 | ), 54 | .binaryTarget( 55 | name: "FocusRustComponents", 56 | // 57 | // For release artifacts, reference the MozillaRustComponents as a URL with checksum. 58 | // IMPORTANT: The checksum has to be on the line directly after the `url` 59 | // this is important for our release script so that all values are updated correctly 60 | url: focusUrl, 61 | checksum: focusChecksum 62 | 63 | // For local testing, you can point at an (unzipped) XCFramework that's part of the repo. 64 | // Note that you have to actually check it in and make a tag for it to work correctly. 65 | // 66 | //path: "./FocusRustComponents.xcframework" 67 | ), 68 | .target( 69 | name: "MozillaAppServices", 70 | dependencies: ["MozillaRustComponentsWrapper"], 71 | path: "swift-source/all" 72 | ), 73 | .target( 74 | name: "FocusAppServices", 75 | dependencies: ["FocusRustComponentsWrapper"], 76 | path: "swift-source/focus" 77 | ), 78 | ] 79 | ) 80 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Swift Package for Mozilla's Rust Components 2 | 3 | This repository is a Swift Package for distributing releases of Mozilla's various 4 | Rust-based application components. It provides the Swift source code packaged in 5 | a format understood by the Swift package manager, and depends on a pre-compiled 6 | binary release of the underlying Rust code published from [mozilla/application-services]( 7 | https://github.com/mozilla/application-services). 8 | 9 | **This repository is mostly updated by automation, all the logic is copied from [mozilla/application-services]( 10 | https://github.com/mozilla/application-services)** 11 | 12 | ## Overview 13 | 14 | * The `application-services` repo publishes two binary artifacts `MozillaRustComponents.xcframework.zip` and `FocusRustComponents.xcframework.zip` containing 15 | the Rust code and FFI definitions for all components, compiled together into a single library. 16 | * The `Package.swift` file refrences the xcframeworks as Swift binary targets. 17 | * The `Package.swift` file defines a library per target (one for all the components used by `firefox-ios` and one for `focus-ios`) 18 | * Each library references its Swift source code directly as files in the repo. All components used by a target are copied into the same directory. For example, all the `firefox-ios` files are in the `swift-source/all` directory. 19 | * Each library depends on wrapper which wraps the binary to provide the pre-compiled Rust code. For example, [`FocusRustComponentWrapper`](./FocusRustComponentsWrapper/) wraps the Focus xcframework. 20 | 21 | For more information, please consult: 22 | 23 | * [application-services ADR-0003](https://github.com/mozilla/application-services/blob/main/docs/adr/0003-swift-packaging.md), 24 | which describes the overall plan for distributing Rust-based components as Swift packages. 25 | * The [Swift Package Manager docs](https://swift.org/package-manager/) and [GitHub repo](https://github.com/apple/swift-package-manager), 26 | which explain the details of how Swift Packages work (and hence why this repo is set up the way it is). 27 | * The [`ios-rust` crate](https://github.com/mozilla/application-services/tree/main/megazords/ios-rust) which is currently 28 | responsible for publishing the pre-built `MozillaRustComponents.xcframework.zip` and `FocusRustComponents.xcframework.zip` bundles on which this repository depends. 29 | 30 | 31 | ## Releases 32 | ### Nightly 33 | 34 | Nightly releases are automated and run every night as a cron job that pushes directly to the main branch. Nightly releases are tagged with three components (i.e `X.0.Y`) where the first component is the current Firefox release (i.e `117`, etc) and the last component is a timestamp. 35 | 36 | Note that we need three components because that's a Swift Package manager requirement. 37 | 38 | ### Cutting a Release 39 | 40 | To cut a release of `rust-components-swift`, you will need to do the following: 41 | - Run `./automation/update-from-application-services.py `, where `X.Y` is the version of application services. 42 | - Open a pull request with the resulting changes 43 | - Once landed on the main branch, cut a release using the GitHub UI and tag it 44 | - **IMPORTANT**: The release tag must be in the form `X.0.Y`, where `X.Y` is the version of application services 45 | 46 | ## Testing and Local development 47 | To enable local development of `rust-component-swift` read the instructions [documented in application services](https://mozilla.github.io/application-services/book/howtos/locally-published-components-in-firefox-ios.html) 48 | 49 | ## Adding a new component 50 | 51 | Check out the instructions in the [docs in `application-services` for adding a new component and publishing it for iOS](https://github.com/mozilla/application-services/blob/main/docs/howtos/adding-a-new-component.md#distribute-your-component-with-rust-components-swift). The docs are also published for convenience in . 52 | 53 | 54 | ## Filing issues with rust-components-swift 55 | Please open a ticket in https://github.com/mozilla/application-services/issues for any rust-component-swift related issues. 56 | -------------------------------------------------------------------------------- /appservices_local_xcframework.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # This Source Code Form is subject to the terms of the Mozilla Public 4 | # License, v. 2.0. If a copy of the MPL was not distributed with this 5 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | 7 | # Uses a local version of application services xcframework 8 | 9 | # This script allows switches the usage of application services to a local xcframework 10 | # built from a local checkout of application services 11 | 12 | set -e 13 | 14 | # CMDNAME is used in the usage text below 15 | CMDNAME=$(basename "$0") 16 | USAGE=$(cat < 19 | 20 | Uses a local version of application services xcframework 21 | 22 | This script allows switches the usage of application services to a local xcframework 23 | built from a local checkout of application services 24 | 25 | 26 | USAGE: 27 | ${CMDNAME} [OPTIONS] 28 | 29 | OPTIONS: 30 | -d, --disable Disables local development on application services 31 | -h, --help Display this help message. 32 | EOT 33 | ) 34 | 35 | msg () { 36 | printf "\033[0;34m> %s\033[0m\n" "${1}" 37 | } 38 | 39 | helptext() { 40 | echo "$USAGE" 41 | } 42 | 43 | 44 | 45 | THIS_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 46 | PACKAGE_FILE="$THIS_DIR/Package.swift" 47 | SWIFT_SOURCE="$THIS_DIR/swift-source" 48 | FRAMEWORK_PATH="./MozillaRustComponents.xcframework" 49 | FOCUS_FRAMEWORK_PATH="./FocusRustComponents.xcframework" 50 | FRAMEWORK_PATH_ESCAPED=$( echo $FRAMEWORK_PATH | sed 's/\//\\\//g' ) 51 | FOCUS_FRAMEWORK_PATH_ESCAPED=$( echo $FOCUS_FRAMEWORK_PATH | sed 's/\//\\\//g' ) 52 | APP_SERVICES_REMOTE="https://github.com/mozilla/application-services" 53 | 54 | DISABLE="false" 55 | APP_SERVICES_DIR= 56 | while (( "$#" )); do 57 | case "$1" in 58 | -d|--disable) 59 | DISABLE="true" 60 | shift 61 | ;; 62 | -h|--help) 63 | helptext 64 | exit 0 65 | ;; 66 | --) # end argument parsing 67 | shift 68 | break 69 | ;; 70 | --*=|-*) # unsupported flags 71 | echo "Error: Unsupported flag $1" >&2 72 | exit 1 73 | ;; 74 | *) # preserve positional arguments 75 | APP_SERVICES_DIR=$1 76 | shift 77 | ;; 78 | esac 79 | done 80 | 81 | if [ "true" = $DISABLE ]; then 82 | msg "Resetting $PACKAGE_FILE to use remote xcframework" 83 | # We disable the local development and revert back 84 | # ideally, users should just use git reset. 85 | # 86 | # This exist so local development can be easy to enable/disable 87 | # and we trust that once developers are ready to push changes 88 | # they will clean the files to make sure they are in the same 89 | # state they were in before any of the changes happened. 90 | perl -0777 -pi -e "s/ path: \"$FRAMEWORK_PATH_ESCAPED\"/ url: url, 91 | checksum: checksum/igs" $PACKAGE_FILE 92 | perl -0777 -pi -e "s/ path: \"$FOCUS_FRAMEWORK_PATH_ESCAPED\"/ url: focusUrl, 93 | checksum: focusChecksum/igs" $PACKAGE_FILE 94 | 95 | msg "Done reseting $PACKAGE_FILE" 96 | git add $PACKAGE_FILE 97 | msg "$PACKAGE_FILE changes staged" 98 | 99 | if [ -d $FRAMEWORK_PATH ]; then 100 | msg "Detected local framework, deleting it.." 101 | rm -rf $FRAMEWORK_PATH 102 | git add $FRAMEWORK_PATH 103 | msg "Deleted and staged the deletion of the local framework" 104 | fi 105 | if [ -d $FOCUS_FRAMEWORK_PATH ]; then 106 | msg "Detected local framework, deleting it.." 107 | rm -rf $FOCUS_FRAMEWORK_PATH 108 | git add $FOCUS_FRAMEWORK_PATH 109 | msg "Deleted and staged the deletion of the local framework" 110 | fi 111 | msg "IMPORTANT: reminder that changes to this repository are not visable to consumers until 112 | commited" 113 | exit 0 114 | fi 115 | 116 | if [ -z $APP_SERVICES_DIR ]; then 117 | msg "Please set the application-services path." 118 | msg "This is a path to a local checkout of the application services repository" 119 | msg "You can find the repository on $APP_SERVICES_REMOTE" 120 | exit 1 121 | fi 122 | 123 | ## We replace the url and checksum in the Package.swift with a refernce to the local 124 | ## framework path 125 | perl -0777 -pi -e "s/ url: url, 126 | checksum: checksum/ path: \"$FRAMEWORK_PATH_ESCAPED\"/igs" $PACKAGE_FILE 127 | 128 | ## We replace the url and checksum in the Package.swift with a refernce to the local 129 | ## framework path 130 | perl -0777 -pi -e "s/ url: focusUrl, 131 | checksum: focusChecksum/ path: \"$FOCUS_FRAMEWORK_PATH_ESCAPED\"/igs" $PACKAGE_FILE 132 | 133 | rm -rf "$SWIFT_SOURCE" 134 | 135 | ## First we build the xcframework in the application services repository 136 | msg "Building the xcframework in $APP_SERVICES_DIR" 137 | msg "This might take a few minutes" 138 | pushd $APP_SERVICES_DIR 139 | ./taskcluster/scripts/build-and-test-swift.py "$SWIFT_SOURCE" "$THIS_DIR" "$THIS_DIR/build/glean-dir" --force_build 140 | popd 141 | unzip -o "$THIS_DIR/MozillaRustComponents.xcframework.zip" && rm "$THIS_DIR/MozillaRustComponents.xcframework.zip" 142 | unzip -o "$THIS_DIR/FocusRustComponents.xcframework.zip" && rm "$THIS_DIR/FocusRustComponents.xcframework.zip" 143 | 144 | 145 | ## We also add the xcframework and swiftsource to git, and remind the user that it **needs** to be committed 146 | ## for it to be used 147 | msg "Staging the xcframework and package.swift changes to git" 148 | git add $FRAMEWORK_PATH 149 | git add $FOCUS_FRAMEWORK_PATH 150 | git add $PACKAGE_FILE 151 | 152 | 153 | msg "Swift source code also generated, staging it now" 154 | git add $SWIFT_SOURCE 155 | 156 | msg "Done setting up rust-components-swift to use $APP_SERVICES_DIR" 157 | msg "IMPORTANT: Reminder that changes to this repository 158 | MUST be commited before they can be used by consumers" 159 | -------------------------------------------------------------------------------- /automation/requirements.txt: -------------------------------------------------------------------------------- 1 | requests 2 | pygithub -------------------------------------------------------------------------------- /automation/update-from-application-services.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | from pathlib import Path 4 | from urllib.request import urlopen 5 | import argparse 6 | import fileinput 7 | import hashlib 8 | import json 9 | import subprocess 10 | import shutil 11 | import sys 12 | import tarfile 13 | import tempfile 14 | 15 | ROOT_DIR = Path(__file__).parent.parent 16 | PACKAGE_SWIFT = ROOT_DIR / "Package.swift" 17 | # Latest nightly nightly.json file from the taskcluster index 18 | NIGHTLY_JSON_URL = "https://firefox-ci-tc.services.mozilla.com/api/index/v1/task/project.application-services.v2.nightly.latest/artifacts/public%2Fbuild%2Fnightly.json" 19 | 20 | def main(): 21 | args = parse_args() 22 | version = VersionInfo(args.version) 23 | tag = version.swift_version 24 | if rev_exists(tag): 25 | print(f"Tag {tag} already exists, quitting") 26 | sys.exit(1) 27 | update_source(version) 28 | if not repo_has_changes(): 29 | print("No changes detected, quitting") 30 | sys.exit(0) 31 | subprocess.check_call([ 32 | "git", 33 | "commit", 34 | "--author", 35 | "Firefox Sync Engineering", 36 | "--message", 37 | f"Nightly auto-update ({version.swift_version})" 38 | ]) 39 | subprocess.check_call(["git", "tag", tag]) 40 | if args.push: 41 | subprocess.check_call(["git", "push", args.remote]) 42 | subprocess.check_call(["git", "push", args.remote, tag]) 43 | 44 | def update_source(version): 45 | print("Updating Package.swift xcframework info", flush=True) 46 | update_package_swift(version) 47 | 48 | print("Updating swift source files", flush=True) 49 | with tempfile.TemporaryDirectory() as temp_dir: 50 | temp_dir = Path(temp_dir) 51 | extract_tarball(version, temp_dir) 52 | replace_all_files(temp_dir) 53 | 54 | def parse_args(): 55 | parser = argparse.ArgumentParser(prog='build-and-test-swift.py') 56 | parser.add_argument('version', help="version to use (or `nightly`)") 57 | parser.add_argument('--push', help="Push changes to remote repository", 58 | action="store_true") 59 | parser.add_argument('--remote', help="Remote repository name", default="origin") 60 | return parser.parse_args() 61 | 62 | class VersionInfo: 63 | def __init__(self, app_services_version): 64 | self.is_nightly = app_services_version == "nightly" 65 | if self.is_nightly: 66 | with urlopen(NIGHTLY_JSON_URL) as stream: 67 | data = json.loads(stream.read()) 68 | app_services_version = data['version'] 69 | components = app_services_version.split(".") 70 | # check if the app services version is using the 2 or 3 component semver 71 | if len(components) == 2: 72 | # app_services_version is the 2-component version we normally use for application services 73 | self.app_services_version = app_services_version 74 | # swift_version is the 3-component version we use for Swift so that it's semver-compatible 75 | self.swift_version = f"{components[0]}.0.{components[1]}" 76 | # if it's 3-component, use as-is 77 | elif len(components) == 3: 78 | self.app_services_version = app_services_version 79 | self.swift_version = app_services_version 80 | else: 81 | raise ValueError(f"Invalid app_services_version: {app_services_version}") 82 | 83 | def rev_exists(branch): 84 | result = subprocess.run( 85 | ["git", "rev-parse", "--verify", branch], 86 | stdout=subprocess.DEVNULL, 87 | stderr=subprocess.DEVNULL) 88 | return result.returncode == 0 89 | 90 | def update_package_swift(version): 91 | url = swift_artifact_url(version, "MozillaRustComponents.xcframework.zip") 92 | focus_url = swift_artifact_url(version, "FocusRustComponents.xcframework.zip") 93 | checksum = compute_checksum(url) 94 | focus_checksum = compute_checksum(focus_url) 95 | replacements = { 96 | "let version =": f'let version = "{version.swift_version}"', 97 | "let url =": f'let url = "{url}"', 98 | "let checksum =": f'let checksum = "{checksum}"', 99 | "let focusUrl =": f'let focusUrl = "{focus_url}"', 100 | "let focusChecksum =": f'let focusChecksum = "{focus_checksum}"', 101 | } 102 | for line in fileinput.input(PACKAGE_SWIFT, inplace=True): 103 | for (line_start, replacement) in replacements.items(): 104 | if line.strip().startswith(line_start): 105 | line = f"{replacement}\n" 106 | break 107 | sys.stdout.write(line) 108 | subprocess.check_call(["git", "add", PACKAGE_SWIFT]) 109 | 110 | def compute_checksum(url): 111 | with urlopen(url) as stream: 112 | return hashlib.sha256(stream.read()).hexdigest() 113 | 114 | def extract_tarball(version, temp_dir): 115 | with urlopen(swift_artifact_url(version, "swift-components.tar.xz")) as f: 116 | with tarfile.open(mode="r|xz", fileobj=f) as tar: 117 | for member in tar: 118 | if not Path(member.name).name.startswith("._"): 119 | tar.extract(member, path=temp_dir) 120 | 121 | def replace_all_files(temp_dir): 122 | replace_files(temp_dir / "swift-components/all", "swift-source/all") 123 | replace_files(temp_dir / "swift-components/focus", "swift-source/focus") 124 | subprocess.check_call(["git", "add", "swift-source"]) 125 | 126 | """ 127 | Replace files in the git repo with files extracted from the tarball 128 | 129 | Args: 130 | source_dir: directory to look for sources 131 | repo_dir: relative directory in the repo to replace files in 132 | """ 133 | def replace_files(source_dir, repo_dir): 134 | shutil.rmtree(repo_dir) 135 | shutil.copytree(source_dir, repo_dir) 136 | 137 | def swift_artifact_url(version, filename): 138 | if version.is_nightly: 139 | return ("https://firefox-ci-tc.services.mozilla.com" 140 | "/api/index/v1/task/project.application-services.v2" 141 | f".swift.{version.app_services_version}/artifacts/public/build/{filename}") 142 | else: 143 | return ("https://archive.mozilla.org" 144 | "/pub/app-services/releases/" 145 | f"{version.app_services_version}/{filename}") 146 | 147 | def repo_has_changes(): 148 | result = subprocess.run([ 149 | "git", 150 | "diff-index", 151 | "--quiet", 152 | "HEAD", 153 | ]) 154 | return result.returncode != 0 155 | 156 | if __name__ == '__main__': 157 | main() 158 | -------------------------------------------------------------------------------- /swift-source/all/FxAClient/FxAccountConfig.swift: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | import Foundation 6 | 7 | // Compatibility wrapper around the `FxaConfig` struct. Let's keep this around for a bit to avoid 8 | // too many breaking changes for the consumer, but at some point soon we should switch them to using 9 | // the standard class 10 | // 11 | // Note: FxAConfig and FxAServer, with an upper-case "A" are the wrapper classes. FxaConfig and 12 | // FxaServer are the classes from Rust. 13 | open class FxAConfig { 14 | public enum Server: String { 15 | case release 16 | case stable 17 | case stage 18 | case china 19 | case localdev 20 | } 21 | 22 | // FxaConfig with lowercase "a" is the version the Rust code uses 23 | let rustConfig: FxaConfig 24 | 25 | public init( 26 | contentUrl: String, 27 | clientId: String, 28 | redirectUri: String, 29 | tokenServerUrlOverride: String? = nil 30 | ) { 31 | rustConfig = FxaConfig( 32 | server: FxaServer.custom(url: contentUrl), 33 | clientId: clientId, 34 | redirectUri: redirectUri, 35 | tokenServerUrlOverride: tokenServerUrlOverride 36 | ) 37 | } 38 | 39 | public init( 40 | server: Server, 41 | clientId: String, 42 | redirectUri: String, 43 | tokenServerUrlOverride: String? = nil 44 | ) { 45 | let rustServer: FxaServer 46 | switch server { 47 | case .release: 48 | rustServer = FxaServer.release 49 | case .stable: 50 | rustServer = FxaServer.stable 51 | case .stage: 52 | rustServer = FxaServer.stage 53 | case .china: 54 | rustServer = FxaServer.china 55 | case .localdev: 56 | rustServer = FxaServer.localDev 57 | } 58 | 59 | rustConfig = FxaConfig( 60 | server: rustServer, 61 | clientId: clientId, 62 | redirectUri: redirectUri, 63 | tokenServerUrlOverride: tokenServerUrlOverride 64 | ) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /swift-source/all/FxAClient/FxAccountDeviceConstellation.swift: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | import Foundation 6 | 7 | public extension Notification.Name { 8 | static let constellationStateUpdate = Notification.Name("constellationStateUpdate") 9 | } 10 | 11 | public struct ConstellationState { 12 | public let localDevice: Device? 13 | public let remoteDevices: [Device] 14 | } 15 | 16 | public enum SendEventError: Error { 17 | case tabsNotClosed(urls: [String]) 18 | case other(Error) 19 | } 20 | 21 | public class DeviceConstellation { 22 | var constellationState: ConstellationState? 23 | let account: PersistedFirefoxAccount 24 | 25 | init(account: PersistedFirefoxAccount) { 26 | self.account = account 27 | } 28 | 29 | /// Get local + remote devices synchronously. 30 | /// Note that this state might be empty, which should handle by calling `refreshState()` 31 | /// A `.constellationStateUpdate` notification is fired if the device list changes at any time. 32 | public func state() -> ConstellationState? { 33 | return constellationState 34 | } 35 | 36 | /// Refresh the list of remote devices. 37 | /// A `.constellationStateUpdate` notification might get fired once the new device list is fetched. 38 | public func refreshState() { 39 | DispatchQueue.global().async { 40 | FxALog.info("Refreshing device list...") 41 | do { 42 | let devices = try self.account.getDevices(ignoreCache: true) 43 | let localDevice = devices.first { $0.isCurrentDevice } 44 | if localDevice?.pushEndpointExpired ?? false { 45 | FxALog.debug("Current device needs push endpoint registration.") 46 | } 47 | let remoteDevices = devices.filter { !$0.isCurrentDevice } 48 | 49 | let newState = ConstellationState(localDevice: localDevice, remoteDevices: remoteDevices) 50 | self.constellationState = newState 51 | 52 | FxALog.debug("Refreshed device list; saw \(devices.count) device(s).") 53 | 54 | DispatchQueue.main.async { 55 | NotificationCenter.default.post( 56 | name: .constellationStateUpdate, 57 | object: nil, 58 | userInfo: ["newState": newState] 59 | ) 60 | } 61 | } catch { 62 | FxALog.error("Failure fetching the device list: \(error).") 63 | return 64 | } 65 | } 66 | } 67 | 68 | /// Updates the local device name. 69 | public func setLocalDeviceName(name: String) { 70 | DispatchQueue.global().async { 71 | do { 72 | try self.account.setDeviceName(name) 73 | // Update our list of devices in the background to reflect the change. 74 | self.refreshState() 75 | } catch { 76 | FxALog.error("Failure changing the local device name: \(error).") 77 | } 78 | } 79 | } 80 | 81 | /// Poll for device events we might have missed (e.g. Push notification missed, or device offline). 82 | /// Your app should probably call this on a regular basic (e.g. once a day). 83 | public func pollForCommands(completionHandler: @escaping (Result<[IncomingDeviceCommand], Error>) -> Void) { 84 | DispatchQueue.global().async { 85 | do { 86 | let events = try self.account.pollDeviceCommands() 87 | DispatchQueue.main.async { completionHandler(.success(events)) } 88 | } catch { 89 | DispatchQueue.main.async { completionHandler(.failure(error)) } 90 | } 91 | } 92 | } 93 | 94 | /// Send an event to another device such as Send Tab. 95 | public func sendEventToDevice(targetDeviceId: String, 96 | e: DeviceEventOutgoing, 97 | completionHandler: ((Result) -> Void)? = nil) 98 | { 99 | DispatchQueue.global().async { 100 | do { 101 | switch e { 102 | case let .sendTab(title, url): do { 103 | try self.account.sendSingleTab(targetDeviceId: targetDeviceId, title: title, url: url) 104 | completionHandler?(.success(())) 105 | } 106 | case let .closeTabs(urls): 107 | let result = try self.account.closeTabs(targetDeviceId: targetDeviceId, urls: urls) 108 | switch result { 109 | case .ok: 110 | completionHandler?(.success(())) 111 | case let .tabsNotClosed(urls): 112 | completionHandler?(.failure(.tabsNotClosed(urls: urls))) 113 | } 114 | } 115 | } catch { 116 | FxALog.error("Error sending event to another device: \(error).") 117 | completionHandler?(.failure(.other(error))) 118 | } 119 | } 120 | } 121 | 122 | /// Register the local AutoPush subscription with the FxA server. 123 | public func setDevicePushSubscription(sub: DevicePushSubscription) { 124 | DispatchQueue.global().async { 125 | do { 126 | try self.account.setDevicePushSubscription(sub: sub) 127 | } catch { 128 | FxALog.error("Failure setting push subscription: \(error).") 129 | } 130 | } 131 | } 132 | 133 | /// Once Push has decrypted a payload, send the payload to this method 134 | /// which will tell the app what to do with it in form of an `AccountEvent`. 135 | public func handlePushMessage(pushPayload: String, 136 | completionHandler: @escaping (Result) -> Void) 137 | { 138 | DispatchQueue.global().async { 139 | do { 140 | let event = try self.account.handlePushMessage(payload: pushPayload) 141 | self.processAccountEvent(event) 142 | DispatchQueue.main.async { completionHandler(.success(event)) } 143 | } catch { 144 | DispatchQueue.main.async { completionHandler(.failure(error)) } 145 | } 146 | } 147 | } 148 | 149 | /// This allows us to be helpful in certain circumstances e.g. refreshing the device list 150 | /// if we see a "device disconnected" push notification. 151 | func processAccountEvent(_ event: AccountEvent) { 152 | switch event { 153 | case .deviceDisconnected, .deviceConnected: refreshState() 154 | default: return 155 | } 156 | } 157 | 158 | func initDevice(name: String, type: DeviceType, capabilities: [DeviceCapability]) { 159 | // This method is called by `FxAccountManager` on its own asynchronous queue, hence 160 | // no wrapping in a `DispatchQueue.global().async`. 161 | assert(!Thread.isMainThread) 162 | do { 163 | try account.initializeDevice(name: name, deviceType: type, supportedCapabilities: capabilities) 164 | } catch { 165 | FxALog.error("Failure initializing device: \(error).") 166 | } 167 | } 168 | 169 | func ensureCapabilities(capabilities: [DeviceCapability]) { 170 | // This method is called by `FxAccountManager` on its own asynchronous queue, hence 171 | // no wrapping in a `DispatchQueue.global().async`. 172 | assert(!Thread.isMainThread) 173 | do { 174 | try account.ensureCapabilities(supportedCapabilities: capabilities) 175 | } catch { 176 | FxALog.error("Failure ensuring device capabilities: \(error).") 177 | } 178 | } 179 | } 180 | 181 | public enum DeviceEventOutgoing { 182 | case sendTab(title: String, url: String) 183 | case closeTabs(urls: [String]) 184 | } 185 | -------------------------------------------------------------------------------- /swift-source/all/FxAClient/FxAccountLogging.swift: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | import Foundation 6 | import os.log 7 | 8 | enum FxALog { 9 | private static let log = OSLog( 10 | subsystem: Bundle.main.bundleIdentifier!, 11 | category: "FxAccountManager" 12 | ) 13 | 14 | static func info(_ msg: String) { 15 | log(msg, type: .info) 16 | } 17 | 18 | static func debug(_ msg: String) { 19 | log(msg, type: .debug) 20 | } 21 | 22 | static func error(_ msg: String) { 23 | log(msg, type: .error) 24 | } 25 | 26 | private static func log(_ msg: String, type: OSLogType) { 27 | os_log("%@", log: log, type: type, msg) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /swift-source/all/FxAClient/FxAccountOAuth.swift: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | import Foundation 6 | 7 | public enum OAuthScope { 8 | // Necessary to fetch a profile. 9 | public static let profile: String = "profile" 10 | // Necessary to obtain sync keys. 11 | public static let oldSync: String = "https://identity.mozilla.com/apps/oldsync" 12 | // Necessary to obtain a sessionToken, which gives full access to the account. 13 | public static let session: String = "https://identity.mozilla.com/tokens/session" 14 | } 15 | -------------------------------------------------------------------------------- /swift-source/all/FxAClient/FxAccountState.swift: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | import Foundation 6 | 7 | /** 8 | * States of the [FxAccountManager]. 9 | */ 10 | enum AccountState { 11 | case start 12 | case notAuthenticated 13 | case authenticationProblem 14 | case authenticatedNoProfile 15 | case authenticatedWithProfile 16 | } 17 | 18 | /** 19 | * Base class for [FxAccountManager] state machine events. 20 | * Events aren't a simple enum class because we might want to pass data along with some of the events. 21 | */ 22 | enum Event { 23 | case initialize 24 | case accountNotFound 25 | case accountRestored 26 | case changedPassword(newSessionToken: String) 27 | case authenticated(authData: FxaAuthData) 28 | case authenticationError /* (error: AuthException) */ 29 | case recoveredFromAuthenticationProblem 30 | case fetchProfile(ignoreCache: Bool) 31 | case fetchedProfile 32 | case failedToFetchProfile 33 | case logout 34 | } 35 | 36 | extension FxAccountManager { 37 | // State transition matrix. Returns nil if there's no transition. 38 | static func nextState(state: AccountState, event: Event) -> AccountState? { 39 | switch state { 40 | case .start: 41 | switch event { 42 | case .initialize: return .start 43 | case .accountNotFound: return .notAuthenticated 44 | case .accountRestored: return .authenticatedNoProfile 45 | default: return nil 46 | } 47 | case .notAuthenticated: 48 | switch event { 49 | case .authenticated: return .authenticatedNoProfile 50 | default: return nil 51 | } 52 | case .authenticatedNoProfile: 53 | switch event { 54 | case .authenticationError: return .authenticationProblem 55 | case .fetchProfile: return .authenticatedNoProfile 56 | case .fetchedProfile: return .authenticatedWithProfile 57 | case .failedToFetchProfile: return .authenticatedNoProfile 58 | case .changedPassword: return .authenticatedNoProfile 59 | case .logout: return .notAuthenticated 60 | default: return nil 61 | } 62 | case .authenticatedWithProfile: 63 | switch event { 64 | case .fetchProfile: return .authenticatedWithProfile 65 | case .fetchedProfile: return .authenticatedWithProfile 66 | case .authenticationError: return .authenticationProblem 67 | case .changedPassword: return .authenticatedNoProfile 68 | case .logout: return .notAuthenticated 69 | default: return nil 70 | } 71 | case .authenticationProblem: 72 | switch event { 73 | case .recoveredFromAuthenticationProblem: return .authenticatedNoProfile 74 | case .authenticated: return .authenticatedNoProfile 75 | case .logout: return .notAuthenticated 76 | default: return nil 77 | } 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /swift-source/all/FxAClient/FxAccountStorage.swift: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | import Foundation 6 | 7 | class KeyChainAccountStorage { 8 | var keychainWrapper: MZKeychainWrapper 9 | static var keychainKey: String = "accountJSON" 10 | static var accessibility: MZKeychainItemAccessibility = .afterFirstUnlock 11 | 12 | init(keychainAccessGroup: String?) { 13 | keychainWrapper = MZKeychainWrapper.sharedAppContainerKeychain(keychainAccessGroup: keychainAccessGroup) 14 | } 15 | 16 | func read() -> PersistedFirefoxAccount? { 17 | // Firefox iOS v25.0 shipped with the default accessibility, which breaks Send Tab when the screen is locked. 18 | // This method migrates the existing keychains to the correct accessibility. 19 | keychainWrapper.ensureStringItemAccessibility( 20 | KeyChainAccountStorage.accessibility, 21 | forKey: KeyChainAccountStorage.keychainKey 22 | ) 23 | if let json = keychainWrapper.string( 24 | forKey: KeyChainAccountStorage.keychainKey, 25 | withAccessibility: KeyChainAccountStorage.accessibility 26 | ) { 27 | do { 28 | return try PersistedFirefoxAccount.fromJSON(data: json) 29 | } catch { 30 | FxALog.error("FxAccount internal state de-serialization failed: \(error).") 31 | return nil 32 | } 33 | } 34 | return nil 35 | } 36 | 37 | func write(_ json: String) { 38 | if !keychainWrapper.set( 39 | json, 40 | forKey: KeyChainAccountStorage.keychainKey, 41 | withAccessibility: KeyChainAccountStorage.accessibility 42 | ) { 43 | FxALog.error("Could not write account state.") 44 | } 45 | } 46 | 47 | func clear() { 48 | if !keychainWrapper.removeObject( 49 | forKey: KeyChainAccountStorage.keychainKey, 50 | withAccessibility: KeyChainAccountStorage.accessibility 51 | ) { 52 | FxALog.error("Could not clear account state.") 53 | } 54 | } 55 | } 56 | 57 | public extension MZKeychainWrapper { 58 | func ensureStringItemAccessibility( 59 | _ accessibility: MZKeychainItemAccessibility, 60 | forKey key: String 61 | ) { 62 | if hasValue(forKey: key) { 63 | if accessibilityOfKey(key) != accessibility { 64 | FxALog.info("ensureStringItemAccessibility: updating item \(key) with \(accessibility)") 65 | 66 | guard let value = string(forKey: key) else { 67 | FxALog.error("ensureStringItemAccessibility: failed to get item \(key)") 68 | return 69 | } 70 | 71 | if !removeObject(forKey: key) { 72 | FxALog.error("ensureStringItemAccessibility: failed to remove item \(key)") 73 | } 74 | 75 | if !set(value, forKey: key, withAccessibility: accessibility) { 76 | FxALog.error("ensureStringItemAccessibility: failed to update item \(key)") 77 | } 78 | } 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /swift-source/all/FxAClient/KeychainWrapper+.swift: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | import Foundation 6 | 7 | extension MZKeychainWrapper { 8 | /// Return the base bundle identifier. 9 | /// 10 | /// This function is smart enough to find out if it is being called from an extension or the main application. In 11 | /// case of the former, it will chop off the extension identifier from the bundle since that is a suffix not part 12 | /// of the *base* bundle identifier. 13 | static var baseBundleIdentifier: String { 14 | let bundle = Bundle.main 15 | let packageType = bundle.object(forInfoDictionaryKey: "CFBundlePackageType") as? String 16 | let baseBundleIdentifier = bundle.bundleIdentifier! 17 | if packageType == "XPC!" { 18 | let components = baseBundleIdentifier.components(separatedBy: ".") 19 | return components[0 ..< components.count - 1].joined(separator: ".") 20 | } 21 | return baseBundleIdentifier 22 | } 23 | 24 | static var shared: MZKeychainWrapper? 25 | 26 | static func sharedAppContainerKeychain(keychainAccessGroup: String?) -> MZKeychainWrapper { 27 | if let s = shared { 28 | return s 29 | } 30 | let wrapper = MZKeychainWrapper(serviceName: baseBundleIdentifier, accessGroup: keychainAccessGroup) 31 | shared = wrapper 32 | return wrapper 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /swift-source/all/FxAClient/MZKeychain/KeychainItemAccessibility.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeychainItemAccessibility.swift 3 | // SwiftKeychainWrapper 4 | // 5 | // Created by James Blair on 4/24/16. 6 | // Copyright © 2016 Jason Rendel. All rights reserved. 7 | // 8 | // The MIT License (MIT) 9 | // 10 | // Permission is hereby granted, free of charge, to any person obtaining a copy 11 | // of this software and associated documentation files (the "Software"), to deal 12 | // in the Software without restriction, including without limitation the rights 13 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | // copies of the Software, and to permit persons to whom the Software is 15 | // furnished to do so, subject to the following conditions: 16 | // 17 | // The above copyright notice and this permission notice shall be included in all 18 | // copies or substantial portions of the Software. 19 | // 20 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 | // SOFTWARE. 27 | 28 | // swiftlint:disable all 29 | 30 | import Foundation 31 | 32 | protocol MZKeychainAttrRepresentable { 33 | var keychainAttrValue: CFString { get } 34 | } 35 | 36 | // MARK: - KeychainItemAccessibility 37 | 38 | public enum MZKeychainItemAccessibility { 39 | /** 40 | The data in the keychain item cannot be accessed after a restart until the device has been unlocked once by the user. 41 | 42 | After the first unlock, the data remains accessible until the next restart. This is recommended for items that need to be accessed by background applications. Items with this attribute migrate to a new device when using encrypted backups. 43 | */ 44 | @available(iOS 4, *) 45 | case afterFirstUnlock 46 | 47 | /** 48 | The data in the keychain item cannot be accessed after a restart until the device has been unlocked once by the user. 49 | 50 | After the first unlock, the data remains accessible until the next restart. This is recommended for items that need to be accessed by background applications. Items with this attribute do not migrate to a new device. Thus, after restoring from a backup of a different device, these items will not be present. 51 | */ 52 | @available(iOS 4, *) 53 | case afterFirstUnlockThisDeviceOnly 54 | 55 | /** 56 | The data in the keychain item can always be accessed regardless of whether the device is locked. 57 | 58 | This is not recommended for application use. Items with this attribute migrate to a new device when using encrypted backups. 59 | */ 60 | @available(iOS 4, *) 61 | case always 62 | 63 | /** 64 | The data in the keychain can only be accessed when the device is unlocked. Only available if a passcode is set on the device. 65 | 66 | This is recommended for items that only need to be accessible while the application is in the foreground. Items with this attribute never migrate to a new device. After a backup is restored to a new device, these items are missing. No items can be stored in this class on devices without a passcode. Disabling the device passcode causes all items in this class to be deleted. 67 | */ 68 | @available(iOS 8, *) 69 | case whenPasscodeSetThisDeviceOnly 70 | 71 | /** 72 | The data in the keychain item can always be accessed regardless of whether the device is locked. 73 | 74 | This is not recommended for application use. Items with this attribute do not migrate to a new device. Thus, after restoring from a backup of a different device, these items will not be present. 75 | */ 76 | @available(iOS 4, *) 77 | case alwaysThisDeviceOnly 78 | 79 | /** 80 | The data in the keychain item can be accessed only while the device is unlocked by the user. 81 | 82 | This is recommended for items that need to be accessible only while the application is in the foreground. Items with this attribute migrate to a new device when using encrypted backups. 83 | 84 | This is the default value for keychain items added without explicitly setting an accessibility constant. 85 | */ 86 | @available(iOS 4, *) 87 | case whenUnlocked 88 | 89 | /** 90 | The data in the keychain item can be accessed only while the device is unlocked by the user. 91 | 92 | This is recommended for items that need to be accessible only while the application is in the foreground. Items with this attribute do not migrate to a new device. Thus, after restoring from a backup of a different device, these items will not be present. 93 | */ 94 | @available(iOS 4, *) 95 | case whenUnlockedThisDeviceOnly 96 | 97 | static func accessibilityForAttributeValue(_ keychainAttrValue: CFString) -> MZKeychainItemAccessibility? { 98 | keychainItemAccessibilityLookup.first { $0.value == keychainAttrValue }?.key 99 | } 100 | } 101 | 102 | private let keychainItemAccessibilityLookup: [MZKeychainItemAccessibility: CFString] = 103 | [ 104 | .afterFirstUnlock: kSecAttrAccessibleAfterFirstUnlock, 105 | .afterFirstUnlockThisDeviceOnly: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, 106 | .whenPasscodeSetThisDeviceOnly: kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly, 107 | .whenUnlocked: kSecAttrAccessibleWhenUnlocked, 108 | .whenUnlockedThisDeviceOnly: kSecAttrAccessibleWhenUnlockedThisDeviceOnly, 109 | ] 110 | 111 | extension MZKeychainItemAccessibility: MZKeychainAttrRepresentable { 112 | var keychainAttrValue: CFString { 113 | keychainItemAccessibilityLookup[self]! 114 | } 115 | } 116 | 117 | // swiftlint: enable all 118 | -------------------------------------------------------------------------------- /swift-source/all/FxAClient/MZKeychain/KeychainWrapperSubscript.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeychainWrapperSubscript.swift 3 | // SwiftKeychainWrapper 4 | // 5 | // Created by Vato Kostava on 5/10/20. 6 | // Copyright © 2020 Jason Rendel. All rights reserved. 7 | // 8 | 9 | // swiftlint:disable all 10 | 11 | import Foundation 12 | 13 | #if canImport(CoreGraphics) 14 | import CoreGraphics 15 | #endif 16 | 17 | public extension MZKeychainWrapper { 18 | func remove(forKey key: Key) { 19 | removeObject(forKey: key.rawValue) 20 | } 21 | } 22 | 23 | public extension MZKeychainWrapper { 24 | subscript(key: Key) -> String? { 25 | get { return string(forKey: key) } 26 | set { 27 | guard let value = newValue else { return } 28 | set(value, forKey: key.rawValue) 29 | } 30 | } 31 | 32 | subscript(key: Key) -> Bool? { 33 | get { return bool(forKey: key) } 34 | set { 35 | guard let value = newValue else { return } 36 | set(value, forKey: key.rawValue) 37 | } 38 | } 39 | 40 | subscript(key: Key) -> Int? { 41 | get { return integer(forKey: key) } 42 | set { 43 | guard let value = newValue else { return } 44 | set(value, forKey: key.rawValue) 45 | } 46 | } 47 | 48 | subscript(key: Key) -> Double? { 49 | get { return double(forKey: key) } 50 | set { 51 | guard let value = newValue else { return } 52 | set(value, forKey: key.rawValue) 53 | } 54 | } 55 | 56 | subscript(key: Key) -> Float? { 57 | get { return float(forKey: key) } 58 | set { 59 | guard let value = newValue else { return } 60 | set(value, forKey: key.rawValue) 61 | } 62 | } 63 | 64 | #if canImport(CoreGraphics) 65 | subscript(key: Key) -> CGFloat? { 66 | get { return cgFloat(forKey: key) } 67 | set { 68 | guard let cgValue = newValue else { return } 69 | let value = Float(cgValue) 70 | set(value, forKey: key.rawValue) 71 | } 72 | } 73 | #endif 74 | 75 | subscript(key: Key) -> Data? { 76 | get { return data(forKey: key) } 77 | set { 78 | guard let value = newValue else { return } 79 | set(value, forKey: key.rawValue) 80 | } 81 | } 82 | } 83 | 84 | public extension MZKeychainWrapper { 85 | func data(forKey key: Key) -> Data? { 86 | if let value = data(forKey: key.rawValue) { 87 | return value 88 | } 89 | return nil 90 | } 91 | 92 | func bool(forKey key: Key) -> Bool? { 93 | if let value = bool(forKey: key.rawValue) { 94 | return value 95 | } 96 | return nil 97 | } 98 | 99 | func integer(forKey key: Key) -> Int? { 100 | if let value = integer(forKey: key.rawValue) { 101 | return value 102 | } 103 | return nil 104 | } 105 | 106 | func float(forKey key: Key) -> Float? { 107 | if let value = float(forKey: key.rawValue) { 108 | return value 109 | } 110 | return nil 111 | } 112 | 113 | #if canImport(CoreGraphics) 114 | func cgFloat(forKey key: Key) -> CGFloat? { 115 | if let value = float(forKey: key) { 116 | return CGFloat(value) 117 | } 118 | 119 | return nil 120 | } 121 | #endif 122 | 123 | func double(forKey key: Key) -> Double? { 124 | if let value = double(forKey: key.rawValue) { 125 | return value 126 | } 127 | return nil 128 | } 129 | 130 | func string(forKey key: Key) -> String? { 131 | if let value = string(forKey: key.rawValue) { 132 | return value 133 | } 134 | 135 | return nil 136 | } 137 | } 138 | 139 | public extension MZKeychainWrapper { 140 | struct Key: Hashable, RawRepresentable, ExpressibleByStringLiteral { 141 | public var rawValue: String 142 | 143 | public init(rawValue: String) { 144 | self.rawValue = rawValue 145 | } 146 | 147 | public init(stringLiteral value: String) { 148 | rawValue = value 149 | } 150 | } 151 | } 152 | 153 | // swiftlint:enable all 154 | -------------------------------------------------------------------------------- /swift-source/all/Generated/megazord_ios.modulemap: -------------------------------------------------------------------------------- 1 | module MozillaRustComponents { 2 | header "autofillFFI.h" 3 | header "contextIDFFI.h" 4 | header "crashtestFFI.h" 5 | header "errorFFI.h" 6 | header "fxa_clientFFI.h" 7 | header "init_rust_componentsFFI.h" 8 | header "loginsFFI.h" 9 | header "merinoFFI.h" 10 | header "nimbusFFI.h" 11 | header "placesFFI.h" 12 | header "pushFFI.h" 13 | header "relayFFI.h" 14 | header "remote_settingsFFI.h" 15 | header "rustlogforwarderFFI.h" 16 | header "searchFFI.h" 17 | header "suggestFFI.h" 18 | header "sync15FFI.h" 19 | header "syncmanagerFFI.h" 20 | header "tabsFFI.h" 21 | header "tracingFFI.h" 22 | export * 23 | } -------------------------------------------------------------------------------- /swift-source/all/Logins/LoginsStorage.swift: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | import Foundation 6 | import Glean 7 | import UIKit 8 | 9 | typealias LoginsStoreError = LoginsApiError 10 | 11 | /* 12 | ** We probably should have this class go away eventually as it's really only a thin wrapper 13 | * similar to its kotlin equivalents, however the only thing preventing this from being removed is 14 | * the queue.sync which we should be moved over to the consumer side of things 15 | */ 16 | open class LoginsStorage { 17 | private var store: LoginStore 18 | private let queue = DispatchQueue(label: "com.mozilla.logins-storage") 19 | 20 | public init(databasePath: String, keyManager: KeyManager) throws { 21 | store = try LoginStore(path: databasePath, encdec: createManagedEncdec(keyManager: keyManager)) 22 | } 23 | 24 | open func wipeLocal() throws { 25 | try queue.sync { 26 | try self.store.wipeLocal() 27 | } 28 | } 29 | 30 | /// Delete the record with the given ID. Returns false if no such record existed. 31 | open func delete(id: String) throws -> Bool { 32 | return try queue.sync { 33 | try self.store.delete(id: id) 34 | } 35 | } 36 | 37 | /// Locally delete records from the store that cannot be decrypted. For exclusive 38 | /// use in the iOS logins verification process. 39 | open func deleteUndecryptableRecordsForRemoteReplacement() throws { 40 | return try queue.sync { 41 | try self.store.deleteUndecryptableRecordsForRemoteReplacement() 42 | } 43 | } 44 | 45 | /// Bump the usage count for the record with the given id. 46 | /// 47 | /// Throws `LoginStoreError.NoSuchRecord` if there was no such record. 48 | open func touch(id: String) throws { 49 | try queue.sync { 50 | try self.store.touch(id: id) 51 | } 52 | } 53 | 54 | /// Insert `login` into the database. If `login.id` is not empty, 55 | /// then this throws `LoginStoreError.DuplicateGuid` if there is a collision 56 | /// 57 | /// Returns the `id` of the newly inserted record. 58 | open func add(login: LoginEntry) throws -> Login { 59 | return try queue.sync { 60 | try self.store.add(login: login) 61 | } 62 | } 63 | 64 | /// Update `login` in the database. If `login.id` does not refer to a known 65 | /// login, then this throws `LoginStoreError.NoSuchRecord`. 66 | open func update(id: String, login: LoginEntry) throws -> Login { 67 | return try queue.sync { 68 | try self.store.update(id: id, login: login) 69 | } 70 | } 71 | 72 | /// Get the record with the given id. Returns nil if there is no such record. 73 | open func get(id: String) throws -> Login? { 74 | return try queue.sync { 75 | try self.store.get(id: id) 76 | } 77 | } 78 | 79 | /// Check whether the database is empty. 80 | open func isEmpty() throws -> Bool { 81 | return try queue.sync { 82 | try self.store.isEmpty() 83 | } 84 | } 85 | 86 | /// Get the entire list of records. 87 | open func list() throws -> [Login] { 88 | return try queue.sync { 89 | try self.store.list() 90 | } 91 | } 92 | 93 | /// Check whether logins exist for some base domain. 94 | open func hasLoginsByBaseDomain(baseDomain: String) throws -> Bool { 95 | return try queue.sync { 96 | try self.store.hasLoginsByBaseDomain(baseDomain: baseDomain) 97 | } 98 | } 99 | 100 | /// Get the list of records for some base domain. 101 | open func getByBaseDomain(baseDomain: String) throws -> [Login] { 102 | return try queue.sync { 103 | try self.store.getByBaseDomain(baseDomain: baseDomain) 104 | } 105 | } 106 | 107 | /// Register with the sync manager 108 | open func registerWithSyncManager() { 109 | return queue.sync { 110 | self.store.registerWithSyncManager() 111 | } 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /swift-source/all/Nimbus/ArgumentProcessor.swift: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | import Foundation 6 | 7 | enum ArgumentProcessor { 8 | static func initializeTooling(nimbus: NimbusInterface, args: CliArgs) { 9 | if args.resetDatabase { 10 | nimbus.resetEnrollmentsDatabase().waitUntilFinished() 11 | } 12 | if let experiments = args.experiments { 13 | nimbus.setExperimentsLocally(experiments) 14 | nimbus.applyPendingExperiments().waitUntilFinished() 15 | // setExperimentsLocally and applyPendingExperiments run on the 16 | // same single threaded dispatch queue, so we can run them in series, 17 | // and wait for the apply. 18 | nimbus.setFetchEnabled(false) 19 | } 20 | if args.logState { 21 | nimbus.dumpStateToLog() 22 | } 23 | // We have isLauncher here doing nothing; this is to match the Android implementation. 24 | // There is nothing to do at this point, because we're unable to affect the flow of the app. 25 | if args.isLauncher { 26 | () // NOOP. 27 | } 28 | } 29 | 30 | static func createCommandLineArgs(url: URL) -> CliArgs? { 31 | guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), 32 | let scheme = components.scheme, 33 | let queryItems = components.queryItems, 34 | !["http", "https"].contains(scheme) 35 | else { 36 | return nil 37 | } 38 | 39 | var experiments: String? 40 | var resetDatabase = false 41 | var logState = false 42 | var isLauncher = false 43 | var meantForUs = false 44 | 45 | func flag(_ v: String?) -> Bool { 46 | guard let v = v else { 47 | return true 48 | } 49 | return ["1", "true"].contains(v.lowercased()) 50 | } 51 | 52 | for item in queryItems { 53 | switch item.name { 54 | case "--nimbus-cli": 55 | meantForUs = flag(item.value) 56 | case "--experiments": 57 | experiments = item.value?.removingPercentEncoding 58 | case "--reset-db": 59 | resetDatabase = flag(item.value) 60 | case "--log-state": 61 | logState = flag(item.value) 62 | case "--is-launcher": 63 | isLauncher = flag(item.value) 64 | default: 65 | () // NOOP 66 | } 67 | } 68 | 69 | if !meantForUs { 70 | return nil 71 | } 72 | 73 | return check(args: CliArgs( 74 | resetDatabase: resetDatabase, 75 | experiments: experiments, 76 | logState: logState, 77 | isLauncher: isLauncher 78 | )) 79 | } 80 | 81 | static func createCommandLineArgs(args: [String]?) -> CliArgs? { 82 | guard let args = args else { 83 | return nil 84 | } 85 | if !args.contains("--nimbus-cli") { 86 | return nil 87 | } 88 | 89 | var argMap = [String: String]() 90 | var key: String? 91 | var resetDatabase = false 92 | var logState = false 93 | 94 | for arg in args { 95 | var value: String? 96 | switch arg { 97 | case "--version": 98 | key = "version" 99 | case "--experiments": 100 | key = "experiments" 101 | case "--reset-db": 102 | resetDatabase = true 103 | case "--log-state": 104 | logState = true 105 | default: 106 | value = arg.replacingOccurrences(of: "'", with: "'") 107 | } 108 | 109 | if let k = key, let v = value { 110 | argMap[k] = v 111 | key = nil 112 | value = nil 113 | } 114 | } 115 | 116 | if argMap["version"] != "1" { 117 | return nil 118 | } 119 | 120 | let experiments = argMap["experiments"] 121 | 122 | return check(args: CliArgs( 123 | resetDatabase: resetDatabase, 124 | experiments: experiments, 125 | logState: logState, 126 | isLauncher: false 127 | )) 128 | } 129 | 130 | static func check(args: CliArgs) -> CliArgs? { 131 | if let string = args.experiments { 132 | guard let payload = try? Dictionary.parse(jsonString: string), payload["data"] is [Any] 133 | else { 134 | return nil 135 | } 136 | } 137 | return args 138 | } 139 | } 140 | 141 | struct CliArgs: Equatable { 142 | let resetDatabase: Bool 143 | let experiments: String? 144 | let logState: Bool 145 | let isLauncher: Bool 146 | } 147 | 148 | public extension NimbusInterface { 149 | func initializeTooling(url: URL?) { 150 | guard let url = url, 151 | let args = ArgumentProcessor.createCommandLineArgs(url: url) 152 | else { 153 | return 154 | } 155 | ArgumentProcessor.initializeTooling(nimbus: self, args: args) 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /swift-source/all/Nimbus/Bundle+.swift: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | import Foundation 6 | #if canImport(UIKit) 7 | import UIKit 8 | #endif 9 | 10 | public extension Array where Element == Bundle { 11 | /// Search through the resource bundles looking for an image of the given name. 12 | /// 13 | /// If no image is found in any of the `resourceBundles`, then the `nil` is returned. 14 | func getImage(named name: String) -> UIImage? { 15 | for bundle in self { 16 | if let image = UIImage(named: name, in: bundle, compatibleWith: nil) { 17 | image.accessibilityIdentifier = name 18 | return image 19 | } 20 | } 21 | return nil 22 | } 23 | 24 | /// Search through the resource bundles looking for an image of the given name. 25 | /// 26 | /// If no image is found in any of the `resourceBundles`, then a fatal error is 27 | /// thrown. This method is only intended for use with hard coded default images 28 | /// when other images have been omitted or are missing. 29 | /// 30 | /// The two ways of fixing this would be to provide the image as its named in the `.fml.yaml` 31 | /// file or to change the name of the image in the FML file. 32 | func getImageNotNull(named name: String) -> UIImage { 33 | guard let image = getImage(named: name) else { 34 | fatalError( 35 | "An image named \"\(name)\" has been named in a `.fml.yaml` file, but is missing from the asset bundle") 36 | } 37 | return image 38 | } 39 | 40 | /// Search through the resource bundles looking for localized strings with the given name. 41 | /// If the `name` contains exactly one slash, it is split up and the first part of the string is used 42 | /// as the `tableName` and the second the `key` in localized string lookup. 43 | /// If no string is found in any of the `resourceBundles`, then the `name` is passed back unmodified. 44 | func getString(named name: String) -> String? { 45 | let parts = name.split(separator: "/", maxSplits: 1, omittingEmptySubsequences: true).map { String($0) } 46 | let key: String 47 | let tableName: String? 48 | switch parts.count { 49 | case 2: 50 | tableName = parts[0] 51 | key = parts[1] 52 | default: 53 | tableName = nil 54 | key = name 55 | } 56 | 57 | for bundle in self { 58 | let value = bundle.localizedString(forKey: key, value: nil, table: tableName) 59 | if value != key { 60 | return value 61 | } 62 | } 63 | return nil 64 | } 65 | } 66 | 67 | public extension Bundle { 68 | /// Loads the language bundle from this one. 69 | /// If `language` is `nil`, then look for the development region language. 70 | /// If no bundle for the language exists, then return `nil`. 71 | func fallbackTranslationBundle(language: String? = nil) -> Bundle? { 72 | #if canImport(UIKit) 73 | if let lang = language ?? infoDictionary?["CFBundleDevelopmentRegion"] as? String, 74 | let path = path(forResource: lang, ofType: "lproj") 75 | { 76 | return Bundle(path: path) 77 | } 78 | #endif 79 | return nil 80 | } 81 | } 82 | 83 | public extension UIImage { 84 | /// The ``accessibilityIdentifier``, or "unknown-image" if not found. 85 | /// 86 | /// The ``accessibilityIdentifier`` is set when images are loaded via Nimbus, so this 87 | /// really to make the compiler happy with the generated code. 88 | var encodableImageName: String { 89 | accessibilityIdentifier ?? "unknown-image" 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /swift-source/all/Nimbus/Collections+.swift: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | import Foundation 6 | 7 | public extension Dictionary { 8 | func mapKeysNotNull(_ transform: (Key) -> K1?) -> [K1: Value] { 9 | let transformed: [(K1, Value)] = compactMap { k, v in 10 | transform(k).flatMap { ($0, v) } 11 | } 12 | return [K1: Value](uniqueKeysWithValues: transformed) 13 | } 14 | 15 | @inline(__always) 16 | func mapValuesNotNull(_ transform: (Value) -> V1?) -> [Key: V1] { 17 | return compactMapValues(transform) 18 | } 19 | 20 | func mapEntriesNotNull(_ keyTransform: (Key) -> K1?, _ valueTransform: (Value) -> V1?) -> [K1: V1] { 21 | let transformed: [(K1, V1)] = compactMap { k, v in 22 | guard let k1 = keyTransform(k), 23 | let v1 = valueTransform(v) 24 | else { 25 | return nil 26 | } 27 | return (k1, v1) 28 | } 29 | return [K1: V1](uniqueKeysWithValues: transformed) 30 | } 31 | 32 | func mergeWith(_ defaults: [Key: Value], _ valueMerger: ((Value, Value) -> Value)? = nil) -> [Key: Value] { 33 | guard let valueMerger = valueMerger else { 34 | return merging(defaults, uniquingKeysWith: { override, _ in override }) 35 | } 36 | 37 | return merging(defaults, uniquingKeysWith: valueMerger) 38 | } 39 | } 40 | 41 | public extension Array { 42 | @inline(__always) 43 | func mapNotNull(_ transform: (Element) throws -> ElementOfResult?) rethrows -> [ElementOfResult] { 44 | try compactMap(transform) 45 | } 46 | } 47 | 48 | /// Convenience extensions to make working elements coming from the `Variables` 49 | /// object slightly easier/regular. 50 | public extension String { 51 | func map(_ transform: (Self) throws -> V?) rethrows -> V? { 52 | return try transform(self) 53 | } 54 | } 55 | 56 | public extension Variables { 57 | func map(_ transform: (Self) throws -> V) rethrows -> V { 58 | return try transform(self) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /swift-source/all/Nimbus/Dictionary+.swift: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | import Foundation 6 | 7 | extension Dictionary where Key == String, Value == Any { 8 | func stringify() throws -> String { 9 | let data = try JSONSerialization.data(withJSONObject: self) 10 | guard let s = String(data: data, encoding: .utf8) else { 11 | throw NimbusError.JsonError(message: "Unable to encode") 12 | } 13 | return s 14 | } 15 | 16 | static func parse(jsonString string: String) throws -> [String: Any] { 17 | guard let data = string.data(using: .utf8) else { 18 | throw NimbusError.JsonError(message: "Unable to decode string into data") 19 | } 20 | let obj = try JSONSerialization.jsonObject(with: data) 21 | guard let obj = obj as? [String: Any] else { 22 | throw NimbusError.JsonError(message: "Unable to cast into JSONObject") 23 | } 24 | return obj 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /swift-source/all/Nimbus/FeatureHolder.swift: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | import Foundation 5 | 6 | public typealias GetSdk = () -> FeaturesInterface? 7 | 8 | public protocol FeatureHolderInterface { 9 | /// Send an exposure event for this feature. This should be done when the user is shown the feature, and may change 10 | /// their behavior because of it. 11 | func recordExposure() 12 | 13 | /// Send an exposure event for this feature, in the given experiment. 14 | /// 15 | /// If the experiment does not exist, or the client is not enrolled in that experiment, then no exposure event 16 | /// is recorded. 17 | /// 18 | /// If you are not sure of the experiment slug, then this is _not_ the API you need: you should use 19 | /// {recordExposure} instead. 20 | /// 21 | /// - Parameter slug the experiment identifier, likely derived from the ``value``. 22 | func recordExperimentExposure(slug: String) 23 | 24 | /// Send a malformed feature event for this feature. 25 | /// 26 | /// - Parameter partId an optional detail or part identifier to be attached to the event. 27 | func recordMalformedConfiguration(with partId: String) 28 | 29 | /// Is this feature the focus of an automated test. 30 | /// 31 | /// A utility flag to be used in conjunction with ``HardcodedNimbusFeatures``. 32 | /// 33 | /// It is intended for use for app-code to detect when the app is under test, and 34 | /// take steps to make itself easier to test. 35 | /// 36 | /// These cases should be rare, and developers should look for other ways to test 37 | /// code without relying on this facility. 38 | /// 39 | /// For example, a background worker might be scheduled to run every 24 hours, but 40 | /// under test it would be desirable to run immediately, and only once. 41 | func isUnderTest() -> Bool 42 | } 43 | 44 | /// ``FeatureHolder`` is a class that unpacks a JSON object from the Nimbus SDK and transforms it into a useful 45 | /// type safe object, generated from a feature manifest (a `.fml.yaml` file). 46 | /// 47 | /// The routinely useful methods to application developers are the ``value()`` and the event recording 48 | /// methods of ``FeatureHolderInterface``. 49 | /// 50 | /// There are methods useful for testing, and more advanced uses: these all start with `with`. 51 | /// 52 | public class FeatureHolder { 53 | private let lock = NSLock() 54 | private var cachedValue: T? 55 | 56 | private var getSdk: GetSdk 57 | private let featureId: String 58 | 59 | private var create: (Variables, UserDefaults?) -> T 60 | 61 | public init(_ getSdk: @escaping () -> FeaturesInterface?, 62 | featureId: String, 63 | with create: @escaping (Variables, UserDefaults?) -> T) 64 | { 65 | self.getSdk = getSdk 66 | self.featureId = featureId 67 | self.create = create 68 | } 69 | 70 | /// Get the JSON configuration from the Nimbus SDK and transform it into a configuration object as specified 71 | /// in the feature manifest. This is done each call of the method, so the method should be called once, and the 72 | /// result used for the configuration of the feature. 73 | /// 74 | /// Some care is taken to cache the value, this is for performance critical uses of the API. 75 | /// It is possible to invalidate the cache with `FxNimbus.invalidateCachedValues()` or ``with(cachedValue: nil)``. 76 | public func value() -> T { 77 | lock.lock() 78 | defer { self.lock.unlock() } 79 | if let v = cachedValue { 80 | return v 81 | } 82 | var variables: Variables = NilVariables.instance 83 | var defaults: UserDefaults? 84 | if let sdk = getSdk() { 85 | variables = sdk.getVariables(featureId: featureId, sendExposureEvent: false) 86 | defaults = sdk.userDefaults 87 | } 88 | let v = create(variables, defaults) 89 | cachedValue = v 90 | return v 91 | } 92 | 93 | /// This overwrites the cached value with the passed one. 94 | /// 95 | /// This is most likely useful during testing only. 96 | public func with(cachedValue value: T?) { 97 | lock.lock() 98 | defer { self.lock.unlock() } 99 | cachedValue = value 100 | } 101 | 102 | /// This resets the SDK and clears the cached value. 103 | /// 104 | /// This is especially useful at start up and for imported features. 105 | public func with(sdk: @escaping () -> FeaturesInterface?) { 106 | lock.lock() 107 | defer { self.lock.unlock() } 108 | getSdk = sdk 109 | cachedValue = nil 110 | } 111 | 112 | /// This changes the mapping between a ``Variables`` and the feature configuration object. 113 | /// 114 | /// This is most likely useful during testing and other generated code. 115 | public func with(initializer: @escaping (Variables, UserDefaults?) -> T) { 116 | lock.lock() 117 | defer { self.lock.unlock() } 118 | cachedValue = nil 119 | create = initializer 120 | } 121 | } 122 | 123 | extension FeatureHolder: FeatureHolderInterface { 124 | public func recordExposure() { 125 | if !value().isModified() { 126 | getSdk()?.recordExposureEvent(featureId: featureId, experimentSlug: nil) 127 | } 128 | } 129 | 130 | public func recordExperimentExposure(slug: String) { 131 | if !value().isModified() { 132 | getSdk()?.recordExposureEvent(featureId: featureId, experimentSlug: slug) 133 | } 134 | } 135 | 136 | public func recordMalformedConfiguration(with partId: String = "") { 137 | getSdk()?.recordMalformedConfiguration(featureId: featureId, with: partId) 138 | } 139 | 140 | public func isUnderTest() -> Bool { 141 | lock.lock() 142 | defer { self.lock.unlock() } 143 | 144 | guard let features = getSdk() as? HardcodedNimbusFeatures else { 145 | return false 146 | } 147 | return features.has(featureId: featureId) 148 | } 149 | } 150 | 151 | /// Swift generics don't allow us to do wildcards, which means implementing a 152 | /// ``getFeature(featureId: String) -> FeatureHolder<*>`` unviable. 153 | /// 154 | /// To implement such a method, we need a wrapper object that gets the value, and forwards 155 | /// all other calls onto an inner ``FeatureHolder``. 156 | public class FeatureHolderAny { 157 | let inner: FeatureHolderInterface 158 | let innerValue: FMLFeatureInterface 159 | public init(wrapping holder: FeatureHolder) { 160 | inner = holder 161 | innerValue = holder.value() 162 | } 163 | 164 | public func value() -> FMLFeatureInterface { 165 | innerValue 166 | } 167 | 168 | /// Returns a JSON string representing the complete configuration. 169 | /// 170 | /// A convenience for `self.value().toJSONString()`. 171 | public func toJSONString() -> String { 172 | innerValue.toJSONString() 173 | } 174 | } 175 | 176 | extension FeatureHolderAny: FeatureHolderInterface { 177 | public func recordExposure() { 178 | inner.recordExposure() 179 | } 180 | 181 | public func recordExperimentExposure(slug: String) { 182 | inner.recordExperimentExposure(slug: slug) 183 | } 184 | 185 | public func recordMalformedConfiguration(with partId: String) { 186 | inner.recordMalformedConfiguration(with: partId) 187 | } 188 | 189 | public func isUnderTest() -> Bool { 190 | inner.isUnderTest() 191 | } 192 | } 193 | 194 | /// A bare-bones interface for the FML generated objects. 195 | public protocol FMLObjectInterface: Encodable {} 196 | 197 | /// A bare-bones interface for the FML generated features. 198 | /// 199 | /// App developers should use the generated concrete classes, which 200 | /// implement this interface. 201 | /// 202 | public protocol FMLFeatureInterface: FMLObjectInterface { 203 | /// A test if the feature configuration has been modified somehow, invalidating any experiment 204 | /// that uses it. 205 | /// 206 | /// This may be `true` if a `pref-key` has been set in the feature manifest and the user has 207 | /// set that preference. 208 | func isModified() -> Bool 209 | 210 | /// Returns a string representation of the complete feature configuration in JSON format. 211 | func toJSONString() -> String 212 | } 213 | 214 | public extension FMLFeatureInterface { 215 | func isModified() -> Bool { 216 | return false 217 | } 218 | 219 | func toJSONString() -> String { 220 | let encoder = JSONEncoder() 221 | guard let data = try? encoder.encode(self) else { 222 | fatalError("`JSONEncoder.encode()` must succeed for `FMLFeatureInterface`") 223 | } 224 | guard let string = String(data: data, encoding: .utf8) else { 225 | fatalError("`JSONEncoder.encode()` must return valid UTF-8") 226 | } 227 | return string 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /swift-source/all/Nimbus/FeatureInterface.swift: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | import Foundation 5 | 6 | /// A small protocol to get the feature variables out of the Nimbus SDK. 7 | /// 8 | /// This is intended to be standalone to allow for testing the Nimbus FML. 9 | public protocol FeaturesInterface: AnyObject { 10 | var userDefaults: UserDefaults? { get } 11 | 12 | /// Get the variables needed to configure the feature given by `featureId`. 13 | /// 14 | /// - Parameters: 15 | /// - featureId The string feature id that identifies to the feature under experiment. 16 | /// - recordExposureEvent Passing `true` to this parameter will record the exposure 17 | /// event automatically if the client is enrolled in an experiment for the given `featureId`. 18 | /// Passing `false` here indicates that the application will manually record the exposure 19 | /// event by calling `recordExposureEvent`. 20 | /// 21 | /// See `recordExposureEvent` for more information on manually recording the event. 22 | /// 23 | /// - Returns a `Variables` object used to configure the feature. 24 | func getVariables(featureId: String, sendExposureEvent: Bool) -> Variables 25 | 26 | /// Records the `exposure` event in telemetry. 27 | /// 28 | /// This is a manual function to accomplish the same purpose as passing `true` as the 29 | /// `recordExposureEvent` property of the `getVariables` function. It is intended to be used 30 | /// when requesting feature variables must occur at a different time than the actual user's 31 | /// exposure to the feature within the app. 32 | /// 33 | /// - Examples: 34 | /// - If the `Variables` are needed at a different time than when the exposure to the feature 35 | /// actually happens, such as constructing a menu happening at a different time than the 36 | /// user seeing the menu. 37 | /// - If `getVariables` is required to be called multiple times for the same feature and it is 38 | /// desired to only record the exposure once, such as if `getVariables` were called 39 | /// with every keystroke. 40 | /// 41 | /// In the case where the use of this function is required, then the `getVariables` function 42 | /// should be called with `false` so that the exposure event is not recorded when the variables 43 | /// are fetched. 44 | /// 45 | /// This function is safe to call even when there is no active experiment for the feature. The SDK 46 | /// will ensure that an event is only recorded for active experiments. 47 | /// 48 | /// - Parameter featureId string representing the id of the feature for which to record the exposure 49 | /// event. 50 | /// 51 | func recordExposureEvent(featureId: String, experimentSlug: String?) 52 | 53 | /// Records an event signifying a malformed feature configuration, or part of one. 54 | /// 55 | /// - Parameter featureId string representing the id of the feature which app code has found to 56 | /// malformed. 57 | /// - Parameter partId string representing the card id or message id of the part of the feature that 58 | /// is malformed, providing more detail to experiment owners of where to look for the problem. 59 | func recordMalformedConfiguration(featureId: String, with partId: String) 60 | } 61 | 62 | public extension FeaturesInterface { 63 | var userDefaults: UserDefaults? { 64 | nil 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /swift-source/all/Nimbus/FeatureManifestInterface.swift: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | import Foundation 6 | 7 | public protocol FeatureManifestInterface { 8 | // The `associatedtype``, and the `features`` getter require existential types, in Swift 5.7. 9 | // associatedtype Features 10 | 11 | // Accessor object for generated configuration classes extracted from Nimbus, with built-in 12 | // default values. 13 | // The `associatedtype``, and the `features`` getter require existential types, in Swift 5.7. 14 | // var features: Features { get } 15 | 16 | /// This method should be called as early in the startup sequence of the app as possible. 17 | /// This is to connect the Nimbus SDK (and thus server) with the `{{ nimbus_object }}` 18 | /// class. 19 | /// 20 | /// The lambda MUST be threadsafe in its own right. 21 | /// 22 | /// This happens automatically if you use the `NimbusBuilder` pattern of initialization. 23 | func initialize(with getSdk: @escaping () -> FeaturesInterface?) 24 | 25 | /// Refresh the cache of configuration objects. 26 | /// 27 | /// For performance reasons, the feature configurations are constructed once then cached. 28 | /// This method is to clear that cache for all features configured with Nimbus. 29 | /// 30 | /// It must be called whenever the Nimbus SDK finishes the `applyPendingExperiments()` method. 31 | /// 32 | /// This happens automatically if you use the `NimbusBuilder` pattern of initialization. 33 | func invalidateCachedValues() 34 | 35 | /// Get a feature configuration. This is of limited use for most uses of the FML, though 36 | /// is quite useful for introspection. 37 | func getFeature(featureId: String) -> FeatureHolderAny? 38 | 39 | func getCoenrollingFeatureIds() -> [String] 40 | } 41 | -------------------------------------------------------------------------------- /swift-source/all/Nimbus/HardcodedNimbusFeatures.swift: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | import Foundation 6 | 7 | /// Shim class for injecting JSON feature configs, as typed into the experimenter branch config page, 8 | /// straight into the application. 9 | /// 10 | /// This is suitable for unit testing and ui testing. 11 | /// 12 | /// let hardcodedNimbus = HardcodedNimbus(with: [ 13 | /// "my-feature": """{ 14 | /// "enabled": true 15 | /// }""" 16 | /// ]) 17 | /// hardcodedNimbus.connect(with: FxNimbus.shared) 18 | /// 19 | /// 20 | /// Once the `hardcodedNimbus` is connected to the `FxNimbus.shared`, then 21 | /// calling `FxNimbus.shared.features.myFeature.value()` will behave as if the given JSON 22 | /// came from an experiment. 23 | /// 24 | public class HardcodedNimbusFeatures { 25 | let features: [String: [String: Any]] 26 | let bundles: [Bundle] 27 | var exposureCounts = [String: Int]() 28 | var malformedFeatures = [String: String]() 29 | 30 | public init(bundles: [Bundle] = [.main], with features: [String: [String: Any]]) { 31 | self.features = features 32 | self.bundles = bundles 33 | } 34 | 35 | public convenience init(bundles: [Bundle] = [.main], with jsons: [String: String] = [String: String]()) { 36 | let features = jsons.mapValuesNotNull { 37 | try? Dictionary.parse(jsonString: $0) 38 | } 39 | self.init(bundles: bundles, with: features) 40 | } 41 | 42 | /// Reports how many times the feature has had {recordExposureEvent} on it. 43 | public func getExposureCount(featureId: String) -> Int { 44 | return exposureCounts[featureId] ?? 0 45 | } 46 | 47 | /// Helper function for testing if the exposure count for this feature is greater than zero. 48 | public func isExposed(featureId: String) -> Bool { 49 | return getExposureCount(featureId: featureId) > 0 50 | } 51 | 52 | /// Helper function for testing if app code has reported that any of the feature 53 | /// configuration is malformed. 54 | public func isMalformed(featureId: String) -> Bool { 55 | return malformedFeatures[featureId] != nil 56 | } 57 | 58 | /// Getter method for the last part of the given feature was reported malformed. 59 | public func getMalformed(for featureId: String) -> String? { 60 | return malformedFeatures[featureId] 61 | } 62 | 63 | /// Utility function for {isUnderTest} to detect if the feature is under test. 64 | public func has(featureId: String) -> Bool { 65 | return features[featureId] != nil 66 | } 67 | 68 | /// Use this `NimbusFeatures` instance to populate the passed feature configurations. 69 | public func connect(with fm: FeatureManifestInterface) { 70 | fm.initialize { self } 71 | } 72 | } 73 | 74 | extension HardcodedNimbusFeatures: FeaturesInterface { 75 | public func getVariables(featureId: String, sendExposureEvent: Bool) -> Variables { 76 | if let json = features[featureId] { 77 | if sendExposureEvent { 78 | recordExposureEvent(featureId: featureId) 79 | } 80 | return JSONVariables(with: json, in: bundles) 81 | } 82 | return NilVariables.instance 83 | } 84 | 85 | public func recordExposureEvent(featureId: String, experimentSlug _: String? = nil) { 86 | if features[featureId] != nil { 87 | exposureCounts[featureId] = getExposureCount(featureId: featureId) + 1 88 | } 89 | } 90 | 91 | public func recordMalformedConfiguration(featureId: String, with partId: String) { 92 | malformedFeatures[featureId] = partId 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /swift-source/all/Nimbus/NimbusBuilder.swift: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | import Foundation 6 | 7 | /** 8 | * A builder for [Nimbus] singleton objects, parameterized in a declarative class. 9 | */ 10 | public class NimbusBuilder { 11 | let dbFilePath: String 12 | 13 | public init(dbPath: String) { 14 | dbFilePath = dbPath 15 | } 16 | 17 | /** 18 | * An optional server URL string. 19 | * 20 | * This will only be null or empty in development or testing, or in any build variant of a 21 | * non-Mozilla fork. 22 | */ 23 | @discardableResult 24 | public func with(url: String?) -> Self { 25 | self.url = url 26 | return self 27 | } 28 | 29 | var url: String? 30 | 31 | /** 32 | * A closure for reporting errors from Rust. 33 | */ 34 | @discardableResult 35 | public func with(errorReporter reporter: @escaping NimbusErrorReporter) -> NimbusBuilder { 36 | errorReporter = reporter 37 | return self 38 | } 39 | 40 | var errorReporter: NimbusErrorReporter = defaultErrorReporter 41 | 42 | /** 43 | * A flag to select the main or preview collection of remote settings. Defaults to `false`. 44 | */ 45 | @discardableResult 46 | public func using(previewCollection flag: Bool) -> NimbusBuilder { 47 | usePreviewCollection = flag 48 | return self 49 | } 50 | 51 | var usePreviewCollection: Bool = false 52 | 53 | /** 54 | * A flag to indicate if this is being run on the first run of the app. This is used to control 55 | * whether the `initial_experiments` file is used to populate Nimbus. 56 | */ 57 | @discardableResult 58 | public func isFirstRun(_ flag: Bool) -> NimbusBuilder { 59 | isFirstRun = flag 60 | return self 61 | } 62 | 63 | var isFirstRun: Bool = true 64 | 65 | /** 66 | * A optional raw resource of a file downloaded at or near build time from Remote Settings. 67 | */ 68 | @discardableResult 69 | public func with(initialExperiments fileURL: URL?) -> NimbusBuilder { 70 | initialExperiments = fileURL 71 | return self 72 | } 73 | 74 | var initialExperiments: URL? 75 | 76 | /** 77 | * The timeout used to wait for the loading of the `initial_experiments 78 | */ 79 | @discardableResult 80 | public func with(timeoutForLoadingInitialExperiments seconds: TimeInterval) -> NimbusBuilder { 81 | timeoutLoadingExperiment = seconds 82 | return self 83 | } 84 | 85 | var timeoutLoadingExperiment: TimeInterval = 0.200 /* seconds */ 86 | 87 | /** 88 | * Optional callback to be called after the creation of the nimbus object and it is ready 89 | * to be used. 90 | */ 91 | @discardableResult 92 | public func onCreate(callback: @escaping (NimbusInterface) -> Void) -> NimbusBuilder { 93 | onCreateCallback = callback 94 | return self 95 | } 96 | 97 | var onCreateCallback: ((NimbusInterface) -> Void)? 98 | 99 | /** 100 | * Optional callback to be called after the calculation of new enrollments and applying of changes to 101 | * experiments recipes. 102 | */ 103 | @discardableResult 104 | public func onApply(callback: @escaping (NimbusInterface) -> Void) -> NimbusBuilder { 105 | onApplyCallback = callback 106 | return self 107 | } 108 | 109 | var onApplyCallback: ((NimbusInterface) -> Void)? 110 | 111 | /** 112 | * Optional callback to be called after the fetch of new experiments has completed. 113 | * experiments recipes. 114 | */ 115 | @discardableResult 116 | public func onFetch(callback: @escaping (NimbusInterface) -> Void) -> NimbusBuilder { 117 | onFetchCallback = callback 118 | return self 119 | } 120 | 121 | var onFetchCallback: ((NimbusInterface) -> Void)? 122 | 123 | /** 124 | * Resource bundles used to look up bundled text and images. Defaults to `[Bundle.main]`. 125 | */ 126 | @discardableResult 127 | public func with(bundles: [Bundle]) -> NimbusBuilder { 128 | resourceBundles = bundles 129 | return self 130 | } 131 | 132 | var resourceBundles: [Bundle] = [.main] 133 | 134 | /** 135 | * The object generated from the `nimbus.fml.yaml` file. 136 | */ 137 | @discardableResult 138 | public func with(featureManifest: FeatureManifestInterface) -> NimbusBuilder { 139 | self.featureManifest = featureManifest 140 | return self 141 | } 142 | 143 | var featureManifest: FeatureManifestInterface? 144 | 145 | /** 146 | * Main user defaults for the app. 147 | */ 148 | @discardableResult 149 | public func with(userDefaults: UserDefaults) -> NimbusBuilder { 150 | self.userDefaults = userDefaults 151 | return self 152 | } 153 | 154 | var userDefaults = UserDefaults.standard 155 | 156 | /** 157 | * The command line arguments for the app. This is useful for QA, and can be safely left in the app in production. 158 | */ 159 | @discardableResult 160 | public func with(commandLineArgs: [String]) -> NimbusBuilder { 161 | self.commandLineArgs = commandLineArgs 162 | return self 163 | } 164 | 165 | var commandLineArgs: [String]? 166 | 167 | /** 168 | * An optional RecordedContext object. 169 | * 170 | * When provided, its JSON contents will be added to the Nimbus targeting context, and its value will be published 171 | * to Glean. 172 | */ 173 | @discardableResult 174 | public func with(recordedContext: RecordedContext?) -> Self { 175 | self.recordedContext = recordedContext 176 | return self 177 | } 178 | 179 | var recordedContext: RecordedContext? 180 | 181 | // swiftlint:disable function_body_length 182 | /** 183 | * Build a [Nimbus] singleton for the given [NimbusAppSettings]. Instances built with this method 184 | * have been initialized, and are ready for use by the app. 185 | * 186 | * Instance have _not_ yet had [fetchExperiments()] called on it, or anything usage of the 187 | * network. This is to allow the networking stack to be initialized after this method is called 188 | * and the networking stack to be involved in experiments. 189 | */ 190 | public func build(appInfo: NimbusAppSettings) -> NimbusInterface { 191 | let serverSettings: NimbusServerSettings? 192 | if let string = url, 193 | let url = URL(string: string) 194 | { 195 | if usePreviewCollection { 196 | serverSettings = NimbusServerSettings(url: url, collection: remoteSettingsPreviewCollection) 197 | } else { 198 | serverSettings = NimbusServerSettings(url: url, collection: remoteSettingsCollection) 199 | } 200 | } else { 201 | serverSettings = nil 202 | } 203 | 204 | do { 205 | let nimbus = try newNimbus(appInfo, serverSettings: serverSettings) 206 | let fm = featureManifest 207 | let onApplyCallback = onApplyCallback 208 | if fm != nil || onApplyCallback != nil { 209 | NotificationCenter.default.addObserver(forName: .nimbusExperimentsApplied, 210 | object: nil, 211 | queue: nil) 212 | { _ in 213 | fm?.invalidateCachedValues() 214 | onApplyCallback?(nimbus) 215 | } 216 | } 217 | 218 | if let callback = onFetchCallback { 219 | NotificationCenter.default.addObserver(forName: .nimbusExperimentsFetched, 220 | object: nil, 221 | queue: nil) 222 | { _ in 223 | callback(nimbus) 224 | } 225 | } 226 | 227 | // Is the app being built locally, and the nimbus-cli 228 | // hasn't been used before this run. 229 | func isLocalBuild() -> Bool { 230 | serverSettings == nil && nimbus.isFetchEnabled() 231 | } 232 | 233 | if let args = ArgumentProcessor.createCommandLineArgs(args: commandLineArgs) { 234 | ArgumentProcessor.initializeTooling(nimbus: nimbus, args: args) 235 | } else if let file = initialExperiments, isFirstRun || isLocalBuild() { 236 | let job = nimbus.applyLocalExperiments(fileURL: file) 237 | _ = job.joinOrTimeout(timeout: timeoutLoadingExperiment) 238 | } else { 239 | nimbus.applyPendingExperiments().waitUntilFinished() 240 | } 241 | 242 | // By now, on this thread, we have a fully initialized Nimbus object, ready for use: 243 | // * we gave a 200ms timeout to the loading of a file from res/raw 244 | // * on completion or cancellation, applyPendingExperiments or initialize was 245 | // called, and this thread waited for that to complete. 246 | featureManifest?.initialize { nimbus } 247 | onCreateCallback?(nimbus) 248 | 249 | return nimbus 250 | } catch { 251 | errorReporter(error) 252 | return newNimbusDisabled() 253 | } 254 | } 255 | 256 | // swiftlint:enable function_body_length 257 | 258 | func getCoenrollingFeatureIds() -> [String] { 259 | featureManifest?.getCoenrollingFeatureIds() ?? [] 260 | } 261 | 262 | func newNimbus(_ appInfo: NimbusAppSettings, serverSettings: NimbusServerSettings?) throws -> NimbusInterface { 263 | try Nimbus.create(serverSettings, 264 | appSettings: appInfo, 265 | coenrollingFeatureIds: getCoenrollingFeatureIds(), 266 | dbPath: dbFilePath, 267 | resourceBundles: resourceBundles, 268 | userDefaults: userDefaults, 269 | errorReporter: errorReporter, 270 | recordedContext: recordedContext) 271 | } 272 | 273 | func newNimbusDisabled() -> NimbusInterface { 274 | NimbusDisabled.shared 275 | } 276 | } 277 | -------------------------------------------------------------------------------- /swift-source/all/Nimbus/NimbusCreate.swift: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | import Foundation 6 | import Glean 7 | import UIKit 8 | 9 | private let logTag = "Nimbus.swift" 10 | private let logger = Logger(tag: logTag) 11 | 12 | public let defaultErrorReporter: NimbusErrorReporter = { err in 13 | switch err { 14 | case is LocalizedError: 15 | let description = err.localizedDescription 16 | logger.error("Nimbus error: \(description)") 17 | default: 18 | logger.error("Nimbus error: \(err)") 19 | } 20 | } 21 | 22 | class GleanMetricsHandler: MetricsHandler { 23 | func recordEnrollmentStatuses(enrollmentStatusExtras: [EnrollmentStatusExtraDef]) { 24 | for extra in enrollmentStatusExtras { 25 | GleanMetrics.NimbusEvents.enrollmentStatus 26 | .record(GleanMetrics.NimbusEvents.EnrollmentStatusExtra( 27 | branch: extra.branch, 28 | conflictSlug: extra.conflictSlug, 29 | errorString: extra.errorString, 30 | reason: extra.reason, 31 | slug: extra.slug, 32 | status: extra.status 33 | )) 34 | } 35 | } 36 | 37 | func recordFeatureActivation(event: FeatureExposureExtraDef) { 38 | GleanMetrics.NimbusEvents.activation 39 | .record(GleanMetrics.NimbusEvents.ActivationExtra( 40 | branch: event.branch, 41 | experiment: event.slug, 42 | featureId: event.featureId 43 | )) 44 | } 45 | 46 | func recordFeatureExposure(event: FeatureExposureExtraDef) { 47 | GleanMetrics.NimbusEvents.exposure 48 | .record(GleanMetrics.NimbusEvents.ExposureExtra( 49 | branch: event.branch, 50 | experiment: event.slug, 51 | featureId: event.featureId 52 | )) 53 | } 54 | 55 | func recordMalformedFeatureConfig(event: MalformedFeatureConfigExtraDef) { 56 | GleanMetrics.NimbusEvents.malformedFeature 57 | .record(GleanMetrics.NimbusEvents.MalformedFeatureExtra( 58 | branch: event.branch, 59 | experiment: event.slug, 60 | featureId: event.featureId, 61 | partId: event.part 62 | )) 63 | } 64 | } 65 | 66 | public extension Nimbus { 67 | /// Create an instance of `Nimbus`. 68 | /// 69 | /// - Parameters: 70 | /// - server: the server that experiments will be downloaded from 71 | /// - appSettings: the name and channel for the app 72 | /// - dbPath: the path on disk for the database 73 | /// - resourceBundles: an optional array of `Bundle` objects that are used to lookup text and images 74 | /// - enabled: intended for FeatureFlags. If false, then return a dummy `Nimbus` instance. Defaults to `true`. 75 | /// - errorReporter: a closure capable of reporting errors. Defaults to using a logger. 76 | /// - Returns an implementation of `NimbusApi`. 77 | /// - Throws `NimbusError` if anything goes wrong with the Rust FFI or in the `NimbusClient` constructor. 78 | /// 79 | static func create( 80 | _ server: NimbusServerSettings?, 81 | appSettings: NimbusAppSettings, 82 | coenrollingFeatureIds: [String] = [], 83 | dbPath: String, 84 | resourceBundles: [Bundle] = [Bundle.main], 85 | enabled: Bool = true, 86 | userDefaults: UserDefaults? = nil, 87 | errorReporter: @escaping NimbusErrorReporter = defaultErrorReporter, 88 | recordedContext: RecordedContext? = nil 89 | ) throws -> NimbusInterface { 90 | guard enabled else { 91 | return NimbusDisabled.shared 92 | } 93 | 94 | let context = Nimbus.buildExperimentContext(appSettings) 95 | let remoteSettings = server.map { server -> RemoteSettingsConfig in 96 | RemoteSettingsConfig( 97 | collectionName: server.collection, 98 | server: .custom(url: server.url.absoluteString) 99 | ) 100 | } 101 | let nimbusClient = try NimbusClient( 102 | appCtx: context, 103 | recordedContext: recordedContext, 104 | coenrollingFeatureIds: coenrollingFeatureIds, 105 | dbpath: dbPath, 106 | remoteSettingsConfig: remoteSettings, 107 | metricsHandler: GleanMetricsHandler() 108 | ) 109 | 110 | return Nimbus( 111 | nimbusClient: nimbusClient, 112 | resourceBundles: resourceBundles, 113 | userDefaults: userDefaults, 114 | errorReporter: errorReporter 115 | ) 116 | } 117 | 118 | static func buildExperimentContext( 119 | _ appSettings: NimbusAppSettings, 120 | bundle: Bundle = Bundle.main, 121 | device: UIDevice = .current 122 | ) -> AppContext { 123 | let info = bundle.infoDictionary ?? [:] 124 | var inferredDateInstalledOn: Date? { 125 | guard 126 | let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).last, 127 | let attributes = try? FileManager.default.attributesOfItem(atPath: documentsURL.path) 128 | else { return nil } 129 | return attributes[.creationDate] as? Date 130 | } 131 | let installationDateSinceEpoch = inferredDateInstalledOn.map { 132 | Int64(($0.timeIntervalSince1970 * 1000).rounded()) 133 | } 134 | 135 | return AppContext( 136 | appName: appSettings.appName, 137 | appId: info["CFBundleIdentifier"] as? String ?? "unknown", 138 | channel: appSettings.channel, 139 | appVersion: info["CFBundleShortVersionString"] as? String, 140 | appBuild: info["CFBundleVersion"] as? String, 141 | architecture: Sysctl.machine, // Sysctl is from Glean. 142 | deviceManufacturer: Sysctl.manufacturer, 143 | deviceModel: Sysctl.model, 144 | locale: getLocaleTag(), // from Glean utils 145 | os: device.systemName, 146 | osVersion: device.systemVersion, 147 | androidSdkVersion: nil, 148 | debugTag: "Nimbus.rs", 149 | installationDate: installationDateSinceEpoch, 150 | homeDirectory: nil, 151 | customTargetingAttributes: try? appSettings.customTargetingAttributes.stringify() 152 | ) 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /swift-source/all/Nimbus/NimbusMessagingHelpers.swift: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | import Foundation 6 | import Glean 7 | 8 | /** 9 | * Instances of this class are useful for implementing a messaging service based upon 10 | * Nimbus. 11 | * 12 | * The message helper is designed to help string interpolation and JEXL evalutaiuon against the context 13 | * of the attrtibutes Nimbus already knows about. 14 | * 15 | * App-specific, additional context can be given at creation time. 16 | * 17 | * The helpers are designed to evaluate multiple messages at a time, however: since the context may change 18 | * over time, the message helper should not be stored for long periods. 19 | */ 20 | public protocol NimbusMessagingProtocol { 21 | func createMessageHelper() throws -> NimbusMessagingHelperProtocol 22 | func createMessageHelper(additionalContext: [String: Any]) throws -> NimbusMessagingHelperProtocol 23 | func createMessageHelper(additionalContext: T) throws -> NimbusMessagingHelperProtocol 24 | 25 | var events: NimbusEventStore { get } 26 | } 27 | 28 | public protocol NimbusMessagingHelperProtocol: NimbusStringHelperProtocol, NimbusTargetingHelperProtocol { 29 | /** 30 | * Clear the JEXL cache 31 | */ 32 | func clearCache() 33 | } 34 | 35 | /** 36 | * A helper object to make working with Strings uniform across multiple implementations of the messaging 37 | * system. 38 | * 39 | * This object provides access to a JEXL evaluator which runs against the same context as provided by 40 | * Nimbus targeting. 41 | * 42 | * It should also provide a similar function for String substitution, though this scheduled for EXP-2159. 43 | */ 44 | public class NimbusMessagingHelper: NimbusMessagingHelperProtocol { 45 | private let targetingHelper: NimbusTargetingHelperProtocol 46 | private let stringHelper: NimbusStringHelperProtocol 47 | private var cache: [String: Bool] 48 | 49 | public init(targetingHelper: NimbusTargetingHelperProtocol, 50 | stringHelper: NimbusStringHelperProtocol, 51 | cache: [String: Bool] = [:]) 52 | { 53 | self.targetingHelper = targetingHelper 54 | self.stringHelper = stringHelper 55 | self.cache = cache 56 | } 57 | 58 | public func evalJexl(expression: String) throws -> Bool { 59 | if let result = cache[expression] { 60 | return result 61 | } else { 62 | let result = try targetingHelper.evalJexl(expression: expression) 63 | cache[expression] = result 64 | return result 65 | } 66 | } 67 | 68 | public func clearCache() { 69 | cache.removeAll() 70 | } 71 | 72 | public func getUuid(template: String) -> String? { 73 | stringHelper.getUuid(template: template) 74 | } 75 | 76 | public func stringFormat(template: String, uuid: String?) -> String { 77 | stringHelper.stringFormat(template: template, uuid: uuid) 78 | } 79 | } 80 | 81 | // MARK: Dummy implementations 82 | 83 | class AlwaysConstantTargetingHelper: NimbusTargetingHelperProtocol { 84 | private let constant: Bool 85 | 86 | public init(constant: Bool = false) { 87 | self.constant = constant 88 | } 89 | 90 | public func evalJexl(expression _: String) throws -> Bool { 91 | constant 92 | } 93 | } 94 | 95 | class EchoStringHelper: NimbusStringHelperProtocol { 96 | public func getUuid(template _: String) -> String? { 97 | nil 98 | } 99 | 100 | public func stringFormat(template: String, uuid _: String?) -> String { 101 | template 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /swift-source/all/Nimbus/Operation+.swift: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | import Foundation 6 | 7 | public extension Operation { 8 | /// Wait for the operation to finish, or a timeout. 9 | /// 10 | /// The operation is cooperatively cancelled on timeout, that is to say, it checks its {isCancelled}. 11 | func joinOrTimeout(timeout: TimeInterval) -> Bool { 12 | if isFinished { 13 | return !isCancelled 14 | } 15 | DispatchQueue.global().async { 16 | Thread.sleep(forTimeInterval: timeout) 17 | if !self.isFinished { 18 | self.cancel() 19 | } 20 | } 21 | 22 | waitUntilFinished() 23 | return !isCancelled 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /swift-source/all/Nimbus/Utils/Logger.swift: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | import Foundation 6 | import os.log 7 | 8 | class Logger { 9 | private let log: OSLog 10 | 11 | /// Creates a new logger instance with the specified tag value 12 | /// 13 | /// - parameters: 14 | /// * tag: `String` value used to tag log messages 15 | init(tag: String) { 16 | self.log = OSLog( 17 | subsystem: Bundle.main.bundleIdentifier!, 18 | category: tag 19 | ) 20 | } 21 | 22 | /// Output a debug log message 23 | /// 24 | /// - parameters: 25 | /// * message: The message to log 26 | func debug(_ message: String) { 27 | log(message, type: .debug) 28 | } 29 | 30 | /// Output an info log message 31 | /// 32 | /// - parameters: 33 | /// * message: The message to log 34 | func info(_ message: String) { 35 | log(message, type: .info) 36 | } 37 | 38 | /// Output an error log message 39 | /// 40 | /// - parameters: 41 | /// * message: The message to log 42 | func error(_ message: String) { 43 | log(message, type: .error) 44 | } 45 | 46 | /// Private function that calls os_log with the proper parameters 47 | /// 48 | /// - parameters: 49 | /// * message: The message to log 50 | /// * level: The `LogLevel` at which to output the message 51 | private func log(_ message: String, type: OSLogType) { 52 | os_log("%@", log: self.log, type: type, message) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /swift-source/all/Nimbus/Utils/Sysctl.swift: -------------------------------------------------------------------------------- 1 | // swiftlint:disable line_length 2 | // REASON: URLs and doc strings 3 | // Copyright © 2017 Matt Gallagher ( http://cocoawithlove.com ). All rights reserved. 4 | // 5 | // Original: https://github.com/mattgallagher/CwlUtils/blob/0e08b0194bf95861e5aac27e8857a972983315d7/Sources/CwlUtils/CwlSysctl.swift 6 | // Modified: 7 | // * iOS only 8 | // * removed unused functions 9 | // * reformatted 10 | // 11 | // ISC License 12 | // 13 | // Permission to use, copy, modify, and/or distribute this software for any 14 | // purpose with or without fee is hereby granted, provided that the above 15 | // copyright notice and this permission notice appear in all copies. 16 | // 17 | // THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 18 | // WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 19 | // MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY 20 | // SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 21 | // WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 22 | // ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR 23 | // IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 24 | 25 | import Foundation 26 | 27 | // swiftlint:disable force_try 28 | // REASON: Used on infallible operations 29 | 30 | /// A "static"-only namespace around a series of functions that operate on buffers returned from the `Darwin.sysctl` function 31 | struct Sysctl { 32 | /// Possible errors. 33 | enum Error: Swift.Error { 34 | case unknown 35 | case malformedUTF8 36 | case invalidSize 37 | case posixError(POSIXErrorCode) 38 | } 39 | 40 | /// Access the raw data for an array of sysctl identifiers. 41 | public static func data(for keys: [Int32]) throws -> [Int8] { 42 | return try keys.withUnsafeBufferPointer { keysPointer throws -> [Int8] in 43 | // Preflight the request to get the required data size 44 | var requiredSize = 0 45 | let preFlightResult = Darwin.sysctl( 46 | UnsafeMutablePointer(mutating: keysPointer.baseAddress), 47 | UInt32(keys.count), 48 | nil, 49 | &requiredSize, 50 | nil, 51 | 0 52 | ) 53 | if preFlightResult != 0 { 54 | throw POSIXErrorCode(rawValue: errno).map { 55 | print($0.rawValue) 56 | return Error.posixError($0) 57 | } ?? Error.unknown 58 | } 59 | 60 | // Run the actual request with an appropriately sized array buffer 61 | let data = [Int8](repeating: 0, count: requiredSize) 62 | let result = data.withUnsafeBufferPointer { dataBuffer -> Int32 in 63 | Darwin.sysctl( 64 | UnsafeMutablePointer(mutating: keysPointer.baseAddress), 65 | UInt32(keys.count), 66 | UnsafeMutableRawPointer(mutating: dataBuffer.baseAddress), 67 | &requiredSize, 68 | nil, 69 | 0 70 | ) 71 | } 72 | if result != 0 { 73 | throw POSIXErrorCode(rawValue: errno).map { Error.posixError($0) } ?? Error.unknown 74 | } 75 | 76 | return data 77 | } 78 | } 79 | 80 | /// Convert a sysctl name string like "hw.memsize" to the array of `sysctl` identifiers (e.g. [CTL_HW, HW_MEMSIZE]) 81 | public static func keys(for name: String) throws -> [Int32] { 82 | var keysBufferSize = Int(CTL_MAXNAME) 83 | var keysBuffer = [Int32](repeating: 0, count: keysBufferSize) 84 | try keysBuffer.withUnsafeMutableBufferPointer { (lbp: inout UnsafeMutableBufferPointer) throws in 85 | try name.withCString { (nbp: UnsafePointer) throws in 86 | guard sysctlnametomib(nbp, lbp.baseAddress, &keysBufferSize) == 0 else { 87 | throw POSIXErrorCode(rawValue: errno).map { Error.posixError($0) } ?? Error.unknown 88 | } 89 | } 90 | } 91 | if keysBuffer.count > keysBufferSize { 92 | keysBuffer.removeSubrange(keysBufferSize ..< keysBuffer.count) 93 | } 94 | return keysBuffer 95 | } 96 | 97 | /// Invoke `sysctl` with an array of identifiers, interpreting the returned buffer as the specified type. 98 | /// This function will throw `Error.invalidSize` if the size of buffer returned from `sysctl` fails to match the size of `T`. 99 | public static func value(ofType _: T.Type, forKeys keys: [Int32]) throws -> T { 100 | let buffer = try data(for: keys) 101 | if buffer.count != MemoryLayout.size { 102 | throw Error.invalidSize 103 | } 104 | return try buffer.withUnsafeBufferPointer { bufferPtr throws -> T in 105 | guard let baseAddress = bufferPtr.baseAddress else { throw Error.unknown } 106 | return baseAddress.withMemoryRebound(to: T.self, capacity: 1) { $0.pointee } 107 | } 108 | } 109 | 110 | /// Invoke `sysctl` with an array of identifiers, interpreting the returned buffer as the specified type. 111 | /// This function will throw `Error.invalidSize` if the size of buffer returned from `sysctl` fails to match the size of `T`. 112 | public static func value(ofType type: T.Type, forKeys keys: Int32...) throws -> T { 113 | return try value(ofType: type, forKeys: keys) 114 | } 115 | 116 | /// Invoke `sysctl` with the specified name, interpreting the returned buffer as the specified type. 117 | /// This function will throw `Error.invalidSize` if the size of buffer returned from `sysctl` fails to match the size of `T`. 118 | public static func value(ofType type: T.Type, forName name: String) throws -> T { 119 | return try value(ofType: type, forKeys: keys(for: name)) 120 | } 121 | 122 | /// Invoke `sysctl` with an array of identifiers, interpreting the returned buffer as a `String`. 123 | /// This function will throw `Error.malformedUTF8` if the buffer returned from `sysctl` cannot be interpreted as a UTF8 buffer. 124 | public static func string(for keys: [Int32]) throws -> String { 125 | let optionalString = try data(for: keys).withUnsafeBufferPointer { dataPointer -> String? in 126 | dataPointer.baseAddress.flatMap { String(validatingUTF8: $0) } 127 | } 128 | guard let s = optionalString else { 129 | throw Error.malformedUTF8 130 | } 131 | return s 132 | } 133 | 134 | /// Invoke `sysctl` with an array of identifiers, interpreting the returned buffer as a `String`. 135 | /// This function will throw `Error.malformedUTF8` if the buffer returned from `sysctl` cannot be interpreted as a UTF8 buffer. 136 | public static func string(for keys: Int32...) throws -> String { 137 | return try string(for: keys) 138 | } 139 | 140 | /// Invoke `sysctl` with the specified name, interpreting the returned buffer as a `String`. 141 | /// This function will throw `Error.malformedUTF8` if the buffer returned from `sysctl` cannot be interpreted as a UTF8 buffer. 142 | public static func string(for name: String) throws -> String { 143 | return try string(for: keys(for: name)) 144 | } 145 | 146 | /// Always the same on Apple hardware 147 | public static var manufacturer: String = "Apple" 148 | 149 | /// e.g. "N71mAP" 150 | public static var machine: String { 151 | return try! Sysctl.string(for: [CTL_HW, HW_MODEL]) 152 | } 153 | 154 | /// e.g. "iPhone8,1" 155 | public static var model: String { 156 | return try! Sysctl.string(for: [CTL_HW, HW_MACHINE]) 157 | } 158 | 159 | /// e.g. "15D21" or "13D20" 160 | public static var osVersion: String { return try! Sysctl.string(for: [CTL_KERN, KERN_OSVERSION]) } 161 | } 162 | // swiftlint:enable force_try 163 | // swiftlint:enable line_length 164 | -------------------------------------------------------------------------------- /swift-source/all/Nimbus/Utils/Unreachable.swift: -------------------------------------------------------------------------------- 1 | // Unreachable.swift 2 | // Unreachable 3 | // Original: https://github.com/nvzqz/Unreachable 4 | // 5 | // The MIT License (MIT) 6 | // 7 | // Copyright (c) 2017 Nikolai Vazquez 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in 17 | // all copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 25 | // THE SOFTWARE. 26 | // 27 | 28 | /// An unreachable code path. 29 | /// 30 | /// This can be used for whenever the compiler can't determine that a 31 | /// path is unreachable, such as dynamically terminating an iterator. 32 | @inline(__always) 33 | func unreachable() -> Never { 34 | return unsafeBitCast((), to: Never.self) 35 | } 36 | 37 | /// Asserts that the code path is unreachable. 38 | /// 39 | /// Calls `assertionFailure(_:file:line:)` in unoptimized builds and `unreachable()` otherwise. 40 | /// 41 | /// - parameter message: The message to print. The default is "Encountered unreachable path". 42 | /// - parameter file: The file name to print with the message. The default is the file where this function is called. 43 | /// - parameter line: The line number to print with the message. The default is the line where this function is called. 44 | @inline(__always) 45 | func assertUnreachable(_ message: @autoclosure () -> String = "Encountered unreachable path", 46 | file: StaticString = #file, 47 | line: UInt = #line) -> Never { 48 | var isDebug = false 49 | assert({ isDebug = true; return true }()) 50 | 51 | if isDebug { 52 | fatalError(message(), file: file, line: line) 53 | } else { 54 | unreachable() 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /swift-source/all/Nimbus/Utils/Utils.swift: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | import Foundation 6 | 7 | extension Bool { 8 | /// Convert a bool to its byte equivalent. 9 | func toByte() -> UInt8 { 10 | return self ? 1 : 0 11 | } 12 | } 13 | 14 | extension UInt8 { 15 | /// Convert a byte to its Bool equivalen. 16 | func toBool() -> Bool { 17 | return self != 0 18 | } 19 | } 20 | 21 | /// Create a temporary array of C-compatible (null-terminated) strings to pass over FFI. 22 | /// 23 | /// The strings are deallocated after the closure returns. 24 | /// 25 | /// - parameters: 26 | /// * args: The array of strings to use. 27 | /// If `nil` no output array will be allocated and `nil` will be passed to `body`. 28 | /// * body: The closure that gets an array of C-compatible strings 29 | func withArrayOfCStrings( 30 | _ args: [String]?, 31 | _ body: ([UnsafePointer?]?) -> R 32 | ) -> R { 33 | if let args = args { 34 | let cStrings = args.map { UnsafePointer(strdup($0)) } 35 | defer { 36 | cStrings.forEach { free(UnsafeMutableRawPointer(mutating: $0)) } 37 | } 38 | return body(cStrings) 39 | } else { 40 | return body(nil) 41 | } 42 | } 43 | 44 | /// This struct creates a Boolean with atomic or synchronized access. 45 | /// 46 | /// This makes use of synchronization tools from Grand Central Dispatch (GCD) 47 | /// in order to synchronize access. 48 | struct AtomicBoolean { 49 | private var semaphore = DispatchSemaphore(value: 1) 50 | private var val: Bool 51 | var value: Bool { 52 | get { 53 | semaphore.wait() 54 | let tmp = val 55 | semaphore.signal() 56 | return tmp 57 | } 58 | set { 59 | semaphore.wait() 60 | val = newValue 61 | semaphore.signal() 62 | } 63 | } 64 | 65 | init(_ initialValue: Bool = false) { 66 | val = initialValue 67 | } 68 | } 69 | 70 | /// Get a timestamp in nanos. 71 | /// 72 | /// This is a monotonic clock. 73 | func timestampNanos() -> UInt64 { 74 | var info = mach_timebase_info() 75 | guard mach_timebase_info(&info) == KERN_SUCCESS else { return 0 } 76 | let currentTime = mach_absolute_time() 77 | let nanos = currentTime * UInt64(info.numer) / UInt64(info.denom) 78 | return nanos 79 | } 80 | 81 | /// Gets a gecko-compatible locale string (e.g. "es-ES") 82 | /// If the locale can't be determined on the system, the value is "und", 83 | /// to indicate "undetermined". 84 | /// 85 | /// - returns: a locale string that supports custom injected locale/languages. 86 | public func getLocaleTag() -> String { 87 | if NSLocale.current.languageCode == nil { 88 | return "und" 89 | } else { 90 | if NSLocale.current.regionCode == nil { 91 | return NSLocale.current.languageCode! 92 | } else { 93 | return "\(NSLocale.current.languageCode!)-\(NSLocale.current.regionCode!)" 94 | } 95 | } 96 | } 97 | 98 | /// Gather information about the running application 99 | struct AppInfo { 100 | /// The application's identifier name 101 | public static var name: String { 102 | return Bundle.main.bundleIdentifier! 103 | } 104 | 105 | /// The application's display version string 106 | public static var displayVersion: String { 107 | return Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown" 108 | } 109 | 110 | /// The application's build ID 111 | public static var buildId: String { 112 | return Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Unknown" 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /swift-source/all/Places/Bookmark.swift: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | import Foundation 6 | #if canImport(MozillaRustComponents) 7 | import MozillaRustComponents 8 | #endif 9 | 10 | /// Snarfed from firefox-ios, although we don't have the fake desktop root, 11 | /// and we only have the `All` Set. 12 | public enum BookmarkRoots { 13 | public static let RootGUID = "root________" 14 | public static let MobileFolderGUID = "mobile______" 15 | public static let MenuFolderGUID = "menu________" 16 | public static let ToolbarFolderGUID = "toolbar_____" 17 | public static let UnfiledFolderGUID = "unfiled_____" 18 | 19 | public static let All = Set([ 20 | BookmarkRoots.RootGUID, 21 | BookmarkRoots.MobileFolderGUID, 22 | BookmarkRoots.MenuFolderGUID, 23 | BookmarkRoots.ToolbarFolderGUID, 24 | BookmarkRoots.UnfiledFolderGUID, 25 | ]) 26 | 27 | public static let DesktopRoots = Set([ 28 | BookmarkRoots.MenuFolderGUID, 29 | BookmarkRoots.ToolbarFolderGUID, 30 | BookmarkRoots.UnfiledFolderGUID, 31 | ]) 32 | } 33 | 34 | // Keeping `BookmarkNodeType` in the swift wrapper because the iOS code relies on the raw value of the variants of 35 | // this enum. 36 | public enum BookmarkNodeType: Int32 { 37 | // Note: these values need to match the Rust BookmarkType 38 | // enum in types.rs 39 | case bookmark = 1 40 | case folder = 2 41 | case separator = 3 42 | // The other node types are either queries (which we handle as 43 | // normal bookmarks), or have been removed from desktop, and 44 | // are not supported 45 | } 46 | 47 | /** 48 | * A base class containing the set of fields common to all nodes 49 | * in the bookmark tree. 50 | */ 51 | public class BookmarkNodeData { 52 | /** 53 | * The type of this bookmark. 54 | */ 55 | public let type: BookmarkNodeType 56 | 57 | /** 58 | * The guid of this record. Bookmark guids are always 12 characters in the url-safe 59 | * base64 character set. 60 | */ 61 | public let guid: String 62 | 63 | /** 64 | * Creation time, in milliseconds since the unix epoch. 65 | * 66 | * May not be a local timestamp. 67 | */ 68 | public let dateAdded: Int64 69 | 70 | /** 71 | * Last modification time, in milliseconds since the unix epoch. 72 | */ 73 | public let lastModified: Int64 74 | 75 | /** 76 | * The guid of this record's parent, or null if the record is the bookmark root. 77 | */ 78 | public let parentGUID: String? 79 | 80 | /** 81 | * The (0-based) position of this record within it's parent. 82 | */ 83 | public let position: UInt32 84 | // We use this from tests. 85 | // swiftformat:disable redundantFileprivate 86 | fileprivate init(type: BookmarkNodeType, 87 | guid: String, 88 | dateAdded: Int64, 89 | lastModified: Int64, 90 | parentGUID: String?, 91 | position: UInt32) 92 | { 93 | self.type = type 94 | self.guid = guid 95 | self.dateAdded = dateAdded 96 | self.lastModified = lastModified 97 | self.parentGUID = parentGUID 98 | self.position = position 99 | } 100 | 101 | // swiftformat:enable redundantFileprivate 102 | /** 103 | * Returns true if this record is a bookmark root. 104 | * 105 | * - Note: This is determined entirely by inspecting the GUID. 106 | */ 107 | public var isRoot: Bool { 108 | return BookmarkRoots.All.contains(guid) 109 | } 110 | } 111 | 112 | public extension BookmarkItem { 113 | var asBookmarkNodeData: BookmarkNodeData { 114 | switch self { 115 | case let .separator(s): 116 | return BookmarkSeparatorData(guid: s.guid, 117 | dateAdded: s.dateAdded, 118 | lastModified: s.lastModified, 119 | parentGUID: s.parentGuid, 120 | position: s.position) 121 | case let .bookmark(b): 122 | return BookmarkItemData(guid: b.guid, 123 | dateAdded: b.dateAdded, 124 | lastModified: b.lastModified, 125 | parentGUID: b.parentGuid, 126 | position: b.position, 127 | url: b.url, 128 | title: b.title ?? "") 129 | case let .folder(f): 130 | return BookmarkFolderData(guid: f.guid, 131 | dateAdded: f.dateAdded, 132 | lastModified: f.lastModified, 133 | parentGUID: f.parentGuid, 134 | position: f.position, 135 | title: f.title ?? "", 136 | childGUIDs: f.childGuids ?? [String](), 137 | children: f.childNodes?.map { child in child.asBookmarkNodeData }) 138 | } 139 | } 140 | } 141 | 142 | // XXX - This function exists to convert the return types of the `bookmarksGetAllWithUrl`, 143 | // `bookmarksSearch`, and `bookmarksGetRecent` functions which will always return the `BookmarkData` 144 | // variant of the `BookmarkItem` enum. This function should be removed once the return types of the 145 | // backing rust functions have been converted from `BookmarkItem`. 146 | func toBookmarkItemDataList(items: [BookmarkItem]) -> [BookmarkItemData] { 147 | func asBookmarkItemData(item: BookmarkItem) -> BookmarkItemData? { 148 | if case let .bookmark(b) = item { 149 | return BookmarkItemData(guid: b.guid, 150 | dateAdded: b.dateAdded, 151 | lastModified: b.lastModified, 152 | parentGUID: b.parentGuid, 153 | position: b.position, 154 | url: b.url, 155 | title: b.title ?? "") 156 | } 157 | return nil 158 | } 159 | 160 | return items.map { asBookmarkItemData(item: $0)! } 161 | } 162 | 163 | /** 164 | * A bookmark which is a separator. 165 | * 166 | * It's type is always `BookmarkNodeType.separator`, and it has no fields 167 | * besides those defined by `BookmarkNodeData`. 168 | */ 169 | public class BookmarkSeparatorData: BookmarkNodeData { 170 | public init(guid: String, dateAdded: Int64, lastModified: Int64, parentGUID: String?, position: UInt32) { 171 | super.init( 172 | type: .separator, 173 | guid: guid, 174 | dateAdded: dateAdded, 175 | lastModified: lastModified, 176 | parentGUID: parentGUID, 177 | position: position 178 | ) 179 | } 180 | } 181 | 182 | /** 183 | * A bookmark tree node that actually represents a bookmark. 184 | * 185 | * It's type is always `BookmarkNodeType.bookmark`, and in addition to the 186 | * fields provided by `BookmarkNodeData`, it has a `title` and a `url`. 187 | */ 188 | public class BookmarkItemData: BookmarkNodeData { 189 | /** 190 | * The URL of this bookmark. 191 | */ 192 | public let url: String 193 | 194 | /** 195 | * The title of the bookmark. 196 | * 197 | * Note that the bookmark storage layer treats NULL and the 198 | * empty string as equivalent in titles. 199 | */ 200 | public let title: String 201 | 202 | public init(guid: String, 203 | dateAdded: Int64, 204 | lastModified: Int64, 205 | parentGUID: String?, 206 | position: UInt32, 207 | url: String, 208 | title: String) 209 | { 210 | self.url = url 211 | self.title = title 212 | super.init( 213 | type: .bookmark, 214 | guid: guid, 215 | dateAdded: dateAdded, 216 | lastModified: lastModified, 217 | parentGUID: parentGUID, 218 | position: position 219 | ) 220 | } 221 | } 222 | 223 | /** 224 | * A bookmark which is a folder. 225 | * 226 | * It's type is always `BookmarkNodeType.folder`, and in addition to the 227 | * fields provided by `BookmarkNodeData`, it has a `title`, a list of `childGUIDs`, 228 | * and possibly a list of `children`. 229 | */ 230 | public class BookmarkFolderData: BookmarkNodeData { 231 | /** 232 | * The title of this bookmark folder. 233 | * 234 | * Note that the bookmark storage layer treats NULL and the 235 | * empty string as equivalent in titles. 236 | */ 237 | public let title: String 238 | 239 | /** 240 | * The GUIDs of this folder's list of children. 241 | */ 242 | public let childGUIDs: [String] 243 | 244 | /** 245 | * If this node was returned from the `PlacesReadConnection.getBookmarksTree` function, 246 | * then this should have the list of children, otherwise it will be nil. 247 | * 248 | * Note that if `recursive = false` is passed to the `getBookmarksTree` function, and 249 | * this is a child (or grandchild, etc) of the directly returned node, then `children` 250 | * will *not* be present (as that is the point of `recursive = false`). 251 | */ 252 | public let children: [BookmarkNodeData]? 253 | 254 | public init(guid: String, 255 | dateAdded: Int64, 256 | lastModified: Int64, 257 | parentGUID: String?, 258 | position: UInt32, 259 | title: String, 260 | childGUIDs: [String], 261 | children: [BookmarkNodeData]?) 262 | { 263 | self.title = title 264 | self.childGUIDs = childGUIDs 265 | self.children = children 266 | super.init( 267 | type: .folder, 268 | guid: guid, 269 | dateAdded: dateAdded, 270 | lastModified: lastModified, 271 | parentGUID: parentGUID, 272 | position: position 273 | ) 274 | } 275 | } 276 | -------------------------------------------------------------------------------- /swift-source/all/Places/HistoryMetadata.swift: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | import Foundation 6 | #if canImport(MozillaRustComponents) 7 | import MozillaRustComponents 8 | #endif 9 | 10 | /** 11 | Represents a set of properties which uniquely identify a history metadata. In database terms this is a compound key. 12 | */ 13 | public struct HistoryMetadataKey: Codable { 14 | public let url: String 15 | public let searchTerm: String? 16 | public let referrerUrl: String? 17 | 18 | public init(url: String, searchTerm: String?, referrerUrl: String?) { 19 | self.url = url 20 | self.searchTerm = searchTerm 21 | self.referrerUrl = referrerUrl 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /swift-source/all/Sync15/ResultError.swift: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | import Foundation 6 | 7 | enum ResultError: Error { 8 | case empty 9 | } 10 | -------------------------------------------------------------------------------- /swift-source/all/Sync15/SyncUnlockInfo.swift: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | import Foundation 6 | import UIKit 7 | 8 | /// Set of arguments required to sync. 9 | open class SyncUnlockInfo { 10 | public var kid: String 11 | public var fxaAccessToken: String 12 | public var syncKey: String 13 | public var tokenserverURL: String 14 | public var loginEncryptionKey: String 15 | public var tabsLocalId: String? 16 | 17 | public init( 18 | kid: String, 19 | fxaAccessToken: String, 20 | syncKey: String, 21 | tokenserverURL: String, 22 | loginEncryptionKey: String, 23 | tabsLocalId: String? = nil 24 | ) { 25 | self.kid = kid 26 | self.fxaAccessToken = fxaAccessToken 27 | self.syncKey = syncKey 28 | self.tokenserverURL = tokenserverURL 29 | self.loginEncryptionKey = loginEncryptionKey 30 | self.tabsLocalId = tabsLocalId 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /swift-source/all/SyncManager/SyncManagerComponent.swift: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | import Foundation 6 | import Glean 7 | 8 | open class SyncManagerComponent { 9 | private var api: SyncManager 10 | 11 | public init() { 12 | api = SyncManager() 13 | } 14 | 15 | public func disconnect() { 16 | api.disconnect() 17 | } 18 | 19 | public func sync(params: SyncParams) throws -> SyncResult { 20 | return try api.sync(params: params) 21 | } 22 | 23 | public func getAvailableEngines() -> [String] { 24 | return api.getAvailableEngines() 25 | } 26 | 27 | public static func reportSyncTelemetry(syncResult: SyncResult) throws { 28 | if let json = syncResult.telemetryJson { 29 | let telemetry = try RustSyncTelemetryPing.fromJSONString(jsonObjectText: json) 30 | try processSyncTelemetry(syncTelemetry: telemetry) 31 | } 32 | } 33 | 34 | public static func reportOpenSyncSettingsMenuTelemetry() { 35 | GleanMetrics.SyncSettings.openMenu.record() 36 | } 37 | 38 | public static func reportSaveSyncSettingsTelemetry(enabledEngines: [String], disabledEngines: [String]) { 39 | let enabledList = enabledEngines.isEmpty ? nil : enabledEngines.joined(separator: ",") 40 | let disabledList = disabledEngines.isEmpty ? nil : disabledEngines.joined(separator: ",") 41 | let extras = GleanMetrics.SyncSettings.SaveExtra(disabledEngines: disabledList, enabledEngines: enabledList) 42 | 43 | GleanMetrics.SyncSettings.save.record(extras) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /swift-source/all/Viaduct/RustViaductFFI.h: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | #pragma once 6 | #include 7 | #include 8 | 9 | void viaduct_use_reqwest_backend(); 10 | -------------------------------------------------------------------------------- /swift-source/all/Viaduct/Viaduct.swift: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | import Foundation 6 | #if canImport(MozillaRustComponents) 7 | import MozillaRustComponents 8 | #endif 9 | 10 | /// The public interface to viaduct. 11 | /// Right now it doesn't do any "true" viaduct things, 12 | /// it simply activated the reqwest backend. 13 | /// 14 | /// This is a singleton, and should be used via the 15 | /// `shared` static member. 16 | public class Viaduct { 17 | /// The singleton instance of Viaduct 18 | public static let shared = Viaduct() 19 | 20 | private init() {} 21 | 22 | public func useReqwestBackend() { 23 | // Note: Doesn't need to synchronize since 24 | // use_reqwest_backend is backend by a CallOnce. 25 | viaduct_use_reqwest_backend() 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /swift-source/focus/Generated/megazord_focus.modulemap: -------------------------------------------------------------------------------- 1 | module MozillaRustComponents { 2 | header "errorFFI.h" 3 | header "nimbusFFI.h" 4 | header "remote_settingsFFI.h" 5 | header "rustlogforwarderFFI.h" 6 | header "tracingFFI.h" 7 | export * 8 | } -------------------------------------------------------------------------------- /swift-source/focus/Nimbus/ArgumentProcessor.swift: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | import Foundation 6 | 7 | enum ArgumentProcessor { 8 | static func initializeTooling(nimbus: NimbusInterface, args: CliArgs) { 9 | if args.resetDatabase { 10 | nimbus.resetEnrollmentsDatabase().waitUntilFinished() 11 | } 12 | if let experiments = args.experiments { 13 | nimbus.setExperimentsLocally(experiments) 14 | nimbus.applyPendingExperiments().waitUntilFinished() 15 | // setExperimentsLocally and applyPendingExperiments run on the 16 | // same single threaded dispatch queue, so we can run them in series, 17 | // and wait for the apply. 18 | nimbus.setFetchEnabled(false) 19 | } 20 | if args.logState { 21 | nimbus.dumpStateToLog() 22 | } 23 | // We have isLauncher here doing nothing; this is to match the Android implementation. 24 | // There is nothing to do at this point, because we're unable to affect the flow of the app. 25 | if args.isLauncher { 26 | () // NOOP. 27 | } 28 | } 29 | 30 | static func createCommandLineArgs(url: URL) -> CliArgs? { 31 | guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), 32 | let scheme = components.scheme, 33 | let queryItems = components.queryItems, 34 | !["http", "https"].contains(scheme) 35 | else { 36 | return nil 37 | } 38 | 39 | var experiments: String? 40 | var resetDatabase = false 41 | var logState = false 42 | var isLauncher = false 43 | var meantForUs = false 44 | 45 | func flag(_ v: String?) -> Bool { 46 | guard let v = v else { 47 | return true 48 | } 49 | return ["1", "true"].contains(v.lowercased()) 50 | } 51 | 52 | for item in queryItems { 53 | switch item.name { 54 | case "--nimbus-cli": 55 | meantForUs = flag(item.value) 56 | case "--experiments": 57 | experiments = item.value?.removingPercentEncoding 58 | case "--reset-db": 59 | resetDatabase = flag(item.value) 60 | case "--log-state": 61 | logState = flag(item.value) 62 | case "--is-launcher": 63 | isLauncher = flag(item.value) 64 | default: 65 | () // NOOP 66 | } 67 | } 68 | 69 | if !meantForUs { 70 | return nil 71 | } 72 | 73 | return check(args: CliArgs( 74 | resetDatabase: resetDatabase, 75 | experiments: experiments, 76 | logState: logState, 77 | isLauncher: isLauncher 78 | )) 79 | } 80 | 81 | static func createCommandLineArgs(args: [String]?) -> CliArgs? { 82 | guard let args = args else { 83 | return nil 84 | } 85 | if !args.contains("--nimbus-cli") { 86 | return nil 87 | } 88 | 89 | var argMap = [String: String]() 90 | var key: String? 91 | var resetDatabase = false 92 | var logState = false 93 | 94 | for arg in args { 95 | var value: String? 96 | switch arg { 97 | case "--version": 98 | key = "version" 99 | case "--experiments": 100 | key = "experiments" 101 | case "--reset-db": 102 | resetDatabase = true 103 | case "--log-state": 104 | logState = true 105 | default: 106 | value = arg.replacingOccurrences(of: "'", with: "'") 107 | } 108 | 109 | if let k = key, let v = value { 110 | argMap[k] = v 111 | key = nil 112 | value = nil 113 | } 114 | } 115 | 116 | if argMap["version"] != "1" { 117 | return nil 118 | } 119 | 120 | let experiments = argMap["experiments"] 121 | 122 | return check(args: CliArgs( 123 | resetDatabase: resetDatabase, 124 | experiments: experiments, 125 | logState: logState, 126 | isLauncher: false 127 | )) 128 | } 129 | 130 | static func check(args: CliArgs) -> CliArgs? { 131 | if let string = args.experiments { 132 | guard let payload = try? Dictionary.parse(jsonString: string), payload["data"] is [Any] 133 | else { 134 | return nil 135 | } 136 | } 137 | return args 138 | } 139 | } 140 | 141 | struct CliArgs: Equatable { 142 | let resetDatabase: Bool 143 | let experiments: String? 144 | let logState: Bool 145 | let isLauncher: Bool 146 | } 147 | 148 | public extension NimbusInterface { 149 | func initializeTooling(url: URL?) { 150 | guard let url = url, 151 | let args = ArgumentProcessor.createCommandLineArgs(url: url) 152 | else { 153 | return 154 | } 155 | ArgumentProcessor.initializeTooling(nimbus: self, args: args) 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /swift-source/focus/Nimbus/Bundle+.swift: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | import Foundation 6 | #if canImport(UIKit) 7 | import UIKit 8 | #endif 9 | 10 | public extension Array where Element == Bundle { 11 | /// Search through the resource bundles looking for an image of the given name. 12 | /// 13 | /// If no image is found in any of the `resourceBundles`, then the `nil` is returned. 14 | func getImage(named name: String) -> UIImage? { 15 | for bundle in self { 16 | if let image = UIImage(named: name, in: bundle, compatibleWith: nil) { 17 | image.accessibilityIdentifier = name 18 | return image 19 | } 20 | } 21 | return nil 22 | } 23 | 24 | /// Search through the resource bundles looking for an image of the given name. 25 | /// 26 | /// If no image is found in any of the `resourceBundles`, then a fatal error is 27 | /// thrown. This method is only intended for use with hard coded default images 28 | /// when other images have been omitted or are missing. 29 | /// 30 | /// The two ways of fixing this would be to provide the image as its named in the `.fml.yaml` 31 | /// file or to change the name of the image in the FML file. 32 | func getImageNotNull(named name: String) -> UIImage { 33 | guard let image = getImage(named: name) else { 34 | fatalError( 35 | "An image named \"\(name)\" has been named in a `.fml.yaml` file, but is missing from the asset bundle") 36 | } 37 | return image 38 | } 39 | 40 | /// Search through the resource bundles looking for localized strings with the given name. 41 | /// If the `name` contains exactly one slash, it is split up and the first part of the string is used 42 | /// as the `tableName` and the second the `key` in localized string lookup. 43 | /// If no string is found in any of the `resourceBundles`, then the `name` is passed back unmodified. 44 | func getString(named name: String) -> String? { 45 | let parts = name.split(separator: "/", maxSplits: 1, omittingEmptySubsequences: true).map { String($0) } 46 | let key: String 47 | let tableName: String? 48 | switch parts.count { 49 | case 2: 50 | tableName = parts[0] 51 | key = parts[1] 52 | default: 53 | tableName = nil 54 | key = name 55 | } 56 | 57 | for bundle in self { 58 | let value = bundle.localizedString(forKey: key, value: nil, table: tableName) 59 | if value != key { 60 | return value 61 | } 62 | } 63 | return nil 64 | } 65 | } 66 | 67 | public extension Bundle { 68 | /// Loads the language bundle from this one. 69 | /// If `language` is `nil`, then look for the development region language. 70 | /// If no bundle for the language exists, then return `nil`. 71 | func fallbackTranslationBundle(language: String? = nil) -> Bundle? { 72 | #if canImport(UIKit) 73 | if let lang = language ?? infoDictionary?["CFBundleDevelopmentRegion"] as? String, 74 | let path = path(forResource: lang, ofType: "lproj") 75 | { 76 | return Bundle(path: path) 77 | } 78 | #endif 79 | return nil 80 | } 81 | } 82 | 83 | public extension UIImage { 84 | /// The ``accessibilityIdentifier``, or "unknown-image" if not found. 85 | /// 86 | /// The ``accessibilityIdentifier`` is set when images are loaded via Nimbus, so this 87 | /// really to make the compiler happy with the generated code. 88 | var encodableImageName: String { 89 | accessibilityIdentifier ?? "unknown-image" 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /swift-source/focus/Nimbus/Collections+.swift: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | import Foundation 6 | 7 | public extension Dictionary { 8 | func mapKeysNotNull(_ transform: (Key) -> K1?) -> [K1: Value] { 9 | let transformed: [(K1, Value)] = compactMap { k, v in 10 | transform(k).flatMap { ($0, v) } 11 | } 12 | return [K1: Value](uniqueKeysWithValues: transformed) 13 | } 14 | 15 | @inline(__always) 16 | func mapValuesNotNull(_ transform: (Value) -> V1?) -> [Key: V1] { 17 | return compactMapValues(transform) 18 | } 19 | 20 | func mapEntriesNotNull(_ keyTransform: (Key) -> K1?, _ valueTransform: (Value) -> V1?) -> [K1: V1] { 21 | let transformed: [(K1, V1)] = compactMap { k, v in 22 | guard let k1 = keyTransform(k), 23 | let v1 = valueTransform(v) 24 | else { 25 | return nil 26 | } 27 | return (k1, v1) 28 | } 29 | return [K1: V1](uniqueKeysWithValues: transformed) 30 | } 31 | 32 | func mergeWith(_ defaults: [Key: Value], _ valueMerger: ((Value, Value) -> Value)? = nil) -> [Key: Value] { 33 | guard let valueMerger = valueMerger else { 34 | return merging(defaults, uniquingKeysWith: { override, _ in override }) 35 | } 36 | 37 | return merging(defaults, uniquingKeysWith: valueMerger) 38 | } 39 | } 40 | 41 | public extension Array { 42 | @inline(__always) 43 | func mapNotNull(_ transform: (Element) throws -> ElementOfResult?) rethrows -> [ElementOfResult] { 44 | try compactMap(transform) 45 | } 46 | } 47 | 48 | /// Convenience extensions to make working elements coming from the `Variables` 49 | /// object slightly easier/regular. 50 | public extension String { 51 | func map(_ transform: (Self) throws -> V?) rethrows -> V? { 52 | return try transform(self) 53 | } 54 | } 55 | 56 | public extension Variables { 57 | func map(_ transform: (Self) throws -> V) rethrows -> V { 58 | return try transform(self) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /swift-source/focus/Nimbus/Dictionary+.swift: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | import Foundation 6 | 7 | extension Dictionary where Key == String, Value == Any { 8 | func stringify() throws -> String { 9 | let data = try JSONSerialization.data(withJSONObject: self) 10 | guard let s = String(data: data, encoding: .utf8) else { 11 | throw NimbusError.JsonError(message: "Unable to encode") 12 | } 13 | return s 14 | } 15 | 16 | static func parse(jsonString string: String) throws -> [String: Any] { 17 | guard let data = string.data(using: .utf8) else { 18 | throw NimbusError.JsonError(message: "Unable to decode string into data") 19 | } 20 | let obj = try JSONSerialization.jsonObject(with: data) 21 | guard let obj = obj as? [String: Any] else { 22 | throw NimbusError.JsonError(message: "Unable to cast into JSONObject") 23 | } 24 | return obj 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /swift-source/focus/Nimbus/FeatureHolder.swift: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | import Foundation 5 | 6 | public typealias GetSdk = () -> FeaturesInterface? 7 | 8 | public protocol FeatureHolderInterface { 9 | /// Send an exposure event for this feature. This should be done when the user is shown the feature, and may change 10 | /// their behavior because of it. 11 | func recordExposure() 12 | 13 | /// Send an exposure event for this feature, in the given experiment. 14 | /// 15 | /// If the experiment does not exist, or the client is not enrolled in that experiment, then no exposure event 16 | /// is recorded. 17 | /// 18 | /// If you are not sure of the experiment slug, then this is _not_ the API you need: you should use 19 | /// {recordExposure} instead. 20 | /// 21 | /// - Parameter slug the experiment identifier, likely derived from the ``value``. 22 | func recordExperimentExposure(slug: String) 23 | 24 | /// Send a malformed feature event for this feature. 25 | /// 26 | /// - Parameter partId an optional detail or part identifier to be attached to the event. 27 | func recordMalformedConfiguration(with partId: String) 28 | 29 | /// Is this feature the focus of an automated test. 30 | /// 31 | /// A utility flag to be used in conjunction with ``HardcodedNimbusFeatures``. 32 | /// 33 | /// It is intended for use for app-code to detect when the app is under test, and 34 | /// take steps to make itself easier to test. 35 | /// 36 | /// These cases should be rare, and developers should look for other ways to test 37 | /// code without relying on this facility. 38 | /// 39 | /// For example, a background worker might be scheduled to run every 24 hours, but 40 | /// under test it would be desirable to run immediately, and only once. 41 | func isUnderTest() -> Bool 42 | } 43 | 44 | /// ``FeatureHolder`` is a class that unpacks a JSON object from the Nimbus SDK and transforms it into a useful 45 | /// type safe object, generated from a feature manifest (a `.fml.yaml` file). 46 | /// 47 | /// The routinely useful methods to application developers are the ``value()`` and the event recording 48 | /// methods of ``FeatureHolderInterface``. 49 | /// 50 | /// There are methods useful for testing, and more advanced uses: these all start with `with`. 51 | /// 52 | public class FeatureHolder { 53 | private let lock = NSLock() 54 | private var cachedValue: T? 55 | 56 | private var getSdk: GetSdk 57 | private let featureId: String 58 | 59 | private var create: (Variables, UserDefaults?) -> T 60 | 61 | public init(_ getSdk: @escaping () -> FeaturesInterface?, 62 | featureId: String, 63 | with create: @escaping (Variables, UserDefaults?) -> T) 64 | { 65 | self.getSdk = getSdk 66 | self.featureId = featureId 67 | self.create = create 68 | } 69 | 70 | /// Get the JSON configuration from the Nimbus SDK and transform it into a configuration object as specified 71 | /// in the feature manifest. This is done each call of the method, so the method should be called once, and the 72 | /// result used for the configuration of the feature. 73 | /// 74 | /// Some care is taken to cache the value, this is for performance critical uses of the API. 75 | /// It is possible to invalidate the cache with `FxNimbus.invalidateCachedValues()` or ``with(cachedValue: nil)``. 76 | public func value() -> T { 77 | lock.lock() 78 | defer { self.lock.unlock() } 79 | if let v = cachedValue { 80 | return v 81 | } 82 | var variables: Variables = NilVariables.instance 83 | var defaults: UserDefaults? 84 | if let sdk = getSdk() { 85 | variables = sdk.getVariables(featureId: featureId, sendExposureEvent: false) 86 | defaults = sdk.userDefaults 87 | } 88 | let v = create(variables, defaults) 89 | cachedValue = v 90 | return v 91 | } 92 | 93 | /// This overwrites the cached value with the passed one. 94 | /// 95 | /// This is most likely useful during testing only. 96 | public func with(cachedValue value: T?) { 97 | lock.lock() 98 | defer { self.lock.unlock() } 99 | cachedValue = value 100 | } 101 | 102 | /// This resets the SDK and clears the cached value. 103 | /// 104 | /// This is especially useful at start up and for imported features. 105 | public func with(sdk: @escaping () -> FeaturesInterface?) { 106 | lock.lock() 107 | defer { self.lock.unlock() } 108 | getSdk = sdk 109 | cachedValue = nil 110 | } 111 | 112 | /// This changes the mapping between a ``Variables`` and the feature configuration object. 113 | /// 114 | /// This is most likely useful during testing and other generated code. 115 | public func with(initializer: @escaping (Variables, UserDefaults?) -> T) { 116 | lock.lock() 117 | defer { self.lock.unlock() } 118 | cachedValue = nil 119 | create = initializer 120 | } 121 | } 122 | 123 | extension FeatureHolder: FeatureHolderInterface { 124 | public func recordExposure() { 125 | if !value().isModified() { 126 | getSdk()?.recordExposureEvent(featureId: featureId, experimentSlug: nil) 127 | } 128 | } 129 | 130 | public func recordExperimentExposure(slug: String) { 131 | if !value().isModified() { 132 | getSdk()?.recordExposureEvent(featureId: featureId, experimentSlug: slug) 133 | } 134 | } 135 | 136 | public func recordMalformedConfiguration(with partId: String = "") { 137 | getSdk()?.recordMalformedConfiguration(featureId: featureId, with: partId) 138 | } 139 | 140 | public func isUnderTest() -> Bool { 141 | lock.lock() 142 | defer { self.lock.unlock() } 143 | 144 | guard let features = getSdk() as? HardcodedNimbusFeatures else { 145 | return false 146 | } 147 | return features.has(featureId: featureId) 148 | } 149 | } 150 | 151 | /// Swift generics don't allow us to do wildcards, which means implementing a 152 | /// ``getFeature(featureId: String) -> FeatureHolder<*>`` unviable. 153 | /// 154 | /// To implement such a method, we need a wrapper object that gets the value, and forwards 155 | /// all other calls onto an inner ``FeatureHolder``. 156 | public class FeatureHolderAny { 157 | let inner: FeatureHolderInterface 158 | let innerValue: FMLFeatureInterface 159 | public init(wrapping holder: FeatureHolder) { 160 | inner = holder 161 | innerValue = holder.value() 162 | } 163 | 164 | public func value() -> FMLFeatureInterface { 165 | innerValue 166 | } 167 | 168 | /// Returns a JSON string representing the complete configuration. 169 | /// 170 | /// A convenience for `self.value().toJSONString()`. 171 | public func toJSONString() -> String { 172 | innerValue.toJSONString() 173 | } 174 | } 175 | 176 | extension FeatureHolderAny: FeatureHolderInterface { 177 | public func recordExposure() { 178 | inner.recordExposure() 179 | } 180 | 181 | public func recordExperimentExposure(slug: String) { 182 | inner.recordExperimentExposure(slug: slug) 183 | } 184 | 185 | public func recordMalformedConfiguration(with partId: String) { 186 | inner.recordMalformedConfiguration(with: partId) 187 | } 188 | 189 | public func isUnderTest() -> Bool { 190 | inner.isUnderTest() 191 | } 192 | } 193 | 194 | /// A bare-bones interface for the FML generated objects. 195 | public protocol FMLObjectInterface: Encodable {} 196 | 197 | /// A bare-bones interface for the FML generated features. 198 | /// 199 | /// App developers should use the generated concrete classes, which 200 | /// implement this interface. 201 | /// 202 | public protocol FMLFeatureInterface: FMLObjectInterface { 203 | /// A test if the feature configuration has been modified somehow, invalidating any experiment 204 | /// that uses it. 205 | /// 206 | /// This may be `true` if a `pref-key` has been set in the feature manifest and the user has 207 | /// set that preference. 208 | func isModified() -> Bool 209 | 210 | /// Returns a string representation of the complete feature configuration in JSON format. 211 | func toJSONString() -> String 212 | } 213 | 214 | public extension FMLFeatureInterface { 215 | func isModified() -> Bool { 216 | return false 217 | } 218 | 219 | func toJSONString() -> String { 220 | let encoder = JSONEncoder() 221 | guard let data = try? encoder.encode(self) else { 222 | fatalError("`JSONEncoder.encode()` must succeed for `FMLFeatureInterface`") 223 | } 224 | guard let string = String(data: data, encoding: .utf8) else { 225 | fatalError("`JSONEncoder.encode()` must return valid UTF-8") 226 | } 227 | return string 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /swift-source/focus/Nimbus/FeatureInterface.swift: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | import Foundation 5 | 6 | /// A small protocol to get the feature variables out of the Nimbus SDK. 7 | /// 8 | /// This is intended to be standalone to allow for testing the Nimbus FML. 9 | public protocol FeaturesInterface: AnyObject { 10 | var userDefaults: UserDefaults? { get } 11 | 12 | /// Get the variables needed to configure the feature given by `featureId`. 13 | /// 14 | /// - Parameters: 15 | /// - featureId The string feature id that identifies to the feature under experiment. 16 | /// - recordExposureEvent Passing `true` to this parameter will record the exposure 17 | /// event automatically if the client is enrolled in an experiment for the given `featureId`. 18 | /// Passing `false` here indicates that the application will manually record the exposure 19 | /// event by calling `recordExposureEvent`. 20 | /// 21 | /// See `recordExposureEvent` for more information on manually recording the event. 22 | /// 23 | /// - Returns a `Variables` object used to configure the feature. 24 | func getVariables(featureId: String, sendExposureEvent: Bool) -> Variables 25 | 26 | /// Records the `exposure` event in telemetry. 27 | /// 28 | /// This is a manual function to accomplish the same purpose as passing `true` as the 29 | /// `recordExposureEvent` property of the `getVariables` function. It is intended to be used 30 | /// when requesting feature variables must occur at a different time than the actual user's 31 | /// exposure to the feature within the app. 32 | /// 33 | /// - Examples: 34 | /// - If the `Variables` are needed at a different time than when the exposure to the feature 35 | /// actually happens, such as constructing a menu happening at a different time than the 36 | /// user seeing the menu. 37 | /// - If `getVariables` is required to be called multiple times for the same feature and it is 38 | /// desired to only record the exposure once, such as if `getVariables` were called 39 | /// with every keystroke. 40 | /// 41 | /// In the case where the use of this function is required, then the `getVariables` function 42 | /// should be called with `false` so that the exposure event is not recorded when the variables 43 | /// are fetched. 44 | /// 45 | /// This function is safe to call even when there is no active experiment for the feature. The SDK 46 | /// will ensure that an event is only recorded for active experiments. 47 | /// 48 | /// - Parameter featureId string representing the id of the feature for which to record the exposure 49 | /// event. 50 | /// 51 | func recordExposureEvent(featureId: String, experimentSlug: String?) 52 | 53 | /// Records an event signifying a malformed feature configuration, or part of one. 54 | /// 55 | /// - Parameter featureId string representing the id of the feature which app code has found to 56 | /// malformed. 57 | /// - Parameter partId string representing the card id or message id of the part of the feature that 58 | /// is malformed, providing more detail to experiment owners of where to look for the problem. 59 | func recordMalformedConfiguration(featureId: String, with partId: String) 60 | } 61 | 62 | public extension FeaturesInterface { 63 | var userDefaults: UserDefaults? { 64 | nil 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /swift-source/focus/Nimbus/FeatureManifestInterface.swift: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | import Foundation 6 | 7 | public protocol FeatureManifestInterface { 8 | // The `associatedtype``, and the `features`` getter require existential types, in Swift 5.7. 9 | // associatedtype Features 10 | 11 | // Accessor object for generated configuration classes extracted from Nimbus, with built-in 12 | // default values. 13 | // The `associatedtype``, and the `features`` getter require existential types, in Swift 5.7. 14 | // var features: Features { get } 15 | 16 | /// This method should be called as early in the startup sequence of the app as possible. 17 | /// This is to connect the Nimbus SDK (and thus server) with the `{{ nimbus_object }}` 18 | /// class. 19 | /// 20 | /// The lambda MUST be threadsafe in its own right. 21 | /// 22 | /// This happens automatically if you use the `NimbusBuilder` pattern of initialization. 23 | func initialize(with getSdk: @escaping () -> FeaturesInterface?) 24 | 25 | /// Refresh the cache of configuration objects. 26 | /// 27 | /// For performance reasons, the feature configurations are constructed once then cached. 28 | /// This method is to clear that cache for all features configured with Nimbus. 29 | /// 30 | /// It must be called whenever the Nimbus SDK finishes the `applyPendingExperiments()` method. 31 | /// 32 | /// This happens automatically if you use the `NimbusBuilder` pattern of initialization. 33 | func invalidateCachedValues() 34 | 35 | /// Get a feature configuration. This is of limited use for most uses of the FML, though 36 | /// is quite useful for introspection. 37 | func getFeature(featureId: String) -> FeatureHolderAny? 38 | 39 | func getCoenrollingFeatureIds() -> [String] 40 | } 41 | -------------------------------------------------------------------------------- /swift-source/focus/Nimbus/HardcodedNimbusFeatures.swift: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | import Foundation 6 | 7 | /// Shim class for injecting JSON feature configs, as typed into the experimenter branch config page, 8 | /// straight into the application. 9 | /// 10 | /// This is suitable for unit testing and ui testing. 11 | /// 12 | /// let hardcodedNimbus = HardcodedNimbus(with: [ 13 | /// "my-feature": """{ 14 | /// "enabled": true 15 | /// }""" 16 | /// ]) 17 | /// hardcodedNimbus.connect(with: FxNimbus.shared) 18 | /// 19 | /// 20 | /// Once the `hardcodedNimbus` is connected to the `FxNimbus.shared`, then 21 | /// calling `FxNimbus.shared.features.myFeature.value()` will behave as if the given JSON 22 | /// came from an experiment. 23 | /// 24 | public class HardcodedNimbusFeatures { 25 | let features: [String: [String: Any]] 26 | let bundles: [Bundle] 27 | var exposureCounts = [String: Int]() 28 | var malformedFeatures = [String: String]() 29 | 30 | public init(bundles: [Bundle] = [.main], with features: [String: [String: Any]]) { 31 | self.features = features 32 | self.bundles = bundles 33 | } 34 | 35 | public convenience init(bundles: [Bundle] = [.main], with jsons: [String: String] = [String: String]()) { 36 | let features = jsons.mapValuesNotNull { 37 | try? Dictionary.parse(jsonString: $0) 38 | } 39 | self.init(bundles: bundles, with: features) 40 | } 41 | 42 | /// Reports how many times the feature has had {recordExposureEvent} on it. 43 | public func getExposureCount(featureId: String) -> Int { 44 | return exposureCounts[featureId] ?? 0 45 | } 46 | 47 | /// Helper function for testing if the exposure count for this feature is greater than zero. 48 | public func isExposed(featureId: String) -> Bool { 49 | return getExposureCount(featureId: featureId) > 0 50 | } 51 | 52 | /// Helper function for testing if app code has reported that any of the feature 53 | /// configuration is malformed. 54 | public func isMalformed(featureId: String) -> Bool { 55 | return malformedFeatures[featureId] != nil 56 | } 57 | 58 | /// Getter method for the last part of the given feature was reported malformed. 59 | public func getMalformed(for featureId: String) -> String? { 60 | return malformedFeatures[featureId] 61 | } 62 | 63 | /// Utility function for {isUnderTest} to detect if the feature is under test. 64 | public func has(featureId: String) -> Bool { 65 | return features[featureId] != nil 66 | } 67 | 68 | /// Use this `NimbusFeatures` instance to populate the passed feature configurations. 69 | public func connect(with fm: FeatureManifestInterface) { 70 | fm.initialize { self } 71 | } 72 | } 73 | 74 | extension HardcodedNimbusFeatures: FeaturesInterface { 75 | public func getVariables(featureId: String, sendExposureEvent: Bool) -> Variables { 76 | if let json = features[featureId] { 77 | if sendExposureEvent { 78 | recordExposureEvent(featureId: featureId) 79 | } 80 | return JSONVariables(with: json, in: bundles) 81 | } 82 | return NilVariables.instance 83 | } 84 | 85 | public func recordExposureEvent(featureId: String, experimentSlug _: String? = nil) { 86 | if features[featureId] != nil { 87 | exposureCounts[featureId] = getExposureCount(featureId: featureId) + 1 88 | } 89 | } 90 | 91 | public func recordMalformedConfiguration(featureId: String, with partId: String) { 92 | malformedFeatures[featureId] = partId 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /swift-source/focus/Nimbus/NimbusBuilder.swift: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | import Foundation 6 | 7 | /** 8 | * A builder for [Nimbus] singleton objects, parameterized in a declarative class. 9 | */ 10 | public class NimbusBuilder { 11 | let dbFilePath: String 12 | 13 | public init(dbPath: String) { 14 | dbFilePath = dbPath 15 | } 16 | 17 | /** 18 | * An optional server URL string. 19 | * 20 | * This will only be null or empty in development or testing, or in any build variant of a 21 | * non-Mozilla fork. 22 | */ 23 | @discardableResult 24 | public func with(url: String?) -> Self { 25 | self.url = url 26 | return self 27 | } 28 | 29 | var url: String? 30 | 31 | /** 32 | * A closure for reporting errors from Rust. 33 | */ 34 | @discardableResult 35 | public func with(errorReporter reporter: @escaping NimbusErrorReporter) -> NimbusBuilder { 36 | errorReporter = reporter 37 | return self 38 | } 39 | 40 | var errorReporter: NimbusErrorReporter = defaultErrorReporter 41 | 42 | /** 43 | * A flag to select the main or preview collection of remote settings. Defaults to `false`. 44 | */ 45 | @discardableResult 46 | public func using(previewCollection flag: Bool) -> NimbusBuilder { 47 | usePreviewCollection = flag 48 | return self 49 | } 50 | 51 | var usePreviewCollection: Bool = false 52 | 53 | /** 54 | * A flag to indicate if this is being run on the first run of the app. This is used to control 55 | * whether the `initial_experiments` file is used to populate Nimbus. 56 | */ 57 | @discardableResult 58 | public func isFirstRun(_ flag: Bool) -> NimbusBuilder { 59 | isFirstRun = flag 60 | return self 61 | } 62 | 63 | var isFirstRun: Bool = true 64 | 65 | /** 66 | * A optional raw resource of a file downloaded at or near build time from Remote Settings. 67 | */ 68 | @discardableResult 69 | public func with(initialExperiments fileURL: URL?) -> NimbusBuilder { 70 | initialExperiments = fileURL 71 | return self 72 | } 73 | 74 | var initialExperiments: URL? 75 | 76 | /** 77 | * The timeout used to wait for the loading of the `initial_experiments 78 | */ 79 | @discardableResult 80 | public func with(timeoutForLoadingInitialExperiments seconds: TimeInterval) -> NimbusBuilder { 81 | timeoutLoadingExperiment = seconds 82 | return self 83 | } 84 | 85 | var timeoutLoadingExperiment: TimeInterval = 0.200 /* seconds */ 86 | 87 | /** 88 | * Optional callback to be called after the creation of the nimbus object and it is ready 89 | * to be used. 90 | */ 91 | @discardableResult 92 | public func onCreate(callback: @escaping (NimbusInterface) -> Void) -> NimbusBuilder { 93 | onCreateCallback = callback 94 | return self 95 | } 96 | 97 | var onCreateCallback: ((NimbusInterface) -> Void)? 98 | 99 | /** 100 | * Optional callback to be called after the calculation of new enrollments and applying of changes to 101 | * experiments recipes. 102 | */ 103 | @discardableResult 104 | public func onApply(callback: @escaping (NimbusInterface) -> Void) -> NimbusBuilder { 105 | onApplyCallback = callback 106 | return self 107 | } 108 | 109 | var onApplyCallback: ((NimbusInterface) -> Void)? 110 | 111 | /** 112 | * Optional callback to be called after the fetch of new experiments has completed. 113 | * experiments recipes. 114 | */ 115 | @discardableResult 116 | public func onFetch(callback: @escaping (NimbusInterface) -> Void) -> NimbusBuilder { 117 | onFetchCallback = callback 118 | return self 119 | } 120 | 121 | var onFetchCallback: ((NimbusInterface) -> Void)? 122 | 123 | /** 124 | * Resource bundles used to look up bundled text and images. Defaults to `[Bundle.main]`. 125 | */ 126 | @discardableResult 127 | public func with(bundles: [Bundle]) -> NimbusBuilder { 128 | resourceBundles = bundles 129 | return self 130 | } 131 | 132 | var resourceBundles: [Bundle] = [.main] 133 | 134 | /** 135 | * The object generated from the `nimbus.fml.yaml` file. 136 | */ 137 | @discardableResult 138 | public func with(featureManifest: FeatureManifestInterface) -> NimbusBuilder { 139 | self.featureManifest = featureManifest 140 | return self 141 | } 142 | 143 | var featureManifest: FeatureManifestInterface? 144 | 145 | /** 146 | * Main user defaults for the app. 147 | */ 148 | @discardableResult 149 | public func with(userDefaults: UserDefaults) -> NimbusBuilder { 150 | self.userDefaults = userDefaults 151 | return self 152 | } 153 | 154 | var userDefaults = UserDefaults.standard 155 | 156 | /** 157 | * The command line arguments for the app. This is useful for QA, and can be safely left in the app in production. 158 | */ 159 | @discardableResult 160 | public func with(commandLineArgs: [String]) -> NimbusBuilder { 161 | self.commandLineArgs = commandLineArgs 162 | return self 163 | } 164 | 165 | var commandLineArgs: [String]? 166 | 167 | /** 168 | * An optional RecordedContext object. 169 | * 170 | * When provided, its JSON contents will be added to the Nimbus targeting context, and its value will be published 171 | * to Glean. 172 | */ 173 | @discardableResult 174 | public func with(recordedContext: RecordedContext?) -> Self { 175 | self.recordedContext = recordedContext 176 | return self 177 | } 178 | 179 | var recordedContext: RecordedContext? 180 | 181 | // swiftlint:disable function_body_length 182 | /** 183 | * Build a [Nimbus] singleton for the given [NimbusAppSettings]. Instances built with this method 184 | * have been initialized, and are ready for use by the app. 185 | * 186 | * Instance have _not_ yet had [fetchExperiments()] called on it, or anything usage of the 187 | * network. This is to allow the networking stack to be initialized after this method is called 188 | * and the networking stack to be involved in experiments. 189 | */ 190 | public func build(appInfo: NimbusAppSettings) -> NimbusInterface { 191 | let serverSettings: NimbusServerSettings? 192 | if let string = url, 193 | let url = URL(string: string) 194 | { 195 | if usePreviewCollection { 196 | serverSettings = NimbusServerSettings(url: url, collection: remoteSettingsPreviewCollection) 197 | } else { 198 | serverSettings = NimbusServerSettings(url: url, collection: remoteSettingsCollection) 199 | } 200 | } else { 201 | serverSettings = nil 202 | } 203 | 204 | do { 205 | let nimbus = try newNimbus(appInfo, serverSettings: serverSettings) 206 | let fm = featureManifest 207 | let onApplyCallback = onApplyCallback 208 | if fm != nil || onApplyCallback != nil { 209 | NotificationCenter.default.addObserver(forName: .nimbusExperimentsApplied, 210 | object: nil, 211 | queue: nil) 212 | { _ in 213 | fm?.invalidateCachedValues() 214 | onApplyCallback?(nimbus) 215 | } 216 | } 217 | 218 | if let callback = onFetchCallback { 219 | NotificationCenter.default.addObserver(forName: .nimbusExperimentsFetched, 220 | object: nil, 221 | queue: nil) 222 | { _ in 223 | callback(nimbus) 224 | } 225 | } 226 | 227 | // Is the app being built locally, and the nimbus-cli 228 | // hasn't been used before this run. 229 | func isLocalBuild() -> Bool { 230 | serverSettings == nil && nimbus.isFetchEnabled() 231 | } 232 | 233 | if let args = ArgumentProcessor.createCommandLineArgs(args: commandLineArgs) { 234 | ArgumentProcessor.initializeTooling(nimbus: nimbus, args: args) 235 | } else if let file = initialExperiments, isFirstRun || isLocalBuild() { 236 | let job = nimbus.applyLocalExperiments(fileURL: file) 237 | _ = job.joinOrTimeout(timeout: timeoutLoadingExperiment) 238 | } else { 239 | nimbus.applyPendingExperiments().waitUntilFinished() 240 | } 241 | 242 | // By now, on this thread, we have a fully initialized Nimbus object, ready for use: 243 | // * we gave a 200ms timeout to the loading of a file from res/raw 244 | // * on completion or cancellation, applyPendingExperiments or initialize was 245 | // called, and this thread waited for that to complete. 246 | featureManifest?.initialize { nimbus } 247 | onCreateCallback?(nimbus) 248 | 249 | return nimbus 250 | } catch { 251 | errorReporter(error) 252 | return newNimbusDisabled() 253 | } 254 | } 255 | 256 | // swiftlint:enable function_body_length 257 | 258 | func getCoenrollingFeatureIds() -> [String] { 259 | featureManifest?.getCoenrollingFeatureIds() ?? [] 260 | } 261 | 262 | func newNimbus(_ appInfo: NimbusAppSettings, serverSettings: NimbusServerSettings?) throws -> NimbusInterface { 263 | try Nimbus.create(serverSettings, 264 | appSettings: appInfo, 265 | coenrollingFeatureIds: getCoenrollingFeatureIds(), 266 | dbPath: dbFilePath, 267 | resourceBundles: resourceBundles, 268 | userDefaults: userDefaults, 269 | errorReporter: errorReporter, 270 | recordedContext: recordedContext) 271 | } 272 | 273 | func newNimbusDisabled() -> NimbusInterface { 274 | NimbusDisabled.shared 275 | } 276 | } 277 | -------------------------------------------------------------------------------- /swift-source/focus/Nimbus/NimbusCreate.swift: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | import Foundation 6 | import Glean 7 | import UIKit 8 | 9 | private let logTag = "Nimbus.swift" 10 | private let logger = Logger(tag: logTag) 11 | 12 | public let defaultErrorReporter: NimbusErrorReporter = { err in 13 | switch err { 14 | case is LocalizedError: 15 | let description = err.localizedDescription 16 | logger.error("Nimbus error: \(description)") 17 | default: 18 | logger.error("Nimbus error: \(err)") 19 | } 20 | } 21 | 22 | class GleanMetricsHandler: MetricsHandler { 23 | func recordEnrollmentStatuses(enrollmentStatusExtras: [EnrollmentStatusExtraDef]) { 24 | for extra in enrollmentStatusExtras { 25 | GleanMetrics.NimbusEvents.enrollmentStatus 26 | .record(GleanMetrics.NimbusEvents.EnrollmentStatusExtra( 27 | branch: extra.branch, 28 | conflictSlug: extra.conflictSlug, 29 | errorString: extra.errorString, 30 | reason: extra.reason, 31 | slug: extra.slug, 32 | status: extra.status 33 | )) 34 | } 35 | } 36 | 37 | func recordFeatureActivation(event: FeatureExposureExtraDef) { 38 | GleanMetrics.NimbusEvents.activation 39 | .record(GleanMetrics.NimbusEvents.ActivationExtra( 40 | branch: event.branch, 41 | experiment: event.slug, 42 | featureId: event.featureId 43 | )) 44 | } 45 | 46 | func recordFeatureExposure(event: FeatureExposureExtraDef) { 47 | GleanMetrics.NimbusEvents.exposure 48 | .record(GleanMetrics.NimbusEvents.ExposureExtra( 49 | branch: event.branch, 50 | experiment: event.slug, 51 | featureId: event.featureId 52 | )) 53 | } 54 | 55 | func recordMalformedFeatureConfig(event: MalformedFeatureConfigExtraDef) { 56 | GleanMetrics.NimbusEvents.malformedFeature 57 | .record(GleanMetrics.NimbusEvents.MalformedFeatureExtra( 58 | branch: event.branch, 59 | experiment: event.slug, 60 | featureId: event.featureId, 61 | partId: event.part 62 | )) 63 | } 64 | } 65 | 66 | public extension Nimbus { 67 | /// Create an instance of `Nimbus`. 68 | /// 69 | /// - Parameters: 70 | /// - server: the server that experiments will be downloaded from 71 | /// - appSettings: the name and channel for the app 72 | /// - dbPath: the path on disk for the database 73 | /// - resourceBundles: an optional array of `Bundle` objects that are used to lookup text and images 74 | /// - enabled: intended for FeatureFlags. If false, then return a dummy `Nimbus` instance. Defaults to `true`. 75 | /// - errorReporter: a closure capable of reporting errors. Defaults to using a logger. 76 | /// - Returns an implementation of `NimbusApi`. 77 | /// - Throws `NimbusError` if anything goes wrong with the Rust FFI or in the `NimbusClient` constructor. 78 | /// 79 | static func create( 80 | _ server: NimbusServerSettings?, 81 | appSettings: NimbusAppSettings, 82 | coenrollingFeatureIds: [String] = [], 83 | dbPath: String, 84 | resourceBundles: [Bundle] = [Bundle.main], 85 | enabled: Bool = true, 86 | userDefaults: UserDefaults? = nil, 87 | errorReporter: @escaping NimbusErrorReporter = defaultErrorReporter, 88 | recordedContext: RecordedContext? = nil 89 | ) throws -> NimbusInterface { 90 | guard enabled else { 91 | return NimbusDisabled.shared 92 | } 93 | 94 | let context = Nimbus.buildExperimentContext(appSettings) 95 | let remoteSettings = server.map { server -> RemoteSettingsConfig in 96 | RemoteSettingsConfig( 97 | collectionName: server.collection, 98 | server: .custom(url: server.url.absoluteString) 99 | ) 100 | } 101 | let nimbusClient = try NimbusClient( 102 | appCtx: context, 103 | recordedContext: recordedContext, 104 | coenrollingFeatureIds: coenrollingFeatureIds, 105 | dbpath: dbPath, 106 | remoteSettingsConfig: remoteSettings, 107 | metricsHandler: GleanMetricsHandler() 108 | ) 109 | 110 | return Nimbus( 111 | nimbusClient: nimbusClient, 112 | resourceBundles: resourceBundles, 113 | userDefaults: userDefaults, 114 | errorReporter: errorReporter 115 | ) 116 | } 117 | 118 | static func buildExperimentContext( 119 | _ appSettings: NimbusAppSettings, 120 | bundle: Bundle = Bundle.main, 121 | device: UIDevice = .current 122 | ) -> AppContext { 123 | let info = bundle.infoDictionary ?? [:] 124 | var inferredDateInstalledOn: Date? { 125 | guard 126 | let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).last, 127 | let attributes = try? FileManager.default.attributesOfItem(atPath: documentsURL.path) 128 | else { return nil } 129 | return attributes[.creationDate] as? Date 130 | } 131 | let installationDateSinceEpoch = inferredDateInstalledOn.map { 132 | Int64(($0.timeIntervalSince1970 * 1000).rounded()) 133 | } 134 | 135 | return AppContext( 136 | appName: appSettings.appName, 137 | appId: info["CFBundleIdentifier"] as? String ?? "unknown", 138 | channel: appSettings.channel, 139 | appVersion: info["CFBundleShortVersionString"] as? String, 140 | appBuild: info["CFBundleVersion"] as? String, 141 | architecture: Sysctl.machine, // Sysctl is from Glean. 142 | deviceManufacturer: Sysctl.manufacturer, 143 | deviceModel: Sysctl.model, 144 | locale: getLocaleTag(), // from Glean utils 145 | os: device.systemName, 146 | osVersion: device.systemVersion, 147 | androidSdkVersion: nil, 148 | debugTag: "Nimbus.rs", 149 | installationDate: installationDateSinceEpoch, 150 | homeDirectory: nil, 151 | customTargetingAttributes: try? appSettings.customTargetingAttributes.stringify() 152 | ) 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /swift-source/focus/Nimbus/NimbusMessagingHelpers.swift: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | import Foundation 6 | import Glean 7 | 8 | /** 9 | * Instances of this class are useful for implementing a messaging service based upon 10 | * Nimbus. 11 | * 12 | * The message helper is designed to help string interpolation and JEXL evalutaiuon against the context 13 | * of the attrtibutes Nimbus already knows about. 14 | * 15 | * App-specific, additional context can be given at creation time. 16 | * 17 | * The helpers are designed to evaluate multiple messages at a time, however: since the context may change 18 | * over time, the message helper should not be stored for long periods. 19 | */ 20 | public protocol NimbusMessagingProtocol { 21 | func createMessageHelper() throws -> NimbusMessagingHelperProtocol 22 | func createMessageHelper(additionalContext: [String: Any]) throws -> NimbusMessagingHelperProtocol 23 | func createMessageHelper(additionalContext: T) throws -> NimbusMessagingHelperProtocol 24 | 25 | var events: NimbusEventStore { get } 26 | } 27 | 28 | public protocol NimbusMessagingHelperProtocol: NimbusStringHelperProtocol, NimbusTargetingHelperProtocol { 29 | /** 30 | * Clear the JEXL cache 31 | */ 32 | func clearCache() 33 | } 34 | 35 | /** 36 | * A helper object to make working with Strings uniform across multiple implementations of the messaging 37 | * system. 38 | * 39 | * This object provides access to a JEXL evaluator which runs against the same context as provided by 40 | * Nimbus targeting. 41 | * 42 | * It should also provide a similar function for String substitution, though this scheduled for EXP-2159. 43 | */ 44 | public class NimbusMessagingHelper: NimbusMessagingHelperProtocol { 45 | private let targetingHelper: NimbusTargetingHelperProtocol 46 | private let stringHelper: NimbusStringHelperProtocol 47 | private var cache: [String: Bool] 48 | 49 | public init(targetingHelper: NimbusTargetingHelperProtocol, 50 | stringHelper: NimbusStringHelperProtocol, 51 | cache: [String: Bool] = [:]) 52 | { 53 | self.targetingHelper = targetingHelper 54 | self.stringHelper = stringHelper 55 | self.cache = cache 56 | } 57 | 58 | public func evalJexl(expression: String) throws -> Bool { 59 | if let result = cache[expression] { 60 | return result 61 | } else { 62 | let result = try targetingHelper.evalJexl(expression: expression) 63 | cache[expression] = result 64 | return result 65 | } 66 | } 67 | 68 | public func clearCache() { 69 | cache.removeAll() 70 | } 71 | 72 | public func getUuid(template: String) -> String? { 73 | stringHelper.getUuid(template: template) 74 | } 75 | 76 | public func stringFormat(template: String, uuid: String?) -> String { 77 | stringHelper.stringFormat(template: template, uuid: uuid) 78 | } 79 | } 80 | 81 | // MARK: Dummy implementations 82 | 83 | class AlwaysConstantTargetingHelper: NimbusTargetingHelperProtocol { 84 | private let constant: Bool 85 | 86 | public init(constant: Bool = false) { 87 | self.constant = constant 88 | } 89 | 90 | public func evalJexl(expression _: String) throws -> Bool { 91 | constant 92 | } 93 | } 94 | 95 | class EchoStringHelper: NimbusStringHelperProtocol { 96 | public func getUuid(template _: String) -> String? { 97 | nil 98 | } 99 | 100 | public func stringFormat(template: String, uuid _: String?) -> String { 101 | template 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /swift-source/focus/Nimbus/Operation+.swift: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | import Foundation 6 | 7 | public extension Operation { 8 | /// Wait for the operation to finish, or a timeout. 9 | /// 10 | /// The operation is cooperatively cancelled on timeout, that is to say, it checks its {isCancelled}. 11 | func joinOrTimeout(timeout: TimeInterval) -> Bool { 12 | if isFinished { 13 | return !isCancelled 14 | } 15 | DispatchQueue.global().async { 16 | Thread.sleep(forTimeInterval: timeout) 17 | if !self.isFinished { 18 | self.cancel() 19 | } 20 | } 21 | 22 | waitUntilFinished() 23 | return !isCancelled 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /swift-source/focus/Nimbus/Utils/Logger.swift: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | import Foundation 6 | import os.log 7 | 8 | class Logger { 9 | private let log: OSLog 10 | 11 | /// Creates a new logger instance with the specified tag value 12 | /// 13 | /// - parameters: 14 | /// * tag: `String` value used to tag log messages 15 | init(tag: String) { 16 | self.log = OSLog( 17 | subsystem: Bundle.main.bundleIdentifier!, 18 | category: tag 19 | ) 20 | } 21 | 22 | /// Output a debug log message 23 | /// 24 | /// - parameters: 25 | /// * message: The message to log 26 | func debug(_ message: String) { 27 | log(message, type: .debug) 28 | } 29 | 30 | /// Output an info log message 31 | /// 32 | /// - parameters: 33 | /// * message: The message to log 34 | func info(_ message: String) { 35 | log(message, type: .info) 36 | } 37 | 38 | /// Output an error log message 39 | /// 40 | /// - parameters: 41 | /// * message: The message to log 42 | func error(_ message: String) { 43 | log(message, type: .error) 44 | } 45 | 46 | /// Private function that calls os_log with the proper parameters 47 | /// 48 | /// - parameters: 49 | /// * message: The message to log 50 | /// * level: The `LogLevel` at which to output the message 51 | private func log(_ message: String, type: OSLogType) { 52 | os_log("%@", log: self.log, type: type, message) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /swift-source/focus/Nimbus/Utils/Sysctl.swift: -------------------------------------------------------------------------------- 1 | // swiftlint:disable line_length 2 | // REASON: URLs and doc strings 3 | // Copyright © 2017 Matt Gallagher ( http://cocoawithlove.com ). All rights reserved. 4 | // 5 | // Original: https://github.com/mattgallagher/CwlUtils/blob/0e08b0194bf95861e5aac27e8857a972983315d7/Sources/CwlUtils/CwlSysctl.swift 6 | // Modified: 7 | // * iOS only 8 | // * removed unused functions 9 | // * reformatted 10 | // 11 | // ISC License 12 | // 13 | // Permission to use, copy, modify, and/or distribute this software for any 14 | // purpose with or without fee is hereby granted, provided that the above 15 | // copyright notice and this permission notice appear in all copies. 16 | // 17 | // THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 18 | // WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 19 | // MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY 20 | // SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 21 | // WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 22 | // ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR 23 | // IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 24 | 25 | import Foundation 26 | 27 | // swiftlint:disable force_try 28 | // REASON: Used on infallible operations 29 | 30 | /// A "static"-only namespace around a series of functions that operate on buffers returned from the `Darwin.sysctl` function 31 | struct Sysctl { 32 | /// Possible errors. 33 | enum Error: Swift.Error { 34 | case unknown 35 | case malformedUTF8 36 | case invalidSize 37 | case posixError(POSIXErrorCode) 38 | } 39 | 40 | /// Access the raw data for an array of sysctl identifiers. 41 | public static func data(for keys: [Int32]) throws -> [Int8] { 42 | return try keys.withUnsafeBufferPointer { keysPointer throws -> [Int8] in 43 | // Preflight the request to get the required data size 44 | var requiredSize = 0 45 | let preFlightResult = Darwin.sysctl( 46 | UnsafeMutablePointer(mutating: keysPointer.baseAddress), 47 | UInt32(keys.count), 48 | nil, 49 | &requiredSize, 50 | nil, 51 | 0 52 | ) 53 | if preFlightResult != 0 { 54 | throw POSIXErrorCode(rawValue: errno).map { 55 | print($0.rawValue) 56 | return Error.posixError($0) 57 | } ?? Error.unknown 58 | } 59 | 60 | // Run the actual request with an appropriately sized array buffer 61 | let data = [Int8](repeating: 0, count: requiredSize) 62 | let result = data.withUnsafeBufferPointer { dataBuffer -> Int32 in 63 | Darwin.sysctl( 64 | UnsafeMutablePointer(mutating: keysPointer.baseAddress), 65 | UInt32(keys.count), 66 | UnsafeMutableRawPointer(mutating: dataBuffer.baseAddress), 67 | &requiredSize, 68 | nil, 69 | 0 70 | ) 71 | } 72 | if result != 0 { 73 | throw POSIXErrorCode(rawValue: errno).map { Error.posixError($0) } ?? Error.unknown 74 | } 75 | 76 | return data 77 | } 78 | } 79 | 80 | /// Convert a sysctl name string like "hw.memsize" to the array of `sysctl` identifiers (e.g. [CTL_HW, HW_MEMSIZE]) 81 | public static func keys(for name: String) throws -> [Int32] { 82 | var keysBufferSize = Int(CTL_MAXNAME) 83 | var keysBuffer = [Int32](repeating: 0, count: keysBufferSize) 84 | try keysBuffer.withUnsafeMutableBufferPointer { (lbp: inout UnsafeMutableBufferPointer) throws in 85 | try name.withCString { (nbp: UnsafePointer) throws in 86 | guard sysctlnametomib(nbp, lbp.baseAddress, &keysBufferSize) == 0 else { 87 | throw POSIXErrorCode(rawValue: errno).map { Error.posixError($0) } ?? Error.unknown 88 | } 89 | } 90 | } 91 | if keysBuffer.count > keysBufferSize { 92 | keysBuffer.removeSubrange(keysBufferSize ..< keysBuffer.count) 93 | } 94 | return keysBuffer 95 | } 96 | 97 | /// Invoke `sysctl` with an array of identifiers, interpreting the returned buffer as the specified type. 98 | /// This function will throw `Error.invalidSize` if the size of buffer returned from `sysctl` fails to match the size of `T`. 99 | public static func value(ofType _: T.Type, forKeys keys: [Int32]) throws -> T { 100 | let buffer = try data(for: keys) 101 | if buffer.count != MemoryLayout.size { 102 | throw Error.invalidSize 103 | } 104 | return try buffer.withUnsafeBufferPointer { bufferPtr throws -> T in 105 | guard let baseAddress = bufferPtr.baseAddress else { throw Error.unknown } 106 | return baseAddress.withMemoryRebound(to: T.self, capacity: 1) { $0.pointee } 107 | } 108 | } 109 | 110 | /// Invoke `sysctl` with an array of identifiers, interpreting the returned buffer as the specified type. 111 | /// This function will throw `Error.invalidSize` if the size of buffer returned from `sysctl` fails to match the size of `T`. 112 | public static func value(ofType type: T.Type, forKeys keys: Int32...) throws -> T { 113 | return try value(ofType: type, forKeys: keys) 114 | } 115 | 116 | /// Invoke `sysctl` with the specified name, interpreting the returned buffer as the specified type. 117 | /// This function will throw `Error.invalidSize` if the size of buffer returned from `sysctl` fails to match the size of `T`. 118 | public static func value(ofType type: T.Type, forName name: String) throws -> T { 119 | return try value(ofType: type, forKeys: keys(for: name)) 120 | } 121 | 122 | /// Invoke `sysctl` with an array of identifiers, interpreting the returned buffer as a `String`. 123 | /// This function will throw `Error.malformedUTF8` if the buffer returned from `sysctl` cannot be interpreted as a UTF8 buffer. 124 | public static func string(for keys: [Int32]) throws -> String { 125 | let optionalString = try data(for: keys).withUnsafeBufferPointer { dataPointer -> String? in 126 | dataPointer.baseAddress.flatMap { String(validatingUTF8: $0) } 127 | } 128 | guard let s = optionalString else { 129 | throw Error.malformedUTF8 130 | } 131 | return s 132 | } 133 | 134 | /// Invoke `sysctl` with an array of identifiers, interpreting the returned buffer as a `String`. 135 | /// This function will throw `Error.malformedUTF8` if the buffer returned from `sysctl` cannot be interpreted as a UTF8 buffer. 136 | public static func string(for keys: Int32...) throws -> String { 137 | return try string(for: keys) 138 | } 139 | 140 | /// Invoke `sysctl` with the specified name, interpreting the returned buffer as a `String`. 141 | /// This function will throw `Error.malformedUTF8` if the buffer returned from `sysctl` cannot be interpreted as a UTF8 buffer. 142 | public static func string(for name: String) throws -> String { 143 | return try string(for: keys(for: name)) 144 | } 145 | 146 | /// Always the same on Apple hardware 147 | public static var manufacturer: String = "Apple" 148 | 149 | /// e.g. "N71mAP" 150 | public static var machine: String { 151 | return try! Sysctl.string(for: [CTL_HW, HW_MODEL]) 152 | } 153 | 154 | /// e.g. "iPhone8,1" 155 | public static var model: String { 156 | return try! Sysctl.string(for: [CTL_HW, HW_MACHINE]) 157 | } 158 | 159 | /// e.g. "15D21" or "13D20" 160 | public static var osVersion: String { return try! Sysctl.string(for: [CTL_KERN, KERN_OSVERSION]) } 161 | } 162 | // swiftlint:enable force_try 163 | // swiftlint:enable line_length 164 | -------------------------------------------------------------------------------- /swift-source/focus/Nimbus/Utils/Unreachable.swift: -------------------------------------------------------------------------------- 1 | // Unreachable.swift 2 | // Unreachable 3 | // Original: https://github.com/nvzqz/Unreachable 4 | // 5 | // The MIT License (MIT) 6 | // 7 | // Copyright (c) 2017 Nikolai Vazquez 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in 17 | // all copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 25 | // THE SOFTWARE. 26 | // 27 | 28 | /// An unreachable code path. 29 | /// 30 | /// This can be used for whenever the compiler can't determine that a 31 | /// path is unreachable, such as dynamically terminating an iterator. 32 | @inline(__always) 33 | func unreachable() -> Never { 34 | return unsafeBitCast((), to: Never.self) 35 | } 36 | 37 | /// Asserts that the code path is unreachable. 38 | /// 39 | /// Calls `assertionFailure(_:file:line:)` in unoptimized builds and `unreachable()` otherwise. 40 | /// 41 | /// - parameter message: The message to print. The default is "Encountered unreachable path". 42 | /// - parameter file: The file name to print with the message. The default is the file where this function is called. 43 | /// - parameter line: The line number to print with the message. The default is the line where this function is called. 44 | @inline(__always) 45 | func assertUnreachable(_ message: @autoclosure () -> String = "Encountered unreachable path", 46 | file: StaticString = #file, 47 | line: UInt = #line) -> Never { 48 | var isDebug = false 49 | assert({ isDebug = true; return true }()) 50 | 51 | if isDebug { 52 | fatalError(message(), file: file, line: line) 53 | } else { 54 | unreachable() 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /swift-source/focus/Nimbus/Utils/Utils.swift: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | import Foundation 6 | 7 | extension Bool { 8 | /// Convert a bool to its byte equivalent. 9 | func toByte() -> UInt8 { 10 | return self ? 1 : 0 11 | } 12 | } 13 | 14 | extension UInt8 { 15 | /// Convert a byte to its Bool equivalen. 16 | func toBool() -> Bool { 17 | return self != 0 18 | } 19 | } 20 | 21 | /// Create a temporary array of C-compatible (null-terminated) strings to pass over FFI. 22 | /// 23 | /// The strings are deallocated after the closure returns. 24 | /// 25 | /// - parameters: 26 | /// * args: The array of strings to use. 27 | /// If `nil` no output array will be allocated and `nil` will be passed to `body`. 28 | /// * body: The closure that gets an array of C-compatible strings 29 | func withArrayOfCStrings( 30 | _ args: [String]?, 31 | _ body: ([UnsafePointer?]?) -> R 32 | ) -> R { 33 | if let args = args { 34 | let cStrings = args.map { UnsafePointer(strdup($0)) } 35 | defer { 36 | cStrings.forEach { free(UnsafeMutableRawPointer(mutating: $0)) } 37 | } 38 | return body(cStrings) 39 | } else { 40 | return body(nil) 41 | } 42 | } 43 | 44 | /// This struct creates a Boolean with atomic or synchronized access. 45 | /// 46 | /// This makes use of synchronization tools from Grand Central Dispatch (GCD) 47 | /// in order to synchronize access. 48 | struct AtomicBoolean { 49 | private var semaphore = DispatchSemaphore(value: 1) 50 | private var val: Bool 51 | var value: Bool { 52 | get { 53 | semaphore.wait() 54 | let tmp = val 55 | semaphore.signal() 56 | return tmp 57 | } 58 | set { 59 | semaphore.wait() 60 | val = newValue 61 | semaphore.signal() 62 | } 63 | } 64 | 65 | init(_ initialValue: Bool = false) { 66 | val = initialValue 67 | } 68 | } 69 | 70 | /// Get a timestamp in nanos. 71 | /// 72 | /// This is a monotonic clock. 73 | func timestampNanos() -> UInt64 { 74 | var info = mach_timebase_info() 75 | guard mach_timebase_info(&info) == KERN_SUCCESS else { return 0 } 76 | let currentTime = mach_absolute_time() 77 | let nanos = currentTime * UInt64(info.numer) / UInt64(info.denom) 78 | return nanos 79 | } 80 | 81 | /// Gets a gecko-compatible locale string (e.g. "es-ES") 82 | /// If the locale can't be determined on the system, the value is "und", 83 | /// to indicate "undetermined". 84 | /// 85 | /// - returns: a locale string that supports custom injected locale/languages. 86 | public func getLocaleTag() -> String { 87 | if NSLocale.current.languageCode == nil { 88 | return "und" 89 | } else { 90 | if NSLocale.current.regionCode == nil { 91 | return NSLocale.current.languageCode! 92 | } else { 93 | return "\(NSLocale.current.languageCode!)-\(NSLocale.current.regionCode!)" 94 | } 95 | } 96 | } 97 | 98 | /// Gather information about the running application 99 | struct AppInfo { 100 | /// The application's identifier name 101 | public static var name: String { 102 | return Bundle.main.bundleIdentifier! 103 | } 104 | 105 | /// The application's display version string 106 | public static var displayVersion: String { 107 | return Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown" 108 | } 109 | 110 | /// The application's build ID 111 | public static var buildId: String { 112 | return Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Unknown" 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /swift-source/focus/Viaduct/RustViaductFFI.h: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | #pragma once 6 | #include 7 | #include 8 | 9 | void viaduct_use_reqwest_backend(); 10 | -------------------------------------------------------------------------------- /swift-source/focus/Viaduct/Viaduct.swift: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this 3 | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | import Foundation 6 | #if canImport(MozillaRustComponents) 7 | import MozillaRustComponents 8 | #endif 9 | 10 | /// The public interface to viaduct. 11 | /// Right now it doesn't do any "true" viaduct things, 12 | /// it simply activated the reqwest backend. 13 | /// 14 | /// This is a singleton, and should be used via the 15 | /// `shared` static member. 16 | public class Viaduct { 17 | /// The singleton instance of Viaduct 18 | public static let shared = Viaduct() 19 | 20 | private init() {} 21 | 22 | public func useReqwestBackend() { 23 | // Note: Doesn't need to synchronize since 24 | // use_reqwest_backend is backend by a CallOnce. 25 | viaduct_use_reqwest_backend() 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /taskcluster/config.yml: -------------------------------------------------------------------------------- 1 | --- 2 | trust-domain: app-services 3 | task-priority: low 4 | 5 | taskgraph: 6 | cached-task-prefix: "app-services.v2.rust-components-swift" 7 | repositories: 8 | rust_components_swift: 9 | name: "rust-components-swift" 10 | 11 | workers: 12 | aliases: 13 | b-linux: 14 | provisioner: '{trust-domain}-{level}' 15 | implementation: docker-worker 16 | os: linux 17 | worker-type: '{alias}-gcp' 18 | images: 19 | provisioner: '{trust-domain}-{level}' 20 | implementation: docker-worker 21 | os: linux 22 | worker-type: '{alias}-gcp' 23 | t-linux-large: 24 | provisioner: '{trust-domain}-t' 25 | implementation: docker-worker 26 | os: linux 27 | worker-type: '{alias}-gcp' 28 | -------------------------------------------------------------------------------- /taskcluster/docker/linux/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:latest 2 | 3 | # Add worker user 4 | RUN mkdir -p /builds && \ 5 | adduser -h /builds/worker -s /bin/ash -D worker && \ 6 | mkdir /builds/worker/artifacts && \ 7 | chown worker:worker /builds/worker/artifacts 8 | 9 | # Update repositories 10 | RUN apk update 11 | 12 | # Setup Python 13 | RUN apk add --no-cache python3 py3-pip && \ 14 | python3 -m pip install --no-cache --upgrade --break-system-packages pip setuptools 15 | 16 | # Setup other dependencies 17 | RUN apk add bash git coreutils 18 | 19 | # %include-run-task 20 | 21 | ENV SHELL=/bin/ash \ 22 | HOME=/builds/worker \ 23 | PATH=/builds/worker/.local/bin:$PATH 24 | 25 | VOLUME /builds/worker/checkouts 26 | VOLUME /builds/worker/.cache 27 | 28 | # Set a default command useful for debugging 29 | CMD ["/bin/ash"] 30 | -------------------------------------------------------------------------------- /taskcluster/kinds/docker-image/kind.yml: -------------------------------------------------------------------------------- 1 | --- 2 | loader: taskgraph.loader.transform:loader 3 | 4 | transforms: 5 | - taskgraph.transforms.docker_image:transforms 6 | - taskgraph.transforms.cached_tasks:transforms 7 | - taskgraph.transforms.task:transforms 8 | 9 | tasks: 10 | linux: {} 11 | -------------------------------------------------------------------------------- /taskcluster/kinds/hello/kind.yml: -------------------------------------------------------------------------------- 1 | --- 2 | transforms: 3 | - rust_components_swift_taskgraph.transforms.hello:transforms 4 | 5 | task-defaults: 6 | worker-type: t-linux-large 7 | worker: 8 | docker-image: {in-tree: linux} 9 | max-run-time: 1800 10 | 11 | tasks: 12 | world: 13 | noun: world 14 | run: 15 | using: run-task 16 | command: >- 17 | echo "Hello $NOUN!" 18 | -------------------------------------------------------------------------------- /taskcluster/rust_components_swift_taskgraph/transforms/hello.py: -------------------------------------------------------------------------------- 1 | from voluptuous import ALLOW_EXTRA, Required 2 | 3 | from taskgraph.transforms.base import TransformSequence 4 | from taskgraph.util.schema import Schema 5 | 6 | HELLO_SCHEMA = Schema( 7 | { 8 | Required("noun"): str, 9 | }, 10 | extra=ALLOW_EXTRA, 11 | ) 12 | 13 | transforms = TransformSequence() 14 | transforms.add_validate(HELLO_SCHEMA) 15 | 16 | 17 | @transforms.add 18 | def add_noun(config, tasks): 19 | for task in tasks: 20 | noun = task.pop("noun").capitalize() 21 | task["description"] = f"Prints 'Hello {noun}'" 22 | 23 | env = task.setdefault("worker", {}).setdefault("env", {}) 24 | env["NOUN"] = noun 25 | 26 | yield task 27 | --------------------------------------------------------------------------------