@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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@saltcorn/mobile-app",
3
3
  "displayName": "Saltcorn mobile app",
4
- "version": "1.7.0-alpha.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>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
+ }
@@ -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
- const url = `${serverPath}${path}`;
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
- const token = config.jwt;
30
- if (token) {
31
- try {
32
- const decoded = jwtDecode(token);
33
- if (decoded.exp && decoded.exp < Date.now() / 1000 && typeof saltcorn !== "undefined" && !path.startsWith("/auth/")) {
34
- const mobileConfig = saltcorn.data.state.getState().mobileConfig;
35
- mobileConfig.jwt = undefined;
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 result = await axios({
54
- url: url,
55
- method,
56
- params,
57
- headers,
58
- responseType: responseType ? responseType : "json",
59
- data: body,
60
- timeout: timeout ? timeout : 0,
61
- });
62
- return result;
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
  }