yaver-feedback-react-native 0.9.3 → 0.9.6
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/README.md +31 -16
- package/android/src/main/java/io/yaver/feedback/YaverHotReloadModule.java +59 -0
- package/app.plugin.js +112 -3
- package/dist/AuthOverlay.js +13 -2
- package/dist/FeedbackModal.js +70 -7
- package/dist/ShakeDetector.js +31 -17
- package/dist/YaverFeedback.d.ts +34 -9
- package/dist/YaverFeedback.js +304 -20
- package/dist/__tests__/NativeDogfoodShortcut.test.d.ts +1 -0
- package/dist/__tests__/NativeDogfoodShortcut.test.js +34 -0
- package/dist/__tests__/ReportIdentity.test.js +6 -3
- package/dist/__tests__/ShakeDetectorWeb.test.d.ts +1 -0
- package/dist/__tests__/ShakeDetectorWeb.test.js +27 -0
- package/dist/__tests__/YaverFeedback.test.js +97 -4
- package/dist/__tests__/deviceDogfood.test.js +2 -2
- package/dist/__tests__/dogfoodPolicy.test.js +1 -1
- package/dist/auth.d.ts +7 -0
- package/dist/auth.js +28 -0
- package/dist/deviceDogfood.d.ts +6 -2
- package/dist/deviceDogfood.js +6 -3
- package/dist/dogfoodPolicy.d.ts +32 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.js +5 -2
- package/dist/preferences.d.ts +2 -0
- package/dist/preferences.js +24 -0
- package/ios/YaverHotReload.m +8 -0
- package/ios/YaverHotReload.swift +37 -0
- package/package.json +2 -2
- package/src/AuthOverlay.tsx +11 -2
- package/src/FeedbackModal.tsx +98 -6
- package/src/ShakeDetector.ts +31 -18
- package/src/YaverFeedback.ts +271 -19
- package/src/__tests__/NativeDogfoodShortcut.test.ts +37 -0
- package/src/__tests__/ReportIdentity.test.ts +6 -4
- package/src/__tests__/ShakeDetectorWeb.test.ts +30 -0
- package/src/__tests__/YaverFeedback.test.ts +104 -5
- package/src/__tests__/deviceDogfood.test.ts +2 -2
- package/src/__tests__/dogfoodPolicy.test.ts +1 -1
- package/src/auth.ts +29 -0
- package/src/deviceDogfood.ts +8 -3
- package/src/dogfoodPolicy.ts +32 -1
- package/src/index.ts +5 -2
- package/src/preferences.ts +20 -0
package/src/YaverFeedback.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
setStrictNativeAuth,
|
|
15
15
|
getToken,
|
|
16
16
|
getSelectedDeviceId,
|
|
17
|
+
getDogfoodAccountAccess,
|
|
17
18
|
listReachableDevices,
|
|
18
19
|
clearToken,
|
|
19
20
|
clearSelectedDeviceId,
|
|
@@ -26,7 +27,7 @@ import {
|
|
|
26
27
|
setQuickIconColorPreset,
|
|
27
28
|
QuickIconColorPreset,
|
|
28
29
|
} from './preferences';
|
|
29
|
-
import { resolveSDKDogfood, type SDKDogfoodStatus } from './dogfoodPolicy';
|
|
30
|
+
import { resolveSDKDogfood, type SDKDogfoodStatus, type DogfoodAccessSnapshot } from './dogfoodPolicy';
|
|
30
31
|
import { YaverDeviceDogfood, type DeviceDogfoodOptions, type DeviceDogfoodSession, type DeviceDogfoodState } from './deviceDogfood';
|
|
31
32
|
|
|
32
33
|
export interface DogfoodOnboardingOptions extends DeviceDogfoodOptions {
|
|
@@ -36,6 +37,13 @@ export interface DogfoodOnboardingOptions extends DeviceDogfoodOptions {
|
|
|
36
37
|
framework?: string;
|
|
37
38
|
}
|
|
38
39
|
|
|
40
|
+
export type DogfoodFlowPhase = 'idle' | 'denied' | 'auth-required' | 'machine-required' | 'opening' | 'error';
|
|
41
|
+
export interface DogfoodFlowState {
|
|
42
|
+
phase: DogfoodFlowPhase;
|
|
43
|
+
appId?: string;
|
|
44
|
+
error?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
39
47
|
function unrefTimer(timer: ReturnType<typeof setTimeout>): void {
|
|
40
48
|
const maybeNodeTimer = timer as unknown as { unref?: () => void };
|
|
41
49
|
if (typeof maybeNodeTimer.unref === 'function') {
|
|
@@ -179,6 +187,50 @@ let autoStartTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
179
187
|
let reportLaunchInFlight = false;
|
|
180
188
|
let crashReportInFlight = false;
|
|
181
189
|
let dogfoodOnboarding: DogfoodOnboardingOptions | null = null;
|
|
190
|
+
let dogfoodFlowState: DogfoodFlowState = { phase: 'idle' };
|
|
191
|
+
const dogfoodFlowListeners = new Set<(state: DogfoodFlowState) => void>();
|
|
192
|
+
let dogfoodShortcutAppStateSubscription: { remove: () => void } | null = null;
|
|
193
|
+
let dogfoodActivationSubscription: { remove: () => void } | null = null;
|
|
194
|
+
let dogfoodShortcutLaunchInFlight = false;
|
|
195
|
+
let lastDogfoodActivationUrl = '';
|
|
196
|
+
|
|
197
|
+
function publishDogfoodFlow(state: DogfoodFlowState): void {
|
|
198
|
+
dogfoodFlowState = state;
|
|
199
|
+
try { config?.dogfood?.onStateChange?.(state); } catch { /* host callback */ }
|
|
200
|
+
dogfoodFlowListeners.forEach((listener) => {
|
|
201
|
+
try { listener(state); } catch { /* host callbacks never break the SDK */ }
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function consumeNativeDogfoodShortcut(): Promise<void> {
|
|
206
|
+
if (dogfoodShortcutLaunchInFlight || !config?.dogfood?.appShortcut) return;
|
|
207
|
+
const native = (NativeModules as any)?.YaverHotReload;
|
|
208
|
+
if (typeof native?.consumeDogfoodShortcut !== 'function') return;
|
|
209
|
+
try {
|
|
210
|
+
if (!(await native.consumeDogfoodShortcut())) return;
|
|
211
|
+
dogfoodShortcutLaunchInFlight = true;
|
|
212
|
+
// Let FeedbackModal/AuthOverlay effects mount after a cold launch.
|
|
213
|
+
const timer = setTimeout(() => {
|
|
214
|
+
void YaverFeedback.openDogfood().finally(() => { dogfoodShortcutLaunchInFlight = false; });
|
|
215
|
+
}, 350);
|
|
216
|
+
unrefTimer(timer);
|
|
217
|
+
} catch {
|
|
218
|
+
dogfoodShortcutLaunchInFlight = false;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function handleDogfoodActivationUrl(url?: string | null): void {
|
|
223
|
+
if (!url || url === lastDogfoodActivationUrl || !config?.dogfood) return;
|
|
224
|
+
try {
|
|
225
|
+
const parsed = new URL(url);
|
|
226
|
+
if (!parsed.protocol.startsWith('yaver-dogfood-') || parsed.hostname !== 'activate') return;
|
|
227
|
+
} catch {
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
lastDogfoodActivationUrl = url;
|
|
231
|
+
const timer = setTimeout(() => { void YaverFeedback.openDogfood(); }, 350);
|
|
232
|
+
unrefTimer(timer);
|
|
233
|
+
}
|
|
182
234
|
|
|
183
235
|
/** Resolve the user's relay password by validating their auth token
|
|
184
236
|
* against Convex. Used whenever we (re)build the P2PClient so a
|
|
@@ -438,6 +490,31 @@ export class YaverFeedback {
|
|
|
438
490
|
void YaverFeedback.hydrateSession();
|
|
439
491
|
}
|
|
440
492
|
|
|
493
|
+
if (config.dogfood?.appShortcut) {
|
|
494
|
+
void YaverFeedback.syncDogfoodAppShortcut();
|
|
495
|
+
void consumeNativeDogfoodShortcut();
|
|
496
|
+
try {
|
|
497
|
+
const { AppState } = require('react-native');
|
|
498
|
+
dogfoodShortcutAppStateSubscription?.remove();
|
|
499
|
+
dogfoodShortcutAppStateSubscription = AppState.addEventListener('change', (state: string) => {
|
|
500
|
+
if (state === 'active') {
|
|
501
|
+
void YaverFeedback.syncDogfoodAppShortcut();
|
|
502
|
+
void consumeNativeDogfoodShortcut();
|
|
503
|
+
}
|
|
504
|
+
});
|
|
505
|
+
} catch { /* native shortcut unavailable on web/test runtimes */ }
|
|
506
|
+
}
|
|
507
|
+
if (config.dogfood) {
|
|
508
|
+
try {
|
|
509
|
+
const { Linking } = require('react-native');
|
|
510
|
+
dogfoodActivationSubscription?.remove();
|
|
511
|
+
dogfoodActivationSubscription = Linking.addEventListener('url', ({ url }: { url: string }) => {
|
|
512
|
+
handleDogfoodActivationUrl(url);
|
|
513
|
+
});
|
|
514
|
+
void Linking.getInitialURL().then(handleDogfoodActivationUrl).catch(() => {});
|
|
515
|
+
} catch { /* linking unavailable in non-native test runtimes */ }
|
|
516
|
+
}
|
|
517
|
+
|
|
441
518
|
// Create P2P client if we have a URL
|
|
442
519
|
if (config.agentUrl) {
|
|
443
520
|
p2pAuthToken = config.authToken ?? null;
|
|
@@ -634,7 +711,7 @@ export class YaverFeedback {
|
|
|
634
711
|
* Sets config.agentUrl and creates P2PClient on success.
|
|
635
712
|
*/
|
|
636
713
|
static async discoverAgent(): Promise<void> {
|
|
637
|
-
if (!config || !enabled) return;
|
|
714
|
+
if (!config || (!enabled && !dogfoodOnboarding)) return;
|
|
638
715
|
if (config.agentUrl) return; // already have a URL
|
|
639
716
|
if (!config.authToken) return; // need auth before discovery can succeed
|
|
640
717
|
|
|
@@ -663,7 +740,7 @@ export class YaverFeedback {
|
|
|
663
740
|
* Returns true when a new URL was adopted.
|
|
664
741
|
*/
|
|
665
742
|
static async reconnect(): Promise<boolean> {
|
|
666
|
-
if (!config || !enabled) return false;
|
|
743
|
+
if (!config || (!enabled && !dogfoodOnboarding)) return false;
|
|
667
744
|
if (!config.authToken || !config.convexUrl) return false;
|
|
668
745
|
try {
|
|
669
746
|
const result = await YaverDiscovery.refreshFromConvex({
|
|
@@ -727,6 +804,7 @@ export class YaverFeedback {
|
|
|
727
804
|
if (config.autoStartBlackBox !== false && !BlackBox.isStreaming) {
|
|
728
805
|
YaverFeedback.scheduleBlackBoxAutoStart();
|
|
729
806
|
}
|
|
807
|
+
await YaverFeedback.syncDogfoodAppShortcut().catch(() => false);
|
|
730
808
|
}
|
|
731
809
|
|
|
732
810
|
/** Returns true once the SDK has a session token it can use. */
|
|
@@ -807,17 +885,181 @@ export class YaverFeedback {
|
|
|
807
885
|
DeviceEventEmitter.emit('yaverFeedback:startMachinePicker');
|
|
808
886
|
}
|
|
809
887
|
|
|
810
|
-
/**
|
|
811
|
-
*
|
|
812
|
-
|
|
813
|
-
static beginDogfoodOnboarding(options: DogfoodOnboardingOptions): void {
|
|
888
|
+
/** Configure Dogfood once during host init. Hosts still decide whether and
|
|
889
|
+
* where to render an affordance; `openDogfood()` owns all flow mechanics. */
|
|
890
|
+
static configureDogfood(options: DogfoodOnboardingOptions): void {
|
|
814
891
|
dogfoodOnboarding = options;
|
|
815
|
-
|
|
816
|
-
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/**
|
|
895
|
+
* Open Dogfood using config.dogfood + the normal app identity. Cached OAuth,
|
|
896
|
+
* machine, runner and model choices are reused. The corresponding picker is
|
|
897
|
+
* shown only when a required choice is missing.
|
|
898
|
+
*/
|
|
899
|
+
static async openDogfood(overrides?: Partial<DogfoodOnboardingOptions>): Promise<DogfoodFlowState> {
|
|
900
|
+
if (!config && overrides?.appId) {
|
|
901
|
+
return YaverFeedback.beginDogfoodOnboarding(overrides as DogfoodOnboardingOptions);
|
|
902
|
+
}
|
|
903
|
+
const configured = config?.dogfood;
|
|
904
|
+
const appId = overrides?.appId || configured?.appId || config?.bundleId;
|
|
905
|
+
if (!appId) {
|
|
906
|
+
const state: DogfoodFlowState = { phase: 'error', error: 'Dogfood requires an appId or FeedbackConfig.bundleId.' };
|
|
907
|
+
publishDogfoodFlow(state);
|
|
908
|
+
return state;
|
|
909
|
+
}
|
|
910
|
+
dogfoodOnboarding = {
|
|
911
|
+
appId,
|
|
912
|
+
label: overrides?.label || configured?.label || config?.projectName || appId,
|
|
913
|
+
projectName: overrides?.projectName || configured?.projectName || config?.projectName,
|
|
914
|
+
framework: overrides?.framework || configured?.framework,
|
|
915
|
+
backendUrl: overrides?.backendUrl || configured?.backendUrl,
|
|
916
|
+
secureStore: overrides?.secureStore,
|
|
917
|
+
};
|
|
918
|
+
if (configured?.canShow) {
|
|
919
|
+
// This hook is presentation policy, but it still receives the complete
|
|
920
|
+
// backend-authoritative snapshot. Passing an owner-only approximation
|
|
921
|
+
// here made legitimate approved testers fail custom `access.authorized`
|
|
922
|
+
// gates even though their exact phone key was active.
|
|
923
|
+
const access = await YaverFeedback.getDogfoodAccess();
|
|
924
|
+
try {
|
|
925
|
+
if (!(await configured.canShow(access))) {
|
|
926
|
+
const state: DogfoodFlowState = { phase: 'denied', appId };
|
|
927
|
+
publishDogfoodFlow(state);
|
|
928
|
+
return state;
|
|
929
|
+
}
|
|
930
|
+
} catch (cause) {
|
|
931
|
+
const state: DogfoodFlowState = {
|
|
932
|
+
phase: 'error',
|
|
933
|
+
appId,
|
|
934
|
+
error: cause instanceof Error ? cause.message : String(cause),
|
|
935
|
+
};
|
|
936
|
+
publishDogfoodFlow(state);
|
|
937
|
+
return state;
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
return YaverFeedback.continueDogfoodOnboarding();
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/** Resolve the host-facing ACL snapshot without granting authority. This is
|
|
944
|
+
* the one endpoint custom Settings screens need for visibility/status UI. */
|
|
945
|
+
static async getDogfoodAccess(): Promise<DogfoodAccessSnapshot> {
|
|
946
|
+
const configured = config?.dogfood;
|
|
947
|
+
const appId = dogfoodOnboarding?.appId || configured?.appId || config?.bundleId;
|
|
948
|
+
if (!appId) throw new Error('Dogfood requires an appId or FeedbackConfig.bundleId.');
|
|
949
|
+
let installationId: string | undefined;
|
|
950
|
+
let deviceState: DogfoodAccessSnapshot['deviceState'] = 'unknown';
|
|
951
|
+
try {
|
|
952
|
+
const device = new YaverDeviceDogfood({
|
|
953
|
+
appId,
|
|
954
|
+
label: dogfoodOnboarding?.label || configured?.label || config?.projectName,
|
|
955
|
+
backendUrl: dogfoodOnboarding?.backendUrl || configured?.backendUrl,
|
|
956
|
+
secureStore: dogfoodOnboarding?.secureStore,
|
|
957
|
+
});
|
|
958
|
+
installationId = (await device.enrollmentInfo()).installationId;
|
|
959
|
+
deviceState = await device.status();
|
|
960
|
+
} catch {
|
|
961
|
+
// UI status must degrade to unknown; openDogfood still offers owner OAuth.
|
|
962
|
+
}
|
|
963
|
+
const token = config?.authToken || await getToken();
|
|
964
|
+
const account = token
|
|
965
|
+
? await getDogfoodAccountAccess(appId, token, installationId)
|
|
966
|
+
: { authenticated: false, ownerAuthorized: false, installationAuthorized: false };
|
|
967
|
+
const yaverAuthenticated = account.authenticated;
|
|
968
|
+
const ownerAuthorized = account.ownerAuthorized;
|
|
969
|
+
return {
|
|
970
|
+
appId,
|
|
971
|
+
yaverAuthenticated,
|
|
972
|
+
ownerAuthorized,
|
|
973
|
+
installationId,
|
|
974
|
+
deviceState,
|
|
975
|
+
authorized: yaverAuthenticated && account.installationAuthorized && deviceState === 'active',
|
|
976
|
+
};
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/** Add/remove the platform Home Screen shortcut from backend-authoritative
|
|
980
|
+
* owner/device ACL state. Static plist shortcuts are intentionally avoided:
|
|
981
|
+
* an unauthorized install must never advertise a hidden developer action. */
|
|
982
|
+
static async syncDogfoodAppShortcut(): Promise<boolean> {
|
|
983
|
+
const shortcut = config?.dogfood?.appShortcut;
|
|
984
|
+
const native = (NativeModules as any)?.YaverHotReload;
|
|
985
|
+
if (!shortcut || typeof native?.setDogfoodShortcut !== 'function') return false;
|
|
986
|
+
const access = await YaverFeedback.getDogfoodAccess();
|
|
987
|
+
let visible = access.authorized;
|
|
988
|
+
if (visible && config?.dogfood?.canShow) {
|
|
989
|
+
try {
|
|
990
|
+
visible = await config.dogfood.canShow(access);
|
|
991
|
+
} catch {
|
|
992
|
+
visible = false;
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
const label = typeof shortcut === 'object' && shortcut.label
|
|
996
|
+
? shortcut.label
|
|
997
|
+
: `Dogfood ${config?.dogfood?.label || config?.projectName || ''}`.trim();
|
|
998
|
+
// Product contract: both a valid full Yaver account AND this phone's
|
|
999
|
+
// backend-approved app installation are required. Neither factor alone
|
|
1000
|
+
// advertises the developer surface.
|
|
1001
|
+
await native.setDogfoodShortcut(visible, label || 'Dogfood');
|
|
1002
|
+
return visible;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/** Backwards-compatible entry point for existing integrations. */
|
|
1006
|
+
static async beginDogfoodOnboarding(options: DogfoodOnboardingOptions): Promise<DogfoodFlowState> {
|
|
1007
|
+
YaverFeedback.configureDogfood(options);
|
|
1008
|
+
if (!config) {
|
|
1009
|
+
YaverFeedback.init({
|
|
1010
|
+
autoLogin: true,
|
|
1011
|
+
enabled: true,
|
|
1012
|
+
projectName: options.projectName || options.label,
|
|
1013
|
+
bundleId: options.appId,
|
|
1014
|
+
} as FeedbackConfig);
|
|
1015
|
+
}
|
|
1016
|
+
return YaverFeedback.continueDogfoodOnboarding();
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/** Continue after OAuth or machine selection. Public so custom host UI can
|
|
1020
|
+
* hand control back without recreating the SDK state machine. */
|
|
1021
|
+
static async continueDogfoodOnboarding(): Promise<DogfoodFlowState> {
|
|
1022
|
+
const appId = dogfoodOnboarding?.appId;
|
|
1023
|
+
if (!dogfoodOnboarding || !appId) {
|
|
1024
|
+
const state: DogfoodFlowState = { phase: 'error', error: 'Dogfood is not configured.' };
|
|
1025
|
+
publishDogfoodFlow(state);
|
|
1026
|
+
return state;
|
|
1027
|
+
}
|
|
1028
|
+
await YaverFeedback.hydrateSession();
|
|
1029
|
+
const token = config?.authToken || await getToken();
|
|
1030
|
+
const account = token
|
|
1031
|
+
? await getDogfoodAccountAccess(appId, token)
|
|
1032
|
+
: { authenticated: false, ownerAuthorized: false, installationAuthorized: false };
|
|
1033
|
+
// A device key is a second factor for this installation, never a
|
|
1034
|
+
// replacement for a real Yaver account. Narrow installation sessions and
|
|
1035
|
+
// stale/invalid cached tokens both return authenticated=false here.
|
|
1036
|
+
if (!account.authenticated) {
|
|
1037
|
+
const state: DogfoodFlowState = { phase: 'auth-required', appId };
|
|
1038
|
+
publishDogfoodFlow(state);
|
|
817
1039
|
YaverFeedback.showLogin();
|
|
818
|
-
return;
|
|
1040
|
+
return state;
|
|
1041
|
+
}
|
|
1042
|
+
if (!config?.preferredDeviceId) {
|
|
1043
|
+
const state: DogfoodFlowState = { phase: 'machine-required', appId };
|
|
1044
|
+
publishDogfoodFlow(state);
|
|
1045
|
+
YaverFeedback.showMachinePicker();
|
|
1046
|
+
return state;
|
|
819
1047
|
}
|
|
820
|
-
|
|
1048
|
+
const state: DogfoodFlowState = { phase: 'opening', appId };
|
|
1049
|
+
publishDogfoodFlow(state);
|
|
1050
|
+
const { DeviceEventEmitter } = require('react-native');
|
|
1051
|
+
DeviceEventEmitter.emit('yaverFeedback:startReport');
|
|
1052
|
+
return state;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
static getDogfoodFlowState(): DogfoodFlowState {
|
|
1056
|
+
return dogfoodFlowState;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
static onDogfoodFlowState(listener: (state: DogfoodFlowState) => void): () => void {
|
|
1060
|
+
dogfoodFlowListeners.add(listener);
|
|
1061
|
+
listener(dogfoodFlowState);
|
|
1062
|
+
return () => dogfoodFlowListeners.delete(listener);
|
|
821
1063
|
}
|
|
822
1064
|
|
|
823
1065
|
static getDogfoodOnboarding(): DogfoodOnboardingOptions | null {
|
|
@@ -826,6 +1068,7 @@ export class YaverFeedback {
|
|
|
826
1068
|
|
|
827
1069
|
static clearDogfoodOnboarding(): void {
|
|
828
1070
|
dogfoodOnboarding = null;
|
|
1071
|
+
publishDogfoodFlow({ phase: 'idle' });
|
|
829
1072
|
}
|
|
830
1073
|
|
|
831
1074
|
/**
|
|
@@ -847,6 +1090,7 @@ export class YaverFeedback {
|
|
|
847
1090
|
p2pClient = null;
|
|
848
1091
|
renderP2PClient = null;
|
|
849
1092
|
p2pAuthToken = null;
|
|
1093
|
+
await import('./auth').then(({ saveSelectedDeviceId }) => saveSelectedDeviceId(deviceId));
|
|
850
1094
|
await YaverFeedback.discoverAgent();
|
|
851
1095
|
}
|
|
852
1096
|
|
|
@@ -917,6 +1161,7 @@ export class YaverFeedback {
|
|
|
917
1161
|
p2pClient = null;
|
|
918
1162
|
renderP2PClient = null;
|
|
919
1163
|
p2pAuthToken = null;
|
|
1164
|
+
await YaverFeedback.syncDogfoodAppShortcut().catch(() => false);
|
|
920
1165
|
}
|
|
921
1166
|
|
|
922
1167
|
/**
|
|
@@ -1178,16 +1423,16 @@ export class YaverFeedback {
|
|
|
1178
1423
|
return resolveSDKDogfood(config?.dogfood);
|
|
1179
1424
|
}
|
|
1180
1425
|
|
|
1181
|
-
/** One-call
|
|
1182
|
-
*
|
|
1183
|
-
*
|
|
1184
|
-
*
|
|
1426
|
+
/** One-call account-bound Dogfood bootstrap for third-party apps. The host
|
|
1427
|
+
* app needs no auth backend of its own: SDK OAuth supplies the full Yaver
|
|
1428
|
+
* account, then this creates/proves the installation key. Owner approval
|
|
1429
|
+
* binds that account + appId + phone key before a scoped session is minted. */
|
|
1185
1430
|
static async enableDeviceDogfood(options: DeviceDogfoodOptions): Promise<{
|
|
1186
1431
|
status: DeviceDogfoodState;
|
|
1187
1432
|
installationId: string;
|
|
1188
1433
|
session: DeviceDogfoodSession | null;
|
|
1189
1434
|
}> {
|
|
1190
|
-
const client = new YaverDeviceDogfood(options);
|
|
1435
|
+
const client = new YaverDeviceDogfood({ ...options, authToken: options.authToken || config?.authToken });
|
|
1191
1436
|
let status = await client.status();
|
|
1192
1437
|
if (status === 'unregistered' || status === 'pending' || status === 'cancelled' || status === 'revoked' || status === 'superseded') {
|
|
1193
1438
|
const enrolled = status === 'unregistered' || status === 'pending'
|
|
@@ -1200,20 +1445,22 @@ export class YaverFeedback {
|
|
|
1200
1445
|
if (!config) YaverFeedback.init({ autoLogin: false } as FeedbackConfig);
|
|
1201
1446
|
if (config) {
|
|
1202
1447
|
config.dogfood = {
|
|
1448
|
+
...config.dogfood,
|
|
1203
1449
|
enabled: status === 'active' && !!session,
|
|
1204
1450
|
appId: options.appId,
|
|
1205
1451
|
installationId: info.installationId,
|
|
1206
1452
|
installationStatus: status === 'active' && session ? 'active' : status === 'unregistered' ? 'pending' : status,
|
|
1207
1453
|
label: options.label,
|
|
1208
1454
|
};
|
|
1209
|
-
// A normal Yaver OAuth session remains authoritative for the
|
|
1210
|
-
// wizard.
|
|
1211
|
-
|
|
1455
|
+
// A normal full Yaver OAuth session remains authoritative for the
|
|
1456
|
+
// runtime wizard. The narrow installation token is intentionally never
|
|
1457
|
+
// promoted into account auth.
|
|
1212
1458
|
}
|
|
1213
1459
|
try {
|
|
1214
1460
|
const { DeviceEventEmitter } = require('react-native');
|
|
1215
1461
|
DeviceEventEmitter.emit('yaverFeedback:dogfoodChanged', { active: !!session, status });
|
|
1216
1462
|
} catch { /* noop */ }
|
|
1463
|
+
await YaverFeedback.syncDogfoodAppShortcut().catch(() => false);
|
|
1217
1464
|
return { status, installationId: info.installationId, session };
|
|
1218
1465
|
}
|
|
1219
1466
|
|
|
@@ -1768,6 +2015,11 @@ export class YaverFeedback {
|
|
|
1768
2015
|
// init() stacked a second one on top.
|
|
1769
2016
|
commandUnsubscribe?.();
|
|
1770
2017
|
commandUnsubscribe = null;
|
|
2018
|
+
dogfoodShortcutAppStateSubscription?.remove();
|
|
2019
|
+
dogfoodShortcutAppStateSubscription = null;
|
|
2020
|
+
dogfoodActivationSubscription?.remove();
|
|
2021
|
+
dogfoodActivationSubscription = null;
|
|
2022
|
+
lastDogfoodActivationUrl = '';
|
|
1771
2023
|
// Before `enabled = false` / `config = null` below, so an in-flight retry
|
|
1772
2024
|
// can't fire against a torn-down config.
|
|
1773
2025
|
YaverFeedback.cancelBlackBoxAutoStart();
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
|
|
4
|
+
const plugin = require('../../app.plugin.js') as {
|
|
5
|
+
__test: { patchDogfoodAppShortcut(contents: string): string };
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
describe('native Dogfood shortcut contract', () => {
|
|
9
|
+
it('patches cold and warm iOS shortcut delivery idempotently', () => {
|
|
10
|
+
const source = `
|
|
11
|
+
public class AppDelegate: ExpoAppDelegate {
|
|
12
|
+
public override func application(
|
|
13
|
+
_ application: UIApplication,
|
|
14
|
+
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
|
|
15
|
+
) -> Bool {
|
|
16
|
+
setupYaverHotReload()
|
|
17
|
+
return true
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
`;
|
|
21
|
+
const once = plugin.__test.patchDogfoodAppShortcut(source);
|
|
22
|
+
expect(once).toContain('launchOptions?[.shortcutItem]');
|
|
23
|
+
expect(once).toContain('performActionFor shortcutItem');
|
|
24
|
+
expect(once).toContain('markDogfoodShortcutPending()');
|
|
25
|
+
expect(plugin.__test.patchDogfoodAppShortcut(once)).toBe(once);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('uses dynamic native shortcuts on both platforms', () => {
|
|
29
|
+
const ios = readFileSync(join(__dirname, '../../ios/YaverHotReload.swift'), 'utf8');
|
|
30
|
+
const android = readFileSync(join(__dirname, '../../android/src/main/java/io/yaver/feedback/YaverHotReloadModule.java'), 'utf8');
|
|
31
|
+
expect(ios).toContain('UIApplication.shared.shortcutItems');
|
|
32
|
+
expect(ios).toContain('setDogfoodShortcut');
|
|
33
|
+
expect(android).toContain('ShortcutManager');
|
|
34
|
+
expect(android).toContain('addDynamicShortcuts');
|
|
35
|
+
expect(android).toContain('consumeDogfoodShortcut');
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -10,9 +10,6 @@ const mockReactNative = {
|
|
|
10
10
|
};
|
|
11
11
|
const mockExpoModule: { default: Record<string, unknown> } = { default: {} };
|
|
12
12
|
|
|
13
|
-
jest.mock('react-native', () => mockReactNative);
|
|
14
|
-
jest.mock('expo-constants', () => mockExpoModule, { virtual: true });
|
|
15
|
-
|
|
16
13
|
/** Mirrors mobile/app.json in the Talos repo — the SDK's first external consumer. */
|
|
17
14
|
const EXPO_CONFIG = {
|
|
18
15
|
name: 'Talos',
|
|
@@ -29,8 +26,13 @@ function mockExpoConstants(expoConfig: unknown, extra: Record<string, unknown> =
|
|
|
29
26
|
function loadResolve(): typeof import('../P2PClient').resolveReportIdentity {
|
|
30
27
|
let resolve!: typeof import('../P2PClient').resolveReportIdentity;
|
|
31
28
|
jest.isolateModules(() => {
|
|
29
|
+
// Configure the mocks inside the isolated registry. Top-level virtual
|
|
30
|
+
// mocks are sticky across Jest's worker registry and lost to the real
|
|
31
|
+
// expo-constants package after a clean npm ci, making this suite depend on
|
|
32
|
+
// file order. doMock is non-hoisted and binds these exact mutable objects
|
|
33
|
+
// immediately before P2PClient is evaluated.
|
|
32
34
|
jest.doMock('react-native', () => mockReactNative);
|
|
33
|
-
jest.doMock('expo-constants', () => mockExpoModule
|
|
35
|
+
jest.doMock('expo-constants', () => mockExpoModule);
|
|
34
36
|
resolve = require('../P2PClient').resolveReportIdentity;
|
|
35
37
|
});
|
|
36
38
|
return resolve;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Browsers cannot provide a native shake gesture. expo-sensors still exports
|
|
2
|
+
// an Accelerometer facade there, and older SDK builds attempted to subscribe
|
|
3
|
+
// to it, crashing the host React tree through NativeEventEmitter.
|
|
4
|
+
|
|
5
|
+
const sensorAddListener = jest.fn(() => ({ remove: jest.fn() }));
|
|
6
|
+
const devEventAddListener = jest.fn(() => ({ remove: jest.fn() }));
|
|
7
|
+
|
|
8
|
+
jest.mock('react-native', () => ({
|
|
9
|
+
Platform: { OS: 'web' },
|
|
10
|
+
NativeModules: {},
|
|
11
|
+
DeviceEventEmitter: { addListener: devEventAddListener },
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
jest.mock('expo-sensors', () => ({
|
|
15
|
+
Accelerometer: {
|
|
16
|
+
setUpdateInterval: jest.fn(),
|
|
17
|
+
addListener: sensorAddListener,
|
|
18
|
+
},
|
|
19
|
+
}), { virtual: true });
|
|
20
|
+
|
|
21
|
+
import { ShakeDetector } from '../ShakeDetector';
|
|
22
|
+
|
|
23
|
+
it('keeps explicit SDK controls alive without touching the native sensor on web', () => {
|
|
24
|
+
const detector = new ShakeDetector();
|
|
25
|
+
expect(() => detector.start(jest.fn())).not.toThrow();
|
|
26
|
+
expect(sensorAddListener).not.toHaveBeenCalled();
|
|
27
|
+
// The ordinary DeviceEventEmitter lane remains harmless and removable.
|
|
28
|
+
expect(devEventAddListener).toHaveBeenCalledTimes(1);
|
|
29
|
+
expect(() => detector.stop()).not.toThrow();
|
|
30
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { DeviceEventEmitter } from 'react-native';
|
|
1
|
+
import { DeviceEventEmitter, NativeModules } from 'react-native';
|
|
2
2
|
import { YaverFeedback } from '../YaverFeedback';
|
|
3
3
|
|
|
4
4
|
// Mock react-native: DeviceEventEmitter for event dispatch + Platform so
|
|
@@ -9,6 +9,13 @@ jest.mock('react-native', () => ({
|
|
|
9
9
|
addListener: jest.fn(() => ({ remove: jest.fn() })),
|
|
10
10
|
},
|
|
11
11
|
Platform: { OS: 'ios' },
|
|
12
|
+
NativeModules: {
|
|
13
|
+
YaverHotReload: {
|
|
14
|
+
setDogfoodShortcut: jest.fn(async () => true),
|
|
15
|
+
consumeDogfoodShortcut: jest.fn(async () => false),
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
AppState: { addEventListener: jest.fn(() => ({ remove: jest.fn() })) },
|
|
12
19
|
}));
|
|
13
20
|
|
|
14
21
|
// Mock Discovery
|
|
@@ -23,6 +30,12 @@ jest.mock('../auth', () => ({
|
|
|
23
30
|
setStrictNativeAuth: jest.fn(),
|
|
24
31
|
getToken: jest.fn(async () => null),
|
|
25
32
|
getSelectedDeviceId: jest.fn(async () => null),
|
|
33
|
+
getDogfoodAccountAccess: jest.fn(async (_appId: string, token: string) => ({
|
|
34
|
+
authenticated: token === 'owner-token',
|
|
35
|
+
ownerAuthorized: token === 'owner-token',
|
|
36
|
+
installationAuthorized: token === 'owner-token',
|
|
37
|
+
})),
|
|
38
|
+
saveSelectedDeviceId: jest.fn(async () => {}),
|
|
26
39
|
clearToken: jest.fn(async () => {}),
|
|
27
40
|
clearSelectedDeviceId: jest.fn(async () => {}),
|
|
28
41
|
listReachableDevices: jest.fn(async () => ({
|
|
@@ -48,22 +61,108 @@ beforeEach(() => {
|
|
|
48
61
|
// YaverFeedback uses module-level variables (config, enabled, p2pClient).
|
|
49
62
|
// We reset them by calling init with a known state or relying on isInitialized checks.
|
|
50
63
|
// For a clean slate, we re-init with enabled=false then verify.
|
|
64
|
+
jest.restoreAllMocks();
|
|
51
65
|
jest.clearAllMocks();
|
|
52
66
|
});
|
|
53
67
|
|
|
54
68
|
describe('YaverFeedback', () => {
|
|
55
69
|
describe('Dogfood onboarding', () => {
|
|
56
|
-
it('starts with Yaver OAuth when the host has no session', () => {
|
|
70
|
+
it('starts with Yaver OAuth when the host has no session', async () => {
|
|
57
71
|
YaverFeedback.init({ enabled: true });
|
|
58
|
-
YaverFeedback.beginDogfoodOnboarding({ appId: 'io.example.app', label: 'Example' });
|
|
72
|
+
await YaverFeedback.beginDogfoodOnboarding({ appId: 'io.example.app', label: 'Example' });
|
|
59
73
|
expect(DeviceEventEmitter.emit).toHaveBeenCalledWith('yaverFeedback:startLogin');
|
|
60
74
|
});
|
|
61
75
|
|
|
62
|
-
it('asks for a machine after an existing OAuth session', () => {
|
|
76
|
+
it('asks for a machine after an existing OAuth session', async () => {
|
|
63
77
|
YaverFeedback.init({ enabled: true, authToken: 'owner-token' });
|
|
64
|
-
YaverFeedback.beginDogfoodOnboarding({ appId: 'io.example.app', label: 'Example' });
|
|
78
|
+
await YaverFeedback.beginDogfoodOnboarding({ appId: 'io.example.app', label: 'Example' });
|
|
65
79
|
expect(DeviceEventEmitter.emit).toHaveBeenCalledWith('yaverFeedback:startMachinePicker');
|
|
66
80
|
});
|
|
81
|
+
|
|
82
|
+
it('reuses the configured app identity and selected machine', async () => {
|
|
83
|
+
YaverFeedback.init({
|
|
84
|
+
enabled: true,
|
|
85
|
+
authToken: 'owner-token',
|
|
86
|
+
preferredDeviceId: 'device-1',
|
|
87
|
+
projectName: 'Example',
|
|
88
|
+
bundleId: 'io.example.app',
|
|
89
|
+
dogfood: { framework: 'expo' },
|
|
90
|
+
});
|
|
91
|
+
const state = await YaverFeedback.openDogfood();
|
|
92
|
+
expect(state).toEqual({ phase: 'opening', appId: 'io.example.app' });
|
|
93
|
+
expect(DeviceEventEmitter.emit).toHaveBeenCalledWith('yaverFeedback:startReport');
|
|
94
|
+
expect(DeviceEventEmitter.emit).not.toHaveBeenCalledWith('yaverFeedback:startMachinePicker');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('publishes flow state for custom host UI', async () => {
|
|
98
|
+
YaverFeedback.init({ enabled: true });
|
|
99
|
+
const states: string[] = [];
|
|
100
|
+
const unsubscribe = YaverFeedback.onDogfoodFlowState((state) => states.push(state.phase));
|
|
101
|
+
await YaverFeedback.beginDogfoodOnboarding({ appId: 'io.example.app' });
|
|
102
|
+
unsubscribe();
|
|
103
|
+
expect(states[states.length - 1]).toBe('auth-required');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('lets a host ACL hide its affordance without opening auth UI', async () => {
|
|
107
|
+
YaverFeedback.init({
|
|
108
|
+
enabled: true,
|
|
109
|
+
bundleId: 'io.example.app',
|
|
110
|
+
dogfood: { canShow: () => false },
|
|
111
|
+
});
|
|
112
|
+
const state = await YaverFeedback.openDogfood();
|
|
113
|
+
expect(state).toEqual({ phase: 'denied', appId: 'io.example.app' });
|
|
114
|
+
expect(DeviceEventEmitter.emit).not.toHaveBeenCalledWith('yaverFeedback:startLogin');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('adds the app shortcut only for a signed-in and approved phone', async () => {
|
|
118
|
+
YaverFeedback.init({ bundleId: 'io.example.app', dogfood: {} });
|
|
119
|
+
YaverFeedback.getConfig()!.dogfood!.appShortcut = { label: 'Dogfood Example' };
|
|
120
|
+
jest.spyOn(YaverFeedback, 'getDogfoodAccess').mockResolvedValueOnce({
|
|
121
|
+
appId: 'io.example.app',
|
|
122
|
+
yaverAuthenticated: true,
|
|
123
|
+
ownerAuthorized: false,
|
|
124
|
+
installationId: 'phone-1',
|
|
125
|
+
deviceState: 'active',
|
|
126
|
+
authorized: true,
|
|
127
|
+
});
|
|
128
|
+
await YaverFeedback.syncDogfoodAppShortcut();
|
|
129
|
+
expect((NativeModules as any).YaverHotReload.setDogfoodShortcut)
|
|
130
|
+
.toHaveBeenCalledWith(true, 'Dogfood Example');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('removes the shortcut when the phone is approved but Yaver is signed out', async () => {
|
|
134
|
+
YaverFeedback.init({ bundleId: 'io.example.app', dogfood: {} });
|
|
135
|
+
YaverFeedback.getConfig()!.dogfood!.appShortcut = true;
|
|
136
|
+
jest.spyOn(YaverFeedback, 'getDogfoodAccess').mockResolvedValueOnce({
|
|
137
|
+
appId: 'io.example.app',
|
|
138
|
+
yaverAuthenticated: false,
|
|
139
|
+
ownerAuthorized: false,
|
|
140
|
+
installationId: 'phone-1',
|
|
141
|
+
deviceState: 'active',
|
|
142
|
+
authorized: false,
|
|
143
|
+
});
|
|
144
|
+
await YaverFeedback.syncDogfoodAppShortcut();
|
|
145
|
+
expect((NativeModules as any).YaverHotReload.setDogfoodShortcut)
|
|
146
|
+
.toHaveBeenCalledWith(false, 'Dogfood');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('applies the host presentation ACL after backend device authorization', async () => {
|
|
150
|
+
YaverFeedback.init({
|
|
151
|
+
bundleId: 'io.example.app',
|
|
152
|
+
dogfood: { appShortcut: true, canShow: async () => false },
|
|
153
|
+
});
|
|
154
|
+
jest.spyOn(YaverFeedback, 'getDogfoodAccess').mockResolvedValueOnce({
|
|
155
|
+
appId: 'io.example.app',
|
|
156
|
+
yaverAuthenticated: true,
|
|
157
|
+
ownerAuthorized: false,
|
|
158
|
+
installationId: 'phone-1',
|
|
159
|
+
deviceState: 'active',
|
|
160
|
+
authorized: true,
|
|
161
|
+
});
|
|
162
|
+
await YaverFeedback.syncDogfoodAppShortcut();
|
|
163
|
+
expect((NativeModules as any).YaverHotReload.setDogfoodShortcut)
|
|
164
|
+
.toHaveBeenCalledWith(false, 'Dogfood');
|
|
165
|
+
});
|
|
67
166
|
});
|
|
68
167
|
|
|
69
168
|
describe('init()', () => {
|
|
@@ -23,7 +23,7 @@ describe('YaverDeviceDogfood', () => {
|
|
|
23
23
|
|
|
24
24
|
test('keeps identity stable and proves possession rather than trusting the UUID', async () => {
|
|
25
25
|
const store = new MemorySecureStore();
|
|
26
|
-
const client = new YaverDeviceDogfood({ appId: 'io.example.test', secureStore: store, backendUrl: 'https://dogfood.test' });
|
|
26
|
+
const client = new YaverDeviceDogfood({ appId: 'io.example.test', authToken: 'full-yaver-token', secureStore: store, backendUrl: 'https://dogfood.test' });
|
|
27
27
|
const first = await client.enrollmentInfo();
|
|
28
28
|
const second = await client.enrollmentInfo();
|
|
29
29
|
expect(second).toEqual(first);
|
|
@@ -45,7 +45,7 @@ describe('YaverDeviceDogfood', () => {
|
|
|
45
45
|
|
|
46
46
|
test('re-register rotates key and installation while preserving only the logical slot', async () => {
|
|
47
47
|
const store = new MemorySecureStore();
|
|
48
|
-
const client = new YaverDeviceDogfood({ appId: 'io.example.test', secureStore: store, backendUrl: 'https://dogfood.test' });
|
|
48
|
+
const client = new YaverDeviceDogfood({ appId: 'io.example.test', authToken: 'full-yaver-token', secureStore: store, backendUrl: 'https://dogfood.test' });
|
|
49
49
|
const before = await client.enrollmentInfo();
|
|
50
50
|
jest.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
|
51
51
|
if (String(input).endsWith('/dogfood/enroll/start')) return response({ status: 'pending', challenge: 'rotate-nonce' });
|