@saltcorn/mobile-app 1.6.3-beta.1 → 1.7.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,134 @@
1
+ import { BackgroundFetch } from "@transistorsoft/capacitor-background-fetch";
2
+ import { performSync, performHeartbeat } from "./background_tasks.js";
3
+
4
+ const SYNC_TASK_ID = "com.transistorsoft.background_sync";
5
+ const HEARTBEAT_TASK_ID = "com.transistorsoft.push_sync_heartbeat";
6
+
7
+ let isConfigured = false;
8
+
9
+ /**
10
+ * Call BackgroundFetch.configure() with a task-routing callback.
11
+ * Sets isConfigured on success.
12
+ * @param {number} minimumFetchInterval hint to the OS in minutes
13
+ */
14
+ async function configure(minimumFetchInterval) {
15
+ const status = await BackgroundFetch.configure(
16
+ { minimumFetchInterval },
17
+ async (taskId) => {
18
+ switch (taskId) {
19
+ case SYNC_TASK_ID:
20
+ try {
21
+ await performSync();
22
+ } catch (e) {
23
+ console.error("Background sync error:", e);
24
+ }
25
+ break;
26
+ case HEARTBEAT_TASK_ID:
27
+ try {
28
+ await performHeartbeat();
29
+ } catch (e) {
30
+ console.error("Heartbeat error:", e);
31
+ }
32
+ break;
33
+ default:
34
+ console.log("[BackgroundFetch] unknown task:", taskId);
35
+ }
36
+ BackgroundFetch.finish(taskId);
37
+ },
38
+ async (taskId) => {
39
+ console.log("[BackgroundFetch] TIMEOUT:", taskId);
40
+ BackgroundFetch.finish(taskId);
41
+ }
42
+ );
43
+
44
+ if (status === BackgroundFetch.STATUS_AVAILABLE) {
45
+ isConfigured = true;
46
+ console.log("BackgroundFetch configured successfully");
47
+ } else if (status === BackgroundFetch.STATUS_DENIED) {
48
+ console.log("BackgroundFetch: user denied background behavior");
49
+ } else if (status === BackgroundFetch.STATUS_RESTRICTED) {
50
+ console.log("BackgroundFetch: background updates restricted");
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Schedule the configured named tasks via BackgroundFetch.scheduleTask.
56
+ * @param {number|undefined} syncInterval background-sync interval in minutes
57
+ * @param {number|undefined} heartbeatInterval push-sync heartbeat interval in minutes
58
+ */
59
+ async function scheduleTasks(syncInterval, heartbeatInterval) {
60
+ if (syncInterval > 0) {
61
+ try {
62
+ await BackgroundFetch.scheduleTask({
63
+ taskId: SYNC_TASK_ID,
64
+ delay: syncInterval * 60 * 1000,
65
+ periodic: true,
66
+ requiresNetworkConnectivity: true,
67
+ });
68
+ console.log(
69
+ `Background sync scheduled, interval: ${syncInterval} minute(s)`
70
+ );
71
+ } catch (e) {
72
+ console.error("Error scheduling sync task:", e);
73
+ }
74
+ }
75
+
76
+ if (heartbeatInterval > 0) {
77
+ try {
78
+ await BackgroundFetch.scheduleTask({
79
+ taskId: HEARTBEAT_TASK_ID,
80
+ delay: heartbeatInterval * 60 * 1000,
81
+ periodic: true,
82
+ requiresNetworkConnectivity: true,
83
+ });
84
+ console.log(
85
+ `Push sync heartbeat scheduled, interval: ${heartbeatInterval} minute(s)`
86
+ );
87
+ } catch (e) {
88
+ console.error("Error scheduling heartbeat task:", e);
89
+ }
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Init background tasks. Routes task IDs to performSync / performHeartbeat
95
+ * from background_tasks.js. Only tasks with a configured interval are scheduled.
96
+ * @param {number|undefined} syncInterval background-sync interval in minutes
97
+ * @param {number|undefined} heartbeatInterval push-sync heartbeat interval in minutes
98
+ * @returns {Promise<boolean>} true if BackgroundFetch was configured successfully
99
+ */
100
+ export async function initBackground(syncInterval, heartbeatInterval) {
101
+ const hasSyncInterval = syncInterval && syncInterval > 0;
102
+ const hasHeartbeatInterval = heartbeatInterval && heartbeatInterval > 0;
103
+
104
+ if (!hasSyncInterval && !hasHeartbeatInterval) {
105
+ console.log("initBackground: no intervals configured, skipping");
106
+ return false;
107
+ }
108
+
109
+ if (!isConfigured) {
110
+ const minimumFetchInterval = Math.min(
111
+ hasSyncInterval ? syncInterval : Infinity,
112
+ hasHeartbeatInterval ? heartbeatInterval : Infinity
113
+ );
114
+ await configure(minimumFetchInterval);
115
+ }
116
+
117
+ if (isConfigured) await scheduleTasks(syncInterval, heartbeatInterval);
118
+
119
+ return isConfigured;
120
+ }
121
+
122
+ /**
123
+ * Stop all background tasks (sync + heartbeat). Call on logout.
124
+ */
125
+ export async function stopBackground() {
126
+ if (!isConfigured) return;
127
+ try {
128
+ await BackgroundFetch.stop();
129
+ isConfigured = false;
130
+ console.log("Background tasks stopped");
131
+ } catch (e) {
132
+ console.error("Error stopping background tasks:", e);
133
+ }
134
+ }
@@ -0,0 +1,31 @@
1
+ /*global saltcorn*/
2
+
3
+ import { CapacitorSilentNotifications } from "capacitor-plugin-silent-notifications";
4
+
5
+ let silentNotificationListener = null;
6
+
7
+ export async function initIosSilentPushListener() {
8
+ if (silentNotificationListener) {
9
+ await silentNotificationListener.remove();
10
+ silentNotificationListener = null;
11
+ }
12
+ silentNotificationListener = await CapacitorSilentNotifications.addListener(
13
+ "silentNotificationReceived",
14
+ async (notification) => {
15
+ console.log("iOS silent push received:", notification);
16
+ const state = saltcorn.data.state.getState();
17
+ if (state.mobile_push_handler?.["push_sync"]) {
18
+ try {
19
+ await state.mobile_push_handler["push_sync"](notification);
20
+ } catch (error) {
21
+ console.error("Error handling push_sync silent notification:", error);
22
+ }
23
+ }
24
+ }
25
+ );
26
+ }
27
+
28
+ export async function removeIosSilentPushListener() {
29
+ await silentNotificationListener?.remove();
30
+ silentNotificationListener = null;
31
+ }
@@ -11,28 +11,40 @@ import { Device } from "@capacitor/device";
11
11
  */
12
12
  async function notifyTokenApi(config, isSubscribe) {
13
13
  console.log("notifyTokenApi subscribe:", isSubscribe);
14
+ if (!config.pushConfiguration) {
15
+ console.warn("notifyTokenApi: pushConfiguration is not set, skipping");
16
+ return;
17
+ }
14
18
  const { token, deviceId } = config.pushConfiguration;
15
19
  const platform = Capacitor.getPlatform();
16
20
  try {
17
21
  const response = await apiCall({
18
22
  method: "POST",
19
- path: `/notifications/mobile-${isSubscribe ? "subscribe" : "remove-subscription"}`,
23
+ path: `/notifications/mobile-${
24
+ isSubscribe ? "subscribe" : "remove-subscription"
25
+ }`,
20
26
  body: { token, deviceId, platform },
21
27
  });
22
28
  const data = response.data;
23
29
  if (data.success === "ok")
24
30
  console.log(
25
- `successfully ${isSubscribe ? "subscribed" : "unsubscribed"} to push notifications`,
31
+ `successfully ${
32
+ isSubscribe ? "subscribed" : "unsubscribed"
33
+ } to push notifications`,
26
34
  data
27
35
  );
28
36
  else
29
37
  console.error(
30
- `unable to ${isSubscribe ? "subscribe" : "unsubscribe"} to push notifications`,
38
+ `unable to ${
39
+ isSubscribe ? "subscribe" : "unsubscribe"
40
+ } to push notifications`,
31
41
  data
32
42
  );
33
43
  } catch (error) {
34
44
  console.error(
35
- `unable to ${isSubscribe ? "subscribe" : "unsubscribe"} to push notifications`,
45
+ `unable to ${
46
+ isSubscribe ? "subscribe" : "unsubscribe"
47
+ } to push notifications`,
36
48
  error
37
49
  );
38
50
  }
@@ -45,18 +57,30 @@ async function notifyTokenApi(config, isSubscribe) {
45
57
  */
46
58
  async function syncTokenApi(config, isSubscribe) {
47
59
  console.log("syncTokenApi subscribe:", isSubscribe);
60
+ if (!config.pushConfiguration) {
61
+ console.warn("syncTokenApi: pushConfiguration is not set, skipping");
62
+ return;
63
+ }
48
64
  const { token, deviceId } = config.pushConfiguration;
49
65
  const platform = Capacitor.getPlatform();
50
66
  try {
51
67
  const response = await apiCall({
52
68
  method: "POST",
53
69
  path: `/sync/push_${isSubscribe ? "subscribe" : "unsubscribe"}`,
54
- body: { token, deviceId, synchedTables: config.synchedTables, platform },
70
+ body: {
71
+ token,
72
+ deviceId,
73
+ synchedTables: config.synchedTables,
74
+ platform,
75
+ apnsEnvironment: config.apnsEnvironment,
76
+ },
55
77
  });
56
78
  const data = response.data;
57
79
  if (data.success === "ok")
58
80
  console.log(
59
- `successfully ${isSubscribe ? "subscribed" : "unsubscribed"} to push sync`,
81
+ `successfully ${
82
+ isSubscribe ? "subscribed" : "unsubscribed"
83
+ } to push sync`,
60
84
  data
61
85
  );
62
86
  else
@@ -94,7 +118,6 @@ export async function initPushNotifications() {
94
118
  await removePushListeners();
95
119
  const permStatus = await PushNotifications.requestPermissions();
96
120
  if (permStatus.receive === "granted") {
97
- await PushNotifications.register();
98
121
  registrationListener = PushNotifications.addListener(
99
122
  "registration",
100
123
  async (token) => {
@@ -109,8 +132,7 @@ export async function initPushNotifications() {
109
132
  deviceId: identifier,
110
133
  };
111
134
  await notifyTokenApi(config, true);
112
- if (config.allowOfflineMode && config.pushSync)
113
- await syncTokenApi(config, true);
135
+ if (config.pushSync) await syncTokenApi(config, true);
114
136
  }
115
137
  }
116
138
  );
@@ -126,6 +148,8 @@ export async function initPushNotifications() {
126
148
  "pushNotificationReceived",
127
149
  messageHandler
128
150
  );
151
+
152
+ await PushNotifications.register();
129
153
  } else {
130
154
  console.warn("Push notification permission not granted");
131
155
  }
@@ -134,17 +158,29 @@ export async function initPushNotifications() {
134
158
 
135
159
  export async function unregisterPushNotifications() {
136
160
  if (Capacitor.getPlatform() !== "web" && PushNotifications) {
161
+ const config = saltcorn.data.state.getState().mobileConfig;
137
162
  try {
138
163
  await PushNotifications.unregister();
139
- const config = saltcorn.data.state.getState().mobileConfig;
140
164
  await notifyTokenApi(config, false);
141
- if (config.allowOfflineMode && config.pushSync)
165
+ if (config.pushSync) {
142
166
  await syncTokenApi(config, false);
143
- await removePushListeners();
144
- config.pushConfiguration = null;
167
+ if (Capacitor.getPlatform() === "ios") {
168
+ try {
169
+ const { removeIosSilentPushListener } = await import(
170
+ "../helpers/ios_silent_push.js"
171
+ );
172
+ await removeIosSilentPushListener();
173
+ } catch (e) {
174
+ console.log("iOS silent push not available:", e);
175
+ }
176
+ }
177
+ }
145
178
  console.log("Push notifications unregistered successfully");
146
179
  } catch (error) {
147
180
  console.error("Error unregistering push notifications:", error);
181
+ } finally {
182
+ await removePushListeners();
183
+ config.pushConfiguration = null;
148
184
  }
149
185
  }
150
186
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@saltcorn/mobile-app",
3
3
  "displayName": "Saltcorn mobile app",
4
- "version": "1.6.3-beta.1",
4
+ "version": "1.7.0-alpha.1",
5
5
  "description": "Saltcorn mobile app for Android and iOS",
6
6
  "main": "index.js",
7
7
  "scripts": {
@@ -16,7 +16,6 @@
16
16
  },
17
17
  "dependencies": {
18
18
  "universal-router": "9.2.1",
19
- "axios": "0.32.0",
20
19
  "jwt-decode": "4.0.0",
21
20
  "i18next": "24.2.2",
22
21
  "i18next-sprintf-postprocessor": "0.2.2",
@@ -2,6 +2,24 @@
2
2
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
3
  <plist version="1.0">
4
4
  <dict>
5
+ <key>CFBundleDevelopmentRegion</key>
6
+ <string>en</string>
7
+ <key>CFBundleDisplayName</key>
8
+ <string>$(PRODUCT_NAME)</string>
9
+ <key>CFBundleExecutable</key>
10
+ <string>$(EXECUTABLE_NAME)</string>
11
+ <key>CFBundleIdentifier</key>
12
+ <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
13
+ <key>CFBundleInfoDictionaryVersion</key>
14
+ <string>6.0</string>
15
+ <key>CFBundleName</key>
16
+ <string>$(PRODUCT_NAME)</string>
17
+ <key>CFBundlePackageType</key>
18
+ <string>XPC!</string>
19
+ <key>CFBundleShortVersionString</key>
20
+ <string>$(MARKETING_VERSION)</string>
21
+ <key>CFBundleVersion</key>
22
+ <string>$(CURRENT_PROJECT_VERSION)</string>
5
23
  <key>NSExtension</key>
6
24
  <dict>
7
25
  <key>NSExtensionAttributes</key>
@@ -24,8 +42,8 @@
24
42
  <false/>
25
43
  </dict>
26
44
  </dict>
27
- <key>NSExtensionMainStoryboard</key>
28
- <string>MainInterface</string>
45
+ <key>NSExtensionPrincipalClass</key>
46
+ <string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
29
47
  <key>NSExtensionPointIdentifier</key>
30
48
  <string>com.apple.share-services</string>
31
49
  </dict>
@@ -10,89 +10,104 @@ import Social
10
10
  import UIKit
11
11
 
12
12
  class ShareItem {
13
-
13
+
14
14
  public var title: String?
15
15
  public var type: String?
16
16
  public var url: String?
17
17
  }
18
18
 
19
19
  class ShareViewController: UIViewController {
20
-
20
+
21
21
  private var shareItems: [ShareItem] = []
22
-
23
- override public func viewDidAppear(_ animated: Bool) {
24
- super.viewDidAppear(animated)
25
- self.extensionContext!.completeRequest(returningItems: [], completionHandler: nil)
26
- }
27
-
22
+
28
23
  private func sendData() {
24
+ // URLComponents.url already percent-encodes queryItems values when
25
+ // building the final URL string - encoding them here too caused
26
+ // double-encoding (e.g. "text/plain" arrived as "text%2Fplain"),
27
+ // which broke type checks like isFileItem() on the JS side.
29
28
  let queryItems = shareItems.map {
30
29
  [
31
- URLQueryItem(
32
- name: "title",
33
- value: $0.title?.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? ""),
30
+ URLQueryItem(name: "title", value: $0.title ?? ""),
34
31
  URLQueryItem(name: "description", value: ""),
35
- URLQueryItem(
36
- name: "type",
37
- value: $0.type?.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? ""),
38
- URLQueryItem(
39
- name: "url",
40
- value: $0.url?.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? ""),
32
+ URLQueryItem(name: "type", value: $0.type ?? ""),
33
+ URLQueryItem(name: "url", value: $0.url ?? ""),
41
34
  ]
42
35
  }.flatMap({ $0 })
43
36
  var urlComps = URLComponents(string: "scappscheme://")!
44
37
  urlComps.queryItems = queryItems
45
38
  openURL(urlComps.url!)
46
39
  }
47
-
40
+
48
41
  fileprivate func createSharedFileUrl(_ url: URL?) -> String {
49
- let fileManager = FileManager.default
50
-
51
- let copyFileUrl =
52
- fileManager.containerURL(forSecurityApplicationGroupIdentifier: "YOUR_APP_GROUP_ID")!
53
- .absoluteString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)! + url!
54
- .lastPathComponent.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)!
55
- try? Data(contentsOf: url!).write(to: URL(string: copyFileUrl)!)
56
-
57
- return copyFileUrl
42
+ guard let url = url else { return "" }
43
+ guard let containerURL = FileManager.default.containerURL(
44
+ forSecurityApplicationGroupIdentifier: "YOUR_APP_GROUP_ID"
45
+ ) else {
46
+ return ""
47
+ }
48
+ // appendingPathComponent handles escaping correctly on its own -
49
+ // hand-building a percent-encoded string here and then encoding it
50
+ // *again* as a URLQueryItem value in sendData() double-encoded it,
51
+ // so the path JS received didn't match any real file
52
+ let destinationURL = containerURL.appendingPathComponent(url.lastPathComponent)
53
+
54
+ // files vended by the Files app (iCloud, other apps' sandboxes) need
55
+ // this before they can actually be read - without it, Data(contentsOf:)
56
+ // fails and this used to silently return a path to a file that was
57
+ // never written
58
+ let didStartAccessing = url.startAccessingSecurityScopedResource()
59
+ defer {
60
+ if didStartAccessing { url.stopAccessingSecurityScopedResource() }
61
+ }
62
+ do {
63
+ try Data(contentsOf: url).write(to: destinationURL)
64
+ return destinationURL.absoluteString
65
+ } catch {
66
+ print("createSharedFileUrl failed: \(error)")
67
+ return ""
68
+ }
58
69
  }
59
-
70
+
60
71
  func saveScreenshot(_ image: UIImage, _ index: Int) -> String {
61
- let fileManager = FileManager.default
62
-
63
- let copyFileUrl =
64
- fileManager.containerURL(forSecurityApplicationGroupIdentifier: "YOUR_APP_GROUP_ID")!
65
- .absoluteString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)!
66
- + "/screenshot_\(index).png"
72
+ guard let containerURL = FileManager.default.containerURL(
73
+ forSecurityApplicationGroupIdentifier: "YOUR_APP_GROUP_ID"
74
+ ) else {
75
+ return ""
76
+ }
77
+ let destinationURL = containerURL.appendingPathComponent("screenshot_\(index).png")
67
78
  do {
68
- try image.pngData()?.write(to: URL(string: copyFileUrl)!)
69
- return copyFileUrl
79
+ try image.pngData()?.write(to: destinationURL)
80
+ return destinationURL.absoluteString
70
81
  } catch {
71
- print(error.localizedDescription)
82
+ print("saveScreenshot failed: \(error.localizedDescription)")
72
83
  return ""
73
84
  }
74
85
  }
75
-
86
+
76
87
  fileprivate func handleTypeUrl(_ attachment: NSItemProvider)
77
88
  async throws -> ShareItem
78
89
  {
79
90
  let results = try await attachment.loadItem(forTypeIdentifier: kUTTypeURL as String, options: nil)
80
- let url = results as! URL?
91
+ guard let url = results as? URL else {
92
+ throw NSError(domain: "ShareViewController", code: 1, userInfo: [
93
+ NSLocalizedDescriptionKey: "handleTypeUrl: expected a URL, got \(type(of: results))"
94
+ ])
95
+ }
81
96
  let shareItem: ShareItem = ShareItem()
82
-
83
- if url!.isFileURL {
84
- shareItem.title = url!.lastPathComponent
85
- shareItem.type = "application/" + url!.pathExtension.lowercased()
97
+
98
+ if url.isFileURL {
99
+ shareItem.title = url.lastPathComponent
100
+ shareItem.type = "application/" + url.pathExtension.lowercased()
86
101
  shareItem.url = createSharedFileUrl(url)
87
102
  } else {
88
- shareItem.title = url!.absoluteString
89
- shareItem.url = url!.absoluteString
103
+ shareItem.title = url.absoluteString
104
+ shareItem.url = url.absoluteString
90
105
  shareItem.type = "text/plain"
91
106
  }
92
-
107
+
93
108
  return shareItem
94
109
  }
95
-
110
+
96
111
  fileprivate func handleTypeText(_ attachment: NSItemProvider)
97
112
  async throws -> ShareItem
98
113
  {
@@ -103,25 +118,29 @@ class ShareViewController: UIViewController {
103
118
  shareItem.type = "text/plain"
104
119
  return shareItem
105
120
  }
106
-
121
+
107
122
  fileprivate func handleTypeMovie(_ attachment: NSItemProvider)
108
123
  async throws -> ShareItem
109
124
  {
110
125
  let results = try await attachment.loadItem(forTypeIdentifier: kUTTypeMovie as String, options: nil)
111
126
  let shareItem: ShareItem = ShareItem()
112
-
113
- let url = results as! URL?
114
- shareItem.title = url!.lastPathComponent
115
- shareItem.type = "video/" + url!.pathExtension.lowercased()
127
+
128
+ guard let url = results as? URL else {
129
+ throw NSError(domain: "ShareViewController", code: 1, userInfo: [
130
+ NSLocalizedDescriptionKey: "handleTypeMovie: expected a URL, got \(type(of: results))"
131
+ ])
132
+ }
133
+ shareItem.title = url.lastPathComponent
134
+ shareItem.type = "video/" + url.pathExtension.lowercased()
116
135
  shareItem.url = createSharedFileUrl(url)
117
136
  return shareItem
118
137
  }
119
-
138
+
120
139
  fileprivate func handleTypeImage(_ attachment: NSItemProvider, _ index: Int)
121
140
  async throws -> ShareItem
122
141
  {
123
142
  let data = try await attachment.loadItem(forTypeIdentifier: kUTTypeImage as String, options: nil)
124
-
143
+
125
144
  let shareItem: ShareItem = ShareItem()
126
145
  switch data {
127
146
  case let image as UIImage:
@@ -137,48 +156,58 @@ class ShareViewController: UIViewController {
137
156
  }
138
157
  return shareItem
139
158
  }
140
-
159
+
141
160
  override public func viewDidLoad() {
142
161
  super.viewDidLoad()
143
-
162
+
144
163
  shareItems.removeAll()
145
-
164
+
146
165
  let extensionItem = extensionContext?.inputItems[0] as! NSExtensionItem
147
166
  Task {
148
- try await withThrowingTaskGroup(
149
- of: ShareItem.self,
150
- body: { taskGroup in
151
-
152
- for (index, attachment) in extensionItem.attachments!.enumerated() {
153
- if attachment.hasItemConformingToTypeIdentifier(kUTTypeURL as String) {
154
- taskGroup.addTask {
155
- return try await self.handleTypeUrl(attachment)
156
- }
157
- } else if attachment.hasItemConformingToTypeIdentifier(kUTTypeText as String) {
158
- taskGroup.addTask {
159
- return try await self.handleTypeText(attachment)
160
- }
161
- } else if attachment.hasItemConformingToTypeIdentifier(kUTTypeMovie as String) {
162
- taskGroup.addTask {
163
- return try await self.handleTypeMovie(attachment)
164
- }
165
- } else if attachment.hasItemConformingToTypeIdentifier(kUTTypeImage as String) {
166
- taskGroup.addTask {
167
- return try await self.handleTypeImage(attachment, index)
167
+ do {
168
+ try await withThrowingTaskGroup(
169
+ of: ShareItem.self,
170
+ body: { taskGroup in
171
+
172
+ for (index, attachment) in extensionItem.attachments!.enumerated() {
173
+ if attachment.hasItemConformingToTypeIdentifier(kUTTypeURL as String) {
174
+ taskGroup.addTask {
175
+ return try await self.handleTypeUrl(attachment)
176
+ }
177
+ } else if attachment.hasItemConformingToTypeIdentifier(kUTTypeText as String) {
178
+ taskGroup.addTask {
179
+ return try await self.handleTypeText(attachment)
180
+ }
181
+ } else if attachment.hasItemConformingToTypeIdentifier(kUTTypeMovie as String) {
182
+ taskGroup.addTask {
183
+ return try await self.handleTypeMovie(attachment)
184
+ }
185
+ } else if attachment.hasItemConformingToTypeIdentifier(kUTTypeImage as String) {
186
+ taskGroup.addTask {
187
+ return try await self.handleTypeImage(attachment, index)
188
+ }
168
189
  }
169
190
  }
170
- }
171
-
172
- for try await item in taskGroup {
173
- self.shareItems.append(item)
174
- }
175
- })
176
-
191
+
192
+ for try await item in taskGroup {
193
+ self.shareItems.append(item)
194
+ }
195
+ })
196
+ } catch {
197
+ // a bad attachment (e.g. an unexpected type) still needs to
198
+ // complete the request below, or the share sheet hangs
199
+ print("viewDidLoad: task group failed: \(error)")
200
+ }
201
+
177
202
  self.sendData()
178
-
203
+ // only complete (letting the OS tear this extension down) once
204
+ // sendData() has actually handed off to the host app - doing
205
+ // this in viewDidAppear instead raced the async work above and
206
+ // usually won, killing the extension before openURL() ran
207
+ self.extensionContext!.completeRequest(returningItems: [], completionHandler: nil)
179
208
  }
180
209
  }
181
-
210
+
182
211
  @objc func openURL(_ url: URL) {
183
212
  var responder: UIResponder? = self
184
213
  while responder != nil {
@@ -189,5 +218,5 @@ class ShareViewController: UIViewController {
189
218
  responder = responder?.next
190
219
  }
191
220
  }
192
-
193
- }
221
+
222
+ }