Skip to content

Commit 8e5c73f

Browse files
authored
feat: app picker bundle ID lookup (#3653)
* feat: app picker bundle ID lookup * fix: prevent some but not all bad entries * fix: filter out remaining invalid entries
1 parent 66bf00b commit 8e5c73f

5 files changed

Lines changed: 217 additions & 7 deletions

File tree

harper-desktop/src-tauri/src/commands.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
44
use crate::config::Config;
55
use crate::highlighter_service::HighlighterService;
6-
use crate::os_broker::{AccessibilityPermissionStatus, OsBroker};
6+
use crate::os_broker::{AccessibilityPermissionStatus, AppSearchResult, OsBroker};
77
use crate::{IntegrationView, accessibility_allows_highlighter_start, platform_broker};
88
use harper_core::{
99
Dialect, DictWordMetadata, IgnoredLints,
@@ -40,6 +40,7 @@ pub fn application_message_handler<R: Runtime>() -> impl Fn(Invoke<R>) -> bool {
4040
start_highlighter_service,
4141
stop_highlighter_service,
4242
launch_app,
43+
search_apps,
4344
]
4445
}
4546

@@ -340,3 +341,8 @@ pub(crate) async fn stop_highlighter_service(
340341
fn launch_app(bundle_id: String) -> Result<(), String> {
341342
platform_broker().launch_app_bundle(&bundle_id)
342343
}
344+
345+
#[tauri::command]
346+
fn search_apps(query: String) -> Result<Vec<AppSearchResult>, String> {
347+
platform_broker().search_apps(&query)
348+
}

harper-desktop/src-tauri/src/mac_broker.rs

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ use std::{
3838
};
3939

4040
use crate::config::{Config, Integration};
41-
use crate::os_broker::{AccessibilityPermissionStatus, OsBroker};
41+
use crate::os_broker::{AccessibilityPermissionStatus, AppSearchResult, OsBroker};
4242
use crate::rect::{ActionableLint, Rect};
4343

4444
const WINDOW_MOVEMENT_SETTLE_DURATION: Duration = Duration::from_millis(150);
@@ -417,6 +417,103 @@ impl OsBroker for MacBroker {
417417

418418
Ok(())
419419
}
420+
421+
fn search_apps(&self, query: &str) -> Result<Vec<AppSearchResult>, String> {
422+
let query = query.trim();
423+
424+
if query.is_empty() {
425+
return Ok(Vec::new());
426+
}
427+
428+
// Search for apps by display name using mdfind
429+
// Use case-insensitive search and ensure we only get .app bundles
430+
let escaped_query = query.replace('\\', "\\\\").replace('"', "\\\"");
431+
let output = Command::new("mdfind")
432+
.arg(format!(
433+
"kMDItemContentType == 'com.apple.application-bundle' && kMDItemDisplayName == '*{escaped_query}*'cd"
434+
))
435+
.output()
436+
.map_err(|error| format!("Failed to execute mdfind: {error}"))?;
437+
438+
if !output.status.success() {
439+
return Err(format!(
440+
"mdfind failed: {}",
441+
String::from_utf8_lossy(&output.stderr)
442+
));
443+
}
444+
445+
let mut results = Vec::new();
446+
let mut seen_bundle_ids = std::collections::HashSet::new();
447+
448+
for line in String::from_utf8_lossy(&output.stdout).lines() {
449+
let line = line.trim();
450+
451+
// Skip if not an .app bundle
452+
if !line.ends_with(".app") {
453+
continue;
454+
}
455+
456+
if let Some(display_name) = display_name_from_app_path(line) {
457+
// Skip if display name looks like a bundle ID (contains dots)
458+
if display_name.contains('.') {
459+
continue;
460+
}
461+
462+
if let Some(bundle_id) = bundle_id_from_app_path(line) {
463+
// Skip if we've already seen this bundle ID (deduplication)
464+
if seen_bundle_ids.contains(&bundle_id) {
465+
continue;
466+
}
467+
468+
// Skip if bundle ID looks like a path or is invalid
469+
if bundle_id.contains('/') || bundle_id.is_empty() {
470+
continue;
471+
}
472+
473+
seen_bundle_ids.insert(bundle_id.clone());
474+
results.push(AppSearchResult {
475+
name: display_name,
476+
bundle_id,
477+
});
478+
}
479+
}
480+
}
481+
482+
Ok(results)
483+
}
484+
}
485+
486+
fn bundle_id_from_app_path(path: &str) -> Option<String> {
487+
let output = Command::new("mdls")
488+
.arg("-name")
489+
.arg("kMDItemCFBundleIdentifier")
490+
.arg(path)
491+
.output()
492+
.ok()?;
493+
494+
if !output.status.success() {
495+
return None;
496+
}
497+
498+
let output = String::from_utf8_lossy(&output.stdout);
499+
500+
// Parse the output from mdls which looks like:
501+
// kMDItemCFBundleIdentifier = "com.example.app"
502+
let bundle_id = output
503+
.lines()
504+
.find(|line| line.contains("kMDItemCFBundleIdentifier"))
505+
.and_then(|line| {
506+
line.split('=')
507+
.nth(1)
508+
.map(|s| s.trim().trim_matches('"').to_string())
509+
})?;
510+
511+
// Filter out null or empty bundle IDs
512+
if bundle_id.is_empty() || bundle_id == "(null)" || bundle_id == "null" {
513+
return None;
514+
}
515+
516+
Some(bundle_id)
420517
}
421518

422519
fn system_integration_display_name(bundle_id: &str) -> Option<String> {

harper-desktop/src-tauri/src/os_broker.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,16 @@ pub trait OsBroker {
4646
fn launch_app_bundle(&self, _bundle_id: &str) -> Result<(), String> {
4747
Err("Launching apps by bundle ID is only supported on macOS.".to_string())
4848
}
49+
50+
fn search_apps(&self, _query: &str) -> Result<Vec<AppSearchResult>, String> {
51+
Err("App search is only supported on macOS.".to_string())
52+
}
53+
}
54+
55+
#[derive(Debug, Clone, Serialize)]
56+
pub struct AppSearchResult {
57+
pub name: String,
58+
pub bundle_id: String,
4959
}
5060

5161
fn fallback_integration_display_name(bundle_id: &str) -> String {

harper-desktop/src/lib/settings/components/AppPickerModal.svelte

Lines changed: 73 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,58 @@
11
<script lang="ts">
2+
import { invoke } from '@tauri-apps/api/core';
3+
24
export let bundleId = '';
35
export let existingBundleIds: string[];
46
export let isSaving = false;
57
export let close: () => void;
68
export let add: (bundleId: string) => void;
79
10+
let searchResults: Array<{ name: string; bundle_id: string }> = [];
11+
let isSearching = false;
12+
let debounceTimeout: number | null = null;
13+
814
$: trimmedBundleId = bundleId.trim();
915
$: isDuplicate = existingBundleIds.includes(trimmedBundleId);
1016
$: canAdd = Boolean(trimmedBundleId) && !isDuplicate && !isSaving;
1117
18+
async function performSearch(query: string) {
19+
if (!query.trim()) {
20+
searchResults = [];
21+
return;
22+
}
23+
24+
isSearching = true;
25+
try {
26+
const results = await invoke<Array<{ name: string; bundle_id: string }>>('search_apps', {
27+
query,
28+
});
29+
searchResults = results;
30+
} catch (error) {
31+
console.error('Search failed:', error);
32+
searchResults = [];
33+
} finally {
34+
isSearching = false;
35+
}
36+
}
37+
38+
function handleInput(event: Event) {
39+
const target = event.target as HTMLInputElement;
40+
bundleId = target.value;
41+
42+
if (debounceTimeout) {
43+
clearTimeout(debounceTimeout);
44+
}
45+
46+
debounceTimeout = window.setTimeout(() => {
47+
performSearch(bundleId);
48+
}, 300);
49+
}
50+
51+
function selectApp(selectedBundleId: string) {
52+
bundleId = selectedBundleId;
53+
searchResults = [];
54+
}
55+
1256
function submit() {
1357
if (canAdd) {
1458
add(trimmedBundleId);
@@ -48,9 +92,10 @@ function submit() {
4892
<span class="settings-icon icon-search" aria-hidden="true"></span>
4993
<input
5094
type="text"
51-
placeholder="com.apple.TextEdit"
52-
bind:value={bundleId}
95+
placeholder="Search for an app..."
96+
value={bundleId}
5397
disabled={isSaving}
98+
on:input={handleInput}
5499
on:keydown={(event) => {
55100
if (event.key === "Enter") {
56101
submit();
@@ -59,10 +104,33 @@ function submit() {
59104
/>
60105
</div>
61106
<div class="modal-list">
62-
{#if isDuplicate}
63-
<div class="empty">That application is already configured.</div>
107+
{#if isSearching}
108+
<div class="empty">Searching...</div>
109+
{:else if searchResults.length > 0}
110+
{#each searchResults as result}
111+
<div
112+
class="app-result"
113+
role="button"
114+
tabindex="0"
115+
on:click={() => selectApp(result.bundle_id)}
116+
on:keydown={(event) => {
117+
if (event.key === "Enter" || event.key === " ") {
118+
selectApp(result.bundle_id);
119+
}
120+
}}
121+
>
122+
<div class="app-result-name">{result.name}</div>
123+
<div class="app-result-bundle-id">{result.bundle_id}</div>
124+
</div>
125+
{/each}
126+
{:else if trimmedBundleId}
127+
{#if isDuplicate}
128+
<div class="empty">That application is already configured.</div>
129+
{:else}
130+
<div class="empty">No matching apps found. Try typing the bundle ID directly (e.g., com.apple.TextEdit)</div>
131+
{/if}
64132
{:else}
65-
<div class="empty">Use the app's macOS bundle identifier. Example: com.apple.TextEdit</div>
133+
<div class="empty">Search for an app by name, or enter the bundle ID directly.</div>
66134
{/if}
67135
</div>
68136
<div class="modal-actions">

harper-desktop/src/lib/settings/settings.css

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -919,6 +919,35 @@ em,
919919
padding: 6px;
920920
}
921921

922+
.app-result {
923+
display: flex;
924+
flex-direction: column;
925+
gap: 2px;
926+
padding: 10px 12px;
927+
border-radius: 6px;
928+
cursor: pointer;
929+
}
930+
931+
.app-result:hover {
932+
background: rgba(0, 0, 0, 0.04);
933+
}
934+
935+
.app-result:focus {
936+
outline: 2px solid var(--settings-accent);
937+
outline-offset: -2px;
938+
}
939+
940+
.app-result-name {
941+
font-weight: 600;
942+
color: var(--settings-ink);
943+
}
944+
945+
.app-result-bundle-id {
946+
font-size: 11.5px;
947+
color: var(--settings-ink-3);
948+
font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
949+
}
950+
922951
.picker-row {
923952
width: 100%;
924953
display: flex;

0 commit comments

Comments
 (0)