Skip to content

Commit 133b86d

Browse files
committed
fix: stabilize launcher behavior and local installs
- reduce idle polling in the launcher event loops and extension navigation\n- refresh app icons after async extraction and fix main-thread icon extraction\n- add stable local signing and install scripts so rebuilds keep a consistent macOS identity
1 parent e5096ba commit 133b86d

12 files changed

Lines changed: 753 additions & 155 deletions

File tree

README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,25 @@ cargo build --release
4444
cargo run
4545
```
4646

47+
## Local Install
48+
49+
```bash
50+
# Create a stable local code-signing identity once
51+
./scripts/create-dev-signing-identity.sh
52+
53+
# Build a signed-or-ad-hoc app bundle in build/PhotonCast.app
54+
./scripts/release-build.sh
55+
56+
# Replace the installed app in /Applications and relaunch it
57+
./scripts/install-app.sh
58+
```
59+
60+
If PhotonCast is only ad-hoc signed, macOS tracks it by `cdhash`, so Accessibility
61+
and Calendar permissions may need to be re-granted after rebuilds. A stable signing
62+
identity avoids that churn. `create-dev-signing-identity.sh` provisions a local
63+
`PhotonCast Local Dev` identity, and `release-build.sh` / `sign.sh` automatically
64+
use it when `~/.config/photoncast/dev-signing.env` is present.
65+
4766
## Project Structure
4867

4968
```

crates/photoncast/src/extension_views/navigation.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -312,7 +312,9 @@ impl NavigationContainer {
312312
/// Starts polling for external updates from ViewHandle.
313313
fn start_update_polling(&self, cx: &mut ViewContext<Self>) {
314314
cx.spawn(|this, mut cx| async move {
315-
const POLL_INTERVAL_MS: u64 = 16; // ~60 FPS
315+
// Extension updates are not animation frames; keep this low-frequency
316+
// while the actual transition animation uses its own 16ms timer.
317+
const POLL_INTERVAL_MS: u64 = 100;
316318

317319
loop {
318320
cx.background_executor()

crates/photoncast/src/icon_cache.rs

Lines changed: 26 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ pub fn clear_icon(app_path: &Path) {
129129
/// Extracts an app icon to the cache path.
130130
///
131131
/// Spawns a synchronous `sips` process to convert `.icns` to `.png`.
132-
/// Must be called from a background thread (see [`get_icon_static`]).
132+
/// This is used as a fallback when the richer `NSWorkspace` path fails.
133133
pub fn extract_icon(app_path: &Path, cache_path: &Path) -> Option<PathBuf> {
134134
// Try to find the icon in the app bundle
135135
let icns_path = app_path.join("Contents/Resources/AppIcon.icns");
@@ -199,15 +199,20 @@ pub fn extract_icon(app_path: &Path, cache_path: &Path) -> Option<PathBuf> {
199199
None
200200
}
201201

202-
/// Static version of [`get_icon`] for use in async context.
203-
///
204-
/// # Threading Model
205-
///
206-
/// This function performs synchronous I/O (filesystem checks and `sips` process
207-
/// spawning). It must only be called from a background thread — never from the
208-
/// main/UI thread. All current call sites dispatch through
209-
/// `cx.background_executor().spawn()` which satisfies this requirement.
210-
pub fn get_icon_static(app_path: &Path) -> Option<PathBuf> {
202+
fn extract_best_available_icon(app_path: &Path, cache_path: &Path) -> Option<PathBuf> {
203+
if crate::platform::save_app_icon_as_png(app_path, cache_path, 64) {
204+
tracing::debug!(
205+
"Extracted icon for {} -> {}",
206+
app_path.display(),
207+
cache_path.display()
208+
);
209+
Some(cache_path.to_path_buf())
210+
} else {
211+
extract_icon(app_path, cache_path)
212+
}
213+
}
214+
215+
fn get_or_extract_icon(app_path: &Path) -> Option<PathBuf> {
211216
let cache_dir = cache_dir();
212217

213218
// Ensure cache directory exists
@@ -236,52 +241,20 @@ pub fn get_icon_static(app_path: &Path) -> Option<PathBuf> {
236241
}
237242
}
238243

239-
// Extract icon using platform-specific code
240-
extract_icon(app_path, &cached_path)
244+
extract_best_available_icon(app_path, &cached_path)
245+
}
246+
247+
/// Static version of [`get_icon`] for use in async context.
248+
///
249+
/// Safe to call from a background thread. AppKit icon extraction is marshalled
250+
/// onto the main queue internally, with `.icns` conversion as a fallback.
251+
pub fn get_icon_static(app_path: &Path) -> Option<PathBuf> {
252+
get_or_extract_icon(app_path)
241253
}
242254

243255
/// Gets or extracts the icon for an app bundle as PNG.
244256
///
245-
/// Uses `NSWorkspace` to handle all icon formats including asset catalogs.
257+
/// Safe to call from any thread.
246258
pub fn get_icon(app_path: &Path) -> Option<PathBuf> {
247-
let cache_dir = cache_dir();
248-
249-
// Ensure cache directory exists
250-
if let Err(e) = std::fs::create_dir_all(&cache_dir) {
251-
tracing::warn!("Failed to create icon cache dir: {}", e);
252-
return None;
253-
}
254-
255-
let cached_path = cached_icon_filename(app_path);
256-
257-
// Return cached icon if it exists and is fresh
258-
if cached_path.exists() {
259-
// Check if app is newer than cached icon
260-
let app_modified = std::fs::metadata(app_path)
261-
.ok()
262-
.and_then(|m| m.modified().ok());
263-
let cached_modified = std::fs::metadata(&cached_path)
264-
.ok()
265-
.and_then(|m| m.modified().ok());
266-
267-
match (app_modified, cached_modified) {
268-
(Some(app_time), Some(cache_time)) if cache_time >= app_time => {
269-
return Some(cached_path);
270-
},
271-
_ => {}, // Re-extract if we can't determine freshness
272-
}
273-
}
274-
275-
// Extract icon using NSWorkspace (handles all icon formats)
276-
if crate::platform::save_app_icon_as_png(app_path, &cached_path, 64) {
277-
tracing::debug!(
278-
"Extracted icon for {} -> {}",
279-
app_path.display(),
280-
cached_path.display()
281-
);
282-
Some(cached_path)
283-
} else {
284-
tracing::warn!("Failed to extract icon for {}", app_path.display());
285-
None
286-
}
259+
get_or_extract_icon(app_path)
287260
}

crates/photoncast/src/launcher/indexing.rs

Lines changed: 119 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ impl LauncherWindow {
1212
// but GPUI uses its own async executor
1313
let (tx, rx) = std::sync::mpsc::channel();
1414

15-
let photoncast_app_for_icons = Arc::clone(&photoncast_app);
1615
std::thread::spawn(move || {
1716
tracing::info!("Starting application indexing...");
1817

@@ -23,42 +22,7 @@ impl LauncherWindow {
2322
});
2423

2524
// Send scan results immediately so UI becomes responsive
26-
let apps_for_icons = result.as_ref().ok().cloned();
2725
let _ = tx.send(result);
28-
29-
// Now extract icons in the same background thread (doesn't block UI)
30-
if let Some(apps) = apps_for_icons {
31-
tracing::info!(
32-
"Starting background icon extraction for {} apps",
33-
apps.len()
34-
);
35-
let start = std::time::Instant::now();
36-
let mut extracted = 0;
37-
let mut cached = 0;
38-
39-
for app in &apps {
40-
// Extract or get cached icon
41-
if let Some(icon_path) = Self::get_app_icon_path(&app.path) {
42-
// Update the app's icon in shared state
43-
photoncast_app_for_icons
44-
.write()
45-
.update_app_icon(&app.bundle_id.to_string(), icon_path);
46-
47-
if Self::get_cached_icon_path(&app.path).is_some() {
48-
cached += 1;
49-
} else {
50-
extracted += 1;
51-
}
52-
}
53-
}
54-
55-
tracing::info!(
56-
"Icon extraction complete: {} cached, {} extracted in {:?}",
57-
cached,
58-
extracted,
59-
start.elapsed()
60-
);
61-
}
6226
});
6327

6428
// Poll for results in GPUI's async context
@@ -73,6 +37,7 @@ impl LauncherWindow {
7337
match rx.try_recv() {
7438
Ok(Ok(apps)) => {
7539
let app_count = apps.len();
40+
let apps_for_icons = apps.clone();
7641
tracing::info!("Indexed {} applications", app_count);
7742

7843
// Update the PhotonCast app with indexed apps
@@ -87,6 +52,43 @@ impl LauncherWindow {
8752
this.start_app_watching(cx);
8853
cx.notify();
8954
});
55+
56+
let photoncast_app_for_icons = Arc::clone(&photoncast_app);
57+
let this_for_icons = this.clone();
58+
cx.spawn(|mut cx| async move {
59+
tracing::info!(
60+
"Starting background icon extraction for {} apps",
61+
apps_for_icons.len()
62+
);
63+
64+
let mut updated_icons = 0usize;
65+
for app in apps_for_icons {
66+
let app_path = app.path.clone();
67+
let bundle_id = app.bundle_id.to_string();
68+
69+
let icon_result = cx
70+
.background_executor()
71+
.spawn(async move { Self::get_app_icon_path_static(&app_path) })
72+
.await;
73+
74+
if let Some(icon_path) = icon_result {
75+
photoncast_app_for_icons
76+
.write()
77+
.update_app_icon(&bundle_id, icon_path);
78+
updated_icons += 1;
79+
80+
let _ = this_for_icons.update(&mut cx, |this, cx| {
81+
this.refresh_visible_app_icons(cx);
82+
});
83+
}
84+
}
85+
86+
tracing::info!(
87+
"Background icon extraction complete: {} icons updated",
88+
updated_icons
89+
);
90+
})
91+
.detach();
9092
break;
9193
},
9294
Ok(Err(e)) => {
@@ -156,30 +158,44 @@ impl LauncherWindow {
156158
}
157159
});
158160
});
161+
let event_rx = Arc::new(std::sync::Mutex::new(event_rx));
159162

160163
// Process watch events in GPUI's async context
161164
cx.spawn(|this, mut cx| async move {
165+
const WATCH_EVENT_TIMEOUT_MS: u64 = 500;
166+
162167
loop {
163-
// Poll for events periodically
164-
cx.background_executor()
165-
.timer(Duration::from_millis(100))
166-
.await;
168+
let next_events = {
169+
let event_rx = Arc::clone(&event_rx);
170+
cx.background_executor().spawn(async move {
171+
let receiver = event_rx.lock().expect("watch event receiver poisoned");
172+
match receiver.recv_timeout(Duration::from_millis(WATCH_EVENT_TIMEOUT_MS)) {
173+
Ok(first_event) => {
174+
let mut events = vec![first_event];
175+
while let Ok(event) = receiver.try_recv() {
176+
events.push(event);
177+
}
178+
Ok(events)
179+
},
180+
Err(err) => Err(err),
181+
}
182+
})
183+
}
184+
.await;
167185

168-
// Process all pending events
169-
loop {
170-
match event_rx.try_recv() {
171-
Ok(event) => {
186+
match next_events {
187+
Ok(events) => {
188+
for event in events {
172189
Self::handle_watch_event(&this, &mut cx, &photoncast_app, event).await;
173-
},
174-
Err(std::sync::mpsc::TryRecvError::Empty) => {
175-
// No more events, wait for next poll
176-
break;
177-
},
178-
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
179-
tracing::info!("Watcher thread disconnected");
180-
return;
181-
},
182-
}
190+
}
191+
},
192+
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
193+
// No watch events before timeout, continue waiting.
194+
},
195+
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
196+
tracing::info!("Watcher thread disconnected");
197+
return;
198+
},
183199
}
184200
}
185201
})
@@ -271,9 +287,8 @@ impl LauncherWindow {
271287
.write()
272288
.update_app_icon(&bundle_id, icon_path);
273289

274-
// Notify UI that icon is now available
275-
let _ = this_for_icon.update(&mut cx, |_this, cx| {
276-
cx.notify();
290+
let _ = this_for_icon.update(&mut cx, |this, cx| {
291+
this.refresh_visible_app_icons(cx);
277292
});
278293
}
279294
})
@@ -327,9 +342,8 @@ impl LauncherWindow {
327342
.write()
328343
.update_app_icon(&bundle_id, icon_path);
329344

330-
// Notify UI that icon has been updated
331-
let _ = this_for_icon.update(&mut cx, |_this, cx| {
332-
cx.notify();
345+
let _ = this_for_icon.update(&mut cx, |this, cx| {
346+
this.refresh_visible_app_icons(cx);
333347
});
334348
}
335349
})
@@ -415,6 +429,51 @@ impl LauncherWindow {
415429
crate::icon_cache::get_cached_icon_path(app_path)
416430
}
417431

432+
fn backfill_cached_icon_paths(results: &mut [SearchResult]) -> bool {
433+
let mut changed = false;
434+
435+
for result in results {
436+
let SearchAction::LaunchApp { path, .. } = &result.action else {
437+
continue;
438+
};
439+
440+
let IconSource::AppIcon { icon_path, .. } = &mut result.icon else {
441+
continue;
442+
};
443+
444+
if icon_path.is_some() {
445+
continue;
446+
}
447+
448+
if let Some(cached_path) = Self::get_cached_icon_path(path) {
449+
*icon_path = Some(cached_path);
450+
changed = true;
451+
}
452+
}
453+
454+
changed
455+
}
456+
457+
fn refresh_visible_app_icons(&mut self, cx: &mut ViewContext<Self>) {
458+
let suggestions_changed = Self::backfill_cached_icon_paths(&mut self.search.suggestions);
459+
let core_changed = Self::backfill_cached_icon_paths(&mut self.search.core_results);
460+
461+
if !(suggestions_changed || core_changed) {
462+
return;
463+
}
464+
465+
if !matches!(self.search.mode, SearchMode::Calendar { .. }) {
466+
self.search.results = self
467+
.search
468+
.core_results
469+
.iter()
470+
.map(Self::search_result_to_result_item)
471+
.collect();
472+
}
473+
474+
cx.notify();
475+
}
476+
418477
/// Converts an icon source to a display emoji (fallback)
419478
pub(super) fn icon_to_emoji(icon: &IconSource) -> SharedString {
420479
match icon {

0 commit comments

Comments
 (0)