mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(native): the apps load the server's own UI
The apps ship only an address screen now. Once a server is picked, the native side points the WebView at it: TrekBridgeViewController on iOS and a config copy with server.url on Android. The UI then comes from that server, in the version it runs and on its own origin, so the session cookie, uploads, /ws and plugins work exactly as in a browser and no app release has to keep pace with the servers out there. The address screen (native-shell/, built into native-dist/) probes /api/health/features through native HTTP, tries https before http when no scheme was typed, and refuses servers without the nativeApp flag. It also serves as the error page when the remembered server does not load: retry, change server, or on iOS the stored copy of the server's start page. TrekShell is the app's own plugin for all of that: remember, change and reload the server, sign in through ASWebAuthenticationSession or a Custom Tab, and on iOS keep and open the offline copy of the start page, because WKWebView runs no service worker for a domain the app does not list. Plugins the frame does not use are gone (push, camera, geolocation, calendar, file transfer, in-app browser and more): the WebView covers the camera and location itself, and push needs a relay before it is worth shipping. With them go the push entitlement, the calendar usage strings and the Firebase bits. The iOS scheme falls back to capacitor://, since Capacitor drops an iosScheme of https, and the arm64 capability replaces armv7.
This commit is contained in:
@@ -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/
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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')
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -24,16 +24,17 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Deep links (@capacitor/app appUrlOpen).
|
||||
<!-- Deep links.
|
||||
|
||||
The scheme is @string/custom_url_scheme (com.liketrek.trek).
|
||||
launchMode is singleTask above, so a link delivers into the
|
||||
running task via onNewIntent rather than starting a second copy
|
||||
of the app on top of the first.
|
||||
|
||||
This is the return path for native OIDC/OAuth login: the flow
|
||||
opens in a Custom Tab and the provider redirects back to
|
||||
com.liketrek.trek://… , which lands here.
|
||||
This is the return path for SSO and passkey sign-in: TrekShell
|
||||
opens the server's login page in a Custom Tab, and after the
|
||||
hand-off it redirects to com.liketrek.trek://auth?code=…, which
|
||||
lands here and in TrekShellPlugin.handleOnNewIntent.
|
||||
|
||||
Not an App Link (autoVerify + https). App Links are verified
|
||||
against a specific domain via assetlinks.json, and TREK is
|
||||
@@ -59,101 +60,21 @@
|
||||
android:resource="@xml/file_paths"></meta-data>
|
||||
</provider>
|
||||
|
||||
<!-- Notification icon, shared by @capacitor/push-notifications (via this
|
||||
FCM meta-data) and @capacitor/local-notifications (via the smallIcon
|
||||
option in capacitor.config.ts).
|
||||
|
||||
It must be a white silhouette on transparency; the launcher icon is a
|
||||
full-colour square and Android would render it as a white blob.
|
||||
notification_icon is generated from public/icons/icon-white.svg by
|
||||
scripts/generate-native-icons.mjs.
|
||||
|
||||
default_notification_channel_id names the channel FCM uses for any
|
||||
message that does not carry its own. The channel itself still has to
|
||||
be created at runtime with PushNotifications.createChannel() using
|
||||
this exact id — Android drops notifications for a channel that was
|
||||
never created, and FCM silently falls back to its own channel.
|
||||
|
||||
No default_notification_color on purpose. TREK's palette is neutral
|
||||
and user-selectable, so there is no one brand colour to tint with;
|
||||
left unset, Android tints the small icon with the system accent,
|
||||
which stays legible in both the light and dark shade. -->
|
||||
<meta-data
|
||||
android:name="com.google.firebase.messaging.default_notification_icon"
|
||||
android:resource="@drawable/notification_icon" />
|
||||
<meta-data
|
||||
android:name="com.google.firebase.messaging.default_notification_channel_id"
|
||||
android:value="@string/default_notification_channel_id" />
|
||||
|
||||
<!-- Asks Play services to install the backported Android photo picker on
|
||||
Android 11/12 devices, so @capacitor/camera gets the modern picker
|
||||
instead of falling back to ACTION_OPEN_DOCUMENT. Disabled by design:
|
||||
it is a dependency declaration, not a service that runs.
|
||||
|
||||
Drop this block if TREK ever ships a Play-services-free build
|
||||
(F-Droid); the fallback picker keeps working without it. -->
|
||||
<service
|
||||
android:name="com.google.android.gms.metadata.ModuleDependencies"
|
||||
android:enabled="false"
|
||||
android:exported="false"
|
||||
tools:ignore="MissingClass">
|
||||
<intent-filter>
|
||||
<action android:name="com.google.android.gms.metadata.MODULE_DEPENDENCIES" />
|
||||
</intent-filter>
|
||||
<meta-data android:name="photopicker_activity:0:required" android:value="" />
|
||||
</service>
|
||||
</application>
|
||||
|
||||
<!-- Permissions.
|
||||
|
||||
Declared here only where the app itself needs them. Plugins that ship
|
||||
their own manifest contribute theirs through the merge and are NOT
|
||||
repeated: @capacitor/haptics (VIBRATE), @capacitor/calendar
|
||||
(READ_CALENDAR, WRITE_CALENDAR) and @capacitor/local-notifications
|
||||
(RECEIVE_BOOT_COMPLETED, WAKE_LOCK, SCHEDULE_EXACT_ALARM). -->
|
||||
<!-- Permissions. Only what the WebView itself needs: the file picker's
|
||||
camera option and navigator.geolocation for "locate me" on the map,
|
||||
both granted at runtime through Capacitor's WebChromeClient. -->
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<!-- Reachability: connectivity.ts probes the server on an interval, and on
|
||||
native we want to skip the probe entirely when there is no transport.
|
||||
@capacitor/network declares this too; kept explicit because
|
||||
connectivity.ts depends on it directly. -->
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<!-- Android 13+ gates notifications behind a runtime grant. firebase-messaging
|
||||
and @capacitor/local-notifications declare this too, but relying on a
|
||||
transitive manifest merge for a permission the app must request by hand is
|
||||
a silent-failure waiting to happen — it must be requested via
|
||||
requestPermissions() before register()/schedule(). -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<!-- @capacitor/geolocation. Declares no permissions of its own, so both are
|
||||
required here. COARSE is listed first because it is the weaker grant and
|
||||
is enough for "what city am I in" — request FINE only where the map
|
||||
genuinely needs it. -->
|
||||
<!-- COARSE is listed first because it is the weaker grant and is enough
|
||||
for "what city am I in". -->
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<!-- required="false" on purpose: TREK is a planner, and everything except
|
||||
"locate me" works without GPS hardware. Marking it required would have
|
||||
Play hide the app from tablets and other GPS-less devices. -->
|
||||
<uses-feature android:name="android.hardware.location.gps" android:required="false" />
|
||||
|
||||
<!-- @capacitor/camera. Not strictly required — the plugin launches the system
|
||||
camera activity, which carries its own permission — but io.ionic.libs's
|
||||
inappbrowser transitively declares CAMERA anyway, and once it is declared
|
||||
Android routes the plugin through the runtime-grant path. Declared here
|
||||
explicitly so it is a decision rather than something inherited. -->
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
|
||||
<!-- Stripped from the merge. io.ionic.libs:ioninappbrowser-android declares
|
||||
RECORD_AUDIO and MODIFY_AUDIO_SETTINGS so that pages opened in its WebView
|
||||
mode can use getUserMedia for microphone capture. TREK records no audio
|
||||
anywhere, and shipping these would put "Microphone" on the store listing
|
||||
and in the app's permission screen for a capability that does not exist.
|
||||
|
||||
Revisit only if a feature genuinely needs mic input in an embedded page. -->
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" tools:node="remove" />
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" tools:node="remove" />
|
||||
|
||||
<!-- Deliberately NOT declared: READ/WRITE_EXTERNAL_STORAGE.
|
||||
@capacitor/camera needs storage permissions only for saveToGallery, and
|
||||
TREK uploads photos to the server rather than writing them to the device
|
||||
gallery. Add them the day a feature actually needs them. -->
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
</manifest>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,4 @@
|
||||
<string name="title_activity_main">TREK - Plan your trips!</string>
|
||||
<string name="package_name">com.liketrek.trek</string>
|
||||
<string name="custom_url_scheme">com.liketrek.trek</string>
|
||||
<!-- Referenced by the FCM default_notification_channel_id meta-data in
|
||||
AndroidManifest.xml. A channel with this exact id must also be created
|
||||
at runtime via PushNotifications.createChannel(). -->
|
||||
<string name="default_notification_channel_id">trek_default</string>
|
||||
</resources>
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
+17
-32
@@ -1,5 +1,3 @@
|
||||
/// <reference types="@capacitor/push-notifications" />
|
||||
/// <reference types="@capacitor/local-notifications" />
|
||||
/// <reference types="@capacitor/splash-screen" />
|
||||
/// <reference types="@capacitor/keyboard" />
|
||||
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 <meta name="theme-color"> 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().
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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/**',
|
||||
|
||||
@@ -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 = "<group>"; };
|
||||
7A1E00022C0000000000A002 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
||||
7A1E00042C0000000000A004 /* TrekBridgeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrekBridgeViewController.swift; sourceTree = "<group>"; };
|
||||
7A1E00062C0000000000A006 /* TrekShellPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrekShellPlugin.swift; sourceTree = "<group>"; };
|
||||
/* 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;
|
||||
};
|
||||
|
||||
@@ -2,20 +2,5 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!--
|
||||
The Push Notifications capability. Xcode normally writes this when you tick
|
||||
"Push Notifications" under Signing & Capabilities; it is committed here so a
|
||||
clean checkout builds with push already enabled.
|
||||
|
||||
`development` is correct for debug builds and for TestFlight. Xcode's
|
||||
automatic signing rewrites this to `production` when archiving for
|
||||
distribution, so it does not need a second value here.
|
||||
|
||||
Enabling the capability in the Apple Developer portal for the App ID
|
||||
com.liketrek.trek is a separate, manual step — the entitlement alone will
|
||||
fail to sign until the App ID has it too.
|
||||
-->
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<!--Bridge View Controller-->
|
||||
<scene sceneID="tne-QT-ifu">
|
||||
<objects>
|
||||
<viewController id="BYZ-38-t0r" customClass="CAPBridgeViewController" customModule="Capacitor" sceneMemberID="viewController"/>
|
||||
<viewController id="BYZ-38-t0r" customClass="TrekBridgeViewController" customModule="App" customModuleProvider="target" sceneMemberID="viewController"/>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
</scene>
|
||||
|
||||
@@ -51,39 +51,25 @@
|
||||
logs "Local network prohibited" instead of a useful error. -->
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>TREK connects to the TREK server you choose, which is often running on your home or office network.</string>
|
||||
<!-- Permission usage descriptions. iOS does not merely deny a missing one —
|
||||
<!-- Permission usage descriptions. iOS does not merely deny a missing one:
|
||||
it terminates the app the first time the API is touched, so every key
|
||||
below is load-bearing even where the feature is optional.
|
||||
|
||||
@capacitor/camera -->
|
||||
The WebView's file picker offers "Take Photo" and "Photo Library". -->
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>TREK uses the camera so you can photograph a place, a journey entry or a booking document and add it to your trip.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>TREK needs access to your photos so you can add them to trips, places and journey entries.</string>
|
||||
<!-- "Save Image" in the share sheet that exports and downloads open in. -->
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>TREK saves photos and exports you download from your trips back to your photo library.</string>
|
||||
<!-- @capacitor/geolocation. NSLocationAlwaysAndWhenInUse is required by
|
||||
Apple because the underlying ion-ios-geolocation library is capable of
|
||||
background reporting; TREK never asks for it, so the user only ever sees
|
||||
the when-in-use prompt. -->
|
||||
<!-- navigator.geolocation in the WebView ("locate me" on the map). -->
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>TREK uses your location to centre the map on you and to find places near you.</string>
|
||||
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||
<string>TREK uses your location to centre the map on you and to find places near you.</string>
|
||||
<!-- @capacitor/calendar. iOS 17 split calendar access into full and
|
||||
write-only, each with its own key; the pre-17 key stays for older
|
||||
devices. Adding a trip to the device calendar needs only write-only
|
||||
access, which is the weaker prompt. -->
|
||||
<key>NSCalendarsWriteOnlyAccessUsageDescription</key>
|
||||
<string>TREK adds your trip days and bookings to your calendar.</string>
|
||||
<key>NSCalendarsFullAccessUsageDescription</key>
|
||||
<string>TREK reads your calendar to show existing commitments alongside your trip, and adds trip events to it.</string>
|
||||
<key>NSCalendarsUsageDescription</key>
|
||||
<string>TREK adds your trip days and bookings to your calendar.</string>
|
||||
<!-- Deep links (@capacitor/app appUrlOpen), matching the intent-filter in
|
||||
AndroidManifest.xml and @string/custom_url_scheme there. This is the
|
||||
return path for native OIDC/OAuth login, which opens in an
|
||||
SFSafariViewController and redirects back to com.liketrek.trek://…
|
||||
return path for SSO and passkey sign-in, which runs in an
|
||||
ASWebAuthenticationSession and redirects back to com.liketrek.trek://…
|
||||
|
||||
Not a Universal Link: those are verified against one specific domain via
|
||||
apple-app-site-association, and a self-hosted app has no single domain to
|
||||
@@ -127,7 +113,7 @@
|
||||
<string>Main</string>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>armv7</string>
|
||||
<string>arm64</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
|
||||
@@ -4,19 +4,15 @@
|
||||
<dict>
|
||||
<!--
|
||||
Apple's required-reason API declaration. App Store Connect rejects uploads
|
||||
that call these APIs without an entry here.
|
||||
|
||||
NSPrivacyAccessedAPICategoryFileTimestamp / C617.1 is required by
|
||||
@capacitor/filesystem, which @capacitor/file-transfer depends on to resolve
|
||||
a native path before it can download or upload. Capacitor's own frameworks
|
||||
that call these APIs without an entry here. Capacitor's own frameworks
|
||||
ship their own PrivacyInfo; this file covers the app target only.
|
||||
|
||||
Add to this file when adding a plugin that touches a required-reason API —
|
||||
UserDefaults (CA92.1) for @capacitor/preferences, disk space (E174.1),
|
||||
system boot time (35F9.1).
|
||||
Add to this file when adding a plugin that touches a required-reason API,
|
||||
e.g. disk space (E174.1) or system boot time (35F9.1).
|
||||
-->
|
||||
<key>NSPrivacyAccessedAPITypes</key>
|
||||
<array>
|
||||
<!-- @capacitor/filesystem, which writes exports and downloads before
|
||||
they are handed to the share sheet. -->
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
|
||||
@@ -25,7 +21,8 @@
|
||||
<string>C617.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
<!-- @capacitor/preferences, which backs the saved server profiles. -->
|
||||
<!-- TrekServerStore (TrekBridgeViewController.swift) keeps the chosen
|
||||
server in UserDefaults. -->
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
]
|
||||
)
|
||||
]
|
||||
|
||||
@@ -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<TranslationStrings>('../../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, string> = {}): string => {
|
||||
const template = strings[key] ?? fallback[key] ?? key
|
||||
return typeof template === 'string'
|
||||
? template.replace(/\{(\w+)\}/g, (whole, name: string) => params[name] ?? whole)
|
||||
: key
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
<meta name="theme-color" content="#09090b" />
|
||||
<title>TREK</title>
|
||||
</head>
|
||||
<body>
|
||||
<main id="shell" class="shell" aria-live="polite"></main>
|
||||
<script type="module" src="./main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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<ProbeFailure, string> = {
|
||||
invalid: 'native.connect.errorInvalid',
|
||||
unreachable: 'native.connect.errorUnreachable',
|
||||
notTrek: 'native.connect.errorNotTrek',
|
||||
tooOld: 'native.connect.errorTooOld',
|
||||
}
|
||||
|
||||
function el<K extends keyof HTMLElementTagNameMap>(tag: K, props: Partial<HTMLElementTagNameMap[K]> = {}, ...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()
|
||||
@@ -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; }
|
||||
+2
-12
@@ -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",
|
||||
|
||||
@@ -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: '<html>', 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()
|
||||
})
|
||||
})
|
||||
@@ -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<ProbeResponse>
|
||||
|
||||
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<string, unknown> {
|
||||
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<ProbeFailure, number> = { invalid: 0, unreachable: 1, notTrek: 2, tooOld: 3 }
|
||||
|
||||
export async function probeServer(input: string, get: ProbeGet): Promise<ProbeResult> {
|
||||
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 }
|
||||
}
|
||||
@@ -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<void>
|
||||
/** Forgets the server and returns to the address screen. */
|
||||
resetServer(): Promise<void>
|
||||
/** Loads the remembered server again, after it could not be reached. */
|
||||
reload(): Promise<void>
|
||||
/**
|
||||
* 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<TrekShellPlugin>('TrekShell')
|
||||
|
||||
export const APP_URL_SCHEME = 'com.liketrek.trek'
|
||||
@@ -26,5 +26,5 @@
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src", "tests"]
|
||||
"include": ["src", "tests", "native-shell"]
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
})
|
||||
Generated
-126
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user