diff --git a/.gitignore b/.gitignore index 639be47..38d6db9 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,15 @@ node_modules # Tauri /src-tauri/target -/src-tauri/gen + +# Track Android source (MainActivity.kt, manifest, proguard, gradle); Tauri's nested +# gen/android/.gitignore + app/.gitignore keep build/, .gradle/, .cxx/, generated/, *.so out. +/src-tauri/gen/* +!/src-tauri/gen/android/ + +# Never commit signing secrets (nested ignore misses keystore.properties) +/src-tauri/gen/android/keystore.properties +/src-tauri/gen/android/key.properties # OS .DS_Store diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index df6bcbc..58ce254 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -39,7 +39,7 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "str futures = "0.3" rayon = "1" rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] } -[target.'cfg(not(target_os = "android"))'.dependencies] +[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-updater = "2" tauri-plugin-single-instance = "2" tauri-plugin-window-state = "2" diff --git a/src-tauri/gen/android/.editorconfig b/src-tauri/gen/android/.editorconfig new file mode 100644 index 0000000..ebe51d3 --- /dev/null +++ b/src-tauri/gen/android/.editorconfig @@ -0,0 +1,12 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = false +insert_final_newline = false \ No newline at end of file diff --git a/src-tauri/gen/android/.gitignore b/src-tauri/gen/android/.gitignore new file mode 100644 index 0000000..b248203 --- /dev/null +++ b/src-tauri/gen/android/.gitignore @@ -0,0 +1,19 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +build +/captures +.externalNativeBuild +.cxx +local.properties +key.properties + +/.tauri +/tauri.settings.gradle \ No newline at end of file diff --git a/src-tauri/gen/android/app/.gitignore b/src-tauri/gen/android/app/.gitignore new file mode 100644 index 0000000..ea4c18b --- /dev/null +++ b/src-tauri/gen/android/app/.gitignore @@ -0,0 +1,6 @@ +/src/main/java/com/helixnotes/app/generated +/src/main/jniLibs/**/*.so +/src/main/assets/tauri.conf.json +/tauri.build.gradle.kts +/proguard-tauri.pro +/tauri.properties \ No newline at end of file diff --git a/src-tauri/gen/android/app/build.gradle.kts b/src-tauri/gen/android/app/build.gradle.kts new file mode 100644 index 0000000..3fa2f70 --- /dev/null +++ b/src-tauri/gen/android/app/build.gradle.kts @@ -0,0 +1,92 @@ +import java.util.Properties + +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("rust") +} + +val tauriProperties = Properties().apply { + val propFile = file("tauri.properties") + if (propFile.exists()) { + propFile.inputStream().use { load(it) } + } +} + +val keystoreProperties = Properties().apply { + val propFile = rootProject.file("keystore.properties") + if (propFile.exists()) { + propFile.inputStream().use { load(it) } + } +} + +android { + compileSdk = 36 + namespace = "com.helixnotes.app" + + if (keystoreProperties.containsKey("storeFile")) { + signingConfigs { + create("release") { + storeFile = file(keystoreProperties["storeFile"] as String) + storePassword = keystoreProperties["storePassword"] as String + keyAlias = keystoreProperties["keyAlias"] as String + keyPassword = keystoreProperties["keyPassword"] as String + } + } + } + + defaultConfig { + manifestPlaceholders["usesCleartextTraffic"] = "false" + applicationId = "com.helixnotes.app" + minSdk = 24 + targetSdk = 36 + versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt() + versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0") + } + buildTypes { + getByName("debug") { + manifestPlaceholders["usesCleartextTraffic"] = "true" + isDebuggable = true + isJniDebuggable = true + isMinifyEnabled = false + packaging { jniLibs.keepDebugSymbols.add("*/arm64-v8a/*.so") + jniLibs.keepDebugSymbols.add("*/armeabi-v7a/*.so") + jniLibs.keepDebugSymbols.add("*/x86/*.so") + jniLibs.keepDebugSymbols.add("*/x86_64/*.so") + } + } + getByName("release") { + isMinifyEnabled = true + proguardFiles( + *fileTree(".") { include("**/*.pro") } + .plus(getDefaultProguardFile("proguard-android-optimize.txt")) + .toList().toTypedArray() + ) + if (signingConfigs.names.contains("release")) { + signingConfig = signingConfigs.getByName("release") + } + } + } + kotlinOptions { + jvmTarget = "1.8" + } + buildFeatures { + buildConfig = true + } +} + +rust { + rootDirRel = "../../../" +} + +dependencies { + implementation("androidx.webkit:webkit:1.14.0") + implementation("androidx.appcompat:appcompat:1.7.1") + implementation("androidx.activity:activity-ktx:1.10.1") + implementation("com.google.android.material:material:1.12.0") + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.1.4") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0") +} + +apply(from = "tauri.build.gradle.kts") \ No newline at end of file diff --git a/src-tauri/gen/android/app/proguard-rules.pro b/src-tauri/gen/android/app/proguard-rules.pro new file mode 100644 index 0000000..569ca18 --- /dev/null +++ b/src-tauri/gen/android/app/proguard-rules.pro @@ -0,0 +1,22 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# Preserve JS interface annotations +-keepattributes JavascriptInterface + +# Keep our custom WebView JS bridge and all its public methods +-keep class com.helixnotes.app.StorageBridge { + public *; +} + +# Keep MainActivity public methods (called from StorageBridge) +-keep class com.helixnotes.app.MainActivity { + public *; +} + +# Ignore missing java.beans classes (referenced by Jackson but not available on Android) +-dontwarn java.beans.** diff --git a/src-tauri/gen/android/app/src/main/AndroidManifest.xml b/src-tauri/gen/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..3d56096 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src-tauri/gen/android/app/src/main/java/com/helixnotes/app/MainActivity.kt b/src-tauri/gen/android/app/src/main/java/com/helixnotes/app/MainActivity.kt new file mode 100644 index 0000000..5b64a56 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/java/com/helixnotes/app/MainActivity.kt @@ -0,0 +1,150 @@ +package com.helixnotes.app + +import android.Manifest +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.Settings +import android.webkit.JavascriptInterface +import android.webkit.WebView +import android.webkit.MimeTypeMap +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.core.content.FileProvider +import java.io.File + +class MainActivity : TauriActivity() { + private var webView: WebView? = null + + companion object { + private const val STORAGE_PERMISSION_REQUEST_CODE = 1001 + } + + override fun onWebViewCreate(webView: WebView) { + super.onWebViewCreate(webView) + this.webView = webView + webView.addJavascriptInterface(StorageBridge(this), "Android") + } + + override fun onResume() { + super.onResume() + if (hasStoragePermission()) { + webView?.evaluateJavascript( + "window.__storagePermissionGranted && window.__storagePermissionGranted()", + null + ) + } + } + + fun hasStoragePermission(): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + // Android 11+ (API 30+): "All files access" special permission. + Environment.isExternalStorageManager() + } else { + // Android 7-10 (API 24-29): legacy runtime storage permission. + ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE) == + PackageManager.PERMISSION_GRANTED + } + } + + fun requestStoragePermission() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + if (Environment.isExternalStorageManager()) return + try { + val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION) + intent.data = Uri.parse("package:$packageName") + startActivity(intent) + } catch (e: Exception) { + try { + val fallback = Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION) + startActivity(fallback) + } catch (_: Exception) { + val details = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + details.data = Uri.parse("package:$packageName") + startActivity(details) + } + } + } else { + // Android 7-10: request the legacy runtime permission directly. + ActivityCompat.requestPermissions( + this, + arrayOf( + Manifest.permission.READ_EXTERNAL_STORAGE, + Manifest.permission.WRITE_EXTERNAL_STORAGE + ), + STORAGE_PERMISSION_REQUEST_CODE + ) + } + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode == STORAGE_PERMISSION_REQUEST_CODE && hasStoragePermission()) { + webView?.evaluateJavascript( + "window.__storagePermissionGranted && window.__storagePermissionGranted()", + null + ) + } + } + + fun prepareVaultDir(path: String): Boolean { + return try { + val dir = File(path) + dir.mkdirs() + File(dir, ".helixnotes/trash").mkdirs() + File(dir, ".helixnotes/attachments").mkdirs() + dir.exists() && dir.canWrite() + } catch (e: Exception) { + false + } + } + + fun openFile(path: String) { + try { + val file = File(path) + if (!file.exists()) return + val uri = FileProvider.getUriForFile(this, "${packageName}.fileprovider", file) + val ext = MimeTypeMap.getFileExtensionFromUrl(path) + val mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext) ?: "*/*" + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, mime) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + startActivity(intent) + } catch (e: Exception) { + android.util.Log.e("HelixNotes", "Failed to open file: $path", e) + } + } +} + +class StorageBridge(private val activity: MainActivity) { + @JavascriptInterface + fun hasStoragePermission(): Boolean { + return activity.hasStoragePermission() + } + + @JavascriptInterface + fun requestStoragePermission() { + activity.runOnUiThread { + activity.requestStoragePermission() + } + } + + @JavascriptInterface + fun prepareVaultDir(path: String): Boolean { + return activity.prepareVaultDir(path) + } + + @JavascriptInterface + fun openFile(path: String) { + activity.runOnUiThread { + activity.openFile(path) + } + } +} diff --git a/src-tauri/gen/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/src-tauri/gen/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/src-tauri/gen/android/app/src/main/res/drawable/ic_launcher_background.xml b/src-tauri/gen/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..069d0d5 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,10 @@ + + + + diff --git a/src-tauri/gen/android/app/src/main/res/layout/activity_main.xml b/src-tauri/gen/android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..4fc2444 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,18 @@ + + + + + + \ No newline at end of file diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/src-tauri/gen/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..2ffbf24 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..f19fd59 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..b8ff6ed Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..83a807f Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..48ad3c9 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..38d5847 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..cc4ccf2 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..9ce7204 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..5ae6cc8 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..55f776d Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d63d540 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..56cf5aa Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..da7a48e Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..d205907 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..18b1f31 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..25a2033 Binary files /dev/null and b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/src-tauri/gen/android/app/src/main/res/values-night/themes.xml b/src-tauri/gen/android/app/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..911a468 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/res/values-night/themes.xml @@ -0,0 +1,8 @@ + + + + diff --git a/src-tauri/gen/android/app/src/main/res/values/colors.xml b/src-tauri/gen/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/src-tauri/gen/android/app/src/main/res/values/ic_launcher_background.xml b/src-tauri/gen/android/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..ea9c223 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #fff + \ No newline at end of file diff --git a/src-tauri/gen/android/app/src/main/res/values/strings.xml b/src-tauri/gen/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..457996c --- /dev/null +++ b/src-tauri/gen/android/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + HelixNotes + HelixNotes + \ No newline at end of file diff --git a/src-tauri/gen/android/app/src/main/res/values/themes.xml b/src-tauri/gen/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..16272ed --- /dev/null +++ b/src-tauri/gen/android/app/src/main/res/values/themes.xml @@ -0,0 +1,9 @@ + + + + diff --git a/src-tauri/gen/android/app/src/main/res/xml/file_paths.xml b/src-tauri/gen/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..782d63b --- /dev/null +++ b/src-tauri/gen/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src-tauri/gen/android/build.gradle.kts b/src-tauri/gen/android/build.gradle.kts new file mode 100644 index 0000000..607240b --- /dev/null +++ b/src-tauri/gen/android/build.gradle.kts @@ -0,0 +1,22 @@ +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath("com.android.tools.build:gradle:8.11.0") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.25") + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +tasks.register("clean").configure { + delete("build") +} + diff --git a/src-tauri/gen/android/buildSrc/build.gradle.kts b/src-tauri/gen/android/buildSrc/build.gradle.kts new file mode 100644 index 0000000..5c55bba --- /dev/null +++ b/src-tauri/gen/android/buildSrc/build.gradle.kts @@ -0,0 +1,23 @@ +plugins { + `kotlin-dsl` +} + +gradlePlugin { + plugins { + create("pluginsForCoolKids") { + id = "rust" + implementationClass = "RustPlugin" + } + } +} + +repositories { + google() + mavenCentral() +} + +dependencies { + compileOnly(gradleApi()) + implementation("com.android.tools.build:gradle:8.11.0") +} + diff --git a/src-tauri/gen/android/buildSrc/src/main/java/com/helixnotes/app/kotlin/BuildTask.kt b/src-tauri/gen/android/buildSrc/src/main/java/com/helixnotes/app/kotlin/BuildTask.kt new file mode 100644 index 0000000..f764e2a --- /dev/null +++ b/src-tauri/gen/android/buildSrc/src/main/java/com/helixnotes/app/kotlin/BuildTask.kt @@ -0,0 +1,68 @@ +import java.io.File +import org.apache.tools.ant.taskdefs.condition.Os +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.logging.LogLevel +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.TaskAction + +open class BuildTask : DefaultTask() { + @Input + var rootDirRel: String? = null + @Input + var target: String? = null + @Input + var release: Boolean? = null + + @TaskAction + fun assemble() { + val executable = """pnpm"""; + try { + runTauriCli(executable) + } catch (e: Exception) { + if (Os.isFamily(Os.FAMILY_WINDOWS)) { + // Try different Windows-specific extensions + val fallbacks = listOf( + "$executable.exe", + "$executable.cmd", + "$executable.bat", + ) + + var lastException: Exception = e + for (fallback in fallbacks) { + try { + runTauriCli(fallback) + return + } catch (fallbackException: Exception) { + lastException = fallbackException + } + } + throw lastException + } else { + throw e; + } + } + } + + fun runTauriCli(executable: String) { + val rootDirRel = rootDirRel ?: throw GradleException("rootDirRel cannot be null") + val target = target ?: throw GradleException("target cannot be null") + val release = release ?: throw GradleException("release cannot be null") + val args = listOf("tauri", "android", "android-studio-script"); + + project.exec { + workingDir(File(project.projectDir, rootDirRel)) + executable(executable) + args(args) + if (project.logger.isEnabled(LogLevel.DEBUG)) { + args("-vv") + } else if (project.logger.isEnabled(LogLevel.INFO)) { + args("-v") + } + if (release) { + args("--release") + } + args(listOf("--target", target)) + }.assertNormalExitValue() + } +} \ No newline at end of file diff --git a/src-tauri/gen/android/buildSrc/src/main/java/com/helixnotes/app/kotlin/RustPlugin.kt b/src-tauri/gen/android/buildSrc/src/main/java/com/helixnotes/app/kotlin/RustPlugin.kt new file mode 100644 index 0000000..4aa7fca --- /dev/null +++ b/src-tauri/gen/android/buildSrc/src/main/java/com/helixnotes/app/kotlin/RustPlugin.kt @@ -0,0 +1,85 @@ +import com.android.build.api.dsl.ApplicationExtension +import org.gradle.api.DefaultTask +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.get + +const val TASK_GROUP = "rust" + +open class Config { + lateinit var rootDirRel: String +} + +open class RustPlugin : Plugin { + private lateinit var config: Config + + override fun apply(project: Project) = with(project) { + config = extensions.create("rust", Config::class.java) + + val defaultAbiList = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64"); + val abiList = (findProperty("abiList") as? String)?.split(',') ?: defaultAbiList + + val defaultArchList = listOf("arm64", "arm", "x86", "x86_64"); + val archList = (findProperty("archList") as? String)?.split(',') ?: defaultArchList + + val targetsList = (findProperty("targetList") as? String)?.split(',') ?: listOf("aarch64", "armv7", "i686", "x86_64") + + extensions.configure { + @Suppress("UnstableApiUsage") + flavorDimensions.add("abi") + productFlavors { + create("universal") { + dimension = "abi" + ndk { + abiFilters += abiList + } + } + defaultArchList.forEachIndexed { index, arch -> + create(arch) { + dimension = "abi" + ndk { + abiFilters.add(defaultAbiList[index]) + } + } + } + } + } + + afterEvaluate { + for (profile in listOf("debug", "release")) { + val profileCapitalized = profile.replaceFirstChar { it.uppercase() } + val buildTask = tasks.maybeCreate( + "rustBuildUniversal$profileCapitalized", + DefaultTask::class.java + ).apply { + group = TASK_GROUP + description = "Build dynamic library in $profile mode for all targets" + } + + tasks["mergeUniversal${profileCapitalized}JniLibFolders"].dependsOn(buildTask) + + for (targetPair in targetsList.withIndex()) { + val targetName = targetPair.value + val targetArch = archList[targetPair.index] + val targetArchCapitalized = targetArch.replaceFirstChar { it.uppercase() } + val targetBuildTask = project.tasks.maybeCreate( + "rustBuild$targetArchCapitalized$profileCapitalized", + BuildTask::class.java + ).apply { + group = TASK_GROUP + description = "Build dynamic library in $profile mode for $targetArch" + rootDirRel = config.rootDirRel + target = targetName + release = profile == "release" + } + + buildTask.dependsOn(targetBuildTask) + tasks["merge$targetArchCapitalized${profileCapitalized}JniLibFolders"].dependsOn( + targetBuildTask + ) + } + } + } + } +} \ No newline at end of file diff --git a/src-tauri/gen/android/gradle.properties b/src-tauri/gen/android/gradle.properties new file mode 100644 index 0000000..2a7ec69 --- /dev/null +++ b/src-tauri/gen/android/gradle.properties @@ -0,0 +1,24 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app"s APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official +# Enables namespacing of each library's R class so that its R class includes only the +# resources declared in the library itself and none from the library's dependencies, +# thereby reducing the size of the R class for that library +android.nonTransitiveRClass=true +android.nonFinalResIds=false \ No newline at end of file diff --git a/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.jar b/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..e708b1c Binary files /dev/null and b/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.properties b/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..c5f9a53 --- /dev/null +++ b/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Tue May 10 19:22:52 CST 2022 +distributionBase=GRADLE_USER_HOME +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +distributionPath=wrapper/dists +zipStorePath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME diff --git a/src-tauri/gen/android/gradlew b/src-tauri/gen/android/gradlew new file mode 100755 index 0000000..4f906e0 --- /dev/null +++ b/src-tauri/gen/android/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/src-tauri/gen/android/gradlew.bat b/src-tauri/gen/android/gradlew.bat new file mode 100644 index 0000000..107acd3 --- /dev/null +++ b/src-tauri/gen/android/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/src-tauri/gen/android/settings.gradle b/src-tauri/gen/android/settings.gradle new file mode 100644 index 0000000..3939116 --- /dev/null +++ b/src-tauri/gen/android/settings.gradle @@ -0,0 +1,3 @@ +include ':app' + +apply from: 'tauri.settings.gradle' diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index ce45fe8..efc2396 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -11,18 +11,18 @@ use tauri::{AppHandle, Manager, State}; pub fn open_vault(app: AppHandle, state: State<'_, AppState>, path: String) -> Result<(), String> { operations::ensure_vault_structure(&path)?; - // Initialize search index - rebuild in background on Android (FUSE is slow) + // Initialize search index - rebuild in background on mobile (sandboxed FS is slow) let search = std::sync::Arc::new(SearchIndex::new(&path)?); - #[cfg(target_os = "android")] + #[cfg(mobile)] { let search_bg = search.clone(); let vault = path.clone(); std::thread::spawn(move || { let _ = search_bg.rebuild(&vault); - log::info!("Android: search index rebuild complete"); + log::info!("mobile: search index rebuild complete"); }); } - #[cfg(not(target_os = "android"))] + #[cfg(desktop)] { search.rebuild(&path)?; } @@ -670,7 +670,7 @@ pub fn save_vault_state(state: State<'_, AppState>, vault_state: VaultState) -> /// Read image from system clipboard (bypasses WebKitGTK clipboard bug). /// Returns PNG bytes as Vec, or error if no image on clipboard. -#[cfg(not(target_os = "android"))] +#[cfg(desktop)] #[tauri::command] pub fn read_clipboard_image() -> Result, String> { let mut clipboard = @@ -695,14 +695,14 @@ pub fn read_clipboard_image() -> Result, String> { Ok(buf) } -#[cfg(target_os = "android")] +#[cfg(mobile)] #[tauri::command] pub fn read_clipboard_image() -> Result, String> { Err("Clipboard image reading not supported on Android".to_string()) } /// Copy an image file to the system clipboard. -#[cfg(not(target_os = "android"))] +#[cfg(desktop)] #[tauri::command] pub fn copy_image_to_clipboard(path: String) -> Result<(), String> { let data = std::fs::read(&path).map_err(|e| format!("Failed to read image: {}", e))?; @@ -722,14 +722,14 @@ pub fn copy_image_to_clipboard(path: String) -> Result<(), String> { Ok(()) } -#[cfg(target_os = "android")] +#[cfg(mobile)] #[tauri::command] pub fn copy_image_to_clipboard(_path: String) -> Result<(), String> { Err("Clipboard image copy not supported on Android".to_string()) } /// Copy PNG bytes directly to the system clipboard. -#[cfg(not(target_os = "android"))] +#[cfg(desktop)] #[tauri::command] pub fn copy_png_to_clipboard(data: Vec) -> Result<(), String> { let img = image::load_from_memory(&data) @@ -748,7 +748,7 @@ pub fn copy_png_to_clipboard(data: Vec) -> Result<(), String> { Ok(()) } -#[cfg(target_os = "android")] +#[cfg(mobile)] #[tauri::command] pub fn copy_png_to_clipboard(_data: Vec) -> Result<(), String> { Err("Clipboard image copy not supported on Android".to_string()) @@ -1798,17 +1798,21 @@ pub fn ai_ask( // ── Helpers ── -static ANDROID_CONFIG_DIR: std::sync::OnceLock = std::sync::OnceLock::new(); +// On mobile (Android + iOS) the OS config dir is injected at startup via the Tauri +// path resolver, since dirs::config_dir() is not reliable in the app sandbox. +static MOBILE_CONFIG_DIR: std::sync::OnceLock = std::sync::OnceLock::new(); -pub fn set_android_config_dir(path: std::path::PathBuf) { - let _ = ANDROID_CONFIG_DIR.set(path); +pub fn set_mobile_config_dir(path: std::path::PathBuf) { + let _ = MOBILE_CONFIG_DIR.set(path); } fn app_config_path() -> Result { - let app_dir = if let Some(config_dir) = dirs::config_dir() { + // Prefer the injected mobile dir when present (set only on mobile); fall back to + // the platform config dir on desktop. + let app_dir = if let Some(mobile_dir) = MOBILE_CONFIG_DIR.get() { + mobile_dir.join("helixnotes") + } else if let Some(config_dir) = dirs::config_dir() { config_dir.join("helixnotes") - } else if let Some(android_dir) = ANDROID_CONFIG_DIR.get() { - android_dir.join("helixnotes") } else { return Err("Config directory not available yet".to_string()); }; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3db775b..6be7ad1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -11,7 +11,7 @@ use state::AppState; #[allow(unused_imports)] use tauri::{Emitter, Manager}; -#[cfg(not(target_os = "android"))] +#[cfg(desktop)] use tauri::{ image::Image, menu::{MenuBuilder, MenuItemBuilder}, @@ -20,14 +20,20 @@ use tauri::{ #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + // Work around blank window from WebKitGTK DMABUF/GBM allocation failures on some Linux GPUs. + #[cfg(target_os = "linux")] + if std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() { + std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1"); + } + rustls::crypto::ring::default_provider() .install_default() .expect("Failed to install rustls crypto provider"); let config = commands::load_app_config(); - #[cfg(not(target_os = "android"))] + #[cfg(desktop)] let show_tray = config.show_tray_icon; - #[cfg(not(target_os = "android"))] + #[cfg(desktop)] let close_to_tray = config.close_to_tray && show_tray; let app_state = AppState::new(config); @@ -44,28 +50,28 @@ pub fn run() { )?; } - // On Android, set config dir from Tauri's path resolver, then reload config - #[cfg(target_os = "android")] + // On mobile, set config dir from Tauri's path resolver, then reload config + #[cfg(mobile)] { if let Ok(config_dir) = app.path().config_dir() { - commands::set_android_config_dir(config_dir); + commands::set_mobile_config_dir(config_dir); } else if let Ok(data_dir) = app.path().data_dir() { - commands::set_android_config_dir(data_dir); + commands::set_mobile_config_dir(data_dir); } - // Reload config now that the Android config dir is available + // Reload config now that the mobile config dir is available let reloaded = commands::load_app_config(); let _ = app.state::().config.lock().map(|mut cfg| { *cfg = reloaded; }); } - #[cfg(not(target_os = "android"))] + #[cfg(desktop)] if show_tray { setup_tray(app)?; } // Check CLI args for a .md file path on initial launch - #[cfg(not(target_os = "android"))] + #[cfg(desktop)] { let file_arg = std::env::args().skip(1).find(|a| { let a = a.trim(); @@ -229,7 +235,7 @@ pub fn run() { }); }); - #[cfg(not(target_os = "android"))] + #[cfg(desktop)] { builder = builder.plugin(tauri_plugin_single_instance::init(|app, args, cwd| { // Always show/focus the main window @@ -290,7 +296,7 @@ pub fn run() { .expect("error while running tauri application"); } -#[cfg(not(target_os = "android"))] +#[cfg(desktop)] fn setup_tray(app: &mut tauri::App) -> Result<(), Box> { let show = MenuItemBuilder::with_id("show", "Show HelixNotes").build(app)?; let quit = MenuItemBuilder::with_id("quit", "Quit").build(app)?; diff --git a/src-tauri/src/search/mod.rs b/src-tauri/src/search/mod.rs index 4454fa3..1bc2a70 100644 --- a/src-tauri/src/search/mod.rs +++ b/src-tauri/src/search/mod.rs @@ -5,9 +5,9 @@ use std::fs; use std::path::Path; use std::sync::Mutex; use tantivy::collector::TopDocs; -#[cfg(not(target_os = "android"))] +#[cfg(desktop)] use tantivy::directory::MmapDirectory; -#[cfg(target_os = "android")] +#[cfg(mobile)] use tantivy::directory::RamDirectory; use tantivy::query::{BooleanQuery, FuzzyTermQuery, Occur, PhrasePrefixQuery, Query}; use tantivy::schema::*; @@ -34,14 +34,14 @@ impl SearchIndex { let tags_field = schema_builder.add_text_field("tags", TEXT | STORED); let schema = schema_builder.build(); - // Android: use in-memory index (flock doesn't work on /storage/emulated FUSE filesystem) + // Mobile: use in-memory index (flock is unreliable on the sandboxed/FUSE filesystem) // Desktop: use mmap directory for persistent index on disk - #[cfg(target_os = "android")] + #[cfg(mobile)] let index = { let dir = RamDirectory::create(); Index::open_or_create(dir, schema.clone()).map_err(|e| e.to_string())? }; - #[cfg(not(target_os = "android"))] + #[cfg(desktop)] let index = { let index_dir = helixnotes_dir(vault_path).join("search_index"); fs::create_dir_all(&index_dir).map_err(|e| e.to_string())?; @@ -49,9 +49,9 @@ impl SearchIndex { Index::open_or_create(dir, schema.clone()).map_err(|e| e.to_string())? }; - #[cfg(target_os = "android")] + #[cfg(mobile)] let heap_size = 15_000_000; - #[cfg(not(target_os = "android"))] + #[cfg(desktop)] let heap_size = 50_000_000; let writer = index.writer(heap_size).map_err(|e| e.to_string())?; diff --git a/src-tauri/src/vault/operations.rs b/src-tauri/src/vault/operations.rs index 7513116..9a0156e 100644 --- a/src-tauri/src/vault/operations.rs +++ b/src-tauri/src/vault/operations.rs @@ -194,8 +194,8 @@ pub fn scan_notes(vault_path: &str, notebook_path: Option<&str>) -> Result) -> Result = if notebook_path.is_some() { fs::read_dir(root) @@ -279,7 +279,7 @@ fn read_note_entry(path: &Path, vault_root: &Path) -> Result /// Android-only: reads just the frontmatter (first 2KB) for tags/title/pinned, /// uses filesystem timestamps for dates. No preview text. -#[cfg(target_os = "android")] +#[cfg(mobile)] fn read_note_entry_metadata_only(path: &Path, vault_root: &Path) -> Result { // Read first 2KB - enough for frontmatter with tags, title, pinned let mut file = fs::File::open(path).map_err(|e| e.to_string())?; diff --git a/src-tauri/tauri.ios.conf.json b/src-tauri/tauri.ios.conf.json new file mode 100644 index 0000000..7440caa --- /dev/null +++ b/src-tauri/tauri.ios.conf.json @@ -0,0 +1,8 @@ +{ + "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", + "bundle": { + "iOS": { + "minimumSystemVersion": "14.0" + } + } +} diff --git a/src-tauri/tauri.windows.conf.json b/src-tauri/tauri.windows.conf.json new file mode 100644 index 0000000..5d87657 --- /dev/null +++ b/src-tauri/tauri.windows.conf.json @@ -0,0 +1,18 @@ +{ + "app": { + "windows": [ + { + "title": "HelixNotes", + "label": "main", + "width": 1200, + "height": 800, + "minWidth": 800, + "minHeight": 500, + "resizable": true, + "fullscreen": false, + "decorations": false, + "dragDropEnabled": false + } + ] + } +} diff --git a/src/app.css b/src/app.css index d938f01..20bd6e5 100644 --- a/src/app.css +++ b/src/app.css @@ -5,6 +5,37 @@ src: local("Inter"); } +/* JetBrains Mono - self-hosted (see static/fonts/jetbrains-mono/, OFL-1.1). + Used for code blocks, inline code, the raw source editor, and the "Mono" font option. */ +@font-face { + font-family: "JetBrains Mono"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url("/fonts/jetbrains-mono/JetBrainsMono-Regular.woff2") format("woff2"); +} +@font-face { + font-family: "JetBrains Mono"; + font-style: italic; + font-weight: 400; + font-display: swap; + src: url("/fonts/jetbrains-mono/JetBrainsMono-Italic.woff2") format("woff2"); +} +@font-face { + font-family: "JetBrains Mono"; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url("/fonts/jetbrains-mono/JetBrainsMono-Bold.woff2") format("woff2"); +} +@font-face { + font-family: "JetBrains Mono"; + font-style: italic; + font-weight: 700; + font-display: swap; + src: url("/fonts/jetbrains-mono/JetBrainsMono-BoldItalic.woff2") format("woff2"); +} + :root { /* Light theme */ --bg-primary: #ffffff; @@ -32,6 +63,9 @@ --sidebar-width: 220px; --notelist-width: 280px; --panel-resize-handle: 3px; + /* Monospace / code font (self-hosted JetBrains Mono, see @font-face above) */ + --font-mono: "JetBrains Mono", "Fira Code", "Cascadia Code", ui-monospace, + monospace; } :root.dark { @@ -155,6 +189,15 @@ body.resizing .ProseMirror { contain: strict; } +/* Skip layout/paint of off-screen blocks in very large (math-heavy) notes. */ +.tiptap-wrapper.large-doc .ProseMirror > * { + content-visibility: auto; + contain-intrinsic-size: auto 2rem; +} +.tiptap-wrapper.large-doc .ProseMirror > .math-block { + contain-intrinsic-size: auto 3.5rem; +} + body.resizing { user-select: none; } diff --git a/src/lib/components/AppLayout.svelte b/src/lib/components/AppLayout.svelte index 26cb226..0ba938e 100644 --- a/src/lib/components/AppLayout.svelte +++ b/src/lib/components/AppLayout.svelte @@ -45,7 +45,7 @@ const appWindow = getCurrentWindow(); const isMac = navigator.platform.startsWith('Mac'); - const isMobile = /android|ios/i.test(navigator.userAgent); + const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent); const isAndroid = /android/i.test(navigator.userAgent); import { loadVaultState, saveVaultState, readNote, createDailyNote, createBackup, getPendingOpenFile, addQuickAccess, removeQuickAccess, getQuickAccess, setTheme } from '$lib/api'; import { debounce } from '$lib/utils/debounce'; diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index c352404..3d6e8c0 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -46,7 +46,7 @@ import GraphView from './GraphView.svelte'; const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl'; - const isMobile = /android|ios/i.test(navigator.userAgent); + const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent); // Track virtual keyboard height on mobile via visualViewport let keyboardHeight = $state(0); @@ -59,6 +59,8 @@ let editorElement = $state(null!); let sourceElement = $state(null!); + const LARGE_DOC_CHARS = 100_000; + let isLargeDoc = $state(false); let editor: Editor | null = null; let editorReady = $state(false); let sourceContent = $state(''); @@ -245,6 +247,38 @@ editor.chain().focus().insertContent({ type: nodeType, attrs: { tex: trimmed } }).run(); } } + const katexCache = new Map(); + function renderKatex(tex: string, displayMode: boolean): string { + const key = (displayMode ? 'B:' : 'I:') + tex; + let html = katexCache.get(key); + if (html === undefined) { + try { + html = katex.renderToString(tex, { displayMode, throwOnError: false }); + } catch { + html = `${tex}`; + } + katexCache.set(key, html); + } + return html; + } + + let mathObserver: IntersectionObserver | null = null; + const mathPending = new WeakMap void>(); + function observeMath(dom: HTMLElement, render: () => void) { + if (!mathObserver) { + const root = (editorElement?.closest('.editor-body') as Element) ?? null; + mathObserver = new IntersectionObserver((entries) => { + for (const e of entries) { + if (!e.isIntersecting) continue; + const fn = mathPending.get(e.target); + if (fn) { mathPending.delete(e.target); mathObserver!.unobserve(e.target); fn(); } + } + }, { root, rootMargin: '1000px 0px' }); + } + mathPending.set(dom, render); + mathObserver.observe(dom); + } + function renderMathPreview(tex: string, displayMode: boolean): string { if (!tex.trim()) return ''; try { @@ -453,7 +487,7 @@ }, renderHTML({ HTMLAttributes }) { const tex = HTMLAttributes.tex || ''; - const rendered = katex.renderToString(tex, { displayMode: true, throwOnError: false }); + const rendered = renderKatex(tex, true); return ['div', { 'data-math-block': encodeURIComponent(tex), class: 'math-block', contenteditable: 'false' }, ['div', { innerHTML: rendered }]]; }, addNodeView() { @@ -462,14 +496,15 @@ dom.classList.add('math-block'); dom.contentEditable = 'false'; dom.setAttribute('data-math-block', encodeURIComponent(node.attrs.tex)); - dom.innerHTML = katex.renderToString(node.attrs.tex, { displayMode: true, throwOnError: false }); + const render = () => { dom.innerHTML = renderKatex(node.attrs.tex, true); }; + if (isLargeDoc) { dom.textContent = node.attrs.tex; observeMath(dom, render); } else { render(); } dom.addEventListener('dblclick', (e) => { e.preventDefault(); e.stopPropagation(); const pos = typeof getPos === 'function' ? getPos() : null; if (pos !== null && pos !== undefined) openMathEdit(pos, 'block', node.attrs.tex); }); - return { dom }; + return { dom, destroy() { mathObserver?.unobserve(dom); mathPending.delete(dom); } }; }; }, }); @@ -490,7 +525,7 @@ }, renderHTML({ HTMLAttributes }) { const tex = HTMLAttributes.tex || ''; - const rendered = katex.renderToString(tex, { displayMode: false, throwOnError: false }); + const rendered = renderKatex(tex, false); return ['span', { 'data-math-inline': encodeURIComponent(tex), class: 'math-inline', contenteditable: 'false' }, ['span', { innerHTML: rendered }]]; }, addNodeView() { @@ -499,14 +534,15 @@ dom.classList.add('math-inline'); dom.contentEditable = 'false'; dom.setAttribute('data-math-inline', encodeURIComponent(node.attrs.tex)); - dom.innerHTML = katex.renderToString(node.attrs.tex, { displayMode: false, throwOnError: false }); + const render = () => { dom.innerHTML = renderKatex(node.attrs.tex, false); }; + if (isLargeDoc) { dom.textContent = node.attrs.tex; observeMath(dom, render); } else { render(); } dom.addEventListener('dblclick', (e) => { e.preventDefault(); e.stopPropagation(); const pos = typeof getPos === 'function' ? getPos() : null; if (pos !== null && pos !== undefined) openMathEdit(pos, 'inline', node.attrs.tex); }); - return { dom }; + return { dom, destroy() { mathObserver?.unobserve(dom); mathPending.delete(dom); } }; }; }, }); @@ -1912,6 +1948,7 @@ loadedPath = path; lastSourceMode = $sourceMode; isLoadingNote = true; + isLargeDoc = content.length > LARGE_DOC_CHARS; // Apply default view mode when switching notes - but new notes always open in edit mode. // Viewer mode (external file) always forces read-only. const isViewer = !!get(viewerNote); @@ -2633,9 +2670,7 @@ if (!mathBlock) { mathBlock = []; continue; } const tex = mathBlock.join('\n').trim(); mathBlock = null; - try { - outLines.push(`
${katex.renderToString(tex, { displayMode: true, throwOnError: false })}
`); - } catch { outLines.push('$$', tex, '$$'); } + outLines.push(`
`); continue; } if (mathBlock) { mathBlock.push(line); continue; } @@ -2645,11 +2680,9 @@ let offset = 0; for (const m of processed.matchAll(/(?${katex.renderToString(tex, { displayMode: false, throwOnError: false })}`; - result = result.slice(0, m.index! + offset) + html + result.slice(m.index! + m[0].length + offset); - offset += html.length - m[0].length; - } catch { /* leave as-is */ } + const html = ``; + result = result.slice(0, m.index! + offset) + html + result.slice(m.index! + m[0].length + offset); + offset += html.length - m[0].length; } outLines.push(result); } @@ -2769,6 +2802,8 @@ editor.destroy(); editor = null; } + mathObserver?.disconnect(); + mathObserver = null; editorReady = false; closeSlashMenu(); } @@ -2777,6 +2812,7 @@ if (!editorElement) return; destroyEditor(); + isLargeDoc = content.length > LARGE_DOC_CHARS; const html = markdownToHtml(content); editor = new Editor({ @@ -4312,7 +4348,7 @@ spellcheck="false" > -
{ closeLinkContextMenu(); handleEditorClick(e); showOutline = false; }}>
+
{ closeLinkContextMenu(); handleEditorClick(e); showOutline = false; }}>
{:else} {#if $sourceMode} @@ -4420,7 +4456,7 @@ > {:else} -
{ closeLinkContextMenu(); handleEditorClick(e); showOutline = false; }} oncontextmenu={handleEditorContextMenu}>
+
{ closeLinkContextMenu(); handleEditorClick(e); showOutline = false; }} oncontextmenu={handleEditorContextMenu}>
{/if} {/if} diff --git a/src/lib/components/GraphView.svelte b/src/lib/components/GraphView.svelte index 458bb65..bde8ad9 100644 --- a/src/lib/components/GraphView.svelte +++ b/src/lib/components/GraphView.svelte @@ -8,7 +8,7 @@ onnavigate: (path: string, title: string) => void; } = $props(); - const isMobile = /android|ios/i.test(navigator.userAgent); + const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent); let canvas = $state(null!); let loading = $state(true); diff --git a/src/lib/components/InfoPanel.svelte b/src/lib/components/InfoPanel.svelte index 40f3739..0462d72 100644 --- a/src/lib/components/InfoPanel.svelte +++ b/src/lib/components/InfoPanel.svelte @@ -6,7 +6,7 @@ import type { VaultStats } from '$lib/types'; const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl'; - const isMobile = /android|ios/i.test(navigator.userAgent); + const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent); let stats = $state(null); let activeTab = $state<'about' | 'shortcuts'>(isMobile ? 'about' : 'shortcuts'); diff --git a/src/lib/components/NoteList.svelte b/src/lib/components/NoteList.svelte index e33e066..ce84bbe 100644 --- a/src/lib/components/NoteList.svelte +++ b/src/lib/components/NoteList.svelte @@ -47,7 +47,7 @@ } = $props(); const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl'; - const isMobile = /android|ios/i.test(navigator.userAgent); + const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent); const isAndroid = /android/i.test(navigator.userAgent); let multiSelectMode = $state(false); let trashNotebooks = $state([]); diff --git a/src/lib/components/SearchPanel.svelte b/src/lib/components/SearchPanel.svelte index b16ac8f..0e00f77 100644 --- a/src/lib/components/SearchPanel.svelte +++ b/src/lib/components/SearchPanel.svelte @@ -83,7 +83,7 @@ return str.replace(/&/g, '&').replace(//g, '>'); } - const isMobile = /android|ios/i.test(navigator.userAgent); + const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent); async function openResult(result: SearchResult) { try { diff --git a/src/lib/components/SettingsPanel.svelte b/src/lib/components/SettingsPanel.svelte index f1c1eb0..47f8460 100644 --- a/src/lib/components/SettingsPanel.svelte +++ b/src/lib/components/SettingsPanel.svelte @@ -7,7 +7,7 @@ import { openUrl } from '$lib/api'; import type { ImportResult, BackupEntry } from '$lib/types'; - const isMobile = /android|ios/i.test(navigator.userAgent); + const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent); type Tab = 'general' | 'editor' | 'styling' | 'import' | 'backup' | 'ai' | 'updates'; let activeTab = $state('styling'); @@ -301,7 +301,7 @@ ]; const fontFamilyPresets = [ - { name: 'System', value: 'system', stack: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif' }, + { name: 'System', value: 'system', stack: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif' }, { name: 'Inter', value: 'inter', stack: '"Inter", -apple-system, BlinkMacSystemFont, sans-serif' }, { name: 'Georgia', value: 'georgia', stack: 'Georgia, "Times New Roman", serif' }, { name: 'Merriweather', value: 'merriweather', stack: '"Merriweather", Georgia, serif' }, diff --git a/src/lib/components/Sidebar.svelte b/src/lib/components/Sidebar.svelte index 2b8d263..368894f 100644 --- a/src/lib/components/Sidebar.svelte +++ b/src/lib/components/Sidebar.svelte @@ -31,7 +31,7 @@ } = $props(); const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl'; - const isMobile = /android|ios/i.test(navigator.userAgent); + const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent); let editingNotebook = $state(null); let editValue = $state(''); diff --git a/src/lib/components/VaultPicker.svelte b/src/lib/components/VaultPicker.svelte index 312df61..f65bca3 100644 --- a/src/lib/components/VaultPicker.svelte +++ b/src/lib/components/VaultPicker.svelte @@ -1,20 +1,22 @@ { if (!isMobile) e.preventDefault(); }} /> diff --git a/static/fonts/jetbrains-mono/JetBrainsMono-Bold.woff2 b/static/fonts/jetbrains-mono/JetBrainsMono-Bold.woff2 new file mode 100644 index 0000000..3a4e333 Binary files /dev/null and b/static/fonts/jetbrains-mono/JetBrainsMono-Bold.woff2 differ diff --git a/static/fonts/jetbrains-mono/JetBrainsMono-BoldItalic.woff2 b/static/fonts/jetbrains-mono/JetBrainsMono-BoldItalic.woff2 new file mode 100644 index 0000000..69e029c Binary files /dev/null and b/static/fonts/jetbrains-mono/JetBrainsMono-BoldItalic.woff2 differ diff --git a/static/fonts/jetbrains-mono/JetBrainsMono-Italic.woff2 b/static/fonts/jetbrains-mono/JetBrainsMono-Italic.woff2 new file mode 100644 index 0000000..3d3c5d7 Binary files /dev/null and b/static/fonts/jetbrains-mono/JetBrainsMono-Italic.woff2 differ diff --git a/static/fonts/jetbrains-mono/JetBrainsMono-Regular.woff2 b/static/fonts/jetbrains-mono/JetBrainsMono-Regular.woff2 new file mode 100644 index 0000000..5858873 Binary files /dev/null and b/static/fonts/jetbrains-mono/JetBrainsMono-Regular.woff2 differ diff --git a/static/fonts/jetbrains-mono/OFL.txt b/static/fonts/jetbrains-mono/OFL.txt new file mode 100644 index 0000000..5ceee00 --- /dev/null +++ b/static/fonts/jetbrains-mono/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE.