feat(ios): add share to support (#15340)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
  * Added iOS sharing for URLs, text, web pages, and images.
* Shared content can be reviewed, titled, and saved to an AFFiNE
workspace.
  * Choose destinations including workspaces, tags, and collections.
* Added previews, attachment handling, import status, retry support, and
success/error feedback.
* Pending shares are processed when AFFiNE opens or returns to the
foreground.
* **Bug Fixes**
* Improved workspace profile handling across different workspace types.
* Onboarding completion is now recorded immediately after successful
sign-in verification.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: DarkSky <25152247+darkskygit@users.noreply.github.com>
This commit is contained in:
keepClamDown
2026-08-27 19:09:51 +08:00
committed by GitHub
parent ca056ae7b9
commit 329839e467
33 changed files with 2723 additions and 48 deletions
@@ -46,13 +46,26 @@
9DAE9BD92D8D1AB0000C1D5A /* AppConfigManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DAE9BD82D8D1AA9000C1D5A /* AppConfigManager.swift */; };
9DEC59432D323EE40027CEBD /* Mutex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DEC59422D323EE00027CEBD /* Mutex.swift */; };
9DFCD1462D27D1D70028C92B /* libaffine_mobile_native.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 9DFCD1452D27D1D70028C92B /* libaffine_mobile_native.a */; };
A10100000000000000000001 /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = A10100000000000000000003 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
AA0000040000000000000000 /* AuthDateParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000010000000000000000 /* AuthDateParser.swift */; };
AA0000050000000000000000 /* AuthDateParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000020000000000000000 /* AuthDateParserTests.swift */; };
AB0000010000000000000000 /* ShareInboxSafety.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0000030000000000000000 /* ShareInboxSafety.swift */; };
AB0000020000000000000000 /* ShareInboxSafetyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0000040000000000000000 /* ShareInboxSafetyTests.swift */; };
C4C97C7C2D030BE000BC2AD1 /* affine_mobile_native.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4C97C6F2D0307B700BC2AD1 /* affine_mobile_native.swift */; };
C4C97C7D2D030BE000BC2AD1 /* affine_mobile_nativeFFI.h in Sources */ = {isa = PBXBuildFile; fileRef = C4C97C702D0307B700BC2AD1 /* affine_mobile_nativeFFI.h */; };
C4C97C7E2D030BE000BC2AD1 /* affine_mobile_nativeFFI.modulemap in Sources */ = {isa = PBXBuildFile; fileRef = C4C97C712D0307B700BC2AD1 /* affine_mobile_nativeFFI.modulemap */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
A10100000000000000000006 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 504EC2FC1FED79650016851F /* Project object */;
proxyType = 1;
remoteGlobalIDString = A1010000000000000000000D;
remoteInfo = ShareExtension;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
50E218312E0BE1A700EA4C6E /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
@@ -64,6 +77,17 @@
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
A1010000000000000000000B /* Embed Foundation Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
A10100000000000000000001 /* ShareExtension.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
@@ -109,8 +133,12 @@
9DAE9BD82D8D1AA9000C1D5A /* AppConfigManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppConfigManager.swift; sourceTree = "<group>"; };
9DEC59422D323EE00027CEBD /* Mutex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mutex.swift; sourceTree = "<group>"; };
9DFCD1452D27D1D70028C92B /* libaffine_mobile_native.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libaffine_mobile_native.a; sourceTree = "<group>"; };
A10100000000000000000003 /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
A10100000000000000000005 /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = "<group>"; };
AA0000010000000000000000 /* AuthDateParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ../App/Plugins/Auth/AuthDateParser.swift; sourceTree = "<group>"; };
AA0000020000000000000000 /* AuthDateParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthDateParserTests.swift; sourceTree = "<group>"; };
AB0000030000000000000000 /* ShareInboxSafety.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ../Shared/ShareInbox/ShareInboxSafety.swift; sourceTree = "<group>"; };
AB0000040000000000000000 /* ShareInboxSafetyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareInboxSafetyTests.swift; sourceTree = "<group>"; };
AA0000030000000000000000 /* AFFiNETests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AFFiNETests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = "<group>"; };
BF48636D7DB5BEE00770FD9A /* Pods_AFFiNE.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_AFFiNE.framework; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -122,6 +150,17 @@
FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
A10100000000000000000007 /* Exceptions for "ShareExtension" folder in "ShareExtension" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
ShareExtension.entitlements,
);
target = A1010000000000000000000D /* ShareExtension */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
9DAE85B72E7BAC3B00DB9F1D /* Plugins */ = {
isa = PBXFileSystemSynchronizedRootGroup;
@@ -130,6 +169,21 @@
path = Plugins;
sourceTree = "<group>";
};
A10100000000000000000008 /* ShareExtension */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
A10100000000000000000007 /* Exceptions for "ShareExtension" folder in "ShareExtension" target */,
);
path = ShareExtension;
sourceTree = "<group>";
};
A10100000000000000000009 /* Shared/ShareInbox */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = Shared/ShareInbox;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
@@ -147,6 +201,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
A1010000000000000000000A /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
AA0000070000000000000000 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
@@ -173,6 +234,8 @@
children = (
C4C97C722D0307B700BC2AD1 /* uniffi */,
9D90BE242CCB9876006677DB /* App */,
A10100000000000000000008 /* ShareExtension */,
A10100000000000000000009 /* Shared/ShareInbox */,
AA00000D0000000000000000 /* AppTests */,
50802D5F2D112F7D00694021 /* Packages */,
504EC3051FED79650016851F /* Products */,
@@ -187,6 +250,7 @@
isa = PBXGroup;
children = (
504EC3041FED79650016851F /* AFFiNE.app */,
A10100000000000000000003 /* ShareExtension.appex */,
AA0000030000000000000000 /* AFFiNETests.xctest */,
);
name = Products;
@@ -247,6 +311,7 @@
9D90BE242CCB9876006677DB /* App */ = {
isa = PBXGroup;
children = (
A10100000000000000000005 /* App.entitlements */,
9DAE9BD82D8D1AA9000C1D5A /* AppConfigManager.swift */,
9DEC59422D323EE00027CEBD /* Mutex.swift */,
9D52FC422D26CDB600105D0A /* JSValueContainerExt.swift */,
@@ -277,6 +342,8 @@
children = (
AA0000010000000000000000 /* AuthDateParser.swift */,
AA0000020000000000000000 /* AuthDateParserTests.swift */,
AB0000030000000000000000 /* ShareInboxSafety.swift */,
AB0000040000000000000000 /* ShareInboxSafetyTests.swift */,
);
path = AppTests;
sourceTree = "<group>";
@@ -306,24 +373,48 @@
504EC3021FED79650016851F /* Resources */,
9592DBEFFC6D2A0C8D5DEB22 /* [CP] Embed Pods Frameworks */,
50E218312E0BE1A700EA4C6E /* Embed Frameworks */,
A1010000000000000000000B /* Embed Foundation Extensions */,
);
buildRules = (
);
dependencies = (
A1010000000000000000000C /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
9DAE85B72E7BAC3B00DB9F1D /* Plugins */,
A10100000000000000000009 /* Shared/ShareInbox */,
);
name = AFFiNE;
packageProductDependencies = (
5027D4792E7C5FC100ADD25A /* AffineResources */,
5027D4772E7C5FBD00ADD25A /* AffinePaywall */,
5027D47B2E7C5FC400ADD25A /* AffineGraphQL */,
500D72F62E825064006E553F /* Intelligents */,
);
productName = App;
productReference = 504EC3041FED79650016851F /* AFFiNE.app */;
productType = "com.apple.product-type.application";
};
A1010000000000000000000D /* ShareExtension */ = {
isa = PBXNativeTarget;
buildConfigurationList = A1010000000000000000000E /* Build configuration list for PBXNativeTarget "ShareExtension" */;
buildPhases = (
A1010000000000000000000F /* Sources */,
A1010000000000000000000A /* Frameworks */,
A10100000000000000000010 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
9DAE85B72E7BAC3B00DB9F1D /* Plugins */,
A10100000000000000000008 /* ShareExtension */,
A10100000000000000000009 /* Shared/ShareInbox */,
);
name = AFFiNE;
packageProductDependencies = (
500D72F62E825064006E553F /* Intelligents */,
5027D4772E7C5FBD00ADD25A /* AffinePaywall */,
5027D4792E7C5FC100ADD25A /* AffineResources */,
5027D47B2E7C5FC400ADD25A /* AffineGraphQL */,
);
productName = App;
productReference = 504EC3041FED79650016851F /* AFFiNE.app */;
productType = "com.apple.product-type.application";
name = ShareExtension;
productName = ShareExtension;
productReference = A10100000000000000000003 /* ShareExtension.appex */;
productType = "com.apple.product-type.app-extension";
};
AA0000090000000000000000 /* AFFiNETests */ = {
isa = PBXNativeTarget;
@@ -355,6 +446,19 @@
504EC3031FED79650016851F = {
CreatedOnToolsVersion = 9.2;
LastSwiftMigration = 1600;
SystemCapabilities = {
com.apple.ApplicationGroups.iOS = {
enabled = 1;
};
};
};
A1010000000000000000000D = {
CreatedOnToolsVersion = 16.0;
SystemCapabilities = {
com.apple.ApplicationGroups.iOS = {
enabled = 1;
};
};
};
AA0000090000000000000000 = {
CreatedOnToolsVersion = 26.0;
@@ -381,6 +485,7 @@
projectRoot = "";
targets = (
504EC3031FED79650016851F /* AFFiNE */,
A1010000000000000000000D /* ShareExtension */,
AA0000090000000000000000 /* AFFiNETests */,
);
};
@@ -401,6 +506,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
A10100000000000000000010 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
AA0000080000000000000000 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
@@ -500,17 +612,34 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
A1010000000000000000000F /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
AA0000060000000000000000 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
AA0000040000000000000000 /* AuthDateParser.swift in Sources */,
AA0000050000000000000000 /* AuthDateParserTests.swift in Sources */,
AB0000010000000000000000 /* ShareInboxSafety.swift in Sources */,
AB0000020000000000000000 /* ShareInboxSafetyTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
A1010000000000000000000C /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = A1010000000000000000000D /* ShareExtension */;
targetProxy = A10100000000000000000006 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
9D90BE222CCB9876006677DB /* Main.storyboard */ = {
isa = PBXVariantGroup;
@@ -656,6 +785,7 @@
baseConfigurationReference = 3256F4410D881A03FE77D092 /* Pods-AFFiNE.debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 17;
@@ -692,6 +822,7 @@
baseConfigurationReference = E5E5070D1CA1200D4964D91F /* Pods-AFFiNE.release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 17;
@@ -723,6 +854,62 @@
};
name = Release;
};
A10100000000000000000011 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 17;
DEVELOPMENT_TEAM = 73YMMDVT2M;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = ShareExtension/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.5;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 2026.7.14;
PRODUCT_BUNDLE_IDENTIFIER = app.affine.pro.ShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
A10100000000000000000012 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 17;
DEVELOPMENT_TEAM = 73YMMDVT2M;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = ShareExtension/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.5;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 2026.7.14;
PRODUCT_BUNDLE_IDENTIFIER = app.affine.pro.ShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
AA00000A0000000000000000 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
@@ -776,6 +963,15 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
A1010000000000000000000E /* Build configuration list for PBXNativeTarget "ShareExtension" */ = {
isa = XCConfigurationList;
buildConfigurations = (
A10100000000000000000011 /* Debug */,
A10100000000000000000012 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
AA00000C0000000000000000 /* Build configuration list for PBXNativeTarget "AFFiNETests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
@@ -15,7 +15,7 @@
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "A11F1E000000000000000005"
BlueprintIdentifier = "AA0000090000000000000000"
BuildableName = "AFFiNETests.xctest"
BlueprintName = "AFFiNETests"
ReferencedContainer = "container:App.xcodeproj">
@@ -30,10 +30,11 @@
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
<Testables>
<TestableReference skipped = "NO">
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "A11F1E000000000000000005"
BlueprintIdentifier = "AA0000090000000000000000"
BuildableName = "AFFiNETests.xctest"
BlueprintName = "AFFiNETests"
ReferencedContainer = "container:App.xcodeproj">
@@ -41,4 +42,30 @@
</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"
queueDebuggingEnableBacktraceRecording = "Yes">
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
@@ -71,6 +71,7 @@ class AFFiNEViewController: CAPBridgeViewController, UIScrollViewDelegate, Affin
NbStorePlugin(),
PayWallPlugin(associatedController: self),
PreviewPlugin(),
ShareInboxPlugin(),
]
plugins.forEach { bridge?.registerPluginInstance($0) }
}
@@ -90,6 +91,14 @@ class AFFiNEViewController: CAPBridgeViewController, UIScrollViewDelegate, Affin
}
intelligentsButtonTimer = timer
RunLoop.main.add(timer, forMode: .common)
processShareInboxIfNeeded()
}
func processShareInboxIfNeeded() {
guard let webView, !ShareInboxStore.shared.pendingItems().isEmpty else { return }
webView.evaluateJavaScript(
"window.dispatchEvent(new Event('affine:share-inbox'))"
)
}
private func checkEligibilityOfIntelligent() {
@@ -0,0 +1,10 @@
<?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.affine.pro</string>
</array>
</dict>
</plist>
@@ -26,6 +26,31 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
func applicationDidBecomeActive(_: UIApplication) {
// Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
if let root = window?.rootViewController {
findAffineViewController(from: root)?.processShareInboxIfNeeded()
}
}
private func findAffineViewController(from root: UIViewController) -> AFFiNEViewController? {
if let affine = root as? AFFiNEViewController {
return affine
}
if let navigation = root as? UINavigationController {
for controller in navigation.viewControllers {
if let found = findAffineViewController(from: controller) {
return found
}
}
}
for child in root.children {
if let found = findAffineViewController(from: child) {
return found
}
}
if let presented = root.presentedViewController {
return findAffineViewController(from: presented)
}
return nil
}
func applicationWillTerminate(_: UIApplication) {
@@ -35,7 +60,15 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
// Called when the app was launched with a url. Feel free to add additional processing here,
// but if you want the App API to support tracking app url opens, make sure to keep this call
ApplicationDelegateProxy.shared.application(app, open: url, options: options)
let handled = ApplicationDelegateProxy.shared.application(app, open: url, options: options)
let isShareInboxURL = url.scheme == "affine" && url.host == "share-inbox"
if isShareInboxURL {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
guard let self, let root = self.window?.rootViewController else { return }
self.findAffineViewController(from: root)?.processShareInboxIfNeeded()
}
}
return handled || isShareInboxURL
}
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
@@ -0,0 +1,120 @@
import Capacitor
import Foundation
@objc(ShareInboxPlugin)
public final class ShareInboxPlugin: CAPPlugin, CAPBridgedPlugin {
public let identifier = "ShareInboxPlugin"
public let jsName = "ShareInbox"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "listPending", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "updateTarget", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "resolveAttachment", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "complete", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "setError", returnType: CAPPluginReturnPromise),
]
private let store = ShareInboxStore.shared
private let encoder: JSONEncoder = {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
return encoder
}()
@objc func listPending(_ call: CAPPluginCall) {
do {
let items = try store.pendingItems().compactMap { item -> [String: Any]? in
if item.result != nil {
try? store.remove(item)
return nil
}
let data = try encoder.encode(item)
return try JSONSerialization.jsonObject(with: data) as? [String: Any]
}
call.resolve(["items": items])
} catch {
call.reject("Failed to read shared content.", nil, error)
}
}
@objc func updateTarget(_ call: CAPPluginCall) {
do {
var item = try item(from: call)
guard let target = call.getObject("target"),
let workspaceId = target["workspaceId"] as? String,
let workspaceFlavour = target["workspaceFlavour"] as? String,
let tagIds = target["tagIds"] as? [String]
else {
throw ShareInboxError.invalidPayload
}
item.target = ShareInboxTarget(
workspaceId: workspaceId,
workspaceFlavour: workspaceFlavour,
tagIds: tagIds,
collectionId: target["collectionId"] as? String
)
item.lastError = nil
try store.update(item)
call.resolve()
} catch {
call.reject("Failed to update the share destination.", nil, error)
}
}
@objc func resolveAttachment(_ call: CAPPluginCall) {
do {
let item = try item(from: call)
guard let attachment = item.attachments.first,
let url = store.attachmentURL(for: attachment),
FileManager.default.fileExists(atPath: url.path)
else {
call.resolve([:])
return
}
call.resolve([
"path": url.absoluteString,
"mimeType": attachment.mimeType,
])
} catch {
call.reject("Failed to resolve the shared attachment.", nil, error)
}
}
@objc func complete(_ call: CAPPluginCall) {
do {
var item = try item(from: call)
guard let docId = call.getString("docId"), !docId.isEmpty else {
throw ShareInboxError.invalidPayload
}
item.result = ShareInboxResult(docId: docId, committedAt: Date())
item.lastError = nil
try store.update(item)
try store.remove(item)
call.resolve()
} catch {
call.reject("Failed to finish importing shared content.", nil, error)
}
}
@objc func setError(_ call: CAPPluginCall) {
do {
var item = try item(from: call)
guard let error = call.getString("error"), !error.isEmpty else {
throw ShareInboxError.invalidPayload
}
item.lastError = error
try store.update(item)
call.resolve()
} catch {
call.reject("Failed to update shared content.", nil, error)
}
}
private func item(from call: CAPPluginCall) throws -> ShareInboxItem {
guard let itemId = call.getString("itemId"),
let item = store.pendingItems().first(where: { $0.id == itemId })
else {
throw ShareInboxError.invalidPayload
}
return item
}
}
@@ -82,6 +82,7 @@ class RootViewController: UINavigationController {
showOnboardingAlert(message: error.localizedDescription)
return
}
OnboardingFlag.markCompleted()
guard isSignedIn else {
return
}
@@ -0,0 +1,27 @@
import XCTest
final class ShareInboxSafetyTests: XCTestCase {
func testManifestIDsMustBeUUIDs() {
let id = UUID().uuidString
XCTAssertEqual(ShareInboxSafety.normalizedManifestID(id.lowercased()), id)
XCTAssertNil(ShareInboxSafety.normalizedManifestID("../item"))
XCTAssertNil(ShareInboxSafety.normalizedManifestID("nested/item"))
}
func testWebURLsRejectCredentialsAndUnsupportedSchemes() {
XCTAssertEqual(
ShareInboxSafety.normalizedWebURL("https://example.com/page"),
"https://example.com/page"
)
XCTAssertNil(ShareInboxSafety.normalizedWebURL("file:///private/item"))
XCTAssertNil(ShareInboxSafety.normalizedWebURL("https://user@example.com/page"))
}
func testSupportedImagesAreDetectedByContent() {
XCTAssertEqual(
ShareInboxSafety.detectRasterImageMimeType(Data([0xFF, 0xD8, 0xFF, 0x00])),
"image/jpeg"
)
XCTAssertNil(ShareInboxSafety.detectRasterImageMimeType(Data("<svg/>".utf8)))
}
}
@@ -0,0 +1,47 @@
<?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>CFBundleDisplayName</key>
<string>AFFiNE</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>
<dict>
<key>NSExtensionActivationDictionaryVersion</key>
<integer>2</integer>
<key>NSExtensionActivationSupportsText</key>
<true/>
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
<integer>1</integer>
<key>NSExtensionActivationSupportsWebPageWithMaxCount</key>
<integer>1</integer>
<key>NSExtensionActivationSupportsImageWithMaxCount</key>
<integer>1</integer>
</dict>
<key>NSExtensionJavaScriptPreprocessingFile</key>
<string>SafariPageCapture</string>
</dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.share-services</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
</dict>
</dict>
</plist>
@@ -0,0 +1,14 @@
var SafariPageCapture = function () {};
SafariPageCapture.prototype = {
run: function (context) {
var selection = window.getSelection();
context.completionFunction({
title: document.title || '',
url: document.location.href,
selectedText: selection ? selection.toString() : '',
});
},
};
var ExtensionPreprocessingJS = new SafariPageCapture();
@@ -0,0 +1,10 @@
<?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.affine.pro</string>
</array>
</dict>
</plist>
@@ -0,0 +1,79 @@
import SwiftUI
struct ShareExtensionView: View {
@ObservedObject var viewModel: ShareViewModel
var onCancel: () -> Void
var onSave: () -> Void
var body: some View {
NavigationStack {
Group {
if viewModel.isLoading {
ProgressView("Reading shared content…")
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
content
}
}
.navigationTitle("AFFiNE")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Not now", action: onCancel)
.disabled(viewModel.isSaving)
}
ToolbarItem(placement: .confirmationAction) {
if viewModel.isSaving {
ProgressView()
} else {
Button(viewModel.actionTitle, action: onSave)
.fontWeight(.semibold)
.disabled(!viewModel.canSave)
}
}
}
}
}
private var content: some View {
Form {
Section {
Text("Choose a workspace in AFFiNE. This item will stay saved until then.")
.font(.footnote)
.foregroundStyle(.secondary)
}
Section {
HStack(alignment: .top, spacing: 12) {
Image(systemName: viewModel.previewImage == nil ? "doc.text" : "photo")
.font(.title2)
.frame(width: 32, height: 32)
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 4) {
TextField("Title", text: $viewModel.title)
.font(.headline)
if !viewModel.previewText.isEmpty {
Text(viewModel.previewText)
.lineLimit(3)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
}
if let image = viewModel.previewImage {
Image(uiImage: image)
.resizable()
.scaledToFit()
.frame(maxHeight: 180)
}
}
if let errorMessage = viewModel.errorMessage {
Section {
Text(errorMessage)
.foregroundStyle(.red)
}
}
}
}
}
@@ -0,0 +1,89 @@
//
// ShareViewController.swift
// ShareExtension
//
import SwiftUI
import UIKit
final class ShareViewController: UIViewController {
private let viewModel = ShareViewModel()
private var hostingController: UIHostingController<ShareExtensionView>?
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
let rootView = ShareExtensionView(
viewModel: viewModel,
onCancel: { [weak self] in
self?.cancel()
},
onSave: { [weak self] in
self?.save()
}
)
let hosting = UIHostingController(rootView: rootView)
hostingController = hosting
addChild(hosting)
view.addSubview(hosting.view)
hosting.view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
hosting.view.topAnchor.constraint(equalTo: view.topAnchor),
hosting.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
hosting.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
hosting.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
hosting.didMove(toParent: self)
Task {
await viewModel.load(from: extensionContext)
}
}
private func cancel() {
extensionContext?.completeRequest(returningItems: nil)
}
private func save() {
Task { [weak self] in
guard let self else { return }
let success = await viewModel.save()
guard success else { return }
_ = await openMainAppIfPossible()
extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
}
}
@discardableResult
private func openMainAppIfPossible() async -> Bool {
let url = ShareInboxConstants.openInboxURL
let openedByContext = await withCheckedContinuation { continuation in
extensionContext?.open(url) { success in
continuation.resume(returning: success)
} ?? continuation.resume(returning: false)
}
let opened = openedByContext || openMainAppViaResponderChain(url)
#if DEBUG
NSLog(
"[AFFiNE Share] open url=%@ extensionContext=%@ final=%@",
url.absoluteString,
openedByContext ? "YES" : "NO",
opened ? "YES" : "NO"
)
#endif
return opened
}
private func openMainAppViaResponderChain(_ url: URL) -> Bool {
var responder: UIResponder? = self
while let current = responder {
if let application = current as? UIApplication {
application.open(url, options: [:], completionHandler: nil)
return true
}
responder = current.next
}
return false
}
}
@@ -0,0 +1,93 @@
import Foundation
import UIKit
@MainActor
final class ShareViewModel: ObservableObject {
@Published var title = ""
@Published var previewText = ""
@Published var previewImage: UIImage?
@Published var isLoading = true
@Published var isSaving = false
@Published var hasSaved = false
@Published var errorMessage: String?
var actionTitle: String {
"Open AFFiNE"
}
var canSave: Bool {
!isLoading
&& !isSaving
&& !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& draft?.content != nil
}
private var draft: SharePayloadDraft?
private let store: ShareInboxStore
init(store: ShareInboxStore = .shared) {
self.store = store
}
func load(from extensionContext: NSExtensionContext?) async {
isLoading = true
defer { isLoading = false }
let items = extensionContext?.inputItems.compactMap { $0 as? NSExtensionItem } ?? []
let built = await SharePayloadBuilder.build(from: items)
draft = built
title = built.title
previewText = built.previewText
errorMessage = built.errorMessage
if let file = built.file {
previewImage = UIImage(data: file.data)?
.preparingThumbnail(of: CGSize(width: 480, height: 480))
}
}
func save() async -> Bool {
guard !isSaving, !hasSaved else { return false }
isSaving = true
defer { isSaving = false }
let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedTitle.isEmpty else {
errorMessage = "Title is required."
return false
}
guard let draft, let content = draft.content else {
errorMessage = draft?.errorMessage ?? "Nothing to share."
return false
}
let itemId = UUID().uuidString
var attachments: [ShareInboxAttachment] = []
var attachmentData: [(ShareInboxAttachment, Data)] = []
if let file = draft.file {
let attachment = ShareInboxAttachment(
fileName: file.fileName,
mimeType: file.mimeType,
relativePath: "\(itemId)/\(file.fileName)"
)
attachments = [attachment]
attachmentData = [(attachment, file.data)]
}
let item = ShareInboxItem(
id: itemId,
title: trimmedTitle,
content: content,
previewText: draft.previewText,
attachments: attachments
)
do {
try store.enqueue(item, attachmentData: attachmentData)
hasSaved = true
return true
} catch {
errorMessage = "Failed to save shared content."
return false
}
}
}
@@ -0,0 +1,14 @@
//
// ShareInboxConstants.swift
// Shared between AFFiNE and ShareExtension
//
import Foundation
enum ShareInboxConstants {
static let appGroupId = "group.app.affine.pro"
static let inboxDirectoryName = "ShareInbox"
static let attachmentsDirectoryName = "Attachments"
static let invalidDirectoryName = "Invalid"
static let openInboxURL = URL(string: "affine://share-inbox")!
}
@@ -0,0 +1,82 @@
import Foundation
struct ShareInboxAttachment: Codable, Equatable {
var fileName: String
var mimeType: String
var relativePath: String
}
enum ShareInboxContentKind: String, Codable {
case url
case text
case image
}
struct ShareInboxContent: Codable, Equatable {
var kind: ShareInboxContentKind
var url: String?
var text: String?
}
struct ShareInboxTarget: Codable, Equatable {
var workspaceId: String
var workspaceFlavour: String
var tagIds: [String]
var collectionId: String?
}
struct ShareInboxResult: Codable, Equatable {
var docId: String
var committedAt: Date
}
struct ShareInboxItem: Codable, Equatable, Identifiable {
var id: String
var documentId: String
var createdAt: Date
var title: String
var content: ShareInboxContent
var target: ShareInboxTarget?
var previewText: String?
var attachments: [ShareInboxAttachment]
var result: ShareInboxResult?
var lastError: String?
init(
id: String = UUID().uuidString,
documentId: String = UUID().uuidString,
createdAt: Date = Date(),
title: String,
content: ShareInboxContent,
target: ShareInboxTarget? = nil,
previewText: String? = nil,
attachments: [ShareInboxAttachment] = [],
result: ShareInboxResult? = nil,
lastError: String? = nil
) {
self.id = id
self.documentId = documentId
self.createdAt = createdAt
self.title = title
self.content = content
self.target = target
self.previewText = previewText
self.attachments = attachments
self.result = result
self.lastError = lastError
}
}
struct SharePayloadFile: Equatable {
var data: Data
var mimeType: String
var fileName: String
}
struct SharePayloadDraft: Equatable {
var title: String
var content: ShareInboxContent?
var previewText: String
var file: SharePayloadFile?
var errorMessage: String?
}
@@ -0,0 +1,50 @@
import Foundation
enum ShareInboxSafety {
static func normalizedManifestID(_ value: String) -> String? {
UUID(uuidString: value)?.uuidString
}
static func normalizedWebURL(_ value: String) -> String? {
guard
let components = URLComponents(
string: value.trimmingCharacters(in: .whitespacesAndNewlines)
),
let scheme = components.scheme?.lowercased(),
scheme == "http" || scheme == "https",
components.host?.isEmpty == false,
components.user == nil,
components.password == nil,
let url = components.url
else {
return nil
}
return url.absoluteString
}
static func detectRasterImageMimeType(_ data: Data) -> String? {
let bytes = [UInt8](data.prefix(12))
if bytes.starts(with: [0xFF, 0xD8, 0xFF]) {
return "image/jpeg"
}
if bytes.starts(with: [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) {
return "image/png"
}
if bytes.starts(with: Array("GIF87a".utf8)) || bytes.starts(with: Array("GIF89a".utf8)) {
return "image/gif"
}
if bytes.count >= 12,
Array(bytes[0..<4]) == Array("RIFF".utf8),
Array(bytes[8..<12]) == Array("WEBP".utf8)
{
return "image/webp"
}
if bytes.count >= 12, Array(bytes[4..<8]) == Array("ftyp".utf8) {
let brand = String(decoding: bytes[8..<12], as: UTF8.self).lowercased()
if ["heic", "heix", "hevc", "hevx", "mif1", "msf1"].contains(brand) {
return "image/heic"
}
}
return nil
}
}
@@ -0,0 +1,188 @@
//
// ShareInboxStore.swift
// Shared between AFFiNE and ShareExtension
//
import Foundation
final class ShareInboxStore {
static let shared = ShareInboxStore()
private let fileManager = FileManager.default
private let encoder: JSONEncoder = {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
return encoder
}()
private let decoder: JSONDecoder = {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return decoder
}()
private init() {}
var containerURL: URL? {
fileManager.containerURL(forSecurityApplicationGroupIdentifier: ShareInboxConstants.appGroupId)
}
private var inboxDirectoryURL: URL? {
guard let containerURL else { return nil }
return containerURL
.appendingPathComponent(ShareInboxConstants.inboxDirectoryName, isDirectory: true)
}
private var attachmentsDirectoryURL: URL? {
guard let inboxDirectoryURL else { return nil }
return inboxDirectoryURL
.appendingPathComponent(ShareInboxConstants.attachmentsDirectoryName, isDirectory: true)
}
private var invalidDirectoryURL: URL? {
guard let inboxDirectoryURL else { return nil }
return inboxDirectoryURL
.appendingPathComponent(ShareInboxConstants.invalidDirectoryName, isDirectory: true)
}
@discardableResult
func ensureDirectories() -> Bool {
guard let inboxDirectoryURL, let attachmentsDirectoryURL, let invalidDirectoryURL else {
return false
}
do {
try fileManager.createDirectory(at: inboxDirectoryURL, withIntermediateDirectories: true)
try fileManager.createDirectory(at: attachmentsDirectoryURL, withIntermediateDirectories: true)
try fileManager.createDirectory(at: invalidDirectoryURL, withIntermediateDirectories: true)
return true
} catch {
return false
}
}
func enqueue(_ item: ShareInboxItem, attachmentData: [(ShareInboxAttachment, Data)] = []) throws {
guard ensureDirectories() else {
throw ShareInboxError.containerUnavailable
}
var writtenURLs: [URL] = []
var writtenParentURLs: [URL] = []
do {
for (attachment, data) in attachmentData {
guard let destination = attachmentURL(for: attachment) else {
throw ShareInboxError.invalidPayload
}
let parent = destination.deletingLastPathComponent()
try fileManager.createDirectory(at: parent, withIntermediateDirectories: true)
writtenParentURLs.append(parent)
try data.write(to: destination, options: .atomic)
writtenURLs.append(destination)
}
guard let fileURL = manifestURL(for: item.id) else {
throw ShareInboxError.invalidPayload
}
let data = try encoder.encode(item)
try data.write(to: fileURL, options: .atomic)
} catch {
for url in writtenURLs {
try? fileManager.removeItem(at: url)
}
for url in writtenParentURLs.reversed() {
try? fileManager.removeItem(at: url)
}
throw error
}
}
func update(_ item: ShareInboxItem) throws {
guard ensureDirectories(), let fileURL = manifestURL(for: item.id) else {
throw ShareInboxError.containerUnavailable
}
try encoder.encode(item).write(to: fileURL, options: .atomic)
}
func pendingItems() -> [ShareInboxItem] {
guard ensureDirectories(), let inboxDirectoryURL else { return [] }
guard let urls = try? fileManager.contentsOfDirectory(
at: inboxDirectoryURL,
includingPropertiesForKeys: [.contentModificationDateKey],
options: [.skipsHiddenFiles]
) else {
return []
}
return urls
.filter { $0.pathExtension.lowercased() == "json" }
.compactMap { url -> ShareInboxItem? in
guard let data = try? Data(contentsOf: url) else {
quarantine(url)
return nil
}
guard let item = try? decoder.decode(ShareInboxItem.self, from: data),
let expectedURL = manifestURL(for: item.id),
expectedURL.lastPathComponent.caseInsensitiveCompare(url.lastPathComponent) == .orderedSame
else {
quarantine(url)
return nil
}
return item
}
.sorted { $0.createdAt < $1.createdAt }
}
func attachmentURL(for attachment: ShareInboxAttachment) -> URL? {
guard let attachmentsDirectoryURL else { return nil }
guard !attachment.relativePath.isEmpty,
!attachment.relativePath.hasPrefix("/"),
!attachment.relativePath.split(separator: "/").contains("..")
else {
return nil
}
let base = attachmentsDirectoryURL.standardizedFileURL
let candidate = base.appendingPathComponent(attachment.relativePath).standardizedFileURL
guard candidate.path.hasPrefix(base.path + "/") else { return nil }
return candidate
}
func remove(_ item: ShareInboxItem) throws {
guard let fileURL = manifestURL(for: item.id) else {
throw ShareInboxError.invalidPayload
}
try fileManager.removeItem(at: fileURL)
for attachment in item.attachments {
if let url = attachmentURL(for: attachment) {
try? fileManager.removeItem(at: url)
try? fileManager.removeItem(at: url.deletingLastPathComponent())
}
}
}
private func manifestURL(for itemId: String) -> URL? {
guard let inboxDirectoryURL,
let normalizedId = ShareInboxSafety.normalizedManifestID(itemId)
else {
return nil
}
let base = inboxDirectoryURL.standardizedFileURL
let candidate = base
.appendingPathComponent("\(normalizedId).json")
.standardizedFileURL
guard candidate.path.hasPrefix(base.path + "/") else { return nil }
return candidate
}
private func quarantine(_ url: URL) {
guard let invalidDirectoryURL else { return }
let destination = invalidDirectoryURL.appendingPathComponent(url.lastPathComponent)
try? fileManager.removeItem(at: destination)
try? fileManager.moveItem(at: url, to: destination)
}
}
enum ShareInboxError: Error {
case containerUnavailable
case invalidPayload
case payloadTooLarge
}
@@ -0,0 +1,289 @@
import Foundation
import UIKit
import UniformTypeIdentifiers
enum SharePayloadBuilder {
private static let maxImageBytes = 12 * 1024 * 1024
private static let maxTextCharacters = 250_000
static func build(from extensionItems: [NSExtensionItem]) async -> SharePayloadDraft {
var title = "Shared"
var url: String?
var text: String?
var fallbackText: String?
var file: SharePayloadFile?
var imageProviderCount = 0
for item in extensionItems {
for provider in item.attachments ?? [] {
if provider.hasItemConformingToTypeIdentifier(UTType.propertyList.identifier),
let page = try? await loadSafariPage(from: provider)
{
title = page.title ?? title
url = page.url ?? url
text = page.selectedText.map {
String($0.prefix(maxTextCharacters))
} ?? text
}
if url == nil,
provider.hasItemConformingToTypeIdentifier(UTType.url.identifier),
let loadedURL = try? await loadURL(from: provider),
let normalized = ShareInboxSafety.normalizedWebURL(loadedURL.absoluteString)
{
url = normalized
if title == "Shared" {
title = loadedURL.host ?? normalized
}
}
if fallbackText == nil,
provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier),
let loadedText = try? await loadText(from: provider)
{
let trimmed = loadedText.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty, trimmed != url {
fallbackText = String(trimmed.prefix(maxTextCharacters))
}
}
if provider.hasItemConformingToTypeIdentifier(UTType.image.identifier) {
imageProviderCount += 1
if imageProviderCount > 1 {
return failure(title: title, message: "Share one image at a time.")
}
do {
file = try await loadImage(from: provider)
} catch ShareInboxError.payloadTooLarge {
return failure(title: title, message: "The image must be smaller than 12 MB.")
} catch {
return failure(title: title, message: "This image format is not supported.")
}
}
}
if let attributedText = nonEmpty(item.attributedContentText?.string),
attributedText != url
{
if title == "Shared" {
title = firstNonEmptyLine(attributedText)
}
if fallbackText == nil {
fallbackText = String(attributedText.prefix(maxTextCharacters))
}
}
}
if text == nil, url == nil {
text = fallbackText
}
if title == "Shared" {
if let file {
title = (file.fileName as NSString).deletingPathExtension
} else if let url, let host = URL(string: url)?.host {
title = host
} else if let fallbackText {
title = firstNonEmptyLine(fallbackText)
}
}
let content: ShareInboxContent?
if file != nil {
content = ShareInboxContent(kind: .image, url: url, text: text)
} else if let url {
content = ShareInboxContent(kind: .url, url: url, text: text)
} else if let text {
content = ShareInboxContent(kind: .text, url: nil, text: text)
} else {
content = nil
}
guard let content else {
return failure(
title: title,
message: "AFFiNE can currently save links, text, or one image."
)
}
let preview = text ?? url ?? file?.fileName ?? "Shared content"
return SharePayloadDraft(
title: sanitizeTitle(title),
content: content,
previewText: String(preview.prefix(280)),
file: file,
errorMessage: nil
)
}
private static func failure(title: String, message: String) -> SharePayloadDraft {
SharePayloadDraft(
title: sanitizeTitle(title),
content: nil,
previewText: "",
file: nil,
errorMessage: message
)
}
private struct SafariPage {
var title: String?
var url: String?
var selectedText: String?
}
private static func loadSafariPage(from provider: NSItemProvider) async throws -> SafariPage {
let item: Any = try await withCheckedThrowingContinuation { continuation in
provider.loadItem(
forTypeIdentifier: UTType.propertyList.identifier,
options: nil
) { item, error in
if let error {
continuation.resume(throwing: error)
} else if let item {
continuation.resume(returning: item)
} else {
continuation.resume(throwing: ShareInboxError.invalidPayload)
}
}
}
let dictionary: [String: Any]?
if let value = item as? [String: Any] {
dictionary = value
} else if let data = item as? Data {
dictionary = try? PropertyListSerialization.propertyList(
from: data,
options: [],
format: nil
) as? [String: Any]
} else {
dictionary = nil
}
guard let dictionary else { throw ShareInboxError.invalidPayload }
let result =
dictionary[NSExtensionJavaScriptPreprocessingResultsKey] as? [String: Any]
?? dictionary
let pageURL = (result["url"] as? String).flatMap(ShareInboxSafety.normalizedWebURL)
return SafariPage(
title: nonEmpty(result["title"] as? String),
url: pageURL,
selectedText: nonEmpty(result["selectedText"] as? String)
)
}
private static func loadURL(from provider: NSItemProvider) async throws -> URL {
try await withCheckedThrowingContinuation { continuation in
provider.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { item, error in
if let error {
continuation.resume(throwing: error)
} else if let url = item as? URL {
continuation.resume(returning: url)
} else if let value = item as? String, let url = URL(string: value) {
continuation.resume(returning: url)
} else {
continuation.resume(throwing: ShareInboxError.invalidPayload)
}
}
}
}
private static func loadText(from provider: NSItemProvider) async throws -> String {
try await withCheckedThrowingContinuation { continuation in
provider.loadItem(
forTypeIdentifier: UTType.plainText.identifier,
options: nil
) { item, error in
if let error {
continuation.resume(throwing: error)
} else if let text = item as? String {
continuation.resume(returning: text)
} else if let attributed = item as? NSAttributedString {
continuation.resume(returning: attributed.string)
} else {
continuation.resume(throwing: ShareInboxError.invalidPayload)
}
}
}
}
private static func loadImage(from provider: NSItemProvider) async throws -> SharePayloadFile {
let item: Any = try await withCheckedThrowingContinuation { continuation in
provider.loadItem(forTypeIdentifier: UTType.image.identifier, options: nil) { item, error in
if let error {
continuation.resume(throwing: error)
} else if let item {
continuation.resume(returning: item)
} else {
continuation.resume(throwing: ShareInboxError.invalidPayload)
}
}
}
let data: Data
let suggestedName: String?
if let value = item as? Data {
data = value
suggestedName = provider.suggestedName
} else if let url = item as? URL, url.isFileURL {
let values = try url.resourceValues(forKeys: [.fileSizeKey])
if let fileSize = values.fileSize, fileSize > maxImageBytes {
throw ShareInboxError.payloadTooLarge
}
data = try Data(contentsOf: url, options: .mappedIfSafe)
suggestedName = url.lastPathComponent
} else if let image = item as? UIImage, let jpeg = image.jpegData(compressionQuality: 0.9) {
data = jpeg
suggestedName = provider.suggestedName
} else {
throw ShareInboxError.invalidPayload
}
guard data.count <= maxImageBytes else { throw ShareInboxError.payloadTooLarge }
guard let mimeType = ShareInboxSafety.detectRasterImageMimeType(data) else {
throw ShareInboxError.invalidPayload
}
let fileExtension = fileExtension(for: mimeType)
let baseName = nonEmpty(suggestedName)
.map { ($0 as NSString).lastPathComponent }
.flatMap { nonEmpty(($0 as NSString).deletingPathExtension) }
?? "shared-image"
return SharePayloadFile(
data: data,
mimeType: mimeType,
fileName: "\(baseName).\(fileExtension)"
)
}
private static func nonEmpty(_ value: String?) -> String? {
guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines),
!trimmed.isEmpty
else {
return nil
}
return trimmed
}
private static func sanitizeTitle(_ value: String) -> String {
String((nonEmpty(value) ?? "Shared").prefix(120))
}
private static func firstNonEmptyLine(_ value: String) -> String {
value
.split(whereSeparator: \.isNewline)
.lazy
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.first { !$0.isEmpty }
.map { String($0.prefix(120)) }
?? "Shared text"
}
private static func fileExtension(for mimeType: String) -> String {
switch mimeType {
case "image/png": "png"
case "image/gif": "gif"
case "image/webp": "webp"
case "image/heic": "heic"
default: "jpg"
}
}
}
+11 -8
View File
@@ -3,6 +3,7 @@ import { getStoreManager } from '@affine/core/blocksuite/manager/store';
import { AffineContext } from '@affine/core/components/context';
import { AppFallback } from '@affine/core/mobile/components/app-fallback';
import { MobileModalConfigProvider } from '@affine/core/mobile/components/mobile-modal-config-provider';
import { ShareImportController } from '@affine/core/mobile/components/share-import-controller';
import { configureMobileModules } from '@affine/core/mobile/modules';
import { MobileBackCoordinator } from '@affine/core/mobile/modules/back-coordinator';
import { HapticProvider } from '@affine/core/mobile/modules/haptics';
@@ -89,6 +90,7 @@ import { NavigationGesture } from './plugins/navigation-gesture';
import { NbStoreNativeDBApis } from './plugins/nbstore';
import { PayWall } from './plugins/paywall';
import { Preview } from './plugins/preview';
import { shareInboxProvider } from './plugins/share-inbox';
import {
authRequestProvider,
clearEndpointSession,
@@ -543,6 +545,7 @@ const showNativeSignIn = async () => {
(window as any).requestSignIn = async () => {
return await showNativeSignIn();
};
(window as any).getCurrentDocContentInMarkdown = async () => {
const globalContextService = frameworkProvider.get(GlobalContextService);
const globalContext = globalContextService.globalContext;
@@ -565,6 +568,7 @@ const showNativeSignIn = async () => {
const docsService = workspace.scope.get(DocsService);
const docRef = currentDocId ? docsService.open(currentDocId) : null;
if (!docRef) {
disposeWorkspace();
return;
}
const { doc, release: disposeDoc } = docRef;
@@ -625,7 +629,7 @@ const showNativeSignIn = async () => {
}
const workbench = workspace.scope.get(WorkbenchService).workbench;
await workspace.engine.doc.waitForDocReady(workspace.id); // wait for root doc ready
await workspace.engine.doc.waitForDocReady(workspace.id);
const docId = await MarkdownTransformer.importMarkdownToDoc({
collection: workspace.docCollection,
schema: getAFFiNEWorkspaceSchema(),
@@ -633,15 +637,13 @@ const showNativeSignIn = async () => {
extensions: getStoreManager().config.init().value.get('store'),
});
const docsService = workspace.scope.get(DocsService);
if (docId) {
// only support page mode for now
await docsService.changeDocTitle(docId, title);
docsService.list.setPrimaryMode(docId, 'page');
workbench.openDoc(docId);
return docId;
} else {
if (!docId) {
throw new Error('Failed to import doc');
}
await docsService.changeDocTitle(docId, title);
docsService.list.setPrimaryMode(docId, 'page');
workbench.openDoc(docId);
return docId;
} finally {
workspaceRef?.dispose();
}
@@ -876,6 +878,7 @@ export function App() {
<AffineContext store={getCurrentStore()}>
<KeyboardThemeProvider />
<IOSBackAdapter />
<ShareImportController provider={shareInboxProvider} />
<BlocksuiteMenuConfigProvider>
<RouterProvider
fallbackElement={<AppFallback />}
@@ -0,0 +1,17 @@
import type {
PendingShareItem,
ShareImportTarget,
} from '@affine/core/mobile/components/share-import-controller/types';
export interface ShareInboxPlugin {
listPending(): Promise<{ items: PendingShareItem[] }>;
updateTarget(options: {
itemId: string;
target: ShareImportTarget;
}): Promise<void>;
resolveAttachment(options: {
itemId: string;
}): Promise<{ path?: string; mimeType?: string }>;
complete(options: { itemId: string; docId: string }): Promise<void>;
setError(options: { itemId: string; error: string }): Promise<void>;
}
@@ -0,0 +1,41 @@
import type { ShareInboxProvider } from '@affine/core/mobile/components/share-import-controller';
import { Capacitor, registerPlugin } from '@capacitor/core';
import type { ShareInboxPlugin } from './definitions';
const plugin = registerPlugin<ShareInboxPlugin>('ShareInbox');
const blobToDataURL = (blob: Blob) =>
new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(blob);
});
export const shareInboxProvider: ShareInboxProvider = {
async listPending() {
return (await plugin.listPending()).items;
},
async updateTarget(itemId, target) {
await plugin.updateTarget({ itemId, target });
},
async resolveAttachment(itemId) {
const { path, mimeType } = await plugin.resolveAttachment({ itemId });
if (!path) return undefined;
const response = await fetch(Capacitor.convertFileSrc(path));
if (!response.ok) return undefined;
const blob = await response.blob();
return blobToDataURL(
mimeType && blob.type !== mimeType
? new Blob([blob], { type: mimeType })
: blob
);
},
async complete(itemId, docId) {
await plugin.complete({ itemId, docId });
},
async setError(itemId, error) {
await plugin.setError({ itemId, error });
},
};
@@ -0,0 +1,549 @@
import { Button, Modal, notify, SafeArea, Scrollable } from '@affine/component';
import {
ImportClipperService,
type ShareDestinationOptions,
} from '@affine/core/modules/import-clipper';
import {
type WorkspaceMetadata,
WorkspacesService,
} from '@affine/core/modules/workspace';
import { ImageIcon, LinkIcon, TextIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { useCallback, useEffect, useRef, useState } from 'react';
import { PageHeader } from '../page-header';
import { SelectionPage, type SelectionPageOption } from './selection-page';
import * as styles from './style.css';
import type {
PendingShareItem,
ShareImportTarget,
ShareInboxProvider,
} from './types';
export type { ShareInboxProvider } from './types';
type Page = 'main' | 'workspace' | 'tags' | 'collection' | 'offline';
const errorMessage = (error?: string) => {
switch (error) {
case 'workspace-not-found':
return 'The selected workspace is no longer available. Choose another workspace.';
case 'permission-denied':
return 'You no longer have permission to create documents in this workspace.';
case 'destination-not-found':
return 'One or more selected tags or the collection no longer exist.';
case 'offline-confirmation-required':
return 'AFFiNE could not confirm the latest workspace state.';
case 'attachment-missing':
return 'The shared image is no longer available.';
default:
return undefined;
}
};
const workspaceKey = (workspace: WorkspaceMetadata) =>
`${workspace.flavour}:${workspace.id}`;
const sourceDetails = (item: PendingShareItem) => {
if (item.content.kind === 'url') {
return {
title: item.title,
detail: item.content.url?.replace(/^https?:\/\//, '').split('/')[0],
};
}
if (item.content.kind === 'image') {
return {
title: item.title,
detail: item.attachments?.[0]?.fileName ?? 'Shared image',
};
}
return {
title: item.title,
detail: `${item.content.text?.length ?? 0} characters`,
};
};
const SourceIcon = ({
kind,
}: {
kind: PendingShareItem['content']['kind'];
}) => {
switch (kind) {
case 'url':
return <LinkIcon />;
case 'image':
return <ImageIcon />;
case 'text':
return <TextIcon />;
}
};
export const ShareImportController = ({
provider,
}: {
provider: ShareInboxProvider;
}) => {
const workspacesService = useService(WorkspacesService);
const importer = useService(ImportClipperService);
const workspaces = useLiveData(workspacesService.list.workspaces$);
const [item, setItem] = useState<PendingShareItem>();
const [page, setPage] = useState<Page>('main');
const [selectedWorkspaceKey, setSelectedWorkspaceKey] = useState('');
const [tagIds, setTagIds] = useState<string[]>([]);
const [collectionId, setCollectionId] = useState('');
const [destinations, setDestinations] = useState<ShareDestinationOptions>();
const [isLoadingDestinations, setIsLoadingDestinations] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [attachmentPreview, setAttachmentPreview] = useState<string>();
const refreshing = useRef(false);
const itemId = item?.id;
const selectedWorkspace = workspaces.find(
workspace => workspaceKey(workspace) === selectedWorkspaceKey
);
const selectedWorkspaceName = selectedWorkspace
? workspacesService.getProfile(selectedWorkspace).name$.value ||
selectedWorkspace.id
: undefined;
const setManualItem = useCallback((next: PendingShareItem) => {
setItem(next);
setPage('main');
setSelectedWorkspaceKey(
next.target
? `${next.target.workspaceFlavour}:${next.target.workspaceId}`
: ''
);
setTagIds(next.target?.tagIds ?? []);
setCollectionId(next.target?.collectionId ?? '');
}, []);
const importItem = useCallback(
async (
pending: PendingShareItem,
target: ShareImportTarget,
allowOffline: boolean
) => {
await provider.updateTarget(pending.id, target);
const workspace = workspacesService.list.workspaces$.value.find(
metadata =>
metadata.id === target.workspaceId &&
metadata.flavour === target.workspaceFlavour
);
if (!workspace) {
await provider.setError(pending.id, 'workspace-not-found');
return false;
}
const attachmentUrl =
pending.content.kind === 'image'
? await provider.resolveAttachment(pending.id)
: undefined;
if (pending.content.kind === 'image' && !attachmentUrl) {
await provider.setError(pending.id, 'attachment-missing');
return false;
}
const result = await importer.importShareToWorkspace(
workspace,
{
documentId: pending.documentId,
title: pending.title,
content: pending.content,
attachmentUrl,
tagIds: target.tagIds,
collectionId: target.collectionId,
},
{ allowOffline }
);
if (result.status !== 'imported') {
await provider.setError(pending.id, result.status);
return false;
}
await provider.complete(pending.id, result.docId);
return true;
},
[importer, provider, workspacesService]
);
const refresh = useCallback(async () => {
if (refreshing.current) return;
refreshing.current = true;
try {
const pending = await provider.listPending();
let importedCount = 0;
let nextItem: PendingShareItem | undefined;
for (const candidate of pending) {
if (candidate.target && !candidate.lastError) {
const imported = await importItem(candidate, candidate.target, false);
if (imported) {
importedCount += 1;
continue;
}
const latest = await provider.listPending();
nextItem = latest.find(item => item.id === candidate.id);
break;
}
nextItem = candidate;
break;
}
if (importedCount > 0) {
notify.success({
title: `${importedCount} shared ${importedCount === 1 ? 'item' : 'items'} saved`,
});
}
if (nextItem) {
setManualItem(nextItem);
} else {
setItem(undefined);
}
} finally {
refreshing.current = false;
}
}, [importItem, provider, setManualItem]);
useEffect(() => {
void refresh().catch(console.error);
const handleRefresh = () => {
void refresh().catch(console.error);
};
window.addEventListener('affine:share-inbox', handleRefresh);
return () =>
window.removeEventListener('affine:share-inbox', handleRefresh);
}, [refresh]);
useEffect(() => {
let active = true;
setAttachmentPreview(undefined);
if (item?.content.kind === 'image') {
void provider
.resolveAttachment(item.id)
.then(preview => {
if (active) setAttachmentPreview(preview);
})
.catch(console.error);
}
return () => {
active = false;
};
}, [item?.content.kind, item?.id, provider]);
useEffect(() => {
if (!selectedWorkspace) {
setDestinations(undefined);
return;
}
let active = true;
setDestinations(undefined);
setIsLoadingDestinations(true);
void importer
.getShareDestinationOptions(selectedWorkspace)
.then(async options => {
if (!active) return;
if (!options) {
if (itemId) {
await provider.setError(itemId, 'workspace-not-found');
setItem(current =>
current?.id === itemId &&
current.lastError !== 'workspace-not-found'
? { ...current, lastError: 'workspace-not-found' }
: current
);
}
return;
}
setDestinations(options);
const validTags = new Set(options.tags.map(tag => tag.id));
setTagIds(ids => ids.filter(id => validTags.has(id)));
setCollectionId(id =>
id && options.collections.some(collection => collection.id === id)
? id
: ''
);
})
.catch(console.error)
.finally(() => {
if (active) setIsLoadingDestinations(false);
});
return () => {
active = false;
};
}, [importer, itemId, provider, selectedWorkspace]);
const save = async (allowOffline: boolean) => {
if (!item || !selectedWorkspace || isSaving) return;
setIsSaving(true);
try {
const imported = await importItem(
item,
{
workspaceId: selectedWorkspace.id,
workspaceFlavour: selectedWorkspace.flavour,
tagIds,
collectionId: collectionId || undefined,
},
allowOffline
);
if (imported) {
notify.success({ title: 'Shared content saved' });
}
await refresh();
} finally {
setIsSaving(false);
}
};
if (!item) return null;
const workspaceOptions: SelectionPageOption[] = workspaces.map(workspace => ({
id: workspaceKey(workspace),
label: workspacesService.getProfile(workspace).name$.value || workspace.id,
detail: workspace.flavour === 'local' ? 'On this device' : 'Cloud',
}));
const tagOptions: SelectionPageOption[] =
destinations?.tags.map(tag => ({
id: tag.id,
label: tag.name,
color: tag.color,
})) ?? [];
const collectionOptions: SelectionPageOption[] = [
{ id: '', label: 'No collection' },
...(destinations?.collections.map(collection => ({
id: collection.id,
label: collection.name,
})) ?? []),
];
const selectedTagNames =
destinations?.tags
.filter(tag => tagIds.includes(tag.id))
.map(tag => tag.name) ?? [];
const collectionName =
destinations?.collections.find(collection => collection.id === collectionId)
?.name ?? 'None';
const requiresOfflineConfirmation =
item.lastError === 'offline-confirmation-required' ||
destinations?.verification === 'unavailable';
const source = sourceDetails(item);
const content = (() => {
if (page === 'workspace') {
return (
<SelectionPage
title="Workspace"
options={workspaceOptions}
selectedIds={selectedWorkspaceKey ? [selectedWorkspaceKey] : []}
onBack={() => setPage('main')}
onSelect={id => {
setSelectedWorkspaceKey(id);
setTagIds([]);
setCollectionId('');
setItem(current =>
current ? { ...current, lastError: undefined } : current
);
setPage('main');
}}
/>
);
}
if (page === 'tags') {
return (
<SelectionPage
title="Tags"
multiple
options={tagOptions}
selectedIds={tagIds}
onBack={() => setPage('main')}
onSelect={id =>
setTagIds(ids =>
ids.includes(id)
? ids.filter(current => current !== id)
: [...ids, id]
)
}
onConfirm={() => setPage('main')}
/>
);
}
if (page === 'collection') {
return (
<SelectionPage
title="Collection"
options={collectionOptions}
selectedIds={[collectionId]}
onBack={() => setPage('main')}
onSelect={id => {
setCollectionId(id);
setPage('main');
}}
/>
);
}
if (page === 'offline') {
return (
<div className={styles.page}>
<PageHeader back backAction={() => setPage('main')}>
<span className={styles.headerTitle}>
Use local workspace data?
</span>
</PageHeader>
<main className={styles.confirmation}>
<h2 className={styles.confirmationTitle}>
{selectedWorkspaceName}
</h2>
<p className={styles.confirmationText}>
AFFiNE could not confirm that this workspace, your permissions,
and its destinations are current online. Saving will use the most
recent data available on this device.
</p>
</main>
<SafeArea bottom className={styles.footer}>
<Button
className={styles.action}
variant="primary"
disabled={isSaving}
onClick={() => void save(true).catch(console.error)}
>
{isSaving ? 'Saving…' : 'Save using local data'}
</Button>
</SafeArea>
</div>
);
}
return (
<div className={styles.page}>
<PageHeader
suffix={
<Button variant="plain" onClick={() => setItem(undefined)}>
Not now
</Button>
}
>
<span className={styles.headerTitle}>Choose where to save</span>
</PageHeader>
<Scrollable.Root className={styles.scrollArea}>
<Scrollable.Scrollbar />
<Scrollable.Viewport>
<main className={styles.main}>
<section className={styles.source}>
<div className={styles.sourceIcon}>
{attachmentPreview ? (
<img
className={styles.sourceImage}
src={attachmentPreview}
alt=""
/>
) : (
<SourceIcon kind={item.content.kind} />
)}
</div>
<div className={styles.sourceContent}>
<div className={styles.sourceTitle}>{source.title}</div>
{source.detail ? (
<div className={styles.sourceDetail}>{source.detail}</div>
) : null}
</div>
</section>
<section className={styles.destinationGroup}>
<button
className={styles.destinationRow}
type="button"
onClick={() => setPage('workspace')}
>
<span className={styles.rowLabel}>Workspace</span>
<span className={styles.rowValue}>
{selectedWorkspaceName ?? 'Choose'}
<span className={styles.rowArrow}></span>
</span>
</button>
<button
className={styles.destinationRow}
type="button"
disabled={!destinations || isLoadingDestinations}
onClick={() => setPage('tags')}
>
<span className={styles.rowLabel}>
Tags <span className={styles.optional}>Optional</span>
</span>
<span className={styles.rowValue}>
{selectedTagNames.length
? `${selectedTagNames.length} selected`
: 'None'}
<span className={styles.rowArrow}></span>
</span>
</button>
<button
className={styles.destinationRow}
type="button"
disabled={!destinations || isLoadingDestinations}
onClick={() => setPage('collection')}
>
<span className={styles.rowLabel}>
Collection <span className={styles.optional}>Optional</span>
</span>
<span className={styles.rowValue}>
{collectionName}
<span className={styles.rowArrow}></span>
</span>
</button>
</section>
{isLoadingDestinations ? (
<div className={styles.status}>Checking workspace</div>
) : requiresOfflineConfirmation ? (
<div className={styles.warning}>
The latest online workspace state could not be confirmed.
</div>
) : null}
{errorMessage(item.lastError) ? (
<div className={styles.error}>
{errorMessage(item.lastError)}
</div>
) : null}
</main>
</Scrollable.Viewport>
</Scrollable.Root>
<SafeArea bottom className={styles.footer}>
<Button
className={styles.action}
variant="primary"
disabled={
!selectedWorkspace ||
!destinations ||
isSaving ||
isLoadingDestinations
}
onClick={() => {
if (requiresOfflineConfirmation) {
setPage('offline');
} else {
void save(false).catch(console.error);
}
}}
>
{isSaving ? 'Saving…' : 'Save'}
</Button>
</SafeArea>
</div>
);
})();
return (
<Modal
fullScreen
animation="slideBottom"
open
withoutCloseButton
onOpenChange={() => setItem(undefined)}
contentOptions={{ style: { padding: 0 } }}
>
{content}
</Modal>
);
};
@@ -0,0 +1,93 @@
import { Button, SafeArea, Scrollable } from '@affine/component';
import { PageHeader } from '../page-header';
import * as styles from './style.css';
export interface SelectionPageOption {
id: string;
label: string;
detail?: string;
color?: string;
}
export const SelectionPage = ({
title,
options,
selectedIds,
multiple = false,
onBack,
onSelect,
onConfirm,
}: {
title: string;
options: SelectionPageOption[];
selectedIds: string[];
multiple?: boolean;
onBack: () => void;
onSelect: (id: string) => void;
onConfirm?: () => void;
}) => {
return (
<div className={styles.page}>
<PageHeader back backAction={onBack}>
<span className={styles.headerTitle}>{title}</span>
</PageHeader>
<Scrollable.Root className={styles.scrollArea}>
<Scrollable.Scrollbar />
<Scrollable.Viewport>
<ul className={styles.selectionList}>
{options.map(option => {
const selected = selectedIds.includes(option.id);
return (
<li key={option.id}>
<button
className={styles.selectionRow}
type="button"
aria-pressed={selected}
onClick={() => onSelect(option.id)}
>
{option.color ? (
<span
className={styles.colorDot}
style={{ backgroundColor: option.color }}
/>
) : null}
<span className={styles.selectionLabel}>
{option.label}
{option.detail ? (
<span className={styles.selectionDetail}>
{option.detail}
</span>
) : null}
</span>
{selected ? (
<span className={styles.checkmark} aria-hidden="true">
</span>
) : (
<span
className={styles.checkmarkPlaceholder}
aria-hidden="true"
/>
)}
</button>
</li>
);
})}
</ul>
</Scrollable.Viewport>
</Scrollable.Root>
{multiple ? (
<SafeArea bottom className={styles.footer}>
<Button
className={styles.action}
variant="primary"
onClick={onConfirm}
>
Done
</Button>
</SafeArea>
) : null}
</div>
);
};
@@ -0,0 +1,252 @@
import {
bodyEmphasized,
bodyRegular,
footnoteRegular,
subHeadlineRegular,
} from '@toeverything/theme/typography';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const page = style({
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
background: cssVarV2('layer/background/secondary'),
});
export const headerTitle = style([
bodyEmphasized,
{ color: cssVarV2('text/primary') },
]);
export const scrollArea = style({
height: 0,
flex: 1,
});
export const main = style({
display: 'flex',
flexDirection: 'column',
gap: 20,
padding: '20px 16px',
});
export const source = style({
display: 'flex',
alignItems: 'center',
gap: 12,
padding: 12,
borderRadius: 12,
background: cssVarV2('layer/background/primary'),
});
export const sourceIcon = style({
width: 40,
height: 40,
flex: '0 0 auto',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 8,
fontSize: 20,
color: cssVarV2('icon/primary'),
background: cssVarV2('layer/background/secondary'),
overflow: 'hidden',
});
export const sourceImage = style({
width: '100%',
height: '100%',
objectFit: 'cover',
});
export const sourceContent = style({
minWidth: 0,
flex: 1,
});
export const sourceTitle = style([
bodyEmphasized,
{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
color: cssVarV2('text/primary'),
},
]);
export const sourceDetail = style([
footnoteRegular,
{
marginTop: 2,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
color: cssVarV2('text/secondary'),
},
]);
export const destinationGroup = style({
overflow: 'hidden',
borderRadius: 12,
background: cssVarV2('layer/background/primary'),
});
export const destinationRow = style({
width: '100%',
minHeight: 52,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
padding: '0 12px',
border: 0,
borderBottom: `0.5px solid ${cssVarV2('layer/insideBorder/border')}`,
color: cssVarV2('text/primary'),
background: 'transparent',
textAlign: 'left',
selectors: {
'&:last-child': { borderBottom: 0 },
'&:disabled': { opacity: 0.5 },
},
});
export const rowLabel = style([
bodyRegular,
{ display: 'flex', alignItems: 'baseline', gap: 6 },
]);
export const optional = style([
footnoteRegular,
{ color: cssVarV2('text/tertiary') },
]);
export const rowValue = style([
bodyRegular,
{
minWidth: 0,
display: 'flex',
alignItems: 'center',
gap: 8,
overflow: 'hidden',
color: cssVarV2('text/secondary'),
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
},
]);
export const rowArrow = style({
fontSize: 24,
lineHeight: 1,
color: cssVarV2('icon/secondary'),
});
export const status = style([
footnoteRegular,
{ color: cssVarV2('text/secondary') },
]);
export const warning = style([
footnoteRegular,
{
padding: 12,
border: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
borderRadius: 8,
color: cssVarV2('text/primary'),
background: cssVarV2('layer/background/primary'),
},
]);
export const error = style([
footnoteRegular,
{ color: cssVarV2('status/error') },
]);
export const footer = style({
width: '100%',
padding: '8px 16px',
borderTop: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
background: cssVarV2('layer/background/primary'),
});
export const action = style({
width: '100%',
height: 44,
borderRadius: 8,
fontSize: 17,
fontWeight: 400,
});
export const selectionList = style({
margin: 0,
padding: '8px 16px',
listStyle: 'none',
});
export const selectionRow = style({
width: '100%',
minHeight: 52,
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '6px 8px',
border: 0,
borderBottom: `0.5px solid ${cssVarV2('layer/insideBorder/border')}`,
color: cssVarV2('text/primary'),
background: 'transparent',
textAlign: 'left',
});
export const selectionLabel = style([
bodyRegular,
{
minWidth: 0,
flex: 1,
display: 'flex',
flexDirection: 'column',
},
]);
export const selectionDetail = style([
footnoteRegular,
{ color: cssVarV2('text/secondary') },
]);
export const colorDot = style({
width: 12,
height: 12,
flex: '0 0 auto',
borderRadius: '50%',
});
export const checkmark = style({
width: 32,
textAlign: 'center',
fontSize: 18,
color: cssVarV2('button/primary'),
});
export const checkmarkPlaceholder = style({
width: 32,
});
export const confirmation = style({
padding: '32px 20px',
});
export const confirmationTitle = style([
subHeadlineRegular,
{
margin: 0,
color: cssVarV2('text/primary'),
},
]);
export const confirmationText = style([
bodyRegular,
{
margin: '12px 0 0',
color: cssVarV2('text/secondary'),
},
]);
@@ -0,0 +1,29 @@
export interface PendingShareItem {
id: string;
documentId: string;
title: string;
content: {
kind: 'url' | 'text' | 'image';
url?: string;
text?: string;
};
target?: ShareImportTarget;
previewText?: string;
attachments?: { fileName: string; mimeType: string }[];
lastError?: string;
}
export interface ShareImportTarget {
workspaceId: string;
workspaceFlavour: string;
tagIds: string[];
collectionId?: string;
}
export interface ShareInboxProvider {
listPending(): Promise<PendingShareItem[]>;
updateTarget(itemId: string, target: ShareImportTarget): Promise<void>;
resolveAttachment(itemId: string): Promise<string | undefined>;
complete(itemId: string, docId: string): Promise<void>;
setError(itemId: string, error: string): Promise<void>;
}
@@ -3,7 +3,13 @@ import { type Framework } from '@toeverything/infra';
import { WorkspacesService } from '../workspace';
import { ImportClipperService } from './services/import';
export { type ClipperInput, ImportClipperService } from './services/import';
export {
type ClipperInput,
ImportClipperService,
type ShareDestinationOptions,
type ShareImportInput,
type ShareImportResult,
} from './services/import';
export function configureImportClipperModule(framework: Framework) {
framework.service(ImportClipperService, [WorkspacesService]);
@@ -2,7 +2,10 @@ import { getStoreManager } from '@affine/core/blocksuite/manager/store';
import { MarkdownTransformer } from '@blocksuite/affine/widgets/linked-doc';
import { Service } from '@toeverything/infra';
import { CollectionService } from '../../collection';
import { DocsService } from '../../doc';
import { GuardService } from '../../permissions';
import { TagService } from '../../tag';
import {
getAFFiNEWorkspaceSchema,
type WorkspaceMetadata,
@@ -17,11 +20,308 @@ export interface ClipperInput {
workspace?: 'select-by-user' | 'last-open-workspace';
}
export interface ShareImportInput {
documentId: string;
title: string;
content: {
kind: 'url' | 'text' | 'image';
url?: string;
text?: string;
};
attachmentUrl?: string;
tagIds: string[];
collectionId?: string;
}
export type ShareImportResult =
| { status: 'imported'; docId: string }
| {
status:
| 'workspace-not-found'
| 'permission-denied'
| 'destination-not-found'
| 'offline-confirmation-required';
missingTagIds?: string[];
};
export interface ShareDestinationOptions {
verification: 'confirmed' | 'unavailable';
tags: { id: string; name: string; color: string }[];
collections: { id: string; name: string }[];
}
type WorkspaceVerification = 'confirmed' | 'missing' | 'unavailable';
export class ImportClipperService extends Service {
constructor(private readonly workspacesService: WorkspacesService) {
super();
}
async importShareToWorkspace(
workspaceMetadata: WorkspaceMetadata,
input: ShareImportInput,
options: { allowOffline?: boolean } = {}
): Promise<ShareImportResult> {
const verification = await this.revalidateWorkspace(workspaceMetadata);
if (verification === 'missing') {
return { status: 'workspace-not-found' };
}
const currentMetadata = this.workspacesService.list.workspaces$.value.find(
workspace =>
workspace.id === workspaceMetadata.id &&
workspace.flavour === workspaceMetadata.flavour
);
if (!currentMetadata) {
return { status: 'workspace-not-found' };
}
const workspaceRef = this.workspacesService.open({
metadata: currentMetadata,
});
if (!workspaceRef) {
return { status: 'workspace-not-found' };
}
try {
const { workspace } = workspaceRef;
await workspace.engine.doc.waitForDocReady(workspace.id);
const rootSynced =
workspace.meta.flavour === 'local' ||
(verification === 'confirmed' &&
(await this.waitForRootSync(workspace)));
if (!rootSynced && !options.allowOffline) {
return { status: 'offline-confirmation-required' };
}
const guard = workspace.scope.get(GuardService);
if (!(await guard.can('Workspace_CreateDoc'))) {
return { status: 'permission-denied' };
}
const tagService = workspace.scope.get(TagService);
const tags = tagService.tagList.tags$.value;
const missingTagIds = input.tagIds.filter(
id => !tags.some(tag => tag.id === id)
);
const collectionService = workspace.scope.get(CollectionService);
if (
missingTagIds.length > 0 ||
(input.collectionId &&
!collectionService.collectionMetas$.value.some(
collection => collection.id === input.collectionId
))
) {
return { status: 'destination-not-found', missingTagIds };
}
const docsService = workspace.scope.get(DocsService);
let record = docsService.list.doc$(input.documentId).value;
if (!record) {
record = docsService.createDoc({
id: input.documentId,
primaryMode: 'page',
});
}
const { doc, release } = docsService.open(record.id);
try {
await doc.waitForSyncReady();
const page = doc.blockSuiteDoc.getBlocksByFlavour('affine:page')[0];
if (!page) {
throw new Error('Failed to initialize shared doc');
}
page.model.children.forEach(child => {
doc.blockSuiteDoc.deleteBlock(child);
});
const noteId = doc.blockSuiteDoc.addBlock('affine:note', {}, page.id);
if (input.content.kind === 'url' && input.content.url) {
doc.blockSuiteDoc.addBlock(
'affine:bookmark',
{ url: input.content.url, style: 'horizontal' },
noteId
);
}
const markdown = this.shareMarkdown(input);
if (markdown) {
await MarkdownTransformer.importMarkdownToBlock({
doc: doc.blockSuiteDoc,
blockId: noteId,
markdown,
extensions: getStoreManager().config.init().value.get('store'),
});
}
} finally {
release();
}
await docsService.changeDocTitle(input.documentId, input.title);
const existingTagIds = new Set(record.meta$.value.tags ?? []);
const selectedTagIds = new Set(input.tagIds);
for (const tagId of existingTagIds) {
if (!selectedTagIds.has(tagId)) {
tagService.tagList.tagByTagId$(tagId).value?.untag(input.documentId);
}
}
for (const tagId of input.tagIds) {
if (!existingTagIds.has(tagId)) {
tagService.tagList.tagByTagId$(tagId).value?.tag(input.documentId);
}
}
for (const collection of collectionService.collections$.value.values()) {
if (
collection.id !== input.collectionId &&
collection.allowList$.value.includes(input.documentId)
) {
collectionService.removeDocFromCollection(
collection.id,
input.documentId
);
}
}
if (input.collectionId) {
collectionService.addDocToCollection(
input.collectionId,
input.documentId
);
}
workspace.engine.doc.addPriority(workspace.id, 100);
workspace.engine.doc.addPriority(input.documentId, 100);
await Promise.all([
workspace.engine.doc.waitForSynced(workspace.id),
workspace.engine.doc.waitForSynced(input.documentId),
]);
return { status: 'imported', docId: input.documentId };
} finally {
workspaceRef.dispose();
}
}
async getShareDestinationOptions(
workspaceMetadata: WorkspaceMetadata
): Promise<ShareDestinationOptions | null> {
const verification = await this.revalidateWorkspace(workspaceMetadata);
if (verification === 'missing') return null;
const currentMetadata = this.workspacesService.list.workspaces$.value.find(
workspace =>
workspace.id === workspaceMetadata.id &&
workspace.flavour === workspaceMetadata.flavour
);
if (!currentMetadata) return null;
const workspaceRef = this.workspacesService.open({
metadata: currentMetadata,
});
if (!workspaceRef) return null;
try {
const { workspace } = workspaceRef;
await workspace.engine.doc.waitForDocReady(workspace.id);
const rootConfirmed =
workspace.meta.flavour === 'local' ||
(verification === 'confirmed' &&
(await this.waitForRootSync(workspace)));
return {
verification: rootConfirmed ? 'confirmed' : 'unavailable',
tags: workspace.scope
.get(TagService)
.tagList.tagMetas$.value.map(tag => ({
id: tag.id,
name: tag.name,
color: tag.color,
})),
collections: workspace.scope
.get(CollectionService)
.collectionMetas$.value.map(collection => ({
id: collection.id,
name: collection.name,
})),
};
} finally {
workspaceRef.dispose();
}
}
private shareMarkdown(input: ShareImportInput) {
const parts: string[] = [];
if (input.content.kind === 'url') {
if (input.content.text) {
parts.push(
`> ${this.escapeMarkdown(input.content.text).replaceAll('\n', '\n> ')}`
);
}
} else if (input.content.kind === 'image') {
if (input.attachmentUrl) {
parts.push(`![Shared image](<${input.attachmentUrl}>)`);
}
if (input.content.text) {
parts.push(this.escapeMarkdown(input.content.text));
}
if (input.content.url) {
parts.push(`[Source](<${input.content.url}>)`);
}
} else if (input.content.text) {
parts.push(this.escapeMarkdown(input.content.text));
}
return parts.join('\n\n');
}
private escapeMarkdown(value: string) {
return value.replace(/[\\`*_{}[\]()#+\-.!|<>]/g, '\\$&');
}
private async revalidateWorkspace(
metadata: WorkspaceMetadata
): Promise<WorkspaceVerification> {
if (metadata.flavour === 'local') {
return this.hasWorkspace(metadata) ? 'confirmed' : 'missing';
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
await this.workspacesService.list.waitForRevalidation(controller.signal);
if (!this.hasWorkspace(metadata)) return 'missing';
const provider =
this.workspacesService.getWorkspaceFlavourProvider(metadata);
if (!provider) return 'unavailable';
const profile = await provider.getWorkspaceProfile(
metadata.id,
controller.signal
);
return profile ? 'confirmed' : 'missing';
} catch {
return this.hasWorkspace(metadata) ? 'unavailable' : 'missing';
} finally {
clearTimeout(timeout);
}
}
private hasWorkspace(metadata: WorkspaceMetadata) {
return this.workspacesService.list.workspaces$.value.some(
workspace =>
workspace.id === metadata.id && workspace.flavour === metadata.flavour
);
}
private async waitForRootSync(workspace: {
id: string;
engine: { doc: { waitForSynced(id: string): Promise<unknown> } };
}) {
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
await Promise.race([
workspace.engine.doc.waitForSynced(workspace.id),
new Promise<never>((_, reject) => {
timeout = setTimeout(() => reject(new Error('Sync timed out')), 5000);
}),
]);
return true;
} catch {
return false;
} finally {
if (timeout) clearTimeout(timeout);
}
}
async importToWorkspace(
workspaceMetadata: WorkspaceMetadata,
clipperInput: ClipperInput
@@ -40,7 +40,7 @@ export class WorkspaceProfile extends Entity<{ metadata: WorkspaceMetadata }> {
}
profile$ = LiveData.from<WorkspaceProfileInfo | null>(
this.cache.watchProfileCache(this.props.metadata.id),
this.cache.watchProfileCache(this.props.metadata),
null
);
@@ -65,7 +65,7 @@ export class WorkspaceProfile extends Entity<{ metadata: WorkspaceMetadata }> {
if (isEqual(this.profile$.value, info)) {
return;
}
this.cache.setProfileCache(this.props.metadata.id, info);
this.cache.setProfileCache(this.props.metadata, info);
}
revalidate = effect(
@@ -3,3 +3,6 @@ export type WorkspaceMetadata = {
flavour: string;
initialized?: boolean;
};
export const workspaceMetadataKey = (metadata: WorkspaceMetadata) =>
`${metadata.flavour}:${metadata.id}`;
@@ -1,13 +1,14 @@
import { ObjectPool, Service } from '@toeverything/infra';
import { WorkspaceProfile } from '../entities/profile';
import type { WorkspaceMetadata } from '../metadata';
import { type WorkspaceMetadata, workspaceMetadataKey } from '../metadata';
export class WorkspaceProfileService extends Service {
pool = new ObjectPool<string, WorkspaceProfile>();
getProfile = (metadata: WorkspaceMetadata): WorkspaceProfile => {
const exists = this.pool.get(metadata.id);
const key = workspaceMetadataKey(metadata);
const exists = this.pool.get(key);
if (exists) {
return exists.obj;
}
@@ -16,6 +17,6 @@ export class WorkspaceProfileService extends Service {
metadata,
});
return this.pool.put(metadata.id, profile).obj;
return this.pool.put(key, profile).obj;
};
}
@@ -4,7 +4,7 @@ import { ObjectPool, Service } from '@toeverything/infra';
import type { Workspace } from '../entities/workspace';
import { WorkspaceInitialized } from '../events';
import type { WorkspaceMetadata } from '../metadata';
import { workspaceMetadataKey } from '../metadata';
import type { WorkspaceOpenOptions } from '../open-options';
import { WorkspaceScope } from '../scopes/workspace';
import type { WorkspaceFlavoursService } from './flavours';
@@ -14,9 +14,6 @@ import { WorkspaceService } from './workspace';
const logger = new DebugLogger('affine:workspace-repository');
const getWorkspacePoolKey = (metadata: WorkspaceMetadata) =>
`${metadata.flavour}:${metadata.id}`;
export class WorkspaceRepositoryService extends Service {
constructor(
private readonly flavoursService: WorkspaceFlavoursService,
@@ -62,7 +59,7 @@ export class WorkspaceRepositoryService extends Service {
};
}
const exist = this.pool.get(getWorkspacePoolKey(options.metadata));
const exist = this.pool.get(workspaceMetadataKey(options.metadata));
if (exist) {
return {
workspace: exist.obj,
@@ -72,7 +69,7 @@ export class WorkspaceRepositoryService extends Service {
const workspace = this.instantiate(options, customEngineWorkerInitOptions);
const ref = this.pool.put(getWorkspacePoolKey(workspace.meta), workspace);
const ref = this.pool.put(workspaceMetadataKey(workspace.meta), workspace);
return {
workspace: ref.obj,
@@ -3,6 +3,7 @@ import { map } from 'rxjs';
import type { GlobalCache } from '../../storage';
import type { WorkspaceProfileInfo } from '../entities/profile';
import { type WorkspaceMetadata, workspaceMetadataKey } from '../metadata';
const WORKSPACE_PROFILE_CACHE_KEY = 'workspace-information:';
@@ -11,20 +12,25 @@ export class WorkspaceProfileCacheStore extends Store {
super();
}
watchProfileCache(workspaceId: string) {
return this.cache.watch(WORKSPACE_PROFILE_CACHE_KEY + workspaceId).pipe(
map(data => {
if (!data || typeof data !== 'object') {
return null;
}
watchProfileCache(metadata: WorkspaceMetadata) {
return this.cache
.watch(WORKSPACE_PROFILE_CACHE_KEY + workspaceMetadataKey(metadata))
.pipe(
map(data => {
if (!data || typeof data !== 'object') {
return null;
}
const info = data as WorkspaceProfileInfo;
return info;
})
const info = data as WorkspaceProfileInfo;
return info;
})
);
}
setProfileCache(metadata: WorkspaceMetadata, info: WorkspaceProfileInfo) {
this.cache.set(
WORKSPACE_PROFILE_CACHE_KEY + workspaceMetadataKey(metadata),
info
);
}
setProfileCache(workspaceId: string, info: WorkspaceProfileInfo) {
this.cache.set(WORKSPACE_PROFILE_CACHE_KEY + workspaceId, info);
}
}