# Ruby Native full documentation > Turn your Rails app into iOS and Android apps. Any frontend framework. No Xcode or Android Studio required. -------------------------------------------------------------------------------- Page: Documentation URL: https://rubynative.com/docs -------------------------------------------------------------------------------- # Documentation Learn how to get the most out of Ruby Native. These guides cover common features you can add after your first preview. Try Ruby Native ## Getting started - [Setup](/docs/setup) - configure your app and layout - [Tabs](/docs/tabs) - tab bar, eager loading, and tab routing - [Icons](/docs/icons) - SF Symbols on iOS and Material Symbols on Android - [Navigation bar](/docs/navbar) - native navbar with buttons, menus, and submit buttons - [Form handling](/docs/forms) - skip form pages when navigating back - [Inertia (React & Vue)](/docs/inertia) - setup for apps using React or Vue instead of ERB ## More features - [Appearance](/docs/appearance) - colors, dark mode, edge-to-edge content, and landscape orientation - [Error screen](/docs/error-screen) - customize and translate the offline and error fallback screens - [Authentication](/docs/authentication) - keep sessions alive for native users - [OAuth](/docs/oauth) - sign in with Google, GitHub, and other providers - [Linked domains](/docs/linked-domains) - tap-to-open in app and password autofill (iOS) - [Back buttons](/docs/back-buttons) - add native back navigation with a single CSS class - [Root pages](/docs/root-pages) - land a page with nothing behind it and no back button - [Floating action button](/docs/fab) - native FAB above the tab bar - [Barcode scanner](/docs/barcode-scanner) - scan QR codes and barcodes with the device camera - [Haptics](/docs/haptics) - trigger device vibrations on user interaction - [Push notifications](/docs/push-notifications) - request permission, register tokens, and send notifications - [Badges](/docs/badges) - update app icon and tab bar badges from page loads - [In-app review](/docs/review) - ask for an App Store or Google Play rating in-app - [In-app purchases](/docs/in-app-purchases) - sell subscriptions through StoreKit - [Permissions](/docs/permissions) - camera, photo library, and microphone access - [Screenshots](/docs/screenshots) - capture phone screenshots for both stores from one flow - [CLI](/docs/cli) - deploy builds and auto-deploy from CI ## Native screen transitions Want push/pop animations and swipe-to-go-back between screens? [Advanced Mode](/docs/advanced-mode) adds native navigation transitions on top of everything above. No extra JavaScript or dependencies, just a one-line change in `config/ruby_native.yml`. ## iOS The [iOS guides](/docs/ios) cover enrolling in the Apple Developer Program, setting up App Store Connect, and submitting your app to the App Store. ## Android The [Android guides](/docs/android) cover creating your Google Play Developer account, internal testing, and promoting to the production Play Store. -------------------------------------------------------------------------------- Page: Setup URL: https://rubynative.com/docs/setup -------------------------------------------------------------------------------- # Setup ## Installation Add the gem to your Gemfile and run the install generator: ```sh bundle add ruby_native rails generate ruby_native:install ``` The generator creates `config/ruby_native.yml` and prints instructions for updating your layout. ## Tabs Define your tab bar in `config/ruby_native.yml` with a title, path, and [icon](/docs/icons) for each tab. ```yaml tabs: - title: Home path: / icon: house - title: Profile path: /profile icon: person ``` See the [tabs guide](/docs/tabs) for eager loading, tab routing, and the `auto_route` option. ## Entry path By default the app launches on the first tab's path. Set `entry_path` to override this. ```yaml app: entry_path: /inbox ``` If `entry_path` is not set, the app loads the first tab's path, then falls back to `/`. ## Layout Add `native_tabs_tag` to your layout `` to tell the app when to show the tab bar. Like all Ruby Native helpers, it must render inside the ``, not the ``. See the [tabs guide](/docs/tabs) for React and Vue examples. ```erb <%= native_tabs_tag if user_signed_in? %> ``` ### Stylesheet Include the gem's stylesheet in your layout ``. This provides utility classes for safe area layout and hides elements marked with `native-hidden`. ```erb <%= stylesheet_link_tag :ruby_native %> ``` On Sprockets apps, also add `//= link ruby_native.css` to `app/assets/config/manifest.js` or the tag raises an error. Propshaft apps need no extra step. ### Viewport meta tag Add `viewport-fit=cover` to your viewport meta tag. This is required for CSS `env(safe-area-inset-*)` variables to return real values when the web view extends behind the status bar. Without it they resolve to `0` and the `native-inset` classes silently add no padding, so content hides under the status bar. ```erb ``` ### Safe area layout The web view extends behind the status bar and Dynamic Island. Add the `native-inset` class to your main content wrapper so content clears the status bar at the top and the tab bar at the bottom. It stacks with your existing padding. ```erb
<%= yield %>
``` Use `native-inset-top` or `native-inset-bottom` if you only need one side. See the [appearance guide](/docs/appearance) for details and fixed navbar handling. ### Hide web-only UI Use `native_app?` to hide elements that the native app replaces, like a web navbar or footer. ```erb <%= render "navbar" unless native_app? %> <%= render "footer" unless native_app? %> ``` ### Check the native library version Use `native_version` to show or hide content based on the Ruby Native library version the app was built with, like a feature that needs a newer native shell. It returns `"0"` when the version is unknown and supports string comparisons. For the app's own version, use `native_app_version` below. ```erb <% if native_version >= "0.13" %> <%# Show features that require the 0.13 native shell %> <% end %> ``` ### Read app and device details Three more helpers expose what the app reports about itself and the device. Each returns a string for native requests and `nil` for web browsers. ```erb <%= native_app_version %> <%# "5.2", the app's marketing version %> <%= native_app_build %> <%# "35", the build number %> <%= native_os_version %> <%# "26.5.2", the iOS or Android version %> ``` `native_app_build` and `native_os_version` also return `nil` for app builds from before the User-Agent carried them. ## Next steps - [Tabs](/docs/tabs) - eager loading, tab routing, and the `auto_route` option - [Navigation bar](/docs/navbar) - add a native navbar with buttons and menus - [Appearance](/docs/appearance) - customize colors, dark mode, and edge-to-edge content - [Inertia (React & Vue)](/docs/inertia) - setup for apps using React or Vue instead of ERB -------------------------------------------------------------------------------- Page: Tabs URL: https://rubynative.com/docs/tabs -------------------------------------------------------------------------------- # Tabs Define your tab bar in `config/ruby_native.yml` with a title, path, and [icon](/docs/icons) for each tab. ```yaml tabs: - title: Home path: / icon: house - title: Profile path: /profile icon: person ``` You may also omit `tabs` to hide the tab bar entirely. The app will load [`entry_path`](/docs/setup#entry-path) or fall back to `/`. ![Tab bar on iOS](https://rubynative.com/docs/screenshots/tabs/ios.png) ## Showing and hiding the tab bar Add `native_tabs_tag` to your layout to tell the app when to show the tab bar. **ERB:** ```erb <%= native_tabs_tag if user_signed_in? %> ``` Pass `enabled: false` to dynamically hide the tab bar, for example during edit mode: ```erb <%= native_tabs_tag(enabled: !@editing) %> ``` **React:** ```jsx import { NativeTabs } from "@ruby-native/react" export default function Layout({ children }) { const { currentUser } = usePage().props return ( <> {currentUser && } {children} ) } ``` Pass `enabled={false}` to dynamically hide the tab bar, for example during edit mode: ```jsx ``` **Vue:** ```vue ``` Pass `:enabled="false"` to dynamically hide the tab bar, for example during edit mode: ```vue ``` ## Eager loading By default, each tab's content loads when the user first taps it. To load a tab immediately when the tab bar appears, set `eager: true`: ```yaml tabs: - title: Home path: / icon: house - title: Profile path: /profile icon: person eager: true ``` The first tab always loads immediately (it is the visible tab). Use `eager` on other tabs that benefit from being ready when the user switches to them. ## Search tab Mark your app's search screen with `search: true`: ```yaml tabs: - title: Home path: / icon: house - title: Search path: /search icon: magnifyingglass search: true ``` iOS gives the tab the system search treatment: it sits apart from the other tabs with the search appearance, and tapping it loads the tab's `path` like any other tab. This currently applies on iOS in [Advanced Mode](/docs/advanced-mode) only. Normal Mode and Android read the option and ignore it, so it is safe to keep in your config while support catches up. ## Tab routing When a user clicks a link that belongs to a different tab, the app automatically switches to that tab. This works with Turbo, Inertia, and plain HTML links for GET requests. Form submissions (POST, PUT, DELETE) always stay in the current tab, and server-side redirects are not intercepted. ![Tapping a link belonging to another tab auto-switches](https://rubynative.com/docs/screenshots/tabs/routing.gif) By default, each tab matches URLs that start with its `path`. A tab with `path: /inbox` matches `/inbox`, `/inbox/123`, `/inbox/archive`, etc. ### Disabling routing Set `auto_route: false` to disable routing for a tab entirely. ```yaml tabs: - title: Home path: / icon: house auto_route: false - title: Inbox path: /inbox icon: tray - title: Profile path: /profile icon: person ``` ### Custom route prefixes For more control, set `auto_route` to an array of route prefixes. This replaces the default prefix match on `path`. ```yaml tabs: - title: Explore path: /explore icon: binoculars auto_route: - /explore - /breweries/ - /neighborhoods/ - title: Passport path: /passport icon: wallet.bifold ``` A trailing slash means "only match sub-paths." `/breweries/` matches `/breweries/123` but not `/breweries`. Without the trailing slash, both would match. When multiple tabs match a URL, the longest prefix wins. If no tab matches, the link navigates within the current tab as usual. ### JavaScript API `RubyNative.visit()` is tab-aware. Call it to navigate to any URL and the app will switch tabs if needed. ```js RubyNative.visit("/inbox/123") // switches to Inbox tab and navigates RubyNative.visit("/profile") // switches to Profile tab ``` If the URL matches the current tab (or no tab at all), it navigates locally using Turbo, Inertia, or a standard page load. -------------------------------------------------------------------------------- Page: Icons URL: https://rubynative.com/docs/icons -------------------------------------------------------------------------------- # Icons Ruby Native uses native icon libraries for tab bar and button icons: [SF Symbols](https://developer.apple.com/sf-symbols/) on iOS and [Material Symbols](https://fonts.google.com/icons) on Android. Think of either as Font Awesome or Heroicons, but built into the platform. ## Setting an icon In your `config/ruby_native.yml`, set the `icon` for each tab. When the iOS and Android names match (most common icons), one value works for both. ```yaml tabs: - title: Home path: / icon: home - title: Profile path: /profile icon: person ``` When the names differ, use the `icons:` hash and give both platforms a name. Drop `icon:` when you do; it's the fallback for when one name covers both, not something `icons:` needs alongside it. ```yaml tabs: - title: New path: /new icons: ios: plus android: add ``` ## Cross-platform names The most popular choices for typical app navigation: | iOS (SF Symbol) | Android (Material Symbol) | Good for | |---|---|---| | `house` | `home` | Home, dashboard | | `magnifyingglass` | `search` | Search | | `plus` | `add` | Create, new item | | `person` | `person` | Profile, account | | `envelope` | `mail` | Messages, inbox | | `bell` | `notifications` | Notifications | | `gear` | `settings` | Settings | | `star` | `star` | Favorites, bookmarks | | `shippingbox` | `inventory_2` | Orders, packages | | `link` | `link` | Links, connections | | `building.2` | `business` | Company, organization | | `calendar` | `calendar_today` | Events, schedule | ## iOS (SF Symbols) Apple's built-in icon library. Names are case-sensitive and use dots as separators (e.g., `person.crop.circle`, not `personCropCircle`). Apple offers over 6,000 symbols. Two ways to browse them: - **[SF Symbols app](https://developer.apple.com/sf-symbols/) (Mac only):** Download the free SF Symbols app for a more complete browsing experience with search, categories, and weight previews. This is optional. You don't need a Mac to use SF Symbols with Ruby Native. - **[On the web](https://github.com/andrewtavis/sf-symbols-online/blob/master/README.md):** View a searchable gallery with names. ## Android (Material Symbols) Google's icon library, used by Android. Names are lowercase and use underscores as separators (e.g., `calendar_today`, not `calendarToday`). Google offers thousands of symbols. Browse them at [fonts.google.com/icons](https://fonts.google.com/icons). Copy the name shown beneath the icon and paste it into your config exactly as it appears. That name is the entire value, with no `@drawable/` prefix and no trailing size like `_24`. ## Tips - **Stick to the outline style.** Tab bars on both platforms use the outline variant by default and switch to the filled version for the active tab. Use `house`, not `house.fill`. - **Search by concept.** Looking for a "dashboard" icon? Try "chart", "gauge", or "rectangle.grid" on iOS, or "dashboard", "speed", or "view_module" on Android. Naming isn't always obvious. - **Use the underscore name on Android, not the display name.** "Calendar today" is the display name; `calendar_today` is what goes in the config. - **A name that doesn't exist renders a placeholder.** Both platforms fall back to a boxed question mark rather than drawing nothing, so a typo looks like a broken icon instead of an invisible button. - **Test with your tab bar.** Some symbols look great at large sizes but are hard to read at tab bar size. Stick to simple, recognizable shapes. -------------------------------------------------------------------------------- Page: Navigation bar URL: https://rubynative.com/docs/navbar -------------------------------------------------------------------------------- # Navigation bar You have two options for the navigation bar: a native one provided by Ruby Native, or your own web-based one. We recommend the native navigation bar for most apps. It uses liquid glass styling, includes a back button automatically, and supports buttons, menus, submit buttons, and share buttons. If you need full control over the navigation bar's layout and design, you can keep your web-based navbar instead. See [using a web navbar](#using-a-web-navbar) below. To put a centered logo and brand colors on the native bar across the whole app, see [navbar branding](/docs/appearance#navbar-branding). For Inertia setup, see the [Inertia guide](/docs/inertia) first. ![Native navigation bar with title on iOS](https://rubynative.com/docs/screenshots/navbar-title/ios.png) ## Native navigation bar ### Basic usage Add a navbar with a title to each page. Hide your web heading when native so the title isn't duplicated. **ERB:** ```erb <%# app/views/categories/index.html.erb %> <%= native_navbar_tag("Menu") %>

">Menu

``` **React:** ```jsx import { usePage } from "@inertiajs/react" import { NativeNavbar } from "@ruby-native/react" export default function Index() { const { nativeApp } = usePage().props return ( <> {!nativeApp &&

Menu

} ) } ``` **Vue:** ```vue ``` ## Buttons Add buttons to the navigation bar. Use `href` to navigate to a URL, or `click` to click a DOM element by CSS selector. ![Navbar with a trailing button on iOS](https://rubynative.com/docs/screenshots/navbar-button/ios.png) **ERB:** ```erb <%= native_navbar_tag("Menu") do |navbar| %> <%= navbar.button icons: { ios: "bag", android: "shopping_bag" }, href: cart_path %> <% end %> ``` **Inertia:** ```jsx ``` Call the builder with `<%= %>`, not `<% %>`. The methods render nothing on their own, so an output tag keeps inline navbars clean under `erb_lint`, which flags a bare `<% %>` call as an unused expression. **Button options:** | Option | Type | Default | Description | |---|---|---|---| | `icon` | string | | [icon](/docs/icons) name (e.g., `"bag"`, `"plus"`, `"pencil"`). | | `icons` | hash | | Per-platform [icons](/docs/icons), e.g. `{ ios: "ellipsis.circle", android: "more_horiz" }`. A match overrides `icon`. | | `title` | string | | Text label. Use `icon` or `title`, not both. | | `href` | string | | URL to navigate to when tapped. | | `click` | string | | CSS selector of a DOM element to `.click()` when tapped. | | `position` | string | `"trailing"` | `"trailing"` (right), `"leading"` (left), or `"title"` (a dropdown on the title, see [Title menu](#title-menu)). | | `selected` | boolean | `false` | Renders the button in a selected/highlighted state. | A leading button shares its slot with the back button, and the back button wins: the leading button shows on screens with nothing to go back to, like a tab's root. Put actions that should stay reachable everywhere in `trailing`. ## Menus A button with children creates a dropdown menu. Each menu item uses `href` to navigate or `click` to click a DOM element. ![Navbar menu open on iOS](https://rubynative.com/docs/screenshots/navbar-menu/ios.png) **ERB:** ```erb <%= native_navbar_tag("Account") do |navbar| %> <%= navbar.button icons: { ios: "ellipsis.circle", android: "more_horiz" }, position: :leading do |menu| %> <%= menu.item "Edit profile", href: edit_account_path, icons: { ios: "pencil", android: "edit" } %> <%= menu.item "Sign out", click: "#sign-out-button", icons: { ios: "rectangle.portrait.and.arrow.right", android: "logout" } %> <% end %> <% end %> <%# Keep the web element in the DOM so the native menu can click it %> <%= button_to "Sign out", session_path, method: :delete, id: "sign-out-button", class: "native-hidden ..." %> ``` **Inertia:** ```jsx {/* Keep the web element in the DOM so the native menu can click it */} ``` **Menu item options:** | Option | Type | Description | |---|---|---| | `title` | string | The label shown in the action sheet. | | `href` | string | URL to navigate to when selected. | | `click` | string | CSS selector of a DOM element to `.click()` when selected. | | `icon` | string | Optional [icon](/docs/icons) name shown next to the title. | | `icons` | hash | Per-platform [icons](/docs/icons), e.g. `{ ios: "pencil", android: "edit" }`. A match overrides `icon`. | | `selected` | boolean | Renders a checkmark next to the item. | ## Title menu Turn the title itself into a dropdown menu with `position: :title`. Tapping the title opens the menu, and the selected item shows a checkmark. It's the native counterpart of SwiftUI's `toolbarTitleMenu`, and unlike [segments](#segments) it works on both platforms and holds more than a handful of options, so it fits a sort or filter switcher well. ![Navigation bar title with a dropdown chevron on iOS](https://rubynative.com/docs/screenshots/navbar-title-menu-closed/ios.png) Give the button a `menu` and `position: :title`, with no icon or label of its own. Keep a title on `native_navbar_tag` and it stays as the label beside a dropdown chevron; omit the title and the selected item's text becomes the label. Pair the items with `action: :replace` so switching doesn't stack the back button, the same as segments. **ERB:** ```erb <%= native_navbar_tag("Inbox") do |navbar| %> <%= navbar.button position: :title do |menu| %> <%= menu.item "Sort by due date", href: inbox_path(sort: "due"), action: :replace, icon: "calendar", selected: @sort == "due" %> <%= menu.item "Sort by created", href: inbox_path(sort: "created"), action: :replace, icon: "clock", selected: @sort == "created" %> <% end %> <% end %> ``` **Inertia:** ```jsx ``` The items take the same [menu item options](#menus) as any navbar menu. A [navbar logo](/docs/appearance#navbar-branding) owns the center of the bar, so when one is configured the title menu, like segments, doesn't render. ## Segments Show up to three segmented buttons at the top of the screen to switch between closely related pages. For your app's primary sections, use the bottom [tab bar](/docs/tabs) instead. ![Segmented control in the navigation bar on iOS](https://rubynative.com/docs/screenshots/navbar-segments/ios.png) **ERB:** ```erb <%= native_navbar_tag do |navbar| %> <%= navbar.segment "Pledges", href: pledges_path, selected: true %> <%= navbar.segment "Digital Rewards", href: digital_rewards_path %> <% end %> ``` **Inertia:** ```jsx ``` Mark the current page's segment `selected`, and render the same set on each sibling page so the control stays in place as the user moves between them. Switching segments replaces the current history entry instead of stacking it, so the back button doesn't step back through segment switches. **Segment options:** | Option | Type | Default | Description | |---|---|---|---| | `title` | string | | The segment label. First positional argument. | | `href` | string | | URL to navigate to when tapped. | | `click` | string | | CSS selector of a DOM element to `.click()` when tapped, instead of `href`. | | `selected` | boolean | `false` | Marks the current page's segment. | Segments and a [navbar logo](/docs/appearance#navbar-branding) both occupy the center of the bar, so when a logo is configured the segments don't render. ## Submit buttons Add a native submit button that clicks the web form's submit button. The native button mirrors the web button's disabled state automatically, so it disables during form submission and re-enables when done. ![Submit button in navbar on iOS](https://rubynative.com/docs/screenshots/navbar-submit/ios.png) Keep the web submit button in the DOM (use the `native-hidden` class) so the native button can click it. **ERB:** ```erb <%# app/views/accounts/edit.html.erb %> <%= native_form_tag %> <%= native_navbar_tag("Edit profile") do |navbar| %> <%= navbar.submit_button title: "Save" %> <% end %> ``` **React:** ```jsx import { NativeNavbar, NativeSubmitButton } from "@ruby-native/react" export default function Edit() { return ( <>
{/* ... */}
) } ``` **Vue:** ```vue ``` **Submit button options:** | Option | Type | Default | Description | |---|---|---|---| | `title` | string | `"Save"` | The button label in the navigation bar. | | `click` | string | `"[type='submit']"` | CSS selector for the web submit button to click. | ## Share buttons Add a button that opens the native share sheet. By default it shares the current page. Pass `url` to share a different link, and customize the label, icon, and position like any other navbar button. ![Navbar with a share button on iOS](https://rubynative.com/docs/screenshots/navbar-share/ios.png) **ERB:** ```erb <%= native_navbar_tag("Brewery") do |navbar| %> <%# Shares the current page %> <%= navbar.share_button %> <%# Or customize everything %> <%= navbar.share_button url: brewery_url(@brewery), title: "Send", icons: { ios: "square.and.arrow.up.circle", android: "share" }, position: :leading %> <% end %> ``` **React:** ```jsx import { NativeNavbar, NativeShareButton } from "@ruby-native/react" {/* Shares the current page */} {/* Or customize everything */} ``` **Vue:** ```vue ``` **Share button options:** | Option | Type | Default | Description | |---|---|---|---| | `url` | string | current page | The link to share. Defaults to the current page's URL. | | `title` | string | `"Share"` | Accessibility label. Becomes the visible text when the button has no icon. | | `icon` | string | `"square.and.arrow.up"` | [icon](/docs/icons) name, applied to every platform. | | `icons` | hash | | Per-platform [icons](/docs/icons), e.g. `{ ios: "square.and.arrow.up", android: "share" }`. A match overrides `icon`. | | `position` | string | `"trailing"` | `"trailing"` (right) or `"leading"` (left; the back button wins the slot on pushed screens). | ### Share from a menu Put share inside a button's dropdown with `share_item` (`NativeShareMenuItem` in Inertia). It takes the same `url`, `title`, and `icon`/`icons` options. **ERB:** ```erb <%= native_navbar_tag("Brewery") do |navbar| %> <%= navbar.button icons: { ios: "ellipsis.circle", android: "more_horiz" } do |menu| %> <%= menu.item "Edit", href: edit_brewery_path(@brewery) %> <%= menu.share_item %> <% end %> <% end %> ``` **React:** ```jsx import { NativeNavbar, NativeButton, NativeMenuItem, NativeShareMenuItem } from "@ruby-native/react" ``` **Vue:** ```vue ``` ## Pull to refresh The pull-to-refresh control is installed on every page rendering a native navbar. Drag down from the top of the page and release to refresh the current URL. Turbo and Inertia pages refresh correctly because both push state on navigation and the native WebView's URL tracks that. Pass `pull_to_refresh: false` to opt out on a specific page. Useful when a page has its own refresh affordance or an infinite scroll at the top that would conflict. **ERB:** ```erb <%= native_navbar_tag("Map", pull_to_refresh: false) %> ``` **Inertia:** ```jsx ``` HTMX users: pull-to-refresh reloads whatever URL is in the address bar, which may not match the current view after a partial swap. Use `hx-push-url="true"` on the swaps you want refresh to reach, or pass `pull_to_refresh: false` on pages that don't push state. ## Hiding web elements There are two ways to hide web elements when running in the native app: **`native-hidden` class:** The element stays in the DOM but is visually hidden. Use this for elements that need to remain clickable by native buttons, menus, or submit buttons. The `native-hidden` class requires the gem stylesheet (`<%= stylesheet_link_tag :ruby_native %>`). ```erb <%= link_to "Desktop page", desktop_path, class: "native-hidden" %> ``` **Server-side conditional (`unless native_app?`):** The element is not rendered at all. Use this for web-only UI that has no native equivalent, like a web navbar. ```erb <% unless native_app? %> <%= render "shared/footer" %> <% end %> ``` ## Using a web navbar If you need custom controls, branding, or a layout that the native navigation bar doesn't support, you can keep your web-based navbar. The native navigation bar is hidden by default, so if you don't add any `native_navbar_tag` or `NativeNavbar` signals, your web navbar will show as-is. ### Make it sticky Pin your web navbar to the top of the screen so it stays visible as users scroll. ```erb ``` Use `fixed top-0` (Tailwind) or `fixed-top` (Bootstrap) on your navbar element. ### Safe area padding Add `native-inset-top` to your fixed navbar element so its content clears the Dynamic Island. The navbar background extends behind the status bar automatically. See the [appearance guide](/docs/appearance) for safe area classes and layout details. -------------------------------------------------------------------------------- Page: Form handling URL: https://rubynative.com/docs/forms -------------------------------------------------------------------------------- # Form handling When a user submits a form and the app navigates to a success page, tapping "back" should skip the form and return to the page before it. Ruby Native marks form pages so the app skips them in the back stack. In [Advanced Mode](/docs/advanced-mode), you can also add a native submit button to the navigation bar by nesting `navbar.submit_button` inside a `native_navbar_tag`. ![Tapping back skips the form page](https://rubynative.com/docs/screenshots/forms/ios.gif) ## How it works Mark any page that contains a form. The app records it as a form page and skips over it when navigating back. Add this to every form page: new, edit, sign in, sign up, and any other page with a form submission. Signal elements must be in the ``, not the ``. The app detects them via a MutationObserver on the body element. ## Usage **ERB:** ```erb <%# app/views/links/new.html.erb %> <%= native_form_tag %>

New link

<%= render "form", link: @link %> ``` **React:** Set `@native_form = true` in your controller action. The `InertiaSupport` concern shares it automatically as `nativeForm`. ```ruby # app/controllers/habits_controller.rb def new @native_form = true render inertia: "Habits/New", props: { habit: Habit.new } end ``` Then render the `NativeForm` component on form pages: ```jsx import { NativeForm } from "@ruby-native/react" export default function New({ habit, errors }) { return ( <>
{/* ... */}
) } ``` **Vue:** Set `@native_form = true` in your controller action. The `InertiaSupport` concern shares it automatically as `nativeForm`. ```ruby # app/controllers/habits_controller.rb def new @native_form = true render inertia: "Habits/New", props: { habit: Habit.new } end ``` Then render the `NativeForm` component on form pages: ```vue ``` ## Native submit button You can also add a submit button to the [navigation bar](/docs/navbar#submit-buttons) that clicks the web form's submit button and mirrors its disabled state. See the [navbar guide](/docs/navbar#submit-buttons) for details. -------------------------------------------------------------------------------- Page: Inertia (React & Vue) URL: https://rubynative.com/docs/inertia -------------------------------------------------------------------------------- # Inertia (React & Vue) Ruby Native includes first-class support for Inertia.js apps using React or Vue. This guide covers the one-time setup. Once configured, all the feature guides (navbar, forms, badges, etc.) include Inertia examples alongside ERB. ## 1. Include the concern Add `RubyNative::InertiaSupport` to your application controller. It shares `nativeApp` and `nativeForm` props automatically via `inertia_share`. ```ruby # app/controllers/application_controller.rb class ApplicationController < ActionController::Base include RubyNative::InertiaSupport end ``` Your own `InertiaSharedData` concern handles app-specific props (flash, current_user, etc.). The gem concern only shares what it owns. ## 2. Install the npm package React and Vue components ship as two scoped npm packages under the Ruby Native org. Install the one that matches your frontend. **React:** ```sh npm install @ruby-native/react ``` **Vue:** ```sh npm install @ruby-native/vue ``` Import the components directly in your pages and layouts. **React:** ```jsx import { NativeTabs, NativePush, NativePresentation } from "@ruby-native/react" ``` **Vue:** ```js import { NativeTabs, NativePush, NativePresentation } from "@ruby-native/vue" ``` ## 3. Use the `nativeApp` prop The `InertiaSupport` concern shares a `nativeApp` boolean prop on every page. Use it to hide web-only elements when the native app is connected. **React:** ```jsx import { usePage } from "@inertiajs/react" export default function Index() { const { nativeApp } = usePage().props return ( <> {!nativeApp &&

Habits

} {/* ... */} ) } ``` **Vue:** ```vue ``` This is the Inertia equivalent of the `native_app?` helper used in ERB views. ## Feature guides With setup complete, follow the feature guides to add native functionality. Each guide includes Inertia examples alongside ERB. - [Setup](/docs/setup) - tabs and layout - [Navigation bar](/docs/navbar) - native navbar with buttons, menus, and submit buttons - [Form handling](/docs/forms) - skip form pages when navigating back - [Back buttons](/docs/back-buttons) - native back navigation - [Haptics](/docs/haptics) - device vibrations on user interaction - [Badges](/docs/badges) - app icon and tab bar badges -------------------------------------------------------------------------------- Page: Appearance URL: https://rubynative.com/docs/appearance -------------------------------------------------------------------------------- # Appearance Customize your app's colors and layout in `config/ruby_native.yml`. ```yaml appearance: tint_color: "#007AFF" background_color: "#FFFFFF" ``` The `tint_color` controls the active tab icon color and any tinted UI elements. The `background_color` is used as the window background, visible during app launch and view transitions. Set it to match your CSS body background to avoid a flash of white before content loads. If omitted, the default is white. ## Theme By default, the app follows the device's system appearance. Set `theme` to force light or dark mode regardless of the user's device setting. ```yaml appearance: theme: light ``` | Value | Behavior | |---|---| | `auto` | Follows the device setting (default) | | `light` | Always light mode | | `dark` | Always dark mode | When omitted, `theme` defaults to `auto`. ## Dark mode Color fields accept a plain hex string or an object with `light` and `dark` keys. ```yaml appearance: tint_color: "#007AFF" background_color: light: "#FFFFFF" dark: "#212529" ``` The app picks the matching value based on the current appearance. This follows the device setting by default, or the forced `theme` if you set one. If you pass a plain string instead of an object, that color is used for both modes. ## Navbar branding Brand the navigation bar across your whole app: a centered logo in place of the page title, your own bar colors, and a matching status bar. ```yaml appearance: navbar: logo: "<%= image_url('logo.png') %>" background_color: "#3B3F54" foreground_color: "#FFFFFF" status_bar: light ``` Everything in the `navbar` block is optional. Set only the colors for a themed bar with text titles, or add a logo to replace those titles. ### Logo Set `logo` with `image_url`. `config/ruby_native.yml` is evaluated as ERB, and the fingerprinted URL means the app downloads the logo once, caches it, and re-downloads it only when the image changes. ```yaml logo: "<%= image_url('logo.png') %>" ``` A full URL works too, for a logo hosted on a CDN. Use a PNG (or any raster format) with a transparent background; SVGs are not supported. The logo renders about 30 pt tall with its width following the aspect ratio, so export it at roughly 3x that (around 90 to 120 px tall) and keep it a compact wordmark so it doesn't crowd the back button. The logo replaces the page title and stays centered on every screen, including during the native push and pop transitions in [Advanced Mode](/docs/advanced-mode). Leading and trailing [buttons](/docs/navbar#buttons) added with `native_navbar_tag` still appear as usual. ### Bar colors `background_color` sets the bar's background, and `foreground_color` sets the title and bar button color. Each accepts a hex string or a `{ light:, dark: }` object, like the [dark mode](#dark-mode) colors above. ### Status bar Set `status_bar` to keep the clock and system icons readable against your bar: `light` for white content on a dark bar, `dark` for dark content on a light bar. Omit it to let the system decide; it is not derived from your bar color. ## Splash screen On iOS, show the launch screen with an activity indicator while the app loads, instead of flashing to a blank web view. Set `enabled: true` to turn it on. Android needs no additional configuration. The system splash screen and your icon on your background color always remain visible until the first page renders. ```yaml appearance: splash: enabled: true spinner_color: "#007AFF" status_bar: light ``` Everything except `enabled` is optional: - `spinner_color` sets the color of the activity indicator. By default it picks black or white from your `background_color` so it stays visible; override it with your own color, such as a brand accent. Accepts a hex string or a `{ light:, dark: }` object, like the [dark mode](#dark-mode) colors above. - `status_bar` sets the status bar content over the splash: `light` for white content on a dark background, `dark` for dark content on a light one. By default it is chosen to stay readable against your `background_color`. The splash shows on every launch except the very first after install, when your config hasn't been cached yet. Android doesn't read this block. It always shows the system splash screen, holding your launch icon on your `background_color` until the first screen paints, so there's nothing to enable and no spinner to color. ## Edge-to-edge content The web view always extends behind the status bar and Dynamic Island. Your web page controls the entire screen, including the area behind system UI. Add `viewport-fit=cover` to your viewport meta tag so CSS `env(safe-area-inset-*)` variables return real values. ```erb ``` ### Safe area CSS classes The gem stylesheet provides utility classes to add safe area spacing. These stack with your existing padding and margin utilities. ```erb <%= stylesheet_link_tag :ruby_native %> ``` | Class | Effect | |---|---| | `native-inset` | Adds safe area spacing at top and bottom | | `native-inset-top` | Adds safe area spacing at top only | | `native-inset-bottom` | Adds safe area spacing at bottom only | ```erb
<%= yield %>
``` These classes use `::before` and `::after` pseudo-elements, so they stack with padding utilities like `pb-8` without conflicting. Background colors extend through the inset area. ### Fixed navbars For `position: fixed` navbars, use `native-inset-top` directly on the navbar element. The navbar background extends behind the status bar while its content is pushed below the Dynamic Island. ```erb ``` The content area below a fixed navbar still needs its own top padding to clear the navbar, plus `native-inset-top` for the safe area. ### Fixed overlays Navbars pinned to `top: 0` can use `native-inset-top` because the spacer pushes their content down. Overlays anchored at an offset can't: a banner at `top: 0.75rem` or a toast stack near the top of the screen needs the offset itself to account for the safe area. Anchor these with `max()` and the gem's safe area variables: ```css .banner { position: fixed; top: max(0.75rem, calc(var(--ruby-native-safe-area-top) + 0.5rem)); } ``` Or inline with Tailwind: ```erb
``` The same applies to full-screen modals: give the modal container `padding-top: max(0.75rem, var(--ruby-native-safe-area-top))` so its header and close button sit below the status bar. `--ruby-native-safe-area-top` and `--ruby-native-safe-area-bottom` come from the gem stylesheet and resolve everywhere. On iOS they read `env(safe-area-inset-top)`, on Android the app pushes in the real system bar heights, and on the web they fall back to `0px`, so they're safe in CSS that also serves your website. Don't use raw `env(safe-area-inset-*)` for these rules. Android's WebView doesn't populate it from system bars, so an overlay that looks right on iOS silently sits under the Android status bar. ## Landscape orientation By default, apps run in portrait only on phones. Set `landscape: true` to let phones rotate into landscape left and landscape right as well. ```yaml appearance: landscape: true ``` | Value | Behavior on phones | |---|---| | `false` or omitted | Portrait only (default) | | `true` | Portrait, landscape left, and landscape right | Tablets always rotate freely, regardless of this setting. iPads and newer Android tablets ignore orientation locks at the system level, so locking would only apply to older Android tablets and split behavior by OS version. Make sure your layouts work at landscape widths before enabling this, and at tablet widths if you support tablets. Test fixed-width elements, full-height sections, and any CSS that assumes a narrow viewport. -------------------------------------------------------------------------------- Page: Error screen URL: https://rubynative.com/docs/error-screen -------------------------------------------------------------------------------- # Error screen When the app fails to load a page it shows a native screen with an icon, a short message, and a retry button. You can customize the icon and copy and translate the copy into any language your app supports. Customize only what you want to change to override the English defaults. ## The two types of errors | Type | When it shows | |---|---| | `offline` | The device has no network connection: airplane mode or no signal | | `generic` | Any other failure to load: a 500 response, an unreachable server, or a request time out | ## Error messages Title and message copy lives in your app's own locale files, under the `ruby_native` namespace. This reuses the I18n setup you already have, so error copy sits alongside the rest of your app's strings. ```yaml # config/locales/en.yml en: ruby_native: errors: retry: "Retry" offline: title: "You're offline" message: "Check your connection and try again." generic: title: "Something went wrong" message: "Please try again in a moment." ``` The `retry` label is shared by both states. ## Icons Set [per-state icons](/docs/icons) in `config/ruby_native.yml`. ```yaml errors: offline: icons: ios: wifi.slash android: wifi_off generic: icons: ios: exclamationmark.triangle android: error_outline ``` -------------------------------------------------------------------------------- Page: Authentication URL: https://rubynative.com/docs/authentication -------------------------------------------------------------------------------- # Authentication Ruby Native reuses your existing sign in and registration screens in the native app. There's no need to build separate native auth flows. Your web forms, validation, error messages, and forgot password pages all work as-is. There are two things to get right: keeping sessions alive so native users aren't logged out unexpectedly, and telling the app who is signed in so signing out clears their screens. If your app uses OAuth (Sign in with Google, GitHub, etc.), see the [OAuth guide](/docs/oauth). ## Rails authentication generator If you're using the built-in Rails authentication generator, set the session cookie to permanent. ```ruby cookies.signed.permanent[:session_id] = session.id ``` ## Devise Always remember native users by adding a hidden field to your sign-in form. ```erb <%= form_with url: session_path do |form| %> <% if native_app? %> <%= form.hidden_field :remember_me, value: true %> <% else %> <%= form.check_box :remember_me %> <%= form.label :remember_me %> <% end %> <% end %> ``` ## Sign out The native app keeps screens alive between page loads. When signing out, other tabs need their context cleared. Tell the app who is signed in with `native_identity_tag`, and it will clear everything when they sign out or switch accounts. Render the tag on every page, even when the user is signed out: ```erb <%= native_identity_tag current_user&.id %> ``` The value rendered with this tag is hashed using your app's `secret_key_base`, so it won't expose any information. ### Account and team switching Pass an array to widen the identity boundary: ```erb <%= native_identity_tag [current_user&.id, current_team&.id] %> ``` Use this when switching teams should clear the screens, not just re-render the current one. ### Behavior The app resets when the identity is removed or changes: signing out or switching accounts. Signing in never resets, so a login reached deep in a checkout flow keeps its place. A page that renders no tag at all changes nothing in either direction, so a template that misses the layout can never sign anyone out by accident. ### Inertia apps The token is computed server-side so `secret_key_base` never reaches the client. Share it as a prop alongside your other shared data: ```ruby # app/controllers/concerns/inertia_shared_data.rb inertia_share do { native_identity: helpers.native_identity_token(current_user&.id), # ... } end ``` Don't reach for the ERB helper here: Inertia visits never re-render the Rails layout, so an element placed there would keep the old token across a sign-out. Attach the element in `createInertiaApp`'s `resolve` wrapper, never only in your main layout component. Auth and landing pages opt out of layouts, and a signed-out page missing the element makes sign-out invisible to the app. The React version wraps whatever layout a page chose; the Vue version leans on Inertia's nested layout arrays to do the same. **React:** ```jsx // app/frontend/entrypoints/inertia.jsx import { createInertiaApp } from "@inertiajs/react" import { createRoot } from "react-dom/client" import Layout from "~/layouts/Layout" const pages = import.meta.glob("../pages/**/*.jsx", { eager: true }) createInertiaApp({ resolve: (name) => { const page = pages[`../pages/${name}.jsx`] if (!page) throw new Error(`Page not found: ${name}`) if (!page.default.layout?.__withIdentity) { const layout = page.default.layout || ((page) => {page}) const wrapped = (page) => ( <> ``` ### Signal elements | Attribute | Description | |-----------|-------------| | `data-native-purchase="PRODUCT_ID"` | Container for a purchasable product. Tapping the submit button inside triggers StoreKit. | | `data-native-customer-id="ID"` | Your user identifier. Passed back in the `owner_token` event field. | | `data-native-success-path="/path"` | Where to navigate after a successful purchase. | | `data-native-price="PRODUCT_ID"` | Replaced with the localized price from Apple (e.g., "$9.99"). | | `data-native-restore` | Tapping this element restores previous purchases. | The `data-native-price` element shows a fallback price in HTML until Apple loads the real price. This means your paywall works on the web too. ## 4. Deploy Deploy your Rails app and trigger a new build in Ruby Native. The build automatically enables StoreKit when synced products exist. ## Testing Apple provides a sandbox environment for testing purchases without real charges. Create a [sandbox tester account](https://appstoreconnect.apple.com/access/testers) in App Store Connect and sign in on your test device under Settings, then App Store, then Sandbox Account. Sandbox subscriptions renew on an accelerated schedule: | Duration | Sandbox renewal | |----------|----------------| | 1 week | 3 minutes | | 1 month | 5 minutes | | 3 months | 15 minutes | | 6 months | 30 minutes | | 1 year | 1 hour | -------------------------------------------------------------------------------- Page: Permissions URL: https://rubynative.com/docs/permissions -------------------------------------------------------------------------------- # Permissions Both iOS and Android require apps to declare which sensitive capabilities they need. Ruby Native uses one set of dashboard fields to configure permissions for both platforms. ## When you need them **Camera and photo library** come into play when your app has a file input (``). Tapping it opens a picker that can take a new photo (camera) or choose an existing one (photo library). Declare whichever ones your app offers. A library-only upload doesn't need camera. **Microphone** is required if your app captures audio, such as voice notes or audio messages, or records video with sound. If your app doesn't use any of these features, leave the fields blank. Ruby Native excludes the permissions from your build so the system never prompts the user. ## Adding permissions In your app settings, fill in the **Permissions** section for each permission your app uses. The two platforms treat the text differently: - **iOS** shows your text in the system permission dialog the first time the feature is used. - **Android** shows its own generic prompt and never displays your text. There, any non-blank value simply tells Ruby Native to declare the permission in your manifest. Leave a field blank to skip that permission. Good examples: - "Take a photo for your profile picture." - "Choose photos to attach to your listing." - "Record video to share with your team." Keep it specific. Apple reviews these strings during App Store review and recommends being clear, specific, and honest about how the data will be used. See [Write clear purpose strings](https://developer.apple.com/videos/play/tech-talks/110152/) for their best practices. On Android, if you declare camera, microphone, or photo access, you'll also declare that data in your Play Console **Data safety** form when you promote a build to production. That's part of going live, not per-permission setup. See [data safety](/docs/google-play/data-safety) and [submitting your app](/docs/google-play/submission). -------------------------------------------------------------------------------- Page: Screenshots URL: https://rubynative.com/docs/screenshots -------------------------------------------------------------------------------- # Screenshots Both Apple and Google require screenshots before you can publish a production listing. They're often the first thing a potential user sees in store search results. ## How Ruby Native captures screenshots When you click **Capture** on the Screenshots page, Ruby Native boots a real device on its CI infrastructure (an iOS Simulator for iOS, an Android emulator for Android), launches your app, signs in as a designated user, navigates each path you've configured, and captures a screenshot at the native resolution of the device. Each capture takes about 5 minutes for iOS and 5 to 10 minutes for Android (the emulator-cold-boot step is the bulk of the difference). The resulting PNGs land back on the dashboard. Setup is the same for both platforms. Once you've configured the key, the sign-in lambda, and the paths, you can capture for either platform with one click each. ## Setup Open the Screenshots page in your Ruby Native dashboard and follow the three-step wizard. The summary: ### 1. Generate the key Click **Generate key**. The plaintext is shown once. Copy it immediately, Ruby Native stores only an encrypted copy and won't display it again. If you lose it, regenerate it (which invalidates the prior one). ### 2. Configure your Rails app Add the key to your Rails credentials: ```sh bin/rails credentials:edit ``` ```yaml ruby_native: screenshot_key: ``` In `config/initializers/ruby_native.rb`, register the screenshot key and a sign-in lambda. The same lambda handles both iOS and Android, the bridge hits the same `/native/screenshots/session` endpoint from either platform. Your lambda needs to establish a session the same way a real login does. The simplest rule: do whatever your `SessionsController#create` does after the password checks out. If your auth just sets a cookie, set the cookie. If it creates a session record, create the record too. Setting a cookie alone isn't always enough. `helper` exposes `cookies`, `request`, and `session` as public accessors. Methods your `ApplicationController` mixes in (like Devise's `sign_in` helper) aren't available, so manipulate the cookie jar, session, or warden proxy directly. **Cookie-based auth.** Devise, Clearance, or a hand-rolled encrypted cookie. Signing in is one line: ```ruby RubyNative.configure do |c| c.screenshot_key = Rails.application.credentials.ruby_native.screenshot_key c.screenshot_sign_in = ->(helper) { user = User.find_by!(email: "screenshots@example.com") # Pick the line that matches your auth library: # Devise: helper.request.env["warden"].set_user(user) # Clearance: helper.cookies[:remember_token] = user.remember_token # Custom: helper.cookies.permanent.encrypted[:user_id] = user.id } end ``` **Session-record auth.** Rails 8's built-in `authentication` generator and most roll-your-own systems authenticate by looking up a `Session` record from the cookie. A cookie alone won't sign you in here, the record it points to has to exist. Create it, then set the cookie: ```ruby RubyNative.configure do |c| c.screenshot_key = Rails.application.credentials.ruby_native.screenshot_key c.screenshot_sign_in = ->(helper) { user = User.find_by!(email: "screenshots@example.com") session = user.sessions.create!( user_agent: helper.request.user_agent, ip_address: helper.request.remote_ip ) helper.cookies.signed.permanent[:session_id] = { value: session.id, httponly: true, same_site: :lax } } end ``` ### 3. Pick paths to capture In the dashboard, list the paths you want to screenshot, one per line: ``` /dashboard /projects /settings ``` The same paths are used for both platforms. Each store has its own minimum: | Store | Minimum | Maximum | Capture resolution | |---|---|---|---| | App Store | 3 | 10 | 1320 × 2868 (6.9" iPhone) | | Play Store | 2 | 8 | 1080 × 2400 (Pixel 6) | Pick at least 3 to satisfy both stores. ## Tips for good screenshots - **Designate a screenshot user with realistic data.** Empty states make a bad first impression. Seed the user with several projects, completed onboarding, etc. - **Use the screenshot session cookie for determinism.** Wrap relative timestamps, push banners, A/B variants, and analytics calls in `if ruby_native_screenshot_session?` so every capture renders identically. - **Lead with your best screen.** The first screenshot appears in store search results. Pick the one that best communicates what your app does. - **Keep it focused.** A few great screenshots that tell a story are better than 10 mediocre ones. ## Risks and mitigations The endpoint at `/native/screenshots/session` is permanent and hittable from anywhere. The whole security model rests on three things: 1. **The screenshot key is in your Rails credentials, not in source.** Without the key, every request to the endpoint gets a 401. The key is sent via URL parameter on the device's first navigation; the gem auto-filters it from `Rails.application.config.filter_parameters`, so it won't appear in your access logs as anything other than `[FILTERED]`. 2. **You designate which user gets signed in.** Use a sandboxed account, not an admin. The sign-in lambda runs in your Rails process; the gem can't see or modify it. If an attacker gets the key, the worst they can do is sign in as the screenshot user. 3. **Rotation is one click.** "Regenerate" on the dashboard invalidates the old key. Update your Rails credentials and redeploy and the leaked key is dead. What this is *not*: a tunneled, ephemeral, or short-lived auth flow. It's a long-lived shared secret. If you need stricter control (audit log, per-capture token, IP allowlist on the endpoint), open a support ticket and we'll discuss. Most apps don't need it. ## Troubleshooting **Capture finishes but no images uploaded.** The device timed out trying to authenticate. Check the workflow run logs, usually the auth path returned 401 because the dashboard's stored key doesn't match what's in your Rails credentials. Regenerate from the dashboard, update credentials, redeploy. **Pages render but show empty / signed-out content.** Two common causes. Either your `screenshot_sign_in` lambda signed in a real user who happens to have no data (add seed data or designate a different user), or the lambda set a cookie but your auth resolves the current user from a session record it never created. For the second case, mirror your real `SessionsController#create`: create the record, then set the cookie to point at it. See the session-record example in [setup](#2-configure-your-rails-app). **Pages flash with skeleton loaders or missing content (iOS).** The bridge fires the "ready" signal as soon as page chrome enumerates, but customer-side hydration (Stimulus, Inertia, XHR) may continue after that. Wrap deterministic hooks in `ruby_native_screenshot_session?` or contact support if your app needs an explicit `RubyNative.markReady()` JS hook. **Capture hangs partway through (Android).** The emulator may have hit a system UI ANR or wedged on a path that never fires the ready signal. The capture run is time-boxed per path, so a hang on one path skips the rest. Try removing or splitting the offending path. ## Next steps Once your screenshots are captured, fill out the store's data declarations before submitting: - iOS: [App privacy](/docs/ship/app-privacy) - Android: [Data safety](/docs/google-play/data-safety) -------------------------------------------------------------------------------- Page: CLI URL: https://rubynative.com/docs/cli -------------------------------------------------------------------------------- # CLI The Ruby Native CLI lets you deploy builds and manage your app from the terminal. ## Authentication Log in with your Ruby Native account: ```sh bundle exec ruby_native login ``` This opens your browser, authorizes the CLI, and stores a token locally. ### CI environments Set the `RUBY_NATIVE_TOKEN` environment variable instead of using `ruby_native login`. Generate a token by running `ruby_native login` locally, then copy it from `~/.ruby_native/credentials`. ```yaml # GitHub Actions example env: RUBY_NATIVE_TOKEN: ${{ secrets.RUBY_NATIVE_TOKEN }} ``` ## Deploy Trigger an iOS build: ```sh bundle exec ruby_native deploy ``` The CLI queues a build, then polls until it completes. You can press Ctrl+C to stop polling without cancelling the build. ### Android builds Add `--android` to build for Google Play instead: ```sh bundle exec ruby_native deploy --android ``` `--platform=android` and `--platform=ios` work too. Without a flag, `deploy` builds for iOS. ### Auto-deploy in CI Add `--if-needed` to only build when the gem version has changed since your last successful build: ```sh bundle exec ruby_native deploy --if-needed ``` This compares `RubyNative::VERSION` in your bundle against the gem version from your most recent build. If they match, the deploy is skipped (exit code 0). If the gem version is newer, a build is triggered. A typical CI setup: ```yaml # .github/workflows/deploy.yml - name: Deploy to Ruby Native run: bundle exec ruby_native deploy --if-needed env: RUBY_NATIVE_TOKEN: ${{ secrets.RUBY_NATIVE_TOKEN }} ``` This way every Rails deploy checks if a native rebuild is needed, but only triggers one when the gem actually changed. The command triggers the build and exits immediately without waiting for it to finish, so it won't hold up your CI pipeline. If a build fails for any reason, you'll get an email with the error details. ### App linking The first time you run `deploy`, the CLI asks which app to build (if your account has more than one). It stores the selection as `ruby_native.app_id` in `config/ruby_native.yml`. ## Preview Start a Cloudflare Tunnel to your local Rails server and print a QR code to scan with the Ruby Native app: ```sh bundle exec ruby_native preview ``` Two options: | Option | Description | |---|---| | `--port 4000` | The local port your Rails server is on. Defaults to `PORT` when set, then 3000, the same way `rails server` picks its port. | | `--url https://staging.example.com` | Skip the tunnel and point the QR code at a URL you already host. | ## Other commands ```sh bundle exec ruby_native logout # remove stored credentials ``` App Store and Play screenshots are captured by rubynative.com against your deployed site, not from the CLI. See the [screenshots guide](/docs/screenshots). -------------------------------------------------------------------------------- Page: Advanced Mode (BETA) URL: https://rubynative.com/docs/advanced-mode -------------------------------------------------------------------------------- # Advanced Mode (BETA) Advanced Mode replaces web navigation with native screen transitions. Pages push and pop with the same animations users expect from any native app, complete with a native navigation bar, back button, and swipe-to-go-back gesture. It builds on Normal Mode, so everything you already have keeps working. > **Please note that Advanced Mode is currently in BETA.** There might be some rough edges or bugs and the API might change. If you run into any issues then please [email joe](mailto:joe@rubynative.com). ## What you get - **Native screen transitions** with push/pop animations and swipe-to-go-back - **Native navigation bar** with a system back button on every pushed screen - **All Normal Mode signal helpers** keep working unchanged: tabs, navbar title + buttons + menus + submit button, push prompts, badges, haptics, overscroll colors Going Advanced means hiding your web navbar and letting the native navigation bar handle those actions instead. ## Setup Set your app mode to `advanced` in `config/ruby_native.yml`: ```yaml app: mode: advanced ``` Hide your web navbar for native users so the native navigation bar takes over: ```erb <%= render "navbar" unless native_app? %> ``` That's it. No additional JavaScript dependencies, no Stimulus setup, no separate imports. The same `native_*` helpers you use in Normal Mode drive both the web and native experiences. One requirement: every page must load Turbo. Advanced Mode drives native navigation with it, so a page that doesn't load turbo.js shows a configuration error instead of rendering. A stock Rails app with `turbo-rails` installed is already covered; just make sure no layout or page opts out. ## Clear the native navigation bar The native navigation bar in Advanced Mode is translucent, so once you hide your web navbar your page content scrolls underneath it. Add the `native-inset-top` class to the element that should start below the bar, usually your main content wrapper: ```erb
<%= yield %>
``` The class ships in the gem stylesheet, so make sure your layout includes it: ```erb <%= stylesheet_link_tag :ruby_native %> ``` The inset is exactly the height of the system area above your content, so you never guess a value. On iOS it resolves to `env(safe-area-inset-top)`, which spans the status bar and the navigation bar together, so one class clears both. On Android, where the WebView doesn't populate `env(safe-area-inset-*)` from system bars, the native shell measures the real bar height and feeds it in, so you get the same result without any per-device tweaking. Two companion classes cover the rest of the screen: - `native-inset-bottom` clears the home indicator or gesture bar at the bottom. - `native-inset` clears both the top and bottom at once. ## Helpers Advanced Mode uses the same signal helpers as Normal Mode. See the individual guides for the full API: - **Tabs** — [`native_tabs_tag`](/docs/tabs) - **Navigation bar** — [`native_navbar_tag`](/docs/navbar) with nested `button`, `menu`, `segment`, `submit_button`, and `share_button` - **Forms** — [`native_form_tag`](/docs/forms) to mark pages as forms (so back navigation skips them) - **Push notifications** — [`native_push_tag`](/docs/push-notifications) - **Badges** — [`native_badge_tag`](/docs/badges) - **Haptics** — [`native_haptic_data`](/docs/haptics) - **Root pages** — [`native_presentation_tag`](/docs/root-pages) - **Barcode scanner** — [`native_scan_button_tag`](/docs/barcode-scanner) - **In-app review** — [`native_review_tag`](/docs/review) - **Overscroll colors** — `native_overscroll_tag` ## Migrating from Normal to Advanced If your app already uses Normal Mode, flipping to Advanced is a two-line change: 1. Set `mode: advanced` in your `config/ruby_native.yml` (see [setup](#setup) above). 2. Hide your web navbar with `native_app?` so the native navigation bar takes over. That's it. Every `native_*` helper you're already using — tabs, navbar, buttons, menus, submit buttons, badges, haptics — keeps working identically in Advanced Mode. The difference is that new pages now push onto a native navigation stack instead of loading in place. -------------------------------------------------------------------------------- Page: iOS URL: https://rubynative.com/docs/ios -------------------------------------------------------------------------------- # iOS Step-by-step guides for configuring your Apple Developer account, shipping to TestFlight, and submitting to the App Store. ## Setup - [Apple Developer Program](/docs/ios/enrollment) - enroll in the program and set up your account - [Developer credentials](/docs/ios/credentials) - Team ID, App Store Connect API key, and .p8 file - [App Store Connect](/docs/ios/app-store-connect) - create your app record so Ruby Native can upload builds - [Push notifications](/docs/ios/push-notifications) - configure APNs credentials in your Rails app ## Ship to the App Store - [App privacy](/docs/ship/app-privacy) - privacy nutrition labels and what to declare - [In-app purchase setup](/docs/iap/app-store-connect) - create your subscription products - [Submitting your app](/docs/ship/submission) - fill out your listing and submit for review - [App Store review](/docs/ship/app-store-review) - why Ruby Native apps get approved and tips for a smooth review -------------------------------------------------------------------------------- Page: Apple Developer Program URL: https://rubynative.com/docs/ios/enrollment -------------------------------------------------------------------------------- # Apple Developer Program You need an Apple Developer Program membership to distribute apps through TestFlight and the App Store. Ruby Native uses your membership to sign builds and upload them to Apple. ## What you need before enrolling - **An Apple ID.** If you don't have one, create one at [account.apple.com](https://account.apple.com). You don't need a Mac or an iPhone to create an Apple ID. - **Two-factor authentication** enabled on your Apple ID. Apple requires this for developer accounts. ## Enroll 1. Go to [developer.apple.com/programs/enroll](https://developer.apple.com/programs/enroll). 2. Sign in with your Apple ID. 3. Follow the steps to complete enrollment. You'll need to agree to the Apple Developer Agreement and pay the $99/year fee. Apple reviews enrollment applications before granting access. This typically takes **24 to 48 hours**, though it can occasionally take longer. You'll receive an email when your account is ready. ## Individual vs. organization Apple offers two enrollment types: - **Individual** ($99/year): the app is published under your personal name. Fastest to set up. No additional paperwork. - **Organization** ($99/year): the app is published under your company name. Requires a [D-U-N-S number](https://developer.apple.com/support/D-U-N-S/), which is free but can take a few days to obtain. If you're a solo developer or just getting started, individual is fine. You can transfer apps to an organization account later. ## After enrollment Once Apple approves your enrollment, you'll have access to: - [App Store Connect](https://appstoreconnect.apple.com) for managing apps, TestFlight builds, and App Store listings - The [Developer portal](https://developer.apple.com/account) for managing certificates, keys, and identifiers Your next step is to [set up your developer credentials](/docs/ios/credentials) so Ruby Native can build and upload on your behalf. -------------------------------------------------------------------------------- Page: Apple Developer credentials URL: https://rubynative.com/docs/ios/credentials -------------------------------------------------------------------------------- # Apple Developer credentials Ruby Native needs three things from your Apple Developer account to sign your app and upload it to TestFlight: your **Team ID**, an **App Store Connect API key** (with its key ID and issuer ID), and the key's **.p8 private key file**. ## Team ID Your Team ID identifies your Apple Developer account. You'll use the same one for all your apps. 1. Sign in to [developer.apple.com/account](https://developer.apple.com/account). 2. Scroll down to the **Membership details** section. 3. Your **Team ID** is a 10-character alphanumeric string (e.g., ABCDE12345). Copy it. ![Membership details section with Team ID highlighted](/docs/ios/team-id.png) If you're on a team with multiple members, any member can view the Team ID. It's not a secret, just an identifier. ## App Store Connect API key The API key lets Ruby Native upload builds to TestFlight on your behalf. You create it in App Store Connect, not the Developer portal. 1. Go to [App Store Connect](https://appstoreconnect.apple.com) and sign in. 2. Click **Users and Access** in the top navigation. 3. Click the **Integrations** tab. 4. In the left sidebar, click **App Store Connect API**. 5. Under **Team Keys**, click the **+** button to generate a new key. 6. Enter a name you'll recognize later, like "Ruby Native". 7. For access, select **Admin**. Ruby Native needs this role to upload builds and manage provisioning. 8. Click **Generate**. Make sure it's a **Team Key**, the kind under Users and Access. An **Individual Key**, generated from your own user profile, can't access provisioning, so it can't sign your app even with the Admin role. ![Generate API Key dialog with Admin role selected](/docs/ios/generate-api-key.png) After generating the key, you'll see it listed in the table. Three values come from this page: - **Key ID**: shown in the table next to your key name. A short alphanumeric string like ABC123DEFG. - **Issuer ID**: shown at the top of the page, above the key table. A UUID like xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx. This is the same for all keys under your account. - **.p8 file**: click **Download** next to the key. This downloads a file named `AuthKey_XXXXXXXX.p8`. ![API keys table showing Key ID, Issuer ID, and Download button](/docs/ios/api-keys-table.png) **Important:** Apple only lets you download the .p8 file once. If you lose it, you'll need to revoke the key and create a new one. Store it somewhere safe. ## Can I reuse an existing key? Yes, as long as it's a **Team Key** with the **Admin** role. An Individual Key can't sign apps, even as Admin. To reuse a key, you'll need the key ID, issuer ID, and the .p8 file you downloaded when you first created it. ## What if I don't have an Apple Developer account? You need an Apple Developer Program membership ($99/year) to distribute apps through TestFlight and the App Store. Ruby Native can't build or upload without one. See the [enrollment guide](/docs/ios/enrollment) for step-by-step instructions. -------------------------------------------------------------------------------- Page: Create your app in App Store Connect URL: https://rubynative.com/docs/ios/app-store-connect -------------------------------------------------------------------------------- # Create your app in App Store Connect App Store Connect is where Apple manages your app's listing, TestFlight builds, and App Store submissions. Before Ruby Native can upload a build, your app needs to exist there. Ruby Native registers your bundle ID automatically, but Apple doesn't allow creating app records through their API. This is a one-time manual step that takes about two minutes. ## Before you start Make sure you have: - An active [Apple Developer Program membership](/docs/ios/enrollment) - Your [developer credentials](/docs/ios/credentials) entered in Ruby Native (this registers your bundle ID) ## Create the app 1. Go to [appstoreconnect.apple.com/apps](https://appstoreconnect.apple.com/apps) and sign in. 2. Click the **+** button in the top left and select **New App**. 3. Fill in the form: | Field | What to enter | |---|---| | **Platforms** | Check **iOS** | | **Name** | Your app's display name (e.g., "My App"). This is what users see on the App Store. You can change it later. | | **Primary language** | Your preferred language (e.g., English (U.S.)) | | **Bundle ID** | Select your bundle ID from the dropdown. Ruby Native registered it when you entered your credentials. | | **SKU** | Any unique string. Your bundle ID works fine here. | | **User Access** | Full Access (unless you have specific team access needs) | 4. Click **Create**. You'll land on your app's page in App Store Connect. That's it. Head back to Ruby Native and click **Verify app** to continue. ## Common issues ### Bundle ID not showing in the dropdown The dropdown only shows registered bundle IDs. If yours isn't listed: - **Wait a moment.** It can take a minute after Ruby Native registers the bundle ID for it to appear in App Store Connect. - **Check your credentials.** Make sure the API key you entered in Ruby Native belongs to the same Apple Developer account you're signed into in App Store Connect. - **Reload the page.** Close the "New App" dialog, refresh, and try again. ### App name is taken Apple requires unique app names across the entire App Store. If someone else is already using your preferred name, you'll need to pick a different one. This only affects the display name. Your bundle ID stays the same. Try adding a descriptor (e.g., "Acme Tasks" instead of "Tasks") or use your company name as a prefix. ### "You don't have required role" You need the **Account Holder** or **Admin** role in App Store Connect to create apps. If you're on a team, ask someone with the right role to either create the app or upgrade your access in Users and Access. -------------------------------------------------------------------------------- Page: Push notifications URL: https://rubynative.com/docs/ios/push-notifications -------------------------------------------------------------------------------- # Push notifications Configure APNs credentials so your Rails server can send push notifications to iOS devices. If you haven't yet, set up the [Rails side first](/docs/push-notifications). ## Create an APNs key To send push notifications, you need an **APNs key** from your Apple Developer account. This is a separate key from the App Store Connect API key used for builds. 1. Sign in to [developer.apple.com/account](https://developer.apple.com/account). 2. In the middle column, click **Keys** (under Certificates, Identifiers & Profiles). 3. Click the **+** button to register a new key. 4. Enter a name you'll recognize, like "Ruby Native Push". 5. Check the **Apple Push Notifications service (APNs)** checkbox. Leave everything else unchecked. ![Register a New Key page with APNs checkbox checked](/docs/ios/register-key.png) 6. Click **Configure** next to the checkbox. 7. Select **Sandbox & Production** for Environment. 8. Ensure Key Restriction is set to **Team Scoped (All Topics)**. 9. Click **Save**, then **Continue**, then **Register**. ![Configure Key page with Sandbox & Production and Team Scoped selected](/docs/ios/configure-key.png) After registering, you'll see a confirmation page with your **Key ID** (a 10-character string) and a **Download** button for the `.p8` file. ![Download Your Key page showing Key ID and Download button](/docs/ios/download-key.png) **Important:** Apple only lets you download the .p8 file once. If you lose it, you'll need to revoke the key and create a new one. A single APNs key works for every app under your Apple Developer team. You don't need to create a new one per app. APNs keys also never expire. ## Configure APNs credentials The installer creates `config/push.yml`. Add your Apple credentials so the gem can send notifications through APNs: ```yaml # config/push.yml shared: apple: key_id: <%= Rails.application.credentials.dig(:apns, :key_id) %> team_id: <%= Rails.application.credentials.dig(:apns, :team_id) %> topic: com.yourcompany.yourapp encryption_key: <%= Rails.application.credentials.dig(:apns, :encryption_key)&.dump %> ``` - **key_id**: from the APNs key you just created. - **encryption_key**: the full contents of the `.p8` file. - **team_id**: your [Team ID](/docs/ios/credentials#team-id). - **topic**: your app's bundle ID. You can find this on your app's settings page in the Ruby Native dashboard. ![Bundle ID shown on the app page in the Ruby Native dashboard](/docs/ios/bundle-id-dashboard.png) -------------------------------------------------------------------------------- Page: App privacy URL: https://rubynative.com/docs/ship/app-privacy -------------------------------------------------------------------------------- # App privacy Apple requires every app to disclose what data it collects through privacy "nutrition labels." You fill these out in App Store Connect before your first submission. ## Where to find it 1. Go to [appstoreconnect.apple.com](https://appstoreconnect.apple.com) and open your app. 2. Click **App Privacy** in the left sidebar. 3. Click **Get Started** or **Edit** next to Data Collection. Apple will walk you through a series of questions about the data your app collects. Answer based on what your Rails app actually stores, not what iOS frameworks are capable of. ## Data types for a typical Rails app Most Rails apps with user accounts collect a few standard data types. Here's what to declare and how to categorize each one. ### Contact info (email address) Almost every Rails app collects an email address for authentication. - **Data type:** Contact Info > Email Address - **Purpose:** App Functionality - **Linked to identity:** Yes ### Identifiers (user ID) If your app has user accounts, you store some form of user ID. - **Data type:** Identifiers > User ID - **Purpose:** App Functionality - **Linked to identity:** Yes ### Name (if collected) Only declare this if your app collects the user's name during signup or in a profile. - **Data type:** Contact Info > Name - **Purpose:** App Functionality - **Linked to identity:** Yes ### Usage data (analytics) If your app records how users interact with it, declare it. This includes server-side analytics like page views, feature usage, or event tracking. See the next section for details. - **Data type:** Usage Data > Product Interaction - **Purpose:** Analytics - **Linked to identity:** Depends on your setup (see below) ### What most Rails apps don't need to declare Unless your app specifically collects these, skip them: - Location data - Health and fitness data - Financial information - Browsing history - Contacts or address book - Photos or videos (unless your app uploads them) ## Analytics and tracking Apple's definitions here are specific and worth understanding. **"Collect" means data transmitted off the device and stored.** Server-side analytics count if the data is retained, which it almost always is. If your Rails app logs page views, tracks events, or stores any usage metrics, you need to declare "Product Interaction" under Usage Data. **"Tracking" is narrower than you'd think.** Apple defines tracking as linking user or device data with third-party data for targeted advertising or sharing with data brokers. Standard first-party analytics, where you record how users interact with your own app, are categorized as "analytics," not "tracking." **Ruby Native's MAU tracking** uses anonymous device fingerprints to count monthly active users. This needs to be declared as Usage Data > Product Interaction, with the purpose set to Analytics and not linked to identity. If you use third-party analytics like Google Analytics, Mixpanel, or Amplitude, check their documentation for additional data types you may need to declare. ## Privacy policy Every app needs a privacy policy URL in App Store Connect. Add it under **App Information > Privacy Policy URL**. Your existing website privacy policy usually works. Just make sure it mentions the iOS app and covers push notification device tokens if you use them. Your next step is to [submit your app](/docs/ship/submission).

Need help getting to the App Store?

The Turnkey package includes hands-on App Store submission assistance. We'll help you prepare your listing and get through review.

View pricing
-------------------------------------------------------------------------------- Page: App Store Connect setup for in-app purchases URL: https://rubynative.com/docs/iap/app-store-connect -------------------------------------------------------------------------------- # App Store Connect setup for in-app purchases Create your subscription products in App Store Connect so users can purchase them in your app. ## Create a subscription group Subscription groups let users upgrade or downgrade between plans without being charged twice. Each app needs one group. 1. Open your app in [App Store Connect](https://appstoreconnect.apple.com). 2. Go to **Subscriptions** under In-App Purchases in the sidebar. 3. Click the **+** next to **Subscription Groups**. 4. Enter a reference name matching your app name (e.g., "My App"). ![Creating a new subscription group](/docs/iap/asc-create-group.png) ## Add a subscription 1. Within your subscription group, click **Create** next to **Subscriptions**. 2. Enter a **Reference Name** (internal only, e.g., "Pro Annual"). 3. Enter a **Product ID** using reverse domain notation: `com.yourapp.pro.annual`. ![Creating a new subscription with product ID](/docs/iap/asc-create-subscription.png) 4. Click **Create**. 5. Set the **Subscription Duration** (1 week, 1 month, 1 year, etc.). 6. Under **Subscription Prices**, click **Add Subscription Price** and choose your price. Repeat for each plan (e.g., `com.yourapp.pro.monthly` and `com.yourapp.pro.annual`). ## Submit for review Your first subscription must be submitted with a new app version. After that, additional subscriptions can be submitted directly from the Subscriptions page. 1. Go to your app version page in App Store Connect. 2. Scroll to **In-App Purchases and Subscriptions** and add your subscription. 3. Submit the version for review. A review screenshot is required. It must be a JPEG at one of Apple's accepted device dimensions. PNGs may fail with a misleading "dimensions are wrong" error due to file size. | Device | Dimensions | |--------|------------| | iPhone 16 Pro Max (6.9") | 1320 x 2868 | | iPhone 15 Pro Max (6.7") | 1290 x 2796 | | iPhone 14 Pro Max (6.5") | 1284 x 2778 | Ruby Native captures screenshots on an iPhone 16 Pro Max simulator. If uploading manually, take a simulator screenshot and convert to JPEG before uploading. If you're using Ruby Native's screenshot pipeline, the review screenshot is uploaded automatically when you publish your screenshots. ## Sync your products After creating your products, go to your app's **Purchases** tab in the Ruby Native dashboard and click **Sync**. This pulls your product IDs so you can copy them into your paywall HTML. -------------------------------------------------------------------------------- Page: Submitting your app URL: https://rubynative.com/docs/ship/submission -------------------------------------------------------------------------------- # Submitting your app Your build is on TestFlight, your screenshots are uploaded, and your privacy questionnaire is done. Time to submit. ## Before you start Make sure you've completed each of these: - [Screenshots](/docs/screenshots) - uploaded to App Store Connect - [App privacy](/docs/ship/app-privacy) - data collection questionnaire filled out - [App Store review preparation](/docs/ship/app-store-review) - your app meets Apple's guidelines - A privacy policy URL added to App Store Connect ## Fill out the App Store listing Go to [appstoreconnect.apple.com](https://appstoreconnect.apple.com), open your app, and click the version under **iOS App** in the left sidebar. ### App name and subtitle Your app name can be up to 30 characters. The subtitle is another 30 characters and appears below the name in search results. Use both to communicate what your app does. Include relevant keywords where they fit naturally. ### Description You have 4,000 characters, but the first two or three lines matter most. That's what users see before tapping "more." Lead with what the app does and why someone would use it. Don't mention Ruby Native, Rails, or implementation details in your description. Users care about what the app does for them, not how it's built. ### Keywords You get 100 characters of comma-separated keywords. These are hidden from users but used by App Store search. - Don't repeat words from your app name. Apple already indexes those. - Use singular forms ("recipe" not "recipes"). - Don't add spaces after commas. Every character counts. - Think about what your users would search for, not technical terms. ### Promotional text 170 characters that appear above your description. This is the one field you can update without submitting a new version for review. Use it for announcements, seasonal messaging, or highlighting new features. ### Category Pick the category that best describes your app. Common choices for Ruby Native apps: - **Business** - internal tools, B2B apps - **Productivity** - task management, workflow apps - **Lifestyle** - community, events, memberships - **Education** - courses, learning platforms - **Social Networking** - forums, groups, networking - **Utilities** - practical tools You can pick a primary and secondary category. ## Age rating Apple asks a series of yes/no questions about your app's content to determine its age rating. Answer honestly based on what your app contains. Most standard web apps end up rated 4+. ## Select your build Scroll to the **Build** section and click the **+** button. You'll see a list of builds uploaded to TestFlight. Select the one you want to submit. If you don't see your build, make sure it has finished processing in TestFlight. New builds take a few minutes to become available. ## Submit for review 1. Click **Add for Review** in the upper right. 2. Review the summary of everything you're submitting. 3. Click **Submit to App Review**. **Demo account:** if your app requires a login, Apple needs a demo account to test with. Add the credentials in the **App Review Information** section before submitting. This account should have access to the app's core features. ## What to expect Most apps are reviewed within 24 hours. You can check the status in App Store Connect or wait for an email. If Apple has questions or rejects your app, you'll find the details in the **Resolution Center** inside App Store Connect. Common rejection reasons and how to handle them are covered in the [App Store review guide](/docs/ship/app-store-review). ## After approval Once approved, you choose when to release: - **Manually release** - you click a button when you're ready - **Automatically after approval** - goes live as soon as Apple approves - **On a specific date** - schedule a launch date Your app will be available on the App Store within a few hours of release. The direct link to your listing follows this format: ``` https://apps.apple.com/app/id{your_apple_id} ``` You can find your Apple ID in App Store Connect under **App Information**.

Need help with your submission?

The Turnkey package includes hands-on App Store submission assistance. We'll help you prepare your listing, handle rejections, and get your app live.

View pricing
-------------------------------------------------------------------------------- Page: App Store review URL: https://rubynative.com/docs/ship/app-store-review -------------------------------------------------------------------------------- # App Store review Apple reviews every app before it goes live. If your app wraps a website without adding native value, it will be rejected under [guideline 4.2 (Minimum Functionality)](https://developer.apple.com/app-store/review/guidelines/#minimum-functionality). Ruby Native is designed to pass this review. ## Why Ruby Native apps get approved Apple rejects apps that are "repackaged websites." Ruby Native apps are not. Every app includes real native components that Apple expects to see: - **Native tab bar.** Built with first-party iOS APIs, not a web element styled to look native. - **Push notifications.** Real APNs integration, not a web push workaround. - **App icon and launch screen.** Proper Xcode assets, not a web favicon. These features are what separate a native app from a wrapped website in Apple's eyes. Ruby Native includes them by default, configured through YAML. ## Tips for a smooth review Based on shipping 25+ apps through App Store review, here are the most common things to get right. ### Make your app feel complete Apple tests your app as a new user. If reviewers hit a login wall with no way to explore, they may reject for "demo account required." Either provide a demo account in App Store Connect, or make sure unauthenticated users can see enough of the app to understand its value. ### Support account deletion If your app supports account creation, Apple requires you to offer account deletion within the app. This has been enforced since June 2022. Add a "Delete account" option in your settings or profile page that removes the user's account and personal data. Your existing web-based account deletion page works inside the Ruby Native shell. No native-specific code needed. ### Be careful with in-app purchases If your app sells digital goods or subscriptions, Apple requires you to use their in-app purchase system and takes a 30% cut. This applies to any "digital content or services" purchased within the app. Physical goods, services performed outside the app, and person-to-person transactions are exempt. If you're unsure whether your app needs in-app purchases, review [guideline 3.1 (Payments)](https://developer.apple.com/app-store/review/guidelines/#payments) before submitting. ### Include required legal links Every app needs a privacy policy link in two places: somewhere in your Rails app (a settings or profile page works well) and in the App Store Connect metadata. If you have terms of service, include those too. Apple checks for these during review. ## What Ruby Native handles for you When you use cloud builds, Ruby Native takes care of the technical requirements that commonly trip up first-time submissions: - Proper code signing and provisioning profiles - Required `Info.plist` entries (camera, photo library permissions) - Export compliance flag (`ITSAppUsesNonExemptEncryption` set to `NO`), so Apple won't ask about encryption on each submission - App icon and launch screen assets in the correct formats - A properly structured Xcode archive for App Store submission

Need help with App Store review?

The Turnkey package includes hands-on App Store review assistance. If your app is rejected, we'll help you understand the feedback and get it approved.

View pricing
-------------------------------------------------------------------------------- Page: Android URL: https://rubynative.com/docs/android -------------------------------------------------------------------------------- # Android Step-by-step guides for configuring your Google Play Console account, shipping to internal testing, and promoting to the production Play Store. ## Setup - [Developer account](/docs/android/developer-account) - sign up for the Google Play Console - [App listing](/docs/android/app-listing) - create the Play Console app and confirm Play App Signing - [Invite Ruby Native](/docs/android/invite-publisher) - grant our publisher service account permission to upload builds - [Push notifications](/docs/android/push-notifications) - connect Firebase and send notifications to Android devices ## Ship to Google Play - [Internal testing](/docs/google-play/internal-testing) - add testers and trigger your first build - [Data safety](/docs/google-play/data-safety) - fill out the Data safety form for production - [Submitting your app](/docs/google-play/submission) - promote a build from internal testing to production - [Play review](/docs/google-play/play-review) - tips for getting through Google's review process -------------------------------------------------------------------------------- Page: Google Play Developer account URL: https://rubynative.com/docs/android/developer-account -------------------------------------------------------------------------------- # Google Play Developer account You need a Google Play Developer account to distribute apps through the Play Store. Ruby Native uses your account to host your app listing and accept builds we upload on your behalf. ## Enroll 1. Go to [play.google.com/console/signup](https://play.google.com/console/signup). 2. Sign in with the Google account that should own your developer profile. 3. Follow the steps to complete enrollment. You'll need to agree to the Google Play Developer Distribution Agreement and pay the **$25 one-time** registration fee. Google verifies your identity before granting access. This usually takes a few hours, though it can occasionally take longer. You'll get an email when your account is ready. ## Personal vs. organization Google offers two account types: - **Personal** - the app is published under your name. Fastest to set up. - **Organization** - the app is published under your company name. Requires verifying the organization with Google before your first listing can go live. If you're a solo developer or just getting started, personal is fine. You can transfer apps later. ## After enrollment Once Google approves your account, you'll have access to [Play Console](https://play.google.com/console) for managing apps, internal testing tracks, and store listings. Your next step is to [create your app listing](/docs/android/app-listing) so Ruby Native can upload builds. -------------------------------------------------------------------------------- Page: Create your app in Play Console URL: https://rubynative.com/docs/android/app-listing -------------------------------------------------------------------------------- # Create your app in Play Console Play Console is where Google manages your app's listing, internal testing tracks, and Play Store submissions. Before Ruby Native can upload a build, your app needs to exist there. The Google Play Developer API does not allow programmatic creation of new app listings, so this is a one-time manual step that takes about two minutes. ## Before you start Make sure you have an active [Google Play Developer account](/docs/android/developer-account). You'll also need your app's **Bundle ID** from Ruby Native. Open your app's *Settings* page on rubynative.com and copy the value of the *Bundle ID* field. It looks like `app.example.rubynative`. ## Create the app 1. Go to [play.google.com/console](https://play.google.com/console) and sign in. 2. Click **Create app**. 3. Set the app name, default language, and **App** or **Game** classification. 4. Pick **Free** or **Paid**, accept the declarations, and click **Create app**. 5. When Play asks for the package name, paste the Bundle ID you copied from Ruby Native. If the package name doesn't match, the preflight returns: ``` Your Play Console listing for {package_name} doesn't exist. Create it in Play Console first, then retry. ``` ## Play App Signing Play App Signing is enrolled automatically for every new app listing. There's nothing to accept and no key to manage. Google holds the upload key and signs your AABs at distribution time. If you're migrating an existing signing key from another distribution channel, follow [Google's App Signing docs](https://support.google.com/googleplay/android-developer/answer/9842756) for the import flow. Your next step is to [invite Ruby Native's publisher service account](/docs/android/invite-publisher) so we can upload AABs on your behalf. -------------------------------------------------------------------------------- Page: Invite Ruby Native to Play Console URL: https://rubynative.com/docs/android/invite-publisher -------------------------------------------------------------------------------- # Invite Ruby Native to Play Console Ruby Native uploads your Android builds using a shared Google Cloud service account. To grant the build pipeline permission to upload AABs for your app, invite our publisher email into your Play Console with **Release manager** permission. We never see or store your Google credentials. The service account only has access to the apps you explicitly invite it to. ## Find the publisher email The Play Console panel on each app's Settings page in the Ruby Native dashboard shows the publisher email with a copy button. Use that. If the panel says "Not configured," the Ruby Native admin still needs to set the publisher email in credentials. Reach out to support if you don't see a value there. ## Invite the service account 1. Open [Play Console](https://play.google.com/console) and select your app. 2. Click the gear icon (top right) and choose **Users and permissions**. 3. Click **Invite new users**. 4. Paste the publisher email from the dashboard panel into **Email address**. 5. Leave **Account permissions** at default. Service accounts inherit per-app permissions only. 6. Click **Add app** and select your app. 7. Grant the **Release manager** role. If you prefer granular permissions, select all of: - Create, edit, and delete draft apps - Release apps to testing tracks - Release apps to production 8. Click **Save changes**, then **Invite user**. Service accounts show as **Active** immediately. There's no email confirmation step. ## What happens if you skip this If this step is skipped or scoped to the wrong app, the preflight returns: ``` Service account doesn't have publisher access for {package_name}. Invite our publisher email to your Play Console with Release manager permission for this app. ``` Repeat the steps above to fix it, then retry the build. Your next step is to [trigger your first build](/docs/google-play/internal-testing) and get it onto the Internal Testing track. -------------------------------------------------------------------------------- Page: Push notifications URL: https://rubynative.com/docs/android/push-notifications -------------------------------------------------------------------------------- # Push notifications Configure FCM credentials so your Rails server can send push notifications to Android devices. If you haven't yet, set up the [Rails side first](/docs/push-notifications). Android delivery runs through Firebase Cloud Messaging (FCM). The `action_push_native` device model stores Android tokens with `platform: "google"`. ## Create a Firebase project Unlike iOS, Android push needs a Firebase project. Its config is compiled into your app, so this is a one-time step. 1. Create a project at [console.firebase.google.com](https://console.firebase.google.com). 2. Add an Android app using the package name shown on the **Push** tab of your app in the Ruby Native dashboard. 3. Download the `google-services.json` file. Firebase then prompts you to add the SDK and verify the install. Skip those steps. Ruby Native wires up Firebase when it builds your app. 4. Upload `google-services.json` on the **Push** tab in the Ruby Native dashboard. That covers the build-time half: the config baked into the app so it can receive notifications. ## Create an FCM service account key To *send* notifications, your server needs a service account key from the same Firebase project. 1. In the Firebase console, open your project and click the gear icon, then **Project settings**. 2. Open the **Service accounts** tab. 3. Click **Generate new private key**, then confirm. A JSON file downloads. This JSON is a credential. Treat it like a password and do not commit it to your repo. ### If key creation is blocked On a Google Cloud organization (common with Google Workspace accounts) the download can fail with: > Key creation is not allowed on this service account. Please check if service account key creation is restricted by organization policies. Your organization enforces a policy that disables service account keys, and you need an organization admin to lift it: 1. Open [console.cloud.google.com](https://console.cloud.google.com) and switch the resource picker at the top from your project to the **organization**. The role and policy below are only available at the organization scope, not a single project. 2. In **IAM**, grant your own account the **Organization Policy Administrator** role. 3. Go to **IAM & Admin > Organization policies**, find **Disable service account key creation** (`iam.disableServiceAccountKeyCreation`), and set it to **not enforced** for the organization. 4. Wait a few minutes for the change to propagate, then retry **Generate new private key**. ## Configure FCM credentials The contents of that JSON file go into your Rails credentials. Run `bin/rails credentials:edit` and add the `fcm` section: ```yaml action_push_native: fcm: project_id: your-firebase-project-id encryption_key: | { "type": "service_account", "project_id": "your-firebase-project-id", "private_key_id": "...", "private_key": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n", "client_email": "...", ... } ``` Paste the JSON exactly as Firebase generated it. Keep the inner `private_key` value untouched, including its `\n` escapes. Every line must be indented deeper than `encryption_key:` so YAML reads it as one block. The installer creates `config/push.yml`. Add a `google` section so the gem can send through FCM: ```yaml # config/push.yml shared: google: project_id: <%= Rails.application.credentials.dig(:action_push_native, :fcm, :project_id) %> encryption_key: <%= Rails.application.credentials.dig(:action_push_native, :fcm, :encryption_key)&.dump %> ``` If your app also supports iOS, this sits alongside the existing `apple` section. The gem picks the right service per device automatically. -------------------------------------------------------------------------------- Page: Internal testing URL: https://rubynative.com/docs/google-play/internal-testing -------------------------------------------------------------------------------- # Internal testing Internal testing is the fastest Play track. Builds are available to up to 100 testers within minutes of upload, with no Play review. It needs almost nothing set up: as long as your app exists in Play Console and a valid AAB uploads, testers can install it. The store listing, content rating, and App content declarations are only needed for [production](/docs/google-play/submission). ## Trigger your first build 1. From the rubynative.com dashboard, open the app and click the Android build button. 2. Preflight runs in about five seconds. If it fails, fix the underlying issue (usually a missing service account invite, or the app not yet created in Play Console) and retry. 3. The Gradle build runs (2 to 7 minutes) and produces an AAB. 4. The AAB uploads to the Internal Testing track on Play Console automatically. If the AAB build succeeds but the Play upload fails, the build is marked `success` (the AAB is downloadable from the dashboard) and a separate Play-specific error message is attached. Fix the issue it names and re-trigger the build. ## Add testers If you've filled the **Beta testers** field in Ruby Native, the same Play Console panel in the dashboard has a copy-button list of emails. Once the first AAB lands on the Internal Testing track: 1. In Play Console, go to **Testing** -> **Internal testing**. 2. Click the **Testers** tab. 3. Click **Create email list** and paste in tester emails, or share the **Copy link** opt-in URL. 4. Testers install the app from the link on their Android device. Testers must accept the opt-in URL once before they can install the app. The URL stays the same across builds, so you only need to share it once. Google's [set up an open, closed, or internal test](https://support.google.com/googleplay/android-developer/answer/9845334) guide has the full walkthrough. ## Troubleshooting The preflight and upload steps emit specific error messages. Each maps to a setup step: | Message | Cause | Fix | |---|---|---| | Service account doesn't have publisher access for {package_name} | Publisher invite not done or scoped to wrong app | Repeat [invite Ruby Native](/docs/android/invite-publisher) | | Your Play Console listing for {package_name} doesn't exist | App not created in Play Console, or package name mismatch | Repeat [create your app](/docs/android/app-listing) with the bundle ID configured in Ruby Native | If you imported an existing AAB or signing key and Play prompts you to accept App Signing, click through once in Play Console, then retry the build. -------------------------------------------------------------------------------- Page: Data safety URL: https://rubynative.com/docs/google-play/data-safety -------------------------------------------------------------------------------- # Data safety Google requires every app to disclose what data it collects and how it's handled through the Data safety form. You fill this out in Play Console before promoting a build to production. ## Where to find it 1. Go to [play.google.com/console](https://play.google.com/console) and open your app. 2. In the left sidebar, open **Policy and programs** then **App content**. 3. Find the **Data safety** card and click **Start** or **Manage**. Play Console walks you through a series of questions about what data your app collects, how it's used, and who it's shared with. Answer based on what your Rails app actually stores, not what Android frameworks are capable of. ## Data types for a typical Rails app Most Rails apps with user accounts collect a few standard data types. Here's what to declare and how to categorize each one. ### Personal info (email address) Almost every Rails app collects an email address for authentication. - **Category:** Personal info - **Data type:** Email address - **Collected:** Yes - **Shared:** No (unless you send it to a third party) - **Optional:** No - **Purposes:** Account management ### Personal info (user ID) If your app has user accounts, you store some form of user ID. - **Category:** Personal info - **Data type:** User IDs - **Collected:** Yes - **Shared:** No - **Optional:** No - **Purposes:** Account management ### Personal info (name) Only declare this if your app collects the user's name during signup or in a profile. - **Category:** Personal info - **Data type:** Name - **Collected:** Yes - **Shared:** No - **Optional:** Depends on your signup flow - **Purposes:** Account management ### App activity (analytics) If your app records how users interact with it, declare it. This includes server-side analytics like page views, feature usage, or event tracking. - **Category:** App activity - **Data type:** App interactions - **Collected:** Yes - **Shared:** Only if you send it to a third party - **Optional:** No - **Purposes:** Analytics ### What most Rails apps don't need to declare Unless your app specifically collects these, skip them: - Location - Health and fitness - Financial info - Web browsing history - Contacts - Photos and videos (unless your app uploads them) - Audio files - Files and docs ## Data collection vs sharing Google draws a sharp line between these two and the form asks about each separately. **Collection** means your app sends data off the device to a server you control. Storing user records in your Rails database counts as collection. Almost every Rails app collects email and user IDs. **Sharing** means transferring data to a third party. Sending data to your own server is not sharing. Sending it to Mixpanel, Google Analytics, Stripe, or any other vendor is. Be honest here, Play reviewers cross-check declarations against the SDKs in your AAB. ## Security practices Play Console also asks about how you handle the data once you've collected it. - **Data is encrypted in transit.** Yes if your app uses HTTPS, which it almost certainly does. Ruby Native apps enforce HTTPS by default in production. - **You provide a way for users to request data deletion.** Yes if your app has an account-delete flow or you handle requests via email. Google strongly recommends an in-app option. - **Committed to Play's Families Policy.** Only if your app is designed for or appeals to children. Most B2B and prosumer apps answer no. ## Privacy policy Every app needs a privacy policy URL in Play Console. Add it under **App content** then **Privacy Policy**. Your existing website privacy policy usually works. Just make sure it mentions the Android app and covers push notification device tokens if you use them. ## Keeping the declaration up to date The Data safety form is not a one-time thing. Any time you add a new data type (location, contacts, etc.) or a new third-party SDK (analytics, crash reporting, etc.), update the form before your next production release. Play can reject releases that don't match the declared data practices. Your next step is to [submit your app](/docs/google-play/submission).

Need help getting to the Play Store?

The Turnkey package includes hands-on Play Store submission assistance. We'll help you prepare your listing and get through review.

View pricing
-------------------------------------------------------------------------------- Page: Submitting your app URL: https://rubynative.com/docs/google-play/submission -------------------------------------------------------------------------------- # Submitting your app Ruby Native uploads every Android build to the Internal Testing track. Internal testing skips Play review and needs almost nothing beyond a valid AAB, which makes it the right place to validate your app, but it is not the public Play Store. To go live, you promote a build from internal testing to production yourself in Play Console. This mirrors how iOS works: Ruby Native delivers to TestFlight, and you do the App Store release in App Store Connect. ## Before you can go to production Production has requirements internal testing doesn't. Play blocks a production release until each of these is done in Play Console. ### App content In the left sidebar, open **Policy and programs -> App content** and complete every declaration: - **Content rating.** Fill out and submit the [rating questionnaire](https://support.google.com/googleplay/android-developer/answer/9898843). Answer honestly about your app's content. Submitting it generates the IARC age ratings Play shows on your listing. There's no API for this, so it has to be done by hand once. - **Target audience and content.** Declare the [age groups](https://support.google.com/googleplay/android-developer/answer/9867159) your app is designed for. - **Data safety.** Declare what data your app collects, why, and whether it's shared. See [data safety](/docs/google-play/data-safety) for a typical Rails app walkthrough. - **Privacy policy.** Add a privacy policy URL. Required for nearly every app, and your existing web privacy policy usually works. - **Ads, government apps, financial features, health.** Answer the remaining declarations. Most apps answer "no" to all of them. ### Store listing Open **Grow -> Store presence -> Main store listing** and fill out every field: - **App title.** Up to 30 characters. This is what users see on the Play Store. - **Short description.** Up to 80 characters. Appears at the top of your listing. - **Full description.** Up to 4,000 characters. Lead with what your app does. - **Phone screenshots.** At least 2, up to 8 allowed. - **Feature graphic.** 1024 by 500 pixels. Required for every listing. - **App icon.** 512 by 512 pixels. Play wants its own copy even though Ruby Native already uploads one with each build. - **Category.** Pick the one that best describes your app, under **Store settings**. A couple of tips: - **Lead with your best screenshot.** The first image appears in Play Store search results. - **Don't mention Ruby Native or Rails in the description.** Users care about what the app does for them, not how it's built. Play Console shows a checklist on the **Dashboard** and on the **App content** page. Work through it until every item has a green check. ## New developer accounts need a closed test first If your Play developer account is a **personal account created on or after November 13, 2023**, you can't promote straight to production. Google [requires a closed test](https://support.google.com/googleplay/android-developer/answer/14151465) with at least 12 testers opted in continuously for 14 days before you can apply for production access. **Organization (business) developer accounts are not subject to this.** With one of those, you can go straight to production once the checklist above is done. To run the closed test, promote a Ruby Native build to a closed testing track the same way you'd promote to production, choosing **Closed testing** instead of **Production**. Add your testers, leave it running for the full 14 days, then apply for production access from the Play Console **Dashboard**. ## Promote a build to production Once a Ruby Native build has landed on internal testing and you've cleared the checklist above: 1. In Play Console, open **Testing -> Internal testing**. 2. Find the release you want to ship and click **Promote release -> Production**. 3. Review the release. The AAB, version code, and release notes carry over from internal testing, so you can edit the release notes if you want and leave the rest. 4. Set the rollout percentage. A staged rollout, say 20 percent, lets you pause if something goes wrong, while 100 percent ships to everyone at once. 5. Click **Next**, then **Save and publish**, or **Send for review** if this is your first production release. You can also create a fresh production release from **Production -> Create new release** and add the same AAB, but promoting the internal build is fewer steps. Google's [prepare and roll out a release](https://support.google.com/googleplay/android-developer/answer/9859348) guide covers the full release form. ## Play review Google reviews every production release. A brand new app usually takes a few days, while updates to an app that's already live usually clear in a few hours. Internal testing builds skip review entirely, which is why you can keep shipping to testers while a production release is in review. Most rejections trace back to the App content declarations: a missing privacy policy, an incomplete data safety form, or a content rating that doesn't match what the app actually does. The [Play review](/docs/google-play/play-review) guide has more detail. ## Future builds After your first production release, every new Ruby Native build still lands on internal testing. Promote it to production the same way whenever you're ready, and returning releases clear review much faster than the first one. If you want hands-on help with your first Android submission, the [Turnkey package](/pricing) includes Play Store submission assistance. -------------------------------------------------------------------------------- Page: Play review URL: https://rubynative.com/docs/google-play/play-review -------------------------------------------------------------------------------- # Play review Google reviews every app before it goes live on the production track. Internal testing builds skip review, so you can validate your app end-to-end before facing reviewers. More guidance coming soon. In the meantime, the most common rejection reasons map directly to the production checklist in [submitting your app](/docs/google-play/submission): missing privacy policy, incomplete data safety form, or content rating that doesn't match what the app actually does. If you want hands-on help getting through Play review, the [Turnkey package](/pricing) includes review assistance for both iOS and Android.