Merge pull request #1 from omnivore-app/oss

Open source omnivore
This commit is contained in:
Jackson Harper 2022-02-11 09:32:46 -08:00 committed by GitHub
commit d05c4afec4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
1485 changed files with 188409 additions and 0 deletions

8
Makefile Normal file
View file

@ -0,0 +1,8 @@
open_ios:
$(MAKE) -C apple open
apple_graphql_gen:
$(MAKE) -C apple graphql_gen
apple_extension_gen:
$(MAKE) -C apple extension_gen

37
README.md Normal file
View file

@ -0,0 +1,37 @@
# Omnivore
[Omnivore](https://omnivore.app) is a complete, open source read-it-later solution for people who like text.
We built Omnivore because we love reading and we want it to be more social. Join us!
- Highlighting, notes, search, and sharing
- Full keyboard navigation
- Automatically saves your place in long articles
- Add articles via email (with substack support!)
- PDF support
- [Web app](https://omnivore.app/) written in node and typescript
- [Native iOS app](https://discord.gg/nyqRrjujNe)
- Progressive web app for Android users
- [Browser extensions](https://omnivore.app/install/chrome) for Chrome, Safari, Firefox, and Edge
- Tagging (coming soon!)
- Offline support (coming soon!)
Every single part is fully open source! Fork it, extend it, or deploy it to your own server.
We also have a free hosted version of Omnivore at [omnivore.app](https://omnivore.app/) -- try it now!
## Join us on Discord!
We're building our community on Discord. [Join us!](https://discord.gg/nyqRrjujNe)
## How to setup local development
FIXME: Jackson to fill this in
## How to deploy to your own server
FIXME: Jackson to fill this in
## License
Omnivore and our extensions to Readability.js are under the AGPL-3.0 license.

20
apple/.gitignore vendored Normal file
View file

@ -0,0 +1,20 @@
.swiftpm/
*.xcuserstate
.DS_Store
*.xcuserdatad
.build
/build
*/xcuserdata
*.xccheckout
*.moved-aside
DerivedData
*.hmap
*.ipa
.bundle
output/
/vendor
*.dSYM.zip
OmnivoreKit/Sources/Utils/Resources/Keys.json

6
apple/.swiftformat Normal file
View file

@ -0,0 +1,6 @@
--exclude build,.build,vendor,docs,.bundle
--indent 2
--tabwidth 2
--commas inline
--self init-only
--swiftversion 5.5

View file

@ -0,0 +1,11 @@
excluded:
- build
- .build
- vendor
- docs
- .bundle
- BuildTools
identifier_name:
excluded:
- id

View file

@ -0,0 +1 @@
// Empty

View file

@ -0,0 +1,88 @@
{
"object": {
"pins": [
{
"package": "Files",
"repositoryURL": "https://github.com/JohnSundell/Files",
"state": {
"branch": null,
"revision": "d273b5b7025d386feef79ef6bad7de762e106eaf",
"version": "4.2.0"
}
},
{
"package": "SourceKitten",
"repositoryURL": "https://github.com/jpsim/SourceKitten.git",
"state": {
"branch": null,
"revision": "7f4be006fe73211b0fd9666c73dc2f2303ffa756",
"version": "0.31.0"
}
},
{
"package": "swift-argument-parser",
"repositoryURL": "https://github.com/apple/swift-argument-parser",
"state": {
"branch": null,
"revision": "9564d61b08a5335ae0a36f789a7d71493eacadfc",
"version": "0.3.2"
}
},
{
"package": "swift-graphql",
"repositoryURL": "https://github.com/maticzav/swift-graphql",
"state": {
"branch": null,
"revision": "c3dee58c57543db473b2d5fd76871e282adedc9d",
"version": "2.2.1"
}
},
{
"package": "SwiftFormat",
"repositoryURL": "https://github.com/nicklockwood/SwiftFormat",
"state": {
"branch": null,
"revision": "a9bdfd2548ebe518a693a5c6341d27c42b7e7a0a",
"version": "0.48.11"
}
},
{
"package": "SwiftLint",
"repositoryURL": "https://github.com/realm/SwiftLint",
"state": {
"branch": null,
"revision": "e820e750b08bd67bc9d98f4817868e9bc3d5d865",
"version": "0.44.0"
}
},
{
"package": "SwiftyTextTable",
"repositoryURL": "https://github.com/scottrhoyt/SwiftyTextTable.git",
"state": {
"branch": null,
"revision": "c6df6cf533d120716bff38f8ff9885e1ce2a4ac3",
"version": "0.9.0"
}
},
{
"package": "SWXMLHash",
"repositoryURL": "https://github.com/drmohundro/SWXMLHash.git",
"state": {
"branch": null,
"revision": "9183170d20857753d4f331b0ca63f73c60764bf3",
"version": "5.0.2"
}
},
{
"package": "Yams",
"repositoryURL": "https://github.com/jpsim/Yams.git",
"state": {
"branch": null,
"revision": "9ff1cc9327586db4e0c8f46f064b6a82ec1566fa",
"version": "4.0.6"
}
}
]
},
"version": 1
}

View file

@ -0,0 +1,13 @@
// swift-tools-version:5.5
import PackageDescription
let package = Package(
name: "BuildTools",
platforms: [.macOS(.v10_11)],
dependencies: [
.package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.48.11"),
.package(url: "https://github.com/realm/SwiftLint", from: "0.44.0"),
.package(url: "https://github.com/maticzav/swift-graphql", from: "2.2.1")
],
targets: [.target(name: "BuildTools", path: "")]
)

View file

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.applesignin</key>
<array>
<string>Default</string>
</array>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>QJF2XZ86HB.app.omnivore.app</string>
</array>
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)app.omnivoreapp.shared</string>
</array>
</dict>
</plist>

View file

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>production</string>
<key>com.apple.developer.applesignin</key>
<array>
<string>Default</string>
</array>
<key>com.apple.security.application-groups</key>
<array>
<string>group.app.omnivoreapp</string>
</array>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)app.omnivore.shared</string>
</array>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:omnivore.app</string>
<string>applinks:dev.omnivore.app</string>
<string>applinks:demo.omnivore.app</string>
</array>
</dict>
</plist>

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>QJF2XZ86HB.app.omnivore.app</string>
</array>
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)app.omnivoreapp.shared</string>
</array>
</dict>
</plist>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.app.omnivoreapp</string>
</array>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)app.omnivore.shared</string>
</array>
</dict>
</plist>

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>QJF2XZ86HB.app.omnivore.app</string>
</array>
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)app.omnivoreapp.shared</string>
</array>
</dict>
</plist>

View file

@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIconFile</key>
<string></string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.omnivore</string>
<key>CFBundleURLSchemes</key>
<array>
<string>omnivore</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.utilities</string>
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
</dict>
</plist>

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
</dict>
</plist>

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
</dict>
</plist>

View file

@ -0,0 +1,73 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Omnivore</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.omnivore</string>
<key>CFBundleURLSchemes</key>
<array>
<string>omnivore</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>FirebaseAppDelegateProxyEnabled</key>
<false/>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSCameraUsageDescription</key>
<string>Pictures captured with the camera can be added to the document as image annotations.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Recording sound annotations requires the microphone.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>Images from documents and annotations can be saved to the photo library.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Images from your photo library can be chosen by you to send as feedback.</string>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>LaunchScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>BNDL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
</dict>
</plist>

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.Safari.web-extension</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).SafariWebExtensionHandler</string>
</dict>
</dict>
</plist>

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.Safari.web-extension</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).SafariWebExtensionHandler</string>
</dict>
</dict>
</plist>

View file

@ -0,0 +1,54 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Omnivore</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>NSExtensionActivationRule</key>
<string>
SUBQUERY (
extensionItems,
$extensionItem,
SUBQUERY (
$extensionItem.attachments,
$attachment,
ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "com.adobe.pdf"
|| ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.url"
|| ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.file-url"
|| ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.text"
|| ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.plain-text"
).@count &gt; 0
).@count &gt;= 1
</string>
<key>NSExtensionActivationUsesStrictMatching</key>
<integer>2</integer>
<key>NSExtensionJavaScriptPreprocessingFile</key>
<string>ShareExtension</string>
</dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.share-services</string>
<key>NSExtensionPrincipalClass</key>
<string>ShareExtensionViewController</string>
</dict>
</dict>
</plist>

View file

@ -0,0 +1,52 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Omnivore</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>NSExtensionActivationRule</key>
<dict>
<key>NSExtensionActivationSupportsFileWithMaxCount</key>
<integer>1</integer>
<key>NSExtensionActivationSupportsWebPageWithMaxCount</key>
<integer>1</integer>
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
<integer>1</integer>
</dict>
<key>NSExtensionJavaScriptPreprocessingFile</key>
<string>ShareExtension</string>
</dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.share-services</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
</dict>
</dict>
</plist>

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
</dict>
</plist>

10
apple/Makefile Normal file
View file

@ -0,0 +1,10 @@
open:
open Omnivore.xcworkspace
graphql_gen:
scripts/graphql_gen.sh
extension_gen:
pushd ../pkg/extension/ && yarn build-prod && popd
cp -r ../pkg/extension/dist/* Sources/SafariExtension/Resources/.
cat ../pkg/extension/dist/manifest.json | jq '.background.persistent = false' > Sources/SafariExtension/Resources/manifest.json

View file

@ -0,0 +1,40 @@
{
"configurations" : [
{
"id" : "00A0264D-0F05-4754-86EC-F9739B63A279",
"name" : "Configuration 1",
"options" : {
}
}
],
"defaultOptions" : {
"codeCoverage" : false,
"targetForVariableExpansion" : {
"containerPath" : "container:Omnivore.xcodeproj",
"identifier" : "048ECFFB26A0B1CB00469E57",
"name" : "Omnivore"
}
},
"testTargets" : [
{
"parallelizable" : true,
"target" : {
"containerPath" : "container:Omnivore.xcodeproj",
"identifier" : "0411792626A22860004AE24F",
"name" : "UnitTests-MacApp"
}
},
{
"skippedTests" : [
"MacUILaunchTest\/testLaunchPerformance()"
],
"target" : {
"containerPath" : "container:Omnivore.xcodeproj",
"identifier" : "0480E71026D939BF006CAE2F",
"name" : "UITests-Mac"
}
}
],
"version" : 1
}

View file

@ -0,0 +1,40 @@
{
"configurations" : [
{
"id" : "B3384F57-C0C0-4F6C-AA64-BE1CE084907B",
"name" : "Configuration 1",
"options" : {
}
}
],
"defaultOptions" : {
"codeCoverage" : false,
"targetForVariableExpansion" : {
"containerPath" : "container:Omnivore.xcodeproj",
"identifier" : "8260E9C026EF983F8970EC05",
"name" : "Omnivore-iOS"
}
},
"testTargets" : [
{
"parallelizable" : true,
"target" : {
"containerPath" : "container:Omnivore.xcodeproj",
"identifier" : "B46CFFB2A582C337CB0AAAEF",
"name" : "OmnivoreTests-iOS"
}
},
{
"skippedTests" : [
"IosLaunchTest\/testLaunchPerformance()"
],
"target" : {
"containerPath" : "container:Omnivore.xcodeproj",
"identifier" : "042F48D826DFD10E00BF98FC",
"name" : "UITests-iOS"
}
}
],
"version" : 1
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>classNames</key>
<dict>
<key>IosLaunchTest</key>
<dict>
<key>testLaunchPerformance()</key>
<dict>
<key>com.apple.dt.XCTMetric_ApplicationLaunch-AppLaunch.duration</key>
<dict>
<key>baselineAverage</key>
<real>3.7507</real>
<key>baselineIntegrationDisplayName</key>
<string>Local Baseline</string>
</dict>
</dict>
</dict>
</dict>
</dict>
</plist>

View file

@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>runDestinationsByUUID</key>
<dict>
<key>54965375-310F-4852-A475-362908352C47</key>
<dict>
<key>localComputer</key>
<dict>
<key>busSpeedInMHz</key>
<integer>0</integer>
<key>cpuCount</key>
<integer>1</integer>
<key>cpuKind</key>
<string>Apple M1</string>
<key>cpuSpeedInMHz</key>
<integer>0</integer>
<key>logicalCPUCoresPerPackage</key>
<integer>8</integer>
<key>modelCode</key>
<string>MacBookAir10,1</string>
<key>physicalCPUCoresPerPackage</key>
<integer>8</integer>
<key>platformIdentifier</key>
<string>com.apple.platform.macosx</string>
</dict>
<key>targetArchitecture</key>
<string>arm64</string>
<key>targetDevice</key>
<dict>
<key>modelCode</key>
<string>iPhone13,1</string>
<key>platformIdentifier</key>
<string>com.apple.platform.iphonesimulator</string>
</dict>
</dict>
</dict>
</dict>
</plist>

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>classNames</key>
<dict>
<key>MacUILaunchTest</key>
<dict>
<key>testLaunchPerformance()</key>
<dict>
<key>com.apple.dt.XCTMetric_ApplicationLaunch-ApplicationLaunch.duration</key>
<dict>
<key>baselineAverage</key>
<real>2</real>
<key>baselineIntegrationDisplayName</key>
<string>Local Baseline</string>
</dict>
</dict>
</dict>
</dict>
</dict>
</plist>

View file

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>runDestinationsByUUID</key>
<dict>
<key>B88E4913-1A28-4133-9F9E-A4B706684B27</key>
<dict>
<key>localComputer</key>
<dict>
<key>busSpeedInMHz</key>
<integer>0</integer>
<key>cpuCount</key>
<integer>1</integer>
<key>cpuKind</key>
<string>Apple M1</string>
<key>cpuSpeedInMHz</key>
<integer>0</integer>
<key>logicalCPUCoresPerPackage</key>
<integer>8</integer>
<key>modelCode</key>
<string>MacBookAir10,1</string>
<key>physicalCPUCoresPerPackage</key>
<integer>8</integer>
<key>platformIdentifier</key>
<string>com.apple.platform.macosx</string>
</dict>
<key>targetArchitecture</key>
<string>arm64</string>
</dict>
</dict>
</dict>
</plist>

View file

@ -0,0 +1,104 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1300"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "048ECFFB26A0B1CB00469E57"
BuildableName = "Omnivore.app"
BlueprintName = "Omnivore"
ReferencedContainer = "container:Omnivore.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<TestPlans>
<TestPlanReference
reference = "container:Omnivore-Mac.xctestplan"
default = "YES">
</TestPlanReference>
</TestPlans>
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "0411792626A22860004AE24F"
BuildableName = "UnitTests-MacApp.xctest"
BlueprintName = "UnitTests-MacApp"
ReferencedContainer = "container:Omnivore.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "0480E71026D939BF006CAE2F"
BuildableName = "UITests-Mac.xctest"
BlueprintName = "UITests-Mac"
ReferencedContainer = "container:Omnivore.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "048ECFFB26A0B1CB00469E57"
BuildableName = "Omnivore.app"
BlueprintName = "Omnivore"
ReferencedContainer = "container:Omnivore.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "048ECFFB26A0B1CB00469E57"
BuildableName = "Omnivore.app"
BlueprintName = "Omnivore"
ReferencedContainer = "container:Omnivore.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View file

@ -0,0 +1,104 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1300"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "8260E9C026EF983F8970EC05"
BuildableName = "Omnivore.app"
BlueprintName = "Omnivore-iOS"
ReferencedContainer = "container:Omnivore.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<TestPlans>
<TestPlanReference
reference = "container:Omnivore-iOS.xctestplan"
default = "YES">
</TestPlanReference>
</TestPlans>
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "B46CFFB2A582C337CB0AAAEF"
BuildableName = "OmnivoreTests.xctest"
BlueprintName = "OmnivoreTests-iOS"
ReferencedContainer = "container:Omnivore.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "042F48D826DFD10E00BF98FC"
BuildableName = "UITests-iOS.xctest"
BlueprintName = "UITests-iOS"
ReferencedContainer = "container:Omnivore.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "8260E9C026EF983F8970EC05"
BuildableName = "Omnivore.app"
BlueprintName = "Omnivore-iOS"
ReferencedContainer = "container:Omnivore.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "8260E9C026EF983F8970EC05"
BuildableName = "Omnivore.app"
BlueprintName = "Omnivore-iOS"
ReferencedContainer = "container:Omnivore.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Omnivore.xcodeproj">
</FileRef>
</Workspace>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View file

@ -0,0 +1,205 @@
{
"object": {
"pins": [
{
"package": "abseil",
"repositoryURL": "https://github.com/firebase/abseil-cpp-SwiftPM.git",
"state": {
"branch": null,
"revision": "fffc3c2729be5747390ad02d5100291a0d9ad26a",
"version": "0.20200225.4"
}
},
{
"package": "AppAuth",
"repositoryURL": "https://github.com/openid/AppAuth-iOS.git",
"state": {
"branch": null,
"revision": "01131d68346c8ae552961c768d583c715fbe1410",
"version": "1.4.0"
}
},
{
"package": "BoringSSL-GRPC",
"repositoryURL": "https://github.com/firebase/boringssl-SwiftPM.git",
"state": {
"branch": null,
"revision": "734a8247442fde37df4364c21f6a0085b6a36728",
"version": "0.7.2"
}
},
{
"package": "Files",
"repositoryURL": "https://github.com/JohnSundell/Files",
"state": {
"branch": null,
"revision": "d273b5b7025d386feef79ef6bad7de762e106eaf",
"version": "4.2.0"
}
},
{
"package": "Firebase",
"repositoryURL": "https://github.com/firebase/firebase-ios-sdk",
"state": {
"branch": null,
"revision": "08686f04881483d2bc098b2696e674c0ba135e47",
"version": "8.10.0"
}
},
{
"package": "GoogleAppMeasurement",
"repositoryURL": "https://github.com/google/GoogleAppMeasurement.git",
"state": {
"branch": null,
"revision": "9b2f6aca5b4685c45f9f5481f19bee8e7982c538",
"version": "8.9.1"
}
},
{
"package": "GoogleDataTransport",
"repositoryURL": "https://github.com/google/GoogleDataTransport.git",
"state": {
"branch": null,
"revision": "15ccdfd25ac55b9239b82809531ff26605e7556e",
"version": "9.1.2"
}
},
{
"package": "GoogleUtilities",
"repositoryURL": "https://github.com/google/GoogleUtilities.git",
"state": {
"branch": null,
"revision": "b3bb0c5551fb3f80ca939829639ab5b093edd14f",
"version": "7.7.0"
}
},
{
"package": "gRPC",
"repositoryURL": "https://github.com/firebase/grpc-SwiftPM.git",
"state": {
"branch": null,
"revision": "fb405dd2c7901485f7e158b24e3a0a47e4efd8b5",
"version": "1.28.4"
}
},
{
"package": "GTMSessionFetcher",
"repositoryURL": "https://github.com/google/gtm-session-fetcher.git",
"state": {
"branch": null,
"revision": "bc6a19702ac76ac4e488b68148710eb815f9bc56",
"version": "1.7.0"
}
},
{
"package": "Intercom",
"repositoryURL": "https://github.com/intercom/intercom-ios",
"state": {
"branch": null,
"revision": "c782c99bf164cc38210dc98a926b67b8b61d8195",
"version": "10.3.2"
}
},
{
"package": "leveldb",
"repositoryURL": "https://github.com/firebase/leveldb.git",
"state": {
"branch": null,
"revision": "0706abcc6b0bd9cedfbb015ba840e4a780b5159b",
"version": "1.22.2"
}
},
{
"package": "nanopb",
"repositoryURL": "https://github.com/firebase/nanopb.git",
"state": {
"branch": null,
"revision": "7ee9ef9f627d85cbe1b8c4f49a3ed26eed216c77",
"version": "2.30908.0"
}
},
{
"package": "Promises",
"repositoryURL": "https://github.com/google/promises.git",
"state": {
"branch": null,
"revision": "611337c330350c9c1823ad6d671e7f936af5ee13",
"version": "2.0.0"
}
},
{
"package": "PSPDFKit",
"repositoryURL": "https://github.com/PSPDFKit/PSPDFKit-SP",
"state": {
"branch": "master",
"revision": "344c895fea62eb42c6b56e6a060d5dff73cc5bc2",
"version": null
}
},
{
"package": "swift-argument-parser",
"repositoryURL": "https://github.com/apple/swift-argument-parser",
"state": {
"branch": null,
"revision": "e1465042f195f374b94f915ba8ca49de24300a0d",
"version": "1.0.2"
}
},
{
"package": "swift-graphql",
"repositoryURL": "https://github.com/maticzav/swift-graphql",
"state": {
"branch": null,
"revision": "716973b9af4fcce8cb5dd6ac5fda80aff1458b84",
"version": "2.3.0"
}
},
{
"package": "SwiftProtobuf",
"repositoryURL": "https://github.com/apple/swift-protobuf.git",
"state": {
"branch": null,
"revision": "7e2c5f3cbbeea68e004915e3a8961e20bd11d824",
"version": "1.18.0"
}
},
{
"package": "SwiftFormat",
"repositoryURL": "https://github.com/nicklockwood/SwiftFormat",
"state": {
"branch": null,
"revision": "872e7034f54aeee3f20acf790ecc13e1383f7360",
"version": "0.48.4"
}
},
{
"package": "Introspect",
"repositoryURL": "https://github.com/siteline/SwiftUI-Introspect.git",
"state": {
"branch": null,
"revision": "2e09be8af614401bc9f87d40093ec19ce56ccaf2",
"version": "0.1.3"
}
},
{
"package": "Valet",
"repositoryURL": "https://github.com/Square/Valet",
"state": {
"branch": null,
"revision": "2bf3329055f5d71d42a12801dd69d1d770fafa5e",
"version": "4.1.2"
}
},
{
"package": "Yams",
"repositoryURL": "https://github.com/jpsim/Yams.git",
"state": {
"branch": null,
"revision": "9ff1cc9327586db4e0c8f46f064b6a82ec1566fa",
"version": "4.0.6"
}
}
]
},
"version": 1
}

5
apple/OmnivoreKit/.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
.DS_Store
/.build
/Packages
/*.xcodeproj
xcuserdata/

View file

@ -0,0 +1,53 @@
// swift-tools-version:5.5
import PackageDescription
let package = Package(
name: "OmnivoreKit",
defaultLocalization: "en",
platforms: [
.iOS(.v14),
.macOS(.v11)
],
products: [
.library(name: "Binders", targets: ["Binders"]),
.library(name: "Views", targets: ["Views"]),
.library(name: "Services", targets: ["Services"]),
.library(name: "Models", targets: ["Models"]),
.library(name: "Utils", targets: ["Utils"])
],
dependencies: [
.package(name: "AppAuth", url: "https://github.com/openid/AppAuth-iOS.git", .upToNextMajor(from: "1.4.0")),
.package(url: "https://github.com/Square/Valet", from: "4.1.2"),
.package(url: "https://github.com/maticzav/swift-graphql", from: "2.3.0"),
.package(url: "https://github.com/siteline/SwiftUI-Introspect.git", from: "0.1.3")
],
targets: [
.target(name: "Binders", dependencies: ["Views", "Services", "Models", "Utils"]),
.testTarget(name: "BindersTests", dependencies: ["Binders"]),
.target(
name: "Views",
dependencies: [
"Models",
.product(name: "Introspect", package: "SwiftUI-Introspect")
],
resources: [.process("Resources")]
),
.testTarget(name: "ViewsTests", dependencies: ["Views"]),
.target(
name: "Services",
dependencies: [
"AppAuth",
"Valet",
.product(name: "SwiftGraphQL", package: "swift-graphql"),
"Models",
"Utils"
]
),
.testTarget(name: "ServicesTests", dependencies: ["Services"]),
.target(name: "Models", dependencies: ["Utils"]),
.testTarget(name: "ModelsTests", dependencies: ["Models"]),
.target(name: "Utils", dependencies: [], resources: [.process("Resources")]),
.testTarget(name: "UtilsTests", dependencies: ["Utils"])
]
)

View file

@ -0,0 +1 @@
# OmnivoreKit

View file

@ -0,0 +1,124 @@
import Combine
import Foundation
import Models
import Services
import SwiftUI
import Utils
import Views
public extension PlatformViewController {
static func makeShareExtensionController(extensionContext: NSExtensionContext?) -> PlatformViewController {
let viewModel = ShareExtensionViewModel.make(extensionContext: extensionContext)
let rootView = ShareExtensionView(viewModel: viewModel)
let hostingController = PlatformHostingController(rootView: rootView)
#if os(iOS)
hostingController.view.layer.cornerRadius = 12
hostingController.view.layer.masksToBounds = true
hostingController.view.layer.isOpaque = false
#endif
return hostingController
}
}
extension ShareExtensionViewModel {
static func make(extensionContext: NSExtensionContext?) -> ShareExtensionViewModel {
let viewModel = ShareExtensionViewModel()
viewModel.bind(extensionContext: extensionContext)
return viewModel
}
func bind(extensionContext: NSExtensionContext?) {
performActionSubject.sink { [weak self] action in
switch action {
case let .savePage(requestID):
self?.savePage(extensionContext: extensionContext, requestId: requestID)
case let .dismissButtonTapped(reminderTime, hideUntilReminded):
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
//
// guard let reminderTime = reminderTime, let self = self else {
// extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
// return
// }
//
// let services = Services()
//
// services.dataService.createReminderPublisher(
// hideUntilReminded: hideUntilReminded,
// reminderTime: reminderTime,
// reminderItemId: .clientRequest(id: self.requestID)
// )
// .sink { _ in
// extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
// } receiveValue: { _ in
// extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
// }
// .store(in: &self.subscriptions)
//
// extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
case .copyLinkButtonTapped:
print("copy link button tapped")
case .readNowButtonTapped:
#if os(iOS)
if let application = UIApplication.value(forKeyPath: #keyPath(UIApplication.shared)) as? UIApplication {
let deepLinkUrl = NSURL(string: "omnivore://shareExtensionRequestID/\(self?.requestID ?? "")")
application.perform(NSSelectorFromString("openURL:"), with: deepLinkUrl)
}
#endif
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
// TODO: implement macos
case .archiveButtonTapped:
print("archive button tapped")
}
}
.store(in: &subscriptions)
}
private func savePage(extensionContext: NSExtensionContext?, requestId: String) {
PageScraper.scrape(extensionContext: extensionContext) { [weak self] result in
switch result {
case let .success(payload):
self?.persist(pageScrapePayload: payload, requestId: requestId)
case let .failure(error):
self?.debugText = error.message
}
}
}
private func persist(pageScrapePayload: PageScrapePayload, requestId: String) {
let services = Services()
guard services.authenticator.hasValidAuthToken else {
status = .failed(error: .unauthorized)
return
}
let saveLinkPublisher: AnyPublisher<Void, SaveArticleError> = {
if pageScrapePayload.contentType == .pdf {
return services.dataService.uploadPDFPublisher(pageScrapePayload: pageScrapePayload, requestId: requestId)
} else if pageScrapePayload.html != nil {
return services.dataService.savePagePublisher(pageScrapePayload: pageScrapePayload, requestId: requestId)
} else {
return services.dataService.saveUrlPublisher(pageScrapePayload: pageScrapePayload, requestId: requestId)
}
}()
saveLinkPublisher
.sink { [weak self] completion in
guard case let .failure(error) = completion else { return }
self?.debugText = "saveArticleError: \(error)"
self?.status = .failed(error: error)
} receiveValue: { [weak self] _ in
self?.status = .successfullySaved
}
.store(in: &subscriptions)
// Using viewerPublisher to get fast feedback for auth/network errors
services.dataService.viewerPublisher()
.sink { [weak self] completion in
guard case let .failure(error) = completion else { return }
self?.debugText = "saveArticleError: \(error)"
self?.status = .failed(error: .unknown(description: ""))
} receiveValue: { _ in }
.store(in: &subscriptions)
}
}

View file

@ -0,0 +1,153 @@
import Combine
import Foundation
import Models
import Services
public final class PDFViewerViewModel: ObservableObject {
@Published public var errorMessage: String?
@Published public var readerView: Bool = false
public var feedItem: FeedItem
var subscriptions = Set<AnyCancellable>()
let services: Services
public init(services: Services, feedItem: FeedItem) {
self.services = services
self.feedItem = feedItem
}
public func allHighlights() -> [Highlight] {
var resultSet = [String: Highlight]()
for highlight in services.dataService.cachedHighlights(pdfID: feedItem.id) {
resultSet[highlight.id] = highlight
}
for highlight in feedItem.highlights ?? [] {
resultSet[highlight.id] = highlight
}
for highlightId in services.dataService.fetchRemovedHighlightIds(pdfID: feedItem.id) {
resultSet.removeValue(forKey: highlightId)
}
return Array(resultSet.values)
}
public func createHighlight(shortId: String, highlightID: String, quote: String, patch: String) {
services.dataService.persistHighlight(
pdfID: feedItem.id,
highlight: Highlight(
id: highlightID,
shortId: shortId,
quote: quote,
prefix: nil,
suffix: nil,
patch: patch,
annotation: nil
)
)
services.dataService
.createHighlightPublisher(
shortId: shortId,
highlightID: highlightID,
quote: quote,
patch: patch,
articleId: feedItem.id
)
.sink { [weak self] completion in
guard case let .failure(error) = completion else { return }
self?.errorMessage = error.localizedDescription
} receiveValue: { value in
print("highlight value", value)
}
.store(in: &subscriptions)
}
public func mergeHighlight(
shortId: String,
highlightID: String,
quote: String,
patch: String,
overlapHighlightIdList: [String]
) {
services.dataService.persistHighlight(
pdfID: feedItem.id,
highlight: Highlight(
id: highlightID,
shortId: shortId,
quote: quote,
prefix: nil,
suffix: nil,
patch: patch,
annotation: nil
)
)
removeLocalHighlights(highlightIds: overlapHighlightIdList)
services.dataService
.mergeHighlightPublisher(
shortId: shortId,
highlightID: highlightID,
quote: quote,
patch: patch,
articleId: feedItem.id,
overlapHighlightIdList: overlapHighlightIdList
)
.sink { [weak self] completion in
guard case let .failure(error) = completion else { return }
self?.errorMessage = error.localizedDescription
} receiveValue: { value in
print("highlight value", value)
}
.store(in: &subscriptions)
}
public func removeHighlights(highlightIds: [String]) {
removeLocalHighlights(highlightIds: highlightIds)
highlightIds.forEach { highlightId in
services.dataService.deleteHighlightPublisher(highlightId: highlightId)
.sink { [weak self] completion in
guard case let .failure(error) = completion else { return }
self?.errorMessage = error.localizedDescription
} receiveValue: { value in
print("remove highlight value", value)
}
.store(in: &subscriptions)
}
}
public func updateItemReadProgress(percent: Double, anchorIndex: Int) {
services.dataService
.updateArticleReadingProgressPublisher(
itemID: feedItem.id,
readingProgress: percent,
anchorIndex: anchorIndex
)
.sink { completion in
guard case let .failure(error) = completion else { return }
print(error)
} receiveValue: { _ in
}
.store(in: &subscriptions)
}
public func highlightShareURL(shortId: String) -> URL? {
let baseURL = services.dataService.appEnvironment.serverBaseURL
var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)
if let viewer = services.dataService.currentViewer?.username {
components?.path = "/\(viewer)/\(feedItem.slug)/highlights/\(shortId)"
} else {
return nil
}
return components?.url
}
private func removeLocalHighlights(highlightIds: [String]) {
feedItem.highlights?.removeAll { highlightIds.contains($0.id) }
services.dataService.removeHighlights(pdfID: feedItem.id, highlightIds: highlightIds)
}
}

View file

@ -0,0 +1,279 @@
import Combine
import Foundation
import Models
import Services
import SwiftUI
import Utils
import Views
#if os(iOS)
let isMacApp = false
#elseif os(macOS)
let isMacApp = true
#endif
public final class RootViewModel: ObservableObject {
let services = Services()
@Published public var showPushNotificationPrimer = false
@Published fileprivate var webLinkPath: SafariWebLinkPath?
@Published fileprivate var snackbarMessage: String?
@Published fileprivate var showSnackbar = false
public enum Action {}
public var subscriptions = Set<AnyCancellable>()
public let performActionSubject = PassthroughSubject<Action, Never>()
public init() {
registerFonts()
}
func updateWaitlistStatus() {
services.dataService.viewerPublisher().sink(
receiveCompletion: { completion in
guard case let .failure(error) = completion else { return }
print(error)
},
receiveValue: { [weak self] viewer in
guard let self = self else { return }
let isWaitlisted = viewer.isWaitlisted
self.services.authenticator.updateWaitlistStatus(isWaitlistedUser: isWaitlisted)
}
)
.store(in: &subscriptions)
}
func configurePDFProvider(pdfViewerProvider: @escaping (URL, PDFViewerViewModel) -> AnyView) {
PDFProvider.pdfViewerProvider = { [weak self] url, feedItem in
guard let self = self else { return AnyView(Text("")) }
return pdfViewerProvider(url, PDFViewerViewModel(services: self.services, feedItem: feedItem))
}
}
func webAppWrapperViewModel(webLinkPath: String) -> WebAppWrapperViewModel {
let baseURL = services.dataService.appEnvironment.webAppBaseURL
let urlRequest = URLRequest.webRequest(
baseURL: services.dataService.appEnvironment.webAppBaseURL,
urlPath: webLinkPath,
queryParams: ["isAppEmbedView": "true", "highlightBarDisabled": isMacApp ? "false" : "true"]
)
return WebAppWrapperViewModel(
webViewURLRequest: urlRequest,
baseURL: baseURL,
rawAuthCookie: services.authenticator.omnivoreAuthCookieString
)
}
func onOpenURL(url: URL) {
guard let linkRequestID = DeepLink.make(from: url)?.linkRequestID else { return }
if let username = services.dataService.currentViewer?.username {
let path = linkRequestPath(username: username, requestID: linkRequestID)
webLinkPath = SafariWebLinkPath(id: UUID(), path: path)
return
}
services.dataService.viewerPublisher().sink(
receiveCompletion: { completion in
guard case let .failure(error) = completion else { return }
print(error)
},
receiveValue: { [weak self] viewer in
let path = self?.linkRequestPath(username: viewer.username, requestID: linkRequestID) ?? ""
self?.webLinkPath = SafariWebLinkPath(id: UUID(), path: path)
}
)
.store(in: &subscriptions)
}
func triggerPushNotificationRequestIfNeeded() {
guard FeatureFlag.enablePushNotifications else { return }
if UserDefaults.standard.bool(forKey: UserDefaultKey.userHasDeniedPushPrimer.rawValue) {
return
}
#if os(iOS)
UNUserNotificationCenter.current().getNotificationSettings { [weak self] settings in
switch settings.authorizationStatus {
case .notDetermined:
DispatchQueue.main.async {
self?.showPushNotificationPrimer = true
}
case .authorized, .provisional, .ephemeral, .denied:
return
@unknown default:
return
}
}
#endif
}
#if os(iOS)
func handlePushNotificationPrimerAcceptance() {
showPushNotificationPrimer = false
UNUserNotificationCenter.current().requestAuth()
}
#endif
private func linkRequestPath(username: String, requestID: String) -> String {
"/app/\(username)/link-request/\(requestID)"
}
}
private struct SafariWebLinkPath: Identifiable {
let id: UUID
let path: String
}
public struct RootView: View {
@ObservedObject private var viewModel: RootViewModel
@ObservedObject private var authenticator: Authenticator
private var primaryViewModel: PrimaryContentViewModel
public init(
pdfViewerProvider: ((URL, PDFViewerViewModel) -> AnyView)?,
intercomProvider: IntercomProvider?
) {
let rootViewModel = RootViewModel()
self.viewModel = rootViewModel
self.authenticator = rootViewModel.services.authenticator
self.primaryViewModel = PrimaryContentViewModel.make(services: rootViewModel.services)
#if DEBUG
if CommandLine.arguments.contains("--uitesting") {
authenticator.logout()
}
#endif
if let pdfViewerProvider = pdfViewerProvider {
viewModel.configurePDFProvider(pdfViewerProvider: pdfViewerProvider)
}
if let intercomProvider = intercomProvider {
DataService.showIntercomMessenger = intercomProvider.showIntercomMessenger
DataService.registerIntercomUser = intercomProvider.registerIntercomUser
Authenticator.unregisterIntercomUser = intercomProvider.unregisterIntercomUser
}
}
@ViewBuilder private var innerBody: some View {
if authenticator.isLoggedIn {
if authenticator.isWaitlisted {
WaitlistView(viewModel: WaitlistViewModel.make(services: viewModel.services))
} else {
PrimaryContentView(viewModel: primaryViewModel)
.onAppear {
viewModel.updateWaitlistStatus()
viewModel.triggerPushNotificationRequestIfNeeded()
}
#if os(iOS)
.fullScreenCover(item: $viewModel.webLinkPath, content: { safariLinkPath in
NavigationView {
FullScreenWebAppView(
viewModel: viewModel.webAppWrapperViewModel(webLinkPath: safariLinkPath.path),
handleClose: { viewModel.webLinkPath = nil }
)
}
})
#endif
.snackBar(
isShowing: $viewModel.showSnackbar,
text: Text(viewModel.snackbarMessage ?? "")
)
#if os(iOS)
.customAlert(isPresented: $viewModel.showPushNotificationPrimer) {
pushNotificationPrimerView
}
#endif
}
} else {
WelcomeView(viewModel: WelcomeViewModel.make(services: viewModel.services))
.accessibilityElement()
.accessibilityIdentifier("welcomeView")
}
}
public var body: some View {
Group {
#if os(iOS)
innerBody
#elseif os(macOS)
innerBody
.frame(minWidth: 400, idealWidth: 1200, minHeight: 400, idealHeight: 1200)
#endif
}
#if os(iOS)
.onOpenURL { url in
withoutAnimation {
if viewModel.webLinkPath != nil {
viewModel.webLinkPath = nil
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
viewModel.onOpenURL(url: url)
}
} else {
viewModel.onOpenURL(url: url)
}
}
}
.onReceive(NSNotification.operationSuccessPublisher) { notification in
if let message = notification.userInfo?["message"] as? String {
viewModel.showSnackbar = true
viewModel.snackbarMessage = message
}
}
.onReceive(NSNotification.operationFailedPublisher) { notification in
if let message = notification.userInfo?["message"] as? String {
viewModel.showSnackbar = true
viewModel.snackbarMessage = message
}
}
#endif
}
#if os(iOS)
private var pushNotificationPrimerView: PushNotificationPrimer {
PushNotificationPrimer(
acceptAction: { viewModel.handlePushNotificationPrimerAcceptance() },
denyAction: {
UserDefaults.standard.set(true, forKey: UserDefaultKey.userHasDeniedPushPrimer.rawValue)
viewModel.showPushNotificationPrimer = false
}
)
}
#endif
}
public struct IntercomProvider {
public init(
registerIntercomUser: @escaping (String) -> Void,
unregisterIntercomUser: @escaping () -> Void,
showIntercomMessenger: @escaping () -> Void
) {
self.registerIntercomUser = registerIntercomUser
self.unregisterIntercomUser = unregisterIntercomUser
self.showIntercomMessenger = showIntercomMessenger
}
public let registerIntercomUser: (String) -> Void
public let unregisterIntercomUser: () -> Void
public let showIntercomMessenger: () -> Void
}
#if os(iOS)
// Allows us to present a sheet without animation
// Used to configure full screen modal view coming from share extension read now button action
private extension View {
func withoutAnimation(_ completion: @escaping () -> Void) {
UIView.setAnimationsEnabled(false)
completion()
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(200)) {
UIView.setAnimationsEnabled(true)
}
}
}
#endif

View file

@ -0,0 +1,68 @@
import Models
import Services
import SwiftUI
import Utils
import Views
extension CreateProfileViewModel {
static func make(services: Services, pendingUserProfile: UserProfile) -> CreateProfileViewModel {
let viewModel = CreateProfileViewModel(initialUserProfile: pendingUserProfile)
viewModel.bind(services: services)
return viewModel
}
func bind(services: Services) {
performActionSubject.sink { [weak self] action in
switch action {
case let .submitProfile(userProfile):
self?.submitProfile(userProfile: userProfile, authenticator: services.authenticator)
case let .validateUsername(username: username):
self?.validateUsername(username: username, dataService: services.dataService)
}
}
.store(in: &subscriptions)
}
private func validateUsername(username: String, dataService: DataService) {
if let status = PotentialUsernameStatus.validationError(username: username.lowercased()) {
potentialUsernameStatus = status
return
}
dataService.validateUsernamePublisher(username: username).sink(
receiveCompletion: { [weak self] completion in
guard case let .failure(usernameError) = completion else { return }
switch usernameError {
case .tooShort:
self?.potentialUsernameStatus = .tooShort
case .tooLong:
self?.potentialUsernameStatus = .tooLong
case .invalidPattern:
self?.potentialUsernameStatus = .invalidPattern
case .nameUnavailable:
self?.potentialUsernameStatus = .unavailable
case .internalServer, .unknown:
self?.loginError = .unknown
case .network:
self?.loginError = .network
}
},
receiveValue: { [weak self] in
self?.potentialUsernameStatus = .available
}
)
.store(in: &subscriptions)
}
private func submitProfile(userProfile: UserProfile, authenticator: Authenticator) {
authenticator
.createAccount(userProfile: userProfile).sink(
receiveCompletion: { [weak self] completion in
guard case let .failure(loginError) = completion else { return }
self?.loginError = loginError
},
receiveValue: { _ in }
)
.store(in: &subscriptions)
}
}

View file

@ -0,0 +1,30 @@
import Services
import SwiftUI
import Views
extension DebugMenuViewModel {
static func make(services: Services) -> DebugMenuViewModel {
let viewModel = DebugMenuViewModel(initialEnvironment: services.dataService.appEnvironment)
viewModel.bind(services: services)
return viewModel
}
func bind(services: Services) {
performActionSubject.sink { action in
switch action {
case let .applyChanges(environment):
switch environment {
case .dev:
services.switchAppEnvironment(to: .dev)
case .demo:
services.switchAppEnvironment(to: .demo)
case .prod:
services.switchAppEnvironment(to: .prod)
case .local:
services.switchAppEnvironment(to: .local)
}
}
}
.store(in: &subscriptions)
}
}

View file

@ -0,0 +1,191 @@
import Models
import Services
import SwiftUI
import UserNotifications
import Utils
import Views
extension HomeFeedViewModel {
static func make(services: Services) -> HomeFeedViewModel {
let viewModel = HomeFeedViewModel { feedItem in
LinkItemDetailViewModel.make(feedItem: feedItem, services: services)
}
viewModel.bind(services: services)
viewModel.loadItems(dataService: services.dataService, searchQuery: nil)
return viewModel
}
func bind(services: Services) {
performActionSubject.sink { [weak self] action in
switch action {
case let .loadItems(query):
self?.loadItems(dataService: services.dataService, searchQuery: query)
case let .archive(linkId):
self?.setLinkArchived(dataService: services.dataService, linkId: linkId, archived: true)
case let .unarchive(linkId):
self?.setLinkArchived(dataService: services.dataService, linkId: linkId, archived: false)
case let .remove(linkId):
self?.removeLink(dataService: services.dataService, linkId: linkId)
case let .snooze(linkId, until, successMessage):
self?.snoozeUntil(
dataService: services.dataService,
linkId: linkId,
until: until,
successMessage: successMessage
)
}
}
.store(in: &subscriptions)
}
private func loadItems(dataService: DataService, searchQuery: String?) {
// Clear offline highlights since we'll be populating new FeedItems with the correct highlights set
dataService.clearHighlights()
let thisSearchIdx = searchIdx
searchIdx += 1
isLoading = true
startNetworkActivityIndicator()
// Cache the viewer
if dataService.currentViewer == nil {
dataService.viewerPublisher().sink(
receiveCompletion: { _ in },
receiveValue: { _ in }
)
.store(in: &subscriptions)
}
dataService.libraryItemsPublisher(
limit: 100,
sortDescending: true,
searchQuery: searchQuery,
cursor: nil
)
.sink(
receiveCompletion: { [weak self] completion in
guard case let .failure(error) = completion else { return }
self?.isLoading = false
stopNetworkActivityIndicator()
print(error)
},
receiveValue: { [weak self] result in
// Search results aren't guaranteed to return in order so this
// will discard old results that are returned while a user is typing.
// For example if a user types 'Canucks', often the search results
// for 'C' are returned after 'Canucks' because it takes the backend
// much longer to compute.
if thisSearchIdx > 0, thisSearchIdx <= self?.receivedIdx ?? 0 {
return
}
self?.items = result.items
self?.isLoading = false
self?.receivedIdx = thisSearchIdx
stopNetworkActivityIndicator()
}
)
.store(in: &subscriptions)
}
private func setLinkArchived(dataService: DataService, linkId: String, archived: Bool) {
isLoading = true
startNetworkActivityIndicator()
// First remove the link from the internal list,
// then make a call to remove it. The isLoading block should
// prevent our local change from being overwritten, but we
// might need to cache a local list of archived links
if let itemIndex = items.firstIndex(where: { $0.id == linkId }) {
items.remove(at: itemIndex)
}
dataService.archiveLinkPublisher(itemID: linkId, archived: archived)
.sink(
receiveCompletion: { [weak self] completion in
guard case let .failure(error) = completion else { return }
self?.isLoading = false
stopNetworkActivityIndicator()
print(error)
NSNotification.operationFailed(message: archived ? "Failed to archive link" : "Failed to unarchive link")
},
receiveValue: { [weak self] _ in
self?.isLoading = false
stopNetworkActivityIndicator()
NSNotification.operationSuccess(message: archived ? "Link archived" : "Link moved to Inbox")
}
)
.store(in: &subscriptions)
}
private func removeLink(dataService: DataService, linkId: String) {
isLoading = true
startNetworkActivityIndicator()
if let itemIndex = items.firstIndex(where: { $0.id == linkId }) {
items.remove(at: itemIndex)
}
dataService.removeLinkPublisher(itemID: linkId)
.sink(
receiveCompletion: { [weak self] completion in
guard case let .failure(error) = completion else { return }
self?.isLoading = false
stopNetworkActivityIndicator()
print(error)
NSNotification.operationFailed(message: "Failed to remove link")
},
receiveValue: { [weak self] _ in
self?.isLoading = false
stopNetworkActivityIndicator()
NSNotification.operationSuccess(message: "Link removed")
}
)
.store(in: &subscriptions)
}
private func snoozeUntil(dataService: DataService, linkId: String, until: Date, successMessage: String?) {
isLoading = true
startNetworkActivityIndicator()
if let itemIndex = items.firstIndex(where: { $0.id == linkId }) {
items.remove(at: itemIndex)
}
dataService.createReminderPublisher(
reminderItemId: .link(id: linkId),
remindAt: until
)
.sink(
receiveCompletion: { [weak self] completion in
guard case let .failure(error) = completion else { return }
self?.isLoading = false
stopNetworkActivityIndicator()
print(error)
NSNotification.operationFailed(message: "Failed to snooze")
},
receiveValue: { [weak self] _ in
self?.isLoading = false
stopNetworkActivityIndicator()
if let message = successMessage {
NSNotification.operationSuccess(message: message)
}
}
)
.store(in: &subscriptions)
}
}
// TODO: replace these deprecated calls with a progress bar or custom spinner
private func startNetworkActivityIndicator() {
#if os(iOS)
UIApplication.shared.isNetworkActivityIndicatorVisible = true
#endif
}
private func stopNetworkActivityIndicator() {
#if os(iOS)
UIApplication.shared.isNetworkActivityIndicatorVisible = false
#endif
}

View file

@ -0,0 +1,86 @@
import Models
import Services
import SwiftUI
import Utils
import Views
extension LinkItemDetailViewModel {
static func make(feedItem: FeedItem, services: Services) -> LinkItemDetailViewModel {
let viewModel = LinkItemDetailViewModel(item: feedItem)
viewModel.bind(services: services)
return viewModel
}
func bind(services: Services) {
performActionSubject.sink { [weak self] action in
switch action {
case .load:
self?.loadWebAppWrapper(services: services)
case let .updateReadStatus(markAsRead: markAsRead):
self?.updateItemReadStatus(markAsRead: markAsRead, dataService: services.dataService)
}
}
.store(in: &subscriptions)
}
private func updateItemReadStatus(markAsRead: Bool, dataService: DataService) {
dataService
.updateArticleReadingProgressPublisher(
itemID: item.id,
readingProgress: markAsRead ? 100 : 0,
anchorIndex: 0
)
.sink { completion in
guard case let .failure(error) = completion else { return }
print(error)
} receiveValue: { [weak self] feedItem in
self?.item.readingProgress = feedItem.readingProgress
}
.store(in: &subscriptions)
}
private func loadWebAppWrapper(services: Services) {
// Attempt to get `Viewer` from DataService
if let currentViewer = services.dataService.currentViewer {
createWebAppWrapperViewModel(username: currentViewer.username, services: services)
return
}
services.dataService.viewerPublisher().sink(
receiveCompletion: { completion in
guard case let .failure(error) = completion else { return }
print(error)
},
receiveValue: { [weak self] viewer in
self?.createWebAppWrapperViewModel(username: viewer.username, services: services)
}
)
.store(in: &subscriptions)
}
private func createWebAppWrapperViewModel(username: String, services: Services) {
let baseURL = services.dataService.appEnvironment.webAppBaseURL
let urlRequest = URLRequest.webRequest(
baseURL: services.dataService.appEnvironment.webAppBaseURL,
urlPath: "/app/\(username)/\(item.slug)",
queryParams: ["isAppEmbedView": "true", "highlightBarDisabled": isMacApp ? "false" : "true"]
)
let newWebAppWrapperViewModel = WebAppWrapperViewModel(
webViewURLRequest: urlRequest,
baseURL: baseURL,
rawAuthCookie: services.authenticator.omnivoreAuthCookieString
)
newWebAppWrapperViewModel.performActionSubject.sink { action in
switch action {
case let .shareHighlight(highlightID):
print("show share modal for highlight with id: \(highlightID)")
}
}
.store(in: &newWebAppWrapperViewModel.subscriptions)
webAppWrapperViewModel = newWebAppWrapperViewModel
}
}

View file

@ -0,0 +1,41 @@
import Models
import Services
import SwiftUI
import Utils
import Views
extension NewAppleSignupViewModel {
static func make(
services: Services,
userProfile: UserProfile,
showProfileEditView: @escaping () -> Void
) -> NewAppleSignupViewModel {
let viewModel = NewAppleSignupViewModel(userProfile: userProfile)
viewModel.bind(services: services, showProfileEditView: showProfileEditView)
return viewModel
}
func bind(services: Services, showProfileEditView: @escaping () -> Void) {
performActionSubject.sink { [weak self] action in
switch action {
case let .acceptProfile(userProfile: userProfile):
self?.submitProfile(userProfile: userProfile, authenticator: services.authenticator)
case .changeProfile:
showProfileEditView()
}
}
.store(in: &subscriptions)
}
private func submitProfile(userProfile: UserProfile, authenticator: Authenticator) {
authenticator
.createAccount(userProfile: userProfile).sink(
receiveCompletion: { [weak self] completion in
guard case let .failure(loginError) = completion else { return }
self?.loginError = loginError
},
receiveValue: { _ in }
)
.store(in: &subscriptions)
}
}

View file

@ -0,0 +1,24 @@
import Services
import SwiftUI
import Views
extension PrimaryContentViewModel {
static func make(services: Services) -> PrimaryContentViewModel {
let viewModel = PrimaryContentViewModel(
homeFeedViewModel: HomeFeedViewModel.make(services: services),
profileContainerViewModel: ProfileContainerViewModel.make(services: services)
)
viewModel.bind(services: services)
return viewModel
}
func bind(services _: Services) {
performActionSubject.sink { action in
switch action {
case .nothing:
break
}
}
.store(in: &subscriptions)
}
}

View file

@ -0,0 +1,41 @@
import Services
import SwiftUI
import Views
extension ProfileContainerViewModel {
static func make(services: Services) -> ProfileContainerViewModel {
let viewModel = ProfileContainerViewModel()
viewModel.bind(services: services)
return viewModel
}
func bind(services: Services) {
performActionSubject.sink { [weak self] action in
switch action {
case .logout:
services.authenticator.logout()
case .loadProfileData:
self?.loadProfileData(dataService: services.dataService)
case .showIntercomMessenger:
DataService.showIntercomMessenger?()
case .deleteAccount:
print("delete account")
}
}
.store(in: &subscriptions)
}
private func loadProfileData(dataService: DataService) {
dataService.viewerPublisher().sink(
receiveCompletion: { _ in },
receiveValue: { [weak self] viewer in
self?.profileCardData = ProfileCardData(
name: viewer.name,
username: viewer.username,
imageURL: viewer.profileImageURL.flatMap { URL(string: $0) }
)
}
)
.store(in: &subscriptions)
}
}

View file

@ -0,0 +1,116 @@
import AuthenticationServices
import Models
import Services
import SwiftUI
import Utils
import Views
extension RegistrationViewModel {
static func make(services: Services) -> RegistrationViewModel {
let viewModel = RegistrationViewModel()
viewModel.bind(services: services)
return viewModel
}
func bind(services: Services) {
performActionSubject.sink { [weak self] action in
self?.loginError = nil
switch action {
case .googleButtonTapped:
self?.handleGoogleAuth(services: services)
case let .appleSignInCompleted(result: result):
switch AppleSigninPayload.parse(authResult: result) {
case let .success(payload):
self?.handleAppleToken(payload: payload, services: services)
case let .failure(error):
switch error {
case .unauthorized, .unknown:
break
case .network:
self?.loginError = error
}
}
}
}
.store(in: &subscriptions)
}
private func handleAppleToken(payload: AppleSigninPayload, services: Services) {
services.authenticator.submitAppleToken(token: payload.token).sink(
receiveCompletion: { [weak self] completion in
guard case let .failure(loginError) = completion else { return }
switch loginError {
case .unauthorized, .unknown:
self?.handleAppleSignUp(services: services, payload: payload)
case .network:
self?.loginError = loginError
}
},
receiveValue: { _ in }
)
.store(in: &subscriptions)
}
private func handleAppleSignUp(services: Services, payload: AppleSigninPayload) {
services.authenticator
.createPendingAccountUsingApple(token: payload.token, name: payload.fullName)
.sink(
receiveCompletion: { [weak self] completion in
guard case let .failure(loginError) = completion else { return }
self?.loginError = loginError
},
receiveValue: { [weak self] userProfile in
if userProfile.name.isEmpty {
self?.showProfileEditView(services: services, pendingUserProfile: userProfile)
} else {
self?.newAppleSignupViewModel = NewAppleSignupViewModel.make(
services: services,
userProfile: userProfile,
showProfileEditView: {
self?.showProfileEditView(services: services, pendingUserProfile: userProfile)
}
)
}
}
)
.store(in: &subscriptions)
}
private func handleGoogleAuth(services: Services) {
services.authenticator
.handleGoogleAuth(presentingViewController: presentingViewController())
.sink(
receiveCompletion: { [weak self] completion in
guard case let .failure(loginError) = completion else { return }
self?.loginError = loginError
},
receiveValue: { [weak self] isNewAccount in
if isNewAccount {
let pendingUserProfile = UserProfile(username: "", name: "")
self?.showProfileEditView(services: services, pendingUserProfile: pendingUserProfile)
}
}
)
.store(in: &subscriptions)
}
func showProfileEditView(services: Services, pendingUserProfile: UserProfile) {
createProfileViewModel = CreateProfileViewModel.make(
services: services,
pendingUserProfile: pendingUserProfile
)
newAppleSignupViewModel = nil
}
}
private func presentingViewController() -> PlatformViewController? {
#if os(iOS)
return UIApplication.shared.windows
.filter(\.isKeyWindow)
.first?
.rootViewController
#elseif os(macOS)
return nil
#endif
}

View file

@ -0,0 +1,39 @@
import Models
import Services
import SwiftUI
import Utils
import Views
extension WaitlistViewModel {
static func make(services: Services) -> WaitlistViewModel {
let viewModel = WaitlistViewModel()
viewModel.bind(services: services)
return viewModel
}
func bind(services: Services) {
performActionSubject.sink { [weak self] action in
switch action {
case .logout:
services.authenticator.logout()
case .checkStatus:
self?.updateWaitlistStatus(services: services)
}
}
.store(in: &subscriptions)
}
private func updateWaitlistStatus(services: Services) {
services.dataService.viewerPublisher().sink(
receiveCompletion: { completion in
guard case let .failure(error) = completion else { return }
print(error)
},
receiveValue: { viewer in
let isWaitlisted = viewer.isWaitlisted
services.authenticator.updateWaitlistStatus(isWaitlistedUser: isWaitlisted)
}
)
.store(in: &subscriptions)
}
}

View file

@ -0,0 +1,26 @@
import Services
import SwiftUI
import Utils
import Views
extension WelcomeViewModel {
static func make(services: Services) -> WelcomeViewModel {
let registrationViewModel = RegistrationViewModel.make(services: services)
let viewModel = WelcomeViewModel(registrationViewModel: registrationViewModel)
viewModel.bind(services: services)
return viewModel
}
func bind(services: Services) {
performActionSubject.sink { [weak self] action in
switch action {
case .hiddenGesturePerformed:
if !Bundle.main.isAppStoreBuild {
self?.debugMenuViewModel = DebugMenuViewModel.make(services: services)
self?.showDebugModal = true
}
}
}
.store(in: &subscriptions)
}
}

View file

@ -0,0 +1,19 @@
import Foundation
import Models
import Services
public final class Services {
public let authenticator: Authenticator
public let dataService: DataService
public init(appEnvironment: AppEnvironment = PublicValet.storedAppEnvironment ?? .initialAppEnvironment) {
let networker = Networker(appEnvironment: appEnvironment)
self.authenticator = Authenticator(networker: networker)
self.dataService = DataService(appEnvironment: appEnvironment, networker: networker)
}
public func switchAppEnvironment(to appEnvironment: AppEnvironment) {
authenticator.logout()
dataService.switchAppEnvironment(appEnvironment: appEnvironment)
}
}

View file

@ -0,0 +1,48 @@
#if os(iOS)
import UIKit
public extension UIViewController {
func embed(childViewController child: UIViewController, heightRatio: CGFloat? = nil) {
addChild(child)
child.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(child.view)
var constraints = [
child.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
child.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
child.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
]
if let heightRatio = heightRatio {
constraints.append(child.view.heightAnchor.constraint(equalTo: view.heightAnchor, multiplier: heightRatio))
} else {
constraints.append(child.view.topAnchor.constraint(equalTo: view.topAnchor))
}
NSLayoutConstraint.activate(constraints)
child.didMove(toParent: self)
}
}
#endif
#if os(macOS)
import AppKit
public extension NSViewController {
func embed(childViewController child: NSViewController) {
addChild(child)
child.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(child.view)
NSLayoutConstraint.activate([
child.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
child.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
child.view.topAnchor.constraint(equalTo: view.topAnchor),
child.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
}
}
#endif

View file

@ -0,0 +1,62 @@
import Foundation
import Utils
public enum AppEnvironment: String {
case local
case dev
case prod
case demo
case test
public static let initialAppEnvironment: AppEnvironment = {
#if DEBUG
#if targetEnvironment(simulator)
return .demo // could also return .local here
#else
return .demo
#endif
#else
return .prod
#endif
}()
}
private let devBaseURL = "https://api-dev.omnivore.app"
private let demoBaseURL = "https://api-demo.omnivore.app"
private let prodBaseURL = "https://api-prod.omnivore.app"
private let devWebURL = "https://web-dev.omnivore.app"
private let demoWebURL = "https://demo.omnivore.app"
private let prodWebURL = "https://web-prod.omnivore.app"
public extension AppEnvironment {
var graphqlPath: String {
"\(serverBaseURL.absoluteString)/api/graphql"
}
var serverBaseURL: URL {
switch self {
case .dev:
return URL(string: devBaseURL)!
case .demo:
return URL(string: demoBaseURL)!
case .prod:
return URL(string: prodBaseURL)!
case .test, .local:
return URL(string: "http://localhost:4000")!
}
}
var webAppBaseURL: URL {
switch self {
case .dev:
return URL(string: devWebURL)!
case .demo:
return URL(string: demoWebURL)!
case .prod:
return URL(string: prodWebURL)!
case .test, .local:
return URL(string: "http://localhost:3000")!
}
}
}

View file

@ -0,0 +1,48 @@
import AuthenticationServices
import Foundation
public struct AppleSigninPayload {
public let token: String
public let email: String?
public let appleUserId: String
public let fullName: PersonNameComponents?
static func make(from authValue: ASAuthorization) -> AppleSigninPayload? {
let credential = authValue.credential as? ASAuthorizationAppleIDCredential
let jwt = credential?.identityToken.flatMap { String(data: $0, encoding: .utf8) }
guard let jwt = jwt, let userId = credential?.user else { return nil }
return AppleSigninPayload(
token: jwt,
email: credential?.email,
appleUserId: userId,
fullName: credential?.fullName
)
}
public static func parse(authResult: Result<ASAuthorization, Error>) -> Result<AppleSigninPayload, LoginError> {
switch authResult {
case let .success(authValue):
if let payload = AppleSigninPayload.make(from: authValue) {
return Result.success(payload)
} else {
return Result.failure(.unknown)
}
case let .failure(error):
return Result.failure(LoginError.make(from: error))
}
}
}
private extension LoginError {
static func make(from authError: Error) -> LoginError {
guard let error = authError as? ASAuthorizationError else { return .unknown }
switch error.code {
case .invalidResponse, .failed:
return .unauthorized
default:
return .unknown
}
}
}

View file

@ -0,0 +1,35 @@
import Foundation
public enum DeepLink {
case webAppLinkRequest(requestID: String)
}
public extension DeepLink {
var linkRequestID: String? {
if case let DeepLink.webAppLinkRequest(requestID) = self {
return requestID
}
return nil
}
static func make(from url: URL) -> DeepLink? {
if url.scheme == "omnivore" {
return deepLinkFromOmnivoreScheme(url: url)
}
return nil
}
private static func deepLinkFromOmnivoreScheme(url: URL) -> DeepLink? {
switch url.host {
case "shareExtensionRequestID":
let requestID = url.path.replacingOccurrences(of: "/", with: "")
return .webAppLinkRequest(requestID: requestID)
default:
return nil
}
}
}
// Example deep links
// "omnivore://shareExtensionRequestID/sampleRequestID"

View file

@ -0,0 +1,5 @@
import Foundation
public struct EmptyResponse {
public init() {}
}

View file

@ -0,0 +1,5 @@
import Foundation
public enum BasicError: Error {
case message(messageText: String)
}

View file

@ -0,0 +1,6 @@
import Foundation
public enum DocumentSaveError: Error {
case saveFailed
case nonPDFItem
}

View file

@ -0,0 +1,7 @@
import Foundation
public enum LoginError: Error {
case unauthorized
case network
case unknown
}

View file

@ -0,0 +1,8 @@
import Foundation
public enum SaveArticleError: Error {
case unauthorized
case network
case badData
case unknown(description: String)
}

View file

@ -0,0 +1,7 @@
import Foundation
public enum SaveError: Error {
case network
case unauthorized
case unknown(description: String)
}

View file

@ -0,0 +1,42 @@
import Foundation
public enum PotentialUsernameStatus {
case tooShort
case tooLong
case invalidPattern
case unavailable
case available
case noUsername
}
public enum UsernameAvailabilityError: Error {
case tooShort
case tooLong
case invalidPattern
case nameUnavailable
case internalServer
case network
case unknown
}
public extension PotentialUsernameStatus {
static func validationError(username: String) -> PotentialUsernameStatus? {
if username.count == 0 { return .noUsername }
if username.count < 4 { return .tooShort }
if username.count > 15 { return .tooLong }
if hasInvalidPattern(username: username) { return .invalidPattern }
return nil
}
private static func hasInvalidPattern(username: String) -> Bool {
let usernamePattern = #"^[a-z0-9][a-z0-9_]+[a-z0-9]$"#
let match = username.range(
of: usernamePattern,
options: .regularExpression
)
return match == nil
}
}

View file

@ -0,0 +1,120 @@
import Foundation
public struct HomeFeedData {
public let items: [FeedItem]
public init(items: [FeedItem]) {
self.items = items
}
}
public struct FeedItem: Identifiable, Hashable, Decodable {
public let id: String
public let title: String
public var readingProgress: Double
public var readingProgressAnchor: Int
public let imageURLString: String?
public let onDeviceImageURLString: String?
public let documentDirectoryPath: String?
public let pageURLString: String
public let description: String?
public let publisherURLString: String?
public let author: String?
public let publishDate: Date?
public let slug: String
public let isArchived: Bool
public let contentReader: String?
public var highlights: [Highlight]?
public init(
id: String,
title: String,
readingProgress: Double,
readingProgressAnchor: Int,
imageURLString: String?,
onDeviceImageURLString: String?,
documentDirectoryPath: String?,
pageURLString: String,
description: String?,
publisherURLString: String?,
author: String?,
publishDate: Date?,
slug: String,
isArchived: Bool,
contentReader: String?,
highlights: [Highlight]?
) {
self.id = id
self.title = title
self.readingProgress = readingProgress
self.readingProgressAnchor = readingProgressAnchor
self.imageURLString = imageURLString
self.onDeviceImageURLString = onDeviceImageURLString
self.documentDirectoryPath = documentDirectoryPath
self.pageURLString = pageURLString
self.description = description
self.publisherURLString = publisherURLString
self.author = author
self.publishDate = publishDate
self.slug = slug
self.isArchived = isArchived
self.contentReader = contentReader
self.highlights = highlights
}
enum CodingKeys: String, CodingKey {
case id, title, image, isArchived, readingProgressPercent, readingProgressAnchorIndex, slug, contentReader, url
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: .id)
title = try container.decode(String.self, forKey: .title)
description = try container.decode(String?.self, forKey: .title)
imageURLString = try container.decode(String?.self, forKey: .image)
readingProgress = try container.decode(Double.self, forKey: .readingProgressPercent)
readingProgressAnchor = try container.decode(Int.self, forKey: .readingProgressAnchorIndex)
slug = try container.decode(String.self, forKey: .slug)
contentReader = try container.decode(String.self, forKey: .contentReader)
pageURLString = try container.decode(String.self, forKey: .url)
isArchived = try container.decode(Bool.self, forKey: .isArchived)
self.onDeviceImageURLString = nil
self.documentDirectoryPath = nil
self.publisherURLString = nil
self.author = nil
self.publishDate = nil
}
public static func fromJsonArticle(linkData: Data) -> FeedItem? {
if let item = try? JSONDecoder().decode(FeedItem.self, from: linkData) {
return item
}
return nil
}
public var isRead: Bool {
readingProgress >= 0.98
}
public var isPDF: Bool {
if let contentReader = contentReader {
return contentReader == "PDF"
}
return pageURLString.hasSuffix("pdf")
}
public var publisherHostname: String? {
URL(string: publisherURLString ?? pageURLString)?.host
}
public var imageURL: URL? {
imageURLString.flatMap { URL(string: $0) }
}
public var pdfURL: URL? {
guard isPDF else { return nil }
let documentDirectoryURL = documentDirectoryPath.flatMap { URL(string: $0) }
return documentDirectoryURL ?? URL(string: pageURLString)
}
}

View file

@ -0,0 +1,29 @@
import Foundation
public struct Highlight: Identifiable, Hashable {
public let id: String
public let shortId: String
public let quote: String
public let prefix: String?
public let suffix: String?
public let patch: String
public let annotation: String?
public init(
id: String,
shortId: String,
quote: String,
prefix: String?,
suffix: String?,
patch: String,
annotation: String?
) {
self.id = id
self.shortId = shortId
self.quote = quote
self.prefix = prefix
self.suffix = suffix
self.patch = patch
self.annotation = annotation
}
}

View file

@ -0,0 +1,221 @@
import Foundation
#if os(iOS)
import MobileCoreServices
#endif
import UniformTypeIdentifiers
let URLREGEX = #"[(http(s)?):\/\/(www\.)?a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)"#
public struct PageScrapePayload {
public enum ContentType {
case html
case pdf
}
public let title: String?
public let html: String?
public let url: String
public let contentType: ContentType
init(url: String, title: String?, html: String?) {
self.url = url
self.title = title
self.html = html
self.contentType = url.hasSuffix(".pdf") ? .pdf : .html
}
}
public struct PageScrapeError: Error {
public let message: String
}
public enum PageScraper {
public static func scrape(
extensionContext: NSExtensionContext?,
completion: @escaping (Result<PageScrapePayload, PageScrapeError>) -> Void
) {
let extensionItem = extensionContext?.inputItems.first as? NSExtensionItem
let extensionAttachments = extensionItem?.attachments
guard let attachments = extensionAttachments else {
completion(.failure(PageScrapeError(message: "no attachments")))
return
}
var pageScrapePayload: PageScrapePayload?
let propertyListKey = String(kUTTypePropertyList)
let group = DispatchGroup()
for attachment in attachments where attachment.hasItemConformingToTypeIdentifier(propertyListKey) {
group.enter()
attachment.loadItem(
forTypeIdentifier: propertyListKey,
options: nil
) { item, _ in
if let payload = PageScrapePayload.make(item: item) {
pageScrapePayload = payload
}
group.leave()
}
}
group.notify(queue: .main) {
if let payload = pageScrapePayload {
completion(.success(payload))
} else {
scrapeURLOnly(extensionContext: extensionContext, completion: completion)
}
}
}
private static func scrapeURLOnly(
extensionContext: NSExtensionContext?,
completion: @escaping (Result<PageScrapePayload, PageScrapeError>) -> Void
) {
let urlKey = String(kUTTypeURL)
// First look for a URL type
let urlFound = extensionContext?.inputItems.first { inputItem in
let itemProvider = (inputItem as? NSExtensionItem)?.attachments?.first(where: { attachment in
attachment.hasItemConformingToTypeIdentifier(urlKey)
})
let hasPublicURL = itemProvider?.hasItemConformingToTypeIdentifier(urlKey) == true
if hasPublicURL {
itemProvider?.loadItem(forTypeIdentifier: urlKey, options: nil) { item, _ in
let shareURL = item as? URL
let urlString = (item as? Data).flatMap { String(data: $0, encoding: .utf8) }
let shareUrlFromData = urlString.flatMap { URL(string: $0) }
let url = shareURL ?? shareUrlFromData
let pageScrapePayload = PageScrapePayload.make(url: url)
DispatchQueue.main.async {
if let payload = pageScrapePayload {
return completion(.success(payload))
} else {
return completion(.failure(PageScrapeError(message: "could not extract url")))
}
}
}
}
return hasPublicURL
}
if urlFound != nil {
return
}
let textKey = String(kUTTypeText)
// We didn't find a URL type, so check for a string that contains a URL
let textUrlFound = extensionContext?.inputItems.first { inputItem in
let itemProvider = (inputItem as? NSExtensionItem)?.attachments?.first(where: { attachment in
attachment.hasItemConformingToTypeIdentifier(textKey)
})
let hasPublicText = itemProvider?.hasItemConformingToTypeIdentifier(textKey) == true
if hasPublicText {
itemProvider?.loadItem(forTypeIdentifier: textKey, options: nil) { item, _ in
var url: URL?
if let item = item as? String {
if let range = item.range(of: URLREGEX, options: .regularExpression) {
let urlStr = item[range]
url = URL(string: String(urlStr))
}
}
let pageScrapePayload = PageScrapePayload.make(url: url)
DispatchQueue.main.async {
if let payload = pageScrapePayload {
return completion(.success(payload))
} else {
return completion(.failure(PageScrapeError(message: "could not extract url")))
}
}
}
}
return hasPublicText
}
if textUrlFound != nil {
return
}
completion(.failure(PageScrapeError(message: "could not find a link to save")))
}
private static func tryScrapeUrlFromText(
extensionContext: NSExtensionContext?,
completion: @escaping (Result<PageScrapePayload, PageScrapeError>) -> Void
) {
let urlKey = String(kUTTypeURL)
let textKey = String(kUTTypeUTF16PlainText)
let inputItem = extensionContext?.inputItems.first as? NSExtensionItem
let itemProvider = inputItem?.attachments?.first(where: { attachment in
attachment.hasItemConformingToTypeIdentifier(urlKey)
})
let hasPublicURL = itemProvider?.hasItemConformingToTypeIdentifier(urlKey) == true
let hasPublicText = itemProvider?.hasItemConformingToTypeIdentifier(textKey) == true
guard hasPublicURL || hasPublicText else {
completion(.failure(PageScrapeError(message: "no public url")))
return
}
if hasPublicURL {
itemProvider?.loadItem(forTypeIdentifier: urlKey, options: nil) { item, _ in
let shareURL = item as? URL
let urlString = (item as? Data).flatMap { String(data: $0, encoding: .utf8) }
let shareUrlFromData = urlString.flatMap { URL(string: $0) }
let url = shareURL ?? shareUrlFromData
let pageScrapePayload = PageScrapePayload.make(url: url)
DispatchQueue.main.async {
if let payload = pageScrapePayload {
return completion(.success(payload))
} else {
return completion(.failure(PageScrapeError(message: "could not extract url")))
}
}
}
} else {
itemProvider?.loadItem(forTypeIdentifier: textKey, options: nil) { item, _ in
var url: URL?
if let item = item as? String {
if let range = item.range(of: URLREGEX, options: .regularExpression) {
let urlStr = item[range]
url = URL(string: String(urlStr))
}
}
let pageScrapePayload = PageScrapePayload.make(url: url)
DispatchQueue.main.async {
if let payload = pageScrapePayload {
return completion(.success(payload))
} else {
return completion(.failure(PageScrapeError(message: "could not extract url")))
}
}
}
}
}
}
private extension PageScrapePayload {
static func make(url: URL?) -> PageScrapePayload? {
guard let url = url else { return nil }
return PageScrapePayload(url: url.absoluteString, title: nil, html: nil)
}
static func make(item: NSSecureCoding?) -> PageScrapePayload? {
let dictionary = item as? NSDictionary
let results = dictionary?[NSExtensionJavaScriptPreprocessingResultsKey] as? NSDictionary
guard let url = results?["url"] as? String else { return nil }
let html = results?["documentHTML"] as? String
let title = results?["title"] as? String
return PageScrapePayload(url: url, title: title, html: html)
}
}

View file

@ -0,0 +1,7 @@
import Foundation
public enum ReminderTime: Equatable {
case tonight
case tomorrow
case thisWeekend
}

View file

@ -0,0 +1,68 @@
import Foundation
import Utils
public struct UserProfile: Codable {
public let username: String
public let name: String
public let bio: String?
public init(
username: String,
name: String,
bio: String? = nil
) {
self.username = username
self.name = name
self.bio = bio
}
}
public extension UserProfile {
static func make(
username: String,
name: String,
bio: String?
) -> Either<UserProfile, String> {
let userProfile = UserProfile(
username: username,
name: name,
bio: bio
)
if let errorMessage = userProfile.validationErrorMessage {
return .right(errorMessage)
} else {
return .left(userProfile)
}
}
private var validationErrorMessage: String? {
if name.isEmpty {
return "The name field is missing."
}
return nil
}
}
public struct Viewer {
public let userID: String
public let username: String
public let name: String
public let profileImageURL: String?
public let isWaitlisted: Bool
public init(
username: String,
name: String,
profileImageURL: String?,
isWaitlisted: Bool,
userID: String
) {
self.username = username
self.name = name
self.profileImageURL = profileImageURL
self.isWaitlisted = isWaitlisted
self.userID = userID
}
}

View file

@ -0,0 +1,54 @@
import Combine
import Foundation
import Models
public extension Authenticator {
func createPendingAccountUsingApple(
token: String,
name: PersonNameComponents?
) -> AnyPublisher<UserProfile, LoginError> {
let params = CreatePendingAccountParams(token: token, provider: .apple, fullName: name)
return createPendingAccount(params: params)
}
func createAccount(userProfile: UserProfile) -> AnyPublisher<Void, LoginError> {
let params = CreateAccountParams(
pendingUserToken: pendingUserToken ?? "",
userProfile: userProfile
)
let encodedParams = (try? JSONEncoder().encode(params)) ?? Data()
return networker
.createAccount(params: encodedParams)
.tryMap { [weak self] in
try ValetKey.authCookieString.setValue($0.commentedAuthCookieString)
try ValetKey.authToken.setValue($0.authToken)
self?.pendingUserToken = nil
self?.isLoggedIn = true
}
.mapError { error in
let serverError = (error as? ServerError) ?? ServerError.unknown
return LoginError.make(serverError: serverError)
}
.eraseToAnyPublisher()
}
}
extension Authenticator {
func createPendingAccount(params: CreatePendingAccountParams) -> AnyPublisher<UserProfile, LoginError> {
let encodedParams = (try? JSONEncoder().encode(params)) ?? Data()
return networker
.createPendingUser(params: encodedParams)
.tryMap { [weak self] in
self?.pendingUserToken = $0.pendingUserToken
return $0.pendingUserProfile
}
.mapError { error in
let serverError = (error as? ServerError) ?? ServerError.unknown
return LoginError.make(serverError: serverError)
}
.eraseToAnyPublisher()
}
}

View file

@ -0,0 +1,20 @@
import Combine
import Foundation
import Models
public extension Authenticator {
func submitAppleToken(token: String) -> AnyPublisher<Void, LoginError> {
networker
.submitAppleToken(token: token)
.tryMap { [weak self] in
try ValetKey.authCookieString.setValue($0.commentedAuthCookieString)
try ValetKey.authToken.setValue($0.authToken)
self?.isLoggedIn = true
}
.mapError { error in
let serverError = (error as? ServerError) ?? ServerError.unknown
return LoginError.make(serverError: serverError)
}
.eraseToAnyPublisher()
}
}

View file

@ -0,0 +1,66 @@
import Foundation
import Models
struct CreatePendingAccountParams: Encodable {
let token: String
let provider: AuthProvider
let name: String?
init(token: String, provider: AuthProvider, fullName: PersonNameComponents?) {
self.token = token
self.provider = provider
self.name = fullName?.formattedMediumLengthName
}
}
struct SignInParams: Encodable {
let token: String
let provider: AuthProvider
}
enum AuthProvider: String, Encodable {
case apple = "APPLE"
case google = "GOOGLE"
}
struct AuthPayload: Decodable {
let authCookieString: String
let authToken: String
}
struct CreateAccountParams: Encodable {
let pendingUserToken: String
let userProfile: UserProfile
}
struct PendingUserAuthPayload: Decodable {
let pendingUserToken: String
let pendingUserProfile: UserProfile
}
extension AuthPayload {
var commentedAuthCookieString: String {
authCookieString.replacingOccurrences(of: "HttpOnly", with: "comment=ios-webview-cookie; HttpOnly")
}
}
extension LoginError {
static func make(serverError: ServerError) -> LoginError {
switch serverError {
case .noConnection, .timeout:
return .network
case .unauthenticated:
return .unauthorized
case .unknown:
return .unknown
}
}
}
private extension PersonNameComponents {
var formattedMediumLengthName: String {
let formatter = PersonNameComponentsFormatter()
formatter.style = .medium
return formatter.string(from: self)
}
}

View file

@ -0,0 +1,74 @@
import AppAuth
import Combine
import Foundation
import Utils
import WebKit
public final class Authenticator: ObservableObject {
public static var unregisterIntercomUser: (() -> Void)?
public enum AuthStatus {
case loggedOut
case pendingUser
case loggedIn
}
@Published public internal(set) var isLoggedIn: Bool
@Published public internal(set) var isWaitlisted = false
let networker: Networker
var subscriptions = Set<AnyCancellable>()
var currentAuthorizationFlow: OIDExternalUserAgentSession?
var pendingUserToken: String?
#if os(macOS)
var authRedirectHandler: OIDRedirectHTTPHandler?
#endif
public init(networker: Networker) {
self.networker = networker
self.isLoggedIn = ValetKey.authToken.exists
}
public var omnivoreAuthCookieString: String? {
ValetKey.authCookieString.value()
}
public var authToken: String? {
ValetKey.authToken.value()
}
public func logout() {
clearCreds()
Authenticator.unregisterIntercomUser?()
isLoggedIn = false
}
public func clearCreds() {
clearCookies()
ValetKey.removeAllKeys()
UserDefaults.standard.removeObject(forKey: Keys.userIdKey)
}
public var hasValidAuthToken: Bool {
guard let authToken = ValetKey.authToken.value() else { return false }
// We could do a more thorough check by decoding the token and reading it's properties
// but then we would have to store the jwt secret client side.
return !authToken.isEmpty
}
public func updateWaitlistStatus(isWaitlistedUser: Bool) {
isWaitlisted = isWaitlistedUser
}
private func clearCookies() {
HTTPCookieStorage.shared.removeCookies(since: Date.distantPast)
WKWebsiteDataStore.default().fetchDataRecords(ofTypes: WKWebsiteDataStore.allWebsiteDataTypes()) { records in
records.forEach { record in
WKWebsiteDataStore.default().removeData(ofTypes: record.dataTypes, for: [record], completionHandler: {})
}
}
}
}

View file

@ -0,0 +1,123 @@
import AppAuth
import Combine
import Foundation
import Models
import Utils
#if os(iOS)
import UIKit
public extension Authenticator {
func handleGoogleAuth(presentingViewController: PlatformViewController?) -> AnyPublisher<Bool, LoginError> {
Future { [weak self] promise in
guard let self = self, let presenting = presentingViewController else { return }
// swiftlint:disable:next line_length
self.currentAuthorizationFlow = OIDAuthState.authState(byPresenting: self.googleAuthRequest(redirectURL: nil), presenting: presenting) { authState, authError in
self.resolveAuthResponse(promise: promise, authState: authState, authError: authError)
}
}
.eraseToAnyPublisher()
}
}
#endif
#if os(macOS)
import AppKit
public extension Authenticator {
func handleGoogleAuth(presentingViewController _: PlatformViewController?) -> AnyPublisher<Bool, LoginError> {
authRedirectHandler = OIDRedirectHTTPHandler(
successURL: URL(string: "https://omnivore.app")!
)
let redirectURL = authRedirectHandler?.startHTTPListener(nil)
let authRequest = googleAuthRequest(redirectURL: redirectURL)
return Future { [weak self] promise in
guard let self = self else { return }
// swiftlint:disable:next line_length
self.authRedirectHandler?.currentAuthorizationFlow = OIDAuthState.authState(byPresenting: authRequest) { authState, authError in
NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps])
self.resolveAuthResponse(promise: promise, authState: authState, authError: authError)
}
}
.eraseToAnyPublisher()
}
}
#endif
private extension Authenticator {
func resolveAuthResponse(
promise: @escaping (Result<Bool, LoginError>) -> Void,
authState: OIDAuthState?,
authError: Error?
) {
if let idToken = authState?.lastTokenResponse?.idToken {
networker.submitGoogleToken(idToken: idToken)
.sink { completion in
guard case let .failure(loginError) = completion else { return }
switch loginError {
case .unauthorized, .unknown:
self.resolveAuthResponseForAccountCreation(promise: promise, authState: authState, authError: authError)
case .network:
promise(.failure(loginError))
}
} receiveValue: { [weak self] in
do {
try ValetKey.authCookieString.setValue($0.commentedAuthCookieString)
try ValetKey.authToken.setValue($0.authToken)
self?.isLoggedIn = true
promise(.success(false))
} catch {
promise(.failure(.unknown))
}
}
.store(in: &subscriptions)
} else {
resolveAuthResponseForAccountCreation(promise: promise, authState: authState, authError: authError)
}
}
func resolveAuthResponseForAccountCreation(
promise: @escaping (Result<Bool, LoginError>) -> Void,
authState: OIDAuthState?,
authError _: Error?
) {
if let idToken = authState?.lastTokenResponse?.idToken {
let params = CreatePendingAccountParams(token: idToken, provider: .google, fullName: nil)
let encodedParams = (try? JSONEncoder().encode(params)) ?? Data()
networker
.createPendingUser(params: encodedParams)
.sink { completion in
guard case let .failure(serverError) = completion else { return }
promise(.failure(LoginError.make(serverError: serverError)))
} receiveValue: { [weak self] in
self?.pendingUserToken = $0.pendingUserToken
promise(.success(true))
}
.store(in: &subscriptions)
} else {
promise(.failure(.unauthorized))
}
}
func googleAuthRequest(redirectURL: URL?) -> OIDAuthorizationRequest {
let authEndpoint = URL(string: "https://accounts.google.com/o/oauth2/v2/auth")!
let tokenEndpoint = URL(string: "https://www.googleapis.com/oauth2/v4/token")!
let iosClientGoogleId = AppKeys.sharedInstance?.iosClientGoogleId ?? ""
let scopes = ["profile", "email"]
return OIDAuthorizationRequest(
configuration: OIDServiceConfiguration(authorizationEndpoint: authEndpoint, tokenEndpoint: tokenEndpoint),
clientId: "\(iosClientGoogleId).apps.googleusercontent.com",
scopes: scopes,
redirectURL: redirectURL ?? URL(
string: "com.googleusercontent.apps.\(iosClientGoogleId):/oauth2redirect/google"
)!,
responseType: "code",
additionalParameters: nil
)
}
}

View file

@ -0,0 +1,57 @@
import Foundation
import SwiftGraphQL
struct DateTime: Codec {
private var data: Date?
init(from date: Date) {
self.data = date
}
// MARK: - Public interface
var value: Date? {
data
}
// MARK: - Codec conformance
// MARK: - Decoder
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
let dateString = try container.decode(String.self)
let formatter = DateFormatter.formatterISO8601
self.data = formatter.date(from: dateString)
}
// MARK: - Encoder
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
if let data = data {
try container.encode(DateFormatter.formatterISO8601.string(from: data))
} else {
throw EncodingError.invalidValue(
data as Any, .init(codingPath: [], debugDescription: "trying to encodea DateTime value")
)
}
}
// MARK: - Mock value
static var mockValue = DateTime(from: Date())
}
public extension DateFormatter {
static let formatterISO8601: DateFormatter = {
let formatter = DateFormatter()
formatter.calendar = Calendar(identifier: .iso8601)
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone(secondsFromGMT: 0)
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"
return formatter
}()
}

View file

@ -0,0 +1,32 @@
import Foundation
import Models
public final class DataService {
public static var registerIntercomUser: ((String) -> Void)?
public static var showIntercomMessenger: (() -> Void)?
public let appEnvironment: AppEnvironment
public internal(set) var currentViewer: Viewer?
let networker: Networker
let highlightsCache = NSCache<AnyObject, CachedPDFHighlights>()
let highlightsCacheQueue = DispatchQueue(label: "app.omnivore.highlights.cache.queue", attributes: .concurrent)
public init(appEnvironment: AppEnvironment, networker: Networker) {
self.appEnvironment = appEnvironment
self.networker = networker
}
public func clearHighlights() {
highlightsCache.removeAllObjects()
}
public func switchAppEnvironment(appEnvironment: AppEnvironment) {
do {
try ValetKey.appEnvironmentString.setValue(appEnvironment.rawValue)
fatalError("App environment changed -- restarting app")
} catch {
fatalError("Unable to write to Keychain: \(error)")
}
}
}

View file

@ -0,0 +1,34 @@
import Foundation
import Models
public enum ServerError: String, Error {
case noConnection
case unauthenticated
case timeout
case unknown
}
extension ServerError {
init(serverResponse: ServerResponse) {
switch serverResponse.httpUrlResponse?.statusCode {
case 401?, 403?:
self = .unauthenticated
return
default:
break
}
switch serverResponse.urlError?.code {
case .some(.timedOut):
self = .timeout
return
case .some(.notConnectedToInternet):
self = .noConnection
return
default:
break
}
self = .unknown
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,60 @@
import Combine
import Foundation
import Models
import SwiftGraphQL
public extension DataService {
func archiveLinkPublisher(
itemID: String,
archived: Bool
) -> AnyPublisher<String, BasicError> {
enum MutationResult {
case success(linkId: String)
case error(errorCode: Enums.ArchiveLinkErrorCode)
}
let selection = Selection<MutationResult, Unions.ArchiveLinkResult> {
try $0.on(
archiveLinkSuccess: .init { .success(linkId: try $0.linkId()) },
archiveLinkError: .init { .error(errorCode: try $0.errorCodes().first ?? .badRequest) }
)
}
let mutation = Selection.Mutation {
try $0.setLinkArchived(
input: InputObjects.ArchiveLinkInput(
linkId: itemID,
archived: archived
),
selection: selection
)
}
let path = appEnvironment.graphqlPath
let headers = networker.defaultHeaders
return Deferred {
Future { promise in
send(mutation, to: path, headers: headers) { result in
switch result {
case let .success(payload):
if payload.errors != nil {
promise(.failure(.message(messageText: "Error archiving link")))
}
switch payload.data {
case let .success(linkId):
promise(.success(linkId))
case .error:
promise(.failure(.message(messageText: "Error archiving link")))
}
case .failure:
promise(.failure(.message(messageText: "Error archiving link")))
}
}
}
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}

View file

@ -0,0 +1,64 @@
import Combine
import Foundation
import Models
import SwiftGraphQL
public extension DataService {
func createHighlightPublisher(
shortId: String,
highlightID: String,
quote: String,
patch: String,
articleId: String
) -> AnyPublisher<String, BasicError> {
enum MutationResult {
case saved(id: String)
case error(errorCode: Enums.CreateHighlightErrorCode)
}
let selection = Selection<MutationResult, Unions.CreateHighlightResult> {
try $0.on(
createHighlightSuccess: .init {
.saved(id: try $0.highlight(selection: Selection.Highlight { try $0.id() }))
},
createHighlightError: .init { .error(errorCode: try $0.errorCodes().first ?? .badData) }
)
}
let mutation = Selection.Mutation {
try $0.createHighlight(
input: InputObjects.CreateHighlightInput(
id: highlightID, shortId: shortId, articleId: articleId, patch: patch, quote: quote
),
selection: selection
)
}
let path = appEnvironment.graphqlPath
let headers = networker.defaultHeaders
return Deferred {
Future { promise in
send(mutation, to: path, headers: headers) { result in
switch result {
case let .success(payload):
if let graphqlError = payload.errors {
promise(.failure(.message(messageText: "graphql error: \(graphqlError)")))
}
switch payload.data {
case let .saved(id: id):
promise(.success(id))
case let .error(errorCode: errorCode):
promise(.failure(.message(messageText: errorCode.rawValue)))
}
case .failure:
promise(.failure(.message(messageText: "graphql error")))
}
}
}
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}

View file

@ -0,0 +1,88 @@
import Combine
import Foundation
import Models
import SwiftGraphQL
public enum ReminderItemId {
case clientRequest(id: String)
case link(id: String)
var linkId: String? {
switch self {
case .clientRequest:
return nil
case let .link(id):
return id
}
}
var clientRequestId: String? {
switch self {
case .link:
return nil
case let .clientRequest(id):
return id
}
}
}
public extension DataService {
func createReminderPublisher(
reminderItemId: ReminderItemId,
remindAt: Date
) -> AnyPublisher<String, BasicError> {
enum MutationResult {
case complete(id: String)
case error(errorCode: Enums.CreateReminderErrorCode)
}
let selection = Selection<MutationResult, Unions.CreateReminderResult> {
try $0.on(
createReminderSuccess: .init {
.complete(id: try $0.reminder(selection: Selection.Reminder { try $0.id() }))
},
createReminderError: .init { .error(errorCode: try $0.errorCodes().first ?? .badRequest) }
)
}
let mutation = Selection.Mutation {
try $0.createReminder(
input: InputObjects.CreateReminderInput(
linkId: OptionalArgument(reminderItemId.linkId),
clientRequestId: OptionalArgument(reminderItemId.clientRequestId),
archiveUntil: true,
sendNotification: true,
remindAt: DateTime(from: remindAt)
),
selection: selection
)
}
let path = appEnvironment.graphqlPath
let headers = networker.defaultHeaders
return Deferred {
Future { promise in
send(mutation, to: path, headers: headers) { result in
switch result {
case let .success(payload):
if let graphqlError = payload.errors {
promise(.failure(.message(messageText: "graphql error: \(graphqlError)")))
}
switch payload.data {
case let .complete(id: id):
promise(.success(id))
case let .error(errorCode: errorCode):
promise(.failure(.message(messageText: errorCode.rawValue)))
}
case .failure:
promise(.failure(.message(messageText: "graphql error")))
}
}
}
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}

View file

@ -0,0 +1,58 @@
import Combine
import Foundation
import Models
import SwiftGraphQL
public extension DataService {
func deleteHighlightPublisher(
highlightId: String
) -> AnyPublisher<String, BasicError> {
enum MutationResult {
case saved(id: String)
case error(errorCode: Enums.DeleteHighlightErrorCode)
}
let selection = Selection<MutationResult, Unions.DeleteHighlightResult> {
try $0.on(
deleteHighlightSuccess: .init {
.saved(id: try $0.highlight(selection: Selection.Highlight { try $0.id() }))
},
deleteHighlightError: .init { .error(errorCode: try $0.errorCodes().first ?? .unauthorized) }
)
}
let mutation = Selection.Mutation {
try $0.deleteHighlight(
highlightId: highlightId.lowercased(),
selection: selection
)
}
let path = appEnvironment.graphqlPath
let headers = networker.defaultHeaders
return Deferred {
Future { promise in
send(mutation, to: path, headers: headers) { result in
switch result {
case let .success(payload):
if let graphqlError = payload.errors {
promise(.failure(.message(messageText: "graphql error: \(graphqlError)")))
}
switch payload.data {
case let .saved(id: id):
promise(.success(id))
case let .error(errorCode: errorCode):
promise(.failure(.message(messageText: errorCode.rawValue)))
}
case .failure:
promise(.failure(.message(messageText: "graphql error")))
}
}
}
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}

View file

@ -0,0 +1,84 @@
import Combine
import Foundation
import Models
import SwiftGraphQL
public enum DeviceTokenOperation {
case addToken(token: String)
case deleteToken(tokenID: String)
var token: String? {
switch self {
case let .addToken(token):
return token
case .deleteToken:
return nil
}
}
var tokenID: String? {
switch self {
case .addToken:
return nil
case let .deleteToken(tokenID):
return tokenID
}
}
}
public extension DataService {
func deviceTokenPublisher(
deviceTokenOperation: DeviceTokenOperation
) -> AnyPublisher<String, BasicError> {
enum MutationResultNew {
case saved(id: String)
case error(errorCode: Enums.SetDeviceTokenErrorCode)
}
let selection = Selection<MutationResultNew, Unions.SetDeviceTokenResult> {
try $0.on(
setDeviceTokenSuccess: .init {
.saved(id: try $0.deviceToken(selection: Selection.DeviceToken { try $0.id() }))
},
setDeviceTokenError: .init { .error(errorCode: try $0.errorCodes().first ?? .badRequest) }
)
}
let mutation = Selection.Mutation {
try $0.setDeviceToken(
input: InputObjects.SetDeviceTokenInput(
id: OptionalArgument(deviceTokenOperation.tokenID),
token: OptionalArgument(deviceTokenOperation.token)
),
selection: selection
)
}
let path = appEnvironment.graphqlPath
let headers = networker.defaultHeaders
return Deferred {
Future { promise in
send(mutation, to: path, headers: headers) { result in
switch result {
case let .success(payload):
if let graphqlError = payload.errors {
promise(.failure(.message(messageText: "graphql error: \(graphqlError)")))
}
switch payload.data {
case let .saved(id: id):
promise(.success(id))
case let .error(errorCode: errorCode):
promise(.failure(.message(messageText: errorCode.rawValue)))
}
case .failure:
promise(.failure(.message(messageText: "graphql error")))
}
}
}
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}

View file

@ -0,0 +1,74 @@
import Combine
import Foundation
import Models
import SwiftGraphQL
public extension DataService {
// swiftlint:disable:next function_parameter_count
func mergeHighlightPublisher(
shortId: String,
highlightID: String,
quote: String,
patch: String,
articleId: String,
overlapHighlightIdList: [String]
) -> AnyPublisher<String, BasicError> {
enum MutationResult {
case saved(id: String)
case error(errorCode: Enums.MergeHighlightErrorCode)
}
let selection = Selection<MutationResult, Unions.MergeHighlightResult> {
try $0.on(
mergeHighlightSuccess: .init {
.saved(id: try $0.highlight(selection: Selection.Highlight { try $0.id() }))
},
mergeHighlightError: .init { .error(errorCode: try $0.errorCodes().first ?? .badData) }
)
}
let mutation = Selection.Mutation {
try $0.mergeHighlight(
input: InputObjects.MergeHighlightInput(
id: highlightID,
shortId: shortId,
articleId: articleId,
patch: patch,
quote: quote,
prefix: .absent(),
suffix: .absent(),
annotation: .absent(),
overlapHighlightIdList: overlapHighlightIdList
),
selection: selection
)
}
let path = appEnvironment.graphqlPath
let headers = networker.defaultHeaders
return Deferred {
Future { promise in
send(mutation, to: path, headers: headers) { result in
switch result {
case let .success(payload):
if let graphqlError = payload.errors {
promise(.failure(.message(messageText: "graphql error: \(graphqlError)")))
}
switch payload.data {
case let .saved(id: id):
promise(.success(id))
case let .error(errorCode: errorCode):
promise(.failure(.message(messageText: errorCode.rawValue)))
}
case .failure:
promise(.failure(.message(messageText: "graphql error")))
}
}
}
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}

View file

@ -0,0 +1,67 @@
import Combine
import Foundation
import Models
import SwiftGraphQL
let setBookmarkedArticleSelection = Selection.Article {
try $0.id()
}
public extension DataService {
func removeLinkPublisher(
itemID: String
) -> AnyPublisher<String, BasicError> {
enum MutationResult {
case success(linkId: String)
case error(errorCode: Enums.SetBookmarkArticleErrorCode)
}
let selection = Selection<MutationResult, Unions.SetBookmarkArticleResult> {
try $0.on(
setBookmarkArticleSuccess: .init {
.success(
linkId: try $0.bookmarkedArticle(selection: setBookmarkedArticleSelection)
)
},
setBookmarkArticleError: .init { .error(errorCode: try $0.errorCodes().first ?? .notFound) }
)
}
let mutation = Selection.Mutation {
try $0.setBookmarkArticle(
input: InputObjects.SetBookmarkArticleInput(
articleId: itemID,
bookmark: false
),
selection: selection
)
}
let path = appEnvironment.graphqlPath
let headers = networker.defaultHeaders
return Deferred {
Future { promise in
send(mutation, to: path, headers: headers) { result in
switch result {
case let .success(payload):
if payload.errors != nil {
promise(.failure(.message(messageText: "Error removing link")))
}
switch payload.data {
case let .success(item):
promise(.success(item))
case .error(errorCode: _):
promise(.failure(.message(messageText: "Error removing link")))
}
case .failure:
promise(.failure(.message(messageText: "Error removing link")))
}
}
}
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}

View file

@ -0,0 +1,230 @@
import Combine
import Foundation
import Models
import SwiftGraphQL
public enum SaveArticleStatus {
case succeeeded
case processing(jobId: String)
case failed
static func make(jobId: String, savingStatus: Enums.ArticleSavingRequestStatus) -> SaveArticleStatus {
switch savingStatus {
case .processing:
return .processing(jobId: jobId)
case .succeeded:
return .succeeeded
case .failed:
return .failed
}
}
}
public extension Networker {
func articleSaveStatus(jobId: String) -> AnyPublisher<SaveArticleStatus, SaveArticleError> {
enum QueryResult {
case saved(status: SaveArticleStatus)
case error(errorCode: Enums.ArticleSavingRequestErrorCode)
}
let selection = Selection<QueryResult, Unions.ArticleSavingRequestResult> {
try $0.on(
articleSavingRequestSuccess: .init {
.saved(
status: try $0.articleSavingRequest(
selection: .init {
SaveArticleStatus.make(
jobId: try $0.id(),
savingStatus: try $0.status()
)
}
)
)
},
articleSavingRequestError: .init { .error(errorCode: (try? $0.errorCodes().first) ?? .notFound) }
)
}
let query = Selection.Query {
try $0.articleSavingRequest(id: jobId, selection: selection)
}
let path = appEnvironment.graphqlPath
let headers = defaultHeaders
return Deferred {
Future { promise in
send(query, to: path, headers: headers) { result in
switch result {
case let .success(payload):
if let graphqlError = payload.errors {
promise(.failure(.unknown(description: graphqlError.first.debugDescription)))
}
switch payload.data {
case let .saved(status):
promise(.success(status))
case let .error(errorCode: errorCode):
switch errorCode {
case .unauthorized:
promise(.failure(.unauthorized))
case .notFound:
promise(.failure(.badData))
}
}
case let .failure(error):
promise(.failure(SaveArticleError.make(from: error)))
}
}
}
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}
public extension DataService {
// swiftlint:disable:next line_length
func saveArticlePublisher(pageScrapePayload: PageScrapePayload, uploadFileId: String?) -> AnyPublisher<Void, SaveArticleError> {
enum MutationResult {
case saved(created: Bool)
case error(errorCode: Enums.CreateArticleErrorCode)
}
let preparedDocument: InputObjects.PreparedDocumentInput? = {
guard let html = pageScrapePayload.html, let title = pageScrapePayload.title else { return nil }
return InputObjects.PreparedDocumentInput(
document: html,
pageInfo: InputObjects.PageInfoInput(title: OptionalArgument(title))
)
}()
let input = InputObjects.CreateArticleInput(
url: pageScrapePayload.url,
preparedDocument: OptionalArgument(preparedDocument),
uploadFileId: uploadFileId != nil ? .present(uploadFileId!) : .null()
)
let selection = Selection<MutationResult, Unions.CreateArticleResult> {
try $0.on(
createArticleSuccess: .init { .saved(created: try $0.created()) },
createArticleError: .init { .error(errorCode: (try? $0.errorCodes().first) ?? .unableToParse) }
)
}
let mutation = Selection.Mutation {
try $0.createArticle(input: input, selection: selection)
}
let path = appEnvironment.graphqlPath
let headers = networker.defaultHeaders
return Deferred {
Future { promise in
send(mutation, to: path, headers: headers) { result in
switch result {
case let .success(payload):
if let graphqlError = payload.errors {
promise(.failure(.unknown(description: graphqlError.first.debugDescription)))
}
switch payload.data {
case .saved:
promise(.success(()))
case let .error(errorCode: errorCode):
switch errorCode {
case .unauthorized:
promise(.failure(.unauthorized))
default:
promise(.failure(.unknown(description: errorCode.rawValue)))
}
}
case let .failure(error):
promise(.failure(SaveArticleError.make(from: error)))
}
}
}
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
func saveArticlePublisher(articleURL: URL) -> AnyPublisher<SaveArticleStatus, SaveArticleError> {
saveArticlePublisher(articleURLString: articleURL.absoluteString)
}
func saveArticlePublisher(articleURLString: String) -> AnyPublisher<SaveArticleStatus, SaveArticleError> {
enum MutationResult {
case saved(status: SaveArticleStatus)
case error(errorCode: Enums.CreateArticleSavingRequestErrorCode)
}
let selection = Selection<MutationResult, Unions.CreateArticleSavingRequestResult> {
try $0.on(
createArticleSavingRequestSuccess: .init {
.saved(
status: try $0.articleSavingRequest(
selection: .init {
SaveArticleStatus.make(
jobId: try $0.id(),
savingStatus: try $0.status()
)
}
)
)
},
createArticleSavingRequestError: .init { .error(errorCode: (try? $0.errorCodes().first) ?? .badData) }
)
}
let mutation = Selection.Mutation {
try $0.createArticleSavingRequest(
input: InputObjects.CreateArticleSavingRequestInput(url: articleURLString),
selection: selection
)
}
let path = appEnvironment.graphqlPath
let headers = networker.defaultHeaders
return Deferred {
Future { promise in
send(mutation, to: path, headers: headers) { result in
switch result {
case let .success(payload):
if let graphqlError = payload.errors {
promise(.failure(.unknown(description: graphqlError.first.debugDescription)))
}
switch payload.data {
case let .saved(status):
promise(.success(status))
case let .error(errorCode: errorCode):
switch errorCode {
case .unauthorized:
promise(.failure(.unauthorized))
case .badData:
promise(.failure(.badData))
}
}
case let .failure(error):
promise(.failure(SaveArticleError.make(from: error)))
}
}
}
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}
private extension SaveArticleError {
static func make(from httpError: HttpError) -> SaveArticleError {
switch httpError {
case .network, .timeout:
return .network
case .badpayload, .badURL, .badstatus, .cancelled:
return .unknown(description: httpError.localizedDescription)
}
}
}

View file

@ -0,0 +1,195 @@
import Combine
import Foundation
import Models
import SwiftGraphQL
public extension DataService {
func uploadPDFPublisher(
pageScrapePayload: PageScrapePayload,
requestId: String
) -> AnyPublisher<Void, SaveArticleError> {
uploadFileRequestPublisher(pageScrapePayload: pageScrapePayload)
.flatMap { self.uploadFilePublisher(fileUploadConfig: $0, fileURLString: pageScrapePayload.url) }
.flatMap { self.saveFilePublisher(pageScrapePayload: pageScrapePayload, uploadFileId: $0, requestId: requestId) }
.catch { _ in self.saveUrlPublisher(pageScrapePayload: pageScrapePayload, requestId: requestId) }
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}
private struct UploadFileRequestPayload {
let uploadID: String?
let uploadFileID: String?
let urlString: String?
}
private extension DataService {
// swiftlint:disable:next line_length
func uploadFileRequestPublisher(pageScrapePayload: PageScrapePayload) -> AnyPublisher<UploadFileRequestPayload, SaveArticleError> {
enum MutationResult {
case success(payload: UploadFileRequestPayload)
case error(errorCode: Enums.UploadFileRequestErrorCode?)
}
let input = InputObjects.UploadFileRequestInput(
url: pageScrapePayload.url,
contentType: "application/pdf"
)
let selection = Selection<MutationResult, Unions.UploadFileRequestResult> {
try $0.on(
uploadFileRequestSuccess: .init {
.success(
payload: UploadFileRequestPayload(
uploadID: try $0.id(),
uploadFileID: try $0.uploadFileId(),
urlString: try $0.uploadSignedUrl()
)
)
},
uploadFileRequestError: .init { .error(errorCode: try? $0.errorCodes().first) }
)
}
let mutation = Selection.Mutation {
try $0.uploadFileRequest(input: input, selection: selection)
}
let path = appEnvironment.graphqlPath
let headers = networker.defaultHeaders
return Deferred {
Future { promise in
send(mutation, to: path, headers: headers) { result in
switch result {
case let .success(payload):
if let graphqlError = payload.errors {
promise(.failure(.unknown(description: graphqlError.first.debugDescription)))
}
switch payload.data {
case let .success(payload):
promise(.success(payload))
case let .error(errorCode):
switch errorCode {
case .unauthorized:
promise(.failure(.unauthorized))
default:
promise(.failure(.unknown(description: errorCode.debugDescription)))
}
}
case .failure:
promise(.failure(.badData))
}
}
}
}
.eraseToAnyPublisher()
}
// swiftlint:disable:next line_length
func uploadFilePublisher(fileUploadConfig: UploadFileRequestPayload, fileURLString: String) -> AnyPublisher<String, SaveArticleError> {
let pdfData = URL(string: fileURLString).flatMap { try? Data(contentsOf: $0) }
let url = fileUploadConfig.urlString.flatMap { URL(string: $0) }
guard let url = url, let pdfData = pdfData else { return Future { $0(.failure(.badData)) }.eraseToAnyPublisher() }
var request = URLRequest(url: url)
request.httpMethod = "PUT"
request.addValue("application/pdf", forHTTPHeaderField: "content-type")
request.httpBody = pdfData
return networker.urlSession.dataTaskPublisher(for: request)
.tryMap { data, response -> String in
let serverResponse = ServerResponse(data: data, response: response)
if serverResponse.httpUrlResponse?.statusCode == 200, let fileUploadID = fileUploadConfig.uploadID {
return fileUploadID
}
throw ServerError(serverResponse: serverResponse)
}
.mapError { error -> SaveArticleError in
let serverResponse = ServerResponse(error: error)
NetworkRequestLogger.log(request: request, serverResponse: serverResponse)
let serverError = ServerError(serverResponse: serverResponse)
switch serverError {
case .noConnection, .timeout:
return .network
case .unauthenticated:
return .unauthorized
case .unknown:
return .unknown(description: "upload to file server failed")
}
}
.eraseToAnyPublisher()
}
// swiftlint:disable:next line_length
func saveFilePublisher(pageScrapePayload: PageScrapePayload, uploadFileId: String, requestId: String) -> AnyPublisher<Void, SaveArticleError> {
enum MutationResult {
case saved(requestId: String, url: String)
case error(errorCode: Enums.SaveErrorCode)
}
let input = InputObjects.SaveFileInput(
url: pageScrapePayload.url,
source: "ios-file",
clientRequestId: requestId,
uploadFileId: uploadFileId
)
let selection = Selection<MutationResult, Unions.SaveResult> {
try $0.on(
saveSuccess: .init { .saved(requestId: requestId, url: (try? $0.url()) ?? "") },
saveError: .init { .error(errorCode: (try? $0.errorCodes().first) ?? .unknown) }
)
}
let mutation = Selection.Mutation {
try $0.saveFile(input: input, selection: selection)
}
let path = appEnvironment.graphqlPath
let headers = networker.defaultHeaders
return Deferred {
Future { promise in
send(mutation, to: path, headers: headers) { result in
switch result {
case let .success(payload):
if let graphqlError = payload.errors {
promise(.failure(.unknown(description: graphqlError.first.debugDescription)))
}
switch payload.data {
case .saved:
promise(.success(()))
case let .error(errorCode: errorCode):
switch errorCode {
case .unauthorized:
promise(.failure(.unauthorized))
default:
promise(.failure(.unknown(description: errorCode.rawValue)))
}
}
case let .failure(error):
promise(.failure(SaveError.make(from: error)))
}
}
}
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}
private extension SaveError {
static func make(from httpError: HttpError) -> SaveArticleError {
switch httpError {
case .network, .timeout:
return .network
case .badpayload, .badURL, .badstatus, .cancelled:
return .unknown(description: httpError.localizedDescription)
}
}
}

View file

@ -0,0 +1,76 @@
import Combine
import Foundation
import Models
import SwiftGraphQL
public extension DataService {
// swiftlint:disable:next line_length
func savePagePublisher(pageScrapePayload: PageScrapePayload, requestId: String) -> AnyPublisher<Void, SaveArticleError> {
enum MutationResult {
case saved(requestId: String, url: String)
case error(errorCode: Enums.SaveErrorCode)
}
let input = InputObjects.SavePageInput(
url: pageScrapePayload.url,
source: "ios-page",
clientRequestId: requestId,
title: OptionalArgument(pageScrapePayload.title),
originalContent: pageScrapePayload.html ?? ""
)
let selection = Selection<MutationResult, Unions.SaveResult> {
try $0.on(
saveSuccess: .init { .saved(requestId: requestId, url: (try? $0.url()) ?? "") },
saveError: .init { .error(errorCode: (try? $0.errorCodes().first) ?? .unknown) }
)
}
let mutation = Selection.Mutation {
try $0.savePage(input: input, selection: selection)
}
let path = appEnvironment.graphqlPath
let headers = networker.defaultHeaders
return Deferred {
Future { promise in
send(mutation, to: path, headers: headers) { result in
switch result {
case let .success(payload):
if let graphqlError = payload.errors {
promise(.failure(.unknown(description: graphqlError.first.debugDescription)))
}
switch payload.data {
case .saved:
promise(.success(()))
case let .error(errorCode: errorCode):
switch errorCode {
case .unauthorized:
promise(.failure(.unauthorized))
default:
promise(.failure(.unknown(description: errorCode.rawValue)))
}
}
case let .failure(error):
promise(.failure(SaveError.make(from: error)))
}
}
}
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}
private extension SaveError {
static func make(from httpError: HttpError) -> SaveArticleError {
switch httpError {
case .network, .timeout:
return .network
case .badpayload, .badURL, .badstatus, .cancelled:
return .unknown(description: httpError.localizedDescription)
}
}
}

View file

@ -0,0 +1,74 @@
import Combine
import Foundation
import Models
import SwiftGraphQL
public extension DataService {
// swiftlint:disable:next line_length
func saveUrlPublisher(pageScrapePayload: PageScrapePayload, requestId: String) -> AnyPublisher<Void, SaveArticleError> {
enum MutationResult {
case saved(requestId: String, url: String)
case error(errorCode: Enums.SaveErrorCode)
}
let input = InputObjects.SaveUrlInput(
url: pageScrapePayload.url,
source: "ios-url",
clientRequestId: requestId
)
let selection = Selection<MutationResult, Unions.SaveResult> {
try $0.on(
saveSuccess: .init { .saved(requestId: requestId, url: (try? $0.url()) ?? "") },
saveError: .init { .error(errorCode: (try? $0.errorCodes().first) ?? .unknown) }
)
}
let mutation = Selection.Mutation {
try $0.saveUrl(input: input, selection: selection)
}
let path = appEnvironment.graphqlPath
let headers = networker.defaultHeaders
return Deferred {
Future { promise in
send(mutation, to: path, headers: headers) { result in
switch result {
case let .success(payload):
if let graphqlError = payload.errors {
promise(.failure(.unknown(description: graphqlError.first.debugDescription)))
}
switch payload.data {
case .saved:
promise(.success(()))
case let .error(errorCode: errorCode):
switch errorCode {
case .unauthorized:
promise(.failure(.unauthorized))
default:
promise(.failure(.unknown(description: errorCode.rawValue)))
}
}
case let .failure(error):
promise(.failure(SaveError.make(from: error)))
}
}
}
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}
private extension SaveError {
static func make(from httpError: HttpError) -> SaveArticleError {
switch httpError {
case .network, .timeout:
return .network
case .badpayload, .badURL, .badstatus, .cancelled:
return .unknown(description: httpError.localizedDescription)
}
}
}

View file

@ -0,0 +1,71 @@
import Combine
import Foundation
import Models
import SwiftGraphQL
public extension DataService {
func updateArticleReadingProgressPublisher(
itemID: String,
readingProgress: Double,
anchorIndex: Int
) -> AnyPublisher<FeedItem, SaveArticleError> {
enum MutationResult {
case saved(feedItem: FeedItem)
case error(errorCode: Enums.SaveArticleReadingProgressErrorCode)
}
let selection = Selection<MutationResult, Unions.SaveArticleReadingProgressResult> {
try $0.on(
saveArticleReadingProgressSuccess: .init {
.saved(
feedItem: try $0.updatedArticle(selection: homeFeedItemSelection)
)
},
saveArticleReadingProgressError: .init { .error(errorCode: try $0.errorCodes().first ?? .badData) }
)
}
let mutation = Selection.Mutation {
try $0.saveArticleReadingProgress(
input: InputObjects.SaveArticleReadingProgressInput(
id: itemID,
readingProgressPercent: readingProgress,
readingProgressAnchorIndex: anchorIndex
),
selection: selection
)
}
let path = appEnvironment.graphqlPath
let headers = networker.defaultHeaders
return Deferred {
Future { promise in
send(mutation, to: path, headers: headers) { result in
switch result {
case let .success(payload):
if let graphqlError = payload.errors {
promise(.failure(.unknown(description: graphqlError.first.debugDescription)))
}
switch payload.data {
case let .saved(feedItem):
promise(.success(feedItem))
case let .error(errorCode: errorCode):
switch errorCode {
case .unauthorized:
promise(.failure(.unauthorized))
case .badData, .notFound:
promise(.failure(.badData))
}
}
case .failure:
promise(.failure(.badData))
}
}
}
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}

View file

@ -0,0 +1,64 @@
import Combine
import Foundation
import Models
import SwiftGraphQL
public extension DataService {
func updateHighlightAttributesPublisher(
highlightID: String,
annotation: String?,
sharedAt: Date?
) -> AnyPublisher<String, BasicError> {
enum MutationResult {
case saved(id: String)
case error(errorCode: Enums.UpdateHighlightErrorCode)
}
let selection = Selection<MutationResult, Unions.UpdateHighlightResult> {
try $0.on(
updateHighlightSuccess: .init {
.saved(id: try $0.highlight(selection: Selection.Highlight { try $0.id() }))
},
updateHighlightError: .init { .error(errorCode: try $0.errorCodes().first ?? .badData) }
)
}
let mutation = Selection.Mutation {
try $0.updateHighlight(
input: InputObjects.UpdateHighlightInput(
highlightId: highlightID,
annotation: OptionalArgument(annotation),
sharedAt: OptionalArgument(sharedAt.flatMap { DateTime(from: $0) })
),
selection: selection
)
}
let path = appEnvironment.graphqlPath
let headers = networker.defaultHeaders
return Deferred {
Future { promise in
send(mutation, to: path, headers: headers) { result in
switch result {
case let .success(payload):
if let graphqlError = payload.errors {
promise(.failure(.message(messageText: "graphql error: \(graphqlError)")))
}
switch payload.data {
case let .saved(id: id):
promise(.success(id))
case let .error(errorCode: errorCode):
promise(.failure(.message(messageText: errorCode.rawValue)))
}
case .failure:
promise(.failure(.message(messageText: "graphql error")))
}
}
}
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}

View file

@ -0,0 +1,41 @@
import Foundation
private let standardJSONDecoder: JSONDecoder = {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
return decoder
}()
extension Decodable {
static func decodeJSON(fromData data: Data) -> Self? {
do {
return try standardJSONDecoder.decode(Self.self, from: data)
} catch {
let jsonReadingOptions = JSONSerialization.ReadingOptions(rawValue: 0)
let json = (try? JSONSerialization.jsonObject(with: data, options: jsonReadingOptions))
print("Decoding Error: \(error)\nRaw JSON: \(String(describing: json))")
return nil
}
}
static func decode<ResponseModel: Decodable>(serverResponse: ServerResponse) -> ResponseModel? {
guard let httpResponse = serverResponse.httpUrlResponse else { return nil }
guard 200 ... 299 ~= httpResponse.statusCode else { return nil }
guard let data = serverResponse.data else { return nil }
return decodeJSON(fromData: data) as? ResponseModel
}
}
extension UnkeyedDecodingContainer {
func decodeAll<T: Decodable>() throws -> [T] {
var container = self
var decoded = [T]()
while !container.isAtEnd {
let decodedElement = try container.decode(T.self)
decoded.append(decodedElement)
}
return decoded
}
}

View file

@ -0,0 +1,88 @@
import Foundation
struct NetworkRequestLogger {
static func log(request: URLRequest, serverResponse: ServerResponse) {
log(request: request)
log(serverResponse: serverResponse)
}
private static func log(request: URLRequest) {
let urlString = request.url?.absoluteString ?? ""
let components = URLComponents(string: urlString)
let method = request.httpMethod != nil ? "\(request.httpMethod!)" : ""
let path = "\(components?.path ?? "")"
let query = "\(components?.query ?? "")"
let host = "\(components?.host ?? "")"
var requestLog = "\n\n\n------ OUTGOING NETWORK REQUEST ---------->\n\n"
requestLog += "\(urlString)"
requestLog += "\n\n"
requestLog += "\(method) \(path)?\(query) HTTP/1.1\n"
requestLog += "Host: \(host)\n"
for (key, value) in request.allHTTPHeaderFields ?? [:] {
requestLog += "\(key): \(value)\n"
}
if let body = request.httpBody {
requestLog += "\n\(body.prettyPrintedJSONString ?? "")\n"
}
requestLog += "\n------------------------->\n"
print(requestLog)
}
private static func log(serverResponse: ServerResponse, includeJSON: Bool = true) {
let urlString = serverResponse.httpUrlResponse?.url?.absoluteString
let components = URLComponents(string: urlString ?? "")
let path = "\(components?.path ?? "")"
let query = "?\(components?.query ?? "")"
var responseLog = "\n\n\n<---------- INCOMING NETWORK RESPONSE ----------\n\n"
if let urlString = urlString {
responseLog += "\(urlString)"
responseLog += "\n\n"
}
if let statusCode = serverResponse.httpUrlResponse?.statusCode {
responseLog += "HTTP \(statusCode) \(path)\(query)\n"
}
if let host = components?.host {
responseLog += "Host: \(host)\n"
}
for (key, value) in serverResponse.httpUrlResponse?.allHeaderFields ?? [:] {
responseLog += "\(key): \(value)\n"
}
if let body = serverResponse.data, includeJSON {
responseLog += "\n\(body.prettyPrintedJSONString ?? "")\n"
}
if let error = serverResponse.urlError {
responseLog += "\nError: \(error.localizedDescription)\n"
}
responseLog += "<---------------------------------\n"
print(responseLog)
}
}
private extension Data {
var prettyPrintedJSONString: String? {
guard
let object = try? JSONSerialization.jsonObject(with: self, options: []),
let data = try? JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted]),
let prettyPrintedString = String(data: data, encoding: String.Encoding(rawValue: String.Encoding.utf8.rawValue))
else {
return nil
}
return prettyPrintedString
}
}

View file

@ -0,0 +1,22 @@
import Foundation
import Models
public final class Networker {
let urlSession: URLSession
let appEnvironment: AppEnvironment
var defaultHeaders: [String: String] {
var headers = URLRequest.defaultHeaders
if let authToken = ValetKey.authToken.value() {
headers["authorization"] = authToken
}
return headers
}
public init(appEnvironment: AppEnvironment, urlSession: URLSession = .shared) {
self.appEnvironment = appEnvironment
self.urlSession = urlSession
}
}

View file

@ -0,0 +1,157 @@
import Combine
import Foundation
import Utils
struct ServerResource<ResponseModel> {
let urlRequest: URLRequest
let decode: (ServerResponse) -> ResponseModel?
}
struct ServerResponse {
let data: Data?
let httpUrlResponse: HTTPURLResponse?
let urlError: URLError?
init(data: Data?, response: URLResponse?) {
self.data = data
self.httpUrlResponse = response as? HTTPURLResponse
self.urlError = nil
}
init(error: Error) {
self.data = nil
self.httpUrlResponse = nil
self.urlError = error as? URLError
}
}
extension ServerResponse {
// extract custom error message here
var errorMessage: String? {
nil
}
}
extension URLSession {
// swiftlint:disable:next line_length
func performRequest<ResponseModel>(resource: ServerResource<ResponseModel>) -> AnyPublisher<ResponseModel, ServerError> {
let request = resource.urlRequest
return dataTaskPublisher(for: resource.urlRequest)
.tryMap { data, response -> ResponseModel in
let serverResponse = ServerResponse(data: data, response: response)
NetworkRequestLogger.log(request: request, serverResponse: serverResponse)
if let decodedValue = resource.decode(serverResponse) {
return decodedValue
}
throw ServerError(serverResponse: serverResponse)
}
.mapError { error -> ServerError in
let serverResponse = ServerResponse(error: error)
NetworkRequestLogger.log(request: request, serverResponse: serverResponse)
return ServerError(serverResponse: serverResponse)
}
.eraseToAnyPublisher()
}
}
extension URLRequest {
static var defaultHeaders: [String: String] {
var headers = [
"content-type": "application/json",
"user-agent": userAgent,
"app-language": Locale.preferredLanguages[0]
]
if let deviceLanguage = NSLocale.current.languageCode {
headers["device-language"] = deviceLanguage
}
return headers
}
public static func webRequest(
baseURL: URL,
urlPath: String,
queryParams: [String: String]?
) -> URLRequest {
create(
baseURL: baseURL,
urlPath: urlPath,
requestMethod: .get,
includeAuthToken: true,
additionalHeaders: [:],
queryParams: queryParams
)
}
static func create(
baseURL: URL,
urlPath: String,
requestMethod: HTTPMethod,
includeAuthToken: Bool = false,
additionalHeaders: [String: String] = [:],
queryParams: [String: String]? = nil
) -> URLRequest {
let url: URL = {
var urlComponents = URLComponents()
urlComponents.path = urlPath
urlComponents.queryItems = queryParams?.map {
URLQueryItem(
name: $0.key,
value: $0.value.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed)
)
}
return urlComponents.url(relativeTo: baseURL)!
}()
var request = URLRequest(url: url)
request.httpMethod = requestMethod.stringValue
for (headerField, value) in defaultHeaders {
request.addValue(value, forHTTPHeaderField: headerField)
}
if includeAuthToken, let authToken = ValetKey.authToken.value() {
request.addValue(authToken, forHTTPHeaderField: "authorization")
}
for (headerField, value) in additionalHeaders {
request.addValue(value, forHTTPHeaderField: headerField)
}
switch requestMethod {
case let .put(params), let .patch(params), let .post(params):
request.httpBody = params
case .get, .delete:
break
}
return request
}
}
enum HTTPMethod {
case get
case put(params: Data)
case patch(params: Data)
case post(params: Data)
case delete
var stringValue: String {
switch self {
case .get:
return "GET"
case .put:
return "PUT"
case .patch:
return "PATCH"
case .post:
return "POST"
case .delete:
return "DELETE"
}
}
}

View file

@ -0,0 +1,22 @@
import Combine
import Foundation
extension Networker {
func createAccount(params: Data) -> AnyPublisher<AuthPayload, ServerError> {
let urlRequest = URLRequest.create(
baseURL: appEnvironment.serverBaseURL,
urlPath: "/api/mobile-auth/create-account",
requestMethod: .post(params: params)
)
let resource = ServerResource<AuthPayload>(
urlRequest: urlRequest,
decode: AuthPayload.decode
)
return urlSession
.performRequest(resource: resource)
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}

View file

@ -0,0 +1,27 @@
import Combine
import Foundation
import Models
extension Networker {
func submitGoogleToken(idToken: String) -> AnyPublisher<AuthPayload, LoginError> {
let params = SignInParams(token: idToken, provider: .google)
let encodedParams = (try? JSONEncoder().encode(params)) ?? Data()
let urlRequest = URLRequest.create(
baseURL: appEnvironment.serverBaseURL,
urlPath: "/api/mobile-auth/sign-in",
requestMethod: .post(params: encodedParams)
)
let resource = ServerResource<AuthPayload>(
urlRequest: urlRequest,
decode: AuthPayload.decode
)
return urlSession
.performRequest(resource: resource)
.mapError { LoginError.make(serverError: $0) }
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}

View file

@ -0,0 +1,23 @@
import Combine
import Foundation
import Models
extension Networker {
func createPendingUser(params: Data) -> AnyPublisher<PendingUserAuthPayload, ServerError> {
let urlRequest = URLRequest.create(
baseURL: appEnvironment.serverBaseURL,
urlPath: "/api/mobile-auth/sign-up",
requestMethod: .post(params: params)
)
let resource = ServerResource<PendingUserAuthPayload>(
urlRequest: urlRequest,
decode: PendingUserAuthPayload.decode
)
return urlSession
.performRequest(resource: resource)
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}

View file

@ -0,0 +1,26 @@
import Combine
import Foundation
import Models
extension Networker {
func submitAppleToken(token: String) -> AnyPublisher<AuthPayload, ServerError> {
let params = SignInParams(token: token, provider: .apple)
let encodedParams = (try? JSONEncoder().encode(params)) ?? Data()
let urlRequest = URLRequest.create(
baseURL: appEnvironment.serverBaseURL,
urlPath: "/api/mobile-auth/sign-in",
requestMethod: .post(params: encodedParams)
)
let resource = ServerResource<AuthPayload>(
urlRequest: urlRequest,
decode: AuthPayload.decode
)
return urlSession
.performRequest(resource: resource)
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}

Some files were not shown because too many files have changed in this diff Show more