@livx.cc/appwrap 0.58.2 → 0.58.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livx.cc/appwrap",
3
- "version": "0.58.2",
3
+ "version": "0.58.4",
4
4
  "description": "Wrap any PWA into a native app with native capabilities (appwrap runtime + @livx.cc/native-kit).",
5
5
  "license": "MIT",
6
6
  "author": "Elya Livshitz",
@@ -14,7 +14,7 @@ android {
14
14
 
15
15
  defaultConfig {
16
16
  minSdkVersion 26 // Android 8.0 — Health Connect's floor (~99% of devices); was 24
17
- targetSdkVersion 35
17
+ targetSdkVersion 36 // Play requires target API 36 (Android 16) from 31 Aug 2026
18
18
 
19
19
  // Version Information
20
20
  versionCode 2
@@ -1,4 +1,4 @@
1
- import { Application, AndroidApplication, EventData, Page, isAndroid, isIOS, knownFolders, path } from '@nativescript/core';
1
+ import { Application, AndroidApplication, Button, EventData, LoadEventData, Page, StackLayout, WebView, isAndroid, isIOS, knownFolders, path } from '@nativescript/core';
2
2
  import { bridge } from './shell/bridge';
3
3
  import { effectiveServerUrl } from './shell/server-url';
4
4
  import { registerHandlers } from './shell/handlers';
@@ -126,6 +126,7 @@ export function onPageLoaded(args: EventData): void {
126
126
  // if a future surface orphans a window with no dismiss callback — gate it on a "surface presented" flag.
127
127
  void armNativeSurfaceRecovery; // referenced to keep the helper (still callable if a scoped need arises)
128
128
  if (isAndroid) wireAndroidSafeArea(webView); // experimental edge-to-edge (no-op unless config on)
129
+ wireLoadFallback(page, webView); // loader:'server' failure → branded retry view (no white screen)
129
130
  startEventForwarding();
130
131
  loadBundle(webView);
131
132
  // Env indicator banner: shown in the bottom safe area on relaunch when a non-default env override is
@@ -167,6 +168,58 @@ export function onPageLoaded(args: EventData): void {
167
168
  }
168
169
  }
169
170
 
171
+ /**
172
+ * loader:'server' load-failure fallback (App Review 2.1a). A server-loader shell has NO local UI —
173
+ * if the serverUrl is unreachable (offline, server down, DNS) the reviewer sees a bare white screen.
174
+ * NS core forwards BOTH webView:didFailNavigation: and didFailProvisionalNavigation: (iOS) and the
175
+ * main-frame onReceivedError (Android) into `loadFinishedEvent` with `args.error` — so one listener
176
+ * covers initial-load and in-app navigation failures on both platforms. On failure: show a simple
177
+ * branded "can't connect — retry" view + auto-retry with capped backoff; any successful load hides
178
+ * it and resets. iOS NSURLErrorCancelled ("cancelled", a superseded navigation) is NOT a failure.
179
+ * NOTE: web-process termination has no NS seam in a prod build (the didTerminate forwarder lives on
180
+ * the debug-only DevCertNavDelegate) — out of scope here.
181
+ */
182
+ function wireLoadFallback(page: Page, webView: CustomWebView): void {
183
+ if (SHELL_CONFIG.loader !== 'server') return; // bundled loaders can't fail on network
184
+ const fallback = page.getViewById<StackLayout>('loadFallback');
185
+ const retryBtn = page.getViewById<Button>('loadRetryBtn');
186
+ if (!fallback || !retryBtn) return;
187
+
188
+ let retryTimer: ReturnType<typeof setTimeout> | null = null;
189
+ let backoffMs = 3000;
190
+ const clearTimer = () => { if (retryTimer) { clearTimeout(retryTimer); retryTimer = null; } };
191
+ const retry = () => {
192
+ clearTimer();
193
+ appwrapNativeLog('[native:fallback] retrying server load');
194
+ // Android: loadBundle sets `src`, but re-setting the SAME value is a NS property no-op — drive
195
+ // the native reload directly. iOS's loadBundle path issues a fresh loadRequest, so reuse it.
196
+ const droid = isAndroid ? (webView.android as android.webkit.WebView | null) : null;
197
+ if (droid) droid.loadUrl(effectiveServerUrl());
198
+ else loadBundle(webView);
199
+ };
200
+
201
+ webView.on(WebView.loadFinishedEvent, (args: LoadEventData) => {
202
+ const err = String(args.error ?? '');
203
+ if (!err) {
204
+ // successful load → hide + reset (idempotent; covers the auto-retry that finally lands)
205
+ clearTimer();
206
+ backoffMs = 3000;
207
+ if (fallback.visibility !== 'collapse') {
208
+ appwrapNativeLog('[native:fallback] load recovered — hiding fallback');
209
+ fallback.visibility = 'collapse';
210
+ }
211
+ return;
212
+ }
213
+ if (/cancel/i.test(err)) return; // NSURLErrorCancelled: navigation superseded, not a failure
214
+ appwrapNativeLog(`[native:fallback] load failed: ${err}`);
215
+ fallback.visibility = 'visible';
216
+ if (!retryTimer) {
217
+ retryTimer = setTimeout(() => { backoffMs = Math.min(backoffMs * 2, 30_000); retry(); }, backoffMs);
218
+ }
219
+ });
220
+ retryBtn.on(Button.tapEvent, retry);
221
+ }
222
+
170
223
  function loadBundle(webView: CustomWebView): void {
171
224
  const wwwPath = path.join(knownFolders.currentApp().path, 'www');
172
225
  const entryPath = path.join(wwwPath, SHELL_CONFIG.entry);
@@ -6,5 +6,13 @@
6
6
  backgroundColor="{{ backgroundColor }}">
7
7
  <GridLayout rows="*">
8
8
  <shell:CustomWebView id="webview" row="0" />
9
+ <!-- loader:'server' load-failure fallback (App Review 2.1a): shown by main-page.ts when the
10
+ server navigation fails (offline / server down) so the reviewer never sees a white screen. -->
11
+ <StackLayout id="loadFallback" row="0" visibility="collapse" backgroundColor="#ffffff" verticalAlignment="center" padding="32">
12
+ <Label text="Blank" fontSize="34" fontWeight="700" color="#000000" textAlignment="center" />
13
+ <Label text="Can't connect" fontSize="17" fontWeight="600" color="#333333" textAlignment="center" marginTop="18" />
14
+ <Label text="Check your internet connection — we'll keep trying." fontSize="14" color="#777777" textAlignment="center" textWrap="true" marginTop="6" />
15
+ <Button id="loadRetryBtn" text="Retry" fontSize="16" fontWeight="600" color="#ffffff" backgroundColor="#000000" borderRadius="22" height="44" width="160" marginTop="24" />
16
+ </StackLayout>
9
17
  </GridLayout>
10
18
  </Page>
@@ -127,6 +127,22 @@ class DevCertNavDelegate extends NSObject implements WKNavigationDelegate {
127
127
  // EXPLICITLY — ObjC message forwarding (forwardingTargetForSelector) is unreliable for a @NativeClass,
128
128
  // and a decision method (below) whose `decisionHandler` is never called STALLS the navigation → blank.
129
129
  webViewDecidePolicyForNavigationActionDecisionHandler(webView: WKWebView, navigationAction: any, decisionHandler: (p: number) => void): void {
130
+ // External URL schemes (mailto:/tel:/app deeplinks/…) can't be rendered by the WKWebView —
131
+ // navigating to one in-place silently fails ("unsupported URL"). Mirror the Android shell's
132
+ // shouldOverrideUrlLoading: hand every non-web scheme to the OS and cancel the navigation.
133
+ // http/https/file/about/blob/data (+ empty) stay in-WebView.
134
+ try {
135
+ const url: NSURL | null = navigationAction?.request?.URL ?? null;
136
+ const scheme = url?.scheme?.toLowerCase?.() ?? '';
137
+ // 'app' is the shell's own WKURLSchemeHandler serving the bundled PWA (loader:'app') — in-WebView.
138
+ if (scheme && !['http', 'https', 'file', 'about', 'blob', 'data', 'javascript', 'app'].includes(scheme)) {
139
+ UIApplication.sharedApplication.openURLOptionsCompletionHandler(url!, NSDictionary.new() as any, null);
140
+ decisionHandler(0 /* WKNavigationActionPolicy.Cancel */);
141
+ return;
142
+ }
143
+ } catch (e) {
144
+ console.warn('AppWrap: external-scheme nav check failed', e);
145
+ }
130
146
  if (this.fbResponds('webView:decidePolicyForNavigationAction:decisionHandler:')) {
131
147
  (this.fallback as any).webViewDecidePolicyForNavigationActionDecisionHandler(webView, navigationAction, decisionHandler);
132
148
  } else {
package/src/cli.ts CHANGED
@@ -13,7 +13,7 @@ import { createHash } from 'crypto';
13
13
  import { cpSync, existsSync, mkdirSync, openSync, closeSync, readdirSync, readFileSync, readSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, writeSync } from 'fs';
14
14
  import { builtinModules } from 'module';
15
15
  import { networkInterfaces, tmpdir } from 'os';
16
- import { basename, dirname, extname, join, resolve } from 'path';
16
+ import { basename, dirname, extname, join, relative, resolve } from 'path';
17
17
  import { pathToFileURL } from 'url';
18
18
  // PURE-DATA capability manifest (no NativeScript globals) — type-only import (erased at runtime);
19
19
  // the VALUES are loaded dynamically below from the resolved runtime so the CLI works both in the
@@ -979,7 +979,9 @@ function stampIOSDisplayName(outDir: string, cfg: AppwrapConfig, req: NativeReqs
979
979
  // and notifications fall back to a plain banner with the host app icon. native-kit's notification
980
980
  // identity path (INSendMessageIntent → communication notification) needs this to render the sender.
981
981
  if (cfg.iosEntitlements?.['com.apple.developer.usernotifications.communication']) {
982
- extras.push(` <key>NSUserActivityTypes</key>\n <array>\n <string>INSendMessageIntent</string>\n <string>INStartCallIntent</string>\n </array>`);
982
+ // ONLY the intents the shell actually donates: INSendMessageIntent (communication-notification
983
+ // styling). Declaring INStartCallIntent in an app with no calling feature is an App Review flag.
984
+ extras.push(` <key>NSUserActivityTypes</key>\n <array>\n <string>INSendMessageIntent</string>\n </array>`);
983
985
  }
984
986
  if (extras.length) {
985
987
  src = src.replace(
@@ -2009,6 +2011,50 @@ export function isFrameworkRepo(root: string): boolean {
2009
2011
  return existsSync(join(root, 'packages/appwrap-cli/src/cli.ts'));
2010
2012
  }
2011
2013
 
2014
+ /** Context the CI workflow templates are rendered against — derived from the repo shape + config.
2015
+ * - `subdir`: app dir relative to the git root ('' = app at repo root). Auto-detected from where the
2016
+ * appwrap config lives, so a monorepo gets path-scoped jobs + a PR paths filter for free.
2017
+ * - `tagPrefix`: release-trigger tag prefix. Monorepo default `<subdir>-v` (NOT `v`) so an unrelated
2018
+ * repo `v*` tag can never cut a store release; overridable via config `ci.tagPrefix`.
2019
+ * - `webBuild`: false for loader:'server' wraps (no local PWA bundle) — web build steps drop out. */
2020
+ export interface CiRenderContext { subdir: string; tagPrefix: string; webBuild: boolean; }
2021
+
2022
+ /** Derive the render context for `cwd` (the app dir holding the appwrap config) inside `repoRoot`. */
2023
+ export function ciRenderContext(cwd: string, repoRoot: string, cfg: AppwrapConfig): CiRenderContext {
2024
+ const rel = relative(repoRoot, cwd);
2025
+ const subdir = rel === '' || rel === '.' ? '' : rel;
2026
+ // Default tag prefix uses the LAST path segment (apps/foo → foo-v): tags are flat names, not paths.
2027
+ const tagPrefix = cfg.ci?.tagPrefix ?? (subdir ? `${basename(subdir)}-v` : 'v');
2028
+ return { subdir, tagPrefix, webBuild: cfg.loader !== 'server' };
2029
+ }
2030
+
2031
+ /** Render a CI workflow template: substitute tokens and resolve `#@if:` directives.
2032
+ * Directives (never survive into output): a trailing ` #@if:<cond>` keeps that line only when the
2033
+ * condition holds; standalone `#@if:<cond>` … `#@end` lines gate a block (non-nesting). Conds:
2034
+ * `monorepo` (subdir set) / `root` / `web` (local PWA build) / `server`. Tokens: `__DIR__` →
2035
+ * `<subdir>/` or '' (path prefix), `__APP_DIR__` → subdir (working-directory), `__TAG_PREFIX__`. */
2036
+ export function renderCiWorkflow(src: string, ctx: CiRenderContext): string {
2037
+ const holds = (c: string): boolean =>
2038
+ c === 'monorepo' ? !!ctx.subdir : c === 'root' ? !ctx.subdir : c === 'web' ? ctx.webBuild : c === 'server' ? !ctx.webBuild
2039
+ : c === 'doc' ? false // template-authoring notes — never emitted
2040
+ : true;
2041
+ const out: string[] = [];
2042
+ let dropBlock = false;
2043
+ for (const line of src.split('\n')) {
2044
+ const t = line.trim();
2045
+ if (t.startsWith('#@if:')) { dropBlock = !holds(t.slice(5)); continue; }
2046
+ if (t === '#@end') { dropBlock = false; continue; }
2047
+ if (dropBlock) continue;
2048
+ const m = line.match(/^(.*?)\s*#@if:([a-z]+)$/);
2049
+ if (m) { if (holds(m[2])) out.push(m[1]); }
2050
+ else out.push(line);
2051
+ }
2052
+ return out.join('\n')
2053
+ .replaceAll('__DIR__', ctx.subdir ? `${ctx.subdir}/` : '')
2054
+ .replaceAll('__APP_DIR__', ctx.subdir)
2055
+ .replaceAll('__TAG_PREFIX__', ctx.tagPrefix);
2056
+ }
2057
+
2012
2058
  /** Emit CI scaffolding (GH Actions → git repo root ONLY with --ci, fastlane → native/).
2013
2059
  * Writing `.github/workflows` into a consumer repo is a repo-level side effect (it can shadow or
2014
2060
  * clash with the project's own CI), so it is OPT-IN via `appwrap init --ci` — never implicit.
@@ -2027,19 +2073,28 @@ function copyCiTemplates(cwd: string, outDir: string, cfg: AppwrapConfig, force
2027
2073
  const scaffoldWorkflows = withWorkflows && !isFrameworkRepo(repoRoot);
2028
2074
  if (withWorkflows && !scaffoldWorkflows) {
2029
2075
  console.log(' ci ← GH Actions scaffold skipped (inside the appwrap framework repo — manages its own CI)');
2030
- } else if (scaffoldWorkflows) {
2031
- targets.unshift([join(CI_TEMPLATE_DIR, 'github/workflows'), join(repoRoot, '.github/workflows'), false]);
2032
2076
  }
2033
2077
  for (const [from, to, overwrite] of targets) {
2034
2078
  mkdirSync(to, { recursive: true });
2035
2079
  cpSync(from, to, { recursive: true, force: overwrite, errorOnExist: false });
2036
2080
  }
2037
- // Pin the emitted workflow's `bunx @livx.cc/appwrap@^x.y.z` to THIS CLI's version floor, so a CI run
2038
- // using a freshly-emitted workflow can't silently resolve an older published build that lacks the
2039
- // `init`/`release` commands (it would 404 loudly instead). Idempotent: re-init finds no placeholder.
2081
+ // Workflows are RENDERED per-repo-shape (not raw-copied): monorepo path scoping + tag prefix +
2082
+ // loader-aware web steps (see renderCiWorkflow), and the `bunx @livx.cc/appwrap@^x.y.z` pin is
2083
+ // stamped to THIS CLI's version floor so a CI run can't silently resolve an older published build
2084
+ // that lacks the `init`/`release` commands (it fails loudly instead). Never overwritten (users may
2085
+ // customize); re-init on an existing file is a no-op.
2040
2086
  if (scaffoldWorkflows) {
2041
- const wf = join(repoRoot, '.github/workflows/appwrap-release-ios.yml');
2042
- if (existsSync(wf)) writeFileSync(wf, readFileSync(wf, 'utf8').replaceAll('__APPWRAP_VERSION__', CLI_VERSION));
2087
+ const ctx = ciRenderContext(cwd, repoRoot, cfg);
2088
+ const wfSrcDir = join(CI_TEMPLATE_DIR, 'github/workflows');
2089
+ const wfDir = join(repoRoot, '.github/workflows');
2090
+ mkdirSync(wfDir, { recursive: true });
2091
+ for (const file of readdirSync(wfSrcDir)) {
2092
+ const dest = join(wfDir, file);
2093
+ if (existsSync(dest)) continue;
2094
+ const rendered = renderCiWorkflow(readFileSync(join(wfSrcDir, file), 'utf8'), ctx)
2095
+ .replaceAll('__APPWRAP_VERSION__', CLI_VERSION);
2096
+ writeFileSync(dest, rendered);
2097
+ }
2043
2098
  }
2044
2099
  // Stamp the app id + team into the emitted fastlane (signing needs them; the templates ship
2045
2100
  // `__APP_ID__`/`__TEAM_ID__` placeholders). Idempotent: re-init finds no placeholders → no-op.
package/src/config.ts CHANGED
@@ -249,6 +249,15 @@ export interface AppwrapConfig {
249
249
  loader?: 'app' | 'file' | 'server';
250
250
  /** Live URL loaded when loader === 'server'. Set via config or `appwrap dev --url <url>`. */
251
251
  serverUrl?: string;
252
+ /** CI scaffolding options (`appwrap init --ci`). The emitted GH Actions workflows auto-adapt to the
253
+ * repo shape (job paths + PR path filter scoped to the app subdir when the config sits below the git
254
+ * root; web-build steps dropped for loader:'server'); this block only overrides the derived defaults. */
255
+ ci?: {
256
+ /** Release tag prefix the release workflows trigger on (`tags: ['<prefix>*']`). Default: `v` at
257
+ * the repo root, `<subdir>-v` in a monorepo — so a monorepo's generic `v*` tags can never cut a
258
+ * store release by accident. */
259
+ tagPrefix?: string;
260
+ };
252
261
  /** Runtime env-switcher (loader:'server' apps). Lets a build re-point the WebView between declared
253
262
  * environments (prod / lab / a preview URL) at runtime — via the native dev-menu "Switch Environment"
254
263
  * action + a bottom env-indicator banner — surviving a cold start, with NO separate native build.
@@ -348,6 +357,10 @@ export interface AppwrapConfig {
348
357
  * build finds MULTIPLE candidate profiles for an id and you pick one interactively — so you're not
349
358
  * re-prompted. Usually unset: a single matching profile is selected automatically. */
350
359
  signingProfiles?: Record<string, string>;
360
+ /** Store-lane block (listing metadata, screenshot dirs, submission answers) consumed by an EE host
361
+ * (`appwrap-ee store bootstrap|push|assets`). CE carries it OPAQUE — nothing in CE reads it; the
362
+ * key is registered only so `unknownConfigKeys` doesn't warn on configs that declare it. */
363
+ store?: Record<string, unknown>;
351
364
  /** Pure-native escape hatch: a directory (relative to the PWA project) whose contents are copied
352
365
  * OVER the generated wrapper after stamping — for legacy/custom native code the declarative config
353
366
  * can't express. Default `'appwrap.overrides'`; applied only if it exists. */
@@ -434,10 +447,10 @@ export function defineConfig(config: AppwrapConfig): AppwrapConfig {
434
447
  * with no error, then an App Store rejection). A loud warning turns that silent no-op into a signal.
435
448
  */
436
449
  export const KNOWN_CONFIG_KEYS: ReadonlySet<string> = new Set([
437
- 'androidAppLinks', 'appBoundDomains', 'backendOrigin', 'backgroundAudio', 'backgroundColor', 'backgroundTasks', 'buildNumber', 'debug',
450
+ 'androidAppLinks', 'appBoundDomains', 'backendOrigin', 'backgroundAudio', 'backgroundColor', 'backgroundTasks', 'buildNumber', 'ci', 'debug',
438
451
  'debugLog', 'desktop', 'devMenu', 'edgeToEdge', 'entry', 'icon', 'id', 'iosKeyboardExtraLift', 'loader', 'modules', 'modulePacks', 'name',
439
452
  'envSwitcher', 'iosEntitlements', 'neutralizeServiceWorker', 'oauthRedirectSchemes', 'openNewWindowsInBrowser', 'orientation', 'overrides', 'permissions',
440
- 'plugins', 'push', 'pwaDist', 'queryPackages', 'queryUrlSchemes', 'serverUrl', 'shareTarget', 'signing', 'signingProfiles', 'statusBarStyle',
453
+ 'plugins', 'push', 'pwaDist', 'queryPackages', 'queryUrlSchemes', 'serverUrl', 'shareTarget', 'signing', 'signingProfiles', 'statusBarStyle', 'store',
441
454
  'splashIcon', 'storekitConfig', 'targetedDevices', 'teamId', 'themeColor', 'trackingDomains', 'urlScheme',
442
455
  'usesNonExemptEncryption', 'vendorPaths', 'version',
443
456
  ]);
@@ -1,29 +1,49 @@
1
- # appwrap PR checks — emitted by `appwrap init`. Adjust build commands to your project.
1
+ # appwrap PR checks — emitted by `appwrap init --ci`. Adjust build commands to your project.
2
+ #@if:doc
3
+ # Template directives (processed at emit by renderCiWorkflow; none survive into the scaffolded file):
4
+ # `#@if:<cond>` trailing a line keeps it only when the condition holds; a standalone `#@if:<cond>`
5
+ # … `#@end` pair gates a block (non-nesting). Conds: monorepo/root (app dir vs repo root),
6
+ # web/server (loader), doc (never emitted — this note).
7
+ # Tokens: __DIR__ → `<subdir>/` or ``; __APP_DIR__ → subdir; __TAG_PREFIX__ → release tag prefix.
8
+ #@end
2
9
  name: PR
3
10
  on:
4
11
  pull_request:
12
+ paths: #@if:monorepo
13
+ - '__DIR__**' #@if:monorepo
14
+ - '.github/workflows/appwrap-pr.yml' #@if:monorepo
15
+ workflow_dispatch: #@if:monorepo
5
16
 
6
17
  jobs:
18
+ #@if:web
7
19
  web:
8
20
  runs-on: ubuntu-latest
9
21
  steps:
10
22
  - uses: actions/checkout@v5
11
23
  - uses: oven-sh/setup-bun@v2
12
24
  - run: bun install
25
+ working-directory: __APP_DIR__ #@if:monorepo
13
26
  - run: bun test || echo "no tests"
27
+ working-directory: __APP_DIR__ #@if:monorepo
14
28
  - run: bun run build # → dist/ (the PWA bundle the wrapper ships)
29
+ working-directory: __APP_DIR__ #@if:monorepo
30
+ #@end
15
31
 
16
32
  ios-build:
17
33
  runs-on: macos-15
18
- needs: web
34
+ needs: web #@if:web
19
35
  steps:
20
36
  - uses: actions/checkout@v5
21
37
  - uses: oven-sh/setup-bun@v2
22
38
  - uses: actions/setup-node@v5
23
39
  with: { node-version: 22 }
24
- - run: bun install && bun run build
40
+ - run: bun install && bun run build #@if:web
41
+ - run: bun install # server-loader wrap — no local web build (the app loads the live URL) #@if:server
42
+ working-directory: __APP_DIR__ #@if:monorepo
25
43
  # Pin global tools — unpinned installs can grab a breaking release mid-flight. Bump deliberately.
26
44
  - run: npm i -g nativescript@9.0.6
27
- - run: bunx @livx.cc/appwrap init # native/ is generated (gitignored) — regenerate it fresh in CI
28
- - working-directory: native
45
+ # Pinned to the appwrap version that emitted this workflow (see release workflow header for why).
46
+ - run: bunx @livx.cc/appwrap@^__APPWRAP_VERSION__ init # native/ is generated (gitignored) — regenerate it fresh in CI
47
+ working-directory: __APP_DIR__ #@if:monorepo
48
+ - working-directory: __DIR__native
29
49
  run: npm install && ns build ios --emulator
@@ -1,4 +1,5 @@
1
- # appwrap Android release — tag push (v*) → Play internal track.
1
+ # appwrap Android release — tag push (__TAG_PREFIX__*) → Play internal track.
2
+ # Template directives (`#@if:` / `#@end` / `__TOKENS__`) are processed at emit — see appwrap-pr.yml header. #@if:doc
2
3
  # Build is the appwrap CLI (`appwrap build android --release --aab` → signed AAB);
3
4
  # upload is fastlane supply. Secrets contract (repo settings → Actions secrets):
4
5
  # ANDROID_KEYSTORE_BASE64 upload keystore, base64-encoded
@@ -6,12 +7,14 @@
6
7
  # ANDROID_KEY_ALIAS key alias
7
8
  # ANDROID_KEY_ALIAS_PASSWORD key (alias) password
8
9
  # PLAY_SERVICE_ACCOUNT_JSON Play Developer API service-account JSON (the app must already
9
- # exist on Play with one prior manual upload before supply works)
10
+ # exist on Play with one prior manual upload before supply works).
11
+ # OPTIONAL — when unset the job stays green and ships the AAB
12
+ # artifact only (graceful degradation until the SA exists).
10
13
  name: Release Android
11
14
  on:
12
15
  workflow_dispatch:
13
16
  push:
14
- tags: ['v*']
17
+ tags: ['__TAG_PREFIX__*']
15
18
 
16
19
  # Monotonic store build number (Android versionCode): computed in the shell below — GitHub Actions
17
20
  # expressions have NO arithmetic operators, so `${{ github.run_number + 100 }}` is an INVALID workflow
@@ -37,35 +40,53 @@ jobs:
37
40
  path: |
38
41
  ~/.gradle/caches
39
42
  ~/.gradle/wrapper
40
- key: gradle-${{ runner.os }}-${{ hashFiles('bun.lock', 'package.json') }}
43
+ key: gradle-${{ runner.os }}-${{ hashFiles('__DIR__bun.lock', '__DIR__package.json') }}
41
44
  restore-keys: gradle-${{ runner.os }}-
42
45
  - uses: android-actions/setup-android@v3
43
46
  - run: sudo apt-get update -qq && sudo apt-get install -y -qq imagemagick # appwrap icon-gen needs an image tool (no sips on Linux) — else the default NS icon ships
44
- - run: bun install && bun run build # → dist/ (the PWA bundle the wrapper ships)
47
+ - run: bun install && bun run build # → dist/ (the PWA bundle the wrapper ships) #@if:web
48
+ - run: bun install # server-loader wrap — no local web build (the app loads the live URL) #@if:server
49
+ working-directory: __APP_DIR__ #@if:monorepo
45
50
  # Pin global tools — unpinned installs can grab a breaking release mid-flight. Bump deliberately.
46
51
  - run: bun add -g nativescript@9.0.6
47
- - run: bunx @livx.cc/appwrap init # native/ is generated (gitignored) — regenerate fresh in CI
48
- - working-directory: native
52
+ # Pinned to the appwrap version that emitted this workflow (see the iOS release workflow for why).
53
+ - run: bunx @livx.cc/appwrap@^__APPWRAP_VERSION__ init # native/ is generated (gitignored) — regenerate fresh in CI
54
+ working-directory: __APP_DIR__ #@if:monorepo
55
+ - working-directory: __DIR__native
49
56
  run: bun install
50
57
  - name: Decode upload keystore
51
58
  run: echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > "$RUNNER_TEMP/upload.keystore"
52
59
  - name: Build signed AAB
60
+ working-directory: __APP_DIR__ #@if:monorepo
53
61
  env:
54
62
  GRADLE_OPTS: "-Dorg.gradle.caching=true -Dorg.gradle.parallel=true -Dorg.gradle.daemon=false"
55
63
  APPWRAP_ANDROID_KEYSTORE: ${{ runner.temp }}/upload.keystore
56
64
  APPWRAP_ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
57
65
  APPWRAP_ANDROID_KEYSTORE_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
58
66
  APPWRAP_ANDROID_KEYSTORE_ALIAS_PASSWORD: ${{ secrets.ANDROID_KEY_ALIAS_PASSWORD }}
59
- run: bunx @livx.cc/appwrap build android --release --aab
67
+ run: bunx @livx.cc/appwrap@^__APPWRAP_VERSION__ build android --release --aab
60
68
  - uses: actions/upload-artifact@v4 # keep the AAB even if Play upload is skipped
61
69
  with:
62
70
  name: app-release-aab
63
- path: native/platforms/android/app/build/outputs/bundle/release/app-release.aab
71
+ path: __DIR__native/platforms/android/app/build/outputs/bundle/release/app-release.aab
72
+ # ---- Play upload, gracefully gated on the service account ----
73
+ # Without PLAY_SERVICE_ACCOUNT_JSON (e.g. the SA isn't provisioned yet) the job stays GREEN and
74
+ # ships the AAB artifact only. secrets.* can't appear in a step `if:` — indirect through env.
75
+ - name: Check Play service account
76
+ id: play-sa
77
+ env:
78
+ PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
79
+ run: |
80
+ if [ -n "$PLAY_SERVICE_ACCOUNT_JSON" ]; then echo "present=true" >> "$GITHUB_OUTPUT";
81
+ else echo "present=false" >> "$GITHUB_OUTPUT"; echo "::notice::PLAY_SERVICE_ACCOUNT_JSON not set — skipping Play upload, AAB artifact only"; fi
64
82
  - uses: ruby/setup-ruby@v1
83
+ if: steps.play-sa.outputs.present == 'true'
65
84
  with: { ruby-version: '3.3', bundler-cache: false }
66
85
  - run: gem install fastlane -v 2.236.1
86
+ if: steps.play-sa.outputs.present == 'true'
67
87
  - name: Upload to Play (internal)
68
- working-directory: native
88
+ if: steps.play-sa.outputs.present == 'true'
89
+ working-directory: __DIR__native
69
90
  env:
70
91
  PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
71
92
  run: fastlane android beta
@@ -1,4 +1,5 @@
1
- # appwrap iOS → TestFlight via fastlane. On-demand (workflow_dispatch) or tag push (v*).
1
+ # appwrap iOS → TestFlight via fastlane. On-demand (workflow_dispatch) or tag push (__TAG_PREFIX__*).
2
+ # Template directives (`#@if:` / `#@end` / `__TOKENS__`) are processed at emit — see appwrap-pr.yml header. #@if:doc
2
3
  # Secrets contract (repo settings → Actions secrets):
3
4
  # MATCH_GIT_URL private repo holding signing certs (fastlane match) — SSH form for the deploy key
4
5
  # MATCH_DEPLOY_KEY read-only SSH deploy key on that certs repo (so CI can clone it)
@@ -12,7 +13,7 @@ name: Release iOS
12
13
  on:
13
14
  workflow_dispatch:
14
15
  push:
15
- tags: ['v*']
16
+ tags: ['__TAG_PREFIX__*']
16
17
 
17
18
  # Monotonic store build number (CFBundleVersion): computed in the shell below — GitHub Actions
18
19
  # expressions have NO arithmetic operators, so `${{ github.run_number + 100 }}` is an INVALID workflow
@@ -33,7 +34,9 @@ jobs:
33
34
  - uses: oven-sh/setup-bun@v2
34
35
  - uses: actions/setup-node@v5
35
36
  with: { node-version: 22 }
36
- - run: bun install && bun run build
37
+ - run: bun install && bun run build #@if:web
38
+ - run: bun install # server-loader wrap — no local web build (the app loads the live URL) #@if:server
39
+ working-directory: __APP_DIR__ #@if:monorepo
37
40
  # Pin global tools — unpinned installs can grab a breaking release mid-flight. Bump deliberately.
38
41
  - run: npm i -g nativescript@9.0.6
39
42
  # bunx is PINNED to the appwrap version that emitted this workflow (stamped at `init`). Pinning is
@@ -43,7 +46,8 @@ jobs:
43
46
  # published to the @livx.cc registry. PUBLISH-ORDERING: publish the appwrap CLI BEFORE the first CI
44
47
  # run that uses a freshly-emitted workflow.
45
48
  - run: bunx @livx.cc/appwrap@^__APPWRAP_VERSION__ init # native/ is generated (gitignored) — regenerate it fresh in CI
46
- - working-directory: native
49
+ working-directory: __APP_DIR__ #@if:monorepo
50
+ - working-directory: __DIR__native
47
51
  run: npm install
48
52
  - uses: ruby/setup-ruby@v1
49
53
  with: { ruby-version: '3.3', bundler-cache: false }
@@ -58,6 +62,7 @@ jobs:
58
62
  # Single source of truth: the SAME command a developer runs locally. It re-stamps native/ and
59
63
  # delegates the full archive/sign/upload to the emitted fastlane lane — no fastlane logic here.
60
64
  - name: Build & upload to TestFlight
65
+ working-directory: __APP_DIR__ #@if:monorepo
61
66
  env:
62
67
  MATCH_GIT_URL: ${{ secrets.MATCH_GIT_URL }}
63
68
  MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}