Skip to content

Commit 1d95443

Browse files
author
Ajit Kumar
committed
fix(ios): keep FAQs in-app and preserve website context
- Add searchable FAQs with sanitized answers, offline caching, timeout handling, and retry feedback. - Preserve iOS context on trusted Acode website links without rewriting encoded query parameters. - Guard asynchronous Help navigation and make search cleanup reliable and accessible. - Add focused regression coverage and update build metadata to 1021. Validation: 18 FAQ, Help, and search-bar tests; Biome checks; production iOS web bundle build (two asset-size warnings).
1 parent 131c24a commit 1d95443

15 files changed

Lines changed: 861 additions & 9 deletions

File tree

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "acode-app",
33
"displayName": "Acode",
44
"version": "1.13.6",
5-
"versionCode": 1018,
5+
"versionCode": 1021,
66
"androidPackageId": "com.foxdebug.acode",
77
"appleAppId": "app.acode",
88
"description": "Powerful code editor and terminal for IOS and Android",

‎platforms/ios/runner.xcodeproj/project.pbxproj‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -375,7 +375,7 @@
375375
CLANG_WARN_UNREACHABLE_CODE = YES;
376376
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
377377
COPY_PHASE_STRIP = NO;
378-
CURRENT_PROJECT_VERSION = 1018;
378+
CURRENT_PROJECT_VERSION = 1021;
379379
DEBUG_INFORMATION_FORMAT = dwarf;
380380
ENABLE_STRICT_OBJC_MSGSEND = YES;
381381
ENABLE_TESTABILITY = YES;
@@ -443,7 +443,7 @@
443443
CLANG_WARN_UNREACHABLE_CODE = YES;
444444
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
445445
COPY_PHASE_STRIP = NO;
446-
CURRENT_PROJECT_VERSION = 1018;
446+
CURRENT_PROJECT_VERSION = 1021;
447447
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
448448
ENABLE_NS_ASSERTIONS = NO;
449449
ENABLE_STRICT_OBJC_MSGSEND = YES;

‎platforms/ios/runner/lib/SafariService.swift‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,10 @@ final class SafariService: BaseService, SFSafariViewControllerDelegate {
3131
}
3232
return
3333
}
34-
guard let value = args[safe: 0] as? String, let url = URL(string: value), let scheme = url.scheme?.lowercased() else {
34+
guard let value = args[safe: 0] as? String, let inputURL = URL(string: value), let scheme = inputURL.scheme?.lowercased() else {
3535
callback.error("Invalid browser URL"); return
3636
}
37+
let url = Self.websiteURL(inputURL)
3738
if action == "external" {
3839
guard !["javascript", "data", "file", "acode", "about"].contains(scheme) else { callback.error("Unsupported external URL"); return }
3940
DispatchQueue.main.async {
@@ -88,4 +89,17 @@ final class SafariService: BaseService, SFSafariViewControllerDelegate {
8889
lifecycleCallback?.success(["type": "closed"])
8990
lifecycleCallback = nil
9091
}
92+
93+
static func websiteURL(_ url: URL) -> URL {
94+
guard url.scheme?.lowercased() == "https",
95+
["acode.app", "dev.acode.app"].contains(url.host?.lowercased() ?? ""),
96+
url.port == nil || url.port == 443,
97+
url.user == nil, url.password == nil,
98+
var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return url }
99+
var query = components.percentEncodedQuery?.components(separatedBy: "&") ?? []
100+
query.removeAll { $0.components(separatedBy: "=").first?.removingPercentEncoding == "src" }
101+
query.append("src=ios")
102+
components.percentEncodedQuery = query.joined(separator: "&")
103+
return components.url ?? url
104+
}
91105
}

‎platforms/ios/runnerTests/BrowserBridgeTests.swift‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,34 @@ import XCTest
55

66
@MainActor
77
final class BrowserBridgeTests: BridgeTestCase {
8+
func testAcodeWebsiteLinksCarryIOSSourceWithoutLosingTheirDestination() throws {
9+
for host in ["acode.app", "dev.acode.app"] {
10+
for path in ["/", "/faqs", "/profile/edit"] {
11+
let original = try XCTUnwrap(URL(string: "https://\(host)\(path)?state=a%26b&src=android&src=ios#details"))
12+
let rewritten = SafariService.websiteURL(original)
13+
let components = try XCTUnwrap(URLComponents(url: rewritten, resolvingAgainstBaseURL: false))
14+
XCTAssertEqual(components.host, host)
15+
XCTAssertEqual(components.path, path)
16+
XCTAssertEqual(components.fragment, "details")
17+
XCTAssertEqual(components.queryItems?.filter { $0.name == "src" }, [URLQueryItem(name: "src", value: "ios")])
18+
XCTAssertEqual(components.queryItems?.first { $0.name == "state" }?.value, "a&b")
19+
XCTAssertEqual(SafariService.websiteURL(rewritten), rewritten)
20+
}
21+
}
22+
XCTAssertEqual(SafariService.websiteURL(URL(string: "https://acode.app")!).query, "src=ios")
23+
let encoded = "https://acode.app/login?code=a%2Bb&redirect=https%3A%2F%2Facode.app%2Fx%3Fkey%3Da%2Bb&flag&src=android#done"
24+
XCTAssertEqual(SafariService.websiteURL(URL(string: encoded)!).absoluteString,
25+
encoded.replacingOccurrences(of: "src=android", with: "src=ios"))
26+
}
27+
28+
func testOtherBrowserDestinationsRemainUnchanged() throws {
29+
for value in ["https://example.com/?src=android", "https://acode.app.example.com/", "https://docs.acode.app/",
30+
"https://acode.app:8443/", "https://user@acode.app/", "http://acode.app/", "mailto:acode@foxdebug.com"] {
31+
let url = try XCTUnwrap(URL(string: value))
32+
XCTAssertEqual(SafariService.websiteURL(url), url)
33+
}
34+
}
35+
836
func testResetSettlesOpenCallbacksDuringPresentation() async throws {
937
let webView = try await appWebView()
1038
var responder: UIResponder? = webView

‎src/components/searchbar/index.js‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ function searchBar(
2020
) {
2121
let hideOnBlur = true;
2222
let timeout = null;
23+
let closed = false;
2324
const $searchInput = Ref();
2425
/**@type {HTMLDivElement} */
2526
const $container = (
@@ -30,7 +31,12 @@ function searchBar(
3031
placeholder={strings.search}
3132
enterKeyHint="go"
3233
/>
33-
<span className="icon clearclose" onclick={hide}></span>
34+
<button
35+
type="button"
36+
className="icon clearclose"
37+
aria-label={strings.close}
38+
onclick={hide}
39+
/>
3440
</div>
3541
);
3642

@@ -56,8 +62,15 @@ function searchBar(
5662
});
5763

5864
function hide() {
65+
if (closed) return;
66+
closed = true;
67+
clearTimeout(timeout);
5968
actionStack.remove("searchbar");
60-
if (typeof onhideCb === "function") onhideCb();
69+
try {
70+
if (typeof onhideCb === "function") onhideCb();
71+
} catch (error) {
72+
console.error("Search close callback failed:", error);
73+
}
6174

6275
$list.content = children;
6376
$container.classList.add("hide");
@@ -75,6 +88,7 @@ function searchBar(
7588
* @this {HTMLInputElement}
7689
*/
7790
async function searchNow() {
91+
if (closed) return;
7892
const val = $searchInput.value.toLowerCase();
7993

8094
if (!val) {
@@ -100,6 +114,7 @@ function searchBar(
100114
result = filterList(val);
101115
}
102116

117+
if (closed) return;
103118
$list.textContent = "";
104119
$list.append(...(cloneResults ? buildSearchContent(result, val) : result));
105120
}

‎src/components/searchbar/style.scss‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,15 @@
3333
}
3434

3535
.icon {
36+
flex-shrink: 0;
37+
margin: 0;
38+
padding: 0;
39+
border: 0;
40+
background: transparent;
3641
height: 45px;
3742
width: 45px;
3843
font-size: 1.2em;
39-
color: rgb(255, 255, 255);
44+
color: var(--primary-text-color);
4045
}
4146

4247
&.hide {

‎src/lang/en-us.json‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,9 @@
250250
"download acode pro": "Download Acode pro",
251251
"loading plugins": "Loading plugins",
252252
"faqs": "FAQs",
253+
"faqs load failed": "Unable to load FAQs. Please try again.",
254+
"faqs empty": "No FAQs are available yet.",
255+
"retry": "Retry",
253256
"feedback": "Feedback",
254257
"header": "Header",
255258
"sidebar": "Sidebar",

‎src/lang/index.d.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,9 @@ declare type LangStrings = {
253253
"download acode pro": string;
254254
"loading plugins": string;
255255
faqs: string;
256+
"faqs load failed": string;
257+
"faqs empty": string;
258+
retry: string;
256259
feedback: string;
257260
header: string;
258261
sidebar: string;

‎src/pages/faqs/faqs.js‎

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import "./faqs.scss";
2+
import Page from "components/page";
3+
import searchBar from "components/searchbar";
4+
import DOMPurify from "dompurify";
5+
import defaultStrings from "lang/en-us.json";
6+
import actionStack from "lib/actionStack";
7+
import config from "lib/config";
8+
import markdownIt from "markdown-it";
9+
10+
export default function FAQs() {
11+
const cacheKey = `faqs:${config.API_BASE}`;
12+
const markdown = markdownIt({ linkify: true });
13+
const search = (
14+
<button
15+
type="button"
16+
className="icon search"
17+
attr-action="search"
18+
aria-label={strings.search}
19+
onclick={showSearch}
20+
disabled
21+
/>
22+
);
23+
const page = Page(strings.faqs, { tail: search });
24+
page.classList.add("faqs-page");
25+
const status = <p role="status" />;
26+
const retry = (
27+
<button type="button" className="button faq-retry" onclick={load} hidden>
28+
{strings.retry || defaultStrings.retry}
29+
</button>
30+
);
31+
const state = (
32+
<div className="faq-state">
33+
<span className="icon help" aria-hidden="true" />
34+
{status}
35+
{retry}
36+
</div>
37+
);
38+
const list = <div className="faq-list" />;
39+
let faqs = [];
40+
let controller;
41+
let closed = false;
42+
let hideSearchBar;
43+
44+
page.body = (
45+
<main id="faqs-page" className="main scroll">
46+
{state}
47+
{list}
48+
</main>
49+
);
50+
page.onhide = () => {
51+
closed = true;
52+
hideSearchBar?.();
53+
controller?.abort();
54+
actionStack.remove("faqs");
55+
};
56+
page.ondisconnect = () => hideSearchBar?.();
57+
actionStack.push({ id: "faqs", action: page.hide });
58+
app.append(page);
59+
void load();
60+
return page;
61+
62+
async function load() {
63+
const cached = readCache();
64+
const request = new AbortController();
65+
controller = request;
66+
const timeout = setTimeout(() => request.abort(), 15000);
67+
retry.hidden = true;
68+
if (cached) render(cached);
69+
else status.textContent = strings["loading..."];
70+
try {
71+
const response = await fetch(`${config.API_BASE}/faqs`, {
72+
signal: request.signal,
73+
});
74+
if (!response.ok)
75+
throw new Error(`FAQ request failed: ${response.status}`);
76+
const data = await response.json();
77+
if (!isValid(data)) throw new Error("Invalid FAQ response");
78+
if (closed) return;
79+
try {
80+
localStorage.setItem(cacheKey, JSON.stringify(data));
81+
} catch (error) {
82+
console.warn("Unable to cache FAQs", error);
83+
}
84+
if (!cached?.length) render(data);
85+
} catch (error) {
86+
if (closed || cached?.length) return;
87+
console.error("Unable to load FAQs", error);
88+
status.textContent =
89+
strings["faqs load failed"] || defaultStrings["faqs load failed"];
90+
retry.hidden = false;
91+
} finally {
92+
clearTimeout(timeout);
93+
}
94+
}
95+
96+
function render(data) {
97+
faqs = data.map((faq) => ({ ...faq, element: renderFAQ(faq) }));
98+
search.disabled = !faqs.length;
99+
state.hidden = !!faqs.length;
100+
status.textContent = faqs.length
101+
? ""
102+
: strings["faqs empty"] || defaultStrings["faqs empty"];
103+
list.replaceChildren(...(faqs.length ? results(faqs) : []));
104+
}
105+
106+
function readCache() {
107+
try {
108+
const data = JSON.parse(localStorage.getItem(cacheKey));
109+
if (isValid(data)) return data;
110+
} catch (error) {
111+
console.warn("Unable to read cached FAQs", error);
112+
}
113+
}
114+
115+
function isValid(data) {
116+
return (
117+
Array.isArray(data) &&
118+
data.every(
119+
(faq) => typeof faq?.q === "string" && typeof faq?.a === "string",
120+
)
121+
);
122+
}
123+
124+
function showSearch() {
125+
if (hideSearchBar || search.disabled) return;
126+
searchBar(
127+
list,
128+
(hide) => {
129+
hideSearchBar = hide;
130+
},
131+
() => {
132+
hideSearchBar = null;
133+
},
134+
(value) => {
135+
const query = value.trim().toLowerCase();
136+
page.body.scrollTop = 0;
137+
return results(
138+
faqs.filter((faq) =>
139+
`${faq.q}\n${faq.a}`.toLowerCase().includes(query),
140+
),
141+
true,
142+
);
143+
},
144+
false,
145+
);
146+
}
147+
148+
function results(matches, searching = false) {
149+
if (!matches.length)
150+
return [
151+
<div className="faq-state">
152+
<span className="icon search" aria-hidden="true" />
153+
<p role="status">{strings["no result"]}</p>
154+
</div>,
155+
];
156+
const key =
157+
matches.length === 1
158+
? "search result label singular"
159+
: "search result label plural";
160+
const label = searching
161+
? strings[key] || defaultStrings[key]
162+
: strings.faqs;
163+
return [
164+
<div className="faq-count" role="status">
165+
{`${matches.length} ${label}`}
166+
</div>,
167+
...matches.map((faq) => faq.element),
168+
];
169+
}
170+
171+
function renderFAQ(faq) {
172+
const answer = <div className="md faq-answer" />;
173+
const details = (
174+
<details>
175+
<summary>
176+
<span>{faq.q}</span>
177+
<span className="icon keyboard_arrow_down" aria-hidden="true" />
178+
</summary>
179+
{answer}
180+
</details>
181+
);
182+
details.ontoggle = () => {
183+
if (!details.open || answer.childNodes.length) return;
184+
answer.innerHTML = DOMPurify.sanitize(markdown.render(faq.a));
185+
for (const link of answer.querySelectorAll("a[href]")) {
186+
try {
187+
link.href = new URL(link.getAttribute("href"), config.BASE_URL).href;
188+
} catch {
189+
link.removeAttribute("href");
190+
}
191+
}
192+
};
193+
return details;
194+
}
195+
}

0 commit comments

Comments
 (0)