Repository: calleluks/Tofu Branch: main Commit: b993d4674955 Files: 163 Total size: 247.3 KB Directory structure: gitextract_iqplvj7o/ ├── .github/ │ └── ISSUE_TEMPLATE/ │ └── issuer-icon-request.md ├── .gitignore ├── AppIcon.sketch ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── GenerateIssuerIconAssets.sh ├── LICENSE ├── README.md ├── Tofu/ │ ├── AppDelegate.swift │ ├── Assets.xcassets/ │ │ ├── 17thShard.imageset/ │ │ │ └── Contents.json │ │ ├── AWS.imageset/ │ │ │ └── Contents.json │ │ ├── Adobe.imageset/ │ │ │ └── Contents.json │ │ ├── Allegro.imageset/ │ │ │ └── Contents.json │ │ ├── Amazon.imageset/ │ │ │ └── Contents.json │ │ ├── AnonAddy.imageset/ │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset/ │ │ │ └── Contents.json │ │ ├── Atlassian.imageset/ │ │ │ └── Contents.json │ │ ├── Backblaze.imageset/ │ │ │ └── Contents.json │ │ ├── Basecamp.imageset/ │ │ │ └── Contents.json │ │ ├── Binance.imageset/ │ │ │ └── Contents.json │ │ ├── BitBay.imageset/ │ │ │ └── Contents.json │ │ ├── Bitbucket.imageset/ │ │ │ └── Contents.json │ │ ├── Bitstamp.imageset/ │ │ │ └── Contents.json │ │ ├── Bittrex.imageset/ │ │ │ └── Contents.json │ │ ├── Bitwarden.imageset/ │ │ │ └── Contents.json │ │ ├── CircularProgressViewBorderThick.imageset/ │ │ │ └── Contents.json │ │ ├── CircularProgressViewBorderThin.imageset/ │ │ │ └── Contents.json │ │ ├── Cloudflare.imageset/ │ │ │ └── Contents.json │ │ ├── Coinbase.imageset/ │ │ │ └── Contents.json │ │ ├── Contentful.imageset/ │ │ │ └── Contents.json │ │ ├── Contents.json │ │ ├── CorporateTrust.imageset/ │ │ │ └── Contents.json │ │ ├── CyDIS.imageset/ │ │ │ └── Contents.json │ │ ├── DNSimple.imageset/ │ │ │ └── Contents.json │ │ ├── DigitalOcean.imageset/ │ │ │ └── Contents.json │ │ ├── Discord.imageset/ │ │ │ └── Contents.json │ │ ├── Docker.imageset/ │ │ │ └── Contents.json │ │ ├── Dropbox.imageset/ │ │ │ └── Contents.json │ │ ├── ElectronicArts.imageset/ │ │ │ └── Contents.json │ │ ├── EpicGames.imageset/ │ │ │ └── Contents.json │ │ ├── Evernote.imageset/ │ │ │ └── Contents.json │ │ ├── Facebook.imageset/ │ │ │ └── Contents.json │ │ ├── FastMail.imageset/ │ │ │ └── Contents.json │ │ ├── Fidelity.imageset/ │ │ │ └── Contents.json │ │ ├── Figma.imageset/ │ │ │ └── Contents.json │ │ ├── Firefox.imageset/ │ │ │ └── Contents.json │ │ ├── Gandi.imageset/ │ │ │ └── Contents.json │ │ ├── GitHub.imageset/ │ │ │ └── Contents.json │ │ ├── GitLab.imageset/ │ │ │ └── Contents.json │ │ ├── Gitea.imageset/ │ │ │ └── Contents.json │ │ ├── GoDaddy.imageset/ │ │ │ └── Contents.json │ │ ├── Google.imageset/ │ │ │ └── Contents.json │ │ ├── GreenAddress.imageset/ │ │ │ └── Contents.json │ │ ├── HEY.imageset/ │ │ │ └── Contents.json │ │ ├── HackTheBox.imageset/ │ │ │ └── Contents.json │ │ ├── Heroku.imageset/ │ │ │ └── Contents.json │ │ ├── Hetzner.imageset/ │ │ │ └── Contents.json │ │ ├── HomeAssistant.imageset/ │ │ │ └── Contents.json │ │ ├── Honeybadger.imageset/ │ │ │ └── Contents.json │ │ ├── Hostek.imageset/ │ │ │ └── Contents.json │ │ ├── Hover.imageset/ │ │ │ └── Contents.json │ │ ├── HumbleBundle.imageset/ │ │ │ └── Contents.json │ │ ├── IDme.imageset/ │ │ │ └── Contents.json │ │ ├── IFTTT.imageset/ │ │ │ └── Contents.json │ │ ├── Instagram.imageset/ │ │ │ └── Contents.json │ │ ├── Intercom.imageset/ │ │ │ └── Contents.json │ │ ├── IssuerIcons/ │ │ │ └── Philips.imageset/ │ │ │ └── IssuerIcons │ │ ├── JetBrains.imageset/ │ │ │ └── Contents.json │ │ ├── Kickstarter.imageset/ │ │ │ └── Contents.json │ │ ├── LastPass.imageset/ │ │ │ └── Contents.json │ │ ├── LinkedIn.imageset/ │ │ │ └── Contents.json │ │ ├── Linode.imageset/ │ │ │ └── Contents.json │ │ ├── Lobsters.imageset/ │ │ │ └── Contents.json │ │ ├── LocalBitcoins.imageset/ │ │ │ └── Contents.json │ │ ├── Mailchimp.imageset/ │ │ │ └── Contents.json │ │ ├── Mastodon.imageset/ │ │ │ └── Contents.json │ │ ├── Mega.imageset/ │ │ │ └── Contents.json │ │ ├── Microsoft.imageset/ │ │ │ └── Contents.json │ │ ├── Name.com.imageset/ │ │ │ └── Contents.json │ │ ├── Netlify.imageset/ │ │ │ └── Contents.json │ │ ├── Nextcloud.imageset/ │ │ │ └── Contents.json │ │ ├── NexusMods.imageset/ │ │ │ └── Contents.json │ │ ├── NiceHash.imageset/ │ │ │ └── Contents.json │ │ ├── Nintendo.imageset/ │ │ │ └── Contents.json │ │ ├── Njalla.imageset/ │ │ │ └── Contents.json │ │ ├── Nodecraft.imageset/ │ │ │ └── Contents.json │ │ ├── NordPass.imageset/ │ │ │ └── Contents.json │ │ ├── PaladinExtensions.imageset/ │ │ │ └── Contents.json │ │ ├── Parler.imageset/ │ │ │ └── Contents.json │ │ ├── PayPal.imageset/ │ │ │ └── Contents.json │ │ ├── PhilipsHue.imageset/ │ │ │ └── Contents.json │ │ ├── Posteo.imageset/ │ │ │ └── Contents.json │ │ ├── Postmark.imageset/ │ │ │ └── Contents.json │ │ ├── Privacy.imageset/ │ │ │ └── Contents.json │ │ ├── ProfitBricks.imageset/ │ │ │ └── Contents.json │ │ ├── ProtonMail.imageset/ │ │ │ └── Contents.json │ │ ├── Prusa.imageset/ │ │ │ └── Contents.json │ │ ├── PrusaAccount.imageset/ │ │ │ └── Contents.json │ │ ├── Reddit.imageset/ │ │ │ └── Contents.json │ │ ├── Robinhood.imageset/ │ │ │ └── Contents.json │ │ ├── RubyGems.imageset/ │ │ │ └── Contents.json │ │ ├── RuneScape.imageset/ │ │ │ └── Contents.json │ │ ├── STACK.imageset/ │ │ │ └── Contents.json │ │ ├── SimpleLogin.imageset/ │ │ │ └── Contents.json │ │ ├── Slack.imageset/ │ │ │ └── Contents.json │ │ ├── Snapchat.imageset/ │ │ │ └── Contents.json │ │ ├── Sony.imageset/ │ │ │ └── Contents.json │ │ ├── Squarespace.imageset/ │ │ │ └── Contents.json │ │ ├── StandardNotes.imageset/ │ │ │ └── Contents.json │ │ ├── Stripe.imageset/ │ │ │ └── Contents.json │ │ ├── Surfshark.imageset/ │ │ │ └── Contents.json │ │ ├── TETR.IO.imageset/ │ │ │ └── Contents.json │ │ ├── Time4VPS.imageset/ │ │ │ └── Contents.json │ │ ├── TorGuard.imageset/ │ │ │ └── Contents.json │ │ ├── Tresorit.imageset/ │ │ │ └── Contents.json │ │ ├── Tumblr.imageset/ │ │ │ └── Contents.json │ │ ├── TurboTax.imageset/ │ │ │ └── Contents.json │ │ ├── Tutanota.imageset/ │ │ │ └── Contents.json │ │ ├── Tweakers.imageset/ │ │ │ └── Contents.json │ │ ├── Twilio.imageset/ │ │ │ └── Contents.json │ │ ├── Twitch.imageset/ │ │ │ └── Contents.json │ │ ├── Twitter.imageset/ │ │ │ └── Contents.json │ │ ├── Uber.imageset/ │ │ │ └── Contents.json │ │ ├── Ubisoft.imageset/ │ │ │ └── Contents.json │ │ ├── Unity.imageset/ │ │ │ └── Contents.json │ │ ├── VKontakte.imageset/ │ │ │ └── Contents.json │ │ ├── Wallabag.imageset/ │ │ │ └── Contents.json │ │ ├── WordPress.imageset/ │ │ │ └── Contents.json │ │ ├── YNAB.imageset/ │ │ │ └── Contents.json │ │ ├── Zoom.imageset/ │ │ │ └── Contents.json │ │ └── ownCloud.imageset/ │ │ └── Contents.json │ ├── Base.lproj/ │ │ ├── LaunchScreen.storyboard │ │ └── Main.storyboard │ ├── Controllers/ │ │ ├── AccountCreationViewController.swift │ │ ├── AccountSearchResultsViewController.swift │ │ ├── AccountUpdateViewController.swift │ │ ├── AccountsTableViewUpdater.swift │ │ ├── AccountsViewController.swift │ │ ├── AlgorithmsViewController.swift │ │ └── ScanningViewController.swift │ ├── Extensions/ │ │ ├── Data.swift │ │ └── UIViewController.swift │ ├── Info.plist │ ├── Models/ │ │ ├── Account.swift │ │ ├── Algorithm.swift │ │ ├── ExternalDataInterop.swift │ │ ├── Keychain.swift │ │ └── Password.swift │ ├── Protocols/ │ │ ├── AccountCreationDelegate.swift │ │ ├── AccountUpdateDelegate.swift │ │ └── AlgorithmSelectionDelegate.swift │ ├── Tofu.xcconfig │ └── Views/ │ ├── AccountCell.swift │ └── CircularProgressView.swift ├── Tofu.xcodeproj/ │ ├── project.pbxproj │ └── project.xcworkspace/ │ ├── contents.xcworkspacedata │ └── xcshareddata/ │ └── IDEWorkspaceChecks.plist ├── TofuTests/ │ ├── AccountTests.swift │ ├── DataTests.swift │ ├── Info.plist │ └── PasswordTests.swift └── TofuUITests/ ├── Info.plist └── TofuUITests.swift ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/ISSUE_TEMPLATE/issuer-icon-request.md ================================================ --- name: Issuer Icon Request about: Use this template when asking for an icon to be added to the app. title: Add an icon for Example labels: icon request assignees: '' --- Hi! Could you please add an icon for Example? Their website is at https://example.com. When scanning their QR code with Tofu, the account issuer shows up as Example. ================================================ FILE: .gitignore ================================================ xcuserdata .DS_Store ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. ## Our Standards Examples of behavior that contributes to creating a positive environment include: * Using welcoming and inclusive language * Being respectful of differing viewpoints and experiences * Gracefully accepting constructive criticism * Focusing on what is best for the community * Showing empathy towards other community members Examples of unacceptable behavior by participants include: * The use of sexualized language or imagery and unwelcome sexual attention or advances * Trolling, insulting/derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or electronic address, without explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Our Responsibilities Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. ## Scope This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at . All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version]. [homepage]: http://contributor-covenant.org [version]: http://contributor-covenant.org/version/1/4/ ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing We love pull requests from everyone. By participating in this project, you agree to abide by its [code of conduct]. [code of conduct]: https://github.com/calleerlandsson/Tofu/blob/master/CODE_OF_CONDUCT.md ## Getting Started To get started contributing to Tofu, follow these steps: 1. Fork and clone the repo. 2. Make your changes. If you're contributing code, please include tests that fail without your code and pass with it. 3. Make sure all automated tests pass. One way of running them is to use the ⌘U keyboard shortcut in Xcode. 4. Submit a pull request We try to respond to, if not merge, pull requests as soon as we can. ================================================ FILE: GenerateIssuerIconAssets.sh ================================================ #!/usr/bin/env bash set -euo pipefail get_name() { echo $1 | sed -E 's:.+/(.+)\.png:\1:' } write_json() { # JSON copied from Xcode output cat << EOF > "$2" { "images" : [ { "idiom" : "universal", "filename" : "${1}.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "${1}@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "${1}@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } EOF } cd "$(dirname "$0")" for file in ./IssuerIcons/*.png; do name="$(get_name $file)" echo "Generating icon for ${name}" imageset="./Tofu/Assets.xcassets/${name}.imageset/" mkdir -p "$imageset" sips --resampleWidth 192 "$file" --out "${imageset}${name}@3x.png" >/dev/null sips --resampleWidth 128 "$file" --out "${imageset}${name}@2x.png" >/dev/null sips --resampleWidth 64 "$file" --out "${imageset}${name}.png" >/dev/null write_json "$name" "${imageset}Contents.json" done ================================================ FILE: LICENSE ================================================ Copyright (c) 2016 Calle Erlandsson Permission to use, copy, modify, and distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ================================================ FILE: README.md ================================================ # Tofu An easy-to-use, open-source two-factor authentication app designed specifically for iOS. Tofu generates one-time passwords to help you protect your online accounts. These passwords are used together with your normal password when you sign into services like Google, Facebook, Dropbox, Amazon, and GitHub. Tofu works with all services that provide two-factor authentication using the HOTP and TOTP algorithms. It does not require a network or cellular connection and can be used in airplane mode. ## New maintainer Hey there, [Calle](https://github.com/calleluks) here, the original author of Tofu Authenticator. Over the last few years I haven’t had as much time to work on the app as I had hoped for. While Tofu has been working fine (I still use the app every day), few improvements have been made and no new platform features have been integrated. Luckily, my friend [Daniel Kennett](https://github.com/ikenndac) who is both a long-time user of Tofu and a way more experienced iOS developer than I am, has offered to take over maintenance and ownership of the app going forward. I’m really thankful for being able to place the app in Daniel’s safe hands and look forward to following the future development of it! ## Installation Tofu is available for free on the App Store. [![Download on the App Store](https://tofuauth.com/images/app-store.svg)](https://itunes.apple.com/app/tofu-authenticator/id1082229305) ## Issuer icons Here's how you can help add new icons to the app: 1. Fork and clone this repo. 2. Add your icon to the `IssuerIcons/` directory. The icon should be a square PNG without rounded corners and without borders. It must be at least 196x196 pixels but we prefer larger sizes such as 1024x1024. 3. Run `./GenerateIssuerIconAssets.sh` from the root of the repo. 4. Add an entry for the icon to [the `imageNames` dictionary](https://github.com/calleerlandsson/Tofu/blob/master/Tofu/AccountCell.swift#L15). The key should be the string that shows up in the account's Issuer field when scanning a QR code for the service. The value should be the name of the icon file. 5. Commit your changes and open a PR. Here's an example commit for adding a new icon: [692e32a](https://github.com/calleerlandsson/Tofu/commit/692e32a9744bcaa360e4d7db9f00c4e90f6f66ac) If you don't feel comfortable adding icons yourself, you can ask others to do so by [opening issues using the Issuer Icon Request template](https://github.com/calleerlandsson/Tofu/issues/new?labels=icon+request&template=issuer-icon-request.md&title=Add+an+icon+for+Example). ================================================ FILE: Tofu/AppDelegate.swift ================================================ import UIKit @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { return true } func application( _ application: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { let rootViewController = window!.rootViewController! guard let account = Account(url: url) else { let alert = UIAlertController( title: "Could Not Import Account", message: "The account information was not of the expected format.", preferredStyle: .alert) alert.addAction(UIAlertAction(title: "Close", style: .default)) rootViewController.present(alert, animated: true) return false } let accountsViewController = rootViewController.children.first as! AccountsViewController accountsViewController.createAccount(account) let alert = UIAlertController( title: "Account Imported", message: "Successfully imported \(account.description)", preferredStyle: .alert) alert.addAction(UIAlertAction(title: "OK", style: .default)) rootViewController.present(alert, animated: true) return true } } ================================================ FILE: Tofu/Assets.xcassets/17thShard.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "17thShard.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "17thShard@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "17thShard@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/AWS.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "AWS.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "AWS@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "AWS@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Adobe.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Adobe.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Adobe@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Adobe@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Allegro.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Allegro.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Allegro@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Allegro@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Amazon.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Amazon.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Amazon@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Amazon@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/AnonAddy.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "AnonAddy.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "AnonAddy@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "AnonAddy@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/AppIcon.appiconset/Contents.json ================================================ { "images" : [ { "idiom" : "iphone", "size" : "20x20", "scale" : "2x" }, { "idiom" : "iphone", "size" : "20x20", "scale" : "3x" }, { "size" : "29x29", "idiom" : "iphone", "filename" : "AppIcon29@2x-1.png", "scale" : "2x" }, { "size" : "29x29", "idiom" : "iphone", "filename" : "AppIcon29@3x.png", "scale" : "3x" }, { "size" : "40x40", "idiom" : "iphone", "filename" : "AppIcon40@2x-1.png", "scale" : "2x" }, { "size" : "40x40", "idiom" : "iphone", "filename" : "AppIcon40@3x.png", "scale" : "3x" }, { "size" : "60x60", "idiom" : "iphone", "filename" : "AppIcon60@2x.png", "scale" : "2x" }, { "size" : "60x60", "idiom" : "iphone", "filename" : "AppIcon60@3x.png", "scale" : "3x" }, { "idiom" : "ipad", "size" : "20x20", "scale" : "1x" }, { "idiom" : "ipad", "size" : "20x20", "scale" : "2x" }, { "size" : "29x29", "idiom" : "ipad", "filename" : "AppIcon29@1x.png", "scale" : "1x" }, { "size" : "29x29", "idiom" : "ipad", "filename" : "AppIcon29@2x.png", "scale" : "2x" }, { "size" : "40x40", "idiom" : "ipad", "filename" : "AppIcon40@1x.png", "scale" : "1x" }, { "size" : "40x40", "idiom" : "ipad", "filename" : "AppIcon40@2x.png", "scale" : "2x" }, { "size" : "76x76", "idiom" : "ipad", "filename" : "AppIcon76@1x.png", "scale" : "1x" }, { "size" : "76x76", "idiom" : "ipad", "filename" : "AppIcon76@2x.png", "scale" : "2x" }, { "size" : "83.5x83.5", "idiom" : "ipad", "filename" : "AppIcon83.5@2x.png", "scale" : "2x" }, { "size" : "1024x1024", "idiom" : "ios-marketing", "filename" : "AppIcon-iTC.png", "scale" : "1x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Atlassian.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Atlassian.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Atlassian@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Atlassian@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Backblaze.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Backblaze.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Backblaze@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Backblaze@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Basecamp.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Basecamp.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Basecamp@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Basecamp@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Binance.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Binance.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Binance@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Binance@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/BitBay.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "BitBay.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "BitBay@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "BitBay@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Bitbucket.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Bitbucket.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Bitbucket@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Bitbucket@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Bitstamp.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Bitstamp.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Bitstamp@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Bitstamp@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Bittrex.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Bittrex.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Bittrex@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Bittrex@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Bitwarden.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Bitwarden.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Bitwarden@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Bitwarden@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/CircularProgressViewBorderThick.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "scale" : "1x" }, { "idiom" : "universal", "filename" : "CircularProgressViewBorderThick@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "CircularProgressViewBorderThick@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" }, "properties" : { "template-rendering-intent" : "template" } } ================================================ FILE: Tofu/Assets.xcassets/CircularProgressViewBorderThin.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "scale" : "1x" }, { "idiom" : "universal", "filename" : "CircularProgressViewBorderThin@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "CircularProgressViewBorderThin@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" }, "properties" : { "template-rendering-intent" : "template" } } ================================================ FILE: Tofu/Assets.xcassets/Cloudflare.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Cloudflare.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Cloudflare@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Cloudflare@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Coinbase.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Coinbase.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Coinbase@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Coinbase@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Contentful.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Contentful.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Contentful@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Contentful@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Contents.json ================================================ { "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Tofu/Assets.xcassets/CorporateTrust.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "CorporateTrust.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "CorporateTrust@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "CorporateTrust@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/CyDIS.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "CyDIS.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "CyDIS@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "CyDIS@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/DNSimple.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "DNSimple.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "DNSimple@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "DNSimple@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/DigitalOcean.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "DigitalOcean.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "DigitalOcean@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "DigitalOcean@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Discord.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Discord.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Discord@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Discord@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Docker.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Docker.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Docker@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Docker@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Dropbox.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Dropbox.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Dropbox@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Dropbox@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/ElectronicArts.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "ElectronicArts.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "ElectronicArts@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "ElectronicArts@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/EpicGames.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "EpicGames.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "EpicGames@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "EpicGames@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Evernote.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Evernote.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Evernote@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Evernote@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Facebook.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Facebook.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Facebook@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Facebook@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/FastMail.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "FastMail.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "FastMail@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "FastMail@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Fidelity.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Fidelity.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Fidelity@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Fidelity@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Figma.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Figma.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Figma@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Figma@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Firefox.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Firefox.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Firefox@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Firefox@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Gandi.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Gandi.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Gandi@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Gandi@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/GitHub.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "GitHub.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "GitHub@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "GitHub@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/GitLab.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "GitLab.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "GitLab@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "GitLab@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Gitea.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Gitea.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Gitea@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Gitea@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/GoDaddy.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "GoDaddy.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "GoDaddy@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "GoDaddy@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Google.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Google.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Google@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Google@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/GreenAddress.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "GreenAddress.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "GreenAddress@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "GreenAddress@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/HEY.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "HEY.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "HEY@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "HEY@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/HackTheBox.imageset/Contents.json ================================================ { "images" : [ { "filename" : "hackthebox.png", "idiom" : "universal", "scale" : "1x" }, { "filename" : "hackthebox@2x.png", "idiom" : "universal", "scale" : "2x" }, { "filename" : "hackthebox@3x.png", "idiom" : "universal", "scale" : "3x" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Tofu/Assets.xcassets/Heroku.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Heroku.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Heroku@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Heroku@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Hetzner.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Hetzner.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Hetzner@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Hetzner@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/HomeAssistant.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "HomeAssistant.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "HomeAssistant@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "HomeAssistant@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Honeybadger.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Honeybadger.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Honeybadger@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Honeybadger@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Hostek.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Hostek.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Hostek@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Hostek@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Hover.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Hover.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Hover@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Hover@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/HumbleBundle.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "HumbleBundle.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "HumbleBundle@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "HumbleBundle@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/IDme.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "IDme.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "IDme@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "IDme@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/IFTTT.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "IFTTT.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "IFTTT@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "IFTTT@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Instagram.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Instagram.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Instagram@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Instagram@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Intercom.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Intercom.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Intercom@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Intercom@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/JetBrains.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "JetBrains.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "JetBrains@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "JetBrains@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Kickstarter.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Kickstarter.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Kickstarter@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Kickstarter@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/LastPass.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "LastPass.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "LastPass@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "LastPass@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/LinkedIn.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "LinkedIn.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "LinkedIn@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "LinkedIn@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Linode.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Linode.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Linode@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Linode@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Lobsters.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Lobsters.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Lobsters@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Lobsters@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/LocalBitcoins.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "LocalBitcoins.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "LocalBitcoins@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "LocalBitcoins@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Mailchimp.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Mailchimp.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Mailchimp@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Mailchimp@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Mastodon.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Mastodon.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Mastodon@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Mastodon@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Mega.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Mega.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Mega@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Mega@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Microsoft.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Microsoft.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Microsoft@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Microsoft@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Name.com.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Name.com.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Name.com@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Name.com@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Netlify.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Netlify.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Netlify@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Netlify@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Nextcloud.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Nextcloud.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Nextcloud@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Nextcloud@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/NexusMods.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "NexusMods.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "NexusMods@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "NexusMods@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/NiceHash.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "NiceHash.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "NiceHash@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "NiceHash@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Nintendo.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Nintendo.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Nintendo@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Nintendo@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Njalla.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Njalla.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Njalla@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Njalla@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Nodecraft.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Nodecraft.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Nodecraft@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Nodecraft@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/NordPass.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "NordPass.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "NordPass@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "NordPass@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/PaladinExtensions.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "PaladinExtensions.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "PaladinExtensions@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "PaladinExtensions@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Parler.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Parler.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Parler@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Parler@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/PayPal.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "PayPal.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "PayPal@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "PayPal@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/PhilipsHue.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "PhilipsHue.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "PhilipsHue@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "PhilipsHue@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Posteo.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Posteo.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Posteo@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Posteo@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Postmark.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Postmark.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Postmark@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Postmark@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Privacy.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Privacy.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Privacy@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Privacy@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/ProfitBricks.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "ProfitBricks.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "ProfitBricks@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "ProfitBricks@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/ProtonMail.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "ProtonMail.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "ProtonMail@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "ProtonMail@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Prusa.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Prusa.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Prusa@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Prusa@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/PrusaAccount.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "PrusaAccount.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "PrusaAccount@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "PrusaAccount@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Reddit.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Reddit.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Reddit@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Reddit@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Robinhood.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Robinhood.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Robinhood@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Robinhood@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/RubyGems.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "RubyGems.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "RubyGems@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "RubyGems@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/RuneScape.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "RuneScape.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "RuneScape@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "RuneScape@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/STACK.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "STACK.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "STACK@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "STACK@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/SimpleLogin.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "SimpleLogin.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "SimpleLogin@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "SimpleLogin@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Slack.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Slack.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Slack@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Slack@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Snapchat.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Snapchat.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Snapchat@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Snapchat@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Sony.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Sony.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Sony@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Sony@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Squarespace.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Squarespace.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Squarespace@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Squarespace@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/StandardNotes.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "StandardNotes.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "StandardNotes@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "StandardNotes@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Stripe.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Stripe.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Stripe@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Stripe@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Surfshark.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Surfshark.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Surfshark@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Surfshark@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/TETR.IO.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "TETR.IO.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "TETR.IO@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "TETR.IO@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Time4VPS.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Time4VPS.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Time4VPS@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Time4VPS@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/TorGuard.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "TorGuard.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "TorGuard@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "TorGuard@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Tresorit.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Tresorit.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Tresorit@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Tresorit@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Tumblr.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Tumblr.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Tumblr@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Tumblr@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/TurboTax.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "TurboTax.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "TurboTax@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "TurboTax@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Tutanota.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Tutanota.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Tutanota@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Tutanota@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Tweakers.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Tweakers.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Tweakers@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Tweakers@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Twilio.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Twilio.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Twilio@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Twilio@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Twitch.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Twitch.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Twitch@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Twitch@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Twitter.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Twitter.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Twitter@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Twitter@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Uber.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Uber.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Uber@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Uber@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Ubisoft.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Ubisoft.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Ubisoft@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Ubisoft@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Unity.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Unity.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Unity@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Unity@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/VKontakte.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "VKontakte.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "VKontakte@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "VKontakte@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Wallabag.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Wallabag.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Wallabag@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Wallabag@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/WordPress.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "WordPress.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "WordPress@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "WordPress@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/YNAB.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "YNAB.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "YNAB@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "YNAB@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/Zoom.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "Zoom.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "Zoom@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "Zoom@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Assets.xcassets/ownCloud.imageset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "filename" : "ownCloud.png", "scale" : "1x" }, { "idiom" : "universal", "filename" : "ownCloud@2x.png", "scale" : "2x" }, { "idiom" : "universal", "filename" : "ownCloud@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Tofu/Base.lproj/LaunchScreen.storyboard ================================================ ================================================ FILE: Tofu/Base.lproj/Main.storyboard ================================================ ================================================ FILE: Tofu/Controllers/AccountCreationViewController.swift ================================================ import UIKit private let formatter: NumberFormatter = { let formatter = NumberFormatter() formatter.numberStyle = .none return formatter }() class AccountCreationViewController: UITableViewController, AlgorithmSelectionDelegate { @IBOutlet weak var doneItem: UIBarButtonItem! @IBOutlet weak var nameField: UITextField! @IBOutlet weak var issuerField: UITextField! @IBOutlet weak var secretField: UITextField! @IBOutlet weak var algorithmLabel: UILabel! @IBOutlet weak var eightDigitsSwitch: UISwitch! @IBOutlet weak var timeBasedSwitch: UISwitch! @IBOutlet weak var periodCounterCell: UITableViewCell! @IBOutlet weak var periodCounterLabel: UILabel! @IBOutlet weak var periodCounterField: UITextField! var delegate: AccountCreationDelegate? private var algorithm = Algorithm.sha1 private var periodString: String? private var counterString: String? private var period: Int? { guard periodCounterField.text?.count ?? 0 > 0 else { return 30 } return formatter.number(from: periodCounterField.text!)?.intValue } private var counter: Int? { guard periodCounterField.text?.count ?? 0 > 0 else { return 0 } return formatter.number(from: periodCounterField.text!)?.intValue } @IBAction func didPressCancel(_ sender: UIBarButtonItem) { presentingViewController?.dismiss(animated: true, completion: nil) } @IBAction func didPressDone(_ sender: UIBarButtonItem) { let password = Password() password.timeBased = timeBasedSwitch.isOn password.algorithm = algorithm password.digits = eightDigitsSwitch.isOn ? 8 : 6 password.secret = Data(base32Encoded: secretField.text!)! if timeBasedSwitch.isOn { password.period = period! } else { password.counter = counter! } let account = Account() account.name = nameField.text account.issuer = issuerField.text account.password = password presentingViewController?.dismiss(animated: true) { self.delegate?.createAccount(account) } } @IBAction func editingChangedForTextField(_ textField: UITextField) { validate() } @IBAction func valueChangedForTimeBasedSwitch() { if self.timeBasedSwitch.isOn { counterString = periodCounterField.text } else { periodString = periodCounterField.text } UIView.transition(with: periodCounterCell, duration: 0.2, options: .transitionCrossDissolve, animations: { if self.timeBasedSwitch.isOn { self.periodCounterLabel.text = "Period" self.periodCounterField.placeholder = String(30) self.periodCounterField.text = self.periodString } else { self.periodCounterLabel.text = "Counter" self.periodCounterField.placeholder = String(0) self.periodCounterField.text = self.counterString } }, completion: { _ in self.validate() }) } override func viewDidLoad() { super.viewDidLoad() nameField.becomeFirstResponder() algorithmLabel.text = algorithm.name } override func prepare(for segue: UIStoryboardSegue, sender: Any?) { if let algorithmsController = segue.destination as? AlgorithmsViewController { algorithmsController.algorithms = [.sha1, .sha256, .sha512] algorithmsController.selected = algorithm algorithmsController.delegate = self } } private func validate() { doneItem.isEnabled = secretField.text?.count ?? 0 > 0 && Data(base32Encoded: secretField.text!) != nil && (timeBasedSwitch.isOn ? period != nil : counter != nil) } // MARK: AlgorithmSelectionDelegate func selectAlgorithm(_ algorithm: Algorithm) { self.algorithm = algorithm algorithmLabel.text = algorithm.name } } ================================================ FILE: Tofu/Controllers/AccountSearchResultsViewController.swift ================================================ import UIKit private let accountCellIdentifier = "AccountCell" class AccountSearchResultsViewController: UITableViewController, AccountUpdateDelegate { @IBOutlet var emptyView: UIView! var accounts: [Account]! { didSet { tableView.reloadData() tableView.backgroundView = accounts.count == 0 ? emptyView : nil tableView.separatorStyle = accounts.count == 0 ? .none : .singleLine } } override func viewDidLoad() { super.viewDidLoad() let updater = AccountsTableViewUpdater(tableView: tableView) updater.startUpdating() NotificationCenter.default.addObserver( self, selector: #selector(deselectSelectedTableViewRow), name: UIMenuController.willHideMenuNotification, object: nil) } @objc func deselectSelectedTableViewRow() { if let indexPath = tableView.indexPathForSelectedRow { tableView.deselectRow(at: indexPath, animated: true) } } // MARK: UITableViewDataSource override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { if let cell = tableView.cellForRow(at: indexPath) { guard let cellSuperview = cell.superview else { assertionFailure("The cell does not seem to be in the view hierarchy. How is that even possible!?") return } let menuController = UIMenuController.shared // If you tap the same cell twice, this condition prevents the menu from being // hidden and then instantly shown again causing an unpleasant flash. // // Since the cell could already be the first responder (from previously showing // its menu and then scrolling the table view) and the menu could already be // visible for another cell, we make sure to check both values. if !(cell.isFirstResponder && menuController.isMenuVisible) { cell.becomeFirstResponder() menuController.showMenu(from: cellSuperview, rect: cell.frame) } } } override func numberOfSections(in tableView: UITableView) -> Int { return 1 } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return accounts.count } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: accountCellIdentifier, for: indexPath) as! AccountCell cell.account = accounts[indexPath.row] cell.delegate = self return cell } // MARK: UITableViewDelegate override func tableView(_ tableView: UITableView, shouldShowMenuForRowAt indexPath: IndexPath) -> Bool { return true } override func tableView(_ tableView: UITableView, canPerformAction action: Selector, forRowAt indexPath: IndexPath, withSender sender: Any?) -> Bool { return action == #selector(copy(_:)) } override func tableView(_ tableView: UITableView, performAction action: Selector, forRowAt indexPath: IndexPath, withSender sender: Any?) { if action == #selector(copy(_:)) { let cell = tableView.cellForRow(at: indexPath) as! AccountCell cell.copy(self) } } // MARK: AccountUpdateDelegate func updateAccount(_ account: Account) { (presentingViewController as! AccountUpdateDelegate).updateAccount(account) let row = accounts.firstIndex { $0 === account }! let indexPath = IndexPath(row: row, section: 0) guard let cell = tableView.cellForRow(at: indexPath) as? AccountCell else { return } cell.updateWithDate(Date()) } } ================================================ FILE: Tofu/Controllers/AccountUpdateViewController.swift ================================================ import UIKit class AccountUpdateViewController: UITableViewController { @IBOutlet weak var nameField: UITextField! @IBOutlet weak var issuerField: UITextField! var delegate: AccountUpdateDelegate? var account: Account! override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) nameField.text = account.name issuerField.text = account.issuer } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) account.name = nameField.text account.issuer = issuerField.text delegate?.updateAccount(account) } } ================================================ FILE: Tofu/Controllers/AccountsTableViewUpdater.swift ================================================ import UIKit class AccountsTableViewUpdater: NSObject { var tableView: UITableView init(tableView: UITableView) { self.tableView = tableView } func startUpdating() { let timer = Timer(timeInterval: 1, target: self, selector: #selector(updateCells), userInfo: nil, repeats: true) RunLoop.main.add(timer, forMode: RunLoop.Mode.common) } @objc func updateCells() { let now = Date() for cell in tableView.visibleCells { let accountCell = cell as! AccountCell accountCell.updateWithDate(now) } } } ================================================ FILE: Tofu/Controllers/AccountsViewController.swift ================================================ import UIKit private let accountOrderKey = "persistentRefs" class AccountsViewController: UITableViewController { @IBOutlet weak var emptyView: UIView! private let keychain = Keychain() private var accounts = [Account]() private lazy var searchController = makeSearchController() private lazy var addAccountAlertController = makeAddAccountAlertController() override func viewDidLoad() { super.viewDidLoad() accounts = keychain.accounts let sortedPersistentRefs = UserDefaults.standard.array(forKey: accountOrderKey) as? [Data] ?? [] accounts.sort { a, b in let aIndex = sortedPersistentRefs.firstIndex(of: a.persistentRef! as Data) ?? 0 let bIndex = sortedPersistentRefs.firstIndex(of: b.persistentRef! as Data) ?? 0 return aIndex < bIndex } persistAccountOrder() navigationItem.searchController = searchController let updater = AccountsTableViewUpdater(tableView: tableView) updater.startUpdating() updateEditing() NotificationCenter.default.addObserver( self, selector: #selector(deselectSelectedTableViewRow), name: UIMenuController.willHideMenuNotification, object: nil) } @objc func deselectSelectedTableViewRow() { if let indexPath = tableView.indexPathForSelectedRow { tableView.deselectRow(at: indexPath, animated: true) } } @IBAction func addAccount(_ sender: Any) { present(addAccountAlertController, animated: true, completion: nil) } override func prepare(for segue: UIStoryboardSegue, sender: Any?) { if let navigationController = segue.destination as? UINavigationController { if let accountCreationViewController = navigationController.topViewController as? AccountCreationViewController { accountCreationViewController.delegate = self } else { let scanningViewController = navigationController.topViewController as! ScanningViewController scanningViewController.delegate = self } } else { let accountUpdateViewController = segue.destination as! AccountUpdateViewController let cell = sender as! AccountCell accountUpdateViewController.delegate = self accountUpdateViewController.account = cell.account } } private func makeSearchController() -> UISearchController { let searchResultsController = storyboard!.instantiateViewController(withIdentifier: "AccountSearchResultsViewController") as! AccountSearchResultsViewController let searchController = UISearchController(searchResultsController: searchResultsController) searchController.searchResultsUpdater = self return searchController } private func makeAddAccountAlertController() -> UIAlertController { let title = "Add Account" let message = "Add an account by scanning a QR code, importing a QR image, or entering a secret manually." let alertController = UIAlertController(title: title, message: message, preferredStyle: .actionSheet) let scanQRCode = UIAlertAction(title: "Scan QR Code", style: .default) { [unowned self] _ in self.performSegue(withIdentifier: "ScanSegue", sender: self) } let importQRCode = UIAlertAction(title: "Import QR Image", style: .default) { [unowned self] _ in if UIImagePickerController.isSourceTypeAvailable(.photoLibrary) { let imagePickerController = UIImagePickerController() imagePickerController.delegate = self imagePickerController.allowsEditing = false imagePickerController.sourceType = .photoLibrary self.present(imagePickerController, animated: true, completion: nil) } else { presentErrorAlert(title: "Photo Library Empty", message: "The photo library is empty and there are no images to import.") } } let enterManually = UIAlertAction(title: "Enter Manually", style: .default) { [unowned self] _ in self.performSegue(withIdentifier: "EnterManuallySegue", sender: self) } let cancel = UIAlertAction(title: "Cancel", style: .cancel, handler: nil) alertController.addAction(scanQRCode) alertController.addAction(importQRCode) alertController.addAction(enterManually) alertController.addAction(cancel) return alertController } private func persistAccountOrder() { let sortedPersistentRefs = accounts.map { $0.persistentRef! } UserDefaults.standard.set(sortedPersistentRefs, forKey: accountOrderKey) } private func updateEditing() { if accounts.count == 0 { tableView.backgroundView = emptyView tableView.separatorStyle = .none navigationItem.leftBarButtonItem = nil setEditing(false, animated: true) } else { tableView.backgroundView = nil tableView.separatorStyle = .singleLine navigationItem.leftBarButtonItem = editButtonItem } } // MARK: UITableViewDataSource override func tableView(_ tableView: UITableView, canMoveRowAt indexPath: IndexPath) -> Bool { return true } override func numberOfSections(in tableView: UITableView) -> Int { return 1 } override func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) { accounts.insert(accounts.remove(at: sourceIndexPath.row), at: destinationIndexPath.row) persistAccountOrder() } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return accounts.count } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "AccountCell", for: indexPath) as! AccountCell cell.account = accounts[indexPath.row] cell.delegate = self return cell } override func tableView( _ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) { if editingStyle == .delete { let alertController = UIAlertController( title: "Deleting This Account Will Not Turn Off Two-Factor Authentication", message: "Please make sure two-factor authentication is turned off in the issuer's sett" + "ings before deleting this account to prevent being locked out.", preferredStyle: .actionSheet) let deleteAccountAction = UIAlertAction(title: "Delete Account", style: .destructive) { _ in self.deleteAccountForRowAtIndexPath(indexPath) } let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil) alertController.addAction(deleteAccountAction) alertController.addAction(cancelAction) present(alertController, animated: true, completion: nil) } } private func deleteAccountForRowAtIndexPath(_ indexPath: IndexPath) { let account = self.accounts[indexPath.row] guard self.keychain.deleteAccount(account) else { presentTryAgainAlertWithTitle( "Could Not Delete Account", message: "An error occurred when deleting the account from the keychain.") { self.deleteAccountForRowAtIndexPath(indexPath) } return } accounts.remove(at: indexPath.row) persistAccountOrder() tableView.deleteRows(at: [indexPath], with: .automatic) updateEditing() } // MARK: UITableViewDelegate override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { if tableView.isEditing { tableView.deselectRow(at: indexPath, animated: true) if let cell = tableView.cellForRow(at: indexPath) as? AccountCell { performSegue(withIdentifier: "EditAccountSegue", sender: cell) } } else { // Not editing if let cell = tableView.cellForRow(at: indexPath) { guard let cellSuperview = cell.superview else { assertionFailure("The cell does not seem to be in the view hierarchy. How is that even possible!?") return } let menuController = UIMenuController.shared // If you tap the same cell twice, this condition prevents the menu from being // hidden and then instantly shown again causing an unpleasant flash. // // Since the cell could already be the first responder (from previously showing // its menu and then scrolling the table view) and the menu could already be // visible for another cell, we make sure to check both values. if !(cell.isFirstResponder && menuController.isMenuVisible) { cell.becomeFirstResponder() menuController.showMenu(from: cellSuperview, rect: cell.frame) } } } } override func tableView(_ tableView: UITableView, shouldShowMenuForRowAt indexPath: IndexPath) -> Bool { return true } override func tableView(_ tableView: UITableView, canPerformAction action: Selector, forRowAt indexPath: IndexPath, withSender sender: Any?) -> Bool { return action == #selector(copy(_:)) } override func tableView(_ tableView: UITableView, performAction action: Selector, forRowAt indexPath: IndexPath, withSender sender: Any?) { if action == #selector(copy(_:)) { let cell = tableView.cellForRow(at: indexPath) as! AccountCell cell.copy(self) } } } extension AccountsViewController: UISearchResultsUpdating { func updateSearchResults(for searchController: UISearchController) { let accountSearchResultsViewController = searchController.searchResultsController as! AccountSearchResultsViewController accountSearchResultsViewController.accounts = accounts.filter { guard let string = searchController.searchBar.text else { return false } return $0.description.range(of: string, options: .caseInsensitive, range: nil, locale: nil) != nil } } } extension AccountsViewController: UIImagePickerControllerDelegate, UINavigationControllerDelegate { func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) { dismiss(animated: true, completion: nil) guard let selectedQRCode = info[UIImagePickerController.InfoKey.originalImage] as? UIImage, let detector = CIDetector(ofType: CIDetectorTypeQRCode, context: nil, options: [CIDetectorAccuracy: CIDetectorAccuracyHigh]), let ciImage = CIImage(image: selectedQRCode), let features = detector.features(in: ciImage) as? [CIQRCodeFeature], let messageString = features.first?.messageString else { presentErrorAlert(title: "Could Not Detect QR Code", message: "No QR code was detected in the provided image. Please try importing a different image.") return } guard let qrCodeURL = URL(string: messageString), let account = Account(url: qrCodeURL) else { presentErrorAlert(title: "Invalid QR Code", message: "The QR code detected in the provided image is invalid. Please try a different image.") return } self.createAccount(account) } } extension AccountsViewController: AccountCreationDelegate { func createAccount(_ account: Account) { guard keychain.insertAccount(account) else { presentTryAgainAlertWithTitle( "Could Not Create Account", message: "An error occurred when inserting the account into the keychain.") { self.createAccount(account) } return } accounts.append(account) persistAccountOrder() let lastRow = accounts.count - 1 let indexPaths = [IndexPath(row: lastRow, section: 0)] tableView.insertRows(at: indexPaths, with: .automatic) updateEditing() } } extension AccountsViewController: AccountUpdateDelegate { func updateAccount(_ account: Account) { guard keychain.updateAccount(account) else { presentTryAgainAlertWithTitle( "Could Not Update Account", message: "An error occurred when persisting the account updates to the keychain.") { self.updateAccount(account) } return } let row = accounts.firstIndex { $0 === account }! let indexPath = IndexPath(row: row, section: 0) guard let cell = tableView.cellForRow(at: indexPath) as? AccountCell else { return } cell.updateWithDate(Date()) } private func presentTryAgainAlertWithTitle(_ title: String, message: String, handler: @escaping () -> Void) { let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert) let tryAgainAccountAction = UIAlertAction(title: "Try again", style: .default) { _ in handler() } let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil) alertController.addAction(tryAgainAccountAction) alertController.addAction(cancelAction) present(alertController, animated: true, completion: nil) } } ================================================ FILE: Tofu/Controllers/AlgorithmsViewController.swift ================================================ import UIKit private let algorithmCellIdentifier = "AlgorithmCell" class AlgorithmsViewController: UITableViewController { var algorithms = [Algorithm]() var selected: Algorithm! var delegate: AlgorithmSelectionDelegate? // MARK: UITableViewDataSource override func numberOfSections(in tableView: UITableView) -> Int { return 1 } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return algorithms.count } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: algorithmCellIdentifier, for: indexPath) let algorithm = algorithms[indexPath.row] cell.textLabel?.text = algorithm.name cell.accessoryType = selected == algorithm ? .checkmark : .none return cell } // MARK: UITableViewDelegate override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) let previouslySelectedCell = tableView.cellForRow( at: IndexPath(row: algorithms.firstIndex(of: selected)!, section: 0))! previouslySelectedCell.accessoryType = .none let selectedCell = tableView.cellForRow(at: indexPath)! selectedCell.accessoryType = .checkmark selected = algorithms[indexPath.row] delegate?.selectAlgorithm(selected) } } ================================================ FILE: Tofu/Controllers/ScanningViewController.swift ================================================ import UIKit import AVFoundation class ScanningViewController: UIViewController, AVCaptureMetadataOutputObjectsDelegate { @IBOutlet weak var allowCameraAccessView: UIView! var delegate: AccountCreationDelegate? private var session = AVCaptureSession() private let output = AVCaptureMetadataOutput() private var layer: AVCaptureVideoPreviewLayer? @IBAction func didPressCancel(_ sender: UIBarButtonItem) { output.setMetadataObjectsDelegate(nil, queue: nil) presentingViewController?.dismiss(animated: true, completion: nil) } override func viewDidLoad() { super.viewDidLoad() if AVCaptureDevice.authorizationStatus(for: .video) == .authorized { startScanning() } else { AVCaptureDevice.requestAccess(for: .video) { granted in guard granted else { return } DispatchQueue.main.async { self.startScanning() } } } } override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() updateLayerFrameAndOrientation() } private func startScanning() { if let device = AVCaptureDevice.default(for: .video), let input = try? AVCaptureDeviceInput(device: device) { allowCameraAccessView.isHidden = true navigationItem.prompt = "Point your camera at a QR code to scan it." session.addInput(input) session.addOutput(output) output.setMetadataObjectsDelegate(self, queue: DispatchQueue.main) output.metadataObjectTypes = [.qr] layer = AVCaptureVideoPreviewLayer(session: session) layer!.videoGravity = .resizeAspectFill view.layer.addSublayer(layer!) updateLayerFrameAndOrientation() session.startRunning() } } private func updateLayerFrameAndOrientation() { layer?.frame = view.layer.bounds switch UIDevice.current.orientation { case .landscapeLeft: layer?.connection?.videoOrientation = .landscapeRight case .landscapeRight: layer?.connection?.videoOrientation = .landscapeLeft default: layer?.connection?.videoOrientation = .portrait } } // MARK: AVCaptureMetadataOutputObjectsDelegate func metadataOutput( _ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) { guard presentedViewController == nil, // Not presenting an error alert metadataObjects.count > 0, let metadataObject = metadataObjects.first as? AVMetadataMachineReadableCodeObject, metadataObject.type == .qr, let urlString = metadataObject.stringValue else { return } guard let url = URL(string: urlString), let account = Account(url: url) else { presentErrorAlert(title: "Invalid QR Code", message: "The detected QR code is invalid. Please try scanning a different code.") return } output.setMetadataObjectsDelegate(nil, queue: nil) delegate?.createAccount(account) presentingViewController?.dismiss(animated: true, completion: nil) } } ================================================ FILE: Tofu/Extensions/Data.swift ================================================ import Foundation private enum DecodedByte { case valid(UInt8) case invalid case padding } private let padding: UInt8 = 61 // = private let byteMappings: [CountableRange] = [ 65 ..< 91, // A-Z 50 ..< 56, // 2-7 ] private func decode(byte encodedByte: UInt8) -> DecodedByte { if encodedByte == padding { return .padding } var decodedStart: UInt8 = 0 for range in byteMappings { if range.contains(encodedByte) { let result = decodedStart + (encodedByte - range.lowerBound) return .valid(result) } decodedStart += range.upperBound - range.lowerBound } return .invalid } private func decoded(bytes encodedBytes: [UInt8]) -> [UInt8]? { var decodedBytes = [UInt8]() decodedBytes.reserveCapacity(encodedBytes.count / 8 * 5) var decodedByte: UInt8 = 0 var characterCount = 0 var paddingCount = 0 var index = 0 for encodedByte in encodedBytes { let value: UInt8 switch decode(byte: encodedByte) { case .valid(let v): value = v characterCount += 1 case .invalid: return nil case .padding: paddingCount += 1 continue } // Only allow padding at the end of the sequence if paddingCount > 0 { return nil } switch index % 8 { case 0: decodedByte = value << 3 case 1: decodedByte |= value >> 2 decodedBytes.append(decodedByte) decodedByte = value << 6 case 2: decodedByte |= value << 1 case 3: decodedByte |= value >> 4 decodedBytes.append(decodedByte) decodedByte = value << 4 case 4: decodedByte |= value >> 1 decodedBytes.append(decodedByte) decodedByte = value << 7 case 5: decodedByte |= value << 2 case 6: decodedByte |= value >> 3 decodedBytes.append(decodedByte) decodedByte = value << 5 case 7: decodedByte |= value decodedBytes.append(decodedByte) default: fatalError() } index += 1 } let characterCountIsValid = [0, 2, 4, 5, 7].contains(characterCount % 8) let paddingCountIsValid = paddingCount == 0 || (characterCount + paddingCount) % 8 == 0 guard characterCountIsValid && paddingCountIsValid else { return nil } return decodedBytes } extension Data { init?(base32Encoded string: String) { let encodedBytes = Array(string.uppercased().utf8) guard let decodedBytes = decoded(bytes: encodedBytes) else { return nil } self.init(decodedBytes) } /// Read a given type from a Data's buffer. /// /// Very much like UnsafeRawBufferPointer's load(fromByteOffset:as:), but doesn't barf if the value /// isn't at an aligned position. /// /// - Parameters: /// - offset: The offset to load the value from. /// - type: The type of the value. /// - Returns: The value loaded from the data. func alignmentSafeLoad(fromByteOffset offset: Int = 0, as type: T.Type) throws -> T { guard count >= (offset + MemoryLayout.size) else { throw NSError(domain: "com.calleerlandsson.Tofu", code: -1) } let chunk = subdata(in: offset..<(offset + MemoryLayout.size)) return chunk.withUnsafeBytes({ (bytePointer: UnsafeRawBufferPointer) -> T in return bytePointer.load(as: T.self) }) } } ================================================ FILE: Tofu/Extensions/UIViewController.swift ================================================ import UIKit extension UIViewController { func presentErrorAlert(title: String, message: String) { let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert) alertController.addAction(UIAlertAction(title: "OK", style: .default, handler: nil)) self.present(alertController, animated: true, completion: nil) } } ================================================ FILE: Tofu/Info.plist ================================================ CFBundleDevelopmentRegion en CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleInfoDictionaryVersion 6.0 CFBundleName $(PRODUCT_NAME) CFBundlePackageType APPL CFBundleShortVersionString $(MARKETING_VERSION) CFBundleSignature ???? CFBundleURLTypes CFBundleTypeRole Viewer CFBundleURLName com.calleerlandsson.Tofu.otpauth CFBundleURLSchemes otpauth CFBundleVersion TOFU_BUNDLE_VERSION ITSAppUsesNonExemptEncryption LSRequiresIPhoneOS NSCameraUsageDescription Used to scan QR codes UILaunchStoryboardName LaunchScreen UIMainStoryboardFile Main UIRequiredDeviceCapabilities armv7 UISupportedInterfaceOrientations UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UISupportedInterfaceOrientations~ipad UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight ================================================ FILE: Tofu/Models/Account.swift ================================================ import Foundation @objc(Account) class Account: NSObject, NSSecureCoding { static var supportsSecureCoding: Bool { return true } /// This is a "pointer" to the account in the Keychain, and is set upon encode to/decode from such. It's not /// included in serialisation or equality checks, since it's not required for exporting to/importing from from /// elsewhere, and isn't useful for duplicate checking etc. var persistentRef: Data? var name: String? var issuer: String? var password = Password() override init() {} init?(url: URL) { let label = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) guard let host = url.host, host == "hotp" || host == "totp" else { return nil } let labelComponents = label.components(separatedBy: ":") guard labelComponents.count > 0, let components = URLComponents(url: url, resolvingAgainstBaseURL: false), let queryItems = components.queryItems, queryItems.count > 0 else { return nil } name = labelComponents.last?.trimmingCharacters(in: CharacterSet.whitespaces) issuer = labelComponents.count > 1 ? labelComponents.first : nil password.timeBased = host == "totp" for queryItem in queryItems { switch queryItem.name { case "secret": guard let secretString = queryItem.value, let secret = Data(base32Encoded: secretString) else { break } password.secret = secret case "algorithm": switch queryItem.value { case .some("SHA256"): password.algorithm = .sha256 case .some("SHA512"): password.algorithm = .sha512 default: break } case "digits": guard let string = queryItem.value, let digits = Int(string) else { break } if digits < 6 || digits > 9 { return nil } password.digits = digits case "issuer": issuer = queryItem.value case "counter": guard let string = queryItem.value, let counter = Int(string) else { break } password.counter = counter case "period": guard let string = queryItem.value, let period = Int(string) else { break } if period < 1 { return nil } password.period = period default: break } } if password.secret.count == 0 { return nil } } required init?(coder: NSCoder) { guard let secret = coder.decodeObject(of: NSData.self, forKey: "secret") as? Data else { return nil } guard coder.containsValue(forKey: "algorithm") else { return nil } guard let algorithm = Algorithm(rawValue: coder.decodeInt32(forKey: "algorithm")) else { return nil } password.algorithm = algorithm password.secret = secret password.digits = Int(coder.decodeInt32(forKey: "digits")) password.timeBased = coder.decodeBool(forKey: "timeBased") password.counter = Int(coder.decodeInt32(forKey: "counter")) password.period = Int(coder.decodeInt32(forKey: "period")) name = coder.decodeObject(of: NSString.self, forKey: "name") as? String issuer = coder.decodeObject(of: NSString.self, forKey: "issuer") as? String } func encode(with coder: NSCoder) { coder.encode(password.timeBased, forKey: "timeBased") coder.encode(password.algorithm.rawValue, forKey: "algorithm") coder.encode(Int32(password.digits), forKey: "digits") coder.encode(password.secret, forKey: "secret") coder.encode(Int32(password.counter), forKey: "counter") coder.encode(Int32(password.period), forKey: "period") coder.encode(name, forKey: "name") coder.encode(issuer, forKey: "issuer") } override func isEqual(_ object: Any?) -> Bool { guard let other = object as? Account else { return false } return name == other.name && issuer == other.issuer && password == other.password } override var hash: Int { var hasher = Hasher() hasher.combine(name) hasher.combine(issuer) hasher.combine(password) return hasher.finalize() } override var description: String { guard let issuer = issuer, issuer.count > 0 else { return name ?? "" } guard let name = name, name.count > 0 else { return issuer } return "\(issuer) (\(name))" } } ================================================ FILE: Tofu/Models/Algorithm.swift ================================================ import Foundation import CommonCrypto enum Algorithm: Int32 { case sha1 = 0 case sha256 = 1 case sha512 = 2 var name: String { switch self { case .sha1: return "SHA1" case .sha256: return "SHA256" case .sha512: return "SHA512" } } var digestLength: Int { switch self { case .sha1: return Int(CC_SHA1_DIGEST_LENGTH) case .sha256: return Int(CC_SHA256_DIGEST_LENGTH) case .sha512: return Int(CC_SHA512_DIGEST_LENGTH) } } var hmacAlgorithm: CCHmacAlgorithm { switch self { case .sha1: return CCHmacAlgorithm(kCCHmacAlgSHA1) case .sha256: return CCHmacAlgorithm(kCCHmacAlgSHA256) case .sha512: return CCHmacAlgorithm(kCCHmacAlgSHA512) } } } ================================================ FILE: Tofu/Models/ExternalDataInterop.swift ================================================ import Foundation import CryptoKit import CommonCrypto class ExternalDataInterop { enum ExternalDataInteropError: Error { case invalidPasscode case invalidData case encryptionFailed } /// Encrypt the given accounts with the given passcode. A random salt will be generated. func encryptedData(for accounts: [Account], with passcode: String) throws -> Data { let encodedAccounts = try NSKeyedArchiver.archivedData(withRootObject: accounts, requiringSecureCoding: true) let container = try EncryptedAccountContainer(encrypting: encodedAccounts, with: .aesGCMWithSalted256BitSHAPBKDF2DerivedKey, password: passcode) return try NSKeyedArchiver.archivedData(withRootObject: container, requiringSecureCoding: true) } /// Attempt to decrypt the given accounts with using the given passcode. func decryptAccounts(from encryptedAccountData: Data, with passcode: String) throws -> [Account] { guard let decodedContainer = try NSKeyedUnarchiver.unarchivedObject(ofClass: EncryptedAccountContainer.self, from: encryptedAccountData) else { throw ExternalDataInteropError.invalidData } let decryptedData = try decodedContainer.decryptedData(with: passcode) guard let accounts: [Account] = try { let unarchiver = try NSKeyedUnarchiver(forReadingFrom: decryptedData) unarchiver.requiresSecureCoding = true return unarchiver.decodeObject(of: [NSArray.self, Account.self], forKey: NSKeyedArchiveRootObjectKey) as? [Account] }() else { throw ExternalDataInteropError.invalidData } return accounts } /// This class encapsulates the encryption/decryption implementation details. @objc(EncryptedAccountContainer) private class EncryptedAccountContainer: NSObject, NSSecureCoding { static let supportsSecureCoding: Bool = true enum Algorithm: Int { // AES-GCM with a 256-bit encryption key derived with PBKDF2 with the SHA256 pseudo-random algorithm. case aesGCMWithSalted256BitSHAPBKDF2DerivedKey = 0 } /// Initialise a container with the given unencrypted data. The data will be encrypted with the given algorithm /// using sensible defaults for it. If a salt is needed, it'll be randomly generated. init(encrypting unEncryptedData: Data, with algorithm: Algorithm, password: String) throws { // 600k figure from https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#pbkdf2 self.rounds = 600_000 self.algorithm = algorithm let (u1,u2,u3,u4,u5,u6,u7,u8,u9,u10,u11,u12,u13,u14,u15,u16) = UUID().uuid let uuidArray = [u1,u2,u3,u4,u5,u6,u7,u8,u9,u10,u11,u12,u13,u14,u15,u16] self.salt = Data(uuidArray) super.init() let key = try encryptionKey(from: password, salt: salt, rounds: rounds) self.encryptedData = try AES.GCM.seal(unEncryptedData, using: key).combined! } required init?(coder: NSCoder) { let rounds = coder.decodeInteger(forKey: "rounds") guard rounds > 0, let salt = coder.decodeObject(of: NSData.self, forKey: "salt") as? Data, let algorithm = Algorithm(rawValue: coder.decodeInteger(forKey: "algorithm")), let encryptedData = coder.decodeObject(of: NSData.self, forKey: "payload") as? Data else { return nil } self.salt = salt self.rounds = rounds self.algorithm = algorithm self.encryptedData = encryptedData } let salt: Data let rounds: Int let algorithm: Algorithm private(set) var encryptedData: Data = Data() func encode(with coder: NSCoder) { coder.encode(salt, forKey: "salt") coder.encode(rounds, forKey: "rounds") coder.encode(algorithm.rawValue, forKey: "algorithm") coder.encode(encryptedData, forKey: "payload") } func decryptedData(with password: String) throws -> Data { let key = try encryptionKey(from: password, salt: salt, rounds: rounds) let sealedBox = try AES.GCM.SealedBox(combined: encryptedData) return try AES.GCM.open(sealedBox, using: key) } /// Generate an encryption/decryption key for the given passcode. private func encryptionKey(from passcode: String, salt: Data, rounds: Int) throws -> SymmetricKey { guard !passcode.isEmpty else { throw ExternalDataInteropError.invalidPasscode } return SymmetricKey(data: try derivedPBKDF2Key(from: passcode, salt: salt, keySize: .bits256, rounds: rounds)) } /// Generate a PBKDF2 derived key from the given password, salt, and rounds. private func derivedPBKDF2Key(from password: String, salt saltData: Data, keySize: SymmetricKeySize, rounds: Int) throws -> Data { // To perform PBKDF2 key derivation, we need to use CommonCrypto, which isn't very Swift-y. let passwordData = Data(password.utf8) let saltLength = saltData.count let derivedKeyByteLength = keySize.bitCount / 8 var derivedKeyData = Data(repeating: 0, count: derivedKeyByteLength) let derivationStatus: Int32 = derivedKeyData.withUnsafeMutableBytes { derivedKeyBytes in saltData.withUnsafeBytes { saltBytes in let keyBuffer: UnsafeMutablePointer = derivedKeyBytes.baseAddress!.assumingMemoryBound(to: UInt8.self) let saltBuffer: UnsafePointer = saltBytes.baseAddress!.assumingMemoryBound(to: UInt8.self) return CCKeyDerivationPBKDF(CCPBKDFAlgorithm(kCCPBKDF2), password, passwordData.count, saltBuffer, saltLength, CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256), UInt32(rounds), keyBuffer, derivedKeyByteLength) } } guard derivationStatus == kCCSuccess else { throw ExternalDataInteropError.encryptionFailed } return derivedKeyData } } } ================================================ FILE: Tofu/Models/Keychain.swift ================================================ import Foundation private enum KeychainEncodingVersion: UInt8 { case version1 = 1 case version2 = 2 } private func archivedDataForKeychainWithAccount(_ account: Account) throws -> Data { let data = try NSKeyedArchiver.archivedData(withRootObject: account, requiringSecureCoding: true) let version: UInt8 = KeychainEncodingVersion.version2.rawValue var versionedData = Data([version]) versionedData.append(data) return versionedData } private func unarchiveAccountWithData(_ data: Data) -> Account? { guard !data.isEmpty else { return nil } guard let version = KeychainEncodingVersion(rawValue: data.first!) else { return nil } let encodedData = data.subdata(in: 1.. Account? { // This is here to decode accounts saved with Tofu >= 1.11. Since then, we've moved to a // slightly different encoding method (Accounts now conform to NSSecureCoding directly). guard let coder = try? NSKeyedUnarchiver(forReadingFrom: data), let secret = coder.decodeObject(of: NSData.self, forKey: "secret") as? Data, coder.containsValue(forKey: "algorithm"), let algorithm = Algorithm(rawValue: coder.decodeInt32(forKey: "algorithm")) else { return nil } let password = Password() password.algorithm = algorithm password.secret = secret password.digits = Int(coder.decodeInt32(forKey: "digits")) password.timeBased = coder.decodeBool(forKey: "timeBased") password.counter = Int(coder.decodeInt32(forKey: "counter")) password.period = Int(coder.decodeInt32(forKey: "period")) let account = Account() account.name = coder.decodeObject(of: NSString.self, forKey: "name") as? String account.issuer = coder.decodeObject(of: NSString.self, forKey: "issuer") as? String account.password = password return account } private func accountWithPersistentRef(_ persistentRef: Data) -> Account? { let query: [NSString: AnyObject] = [ kSecClass: kSecClassGenericPassword, kSecValuePersistentRef: persistentRef as AnyObject, kSecReturnData: kCFBooleanTrue, ] var result: AnyObject? let code = SecItemCopyMatching(query as CFDictionary, &result) guard code == errSecSuccess, let data = result as? Data, let account = unarchiveAccountWithData(data) else { return nil } account.persistentRef = persistentRef return account } class Keychain { var accounts: [Account] { let query: [NSString: AnyObject] = [ kSecClass: kSecClassGenericPassword, kSecReturnPersistentRef: kCFBooleanTrue, kSecMatchLimit: kSecMatchLimitAll, ] var result: AnyObject? let code = SecItemCopyMatching(query as CFDictionary, &result) guard code == errSecSuccess, let persistentRefs = result as? [Data] else { return [] } return persistentRefs.compactMap { accountWithPersistentRef($0) } } func insertAccount(_ account: Account) -> Bool { guard let accountData = try? archivedDataForKeychainWithAccount(account) else { return false } let query: [NSString: AnyObject] = [ kSecClass: kSecClassGenericPassword, kSecAttrAccount: ProcessInfo().globallyUniqueString as AnyObject, kSecAttrDescription: account.description as AnyObject, kSecValueData: accountData as AnyObject, kSecAttrAccessible: kSecAttrAccessibleWhenUnlocked, kSecReturnPersistentRef: true as AnyObject, ] var result: AnyObject? guard SecItemAdd(query as CFDictionary, &result) == errSecSuccess else { return false } account.persistentRef = (result as! Data) return true } func updateAccount(_ account: Account) -> Bool { guard let accountData = try? archivedDataForKeychainWithAccount(account) else { return false } let query: [NSString: Any] = [ kSecClass: kSecClassGenericPassword, kSecValuePersistentRef: account.persistentRef! ] let attributes: [NSString: AnyObject] = [ kSecAttrDescription: account.description as AnyObject, kSecValueData: accountData as AnyObject, kSecAttrAccessible: kSecAttrAccessibleWhenUnlocked, ] return SecItemUpdate(query as CFDictionary, attributes as CFDictionary) == errSecSuccess } func deleteAccount(_ account: Account) -> Bool { let query: [NSString: Any] = [ kSecClass: kSecClassGenericPassword, kSecValuePersistentRef: account.persistentRef! ] return SecItemDelete(query as CFDictionary) == errSecSuccess } } ================================================ FILE: Tofu/Models/Password.swift ================================================ import Foundation import CommonCrypto class Password: Equatable, Hashable { static func == (lhs: Password, rhs: Password) -> Bool { return lhs.algorithm == rhs.algorithm && lhs.secret == rhs.secret && lhs.digits == rhs.digits && lhs.period == rhs.period && lhs.counter == rhs.counter && lhs.timeBased == rhs.timeBased } func hash(into hasher: inout Hasher) { hasher.combine(algorithm) hasher.combine(counter) hasher.combine(digits) hasher.combine(secret) hasher.combine(period) hasher.combine(timeBased) } var algorithm: Algorithm = .sha1 var counter = 0 private var _digits = 6 var digits: Int { get { return _digits } set { if newValue < 6 { assertionFailure("digits must be >= 6") _digits = 6 } else if newValue > 9 { assertionFailure("digits must be <= 9") _digits = 9 } else { _digits = newValue } } } private var _period = 30 var period: Int { get { return _period } set { if newValue < 1 { assertionFailure("period must be > 1") _period = 30 } else { _period = newValue } } } var secret = Data() var timeBased = false func valueForDate(_ date: Date) -> String { let counter = timeBased ? UInt64(date.timeIntervalSince1970) / UInt64(period) : UInt64(self.counter) var input = counter.bigEndian let digest = UnsafeMutablePointer.allocate(capacity: algorithm.digestLength) defer { digest.deallocate() } secret.withUnsafeBytes { secretBytes in CCHmac(algorithm.hmacAlgorithm, secretBytes.baseAddress, secret.count, &input, MemoryLayout.size(ofValue: input), digest) } let offset = digest[algorithm.digestLength - 1] & 0x0f let digestData = Data(bytes: digest, count: algorithm.digestLength) let bigEndianNumber = try! digestData.alignmentSafeLoad(fromByteOffset: Int(offset), as: UInt32.self) let number = UInt32(bigEndian: bigEndianNumber) & 0x7fffffff return String(format: "%0\(digits)d", number % UInt32(pow(10, Float(digits)))) } func progressForDate(_ date: Date) -> Double { return timeIntervalRemainingForDate(date) / Double(period) } func timeIntervalRemainingForDate(_ date: Date) -> Double { let period = Double(self.period) return period - (date.timeIntervalSince1970.truncatingRemainder(dividingBy: period)) } } ================================================ FILE: Tofu/Protocols/AccountCreationDelegate.swift ================================================ protocol AccountCreationDelegate: AnyObject { func createAccount(_ account: Account) } ================================================ FILE: Tofu/Protocols/AccountUpdateDelegate.swift ================================================ protocol AccountUpdateDelegate: AnyObject { func updateAccount(_ account: Account) } ================================================ FILE: Tofu/Protocols/AlgorithmSelectionDelegate.swift ================================================ protocol AlgorithmSelectionDelegate: AnyObject { func selectAlgorithm(_ algorithm: Algorithm) } ================================================ FILE: Tofu/Tofu.xcconfig ================================================ // Configuration settings file format documentation can be found at: // https://help.apple.com/xcode/#/dev745c5c974 MARKETING_VERSION = 1.11 ================================================ FILE: Tofu/Views/AccountCell.swift ================================================ import UIKit private struct CaseInsensitiveString: Hashable, ExpressibleByStringLiteral { let value: String init(stringLiteral: String) { self.init(stringLiteral) } init(_ string: String) { self.value = string.lowercased() } } private let imageNames: [CaseInsensitiveString: String] = [ "17th Shard": "17thShard", "Adobe ID": "Adobe", "Allegro": "Allegro", "Amazon Web Services": "AWS", "Amazon": "Amazon", "AnonAddy": "AnonAddy", "Atlassian": "Atlassian", "AWS": "AWS", "Backblaze": "Backblaze", "Basecamp's+Launchpad": "Basecamp", "Binance.com": "Binance", "BitBayAuth": "BitBay", "Bitbucket": "Bitbucket", "Bitstamp": "Bitstamp", "Bittrex": "Bittrex", "Bitwarden": "Bitwarden", "Cloudflare": "Cloudflare", "Coinbase": "Coinbase", "Contentful": "Contentful", "CorporateTrust": "CorporateTrust", "CyDIS": "CyDIS", "DigitalOcean": "DigitalOcean", "Discord": "Discord", "DNSimple": "DNSimple", "Dropbox": "Dropbox", "Electronic Arts": "ElectronicArts", "Epic+Games": "EpicGames", "Evernote": "Evernote", "Facebook": "Facebook", "Fastmail": "FastMail", "Fidelity": "Fidelity", "Figma": "Figma", "Firefox": "Firefox", "gandi.net": "Gandi", "Gitea": "Gitea", "GitHub": "GitHub", "gitlab.com": "GitLab", "GoDaddy": "GoDaddy", "Google": "Google", "GreenAddress": "GreenAddress", "Hack The Box": "HackTheBox", "Heroku": "Heroku", "Hetzner": "Hetzner", "HEY": "HEY", "Home Assistant": "HomeAssistant", "Honeybadger.io": "Honeybadger", "Hostek": "Hostek", "Hover": "Hover", "hub.docker.com": "Docker", "HumbleBundle": "HumbleBundle", "id.unity.com": "Unity", "IFTTT": "IFTTT", "ID.me": "IDme", "Instagram": "Instagram", "Intercom": "Intercom", "JetBrains+Account": "JetBrains", "Kickstarter": "Kickstarter", "LastPass": "LastPass", "LinkedIn": "LinkedIn", "LinodeManager": "Linode", "Lobsters": "Lobsters", "LocalBitcoins": "LocalBitcoins", "Mastodon": "Mastodon", "Mailchimp": "Mailchimp", "Mega": "Mega", "Microsoft": "Microsoft", "Name.com": "Name.com", "Netlify": "Netlify", "Nextcloud": "Nextcloud", "Nexus Mods": "NexusMods", "NiceHash - New platform": "NiceHash", "NiceHash": "NiceHash", "Nintendo Account": "Nintendo", "Njalla": "Njalla", "Nodecraft Inc": "Nodecraft", "NordPass": "NordPass", "ownCloud": "ownCloud", "Paladin Extensions": "PaladinExtensions", "Parler": "Parler", "PayPal": "PayPal", "Philips Hue": "PhilipsHue", "Posteo": "Posteo", "Postmark": "Postmark", "Privacy.com": "Privacy", "ProfitBricks": "ProfitBricks", "ProtonMail": "ProtonMail", "PrusaAccount": "PrusaAccount", "Reddit": "Reddit", "Robinhood": "Robinhood", "rubygems.org": "RubyGems", "RuneScape": "RuneScape", "SimpleLogin": "SimpleLogin", "Slack": "Slack", "Snapchat": "Snapchat", "Sony": "Sony", "Squarespace": "Squarespace", "STACK": "STACK", "Standard Notes": "StandardNotes", "Stripe": "Stripe", "Surfshark": "Surfshark", "TETR.IO": "TETR.IO", "Time4VPS": "Time4VPS", "TorGuard": "TorGuard", "Tresorit": "Tresorit", "Tumblr": "Tumblr", "TurboTax": "TurboTax", "Tutanota": "Tutanota", "Tweakers": "Tweakers", "Twilio": "Twilio", "Twitch": "Twitch", "Twitter": "Twitter", "Uber": "Uber", "Ubisoft": "Ubisoft", "VKontakte": "VKontakte", "Wallabag": "Wallabag", "WordPress": "WordPress", "WordPress.com": "WordPress", "YNAB": "YNAB", "Zoom": "Zoom", ] private func image(for account: Account) -> UIImage? { if let issuer = account.issuer, let imageName = imageNames[CaseInsensitiveString(issuer)] { return UIImage(named: imageName)! } // Scanning Mailchimp's QR codes generates accounts without issuers and with names similar to this: username@us20.admin.mailchimp.com if let name = account.name, name.contains("admin.mailchimp.com") { return UIImage(named: "Mailchimp")! } return nil } private func imageWithColor(_ color: UIColor, size: CGSize) -> UIImage { UIGraphicsBeginImageContext(size) color.setFill() UIRectFill(CGRect(x: 0, y: 0, width: size.width, height: size.height)) let image = UIGraphicsGetImageFromCurrentImageContext() UIGraphicsEndImageContext() return image!; } private func formattedValue(_ value: String) -> String { let length = value.count let prefix = String(value.prefix(length / 2)) let suffix = String(value.suffix(length - length / 2)) return "\(prefix) \(suffix)" } class AccountCell: UITableViewCell { @IBOutlet weak var accountImageView: UIImageView! @IBOutlet weak var issuerLabel: UILabel! @IBOutlet weak var valueLabel: UILabel! @IBOutlet weak var identifierLabel: UILabel! var delegate: AccountUpdateDelegate? private let button = UIButton(type: .custom) private let progressView = CircularProgressView() var account: Account! { didSet { accessoryView = account.password.timeBased ? progressView : button let now = Date() updateWithDate(now) } } override func awakeFromNib() { accountImageView.layer.cornerRadius = accountImageView.bounds.size.width / 4.5 accountImageView.layer.cornerCurve = .continuous accountImageView.layer.masksToBounds = true accountImageView.layer.borderWidth = 1 updateColors() let featureSettings: [[UIFontDescriptor.FeatureKey: Any]] = [[.featureIdentifier: kNumberSpacingType, .typeIdentifier: kMonospacedNumbersSelector]] let fontDescriptor = valueLabel.font.fontDescriptor.addingAttributes([.featureSettings: featureSettings]) valueLabel.font = UIFont(descriptor: fontDescriptor, size: 0) button.titleLabel?.font = UIFont.boldSystemFont(ofSize: 13) button.setTitle("NEXT", for: UIControl.State()) button.setTitleColor(tintColor, for: UIControl.State()) button.setTitleColor(UIColor.white, for: .highlighted) button.setTitleColor(UIColor.white, for: .selected) button.layer.borderColor = button.tintColor.cgColor button.layer.borderWidth = 1 button.layer.cornerRadius = 4 button.contentEdgeInsets = UIEdgeInsets(top: 5, left: 10, bottom: 5, right: 10) button.sizeToFit() let image = imageWithColor(tintColor, size: button.bounds.size) button.setBackgroundImage(image, for: .highlighted) button.setBackgroundImage(image, for: .selected) button.clipsToBounds = true button.addTarget(self, action: #selector(didPressButton(_:)), for: .touchUpInside) } @objc func didPressButton(_ sender: UIButton) { account.password.counter += 1 delegate?.updateAccount(account) } func updateWithDate(_ date: Date) { accountImageView.image = Tofu.image(for: account) issuerLabel.text = (account.description.first ?? "?").uppercased() issuerLabel.isHidden = accountImageView.image != nil valueLabel.text = formattedValue(account.password.valueForDate(date)) identifierLabel.text = account.description progressView.progress = account.password.progressForDate(date) progressView.tintColor = account.password.timeIntervalRemainingForDate(date) < 5 ? .systemRed : tintColor } override func copy(_ sender: Any?) { guard let labelText = valueLabel.text else { return } UIPasteboard.general.string = labelText.replacingOccurrences(of: " ", with: "") } override var canBecomeFirstResponder: Bool { return true } override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) if traitCollection.userInterfaceStyle != previousTraitCollection?.userInterfaceStyle, let account = account { // When we change between light and dark mode, placeholder images need to be re-generated. accountImageView.image = Tofu.image(for: account) updateColors() } } private func updateColors() { if traitCollection.userInterfaceStyle == .dark { accountImageView.layer.backgroundColor = CGColor(red: 0.08, green: 0.08, blue: 0.1, alpha: 1) accountImageView.layer.borderColor = CGColor(red: 1, green: 1, blue: 1, alpha: 0.1) } else { accountImageView.layer.backgroundColor = CGColor(red: 0.97, green: 0.97, blue: 0.98, alpha: 1) accountImageView.layer.borderColor = CGColor(red: 0, green: 0, blue: 0, alpha: 0.1) } } } ================================================ FILE: Tofu/Views/CircularProgressView.swift ================================================ import UIKit class CircularProgressView: UIView { var progress: Double = 0 { didSet { maskLayer.strokeEnd = min(max(CGFloat(progress), 0), 1) } } override var tintColor: UIColor! { didSet { imageView.tintColor = tintColor backgroundImageView.tintColor = tintColor } } private let maskLayer = CAShapeLayer() private let imageView: UIImageView private let backgroundImageView: UIImageView init() { let backgroundImage = UIImage(named: "CircularProgressViewBorderThin")! backgroundImageView = UIImageView(image: backgroundImage) let image = UIImage(named: "CircularProgressViewBorderThick")! imageView = UIImageView(image: image) super.init(frame: backgroundImageView.frame) let x = frame.size.width / 2 let y = frame.size.height / 2 let radius = max(x, y) let path = CGMutablePath() path.move(to: CGPoint(x: x, y: y - radius / 2)) path.addArc(center: CGPoint(x: x, y: y), radius: radius / 2, startAngle: -CGFloat.pi / 2, endAngle: 3 * CGFloat.pi / 2, clockwise: false) maskLayer.fillColor = UIColor.clear.cgColor maskLayer.strokeColor = UIColor.black.cgColor maskLayer.lineWidth = radius maskLayer.path = path maskLayer.strokeEnd = CGFloat(progress) imageView.layer.mask = maskLayer addSubview(backgroundImageView) addSubview(imageView) } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } } ================================================ FILE: Tofu.xcodeproj/project.pbxproj ================================================ // !$*UTF8*$! { archiveVersion = 1; classes = { }; objectVersion = 54; objects = { /* Begin PBXBuildFile section */ 2506E6E31C6F83AE00E694D3 /* Password.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2506E6E21C6F83AE00E694D3 /* Password.swift */; }; 2506E6E51C6F9A5A00E694D3 /* Algorithm.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2506E6E41C6F9A5A00E694D3 /* Algorithm.swift */; }; 250CB0111C81BF0B00D48AC9 /* AccountsTableViewUpdater.swift in Sources */ = {isa = PBXBuildFile; fileRef = 250CB0101C81BF0B00D48AC9 /* AccountsTableViewUpdater.swift */; }; 251A79871C7A68BC00E2747E /* AccountSearchResultsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 251A79861C7A68BC00E2747E /* AccountSearchResultsViewController.swift */; }; 259FAC501C765BB00013B4F7 /* AccountCreationDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 259FAC4F1C765BB00013B4F7 /* AccountCreationDelegate.swift */; }; 259FAC521C765F4B0013B4F7 /* AccountUpdateDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 259FAC511C765F4B0013B4F7 /* AccountUpdateDelegate.swift */; }; 259FAC541C765FEB0013B4F7 /* AlgorithmSelectionDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 259FAC531C765FEB0013B4F7 /* AlgorithmSelectionDelegate.swift */; }; 25A81D0E1C500E9C008E51B9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25A81D0D1C500E9C008E51B9 /* AppDelegate.swift */; }; 25A81D101C500E9C008E51B9 /* AccountsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25A81D0F1C500E9C008E51B9 /* AccountsViewController.swift */; }; 25A81D131C500E9C008E51B9 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 25A81D111C500E9C008E51B9 /* Main.storyboard */; }; 25A81D151C500E9C008E51B9 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 25A81D141C500E9C008E51B9 /* Assets.xcassets */; }; 25A81D181C500E9C008E51B9 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 25A81D161C500E9C008E51B9 /* LaunchScreen.storyboard */; }; 25A81D2E1C500E9C008E51B9 /* TofuUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25A81D2D1C500E9C008E51B9 /* TofuUITests.swift */; }; 25A81D3C1C502755008E51B9 /* Keychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25A81D3B1C502755008E51B9 /* Keychain.swift */; }; 25A81D3E1C51758C008E51B9 /* AccountCreationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25A81D3D1C51758C008E51B9 /* AccountCreationViewController.swift */; }; 25A81D431C5176F0008E51B9 /* Account.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25A81D421C5176F0008E51B9 /* Account.swift */; }; 25A81D451C53923D008E51B9 /* Data.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25A81D441C53923D008E51B9 /* Data.swift */; }; 25A81D471C539253008E51B9 /* DataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25A81D461C539253008E51B9 /* DataTests.swift */; }; 25A81D4B1C540058008E51B9 /* PasswordTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25A81D4A1C540058008E51B9 /* PasswordTests.swift */; }; 25A81D511C551F17008E51B9 /* AccountCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25A81D501C551F17008E51B9 /* AccountCell.swift */; }; 25A81D531C5558A2008E51B9 /* AccountTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25A81D521C5558A2008E51B9 /* AccountTests.swift */; }; 25A81D551C5BC31B008E51B9 /* AccountUpdateViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25A81D541C5BC31B008E51B9 /* AccountUpdateViewController.swift */; }; 25A81D571C5DFC5C008E51B9 /* ScanningViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25A81D561C5DFC5C008E51B9 /* ScanningViewController.swift */; }; 25A81D5B1C5F656A008E51B9 /* CircularProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25A81D5A1C5F656A008E51B9 /* CircularProgressView.swift */; }; 25CF8B6C1C57946B00665FFE /* AlgorithmsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25CF8B6B1C57946B00665FFE /* AlgorithmsViewController.swift */; }; 50C456A12B2325A0009C83C6 /* ExternalDataInterop.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50C456A02B2325A0009C83C6 /* ExternalDataInterop.swift */; }; EB61B64D25D2BE15000B1735 /* UIViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB61B64C25D2BE15000B1735 /* UIViewController.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ 25A81D1F1C500E9C008E51B9 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 25A81D021C500E9B008E51B9 /* Project object */; proxyType = 1; remoteGlobalIDString = 25A81D091C500E9C008E51B9; remoteInfo = Tofu; }; 25A81D2A1C500E9C008E51B9 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 25A81D021C500E9B008E51B9 /* Project object */; proxyType = 1; remoteGlobalIDString = 25A81D091C500E9C008E51B9; remoteInfo = Tofu; }; /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ 2506E6E21C6F83AE00E694D3 /* Password.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Password.swift; sourceTree = ""; }; 2506E6E41C6F9A5A00E694D3 /* Algorithm.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Algorithm.swift; sourceTree = ""; }; 250CB0101C81BF0B00D48AC9 /* AccountsTableViewUpdater.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccountsTableViewUpdater.swift; sourceTree = ""; }; 251A79861C7A68BC00E2747E /* AccountSearchResultsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccountSearchResultsViewController.swift; sourceTree = ""; }; 259FAC4F1C765BB00013B4F7 /* AccountCreationDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccountCreationDelegate.swift; sourceTree = ""; }; 259FAC511C765F4B0013B4F7 /* AccountUpdateDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccountUpdateDelegate.swift; sourceTree = ""; }; 259FAC531C765FEB0013B4F7 /* AlgorithmSelectionDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AlgorithmSelectionDelegate.swift; sourceTree = ""; }; 25A81D0A1C500E9C008E51B9 /* Tofu.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Tofu.app; sourceTree = BUILT_PRODUCTS_DIR; }; 25A81D0D1C500E9C008E51B9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 25A81D0F1C500E9C008E51B9 /* AccountsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountsViewController.swift; sourceTree = ""; }; 25A81D121C500E9C008E51B9 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 25A81D141C500E9C008E51B9 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 25A81D171C500E9C008E51B9 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 25A81D191C500E9C008E51B9 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 25A81D1E1C500E9C008E51B9 /* TofuTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TofuTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 25A81D241C500E9C008E51B9 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 25A81D291C500E9C008E51B9 /* TofuUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TofuUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 25A81D2D1C500E9C008E51B9 /* TofuUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TofuUITests.swift; sourceTree = ""; }; 25A81D2F1C500E9C008E51B9 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 25A81D3B1C502755008E51B9 /* Keychain.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Keychain.swift; sourceTree = ""; }; 25A81D3D1C51758C008E51B9 /* AccountCreationViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccountCreationViewController.swift; sourceTree = ""; }; 25A81D421C5176F0008E51B9 /* Account.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Account.swift; sourceTree = ""; }; 25A81D441C53923D008E51B9 /* Data.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Data.swift; sourceTree = ""; }; 25A81D461C539253008E51B9 /* DataTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DataTests.swift; sourceTree = ""; }; 25A81D4A1C540058008E51B9 /* PasswordTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PasswordTests.swift; sourceTree = ""; }; 25A81D501C551F17008E51B9 /* AccountCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccountCell.swift; sourceTree = ""; }; 25A81D521C5558A2008E51B9 /* AccountTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccountTests.swift; sourceTree = ""; }; 25A81D541C5BC31B008E51B9 /* AccountUpdateViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccountUpdateViewController.swift; sourceTree = ""; }; 25A81D561C5DFC5C008E51B9 /* ScanningViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScanningViewController.swift; sourceTree = ""; }; 25A81D5A1C5F656A008E51B9 /* CircularProgressView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CircularProgressView.swift; sourceTree = ""; }; 25CF8B6B1C57946B00665FFE /* AlgorithmsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AlgorithmsViewController.swift; sourceTree = ""; }; 50C456992B232054009C83C6 /* Tofu.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Tofu.xcconfig; path = Tofu/Tofu.xcconfig; sourceTree = ""; }; 50C456A02B2325A0009C83C6 /* ExternalDataInterop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExternalDataInterop.swift; sourceTree = ""; }; EB61B64C25D2BE15000B1735 /* UIViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIViewController.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ 25A81D071C500E9C008E51B9 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; 25A81D1B1C500E9C008E51B9 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; 25A81D261C500E9C008E51B9 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ 25A81D011C500E9B008E51B9 = { isa = PBXGroup; children = ( 50C456992B232054009C83C6 /* Tofu.xcconfig */, 25A81D0C1C500E9C008E51B9 /* Tofu */, 25A81D211C500E9C008E51B9 /* TofuTests */, 25A81D2C1C500E9C008E51B9 /* TofuUITests */, 25A81D0B1C500E9C008E51B9 /* Products */, ); sourceTree = ""; }; 25A81D0B1C500E9C008E51B9 /* Products */ = { isa = PBXGroup; children = ( 25A81D0A1C500E9C008E51B9 /* Tofu.app */, 25A81D1E1C500E9C008E51B9 /* TofuTests.xctest */, 25A81D291C500E9C008E51B9 /* TofuUITests.xctest */, ); name = Products; sourceTree = ""; }; 25A81D0C1C500E9C008E51B9 /* Tofu */ = { isa = PBXGroup; children = ( 50C4569B2B2324E2009C83C6 /* Extensions */, 50C4569C2B2324F3009C83C6 /* Protocols */, 50C4569D2B232507009C83C6 /* Models */, 50C4569E2B23251B009C83C6 /* Views */, 50C4569F2B23252D009C83C6 /* Controllers */, 25A81D0D1C500E9C008E51B9 /* AppDelegate.swift */, 25A81D111C500E9C008E51B9 /* Main.storyboard */, 25A81D161C500E9C008E51B9 /* LaunchScreen.storyboard */, 25A81D141C500E9C008E51B9 /* Assets.xcassets */, 25A81D191C500E9C008E51B9 /* Info.plist */, ); path = Tofu; sourceTree = ""; }; 25A81D211C500E9C008E51B9 /* TofuTests */ = { isa = PBXGroup; children = ( 25A81D461C539253008E51B9 /* DataTests.swift */, 25A81D4A1C540058008E51B9 /* PasswordTests.swift */, 25A81D521C5558A2008E51B9 /* AccountTests.swift */, 25A81D241C500E9C008E51B9 /* Info.plist */, ); path = TofuTests; sourceTree = ""; }; 25A81D2C1C500E9C008E51B9 /* TofuUITests */ = { isa = PBXGroup; children = ( 25A81D2D1C500E9C008E51B9 /* TofuUITests.swift */, 25A81D2F1C500E9C008E51B9 /* Info.plist */, ); path = TofuUITests; sourceTree = ""; }; 50C4569B2B2324E2009C83C6 /* Extensions */ = { isa = PBXGroup; children = ( 25A81D441C53923D008E51B9 /* Data.swift */, EB61B64C25D2BE15000B1735 /* UIViewController.swift */, ); path = Extensions; sourceTree = ""; }; 50C4569C2B2324F3009C83C6 /* Protocols */ = { isa = PBXGroup; children = ( 259FAC4F1C765BB00013B4F7 /* AccountCreationDelegate.swift */, 259FAC511C765F4B0013B4F7 /* AccountUpdateDelegate.swift */, 259FAC531C765FEB0013B4F7 /* AlgorithmSelectionDelegate.swift */, ); path = Protocols; sourceTree = ""; }; 50C4569D2B232507009C83C6 /* Models */ = { isa = PBXGroup; children = ( 25A81D421C5176F0008E51B9 /* Account.swift */, 25A81D3B1C502755008E51B9 /* Keychain.swift */, 2506E6E21C6F83AE00E694D3 /* Password.swift */, 2506E6E41C6F9A5A00E694D3 /* Algorithm.swift */, 50C456A02B2325A0009C83C6 /* ExternalDataInterop.swift */, ); path = Models; sourceTree = ""; }; 50C4569E2B23251B009C83C6 /* Views */ = { isa = PBXGroup; children = ( 25A81D501C551F17008E51B9 /* AccountCell.swift */, 25A81D5A1C5F656A008E51B9 /* CircularProgressView.swift */, ); path = Views; sourceTree = ""; }; 50C4569F2B23252D009C83C6 /* Controllers */ = { isa = PBXGroup; children = ( 25A81D0F1C500E9C008E51B9 /* AccountsViewController.swift */, 25A81D3D1C51758C008E51B9 /* AccountCreationViewController.swift */, 25CF8B6B1C57946B00665FFE /* AlgorithmsViewController.swift */, 25A81D541C5BC31B008E51B9 /* AccountUpdateViewController.swift */, 25A81D561C5DFC5C008E51B9 /* ScanningViewController.swift */, 251A79861C7A68BC00E2747E /* AccountSearchResultsViewController.swift */, 250CB0101C81BF0B00D48AC9 /* AccountsTableViewUpdater.swift */, ); path = Controllers; sourceTree = ""; }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ 25A81D091C500E9C008E51B9 /* Tofu */ = { isa = PBXNativeTarget; buildConfigurationList = 25A81D321C500E9C008E51B9 /* Build configuration list for PBXNativeTarget "Tofu" */; buildPhases = ( 50C4569A2B2321D0009C83C6 /* Generate Version Headers */, 25A81D061C500E9C008E51B9 /* Sources */, 25A81D071C500E9C008E51B9 /* Frameworks */, 25A81D081C500E9C008E51B9 /* Resources */, ); buildRules = ( ); dependencies = ( ); name = Tofu; productName = Tofu; productReference = 25A81D0A1C500E9C008E51B9 /* Tofu.app */; productType = "com.apple.product-type.application"; }; 25A81D1D1C500E9C008E51B9 /* TofuTests */ = { isa = PBXNativeTarget; buildConfigurationList = 25A81D351C500E9C008E51B9 /* Build configuration list for PBXNativeTarget "TofuTests" */; buildPhases = ( 25A81D1A1C500E9C008E51B9 /* Sources */, 25A81D1B1C500E9C008E51B9 /* Frameworks */, 25A81D1C1C500E9C008E51B9 /* Resources */, ); buildRules = ( ); dependencies = ( 25A81D201C500E9C008E51B9 /* PBXTargetDependency */, ); name = TofuTests; productName = TofuTests; productReference = 25A81D1E1C500E9C008E51B9 /* TofuTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; 25A81D281C500E9C008E51B9 /* TofuUITests */ = { isa = PBXNativeTarget; buildConfigurationList = 25A81D381C500E9C008E51B9 /* Build configuration list for PBXNativeTarget "TofuUITests" */; buildPhases = ( 25A81D251C500E9C008E51B9 /* Sources */, 25A81D261C500E9C008E51B9 /* Frameworks */, 25A81D271C500E9C008E51B9 /* Resources */, ); buildRules = ( ); dependencies = ( 25A81D2B1C500E9C008E51B9 /* PBXTargetDependency */, ); name = TofuUITests; productName = TofuUITests; productReference = 25A81D291C500E9C008E51B9 /* TofuUITests.xctest */; productType = "com.apple.product-type.bundle.ui-testing"; }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ 25A81D021C500E9B008E51B9 /* Project object */ = { isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; LastSwiftUpdateCheck = 0720; LastUpgradeCheck = 1510; ORGANIZATIONNAME = "Daniel Kennett"; TargetAttributes = { 25A81D091C500E9C008E51B9 = { CreatedOnToolsVersion = 7.2; DevelopmentTeam = QG8TM5XJ84; LastSwiftMigration = 1020; }; 25A81D1D1C500E9C008E51B9 = { CreatedOnToolsVersion = 7.2; LastSwiftMigration = 1020; TestTargetID = 25A81D091C500E9C008E51B9; }; 25A81D281C500E9C008E51B9 = { CreatedOnToolsVersion = 7.2; LastSwiftMigration = 1020; TestTargetID = 25A81D091C500E9C008E51B9; }; }; }; buildConfigurationList = 25A81D051C500E9B008E51B9 /* Build configuration list for PBXProject "Tofu" */; compatibilityVersion = "Xcode 3.2"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( en, Base, ); mainGroup = 25A81D011C500E9B008E51B9; productRefGroup = 25A81D0B1C500E9C008E51B9 /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( 25A81D091C500E9C008E51B9 /* Tofu */, 25A81D1D1C500E9C008E51B9 /* TofuTests */, 25A81D281C500E9C008E51B9 /* TofuUITests */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ 25A81D081C500E9C008E51B9 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( 25A81D181C500E9C008E51B9 /* LaunchScreen.storyboard in Resources */, 25A81D151C500E9C008E51B9 /* Assets.xcassets in Resources */, 25A81D131C500E9C008E51B9 /* Main.storyboard in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; 25A81D1C1C500E9C008E51B9 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; 25A81D271C500E9C008E51B9 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ 50C4569A2B2321D0009C83C6 /* Generate Version Headers */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( ); inputPaths = ( ); name = "Generate Version Headers"; outputFileListPaths = ( ); outputPaths = ( "${BUILT_PRODUCTS_DIR}/include/TofuVersions.h", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "GIT_RELEASE_VERSION=$(git describe --tags --always --dirty --long)\nGIT_RELEASE_VERSION=${GIT_RELEASE_VERSION/-0-/-}\n\nCOMMITS=$(git rev-list HEAD | wc -l)\nCOMMITS=$(($COMMITS))\n\nFINAL_HEADER_LOCATION=\"${BUILT_PRODUCTS_DIR}/include/TofuVersions.h\"\nTEMP_HEADER_LOCATION=\"${FINAL_HEADER_LOCATION}~\"\n\nmkdir -p \"${BUILT_PRODUCTS_DIR}/include\"\n\necho \"#define TOFU_VERBOSE_VERSION ${GIT_RELEASE_VERSION#*v}\" > \"${TEMP_HEADER_LOCATION}\"\necho \"#define TOFU_BUNDLE_VERSION ${COMMITS}\" >> \"${TEMP_HEADER_LOCATION}\"\necho \"#define TOFU_MARKETING_VERSION ${MARKETING_VERSION}\" >> \"${TEMP_HEADER_LOCATION}\"\n\ncmp --silent \"${FINAL_HEADER_LOCATION}\" \"${TEMP_HEADER_LOCATION}\"\n\nif [ $? -eq 0 ]\nthen\n echo \"Versions unchanged - skipping.\"\n rm \"${TEMP_HEADER_LOCATION}\"\nelse\n rm -f \"${FINAL_HEADER_LOCATION}\"\n mv \"${TEMP_HEADER_LOCATION}\" \"${FINAL_HEADER_LOCATION}\"\nfi\n\necho \"${FINAL_HEADER_LOCATION}\"\n"; }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ 25A81D061C500E9C008E51B9 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( 25A81D101C500E9C008E51B9 /* AccountsViewController.swift in Sources */, 259FAC521C765F4B0013B4F7 /* AccountUpdateDelegate.swift in Sources */, 25A81D511C551F17008E51B9 /* AccountCell.swift in Sources */, EB61B64D25D2BE15000B1735 /* UIViewController.swift in Sources */, 251A79871C7A68BC00E2747E /* AccountSearchResultsViewController.swift in Sources */, 25A81D0E1C500E9C008E51B9 /* AppDelegate.swift in Sources */, 25A81D451C53923D008E51B9 /* Data.swift in Sources */, 2506E6E51C6F9A5A00E694D3 /* Algorithm.swift in Sources */, 259FAC541C765FEB0013B4F7 /* AlgorithmSelectionDelegate.swift in Sources */, 25A81D5B1C5F656A008E51B9 /* CircularProgressView.swift in Sources */, 2506E6E31C6F83AE00E694D3 /* Password.swift in Sources */, 25A81D571C5DFC5C008E51B9 /* ScanningViewController.swift in Sources */, 25CF8B6C1C57946B00665FFE /* AlgorithmsViewController.swift in Sources */, 25A81D3C1C502755008E51B9 /* Keychain.swift in Sources */, 250CB0111C81BF0B00D48AC9 /* AccountsTableViewUpdater.swift in Sources */, 259FAC501C765BB00013B4F7 /* AccountCreationDelegate.swift in Sources */, 25A81D551C5BC31B008E51B9 /* AccountUpdateViewController.swift in Sources */, 25A81D3E1C51758C008E51B9 /* AccountCreationViewController.swift in Sources */, 25A81D431C5176F0008E51B9 /* Account.swift in Sources */, 50C456A12B2325A0009C83C6 /* ExternalDataInterop.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; 25A81D1A1C500E9C008E51B9 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( 25A81D4B1C540058008E51B9 /* PasswordTests.swift in Sources */, 25A81D471C539253008E51B9 /* DataTests.swift in Sources */, 25A81D531C5558A2008E51B9 /* AccountTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; 25A81D251C500E9C008E51B9 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( 25A81D2E1C500E9C008E51B9 /* TofuUITests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ 25A81D201C500E9C008E51B9 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 25A81D091C500E9C008E51B9 /* Tofu */; targetProxy = 25A81D1F1C500E9C008E51B9 /* PBXContainerItemProxy */; }; 25A81D2B1C500E9C008E51B9 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 25A81D091C500E9C008E51B9 /* Tofu */; targetProxy = 25A81D2A1C500E9C008E51B9 /* PBXContainerItemProxy */; }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ 25A81D111C500E9C008E51B9 /* Main.storyboard */ = { isa = PBXVariantGroup; children = ( 25A81D121C500E9C008E51B9 /* Base */, ); name = Main.storyboard; sourceTree = ""; }; 25A81D161C500E9C008E51B9 /* LaunchScreen.storyboard */ = { isa = PBXVariantGroup; children = ( 25A81D171C500E9C008E51B9 /* Base */, ); name = LaunchScreen.storyboard; sourceTree = ""; }; /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ 25A81D301C500E9C008E51B9 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_EMPTY_BODY = YES; CLANG_WARN_ENUM_CONVERSION = YES; CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; GCC_OPTIMIZATION_LEVEL = 0; GCC_PREPROCESSOR_DEFINITIONS = ( "DEBUG=1", "$(inherited)", ); GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; }; 25A81D311C500E9C008E51B9 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_EMPTY_BODY = YES; CLANG_WARN_ENUM_CONVERSION = YES; CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; TARGETED_DEVICE_FAMILY = "1,2"; VALIDATE_PRODUCT = YES; }; name = Release; }; 25A81D331C500E9C008E51B9 /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = 50C456992B232054009C83C6 /* Tofu.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "iPhone Developer"; DEVELOPMENT_TEAM = QG8TM5XJ84; INFOPLIST_FILE = Tofu/Info.plist; INFOPLIST_OTHER_PREPROCESSOR_FLAGS = "-traditional"; INFOPLIST_PREFIX_HEADER = "${BUILT_PRODUCTS_DIR}/include/TofuVersions.h"; INFOPLIST_PREPROCESS = YES; IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = com.calleerlandsson.Tofu; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; }; name = Debug; }; 25A81D341C500E9C008E51B9 /* Release */ = { isa = XCBuildConfiguration; baseConfigurationReference = 50C456992B232054009C83C6 /* Tofu.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "iPhone Developer"; DEVELOPMENT_TEAM = QG8TM5XJ84; INFOPLIST_FILE = Tofu/Info.plist; INFOPLIST_OTHER_PREPROCESSOR_FLAGS = "-traditional"; INFOPLIST_PREFIX_HEADER = "${BUILT_PRODUCTS_DIR}/include/TofuVersions.h"; INFOPLIST_PREPROCESS = YES; IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = com.calleerlandsson.Tofu; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; }; name = Release; }; 25A81D361C500E9C008E51B9 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; INFOPLIST_FILE = TofuTests/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = com.calleerlandsson.TofuTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Tofu.app/Tofu"; }; name = Debug; }; 25A81D371C500E9C008E51B9 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; INFOPLIST_FILE = TofuTests/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = com.calleerlandsson.TofuTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Tofu.app/Tofu"; }; name = Release; }; 25A81D391C500E9C008E51B9 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { INFOPLIST_FILE = TofuUITests/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = com.calleerlandsson.TofuUITests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_TARGET_NAME = Tofu; USES_XCTRUNNER = YES; }; name = Debug; }; 25A81D3A1C500E9C008E51B9 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { INFOPLIST_FILE = TofuUITests/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = com.calleerlandsson.TofuUITests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_TARGET_NAME = Tofu; USES_XCTRUNNER = YES; }; name = Release; }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ 25A81D051C500E9B008E51B9 /* Build configuration list for PBXProject "Tofu" */ = { isa = XCConfigurationList; buildConfigurations = ( 25A81D301C500E9C008E51B9 /* Debug */, 25A81D311C500E9C008E51B9 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; 25A81D321C500E9C008E51B9 /* Build configuration list for PBXNativeTarget "Tofu" */ = { isa = XCConfigurationList; buildConfigurations = ( 25A81D331C500E9C008E51B9 /* Debug */, 25A81D341C500E9C008E51B9 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; 25A81D351C500E9C008E51B9 /* Build configuration list for PBXNativeTarget "TofuTests" */ = { isa = XCConfigurationList; buildConfigurations = ( 25A81D361C500E9C008E51B9 /* Debug */, 25A81D371C500E9C008E51B9 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; 25A81D381C500E9C008E51B9 /* Build configuration list for PBXNativeTarget "TofuUITests" */ = { isa = XCConfigurationList; buildConfigurations = ( 25A81D391C500E9C008E51B9 /* Debug */, 25A81D3A1C500E9C008E51B9 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; /* End XCConfigurationList section */ }; rootObject = 25A81D021C500E9B008E51B9 /* Project object */; } ================================================ FILE: Tofu.xcodeproj/project.xcworkspace/contents.xcworkspacedata ================================================ ================================================ FILE: Tofu.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist ================================================ IDEDidComputeMac32BitWarning ================================================ FILE: TofuTests/AccountTests.swift ================================================ import XCTest @testable import Tofu class AccountTests: XCTestCase { func testInitWithURL() { var account = Account(url: URL( string: "otpauth://totp/Example:alice@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Example")!) XCTAssertEqual(account?.name, "alice@example.com") XCTAssertEqual(account?.issuer, "Example") XCTAssertEqual(account?.password.timeBased, true) XCTAssertEqual(account?.password.secret, Data(base32Encoded: "JBSWY3DPEHPK3PXP")!) XCTAssertEqual(account?.password.algorithm, .sha1) XCTAssertEqual(account?.password.digits, 6) XCTAssertEqual(account?.password.period, 30) account = Account(url: URL( string: "otpauth://hotp/Example:alice@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Example&counter=1")!) XCTAssertEqual(account?.name, "alice@example.com") XCTAssertEqual(account?.issuer, "Example") XCTAssertEqual(account?.password.timeBased, false) XCTAssertEqual(account?.password.secret, Data(base32Encoded: "JBSWY3DPEHPK3PXP")!) XCTAssertEqual(account?.password.algorithm, .sha1) XCTAssertEqual(account?.password.digits, 6) XCTAssertEqual(account?.password.counter, 1) account = Account(url: URL( string: "otpauth://totp/alice@example.com?secret=JBSWY3DPEHPK3PXP")!) XCTAssertEqual(account?.name, "alice@example.com") account = Account(url: URL( string: "otpauth://totp/Example%3Aalice@example.com?secret=JBSWY3DPEHPK3PXP")!) XCTAssertEqual(account?.name, "alice@example.com") account = Account(url: URL( string: "otpauth://totp/Example:%20%20alice@example.com?secret=JBSWY3DPEHPK3PXP")!) XCTAssertEqual(account?.name, "alice@example.com") account = Account(url: URL( string: "otpauth://totp/Example%3A%20%20alice@example.com?secret=JBSWY3DPEHPK3PXP")!) XCTAssertEqual(account?.name, "alice@example.com") account = Account(url: URL( string: "otpauth://totp/example.com/alice?secret=JBSWY3DPEHPK3PXP")!) XCTAssertEqual(account?.name, "example.com/alice") account = Account(url: URL( string: "otpauth://totp/Example:alice@example.com?secret=JBSWY3DPEHPK3PXP")!) XCTAssertEqual(account?.issuer, "Example") account = Account(url: URL(string: "otpauth://totp/alice@example.com")!) XCTAssertNil(account) account = Account(url: URL(string: "otpauth://totp/alice@example.com?secret=AAA")!) XCTAssertNil(account) account = Account(url: URL( string: "otpauth://totp/alice@example.com?secret=JBSWY3DPEHPK3PXP&algorithm=SHA1")!) XCTAssertEqual(account?.password.algorithm, .sha1) account = Account(url: URL( string: "otpauth://totp/alice@example.com?secret=JBSWY3DPEHPK3PXP&algorithm=SHA256")!) XCTAssertEqual(account?.password.algorithm, .sha256) account = Account(url: URL( string: "otpauth://totp/alice@example.com?secret=JBSWY3DPEHPK3PXP&algorithm=SHA512")!) XCTAssertEqual(account?.password.algorithm, .sha512) account = Account(url: URL( string: "otpauth://totp/alice@example.com?secret=JBSWY3DPEHPK3PXP&digits=6")!) XCTAssertEqual(account?.password.digits, 6) account = Account(url: URL( string: "otpauth://totp/alice@example.com?secret=JBSWY3DPEHPK3PXP&digits=8")!) XCTAssertEqual(account?.password.digits, 8) account = Account(url: URL( string: "otpauth://hotp/Example:alice@example.com?secret=JBSWY3DPEHPK3PXP")!) XCTAssertEqual(account?.password.timeBased, false) XCTAssertEqual(account?.password.digits, 6) XCTAssertEqual(account?.password.counter, 0) account = Account(url: URL( string: "otpauth://totp/alice@example.com?secret=JBSWY3DPEHPK3PXP&period=60")!) XCTAssertEqual(account?.password.period, 60) } func testDescription() { let account = Account() account.name = "test@example.com" account.issuer = "Example" XCTAssertEqual(account.description, "Example (test@example.com)") account.name = "test@example.com" account.issuer = nil XCTAssertEqual(account.description, "test@example.com") account.name = "test@example.com" account.issuer = "" XCTAssertEqual(account.description, "test@example.com") account.name = nil account.issuer = "Example" XCTAssertEqual(account.description, "Example") account.name = "" account.issuer = "Example" XCTAssertEqual(account.description, "Example") account.name = nil account.issuer = nil XCTAssertEqual(account.description, "") account.name = "" account.issuer = "" XCTAssertEqual(account.description, "") } } ================================================ FILE: TofuTests/DataTests.swift ================================================ import XCTest @testable import Tofu class DataTests: XCTestCase { func testInitBase32Encoded() { let examples: [(encoded: String, decoded: String)] = [ ("", ""), ("MY======", "f"), ("MZXQ====", "fo"), ("MZXW6===", "foo"), ("MZXW6YQ=", "foob"), ("MZXW6YTB", "fooba"), ("MZXW6YTBOI======", "foobar"), ("MY", "f"), ("MZXQ", "fo"), ("MZXW6", "foo"), ("MZXW6YQ", "foob"), ("MZXW6YTB", "fooba"), ("MZXW6YTBOI", "foobar"), ("mzxw6ytboi", "foobar"), ] for example in examples { let actual = Data(base32Encoded: example.encoded) let expected = example.decoded.data(using: .ascii) XCTAssertEqual(actual, expected) } XCTAssertNil(Data(base32Encoded: "1")) // Invalid character XCTAssertNil(Data(base32Encoded: "A")) // Invalid length XCTAssertNil(Data(base32Encoded: "AAA")) XCTAssertNil(Data(base32Encoded: "AAAAAA")) XCTAssertNil(Data(base32Encoded: "MY==")) // Invalid padding XCTAssertNil(Data(base32Encoded: "MY=====")) XCTAssertNil(Data(base32Encoded: "MZXW6Y===")) } func testExportRoundTrip() throws { let interop = ExternalDataInterop() let account1 = Account() account1.name = "Test" account1.issuer = "Xcode" account1.password.algorithm = .sha1 account1.password.secret = Data(base32Encoded: "aaaaaaa")! account1.password.timeBased = true account1.password.period = 30 account1.password.digits = 6 let account2 = Account() account2.name = "Test 2" account2.issuer = "Xcode" account2.password.algorithm = .sha1 account2.password.secret = Data(base32Encoded: "bbbbbbb")! account2.password.timeBased = true account2.password.period = 30 account2.password.digits = 6 XCTAssertNotEqual(account1, account2) let sourceAccounts: [Account] = [account1, account2] let encryptedAccounts = try interop.encryptedData(for: sourceAccounts, with: "12345678") let decryptedAccounts = try interop.decryptAccounts(from: encryptedAccounts, with: "12345678") XCTAssertEqual(sourceAccounts, decryptedAccounts) XCTAssertThrowsError(try interop.decryptAccounts(from: encryptedAccounts, with: "87654321")) } } ================================================ FILE: TofuTests/Info.plist ================================================ CFBundleDevelopmentRegion en CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleInfoDictionaryVersion 6.0 CFBundleName $(PRODUCT_NAME) CFBundlePackageType BNDL CFBundleShortVersionString 1.0 CFBundleSignature ???? CFBundleVersion 1 ================================================ FILE: TofuTests/PasswordTests.swift ================================================ import XCTest @testable import Tofu class PasswordTests: XCTestCase { func testValueForDate() { let secret = "12345678901234567890".data(using: String.Encoding.ascii)! let counterBasedTests: [(Int, String, String, String)] = [ (0, "755224", "875740", "125165"), (1, "287082", "247374", "342147"), (2, "359152", "254785", "730102"), (3, "969429", "496144", "778726"), (4, "338314", "480556", "937510"), (5, "254676", "697997", "848329"), (6, "287922", "191609", "266680"), (7, "162583", "579288", "588359"), (8, "399871", "895912", "039399"), (9, "520489", "184989", "643409"), ] let timeBasedTests = [ (Date(timeIntervalSince1970: 59), "94287082", "32247374", "69342147"), (Date(timeIntervalSince1970: 1111111109), "07081804", "34756375", "63049338"), (Date(timeIntervalSince1970: 1111111111), "14050471", "74584430", "54380122"), (Date(timeIntervalSince1970: 1234567890), "89005924", "42829826", "76671578"), (Date(timeIntervalSince1970: 2000000000), "69279037", "78428693", "56464532"), (Date(timeIntervalSince1970: 20000000000), "65353130", "24142410", "69481994"), ] let counterBasedSHA1Password = passwordWithSecret(secret, algorithm: .sha1, digits: 6, timeBased: false) let counterBasedSHA256Password = passwordWithSecret(secret, algorithm: .sha256, digits: 6, timeBased: false) let counterBasedSHA512Password = passwordWithSecret(secret, algorithm: .sha512, digits: 6, timeBased: false) let timeBasedSHA1Password = passwordWithSecret(secret, algorithm: .sha1, digits: 8, timeBased: true) let timeBasedSHA256Password = passwordWithSecret(secret, algorithm: .sha256, digits: 8, timeBased: true) let timeBasedSHA512Password = passwordWithSecret(secret, algorithm: .sha512, digits: 8, timeBased: true) for (counter, expSHA1, expSHA256, expSHA512) in counterBasedTests { counterBasedSHA1Password.counter = counter XCTAssertEqual(counterBasedSHA1Password.valueForDate(Date()), expSHA1) counterBasedSHA256Password.counter = counter XCTAssertEqual(counterBasedSHA256Password.valueForDate(Date()), expSHA256) counterBasedSHA512Password.counter = counter XCTAssertEqual(counterBasedSHA512Password.valueForDate(Date()), expSHA512) } for (date, expSHA1, expSHA256, expSHA512) in timeBasedTests { XCTAssertEqual(timeBasedSHA1Password.valueForDate(date), expSHA1) XCTAssertEqual(timeBasedSHA256Password.valueForDate(date), expSHA256) XCTAssertEqual(timeBasedSHA512Password.valueForDate(date), expSHA512) } } func testProgressForDate() { let password = Password() password.period = 30 XCTAssertEqual(password.progressForDate(Date(timeIntervalSince1970: 0)), 1) XCTAssertEqual(password.progressForDate(Date(timeIntervalSince1970: 15)), 0.5) XCTAssertEqual(password.progressForDate(Date(timeIntervalSince1970: 22.5)), 0.25) XCTAssertEqual(password.progressForDate(Date(timeIntervalSince1970: 30)), 1) } func timeIntervalRemainingForDate() { let password = Password() password.period = 30 XCTAssertEqual(password.timeIntervalRemainingForDate(Date(timeIntervalSince1970: 0)), 30) XCTAssertEqual(password.timeIntervalRemainingForDate(Date(timeIntervalSince1970: 15)), 15) XCTAssertEqual(password.timeIntervalRemainingForDate(Date(timeIntervalSince1970: 22.5)), 7.5) XCTAssertEqual(password.timeIntervalRemainingForDate(Date(timeIntervalSince1970: 30)), 0) } private func passwordWithSecret(_ secret: Data, algorithm: Algorithm, digits: Int, timeBased: Bool) -> Password { let password = Password() password.secret = secret password.algorithm = algorithm password.digits = digits password.timeBased = timeBased return password } } ================================================ FILE: TofuUITests/Info.plist ================================================ CFBundleDevelopmentRegion en CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleInfoDictionaryVersion 6.0 CFBundleName $(PRODUCT_NAME) CFBundlePackageType BNDL CFBundleShortVersionString 1.0 CFBundleSignature ???? CFBundleVersion 1 ================================================ FILE: TofuUITests/TofuUITests.swift ================================================ // // TofuUITests.swift // TofuUITests // // Created by Calle Erlandsson on 20/01/16. // Copyright © 2016 Calle Erlandsson. All rights reserved. // import XCTest class TofuUITests: XCTestCase { override func setUp() { super.setUp() // Put setup code here. This method is called before the invocation of each test method in the class. // In UI tests it is usually best to stop immediately when a failure occurs. continueAfterFailure = false // UI tests must launch the application that they test. Doing this in setup will make sure it happens for each test method. XCUIApplication().launch() // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. } override func tearDown() { // Put teardown code here. This method is called after the invocation of each test method in the class. super.tearDown() } func testExample() { // Use recording to get started writing UI tests. // Use XCTAssert and related functions to verify your tests produce the correct results. } }