| name | platform-specialist |
| description | Platform-specific integration expert for LINE Mini App (LIFF), Expo (React Native), and Tauri (Desktop). Handles platform APIs, native features, and deployment. Called when app needs platform-specific features beyond standard web. Triggers: "LINE Mini App", "LIFF", "LINE OA", "mobile app", "Expo", "React Native", "desktop app", "Tauri", platform integration, native features. |
Platform Specialist
Make web apps native. LINE, Mobile, Desktop - same quality, platform-optimized.
Same beautiful UI → Platform-specific magic → Native-feeling experience
We adapt, not rebuild. Core logic stays the same.
What is LIFF?
LINE Front-end Framework - run web apps inside LINE app with access to LINE APIs.
Setup
npm install @line/liff
// src/lib/liff.ts
import liff from '@line/liff'
const LIFF_ID = process.env.NEXT_PUBLIC_LIFF_ID!
export async function initializeLiff() {
try {
await liff.init({ liffId: LIFF_ID })
return true
} catch (error) {
console.error('LIFF init failed:', error)
return false
}
}
export function isInLiff(): boolean {
return liff.isInClient()
}
export function isLoggedIn(): boolean {
return liff.isLoggedIn()
}
export async function login() {
if (!liff.isLoggedIn()) {
liff.login()
}
}
export async function logout() {
if (liff.isLoggedIn()) {
liff.logout()
}
}
export async function getProfile() {
if (!liff.isLoggedIn()) return null
return await liff.getProfile()
}
export async function getAccessToken() {
return liff.getAccessToken()
}
// Send message to LINE chat
export async function sendMessage(text: string) {
if (!liff.isInClient()) return
await liff.sendMessages([
{ type: 'text', text }
])
}
// Share to LINE
export async function shareMessage(text: string) {
if (!liff.isApiAvailable('shareTargetPicker')) return
await liff.shareTargetPicker([
{ type: 'text', text }
])
}
// Close LIFF window
export function closeLiff() {
if (liff.isInClient()) {
liff.closeWindow()
}
}
LIFF Provider
// src/providers/liff-provider.tsx
'use client'
import { createContext, useContext, useEffect, useState } from 'react'
import { initializeLiff, getProfile, isLoggedIn, isInLiff } from '@/lib/liff'
interface LiffProfile {
userId: string
displayName: string
pictureUrl?: string
statusMessage?: string
}
interface LiffContextType {
isReady: boolean
isInLiff: boolean
isLoggedIn: boolean
profile: LiffProfile | null
error: string | null
}
const LiffContext = createContext<LiffContextType>({
isReady: false,
isInLiff: false,
isLoggedIn: false,
profile: null,
error: null,
})
export function LiffProvider({ children }: { children: React.ReactNode }) {
const [state, setState] = useState<LiffContextType>({
isReady: false,
isInLiff: false,
isLoggedIn: false,
profile: null,
error: null,
})
useEffect(() => {
async function init() {
const success = await initializeLiff()
if (!success) {
setState(prev => ({ ...prev, isReady: true, error: 'LIFF init failed' }))
return
}
const inLiff = isInLiff()
const loggedIn = isLoggedIn()
let profile = null
if (loggedIn) {
profile = await getProfile()
}
setState({
isReady: true,
isInLiff: inLiff,
isLoggedIn: loggedIn,
profile,
error: null,
})
}
init()
}, [])
return (
<LiffContext.Provider value={state}>
{children}
</LiffContext.Provider>
)
}
export const useLiff = () => useContext(LiffContext)
Connect LIFF to Supabase Auth
// src/lib/liff-auth.ts
import { supabase } from './supabase'
import { getAccessToken, getProfile } from './liff'
export async function signInWithLiff() {
const accessToken = getAccessToken()
const profile = await getProfile()
if (!accessToken || !profile) {
throw new Error('LIFF not logged in')
}
// Create or sign in user via Supabase Edge Function
const { data, error } = await supabase.functions.invoke('liff-auth', {
body: {
accessToken,
userId: profile.userId,
displayName: profile.displayName,
pictureUrl: profile.pictureUrl,
}
})
if (error) throw error
// Set Supabase session
await supabase.auth.setSession({
access_token: data.access_token,
refresh_token: data.refresh_token,
})
return data.user
}
LINE-Specific UI Components
// LINE-style button
export function LineButton({ onClick, children }: {
onClick: () => void
children: React.ReactNode
}) {
return (
<button
onClick={onClick}
className="w-full bg-[#06C755] hover:bg-[#05B34D] text-white
font-medium py-3 px-4 rounded-lg transition-colors"
>
{children}
</button>
)
}
// LINE profile card
export function LineProfileCard({ profile }: { profile: LiffProfile }) {
return (
<div className="flex items-center gap-3 p-4 bg-white rounded-lg shadow-sm">
<img
src={profile.pictureUrl || '/default-avatar.png'}
alt={profile.displayName}
className="w-12 h-12 rounded-full"
/>
<div>
<p className="font-medium">{profile.displayName}</p>
{profile.statusMessage && (
<p className="text-sm text-slate-500">{profile.statusMessage}</p>
)}
</div>
</div>
)
}
LIFF Deployment Checklist
- Create LIFF app in LINE Developers Console
- Set LIFF endpoint URL (your deployed URL)
- Configure LIFF scope (profile, openid, etc.)
- Set NEXT_PUBLIC_LIFF_ID in environment
- Test in LINE app (not browser)
Project Setup
# Create new Expo project
npx create-expo-app my-app --template tabs
# Or with our stack
npx create-expo-app my-app
cd my-app
npx expo install nativewind
npx expo install react-native-reanimated
npm install zustand @supabase/supabase-js
NativeWind Setup (Tailwind for RN)
// tailwind.config.js
module.exports = {
content: [
"./app/**/*.{js,jsx,ts,tsx}",
"./components/**/*.{js,jsx,ts,tsx}"
],
presets: [require("nativewind/preset")],
theme: {
extend: {},
},
plugins: [],
}
// babel.config.js
module.exports = function (api) {
api.cache(true);
return {
presets: [
["babel-preset-expo", { jsxImportSource: "nativewind" }],
"nativewind/babel",
],
};
};
File Structure
my-app/
├── app/ # Expo Router (file-based routing)
│ ├── (tabs)/ # Tab navigation group
│ │ ├── index.tsx # Home tab
│ │ ├── explore.tsx # Explore tab
│ │ └── _layout.tsx # Tab layout
│ ├── _layout.tsx # Root layout
│ └── +not-found.tsx # 404 page
├── components/
│ ├── ui/ # Reusable UI components
│ └── features/ # Feature-specific components
├── lib/
│ ├── supabase.ts # Supabase client
│ └── utils.ts # Utilities
├── stores/ # Zustand stores
└── types/ # TypeScript types
Common Components Translation
Web (shadcn) → React Native
// Web: Button
<Button variant="default">Click me</Button>
// React Native equivalent
import { Pressable, Text } from 'react-native'
export function Button({ children, onPress, variant = 'default' }) {
return (
<Pressable
onPress={onPress}
className={`px-4 py-3 rounded-lg ${
variant === 'default'
? 'bg-blue-600 active:bg-blue-700'
: 'bg-slate-100 active:bg-slate-200'
}`}
>
<Text className={`text-center font-medium ${
variant === 'default' ? 'text-white' : 'text-slate-900'
}`}>
{children}
</Text>
</Pressable>
)
}
// Web: Card
<Card><CardContent>...</CardContent></Card>
// React Native equivalent
import { View } from 'react-native'
export function Card({ children, className = '' }) {
return (
<View className={`bg-white rounded-xl shadow-sm p-4 ${className}`}>
{children}
</View>
)
}
// Web: Input
<Input placeholder="Enter text" />
// React Native equivalent
import { TextInput } from 'react-native'
export function Input({ placeholder, value, onChangeText, ...props }) {
return (
<TextInput
placeholder={placeholder}
value={value}
onChangeText={onChangeText}
className="border border-slate-200 rounded-lg px-4 py-3 text-base"
placeholderTextColor="#94a3b8"
{...props}
/>
)
}
Navigation (Expo Router)
// app/(tabs)/_layout.tsx
import { Tabs } from 'expo-router'
import { Home, Search, User } from 'lucide-react-native'
export default function TabLayout() {
return (
<Tabs screenOptions={{
tabBarActiveTintColor: '#2563eb',
headerShown: false,
}}>
<Tabs.Screen
name="index"
options={{
title: 'Home',
tabBarIcon: ({ color, size }) => (
<Home size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="explore"
options={{
title: 'Search',
tabBarIcon: ({ color, size }) => (
<Search size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="profile"
options={{
title: 'Profile',
tabBarIcon: ({ color, size }) => (
<User size={size} color={color} />
),
}}
/>
</Tabs>
)
}
Supabase in Expo
// lib/supabase.ts
import 'react-native-url-polyfill/auto'
import AsyncStorage from '@react-native-async-storage/async-storage'
import { createClient } from '@supabase/supabase-js'
const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL!
const supabaseKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY!
export const supabase = createClient(supabaseUrl, supabaseKey, {
auth: {
storage: AsyncStorage,
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: false,
},
})
Expo Deployment
# Development build
npx expo start
# Build for testing
eas build --profile development --platform ios
eas build --profile development --platform android
# Production build
eas build --profile production --platform all
# Submit to stores
eas submit --platform ios
eas submit --platform android
Why Tauri?
- Reuse Next.js/React web code
- Native performance (Rust backend)
- Small bundle size (~10MB vs Electron's 100MB+)
- Cross-platform (macOS, Windows, Linux)
Setup (Add to existing Next.js)
# Install Tauri CLI
npm install -D @tauri-apps/cli
# Initialize Tauri in existing project
npx tauri init
Configuration
// src-tauri/tauri.conf.json
{
"build": {
"beforeBuildCommand": "npm run build",
"beforeDevCommand": "npm run dev",
"devPath": "http://localhost:3000",
"distDir": "../out"
},
"package": {
"productName": "My App",
"version": "1.0.0"
},
"tauri": {
"bundle": {
"active": true,
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"identifier": "com.myapp.app",
"targets": "all"
},
"windows": [
{
"title": "My App",
"width": 1200,
"height": 800,
"resizable": true,
"fullscreen": false
}
]
}
}
Next.js Config for Tauri
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'export', // Static export for Tauri
images: {
unoptimized: true // Required for static export
}
}
module.exports = nextConfig
Tauri Commands (Rust ↔ JavaScript)
// src-tauri/src/main.rs
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}!", name)
}
#[tauri::command]
async fn read_file(path: String) -> Result<String, String> {
std::fs::read_to_string(path)
.map_err(|e| e.to_string())
}
fn main() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![greet, read_file])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
// Call from React
import { invoke } from '@tauri-apps/api/tauri'
async function greetUser() {
const message = await invoke('greet', { name: 'Wasin' })
console.log(message) // "Hello, Wasin!"
}
async function readLocalFile() {
const content = await invoke('read_file', {
path: '/path/to/file.txt'
})
console.log(content)
}
Desktop-Specific Features
// Window controls
import { appWindow } from '@tauri-apps/api/window'
await appWindow.minimize()
await appWindow.maximize()
await appWindow.close()
// System tray
import { TrayIcon } from '@tauri-apps/api/tray'
// File dialogs
import { open, save } from '@tauri-apps/api/dialog'
const selected = await open({
multiple: false,
filters: [{ name: 'Images', extensions: ['png', 'jpg'] }]
})
// Notifications
import { sendNotification } from '@tauri-apps/api/notification'
await sendNotification({
title: 'My App',
body: 'Operation completed!'
})
Build & Distribute
# Development
npm run tauri dev
# Build for current platform
npm run tauri build
# Build for all platforms (requires cross-compilation setup)
npm run tauri build -- --target universal-apple-darwin # macOS
npm run tauri build -- --target x86_64-pc-windows-msvc # Windows
npm run tauri build -- --target x86_64-unknown-linux-gnu # Linux
User Request
│
▼
┌─────────────────────────────────────┐
│ Has "LINE" or "LIFF" keywords? │
│ Or targets LINE users specifically? │
└─────────────────────────────────────┘
│ YES → LINE Mini App
│ NO ↓
┌─────────────────────────────────────┐
│ Has "mobile", "iOS", "Android", │
│ "app store", or "native" keywords? │
└─────────────────────────────────────┘
│ YES → Expo (React Native)
│ NO ↓
┌─────────────────────────────────────┐
│ Has "desktop", "mac", "windows", │
│ "offline", or "native" keywords? │
└─────────────────────────────────────┘
│ YES → Tauri
│ NO ↓
┌─────────────────────────────────────┐
│ Default: Next.js Web App │
│ (Works everywhere via browser) │
└─────────────────────────────────────┘