项目文件夹

文件
wehub-resource-sync 2114b14ee0
Sync main into demo / sync (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:35:26 +08:00

283 行
8.5 KiB
TypeScript

import type { AppId } from './types';
import { getAppManifest } from './data/appRegistry';
import {
isPermissionId,
PERMISSION_IDS,
type PermissionId,
type PermissionRequestOptions,
type PermissionSnapshot,
type PermissionStatus,
} from './permissions';
import { mutateOsState, useOsStateStore } from './OsStateStore';
import { now as timeNow } from './TimeService';
type Listener = (snapshot: PermissionSnapshot) => void;
/** Partial map — not every PermissionId key is required. */
type PermissionResultMap = Partial<Record<PermissionId, PermissionStatus>>;
interface PermissionRequestResultDetail {
requestId: string;
results?: PermissionResultMap;
}
interface PendingRequest {
appId: AppId;
permIds: PermissionId[];
baseResults: PermissionResultMap;
resolve: (results: PermissionResultMap) => void;
}
export const PERMISSION_REQUEST_OPEN_EVENT = 'permission-request-open';
export const PERMISSION_REQUEST_RESULT_EVENT = 'permission-request-result';
export const PERMISSION_REQUEST_BACK_EVENT = 'permission-request-back';
const VALID_STATUSES: PermissionStatus[] = ['not_requested', 'granted', 'denied', 'denied_forever'];
const listeners = new Set<Listener>();
const pendingRequests = new Map<string, PendingRequest>();
let requestQueue: Promise<void> = Promise.resolve();
let cachedSnapshot: PermissionSnapshot | null = null;
function normalizeStatus(value: unknown): PermissionStatus | null {
if (typeof value !== 'string') return null;
return VALID_STATUSES.includes(value as PermissionStatus) ? (value as PermissionStatus) : null;
}
function uniqPermissionIds(permIds: PermissionId[]): PermissionId[] {
return Array.from(new Set(permIds));
}
function getGrants(): PermissionSnapshot['grants'] {
return useOsStateStore.getState().permissions as PermissionSnapshot['grants'];
}
function readStatus(appId: AppId, permissionId: PermissionId): PermissionStatus {
return getGrants()[appId]?.[permissionId] ?? 'not_requested';
}
function writeStatus(appId: AppId, permissionId: PermissionId, status: PermissionStatus): boolean {
const prev = readStatus(appId, permissionId);
if (prev === status) return false;
const current = getGrants();
const appGrants = { ...(current[appId] || {}) };
appGrants[permissionId] = status;
mutateOsState((state) => {
state.permissions = {
...current,
[appId]: appGrants,
};
});
return true;
}
function computeSnapshot(): PermissionSnapshot {
return { grants: getGrants() };
}
function normalizeRequestPermissionIds(permIds: PermissionId[]): PermissionId[] {
const valid = permIds.filter((permId): permId is PermissionId => PERMISSION_IDS.includes(permId));
return uniqPermissionIds(valid);
}
function genRequestId(): string {
try {
const c = (globalThis as any).crypto;
if (c && typeof c.randomUUID === 'function') return c.randomUUID();
} catch {
// ignore
}
return `perm_${timeNow()}_${Math.random().toString(16).slice(2)}`;
}
function resolvePermissionRequest(detail: PermissionRequestResultDetail | undefined) {
if (!detail?.requestId) return;
const pending = pendingRequests.get(detail.requestId);
if (!pending) return;
pendingRequests.delete(detail.requestId);
const mergedResults: PermissionResultMap = { ...pending.baseResults };
let changed = false;
for (const permId of pending.permIds) {
const prev = readStatus(pending.appId, permId);
const fromDialog = detail.results?.[permId];
const normalized = normalizeStatus(fromDialog);
let next: PermissionStatus = normalized || 'denied';
if (next === 'not_requested') next = 'denied';
if (next === 'denied' && prev === 'denied') {
next = 'denied_forever';
}
if (writeStatus(pending.appId, permId, next)) {
changed = true;
}
mergedResults[permId] = next;
}
pending.resolve(mergedResults);
}
async function requestPermissionsInternal(
appId: AppId,
permIds: PermissionId[],
options?: PermissionRequestOptions,
): Promise<PermissionResultMap> {
const normalizedPermIds = normalizeRequestPermissionIds(permIds);
const instantResults: PermissionResultMap = {};
const needPrompt: PermissionId[] = [];
for (const permId of normalizedPermIds) {
const status = readStatus(appId, permId);
if (status === 'granted' || status === 'denied_forever') {
instantResults[permId] = status;
continue;
}
needPrompt.push(permId);
}
if (needPrompt.length === 0) {
return instantResults;
}
const requestId = genRequestId();
const PERMISSION_TIMEOUT_MS = 60_000;
return new Promise((resolve) => {
pendingRequests.set(requestId, {
appId,
permIds: needPrompt,
baseResults: instantResults,
resolve,
});
setTimeout(() => {
const pending = pendingRequests.get(requestId);
if (!pending) return; // already resolved
pendingRequests.delete(requestId);
const results: PermissionResultMap = { ...pending.baseResults };
let changed = false;
for (const permId of pending.permIds) {
if (!(permId in results)) {
const prev = readStatus(pending.appId, permId);
const next: PermissionStatus = prev === 'denied' ? 'denied_forever' : 'denied';
if (writeStatus(pending.appId, permId, next)) changed = true;
results[permId] = next;
}
}
void changed;
pending.resolve(results);
}, PERMISSION_TIMEOUT_MS);
window.dispatchEvent(
new CustomEvent(PERMISSION_REQUEST_OPEN_EVENT, {
detail: {
requestId,
appId,
permIds: needPrompt,
rationale: options?.rationale,
},
}),
);
});
}
function enqueueRequest<T>(task: () => Promise<T>): Promise<T> {
const nextRun = requestQueue.then(task, task);
requestQueue = nextRun.then(
() => undefined,
() => undefined,
);
return nextRun;
}
if (typeof window !== 'undefined') {
window.addEventListener(PERMISSION_REQUEST_RESULT_EVENT, (event: Event) => {
resolvePermissionRequest((event as CustomEvent<PermissionRequestResultDetail>).detail);
});
}
;(useOsStateStore.subscribe as any)(
(state: { permissions: PermissionSnapshot['grants'] }) => state.permissions,
(permissions: PermissionSnapshot['grants']) => {
cachedSnapshot = { grants: permissions as PermissionSnapshot['grants'] };
for (const listener of listeners) listener(cachedSnapshot);
},
);
export const PermissionService = {
getState(): PermissionSnapshot {
if (!cachedSnapshot) cachedSnapshot = computeSnapshot();
return cachedSnapshot;
},
subscribe(listener: Listener): () => void {
listeners.add(listener);
listener(this.getState());
return () => listeners.delete(listener);
},
checkPermission(appId: AppId, permissionId: PermissionId): PermissionStatus {
return readStatus(appId, permissionId);
},
checkPermissions(appId: AppId, permissionIds: PermissionId[]): PermissionResultMap {
const results: PermissionResultMap = {};
for (const permissionId of normalizeRequestPermissionIds(permissionIds)) {
results[permissionId] = readStatus(appId, permissionId);
}
return results;
},
requestPermissions(
appId: AppId,
permissionIds: PermissionId[],
options?: PermissionRequestOptions,
): Promise<PermissionResultMap> {
return enqueueRequest(() => requestPermissionsInternal(appId, permissionIds, options));
},
grantPermission(appId: AppId, permissionId: PermissionId): void {
if (!writeStatus(appId, permissionId, 'granted')) return;
},
revokePermission(appId: AppId, permissionId: PermissionId): void {
if (!writeStatus(appId, permissionId, 'denied')) return;
},
revokeAll(appId: AppId): void {
const existing = getGrants()[appId] || {};
const declared = this.getDeclaredPermissions(appId);
const all = uniqPermissionIds([
...(Object.keys(existing).filter(isPermissionId) as PermissionId[]),
...declared,
]);
if (all.length === 0) return;
let changed = false;
for (const permissionId of all) {
if (writeStatus(appId, permissionId, 'denied')) changed = true;
}
if (!changed) return;
},
getAppsWithPermissions(): AppId[] {
const grants = getGrants();
return Object.keys(grants).filter((appId) => {
const appGrants = grants[appId as AppId];
return !!appGrants && Object.keys(appGrants).length > 0;
});
},
getDeclaredPermissions(appId: AppId): PermissionId[] {
const manifest = getAppManifest(appId);
const declared = manifest?.permissions ?? [];
return normalizeRequestPermissionIds(declared);
},
};
export default PermissionService;