@tastic/profile 0.1.3 → 0.3.2

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 CHANGED
@@ -54,6 +54,38 @@ see its own doc comment for why that component is generic over your own `Profile
54
54
  inline in the row you tap, with a destructive-delete confirmation. No navigation of its own (no
55
55
  router, no back button) — a host app renders this as a routed screen's body and supplies its own
56
56
  persistence via `profiles`/`onCreate`/`onSave`/`onDelete`.
57
+ - **`loadSharedProfiles` / `saveSharedProfiles` / `isSharedProfileStoreAvailable`** — an *optional*
58
+ cross-app roster, shared between multiple apps of yours via a native iOS App Group (same Apple
59
+ Developer Team, same `"com.apple.security.application-groups"` entitlement value on every
60
+ participating app). This only ever carries the base `Profile` shape above — a host app's own
61
+ extension fields (a key scheme, a control scheme, anything else) are NOT part of it and have to
62
+ keep living in that app's own *local* storage, keyed by profile id, merged onto the shared base
63
+ roster at read time (falling back to some per-app default whenever a shared profile's id isn't
64
+ in that app's own local extension table yet — e.g. it was created on a different app in the
65
+ group). This split is deliberate, not a limitation to work around: it's this package's own
66
+ base-fields-only `Profile` boundary (see above), just applied to storage instead of just to the
67
+ TypeScript type.
68
+
69
+ ```ts
70
+ // App-side sketch — see BoxHockey's or LightCycles' own useProfiles.tsx for a full example.
71
+ const GROUP_ID = 'group.com.yourteam.yourgames'
72
+ const base = isSharedProfileStoreAvailable ? await loadSharedProfiles(GROUP_ID) : localBaseFallback
73
+ const profiles = base.map((p) => ({ ...p, controlScheme: localExtensions[p.id]?.controlScheme ?? DEFAULT_CONTROL_SCHEME }))
74
+ // ...on create/update/delete, write the base fields back with saveSharedProfiles(GROUP_ID, nextBase)
75
+ // and the extension field to the app's own local storage, same as today.
76
+ ```
77
+
78
+ Backed by a small native Expo Module (`ios/TasticProfileModule.swift`) wrapping
79
+ `UserDefaults(suiteName:)` — iOS only (see `expo-module.config.json`'s `"apple"`-only platform
80
+ list); `isSharedProfileStoreAvailable` is `false` on Android/web and on any iOS build that hasn't
81
+ run `expo prebuild` since adding this App Group's entitlement, so a host app always needs its own
82
+ local-storage fallback path regardless of platform. Requires the App Group itself to be declared
83
+ in each consuming app's own Expo config, e.g.:
84
+
85
+ ```json
86
+ // app.json
87
+ { "expo": { "ios": { "entitlements": { "com.apple.security.application-groups": ["group.com.yourteam.yourgames"] } } } }
88
+ ```
57
89
 
58
90
  ## Install (local dev via yalc)
59
91
 
package/dist/index.d.mts CHANGED
@@ -55,4 +55,8 @@ declare const MAX_TAG_LENGTH = 3;
55
55
  declare function isValidTag(value: unknown): value is string;
56
56
  declare function isValidProfile(value: unknown): value is Profile;
57
57
 
58
- export { MAX_PROFILE_NAME_LENGTH, MAX_TAG_LENGTH, type Profile, ProfileChip, ProfilePicker, ProfilesManager, type ProfilesManagerProps, isValidProfile, isValidTag };
58
+ declare const isSharedProfileStoreAvailable: boolean;
59
+ declare function loadSharedProfiles(groupId: string): Promise<Profile[]>;
60
+ declare function saveSharedProfiles(groupId: string, profiles: Profile[]): Promise<void>;
61
+
62
+ export { MAX_PROFILE_NAME_LENGTH, MAX_TAG_LENGTH, type Profile, ProfileChip, ProfilePicker, ProfilesManager, type ProfilesManagerProps, isSharedProfileStoreAvailable, isValidProfile, isValidTag, loadSharedProfiles, saveSharedProfiles };
package/dist/index.d.ts CHANGED
@@ -55,4 +55,8 @@ declare const MAX_TAG_LENGTH = 3;
55
55
  declare function isValidTag(value: unknown): value is string;
56
56
  declare function isValidProfile(value: unknown): value is Profile;
57
57
 
58
- export { MAX_PROFILE_NAME_LENGTH, MAX_TAG_LENGTH, type Profile, ProfileChip, ProfilePicker, ProfilesManager, type ProfilesManagerProps, isValidProfile, isValidTag };
58
+ declare const isSharedProfileStoreAvailable: boolean;
59
+ declare function loadSharedProfiles(groupId: string): Promise<Profile[]>;
60
+ declare function saveSharedProfiles(groupId: string, profiles: Profile[]): Promise<void>;
61
+
62
+ export { MAX_PROFILE_NAME_LENGTH, MAX_TAG_LENGTH, type Profile, ProfileChip, ProfilePicker, ProfilesManager, type ProfilesManagerProps, isSharedProfileStoreAvailable, isValidProfile, isValidTag, loadSharedProfiles, saveSharedProfiles };
package/dist/index.js CHANGED
@@ -25,8 +25,11 @@ __export(index_exports, {
25
25
  ProfileChip: () => ProfileChip,
26
26
  ProfilePicker: () => ProfilePicker,
27
27
  ProfilesManager: () => ProfilesManager,
28
+ isSharedProfileStoreAvailable: () => isSharedProfileStoreAvailable,
28
29
  isValidProfile: () => isValidProfile,
29
- isValidTag: () => isValidTag
30
+ isValidTag: () => isValidTag,
31
+ loadSharedProfiles: () => loadSharedProfiles,
32
+ saveSharedProfiles: () => saveSharedProfiles
30
33
  });
31
34
  module.exports = __toCommonJS(index_exports);
32
35
 
@@ -503,6 +506,40 @@ var styles3 = import_react_native3.StyleSheet.create({
503
506
  fontWeight: "bold"
504
507
  }
505
508
  });
509
+
510
+ // src/sharedProfileStore.ts
511
+ var native = null;
512
+ try {
513
+ const { requireOptionalNativeModule } = require("expo-modules-core");
514
+ native = requireOptionalNativeModule("TasticProfile") ?? null;
515
+ } catch {
516
+ native = null;
517
+ }
518
+ var PROFILES_KEY = "sharedProfiles";
519
+ var isSharedProfileStoreAvailable = native !== null;
520
+ async function loadSharedProfiles(groupId) {
521
+ if (!native) return [];
522
+ let json = null;
523
+ try {
524
+ json = await native.getSharedJSON(groupId, PROFILES_KEY);
525
+ } catch {
526
+ return [];
527
+ }
528
+ if (!json) return [];
529
+ try {
530
+ const parsed = JSON.parse(json);
531
+ return Array.isArray(parsed) ? parsed.filter(isValidProfile) : [];
532
+ } catch {
533
+ return [];
534
+ }
535
+ }
536
+ async function saveSharedProfiles(groupId, profiles) {
537
+ if (!native) return;
538
+ try {
539
+ await native.setSharedJSON(groupId, PROFILES_KEY, JSON.stringify(profiles));
540
+ } catch {
541
+ }
542
+ }
506
543
  // Annotate the CommonJS export names for ESM import in node:
507
544
  0 && (module.exports = {
508
545
  MAX_PROFILE_NAME_LENGTH,
@@ -510,6 +547,9 @@ var styles3 = import_react_native3.StyleSheet.create({
510
547
  ProfileChip,
511
548
  ProfilePicker,
512
549
  ProfilesManager,
550
+ isSharedProfileStoreAvailable,
513
551
  isValidProfile,
514
- isValidTag
552
+ isValidTag,
553
+ loadSharedProfiles,
554
+ saveSharedProfiles
515
555
  });
package/dist/index.mjs CHANGED
@@ -1,3 +1,10 @@
1
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
2
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
3
+ }) : x)(function(x) {
4
+ if (typeof require !== "undefined") return require.apply(this, arguments);
5
+ throw Error('Dynamic require of "' + x + '" is not supported');
6
+ });
7
+
1
8
  // src/ProfileChip.tsx
2
9
  import { getContrastColor } from "@rific/auto-paper";
3
10
  import { StyleSheet, View } from "react-native";
@@ -471,12 +478,49 @@ var styles3 = StyleSheet3.create({
471
478
  fontWeight: "bold"
472
479
  }
473
480
  });
481
+
482
+ // src/sharedProfileStore.ts
483
+ var native = null;
484
+ try {
485
+ const { requireOptionalNativeModule } = __require("expo-modules-core");
486
+ native = requireOptionalNativeModule("TasticProfile") ?? null;
487
+ } catch {
488
+ native = null;
489
+ }
490
+ var PROFILES_KEY = "sharedProfiles";
491
+ var isSharedProfileStoreAvailable = native !== null;
492
+ async function loadSharedProfiles(groupId) {
493
+ if (!native) return [];
494
+ let json = null;
495
+ try {
496
+ json = await native.getSharedJSON(groupId, PROFILES_KEY);
497
+ } catch {
498
+ return [];
499
+ }
500
+ if (!json) return [];
501
+ try {
502
+ const parsed = JSON.parse(json);
503
+ return Array.isArray(parsed) ? parsed.filter(isValidProfile) : [];
504
+ } catch {
505
+ return [];
506
+ }
507
+ }
508
+ async function saveSharedProfiles(groupId, profiles) {
509
+ if (!native) return;
510
+ try {
511
+ await native.setSharedJSON(groupId, PROFILES_KEY, JSON.stringify(profiles));
512
+ } catch {
513
+ }
514
+ }
474
515
  export {
475
516
  MAX_PROFILE_NAME_LENGTH,
476
517
  MAX_TAG_LENGTH,
477
518
  ProfileChip,
478
519
  ProfilePicker,
479
520
  ProfilesManager,
521
+ isSharedProfileStoreAvailable,
480
522
  isValidProfile,
481
- isValidTag
523
+ isValidTag,
524
+ loadSharedProfiles,
525
+ saveSharedProfiles
482
526
  };
@@ -0,0 +1,6 @@
1
+ {
2
+ "platforms": ["apple"],
3
+ "apple": {
4
+ "modules": ["TasticProfileModule"]
5
+ }
6
+ }
@@ -0,0 +1,26 @@
1
+ require 'json'
2
+
3
+ package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
4
+
5
+ Pod::Spec.new do |s|
6
+ s.name = 'TasticProfile'
7
+ s.version = package['version']
8
+ s.summary = package['description']
9
+ s.description = package['description']
10
+ s.license = package['license']
11
+ s.author = package['author']
12
+ s.homepage = package['homepage']
13
+ s.platforms = { :ios => '16.4' }
14
+ s.swift_version = '5.9'
15
+ s.source = { git: package['repository']['url'] }
16
+ s.static_framework = true
17
+
18
+ s.dependency 'ExpoModulesCore'
19
+
20
+ s.pod_target_xcconfig = {
21
+ 'DEFINES_MODULE' => 'YES',
22
+ 'SWIFT_COMPILATION_MODE' => 'wholemodule'
23
+ }
24
+
25
+ s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
26
+ end
@@ -0,0 +1,22 @@
1
+ import ExpoModulesCore
2
+
3
+ // Generic group/key -> JSON-string bridge onto a native iOS App Group's shared UserDefaults suite
4
+ // — the mechanism that actually lets two separate apps (same Apple Developer Team, both declaring
5
+ // the same "com.apple.security.application-groups" entitlement) read and write one common blob of
6
+ // data. Nothing here is @tastic/profile-specific — see src/sharedProfileStore.ts, the one caller,
7
+ // for the profile-roster-shaped API built on top of this. Android/web never register this module
8
+ // at all (see expo-module.config.json's "apple"-only platform list), so a consuming app has to
9
+ // treat "module not present" as a normal, expected case rather than an error.
10
+ public final class TasticProfileModule: Module {
11
+ public func definition() -> ModuleDefinition {
12
+ Name("TasticProfile")
13
+
14
+ AsyncFunction("getSharedJSON") { (groupId: String, key: String) -> String? in
15
+ UserDefaults(suiteName: groupId)?.string(forKey: key)
16
+ }
17
+
18
+ AsyncFunction("setSharedJSON") { (groupId: String, key: String, value: String) -> Void in
19
+ UserDefaults(suiteName: groupId)?.set(value, forKey: key)
20
+ }
21
+ }
22
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tastic/profile",
3
- "version": "0.1.3",
3
+ "version": "0.3.2",
4
4
  "description": "Player profile identity for local-multiplayer React Native games — a saved name/color/tag a player selects per seat, with a picker, an inline-editable roster screen, and a reusable identity badge, deliberately blind to any app-specific fields (control scheme, stats) an app layers on top",
5
5
  "keywords": [
6
6
  "react-native",
@@ -39,13 +39,14 @@
39
39
  "files": [
40
40
  "dist",
41
41
  "src",
42
+ "ios",
43
+ "expo-module.config.json",
42
44
  "!src/__tests__",
43
45
  "!src/__mocks__"
44
46
  ],
45
47
  "scripts": {
46
48
  "build": "tsup src/index.ts --format cjs,esm --dts --clean",
47
49
  "build:watch": "tsup src/index.ts --format cjs,esm --dts --watch",
48
- "ci": "npm run lint && npm test && npm run typecheck && npm run build",
49
50
  "fix": "eslint --fix",
50
51
  "lint": "eslint",
51
52
  "prepublishOnly": "npm run build",
@@ -56,7 +57,8 @@
56
57
  "test": "jest",
57
58
  "test:watch": "jest --watchAll",
58
59
  "typecheck": "tsc --noEmit",
59
- "preversion": "npm run ci"
60
+ "verify": "npm run lint && npm test && npm run typecheck && npm run build",
61
+ "preversion": "npm ci && npm run verify"
60
62
  },
61
63
  "prettier": "@infinitetoken/eslint-config/prettier",
62
64
  "devDependencies": {
@@ -75,6 +77,7 @@
75
77
  "eslint": "^9.39.4",
76
78
  "eslint-plugin-react-hooks": "^7.1.1",
77
79
  "eslint-plugin-react-native": "^5.0.0",
80
+ "expo-modules-core": "^57.0.14",
78
81
  "jest": "^30.4.2",
79
82
  "jest-environment-jsdom": "^30.4.1",
80
83
  "prettier": "^3.8.3",
@@ -94,11 +97,17 @@
94
97
  "@rific/toaster": ">=0.4.0",
95
98
  "@tastic/core": ">=0.1.0",
96
99
  "@tastic/hud": ">=0.1.3",
100
+ "expo-modules-core": ">=1.0.0",
97
101
  "react": ">=19.0.0",
98
102
  "react-native": ">=0.76.0",
99
103
  "react-native-paper": ">=5.0.0",
100
104
  "react-native-safe-area-context": ">=4.0.0"
101
105
  },
106
+ "peerDependenciesMeta": {
107
+ "expo-modules-core": {
108
+ "optional": true
109
+ }
110
+ },
102
111
  "publishConfig": {
103
112
  "access": "public"
104
113
  }
package/src/index.ts CHANGED
@@ -2,4 +2,5 @@ export { ProfileChip } from './ProfileChip'
2
2
  export { ProfilePicker } from './ProfilePicker'
3
3
  export { ProfilesManager, type ProfilesManagerProps } from './ProfilesManager'
4
4
  export { isValidProfile, isValidTag, MAX_PROFILE_NAME_LENGTH, MAX_TAG_LENGTH } from './profilesValidation'
5
+ export { isSharedProfileStoreAvailable, loadSharedProfiles, saveSharedProfiles } from './sharedProfileStore'
5
6
  export type { Profile } from './types'
@@ -0,0 +1,65 @@
1
+ import { isValidProfile } from './profilesValidation'
2
+ import { Profile } from './types'
3
+
4
+ interface NativeTasticProfile {
5
+ getSharedJSON(groupId: string, key: string): Promise<string | null>
6
+ setSharedJSON(groupId: string, key: string, value: string): Promise<void>
7
+ }
8
+
9
+ // Lazily/optionally resolved — this package has no hard dependency on expo-modules-core (it's a
10
+ // peer, provided by whatever Expo app consumes it), and the native module itself only registers on
11
+ // iOS (see ios/TasticProfileModule.swift and expo-module.config.json's "apple"-only platform list).
12
+ // Guarding both the require and the lookup means Android/web, and an iOS build that hasn't run
13
+ // `expo prebuild` since adding this package yet, both degrade to "sharing unavailable" instead of a
14
+ // hard crash.
15
+ let native: NativeTasticProfile | null = null
16
+ try {
17
+ const { requireOptionalNativeModule } = require('expo-modules-core')
18
+ native = (requireOptionalNativeModule('TasticProfile') ?? null) as NativeTasticProfile | null
19
+ } catch {
20
+ native = null
21
+ }
22
+
23
+ const PROFILES_KEY = 'sharedProfiles'
24
+
25
+ // True once at module load — a consuming app uses this to decide whether to fall back to its own
26
+ // local-only storage for the base roster too (see this package's README's App Group section).
27
+ export const isSharedProfileStoreAvailable = native !== null
28
+
29
+ // Reads the shared base-identity roster out of a given App Group suite. Always resolves to an
30
+ // array, never null/throws — an unavailable store, a missing/empty blob, and a corrupt blob all
31
+ // read the same as "nothing shared yet." Entries that fail isValidProfile are dropped individually
32
+ // rather than invalidating the whole roster, since the base fields validated here are the ONLY
33
+ // thing this store is a source of truth for — a host app's own extension fields (a control scheme,
34
+ // anything else) never travel through it at all; see the README for the base/extension split this
35
+ // is meant to be used with.
36
+ export async function loadSharedProfiles(groupId: string): Promise<Profile[]> {
37
+ if (!native) return []
38
+ let json: string | null = null
39
+ try {
40
+ json = await native.getSharedJSON(groupId, PROFILES_KEY)
41
+ } catch {
42
+ return []
43
+ }
44
+ if (!json) return []
45
+ try {
46
+ const parsed: unknown = JSON.parse(json)
47
+ return Array.isArray(parsed) ? parsed.filter(isValidProfile) : []
48
+ } catch {
49
+ return []
50
+ }
51
+ }
52
+
53
+ // Overwrites the entire shared roster — same whole-blob-write discipline a host app's own
54
+ // AsyncStorage-backed profile store already uses, not an incremental per-profile update. A write
55
+ // failure (store unavailable, or some other native error) is swallowed rather than thrown: the
56
+ // caller's own local state is already the source of truth for what just changed, and this is a
57
+ // best-effort mirror of it, not the other way around.
58
+ export async function saveSharedProfiles(groupId: string, profiles: Profile[]): Promise<void> {
59
+ if (!native) return
60
+ try {
61
+ await native.setSharedJSON(groupId, PROFILES_KEY, JSON.stringify(profiles))
62
+ } catch {
63
+ // Best-effort — see doc comment above.
64
+ }
65
+ }