@webority/mobile-core

中文文档
0.119.0发布于 2026/11/03SEE LICENSE IN LICENSE.txtTypeScriptESM + CJS

Platform layer for Webority React Native apps: HTTP clients, auth/token storage, config, logging, formatters, validators, network status, permissions, storage, version checks and over-the-air updates. No UI.

原始使用文档
文档目录7 节
  1. Import paths
  2. Offline copy
  3. Notification buttons
  4. Build number header and forced updates
  5. Quiet requests and log redaction
  6. App lock
  7. Metro bridge

@webority/mobile-core

The platform layer for Webority React Native apps: HTTP clients, auth and token storage, config, logging, formatters, validators, network status, permissions, storage, image compression and version checks. No UI — components live in @webority/mobile-ui.

npm install @webority/mobile-core

Runtime dependencies: axios and libphonenumber-js. The required peers are expo, expo-application, expo-network, expo-secure-store (the HTTP client reads the bearer token from it) and expo-sqlite. Every other native peer is optional and reached only through its own import path below, so the package installs and runs without any of them. react-native-keychain is no longer used; token storage moved to expo-secure-store.

Full documentation lives in the repository's docs/ directory.

Import paths

Anything needing a native package lives behind its own path, so an app only bundles what it imports:

import { Permissions } from '@webority/mobile-core/permissions';
import { Geolocation } from '@webority/mobile-core/geolocation';
import { openDatabase } from '@webority/mobile-core/sqlite';
import { downloadFile } from '@webority/mobile-core/download';
import { shareFile } from '@webority/mobile-core/share';
import { fetchAttachment, fetchPreviewAttachment, previewKind } from '@webority/mobile-core/attachments';
import { getOrCreateInstallationId, clearTokensOnFirstLaunch } from '@webority/mobile-core/installation';
import { requestReviewAfterSuccess } from '@webority/mobile-core/review';
import { initPush, enablePush, usePush } from '@webority/mobile-core/push';
import { definePushActionTask } from '@webority/mobile-core/push/task';
import { compressImage } from '@webority/mobile-core/compressor';
import { initOtaUpdates, useOtaUpdates } from '@webority/mobile-core/updates';
import { createApiBaseQuery, createAppApi, setupApiListeners } from '@webority/mobile-core/query';

Metro resolves requires statically, so anything reachable from the main barrel is bundled for every consumer. Keeping these off it is what makes their peers genuinely optional.

/query is the RTK Query layer; it needs @reduxjs/toolkit (2.12 or later) and react-redux, both optional peers. One createApi per app, built with createAppApi and the base query over the library's clients, so refresh, retry, the offline check and the extractApiError failure shape stay in one place. Never a fetchBaseQuery.

// services/api/apiSlice.ts
import { publicClient, userClient } from '@webority/mobile-core';
import { createApiBaseQuery, createAppApi, buildListQuery, providesPagedList,
  type PagedDataObject } from '@webority/mobile-core/query';

export const apiSlice = createAppApi({
  reducerPath: 'api',
  baseQuery: createApiBaseQuery({ client: userClient, publicClient }),
  tagTypes: ['Project'],
  endpoints: (build) => ({
    getProjects: build.query<PagedDataObject<Project>, { page: number }>({
      query: ({ page }) => ({ url: '/api/projects', params: buildListQuery({ page, pageSize: 20 }) }),
      providesTags: providesPagedList('Project')
    })
  })
});
// store.ts: add apiSlice.reducer and apiSlice.middleware, then setupApiListeners(store.dispatch)

setupApiListeners maps the app returning to the foreground (AppState) to RTK Query's focus, and this library's network status to online and offline; RTK's own defaults listen to window events that never fire on a device. A call marked silent: true, or an endpoint with extraOptions: { silent: true }, carries silent on action.meta.baseQueryMeta, which the error toast middleware skips.

/push registers the phone for push notifications. It needs expo-notifications, an optional peer, and reports unavailable without it or in Expo Go. Call initPush once at startup with getInstallationId, register, unregister and onOpen; enablePush asks permission, sends the native token (Fcm on Android, Apns on iOS) and the bundle id to register, and re-registers when the token changes; disablePush calls unregister and turns push off only once it succeeds (a failure rejects and leaves push on). resetPush turns push off on the phone with no server call and drops the native token, for a sign-out with no session left. onOpen receives the tapped notification's data as strings, including the tap that launched the app. usePush gives a settings switch its status and enabled. The library does not remember the switch: an app that keeps push on calls enablePush at each start. setBadgeCount(count) sets the app icon badge. dismissAllNotifications() clears every notification from the notification tray; it never throws, and logs a failure at warn.

  • enablePush({ prompt: false }) never shows the permission prompt: a phone already granted registers silently, and any other resolves with its status (undetermined when the person has not been asked yet, denied when refused and the prompt can still show, blocked when only Settings can turn it on). Call it at sign-in, and call enablePush() when the person reaches a moment where asking makes sense.
  • Every registered device carries timeZone, the phone's IANA zone (Asia/Kolkata), or null when the platform reports none, so the server can send at the person's local time.
  • Every registered device carries apnsEnvironment: Development or Production on iOS, read from the build's aps-environment (expo-application), so the server sends each phone to the APNs gateway its signing accepts. It is null on Android, and on iOS when it cannot be read (the simulator); the server then uses its own setting.
  • onReceive(handler) calls handler({ notificationId, title, body, data }) for each notification that arrives while the app is open, shown or not, so the app can refresh a list or an unread count. It returns a function that removes the handler. A notification arriving in the background does not reach it; its tap still goes to onOpen.

Offline copy

createPersistedQueries keeps the answers of chosen endpoints on the phone, so a screen opened with no connection shows the last data with the time it was loaded. Listed endpoints are saved to Storage when they load; restore puts them back once at start.

  1. Pass extractRehydrationInfo to createAppApi.
  2. Add middleware after the api's middleware.
  3. Call restore(store.dispatch) once after the store is created.
  4. Call clear() on sign-out, so the next person on the phone never sees the last person's data.
// services/api/offlineQueries.ts
export const offlineQueries = createPersistedQueries({
  storageKey: 'offline.queries',
  endpoints: ['getProjects']
});

// services/api/apiSlice.ts: add extractRehydrationInfo: offlineQueries.extractRehydrationInfo
// store.ts: reducer: { [apiSlice.reducerPath]: apiSlice.reducer }
//           middleware: (getDefault) => getDefault().concat(apiSlice.middleware, offlineQueries.middleware)
offlineQueries.restore(store.dispatch); // once, after the store exists
// sign-out
await offlineQueries.clear();

A restored answer shows at once without a fetch, so set refetchOnMountOrArgChange: true on each kept endpoint: a screen that mounts then asks the server again and refreshes the restored data.

Notification buttons

A push names a category; the category's buttons are registered on the phone. Register them at every start, with the same ids on both platforms:

import { setCategories, notifyLocal } from '@webority/mobile-core/push';

await setCategories([
  {
    id: 'approval',
    actions: [
      { id: 'approve', title: 'Approve', opensApp: false },
      { id: 'reject', title: 'Reject', destructive: true }
    ]
  },
  {
    id: 'reply',
    actions: [
      {
        id: 'reply',
        title: 'Reply',
        opensApp: false,
        textInput: { submitTitle: 'Send', placeholder: 'Write a reply' }
      }
    ]
  }
]);

opensApp defaults to true; destructive and authenticationRequired (unlock first) are iOS only. A button tap arrives as { actionId, userText, data, notificationId }: userText is the reply text, data the push data as strings, notificationId the notification's identifier on the phone. A plain tap still goes to onOpen; a button tap with no handler opens like a plain tap.

To receive button taps in every app state, the killed app included, call definePushActionTask once at module scope in the entry file, before the root component registers. It needs expo-task-manager, an optional peer (npx expo install expo-task-manager), so it lives on its own path:

// index.js
import { definePushActionTask } from '@webority/mobile-core/push/task';
import { notifyLocal } from '@webority/mobile-core/push';

definePushActionTask(async ({ actionId, userText, data }) => {
  try {
    await settle(actionId, userText, data);
  } catch {
    await notifyLocal({ title: 'Could not approve', body: 'Open the app to try again.' });
  }
});

The handler's promise is awaited, so its request can finish before the background work ends. onAction(handler) from /push sets the same handler for taps while JavaScript is running and returns a function that removes it. A response reported twice within ten seconds (the launch read, the listener and the Android task can each report it) runs once.

  • Android: the background notification task runs the handler for a button tap on a backgrounded or killed app. Buttons appear only on notifications expo-notifications draws (for example notifyLocal). An app that draws its own notifications natively from data-only pushes must build each button's intent with expo's NotificationsService.createNotificationResponseIntent, so the tap reaches this handler.
  • iOS: a button with opensApp: false launches the app in the background; the response reaches the listener definePushActionTask sets up. The task is not registered on iOS.

Storage and network status stay on the main barrel but load no package on their own. Wire them at startup with setStorageImplementation and setNetworkStatusImplementation if you want them.

Build number header and forced updates

Both HTTP clients send App-Build: <integer> on every request: the app's whole build number (VERSION_INTERNAL, Android versionCode, iOS CFBundleVersion). The API compares it with its minimum and answers an older build with 426 client_update_required. The number comes from initWebority({ appBuild }) (setConfig({ appBuild })), else from expo-application's nativeBuildVersion. Without a whole number the header is left off, and the server never refuses a request without it. Client-Build (<appVersion>+<commit>) is unchanged.

Both HTTP clients also send Client-App: <appId> when the config has an app id (initWebority({ appId })). It is the app's store identifier, so the API can tell apps of the same client kind apart.

The version floor comes only from the Min-App-Version response header. When a reply carries it, useVersionCheck turns isUpdateRequired on for a build below it. A 426 client_update_required does not move the floor, so a 426 without that header leaves the floor where it is. A 426 is never retried.

An app moves to this contract by running webority upgrade 0.93.0. It renames the old X- header strings to the plain names, ApiError.detail to diagnostic and detail.signOutReason to detail.code, and lists each removed option (correlationIdHeader, signOutReasonHeader) and constant for a person to delete.

Quiet requests and log redaction

Both HTTP clients log a failed response through Logger.error. For a call whose failure the app handles itself, pass skipErrorLog: true in the axios config and the clients stay silent:

await userClient.get('/status', { skipErrorLog: true });

Logger masks secrets in every message and argument before any sink sees them: Bearer tokens, JWT-shaped strings, and the values of keys named token, accessToken, refreshToken, password and authorization (any case) in objects and JSON strings.

configureLogger({ redactKeys }) adds app keys to the built-in masked list and never removes one. Logger.getBreadcrumbs() returns the last 50 breadcrumbs, oldest first, already redacted; configureLogger({ breadcrumbLimit }) changes the cap. initWebority takes a required remoteLogger: the sink, or null for a build without crash reporting. It also takes a required appId, the store id (APP_ID in appsettings.json, with no environment suffix), and a required environment. It adds that environment's suffix (.development, .staging, none for Production) and refuses to start when the result differs from the installed app's id.

App lock

@webority/mobile-core/appLock locks the app when a signed-in person returns after the lock delay (five minutes by default). useAppLock returns what a settings screen and the lock screen need:

import { useAppLock } from '@webority/mobile-core/appLock';

const { locked, enabled, biometricAvailable, availabilityKnown, setEnabled, unlock } = useAppLock({ active: isSignedIn });
  • availabilityKnown is false until the first biometric check settles. Until then biometricAvailable is not an answer yet, so a screen should wait before it shows the lock as unavailable or disables its switch.
  • setEnabled(next) and unlock(reason) return an AppLockResult: { ok: true }, or { ok: false, reason } with the reason cancelled, lockout, not-enrolled, not-available or failed.
  • policy overrides the delay and the two storage keys; the defaults are used for any key left out.

Metro bridge

@webority/mobile-core/metro builds the development bridge that a Metro dev tool reads as globalThis.__METRO_BRIDGE__. The app assigns it under __DEV__; the builder adds no check of its own.

import { createMetroBridge } from '@webority/mobile-core/metro';

if (__DEV__) {
  globalThis.__METRO_BRIDGE__ = createMetroBridge({ store, navigationRef, signIn, signOut, isSignedIn });
}
  • store: the Redux store, read through getState and written through dispatch.
  • navigationRef: the navigation container's ref (isReady, getRootState, canGoBack, goBack, navigate).
  • signIn(request) resolves { codeRequired }; signOut() resolves once the session is cleared; isSignedIn() returns a boolean.
  • commands (optional): extra named commands. A name that is signIn, signOut or navigate throws when the bridge is created.

The bridge exposes commands (signIn, signOut, navigate, and the extras), navigation (getState, which is null until the navigator is ready, and goBack, which returns { wentBack }), redux, auth (isSignedIn) and push (notifyLocal and deviceToken, the device's push token).

版本与发布记录

@webority/mobile-core 共发布 101 个版本,首次发布于 2026/10/04;过去 12 个月发布了 101 个版本。

版本发布日期距今
0.119.0latest2026/11/03今天
0.118.02026/11/03今天
0.117.02026/11/03今天
0.116.02026/11/02今天
0.115.02026/11/021 天前
0.114.02026/11/021 天前

关于 @webority/mobile-core 的常见问题

@webority/mobile-core 是什么?

webbority React Native应用的平台层:HTTP客户端、认证/令牌存储、配置、日志、格式化器、验证器、网络状态、权限、存储、版本检查和无线更新。没有用户界面。

如何安装 @webority/mobile-core?

在项目目录运行 npm install @webority/mobile-core,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:expo(>=57.0.0)、react(^19.1.0)、expo-crypto(>=57.0.0)、expo-sqlite(>=57.0.0)、expo-network(>=57.0.0)、react-native(>=0.81.0)、expo-application(>=57.0.0)、expo-secure-store(>=57.0.0)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@webority/mobile-core 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@webority/mobile-core 支持 ESM 还是 CommonJS?

同时提供 ES Module 与 CommonJS 入口,import 和 require() 都可以使用,打包工具会自动选择合适的版本。

@webority/mobile-core 可以商用吗?

@webority/mobile-core 使用 SEE LICENSE IN LICENSE.txt 许可。许可条款写在包内的独立文件中,请阅读该文件确认可否商用。以上为概括,不构成法律意见,请以许可原文为准。

@webority/mobile-core 还在维护吗?

最新版本 0.119.0 发布于 2026/11/03(今天);至今共发布 101 个版本,其中过去 12 个月发布了 101 个;首次发布于 2026/10/04。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。