Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .cursor/rules/project-conventions.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,11 @@ All code should maximize readability and simplicity.
- ActiveRecord validations _may_ mirror the DB level ones, but not 100% necessary. These are for convenience when error handling in forms. Always prefer client-side form validation when possible.
- Complex validations and business logic should remain in ActiveRecord

### Hotwire Native Prototype Guidance

- Consult [docs/hotwire_native_prototype_plan.md](mdc:docs/hotwire_native_prototype_plan.md) before starting native wrapper work.
- Create a dedicated branch named `feature/hotwire-native-prototype` from `main` for prototype tasks and keep unrelated changes out.
- Every change that targets the native wrapper must also be validated in the existing PWA to maintain parity.
- Turbo Native user agents are auto-detected via `TurboNative::Controller`; rely on the `:turbo_native` layout variant rather than bespoke conditionals in views.
- Keep navigation definitions in `ApplicationHelper#primary_navigation_items` so both the web layout and the native bridge stay synchronized.

7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@
- Lint/format JS/CSS: `npm run lint` and `npm run format` — uses Biome.
- Security scan: `bin/brakeman` — static analysis for common Rails issues.

## Mobile Prototype Workflow
- Planning artifacts for the Hotwire Native prototype live in `docs/hotwire_native_prototype_plan.md`. Review and update the checklist before starting native-wrapper work.
- Implementation spikes for the native wrappers must originate from the `feature/hotwire-native-prototype` branch cut off the latest `main` once planning is approved.
- Keep PWA regressions in mind: always confirm changes behave in both the web PWA and Turbo Native contexts.
- Turbo Native requests are detected via `TurboNative::Controller`; when you add new controllers ensure they inherit from `ApplicationController` (or include the concern) so the `:turbo_native` layout variant is applied automatically.
- The native layout exports navigation metadata through `ApplicationHelper#turbo_native_navigation_payload` and the `turbo_native_bridge` Stimulus controller. When adding tabs or nav links, update the helper to keep web and native shells in sync.

## Coding Style & Naming Conventions
- Ruby: 2-space indent, `snake_case` for methods/vars, `CamelCase` for classes/modules. Follow Rails conventions for folders and file names.
- Views: ERB checked by `erb-lint` (see `.erb_lint.yml`). Avoid heavy logic in views; prefer helpers/components.
Expand Down
7 changes: 7 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,13 @@ Only proceed with pull request creation if ALL checks pass.
- Do not run `rails credentials`
- Do not automatically run migrations

### Hotwire Native Prototype Process
- Review `docs/hotwire_native_prototype_plan.md` before beginning any native wrapper work.
- Cut a fresh branch named `feature/hotwire-native-prototype` from `main` for all prototype changes; keep it focused on the Turbo Native spike.
- Ensure features continue to function for both Turbo Native wrappers and the existing PWA before submitting a PR.
- Controllers automatically detect native requests via `TurboNative::Controller`; lean on the `turbo_native_app?` helper instead of inspecting headers manually.
- Keep navigation data centralized in `ApplicationHelper#primary_navigation_items` so the `turbo_native_bridge` controller can mirror updates inside the native shells.

## High-Level Architecture

### Application Modes
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
class ApplicationController < ActionController::Base
include RestoreLayoutPreferences, Onboardable, Localize, AutoSync, Authentication, Invitable,
SelfHostable, StoreLocation, Impersonatable, Breadcrumbable,
FeatureGuardable, Notifiable
FeatureGuardable, Notifiable, TurboNative::Controller

include Pagy::Backend

Expand Down
32 changes: 32 additions & 0 deletions app/controllers/concerns/turbo_native/controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
module TurboNative
module Controller
extend ActiveSupport::Concern

TURBO_NATIVE_USER_AGENT = /Turbo\s?Native/i
TURBO_NATIVE_HEADER = "Turbo-Native"
TURBO_VISIT_CONTROL_HEADER = "Turbo-Visit-Control"

included do
before_action :set_turbo_native_variant
helper_method :turbo_native_app?
end

private
def set_turbo_native_variant
request.variant = :turbo_native if turbo_native_app?
end

def turbo_native_app?
return @turbo_native_app unless @turbo_native_app.nil?

@turbo_native_app = turbo_native_user_agent? ||
request.headers[TURBO_NATIVE_HEADER].present? ||
request.headers[TURBO_VISIT_CONTROL_HEADER] == "native"
end

def turbo_native_user_agent?
user_agent = request.user_agent.to_s
user_agent.match?(TURBO_NATIVE_USER_AGENT)
end
end
end
1 change: 1 addition & 0 deletions app/controllers/sessions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ def create

def destroy
@session.destroy
response.set_header("Turbo-Visit-Control", "reload") if turbo_native_app?
redirect_to new_session_path, notice: t(".logout_successful")
end

Expand Down
33 changes: 33 additions & 0 deletions app/helpers/application_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,28 @@ def page_active?(path)
current_page?(path) || (request.path.start_with?(path) && path != "/")
end

def primary_navigation_items
[
nav_item("Home", root_path, "pie-chart"),
nav_item("Transactions", transactions_path, "credit-card"),
nav_item("Budgets", budgets_path, "map"),
nav_item("Assistant", chats_path, "icon-assistant", icon_custom: true, mobile_only: true)
]
end

def turbo_native_navigation_payload
primary_navigation_items.map do |item|
{
title: item[:name],
url: item[:path],
icon: item[:icon],
icon_custom: item[:icon_custom],
mobile_only: item[:mobile_only],
active: item[:active]
}
end
end

# Wrapper around I18n.l to support custom date formats
def format_date(object, format = :default, options = {})
date = object.to_date
Expand Down Expand Up @@ -123,6 +145,17 @@ def markdown(text)
end

private
def nav_item(name, path, icon, icon_custom: false, mobile_only: false)
{
name: name,
path: path,
icon: icon,
icon_custom: icon_custom,
mobile_only: mobile_only,
active: page_active?(path)
}
end

def calculate_total(item, money_method, negate)
# Filter out transfer-type transactions from entries
# Only Entry objects have entryable transactions, Account objects don't
Expand Down
84 changes: 84 additions & 0 deletions app/javascript/controllers/turbo_native_bridge_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { Controller } from "@hotwired/stimulus";

const messageHandlers = [
(payload) => window.webkit?.messageHandlers?.hotwireNative?.postMessage?.(payload),
(payload) => window.HotwireNative?.postMessage?.(payload),
(payload) => window.HotwireNativeBridge?.postMessage?.(payload),
];

export default class extends Controller {
static values = {
navigation: Array,
activePath: String,
};

connect() {
this.visitListener = this.handleVisitRequest.bind(this);
this.boundHandleTurboLoad = this.handleTurboLoad.bind(this);

document.addEventListener("hotwire-native:visit", this.visitListener);
document.addEventListener("turbo:load", this.boundHandleTurboLoad);

window.hotwireNative ||= {};
window.hotwireNative.visit = (url, options = {}) => {
if (!url) return;
window.Turbo?.visit(url, options);
};

this.publish({ event: "connect" });
}

disconnect() {
document.removeEventListener("hotwire-native:visit", this.visitListener);
document.removeEventListener("turbo:load", this.boundHandleTurboLoad);
}

navigationValueChanged() {
this.publish({ event: "navigation:update" });
}

activePathValueChanged() {
this.publish({ event: "location:update" });
}

handleTurboLoad() {
this.publish({ event: "visit" });
}

handleVisitRequest(event) {
const { url, options } = event.detail || {};
if (!url) {
return;
}

window.Turbo?.visit(url, options || {});
}

publish({ event }) {
const payload = {
event,
url: window.location.href,
path: this.activePathValue || window.location.pathname,
title: document.title,
navigation: this.navigationValue || [],
};

document.dispatchEvent(
new CustomEvent("hotwire-native:bridge", { detail: payload }),
);

messageHandlers.some((handler) => {
if (typeof handler !== "function") {
return false;
}

try {
handler(payload);
return true;
} catch (error) {
console.warn("Failed to notify native bridge", error);
return false;
}
});
}
}
16 changes: 16 additions & 0 deletions app/views/layouts/application.html+turbo_native.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<% native_navigation_payload = turbo_native_navigation_payload %>

<% content_for :head do %>
<%= tag.meta name: "turbo-native", content: "true" %>
<%= tag.meta name: "turbo-native-navigation", content: native_navigation_payload.to_json %>
<% end %>

<%= render "layouts/shared/htmldoc" do %>
<%= tag.div class: "min-h-full bg-surface", data: {
controller: "turbo-native-bridge",
turbo_native_bridge_navigation_value: native_navigation_payload.to_json,
turbo_native_bridge_active_path_value: request.fullpath
} do %>
<%= yield %>
<% end %>
<% end %>
7 changes: 1 addition & 6 deletions app/views/layouts/application.html.erb
Original file line number Diff line number Diff line change
@@ -1,9 +1,4 @@
<% mobile_nav_items = [
{ name: "Home", path: root_path, icon: "pie-chart", icon_custom: false, active: page_active?(root_path) },
{ name: "Transactions", path: transactions_path, icon: "credit-card", icon_custom: false, active: page_active?(transactions_path) },
{ name: "Budgets", path: budgets_path, icon: "map", icon_custom: false, active: page_active?(budgets_path) },
{ name: "Assistant", path: chats_path, icon: "icon-assistant", icon_custom: true, active: page_active?(chats_path), mobile_only: true }
] %>
<% mobile_nav_items = primary_navigation_items %>

<% desktop_nav_items = mobile_nav_items.reject { |item| item[:mobile_only] } %>
<% expanded_sidebar_class = "w-full" %>
Expand Down
10 changes: 9 additions & 1 deletion app/views/layouts/shared/_head.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,15 @@
<%= csrf_meta_tags %>
<%= csp_meta_tag %>

<%= stylesheet_link_tag "tailwind", "data-turbo-track": "reload" %>
<% if Rails.env.test? %>
<% begin %>
<%= stylesheet_link_tag "tailwind", "data-turbo-track": "reload" %>
<% rescue StandardError => e %>
<% Rails.logger.debug { "tailwind.css unavailable in test: #{e.message}" } %>
<% end %>
<% else %>
<%= stylesheet_link_tag "tailwind", "data-turbo-track": "reload" %>
<% end %>

<%= javascript_include_tag "https://cdn.plaid.com/link/v2/stable/link-initialize.js" %>
<%= combobox_style_tag %>
Expand Down
126 changes: 126 additions & 0 deletions docs/hosting/native.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# Turbo Native Hosting Guide

This guide explains how to build and ship the iOS and Android shells that host the Sure web experience via Hotwire Native. It assumes you have already cut the `feature/hotwire-native-prototype` branch and merged the Rails changes documented in `docs/hotwire_native_prototype_plan.md`.

## 1. Account & Access Checklist

| Platform | Required Accounts | Notes |
| --- | --- | --- |
| Apple iOS | Apple Developer Program (Individual or Company) | One team owner must invite the rest of the mobile crew via App Store Connect. Enable access to Certificates, Identifiers & Profiles. |
| Google Android | Google Play Console & Google Cloud project | Grant release managers the "Release manager" role and ensure a linked Firebase project for crash reporting (optional but recommended). |
| Source Control | GitHub (or host of record) access to the `feature/hotwire-native-prototype` branch | Keep Rails and native wrappers in sync by rebasing on `main` before every release build. |
| Distribution | TestFlight and Google Play Internal testing tracks | Set up at least one internal track per platform for QA builds. |

## 2. Local Development Environment

### Shared prerequisites

1. **Ruby & Node** – Match the versions declared in `.ruby-version` and `.node-version`. Run `bin/setup` once to install gems, npm packages, and prepare the Rails application.
2. **Hotwire Native CLI** – Install via `gem install hotwire-native` (requires Ruby 3.1+). This provides the `hotwire-native ios` and `hotwire-native android` commands used below.
3. **Environment files** – Copy `.env.local.example` to `.env.native`. Populate API hosts, feature flags, and any third-party keys required by the mobile shell. Do **not** commit secrets; use 1Password or the shared vault to distribute them.
4. **HTTPS tunnel** – For simulator work, expose your Rails dev server over HTTPS (e.g., `cloudflared tunnel` or `ngrok http https://localhost:3000`). The native apps refuse clear-text HTTP when ATS/Network Security Config is enabled.

### macOS requirements (iOS builds)

- macOS 13 Ventura or newer.
- Xcode 15.x with the Command Line Tools installed (`xcode-select --install`).
- CocoaPods (`sudo gem install cocoapods`) for dependency syncing.
- Homebrew (optional) for installing simulators and utilities.

### Windows/Linux requirements (Android builds)

- Android Studio Giraffe+ with Android SDK Platform 34.
- Java 17 (Android Gradle Plugin 8 requires it).
- `adb` available in your `$PATH`.
- Gradle managed through Android Studio (wrapper checked into the native repo).

## 3. Repository Layout

The prototype keeps the native wrappers under `native/` alongside the Rails app:

```
native/
├── ios/ # Xcode workspace generated by hotwire-native
├── android/ # Gradle project for the Android shell
└── README.md # Shared notes specific to the wrappers
```

If `native/` does not exist yet, initialize it with:

```sh
hotwire-native init --platforms=ios,android --path=native \
--app-name "Sure" \
--server-url "https://dev.sure.example" # replace with your tunnel URL
```

Commit the scaffolding to the prototype branch so others can collaborate.

## 4. Configuring the WebView bridge

1. Update `native/ios/Sure/Info.plist` and `native/android/app/src/main/AndroidManifest.xml` with the production domain (`https://app.sure.com`).
2. Ensure the Rails layout `app/views/layouts/application.html+turbo_native.erb` serves navigation payloads. No code change is required, but verify the JSON includes the tabs expected by the native shell.
3. Add your HTTPS tunnel domain to the allow-list during development:
- **iOS**: `NSAppTransportSecurity -> NSAllowsArbitraryLoadsInWebContent = YES` for the tunnel hostname only.
- **Android**: Update `res/xml/network_security_config.xml` to allow the dev hostname over HTTPS.

## 5. Building the iOS app

```sh
cd native/ios
pod install
xed . # or open Sure.xcworkspace manually
```

1. Select the `Sure` scheme.
2. Choose a signing team that matches your Apple Developer account.
3. Configure the bundle identifier under *Signing & Capabilities* (e.g., `com.sure.app` for production, `com.sure.dev` for staging).
4. Update the build settings:
- **Server URL**: Edit `Config.xcconfig` to point to your desired environment (`https://staging.sure.com`).
- **App Icon & Assets**: Replace the placeholder images in `Assets.xcassets`.
5. To run in the simulator, press **⌘R**. For a physical device, ensure it is registered in the Developer portal and use **Product → Destination** to select it.
6. Create an archive (**Product → Archive**) and upload to TestFlight using the Organizer. Document the build number in the release notes.

## 6. Building the Android app

```sh
cd native/android
./gradlew wrapper
./gradlew assembleDevDebug # dev tunnel build
./gradlew assembleRelease # production-ready build (requires keystore)
```

1. In Android Studio, open the `native/android` folder.
2. Set the application ID in `app/build.gradle` (`applicationId "com.sure.app"`). Use suffixes like `.dev` for QA channels.
3. Update `app/src/main/res/xml/remote_config_defaults.xml` or equivalent to point to the correct server URL.
4. Configure signing:
- Place the keystore under `native/android/keystores/` (ignored by git).
- Add a `keystore.properties` file (ignored) with `storeFile`, `storePassword`, `keyAlias`, `keyPassword`.
- Reference the file in `build.gradle` to enable release signing.
5. Verify the WebView bridge loads the Turbo Native layout by launching `devDebug` on an emulator (`Run > Run 'app'`).
6. Use the Play Console Internal track for QA: upload the `app-release.aab` generated by `./gradlew bundleRelease`.

## 7. Continuous Integration (Optional)

For automated builds, configure CI jobs to:

- Check out the Rails repo and run `bin/rails test` to ensure the web bundle is healthy.
- Cache `native/ios/Pods` and Android Gradle caches between runs.
- Use `xcodebuild -workspace Sure.xcworkspace -scheme Sure -archivePath ... archive` for iOS.
- Use `./gradlew bundleRelease` for Android.
- Store signed artifacts in the build pipeline or upload to App Store Connect / Play Console via API keys.

## 8. Release Checklist

1. Verify the Rails backend has been deployed with the matching commit hash.
2. Smoke-test the PWA to ensure parity with the native wrappers.
3. Update the in-app version string (Settings → About screen) to reflect the build.
4. Publish release notes describing the Turbo Native changes.
5. Coordinate rollout percentages (start with 5%, monitor, then ramp).

## 9. Support & Troubleshooting

- **Authentication loops**: Confirm `Turbo-Visit-Control` headers are present on sign-out routes and that cookies share the same domain between web and native shells.
- **Push notifications**: The prototype does not ship push support; add Firebase Cloud Messaging / APNs later.
- **Performance**: Use `WKWebView` and `androidx.webkit.WebViewCompat` debugging tools to profile slow pages. Enable remote debugging via Safari DevTools or `chrome://inspect`.

Keep this document updated as the prototype graduates from experimentation to production. Contributions should include simulator screenshots and updated instructions when the workflow changes.
Loading