@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.
- package/optional_sources/background.js +134 -0
- package/optional_sources/ios_silent_push.js +31 -0
- package/optional_sources/notifications.js +49 -13
- package/package.json +1 -2
- package/share_extension_files/Info.plist +20 -2
- package/share_extension_files/ShareViewController.swift +117 -88
- package/src/helpers/api.js +67 -35
- package/src/helpers/auth.js +127 -116
- package/src/helpers/background_tasks.js +47 -0
- package/src/helpers/common.js +32 -16
- package/src/helpers/db_schema.js +6 -4
- package/src/helpers/file_system.js +3 -0
- package/src/helpers/offline_mode.js +9 -13
- package/src/index.js +5 -5
- package/src/init.js +41 -42
- package/src/routing/mocks/request.js +1 -1
- package/src/routing/routes/notifications.js +34 -10
- package/src/routing/routes/view.js +9 -3
- package/src/routing/utils.js +1 -1
- package/www/js/iframe_view_utils.js +65 -18
- package/add_entitlements.rb +0 -16
- package/optional_sources/background_sync.js +0 -91
|
@@ -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-${
|
|
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 ${
|
|
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 ${
|
|
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 ${
|
|
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: {
|
|
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 ${
|
|
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.
|
|
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.
|
|
165
|
+
if (config.pushSync) {
|
|
142
166
|
await syncTokenApi(config, false);
|
|
143
|
-
|
|
144
|
-
|
|
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.
|
|
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>
|
|
28
|
-
<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
|
-
|
|
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
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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:
|
|
69
|
-
return
|
|
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
|
|
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
|
|
84
|
-
shareItem.title = url
|
|
85
|
-
shareItem.type = "application/" + url
|
|
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
|
|
89
|
-
shareItem.url = url
|
|
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
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
173
|
-
|
|
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
|
+
}
|