@saltcorn/mobile-app 1.7.0-alpha.0 → 1.7.0-beta.0
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/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/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 +131 -19
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.7.0-
|
|
4
|
+
"version": "1.7.0-beta.0",
|
|
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
|
+
}
|
package/src/helpers/api.js
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
/*global saltcorn, splashConfig*/
|
|
2
2
|
|
|
3
|
-
import axios from "axios";
|
|
4
|
-
import { jwtDecode } from "jwt-decode";
|
|
5
3
|
import { router } from "../routing/index";
|
|
6
4
|
import { replaceIframe, clearHistory } from "./navigation";
|
|
7
5
|
|
|
@@ -19,49 +17,83 @@ export async function apiCall({
|
|
|
19
17
|
? saltcorn.data.state.getState().mobileConfig
|
|
20
18
|
: splashConfig;
|
|
21
19
|
const serverPath = config.server_path;
|
|
22
|
-
|
|
20
|
+
let url = `${serverPath}${path}`;
|
|
21
|
+
if (params) {
|
|
22
|
+
const qs = new URLSearchParams(params).toString();
|
|
23
|
+
if (qs) url += `${url.includes("?") ? "&" : "?"}${qs}`;
|
|
24
|
+
}
|
|
23
25
|
const headers = {
|
|
24
26
|
"X-Requested-With": "XMLHttpRequest",
|
|
25
27
|
"X-Saltcorn-Client": "mobile-app",
|
|
26
28
|
...(additionalHeaders || {}),
|
|
27
29
|
};
|
|
28
30
|
if (config.tenantAppName) headers["X-Saltcorn-App"] = config.tenantAppName;
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
await saltcorn.data.db.deleteWhere("jwt_table");
|
|
37
|
-
clearHistory();
|
|
38
|
-
const page = await router.resolve({
|
|
39
|
-
pathname: "get/auth/login",
|
|
40
|
-
alerts: [
|
|
41
|
-
{ type: "warning", msg: "Your session has expired, please log in again." },
|
|
42
|
-
],
|
|
43
|
-
});
|
|
44
|
-
await replaceIframe(page.content);
|
|
45
|
-
return;
|
|
46
|
-
}
|
|
47
|
-
} catch {
|
|
48
|
-
// malformed token, let the request proceed and fail naturally
|
|
49
|
-
}
|
|
50
|
-
headers.Authorization = `jwt ${token}`;
|
|
31
|
+
// The session cookie is attached automatically by the WebView (credentials: "include").
|
|
32
|
+
// The CSRF token has to be sent explicitly, same as the web client does.
|
|
33
|
+
if (config.csrfToken) headers["CSRF-Token"] = config.csrfToken;
|
|
34
|
+
const fetchOptions = { method, headers, credentials: "include" };
|
|
35
|
+
if (body !== undefined) {
|
|
36
|
+
fetchOptions.body = JSON.stringify(body);
|
|
37
|
+
headers["Content-Type"] = "application/json";
|
|
51
38
|
}
|
|
39
|
+
const controller = timeout ? new AbortController() : null;
|
|
40
|
+
const timeoutId = controller
|
|
41
|
+
? setTimeout(() => controller.abort(), timeout)
|
|
42
|
+
: null;
|
|
43
|
+
if (controller) fetchOptions.signal = controller.signal;
|
|
52
44
|
try {
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
45
|
+
const res = await fetch(url, fetchOptions);
|
|
46
|
+
if (!res.ok) {
|
|
47
|
+
const error = new Error(`Request failed with status code ${res.status}`);
|
|
48
|
+
error.response = { status: res.status };
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
let data;
|
|
52
|
+
if (responseType === "blob") data = await res.blob();
|
|
53
|
+
else if (responseType === "text") data = await res.text();
|
|
54
|
+
else {
|
|
55
|
+
// A redirected error page still counts as a 200 here, so parse by
|
|
56
|
+
// hand to give a clear error instead of a cryptic JSON parse failure.
|
|
57
|
+
const text = await res.text();
|
|
58
|
+
try {
|
|
59
|
+
data = text ? JSON.parse(text) : undefined;
|
|
60
|
+
} catch {
|
|
61
|
+
const error = new Error(
|
|
62
|
+
`Expected JSON but got ${
|
|
63
|
+
res.headers.get("content-type") || "unknown content-type"
|
|
64
|
+
} (status ${res.status}, url ${res.url})`
|
|
65
|
+
);
|
|
66
|
+
error.response = { status: res.status };
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return { data, status: res.status };
|
|
63
71
|
} catch (error) {
|
|
72
|
+
if (
|
|
73
|
+
error.response?.status === 401 &&
|
|
74
|
+
typeof saltcorn !== "undefined" &&
|
|
75
|
+
!path.startsWith("/auth/")
|
|
76
|
+
) {
|
|
77
|
+
const mobileConfig = saltcorn.data.state.getState().mobileConfig;
|
|
78
|
+
mobileConfig.csrfToken = undefined;
|
|
79
|
+
mobileConfig.hasSession = false;
|
|
80
|
+
await saltcorn.data.db.deleteWhere("session_table");
|
|
81
|
+
clearHistory();
|
|
82
|
+
const page = await router.resolve({
|
|
83
|
+
pathname: "get/auth/login",
|
|
84
|
+
alerts: [
|
|
85
|
+
{
|
|
86
|
+
type: "warning",
|
|
87
|
+
msg: "Your session has expired, please log in again.",
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
});
|
|
91
|
+
await replaceIframe(page.content);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
64
94
|
error.message = `Unable to call ${method} ${url}:\n${error.message}`;
|
|
65
95
|
throw error;
|
|
96
|
+
} finally {
|
|
97
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
66
98
|
}
|
|
67
99
|
}
|