diff --git a/.gitignore b/.gitignore index f719966..d20dfaf 100644 --- a/.gitignore +++ b/.gitignore @@ -26,7 +26,7 @@ dist-ssr # Tauri src-tauri/target/ -# Bundled fixed-version WebView2 runtime (downloaded, not committed; see build_dist_fixed.ps1) +# Optional private WebView2 runtime (downloaded, not committed) src-tauri/webview2-runtime/ *.cab diff --git a/CHANGELOG.md b/CHANGELOG.md index 908da76..087478c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Customization files**: `zlack.css` next to the executable is injected into Slack, `zlack.png` / `zlack.ico` can override the running window/taskbar/tray icon, and `zlack-taskbar.png` can override only the Windows taskbar icon. + ## [1.2.0] - 2026-06-19 ### Added - **Unread Badges** ([#3](https://github.com/sanguneo/zlack/issues/3)): Zlack now mirrors Slack's unread state without relying on popup notifications — red for unread DMs / @mentions, blue for other unread messages. It is shown in three places: the system-tray icon swaps to a badged variant, the **Windows taskbar button gets an overlay badge** (`ITaskbarList3::SetOverlayIcon`, stamped with the unread DM/mention count when available), and the window title is prefixed with `!` when there are unread DMs / mentions. Detection is driven from the Slack tab title in `preload.js`; native rendering lives in `main.rs`. -- **Fixed WebView2 Runtime Build** ([#2](https://github.com/sanguneo/zlack/issues/2)): Added an optional Windows build (`npm run build:dist:windows:fixed`) that bundles a private, fixed-version WebView2 runtime inside Zlack's directory via `webviewInstallMode: fixedRuntime`. This lets users scope a software-firewall rule to Zlack instead of allowing the shared system WebView2. The default build is unchanged (smaller, shared runtime). +- **Private WebView2 Runtime Support** ([#2](https://github.com/sanguneo/zlack/issues/2)): On Windows, Zlack now uses a private `webview2-runtime` folder next to the executable when present, otherwise it falls back to the shared system WebView2 runtime. This lets users scope a software-firewall rule to Zlack without requiring a separate fixed-runtime build. ### Changed - **Notification context correlation**: `preload.js` now timestamps captured Slack telemetry and only attaches a team/channel to a notification when that context was captured close in time to it, so a notification without its own context no longer inherits the previously captured channel. diff --git a/README.ko.md b/README.ko.md index 8ed3798..88d9ae3 100644 --- a/README.ko.md +++ b/README.ko.md @@ -8,7 +8,8 @@ * **네이티브 데스크탑 알림**: Windows 네이티브 토스트 알림과 직접 연동됩니다. * **안 읽음 트레이/제목 뱃지**: 트레이 아이콘에 안 읽은 DM/@멘션은 빨간 뱃지, 그 외 안 읽은 메시지는 파란 뱃지로 표시하고, 안 읽은 DM이 있으면 창 제목 앞에 `!`를 붙입니다. 토스트 팝업보다 은은한 표시를 선호하는 분께 유용합니다. -* **방화벽 친화적 빌드 (선택)**: Windows 빌드 변형에서 고정 버전 WebView2 런타임을 Zlack 디렉토리 안에 전용으로 포함시켜, 방화벽 규칙을 Zlack에만 한정할 수 있습니다. +* **사용자 CSS 및 아이콘**: 실행 파일 옆에 `zlack.css`, `zlack.png`, `zlack.ico`, 또는 `zlack-taskbar.png`를 두면 Slack UI와 실행 중인 Zlack 아이콘을 사용자 지정할 수 있습니다. +* **전용 WebView2 런타임 (선택)**: Windows에서 실행 파일 옆의 `webview2-runtime` 폴더가 있으면 이를 사용하고, 없으면 시스템 공유 WebView2 런타임을 사용합니다. * **스마트 컨텍스트 감지**: Slack의 네트워크 로그를 분석하여 `Team ID`와 `Channel ID`를 추출, 알림 클릭 시 정확한 채널로 이동합니다. * **백그라운드 안정성**: Rust 백엔드를 통해 알림 클릭 시 시스템 트레이에 숨겨진 윈도우를 안정적으로 복구하고 포커싱합니다. * **멀티 워크스페이스 지원**: 표준 웹뷰 로그인을 통해 여러 슬랙 워크스페이스 간 네비게이션을 지원합니다. @@ -66,21 +67,34 @@ npm run build:dist:unix ``` Mac 또는 Linux 머신에서 실행해야 합니다. `dists/` 폴더에 `.dmg`/`.app` (macOS) 또는 `.deb`/`.AppImage` (Linux) 파일을 생성합니다. -**고정 WebView2 런타임 포함 빌드 (Windows):** -```bash -npm run build:dist:windows:fixed +**선택 사항: 전용 WebView2 런타임 (Windows):** + +기본 Zlack은 시스템 공유 WebView2 런타임을 사용합니다. 이 런타임은 공유 자원이라 방화벽에서 허용하면 *어떤 앱이든* 이를 통해 인터넷에 나갈 수 있습니다. + +전용 런타임을 사용하려면 [WebView2 다운로드 페이지](https://developer.microsoft.com/microsoft-edge/webview2/)에서 아키텍처에 맞는 **Fixed Version** WebView2 런타임을 받아 압축을 풀고, `Zlack.exe` 옆에 다음과 같이 배치하세요: + +```text +Zlack.exe +webview2-runtime/ + msedgewebview2.exe + ... ``` -기본 Zlack은 시스템 공유 WebView2 런타임을 사용합니다. 이 런타임은 공유 자원이라 방화벽에서 허용하면 *어떤 앱이든* 이를 통해 인터넷에 나갈 수 있습니다. 이 변형 빌드는 **고정 버전 WebView2 런타임을 Zlack 디렉토리 안에 전용 사본**으로 포함시켜, 방화벽 규칙을 Zlack에만 한정할 수 있게 합니다(이슈 #2). 대신 설치 파일이 약 150~180MB 커집니다. - -런타임은 한 번만 받으면 되며 저장소에 커밋되지 않습니다. [WebView2 다운로드 페이지](https://developer.microsoft.com/microsoft-edge/webview2/)에서 아키텍처에 맞는 **Fixed Version** `.cab`을 받은 뒤 실행하세요: -```powershell -# cab 경로를 직접 전달하거나... -npm run build:dist:windows:fixed -- "C:\경로\Microsoft.WebView2.FixedVersionRuntime..x64.cab" -# ...환경 변수나 직접 URL로 지정: -$env:WEBVIEW2_FIXED_CAB = "C:\경로\to.cab"; npm run build:dist:windows:fixed -$env:WEBVIEW2_FIXED_URL = "https://.../...cab"; npm run build:dist:windows:fixed + +`webview2-runtime/msedgewebview2.exe`가 있으면 Zlack이 이를 사용합니다. 없으면 시스템 공유 런타임을 사용합니다. + +## 🎨 사용자 지정 + +선택 사항으로 다음 파일을 `Zlack.exe` 옆에 둘 수 있습니다: + +```text +Zlack.exe +zlack.css # Slack에 주입되는 CSS +zlack.png # 우선 사용되는 사용자 지정 실행 아이콘 +zlack.ico # 대체 사용자 지정 실행 아이콘 +zlack-taskbar.png # 선택 사항: Windows 작업 표시줄 전용 아이콘 ``` -`src-tauri/webview2-runtime/`에 한 번 추출되면 이후 빌드에서 재사용됩니다. 결과 설치 파일에는 `_webview2fixed` 접미사가 붙습니다. + +사용자 지정 아이콘은 창, 작업 표시줄, 트레이 뱃지 기본 아이콘에 사용됩니다. `zlack-taskbar.png`가 있으면 Windows 작업 표시줄 아이콘에만 우선 사용됩니다. exe/설치 파일에 내장된 아이콘은 다시 빌드하기 전까지 그대로입니다. ## 🧩 작동 원리 diff --git a/README.md b/README.md index 5ad85c5..ca56f9c 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,8 @@ * **Native Desktop Notifications**: Integrated directly with Windows native toast notifications. * **Unread Tray & Title Badges**: The tray icon shows a red badge for unread DMs/@mentions and a blue badge for other unread messages, and the window title is prefixed with `!` for unread DMs — handy if you prefer ambient indicators over toast popups. -* **Firewall-Friendly Build (optional)**: A Windows build variant can bundle a private, fixed-version WebView2 runtime so firewall rules can be scoped to Zlack alone. +* **Custom CSS & Icon**: Place `zlack.css`, `zlack.png`, `zlack.ico`, or `zlack-taskbar.png` next to the executable to customize Slack's UI and Zlack's running icons. +* **Private WebView2 Runtime (optional)**: On Windows, Zlack uses a private `webview2-runtime` folder next to the executable when present, otherwise it falls back to the shared system WebView2 runtime. * **Smart Context**: Extracts `Team ID` and `Channel ID` from Slack's console logs to ensure notifications take you to the exact right place. * **Background Reliability**: Includes a custom rust backend to ensure clicking a notification properly restores the window from the system tray and focuses it. * **Multi-Workspace Support**: Handles navigation for multiple Slack workspaces via standard webview login. @@ -66,21 +67,34 @@ npm run build:dist:unix ``` This requires running on a Mac or Linux machine. It will generate `.dmg`/`.app` (macOS) or `.deb`/`.AppImage` (Linux) in the `dists/` folder. -**Build with a private, fixed WebView2 runtime (Windows):** -```bash -npm run build:dist:windows:fixed +**Optional private WebView2 runtime (Windows):** + +By default Zlack uses the shared, system-wide WebView2 runtime. Because that runtime is shared, allowing it through a software firewall effectively allows *any* app to reach the internet through it. + +To use a private runtime instead, get the **Fixed Version** WebView2 runtime for your architecture from the [WebView2 download page](https://developer.microsoft.com/microsoft-edge/webview2/), extract it, and place it next to `Zlack.exe` as: + +```text +Zlack.exe +webview2-runtime/ + msedgewebview2.exe + ... ``` -By default Zlack uses the shared, system-wide WebView2 runtime. Because that runtime is shared, allowing it through a software firewall effectively allows *any* app to reach the internet through it. This variant instead bundles a **private, fixed-version WebView2 runtime** inside Zlack's own directory, so you can write a firewall rule scoped to Zlack alone (issue #2). The trade-off is a ~150–180MB larger installer. - -The runtime is downloaded once and is not committed to the repo. Get the **Fixed Version** `.cab` for your architecture from the [WebView2 download page](https://developer.microsoft.com/microsoft-edge/webview2/), then run: -```powershell -# pass the cab path directly... -npm run build:dist:windows:fixed -- "C:\path\to\Microsoft.WebView2.FixedVersionRuntime..x64.cab" -# ...or via an env var, or a direct URL: -$env:WEBVIEW2_FIXED_CAB = "C:\path\to.cab"; npm run build:dist:windows:fixed -$env:WEBVIEW2_FIXED_URL = "https://.../...cab"; npm run build:dist:windows:fixed + +If `webview2-runtime/msedgewebview2.exe` exists, Zlack uses it. Otherwise, it falls back to the shared system runtime. + +## 🎨 Customization + +Place optional customization files next to `Zlack.exe`: + +```text +Zlack.exe +zlack.css # injected into Slack +zlack.png # preferred custom running icon +zlack.ico # fallback custom running icon +zlack-taskbar.png # optional Windows taskbar-only override ``` -Once extracted to `src-tauri/webview2-runtime/`, subsequent builds reuse it. Output installers are suffixed `_webview2fixed`. + +The custom icon is used for the window, taskbar, and tray badge base. If `zlack-taskbar.png` exists, it overrides only the Windows taskbar icon. The embedded exe/installer icon remains fixed until rebuilt. ## 🧩 How It Works diff --git a/build_dist_fixed.ps1 b/build_dist_fixed.ps1 deleted file mode 100644 index 547cd9a..0000000 --- a/build_dist_fixed.ps1 +++ /dev/null @@ -1,116 +0,0 @@ -# Builds a Windows installer that bundles a PRIVATE, fixed-version WebView2 runtime -# inside Zlack's own directory (issue #2). This lets users write a software-firewall -# rule for Zlack's own msedgewebview2.exe instead of allowing the shared, system-wide -# WebView2 (which any app can ride out to the internet). -# -# This is a SEPARATE build from the normal one (build_dist.ps1). The normal build stays -# small and uses the shared system runtime; this variant is ~150-180MB larger. -# -# The fixed runtime is NOT committed to the repo. Acquire it once, then this script -# reuses the extracted copy in src-tauri/webview2-runtime/. -# -# Acquiring the runtime (pick one): -# A) Download the "Fixed Version" (.cab) for your architecture from -# https://developer.microsoft.com/microsoft-edge/webview2/ (Download section) -# then either: -# - set $env:WEBVIEW2_FIXED_CAB = "C:\path\to\Microsoft.WebView2.FixedVersionRuntime..x64.cab" -# - or pass it as the first argument: ./build_dist_fixed.ps1 "C:\path\to.cab" -# B) Or provide a direct download URL via $env:WEBVIEW2_FIXED_URL -# C) Or manually extract a cab so that src-tauri\webview2-runtime\msedgewebview2.exe exists. - -param( - [string]$CabPath = $env:WEBVIEW2_FIXED_CAB -) - -$ErrorActionPreference = "Stop" - -$runtimeDir = Join-Path $PSScriptRoot "src-tauri\webview2-runtime" -$runtimeExe = Join-Path $runtimeDir "msedgewebview2.exe" -$cabUrl = $env:WEBVIEW2_FIXED_URL - -function Expand-FixedRuntime { - param([string]$Cab) - - Write-Host "📂 Extracting WebView2 fixed runtime from: $Cab" - $tmp = Join-Path $env:TEMP ("zlack-webview2-" + [System.Guid]::NewGuid().ToString("N")) - New-Item -ItemType Directory -Force -Path $tmp | Out-Null - try { - # `expand` ships with Windows and handles .cab files. - & expand.exe "$Cab" -F:* "$tmp" | Out-Null - if ($LASTEXITCODE -ne 0) { throw "expand.exe failed with exit code $LASTEXITCODE" } - - # The cab contains a top-level "Microsoft.WebView2.FixedVersionRuntime.." folder. - # Flatten it into webview2-runtime/ so the config path stays version-independent. - $inner = Get-ChildItem -Path $tmp -Directory | - Where-Object { Test-Path (Join-Path $_.FullName "msedgewebview2.exe") } | - Select-Object -First 1 - $sourceRoot = if ($inner) { $inner.FullName } else { $tmp } - - if (-not (Test-Path (Join-Path $sourceRoot "msedgewebview2.exe"))) { - throw "Extracted cab does not contain msedgewebview2.exe. Is this a Fixed Version runtime cab?" - } - - if (Test-Path $runtimeDir) { Remove-Item -Recurse -Force $runtimeDir } - New-Item -ItemType Directory -Force -Path $runtimeDir | Out-Null - Copy-Item -Path (Join-Path $sourceRoot "*") -Destination $runtimeDir -Recurse -Force - Write-Host " ✅ Runtime extracted to $runtimeDir" - } - finally { - Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue - } -} - -# --- 1. Ensure the fixed runtime is present --------------------------------- -if (Test-Path $runtimeExe) { - Write-Host "✅ Reusing existing fixed runtime at $runtimeDir" -} -elseif ($CabPath -and (Test-Path $CabPath)) { - Expand-FixedRuntime -Cab $CabPath -} -elseif ($cabUrl) { - $dl = Join-Path $env:TEMP "zlack-webview2-fixed.cab" - Write-Host "⬇️ Downloading WebView2 fixed runtime from $cabUrl" - Invoke-WebRequest -Uri $cabUrl -OutFile $dl - Expand-FixedRuntime -Cab $dl - Remove-Item -Force $dl -ErrorAction SilentlyContinue -} -else { - Write-Error @" -WebView2 fixed runtime not found. - -Acquire it once (see header of this script), then re-run. Quick options: - - ./build_dist_fixed.ps1 "C:\path\to\Microsoft.WebView2.FixedVersionRuntime..x64.cab" - - `$env:WEBVIEW2_FIXED_CAB = "C:\path\to.cab"; ./build_dist_fixed.ps1` - - `$env:WEBVIEW2_FIXED_URL = "https://.../...cab"; ./build_dist_fixed.ps1` -Download page: https://developer.microsoft.com/microsoft-edge/webview2/ (Fixed Version) -"@ - exit 1 -} - -# --- 2. Build with the fixed-runtime config patch -------------------------- -Write-Host "🚧 Starting Zlack Build (fixed WebView2 runtime)..." -npm run tauri -- build --config "src-tauri/tauri.fixed.conf.json" -if ($LASTEXITCODE -ne 0) { throw "tauri build failed with exit code $LASTEXITCODE" } - -# --- 3. Copy artifacts to dists/ with a distinguishing suffix --------------- -$distDir = "dists" -if (-not (Test-Path $distDir)) { - New-Item -ItemType Directory -Force -Path $distDir | Out-Null -} - -Write-Host "📦 Copying artifacts to $distDir..." - -function Copy-WithSuffix { - param([string]$Glob, [string]$Label) - $items = Get-ChildItem -Path $Glob -ErrorAction SilentlyContinue - foreach ($item in $items) { - $name = "$($item.BaseName)_webview2fixed$($item.Extension)" - Copy-Item $item.FullName -Destination (Join-Path $distDir $name) -Force - Write-Host " ✅ $Label copied as $name" - } -} - -Copy-WithSuffix "src-tauri/target/release/bundle/msi/*.msi" "MSI" -Copy-WithSuffix "src-tauri/target/release/bundle/nsis/*.exe" "Setup EXE" - -Write-Host "✨ Fixed-runtime build complete! Artifacts are in the '$distDir' folder." diff --git a/package.json b/package.json index 01093ff..e8b67a7 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,6 @@ "pretauri": "npm run update-version", "update-version": "node scripts/update-version.js", "build:dist:windows": "powershell -ExecutionPolicy Bypass -File build_dist.ps1", - "build:dist:windows:fixed": "powershell -ExecutionPolicy Bypass -File build_dist_fixed.ps1", "build:dist:unix": "bash build_dist.sh" }, "devDependencies": { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 4b91468..3caa0f4 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -8,10 +8,14 @@ version = "1.2.0" dependencies = [ "image", "lazy_static", + "serde", + "serde_json", "tauri", "tauri-build", "tauri-plugin-single-instance", "tauri-winrt-notification", + "webview2-com", + "windows 0.39.0", "windows 0.48.0", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c8ca118..4eca034 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -12,12 +12,16 @@ tauri-build = { version = "1", features = [] } [dependencies] lazy_static = "1.5.0" -image = { version = "0.24", default-features = false, features = ["png"] } +image = { version = "0.24", default-features = false, features = ["png", "ico"] } tauri = { version = "1", features = [ "notification-all", "shell-open", "system-tray"] } tauri-plugin-single-instance = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" [target.'cfg(windows)'.dependencies] tauri-winrt-notification = "0.7.2" +webview2-com = "0.19.1" +windows-webview2 = { package = "windows", version = "0.39.0" } windows = { version = "0.48", features = [ "Win32_Foundation", "Win32_System_Com", diff --git a/src-tauri/preload.js b/src-tauri/preload.js index cc3a018..1fe5ccf 100644 --- a/src-tauri/preload.js +++ b/src-tauri/preload.js @@ -67,7 +67,6 @@ function processTelemetryBody(bodyStr) { }); if (tid && cid) { - console.log(`[Zlack] Captured context from network: Team=${tid}, Channel=${cid}`); lastEventContext.teamId = tid; lastEventContext.channelId = cid; lastEventContext.ts = Date.now(); @@ -88,7 +87,25 @@ window.fetch = function(input, init) { processTelemetryBody(init.body); } } - return originalFetch.apply(this, arguments); + + const responsePromise = originalFetch.apply(this, arguments); + try { + responsePromise.then(response => { + const url = (typeof input === 'string' ? input : input && input.url) || response.url || ''; + if (isBadgeCountsUrl(url)) { + rememberBadgeCountsRequest( + url, + (init && init.method) || (input && input.method) || 'GET', + init && init.body, + (init && init.headers) || (input && input.headers) + ); + response.clone().text().then(text => processBadgeCountsResponse(url, text)).catch(() => {}); + } else if (isWorkspaceInitUrl(url)) { + response.clone().text().then(text => processWorkspaceInitResponse(url, text)).catch(() => {}); + } + }); + } catch (_) {} + return responsePromise; }; // Intercept navigator.sendBeacon @@ -101,11 +118,45 @@ navigator.sendBeacon = function(url, data) { }; // Intercept XMLHttpRequest (just in case they use it for some calls) +const originalXHROpen = XMLHttpRequest.prototype.open; +XMLHttpRequest.prototype.open = function(method, url) { + this.__zlackMethod = method; + this.__zlackUrl = url; + this.__zlackHeaders = {}; + return originalXHROpen.apply(this, arguments); +}; + +const originalXHRSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader; +XMLHttpRequest.prototype.setRequestHeader = function(header, value) { + try { + if (!this.__zlackHeaders) this.__zlackHeaders = {}; + this.__zlackHeaders[header] = value; + } catch (_) {} + return originalXHRSetRequestHeader.apply(this, arguments); +}; + const originalXHRScan = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.send = function(body) { if (body && typeof body === 'string') { processTelemetryBody(body); } + try { + const requestUrl = this.__zlackUrl || ''; + if (isBadgeCountsUrl(requestUrl)) { + rememberBadgeCountsRequest(requestUrl, this.__zlackMethod || 'GET', body, this.__zlackHeaders); + } + this.addEventListener('load', () => { + const url = this.responseURL || this.__zlackUrl || ''; + if (!isBadgeCountsUrl(url) && !isWorkspaceInitUrl(url)) return; + if (this.responseType === '' || this.responseType === 'text') { + if (isBadgeCountsUrl(url)) processBadgeCountsResponse(url, this.responseText); + else processWorkspaceInitResponse(url, this.responseText); + } else if (this.responseType === 'json') { + if (isBadgeCountsUrl(url)) processBadgeCountsResponse(url, this.response); + else processWorkspaceInitResponse(url, this.response); + } + }); + } catch (_) {} return originalXHRScan.apply(this, arguments); }; @@ -136,17 +187,15 @@ const ZlackNotification = class { const teamId = fresh ? (lastEventContext.teamId || 'unknown') : 'unknown'; const channelId = fresh ? (lastEventContext.channelId || 'unknown') : 'unknown'; - window.__TAURI__.invoke('notify', { + tauriInvoke('notify', { title: notifyTitle, body: originalBody, teamId: teamId, channelId: channelId - }); + }).catch(() => {}); }, NOTIFY_DELAY_MS); } - } catch (e) { - console.error('Zlack: Failed to invoke notify', e); - } + } catch (_) {} } static get permission() { return "granted"; } @@ -179,69 +228,1025 @@ Object.defineProperty(window, 'Notification', { }); -// 3.5 Unread / mention badge bridge -> native tray icon + window title -// Slack keeps the browser tab title (document.title) in sync with unread state: -// - a parenthesised count "(3) ..." -> unread DMs / @mentions (RED) -// - a leading bullet/asterisk "* ..." -> other unread messages (BLUE) -// - no prefix -> everything read -// We watch and forward a coarse state to Rust, which mirrors it onto the -// OS window title (prefixed with "!" for DMs) and swaps the tray icon. -// The classification is intentionally isolated here so it's easy to adjust if -// Slack ever changes its title format. +// 3.5 Unread / mention badge bridge -> native tray icon + window title. +// Badge state comes from Slack count snapshots and WebSocket events, not from +// document.title. The title observer below only keeps the native window title text +// in sync, with Slack's own unread markers stripped before Rust adds a "!" prefix. +const BADGE_COUNTS_URL_RE = /\/api\/client\.counts\b|\/cache\/[^/]+\/users\/counts\b/i; +const WORKSPACE_INIT_URL_RE = /\/api\/client\.init\b/i; +const WORKSPACE_CACHE_KEY = 'ZLACK_WORKSPACES'; +const BADGE_TITLE_UNREAD_MARKER = /^\s*[\*•●·⁕∗∘◦]+/; +const BADGE_TITLE_COUNT_MARKER = /^\s*\((\d+)\)/; +const BADGE_STATE_PRIORITY = { none: 0, unread: 1, mention: 2 }; +const BADGE_COUNTS_POLL_MS = 30000; +const BADGE_MENTION_COUNT_KEYS = [ + 'dm_count', + 'mention_count_display', + 'num_mentions_display', + 'mention_count', + 'mentions_count', + 'highlight_count', + 'highlight_cnt', + 'badge_count', +]; +const BADGE_UNREAD_COUNT_KEYS = [ + 'unread_count_display', + 'unread_count', + 'unread_count_sum', + 'unread_cnt', +]; +const BADGE_MENTION_BOOL_KEYS = ['has_mentions', 'has_highlights', 'is_mentioned', 'mentioned']; +const BADGE_UNREAD_BOOL_KEYS = ['unread', 'is_unread', 'has_unreads', 'has_unread']; + +let badgeSelfUserId = null; +let badgeWindowTitle = 'Zlack'; +let badgeLastSent = { state: null, title: null }; +let lastWorkspaceSwitch = { team: null, ts: 0 }; +let didRegisterCachedWorkspaces = false; +let workspaceOrder = []; +let badgeLastCountsRequest = null; +let badgeCountsPollInFlight = false; +let workspaceButtonsStatus = { active: null, workspaces: [] }; +let workspaceButtonsRequestInFlight = false; +let workspaceButtonsRenderScheduled = false; +let workspaceMetaLastSent = ''; +const badgeCountSnapshots = new Map(); +const badgeRealtimeStates = new Map(); +const workspaceTeamByDomain = new Map(); +const workspaceTeamByName = new Map(); + +function readWorkspaceCache() { + try { + const parsed = JSON.parse(localStorage.getItem(WORKSPACE_CACHE_KEY) || '[]'); + return Array.isArray(parsed) ? parsed : []; + } catch (_) { + return []; + } +} + +function writeWorkspaceCache(entries) { + try { + localStorage.setItem(WORKSPACE_CACHE_KEY, JSON.stringify(entries)); + } catch (_) {} +} + +function cacheWorkspace(team, { domain = null, name = null } = {}) { + if (!/^T[A-Z0-9]{7,}$/.test(team || '')) return; + const normalizedDomain = domain ? normalizeWorkspaceDomain(domain) : null; + const normalizedName = name ? normalizeWorkspaceName(name) : null; + const existing = readWorkspaceCache().find(entry => entry && entry.team === team) || {}; + let entries = readWorkspaceCache().filter(entry => { + if (!entry || !entry.team) return false; + if (entry.team === team) return false; + if (normalizedDomain && normalizeWorkspaceDomain(entry.domain) === normalizedDomain) return false; + if (normalizedName && normalizeWorkspaceName(entry.name) === normalizedName) return false; + return true; + }); + entries.push({ + team, + domain: normalizedDomain || normalizeWorkspaceDomain(existing.domain) || null, + name: normalizedName || normalizeWorkspaceName(existing.name) || null, + }); + writeWorkspaceCache(entries); +} + +function readLocalConfigWorkspaces() { + try { + const config = JSON.parse(localStorage.getItem('localConfig_v2') || '{}'); + const teams = config && config.teams && typeof config.teams === 'object' ? config.teams : {}; + return Object.values(teams) + .map(team => ({ + team: team && team.id, + domain: team && (team.domain || team.url), + name: team && team.name, + })) + .filter(entry => /^T[A-Z0-9]{7,}$/.test(entry.team || '')); + } catch (_) { + return []; + } +} + +function setWorkspaceOrder(teams) { + const seen = new Set(); + workspaceOrder = teams.filter(team => { + if (!/^T[A-Z0-9]{7,}$/.test(team || '') || seen.has(team)) return false; + seen.add(team); + return true; + }); + return workspaceOrder; +} + +function loadWorkspaceCache() { + const merged = new Map(); + // localConfig_v2 is Slack's own account/workspace list, so prefer its order. + for (const entry of readLocalConfigWorkspaces().concat(readWorkspaceCache())) { + if (!entry || !/^T[A-Z0-9]{7,}$/.test(entry.team || '')) continue; + const existing = merged.get(entry.team) || {}; + merged.set(entry.team, { + team: entry.team, + domain: normalizeWorkspaceDomain(entry.domain) || normalizeWorkspaceDomain(existing.domain) || null, + name: normalizeWorkspaceName(entry.name) || normalizeWorkspaceName(existing.name) || null, + }); + } + + const validEntries = Array.from(merged.values()); + writeWorkspaceCache(validEntries); + + const teams = []; + for (const entry of validEntries) { + teams.push(entry.team); + rememberWorkspaceAlias(entry.team, entry.domain, true, false); + rememberWorkspaceAlias(entry.team, entry.name, false, false); + } + return setWorkspaceOrder(teams); +} + +function createTauriCallback(callback, once = true) { + const id = window.crypto.getRandomValues(new Uint32Array(1))[0]; + const prop = `_${id}`; + Object.defineProperty(window, prop, { + value: value => { + if (once) Reflect.deleteProperty(window, prop); + return callback && callback(value); + }, + writable: false, + configurable: true, + }); + return id; +} + +function directTauriInvoke(command, args = {}) { + return new Promise((resolve, reject) => { + const callback = createTauriCallback(value => { + resolve(value); + Reflect.deleteProperty(window, `_${error}`); + }); + const error = createTauriCallback(value => { + reject(value); + Reflect.deleteProperty(window, `_${callback}`); + }); + window.__TAURI_IPC__({ cmd: command, callback, error, ...args }); + }); +} + +function currentTauriInvoke() { + // Only treat IPC as ready once __TAURI_IPC__ exists. Prefer the public global + // Tauri API; __TAURI_INVOKE__ can queue forever in some remote-domain cases. + if (typeof window.__TAURI_IPC__ !== 'function') return null; + if (window.__TAURI__ && typeof window.__TAURI__.invoke === 'function') { + return (command, args) => window.__TAURI__.invoke(command, args); + } + if (window.__TAURI__ && window.__TAURI__.tauri && typeof window.__TAURI__.tauri.invoke === 'function') { + return (command, args) => window.__TAURI__.tauri.invoke(command, args); + } + if (typeof window.__TAURI_INVOKE__ === 'function') return window.__TAURI_INVOKE__; + return directTauriInvoke; +} + +function tauriInvoke(command, args, timeoutMs = 10000) { + const started = Date.now(); + return new Promise((resolve, reject) => { + function attempt() { + const invoke = currentTauriInvoke(); + if (invoke) { + Promise.resolve(invoke(command, args)).then(resolve, reject); + } else if (Date.now() - started < timeoutMs) { + setTimeout(attempt, 50); + } else { + reject(new Error('Tauri IPC is not available')); + } + } + attempt(); + }); +} + +function isBadgeCountsUrl(url) { + return BADGE_COUNTS_URL_RE.test(url || ''); +} + +function isWorkspaceInitUrl(url) { + return WORKSPACE_INIT_URL_RE.test(url || ''); +} + +function badgeHeadersObject(headers) { + const out = {}; + try { + if (!headers) return out; + if (headers instanceof Headers) { + headers.forEach((value, key) => { out[key] = value; }); + } else if (Array.isArray(headers)) { + headers.forEach(([key, value]) => { out[key] = value; }); + } else if (typeof headers === 'object') { + Object.assign(out, headers); + } + } catch (_) {} + return out; +} + +function rememberBadgeCountsRequest(url, method = 'GET', body = null, headers = null) { + if (!isBadgeCountsUrl(url)) return; + try { + const normalizedMethod = String(method || 'GET').toUpperCase(); + const normalizedBody = body instanceof URLSearchParams ? body.toString() : body; + if (normalizedMethod !== 'GET' && typeof normalizedBody !== 'string') return; + badgeLastCountsRequest = { + url: new URL(url, window.location.href).href, + method: normalizedMethod, + body: normalizedMethod === 'GET' ? null : normalizedBody, + headers: badgeHeadersObject(headers), + }; + } catch (_) {} +} + +function pollBadgeCounts() { + if (!badgeLastCountsRequest || badgeCountsPollInFlight) return; + const request = badgeLastCountsRequest; + badgeCountsPollInFlight = true; + + const init = { + method: request.method, + credentials: 'include', + cache: 'no-store', + headers: request.headers, + }; + if (request.method !== 'GET') init.body = request.body; + + originalFetch(request.url, init) + .then(response => response.text()) + .then(text => processBadgeCountsResponse(request.url, text)) + .catch(() => {}) + .finally(() => { badgeCountsPollInFlight = false; }); +} + +function badgeCount(value) { + const n = typeof value === 'number' ? value : parseInt(value, 10); + return Number.isFinite(n) ? n : 0; +} + +function badgeMaxCount(obj, keys) { + if (!obj || typeof obj !== 'object') return 0; + let max = 0; + for (const key of keys) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { + max = Math.max(max, badgeCount(obj[key])); + } + } + return max; +} + +function badgeChannelId(obj) { + if (!obj || typeof obj !== 'object') return null; + if (typeof obj.channel === 'string') return obj.channel; + if (obj.channel && typeof obj.channel === 'object') return obj.channel.id || obj.channel.channel_id || null; + if (typeof obj.channel_id === 'string') return obj.channel_id; + if (typeof obj.id === 'string' && /^[CDG][A-Z0-9]{7,}$/.test(obj.id)) return obj.id; + return null; +} + +function badgeTeamId(obj) { + if (!obj || typeof obj !== 'object') return null; + if (typeof obj.team === 'string') return obj.team; + if (obj.team && typeof obj.team === 'object') return obj.team.id || obj.team.team_id || null; + if (typeof obj.team_id === 'string') return obj.team_id; + if (typeof obj.id === 'string' && /^T[A-Z0-9]{7,}$/.test(obj.id)) return obj.id; + return null; +} + +function badgeContextFromKey(key, context) { + const next = { ...context }; + if (/^[CDG][A-Z0-9]{7,}$/.test(key)) next.channel = key; + if (/^T[A-Z0-9]{7,}$/.test(key)) next.team = key; + return next; +} + +function normalizeWorkspaceDomain(value) { + return String(value || '') + .trim() + .toLowerCase() + .replace(/^https?:\/\//, '') + .replace(/\/.*$/, '') + .replace(/\/+$/, ''); +} + +function normalizeWorkspaceName(value) { + return String(value || '').trim().toLowerCase().replace(/\s+/g, ' '); +} + +function rememberWorkspaceAlias(team, value, isDomain, persist = true) { + if (!team || !value) return; + if (isDomain) { + const domain = normalizeWorkspaceDomain(value); + if (!domain) return; + workspaceTeamByDomain.set(domain, team); + if (!domain.endsWith('.slack.com')) workspaceTeamByDomain.set(`${domain}.slack.com`, team); + if (persist) cacheWorkspace(team, { domain }); + } else { + const name = normalizeWorkspaceName(value); + if (name) { + workspaceTeamByName.set(name, team); + if (persist) cacheWorkspace(team, { name }); + } + } +} + +function collectWorkspaceTeams(value, out = new Set(), depth = 0) { + if (!value || depth > 8) return out; + if (Array.isArray(value)) { + value.forEach(item => collectWorkspaceTeams(item, out, depth + 1)); + return out; + } + if (typeof value !== 'object') return out; + + const team = badgeTeamId(value); + if (team) { + out.add(team); + rememberWorkspaceAlias(team, value.name || value.team_name || value.label, false); + rememberWorkspaceAlias(team, value.domain || value.url || value.team_url || value.enterprise_url, true); + if (value.team && typeof value.team === 'object') { + rememberWorkspaceAlias(team, value.team.name || value.team.team_name, false); + rememberWorkspaceAlias(team, value.team.domain || value.team.url || value.team.team_url, true); + } + } + for (const [key, child] of Object.entries(value)) { + if (/^T[A-Z0-9]{7,}$/.test(key)) out.add(key); + if (child && typeof child === 'object') collectWorkspaceTeams(child, out, depth + 1); + } + return out; +} + +function processWorkspaceInitResponse(url, textOrObject) { + if (!isWorkspaceInitUrl(url)) return; + try { + const data = typeof textOrObject === 'string' ? JSON.parse(textOrObject) : textOrObject; + if (!data || typeof data !== 'object') return; + if (data.self && data.self.id) badgeSelfUserId = data.self.id; + const route = badgeCurrentRoute(); + const active = route.team || badgeTeamId(data.team) || badgeTeamId(data); + const teams = Array.from(collectWorkspaceTeams(data.workspaces || data)) + .filter(team => /^T[A-Z0-9]{7,}$/.test(team)); + if (active) { + if (!teams.includes(active)) teams.push(active); + if (teams.length > workspaceOrder.length) setWorkspaceOrder(teams); + if (data.team) { + rememberWorkspaceAlias(active, data.team.name || data.team.team_name, false); + rememberWorkspaceAlias(active, data.team.domain || data.team.url || data.team.team_url, true); + } + } + if (teams.length) { + tauriInvoke('register_workspaces', { teams, active }).catch(() => {}); + } + } catch (_) {} +} + +function badgeIsDmChannel(channel, path = '') { + return (!!channel && String(channel).startsWith('D')) || /(^|\.)(ims|mpims)(\.|\[|$)/i.test(path); +} + +function badgeCleanTitle(title) { + return (title || 'Zlack') + .replace(BADGE_TITLE_COUNT_MARKER, '') + .replace(BADGE_TITLE_UNREAD_MARKER, '') + .replace(/^\s*!+\s*/, '') + .trim() || 'Zlack'; +} + +function badgeCurrentRoute() { + const m = window.location.pathname.match(/\/client\/([^/]+)(?:\/([^/?#]+))?/); + return m ? { team: m[1], channel: m[2] || null } : { team: null, channel: null }; +} + +function badgeStateFromCounts(obj, path = '', channelHint = null) { + if (!obj || typeof obj !== 'object') return { state: 'none', hasCounts: false }; + + const channel = badgeChannelId(obj) || channelHint; + const mentionCount = badgeMaxCount(obj, BADGE_MENTION_COUNT_KEYS); + const unreadCount = badgeMaxCount(obj, BADGE_UNREAD_COUNT_KEYS); + const hasExplicitCount = BADGE_MENTION_COUNT_KEYS + .concat(BADGE_UNREAD_COUNT_KEYS) + .some(key => Object.prototype.hasOwnProperty.call(obj, key)); + const hasMentionBool = BADGE_MENTION_BOOL_KEYS.some(key => obj[key] === true || obj[key] === 'true'); + const hasUnreadBool = BADGE_UNREAD_BOOL_KEYS.some(key => obj[key] === true || obj[key] === 'true'); + + if (mentionCount > 0 || hasMentionBool) return { state: 'mention', hasCounts: true }; + if (unreadCount > 0 || hasUnreadBool) { + return { + state: badgeIsDmChannel(channel, path) ? 'mention' : 'unread', + hasCounts: true, + }; + } + if (hasExplicitCount) return { state: 'none', hasCounts: true }; + + return { state: 'none', hasCounts: false }; +} + +function badgeAggregateState() { + let state = 'none'; + for (const snapshot of badgeCountSnapshots.values()) { + for (const item of snapshot.values()) { + if (BADGE_STATE_PRIORITY[item.state] > BADGE_STATE_PRIORITY[state]) state = item.state; + } + } + for (const item of badgeRealtimeStates.values()) { + if (BADGE_STATE_PRIORITY[item.state] > BADGE_STATE_PRIORITY[state]) state = item.state; + } + return state; +} + +function badgeSetRealtime(key, item) { + if (!key) return; + if (!item || item.state === 'none') badgeRealtimeStates.delete(key); + else badgeRealtimeStates.set(key, item); +} + +function badgePush() { + badgeClearCurrentVisibleChannel(); + const state = badgeAggregateState(); + const title = badgeWindowTitle || badgeCleanTitle(document.title) || 'Zlack'; + if (state === badgeLastSent.state && title === badgeLastSent.title) return; + badgeLastSent = { state, title }; + try { + window.dispatchEvent(new Event('zlack-badge-state-changed')); + } catch (_) {} + try { + // The taskbar overlay is a dot only, so no unread count is needed. + const team = badgeCurrentRoute().team; + tauriInvoke('update_badge', { state, title, count: null, team }).catch(() => {}); + } catch (_) {} +} + +function badgeCollectSnapshot(value, path = '', out = [], depth = 0, context = {}) { + if (!value || depth > 8) return out; + if (Array.isArray(value)) { + value.forEach((item, index) => badgeCollectSnapshot(item, `${path}[${index}]`, out, depth + 1, context)); + return out; + } + if (typeof value !== 'object') return out; + + const channel = badgeChannelId(value) || context.channel || null; + const team = badgeTeamId(value) || context.team || null; + const nextContext = { channel, team }; + const countState = badgeStateFromCounts(value, path, channel); + if (channel && countState.hasCounts && countState.state !== 'none') { + out.push({ + key: `${team || 'team'}:${channel}`, + state: countState.state, + }); + } + + for (const [key, child] of Object.entries(value)) { + if (child && typeof child === 'object') { + badgeCollectSnapshot(child, path ? `${path}.${key}` : key, out, depth + 1, badgeContextFromKey(key, nextContext)); + } + } + return out; +} + +function badgeSourceKey(url, data) { + const route = badgeCurrentRoute(); + const cacheMatch = String(url || '').match(/\/cache\/([^/]+)\/users\/counts\b/i); + if (cacheMatch) return `users-counts:${cacheMatch[1]}`; + return `client-counts:${badgeTeamId(data) || route.team || window.location.hostname}`; +} + +function processBadgeCountsResponse(url, textOrObject) { + if (!isBadgeCountsUrl(url)) return; + try { + const data = typeof textOrObject === 'string' ? JSON.parse(textOrObject) : textOrObject; + if (!data || typeof data !== 'object') return; + if (data.self && data.self.id) badgeSelfUserId = data.self.id; + + const snapshot = new Map(); + for (const item of badgeCollectSnapshot(data)) { + snapshot.set(item.key, { state: item.state }); + } + badgeCountSnapshots.set(badgeSourceKey(url, data), snapshot); + badgePush(); + } catch (_) {} +} + +function badgeClearChannel(obj) { + const team = badgeTeamId(obj); + const channel = badgeChannelId(obj); + if (!channel) return false; + + let changed = false; + if (badgeRealtimeStates.delete(`${team || 'team'}:${channel}`)) changed = true; + for (const key of Array.from(badgeRealtimeStates.keys())) { + if (key.endsWith(`:${channel}`) || key === channel) { + badgeRealtimeStates.delete(key); + changed = true; + } + } + for (const snapshot of badgeCountSnapshots.values()) { + if (snapshot.delete(`${team || 'team'}:${channel}`)) changed = true; + for (const key of Array.from(snapshot.keys())) { + if (key.endsWith(`:${channel}`) || key === channel) { + snapshot.delete(key); + changed = true; + } + } + } + return changed; +} + +function badgeClearCurrentVisibleChannel() { + const route = badgeCurrentRoute(); + if (!route.team || !route.channel || !document.hasFocus() || document.visibilityState === 'hidden') return false; + return badgeClearChannel({ team_id: route.team, channel_id: route.channel }); +} + +function badgeClearCurrentVisibleChannelAndPush() { + if (badgeClearCurrentVisibleChannel()) badgePush(); +} + +function badgeProcessRealtimeObject(obj, depth = 0, context = {}) { + if (!obj || typeof obj !== 'object' || depth > 5) return; + if (obj.self && obj.self.id) badgeSelfUserId = obj.self.id; + + // Reaction events include an `item` that looks like a message reference + // (`{ type: 'message', channel, ts }`). Don't treat that reference as a + // new unread message. + if (obj.type === 'reaction_added' || obj.type === 'reaction_removed') return; + + const channel = badgeChannelId(obj) || context.channel || null; + const team = badgeTeamId(obj) || context.team || null; + const nextContext = { channel, team }; + + if (Array.isArray(obj.clearing_data)) { + obj.clearing_data.forEach(badgeClearChannel); + badgeClearChannel({ ...obj, channel_id: channel, team_id: team }); + badgePush(); + } + + const countState = badgeStateFromCounts(obj, 'event', channel); + if (channel && countState.hasCounts) { + if (countState.state === 'none') { + badgeClearChannel({ ...obj, team_id: team, channel_id: channel }); + } else { + badgeSetRealtime(`${team || 'team'}:${channel}`, { state: countState.state }); + } + badgePush(); + } + + if (obj.type === 'message' && channel) { + const route = badgeCurrentRoute(); + const isVisibleChannel = document.hasFocus() && channel === route.channel && (!team || !route.team || team === route.team); + if (!isVisibleChannel && !obj.subtype && (!badgeSelfUserId || obj.user !== badgeSelfUserId)) { + const text = typeof obj.text === 'string' ? obj.text : ''; + const mentionedSelf = badgeSelfUserId && text.includes(`<@${badgeSelfUserId}>`); + const state = (badgeIsDmChannel(channel) || mentionedSelf) ? 'mention' : 'unread'; + badgeSetRealtime(`${team || 'team'}:${channel}`, { state }); + badgePush(); + } + } + + for (const [key, child] of Object.entries(obj)) { + if (child && typeof child === 'object') { + badgeProcessRealtimeObject(child, depth + 1, badgeContextFromKey(key, nextContext)); + } + } +} + +function processBadgeSocketMessage(data) { + if (typeof data !== 'string') return; + try { + badgeProcessRealtimeObject(JSON.parse(data)); + } catch (_) {} +} + (function setupBadgeBridge() { - const UNREAD_MARKER = /^\s*[\*•●·⁕∗∘◦]+/; - const COUNT_MARKER = /^\s*\((\d+)\)/; - - let lastState = null; - let lastTitle = null; - let lastCount = null; - - function classify(title) { - if (!title) return 'none'; - if (COUNT_MARKER.test(title)) return 'mention'; - if (UNREAD_MARKER.test(title)) return 'unread'; - return 'none'; - } - - function cleanTitle(title) { - return (title || 'Zlack') - .replace(COUNT_MARKER, '') - .replace(UNREAD_MARKER, '') - .trim() || 'Zlack'; - } - - function push() { - const raw = document.title || 'Zlack'; - const state = classify(raw); - const clean = cleanTitle(raw); - const m = COUNT_MARKER.exec(raw); - const count = m ? parseInt(m[1], 10) : null; - if (state === lastState && clean === lastTitle && count === lastCount) return; - lastState = state; - lastTitle = clean; - lastCount = count; - try { - if (window.__TAURI__) { - window.__TAURI__.invoke('update_badge', { state: state, title: clean, count: count }); + if (window.WebSocket) { + const OriginalWebSocket = window.WebSocket; + window.WebSocket = function(url, protocols) { + const ws = protocols === undefined ? new OriginalWebSocket(url) : new OriginalWebSocket(url, protocols); + try { + ws.addEventListener('message', event => processBadgeSocketMessage(event.data)); + } catch (_) {} + return ws; + }; + window.WebSocket.prototype = OriginalWebSocket.prototype; + Object.setPrototypeOf(window.WebSocket, OriginalWebSocket); + } + + function pushTitle() { + badgeWindowTitle = badgeCleanTitle(document.title || 'Zlack'); + badgePush(); + } + + function injectUserCss(css) { + if (!css || typeof css !== 'string') return; + let style = document.getElementById('zlack-user-css'); + if (!style) { + style = document.createElement('style'); + style.id = 'zlack-user-css'; + style.type = 'text/css'; + (document.head || document.documentElement).appendChild(style); + } + style.textContent = css; + } + + function loadUserCss() { + tauriInvoke('load_user_css').then(injectUserCss).catch(() => {}); + } + + function closestFromEventTarget(target, selector) { + const el = target && (target.closest ? target : target.parentElement); + return el && el.closest ? el.closest(selector) : null; + } + + function findNativeWorkspaceButton() { + return Array.from(document.querySelectorAll('button.p-account_switcher[data-qa="account_switcher_team_icon"], button[data-qa="account_switcher_team_icon"]')) + .find(button => !button.closest('#zlack-workspace-switcher')) || null; + } + + function workspaceFallbackName(team) { + return team || 'Workspace'; + } + + function workspaceInitials(name, team) { + const text = String(name || '').trim(); + if (text) { + const initials = text.split(/\s+/).map(part => part[0]).join('').slice(0, 2).toUpperCase(); + if (initials) return initials; + } + return String(team || '?').replace(/^T/, '').slice(0, 2).toUpperCase() || '?'; + } + + function workspaceNameFromNativeButton(button, iconEl) { + let name = iconEl?.getAttribute('aria-label') || ''; + if (!name) { + const label = button?.getAttribute('aria-label') || ''; + const match = label.match(/\(([^()]+)\)\s*$/); + name = match ? match[1] : label.replace(/^Switch workspaces…?\s*/i, '').trim(); + } + return name || ''; + } + + function currentWorkspaceMeta() { + const team = badgeCurrentRoute().team; + const button = findNativeWorkspaceButton(); + if (!team || !button) return null; + const iconEl = button.querySelector('[data-qa="team-icon"], .c-team_icon'); + const computed = iconEl ? window.getComputedStyle(iconEl) : null; + const name = workspaceNameFromNativeButton(button, iconEl); + const backgroundImage = (iconEl?.style?.backgroundImage || computed?.backgroundImage || '').trim(); + const backgroundColor = (iconEl?.style?.backgroundColor || computed?.backgroundColor || '').trim(); + const iconText = (iconEl?.textContent || '').trim() || workspaceInitials(name, team); + return { + team, + name, + iconImage: backgroundImage && backgroundImage !== 'none' ? backgroundImage : '', + iconText, + iconColor: backgroundColor && !/^rgba\(\s*0\s*,\s*0\s*,\s*0\s*,\s*0\s*\)$/i.test(backgroundColor) ? backgroundColor : '', + }; + } + + function sendCurrentWorkspaceMeta() { + const meta = currentWorkspaceMeta(); + if (!meta) return; + const serialized = JSON.stringify(meta); + if (serialized === workspaceMetaLastSent) return; + workspaceMetaLastSent = serialized; + tauriInvoke('update_workspace_meta', { + team: meta.team, + name: meta.name, + iconImage: meta.iconImage, + iconText: meta.iconText, + iconColor: meta.iconColor, + }).catch(() => {}); + } + + function workspaceInfoByTeam(team) { + const workspaces = Array.isArray(workspaceButtonsStatus?.workspaces) ? workspaceButtonsStatus.workspaces : []; + return workspaces.find(item => item && item.team === team) || null; + } + + function workspaceBadgeState(team) { + const statusBadge = workspaceInfoByTeam(team)?.badge || 'none'; + if (team === badgeCurrentRoute().team) { + const localBadge = badgeAggregateState(); + return localBadge !== 'none' ? localBadge : statusBadge; + } + return statusBadge; + } + + function setWorkspaceBadge(button, state) { + button.style.setProperty('position', 'relative', 'important'); + let badge = button.querySelector(':scope > .zlack-workspace-badge'); + if (!badge) { + badge = document.createElement('span'); + badge.className = 'zlack-workspace-badge'; + badge.setAttribute('aria-hidden', 'true'); + button.appendChild(badge); + } + Object.assign(badge.style, { + display: state === 'mention' || state === 'unread' ? 'block' : 'none', + position: 'absolute', + top: '3px', + right: '3px', + width: '11px', + height: '11px', + borderRadius: '999px', + border: '2px solid var(--sk_primary_background, #1a1d21)', + background: state === 'mention' ? '#e01e5a' : '#36c5f0', + zIndex: '2', + pointerEvents: 'none', + }); + } + + function applyWorkspaceIcon(button, info) { + let icon = button.querySelector('.zlack-workspace-team-icon'); + if (!icon) { + icon = document.createElement('i'); + icon.className = 'c-team_icon p-account_switcher__team_icon zlack-workspace-team-icon'; + icon.setAttribute('role', 'img'); + icon.setAttribute('aria-hidden', 'true'); + icon.setAttribute('data-qa', 'team-icon'); + button.appendChild(icon); + } + const name = info.name || workspaceFallbackName(info.team); + const iconImage = info.icon_image || info.iconImage || ''; + icon.setAttribute('aria-label', name); + Object.assign(icon.style, { + height: '36px', + width: '36px', + minWidth: '36px', + fontSize: '18px', + lineHeight: '36px', + textAlign: 'center', + whiteSpace: 'nowrap', + backgroundImage: iconImage || '', + backgroundColor: info.icon_color || info.iconColor || '', + backgroundSize: 'cover', + backgroundPosition: 'center', + }); + icon.textContent = iconImage ? '' : (info.icon_text || info.iconText || workspaceInitials(name, info.team)); + } + + function renderWorkspaceButtons() { + const currentTeam = badgeCurrentRoute().team; + const nativeButton = findNativeWorkspaceButton(); + if (!currentTeam || !nativeButton) return; + + const currentInfo = workspaceInfoByTeam(currentTeam) || currentWorkspaceMeta() || { team: currentTeam }; + const currentName = currentInfo.name || workspaceNameFromNativeButton(nativeButton, nativeButton.querySelector('[data-qa="team-icon"], .c-team_icon')) || workspaceFallbackName(currentTeam); + nativeButton.classList.add('zlack-workspace-native-button'); + nativeButton.title = currentName; + setWorkspaceBadge(nativeButton, workspaceBadgeState(currentTeam)); + + const trigger = nativeButton.closest('.p-peek_trigger') || nativeButton.parentElement; + if (!trigger || !trigger.parentElement) return; + let container = document.getElementById('zlack-workspace-switcher'); + if (!container) { + container = document.createElement('div'); + container.id = 'zlack-workspace-switcher'; + container.setAttribute('role', 'none'); + } + container.style.display = 'contents'; + if (container.parentElement !== trigger.parentElement || container.previousElementSibling !== trigger) { + trigger.after(container); + } + + container.textContent = ''; + const workspaces = Array.isArray(workspaceButtonsStatus?.workspaces) ? workspaceButtonsStatus.workspaces : []; + for (const info of workspaces) { + if (!info || !info.team || info.team === currentTeam) continue; + const name = info.name || workspaceFallbackName(info.team); + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'c-button-unstyled p-account_switcher zlack-workspace-native-button'; + button.dataset.zlackWorkspaceTeam = info.team; + button.setAttribute('aria-label', name); + button.title = name; + applyWorkspaceIcon(button, info); + setWorkspaceBadge(button, info.badge || 'none'); + container.appendChild(button); + } + } + + function scheduleWorkspaceButtonsRender() { + if (workspaceButtonsRenderScheduled) return; + workspaceButtonsRenderScheduled = true; + requestAnimationFrame(() => { + workspaceButtonsRenderScheduled = false; + sendCurrentWorkspaceMeta(); + renderWorkspaceButtons(); + }); + } + + function applyWorkspaceStatus(status) { + if (status && Array.isArray(status.workspaces)) workspaceButtonsStatus = status; + scheduleWorkspaceButtonsRender(); + } + + function requestWorkspaceButtonStatus() { + sendCurrentWorkspaceMeta(); + renderWorkspaceButtons(); + if (workspaceButtonsRequestInFlight) return; + workspaceButtonsRequestInFlight = true; + tauriInvoke('workspace_status', { current: badgeCurrentRoute().team }, 3000) + .then(applyWorkspaceStatus) + .catch(() => {}) + .finally(() => { workspaceButtonsRequestInFlight = false; }); + } + + function maybeHandleWorkspaceButtonEvent(event) { + const button = closestFromEventTarget(event.target, '#zlack-workspace-switcher button[data-zlack-workspace-team]'); + if (!button) return; + + event.preventDefault(); + event.stopImmediatePropagation(); + + if (!['pointerup', 'mouseup', 'click'].includes(event.type)) return; + const team = button.dataset.zlackWorkspaceTeam; + const currentTeam = badgeCurrentRoute().team; + if (!team || !currentTeam || team === currentTeam) return; + + const now = Date.now(); + if (lastWorkspaceSwitch.team === team && now - lastWorkspaceSwitch.ts < 750) return; + lastWorkspaceSwitch = { team, ts: now }; + closeWorkspaceSwitcherPopover(); + tauriInvoke('switch_workspace', { team, url: null }).catch(() => {}); + } + + window.__ZlackWorkspaceStatus = applyWorkspaceStatus; + window.addEventListener('zlack-badge-state-changed', scheduleWorkspaceButtonsRender); + + function switchInfoFromSwitcherButton(target) { + const button = closestFromEventTarget(target, 'button.p-team_switcher_menu__item, button[class*="team_switcher"], button[role="menuitemradio"]'); + if (!button) return null; + + const domain = normalizeWorkspaceDomain(button.querySelector('.p-account_switcher__row_url')?.textContent); + let team = null; + if (domain && workspaceTeamByDomain.has(domain)) team = workspaceTeamByDomain.get(domain); + if (!team && domain && workspaceTeamByDomain.has(`${domain}.slack.com`)) team = workspaceTeamByDomain.get(`${domain}.slack.com`); + + let name = button.querySelector('.p-account_switcher__row_name')?.textContent; + const labelledBy = button.getAttribute('aria-labelledby'); + if (!name && labelledBy) { + const labelId = labelledBy.split(/\s+/).find(Boolean); + name = labelId && document.getElementById(labelId)?.textContent; + } + if (!name) name = button.querySelector('[data-qa="team-icon"]')?.getAttribute('aria-label'); + name = normalizeWorkspaceName(name); + if (!team) team = workspaceTeamByName.get(name) || null; + + if (team) return { team, url: null }; + + // Do not intercept if we only know the workspace domain. Tauri v1 remote + // IPC is exact-domain scoped, so loading a workspace subdomain such as + // foo.slack.com breaks badge IPC. Let Slack do its normal switch; once that + // workspace boots, client.init gives us the real team id for future swaps. + return null; + } + + function closeWorkspaceSwitcherPopover() { + const popover = document.querySelector('.ReactModal__Content.c-popover__content, .ReactModal__Content.popover'); + const eventInit = { + key: 'Escape', + code: 'Escape', + keyCode: 27, + which: 27, + bubbles: true, + cancelable: true, + }; + for (const target of [popover, document.activeElement, document, window].filter(Boolean)) { + try { + target.dispatchEvent(new KeyboardEvent('keydown', eventInit)); + target.dispatchEvent(new KeyboardEvent('keyup', eventInit)); + } catch (_) {} + } + } + + function maybeSwitchWorkspace(event) { + const anchor = closestFromEventTarget(event.target, 'a[href]'); + let team = null; + let switchUrl = null; + + if (anchor) { + try { + const url = new URL(anchor.href, window.location.href); + if (/slack\.com$/i.test(url.hostname) || /\.slack\.com$/i.test(url.hostname)) { + const m = url.pathname.match(/^\/client\/([^/]+)(?:\/([^/?#]+))?/); + if (m) { + team = m[1]; + switchUrl = url.href; + } + } + } catch (_) {} + } + + if (!team) { + const info = switchInfoFromSwitcherButton(event.target); + if (info) { + team = info.team; + switchUrl = info.url; } - } catch (e) { - console.error('Zlack: update_badge failed', e); } + + const currentTeam = badgeCurrentRoute().team; + if (!team || !currentTeam || team === currentTeam) return; + + event.preventDefault(); + event.stopImmediatePropagation(); + + const now = Date.now(); + if (lastWorkspaceSwitch.team === team && now - lastWorkspaceSwitch.ts < 750) { + // pointerdown/mousedown/click can all fire for the same button press. + // Keep suppressing Slack's native handler, but invoke Rust only once. + return; + } + lastWorkspaceSwitch = { team, ts: now }; + + closeWorkspaceSwitcherPopover(); + tauriInvoke('switch_workspace', { team, url: switchUrl }).catch(() => {}); + } + + function maybeSwitchWorkspaceShortcut(event) { + if (!event.ctrlKey || event.altKey || event.metaKey || event.shiftKey || event.repeat) return; + + let digit = null; + if (/^Digit[1-9]$/.test(event.code || '')) digit = event.code.slice(5); + else if (/^Numpad[1-9]$/.test(event.code || '')) digit = event.code.slice(6); + else if (/^[1-9]$/.test(event.key || '')) digit = event.key; + if (!digit) return; + + const teams = workspaceOrder.length ? workspaceOrder : loadWorkspaceCache(); + const team = teams[parseInt(digit, 10) - 1]; + if (!team) return; + + event.preventDefault(); + event.stopImmediatePropagation(); + + const currentTeam = badgeCurrentRoute().team; + if (team === currentTeam) return; + + const now = Date.now(); + if (lastWorkspaceSwitch.team === team && now - lastWorkspaceSwitch.ts < 750) return; + lastWorkspaceSwitch = { team, ts: now }; + + tauriInvoke('switch_workspace', { team, url: null }).catch(() => {}); + } + + function registerCachedWorkspacesWhenReady() { + if (didRegisterCachedWorkspaces) return; + const active = badgeCurrentRoute().team; + if (!active) return; + const cachedTeams = loadWorkspaceCache(); + if (!cachedTeams.length) return; + didRegisterCachedWorkspaces = true; + tauriInvoke('register_workspaces', { teams: cachedTeams, active }).catch(() => {}); } function start() { const head = document.querySelector('head'); if (head) { - // subtree covers <title> being replaced by Slack's SPA, plus its text edits. - new MutationObserver(push).observe(head, { + new MutationObserver(pushTitle).observe(head, { subtree: true, childList: true, characterData: true, }); } - // Safety net in case the observed nodes get swapped out entirely. - setInterval(push, 3000); - push(); + const workspaceObserverRoot = document.body || document.documentElement; + if (workspaceObserverRoot) { + const nativeButtonSelector = 'button.p-account_switcher[data-qa="account_switcher_team_icon"], button[data-qa="account_switcher_team_icon"]'; + new MutationObserver(records => { + const changedWorkspaceButton = records.some(record => { + const target = record.target; + if (target && target.closest && target.closest('#zlack-workspace-switcher')) return false; + return Array.from(record.addedNodes).concat(Array.from(record.removedNodes)).some(node => { + if (!node || node.nodeType !== 1) return false; + return node.matches?.(nativeButtonSelector) || node.querySelector?.(nativeButtonSelector); + }); + }); + if (changedWorkspaceButton) scheduleWorkspaceButtonsRender(); + }).observe(workspaceObserverRoot, { childList: true, subtree: true }); + } + document.addEventListener('pointerdown', maybeHandleWorkspaceButtonEvent, true); + document.addEventListener('mousedown', maybeHandleWorkspaceButtonEvent, true); + document.addEventListener('pointerup', maybeHandleWorkspaceButtonEvent, true); + document.addEventListener('mouseup', maybeHandleWorkspaceButtonEvent, true); + document.addEventListener('click', maybeHandleWorkspaceButtonEvent, true); + document.addEventListener('pointerdown', maybeSwitchWorkspace, true); + document.addEventListener('mousedown', maybeSwitchWorkspace, true); + document.addEventListener('mouseup', maybeSwitchWorkspace, true); + document.addEventListener('click', maybeSwitchWorkspace, true); + document.addEventListener('keydown', maybeSwitchWorkspaceShortcut, true); + document.addEventListener('pointerup', badgeClearCurrentVisibleChannelAndPush, true); + document.addEventListener('keyup', badgeClearCurrentVisibleChannelAndPush, true); + document.addEventListener('visibilitychange', badgeClearCurrentVisibleChannelAndPush, true); + window.addEventListener('focus', badgeClearCurrentVisibleChannelAndPush, true); + window.addEventListener('popstate', badgeClearCurrentVisibleChannelAndPush, true); + setInterval(() => { + badgeClearCurrentVisibleChannelAndPush(); + registerCachedWorkspacesWhenReady(); + pushTitle(); + }, 3000); + setInterval(pollBadgeCounts, BADGE_COUNTS_POLL_MS); + loadUserCss(); + registerCachedWorkspacesWhenReady(); + requestWorkspaceButtonStatus(); + pushTitle(); } if (document.readyState === 'loading') { @@ -252,32 +1257,154 @@ Object.defineProperty(window, 'Notification', { })(); -// 4. Intercept External Links +// 4. Intercept links/new windows. +// External links still open in the OS browser. Slack app/permalink links that +// Slack marks as a new tab are retargeted to this WebView so Slack's own router +// can handle them without a full app reload when possible. +const ZLACK_SLACK_HOST_RE = /(^|\.)slack\.com$/i; + +function zlackUrl(href) { + try { + return new URL(href, window.location.href); + } catch (_) { + return null; + } +} + +function zlackIsHttpUrl(url) { + return url && (url.protocol === 'http:' || url.protocol === 'https:'); +} + +function zlackIsSlackUrl(url) { + return zlackIsHttpUrl(url) && ZLACK_SLACK_HOST_RE.test(url.hostname || ''); +} + +function zlackIsSlackAppPath(pathname) { + return /^\/(?:client|archives)(?:\/|$)/i.test(pathname || ''); +} + +function zlackTeamForSlackUrl(url) { + const queryTeam = url.searchParams.get('team') || url.searchParams.get('team_id'); + if (/^T[A-Z0-9]{7,}$/.test(queryTeam || '')) return queryTeam; + + const clientMatch = url.pathname.match(/^\/client\/([^/?#]+)/i); + if (/^T[A-Z0-9]{7,}$/.test(clientMatch?.[1] || '')) return clientMatch[1]; + + const domain = normalizeWorkspaceDomain(url.hostname); + return workspaceTeamByDomain.get(domain) || badgeCurrentRoute().team || null; +} + +function zlackSlackAppHref(href) { + const url = zlackUrl(href); + if (!zlackIsSlackUrl(url) || !zlackIsSlackAppPath(url.pathname)) return null; + + if (/^\/client(?:\/|$)/i.test(url.pathname)) return url.href; + + const team = zlackTeamForSlackUrl(url); + const archiveMessageMatch = url.pathname.match(/^\/archives\/([^/?#]+)\/p(\d{10})(\d{6})/i); + if (archiveMessageMatch && team) { + const channel = archiveMessageMatch[1]; + const ts = `${archiveMessageMatch[2]}${archiveMessageMatch[3]}`; + const appUrl = new URL(`https://app.slack.com/client/${encodeURIComponent(team)}/${encodeURIComponent(channel)}/thread/${encodeURIComponent(channel)}-p${ts}`); + appUrl.search = url.search; + appUrl.hash = url.hash; + return appUrl.href; + } + + const archiveChannelMatch = url.pathname.match(/^\/archives\/([^/?#]+)/i); + if (archiveChannelMatch && team) { + const channel = archiveChannelMatch[1]; + const appUrl = new URL(`https://app.slack.com/client/${encodeURIComponent(team)}/${encodeURIComponent(channel)}`); + appUrl.search = url.search; + appUrl.hash = url.hash; + return appUrl.href; + } + + return url.href; +} + +function zlackRetargetSlackAnchor(anchor) { + const appHref = zlackSlackAppHref(anchor.href); + if (!appHref) return false; + + const oldHref = anchor.getAttribute('href'); + const oldTarget = anchor.getAttribute('target'); + anchor.setAttribute('href', appHref); + anchor.removeAttribute('target'); + + setTimeout(() => { + if (oldHref == null) anchor.removeAttribute('href'); + else anchor.setAttribute('href', oldHref); + if (oldTarget == null) anchor.removeAttribute('target'); + else anchor.setAttribute('target', oldTarget); + }, 0); + + return true; +} + +function zlackOpenSlackUrlInCurrentWindow(href) { + const appHref = zlackSlackAppHref(href); + if (!appHref) return false; + + const anchor = document.createElement('a'); + anchor.href = appHref; + anchor.style.display = 'none'; + anchor.setAttribute('data-zlack-synthetic-link', 'true'); + (document.body || document.documentElement).appendChild(anchor); + + const event = new MouseEvent('click', { + bubbles: true, + cancelable: true, + view: window, + }); + const notCanceled = anchor.dispatchEvent(event); + anchor.remove(); + + if (notCanceled && !event.defaultPrevented && window.location.href !== appHref) { + window.location.href = appHref; + } + return true; +} + +window.__ZlackNavigateSlackUrl = zlackOpenSlackUrlInCurrentWindow; + +const originalWindowOpen = window.open; +window.open = function(url, target, features) { + const href = typeof url === 'string' ? url : (url && String(url)); + const parsed = href ? zlackUrl(href) : null; + + if (zlackSlackAppHref(href)) { + zlackOpenSlackUrlInCurrentWindow(href); + return null; + } + + if (zlackIsHttpUrl(parsed) && !zlackIsSlackUrl(parsed)) { + if (window.__TAURI__) window.__TAURI__.shell.open(parsed.href); + return null; + } + + return originalWindowOpen.apply(this, arguments); +}; + document.addEventListener('click', (e) => { - const target = e.target.closest('a'); - if (target && target.href) { - // Check if it's an external link (http/https) and NOT part of the Slack app itself - const href = target.href; - const isExternal = href.startsWith('http') && - !href.includes('app.slack.com') && - !href.includes('slack.com'); - - const opensInNewTab = target.target === '_blank'; - - if (isExternal) { - console.log("Zlack: Intercepted external link click:", href); - e.preventDefault(); - e.stopPropagation(); - if (window.__TAURI__) { - window.__TAURI__.shell.open(href); - } - } else if (opensInNewTab) { - // Internal link meant for new tab -> force open in this window - console.log("Zlack: Force-opening internal new-tab link in same window:", href); - e.preventDefault(); - e.stopPropagation(); - window.location.href = href; - } + const target = e.target && e.target.closest ? e.target.closest('a[href]') : null; + if (!target || !target.href) return; + + const href = target.href; + const parsed = zlackUrl(href); + const isExternal = zlackIsHttpUrl(parsed) && !zlackIsSlackUrl(parsed); + + if (isExternal) { + e.preventDefault(); + e.stopImmediatePropagation(); + if (window.__TAURI__) window.__TAURI__.shell.open(parsed.href); + return; + } + + if (target.target === '_blank') { + // Do not prevent default here. Just remove the new-tab behavior for this + // event turn and let Slack's own click handling do the in-app navigation. + zlackRetargetSlackAnchor(target); } }, true); // Capture phase to ensure we get it before Slack diff --git a/src-tauri/src/icons.rs b/src-tauri/src/icons.rs new file mode 100644 index 0000000..ff10367 --- /dev/null +++ b/src-tauri/src/icons.rs @@ -0,0 +1,405 @@ +use tauri::Icon; + +#[cfg(target_os = "windows")] +use windows::{ + core::PCWSTR, + Win32::Foundation::{HANDLE, HWND, LPARAM, WPARAM}, + Win32::Graphics::Gdi::{ + CreateBitmap, CreateDIBSection, DeleteObject, BITMAPINFO, BITMAPINFOHEADER, DIB_RGB_COLORS, + HDC, HGDIOBJ, + }, + Win32::System::Com::{ + CoCreateInstance, CoInitializeEx, CLSCTX_INPROC_SERVER, COINIT_APARTMENTTHREADED, + }, + Win32::UI::Shell::{ITaskbarList3, TaskbarList}, + Win32::UI::WindowsAndMessaging::{ + CreateIconIndirect, DestroyIcon, SendMessageW, HICON, ICONINFO, ICON_BIG, ICON_SMALL, + WM_SETICON, + }, +}; + +use crate::exe_sibling; + +// --- Tray icon badges ------------------------------------------------------- +// We composite a small coloured dot onto the base tray icon to mirror Slack's +// behaviour: a red badge for unread DMs / @mentions and a blue badge for other +// unread messages. The three variants are built once and reused. + +const TRAY_ICON_SIZE: u32 = 32; +const WINDOW_ICON_SIZE: u32 = 256; +pub(crate) const BADGE_BLUE: [u8; 3] = [0x09, 0xc1, 0xf5]; +pub(crate) const BADGE_RED: [u8; 3] = [224, 30, 90]; + +const BADGE_ANTIALIAS_SAMPLES: i32 = 4; +const TRAY_BADGE_RADIUS: f32 = 9.5; +const TRAY_BADGE_OUTLINE_WIDTH: f32 = 1.5; + +#[cfg(target_os = "windows")] +const TASKBAR_BADGE_ICON_SIZE: i32 = 32; +#[cfg(target_os = "windows")] +const TASKBAR_BADGE_RADIUS: f32 = 13.0; +#[cfg(target_os = "windows")] +const TASKBAR_BADGE_OUTLINE_WIDTH: f32 = 2.0; + +fn blend_pixel(img: &mut image::RgbaImage, x: u32, y: u32, src: [u8; 4]) { + let src_a = src[3] as f32 / 255.0; + if src_a <= 0.0 { + return; + } + let dst = img.get_pixel(x, y).0; + let dst_a = dst[3] as f32 / 255.0; + let out_a = src_a + dst_a * (1.0 - src_a); + if out_a <= 0.0 { + return; + } + + let mut out = [0u8; 4]; + for i in 0..3 { + let src_c = src[i] as f32 / 255.0; + let dst_c = dst[i] as f32 / 255.0; + out[i] = (((src_c * src_a + dst_c * dst_a * (1.0 - src_a)) / out_a) * 255.0) + .round() + .clamp(0.0, 255.0) as u8; + } + out[3] = (out_a * 255.0).round().clamp(0.0, 255.0) as u8; + img.put_pixel(x, y, image::Rgba(out)); +} + +fn draw_badge_circle( + img: &mut image::RgbaImage, + color: [u8; 3], + cx: f32, + cy: f32, + outer_radius: f32, + outline_width: f32, +) { + let inner_radius = (outer_radius - outline_width).max(0.0); + let min_x = (cx - outer_radius - 1.0).floor() as i32; + let max_x = (cx + outer_radius + 1.0).ceil() as i32; + let min_y = (cy - outer_radius - 1.0).floor() as i32; + let max_y = (cy + outer_radius + 1.0).ceil() as i32; + let sample_count = (BADGE_ANTIALIAS_SAMPLES * BADGE_ANTIALIAS_SAMPLES) as f32; + + for y in min_y..=max_y { + for x in min_x..=max_x { + if x < 0 || y < 0 || x >= img.width() as i32 || y >= img.height() as i32 { + continue; + } + + let mut a = 0.0; + let mut r = 0.0; + let mut g = 0.0; + let mut b = 0.0; + for sy in 0..BADGE_ANTIALIAS_SAMPLES { + for sx in 0..BADGE_ANTIALIAS_SAMPLES { + let px = x as f32 + (sx as f32 + 0.5) / BADGE_ANTIALIAS_SAMPLES as f32; + let py = y as f32 + (sy as f32 + 0.5) / BADGE_ANTIALIAS_SAMPLES as f32; + let dx = px - cx; + let dy = py - cy; + let dist = (dx * dx + dy * dy).sqrt(); + if dist <= inner_radius { + a += 1.0; + r += color[0] as f32; + g += color[1] as f32; + b += color[2] as f32; + } else if dist <= outer_radius { + a += 1.0; + } + } + } + + if a > 0.0 { + let alpha = a / sample_count; + blend_pixel( + img, + x as u32, + y as u32, + [ + (r / a).round() as u8, + (g / a).round() as u8, + (b / a).round() as u8, + (alpha * 255.0).round() as u8, + ], + ); + } + } + } +} + +fn draw_badge(img: &mut image::RgbaImage, color: [u8; 3]) { + let (w, h) = (img.width() as f32, img.height() as f32); + let r = TRAY_BADGE_RADIUS.min(w.min(h) / 2.0).max(1.0); + draw_badge_circle( + img, + color, + w - r - 1.0, + h - r - 1.0, + r, + TRAY_BADGE_OUTLINE_WIDTH, + ); +} + +fn load_named_icon_image(size: u32, names: &[&str]) -> image::RgbaImage { + let img = names + .iter() + .filter_map(|name| exe_sibling(name)) + .find_map(|path| { + std::fs::read(path) + .ok() + .and_then(|bytes| image::load_from_memory(&bytes).ok()) + }) + .unwrap_or_else(|| { + image::load_from_memory(include_bytes!("../icons/icon.png")) + .expect("Zlack: failed to decode bundled icon") + }) + .to_rgba8(); + + if img.width() == size && img.height() == size { + img + } else { + image::imageops::resize(&img, size, size, image::imageops::FilterType::Lanczos3) + } +} + +fn load_icon_image(size: u32) -> image::RgbaImage { + load_named_icon_image(size, &["zlack.png", "zlack.ico"]) +} + +#[cfg(target_os = "windows")] +fn load_taskbar_icon_image(size: u32) -> image::RgbaImage { + load_named_icon_image(size, &["zlack-taskbar.png", "zlack.png", "zlack.ico"]) +} + +fn icon_from_image(img: image::RgbaImage) -> Icon { + let (w, h) = (img.width(), img.height()); + Icon::Rgba { + rgba: img.into_raw(), + width: w, + height: h, + } +} + +fn make_icon(badge: Option<[u8; 3]>) -> Icon { + let mut img = load_icon_image(TRAY_ICON_SIZE); + if let Some(color) = badge { + draw_badge(&mut img, color); + } + icon_from_image(img) +} + +#[cfg(target_os = "windows")] +fn set_windows_window_icons(window: &tauri::Window) { + let hwnd = match window.hwnd() { + Ok(hwnd) => HWND(hwnd.0), + Err(_) => return, + }; + + unsafe { + let icons = [ + (load_icon_image(TRAY_ICON_SIZE), ICON_SMALL), + (load_taskbar_icon_image(WINDOW_ICON_SIZE), ICON_BIG), + ]; + for (img, icon_type) in icons { + let (w, h) = (img.width(), img.height()); + if let Some(hicon) = rgba_to_hicon(&img.into_raw(), w, h) { + // WM_SETICON keeps using the HICON handle; do not destroy it here. + let _ = SendMessageW( + hwnd, + WM_SETICON, + WPARAM(icon_type as usize), + LPARAM(hicon.0), + ); + } + } + } +} + +pub(crate) fn apply_window_icon(window: &tauri::Window) { + let _ = window.set_icon(ICON_WINDOW.clone()); + #[cfg(target_os = "windows")] + set_windows_window_icons(window); +} + +lazy_static::lazy_static! { + pub(crate) static ref ICON_WINDOW: Icon = icon_from_image(load_icon_image(WINDOW_ICON_SIZE)); + pub(crate) static ref ICON_NORMAL: Icon = make_icon(None); + pub(crate) static ref ICON_BLUE: Icon = make_icon(Some(BADGE_BLUE)); // general unread + pub(crate) static ref ICON_RED: Icon = make_icon(Some(BADGE_RED)); // DM / mention +} + +// --- Windows taskbar overlay icon ------------------------------------------ +// The tray badge above covers the hidden-to-tray case. When the window is +// visible on the taskbar we also overlay a small coloured badge on the taskbar +// button via ITaskbarList3 (red = DM/mention, blue = other unread), stamped +// with the unread DM/mention count when one is available. +#[cfg(target_os = "windows")] +const DIGITS_3X5: [[u8; 5]; 10] = [ + [0b111, 0b101, 0b101, 0b101, 0b111], // 0 + [0b010, 0b110, 0b010, 0b010, 0b111], // 1 + [0b111, 0b001, 0b111, 0b100, 0b111], // 2 + [0b111, 0b001, 0b111, 0b001, 0b111], // 3 + [0b101, 0b101, 0b111, 0b001, 0b001], // 4 + [0b111, 0b100, 0b111, 0b001, 0b111], // 5 + [0b111, 0b100, 0b111, 0b101, 0b111], // 6 + [0b111, 0b001, 0b010, 0b010, 0b010], // 7 + [0b111, 0b101, 0b111, 0b101, 0b111], // 8 + [0b111, 0b101, 0b111, 0b001, 0b111], // 9 +]; + +#[cfg(target_os = "windows")] +fn draw_overlay_digits(img: &mut image::RgbaImage, text: &str, size: i32) { + let chars: Vec<char> = text + .chars() + .filter(|c| c.is_ascii_digit() || *c == '+') + .collect(); + if chars.is_empty() { + return; + } + let scale: i32 = if chars.len() <= 1 { 5 } else { 3 }; + let glyph_w = 3 * scale; + let spacing = scale; + let total_w = chars.len() as i32 * glyph_w + (chars.len() as i32 - 1) * spacing; + let total_h = 5 * scale; + let mut x0 = (size - total_w) / 2; + let y0 = (size - total_h) / 2; + for ch in chars { + let rows: [u8; 5] = match ch { + '+' => [0b000, 0b010, 0b111, 0b010, 0b000], + d => DIGITS_3X5[(d as u8 - b'0') as usize], + }; + for (ry, bits) in rows.iter().enumerate() { + for col in 0..3i32 { + if (bits >> (2 - col)) & 1 == 1 { + for sy in 0..scale { + for sx in 0..scale { + let px = x0 + col * scale + sx; + let py = y0 + ry as i32 * scale + sy; + if px >= 0 && py >= 0 && px < size && py < size { + img.put_pixel( + px as u32, + py as u32, + image::Rgba([255, 255, 255, 255]), + ); + } + } + } + } + } + } + x0 += glyph_w + spacing; + } +} + +#[cfg(target_os = "windows")] +fn make_overlay_rgba(color: [u8; 3], count: Option<u32>) -> (Vec<u8>, u32, u32) { + let size = TASKBAR_BADGE_ICON_SIZE; + let mut img = image::RgbaImage::from_pixel(size as u32, size as u32, image::Rgba([0, 0, 0, 0])); + let c = (size as f32) / 2.0; + let r = TASKBAR_BADGE_RADIUS.min(size as f32 / 2.0).max(1.0); + draw_badge_circle(&mut img, color, c, c, r, TASKBAR_BADGE_OUTLINE_WIDTH); + if let Some(n) = count { + if n >= 1 { + let text = if n <= 99 { + n.to_string() + } else { + "+".to_string() + }; + draw_overlay_digits(&mut img, &text, size); + } + } + (img.into_raw(), size as u32, size as u32) +} + +#[cfg(target_os = "windows")] +fn rgba_to_hicon(rgba: &[u8], w: u32, h: u32) -> Option<HICON> { + unsafe { + // 32bpp top-down DIB section so the per-pixel alpha is preserved (a plain + // CreateBitmap DDB can drop alpha and render the badge as an opaque block). + let mut bmi: BITMAPINFO = std::mem::zeroed(); + bmi.bmiHeader.biSize = std::mem::size_of::<BITMAPINFOHEADER>() as u32; + bmi.bmiHeader.biWidth = w as i32; + bmi.bmiHeader.biHeight = -(h as i32); // top-down + bmi.bmiHeader.biPlanes = 1; + bmi.bmiHeader.biBitCount = 32; + // biCompression stays 0 (BI_RGB) from the zeroed struct. + let mut bits: *mut core::ffi::c_void = std::ptr::null_mut(); + let hbm_color = match CreateDIBSection( + HDC::default(), + &bmi, + DIB_RGB_COLORS, + &mut bits, + HANDLE::default(), + 0, + ) { + Ok(b) if !bits.is_null() && b.0 != 0 => b, + _ => return None, + }; + // Fill the DIB with BGRA pixels. + let dst = std::slice::from_raw_parts_mut(bits as *mut u8, rgba.len()); + let mut i = 0; + while i + 3 < rgba.len() { + dst[i] = rgba[i + 2]; + dst[i + 1] = rgba[i + 1]; + dst[i + 2] = rgba[i]; + dst[i + 3] = rgba[i + 3]; + i += 4; + } + // Monochrome AND mask, all zero (alpha channel drives transparency). + let mask_len = ((((w + 15) & !15) / 8) * h) as usize; + let mask = vec![0u8; mask_len]; + let hbm_mask = CreateBitmap(w as i32, h as i32, 1, 1, Some(mask.as_ptr() as *const _)); + let info = ICONINFO { + fIcon: true.into(), + xHotspot: 0, + yHotspot: 0, + hbmMask: hbm_mask, + hbmColor: hbm_color, + }; + let hicon = CreateIconIndirect(&info); + let _ = DeleteObject(HGDIOBJ(hbm_color.0)); + let _ = DeleteObject(HGDIOBJ(hbm_mask.0)); + match hicon { + Ok(h) if h.0 != 0 => Some(h), + _ => None, + } + } +} + +#[cfg(target_os = "windows")] +pub(crate) fn set_taskbar_overlay( + window: &tauri::Window, + color: Option<[u8; 3]>, + count: Option<u32>, +) { + let raw = match window.hwnd() { + Ok(h) => h.0, + Err(_) => return, + }; + let hwnd = HWND(raw); + unsafe { + let _ = CoInitializeEx(None, COINIT_APARTMENTTHREADED); + let taskbar: ITaskbarList3 = + match CoCreateInstance(&TaskbarList, None, CLSCTX_INPROC_SERVER) { + Ok(t) => t, + Err(_) => return, + }; + if taskbar.HrInit().is_err() { + return; + } + match color { + Some(c) => { + let (rgba, w, h) = make_overlay_rgba(c, count); + match rgba_to_hicon(&rgba, w, h) { + Some(hicon) => { + let _ = taskbar.SetOverlayIcon(hwnd, hicon, PCWSTR::null()); + let _ = DestroyIcon(hicon); + } + None => {} + } + } + None => { + let _ = taskbar.SetOverlayIcon(hwnd, HICON::default(), PCWSTR::null()); + } + } + } +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 32dd3b9..3d74611 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -3,477 +3,782 @@ windows_subsystem = "windows" )] -#[cfg(not(target_os = "windows"))] -use tauri::api::notification::Notification; +use serde::Serialize; +use std::{ + collections::{HashMap, HashSet, VecDeque}, + path::PathBuf, + sync::Mutex, +}; use tauri::{ - CustomMenuItem, Manager, SystemTray, SystemTrayEvent, SystemTrayMenu, SystemTrayMenuItem, - WindowEvent, + scope::ipc::RemoteDomainAccessScope, CustomMenuItem, Manager, PhysicalPosition, PhysicalSize, + Position, Size, SystemTray, SystemTrayEvent, SystemTrayMenu, SystemTrayMenuItem, WindowEvent, }; -#[cfg(target_os = "windows")] -use tauri_winrt_notification::{Duration, Sound, Toast}; - -use tauri::Icon; - -#[cfg(target_os = "windows")] -use windows::{ - core::PCWSTR, - Win32::Foundation::{HANDLE, HWND}, - Win32::Graphics::Gdi::{ - CreateBitmap, CreateDIBSection, DeleteObject, BITMAPINFO, BITMAPINFOHEADER, DIB_RGB_COLORS, - HDC, HGDIOBJ, - }, - Win32::System::Com::{ - CoCreateInstance, CoInitializeEx, CLSCTX_INPROC_SERVER, COINIT_APARTMENTTHREADED, - }, - Win32::UI::Shell::{ITaskbarList3, TaskbarList}, - Win32::UI::WindowsAndMessaging::{CreateIconIndirect, DestroyIcon, HICON, ICONINFO}, -}; +mod icons; +mod platform; -// --- Debug logging (dev builds only) --------------------------------------- -// In debug builds this appends to <temp>/zlack-debug.log so the unread-badge -// pipeline can be inspected without a DevTools console; in release it is a no-op. -fn debug_log(_msg: &str) { - #[cfg(debug_assertions)] - { - use std::io::Write; - let path = std::env::temp_dir().join("zlack-debug.log"); - if let Ok(mut f) = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(path) - { - let _ = writeln!(f, "{}", _msg); - } - } +pub(crate) fn exe_sibling(name: &str) -> Option<PathBuf> { + std::env::current_exe() + .ok() + .and_then(|exe| exe.parent().map(|dir| dir.join(name))) } -// --- Tray icon badges ------------------------------------------------------- -// We composite a small coloured dot onto the base tray icon to mirror Slack's -// behaviour: a red badge for unread DMs / @mentions and a blue badge for other -// unread messages. The three variants are built once and reused. +#[tauri::command] +fn load_user_css() -> Option<String> { + exe_sibling("zlack.css").and_then(|path| std::fs::read_to_string(path).ok()) +} -fn draw_badge(img: &mut image::RgbaImage, color: [u8; 3]) { - let (w, h) = (img.width() as i32, img.height() as i32); - let r = (w as f32 * 0.32).round() as i32; // badge radius (~1/3 of the icon) - let cx = w - r - 1; // bottom-right corner - let cy = h - r - 1; - for y in (cy - r - 2)..(cy + r + 2) { - for x in (cx - r - 2)..(cx + r + 2) { - if x < 0 || y < 0 || x >= w || y >= h { - continue; - } - let dx = (x - cx) as f32; - let dy = (y - cy) as f32; - let dist = (dx * dx + dy * dy).sqrt(); - if dist <= r as f32 { - img.put_pixel( - x as u32, - y as u32, - image::Rgba([color[0], color[1], color[2], 255]), - ); - } else if dist <= r as f32 + 1.5 { - // White ring so the badge stays visible against any background. - img.put_pixel(x as u32, y as u32, image::Rgba([255, 255, 255, 255])); - } +fn window_state_path(app: &tauri::AppHandle) -> Option<PathBuf> { + app.path_resolver() + .app_config_dir() + .map(|dir| dir.join("window-state")) +} + +fn load_window_maximized(app: &tauri::AppHandle) -> bool { + window_state_path(app) + .and_then(|path| std::fs::read_to_string(path).ok()) + .map(|state| state.trim() == "maximized=true") + .unwrap_or(false) +} + +fn save_window_maximized(app: &tauri::AppHandle, maximized: bool) { + if let Some(path) = window_state_path(app) { + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); } + let value = if maximized { + "maximized=true\n" + } else { + "maximized=false\n" + }; + let _ = std::fs::write(path, value); } } -fn make_icon(badge: Option<[u8; 3]>) -> Icon { - let base = include_bytes!("../icons/32x32.png"); - let mut img = image::load_from_memory(base) - .expect("Zlack: failed to decode tray icon") - .to_rgba8(); - if let Some(color) = badge { - draw_badge(&mut img, color); +fn save_window_maximized_from_window(app: &tauri::AppHandle, window: &tauri::Window) { + if let Ok(maximized) = window.is_maximized() { + save_window_maximized(app, maximized); } - let (w, h) = (img.width(), img.height()); - Icon::Rgba { - rgba: img.into_raw(), - width: w, - height: h, +} + +#[derive(Clone, Default)] +struct BadgeInfo { + state: String, + title: String, +} + +#[derive(Clone, Default)] +struct WorkspaceMeta { + name: String, + icon_image: String, + icon_text: String, + icon_color: String, +} + +#[derive(Serialize)] +struct WorkspaceButtonInfo { + team: String, + active: bool, + badge: String, + name: Option<String>, + icon_image: Option<String>, + icon_text: Option<String>, + icon_color: Option<String>, +} + +#[derive(Serialize)] +struct WorkspaceStatus { + active: Option<String>, + workspaces: Vec<WorkspaceButtonInfo>, +} + +const MAX_LOADED_WORKSPACES: usize = 2; + +#[derive(Default)] +struct WorkspaceState { + active_label: String, + active_title: String, + label_by_team: HashMap<String, String>, + team_by_label: HashMap<String, String>, + badges: HashMap<String, BadgeInfo>, + meta_by_team: HashMap<String, WorkspaceMeta>, + loaded_labels: VecDeque<String>, + closing_labels: HashSet<String>, +} + +fn user_agent() -> &'static str { + if cfg!(target_os = "macos") { + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36" + } else { + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36" } } -lazy_static::lazy_static! { - static ref ICON_NORMAL: Icon = make_icon(None); - static ref ICON_BLUE: Icon = make_icon(Some([41, 120, 240])); // general unread - static ref ICON_RED: Icon = make_icon(Some([224, 30, 90])); // DM / mention -} - -// --- Windows taskbar overlay icon ------------------------------------------ -// The tray badge above covers the hidden-to-tray case. When the window is -// visible on the taskbar we also overlay a small coloured badge on the taskbar -// button via ITaskbarList3 (red = DM/mention, blue = other unread), stamped -// with the unread DM/mention count when one is available. -#[cfg(target_os = "windows")] -const DIGITS_3X5: [[u8; 5]; 10] = [ - [0b111, 0b101, 0b101, 0b101, 0b111], // 0 - [0b010, 0b110, 0b010, 0b010, 0b111], // 1 - [0b111, 0b001, 0b111, 0b100, 0b111], // 2 - [0b111, 0b001, 0b111, 0b001, 0b111], // 3 - [0b101, 0b101, 0b111, 0b001, 0b001], // 4 - [0b111, 0b100, 0b111, 0b001, 0b111], // 5 - [0b111, 0b100, 0b111, 0b101, 0b111], // 6 - [0b111, 0b001, 0b010, 0b010, 0b010], // 7 - [0b111, 0b101, 0b111, 0b101, 0b111], // 8 - [0b111, 0b101, 0b111, 0b001, 0b111], // 9 -]; - -#[cfg(target_os = "windows")] -fn draw_overlay_digits(img: &mut image::RgbaImage, text: &str, size: i32) { - let chars: Vec<char> = text +fn workspace_label(team: &str) -> String { + let safe: String = team .chars() - .filter(|c| c.is_ascii_digit() || *c == '+') - .collect(); - if chars.is_empty() { - return; - } - let scale: i32 = if chars.len() <= 1 { 5 } else { 3 }; - let glyph_w = 3 * scale; - let spacing = scale; - let total_w = chars.len() as i32 * glyph_w + (chars.len() as i32 - 1) * spacing; - let total_h = 5 * scale; - let mut x0 = (size - total_w) / 2; - let y0 = (size - total_h) / 2; - for ch in chars { - let rows: [u8; 5] = match ch { - '+' => [0b000, 0b010, 0b111, 0b010, 0b000], - d => DIGITS_3X5[(d as u8 - b'0') as usize], - }; - for (ry, bits) in rows.iter().enumerate() { - for col in 0..3i32 { - if (bits >> (2 - col)) & 1 == 1 { - for sy in 0..scale { - for sx in 0..scale { - let px = x0 + col * scale + sx; - let py = y0 + ry as i32 * scale + sy; - if px >= 0 && py >= 0 && px < size && py < size { - img.put_pixel( - px as u32, - py as u32, - image::Rgba([255, 255, 255, 255]), - ); - } - } - } - } + .map(|c| { + if c.is_alphanumeric() || c == '-' || c == '_' { + c + } else { + '_' } - } - x0 += glyph_w + spacing; + }) + .collect(); + format!("workspace-{}", safe) +} + +fn allow_remote_ipc_for_label(app: &tauri::AppHandle, label: &str) { + for domain in ["app.slack.com", "slack.com"] { + app.ipc_scope().configure_remote_access( + RemoteDomainAccessScope::new(domain) + .add_window(label) + .enable_tauri_api(), + ); } } -#[cfg(target_os = "windows")] -fn make_overlay_rgba(color: [u8; 3], count: Option<u32>) -> (Vec<u8>, u32, u32) { - let size: i32 = 32; - let mut img = image::RgbaImage::from_pixel(size as u32, size as u32, image::Rgba([0, 0, 0, 0])); - let c = (size as f32) / 2.0 - 0.5; - let r = (size as f32) / 2.0 - 1.0; - for y in 0..size { - for x in 0..size { - let dx = x as f32 - c; - let dy = y as f32 - c; - let dist = (dx * dx + dy * dy).sqrt(); - if dist <= r - 2.0 { - img.put_pixel( - x as u32, - y as u32, - image::Rgba([color[0], color[1], color[2], 255]), - ); - } else if dist <= r { - // White ring so the badge reads on both light and dark taskbars. - img.put_pixel(x as u32, y as u32, image::Rgba([255, 255, 255, 255])); +fn active_window(app: &tauri::AppHandle) -> Option<tauri::Window> { + let label = app + .try_state::<Mutex<WorkspaceState>>() + .map(|state| { + let state = state.lock().unwrap(); + if state.active_label.is_empty() { + "main".to_string() + } else { + state.active_label.clone() } + }) + .unwrap_or_else(|| "main".to_string()); + app.get_window(&label).or_else(|| app.get_window("main")) +} + +fn touch_loaded_label(state: &mut WorkspaceState, label: &str) { + state.loaded_labels.retain(|existing| existing != label); + state.loaded_labels.push_back(label.to_string()); +} + +fn set_label_team(state: &mut WorkspaceState, label: &str, team: &str) { + if let Some(previous) = state + .team_by_label + .insert(label.to_string(), team.to_string()) + { + if previous != team + && state + .label_by_team + .get(&previous) + .is_some_and(|old| old == label) + { + state.label_by_team.remove(&previous); } } - if let Some(n) = count { - if n >= 1 { - let text = if n <= 99 { - n.to_string() - } else { - "+".to_string() - }; - draw_overlay_digits(&mut img, &text, size); - } + state + .label_by_team + .insert(team.to_string(), label.to_string()); +} + +fn forget_workspace_label(state: &mut WorkspaceState, label: &str) { + state.loaded_labels.retain(|existing| existing != label); + state.closing_labels.remove(label); + if let Some(team) = state.team_by_label.remove(label) { + state.label_by_team.remove(&team); + state.badges.remove(&team); } - (img.into_raw(), size as u32, size as u32) -} - -#[cfg(target_os = "windows")] -fn rgba_to_hicon(rgba: &[u8], w: u32, h: u32) -> Option<HICON> { - unsafe { - // 32bpp top-down DIB section so the per-pixel alpha is preserved (a plain - // CreateBitmap DDB can drop alpha and render the badge as an opaque block). - let mut bmi: BITMAPINFO = std::mem::zeroed(); - bmi.bmiHeader.biSize = std::mem::size_of::<BITMAPINFOHEADER>() as u32; - bmi.bmiHeader.biWidth = w as i32; - bmi.bmiHeader.biHeight = -(h as i32); // top-down - bmi.bmiHeader.biPlanes = 1; - bmi.bmiHeader.biBitCount = 32; - // biCompression stays 0 (BI_RGB) from the zeroed struct. - let mut bits: *mut core::ffi::c_void = std::ptr::null_mut(); - let hbm_color = match CreateDIBSection( - HDC::default(), - &bmi, - DIB_RGB_COLORS, - &mut bits, - HANDLE::default(), - 0, - ) { - Ok(b) if !bits.is_null() && b.0 != 0 => b, - other => { - debug_log(&format!( - "[overlay] CreateDIBSection failed: {:?}", - other.err() - )); - return None; - } - }; - // Fill the DIB with BGRA pixels. - let dst = std::slice::from_raw_parts_mut(bits as *mut u8, rgba.len()); - let mut i = 0; - while i + 3 < rgba.len() { - dst[i] = rgba[i + 2]; - dst[i + 1] = rgba[i + 1]; - dst[i + 2] = rgba[i]; - dst[i + 3] = rgba[i + 3]; - i += 4; - } - // Monochrome AND mask, all zero (alpha channel drives transparency). - let mask_len = ((((w + 15) & !15) / 8) * h) as usize; - let mask = vec![0u8; mask_len]; - let hbm_mask = CreateBitmap(w as i32, h as i32, 1, 1, Some(mask.as_ptr() as *const _)); - let info = ICONINFO { - fIcon: true.into(), - xHotspot: 0, - yHotspot: 0, - hbmMask: hbm_mask, - hbmColor: hbm_color, + state.badges.remove(label); +} + +fn evict_loaded_workspaces(app: &tauri::AppHandle) { + let (labels, aggregate, title) = { + let state = app.state::<Mutex<WorkspaceState>>(); + let mut state = state.lock().unwrap(); + let active_label = if state.active_label.is_empty() { + "main".to_string() + } else { + state.active_label.clone() }; - let hicon = CreateIconIndirect(&info); - let _ = DeleteObject(HGDIOBJ(hbm_color.0)); - let _ = DeleteObject(HGDIOBJ(hbm_mask.0)); - match hicon { - Ok(h) if h.0 != 0 => Some(h), - other => { - debug_log(&format!( - "[overlay] CreateIconIndirect failed: {:?}", - other.err() - )); - None + state + .loaded_labels + .retain(|label| app.get_window(label).is_some()); + + let mut labels = Vec::new(); + while state.loaded_labels.len() > MAX_LOADED_WORKSPACES { + let Some(pos) = state + .loaded_labels + .iter() + .position(|label| label != &active_label) + else { + break; + }; + let Some(label) = state.loaded_labels.remove(pos) else { + break; + }; + state.closing_labels.insert(label.clone()); + if let Some(team) = state.team_by_label.remove(&label) { + state.label_by_team.remove(&team); + state.badges.remove(&team); } + state.badges.remove(&label); + labels.push(label); } + let (aggregate, title) = aggregate_badge(&state); + (labels, aggregate, title) + }; + + for label in &labels { + if let Some(window) = app.get_window(label) { + let _ = window.close(); + } + } + if !labels.is_empty() { + apply_global_badge(app, &aggregate, &title); } } -#[cfg(target_os = "windows")] -fn set_taskbar_overlay(window: &tauri::Window, color: Option<[u8; 3]>, count: Option<u32>) { - let raw = match window.hwnd() { - Ok(h) => h.0, - Err(e) => { - debug_log(&format!("[overlay] hwnd() failed: {:?}", e)); - return; - } - }; - debug_log(&format!( - "[overlay] hwnd={} color={:?} count={:?}", - raw, color, count - )); - let hwnd = HWND(raw); - unsafe { - let _ = CoInitializeEx(None, COINIT_APARTMENTTHREADED); - let taskbar: ITaskbarList3 = - match CoCreateInstance(&TaskbarList, None, CLSCTX_INPROC_SERVER) { - Ok(t) => t, - Err(e) => { - debug_log(&format!("[overlay] CoCreateInstance failed: {:?}", e)); - return; - } - }; - if let Err(e) = taskbar.HrInit() { - debug_log(&format!("[overlay] HrInit failed: {:?}", e)); - return; - } - match color { - Some(c) => { - let (rgba, w, h) = make_overlay_rgba(c, count); - match rgba_to_hicon(&rgba, w, h) { - Some(hicon) => { - let r = taskbar.SetOverlayIcon(hwnd, hicon, PCWSTR::null()); - debug_log(&format!("[overlay] SetOverlayIcon(icon) -> {:?}", r)); - let _ = DestroyIcon(hicon); - } - None => debug_log("[overlay] rgba_to_hicon returned None"), - } - } - None => { - let r = taskbar.SetOverlayIcon(hwnd, HICON::default(), PCWSTR::null()); - debug_log(&format!("[overlay] SetOverlayIcon(clear) -> {:?}", r)); - } +fn loaded_workspace_count(app: &tauri::AppHandle) -> usize { + let state = app.state::<Mutex<WorkspaceState>>(); + let mut state = state.lock().unwrap(); + state + .loaded_labels + .retain(|label| app.get_window(label).is_some()); + state.loaded_labels.len() +} + +fn aggregate_badge(state: &WorkspaceState) -> (String, String) { + let mut aggregate = "none"; + for badge in state.badges.values() { + match (aggregate, badge.state.as_str()) { + ("mention", _) => {} + (_, "mention") => aggregate = "mention", + ("none", "unread") => aggregate = "unread", + _ => {} } } + let title = if state.active_title.is_empty() { + "Zlack".to_string() + } else { + state.active_title.clone() + }; + (aggregate.to_string(), title) } -// Bridge from the Slack web app (see preload.js): reflects unread state onto the -// OS window title and the system-tray icon. -// state: "mention" (red) | "unread" (blue) | "none" -// title: Slack's tab title with its own unread markers already stripped -#[tauri::command] -fn update_badge(app_handle: tauri::AppHandle, state: String, title: String, count: Option<u32>) { - debug_log(&format!( - "[update_badge] state={} count={:?} title={:?}", - state, count, title - )); - if let Some(window) = app_handle.get_window("main") { - let new_title = if state == "mention" { - format!("! {}", title) - } else { - title.clone() - }; - let _ = window.set_title(&new_title); +fn apply_global_badge(app_handle: &tauri::AppHandle, state: &str, title: &str) { + let title = if state == "mention" { + format!("! {}", title) + } else { + title.to_string() + }; + + if let Some(window) = active_window(app_handle) { + let _ = window.set_title(&title); } let tray = app_handle.tray_handle(); - match state.as_str() { + match state { "mention" => { #[cfg(target_os = "macos")] let _ = tray.set_icon_as_template(false); - let _ = tray.set_icon(ICON_RED.clone()); + let _ = tray.set_icon(icons::ICON_RED.clone()); } "unread" => { #[cfg(target_os = "macos")] let _ = tray.set_icon_as_template(false); - let _ = tray.set_icon(ICON_BLUE.clone()); + let _ = tray.set_icon(icons::ICON_BLUE.clone()); } _ => { #[cfg(target_os = "macos")] let _ = tray.set_icon_as_template(true); - let _ = tray.set_icon(ICON_NORMAL.clone()); + let _ = tray.set_icon(icons::ICON_NORMAL.clone()); } } #[cfg(target_os = "windows")] { let app = app_handle.clone(); - let state_for_overlay = state.clone(); - let count_for_overlay = count; + let state_for_overlay = state.to_string(); let _ = app_handle.run_on_main_thread(move || { - if let Some(window) = app.get_window("main") { + if let Some(window) = active_window(&app) { let color = match state_for_overlay.as_str() { - "mention" => Some([224u8, 30, 90]), - "unread" => Some([41u8, 120, 240]), + "mention" => Some(icons::BADGE_RED), + "unread" => Some(icons::BADGE_BLUE), _ => None, }; - let overlay_count = if state_for_overlay == "mention" { - count_for_overlay - } else { - None - }; - set_taskbar_overlay(&window, color, overlay_count); + // Keep the overlay as a simple colored dot. The count was hard to + // read at taskbar size. To re-enable it, capture `count` before this + // closure and keep the digits small (for example, use 4/2 instead of + // 5/3 in draw_overlay_digits): + // let overlay_count = if state_for_overlay == "mention" { + // count_for_overlay + // } else { + // None + // }; + let overlay_count = None; + icons::set_taskbar_overlay(&window, color, overlay_count); } }); } } -#[tauri::command] -fn notify( - app_handle: tauri::AppHandle, - title: String, - body: String, - team_id: Option<String>, - channel_id: Option<String>, -) { - // Construct navigation URL if possible - let mut target_url = None; - if let (Some(tid), Some(cid)) = (&team_id, &channel_id) { - if tid != "unknown" && cid != "unknown" { - target_url = Some(format!("https://app.slack.com/client/{}/{}", tid, cid)); - } else if tid != "unknown" { - target_url = Some(format!("https://app.slack.com/client/{}", tid)); +fn create_workspace_window( + app: &tauri::AppHandle, + team: &str, + label: &str, + visible: bool, + url: Option<String>, +) -> tauri::Result<tauri::Window> { + allow_remote_ipc_for_label(app, label); + let target_url = url.unwrap_or_else(|| { + if team.contains('.') { + format!("https://{}/client", team) + } else { + format!("https://app.slack.com/client/{}", team) + } + }); + if let Some(domain) = target_url + .split("://") + .nth(1) + .and_then(|rest| rest.split('/').next()) + { + if domain.ends_with("slack.com") { + app.ipc_scope().configure_remote_access( + RemoteDomainAccessScope::new(domain) + .add_window(label) + .enable_tauri_api(), + ); } } + let window = tauri::WindowBuilder::new( + app, + label, + tauri::WindowUrl::External(target_url.parse().unwrap()), + ) + .additional_browser_args("--disable-background-timer-throttling --disable-backgrounding-occluded-windows --disable-renderer-backgrounding") + .user_agent(user_agent()) + .title("Zlack") + .inner_size(1200.0, 800.0) + .resizable(true) + .visible(visible) + .initialization_script(include_str!("../preload.js")) + .disable_file_drop_handler() + .icon(icons::ICON_WINDOW.clone())? + .build()?; + platform::set_default_download_folder(&window); + icons::apply_window_icon(&window); + let _ = window.set_skip_taskbar(!visible); + if !visible { + // WebView2 may not fully initialize/network until a window has been shown at + // least once. Warm hidden workspaces off-screen so their websocket/counts + // start flowing, then hide them again before the user sees anything. + let _ = window.set_position(Position::Physical(PhysicalPosition { + x: -32000, + y: -32000, + })); + let _ = window.show(); + let _ = window.hide(); + } + Ok(window) +} - #[cfg(target_os = "windows")] +fn ensure_workspace_window( + app: &tauri::AppHandle, + team: &str, + url: Option<String>, +) -> Option<String> { + let label = { + let state = app.state::<Mutex<WorkspaceState>>(); + let mut state = state.lock().unwrap(); + if let Some(label) = state.label_by_team.get(team) { + label.clone() + } else { + let label = workspace_label(team); + state.label_by_team.insert(team.to_string(), label.clone()); + state.team_by_label.insert(label.clone(), team.to_string()); + label + } + }; + + if app.get_window(&label).is_none() + && create_workspace_window(app, team, &label, false, url).is_err() { - let app_handle_clone = app_handle.clone(); - let identifier = app_handle.config().tauri.bundle.identifier.clone(); - let target_url_clone = target_url.clone(); + return None; + } - // EXECUTE ON MAIN THREAD: - // We create the toast on the main thread to ensure the COM apartment/listener - // stays alive for the duration of the app, rather than dying with a background thread. - let _ = app_handle.run_on_main_thread(move || { - let res = Toast::new(&identifier) - .title(&title) - .text1(&body) - .sound(Some(Sound::SMS)) - .duration(Duration::Short) - .on_activated(move |_| { - // Use robust independent clones to avoid borrow errors - let app_dispatcher = app_handle_clone.clone(); - let app_worker = app_handle_clone.clone(); - let url_to_open = target_url_clone.clone(); - - // Dispatch to Main Thread again to perform window operations - let _ = app_dispatcher.run_on_main_thread(move || { - if let Some(window) = app_worker.get_window("main") { - restore_window(&window); - - // Explicitly navigate only on click - if let Some(url) = url_to_open { - let js = format!( - r#" - if (window.location.href !== '{}') {{ - window.location.href = '{}'; - }} - "#, - url, url - ); - if let Err(e) = window.eval(&js) { - eprintln!("Zlack: Failed to navigate on click: {}", e); - } - } - } - }); - Ok(()) - }) - .show(); - - if let Err(e) = res { - eprintln!("Zlack: Failed to show toast: {}", e); + { + let state = app.state::<Mutex<WorkspaceState>>(); + let mut state = state.lock().unwrap(); + touch_loaded_label(&mut state, &label); + } + Some(label) +} + +fn show_workspace_for_switch(window: &tauri::Window) { + let _ = window.unminimize(); + let _ = window.show(); + // Re-add after showing. On Windows, adding a hidden WebView back to the + // taskbar before it is visible can leave it missing from Alt-Tab. + let _ = window.set_skip_taskbar(false); + let _ = window.set_focus(); +} + +fn sync_hidden_workspace_geometry(app: &tauri::AppHandle, source: &tauri::Window) { + let source_label = source.label().to_string(); + let labels = { + let state = app.state::<Mutex<WorkspaceState>>(); + let state = state.lock().unwrap(); + if state.active_label != source_label { + return; + } + state.team_by_label.keys().cloned().collect::<Vec<_>>() + }; + let maximized = source.is_maximized().unwrap_or(false); + let pos = source.outer_position().ok(); + let size = source.inner_size().ok(); + + for label in labels { + if label == source_label { + continue; + } + if let Some(window) = app.get_window(&label) { + if maximized { + let _ = window.maximize(); + } else { + let _ = window.unmaximize(); + if let Some(pos) = pos { + let _ = window + .set_position(Position::Physical(PhysicalPosition { x: pos.x, y: pos.y })); + } + if let Some(size) = size { + let _ = window.set_size(Size::Physical(PhysicalSize { + width: size.width, + height: size.height, + })); + } + } + } + } +} + +fn switch_to_workspace(app: &tauri::AppHandle, team: &str, url: Option<String>) { + let target_label = match ensure_workspace_window(app, team, url.clone()) { + Some(label) => label, + None => return, + }; + let current = active_window(app); + let target = match app.get_window(&target_label) { + Some(window) => window, + None => return, + }; + let was_maximized = current + .as_ref() + .and_then(|window| window.is_maximized().ok()) + .unwrap_or(false); + + if let Some(current) = current.as_ref() { + if current.label() != target_label { + if was_maximized { + let _ = target.maximize(); + } else { + let _ = target.unmaximize(); + if let Ok(pos) = current.outer_position() { + let _ = target + .set_position(Position::Physical(PhysicalPosition { x: pos.x, y: pos.y })); + } + if let Ok(size) = current.inner_size() { + let _ = target.set_size(Size::Physical(PhysicalSize { + width: size.width, + height: size.height, + })); + } } + } + } + + { + let state = app.state::<Mutex<WorkspaceState>>(); + let mut state = state.lock().unwrap(); + state.active_label = target_label.clone(); + touch_loaded_label(&mut state, &target_label); + if let Some(team) = state.team_by_label.get(&target_label) { + if let Some(badge) = state.badges.get(team) { + state.active_title = badge.title.clone(); + } + } else if let Some(badge) = state.badges.get(&target_label) { + state.active_title = badge.title.clone(); + } + } + + if let Some(url) = url { + let js_url = format!("{:?}", url); + let js = format!( + r#"(function(url) {{ + if (window.location.href === url) return; + if (window.__ZlackNavigateSlackUrl && window.__ZlackNavigateSlackUrl(url)) return; + window.location.href = url; + }})({0});"#, + js_url + ); + let _ = target.eval(&js); + } + + if current.as_ref().map(|w| w.label()) != Some(target_label.as_str()) { + show_workspace_for_switch(&target); + if let Some(current) = current { + let _ = current.hide(); + } + } else { + restore_window(&target); + } + + let (state, title) = { + let state = app.state::<Mutex<WorkspaceState>>(); + let state = state.lock().unwrap(); + aggregate_badge(&state) + }; + apply_global_badge(app, &state, &title); + sync_hidden_workspace_geometry(app, &target); + evict_loaded_workspaces(app); + emit_workspace_status(app); +} + +fn trimmed_non_empty(value: Option<String>) -> Option<String> { + value.and_then(|value| { + let trimmed = value.trim().to_string(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } + }) +} + +fn build_workspace_status(state: &WorkspaceState, app: &tauri::AppHandle) -> WorkspaceStatus { + let active_label = if state.active_label.is_empty() { + "main".to_string() + } else { + state.active_label.clone() + }; + let active = state.team_by_label.get(&active_label).cloned(); + + let mut seen = HashSet::new(); + let mut workspaces = Vec::new(); + for loaded_label in state.loaded_labels.iter() { + if state.closing_labels.contains(loaded_label) || app.get_window(loaded_label).is_none() { + continue; + } + let Some(team) = state.team_by_label.get(loaded_label).cloned() else { + continue; + }; + if !seen.insert(team.clone()) { + continue; + } + let badge = state + .badges + .get(&team) + .or_else(|| state.badges.get(loaded_label)) + .map(|badge| badge.state.clone()) + .unwrap_or_else(|| "none".to_string()); + let meta = state.meta_by_team.get(&team).cloned().unwrap_or_default(); + workspaces.push(WorkspaceButtonInfo { + active: active.as_ref() == Some(&team) || loaded_label == &active_label, + team, + badge, + name: trimmed_non_empty(Some(meta.name)), + icon_image: trimmed_non_empty(Some(meta.icon_image)), + icon_text: trimmed_non_empty(Some(meta.icon_text)), + icon_color: trimmed_non_empty(Some(meta.icon_color)), }); } - #[cfg(not(target_os = "windows"))] + WorkspaceStatus { active, workspaces } +} + +fn emit_workspace_status(app: &tauri::AppHandle) { + let status = { + let state = app.state::<Mutex<WorkspaceState>>(); + let state = state.lock().unwrap(); + build_workspace_status(&state, app) + }; + + if let (Some(window), Ok(json)) = (active_window(app), serde_json::to_string(&status)) { + let js = format!( + "window.__ZlackWorkspaceStatus && window.__ZlackWorkspaceStatus({});", + json + ); + let _ = window.eval(&js); + } +} + +#[tauri::command] +fn update_workspace_meta( + window: tauri::Window, + app_handle: tauri::AppHandle, + team: Option<String>, + name: Option<String>, + icon_image: Option<String>, + icon_text: Option<String>, + icon_color: Option<String>, +) { + let label = window.label().to_string(); { - let identifier = app_handle.config().tauri.bundle.identifier.clone(); - let _ = Notification::new(&identifier) - .title(title) - .body(body) - .show(); + let state = app_handle.state::<Mutex<WorkspaceState>>(); + let mut state = state.lock().unwrap(); + let Some(team) = + trimmed_non_empty(team).or_else(|| state.team_by_label.get(&label).cloned()) + else { + return; + }; + + set_label_team(&mut state, &label, &team); + + let meta = state.meta_by_team.entry(team).or_default(); + if let Some(value) = trimmed_non_empty(name) { + meta.name = value; + } + if let Some(value) = trimmed_non_empty(icon_image) { + meta.icon_image = value; + } + if let Some(value) = trimmed_non_empty(icon_text) { + meta.icon_text = value; + } + if let Some(value) = trimmed_non_empty(icon_color) { + meta.icon_color = value; + } } + emit_workspace_status(&app_handle); } -// Helper: robustly restore window on Windows and macOS -fn restore_window(window: &tauri::Window) { - // 1. Ensure it stays in taskbar (critical for tray apps) - if let Err(e) = window.set_skip_taskbar(false) { - eprintln!("Zlack: Failed to set_skip_taskbar: {}", e); +#[tauri::command] +fn workspace_status( + window: tauri::Window, + app_handle: tauri::AppHandle, + current: Option<String>, +) -> WorkspaceStatus { + let label = window.label().to_string(); + let state = app_handle.state::<Mutex<WorkspaceState>>(); + let mut state = state.lock().unwrap(); + + if let Some(current) = trimmed_non_empty(current) { + set_label_team(&mut state, &label, ¤t); } - // 2. Unminimize (Restore geometry) + state + .loaded_labels + .retain(|label| app_handle.get_window(label).is_some()); + + build_workspace_status(&state, &app_handle) +} + +#[tauri::command] +fn register_workspaces( + window: tauri::Window, + app_handle: tauri::AppHandle, + teams: Vec<String>, + active: Option<String>, +) { + let active = active.filter(|team| !team.is_empty()); + let label = window.label().to_string(); + if let Some(active_team) = active.as_ref() { + let state = app_handle.state::<Mutex<WorkspaceState>>(); + let mut state = state.lock().unwrap(); + set_label_team(&mut state, &label, active_team); + touch_loaded_label(&mut state, &label); + if state.active_label.is_empty() { + state.active_label = "main".to_string(); + } + } + emit_workspace_status(&app_handle); + + let app = app_handle.clone(); + std::thread::spawn(move || { + for team in teams { + if active.as_ref() == Some(&team) { + continue; + } + if loaded_workspace_count(&app) >= MAX_LOADED_WORKSPACES { + break; + } + let _ = ensure_workspace_window(&app, &team, None); + evict_loaded_workspaces(&app); + emit_workspace_status(&app); + } + }); +} + +#[tauri::command] +fn switch_workspace(app_handle: tauri::AppHandle, team: String, url: Option<String>) { + let app = app_handle.clone(); + std::thread::spawn(move || { + switch_to_workspace(&app, &team, url); + }); +} + +// Bridge from the Slack web app (see preload.js): reflects unread state onto the +// OS window title and the system-tray icon. Each workspace window reports its own +// state; Rust aggregates all workspaces red > blue > none. +// state: "mention" (red) | "unread" (blue) | "none" +// title: Slack's tab title with its own unread markers already stripped +#[tauri::command] +fn update_badge( + window: tauri::Window, + app_handle: tauri::AppHandle, + state: String, + title: String, + _count: Option<u32>, + team: Option<String>, +) { + let (aggregate, active_title) = { + let workspace_state = app_handle.state::<Mutex<WorkspaceState>>(); + let mut workspace_state = workspace_state.lock().unwrap(); + if workspace_state.active_label.is_empty() { + workspace_state.active_label = "main".to_string(); + } + + let label = window.label().to_string(); + if workspace_state.closing_labels.contains(&label) { + return; + } + touch_loaded_label(&mut workspace_state, &label); + let key = team + .clone() + .or_else(|| workspace_state.team_by_label.get(&label).cloned()) + .unwrap_or_else(|| label.clone()); + if let Some(team) = team { + set_label_team(&mut workspace_state, &label, &team); + } + + workspace_state.badges.insert( + key, + BadgeInfo { + state: state.clone(), + title: title.clone(), + }, + ); + if label == workspace_state.active_label { + workspace_state.active_title = title.clone(); + } + aggregate_badge(&workspace_state) + }; + + apply_global_badge(&app_handle, &aggregate, &active_title); + emit_workspace_status(&app_handle); +} + +// Helper: robustly restore window on Windows and macOS +fn restore_window(window: &tauri::Window) { + // 1. Unminimize (Restore geometry) if let Err(e) = window.unminimize() { eprintln!("Zlack: Failed to Unminimize: {}", e); } - // 3. Force show (Unhide) + // 2. Force show (Unhide) if let Err(e) = window.show() { eprintln!("Zlack: Failed to Show: {}", e); } + // 3. Ensure it stays in taskbar/Alt-Tab after it is visible. + if let Err(e) = window.set_skip_taskbar(false) { + eprintln!("Zlack: Failed to set_skip_taskbar: {}", e); + } + // 4. Force Focus (with hack for stubborn Windows) // Toggle Always On Top forces a z-order refresh let _ = window.set_always_on_top(true); @@ -484,6 +789,8 @@ fn restore_window(window: &tauri::Window) { } fn main() { + platform::prefer_private_webview2_runtime(); + let quit = CustomMenuItem::new("quit".to_string(), "Quit Zlack"); let show = CustomMenuItem::new("show".to_string(), "Show Zlack"); let tray_menu = SystemTrayMenu::new() @@ -491,25 +798,36 @@ fn main() { .add_native_item(SystemTrayMenuItem::Separator) .add_item(quit); - let system_tray = SystemTray::new().with_menu(tray_menu); + let system_tray = SystemTray::new() + .with_icon(icons::ICON_NORMAL.clone()) + .with_menu(tray_menu); tauri::Builder::default() + .manage(Mutex::new(WorkspaceState::default())) .plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| { - let window = app.get_window("main").unwrap(); - restore_window(&window); + if let Some(window) = active_window(app) { + restore_window(&window); + } })) .system_tray(system_tray) .on_system_tray_event(|app, event| match event { SystemTrayEvent::LeftClick { .. } => { - let window = app.get_window("main").unwrap(); - restore_window(&window); + if let Some(window) = active_window(app) { + restore_window(&window); + } } SystemTrayEvent::MenuItemClick { id, .. } => { match id.as_str() { - "quit" => { std::process::exit(0); } + "quit" => { + if let Some(window) = active_window(app) { + save_window_maximized_from_window(app, &window); + } + std::process::exit(0); + } "show" => { - let window = app.get_window("main").unwrap(); - restore_window(&window); + if let Some(window) = active_window(app) { + restore_window(&window); + } } _ => {} } @@ -518,38 +836,82 @@ fn main() { }) .on_window_event(|event| match event.event() { WindowEvent::CloseRequested { api, .. } => { - event.window().hide().unwrap(); - api.prevent_close(); + let label = event.window().label().to_string(); + let app = event.window().app_handle(); + let closing_for_eviction = event.window().try_state::<Mutex<WorkspaceState>>() + .map(|state| state.lock().unwrap().closing_labels.contains(&label)) + .unwrap_or(false); + if !closing_for_eviction { + save_window_maximized_from_window(&app, event.window()); + event.window().hide().unwrap(); + api.prevent_close(); + } + } + WindowEvent::Destroyed => { + let label = event.window().label().to_string(); + let app = event.window().app_handle(); + if let Some(state) = event.window().try_state::<Mutex<WorkspaceState>>() { + forget_workspace_label(&mut state.lock().unwrap(), &label); + } + emit_workspace_status(&app); } WindowEvent::Focused(_focused) => {} + WindowEvent::Resized(_) | WindowEvent::Moved(_) | WindowEvent::ScaleFactorChanged { .. } => { + let app = event.window().app_handle(); + let label = event.window().label().to_string(); + let is_active = app.try_state::<Mutex<WorkspaceState>>() + .map(|state| { + let state = state.lock().unwrap(); + let active = if state.active_label.is_empty() { "main" } else { &state.active_label }; + active == label + }) + .unwrap_or(label == "main"); + if is_active { + save_window_maximized_from_window(&app, event.window()); + } + sync_hidden_workspace_geometry(&app, event.window()); + } _ => {} }) .setup(|app| { + let app_handle = app.handle(); + let start_maximized = load_window_maximized(&app_handle); + allow_remote_ipc_for_label(&app_handle, "main"); let _window = tauri::WindowBuilder::new( app, "main", tauri::WindowUrl::External("https://app.slack.com/client".parse().unwrap()) ) .additional_browser_args("--disable-background-timer-throttling --disable-backgrounding-occluded-windows --disable-renderer-backgrounding") - .user_agent( - if cfg!(target_os = "macos") { - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36" - } else { - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36" - } - ) + .user_agent(user_agent()) .title("Zlack") .inner_size(1200.0, 800.0) .resizable(true) + .maximized(start_maximized) .initialization_script(include_str!("../preload.js")) .disable_file_drop_handler() + .icon(icons::ICON_WINDOW.clone())? .build()?; + platform::set_default_download_folder(&_window); + { + let state = app.state::<Mutex<WorkspaceState>>(); + touch_loaded_label(&mut state.lock().unwrap(), "main"); + } + icons::apply_window_icon(&_window); // Match the runtime-rendered 32px tray base from the start so the larger // bundled icon doesn't briefly flash before preload sends the first state. - let _ = app.tray_handle().set_icon(ICON_NORMAL.clone()); + let _ = app.tray_handle().set_icon(icons::ICON_NORMAL.clone()); Ok(()) }) - .invoke_handler(tauri::generate_handler![notify, update_badge]) + .invoke_handler(tauri::generate_handler![ + platform::notify, + load_user_css, + update_badge, + update_workspace_meta, + workspace_status, + register_workspaces, + switch_workspace + ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/src-tauri/src/platform.rs b/src-tauri/src/platform.rs new file mode 100644 index 0000000..e9044d9 --- /dev/null +++ b/src-tauri/src/platform.rs @@ -0,0 +1,156 @@ +#[cfg(target_os = "windows")] +use std::path::PathBuf; + +#[cfg(not(target_os = "windows"))] +use tauri::api::notification::Notification; + +#[cfg(target_os = "windows")] +use tauri_winrt_notification::{Duration, Sound, Toast}; + +#[cfg(target_os = "windows")] +const WINDOWS_DEFAULT_DOWNLOAD_FOLDER_NAME: &str = "Desktop"; + +#[cfg(target_os = "windows")] +pub(crate) fn prefer_private_webview2_runtime() { + if std::env::var_os("WEBVIEW2_BROWSER_EXECUTABLE_FOLDER").is_some() { + return; + } + if let Some(runtime) = crate::exe_sibling("webview2-runtime") { + if runtime.join("msedgewebview2.exe").is_file() { + std::env::set_var("WEBVIEW2_BROWSER_EXECUTABLE_FOLDER", runtime); + } + } +} + +#[cfg(not(target_os = "windows"))] +pub(crate) fn prefer_private_webview2_runtime() {} + +#[cfg(target_os = "windows")] +fn windows_desktop_dir() -> Option<PathBuf> { + use windows::Win32::{ + Foundation::HANDLE, + System::Com::CoTaskMemFree, + UI::Shell::{FOLDERID_Desktop, SHGetKnownFolderPath, KF_FLAG_DEFAULT}, + }; + + unsafe { + let path = SHGetKnownFolderPath(&FOLDERID_Desktop, KF_FLAG_DEFAULT, HANDLE(0)).ok()?; + let desktop = path.to_string().ok().map(PathBuf::from); + CoTaskMemFree(Some(path.as_ptr() as _)); + desktop + } +} + +#[cfg(target_os = "windows")] +fn windows_default_download_dir() -> PathBuf { + windows_desktop_dir() + .or_else(|| { + std::env::var_os("USERPROFILE") + .map(PathBuf::from) + .map(|home| home.join(WINDOWS_DEFAULT_DOWNLOAD_FOLDER_NAME)) + }) + .unwrap_or_else(|| PathBuf::from(WINDOWS_DEFAULT_DOWNLOAD_FOLDER_NAME)) +} + +#[cfg(target_os = "windows")] +pub(crate) fn set_default_download_folder(window: &tauri::Window) { + use std::os::windows::ffi::OsStrExt; + use webview2_com::Microsoft::Web::WebView2::Win32::ICoreWebView2_13; + use windows_webview2::core::{Interface, PCWSTR}; + + let download_dir = windows_default_download_dir(); + let _ = std::fs::create_dir_all(&download_dir); + let _ = window.with_webview(move |webview| { + let download_dir: Vec<u16> = download_dir + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + + unsafe { + let _ = webview + .controller() + .CoreWebView2() + .and_then(|webview| webview.cast::<ICoreWebView2_13>()) + .and_then(|webview| webview.Profile()) + .and_then(|profile| { + profile.SetDefaultDownloadFolderPath(PCWSTR::from_raw(download_dir.as_ptr())) + }); + } + }); +} + +#[cfg(not(target_os = "windows"))] +pub(crate) fn set_default_download_folder(_window: &tauri::Window) {} + +#[cfg(target_os = "windows")] +fn notification_target_url( + team_id: &Option<String>, + channel_id: &Option<String>, +) -> Option<String> { + if let (Some(tid), Some(cid)) = (team_id, channel_id) { + if tid != "unknown" && cid != "unknown" { + return Some(format!("https://app.slack.com/client/{}/{}", tid, cid)); + } else if tid != "unknown" { + return Some(format!("https://app.slack.com/client/{}", tid)); + } + } + None +} + +#[tauri::command] +pub(crate) fn notify( + app_handle: tauri::AppHandle, + title: String, + body: String, + team_id: Option<String>, + channel_id: Option<String>, +) { + #[cfg(target_os = "windows")] + { + let app_handle_clone = app_handle.clone(); + let identifier = app_handle.config().tauri.bundle.identifier.clone(); + let target_url = notification_target_url(&team_id, &channel_id); + + // Create the toast on the main thread so the COM apartment/listener stays + // alive for the duration of the app, rather than dying with a background thread. + let _ = app_handle.run_on_main_thread(move || { + let res = Toast::new(&identifier) + .title(&title) + .text1(&body) + .sound(Some(Sound::SMS)) + .duration(Duration::Short) + .on_activated(move |_| { + let app_dispatcher = app_handle_clone.clone(); + let app_worker = app_handle_clone.clone(); + let url_to_open = target_url.clone(); + let team_to_open = team_id.clone(); + + let _ = app_dispatcher.run_on_main_thread(move || { + if let Some(team) = team_to_open.filter(|team| team != "unknown") { + crate::switch_to_workspace(&app_worker, &team, url_to_open); + } else if let Some(window) = crate::active_window(&app_worker) { + crate::restore_window(&window); + } + }); + Ok(()) + }) + .show(); + + if let Err(e) = res { + eprintln!("Zlack: Failed to show toast: {}", e); + } + }); + } + + #[cfg(not(target_os = "windows"))] + { + let _ = (&team_id, &channel_id); + let identifier = app_handle.config().tauri.bundle.identifier.clone(); + + let _ = Notification::new(&identifier) + .title(title) + .body(body) + .show(); + } +} diff --git a/src-tauri/tauri.fixed.conf.json b/src-tauri/tauri.fixed.conf.json deleted file mode 100644 index 184793c..0000000 --- a/src-tauri/tauri.fixed.conf.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "tauri": { - "bundle": { - "windows": { - "webviewInstallMode": { - "type": "fixedRuntime", - "path": "webview2-runtime/" - } - } - } - } -}