diff --git a/client/.gitignore b/client/.gitignore index 2530b230e..b6eaff298 100644 --- a/client/.gitignore +++ b/client/.gitignore @@ -6,3 +6,7 @@ playwright/.cache/ # vite-plugin-pwa dev output (devOptions.enabled) dev-dist/ + +# The native apps' address screen (npm run build:native), copied into +# android/ and ios/ by `cap sync` +native-dist/ diff --git a/client/android/app/build.gradle b/client/android/app/build.gradle index 375ba6fe6..969a2a6c0 100644 --- a/client/android/app/build.gradle +++ b/client/android/app/build.gradle @@ -35,6 +35,7 @@ dependencies { implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion" implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion" implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion" + implementation "androidx.browser:browser:$androidxBrowserVersion" implementation project(':capacitor-android') testImplementation "junit:junit:$junitVersion" androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion" diff --git a/client/android/app/capacitor.build.gradle b/client/android/app/capacitor.build.gradle index 262141409..e4738501f 100644 --- a/client/android/app/capacitor.build.gradle +++ b/client/android/app/capacitor.build.gradle @@ -10,23 +10,11 @@ android { apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" dependencies { implementation project(':capacitor-app') - implementation project(':capacitor-browser') - implementation project(':capacitor-calendar') - implementation project(':capacitor-camera') - implementation project(':capacitor-file-transfer') implementation project(':capacitor-filesystem') - implementation project(':capacitor-geolocation') - implementation project(':capacitor-haptics') - implementation project(':capacitor-inappbrowser') implementation project(':capacitor-keyboard') - implementation project(':capacitor-local-notifications') - implementation project(':capacitor-network') - implementation project(':capacitor-preferences') - implementation project(':capacitor-push-notifications') implementation project(':capacitor-share') implementation project(':capacitor-splash-screen') implementation project(':capacitor-status-bar') - implementation project(':capacitor-toast') } diff --git a/client/android/app/src/main/AndroidManifest.xml b/client/android/app/src/main/AndroidManifest.xml index 68156fea0..2bc68148b 100644 --- a/client/android/app/src/main/AndroidManifest.xml +++ b/client/android/app/src/main/AndroidManifest.xml @@ -24,16 +24,17 @@ - - - - - - - - - - - - + - - - - - + - - - - - - - - + diff --git a/client/android/app/src/main/java/com/liketrek/trek/MainActivity.java b/client/android/app/src/main/java/com/liketrek/trek/MainActivity.java index e75665e31..a5d32a1b8 100644 --- a/client/android/app/src/main/java/com/liketrek/trek/MainActivity.java +++ b/client/android/app/src/main/java/com/liketrek/trek/MainActivity.java @@ -1,5 +1,21 @@ package com.liketrek.trek; +import android.os.Bundle; import com.getcapacitor.BridgeActivity; -public class MainActivity extends BridgeActivity {} +public class MainActivity extends BridgeActivity { + + @Override + protected void onCreate(Bundle savedInstanceState) { + registerPlugin(TrekShellPlugin.class); + // With a server picked, the WebView loads that server instead of the + // bundled address screen, so the UI arrives in the version the server + // runs and on its own origin. BridgeActivity reads `config` while it + // builds the bridge inside super.onCreate(). + String server = TrekServerStore.getUrl(this); + if (server != null) { + config = TrekServerStore.configFor(this, server); + } + super.onCreate(savedInstanceState); + } +} diff --git a/client/android/app/src/main/java/com/liketrek/trek/TrekServerStore.java b/client/android/app/src/main/java/com/liketrek/trek/TrekServerStore.java new file mode 100644 index 000000000..9781015a0 --- /dev/null +++ b/client/android/app/src/main/java/com/liketrek/trek/TrekServerStore.java @@ -0,0 +1,79 @@ +package com.liketrek.trek; + +import android.content.Context; +import android.content.SharedPreferences; +import com.getcapacitor.CapConfig; +import com.getcapacitor.Logger; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import org.json.JSONException; +import org.json.JSONObject; + +/** The server the user picked, and the Capacitor configuration that loads it. */ +final class TrekServerStore { + + private static final String PREFS = "trek_shell"; + private static final String KEY_URL = "server_url"; + private static final String CONFIG_DIR = "trek-server-config"; + + private TrekServerStore() {} + + static String getUrl(Context context) { + return prefs(context).getString(KEY_URL, null); + } + + static void setUrl(Context context, String url) { + SharedPreferences.Editor editor = prefs(context).edit(); + if (url == null) { + editor.remove(KEY_URL); + } else { + editor.putString(KEY_URL, url); + } + editor.commit(); + } + + /** + * The bundled capacitor.config.json with server.url set. Capacitor only + * takes the server URL from a config file, so this writes a copy into the + * app's private storage and loads that. Falls back to the bundled config, + * which shows the address screen, if the copy cannot be written. + */ + static CapConfig configFor(Context context, String url) { + try { + JSONObject json = new JSONObject(readAsset(context, "capacitor.config.json")); + JSONObject server = json.optJSONObject("server"); + if (server == null) { + server = new JSONObject(); + json.put("server", server); + } + server.put("url", url); + + File dir = new File(context.getFilesDir(), CONFIG_DIR); + if (!dir.isDirectory() && !dir.mkdirs()) throw new IOException("Cannot create " + dir); + try (FileOutputStream out = new FileOutputStream(new File(dir, "capacitor.config.json"))) { + out.write(json.toString().getBytes(StandardCharsets.UTF_8)); + } + return CapConfig.loadFromFile(context, dir.getAbsolutePath()); + } catch (IOException | JSONException ex) { + Logger.error("TrekShell", "Could not point the app at " + url, ex); + return CapConfig.loadDefault(context); + } + } + + private static String readAsset(Context context, String name) throws IOException { + try (InputStream in = context.getAssets().open(name); ByteArrayOutputStream out = new ByteArrayOutputStream()) { + byte[] buffer = new byte[8192]; + int read; + while ((read = in.read(buffer)) != -1) out.write(buffer, 0, read); + return out.toString("UTF-8"); + } + } + + private static SharedPreferences prefs(Context context) { + return context.getSharedPreferences(PREFS, Context.MODE_PRIVATE); + } +} diff --git a/client/android/app/src/main/java/com/liketrek/trek/TrekShellPlugin.java b/client/android/app/src/main/java/com/liketrek/trek/TrekShellPlugin.java new file mode 100644 index 000000000..bf8e2e13a --- /dev/null +++ b/client/android/app/src/main/java/com/liketrek/trek/TrekShellPlugin.java @@ -0,0 +1,148 @@ +package com.liketrek.trek; + +import android.content.Intent; +import android.net.Uri; +import android.os.Handler; +import android.os.Looper; +import androidx.browser.customtabs.CustomTabsIntent; +import com.getcapacitor.JSObject; +import com.getcapacitor.Plugin; +import com.getcapacitor.PluginCall; +import com.getcapacitor.PluginMethod; +import com.getcapacitor.annotation.CapacitorPlugin; +import org.json.JSONObject; + +/** The native half of client/src/native/trekShell.ts. */ +@CapacitorPlugin(name = "TrekShell") +public class TrekShellPlugin extends Plugin { + + /** How long to wait after returning from the browser for the redirect to land. */ + private static final long CANCEL_GRACE_MS = 600; + + private String authCallId; + private String authScheme; + private boolean authBrowserOpen; + private final Handler handler = new Handler(Looper.getMainLooper()); + + @PluginMethod + public void getServer(PluginCall call) { + String url = TrekServerStore.getUrl(getContext()); + JSObject result = new JSObject(); + result.put("url", url == null ? JSONObject.NULL : url); + call.resolve(result); + } + + @PluginMethod + public void setServer(PluginCall call) { + String url = call.getString("url"); + Uri parsed = url == null ? null : Uri.parse(url); + if (parsed == null || parsed.getHost() == null || !("https".equals(parsed.getScheme()) || "http".equals(parsed.getScheme()))) { + call.reject("A http(s) URL is required"); + return; + } + TrekServerStore.setUrl(getContext(), url); + call.resolve(); + restart(); + } + + @PluginMethod + public void resetServer(PluginCall call) { + TrekServerStore.setUrl(getContext(), null); + call.resolve(); + restart(); + } + + @PluginMethod + public void reload(PluginCall call) { + call.resolve(); + restart(); + } + + /** The server URL is part of the bridge's configuration, so it takes a new activity. */ + private void restart() { + getActivity().runOnUiThread(() -> getActivity().recreate()); + } + + @PluginMethod + public void authenticate(PluginCall call) { + String url = call.getString("url"); + String scheme = call.getString("callbackScheme"); + if (url == null || scheme == null) { + call.reject("url and callbackScheme are required"); + return; + } + rejectPendingAuth("Superseded by a new sign-in"); + call.setKeepAlive(true); + getBridge().saveCall(call); + authCallId = call.getCallbackId(); + authScheme = scheme; + authBrowserOpen = true; + // A Custom Tab shares the browser's cookies, so a running SSO session at + // the identity provider carries over. The redirect back to the app's + // scheme arrives through the intent-filter in AndroidManifest.xml. + new CustomTabsIntent.Builder().build().launchUrl(getActivity(), Uri.parse(url)); + } + + @Override + protected void handleOnNewIntent(Intent intent) { + super.handleOnNewIntent(intent); + Uri data = intent.getData(); + PluginCall call = pendingAuthCall(); + if (call == null || data == null || !authScheme.equals(data.getScheme())) return; + JSObject result = new JSObject(); + result.put("url", data.toString()); + call.resolve(result); + finishAuth(call); + } + + /** + * Back in the app without a redirect means the user closed the browser. + * onNewIntent runs before onResume when the redirect did arrive, so the + * grace period only covers devices that deliver the two out of order. + */ + @Override + protected void handleOnResume() { + super.handleOnResume(); + if (!authBrowserOpen) return; + authBrowserOpen = false; + handler.postDelayed(() -> { + PluginCall call = pendingAuthCall(); + if (call == null) return; + call.reject("Sign-in cancelled", "CANCELLED"); + finishAuth(call); + }, CANCEL_GRACE_MS); + } + + private PluginCall pendingAuthCall() { + return authCallId == null ? null : getBridge().getSavedCall(authCallId); + } + + private void rejectPendingAuth(String message) { + PluginCall call = pendingAuthCall(); + if (call == null) return; + call.reject(message, "CANCELLED"); + finishAuth(call); + } + + private void finishAuth(PluginCall call) { + getBridge().releaseCall(call); + authCallId = null; + authScheme = null; + authBrowserOpen = false; + } + + /** iOS only: Android keeps the service worker, which already starts TREK offline. */ + @PluginMethod + public void refreshOfflineCopy(PluginCall call) { + JSObject result = new JSObject(); + result.put("saved", false); + call.resolve(result); + } + + @PluginMethod + public void openOfflineCopy(PluginCall call) { + JSObject result = new JSObject(); + result.put("opened", false); + call.resolve(result); + } +} diff --git a/client/android/app/src/main/res/values/strings.xml b/client/android/app/src/main/res/values/strings.xml index ba2fc8823..0b80e5533 100644 --- a/client/android/app/src/main/res/values/strings.xml +++ b/client/android/app/src/main/res/values/strings.xml @@ -4,8 +4,4 @@ TREK - Plan your trips! com.liketrek.trek com.liketrek.trek - - trek_default diff --git a/client/android/capacitor.settings.gradle b/client/android/capacitor.settings.gradle index c22006c5e..8b55e3e59 100644 --- a/client/android/capacitor.settings.gradle +++ b/client/android/capacitor.settings.gradle @@ -5,45 +5,12 @@ project(':capacitor-android').projectDir = new File('../../node_modules/@capacit include ':capacitor-app' project(':capacitor-app').projectDir = new File('../../node_modules/@capacitor/app/android') -include ':capacitor-browser' -project(':capacitor-browser').projectDir = new File('../../node_modules/@capacitor/browser/android') - -include ':capacitor-calendar' -project(':capacitor-calendar').projectDir = new File('../../node_modules/@capacitor/calendar/android') - -include ':capacitor-camera' -project(':capacitor-camera').projectDir = new File('../../node_modules/@capacitor/camera/android') - -include ':capacitor-file-transfer' -project(':capacitor-file-transfer').projectDir = new File('../../node_modules/@capacitor/file-transfer/android') - include ':capacitor-filesystem' project(':capacitor-filesystem').projectDir = new File('../../node_modules/@capacitor/filesystem/android') -include ':capacitor-geolocation' -project(':capacitor-geolocation').projectDir = new File('../../node_modules/@capacitor/geolocation/android') - -include ':capacitor-haptics' -project(':capacitor-haptics').projectDir = new File('../../node_modules/@capacitor/haptics/android') - -include ':capacitor-inappbrowser' -project(':capacitor-inappbrowser').projectDir = new File('../../node_modules/@capacitor/inappbrowser/android') - include ':capacitor-keyboard' project(':capacitor-keyboard').projectDir = new File('../../node_modules/@capacitor/keyboard/android') -include ':capacitor-local-notifications' -project(':capacitor-local-notifications').projectDir = new File('../../node_modules/@capacitor/local-notifications/android') - -include ':capacitor-network' -project(':capacitor-network').projectDir = new File('../../node_modules/@capacitor/network/android') - -include ':capacitor-preferences' -project(':capacitor-preferences').projectDir = new File('../../node_modules/@capacitor/preferences/android') - -include ':capacitor-push-notifications' -project(':capacitor-push-notifications').projectDir = new File('../../node_modules/@capacitor/push-notifications/android') - include ':capacitor-share' project(':capacitor-share').projectDir = new File('../../node_modules/@capacitor/share/android') @@ -52,6 +19,3 @@ project(':capacitor-splash-screen').projectDir = new File('../../node_modules/@c include ':capacitor-status-bar' project(':capacitor-status-bar').projectDir = new File('../../node_modules/@capacitor/status-bar/android') - -include ':capacitor-toast' -project(':capacitor-toast').projectDir = new File('../../node_modules/@capacitor/toast/android') diff --git a/client/android/google-services.json.example b/client/android/google-services.json.example deleted file mode 100644 index 5c4e9b003..000000000 --- a/client/android/google-services.json.example +++ /dev/null @@ -1,44 +0,0 @@ -{ - "_comment": [ - "Template only — this file is not read by the build.", - "", - "To enable Android push notifications:", - " 1. Create a Firebase project at https://console.firebase.google.com", - " 2. Add an Android app with package name com.liketrek.trek", - " 3. Download the generated google-services.json", - " 4. Place it at client/android/app/google-services.json (gitignored)", - "", - "Without it, app/build.gradle skips the google-services plugin and the app", - "still builds and runs — PushNotifications.register() simply fails at runtime.", - "", - "The server that SENDS the notifications needs a service-account key from the", - "SAME Firebase project. See docs for the self-hosting implications." - ], - "project_info": { - "project_number": "000000000000", - "project_id": "your-firebase-project-id", - "storage_bucket": "your-firebase-project-id.appspot.com" - }, - "client": [ - { - "client_info": { - "mobilesdk_app_id": "1:000000000000:android:0000000000000000000000", - "android_client_info": { - "package_name": "com.liketrek.trek" - } - }, - "oauth_client": [], - "api_key": [ - { - "current_key": "REPLACE_WITH_YOUR_ANDROID_API_KEY" - } - ], - "services": { - "appinvite_service": { - "other_platform_oauth_client": [] - } - } - } - ], - "configuration_version": "1" -} diff --git a/client/android/variables.gradle b/client/android/variables.gradle index 199310493..980b88bd5 100644 --- a/client/android/variables.gradle +++ b/client/android/variables.gradle @@ -1,10 +1,5 @@ ext { - // 26, not Capacitor's default 24: @capacitor/inappbrowser requires a minimum - // target of 26. This drops Android 7.0/7.1 (~1% of devices and no longer - // receiving security updates). Removing @capacitor/inappbrowser — its system- - // browser mode overlaps @capacitor/browser almost exactly — would allow 24 - // again. - minSdkVersion = 26 + minSdkVersion = 24 compileSdkVersion = 36 targetSdkVersion = 36 androidxActivityVersion = '1.11.0' @@ -18,7 +13,6 @@ ext { androidxJunitVersion = '1.3.0' androidxEspressoCoreVersion = '3.7.0' cordovaAndroidVersion = '14.0.1' - // Consumed by @capacitor/push-notifications. Pinned rather than left to the - // plugin's default so an FCM SDK bump is a visible, reviewable change here. - firebaseMessagingVersion = '25.0.1' + // Custom Tabs for SSO and passkey sign-in (TrekShellPlugin.authenticate). + androidxBrowserVersion = '1.8.0' } \ No newline at end of file diff --git a/client/capacitor.config.ts b/client/capacitor.config.ts index 4e4e6ca88..fd51389cf 100644 --- a/client/capacitor.config.ts +++ b/client/capacitor.config.ts @@ -1,5 +1,3 @@ -/// -/// /// /// import type { CapacitorConfig } from '@capacitor/cli'; @@ -8,40 +6,33 @@ import { KeyboardResize } from '@capacitor/keyboard'; const config: CapacitorConfig = { appId: 'com.liketrek.trek', appName: 'TREK - Plan your trips!', - webDir: 'dist', - // TREK is self-hosted: the server the app talks to is whatever the user - // enters, so every request is cross-origin. Both schemes stay https so the - // webview origin is a secure context (crypto.subtle, WebAuthn, service - // workers, IndexedDB persistence all require it). + // The app ships only the address screen. Once a server is picked, the native + // side points the WebView at it (TrekBridgeViewController.swift, + // MainActivity.java) and the UI comes from that server, always in the version + // the server runs. Session cookie, uploads, /ws and plugins work as they do in + // a browser, because the WebView is on the server's own origin. + webDir: 'native-dist', server: { + // Android serves the address screen from https://localhost so it is a + // secure context. iOS keeps its default capacitor:// scheme: WKWebView + // handles https itself and Capacitor silently drops an iosScheme of https. androidScheme: 'https', - iosScheme: 'https', + // Shown, from the app bundle, whenever the server fails to load. The page + // knows a server is remembered and offers to retry or pick another one; + // on iOS it first tries the stored offline copy of the server's start page. + errorPath: 'index.html', }, plugins: { - PushNotifications: { - // No `badge`: TREK has no unread count the OS could keep honest, and a - // badge that only ever grows is worse than none. - presentationOptions: ['sound', 'banner', 'list'], - }, - LocalNotifications: { - // Same white-on-transparent mask as FCM uses; generated by - // scripts/generate-native-icons.mjs. Without it Android falls back to the - // launcher icon and renders a white blob. - smallIcon: 'notification_icon', - presentationOptions: ['sound', 'banner', 'list'], - }, SplashScreen: { // Matches in index.html, so the launch image, // the splash and the first painted frame are the same colour and there is // no white flash between them. backgroundColor: '#09090b', - launchShowDuration: 500, - // Deliberately true. With `false` the splash stays up until JS calls - // SplashScreen.hide(), and until that call exists a failure to boot the - // bundle looks like a frozen app. Flip this to false — and drop - // launchShowDuration — in the same change that adds the hide() call once - // the React tree has mounted. + // The server's UI hides it once React has mounted (native/nativeApp.ts), + // the address screen once it has rendered. Auto-hide stays on as the + // ceiling, so a page that never gets there cannot leave it up for good. launchAutoHide: true, + launchShowDuration: 4000, launchFadeOutDuration: 200, showSpinner: false, androidScaleType: 'CENTER_CROP', @@ -54,13 +45,7 @@ const config: CapacitorConfig = { // Android-only workaround: with an edge-to-edge status bar the webview // otherwise does not resize at all when the keyboard opens. resizeOnFullScreen: true, - // No `style`: TREK has a real appearance system, so the keyboard should - // follow the device/app theme rather than being pinned here. }, - // No StatusBar block on purpose. From Android 15 / Capacitor 8 the app is - // always edge-to-edge, so `overlaysWebView` and `backgroundColor` no longer - // do anything. The status-bar style has to follow the user's chosen scheme - // at runtime — call StatusBar.setStyle() from applyAppearance(). }, }; diff --git a/client/eslint.config.mjs b/client/eslint.config.mjs index fe8e629c1..e37279968 100644 --- a/client/eslint.config.mjs +++ b/client/eslint.config.mjs @@ -30,12 +30,12 @@ export default tseslint.config( 'public', 'test-results', 'playwright-report', - // Capacitor native shells. `npx cap sync` copies the built bundle from - // dist/ into both platforms, so without these ESLint lints the minified - // output and `npm run lint` reports tens of thousands of errors that have - // nothing to do with the source. gitignore() does not cover them: the iOS - // and Android .gitignore files live one level down, and this config's - // gitignore integration only reads client/.gitignore. + // Capacitor native shells. `npx cap sync` copies the built address screen + // from native-dist/ into both platforms, so without these ESLint lints the + // minified output. gitignore() does not cover them: the iOS and Android + // .gitignore files live one level down, and this config's gitignore + // integration only reads client/.gitignore. + 'native-dist', 'android/**', 'ios/**', 'e2e/**', diff --git a/client/ios/App/App.xcodeproj/project.pbxproj b/client/ios/App/App.xcodeproj/project.pbxproj index 45991c4af..d2dc88309 100644 --- a/client/ios/App/App.xcodeproj/project.pbxproj +++ b/client/ios/App/App.xcodeproj/project.pbxproj @@ -17,6 +17,8 @@ 50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; }; 9582B6832FE993A70072D4E8 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9582B6822FE993A50072D4E8 /* SceneDelegate.swift */; }; 7A1E00032C0000000000A003 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 7A1E00022C0000000000A002 /* PrivacyInfo.xcprivacy */; }; + 7A1E00052C0000000000A005 /* TrekBridgeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1E00042C0000000000A004 /* TrekBridgeViewController.swift */; }; + 7A1E00072C0000000000A007 /* TrekShellPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1E00062C0000000000A006 /* TrekShellPlugin.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -33,6 +35,8 @@ 958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; }; 7A1E00012C0000000000A001 /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = ""; }; 7A1E00022C0000000000A002 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; + 7A1E00042C0000000000A004 /* TrekBridgeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrekBridgeViewController.swift; sourceTree = ""; }; + 7A1E00062C0000000000A006 /* TrekShellPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrekShellPlugin.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -70,6 +74,8 @@ 9582B6822FE993A50072D4E8 /* SceneDelegate.swift */, 50379B222058CBB4000EE86E /* capacitor.config.json */, 504EC3071FED79650016851F /* AppDelegate.swift */, + 7A1E00042C0000000000A004 /* TrekBridgeViewController.swift */, + 7A1E00062C0000000000A006 /* TrekShellPlugin.swift */, 504EC30B1FED79650016851F /* Main.storyboard */, 504EC30E1FED79650016851F /* Assets.xcassets */, 504EC3101FED79650016851F /* LaunchScreen.storyboard */, @@ -171,6 +177,8 @@ files = ( 504EC3081FED79650016851F /* AppDelegate.swift in Sources */, 9582B6832FE993A70072D4E8 /* SceneDelegate.swift in Sources */, + 7A1E00052C0000000000A005 /* TrekBridgeViewController.swift in Sources */, + 7A1E00072C0000000000A007 /* TrekShellPlugin.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/client/ios/App/App/App.entitlements b/client/ios/App/App/App.entitlements index 78c3cf2ae..6631ffa6f 100644 --- a/client/ios/App/App/App.entitlements +++ b/client/ios/App/App/App.entitlements @@ -2,20 +2,5 @@ - - aps-environment - development diff --git a/client/ios/App/App/Base.lproj/Main.storyboard b/client/ios/App/App/Base.lproj/Main.storyboard index b44df7be8..2edb91811 100644 --- a/client/ios/App/App/Base.lproj/Main.storyboard +++ b/client/ios/App/App/Base.lproj/Main.storyboard @@ -11,7 +11,7 @@ - + diff --git a/client/ios/App/App/Info.plist b/client/ios/App/App/Info.plist index fc2f05c0c..ea4ab407b 100644 --- a/client/ios/App/App/Info.plist +++ b/client/ios/App/App/Info.plist @@ -51,39 +51,25 @@ logs "Local network prohibited" instead of a useful error. --> NSLocalNetworkUsageDescription TREK connects to the TREK server you choose, which is often running on your home or office network. - + The WebView's file picker offers "Take Photo" and "Photo Library". --> NSCameraUsageDescription TREK uses the camera so you can photograph a place, a journey entry or a booking document and add it to your trip. NSPhotoLibraryUsageDescription TREK needs access to your photos so you can add them to trips, places and journey entries. + NSPhotoLibraryAddUsageDescription TREK saves photos and exports you download from your trips back to your photo library. - + NSLocationWhenInUseUsageDescription TREK uses your location to centre the map on you and to find places near you. - NSLocationAlwaysAndWhenInUseUsageDescription - TREK uses your location to centre the map on you and to find places near you. - - NSCalendarsWriteOnlyAccessUsageDescription - TREK adds your trip days and bookings to your calendar. - NSCalendarsFullAccessUsageDescription - TREK reads your calendar to show existing commitments alongside your trip, and adds trip events to it. - NSCalendarsUsageDescription - TREK adds your trip days and bookings to your calendar. NSPrivacyAccessedAPITypes + NSPrivacyAccessedAPIType NSPrivacyAccessedAPICategoryFileTimestamp @@ -25,7 +21,8 @@ C617.1 - + NSPrivacyAccessedAPIType NSPrivacyAccessedAPICategoryUserDefaults diff --git a/client/ios/App/App/SceneDelegate.swift b/client/ios/App/App/SceneDelegate.swift index f352e1e95..5c55e16a0 100644 --- a/client/ios/App/App/SceneDelegate.swift +++ b/client/ios/App/App/SceneDelegate.swift @@ -8,7 +8,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { guard let windowScene = scene as? UIWindowScene else { return } window = UIWindow(windowScene: windowScene) - window?.rootViewController = CAPBridgeViewController() + window?.rootViewController = TrekBridgeViewController() window?.makeKeyAndVisible() SceneDelegateProxy.shared.scene(scene, willConnectTo: session, options: connectionOptions) diff --git a/client/ios/App/App/TrekBridgeViewController.swift b/client/ios/App/App/TrekBridgeViewController.swift new file mode 100644 index 000000000..411670442 --- /dev/null +++ b/client/ios/App/App/TrekBridgeViewController.swift @@ -0,0 +1,41 @@ +import UIKit +import Capacitor + +/// Points the WebView at the TREK server the user picked instead of the bundled +/// address screen. The UI then comes from that server, in the version it runs, +/// on its own origin, so its session cookie and service calls work as they do +/// in Safari. +class TrekBridgeViewController: CAPBridgeViewController { + override func instanceDescriptor() -> InstanceDescriptor { + let descriptor = super.instanceDescriptor() + if let server = TrekServerStore.url { + // The trailing slash matters: Capacitor treats every URL that starts + // with this string as the app's own, and without it that would take + // in https://trek.example.com.attacker.test as well. + descriptor.serverURL = server.hasSuffix("/") ? server : server + "/" + } + return descriptor + } + + override func capacitorDidLoad() { + bridge?.registerPluginInstance(TrekShellPlugin()) + } +} + +/// The remembered server and the offline copy of its start page. +enum TrekServerStore { + private static let key = "TrekServerURL" + + static var url: String? { + get { UserDefaults.standard.string(forKey: key) } + set { + if let newValue { UserDefaults.standard.set(newValue, forKey: key) } else { UserDefaults.standard.removeObject(forKey: key) } + try? FileManager.default.removeItem(at: offlineCopyURL) + } + } + + static var offlineCopyURL: URL { + let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + return support.appendingPathComponent("offline-start-page.html") + } +} diff --git a/client/ios/App/App/TrekShellPlugin.swift b/client/ios/App/App/TrekShellPlugin.swift new file mode 100644 index 000000000..c946e5474 --- /dev/null +++ b/client/ios/App/App/TrekShellPlugin.swift @@ -0,0 +1,139 @@ +import AuthenticationServices +import Capacitor +import UIKit +import WebKit + +/// The native half of client/src/native/trekShell.ts. +@objc(TrekShellPlugin) +public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPresentationContextProviding { + public let identifier = "TrekShellPlugin" + public let jsName = "TrekShell" + public let pluginMethods: [CAPPluginMethod] = [ + CAPPluginMethod(name: "getServer", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "setServer", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "resetServer", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "reload", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "authenticate", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "refreshOfflineCopy", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "openOfflineCopy", returnType: CAPPluginReturnPromise), + ] + + private var authSession: ASWebAuthenticationSession? + + @objc func getServer(_ call: CAPPluginCall) { + call.resolve(["url": TrekServerStore.url ?? NSNull()]) + } + + @objc func setServer(_ call: CAPPluginCall) { + guard let raw = call.getString("url"), let url = URL(string: raw), ["https", "http"].contains(url.scheme), url.host != nil else { + call.reject("A http(s) URL is required") + return + } + TrekServerStore.url = raw + call.resolve() + restartBridge() + } + + @objc func resetServer(_ call: CAPPluginCall) { + TrekServerStore.url = nil + call.resolve() + restartBridge() + } + + @objc func reload(_ call: CAPPluginCall) { + call.resolve() + restartBridge() + } + + /// A new bridge rather than a navigation: the server URL is part of the + /// bridge's configuration and decides which origin counts as the app. + private func restartBridge() { + DispatchQueue.main.async { [weak self] in + guard let window = self?.bridge?.viewController?.view.window else { return } + window.rootViewController = TrekBridgeViewController() + } + } + + // MARK: - Sign-in through the system browser + + @objc func authenticate(_ call: CAPPluginCall) { + guard let raw = call.getString("url"), let url = URL(string: raw), let scheme = call.getString("callbackScheme") else { + call.reject("url and callbackScheme are required") + return + } + DispatchQueue.main.async { [weak self] in + guard let self else { return } + let session = ASWebAuthenticationSession(url: url, callbackURLScheme: scheme) { [weak self] callbackURL, error in + self?.authSession = nil + if let callbackURL { + call.resolve(["url": callbackURL.absoluteString]) + } else if let authError = error as? ASWebAuthenticationSessionError, authError.code == .canceledLogin { + call.reject("Sign-in cancelled", "CANCELLED") + } else { + call.reject(error?.localizedDescription ?? "Sign-in failed") + } + } + // Shares Safari's cookies, so a running SSO session at the identity + // provider carries over and nobody types their password twice. + session.prefersEphemeralWebBrowserSession = false + session.presentationContextProvider = self + self.authSession = session + if !session.start() { + self.authSession = nil + call.reject("Could not open the browser") + } + } + } + + public func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + bridge?.viewController?.view.window ?? ASPresentationAnchor() + } + + // MARK: - Offline start + + /// WKWebView runs no service worker for a domain that is not listed in the + /// app at build time, and a user's own server never is. So the app keeps + /// the server's start page itself, and the scripts it references stay in + /// WebKit's HTTP cache (the server marks them immutable). + @objc func refreshOfflineCopy(_ call: CAPPluginCall) { + guard let raw = TrekServerStore.url, let url = URL(string: raw) else { + call.resolve(["saved": false]) + return + } + var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 15) + request.setValue("text/html", forHTTPHeaderField: "Accept") + URLSession.shared.dataTask(with: request) { data, response, _ in + guard let data, let http = response as? HTTPURLResponse, http.statusCode == 200, + http.value(forHTTPHeaderField: "Content-Type")?.hasPrefix("text/html") == true, + TrekServerStore.url == raw else { + call.resolve(["saved": false]) + return + } + do { + let target = TrekServerStore.offlineCopyURL + try FileManager.default.createDirectory(at: target.deletingLastPathComponent(), withIntermediateDirectories: true) + try data.write(to: target, options: .atomic) + call.resolve(["saved": true]) + } catch { + call.resolve(["saved": false]) + } + }.resume() + } + + @objc func openOfflineCopy(_ call: CAPPluginCall) { + guard let raw = TrekServerStore.url, let url = URL(string: raw), + let html = try? Data(contentsOf: TrekServerStore.offlineCopyURL) else { + call.resolve(["opened": false]) + return + } + DispatchQueue.main.async { [weak self] in + guard let webView = self?.bridge?.webView else { + call.resolve(["opened": false]) + return + } + let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: ["Content-Type": "text/html; charset=utf-8"])! + call.resolve(["opened": true]) + webView.loadSimulatedRequest(URLRequest(url: url), response: response, responseData: html) + } + } +} diff --git a/client/ios/App/CapApp-SPM/Package.swift b/client/ios/App/CapApp-SPM/Package.swift index 6b3c3b311..4748cf9b2 100644 --- a/client/ios/App/CapApp-SPM/Package.swift +++ b/client/ios/App/CapApp-SPM/Package.swift @@ -11,25 +11,13 @@ let package = Package( targets: ["CapApp-SPM"]) ], dependencies: [ - .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.5.0"), + .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.5.2"), .package(name: "CapacitorApp", path: "../../../../node_modules/@capacitor/app"), - .package(name: "CapacitorBrowser", path: "../../../../node_modules/@capacitor/browser"), - .package(name: "CapacitorCalendar", path: "../../../../node_modules/@capacitor/calendar"), - .package(name: "CapacitorCamera", path: "../../../../node_modules/@capacitor/camera"), - .package(name: "CapacitorFileTransfer", path: "../../../../node_modules/@capacitor/file-transfer"), .package(name: "CapacitorFilesystem", path: "../../../../node_modules/@capacitor/filesystem"), - .package(name: "CapacitorGeolocation", path: "../../../../node_modules/@capacitor/geolocation"), - .package(name: "CapacitorHaptics", path: "../../../../node_modules/@capacitor/haptics"), - .package(name: "CapacitorInappbrowser", path: "../../../../node_modules/@capacitor/inappbrowser"), .package(name: "CapacitorKeyboard", path: "../../../../node_modules/@capacitor/keyboard"), - .package(name: "CapacitorLocalNotifications", path: "../../../../node_modules/@capacitor/local-notifications"), - .package(name: "CapacitorNetwork", path: "../../../../node_modules/@capacitor/network"), - .package(name: "CapacitorPreferences", path: "../../../../node_modules/@capacitor/preferences"), - .package(name: "CapacitorPushNotifications", path: "../../../../node_modules/@capacitor/push-notifications"), .package(name: "CapacitorShare", path: "../../../../node_modules/@capacitor/share"), .package(name: "CapacitorSplashScreen", path: "../../../../node_modules/@capacitor/splash-screen"), - .package(name: "CapacitorStatusBar", path: "../../../../node_modules/@capacitor/status-bar"), - .package(name: "CapacitorToast", path: "../../../../node_modules/@capacitor/toast") + .package(name: "CapacitorStatusBar", path: "../../../../node_modules/@capacitor/status-bar") ], targets: [ .target( @@ -38,23 +26,11 @@ let package = Package( .product(name: "Capacitor", package: "capacitor-swift-pm"), .product(name: "Cordova", package: "capacitor-swift-pm"), .product(name: "CapacitorApp", package: "CapacitorApp"), - .product(name: "CapacitorBrowser", package: "CapacitorBrowser"), - .product(name: "CapacitorCalendar", package: "CapacitorCalendar"), - .product(name: "CapacitorCamera", package: "CapacitorCamera"), - .product(name: "CapacitorFileTransfer", package: "CapacitorFileTransfer"), .product(name: "CapacitorFilesystem", package: "CapacitorFilesystem"), - .product(name: "CapacitorGeolocation", package: "CapacitorGeolocation"), - .product(name: "CapacitorHaptics", package: "CapacitorHaptics"), - .product(name: "CapacitorInappbrowser", package: "CapacitorInappbrowser"), .product(name: "CapacitorKeyboard", package: "CapacitorKeyboard"), - .product(name: "CapacitorLocalNotifications", package: "CapacitorLocalNotifications"), - .product(name: "CapacitorNetwork", package: "CapacitorNetwork"), - .product(name: "CapacitorPreferences", package: "CapacitorPreferences"), - .product(name: "CapacitorPushNotifications", package: "CapacitorPushNotifications"), .product(name: "CapacitorShare", package: "CapacitorShare"), .product(name: "CapacitorSplashScreen", package: "CapacitorSplashScreen"), - .product(name: "CapacitorStatusBar", package: "CapacitorStatusBar"), - .product(name: "CapacitorToast", package: "CapacitorToast") + .product(name: "CapacitorStatusBar", package: "CapacitorStatusBar") ] ) ] diff --git a/client/native-shell/i18n.ts b/client/native-shell/i18n.ts new file mode 100644 index 000000000..43e9df949 --- /dev/null +++ b/client/native-shell/i18n.ts @@ -0,0 +1,34 @@ +import { SUPPORTED_LANGUAGE_CODES } from '@trek/shared/i18n' +import type { TranslationStrings } from '@trek/shared/i18n' + +// Only the native strings of every locale, not the whole UI: this page ships +// inside the app binary and shows three screens. +const bundles = import.meta.glob('../../shared/src/i18n/*/native.ts', { eager: true, import: 'default' }) + +function bundleFor(code: string): TranslationStrings | undefined { + return bundles[`../../shared/src/i18n/${code}/native.ts`] +} + +export function pickLocale(preferred: readonly string[]): string { + for (const lang of preferred) { + const lower = lang.toLowerCase() + if (lower === 'pt-br') return 'br' + if (lower.startsWith('zh-tw') || lower.startsWith('zh-hk') || lower.startsWith('zh-hant')) return 'zh-TW' + if (lower.startsWith('el')) return 'gr' + const prefix = lower.split('-')[0] + const match = SUPPORTED_LANGUAGE_CODES.find((code) => code.toLowerCase() === lower || code === prefix) + if (match) return match + } + return 'en' +} + +export function translator(code: string) { + const strings = bundleFor(code) ?? {} + const fallback = bundleFor('en') ?? {} + return (key: string, params: Record = {}): string => { + const template = strings[key] ?? fallback[key] ?? key + return typeof template === 'string' + ? template.replace(/\{(\w+)\}/g, (whole, name: string) => params[name] ?? whole) + : key + } +} diff --git a/client/native-shell/index.html b/client/native-shell/index.html new file mode 100644 index 000000000..ba3f6913d --- /dev/null +++ b/client/native-shell/index.html @@ -0,0 +1,14 @@ + + + + + + + + TREK + + +
+ + + diff --git a/client/native-shell/main.ts b/client/native-shell/main.ts new file mode 100644 index 000000000..3dc71ec6c --- /dev/null +++ b/client/native-shell/main.ts @@ -0,0 +1,119 @@ +import { CapacitorHttp } from '@capacitor/core' +import { SplashScreen } from '@capacitor/splash-screen' +import { TrekShell } from '../src/native/trekShell' +import { probeServer, type ProbeFailure, type ProbeGet } from '../src/native/serverAddress' +import { pickLocale, translator } from './i18n' +import logoUrl from '../public/icons/icon-white.svg' +import './shell.css' + +/** + * The only page the app ships itself. It asks for a server on first start and + * stands in when the remembered one cannot be reached; everything else is the + * UI that server delivers. + */ + +const locale = pickLocale(navigator.languages?.length ? navigator.languages : [navigator.language]) +const t = translator(locale) +document.documentElement.lang = locale +document.documentElement.dir = locale === 'ar' ? 'rtl' : 'ltr' + +const root = document.getElementById('shell')! + +const PROBE_TIMEOUT_MS = 8000 + +// Native HTTP rather than fetch: the probe goes to a server of the user's +// choosing, which has no reason to answer this page's origin with CORS. +const get: ProbeGet = async (url) => { + const response = await CapacitorHttp.get({ url, connectTimeout: PROBE_TIMEOUT_MS, readTimeout: PROBE_TIMEOUT_MS }) + return { status: response.status, data: response.data, url: response.url } +} + +const FAILURE_KEYS: Record = { + invalid: 'native.connect.errorInvalid', + unreachable: 'native.connect.errorUnreachable', + notTrek: 'native.connect.errorNotTrek', + tooOld: 'native.connect.errorTooOld', +} + +function el(tag: K, props: Partial = {}, ...children: (Node | string)[]) { + const node = Object.assign(document.createElement(tag), props) + node.append(...children) + return node +} + +function header(title: string, hint: string) { + return [ + el('img', { src: logoUrl, alt: '', className: 'shell-logo' }), + el('h1', { className: 'shell-title' }, title), + el('p', { className: 'shell-hint' }, hint), + ] +} + +function showConnect(prefill = '') { + const input = el('input', { + type: 'url', + className: 'shell-input', + placeholder: t('native.connect.placeholder'), + value: prefill, + autocapitalize: 'off', + spellcheck: false, + required: true, + }) + input.setAttribute('inputmode', 'url') + input.setAttribute('autocomplete', 'url') + input.setAttribute('aria-label', t('native.connect.title')) + const error = el('p', { className: 'shell-error', role: 'alert' }) + const submit = el('button', { type: 'submit', className: 'shell-button' }, t('native.connect.submit')) + const form = el('form', { className: 'shell-form', noValidate: true }, input, submit, error) + + form.addEventListener('submit', async (event) => { + event.preventDefault() + error.textContent = '' + input.disabled = true + submit.disabled = true + submit.textContent = t('native.connect.checking') + const result = await probeServer(input.value, get) + if (result.ok) { + await TrekShell.setServer({ url: result.url }) + return + } + error.textContent = t(FAILURE_KEYS[result.reason]) + input.disabled = false + submit.disabled = false + submit.textContent = t('native.connect.submit') + input.focus() + }) + + root.replaceChildren(...header(t('native.connect.title'), t('native.connect.hint')), form) +} + +function showUnreachable(server: string) { + const retry = el('button', { type: 'button', className: 'shell-button' }, t('native.offline.retry')) + retry.addEventListener('click', () => { + retry.disabled = true + void TrekShell.reload() + }) + const change = el('button', { type: 'button', className: 'shell-link' }, t('native.changeServer')) + change.addEventListener('click', () => { void TrekShell.resetServer() }) + + root.replaceChildren( + ...header(t('native.offline.title'), t('native.offline.hint', { server: new URL(server).host })), + el('div', { className: 'shell-form' }, retry, change), + ) +} + +async function start() { + const { url } = await TrekShell.getServer() + if (!url) { + showConnect() + } else { + // Only reached when loading the server failed. On iOS the stored copy of + // its start page brings the offline-capable UI up anyway. + const { opened } = await TrekShell.openOfflineCopy() + if (opened) return + showUnreachable(url) + } + await SplashScreen.hide() +} + +void start() diff --git a/client/native-shell/shell.css b/client/native-shell/shell.css new file mode 100644 index 000000000..0b3b92173 --- /dev/null +++ b/client/native-shell/shell.css @@ -0,0 +1,90 @@ +/* Mirrors the app's neutral default scheme (client/src/index.css), dark first: + this page shares the launch screen's background and must not flash. */ +:root { + --bg: #09090b; + --surface: #18181b; + --edge: #27272a; + --ink: #fafafa; + --muted: #a1a1aa; + --accent: #e4e4e7; + --accent-ink: #09090b; + --danger: #f87171; + color-scheme: dark; +} + +@media (prefers-color-scheme: light) { + :root { + --bg: #ffffff; + --surface: #f4f4f5; + --edge: #e4e4e7; + --ink: #111827; + --muted: #6b7280; + --accent: #111827; + --accent-ink: #ffffff; + --danger: #dc2626; + color-scheme: light; + } + .shell-logo { filter: invert(1); } +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + min-height: 100%; + background: var(--bg); + color: var(--ink); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + -webkit-text-size-adjust: 100%; +} + +.shell { + min-height: 100vh; + min-height: 100dvh; + display: flex; + flex-direction: column; + justify-content: center; + gap: 12px; + max-width: 420px; + margin: 0 auto; + padding: calc(env(safe-area-inset-top) + 24px) 24px calc(env(safe-area-inset-bottom) + 24px); +} + +.shell-logo { width: 56px; height: 56px; margin-bottom: 8px; } +.shell-title { font-size: 1.5rem; font-weight: 600; margin: 0; } +.shell-hint { color: var(--muted); margin: 0 0 12px; line-height: 1.5; overflow-wrap: anywhere; } + +.shell-form { display: flex; flex-direction: column; gap: 12px; } + +.shell-input { + font: inherit; + font-size: 1rem; + padding: 14px 16px; + border-radius: 12px; + border: 1px solid var(--edge); + background: var(--surface); + color: var(--ink); + outline: none; +} +.shell-input:focus { border-color: var(--accent); } + +.shell-button { + font: inherit; + font-weight: 600; + padding: 14px 16px; + border: 0; + border-radius: 12px; + background: var(--accent); + color: var(--accent-ink); +} +.shell-button:disabled { opacity: 0.6; } + +.shell-link { + font: inherit; + padding: 10px; + border: 0; + background: none; + color: var(--muted); +} + +.shell-error { color: var(--danger); margin: 0; min-height: 1.5em; line-height: 1.5; } diff --git a/client/package.json b/client/package.json index 9afe4e9f7..1bb971287 100644 --- a/client/package.json +++ b/client/package.json @@ -7,6 +7,8 @@ "dev": "vite", "prebuild": "node scripts/patch-maplibre.mjs && node scripts/generate-icons.mjs", "icons:native": "node scripts/generate-native-icons.mjs", + "build:native": "vite build --config vite.native.config.js", + "cap:sync": "npm run build:native && cap sync", "build": "vite build", "build:analyze": "vite build --mode analyze", "preview": "vite preview", @@ -34,25 +36,13 @@ "dependencies": { "@capacitor/android": "^8.5.0", "@capacitor/app": "^8.1.1", - "@capacitor/browser": "^8.0.4", - "@capacitor/calendar": "^1.0.1", - "@capacitor/camera": "^8.2.3", "@capacitor/core": "^8.5.0", - "@capacitor/file-transfer": "^2.0.5", "@capacitor/filesystem": "^8.1.3", - "@capacitor/geolocation": "^8.2.2", - "@capacitor/haptics": "^8.0.2", - "@capacitor/inappbrowser": "^4.0.3", "@capacitor/ios": "^8.5.0", "@capacitor/keyboard": "^8.0.5", - "@capacitor/local-notifications": "^8.3.1", - "@capacitor/network": "^8.0.1", - "@capacitor/preferences": "^8.0.1", - "@capacitor/push-notifications": "^8.1.2", "@capacitor/share": "^8.0.1", "@capacitor/splash-screen": "^8.0.2", "@capacitor/status-bar": "^8.0.3", - "@capacitor/toast": "^8.0.1", "@fontsource/bebas-neue": "^5.3.0", "@fontsource/eb-garamond": "^5.3.0", "@fontsource/geist-sans": "^5.2.5", diff --git a/client/src/native/serverAddress.test.ts b/client/src/native/serverAddress.test.ts new file mode 100644 index 000000000..02e33b71e --- /dev/null +++ b/client/src/native/serverAddress.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect, vi } from 'vitest' +import { candidateUrls, probeServer, type ProbeResponse } from './serverAddress' + +const features = (url: string, data: unknown = { bookingImport: false, aiParsing: false, nativeApp: true }): ProbeResponse => + ({ status: 200, data, url }) + +describe('candidateUrls', () => { + it('tries https before http when no scheme was typed', () => { + expect(candidateUrls('trek.example.com')).toEqual(['https://trek.example.com', 'http://trek.example.com']) + expect(candidateUrls(' 192.168.1.10:3001/ ')).toEqual(['https://192.168.1.10:3001', 'http://192.168.1.10:3001']) + }) + + it('keeps an explicit scheme and path, without query, hash or trailing slashes', () => { + expect(candidateUrls('http://nas.local:3001/trek/?x=1#y')).toEqual(['http://nas.local:3001/trek']) + expect(candidateUrls('HTTPS://Trek.Example.com//')).toEqual(['https://trek.example.com']) + }) + + it('returns nothing for empty input or other schemes', () => { + expect(candidateUrls(' ')).toEqual([]) + expect(candidateUrls('ftp://trek.example.com')).toEqual([]) + expect(candidateUrls('javascript://alert(1)')).toEqual([]) + }) +}) + +describe('probeServer', () => { + it('accepts a server that reports nativeApp and follows its redirect', async () => { + const get = vi.fn().mockResolvedValue(features('https://trek.example.com/api/health/features')) + expect(await probeServer('http://trek.example.com', get)).toEqual({ ok: true, url: 'https://trek.example.com' }) + expect(get).toHaveBeenCalledWith('http://trek.example.com/api/health/features') + }) + + it('falls back to the candidate itself when the final URL is not the probe path', async () => { + const get = vi.fn().mockResolvedValue(features('https://sso.example.com/login')) + expect(await probeServer('https://trek.example.com', get)).toEqual({ ok: true, url: 'https://trek.example.com' }) + }) + + it('moves on to plain http when https cannot be reached', async () => { + const get = vi.fn() + .mockRejectedValueOnce(new Error('ECONNREFUSED')) + .mockResolvedValueOnce(features('http://192.168.1.10:3001/api/health/features')) + expect(await probeServer('192.168.1.10:3001', get)).toEqual({ ok: true, url: 'http://192.168.1.10:3001' }) + expect(get).toHaveBeenNthCalledWith(2, 'http://192.168.1.10:3001/api/health/features') + }) + + it('names a TREK server without the native flag as too old', async () => { + const get = vi.fn().mockResolvedValue(features('https://old.example.com/api/health/features', { bookingImport: true, aiParsing: false })) + expect(await probeServer('https://old.example.com', get)).toEqual({ ok: false, reason: 'tooOld' }) + }) + + it('tells a server that is not TREK apart from one that is not there', async () => { + const html = vi.fn().mockResolvedValue({ status: 200, data: '', url: 'https://x.example.com/api/health/features' }) + expect(await probeServer('https://x.example.com', html)).toEqual({ ok: false, reason: 'notTrek' }) + + const missing = vi.fn().mockResolvedValue({ status: 404, data: {}, url: 'https://x.example.com/api/health/features' }) + expect(await probeServer('https://x.example.com', missing)).toEqual({ ok: false, reason: 'notTrek' }) + + const down = vi.fn().mockRejectedValue(new Error('timeout')) + expect(await probeServer('x.example.com', down)).toEqual({ ok: false, reason: 'unreachable' }) + }) + + it('keeps the most telling failure across candidates', async () => { + const get = vi.fn() + .mockResolvedValueOnce(features('https://nas.local/api/health/features', { bookingImport: false })) + .mockRejectedValueOnce(new Error('ECONNREFUSED')) + expect(await probeServer('nas.local', get)).toEqual({ ok: false, reason: 'tooOld' }) + }) + + it('refuses input that is not an address', async () => { + const get = vi.fn() + expect(await probeServer('', get)).toEqual({ ok: false, reason: 'invalid' }) + expect(get).not.toHaveBeenCalled() + }) +}) diff --git a/client/src/native/serverAddress.ts b/client/src/native/serverAddress.ts new file mode 100644 index 000000000..aa0717182 --- /dev/null +++ b/client/src/native/serverAddress.ts @@ -0,0 +1,78 @@ +/** + * Turns what someone typed into the app's address field into a TREK server it + * can load, or a reason it cannot. + */ + +export type ProbeFailure = 'invalid' | 'unreachable' | 'notTrek' | 'tooOld' +export type ProbeResult = + | { ok: true; url: string; reason?: undefined } + | { ok: false; reason: ProbeFailure; url?: undefined } + +export interface ProbeResponse { + status: number + data: unknown + /** The URL after redirects. */ + url: string +} +export type ProbeGet = (url: string) => Promise + +const FEATURES_PATH = '/api/health/features' + +function normalize(raw: string): string | null { + let parsed: URL + try { + parsed = new URL(raw) + } catch { + return null + } + if ((parsed.protocol !== 'https:' && parsed.protocol !== 'http:') || !parsed.hostname) return null + return parsed.origin + parsed.pathname.replace(/\/+$/, '') +} + +/** + * Without a scheme, https comes first and plain http second: a server on the + * home network is often reached as a bare IP and port without TLS. + */ +export function candidateUrls(input: string): string[] { + const trimmed = input.trim() + if (!trimmed) return [] + const candidates = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) + ? [normalize(trimmed)] + : [normalize(`https://${trimmed}`), normalize(`http://${trimmed}`)] + return candidates.filter((url): url is string => url !== null) +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function judge(response: ProbeResponse): ProbeResult { + if (response.status !== 200 || !isRecord(response.data)) return { ok: false, reason: 'notTrek' } + if (response.data.nativeApp === true) { + // Follow a redirect (http to https, a canonical host) so the app loads the + // address the server actually answers on. + const base = response.url.endsWith(FEATURES_PATH) ? response.url.slice(0, -FEATURES_PATH.length) : null + return { ok: true, url: (base && normalize(base)) || '' } + } + return 'bookingImport' in response.data ? { ok: false, reason: 'tooOld' } : { ok: false, reason: 'notTrek' } +} + +const FAILURE_RANK: Record = { invalid: 0, unreachable: 1, notTrek: 2, tooOld: 3 } + +export async function probeServer(input: string, get: ProbeGet): Promise { + const candidates = candidateUrls(input) + if (!candidates.length) return { ok: false, reason: 'invalid' } + + let failure: ProbeFailure = 'unreachable' + for (const candidate of candidates) { + let verdict: ProbeResult + try { + verdict = judge(await get(candidate + FEATURES_PATH)) + } catch { + continue + } + if (verdict.ok) return { ok: true, url: verdict.url || candidate } + if (FAILURE_RANK[verdict.reason] > FAILURE_RANK[failure]) failure = verdict.reason + } + return { ok: false, reason: failure } +} diff --git a/client/src/native/trekShell.ts b/client/src/native/trekShell.ts new file mode 100644 index 000000000..0551043d1 --- /dev/null +++ b/client/src/native/trekShell.ts @@ -0,0 +1,36 @@ +import { registerPlugin } from '@capacitor/core' + +/** + * The app's own native plugin, implemented in + * ios/App/App/TrekShellPlugin.swift and + * android/app/src/main/java/com/liketrek/trek/TrekShellPlugin.java. + * + * The app is a frame around the UI the user's server delivers, so what the + * web side needs from it is small: which server that is, a way to change it, + * a system browser for the sign-ins a WebView cannot do, and on iOS the + * offline copy of the start page that stands in for the missing service + * worker. + */ +export interface TrekShellPlugin { + getServer(): Promise<{ url: string | null }> + /** Remembers the server and loads it. The current page is gone afterwards. */ + setServer(options: { url: string }): Promise + /** Forgets the server and returns to the address screen. */ + resetServer(): Promise + /** Loads the remembered server again, after it could not be reached. */ + reload(): Promise + /** + * Opens `url` in the system browser and resolves with the URL it was sent + * back to under `callbackScheme`. Rejects with code `CANCELLED` when the + * user closes the browser. + */ + authenticate(options: { url: string; callbackScheme: string }): Promise<{ url: string }> + /** iOS: stores the server's start page for a cold start without network. */ + refreshOfflineCopy(): Promise<{ saved: boolean }> + /** iOS: loads that copy under the server's origin. False when there is none. */ + openOfflineCopy(): Promise<{ opened: boolean }> +} + +export const TrekShell = registerPlugin('TrekShell') + +export const APP_URL_SCHEME = 'com.liketrek.trek' diff --git a/client/tsconfig.json b/client/tsconfig.json index d3c51f17e..84641aa31 100644 --- a/client/tsconfig.json +++ b/client/tsconfig.json @@ -26,5 +26,5 @@ "forceConsistentCasingInFileNames": true, "resolveJsonModule": true }, - "include": ["src", "tests"] + "include": ["src", "tests", "native-shell"] } diff --git a/client/vite.native.config.js b/client/vite.native.config.js new file mode 100644 index 000000000..4c39f31a5 --- /dev/null +++ b/client/vite.native.config.js @@ -0,0 +1,16 @@ +import { defineConfig } from 'vite' +import { fileURLToPath } from 'url' + +// The address screen the native apps ship (native-shell/). Everything past it +// is the UI of the server the user picks, so this build stays tiny and apart +// from the main one: no PWA, no React, only the native strings of each locale. +export default defineConfig({ + root: fileURLToPath(new URL('./native-shell', import.meta.url)), + base: './', + publicDir: false, + build: { + outDir: fileURLToPath(new URL('./native-dist', import.meta.url)), + emptyOutDir: true, + target: ['es2020', 'safari15', 'chrome100'], + }, +}) diff --git a/package-lock.json b/package-lock.json index 4ab3e602e..95c86a4d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,25 +30,13 @@ "dependencies": { "@capacitor/android": "^8.5.0", "@capacitor/app": "^8.1.1", - "@capacitor/browser": "^8.0.4", - "@capacitor/calendar": "^1.0.1", - "@capacitor/camera": "^8.2.3", "@capacitor/core": "^8.5.0", - "@capacitor/file-transfer": "^2.0.5", "@capacitor/filesystem": "^8.1.3", - "@capacitor/geolocation": "^8.2.2", - "@capacitor/haptics": "^8.0.2", - "@capacitor/inappbrowser": "^4.0.3", "@capacitor/ios": "^8.5.0", "@capacitor/keyboard": "^8.0.5", - "@capacitor/local-notifications": "^8.3.1", - "@capacitor/network": "^8.0.1", - "@capacitor/preferences": "^8.0.1", - "@capacitor/push-notifications": "^8.1.2", "@capacitor/share": "^8.0.1", "@capacitor/splash-screen": "^8.0.2", "@capacitor/status-bar": "^8.0.3", - "@capacitor/toast": "^8.0.1", "@fontsource/bebas-neue": "^5.3.0", "@fontsource/eb-garamond": "^5.3.0", "@fontsource/geist-sans": "^5.2.5", @@ -2409,33 +2397,6 @@ "@capacitor/core": ">=8.0.0" } }, - "node_modules/@capacitor/browser": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/@capacitor/browser/-/browser-8.0.4.tgz", - "integrity": "sha512-ORZ4u/y+7aMuu6yVuk6SP529fUZyqcbnuHB1q5McpA3o2SYfVoi28iB8rsQjI2ad1qlKJd29ZUuGtspXLBDlWg==", - "license": "MIT", - "peerDependencies": { - "@capacitor/core": ">=8.0.0" - } - }, - "node_modules/@capacitor/calendar": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@capacitor/calendar/-/calendar-1.0.1.tgz", - "integrity": "sha512-2ogW+Jlamy6BJkZmaggB/Jx282G+o3aBPfgx3GxmjlLjl8hqcorQKFhlCiveDui6xEKoVy13AQpKhukHQ8MrCQ==", - "license": "MIT", - "peerDependencies": { - "@capacitor/core": ">=8.0.0" - } - }, - "node_modules/@capacitor/camera": { - "version": "8.2.4", - "resolved": "https://registry.npmjs.org/@capacitor/camera/-/camera-8.2.4.tgz", - "integrity": "sha512-kZIeRkOvtE2dnbq76tEo3ZWKyGfzHFK0txsR56qKAmjAARTg9r86iIUcKZa/y4U1vOHH/gdQae5e3+OAF3wwPQ==", - "license": "MIT", - "peerDependencies": { - "@capacitor/core": ">=8.0.0" - } - }, "node_modules/@capacitor/cli": { "version": "8.5.2", "resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-8.5.2.tgz", @@ -2560,18 +2521,6 @@ "tslib": "^2.1.0" } }, - "node_modules/@capacitor/file-transfer": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@capacitor/file-transfer/-/file-transfer-2.0.5.tgz", - "integrity": "sha512-G2a/+NKvADNcmxMSqy6HPp+Ij3trCy0+EVNMrRWmp8ueyCNaJEi96LpfEIztv+vqK9+xLRskQ7iVl+Pj8qt4hg==", - "license": "MIT", - "dependencies": { - "@capacitor/synapse": "^1.0.4" - }, - "peerDependencies": { - "@capacitor/core": ">=8.0.0" - } - }, "node_modules/@capacitor/filesystem": { "version": "8.1.3", "resolved": "https://registry.npmjs.org/@capacitor/filesystem/-/filesystem-8.1.3.tgz", @@ -2584,36 +2533,6 @@ "@capacitor/core": ">=8.0.0" } }, - "node_modules/@capacitor/geolocation": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/@capacitor/geolocation/-/geolocation-8.2.2.tgz", - "integrity": "sha512-X8ryw6CfL4LspJnnfVL4++DUKKZVfdUIA9JljWVRljEDIQxZQZtz6xf7qstht+ymc+le6oa5EaUChVA6KsCYlA==", - "license": "MIT", - "dependencies": { - "@capacitor/synapse": "^1.0.4" - }, - "peerDependencies": { - "@capacitor/core": ">=8.0.0" - } - }, - "node_modules/@capacitor/haptics": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@capacitor/haptics/-/haptics-8.0.2.tgz", - "integrity": "sha512-c2hZzRR5Fk1tbTvhG1jhh2XBAf3EhnIerMIb2sl7Mt41Gxx1fhBJFDa0/BI1IbY4loVepyyuqNC9820/GZuoWQ==", - "license": "MIT", - "peerDependencies": { - "@capacitor/core": ">=8.0.0" - } - }, - "node_modules/@capacitor/inappbrowser": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/@capacitor/inappbrowser/-/inappbrowser-4.0.3.tgz", - "integrity": "sha512-Jnl6Ssp2QZByncO6QhDKzNpi3yeum1aSQM+70VMVh8TJ16HOwOrcTnf8E4+7FpT9ruSRGyhqQu1yBSrpl+wD1g==", - "license": "MIT", - "peerDependencies": { - "@capacitor/core": ">=8.0.0" - } - }, "node_modules/@capacitor/ios": { "version": "8.5.2", "resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-8.5.2.tgz", @@ -2632,42 +2551,6 @@ "@capacitor/core": ">=8.0.0" } }, - "node_modules/@capacitor/local-notifications": { - "version": "8.3.1", - "resolved": "https://registry.npmjs.org/@capacitor/local-notifications/-/local-notifications-8.3.1.tgz", - "integrity": "sha512-F7zu/+p/mIR958YP4axOkeI5hBKRaXDyYjbNPrAil2wWfd7OxScPSTvP9t85NpQ0eULVRu0/sntoxOg6ck477A==", - "license": "MIT", - "peerDependencies": { - "@capacitor/core": ">=8.0.0" - } - }, - "node_modules/@capacitor/network": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@capacitor/network/-/network-8.0.1.tgz", - "integrity": "sha512-9xK/FHFmzKGanB6BdoSZOzXk8vF0OFVQSQ4PAsIrzAzLuXHryO317qy8dcHVpgxYeuZq2noI0My9z1DvVDi/9w==", - "license": "MIT", - "peerDependencies": { - "@capacitor/core": ">=8.0.0" - } - }, - "node_modules/@capacitor/preferences": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@capacitor/preferences/-/preferences-8.0.1.tgz", - "integrity": "sha512-T6no3ebi79XJCk91U3Jp/liJUwgBdvHR+s6vhvPkPxSuch7z3zx5Rv1bdWM6sWruNx+pViuEGqZvbfCdyBvcHQ==", - "license": "MIT", - "peerDependencies": { - "@capacitor/core": ">=8.0.0" - } - }, - "node_modules/@capacitor/push-notifications": { - "version": "8.1.2", - "resolved": "https://registry.npmjs.org/@capacitor/push-notifications/-/push-notifications-8.1.2.tgz", - "integrity": "sha512-TQl4XxqJfNF+KbSYBFMXYYT5WXUaleVKXlByhh/8+KySMVNjp+l5OqAUoGe/sY1IAnGXEuUeCjA/WLZEVvGwrA==", - "license": "MIT", - "peerDependencies": { - "@capacitor/core": ">=8.0.0" - } - }, "node_modules/@capacitor/share": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@capacitor/share/-/share-8.0.2.tgz", @@ -2701,15 +2584,6 @@ "integrity": "sha512-/C1FUo8/OkKuAT4nCIu/34ny9siNHr9qtFezu4kxm6GY1wNFxrCFWjfYx5C1tUhVGz3fxBABegupkpjXvjCHrw==", "license": "ISC" }, - "node_modules/@capacitor/toast": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@capacitor/toast/-/toast-8.0.1.tgz", - "integrity": "sha512-0uoyftoAeFjtOMiozBo7YXEo+zcpQCAxtBONSvrljJ05SKdlb1A8OFHaV/DZfIpGt+19kzUGaEaUemLdll4wUw==", - "license": "MIT", - "peerDependencies": { - "@capacitor/core": ">=8.0.0" - } - }, "node_modules/@csstools/color-helpers": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", diff --git a/shared/src/i18n/ar/index.ts b/shared/src/i18n/ar/index.ts index d7fad9f62..a2746abe6 100644 --- a/shared/src/i18n/ar/index.ts +++ b/shared/src/i18n/ar/index.ts @@ -17,6 +17,7 @@ import help from './help'; import inspector from './inspector'; import journey from './journey'; import login from './login'; +import native from './native'; import map from './map'; import members from './members'; import memories from './memories'; @@ -66,6 +67,7 @@ const locale = { ...share, ...shared, ...login, + ...native, ...register, ...vacay, ...collection, diff --git a/shared/src/i18n/ar/native.ts b/shared/src/i18n/ar/native.ts new file mode 100644 index 000000000..f53875ca1 --- /dev/null +++ b/shared/src/i18n/ar/native.ts @@ -0,0 +1,26 @@ +import type { TranslationStrings } from '../types'; + +const native: TranslationStrings = { + 'native.connect.title': 'الاتصال بـ TREK', + 'native.connect.hint': 'أدخل العنوان الذي تفتح به TREK في متصفحك.', + 'native.connect.placeholder': 'trek.example.com', + 'native.connect.submit': 'اتصال', + 'native.connect.checking': 'جارٍ التحقق…', + 'native.connect.errorInvalid': 'لا يبدو هذا عنوان ويب.', + 'native.connect.errorUnreachable': 'لا يستجيب أي خادم على هذا العنوان. تحقق من العنوان ومن اتصالك.', + 'native.connect.errorNotTrek': 'الخادم على هذا العنوان ليس TREK.', + 'native.connect.errorTooOld': 'خادم TREK هذا قديم جدًا بالنسبة للتطبيق. يحتاج إلى تحديث أولًا.', + 'native.offline.title': 'تعذّر الوصول إلى الخادم', + 'native.offline.hint': 'تعذّر على TREK الوصول إلى {server}. تحقق من اتصالك ثم حاول مرة أخرى.', + 'native.offline.retry': 'إعادة المحاولة', + 'native.changeServer': 'تغيير الخادم', + 'native.login.failed': 'تعذّر تسجيل الدخول. يرجى المحاولة مرة أخرى.', + 'native.handoff.title': 'المتابعة في التطبيق', + 'native.handoff.hint': 'لقد سجّلت الدخول باسم {name}. افتح تطبيق TREK لإكمال تسجيل الدخول هناك.', + 'native.handoff.open': 'فتح تطبيق TREK', + 'native.handoff.switchAccount': 'استخدام حساب آخر', + 'native.settings.server': 'الخادم', + 'native.settings.serverHint': 'هذا التطبيق متصل بـ {server}.', +}; + +export default native; diff --git a/shared/src/i18n/br/index.ts b/shared/src/i18n/br/index.ts index 0afbf275b..cb4a48de4 100644 --- a/shared/src/i18n/br/index.ts +++ b/shared/src/i18n/br/index.ts @@ -17,6 +17,7 @@ import help from './help'; import inspector from './inspector'; import journey from './journey'; import login from './login'; +import native from './native'; import map from './map'; import members from './members'; import memories from './memories'; @@ -66,6 +67,7 @@ const locale = { ...share, ...shared, ...login, + ...native, ...register, ...vacay, ...collection, diff --git a/shared/src/i18n/br/native.ts b/shared/src/i18n/br/native.ts new file mode 100644 index 000000000..7bb613ac6 --- /dev/null +++ b/shared/src/i18n/br/native.ts @@ -0,0 +1,26 @@ +import type { TranslationStrings } from '../types'; + +const native: TranslationStrings = { + 'native.connect.title': 'Conectar ao TREK', + 'native.connect.hint': 'Digite o endereço em que você abre o TREK no navegador.', + 'native.connect.placeholder': 'trek.example.com', + 'native.connect.submit': 'Conectar', + 'native.connect.checking': 'Verificando…', + 'native.connect.errorInvalid': 'Isso não parece um endereço da web.', + 'native.connect.errorUnreachable': 'Nenhum servidor respondeu neste endereço. Verifique o endereço e sua conexão.', + 'native.connect.errorNotTrek': 'O servidor neste endereço não é o TREK.', + 'native.connect.errorTooOld': 'Este servidor TREK é antigo demais para o app. Ele precisa ser atualizado primeiro.', + 'native.offline.title': 'Servidor inacessível', + 'native.offline.hint': 'O TREK não conseguiu acessar {server}. Verifique sua conexão e tente novamente.', + 'native.offline.retry': 'Tentar novamente', + 'native.changeServer': 'Trocar de servidor', + 'native.login.failed': 'Não foi possível entrar. Tente novamente.', + 'native.handoff.title': 'Continuar no app', + 'native.handoff.hint': 'Você entrou como {name}. Abra o app TREK para concluir o login por lá.', + 'native.handoff.open': 'Abrir o app TREK', + 'native.handoff.switchAccount': 'Usar outra conta', + 'native.settings.server': 'Servidor', + 'native.settings.serverHint': 'Este app está conectado a {server}.', +}; + +export default native; diff --git a/shared/src/i18n/ca/index.ts b/shared/src/i18n/ca/index.ts index 99ad8c034..0efdeaec1 100644 --- a/shared/src/i18n/ca/index.ts +++ b/shared/src/i18n/ca/index.ts @@ -17,6 +17,7 @@ import help from './help'; import inspector from './inspector'; import journey from './journey'; import login from './login'; +import native from './native'; import map from './map'; import members from './members'; import memories from './memories'; @@ -66,6 +67,7 @@ const locale = { ...share, ...shared, ...login, + ...native, ...register, ...vacay, ...collection, diff --git a/shared/src/i18n/ca/native.ts b/shared/src/i18n/ca/native.ts new file mode 100644 index 000000000..dfa07a281 --- /dev/null +++ b/shared/src/i18n/ca/native.ts @@ -0,0 +1,26 @@ +import type { TranslationStrings } from '../types'; + +const native: TranslationStrings = { + 'native.connect.title': 'Connecta amb TREK', + 'native.connect.hint': 'Introdueix l’adreça amb què obres TREK al navegador.', + 'native.connect.placeholder': 'trek.example.com', + 'native.connect.submit': 'Connecta', + 'native.connect.checking': 'S’està comprovant…', + 'native.connect.errorInvalid': 'Això no sembla una adreça web.', + 'native.connect.errorUnreachable': 'Cap servidor respon en aquesta adreça. Comprova l’adreça i la connexió.', + 'native.connect.errorNotTrek': 'El servidor d’aquesta adreça no és TREK.', + 'native.connect.errorTooOld': 'Aquest servidor de TREK és massa antic per a l’app. Primer cal actualitzar-lo.', + 'native.offline.title': 'No es pot accedir al servidor', + 'native.offline.hint': 'TREK no ha pogut connectar amb {server}. Comprova la connexió i torna-ho a provar.', + 'native.offline.retry': 'Torna-ho a provar', + 'native.changeServer': 'Canvia de servidor', + 'native.login.failed': 'No s’ha pogut iniciar la sessió. Torna-ho a provar.', + 'native.handoff.title': 'Continua a l’app', + 'native.handoff.hint': 'Has iniciat la sessió com a {name}. Obre l’app de TREK per acabar d’iniciar-hi la sessió.', + 'native.handoff.open': 'Obre l’app de TREK', + 'native.handoff.switchAccount': 'Fes servir un altre compte', + 'native.settings.server': 'Servidor', + 'native.settings.serverHint': 'Aquesta app està connectada a {server}.', +}; + +export default native; diff --git a/shared/src/i18n/cs/index.ts b/shared/src/i18n/cs/index.ts index d7fad9f62..a2746abe6 100644 --- a/shared/src/i18n/cs/index.ts +++ b/shared/src/i18n/cs/index.ts @@ -17,6 +17,7 @@ import help from './help'; import inspector from './inspector'; import journey from './journey'; import login from './login'; +import native from './native'; import map from './map'; import members from './members'; import memories from './memories'; @@ -66,6 +67,7 @@ const locale = { ...share, ...shared, ...login, + ...native, ...register, ...vacay, ...collection, diff --git a/shared/src/i18n/cs/native.ts b/shared/src/i18n/cs/native.ts new file mode 100644 index 000000000..826386836 --- /dev/null +++ b/shared/src/i18n/cs/native.ts @@ -0,0 +1,26 @@ +import type { TranslationStrings } from '../types'; + +const native: TranslationStrings = { + 'native.connect.title': 'Připojit k TREK', + 'native.connect.hint': 'Zadejte adresu, na které otevíráte TREK v prohlížeči.', + 'native.connect.placeholder': 'trek.example.com', + 'native.connect.submit': 'Připojit', + 'native.connect.checking': 'Ověřování…', + 'native.connect.errorInvalid': 'To nevypadá jako webová adresa.', + 'native.connect.errorUnreachable': 'Na této adrese neodpovídá žádný server. Zkontrolujte adresu a připojení.', + 'native.connect.errorNotTrek': 'Server na této adrese není TREK.', + 'native.connect.errorTooOld': 'Tento server TREK je pro aplikaci příliš starý. Nejprve potřebuje aktualizaci.', + 'native.offline.title': 'Server není dostupný', + 'native.offline.hint': 'TREK se nepodařilo spojit s {server}. Zkontrolujte připojení a zkuste to znovu.', + 'native.offline.retry': 'Zkusit znovu', + 'native.changeServer': 'Změnit server', + 'native.login.failed': 'Přihlášení se nezdařilo. Zkuste to prosím znovu.', + 'native.handoff.title': 'Pokračovat v aplikaci', + 'native.handoff.hint': 'Jste přihlášeni jako {name}. Otevřete aplikaci TREK a dokončete přihlášení v ní.', + 'native.handoff.open': 'Otevřít aplikaci TREK', + 'native.handoff.switchAccount': 'Použít jiný účet', + 'native.settings.server': 'Server', + 'native.settings.serverHint': 'Tato aplikace je připojena k {server}.', +}; + +export default native; diff --git a/shared/src/i18n/de/index.ts b/shared/src/i18n/de/index.ts index d7fad9f62..a2746abe6 100644 --- a/shared/src/i18n/de/index.ts +++ b/shared/src/i18n/de/index.ts @@ -17,6 +17,7 @@ import help from './help'; import inspector from './inspector'; import journey from './journey'; import login from './login'; +import native from './native'; import map from './map'; import members from './members'; import memories from './memories'; @@ -66,6 +67,7 @@ const locale = { ...share, ...shared, ...login, + ...native, ...register, ...vacay, ...collection, diff --git a/shared/src/i18n/de/native.ts b/shared/src/i18n/de/native.ts new file mode 100644 index 000000000..e19558553 --- /dev/null +++ b/shared/src/i18n/de/native.ts @@ -0,0 +1,26 @@ +import type { TranslationStrings } from '../types'; + +const native: TranslationStrings = { + 'native.connect.title': 'Mit TREK verbinden', + 'native.connect.hint': 'Gib die Adresse ein, unter der du TREK im Browser öffnest.', + 'native.connect.placeholder': 'trek.example.com', + 'native.connect.submit': 'Verbinden', + 'native.connect.checking': 'Wird geprüft…', + 'native.connect.errorInvalid': 'Das sieht nicht nach einer Webadresse aus.', + 'native.connect.errorUnreachable': 'Unter dieser Adresse antwortet kein Server. Prüfe die Adresse und deine Verbindung.', + 'native.connect.errorNotTrek': 'Unter dieser Adresse läuft kein TREK.', + 'native.connect.errorTooOld': 'Dieser TREK-Server ist zu alt für die App. Er braucht zuerst ein Update.', + 'native.offline.title': 'Server nicht erreichbar', + 'native.offline.hint': 'TREK konnte {server} nicht erreichen. Prüfe deine Verbindung und versuche es erneut.', + 'native.offline.retry': 'Erneut versuchen', + 'native.changeServer': 'Server wechseln', + 'native.login.failed': 'Die Anmeldung hat nicht geklappt. Bitte versuche es erneut.', + 'native.handoff.title': 'Weiter in der App', + 'native.handoff.hint': 'Du bist als {name} angemeldet. Öffne die TREK-App, um die Anmeldung dort abzuschließen.', + 'native.handoff.open': 'TREK-App öffnen', + 'native.handoff.switchAccount': 'Anderes Konto verwenden', + 'native.settings.server': 'Server', + 'native.settings.serverHint': 'Diese App ist mit {server} verbunden.', +}; + +export default native; diff --git a/shared/src/i18n/en/index.ts b/shared/src/i18n/en/index.ts index d7fad9f62..a2746abe6 100644 --- a/shared/src/i18n/en/index.ts +++ b/shared/src/i18n/en/index.ts @@ -17,6 +17,7 @@ import help from './help'; import inspector from './inspector'; import journey from './journey'; import login from './login'; +import native from './native'; import map from './map'; import members from './members'; import memories from './memories'; @@ -66,6 +67,7 @@ const locale = { ...share, ...shared, ...login, + ...native, ...register, ...vacay, ...collection, diff --git a/shared/src/i18n/en/native.ts b/shared/src/i18n/en/native.ts new file mode 100644 index 000000000..7c96c9d67 --- /dev/null +++ b/shared/src/i18n/en/native.ts @@ -0,0 +1,26 @@ +import type { TranslationStrings } from '../types'; + +const native: TranslationStrings = { + 'native.connect.title': 'Connect to TREK', + 'native.connect.hint': 'Enter the address you open TREK at in your browser.', + 'native.connect.placeholder': 'trek.example.com', + 'native.connect.submit': 'Connect', + 'native.connect.checking': 'Checking…', + 'native.connect.errorInvalid': 'That does not look like a web address.', + 'native.connect.errorUnreachable': 'No server answered at this address. Check the address and your connection.', + 'native.connect.errorNotTrek': 'The server at this address is not TREK.', + 'native.connect.errorTooOld': 'This TREK server is too old for the app. It needs an update first.', + 'native.offline.title': 'Server not reachable', + 'native.offline.hint': 'TREK could not reach {server}. Check your connection and try again.', + 'native.offline.retry': 'Try again', + 'native.changeServer': 'Change server', + 'native.login.failed': 'Signing in did not work. Please try again.', + 'native.handoff.title': 'Continue in the app', + 'native.handoff.hint': 'You are signed in as {name}. Open the TREK app to finish signing in there.', + 'native.handoff.open': 'Open the TREK app', + 'native.handoff.switchAccount': 'Use a different account', + 'native.settings.server': 'Server', + 'native.settings.serverHint': 'This app is connected to {server}.', +}; + +export default native; diff --git a/shared/src/i18n/es/index.ts b/shared/src/i18n/es/index.ts index e4f631e73..85ed6e733 100644 --- a/shared/src/i18n/es/index.ts +++ b/shared/src/i18n/es/index.ts @@ -17,6 +17,7 @@ import help from './help'; import inspector from './inspector'; import journey from './journey'; import login from './login'; +import native from './native'; import map from './map'; import members from './members'; import memories from './memories'; @@ -66,6 +67,7 @@ const locale = { ...share, ...shared, ...login, + ...native, ...register, ...vacay, ...collection, diff --git a/shared/src/i18n/es/native.ts b/shared/src/i18n/es/native.ts new file mode 100644 index 000000000..d9d0c58b8 --- /dev/null +++ b/shared/src/i18n/es/native.ts @@ -0,0 +1,26 @@ +import type { TranslationStrings } from '../types'; + +const native: TranslationStrings = { + 'native.connect.title': 'Conectar con TREK', + 'native.connect.hint': 'Introduce la dirección con la que abres TREK en tu navegador.', + 'native.connect.placeholder': 'trek.example.com', + 'native.connect.submit': 'Conectar', + 'native.connect.checking': 'Comprobando…', + 'native.connect.errorInvalid': 'Eso no parece una dirección web.', + 'native.connect.errorUnreachable': 'Ningún servidor responde en esta dirección. Revisa la dirección y tu conexión.', + 'native.connect.errorNotTrek': 'El servidor de esta dirección no es TREK.', + 'native.connect.errorTooOld': 'Este servidor de TREK es demasiado antiguo para la app. Primero necesita una actualización.', + 'native.offline.title': 'Servidor no disponible', + 'native.offline.hint': 'TREK no pudo conectar con {server}. Revisa tu conexión e inténtalo de nuevo.', + 'native.offline.retry': 'Reintentar', + 'native.changeServer': 'Cambiar de servidor', + 'native.login.failed': 'No se pudo iniciar sesión. Inténtalo de nuevo.', + 'native.handoff.title': 'Continuar en la app', + 'native.handoff.hint': 'Has iniciado sesión como {name}. Abre la app de TREK para terminar de iniciar sesión allí.', + 'native.handoff.open': 'Abrir la app de TREK', + 'native.handoff.switchAccount': 'Usar otra cuenta', + 'native.settings.server': 'Servidor', + 'native.settings.serverHint': 'Esta app está conectada a {server}.', +}; + +export default native; diff --git a/shared/src/i18n/fr/index.ts b/shared/src/i18n/fr/index.ts index d7fad9f62..a2746abe6 100644 --- a/shared/src/i18n/fr/index.ts +++ b/shared/src/i18n/fr/index.ts @@ -17,6 +17,7 @@ import help from './help'; import inspector from './inspector'; import journey from './journey'; import login from './login'; +import native from './native'; import map from './map'; import members from './members'; import memories from './memories'; @@ -66,6 +67,7 @@ const locale = { ...share, ...shared, ...login, + ...native, ...register, ...vacay, ...collection, diff --git a/shared/src/i18n/fr/native.ts b/shared/src/i18n/fr/native.ts new file mode 100644 index 000000000..a2abdba64 --- /dev/null +++ b/shared/src/i18n/fr/native.ts @@ -0,0 +1,26 @@ +import type { TranslationStrings } from '../types'; + +const native: TranslationStrings = { + 'native.connect.title': 'Se connecter à TREK', + 'native.connect.hint': 'Saisissez l’adresse à laquelle vous ouvrez TREK dans votre navigateur.', + 'native.connect.placeholder': 'trek.example.com', + 'native.connect.submit': 'Se connecter', + 'native.connect.checking': 'Vérification…', + 'native.connect.errorInvalid': 'Cela ne ressemble pas à une adresse web.', + 'native.connect.errorUnreachable': 'Aucun serveur ne répond à cette adresse. Vérifiez l’adresse et votre connexion.', + 'native.connect.errorNotTrek': 'Le serveur à cette adresse n’est pas TREK.', + 'native.connect.errorTooOld': 'Ce serveur TREK est trop ancien pour l’application. Il doit d’abord être mis à jour.', + 'native.offline.title': 'Serveur injoignable', + 'native.offline.hint': 'TREK n’a pas pu joindre {server}. Vérifiez votre connexion et réessayez.', + 'native.offline.retry': 'Réessayer', + 'native.changeServer': 'Changer de serveur', + 'native.login.failed': 'La connexion n’a pas abouti. Veuillez réessayer.', + 'native.handoff.title': 'Continuer dans l’application', + 'native.handoff.hint': 'Vous êtes connecté en tant que {name}. Ouvrez l’application TREK pour y terminer la connexion.', + 'native.handoff.open': 'Ouvrir l’application TREK', + 'native.handoff.switchAccount': 'Utiliser un autre compte', + 'native.settings.server': 'Serveur', + 'native.settings.serverHint': 'Cette application est connectée à {server}.', +}; + +export default native; diff --git a/shared/src/i18n/gr/index.ts b/shared/src/i18n/gr/index.ts index d7fad9f62..a2746abe6 100644 --- a/shared/src/i18n/gr/index.ts +++ b/shared/src/i18n/gr/index.ts @@ -17,6 +17,7 @@ import help from './help'; import inspector from './inspector'; import journey from './journey'; import login from './login'; +import native from './native'; import map from './map'; import members from './members'; import memories from './memories'; @@ -66,6 +67,7 @@ const locale = { ...share, ...shared, ...login, + ...native, ...register, ...vacay, ...collection, diff --git a/shared/src/i18n/gr/native.ts b/shared/src/i18n/gr/native.ts new file mode 100644 index 000000000..6b5e45e10 --- /dev/null +++ b/shared/src/i18n/gr/native.ts @@ -0,0 +1,26 @@ +import type { TranslationStrings } from '../types'; + +const native: TranslationStrings = { + 'native.connect.title': 'Σύνδεση στο TREK', + 'native.connect.hint': 'Πληκτρολογήστε τη διεύθυνση με την οποία ανοίγετε το TREK στο πρόγραμμα περιήγησης.', + 'native.connect.placeholder': 'trek.example.com', + 'native.connect.submit': 'Σύνδεση', + 'native.connect.checking': 'Έλεγχος…', + 'native.connect.errorInvalid': 'Αυτό δεν μοιάζει με διεύθυνση ιστού.', + 'native.connect.errorUnreachable': 'Κανένας διακομιστής δεν απαντά σε αυτή τη διεύθυνση. Ελέγξτε τη διεύθυνση και τη σύνδεσή σας.', + 'native.connect.errorNotTrek': 'Ο διακομιστής σε αυτή τη διεύθυνση δεν είναι TREK.', + 'native.connect.errorTooOld': 'Αυτός ο διακομιστής TREK είναι πολύ παλιός για την εφαρμογή. Χρειάζεται πρώτα ενημέρωση.', + 'native.offline.title': 'Ο διακομιστής δεν είναι προσβάσιμος', + 'native.offline.hint': 'Το TREK δεν μπόρεσε να συνδεθεί στο {server}. Ελέγξτε τη σύνδεσή σας και δοκιμάστε ξανά.', + 'native.offline.retry': 'Δοκιμάστε ξανά', + 'native.changeServer': 'Αλλαγή διακομιστή', + 'native.login.failed': 'Η σύνδεση δεν ολοκληρώθηκε. Δοκιμάστε ξανά.', + 'native.handoff.title': 'Συνέχεια στην εφαρμογή', + 'native.handoff.hint': 'Έχετε συνδεθεί ως {name}. Ανοίξτε την εφαρμογή TREK για να ολοκληρώσετε εκεί τη σύνδεση.', + 'native.handoff.open': 'Άνοιγμα της εφαρμογής TREK', + 'native.handoff.switchAccount': 'Χρήση άλλου λογαριασμού', + 'native.settings.server': 'Διακομιστής', + 'native.settings.serverHint': 'Αυτή η εφαρμογή είναι συνδεδεμένη στο {server}.', +}; + +export default native; diff --git a/shared/src/i18n/hu/index.ts b/shared/src/i18n/hu/index.ts index 0afbf275b..cb4a48de4 100644 --- a/shared/src/i18n/hu/index.ts +++ b/shared/src/i18n/hu/index.ts @@ -17,6 +17,7 @@ import help from './help'; import inspector from './inspector'; import journey from './journey'; import login from './login'; +import native from './native'; import map from './map'; import members from './members'; import memories from './memories'; @@ -66,6 +67,7 @@ const locale = { ...share, ...shared, ...login, + ...native, ...register, ...vacay, ...collection, diff --git a/shared/src/i18n/hu/native.ts b/shared/src/i18n/hu/native.ts new file mode 100644 index 000000000..22112f0d5 --- /dev/null +++ b/shared/src/i18n/hu/native.ts @@ -0,0 +1,26 @@ +import type { TranslationStrings } from '../types'; + +const native: TranslationStrings = { + 'native.connect.title': 'Csatlakozás a TREK-hez', + 'native.connect.hint': 'Add meg azt a címet, amelyen a böngésződben megnyitod a TREK-et.', + 'native.connect.placeholder': 'trek.example.com', + 'native.connect.submit': 'Csatlakozás', + 'native.connect.checking': 'Ellenőrzés…', + 'native.connect.errorInvalid': 'Ez nem úgy néz ki, mint egy webcím.', + 'native.connect.errorUnreachable': 'Ezen a címen nem válaszol szerver. Ellenőrizd a címet és a kapcsolatot.', + 'native.connect.errorNotTrek': 'Az ezen a címen futó szerver nem TREK.', + 'native.connect.errorTooOld': 'Ez a TREK-szerver túl régi az alkalmazáshoz. Előbb frissíteni kell.', + 'native.offline.title': 'A szerver nem érhető el', + 'native.offline.hint': 'A TREK nem érte el ezt: {server}. Ellenőrizd a kapcsolatot, és próbáld újra.', + 'native.offline.retry': 'Újra', + 'native.changeServer': 'Szerver módosítása', + 'native.login.failed': 'A bejelentkezés nem sikerült. Próbáld újra.', + 'native.handoff.title': 'Folytatás az alkalmazásban', + 'native.handoff.hint': '{name} néven vagy bejelentkezve. Nyisd meg a TREK alkalmazást, hogy ott befejezd a bejelentkezést.', + 'native.handoff.open': 'TREK alkalmazás megnyitása', + 'native.handoff.switchAccount': 'Másik fiók használata', + 'native.settings.server': 'Szerver', + 'native.settings.serverHint': 'Ez az alkalmazás ehhez csatlakozik: {server}.', +}; + +export default native; diff --git a/shared/src/i18n/id/index.ts b/shared/src/i18n/id/index.ts index d7fad9f62..a2746abe6 100644 --- a/shared/src/i18n/id/index.ts +++ b/shared/src/i18n/id/index.ts @@ -17,6 +17,7 @@ import help from './help'; import inspector from './inspector'; import journey from './journey'; import login from './login'; +import native from './native'; import map from './map'; import members from './members'; import memories from './memories'; @@ -66,6 +67,7 @@ const locale = { ...share, ...shared, ...login, + ...native, ...register, ...vacay, ...collection, diff --git a/shared/src/i18n/id/native.ts b/shared/src/i18n/id/native.ts new file mode 100644 index 000000000..cc5630681 --- /dev/null +++ b/shared/src/i18n/id/native.ts @@ -0,0 +1,26 @@ +import type { TranslationStrings } from '../types'; + +const native: TranslationStrings = { + 'native.connect.title': 'Hubungkan ke TREK', + 'native.connect.hint': 'Masukkan alamat yang kamu gunakan untuk membuka TREK di browser.', + 'native.connect.placeholder': 'trek.example.com', + 'native.connect.submit': 'Hubungkan', + 'native.connect.checking': 'Memeriksa…', + 'native.connect.errorInvalid': 'Itu tidak terlihat seperti alamat web.', + 'native.connect.errorUnreachable': 'Tidak ada server yang merespons di alamat ini. Periksa alamat dan koneksimu.', + 'native.connect.errorNotTrek': 'Server di alamat ini bukan TREK.', + 'native.connect.errorTooOld': 'Server TREK ini terlalu lama untuk aplikasi. Server perlu diperbarui terlebih dahulu.', + 'native.offline.title': 'Server tidak dapat dijangkau', + 'native.offline.hint': 'TREK tidak dapat menjangkau {server}. Periksa koneksimu dan coba lagi.', + 'native.offline.retry': 'Coba lagi', + 'native.changeServer': 'Ganti server', + 'native.login.failed': 'Gagal masuk. Silakan coba lagi.', + 'native.handoff.title': 'Lanjutkan di aplikasi', + 'native.handoff.hint': 'Kamu masuk sebagai {name}. Buka aplikasi TREK untuk menyelesaikan proses masuk di sana.', + 'native.handoff.open': 'Buka aplikasi TREK', + 'native.handoff.switchAccount': 'Gunakan akun lain', + 'native.settings.server': 'Server', + 'native.settings.serverHint': 'Aplikasi ini terhubung ke {server}.', +}; + +export default native; diff --git a/shared/src/i18n/it/index.ts b/shared/src/i18n/it/index.ts index d7fad9f62..a2746abe6 100644 --- a/shared/src/i18n/it/index.ts +++ b/shared/src/i18n/it/index.ts @@ -17,6 +17,7 @@ import help from './help'; import inspector from './inspector'; import journey from './journey'; import login from './login'; +import native from './native'; import map from './map'; import members from './members'; import memories from './memories'; @@ -66,6 +67,7 @@ const locale = { ...share, ...shared, ...login, + ...native, ...register, ...vacay, ...collection, diff --git a/shared/src/i18n/it/native.ts b/shared/src/i18n/it/native.ts new file mode 100644 index 000000000..e1aae0fad --- /dev/null +++ b/shared/src/i18n/it/native.ts @@ -0,0 +1,26 @@ +import type { TranslationStrings } from '../types'; + +const native: TranslationStrings = { + 'native.connect.title': 'Connetti a TREK', + 'native.connect.hint': 'Inserisci l’indirizzo con cui apri TREK nel browser.', + 'native.connect.placeholder': 'trek.example.com', + 'native.connect.submit': 'Connetti', + 'native.connect.checking': 'Verifica in corso…', + 'native.connect.errorInvalid': 'Non sembra un indirizzo web.', + 'native.connect.errorUnreachable': 'Nessun server risponde a questo indirizzo. Controlla l’indirizzo e la connessione.', + 'native.connect.errorNotTrek': 'Il server a questo indirizzo non è TREK.', + 'native.connect.errorTooOld': 'Questo server TREK è troppo vecchio per l’app. Deve prima essere aggiornato.', + 'native.offline.title': 'Server non raggiungibile', + 'native.offline.hint': 'TREK non è riuscito a raggiungere {server}. Controlla la connessione e riprova.', + 'native.offline.retry': 'Riprova', + 'native.changeServer': 'Cambia server', + 'native.login.failed': 'L’accesso non è riuscito. Riprova.', + 'native.handoff.title': 'Continua nell’app', + 'native.handoff.hint': 'Hai effettuato l’accesso come {name}. Apri l’app TREK per completare lì l’accesso.', + 'native.handoff.open': 'Apri l’app TREK', + 'native.handoff.switchAccount': 'Usa un altro account', + 'native.settings.server': 'Server', + 'native.settings.serverHint': 'Questa app è collegata a {server}.', +}; + +export default native; diff --git a/shared/src/i18n/ja/index.ts b/shared/src/i18n/ja/index.ts index d7fad9f62..a2746abe6 100644 --- a/shared/src/i18n/ja/index.ts +++ b/shared/src/i18n/ja/index.ts @@ -17,6 +17,7 @@ import help from './help'; import inspector from './inspector'; import journey from './journey'; import login from './login'; +import native from './native'; import map from './map'; import members from './members'; import memories from './memories'; @@ -66,6 +67,7 @@ const locale = { ...share, ...shared, ...login, + ...native, ...register, ...vacay, ...collection, diff --git a/shared/src/i18n/ja/native.ts b/shared/src/i18n/ja/native.ts new file mode 100644 index 000000000..7b7b9bd19 --- /dev/null +++ b/shared/src/i18n/ja/native.ts @@ -0,0 +1,26 @@ +import type { TranslationStrings } from '../types'; + +const native: TranslationStrings = { + 'native.connect.title': 'TREK に接続', + 'native.connect.hint': 'ブラウザで TREK を開くときのアドレスを入力してください。', + 'native.connect.placeholder': 'trek.example.com', + 'native.connect.submit': '接続', + 'native.connect.checking': '確認中…', + 'native.connect.errorInvalid': 'Web アドレスではないようです。', + 'native.connect.errorUnreachable': 'このアドレスで応答するサーバーがありません。アドレスと接続を確認してください。', + 'native.connect.errorNotTrek': 'このアドレスのサーバーは TREK ではありません。', + 'native.connect.errorTooOld': 'この TREK サーバーはアプリに対して古すぎます。先に更新が必要です。', + 'native.offline.title': 'サーバーに接続できません', + 'native.offline.hint': 'TREK は {server} に接続できませんでした。接続を確認して、もう一度お試しください。', + 'native.offline.retry': '再試行', + 'native.changeServer': 'サーバーを変更', + 'native.login.failed': 'ログインできませんでした。もう一度お試しください。', + 'native.handoff.title': 'アプリで続行', + 'native.handoff.hint': '{name} としてログインしています。TREK アプリを開いて、そちらでログインを完了してください。', + 'native.handoff.open': 'TREK アプリを開く', + 'native.handoff.switchAccount': '別のアカウントを使う', + 'native.settings.server': 'サーバー', + 'native.settings.serverHint': 'このアプリは {server} に接続されています。', +}; + +export default native; diff --git a/shared/src/i18n/ko/index.ts b/shared/src/i18n/ko/index.ts index d7fad9f62..a2746abe6 100644 --- a/shared/src/i18n/ko/index.ts +++ b/shared/src/i18n/ko/index.ts @@ -17,6 +17,7 @@ import help from './help'; import inspector from './inspector'; import journey from './journey'; import login from './login'; +import native from './native'; import map from './map'; import members from './members'; import memories from './memories'; @@ -66,6 +67,7 @@ const locale = { ...share, ...shared, ...login, + ...native, ...register, ...vacay, ...collection, diff --git a/shared/src/i18n/ko/native.ts b/shared/src/i18n/ko/native.ts new file mode 100644 index 000000000..5a451d621 --- /dev/null +++ b/shared/src/i18n/ko/native.ts @@ -0,0 +1,26 @@ +import type { TranslationStrings } from '../types'; + +const native: TranslationStrings = { + 'native.connect.title': 'TREK에 연결', + 'native.connect.hint': '브라우저에서 TREK을 여는 주소를 입력하세요.', + 'native.connect.placeholder': 'trek.example.com', + 'native.connect.submit': '연결', + 'native.connect.checking': '확인 중…', + 'native.connect.errorInvalid': '웹 주소가 아닌 것 같습니다.', + 'native.connect.errorUnreachable': '이 주소에서 응답하는 서버가 없습니다. 주소와 연결 상태를 확인하세요.', + 'native.connect.errorNotTrek': '이 주소의 서버는 TREK이 아닙니다.', + 'native.connect.errorTooOld': '이 TREK 서버는 앱에서 사용하기에 너무 오래되었습니다. 먼저 업데이트가 필요합니다.', + 'native.offline.title': '서버에 연결할 수 없음', + 'native.offline.hint': 'TREK이 {server}에 연결하지 못했습니다. 연결 상태를 확인한 후 다시 시도하세요.', + 'native.offline.retry': '다시 시도', + 'native.changeServer': '서버 변경', + 'native.login.failed': '로그인하지 못했습니다. 다시 시도하세요.', + 'native.handoff.title': '앱에서 계속', + 'native.handoff.hint': '{name}(으)로 로그인되어 있습니다. TREK 앱을 열어 로그인을 완료하세요.', + 'native.handoff.open': 'TREK 앱 열기', + 'native.handoff.switchAccount': '다른 계정 사용', + 'native.settings.server': '서버', + 'native.settings.serverHint': '이 앱은 {server}에 연결되어 있습니다.', +}; + +export default native; diff --git a/shared/src/i18n/nl/index.ts b/shared/src/i18n/nl/index.ts index d7fad9f62..a2746abe6 100644 --- a/shared/src/i18n/nl/index.ts +++ b/shared/src/i18n/nl/index.ts @@ -17,6 +17,7 @@ import help from './help'; import inspector from './inspector'; import journey from './journey'; import login from './login'; +import native from './native'; import map from './map'; import members from './members'; import memories from './memories'; @@ -66,6 +67,7 @@ const locale = { ...share, ...shared, ...login, + ...native, ...register, ...vacay, ...collection, diff --git a/shared/src/i18n/nl/native.ts b/shared/src/i18n/nl/native.ts new file mode 100644 index 000000000..2ebfc9be5 --- /dev/null +++ b/shared/src/i18n/nl/native.ts @@ -0,0 +1,26 @@ +import type { TranslationStrings } from '../types'; + +const native: TranslationStrings = { + 'native.connect.title': 'Verbinden met TREK', + 'native.connect.hint': 'Voer het adres in waarop je TREK in je browser opent.', + 'native.connect.placeholder': 'trek.example.com', + 'native.connect.submit': 'Verbinden', + 'native.connect.checking': 'Controleren…', + 'native.connect.errorInvalid': 'Dat lijkt geen webadres.', + 'native.connect.errorUnreachable': 'Er reageert geen server op dit adres. Controleer het adres en je verbinding.', + 'native.connect.errorNotTrek': 'De server op dit adres is geen TREK.', + 'native.connect.errorTooOld': 'Deze TREK-server is te oud voor de app. Hij moet eerst worden bijgewerkt.', + 'native.offline.title': 'Server niet bereikbaar', + 'native.offline.hint': 'TREK kon {server} niet bereiken. Controleer je verbinding en probeer het opnieuw.', + 'native.offline.retry': 'Opnieuw proberen', + 'native.changeServer': 'Server wijzigen', + 'native.login.failed': 'Inloggen is niet gelukt. Probeer het opnieuw.', + 'native.handoff.title': 'Verder in de app', + 'native.handoff.hint': 'Je bent ingelogd als {name}. Open de TREK-app om daar het inloggen af te ronden.', + 'native.handoff.open': 'TREK-app openen', + 'native.handoff.switchAccount': 'Ander account gebruiken', + 'native.settings.server': 'Server', + 'native.settings.serverHint': 'Deze app is verbonden met {server}.', +}; + +export default native; diff --git a/shared/src/i18n/pl/index.ts b/shared/src/i18n/pl/index.ts index d7fad9f62..a2746abe6 100644 --- a/shared/src/i18n/pl/index.ts +++ b/shared/src/i18n/pl/index.ts @@ -17,6 +17,7 @@ import help from './help'; import inspector from './inspector'; import journey from './journey'; import login from './login'; +import native from './native'; import map from './map'; import members from './members'; import memories from './memories'; @@ -66,6 +67,7 @@ const locale = { ...share, ...shared, ...login, + ...native, ...register, ...vacay, ...collection, diff --git a/shared/src/i18n/pl/native.ts b/shared/src/i18n/pl/native.ts new file mode 100644 index 000000000..4e8b72bc6 --- /dev/null +++ b/shared/src/i18n/pl/native.ts @@ -0,0 +1,26 @@ +import type { TranslationStrings } from '../types'; + +const native: TranslationStrings = { + 'native.connect.title': 'Połącz z TREK', + 'native.connect.hint': 'Wpisz adres, pod którym otwierasz TREK w przeglądarce.', + 'native.connect.placeholder': 'trek.example.com', + 'native.connect.submit': 'Połącz', + 'native.connect.checking': 'Sprawdzanie…', + 'native.connect.errorInvalid': 'To nie wygląda na adres internetowy.', + 'native.connect.errorUnreachable': 'Pod tym adresem nie odpowiada żaden serwer. Sprawdź adres i połączenie.', + 'native.connect.errorNotTrek': 'Serwer pod tym adresem to nie TREK.', + 'native.connect.errorTooOld': 'Ten serwer TREK jest za stary dla aplikacji. Najpierw wymaga aktualizacji.', + 'native.offline.title': 'Serwer nieosiągalny', + 'native.offline.hint': 'TREK nie mógł połączyć się z {server}. Sprawdź połączenie i spróbuj ponownie.', + 'native.offline.retry': 'Spróbuj ponownie', + 'native.changeServer': 'Zmień serwer', + 'native.login.failed': 'Logowanie się nie powiodło. Spróbuj ponownie.', + 'native.handoff.title': 'Kontynuuj w aplikacji', + 'native.handoff.hint': 'Zalogowano jako {name}. Otwórz aplikację TREK, aby tam dokończyć logowanie.', + 'native.handoff.open': 'Otwórz aplikację TREK', + 'native.handoff.switchAccount': 'Użyj innego konta', + 'native.settings.server': 'Serwer', + 'native.settings.serverHint': 'Ta aplikacja jest połączona z {server}.', +}; + +export default native; diff --git a/shared/src/i18n/ru/index.ts b/shared/src/i18n/ru/index.ts index d7fad9f62..a2746abe6 100644 --- a/shared/src/i18n/ru/index.ts +++ b/shared/src/i18n/ru/index.ts @@ -17,6 +17,7 @@ import help from './help'; import inspector from './inspector'; import journey from './journey'; import login from './login'; +import native from './native'; import map from './map'; import members from './members'; import memories from './memories'; @@ -66,6 +67,7 @@ const locale = { ...share, ...shared, ...login, + ...native, ...register, ...vacay, ...collection, diff --git a/shared/src/i18n/ru/native.ts b/shared/src/i18n/ru/native.ts new file mode 100644 index 000000000..5f5590c45 --- /dev/null +++ b/shared/src/i18n/ru/native.ts @@ -0,0 +1,26 @@ +import type { TranslationStrings } from '../types'; + +const native: TranslationStrings = { + 'native.connect.title': 'Подключение к TREK', + 'native.connect.hint': 'Введите адрес, по которому вы открываете TREK в браузере.', + 'native.connect.placeholder': 'trek.example.com', + 'native.connect.submit': 'Подключиться', + 'native.connect.checking': 'Проверка…', + 'native.connect.errorInvalid': 'Это не похоже на веб-адрес.', + 'native.connect.errorUnreachable': 'По этому адресу не отвечает ни один сервер. Проверьте адрес и подключение.', + 'native.connect.errorNotTrek': 'Сервер по этому адресу не является TREK.', + 'native.connect.errorTooOld': 'Этот сервер TREK слишком старый для приложения. Сначала его нужно обновить.', + 'native.offline.title': 'Сервер недоступен', + 'native.offline.hint': 'TREK не удалось подключиться к {server}. Проверьте подключение и повторите попытку.', + 'native.offline.retry': 'Повторить', + 'native.changeServer': 'Сменить сервер', + 'native.login.failed': 'Не удалось войти. Попробуйте ещё раз.', + 'native.handoff.title': 'Продолжить в приложении', + 'native.handoff.hint': 'Вы вошли как {name}. Откройте приложение TREK, чтобы завершить вход в нём.', + 'native.handoff.open': 'Открыть приложение TREK', + 'native.handoff.switchAccount': 'Использовать другой аккаунт', + 'native.settings.server': 'Сервер', + 'native.settings.serverHint': 'Приложение подключено к {server}.', +}; + +export default native; diff --git a/shared/src/i18n/sv/index.ts b/shared/src/i18n/sv/index.ts index d7fad9f62..a2746abe6 100644 --- a/shared/src/i18n/sv/index.ts +++ b/shared/src/i18n/sv/index.ts @@ -17,6 +17,7 @@ import help from './help'; import inspector from './inspector'; import journey from './journey'; import login from './login'; +import native from './native'; import map from './map'; import members from './members'; import memories from './memories'; @@ -66,6 +67,7 @@ const locale = { ...share, ...shared, ...login, + ...native, ...register, ...vacay, ...collection, diff --git a/shared/src/i18n/sv/native.ts b/shared/src/i18n/sv/native.ts new file mode 100644 index 000000000..d5d80c32f --- /dev/null +++ b/shared/src/i18n/sv/native.ts @@ -0,0 +1,26 @@ +import type { TranslationStrings } from '../types'; + +const native: TranslationStrings = { + 'native.connect.title': 'Anslut till TREK', + 'native.connect.hint': 'Ange adressen du öppnar TREK på i webbläsaren.', + 'native.connect.placeholder': 'trek.example.com', + 'native.connect.submit': 'Anslut', + 'native.connect.checking': 'Kontrollerar…', + 'native.connect.errorInvalid': 'Det ser inte ut som en webbadress.', + 'native.connect.errorUnreachable': 'Ingen server svarar på den här adressen. Kontrollera adressen och din anslutning.', + 'native.connect.errorNotTrek': 'Servern på den här adressen är inte TREK.', + 'native.connect.errorTooOld': 'Den här TREK-servern är för gammal för appen. Den behöver uppdateras först.', + 'native.offline.title': 'Servern går inte att nå', + 'native.offline.hint': 'TREK kunde inte nå {server}. Kontrollera din anslutning och försök igen.', + 'native.offline.retry': 'Försök igen', + 'native.changeServer': 'Byt server', + 'native.login.failed': 'Inloggningen misslyckades. Försök igen.', + 'native.handoff.title': 'Fortsätt i appen', + 'native.handoff.hint': 'Du är inloggad som {name}. Öppna TREK-appen för att slutföra inloggningen där.', + 'native.handoff.open': 'Öppna TREK-appen', + 'native.handoff.switchAccount': 'Använd ett annat konto', + 'native.settings.server': 'Server', + 'native.settings.serverHint': 'Den här appen är ansluten till {server}.', +}; + +export default native; diff --git a/shared/src/i18n/tr/index.ts b/shared/src/i18n/tr/index.ts index d7fad9f62..a2746abe6 100644 --- a/shared/src/i18n/tr/index.ts +++ b/shared/src/i18n/tr/index.ts @@ -17,6 +17,7 @@ import help from './help'; import inspector from './inspector'; import journey from './journey'; import login from './login'; +import native from './native'; import map from './map'; import members from './members'; import memories from './memories'; @@ -66,6 +67,7 @@ const locale = { ...share, ...shared, ...login, + ...native, ...register, ...vacay, ...collection, diff --git a/shared/src/i18n/tr/native.ts b/shared/src/i18n/tr/native.ts new file mode 100644 index 000000000..12636aca5 --- /dev/null +++ b/shared/src/i18n/tr/native.ts @@ -0,0 +1,26 @@ +import type { TranslationStrings } from '../types'; + +const native: TranslationStrings = { + 'native.connect.title': 'TREK’e bağlan', + 'native.connect.hint': 'TREK’i tarayıcında açtığın adresi gir.', + 'native.connect.placeholder': 'trek.example.com', + 'native.connect.submit': 'Bağlan', + 'native.connect.checking': 'Kontrol ediliyor…', + 'native.connect.errorInvalid': 'Bu bir web adresine benzemiyor.', + 'native.connect.errorUnreachable': 'Bu adreste yanıt veren bir sunucu yok. Adresi ve bağlantını kontrol et.', + 'native.connect.errorNotTrek': 'Bu adresteki sunucu TREK değil.', + 'native.connect.errorTooOld': 'Bu TREK sunucusu uygulama için çok eski. Önce güncellenmesi gerekiyor.', + 'native.offline.title': 'Sunucuya ulaşılamıyor', + 'native.offline.hint': 'TREK, {server} adresine ulaşamadı. Bağlantını kontrol edip tekrar dene.', + 'native.offline.retry': 'Tekrar dene', + 'native.changeServer': 'Sunucuyu değiştir', + 'native.login.failed': 'Giriş yapılamadı. Lütfen tekrar dene.', + 'native.handoff.title': 'Uygulamada devam et', + 'native.handoff.hint': '{name} olarak giriş yaptın. Girişi tamamlamak için TREK uygulamasını aç.', + 'native.handoff.open': 'TREK uygulamasını aç', + 'native.handoff.switchAccount': 'Başka bir hesap kullan', + 'native.settings.server': 'Sunucu', + 'native.settings.serverHint': 'Bu uygulama {server} adresine bağlı.', +}; + +export default native; diff --git a/shared/src/i18n/uk/index.ts b/shared/src/i18n/uk/index.ts index d7fad9f62..a2746abe6 100644 --- a/shared/src/i18n/uk/index.ts +++ b/shared/src/i18n/uk/index.ts @@ -17,6 +17,7 @@ import help from './help'; import inspector from './inspector'; import journey from './journey'; import login from './login'; +import native from './native'; import map from './map'; import members from './members'; import memories from './memories'; @@ -66,6 +67,7 @@ const locale = { ...share, ...shared, ...login, + ...native, ...register, ...vacay, ...collection, diff --git a/shared/src/i18n/uk/native.ts b/shared/src/i18n/uk/native.ts new file mode 100644 index 000000000..401bf9f98 --- /dev/null +++ b/shared/src/i18n/uk/native.ts @@ -0,0 +1,26 @@ +import type { TranslationStrings } from '../types'; + +const native: TranslationStrings = { + 'native.connect.title': 'Підключення до TREK', + 'native.connect.hint': 'Введіть адресу, за якою ви відкриваєте TREK у браузері.', + 'native.connect.placeholder': 'trek.example.com', + 'native.connect.submit': 'Підключитися', + 'native.connect.checking': 'Перевірка…', + 'native.connect.errorInvalid': 'Це не схоже на вебадресу.', + 'native.connect.errorUnreachable': 'За цією адресою не відповідає жоден сервер. Перевірте адресу та з’єднання.', + 'native.connect.errorNotTrek': 'Сервер за цією адресою не є TREK.', + 'native.connect.errorTooOld': 'Цей сервер TREK застарий для застосунку. Спершу його потрібно оновити.', + 'native.offline.title': 'Сервер недоступний', + 'native.offline.hint': 'TREK не вдалося підключитися до {server}. Перевірте з’єднання та спробуйте ще раз.', + 'native.offline.retry': 'Спробувати ще раз', + 'native.changeServer': 'Змінити сервер', + 'native.login.failed': 'Не вдалося увійти. Спробуйте ще раз.', + 'native.handoff.title': 'Продовжити в застосунку', + 'native.handoff.hint': 'Ви увійшли як {name}. Відкрийте застосунок TREK, щоб завершити вхід у ньому.', + 'native.handoff.open': 'Відкрити застосунок TREK', + 'native.handoff.switchAccount': 'Використати інший обліковий запис', + 'native.settings.server': 'Сервер', + 'native.settings.serverHint': 'Застосунок підключено до {server}.', +}; + +export default native; diff --git a/shared/src/i18n/vi/index.ts b/shared/src/i18n/vi/index.ts index d7fad9f62..a2746abe6 100644 --- a/shared/src/i18n/vi/index.ts +++ b/shared/src/i18n/vi/index.ts @@ -17,6 +17,7 @@ import help from './help'; import inspector from './inspector'; import journey from './journey'; import login from './login'; +import native from './native'; import map from './map'; import members from './members'; import memories from './memories'; @@ -66,6 +67,7 @@ const locale = { ...share, ...shared, ...login, + ...native, ...register, ...vacay, ...collection, diff --git a/shared/src/i18n/vi/native.ts b/shared/src/i18n/vi/native.ts new file mode 100644 index 000000000..c4b136d77 --- /dev/null +++ b/shared/src/i18n/vi/native.ts @@ -0,0 +1,26 @@ +import type { TranslationStrings } from '../types'; + +const native: TranslationStrings = { + 'native.connect.title': 'Kết nối với TREK', + 'native.connect.hint': 'Nhập địa chỉ bạn dùng để mở TREK trong trình duyệt.', + 'native.connect.placeholder': 'trek.example.com', + 'native.connect.submit': 'Kết nối', + 'native.connect.checking': 'Đang kiểm tra…', + 'native.connect.errorInvalid': 'Đây có vẻ không phải là địa chỉ web.', + 'native.connect.errorUnreachable': 'Không có máy chủ nào phản hồi tại địa chỉ này. Hãy kiểm tra địa chỉ và kết nối của bạn.', + 'native.connect.errorNotTrek': 'Máy chủ tại địa chỉ này không phải TREK.', + 'native.connect.errorTooOld': 'Máy chủ TREK này quá cũ đối với ứng dụng. Máy chủ cần được cập nhật trước.', + 'native.offline.title': 'Không kết nối được máy chủ', + 'native.offline.hint': 'TREK không thể kết nối tới {server}. Hãy kiểm tra kết nối và thử lại.', + 'native.offline.retry': 'Thử lại', + 'native.changeServer': 'Đổi máy chủ', + 'native.login.failed': 'Đăng nhập không thành công. Vui lòng thử lại.', + 'native.handoff.title': 'Tiếp tục trong ứng dụng', + 'native.handoff.hint': 'Bạn đã đăng nhập với tên {name}. Mở ứng dụng TREK để hoàn tất đăng nhập ở đó.', + 'native.handoff.open': 'Mở ứng dụng TREK', + 'native.handoff.switchAccount': 'Dùng tài khoản khác', + 'native.settings.server': 'Máy chủ', + 'native.settings.serverHint': 'Ứng dụng này đang kết nối với {server}.', +}; + +export default native; diff --git a/shared/src/i18n/zh-TW/index.ts b/shared/src/i18n/zh-TW/index.ts index b51d0de98..5d351d89d 100644 --- a/shared/src/i18n/zh-TW/index.ts +++ b/shared/src/i18n/zh-TW/index.ts @@ -17,6 +17,7 @@ import help from './help'; import inspector from './inspector'; import journey from './journey'; import login from './login'; +import native from './native'; import map from './map'; import members from './members'; import memories from './memories'; @@ -66,6 +67,7 @@ const locale = { ...share, ...shared, ...login, + ...native, ...register, ...vacay, ...collection, diff --git a/shared/src/i18n/zh-TW/native.ts b/shared/src/i18n/zh-TW/native.ts new file mode 100644 index 000000000..66bc01fb5 --- /dev/null +++ b/shared/src/i18n/zh-TW/native.ts @@ -0,0 +1,26 @@ +import type { TranslationStrings } from '../types'; + +const native: TranslationStrings = { + 'native.connect.title': '連線到 TREK', + 'native.connect.hint': '請輸入你在瀏覽器中開啟 TREK 時使用的網址。', + 'native.connect.placeholder': 'trek.example.com', + 'native.connect.submit': '連線', + 'native.connect.checking': '正在檢查…', + 'native.connect.errorInvalid': '這看起來不像網址。', + 'native.connect.errorUnreachable': '此網址沒有伺服器回應。請檢查網址與網路連線。', + 'native.connect.errorNotTrek': '此網址上的伺服器不是 TREK。', + 'native.connect.errorTooOld': '此 TREK 伺服器版本過舊,無法用於 App。請先更新伺服器。', + 'native.offline.title': '無法連線到伺服器', + 'native.offline.hint': 'TREK 無法連線到 {server}。請檢查網路連線後再試一次。', + 'native.offline.retry': '再試一次', + 'native.changeServer': '更換伺服器', + 'native.login.failed': '登入未成功,請再試一次。', + 'native.handoff.title': '在 App 中繼續', + 'native.handoff.hint': '你已以 {name} 身分登入。請開啟 TREK App,在那裡完成登入。', + 'native.handoff.open': '開啟 TREK App', + 'native.handoff.switchAccount': '使用其他帳號', + 'native.settings.server': '伺服器', + 'native.settings.serverHint': '此 App 已連線到 {server}。', +}; + +export default native; diff --git a/shared/src/i18n/zh/index.ts b/shared/src/i18n/zh/index.ts index d7fad9f62..a2746abe6 100644 --- a/shared/src/i18n/zh/index.ts +++ b/shared/src/i18n/zh/index.ts @@ -17,6 +17,7 @@ import help from './help'; import inspector from './inspector'; import journey from './journey'; import login from './login'; +import native from './native'; import map from './map'; import members from './members'; import memories from './memories'; @@ -66,6 +67,7 @@ const locale = { ...share, ...shared, ...login, + ...native, ...register, ...vacay, ...collection, diff --git a/shared/src/i18n/zh/native.ts b/shared/src/i18n/zh/native.ts new file mode 100644 index 000000000..a919207bd --- /dev/null +++ b/shared/src/i18n/zh/native.ts @@ -0,0 +1,26 @@ +import type { TranslationStrings } from '../types'; + +const native: TranslationStrings = { + 'native.connect.title': '连接到 TREK', + 'native.connect.hint': '请输入你在浏览器中打开 TREK 时使用的地址。', + 'native.connect.placeholder': 'trek.example.com', + 'native.connect.submit': '连接', + 'native.connect.checking': '正在检查…', + 'native.connect.errorInvalid': '这看起来不像网址。', + 'native.connect.errorUnreachable': '此地址没有服务器响应。请检查地址和网络连接。', + 'native.connect.errorNotTrek': '此地址上的服务器不是 TREK。', + 'native.connect.errorTooOld': '此 TREK 服务器版本过旧,无法用于应用。请先更新服务器。', + 'native.offline.title': '无法连接服务器', + 'native.offline.hint': 'TREK 无法连接到 {server}。请检查网络连接后重试。', + 'native.offline.retry': '重试', + 'native.changeServer': '更换服务器', + 'native.login.failed': '登录未成功,请重试。', + 'native.handoff.title': '在应用中继续', + 'native.handoff.hint': '你已以 {name} 身份登录。请打开 TREK 应用,在那里完成登录。', + 'native.handoff.open': '打开 TREK 应用', + 'native.handoff.switchAccount': '使用其他账号', + 'native.settings.server': '服务器', + 'native.settings.serverHint': '此应用已连接到 {server}。', +}; + +export default native;