Cross-platform groundwork: platform detection, iOS config, Windows drag-drop fix, Android 10 storage fix (#72), bundled fonts

This commit is contained in:
Yuri Karamian
2026-06-03 14:11:10 +02:00
parent fedbdfd2a1
commit 7cbd112585
67 changed files with 1294 additions and 77 deletions
+9 -1
View File
@@ -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
+1 -1
View File
@@ -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"
+12
View File
@@ -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
+19
View File
@@ -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
+6
View File
@@ -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
@@ -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")
+22
View File
@@ -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.**
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<!-- AndroidTV support -->
<uses-feature android:name="android.software.leanback" android:required="false" />
<application
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:requestLegacyExternalStorage="true"
android:theme="@style/Theme.helixnotes"
android:usesCleartextTraffic="${usesCleartextTraffic}">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
android:launchMode="singleTask"
android:label="@string/main_activity_title"
android:name=".MainActivity"
android:windowSoftInputMode="adjustResize"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<!-- AndroidTV support -->
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -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<out String>,
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)
}
}
}
@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#5B6ABF"
android:pathData="M0,0h108v108h-108z" />
</vector>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello World!"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

@@ -0,0 +1,8 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme (dark mode). -->
<style name="Theme.helixnotes" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<item name="android:windowBackground">#1a1b26</item>
<item name="android:navigationBarColor">#1a1b26</item>
<item name="android:statusBarColor">#1a1b26</item>
</style>
</resources>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>
@@ -0,0 +1,4 @@
<resources>
<string name="app_name">HelixNotes</string>
<string name="main_activity_title">HelixNotes</string>
</resources>
@@ -0,0 +1,9 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme (light mode). -->
<style name="Theme.helixnotes" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<item name="android:windowBackground">#ffffff</item>
<item name="android:navigationBarColor">#ffffff</item>
<item name="android:statusBarColor">#ffffff</item>
<item name="android:windowLightStatusBar">true</item>
</style>
</resources>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="my_images" path="." />
<cache-path name="my_cache_images" path="." />
</paths>
+22
View File
@@ -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")
}
@@ -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")
}
@@ -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()
}
}
@@ -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<Project> {
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<ApplicationExtension> {
@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
)
}
}
}
}
}
+24
View File
@@ -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
Binary file not shown.
@@ -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
+185
View File
@@ -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" "$@"
+89
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
include ':app'
apply from: 'tauri.settings.gradle'
+20 -16
View File
@@ -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<u8>, or error if no image on clipboard.
#[cfg(not(target_os = "android"))]
#[cfg(desktop)]
#[tauri::command]
pub fn read_clipboard_image() -> Result<Vec<u8>, String> {
let mut clipboard =
@@ -695,14 +695,14 @@ pub fn read_clipboard_image() -> Result<Vec<u8>, String> {
Ok(buf)
}
#[cfg(target_os = "android")]
#[cfg(mobile)]
#[tauri::command]
pub fn read_clipboard_image() -> Result<Vec<u8>, 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<u8>) -> Result<(), String> {
let img = image::load_from_memory(&data)
@@ -748,7 +748,7 @@ pub fn copy_png_to_clipboard(data: Vec<u8>) -> Result<(), String> {
Ok(())
}
#[cfg(target_os = "android")]
#[cfg(mobile)]
#[tauri::command]
pub fn copy_png_to_clipboard(_data: Vec<u8>) -> 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::path::PathBuf> = 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::path::PathBuf> = 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<std::path::PathBuf, String> {
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());
};
+18 -12
View File
@@ -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::<AppState>().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<dyn std::error::Error>> {
let show = MenuItemBuilder::with_id("show", "Show HelixNotes").build(app)?;
let quit = MenuItemBuilder::with_id("quit", "Quit").build(app)?;
+7 -7
View File
@@ -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())?;
+5 -5
View File
@@ -194,8 +194,8 @@ pub fn scan_notes(vault_path: &str, notebook_path: Option<&str>) -> Result<Vec<N
return Err("Path does not exist".to_string());
}
// On Android, use metadata-only scan (no file reads) for fast listing on FUSE
#[cfg(target_os = "android")]
// On mobile, use metadata-only scan (no file reads) for fast listing on sandboxed FS
#[cfg(mobile)]
{
let mut notes = Vec::new();
// Log what read_dir sees
@@ -238,12 +238,12 @@ pub fn scan_notes(vault_path: &str, notebook_path: Option<&str>) -> Result<Vec<N
}
}
}
log::info!("scan_notes: Android scan found {} notes", notes.len());
log::info!("scan_notes: mobile scan found {} notes", notes.len());
notes.sort_by(|a, b| b.meta.modified.cmp(&a.meta.modified));
return Ok(notes);
}
#[cfg(not(target_os = "android"))]
#[cfg(desktop)]
{
let md_files: Vec<PathBuf> = if notebook_path.is_some() {
fs::read_dir(root)
@@ -279,7 +279,7 @@ fn read_note_entry(path: &Path, vault_root: &Path) -> Result<NoteEntry, String>
/// 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<NoteEntry, String> {
// Read first 2KB - enough for frontmatter with tags, title, pinned
let mut file = fs::File::open(path).map_err(|e| e.to_string())?;
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"bundle": {
"iOS": {
"minimumSystemVersion": "14.0"
}
}
}
+18
View File
@@ -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
}
]
}
}
+43
View File
@@ -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;
}
+1 -1
View File
@@ -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';
+51 -15
View File
@@ -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<HTMLDivElement>(null!);
let sourceElement = $state<HTMLTextAreaElement>(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<string, string>();
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 = `<span class="katex-error">${tex}</span>`;
}
katexCache.set(key, html);
}
return html;
}
let mathObserver: IntersectionObserver | null = null;
const mathPending = new WeakMap<Element, () => 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(`<div data-math-block="${encodeURIComponent(tex)}" class="math-block">${katex.renderToString(tex, { displayMode: true, throwOnError: false })}</div>`);
} catch { outLines.push('$$', tex, '$$'); }
outLines.push(`<div data-math-block="${encodeURIComponent(tex)}" class="math-block"></div>`);
continue;
}
if (mathBlock) { mathBlock.push(line); continue; }
@@ -2645,11 +2680,9 @@
let offset = 0;
for (const m of processed.matchAll(/(?<!\$)\$(?!\$)([^\n$]+?)(?<!\$)\$(?!\$)/g)) {
const tex = m[1].trim();
try {
const html = `<span data-math-inline="${encodeURIComponent(tex)}" class="math-inline">${katex.renderToString(tex, { displayMode: false, throwOnError: false })}</span>`;
const html = `<span data-math-inline="${encodeURIComponent(tex)}" class="math-inline"></span>`;
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 */ }
}
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"
></textarea>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="tiptap-wrapper" style={$sourceMode ? 'display:none' : ''} spellcheck="false" bind:this={editorElement} onclick={(e) => { closeLinkContextMenu(); handleEditorClick(e); showOutline = false; }}></div>
<div class="tiptap-wrapper" class:large-doc={isLargeDoc} style={$sourceMode ? 'display:none' : ''} spellcheck="false" bind:this={editorElement} onclick={(e) => { closeLinkContextMenu(); handleEditorClick(e); showOutline = false; }}></div>
{:else}
<!-- Desktop: conditional rendering with line numbers -->
{#if $sourceMode}
@@ -4420,7 +4456,7 @@
></textarea>
{:else}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="tiptap-wrapper" spellcheck="false" bind:this={editorElement} onclick={(e) => { closeLinkContextMenu(); handleEditorClick(e); showOutline = false; }} oncontextmenu={handleEditorContextMenu}></div>
<div class="tiptap-wrapper" class:large-doc={isLargeDoc} spellcheck="false" bind:this={editorElement} onclick={(e) => { closeLinkContextMenu(); handleEditorClick(e); showOutline = false; }} oncontextmenu={handleEditorContextMenu}></div>
{/if}
{/if}
</div>
+1 -1
View File
@@ -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<HTMLCanvasElement>(null!);
let loading = $state(true);
+1 -1
View File
@@ -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<VaultStats | null>(null);
let activeTab = $state<'about' | 'shortcuts'>(isMobile ? 'about' : 'shortcuts');
+1 -1
View File
@@ -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<TrashNotebookEntry[]>([]);
+1 -1
View File
@@ -83,7 +83,7 @@
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
const isMobile = /android|ios/i.test(navigator.userAgent);
const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
async function openResult(result: SearchResult) {
try {
+2 -2
View File
@@ -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<Tab>('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' },
+1 -1
View File
@@ -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<string | null>(null);
let editValue = $state('');
+32 -8
View File
@@ -1,20 +1,22 @@
<script lang="ts">
import { open } from '@tauri-apps/plugin-dialog';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { documentDir } from '@tauri-apps/api/path';
import { openVault, getAppConfig } from '$lib/api';
import { appConfig, vaultReady } from '$lib/stores/app';
import { isAndroid, isIOS, isMobile } from '$lib/platform';
import type { VaultConfig } from '$lib/types';
const appWindow = getCurrentWindow();
const isMobile = /android|ios/i.test(navigator.userAgent);
let recentVaults: VaultConfig[] = $derived($appConfig?.vaults ?? []);
let loading = $state(false);
let error = $state('');
let vaultName = $state('HelixNotes');
let hasPermission = $state(!isMobile);
let hasPermission = $state(!isAndroid);
let selectedLocation = $state('Documents');
let customPath = $state('');
let iosBasePath = $state('');
const storageLocations = [
{ label: 'Documents', path: '/storage/emulated/0/Documents' },
@@ -22,7 +24,7 @@
{ label: 'Internal Storage', path: '/storage/emulated/0' },
];
if (isMobile) {
if (isAndroid) {
checkPermission();
setTimeout(checkPermission, 500);
(window as any).__storagePermissionGranted = () => { hasPermission = true; };
@@ -30,6 +32,10 @@
if (document.visibilityState === 'visible') setTimeout(checkPermission, 300);
});
}
if (isIOS) {
// iOS is sandboxed: the vault lives in the app's Documents dir (exposed via the Files app).
documentDir().then((d) => { iosBasePath = d.replace(/\/+$/, ''); }).catch(() => {});
}
function checkPermission() {
try {
@@ -56,10 +62,17 @@
return storageLocations.find(l => l.label === selectedLocation)?.path || '/storage/emulated/0/Documents';
}
let fullPath = $derived(isMobile ? `${getMobileBasePath()}/${vaultName.trim() || 'HelixNotes'}` : '');
let fullPath = $derived(
isIOS ? `${iosBasePath}/${vaultName.trim() || 'HelixNotes'}`
: isAndroid ? `${getMobileBasePath()}/${vaultName.trim() || 'HelixNotes'}`
: ''
);
async function pickFolder() {
if (isMobile) {
if (isIOS) {
const base = iosBasePath || (await documentDir()).replace(/\/+$/, '');
await openSelectedVault(`${base}/${vaultName.trim() || 'HelixNotes'}`);
} else if (isAndroid) {
await openSelectedVault(fullPath);
} else {
const selected = await open({ directory: true, multiple: false, title: 'Choose Notes Folder' });
@@ -73,7 +86,7 @@
loading = true;
error = '';
try {
if (isMobile) {
if (isAndroid) {
try {
const bridge = (window as any).Android;
bridge?.prepareVaultDir?.(path);
@@ -84,7 +97,12 @@
$appConfig = config;
$vaultReady = true;
} catch (e) {
error = String(e);
const msg = String(e);
if (isAndroid && /os error 13|permission denied/i.test(msg)) {
error = 'Storage permission is required. Enable file access for HelixNotes in your system settings, then try again.';
} else {
error = msg;
}
} finally {
loading = false;
}
@@ -118,15 +136,20 @@
<h1>HelixNotes</h1>
<p class="subtitle">Local markdown notes</p>
{#if isMobile}
{#if isIOS}
<p class="description">Your notes are saved in the app's folder and appear in the Files app under "On My iPhone &rarr; HelixNotes".</p>
{:else}
<p class="description">Choose where to store your notes. Sync with Syncthing, Nextcloud, or any file sync app.</p>
{/if}
{#if !hasPermission}
{#if isAndroid && !hasPermission}
<button class="btn-permission" onclick={requestPermission}>
Grant File Access
</button>
<p class="permission-hint">Required to access Documents, Downloads, and other shared folders.</p>
{/if}
{#if isAndroid}
<div class="location-selector">
<label class="location-label">Location</label>
<div class="location-options">
@@ -147,6 +170,7 @@
<input class="custom-path-input" type="text" bind:value={customPath} placeholder="/storage/emulated/0/..." />
{/if}
</div>
{/if}
<div class="vault-name-input">
<label for="vault-name">Vault name</label>
+7
View File
@@ -0,0 +1,7 @@
// Centralized platform detection. iOS WebViews report iPhone/iPad/iPod in the user
// agent (not the literal "ios"), so they must be matched explicitly.
const ua = typeof navigator !== 'undefined' ? navigator.userAgent : '';
export const isAndroid = /android/i.test(ua);
export const isIOS = /iphone|ipad|ipod/i.test(ua);
export const isMobile = isAndroid || isIOS;
+20 -1
View File
@@ -7,7 +7,7 @@
let { children } = $props();
const isMobile = /android|ios/i.test(navigator.userAgent);
const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
// Reactively apply theme class to <html> whenever $theme changes
$effect(() => {
@@ -160,6 +160,25 @@
}
};
});
// On Windows the native OS drag-drop handler is disabled (dragDropEnabled:false
// in tauri.windows.conf.json) so HTML5 drag-and-drop works for reordering. With it
// off, a file dropped outside a drop zone would make the webview navigate to / open
// that file, replacing the app. Swallow any drag that bubbles up unhandled. Real drop
// zones (editor, sidebar reordering) call preventDefault in their own handlers first;
// these bubble-phase listeners only act as a fallback. On macOS/Linux OS file drops are
// intercepted natively and never surface as HTML5 events, so this is a harmless no-op there.
onMount(() => {
function preventNavigate(e: DragEvent) {
e.preventDefault();
}
window.addEventListener('dragover', preventNavigate);
window.addEventListener('drop', preventNavigate);
return () => {
window.removeEventListener('dragover', preventNavigate);
window.removeEventListener('drop', preventNavigate);
};
});
</script>
<svelte:document oncontextmenu={(e) => { if (!isMobile) e.preventDefault(); }} />
Binary file not shown.
+93
View File
@@ -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.