also looking at this
fix(ios): answer a malformed argument instead of leaving the page waiting
#246
2 files
+136
-0
| @@ -863,6 +863,8 @@ struct CraftWebView: UIViewRepresentable { | ||
| 863 | 863 | case "setBadge": |
| 864 | 864 | if let count = body["count"] as? Int { |
| 865 | 865 | setBadgeCount(count, callbackId: callbackId) |
| 866 | } else { | |
| 867 | rejectCallback(callbackId, error: "setBadge needs a whole number of badges", code: "INVALID_ARGUMENT") | |
| 866 | 868 | } |
| 867 | 869 | case "clearBadge": |
| 868 | 870 | setBadgeCount(0, callbackId: callbackId) |
| @@ -877,6 +879,8 @@ struct CraftWebView: UIViewRepresentable { | ||
| 877 | 879 | case "setFlashlight": |
| 878 | 880 | if let enabled = body["enabled"] as? Bool { |
| 879 | 881 | setFlashlight(enabled: enabled, callbackId: callbackId) |
| 882 | } else { | |
| 883 | rejectCallback(callbackId, error: "setFlashlight needs true or false", code: "INVALID_ARGUMENT") | |
| 880 | 884 | } |
| 881 | 885 | // Vibrate pattern |
| 882 | 886 | case "vibrate": |
| @@ -891,6 +895,8 @@ struct CraftWebView: UIViewRepresentable { | ||
| 891 | 895 | if let urlString = body["url"] as? String, let url = URL(string: urlString) { |
| 892 | 896 | UIApplication.shared.open(url) |
| 893 | 897 | resolveCallback(callbackId, result: true) |
| 898 | } else { | |
| 899 | rejectCallback(callbackId, error: "openURL needs a URL it can parse", code: "INVALID_ARGUMENT") | |
| 894 | 900 | } |
| 895 | 901 | // App state |
| 896 | 902 | case "getAppState": |
| @@ -1298,6 +1304,8 @@ struct CraftWebView: UIViewRepresentable { | ||
| 1298 | 1304 | case "setShortcuts": |
| 1299 | 1305 | if let shortcuts = body["shortcuts"] as? [[String: Any]] { |
| 1300 | 1306 | setAppShortcuts(shortcuts: shortcuts, callbackId: callbackId) |
| 1307 | } else { | |
| 1308 | rejectCallback(callbackId, error: "setShortcuts needs a list of shortcuts", code: "INVALID_ARGUMENT") | |
| 1301 | 1309 | } |
| 1302 | 1310 | case "clearShortcuts": |
| 1303 | 1311 | clearAppShortcuts(callbackId: callbackId) |
| @@ -1308,16 +1316,22 @@ struct CraftWebView: UIViewRepresentable { | ||
| 1308 | 1316 | let value = body["value"] as? String { |
| 1309 | 1317 | let group = body["group"] as? String |
| 1310 | 1318 | setSharedKeychainItem(key: key, value: value, group: group, callbackId: callbackId) |
| 1319 | } else { | |
| 1320 | rejectCallback(callbackId, error: "setSharedItem needs both a key and a value", code: "INVALID_ARGUMENT") | |
| 1311 | 1321 | } |
| 1312 | 1322 | case "getSharedItem": |
| 1313 | 1323 | if let key = body["key"] as? String { |
| 1314 | 1324 | let group = body["group"] as? String |
| 1315 | 1325 | getSharedKeychainItem(key: key, group: group, callbackId: callbackId) |
| 1326 | } else { | |
| 1327 | rejectCallback(callbackId, error: "getSharedItem needs a key", code: "INVALID_ARGUMENT") | |
| 1316 | 1328 | } |
| 1317 | 1329 | case "removeSharedItem": |
| 1318 | 1330 | if let key = body["key"] as? String { |
| 1319 | 1331 | let group = body["group"] as? String |
| 1320 | 1332 | removeSharedKeychainItem(key: key, group: group, callbackId: callbackId) |
| 1333 | } else { | |
| 1334 | rejectCallback(callbackId, error: "removeSharedItem needs a key", code: "INVALID_ARGUMENT") | |
| 1321 | 1335 | } |
| 1322 | 1336 | |
| 1323 | 1337 | // MARK: - Local Auth Persistence |
| @@ -1325,6 +1339,8 @@ struct CraftWebView: UIViewRepresentable { | ||
| 1325 | 1339 | if let enabled = body["enabled"] as? Bool { |
| 1326 | 1340 | let duration = body["duration"] as? Double ?? 300 // 5 min default |
| 1327 | 1341 | setBiometricPersistence(enabled: enabled, duration: duration, callbackId: callbackId) |
| 1342 | } else { | |
| 1343 | rejectCallback(callbackId, error: "setBiometricPersistence needs true or false", code: "INVALID_ARGUMENT") | |
| 1328 | 1344 | } |
| 1329 | 1345 | case "checkBiometricPersistence": |
| 1330 | 1346 | checkBiometricPersistence(callbackId: callbackId) |
| @@ -1408,6 +1424,8 @@ struct CraftWebView: UIViewRepresentable { | ||
| 1408 | 1424 | case "updateWidget": |
| 1409 | 1425 | if let data = body["data"] as? [String: Any] { |
| 1410 | 1426 | updateWidget(data: data, callbackId: callbackId) |
| 1427 | } else { | |
| 1428 | rejectCallback(callbackId, error: "updateWidget needs a data object", code: "INVALID_ARGUMENT") | |
| 1411 | 1429 | } |
| 1412 | 1430 | case "reloadWidgets": |
| 1413 | 1431 | reloadAllWidgets(callbackId: callbackId) |
| @@ -1417,20 +1435,28 @@ struct CraftWebView: UIViewRepresentable { | ||
| 1417 | 1435 | if let phrase = body["phrase"] as? String, |
| 1418 | 1436 | let action = body["shortcutAction"] as? String { |
| 1419 | 1437 | registerSiriShortcut(phrase: phrase, action: action, callbackId: callbackId) |
| 1438 | } else { | |
| 1439 | rejectCallback(callbackId, error: "registerSiriShortcut needs both a phrase and a shortcutAction", code: "INVALID_ARGUMENT") | |
| 1420 | 1440 | } |
| 1421 | 1441 | case "removeSiriShortcut": |
| 1422 | 1442 | if let action = body["shortcutAction"] as? String { |
| 1423 | 1443 | removeSiriShortcut(action: action, callbackId: callbackId) |
| 1444 | } else { | |
| 1445 | rejectCallback(callbackId, error: "removeSiriShortcut needs a shortcutAction", code: "INVALID_ARGUMENT") | |
| 1424 | 1446 | } |
| 1425 | 1447 | |
| 1426 | 1448 | // MARK: - Watch Connectivity |
| 1427 | 1449 | case "sendToWatch": |
| 1428 | 1450 | if let message = body["message"] as? [String: Any] { |
| 1429 | 1451 | sendMessageToWatch(message: message, callbackId: callbackId) |
| 1452 | } else { | |
| 1453 | rejectCallback(callbackId, error: "sendToWatch needs a message object", code: "INVALID_ARGUMENT") | |
| 1430 | 1454 | } |
| 1431 | 1455 | case "updateWatchContext": |
| 1432 | 1456 | if let context = body["context"] as? [String: Any] { |
| 1433 | 1457 | updateWatchContext(context: context, callbackId: callbackId) |
| 1458 | } else { | |
| 1459 | rejectCallback(callbackId, error: "updateWatchContext needs a context object", code: "INVALID_ARGUMENT") | |
| 1434 | 1460 | } |
| 1435 | 1461 | case "isWatchReachable": |
| 1436 | 1462 | isWatchReachable(callbackId: callbackId) |
| @@ -1800,6 +1800,74 @@ fn caseUsesCallback(region: []const u8, action: []const u8, use: CaseUse) bool { | ||
| 1800 | 1800 | return false; |
| 1801 | 1801 | } |
| 1802 | 1802 | |
| 1803 | /// The lines of the dispatcher's `case` for `action`, label excluded. | |
| 1804 | /// | |
| 1805 | /// Same boundary rule as `caseUsesCallback`: from the label to the next line | |
| 1806 | /// at or above its indentation that starts another case, a `default:` or the | |
| 1807 | /// closing brace. | |
| 1808 | fn caseBody(region: []const u8, action: []const u8) ?[]const u8 { | |
| 1809 | var case_indent: ?usize = null; | |
| 1810 | var body_start: usize = 0; | |
| 1811 | var offset: usize = 0; | |
| 1812 | var lines = std.mem.splitScalar(u8, region, '\n'); | |
| 1813 | while (lines.next()) |line| { | |
| 1814 | const next_offset = offset + line.len + 1; | |
| 1815 | defer offset = next_offset; | |
| 1816 | const body = std.mem.trimStart(u8, line, " \t"); | |
| 1817 | if (body.len == 0) continue; | |
| 1818 | const indent = line.len - body.len; | |
| 1819 | ||
| 1820 | if (case_indent) |at| { | |
| 1821 | const closes = indent <= at and (std.mem.startsWith(u8, body, "case ") or | |
| 1822 | std.mem.startsWith(u8, body, "default") or body[0] == '}'); | |
| 1823 | if (closes) return region[body_start..offset]; | |
| 1824 | continue; | |
| 1825 | } | |
| 1826 | if (std.mem.startsWith(u8, body, "case ") and labels(body, action)) { | |
| 1827 | case_indent = indent; | |
| 1828 | body_start = next_offset; | |
| 1829 | } | |
| 1830 | } | |
| 1831 | if (case_indent != null) return region[body_start..]; | |
| 1832 | return null; | |
| 1833 | } | |
| 1834 | ||
| 1835 | /// Whether every `if let` written directly in this case has an `else`. | |
| 1836 | /// | |
| 1837 | /// A case that casts its argument and answers only inside the `if` leaves the | |
| 1838 | /// page waiting for ever on a malformed one: `craft.setBadge('3')` posted a | |
| 1839 | /// callbackId and nothing settled it, on any path (#221). The `else` does not | |
| 1840 | /// have to reject — `caseUsesCallback` is what checks the case answers at all | |
| 1841 | /// — but a branch that simply is not there cannot. | |
| 1842 | /// | |
| 1843 | /// Only the `if let`s at the case's own indentation are read. One nested | |
| 1844 | /// inside a closure or a helper belongs to that closure's contract, not to | |
| 1845 | /// this dispatch. | |
| 1846 | fn caseAnswersEveryPath(region: []const u8, action: []const u8) bool { | |
| 1847 | const body = caseBody(region, action) orelse return true; | |
| 1848 | ||
| 1849 | var if_indent: ?usize = null; | |
| 1850 | var lines = std.mem.splitScalar(u8, body, '\n'); | |
| 1851 | while (lines.next()) |line| { | |
| 1852 | const trimmed = std.mem.trimStart(u8, line, " \t"); | |
| 1853 | if (trimmed.len == 0 or std.mem.startsWith(u8, trimmed, "//")) continue; | |
| 1854 | const indent = line.len - trimmed.len; | |
| 1855 | ||
| 1856 | if (if_indent) |at| { | |
| 1857 | if (indent != at) continue; | |
| 1858 | // The line that closes it. `} else` in any form is the branch | |
| 1859 | // this is looking for; a bare `}` is the gap. | |
| 1860 | if (!std.mem.startsWith(u8, trimmed, "}")) continue; | |
| 1861 | if (std.mem.indexOf(u8, trimmed, "else") == null) return false; | |
| 1862 | if_indent = null; | |
| 1863 | continue; | |
| 1864 | } | |
| 1865 | if (std.mem.startsWith(u8, trimmed, "if let ")) if_indent = indent; | |
| 1866 | } | |
| 1867 | // An `if let` still open at the end of the case never closed with an else. | |
| 1868 | return if_indent == null; | |
| 1869 | } | |
| 1870 | ||
| 1803 | 1871 | fn specAnswers(region: []const u8, action: []const u8) bool { |
| 1804 | 1872 | return caseUsesCallback(region, action, .answers); |
| 1805 | 1873 | } |
| @@ -1903,6 +1971,48 @@ test "every call the spec answers carries a callbackId to answer it on" { | ||
| 1903 | 1971 | try testing.expect(literal_posts >= 40); |
| 1904 | 1972 | } |
| 1905 | 1973 | |
| 1974 | test "no case the page waits on casts its argument without answering the other way" { | |
| 1975 | // #221: three cases — openURL, setBadge, setFlashlight — cast and then | |
| 1976 | // answered only inside the `if`, so `craft.setBadge('3')` posted a | |
| 1977 | // callbackId that nothing ever settled. The Siri pair had the same shape. | |
| 1978 | // Zig refuses all five, so this is the path an app with no Zig runtime | |
| 1979 | // takes, and the one a Zig build falls back to when the payload will not | |
| 1980 | // serialize. | |
| 1981 | const region = dispatcherRegion(); | |
| 1982 | var awaited = try collectAwaitedActions(testing.allocator); | |
| 1983 | defer awaited.deinit(); | |
| 1984 | try testing.expect(awaited.count() >= 80); | |
| 1985 | ||
| 1986 | // Cases still missing an `else`. It may only shrink: an entry here is a | |
| 1987 | // page promise that never settles for a malformed argument. | |
| 1988 | const known_gaps = [_][]const u8{}; | |
| 1989 | ||
| 1990 | // Every gap, not the first: one case at a time would hide the rest of a | |
| 1991 | // class the whole point of this check is to see all of. | |
| 1992 | var gaps: usize = 0; | |
| 1993 | var it = awaited.keyIterator(); | |
| 1994 | while (it.next()) |action| { | |
| 1995 | if (caseAnswersEveryPath(region, action.*)) continue; | |
| 1996 | for (known_gaps) |allowed| { | |
| 1997 | if (std.mem.eql(u8, allowed, action.*)) break; | |
| 1998 | } else { | |
| 1999 | gaps += 1; | |
| 2000 | std.debug.print( | |
| 2001 | "the page waits on `{s}`, and its case has an `if let` with no `else`.\n", | |
| 2002 | .{action.*}, | |
| 2003 | ); | |
| 2004 | } | |
| 2005 | } | |
| 2006 | if (gaps > 0) { | |
| 2007 | std.debug.print( | |
| 2008 | " An argument that does not cast settles nothing at all, and the page waits\n" ++ | |
| 2009 | " out its own timeout instead of hearing INVALID_ARGUMENT.\n", | |
| 2010 | .{}, | |
| 2011 | ); | |
| 2012 | return error.CaseLeavesAMalformedArgumentUnanswered; | |
| 2013 | } | |
| 2014 | } | |
| 2015 | ||
| 1906 | 2016 | test "every call the page waits on has a case that can resolve it" { |
| 1907 | 2017 | const region = dispatcherRegion(); |
| 1908 | 2018 | var awaited = try collectAwaitedActions(testing.allocator); |