diff --git a/packages/frontend/apps/ios/App/App.xcodeproj/project.pbxproj b/packages/frontend/apps/ios/App/App.xcodeproj/project.pbxproj index 49b05c99cb..b78bc8ab24 100644 --- a/packages/frontend/apps/ios/App/App.xcodeproj/project.pbxproj +++ b/packages/frontend/apps/ios/App/App.xcodeproj/project.pbxproj @@ -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 = ""; }; 9DEC59422D323EE00027CEBD /* Mutex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mutex.swift; sourceTree = ""; }; 9DFCD1452D27D1D70028C92B /* libaffine_mobile_native.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libaffine_mobile_native.a; sourceTree = ""; }; + 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 = ""; }; AA0000010000000000000000 /* AuthDateParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ../App/Plugins/Auth/AuthDateParser.swift; sourceTree = ""; }; AA0000020000000000000000 /* AuthDateParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthDateParserTests.swift; sourceTree = ""; }; + AB0000030000000000000000 /* ShareInboxSafety.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ../Shared/ShareInbox/ShareInboxSafety.swift; sourceTree = ""; }; + AB0000040000000000000000 /* ShareInboxSafetyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareInboxSafetyTests.swift; sourceTree = ""; }; 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 = ""; }; 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 = ""; }; /* 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 = ""; }; + A10100000000000000000008 /* ShareExtension */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + A10100000000000000000007 /* Exceptions for "ShareExtension" folder in "ShareExtension" target */, + ); + path = ShareExtension; + sourceTree = ""; + }; + A10100000000000000000009 /* Shared/ShareInbox */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); + path = Shared/ShareInbox; + sourceTree = ""; + }; /* 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 = ""; @@ -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 = ( diff --git a/packages/frontend/apps/ios/App/App.xcodeproj/xcshareddata/xcschemes/AFFiNETests.xcscheme b/packages/frontend/apps/ios/App/App.xcodeproj/xcshareddata/xcschemes/AFFiNETests.xcscheme index 9fd7c1eaf8..6640b3b8ed 100644 --- a/packages/frontend/apps/ios/App/App.xcodeproj/xcshareddata/xcschemes/AFFiNETests.xcscheme +++ b/packages/frontend/apps/ios/App/App.xcodeproj/xcshareddata/xcschemes/AFFiNETests.xcscheme @@ -15,7 +15,7 @@ buildForAnalyzing = "YES"> @@ -30,10 +30,11 @@ shouldUseLaunchSchemeArgsEnv = "YES" shouldAutocreateTestPlan = "YES"> - + @@ -41,4 +42,30 @@ + + + + + + + + diff --git a/packages/frontend/apps/ios/App/App/AffineViewController.swift b/packages/frontend/apps/ios/App/App/AffineViewController.swift index a9d8e79547..6a5135067f 100644 --- a/packages/frontend/apps/ios/App/App/AffineViewController.swift +++ b/packages/frontend/apps/ios/App/App/AffineViewController.swift @@ -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() { diff --git a/packages/frontend/apps/ios/App/App/App.entitlements b/packages/frontend/apps/ios/App/App/App.entitlements new file mode 100644 index 0000000000..2f106e4a93 --- /dev/null +++ b/packages/frontend/apps/ios/App/App/App.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.app.affine.pro + + + diff --git a/packages/frontend/apps/ios/App/App/AppDelegate.swift b/packages/frontend/apps/ios/App/App/AppDelegate.swift index fae3240822..a3f028f719 100644 --- a/packages/frontend/apps/ios/App/App/AppDelegate.swift +++ b/packages/frontend/apps/ios/App/App/AppDelegate.swift @@ -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 { diff --git a/packages/frontend/apps/ios/App/App/Plugins/ShareInbox/ShareInboxPlugin.swift b/packages/frontend/apps/ios/App/App/Plugins/ShareInbox/ShareInboxPlugin.swift new file mode 100644 index 0000000000..8cb1ad95e7 --- /dev/null +++ b/packages/frontend/apps/ios/App/App/Plugins/ShareInbox/ShareInboxPlugin.swift @@ -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 + } +} diff --git a/packages/frontend/apps/ios/App/App/RootViewController.swift b/packages/frontend/apps/ios/App/App/RootViewController.swift index 090ee0c627..e75a80fa9d 100644 --- a/packages/frontend/apps/ios/App/App/RootViewController.swift +++ b/packages/frontend/apps/ios/App/App/RootViewController.swift @@ -82,6 +82,7 @@ class RootViewController: UINavigationController { showOnboardingAlert(message: error.localizedDescription) return } + OnboardingFlag.markCompleted() guard isSignedIn else { return } diff --git a/packages/frontend/apps/ios/App/AppTests/ShareInboxSafetyTests.swift b/packages/frontend/apps/ios/App/AppTests/ShareInboxSafetyTests.swift new file mode 100644 index 0000000000..89b3608ae3 --- /dev/null +++ b/packages/frontend/apps/ios/App/AppTests/ShareInboxSafetyTests.swift @@ -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("".utf8))) + } +} diff --git a/packages/frontend/apps/ios/App/ShareExtension/Info.plist b/packages/frontend/apps/ios/App/ShareExtension/Info.plist new file mode 100644 index 0000000000..57f71bee5b --- /dev/null +++ b/packages/frontend/apps/ios/App/ShareExtension/Info.plist @@ -0,0 +1,47 @@ + + + + + CFBundleDisplayName + AFFiNE + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionAttributes + + NSExtensionActivationRule + + NSExtensionActivationDictionaryVersion + 2 + NSExtensionActivationSupportsText + + NSExtensionActivationSupportsWebURLWithMaxCount + 1 + NSExtensionActivationSupportsWebPageWithMaxCount + 1 + NSExtensionActivationSupportsImageWithMaxCount + 1 + + NSExtensionJavaScriptPreprocessingFile + SafariPageCapture + + NSExtensionPointIdentifier + com.apple.share-services + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).ShareViewController + + + diff --git a/packages/frontend/apps/ios/App/ShareExtension/SafariPageCapture.js b/packages/frontend/apps/ios/App/ShareExtension/SafariPageCapture.js new file mode 100644 index 0000000000..af6a269f77 --- /dev/null +++ b/packages/frontend/apps/ios/App/ShareExtension/SafariPageCapture.js @@ -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(); diff --git a/packages/frontend/apps/ios/App/ShareExtension/ShareExtension.entitlements b/packages/frontend/apps/ios/App/ShareExtension/ShareExtension.entitlements new file mode 100644 index 0000000000..2f106e4a93 --- /dev/null +++ b/packages/frontend/apps/ios/App/ShareExtension/ShareExtension.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.app.affine.pro + + + diff --git a/packages/frontend/apps/ios/App/ShareExtension/ShareExtensionView.swift b/packages/frontend/apps/ios/App/ShareExtension/ShareExtensionView.swift new file mode 100644 index 0000000000..d87ccbd7e7 --- /dev/null +++ b/packages/frontend/apps/ios/App/ShareExtension/ShareExtensionView.swift @@ -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) + } + } + } + } +} diff --git a/packages/frontend/apps/ios/App/ShareExtension/ShareViewController.swift b/packages/frontend/apps/ios/App/ShareExtension/ShareViewController.swift new file mode 100644 index 0000000000..1b193f97c3 --- /dev/null +++ b/packages/frontend/apps/ios/App/ShareExtension/ShareViewController.swift @@ -0,0 +1,89 @@ +// +// ShareViewController.swift +// ShareExtension +// + +import SwiftUI +import UIKit + +final class ShareViewController: UIViewController { + private let viewModel = ShareViewModel() + private var hostingController: UIHostingController? + + 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 + } +} diff --git a/packages/frontend/apps/ios/App/ShareExtension/ShareViewModel.swift b/packages/frontend/apps/ios/App/ShareExtension/ShareViewModel.swift new file mode 100644 index 0000000000..28af46390a --- /dev/null +++ b/packages/frontend/apps/ios/App/ShareExtension/ShareViewModel.swift @@ -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 + } + } +} diff --git a/packages/frontend/apps/ios/App/Shared/ShareInbox/ShareInboxConstants.swift b/packages/frontend/apps/ios/App/Shared/ShareInbox/ShareInboxConstants.swift new file mode 100644 index 0000000000..87327610c6 --- /dev/null +++ b/packages/frontend/apps/ios/App/Shared/ShareInbox/ShareInboxConstants.swift @@ -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")! +} diff --git a/packages/frontend/apps/ios/App/Shared/ShareInbox/ShareInboxModels.swift b/packages/frontend/apps/ios/App/Shared/ShareInbox/ShareInboxModels.swift new file mode 100644 index 0000000000..2a292869a4 --- /dev/null +++ b/packages/frontend/apps/ios/App/Shared/ShareInbox/ShareInboxModels.swift @@ -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? +} diff --git a/packages/frontend/apps/ios/App/Shared/ShareInbox/ShareInboxSafety.swift b/packages/frontend/apps/ios/App/Shared/ShareInbox/ShareInboxSafety.swift new file mode 100644 index 0000000000..a58f4c0024 --- /dev/null +++ b/packages/frontend/apps/ios/App/Shared/ShareInbox/ShareInboxSafety.swift @@ -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 + } +} diff --git a/packages/frontend/apps/ios/App/Shared/ShareInbox/ShareInboxStore.swift b/packages/frontend/apps/ios/App/Shared/ShareInbox/ShareInboxStore.swift new file mode 100644 index 0000000000..b5b2649f5f --- /dev/null +++ b/packages/frontend/apps/ios/App/Shared/ShareInbox/ShareInboxStore.swift @@ -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 +} diff --git a/packages/frontend/apps/ios/App/Shared/ShareInbox/SharePayloadBuilder.swift b/packages/frontend/apps/ios/App/Shared/ShareInbox/SharePayloadBuilder.swift new file mode 100644 index 0000000000..c0b9b3c135 --- /dev/null +++ b/packages/frontend/apps/ios/App/Shared/ShareInbox/SharePayloadBuilder.swift @@ -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" + } + } +} diff --git a/packages/frontend/apps/ios/src/app.tsx b/packages/frontend/apps/ios/src/app.tsx index 9e161641fc..56fbbe021f 100644 --- a/packages/frontend/apps/ios/src/app.tsx +++ b/packages/frontend/apps/ios/src/app.tsx @@ -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() { + } diff --git a/packages/frontend/apps/ios/src/plugins/share-inbox/definitions.ts b/packages/frontend/apps/ios/src/plugins/share-inbox/definitions.ts new file mode 100644 index 0000000000..f40cb9ea7d --- /dev/null +++ b/packages/frontend/apps/ios/src/plugins/share-inbox/definitions.ts @@ -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; + resolveAttachment(options: { + itemId: string; + }): Promise<{ path?: string; mimeType?: string }>; + complete(options: { itemId: string; docId: string }): Promise; + setError(options: { itemId: string; error: string }): Promise; +} diff --git a/packages/frontend/apps/ios/src/plugins/share-inbox/index.ts b/packages/frontend/apps/ios/src/plugins/share-inbox/index.ts new file mode 100644 index 0000000000..c88ce21d11 --- /dev/null +++ b/packages/frontend/apps/ios/src/plugins/share-inbox/index.ts @@ -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('ShareInbox'); + +const blobToDataURL = (blob: Blob) => + new Promise((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 }); + }, +}; diff --git a/packages/frontend/core/src/mobile/components/share-import-controller/index.tsx b/packages/frontend/core/src/mobile/components/share-import-controller/index.tsx new file mode 100644 index 0000000000..c05ee199c8 --- /dev/null +++ b/packages/frontend/core/src/mobile/components/share-import-controller/index.tsx @@ -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 ; + case 'image': + return ; + case 'text': + return ; + } +}; + +export const ShareImportController = ({ + provider, +}: { + provider: ShareInboxProvider; +}) => { + const workspacesService = useService(WorkspacesService); + const importer = useService(ImportClipperService); + const workspaces = useLiveData(workspacesService.list.workspaces$); + const [item, setItem] = useState(); + const [page, setPage] = useState('main'); + const [selectedWorkspaceKey, setSelectedWorkspaceKey] = useState(''); + const [tagIds, setTagIds] = useState([]); + const [collectionId, setCollectionId] = useState(''); + const [destinations, setDestinations] = useState(); + const [isLoadingDestinations, setIsLoadingDestinations] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [attachmentPreview, setAttachmentPreview] = useState(); + 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 ( + setPage('main')} + onSelect={id => { + setSelectedWorkspaceKey(id); + setTagIds([]); + setCollectionId(''); + setItem(current => + current ? { ...current, lastError: undefined } : current + ); + setPage('main'); + }} + /> + ); + } + if (page === 'tags') { + return ( + setPage('main')} + onSelect={id => + setTagIds(ids => + ids.includes(id) + ? ids.filter(current => current !== id) + : [...ids, id] + ) + } + onConfirm={() => setPage('main')} + /> + ); + } + if (page === 'collection') { + return ( + setPage('main')} + onSelect={id => { + setCollectionId(id); + setPage('main'); + }} + /> + ); + } + if (page === 'offline') { + return ( +
+ setPage('main')}> + + Use local workspace data? + + +
+

+ {selectedWorkspaceName} +

+

+ 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. +

+
+ + + +
+ ); + } + + return ( +
+ setItem(undefined)}> + Not now + + } + > + Choose where to save + + + + + +
+
+
+ {attachmentPreview ? ( + + ) : ( + + )} +
+
+
{source.title}
+ {source.detail ? ( +
{source.detail}
+ ) : null} +
+
+ +
+ + + + + +
+ + {isLoadingDestinations ? ( +
Checking workspace…
+ ) : requiresOfflineConfirmation ? ( +
+ The latest online workspace state could not be confirmed. +
+ ) : null} + + {errorMessage(item.lastError) ? ( +
+ {errorMessage(item.lastError)} +
+ ) : null} +
+
+
+ + + + +
+ ); + })(); + + return ( + setItem(undefined)} + contentOptions={{ style: { padding: 0 } }} + > + {content} + + ); +}; diff --git a/packages/frontend/core/src/mobile/components/share-import-controller/selection-page.tsx b/packages/frontend/core/src/mobile/components/share-import-controller/selection-page.tsx new file mode 100644 index 0000000000..6dad56a2b8 --- /dev/null +++ b/packages/frontend/core/src/mobile/components/share-import-controller/selection-page.tsx @@ -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 ( +
+ + {title} + + + + +
    + {options.map(option => { + const selected = selectedIds.includes(option.id); + return ( +
  • + +
  • + ); + })} +
+
+
+ {multiple ? ( + + + + ) : null} +
+ ); +}; diff --git a/packages/frontend/core/src/mobile/components/share-import-controller/style.css.ts b/packages/frontend/core/src/mobile/components/share-import-controller/style.css.ts new file mode 100644 index 0000000000..2b49cb24ab --- /dev/null +++ b/packages/frontend/core/src/mobile/components/share-import-controller/style.css.ts @@ -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'), + }, +]); diff --git a/packages/frontend/core/src/mobile/components/share-import-controller/types.ts b/packages/frontend/core/src/mobile/components/share-import-controller/types.ts new file mode 100644 index 0000000000..eff201fe04 --- /dev/null +++ b/packages/frontend/core/src/mobile/components/share-import-controller/types.ts @@ -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; + updateTarget(itemId: string, target: ShareImportTarget): Promise; + resolveAttachment(itemId: string): Promise; + complete(itemId: string, docId: string): Promise; + setError(itemId: string, error: string): Promise; +} diff --git a/packages/frontend/core/src/modules/import-clipper/index.ts b/packages/frontend/core/src/modules/import-clipper/index.ts index 4508697353..5296540418 100644 --- a/packages/frontend/core/src/modules/import-clipper/index.ts +++ b/packages/frontend/core/src/modules/import-clipper/index.ts @@ -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]); diff --git a/packages/frontend/core/src/modules/import-clipper/services/import.ts b/packages/frontend/core/src/modules/import-clipper/services/import.ts index d28235c671..1b6fa9cb80 100644 --- a/packages/frontend/core/src/modules/import-clipper/services/import.ts +++ b/packages/frontend/core/src/modules/import-clipper/services/import.ts @@ -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 { + 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 { + 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 { + 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 } }; + }) { + let timeout: ReturnType | undefined; + try { + await Promise.race([ + workspace.engine.doc.waitForSynced(workspace.id), + new Promise((_, 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 diff --git a/packages/frontend/core/src/modules/workspace/entities/profile.ts b/packages/frontend/core/src/modules/workspace/entities/profile.ts index b0237ae7f0..5461e3a86d 100644 --- a/packages/frontend/core/src/modules/workspace/entities/profile.ts +++ b/packages/frontend/core/src/modules/workspace/entities/profile.ts @@ -40,7 +40,7 @@ export class WorkspaceProfile extends Entity<{ metadata: WorkspaceMetadata }> { } profile$ = LiveData.from( - 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( diff --git a/packages/frontend/core/src/modules/workspace/metadata.ts b/packages/frontend/core/src/modules/workspace/metadata.ts index 352ec1312b..a72612f328 100644 --- a/packages/frontend/core/src/modules/workspace/metadata.ts +++ b/packages/frontend/core/src/modules/workspace/metadata.ts @@ -3,3 +3,6 @@ export type WorkspaceMetadata = { flavour: string; initialized?: boolean; }; + +export const workspaceMetadataKey = (metadata: WorkspaceMetadata) => + `${metadata.flavour}:${metadata.id}`; diff --git a/packages/frontend/core/src/modules/workspace/services/profile.ts b/packages/frontend/core/src/modules/workspace/services/profile.ts index 3ee7d29feb..a565d833da 100644 --- a/packages/frontend/core/src/modules/workspace/services/profile.ts +++ b/packages/frontend/core/src/modules/workspace/services/profile.ts @@ -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(); 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; }; } diff --git a/packages/frontend/core/src/modules/workspace/services/repo.ts b/packages/frontend/core/src/modules/workspace/services/repo.ts index 4ce5da5115..18f5514659 100644 --- a/packages/frontend/core/src/modules/workspace/services/repo.ts +++ b/packages/frontend/core/src/modules/workspace/services/repo.ts @@ -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, diff --git a/packages/frontend/core/src/modules/workspace/stores/profile-cache.ts b/packages/frontend/core/src/modules/workspace/stores/profile-cache.ts index 4b583c4cbe..0b1784f3ce 100644 --- a/packages/frontend/core/src/modules/workspace/stores/profile-cache.ts +++ b/packages/frontend/core/src/modules/workspace/stores/profile-cache.ts @@ -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); - } }