feat(dictionary): add system dictionary provider for macOS, iOS, and Android (#4219)

Hand selected words off to the platform's native dictionary surface
when the user opts into the new "System Dictionary" entry under
Settings → Languages → Dictionaries. The setting is exclusive: enabling
it disables all other providers (and vice versa) so the in-app lookup
button either always opens the popup or always invokes the OS — no
mixed states.

Per platform:
- macOS: AppKit's -[NSView showDefinitionForAttributedString:atPoint:]
  via a top-level Tauri command in src-tauri/src/macos/system_dictionary.rs.
  Anchored at the selection's bottom-center (CSS pixels mapped into
  NSView coords), so the inline Lookup HUD appears just below the
  highlighted text without raising Dictionary.app to the foreground.
- iOS: UIReferenceLibraryViewController presented as a half-detent
  pageSheet on iPhone (medium → large drag-to-expand) and as a
  formSheet on iPad. Implemented in the native-bridge plugin.
- Android: ACTION_PROCESS_TEXT intent with EXTRA_PROCESS_TEXT_READONLY,
  dispatched without createChooser so users get the standard system
  disambiguation dialog with "Just once / Always" buttons. Reports
  unavailable=true when no app handles the intent so the TS layer can
  silently skip rather than open an empty chooser.

Web/Linux/Windows hide the row entirely. The provider is a sentinel —
the registry filters it out of the popup tab list (it has no in-popup
UI) and the annotator's handleDictionary checks isSystemDictionaryEnabled
to dispatch directly to the native bridge before opening the in-app
DictionaryPopup.
This commit is contained in:
loveheaven
2026-05-19 13:03:52 +08:00
committed by GitHub
parent d35d2002c4
commit 05da6bdf43
23 changed files with 1074 additions and 10 deletions
@@ -85,6 +85,11 @@ class OpenExternalUrlArgs {
var url: String? = null
}
@InvokeArg
class ShowLookupPopoverArgs {
var word: String? = null
}
@InvokeArg
class FetchProductsRequestArgs {
val productIds: List<String>? = null
@@ -876,6 +881,77 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
}
invoke.resolve(ret)
}
/**
* Hand a selected word off to whatever dictionary / lookup app the
* user has installed, via the standard `ACTION_PROCESS_TEXT`
* intent (Android 6.0+). This is the same dispatch the system
* "selection toolbar" uses for "Translate" / "Define" actions, so
* any third-party dictionary that registers the intent (ColorDict,
* GoldenDict, 欧路, Pleco, etc.) shows up without extra work on
* our side.
*
* Important: we deliberately do NOT wrap the intent with
* `Intent.createChooser`. Chooser-style dialogs always re-prompt
* (no "Always use this app" affordance), which the user found
* annoying when they have a single preferred dictionary. Plain
* `startActivity(intent)` instead surfaces the standard system
* disambiguation dialog with the "Just once / Always" buttons —
* picking "Always" makes subsequent lookups go straight to that
* app. When only one app handles the intent, Android skips the
* picker entirely and launches it directly.
*
* If no app is installed that responds to the intent, returns
* `unavailable: true` instead of throwing — the TS layer surfaces
* a hint rather than a generic error in that case.
*/
@Command
fun show_lookup_popover(invoke: Invoke) {
val args = invoke.parseArgs(ShowLookupPopoverArgs::class.java)
val word = args.word?.trim().orEmpty()
if (word.isEmpty()) {
return invoke.reject("empty word")
}
try {
val intent = Intent(Intent.ACTION_PROCESS_TEXT).apply {
type = "text/plain"
putExtra(Intent.EXTRA_PROCESS_TEXT, word)
// Read-only — we don't want third-party apps writing
// back into a clipboard or selection slot we don't own.
putExtra(Intent.EXTRA_PROCESS_TEXT_READONLY, true)
}
// Probe for handlers before dispatching. An ActivityNotFound
// crash is a worse UX than a quiet "no dictionary app"
// result; surface the empty case explicitly.
val pm = activity.packageManager
val handlers = pm.queryIntentActivities(intent, 0)
if (handlers.isEmpty()) {
val ret = JSObject()
ret.put("success", false)
ret.put("unavailable", true)
return invoke.resolve(ret)
}
// FLAG_ACTIVITY_NEW_TASK is required because `activity`
// here is the plugin's host activity context — without it,
// some OEM ROMs reject the dispatch with "Calling
// startActivity() from outside of an Activity context".
// The system disambiguation dialog still appears (with the
// Always/Just once buttons) for multi-handler cases; for
// single-handler cases it goes straight through.
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
activity.startActivity(intent)
val ret = JSObject()
ret.put("success", true)
invoke.resolve(ret)
} catch (e: Exception) {
Log.e("NativeBridgePlugin", "show_lookup_popover failed", e)
invoke.reject("Failed to look up word: ${e.message}")
}
}
}
@app.tauri.annotation.InvokeArg