fix(dev): pending inspector stable times and refreshPending without nested busy

Expose wall-clock fire targets from the iOS NotificationInspector
(scheduled_time userInfo and predictive_<epochMs> ids) so the debug
panel is not misleading when nextTriggerDate resamples for interval
triggers. Extend TS types and show the scheduled target in the UI,
with a note when iOS nextTriggerDate diverges.

Make refreshPending a plain fetch so mock refresh, wakeup ping, flood
test, and clear notifications can refresh the pending list while an
outer withBusy guard is already active.
This commit is contained in:
Jose Olarte III
2026-05-11 13:50:52 +08:00
parent 48637ae9a8
commit 5a40075ab1
4 changed files with 77 additions and 11 deletions

View File

@@ -16,6 +16,29 @@ public class NotificationInspectorPlugin: CAPPlugin, CAPBridgedPlugin {
]
}
/// Stable wall-clock target: plugin `userInfo["scheduled_time"]`, or epoch ms in `predictive_<ms>` identifiers.
/// (Apple documents `UNTimeIntervalNotificationTrigger.nextTriggerDate()` as resampling ~now+interval when queried.)
private func wallClockMillis(from request: UNNotificationRequest) -> (ms: Int64, source: String)? {
let info = request.content.userInfo
if let v = info["scheduled_time"] as? Int64 {
return (v, "userInfo.scheduled_time")
}
if let n = info["scheduled_time"] as? NSNumber {
return (n.int64Value, "userInfo.scheduled_time")
}
if let i = info["scheduled_time"] as? Int {
return (Int64(i), "userInfo.scheduled_time")
}
let prefix = "predictive_"
if request.identifier.hasPrefix(prefix) {
let suffix = String(request.identifier.dropFirst(prefix.count))
if let ms = Int64(suffix) {
return (ms, "identifier(predictive_)")
}
}
return nil
}
@objc public func getPendingNotifications(_ call: CAPPluginCall) {
UNUserNotificationCenter.current().getPendingNotificationRequests { requests in
let pending: [[String: Any]] = requests.map { req in
@@ -43,6 +66,13 @@ public class NotificationInspectorPlugin: CAPPlugin, CAPBridgedPlugin {
]
obj["nextTriggerDate"] = nextTriggerMs ?? NSNull()
obj["triggerType"] = triggerType ?? NSNull()
if let wall = self.wallClockMillis(from: req) {
obj["wallClockMillis"] = NSNumber(value: wall.ms)
obj["wallClockSource"] = wall.source
} else {
obj["wallClockMillis"] = NSNull()
obj["wallClockSource"] = NSNull()
}
return obj
}

View File

@@ -91,13 +91,37 @@
<li
v-for="p in pending"
:key="p.identifier"
class="px-3 py-2 text-sm flex gap-3 items-center"
class="px-3 py-2 text-sm flex gap-3 items-start"
>
<code class="truncate">{{ p.identifier }}</code>
<span class="ms-auto text-xs text-slate-500">
{{
p.nextTriggerDate ? new Date(p.nextTriggerDate).toISOString() : ""
}}
<code class="truncate min-w-0">{{ p.identifier }}</code>
<span
class="ms-auto text-xs text-right text-slate-600 max-w-[58%] shrink-0"
>
<template v-if="p.wallClockMillis != null">
<span class="block font-medium">{{
formatIsoMs(p.wallClockMillis)
}}</span>
<span class="block text-[10px] text-slate-400"
>Scheduled target ({{ p.wallClockSource }})</span
>
<span
v-if="
p.nextTriggerDate != null &&
Math.abs(p.nextTriggerDate - p.wallClockMillis) > 5000
"
class="block text-[10px] text-amber-800 mt-0.5"
>iOS nextTriggerDate (resamples on each fetch for interval
triggers): {{ formatIsoMs(p.nextTriggerDate) }}</span
>
</template>
<template v-else>
<span class="block">{{
formatIsoMs(p.nextTriggerDate ?? null)
}}</span>
<span class="block text-[10px] text-slate-400"
>iOS nextTriggerDate</span
>
</template>
</span>
</li>
</ul>
@@ -147,6 +171,8 @@ type PendingInfo = {
identifier: string;
nextTriggerDate?: number | null;
triggerType?: string | null;
wallClockMillis?: number | null;
wallClockSource?: string | null;
};
const presets = [
@@ -168,6 +194,13 @@ const intervalLabel = computed(() => {
return preset?.label ?? `${intervalMs.value}ms`;
});
function formatIsoMs(ms: number | null | undefined): string {
if (ms == null || !Number.isFinite(ms)) {
return "";
}
return new Date(ms).toISOString();
}
async function withBusy(fn: () => Promise<void>): Promise<void> {
if (busy.value) return;
busy.value = true;
@@ -179,11 +212,9 @@ async function withBusy(fn: () => Promise<void>): Promise<void> {
}
async function refreshPending(): Promise<void> {
await withBusy(async () => {
const result = await NotificationDebugService.getPendingNotifications();
pending.value = result.pending;
pendingInspectorMessage.value = result.inspectorUnavailableMessage ?? null;
});
const result = await NotificationDebugService.getPendingNotifications();
pending.value = result.pending;
pendingInspectorMessage.value = result.inspectorUnavailableMessage ?? null;
}
async function onMockRefresh(): Promise<void> {

View File

@@ -4,6 +4,9 @@ export type PendingNotificationInfo = {
identifier: string;
nextTriggerDate?: number | null;
triggerType?: string | null;
/** Epoch ms for intended fire time when known (userInfo or predictive_ id); stable across Refresh. */
wallClockMillis?: number | null;
wallClockSource?: string | null;
};
export interface NotificationInspectorPlugin {

View File

@@ -23,6 +23,8 @@ type PendingNotificationInfo = {
identifier: string;
nextTriggerDate?: number | null;
triggerType?: string | null;
wallClockMillis?: number | null;
wallClockSource?: string | null;
};
export type PendingNotificationsResult = {