diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 700c612..e4242af 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -10,6 +10,7 @@ export default defineConfig({ themeConfig: { nav: [ { text: 'Guide', link: '/guide/introduction' }, + { text: 'Quick start', link: '/guide/quick-start' }, { text: 'API', link: '/reference/api' }, { text: 'Links', @@ -27,8 +28,11 @@ export default defineConfig({ text: 'Guide', items: [ { text: 'Introduction', link: '/guide/introduction' }, - { text: 'Installation', link: '/guide/installation' }, + { text: 'Quick start', link: '/guide/quick-start' }, + { text: 'How it works', link: '/guide/how-it-works' }, + { text: 'Feel native', link: '/guide/feel-native' }, { text: 'Navigation', link: '/guide/navigation' }, + { text: 'Manual installation', link: '/guide/installation' }, ], }, { @@ -50,6 +54,10 @@ export default defineConfig({ text: 'Reference', items: [{ text: 'API', link: '/reference/api' }], }, + { + text: 'Help', + items: [{ text: 'FAQ', link: '/guide/faq' }], + }, ], socialLinks: [ diff --git a/docs/guide/faq.md b/docs/guide/faq.md new file mode 100644 index 0000000..08a21a9 --- /dev/null +++ b/docs/guide/faq.md @@ -0,0 +1,112 @@ +# FAQ + +Answers to the questions people ask most before they try Inertia Native. Facts +about other projects come from their own sites and were checked in October +2026. + +## How does it compare to other options? + +Each of these tools gets an app into the App Store and Google Play. They differ +in where your screens come from and how much of your current app you reuse. +Inertia Native keeps your server and your Inertia pages as they are, loads them +in native screens, and works with any backend. + +### Capacitor + +[Capacitor](https://capacitorjs.com/docs) runs a web app inside a native +project, and plugins give your JavaScript access to native APIs. It bundles +your built web assets, starting from an `index.html`, into the app. Its +`server.url` option loads a remote site instead, but the +[config docs](https://capacitorjs.com/docs/config) say it's meant for live +reload and "not intended for use in production." + +An Inertia app needs your server to answer every page visit, so it doesn't fit +the bundled model without changes. Inertia Native loads pages from your server, +and each visit opens a native screen. + +### NativePHP Mobile + +[NativePHP Mobile](https://nativephp.com/) runs your Laravel app on the phone +itself, with a PHP runtime embedded in the app, so the app needs no server. +Since version 4 it can turn Blade views into SwiftUI and Jetpack Compose views, +and it still supports showing HTML in a web view, which is where Inertia apps +run. Its core is free and MIT-licensed, and some plugins are paid. + +Choose it when you want the app to work on the device without your server. +Inertia Native does the opposite: your Laravel app keeps running on your +server, and the phone shows its pages. Releasing a change means deploying your +server, not shipping a new app build. + +### Ruby Native + +[Ruby Native](https://rubynative.com/) is a paid service for Rails apps, built +on Hotwire Native. You add its gem and a configuration file, and its cloud +builds compile, sign, and submit the apps, so you don't need Xcode or Android +Studio. Its site says it works with any Rails frontend, Inertia included. +Plans start at $299 per app per year. + +Inertia Native is free and open source, works with any backend, and gives you +the native projects as plain Xcode and Gradle code that you build yourself. + +### React Native and Expo + +[React Native](https://reactnative.dev/) renders React components to native +platform UI, and [Expo](https://docs.expo.dev/get-started/introduction/) is a +framework and set of tools around it. With either one you write a second app +with its own screens, which usually calls your backend through an API. + +Pick React Native when most of your screens need to be fully native. Inertia +Native reuses the pages you already have, and you can still add native screens +and [bridge components](/components/overview) where they matter. + +## Will Apple accept my app? + +Apple reviews every app on its own, so nobody can promise approval. The +approach has a track record: 37signals builds its +[Basecamp](https://apps.apple.com/us/app/basecamp-project-management/id1015603248) +and [HEY](https://apps.apple.com/us/app/hey-email/id1506603805) apps on +[Turbo Native](https://dev.37signals.com/speeding-up-mobile-development-with-turbo/) +and [Strada](https://dev.37signals.com/announcing-strada/), the two libraries +that [became Hotwire Native](https://dev.37signals.com/announcing-hotwire-native/). +Both apps are in the App Store and Google Play. + +The guideline to read is +[4.2 Minimum Functionality](https://developer.apple.com/app-store/review/guidelines/#minimum-functionality): +"Your app should include features, content, and UI that elevate it beyond a +repackaged website." Native navigation helps. So do hiding your site's own +navigation in the app (see [Feel native](/guide/feel-native)) and adding +native features where your users benefit from them. + +## Does server-side rendering work? + +Yes, with a guard. `initInertiaNative()` uses `window`, so if your entrypoint +also runs on the server, call it only in the browser: + +```js +if (typeof window !== 'undefined') initInertiaNative() +``` + +Bridge components don't render on the server yet. `useBridgeComponent` reads +`window` while a component renders, in all three framework bindings, so a page +that uses a bridge component fails to render on the server. + +## Which Inertia versions does it support? + +Inertia Native needs Inertia 3 (`@inertiajs/core` 3.0 or later) with React, +Vue, or Svelte. Version 1.0 dropped support for Inertia 2. See +[Manual installation](/guide/installation#requirements) for the full list. + +## Does it work offline? Does it support push notifications? + +Neither is built in yet. The app loads every page from your server, so it +needs a network connection. The shells from `init` don't include push +notifications. Both are native features that you can add to the projects in +`ios/` and `android/` yourself. + +## Do I need to know Swift or Kotlin? + +Not to get started. `init` creates both native projects, and +`npm run ios` and `npm run android` build and run them. You open the native +code to change the app's name, icon, or URL, or to add a native component. The +[iOS](/native/ios) and [Android](/native/android) pages show where each of +those lives. diff --git a/docs/guide/feel-native.md b/docs/guide/feel-native.md new file mode 100644 index 0000000..a6d53c2 --- /dev/null +++ b/docs/guide/feel-native.md @@ -0,0 +1,424 @@ +# Feel native + +After the [quick start](/guide/quick-start), your app runs in a native shell +but still looks like a website in places. This page covers the changes that +make the biggest difference: hiding web-only parts, closing the sidebar, +starting fresh after logging in, closing a modal after a form, and adding a +native button. + +The examples use the Laravel and Rails React starter kits, and the tabs show +where the two differ. + +## Tell the app apart from a browser + +Hotwire Native adds its name to the web view's user agent on both platforms. +iOS requests contain `Hotwire Native iOS;` and Android requests contain +`Hotwire Native Android;`. Check for `Hotwire Native` and you cover both. + +Your server sees the user agent on every request, including Inertia's own +requests. The simplest setup checks it there and shares the result with every +page as an Inertia prop: + +::: code-group + +```php [Laravel] +// app/Http/Middleware/HandleInertiaRequests.php +public function share(Request $request): array +{ + return [ + ...parent::share($request), + 'nativeApp' => str_contains($request->userAgent() ?? '', 'Hotwire Native'), + ]; +} +``` + +```ruby [Rails] +# app/controllers/application_controller.rb +class ApplicationController < ActionController::Base + inertia_share do + { nativeApp: native_app? } + end + + private + + def native_app? + request.user_agent.to_s.include?("Hotwire Native") + end +end +``` + +::: + +In JavaScript that runs outside a component, read the user agent directly. +Put the check in a module, so you can import it wherever you need it: + +::: code-group + +```ts [Laravel] +// resources/js/lib/native.ts +export const isNativeApp = + typeof navigator !== 'undefined' && + navigator.userAgent.includes('Hotwire Native'); +``` + +```ts [Rails] +// app/javascript/lib/native.ts +export const isNativeApp = + typeof navigator !== "undefined" && + navigator.userAgent.includes("Hotwire Native") +``` + +::: + +The `typeof` check keeps the line safe during server-side rendering, where +`navigator` may not exist. + +For CSS, add a class to the `` element. Do it in an inline script in +your root template's ``, so the class is there before the page first +appears: + +::: code-group + +```blade [Laravel] +{{-- resources/views/app.blade.php, inside --}} + +``` + +```erb [Rails] +<%# app/views/layouts/application.html.erb, inside %> + +``` + +::: + +A selector that starts with `.native-app` now matches only in the app. React +doesn't render ``, so the class is safe with server-side rendering too. + +::: warning Don't check `window.webkit` +`window.webkit.messageHandlers` comes from WKWebView, the web view on iOS. +Android's web view doesn't have it, so a check like +`window.webkit?.messageHandlers?.turbo` is false on Android. Use the user +agent instead. +::: + +## Hide web-only parts + +In the app, the shell already shows a navigation bar with the page title and a +back button. Your site's own header, back links, and footer now repeat it. +Hide them when `nativeApp` is true: + +::: code-group + +```jsx [React] +import { usePage } from '@inertiajs/react' + +export default function Layout({ children }) { + const { nativeApp } = usePage().props + + return ( + <> + {!nativeApp && } +
{children}
+ {!nativeApp && } + + ) +} +``` + +```vue [Vue] + + + +``` + +```svelte [Svelte] + + +{#if !page.props.nativeApp} + +{/if} +
{@render children()}
+{#if !page.props.nativeApp} + +{/if} +``` + +::: + +The server sends the prop with the page, so the header doesn't flash on screen +while the page loads. + +### Page titles + +The native navigation bar shows the page's ``, the same one you set with +Inertia's `<Head>`. The starter kits add two things around it. Their +`createInertiaApp` adds the app's name to every title, so the bar reads +`Dashboard - Laravel`. And the header at the top of the page shows the title +again as breadcrumbs. + +Drop the app's name in the app: + +::: code-group + +```tsx [Laravel] +// resources/js/app.tsx +import { isNativeApp } from '@/lib/native'; + +createInertiaApp({ + title: (title) => + title ? (isNativeApp ? title : `${title} - ${appName}`) : appName, + // … +}); +``` + +```tsx [Rails] +// app/javascript/entrypoints/inertia.tsx +import { isNativeApp } from "@/lib/native" + +createInertiaApp({ + title: (title) => + title ? (isNativeApp ? title : `${title} - ${appName}`) : appName, + // … +}) +``` + +::: + +Then hide the breadcrumbs with the `native-app` class. The kits build them +with shadcn/ui's `Breadcrumb`, which marks itself with +`data-slot="breadcrumb"`: + +::: code-group + +```css [Laravel] +/* resources/css/app.css */ +.native-app [data-slot='breadcrumb'] { + display: none; +} +``` + +```css [Rails] +/* app/javascript/entrypoints/application.css */ +.native-app [data-slot="breadcrumb"] { + display: none; +} +``` + +::: + +The header keeps its sidebar button, which is how you reach the menu in the +app. + +## Close the sidebar when a link opens + +On a phone, the starter kits show the sidebar as a sheet over the page. In the +Laravel kit, the sheet stays open when you tap a link in it: the next screen +opens with the sheet still on top, and it's still open when you go back. + +The shell shows each screen in the same web view, and the Laravel kit keeps +its sidebar layout between pages, so the open sheet comes along. Close it as +soon as a visit starts: + +```tsx +// resources/js/components/app-sidebar.tsx +import { router } from '@inertiajs/react'; +import { useEffect } from 'react'; +import { useSidebar } from '@/components/ui/sidebar'; +import { isNativeApp } from '@/lib/native'; + +export function AppSidebar() { + const { setOpenMobile } = useSidebar(); + + useEffect(() => { + if (!isNativeApp) { + return; + } + + return router.on('before', (event) => { + if (!event.detail.visit.async) { + setOpenMobile(false); + } + }); + }, [setOpenMobile]); + + // … +} +``` + +Inertia fires `before` when you tap a link, before the shell opens the next +screen. Polls and partial reloads are `async` visits that update the current +page, so they leave the sheet alone. In a browser the sheet behaves as before. + +The Rails kit renders its layout inside each page, so its sheet already closes +on the next screen. + +## Start fresh after logging in + +After you log in, the dashboard shows up on the login screen, and Back takes +you to the screens from before you logged in. Two things cause this. By +default, a form's result stays on the form's screen, so the shell doesn't +learn that you're on the dashboard now. And when it does, it opens the +dashboard on top of the login screen. + +First, have `inertia-native` hand the page a form lands on to the shell, as a +new visit. [Form redirects](/guide/navigation#form-redirects) explains the +option: + +::: code-group + +```tsx [Laravel] +// resources/js/app.tsx +initInertiaNative({ proposeFormRedirects: true }); +``` + +```tsx [Rails] +// app/javascript/entrypoints/inertia.tsx +initInertiaNative({ proposeFormRedirects: true }) +``` + +::: + +Then make the dashboard the first screen, with nothing to go back to. That's +the `replace_root` presentation. In `ios/App/path-configuration.json` and +`android/app/src/main/assets/json/path-configuration.json`, replace the +`^/$` rule with this one: + +::: code-group + +```json [Laravel] +{ + "patterns": ["^/$", "^/dashboard$"], + "properties": { + "presentation": "replace_root" + } +} +``` + +```json [Rails] +{ + "patterns": ["^/$", "^/dashboard$", "^/sign_in$"], + "properties": { + "presentation": "replace_root" + } +} +``` + +::: + +The home page `/` moves to the new rule too. Its old presentation, +`clear_all`, goes back to the first screen, and once you've logged in, that's +the dashboard. + +The rule also handles logging out. The Laravel kit logs you out to `/`. The +Rails kit logs you out to `/sign_in`, so its rule lists that page as well. As +a side effect, the **Log in** link on the Rails kit's welcome page also opens +the login screen as the first screen, without a Back button. + +The app reads these files when it's built, so run `npm run ios` and +`npm run android` again. If you also +[serve the path configuration](/guide/how-it-works#where-it-lives) from your +app, change the rule there too, because the server's copy replaces the bundled +file. + +## Close a modal after a form + +In the shells from `init`, a page whose URL ends in `/new` or `/edit` opens as +a modal. After the form saves, you want the modal to close and show the screen +underneath. A redirect can't do this: Inertia follows a redirect inside its own +request, so by default the shell never sees it. With `proposeFormRedirects`, +the shell sees the page the form lands on, but it opens that page as a new +visit instead of going back to the screen underneath. + +Instead, answer the form with a historical location. Hotwire Native reserves +three paths for this, and the shell handles them itself. Your server doesn't +need routes for them. `/recede_historical_location` closes the modal, or goes +back one screen when there's no modal. + +Send it as an Inertia location visit, and only to the app. Browsers get your +usual redirect: + +::: code-group + +```php [Laravel] +// app/Http/Controllers/PostController.php +use App\Models\Post; +use Illuminate\Http\Request; +use Inertia\Inertia; + +public function store(Request $request) +{ + Post::create($request->validate([ + 'title' => ['required', 'string', 'max:255'], + ])); + + if (str_contains($request->userAgent() ?? '', 'Hotwire Native')) { + return Inertia::location('/recede_historical_location'); + } + + return redirect()->route('posts.index'); +} +``` + +```ruby [Rails] +# app/controllers/posts_controller.rb +def create + @post = Post.new(post_params) + + if @post.save + return inertia_location("/recede_historical_location") if native_app? + + redirect_to posts_path + else + redirect_to new_post_path, inertia: { errors: @post.errors } + end +end +``` + +::: + +`inertia-native` hands the location visit to the shell, and the shell closes +the modal. Validation errors still go back to the form, so they show up inside +the modal as usual. + +To close the modal and also reload the screen under it, so a list shows the +new post, answer with `/refresh_historical_location` instead. +[Historical locations](/guide/navigation#historical-locations) lists all three +paths and has a ready-made Rails helper. + +## Add a native button + +A native button sits in the navigation bar, next to the title, where native +apps put actions like **Save** or **Edit**. The [Button](/components/button) +component adds one from your page and calls your code when someone taps it. In +a browser it renders your normal button instead. + +The shells from `init` already include the native half of this component, and +of the [Alert](/components/alert) component. You only copy the web half into +your app. + +## Next steps + +- [Navigation](/guide/navigation): how every kind of Inertia visit maps to + native screens. +- [Bridge components](/components/overview): how to build your own native + components. diff --git a/docs/guide/how-it-works.md b/docs/guide/how-it-works.md new file mode 100644 index 0000000..35786d4 --- /dev/null +++ b/docs/guide/how-it-works.md @@ -0,0 +1,143 @@ +# How it works + +This page explains Hotwire Native for Inertia developers who haven't used it. +It covers what the native app does, what your web app does, and the two +Hotwire ideas you'll meet in the rest of these docs: path configuration and +bridge components. + +## The mental model + +Think of the native app as a picture frame and your web app as the picture. +The frame is a real iOS or Android app. It owns the parts around the page: +the navigation bar, the back button, modals, and error screens. Your server +still renders every page, the same way it does for a browser. + +Each time you visit a new page, the frame opens a new native screen and shows +the page inside it. Going back closes that screen. That's why the app feels +native even though every page is your own Inertia page. + +[Hotwire Native](https://native.hotwired.dev) is the open-source framework that +provides the frame. 37signals makes it. It replaced Turbo Native and Strada, +the two libraries 37signals built its own mobile apps on. + +## What happens when you tap a link + +Hotwire Native was built for Turbo, the page-navigation library that ships +with Rails. Each time the app loads one of your pages, it adds a small script +that expects to find Turbo there. `inertia-native` stands in for Turbo and +translates between that script and Inertia's router. + +Here is a link tap, step by step: + +```text + You tap <Link href="/posts/1"> + │ + ▼ + inertia-native stops Inertia from changing the current page + and asks the native app to visit /posts/1 + │ + ▼ + The app checks its path configuration: + open /posts/1 as a new screen, or as a modal? + │ + ▼ + The app opens a new native screen + and loads /posts/1 into it +``` + +In a regular browser no native app is listening, so `inertia-native` does +nothing and Inertia navigates as usual. The same JavaScript bundle serves both. + +[Navigation](/guide/navigation) lists every kind of visit and how it maps to +native screens. + +## What the native app does and what your web app does + +These docs call the native app the shell, because it holds your web app but +has little content of its own. Here is how the work splits between the two: + +| The native shell | Your web app | +| --- | --- | +| Starts at your URL | Renders every page, as it does for a browser | +| Shows each page in a web view, the browser engine an app can embed | Decides what's on each page | +| Opens screens and modals, and draws the back button | Handles links, forms, validation, and sign-in | +| Shows the page's `<title>` in the navigation bar | Sets that title, for example with Inertia's `<Head>` | +| Shows native error screens and pull to refresh | Returns errors as usual | + +Most changes you make happen on the web side and reach users when you deploy. +You only touch the shell for things like the app name, its icon, or a new +native component. + +## Path configuration + +Path configuration is a JSON file of rules that tells the shell how to open +each URL. Each rule has a list of URL patterns and the properties to apply +when a URL matches. These are the shared rules in the shells that `init` +creates: + +```json +{ + "settings": {}, + "rules": [ + { + "patterns": ["/new$", "/edit$"], + "properties": { + "context": "modal", + "pull_to_refresh_enabled": false + } + }, + { + "patterns": ["^/$"], + "properties": { + "presentation": "clear_all" + } + } + ] +} +``` + +Read it like this. A URL ending in `/new` or `/edit` opens as a modal, with +pull to refresh turned off. The home page `/` clears the navigation history, +so going home doesn't stack one more screen on top. The patterns are regular +expressions matched against the URL's path. When several rules match, the app +combines their properties, and a later rule wins over an earlier one. + +The Android file starts with one more rule. It tells Android which kind of +native screen shows web pages. You don't need to change it. + +### Where it lives + +The shell reads path configuration from two places: + +- **A file bundled in the app.** It's `ios/App/path-configuration.json` and + `android/app/src/main/assets/json/path-configuration.json`. The app uses + this file from the first launch. +- **A copy on your server, if you serve one.** When the app starts, it + downloads `/inertia-native/path-configuration/ios_v1.json` or + `/inertia-native/path-configuration/android_v1.json` from your app's URL. + If the download works, its rules replace the bundled ones, and the app keeps + a copy for the next launch. If your server doesn't have that route, the app + keeps using the bundled file. + +Serving the file lets you change navigation by deploying your web app, without +shipping an app update. [iOS](/native/ios#path-configuration) and +[Android](/native/android#path-configuration) show a Laravel and a Rails route +that serve it. + +## Bridge components + +A bridge component lets a web page ask the shell for a piece of native UI, such +as a button in the navigation bar, and hear back when someone uses it. Each +component has a web half in your JavaScript and a native half in the shell, +and when the native half is missing the web half renders your normal web +markup instead. + +[Bridge components](/components/overview) covers how to use them, and the +shells from `init` already include the native halves of the ready-made ones. + +## Next steps + +- [Feel native](/guide/feel-native): hide web-only parts and close modals + after forms. +- [Navigation](/guide/navigation): every kind of Inertia visit and how it maps + to native screens. diff --git a/docs/guide/installation.md b/docs/guide/installation.md index a793557..ed12f29 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -1,4 +1,9 @@ -# Installation +# Manual installation + +Most apps should start with `npx inertia-native init`, which installs the +package, sets it up, and creates the native apps. The +[quick start](/guide/quick-start) walks through it. This page covers the +package on its own: what it needs and how to set it up by hand. ```bash npm add inertia-native @@ -6,10 +11,13 @@ npm add inertia-native ## Requirements +The package needs Inertia 3. The framework packages are optional peers, so you +only need the one your app uses: + - `@inertiajs/core` >= 3.0 -- `react` >= 18 — only for the `inertia-native/react` entry -- `vue` >= 3.0 — only for the `inertia-native/vue` entry -- `svelte` >= 4.0 — only for the `inertia-native/svelte` entry +- `react` >= 18, only for the `inertia-native/react` entry +- `vue` >= 3.0, only for the `inertia-native/vue` entry +- `svelte` >= 4.0, only for the `inertia-native/svelte` entry ## Setup @@ -20,24 +28,49 @@ entrypoint: import { createInertiaApp } from '@inertiajs/react' import { initInertiaNative } from 'inertia-native' -const isNativeApp = !!window.webkit?.messageHandlers?.turbo -initInertiaNative({ debug: import.meta.env.DEV || isNativeApp }) +initInertiaNative() createInertiaApp({ /* ... */ }) ``` -That's all that's needed for native navigation (push/pop/replace/restore, -modals, forms, error screens, pull-to-refresh). See the [API](/reference/api) -for the available options. +That's all native navigation needs: new screens, going back, modals, forms, +error screens, and pull to refresh. In a regular browser the call does +nothing. + +To log the messages between your page and the native app while you develop, +pass `debug`: + +```js +initInertiaNative({ debug: import.meta.env.DEV }) +``` + +See the [API](/reference/api) for every option. + +### Server-side rendering -To set up the native iOS and Android shells that load your app, see -[Native apps → iOS](/native/ios) and [Android](/native/android). +`initInertiaNative()` uses `window`. If your entrypoint also runs on the +server, guard the call: + +```js +if (typeof window !== 'undefined') initInertiaNative() +``` + +The [FAQ](/guide/faq#does-server-side-rendering-work) covers bridge components +and server-side rendering. + +## Native apps + +The package is the web half. The native iOS and Android apps that load your +pages are separate projects, and `npx inertia-native init` creates them. See +[iOS](/native/ios) and [Android](/native/android). ## Upgrading from 0.1.0-beta +Version 1.0 renamed the setup function and dropped Inertia 2: + - `initHotwireNative` is now `initInertiaNative`. - `@inertiajs/core` 2.x is no longer supported. - The package was published as `inertia-hotwire-native` before `0.1.0-beta.2`; replace that dependency with `inertia-native`. -Next: [Navigation](/guide/navigation). +Next: [Bridge components](/components/overview). diff --git a/docs/guide/introduction.md b/docs/guide/introduction.md index 1ce16fd..bacc29d 100644 --- a/docs/guide/introduction.md +++ b/docs/guide/introduction.md @@ -1,9 +1,19 @@ # Introduction -[`inertia-native`](https://www.npmjs.com/package/inertia-native) -lets an [Inertia.js](https://inertiajs.com) application run inside -[Hotwire Native](https://native.hotwired.dev) (iOS & Android) and behave like a -native app. +[`inertia-native`](https://www.npmjs.com/package/inertia-native) turns an +[Inertia.js](https://inertiajs.com) app into iOS and Android apps. Your server +keeps rendering every page, and each page visit opens as a native screen, with +native navigation, modals, error screens, and pull to refresh. It works with +React, Vue, and Svelte, and with any backend, such as Laravel or Rails. + +It's built on [Hotwire Native](https://native.hotwired.dev), the open-source +framework from 37signals for apps that show web pages in native screens. You +don't need to know Hotwire Native to use it. + +::: tip New here? +The [quick start](/guide/quick-start) takes you from your Inertia app to a +native app running in a simulator. +::: ::: info Unofficial This is a community-built integration. It is **not** an official Inertia.js or @@ -12,21 +22,25 @@ Hotwire project. ## How it works -Hotwire Native injects a `turbo.js` script into every web view that expects a -`window.Turbo` object to drive. This package installs a small shim that maps the -native adapter protocol onto Inertia's router, so Inertia pages push, pop, and -restore as native screens — plus a web bridge so Inertia pages can use native -bridge components (submit buttons, menus, etc.). +Hotwire Native was built for Turbo, the page-navigation library that ships +with Rails, and it expects to find Turbo on every page. This package stands in +for Turbo and translates its messages to Inertia's router. When Inertia visits a new page, the native app opens a new screen for +it. The package also lets your pages use bridge components, which draw native +UI such as buttons in the navigation bar. + +In a regular browser no native app is listening, so the package does nothing +and Inertia navigates as usual. -In a regular browser it stays inert: with no native adapter connected, Inertia -navigates exactly as usual. +[How it works](/guide/how-it-works) explains the native side step by step. ## Entry points -- **Framework-agnostic core** (`inertia-native`) — peer-depends on - `@inertiajs/core`. Handles all navigation. -- **Bridge component bindings** — optional, each peer-depends on its framework - and provides `useBridgeComponent`: +The package has one core entry and three optional ones: + +- **Framework-agnostic core** (`inertia-native`) peer-depends on + `@inertiajs/core` and handles all navigation. +- **Bridge component bindings** are optional. Each one peer-depends on its + framework and provides `useBridgeComponent`: - `inertia-native/react` - `inertia-native/vue` - `inertia-native/svelte` @@ -35,9 +49,10 @@ navigates exactly as usual. | Repository | What it is | | --- | --- | -| [inertia-native](https://github.com/inertia-native/inertia-native) | The npm package | +| [inertia-native](https://github.com/inertia-native/inertia-native) | The npm package, including the native app templates that `init` uses | +| [bridge-components](https://github.com/inertia-native/bridge-components) | Ready-made bridge components to copy into your app | | [demo-rails](https://github.com/inertia-native/demo-rails) | Web / Rails demo app | | [demo-ios](https://github.com/inertia-native/demo-ios) | iOS Hotwire Native shell | | [demo-android](https://github.com/inertia-native/demo-android) | Android Hotwire Native shell | -Next: [Installation](/guide/installation). +Next: [Quick start](/guide/quick-start). diff --git a/docs/guide/quick-start.md b/docs/guide/quick-start.md new file mode 100644 index 0000000..c31b8f1 --- /dev/null +++ b/docs/guide/quick-start.md @@ -0,0 +1,332 @@ +# Quick start + +This guide takes an Inertia app you already have and runs it as a native iOS +and Android app in a simulator. It works with any backend. The examples show +Laravel and Rails, and the tabs show where the two differ. + +You don't need to know Hotwire Native, Swift, or Kotlin to follow along. +[How it works](/guide/how-it-works) explains the moving parts once you've seen +them run. + +## Before you start + +You need an Inertia app and the tools for the platforms you want to build: + +- An Inertia.js app on Inertia 3 or later, with React, Vue, or Svelte. +- For iOS: a Mac with Xcode 16 or later. +- For Android: Android Studio with at least one emulator. Android Studio + calls an emulator an Android Virtual Device (AVD), and you create one in its + **Device Manager**. + +You can skip the tools for a platform you don't build. + +## 1. Run `init` + +Run this in your app's root, the directory with `package.json`: + +```bash +npx inertia-native init +``` + +It asks a few questions. Press Enter to accept each default: + +- **Platforms**: iOS, Android, or both. +- **App name**: the name under the app icon. The default comes from your + directory name. +- **Bundle ID**: the app's unique ID, written like a reversed domain name + (`com.acme.shop`). The App Store and Google Play use it to tell apps apart. + The default `com.example.…` is fine for trying things out. +- **Dev server URL**: the address the app loads. Use the same URL you open in + your browser during development. +- **Vite host**, if you build for Android: whether `init` may change one line + of your Vite config so that Android can load your scripts. + [Step 2](#_2-start-your-dev-server) explains why. + +Then it changes your project. The output looks like this, with the defaults +accepted: + +::: code-group + +```text [Laravel] +$ npx inertia-native init +? Platforms [both/ios/android] (both) › +✓ Found entrypoint resources/js/app.tsx +? App name (Acme Shop) › +? Bundle ID (com.example.acmeshop) › +? Dev server URL (http://localhost:8000) › +? Set server.host to 127.0.0.1 in vite.config.ts, so Android can load scripts from Vite? (Y/n) › +› npm install inertia-native +✓ Added inertia-native to package.json (npm) +✓ Patched resources/js/app.tsx +✓ Set server.host to 127.0.0.1 in vite.config.ts +✓ Created ios/ and android/ +✓ Added "ios" and "android" scripts to package.json + +Next: start your dev server (composer run dev), then run: npm run ios (or npm run android) +``` + +```text [Rails] +$ npx inertia-native init +? Platforms [both/ios/android] (both) › +✓ Found entrypoint app/javascript/entrypoints/inertia.tsx +? App name (Acme Shop) › +? Bundle ID (com.example.acmeshop) › +? Dev server URL (http://localhost:3000) › +? Set server.host to 127.0.0.1 in vite.config.ts, so Android can load scripts from Vite? (Y/n) › +› npm install inertia-native +✓ Added inertia-native to package.json (npm) +✓ Patched app/javascript/entrypoints/inertia.tsx +✓ Set server.host to 127.0.0.1 in vite.config.ts +✓ Created ios/ and android/ +✓ Added "ios" and "android" scripts to package.json + +Next: start your dev server (bin/dev), then run: npm run ios (or npm run android) +``` + +::: + +`init` finds your Inertia entrypoint by looking for `createInertiaApp(` in the +usual places. It picks `http://localhost:8000` when it finds `artisan` and +`http://localhost:3000` otherwise. If your app runs somewhere else, type that +URL at the prompt. + +`init` uses the package manager your lockfile belongs to, so its hints may say +`pnpm run ios` or `yarn run ios` instead of `npm run ios`. + +`init` is safe to run again: it skips anything that's already done. See +[What `init` did](#what-init-did) for the details, or +[`init` options](#init-options) to run it without questions. + +## 2. Start your dev server + +If you build for Android, `init` asked to change one line of your Vite config. +The Laravel and Rails starter kits serve scripts from `http://[::1]:5173`, an +IPv6 address that Android can't reach. The change makes Vite listen on +`127.0.0.1` instead: + +::: code-group + +```ts [Laravel] +// vite.config.ts +export default defineConfig({ + // … + server: { + host: '127.0.0.1', // [!code ++] + // … + }, +}) +``` + +```ts [Rails] +// vite.config.ts +export default defineConfig(({ command }) => ({ + server: { host: "127.0.0.1" }, // [!code ++] + // … +})) +``` + +::: + +If you answered no, or `init` couldn't change your config safely, make the +change by hand. `init` printed the line to add, and `npm run android` checks +for it and prints the line again if it's missing. iOS works either way. + +Rails apps on `vite_rails` (the ones with `config/vite.json`) don't need this +change, so `init` doesn't ask. Rails serves their scripts from its own +address. + +Now start your app the way you always do: + +::: code-group + +```bash [Laravel] +composer run dev +``` + +```bash [Rails] +bin/dev +``` + +::: + +Open the dev server URL in your browser to check that the app is up. The +native app loads the same URL, so it needs the server running. + +## 3. Run the app + +In a second terminal, run the platform you want: + +```bash +npm run ios +npm run android +``` + +`npm run ios` builds the app, installs it on the simulator that's running, and +opens it. `npm run android` does the same on the emulator or phone that's +connected. On Android it also runs `adb reverse` for your dev server's ports, +so `localhost` on the device reaches your computer. The first build downloads +Hotwire Native and the other dependencies, so it takes longer than the ones +after it. + +If nothing is running, `npm run ios` asks which iPhone simulator to start, +with the newest one as the default. `npm run android` starts your emulator, or asks which +one when you have several. If more than one simulator or device is running, +it asks which one to use. To see them all, add `--list`: + +```bash +npm run ios -- --list +npm run android -- --list +``` + +You should see your home page on a native screen. Try these: + +- Tap a link. A new screen slides in, with a native back button. +- Go back with the back button or a swipe from the left edge. +- Open a page whose URL ends in `/new` or `/edit`. It opens as a modal: a + screen that slides up from the bottom. +- Pull the page down to reload it. + +If Android shows **Error loading page**, see +[the checklist](/native/android#error-loading-page-on-the-emulator). + +::: tip Laravel starter kit: stuck on "Email verification"? +The starter kit asks new users to verify their email. In development it +doesn't send the email: it writes the link to `storage/logs/laravel.log`, and +the app has no address bar to open it. Use an account that's already verified +instead. `php artisan db:seed` creates `test@example.com` with the password +`password`. To verify an account you've already registered, run this and then +pull down to reload the page: + +```bash +php artisan tinker --execute="App\Models\User::where('email', 'you@example.com')->update(['email_verified_at' => now()])" +``` + +::: + +To run on your own phone, see [iOS](/native/ios#run-on-your-iphone) and +[Android](/native/android#run-on-your-phone). + +## 4. Make it feel native + +Your app runs, but it still shows web parts that a native app doesn't need, +such as your site's top navigation bar. [Feel native](/guide/feel-native) +shows how to hide them, close a modal after a form, and add a native button. + +## What `init` did + +`init` made up to five changes. You can make them by hand instead if you +prefer to see each step. + +### The package and two lines of JavaScript + +`init` added the `inertia-native` package with your package manager, then +added two lines to your Inertia entrypoint: + +::: code-group + +```js [Laravel] +// resources/js/app.tsx (or app.ts, app.js) +import { createInertiaApp } from '@inertiajs/react' +import { initInertiaNative } from 'inertia-native' // [!code ++] + +initInertiaNative() // [!code ++] + +createInertiaApp({ + // … +}) +``` + +```js [Rails] +// app/javascript/entrypoints/inertia.tsx (or app/frontend/entrypoints/…) +import { createInertiaApp } from '@inertiajs/react' +import { initInertiaNative } from 'inertia-native' // [!code ++] + +initInertiaNative() // [!code ++] + +createInertiaApp({ + // … +}) +``` + +::: + +The call must come before `createInertiaApp`. In a regular browser it does +nothing, so your website keeps working as before. The +[installation page](/guide/installation) covers the options it takes. + +If `init` can't find your entrypoint, or finds a file it can't safely change, +it prints these two lines for you to add yourself. + +### The Vite host + +If you build for Android and answered yes, `init` set `server.host` to +`127.0.0.1` in your Vite config, as shown in +[step 2](#_2-start-your-dev-server). It adds the line to your `server` options, +or adds `server` if your config doesn't have it, and keeps your quotes and +indentation. + +`init` leaves the file alone when it already sets `server.host`, or when it +can't tell where the line goes, for example when `defineConfig` gets a function +with a body. Then it prints the line for you to add. + +### The native projects + +`init` created two native projects next to your app code: + +- `ios/`: an Xcode project. To run it from Xcode instead of the script, run + `npx inertia-native open ios`, pick a simulator at the top of the window, + and press **Run**. +- `android/`: a Gradle project. To run it from Android Studio instead of the + script, run `npx inertia-native open android`, pick an emulator, and press + **Run**. You also need to + [forward your dev server's ports](/native/android#run-from-android-studio) + once per emulator boot. `open android` prints the commands for it. + +Both are small and readable. [iOS](/native/ios) and [Android](/native/android) +explain each file and how to change the name, bundle ID, and URL later. + +### The scripts + +`init` added two scripts to `package.json`, unless you already had scripts +with those names: + +```json +{ + "scripts": { + "ios": "inertia-native run ios", + "android": "inertia-native run android" + } +} +``` + +`run` warns you if your dev server doesn't answer, and tells you how to fix a +missing Xcode, Android SDK, or JDK. The native pages list its options. + +## `init` options + +Pass flags to skip the questions, for example in a script: + +```bash +npx inertia-native init ios --name "Acme Shop" --bundle-id com.acme.shop --url http://localhost:8000 +``` + +| Option | What it does | +| --- | --- | +| `ios`, `android`, `both` | Which native projects to create. Default: ask, or both. | +| `--name` | Name under the app icon. Default: your directory name, title-cased (`acme-shop` becomes `Acme Shop`). | +| `--bundle-id` | iOS bundle ID and Android application ID. Default: `com.example.` plus the name in lowercase letters and digits. | +| `--url` | URL the app loads. Default: `http://localhost:8000` if `artisan` exists, otherwise `http://localhost:3000`. | +| `--entrypoint` | Path to your Inertia entrypoint, if `init` picks the wrong file. | +| `--skip-install` | Don't add the npm package. | +| `--force` | Replace existing `ios/` and `android/` directories. This deletes changes you made in them. | +| `--yes` | Accept every default without asking, including the Vite host change for Android. | + +`init` only asks questions in an interactive terminal. Elsewhere it uses the +flags and defaults. + +## Next steps + +- [Feel native](/guide/feel-native): hide web-only parts, close modals after + forms, and add native buttons. +- [How it works](/guide/how-it-works): what the native app does and what your + web app does. diff --git a/docs/index.md b/docs/index.md index fdd3cc7..1e36bf7 100644 --- a/docs/index.md +++ b/docs/index.md @@ -10,8 +10,8 @@ hero: text: Get started link: /guide/introduction - theme: alt - text: Installation - link: /guide/installation + text: Quick start + link: /guide/quick-start - theme: alt text: GitHub link: https://github.com/inertia-native/inertia-native diff --git a/docs/native/android.md b/docs/native/android.md index cf91fa0..fea2da5 100644 --- a/docs/native/android.md +++ b/docs/native/android.md @@ -1,25 +1,379 @@ # Android -The native Android shell is a standard [Hotwire Native Android](https://native.hotwired.dev/android/overview) -app that loads your Inertia application in a web view. The package handles the -web side; the Android app provides the navigation stack, path configuration, and -any bridge components. - -::: tip Reference app -A runnable example lives in -[demo-android](https://github.com/inertia-native/demo-android). -Clone it to see a working setup end to end. +`npx inertia-native init` creates an `android/` directory with a small Hotwire +Native Android app. This page explains its files, how to run it on an emulator +and on your phone, and how to change it later. If you haven't run `init` yet, +start with the [quick start](/guide/quick-start). + +## Requirements + +You need Android Studio to build and run the app: + +- Android Studio with at least one emulator. Create one in its **Device + Manager**. Android Studio also installs the Android SDK and comes with a JDK. +- The app runs on Android 9 (API level 28) and later. +- Hotwire Native Android 1.3.1. Gradle downloads it on the first build. + +## What `init` creates + +The project is plain Kotlin with no generated code, so you can read and change +every file: + +```text +android/ +├── app/ +│ ├── build.gradle.kts app ID, the URL the app loads, SDK versions +│ └── src/ +│ ├── main/ +│ │ ├── AndroidManifest.xml +│ │ ├── assets/json/ +│ │ │ └── path-configuration.json bundled navigation rules +│ │ ├── kotlin/dev/inertianative/app/ +│ │ │ ├── MainApplication.kt Hotwire Native setup: path +│ │ │ │ configuration, bridge components +│ │ │ ├── MainActivity.kt opens your URL on launch +│ │ │ ├── WebFragment.kt the screen that shows a web page +│ │ │ ├── Urls.kt the URL the app starts at +│ │ │ └── bridge/ native halves of the alert, button, +│ │ │ form, menu, and overflow-menu components +│ │ └── res/values/strings.xml app name +│ └── debug/res/xml/ +│ └── network_security_config.xml plain HTTP to your computer, +│ debug builds only +├── gradle/libs.versions.toml dependency versions, including Hotwire Native +└── gradlew Gradle wrapper +``` + +The Kotlin package stays `dev.inertianative.app` in every app. Your app's +identity is its application ID, which `init` sets from your bundle ID. You +don't need to rename the package. + +`WebFragment.kt` adds a spinner to the toolbar while a form submits. + +`Urls.kt` adds a trailing `/` to a URL without a path, so the app starts at +`http://localhost:8000/`. That's the form the web view reports, and starting +there avoids an extra redirect on launch. + +## Run on the emulator + +Run this from your app's root, with your dev server running: + +```bash +npm run android +``` + +It builds and installs a debug build with Gradle, forwards your dev server's +ports to the device, and launches your app. It warns you if your dev server +doesn't answer. + +It picks the device this way: + +- **One device or emulator is connected**: it uses that one. +- **Several are connected**: it asks which one. Outside an interactive + terminal, for example in a script, it stops and lists them instead. +- **None is connected**: it starts your emulator and waits for it to boot. If + you have several emulators, it asks which one to start, or stops and lists + them outside an interactive terminal. + +To pick one yourself, pass an option after `--`: + +```bash +npm run android -- --avd Pixel_9 +``` + +| Option | What it does | +| --- | --- | +| `--avd <name>` | The emulator to use, by its AVD name. It's started if needed. | +| `--device <serial>` | A connected device or running emulator to use, by its serial, such as `emulator-5554`. | +| `--list` | List the connected devices and your emulators, then exit. | + +### How the device reaches your computer + +Inside the emulator, `localhost` is the emulator itself, and the app loads your +URL as it is. `npm run android` fixes this with `adb reverse`, which forwards a +port on the device to the same port on your computer. It forwards your app's +port and the Vite dev server's port: + +```text +✓ adb reverse tcp:8000, tcp:5173 (localhost on emulator-5554 reaches this machine) +``` + +After that, `http://localhost:8000` in the app reaches your dev server, and so +do the scripts that Vite serves at `http://127.0.0.1:5173`. The forward lasts +until the emulator shuts down. + +Vite must listen on `127.0.0.1` for this to work. When it listens on the IPv6 +address `[::1]`, the page loads its scripts from `http://[::1]:5173`, which +the device can't reach. `init` offers to make the one-line Vite config change +that [Quick start](/guide/quick-start#_2-start-your-dev-server) shows, and +`npm run android` warns you when it's missing. + +Android blocks plain HTTP by default. In debug builds, +`network_security_config.xml` allows it for `localhost`, `127.0.0.1`, and +`10.0.2.2` only. Release builds keep Android's default and need HTTPS. + +### Run from Android Studio + +You can also run from Android Studio. Run this to open the `android/` +directory in it: + +```bash +npx inertia-native open android +``` + +Wait for Gradle to finish syncing, choose an emulator in the toolbar, and +press **Run**. On Linux, `open` needs Android Studio's `studio` or `studio.sh` +launcher on your `PATH`; otherwise it prints the directory to open. + +Android Studio doesn't forward ports, so `open android` prints the `adb reverse` +commands for your app's ports. Once per emulator boot, either run +`npm run android`, or run those commands. `adb` is in the Android SDK's +`platform-tools` directory: + +::: code-group + +```bash [Laravel] +adb reverse tcp:8000 tcp:8000 +adb reverse tcp:5173 tcp:5173 +``` + +```bash [Rails] +adb reverse tcp:3000 tcp:3000 +adb reverse tcp:5173 tcp:5173 +``` + ::: -## Outline +If your Rails app uses `vite_rails`, its dev server port is in +`config/vite.json`, and it's `3036` by default. + +### "Error loading page" on the emulator + +The app shows this screen when your page doesn't load. Check these, in order: + +1. Your dev server is running, and the URL works in your browser. +2. The ports are forwarded. Run `npm run android` again, or the `adb reverse` + commands above. +3. Vite listens on `127.0.0.1`. If `npm run android` printed a warning about + `[::1]`, add the line it shows to your Vite config and restart the dev + server. + +To look closer, open `chrome://inspect` in Chrome on your computer, find the +app's web view, and check its console and network requests. Debug builds make +the web view inspectable. + +## Run on your phone + +A phone connected over USB works like the emulator, including the port +forwarding. Turn on USB debugging first: + +1. On the phone, open **Settings** > **About phone** and tap **Build number** + seven times. This turns on **Developer options**. +2. In **Developer options**, turn on **USB debugging**. +3. Connect the phone with a cable and accept the prompt on the phone. -1. Create a Hotwire Native Android app (Gradle). -2. Point its navigation at your app's URL. -3. Add a `path-configuration` JSON to control which routes present as modals, - pushes, etc. -4. Register the bridge components your web pages use (`form`, `menu`, …) if any. +Then run `npm run android`. It installs the app on the phone and forwards the +ports, so `localhost` works without changes. If an emulator is running too, it +asks which one to use. You can also pick the phone with `--device` and the +serial that `npm run android -- --list` prints. Unplugging the phone ends the +forward, so run the command again after you reconnect. + +### Over Wi-Fi instead + +Without a cable, the phone needs your computer's address on your Wi-Fi network, +for example `192.168.1.20`. On a Mac, run `ipconfig getifaddr en0` to find it. + +1. Set the URL in `android/app/build.gradle.kts`: + + ```kotlin + buildConfigField("String", "BASE_URL", "\"http://192.168.1.20:8000\"") + ``` + +2. Allow plain HTTP to that address in + `android/app/src/debug/res/xml/network_security_config.xml`. Without this + line, Android blocks the request: + + ```xml + <domain includeSubdomains="false">192.168.1.20</domain> + ``` + +3. Start your server so it accepts connections from the network. Vite's + `127.0.0.1` address isn't reachable over Wi-Fi, so run your app without + the Vite dev server: + + ::: code-group + + ```bash [Laravel] + npm run build + php artisan serve --host=0.0.0.0 + ``` + + ```bash [Rails] + # Rails builds the assets when the Vite dev server isn't running. + bin/rails server -b 0.0.0.0 + ``` + + ::: + +## Change the name, bundle ID, or URL + +`init` wrote your answers into a few places. Change them there: + +| What | Where | +| --- | --- | +| Name under the icon | `app_name` in `android/app/src/main/res/values/strings.xml` | +| Application ID (bundle ID) | `applicationId` in `android/app/build.gradle.kts` | +| URL the app loads | `BASE_URL` in `android/app/build.gradle.kts` | + +`init` also put the name in `android/settings.gradle.kts`. That's only the +Gradle project's name, and users don't see it. + +Before you ship, point release builds at your production site. Set the URL per +build type in `android/app/build.gradle.kts`. A value in a build type wins over +the one in `defaultConfig`: + +```kotlin +buildTypes { + debug { + buildConfigField("String", "BASE_URL", "\"http://localhost:8000\"") + } + release { + buildConfigField("String", "BASE_URL", "\"https://acme.example\"") + // keep the existing release settings + } +} +``` + +You can also run `npx inertia-native init android --force` with new values. It +recreates `android/` from scratch, so it deletes any changes you made there. + +## Path configuration + +Path configuration is the set of rules that decides how each URL opens, for +example as a modal. [How it works](/guide/how-it-works#path-configuration) +explains the format. + +The app reads it from two places: + +1. `android/app/src/main/assets/json/path-configuration.json`, bundled in the + app. +2. `/inertia-native/path-configuration/android_v1.json` on your server, if you + serve it. + +When the app starts, it applies the copy it saved from your server last time, +or the bundled file if it has no saved copy. Then it downloads a fresh copy. A +downloaded copy replaces the bundled rules completely. If the download fails, +for example with a 404, the app keeps what it has. + +The Android file starts with a rule for every URL (`.*`). It tells the app to +show web pages in `WebFragment`, with pull to refresh turned on. Keep that rule +first. + +### Serve it from your app + +Serving the file lets you change navigation with a deploy instead of an app +update. Add a route that returns the same JSON as the bundled file: + +::: code-group + +```php [Laravel] +// routes/web.php +Route::get('/inertia-native/path-configuration/android_v1.json', fn () => [ + 'rules' => [ + [ + 'patterns' => ['.*'], + 'properties' => [ + 'context' => 'default', + 'uri' => 'hotwire://fragment/web', + 'fallback_uri' => 'hotwire://fragment/web', + 'pull_to_refresh_enabled' => true, + ], + ], + [ + 'patterns' => ['/new$', '/edit$'], + 'properties' => ['context' => 'modal', 'pull_to_refresh_enabled' => false], + ], + [ + 'patterns' => ['^/$'], + 'properties' => ['presentation' => 'clear_all'], + ], + ], +]); +``` + +```ruby [Rails] +# config/routes.rb +get "inertia-native/path-configuration/android_v1", to: "path_configurations#android" + +# app/controllers/path_configurations_controller.rb +# ActionController::Base skips your app-wide filters, such as sign-in checks. +class PathConfigurationsController < ActionController::Base + def android + render json: { + settings: {}, + rules: [ + { + patterns: [".*"], + properties: { + context: "default", + uri: "hotwire://fragment/web", + fallback_uri: "hotwire://fragment/web", + pull_to_refresh_enabled: true + } + }, + { patterns: ["/new$", "/edit$"], properties: { context: "modal", pull_to_refresh_enabled: false } }, + { patterns: ["^/$"], properties: { presentation: "clear_all" } } + ] + } + end +end +``` -::: info Work in progress -A full step-by-step walkthrough is being written. For now, the demo app is the -source of truth. ::: + +If you also serve the iOS file, add this action to the same controller. The +[iOS page](/native/ios#serve-it-from-your-app) has the other half. + +Keep these points in mind: + +- **Include every rule.** The server copy replaces the bundled file, so start + from a copy of it. +- **Keep the route public.** The app downloads the file on launch, before + anyone signs in. +- **Leave out `settings` in PHP.** PHP turns an empty array into `[]` instead + of `{}`, and Android can't read `[]` there. The key is optional, so skip it + unless you have settings to send. +- **Relaunch to see changes.** The app downloads the file when it starts. + +## Bridge components + +The native halves of bridge components live in +`android/app/src/main/kotlin/dev/inertianative/app/bridge/`, and +`MainApplication.kt` registers them at launch: + +```kotlin +Hotwire.registerBridgeComponents( + BridgeComponentFactory("alert", ::AlertComponent), + BridgeComponentFactory("button", ::ButtonComponent), + BridgeComponentFactory("form", ::FormComponent), + BridgeComponentFactory("menu", ::MenuComponent), + BridgeComponentFactory("overflow-menu", ::OverflowMenuComponent) +) +``` + +To add a component, put its Kotlin file in the `bridge/` directory and add it +to this list. Set the file's first line to +`package dev.inertianative.app.bridge`. If it uses resources, import +`dev.inertianative.app.R`. Components from the +[registry](https://github.com/inertia-native/bridge-components) mark both +lines for you to replace. See [Bridge components](/components/overview) for +the web side. + +## Debugging + +Debug builds make the web view inspectable. Open `chrome://inspect` in Chrome +on your computer and pick the app's web view. Hotwire Native's own logs show up +in Android Studio's **Logcat** in debug builds. + +To log the messages between your page and the app, turn on `debug` in your +entrypoint. See [Debugging](/guide/navigation#debugging). diff --git a/docs/native/ios.md b/docs/native/ios.md index 00e74ad..9f75e41 100644 --- a/docs/native/ios.md +++ b/docs/native/ios.md @@ -1,44 +1,259 @@ # iOS -The native iOS shell is a standard [Hotwire Native iOS](https://native.hotwired.dev/ios/overview) -app that loads your Inertia application in a web view. The package handles the -web side; the iOS app provides the navigation stack, path configuration, and any -bridge components. - -::: tip Reference app -A runnable example lives in -[demo-ios](https://github.com/inertia-native/demo-ios). -Clone it to see a working setup end to end. +`npx inertia-native init` creates an `ios/` directory with a small Hotwire +Native iOS app. This page explains its files, how to run it on a simulator and +on your iPhone, and how to change it later. If you haven't run `init` yet, +start with the [quick start](/guide/quick-start). + +## Requirements + +You need a Mac with Xcode to build the app: + +- Xcode 16 or later. +- The app runs on iOS 17 and later. +- Hotwire Native iOS 1.3.1. Xcode downloads it with Swift Package Manager on + the first build. + +## What `init` creates + +The project is plain Swift with no generated code, so you can read and change +every file: + +```text +ios/ +├── App.xcodeproj Xcode project: target "App", scheme "App" +└── App/ + ├── AppConfig.swift the URL the app loads + ├── AppDelegate.swift Hotwire Native setup: path configuration, + │ bridge components, options + ├── SceneDelegate.swift opens your URL on launch, handles failed pages + ├── path-configuration.json bundled navigation rules + ├── Bridge/ native halves of the alert, button, form, + │ menu, and overflow-menu components + ├── Assets.xcassets app icon and accent color + └── Info.plist allows plain HTTP to local network addresses +``` + +`AppDelegate.swift` also sets two options: the back button shows only an +arrow, and every modal gets a **Done** button. + +## Run on the simulator + +Run this from your app's root, with your dev server running: + +```bash +npm run ios +``` + +It builds the app into `ios/build`, opens the Simulator app, then installs and +launches your app. It warns you if your dev server doesn't answer. + +It picks the simulator this way: + +- **One simulator is running**: it uses that one. +- **Several are running**: it asks which one. Outside an interactive + terminal, for example in a script, it stops and lists them instead. +- **None is running**: it asks which iPhone simulator to start, with the + newest one selected. Outside an interactive terminal, it starts the newest + iPhone. + +To pick a simulator yourself, pass its name or UDID after `--`: + +```bash +npm run ios -- --device "iPhone 17 Pro" +``` + +| Option | What it does | +| --- | --- | +| `--device <name>` | The simulator to use, by name or UDID. It's started if needed. | +| `--list` | List the running and available simulators, then exit. | + +You can also run from Xcode. Run `npx inertia-native open ios` to open +`ios/App.xcodeproj`, choose a simulator in the run destination menu at the top +of the window, and press **Run** (⌘R). + +The simulator shares your Mac's network, so `http://localhost:8000` or +`http://localhost:3000` reaches your dev server without changes. + +## Run on your iPhone + +`npm run ios` only targets simulators, so use Xcode for a real device. Follow +these steps: + +1. Connect your iPhone to your Mac with a cable. +2. Open the project in Xcode with `npx inertia-native open ios`. +3. Select the **App** target, open **Signing & Capabilities**, and choose your + **Team**. The project ships without a team, so Xcode can't sign the app + until you pick one. If Xcode says the bundle ID isn't available, change it + to one that's yours, like `com.yourname.acmeshop`. +4. On the iPhone, turn on **Developer Mode** in **Settings** > **Privacy & + Security**. +5. Point the app at your Mac and start your server for the network, as + described below. +6. Choose your iPhone in the run destination menu and press **Run**. + +On the phone, `localhost` means the phone itself. Use your Mac's address on +your Wi-Fi network instead, for example `http://192.168.1.20:8000`. Find it in +**System Settings** > **Wi-Fi** > **Details**, or run +`ipconfig getifaddr en0`. Put it in `ios/App/AppConfig.swift`: + +```swift +static let baseURL = URL(string: "http://192.168.1.20:8000")! +``` + +The app's `Info.plist` sets `NSAllowsLocalNetworking`, which lets iOS load +plain HTTP from local network addresses like this one. + +Your server must accept connections from the network, and the page's scripts +must come from an address the phone can reach. The Laravel and Rails starter +kits load scripts from the Vite dev server at a local address, so the page +won't load on the phone. The reliable option is to run your app without the +Vite dev server: + +::: code-group + +```bash [Laravel] +npm run build +php artisan serve --host=0.0.0.0 +``` + +```bash [Rails] +# Rails builds the assets when the Vite dev server isn't running. +bin/rails server -b 0.0.0.0 +``` + +::: + +## Change the name, bundle ID, or URL + +`init` wrote your answers into a few places. Change them there: + +| What | Where | +| --- | --- | +| Name under the icon | Xcode: **App** target > **General** > **Display Name** | +| Bundle ID | Xcode: **App** target > **General** > **Bundle Identifier** | +| URL the app loads | `baseURL` in `ios/App/AppConfig.swift` | + +Xcode saves the name and bundle ID in `ios/App.xcodeproj/project.pbxproj`. The +project, target, and scheme stay named `App`. + +Before you ship, point release builds at your production site. Debug builds +can keep your dev server: + +```swift +// ios/App/AppConfig.swift +#if DEBUG +static let baseURL = URL(string: "http://localhost:8000")! +#else +static let baseURL = URL(string: "https://acme.example")! +#endif +``` + +You can also run `npx inertia-native init ios --force` with new values. It +recreates `ios/` from scratch, so it deletes any changes you made there. + +## Path configuration + +Path configuration is the set of rules that decides how each URL opens, for +example as a modal. [How it works](/guide/how-it-works#path-configuration) +explains the format. + +The app reads it from two places: + +1. `ios/App/path-configuration.json`, bundled in the app. +2. `/inertia-native/path-configuration/ios_v1.json` on your server, if you + serve it. + +When the app starts, it applies the bundled file, then the copy it saved from +your server last time, then downloads a fresh copy. A downloaded copy replaces +the bundled rules completely. If the download fails, for example with a 404, +the app keeps what it has. + +### Serve it from your app + +Serving the file lets you change navigation with a deploy instead of an app +update. Add a route that returns the same JSON as the bundled file: + +::: code-group + +```php [Laravel] +// routes/web.php +Route::get('/inertia-native/path-configuration/ios_v1.json', fn () => [ + 'rules' => [ + [ + 'patterns' => ['/new$', '/edit$'], + 'properties' => ['context' => 'modal', 'pull_to_refresh_enabled' => false], + ], + [ + 'patterns' => ['^/$'], + 'properties' => ['presentation' => 'clear_all'], + ], + ], +]); +``` + +```ruby [Rails] +# config/routes.rb +get "inertia-native/path-configuration/ios_v1", to: "path_configurations#ios" + +# app/controllers/path_configurations_controller.rb +# ActionController::Base skips your app-wide filters, such as sign-in checks. +class PathConfigurationsController < ActionController::Base + def ios + render json: { + settings: {}, + rules: [ + { patterns: ["/new$", "/edit$"], properties: { context: "modal", pull_to_refresh_enabled: false } }, + { patterns: ["^/$"], properties: { presentation: "clear_all" } } + ] + } + end +end +``` + ::: -## Outline +Keep these points in mind: -1. Create a Hotwire Native iOS app and add the SDK via Swift Package Manager. -2. Point its `Navigator` at your app's URL. -3. Add a `path-configuration` JSON to control which routes present as modals, - pushes, etc. -4. Register the bridge components your web pages use (`button`, `form`, `menu`, - …) if any. +- **Include every rule.** The server copy replaces the bundled file, so start + from a copy of it. +- **Keep the route public.** The app downloads the file on launch, before + anyone signs in. +- **Leave out `settings` in PHP.** PHP turns an empty array into `[]` instead + of `{}`. The key is optional, so skip it unless you have settings to send. +- **Relaunch to see changes.** The app downloads the file when it starts. -## Registering bridge components +## Bridge components -Each bridge component your web pages use needs a Swift counterpart registered at -launch, otherwise the web side falls back to its ordinary markup: +The native halves of bridge components live in `ios/App/Bridge/`, and +`AppDelegate.swift` registers them at launch: ```swift Hotwire.registerBridgeComponents([ + AlertComponent.self, ButtonComponent.self, FormComponent.self, MenuComponent.self, + OverflowMenuComponent.self, ]) ``` -Ready-made components — Swift source plus the matching web half — live in the -[bridge-components](https://github.com/inertia-native/bridge-components) -registry. See [Button](/components/button) for a worked example, including the -minimum SDK version they require. +To add a component, put its Swift file in `ios/App/Bridge/` and add it to this +list. Xcode picks up new files in the `App` folder without extra steps. See +[Bridge components](/components/overview) for the web side. -::: info Work in progress -A full step-by-step walkthrough is being written. For now, the demo app is the -source of truth. -::: +## Debugging + +Debug builds make the web view inspectable. Open Safari on your Mac, turn on +**Show features for web developers** in **Settings** > **Advanced**, then pick +the simulator or iPhone from the **Develop** menu. Hotwire Native's own logs +show up in Xcode's console in debug builds. + +To log the messages between your page and the app, turn on `debug` in your +entrypoint. See [Debugging](/guide/navigation#debugging). + +## Handle failed pages + +When a page fails to load, `SceneDelegate.swift` shows Hotwire Native's error +screen with a **Retry** button. To handle a case yourself, such as sending a +401 to your sign-in page, edit `visitableDidFailRequest` there. diff --git a/docs/reference/api.md b/docs/reference/api.md index 0591f7e..ff23638 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -104,4 +104,4 @@ The package installs and reads these on `window`: | --- | --- | --- | | `window.Turbo` | `initInertiaNative()` | Shim that `turbo.js` drives for navigation. Internal. | | `window.HotwireNative.web` | `initInertiaNative()` | Web side of the bridge (`send`, `supportsComponent`, `removeCallback`, …). The name is Hotwire Native's bridge protocol. | -| `window.webkit.messageHandlers.turbo` | WKWebView (iOS) | Feature-detect that the app runs inside the native shell. | +| `window.webkit.messageHandlers.turbo` | WKWebView (iOS) | Hotwire Native's message channel on iOS. Android has no `window.webkit`, so don't use it to detect the app: check the user agent for `Hotwire Native` instead (see [Feel native](/guide/feel-native#tell-the-app-apart-from-a-browser)). |