max / goingson
18 files changed,
+44 insertions,
-40 deletions
| @@ -22,9 +22,13 @@ First beta-ready release. | |||
| 22 | 22 | - macOS, Windows, and Linux builds (signed and notarized on macOS) | |
| 23 | 23 | ||
| 24 | 24 | ### Fixed | |
| 25 | - | - All issues identified in audit runs 1–12 | |
| 26 | - | - Concurrency and type safety patterns (Rust patterns audit) | |
| 25 | + | - Empty-state copy across tasks, emails, projects, contacts and events | |
| 26 | + | - About modal and Settings populated with publisher, license, contact, source, privacy, and copyright | |
| 27 | + | - Hard-fail error messages rewritten to plain language with actionable next steps | |
| 28 | + | - Bulk-action buttons now signal destructive intent with the danger color | |
| 29 | + | - Mutex poisoning recovery — no more panics on contended locks under crash recovery | |
| 30 | + | - SMTP attachment content-type parsing no longer relies on an unwrap | |
| 27 | 31 | ||
| 28 | 32 | ### Security | |
| 29 | - | - Full observability instrumentation (206 traced functions) | |
| 33 | + | - Privacy policy contact address corrected to `info@makenot.work` | |
| 30 | 34 | - No production unsafe code |
| @@ -1,3 +1,5 @@ | |||
| 1 | + | Required Notice: Copyright 2026 Make Creative, LLC (https://makenot.work) | |
| 2 | + | ||
| 1 | 3 | PolyForm Noncommercial License 1.0.0 | |
| 2 | 4 | ||
| 3 | 5 | <https://polyformproject.org/licenses/noncommercial/1.0.0> |
| @@ -71,4 +71,4 @@ Dependency flow: `core` is leaf -> `db-sqlite` and `plugin-runtime` depend on `c | |||
| 71 | 71 | ||
| 72 | 72 | ## License | |
| 73 | 73 | ||
| 74 | - | PolyForm Noncommercial 1.0.0 | |
| 74 | + | PolyForm Noncommercial 1.0.0. Personal, research, and non-commercial use are free. For commercial use, contact `info@makenot.work` to discuss a commercial license. |
| @@ -476,6 +476,7 @@ body { | |||
| 476 | 476 | .ui-mode-desktop .tab.active { | |
| 477 | 477 | background-color: var(--accent-blue); | |
| 478 | 478 | color: var(--text-on-accent); | |
| 479 | + | box-shadow: var(--shadow-brutal-xs); | |
| 479 | 480 | } | |
| 480 | 481 | ||
| 481 | 482 | .ui-mode-desktop .tab-icon { | |
| @@ -526,6 +527,7 @@ body { | |||
| 526 | 527 | background: var(--text-primary); | |
| 527 | 528 | color: var(--bg-card); | |
| 528 | 529 | border-color: var(--text-primary); | |
| 530 | + | box-shadow: var(--shadow-brutal-xs); | |
| 529 | 531 | } | |
| 530 | 532 | ||
| 531 | 533 | /* =================================================================== | |
| @@ -598,29 +600,37 @@ body { | |||
| 598 | 600 | font-size: 0.9rem; | |
| 599 | 601 | font-weight: 600; | |
| 600 | 602 | cursor: pointer; | |
| 601 | - | transition: background-color 0.15s ease; | |
| 603 | + | transition: background-color 0.15s ease, transform 0.1s ease, box-shadow 0.1s ease; | |
| 602 | 604 | text-decoration: none; | |
| 603 | 605 | background: var(--bg-card); | |
| 604 | 606 | color: var(--text-primary); | |
| 607 | + | box-shadow: var(--shadow-brutal-xs); | |
| 605 | 608 | } | |
| 606 | 609 | ||
| 607 | 610 | .btn:hover { | |
| 608 | 611 | background: var(--bg-secondary); | |
| 612 | + | transform: translate(-1px, -1px); | |
| 613 | + | box-shadow: var(--shadow-offset-md) var(--shadow-offset-md) 0 var(--border-color); | |
| 609 | 614 | } | |
| 610 | 615 | ||
| 611 | 616 | .btn:active { | |
| 612 | 617 | background: var(--bg-tertiary); | |
| 618 | + | transform: translate(1px, 1px); | |
| 619 | + | box-shadow: none; | |
| 613 | 620 | } | |
| 614 | 621 | ||
| 615 | 622 | .btn:disabled { | |
| 616 | - | background: var(--bg-tertiary); | |
| 623 | + | background: transparent; | |
| 617 | 624 | color: var(--text-muted); | |
| 625 | + | border-color: var(--text-muted); | |
| 618 | 626 | cursor: not-allowed; | |
| 619 | - | opacity: 0.7; | |
| 627 | + | box-shadow: none; | |
| 620 | 628 | } | |
| 621 | 629 | ||
| 622 | 630 | .btn:disabled:hover { | |
| 623 | - | background: var(--bg-tertiary); | |
| 631 | + | background: transparent; | |
| 632 | + | transform: none; | |
| 633 | + | box-shadow: none; | |
| 624 | 634 | } | |
| 625 | 635 | ||
| 626 | 636 | .btn-primary { | |
| @@ -4698,8 +4708,8 @@ kbd { | |||
| 4698 | 4708 | } | |
| 4699 | 4709 | .sync-dot.connected { background: var(--accent-green); } | |
| 4700 | 4710 | .sync-dot.syncing { background: var(--accent-blue); animation: sync-pulse 1s infinite; } | |
| 4701 | - | .sync-dot.warn { background: var(--accent-yellow); } | |
| 4702 | - | .sync-dot.error { background: var(--accent-red); } | |
| 4711 | + | .sync-dot.warn { background: var(--accent-yellow); border-radius: 0; transform: rotate(45deg); } | |
| 4712 | + | .sync-dot.error { background: var(--accent-red); border-radius: 0; } | |
| 4703 | 4713 | ||
| 4704 | 4714 | /* Status label — always shown at desktop widths so color isn't the sole signal. | |
| 4705 | 4715 | Hidden on narrow widths to save space; hover still surfaces the title attr. */ |
| @@ -267,7 +267,7 @@ | |||
| 267 | 267 | <div id="task-overview-view" class="subview hidden"> | |
| 268 | 268 | <div class="page-header"> | |
| 269 | 269 | <div class="row-flex row-flex-4"> | |
| 270 | - | <button class="btn btn-secondary" onclick="GoingsOn.taskOverview.close()">← Back</button> | |
| 270 | + | <button class="btn btn-secondary" onclick="GoingsOn.taskOverview.close()">← Back</button> | |
| 271 | 271 | <h2 class="page-title" id="task-overview-title">Task Overview</h2> | |
| 272 | 272 | </div> | |
| 273 | 273 | <div id="task-overview-actions"></div> | |
| @@ -463,7 +463,7 @@ | |||
| 463 | 463 | <div id="emails-view" class="subview" role="tabpanel" aria-labelledby="emails-tab"> | |
| 464 | 464 | <div class="page-header"> | |
| 465 | 465 | <h2 class="page-title">Emails</h2> | |
| 466 | - | <div class="row row-flex-2"> | |
| 466 | + | <div class="row-flex row-flex-2"> | |
| 467 | 467 | <button class="btn btn-secondary mobile-hide" onclick="GoingsOn.emails.openDrafts()">Drafts</button> | |
| 468 | 468 | <button class="btn btn-primary" onclick="GoingsOn.emails.openCompose()" title="Compose email (n)">+ Compose</button> | |
| 469 | 469 | </div> | |
| @@ -600,7 +600,7 @@ | |||
| 600 | 600 | <!-- Task Detail Drawer (Phase 7 Tier 6 — side-drawer task detail) --> | |
| 601 | 601 | <aside id="task-detail-drawer" class="task-drawer" role="dialog" aria-modal="false" aria-labelledby="task-drawer-title" aria-hidden="true"> | |
| 602 | 602 | <div class="task-drawer-header"> | |
| 603 | - | <button class="btn btn-sm btn-secondary task-drawer-close" onclick="GoingsOn.taskOverview.close()" title="Close (Esc)" aria-label="Close task detail">x</button> | |
| 603 | + | <button class="btn btn-sm btn-secondary task-drawer-close" onclick="GoingsOn.taskOverview.close()" title="Close (Esc)" aria-label="Close task detail">×</button> | |
| 604 | 604 | <h2 class="task-drawer-title" id="task-drawer-title">Task</h2> | |
| 605 | 605 | <div class="task-drawer-actions" id="task-drawer-actions"></div> | |
| 606 | 606 | </div> |
| @@ -9,9 +9,6 @@ | |||
| 9 | 9 | // ============ Application Initialization ============ | |
| 10 | 10 | ||
| 11 | 11 | document.addEventListener('DOMContentLoaded', async () => { | |
| 12 | - | console.log('GoingsOn Desktop loaded'); | |
| 13 | - | ||
| 14 | - | ||
| 15 | 12 | // Check if api is available | |
| 16 | 13 | if (!GoingsOn.api) { | |
| 17 | 14 | console.error('API not available'); | |
| @@ -25,7 +22,6 @@ document.addEventListener('DOMContentLoaded', async () => { | |||
| 25 | 22 | try { | |
| 26 | 23 | const projects = await GoingsOn.api.projects.list(); | |
| 27 | 24 | GoingsOn.projects.setCache(projects); | |
| 28 | - | console.log('Loaded', projects.length, 'projects'); | |
| 29 | 25 | } catch (err) { | |
| 30 | 26 | console.error('Failed to load projects:', err); | |
| 31 | 27 | } | |
| @@ -34,7 +30,6 @@ document.addEventListener('DOMContentLoaded', async () => { | |||
| 34 | 30 | try { | |
| 35 | 31 | const accounts = await GoingsOn.api.emailAccounts.list(); | |
| 36 | 32 | GoingsOn.emails.setAccountsCache(accounts); | |
| 37 | - | console.log('Loaded', accounts.length, 'email accounts'); | |
| 38 | 33 | } catch (err) { | |
| 39 | 34 | console.error('Failed to load email accounts:', err); | |
| 40 | 35 | } | |
| @@ -143,14 +138,12 @@ if (window.__TAURI__) { | |||
| 143 | 138 | ||
| 144 | 139 | // Database external change detection | |
| 145 | 140 | listen('db:external-change', () => { | |
| 146 | - | console.log('External database change detected, refreshing view'); | |
| 147 | 141 | GoingsOn.cache.invalidateAll(); | |
| 148 | 142 | refreshCurrentViewData(); | |
| 149 | 143 | }); | |
| 150 | 144 | ||
| 151 | 145 | // Cloud sync: remote changes applied | |
| 152 | 146 | listen('sync:changes-applied', () => { | |
| 153 | - | console.log('Sync: remote changes applied, refreshing view'); | |
| 154 | 147 | GoingsOn.cache.invalidateAll(); | |
| 155 | 148 | refreshCurrentViewData(); | |
| 156 | 149 | }); | |
| @@ -189,7 +182,6 @@ if (window.__TAURI__) { | |||
| 189 | 182 | async function refreshCurrentViewData() { | |
| 190 | 183 | // Don't refresh if a modal is open (user is editing something) | |
| 191 | 184 | if (document.querySelector('.modal:not(.hidden)')) { | |
| 192 | - | console.log('Modal open, skipping external refresh'); | |
| 193 | 185 | return; | |
| 194 | 186 | } | |
| 195 | 187 | ||
| @@ -202,9 +194,6 @@ async function refreshCurrentViewData() { | |||
| 202 | 194 | ||
| 203 | 195 | // Reload the current view's data | |
| 204 | 196 | await GoingsOn.navigation.loadViewData(currentView); | |
| 205 | - | ||
| 206 | - | // Subtle indication that data was refreshed | |
| 207 | - | console.log(`View "${currentView}" refreshed due to external change`); | |
| 208 | 197 | } catch (err) { | |
| 209 | 198 | console.error('Failed to refresh view after external change:', err); | |
| 210 | 199 | } |
| @@ -102,7 +102,7 @@ | |||
| 102 | 102 | } | |
| 103 | 103 | GoingsOn.cache.markLoaded('emails'); | |
| 104 | 104 | } catch (err) { | |
| 105 | - | container.innerHTML = `<div class="loading loading--error">Failed to load emails</div>`; | |
| 105 | + | container.innerHTML = `<div class="loading loading--error">Failed to load emails. <button class="btn-link" onclick="GoingsOn.emails.load()">Try again</button></div>`; | |
| 106 | 106 | GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load emails'), 'error', { | |
| 107 | 107 | action: { label: 'Retry', fn: load }, | |
| 108 | 108 | duration: 8000, |
| @@ -416,7 +416,7 @@ | |||
| 416 | 416 | } | |
| 417 | 417 | GoingsOn.cache.markLoaded('events'); | |
| 418 | 418 | } catch (err) { | |
| 419 | - | upcomingContainer.innerHTML = `<div class="error-state error-state--padded">Failed to load events</div>`; | |
| 419 | + | upcomingContainer.innerHTML = `<div class="error-state error-state--padded">Failed to load events. <button class="btn-link" onclick="GoingsOn.cache.invalidate('events'); GoingsOn.events.load()">Try again</button></div>`; | |
| 420 | 420 | GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load events'), 'error', { | |
| 421 | 421 | action: { label: 'Retry', fn: () => { GoingsOn.cache.invalidate('events'); load(); } }, | |
| 422 | 422 | duration: 8000, |
| @@ -390,6 +390,4 @@ GoingsOn.seedData = { | |||
| 390 | 390 | clearAll: clearAllData, | |
| 391 | 391 | }; | |
| 392 | 392 | ||
| 393 | - | console.log('Demo data generator loaded. Run GoingsOn.seedData.seed() in the console to generate sample data.'); | |
| 394 | - | ||
| 395 | 393 | })(); |
| @@ -193,7 +193,7 @@ | |||
| 193 | 193 | ||
| 194 | 194 | const enabledIds = new Set(enabledPlugins.map(p => p.id)); | |
| 195 | 195 | const pluginsList = allPlugins.length === 0 | |
| 196 | - | ? '<p class="empty-italic">No plugins installed. Place plugins in ~/.config/goingson/plugins/</p>' | |
| 196 | + | ? '<p class="empty-italic">No plugins installed. Drop a plugin file into the GoingsOn plugins directory inside your OS app-data location.</p>' | |
| 197 | 197 | : allPlugins.map(plugin => renderPluginItem(plugin, enabledIds.has(plugin.id))).join(''); | |
| 198 | 198 | ||
| 199 | 199 | container.innerHTML = ` |
| @@ -89,7 +89,7 @@ | |||
| 89 | 89 | } catch (err) { | |
| 90 | 90 | const board = document.getElementById('task-kanban-board'); | |
| 91 | 91 | if (board) { | |
| 92 | - | board.innerHTML = `<div class="loading loading--error">Error: ${esc(err.message || String(err))}</div>`; | |
| 92 | + | board.innerHTML = `<div class="loading loading--error">Failed to load board: ${esc(err.message || String(err))}. <button class="btn-link" onclick="GoingsOn.taskBoard.renderBoard()">Try again</button></div>`; | |
| 93 | 93 | } | |
| 94 | 94 | } | |
| 95 | 95 | } |
| @@ -123,7 +123,7 @@ | |||
| 123 | 123 | // Update cache with filtered results | |
| 124 | 124 | GoingsOn.state.set('tasks', response.tasks); | |
| 125 | 125 | } catch (err) { | |
| 126 | - | container.innerHTML = `<div class="loading loading--error">Failed to load tasks</div>`; | |
| 126 | + | container.innerHTML = `<div class="loading loading--error">Failed to load tasks. <button class="btn-link" onclick="GoingsOn.tasks.renderFilteredTasks()">Try again</button></div>`; | |
| 127 | 127 | GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load tasks'), 'error', { | |
| 128 | 128 | action: { label: 'Retry', fn: renderFilteredTasks }, | |
| 129 | 129 | duration: 8000, |
| @@ -595,7 +595,7 @@ async fn send_email_inner(state: &Arc<AppState>, input: SendEmailInput) -> Resul | |||
| 595 | 595 | if !path.is_file() { | |
| 596 | 596 | return Err(ApiError::validation("attachmentPaths", format!("File not found: {}", path_str))); | |
| 597 | 597 | } | |
| 598 | - | let data = std::fs::read(path) | |
| 598 | + | let data = tokio::fs::read(path).await | |
| 599 | 599 | .map_api_err("Failed to read attachment file", ApiError::internal)?; | |
| 600 | 600 | let filename = path.file_name() | |
| 601 | 601 | .and_then(|n| n.to_str()) |
| @@ -46,7 +46,7 @@ pub async fn sync_email_account_inner( | |||
| 46 | 46 | full_sync: Option<bool>, | |
| 47 | 47 | ) -> Result<SyncResponse, ApiError> { | |
| 48 | 48 | // Acquire per-account lock to prevent concurrent syncs on the same account | |
| 49 | - | if !state.email_sync_locks.lock().expect("email_sync_locks poisoned").insert(id) { | |
| 49 | + | if !state.email_sync_locks.lock().unwrap_or_else(|e| e.into_inner()).insert(id) { | |
| 50 | 50 | return Err(ApiError::bad_request("Sync already in progress for this account")); | |
| 51 | 51 | } | |
| 52 | 52 |
| @@ -128,7 +128,7 @@ pub async fn start_oauth( | |||
| 128 | 128 | ||
| 129 | 129 | // Store PKCE verifier and flow details server-side (never sent to frontend) | |
| 130 | 130 | { | |
| 131 | - | let mut flows = state.pending_oauth_flows.lock().expect("pending_oauth_flows poisoned"); | |
| 131 | + | let mut flows = state.pending_oauth_flows.lock().unwrap_or_else(|e| e.into_inner()); | |
| 132 | 132 | flows.insert(start_result.state.clone(), crate::state::PendingOAuthFlow { | |
| 133 | 133 | code_verifier: start_result.code_verifier, | |
| 134 | 134 | provider_id: provider_id.clone(), | |
| @@ -163,7 +163,7 @@ pub async fn complete_oauth( | |||
| 163 | 163 | ) -> Result<OAuthCompleteResponse, ApiError> { | |
| 164 | 164 | // Look up and consume the pending flow by state token (CSRF validation) | |
| 165 | 165 | let flow = { | |
| 166 | - | let mut flows = state.pending_oauth_flows.lock().expect("pending_oauth_flows poisoned"); | |
| 166 | + | let mut flows = state.pending_oauth_flows.lock().unwrap_or_else(|e| e.into_inner()); | |
| 167 | 167 | flows.remove(&input.state) | |
| 168 | 168 | }.ok_or_else(|| ApiError::bad_request("Invalid or expired OAuth state token"))?; | |
| 169 | 169 |
| @@ -72,7 +72,7 @@ pub struct SyncSettingsInput { | |||
| 72 | 72 | ||
| 73 | 73 | /// Extract the sync client from state. Clones the Arc for use across await points. | |
| 74 | 74 | fn get_sync_client(state: &AppState) -> Option<std::sync::Arc<synckit_client::SyncKitClient>> { | |
| 75 | - | state.sync_client.read().expect("sync_client poisoned").clone() | |
| 75 | + | state.sync_client.read().unwrap_or_else(|e| e.into_inner()).clone() | |
| 76 | 76 | } | |
| 77 | 77 | ||
| 78 | 78 | fn require_sync_client(state: &AppState) -> Result<std::sync::Arc<synckit_client::SyncKitClient>, ApiError> { | |
| @@ -174,7 +174,7 @@ pub async fn sync_start_auth( | |||
| 174 | 174 | ||
| 175 | 175 | // Store PKCE verifier server-side (never sent to frontend) | |
| 176 | 176 | { | |
| 177 | - | let mut flows = state.pending_oauth_flows.lock().expect("pending_oauth_flows poisoned"); | |
| 177 | + | let mut flows = state.pending_oauth_flows.lock().unwrap_or_else(|e| e.into_inner()); | |
| 178 | 178 | flows.insert(csrf_state.clone(), crate::state::PendingOAuthFlow { | |
| 179 | 179 | code_verifier, | |
| 180 | 180 | provider_id: "synckit".to_string(), | |
| @@ -200,7 +200,7 @@ pub async fn sync_complete_auth( | |||
| 200 | 200 | ||
| 201 | 201 | // Look up and consume the pending flow by state token (CSRF + PKCE validation) | |
| 202 | 202 | let flow = { | |
| 203 | - | let mut flows = state.pending_oauth_flows.lock().expect("pending_oauth_flows poisoned"); | |
| 203 | + | let mut flows = state.pending_oauth_flows.lock().unwrap_or_else(|e| e.into_inner()); | |
| 204 | 204 | flows.remove(&input.state) | |
| 205 | 205 | }.ok_or_else(|| ApiError::bad_request("Invalid or expired OAuth state token"))?; | |
| 206 | 206 |
| @@ -288,7 +288,8 @@ impl SmtpClient { | |||
| 288 | 288 | ||
| 289 | 289 | for file in ¶ms.attachments { | |
| 290 | 290 | let content_type = file.mime_type.parse::<ContentType>() | |
| 291 | - | .unwrap_or(ContentType::parse("application/octet-stream").unwrap()); | |
| 291 | + | .or_else(|_| "application/octet-stream".parse::<ContentType>()) | |
| 292 | + | .map_err(|e| format!("Invalid attachment content type: {}", e))?; | |
| 292 | 293 | let attachment = Attachment::new(file.filename.clone()) | |
| 293 | 294 | .body(file.data.clone(), content_type); | |
| 294 | 295 | multipart = multipart.singlepart(attachment); |
| @@ -170,7 +170,7 @@ impl AppState { | |||
| 170 | 170 | ||
| 171 | 171 | /// Gets or creates a per-account token refresh lock. | |
| 172 | 172 | pub fn token_refresh_lock(&self, account_id: uuid::Uuid) -> Arc<TokioMutex<()>> { | |
| 173 | - | let mut locks = self.token_refresh_locks.lock().expect("token_refresh_locks poisoned"); | |
| 173 | + | let mut locks = self.token_refresh_locks.lock().unwrap_or_else(|e| e.into_inner()); | |
| 174 | 174 | locks.entry(account_id) | |
| 175 | 175 | .or_insert_with(|| Arc::new(TokioMutex::new(()))) | |
| 176 | 176 | .clone() |