@saltcorn/mobile-app 1.7.0-alpha.0 → 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/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-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
+ }
@@ -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
  }
@@ -1,6 +1,5 @@
1
1
  /*global saltcorn*/
2
2
 
3
- import { jwtDecode } from "jwt-decode";
4
3
  import i18next from "i18next";
5
4
  import { apiCall } from "./api";
6
5
  import { router } from "../routing/index";
@@ -15,50 +14,57 @@ import {
15
14
  } from "./common";
16
15
 
17
16
  /**
18
- * internal helper for the normal login/signup and public login
17
+ * fetches a CSRF token and caches it in mobileConfig.csrfToken.
18
+ * Call again after every login/logout, since both reset it.
19
+ */
20
+ export async function refreshCsrfToken() {
21
+ const response = await apiCall({ method: "GET", path: "/auth/csrf-token" });
22
+ const config = saltcorn.data.state.getState().mobileConfig;
23
+ config.csrfToken = response?.data?.csrfToken;
24
+ return config.csrfToken;
25
+ }
26
+
27
+ /**
28
+ * internal helper for the normal login/signup
19
29
  * @param {any} param0
20
30
  * @returns
21
31
  */
22
- async function loginRequest({ email, password, isSignup, isPublic }) {
23
- const opts = isPublic
32
+ async function loginRequest({ email, password, isSignup, code }) {
33
+ await refreshCsrfToken();
34
+ const opts = code
24
35
  ? {
25
- method: "GET",
26
- path: "/auth/login-with/jwt",
36
+ method: "POST",
37
+ path: "/auth/session-from-code",
38
+ body: { code },
27
39
  }
28
40
  : isSignup
29
41
  ? {
30
42
  method: "POST",
31
43
  path: "/auth/signup",
32
- body: {
33
- email,
34
- password,
35
- },
44
+ body: { email, password },
36
45
  }
37
46
  : {
38
47
  method: "POST",
39
- path: "/auth/login-with/jwt",
40
- body: {
41
- email,
42
- password,
43
- },
48
+ path: "/auth/login-with/session",
49
+ body: { email, password },
44
50
  };
45
51
  const response = await apiCall(opts);
46
52
  return response.data;
47
53
  }
48
54
 
49
55
  /**
50
- * internal helper to process a JWT token
51
- * @param {string} tokenStr
56
+ * internal helper to process a successful session login response
57
+ * ({ success: true, user }) and persist it locally
52
58
  */
53
- const handleToken = async (tokenStr, config) => {
54
- const token = jwtDecode(tokenStr);
55
- const user = token.user;
59
+ const handleSessionLogin = async (user, config) => {
56
60
  config.user = user;
57
61
  config.isPublicUser = false;
58
62
  config.isOfflineMode = false;
63
+ config.hasSession = true;
59
64
  await insertUser(user);
60
- await setJwt(tokenStr);
61
- config.jwt = tokenStr;
65
+ // login regenerated the session, so the previously fetched token is stale
66
+ await refreshCsrfToken();
67
+ await persistSession(config);
62
68
  i18next.changeLanguage(user.language);
63
69
  };
64
70
 
@@ -101,22 +107,22 @@ const getEntryPoint = (config) => {
101
107
  };
102
108
 
103
109
  /**
104
- * For normal login/signup email and password are used
105
- * When called from auth provider login (see google-auth plugin), token is used
110
+ * Normal login/signup use email and password. An OAuth-provider login
111
+ * (e.g. google-auth's finishLogin) instead passes the short-lived code
112
+ * its deep-link callback was handed, which stands in for a session here.
106
113
  * @param {*} param0
107
114
  */
108
115
  export async function login({ email, password, isSignup, token }) {
109
- const loginResult = !token
110
- ? await loginRequest({
111
- email,
112
- password,
113
- isSignup,
114
- })
115
- : token;
116
- if (typeof loginResult === "string") {
116
+ const loginResult = await loginRequest({
117
+ email,
118
+ password,
119
+ isSignup,
120
+ code: token,
121
+ });
122
+ if (loginResult?.success && loginResult?.user) {
117
123
  const alerts = [];
118
124
  const config = saltcorn.data.state.getState().mobileConfig;
119
- await handleToken(loginResult, config);
125
+ await handleSessionLogin(loginResult.user, config);
120
126
  if (config.allowOfflineMode) alerts.push(await initialSync(config));
121
127
  await tryInitPush(config);
122
128
  await tryInitBackgroundSync(config);
@@ -129,14 +135,31 @@ export async function login({ email, password, isSignup, token }) {
129
135
  });
130
136
 
131
137
  // open first page
132
- const entryPoint = getEntryPoint(config);
133
- addRoute({ route: entryPoint, query: undefined });
134
- const page = await router.resolve({
135
- pathname: entryPoint,
136
- fullWrap: true,
137
- alerts,
138
- });
139
- if (page.content) await replaceIframe(page.content, page.isFile);
138
+ try {
139
+ const entryPoint = getEntryPoint(config);
140
+ addRoute({ route: entryPoint, query: undefined });
141
+ const page = await router.resolve({
142
+ pathname: entryPoint,
143
+ fullWrap: true,
144
+ alerts,
145
+ });
146
+ if (page.content) await replaceIframe(page.content, page.isFile);
147
+ } catch (error) {
148
+ // Logged in fine, but this user can't open the app's entry point
149
+ // (e.g. its role doesn't allow it) - say so instead of leaving
150
+ // them stuck on the login page with no explanation.
151
+ showToasts([
152
+ {
153
+ type: "error",
154
+ msg: i18next.t("Logged in as %s, but unable to open the app: %s", {
155
+ postProcess: "sprintf",
156
+ sprintf: [config.user.email, error.message || "unknown error"],
157
+ }),
158
+ },
159
+ ]);
160
+ }
161
+ } else if (loginResult?.error) {
162
+ showToasts([{ type: "error", msg: loginResult.error }]);
140
163
  } else if (loginResult?.alerts) {
141
164
  showToasts(loginResult?.alerts);
142
165
  } else {
@@ -146,44 +169,39 @@ export async function login({ email, password, isSignup, token }) {
146
169
 
147
170
  export async function publicLogin(entryPoint) {
148
171
  try {
149
- const loginResult = await loginRequest({ isPublic: true });
150
- if (typeof loginResult === "string") {
151
- const config = saltcorn.data.state.getState().mobileConfig;
152
- config.user = {
153
- role_id: 100,
154
- email: "public",
155
- language: "en",
156
- };
157
- config.isPublicUser = true;
158
- await setJwt(loginResult);
159
- config.jwt = loginResult;
160
- i18next.changeLanguage(config.user.language);
161
- addRoute({
162
- route: entryPoint,
163
- query: undefined,
164
- });
165
- const page = await router.resolve({
166
- pathname: entryPoint,
167
- fullWrap: true,
168
- alerts: [
169
- {
170
- type: "success",
171
- msg: i18next.t("Welcome to %s!", {
172
- postProcess: "sprintf",
173
- sprintf: [
174
- saltcorn.data.state.getState().getConfig("site_name") ||
175
- "Saltcorn",
176
- ],
177
- }),
178
- },
179
- ],
180
- });
181
- if (page.content) await replaceIframe(page.content, page.isFile);
182
- } else if (loginResult?.alerts) {
183
- showToasts(loginResult?.alerts);
184
- } else {
185
- throw new Error("The login failed.");
186
- }
172
+ const config = saltcorn.data.state.getState().mobileConfig;
173
+ // no login needed for public, but a CSRF token is still cached upfront.
174
+ await refreshCsrfToken();
175
+ config.user = {
176
+ role_id: 100,
177
+ email: "public",
178
+ language: "en",
179
+ };
180
+ config.isPublicUser = true;
181
+ config.hasSession = true;
182
+ await persistSession(config);
183
+ i18next.changeLanguage(config.user.language);
184
+ addRoute({
185
+ route: entryPoint,
186
+ query: undefined,
187
+ });
188
+ const page = await router.resolve({
189
+ pathname: entryPoint,
190
+ fullWrap: true,
191
+ alerts: [
192
+ {
193
+ type: "success",
194
+ msg: i18next.t("Welcome to %s!", {
195
+ postProcess: "sprintf",
196
+ sprintf: [
197
+ saltcorn.data.state.getState().getConfig("site_name") ||
198
+ "Saltcorn",
199
+ ],
200
+ }),
201
+ },
202
+ ],
203
+ });
204
+ if (page.content) await replaceIframe(page.content, page.isFile);
187
205
  } catch (error) {
188
206
  console.error(error);
189
207
  showToasts([
@@ -203,9 +221,11 @@ export async function logout() {
203
221
  await tryStopBackgroundSync();
204
222
  const response = await apiCall({ method: "GET", path: "/auth/logout" });
205
223
  if (response.data.success) {
206
- await removeJwt();
224
+ await clearSession();
207
225
  clearHistory();
208
- config.jwt = undefined;
226
+ config.csrfToken = undefined;
227
+ config.hasSession = false;
228
+ config.isPublicUser = false;
209
229
  const page = await router.resolve({
210
230
  pathname: "get/auth/login",
211
231
  entryView: config.entry_point,
@@ -232,43 +252,34 @@ export async function insertUser({ id, email, role_id, language }) {
232
252
  );
233
253
  }
234
254
 
235
- export async function removeJwt() {
236
- await saltcorn.data.db.deleteWhere("jwt_table");
255
+ export async function clearSession() {
256
+ await saltcorn.data.db.deleteWhere("session_table");
237
257
  }
238
258
 
239
- export async function setJwt(jwt) {
240
- await removeJwt();
241
- await saltcorn.data.db.insert("jwt_table", { jwt: jwt });
242
- }
243
-
244
- async function renewJwt() {
245
- try {
246
- const response = await apiCall({
247
- method: "POST",
248
- path: "/auth/renew-jwt",
249
- });
250
- if (typeof response.data === "string") {
251
- const config = saltcorn.data.state.getState().mobileConfig;
252
- await setJwt(response.data);
253
- config.jwt = response.data;
254
- }
255
- } catch (e) {
256
- console.error("JWT renewal failed:", e.message);
257
- }
259
+ /**
260
+ * persists the current mobileConfig session state (csrf token, user,
261
+ * public/authenticated flag) so the app can restore it at next boot
262
+ * without a network call (used e.g. when starting offline).
263
+ */
264
+ export async function persistSession(config) {
265
+ await clearSession();
266
+ await saltcorn.data.db.insert("session_table", {
267
+ csrfToken: config.csrfToken,
268
+ userJson: config.isPublicUser ? null : JSON.stringify(config.user),
269
+ isPublicUser: !!config.isPublicUser,
270
+ });
258
271
  }
259
272
 
260
- export async function checkJWT(jwtStr) {
261
- if (jwtStr && jwtStr !== "undefined") {
262
- const response = await apiCall({
263
- method: "GET",
264
- path: "/auth/authenticated",
265
- timeout: 10000,
266
- });
267
- if (!response.data.authenticated) return false;
268
- const decoded = jwtDecode(jwtStr);
269
- if (decoded.exp && decoded.exp - Date.now() / 1000 < 7 * 24 * 3600) {
270
- await renewJwt();
271
- }
272
- return true;
273
- } else return false;
273
+ /**
274
+ * checks with the server whether the current session cookie is still valid,
275
+ * and refreshes the cached CSRF token (its secret lives in the session, so
276
+ * it's fine to keep reusing the same token as long as the session is alive).
277
+ */
278
+ export async function checkSession() {
279
+ const response = await apiCall({
280
+ method: "GET",
281
+ path: "/auth/authenticated",
282
+ timeout: 10000,
283
+ });
284
+ return !!response?.data?.authenticated;
274
285
  }
@@ -1,7 +1,7 @@
1
1
  /*global saltcorn */
2
2
 
3
3
  const historyFile = "update_history";
4
- const jwtTableName = "jwt_table";
4
+ const sessionTableName = "session_table";
5
5
 
6
6
  import { readFile, fileExists, writeJSON } from "./file_system";
7
7
  import { Directory } from "@capacitor/filesystem";
@@ -206,8 +206,10 @@ export async function updateDb(tablesJSON) {
206
206
  });
207
207
  }
208
208
 
209
- export async function createJwtTable() {
210
- await saltcorn.data.db.query(`CREATE TABLE IF NOT EXISTS ${jwtTableName} (
211
- jwt VARCHAR(500)
209
+ export async function createSessionTable() {
210
+ await saltcorn.data.db.query(`CREATE TABLE IF NOT EXISTS ${sessionTableName} (
211
+ csrfToken VARCHAR(500),
212
+ userJson VARCHAR(2000),
213
+ isPublicUser BOOLEAN
212
214
  )`);
213
215
  }
@@ -1,4 +1,7 @@
1
1
  import { Filesystem, Encoding, Directory } from "@capacitor/filesystem";
2
+ // side-effect only: registers the plugin so it works on the web platform too,
3
+ // same as Filesystem above (native platforms pick it up either way)
4
+ import "@capacitor/file-transfer";
2
5
 
3
6
  export async function writeFile(name, directory, content) {
4
7
  try {
@@ -59,11 +59,11 @@ const insertRemoteData = async (table, rows, syncTimestamp) => {
59
59
  const ref = _sync_info_tbl_ref_,
60
60
  last_modified = _sync_info_tbl_last_modified_;
61
61
  await saltcorn.data.db.insert(tblName, rest, { replace: true });
62
- const infos = await saltcorn.data.db.select(
63
- `${saltcorn.data.db.sqlsanitize(tblName)}_sync_info`,
64
- {
65
- ref: String(rest[pkName]),
66
- }
62
+ // ref is inlined as a literal, not passed as a param, so numeric-looking
63
+ // refs like "19" don't get silently turned into "19.0" on iOS.
64
+ const { rows: infos } = await saltcorn.data.db.query(
65
+ `select 1 from "${saltcorn.data.db.sqlsanitize(tblName)}_sync_info"
66
+ where ref=${sqlRef(rest[pkName])}`
67
67
  );
68
68
  if (infos.length > 0) {
69
69
  await saltcorn.data.db.query(
@@ -72,14 +72,10 @@ const insertRemoteData = async (table, rows, syncTimestamp) => {
72
72
  where ref=${sqlRef(rest[pkName])}`
73
73
  );
74
74
  } else {
75
- await saltcorn.data.db.insert(
76
- `${saltcorn.data.db.sqlsanitize(tblName)}_sync_info`,
77
- {
78
- ref: String(ref),
79
- last_modified: syncTimestamp,
80
- deleted: false,
81
- modified_local: false,
82
- }
75
+ await saltcorn.data.db.query(
76
+ `insert into "${saltcorn.data.db.sqlsanitize(tblName)}_sync_info"
77
+ (ref, last_modified, deleted, modified_local)
78
+ values (${sqlRef(ref)}, ${syncTimestamp}, false, false)`
83
79
  );
84
80
  }
85
81
  }
package/src/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { init } from "./init";
1
+ import { init, postShare } from "./init";
2
2
  import * as api from "./helpers/api";
3
3
  import * as auth from "./helpers/auth";
4
4
  import * as common from "./helpers/common";
@@ -12,7 +12,7 @@ import { router } from "./routing/index";
12
12
  let notifications = undefined;
13
13
  try {
14
14
  notifications = require("./helpers/notifications");
15
- console.log("Notifications module available");
15
+ console.log("Notifications module available");
16
16
  } catch (err) {
17
17
  console.log("Notifications module not available");
18
18
  }
@@ -21,9 +21,8 @@ try {
21
21
  let backgroundSync = undefined;
22
22
  try {
23
23
  backgroundSync = require("./helpers/background_sync");
24
- console.log("Background sync module available");
25
- }
26
- catch (err) {
24
+ console.log("Background sync module available");
25
+ } catch (err) {
27
26
  console.log("Background sync module not available");
28
27
  }
29
28
 
@@ -38,6 +37,7 @@ context.keys().forEach((key) => {
38
37
 
39
38
  export const mobileApp = {
40
39
  init,
40
+ postShare,
41
41
  api,
42
42
  auth,
43
43
  common,
package/src/init.js CHANGED
@@ -11,9 +11,9 @@ import {
11
11
  createSyncInfoTables,
12
12
  dbUpdateNeeded,
13
13
  updateDb,
14
- createJwtTable,
14
+ createSessionTable,
15
15
  } from "./helpers/db_schema.js";
16
- import { publicLogin, checkJWT } from "./helpers/auth.js";
16
+ import { publicLogin, checkSession } from "./helpers/auth.js";
17
17
  import { router } from "./routing/index.js";
18
18
  import {
19
19
  replaceIframe,
@@ -30,7 +30,6 @@ import { readJSONCordova, readTextCordova } from "./helpers/file_system.js";
30
30
 
31
31
  import i18next from "i18next";
32
32
  import i18nextSprintfPostProcessor from "i18next-sprintf-postprocessor";
33
- import { jwtDecode } from "jwt-decode";
34
33
 
35
34
  import { Network } from "@capacitor/network";
36
35
  import { App } from "@capacitor/app";
@@ -152,19 +151,34 @@ const handlePluginHeaders = async (plugins) => {
152
151
  }
153
152
  };
154
153
 
155
- const getJwt = async () => {
156
- const rows = await saltcorn.data.db.select("jwt_table");
157
- return rows?.length > 0 ? rows[0].jwt : null;
154
+ const getSession = async () => {
155
+ const rows = await saltcorn.data.db.select("session_table");
156
+ return rows?.length > 0 ? rows[0] : null;
158
157
  };
159
158
 
160
- const initJwt = async () => {
161
- if (!(await saltcorn.data.db.tableExists("jwt_table"))) {
162
- await createJwtTable();
159
+ const initSession = async () => {
160
+ if (!(await saltcorn.data.db.tableExists("session_table"))) {
161
+ await createSessionTable();
163
162
  } else {
164
- const jwt = await getJwt();
165
- if (jwt) {
163
+ const row = await getSession();
164
+ if (row?.csrfToken) {
166
165
  const state = saltcorn.data.state.getState();
167
- state.mobileConfig.jwt = jwt;
166
+ state.mobileConfig.csrfToken = row.csrfToken;
167
+ state.mobileConfig.isPublicUser = !!row.isPublicUser;
168
+ state.mobileConfig.hasSession = true;
169
+ if (row.isPublicUser) {
170
+ state.mobileConfig.user = {
171
+ role_id: 100,
172
+ email: "public",
173
+ language: "en",
174
+ };
175
+ } else if (row.userJson) {
176
+ try {
177
+ state.mobileConfig.user = JSON.parse(row.userJson);
178
+ } catch {
179
+ // corrupted local row, fall back to a network check
180
+ }
181
+ }
168
182
  }
169
183
  }
170
184
  };
@@ -210,7 +224,7 @@ const showErrorPage = async (error) => {
210
224
  const onResume = async () => {
211
225
  if (typeof saltcorn === "undefined") return;
212
226
  const mobileConfig = saltcorn.data.state.getState().mobileConfig;
213
- if (mobileConfig?.jwt) {
227
+ if (mobileConfig?.hasSession) {
214
228
  const netStatus = await Network.getStatus();
215
229
  mobileConfig.networkState = netStatus.connectionType;
216
230
  if (
@@ -226,8 +240,8 @@ const onResume = async () => {
226
240
  await showErrorPage(error);
227
241
  }
228
242
  } else if (mobileConfig.networkState !== "none") {
229
- if (!mobileConfig.isOfflineMode && !isPublicJwt(mobileConfig.jwt)) {
230
- await checkJWT(mobileConfig.jwt);
243
+ if (!mobileConfig.isOfflineMode && !mobileConfig.isPublicUser) {
244
+ await checkSession();
231
245
  }
232
246
  if (mobileConfig.allowOfflineMode && mobileConfig.syncOnAppResume) {
233
247
  try {
@@ -240,21 +254,6 @@ const onResume = async () => {
240
254
  }
241
255
  };
242
256
 
243
- const isPublicJwt = (jwt) => {
244
- try {
245
- if (!jwt) return false;
246
- const decoded = jwtDecode(jwt);
247
- return decoded.sub === "public";
248
- } catch (error) {
249
- console.log(
250
- `Unable to inspect '${jwt}': ${
251
- error.message ? error.message : "Unknown error"
252
- }`
253
- );
254
- return false;
255
- }
256
- };
257
-
258
257
  const isPublicEntryPoint = async (entryPoint) => {
259
258
  if (!entryPoint) return false;
260
259
  try {
@@ -309,7 +308,7 @@ const notEmpty = (shareData) => {
309
308
  }
310
309
  };
311
310
 
312
- const postShare = async (shareData) => {
311
+ export const postShare = async (shareData) => {
313
312
  const page = await router.resolve({
314
313
  pathname: "post/notifications/share",
315
314
  shareData,
@@ -390,7 +389,7 @@ export async function init(mobileConfig) {
390
389
  try {
391
390
  const url = event.url;
392
391
  if (url.startsWith("mobileapp://auth/callback")) {
393
- const token = new URL(url).searchParams.get("token");
392
+ const token = new URL(url).searchParams.get("code");
394
393
  const method = new URL(url).searchParams.get("method");
395
394
  const methods = saltcorn.data.state.getState().auth_methods;
396
395
  if (!methods[method])
@@ -429,7 +428,7 @@ export async function init(mobileConfig) {
429
428
  await updateDb(tablesJSON);
430
429
  }
431
430
  await createSyncInfoTables(mobileConfig.synchedTables);
432
- await initJwt();
431
+ await initSession();
433
432
  await state.refresh_tables();
434
433
  await state.refresh_views();
435
434
  await state.refresh_pages();
@@ -455,14 +454,16 @@ export async function init(mobileConfig) {
455
454
  Network.addListener("networkStatusChange", networkChangeCallback);
456
455
 
457
456
  const networkDisabled = state.mobileConfig.networkState === "none";
458
- const jwt = state.mobileConfig.jwt;
457
+ const hasSession = !!state.mobileConfig.hasSession;
458
+ const isPublicUser = !!state.mobileConfig.isPublicUser;
459
459
  const alerts = [];
460
- if ((networkDisabled && jwt) || (await checkJWT(jwt))) {
461
- // already logged in, continue
460
+ if (
461
+ hasSession &&
462
+ !isPublicUser &&
463
+ (networkDisabled || (await checkSession()))
464
+ ) {
465
+ // already logged in, continue - mobileConfig.user was restored by initSession()
462
466
  const mobileConfig = state.mobileConfig;
463
- const decodedJwt = jwtDecode(mobileConfig.jwt);
464
- mobileConfig.user = decodedJwt.user;
465
- mobileConfig.isPublicUser = false;
466
467
  await i18next.changeLanguage(mobileConfig.user.language);
467
468
  if (mobileConfig.allowOfflineMode) {
468
469
  const { offlineUser } = (await getLastOfflineSession()) || {};
@@ -536,11 +537,9 @@ export async function init(mobileConfig) {
536
537
  });
537
538
  }
538
539
  if (page.content) await replaceIframe(page.content, page.isFile);
539
- } else if (isPublicJwt(jwt)) {
540
+ } else if (hasSession && isPublicUser) {
540
541
  // already logged in as public
541
542
  const config = state.mobileConfig;
542
- config.user = { role_id: 100, email: "public", language: "en" };
543
- config.isPublicUser = true;
544
543
  i18next.changeLanguage(config.user.language);
545
544
  const entryPoint = getEntryPoint(100, state, state.mobileConfig);
546
545
  if (!entryPoint) await showLogin(alerts);
@@ -29,7 +29,7 @@ export function MobileRequest({
29
29
  }),
30
30
  isAuthenticated: () => {
31
31
  const mobileCfg = saltcorn.data.state.getState().mobileConfig;
32
- return mobileCfg && mobileCfg.jwt && !mobileCfg.isPublicUser;
32
+ return mobileCfg && mobileCfg.hasSession && !mobileCfg.isPublicUser;
33
33
  },
34
34
  getLocale: () => {
35
35
  const mobileCfg = saltcorn.data.state.getState().mobileConfig;
@@ -3,7 +3,7 @@
3
3
  import { wrapContents } from "../utils";
4
4
  import { MobileRequest } from "../mocks/request";
5
5
 
6
- import { checkJWT } from "../../helpers/auth.js";
6
+ import { checkSession } from "../../helpers/auth.js";
7
7
  import { apiCall } from "../../helpers/api";
8
8
 
9
9
  const buildErrMsg = (msg) => {
@@ -24,21 +24,45 @@ const buildSuccessMsg = (msg) => {
24
24
  );
25
25
  };
26
26
 
27
+ // files have a real type (e.g. image/png); text/link shares are "text/plain"
28
+ const isFileItem = (item) =>
29
+ item?.type && item.type !== "text/plain" && item.url;
30
+
27
31
  export const postShare = async (context) => {
28
32
  let content = "";
29
33
  const mobileCfg = saltcorn.data.state.getState().mobileConfig;
30
34
  if (mobileCfg.networkState === "none")
31
35
  content = buildErrMsg("No network connection");
32
- else if (!(await checkJWT(mobileCfg.jwt)))
33
- content = buildErrMsg("Not authenticated");
36
+ else if (!(await checkSession())) content = buildErrMsg("Not authenticated");
34
37
  else {
35
- const response = await apiCall({
36
- method: "POST",
37
- path: "/notifications/share-handler",
38
- body: context.shareData,
39
- });
40
- if (response.error) content = buildErrMsg(response.error);
41
- else content = buildSuccessMsg("Shared successfully");
38
+ try {
39
+ const { title, description, type, url, additionalItems } =
40
+ context.shareData || {};
41
+ let body = context.shareData;
42
+ if (isFileItem({ type, url })) {
43
+ const items = [{ title, type, url }, ...(additionalItems || [])];
44
+ // title is the real on-disk filename, location is where to find it
45
+ const files = [];
46
+ for (const item of items) {
47
+ const uploaded = await saltcorn.data.models.File.uploadFromPath(
48
+ item.url,
49
+ item.title || "shared-file",
50
+ item.type
51
+ );
52
+ files.push({ title: uploaded.filename, location: uploaded.location });
53
+ }
54
+ body = { description, files };
55
+ }
56
+ const response = await apiCall({
57
+ method: "POST",
58
+ path: "/notifications/share-handler",
59
+ body,
60
+ });
61
+ if (response.error) content = buildErrMsg(response.error);
62
+ else content = buildSuccessMsg("Shared successfully");
63
+ } catch (error) {
64
+ content = buildErrMsg(error.message || "Error sharing");
65
+ }
42
66
  }
43
67
  return await wrapContents(
44
68
  saltcorn.markup.tags.div(
@@ -39,7 +39,7 @@ export const postView = async (context) => {
39
39
  const mobileCfg = state.mobileConfig;
40
40
  if (
41
41
  mobileCfg.user.role_id > view.min_role &&
42
- !(await view.authorise_post({ body, req, ...view }))
42
+ !(await view.authorize(mobileCfg.user, { action: "post", req, body }))
43
43
  ) {
44
44
  throw new saltcorn.data.utils.NotAuthorized(req.__("Not authorized"));
45
45
  }
@@ -150,7 +150,11 @@ export const getView = async (context) => {
150
150
  if (!view) throw new Error(req.__("No such view: %s", viewname));
151
151
  if (
152
152
  state.mobileConfig.user.role_id > view.min_role &&
153
- !(await view.authorise_get({ query, req, ...view }))
153
+ !(await view.authorize(state.mobileConfig.user, {
154
+ action: "get",
155
+ req,
156
+ state: query,
157
+ }))
154
158
  ) {
155
159
  const additionalInfos = `: your role: ${state.mobileConfig.user.role_id}, view min_role: ${view.min_role}`;
156
160
  throw new saltcorn.data.utils.NotAuthorized(
@@ -161,7 +165,9 @@ export const getView = async (context) => {
161
165
  if (!view.renderLocally()) {
162
166
  const response = await apiCall({
163
167
  method: "GET",
164
- path: `/view/${encodeURIComponent(viewname)}${context.query ? `?${context.query}` : ""}`,
168
+ path: `/view/${encodeURIComponent(viewname)}${
169
+ context.query ? `?${context.query}` : ""
170
+ }`,
165
171
  });
166
172
  const data = response.data;
167
173
  contents0 = data;
@@ -24,7 +24,7 @@ export const getHeaders = () => {
24
24
  from_cfg.push({ style: state.getConfig("page_custom_css", "") });
25
25
  if (state.getConfig("page_custom_html", ""))
26
26
  from_cfg.push({ headerTag: state.getConfig("page_custom_html", "") });
27
- return [...stdHeaders, ...config.pluginHeaders, ...from_cfg];
27
+ return [...stdHeaders, ...(config.pluginHeaders || []), ...from_cfg];
28
28
  };
29
29
 
30
30
  export const parseQuery = (queryStr) => {
@@ -1,6 +1,21 @@
1
1
  /*eslint-env browser*/
2
2
  /*global $, KTDrawer, reload_embedded_view, submitWithEmptyAction, is_paging_param, bootstrap, common_done, unique_field_from_rows, inline_submit_success, get_current_state_url, initialize_page, reset_spinners */
3
3
 
4
+ // Tapping a javascript: link silently does nothing on iOS, so we run the
5
+ // code ourselves instead of relying on the link to do it.
6
+ document.addEventListener("click", function (event) {
7
+ const link =
8
+ event.target.closest && event.target.closest("a[href^='javascript:']");
9
+ if (!link) return;
10
+ event.preventDefault();
11
+ const code = link.getAttribute("href").slice("javascript:".length);
12
+ try {
13
+ (0, eval)(code);
14
+ } catch (error) {
15
+ console.error("Error executing javascript: link:", error);
16
+ }
17
+ });
18
+
4
19
  function combineFormAndQuery(form, query) {
5
20
  let paramsList = [];
6
21
  const formData = new FormData(form[0]);
@@ -87,7 +102,10 @@ async function formSubmit(e, urlSuffix, viewname, noSubmitCb, matchingState) {
87
102
  const urlParams = new URLSearchParams();
88
103
  const data = matchingState ? {} : null;
89
104
  for (const entry of new FormData(e).entries()) {
90
- if (entry[1] instanceof File) files[entry[0]] = entry[1];
105
+ // cordova-plugin-file replaces window.File with its own class, so a
106
+ // real picked file no longer passes "instanceof File" - Blob still
107
+ // works, since every File is also a Blob.
108
+ if (entry[1] instanceof Blob) files[entry[0]] = entry[1];
91
109
  else {
92
110
  // is there a hidden input with a filename?
93
111
  const domEl = $(e).find(
@@ -431,22 +449,50 @@ async function pjax_to(href, query, e) {
431
449
  await parent.saltcorn.mobileApp.navigation.handleRoute(safeHref, query);
432
450
  else
433
451
  try {
434
- const headers = {
435
- pjaxpageload: "true",
436
- };
437
- if (localizer.length) headers.localizedstate = "true";
438
- const result = await parent.saltcorn.mobileApp.api.apiCall({
439
- path: path,
440
- method: "GET",
441
- additionalHeaders: headers,
442
- });
452
+ const { isOfflineMode } =
453
+ parent.saltcorn.data.state.getState().mobileConfig;
454
+ let content;
455
+ if (isOfflineMode) {
456
+ const page = await parent.saltcorn.mobileApp.navigation.router.resolve(
457
+ {
458
+ pathname: `get${safeHref}`,
459
+ query: query,
460
+ }
461
+ );
462
+ if (page.redirect) {
463
+ const { path: redirectPath, query: redirectQuery } =
464
+ parent.saltcorn.mobileApp.navigation.splitPathQuery(
465
+ page.redirect
466
+ );
467
+ await parent.saltcorn.mobileApp.navigation.handleRoute(
468
+ redirectPath.startsWith("/") && redirectPath.length > 1
469
+ ? `get${redirectPath}`
470
+ : redirectPath,
471
+ redirectQuery
472
+ );
473
+ return;
474
+ }
475
+ content = typeof page === "string" ? page : page.content;
476
+ } else {
477
+ const headers = {
478
+ pjaxpageload: "true",
479
+ };
480
+ if (localizer.length) headers.localizedstate = "true";
481
+ const result = await parent.saltcorn.mobileApp.api.apiCall({
482
+ path: path,
483
+ method: "GET",
484
+ additionalHeaders: headers,
485
+ responseType: "text",
486
+ });
487
+ content = result.data;
488
+ }
443
489
  if (!inModal && !localizer.length) {
444
490
  // not sure for mobile
445
491
  // window.history.pushState({ url: href }, "", href);
446
492
  }
447
493
  if (inModal && !localizer.length)
448
494
  $(".sc-modal-linkout").attr("href", path);
449
- $dest.html(result.data);
495
+ $dest.html(content);
450
496
  if (localizer.length) localizer.attr("data-sc-local-state", path);
451
497
  initialize_page();
452
498
  } catch (error) {
@@ -675,17 +721,16 @@ async function make_unique_field(
675
721
  }
676
722
  }
677
723
 
678
- function openFile(fileId) {
724
+ async function openFile(fileId) {
679
725
  const config = parent.saltcorn.data.state.getState().mobileConfig;
680
726
  const serverPath = config.server_path;
681
- const token = config.jwt;
682
- const url = `${serverPath}/files/serve/${encodeURIComponent(
683
- fileId
684
- )}?jwt=${token}`;
727
+ const url = `${serverPath}/files/serve/${encodeURIComponent(fileId)}`;
685
728
  parent.cordova.InAppBrowser.open(
686
729
  url,
687
730
  "_blank",
688
- "clearcache=yes,clearsessioncache=yes,location=no,toolbar=yes,toolbarposition=top"
731
+ // No clearcache - it clears cookies app-wide (Android's cookie store
732
+ // isn't scoped per tab), which would log the main app out too.
733
+ "location=no,toolbar=yes,toolbarposition=top"
689
734
  );
690
735
  }
691
736
 
@@ -695,7 +740,9 @@ function openInAppBrowser(url, domId) {
695
740
  const ref = parent.cordova.InAppBrowser.open(
696
741
  url,
697
742
  "_blank",
698
- "clearcache=yes,clearsessioncache=yes,location=no,toolbar=yes,toolbarposition=top"
743
+ // No clearcache - it clears cookies app-wide (Android's cookie store
744
+ // isn't scoped per tab), which would log the main app out too.
745
+ "location=no,toolbar=yes,toolbarposition=top"
699
746
  );
700
747
  ref.addEventListener("exit", function () {
701
748
  $(`#${domId}`).find(".spinner-border").addClass("d-none");