Skip to content

Commit 73eabb9

Browse files
authored
Merge pull request #15 from nativescript-community/feat/generated-native-bindings
feat: generate native bindings from the ambient typings
2 parents 9169234 + 0fe2b62 commit 73eabb9

511 files changed

Lines changed: 32339 additions & 14201 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,4 +56,7 @@ packages/**/*.metadata.json
5656

5757
/blueprint.md
5858

59-
*.tsbuildinfo
59+
!demo-snippets/hooks
60+
!demo-snippets/hooks/**
61+
62+
*.tsbuildinfo

.vscode/settings.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,5 +47,6 @@
4747
"vertical-alignment",
4848
"placeholder-color",
4949
"variant"
50-
]
50+
],
51+
"js/ts.tsdk.path": "node_modules/typescript/lib"
5152
}

CONTRIBUTING.md

Lines changed: 195 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,206 @@
1-
## android typings
1+
# Contributing
22

3+
## What is generated and what is not
4+
5+
| Path | Owner |
6+
| --- | --- |
7+
| `src/ui-massifmaps/typings/*.d.ts` | generated — `npm run typings` |
8+
| `src/ui-massifmaps/bindings/**` | generated — `npm run bindings` |
9+
| `packages/ui-massifmaps/platforms/{android,ios}/native-api-usage.json` | generated — `npm run bindings` |
10+
| everything else under `src/ui-massifmaps/` | hand-written wrappers |
11+
| `packages/ui-massifmaps/**` (except the two files above) | build output — `npm run build` |
12+
13+
Generated files carry a `GENERATED FILE - do not edit by hand` header. Editing one is always the
14+
wrong fix: change the generator, or change the hand-written wrapper it feeds.
15+
16+
Both scripts take `-h`:
17+
18+
```sh
19+
node scripts/typings/index.mjs -h
20+
npm run bindings -- -h
321
```
4-
java -jar build/libs/dts-generator.jar -skip-declarations -input /Volumes/data/dev/massif-maps/MassifMaps/dist/android/massif.jar
22+
23+
## Updating to a new SDK release
24+
25+
1. **Point the plugin at the new SDK.**
26+
- android: bump the `massifSDKVersion` default in
27+
`packages/ui-massifmaps/platforms/android/include.gradle`.
28+
- iOS: replace `packages/ui-massifmaps/platforms/ios/MassifMaps.xcframework/`.
29+
30+
2. **Regenerate the ambient typings.**
31+
32+
```sh
33+
npm run typings
34+
```
35+
36+
Android reads the maven coordinates and default version straight out of `include.gradle`, so
37+
the typings can never be generated against a version the plugin does not link. It resolves the
38+
`.aar` from the gradle cache or downloads it, then runs `dts-generator.jar` — running
39+
`ns prepare android` in `demo-svelte` first if the jar is not there yet.
40+
41+
iOS is a full `ns typings ios` build in `demo-svelte` and takes minutes cold. `--no-build`
42+
reuses whatever is already in `demo-svelte/typings/ios`.
43+
44+
`ns.massifmaps.android.d.ts` (the plugin's own java additions) needs
45+
`packages/ui-massifmaps/platforms/android/ui_massifmaps.aar`, which only exists after a demo
46+
android build. Without it the script says so and skips that file; `--skip-additions` asks for
47+
the same thing explicitly.
48+
49+
3. **Regenerate the binding tables and the native-api-usage files.**
50+
51+
```sh
52+
npm run bindings
53+
```
54+
55+
4. **See what the new SDK brought in.**
56+
57+
```sh
58+
npm run bindings -- --coverage # unforwarded methods, and per-platform gaps
59+
npm run bindings -- --audit # hand-written classes vs the model
60+
```
61+
62+
`--coverage` reports four kinds of gap besides the unforwarded methods, all of which are
63+
silent at build time:
64+
65+
- a binding table bound in `index.android.ts` but not `index.ios.ts`, or the reverse —
66+
the property then does nothing on the other platform instead of failing;
67+
- a wrapper class implemented on one platform only;
68+
- a class both platforms implement that no `.d.ts` declares, so nothing importing the
69+
plugin can see it;
70+
- an SDK class with a generated table and no wrapper at all.
71+
72+
It exits non-zero on any of those, except a class whose SDK counterpart is android-only
73+
to begin with — that one is reported and marked, not counted.
74+
75+
5. **Typecheck, then run both demos.**
76+
77+
```sh
78+
./node_modules/.bin/tsc --build packages/ui-massifmaps/tsconfig.json
79+
npm run demo.svelte.android
80+
npm run demo.svelte.ios
81+
```
82+
83+
## When the SDK adds a class
84+
85+
`--coverage` lists every SDK class with no binding table. For each one worth exposing:
86+
87+
```sh
88+
npm run bindings -- --scaffold RasterTileFilter # or --scaffold-all
89+
npm run bindings -- --scaffold CelestialArc, CelestialLayer, CelestialSprite # several at once
590
```
691

7-
### clean up typings
8-
* regexp:```declare module com \{\n\t*export module massifmaps \{\n\t*export module \w* \{\n\t*export class \w*JNI extends [\w.]* \{([^}]|\n)*\}\n\t*\}\n\t*\}\n\}```
9-
* regexp:```declare module com \{\n\t*export module massifmaps \{\n\t*export module \w* \{\n\t*export module \w* \{\n\t*export class \w*JNI extends [\w.]* \{([^}]|\n)*\}\n\t*\}\n\t*\}\n\t*\}\n\}```
10-
* regexp:```\t*public (delete|swigGetDirectorObject|swigGetClassName|swigGetClassName|swigDirectorDisconnect|swigReleaseOwnership|finalize|swigTakeOwnership)\(\): (void|number|string|any);\n```
11-
* regexp:```\t*public static (swigCreatePolymorphicInstance|getCPtr)\(.*\): .*;\n```
12-
* regexp:```\t*public (swigCMemOwn): .*;\n```
92+
Scaffolds land in `scaffold/` (never `src/`) as three files per class — `<Name>.d.ts`,
93+
`<Name>.android.ts` and `<Name>.ios.ts`, the same three legs every module in `src/` has. The
94+
`.d.ts` carries the `<Name>Options` interface and the `Accessors`/`Methods` merge, so a scaffolded
95+
class is not missing its public types.
1396

14-
<!-- * regexp: ```/export class .*?JNI {(.|[\r\n])*?}//```
15-
* regexp: ```/export module .*? {([\t\r\n])*?}//``` twice
16-
* regexp: ```/declare module com {([\t\r\n])*?}/``` -->
97+
A class taking a listener — `setCelestialEventListener(CelestialEventListener)` — also gets the
98+
glue, which is the same shape every time: a plain TS interface in the `.d.ts`, the
99+
`com.nativescript.massifmaps.additions.*` subclass wired up on android, an `NSMSF*` delegate class
100+
on iOS, one stub per callback, and an `exclude` on `bindNative` so the synthesised accessor cannot
101+
bypass any of it. Only the argument marshalling is left as a TODO.
17102

18-
## ios typings
103+
The native halves come with it when the plugin does not already ship them:
19104

20-
run in the demo app
21105
```
22-
TNS_TYPESCRIPT_DECLARATIONS_PATH="$(pwd)/typings" tns build ios --bundle
106+
scaffold/platforms/android/java/com/nativescript/massifmaps/additions/<X>.java
107+
scaffold/platforms/ios/src/NSMSF<X>.h
108+
scaffold/platforms/ios/src/NSMSF<X>.mm
23109
```
24110

25-
### clean up typings
111+
Both are the same main-thread hop every existing listener does — `SynchronousHandler.postAndWait`
112+
on android, `dispatch_sync(dispatch_get_main_queue(), ...)` on iOS — around a `<name>Threaded`
113+
stub the TypeScript delegate overrides. The ObjC selectors are read out of the SDK's own framework
114+
header, labels included, because an override that misses a label compiles and then silently never
115+
fires. Copy the files into `packages/ui-massifmaps/platforms/`, then add the `#import` line the
116+
command prints to `platforms/ios/src/MassifMapsAdditions.h` — the clang module does not see a
117+
header the umbrella does not name.
118+
119+
Pick the right native constructor, fill in the TODOs, move all three into
120+
`src/ui-massifmaps/<pkg>/`, and export them from that package's index. Then re-run
121+
`npm run bindings` so the class reaches `native-api-usage.json`, and
122+
`npm run bindings -- --coverage` to confirm nothing is left on one platform only.
123+
124+
A class android has and iOS does not is reported at the end of `npm run bindings` and is
125+
deliberately not bound — the binding tables only cover what both platforms have.
126+
127+
## When the SDK changes an existing API
128+
129+
Regenerate the typings first, then:
26130

27-
* regexp: ```/description\(\): string;//```
28-
* regexp: ```/hash\(\): number;//```
29-
* regexp: ```/var:/variant:/```
131+
```sh
132+
npm run bindings -- --audit
133+
```
134+
135+
It reports three things:
136+
137+
- **properties declared with no matching native accessor** — the SDK renamed or dropped the
138+
accessor behind a `@nativeProperty`. A wrapper targeting one of our own `NSMSF*`/`additions`
139+
subclasses shows up here legitimately: the SDK base class genuinely does not declare those
140+
members.
141+
- **properties whose decorator disagrees with the native type** — e.g. a plain `@nativeProperty`
142+
where the accessor now returns a `Color` and needs `colorConverter`.
143+
- **classes with native accessors the plugin does not expose** — new API on a class already wrapped.
144+
145+
To move a hand-written class onto the generated table:
146+
147+
```sh
148+
npm run bindings -- --migrate RasterTileLayer VectorTileLayer # or --migrate-all
149+
```
150+
151+
That attaches `METHODS`/`ACCESSORS`/`SELECTORS` and prunes the `@nativeProperty` declarations the
152+
table now covers (`--no-prune` keeps them). Review the diff: a class that marshals some members
153+
itself needs an `exclude` for them.
154+
155+
## Enums
156+
157+
Enums are not bound. They are hand-written constant objects that read the native value:
158+
159+
```ts
160+
export const RasterTileFilterMode = {
161+
get RASTER_TILE_FILTER_MODE_NEAREST() {
162+
return com.massifmaps.layers.RasterTileFilterMode.RASTER_TILE_FILTER_MODE_NEAREST;
163+
},
164+
...
165+
};
166+
```
167+
168+
That works because android compiles a SWIG enum to a real java class with static fields. iOS
169+
compiles the same enum to a `const enum` — plain integers, nothing to look up.
170+
171+
The android consequence: **every SWIG enum needs a `native-api-usage.json` entry**, or the metadata
172+
filter strips the class and the getter returns `undefined`. `npm run bindings` emits all of them —
173+
do not maintain the list by hand. Whitelisting only the enums a bound method mentions is not
174+
enough: `MBTilesScheme` appears solely in a constructor signature, which the parser drops as noise.
175+
176+
## native-api-usage.json
177+
178+
Both files come out of the same model, so they cannot drift apart:
179+
180+
- android matches `package:Class``com.massifmaps.layers:RasterTileLayer`
181+
- iOS matches `clangModule:ObjCInterface``MassifMaps:MSFRasterTileLayer`
182+
183+
`npm run bindings -- --no-api-usage` skips them.
184+
185+
A consuming app has its own `App_Resources/Android/native-api-usage.json` with
186+
`"whitelist-plugins-usages": true`, which is what pulls this plugin's `uses` list in. An app only
187+
needs its own entries for SDK classes it touches directly, outside the plugin's wrappers.
188+
189+
## Fallback: generating typings by hand
190+
191+
Only if `scripts/typings` fails. The cleanup passes it does — stripping `*JNI` classes, swig
192+
plumbing (`delete`, `swigGetDirectorObject`, `swigCreatePolymorphicInstance`, `swigCMemOwn`, …),
193+
ObjC `description()`/`hash()`, and reserved parameter names — then have to be redone by hand. See
194+
`scripts/typings/swig.mjs` for what they are.
195+
196+
android:
197+
198+
```sh
199+
java -jar build/libs/dts-generator.jar -skip-declarations -input path/to/massif.jar
200+
```
201+
202+
iOS, from `demo-svelte`:
203+
204+
```sh
205+
TNS_TYPESCRIPT_DECLARATIONS_PATH="$(pwd)/typings" ns build ios --bundle
206+
```
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
// @ts-nocheck - a NativeScript build hook, run by node, not part of the app program
2+
const fs = require('fs');
3+
const path = require('path');
4+
5+
const MARKER_START = '// >>> template-snippet: plugin include-settings.gradle (generated, do not edit)';
6+
const MARKER_END = '// <<< template-snippet: plugin include-settings.gradle';
7+
8+
const INCLUDE_SETTINGS_RELATIVE_PATH = path.join('platforms', 'android', 'include-settings.gradle');
9+
10+
function toGradlePath(filePath) {
11+
return filePath.replace(/\\/g, '/');
12+
}
13+
14+
function collectFromDependenciesJson(platformRoot) {
15+
const dependenciesJson = path.join(platformRoot, 'dependencies.json');
16+
if (!fs.existsSync(dependenciesJson)) {
17+
return null;
18+
}
19+
try {
20+
const dependencies = JSON.parse(fs.readFileSync(dependenciesJson, 'utf8'));
21+
return dependencies.map((dependency) => path.resolve(platformRoot, dependency.directory, INCLUDE_SETTINGS_RELATIVE_PATH));
22+
} catch (error) {
23+
return null;
24+
}
25+
}
26+
27+
function collectFromProjectDependencies(projectData) {
28+
const dependencies = Object.keys(projectData.dependencies || {});
29+
return dependencies.map((dependency) => {
30+
try {
31+
return path.join(path.dirname(require.resolve(`${dependency}/package.json`, { paths: [projectData.projectDir] })), INCLUDE_SETTINGS_RELATIVE_PATH);
32+
} catch (error) {
33+
return path.join(projectData.projectDir, 'node_modules', dependency, INCLUDE_SETTINGS_RELATIVE_PATH);
34+
}
35+
});
36+
}
37+
38+
function stripGeneratedBlock(content) {
39+
const start = content.indexOf(MARKER_START);
40+
const end = content.indexOf(MARKER_END);
41+
if (start === -1 || end === -1 || end < start) {
42+
return content;
43+
}
44+
return content.slice(0, start) + content.slice(end + MARKER_END.length);
45+
}
46+
47+
module.exports = function (hookArgs) {
48+
const platformData = hookArgs && hookArgs.platformData;
49+
const projectData = hookArgs && hookArgs.projectData;
50+
if (!platformData || !projectData || platformData.platformNameLowerCase !== 'android') {
51+
return;
52+
}
53+
54+
const platformRoot = platformData.projectRoot;
55+
const settingsGradlePath = path.join(platformRoot, 'settings.gradle');
56+
if (!fs.existsSync(settingsGradlePath)) {
57+
return;
58+
}
59+
60+
const candidates = collectFromDependenciesJson(platformRoot) || collectFromProjectDependencies(projectData);
61+
const includes = candidates.filter((candidate, index) => fs.existsSync(candidate) && candidates.indexOf(candidate) === index);
62+
63+
const currentContent = fs.readFileSync(settingsGradlePath, 'utf8');
64+
let newContent = stripGeneratedBlock(currentContent).replace(/\s+$/, '') + '\n';
65+
if (includes.length) {
66+
const applies = includes.map((include) => `apply from: "${toGradlePath(include)}"`).join('\n');
67+
newContent += `\n${MARKER_START}\n${applies}\n${MARKER_END}\n`;
68+
}
69+
70+
if (newContent !== currentContent) {
71+
fs.writeFileSync(settingsGradlePath, newContent);
72+
}
73+
};

demo-snippets/package.json

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,26 @@
22
"name": "@nativescript-community/template-snippet",
33
"private": true,
44
"version": "0.0.1",
5+
"scripts": {
6+
"postinstall": "node ./scripts/install-hooks.js"
7+
},
8+
"nativescript": {
9+
"hooks": [
10+
{
11+
"name": "template-snippet-include-settings",
12+
"type": "after-prepareNativeApp",
13+
"script": "hooks/after-prepareNativeApp/include-settings.js",
14+
"inject": true
15+
}
16+
]
17+
},
518
"dependencies": {
19+
"@nativescript-community/ui-drawer": "^0.1.32",
620
"@nativescript-community/ui-massifmaps": "*",
21+
"@nativescript-community/ui-material-core": "^7.3.2",
22+
"@nativescript-community/ui-material-segmentedbar": "^7.3.2",
23+
"@nativescript-community/ui-material-slider": "^7.3.2",
24+
"@nativescript-community/ui-persistent-bottomsheet": "^0.1.12",
725
"vue-property-decorator": "9.1.2"
826
}
927
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/**
2+
* Installs the hooks declared in package.json into every sibling `demo*` NativeScript project.
3+
*
4+
* `@nativescript/hook` walks up from the package folder to find the NativeScript project, which
5+
* does not work here: this package lives at the monorepo root and is only symlinked into the demos.
6+
*/
7+
const fs = require('fs');
8+
const path = require('path');
9+
10+
const packageDir = path.resolve(__dirname, '..');
11+
const packageJson = require(path.join(packageDir, 'package.json'));
12+
const hooks = (packageJson.nativescript && packageJson.nativescript.hooks) || [];
13+
14+
function isNativeScriptProject(dir) {
15+
return fs.existsSync(path.join(dir, 'nativescript.config.ts')) || fs.existsSync(path.join(dir, 'nativescript.config.js'));
16+
}
17+
18+
function getDemoProjects() {
19+
const rootDir = path.resolve(packageDir, '..');
20+
return fs
21+
.readdirSync(rootDir)
22+
.filter((entry) => entry.startsWith('demo'))
23+
.map((entry) => path.join(rootDir, entry))
24+
.filter((dir) => fs.statSync(dir).isDirectory() && isNativeScriptProject(dir));
25+
}
26+
27+
function hookFileName(hook) {
28+
return `${(hook.name || packageJson.name).replace(/@/g, '').replace(/\//g, '-')}.js`;
29+
}
30+
31+
for (const projectDir of getDemoProjects()) {
32+
for (const hook of hooks) {
33+
const hookDir = path.join(projectDir, 'hooks', hook.type);
34+
fs.mkdirSync(hookDir, { recursive: true });
35+
const hookPath = path.join(hookDir, hookFileName(hook));
36+
const content = `module.exports = require(${JSON.stringify(`${packageJson.name}/${hook.script}`)});\n`;
37+
if (!fs.existsSync(hookPath) || fs.readFileSync(hookPath, 'utf8') !== content) {
38+
fs.writeFileSync(hookPath, content);
39+
}
40+
}
41+
}

0 commit comments

Comments
 (0)